Phase 5: Supervisor and Stall screens, weld map, heat table, clock and CO verification
ci / frontend (push) Successful in 34s
ci / server (push) Successful in 7m15s
ci / image (push) Successful in 39s
ci / e2e (push) Successful in 2m4s

The shop at parity with the old build. Supervisor screen (department chips, CO headline, numbers, Change orders
on hold, station grid with clocks and CO tags, boards) and Stall screen (station picker, hero, My packets,
Submitted). Shop steps on the packet page: assign with station, welder picker and stamp; pull back; Start, Pause,
Resume; Submit with a PIN signature and the missing steps as links; review with Send to Q.C., Return to station
and Skip Q.C. route; a lead's read-only review. Weld map (pdf.js viewer with pan, pinch, zoom, full screen; marks
with leader lines; weld sheet; weld record with field CAS; carried marks). Heat map (heat table placed, dragged,
sized, removed; legacy amber tags; borrowed heat map read-only). Checklist tab with templates from settings.
VOID stamps on voided pages. Change orders: CO-n labels, supervisor verify with signature, approval copies the
heat table to the new sheet and the BOM merge keeps the shop's values. Server clock of record with VISUAL ticket
hours beside it. Presence per packet over SSE. Migration 0006 (bfx, bfy, carried_from, checklist_templates).
New endpoints /api/shop/board, /api/shop/stations, /api/shop/stall, /api/departments/{code}/people.

