"""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