Phase 5: Supervisor and Stall screens, weld map, heat table, clock and CO verification
ci / frontend (push) Successful in 34s
ci / server (push) Successful in 7m15s
ci / image (push) Successful in 39s
ci / e2e (push) Successful in 2m4s

The shop at parity with the old build. Supervisor screen (department chips, CO headline, numbers, Change orders
on hold, station grid with clocks and CO tags, boards) and Stall screen (station picker, hero, My packets,
Submitted). Shop steps on the packet page: assign with station, welder picker and stamp; pull back; Start, Pause,
Resume; Submit with a PIN signature and the missing steps as links; review with Send to Q.C., Return to station
and Skip Q.C. route; a lead's read-only review. Weld map (pdf.js viewer with pan, pinch, zoom, full screen; marks
with leader lines; weld sheet; weld record with field CAS; carried marks). Heat map (heat table placed, dragged,
sized, removed; legacy amber tags; borrowed heat map read-only). Checklist tab with templates from settings.
VOID stamps on voided pages. Change orders: CO-n labels, supervisor verify with signature, approval copies the
heat table to the new sheet and the BOM merge keeps the shop's values. Server clock of record with VISUAL ticket
hours beside it. Presence per packet over SSE. Migration 0006 (bfx, bfy, carried_from, checklist_templates).
New endpoints /api/shop/board, /api/shop/stations, /api/shop/stall, /api/departments/{code}/people.

