7d6e597389
- 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.
219 lines
12 KiB
Python
219 lines
12 KiB
Python
"""M3 smoke test — run creation, list/detail, quantity override (docs/30 §D.2, FR-MFG-08/09/18).
|
|
|
|
Still touches no stock: creating a run only copies and scales the template. Depends on the
|
|
template m2_templates.py leaves behind, so run that first:
|
|
|
|
python Backend/smoke/m2_templates.py
|
|
python Backend/smoke/m3_runs.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
|
|
from smoke_common import bootstrap
|
|
|
|
TEMPLATE_CODE = "SMOKE-PT-M2"
|
|
TARGET_QTY = 50
|
|
|
|
|
|
def find_template(c):
|
|
listed = c.get(f"/production-templates?q={TEMPLATE_CODE}")
|
|
row = next((t for t in listed.body["items"] if t["code"] == TEMPLATE_CODE), None)
|
|
if not row:
|
|
sys.exit(f"FATAL: template {TEMPLATE_CODE} not found — run m2_templates.py first.")
|
|
return row["templateId"]
|
|
|
|
|
|
def main():
|
|
c, chk, args = bootstrap(__doc__)
|
|
print(f"API {args.api}")
|
|
|
|
tid = find_template(c)
|
|
tpl = c.get(f"/production-templates/{tid}").body
|
|
warehouse = c.get("/warehouses?pageSize=1").body["items"][0]["warehouseId"]
|
|
# Captured before creating so the script stays re-runnable — a previous run of this
|
|
# script leaves its own InProgress run behind for M4.
|
|
runs_before = next(t for t in c.get(f"/production-templates?q={TEMPLATE_CODE}").body["items"]
|
|
if t["templateId"] == tid)["activeRunCount"]
|
|
print(f"template={tid} warehouse={warehouse} targetQty={TARGET_QTY} activeRunsBefore={runs_before}")
|
|
|
|
terminal_stage = next(s for s in tpl["stages"] if s["name"] == "Assemble & QA")
|
|
terminal_qpb = terminal_stage["outputs"][0]["qtyPerBatch"]
|
|
expected_scale = TARGET_QTY / terminal_qpb
|
|
|
|
# ------------------------------------------------------------------ create
|
|
chk.section("1. Create a run (FR-MFG-08: scale, copy, number)")
|
|
created = c.post("/production-runs", {
|
|
"templateId": tid, "targetQty": TARGET_QTY, "warehouseId": warehouse,
|
|
})
|
|
if not chk.status("POST /production-runs", created, 201):
|
|
return chk.finish("M3")
|
|
|
|
run = created.body
|
|
rid = run["runId"]
|
|
chk.check("ETag header present", created.etag is not None, True)
|
|
chk.check("docNo uses the PRD sequence", run["docNo"].startswith("PRD-"), True)
|
|
chk.check("run starts InProgress", run["status"], "InProgress")
|
|
chk.check("reworkCount starts at 0", run["reworkCount"], 0)
|
|
chk.check("scaleFactor computed", float(run["scaleFactor"]), float(expected_scale))
|
|
chk.check("3 stages copied", len(run["stages"]), 3)
|
|
chk.check("2 edges copied", len(run["edges"]), 2)
|
|
|
|
# ------------------------------------------------------- copy-on-create
|
|
chk.section("2. Copy-on-create carries display fields and fieldDefs (FR-MFG-06)")
|
|
stages = {s["name"]: s for s in run["stages"]}
|
|
chk.check("stage names copied", sorted(stages), ["Assemble & QA", "Cut frame", "Prep cushions"])
|
|
|
|
cut = stages["Cut frame"]
|
|
chk.check("roleLabel copied", cut["roleLabel"], "Carpentry")
|
|
chk.check("estimatedMinutes copied", cut["estimatedMinutes"], 60)
|
|
chk.check("posX copied", float(cut["posX"]), 80.0)
|
|
chk.check("fieldDefs copied", cut["fieldDefs"][0]["key"], "moisture_ok")
|
|
chk.check("fieldValues empty before complete", cut["fieldValues"], None)
|
|
chk.check("templateStageId links back", cut["templateStageId"] is not None, True)
|
|
|
|
# -------------------------------------------------------------- readiness
|
|
chk.section("3. Entry stages Ready, others Waiting (FR-MFG-09)")
|
|
chk.check("Cut frame is an entry", cut["isEntry"], True)
|
|
chk.check("Cut frame Ready", cut["status"], "Ready")
|
|
chk.check("Prep cushions Ready", stages["Prep cushions"]["status"], "Ready")
|
|
|
|
asm = stages["Assemble & QA"]
|
|
chk.check("Assemble is terminal", asm["isTerminal"], True)
|
|
chk.check("Assemble is not an entry", asm["isEntry"], False)
|
|
chk.check("Assemble Waiting", asm["status"], "Waiting")
|
|
chk.check("no stage is terminal but Cut/Prep",
|
|
[s["name"] for s in run["stages"] if s["isTerminal"]], ["Assemble & QA"])
|
|
|
|
# ----------------------------------------------------------------- scaling
|
|
chk.section("4. Every quantity scaled by the factor (FR-MFG-08)")
|
|
tpl_stages = {s["name"]: s for s in tpl["stages"]}
|
|
ok = True
|
|
for name, rs in stages.items():
|
|
ts = tpl_stages[name]
|
|
for ti, ri in zip(ts["inputs"], rs["inputs"]):
|
|
want = round(float(ti["qtyPerBatch"]) * expected_scale, 4)
|
|
if float(ri["plannedQty"]) != want:
|
|
ok = False
|
|
print(f" input mismatch on {name}: {ri['plannedQty']} != {want}")
|
|
for to, ro in zip(ts["outputs"], rs["outputs"]):
|
|
want = round(float(to["qtyPerBatch"]) * expected_scale, 4)
|
|
if float(ro["plannedQty"]) != want:
|
|
ok = False
|
|
print(f" output mismatch on {name}: {ro['plannedQty']} != {want}")
|
|
chk.check("all planned quantities == qtyPerBatch x scaleFactor", ok, True)
|
|
chk.check("Cut frame input scaled (8 x 50)", float(cut["inputs"][0]["plannedQty"]), 400.0)
|
|
chk.check("terminal output scaled to the target", float(asm["outputs"][0]["plannedQty"]), float(TARGET_QTY))
|
|
|
|
chk.check("consumed/delivered start at zero",
|
|
all(float(i["consumedQty"]) == 0 and float(i["deliveredQty"]) == 0
|
|
for s in run["stages"] for i in s["inputs"]), True)
|
|
chk.check("cost pool starts empty", float(run["costPool"]["net"]), 0.0)
|
|
|
|
# Upstream inputs must point at the run's own copied outputs, not the template's.
|
|
run_output_ids = {o["runOutputId"] for s in run["stages"] for o in s["outputs"]}
|
|
chk.check("upstream inputs rewired to run outputs",
|
|
all(i["fromRunOutputId"] in run_output_ids
|
|
for i in asm["inputs"] if i["source"] == "Upstream"), True)
|
|
|
|
# -------------------------------------------------------------------- list
|
|
chk.section("5. Run board projection (FR-MFG-18)")
|
|
listed = c.get(f"/production-runs?q={run['docNo']}")
|
|
chk.status("GET /production-runs", listed, 200)
|
|
row = next((r for r in listed.body["items"] if r["runId"] == rid), None)
|
|
chk.check("run appears on the board", row is not None, True)
|
|
if row:
|
|
chk.check("stageSummary counts match",
|
|
row["stageSummary"], {"waiting": 1, "ready": 2, "inProgress": 0, "done": 0, "approved": 0})
|
|
chk.check("templateName joined", row["templateName"], tpl["name"])
|
|
chk.check("finished item surfaced", row["finishedItemId"], asm["outputs"][0]["itemId"])
|
|
chk.check("finished item name joined", row["finishedItemName"] is not None, True)
|
|
|
|
chk.check("filter by status=InProgress finds it",
|
|
any(r["runId"] == rid for r in c.get("/production-runs?status=InProgress&pageSize=200").body["items"]), True)
|
|
chk.check("filter by status=Completed excludes it",
|
|
any(r["runId"] == rid for r in c.get("/production-runs?status=Completed&pageSize=200").body["items"]), False)
|
|
chk.check("filter by templateId finds it",
|
|
any(r["runId"] == rid for r in c.get(f"/production-runs?templateId={tid}&pageSize=200").body["items"]), True)
|
|
|
|
# -------------------------------------------------------------- edit-lock
|
|
chk.section("6. Template edit-lock now that a run is InProgress (FR-MFG-06)")
|
|
head = c.get(f"/production-templates/{tid}")
|
|
locked = c.put(f"/production-templates/{tid}", {
|
|
"code": tpl["code"], "name": tpl["name"], "description": tpl.get("description"),
|
|
"stages": [
|
|
{"key": s["key"], "name": s["name"], "roleLabel": s.get("roleLabel"),
|
|
"estimatedMinutes": s["estimatedMinutes"], "posX": s["posX"], "posY": s["posY"],
|
|
"fieldDefs": s["fieldDefs"],
|
|
"inputs": [{"source": i["source"], "itemId": i.get("itemId"),
|
|
"fromOutputKey": i.get("fromOutputKey"), "uomId": i["uomId"],
|
|
"qtyPerBatch": i["qtyPerBatch"]} for i in s["inputs"]],
|
|
"outputs": [{"key": o["key"], "itemId": o.get("itemId"), "name": o["name"],
|
|
"uomId": o["uomId"], "qtyPerBatch": o["qtyPerBatch"]} for o in s["outputs"]]}
|
|
for s in tpl["stages"]
|
|
],
|
|
"edges": [{"parentKey": e["parentKey"], "childKey": e["childKey"]} for e in tpl["edges"]],
|
|
}, if_match=head.etag)
|
|
chk.status("PUT template while a run is InProgress", locked, 409, "TEMPLATE_IN_USE")
|
|
|
|
# Deactivating must stay allowed — it only blocks NEW runs (FR-MFG-01).
|
|
chk.status("PATCH status while a run is InProgress is still allowed",
|
|
c.patch(f"/production-templates/{tid}/status", {"status": "Inactive"}), 204)
|
|
chk.status("run creation from an Inactive template",
|
|
c.post("/production-runs", {"templateId": tid, "targetQty": 5, "warehouseId": warehouse}),
|
|
422, "TEMPLATE_INACTIVE")
|
|
c.patch(f"/production-templates/{tid}/status", {"status": "Active"})
|
|
|
|
chk.check("activeRunCount incremented by the new run",
|
|
next(t for t in c.get(f"/production-templates?q={TEMPLATE_CODE}").body["items"]
|
|
if t["templateId"] == tid)["activeRunCount"], runs_before + 1)
|
|
|
|
# ------------------------------------------------------------- quantities
|
|
chk.section("7. Per-run quantity override (FR-MFG-08)")
|
|
cut_input = cut["inputs"][0]
|
|
edited = c.put(f"/production-runs/{rid}/stages/{cut['runStageId']}/quantities",
|
|
{"inputs": [{"id": cut_input["runInputId"], "plannedQty": 420}], "outputs": []})
|
|
chk.status("PUT quantities on a Ready stage", edited, 200)
|
|
if edited.status == 200:
|
|
chk.check("plannedQty updated", float(edited.body["inputs"][0]["plannedQty"]), 420.0)
|
|
chk.check("stage still Ready (no upstream inputs)", edited.body["status"], "Ready")
|
|
|
|
chk.status("PUT quantities naming another stage's input",
|
|
c.put(f"/production-runs/{rid}/stages/{cut['runStageId']}/quantities",
|
|
{"inputs": [{"id": asm["inputs"][0]["runInputId"], "plannedQty": 9}], "outputs": []}),
|
|
422)
|
|
|
|
# Raising a Waiting stage's upstream input must keep it Waiting, and the edit must be
|
|
# rejected outright once a stage has started (covered in M4 once we can start one).
|
|
asm_up = next(i for i in asm["inputs"] if i["source"] == "Upstream")
|
|
bumped = c.put(f"/production-runs/{rid}/stages/{asm['runStageId']}/quantities",
|
|
{"inputs": [{"id": asm_up["runInputId"], "plannedQty": 60}], "outputs": []})
|
|
chk.status("PUT quantities on a Waiting stage", bumped, 200)
|
|
if bumped.status == 200:
|
|
chk.check("stage stays Waiting (nothing delivered)", bumped.body["status"], "Waiting")
|
|
|
|
chk.status("PUT quantities on a nonexistent stage",
|
|
c.put(f"/production-runs/{rid}/stages/999999/quantities", {"inputs": [], "outputs": []}), 404)
|
|
|
|
# ------------------------------------------------------------------ events
|
|
chk.section("8. Event history records the edits")
|
|
detail = c.get(f"/production-runs/{rid}")
|
|
chk.status("GET /production-runs/{id}", detail, 200)
|
|
events = detail.body["events"]
|
|
# Two, not four: only the two successful edits are recorded. The 422 (wrong stage) and
|
|
# the 404 both throw inside ExecuteInTransactionAsync, so their event write rolls back
|
|
# with the rest of the change — history never shows an edit that did not happen.
|
|
chk.check("only successful edits are logged",
|
|
sum(1 for e in events if e["eventType"] == "QuantityEdit"), 2)
|
|
chk.check("event payload captured",
|
|
events[0]["payload"] is not None if events else False, True)
|
|
chk.check("event carries an actor", events[0]["userId"] > 0 if events else False, True)
|
|
|
|
print(f"\nleft run {rid} ({run['docNo']}) InProgress for the M4 smoke test")
|
|
return chk.finish("M3")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|