Files
ImanThiyanga 15ddac178c Refactor production and stock tests to remove UOM dependency
- Updated production.spec.ts, stock-adjustments.spec.ts, and stock-transfers.spec.ts to eliminate UOM references in API seeder and test cases.
- Adjusted ApiSeeder methods to remove UOM parameters from stock receiving and production template creation.
- Revised documentation to reflect changes in UOM handling, emphasizing that stock is counted in base UOM only.
- Introduced new enums for MeasureUnit and StageQtyUnit to clarify content size and stage input quantities.
- Implemented ItemContent service to validate and normalize content sizes.
- Updated smoke tests to validate production stage inputs expressed in content units, ensuring correct consumption calculations.
- Modified frontend UOM label handling to reflect the removal of per-line UOMs in document lines.
2026-08-11 11:32:40 +05:30

270 lines
14 KiB
Python

"""M5 smoke test — terminal approve = production receipt + cost pool (FR-MFG-13).
Three things are proven here, the last two being the ones most likely to be silently wrong:
A. A terminal approve creates a finished-goods layer costed at costPool / goodQty,
posts a PRDR inbound ledger entry, and completes the run.
B. Sum-of-ledger reconciliation: PRDR.value == sum(PRDI.value) - sum(PRDL.value) EXACTLY.
This is what the `valueOverride` parameter added to PostLedgerAsync exists for — the
6 dp unit cost, multiplied out over 100+ units, drifts past the ledger's 4 dp tick.
C. A batch/serial-tracked finished item is refused rather than silently receiving
untracked stock (docs/30 defines no batch creation on receipt).
Depends on the run m4_stage_actions.py leaves with its terminal stage InProgress:
python Backend/smoke/m4_stage_actions.py
python Backend/smoke/m5_receipt.py
"""
from __future__ import annotations
import json
import os
import sys
from smoke_common import bootstrap, drain_stock, seed_costed_stock
STATE_FILE = "m4_state.json"
WAREHOUSE_CODE = "SMOKE-PRD"
SCRAP = 2
# Part B — the rounding case. The drift only appears when costPool / goodQty needs more than
# 6 decimal places, so the numbers are chosen so it does:
# consumed = 3.5 x 300 = 1050 base units at 1.234567 -> pool = 1296.29535
# unitCost = round(1296.29535 / 300, 6) = 4.320985 (from 4.3209845, away from zero)
# naive qty x unitCost = 300 x 4.320985 = 1296.2955 != round(pool, 4) = 1296.2954
# Without valueOverride the ledger would carry 1296.2955 and stop reconciling to the pool.
BIG_TEMPLATE_CODE = "SMOKE-PT-M5B"
BIG_TARGET = 300
BIG_QTY_PER_BATCH = 3.5
BIG_UNIT_COST = 1.234567
def on_hand(c, item, wh):
return float(c.get(f"/stock/on-hand?itemId={item}&warehouseId={wh}").body["onHand"])
def ledger_sum(c, run_id, source):
rows = c.get(f"/stock/ledger?sourceDocType={source}&sourceDocId={run_id}&pageSize=200").body["items"]
return sum(float(r["value"]) for r in rows), rows
def main():
c, chk, args = bootstrap(__doc__)
print(f"API {args.api}")
if not os.path.exists(STATE_FILE):
sys.exit(f"FATAL: {STATE_FILE} missing — run m4_stage_actions.py first.")
state = json.load(open(STATE_FILE))
rid, wh = state["runId"], state["warehouseId"]
asm_id, finished = state["assembleStageId"], state["finishedItemId"]
detail = c.get(f"/production-runs/{rid}").body
asm = next((s for s in detail["stages"] if s["runStageId"] == asm_id), None)
if asm is None:
sys.exit(f"FATAL: stage {asm_id} not on run {rid}.")
if asm["status"] != "InProgress":
sys.exit(f"FATAL: expected the terminal stage InProgress, found {asm['status']} — re-run m4.")
pool_before = float(detail["costPool"]["net"])
fin_before = on_hand(c, finished, wh)
print(f"run={rid} costPool={pool_before} finishedOnHand={fin_before}")
# ------------------------------------------------- complete the terminal
chk.section("A1. Complete the terminal stage with scrap (FR-MFG-11)")
out_id = asm["outputs"][0]["runOutputId"]
scrap_reason = next(r["reasonCodeId"] for r in c.get("/reason-codes?context=Production&pageSize=50").body["items"]
if r["code"] == "PRD-SCRAP")
done = c.post(f"/production-runs/{rid}/stages/{asm_id}/complete", {
"outputs": [{"runOutputId": out_id, "producedQty": 50, "scrappedQty": SCRAP,
"scrapReasonCodeId": scrap_reason}],
})
chk.status("complete the terminal stage", done, 200)
if done.status != 200:
return chk.finish("M5")
chk.check("stage Done", done.body["status"], "Done")
chk.check("scrap recorded", float(done.body["outputs"][0]["scrappedQty"]), float(SCRAP))
chk.check("scrap reason recorded", done.body["outputs"][0]["scrapReasonCodeId"], scrap_reason)
# Scrap is absorbed into the pool, not written off (FR-MFG-11): no extra ledger row.
chk.check("scrap posts no ledger entry",
float(c.get(f"/production-runs/{rid}").body["costPool"]["net"]), pool_before)
# ------------------------------------------------------------- the receipt
chk.section("A2. Terminal approve posts the receipt and completes the run (FR-MFG-13)")
good = 50 - SCRAP
approved = c.post(f"/production-runs/{rid}/stages/{asm_id}/approve")
chk.status("approve the terminal stage", approved, 200)
if approved.status != 200:
return chk.finish("M5")
chk.check("stage Approved", approved.body["status"], "Approved")
chk.check("run Completed", approved.body["runStatus"], "Completed")
chk.check("no WIP transfers from a terminal stage", approved.body["transfers"], [])
receipt = approved.body["receipt"]
chk.check("receipt returned", receipt is not None, True)
if receipt:
chk.check("receipt is for the finished item", receipt["itemId"], finished)
chk.check("received the good quantity (produced - scrapped)",
float(receipt["qtyReceived"]), float(good))
chk.check("receipt warehouse is the run warehouse", receipt["warehouseId"], wh)
chk.check("layer created", receipt["layerId"] > 0, True)
pool = approved.body["costPool"]
chk.check("cost pool reported", pool is not None, True)
if pool and receipt:
chk.check("pool net = consumed - returned",
round(float(pool["net"]), 4),
round(float(pool["consumed"]) - float(pool["returned"]), 4))
chk.check("pool net matches the pool before approval", float(pool["net"]), pool_before)
expected_unit = round(float(pool["net"]) / float(good), 6)
chk.check("unitCost = costPool / goodQty", float(receipt["unitCost"]), expected_unit)
chk.check("receipt value = the cost pool exactly",
float(receipt["value"]), round(float(pool["net"]), 4))
chk.check("finished on-hand rose by the good quantity", on_hand(c, finished, wh), fin_before + good)
completed = c.get(f"/production-runs/{rid}").body
chk.check("completedAt stamped", completed["completedAt"] is not None, True)
chk.check("run status persisted as Completed", completed["status"], "Completed")
# ------------------------------------------------- ledger reconciliation
chk.section("A3. Ledger reconciles to the cost pool")
issued, prdi = ledger_sum(c, rid, "PRDI")
returned, _ = ledger_sum(c, rid, "PRDL")
received, prdr = ledger_sum(c, rid, "PRDR")
chk.check("one PRDR row", len(prdr), 1)
if prdr:
chk.check("PRDR direction is In", prdr[0]["direction"], "In")
chk.check("sum(PRDI) - sum(PRDL) == sum(PRDR) [the core invariant]",
round(issued - returned, 4), round(received, 4))
print(f" issued={issued} returned={returned} received={received}")
# --------------------------------------------------- closed-run guards
chk.section("A4. A completed run is closed to further action")
chk.status("approve again", c.post(f"/production-runs/{rid}/stages/{asm_id}/approve"), 409)
chk.status("start a stage on a completed run",
c.post(f"/production-runs/{rid}/stages/{state['assembleStageId']}/start"), 409)
chk.status("edit quantities on a completed run",
c.put(f"/production-runs/{rid}/stages/{asm_id}/quantities", {"inputs": [], "outputs": []}), 409)
# ------------------------------------ the rounding case (>= 100 units)
chk.section("B. Rounding: 300 units, where a 6 dp unit cost drifts past the 4 dp ledger tick")
raw = state["rawItemId"]
raw_uom = next(i["baseUomId"] for i in c.get("/items?pageSize=200").body["items"]
if i["itemId"] == raw)
finished_b = state["finishedItemId"]
# A fresh single-stage template: a pack-counted input, so no content division muddies
# the arithmetic; qtyPerBatch 3.5 so consumed is NOT a multiple of the target (which is what
# forces pool / goodQty to repeat).
payload_b = {
"code": BIG_TEMPLATE_CODE, "name": "Smoke M5 rounding line",
"stages": [{
"key": "tmp-b", "name": "Mix", "estimatedMinutes": 5, "posX": 0, "posY": 0,
"fieldDefs": [],
"inputs": [{"source": "Stock", "itemId": raw,
"qtyPerBatch": BIG_QTY_PER_BATCH}],
"outputs": [{"key": "tmp-bo", "name": "Mixed", "itemId": finished_b,
"qtyPerBatch": 1}],
}],
"edges": [],
}
existing_b = next((t for t in c.get(f"/production-templates?q={BIG_TEMPLATE_CODE}").body["items"]
if t["code"] == BIG_TEMPLATE_CODE), None)
if existing_b:
hb = c.get(f"/production-templates/{existing_b['templateId']}")
rb = c.put(f"/production-templates/{existing_b['templateId']}", payload_b, if_match=hb.etag)
tid = existing_b["templateId"] if rb.status in (200, 409) else None
else:
rb = c.post("/production-templates", payload_b)
tid = rb.body["templateId"] if rb.status == 201 else None
if tid is None:
chk.check("create the rounding template", False, True)
else:
# Drain first, then seed at the exact unit cost the arithmetic above assumes. Without
# the drain, FIFO would consume whatever earlier scripts left behind (at their costs)
# and the pool would not match the figures this section reasons about — the assertions
# would still "pass" while testing something else entirely.
drain_stock(c, wh)
seed_costed_stock(c, wh, [(raw, 5000, BIG_UNIT_COST)])
big = c.post("/production-runs", {"templateId": tid, "targetQty": BIG_TARGET, "warehouseId": wh})
if big.status != 201:
chk.check(f"create the {BIG_TARGET}-unit run", big.status, 201)
else:
brid = big.body["runId"]
bstage = big.body["stages"][0]
bout = bstage["outputs"][0]["runOutputId"]
s = c.post(f"/production-runs/{brid}/stages/{bstage['runStageId']}/start")
chk.status("start", s, 200)
d = c.post(f"/production-runs/{brid}/stages/{bstage['runStageId']}/complete",
{"outputs": [{"runOutputId": bout, "producedQty": BIG_TARGET, "scrappedQty": 0}]})
chk.status("complete", d, 200)
a = c.post(f"/production-runs/{brid}/stages/{bstage['runStageId']}/approve")
chk.status("approve (single stage is the terminal)", a, 200)
if a.status == 200:
bpool = float(a.body["costPool"]["net"])
brec = a.body["receipt"]
unit = float(brec["unitCost"])
naive = round(unit * BIG_TARGET, 4)
chk.check("cost pool is non-zero", bpool > 0, True)
chk.check("receipt value == cost pool exactly", float(brec["value"]), round(bpool, 4))
print(f" pool={bpool} unitCost={unit} naive qty*unit={naive}")
# This is the assertion that justifies the valueOverride parameter existing.
# If the naive product ever equals the pool, the fixture stopped exercising
# the rounding path and the test has quietly gone blind.
chk.check("naive qty x unitCost really would have drifted (fixture still valid)",
naive != round(bpool, 4), True)
bissued, _ = ledger_sum(c, brid, "PRDI")
breturned, _ = ledger_sum(c, brid, "PRDL")
breceived, _ = ledger_sum(c, brid, "PRDR")
chk.check("sum(PRDI) - sum(PRDL) == sum(PRDR) at 300 units",
round(bissued - breturned, 4), round(breceived, 4))
# ------------------------------------------- tracked finished goods
chk.section("C. A batch/serial-tracked finished item is refused")
tracked = next((i for i in c.get("/items?pageSize=200&status=Active").body["items"]
if i.get("trackingMode") in ("Batch", "Serial")), None)
if tracked is None:
chk.check("skipped: no batch/serial-tracked item in the database", True, True)
print(" NOTE: the TrackingMode guard in PostReceiptAsync is unexercised here.")
else:
print(f" using tracked item {tracked['itemId']} ({tracked['trackingMode']})")
payload = {
"code": "SMOKE-PT-TRACKED", "name": "Tracked finished good",
"stages": [{
"key": "tmp-one", "name": "Make", "estimatedMinutes": 1, "posX": 0, "posY": 0,
"fieldDefs": [], "inputs": [],
"outputs": [{"key": "tmp-o", "name": "Tracked", "itemId": tracked["itemId"],
"qtyPerBatch": 1}],
}],
"edges": [],
}
made = c.post("/production-templates", payload)
ttid = made.body["templateId"] if made.status == 201 else next(
(t["templateId"] for t in c.get("/production-templates?q=SMOKE-PT-TRACKED").body["items"]
if t["code"] == "SMOKE-PT-TRACKED"), None)
if ttid:
tr = c.post("/production-runs", {"templateId": ttid, "targetQty": 1, "warehouseId": wh})
if tr.status == 201:
st = tr.body["stages"][0]["runStageId"]
to = tr.body["stages"][0]["outputs"][0]["runOutputId"]
c.post(f"/production-runs/{tr.body['runId']}/stages/{st}/start")
c.post(f"/production-runs/{tr.body['runId']}/stages/{st}/complete",
{"outputs": [{"runOutputId": to, "producedQty": 1, "scrappedQty": 0}]})
chk.status("approve a tracked finished good",
c.post(f"/production-runs/{tr.body['runId']}/stages/{st}/approve"), 422)
return chk.finish("M5")
if __name__ == "__main__":
sys.exit(main())