Archetypy Programowania

Order Archetype (Archetyp zamówienia)

Based on "Enterprise Patterns and MDA" by Jim Arlow & Ila Neustadt

Problem

Order są kluczowe w handlu, ale wiążą się ze złożonymi wymaganiami:

  • Struktura nagłówek + pozycje: Metadane Order vs poszczególne pozycje
  • Zarządzanie cyklem życia: Szkic → Potwierdzone → Opłacone → Wysłane → Zakończone
  • Obliczanie cen: Sumy pozycji, rabaty, podatki, koszty wysyłki, suma końcowa
  • Modyfikacje: Dodawanie/usuwanie pozycji, zmiana ilości, anulowanie
  • Historia: Kto co zmienił i kiedy

Koncepcja

Wzorzec Order modeluje transakcję handlową między kupującym a sprzedającym. Order zawiera:

  • Nagłówek — Ogólne informacje o Order (Customer, daty, kwoty, status)
  • Pozycje — Poszczególne zamawiane Product (Product, ilość, cena)
  • Cykl życia — Stany, przez które przechodzi Order

Order są zazwyczaj niezmienne po potwierdzeniu — zmiany tworzą nowe wersje lub rekordy korygujące.

Struktura

1

1..*

Order

+id: UUID

+orderNumber: String

+customer: Customer

+status: OrderStatus

+lines: OrderLine[]

+billingAddress: Address

+shippingAddress: Address

+calculateTotal() : : Money

+confirm() : : void

OrderLine

+id: UUID

+order: Order

+lineNumber: Integer

+product: Product

+quantity: Integer

+unitPrice: Money

+discount: Money

+getLineTotal() : : Money

«enumeration»

OrderStatus

Draft

Confirmed

Paid

Processing

Shipped

Delivered

Completed

Cancelled

Customer

Product

Atrybuty

Order

Atrybut Typ Wymagane Opis
id UUID Tak Unikalny identyfikator
orderNumber String Tak Czytelny numer Order
customer Customer Tak Kto złożył Order
status OrderStatus Tak Bieżący stan Order
orderDate Timestamp Tak Kiedy Order zostało złożone
confirmedAt Timestamp Nie Kiedy Order zostało potwierdzone
currency String Tak Kod waluty ISO (PLN, EUR)
billingAddress Address Tak Adres do faktury
shippingAddress Address Nie Adres dostawy (jeśli inny)
subtotal Money Tak Suma wartości pozycji
discountTotal Money Nie Rabaty na poziomie Order
taxTotal Money Tak Łączna kwota podatku
shippingCost Money Nie Koszt dostawy
grandTotal Money Tak Końcowa kwota do zapłaty
notes String Nie Uwagi Customer
internalNotes String Nie Notatki wewnętrzne

OrderLine

Atrybut Typ Wymagane Opis
id UUID Tak Unikalny identyfikator
order Order Tak Order nadrzędne
lineNumber Integer Tak Numer kolejny pozycji (1, 2, 3...)
product Product Tak Co jest zamawiane
productName String Tak Nazwa Product (snapshot)
productSku String Tak SKU w momencie Order
quantity Integer Tak Ile sztuk
unitPrice Money Tak Cena za jednostkę
discount Money Nie Rabat na poziomie pozycji
taxRate Decimal Tak Stawka podatku (np. 23.00)
taxAmount Money Tak Obliczony podatek
lineTotal Money Tak (unitPrice × quantity) - discount

Zachowania

