Desain Modul Inventory ERP: Movement, Valuasi, dan Guard

Foto oleh Tiger Lily

Foto oleh Tiger Lily
Setiap ERP yang saya lihat gagal di inventory selalu gagal dengan cara yang sama: seseorang menyimpan jumlah stok on-hand sebagai satu angka mutable di tabel item, meng-update-nya dari enam jalur kode berbeda, dan dalam tiga bulan angka di layar sudah tidak cocok dengan angka di rak gudang. Saat saya membangun alur AP/AR dan stok untuk ANCoraPRO, ERP production untuk klien nyata di Indonesia dengan React, NestJS, dan PostgreSQL, keputusan arsitektur pertamanya justru kebalikannya: kuantitas tidak pernah disimpan sebagai sumber kebenaran, selalu diturunkan dari ledger.
Artikel ini membahas tiga keputusan desain yang menentukan hidup-matinya modul inventory: memodelkan stock movement sebagai ledger immutable, memilih metode valuasi yang bisa Anda pertanggungjawabkan ke akuntan, dan membangun guard stok negatif yang tahan terhadap penulisan bersamaan. Bagian-bagian ini tidak bisa ditambal belakangan, karena semua modul lain — purchasing, sales, accounting — dibangun di atasnya.
Insight intinya dipinjam langsung dari akuntansi double-entry: Anda tidak pernah mengedit saldo, Anda menambahkan transaksi. Setiap penerimaan, pengeluaran, transfer, dan penyesuaian menjadi satu baris immutable di tabel stock_movement dengan kuantitas bertanda. Stok on-hand untuk item apa pun di gudang mana pun hanyalah SUM dari movement-nya. Menghapus atau mengedit movement dilarang; koreksi dilakukan lewat movement kompensasi baru, yang otomatis memberi Anda audit trail gratis.
Setiap movement membawa reference_type dan reference_id yang menunjuk balik ke dokumen bisnis penyebabnya — baris purchase order, pengiriman penjualan, stock opname. Inilah yang membuat Anda bisa menjawab pertanyaan yang benar-benar ditanyakan tim gudang: bukan cuma berapa stok kita, tapi kenapa sistem bilang 40 padahal saya hitung 38. Dengan ledger, Anda bisa melacak selisih itu ke dokumen spesifik dalam hitungan menit.
-- The core table: every stock change is one immutable row
CREATE TABLE stock_movement (
id BIGSERIAL PRIMARY KEY,
item_id BIGINT NOT NULL REFERENCES item(id),
warehouse_id BIGINT NOT NULL REFERENCES warehouse(id),
movement_type TEXT NOT NULL, -- RECEIPT | ISSUE | TRANSFER_IN |
-- TRANSFER_OUT | ADJUSTMENT
quantity NUMERIC(18,4) NOT NULL, -- signed: +in, -out
unit_cost NUMERIC(18,4), -- filled at receipt
reference_type TEXT NOT NULL, -- PO | SO | TRANSFER | OPNAME
reference_id BIGINT NOT NULL,
moved_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by BIGINT NOT NULL REFERENCES app_user(id)
);
-- On-hand quantity is ALWAYS derived, never stored as truth:
SELECT item_id, warehouse_id, SUM(quantity) AS on_hand
FROM stock_movement
GROUP BY item_id, warehouse_id;Memang, SUM atas jutaan baris lama-lama melambat. Solusinya bukan meninggalkan ledger — melainkan baris snapshot periodik plus menjumlahkan hanya movement setelah snapshot, persis seperti bank menangani saldo rekening. PostgreSQL menangani pola ini dengan nyaman sampai ratusan juta baris sebelum Anda butuh sesuatu yang eksotis.
Pro tip: tambahkan tabel stock_snapshot bulanan sejak hari pertama, meskipun belum dibutuhkan untuk performa. Closing akhir bulan di accounting toh akan butuh saldo historis yang dibekukan, dan memasang snapshot ke ledger yang sudah live di minggu closing adalah penderitaan yang tidak saya rekomendasikan.
Valuasi menjawab pertanyaan yang berbeda dari kuantitas: berapa nilai stok ini, dan biaya berapa yang dibukukan saat stok keluar. Metode yang Anda pilih mengubah COGS, margin, dan posisi pajak, jadi keputusan ini diambil bersama akuntan klien, bukan sendirian di depan keyboard. Tiga metode yang relevan di praktik:
| Metode | Bagaimana biaya mengalir | Paling cocok untuk | Biaya implementasi |
|---|---|---|---|
| FIFO | Biaya penerimaan tertua dikonsumsi lebih dulu; setiap pengeluaran dicocokkan ke layer penerimaan yang tersisa secara berurutan. | Barang dengan masa simpan, pelaporan margin yang stabil, bisnis yang alur fisiknya memang first-in-first-out. | Paling tinggi — Anda harus menyimpan cost layer per penerimaan dan melacak sisa kuantitas per layer. |
| Average (AVCO) | Setiap penerimaan menghitung ulang rata-rata tertimbang biaya per unit; setiap pengeluaran memakai rata-rata terkini. | Stok bersifat komoditas atau campuran, harga beli fluktuatif, tim yang ingin perhitungan akhir bulan sederhana. | Rendah — satu running average per item per scope costing, dihitung ulang saat penerimaan. |
| Standard | Biaya preset dipakai untuk semua movement; selisih terhadap biaya beli aktual dibukukan ke akun variance. | Manufaktur dengan tim cost engineering disiplin yang memelihara standarnya. | Kode menengah, disiplin proses tinggi — percuma kalau tidak ada yang memelihara standarnya. |
Dokumentasi desain Business Central dari Microsoft jujur soal trade-off-nya: FIFO menggelembungkan nilai neraca saat harga naik, average cost meratakan volatilitas, dan movement yang di-backdate memaksa perhitungan ulang semua entri yang terdampak. Poin terakhir itulah yang sering menggigit custom build — kalau Anda mengizinkan penerimaan backdate, implementasi AVCO Anda harus me-replay rata-rata maju dari tanggal itu, atau pembukuan Anda melenceng diam-diam.
Untuk mayoritas klien SMB Indonesia, default saya adalah average cost. Harga beli dalam IDR cukup fluktuatif sehingga pelacakan layer FIFO menambah kompleksitas nyata, sementara AVCO memberi akuntan satu angka yang bisa dipertanggungjawabkan per item. Saya mengimplementasikan FIFO hanya kalau bisnisnya memang merotasi stok seperti itu dan tim finance yang memintanya. Dokumentasi Odoo sendiri menyebut FIFO paling akurat tetapi sangat sensitif terhadap data input dan human error — deskripsi jujur tentang apa yang akan Anda pelihara.
Stok negatif adalah penyakit kualitas data klasik di modul inventory. Dua operator gudang mengeluarkan 5 unit terakhir di saat bersamaan, kedua pembacaan melihat on-hand 5, kedua penulisan sukses, dan sekarang sistem bilang minus 5. Semua laporan hilir — valuasi, COGS, saran reorder — diam-diam jadi salah.
Pola check-then-insert di kode aplikasi saja tidak cukup. Di antara SELECT dan INSERT Anda, transaksi lain bisa commit pengeluarannya sendiri. Anda butuh database yang menserialisasi penulis pada pasangan item-gudang yang sama: row-level locking, isolation level serializable, atau exclusion constraint. Code review tidak akan menangkap ini; load testing yang akan.
Pendekatan saya di NestJS dengan PostgreSQL: bungkus pembacaan dan penulisan dalam satu transaksi, ambil row-level lock pada movement untuk item dan gudang itu dengan SELECT FOR UPDATE, validasi saldo, lalu append. Lock memaksa pengeluaran bersamaan untuk item yang sama mengantre dan membaca ulang saldo yang benar:
// NestJS service: issue stock with a row-level lock guard
async issueStock(dto: IssueStockDto) {
return this.dataSource.transaction(async (em) => {
// 1. Lock the item+warehouse balance row (serialize writers)
const balance = await em.query(
`SELECT COALESCE(SUM(quantity), 0) AS on_hand
FROM stock_movement
WHERE item_id = $1 AND warehouse_id = $2
FOR UPDATE OF stock_movement`,
[dto.itemId, dto.warehouseId],
);
// 2. The guard: reject, do not "fix later"
if (Number(balance[0].on_hand) < dto.quantity) {
throw new ConflictException(
'Insufficient stock: on hand ' + balance[0].on_hand,
);
}
// 3. Append the movement row
await em.getRepository(StockMovement).insert({
itemId: dto.itemId,
warehouseId: dto.warehouseId,
movementType: 'ISSUE',
quantity: -dto.quantity,
referenceType: dto.referenceType,
referenceId: dto.referenceId,
createdBy: dto.userId,
});
});
}Satu nuansa yang layak dibangun: flag allow_negative_stock per gudang. Beberapa klien benar-benar perlu kirim sekarang dan bereskan dokumen belakangan — blokir keras menghentikan bisnis, dan mereka akan mengakali sistem Anda lewat Excel, yang lebih buruk. Kompromi yang berhasil: izinkan per gudang, tandai setiap movement stok negatif untuk review wajib, dan tampilkan daftarnya di dashboard inventory sampai ada yang membereskannya.
Kesalahan desain kedua yang paling umum: menganggap on-hand sebagai satu-satunya kuantitas. Begitu Anda mendukung sales order yang sudah dikonfirmasi tapi belum dikirim, atau purchase order yang masih dalam perjalanan, satu angka tidak cukup. Anda butuh empat, dan UI harus eksplisit menunjukkan yang mana:
On hand
Stok fisik di gudang saat ini — SUM dari ledger movement. Inilah yang dihitung saat stock opname.
Reserved
Sudah dialokasikan ke sales order atau production order yang dikonfirmasi tapi belum dikeluarkan. Reservasi punya tabel sendiri, bukan movement.
Available
On hand dikurangi reserved. Inilah angka yang dibutuhkan tim sales saat menjanjikan tanggal kirim — tampilkan ini, bukan on hand.
Incoming
Baris purchase order terkonfirmasi yang belum diterima. Menjadi dasar logika reorder supaya Anda tidak memesan barang yang sudah di atas truk.
Jaga reservasi tetap di luar ledger movement. Reservasi adalah niat, bukan kejadian fisik — mencampur keduanya membuat valuasi salah dan audit trail berbohong. Saat barang fisik keluar, reservasi dikonsumsi dan movement ISSUE sungguhan ditambahkan dalam transaksi yang sama.
Ini pertanyaan-pertanyaan yang sekarang saya paksa diri saya jawab sebelum menulis kode modul inventory, kira-kira berurutan dari yang paling menyakitkan kalau dilewati:
Modul inventory adalah sistem akuntansi yang kebetulan menghitung kardus. Perlakukan kuantitas seperti uang: ledger append-only, saldo turunan, guard yang ditegakkan database, dan aturan valuasi yang disepakati dengan orang-orang yang menandatangani laporan keuangan. Setiap jalan pintas melawan prinsip itu berubah jadi rapat rekonsiliasi enam bulan setelah go-live.
Kalau Anda membangun ini di NestJS dan PostgreSQL seperti saya, semua yang Anda butuhkan sudah ada: transaksi, row lock, dan kolom NUMERIC. Bagian sulitnya bukan teknologi — melainkan menolak menyimpan satu kolom kuantitas mutable yang menggoda itu saat tekanan deadline bilang sebaiknya iya.