Skip to content

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_LASTCOST store the raw last buy price, not the average.
  • A brand-new item seeds both ST_AVGCOST and ST_LASTCOST from 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.ProsesHitungStockUsp_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)

  1. Delete any existing tbHist_Stock rows for the branch+period.
  2. Refresh cost columns on tbMaster_Stock from tbMaster_Barang (ST_HRGJUAL, ST_HRGBELI, ST_AVGCOST, ST_LASTCOST).
  3. Snapshot tbMaster_StocktbHist_Stock, stamped with the period.
  4. 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.