feat: add production templates API and documentation for manufacturing phase 2

- Implemented CRUD operations for production templates, including listing, retrieving, creating, updating, and deactivating templates.
- Introduced a new API contract for production runs, detailing the lifecycle from creation to completion, including handling of stock inputs and outputs.
- Documented the architecture, requirements, entity model, and API contract for the manufacturing phase 2, ensuring clarity on the production process and its integration with existing systems.
This commit is contained in:
2026-07-31 10:22:40 +05:30
parent 415ac94ab2
commit 7d6e597389
80 changed files with 11037 additions and 886 deletions
+269
View File
@@ -0,0 +1,269 @@
"""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: base-UOM input so no conversion muddies the arithmetic,
# qtyPerBatch 3.5 so the consumed quantity 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, "uomId": raw_uom,
"qtyPerBatch": BIG_QTY_PER_BATCH}],
"outputs": [{"key": "tmp-bo", "name": "Mixed", "itemId": finished_b,
"uomId": raw_uom, "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, raw_uom, 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"],
"uomId": tracked["baseUomId"], "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())