Tests: policy oracle for every shop action, role and status; clock arithmetic; concurrent inserts; CO apply;
frontend geometry, heat table, clock, checklist, weld sheet, screens; seven Playwright scenarios. Docs updated.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-16 22:46:56 -06:00
co-authored by Claude Fable 5.1
parent 39a4eb5213
commit 5a4174e0c2
81 changed files with 11532 additions and 352 deletions
+392
View File
@@ -0,0 +1,392 @@
"""Helpers for the phase 5 shop tests: one signed-in client per user, a packet brought to `ready` in 03 through
the warehouse, and the shop steps (assign, start, finish the stage, submit, review).
The packet: a hand draft in 03 on a fresh unit, two work orders (target 2 h and 2.5 h), three BOM lines, a
two-page PDF drawing, heat map answered, sent to the warehouse, every line pulled with a heat by wh.wade, released.
"""
from __future__ import annotations
import secrets
from collections.abc import Iterator
from dataclasses import dataclass, field
from typing import Any
from fakedir import FAKE_PASSWORD, FakeDirectory
from fastapi.testclient import TestClient
from pkthelpers import phase2_client, seed_users, step, tiny_pdf, upload
from pshelpers import ORIGIN_HEADERS, app_conn, sign_in
from psycopg.types.json import Jsonb
from packetstation.settings import Settings
SIGN = {"factor": "password", "secret": FAKE_PASSWORD}
IT = "Ticket 5501: shop lead is out today"
STALE = "Someone else already took this step."
USERS = (
"admin.ann",
"clerk.clara",
"clerk.leo",
"wh.wade",
"qc.quinn",
"pm.paula",
"weld.andy",
"lead.lou",
"sup.wes",
"multi.mia",
"both.bo",
"dup.dave",
)
WOS = [
{"wo_base": "L670001", "lot": "001", "split": "0", "sub": "0", "qty": 2, "target_hrs": 2},
{"wo_base": "L670002", "lot": "001", "split": "0", "sub": "0", "qty": 1, "target_hrs": 2.5},
]
BOM3 = [
{"item": 1, "qty": 1, "part_id": "PIPE-4", "description": "4in pipe SCH40", "material": "A106-B"},
{"item": 2, "qty": 2, "part_id": "GSKT-4", "description": "4in gasket"},
{"item": 3, "qty": 4, "part_id": "STUD-58", "description": "5/8 stud bolt"},
]
WELD_KEYS = ("dwg", "heat", "wps", "fit", "pre", "vt", "stamp", "clean", "qty")
PLAIN_KEYS = ("dwg", "prev", "mat", "work", "insp", "qty")
MSG_CHECKLIST = "Finish the checklist first."
MSG_WELDS = "Record at least one weld first."
MSG_FILLER = "Enter the filler metal first."
MSG_QC = "Confirm the book and the spool, and give the location."
MSG_OTHER_DEPT = "Choose a different department."
# ---------------------------------------------------------------- clients
@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 = self.fresh(sam)
self.clients[sam] = c
return c
def fresh(self, sam: str) -> TestClient:
"""A new client with its own session (two sessions of one user, or a thread of its own)."""
c = TestClient(self.main.app, headers=ORIGIN_HEADERS)
r = sign_in(c, sam)
assert r.status_code == 200, r.text
return c
def uid(self, sam: str) -> str:
return user_id(self.settings, sam)
def stall(self, dept: str = "03", name: str = "Stall 1") -> int:
return station_id(self.settings, dept, name)
def open_shop(settings: Settings, fake_dir: FakeDirectory) -> Iterator[Shop]:
"""The body of a `shop` fixture: the app, every test user signed in once (their users rows exist)."""
with phase2_client(settings, fake_dir) as client:
seed_users(client, USERS)
yield Shop(client, settings)
# ---------------------------------------------------------------- SQL
def one(settings: Settings, sql: str, params: Any = ()) -> Any:
with app_conn(settings) as conn:
return conn.execute(sql, params).fetchone()
def all_rows(settings: Settings, sql: str, params: Any = ()) -> list[Any]:
with app_conn(settings) as conn:
return list(conn.execute(sql, params).fetchall())
def run(settings: Settings, sql: str, params: Any = ()) -> None:
with app_conn(settings) as conn:
conn.execute(sql, params)
def user_id(settings: Settings, sam: str) -> str:
row = one(settings, "SELECT id FROM users WHERE sam_account = %s", (sam,))
assert row is not None, sam
return str(row[0])
def station_id(settings: Settings, dept: str = "03", name: str = "Stall 1") -> int:
row = one(settings, "SELECT id FROM stations WHERE dept_code = %s AND name = %s", (dept, name))
assert row is not None, (dept, name)
return int(row[0])
def set_stamp(settings: Settings, sam: str, stamp: str | None) -> None:
run(settings, "UPDATE users SET welder_stamp = %s WHERE sam_account = %s", (stamp, sam))
def set_limits(settings: Settings, sam: str, station_ids: list[int]) -> None:
"""Replace a user's station limits (an empty list removes them)."""
with app_conn(settings) as conn:
uid = conn.execute("SELECT id FROM users WHERE sam_account = %s", (sam,)).fetchone()[0]
conn.execute("DELETE FROM user_station_limits WHERE user_id = %s", (uid,))
for sid in station_ids:
conn.execute("INSERT INTO user_station_limits (user_id, station_id) VALUES (%s, %s)", (uid, sid))
def make_unit(settings: Settings, number: str | None = None, lot: str = "001") -> tuple[int, int]:
"""A project with one unit by SQL. Returns (project_id, unit_id)."""
number = number or "L5" + secrets.token_hex(3).upper()
with app_conn(settings) as conn:
pid = conn.execute(
"INSERT INTO projects (number, name, customer, po) VALUES (%s, 'Shop project', 'Acme Refining', 'PO-55')"
" 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 set_running_since(settings: Settings, packet_id: int, seconds_ago: float) -> None:
"""Move the open stage's running clock back in time, as if it had run that long."""
run(
settings,
"UPDATE packet_stages SET clock_running_since = now() - make_interval(secs => %s)"
" WHERE packet_id = %s AND closed_at IS NULL AND clock_running_since IS NOT NULL",
(seconds_ago, packet_id),
)
def set_templates(settings: Settings, value: Any) -> None:
run(
settings,
"INSERT INTO settings (key, value, version, updated_at) VALUES ('checklist_templates', %s, 1, now())"
" ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, version = settings.version + 1",
(Jsonb(value),),
)
# ---------------------------------------------------------------- reads
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 find_log(c: TestClient, pid: int, action: str) -> dict[str, Any]:
"""The newest log row with this action text."""
rows = [r for r in log_rows(c, pid) if r["action"] == action]
assert rows, (action, [r["action"] for r in log_rows(c, pid)])
return rows[0]
def ok(r: Any) -> dict[str, Any]:
assert r.status_code == 200, r.text
out: dict[str, Any] = r.json()
return out
def refused(r: Any, status: int, code: str, detail: str | None = None) -> dict[str, Any]:
assert r.status_code == status, r.text
body: dict[str, Any] = r.json()
assert body["code"] == code, body
if detail is not None:
assert body["detail"] == detail, body
return body
def weld_of(pkt: dict[str, Any], no: int) -> dict[str, Any]:
return next(w for w in pkt["welds"] if w["weld_no"] == no)
def tag_of(pkt: dict[str, Any], no: int) -> dict[str, Any]:
return next(t for t in pkt["heat_tags"] if t["tag_no"] == no)
def line_of(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)}
# ---------------------------------------------------------------- the packet
def draft(
shop: Shop,
unit_id: int | None = None,
*,
title: str = "Spool 7 outlet",
drawing: str = "D-5001",
rev: str = "B",
bom: list[dict[str, Any]] | None = None,
wos: list[dict[str, Any]] | None = None,
dept: str = "03",
pages: int = 2,
) -> dict[str, Any]:
"""A hand draft by clerk.clara with the heat map answered and a PDF drawing of `pages` pages."""
if unit_id is None:
_, unit_id = make_unit(shop.settings)
c = shop("clerk.clara")
body = {
"unit_id": unit_id,
"dept_code": dept,
"op": "WELD-PART",
"title": title,
"customer": "Acme Refining",
"drawing": drawing,
"rev": rev,
"wps": "WPS-11",
"work_orders": WOS if wos is None else wos,
"bom": BOM3 if bom is None else bom,
}
r = c.post("/api/packets", json=body)
assert r.status_code == 201, r.text
pid = r.json()["id"]
assert c.patch(f"/api/packets/{pid}", json={"set": {"heat_map": True}, "base": {}}).status_code == 200
a = upload(c, tiny_pdf(pages))
assert a.status_code == 201, a.text
r = c.post(f"/api/packets/{pid}/pages", json={"asset_id": a.json()["id"], "type": "drawing", "sign": SIGN})
return ok(r)
def to_warehouse(shop: Shop, pkt: dict[str, Any]) -> dict[str, Any]:
out = ok(step(shop("clerk.clara"), pkt, "send_to_warehouse"))
assert out["status"] == "warehouse"
return out
def pull_all(shop: Shop, pkt: dict[str, Any]) -> dict[str, Any]:
"""wh.wade ticks every line pulled and gives it a heat."""
c = shop("wh.wade")
for b in list(pkt["bom"]):
item = b["item"]
cur = line_of(pkt, item)
pkt = ok(c.patch(f"/api/packets/{pkt['id']}/bom/{item}", json={"pulled": True}, headers=im(cur["row_version"])))
cur = line_of(pkt, item)
pkt = ok(
c.patch(
f"/api/packets/{pkt['id']}/bom/{item}", json={"heat": f"HT-{item}0"}, headers=im(cur["row_version"])
)
)
return pkt
def release(shop: Shop, pkt: dict[str, Any]) -> dict[str, Any]:
out = ok(step(shop("wh.wade"), pkt, "wh_release"))
assert out["status"] == "ready" and out["dept"]["code"] == "03", out["status"]
return out
def ready_packet(shop: Shop, unit_id: int | None = None, **kw: Any) -> dict[str, Any]:
"""Draft, warehouse, every line pulled with a heat, released: `ready` in 03 with an open stage."""
pkt = to_warehouse(shop, draft(shop, unit_id, **kw))
return release(shop, pull_all(shop, pkt))
def ready_skipped(shop: Shop, unit_id: int | None = None, dept: str = "03", **kw: Any) -> dict[str, Any]:
"""A draft sent straight to a department (wh_skipped true)."""
pkt = draft(shop, unit_id, **kw)
out = ok(step(shop("clerk.clara"), pkt, "release_to_shop", dept=dept))
assert out["status"] == "ready" and out["wh_skipped"] is True
return out
def to_station(
shop: Shop,
pkt: dict[str, Any],
*,
by: str = "sup.wes",
welder: str = "weld.andy",
dept: str = "03",
station: str = "Stall 1",
**kw: Any,
) -> dict[str, Any]:
"""ASSIGN the packet to a station and a welder."""
out = ok(step(shop(by), pkt, "assign", station_id=shop.stall(dept, station), welder_id=shop.uid(welder), **kw))
assert out["status"] == "assigned"
return out
def start(shop: Shop, pkt: dict[str, Any], by: str = "weld.andy") -> dict[str, Any]:
out = ok(step(shop(by), pkt, "start"))
assert out["status"] == "in_progress"
return out
def add_weld(shop: Shop, pkt: dict[str, Any], by: str = "weld.andy", **over: Any) -> dict[str, Any]:
body: dict[str, Any] = {"page": 1, "fx": 0.5, "fy": 0.5, "items": [1, 2], "type": "Fillet", "process": "GMAW"}
body.update(over)
return ok(shop(by).post(f"/api/packets/{pkt['id']}/welds", json=body))
def tick_all(
shop: Shop, pkt: dict[str, Any], by: str = "weld.andy", keys: tuple[str, ...] = WELD_KEYS
) -> dict[str, Any]:
for k in keys:
pkt = ok(shop(by).put(f"/api/packets/{pkt['id']}/stage/checklist/{k}", json={"done": True}))
return pkt
def set_filler(shop: Shop, pkt: dict[str, Any], by: str = "weld.andy", filler: str = "ER70S-6") -> dict[str, Any]:
return ok(shop(by).patch(f"/api/packets/{pkt['id']}/stage", json={"set": {"filler": filler}, "base": {}}))
def finish_stage(shop: Shop, pkt: dict[str, Any], by: str = "weld.andy", welds: int = 1) -> dict[str, Any]:
"""Every checklist key ticked, `welds` welds marked, the filler set: SUBMIT can pass."""
keys = WELD_KEYS if pkt["dept"]["weld"] else PLAIN_KEYS
pkt = tick_all(shop, pkt, by, keys)
for _ in range(welds):
pkt = add_weld(shop, pkt, by)
return set_filler(shop, pkt, by)
def submit(shop: Shop, pkt: dict[str, Any], by: str = "weld.andy") -> dict[str, Any]:
out = ok(step(shop(by), pkt, "submit", sign=SIGN))
assert out["status"] == "review"
return out
def in_progress(shop: Shop, unit_id: int | None = None, **kw: Any) -> dict[str, Any]:
return start(shop, to_station(shop, ready_packet(shop, unit_id, **kw)))
def in_review(shop: Shop, unit_id: int | None = None, **kw: Any) -> dict[str, Any]:
pkt = in_progress(shop, unit_id, **kw)
return submit(shop, finish_stage(shop, pkt))
def issue_co(shop: Shop, pkt: dict[str, Any], required: list[str], **over: Any) -> dict[str, Any]:
"""A manual change order by clerk.clara (approved at once, holds until every required department verifies)."""
body = {"reason": "Nozzle moved 2 in on rev C", "required_depts": required, "sign": SIGN, **over}
return ok(shop("clerk.clara").post(f"/api/packets/{pkt['id']}/change-orders", json=body))
def verify(shop: Shop, pkt: dict[str, Any], no: int, dept: str, by: str, **over: Any) -> Any:
body = {"dept": dept, "note": "Reviewed with the crew", "sign": SIGN, **over}
return shop(by).post(f"/api/packets/{pkt['id']}/change-orders/{no}/verify", json=body)
+431
View File
@@ -0,0 +1,431 @@
"""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
+4 -3
View File
@@ -31,8 +31,8 @@ def test_fan_out_presence_and_session_targeting() -> None:
assert [n for n, _ in got_a] == ["presence", "presence"]
assert got_a[-1][1] == {
"users": [
{"user_id": a.user_id, "display_name": "Ann", "screen": "admin"},
{"user_id": b.user_id, "display_name": "Bo", "screen": "stall"},
{"user_id": a.user_id, "display_name": "Ann", "screen": "admin", "packet_id": None},
{"user_id": b.user_id, "display_name": "Bo", "screen": "stall", "packet_id": None},
]
}
await _drain(b.queue)
@@ -52,7 +52,8 @@ def test_fan_out_presence_and_session_targeting() -> None:
hub.unsubscribe(b)
got = await _drain(a.queue)
assert got == [("presence", {"users": [{"user_id": a.user_id, "display_name": "Ann", "screen": "admin"}]})]
users = [{"user_id": a.user_id, "display_name": "Ann", "screen": "admin", "packet_id": None}]
assert got == [("presence", {"users": users})]
asyncio.run(run())
+6 -3
View File
@@ -296,7 +296,7 @@ def test_welds(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, i
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": "Butt"})
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]
@@ -355,7 +355,10 @@ def test_heat_tags_and_heat_table(pg_client2: TestClient, pg_settings: Settings,
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 and r1.json()["heat_table"] == [{"page": 1, "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})
@@ -536,7 +539,7 @@ def test_change_orders(pg_client2: TestClient, pg_settings: Settings, unit: tupl
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 1" in r.json()["detail"]
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
+1 -1
View File
@@ -313,7 +313,7 @@ def test_co_hold_blocks_filing(pg_client2: TestClient, pg_settings: Settings, un
assert pkt["open_cos"] == [1] and "file" in pkt["blocked"]
r = step(pg_client2, pkt, "file", sign=SIGN)
assert r.status_code == 403 and r.json()["code"] == "co_hold"
assert r.json()["detail"] == "Change order 1 is waiting for verification by Weld Parts."
assert r.json()["detail"] == "Change order CO-1 must be verified first by: 03 Weld Parts."
as_user(pg_client2, "admin.ann")
r = step(pg_client2, pkt, "file", sign=SIGN, it_reason="Close out the old job now")
assert r.status_code == 403 and r.json()["code"] == "co_hold"
+574
View File
@@ -0,0 +1,574 @@
"""Phase 5 shop policy: every shop step as every test user from every status the helpers reach, with the expected
code (200, 403 forbidden, 403 wrong_status, 403 precondition, 400 it_reason_required, 400 reason_required, 400
signature_required); the assigned-welder and station-limit rules; lead versus supervisor; RETURN_TO_CLERICAL only
when wh_skipped; the change-order hold set; and the read model's `can` and `blocked` agreeing with the writes.
The expectations come from an oracle written from the contract (who, from-status, preconditions), not from the
engine, so the engine and the oracle check each other. Part one runs the pure policy; part two runs the API."""
from __future__ import annotations
import dataclasses
import itertools
from collections.abc import Iterator
from typing import Any
import pytest
from fakedir import FakeDirectory
from pkthelpers import step
from pshelpers import app_conn, needs_pg
from psycopg.types.json import Jsonb
from shophelpers import (
IT,
MSG_CHECKLIST,
SIGN,
USERS,
Shop,
draft,
finish_stage,
fresh,
in_progress,
in_review,
issue_co,
ok,
open_shop,
ready_packet,
ready_skipped,
refused,
run,
set_limits,
start,
submit,
to_station,
to_warehouse,
verify,
)
from packetstation.policy import Action, PacketState, Principal, can, check, principal_from_roles
from packetstation.settings import Settings
A = Action
ANDY = "00000000-0000-0000-0000-00000000a001"
STALL1, STALL2 = 101, 102
SHOP_ACTIONS = (
"assign",
"unassign",
"pull_back",
"start",
"submit",
"return_to_station",
"send_to_qc",
"route_from_review",
"send_back_to_warehouse",
"return_to_clerical",
)
FROM: dict[str, frozenset[str]] = {
"assign": frozenset({"ready"}),
"unassign": frozenset({"assigned"}),
"pull_back": frozenset({"in_progress"}),
"start": frozenset({"assigned"}),
"submit": frozenset({"assigned", "in_progress"}),
"return_to_station": frozenset({"review"}),
"send_to_qc": frozenset({"review"}),
"route_from_review": frozenset({"review"}),
"send_back_to_warehouse": frozenset({"ready"}),
"return_to_clerical": frozenset({"ready"}),
}
WHO = {
"assign": "lead",
"unassign": "lead",
"pull_back": "lead",
"start": "works",
"submit": "works",
"return_to_station": "sup",
"send_to_qc": "sup",
"route_from_review": "sup",
"send_back_to_warehouse": "lead",
"return_to_clerical": "lead",
}
CO_HELD = frozenset({"submit", "send_to_qc", "route_from_review"})
REASON = "Weld 2 needs a repair pass"
# Shop levels in 03 per test user (multi.mia leads 06 only; both.bo is worker and supervisor, so supervisor).
LEVEL_03 = {"weld.andy": 1, "dup.dave": 1, "lead.lou": 2, "sup.wes": 3, "both.bo": 3}
ROLES = {
"admin.ann": ["admin"],
"clerk.clara": ["clerical"],
"clerk.leo": ["clerical_supervisor"],
"wh.wade": ["warehouse"],
"qc.quinn": ["qc"],
"pm.paula": ["pm"],
"weld.andy": ["shop:03:worker"],
"lead.lou": ["shop:03:lead"],
"sup.wes": ["shop:03:supervisor"],
"multi.mia": ["clerical", "shop:06:supervisor"],
"both.bo": ["shop:03:worker", "shop:03:supervisor"],
"dup.dave": ["shop:03:worker"],
}
@dataclasses.dataclass(frozen=True)
class Case:
"""A status the helpers reach, and what matters about it for the shop steps."""
name: str
status: str
skipped: bool = False # wh_skipped
finished: bool = False # checklist, a weld and the filler done
CASES = {
"warehouse": Case("warehouse", "warehouse"),
"ready": Case("ready", "ready"),
"ready_skipped": Case("ready_skipped", "ready", skipped=True),
"assigned": Case("assigned", "assigned"),
"in_progress": Case("in_progress", "in_progress"),
"in_progress_done": Case("in_progress_done", "in_progress", finished=True),
"review": Case("review", "review", finished=True),
"qc": Case("qc", "qc", finished=True),
}
def who_ok(user: str, action: str, *, welder: str | None = "weld.andy", limited_out: bool = False) -> bool:
level = LEVEL_03.get(user, 0)
kind = WHO[action]
if kind == "lead":
return level >= 2
if kind == "sup":
return level >= 3
return level >= 2 or (level == 1 and user == welder and not limited_out)
def expected(
user: str,
case: Case,
action: str,
*,
it_reason: bool = False,
co_hold: bool = False,
welder: str | None = "weld.andy",
limited_out: bool = False,
) -> tuple[int, str]:
"""The answer to the step with every request input given (station, welder, reason, book, spool, location,
dept, signature). The order is the engine's: status, CO hold, admin bypass, who, preconditions."""
if case.status not in FROM[action]:
return 403, "wrong_status"
if action == "return_to_clerical" and not case.skipped:
return 403, "wrong_status"
if co_hold and action in CO_HELD:
return 403, "co_hold"
allowed = who_ok(user, action, welder=welder, limited_out=limited_out)
if not allowed and user == "admin.ann":
if not it_reason:
return 400, "it_reason_required"
allowed = True
if not allowed:
return 403, "forbidden"
if action == "submit" and not case.finished:
return 403, "precondition"
return 200, ""
def in_can(user: str, case: Case, action: str, **kw: Any) -> bool:
"""`can` assumes the admin's IT reason and skips request inputs."""
return expected(user, case, action, it_reason=True, **kw)[0] == 200
def in_blocked(user: str, case: Case, action: str, **kw: Any) -> bool:
"""`blocked`: who and custody pass (or the admin bypass) but a state precondition or the CO hold fails."""
code = expected(user, case, action, it_reason=True, **kw)[1]
if code == "co_hold":
# The engine defers the CO hold in read mode and lists it only once who passes.
return who_ok(user, action, welder=kw.get("welder", "weld.andy")) or user == "admin.ann"
return code == "precondition"
# ================================================================ part one: the pure policy
def P(user: str, limits: tuple[int, ...] = ()) -> Principal:
uid = ANDY if user == "weld.andy" else f"00000000-0000-0000-0000-{abs(hash(user)) % 10**12:012d}"
return principal_from_roles(uid, ROLES[user], station_limits=limits)
def state(case: Case, **kw: Any) -> PacketState:
active = case.status in ("assigned", "in_progress", "review")
base = PacketState(
id=1,
status=case.status,
dept="03",
station_id=STALL1 if active else None,
welder_id=ANDY if active else None,
wh_skipped=case.skipped,
routed_to=None,
vclosed=False,
removed=False,
source_kind="hand",
weld_dept=True,
co_hold=False,
drawing_attached=True,
heat_map=True,
bom_total=3,
bom_ready=3,
welds=1 if case.finished else 0,
checklist_complete=case.finished,
filler_set=case.finished,
)
return dataclasses.replace(base, **kw)
EXTRA = {
"assign": {"station_id": STALL1, "welder_id": ANDY},
"return_to_station": {"reason": REASON},
"send_back_to_warehouse": {"reason": REASON},
"return_to_clerical": {"reason": REASON},
"send_to_qc": {"book": True, "spool": True, "location": "Bay 2"},
"route_from_review": {"dept": "09"},
}
@pytest.mark.parametrize(("user", "case", "action"), list(itertools.product(ROLES, CASES, SHOP_ACTIONS)))
def test_policy_matrix(user: str, case: str, action: str) -> None:
c = CASES[case]
s = state(c)
d = check(P(user), s, A(action), extra=EXTRA.get(action, {}))
want = expected(user, c, action)
code = "" if d.ok else d.code
assert code == want[1], (user, case, action, d)
allowed, blocked = can(P(user), s)
assert (A(action) in allowed) == in_can(user, c, action), (user, case, action, allowed)
assert (A(action) in blocked) == in_blocked(user, c, action), (user, case, action, blocked)
@pytest.mark.parametrize("user", list(ROLES))
@pytest.mark.parametrize("case", ["ready", "assigned", "in_progress_done", "review"])
def test_policy_co_hold_set(user: str, case: str) -> None:
"""The CO hold blocks SUBMIT, SEND_TO_QC and ROUTE_FROM_REVIEW; never ASSIGN, START or the others."""
c = CASES[case]
why = "Change order CO-2 must be verified first by: 03 Weld Parts."
s = state(c, co_hold=True, co_hold_why=why)
allowed, blocked = can(P(user), s)
for action in SHOP_ACTIONS:
d = check(P(user), s, A(action), extra=EXTRA.get(action, {}))
want = expected(user, c, action, co_hold=True)
assert ("" if d.ok else d.code) == want[1], (user, case, action, d)
if want[1] == "co_hold":
assert d.why == why
if in_blocked(user, c, action, co_hold=True):
assert blocked[A(action)][0] == why
assert (A(action) in allowed) == in_can(user, c, action, co_hold=True), (user, case, action)
@pytest.mark.parametrize("action", ["start", "submit"])
def test_policy_assigned_welder_rules(action: str) -> None:
c = CASES["in_progress_done"] if action == "submit" else CASES["assigned"]
s = state(c)
assert check(P("weld.andy"), s, A(action)).ok
# another worker in the department is not the assigned welder
assert check(P("dup.dave"), s, A(action)).code == "forbidden"
# the assigned welder limited to another station
assert check(P("weld.andy", (STALL2,)), s, A(action)).code == "forbidden"
assert check(P("weld.andy", (STALL1, STALL2)), s, A(action)).ok
# no welder recorded: a worker cannot, a lead can
nobody = dataclasses.replace(s, welder_id=None)
assert check(P("weld.andy"), nobody, A(action)).code == "forbidden"
assert check(P("lead.lou"), nobody, A(action)).ok
# lead and above pass without being the welder
for user in ("lead.lou", "sup.wes", "both.bo"):
assert check(P(user), s, A(action)).ok, user
def test_policy_lead_versus_supervisor() -> None:
s = state(CASES["review"])
for action in ("send_to_qc", "route_from_review", "return_to_station"):
d = check(P("lead.lou"), s, A(action), extra=EXTRA[action])
assert d.code == "forbidden", action
assert check(P("sup.wes"), s, A(action), extra=EXTRA[action]).ok, action
allowed, blocked = can(P("lead.lou"), s)
assert not {A.SEND_TO_QC, A.ROUTE_FROM_REVIEW, A.RETURN_TO_STATION} & set(allowed)
assert not {A.SEND_TO_QC, A.ROUTE_FROM_REVIEW, A.RETURN_TO_STATION} & set(blocked)
def test_policy_return_to_clerical_only_when_skipped() -> None:
body = EXTRA["return_to_clerical"]
d = check(P("lead.lou"), state(CASES["ready"]), A.RETURN_TO_CLERICAL, extra=body)
assert d.code == "wrong_status"
assert d.why == "This packet came through the warehouse. Send it back to the warehouse instead."
assert check(P("lead.lou"), state(CASES["ready_skipped"]), A.RETURN_TO_CLERICAL, extra=body).ok
d = check(P("lead.lou"), state(CASES["ready_skipped"]), A.RETURN_TO_CLERICAL, extra={})
assert d.code == "reason_required"
# ================================================================ part two: the API
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def body_for(shop: Shop, action: str) -> dict[str, Any]:
"""Every request input the step can need."""
return {
"assign": {"station_id": shop.stall(), "welder_id": shop.uid("weld.andy")},
"submit": {"sign": SIGN},
"return_to_station": {"reason": REASON},
"send_to_qc": {"book": True, "spool": True, "location": "Bay 2", "sign": SIGN},
"route_from_review": {"dept": "09", "sign": SIGN},
"send_back_to_warehouse": {"reason": REASON},
"return_to_clerical": {"reason": REASON},
}.get(action, {})
def build(shop: Shop, case: str) -> dict[str, Any]:
"""A packet in the case's status, made through the API."""
if case == "warehouse":
return to_warehouse(shop, draft(shop))
if case == "ready":
return ready_packet(shop)
if case == "ready_skipped":
return ready_skipped(shop)
if case == "assigned":
return to_station(shop, ready_packet(shop))
if case == "in_progress":
return in_progress(shop)
if case == "in_progress_done":
return finish_stage(shop, in_progress(shop))
if case == "review":
return in_review(shop)
if case == "qc":
pkt = in_review(shop)
return ok(step(shop("sup.wes"), pkt, "send_to_qc", book=True, spool=True, location="Bay 2", sign=SIGN))
raise AssertionError(case)
@needs_pg
@pytest.mark.parametrize("case", list(CASES))
def test_api_matrix(shop: Shop, case: str) -> None:
"""Every refused step answers the oracle's code and changes nothing; `can` and `blocked` agree for every user.
Steps the oracle allows are not sent here (they would move the packet); test_api_success sends each one."""
c = CASES[case]
pkt = build(shop, case)
assert pkt["status"] == c.status and pkt["wh_skipped"] == c.skipped
for user in USERS:
mine = fresh(shop(user), pkt["id"])
for action in SHOP_ACTIONS:
want = expected(user, c, action)
assert (action in mine["can"]) == in_can(user, c, action), (user, case, action, mine["can"])
assert (action in mine["blocked"]) == in_blocked(user, c, action), (user, case, action, mine["blocked"])
if want[0] == 200:
continue
r = step(shop(user), pkt, action, **body_for(shop, action))
assert (r.status_code, r.json().get("code")) == want, (user, case, action, r.text)
if want[1] == "precondition" and action == "submit":
assert r.json()["detail"] == MSG_CHECKLIST
assert mine["blocked"]["submit"][0] == MSG_CHECKLIST
after = fresh(shop("admin.ann"), pkt["id"])
assert (after["status"], after["version"]) == (pkt["status"], pkt["version"])
SUCCESS = [
(case, action, user)
for case, action in (
("ready", "assign"),
("ready", "send_back_to_warehouse"),
("ready_skipped", "return_to_clerical"),
("assigned", "unassign"),
("assigned", "start"),
("assigned", "submit"),
("in_progress", "pull_back"),
("in_progress_done", "submit"),
("review", "return_to_station"),
("review", "send_to_qc"),
("review", "route_from_review"),
)
for user in USERS
if expected(user, CASES[case], action, it_reason=True)[0] == 200 and not (case == "assigned" and action == "submit")
]
TO_STATUS = {
"assign": "assigned",
"send_back_to_warehouse": "warehouse",
"return_to_clerical": "draft",
"unassign": "ready",
"start": "in_progress",
"submit": "review",
"pull_back": "ready",
"return_to_station": "assigned",
"send_to_qc": "qc",
"route_from_review": "ready",
}
@needs_pg
@pytest.mark.parametrize(("case", "action", "user"), SUCCESS)
def test_api_success(shop: Shop, case: str, action: str, user: str) -> None:
"""Each allowed user does each step; the admin only with an IT reason."""
pkt = build(shop, case)
assert action in fresh(shop(user), pkt["id"])["can"]
body = body_for(shop, action)
if user == "admin.ann":
refused(step(shop(user), pkt, action, **body), 400, "it_reason_required")
body["it_reason"] = IT
out = ok(step(shop(user), pkt, action, **body))
assert out["status"] == TO_STATUS[action]
if action == "route_from_review":
assert out["dept"]["code"] == "09"
@needs_pg
@pytest.mark.parametrize(
("case", "action"),
[
("ready", "send_back_to_warehouse"),
("ready_skipped", "return_to_clerical"),
("review", "return_to_station"),
],
)
def test_api_reason_required(shop: Shop, case: str, action: str) -> None:
pkt = build(shop, case)
for reason in (None, "", "short"):
body = {} if reason is None else {"reason": reason}
refused(step(shop("sup.wes"), pkt, action, **body), 400, "reason_required")
assert fresh(shop("sup.wes"), pkt["id"])["status"] == pkt["status"]
@needs_pg
@pytest.mark.parametrize(
("case", "action", "user"),
[
("in_progress_done", "submit", "weld.andy"),
("review", "send_to_qc", "sup.wes"),
("review", "route_from_review", "sup.wes"),
],
)
def test_api_signature_required(shop: Shop, case: str, action: str, user: str) -> None:
pkt = build(shop, case)
body = body_for(shop, action)
body.pop("sign")
r = refused(step(shop(user), pkt, action, **body), 400, "signature_required")
assert r.get("meaning")
assert fresh(shop(user), pkt["id"])["status"] == pkt["status"]
@needs_pg
def test_api_assigned_welder_and_station_limits(shop: Shop) -> None:
pkt = to_station(shop, ready_packet(shop))
# dup.dave works in 03 but is not the welder
refused(step(shop("dup.dave"), pkt, "start"), 403, "forbidden")
assert "start" not in fresh(shop("dup.dave"), pkt["id"])["can"]
# weld.andy limited to Stall 2 while the packet is at Stall 1
set_limits(shop.settings, "weld.andy", [shop.stall("03", "Stall 2")])
refused(step(shop("weld.andy"), pkt, "start"), 403, "forbidden")
mine = fresh(shop("weld.andy"), pkt["id"])
assert "start" not in mine["can"] and "start" not in mine["blocked"]
# the lead starts it (lead and above pass)
pkt = start(shop, pkt, by="lead.lou")
refused(step(shop("weld.andy"), pkt, "submit", sign=SIGN), 403, "forbidden")
# limits that include Stall 1: weld.andy works on it again
set_limits(shop.settings, "weld.andy", [shop.stall("03", "Stall 1"), shop.stall("03", "Stall 2")])
pkt = finish_stage(shop, pkt)
pkt = submit(shop, pkt, by="weld.andy")
# the lead may submit too: a second packet
other = finish_stage(shop, start(shop, to_station(shop, ready_packet(shop)), by="weld.andy"), by="lead.lou")
submit(shop, other, by="lead.lou")
@needs_pg
def test_api_lead_review_read_only(shop: Shop) -> None:
pkt = in_review(shop)
lou = fresh(shop("lead.lou"), pkt["id"])
for action in ("send_to_qc", "route_from_review", "return_to_station"):
assert action not in lou["can"] and action not in lou["blocked"], action
refused(step(shop("lead.lou"), pkt, action, **body_for(shop, action)), 403, "forbidden")
wes = fresh(shop("sup.wes"), pkt["id"])
assert {"send_to_qc", "route_from_review", "return_to_station"} <= set(wes["can"])
@needs_pg
def test_api_return_to_clerical_only_when_skipped(shop: Shop) -> None:
pkt = ready_packet(shop)
r = refused(step(shop("lead.lou"), pkt, "return_to_clerical", reason=REASON), 403, "wrong_status")
assert r["detail"] == "This packet came through the warehouse. Send it back to the warehouse instead."
skipped = ready_skipped(shop)
out = ok(step(shop("lead.lou"), skipped, "return_to_clerical", reason=REASON))
assert out["status"] == "draft" and out["return_note"] == REASON
def held_text(no: int, depts: str = "03 Weld Parts") -> str:
return f"Change order CO-{no} must be verified first by: {depts}."
@needs_pg
def test_api_co_hold_set(shop: Shop) -> None:
"""A CO requiring 03 holds SUBMIT, SEND_TO_QC and ROUTE_FROM_REVIEW, never ASSIGN or START."""
pkt = ready_packet(shop)
pkt = issue_co(shop, pkt, ["03"])
assert pkt["open_cos"] == [1]
wes = fresh(shop("sup.wes"), pkt["id"])
assert "assign" in wes["can"] and "assign" not in wes["blocked"]
pkt = to_station(shop, pkt)
andy = fresh(shop("weld.andy"), pkt["id"])
assert "start" in andy["can"]
pkt = finish_stage(shop, start(shop, pkt))
andy = fresh(shop("weld.andy"), pkt["id"])
assert "submit" not in andy["can"] and andy["blocked"]["submit"] == [held_text(1)]
refused(step(shop("weld.andy"), pkt, "submit", sign=SIGN), 403, "co_hold", held_text(1))
refused(step(shop("admin.ann"), pkt, "submit", sign=SIGN, it_reason=IT), 403, "co_hold", held_text(1))
ok(verify(shop, pkt, 1, "03", "sup.wes"))
pkt = submit(shop, fresh(shop("weld.andy"), pkt["id"]))
pkt = issue_co(shop, pkt, ["03"])
wes = fresh(shop("sup.wes"), pkt["id"])
for action in ("send_to_qc", "route_from_review"):
assert action not in wes["can"] and wes["blocked"][action] == [held_text(2)], action
refused(step(shop("sup.wes"), pkt, action, **body_for(shop, action)), 403, "co_hold", held_text(2))
assert "return_to_station" in wes["can"]
# a pending VISUAL change order with hold: both sentences, joined by a space
with_pending = _pending_feed_co(shop, pkt["id"], hold=True)
pending = (
f"CO-{with_pending}: Clerical is reviewing a change from VISUAL. Held until Clerical approves or rejects it."
)
wes = fresh(shop("sup.wes"), pkt["id"])
assert wes["blocked"]["send_to_qc"] == [f"{held_text(2)} {pending}"] or wes["blocked"]["send_to_qc"] == [
f"{pending} {held_text(2)}"
]
ok(verify(shop, pkt, 2, "03", "sup.wes"))
wes = fresh(shop("sup.wes"), pkt["id"])
assert wes["blocked"]["send_to_qc"] == [pending]
run(
shop.settings,
"UPDATE change_orders SET hold = false WHERE packet_id = %s AND co_no = %s",
(pkt["id"], with_pending),
)
wes = fresh(shop("sup.wes"), pkt["id"])
assert "send_to_qc" in wes["can"] and "send_to_qc" not in wes["blocked"]
ok(step(shop("sup.wes"), wes, "send_to_qc", book=True, spool=True, location="Bay 2", sign=SIGN))
def _pending_feed_co(shop: Shop, pid: int, *, hold: bool) -> int:
"""A pending VISUAL change order by SQL, as the feed opens one. Returns its number."""
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]
conn.execute(
"INSERT INTO change_orders (packet_id, co_no, origin, review, minor, hold, held_at, ecn, reason, vis)"
" VALUES (%s, %s, 'visual', 'pending', false, %s, CASE WHEN %s THEN now() END, 'VISUAL',"
" 'VISUAL changed the packet: qty 2 -> 3', %s)",
(pid, no, hold, hold, Jsonb({"dwg_change": False, "changes": ["qty 2 -> 3"]})),
)
return int(no)
@needs_pg
def test_api_stale_expect_after_pull_back(shop: Shop) -> None:
pkt = finish_stage(shop, in_progress(shop))
ok(step(shop("sup.wes"), pkt, "pull_back"))
r = refused(
step(shop("weld.andy"), pkt, "submit", sign=SIGN), 409, "stale_step", "Someone else already took this step."
)
assert r["current"]["status"] == "ready"
+30
View File
@@ -497,3 +497,33 @@ def test_notification_events(pg_settings: Settings) -> None:
)
conn.execute("INSERT INTO notification_events (kind, subject) VALUES ('t_kind', 'assets')")
assert one(conn, "SELECT count(*) FROM notification_events WHERE kind = 't_kind'") == 2
def test_shop_columns_and_checklist_templates(pg_settings: Settings) -> None:
"""0006: label positions on welds and heat tags, carried_from on heat table placements, the checklist row."""
with db.connect_as("migrate", pg_settings, autocommit=True) as conn:
names = [r[0] for r in conn.execute("SELECT name FROM schema_migrations ORDER BY name").fetchall()]
assert "0006_shop.sql" in names
with app_conn(pg_settings) as conn:
for t, c, kind in (
("welds", "bfx", "double precision"),
("welds", "bfy", "double precision"),
("heat_tags", "bfx", "double precision"),
("heat_tags", "bfy", "double precision"),
("heat_table_placements", "carried_from", "integer"),
):
got = one(
conn,
"SELECT data_type FROM information_schema.columns WHERE table_name = %s AND column_name = %s",
(t, c),
)
assert got == kind, (t, c)
ids = seed(conn)
with pytest.raises(psycopg.errors.CheckViolation), conn.transaction():
conn.execute(
"INSERT INTO welds (packet_id, weld_no, items, bfx) VALUES (%s, 1, '{1}', 1.5)", (ids["packet"],)
)
value = one(conn, "SELECT value FROM settings WHERE key = 'checklist_templates'")
assert [i["key"] for i in value["weld"]] == ["dwg", "heat", "wps", "fit", "pre", "vt", "stamp", "clean", "qty"]
assert [i["key"] for i in value["plain"]] == ["dwg", "prev", "mat", "work", "insp", "qty"]
assert value["weld"][0]["text"] == "Drawing {drawing} Rev {rev} matches the packet"
+948
View File
@@ -0,0 +1,948 @@
"""Phase 5 shop API: the assign flow and its validations, the shop transitions with their exact log texts, the
SUBMIT preconditions one by one, routing with carried marks, the checklist and its templates, stage field CAS,
welds, heat tags and the heat table with their texts and validation, heat map borrowing, VISUAL labor hours, and
the shop board, stations, stall and people endpoints."""
from __future__ import annotations
import time
from collections.abc import Iterator
from typing import Any
import pytest
from fakedir import FakeDirectory
from fastapi.testclient import TestClient
from pkthelpers import step
from pshelpers import ORIGIN_HEADERS, app_conn, needs_pg
from shophelpers import (
IT,
MSG_CHECKLIST,
MSG_FILLER,
MSG_OTHER_DEPT,
MSG_QC,
MSG_WELDS,
PLAIN_KEYS,
SIGN,
WELD_KEYS,
Shop,
add_weld,
all_rows,
draft,
find_log,
finish_stage,
fresh,
im,
in_progress,
in_review,
issue_co,
last_log,
log_rows,
make_unit,
ok,
one,
open_shop,
pull_all,
ready_packet,
ready_skipped,
refused,
release,
run,
set_filler,
set_limits,
set_running_since,
set_stamp,
set_templates,
start,
submit,
tag_of,
tick_all,
to_station,
to_warehouse,
weld_of,
)
from packetstation.settings import Settings
pytestmark = needs_pg
MSG_WELDER = "Pick a welder in this department."
MSG_VOID = "This sheet is VOID. Mark the current sheet."
MSG_TYPE = "Pick a weld type from the list."
MSG_PROCESS = "Pick a process from the list."
MSG_NOT_LEAD = "You do not lead a department."
SHOP_DEPTS = ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "13", "14"]
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def page_label(pkt: dict[str, Any], n: int) -> str:
page = next(p for p in pkt["pages"] if p["n"] == n)
return page["dwg"] or f"sheet {n}"
# ================================================================ assign
def test_assign_validations(shop: Shop) -> None:
s = shop.settings
set_stamp(s, "weld.andy", "75")
pkt = ready_packet(shop)
wes = shop("sup.wes")
andy = shop.uid("weld.andy")
# a station of another department
r = step(wes, pkt, "assign", station_id=shop.stall("06", "Stall 1"), welder_id=andy)
refused(r, 422, "invalid_station")
# welders outside the department: Q.C., a supervisor of 06, Clerical
for sam in ("qc.quinn", "multi.mia", "clerk.clara"):
r = step(wes, pkt, "assign", station_id=shop.stall(), welder_id=shop.uid(sam))
refused(r, 422, "invalid_welder", MSG_WELDER)
# no welder or no station
refused(step(wes, pkt, "assign", station_id=shop.stall()), 403, "precondition", "Pick a station and a welder.")
# a worker limited to Stall 2
set_limits(s, "weld.andy", [shop.stall("03", "Stall 2")])
r = step(wes, pkt, "assign", station_id=shop.stall(), welder_id=andy)
refused(r, 422, "station_limit", "Andy Welder is limited to Stall 2.")
set_limits(s, "weld.andy", [shop.stall("03", "Stall 2"), shop.stall("03", "Stall 3")])
r = step(wes, pkt, "assign", station_id=shop.stall(), welder_id=andy)
refused(r, 422, "station_limit", "Andy Welder is limited to Stall 2, Stall 3.")
# a bad stamp override
set_limits(s, "weld.andy", [])
r = step(wes, pkt, "assign", station_id=shop.stall(), welder_id=andy, stamp="no good!")
refused(r, 422, "invalid_stamp")
assert fresh(wes, pkt["id"])["status"] == "ready"
# the stamp defaults from users.welder_stamp; the stage gets the same three values
out = to_station(shop, pkt)
assert out["station"]["name"] == "Stall 1" and out["welder"]["id"] == andy and out["welder"]["stamp"] == "75"
st = out["stage"]
assert st["station"]["id"] == shop.stall() and st["welder"]["id"] == andy and st["welder"]["stamp"] == "75"
log = last_log(wes, pkt["id"])
assert (log["action"], log["note"]) == ("Sent to Stall 1", "Andy Welder [75]")
assert log["by"]["display_name"] == "Wes Super"
# unassign clears them; a stamp override wins over the user's stamp
out = ok(step(wes, out, "unassign"))
assert out["status"] == "ready" and out["station"] is None and out["welder"] is None
assert out["stage"]["station"] is None and out["stage"]["welder"] is None
assert last_log(wes, pkt["id"])["action"] == "Taken off Stall 1"
out = to_station(shop, out, station="Stall 4", stamp="A7")
assert out["welder"]["stamp"] == "A7" and out["stage"]["welder"]["stamp"] == "A7"
assert (last_log(wes, pkt["id"])["action"], last_log(wes, pkt["id"])["note"]) == (
"Sent to Stall 4",
"Andy Welder [A7]",
)
assert one(s, "SELECT welder_stamp FROM users WHERE sam_account = 'weld.andy'")[0] == "75"
# station limits bind workers only: lead.lou limited to Stall 2 may still be put on Stall 1
out = ok(step(wes, out, "unassign"))
set_limits(s, "lead.lou", [shop.stall("03", "Stall 2")])
out = to_station(shop, out, welder="lead.lou")
assert out["welder"]["display_name"] == "Lou Lead"
log = last_log(wes, pkt["id"])
assert (log["action"], log["note"]) == ("Sent to Stall 1", "Lou Lead")
# the lead assigns too
out = ok(step(shop("lead.lou"), out, "unassign"))
to_station(shop, out, by="lead.lou")
def test_people(shop: Shop) -> None:
s = shop.settings
set_stamp(s, "weld.andy", "75")
set_limits(s, "weld.andy", [shop.stall("03", "Stall 2")])
r = shop("weld.andy").get("/api/departments/03/people")
people = ok(r)
names = [p["display_name"] for p in people]
assert names == sorted(names)
by = {p["display_name"]: p for p in people}
assert {"Andy Welder", "Lou Lead", "Wes Super", "Bo Both", "Dave Dup"} <= set(by)
assert not {"Mia Multi", "Quinn QC", "Clara Clerk", "Ann Admin"} & set(by)
assert by["Andy Welder"] == {
"id": shop.uid("weld.andy"),
"display_name": "Andy Welder",
"stamp": "75",
"level": "worker",
"station_limits": [shop.stall("03", "Stall 2")],
}
assert by["Lou Lead"]["level"] == "lead" and by["Wes Super"]["level"] == "supervisor"
assert by["Bo Both"]["level"] == "supervisor" and by["Lou Lead"]["station_limits"] == []
six = ok(shop("sup.wes").get("/api/departments/06/people"))
assert [p["display_name"] for p in six] == ["Mia Multi"]
for code in ("ZZ", "WH", "CL", "QC"):
assert shop("sup.wes").get(f"/api/departments/{code}/people").status_code == 404, code
# a session is needed
anon = TestClient(shop.main.app, headers=ORIGIN_HEADERS)
assert anon.get("/api/departments/03/people").status_code == 401
# ================================================================ the shop transitions and their texts
def test_unassign_and_pull_back_freeze_the_clock(shop: Shop) -> None:
wes = shop("sup.wes")
pkt = to_station(shop, ready_packet(shop))
pkt = start(shop, pkt)
log = last_log(wes, pkt["id"])
assert (log["action"], log["note"]) == ("Work started", "Stall 1")
assert pkt["stage"]["clock"]["running"] is True and pkt["stage"]["clock"]["running_since"]
assert pkt["stage"]["clock"]["started_at"]
set_running_since(shop.settings, pkt["id"], 90)
out = ok(step(wes, pkt, "pull_back"))
assert out["status"] == "ready" and out["station"] is None and out["welder"] is None
assert last_log(wes, pkt["id"])["action"] == "Pulled back from Stall 1"
clock = out["stage"]["clock"]
assert clock["running"] is False and clock["running_since"] is None
assert 90_000 <= clock["stored_ms"] < 120_000 and clock["elapsed_ms"] == clock["stored_ms"]
frozen = clock["stored_ms"]
time.sleep(1.1)
again = fresh(wes, pkt["id"])["stage"]["clock"]
assert again["elapsed_ms"] == frozen and again["running"] is False
# assigned again and unassigned before a start: the clock stays frozen at the same value
out = to_station(shop, out)
out = ok(step(wes, out, "unassign"))
assert out["stage"]["clock"]["elapsed_ms"] == frozen
assert last_log(wes, pkt["id"])["action"] == "Taken off Stall 1"
def test_submit_preconditions_one_by_one(shop: Shop) -> None:
andy = shop("weld.andy")
pkt = in_progress(shop)
mine = fresh(andy, pkt["id"])
assert mine["blocked"]["submit"] == [MSG_CHECKLIST, MSG_WELDS, MSG_FILLER]
refused(step(andy, pkt, "submit", sign=SIGN), 403, "precondition", MSG_CHECKLIST)
pkt = tick_all(shop, pkt)
assert fresh(andy, pkt["id"])["blocked"]["submit"] == [MSG_WELDS, MSG_FILLER]
refused(step(andy, pkt, "submit", sign=SIGN), 403, "precondition", MSG_WELDS)
pkt = add_weld(shop, pkt)
pkt = add_weld(shop, pkt, fx=0.6, fy=0.4)
refused(step(andy, pkt, "submit", sign=SIGN), 403, "precondition", MSG_FILLER)
pkt = set_filler(shop, pkt)
mine = fresh(andy, pkt["id"])
assert "submit" in mine["can"] and "submit" not in mine["blocked"]
r = refused(step(andy, pkt, "submit"), 400, "signature_required")
assert r["meaning"] == "Submit work to the supervisor"
set_running_since(shop.settings, pkt["id"], 54 * 60)
out = ok(step(andy, pkt, "submit", sign=SIGN))
assert out["status"] == "review" and out["return_note"] is None
assert out["stage"]["clock"]["running"] is False and out["station"]["name"] == "Stall 1"
log = last_log(andy, pkt["id"])
assert (log["action"], log["note"], log["signed"]) == ("Submitted to supervisor", "2 welds · 0.9 h", True)
sig = one(
shop.settings,
"SELECT signature_id FROM packet_transitions WHERE packet_id = %s AND action = 'submit'",
(pkt["id"],),
)
assert sig[0] is not None
def test_return_to_station(shop: Shop) -> None:
wes = shop("sup.wes")
pkt = in_review(shop)
refused(step(wes, pkt, "return_to_station"), 400, "reason_required")
reason = "Weld 2 undercut, grind and reweld"
out = ok(step(wes, pkt, "return_to_station", reason=reason))
assert out["status"] == "assigned" and out["return_note"] == reason
assert out["station"]["name"] == "Stall 1" and out["welder"]["id"] == shop.uid("weld.andy")
log = last_log(wes, pkt["id"])
assert (log["action"], log["note"]) == ("Returned to Stall 1", reason)
# weld.andy starts again and submits: the return note clears
out = start(shop, out)
out = submit(shop, out)
assert out["return_note"] is None
def test_send_to_qc(shop: Shop) -> None:
wes = shop("sup.wes")
pkt = in_review(shop)
for body in (
{"book": True, "spool": True},
{"book": True, "spool": False, "location": "Bay 2"},
{"book": False, "spool": True, "location": "Bay 2"},
{"book": True, "spool": True, "location": " "},
):
refused(step(wes, pkt, "send_to_qc", sign=SIGN, **body), 403, "precondition", MSG_QC)
refused(
step(shop("lead.lou"), pkt, "send_to_qc", book=True, spool=True, location="Bay 2", sign=SIGN), 403, "forbidden"
)
out = ok(step(wes, pkt, "send_to_qc", book=True, spool=True, location="Bay 2", sign=SIGN))
assert out["status"] == "qc" and out["qc"]["location"] == "Bay 2"
assert out["qc"]["book"] is True and out["qc"]["spool"] is True and out["qc"]["sent_at"]
log = last_log(wes, pkt["id"])
assert (log["action"], log["note"], log["signed"]) == ("Sent to Q.C.", "Spool at Bay 2", True)
def test_send_to_qc_note(shop: Shop) -> None:
wes = shop("sup.wes")
pkt = in_review(shop)
ok(step(wes, pkt, "send_to_qc", book=True, spool=True, location="Bay 2", note="Top rack", sign=SIGN))
assert last_log(wes, pkt["id"])["note"] == "Spool at Bay 2 · Top rack"
def test_route_from_review_to_hydro(shop: Shop) -> None:
wes = shop("sup.wes")
pkt = in_progress(shop)
pkt = ok(
shop("weld.andy").post(f"/api/packets/{pkt['id']}/heat-tags", json={"page": 1, "item": 3, "fx": 0.3, "fy": 0.3})
)
pkt = submit(shop, finish_stage(shop, pkt, welds=2))
first_stage = pkt["stage"]["id"]
assert [w["carried"] for w in pkt["welds"]] == [False, False]
assert [t["carried"] for t in pkt["heat_tags"]] == [False]
refused(step(wes, pkt, "route_from_review", dept="03", sign=SIGN), 403, "precondition", MSG_OTHER_DEPT)
refused(step(wes, pkt, "route_from_review", dept="09"), 400, "signature_required")
out = ok(step(wes, pkt, "route_from_review", dept="09", sign=SIGN))
assert out["status"] == "ready" and out["dept"]["code"] == "09"
assert out["station"] is None and out["welder"] is None and out["return_note"] is None
assert out["stage"]["dept_code"] == "09" and out["stage"]["id"] != first_stage
assert out["stage"]["station"] is None and out["stage"]["welder"] is None
closed = [x for x in out["stages"] if x["id"] == first_stage]
assert len(closed) == 1 and closed[0]["closed_at"] and closed[0]["dept_code"] == "03"
assert closed[0]["clock"]["running"] is False
log = last_log(wes, pkt["id"])
assert (log["action"], log["note"], log["signed"]) == (
"Routed to 09 Hydro",
"Supervisor, Q.C. skipped · from 03 Weld Parts",
True,
)
# the marks stay on the packet and read as carried
assert len(out["welds"]) == 2 and all(w["carried"] is True for w in out["welds"])
assert all(w["dept_code"] == "03" and w["stage_id"] == first_stage for w in out["welds"])
assert len(out["heat_tags"]) == 1 and out["heat_tags"][0]["carried"] is True
# 09 is not a weld department: the plain checklist
assert [i["key"] for i in out["checklist_items"]] == list(PLAIN_KEYS)
assert out["stage"]["checklist"] == {}
def test_route_to_a_weld_department_needs_its_own_weld(shop: Shop) -> None:
"""welds_recorded counts the current department's welds only."""
pkt = in_review(shop)
out = ok(step(shop("sup.wes"), pkt, "route_from_review", dept="06", sign=SIGN))
assert out["dept"]["code"] == "06" and all(w["carried"] for w in out["welds"])
mia = shop("multi.mia")
out = to_station(shop, out, by="multi.mia", welder="multi.mia", dept="06", station="Stall 2")
out = start(shop, out, by="multi.mia")
out = tick_all(shop, out, by="multi.mia")
out = set_filler(shop, out, by="multi.mia")
mine = fresh(mia, out["id"])
assert mine["blocked"]["submit"] == [MSG_WELDS]
refused(step(mia, out, "submit", sign=SIGN), 403, "precondition", MSG_WELDS)
out = add_weld(shop, out, by="multi.mia")
w = max(out["welds"], key=lambda x: x["weld_no"])
assert w["dept_code"] == "06" and w["carried"] is False and w["weld_no"] == 2
out = submit(shop, out, by="multi.mia")
assert last_log(mia, out["id"])["note"].startswith("1 weld")
# ================================================================ checklist and templates
def test_checklist_per_key(shop: Shop) -> None:
s = shop.settings
set_stamp(s, "weld.andy", "75")
andy = shop("weld.andy")
pkt = in_progress(shop, drawing="D-5001", rev="B")
items = {i["key"]: i for i in pkt["checklist_items"]}
assert list(items) == list(WELD_KEYS)
assert items["dwg"]["text"] == "Drawing D-5001 Rev B matches the packet"
assert items["dwg"]["group"] == "Before welding" and items["vt"]["group"] == "After welding"
out = ok(andy.put(f"/api/packets/{pkt['id']}/stage/checklist/dwg", json={"done": True}))
entry = out["stage"]["checklist"]["dwg"]
assert entry["done"] is True and entry["at"] and entry["by"] == shop.uid("weld.andy")
assert entry["stamp"] == "75" and entry["by_name"] == "Andy Welder"
assert last_log(andy, pkt["id"])["action"] == "Checked: Drawing D-5001 Rev B matches the packet"
stored = one(s, "SELECT checklist FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (pkt["id"],))[0]
assert stored["dwg"]["done"] is True and stored["dwg"]["stamp"] == "75"
# the same tick again writes nothing
before = len(log_rows(andy, pkt["id"]))
ok(andy.put(f"/api/packets/{pkt['id']}/stage/checklist/dwg", json={"done": True}))
assert len(log_rows(andy, pkt["id"])) == before
out = ok(andy.put(f"/api/packets/{pkt['id']}/stage/checklist/dwg", json={"done": False}))
assert out["stage"]["checklist"]["dwg"]["done"] is False
assert last_log(andy, pkt["id"])["action"] == "Unchecked: Drawing D-5001 Rev B matches the packet"
refused(andy.put(f"/api/packets/{pkt['id']}/stage/checklist/zzz", json={"done": True}), 422, "invalid_key")
refused(andy.put(f"/api/packets/{pkt['id']}/stage/checklist/prev", json={"done": True}), 422, "invalid_key")
# the stamp falls back to the packet's stamp when the user has none
set_stamp(s, "lead.lou", None)
out = ok(shop("lead.lou").put(f"/api/packets/{pkt['id']}/stage/checklist/fit", json={"done": True}))
assert out["stage"]["checklist"]["fit"]["stamp"] == "75"
assert out["stage"]["checklist"]["fit"]["by_name"] == "Lou Lead"
# someone who does not hold it
refused(shop("qc.quinn").put(f"/api/packets/{pkt['id']}/stage/checklist/vt", json={"done": True}), 403, "forbidden")
def test_checklist_plain_department(shop: Shop) -> None:
pkt = ready_skipped(shop, dept="09")
assert [i["key"] for i in pkt["checklist_items"]] == list(PLAIN_KEYS)
groups = {i["key"]: i["group"] for i in pkt["checklist_items"]}
assert groups["prev"] == "Before starting" and groups["work"] == "When done"
ann = shop("admin.ann")
url = f"/api/packets/{pkt['id']}/stage/checklist"
refused(ann.put(f"{url}/prev", json={"done": True}), 400, "it_reason_required")
out = ok(ann.put(f"{url}/prev", json={"done": True, "it_reason": IT}))
assert out["stage"]["checklist"]["prev"]["done"] is True
refused(ann.put(f"{url}/heat", json={"done": True, "it_reason": IT}), 422, "invalid_key")
def test_checklist_templates_from_settings(shop: Shop) -> None:
s = shop.settings
row = one(s, "SELECT value FROM settings WHERE key = 'checklist_templates'")
assert row is not None, "migration 0006 seeds checklist_templates"
defaults = row[0]
assert [i["key"] for i in defaults["weld"]] == list(WELD_KEYS)
assert [i["key"] for i in defaults["plain"]] == list(PLAIN_KEYS)
custom = {
"weld": [
{"group": "Before welding", "key": "dwg", "text": "Sheet {drawing} at Rev {rev} checked"},
{"group": "Before welding", "key": "wps", "text": "Procedure {wps} read"},
{"group": "After welding", "key": "vt", "text": "Visual done"},
],
"plain": defaults["plain"],
}
set_templates(s, custom)
andy = shop("weld.andy")
pkt = in_progress(shop, drawing="D-6006", rev="C")
pkt = fresh(andy, pkt["id"])
assert [(i["key"], i["text"]) for i in pkt["checklist_items"]] == [
("dwg", "Sheet D-6006 at Rev C checked"),
("wps", "Procedure WPS-11 read"),
("vt", "Visual done"),
]
refused(andy.put(f"/api/packets/{pkt['id']}/stage/checklist/fit", json={"done": True}), 422, "invalid_key")
out = ok(andy.put(f"/api/packets/{pkt['id']}/stage/checklist/wps", json={"done": True}))
assert last_log(andy, pkt["id"])["action"] == "Checked: Procedure WPS-11 read"
# three keys complete the custom checklist
out = tick_all(shop, out, keys=("dwg", "vt"))
out = set_filler(shop, add_weld(shop, out))
assert "submit" in fresh(andy, out["id"])["can"]
# a malformed row falls back to the defaults in code
set_templates(s, {"weld": "nonsense"})
pkt = fresh(andy, pkt["id"])
assert [i["key"] for i in pkt["checklist_items"]] == list(WELD_KEYS)
run(s, "DELETE FROM settings WHERE key = 'checklist_templates'")
assert [i["key"] for i in fresh(andy, pkt["id"])["checklist_items"]] == list(WELD_KEYS)
# ================================================================ stage fields
def test_stage_fields_field_cas(shop: Shop) -> None:
andy = shop("weld.andy")
pkt = in_progress(shop)
url = f"/api/packets/{pkt['id']}/stage"
out = ok(andy.patch(url, json={"set": {"filler": "ER70S-6", "filler_lot": "1234"}, "base": {"filler": None}}))
assert out["stage"]["filler"] == "ER70S-6" and out["stage"]["filler_lot"] == "1234"
log = last_log(andy, pkt["id"])
assert (log["action"], log["note"]) == ("Weld record changed", "filler ER70S-6 · filler lot 1234")
# another session saw filler empty and writes over it: a conflict on that field only
r = refused(
shop("sup.wes").patch(url, json={"set": {"filler": "E7018"}, "base": {"filler": None}}), 409, "field_conflict"
)
assert r["current"] == {"filler": "ER70S-6"}
# a base that matches, and a key without a base, are written
out = ok(
shop("sup.wes").patch(url, json={"set": {"filler": "E7018", "preheat": "150F"}, "base": {"filler": "ER70S-6"}})
)
assert out["stage"]["filler"] == "E7018" and out["stage"]["preheat"] == "150F"
assert last_log(andy, pkt["id"])["note"] == "filler E7018 · preheat 150F"
# the flat body with If-Match still works
rv = out["stage"]["row_version"]
out = ok(andy.patch(url, json={"notes": "Root pass by Andy"}, headers=im(rv)))
assert out["stage"]["notes"] == "Root pass by Andy"
refused(andy.patch(url, json={"notes": "stale"}, headers=im(rv)), 409, "row_conflict")
# limits: 40 for the short fields, 2000 for notes
r = andy.patch(url, json={"set": {"filler_lot": "X" * 60, "notes": "n" * 2500}, "base": {}})
if r.status_code == 200:
assert len(r.json()["stage"]["filler_lot"]) == 40 and len(r.json()["stage"]["notes"]) == 2000
else:
assert r.status_code == 422, r.text
refused(shop("qc.quinn").patch(url, json={"set": {"filler": "x"}, "base": {}}), 403, "forbidden")
# ================================================================ welds
def test_welds(shop: Shop) -> None:
s = shop.settings
set_stamp(s, "weld.andy", "75")
andy = shop("weld.andy")
pkt = in_progress(shop)
url = f"/api/packets/{pkt['id']}/welds"
body = {
"page": 1,
"fx": 0.5,
"fy": 0.5,
"bfx": 0.55,
"bfy": 0.43,
"items": [2, 1],
"wo_base": "L670001",
"piece": 1,
"type": "Fillet",
"process": "GMAW",
}
out = ok(andy.post(url, json=body))
w1 = weld_of(out, 1)
assert (w1["size"], w1["type"], w1["process"], w1["items"]) == ('5/16"', "Fillet", "GMAW", [1, 2])
assert (w1["bfx"], w1["bfy"], w1["fx"], w1["fy"]) == (0.55, 0.43, 0.5, 0.5)
assert w1["dept_code"] == "03" and w1["stage_id"] == out["stage"]["id"] and w1["carried"] is False
assert w1["by"]["display_name"] == "Andy Welder" and w1["welder"]["id"] == shop.uid("weld.andy")
assert w1["stamp"] == "75"
log = last_log(andy, pkt["id"])
assert (log["action"], log["note"]) == ("Weld W1 marked", 'Fillet 5/16" · items 1 + 2 · L670001 #1')
# the client never picks the number
out = ok(andy.post(url, json={**body, "weld_no": 9, "size": '1/4"', "bfx": None, "bfy": None}))
w2 = weld_of(out, 2)
assert w2["size"] == '1/4"' and w2["bfx"] is None and w2["bfy"] is None
assert sorted(w["weld_no"] for w in out["welds"]) == [1, 2]
# validation
refused(andy.post(url, json={**body, "type": "Butt"}), 422, "invalid_weld", MSG_TYPE)
refused(andy.post(url, json={**body, "process": "TIG"}), 422, "invalid_weld", MSG_PROCESS)
refused(andy.post(url, json={**body, "wo_base": "L999999"}), 422, "invalid_wo")
for bad in ({"fx": 1.5}, {"fy": -0.1}, {"bfx": 1.2}, {"bfy": -0.5}, {"piece": 0}):
r = andy.post(url, json={**body, **bad})
assert r.status_code == 422, (bad, r.text)
refused(andy.post(url, json={**body, "items": [7]}), 422, "invalid_items")
for t in ("Fillet", "Groove", "Fill", "Seal", "Tack"):
assert andy.post(url, json={**body, "type": t, "page": 2}).status_code == 200, t
for p in ("GMAW", "FCAW", "SMAW", "GTAW", "SAW"):
assert andy.post(url, json={**body, "process": p, "page": 2}).status_code == 200, p
out = fresh(andy, pkt["id"])
n = max(w["weld_no"] for w in out["welds"])
assert n == 12 and len(out["welds"]) == 12
# a position-only patch logs "moved"; a field change logs the changed fields
rv = weld_of(out, 1)["row_version"]
out = ok(andy.patch(f"{url}/1", json={"fx": 0.6, "fy": 0.62, "bfx": 0.7, "bfy": 0.5}, headers=im(rv)))
assert (weld_of(out, 1)["fx"], weld_of(out, 1)["bfy"]) == (0.6, 0.5)
log = last_log(andy, pkt["id"])
assert (log["action"], log["note"]) == ("Weld W1 moved", None)
rv = weld_of(out, 1)["row_version"]
out = ok(andy.patch(f"{url}/1", json={"type": "Groove", "items": [1, 3]}, headers=im(rv)))
log = last_log(andy, pkt["id"])
assert (log["action"], log["note"]) == ("Weld W1 changed", "type Groove · items 1 + 3")
refused(
andy.patch(f"{url}/1", json={"type": "Butt"}, headers=im(weld_of(out, 1)["row_version"])), 422, "invalid_weld"
)
refused(andy.patch(f"{url}/1", json={"fx": 0.1}, headers=im(rv)), 409, "row_conflict")
# removed with a reason
out = ok(andy.request("DELETE", f"{url}/2", json={"reason": "Logged twice"}))
assert 2 not in [w["weld_no"] for w in out["welds"]]
log = last_log(andy, pkt["id"])
assert (log["action"], log["note"]) == ("Weld W2 removed", "Logged twice")
# numbers are never reused
out = ok(andy.post(url, json=body))
assert max(w["weld_no"] for w in out["welds"]) == 13
# a voided sheet refuses new marks and moving old ones
run(s, "UPDATE packet_pages SET void_at = now() WHERE packet_id = %s AND n = 2", (pkt["id"],))
refused(andy.post(url, json={**body, "page": 2}), 422, "void_page", MSG_VOID)
on_two = next(w for w in fresh(andy, pkt["id"])["welds"] if w["page"] == 2)
r = andy.patch(f"{url}/{on_two['weld_no']}", json={"fx": 0.2}, headers=im(on_two["row_version"]))
refused(r, 422, "void_page", MSG_VOID)
# moving a page-1 weld onto the void sheet is refused too
w1 = weld_of(fresh(andy, pkt["id"]), 1)
refused(andy.patch(f"{url}/1", json={"page": 2}, headers=im(w1["row_version"])), 422, "void_page", MSG_VOID)
# who: Q.C. and the warehouse may not
for sam in ("qc.quinn", "wh.wade", "clerk.clara"):
refused(shop(sam).post(url, json=body), 403, "forbidden")
# ================================================================ heat tags and the heat table
def test_heat_tags(shop: Shop) -> None:
andy = shop("weld.andy")
pkt = in_progress(shop)
url = f"/api/packets/{pkt['id']}/heat-tags"
out = ok(andy.post(url, json={"page": 1, "item": 3, "fx": 0.3, "fy": 0.3, "bfx": 0.35, "bfy": 0.24}))
t = tag_of(out, 1)
assert (t["item"], t["bfx"], t["bfy"], t["carried"]) == (3, 0.35, 0.24, False)
log = last_log(andy, pkt["id"])
assert (log["action"], log["note"]) == ("Heat tag H1 placed", "item 3")
out = ok(andy.patch(f"{url}/1", json={"fx": 0.4, "bfx": 0.45}, headers=im(t["row_version"])))
assert (last_log(andy, pkt["id"])["action"], last_log(andy, pkt["id"])["note"]) == ("Heat tag H1 moved", "item 3")
out = ok(andy.patch(f"{url}/1", json={"item": 2}, headers=im(tag_of(out, 1)["row_version"])))
log = last_log(andy, pkt["id"])
assert (log["action"], log["note"]) == ("Heat tag H1 changed", "item 2")
for bad in ({"bfx": 1.01}, {"bfy": -0.01}, {"fx": 2}):
assert andy.post(url, json={"page": 1, "item": 1, "fx": 0.2, "fy": 0.2, **bad}).status_code == 422, bad
out = ok(andy.post(url, json={"page": 1, "item": 1, "fx": 0.2, "fy": 0.2}))
assert tag_of(out, 2)["bfx"] is None
out = ok(andy.request("DELETE", f"{url}/1", json={"reason": "Wrong item"}))
log = last_log(andy, pkt["id"])
assert log["action"] == "Heat tag H1 removed" and log["note"] in ("Wrong item", "item 2 · Wrong item")
run(shop.settings, "UPDATE packet_pages SET void_at = now() WHERE packet_id = %s AND n = 2", (pkt["id"],))
refused(andy.post(url, json={"page": 2, "item": 1, "fx": 0.2, "fy": 0.2}), 422, "void_page", MSG_VOID)
def test_heat_tag_by_the_warehouse_reads_carried(shop: Shop) -> None:
pkt = draft(shop)
pkt = to_warehouse(shop, pkt)
pkt = ok(
shop("wh.wade").post(f"/api/packets/{pkt['id']}/heat-tags", json={"page": 1, "item": 1, "fx": 0.2, "fy": 0.2})
)
pkt = release(shop, pull_all(shop, pkt))
assert tag_of(pkt, 1)["carried"] is True
def test_heat_table(shop: Shop) -> None:
andy = shop("weld.andy")
pkt = in_progress(shop)
url = f"/api/packets/{pkt['id']}/heat-table"
sheet1 = page_label(pkt, 1)
out = ok(andy.put(f"{url}/1", json={"x": 0.1, "y": 0.1, "s": 1.3}))
ht = out["heat_table"][0]
assert (ht["page"], ht["x"], ht["y"], ht["s"], ht["carried_from"]) == (1, 0.1, 0.1, 1.3, None)
assert ht["by"]["display_name"] == "Andy Welder" and ht["at"]
assert (last_log(andy, pkt["id"])["action"], last_log(andy, pkt["id"])["note"]) == (
"Heat table placed on drawing",
sheet1,
)
ok(andy.put(f"{url}/1", json={"x": 0.4, "y": 0.2, "s": 1.3}))
assert (last_log(andy, pkt["id"])["action"], last_log(andy, pkt["id"])["note"]) == ("Heat table moved", sheet1)
ok(andy.put(f"{url}/1", json={"x": 0.4, "y": 0.2, "s": 1.45}))
assert (last_log(andy, pkt["id"])["action"], last_log(andy, pkt["id"])["note"]) == ("Heat table resized", "A+ 1.45")
ok(andy.put(f"{url}/1", json={"x": 0.4, "y": 0.2, "s": 1.3}))
assert (last_log(andy, pkt["id"])["action"], last_log(andy, pkt["id"])["note"]) == ("Heat table resized", "A- 1.3")
# the same placement again writes nothing
before = len(log_rows(andy, pkt["id"]))
ok(andy.put(f"{url}/1", json={"x": 0.4, "y": 0.2, "s": 1.3}))
assert len(log_rows(andy, pkt["id"])) == before
# validation
for bad in (
{"x": 0.98, "y": 0.1, "s": 1},
{"x": -0.01, "y": 0.1, "s": 1},
{"x": 0.1, "y": 0.971, "s": 1},
{"x": 0.1, "y": 0.1, "s": 0.59},
{"x": 0.1, "y": 0.1, "s": 2.11},
):
refused(andy.put(f"{url}/1", json=bad), 422, "invalid_heat_table")
ok(andy.put(f"{url}/1", json={"x": 0.97, "y": 0.97, "s": 2.1}))
ok(andy.put(f"{url}/1", json={"x": 0, "y": 0, "s": 0.6}))
refused(andy.put(f"{url}/99", json={"x": 0.1, "y": 0.1, "s": 1}), 422, "invalid_page")
# carried_from is accepted and read back
out = ok(andy.put(f"{url}/2", json={"x": 0.1, "y": 0.1, "s": 1, "carried_from": 1}))
assert next(h for h in out["heat_table"] if h["page"] == 2)["carried_from"] == 1
# removed
ok(andy.request("DELETE", f"{url}/2"))
assert (last_log(andy, pkt["id"])["action"], last_log(andy, pkt["id"])["note"]) == (
"Heat table removed from drawing",
page_label(pkt, 2),
)
refused(andy.request("DELETE", f"{url}/2"), 404, "not_found")
# Q.C. reads it but may not place it
q = fresh(shop("qc.quinn"), pkt["id"])
assert [h["page"] for h in q["heat_table"]] == [1]
assert "edit_heat_table" not in q["can"]
refused(shop("qc.quinn").put(f"{url}/1", json={"x": 0.1, "y": 0.1, "s": 1}), 403, "forbidden")
assert "edit_heat_table" in fresh(andy, pkt["id"])["can"]
# ================================================================ heat map borrowing
def test_heat_src_borrowing(shop: Shop) -> None:
s = shop.settings
_, unit = make_unit(s)
_, other_unit = make_unit(s)
sub_only = [{"item": 1, "qty": 1, "part_id": "SUB-1", "description": "Nozzle sub-assembly"}]
# the sibling with a heat map, rev B, made first
old_b = draft(shop, unit, title="Sibling rev B", drawing="D-77 01", rev="B")
run(s, "INSERT INTO heat_table_placements (packet_id, page, x, y, s) VALUES (%s, 1, 0.1, 0.1, 1)", (old_b["id"],))
# a newer sibling with the same drawing, rev C, with a heat tag only
new_c = draft(shop, unit, title="Sibling rev C", drawing="d-7701", rev="C")
run(
s,
"INSERT INTO heat_tags (packet_id, tag_no, page, item, fx, fy) VALUES (%s, 1, 1, 1, 0.2, 0.2)",
(new_c["id"],),
)
# a sibling with no heat map is never a source
draft(shop, unit, title="Sibling no map", drawing="D-7701", rev="C")
# only sub-assembly lines, rev B: borrows rev B (same rev)
b = draft(shop, unit, title="Borrower B", drawing="D-7701", rev="B", bom=sub_only)
run(s, "UPDATE bom_lines SET sub = 'Y' WHERE packet_id = %s", (b["id"],))
src = fresh(shop("sup.wes"), b["id"])["heat_src"]
assert src is not None and src["packet_id"] == old_b["id"]
assert (src["drawing"], src["rev"], src["status"]) == ("D-77 01", "B", "draft")
assert [h["page"] for h in src["heat_table"]] == [1]
assert {"n", "type", "asset_id", "pn", "dwg", "rev", "void", "url"} <= set(src["pages"][0])
assert src["bom"] and {"item", "heat", "na"} <= set(src["bom"][0])
# rev C: the same rev wins over the older sibling
c = draft(shop, unit, title="Borrower C", drawing="D-7701", rev="C", bom=sub_only)
run(s, "UPDATE bom_lines SET sub = 'Y' WHERE packet_id = %s", (c["id"],))
assert fresh(shop("sup.wes"), c["id"])["heat_src"]["packet_id"] == new_c["id"]
# rev D: no same rev, the oldest sibling
d = draft(shop, unit, title="Borrower D", drawing="D-7701", rev="D", bom=[])
assert fresh(shop("sup.wes"), d["id"])["heat_src"]["packet_id"] == old_b["id"]
# every line N/A borrows too
na = draft(shop, unit, title="Borrower NA", drawing="D-7701", rev="B")
run(s, "UPDATE bom_lines SET na = true WHERE packet_id = %s", (na["id"],))
assert fresh(shop("sup.wes"), na["id"])["heat_src"]["packet_id"] == old_b["id"]
# its own raw material: no borrowing
own = draft(shop, unit, title="Own material", drawing="D-7701", rev="B")
assert fresh(shop("sup.wes"), own["id"])["heat_src"] is None
# its own heat map: no borrowing
mapped = draft(shop, unit, title="Own map", drawing="D-7701", rev="B", bom=[])
run(s, "INSERT INTO heat_table_placements (packet_id, page, x, y, s) VALUES (%s, 1, 0.5, 0.5, 1)", (mapped["id"],))
assert fresh(shop("sup.wes"), mapped["id"])["heat_src"] is None
# another unit or another drawing: no borrowing
far = draft(shop, other_unit, title="Other unit", drawing="D-7701", rev="B", bom=[])
assert fresh(shop("sup.wes"), far["id"])["heat_src"] is None
diff = draft(shop, unit, title="Other drawing", drawing="D-7702", rev="B", bom=[])
assert fresh(shop("sup.wes"), diff["id"])["heat_src"] is None
# a removed sibling is not a source
run(s, "UPDATE packets SET removed_at = now(), removed_reason = 'test' WHERE id = %s", (old_b["id"],))
assert fresh(shop("sup.wes"), d["id"])["heat_src"]["packet_id"] == new_c["id"]
# read-only: nothing on the source is written through the borrower
pk = ready_skipped(shop, unit, drawing="D-7701", rev="C", bom=[])
src = fresh(shop("sup.wes"), pk["id"])["heat_src"]
assert src["packet_id"] == new_c["id"]
before = all_rows(s, "SELECT tag_no, fx FROM heat_tags WHERE packet_id = %s", (new_c["id"],))
shop("sup.wes").post(f"/api/packets/{pk['id']}/heat-tags", json={"page": 1, "item": 1, "fx": 0.9, "fy": 0.9})
assert all_rows(s, "SELECT tag_no, fx FROM heat_tags WHERE packet_id = %s", (new_c["id"],)) == before
# ================================================================ labor from VISUAL
def test_labor_from_visual_rows(shop: Shop) -> None:
s = shop.settings
pkt = ready_packet(shop)
assert pkt["labor"] is None # a hand packet
assert float(pkt["target_hrs"]) == pytest.approx(4.5)
with app_conn(s) as conn:
conn.execute("UPDATE packets SET source_kind = 'visual' WHERE id = %s", (pkt["id"],))
conn.execute(
"UPDATE packet_work_orders SET seq = 10 WHERE packet_id = %s AND wo_base = 'L670001'", (pkt["id"],)
)
conn.execute(
"UPDATE packet_work_orders SET seq = 20 WHERE packet_id = %s AND wo_base = 'L670002'", (pkt["id"],)
)
assert fresh(shop("sup.wes"), pkt["id"])["labor"] is None # no rows match yet
with app_conn(s) as conn:
for base, seq, hours, tickets, last in (
("L670001", 10, "1.5", 2, "2026-10-10"),
("L670002", 20, "1.7", 1, "2026-10-12"),
("L670002", 30, "9.0", 4, "2026-10-12"), # another operation: not this packet's
("L670009", 10, "5.0", 1, "2026-10-12"),
):
conn.execute(
"INSERT INTO visual_labor (wo_base, lot, split, sub, seq, hours, tickets, last_ticket)"
" VALUES (%s, '001', '0', '0', %s, %s, %s, %s)",
(base, seq, hours, tickets, last),
)
labor = fresh(shop("sup.wes"), pkt["id"])["labor"]
assert labor is not None
assert float(labor["hours"]) == pytest.approx(3.2) and int(labor["tickets"]) == 3 and labor["observed_at"]
per = {w["wo_base"]: w for w in labor["per_wo"]}
assert set(per) == {"L670001", "L670002"}
assert float(per["L670001"]["hours"]) == pytest.approx(1.5) and per["L670001"]["tickets"] == 2
assert per["L670002"]["seq"] == 20 and str(per["L670002"]["last_ticket"]).startswith("2026-10-12")
assert (per["L670001"]["lot"], per["L670001"]["split"], per["L670001"]["sub"]) == ("001", "0", "0")
# ================================================================ the board, stations, stall
def _board(shop: Shop, sam: str, **params: Any) -> Any:
return shop(sam).get("/api/shop/board", params=params)
def _ids(cards: list[dict[str, Any]]) -> set[int]:
return {c["id"] for c in cards}
def test_shop_board(shop: Shop) -> None:
s = shop.settings
set_stamp(s, "weld.andy", "75")
at_stall = in_progress(shop, title="At the stall")
set_running_since(s, at_stall["id"], 600)
waiting = ready_packet(shop, title="Waiting to assign")
review = in_review(shop, title="Waiting on review")
six = ready_skipped(shop, dept="06", title="In code bldg 2")
at_stall = issue_co(shop, at_stall, ["03"])
b = ok(_board(shop, "sup.wes"))
assert [d["code"] for d in b["depts"]] == ["03"]
d03 = b["depts"][0]
assert (d03["name"], d03["weld"], d03["level"]) == ("Weld Parts", True, "supervisor")
assert [st["name"] for st in d03["stations"]] == [f"Stall {n}" for n in range(1, 7)]
stall1 = d03["stations"][0]
assert [p["id"] for p in stall1["packets"]] == [at_stall["id"]]
tile = stall1["packets"][0]
assert tile["welder"]["display_name"] == "Andy Welder" and tile["welder_stamp"] == "75"
assert tile["station"]["name"] == "Stall 1" and tile["status"] == "in_progress"
assert tile["clock"]["running"] is True and tile["clock"]["elapsed_ms"] >= 600_000
assert float(tile["target_hrs"]) == pytest.approx(4.5)
assert [{k: c[k] for k in ("label", "review")} for c in tile["open_cos"]] == [
{"label": "CO-1", "review": "approved"}
]
assert all(st["packets"] == [] for st in d03["stations"][1:])
k = b["kpis"]
assert (k["ready"], k["at_stations"], k["review"]) == (1, 1, 1)
assert k["elapsed_ms"] >= 600_000 and float(k["target_hrs"]) == pytest.approx(4.5)
cols = b["columns"]
assert set(cols) == {"ready", "review", "qc", "warehouse", "complete"}
assert _ids(cols["ready"]) == {waiting["id"]} and _ids(cols["review"]) == {review["id"]}
assert cols["qc"] == [] and cols["complete"] == [] and cols["warehouse"] == []
every = set().union(*(_ids(c) for c in cols.values()))
assert six["id"] not in every
assert len(b["cos"]) == 1
co = b["cos"][0]
assert (co["packet_id"], co["co_no"], co["label"], co["review"], co["mine"]) == (
at_stall["id"],
1,
"CO-1",
"approved",
True,
)
assert co["pending_depts"] == ["03"] and co["dept_code"] == "03" and co["op"] == "WELD-PART"
assert co["title"] == "At the stall" and co["reason"] and co["issued_at"]
# dept narrows within the scope; outside it is refused
assert [d["code"] for d in ok(_board(shop, "sup.wes", dept="03"))["depts"]] == ["03"]
assert [d["code"] for d in ok(_board(shop, "sup.wes", dept="all"))["depts"]] == ["03"]
refused(_board(shop, "sup.wes", dept="06"), 403, "forbidden")
# project and unit narrow everything
other = ok(_board(shop, "sup.wes", unit_id=waiting["unit"]["id"]))
assert _ids(other["columns"]["ready"]) == {waiting["id"]} and other["columns"]["review"] == []
assert other["depts"][0]["stations"][0]["packets"] == [] and other["cos"] == []
assert other["kpis"]["at_stations"] == 0
proj = ok(_board(shop, "sup.wes", project_id=review["unit"]["project"]["id"]))
assert _ids(proj["columns"]["review"]) == {review["id"]} and proj["columns"]["ready"] == []
# the lead sees the same scope at lead level
lou = ok(_board(shop, "lead.lou"))
assert [(d["code"], d["level"]) for d in lou["depts"]] == [("03", "lead")]
assert lou["cos"][0]["mine"] is False
# multi.mia leads 06 only
mia = ok(_board(shop, "multi.mia"))
assert [(d["code"], d["level"]) for d in mia["depts"]] == [("06", "supervisor")]
assert _ids(mia["columns"]["ready"]) == {six["id"]} and mia["cos"] == []
every = set().union(*(_ids(c) for c in mia["columns"].values()))
assert not {at_stall["id"], waiting["id"], review["id"]} & every
refused(_board(shop, "multi.mia", dept="03"), 403, "forbidden")
# a worker leads nothing; nor do support roles
for sam in ("weld.andy", "dup.dave", "qc.quinn", "wh.wade", "clerk.clara", "pm.paula"):
refused(_board(shop, sam), 403, "forbidden", MSG_NOT_LEAD)
# admin: every active shop department, at admin level
ann = ok(_board(shop, "admin.ann"))
assert [d["code"] for d in ann["depts"]] == SHOP_DEPTS
assert {d["level"] for d in ann["depts"]} == {"admin"}
assert _ids(ann["columns"]["ready"]) == {waiting["id"], six["id"]}
assert ok(_board(shop, "admin.ann", dept="09"))["depts"][0]["code"] == "09"
def test_shop_board_cos_follow_my_departments(shop: Shop) -> None:
"""A CO pending 03 shows on the 03 board when the packet has moved on to Q.C."""
pkt = in_review(shop)
pkt = ok(step(shop("sup.wes"), pkt, "send_to_qc", book=True, spool=True, location="Bay 2", sign=SIGN))
pkt = issue_co(shop, pkt, ["03", "QC"])
b = ok(_board(shop, "sup.wes"))
assert _ids(b["columns"]["qc"]) == {pkt["id"]}
assert [(c["packet_id"], c["mine"], c["pending_depts"]) for c in b["cos"]] == [(pkt["id"], True, ["03", "QC"])]
mia = ok(_board(shop, "multi.mia"))
assert mia["cos"] == []
# a pending feed CO with hold shows as "review" pending, hold true
run(shop.settings, "UPDATE packets SET next_co_no = next_co_no + 1 WHERE id = %s", (pkt["id"],))
run(
shop.settings,
"INSERT INTO change_orders (packet_id, co_no, origin, review, hold, ecn, reason, vis)"
" VALUES (%s, 2, 'visual', 'pending', true, 'VISUAL', 'VISUAL changed the packet: qty', '{}')",
(pkt["id"],),
)
b = ok(_board(shop, "sup.wes"))
held = [c for c in b["cos"] if c["co_no"] == 2]
assert held and held[0]["review"] == "pending" and held[0]["hold"] is True
def test_shop_stations(shop: Shop) -> None:
s = shop.settings
def names(sam: str) -> list[tuple[str, str]]:
return [(x["dept_code"], x["name"]) for x in ok(shop(sam).get("/api/shop/stations"))]
stalls = [("03", f"Stall {n}") for n in range(1, 7)]
assert names("weld.andy") == stalls
first = ok(shop("weld.andy").get("/api/shop/stations"))[0]
assert first["dept_name"] == "Weld Parts" and first["id"] == shop.stall()
set_limits(s, "weld.andy", [shop.stall("03", "Stall 2"), shop.stall("03", "Stall 5")])
assert names("weld.andy") == [("03", "Stall 2"), ("03", "Stall 5")]
# limits bind workers only
set_limits(s, "sup.wes", [shop.stall("03", "Stall 2")])
assert names("sup.wes") == stalls
assert names("lead.lou") == stalls
assert names("multi.mia") == [("06", f"Stall {n}") for n in range(1, 7)]
for sam in ("qc.quinn", "wh.wade", "clerk.clara", "pm.paula"):
assert names(sam) == [], sam
total = one(s, "SELECT count(*) FROM stations WHERE active")[0]
assert len(names("admin.ann")) == total
def test_shop_stall(shop: Shop) -> None:
s = shop.settings
mine = in_progress(shop, title="On the stall")
waiting = to_station(shop, ready_packet(shop, title="Queued at the stall"))
sent = in_review(shop, title="Sent to review")
elsewhere = to_station(shop, ready_packet(shop, title="Stall 3 job"), station="Stall 3")
stall1 = shop.stall()
out = ok(shop("weld.andy").get("/api/shop/stall", params={"station_id": stall1}))
assert out["station"] == {"id": stall1, "name": "Stall 1", "dept_code": "03", "dept_name": "Weld Parts"}
assert _ids(out["mine"]) == {mine["id"], waiting["id"]}
assert _ids(out["submitted"]) == {sent["id"]}
assert elsewhere["id"] not in _ids(out["mine"]) | _ids(out["submitted"])
card = next(c for c in out["mine"] if c["id"] == mine["id"])
assert card["status"] == "in_progress" and card["clock"]["running"] is True
# a station outside the user's list
set_limits(s, "weld.andy", [shop.stall("03", "Stall 2")])
refused(shop("weld.andy").get("/api/shop/stall", params={"station_id": stall1}), 403, "forbidden")
refused(shop("multi.mia").get("/api/shop/stall", params={"station_id": stall1}), 403, "forbidden")
refused(shop("qc.quinn").get("/api/shop/stall", params={"station_id": stall1}), 403, "forbidden")
ok(shop("admin.ann").get("/api/shop/stall", params={"station_id": stall1}))
ok(shop("sup.wes").get("/api/shop/stall", params={"station_id": stall1}))
# after routing on, the packet leaves the stall
ok(step(shop("sup.wes"), sent, "route_from_review", dept="09", sign=SIGN))
out = ok(shop("sup.wes").get("/api/shop/stall", params={"station_id": stall1}))
assert sent["id"] not in _ids(out["submitted"])
def test_server_now_and_target_hours(shop: Shop) -> None:
pkt = ready_packet(shop)
assert isinstance(pkt["server_now"], str) and "T" in pkt["server_now"]
assert float(pkt["target_hrs"]) == pytest.approx(4.5)
empty = ready_skipped(shop, wos=[])
assert float(empty["target_hrs"]) == 0
def test_change_order_shape(shop: Shop) -> None:
pkt = issue_co(shop, ready_packet(shop), ["03"])
co = pkt["change_orders"][0]
assert co["label"] == "CO-1" and co["pending_depts"] == ["03"] and co["open"] is True
assert last_log(shop("sup.wes"), pkt["id"])["action"] == "Change order CO-1 issued"
out = ok(
shop("sup.wes").post(
f"/api/packets/{pkt['id']}/change-orders/1/verify", json={"dept": "03", "note": "OK", "sign": SIGN}
)
)
co = out["change_orders"][0]
assert co["pending_depts"] == [] and co["open"] is False
v = co["verified"]["03"]
assert v["signed"] is True and v["by"]["display_name"] == "Wes Super" and v["note"] == "OK"
log = find_log(shop("sup.wes"), pkt["id"], "Change order CO-1 verified by Weld Parts")
assert log["note"] == "OK · change order fully verified"
+188
View File
@@ -0,0 +1,188 @@
"""Phase 5 station clock: every value is the server's. START starts it, the clock endpoint pauses and resumes it,
PULL_BACK and SUBMIT fold the running time in, the live elapsed grows without a write, and no request field sets
it. Time is moved by updating clock_running_since by SQL, so the tests do not wait."""
from __future__ import annotations
import time
from collections.abc import Iterator
from datetime import datetime
from typing import Any
import pytest
from fakedir import FakeDirectory
from pkthelpers import step
from pshelpers import needs_pg
from shophelpers import (
SIGN,
Shop,
finish_stage,
fresh,
im,
in_progress,
in_review,
last_log,
ok,
one,
open_shop,
ready_packet,
refused,
set_running_since,
start,
to_station,
)
from packetstation.settings import Settings
pytestmark = needs_pg
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def stored(shop: Shop, pid: int) -> tuple[int, bool]:
row = one(
shop.settings,
"SELECT clock_elapsed_ms, clock_running_since IS NOT NULL FROM packet_stages"
" WHERE packet_id = %s AND closed_at IS NULL",
(pid,),
)
return int(row[0]), bool(row[1])
def clock_op(shop: Shop, pkt: dict[str, Any], op: str, by: str = "weld.andy", **extra: Any) -> Any:
return shop(by).post(f"/api/packets/{pkt['id']}/stage/clock", json={"op": op, **extra})
def ts(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def test_start_pause_resume_pull_back(shop: Shop) -> None:
pkt = to_station(shop, ready_packet(shop))
c = pkt["stage"]["clock"]
assert (c["running"], c["stored_ms"], c["elapsed_ms"], c["running_since"], c["started_at"]) == (
False,
0,
0,
None,
None,
)
pkt = start(shop, pkt)
c = pkt["stage"]["clock"]
assert c["running"] is True and c["stored_ms"] == 0 and c["running_since"] and c["started_at"]
started_at = c["started_at"]
# pause after 90 s of running
set_running_since(shop.settings, pkt["id"], 90)
out = ok(clock_op(shop, pkt, "pause"))
c = out["stage"]["clock"]
assert c["running"] is False and c["running_since"] is None
assert 90_000 <= c["stored_ms"] < 95_000 and c["elapsed_ms"] == c["stored_ms"]
assert stored(shop, pkt["id"]) == (c["stored_ms"], False)
assert last_log(shop("weld.andy"), pkt["id"])["action"] == "Paused"
paused_at = c["stored_ms"]
# resume, run 30 s, then the supervisor pulls it back: the running time is folded in
out = ok(clock_op(shop, out, "resume"))
assert out["stage"]["clock"]["running"] is True and out["stage"]["clock"]["started_at"] == started_at
assert last_log(shop("weld.andy"), pkt["id"])["action"] == "Resumed"
set_running_since(shop.settings, pkt["id"], 30)
out = ok(step(shop("sup.wes"), out, "pull_back"))
c = out["stage"]["clock"]
assert c["running"] is False and paused_at + 30_000 <= c["stored_ms"] < paused_at + 35_000
assert stored(shop, pkt["id"])[1] is False
# assigned and started again: it carries on from the stored value
out = start(shop, to_station(shop, out))
assert out["stage"]["clock"]["stored_ms"] == c["stored_ms"] and out["stage"]["clock"]["running"] is True
assert out["stage"]["clock"]["started_at"] == started_at
def test_live_elapsed_grows_without_a_write(shop: Shop) -> None:
pkt = in_progress(shop)
set_running_since(shop.settings, pkt["id"], 10)
a = fresh(shop("weld.andy"), pkt["id"])
time.sleep(1.1)
b = fresh(shop("weld.andy"), pkt["id"])
assert a["version"] == b["version"]
assert a["stage"]["clock"]["stored_ms"] == b["stage"]["clock"]["stored_ms"] == 0
assert 10_000 <= a["stage"]["clock"]["elapsed_ms"] < b["stage"]["clock"]["elapsed_ms"]
assert b["stage"]["clock"]["elapsed_ms"] - a["stage"]["clock"]["elapsed_ms"] >= 1_000
# elapsed = stored + (server_now - running_since), from the server's own clock
since = ts(b["stage"]["clock"]["running_since"])
now = ts(b["server_now"])
want = (now - since).total_seconds() * 1000
assert abs(b["stage"]["clock"]["elapsed_ms"] - want) < 1_000
def test_submit_stops_the_clock(shop: Shop) -> None:
pkt = finish_stage(shop, in_progress(shop))
set_running_since(shop.settings, pkt["id"], 3 * 3600)
out = ok(step(shop("weld.andy"), pkt, "submit", sign=SIGN))
c = out["stage"]["clock"]
assert c["running"] is False and 3 * 3_600_000 <= c["stored_ms"] < 3 * 3_600_000 + 10_000
assert last_log(shop("weld.andy"), pkt["id"])["note"] in ("1 weld · 3.0 h", "1 welds · 3.0 h")
def test_clock_state_conflicts(shop: Shop) -> None:
pkt = to_station(shop, ready_packet(shop))
# never started: pause and resume are refused
r = refused(clock_op(shop, pkt, "pause"), 409, "clock_state")
assert r["current"] == {"running": False, "started": False}
refused(clock_op(shop, pkt, "resume"), 409, "clock_state")
pkt = start(shop, pkt)
r = refused(clock_op(shop, pkt, "start"), 409, "clock_state")
assert r["current"]["running"] is True
refused(clock_op(shop, pkt, "resume"), 409, "clock_state")
ok(clock_op(shop, pkt, "pause"))
refused(clock_op(shop, pkt, "pause"), 409, "clock_state")
assert clock_op(shop, pkt, "stop").status_code == 422
# who: Q.C. and the warehouse may not touch it
for sam in ("qc.quinn", "wh.wade", "clerk.clara", "multi.mia"):
refused(clock_op(shop, pkt, "resume", by=sam), 403, "forbidden")
def test_no_field_sets_elapsed(shop: Shop) -> None:
pkt = in_progress(shop)
set_running_since(shop.settings, pkt["id"], 60)
big = 999_999_999
# the clock endpoint takes only op
r = clock_op(shop, pkt, "pause", elapsed_ms=big, clock_elapsed_ms=big, stored_ms=big)
assert r.status_code in (200, 422), r.text
if r.status_code == 422:
ok(clock_op(shop, pkt, "pause"))
ms, running = stored(shop, pkt["id"])
assert 60_000 <= ms < 70_000 and running is False
# the stage fields refuse or ignore clock columns, in both body shapes
url = f"/api/packets/{pkt['id']}/stage"
for body in (
{"set": {"clock_elapsed_ms": big, "clock_running_since": None}, "base": {}},
{"set": {"filler": "ER70S-6", "clock_elapsed_ms": big}, "base": {}},
):
r = shop("weld.andy").patch(url, json=body)
assert r.status_code in (200, 422), r.text
rv = fresh(shop("weld.andy"), pkt["id"])["stage"]["row_version"]
r = shop("weld.andy").patch(url, json={"clock_elapsed_ms": big, "notes": "x"}, headers=im(rv))
assert r.status_code in (200, 422), r.text
assert stored(shop, pkt["id"])[0] == ms
# the transitions carry no clock either
r = step(shop("sup.wes"), fresh(shop("sup.wes"), pkt["id"]), "pull_back", elapsed_ms=big, clock_elapsed_ms=big)
assert r.status_code in (200, 422), r.text
assert stored(shop, pkt["id"])[0] == ms
# and the read model's value is the stored one
assert fresh(shop("sup.wes"), pkt["id"])["stage"]["clock"]["elapsed_ms"] == ms
def test_closed_stage_clock(shop: Shop) -> None:
pkt = in_review(shop)
out = ok(step(shop("sup.wes"), pkt, "route_from_review", dept="09", sign=SIGN))
old = out["stages"][-1]
assert old["clock"]["running"] is False and old["clock"]["elapsed_ms"] == old["clock"]["stored_ms"]
new = out["stage"]["clock"]
assert (new["running"], new["stored_ms"], new["elapsed_ms"]) == (False, 0, 0)
+221
View File
@@ -0,0 +1,221 @@
"""Phase 5 shop writes under concurrency: weld and heat tag numbers come from the packet counter under the row
lock, so two sessions marking at once never collide; two supervisors verify two departments of one change order
at once and both land; two users tick different checklist keys and both stay; a stale expect is refused with the
message; a write to a packet filed in the meantime is refused."""
from __future__ import annotations
import threading
from collections.abc import Callable, Iterator
from typing import Any
import pytest
from fakedir import FakeDirectory
from fastapi.testclient import TestClient
from pkthelpers import step
from pshelpers import needs_pg
from shophelpers import (
SIGN,
STALE,
Shop,
all_rows,
finish_stage,
fresh,
in_progress,
issue_co,
ok,
open_shop,
refused,
run,
to_station,
verify,
)
from packetstation.settings import Settings
pytestmark = needs_pg
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def together(calls: list[Callable[[], Any]]) -> list[Any]:
"""Run the calls in threads released by one barrier. Returns their answers in order."""
results: list[Any] = [None] * len(calls)
errors: list[BaseException] = []
barrier = threading.Barrier(len(calls))
def run_one(i: int) -> None:
try:
barrier.wait(10)
results[i] = calls[i]()
except BaseException as e: # reported below
errors.append(e)
threads = [threading.Thread(target=run_one, args=(i,)) for i in range(len(calls))]
for t in threads:
t.start()
for t in threads:
t.join(60)
assert not errors, errors
return results
def weld_call(c: TestClient, pid: int, fx: float) -> Callable[[], Any]:
body = {"page": 1, "fx": fx, "fy": 0.5, "items": [1], "type": "Fillet", "process": "GMAW"}
return lambda: c.post(f"/api/packets/{pid}/welds", json=body)
def tag_call(c: TestClient, pid: int, fx: float) -> Callable[[], Any]:
return lambda: c.post(f"/api/packets/{pid}/heat-tags", json={"page": 1, "item": 2, "fx": fx, "fy": 0.4})
def test_concurrent_welds_never_collide(shop: Shop) -> None:
pkt = in_progress(shop)
pid = pkt["id"]
# weld.andy on two sessions (two tablets) and sup.wes, three welds each, all at once
clients = [shop.fresh("weld.andy"), shop.fresh("weld.andy"), shop.fresh("sup.wes")]
calls = [weld_call(c, pid, 0.1 + 0.1 * i + 0.01 * k) for i, c in enumerate(clients) for k in range(3)]
answers = together(calls)
assert [r.status_code for r in answers] == [200] * len(calls), [r.text for r in answers if r.status_code != 200]
nos = sorted(w["weld_no"] for w in fresh(shop("sup.wes"), pid)["welds"])
assert nos == list(range(1, len(calls) + 1))
rows = all_rows(shop.settings, "SELECT weld_no FROM welds WHERE packet_id = %s ORDER BY weld_no", (pid,))
assert [r[0] for r in rows] == nos
marked = sorted(
r[0]
for r in all_rows(shop.settings, "SELECT action FROM packet_log WHERE packet_id = %s", (pid,))
if r[0].endswith(" marked") and r[0].startswith("Weld W")
)
assert marked == sorted(f"Weld W{n} marked" for n in nos)
assert (
int(all_rows(shop.settings, "SELECT next_weld_no FROM packets WHERE id = %s", (pid,))[0][0]) == len(calls) + 1
)
def test_two_welds_at_once_are_w1_and_w2(shop: Shop) -> None:
pkt = in_progress(shop)
a, b = together(
[weld_call(shop.fresh("weld.andy"), pkt["id"], 0.2), weld_call(shop.fresh("sup.wes"), pkt["id"], 0.7)]
)
assert a.status_code == 200 and b.status_code == 200, (a.text, b.text)
got = {w["fx"]: w["weld_no"] for w in fresh(shop("sup.wes"), pkt["id"])["welds"]}
assert sorted(got.values()) == [1, 2] and got[0.2] != got[0.7]
def test_concurrent_heat_tags_never_collide(shop: Shop) -> None:
pkt = in_progress(shop)
pid = pkt["id"]
clients = [shop.fresh("weld.andy"), shop.fresh("sup.wes"), shop.fresh("lead.lou")]
calls = [tag_call(c, pid, 0.1 + 0.1 * i + 0.01 * k) for i, c in enumerate(clients) for k in range(3)]
answers = together(calls)
assert [r.status_code for r in answers] == [200] * len(calls), [r.text for r in answers if r.status_code != 200]
nos = sorted(t["tag_no"] for t in fresh(shop("sup.wes"), pid)["heat_tags"])
assert nos == list(range(1, len(calls) + 1))
def test_two_supervisors_verify_at_once(shop: Shop) -> None:
pkt = in_progress(shop)
pkt = issue_co(shop, pkt, ["03", "06"])
assert pkt["change_orders"][0]["required"] == ["03", "06"]
wes, mia = shop.fresh("sup.wes"), shop.fresh("multi.mia")
def call(c: TestClient, dept: str) -> Callable[[], Any]:
body = {"dept": dept, "note": f"Checked in {dept}", "sign": SIGN}
return lambda: c.post(f"/api/packets/{pkt['id']}/change-orders/1/verify", json=body)
a, b = together([call(wes, "03"), call(mia, "06")])
assert a.status_code == 200 and b.status_code == 200, (a.text, b.text)
out = fresh(shop("sup.wes"), pkt["id"])
co = out["change_orders"][0]
assert set(co["verified"]) == {"03", "06"} and co["pending_depts"] == [] and co["open"] is False
assert out["open_cos"] == []
rows = all_rows(shop.settings, "SELECT dept_code FROM co_verifications ORDER BY dept_code")
assert [r[0] for r in rows] == ["03", "06"]
notes = sorted(
r[0]
for r in all_rows(
shop.settings,
"SELECT note FROM packet_log WHERE packet_id = %s AND action LIKE 'Change order CO-1 verified by %%'",
(pkt["id"],),
)
)
# one of them landed first and still waited on the other; the second fully verified it
assert len(notes) == 2
assert any(n.endswith("change order fully verified") for n in notes)
assert any("still waiting on" in n for n in notes)
# the same department twice at once: one lands, one is told it is done
pkt2 = issue_co(shop, out, ["03"])
no = pkt2["change_orders"][-1]["co_no"]
def again(c: TestClient) -> Callable[[], Any]:
return lambda: c.post(
f"/api/packets/{pkt['id']}/change-orders/{no}/verify", json={"dept": "03", "note": "x", "sign": SIGN}
)
a, b = together([again(shop.fresh("sup.wes")), again(shop.fresh("sup.wes"))])
assert sorted([a.status_code, b.status_code]) == [200, 409], (a.text, b.text)
loser = a if a.status_code == 409 else b
assert loser.json()["code"] == "already_verified"
def test_checklist_keys_merge(shop: Shop) -> None:
pkt = in_progress(shop)
andy, wes = shop.fresh("weld.andy"), shop.fresh("sup.wes")
url = f"/api/packets/{pkt['id']}/stage/checklist"
pairs = [(andy, "dwg"), (wes, "heat"), (andy, "wps"), (wes, "fit")]
answers = together([(lambda c=c, k=k: c.put(f"{url}/{k}", json={"done": True})) for c, k in pairs])
assert [r.status_code for r in answers] == [200] * 4, [r.text for r in answers]
ck = fresh(shop("sup.wes"), pkt["id"])["stage"]["checklist"]
assert {k for k, v in ck.items() if v.get("done")} == {"dwg", "heat", "wps", "fit"}
assert ck["dwg"]["by"] == shop.uid("weld.andy") and ck["heat"]["by"] == shop.uid("sup.wes")
def test_stale_expect_is_refused(shop: Shop) -> None:
pkt = finish_stage(shop, in_progress(shop))
andy_view = fresh(shop("weld.andy"), pkt["id"])
ok(step(shop("sup.wes"), pkt, "pull_back"))
r = refused(step(shop("weld.andy"), andy_view, "submit", sign=SIGN), 409, "stale_step", STALE)
assert r["current"]["status"] == "ready" and r["current"]["dept"] == "03"
assert r["current"]["version"] == fresh(shop("sup.wes"), pkt["id"])["version"]
# pull back and start at once: one of them is stale
pkt = to_station(shop, fresh(shop("sup.wes"), pkt["id"]))
last = all_rows(
shop.settings, "SELECT coalesce(max(id), 0) FROM packet_transitions WHERE packet_id = %s", (pkt["id"],)
)
wes, andy = shop.fresh("sup.wes"), shop.fresh("weld.andy")
a, b = together([lambda: step(wes, pkt, "unassign"), lambda: step(andy, pkt, "start")])
assert sorted([a.status_code, b.status_code]) == [200, 409], (a.text, b.text)
loser = a if a.status_code == 409 else b
assert loser.json()["code"] == "stale_step" and loser.json()["detail"] == STALE
n = all_rows(
shop.settings,
"SELECT count(*) FROM packet_transitions WHERE packet_id = %s AND id > %s",
(pkt["id"], last[0][0]),
)[0][0]
assert n == 1
def test_filed_in_the_meantime_is_refused(shop: Shop) -> None:
pkt = in_progress(shop)
pkt = issue_co(shop, pkt, ["03"])
seen = fresh(shop("sup.wes"), pkt["id"])
run(shop.settings, "UPDATE packets SET status = 'filed', version = version + 1 WHERE id = %s", (pkt["id"],))
msg = "This packet is filed and locked. IT can reopen it if a correction is needed."
refused(verify(shop, seen, 1, "03", "sup.wes"), 403, "filed", msg)
refused(
shop("clerk.clara").patch(f"/api/packets/{pkt['id']}", json={"set": {"drawing": "D-9"}, "base": {}}),
403,
"filed",
msg,
)
refused(verify(shop, seen, 1, "03", "admin.ann", it_reason="Ticket 5502: verify after filing"), 403, "filed", msg)
# a shop edit is not open on a filed packet either
r = shop("weld.andy").post(
f"/api/packets/{pkt['id']}/welds", json={"page": 1, "fx": 0.5, "fy": 0.5, "items": [1], "type": "Fillet"}
)
assert r.status_code == 403 and r.json()["code"] in ("filed", "wrong_status"), r.text
r = step(shop("weld.andy"), seen, "submit", sign=SIGN)
assert r.status_code == 409 and r.json()["code"] == "stale_step", r.text