Archetypy Programowania

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

1

1

1..*

0..*

Shipment

+id: UUID

+shipmentNumber: String

+order: Order

+carrier: Carrier

+trackingNumber: String

+status: ShipmentStatus

+items: ShipmentItem[]

+events: ShipmentEvent[]

+getTrackingUrl() : : String

ShipmentItem

+id: UUID

+shipment: Shipment

+product: Product

+quantity: Integer

+serialNumber: String

ShipmentEvent

+id: UUID

+shipment: Shipment

+status: ShipmentStatus

+description: String

+location: String

+occurredAt: Timestamp

Carrier

+id: UUID

+code: String

+name: String

+trackingUrlTemplate: String

+getTrackingUrl(tracking) : : String

Order

Product

Cykl życia Shipment

Internal (Warehouse)

External (Carrier)

startPicking()

markPacked()

printLabel()

handOver()

carrier scan

out for delivery

delivered

failed attempt

retry

returned to sender

Created

Picking

Packed

LabelPrinted

HandedOver

InTransit

OutForDelivery

Delivered

DeliveryFailed

Returned

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()  // 2

Znaczenie 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 = batch

Integracja 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 mappings

Niezmienniki

  1. Unikalny numer Shipment — Dwie Shipment nie mogą mieć tego samego numeru
  2. Co najmniej jedna pozycja — Nie można wysłać pustej Shipment
  3. Carrier wymagany przy przekazaniu — Carrier musi być przypisany przed oznaczeniem jako HandedOver
  4. Prawidłowe przejścia stanów — Tylko dozwolone przejścia zgodnie z maszyną stanów
  5. Pozycje z tego samego Order — Jeśli Shipment powiązana z Order, pozycje powinny odpowiadać Order Line
  6. 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:

  1. Utwórz wiele Shipment z tego samego Order
  2. Każda Shipment Item odwołuje się do swojej Order Line
  3. Ś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)
  • Order — Co jest wysyłane
  • Inventory — Wydanie towaru przy Shipment
  • Payment — Może być wyzwolona przy doręczeniu (za pobraniem)
  • Product — Co jest w Shipment