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
WarehousedanWarehouseBinadalah lokasi organisasi.InventoryItemadalah SKU/unit dengan status, minStock, barcode, dan valuation method.WarehouseStockadalah aggregate quantity unik per warehouse dan item.InventoryBatchmenyimpan expiration dan remaining quantity.InventoryMovementadalah ledger event; perlakukan sebagai immutable.InventoryTransfermengoordinasikan 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
āāā InventoryMovementInventory 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:
fefoIni konfigurasi yang sangat masuk akal.
WarehouseStock menjadi current-state projection
Ledger tidak dijadikan query utama untuk setiap halaman dashboard.
Karena itu:
InventoryMovement
ā
ā posts changes
ā¼
WarehouseStockWarehouseStock menyimpan:
onHandQuantity
reservedQuantity
averageUnitCostsedangkan:
availableQuantityselalu:
onHandQuantity - reservedQuantitydan tidak disimpan dua kali.
Ini menghindari keadaan buruk seperti:
onHand = 100
reserved = 20
available = 70Ledger 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 = 40Tidak boleh membuat InventoryMovement -60.
Karena barang secara fisik masih ada.
Maka:
Reservation
ā
Stock IssueBarang 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: 25Satu InventoryBatch tidak bisa dengan bersih menggambarkan ketiganya.
Karena itu:
InventoryLot
ā
āāā LotBalance Jakarta
āāā LotBalance Bandung
āāā LotBalance SurabayaJadi:
InventoryLot
= identity / batch / expiration / cost
InventoryLotBalance
= quantity at physical locationLedger dapat menjawab COGS
Misalnya issue:
-100 pcsdari:
Lot A:
60 Ć 10,000
Lot B:
40 Ć 12,000InventoryMovement:
quantityDelta = -100kemudian:
InventoryMovementAllocation
Lot A
60 Ć 10,000 = 600,000
Lot B
40 Ć 12,000 = 480,000Maka:
totalCost = 1,080,000Ini memungkinkan:
FIFO
LIFO
FEFO allocation
COGS traceability
lot traceability
returns costingtanpa menghancurkan histori.
Movement benar-benar immutable
Secara filosofis:
movement = factbukan state.
Kalau salah:
Movement #100
-50jangan:
UPDATE #100 ā -40melainkan:
Movement #100
-50
Movement #105
+10
reversalOfId = #100Dengan 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 CidempotencyKey juga penting supaya request seperti:
POST /inventory/receiveyang 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 BIni memungkinkan:
Jakarta
100 pcs
ā
ā transfer
ā¼
In Transit
ā
ā¼
Bandungdan 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
VarianceMaka:
InventoryStocktake
ā
āāā StocktakeLine
ā
āāā finalize
ā
ā¼
stocktake movementsContoh:
Expected = 100
Counted = 97
Variance = -3Ia menghasilkan ledger event:
stocktake_out
quantityDelta = -3Serial number optional, bukan mandatory
Dengan:
trackingModesatu InventoryItem bisa:
none
batch
serial
batch_and_serialSehingga:
Oil
ā none / batch
Laptop
ā serial
Engine
ā batch_and_serialIni jauh lebih fleksibel daripada memaksa seluruh inventory mengikuti satu pola.
Warehouse Assignment adalah assignment
Sehingga tahu siapa yang sebenarnya ditugaskan.
memberId
positionId
role
assignedAt
revokedAtIni memiliki makna operasional.
Archive ā lifecycle
Catalog memisahkan status bisnis dan archive state, sehingga Inventory juga demikian.
Misalnya:
InventoryItem
status = discontinued
archivedAt = nullartinya 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.ObjectIdtetap 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.