Entity Order:
    
    function addLine(product: Product, quantity: Integer, unitPrice: Money): OrderLine
        require status == OrderStatus.Draft
        require quantity > 0
        
        line = new OrderLine(
            order: this,
            lineNumber: lines.length + 1,
            product: product,
            productName: product.name,  // Snapshot
            productSku: product.sku,
            quantity: quantity,
            unitPrice: unitPrice,
            taxRate: product.taxRate
        )
        
        lines.add(line)
        recalculateTotals()
        return line
    
    function removeLine(line: OrderLine):
        require status == OrderStatus.Draft
        require line in lines
        
        lines.remove(line)
        renumberLines()
        recalculateTotals()
    
    function updateQuantity(line: OrderLine, newQuantity: Integer):
        require status == OrderStatus.Draft
        require newQuantity > 0
        
        line.quantity = newQuantity
        line.recalculate()
        recalculateTotals()
    
    function recalculateTotals():
        subtotal = lines.sum(line => line.lineTotal)
        taxTotal = lines.sum(line => line.taxAmount)
        grandTotal = subtotal + taxTotal + shippingCost - discountTotal
    
    function confirm():
        require status == OrderStatus.Draft
        require lines.isNotEmpty()
        require validate().isValid
        
        status = OrderStatus.Confirmed
        confirmedAt = now()
        
        emit OrderConfirmed(this)
    
    function markPaid(payment: Payment):
        require status == OrderStatus.Confirmed
        require payment.amount >= grandTotal
        
        status = OrderStatus.Paid
        paidAt = now()
        
        emit OrderPaid(this, payment)
    
    function cancel(reason: String):
        require status in [OrderStatus.Draft, OrderStatus.Confirmed]
        
        previousStatus = status
        status = OrderStatus.Cancelled
        cancelledAt = now()
        cancellationReason = reason
        
        emit OrderCancelled(this, reason)
    
    function validate(): ValidationResult
        errors = []
        
        if lines.isEmpty():
            errors.add("Order must have at least one line")
        
        if billingAddress is None:
            errors.add("Billing address is required")
        
        for line in lines:
            if line.quantity <= 0:
                errors.add("Line " + line.lineNumber + ": quantity must be positive")
            if line.unitPrice < 0:
                errors.add("Line " + line.lineNumber + ": price cannot be negative")
        
        return ValidationResult(isValid: errors.isEmpty(), errors: errors)
    
    function canBeModified(): Boolean
        return status == OrderStatus.Draft
    
    function canBeCancelled(): Boolean
        return status in [OrderStatus.Draft, OrderStatus.Confirmed]


Entity OrderLine:
    
    function getLineTotal(): Money
        return (unitPrice * quantity) - discount
    
    function getTaxAmount(): Money
        return getLineTotal() * (taxRate / 100)
    
    function getGrossTotal(): Money
        return getLineTotal() + getTaxAmount()
    
    function recalculate():
        lineTotal = getLineTotal()
        taxAmount = getTaxAmount()

Maszyna stanów

create

confirm()

cancel()

markPaid()

cancel()

startProcessing()

ship()

markDelivered()

complete()

Draft

Confirmed

Cancelled

Paid

Processing

Shipped

Delivered

Completed

Can modify lines
Can be cancelled

Lines locked
Awaiting payment

Przykłady

Tworzenie i potwierdzanie Order

jan = findCustomer("CUST-00001234")
thinkpad = findProduct("LEN-X1C-G11-16-512")
mouse = findProduct("LOG-MX-MASTER-3S")

order = new Order(
    orderNumber: "ORD-2026-000042",
    customer: jan,
    currency: "PLN",
    billingAddress: jan.party.getPrimaryAddress()
)

// Add lines (order is in Draft status)
order.addLine(thinkpad, quantity: 1, unitPrice: Money(649900, "PLN"))
order.addLine(mouse, quantity: 2, unitPrice: Money(44900, "PLN"))

order.subtotal     // 739700 (6499.00 + 2 x 449.00)
order.grandTotal   // 909633 (with 23% VAT)

// Confirm locks the lines
order.confirm()
// emits OrderConfirmed(order)
// order.status == Confirmed
// order.addLine(...) would now fail — require status == Draft

Anulowanie Order

order.cancel("Customer changed their mind")
// emits OrderCancelled(order, "Customer changed their mind")
// order.status == Cancelled

Generowanie numeru Order

// Strategies for generating order numbers

// 1. Sequential with prefix
function generateOrderNumber(): String
    year = currentYear()
    sequence = getNextSequence("order", year)
    return "ORD-" + year + "-" + padLeft(sequence, 6, '0')
    // Example: ORD-2024-000123

