Kebenaran Stok: Buku Besar Pergerakan, Bukan Kolom

Foto oleh Nick Saltmarsh via Openverse (CC BY 2.0)
Karena buku besar mencatat setiap perubahan sebagai baris yang tidak dapat diubah, jumlah semua nilai qtyDelta adalah riwayat lengkap yang bisa direkonstruksi, bukan satu angka yang bisa ditimpa. Product.stockQty hanya cache yang ditulis untuk baca cepat. Jika keduanya berbeda, buku besar bersifat otoritatif dan cache dibangun ulang darinya.
Tidak dalam praktik. Indeks komposit pada branchId dan productId menjaga SUM yang dikelompokkan tetap cepat, dan cache Product.stockQty yang terdenormalisasi melayani baca daftar produk secara instan. Anda hanya mengagregasi buku besar langsung saat butuh kebenaran terjamin, seperti saat rekonsiliasi atau pengecekan drift.
Anda tidak pernah menyunting Product.stockQty atau baris pergerakan secara langsung. Anda mencatat StockMovement baru bertipe ADJUSTMENT yang qtyDelta-nya sama dengan selisih hasil hitung, merujuk dokumen stok opname. Ini menjaga buku besar tetap append-only dan menjadikan koreksi sebagai peristiwa yang dapat dilacak dalam riwayat.
Sebuah job terjadwal baca-saja menjalankan SUM yang dikelompokkan yang sama seperti perekonsiliasi dan membandingkan setiap total terhitung dengan stockQty yang di-cache. Produk mana pun yang berbeda memancarkan metrik dan peringatan yang menyebut produk dan cabangnya. Perekonsiliasi lalu dapat menimpa cache dari buku besar dalam satu perintah.
Ia tumbuh mengikuti setiap pergerakan, yang memang biaya nyata, tetapi memberi jejak audit lengkap, kekebalan terhadap balapan lost-update, dan kemampuan membangun ulang keadaan setelah bug apa pun. Untuk ERP multi-cabang di mana hitungan yang salah berarti uang nyata, pertukaran itu jelas sepadan dengan baris tambahan.

