Shipment Pattern (Wzorzec wysyłki)
Based on "Enterprise Patterns and MDA" by Jim Arlow & Ila Neustadt
Problem
Order śledzą co zostało zamówione. Shipment śledzą jak towar dociera do Customer:
- Shipment częściowe — Duże Order podzielone na wiele dostaw
- Wielu Carrier — DHL, UPS, InPost, Poczta Polska
- Integracja śledzenia — Zewnętrzne numery przesyłek, aktualizacje statusu
- Shipment Event — Odebrane, w tranzycie, w doręczeniu, doręczone
- Potwierdzenie doręczenia — Podpisy, zdjęcia, znaczniki czasu
Koncepcja
Shipment reprezentuje fizyczny ruch towarów od nadawcy do odbiorcy. Ma własny cykl życia, niezależny od Order.
Kluczowa obserwacja: Jedno Order może mieć wiele Shipment (częściowa realizacja), a jedna Shipment może konsolidować wiele Order.
Struktura
Cykl życia Shipment
Przykłady
Realizacja Order
order = findOrder("ORD-2026-000042")
warehouseAddress = findAddress("WH-01")
// Create shipment from order
shipment = Shipment.fromOrder(order, "SHP-2026-000018", warehouseAddress)
// Add items from order lines
shipment.addItemFromOrderLine(order.lines[0], 1) // ThinkPad x1
shipment.addItemFromOrderLine(order.lines[1], 2) // Mouse x2
// Warehouse flow
shipment.startPicking()
shipment.markPacked()
// Assign carrier
dhl = findCarrier("DHL")
shipment.assignCarrier(dhl, "1234567890")
shipment.markLabelPrinted()
shipment.handOverToCarrier()
// emits ShipmentShipped(shipment)
// shipment.shippedAt = now()
shipment.getTrackingUrl()
// "https://www.dhl.com/track?num=1234567890"Carrier doręcza paczkę
// Carrier tracking updates (via webhook)
shipment.markInTransit("Warszawa Hub")
shipment.markOutForDelivery()
shipment.markDelivered({ signature: "J. Kowalski", photo: "proof_123.jpg" })
// emits ShipmentDelivered(shipment)
shipment.getTransitDays() // 2Znaczenie statusów
| Status | Faza | Opis |
|---|---|---|
| Created | Wewnętrzne | Rekord Shipment utworzony |
| Picking | Wewnętrzne | Personel Inventory kompletuje pozycje |
| Packed | Wewnętrzne | Pozycje zapakowane do paczek |
| LabelPrinted | Wewnętrzne | Etykieta Shipment wygenerowana |
| HandedOver | Wewnętrzne | Przekazano Carrier |
| InTransit | Zewnętrzne | Carrier posiada przesyłkę, w drodze |
| OutForDelivery | Zewnętrzne | Na pojeździe dostawczym |
| Delivered | Zewnętrzne | Doręczono pomyślnie |
| DeliveryFailed | Zewnętrzne | Próba doręczenia nieudana |
| Returned | Zewnętrzne | Zwrócono do nadawcy |
| Cancelled | Dowolna | Shipment anulowana |
Atrybuty
Shipment
| Atrybut | Typ | Wymagane | Opis |
|---|---|---|---|
| id | UUID | Tak | Unikalny identyfikator |
| shipmentNumber | String | Tak | Czytelna referencja |
| order | Order | Nie | Powiązane Order (null dla skonsolidowanych) |
| carrier | Carrier | Nie | Carrier |
| trackingNumber | String | Nie | Numer przesyłki u Carrier |
| status | ShipmentStatus | Tak | Bieżący status |
| shipFromAddress | Address | Tak | Adres nadania (Inventory) |
| shipToAddress | Address | Tak | Adres docelowy (Customer) |
| weightGrams | Integer | Nie | Waga całkowita |
| dimensions | Dimensions | Nie | D × S × W w cm |
| shippingCost | Money | Nie | Koszt Shipment |
| estimatedDelivery | Date | Nie | Przewidywana data doręczenia |
| deliveryInstructions | String | Nie | Specjalne instrukcje |
| proofOfDelivery | JSON | Nie | Podpis, zdjęcie itp. |
| createdAt | Timestamp | Tak | Kiedy utworzono |
| shippedAt | Timestamp | Nie | Kiedy przekazano Carrier |
| deliveredAt | Timestamp | Nie | Kiedy doręczono |
ShipmentItem
| Atrybut | Typ | Wymagane | Opis |
|---|---|---|---|
| id | UUID | Tak | Unikalny identyfikator |
| shipment | Shipment | Tak | Nadrzędna Shipment |
| orderLine | OrderLine | Nie | Powiązana Order Line |
| product | Product | Tak | Co jest wysyłane |
| quantity | Integer | Tak | Ile sztuk |
| serialNumber | String | Nie | Dla pozycji serializowanych |
| batchNumber | String | Nie | Dla pozycji śledzonych partiami |
ShipmentEvent
| Atrybut | Typ | Wymagane | Opis |
|---|---|---|---|
| id | UUID | Tak | Unikalny identyfikator |
| shipment | Shipment | Tak | Nadrzędna Shipment |
| status | ShipmentStatus | Tak | Status w momencie zdarzenia |
| description | String | Tak | Opis zdarzenia |
| location | String | Nie | Gdzie to nastąpiło |
| carrierEventCode | String | Nie | Kod zdarzenia Carrier |
| occurredAt | Timestamp | Tak | Kiedy zdarzenie nastąpiło |
Carrier
| Atrybut | Typ | Wymagane | Opis |
|---|---|---|---|
| id | UUID | Tak | Unikalny identyfikator |
| code | String | Tak | Krótki kod (DHL, UPS) |
| name | String | Tak | Pełna nazwa |
| trackingUrlTemplate | String | Nie | URL z placeholderem {tracking} |
| apiConfig | JSON | Nie | Dane uwierzytelniające/ustawienia API |
| isActive | Boolean | Tak | Czy Carrier jest dostępny? |
Zachowania
Entity Carrier:
function getTrackingUrl(trackingNumber: String): String or None
if trackingUrlTemplate is None:
return None
return trackingUrlTemplate.replace("{tracking}", trackingNumber)
// "https://www.dhl.com/track?num=1234567890"
Entity Shipment:
// === Creation ===
static function fromOrder(order: Order, shipmentNumber: String, fromAddress: Address): Shipment
shipment = new Shipment(
shipmentNumber: shipmentNumber,
order: order,
shipFromAddress: fromAddress,
shipToAddress: order.shippingAddress,
status: ShipmentStatus.Created
)
shipment.addEvent(ShipmentStatus.Created, "Shipment created")
return shipment
function addItem(product: Product, quantity: Integer): ShipmentItem
item = new ShipmentItem(
shipment: this,
product: product,
quantity: quantity
)
items.add(item)
return item
function addItemFromOrderLine(orderLine: OrderLine, quantity: Integer): ShipmentItem
item = addItem(orderLine.product, quantity)
item.orderLine = orderLine
return item
// === Status transitions ===
function startPicking():
require status == ShipmentStatus.Created
updateStatus(ShipmentStatus.Picking, "Picking started")
function markPacked():
require status == ShipmentStatus.Picking
updateStatus(ShipmentStatus.Packed, "Packed")
function assignCarrier(carrier: Carrier, trackingNumber: String):
this.carrier = carrier
this.trackingNumber = trackingNumber
function markLabelPrinted():
require status == ShipmentStatus.Packed
require carrier is not None
updateStatus(ShipmentStatus.LabelPrinted, "Label printed")
function handOverToCarrier():
require status == ShipmentStatus.LabelPrinted
shippedAt = now()
updateStatus(ShipmentStatus.HandedOver, "Handed to " + carrier.name)
emit ShipmentShipped(this)
function markInTransit(location: String):
require status in [ShipmentStatus.HandedOver, ShipmentStatus.InTransit]
updateStatus(ShipmentStatus.InTransit, "In transit", location)
function markOutForDelivery():
require status == ShipmentStatus.InTransit
updateStatus(ShipmentStatus.OutForDelivery, "Out for delivery")
function markDelivered(proof: JSON):
require status == ShipmentStatus.OutForDelivery
deliveredAt = now()
proofOfDelivery = proof
updateStatus(ShipmentStatus.Delivered, "Delivered")
emit ShipmentDelivered(this)
function markDeliveryFailed(reason: String):
require status == ShipmentStatus.OutForDelivery
updateStatus(ShipmentStatus.DeliveryFailed, "Failed: " + reason)
function markReturned(reason: String):
require status == ShipmentStatus.DeliveryFailed
updateStatus(ShipmentStatus.Returned, "Returned: " + reason)
emit ShipmentReturned(this)
function cancel(reason: String):
require not status.isFinal()
updateStatus(ShipmentStatus.Cancelled, "Cancelled: " + reason)
emit ShipmentCancelled(this)
// === Helpers ===
private function updateStatus(newStatus: ShipmentStatus, description: String, location: String = None):
status = newStatus
addEvent(newStatus, description, location)
function addEvent(status: ShipmentStatus, description: String, location: String = None): ShipmentEvent
event = new ShipmentEvent(
shipment: this,
status: status,
description: description,
location: location,
occurredAt: now()
)
events.add(event)
return event
function getTrackingUrl(): String or None
if carrier is None or trackingNumber is None:
return None
return carrier.getTrackingUrl(trackingNumber)
function getTransitDays(): Integer or None
if shippedAt is None or deliveredAt is None:
return None
return daysBetween(shippedAt, deliveredAt)
function isFinal(): Boolean
return status in [ShipmentStatus.Delivered, ShipmentStatus.Returned, ShipmentStatus.Cancelled]
Entity ShipmentItem:
function setSerialNumber(sn: String):
serialNumber = sn
function setBatchNumber(batch: String):
batchNumber = batchIntegracja z webhookami Carrier
// Receiving tracking updates from carrier
function handleCarrierWebhook(carrierCode: String, payload: JSON): Response
// Find shipment by tracking number
shipment = findShipmentByTracking(payload.trackingNumber)
if shipment is None:
return Response(404, "Shipment not found")
// Map carrier status to our status
ourStatus = mapCarrierStatus(carrierCode, payload.status)
// Create tracking event
event = shipment.addEvent(
status: ourStatus,
description: payload.description,
location: payload.location
)
event.carrierEventCode = payload.carrierCode
// Update shipment status
match ourStatus:
case ShipmentStatus.InTransit:
shipment.markInTransit(payload.location)
case ShipmentStatus.OutForDelivery:
shipment.markOutForDelivery()
case ShipmentStatus.Delivered:
proof = { signature: payload.signature, photo: payload.photoUrl }
shipment.markDelivered(proof)
case ShipmentStatus.DeliveryFailed:
shipment.markDeliveryFailed(payload.reason)
save(shipment)
return Response(200, "OK")
function mapCarrierStatus(carrierCode: String, carrierStatus: String): ShipmentStatus
// Each carrier has different status codes
match carrierCode:
case "DHL":
match carrierStatus:
case "PU": return ShipmentStatus.HandedOver // Picked up
case "IT": return ShipmentStatus.InTransit
case "WC": return ShipmentStatus.OutForDelivery
case "DL": return ShipmentStatus.Delivered
// ... etc
case "UPS":
// ... different mappingsNiezmienniki
- Unikalny numer Shipment — Dwie Shipment nie mogą mieć tego samego numeru
- Co najmniej jedna pozycja — Nie można wysłać pustej Shipment
- Carrier wymagany przy przekazaniu — Carrier musi być przypisany przed oznaczeniem jako HandedOver
- Prawidłowe przejścia stanów — Tylko dozwolone przejścia zgodnie z maszyną stanów
- Pozycje z tego samego Order — Jeśli Shipment powiązana z Order, pozycje powinny odpowiadać Order Line
- Waga odpowiada pozycjom — Jeśli podana, waga powinna być rozsądna dla danych pozycji
Obsługa błędów
| Operacja | Naruszony warunek wstępny | Błąd |
|---|---|---|
startPicking() |
Status inny niż Created | PreconditionError: can only start picking from Created status |
markPacked() |
Status inny niż Picking | PreconditionError: must be in Picking status to pack |
markLabelPrinted() |
Brak przypisanego Carrier | PreconditionError: carrier must be assigned before printing label |
handOverToCarrier() |
Status inny niż LabelPrinted | PreconditionError: label must be printed before handover |
markDelivered(proof) |
Status inny niż OutForDelivery | PreconditionError: must be out for delivery to mark delivered |
cancel(reason) |
Już w statusie końcowym | PreconditionError: cannot cancel delivered Shipment |
markInTransit(loc) |
Nie HandedOver/InTransit | PreconditionError: Shipment not yet handed to Carrier |
Odporność webhooków Carrier: Jeśli webhook dotrze ze zmianą statusu niezgodną z bieżącym stanem (np. "Delivered" gdy wciąż "HandedOver"), zaloguj zdarzenie, ale pomiń aktualizację statusu. Zdarzenia od Carrier mogą docierać w nieodpowiedniej kolejności.
Schemat SQL
sql-- Carriers
CREATE TABLE carrier (
id UUID PRIMARY KEY,
code VARCHAR(20) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
tracking_url_template VARCHAR(255),
api_config JSONB,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Shipments
CREATE TABLE shipment (
id UUID PRIMARY KEY,
shipment_number VARCHAR(50) NOT NULL UNIQUE,
order_id UUID REFERENCES orders(id),
carrier_id UUID REFERENCES carrier(id),
tracking_number VARCHAR(100),
status VARCHAR(20) NOT NULL DEFAULT 'Created',
-- Addresses (JSON)
ship_from_address JSONB NOT NULL,
ship_to_address JSONB NOT NULL,
-- Physical
weight_grams INTEGER,
length_cm DECIMAL(10,2),
width_cm DECIMAL(10,2),
height_cm DECIMAL(10,2),
-- Cost
shipping_cost_cents INTEGER,
shipping_cost_currency CHAR(3),
-- Delivery
estimated_delivery DATE,
delivery_instructions TEXT,
proof_of_delivery JSONB,
-- Timestamps
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
shipped_at TIMESTAMP,
delivered_at TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_shipment_status CHECK (
status IN ('Created', 'Picking', 'Packed', 'LabelPrinted', 'HandedOver',
'InTransit', 'OutForDelivery', 'Delivered', 'DeliveryFailed',
'Returned', 'Cancelled')
)
);
-- Shipment items
CREATE TABLE shipment_item (
id UUID PRIMARY KEY,
shipment_id UUID NOT NULL REFERENCES shipment(id) ON DELETE CASCADE,
order_line_id UUID REFERENCES order_line(id),
product_id UUID NOT NULL REFERENCES product(id),
quantity INTEGER NOT NULL,
serial_number VARCHAR(100),
batch_number VARCHAR(50),
CONSTRAINT chk_positive_quantity CHECK (quantity > 0)
);
-- Shipment events (tracking history)
CREATE TABLE shipment_event (
id UUID PRIMARY KEY,
shipment_id UUID NOT NULL REFERENCES shipment(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL,
description VARCHAR(255) NOT NULL,
location VARCHAR(100),
carrier_event_code VARCHAR(50),
occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Indexes
CREATE INDEX idx_shipment_order ON shipment(order_id);
CREATE INDEX idx_shipment_carrier ON shipment(carrier_id);
CREATE INDEX idx_shipment_tracking ON shipment(tracking_number) WHERE tracking_number IS NOT NULL;
CREATE INDEX idx_shipment_status ON shipment(status);
CREATE INDEX idx_shipment_item_shipment ON shipment_item(shipment_id);
CREATE INDEX idx_shipment_event_shipment ON shipment_event(shipment_id);Typowe zapytania
sql-- Get shipment with items and events
SELECT
s.*,
c.name as carrier_name,
json_agg(DISTINCT jsonb_build_object(
'product_id', si.product_id,
'quantity', si.quantity,
'serial_number', si.serial_number
)) as items,
json_agg(DISTINCT jsonb_build_object(
'status', se.status,
'description', se.description,
'location', se.location,
'occurred_at', se.occurred_at
) ORDER BY se.occurred_at DESC) as events
FROM shipment s
LEFT JOIN carrier c ON c.id = s.carrier_id
LEFT JOIN shipment_item si ON si.shipment_id = s.id
LEFT JOIN shipment_event se ON se.shipment_id = s.id
WHERE s.id = :shipmentId
GROUP BY s.id, c.name;
-- Find shipment by tracking number
SELECT s.*, c.name as carrier_name
FROM shipment s
LEFT JOIN carrier c ON c.id = s.carrier_id
WHERE s.tracking_number = :trackingNumber;
-- Shipments pending pickup (ready but not shipped)
SELECT * FROM shipment
WHERE status IN ('Packed', 'LabelPrinted')
AND created_at < CURRENT_TIMESTAMP - INTERVAL '4 hours';
-- Delivery performance by carrier
SELECT
c.name,
COUNT(*) as total_shipments,
AVG(EXTRACT(EPOCH FROM (delivered_at - shipped_at)) / 86400) as avg_transit_days,
COUNT(*) FILTER (WHERE status = 'Delivered') as delivered,
COUNT(*) FILTER (WHERE status = 'DeliveryFailed') as failed
FROM shipment s
JOIN carrier c ON c.id = s.carrier_id
WHERE s.shipped_at >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY c.id;Zagadnienia projektowe
Shipment vs Order
| Aspekt | Order | Shipment |
|---|---|---|
| Cel | Czego chce Customer | Jak to do niego dostarczyć |
| Cykl życia | Przepływ zakupowy | Przepływ realizacji/dostawy |
| Relacja | 1 Order → N Shipment | Podział/częściowa realizacja |
| Kluczowe pola | Product, ceny | Carrier, śledzenie, zdarzenia |
Event sourcing do śledzenia
Każda zmiana statusu tworzy Shipment Event:
- Pełna historia zachowana
- Możliwość odtworzenia osi czasu
- Zdarzenia Carrier zapisane z oryginalnymi kodami
- Umożliwia analizę dostaw
Shipment częściowe
Gdy Order nie może być w pełni wysłane za jednym razem:
- Utwórz wiele Shipment z tego samego Order
- Każda Shipment Item odwołuje się do swojej Order Line
- Śledź status realizacji per linia
Shipment zwrotne (RMA)
W przypadku zwrotów rozważ:
- Osobną encję ReturnShipment
- Lub użycie Shipment z flagą
isReturn - Powiązanie z rekordem RMA (Return Merchandise Authorization)