// 2. Date-based
function generateOrderNumber(): String
    date = today().format("YYYYMMDD")
    sequence = getNextSequence("order", date)
    return date + "-" + padLeft(sequence, 4, '0')
    // Example: 20241215-0042

// 3. Random (for hiding volume)
function generateOrderNumber(): String
    return "ORD-" + randomAlphanumeric(10).toUpperCase()
    // Example: ORD-X7K9M2P4Q1

Niezmienniki

  1. Unikalny numer Order — Żadne dwa Order nie mogą mieć tego samego numeru
  2. Co najmniej jedna pozycja — Potwierdzone Order muszą mieć co najmniej jedną pozycję
  3. Dodatnie ilości — Ilości w pozycjach muszą być > 0
  4. Nieujemne ceny — Ceny i sumy nie mogą być ujemne
  5. Spójna waluta — Wszystkie wartości pieniężne w tej samej walucie
  6. Pozycje zablokowane po potwierdzeniu — Nie można modyfikować pozycji po potwierdzeniu
  7. Prawidłowe przejścia stanów — Tylko dozwolone przejścia między stanami

Obsługa błędów

Operacja Naruszony warunek wstępny Błąd
addLine(product, qty, price) Order nie jest w stanie Draft PreconditionError: cannot modify confirmed order
addLine(product, qty, price) quantity <= 0 PreconditionError: quantity must be positive
removeLine(line) Order nie jest w stanie Draft PreconditionError: cannot modify confirmed order
updateQuantity(line, qty) Order nie jest w stanie Draft PreconditionError: cannot modify confirmed order
confirm() Brak pozycji PreconditionError: order must have at least one line
confirm() Walidacja nie przeszła PreconditionError: billing address is required
markPaid(payment) Nie jest potwierdzone PreconditionError: order must be confirmed before payment
markPaid(payment) Payment < suma PreconditionError: payment amount insufficient
cancel(reason) Już wysłane/zakończone PreconditionError: cannot cancel order in status Shipped

Uwaga: Po potwierdzeniu wszystkie próby modyfikacji zwracają czytelny błąd kierujący wywołującego do anulowania i ponownego złożenia Order.

Schemat SQL

sqlCREATE TABLE orders (  -- "order" is reserved word
    id                  UUID PRIMARY KEY,
    order_number        VARCHAR(30) NOT NULL UNIQUE,
    customer_id         UUID NOT NULL REFERENCES party_role(id),
    status              VARCHAR(20) NOT NULL DEFAULT 'Draft',
    order_date          TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    confirmed_at        TIMESTAMP,
    currency            CHAR(3) NOT NULL DEFAULT 'PLN',
    
    -- Addresses (stored as JSON or separate table)
    billing_address     JSONB NOT NULL,
    shipping_address    JSONB,
    
    -- Totals (stored in cents)
    subtotal_cents      INTEGER NOT NULL DEFAULT 0,
    discount_total_cents INTEGER NOT NULL DEFAULT 0,
    tax_total_cents     INTEGER NOT NULL DEFAULT 0,
    shipping_cost_cents INTEGER NOT NULL DEFAULT 0,
    grand_total_cents   INTEGER NOT NULL DEFAULT 0,
    
    -- Notes
    notes               TEXT,
    internal_notes      TEXT,
    
    -- Cancellation
    cancelled_at        TIMESTAMP,
    cancellation_reason TEXT,
    
    -- Audit
    created_at          TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at          TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    
    CONSTRAINT chk_order_status CHECK (
        status IN ('Draft', 'Confirmed', 'Paid', 'Processing', 'Shipped', 'Delivered', 'Completed', 'Cancelled')
    )
);

