"""Phase 4 warehouse API: every warehouse action as every role, hold reasons, the Pulled auto-start, the heat pattern, the release gate (pulled-or-short and heats), receiving a short by custody, the board endpoint, the card fields, and the exact packet_log texts and audit rows of each write.""" from __future__ import annotations import struct import zlib from collections.abc import Callable, Iterator from dataclasses import dataclass, field from typing import Any import pytest from fakedir import FakeDirectory from fastapi.testclient import TestClient from pkthelpers import as_user, ok_step, phase2_client, ready_draft, seed_users, station_id, step, user_id from pshelpers import ORIGIN_HEADERS, app_conn, needs_pg, sign_in from packetstation.settings import Settings pytestmark = needs_pg IT = "Ticket 5120: warehouse lead is out" MSG_PULL = "Pull every material line or mark it short first." MSG_HEAT = "Scan or type the heat number of every pulled line first. Use N/A for a part with no traceability." HOLD_REASONS = ( "Waiting on material (not in stock)", "Waiting on purchasing / PO", "Question for engineering or clerical", "Drawing / BOM problem", "Other", ) # Everyone but the warehouse and the admin, on a packet the warehouse holds. REFUSED_IN_WH = ("clerk.clara", "qc.quinn", "pm.paula", "weld.andy", "lead.lou", "sup.wes") # On a ready packet, lead.lou and sup.wes hold it and may receive; these may not. REFUSED_IN_READY = ("clerk.clara", "qc.quinn", "pm.paula", "weld.andy") # A normal line, an N/A line (set by SQL), a sub-assembly line (sub = 'Y' by SQL) and a line with qty 4. BOM = [ {"item": 1, "qty": 1, "part_id": "PIPE-4", "description": "4in pipe SCH40", "material": "A106-B", "length": "20ft"}, {"item": 2, "qty": 2, "part_id": "GSKT-4", "description": "4in gasket"}, {"item": 3, "qty": 1, "part_id": "SUB-7", "description": "Nozzle sub-assembly"}, {"item": 4, "qty": 4, "part_id": "STUD-58", "description": "5/8 stud bolt"}, ] # ---------------------------------------------------------------- helpers @dataclass class Shop: """One app, one signed-in TestClient per user (separate cookie jars).""" main: TestClient settings: Settings clients: dict[str, TestClient] = field(default_factory=dict) def __call__(self, sam: str) -> TestClient: c = self.clients.get(sam) if c is None: c = TestClient(self.main.app, headers=ORIGIN_HEADERS) r = sign_in(c, sam) assert r.status_code == 200, r.text self.clients[sam] = c return c @pytest.fixture def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]: with phase2_client(pg_settings, fake_dir) as client: seed_users(client) yield Shop(client, pg_settings) def _one(settings: Settings, sql: str, params: Any = ()) -> Any: with app_conn(settings) as conn: return conn.execute(sql, params).fetchone() def _all(settings: Settings, sql: str, params: Any = ()) -> list[Any]: with app_conn(settings) as conn: return conn.execute(sql, params).fetchall() def _im(v: int) -> dict[str, str]: return {"If-Match": str(v)} def line(pkt: dict[str, Any], item: int) -> dict[str, Any]: return next(b for b in pkt["bom"] if b["item"] == item) def fresh(c: TestClient, pid: int) -> dict[str, Any]: r = c.get(f"/api/packets/{pid}") assert r.status_code == 200, r.text out: dict[str, Any] = r.json() return out def log_rows(c: TestClient, pid: int) -> list[dict[str, Any]]: r = c.get(f"/api/packets/{pid}/log") assert r.status_code == 200, r.text out: list[dict[str, Any]] = r.json() return out def last_log(c: TestClient, pid: int) -> dict[str, Any]: return log_rows(c, pid)[0] def last_audit(settings: Settings, pid: int) -> Any: return _one( settings, "SELECT action, detail, it_reason FROM audit_events WHERE packet_id = %s ORDER BY id DESC LIMIT 1", (pid,), ) def audit_count(settings: Settings, pid: int) -> int: return int(_one(settings, "SELECT count(*) FROM audit_events WHERE packet_id = %s", (pid,))[0]) def make_unit_sql(settings: Settings, number: str, lot: str = "001") -> tuple[int, int]: with app_conn(settings) as conn: pid = conn.execute( "INSERT INTO projects (number, name, customer, po) VALUES (%s, 'Test project', 'Acme Refining', 'PO-77')" " RETURNING id", (number,), ).fetchone()[0] uid = conn.execute( "INSERT INTO units (project_id, lot, name, status) VALUES (%s, %s, 'Unit one', 'open') RETURNING id", (pid, lot), ).fetchone()[0] return int(pid), int(uid) def warehouse_packet(shop: Shop, unit_id: int, **over: Any) -> dict[str, Any]: """A hand draft by clerk.clara with the four BOM lines, drawing attached, heat map answered, sent to the warehouse. Item 2 is N/A and item 3 a sub-assembly (both by SQL).""" c = shop("clerk.clara") pkt = ready_draft(c, unit_id, bom=BOM, **over) with app_conn(shop.settings) as conn: conn.execute("UPDATE bom_lines SET na = true WHERE packet_id = %s AND item = 2", (pkt["id"],)) conn.execute("UPDATE bom_lines SET sub = 'Y' WHERE packet_id = %s AND item = 3", (pkt["id"],)) pkt = fresh(c, pkt["id"]) out = ok_step(c, pkt, "send_to_warehouse") assert out["status"] == "warehouse" and out["wh"]["stage"] == "incoming" return out def tiny_png() -> bytes: def chunk(kind: bytes, data: bytes) -> bytes: return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF) ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0) idat = zlib.compress(b"\x00\xff\x00\x00") return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"") def upload_mtr(c: TestClient, data: bytes, name: str = "mtr.png", mime: str = "image/png") -> dict[str, Any]: r = c.post("/api/assets", files={"file": (name, data, mime)}, data={"purpose": "mtr"}) assert r.status_code == 201, r.text out: dict[str, Any] = r.json() return out def _it(body: dict[str, Any], it: str | None) -> dict[str, Any]: return {**body, "it_reason": it} if it else body Call = Callable[[TestClient, dict[str, Any], str | None], Any] def wh_move(stage: str, expect: str, **kw: Any) -> Call: def call(c: TestClient, pkt: dict[str, Any], it: str | None = None) -> Any: return c.post(f"/api/packets/{pkt['id']}/wh", json=_it({"stage": stage, "expect_stage": expect, **kw}, it)) return call def bom_patch(item: int, **fields: Any) -> Call: def call(c: TestClient, pkt: dict[str, Any], it: str | None = None) -> Any: rv = line(pkt, item)["row_version"] return c.patch(f"/api/packets/{pkt['id']}/bom/{item}", json=_it(fields, it), headers=_im(rv)) return call def short_save(item: int, **body: Any) -> Call: def call(c: TestClient, pkt: dict[str, Any], it: str | None = None) -> Any: return c.post(f"/api/packets/{pkt['id']}/bom/{item}/short", json=_it(body, it)) return call def short_clear(item: int) -> Call: def call(c: TestClient, pkt: dict[str, Any], it: str | None = None) -> Any: return c.request("DELETE", f"/api/packets/{pkt['id']}/bom/{item}/short", json=_it({"expect_open": True}, it)) return call def receipt(item: int, **body: Any) -> Call: def call(c: TestClient, pkt: dict[str, Any], it: str | None = None) -> Any: return c.post(f"/api/packets/{pkt['id']}/bom/{item}/short/receipts", json=_it(body, it)) return call def transition(action: str, **kw: Any) -> Call: def call(c: TestClient, pkt: dict[str, Any], it: str | None = None) -> Any: return step(c, pkt, action, **_it(kw, it)) return call def refused_then_done( shop: Shop, pkt: dict[str, Any], call: Call, performer: str, refused: tuple[str, ...] = REFUSED_IN_WH, ) -> dict[str, Any]: """Every refused role gets 403 forbidden, admin.ann without an IT reason 400 it_reason_required; then the performer (wh.wade, or admin.ann with an IT reason) does it. Returns the new read model.""" for sam in refused: r = call(shop(sam), pkt, None) assert r.status_code == 403 and r.json()["code"] == "forbidden", (sam, r.text) r = call(shop("admin.ann"), pkt, None) assert r.status_code == 400 and r.json()["code"] == "it_reason_required", r.text it = IT if performer == "admin.ann" else None r = call(shop(performer), pkt, it) assert r.status_code == 200, (performer, r.text) out: dict[str, Any] = r.json() return out # ---------------------------------------------------------------- every action, every role, the log texts # (call, log action, log note or None for "do not check", audit action) def walk_steps(mtr_id: str) -> list[tuple[Call, str, str | None, str]]: return [ (wh_move("pulling", "incoming"), "Warehouse: Started pulling", None, "packet.set_wh_stage"), ( wh_move("hold", "pulling", reason="Drawing / BOM problem", note="Asked Leo"), "Warehouse: Put on hold", "Drawing / BOM problem · Asked Leo", "packet.set_wh_stage", ), (wh_move("pulling", "hold"), "Warehouse: Pulling resumed", None, "packet.set_wh_stage"), (bom_patch(1, heat="HT-1"), "Heat recorded · item 1", "HT-1", "packet.edit_bom"), (bom_patch(1, pulled=True), "Pulled · item 1", None, "packet.edit_bom"), (bom_patch(1, pulled=False), "Pulled undone · item 1", None, "packet.edit_bom"), (bom_patch(1, pulled=True), "Pulled · item 1", None, "packet.edit_bom"), (bom_patch(2, na=False), "Traceable again · item 2", None, "packet.edit_bom"), (bom_patch(2, na=True), "Marked N/A · item 2", None, "packet.edit_bom"), (bom_patch(4, issued=2), "Issued qty changed · item 4", "2 of 4", "packet.edit_bom"), (bom_patch(4, loc="Rack 4"), "Location set · item 4", "Rack 4", "packet.edit_bom"), (bom_patch(1, heat=None), "Heat cleared · item 1", None, "packet.edit_bom"), (bom_patch(1, mtr_asset_id=mtr_id), "MTR attached · item 1", None, "packet.edit_bom"), (bom_patch(1, mtr_asset_id=None), "MTR removed · item 1", None, "packet.edit_bom"), (bom_patch(1, heat="HT-2", loc="Bin 2"), "Line changed · item 1", None, "packet.edit_bom"), ( short_save(4, qty=2, reason="On order", ref="PO-4471", eta="2026-11-20", expect_open=False), "Short recorded · item 4", "2 of 4 short · On order · PO-4471 · ETA 2026-11-20", "packet.edit_bom", ), ( short_save(4, qty=1, reason="Back-ordered", expect_open=True), "Short updated · item 4", "1 of 4 short · Back-ordered", "packet.edit_bom", ), (short_clear(4), "Short cleared · item 4", None, "packet.edit_bom"), ( short_save(4, qty=1, reason="Not in stock", heat="HT-4", expect_open=False), "Short recorded · item 4", "1 of 4 short · Not in stock", "packet.edit_bom", ), (bom_patch(3, pulled=True), "Pulled · item 3", None, "packet.edit_bom"), (wh_move("pulled", "pulling"), "Warehouse: Marked pulled, ready for production", None, "packet.set_wh_stage"), (wh_move("pulling", "pulled"), "Warehouse: Back to pulling", None, "packet.set_wh_stage"), (wh_move("pulled", "pulling"), "Warehouse: Marked pulled, ready for production", None, "packet.set_wh_stage"), ( transition("wh_release"), "Released to supervisor with 1 short", "2 heats recorded · SHORT: item 4 ×1 (Not in stock)", "packet.wh_release", ), ] @pytest.mark.parametrize("performer", ["wh.wade", "admin.ann"]) def test_every_warehouse_action_as_every_role(shop: Shop, performer: str) -> None: s = shop.settings _, uid = make_unit_sql(s, "L9W" + ("1" if performer == "wh.wade" else "2")) pkt = warehouse_packet(shop, uid) pid = pkt["id"] mtr = upload_mtr(shop("wh.wade"), tiny_png()) for call, action, note, audit_action in walk_steps(mtr["id"]): before = audit_count(s, pid) # The refusals are checked once, on the wh.wade walk; the admin walk checks the IT reason only. pkt = refused_then_done(shop, pkt, call, performer, REFUSED_IN_WH if performer == "wh.wade" else ()) lg = last_log(shop("wh.wade"), pid) assert lg["action"] == action, (action, lg) if note is not None: assert lg["note"] == note, (action, lg) if action.startswith("Heat recorded"): assert lg["note"] == "HT-1" assert audit_count(s, pid) == before + 1, action au = last_audit(s, pid) assert au[0] == audit_action, (action, au) if performer == "admin.ann": assert lg["it_reason"] == IT and lg["by"]["display_name"] == "Ann Admin", lg assert au[2] == IT else: assert lg["it_reason"] is None and lg["by"]["display_name"] == "Wade Warehouse" if audit_action == "packet.edit_bom" and "item" in action: assert int(au[1]["item"]) == int(action.rsplit(" ", 1)[1]) if action.startswith("Short recorded") or action.startswith("Short updated"): assert float(au[1]["qty"]) in (1.0, 2.0), au if audit_action == "packet.set_wh_stage": assert au[1]["wh_from"] and au[1]["wh_to"] assert pkt["status"] == "ready" and pkt["open_shorts"] == 1 assert line(pkt, 1)["heat"] == "HT-2" and line(pkt, 1)["loc"] == "Bin 2" and line(pkt, 1)["mtr_asset_id"] is None assert line(pkt, 2)["na"] is True and line(pkt, 4)["heat"] == "HT-4" # The short receipt on the ready packet: the warehouse (or IT) receives; Clerical, Q.C., PM and a welder cannot. pkt = refused_then_done(shop, pkt, receipt(4, qty=1, heat="HT-5"), performer, REFUSED_IN_READY) lg = last_log(shop("wh.wade"), pid) assert lg["action"] == "Short received · item 4" assert lg["note"] == "1 pc · heat HT-5 · short closed · packet ready to assign" au = last_audit(s, pid) assert float(au[1]["qty"]) == 1 and int(au[1]["item"]) == 4 assert au[0] == ("packet.wh_receive_short" if performer == "wh.wade" else au[0]) assert pkt["open_shorts"] == 0 and line(pkt, 4)["short"] is None @pytest.mark.parametrize("performer", ["wh.wade", "admin.ann"]) def test_send_back_to_clerical(shop: Shop, performer: str) -> None: _, uid = make_unit_sql(shop.settings, "L9S" + ("1" if performer == "wh.wade" else "2")) pkt = warehouse_packet(shop, uid) reason = "The BOM is the old revision" short = shop("wh.wade").post(f"/api/packets/{pkt['id']}/transitions", json={"action": "wh_send_back"}) assert short.status_code in (400, 422), short.text r = step(shop("wh.wade"), pkt, "wh_send_back", reason="too few") assert r.status_code == 400 and r.json()["code"] == "reason_required", r.text pkt = refused_then_done(shop, pkt, transition("wh_send_back", reason=reason), performer) assert pkt["status"] == "draft" lg = last_log(shop("clerk.clara"), pkt["id"]) assert lg["action"] == "Sent back to Clerical" and lg["note"] == reason assert last_audit(shop.settings, pkt["id"])[0] == "packet.wh_send_back" # ---------------------------------------------------------------- hold reasons and stage moves def test_hold_reasons_and_stage_moves(shop: Shop) -> None: _, uid = make_unit_sql(shop.settings, "L9H1") pkt = warehouse_packet(shop, uid) c = shop("wh.wade") url = f"/api/packets/{pkt['id']}/wh" assert c.post(url, json={"stage": "pulling", "expect_stage": "incoming"}).status_code == 200 same = c.post(url, json={"stage": "pulling", "expect_stage": "pulling"}) assert same.status_code == 409 and same.json()["code"] == "wh_transition", same.text stale = c.post(url, json={"stage": "hold", "reason": "Other", "note": "x", "expect_stage": "incoming"}) assert stale.status_code == 409 and stale.json()["code"] == "stale_step", stale.text assert stale.json()["current"]["stage"] == "pulling" bad = c.post(url, json={"stage": "hold", "reason": "Lunch break", "expect_stage": "pulling"}) assert bad.status_code == 400 and bad.json()["code"] == "invalid_reason", bad.text assert bad.json()["detail"] == "Pick one of the hold reasons." other = c.post(url, json={"stage": "hold", "reason": "Other", "expect_stage": "pulling"}) assert other.status_code == 400 and other.json()["code"] == "reason_required", other.text assert other.json()["detail"] == "Say what it's waiting on." none = c.post(url, json={"stage": "hold", "expect_stage": "pulling"}) assert none.status_code == 400, none.text for reason in HOLD_REASONS: body: dict[str, Any] = {"stage": "hold", "reason": reason, "expect_stage": "pulling"} if reason == "Other": body["note"] = "Crane is down" r = c.post(url, json=body) assert r.status_code == 200, (reason, r.text) assert r.json()["wh"]["stage"] == "hold" and r.json()["wh"]["reason"] == reason lg = last_log(c, pkt["id"]) assert lg["action"] == "Warehouse: Put on hold" assert lg["note"] == (f"{reason} · Crane is down" if reason == "Other" else reason) r = c.post(url, json={"stage": "pulling", "expect_stage": "hold"}) assert r.status_code == 200 and last_log(c, pkt["id"])["action"] == "Warehouse: Pulling resumed" long = c.post(url, json={"stage": "hold", "reason": "Other", "note": "n" * 300, "expect_stage": "pulling"}) assert long.status_code == 200, long.text assert len(long.json()["wh"]["note"]) == 200 c.post(url, json={"stage": "pulling", "expect_stage": "hold"}) r = c.post(url, json={"stage": "pulled", "expect_stage": "pulling"}) assert r.status_code == 200 and r.json()["wh"]["stage"] == "pulled" r = c.post(url, json={"stage": "pulling", "expect_stage": "pulled"}) assert r.status_code == 200 and r.json()["wh"]["stage"] == "pulling" assert last_log(c, pkt["id"])["action"] == "Warehouse: Back to pulling" bad = c.post(url, json={"stage": "incoming", "expect_stage": "pulling"}) assert bad.status_code in (409, 422), bad.text def test_pulled_tick_auto_starts(shop: Shop) -> None: s = shop.settings _, uid = make_unit_sql(s, "L9A1") c = shop("wh.wade") # From incoming a = warehouse_packet(shop, uid) r = bom_patch(1, pulled=True)(c, a, None) assert r.status_code == 200, r.text assert r.json()["wh"]["stage"] == "pulling" and r.json()["wh"]["auto"] is True lg = last_log(c, a["id"]) assert lg["action"] == "Pulled · item 1" and lg["note"].endswith("started pulling"), lg row = _one(s, "SELECT stage, auto FROM packet_wh WHERE packet_id = %s AND closed_at IS NULL", (a["id"],)) assert row == ("pulling", True) # A second tick does not move the stage again r = bom_patch(4, pulled=True)(c, r.json(), None) assert last_log(c, a["id"])["action"] == "Pulled · item 4" assert int(_one(s, "SELECT count(*) FROM packet_wh WHERE packet_id = %s", (a["id"],))[0]) == 2 # From hold _, uid2 = make_unit_sql(s, "L9A2") b = warehouse_packet(shop, uid2) r = c.post( f"/api/packets/{b['id']}/wh", json={"stage": "hold", "reason": "Waiting on purchasing / PO", "expect_stage": "incoming"}, ) assert r.status_code == 200, r.text r = bom_patch(1, pulled=True)(c, r.json(), None) assert r.status_code == 200 and r.json()["wh"]["stage"] == "pulling" and r.json()["wh"]["auto"] is True lg = last_log(c, b["id"]) assert lg["action"] == "Pulled · item 1" and lg["note"].endswith("pulling resumed"), lg row = _one(s, "SELECT stage, auto FROM packet_wh WHERE packet_id = %s AND closed_at IS NULL", (b["id"],)) assert row == ("pulling", True) # ---------------------------------------------------------------- heats and MTRs BAD_HEATS = ("", "A" * 41, " HT1", "HT@1", "-HT1") def test_bad_heat_is_refused(shop: Shop) -> None: _, uid = make_unit_sql(shop.settings, "L9B1") pkt = warehouse_packet(shop, uid) for value in BAD_HEATS: r = bom_patch(1, heat=value)(shop("wh.wade"), pkt, None) assert r.status_code == 422 and r.json()["code"] == "invalid_heat", (value, r.text) if value in ("", "HT@1"): assert r.json()["detail"] == ( f'Heat number "{value}" isn\'t valid: letters, numbers, space . / # _ - only, up to 40 characters.' ) assert fresh(shop("wh.wade"), pkt["id"])["bom"][0]["heat"] is None def test_good_heats_are_recorded(shop: Shop) -> None: _, uid = make_unit_sql(shop.settings, "L9G1") pkt = warehouse_packet(shop, uid) c = shop("wh.wade") for value in ("HT-1234/7A", "7A", "A 1.2#3_x", "A" * 40): r = bom_patch(1, heat=value)(c, pkt, None) assert r.status_code == 200, (value, r.text) pkt = r.json() assert line(pkt, 1)["heat"] == value lg = last_log(c, pkt["id"]) assert lg["action"] == "Heat recorded · item 1" and lg["note"] == value # A trailing space from a scanner is trimmed r = bom_patch(1, heat="HT-9 ")(c, pkt, None) assert r.status_code == 200 and line(r.json(), 1)["heat"] == "HT-9" def test_mtr_must_be_a_pdf_or_image(shop: Shop) -> None: _, uid = make_unit_sql(shop.settings, "L9M1") pkt = warehouse_packet(shop, uid) c = shop("wh.wade") missing = bom_patch(1, mtr_asset_id="6f1c1d1e-0000-4000-8000-000000000000")(c, pkt, None) assert missing.status_code == 422 and missing.json()["code"] == "invalid_asset" csv = shop("clerk.clara").post( "/api/assets", files={"file": ("bom.csv", b"item,qty\n1,4\n", "text/csv")}, data={"purpose": "bom"} ) assert csv.status_code == 201, csv.text wrong = bom_patch(1, mtr_asset_id=csv.json()["id"])(c, pkt, None) assert wrong.status_code == 422 and wrong.json()["code"] == "invalid_asset", wrong.text assert wrong.json()["detail"] == "An MTR is a PDF, PNG or JPG file." png = upload_mtr(c, tiny_png()) r = bom_patch(1, mtr_asset_id=png["id"])(c, pkt, None) assert r.status_code == 200 and line(r.json(), 1)["mtr_asset_id"] == png["id"], r.text r = bom_patch(1, mtr_asset_id=None)(c, r.json(), None) assert r.status_code == 200 and line(r.json(), 1)["mtr_asset_id"] is None # ---------------------------------------------------------------- the release gate def test_release_gate(shop: Shop) -> None: s = shop.settings _, uid = make_unit_sql(s, "L9R1") pkt = warehouse_packet(shop, uid) pid = pkt["id"] c = shop("wh.wade") pkt = bom_patch(1, heat="HT-1")(c, pkt, None).json() pkt = bom_patch(1, pulled=True)(c, pkt, None).json() pkt = bom_patch(3, pulled=True)(c, pkt, None).json() # the sub line needs no heat # Item 4 is not pulled: the first rule only (the N/A line and the sub line need nothing) assert "wh_release" not in pkt["can"] and pkt["blocked"]["wh_release"] == [MSG_PULL], pkt["blocked"] r = step(c, pkt, "wh_release") assert r.status_code == 403 and r.json()["code"] == "precondition" and r.json()["detail"] == MSG_PULL # Every line pulled, item 4 has no heat pkt = bom_patch(4, pulled=True)(c, pkt, None).json() assert pkt["blocked"]["wh_release"] == [MSG_HEAT], pkt["blocked"] r = step(c, pkt, "wh_release") assert r.status_code == 403 and r.json()["code"] == "precondition" and r.json()["detail"] == MSG_HEAT card = next(x for x in c.get("/api/packets", params={"status": "warehouse"}).json()["packets"] if x["id"] == pid) assert card["bom_total"] == 3 and card["bom_pulled"] == 3 and card["bom_missing_heat"] == 1 # Unpull item 4 and record a short instead: the first rule passes, the heat of the pulled pieces is missing pkt = bom_patch(4, pulled=False)(c, pkt, None).json() assert pkt["blocked"]["wh_release"] == [MSG_PULL] r = short_save(4, qty=1, reason="Not in stock", expect_open=False)(c, pkt, None) assert r.status_code == 200, r.text pkt = r.json() assert pkt["blocked"]["wh_release"] == [MSG_HEAT], pkt["blocked"] r = short_save(4, qty=1, reason="Not in stock", heat="HT-4", expect_open=True)(c, pkt, None) assert r.status_code == 200, r.text pkt = r.json() assert "wh_release" in pkt["can"] and "wh_release" not in pkt["blocked"] pkt = ok_step(c, pkt, "wh_release") assert pkt["status"] == "ready" lg = last_log(c, pid) assert lg["action"] == "Released to supervisor with 1 short" assert lg["note"] == "2 heats recorded · SHORT: item 4 ×1 (Not in stock)" board = c.get("/api/warehouse/board").json() rel = [x for x in board["columns"]["released"] if x["id"] == pid] assert len(rel) == 1 and rel[0]["released_at"], board["columns"]["released"] assert board["kpis"]["released_24h"] >= 1 assert all(pid not in [x["id"] for x in board["columns"][k]] for k in ("incoming", "pulling", "hold", "pulled")) # Sent back and released again: it shows once in the released column pkt = ok_step(shop("lead.lou"), pkt, "send_back_to_warehouse", reason="Wrong studs were pulled") assert pkt["status"] == "warehouse" pkt = ok_step(c, fresh(c, pid), "wh_release") lg = last_log(c, pid) assert lg["action"] == "Released to supervisor with 1 short" board = c.get("/api/warehouse/board").json() assert [x["id"] for x in board["columns"]["released"]].count(pid) == 1 # Without shorts the label has no count _, uid2 = make_unit_sql(s, "L9R2") other = warehouse_packet(shop, uid2) for item in (1, 3, 4): other = bom_patch(item, pulled=True)(c, other, None).json() for item in (1, 4): other = bom_patch(item, heat=f"HT-{item}0")(c, other, None).json() other = ok_step(c, other, "wh_release") lg = last_log(c, other["id"]) assert lg["action"] == "Released to supervisor" and lg["note"] == "2 heats recorded" def test_release_gate_without_traceable_lines(shop: Shop) -> None: """A packet whose lines are all N/A or sub-assemblies needs no heat at all.""" _, uid = make_unit_sql(shop.settings, "L9R3") pkt = warehouse_packet(shop, uid) c = shop("wh.wade") pkt = bom_patch(1, na=True)(c, pkt, None).json() pkt = bom_patch(4, na=True)(c, pkt, None).json() assert pkt["blocked"]["wh_release"] == [MSG_PULL] pkt = bom_patch(3, pulled=True)(c, pkt, None).json() assert "wh_release" in pkt["can"], pkt["blocked"] # ---------------------------------------------------------------- receiving a short by custody def test_receive_short_by_custody(shop: Shop) -> None: s = shop.settings _, uid = make_unit_sql(s, "L9C1") pkt = warehouse_packet(shop, uid) pid = pkt["id"] wade = shop("wh.wade") pkt = bom_patch(1, heat="HT-1")(wade, pkt, None).json() pkt = bom_patch(1, pulled=True)(wade, pkt, None).json() pkt = bom_patch(3, pulled=True)(wade, pkt, None).json() pkt = short_save(4, qty=3, reason="On order", ref="PO-88", heat="HT-4", expect_open=False)(wade, pkt, None).json() pkt = ok_step(wade, pkt, "wh_release") assert pkt["status"] == "ready" and pkt["open_shorts"] == 1 r = receipt(4, qty=1, heat="HT-R1")(wade, pkt, None) assert r.status_code == 200, r.text pkt = r.json() lg = last_log(wade, pid) assert lg["action"] == "Short received · item 4" assert lg["note"] == "1 pc · heat HT-R1 · 2 still short · packet ready to assign" assert last_audit(s, pid)[0] == "packet.wh_receive_short" # The warehouse does not hold the ready packet: no other line edit r = bom_patch(1, heat="HT-X")(wade, pkt, None) assert r.status_code == 403 and r.json()["code"] == "forbidden", r.text r = bom_patch(1, loc="Rack 9")(wade, pkt, None) assert r.status_code == 403 and r.json()["code"] == "forbidden", r.text for sam in ("qc.quinn", "clerk.clara", "pm.paula", "weld.andy"): r = receipt(4, qty=1, heat="HT-R2")(shop(sam), pkt, None) assert r.status_code == 403 and r.json()["code"] == "forbidden", (sam, r.text) lou = shop("lead.lou") over = receipt(4, qty=3, heat="HT-R2")(lou, pkt, None) assert over.status_code == 422, over.text noheat = receipt(4, qty=1)(lou, pkt, None) assert noheat.status_code == 422 and noheat.json()["code"] == "heat_required", noheat.text r = receipt(4, qty=1, heat="HT-R2")(lou, pkt, None) assert r.status_code == 200, r.text pkt = r.json() assert last_log(lou, pid)["by"]["display_name"] == "Lou Lead" assert last_audit(s, pid)[0] == "packet.edit_bom" r = receipt(4, qty=1, heat="HT-R3")(shop("sup.wes"), pkt, None) assert r.status_code == 200, r.text pkt = r.json() assert pkt["open_shorts"] == 0 and line(pkt, 4)["short"] is None assert line(pkt, 4)["heat"] == "HT-4 / HT-R1 / HT-R2 / HT-R3" assert last_log(wade, pid)["note"] == "1 pc · heat HT-R3 · short closed · packet ready to assign" closed = _one( s, "SELECT s.closed_at, s.qty_open FROM bom_shorts s JOIN bom_lines b ON b.id = s.bom_line_id" " WHERE s.packet_id = %s AND b.item = 4", (pid,), ) assert closed[0] is not None and float(closed[1]) == 0 r = receipt(4, qty=1, heat="HT-R4")(wade, pkt, None) assert r.status_code in (403, 404), r.text # nothing is short now # The holder may open a short on a ready packet; a receipt on an N/A line needs no heat. r = short_save(2, qty=2, reason="Damaged / rejected", expect_open=False)(lou, pkt, None) assert r.status_code == 200, r.text pkt = r.json() with app_conn(s) as conn: stall = station_id(conn) andy = user_id(conn, "weld.andy") pkt = ok_step(shop("sup.wes"), pkt, "assign", station_id=stall, welder_id=andy) r = receipt(2, qty=2)(wade, pkt, None) assert r.status_code == 200, r.text lg = last_log(wade, pid) assert lg["action"] == "Short received · item 2" assert lg["note"] == "2 pc · short closed · packet at a station in Stall 1" def test_short_reasons_are_a_closed_set(shop: Shop) -> None: _, uid = make_unit_sql(shop.settings, "L9Q1") pkt = warehouse_packet(shop, uid) c = shop("wh.wade") bad = short_save(4, qty=1, reason="Lost it", expect_open=False)(c, pkt, None) assert bad.status_code == 400 and bad.json()["code"] == "invalid_reason", bad.text assert bad.json()["detail"] == "Pick one of the short reasons." for q in (0, 5): r = short_save(4, qty=q, reason="Other", note="x", expect_open=False)(c, pkt, None) assert r.status_code == 422, (q, r.text) reasons = ( "Not in stock", "On order", "Back-ordered", "Damaged / rejected", "Wrong part received", "Needs cutting / prep", "Other", ) open_ = False for reason in reasons: r = short_save(4, qty=1, reason=reason, note="see PO", expect_open=open_)(c, pkt, None) assert r.status_code == 200, (reason, r.text) open_ = True assert line(r.json(), 4)["short"]["reason"] == reason # ---------------------------------------------------------------- the board endpoint def test_board(shop: Shop) -> None: s = shop.settings proj_a, unit_a = make_unit_sql(s, "L9K1") proj_b, unit_b = make_unit_sql(s, "L9K2") p1 = warehouse_packet(shop, unit_a, drawing="D-OLD") p2 = warehouse_packet(shop, unit_a, drawing="D-NEW") p5 = warehouse_packet(shop, unit_a, drawing="D-REL") p3 = warehouse_packet(shop, unit_a, drawing="D-HOLD") p4 = warehouse_packet(shop, unit_b, drawing="D-OTHER") with app_conn(s) as conn: conn.execute("UPDATE packets SET wh_sent_at = now() - interval '3 days' WHERE id = %s", (p1["id"],)) conn.execute( "UPDATE packets SET wh_sent_at = date_trunc('day', now()) - interval '1 day' + interval '9 hours'" " WHERE id = %s", (p2["id"],), ) conn.execute( "UPDATE packets SET wh_sent_at = date_trunc('day', now()) - interval '1 day' + interval '10 hours'," " visual_rel = '2026-11-01' WHERE id = %s", (p5["id"],), ) wade = shop("wh.wade") r = wade.post( f"/api/packets/{p3['id']}/wh", json={"stage": "hold", "reason": "Drawing / BOM problem", "note": "Asked Leo", "expect_stage": "incoming"}, ) assert r.status_code == 200, r.text r = wade.post(f"/api/packets/{p4['id']}/wh", json={"stage": "pulling", "expect_stage": "incoming"}) assert r.status_code == 200, r.text p4 = r.json() p4 = short_save(4, qty=1, reason="On order", ref="PO-1", eta="2020-01-02", expect_open=False)(wade, p4, None).json() p1 = short_save(1, qty=1, reason="Not in stock", expect_open=False)(wade, p1, None).json() assert p1["wh"]["stage"] == "pulling" # the short auto-started it r = shop("pm.paula").get("/api/warehouse/board") assert r.status_code == 200, r.text b = r.json() assert set(b) >= {"kpis", "columns", "shorts"} assert b["kpis"] == {"waiting": 2, "pulling": 2, "hold": 1, "pulled": 0, "open_shorts": 2, "released_24h": 0} cols = {k: [x["id"] for x in v] for k, v in b["columns"].items()} assert set(cols) == {"incoming", "pulling", "hold", "pulled", "released"} assert cols["incoming"] == [p5["id"], p2["id"]] # same day: the earlier VISUAL release date first assert cols["pulling"] == [p1["id"], p4["id"]] # the older day first assert cols["hold"] == [p3["id"]] and cols["pulled"] == [] and cols["released"] == [] hold = b["columns"]["hold"][0] assert hold["wh"]["stage"] == "hold" and hold["wh"]["reason"] == "Drawing / BOM problem" assert hold["wh"]["note"] == "Asked Leo" and hold["wh"]["auto"] is False assert hold["wh"]["by"]["display_name"] == "Wade Warehouse" and hold["wh"]["at"] inc = b["columns"]["incoming"][0] assert inc["visual_rel"] == "2026-11-01" and inc["wh_sent_at"] assert inc["bom_total"] == 3 and inc["bom_pulled"] == 0 and inc["bom_missing_heat"] == 0 shorts = b["shorts"] assert [(x["packet"]["id"], x["item"]) for x in shorts] == [(p4["id"], 4), (p1["id"], 1)] late, plain = shorts assert late["late"] is True and plain["late"] is False assert float(late["qty"]) == 4 and float(late["qty_open"]) == 1 and late["reason"] == "On order" assert late["ref"] == "PO-1" and late["eta"] == "2020-01-02" and late["age_days"] == 0 assert late["part_id"] == "STUD-58" and late["description"] == "5/8 stud bolt" assert late["short_id"] and late["row_version"] >= 1 and late["bom_line_id"] and late["opened_at"] pk = late["packet"] assert pk["status"] == "warehouse" and pk["wh_stage"] == "pulling" and pk["holder"] == "Warehouse" assert pk["dept"]["code"] == "03" and pk["station"] is None and pk["drawing"] == "D-OTHER" assert plain["eta"] is None and plain["material"] == "A106-B" and plain["length"] == "20ft" def board(**params: Any) -> dict[str, Any]: r = wade.get("/api/warehouse/board", params=params) assert r.status_code == 200, r.text out: dict[str, Any] = r.json() return out def ids(bd: dict[str, Any]) -> set[int]: return {x["id"] for v in bd["columns"].values() for x in v} by_project = board(project_id=proj_b) assert ids(by_project) == {p4["id"]} and [x["packet"]["id"] for x in by_project["shorts"]] == [p4["id"]] assert by_project["kpis"]["waiting"] == 0 and by_project["kpis"]["pulling"] == 1 assert by_project["kpis"]["hold"] == 0 and by_project["kpis"]["open_shorts"] == 1 by_unit = board(unit_id=unit_a) assert ids(by_unit) == {p1["id"], p2["id"], p3["id"], p5["id"]} assert by_unit["kpis"]["waiting"] == 2 and by_unit["kpis"]["pulling"] == 1 and by_unit["kpis"]["hold"] == 1 by_q = board(q="d-hold") assert ids(by_q) == {p3["id"]} and by_q["shorts"] == [] assert by_q["kpis"] == b["kpis"] # the KPIs follow the project and unit only by_dept = board(dept="06") assert ids(by_dept) == set() and by_dept["shorts"] == [] and by_dept["kpis"] == b["kpis"] assert ids(board(dept="03")) == ids(b) assert proj_a != proj_b # Every card of the packet list carries the warehouse fields cards = {x["id"]: x for x in wade.get("/api/packets", params={"status": "warehouse"}).json()["packets"]} c4 = cards[p4["id"]] assert c4["wh"]["stage"] == "pulling" and c4["wh_stage"] == "pulling" assert c4["bom_total"] == 3 and c4["bom_pulled"] == 1 and c4["bom_missing_heat"] == 1 assert "wh_sent_at" in c4 and "visual_rel" in c4 # A removed packet's shorts are not listed with app_conn(s) as conn: conn.execute("UPDATE packets SET removed_at = now() WHERE id = %s", (p4["id"],)) after = board() assert p4["id"] not in [x["packet"]["id"] for x in after["shorts"]] assert p4["id"] not in ids(after) def test_board_needs_a_session(shop: Shop) -> None: as_user(shop.main, "weld.andy") r = shop.main.get("/api/warehouse/board") assert r.status_code == 200 and r.json()["kpis"]["waiting"] == 0 anon = TestClient(shop.main.app, headers=ORIGIN_HEADERS) assert anon.get("/api/warehouse/board").status_code == 401