Files

677 lines
33 KiB
Python
Raw Permalink Normal View History

"""Packet child rows: the BOM and shorts, welds, heat tags, the heat table, the stage, the warehouse board, Q.C.
and change orders. Every write goes through the pipeline; these tests check the policy, the row and state checks,
and what each write leaves behind."""
from __future__ import annotations
import threading
from collections.abc import Iterator
from typing import Any
import psycopg
import pytest
from fakedir import FakeDirectory
from fastapi.testclient import TestClient
from pkthelpers import (
SIGN,
as_user,
finish_stage_by_sql,
get,
make_draft,
make_unit,
ok_step,
phase2_client,
ready_draft,
seed_users,
station_id,
step,
user_id,
)
from pshelpers import app_conn, needs_pg
from packetstation.settings import Settings
pytestmark = needs_pg
BOM = [
{"item": 1, "qty": 4, "part_id": "PIPE-4", "description": "4in pipe SCH40"},
{"item": 2, "qty": 2, "part_id": "FLG-4", "description": "4in flange"},
]
@pytest.fixture
def pg_client2(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[TestClient]:
with phase2_client(pg_settings, fake_dir) as client:
seed_users(client)
with app_conn(pg_settings) as conn:
conn.execute("UPDATE users SET welder_stamp = 'A7' WHERE sam_account = 'weld.andy'")
yield client
@pytest.fixture
def unit(pg_settings: Settings) -> tuple[int, int]:
with app_conn(pg_settings) as conn:
return make_unit(conn)
def _one(settings: Settings, sql: str, params: Any = ()) -> Any:
with app_conn(settings) as conn:
return conn.execute(sql, params).fetchone()
def _line(pkt: dict[str, Any], item: int) -> dict[str, Any]:
return next(b for b in pkt["bom"] if b["item"] == item)
def _im(v: int) -> dict[str, str]:
return {"If-Match": str(v)}
def warehouse_packet(client: TestClient, unit_id: int) -> dict[str, Any]:
as_user(client, "clerk.clara")
pkt = ready_draft(client, unit_id, bom=BOM)
return ok_step(client, pkt, "send_to_warehouse")
def pull_all(client: TestClient, pkt: dict[str, Any]) -> dict[str, Any]:
for b in pkt["bom"]:
if not b["pulled"] and b["short"] is None:
r = client.patch(
f"/api/packets/{pkt['id']}/bom/{b['item']}",
json={"pulled": True, "heat": f"HT-{b['item']}"},
headers=_im(b["row_version"]),
)
assert r.status_code == 200, r.text
pkt = r.json()
return pkt
def shop_packet(client: TestClient, settings: Settings, unit_id: int) -> dict[str, Any]:
"""A packet in progress at Stall 1 with weld.andy, every BOM line pulled."""
pkt = warehouse_packet(client, unit_id)
as_user(client, "wh.wade")
pkt = pull_all(client, pkt)
pkt = ok_step(client, pkt, "wh_release")
with app_conn(settings) as conn:
stall = station_id(conn)
andy = user_id(conn, "weld.andy")
as_user(client, "sup.wes")
pkt = ok_step(client, pkt, "assign", station_id=stall, welder_id=andy)
as_user(client, "weld.andy")
pkt = ok_step(client, pkt, "start")
assert pkt["status"] == "in_progress"
return pkt
# ---------------------------------------------------------------- BOM
def test_bom_full_replace_on_a_draft(pg_client2: TestClient, unit: tuple[int, int]) -> None:
c = pg_client2
as_user(c, "clerk.clara")
pkt = ready_draft(c, unit[1])
url = f"/api/packets/{pkt['id']}/bom"
body = {"bom": [{**BOM[0], "cl_item": "A", "material": " A106-B​ ", "length": "20ft"}, BOM[1]]}
assert c.put(url, json=body).status_code == 428
r = c.put(url, json=body, headers=_im(pkt["version"]))
assert r.status_code == 200, r.text
out = r.json()
assert [b["item"] for b in out["bom"]] == [1, 2] and out["version"] == pkt["version"] + 1
assert _line(out, 1)["material"] == "A106-B" and _line(out, 1)["src"] == "hand"
stale = c.put(url, json={"bom": [BOM[0]]}, headers=_im(pkt["version"]))
assert stale.status_code == 409 and stale.json()["code"] == "version_conflict", stale.text
dup = c.put(url, json={"bom": [BOM[0], BOM[0]]}, headers=_im(out["version"]))
assert dup.status_code == 422 and dup.json()["code"] == "duplicate_item"
zero = c.put(url, json={"bom": [{"item": 0, "qty": 1}]}, headers=_im(out["version"]))
assert zero.status_code == 422
r = c.put(url, json={"bom": [BOM[1]]}, headers=_im(out["version"]))
assert r.status_code == 200 and [b["item"] for b in r.json()["bom"]] == [2]
log = c.get(f"/api/packets/{pkt['id']}/log").json()
assert log[0]["action"] == "BOM replaced" and log[0]["note"] == "1 line"
wh = ok_step(c, r.json(), "send_to_warehouse")
r = c.put(url, json={"bom": BOM}, headers=_im(wh["version"]))
assert r.status_code == 403 and r.json()["code"] == "forbidden", r.text
as_user(c, "wh.wade")
r = c.put(url, json={"bom": BOM}, headers=_im(wh["version"]))
assert r.status_code == 403 and r.json()["code"] == "wrong_status", r.text
def test_bom_line_patch_in_the_warehouse(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
c = pg_client2
pkt = warehouse_packet(c, unit[1])
assert pkt["wh"]["stage"] == "incoming"
url = f"/api/packets/{pkt['id']}/bom/1"
r = c.patch(url, json={"heat": "H123"}, headers=_im(1))
assert r.status_code == 403 and r.json()["code"] == "forbidden"
assert "It's with Warehouse right now." in r.json()["detail"]
as_user(c, "wh.wade")
r = c.patch(url, json={"heat": "H123", "loc": "Rack 4"}, headers=_im(1))
assert r.status_code == 200, r.text
line = _line(r.json(), 1)
assert line["heat"] == "H123" and line["loc"] == "Rack 4" and line["row_version"] == 2
assert r.json()["wh"]["stage"] == "incoming"
stale = c.patch(url, json={"heat": "H999"}, headers=_im(1))
assert stale.status_code == 409 and stale.json()["code"] == "row_conflict"
assert stale.json()["current"]["row_version"] == 2 and stale.json()["current"]["heat"] == "H123"
bad = c.patch(url, json={"heat": "@@ bad"}, headers=_im(2))
assert bad.status_code == 422 and bad.json()["code"] == "invalid_heat"
nomtr = c.patch(url, json={"mtr_asset_id": "6f1c1d1e-0000-4000-8000-000000000000"}, headers=_im(2))
assert nomtr.status_code == 422 and nomtr.json()["code"] == "invalid_asset"
r = c.patch(url, json={"pulled": True}, headers=_im(2))
assert r.status_code == 200, r.text
out = r.json()
line = _line(out, 1)
assert line["pulled"] is True and float(line["issued"]) == 4 and line["row_version"] == 3
assert out["wh"]["stage"] == "pulling" and out["wh"]["auto"] is True and out["wh"]["note"] == "started pulling"
row = _one(
pg_settings, "SELECT pulled_at, pulled_by FROM bom_lines WHERE packet_id = %s AND item = 1", (pkt["id"],)
)
assert row[0] is not None and row[1] is not None
r = c.patch(f"/api/packets/{pkt['id']}/bom/2", json={"pulled": True}, headers=_im(1))
assert r.json()["wh"]["stage"] == "pulling"
n = _one(pg_settings, "SELECT count(*) FROM packet_wh WHERE packet_id = %s", (pkt["id"],))[0]
assert n == 2
r = c.patch(url, json={"na": True}, headers=_im(3))
assert _line(r.json(), 1)["na"] is True and _line(r.json(), 1)["heat"] is None
r = c.patch(url, json={"pulled": False}, headers=_im(4))
assert _line(r.json(), 1)["pulled"] is False
row = _one(pg_settings, "SELECT pulled_at FROM bom_lines WHERE packet_id = %s AND item = 1", (pkt["id"],))
assert row[0] is None
same = c.patch(url, json={"pulled": False}, headers=_im(5))
assert same.status_code == 200 and same.json()["version"] == r.json()["version"]
def test_wh_hold_and_first_pull_resumes(pg_client2: TestClient, unit: tuple[int, int]) -> None:
c = pg_client2
pkt = warehouse_packet(c, unit[1])
as_user(c, "wh.wade")
r = c.post(
f"/api/packets/{pkt['id']}/wh",
json={"stage": "hold", "reason": "Waiting on purchasing / PO", "expect_stage": "incoming"},
)
assert r.status_code == 200 and r.json()["wh"]["stage"] == "hold", r.text
r = c.patch(f"/api/packets/{pkt['id']}/bom/1", json={"pulled": True}, headers=_im(1))
assert r.json()["wh"]["stage"] == "pulling" and r.json()["wh"]["note"] == "pulling resumed"
def test_shorts_and_receipts(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
c = pg_client2
pkt = warehouse_packet(c, unit[1])
pid = pkt["id"]
as_user(c, "wh.wade")
url = f"/api/packets/{pid}/bom/1/short"
body = {
"qty": 3,
"reason": "Not in stock",
"ref": "PO-991",
"eta": "2026-11-02",
"heat": "H0",
"expect_open": False,
}
r = c.post(url, json=body)
assert r.status_code == 200, r.text
out = r.json()
line = _line(out, 1)
assert float(line["short"]["qty"]) == 3 and float(line["short"]["qty_open"]) == 3
assert line["short"]["ref"] == "PO-991"
assert line["pulled"] is True and float(line["issued"]) == 1 and out["open_shorts"] == 1
assert out["wh"]["stage"] == "pulling" and out["wh"]["auto"] is True
again = c.post(url, json=body)
assert again.status_code == 409 and again.json()["code"] == "short_conflict"
assert c.post(url, json={**body, "qty": 9, "expect_open": True}).status_code == 422
r = c.post(url, json={**body, "qty": 2, "expect_open": True})
assert r.status_code == 200 and float(_line(r.json(), 1)["short"]["qty_open"]) == 2
assert float(_line(r.json(), 1)["issued"]) == 2
rv = _line(r.json(), 1)["row_version"]
blocked = c.patch(f"/api/packets/{pid}/bom/1", json={"heat": "H1"}, headers=_im(rv))
assert blocked.status_code == 409 and blocked.json()["code"] == "short_open"
# A short on item 2, then cleared: the whole quantity counts as issued.
r = c.post(f"/api/packets/{pid}/bom/2/short", json={"qty": 1, "reason": "Damaged / rejected", "expect_open": False})
assert _line(r.json(), 2)["short"] is not None
r = c.request("DELETE", f"/api/packets/{pid}/bom/2/short", json={"expect_open": True})
assert r.status_code == 200, r.text
assert _line(r.json(), 2)["short"] is None and float(_line(r.json(), 2)["issued"]) == 2
assert c.request("DELETE", f"/api/packets/{pid}/bom/2/short", json={"expect_open": True}).status_code == 409
# The release gate needs a heat on every pulled line.
rv2 = _line(r.json(), 2)["row_version"]
r = c.patch(f"/api/packets/{pid}/bom/2", json={"heat": "H2"}, headers=_im(rv2))
assert r.status_code == 200, r.text
pkt = ok_step(c, r.json(), "wh_release")
assert pkt["status"] == "ready" and pkt["open_shorts"] == 1
rec = f"/api/packets/{pid}/bom/1/short/receipts"
r = c.post(rec, json={"qty": 1, "heat": "H100", "note": "First piece"})
assert r.status_code == 200, r.text
line = _line(r.json(), 1)
assert float(line["short"]["qty_open"]) == 1 and len(line["short"]["receipts"]) == 1
assert line["short"]["receipts"][0]["by"]["display_name"] == "Wade Warehouse"
assert line["heat"] == "H0 / H100" and float(line["issued"]) == 3
assert c.post(f"/api/packets/{pid}/bom/2/short/receipts", json={"qty": 1, "heat": "H1"}).status_code == 404
with app_conn(pg_settings) as conn:
stall = station_id(conn)
andy = user_id(conn, "weld.andy")
as_user(c, "sup.wes")
pkt = ok_step(c, r.json(), "assign", station_id=stall, welder_id=andy)
as_user(c, "weld.andy")
pkt = ok_step(c, pkt, "start")
# Receive by custody: someone who neither is in the warehouse nor holds the packet is refused.
as_user(c, "qc.quinn")
no = c.post(rec, json={"qty": 1, "heat": "H200"})
assert no.status_code == 403 and no.json()["code"] == "forbidden"
as_user(c, "wh.wade")
assert c.post(rec, json={"qty": 2, "heat": "H200"}).status_code == 422
nh = c.post(rec, json={"qty": 1})
assert nh.status_code == 422 and nh.json()["code"] == "heat_required"
r = c.post(rec, json={"qty": 1, "heat": "H200"})
assert r.status_code == 200, r.text
line = _line(r.json(), 1)
assert line["short"] is None and r.json()["open_shorts"] == 0 and r.json()["status"] == "in_progress"
assert line["heat"] == "H0 / H100 / H200" and float(line["issued"]) == 4
closed = _one(
pg_settings,
"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 = 1",
(pid,),
)
assert closed[0] is not None and closed[1] == 0
with app_conn(pg_settings) as conn, pytest.raises(psycopg.Error):
conn.execute("UPDATE short_receipts SET qty = 9 WHERE packet_id = %s", (pid,))
with app_conn(pg_settings) as conn, pytest.raises(psycopg.Error):
conn.execute("DELETE FROM short_receipts WHERE packet_id = %s", (pid,))
# ---------------------------------------------------------------- welds, heat tags, heat table
def test_welds(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
c = pg_client2
pkt = shop_packet(c, pg_settings, unit[1])
pid = pkt["id"]
weld = {"page": 1, "fx": 0.25, "fy": 0.5, "wo_base": "L670001", "piece": 1, "items": [2, 1], "type": "Fillet"}
r1 = c.post(f"/api/packets/{pid}/welds", json={**weld, "size": "1/4"})
assert r1.status_code == 200, r1.text
r2 = c.post(f"/api/packets/{pid}/welds", json={**weld, "type": "Groove"})
assert r2.status_code == 200, r2.text
welds = r2.json()["welds"]
assert [w["weld_no"] for w in welds] == [1, 2]
assert welds[0]["items"] == [1, 2] and welds[0]["stamp"] == "A7"
assert welds[0]["welder"]["display_name"] == "Andy Welder"
row = _one(pg_settings, "SELECT dept_code, stage_id FROM welds WHERE packet_id = %s AND weld_no = 1", (pid,))
assert row[0] == "03" and row[1] == pkt["stage"]["id"]
bad = c.post(f"/api/packets/{pid}/welds", json={**weld, "items": [9]})
assert bad.status_code == 422 and bad.json()["code"] == "invalid_items"
assert c.post(f"/api/packets/{pid}/welds", json={**weld, "items": []}).status_code == 422
assert c.post(f"/api/packets/{pid}/welds", json={**weld, "stamp": "no stamp!"}).status_code == 422
r = c.patch(f"/api/packets/{pid}/welds/1", json={"size": "3/8"}, headers=_im(1))
assert r.status_code == 200 and r.json()["welds"][0]["size"] == "3/8" and r.json()["welds"][0]["row_version"] == 2
stale = c.patch(f"/api/packets/{pid}/welds/1", json={"size": "1/2"}, headers=_im(1))
assert stale.status_code == 409 and stale.json()["code"] == "row_conflict"
r = c.request("DELETE", f"/api/packets/{pid}/welds/2", json={"reason": "Marked twice"})
assert r.status_code == 200 and [w["weld_no"] for w in r.json()["welds"]] == [1]
row = _one(pg_settings, "SELECT removed_reason FROM welds WHERE packet_id = %s AND weld_no = 2", (pid,))
assert row[0] == "Marked twice"
assert c.patch(f"/api/packets/{pid}/welds/2", json={"size": "1"}, headers=_im(1)).status_code == 404
r = c.post(f"/api/packets/{pid}/welds", json=weld)
assert r.json()["welds"][-1]["weld_no"] == 3
# Another worker of the department, limited to another stall, does not hold this packet.
with app_conn(pg_settings) as conn:
conn.execute(
"INSERT INTO user_station_limits (user_id, station_id) VALUES (%s, %s)",
(user_id(conn, "dup.dave"), station_id(conn, "03", "Stall 2")),
)
as_user(c, "dup.dave")
r = c.post(f"/api/packets/{pid}/welds", json=weld)
assert r.status_code == 403 and r.json()["code"] == "forbidden", r.text
as_user(c, "clerk.clara")
assert c.post(f"/api/packets/{pid}/welds", json=weld).status_code == 403
def test_heat_tags_and_heat_table(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
c = pg_client2
pkt = warehouse_packet(c, unit[1])
pid = pkt["id"]
assert c.post(f"/api/packets/{pid}/heat-tags", json={"page": 1, "item": 1}).status_code == 403
as_user(c, "wh.wade")
for item in (1, 2):
r = c.post(f"/api/packets/{pid}/heat-tags", json={"page": 1, "item": item, "fx": 0.1, "fy": 0.2})
assert r.status_code == 200, r.text
tags = r.json()["heat_tags"]
assert [(t["tag_no"], t["item"]) for t in tags] == [(1, 1), (2, 2)]
assert c.post(f"/api/packets/{pid}/heat-tags", json={"page": 1, "item": 7}).status_code == 422
r = c.patch(f"/api/packets/{pid}/heat-tags/2", json={"item": 1}, headers=_im(1))
assert r.status_code == 200 and r.json()["heat_tags"][1]["item"] == 1
stale = c.patch(f"/api/packets/{pid}/heat-tags/2", json={"item": 2}, headers=_im(1))
assert stale.status_code == 409 and stale.json()["code"] == "row_conflict"
r = c.request("DELETE", f"/api/packets/{pid}/heat-tags/1", json={})
assert r.status_code == 200 and [t["tag_no"] for t in r.json()["heat_tags"]] == [2]
url = f"/api/packets/{pid}/heat-table/1"
r1 = c.put(url, json={"x": 0.7, "y": 0.1, "s": 1.0})
assert r1.status_code == 200
ht = r1.json()["heat_table"]
assert [{k: h[k] for k in ("page", "x", "y", "s")} for h in ht] == [{"page": 1, "x": 0.7, "y": 0.1, "s": 1.0}]
assert ht[0]["carried_from"] is None and ht[0]["by"]["display_name"] == "Wade Warehouse"
r2 = c.put(url, json={"x": 0.7, "y": 0.1, "s": 1.0})
assert r2.status_code == 200 and r2.json()["version"] == r1.json()["version"]
r3 = c.put(url, json={"x": 0.6, "y": 0.1, "s": 1.2})
assert r3.json()["heat_table"][0]["x"] == 0.6 and r3.json()["version"] == r1.json()["version"] + 1
n = _one(pg_settings, "SELECT count(*) FROM heat_table_placements WHERE packet_id = %s", (pid,))[0]
assert n == 1
assert c.put(url, json={"x": 0.6, "y": 0.1, "s": 0}).status_code == 422
r = c.request("DELETE", url, json={})
assert r.status_code == 200 and r.json()["heat_table"] == []
assert c.request("DELETE", url, json={}).status_code == 404
# ---------------------------------------------------------------- the stage
def test_stage_fields_and_checklist(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
c = pg_client2
pkt = shop_packet(c, pg_settings, unit[1])
pid = pkt["id"]
rv = pkt["stage"]["row_version"]
r = c.patch(f"/api/packets/{pid}/stage", json={"filler": "ER70S-6", "preheat": "50F"}, headers=_im(rv))
assert r.status_code == 200, r.text
st = r.json()["stage"]
assert st["filler"] == "ER70S-6" and st["preheat"] == "50F" and st["row_version"] == rv + 1
stale = c.patch(f"/api/packets/{pid}/stage", json={"filler": "E7018"}, headers=_im(rv))
assert stale.status_code == 409 and stale.json()["code"] == "row_conflict"
andy = user_id_of(pg_settings, "weld.andy")
r = c.put(f"/api/packets/{pid}/stage/checklist/dwg", json={"done": True})
assert r.status_code == 200, r.text
entry = r.json()["stage"]["checklist"]["dwg"]
assert entry["done"] is True and entry["by"] == andy and entry["stamp"] == "A7" and entry["at"]
assert r.json()["stage"]["row_version"] == st["row_version"]
bad = c.put(f"/api/packets/{pid}/stage/checklist/nope", json={"done": True})
assert bad.status_code == 422 and bad.json()["code"] == "invalid_key"
as_user(c, "lead.lou")
r = c.put(f"/api/packets/{pid}/stage/checklist/heat", json={"done": True})
assert r.status_code == 200 and r.json()["stage"]["checklist"]["heat"]["by"] == user_id_of(pg_settings, "lead.lou")
r = c.put(f"/api/packets/{pid}/stage/checklist/dwg", json={"done": False})
assert r.json()["stage"]["checklist"]["dwg"] == {"done": False}
as_user(c, "wh.wade")
assert c.put(f"/api/packets/{pid}/stage/checklist/dwg", json={"done": True}).status_code == 403
def user_id_of(settings: Settings, sam: str) -> str:
with app_conn(settings) as conn:
return user_id(conn, sam)
def test_stage_clock(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
c = pg_client2
pkt = shop_packet(c, pg_settings, unit[1])
url = f"/api/packets/{pkt['id']}/stage/clock"
assert pkt["stage"]["clock"]["running_since"] is not None
r = c.post(url, json={"op": "start"})
assert r.status_code == 409 and r.json()["code"] == "clock_state"
r = c.post(url, json={"op": "pause"})
assert r.status_code == 200 and r.json()["stage"]["clock"]["running_since"] is None, r.text
assert r.json()["stage"]["clock"]["elapsed_ms"] >= 0
assert c.post(url, json={"op": "pause"}).json()["code"] == "clock_state"
r = c.post(url, json={"op": "resume"})
assert r.status_code == 200 and r.json()["stage"]["clock"]["running_since"] is not None
assert c.post(url, json={"op": "resume"}).status_code == 409
assert c.post(url, json={"op": "stop"}).status_code == 422
as_user(c, "clerk.clara")
assert c.post(url, json={"op": "pause"}).status_code == 403
def test_no_stage(pg_client2: TestClient, unit: tuple[int, int]) -> None:
c = pg_client2
as_user(c, "clerk.clara")
pkt = make_draft(c, unit[1])
r = c.post(f"/api/packets/{pkt['id']}/stage/clock", json={"op": "start"})
assert r.status_code == 403 and r.json()["code"] == "wrong_status"
# ---------------------------------------------------------------- the warehouse board
def test_wh_stage_board(pg_client2: TestClient, unit: tuple[int, int]) -> None:
c = pg_client2
pkt = warehouse_packet(c, unit[1])
url = f"/api/packets/{pkt['id']}/wh"
r = c.post(url, json={"stage": "pulling", "expect_stage": "incoming"})
assert r.status_code == 403 and r.json()["code"] == "forbidden"
as_user(c, "wh.wade")
r = c.post(url, json={"stage": "pulling", "expect_stage": "incoming"})
assert r.status_code == 200 and r.json()["wh"]["stage"] == "pulling" and r.json()["wh"]["auto"] is False
assert r.json()["wh"]["by"]["display_name"] == "Wade Warehouse"
stale = c.post(url, json={"stage": "pulling", "expect_stage": "incoming"})
assert stale.status_code == 409 and stale.json()["code"] == "stale_step"
assert stale.json()["current"]["stage"] == "pulling"
r = c.post(url, json={"stage": "hold", "expect_stage": "pulling"})
assert r.status_code == 400 and r.json()["code"] == "invalid_reason"
r = c.post(url, json={"stage": "hold", "reason": "Other", "expect_stage": "pulling"})
assert r.status_code == 400 and r.json()["code"] == "reason_required"
hold = {"stage": "hold", "reason": "Drawing / BOM problem", "note": "Asked Leo", "expect_stage": "pulling"}
r = c.post(url, json=hold)
assert r.status_code == 200 and r.json()["wh"]["reason"] == "Drawing / BOM problem"
assert r.json()["wh"]["note"] == "Asked Leo"
bad = c.post(url, json={"stage": "pulled", "expect_stage": "hold"})
assert bad.status_code == 409 and bad.json()["code"] == "wh_transition"
r = c.post(url, json={"stage": "pulling", "expect_stage": "hold"})
assert r.status_code == 200
r = c.post(url, json={"stage": "pulled", "expect_stage": "pulling"})
assert r.status_code == 200 and r.json()["wh"]["stage"] == "pulled"
log = c.get(f"/api/packets/{pkt['id']}/log").json()
assert log[0]["action"] == "Warehouse: Marked pulled, ready for production"
assert log[1]["action"] == "Warehouse: Pulling resumed"
# ---------------------------------------------------------------- Q.C.
def test_qc_patch(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
c = pg_client2
pkt = shop_packet(c, pg_settings, unit[1])
pid = pkt["id"]
r = c.patch(f"/api/packets/{pid}/qc", json={"inspector": "Quinn"}, headers=_im(1))
assert r.status_code == 403
finish_stage_by_sql(pg_settings, pid)
pkt = ok_step(c, get(c, pid), "submit", sign=SIGN)
as_user(c, "sup.wes")
pkt = ok_step(c, pkt, "send_to_qc", book=True, spool=True, location="Bay 3", sign=SIGN)
rv = pkt["qc"]["row_version"]
as_user(c, "qc.quinn")
url = f"/api/packets/{pid}/qc"
body = {"checks": {"db_heats": True}, "weld_exam": {"1": {"result": "accept"}}, "inspector": "Quinn QC"}
r = c.patch(url, json={**body, "stamp": "Q5"}, headers=_im(rv))
assert r.status_code == 200, r.text
qc = r.json()["qc"]
assert qc["checks"]["db_heats"]["done"] is True and qc["checks"]["db_heats"]["stamp"] == "Q5"
assert qc["checks"]["db_heats"]["by"] == user_id_of(pg_settings, "qc.quinn")
assert qc["weld_exam"]["1"]["result"] == "accept" and qc["inspector"] == "Quinn QC"
assert qc["row_version"] == rv + 1
r = c.patch(
url, json={"checks": {"db_mtr": True}, "weld_exam": {"1": {"note": "Good tie-in"}}}, headers=_im(rv + 1)
)
assert r.status_code == 200, r.text
qc = r.json()["qc"]
assert qc["checks"]["db_heats"]["done"] and qc["checks"]["db_mtr"]["done"]
assert qc["weld_exam"]["1"]["result"] == "accept"
assert qc["weld_exam"]["1"]["note"] == "Good tie-in"
stale = c.patch(url, json={"notes": "x"}, headers=_im(rv))
assert stale.status_code == 409 and stale.json()["code"] == "row_conflict"
assert c.patch(url, json={"checks": {"nope": True}}, headers=_im(rv + 2)).status_code == 422
assert c.patch(url, json={"weld_exam": {"1": {"result": "maybe"}}}, headers=_im(rv + 2)).status_code == 422
assert c.patch(url, json={"weld_exam": {"8": {"result": "accept"}}}, headers=_im(rv + 2)).status_code == 422
r = c.patch(url, json={"checks": {"db_mtr": False}}, headers=_im(rv + 2))
assert r.json()["qc"]["checks"]["db_mtr"] == {"done": False}
as_user(c, "wh.wade")
assert c.patch(url, json={"notes": "x"}, headers=_im(rv + 3)).status_code == 403
# ---------------------------------------------------------------- change orders
def test_change_orders(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
c = pg_client2
pkt = shop_packet(c, pg_settings, unit[1])
pid = pkt["id"]
finish_stage_by_sql(pg_settings, pid)
as_user(c, "clerk.clara")
body = {"reason": "Rev C moves nozzle N2", "ecn": "ECN-55", "items": [{"old_page": 1, "new_page": 1}]}
r = c.post(f"/api/packets/{pid}/change-orders", json=body)
assert r.status_code == 400 and r.json()["code"] == "signature_required"
r = c.post(f"/api/packets/{pid}/change-orders", json={**body, "sign": SIGN})
assert r.status_code == 200, r.text
co = r.json()["change_orders"][0]
assert co["co_no"] == 1 and co["origin"] == "manual" and co["review"] == "approved"
assert co["required"] == ["03", "WH"] and co["issued_by"]["display_name"] == "Clara Clerk"
assert co["items"][0]["old_page"] == 1 and r.json()["open_cos"] == [1]
bad = c.post(f"/api/packets/{pid}/change-orders", json={**body, "required_depts": ["CL"], "sign": SIGN})
assert bad.status_code == 422 and bad.json()["code"] == "invalid_dept"
listed = c.get(f"/api/packets/{pid}/change-orders")
assert listed.status_code == 200 and [x["co_no"] for x in listed.json()] == [1]
as_user(c, "weld.andy")
r = step(c, get(c, pid), "submit", sign=SIGN)
assert r.status_code == 403 and r.json()["code"] == "co_hold", r.text
assert "Change order CO-1" in r.json()["detail"]
vurl = f"/api/packets/{pid}/change-orders/1/verify"
assert c.post(vurl, json={"dept": "03", "sign": SIGN}).status_code == 403
as_user(c, "sup.wes")
r = c.post(vurl, json={"dept": "03", "note": "Checked N2", "sign": SIGN})
assert r.status_code == 200, r.text
assert r.json()["change_orders"][0]["verified"]["03"]["note"] == "Checked N2"
assert r.json()["open_cos"] == [1]
again = c.post(vurl, json={"dept": "03", "sign": SIGN})
assert again.status_code == 409 and again.json()["code"] == "already_verified"
assert c.post(vurl, json={"dept": "WH", "sign": SIGN}).status_code == 403
as_user(c, "wh.wade")
r = c.post(vurl, json={"dept": "WH", "sign": SIGN})
assert r.status_code == 403 and r.json()["code"] == "forbidden"
as_user(c, "admin.ann")
r = c.post(vurl, json={"dept": "WH", "sign": SIGN})
assert r.status_code == 400 and r.json()["code"] == "it_reason_required"
r = c.post(vurl, json={"dept": "QC", "sign": SIGN, "it_reason": "Warehouse lead is out today"})
assert r.status_code == 422 and r.json()["code"] == "invalid_dept"
r = c.post(vurl, json={"dept": "WH", "sign": SIGN, "it_reason": "Warehouse lead is out today"})
assert r.status_code == 200, r.text
assert r.json()["open_cos"] == []
row = _one(pg_settings, "SELECT it_reason FROM co_verifications WHERE dept_code = 'WH'")
assert row[0] == "Warehouse lead is out today"
as_user(c, "weld.andy")
r = step(c, get(c, pid), "submit", sign=SIGN)
assert r.status_code == 200 and r.json()["status"] == "review", r.text
# A pending change from VISUAL holds nothing until Clerical holds it.
with app_conn(pg_settings) as conn:
conn.execute(
"INSERT INTO change_orders (packet_id, co_no, origin, review, reason) VALUES (%s, 2, 'visual', 'pending',"
" 'Drawing rev changed in VISUAL')",
(pid,),
)
conn.execute("UPDATE packets SET next_co_no = 3 WHERE id = %s", (pid,))
as_user(c, "clerk.clara")
assert get(c, pid)["open_cos"] == []
hurl = f"/api/packets/{pid}/change-orders/2/hold"
r = c.post(hurl, json={"hold": True})
assert r.status_code == 200 and r.json()["open_cos"] == [2], r.text
assert r.json()["change_orders"][1]["hold"] is True
as_user(c, "sup.wes")
r = step(c, get(c, pid), "send_to_qc", book=True, spool=True, location="Bay 3", sign=SIGN)
assert r.status_code == 403 and r.json()["code"] == "co_hold"
as_user(c, "clerk.clara")
r = c.post(hurl, json={"hold": False})
assert r.status_code == 200 and r.json()["open_cos"] == []
assert c.post(f"/api/packets/{pid}/change-orders/1/hold", json={"hold": True}).json()["code"] == "co_state"
rurl = f"/api/packets/{pid}/change-orders/2/reject"
r = c.post(rurl, json={"sign": SIGN})
assert r.status_code == 400 and r.json()["code"] == "reason_required"
r = c.post(rurl, json={"reason": "Not a real change", "sign": SIGN})
assert r.status_code == 200, r.text
co2 = r.json()["change_orders"][1]
assert co2["review"] == "rejected" and co2["rejected_reason"] == "Not a real change"
r = c.post(f"/api/packets/{pid}/change-orders/2/approve", json={"sign": SIGN})
assert r.status_code == 409 and r.json()["code"] == "co_state"
assert c.post(f"/api/packets/{pid}/change-orders/9/approve", json={"sign": SIGN}).status_code == 404
def test_co_approve_pending(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
c = pg_client2
pkt = warehouse_packet(c, unit[1])
pid = pkt["id"]
with app_conn(pg_settings) as conn:
conn.execute(
"INSERT INTO change_orders (packet_id, co_no, origin, review) VALUES (%s, 1, 'visual', 'pending')", (pid,)
)
conn.execute("UPDATE packets SET next_co_no = 2 WHERE id = %s", (pid,))
r = c.post(f"/api/packets/{pid}/change-orders/1/approve", json={"sign": SIGN})
assert r.status_code == 200, r.text
co = r.json()["change_orders"][0]
assert co["review"] == "approved" and co["approved_by"]["display_name"] == "Clara Clerk"
assert co["required"] == ["WH"] and r.json()["open_cos"] == [1]
# ---------------------------------------------------------------- concurrency
def test_concurrent_welds_get_distinct_numbers(
pg_client2: TestClient, pg_settings: Settings, fake_dir: FakeDirectory, unit: tuple[int, int]
) -> None:
pkt = shop_packet(pg_client2, pg_settings, unit[1])
with phase2_client(pg_settings, fake_dir) as other:
as_user(other, "weld.andy")
results: list[Any] = [None, None]
barrier = threading.Barrier(2)
def run(i: int, client: TestClient) -> None:
barrier.wait()
results[i] = client.post(f"/api/packets/{pkt['id']}/welds", json={"items": [1], "type": "Fillet"})
threads = [threading.Thread(target=run, args=(i, cl)) for i, cl in enumerate((pg_client2, other))]
for t in threads:
t.start()
for t in threads:
t.join(30)
assert [r.status_code for r in results] == [200, 200], [r.text for r in results]
nos = [r[0] for r in _all(pg_settings, "SELECT weld_no FROM welds WHERE packet_id = %s", (pkt["id"],))]
assert sorted(nos) == [1, 2]
def test_concurrent_bom_patch_one_wins(
pg_client2: TestClient, pg_settings: Settings, fake_dir: FakeDirectory, unit: tuple[int, int]
) -> None:
pkt = warehouse_packet(pg_client2, unit[1])
as_user(pg_client2, "wh.wade")
with phase2_client(pg_settings, fake_dir) as other:
as_user(other, "wh.wade")
results: list[Any] = [None, None]
barrier = threading.Barrier(2)
def run(i: int, client: TestClient) -> None:
barrier.wait()
results[i] = client.patch(f"/api/packets/{pkt['id']}/bom/1", json={"heat": f"H{i}"}, headers=_im(1))
threads = [threading.Thread(target=run, args=(i, cl)) for i, cl in enumerate((pg_client2, other))]
for t in threads:
t.start()
for t in threads:
t.join(30)
codes = sorted(r.status_code for r in results)
assert codes == [200, 409], [r.text for r in results]
loser = next(r for r in results if r.status_code == 409)
assert loser.json()["code"] == "row_conflict" and loser.json()["current"]["row_version"] == 2
def _all(settings: Settings, sql: str, params: Any = ()) -> list[Any]:
with app_conn(settings) as conn:
return conn.execute(sql, params).fetchall()