CREATE TABLE order_line (
    id                  UUID PRIMARY KEY,
    order_id            UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
    line_number         INTEGER NOT NULL,
    product_id          UUID REFERENCES product(id),  -- Nullable if product deleted
    product_name        VARCHAR(255) NOT NULL,  -- Snapshot
    product_sku         VARCHAR(50) NOT NULL,   -- Snapshot
    quantity            INTEGER NOT NULL,
    unit_price_cents    INTEGER NOT NULL,
    discount_cents      INTEGER NOT NULL DEFAULT 0,
    tax_rate            DECIMAL(5,2) NOT NULL,
    tax_amount_cents    INTEGER NOT NULL,
    line_total_cents    INTEGER NOT NULL,
    
    CONSTRAINT chk_positive_quantity CHECK (quantity > 0),
    CONSTRAINT chk_non_negative_price CHECK (unit_price_cents >= 0),
    UNIQUE(order_id, line_number)
);

-- Order status history for audit
CREATE TABLE order_status_history (
    id              UUID PRIMARY KEY,
    order_id        UUID NOT NULL REFERENCES orders(id),
    from_status     VARCHAR(20),
    to_status       VARCHAR(20) NOT NULL,
    changed_at      TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    changed_by      UUID,  -- User who made change
    reason          TEXT
);

-- Indexes
CREATE INDEX idx_order_customer ON orders(customer_id);
CREATE INDEX idx_order_status ON orders(status);
CREATE INDEX idx_order_date ON orders(order_date);
CREATE INDEX idx_order_line_order ON order_line(order_id);
CREATE INDEX idx_order_line_product ON order_line(product_id);

Typowe zapytania

sql-- Get order with lines
SELECT 
    o.*,
    json_agg(
        json_build_object(
            'lineNumber', ol.line_number,
            'productName', ol.product_name,
            'quantity', ol.quantity,
            'unitPrice', ol.unit_price_cents / 100.0,
            'lineTotal', ol.line_total_cents / 100.0
        ) ORDER BY ol.line_number
    ) as lines
FROM orders o
LEFT JOIN order_line ol ON ol.order_id = o.id
WHERE o.id = :orderId
GROUP BY o.id;

-- Customer order history
SELECT o.order_number, o.order_date, o.status, o.grand_total_cents
FROM orders o
WHERE o.customer_id = :customerId
ORDER BY o.order_date DESC;

-- Orders pending payment
SELECT * FROM orders
WHERE status = 'Confirmed'
  AND confirmed_at < CURRENT_TIMESTAMP - INTERVAL '24 hours';

-- Sales by product
SELECT 
    ol.product_sku,
    ol.product_name,
    SUM(ol.quantity) as total_quantity,
    SUM(ol.line_total_cents) as total_revenue
FROM order_line ol
JOIN orders o ON o.id = ol.order_id
WHERE o.status NOT IN ('Draft', 'Cancelled')
  AND o.order_date BETWEEN :startDate AND :endDate
GROUP BY ol.product_sku, ol.product_name
ORDER BY total_revenue DESC;

Zagadnienia projektowe

Snapshot kontra referencja

Podejście Zalety Wady
Referencja Zawsze aktualne dane Historyczne Order pokazują błędne dane
Snapshot Dokładność historyczna Duplikacja danych

Zalecenie: Zapisuj snapshot krytycznych pól (nazwa, SKU, cena) w momencie składania Order. Zachowaj referencję do nawigacji.

Wartości pieniężne

// NEVER use floating point for money!
// Bad:  price = 19.99 (float)
// Good: priceInCents = 1999 (integer)

// Or use a Money type:
Money {
    amount: Integer  // In smallest unit (cents, grosze)
    currency: String // ISO code
}

Niezmienność po potwierdzeniu

Po potwierdzeniu Order:

  • Nie można dodawać/usuwać pozycji
  • Nie można zmieniać ilości
  • Ceny są zablokowane

W przypadku zmian po potwierdzeniu:

  • Anuluj i utwórz nowe Order
  • Lub utwórz rekord korekty/aneksu

Order vs Oferta vs Faktura

Dokument Przeznaczenie Przepływ stanów
Oferta Wycena Szkic → Wysłana → Zaakceptowana/Wygasła
Order Intencja zakupu Customer Szkic → Potwierdzone → Opłacone...
Faktura Wezwanie do zapłaty Wystawiona → Opłacona → Przeterminowana

Mogą współdzielić strukturę, ale mają różne cykle życia.