Stock & Costing Engine¶
This is the accounting heart of the system: how on-hand quantity is tracked and how each item's cost is valued. Get this right and the rest of the domain follows.
The engine is FrameWork.SmartSoft.BLL.STOCK (the global cSTOK), backed by the
tbMaster_Stock table.
Table relationships¶
erDiagram
tbMaster_Barang ||--o{ tbMaster_Stock : "on-hand per branch+loc"
tbMaster_Cabang ||--o{ tbMaster_Stock : "branch"
tbMaster_Stock ||--o{ tbHist_StockGudang : "period snapshots"
tbMaster_Barang ||--o{ tbMaster_StockGudang : "per warehouse loc"
tbMaster_Barang {
string PRD_PRDCD PK
string PRD_NAMA
double PRD_QTYSTOCK "mirror of loc 01"
double PRD_AVGCOST
double PRD_LASTCOST
}
tbMaster_Stock {
string ST_KODECABANG PK
string ST_LOKASI PK
string ST_PRDCD PK
double ST_QTYAWAL "opening"
double ST_QTYAKHIR "ending (derived)"
double ST_AVGCOST
double ST_LASTCOST
}
tbHist_StockGudang {
string STG_KODECABANG PK
string STG_KODELOK PK
string STG_PERIODE PK
string STG_PRDCD PK
double STG_QTYAKHIR
}
The accumulator model¶
SmartSoft does not keep a movement ledger (one row per event). Instead, each item's stock row holds a set of bucket columns, and every transaction increments the relevant bucket. The on-hand quantity is a derived identity over those buckets.
tbMaster_Stock is keyed ST_KODECABANG (branch) + ST_LOKASI (location) + ST_PRDCD
(item). Its quantity buckets:
| Bucket column | Accumulates |
|---|---|
ST_QTYAWAL |
Opening balance |
ST_QTYBELI |
Purchases / goods receipts (in) |
ST_QTYRBELI |
Purchase returns (out) |
ST_QTYJUAL |
Sales (out) |
ST_QTYRJUAL |
Sales returns (in) |
ST_QTYKELUAR |
Transfers / issues out |
ST_QTYRKELUAR |
Returns of transfers-out (in) |
ST_QTYMASUK |
Transfers in |
ST_QTYRMASUK |
Returns of transfers-in (out) |
ST_QTYADJ |
Adjustments / stock-opname |
ST_QTYRTEMP |
Reserved / temp-out |
ST_QTYAKHIR |
Ending balance (derived) |
The ending-balance identity¶
The on-hand (ending) quantity is always this identity — coded verbatim in
BLL_STOCK.vb, DailyProcess.vb, and STOCK.vb:
ST_QTYAKHIR = ST_QTYAWAL
+ ST_QTYBELI - ST_QTYRBELI
- ST_QTYJUAL + ST_QTYRJUAL
- ST_QTYKELUAR + ST_QTYRKELUAR
+ ST_QTYMASUK - ST_QTYRMASUK
+ ST_QTYADJ - ST_QTYRTEMP
flowchart LR
AWAL["ST_QTYAWAL<br/>(opening)"] --> AKHIR(("ST_QTYAKHIR<br/>ending"))
BELI["+ BELI"] --> AKHIR
RJUAL["+ RJUAL"] --> AKHIR
RKELUAR["+ RKELUAR"] --> AKHIR
MASUK["+ MASUK"] --> AKHIR
ADJ["+ ADJ"] --> AKHIR
RBELI["− RBELI"] --> AKHIR
JUAL["− JUAL"] --> AKHIR
KELUAR["− KELUAR"] --> AKHIR
RMASUK["− RMASUK"] --> AKHIR
RTEMP["− RTEMP"] --> AKHIR
style AKHIR fill:#e8f5e9,stroke:#388e3c
The item master tbMaster_Barang mirrors the primary location's on-hand into
PRD_QTYSTOCK (for ST_LOKASI = '01'), alongside PRD_AVGCOST, PRD_LASTCOST,
PRD_HRGBELI, PRD_HRGJUAL.
Which document type touches which bucket¶
The recompute logic (ProsesHitungStockNew in BLL_STOCK.vb) maps BOH_TIPEDOC to
buckets:
BOH_TIPEDOC |
Bucket | Direction |
|---|---|---|
1 (receipt) |
ST_QTYBELI (qty + BONUS1 + BONUS2) |
in |
2 (purchase return) |
ST_QTYRBELI |
out |
3 (transfer) |
source → ST_QTYKELUAR, destination → ST_QTYMASUK |
move |
4 (lost), 5 (transfer out), 8 (disposal), 9+BOD_KODEPBR='P', S (own use), M with qty<0 |
ST_QTYKELUAR |
out |
6 (transfer in), 9+BOD_KODEPBR='R', M with qty>0 |
ST_QTYMASUK |
in |
7 (stock opname) |
ST_QTYADJ |
± |
Sales JLH_TIPETRN='J' / 'R' |
ST_QTYJUAL / ST_QTYRJUAL |
out / in |
Stock opname posts to its own tables
A physical count is entered in tbTr_StockOpname_H/_D/_G (OPH_/OPD_), not the
back-office document tables — but its resulting adjustment lands in ST_QTYADJ.
Moving weighted-average cost¶
Cost valuation is moving weighted average, computed in one place: STOCK.GetAvgCost().
The active formula, verbatim:
newAvg = ( incomingQty × incomingCost + max(onHand, 0) × oldAvg )
─────────────────────────────────────────────────────────
( incomingQty + max(onHand, 0) )
In code terms:
Hasil = ((vQTY * vHRGBELI) + (IIf(PRD_QTYSTOCK <= 0, 0, PRD_QTYSTOCK) * PRD_AVGCOST)) _
/ (vQTY + IIf(PRD_QTYSTOCK <= 0, 0, PRD_QTYSTOCK))
Rules that fall out of this:
- Negative on-hand is floored to zero before weighting — a negative balance contributes zero weight, not negative weight.
- On any exception, cost falls back to the incoming cost (
vHRGBELI). ST_LASTCOST/PRD_LASTCOSTstore the raw last buy price, not the average.- A brand-new item seeds both
ST_AVGCOSTandST_LASTCOSTfrom the first buy price.
When cost is recomputed vs. valued at current cost¶
flowchart TD
subgraph IN["Inbound with a new cost → RECOMPUTE avg"]
R1["Goods receipt (1)"]
R2["Transfer / mutasi (3)"]
R3["Repacking output (9, 'R')"]
R4["MPP adjustment > 0 (M)"]
end
subgraph OUT["Outbound → value at CURRENT avg, no recompute"]
O1["Sales / COGS"]
O2["Own use (S)"]
O3["Disposal / pemusnahan (8)"]
O4["Lost goods (4)"]
end
IN --> AVG["ST_AVGCOST / PRD_AVGCOST updated"]
OUT --> USE["Consume at existing ST_AVGCOST"]
style AVG fill:#e8f5e9,stroke:#388e3c
style USE fill:#e3f2fd,stroke:#1976d2
| Recompute the average | Value at current average (no recompute) |
|---|---|
| Goods receipt, transfer/mutasi, repacking output, positive MPP | Sales/COGS, own use, disposal, lost goods |
Costing caveat when stock is negative
Because GetAvgCost floors negative on-hand to zero, a receipt posted while the balance
is negative averages against zero prior quantity — so the incoming cost dominates and
the running average can drift from a strict perpetual-average result. This is inherent to
the current implementation; flag it when reconciling cost anomalies. See
Known Issues.
Periods, snapshots & closing¶
Stock is periodised by month (ST_PERIODE = yyyyMM). Past periods are frozen into
tbHist_Stock / tbHist_StockGudang; the live period stays in tbMaster_Stock.
flowchart LR
subgraph Now["Current period"]
MS["tbMaster_Stock<br/>(live buckets)"]
end
subgraph Past["Closed periods"]
HS["tbHist_Stock<br/>(one frozen set per ST_PERIODE)"]
end
MS -->|"monthly close: snapshot"| HS
MS -->|"roll forward:<br/>AWAL = AKHIR, RPHAWAL = AVGCOST,<br/>buckets → 0"| MS
style MS fill:#e8f5e9,stroke:#388e3c
style HS fill:#ede7f6,stroke:#673ab7
Daily process (DailyProcess.Run)¶
Runs an ordered pipeline: backup → recompute stock (DoHitungStock) → sales recap →
rollback → price/tag changes → stock-opname → etc. DoHitungStock loops each month up to
the active month, zeroes the movement buckets (ResetDataStock), runs the stored-proc
recompute (cStock.ProsesHitungStock → Usp_ProsesHitungStockAll), and re-derives
ST_QTYAKHIR via the identity. For the live month it pushes PRD_QTYSTOCK = ST_QTYAKHIR
back to tbMaster_Barang for location '01'.
Monthly close (ClosingStock / Usp_ProsesClosingBulanan)¶
- Delete any existing
tbHist_Stockrows for the branch+period. - Refresh cost columns on
tbMaster_StockfromtbMaster_Barang(ST_HRGJUAL,ST_HRGBELI,ST_AVGCOST,ST_LASTCOST). - Snapshot
tbMaster_Stock→tbHist_Stock, stamped with the period. - Roll forward:
ST_QTYAWAL = ST_QTYAKHIR,ST_RPHAWAL = ST_AVGCOST, and reset all movement buckets to 0 — the prior ending becomes the new opening.
Period-balance reads route accordingly: current period from tbMaster_Stock, past periods
from tbHist_Stock filtered by ST_PERIODE (getSaldoAwal / getSaldoAkhir).
Stored procedures¶
| Procedure | Purpose |
|---|---|
Usp_ProsesHitungStockAll |
Full stock recompute (all locations) — daily driver. |
Usp_ProsesHitungStockGudang |
Per-warehouse (multi-gudang) recompute. |
Usp_ProsesHitungStockPoint |
Loyalty-points stock recompute. |
Usp_ProsesDataHarian |
Daily process driver. |
Usp_ProsesClosingBulanan |
Monthly close. |
Usp_ProsesRekapSales_Pembelian / _Customer |
Sales / purchase / customer recap. |
Usp_HapusDataTransaksiClosing / Usp_RestoreDataTransaksiClosing |
Purge / restore transaction data at closing (to/from a backup DB). |
Why recompute at all?
Because the model is an accumulator (not an immutable ledger), the buckets can drift from the underlying documents — so the system periodically recomputes the buckets from the transactions via stored procedures. This is the accumulator model's main cost: it needs a recompute step to stay trustworthy, where a ledger would be self-reconciling.