"""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())