Files
PacketStation/tests/server/test_co_apply.py
T

432 lines
18 KiB
Python
Raw Normal View History

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