ERP Inventory Module Design: Movements, Valuation, Guards

Photo by Tiger Lily

Photo by Tiger Lily
Every ERP I have seen fail at inventory failed the same way: someone stored on-hand quantity as a single mutable number on the item table, updated it from six different code paths, and within three months the number on screen disagreed with the number on the shelf. When I built the AP/AR and stock-touching flows for ANCoraPRO, a production ERP for a real Indonesian client running on React, NestJS, and PostgreSQL, the first architectural decision was the opposite: quantity is never stored as truth, it is always derived.
This post covers the three design decisions that make or break an inventory module: modeling stock movements as an immutable ledger, picking a valuation method you can actually defend to an accountant, and building negative stock guards that survive concurrent writes. These are the parts you cannot bolt on later, because every other module — purchasing, sales, accounting — builds on top of them.
The core insight is borrowed straight from double-entry accounting: you never edit a balance, you append a transaction. Every receipt, issue, transfer, and adjustment becomes one immutable row in a stock_movement table with a signed quantity. On-hand stock for any item in any warehouse is simply the SUM of its movements. Deleting or editing a movement is forbidden; corrections are new compensating movements, which gives you a free audit trail.
Each movement carries a reference_type and reference_id pointing back to the business document that caused it — a purchase order line, a sales delivery, a stock opname. This is what lets you answer the question warehouse teams actually ask: not just how many do we have, but why does the system say we have 40 when I count 38. With a ledger, you trace the discrepancy to a specific document in minutes.
-- 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;Yes, SUM over millions of rows gets slow eventually. The answer is not to abandon the ledger — it is periodic snapshot rows plus summing only movements after the snapshot, exactly how banks handle account balances. PostgreSQL handles this pattern comfortably into the hundreds of millions of rows before you need anything exotic.
Pro tip: add a monthly stock_snapshot table from day one, even if you do not need it for performance yet. Month-end closing in accounting will need frozen historical balances anyway, and retrofitting snapshots onto a live ledger during closing week is misery I do not recommend.
Valuation answers a different question than quantity: what is this stock worth, and what cost do we book when it leaves. The method you pick changes your COGS, your margins, and your tax position, so this is a decision to make with the client's accountant, not alone at your keyboard. The three methods that matter in practice:
| Method | How cost flows | Best for | Implementation cost |
|---|---|---|---|
| FIFO | Oldest receipt cost is consumed first; each issue is matched against remaining receipt layers in order. | Goods with shelf life, stable margins reporting, businesses where physical flow really is first-in-first-out. | Highest — you must persist cost layers per receipt and track remaining quantity per layer. |
| Average (AVCO) | Each receipt recalculates a weighted average unit cost; every issue uses the current average. | Commodity-like or mixed stock, volatile purchase prices, teams that want simple month-end math. | Low — one running average per item per costing scope, recalculated on receipt. |
| Standard | A preset cost is used for all movements; differences against actual purchase cost post to variance accounts. | Manufacturing with disciplined cost engineering teams who maintain the standards. | Medium code, high process discipline — useless if nobody maintains the standards. |
Microsoft's Business Central design docs are honest about the trade-offs: FIFO inflates balance sheet value when prices rise, average cost smooths volatility, and back-dated movements force recalculation of every affected entry afterwards. That last point is the one that bites custom builds — if you allow back-dated receipts, your AVCO implementation must replay the average forward from that date, or your books silently drift.
For most Indonesian SMB clients I default to average cost. Purchase prices in IDR are volatile enough that FIFO layer tracking adds real complexity, while AVCO gives the accountant one defensible number per item. I implement FIFO only when the business genuinely rotates stock that way and the finance team asks for it. Odoo's own documentation calls FIFO the most accurate but highly sensitive to input data and human error — an honest description of what you are signing up to maintain.
Negative stock is the inventory module's classic data-quality disease. Two warehouse operators issue the last 5 units at the same moment, both reads see on-hand 5, both writes succeed, and now the system says minus 5. Every report downstream — valuation, COGS, reorder suggestions — is now quietly wrong.
The check-then-insert pattern in application code alone does not work. Between your SELECT and your INSERT, another transaction can commit its own issue. You need the database to serialize writers on the same item-warehouse pair: row-level locking, a serializable isolation level, or an exclusion constraint. Code review will not catch this; load testing will.
My approach in NestJS with PostgreSQL: wrap the read and the write in one transaction, take a row-level lock on the movements for that item and warehouse with SELECT FOR UPDATE, validate the balance, then append. The lock forces concurrent issues of the same item to queue up and re-read a true balance:
// 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,
});
});
}One nuance worth building in: a per-warehouse allow_negative_stock flag. Some clients genuinely need to ship now and fix paperwork later — a hard block stops the business, so they will work around your system in Excel, which is worse. The compromise that works: allow it per warehouse, flag every negative-stock movement for mandatory review, and show the offending list on the inventory dashboard until someone clears it.
The second most common design miss: treating on-hand as the only quantity. The moment you support sales orders that are confirmed but not yet shipped, or purchase orders in transit, one number is not enough. You need four, and the UI must be explicit about which one it is showing:
On hand
Physical stock in the warehouse right now — the SUM of the movement ledger. This is what stock opname counts against.
Reserved
Committed to confirmed sales orders or production orders but not yet issued. Reservations are their own table, never movements.
Available
On hand minus reserved. This is the number sales staff need when they promise delivery dates — show them this, not on hand.
Incoming
Confirmed purchase order lines not yet received. Drives reorder logic so you do not order what is already on a truck.
Keep reservations out of the movement ledger. A reservation is an intention, not a physical event — mixing the two makes your valuation wrong and your audit trail lie. When the goods physically leave, the reservation is consumed and a real ISSUE movement is appended in the same transaction.
These are the questions I now force myself to answer before coding any inventory module, in roughly the order they will hurt if skipped:
An inventory module is an accounting system that happens to count boxes. Treat quantity like money: append-only ledgers, derived balances, database-enforced guards, and valuation rules agreed with the people who sign the financial statements. Every shortcut against those principles turns into a reconciliation meeting six months after go-live.
If you are building this on NestJS and PostgreSQL like I do, you already have everything you need: transactions, row locks, and NUMERIC columns. The hard part is not the technology — it is refusing to store that one convenient mutable quantity column when the deadline pressure says you should.