At a Glance
Rule families:COST_VAL_*validation ·COST_AUTH_*permission ·COST_CALC_*calc ·COST_POST_*posting ·COST_XMOD_*cross-module
Rule count: approximately 85 rules
Audience: Test author + developer — every rule ID is anchored from04-test-scenarios*pages
Status lifecycle: Section 5.1 (where present) carries the Live UI vs BRD discrepancy callouts
This page captures the operational business rules that govern the costing module — the engine that picks cost_per_unit for every outbound stock movement, refreshes average_cost_per_unit on every inbound, and writes the period-locked closing_cost_per_unit at period close. Because costing is not a separate document module — it is a behaviour layer over inventory's ledger — the rule set here looks different from the GRN / PR / SR catalogues: there is no document lifecycle of its own, no save / approve / commit progression, no doc_status. Instead, the rules below describe how the engine reads its configuration, picks the cost, performs the arithmetic, refreshes the average, anchors the period boundary, and gates Finance authorities over the resulting valuation. Every rule is invoked from an inventory transaction post or a period-end run that lives in the inventory module; the costing module owns the rule's logic, not the trigger.
Two structural points colour every rule below. First, the costing method is configured at the business unit, not per product (per costing/01-data-model § 5 item 1). The engine resolves the method via tb_business_unit.calculation_method (platform schema, default average) once per call — typically at the start of the inventory transaction post — and applies it to every detail line in the transaction. There is no per-product override; mixed FIFO / WA across products at the same business unit is not a supported configuration. Second, every cost picked at post time is immutable on write — once written to tb_inventory_transaction_cost_layer.cost_per_unit, the value is part of the historical ledger and cannot be edited even if the configured method changes later. Cost revaluation only happens through (a) a credit-note-amount adjustment writing diff_amount and recalculating the originating lot's cost_per_unit per INV_CALC_011, or (b) the period-end EOP rollforward which carries the closing cost forward unchanged. These two properties — single method per business unit, immutable cost on the layer row — are what make the costing audit trail defensible across periods.
Rule IDs follow COST_VAL_NNN. Validation runs at cost-pick time — i.e. when the inventory transaction post invokes the costing engine to populate cost_per_unit / average_cost_per_unit on the cost-layer row about to be written. There is no save-time or commit-time split; the engine is called once per cost-layer row and either returns a valid cost or fails the parent transaction.
| Rule ID | Condition | When enforced | Error / behaviour |
|---|---|---|---|
COST_VAL_001 |
The business unit context resolves to a tb_business_unit row with calculation_method ∈ {average, fifo} (default average per the platform schema). If the JWT / x-app-id does not resolve to a known business unit, the engine refuses to pick. |
Cost-pick time | Reject the inventory transaction with "Cannot resolve costing method: business_unit context missing or invalid." Parent transaction rolled back per INV_VAL_001 cascade. |
COST_VAL_002 |
For FIFO cost-pick on outbound: at least one tb_inventory_transaction_cost_layer row exists at (location_id, product_id) with in_qty − Σ subsequent out_qty > 0. If on-hand is zero (no consumable layer), the engine cannot pick a cost. |
Cost-pick time (outbound) | Reject with "FIFO: no available cost layer at (location, product) to consume." Parent transaction rejected per INV_VAL_005 (no-negative-balance). |
COST_VAL_003 |
For WA cost-pick on outbound: at least one inbound layer exists at (location_id, product_id) so that average_cost_per_unit is initialised. A first-ever outbound at a (location, product) with no prior inbound is rejected. |
Cost-pick time (outbound) | Reject with "Weighted Average: no prior inbound layer at (location, product) to read average from." Parent transaction rejected. |
COST_VAL_004 |
cost_per_unit returned by the engine is non-negative and finite. Same constraint as INV_VAL_007 but enforced specifically at the cost-pick boundary so a corrupt source value (e.g. a vendor pricelist with a negative entry) is caught before the cost-layer write. |
Cost-pick time | Reject with "Cost-pick produced an invalid cost_per_unit (negative or non-finite): <value>." Parent transaction rolled back. |
COST_VAL_005 |
For WA inbound recompute: the new average is computed at full 5dp precision before rounding to the column precision; intermediate inputs (prior_on_hand, prior_average, in_qty, in_cost_per_unit) are non-negative; the divisor (prior_on_hand + in_qty) is strictly positive. |
Cost-pick time (inbound, WA only) | Reject with "Weighted Average recompute: invalid inputs (negative qty / negative cost / zero total qty)." Parent transaction rejected. |
COST_VAL_006 |
For credit-note-amount adjustments (enum_transaction_type = credit_note_amount): the originating lot identified by lot_no, lot_index exists in tb_inventory_transaction_cost_layer, has not been fully drained (positive remaining (Σ in_qty − Σ out_qty) > 0 OR the lot's original receipt event is still present in the ledger for cost rebasing), and the credit-note diff_amount is signed (negative for vendor concession reducing cost; positive for additional charge). |
Cost-pick time (credit-note path) | Reject with "Credit-note-amount adjustment: originating lot not found or fully drained without remaining cost rebasing context." Per INV_VAL_011. |
COST_VAL_007 |
For count-variance posts (count completes → tb_stock_in / tb_stock_out rollup), the configured count-costing source (enum_physical_count_costing_method) resolves to a valid cost: standard requires tb_product.standard_cost > 0; last and last_receiving require at least one matching prior layer at (location, product); average requires a non-zero average_cost_per_unit. |
Cost-pick time (count-variance) | Reject with "Count-variance valuation: configured count-costing-method <X> cannot resolve a cost (missing standard_cost / no prior layer)." Count-derived stock-in / stock-out cannot post; routes back to Controller. |
COST_VAL_008 |
For end-of-period rollforward (enum_transaction_type ∈ {close_period, open_period, eop_in, eop_out}): the closing period's snapshot rows are computed before the open-period rollforward begins; closing_cost_per_unit is the cost the rollforward carries into the next period; both must be present and consistent. |
Period-end orchestration | Reject the rollforward run with "Cannot rollforward period <YYMM> → next: closing snapshot for <N> (location, product, lot) keys is missing or has null closing_cost_per_unit." Period stays at open. |
COST_VAL_009 |
Costing method change on an active business unit is blocked if any product at that business unit has non-zero on-hand. (The cost-layer history is structurally tied to the method — FIFO requires lot_seq_no ordering and per-lot consumption rules; WA reads the most recent average_cost_per_unit. A method change on a non-empty business unit would invalidate the in-flight cost-layer state.) |
At configuration save | Reject with "Cannot change calculation_method on business unit <code>: <N> products have non-zero on-hand. Drain stock or run elevated migration script." Configuration not saved. Mirrors INV_XMOD_009. |
COST_VAL_010 |
Cost-pick on transfers between locations (transfer_out → transfer_in paired rows): the transfer_in cost equals the transfer_out cost (no cost change on movement) — the engine writes transfer_in.cost_per_unit = transfer_out.cost_per_unit. This is enforced by the post pipeline; a manual override (different cost on the in than the out) is rejected. |
Cost-pick time (transfer) | Reject with "Transfer cost mismatch: transfer_in.cost_per_unit must equal transfer_out.cost_per_unit." Per the transfer-coherence rule. |
COST_VAL_011 |
Cost-pick on direct-cost locations (tb_location.location_type = direct): the engine does not run because the receipt is expensed at receipt; no cost-layer row is written. An attempt to invoke the cost-pick for a direct-cost location is silently skipped (not an error — the inventory transaction still writes a header + detail for audit per INV_POST_003). |
Cost-pick time (direct location) | No error; the engine returns "skipped — direct location, no cost layer required". |
COST_VAL_012 |
Cost-pick on consignment locations (tb_location.location_type = consignment): the engine writes a cost-layer row with the receipt cost (for the memo register), but flags the row as consignment via the dimension / info JSON. AP and Inventory journal entries are suppressed at receipt; consumption posts COGS at the consignment cost per INV_POST_005. |
Cost-pick time (consignment location) | No error; engine writes a flagged cost-layer row. Downstream GL routing reads the flag. |
All monetary values are stored as Decimal(20, 5) on the cost-layer and detail rows. Display rounding is half-up to 2 decimals for currency amounts and 3 decimals for quantities. Intermediate computations carry full 5dp precision; aggregates re-read rounded values per step. The rules below are the engine's arithmetic; they are invoked by the rules in Section 2 (validation) and Section 5 (posting).
Rule IDs follow COST_CALC_NNN. These rules delegate to or align with the inventory-module rules INV_CALC_001–INV_CALC_012 because the cost-layer arithmetic is the same set of formulas viewed from the costing perspective.
| Rule ID | Formula |
|---|---|
COST_CALC_001 (FIFO outbound cost-pick) |
When tb_business_unit.calculation_method = fifo (or runtime override) and the cost-pick is outbound: iterate tb_inventory_transaction_cost_layer rows at (location_id, product_id) ordered by lot_seq_no ascending, taking only rows with positive remaining balance. For each consumed lot, produce one outbound cost-layer row at cost_per_unit = lot.cost_per_unit and out_qty = min(lot.remaining, total_out_qty − already_consumed). Stop when total_out_qty is fully consumed. Each consumed-lot row's total_cost = out_qty × cost_per_unit. A single outbound detail line may produce multiple cost-layer rows when the consumption spans lots — this is the FIFO multi-row pattern. Equivalent to INV_CALC_005. |
COST_CALC_002 (WA outbound cost-pick) |
When calculation_method = average and the cost-pick is outbound: read the most recent tb_inventory_transaction_cost_layer.average_cost_per_unit at (location_id, product_id) — the running average refreshed by every prior inbound. Produce one outbound cost-layer row at cost_per_unit = current_average and out_qty = total_out_qty; total_cost = out_qty × current_average. The average is not updated by outbound — issues don't re-blend (only inbound does). Equivalent to INV_CALC_006. |
COST_CALC_003 (WA inbound recompute) |
When calculation_method = average and the cost-pick is inbound: new_average = (prior_on_hand × prior_average + in_qty × in_cost_per_unit) / (prior_on_hand + in_qty) rounded to 5dp. Written to average_cost_per_unit on the new inbound cost-layer row; subsequent reads at (location_id, product_id) pick up this value. If prior_on_hand = 0, the new average equals in_cost_per_unit. Equivalent to INV_CALC_007. |
COST_CALC_004 (FIFO inbound layer creation) |
When calculation_method = fifo and the cost-pick is inbound: create a new cost-layer row with in_qty > 0, cost_per_unit = received_cost, lot_no = current_lot_no (from the inbound detail row), lot_index = 1 (or next available if the same lot_no is re-opened), lot_seq_no = max(existing lot_seq_no at (location, product)) + 1. average_cost_per_unit is also computed (per COST_CALC_003) and stored even under FIFO — to support the enum_physical_count_costing_method = average count-valuation option and to provide a "shadow WA" for reporting. |
COST_CALC_005 (Credit-note-amount lot revaluation) |
When enum_transaction_type = credit_note_amount: write a cost-layer row with in_qty = 0, out_qty = 0, diff_amount = signed_amount. Recalculate the originating lot's cost_per_unit: new_lot_cost_per_unit = (original_lot_total_cost + diff_amount) / original_lot_qty. Downstream consumption from the same lot picks up the revalued cost via the FIFO pick reading cost_per_unit on the lot's most recent revaluation row; already-consumed portions are not retroactively adjusted (the credit-note's effect flows entirely through diff_amount on the layer, leaving previously costed outbound rows untouched). Equivalent to INV_CALC_011. |
COST_CALC_006 (Period snapshot — closing cost-per-unit) |
At period close: closing_cost_per_unit = closing_total_cost / closing_qty where closing_qty = opening_qty + receipt_qty − issue_qty + adjustment_qty and closing_total_cost = opening_total_cost + receipt_total_cost − issue_total_cost + adjustment_total_cost + Σ diff_amount. For WA, this is the period-end weighted average. For FIFO with multiple residual lots, this is the derived weighted average across the residual lots at the period grain — the per-lot cost_per_unit is preserved on the cost-layer rows; the snapshot's closing_cost_per_unit is the rollup-level WA-equivalent. Equivalent to INV_CALC_010. |
COST_CALC_007 (Period rollforward — opening cost preservation) |
At period open: opening_qty / opening_cost_per_unit / opening_total_cost = previous_period.closing_qty / closing_cost_per_unit / closing_total_cost. For FIFO, the open_period cost-layer rows preserve the per-lot breakdown so FIFO sequence carries across the boundary; lot_seq_no is preserved unchanged. For WA, the rollforward writes a single open-period layer per (location, product) carrying the prior closing average. Equivalent to INV_CALC_008. |
COST_CALC_008 (Count-variance valuation — by enum_physical_count_costing_method) |
When a count produces a variance line and the count-variance posting code writes cost_per_unit on the tb_stock_in / tb_stock_out rollup: resolve the cost by the configured enum_physical_count_costing_method: standard → tb_product.standard_cost; last → most recent cost-layer cost_per_unit at (location, product) regardless of direction; average → most recent average_cost_per_unit; last_receiving → most recent inbound layer's cost_per_unit (filter transaction_type ∈ {good_received_note, adjustment_in, transfer_in}). The picked cost flows into the count-rollup document's lines and then into the cost-layer post via the normal FIFO / WA write path. |
COST_CALC_009 (Standard cost — recipe baseline) |
tb_product.standard_cost is read by recipe costing and by enum_physical_count_costing_method = standard. It is not read by the FIFO / WA cost-pick on regular inbound / outbound. Updates to standard_cost are prospective; existing cost-layer rows are not retroactively adjusted. |
COST_CALC_010 (Rounding) |
All rounding is half-up. Stored values use column precision (Decimal(20, 5)); intermediate computations carry 5dp; on-the-fly aggregations re-read rounded values from each step. Same rule as INV_CALC_012. Display: 2dp for currency, 3dp for quantities. |
BU-A with calculation_method = fifo)Two products at the same business unit are both FIFO (the method is per business unit, not per product). Consider product P-1 at LOC-A with no prior on-hand:
good_received_note): in_qty = 100, cost_per_unit = ฿10.00, assigned lot_no = LOT-1, lot_index = 1, lot_seq_no = 1.
in_qty = 100, cost_per_unit = ฿10.00, total_cost = ฿1,000.00, average_cost_per_unit = ฿10.00 (shadow WA).in_qty = 50, cost_per_unit = ฿14.00, lot_no = LOT-2, lot_index = 1, lot_seq_no = 2.
in_qty = 50, cost_per_unit = ฿14.00, total_cost = ฿700.00, average_cost_per_unit = (100×10.00 + 50×14.00)/150 = ฿11.33333.out_qty = 80. FIFO consumes LOT-1 first (lot_seq_no = 1).
out_qty = 80, cost_per_unit = ฿10.00, total_cost = ฿800.00, from_lot_no = LOT-1 (single row — fits within LOT-1's 100-unit balance). Per COST_CALC_001.LOT-1 remaining: 100 − 80 = 20; LOT-2 untouched at 50.20 × 10.00 + 50 × 14.00 = ฿900.00.out_qty = 30. FIFO consumes the remaining LOT-1 (20 units at ฿10.00) plus 10 units from LOT-2 (at ฿14.00).
out_qty = 20, cost_per_unit = ฿10.00, total_cost = ฿200.00, from_lot_no = LOT-1.out_qty = 10, cost_per_unit = ฿14.00, total_cost = ฿140.00, from_lot_no = LOT-2.LOT-2: 40 × 14.00 = ฿560.00.BU-B with calculation_method = average)Same product P-1 at LOC-A, same physical movements, but at a different business unit with WA configured:
in_qty = 100, cost_per_unit = ฿10.00.
in_qty = 100, cost_per_unit = ฿10.00, total_cost = ฿1,000.00, average_cost_per_unit = ฿10.00.in_qty = 50, cost_per_unit = ฿14.00.
in_qty = 50, cost_per_unit = ฿14.00, total_cost = ฿700.00, average_cost_per_unit = ฿11.33333 per COST_CALC_003.out_qty = 80. WA reads current average ฿11.33333 per COST_CALC_002.
out_qty = 80, cost_per_unit = ฿11.33333, total_cost = ฿906.67 (5dp intermediate, rounded display).฿11.33333 (outbound doesn't re-blend).(100 + 50 − 80) = 70 units × ฿11.33333 = ฿793.33.out_qty = 30.
out_qty = 30, cost_per_unit = ฿11.33333, total_cost = ฿340.00.40 × 11.33333 = ฿453.33.Same physical flow, different total COGS (FIFO produced ฿800 + ฿200 + ฿140 = ฿1,140; WA produced ฿906.67 + ฿340.00 = ฿1,246.67). The difference is only the cost-pick rule; the qty ledger is identical. In a rising-price scenario, FIFO COGS is lower and ending-inventory value higher — see calculation-methods.md § 5 for the full comparison.
Continuing from § 3.1 (FIFO): vendor concedes a ฿100.00 price reduction post-receipt on the LOT-2 purchase (originally 50 units at ฿14.00, total ฿700.00). The credit note posts as enum_transaction_type = credit_note_amount:
in_qty = 0, out_qty = 0, diff_amount = −฿100.00, transaction_type = credit_note_amount, lot_no = LOT-2.LOT-2 revalued per COST_CALC_005: new_cost_per_unit = (700.00 + (−100.00)) / 50 = ฿12.00.LOT-2 picks up cost_per_unit = ฿12.00 (not ฿14.00); previously consumed portions (the 10 units consumed in § 3.1's second outbound at ฿14.00) are not retroactively adjusted — the variance flows through diff_amount on the cost-layer ledger, the GL absorbs the difference via Dr AP / Cr Inventory ฿100.00.Rule IDs follow COST_AUTH_NNN. Authorization on the costing module is thin — the engine itself is a system service invoked by inventory transaction posts, and the per-movement authorization gates live on the inventory module (INV_AUTH_001–INV_AUTH_010) and the source-module documents (GRN, SR, count, credit-note). The rules below cover the costing-specific authorities: who configures the costing method, who sees / approves cost-impact reports, who reviews the COGS feed, and who signs off period-end valuation.
| Rule ID | Subject | Right | Constraint |
|---|---|---|---|
COST_AUTH_001 |
System Administrator | Configure tb_business_unit.calculation_method (FIFO ↔ average) |
Sysadmin-only. Mirrors INV_AUTH_008 for the inventory module's location / costing config. Change is blocked on a business unit with non-zero on-hand per COST_VAL_009. |
COST_AUTH_002 |
System Administrator | Configure enum_physical_count_costing_method (count-variance source) |
Sysadmin-only. Set via the business-unit config key/value surface. Change applies prospectively to subsequent count-variance posts. |
COST_AUTH_003 |
System Administrator | Update tb_product.standard_cost (reference / standard cost) |
Typically managed by a cost-control function within Finance, but the underlying configuration write is Sysadmin / product-config. Updates are prospective; the standard count-costing method picks the value in force at count-post time. |
COST_AUTH_004 |
Finance | Read and approve cost-impact reports (COGS by period / location / cost centre, valuation variance, FIFO vs WA shadow comparison) | Finance has full read on tb_inventory_transaction_cost_layer and tb_period_snapshot for valuation reporting. Mirrors INV_AUTH_005's read scope. Sensitive-field exports (per-vendor cost detail) require secondary approval per the audit pattern in good-receive-note/03-user-flow-audit-config. |
COST_AUTH_005 |
Finance | Approve credit-note-amount adjustments (the post-receipt cost revaluation) | Finance approves the tb_credit_note document; the inventory module fires INV_POST_007 which invokes the costing engine's COST_CALC_005 revaluation. Finance is not writing the revaluation directly — the credit-note approval is the trigger. |
COST_AUTH_006 |
Finance Manager | Sign off period-end valuation (the locked tb_period_snapshot.closing_total_cost) |
Period-end valuation sign-off is part of the Finance Manager's closed → locked advance per INV_AUTH_006. The cost-layer rollforward (COST_CALC_007) runs under system context per INV_AUTH_007 and produces the snapshot; Finance Manager's lock makes it audit-immutable. |
COST_AUTH_007 |
Inventory Controller | Read cost-pick previews on outbound documents being approved | Read-only. The Controller's adjustment-approval flow (inventory/03-user-flow-inventory-controller § 2.1 step 3) shows the FIFO / WA cost-pick preview before approving an outbound tb_stock_out. Same read scope on tb_inventory_transaction_cost_layer as inventory; no write. |
COST_AUTH_008 |
Auditor | Read-only access to the full cost-layer ledger, period snapshots, and configuration history | Mirrors INV_AUTH_009. The Auditor runs lot-cost-trace queries (the cost-flow chain from inbound layer through outbound consumption through period rollforward), period-snapshot reconciliation (cost-layer sum matches snapshot closing buckets), and FIFO-vs-WA-shadow drift queries (for tenants who want to monitor method-change impact). |
COST_AUTH_009 |
System / scheduled job | Run the cost-pick engine | The engine is a system service invoked by inventory transaction posts under the actor's RBAC. No separate engine-level RBAC — the RBAC on the parent transaction (e.g. GRN commit, SR approve, stock-in approve) is what permits the engine to write the cost-layer row. |
COST_AUTH_010 |
All transactional roles (Store Keeper, Receiver, Controller, Finance) | Cannot edit cost_per_unit or average_cost_per_unit on a posted cost-layer row |
Cost-layer rows are immutable per the inventory module's INV_POST_012. Costing corrections flow through (a) a credit-note-amount adjustment for vendor concessions, (b) a compensating stock-in / stock-out for clerical-error cost corrections, (c) the period-end rollforward for systemic revaluation. Direct cost-edit is not a path. |
The costing module's "posting" is the cost-layer row write performed by the engine at the moment an inventory transaction posts. The rules below describe what the engine writes on the cost-layer row for each enum_transaction_type, including the cost-pick logic, the average refresh, the lot-sequence assignment, and the diff_amount revaluation path. These rules are invoked from INV_POST_001–INV_POST_010 in the inventory module.
Rule IDs follow COST_POST_NNN. They extend (do not duplicate) the inventory posting rules.
| Rule ID | Trigger | Costing-engine effect |
|---|---|---|
COST_POST_001 |
Inbound to inventory-type location (enum_transaction_type ∈ {good_received_note, adjustment_in, transfer_in}) |
Engine picks the inbound cost (from the source document — GRN unit cost with extra-cost allocation; manual stock-in unit cost; transfer-in cost from the paired transfer_out). Writes cost-layer row: in_qty > 0, cost_per_unit = picked_cost, lot_no = current_lot_no, lot_index = 1 (or next if re-opening), lot_seq_no = max(existing) + 1 per COST_CALC_004. Refreshes average_cost_per_unit per COST_CALC_003 regardless of business-unit method (shadow WA is always maintained). |
COST_POST_002 |
Outbound from inventory-type location (enum_transaction_type ∈ {issue, adjustment_out, transfer_out}) |
Engine resolves the business unit's calculation_method. FIFO branch per COST_CALC_001: iterate available layers by lot_seq_no asc, writing one cost-layer row per consumed layer with out_qty, cost_per_unit = consumed_lot.cost_per_unit, from_lot_no = consumed_lot.lot_no. WA branch per COST_CALC_002: write a single cost-layer row with out_qty = total, cost_per_unit = current_average, from_lot_no left null or set to a sentinel "WA pool" if needed. Average not refreshed (outbound doesn't re-blend). |
COST_POST_003 |
Credit-note-amount adjustment (enum_transaction_type = credit_note_amount) |
Engine writes a cost-layer row with in_qty = 0, out_qty = 0, diff_amount = signed_amount, transaction_type = credit_note_amount, lot_no = originating_lot. Updates the originating lot's cost_per_unit per COST_CALC_005. Downstream FIFO outbound from the same lot picks up the revalued cost. The originating lot's lot_seq_no is preserved. |
COST_POST_004 |
Credit-note-quantity adjustment (enum_transaction_type = credit_note_quantity) |
Engine treats as outbound: writes a cost-layer row with out_qty = credit_qty, cost_per_unit = originating_lot.cost_per_unit, from_lot_no = originating_lot. The outbound is bound to the originating GRN's lot (not a free FIFO pick) because the credit-note semantically reverses that specific receipt. |
COST_POST_005 |
Receipt to direct-cost location (enum_transaction_type = good_received_note with tb_location.location_type = direct) |
Engine does not write a cost-layer row per COST_VAL_011. The receipt is expensed at receipt via the inventory transaction's GL fan-out (Dr Department Expense / Cr AP). Costing-engine returns "skipped — direct location". The product's average_cost_per_unit at this location is not updated (the location carries no balance). |
COST_POST_006 |
Receipt to consignment location (enum_transaction_type = good_received_note with tb_location.location_type = consignment) |
Engine writes a memo cost-layer row with in_qty = consignment_qty, cost_per_unit = consignment_unit_cost, flagged via dimension / info JSON as consignment. No AP debit, no Inventory credit at receipt per INV_POST_004. Consumption from this location later (consignment issue) posts Dr COGS / Cr AP at the consignment cost via INV_POST_005. |
COST_POST_007 |
Period close (enum_inventory_doc_type = close) |
System-scope post per COST_AUTH_009 / INV_AUTH_007. For each (period_id, location_id, product_id, lot_no, lot_index) key, compute closing_qty / closing_cost_per_unit / closing_total_cost per COST_CALC_006 and write to tb_period_snapshot. Write close_period cost-layer rows tying each closing lot's cost_per_unit to the period anchor. Locks the cost for the period. |
COST_POST_008 |
Period open (enum_inventory_doc_type = open) |
Chained with COST_POST_007. Writes open_period cost-layer rows for the next period at cost_per_unit = previous.closing_cost_per_unit, lot_seq_no preserved from the closed lot per COST_CALC_007. FIFO sequence carries across the period boundary. The next period's first outbound at (location, product) reads from the open_period row's cost. |
COST_POST_009 |
Count-variance posting (enum_inventory_doc_type ∈ {stock_in, stock_out} derived from a completed count) |
Engine resolves the count-costing source per enum_physical_count_costing_method and picks the cost per COST_CALC_008. Writes the cost-layer row through the normal COST_POST_001 (overage → adjustment_in) or COST_POST_002 (shortage → adjustment_out) path, with the picked cost passed through. The count's variance valuation source is preserved in the activity log for audit. |
COST_POST_010 |
Standard-cost change on tb_product.standard_cost |
No cost-layer effect. Standard cost is a reference value; updating it does not write any cost-layer row, does not change any prior average_cost_per_unit or any prior cost_per_unit. The change is prospective: subsequent count-variance posts (where enum_physical_count_costing_method = standard) read the new value; recipe baseline costs recompute on next access; no retroactive adjustment. |
State diagram for cost-layer write (degenerate, single-state, mirrors inventory):
[*] → cost-layer row written (with cost_per_unit + average_cost_per_unit set at post time)
→ (immutable: no edit; revaluation only via credit-note-amount diff_amount, or
period-end rollforward writing open_period / close_period anchor rows)
Costing has no per-document status lifecycle. Instead, the cost engine is invoked by inventory-affecting transactions. The table below maps each trigger transaction to its AVCO (Weighted Average) and FIFO effects. Sources: Test_case/System_Process/proc-03-cost-calculation.md (capture date 2026-04-26), plus the originating transaction's Test_case file for trigger details. "AVCO" and "FIFO" match the terminology used in Test_case; the Prisma schema uses average and fifo as the enum_calculation_method values.
| Trigger transaction | AVCO effect | FIFO effect | Notes / Source |
|---|---|---|---|
| GRN (stock-in) | Re-average: new_avg = (prior_qty × prior_avg + in_qty × in_cost) / (prior_qty + in_qty) |
New cost layer added; lot_seq_no incremented |
COST_CALC_003 / COST_CALC_004; Test_case/System_Process/tx-01-grn.md |
| CRN (credit-return) | Re-average; qty removed from on-hand | Oldest layer consumed at originating lot cost (credit_note_quantity) or lot revalued (credit_note_amount via diff_amount) |
COST_POST_003 / COST_POST_004; Test_case/System_Process/proc-03-cost-calculation.md P2 |
| Stock In adjustment | Re-average (same as GRN inbound path) | New cost layer added at manually entered unit cost | COST_CALC_003 / COST_CALC_004; Test_case/System_Process/tx-06-stock-in-adj.md |
| Stock Out adjustment | Cost held (outbound consumes at prevailing average; average is not updated by outbound) | Oldest cost layer consumed first; spans multiple lots if needed | COST_CALC_001 / COST_CALC_002; Test_case/System_Process/tx-07-stock-out-adj.md |
| Issues | Cost held (same as stock-out) | Oldest cost layer consumed | COST_POST_002; Test_case/System_Process/proc-03-cost-calculation.md P2 |
| Sales Consumption (SC) | Cost held | Oldest cost layer consumed | COST_POST_002; Test_case/System_Process/proc-03-cost-calculation.md P2 |
| Wastage Report (WR) | Cost held (WR approval generates a Stock Out adj) | Oldest cost layer consumed | COST_POST_002; Test_case/System_Process/INDEX.md Transaction × Process Matrix |
| Physical Count — variance exists | Re-average per direction (overage → re-average inbound; shortage → cost held outbound) | Overage → new cost layer; shortage → oldest layer consumed | COST_POST_009; COST_CALC_008; Test_case/System_Process/tx-08-physical-stocktake.md |
| Physical Count — no variance | NOT triggered | NOT triggered | No qty change; no cost-layer write; Test_case/System_Process/tx-08-physical-stocktake.md |
| Store Requisition (SR) | Cost pass-through (existing layer consumed, no re-average) | Cost pass-through (existing layer consumed, no new layer) | COST_POST_002, COST_XMOD_003. Per Test_case/System_Process/INDEX.md swim lane: "NOT triggered — goods move at existing cost". The cost engine IS invoked (COST_POST_002 cost-pick), but no AVCO re-average and no new FIFO layer. |
| Spot Check | PENDING | PENDING | Variance posting not yet implemented per Test_case/System_Process/INDEX.md — Spot Checks reach completed but do not post QOH / lot / cost changes |
| End Period Close | Locks period cost; no new recalc | Same | COST_POST_007 / COST_POST_008; period-snapshot rows written; cost locked — no backdated entries after close |
Diff legend: ✅ = confirmed alignment between live Test_case and BRD; NOT triggered = explicitly excluded from cost engine.
⚠️ SR cost-pick is pass-through — no AVCO re-average, no new FIFO layer. Store Requisition moves goods from an inventory location to a Direct or Consignment destination at the existing unit cost. The cost engine is invoked (per
COST_POST_002andCOST_XMOD_003) to pick the existing layer cost, but AVCO is not re-averaged and FIFO does not create a new layer — the outbound at the source location consumes the existing lot at its existingcost_per_unit. This is explicitly confirmed inTest_case/System_Process/proc-03-cost-calculation.mdP1 and the INDEX.md Process Execution Swim Lane ("NOT triggered — goods move at existing cost" refers to no recalculation, not to no engine invocation). BRD documentation that implies SR triggers AVCO/FIFO recalc is incorrect; the goods move at book value with no re-averaging or new layer creation. Source:Test_case/System_Process/proc-03-cost-calculation.md§ SR Exception — Why No Recalc (capture date 2026-04-26).
⚠️ Costing method locked at implementation — cannot be changed after go-live.
tb_business_unit.calculation_method(AVCO or FIFO) is configured per Business Unit at implementation and is permanently locked once inventory is live.COST_VAL_009blocks any change on a business unit with non-zero on-hand. A hotel group with multiple Business Units may have some on AVCO and others on FIFO, but each individual BU's method is immutable. Source:Test_case/System_Process/proc-03-cost-calculation.mdP4 Q1 (confirmed 2026-04-26);Test_case/System_Process/INDEX.md§ Scope and Constraints.
⚠️ Physical Count cost source is configurable, not fixed. Count-variance posts use
enum_physical_count_costing_method(standard,last,average,last_receiving) to select the unit cost for the variance — this is separate from the AVCO/FIFO cost-pick on regular transactions. carmen/docs does not describe this enum. Source:costing/01-data-model.md§ 2.6;COST_CALC_008.
⚠️ CRN lot cost reversal path TBC. Whether a CRN reverses the original GRN's lot cost or uses the current cost is confirmed as "TBC" in
Test_case/System_Process/proc-03-cost-calculation.mdP4 Q5. The Prisma engine has two distinct paths —credit_note_quantity(returns at originating lot cost) andcredit_note_amount(revalues viadiff_amount) — but the UX-level mapping to "which path for which CRN type" is not yet confirmed. Do not describe the CRN cost path as definitive until this TBC is resolved.
⚠️ Multi-currency cost storage TBC. Whether costs are stored in base currency only or with FX conversion preserved per-layer is TBC per
Test_case/System_Process/proc-03-cost-calculation.mdP4 Q6. All current examples assume a single currency (Thai Baht฿).
Rule IDs follow COST_XMOD_NNN.
| Rule ID | Related module | Rule |
|---|---|---|
COST_XMOD_001 |
inventory | The cost-layer ledger tb_inventory_transaction_cost_layer is owned by the inventory module; the costing engine is a behaviour layer that reads and writes the ledger at post time. Every cost-pick in this module is invoked from an INV_POST_001–INV_POST_010 event. There is no inventory write that bypasses the costing engine (except direct-cost-location receipts per COST_POST_005). |
COST_XMOD_002 |
good-receive-note | A GRN commit (saved → committed) invokes COST_POST_001 for each detail-line's inventory-type receipt. The cost_per_unit written to the cost-layer is the GRN line's unit cost after extra-cost allocation (freight, duty, clearance — allocated by tb_good_received_note_extra_cost mode manual / by_value / by_qty). Landed cost is what the costing engine sees, not the raw vendor unit price. |
COST_XMOD_003 |
store-requisition | An approved SR issue invokes COST_POST_002 for the outbound at the source location. The cost-pick produces the SR detail's costed unit (visible to the Inventory Controller as the cost-pick preview per COST_AUTH_007). For inter-location transfers, COST_VAL_010 enforces transfer_in.cost_per_unit = transfer_out.cost_per_unit. |
COST_XMOD_004 |
physical-count / spot-check | Count variance posts via COST_POST_009. The valuation source is enum_physical_count_costing_method per COST_CALC_008. The Inventory Controller commits the count-variance rollup per INV_XMOD_003 / INV_XMOD_004; the costing engine writes the cost-layer row with the picked count-variance cost. |
COST_XMOD_005 |
inventory-adjustment | Manual tb_stock_in / tb_stock_out adjustments invoke COST_POST_001 (inbound — found stock, count overage) or COST_POST_002 (outbound — breakage, expiry write-off, count shortage). The cost_per_unit is either provided by the document (for new-lot stock-in, the user specifies the cost) or picked by the engine (for outbound, FIFO or WA per the configured method). |
COST_XMOD_006 |
Credit note (vendor) | Vendor credit notes invoke COST_POST_003 (amount-only — revaluation) or COST_POST_004 (quantity-only — reverse from the originating lot). The credit-note-amount path is the canonical cost-revaluation mechanism in the system; cost-edits any other way are not permitted per COST_AUTH_010. |
COST_XMOD_007 |
recipe | Recipe costing reads tb_inventory_transaction_cost_layer.average_cost_per_unit (most recent at (location, product)) for the recipe's ingredient cost basis under WA. Under FIFO, the recipe-costing query is more nuanced: it reads either the running average shadow (for stable plate-cost reporting) or the per-lot consumption cost (for actual food-cost matching). The choice is a recipe-module configuration; the costing module exposes both. |
COST_XMOD_008 |
product | tb_product.standard_cost is the recipe baseline and the standard count-costing source per COST_CALC_009. Updates to standard_cost are prospective; no retroactive cost-layer adjustment per COST_POST_010. |
COST_XMOD_009 |
Finance / GL | Period-end valuation (the tb_period_snapshot.closing_total_cost sum) is the balance-sheet inventory asset figure for the closed period. Finance Manager locks this value per COST_AUTH_006 (chained with INV_AUTH_006). The GL Inventory control-account net change for the period must equal the closing_total_cost − opening_total_cost − (receipt_total_cost − issue_total_cost + adjustment_total_cost) arithmetic; mismatch flags an inventory-to-GL reconciliation variance per INV_XMOD_008. |
COST_XMOD_010 |
All movement-generating modules | The costing engine is the single chokepoint for cost-flow. Every cost-layer row is written by the engine; every COGS figure in the system traces back to an engine-written row. This is what makes the costing audit trail tamper-evident: there is one writer (the engine), one ledger (tb_inventory_transaction_cost_layer), and one method (the business unit's configured calculation_method). |
../carmen/docs/costing/enhanced-costing-engine.md — recipe / portion / dynamic-pricing engine built on top of the inventory cost-flow surface; covered here only at the cost-flow layer (Section 5 of costing/01-data-model notes the scope split).COST_CALC_001–COST_CALC_007) are the schema-grounded restatement of that sibling's algorithm pseudocode.tb_inventory_transaction_cost_layer, tb_inventory_transaction_detail, tb_period_snapshot, tb_business_unit.calculation_method, tb_product.standard_cost), enums (enum_calculation_method, enum_business_unit_config_key, enum_physical_count_costing_method, enum_transaction_type), and the divergences catalogue that Section 1 (single-method-per-business-unit) and Section 2 (validation gates) rely on.INV_VAL_001–INV_VAL_013 (validation gates the costing engine inherits), INV_CALC_001–INV_CALC_012 (the arithmetic shared with this page's Section 3), INV_POST_001–INV_POST_012 (the posting events the costing engine is invoked from), INV_AUTH_001–INV_AUTH_010 (the role gates above the engine), INV_XMOD_001–INV_XMOD_010 (cross-module wiring).cost_per_unit the costing engine sees at inbound (COST_XMOD_002).standard_cost reference cost and the deviation-limit tolerance bands.../carmen-turborepo-backend-v2/apps/ — the inventory-transaction service module is the implementation hook for these rules (the costing engine is a service injected into the transaction-write path; the cost-pick branch on calculation_method is the strategy resolver).