Archetypy Programowania

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

parent

0..1

0..1

0..1

1

1

1

0..*

0..*

0..*

0..*

0..*

0..*

Location

+id: UUID

+code: String

+name: String

+type: LocationType

+parent: Location

+isStockable: Boolean

+getPath() : : String

InventoryItem

+id: UUID

+product: Product

+location: Location

+quantityOnHand: Integer

+quantityReserved: Integer

+reorderLevel: Integer

+getAvailable() : : Integer

+receive() : : void

+issue() : : void

+reserve() : : void

InventoryTransaction

+id: UUID

+inventoryItem: InventoryItem

+type: TransactionType

+quantity: Integer

+reference: String

+createdAt: Timestamp

«enumeration»

TransactionType

Receipt

Issue

Reserve

Release

TransferOut

TransferIn

Adjustment

Return

Product

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

  1. Nieujemne ilości — onHand i reserved nie mogą być ujemne
  2. Reserved ≤ OnHand — Nie można zarezerwować więcej niż jest fizycznie obecne
  3. Unikalność produkt-lokalizacja — Jeden InventoryItem na parę (product, location)
  4. Niezmienność transakcji — Nigdy nie edytuj transakcji, twórz jedynie korekty
  5. Uzgadnialność salda — Suma transakcji powinna być równa bieżącemu saldu
  6. 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:

  1. Zarezerwować częściową ilość i zamówić resztę jako zaległe
  2. Sprawdzić inne Location pod kątem dostępnych zapasów
  3. 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 rows

Zagadnienia 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:

  1. Utwórz transakcję korygującą
  2. Podaj powód korekty w referencji
  3. 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
  • Product — Co jest śledzone
  • Order — Wyzwala rezerwacje
  • Shipment — Wyzwala wydania (towar opuszcza Inventory)
  • Location — Gdzie przechowywany jest towar (zdefiniowane w ramach tego wzorca)