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
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
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 == DraftAnulowanie Order
order.cancel("Customer changed their mind")
// emits OrderCancelled(order, "Customer changed their mind")
// order.status == CancelledGenerowanie 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-X7K9M2P4Q1Niezmienniki
- Unikalny numer Order — Żadne dwa Order nie mogą mieć tego samego numeru
- Co najmniej jedna pozycja — Potwierdzone Order muszą mieć co najmniej jedną pozycję
- Dodatnie ilości — Ilości w pozycjach muszą być > 0
- Nieujemne ceny — Ceny i sumy nie mogą być ujemne
- Spójna waluta — Wszystkie wartości pieniężne w tej samej walucie
- Pozycje zablokowane po potwierdzeniu — Nie można modyfikować pozycji po potwierdzeniu
- 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.
Related Patterns
- Party Role (Customer) — Kto składa Order
- Product — Co jest zamawiane
- Payment — Jak Order są opłacane
- Shipment — Jak Order są dostarczane
- Inventory — Zapasy rezerwowane/wydawane dla Order