432 lines
18 KiB
Python
432 lines
18 KiB
Python
"""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
|