Case file 009Hybrid ERP
The problem
“We ring sales on a till. We track stock by hand. When the month ends, nobody can say what anything cost.”
Solved
- For
- Businesses with both retail POS and production or inventory
- Built
- Desktop app + server: POS, production docs, stock, costing
- Stack
- Tauri · NestJS · Postgres · Next.js admin · Better Auth
- Result
- A sale on 300K rows of history: 3.6 s → instant
01 · The pain
Two worlds that never agreed.
- A sale on the till doesn’t move the stock.
- Material sent to production is written on paper.
- What did this batch cost? Nobody knows until month end.
02 · The fix
Stock in, stock out, cost on post.
A sample warehouse opening a shift, ringing a sale, receiving stock, and issuing to production. Watch the cost, stock on hand, and ledger all move together.
Shift
Closed
| Item | Qty | Avg cost | Value |
|---|---|---|---|
| Item A (fabric) | 100 | 10.00 | 1000 |
| Item B (trim) | 50 | 16.00 | 800 |
Ledger
| Type | Item | Qty | Cost | Time |
|---|---|---|---|---|
| receive | item-a | +100 | 10.00 | Today, 08:00 |
| receive | item-b | +50 | 16.00 | Today, 09:00 |
Sample data: Two items with different cost bases. Pick an action, select an item and qty, and post. Watch the weighted-average cost recalculate based on everything in the ledger.
03 · How it works
Three flows, and a hard problem about timing.
A
Every document touches stock
When staff ring a sale, issue material to production, or receive goods from a supplier, the system records it. Each document is a transaction: sale, requisition, issuance, receive note, discard. Nothing goes unseen.
B
Stock finds its cost in real time
A weighted-average cost is calculated at post time from the full ledger history: if you received 100 units at cost 10, then 50 at cost 16, the next issuance costs at 12. Two posts racing on the same item lock in order so cost never reads stale.
C
Documents are offline-first, synced later
The desktop app works on LAN or standalone. Shifts, sales, requisitions—all stored locally. When the server is reachable, they sync up. Licenses and modules can be disabled from the admin panel, and the desktop app finds out even while offline.
The hard part
Weighted-average at post time, with concurrent writes.
Every ledger entry has a cost: received 100 units @10 then 50 @16. A month later, a real issuance happens—pick 30 units—what is their cost? The system calculates it: 10+10+…+16+16+… = the average of everything issued so far. Two issuances racing on the same item and warehouse can both try to read and then write that average at once. The code uses a Postgres advisory lock, locked within the transaction scope, taken before any read and before the items are locked in order—so two documents with overlapping items never deadlock each other.
Even at scale—a stock ledger with 300,000 entries—replaying the entire history for every cost lookup took 3.6 seconds per sale and crashed the server past that point. The fix was aStockBalance table: a cache of the last resolved cost and the item count at that point. On the next write, only new ledger entries since the last cache hit are replayed. That brought it to O(1) after the first touch.
04 · What changed
End-to-end.
Test coverage
97 tests — auth, admin kill switches (user disable, tenant suspend, device disable), license & seat limiting, POS sale & void, production documents (requisition, issuance, receive, discard), weighted-average costing, concurrent write race fix, BOM-based cost rollup, sync push, sync pull, embedded Postgres lifecycle, offline grace period enforcement.
Verification
Every flow verified live against real Postgres, not mocks. Desktop app compiled and run: tenant setup, POS shift, sale, void, production requisition → issuance → receive. Offline mode tested: LAN-only network namespace, embedded Postgres, license cache, grace period enforcement.
Scale & performance
Stock ledger with 300,000 entries, load test found and fixed latency bug. Per-sale costing calculation now O(1) after first touch via StockBalance cache. Concurrent writes protected with Postgres advisory locks; no race, no deadlock.
Client & user count · to confirm
Time per month-end close · to confirm
Active branches · to confirm
