Inventory Pattern (Wzorzec magazynu)
Based on "Enterprise Patterns and MDA" by Jim Arlow & Ila Neustadt
Problem
Proste pole stockQuantity w Product zawodzi, gdy potrzebujesz:
- Wielu Location — Ten sam Product w magazynie A i magazynie B
- Zarezerwowane vs dostępne — 100 na stanie, ale 30 zarezerwowanych dla oczekujących Order
- Ślad audytowy — Dlaczego stan się zmienił? Kto go zmienił?
- Ruchy magazynowe — Transfery między lokalizacjami
- Różne typy śledzenia — Niektóre pozycje serializowane, inne ilościowo
Koncepcja
Inventory jest modelowany jako system księgi. Każda zmiana jest rejestrowana jako transakcja. Saldo jest sumą wszystkich transakcji.
Kluczowe encje:
- Location — Gdzie przechowywany jest towar (hierarchicznie: Warehouse (budynek) → Strefa → Alejka → Pojemnik)
- Inventory Item — Stan Product w danej Location
- Inventory Transaction — Zapis zmiany stanu (niezmienny)
Formuła: Dostępne = Na stanie - Zarezerwowane
Struktura
Szybka ścieżka
| Scenariusz | Operacja | OnHand | Reserved | Available |
|---|---|---|---|---|
| Stan początkowy | — | 100 | 0 | 100 |
| Złożono Order (5 szt.) | reserve(5) |
100 | +5 = 5 | 95 |
| Order wysłane | shipReserved(5) |
-5 = 95 | -5 = 0 | 95 |
| Order anulowane | release(5) |
100 | -5 = 0 | 100 |
| Błąd inwentaryzacji o 3 | adjust(-3) |
-3 = 97 | 0 | 97 |
| Przyjęto towar | receive(50) |
+50=147 | 0 | 147 |
Formuła: Available = OnHand - Reserved
Przykłady
Przyjęcie towaru i realizacja Order
warehouse = findLocation("WH-01-A-001") // Bin in Warehouse 1
thinkpad = findProduct("LEN-X1C-G11-16-512")
// Get or create inventory item for this product at this location
item = findOrCreate InventoryItem(product: thinkpad, location: warehouse)
// Receive shipment from supplier
item.receive(20, "PO-2026-0015", "Supplier delivery")
// item.quantityOnHand = 20, available = 20
// emits StockReceived(item, 20, "PO-2026-0015")
// Customer order placed — reserve stock
item.reserve(1, "ORD-2026-000042", "Order reserved")
// item.quantityOnHand = 20, reserved = 1, available = 19
// emits StockReserved(item, 1, "ORD-2026-000042")
// Order shipped — issue from reserved
item.shipReserved(1, "ORD-2026-000042")
// item.quantityOnHand = 19, reserved = 0, available = 19
// emits StockIssued(item, 1, "ORD-2026-000042")Korekta inwentaryzacyjna
// Physical count finds only 18 units (1 missing)
item.setCount(18, "Annual stock count - 1 unit unaccounted")
// Creates adjustment of -1
// item.quantityOnHand = 18
// emits StockAdjusted(item, -1, "STOCK_COUNT")Transfer między Location
source = findInventoryItem(thinkpad, "WH-01-A-001")
destination = findInventoryItem(thinkpad, "WH-02-B-003")
InventoryService.transfer(source, destination, 5, "XFER-2026-001")
// source.quantityOnHand -= 5
// destination.quantityOnHand += 5
// emits StockTransferred(source, destination, 5, "XFER-2026-001")Atrybuty
Location
| Atrybut | Typ | Wymagane | Opis |
|---|---|---|---|
| id | UUID | Tak | Unikalny identyfikator |
| code | String | Tak | Unikalny kod lokalizacji |
| name | String | Tak | Nazwa wyświetlana |
| type | LocationType | Tak | Warehouse (budynek), Strefa, Alejka, Pojemnik itp. |
| parent | Location | Nie | Location nadrzędna (null = korzeń) |
| isStockable | Boolean | Tak | Czy może przechowywać zapasy? |
| isActive | Boolean | Tak | Czy Location jest aktywna? |
Typy lokalizacji
| Typ | Opis | Przykład |
|---|---|---|
| Warehouse | Obiekt najwyższego poziomu | "Warehouse 1" |
| Zone | Obszar w Warehouse | "Zone A" |
| Aisle | Rząd regałów | "Aisle 01" |
| Rack | Jednostka regałowa | "Rack 02" |
| Shelf | Poziom na regale | "Shelf 03" |
| Bin | Pojedyncze miejsce składowania | "Bin 001" |
| Virtual | Niefizyczne (w tranzycie itp.) | "In-Transit" |
InventoryItem
| Atrybut | Typ | Wymagane | Opis |
|---|---|---|---|
| id | UUID | Tak | Unikalny identyfikator |
| product | Product | Tak | Jaki Product |
| location | Location | Tak | Gdzie jest przechowywany |
| quantityOnHand | Integer | Tak | Fizyczna ilość obecna |
| quantityReserved | Integer | Tak | Zarezerwowane dla Order |
| reorderLevel | Integer | Nie | Próg wyzwalający alert uzupełnienia |
| maxLevel | Integer | Nie | Maksymalny poziom zapasów |
Ograniczenie unikatowości: Jeden InventoryItem na parę (product, location).
InventoryTransaction
| Atrybut | Typ | Wymagane | Opis |
|---|---|---|---|
| id | UUID | Tak | Unikalny identyfikator |
| inventoryItem | InventoryItem | Tak | Którego rekordu stanu dotyczy |
| type | TransactionType | Tak | Jaki rodzaj ruchu |
| quantity | Integer | Tak | Ilość (+ lub -) |
| balanceAfter | Integer | Tak | OnHand po tej transakcji |
| reference | String | Nie | Referencja zewnętrzna (PO, nr Order) |
| notes | String | Nie | Dodatkowe szczegóły |
| relatedTx | Transaction | Nie | Powiązana transakcja (dla transferów) |
| performedBy | String | Nie | Kto dokonał zmiany |
| createdAt | Timestamp | Tak | Kiedy transakcja wystąpiła |
Zachowania
Entity Location:
function isLeaf(): Boolean
return not hasChildren()
function getPath(): String
// Returns "Warehouse 1 / Zone A / Aisle 01 / Bin 001"
if parent is None:
return name
return parent.getPath() + " / " + name
function getRoot(): Location
if parent is None:
return this
return parent.getRoot()
function canStoreInventory(): Boolean
return isStockable and isActive
Entity InventoryItem:
function getQuantityAvailable(): Integer
return quantityOnHand - quantityReserved
function needsReorder(): Boolean
return reorderLevel is not None and quantityOnHand <= reorderLevel
// === Stock operations ===
function receive(quantity: Integer, reference: String, notes: String): Transaction
require quantity > 0
quantityOnHand = quantityOnHand + quantity
tx = createTransaction(
type: TransactionType.Receipt,
quantity: +quantity,
reference: reference,
notes: notes
)
emit StockReceived(this, quantity, reference)
return tx
function issue(quantity: Integer, reference: String, notes: String): Transaction
require quantity > 0
require quantity <= quantityOnHand
quantityOnHand = quantityOnHand - quantity
tx = createTransaction(
type: TransactionType.Issue,
quantity: -quantity,
reference: reference,
notes: notes
)
emit StockIssued(this, quantity, reference)
return tx
function reserve(quantity: Integer, reference: String, notes: String): Transaction
require quantity > 0
require quantity <= getQuantityAvailable()
quantityReserved = quantityReserved + quantity
tx = createTransaction(
type: TransactionType.Reserve,
quantity: +quantity, // Positive = reservation increased
reference: reference,
notes: notes
)
emit StockReserved(this, quantity, reference)
return tx
function release(quantity: Integer, reference: String, notes: String): Transaction
require quantity > 0
require quantity <= quantityReserved
quantityReserved = quantityReserved - quantity
tx = createTransaction(
type: TransactionType.Release,
quantity: -quantity, // Negative = reservation decreased
reference: reference,
notes: notes
)
emit StockReleased(this, quantity, reference)
return tx
function shipReserved(quantity: Integer, reference: String): Transaction
// Ship from reserved stock (order fulfillment)
require quantity > 0
require quantity <= quantityReserved
quantityOnHand = quantityOnHand - quantity
quantityReserved = quantityReserved - quantity
return createTransaction(
type: TransactionType.Issue,
quantity: -quantity,
reference: reference,
notes: "Shipped from reserved stock"
)
function adjust(quantity: Integer, reference: String, notes: String): Transaction
// Positive or negative adjustment
newOnHand = quantityOnHand + quantity
require newOnHand >= 0
quantityOnHand = newOnHand
tx = createTransaction(
type: TransactionType.Adjustment,
quantity: quantity,
reference: reference,
notes: notes
)
emit StockAdjusted(this, quantity, reference)
return tx
function setCount(actualCount: Integer, notes: String): Transaction
// Physical count correction
difference = actualCount - quantityOnHand
if difference == 0:
return None // No change needed
return adjust(difference, "STOCK_COUNT", notes)
// === Transfer operations ===
function transferOut(quantity: Integer, destinationCode: String, reference: String): Transaction
require quantity > 0
require quantity <= getQuantityAvailable()
quantityOnHand = quantityOnHand - quantity
return createTransaction(
type: TransactionType.TransferOut,
quantity: -quantity,
reference: reference,
notes: "Transfer to " + destinationCode
)
function transferIn(quantity: Integer, sourceCode: String, reference: String): Transaction
require quantity > 0
quantityOnHand = quantityOnHand + quantity
return createTransaction(
type: TransactionType.TransferIn,
quantity: +quantity,
reference: reference,
notes: "Transfer from " + sourceCode
)
// === Helper ===
private function createTransaction(
type: TransactionType,
quantity: Integer,
reference: String,
notes: String
): Transaction
tx = new InventoryTransaction(
inventoryItem: this,
type: type,
quantity: quantity,
balanceAfter: quantityOnHand,
reference: reference,
notes: notes,
createdAt: now()
)
transactions.add(tx)
return tx
// Service for multi-item operations
Service InventoryService:
function transfer(
source: InventoryItem,
destination: InventoryItem,
quantity: Integer,
reference: String
): Transaction[]
require source.product == destination.product
txOut = source.transferOut(quantity, destination.location.code, reference)
txIn = destination.transferIn(quantity, source.location.code, reference)
// Link the transactions
txOut.relatedTransaction = txIn
txIn.relatedTransaction = txOut
emit StockTransferred(source, destination, quantity, reference)
return [txOut, txIn]Niezmienniki
- Nieujemne ilości — onHand i reserved nie mogą być ujemne
- Reserved ≤ OnHand — Nie można zarezerwować więcej niż jest fizycznie obecne
- Unikalność produkt-lokalizacja — Jeden InventoryItem na parę (product, location)
- Niezmienność transakcji — Nigdy nie edytuj transakcji, twórz jedynie korekty
- Uzgadnialność salda — Suma transakcji powinna być równa bieżącemu saldu
- Tylko Location składowalne — Inventory Item tylko w Location oznaczonych jako stockable
Obsługa błędów
| Operacja | Naruszony warunek wstępny | Błąd |
|---|---|---|
receive(qty, ref, notes) |
quantity <= 0 |
PreconditionError: receive quantity must be positive |
issue(qty, ref, notes) |
quantity > quantityOnHand |
PreconditionError: cannot issue 10, only 5 on hand |
reserve(qty, ref, notes) |
quantity > available |
PreconditionError: cannot reserve 10, only 5 available |
release(qty, ref, notes) |
quantity > quantityReserved |
PreconditionError: cannot release 10, only 3 reserved |
shipReserved(qty, ref) |
quantity > quantityReserved |
PreconditionError: cannot ship 10, only 3 reserved |
adjust(qty, ref, notes) |
Spowodowałoby ujemny onHand | PreconditionError: adjustment would result in negative stock |
transferOut(qty, dest, ref) |
quantity > available |
PreconditionError: insufficient available stock for transfer |
transfer(src, dest, qty, ref) |
Różne produkty | PreconditionError: source and destination must be same product |
Odzyskiwanie: Gdy rezerwacja nie powiedzie się z powodu niewystarczającego stanu, wywołujący powinien:
- Zarezerwować częściową ilość i zamówić resztę jako zaległe
- Sprawdzić inne Location pod kątem dostępnych zapasów
- Odrzucić Order Line z wyraźnym komunikatem o braku towaru
Schemat SQL
sql-- Location hierarchy
CREATE TABLE location (
id UUID PRIMARY KEY,
code VARCHAR(30) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
location_type VARCHAR(20) NOT NULL,
parent_id UUID REFERENCES location(id),
is_stockable BOOLEAN NOT NULL DEFAULT TRUE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_location_type CHECK (
location_type IN ('Warehouse', 'Zone', 'Aisle', 'Rack', 'Shelf', 'Bin', 'Virtual')
)
);
-- Inventory items (stock at location)
CREATE TABLE inventory_item (
id UUID PRIMARY KEY,
product_id UUID NOT NULL REFERENCES product(id),
location_id UUID NOT NULL REFERENCES location(id),
quantity_on_hand INTEGER NOT NULL DEFAULT 0,
quantity_reserved INTEGER NOT NULL DEFAULT 0,
reorder_level INTEGER,
max_level INTEGER,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(product_id, location_id),
CONSTRAINT chk_non_negative_on_hand CHECK (quantity_on_hand >= 0),
CONSTRAINT chk_non_negative_reserved CHECK (quantity_reserved >= 0),
CONSTRAINT chk_reserved_lte_on_hand CHECK (quantity_reserved <= quantity_on_hand)
);
-- Inventory transactions (ledger)
CREATE TABLE inventory_transaction (
id UUID PRIMARY KEY,
inventory_item_id UUID NOT NULL REFERENCES inventory_item(id),
transaction_type VARCHAR(20) NOT NULL,
quantity INTEGER NOT NULL, -- Can be + or -
balance_after INTEGER NOT NULL,
reference VARCHAR(50),
notes TEXT,
related_tx_id UUID REFERENCES inventory_transaction(id),
performed_by VARCHAR(100),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_transaction_type CHECK (
transaction_type IN ('Receipt', 'Issue', 'Reserve', 'Release',
'TransferOut', 'TransferIn', 'Adjustment', 'Return')
)
);
-- Indexes
CREATE INDEX idx_location_parent ON location(parent_id);
CREATE INDEX idx_inventory_product ON inventory_item(product_id);
CREATE INDEX idx_inventory_location ON inventory_item(location_id);
CREATE INDEX idx_transaction_item ON inventory_transaction(inventory_item_id);
CREATE INDEX idx_transaction_reference ON inventory_transaction(reference)
WHERE reference IS NOT NULL;
CREATE INDEX idx_transaction_date ON inventory_transaction(created_at);Typowe zapytania
sql-- Total stock across all locations
SELECT
p.sku,
p.name,
SUM(ii.quantity_on_hand) as total_on_hand,
SUM(ii.quantity_reserved) as total_reserved,
SUM(ii.quantity_on_hand - ii.quantity_reserved) as total_available
FROM product p
LEFT JOIN inventory_item ii ON ii.product_id = p.id
WHERE p.id = :productId
GROUP BY p.id;
-- Stock by location for a product
SELECT
l.code as location_code,
l.name as location_name,
ii.quantity_on_hand,
ii.quantity_reserved,
ii.quantity_on_hand - ii.quantity_reserved as available
FROM inventory_item ii
JOIN location l ON l.id = ii.location_id
WHERE ii.product_id = :productId
ORDER BY l.code;
-- Items below reorder level
SELECT
p.sku,
p.name,
l.code as location,
ii.quantity_on_hand,
ii.reorder_level
FROM inventory_item ii
JOIN product p ON p.id = ii.product_id
JOIN location l ON l.id = ii.location_id
WHERE ii.reorder_level IS NOT NULL
AND ii.quantity_on_hand <= ii.reorder_level;
-- Transaction history for audit
SELECT
it.*,
p.sku,
l.code as location
FROM inventory_transaction it
JOIN inventory_item ii ON ii.id = it.inventory_item_id
JOIN product p ON p.id = ii.product_id
JOIN location l ON l.id = ii.location_id
WHERE it.reference = :orderNumber
ORDER BY it.created_at;
-- Verify balance matches transactions (integrity check)
SELECT
ii.id,
ii.quantity_on_hand as current_balance,
SUM(CASE WHEN it.transaction_type IN ('Receipt', 'TransferIn', 'Return')
THEN it.quantity ELSE 0 END) +
SUM(CASE WHEN it.transaction_type IN ('Issue', 'TransferOut')
THEN it.quantity ELSE 0 END) +
SUM(CASE WHEN it.transaction_type = 'Adjustment'
THEN it.quantity ELSE 0 END) as calculated_balance
FROM inventory_item ii
LEFT JOIN inventory_transaction it ON it.inventory_item_id = ii.id
GROUP BY ii.id
HAVING ii.quantity_on_hand != calculated_balance; -- Should return no rowsZagadnienia projektowe
Zalety wzorca księgi
- Weryfikowalne saldo — Zawsze można przeliczyć na podstawie transakcji
- Pełny ślad audytowy — Każda zmiana jest rejestrowana
- Brak utraty danych — Historia zachowana nawet po korektach
Na stanie vs Zarezerwowane
| Ilość | Znaczenie | Kto kontroluje |
|---|---|---|
| On Hand | Fizycznie w Location | Operacje Inventory |
| Reserved | Przydzielone do oczekujących Order | Zarządzanie Order |
| Available | Wolne do przyrzeczenia (OnHand-Reserved) | Obliczane |
Niezmienność transakcji
Nigdy nie edytuj ani nie usuwaj transakcji. W przypadku korekt:
- Utwórz transakcję korygującą
- Podaj powód korekty w referencji
- Zachowaj pełny ślad audytowy
Rozszerzenia
| Rozszerzenie | Opis |
|---|---|
| Partia/Seria | Śledzenie batchNumber, expiryDate per item |
| FEFO/FIFO | Wybieraj najstarsze/najwcześniej wygasające |
| Śledzenie kosztów | Śledzenie kosztu per transakcja dla KWS |
| W tranzycie | Wirtualna lokalizacja dla towaru w drodze |
| Inwentaryzacja cykliczna | Okresowe częściowe liczenia vs pełna inwentaryzacja |