"""Phase 5 shop API: the assign flow and its validations, the shop transitions with their exact log texts, the SUBMIT preconditions one by one, routing with carried marks, the checklist and its templates, stage field CAS, welds, heat tags and the heat table with their texts and validation, heat map borrowing, VISUAL labor hours, and the shop board, stations, stall and people endpoints.""" from __future__ import annotations import time from collections.abc import Iterator from typing import Any import pytest from fakedir import FakeDirectory from fastapi.testclient import TestClient from pkthelpers import step from pshelpers import ORIGIN_HEADERS, app_conn, needs_pg from shophelpers import ( IT, MSG_CHECKLIST, MSG_FILLER, MSG_OTHER_DEPT, MSG_QC, MSG_WELDS, PLAIN_KEYS, SIGN, WELD_KEYS, Shop, add_weld, all_rows, draft, find_log, finish_stage, fresh, im, in_progress, in_review, issue_co, last_log, log_rows, make_unit, ok, one, open_shop, pull_all, ready_packet, ready_skipped, refused, release, run, set_filler, set_limits, set_running_since, set_stamp, set_templates, start, submit, tag_of, tick_all, to_station, to_warehouse, weld_of, ) from packetstation.settings import Settings pytestmark = needs_pg MSG_WELDER = "Pick a welder in this department." MSG_VOID = "This sheet is VOID. Mark the current sheet." MSG_TYPE = "Pick a weld type from the list." MSG_PROCESS = "Pick a process from the list." MSG_NOT_LEAD = "You do not lead a department." SHOP_DEPTS = ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "13", "14"] @pytest.fixture def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]: yield from open_shop(pg_settings, fake_dir) def page_label(pkt: dict[str, Any], n: int) -> str: page = next(p for p in pkt["pages"] if p["n"] == n) return page["dwg"] or f"sheet {n}" # ================================================================ assign def test_assign_validations(shop: Shop) -> None: s = shop.settings set_stamp(s, "weld.andy", "75") pkt = ready_packet(shop) wes = shop("sup.wes") andy = shop.uid("weld.andy") # a station of another department r = step(wes, pkt, "assign", station_id=shop.stall("06", "Stall 1"), welder_id=andy) refused(r, 422, "invalid_station") # welders outside the department: Q.C., a supervisor of 06, Clerical for sam in ("qc.quinn", "multi.mia", "clerk.clara"): r = step(wes, pkt, "assign", station_id=shop.stall(), welder_id=shop.uid(sam)) refused(r, 422, "invalid_welder", MSG_WELDER) # no welder or no station refused(step(wes, pkt, "assign", station_id=shop.stall()), 403, "precondition", "Pick a station and a welder.") # a worker limited to Stall 2 set_limits(s, "weld.andy", [shop.stall("03", "Stall 2")]) r = step(wes, pkt, "assign", station_id=shop.stall(), welder_id=andy) refused(r, 422, "station_limit", "Andy Welder is limited to Stall 2.") set_limits(s, "weld.andy", [shop.stall("03", "Stall 2"), shop.stall("03", "Stall 3")]) r = step(wes, pkt, "assign", station_id=shop.stall(), welder_id=andy) refused(r, 422, "station_limit", "Andy Welder is limited to Stall 2, Stall 3.") # a bad stamp override set_limits(s, "weld.andy", []) r = step(wes, pkt, "assign", station_id=shop.stall(), welder_id=andy, stamp="no good!") refused(r, 422, "invalid_stamp") assert fresh(wes, pkt["id"])["status"] == "ready" # the stamp defaults from users.welder_stamp; the stage gets the same three values out = to_station(shop, pkt) assert out["station"]["name"] == "Stall 1" and out["welder"]["id"] == andy and out["welder"]["stamp"] == "75" st = out["stage"] assert st["station"]["id"] == shop.stall() and st["welder"]["id"] == andy and st["welder"]["stamp"] == "75" log = last_log(wes, pkt["id"]) assert (log["action"], log["note"]) == ("Sent to Stall 1", "Andy Welder [75]") assert log["by"]["display_name"] == "Wes Super" # unassign clears them; a stamp override wins over the user's stamp out = ok(step(wes, out, "unassign")) assert out["status"] == "ready" and out["station"] is None and out["welder"] is None assert out["stage"]["station"] is None and out["stage"]["welder"] is None assert last_log(wes, pkt["id"])["action"] == "Taken off Stall 1" out = to_station(shop, out, station="Stall 4", stamp="A7") assert out["welder"]["stamp"] == "A7" and out["stage"]["welder"]["stamp"] == "A7" assert (last_log(wes, pkt["id"])["action"], last_log(wes, pkt["id"])["note"]) == ( "Sent to Stall 4", "Andy Welder [A7]", ) assert one(s, "SELECT welder_stamp FROM users WHERE sam_account = 'weld.andy'")[0] == "75" # station limits bind workers only: lead.lou limited to Stall 2 may still be put on Stall 1 out = ok(step(wes, out, "unassign")) set_limits(s, "lead.lou", [shop.stall("03", "Stall 2")]) out = to_station(shop, out, welder="lead.lou") assert out["welder"]["display_name"] == "Lou Lead" log = last_log(wes, pkt["id"]) assert (log["action"], log["note"]) == ("Sent to Stall 1", "Lou Lead") # the lead assigns too out = ok(step(shop("lead.lou"), out, "unassign")) to_station(shop, out, by="lead.lou") def test_people(shop: Shop) -> None: s = shop.settings set_stamp(s, "weld.andy", "75") set_limits(s, "weld.andy", [shop.stall("03", "Stall 2")]) r = shop("weld.andy").get("/api/departments/03/people") people = ok(r) names = [p["display_name"] for p in people] assert names == sorted(names) by = {p["display_name"]: p for p in people} assert {"Andy Welder", "Lou Lead", "Wes Super", "Bo Both", "Dave Dup"} <= set(by) assert not {"Mia Multi", "Quinn QC", "Clara Clerk", "Ann Admin"} & set(by) assert by["Andy Welder"] == { "id": shop.uid("weld.andy"), "display_name": "Andy Welder", "stamp": "75", "level": "worker", "station_limits": [shop.stall("03", "Stall 2")], } assert by["Lou Lead"]["level"] == "lead" and by["Wes Super"]["level"] == "supervisor" assert by["Bo Both"]["level"] == "supervisor" and by["Lou Lead"]["station_limits"] == [] six = ok(shop("sup.wes").get("/api/departments/06/people")) assert [p["display_name"] for p in six] == ["Mia Multi"] for code in ("ZZ", "WH", "CL", "QC"): assert shop("sup.wes").get(f"/api/departments/{code}/people").status_code == 404, code # a session is needed anon = TestClient(shop.main.app, headers=ORIGIN_HEADERS) assert anon.get("/api/departments/03/people").status_code == 401 # ================================================================ the shop transitions and their texts def test_unassign_and_pull_back_freeze_the_clock(shop: Shop) -> None: wes = shop("sup.wes") pkt = to_station(shop, ready_packet(shop)) pkt = start(shop, pkt) log = last_log(wes, pkt["id"]) assert (log["action"], log["note"]) == ("Work started", "Stall 1") assert pkt["stage"]["clock"]["running"] is True and pkt["stage"]["clock"]["running_since"] assert pkt["stage"]["clock"]["started_at"] set_running_since(shop.settings, pkt["id"], 90) out = ok(step(wes, pkt, "pull_back")) assert out["status"] == "ready" and out["station"] is None and out["welder"] is None assert last_log(wes, pkt["id"])["action"] == "Pulled back from Stall 1" clock = out["stage"]["clock"] assert clock["running"] is False and clock["running_since"] is None assert 90_000 <= clock["stored_ms"] < 120_000 and clock["elapsed_ms"] == clock["stored_ms"] frozen = clock["stored_ms"] time.sleep(1.1) again = fresh(wes, pkt["id"])["stage"]["clock"] assert again["elapsed_ms"] == frozen and again["running"] is False # assigned again and unassigned before a start: the clock stays frozen at the same value out = to_station(shop, out) out = ok(step(wes, out, "unassign")) assert out["stage"]["clock"]["elapsed_ms"] == frozen assert last_log(wes, pkt["id"])["action"] == "Taken off Stall 1" def test_submit_preconditions_one_by_one(shop: Shop) -> None: andy = shop("weld.andy") pkt = in_progress(shop) mine = fresh(andy, pkt["id"]) assert mine["blocked"]["submit"] == [MSG_CHECKLIST, MSG_WELDS, MSG_FILLER] refused(step(andy, pkt, "submit", sign=SIGN), 403, "precondition", MSG_CHECKLIST) pkt = tick_all(shop, pkt) assert fresh(andy, pkt["id"])["blocked"]["submit"] == [MSG_WELDS, MSG_FILLER] refused(step(andy, pkt, "submit", sign=SIGN), 403, "precondition", MSG_WELDS) pkt = add_weld(shop, pkt) pkt = add_weld(shop, pkt, fx=0.6, fy=0.4) refused(step(andy, pkt, "submit", sign=SIGN), 403, "precondition", MSG_FILLER) pkt = set_filler(shop, pkt) mine = fresh(andy, pkt["id"]) assert "submit" in mine["can"] and "submit" not in mine["blocked"] r = refused(step(andy, pkt, "submit"), 400, "signature_required") assert r["meaning"] == "Submit work to the supervisor" set_running_since(shop.settings, pkt["id"], 54 * 60) out = ok(step(andy, pkt, "submit", sign=SIGN)) assert out["status"] == "review" and out["return_note"] is None assert out["stage"]["clock"]["running"] is False and out["station"]["name"] == "Stall 1" log = last_log(andy, pkt["id"]) assert (log["action"], log["note"], log["signed"]) == ("Submitted to supervisor", "2 welds · 0.9 h", True) sig = one( shop.settings, "SELECT signature_id FROM packet_transitions WHERE packet_id = %s AND action = 'submit'", (pkt["id"],), ) assert sig[0] is not None def test_return_to_station(shop: Shop) -> None: wes = shop("sup.wes") pkt = in_review(shop) refused(step(wes, pkt, "return_to_station"), 400, "reason_required") reason = "Weld 2 undercut, grind and reweld" out = ok(step(wes, pkt, "return_to_station", reason=reason)) assert out["status"] == "assigned" and out["return_note"] == reason assert out["station"]["name"] == "Stall 1" and out["welder"]["id"] == shop.uid("weld.andy") log = last_log(wes, pkt["id"]) assert (log["action"], log["note"]) == ("Returned to Stall 1", reason) # weld.andy starts again and submits: the return note clears out = start(shop, out) out = submit(shop, out) assert out["return_note"] is None def test_send_to_qc(shop: Shop) -> None: wes = shop("sup.wes") pkt = in_review(shop) for body in ( {"book": True, "spool": True}, {"book": True, "spool": False, "location": "Bay 2"}, {"book": False, "spool": True, "location": "Bay 2"}, {"book": True, "spool": True, "location": " "}, ): refused(step(wes, pkt, "send_to_qc", sign=SIGN, **body), 403, "precondition", MSG_QC) refused( step(shop("lead.lou"), pkt, "send_to_qc", book=True, spool=True, location="Bay 2", sign=SIGN), 403, "forbidden" ) out = ok(step(wes, pkt, "send_to_qc", book=True, spool=True, location="Bay 2", sign=SIGN)) assert out["status"] == "qc" and out["qc"]["location"] == "Bay 2" assert out["qc"]["book"] is True and out["qc"]["spool"] is True and out["qc"]["sent_at"] log = last_log(wes, pkt["id"]) assert (log["action"], log["note"], log["signed"]) == ("Sent to Q.C.", "Spool at Bay 2", True) def test_send_to_qc_note(shop: Shop) -> None: wes = shop("sup.wes") pkt = in_review(shop) ok(step(wes, pkt, "send_to_qc", book=True, spool=True, location="Bay 2", note="Top rack", sign=SIGN)) assert last_log(wes, pkt["id"])["note"] == "Spool at Bay 2 · Top rack" def test_route_from_review_to_hydro(shop: Shop) -> None: wes = shop("sup.wes") pkt = in_progress(shop) pkt = ok( shop("weld.andy").post(f"/api/packets/{pkt['id']}/heat-tags", json={"page": 1, "item": 3, "fx": 0.3, "fy": 0.3}) ) pkt = submit(shop, finish_stage(shop, pkt, welds=2)) first_stage = pkt["stage"]["id"] assert [w["carried"] for w in pkt["welds"]] == [False, False] assert [t["carried"] for t in pkt["heat_tags"]] == [False] refused(step(wes, pkt, "route_from_review", dept="03", sign=SIGN), 403, "precondition", MSG_OTHER_DEPT) refused(step(wes, pkt, "route_from_review", dept="09"), 400, "signature_required") out = ok(step(wes, pkt, "route_from_review", dept="09", sign=SIGN)) assert out["status"] == "ready" and out["dept"]["code"] == "09" assert out["station"] is None and out["welder"] is None and out["return_note"] is None assert out["stage"]["dept_code"] == "09" and out["stage"]["id"] != first_stage assert out["stage"]["station"] is None and out["stage"]["welder"] is None closed = [x for x in out["stages"] if x["id"] == first_stage] assert len(closed) == 1 and closed[0]["closed_at"] and closed[0]["dept_code"] == "03" assert closed[0]["clock"]["running"] is False log = last_log(wes, pkt["id"]) assert (log["action"], log["note"], log["signed"]) == ( "Routed to 09 Hydro", "Supervisor, Q.C. skipped · from 03 Weld Parts", True, ) # the marks stay on the packet and read as carried assert len(out["welds"]) == 2 and all(w["carried"] is True for w in out["welds"]) assert all(w["dept_code"] == "03" and w["stage_id"] == first_stage for w in out["welds"]) assert len(out["heat_tags"]) == 1 and out["heat_tags"][0]["carried"] is True # 09 is not a weld department: the plain checklist assert [i["key"] for i in out["checklist_items"]] == list(PLAIN_KEYS) assert out["stage"]["checklist"] == {} def test_route_to_a_weld_department_needs_its_own_weld(shop: Shop) -> None: """welds_recorded counts the current department's welds only.""" pkt = in_review(shop) out = ok(step(shop("sup.wes"), pkt, "route_from_review", dept="06", sign=SIGN)) assert out["dept"]["code"] == "06" and all(w["carried"] for w in out["welds"]) mia = shop("multi.mia") out = to_station(shop, out, by="multi.mia", welder="multi.mia", dept="06", station="Stall 2") out = start(shop, out, by="multi.mia") out = tick_all(shop, out, by="multi.mia") out = set_filler(shop, out, by="multi.mia") mine = fresh(mia, out["id"]) assert mine["blocked"]["submit"] == [MSG_WELDS] refused(step(mia, out, "submit", sign=SIGN), 403, "precondition", MSG_WELDS) out = add_weld(shop, out, by="multi.mia") w = max(out["welds"], key=lambda x: x["weld_no"]) assert w["dept_code"] == "06" and w["carried"] is False and w["weld_no"] == 2 out = submit(shop, out, by="multi.mia") assert last_log(mia, out["id"])["note"].startswith("1 weld") # ================================================================ checklist and templates def test_checklist_per_key(shop: Shop) -> None: s = shop.settings set_stamp(s, "weld.andy", "75") andy = shop("weld.andy") pkt = in_progress(shop, drawing="D-5001", rev="B") items = {i["key"]: i for i in pkt["checklist_items"]} assert list(items) == list(WELD_KEYS) assert items["dwg"]["text"] == "Drawing D-5001 Rev B matches the packet" assert items["dwg"]["group"] == "Before welding" and items["vt"]["group"] == "After welding" out = ok(andy.put(f"/api/packets/{pkt['id']}/stage/checklist/dwg", json={"done": True})) entry = out["stage"]["checklist"]["dwg"] assert entry["done"] is True and entry["at"] and entry["by"] == shop.uid("weld.andy") assert entry["stamp"] == "75" and entry["by_name"] == "Andy Welder" assert last_log(andy, pkt["id"])["action"] == "Checked: Drawing D-5001 Rev B matches the packet" stored = one(s, "SELECT checklist FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (pkt["id"],))[0] assert stored["dwg"]["done"] is True and stored["dwg"]["stamp"] == "75" # the same tick again writes nothing before = len(log_rows(andy, pkt["id"])) ok(andy.put(f"/api/packets/{pkt['id']}/stage/checklist/dwg", json={"done": True})) assert len(log_rows(andy, pkt["id"])) == before out = ok(andy.put(f"/api/packets/{pkt['id']}/stage/checklist/dwg", json={"done": False})) assert out["stage"]["checklist"]["dwg"]["done"] is False assert last_log(andy, pkt["id"])["action"] == "Unchecked: Drawing D-5001 Rev B matches the packet" refused(andy.put(f"/api/packets/{pkt['id']}/stage/checklist/zzz", json={"done": True}), 422, "invalid_key") refused(andy.put(f"/api/packets/{pkt['id']}/stage/checklist/prev", json={"done": True}), 422, "invalid_key") # the stamp falls back to the packet's stamp when the user has none set_stamp(s, "lead.lou", None) out = ok(shop("lead.lou").put(f"/api/packets/{pkt['id']}/stage/checklist/fit", json={"done": True})) assert out["stage"]["checklist"]["fit"]["stamp"] == "75" assert out["stage"]["checklist"]["fit"]["by_name"] == "Lou Lead" # someone who does not hold it refused(shop("qc.quinn").put(f"/api/packets/{pkt['id']}/stage/checklist/vt", json={"done": True}), 403, "forbidden") def test_checklist_plain_department(shop: Shop) -> None: pkt = ready_skipped(shop, dept="09") assert [i["key"] for i in pkt["checklist_items"]] == list(PLAIN_KEYS) groups = {i["key"]: i["group"] for i in pkt["checklist_items"]} assert groups["prev"] == "Before starting" and groups["work"] == "When done" ann = shop("admin.ann") url = f"/api/packets/{pkt['id']}/stage/checklist" refused(ann.put(f"{url}/prev", json={"done": True}), 400, "it_reason_required") out = ok(ann.put(f"{url}/prev", json={"done": True, "it_reason": IT})) assert out["stage"]["checklist"]["prev"]["done"] is True refused(ann.put(f"{url}/heat", json={"done": True, "it_reason": IT}), 422, "invalid_key") def test_checklist_templates_from_settings(shop: Shop) -> None: s = shop.settings row = one(s, "SELECT value FROM settings WHERE key = 'checklist_templates'") assert row is not None, "migration 0006 seeds checklist_templates" defaults = row[0] assert [i["key"] for i in defaults["weld"]] == list(WELD_KEYS) assert [i["key"] for i in defaults["plain"]] == list(PLAIN_KEYS) custom = { "weld": [ {"group": "Before welding", "key": "dwg", "text": "Sheet {drawing} at Rev {rev} checked"}, {"group": "Before welding", "key": "wps", "text": "Procedure {wps} read"}, {"group": "After welding", "key": "vt", "text": "Visual done"}, ], "plain": defaults["plain"], } set_templates(s, custom) andy = shop("weld.andy") pkt = in_progress(shop, drawing="D-6006", rev="C") pkt = fresh(andy, pkt["id"]) assert [(i["key"], i["text"]) for i in pkt["checklist_items"]] == [ ("dwg", "Sheet D-6006 at Rev C checked"), ("wps", "Procedure WPS-11 read"), ("vt", "Visual done"), ] refused(andy.put(f"/api/packets/{pkt['id']}/stage/checklist/fit", json={"done": True}), 422, "invalid_key") out = ok(andy.put(f"/api/packets/{pkt['id']}/stage/checklist/wps", json={"done": True})) assert last_log(andy, pkt["id"])["action"] == "Checked: Procedure WPS-11 read" # three keys complete the custom checklist out = tick_all(shop, out, keys=("dwg", "vt")) out = set_filler(shop, add_weld(shop, out)) assert "submit" in fresh(andy, out["id"])["can"] # change C8: the started stage keeps the list it started with, whatever the settings say now custom_keys = ["dwg", "wps", "vt"] set_templates(s, {"weld": "nonsense"}) assert [i["key"] for i in fresh(andy, pkt["id"])["checklist_items"]] == custom_keys # a malformed row falls back to the defaults in code for a stage not yet started later = to_station(shop, ready_packet(shop)) assert [i["key"] for i in fresh(andy, later["id"])["checklist_items"]] == list(WELD_KEYS) run(s, "DELETE FROM settings WHERE key = 'checklist_templates'") assert [i["key"] for i in fresh(andy, later["id"])["checklist_items"]] == list(WELD_KEYS) assert [i["key"] for i in fresh(andy, pkt["id"])["checklist_items"]] == custom_keys # ================================================================ stage fields def test_stage_fields_field_cas(shop: Shop) -> None: andy = shop("weld.andy") pkt = in_progress(shop) url = f"/api/packets/{pkt['id']}/stage" out = ok(andy.patch(url, json={"set": {"filler": "ER70S-6", "filler_lot": "1234"}, "base": {"filler": None}})) assert out["stage"]["filler"] == "ER70S-6" and out["stage"]["filler_lot"] == "1234" log = last_log(andy, pkt["id"]) assert (log["action"], log["note"]) == ("Weld record changed", "filler ER70S-6 · filler lot 1234") # another session saw filler empty and writes over it: a conflict on that field only r = refused( shop("sup.wes").patch(url, json={"set": {"filler": "E7018"}, "base": {"filler": None}}), 409, "field_conflict" ) assert r["current"] == {"filler": "ER70S-6"} # a base that matches, and a key without a base, are written out = ok( shop("sup.wes").patch(url, json={"set": {"filler": "E7018", "preheat": "150F"}, "base": {"filler": "ER70S-6"}}) ) assert out["stage"]["filler"] == "E7018" and out["stage"]["preheat"] == "150F" assert last_log(andy, pkt["id"])["note"] == "filler E7018 · preheat 150F" # the flat body with If-Match still works rv = out["stage"]["row_version"] out = ok(andy.patch(url, json={"notes": "Root pass by Andy"}, headers=im(rv))) assert out["stage"]["notes"] == "Root pass by Andy" refused(andy.patch(url, json={"notes": "stale"}, headers=im(rv)), 409, "row_conflict") # limits: 40 for the short fields, 2000 for notes r = andy.patch(url, json={"set": {"filler_lot": "X" * 60, "notes": "n" * 2500}, "base": {}}) if r.status_code == 200: assert len(r.json()["stage"]["filler_lot"]) == 40 and len(r.json()["stage"]["notes"]) == 2000 else: assert r.status_code == 422, r.text refused(shop("qc.quinn").patch(url, json={"set": {"filler": "x"}, "base": {}}), 403, "forbidden") # ================================================================ welds def test_welds(shop: Shop) -> None: s = shop.settings set_stamp(s, "weld.andy", "75") andy = shop("weld.andy") pkt = in_progress(shop) url = f"/api/packets/{pkt['id']}/welds" body = { "page": 1, "fx": 0.5, "fy": 0.5, "bfx": 0.55, "bfy": 0.43, "items": [2, 1], "wo_base": "L670001", "piece": 1, "type": "Fillet", "process": "GMAW", } out = ok(andy.post(url, json=body)) w1 = weld_of(out, 1) assert (w1["size"], w1["type"], w1["process"], w1["items"]) == ('5/16"', "Fillet", "GMAW", [1, 2]) assert (w1["bfx"], w1["bfy"], w1["fx"], w1["fy"]) == (0.55, 0.43, 0.5, 0.5) assert w1["dept_code"] == "03" and w1["stage_id"] == out["stage"]["id"] and w1["carried"] is False assert w1["by"]["display_name"] == "Andy Welder" and w1["welder"]["id"] == shop.uid("weld.andy") assert w1["stamp"] == "75" log = last_log(andy, pkt["id"]) assert (log["action"], log["note"]) == ("Weld W1 marked", 'Fillet 5/16" · items 1 + 2 · L670001 #1') # the client never picks the number out = ok(andy.post(url, json={**body, "weld_no": 9, "size": '1/4"', "bfx": None, "bfy": None})) w2 = weld_of(out, 2) assert w2["size"] == '1/4"' and w2["bfx"] is None and w2["bfy"] is None assert sorted(w["weld_no"] for w in out["welds"]) == [1, 2] # validation refused(andy.post(url, json={**body, "type": "Butt"}), 422, "invalid_weld", MSG_TYPE) refused(andy.post(url, json={**body, "process": "TIG"}), 422, "invalid_weld", MSG_PROCESS) refused(andy.post(url, json={**body, "wo_base": "L999999"}), 422, "invalid_wo") for bad in ({"fx": 1.5}, {"fy": -0.1}, {"bfx": 1.2}, {"bfy": -0.5}, {"piece": 0}): r = andy.post(url, json={**body, **bad}) assert r.status_code == 422, (bad, r.text) refused(andy.post(url, json={**body, "items": [7]}), 422, "invalid_items") for t in ("Fillet", "Groove", "Fill", "Seal", "Tack"): assert andy.post(url, json={**body, "type": t, "page": 2}).status_code == 200, t for p in ("GMAW", "FCAW", "SMAW", "GTAW", "SAW"): assert andy.post(url, json={**body, "process": p, "page": 2}).status_code == 200, p out = fresh(andy, pkt["id"]) n = max(w["weld_no"] for w in out["welds"]) assert n == 12 and len(out["welds"]) == 12 # a position-only patch logs "moved"; a field change logs the changed fields rv = weld_of(out, 1)["row_version"] out = ok(andy.patch(f"{url}/1", json={"fx": 0.6, "fy": 0.62, "bfx": 0.7, "bfy": 0.5}, headers=im(rv))) assert (weld_of(out, 1)["fx"], weld_of(out, 1)["bfy"]) == (0.6, 0.5) log = last_log(andy, pkt["id"]) assert (log["action"], log["note"]) == ("Weld W1 moved", None) rv = weld_of(out, 1)["row_version"] out = ok(andy.patch(f"{url}/1", json={"type": "Groove", "items": [1, 3]}, headers=im(rv))) log = last_log(andy, pkt["id"]) assert (log["action"], log["note"]) == ("Weld W1 changed", "type Groove · items 1 + 3") refused( andy.patch(f"{url}/1", json={"type": "Butt"}, headers=im(weld_of(out, 1)["row_version"])), 422, "invalid_weld" ) refused(andy.patch(f"{url}/1", json={"fx": 0.1}, headers=im(rv)), 409, "row_conflict") # removed with a reason out = ok(andy.request("DELETE", f"{url}/2", json={"reason": "Logged twice"})) assert 2 not in [w["weld_no"] for w in out["welds"]] log = last_log(andy, pkt["id"]) assert (log["action"], log["note"]) == ("Weld W2 removed", "Logged twice") # numbers are never reused out = ok(andy.post(url, json=body)) assert max(w["weld_no"] for w in out["welds"]) == 13 # a voided sheet refuses new marks and moving old ones run(s, "UPDATE packet_pages SET void_at = now() WHERE packet_id = %s AND n = 2", (pkt["id"],)) refused(andy.post(url, json={**body, "page": 2}), 422, "void_page", MSG_VOID) on_two = next(w for w in fresh(andy, pkt["id"])["welds"] if w["page"] == 2) r = andy.patch(f"{url}/{on_two['weld_no']}", json={"fx": 0.2}, headers=im(on_two["row_version"])) refused(r, 422, "void_page", MSG_VOID) # moving a page-1 weld onto the void sheet is refused too w1 = weld_of(fresh(andy, pkt["id"]), 1) refused(andy.patch(f"{url}/1", json={"page": 2}, headers=im(w1["row_version"])), 422, "void_page", MSG_VOID) # who: Q.C. and the warehouse may not for sam in ("qc.quinn", "wh.wade", "clerk.clara"): refused(shop(sam).post(url, json=body), 403, "forbidden") # ================================================================ heat tags and the heat table def test_heat_tags(shop: Shop) -> None: andy = shop("weld.andy") pkt = in_progress(shop) url = f"/api/packets/{pkt['id']}/heat-tags" out = ok(andy.post(url, json={"page": 1, "item": 3, "fx": 0.3, "fy": 0.3, "bfx": 0.35, "bfy": 0.24})) t = tag_of(out, 1) assert (t["item"], t["bfx"], t["bfy"], t["carried"]) == (3, 0.35, 0.24, False) log = last_log(andy, pkt["id"]) assert (log["action"], log["note"]) == ("Heat tag H1 placed", "item 3") out = ok(andy.patch(f"{url}/1", json={"fx": 0.4, "bfx": 0.45}, headers=im(t["row_version"]))) assert (last_log(andy, pkt["id"])["action"], last_log(andy, pkt["id"])["note"]) == ("Heat tag H1 moved", "item 3") out = ok(andy.patch(f"{url}/1", json={"item": 2}, headers=im(tag_of(out, 1)["row_version"]))) log = last_log(andy, pkt["id"]) assert (log["action"], log["note"]) == ("Heat tag H1 changed", "item 2") for bad in ({"bfx": 1.01}, {"bfy": -0.01}, {"fx": 2}): assert andy.post(url, json={"page": 1, "item": 1, "fx": 0.2, "fy": 0.2, **bad}).status_code == 422, bad out = ok(andy.post(url, json={"page": 1, "item": 1, "fx": 0.2, "fy": 0.2})) assert tag_of(out, 2)["bfx"] is None out = ok(andy.request("DELETE", f"{url}/1", json={"reason": "Wrong item"})) log = last_log(andy, pkt["id"]) assert log["action"] == "Heat tag H1 removed" and log["note"] in ("Wrong item", "item 2 · Wrong item") run(shop.settings, "UPDATE packet_pages SET void_at = now() WHERE packet_id = %s AND n = 2", (pkt["id"],)) refused(andy.post(url, json={"page": 2, "item": 1, "fx": 0.2, "fy": 0.2}), 422, "void_page", MSG_VOID) def test_heat_tag_by_the_warehouse_reads_carried(shop: Shop) -> None: pkt = draft(shop) pkt = to_warehouse(shop, pkt) pkt = ok( shop("wh.wade").post(f"/api/packets/{pkt['id']}/heat-tags", json={"page": 1, "item": 1, "fx": 0.2, "fy": 0.2}) ) pkt = release(shop, pull_all(shop, pkt)) assert tag_of(pkt, 1)["carried"] is True def test_heat_table(shop: Shop) -> None: andy = shop("weld.andy") pkt = in_progress(shop) url = f"/api/packets/{pkt['id']}/heat-table" sheet1 = page_label(pkt, 1) out = ok(andy.put(f"{url}/1", json={"x": 0.1, "y": 0.1, "s": 1.3})) ht = out["heat_table"][0] assert (ht["page"], ht["x"], ht["y"], ht["s"], ht["carried_from"]) == (1, 0.1, 0.1, 1.3, None) assert ht["by"]["display_name"] == "Andy Welder" and ht["at"] assert (last_log(andy, pkt["id"])["action"], last_log(andy, pkt["id"])["note"]) == ( "Heat table placed on drawing", sheet1, ) ok(andy.put(f"{url}/1", json={"x": 0.4, "y": 0.2, "s": 1.3})) assert (last_log(andy, pkt["id"])["action"], last_log(andy, pkt["id"])["note"]) == ("Heat table moved", sheet1) ok(andy.put(f"{url}/1", json={"x": 0.4, "y": 0.2, "s": 1.45})) assert (last_log(andy, pkt["id"])["action"], last_log(andy, pkt["id"])["note"]) == ("Heat table resized", "A+ 1.45") ok(andy.put(f"{url}/1", json={"x": 0.4, "y": 0.2, "s": 1.3})) assert (last_log(andy, pkt["id"])["action"], last_log(andy, pkt["id"])["note"]) == ("Heat table resized", "A- 1.3") # the same placement again writes nothing before = len(log_rows(andy, pkt["id"])) ok(andy.put(f"{url}/1", json={"x": 0.4, "y": 0.2, "s": 1.3})) assert len(log_rows(andy, pkt["id"])) == before # validation for bad in ( {"x": 0.98, "y": 0.1, "s": 1}, {"x": -0.01, "y": 0.1, "s": 1}, {"x": 0.1, "y": 0.971, "s": 1}, {"x": 0.1, "y": 0.1, "s": 0.59}, {"x": 0.1, "y": 0.1, "s": 2.11}, ): refused(andy.put(f"{url}/1", json=bad), 422, "invalid_heat_table") ok(andy.put(f"{url}/1", json={"x": 0.97, "y": 0.97, "s": 2.1})) ok(andy.put(f"{url}/1", json={"x": 0, "y": 0, "s": 0.6})) refused(andy.put(f"{url}/99", json={"x": 0.1, "y": 0.1, "s": 1}), 422, "invalid_page") # carried_from is accepted and read back out = ok(andy.put(f"{url}/2", json={"x": 0.1, "y": 0.1, "s": 1, "carried_from": 1})) assert next(h for h in out["heat_table"] if h["page"] == 2)["carried_from"] == 1 # removed ok(andy.request("DELETE", f"{url}/2")) assert (last_log(andy, pkt["id"])["action"], last_log(andy, pkt["id"])["note"]) == ( "Heat table removed from drawing", page_label(pkt, 2), ) refused(andy.request("DELETE", f"{url}/2"), 404, "not_found") # Q.C. reads it but may not place it q = fresh(shop("qc.quinn"), pkt["id"]) assert [h["page"] for h in q["heat_table"]] == [1] assert "edit_heat_table" not in q["can"] refused(shop("qc.quinn").put(f"{url}/1", json={"x": 0.1, "y": 0.1, "s": 1}), 403, "forbidden") assert "edit_heat_table" in fresh(andy, pkt["id"])["can"] # ================================================================ heat map borrowing def test_heat_src_borrowing(shop: Shop) -> None: s = shop.settings _, unit = make_unit(s) _, other_unit = make_unit(s) sub_only = [{"item": 1, "qty": 1, "part_id": "SUB-1", "description": "Nozzle sub-assembly"}] # the sibling with a heat map, rev B, made first old_b = draft(shop, unit, title="Sibling rev B", drawing="D-77 01", rev="B") run(s, "INSERT INTO heat_table_placements (packet_id, page, x, y, s) VALUES (%s, 1, 0.1, 0.1, 1)", (old_b["id"],)) # a newer sibling with the same drawing, rev C, with a heat tag only new_c = draft(shop, unit, title="Sibling rev C", drawing="d-7701", rev="C") run( s, "INSERT INTO heat_tags (packet_id, tag_no, page, item, fx, fy) VALUES (%s, 1, 1, 1, 0.2, 0.2)", (new_c["id"],), ) # a sibling with no heat map is never a source draft(shop, unit, title="Sibling no map", drawing="D-7701", rev="C") # only sub-assembly lines, rev B: borrows rev B (same rev) b = draft(shop, unit, title="Borrower B", drawing="D-7701", rev="B", bom=sub_only) run(s, "UPDATE bom_lines SET sub = 'Y' WHERE packet_id = %s", (b["id"],)) src = fresh(shop("sup.wes"), b["id"])["heat_src"] assert src is not None and src["packet_id"] == old_b["id"] assert (src["drawing"], src["rev"], src["status"]) == ("D-77 01", "B", "draft") assert [h["page"] for h in src["heat_table"]] == [1] assert {"n", "type", "asset_id", "pn", "dwg", "rev", "void", "url"} <= set(src["pages"][0]) assert src["bom"] and {"item", "heat", "na"} <= set(src["bom"][0]) # rev C: the same rev wins over the older sibling c = draft(shop, unit, title="Borrower C", drawing="D-7701", rev="C", bom=sub_only) run(s, "UPDATE bom_lines SET sub = 'Y' WHERE packet_id = %s", (c["id"],)) assert fresh(shop("sup.wes"), c["id"])["heat_src"]["packet_id"] == new_c["id"] # rev D: no same rev, the oldest sibling d = draft(shop, unit, title="Borrower D", drawing="D-7701", rev="D", bom=[]) assert fresh(shop("sup.wes"), d["id"])["heat_src"]["packet_id"] == old_b["id"] # every line N/A borrows too na = draft(shop, unit, title="Borrower NA", drawing="D-7701", rev="B") run(s, "UPDATE bom_lines SET na = true WHERE packet_id = %s", (na["id"],)) assert fresh(shop("sup.wes"), na["id"])["heat_src"]["packet_id"] == old_b["id"] # its own raw material: no borrowing own = draft(shop, unit, title="Own material", drawing="D-7701", rev="B") assert fresh(shop("sup.wes"), own["id"])["heat_src"] is None # its own heat map: no borrowing mapped = draft(shop, unit, title="Own map", drawing="D-7701", rev="B", bom=[]) run(s, "INSERT INTO heat_table_placements (packet_id, page, x, y, s) VALUES (%s, 1, 0.5, 0.5, 1)", (mapped["id"],)) assert fresh(shop("sup.wes"), mapped["id"])["heat_src"] is None # another unit or another drawing: no borrowing far = draft(shop, other_unit, title="Other unit", drawing="D-7701", rev="B", bom=[]) assert fresh(shop("sup.wes"), far["id"])["heat_src"] is None diff = draft(shop, unit, title="Other drawing", drawing="D-7702", rev="B", bom=[]) assert fresh(shop("sup.wes"), diff["id"])["heat_src"] is None # a removed sibling is not a source run(s, "UPDATE packets SET removed_at = now(), removed_reason = 'test' WHERE id = %s", (old_b["id"],)) assert fresh(shop("sup.wes"), d["id"])["heat_src"]["packet_id"] == new_c["id"] # read-only: nothing on the source is written through the borrower pk = ready_skipped(shop, unit, drawing="D-7701", rev="C", bom=[]) src = fresh(shop("sup.wes"), pk["id"])["heat_src"] assert src["packet_id"] == new_c["id"] before = all_rows(s, "SELECT tag_no, fx FROM heat_tags WHERE packet_id = %s", (new_c["id"],)) shop("sup.wes").post(f"/api/packets/{pk['id']}/heat-tags", json={"page": 1, "item": 1, "fx": 0.9, "fy": 0.9}) assert all_rows(s, "SELECT tag_no, fx FROM heat_tags WHERE packet_id = %s", (new_c["id"],)) == before # ================================================================ labor from VISUAL def test_labor_from_visual_rows(shop: Shop) -> None: s = shop.settings pkt = ready_packet(shop) assert pkt["labor"] is None # a hand packet assert float(pkt["target_hrs"]) == pytest.approx(4.5) with app_conn(s) as conn: conn.execute("UPDATE packets SET source_kind = 'visual' WHERE id = %s", (pkt["id"],)) conn.execute( "UPDATE packet_work_orders SET seq = 10 WHERE packet_id = %s AND wo_base = 'L670001'", (pkt["id"],) ) conn.execute( "UPDATE packet_work_orders SET seq = 20 WHERE packet_id = %s AND wo_base = 'L670002'", (pkt["id"],) ) assert fresh(shop("sup.wes"), pkt["id"])["labor"] is None # no rows match yet with app_conn(s) as conn: for base, seq, hours, tickets, last in ( ("L670001", 10, "1.5", 2, "2026-10-10"), ("L670002", 20, "1.7", 1, "2026-10-12"), ("L670002", 30, "9.0", 4, "2026-10-12"), # another operation: not this packet's ("L670009", 10, "5.0", 1, "2026-10-12"), ): conn.execute( "INSERT INTO visual_labor (wo_base, lot, split, sub, seq, hours, tickets, last_ticket)" " VALUES (%s, '001', '0', '0', %s, %s, %s, %s)", (base, seq, hours, tickets, last), ) labor = fresh(shop("sup.wes"), pkt["id"])["labor"] assert labor is not None assert float(labor["hours"]) == pytest.approx(3.2) and int(labor["tickets"]) == 3 and labor["observed_at"] per = {w["wo_base"]: w for w in labor["per_wo"]} assert set(per) == {"L670001", "L670002"} assert float(per["L670001"]["hours"]) == pytest.approx(1.5) and per["L670001"]["tickets"] == 2 assert per["L670002"]["seq"] == 20 and str(per["L670002"]["last_ticket"]).startswith("2026-10-12") assert (per["L670001"]["lot"], per["L670001"]["split"], per["L670001"]["sub"]) == ("001", "0", "0") # ================================================================ the board, stations, stall def _board(shop: Shop, sam: str, **params: Any) -> Any: return shop(sam).get("/api/shop/board", params=params) def _ids(cards: list[dict[str, Any]]) -> set[int]: return {c["id"] for c in cards} def test_shop_board(shop: Shop) -> None: s = shop.settings set_stamp(s, "weld.andy", "75") at_stall = in_progress(shop, title="At the stall") set_running_since(s, at_stall["id"], 600) waiting = ready_packet(shop, title="Waiting to assign") review = in_review(shop, title="Waiting on review") six = ready_skipped(shop, dept="06", title="In code bldg 2") at_stall = issue_co(shop, at_stall, ["03"]) b = ok(_board(shop, "sup.wes")) assert [d["code"] for d in b["depts"]] == ["03"] d03 = b["depts"][0] assert (d03["name"], d03["weld"], d03["level"]) == ("Weld Parts", True, "supervisor") assert [st["name"] for st in d03["stations"]] == [f"Stall {n}" for n in range(1, 7)] stall1 = d03["stations"][0] assert [p["id"] for p in stall1["packets"]] == [at_stall["id"]] tile = stall1["packets"][0] assert tile["welder"]["display_name"] == "Andy Welder" and tile["welder_stamp"] == "75" assert tile["station"]["name"] == "Stall 1" and tile["status"] == "in_progress" assert tile["clock"]["running"] is True and tile["clock"]["elapsed_ms"] >= 600_000 assert float(tile["target_hrs"]) == pytest.approx(4.5) assert [{k: c[k] for k in ("label", "review")} for c in tile["open_cos"]] == [ {"label": "CO-1", "review": "approved"} ] assert all(st["packets"] == [] for st in d03["stations"][1:]) k = b["kpis"] assert (k["ready"], k["at_stations"], k["review"]) == (1, 1, 1) assert k["elapsed_ms"] >= 600_000 and float(k["target_hrs"]) == pytest.approx(4.5) cols = b["columns"] assert set(cols) == {"ready", "review", "qc", "warehouse", "complete"} assert _ids(cols["ready"]) == {waiting["id"]} and _ids(cols["review"]) == {review["id"]} assert cols["qc"] == [] and cols["complete"] == [] and cols["warehouse"] == [] every = set().union(*(_ids(c) for c in cols.values())) assert six["id"] not in every assert len(b["cos"]) == 1 co = b["cos"][0] assert (co["packet_id"], co["co_no"], co["label"], co["review"], co["mine"]) == ( at_stall["id"], 1, "CO-1", "approved", True, ) assert co["pending_depts"] == ["03"] and co["dept_code"] == "03" and co["op"] == "WELD-PART" assert co["title"] == "At the stall" and co["reason"] and co["issued_at"] # dept narrows within the scope; outside it is refused assert [d["code"] for d in ok(_board(shop, "sup.wes", dept="03"))["depts"]] == ["03"] assert [d["code"] for d in ok(_board(shop, "sup.wes", dept="all"))["depts"]] == ["03"] refused(_board(shop, "sup.wes", dept="06"), 403, "forbidden") # project and unit narrow everything other = ok(_board(shop, "sup.wes", unit_id=waiting["unit"]["id"])) assert _ids(other["columns"]["ready"]) == {waiting["id"]} and other["columns"]["review"] == [] assert other["depts"][0]["stations"][0]["packets"] == [] and other["cos"] == [] assert other["kpis"]["at_stations"] == 0 proj = ok(_board(shop, "sup.wes", project_id=review["unit"]["project"]["id"])) assert _ids(proj["columns"]["review"]) == {review["id"]} and proj["columns"]["ready"] == [] # the lead sees the same scope at lead level lou = ok(_board(shop, "lead.lou")) assert [(d["code"], d["level"]) for d in lou["depts"]] == [("03", "lead")] assert lou["cos"][0]["mine"] is False # multi.mia leads 06 only mia = ok(_board(shop, "multi.mia")) assert [(d["code"], d["level"]) for d in mia["depts"]] == [("06", "supervisor")] assert _ids(mia["columns"]["ready"]) == {six["id"]} and mia["cos"] == [] every = set().union(*(_ids(c) for c in mia["columns"].values())) assert not {at_stall["id"], waiting["id"], review["id"]} & every refused(_board(shop, "multi.mia", dept="03"), 403, "forbidden") # a worker leads nothing; nor do support roles for sam in ("weld.andy", "dup.dave", "qc.quinn", "wh.wade", "clerk.clara", "pm.paula"): refused(_board(shop, sam), 403, "forbidden", MSG_NOT_LEAD) # admin: every active shop department, at admin level ann = ok(_board(shop, "admin.ann")) assert [d["code"] for d in ann["depts"]] == SHOP_DEPTS assert {d["level"] for d in ann["depts"]} == {"admin"} assert _ids(ann["columns"]["ready"]) == {waiting["id"], six["id"]} assert ok(_board(shop, "admin.ann", dept="09"))["depts"][0]["code"] == "09" def test_shop_board_cos_follow_my_departments(shop: Shop) -> None: """A CO pending 03 shows on the 03 board when the packet has moved on to Q.C.""" pkt = in_review(shop) pkt = ok(step(shop("sup.wes"), pkt, "send_to_qc", book=True, spool=True, location="Bay 2", sign=SIGN)) pkt = issue_co(shop, pkt, ["03", "QC"]) b = ok(_board(shop, "sup.wes")) assert _ids(b["columns"]["qc"]) == {pkt["id"]} assert [(c["packet_id"], c["mine"], c["pending_depts"]) for c in b["cos"]] == [(pkt["id"], True, ["03", "QC"])] mia = ok(_board(shop, "multi.mia")) assert mia["cos"] == [] # a pending feed CO with hold shows as "review" pending, hold true run(shop.settings, "UPDATE packets SET next_co_no = next_co_no + 1 WHERE id = %s", (pkt["id"],)) run( shop.settings, "INSERT INTO change_orders (packet_id, co_no, origin, review, hold, ecn, reason, vis)" " VALUES (%s, 2, 'visual', 'pending', true, 'VISUAL', 'VISUAL changed the packet: qty', '{}')", (pkt["id"],), ) b = ok(_board(shop, "sup.wes")) held = [c for c in b["cos"] if c["co_no"] == 2] assert held and held[0]["review"] == "pending" and held[0]["hold"] is True def test_shop_stations(shop: Shop) -> None: s = shop.settings def names(sam: str) -> list[tuple[str, str]]: return [(x["dept_code"], x["name"]) for x in ok(shop(sam).get("/api/shop/stations"))] stalls = [("03", f"Stall {n}") for n in range(1, 7)] assert names("weld.andy") == stalls first = ok(shop("weld.andy").get("/api/shop/stations"))[0] assert first["dept_name"] == "Weld Parts" and first["id"] == shop.stall() set_limits(s, "weld.andy", [shop.stall("03", "Stall 2"), shop.stall("03", "Stall 5")]) assert names("weld.andy") == [("03", "Stall 2"), ("03", "Stall 5")] # limits bind workers only set_limits(s, "sup.wes", [shop.stall("03", "Stall 2")]) assert names("sup.wes") == stalls assert names("lead.lou") == stalls assert names("multi.mia") == [("06", f"Stall {n}") for n in range(1, 7)] for sam in ("qc.quinn", "wh.wade", "clerk.clara", "pm.paula"): assert names(sam) == [], sam total = one(s, "SELECT count(*) FROM stations WHERE active")[0] assert len(names("admin.ann")) == total def test_shop_stall(shop: Shop) -> None: s = shop.settings mine = in_progress(shop, title="On the stall") waiting = to_station(shop, ready_packet(shop, title="Queued at the stall")) sent = in_review(shop, title="Sent to review") elsewhere = to_station(shop, ready_packet(shop, title="Stall 3 job"), station="Stall 3") stall1 = shop.stall() out = ok(shop("weld.andy").get("/api/shop/stall", params={"station_id": stall1})) assert out["station"] == {"id": stall1, "name": "Stall 1", "dept_code": "03", "dept_name": "Weld Parts"} assert _ids(out["mine"]) == {mine["id"], waiting["id"]} assert _ids(out["submitted"]) == {sent["id"]} assert elsewhere["id"] not in _ids(out["mine"]) | _ids(out["submitted"]) card = next(c for c in out["mine"] if c["id"] == mine["id"]) assert card["status"] == "in_progress" and card["clock"]["running"] is True # a station outside the user's list set_limits(s, "weld.andy", [shop.stall("03", "Stall 2")]) refused(shop("weld.andy").get("/api/shop/stall", params={"station_id": stall1}), 403, "forbidden") refused(shop("multi.mia").get("/api/shop/stall", params={"station_id": stall1}), 403, "forbidden") refused(shop("qc.quinn").get("/api/shop/stall", params={"station_id": stall1}), 403, "forbidden") ok(shop("admin.ann").get("/api/shop/stall", params={"station_id": stall1})) ok(shop("sup.wes").get("/api/shop/stall", params={"station_id": stall1})) # after routing on, the packet leaves the stall ok(step(shop("sup.wes"), sent, "route_from_review", dept="09", sign=SIGN)) out = ok(shop("sup.wes").get("/api/shop/stall", params={"station_id": stall1})) assert sent["id"] not in _ids(out["submitted"]) def test_server_now_and_target_hours(shop: Shop) -> None: pkt = ready_packet(shop) assert isinstance(pkt["server_now"], str) and "T" in pkt["server_now"] assert float(pkt["target_hrs"]) == pytest.approx(4.5) empty = ready_skipped(shop, wos=[]) assert float(empty["target_hrs"]) == 0 def test_change_order_shape(shop: Shop) -> None: pkt = issue_co(shop, ready_packet(shop), ["03"]) co = pkt["change_orders"][0] assert co["label"] == "CO-1" and co["pending_depts"] == ["03"] and co["open"] is True assert last_log(shop("sup.wes"), pkt["id"])["action"] == "Change order CO-1 issued" out = ok( shop("sup.wes").post( f"/api/packets/{pkt['id']}/change-orders/1/verify", json={"dept": "03", "note": "OK", "sign": SIGN} ) ) co = out["change_orders"][0] assert co["pending_depts"] == [] and co["open"] is False v = co["verified"]["03"] assert v["signed"] is True and v["by"]["display_name"] == "Wes Super" and v["note"] == "OK" log = find_log(shop("sup.wes"), pkt["id"], "Change order CO-1 verified by Weld Parts") assert log["note"] == "OK · change order fully verified"