Tests: policy oracle for every shop action, role and status; clock arithmetic; concurrent inserts; CO apply;
frontend geometry, heat table, clock, checklist, weld sheet, screens; seven Playwright scenarios. Docs updated.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-16 22:46:56 -06:00
co-authored by Claude Fable 5.1
parent 39a4eb5213
commit 5a4174e0c2
81 changed files with 11532 additions and 352 deletions
+431
View File
@@ -0,0 +1,431 @@
"""Phase 5 change-order approval applies the staged VISUAL change: new pages added with the change order, old pages
voided (void_co_id, void_at, void_by, void_new_n), rev and drawing set, work orders remapped, the BOM merged
keeping the shop's values (heat, na, issued, loc, pulled, pulled_at, pulled_by, the MTR and the open short) on
lines matched by vkey or by item label plus part; the required departments, the hold text, the verifications
with their texts (insert-only, signed, a supervisor of the department only), and the heat table copied to the
new sheet with carried_from. A minor change order needs no verification.
The change order row is inserted by SQL in the shape the feed writes (feed/changes.py vis_for and open_or_reaim,
drawing_lookup staged). Nothing here reads VISUAL or M-Files."""
from __future__ import annotations
import io
from collections.abc import Iterator
from typing import Any
import psycopg
import pytest
from fakedir import FakeDirectory
from pkthelpers import step
from pshelpers import app_conn, needs_pg
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from shophelpers import (
IT,
SIGN,
Shop,
add_weld,
all_rows,
find_log,
fresh,
in_progress,
line_of,
ok,
one,
open_shop,
refused,
set_filler,
tick_all,
)
from packetstation.settings import Settings
pytestmark = needs_pg
FP = "fp-co-test-0001"
NEW_DWG = "D-5001A"
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def landscape_pdf(pages: int = 2) -> bytes:
"""A PDF that differs from the packet's drawing (so the upload is a new asset, not a dedupe)."""
from pypdf import PdfWriter
w = PdfWriter()
for _ in range(pages):
w.add_blank_page(width=792, height=612)
buf = io.BytesIO()
w.write(buf)
return buf.getvalue()
def stage_asset(shop: Shop) -> dict[str, Any]:
r = shop("clerk.clara").post(
"/api/assets",
files={"file": ("D-5001A_C.pdf", landscape_pdf(), "application/pdf")},
data={"purpose": "drawing"},
)
assert r.status_code == 201, r.text
out: dict[str, Any] = r.json()
assert out["pages"] == 2
return out
def vis_bom_line(item: str, qty: str, part: str, desc: str, vkey: str, **over: Any) -> dict[str, Any]:
line = {
"item": item,
"qty": qty,
"part_id": part,
"desc": desc,
"loc": "VIS-LOC",
"whs": "MAIN",
"comm": "",
"cons": False,
"sub": False,
"na": False,
"grp": None,
"vkey": vkey,
"issued": "0",
"istat": None,
}
line.update(over)
return line
def leg(base: str, qty: str, tgt: str) -> dict[str, Any]:
return {
"wo_base": base,
"lot": "001",
"split": "0",
"sub": "0",
"seq": None,
"op": "WELD-PART",
"qty": qty,
"target_hrs": tgt,
"dwg": None,
"rev": None,
"partspecs": None,
"st": "R",
}
def insert_feed_co(
shop: Shop,
pid: int,
*,
vis: dict[str, Any],
staged: dict[str, Any] | None,
minor: bool = False,
reason: str = "VISUAL changed the drawing: D-5001 -> D-5001A Rev B -> Rev C",
) -> tuple[int, int]:
"""A pending VISUAL change order, as the feed and the drawing job leave it. Returns (co id, co_no)."""
with app_conn(shop.settings) as conn:
no = conn.execute(
"UPDATE packets SET next_co_no = next_co_no + 1 WHERE id = %s RETURNING next_co_no - 1", (pid,)
).fetchone()[0]
co_id = conn.execute(
"INSERT INTO change_orders (packet_id, co_no, origin, review, minor, ecn, reason, issued_by, vis, waiting,"
" staged, target_fp) VALUES (%s, %s, 'visual', 'pending', %s, 'VISUAL', %s, NULL, %s, NULL, %s, %s)"
" RETURNING id",
(pid, no, minor, reason, Jsonb(vis), Jsonb(staged) if staged else None, FP),
).fetchone()[0]
return int(co_id), int(no)
def bom_rows(shop: Shop, pid: int) -> dict[int, dict[str, Any]]:
with app_conn(shop.settings) as conn, conn.cursor(row_factory=dict_row) as cur:
return {int(r["item"]): dict(r) for r in cur.execute("SELECT * FROM bom_lines WHERE packet_id = %s", (pid,))}
def held(no: int, names: str) -> str:
return f"Change order CO-{no} must be verified first by: {names}."
def prepared_packet(shop: Shop) -> tuple[dict[str, Any], dict[int, dict[str, Any]], str]:
"""An in_progress packet in 03 with VISUAL-shaped BOM lines, a short on line 2, an MTR on line 1, a weld and a
heat table on page 1. Returns (packet, bom rows before, the MTR asset id)."""
pkt = in_progress(shop, drawing="D-5001", rev="B")
pid = pkt["id"]
andy = shop("weld.andy")
pkt = add_weld(shop, pkt)
pkt = ok(andy.put(f"/api/packets/{pid}/heat-table/1", json={"x": 0.2, "y": 0.3, "s": 1.15}))
mtr = pkt["pages"][0]["asset_id"]
with app_conn(shop.settings) as conn:
conn.execute(
"UPDATE bom_lines SET src = 'visual', vkey = 'VK-1', cl_item = '1', loc = 'Rack 4', issued = 1,"
" mtr_asset_id = %s WHERE packet_id = %s AND item = 1",
(mtr, pid),
)
conn.execute(
"UPDATE bom_lines SET src = 'visual', vkey = NULL, cl_item = '2', issued = 1 WHERE packet_id = %s"
" AND item = 2",
(pid,),
)
conn.execute(
"UPDATE bom_lines SET src = 'visual', vkey = 'VK-3', cl_item = '3' WHERE packet_id = %s AND item = 3",
(pid,),
)
line2 = conn.execute("SELECT id FROM bom_lines WHERE packet_id = %s AND item = 2", (pid,)).fetchone()[0]
conn.execute(
"INSERT INTO bom_shorts (packet_id, bom_line_id, qty, qty_open, reason, ref) VALUES (%s, %s, 1, 1,"
" 'Not in stock', 'PO-4471')",
(pid, line2),
)
before = bom_rows(shop, pid)
assert all(b["pulled"] and b["pulled_at"] and b["pulled_by"] for b in before.values()), before
return fresh(andy, pid), before, mtr
def drawing_vis() -> dict[str, Any]:
return {
"from_dwg": "D-5001",
"from_rev": "B",
"to_dwg": NEW_DWG,
"to_rev": "C",
"dwg_change": True,
"changes": [],
"sig": FP,
"legs": [leg("L670001", "3", "2"), leg("L670003", "1", "1.25")],
"bom": [
vis_bom_line("1", "2", "PIPE-4", "4in pipe SCH80", "VK-1", loc="VIS-A", na=False, issued="0"),
vis_bom_line("2", "3", "GSKT-4", "4in gasket spiral", "VK-2-NEW"),
vis_bom_line("4", "6", "NUT-58", "5/8 heavy hex nut", "VK-4"),
],
"want": None,
"rel": None,
"folded": [],
"partspecs": [],
"fp_parts": {},
}
def test_feed_co_approval_applies_the_change(shop: Shop) -> None:
s = shop.settings
pkt, before, mtr = prepared_packet(shop)
pid = pkt["id"]
wo_before = {w["wo_base"]: w["id"] for w in pkt["work_orders"]}
asset = stage_asset(shop)
staged = {"asset_id": asset["id"], "dwg": NEW_DWG, "rev": "C", "name": "D-5001A_C.pdf", "sha256": asset["sha256"]}
co_id, no = insert_feed_co(shop, pid, vis=drawing_vis(), staged=staged)
assert no == 1
# pending without hold: nothing held yet
assert fresh(shop("weld.andy"), pid)["open_cos"] == []
clara = shop("clerk.clara")
refused(clara.post(f"/api/packets/{pid}/change-orders/1/approve", json={}), 400, "signature_required")
refused(shop("sup.wes").post(f"/api/packets/{pid}/change-orders/1/approve", json={"sign": SIGN}), 403, "forbidden")
out = ok(clara.post(f"/api/packets/{pid}/change-orders/1/approve", json={"sign": SIGN}))
clara_id = shop.uid("clerk.clara")
# header
assert (out["drawing"], out["rev"], out["drawing_state"]) == (NEW_DWG, "C", "attached")
assert one(s, "SELECT visual_fp_applied FROM packets WHERE id = %s", (pid,))[0] == FP
# pages: 1 and 2 voided pointing at 3; 3 and 4 new, carrying the change order
pages = {p["n"]: p for p in out["pages"]}
assert sorted(pages) == [1, 2, 3, 4]
for n in (1, 2):
v = pages[n]["void"]
assert v is not None and v["co_id"] == co_id and v["co_no"] == 1 and v["at"]
assert v["by"] == {"id": clara_id, "display_name": "Clara Clerk"}
assert v["new"] == {"n": 3, "dwg": NEW_DWG, "rev": "C"}
for n in (3, 4):
assert pages[n]["void"] is None and pages[n]["asset_id"] == asset["id"]
assert (pages[n]["dwg"], pages[n]["rev"]) == (NEW_DWG, "C")
rows = all_rows(
s,
"SELECT n, void_co_id, void_at IS NOT NULL, void_by::text, void_new_n, co_id FROM packet_pages"
" WHERE packet_id = %s ORDER BY n",
(pid,),
)
assert [tuple(r) for r in rows] == [
(1, co_id, True, clara_id, 3, None),
(2, co_id, True, clara_id, 3, None),
(3, None, False, None, None, co_id),
(4, None, False, None, None, co_id),
]
items = all_rows(s, "SELECT old_page, new_page, added FROM co_items WHERE co_id = %s ORDER BY id", (co_id,))
assert [tuple(r) for r in items] == [(1, 3, False), (2, 4, False)]
# work orders remapped: L670001 kept in place, L670003 added, L670002 removed
wos = {w["wo_base"]: w for w in out["work_orders"]}
assert set(wos) == {"L670001", "L670003"}
assert wos["L670001"]["id"] == wo_before["L670001"] and float(wos["L670001"]["qty"]) == 3
assert float(wos["L670003"]["target_hrs"]) == pytest.approx(1.25)
assert float(out["target_hrs"]) == pytest.approx(3.25)
# the BOM merge keeps the shop's values on matched lines
after = bom_rows(shop, pid)
one_b, one_a = before[1], after[1]
assert one_a["id"] == one_b["id"], "line 1 matched on vkey"
assert (float(one_a["qty"]), one_a["description"]) == (2.0, "4in pipe SCH80")
# the old coKeepShop: a value the shop recorded wins; an empty one takes VISUAL's
for k in ("heat", "na", "issued", "loc", "pulled", "pulled_at", "pulled_by", "mtr_asset_id"):
assert one_a[k] == one_b[k], (k, one_b[k], one_a[k])
assert str(one_a["mtr_asset_id"]) == mtr
two_b, two_a = before[2], after[2]
assert two_a["id"] == two_b["id"], "line 2 matched on item label plus part"
assert (float(two_a["qty"]), two_a["description"]) == (3.0, "4in gasket spiral")
for k in ("heat", "na", "issued", "pulled", "pulled_at", "pulled_by"):
assert two_a[k] == two_b[k], (k, two_b[k], two_a[k])
assert two_b["loc"] is None and two_a["loc"] == "VIS-LOC" # empty in the shop: VISUAL's value
short = one(s, "SELECT bom_line_id, qty_open, closed_at FROM bom_shorts WHERE packet_id = %s", (pid,))
assert (short[0], float(short[1]), short[2]) == (two_b["id"], 1.0, None)
assert line_of(out, 2)["short"] is not None and line_of(out, 2)["short"]["reason"] == "Not in stock"
# dropped by VISUAL after it was pulled: kept as N/A
assert after[3]["id"] == before[3]["id"] and after[3]["na"] is True and after[3]["heat"] == before[3]["heat"]
# new from VISUAL
four = after[4]
assert (four["part_id"], four["src"], four["pulled"], four["vkey"]) == ("NUT-58", "visual", False, "VK-4")
# the heat table follows to the new sheet; welds and tags stay on the VOID sheet
tables = {h["page"]: h for h in out["heat_table"]}
assert set(tables) == {1, 3}
assert (tables[3]["x"], tables[3]["y"], tables[3]["s"], tables[3]["carried_from"]) == (0.2, 0.3, 1.15, 1)
assert tables[1]["carried_from"] is None
log = find_log(clara, pid, "Heat table copied to new sheet")
assert log["note"] == "from the VOID copy, check its position on the new revision"
assert [w["page"] for w in out["welds"]] == [1]
appr = find_log(clara, pid, "Change order CO-1 approved")
assert appr["signed"] is True
# required: the touched departments without Clerical (WH and 03)
co = out["change_orders"][0]
assert co["review"] == "approved" and co["label"] == "CO-1" and co["open"] is True
assert sorted(co["required"]) == ["03", "WH"] and sorted(co["pending_depts"]) == ["03", "WH"]
req = all_rows(s, "SELECT dept_code FROM co_required_depts WHERE co_id = %s ORDER BY dept_code", (co_id,))
assert [r[0] for r in req] == ["03", "WH"]
assert out["open_cos"] == [1]
# the hold: SUBMIT waits for every verification; ASSIGN and START are never held
andy = shop("weld.andy")
pkt = set_filler(shop, tick_all(shop, fresh(andy, pid)))
both = (held(1, "Warehouse, 03 Weld Parts"), held(1, "03 Weld Parts, Warehouse"))
mine = fresh(andy, pid)
assert "submit" not in mine["can"] and mine["blocked"]["submit"][0] in both
r = refused(step(andy, pkt, "submit", sign=SIGN), 403, "co_hold")
assert r["detail"] in both
# a lead cannot verify; a supervisor of another department cannot verify 03
refused(verify_call(shop, pid, "lead.lou", "03"), 403, "forbidden")
refused(verify_call(shop, pid, "multi.mia", "03"), 403, "forbidden")
refused(verify_call(shop, pid, "sup.wes", "WH"), 403, "forbidden")
refused(verify_call(shop, pid, "sup.wes", "03", sign=None), 400, "signature_required")
refused(verify_call(shop, pid, "admin.ann", "09", it_reason=IT), 422, "invalid_dept")
out = ok(verify_call(shop, pid, "sup.wes", "03", note="Checked rev C"))
log = find_log(shop("sup.wes"), pid, "Change order CO-1 verified by Weld Parts")
assert (log["note"], log["signed"]) == ("Checked rev C · still waiting on Warehouse", True)
v = out["change_orders"][0]["verified"]["03"]
assert v["by"]["display_name"] == "Wes Super" and v["signed"] is True and v["note"] == "Checked rev C"
assert out["change_orders"][0]["pending_depts"] == ["WH"]
refused(verify_call(shop, pid, "sup.wes", "03"), 409, "already_verified")
mine = fresh(andy, pid)
assert mine["blocked"]["submit"] == [held(1, "Warehouse")]
# verifications are insert-only for the app role
with app_conn(s) as conn, pytest.raises(psycopg.Error):
conn.execute("UPDATE co_verifications SET note = 'changed' WHERE co_id = %s", (co_id,))
with app_conn(s) as conn, pytest.raises(psycopg.Error):
conn.execute("DELETE FROM co_verifications WHERE co_id = %s", (co_id,))
# the warehouse department, by the admin with an IT reason: fully verified, SUBMIT passes
refused(verify_call(shop, pid, "admin.ann", "WH", note="WH ok"), 400, "it_reason_required")
out = ok(verify_call(shop, pid, "admin.ann", "WH", note="WH ok", it_reason=IT))
log = find_log(shop("sup.wes"), pid, "Change order CO-1 verified by Warehouse")
assert log["note"] == "WH ok · change order fully verified"
assert out["open_cos"] == [] and out["change_orders"][0]["open"] is False
mine = fresh(andy, pid)
assert "submit" in mine["can"] and "submit" not in mine["blocked"]
out = ok(step(andy, mine, "submit", sign=SIGN))
assert out["status"] == "review"
def verify_call(
shop: Shop,
pid: int,
sam: str,
dept: str,
*,
note: str | None = None,
sign: Any = SIGN,
it_reason: str | None = None,
) -> Any:
body: dict[str, Any] = {"dept": dept}
if note is not None:
body["note"] = note
if sign is not None:
body["sign"] = sign
if it_reason is not None:
body["it_reason"] = it_reason
return shop(sam).post(f"/api/packets/{pid}/change-orders/1/verify", json=body)
def test_feed_co_waits_for_the_staged_drawing(shop: Shop) -> None:
pkt, _, _ = prepared_packet(shop)
insert_feed_co(shop, pkt["id"], vis=drawing_vis(), staged=None)
r = refused(
shop("clerk.clara").post(f"/api/packets/{pkt['id']}/change-orders/1/approve", json={"sign": SIGN}),
409,
"co_not_staged",
)
assert r["detail"] == "The new drawing has not been fetched from M-Files yet."
after = fresh(shop("clerk.clara"), pkt["id"])
assert after["rev"] == "B" and all(p["void"] is None for p in after["pages"])
def test_minor_co_needs_no_verification(shop: Shop) -> None:
pkt = in_progress(shop)
pid = pkt["id"]
vis = {
"from_dwg": "D-5001",
"from_rev": "B",
"to_dwg": "D-5001",
"to_rev": "B",
"dwg_change": False,
"changes": ["target hours 2 -> 3"],
"sig": FP,
"legs": [leg("L670001", "2", "3"), leg("L670002", "1", "2.5")],
"bom": None,
"want": None,
"rel": None,
"folded": [],
"partspecs": [],
"fp_parts": {},
}
co_id, no = insert_feed_co(shop, pid, vis=vis, staged=None, minor=True, reason="VISUAL changed the packet: hours")
out = ok(shop("clerk.clara").post(f"/api/packets/{pid}/change-orders/{no}/approve", json={"sign": SIGN}))
co = out["change_orders"][0]
assert co["review"] == "approved" and co["required"] == [] and co["pending_depts"] == [] and co["open"] is False
assert out["open_cos"] == [] and all(p["void"] is None for p in out["pages"])
assert float(next(w for w in out["work_orders"] if w["wo_base"] == "L670001")["target_hrs"]) == 3
assert one(shop.settings, "SELECT count(*) FROM co_required_depts WHERE co_id = %s", (co_id,))[0] == 0
# nothing to verify, so a verify is refused and SUBMIT is not held
refused(verify_call(shop, pid, "sup.wes", "03"), 422, "invalid_dept")
pkt = set_filler(shop, add_weld(shop, tick_all(shop, fresh(shop("weld.andy"), pid))))
assert "submit" in fresh(shop("weld.andy"), pid)["can"]
def test_co_hold_text_while_clerical_reviews(shop: Shop) -> None:
pkt = in_progress(shop)
pid = pkt["id"]
_, no = insert_feed_co(shop, pid, vis=drawing_vis(), staged=None)
ok(shop("clerk.clara").post(f"/api/packets/{pid}/change-orders/{no}/hold", json={"hold": True}))
log = find_log(shop("clerk.clara"), pid, f"Change order CO-{no} holds the packet")
assert log["note"] is None
pkt = set_filler(shop, add_weld(shop, tick_all(shop, fresh(shop("weld.andy"), pid))))
text = f"CO-{no}: Clerical is reviewing a change from VISUAL. Held until Clerical approves or rejects it."
refused(step(shop("weld.andy"), pkt, "submit", sign=SIGN), 403, "co_hold", text)
out = ok(shop("clerk.clara").post(f"/api/packets/{pid}/change-orders/{no}/hold", json={"hold": False}))
assert find_log(shop("clerk.clara"), pid, f"Change order CO-{no} no longer holds the packet")
assert out["open_cos"] == []
# rejected with a reason: the text names it
reason = "Rev C is not released yet"
ok(shop("clerk.clara").post(f"/api/packets/{pid}/change-orders/{no}/reject", json={"reason": reason, "sign": SIGN}))
log = find_log(shop("clerk.clara"), pid, f"Change order CO-{no} rejected")
assert log["note"] == reason