engineering docs

Inventory database

inventory memisahkan definisi item dari stok fisik. InventoryItem mengarah ke product catalog melalui productId, sedangkan quantity berada pada warehouse/batch.

Model dan ownership

  • Warehouse dan WarehouseBin adalah lokasi organisasi.
  • InventoryItem adalah SKU/unit dengan status, minStock, barcode, dan valuation method.
  • WarehouseStock adalah aggregate quantity unik per warehouse dan item.
  • InventoryBatch menyimpan expiration dan remaining quantity.
  • InventoryMovement adalah ledger event; perlakukan sebagai immutable.
  • InventoryTransfer mengoordinasikan perpindahan antar warehouse.

Aturan mutasi stok

Jangan mengubah quantity tanpa membuat movement yang mencatat previousStock, currentStock, actor, reference, dan warehouse. Gunakan atomic update atau optimistic concurrency untuk mencegah lost update. WarehouseStock memiliki unique pair [warehouseId, inventoryItemId].

Valuation method yang tersedia adalah FIFO, LIFO, FEFO, dan average cost. FEFO harus memilih batch dengan expiration terdekat yang masih memiliki quantity.

Cross-database reference

Validasi organizationId melalui runner dan productId melalui catalog sebelum membuat inventory item. Inventory tidak boleh menghapus product catalog; archive dilakukan pada owner domain.

1. Inventory tidak mencoba menjadi Catalog

Catalog sudah mempunyai Product dan ProductVariant sebagai sumber identitas. Product/Variant memang didefinisikan sebagai catalog entity, dan Product secara eksplisit tidak memiliki inventory.

Maka boundary-nya:

CATALOG
Product
   │
   └── ProductVariant
           │
           ā–¼
INVENTORY
InventoryItem
   │
   ā”œā”€ā”€ WarehouseStock
   ā”œā”€ā”€ InventoryLot
   ā”œā”€ā”€ InventorySerial
   └── InventoryMovement

Inventory hanya mengatakan:

"Saya melacak variant X sebanyak Y."

Bukan:

"Saya adalah pemilik definisi variant X."


FEFO tidak ada dalam valuation

FIFO / LIFO / Average Cost adalah cost valuation methodology.

Sedangkan:

FEFO adalah inventory allocation/picking policy.

Jadi:

enum InventoryValuationMethod {
  fifo
  lifo
  average_cost
}
 
enum InventoryPickingMethod {
  fifo
  lifo
  fefo
}

Dengan demikian sebuah bisnis dapat menggunakan:

Valuation:
average_cost
 
Picking:
fefo

Ini konfigurasi yang sangat masuk akal.


WarehouseStock menjadi current-state projection

Ledger tidak dijadikan query utama untuk setiap halaman dashboard.

Karena itu:

InventoryMovement
        │
        │ posts changes
        ā–¼
WarehouseStock

WarehouseStock menyimpan:

onHandQuantity
reservedQuantity
averageUnitCost

sedangkan:

availableQuantity

selalu:

onHandQuantity - reservedQuantity

dan tidak disimpan dua kali.

Ini menghindari keadaan buruk seperti:

onHand = 100
reserved = 20
available = 70

Ledger adalah source of truth historis; WarehouseStock adalah operational read model/current state.


Reservation terpisah dari stock movement

Ini krusial, Misalnya:

Stock = 100
 
Order A reserves 60
 
On hand = 100
Reserved = 60
Available = 40

Tidak boleh membuat InventoryMovement -60.

Karena barang secara fisik masih ada.

Maka:

Reservation
    ≠
Stock Issue

Barang baru benar-benar keluar ketika terjadi issue movement.

Ini membuat integrasi dengan Commerce jauh lebih bersih:

Commerce Order
      │
      ā–¼
InventoryReservation
      │
      ā–¼
Shipment
      │
      ā–¼
InventoryMovement(issue)

Sementara Commerce memang sudah memisahkan Order dan Shipment secara independen.


Batch tidak menjadi location state

Misalnya dalam satu batch:

Batch A
 ā”œā”€ Warehouse Jakarta: 100
 ā”œā”€ Warehouse Bandung: 50
 └─ Warehouse Surabaya: 25

Satu InventoryBatch tidak bisa dengan bersih menggambarkan ketiganya.

Karena itu:

InventoryLot
      │
      ā”œā”€ā”€ LotBalance Jakarta
      ā”œā”€ā”€ LotBalance Bandung
      └── LotBalance Surabaya

Jadi:

InventoryLot
= identity / batch / expiration / cost
 
InventoryLotBalance
= quantity at physical location

Ledger dapat menjawab COGS

Misalnya issue:

-100 pcs

dari:

Lot A:
60 Ɨ 10,000
 
Lot B:
40 Ɨ 12,000

InventoryMovement:

quantityDelta = -100

kemudian:

InventoryMovementAllocation
 
Lot A
60 Ɨ 10,000 = 600,000
 
Lot B
40 Ɨ 12,000 = 480,000

Maka:

totalCost = 1,080,000

Ini memungkinkan:

FIFO
LIFO
FEFO allocation
COGS traceability
lot traceability
returns costing

tanpa menghancurkan histori.


Movement benar-benar immutable

Secara filosofis:

movement = fact

bukan state.

Kalau salah:

Movement #100
-50

jangan:

UPDATE #100 → -40

melainkan:

Movement #100
-50
 
Movement #105
+10
reversalOfId = #100

Dengan begitu auditor bisa melihat kejadian sebenarnya.


InventoryOperation sebagai transaction boundary

Contohnya receiving:

InventoryOperation
  RECEIPT #RCV-001
 
  ā”œā”€ā”€ Movement
  │     +100 Product A
  │
  ā”œā”€ā”€ Movement
  │     +50 Product B
  │
  └── Movement
        +20 Product C

idempotencyKey juga penting supaya request seperti:

POST /inventory/receive

yang dikirim dua kali tidak menggandakan stock.


Transfer model bisnis

Bukan:

Transfer
  -> movements[]

saja.

Tetapi:

InventoryTransfer
   │
   ā”œā”€ā”€ TransferLine
   │      ā”œā”€ā”€ Product A
   │      └── Product B
   │
   └── Movement(s)

dan bila batch:

TransferLine
   │
   ā”œā”€ā”€ Lot A
   └── Lot B

Ini memungkinkan:

Jakarta
   100 pcs
      │
      │ transfer
      ā–¼
In Transit
      │
      ā–¼
Bandung

dan stock source baru berkurang pada shipping event, sementara destination bertambah ketika receiving benar-benar terjadi.


Stocktake sebagai first-class operation

Inventory hampir pasti membutuhkan:

Physical count
Cycle count
Stock opname
Variance

Maka:

InventoryStocktake
    │
    ā”œā”€ā”€ StocktakeLine
    │
    └── finalize
            │
            ā–¼
       stocktake movements

Contoh:

Expected = 100
Counted  = 97
Variance = -3

Ia menghasilkan ledger event:

stocktake_out
quantityDelta = -3

Serial number optional, bukan mandatory

Dengan:

trackingMode

satu InventoryItem bisa:

none
batch
serial
batch_and_serial

Sehingga:

Oil
→ none / batch
 
Laptop
→ serial
 
Engine
→ batch_and_serial

Ini jauh lebih fleksibel daripada memaksa seluruh inventory mengikuti satu pola.


Warehouse Assignment adalah assignment

Sehingga tahu siapa yang sebenarnya ditugaskan.

memberId
positionId
role
assignedAt
revokedAt

Ini memiliki makna operasional.


Archive ≠ lifecycle

Catalog memisahkan status bisnis dan archive state, sehingga Inventory juga demikian.

Misalnya:

InventoryItem
status = discontinued
archivedAt = null

artinya item sudah tidak dijual/digunakan untuk inventory baru, tetapi data lama masih hidup.

Kemudian setelah benar-benar diarsipkan:

archivedAt = ...

Ledger lama tetap tidak disentuh.


Boundary yang ketat

Inventory:

productId String
variantId String?

bukan:

@relation(...)

karena Product/Variant milik Catalog database.

Sementara relation lokal seperti:

warehouseId String @db.ObjectId
inventoryItemId String @db.ObjectId
lotId String @db.ObjectId

tetap Prisma relation.

Arsitektur

ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│       Catalog        │
│ Product              │
│ ProductVariant       │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
           │ scalar ID
           ā–¼
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│      Inventory       │
│ InventoryItem        │
│ Warehouse            │
│ Lot / Serial         │
│ Stock                │
│ Reservation          │
│ Movement             │
│ Transfer             │
│ Stocktake            │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
           │ scalar ID
           ā–¼
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│      Commerce        │
│ Order                │
│ OrderItem            │
│ Shipment             │
│ ShipmentItem         │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜

Ini konsisten dengan boundary Commerce. Commerce memiliki Order/Shipment, sementara referensi Catalog tetap scalar.