Foto oleh Nick Saltmarsh via Openverse (CC BY 2.0)
Ringkasan Utama
Di ERP cuci mobil saya, stok sebenarnya sebuah produk adalah SUM dari setiap baris StockMovement.qtyDelta, bukan angka di Product.stockQty. Buku besar bersifat append-only dan otoritatif; kolom itu hanya cache terdenormalisasi untuk baca cepat. Saat keduanya berbeda, buku besar menang dan cache dibangun ulang, sehingga tidak ada update yang bisa diam-diam merusak stok.
Suatu pagi seorang manajer cabang menelepon karena aplikasi menyebut stok tire shine kami minus tiga botol. Bukan nol, minus tiga. Angka semacam itu hanya muncul saat beberapa orang memperbarui kolom kuantitas yang sama nyaris bersamaan dan satu tulisan menimpa yang lain. Awalnya saya membangun modul inventory dengan cara paling jelas: Product.stockQty adalah integer biasa, dan setiap penjualan atau restock melakukan UPDATE yang menambah atau mengurangi nilainya. Itu jalan saat demo dan membusuk di produksi.
Perbaikannya adalah berhenti memperlakukan kuantitas sebagai nilai yang saya miliki dan mulai memperlakukannya sebagai nilai yang saya hitung. Alih-alih satu angka yang bisa diubah, saya mencatat baris yang tidak dapat diubah untuk setiap peristiwa yang mengubah stok: penjualan dua unit adalah qtyDelta minus dua, penerimaan pembelian satu karton adalah qtyDelta plus dua puluh empat. Kuantitas saat ini menjadi sekadar hasil agregasi. Ini adalah ide append-only dan event-sourced yang sama yang dipakai sistem inventory besar, dan penting memahami alasannya sebelum menirunya.
Sebuah integer tunggal yang bisa diubah tampak efisien, tetapi menghancurkan informasi di setiap tulisan. UPDATE yang mengubah stockQty dari sepuluh menjadi delapan hanya memberi nilai baru dan menghapus alasan, pelakunya, dan kondisi sebelumnya. Dalam pengaturan multi-cabang dan multi-terminal, itu pertukaran yang merugikan. Berikut yang dibebankan kolom yang bisa diubah kepada saya dalam praktik.
Setiap hal fisik yang memindahkan stok menulis tepat satu baris yang tidak dapat diubah. Tidak ada perintah UPDATE dan DELETE terhadap tabel ini di kode aplikasi. Setiap baris membawa qtyDelta bertanda, cabang pemiliknya, tipe pergerakan, dan referensi ke dokumen penyebabnya. Perhatikan qtyDelta adalah integer unit utuh, disiplin integer yang sama yang saya pakai untuk uang, di mana semua nominal IDR adalah integer dan tidak pernah float.
// prisma/schema.prisma
model StockMovement {
id String @id @default(cuid())
branchId String // every query scopes by this
productId String
qtyDelta Int // signed: -2 sale, +24 receipt
type MovementType // SALE | PURCHASE | ADJUSTMENT | TRANSFER
refType String // 'Order' | 'Purchase' | 'StockOpname'
refId String
createdAt DateTime @default(now())
product Product @relation(fields: [productId], references: [id])
branch Branch @relation(fields: [branchId], references: [id])
@@index([branchId, productId]) // fast per-product SUM
@@index([branchId, createdAt])
}
model Product {
id String @id @default(cuid())
branchId String
name String
stockQty Int @default(0) // CACHE ONLY - derived, never trusted
}Kuantitas otoritatif untuk sebuah produk adalah satu agregasi atas tabel itu, di-scope berdasarkan branchId persis seperti setiap query daftar dan detail lain di API. Scoping berdasarkan branchId juga merupakan perbaikan IDOR yang saya terapkan di mana-mana, sehingga terminal di satu cabang tidak akan pernah bisa membaca atau memindahkan stok cabang lain. Di Prisma, bacaannya adalah penjumlahan yang dikelompokkan.
// The source of truth: SUM(qtyDelta), never Product.stockQty
async function trueStock(branchId: string, productId: string) {
const agg = await prisma.stockMovement.aggregate({
where: { branchId, productId },
_sum: { qtyDelta: true },
});
return agg._sum.qtyDelta ?? 0;
}
// Recording a sale is an INSERT, never an UPDATE of a quantity
await prisma.stockMovement.create({
data: { branchId, productId, qtyDelta: -qtySold,
type: 'SALE', refType: 'Order', refId: order.id },
});Buku besar append-only berpadu rapi dengan bagian lain ERP. AuditLog saya append-only dengan alasan yang sama, penyelesaian pembayaran dilindungi idempotency key agar permintaan yang diulang tidak menulis ganda, dan buku kas adalah tampilan union turunan alih-alih tabel tersimpan. Stok hanyalah satu hal lagi yang Anda hitung alih-alih simpan.
Pertukarannya tidak gratis. Buku besar memakan penyimpanan dan butuh cache agar tetap cepat. Berikut perbandingan jujur yang saya pakai untuk meyakinkan diri sendiri melakukan penulisan ulang.
| Aspek | Buku besar append-only | Kolom kuantitas yang bisa diubah |
|---|---|---|
| Sumber kebenaran | SUM semua baris qtyDelta | Nilai kolom saat ini |
| Jejak audit | Lengkap dan bawaan, setiap perubahan sebuah baris | Tidak ada, tiap tulisan menghapus riwayat |
| Konkurensi | Insert tidak pernah bentrok, tanpa lost update | Balapan baca-ubah-tulis merusak hitungan |
| Pulih dari bug | Catat baris koreksi, putar ulang untuk bangun ulang | Tebakan manual, kebenaran sudah hilang |
| Latensi baca | Lebih lambat kecuali di-cache, butuh indeks | Baca satu baris instan |
| Biaya penyimpanan | Tumbuh mengikuti setiap pergerakan | Konstan, satu baris per produk |
Membaca SUM yang dikelompokkan pada setiap daftar produk akan membebani basis data, jadi Product.stockQty tetap ada, tetapi hanya sebagai cache. Ia ditulis dalam transaksi yang sama dengan insert pergerakan, dan bersifat sekali pakai. Jika suatu saat salah, buku besar membangunnya ulang. Pembangunan ulang adalah proyeksi murni: baca buku besar, tulis cache, selesai.
// scripts/reconcile-stock.ts - safe to run any time, idempotent
const truth = await prisma.stockMovement.groupBy({
by: ['productId'],
where: { branchId },
_sum: { qtyDelta: true },
});
for (const row of truth) {
const computed = row._sum.qtyDelta ?? 0;
await prisma.product.update({
where: { id: row.productId },
data: { stockQty: computed }, // cache := source of truth
});
}
// Because it only ever overwrites the cache FROM the ledger,
// re-running it changes nothing once caches already match.Hitung fisik stok yang berbeda dengan buku besar tidak pernah diperbaiki dengan menyunting stockQty. Itu akan membohongi sumber kebenaran. Sebagai gantinya saya mencatat pergerakan tipe ADJUSTMENT yang qtyDelta-nya adalah selisih hasil hitung, merujuk dokumen stok opname. Hasil hitung menjadi peristiwa lain dalam riwayat, bukan penimpaan diam-diam terhadapnya.
Karena cache secara teoretis bisa melenceng dari buku besar, saya tidak menunggu telepon untuk mengetahuinya. Sebuah job terjadwal menjalankan agregasi yang sama seperti perekonsiliasi, tetapi dalam mode baca-saja: ia membandingkan kebenaran terhitung dengan stockQty yang di-cache dan memancarkan metrik untuk produk mana pun yang berbeda. Dalam enam bulan sejak penulisan ulang, drift nyata praktis nol, dan itulah intinya. Cache kini terbukti merupakan proyeksi buku besar, dan hantu minus tiga tidak bisa kembali, karena tidak ada kuantitas bersama untuk ditimpa dua penulis. Saat drift bukan nol, peringatan memberi tahu persis produk dan cabang mana yang harus diperiksa, dan perekonsiliasi menjadikan perbaikannya operasi satu perintah alih-alih tebak-tebakan tengah malam.