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:
Binary file not shown.
@@ -0,0 +1,351 @@
|
||||
"""M2 smoke test — production template CRUD + graph validation (docs/30 §D.1, FR-MFG-01..07).
|
||||
|
||||
Touches no stock: templates only. Run with the API and AuthHex up:
|
||||
|
||||
python Backend/smoke/m2_templates.py
|
||||
|
||||
Shape under test is a diamond, which exercises multiple entries converging on one
|
||||
terminal *and* a stage with two parents:
|
||||
|
||||
Cut ─┐
|
||||
├─▶ Assemble (terminal, real item)
|
||||
Prep ─┘
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from smoke_common import bootstrap
|
||||
|
||||
TEMPLATE_CODE = "SMOKE-PT-M2"
|
||||
|
||||
|
||||
def pick_fixtures(c, chk):
|
||||
"""Grab two active items and a UOM to build a realistic template from."""
|
||||
items = c.get("/items?pageSize=5&status=Active").body["items"]
|
||||
if len(items) < 2:
|
||||
sys.exit("FATAL: need at least 2 active items in the database.")
|
||||
uoms = c.get("/uoms?pageSize=5").body["items"]
|
||||
if not uoms:
|
||||
sys.exit("FATAL: need at least 1 UOM in the database.")
|
||||
return items[0]["itemId"], items[1]["itemId"], uoms[0]["uomId"]
|
||||
|
||||
|
||||
def diamond(raw_item, finished_item, uom):
|
||||
"""A valid graph: two entry stages feeding one terminal stage."""
|
||||
return {
|
||||
"code": TEMPLATE_CODE,
|
||||
"name": "Smoke chair line",
|
||||
"description": "Created by m2_templates.py",
|
||||
"stages": [
|
||||
{
|
||||
"key": "tmp-cut", "name": "Cut frame", "roleLabel": "Carpentry",
|
||||
"estimatedMinutes": 60, "posX": 80, "posY": 120,
|
||||
"fieldDefs": [{"key": "moisture_ok", "label": "Moisture check",
|
||||
"type": "Checkbox", "required": True}],
|
||||
"inputs": [{"source": "Stock", "itemId": raw_item, "uomId": uom, "qtyPerBatch": 8}],
|
||||
"outputs": [{"key": "tmp-frame", "name": "Frame set", "uomId": uom, "qtyPerBatch": 1}],
|
||||
},
|
||||
{
|
||||
"key": "tmp-prep", "name": "Prep cushions", "roleLabel": "Upholstery",
|
||||
"estimatedMinutes": 30, "posX": 80, "posY": 320, "fieldDefs": [],
|
||||
"inputs": [{"source": "Stock", "itemId": raw_item, "uomId": uom, "qtyPerBatch": 2}],
|
||||
"outputs": [{"key": "tmp-cushion", "name": "Cushion set", "uomId": uom, "qtyPerBatch": 1}],
|
||||
},
|
||||
{
|
||||
"key": "tmp-asm", "name": "Assemble & QA", "roleLabel": "QA",
|
||||
"estimatedMinutes": 45, "posX": 560, "posY": 200, "fieldDefs": [],
|
||||
"inputs": [
|
||||
{"source": "Upstream", "fromOutputKey": "tmp-frame", "uomId": uom, "qtyPerBatch": 1},
|
||||
{"source": "Upstream", "fromOutputKey": "tmp-cushion", "uomId": uom, "qtyPerBatch": 1},
|
||||
],
|
||||
# Terminal output must name the finished item (FR-MFG-05).
|
||||
"outputs": [{"key": "tmp-chair", "name": "Chair", "itemId": finished_item,
|
||||
"uomId": uom, "qtyPerBatch": 1}],
|
||||
},
|
||||
],
|
||||
"edges": [
|
||||
{"parentKey": "tmp-cut", "childKey": "tmp-asm"},
|
||||
{"parentKey": "tmp-prep", "childKey": "tmp-asm"},
|
||||
],
|
||||
# Canvas-only decoration. No graph semantics at all — the validator never sees these,
|
||||
# which is exactly what the "annotations do not affect the graph" assertion checks.
|
||||
"annotations": [
|
||||
{"kind": "box", "posX": 40, "posY": 60, "width": 400, "height": 320,
|
||||
"label": "Sub-assembly", "rotation": None},
|
||||
{"kind": "line", "posX": 480, "posY": 40, "width": 220, "height": 4,
|
||||
"label": "Phase 2", "rotation": 90},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def cleanup(c):
|
||||
"""
|
||||
Find any template this script left behind, and clear the edit-lock if later scripts
|
||||
started runs against it.
|
||||
|
||||
Without this the script is single-use: FR-MFG-06 refuses a PUT while any run of the
|
||||
template is InProgress, so a second execution would fail its very first assertion with
|
||||
409 TEMPLATE_IN_USE. Cancelling those runs is safe — they belong to the smoke suite.
|
||||
"""
|
||||
existing = c.get(f"/production-templates?q={TEMPLATE_CODE}").body["items"]
|
||||
template = next((t for t in existing if t["code"] == TEMPLATE_CODE), None)
|
||||
if template is None:
|
||||
return None
|
||||
|
||||
tid = template["templateId"]
|
||||
if template["activeRunCount"] > 0:
|
||||
reason = next((r["reasonCodeId"] for r in
|
||||
c.get("/reason-codes?context=Production&pageSize=50").body["items"]
|
||||
if r["code"] == "PRD-CANCEL"), None)
|
||||
blocking = [r for r in c.get(f"/production-runs?templateId={tid}&status=InProgress&pageSize=200").body["items"]]
|
||||
for run in blocking:
|
||||
c.post(f"/production-runs/{run['runId']}/cancel",
|
||||
{"reasonCodeId": reason, "note": "cancelled by m2_templates.py to clear the edit-lock"})
|
||||
print(f"cancelled {len(blocking)} in-progress run(s) to release the template edit-lock")
|
||||
return tid
|
||||
|
||||
|
||||
def main():
|
||||
c, chk, args = bootstrap(__doc__)
|
||||
print(f"API {args.api} · AuthHex {args.auth}")
|
||||
|
||||
raw_item, finished_item, uom = pick_fixtures(c, chk)
|
||||
print(f"fixtures: rawItem={raw_item} finishedItem={finished_item} uom={uom}")
|
||||
|
||||
stale = cleanup(c)
|
||||
if stale:
|
||||
print(f"note: reusing/overwriting existing template {stale} ({TEMPLATE_CODE})")
|
||||
|
||||
payload = diamond(raw_item, finished_item, uom)
|
||||
|
||||
# ---------------------------------------------------------------- create
|
||||
chk.section("1. Create a valid diamond graph")
|
||||
if stale:
|
||||
head = c.get(f"/production-templates/{stale}")
|
||||
created = c.put(f"/production-templates/{stale}", payload, if_match=head.etag)
|
||||
chk.status("PUT existing template", created, 200)
|
||||
else:
|
||||
created = c.post("/production-templates", payload)
|
||||
chk.status("POST /production-templates", created, 201)
|
||||
|
||||
if created.status not in (200, 201):
|
||||
return chk.finish("M2")
|
||||
|
||||
tid = created.body["templateId"]
|
||||
chk.check("ETag header present", created.etag is not None, True)
|
||||
chk.check("3 stages persisted", len(created.body["stages"]), 3)
|
||||
chk.check("2 edges persisted", len(created.body["edges"]), 2)
|
||||
|
||||
# ------------------------------------------------------------------- get
|
||||
chk.section("2. GET round-trips ids, keys, positions and fieldDefs")
|
||||
got = c.get(f"/production-templates/{tid}")
|
||||
chk.status("GET /production-templates/{id}", got, 200)
|
||||
g = got.body
|
||||
|
||||
stages = {s["name"]: s for s in g["stages"]}
|
||||
chk.check("stage keys equal their ids",
|
||||
all(s["key"] == str(s["stageId"]) for s in g["stages"]), True)
|
||||
|
||||
cut = stages["Cut frame"]
|
||||
chk.check("posX survived the round trip", float(cut["posX"]), 80.0)
|
||||
chk.check("fieldDefs jsonb survived", cut["fieldDefs"][0]["key"], "moisture_ok")
|
||||
chk.check("fieldDef type survived as enum name", cut["fieldDefs"][0]["type"], "Checkbox")
|
||||
|
||||
asm = stages["Assemble & QA"]
|
||||
chk.check("terminal has 2 upstream inputs",
|
||||
sum(1 for i in asm["inputs"] if i["source"] == "Upstream"), 2)
|
||||
chk.check("upstream inputs resolved fromOutputId",
|
||||
all(i["fromOutputId"] for i in asm["inputs"] if i["source"] == "Upstream"), True)
|
||||
chk.check("upstream inputs expose fromOutputKey",
|
||||
all(i["fromOutputKey"] for i in asm["inputs"] if i["source"] == "Upstream"), True)
|
||||
chk.check("terminal output carries the finished item", asm["outputs"][0]["itemId"], finished_item)
|
||||
|
||||
# The builder reads activeRunCount straight off the graph to decide whether to disable
|
||||
# itself; without it on this response it would need a second request to the list endpoint.
|
||||
chk.check("graph exposes activeRunCount", g["activeRunCount"], 0)
|
||||
|
||||
# Canvas annotations. Not in docs/30 Part C — added so a save can't silently discard the
|
||||
# boxes and dividers the builder already draws.
|
||||
chk.section("2b. Canvas annotations round-trip")
|
||||
anns = {a["kind"]: a for a in g["annotations"]}
|
||||
chk.check("both annotations persisted", len(g["annotations"]), 2)
|
||||
chk.check("box label survived", anns.get("box", {}).get("label"), "Sub-assembly")
|
||||
chk.check("box geometry survived", (float(anns["box"]["posX"]), float(anns["box"]["width"])), (40.0, 400.0))
|
||||
chk.check("line rotation survived", float(anns.get("line", {}).get("rotation") or 0), 90.0)
|
||||
chk.check("annotations are not stages", len(g["stages"]), 3)
|
||||
|
||||
bad_kind = dict(rebuild_from_get(g))
|
||||
bad_kind["annotations"] = [{"kind": "circle", "posX": 0, "posY": 0, "width": 10,
|
||||
"height": 10, "label": None, "rotation": None}]
|
||||
chk.status("unknown annotation kind rejected",
|
||||
c.put(f"/production-templates/{tid}", bad_kind, if_match=got.etag), 422)
|
||||
|
||||
# The keys GET hands back must be directly reusable as a PUT payload.
|
||||
chk.section("3. Idempotent re-save using the server's own keys")
|
||||
echo = rebuild_from_get(g)
|
||||
resaved = c.put(f"/production-templates/{tid}", echo, if_match=got.etag)
|
||||
chk.status("PUT echoing server keys", resaved, 200)
|
||||
if resaved.status == 200:
|
||||
chk.check("stage ids preserved (diffed, not recreated)",
|
||||
sorted(s["stageId"] for s in resaved.body["stages"]),
|
||||
sorted(s["stageId"] for s in g["stages"]))
|
||||
chk.check("edge count still 2", len(resaved.body["edges"]), 2)
|
||||
chk.check("annotations survived the echo re-save", len(resaved.body["annotations"]), 2)
|
||||
etag = resaved.etag
|
||||
|
||||
# Wholesale replacement cuts both ways: a client that forgets to echo annotations back
|
||||
# wipes them. Pinned explicitly because that is a silent data loss, not an error.
|
||||
stripped = rebuild_from_get(g)
|
||||
stripped["annotations"] = []
|
||||
cleared = c.put(f"/production-templates/{tid}", stripped, if_match=etag)
|
||||
chk.status("PUT omitting annotations", cleared, 200)
|
||||
if cleared.status == 200:
|
||||
chk.check("omitted annotations are cleared", len(cleared.body["annotations"]), 0)
|
||||
restored = c.put(f"/production-templates/{tid}", rebuild_from_get(g), if_match=cleared.etag)
|
||||
chk.status("PUT restoring annotations", restored, 200)
|
||||
etag = restored.etag if restored.status == 200 else cleared.etag
|
||||
else:
|
||||
etag = got.etag
|
||||
|
||||
# ------------------------------------------------------- graph rejections
|
||||
chk.section("4. Graph validation rejections (FR-MFG-02, FR-MFG-04, FR-MFG-05)")
|
||||
|
||||
cycle = rebuild_from_get(g)
|
||||
cycle["edges"].append({"parentKey": key_of(g, "Assemble & QA"), "childKey": key_of(g, "Cut frame")})
|
||||
chk.status("cycle", c.put(f"/production-templates/{tid}", cycle, if_match=etag), 422, "GRAPH_CYCLE")
|
||||
|
||||
two_term = rebuild_from_get(g)
|
||||
two_term["edges"] = [e for e in two_term["edges"] if e["parentKey"] != key_of(g, "Prep cushions")]
|
||||
chk.status("two terminals", c.put(f"/production-templates/{tid}", two_term, if_match=etag),
|
||||
422, "GRAPH_TERMINAL_COUNT")
|
||||
|
||||
# An isolated stage has no outbound edge, so it is *also* a second terminal and the
|
||||
# cheaper terminal-count check catches it first. That is the more useful error anyway —
|
||||
# it names both offending stages. GRAPH_DISCONNECTED is unreachable in a valid-so-far
|
||||
# DAG (see the note in ProductionGraphValidator) and is kept only as defence in depth.
|
||||
orphan = rebuild_from_get(g)
|
||||
orphan["stages"].append({
|
||||
"key": "tmp-orphan", "name": "Orphan stage", "estimatedMinutes": 5,
|
||||
"posX": 900, "posY": 600, "fieldDefs": [], "inputs": [],
|
||||
"outputs": [{"key": "tmp-orphan-out", "name": "Nothing", "uomId": uom, "qtyPerBatch": 1}],
|
||||
})
|
||||
chk.status("isolated stage (reported as a second terminal)",
|
||||
c.put(f"/production-templates/{tid}", orphan, if_match=etag),
|
||||
422, "GRAPH_TERMINAL_COUNT")
|
||||
|
||||
# Grandparent reference: Cut -> Mid -> Asm, with Asm drawing from Cut's output.
|
||||
grandparent = rebuild_from_get(g)
|
||||
cut_key = key_of(g, "Cut frame")
|
||||
asm_key = key_of(g, "Assemble & QA")
|
||||
cut_output_key = output_key_of(g, "Cut frame", "Frame set")
|
||||
grandparent["stages"].append({
|
||||
"key": "tmp-mid", "name": "Middle", "estimatedMinutes": 5, "posX": 320, "posY": 120,
|
||||
"fieldDefs": [],
|
||||
"inputs": [{"source": "Upstream", "fromOutputKey": cut_output_key, "uomId": uom, "qtyPerBatch": 1}],
|
||||
"outputs": [{"key": "tmp-mid-out", "name": "Mid part", "uomId": uom, "qtyPerBatch": 1}],
|
||||
})
|
||||
grandparent["edges"] = [e for e in grandparent["edges"] if e["parentKey"] != cut_key]
|
||||
grandparent["edges"] += [{"parentKey": cut_key, "childKey": "tmp-mid"},
|
||||
{"parentKey": "tmp-mid", "childKey": asm_key}]
|
||||
# Assemble still reads Cut's output, but Cut is now a grandparent -> invalid.
|
||||
chk.status("upstream input from a grandparent",
|
||||
c.put(f"/production-templates/{tid}", grandparent, if_match=etag),
|
||||
422, "GRAPH_INPUT_SOURCE_INVALID")
|
||||
|
||||
no_item = rebuild_from_get(g)
|
||||
for s in no_item["stages"]:
|
||||
if s["name"] == "Assemble & QA":
|
||||
s["outputs"][0]["itemId"] = None
|
||||
chk.status("terminal output without an item",
|
||||
c.put(f"/production-templates/{tid}", no_item, if_match=etag),
|
||||
422, "TERMINAL_OUTPUT_ITEM_REQUIRED")
|
||||
|
||||
wip_item = rebuild_from_get(g)
|
||||
for s in wip_item["stages"]:
|
||||
if s["name"] == "Cut frame":
|
||||
s["outputs"][0]["itemId"] = finished_item
|
||||
chk.status("intermediate output claiming an item",
|
||||
c.put(f"/production-templates/{tid}", wip_item, if_match=etag), 422)
|
||||
|
||||
bad_item = rebuild_from_get(g)
|
||||
for s in bad_item["stages"]:
|
||||
if s["name"] == "Cut frame":
|
||||
s["inputs"][0]["itemId"] = 999_999
|
||||
chk.status("stock input naming a nonexistent item",
|
||||
c.put(f"/production-templates/{tid}", bad_item, if_match=etag), 422)
|
||||
|
||||
chk.section("5. Concurrency + status")
|
||||
chk.status("PUT with no If-Match", c.request("PUT", f"/production-templates/{tid}", rebuild_from_get(g)),
|
||||
428, "PRECONDITION_REQUIRED")
|
||||
# Malformed vs stale are different failures. "AQAAAA==" is a well-formed 4-byte token
|
||||
# (xmin = 1) that no live row will ever carry, so it reaches the service's version
|
||||
# comparison and yields 412 rather than the 428 a garbage token would.
|
||||
chk.status("PUT with a malformed If-Match",
|
||||
c.put(f"/production-templates/{tid}", rebuild_from_get(g), if_match='"not-base64"'),
|
||||
428, "PRECONDITION_REQUIRED")
|
||||
chk.status("PUT with a stale If-Match",
|
||||
c.put(f"/production-templates/{tid}", rebuild_from_get(g), if_match='"AQAAAA=="'),
|
||||
412, "CONCURRENCY_CONFLICT")
|
||||
|
||||
chk.status("PATCH status -> Inactive",
|
||||
c.patch(f"/production-templates/{tid}/status", {"status": "Inactive"}), 204)
|
||||
listed = c.get(f"/production-templates?q={TEMPLATE_CODE}")
|
||||
row = next((t for t in listed.body["items"] if t["templateId"] == tid), None)
|
||||
chk.check("list reports Inactive", row and row["status"], "Inactive")
|
||||
chk.check("list reports stageCount 3", row and row["stageCount"], 3)
|
||||
chk.check("list reports activeRunCount 0", row and row["activeRunCount"], 0)
|
||||
|
||||
# Leave it Active so the M3 run-creation smoke can start runs from it.
|
||||
c.patch(f"/production-templates/{tid}/status", {"status": "Active"})
|
||||
print(f"\nleft template {tid} ({TEMPLATE_CODE}) Active for the M3 smoke test")
|
||||
|
||||
return chk.finish("M2")
|
||||
|
||||
|
||||
# --- helpers: turn a GET response back into a save payload -------------------
|
||||
|
||||
def rebuild_from_get(g: dict) -> dict:
|
||||
"""Echo a fetched graph back as a save payload, reusing the server's keys."""
|
||||
return {
|
||||
"code": g["code"],
|
||||
"name": g["name"],
|
||||
"description": g.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 g["stages"]
|
||||
],
|
||||
"edges": [{"parentKey": e["parentKey"], "childKey": e["childKey"]} for e in g["edges"]],
|
||||
# Echoed back deliberately: annotations are replaced wholesale, so omitting them here
|
||||
# would make every re-save silently clear the canvas layout notes.
|
||||
"annotations": g["annotations"],
|
||||
}
|
||||
|
||||
|
||||
def key_of(g: dict, stage_name: str) -> str:
|
||||
return next(s["key"] for s in g["stages"] if s["name"] == stage_name)
|
||||
|
||||
|
||||
def output_key_of(g: dict, stage_name: str, output_name: str) -> str:
|
||||
stage = next(s for s in g["stages"] if s["name"] == stage_name)
|
||||
return next(o["key"] for o in stage["outputs"] if o["name"] == output_name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,218 @@
|
||||
"""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())
|
||||
@@ -0,0 +1,329 @@
|
||||
"""M4 smoke test — stage start/complete/approve/transfer (docs/30 §D.3, FR-MFG-09..12).
|
||||
|
||||
FIRST STOCK-TOUCHING MILESTONE. Everything happens in a dedicated `SMOKE-PRD` warehouse so
|
||||
the effects are isolated from real data and easy to inspect or clean up:
|
||||
|
||||
DELETE FROM stock_ledger WHERE "WarehouseId" = (SELECT "WarehouseId" FROM warehouses WHERE "Code"='SMOKE-PRD');
|
||||
|
||||
Self-contained — builds its own template and run, so it does not depend on M2/M3 leftovers:
|
||||
|
||||
python Backend/smoke/m4_stage_actions.py
|
||||
|
||||
Leaves the terminal stage InProgress for m5_receipt.py to complete and approve.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
from smoke_common import bootstrap, drain_stock, seed_costed_stock
|
||||
|
||||
WAREHOUSE_CODE = "SMOKE-PRD"
|
||||
TEMPLATE_CODE = "SMOKE-PT-M4"
|
||||
TARGET_QTY = 50
|
||||
SEED_RAW = 1000 # base units of the raw item
|
||||
SEED_PACK = 500 # base units of the packaging item
|
||||
# Deliberately awkward unit costs: the raw item is seeded in two layers at different costs
|
||||
# so FIFO consumption produces a genuinely weighted value rather than a round number.
|
||||
RAW_COST_1 = 2.5
|
||||
RAW_COST_2 = 4.75
|
||||
PACK_COST = 1.25
|
||||
STATE_FILE = "m4_state.json"
|
||||
|
||||
|
||||
def ensure_warehouse(c):
|
||||
for w in c.get(f"/warehouses?q={WAREHOUSE_CODE}&pageSize=50").body["items"]:
|
||||
if w["code"] == WAREHOUSE_CODE:
|
||||
return w["warehouseId"]
|
||||
created = c.post("/warehouses", {"code": WAREHOUSE_CODE, "name": "Production smoke warehouse"})
|
||||
if created.status != 201:
|
||||
sys.exit(f"FATAL: could not create the smoke warehouse: {created.status} {created.body}")
|
||||
return created.body["warehouseId"]
|
||||
|
||||
|
||||
def adjustment_reason(c):
|
||||
codes = c.get("/reason-codes?context=Adjustment&pageSize=50").body["items"]
|
||||
if not codes:
|
||||
sys.exit("FATAL: no Adjustment reason codes seeded.")
|
||||
return codes[0]["reasonCodeId"]
|
||||
|
||||
|
||||
def production_reason(c, code):
|
||||
for r in c.get("/reason-codes?context=Production&pageSize=50").body["items"]:
|
||||
if r["code"] == code:
|
||||
return r["reasonCodeId"]
|
||||
sys.exit(f"FATAL: Production reason {code} not seeded.")
|
||||
|
||||
|
||||
def on_hand(c, item, wh):
|
||||
return float(c.get(f"/stock/on-hand?itemId={item}&warehouseId={wh}").body["onHand"])
|
||||
|
||||
|
||||
def ledger_rows(c, run_id, source):
|
||||
rows = c.get(f"/stock/ledger?sourceDocType={source}&sourceDocId={run_id}&pageSize=200").body["items"]
|
||||
return rows
|
||||
|
||||
|
||||
def seed_stock(c, wh, raw, pack, uom):
|
||||
"""
|
||||
Seed on-hand at explicit unit costs. Two raw layers at different costs mean the FIFO
|
||||
consumption at start has to weight them, so `consumedValue` is a real number the
|
||||
assertions can check rather than the 0.00 a positive adjustment would produce.
|
||||
"""
|
||||
seed_costed_stock(c, wh, [
|
||||
(raw, uom, SEED_RAW * 0.4, RAW_COST_1),
|
||||
(raw, uom, SEED_RAW * 0.6, RAW_COST_2),
|
||||
(pack, uom, SEED_PACK, PACK_COST),
|
||||
])
|
||||
|
||||
|
||||
def build_template(c, raw, pack, finished, uom):
|
||||
"""Cut (entry, stock input) → Assemble (terminal, upstream + a late stock input)."""
|
||||
payload = {
|
||||
"code": TEMPLATE_CODE,
|
||||
"name": "Smoke M4 line",
|
||||
"description": "Created by m4_stage_actions.py",
|
||||
"stages": [
|
||||
{
|
||||
"key": "tmp-cut", "name": "Cut", "roleLabel": "Carpentry",
|
||||
"estimatedMinutes": 60, "posX": 80, "posY": 100,
|
||||
"fieldDefs": [{"key": "moisture_ok", "label": "Moisture check",
|
||||
"type": "Checkbox", "required": True}],
|
||||
"inputs": [{"source": "Stock", "itemId": raw, "uomId": uom, "qtyPerBatch": 8}],
|
||||
"outputs": [{"key": "tmp-frame", "name": "Frame", "uomId": uom, "qtyPerBatch": 1}],
|
||||
},
|
||||
{
|
||||
# A Stock input on a non-entry stage — FR-MFG-04 allows material to join late
|
||||
# (packaging), which is exactly what this covers.
|
||||
"key": "tmp-asm", "name": "Assemble", "roleLabel": "QA",
|
||||
"estimatedMinutes": 45, "posX": 520, "posY": 100, "fieldDefs": [],
|
||||
"inputs": [
|
||||
{"source": "Upstream", "fromOutputKey": "tmp-frame", "uomId": uom, "qtyPerBatch": 1},
|
||||
{"source": "Stock", "itemId": pack, "uomId": uom, "qtyPerBatch": 2},
|
||||
],
|
||||
"outputs": [{"key": "tmp-chair", "name": "Chair", "itemId": finished,
|
||||
"uomId": uom, "qtyPerBatch": 1}],
|
||||
},
|
||||
],
|
||||
"edges": [{"parentKey": "tmp-cut", "childKey": "tmp-asm"}],
|
||||
}
|
||||
|
||||
existing = next((t for t in c.get(f"/production-templates?q={TEMPLATE_CODE}").body["items"]
|
||||
if t["code"] == TEMPLATE_CODE), None)
|
||||
if existing:
|
||||
head = c.get(f"/production-templates/{existing['templateId']}")
|
||||
res = c.put(f"/production-templates/{existing['templateId']}", payload, if_match=head.etag)
|
||||
if res.status == 409:
|
||||
# An earlier smoke run is still InProgress; reuse the template as-is.
|
||||
return existing["templateId"]
|
||||
if res.status != 200:
|
||||
sys.exit(f"FATAL: could not update the M4 template: {res.status} {res.body}")
|
||||
return res.body["templateId"]
|
||||
|
||||
res = c.post("/production-templates", payload)
|
||||
if res.status != 201:
|
||||
sys.exit(f"FATAL: could not create the M4 template: {res.status} {res.body}")
|
||||
return res.body["templateId"]
|
||||
|
||||
|
||||
def main():
|
||||
c, chk, args = bootstrap(__doc__)
|
||||
print(f"API {args.api}")
|
||||
|
||||
items = c.get("/items?pageSize=5&status=Active").body["items"]
|
||||
if len(items) < 3:
|
||||
sys.exit("FATAL: need at least 3 active items.")
|
||||
raw, pack, finished = items[0]["itemId"], items[1]["itemId"], items[2]["itemId"]
|
||||
uom = items[0]["baseUomId"]
|
||||
|
||||
wh = ensure_warehouse(c)
|
||||
drained = drain_stock(c, wh)
|
||||
if drained:
|
||||
print(f"drained {len(drained)} leftover item(s) from a previous execution")
|
||||
seed_stock(c, wh, raw, pack, uom)
|
||||
raw_before = on_hand(c, raw, wh)
|
||||
pack_before = on_hand(c, pack, wh)
|
||||
print(f"warehouse={wh} raw={raw}(on-hand {raw_before}) pack={pack}(on-hand {pack_before}) finished={finished}")
|
||||
|
||||
tid = build_template(c, raw, pack, finished, uom)
|
||||
created = c.post("/production-runs", {"templateId": tid, "targetQty": TARGET_QTY, "warehouseId": wh})
|
||||
if not chk.status("create the run", created, 201):
|
||||
return chk.finish("M4")
|
||||
|
||||
run = created.body
|
||||
rid = run["runId"]
|
||||
cut = next(s for s in run["stages"] if s["name"] == "Cut")
|
||||
asm = next(s for s in run["stages"] if s["name"] == "Assemble")
|
||||
cut_id, asm_id = cut["runStageId"], asm["runStageId"]
|
||||
print(f"run={rid} {run['docNo']} cut={cut_id} assemble={asm_id}")
|
||||
|
||||
# ------------------------------------------------------------------- start
|
||||
chk.section("1. Stage start FIFO-consumes its stock inputs (FR-MFG-10)")
|
||||
started = c.post(f"/production-runs/{rid}/stages/{cut_id}/start", idempotency_key="m4-start-cut")
|
||||
if not chk.status("POST .../start", started, 200):
|
||||
return chk.finish("M4")
|
||||
|
||||
chk.check("stage now InProgress", started.body["status"], "InProgress")
|
||||
chk.check("actualStartAt stamped", started.body["actualStartAt"] is not None, True)
|
||||
chk.check("one input consumed", len(started.body["consumed"]), 1)
|
||||
|
||||
con = started.body["consumed"][0]
|
||||
chk.check("consumed the scaled quantity (8 x 50)", float(con["qty"]), 400.0)
|
||||
chk.check("consumed layers reported", len(con["consumedLayers"]) >= 1, True)
|
||||
chk.check("consumed value = qty x layer cost",
|
||||
round(float(con["value"]), 4),
|
||||
round(sum(float(l["qty"]) * float(l["unitCost"]) for l in con["consumedLayers"]), 4))
|
||||
# The whole point of seeding two layers at different costs: prove the value is genuinely
|
||||
# FIFO-weighted rather than zero or a single flat rate.
|
||||
chk.check("consumed value is non-zero", float(con["value"]) > 0, True)
|
||||
layer_costs = {float(l["unitCost"]) for l in con["consumedLayers"]}
|
||||
chk.check("consumption drew from the cheaper layer first (FR-STK-03)",
|
||||
min(layer_costs), RAW_COST_1)
|
||||
chk.check("ledgerRefs returned", len(started.body["ledgerRefs"]), 1)
|
||||
|
||||
chk.check("on-hand fell by exactly the consumed qty", on_hand(c, raw, wh), raw_before - 400.0)
|
||||
|
||||
prdi = ledger_rows(c, rid, "PRDI")
|
||||
chk.check("one PRDI ledger row", len(prdi), 1)
|
||||
if prdi:
|
||||
chk.check("PRDI direction is Out", prdi[0]["direction"], "Out")
|
||||
chk.check("PRDI qty is base-UOM 400", float(prdi[0]["qtyBase"]), 400.0)
|
||||
|
||||
detail = c.get(f"/production-runs/{rid}").body
|
||||
cut_now = next(s for s in detail["stages"] if s["runStageId"] == cut_id)
|
||||
chk.check("consumedQty recorded on the input", float(cut_now["inputs"][0]["consumedQty"]), 400.0)
|
||||
chk.check("cost pool now reflects the consumption",
|
||||
float(detail["costPool"]["net"]), round(float(con["value"]), 4))
|
||||
|
||||
chk.section("2. Guards after starting")
|
||||
chk.status("start again", c.post(f"/production-runs/{rid}/stages/{cut_id}/start"),
|
||||
409, "STAGE_NOT_READY")
|
||||
chk.status("start a Waiting stage", c.post(f"/production-runs/{rid}/stages/{asm_id}/start"),
|
||||
409, "STAGE_NOT_READY")
|
||||
chk.status("edit quantities on a started stage",
|
||||
c.put(f"/production-runs/{rid}/stages/{cut_id}/quantities",
|
||||
{"inputs": [{"id": cut_now["inputs"][0]["runInputId"], "plannedQty": 500}], "outputs": []}),
|
||||
409, "STAGE_NOT_EDITABLE")
|
||||
|
||||
# ---------------------------------------------------------------- complete
|
||||
chk.section("3. Stage complete records produced/scrap/fields (FR-MFG-11)")
|
||||
cut_out = cut_now["outputs"][0]["runOutputId"]
|
||||
|
||||
chk.status("complete without the required custom field",
|
||||
c.post(f"/production-runs/{rid}/stages/{cut_id}/complete",
|
||||
{"outputs": [{"runOutputId": cut_out, "producedQty": 50, "scrappedQty": 0}]}),
|
||||
400, "REQUIRED_FIELD_MISSING")
|
||||
|
||||
chk.status("complete with scrap but no reason code",
|
||||
c.post(f"/production-runs/{rid}/stages/{cut_id}/complete",
|
||||
{"outputs": [{"runOutputId": cut_out, "producedQty": 50, "scrappedQty": 2}],
|
||||
"fieldValues": {"moisture_ok": True}}),
|
||||
400, "REASON_CODE_REQUIRED")
|
||||
|
||||
chk.status("complete with an Adjustment-context reason",
|
||||
c.post(f"/production-runs/{rid}/stages/{cut_id}/complete",
|
||||
{"outputs": [{"runOutputId": cut_out, "producedQty": 50, "scrappedQty": 2,
|
||||
"scrapReasonCodeId": adjustment_reason(c)}],
|
||||
"fieldValues": {"moisture_ok": True}}),
|
||||
422)
|
||||
|
||||
chk.status("complete with scrapped > produced",
|
||||
c.post(f"/production-runs/{rid}/stages/{cut_id}/complete",
|
||||
{"outputs": [{"runOutputId": cut_out, "producedQty": 5, "scrappedQty": 9,
|
||||
"scrapReasonCodeId": production_reason(c, "PRD-SCRAP")}],
|
||||
"fieldValues": {"moisture_ok": True}}),
|
||||
422)
|
||||
|
||||
done = c.post(f"/production-runs/{rid}/stages/{cut_id}/complete",
|
||||
{"outputs": [{"runOutputId": cut_out, "producedQty": 50, "scrappedQty": 0}],
|
||||
"fieldValues": {"moisture_ok": True}})
|
||||
chk.status("complete properly", done, 200)
|
||||
if done.status == 200:
|
||||
chk.check("stage now Done", done.body["status"], "Done")
|
||||
chk.check("actualEndAt stamped", done.body["actualEndAt"] is not None, True)
|
||||
chk.check("actualMinutes computed", done.body["actualMinutes"] is not None, True)
|
||||
chk.check("producedQty recorded", float(done.body["outputs"][0]["producedQty"]), 50.0)
|
||||
chk.check("availableToTransfer = produced - scrapped - transferred",
|
||||
float(done.body["outputs"][0]["availableToTransfer"]), 50.0)
|
||||
chk.check("fieldValues persisted", done.body["fieldValues"], {"moisture_ok": True})
|
||||
|
||||
# ----------------------------------------------------------------- approve
|
||||
chk.section("4. Approve with a partial transfer (FR-MFG-12)")
|
||||
approved = c.post(f"/production-runs/{rid}/stages/{cut_id}/approve",
|
||||
{"transfers": [{"runOutputId": cut_out, "qty": 30}]})
|
||||
chk.status("approve transferring 30 of 50", approved, 200)
|
||||
if approved.status == 200:
|
||||
chk.check("stage now Approved", approved.body["status"], "Approved")
|
||||
chk.check("run still InProgress (non-terminal)", approved.body["runStatus"], "InProgress")
|
||||
chk.check("no receipt on a non-terminal approve", approved.body["receipt"], None)
|
||||
chk.check("one transfer reported", len(approved.body["transfers"]), 1)
|
||||
t = approved.body["transfers"][0]
|
||||
chk.check("transferred 30", float(t["qty"]), 30.0)
|
||||
chk.check("child delivered 30", float(t["childDeliveredQty"]), 30.0)
|
||||
chk.check("child still Waiting (30 < 50 planned)", t["childStatus"], "Waiting")
|
||||
chk.check("remainder held on the stage",
|
||||
float(approved.body["stage"]["outputs"][0]["availableToTransfer"]), 20.0)
|
||||
|
||||
chk.section("5. Transfer the remainder, then over-transfer (FR-MFG-12)")
|
||||
chk.status("transfer 21 (more than the 20 remaining)",
|
||||
c.post(f"/production-runs/{rid}/stages/{cut_id}/transfer",
|
||||
{"runOutputId": cut_out, "qty": 21}),
|
||||
422, "TRANSFER_EXCEEDS_AVAILABLE")
|
||||
|
||||
moved = c.post(f"/production-runs/{rid}/stages/{cut_id}/transfer",
|
||||
{"runOutputId": cut_out, "qty": 20})
|
||||
chk.status("transfer the remaining 20", moved, 200)
|
||||
if moved.status == 200:
|
||||
chk.check("child delivered 50", float(moved.body["transfers"][0]["childDeliveredQty"]), 50.0)
|
||||
chk.check("child flipped to Ready (FR-MFG-09)", moved.body["transfers"][0]["childStatus"], "Ready")
|
||||
chk.check("nothing left to transfer",
|
||||
float(moved.body["stage"]["outputs"][0]["availableToTransfer"]), 0.0)
|
||||
|
||||
chk.status("transfer once everything is gone",
|
||||
c.post(f"/production-runs/{rid}/stages/{cut_id}/transfer",
|
||||
{"runOutputId": cut_out, "qty": 1}),
|
||||
422, "TRANSFER_EXCEEDS_AVAILABLE")
|
||||
|
||||
# -------------------------------------------------- late stock input start
|
||||
chk.section("6. Start the terminal stage — a late Stock input (FR-MFG-04)")
|
||||
started2 = c.post(f"/production-runs/{rid}/stages/{asm_id}/start")
|
||||
chk.status("start Assemble", started2, 200)
|
||||
if started2.status == 200:
|
||||
chk.check("only the Stock input consumed (upstream is WIP)", len(started2.body["consumed"]), 1)
|
||||
chk.check("packaging consumed 2 x 50", float(started2.body["consumed"][0]["qty"]), 100.0)
|
||||
chk.check("packaging on-hand fell by 100", on_hand(c, pack, wh), pack_before - 100.0)
|
||||
chk.check("no ledger row for the upstream WIP input", len(ledger_rows(c, rid, "PRDI")), 2)
|
||||
|
||||
chk.section("7. Insufficient stock is refused (FR-MFG-10)")
|
||||
big = c.post("/production-runs", {"templateId": tid, "targetQty": 100000, "warehouseId": wh})
|
||||
if big.status == 201:
|
||||
big_cut = next(s for s in big.body["stages"] if s["name"] == "Cut")["runStageId"]
|
||||
chk.status("start a stage needing more than on-hand",
|
||||
c.post(f"/production-runs/{big.body['runId']}/stages/{big_cut}/start"),
|
||||
409, "STOCK_NEGATIVE_BLOCKED")
|
||||
chk.check("on-hand untouched by the failed start", on_hand(c, raw, wh), raw_before - 400.0)
|
||||
else:
|
||||
chk.check("could create the oversized run", big.status, 201)
|
||||
|
||||
chk.section("8. Event history")
|
||||
events = c.get(f"/production-runs/{rid}").body["events"]
|
||||
kinds = [e["eventType"] for e in events]
|
||||
chk.check("Start logged twice", kinds.count("Start"), 2)
|
||||
chk.check("Complete logged once", kinds.count("Complete"), 1)
|
||||
chk.check("Approve logged once", kinds.count("Approve"), 1)
|
||||
chk.check("Transfer logged once", kinds.count("Transfer"), 1)
|
||||
chk.check("no event for the rejected actions", kinds.count("QuantityEdit"), 0)
|
||||
|
||||
# Hand off to M5.
|
||||
with open(STATE_FILE, "w") as f:
|
||||
json.dump({"runId": rid, "templateId": tid, "warehouseId": wh,
|
||||
"assembleStageId": asm_id, "finishedItemId": finished,
|
||||
"rawItemId": raw, "packItemId": pack}, f)
|
||||
print(f"\nwrote {STATE_FILE}; run {rid} has Assemble InProgress for m5_receipt.py")
|
||||
|
||||
return chk.finish("M4")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1 @@
|
||||
{"runId": 64, "templateId": 2, "warehouseId": 4, "assembleStageId": 118, "finishedItemId": 13, "rawItemId": 18, "packItemId": 14}
|
||||
@@ -0,0 +1,170 @@
|
||||
"""M4b smoke test — UOM conversion on production stock inputs.
|
||||
|
||||
This covers the single highest-risk correctness gap in the manufacturing phase.
|
||||
`IFifoCostingService.ConsumeAsync` works exclusively in an item's BASE UOM, while
|
||||
`STAGE_INPUT.uom_id` is a free FK — docs/30 never mentions conversion at all. Without the
|
||||
shared `IUomConverter` (extracted from `GrnService.ToBaseAsync`), a stage input declared in
|
||||
"box of 12" would consume 1 base unit instead of 12 and silently mis-cost the whole run.
|
||||
|
||||
The dev database has no `uom_conversions` rows at all, so the non-base path was previously
|
||||
unexercised by any data. This script creates a real conversion and proves:
|
||||
|
||||
* a stage input in a non-base UOM consumes qtyPerBatch x scaleFactor x factor base units
|
||||
* the ledger records the BASE quantity, not the declared one
|
||||
* an input in a UOM with no conversion defined is refused with 422 rather than mis-consumed
|
||||
|
||||
python Backend/smoke/m4b_uom_conversion.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from smoke_common import bootstrap
|
||||
|
||||
WAREHOUSE_CODE = "SMOKE-PRD"
|
||||
TEMPLATE_CODE = "SMOKE-PT-M4B"
|
||||
FACTOR = 12 # 1 case = 12 base units
|
||||
QTY_PER_BATCH = 3 # cases per batch
|
||||
TARGET_QTY = 10 # -> scale 10 -> 30 cases -> 360 base units
|
||||
SEED = 5000
|
||||
|
||||
|
||||
def main():
|
||||
c, chk, args = bootstrap(__doc__)
|
||||
print(f"API {args.api}")
|
||||
|
||||
# --- fixtures ---------------------------------------------------------
|
||||
wh = next((w["warehouseId"] for w in c.get(f"/warehouses?q={WAREHOUSE_CODE}&pageSize=50").body["items"]
|
||||
if w["code"] == WAREHOUSE_CODE), None)
|
||||
if wh is None:
|
||||
sys.exit("FATAL: run m4_stage_actions.py first (it creates the SMOKE-PRD warehouse).")
|
||||
|
||||
items = c.get("/items?pageSize=5&status=Active").body["items"]
|
||||
raw, finished = items[0], items[1]
|
||||
base_uom = raw["baseUomId"]
|
||||
|
||||
uoms = c.get("/uoms?pageSize=50").body["items"]
|
||||
case_uom = next((u["uomId"] for u in uoms if u["uomId"] != base_uom), None)
|
||||
if case_uom is None:
|
||||
sys.exit("FATAL: need at least 2 UOMs to test conversion.")
|
||||
print(f"item={raw['itemId']} baseUom={base_uom} caseUom={case_uom} factor={FACTOR}")
|
||||
|
||||
# --- define the conversion -------------------------------------------
|
||||
chk.section("1. Define a non-base UOM conversion for the item")
|
||||
conv = c.put(f"/items/{raw['itemId']}/uom-conversions",
|
||||
{"conversions": [{"fromUom": case_uom, "toUom": base_uom, "factor": FACTOR}]})
|
||||
chk.status("PUT /items/{id}/uom-conversions", conv, 200)
|
||||
if conv.status != 200:
|
||||
return chk.finish("M4b")
|
||||
chk.check("conversion stored", any(float(x["factor"]) == FACTOR for x in conv.body["conversions"]), True)
|
||||
|
||||
reason = c.get("/reason-codes?context=Adjustment&pageSize=5").body["items"][0]["reasonCodeId"]
|
||||
c.post("/stock-adjustments", {"warehouseId": wh, "reasonCodeId": reason,
|
||||
"lines": [{"itemId": raw["itemId"], "qtyDelta": SEED}]})
|
||||
before = float(c.get(f"/stock/on-hand?itemId={raw['itemId']}&warehouseId={wh}").body["onHand"])
|
||||
print(f"on-hand before: {before}")
|
||||
|
||||
# --- template whose stock input is declared in CASES ------------------
|
||||
chk.section("2. A stage input declared in the non-base UOM")
|
||||
payload = {
|
||||
"code": TEMPLATE_CODE, "name": "Smoke M4b conversion line",
|
||||
"stages": [{
|
||||
"key": "tmp-only", "name": "Pack", "estimatedMinutes": 10,
|
||||
"posX": 0, "posY": 0, "fieldDefs": [],
|
||||
# Declared in cases, not base units.
|
||||
"inputs": [{"source": "Stock", "itemId": raw["itemId"],
|
||||
"uomId": case_uom, "qtyPerBatch": QTY_PER_BATCH}],
|
||||
"outputs": [{"key": "tmp-out", "name": "Packed", "itemId": finished["itemId"],
|
||||
"uomId": base_uom, "qtyPerBatch": 1}],
|
||||
}],
|
||||
"edges": [],
|
||||
}
|
||||
|
||||
existing = next((t for t in c.get(f"/production-templates?q={TEMPLATE_CODE}").body["items"]
|
||||
if t["code"] == TEMPLATE_CODE), None)
|
||||
if existing:
|
||||
head = c.get(f"/production-templates/{existing['templateId']}")
|
||||
res = c.put(f"/production-templates/{existing['templateId']}", payload, if_match=head.etag)
|
||||
tid = existing["templateId"] if res.status in (200, 409) else None
|
||||
chk.check("template ready", tid is not None, True)
|
||||
else:
|
||||
res = c.post("/production-templates", payload)
|
||||
chk.status("create single-stage template", res, 201)
|
||||
tid = res.body["templateId"] if res.status == 201 else None
|
||||
|
||||
if tid is None:
|
||||
return chk.finish("M4b")
|
||||
|
||||
# A lone stage is both the entry and the terminal — worth asserting explicitly.
|
||||
run = c.post("/production-runs", {"templateId": tid, "targetQty": TARGET_QTY, "warehouseId": wh})
|
||||
chk.status("create the run", run, 201)
|
||||
if run.status != 201:
|
||||
return chk.finish("M4b")
|
||||
|
||||
stage = run.body["stages"][0]
|
||||
chk.check("single stage is both entry and terminal",
|
||||
(stage["isEntry"], stage["isTerminal"]), (True, True))
|
||||
chk.check("single stage starts Ready", stage["status"], "Ready")
|
||||
chk.check("plannedQty stays in the DECLARED uom (3 x 10 cases)",
|
||||
float(stage["inputs"][0]["plannedQty"]), float(QTY_PER_BATCH * TARGET_QTY))
|
||||
|
||||
# --- the actual conversion assertion ---------------------------------
|
||||
chk.section("3. Consumption converts cases to base units")
|
||||
expected_base = QTY_PER_BATCH * TARGET_QTY * FACTOR # 3 x 10 x 12 = 360
|
||||
started = c.post(f"/production-runs/{run.body['runId']}/stages/{stage['runStageId']}/start")
|
||||
chk.status("start the stage", started, 200)
|
||||
if started.status != 200:
|
||||
return chk.finish("M4b")
|
||||
|
||||
con = started.body["consumed"][0]
|
||||
chk.check(f"consumed {expected_base} BASE units, not {QTY_PER_BATCH * TARGET_QTY}",
|
||||
float(con["qty"]), float(expected_base))
|
||||
chk.check("on-hand fell by the base quantity",
|
||||
float(c.get(f"/stock/on-hand?itemId={raw['itemId']}&warehouseId={wh}").body["onHand"]),
|
||||
before - expected_base)
|
||||
|
||||
rows = c.get(f"/stock/ledger?sourceDocType=PRDI&sourceDocId={run.body['runId']}&pageSize=50").body["items"]
|
||||
chk.check("one PRDI row", len(rows), 1)
|
||||
if rows:
|
||||
chk.check("ledger qtyBase is the converted quantity", float(rows[0]["qtyBase"]), float(expected_base))
|
||||
|
||||
detail = c.get(f"/production-runs/{run.body['runId']}").body
|
||||
chk.check("consumedQty stored in base units",
|
||||
float(detail["stages"][0]["inputs"][0]["consumedQty"]), float(expected_base))
|
||||
|
||||
# --- missing conversion is refused, not silently mis-consumed --------
|
||||
chk.section("4. An undefined conversion is refused (422), never assumed 1:1")
|
||||
third_uom = next((u["uomId"] for u in c.get("/uoms?pageSize=50").body["items"]
|
||||
if u["uomId"] not in (base_uom, case_uom)), None)
|
||||
if third_uom is None:
|
||||
chk.check("skipped: need a third UOM", True, True)
|
||||
else:
|
||||
bad = dict(payload)
|
||||
bad["code"] = TEMPLATE_CODE + "-BAD"
|
||||
bad["stages"] = [dict(payload["stages"][0])]
|
||||
bad["stages"][0] = {**payload["stages"][0],
|
||||
"inputs": [{"source": "Stock", "itemId": raw["itemId"],
|
||||
"uomId": third_uom, "qtyPerBatch": 1}]}
|
||||
made = c.post("/production-templates", bad)
|
||||
if made.status != 201:
|
||||
head = c.get(f"/production-templates?q={TEMPLATE_CODE}-BAD")
|
||||
tid2 = next((t["templateId"] for t in head.body["items"]
|
||||
if t["code"] == TEMPLATE_CODE + "-BAD"), None)
|
||||
else:
|
||||
tid2 = made.body["templateId"]
|
||||
|
||||
if tid2:
|
||||
run2 = c.post("/production-runs", {"templateId": tid2, "targetQty": 1, "warehouseId": wh})
|
||||
if run2.status == 201:
|
||||
s2 = run2.body["stages"][0]["runStageId"]
|
||||
chk.status("start a stage whose input UOM has no conversion",
|
||||
c.post(f"/production-runs/{run2.body['runId']}/stages/{s2}/start"), 422)
|
||||
else:
|
||||
chk.check("could create the second run", run2.status, 201)
|
||||
|
||||
return chk.finish("M4b")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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())
|
||||
@@ -0,0 +1,422 @@
|
||||
"""M6 + M7 smoke test — leftover return, reject-intake, terminal reject, run cancel.
|
||||
|
||||
Covers FR-MFG-14 (leftover return at the consumed weighted cost), FR-MFG-15 (downstream
|
||||
reject pulls work back to the parent), FR-MFG-16 (terminal reject resets the run for a
|
||||
rework pass) and FR-MFG-17 (cancel returns net consumed stock).
|
||||
|
||||
Self-contained: builds its own two-stage template and three separate runs, and drains the
|
||||
warehouse first so FIFO costs are known. Run after m4 so the warehouse exists:
|
||||
|
||||
python Backend/smoke/m4_stage_actions.py
|
||||
python Backend/smoke/m6_m7_leftover_rework_cancel.py
|
||||
|
||||
The assertions that matter most and are easiest to get silently wrong:
|
||||
* a FULL leftover return must leave returnedValue == consumedValue EXACTLY (no crumb)
|
||||
* reject-intake must DECREMENT the parent's transferredQty, not zero it
|
||||
* a terminal reject must PRESERVE consumedQty/consumedValue and plannedQty
|
||||
* a re-complete after rework must OVERWRITE producedQty, not add to it
|
||||
* a rework restart with an unchanged planned qty must consume NOTHING
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from smoke_common import bootstrap, drain_stock, seed_costed_stock
|
||||
|
||||
WAREHOUSE_CODE = "SMOKE-PRD"
|
||||
TEMPLATE_CODE = "SMOKE-PT-M67"
|
||||
TARGET = 10
|
||||
RAW_QPB = 5 # 5 raw per batch -> 50 base units at target 10
|
||||
UNIT_COST = 3.0
|
||||
SEED = 4000
|
||||
|
||||
|
||||
def on_hand(c, item, wh):
|
||||
return float(c.get(f"/stock/on-hand?itemId={item}&warehouseId={wh}").body["onHand"])
|
||||
|
||||
|
||||
def ledger(c, run_id, source):
|
||||
return c.get(f"/stock/ledger?sourceDocType={source}&sourceDocId={run_id}&pageSize=200").body["items"]
|
||||
|
||||
|
||||
def prod_reason(c, code):
|
||||
for r in c.get("/reason-codes?context=Production&pageSize=50").body["items"]:
|
||||
if r["code"] == code:
|
||||
return r["reasonCodeId"]
|
||||
sys.exit(f"FATAL: Production reason {code} not seeded.")
|
||||
|
||||
|
||||
def ensure_template(c, raw, finished, uom):
|
||||
"""Cut (entry) -> Assemble (terminal). Cut has the stock input we return leftovers from."""
|
||||
payload = {
|
||||
"code": TEMPLATE_CODE, "name": "Smoke M6/M7 line",
|
||||
"stages": [
|
||||
{"key": "tmp-cut", "name": "Cut", "estimatedMinutes": 10, "posX": 0, "posY": 0,
|
||||
"fieldDefs": [],
|
||||
"inputs": [{"source": "Stock", "itemId": raw, "uomId": uom, "qtyPerBatch": RAW_QPB}],
|
||||
"outputs": [{"key": "tmp-f", "name": "Frame", "uomId": uom, "qtyPerBatch": 1}]},
|
||||
{"key": "tmp-asm", "name": "Assemble", "estimatedMinutes": 10, "posX": 400, "posY": 0,
|
||||
"fieldDefs": [],
|
||||
"inputs": [{"source": "Upstream", "fromOutputKey": "tmp-f", "uomId": uom, "qtyPerBatch": 1}],
|
||||
"outputs": [{"key": "tmp-c", "name": "Chair", "itemId": finished,
|
||||
"uomId": uom, "qtyPerBatch": 1}]},
|
||||
],
|
||||
"edges": [{"parentKey": "tmp-cut", "childKey": "tmp-asm"}],
|
||||
}
|
||||
existing = next((t for t in c.get(f"/production-templates?q={TEMPLATE_CODE}").body["items"]
|
||||
if t["code"] == TEMPLATE_CODE), None)
|
||||
if existing:
|
||||
h = c.get(f"/production-templates/{existing['templateId']}")
|
||||
r = c.put(f"/production-templates/{existing['templateId']}", payload, if_match=h.etag)
|
||||
if r.status in (200, 409):
|
||||
return existing["templateId"]
|
||||
sys.exit(f"FATAL: could not update the template: {r.status} {r.body}")
|
||||
r = c.post("/production-templates", payload)
|
||||
if r.status != 201:
|
||||
sys.exit(f"FATAL: could not create the template: {r.status} {r.body}")
|
||||
return r.body["templateId"]
|
||||
|
||||
|
||||
def new_run(c, tid, wh):
|
||||
r = c.post("/production-runs", {"templateId": tid, "targetQty": TARGET, "warehouseId": wh})
|
||||
if r.status != 201:
|
||||
sys.exit(f"FATAL: could not create a run: {r.status} {r.body}")
|
||||
cut = next(s for s in r.body["stages"] if s["name"] == "Cut")
|
||||
asm = next(s for s in r.body["stages"] if s["name"] == "Assemble")
|
||||
return r.body, cut, asm
|
||||
|
||||
|
||||
def main():
|
||||
c, chk, args = bootstrap(__doc__)
|
||||
print(f"API {args.api}")
|
||||
|
||||
wh = next((w["warehouseId"] for w in c.get(f"/warehouses?q={WAREHOUSE_CODE}&pageSize=50").body["items"]
|
||||
if w["code"] == WAREHOUSE_CODE), None)
|
||||
if wh is None:
|
||||
sys.exit("FATAL: run m4_stage_actions.py first (it creates the SMOKE-PRD warehouse).")
|
||||
|
||||
items = c.get("/items?pageSize=5&status=Active").body["items"]
|
||||
raw, finished = items[0]["itemId"], items[1]["itemId"]
|
||||
uom = items[0]["baseUomId"]
|
||||
|
||||
drain_stock(c, wh)
|
||||
seed_costed_stock(c, wh, [(raw, uom, SEED, UNIT_COST)])
|
||||
tid = ensure_template(c, raw, finished, uom)
|
||||
consumed_units = RAW_QPB * TARGET # 50
|
||||
consumed_value = consumed_units * UNIT_COST # 150.00
|
||||
print(f"warehouse={wh} raw={raw} unitCost={UNIT_COST} consumesPerRun={consumed_units}")
|
||||
|
||||
# =====================================================================
|
||||
# M6 — leftover return
|
||||
# =====================================================================
|
||||
chk.section("M6-1. Partial leftover return at the consumed weighted cost (FR-MFG-14)")
|
||||
run, cut, asm = new_run(c, tid, wh)
|
||||
rid = run["runId"]
|
||||
cut_in = cut["inputs"][0]["runInputId"]
|
||||
|
||||
c.post(f"/production-runs/{rid}/stages/{cut['runStageId']}/start")
|
||||
before = on_hand(c, raw, wh)
|
||||
pool0 = float(c.get(f"/production-runs/{rid}").body["costPool"]["net"])
|
||||
chk.check("cost pool after start", pool0, consumed_value)
|
||||
|
||||
ret = c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover",
|
||||
{"qty": 5, "reasonCodeId": prod_reason(c, "PRD-LEFTOVER")})
|
||||
chk.status("return 5 of 50 consumed", ret, 200)
|
||||
if ret.status == 200:
|
||||
chk.check("returnedQty echoed", float(ret.body["returnedQty"]), 5.0)
|
||||
chk.check("returned at the consumed weighted cost",
|
||||
float(ret.body["createdLayer"]["unitCost"]), UNIT_COST)
|
||||
chk.check("returnedValue = qty x weighted cost", float(ret.body["returnedValue"]), 15.0)
|
||||
chk.check("layer id populated", ret.body["createdLayer"]["layerId"] > 0, True)
|
||||
chk.check("pool reduced by the returned value",
|
||||
float(ret.body["costPool"]["net"]), consumed_value - 15.0)
|
||||
chk.check("on-hand rose by the returned qty", on_hand(c, raw, wh), before + 5.0)
|
||||
|
||||
prdl = ledger(c, rid, "PRDL")
|
||||
chk.check("one PRDL row", len(prdl), 1)
|
||||
if prdl:
|
||||
chk.check("PRDL direction is In", prdl[0]["direction"], "In")
|
||||
chk.check("PRDL has no bin (raw material, not the output bin)", prdl[0]["binId"], None)
|
||||
|
||||
chk.section("M6-2. Guards")
|
||||
chk.status("return more than remains",
|
||||
c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover",
|
||||
{"qty": 46, "reasonCodeId": prod_reason(c, "PRD-LEFTOVER")}),
|
||||
422, "LEFTOVER_EXCEEDS_CONSUMED")
|
||||
chk.status("return with no reason code",
|
||||
c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover", {"qty": 1}),
|
||||
400, "REASON_CODE_REQUIRED")
|
||||
adj_reason = c.get("/reason-codes?context=Adjustment&pageSize=5").body["items"][0]["reasonCodeId"]
|
||||
chk.status("return with an Adjustment-context reason",
|
||||
c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover",
|
||||
{"qty": 1, "reasonCodeId": adj_reason}),
|
||||
422)
|
||||
chk.status("return against an upstream (WIP) input",
|
||||
c.post(f"/production-runs/{rid}/inputs/{asm['inputs'][0]['runInputId']}/return-leftover",
|
||||
{"qty": 1, "reasonCodeId": prod_reason(c, "PRD-LEFTOVER")}),
|
||||
422)
|
||||
|
||||
chk.section("M6-3. A FULL return must net the input to exactly zero")
|
||||
rest = c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover",
|
||||
{"qty": 45, "reasonCodeId": prod_reason(c, "PRD-LEFTOVER")})
|
||||
chk.status("return the remaining 45", rest, 200)
|
||||
if rest.status == 200:
|
||||
chk.check("pool is exactly zero after a full return", float(rest.body["costPool"]["net"]), 0.0)
|
||||
detail = c.get(f"/production-runs/{rid}").body
|
||||
ci = detail["stages"][0]["inputs"][0]
|
||||
chk.check("returnedQty == consumedQty exactly",
|
||||
float(ci["returnedQty"]), float(ci["consumedQty"]))
|
||||
chk.check("returnedValue == consumedValue exactly",
|
||||
float(ci["returnedValue"]), float(ci["consumedValue"]))
|
||||
|
||||
# Close this run out so the RUN_COST_CLOSED guard can be checked. Each step is asserted:
|
||||
# letting an intermediate call fail silently here previously made the *next* assertion
|
||||
# look like the bug.
|
||||
cut_out = next(s for s in detail["stages"] if s["name"] == "Cut")["outputs"][0]["runOutputId"]
|
||||
chk.status("close-out: complete Cut",
|
||||
c.post(f"/production-runs/{rid}/stages/{cut['runStageId']}/complete",
|
||||
{"outputs": [{"runOutputId": cut_out, "producedQty": TARGET, "scrappedQty": 0}]}), 200)
|
||||
chk.status("close-out: approve Cut",
|
||||
c.post(f"/production-runs/{rid}/stages/{cut['runStageId']}/approve"), 200)
|
||||
asm_out = next(s for s in c.get(f"/production-runs/{rid}").body["stages"]
|
||||
if s["name"] == "Assemble")["outputs"][0]["runOutputId"]
|
||||
chk.status("close-out: start Assemble",
|
||||
c.post(f"/production-runs/{rid}/stages/{asm['runStageId']}/start"), 200)
|
||||
chk.status("close-out: complete Assemble",
|
||||
c.post(f"/production-runs/{rid}/stages/{asm['runStageId']}/complete",
|
||||
{"outputs": [{"runOutputId": asm_out, "producedQty": TARGET, "scrappedQty": 0}]}), 200)
|
||||
fin = c.post(f"/production-runs/{rid}/stages/{asm['runStageId']}/approve")
|
||||
chk.status("close-out: approve the terminal", fin, 200)
|
||||
if fin.status == 200:
|
||||
chk.check("run completed even though the pool was fully returned (cost 0)",
|
||||
fin.body["runStatus"], "Completed")
|
||||
chk.check("zero-cost receipt still creates a layer", fin.body["receipt"]["layerId"] > 0, True)
|
||||
chk.status("return a leftover after the receipt closed the pool",
|
||||
c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover",
|
||||
{"qty": 1, "reasonCodeId": prod_reason(c, "PRD-LEFTOVER")}),
|
||||
409, "RUN_COST_CLOSED")
|
||||
|
||||
# =====================================================================
|
||||
# M7a — reject-intake
|
||||
# =====================================================================
|
||||
chk.section("M7-1. Reject-intake pulls work back to the parent (FR-MFG-15)")
|
||||
run2, cut2, asm2 = new_run(c, tid, wh)
|
||||
rid2 = run2["runId"]
|
||||
c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/start")
|
||||
d2 = c.get(f"/production-runs/{rid2}").body
|
||||
cut2_out = next(s for s in d2["stages"] if s["name"] == "Cut")["outputs"][0]["runOutputId"]
|
||||
c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/complete",
|
||||
{"outputs": [{"runOutputId": cut2_out, "producedQty": TARGET, "scrappedQty": 0}]})
|
||||
start_at = next(s for s in c.get(f"/production-runs/{rid2}").body["stages"]
|
||||
if s["name"] == "Cut")["actualStartAt"]
|
||||
c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/approve")
|
||||
|
||||
after_approve = c.get(f"/production-runs/{rid2}").body
|
||||
a_cut = next(s for s in after_approve["stages"] if s["name"] == "Cut")
|
||||
a_asm = next(s for s in after_approve["stages"] if s["name"] == "Assemble")
|
||||
chk.check("child Ready before the reject", a_asm["status"], "Ready")
|
||||
chk.check("parent transferred the full quantity",
|
||||
float(a_cut["outputs"][0]["transferredQty"]), float(TARGET))
|
||||
|
||||
rej = c.post(f"/production-runs/{rid2}/stages/{asm2['runStageId']}/reject-intake",
|
||||
{"note": "Frames warped"})
|
||||
chk.status("reject-intake on the Ready child", rej, 200)
|
||||
if rej.status == 200:
|
||||
chk.check("rejecting stage back to Waiting", rej.body["status"], "Waiting")
|
||||
chk.check("one parent pulled back", len(rej.body["pulledBack"]), 1)
|
||||
pb = rej.body["pulledBack"][0]
|
||||
chk.check("pulled-back qty", float(pb["qty"]), float(TARGET))
|
||||
chk.check("parent was Approved", pb["priorParentStatus"], "Approved")
|
||||
chk.check("parent reverted to InProgress", pb["parentStatus"], "InProgress")
|
||||
|
||||
r_cut = next(s for s in rej.body["run"]["stages"] if s["name"] == "Cut")
|
||||
r_asm = next(s for s in rej.body["run"]["stages"] if s["name"] == "Assemble")
|
||||
chk.check("parent transferredQty decremented to 0",
|
||||
float(r_cut["outputs"][0]["transferredQty"]), 0.0)
|
||||
chk.check("parent available to transfer restored",
|
||||
float(r_cut["outputs"][0]["availableToTransfer"]), float(TARGET))
|
||||
chk.check("child deliveredQty cleared", float(r_asm["inputs"][0]["deliveredQty"]), 0.0)
|
||||
chk.check("parent ActualStartAt PRESERVED (FR-MFG-19)", r_cut["actualStartAt"], start_at)
|
||||
chk.check("parent ActualEndAt cleared for rework", r_cut["actualEndAt"], None)
|
||||
chk.check("consumed stock stays consumed",
|
||||
float(r_cut["inputs"][0]["consumedQty"]), float(consumed_units))
|
||||
|
||||
chk.status("reject-intake again with nothing delivered",
|
||||
c.post(f"/production-runs/{rid2}/stages/{asm2['runStageId']}/reject-intake", {}),
|
||||
409, "STAGE_REJECT_INVALID")
|
||||
|
||||
chk.section("M7-2. Re-complete OVERWRITES rather than accumulating")
|
||||
re_done = c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/complete",
|
||||
{"outputs": [{"runOutputId": cut2_out, "producedQty": TARGET, "scrappedQty": 0}]})
|
||||
chk.status("re-complete the parent", re_done, 200)
|
||||
if re_done.status == 200:
|
||||
chk.check("producedQty overwritten, not doubled",
|
||||
float(re_done.body["outputs"][0]["producedQty"]), float(TARGET))
|
||||
re_app = c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/approve")
|
||||
chk.status("re-approve the parent", re_app, 200)
|
||||
if re_app.status == 200:
|
||||
chk.check("child Ready again",
|
||||
next(s for s in re_app.body["stage"]["outputs"]
|
||||
for _ in [0])["transferredQty"] is not None, True)
|
||||
|
||||
# =====================================================================
|
||||
# M7b — terminal reject
|
||||
# =====================================================================
|
||||
chk.section("M7-3. Terminal reject resets the run for a rework pass (FR-MFG-16)")
|
||||
d3 = c.get(f"/production-runs/{rid2}").body
|
||||
asm3 = next(s for s in d3["stages"] if s["name"] == "Assemble")
|
||||
asm3_out = asm3["outputs"][0]["runOutputId"]
|
||||
pool_before = float(d3["costPool"]["net"])
|
||||
|
||||
c.post(f"/production-runs/{rid2}/stages/{asm3['runStageId']}/start")
|
||||
c.post(f"/production-runs/{rid2}/stages/{asm3['runStageId']}/complete",
|
||||
{"outputs": [{"runOutputId": asm3_out, "producedQty": TARGET, "scrappedQty": 0}]})
|
||||
|
||||
trj = c.post(f"/production-runs/{rid2}/stages/{asm3['runStageId']}/reject",
|
||||
{"note": "Final QA failed batch"})
|
||||
chk.status("terminal reject", trj, 200)
|
||||
if trj.status == 200:
|
||||
chk.check("reworkCount incremented", trj.body["reworkCount"], 1)
|
||||
run_after = trj.body["run"]
|
||||
chk.check("run still InProgress", run_after["status"], "InProgress")
|
||||
chk.check("completedAt still null", run_after["completedAt"], None)
|
||||
|
||||
t_cut = next(s for s in run_after["stages"] if s["name"] == "Cut")
|
||||
t_asm = next(s for s in run_after["stages"] if s["name"] == "Assemble")
|
||||
chk.check("entry stage reset to Ready", t_cut["status"], "Ready")
|
||||
chk.check("non-entry stage reset to Waiting", t_asm["status"], "Waiting")
|
||||
chk.check("timings cleared", (t_cut["actualStartAt"], t_cut["actualEndAt"]), (None, None))
|
||||
chk.check("fieldValues cleared", t_cut["fieldValues"], None)
|
||||
chk.check("producedQty cleared", float(t_cut["outputs"][0]["producedQty"]), 0.0)
|
||||
chk.check("transferredQty cleared", float(t_cut["outputs"][0]["transferredQty"]), 0.0)
|
||||
chk.check("deliveredQty cleared", float(t_asm["inputs"][0]["deliveredQty"]), 0.0)
|
||||
|
||||
# The load-bearing half of FR-MFG-16.
|
||||
chk.check("plannedQty PRESERVED", float(t_cut["inputs"][0]["plannedQty"]), float(consumed_units))
|
||||
chk.check("consumedQty PRESERVED", float(t_cut["inputs"][0]["consumedQty"]), float(consumed_units))
|
||||
chk.check("cost pool PRESERVED across the rework", float(run_after["costPool"]["net"]), pool_before)
|
||||
|
||||
snap = [e for e in run_after["events"] if e["eventType"] == "TerminalReject"]
|
||||
chk.check("exactly one snapshot event", len(snap), 1)
|
||||
if snap:
|
||||
chk.check("snapshot records the rework number", snap[0]["payload"]["reworkNumber"], 1)
|
||||
chk.check("snapshot captured every stage", len(snap[0]["payload"]["stages"]), 2)
|
||||
chk.check("snapshot kept the pre-reset produced figure",
|
||||
any(float(o["producedQty"]) == TARGET
|
||||
for s in snap[0]["payload"]["stages"] for o in s["outputs"]), True)
|
||||
chk.check("reject note recorded", snap[0]["note"], "Final QA failed batch")
|
||||
|
||||
# This is the single behavioural rule that makes FR-MFG-16 work: a start always consumes
|
||||
# max(0, plannedBase - consumedQty), never the full planned figure. After a rework the
|
||||
# material is still in the pool, so re-consuming it would double-charge the run.
|
||||
chk.section("M7-4. Rework restart after RAISING the planned qty consumes only the delta")
|
||||
cut2_in = next(i for s in c.get(f"/production-runs/{rid2}").body["stages"]
|
||||
for i in s["inputs"] if s["name"] == "Cut")["runInputId"]
|
||||
raised = consumed_units + 10
|
||||
chk.status("raise the planned qty on the reset (Ready) stage",
|
||||
c.put(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/quantities",
|
||||
{"inputs": [{"id": cut2_in, "plannedQty": raised}], "outputs": []}), 200)
|
||||
|
||||
oh_before = on_hand(c, raw, wh)
|
||||
pool_pre = float(c.get(f"/production-runs/{rid2}").body["costPool"]["net"])
|
||||
restart = c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/start")
|
||||
chk.status("restart after the raise", restart, 200)
|
||||
if restart.status == 200:
|
||||
chk.check("consumed ONLY the 10-unit delta, not the full 60",
|
||||
float(restart.body["consumed"][0]["qty"]), 10.0)
|
||||
chk.check("on-hand fell by only the delta", on_hand(c, raw, wh), oh_before - 10.0)
|
||||
chk.check("consumedQty accumulated to the new planned total",
|
||||
float(next(i for s in c.get(f"/production-runs/{rid2}").body["stages"]
|
||||
for i in s["inputs"] if i["runInputId"] == cut2_in)["consumedQty"]),
|
||||
float(raised))
|
||||
chk.check("pool grew by only the delta's value",
|
||||
float(c.get(f"/production-runs/{rid2}").body["costPool"]["net"]),
|
||||
pool_pre + 10.0 * UNIT_COST)
|
||||
|
||||
chk.section("M7-5. A second rework, restarted UNCHANGED, consumes nothing at all")
|
||||
# Drive the run round again to get the entry stage back to Ready with consumed == planned.
|
||||
d5 = c.get(f"/production-runs/{rid2}").body
|
||||
c5 = next(s for s in d5["stages"] if s["name"] == "Cut")
|
||||
a5 = next(s for s in d5["stages"] if s["name"] == "Assemble")
|
||||
c.post(f"/production-runs/{rid2}/stages/{c5['runStageId']}/complete",
|
||||
{"outputs": [{"runOutputId": c5["outputs"][0]["runOutputId"],
|
||||
"producedQty": TARGET, "scrappedQty": 0}]})
|
||||
c.post(f"/production-runs/{rid2}/stages/{c5['runStageId']}/approve")
|
||||
c.post(f"/production-runs/{rid2}/stages/{a5['runStageId']}/start")
|
||||
c.post(f"/production-runs/{rid2}/stages/{a5['runStageId']}/complete",
|
||||
{"outputs": [{"runOutputId": a5["outputs"][0]["runOutputId"],
|
||||
"producedQty": TARGET, "scrappedQty": 0}]})
|
||||
second = c.post(f"/production-runs/{rid2}/stages/{a5['runStageId']}/reject", {"note": "again"})
|
||||
chk.status("second terminal reject", second, 200)
|
||||
if second.status == 200:
|
||||
chk.check("reworkCount now 2", second.body["reworkCount"], 2)
|
||||
chk.check("two snapshot events retained",
|
||||
sum(1 for e in second.body["run"]["events"] if e["eventType"] == "TerminalReject"), 2)
|
||||
|
||||
oh2 = on_hand(c, raw, wh)
|
||||
prdi2 = len(ledger(c, rid2, "PRDI"))
|
||||
again = c.post(f"/production-runs/{rid2}/stages/{c5['runStageId']}/start")
|
||||
chk.status("restart with planned unchanged", again, 200)
|
||||
if again.status == 200:
|
||||
chk.check("nothing consumed (delta is zero)", len(again.body["consumed"]), 0)
|
||||
chk.check("on-hand unchanged", on_hand(c, raw, wh), oh2)
|
||||
chk.check("no new PRDI ledger row", len(ledger(c, rid2, "PRDI")), prdi2)
|
||||
|
||||
chk.section("M7-6. Cancel returns net consumed stock (FR-MFG-17)")
|
||||
run4, cut4, _ = new_run(c, tid, wh)
|
||||
rid4 = run4["runId"]
|
||||
in4 = cut4["inputs"][0]["runInputId"]
|
||||
c.post(f"/production-runs/{rid4}/stages/{cut4['runStageId']}/start")
|
||||
first = float(next(i for s in c.get(f"/production-runs/{rid4}").body["stages"]
|
||||
for i in s["inputs"] if i["runInputId"] == in4)["consumedQty"])
|
||||
chk.check("first start consumed the full planned qty", first, float(consumed_units))
|
||||
|
||||
# =====================================================================
|
||||
# M7c — cancel
|
||||
# =====================================================================
|
||||
oh_pre_cancel = on_hand(c, raw, wh)
|
||||
cancelled = c.post(f"/production-runs/{rid4}/cancel",
|
||||
{"reasonCodeId": prod_reason(c, "PRD-CANCEL"), "note": "Order cancelled"})
|
||||
chk.status("cancel the run", cancelled, 200)
|
||||
if cancelled.status == 200:
|
||||
chk.check("run Cancelled", cancelled.body["status"], "Cancelled")
|
||||
chk.check("one return posted", len(cancelled.body["returns"]), 1)
|
||||
r0 = cancelled.body["returns"][0]
|
||||
chk.check("returned the net consumed qty", float(r0["qty"]), float(consumed_units))
|
||||
chk.check("returned at the consumed weighted cost", float(r0["unitCost"]), UNIT_COST)
|
||||
chk.check("layer id populated", r0["layerId"] > 0, True)
|
||||
chk.check("ledgerRefs populated", len(cancelled.body["ledgerRefs"]), 1)
|
||||
chk.check("on-hand restored", on_hand(c, raw, wh), oh_pre_cancel + consumed_units)
|
||||
|
||||
prdc = ledger(c, rid4, "PRDC")
|
||||
chk.check("one PRDC row", len(prdc), 1)
|
||||
if prdc:
|
||||
chk.check("PRDC direction is In", prdc[0]["direction"], "In")
|
||||
chk.check("PRDC value = the exact consumed residual",
|
||||
float(prdc[0]["value"]), consumed_units * UNIT_COST)
|
||||
|
||||
after_cancel = c.get(f"/production-runs/{rid4}").body
|
||||
chk.check("cancel reason recorded", after_cancel["cancelReasonCodeId"] is not None, True)
|
||||
chk.check("completedAt stays null on a cancel", after_cancel["completedAt"], None)
|
||||
chk.check("pool nets to zero after the cancel return",
|
||||
float(after_cancel["costPool"]["net"]), 0.0)
|
||||
chk.check("cancel event logged",
|
||||
any(e["eventType"] == "Cancel" for e in after_cancel["events"]), True)
|
||||
|
||||
chk.section("M7-7. Cancel guards")
|
||||
chk.status("cancel an already-cancelled run",
|
||||
c.post(f"/production-runs/{rid4}/cancel", {"reasonCodeId": prod_reason(c, "PRD-CANCEL")}),
|
||||
409, "RUN_NOT_CANCELLABLE")
|
||||
chk.status("cancel a COMPLETED run",
|
||||
c.post(f"/production-runs/{rid}/cancel", {"reasonCodeId": prod_reason(c, "PRD-CANCEL")}),
|
||||
409, "RUN_NOT_CANCELLABLE")
|
||||
run5, _, _ = new_run(c, tid, wh)
|
||||
chk.status("cancel with no reason code",
|
||||
c.post(f"/production-runs/{run5['runId']}/cancel", {}), 400, "REASON_CODE_REQUIRED")
|
||||
|
||||
return chk.finish("M6+M7")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Run the whole manufacturing smoke suite in dependency order.
|
||||
|
||||
python Backend/smoke/run_all.py
|
||||
|
||||
Order matters: m4 creates the isolated SMOKE-PRD warehouse and drains it, m5 consumes the
|
||||
run m4 leaves with its terminal stage InProgress. Each script is individually re-runnable,
|
||||
but m5 deliberately refuses to run twice against an already-approved terminal stage — that
|
||||
guard is what stops it silently asserting against the wrong state.
|
||||
|
||||
Prerequisites: ERPCore on :5224 and AuthHex on :5602 (override with ERP_SMOKE_API /
|
||||
ERP_SMOKE_AUTH / ERP_SMOKE_USER / ERP_SMOKE_PASSWORD).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPTS = [
|
||||
("M2 templates + graph validation", "m2_templates.py"),
|
||||
("M3 run creation / board / quantities", "m3_runs.py"),
|
||||
("M4 stage start / complete / approve / transfer", "m4_stage_actions.py"),
|
||||
("M4b UOM conversion on stock inputs", "m4b_uom_conversion.py"),
|
||||
("M5 terminal receipt + cost pool", "m5_receipt.py"),
|
||||
("M6+M7 leftover / rework / cancel", "m6_m7_leftover_rework_cancel.py"),
|
||||
]
|
||||
|
||||
SUMMARY = re.compile(r"^(\S+): (\d+)/(\d+) assertions passed")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
here = Path(__file__).parent
|
||||
results = []
|
||||
failed = False
|
||||
|
||||
for label, script in SCRIPTS:
|
||||
print(f"\n{'=' * 70}\n{label} ({script})\n{'=' * 70}", flush=True)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, script, *sys.argv[1:]],
|
||||
cwd=here, capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||
)
|
||||
sys.stdout.write(proc.stdout)
|
||||
if proc.stderr.strip():
|
||||
sys.stderr.write(proc.stderr)
|
||||
|
||||
passed = total = 0
|
||||
for line in proc.stdout.splitlines():
|
||||
m = SUMMARY.match(line.strip())
|
||||
if m:
|
||||
passed, total = int(m.group(2)), int(m.group(3))
|
||||
results.append((label, passed, total, proc.returncode))
|
||||
if proc.returncode != 0:
|
||||
failed = True
|
||||
|
||||
print(f"\n{'=' * 70}\nSUITE SUMMARY\n{'=' * 70}")
|
||||
grand_passed = grand_total = 0
|
||||
for label, passed, total, rc in results:
|
||||
grand_passed += passed
|
||||
grand_total += total
|
||||
state = "OK " if rc == 0 else "FAIL"
|
||||
print(f" [{state}] {label:<48} {passed}/{total}")
|
||||
print(f"\n TOTAL: {grand_passed}/{grand_total} assertions"
|
||||
+ (" — ALL GREEN" if not failed else " (SUITE FAILED)"))
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,259 @@
|
||||
"""Shared harness for the manufacturing smoke tests (docs/30-BACKEND-PHASE2.md).
|
||||
|
||||
The repo has no test project; verification is a live smoke test against local Postgres
|
||||
with a real AuthHex session, with the assertion count recorded in Backend/PROGRESS.md.
|
||||
These scripts make that repeatable instead of ad-hoc curl.
|
||||
|
||||
Auth note: ERPCore validates AuthHex's RS256 tokens offline against a statically
|
||||
configured public key, so we log in to AuthHex *directly* and send the access token as a
|
||||
Bearer header. That deliberately bypasses ERPCore's own /auth/login proxy, which would
|
||||
otherwise need AuthHex:BaseUrl to match the port AuthHex actually listens on.
|
||||
|
||||
Usage:
|
||||
python m2_templates.py [--api URL] [--auth URL] [--user EMAIL] [--password PW]
|
||||
|
||||
Environment variables (ERP_SMOKE_API, ERP_SMOKE_AUTH, ERP_SMOKE_USER,
|
||||
ERP_SMOKE_PASSWORD) override the defaults; command-line flags override those.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
# Server messages and box-drawing output contain non-cp1252 characters, and the default
|
||||
# Windows console codepage would raise UnicodeEncodeError mid-report — losing exactly the
|
||||
# diagnostic text a failing assertion needs to show.
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
|
||||
DEFAULTS = {
|
||||
"api": "http://localhost:5224/api/v1",
|
||||
"auth": "http://localhost:5602",
|
||||
"user": "admin@gmail.com",
|
||||
"password": "Naveen@99",
|
||||
}
|
||||
|
||||
|
||||
def parse_args(description: str) -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description=description)
|
||||
for key, default in DEFAULTS.items():
|
||||
p.add_argument(f"--{key}", default=os.environ.get(f"ERP_SMOKE_{key.upper()}", default))
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
class Response:
|
||||
__slots__ = ("status", "body", "headers")
|
||||
|
||||
def __init__(self, status: int, body, headers: dict):
|
||||
self.status = status
|
||||
self.body = body
|
||||
self.headers = headers
|
||||
|
||||
@property
|
||||
def code(self):
|
||||
"""The RFC 7807 domain error code, when the body carries one (docs/11 §1.8)."""
|
||||
return self.body.get("code") if isinstance(self.body, dict) else None
|
||||
|
||||
@property
|
||||
def etag(self):
|
||||
return self.headers.get("ETag")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{self.status} code={self.code}>"
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, api: str, token: str):
|
||||
self.api = api.rstrip("/")
|
||||
self.token = token
|
||||
|
||||
def request(self, method: str, path: str, body=None, if_match: str | None = None,
|
||||
idempotency_key: str | None = None) -> Response:
|
||||
url = f"{self.api}{path}"
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
req.add_header("Accept", "application/json")
|
||||
req.add_header("Authorization", f"Bearer {self.token}")
|
||||
if data is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if if_match:
|
||||
req.add_header("If-Match", if_match)
|
||||
if idempotency_key:
|
||||
req.add_header("Idempotency-Key", idempotency_key)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req) as r:
|
||||
raw = r.read()
|
||||
return Response(r.status, _decode(raw), dict(r.headers))
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read()
|
||||
return Response(e.code, _decode(raw), dict(e.headers))
|
||||
|
||||
def get(self, path):
|
||||
return self.request("GET", path)
|
||||
|
||||
def post(self, path, body=None, **kw):
|
||||
return self.request("POST", path, body, **kw)
|
||||
|
||||
def put(self, path, body=None, **kw):
|
||||
return self.request("PUT", path, body, **kw)
|
||||
|
||||
def patch(self, path, body=None, **kw):
|
||||
return self.request("PATCH", path, body, **kw)
|
||||
|
||||
|
||||
def _decode(raw: bytes):
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return raw.decode(errors="replace")
|
||||
|
||||
|
||||
def login(auth_url: str, identifier: str, password: str) -> str:
|
||||
"""Obtain an AuthHex access token via its `{functionName, payload}` envelope."""
|
||||
payload = {
|
||||
"functionName": "loginUser",
|
||||
"payload": {"identifier": identifier, "password": password, "deviceName": "erp-smoke"},
|
||||
"reference": "",
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
f"{auth_url.rstrip('/')}/api/user",
|
||||
data=json.dumps(payload).encode(),
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as r:
|
||||
envelope = json.loads(r.read())
|
||||
except urllib.error.URLError as e:
|
||||
sys.exit(f"FATAL: cannot reach AuthHex at {auth_url} ({e}). Start ERP_Auth_Service first.")
|
||||
|
||||
if not envelope.get("success") or not envelope.get("data"):
|
||||
sys.exit(f"FATAL: AuthHex login failed: {envelope.get('message')!r}")
|
||||
return envelope["data"]["accessToken"]
|
||||
|
||||
|
||||
class Checker:
|
||||
"""Counts assertions so the pass total can be recorded in PROGRESS.md."""
|
||||
|
||||
def __init__(self):
|
||||
self.passed = 0
|
||||
self.failed = 0
|
||||
|
||||
def check(self, label: str, actual, expected) -> bool:
|
||||
ok = actual == expected
|
||||
if ok:
|
||||
self.passed += 1
|
||||
print(f" PASS {label}")
|
||||
else:
|
||||
self.failed += 1
|
||||
print(f" FAIL {label}\n expected: {expected!r}\n actual: {actual!r}")
|
||||
return ok
|
||||
|
||||
def status(self, label: str, response: Response, expected_status: int, expected_code: str | None = None):
|
||||
ok = self.check(f"{label} -> {expected_status}", response.status, expected_status)
|
||||
if expected_code is not None:
|
||||
ok = self.check(f"{label} -> code {expected_code}", response.code, expected_code) and ok
|
||||
if not ok and response.status >= 400:
|
||||
detail = response.body.get("detail") if isinstance(response.body, dict) else response.body
|
||||
print(f" server said: {detail}")
|
||||
return ok
|
||||
|
||||
def section(self, title: str):
|
||||
print(f"\n--- {title} ---")
|
||||
|
||||
def finish(self, name: str) -> int:
|
||||
total = self.passed + self.failed
|
||||
print(f"\n{'=' * 60}\n{name}: {self.passed}/{total} assertions passed"
|
||||
+ (f" ({self.failed} FAILED)" if self.failed else " — ALL GREEN")
|
||||
+ f"\n{'=' * 60}")
|
||||
return 1 if self.failed else 0
|
||||
|
||||
|
||||
def bootstrap(description: str):
|
||||
"""Standard entry point: parse args, log in, return (client, checker, args)."""
|
||||
args = parse_args(description)
|
||||
token = login(args.auth, args.user, args.password)
|
||||
return Client(args.api, token), Checker(), args
|
||||
|
||||
|
||||
# --- stock fixtures ----------------------------------------------------------
|
||||
#
|
||||
# Seeding matters more than it looks. A positive stock ADJUSTMENT is the obvious way to
|
||||
# create on-hand, but StockMutator's inbound path costs it at *last cost*, which is 0.00
|
||||
# when the item has no prior layers. Stock seeded that way makes every cost-pool assertion
|
||||
# pass trivially against zeros and proves nothing. A direct (no-PO) GRN lets us state the
|
||||
# unit cost explicitly, so consumption produces a real, checkable value.
|
||||
|
||||
|
||||
def drain_stock(c, warehouse_id: int) -> list:
|
||||
"""
|
||||
Zero out every item's on-hand in a warehouse via one negative adjustment.
|
||||
|
||||
Needed because these scripts are re-runnable and FIFO is oldest-first: stock left behind
|
||||
by a previous execution is consumed *before* anything seeded now. If an earlier run left
|
||||
zero-cost layers (as an adjustment-based seed does), a later run's cost assertions would
|
||||
silently read 0.00 and pass against nothing. Draining first makes each execution start
|
||||
from a known-empty warehouse.
|
||||
"""
|
||||
rows = c.get(f"/stock/on-hand/list?warehouseId={warehouse_id}&pageSize=200").body["items"]
|
||||
lines = [{"itemId": r["itemId"], "qtyDelta": -float(r["onHand"])}
|
||||
for r in rows if float(r["onHand"]) > 0]
|
||||
if not lines:
|
||||
return []
|
||||
|
||||
reasons = c.get("/reason-codes?context=Adjustment&pageSize=5").body["items"]
|
||||
if not reasons:
|
||||
sys.exit("FATAL: no Adjustment reason codes seeded.")
|
||||
|
||||
res = c.post("/stock-adjustments", {
|
||||
"warehouseId": warehouse_id,
|
||||
"reasonCodeId": reasons[0]["reasonCodeId"],
|
||||
"lines": lines,
|
||||
})
|
||||
if res.status != 201:
|
||||
sys.exit(f"FATAL: could not drain the smoke warehouse: {res.status} {res.body}")
|
||||
return lines
|
||||
|
||||
|
||||
def ensure_vendor(c) -> int:
|
||||
existing = c.get("/vendors?pageSize=1").body["items"]
|
||||
if existing:
|
||||
return existing[0]["vendorId"]
|
||||
created = c.post("/vendors", {"code": "SMOKE-V", "name": "Smoke vendor"})
|
||||
if created.status != 201:
|
||||
sys.exit(f"FATAL: could not create a vendor: {created.status} {created.body}")
|
||||
return created.body["vendorId"]
|
||||
|
||||
|
||||
def seed_costed_stock(c, warehouse_id: int, lines, vendor_id: int | None = None) -> None:
|
||||
"""
|
||||
Create on-hand at explicit unit costs via a direct GRN + confirm.
|
||||
|
||||
`lines` is an iterable of (item_id, uom_id, qty, unit_cost).
|
||||
"""
|
||||
vendor_id = vendor_id or ensure_vendor(c)
|
||||
grn = c.post("/grns", {
|
||||
"vendorId": vendor_id,
|
||||
"warehouseId": warehouse_id,
|
||||
"lines": [
|
||||
{"itemId": i, "uomId": u, "qty": q, "unitCost": cost, "discountPct": 0, "vatPct": 0}
|
||||
for (i, u, q, cost) in lines
|
||||
],
|
||||
})
|
||||
if grn.status != 201:
|
||||
sys.exit(f"FATAL: could not create the seeding GRN: {grn.status} {grn.body}")
|
||||
|
||||
confirmed = c.post(f"/grns/{grn.body['grnId']}/confirm")
|
||||
if confirmed.status != 200:
|
||||
sys.exit(f"FATAL: could not confirm the seeding GRN: {confirmed.status} {confirmed.body}")
|
||||
Reference in New Issue
Block a user