15ddac178c
- Updated production.spec.ts, stock-adjustments.spec.ts, and stock-transfers.spec.ts to eliminate UOM references in API seeder and test cases. - Adjusted ApiSeeder methods to remove UOM parameters from stock receiving and production template creation. - Revised documentation to reflect changes in UOM handling, emphasizing that stock is counted in base UOM only. - Introduced new enums for MeasureUnit and StageQtyUnit to clarify content size and stage input quantities. - Implemented ItemContent service to validate and normalize content sizes. - Updated smoke tests to validate production stage inputs expressed in content units, ensuring correct consumption calculations. - Modified frontend UOM label handling to reflect the removal of per-line UOMs in document lines.
355 lines
17 KiB
Python
355 lines
17 KiB
Python
"""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, "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, "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", "qtyPerBatch": 1},
|
|
{"source": "Upstream", "fromOutputKey": "tmp-cushion", "qtyPerBatch": 1},
|
|
],
|
|
# Terminal output must name the finished item (FR-MFG-05), and takes its unit
|
|
# from that item — sending a uomId as well is rejected.
|
|
"outputs": [{"key": "tmp-chair", "name": "Chair", "itemId": finished_item,
|
|
"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, "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"),
|
|
"qtyUnit": i["qtyUnit"], "qtyPerBatch": i["qtyPerBatch"]}
|
|
for i in s["inputs"]
|
|
],
|
|
# uomId round-trips as null on an item-bearing output and as the WIP label
|
|
# otherwise, so echoing it back verbatim is correct either way.
|
|
"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())
|