Build Seth's changes C1 to C11 (Gitea issues 1, 8, 18, 23, 24, 25, 27, 31, 32, 33, 36)
ci / server (pull_request) Successful in 12m48s
ci / frontend (pull_request) Successful in 54s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 4m38s

- C1 (issue 1): over target follows VISUAL ticket hours, not the timer.
- C2 (issue 24): a Q.C. reject always needs a reason; accept needs an NCR number after a reject without one.
- C3 (issue 25): an incomplete unit ITP blocks filing.
- C4 (issue 27): prints open the print dialog on the page, no new tab.
- C5 (issue 31): filing is blocked while VISUAL shows a work order open; the feed sets vclosed on VISUAL packets.
- C6 (issue 8): the data book leaves out the history pages.
- C7 (issue 18): the supervisor approves each received short; accept and filing wait for it.
- C8 (issue 23): per-department checklists, edited by the department supervisor or Admin, frozen at START.
- C9 (issue 32): Q.C. and Clerical may run the stamp check.
- C10 (issue 33): the PM screen and reports are for PS-ProjectMgmt and Admin only.
- C11 (issue 36): the Stall screen lists the packets assigned to the welder, at the supervisor's stall.

Migration 0011. Docs: PLAN (also issue 9 confirmed, issue 4 asked), POLICY, SECURITY, SCHEMA, FEED, README, CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-30 16:36:49 -06:00
co-authored by Claude Opus 5.5
parent 150c507a31
commit 70a97398f1
101 changed files with 3871 additions and 1098 deletions
+35 -5
View File
@@ -107,13 +107,15 @@ def set_feed_legs(
observed: bool = True,
) -> None:
"""Simulate the feed: the packet becomes a VISUAL packet (source_kind visual, a visual_key), packet_visual_state
holds the legs as the feed writes them ({key: "base/lot/split/sub", seq, st}) and visual_closed_at is set when
`closed`. `legs` None writes no state row (the feed has not looked yet)."""
holds the legs as the feed writes them ({key: "base/lot/split/sub", seq, st}), and when `closed` the feed's
close is set: visual_closed_at, and vclosed_at with no vclosed_by (C5: the feed is the writer of vclosed on a
VISUAL packet). `legs` None writes no state row (the feed has not looked yet)."""
with app_conn(settings) as conn:
conn.execute(
"UPDATE packets SET source_kind = 'visual', visual_key = coalesce(visual_key, %s),"
" visual_closed_at = CASE WHEN %s THEN now() END WHERE id = %s",
(f"test-{packet_id}-{secrets.token_hex(3)}", closed, packet_id),
" visual_closed_at = CASE WHEN %s THEN now() END, vclosed_at = CASE WHEN %s THEN now() END,"
" vclosed_by = NULL WHERE id = %s",
(f"test-{packet_id}-{secrets.token_hex(3)}", closed, closed, packet_id),
)
if legs is None:
conn.execute("DELETE FROM packet_visual_state WHERE packet_id = %s", (packet_id,))
@@ -250,7 +252,7 @@ def rich_complete(shop: Shop, *, itp_doc: bool = False, mtrs: bool = True) -> di
pkt = submit(shop, finish_stage(shop, pkt, welds=2))
pkt = ok(step(shop("sup.wes"), pkt, "send_to_qc", book=True, spool=True, location="Bay 2", sign=SIGN))
pkt = finish_inspection(shop, pkt)
pkt = ok(accept(shop, pkt, "databook"))
pkt = accept_with_open_short(shop, pkt, 3)
add_doc(shop, pkt)
r = make_qc_doc(shop, unit_id=unit_id, type="ITP" if itp_doc else "WPS / PQR", title="Unit folder doc")
assert r.status_code in (200, 201), r.text
@@ -261,6 +263,34 @@ def rich_complete(shop: Shop, *, itp_doc: bool = False, mtrs: bool = True) -> di
return out
def approve_receipts(shop: Shop, pkt: dict[str, Any], by: str = "sup.wes") -> dict[str, Any]:
"""The department supervisor approves every received short not yet approved (C7). Returns the packet read."""
for sh in pkt.get("shorts") or []:
for r in sh["receipts"]:
if r["approval"] is None:
url = f"/api/packets/{pkt['id']}/shorts/receipts/{r['id']}/approve"
pkt = ok(shop(by).post(url, json={"sign": SIGN}))
return pkt
def accept_with_open_short(shop: Shop, pkt: dict[str, Any], item: int) -> dict[str, Any]:
"""Accept to the data book while item `item` keeps its open short: a packet completed before change C7, which
the data book and the trace CSV still show. The receipts are approved through the API; the open short is closed
by SQL for the accept only (C7 refuses an accept with an open short) and opened again after it."""
pkt = approve_receipts(shop, pkt)
sid = line_of(pkt, item)["short"]["id"]
with app_conn(shop.settings) as conn:
conn.execute("UPDATE bom_shorts SET closed_at = now() WHERE id = %s", (sid,))
pkt = ok(accept(shop, fresh_read(shop, pkt["id"]), "databook"))
with app_conn(shop.settings) as conn:
conn.execute("UPDATE bom_shorts SET closed_at = NULL WHERE id = %s", (sid,))
return fresh_read(shop, pkt["id"])
def fresh_read(shop: Shop, pid: int) -> dict[str, Any]:
return ok(shop("clerk.clara").get(f"/api/packets/{pid}"))
def upload_bytes_pdf() -> bytes:
"""A one-page PDF with its own bytes (the store dedupes equal bytes)."""
from qchelpers import unique_pdf
+16 -8
View File
@@ -7,7 +7,7 @@ The labor hours are rows written straight into `visual_labor`, as the feed tests
The PM world (dates are calendar days in the site time zone, America/Denver):
project L670 "Pump skids" (Acme Gas, PO-9, due today + 20), alias OLD670
unit 001 "Skid A" due yesterday -> late k_run (in progress, timer 6 h, ticket 3.2 h, target 4.5 h),
unit 001 "Skid A" due yesterday -> late k_run (in progress, timer 6 h, ticket 5.2 h, target 4.5 h),
k_short (warehouse pulling, an open short with a past ETA)
unit 002 "Skid B" due today + 4 -> risk k_rev (review after a Q.C. reject), k_qc (at Q.C.)
unit 003 "Skid C" due today + 30 -> risk k_hold (warehouse hold for 5 days), k_inc (warehouse waiting)
@@ -101,13 +101,21 @@ def kpi_labels(period: str = "90") -> tuple[str, ...]:
"Open change orders",
"Open shorts",
f"Q.C. rejects · {lower}",
"Packets over target hours",
"Packets over target hours (tickets)",
f"Filed · {lower}",
"Average lead time, created → filed",
)
HOURS_TAIL = ["Packets", "Timer h", "Ticket h", "Target h", "Variance h", "Timer / target", "Over target"]
HOURS_TAIL = [
"Packets",
"Timer h",
"Ticket h",
"Target h",
"Ticket variance h",
"Ticket h / target",
"Over target (tickets)",
]
HEADS: dict[str, list[str]] = {
"projects": [
"Project / unit",
@@ -131,7 +139,7 @@ HEADS: dict[str, list[str]] = {
"hours_dept": ["Department", *HOURS_TAIL],
"hours_worker": ["Worker", *HOURS_TAIL],
"hours_project": ["Project", *HOURS_TAIL],
"hours_over": ["Job", "Station / worker", "Status", "Timer h", "Ticket h", "Target h", "Over by"],
"hours_over": ["Job", "Station / worker", "Status", "Timer h", "Ticket h", "Target h", "Over by (tickets)"],
"quality_rejects": ["Job", "Date", "Inspector", "Reason / NCR", "Station / worker"],
"quality_shorts": ["Job", "Item", "Short qty", "Reason", "PO / req", "ETA", "Age", "Packet is"],
"quality_cos": ["Job", "Change order", "Change", "Waiting on", "Open for"],
@@ -190,8 +198,8 @@ NOTES = {
"aging_steps": "How long packets have been sitting where they are right now.",
"aging_open": "Over 3 days in one step is flagged.",
"aging_history": "From the step history recorded on every move.",
"hours_over": "Station timer hours and VISUAL ticket hours against the work-order target hours. The timer is the"
" clock of record.",
"hours_over": "VISUAL ticket hours against the work-order target hours decide over target. The station timer is"
" shown beside them. A packet with no ticket hours is not over target.",
}
TAB_TABLES = {
@@ -509,7 +517,7 @@ def pm_world(shop: Shop) -> PmWorld:
u = w.units
wade = shop("wh.wade")
# unit 001, late: a running packet over target with ticket hours, a short with a past ETA
# unit 001, late: a running packet over target on its ticket hours, a short with a past ETA
run_pkt = start(shop, to_station(shop, ready_skipped(shop, u[1], title="Running spool")))
w.k["k_run"] = run_pkt["id"]
run(
@@ -518,7 +526,7 @@ def pm_world(shop: Shop) -> PmWorld:
(4 * 3600 * 1000, run_pkt["id"]),
)
set_running_since(s, run_pkt["id"], 2 * 3600)
labor_rows(s, run_pkt["id"], [("L670001", 10, "1.5"), ("L670002", 20, "1.7")])
labor_rows(s, run_pkt["id"], [("L670001", 10, "3.0"), ("L670002", 20, "2.2")])
short = to_warehouse(shop, draft(shop, u[1], title="Short spool"))
yesterday = (today - timedelta(days=1)).isoformat()
+35 -2
View File
@@ -64,8 +64,6 @@ def endpoints(andy: str, ck_version: int) -> list[tuple[str, str, dict[str, Any]
("GET", "/api/admin/access-log", {}, 200),
("GET", "/api/admin/access-log.csv", {}, 200),
("GET", "/api/admin/presence", {}, 200),
("POST", "/api/admin/stamp-verify", {"json": {"text": "0123456789abcdef"}}, 200),
("POST", "/api/admin/stamp-verify/pdf", {"files": {"file": ("a.pdf", tiny_pdf(), "application/pdf")}}, 200),
("GET", "/api/admin/directory-flags", {}, 200),
("POST", "/api/admin/ad/refresh", {}, 202),
("GET", "/api/admin/ad/reconcile", {}, 200),
@@ -123,3 +121,38 @@ def test_every_new_admin_endpoint_as_every_role(
assert n == len(OTHER_ROLES) * len(eps)
details = conn.execute("SELECT detail FROM audit_events WHERE action = 'policy.denied' LIMIT 1").fetchone()[0]
assert set(details) == {"method", "path", "code"}
def stamp_endpoints() -> list[tuple[str, str, dict[str, Any]]]:
out = []
for base in ("/api/stamp-verify", "/api/admin/stamp-verify"):
out.append(("POST", base, {"json": {"text": "0123456789abcdef"}}))
out.append(("POST", f"{base}/pdf", {"files": {"file": ("a.pdf", tiny_pdf(), "application/pdf")}}))
return out
def test_stamp_check_roles(pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory) -> None:
"""Change C9: the stamp check is open to Admin, Q.C. and every Clerical level, at both paths, on a password
session. Every other role gets 403."""
eps = stamp_endpoints()
with TestClient(create_app(pg_settings, directory=fake_dir), headers=ORIGIN_HEADERS) as anon:
for method, path, kw in eps:
assert call(anon, method, path, kw).status_code == 401, path
for sam in ("admin.ann", "qc.quinn", "clerk.clara", "clerk.leo"):
assert sign_in(pg_client, sam).status_code == 200, sam
for method, path, kw in eps:
r = call(pg_client, method, path, kw)
assert r.status_code == 200, (sam, path, r.status_code, r.text)
pg_client.post("/api/auth/signout")
for sam in ("wh.wade", "pm.paula", "weld.andy", "lead.lou", "sup.wes"):
assert sign_in(pg_client, sam).status_code == 200, sam
for method, path, kw in eps:
r = call(pg_client, method, path, kw)
assert r.status_code == 403 and r.json()["code"] == "forbidden", (sam, path, r.status_code)
pg_client.post("/api/auth/signout")
# A PIN session: the password session rule stays
register_device(pg_client)
set_pin(pg_client, "admin.ann")
assert pin_signin(pg_client, "1001").status_code == 200
r = call(pg_client, eps[0][0], eps[0][1], eps[0][2])
assert r.status_code == 403 and r.json()["code"] == "password_session_required", r.text
+66
View File
@@ -0,0 +1,66 @@
"""Change C1 (Gitea issue 1): "over target" is decided by the VISUAL ticket hours against the work-order target, not
by the station timer. A packet with no ticket hours is not over target. No database: a stub report feeds the PM
hours tables."""
from __future__ import annotations
from typing import Any
from packetstation.domain import pm
class StubReport:
def __init__(self, hours: dict[int, dict[str, float]]) -> None:
self._h = hours
self.cards = [
{"id": pid, "status": "station", "dept": {"code": "WP"}, "unit": {"number": f"L1-00{pid}"}, "op": "10"}
for pid in hours
]
def hours(self) -> dict[int, dict[str, float]]:
return self._h
def dept_short(self, code: str) -> str:
return code
def h(timer: float, ticket: float, target: float) -> dict[str, float]:
return {"timer_h": timer, "ticket_h": ticket, "target_h": target}
WORLD = {
1: h(9.0, 0.0, 4.0), # timer over, no tickets: not over
2: h(1.0, 5.5, 4.0), # tickets over, timer under: over
3: h(9.0, 3.0, 4.0), # timer over, tickets under: not over
4: h(0.0, 2.0, 0.0), # no target: never over
}
def tables() -> dict[str, dict[str, Any]]:
rep: Any = StubReport(WORLD)
return {t["key"]: t for t in pm.hours_tables(rep)}
def test_is_over_reads_the_tickets() -> None:
assert [pid for pid, x in WORLD.items() if pm.is_over(x)] == [2]
def test_over_table_lists_ticket_overs_only() -> None:
over = tables()["hours_over"]
assert over["head"][-1] == "Over by (tickets)"
assert [r["pid"] for r in over["rows"]] == [2]
assert over["rows"][0]["cells"][-1] == "+1.5 h"
def test_group_counts_and_heads() -> None:
dept = tables()["hours_dept"]
assert dept["head"][-3:] == ["Ticket variance h", "Ticket h / target", "Over target (tickets)"]
cells = dept["rows"][0]["cells"]
assert cells[-1] == "1" # one packet over on its tickets
# ticket 8.5 against target 12: under, so the group is not flagged and the ratio is the tickets'
assert cells[-2] == "71%" and not dept["rows"][0]["flag"]
def test_over_target_count() -> None:
rep: Any = StubReport(WORLD)
assert pm.over_target_count(rep) == 1
+77
View File
@@ -0,0 +1,77 @@
"""Change C2 (Gitea issue 24): a Q.C. reject always needs a detailed reason, and a packet rejected without an NCR
number cannot be accepted until the NCR number is given with the accept (QC_ACCEPT and QC_ACCEPT_AND_ROUTE).
The packet read says `ncr_required`; can() does not block on it (the NCR comes with the step); the server refuses
the step without it, 422 ncr_required. The accept stores the NCR on its inspection and in the log."""
from __future__ import annotations
from collections.abc import Iterator
import pytest
from fakedir import FakeDirectory
from pkthelpers import step
from pshelpers import needs_pg
from qchelpers import NCR, accept, at_qc, finish_inspection, reject, to_qc
from shophelpers import SIGN, Shop, fresh, last_log, ok, one, open_shop, refused
from packetstation.settings import Settings
pytestmark = needs_pg
REASON = "Weld 2 undercut too deep"
MSG = "Enter the NCR number of the reject first."
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def test_accept_after_a_reject_without_ncr_needs_one(shop: Shop) -> None:
pkt = at_qc(shop)
assert pkt["ncr_required"] is False
out = ok(reject(shop, pkt, REASON))
assert out["ncr_required"] is True and out["qc_last"]["ncr"] is None
pkt = finish_inspection(shop, to_qc(shop, fresh(shop("sup.wes"), pkt["id"])))
assert pkt["ncr_required"] is True
# can() does not block on it: the NCR is input with the step
assert {"qc_accept", "qc_accept_and_route"} <= set(pkt["can"]) and "qc_accept" not in pkt["blocked"]
refused(accept(shop, pkt), 422, "ncr_required", MSG)
refused(step(shop("qc.quinn"), pkt, "qc_accept_and_route", dept="09", sign=SIGN), 422, "ncr_required", MSG)
refused(step(shop("qc.quinn"), pkt, "qc_accept", routed_to="databook", ncr=" ", sign=SIGN), 422, "ncr_required")
refused(
step(shop("qc.quinn"), pkt, "qc_accept", routed_to="databook", ncr="NCR-26-1", sign=SIGN), 422, "invalid_ncr"
)
assert fresh(shop("qc.quinn"), pkt["id"])["status"] == "qc"
out = ok(step(shop("qc.quinn"), pkt, "qc_accept", routed_to="databook", ncr=" ncr-2026-0021 ", sign=SIGN))
assert out["status"] == "complete" and out["ncr_required"] is False
assert out["qc_last"]["result"] == "accepted" and out["qc_last"]["ncr"] == NCR
row = one(
shop.settings,
"SELECT ncr, result FROM qc_inspections WHERE packet_id = %s ORDER BY id DESC LIMIT 1",
(pkt["id"],),
)
assert tuple(row) == (NCR, "accepted")
log = last_log(shop("qc.quinn"), pkt["id"])
assert log["action"] == "Q.C. accepted → Clerical – Data Book"
assert log["note"] == f"Quinn QC [Q1] · 2 welds VT accepted · {NCR}", log
def test_accept_and_route_carries_the_ncr(shop: Shop) -> None:
pkt = at_qc(shop)
ok(reject(shop, pkt, REASON))
pkt = finish_inspection(shop, to_qc(shop, fresh(shop("sup.wes"), pkt["id"])))
out = ok(step(shop("qc.quinn"), pkt, "qc_accept_and_route", dept="09", ncr=NCR, sign=SIGN))
assert out["status"] == "ready" and out["ncr_required"] is False and out["qc_last"]["ncr"] == NCR
log = last_log(shop("qc.quinn"), pkt["id"])
assert log["note"] == f"Quinn QC [Q1] · from 03 Weld Parts · {NCR}", log
def test_a_reject_with_its_ncr_needs_none_at_accept(shop: Shop) -> None:
pkt = at_qc(shop)
out = ok(reject(shop, pkt, REASON, ncr=NCR))
assert out["ncr_required"] is False and out["return_note"] == f"{NCR} · {REASON}"
pkt = finish_inspection(shop, to_qc(shop, fresh(shop("sup.wes"), pkt["id"])))
out = ok(accept(shop, pkt))
assert out["status"] == "complete" and out["qc_last"]["ncr"] is None
+42
View File
@@ -0,0 +1,42 @@
"""Change C3 (Gitea issue 25): FILE is refused while the packet's unit has an ITP that is not complete. A unit with
no ITP does not block. The reason shows in `blocked.file` so the close panel says why."""
from __future__ import annotations
from collections.abc import Iterator
import pytest
from closehelpers import at_complete, file_packet, vclose
from fakedir import FakeDirectory
from pshelpers import needs_pg
from qchelpers import start_itp
from shophelpers import Shop, fresh, make_unit, ok, open_shop, refused, run
from packetstation.settings import Settings
pytestmark = needs_pg
MSG = "Complete the unit's ITP first. Q.C. signs it off on the ITP page."
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def test_open_itp_blocks_the_file_until_signed_off(shop: Shop) -> None:
_, unit_id = make_unit(shop.settings)
pkt = vclose(shop, at_complete(shop, unit_id))
pid = pkt["id"]
assert "file" in pkt["can"] # no ITP on the unit: nothing blocks
itp = start_itp(shop, unit_id)
view = fresh(shop("clerk.clara"), pid)
assert "file" not in view["can"] and view["blocked"]["file"] == [MSG]
[card] = ok(shop("clerk.clara").get(f"/api/packets?unit_id={unit_id}"))["packets"]
assert card["blocked"]["file"] == [MSG]
refused(file_packet(shop, view), 403, "precondition", MSG)
# the ITP sign-off itself is tested in test_itp.py; here its end state is enough
run(shop.settings, "UPDATE itps SET status = 'complete', signoff_at = now() WHERE id = %s", (itp["id"],))
view = fresh(shop("clerk.clara"), pid)
assert "file" in view["can"] and "file" not in view["blocked"]
assert ok(file_packet(shop, view))["status"] == "filed"
+80
View File
@@ -0,0 +1,80 @@
"""Change C5 (Gitea issue 31): filing is blocked, not warned, while VISUAL still shows a work order open, on both
routes. On a VISUAL packet the feed is the one writer of `vclosed`: it sets it when it sees every work order closed
(the Clerical board then shows the packet under "Ready for data book") and clears it when one shows open again.
The feed's observe step runs here on legs written by SQL. Nothing here reads VISUAL or M-Files."""
from __future__ import annotations
from collections.abc import Iterator, Mapping
from datetime import date
from typing import Any
import pytest
from closehelpers import at_complete, file_packet, leg, q1, set_feed_legs
from fakedir import FakeDirectory
from pshelpers import app_conn, needs_pg
from shophelpers import Shop, find_log, fresh, ok, open_shop, refused
from packetstation.feed import apply
from packetstation.feed.types import WoKey
from packetstation.settings import Settings
pytestmark = needs_pg
W1 = WoKey("L670001", "001", "0", "0")
W2 = WoKey("L670002", "001", "0", "0")
OPEN_W2 = "VISUAL still shows work orders open on this packet: L670002/001. Filing waits until VISUAL closes them."
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def observe(settings: Settings, seen: Mapping[WoKey, str]) -> dict[str, Any]:
"""The feed's observe step for the given leg statuses. Returns the run's counts."""
with app_conn(settings) as conn:
run = apply._Run(conn, None, None, None, date.today())
apply._observe(run, seen, None)
return dict(run.counts)
@pytest.mark.parametrize("route", ["databook", "shipping"])
def test_feed_closes_and_opens_a_visual_packet(shop: Shop, route: str) -> None:
pkt = at_complete(shop, route=route)
pid = pkt["id"]
set_feed_legs(shop.settings, pid, [leg("L670001", st="R"), leg("L670002", st="R")])
c = shop("clerk.clara")
# one leg closed: still open in VISUAL, nothing set
counts = observe(shop.settings, {W1: "C", W2: "R"})
assert counts["closed_observed"] == 0 and counts["errors"] == 0, counts
view = fresh(c, pid)
assert view["vclosed"] is None and view["blocked"]["file"] == [OPEN_W2]
# every leg closed: the feed sets vclosed (no person) and the file opens
counts = observe(shop.settings, {W1: "C", W2: "C"})
assert counts["closed_observed"] == 1 and counts["errors"] == 0, counts
view = fresh(c, pid)
assert view["vclosed"]["at"] is not None and view["vclosed"]["by"] is None
assert view["vclose_check"]["closed_at"] is not None and view["vclose_check"]["agrees"] is True
assert "file" in view["can"]
assert find_log(c, pid, "Closed in VISUAL (observed)")
assert observe(shop.settings, {W1: "C", W2: "C"})["closed_observed"] == 0 # nothing changed: no write
# a leg open again: the feed clears it and the file waits
observe(shop.settings, {W1: "C", W2: "R"})
view = fresh(c, pid)
assert view["vclosed"] is None and view["vclose_check"]["closed_at"] is None
assert view["blocked"]["file"] == [OPEN_W2]
assert find_log(c, pid, "Open again in VISUAL (observed)")["note"] == "L670002/001"
refused(file_packet(shop, view), 403, "precondition", OPEN_W2)
# closed and cancelled count as done
assert observe(shop.settings, {W1: "C", W2: "X"})["closed_observed"] == 1
out = ok(file_packet(shop, fresh(c, pid)))
assert out["status"] == "filed" and out["vclosed"]["by"] is None
# the feed leaves a filed packet alone
observe(shop.settings, {W1: "R", W2: "R"})
assert q1(shop.settings, "SELECT vclosed_at IS NOT NULL FROM packets WHERE id = %s", (pid,))[0] is True
+169
View File
@@ -0,0 +1,169 @@
"""Change C7 (Gitea issue 18): every received short needs the department supervisor's approval, one receipt at a time.
SHORT_APPROVE through POST /api/packets/{id}/shorts/receipts/{receipt_id}/approve: who (the supervisor of the
packet's department, Admin with an IT reason), the signature, one approval per receipt (409), the read model (who
received and who approved each receipt, shorts_unapproved, can), and the shorts_approved precondition on QC_ACCEPT
and FILE (not on QC_ACCEPT_AND_ROUTE). Nothing here reads VISUAL or M-Files."""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
import pytest
from closehelpers import at_complete, file_packet, q1, vclose
from fakedir import FakeDirectory
from pshelpers import app_conn, needs_pg
from qchelpers import accept, accept_route, finish_inspection, to_qc
from shophelpers import (
IT,
SIGN,
Shop,
draft,
find_log,
finish_stage,
fresh,
im,
line_of,
ok,
open_shop,
refused,
release,
start,
submit,
to_station,
to_warehouse,
)
from packetstation.settings import Settings
pytestmark = needs_pg
MSG = "Every short must be received and approved by the supervisor first."
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def approve(shop: Shop, pkt: dict[str, Any], receipt_id: int, by: str = "sup.wes", **body: Any) -> Any:
body.setdefault("sign", SIGN)
return shop(by).post(f"/api/packets/{pkt['id']}/shorts/receipts/{receipt_id}/approve", json=body)
def receive(shop: Shop, pkt: dict[str, Any], item: int, qty: int, heat: str, by: str = "wh.wade") -> dict[str, Any]:
return ok(shop(by).post(f"/api/packets/{pkt['id']}/bom/{item}/short/receipts", json={"qty": qty, "heat": heat}))
def short_at_qc(shop: Shop) -> dict[str, Any]:
"""Items 1 and 2 pulled; item 3 short 2 of 4, one piece received (HT-31), one still open; through the shop
and at Q.C. with the inspection finished."""
pkt = to_warehouse(shop, draft(shop))
wade = shop("wh.wade")
pid = pkt["id"]
for item in (1, 2):
cur = line_of(pkt, item)
pkt = ok(wade.patch(f"/api/packets/{pid}/bom/{item}", json={"pulled": True}, headers=im(cur["row_version"])))
cur = line_of(pkt, item)
pkt = ok(wade.patch(f"/api/packets/{pid}/bom/{item}", json={"heat": "HT-1"}, headers=im(cur["row_version"])))
pkt = ok(wade.post(f"/api/packets/{pid}/bom/3/short", json={"qty": 2, "reason": "On order", "expect_open": False}))
pkt = receive(shop, pkt, 3, 1, "HT-31")
pkt = start(shop, to_station(shop, release(shop, pkt)))
pkt = submit(shop, finish_stage(shop, pkt, welds=1))
return finish_inspection(shop, to_qc(shop, pkt))
def test_approve_receipts_then_accept(shop: Shop) -> None:
pkt = short_at_qc(shop)
pid = pkt["id"]
assert pkt["shorts_unapproved"] == 2 # one open short and one receipt without approval
assert pkt["blocked"]["qc_accept"] == [MSG]
assert "qc_accept_and_route" in pkt["can"] and "qc_accept_and_route" not in pkt["blocked"]
refused(accept(shop, pkt), 403, "precondition", MSG)
[short] = pkt["shorts"]
assert (short["item"], float(short["qty"]), float(short["qty_open"]), short["closed_at"]) == (3, 2, 1, None)
[r1] = short["receipts"]
assert float(r1["qty"]) == 1 and r1["heat"] == "HT-31" and r1["by"]["display_name"] and r1["approval"] is None
assert line_of(pkt, 3)["short"]["receipts"][0]["approval"] is None
# who: the supervisor of the packet's department; a lead, Q.C., the warehouse and Clerical are refused
wes = fresh(shop("sup.wes"), pid)
assert "short_approve" in wes["can"]
for sam in ("lead.lou", "weld.andy", "qc.quinn", "wh.wade", "clerk.leo"):
assert "short_approve" not in fresh(shop(sam), pid)["can"], sam
refused(approve(shop, pkt, r1["id"], by=sam), 403, "forbidden", "Your access doesn't allow this step.")
refused(approve(shop, pkt, r1["id"], by="admin.ann"), 400, "it_reason_required")
# a signature is required; an unknown receipt is 404
refused(approve(shop, pkt, r1["id"], sign=None), 400, "signature_required")
refused(approve(shop, pkt, r1["id"] + 100000), 404, "not_found")
out = ok(approve(shop, pkt, r1["id"], note="Heat matches the issue ticket"))
a = out["shorts"][0]["receipts"][0]["approval"]
assert a["by"]["id"] == str(shop.uid("sup.wes")) and a["by"]["display_name"], a
assert a["note"] == "Heat matches the issue ticket" and a["at"] and a["dept_code"] == "03"
assert line_of(out, 3)["short"]["receipts"][0]["approval"]["note"] == "Heat matches the issue ticket"
assert out["shorts_unapproved"] == 1 # the short is still open
log = find_log(shop("sup.wes"), pid, "Short approved · item 3")
assert log["note"] == "1 pc · heat HT-31 · Heat matches the issue ticket" and log["signed"] is True
row = q1(shop.settings, "SELECT dept_code, signature_id FROM short_approvals WHERE receipt_id = %s", (r1["id"],))
assert row[0] == "03" and row[1] is not None
# one approval per receipt
refused(approve(shop, pkt, r1["id"]), 409, "already_approved", "This receipt is already approved.")
# the warehouse receives the last piece: the short closes, the new receipt waits for approval
out = receive(shop, out, 3, 1, "HT-32")
assert out["shorts"][0]["closed_at"] is not None and line_of(out, 3)["short"] is None
assert out["shorts_unapproved"] == 1 and fresh(shop("qc.quinn"), pid)["blocked"]["qc_accept"] == [MSG]
r2 = out["shorts"][0]["receipts"][1]
assert r2["heat"] == "HT-32" and r2["approval"] is None
# Admin approves through the bypass with an IT reason
out = ok(approve(shop, out, r2["id"], by="admin.ann", it_reason=IT))
assert out["shorts_unapproved"] == 0 and "qc_accept" not in fresh(shop("qc.quinn"), pid)["blocked"]
done = ok(accept(shop, fresh(shop("qc.quinn"), pid)))
assert done["status"] == "complete"
# an approval needs no open step: still open to the supervisor at complete, but nothing is left to approve
refused(approve(shop, done, r2["id"]), 409, "already_approved")
def test_accept_and_route_does_not_wait_for_the_shorts(shop: Shop) -> None:
pkt = short_at_qc(shop)
out = ok(accept_route(shop, pkt, "09"))
assert out["status"] == "ready" and out["dept"]["code"] == "09" and out["shorts_unapproved"] == 2
# the packet's department is now 09: the 03 supervisor no longer approves, a 09 supervisor would
r1 = out["shorts"][0]["receipts"][0]
refused(approve(shop, out, r1["id"]), 403, "forbidden")
def test_file_waits_for_the_approval_and_the_filed_lock(shop: Shop) -> None:
"""A packet completed before C7 with a receipt nobody approved: FILE shows and refuses it until approved."""
pkt = at_complete(shop)
pid = pkt["id"]
with app_conn(shop.settings) as conn:
line = conn.execute("SELECT id FROM bom_lines WHERE packet_id = %s AND item = 3", (pid,)).fetchone()[0]
sid = conn.execute(
"INSERT INTO bom_shorts (packet_id, bom_line_id, qty, qty_open, reason, closed_at)"
" VALUES (%s, %s, 1, 0, 'On order', now()) RETURNING id",
(pid, line),
).fetchone()[0]
rid = conn.execute(
"INSERT INTO short_receipts (short_id, packet_id, qty, heat) VALUES (%s, %s, 1, 'HT-77') RETURNING id",
(sid, pid),
).fetchone()[0]
pkt = vclose(shop, fresh(shop("clerk.clara"), pid))
assert pkt["shorts_unapproved"] == 1 and pkt["blocked"]["file"] == [MSG]
refused(file_packet(shop, pkt), 403, "precondition", MSG)
[card] = ok(shop("clerk.clara").get(f"/api/packets?unit_id={pkt['unit']['id']}"))["packets"]
assert card["shorts_unapproved"] == 1 and card["blocked"]["file"] == [MSG]
ok(approve(shop, pkt, rid))
out = ok(file_packet(shop, fresh(shop("clerk.clara"), pid)))
assert out["status"] == "filed"
# filed: nobody approves any more
refused(approve(shop, out, rid, by="admin.ann", it_reason=IT), 409, "already_approved")
with app_conn(shop.settings) as conn:
rid2 = conn.execute(
"INSERT INTO short_receipts (short_id, packet_id, qty, heat) VALUES (%s, %s, 1, 'HT-78') RETURNING id",
(sid, pid),
).fetchone()[0]
refused(approve(shop, out, rid2), 403, "filed")
+161
View File
@@ -0,0 +1,161 @@
"""Change C8 (Gitea issue 23): checklists per department.
The department supervisor edits their own department's list, Admin edits any, a lead, a worker and Q.C. do not. A
save carries the version (`base` or If-Match) and is audited. A stage freezes its list at START, so a change reaches
only stages not yet started. A department without its own list uses the default for its kind. Nothing here reads
VISUAL or M-Files."""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
import pytest
from fakedir import FakeDirectory
from shophelpers import (
PLAIN_KEYS,
WELD_KEYS,
Shop,
add_weld,
fresh,
ok,
one,
open_shop,
ready_packet,
refused,
set_filler,
start,
tick_all,
to_station,
)
from packetstation.domain import checklists
from packetstation.settings import Settings
URL = "/api/checklists"
NEW_LIST = [
{"group": "Before welding", "key": "dwg", "text": "Drawing {drawing} Rev {rev} on the bench"},
{"group": "Before welding", "key": "purge", "text": "Back purge set"},
{"group": "After welding", "key": "vt", "text": "Visual done"},
]
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def keys(pkt: dict[str, Any]) -> list[str]:
return [i["key"] for i in pkt["checklist_items"]]
def put(shop: Shop, sam: str, dept: str, items: Any, base: int | None) -> Any:
body: dict[str, Any] = {"items": items}
if base is not None:
body["base"] = base
return shop(sam).put(f"{URL}/{dept}", json=body)
def test_list_and_get(shop: Shop) -> None:
rows = {r["dept_code"]: r for r in ok(shop("sup.wes").get(URL))}
assert "03" in rows and "CL" not in rows and "QC" not in rows
assert rows["03"]["editable"] is True and rows["03"]["weld"] is True and rows["03"]["custom"] is False
assert rows["03"]["version"] == 0 and rows["03"]["dept_name"] == "Weld Parts"
assert not any(r["editable"] for code, r in rows.items() if code != "03")
assert all(r["editable"] for r in ok(shop("admin.ann").get(URL)))
for sam in ("lead.lou", "weld.andy", "qc.quinn", "clerk.clara"):
assert not any(r["editable"] for r in ok(shop(sam).get(URL))), sam
got = ok(shop("qc.quinn").get(f"{URL}/03"))
assert [i["key"] for i in got["items"]] == list(WELD_KEYS) and got["defaults"] == got["items"]
assert got["editable"] is False and got["custom"] is False
assert "{drawing}" in got["items"][0]["text"] # unfilled
plain = ok(shop("sup.wes").get(f"{URL}/09"))
assert [i["key"] for i in plain["items"]] == list(PLAIN_KEYS) and plain["editable"] is False
refused(shop("sup.wes").get(f"{URL}/QC"), 404, "not_found")
def test_who_may_save(shop: Shop) -> None:
for sam in ("lead.lou", "weld.andy", "qc.quinn", "clerk.clara", "pm.paula"):
refused(put(shop, sam, "03", NEW_LIST, 0), 403, "forbidden")
refused(put(shop, "sup.wes", "09", NEW_LIST, 0), 403, "forbidden") # not his department
out = ok(put(shop, "sup.wes", "03", NEW_LIST, 0))
assert out["custom"] is True and out["version"] == 1 and [i["key"] for i in out["items"]] == ["dwg", "purge", "vt"]
# Admin edits any department
out = ok(put(shop, "admin.ann", "09", NEW_LIST, 0))
assert out["custom"] is True and out["dept_code"] == "09"
# the other departments keep the default for their kind
assert ok(shop("sup.wes").get(f"{URL}/04"))["custom"] is False
row = one(
shop.settings,
"SELECT count(*) FROM audit_events WHERE action = 'checklists.dept_updated' AND subject_id = 'checklist:03'",
)
assert row is not None and row[0] == 1
def test_version_cas_and_validation(shop: Shop) -> None:
ok(put(shop, "sup.wes", "03", NEW_LIST, 0))
stale = refused(put(shop, "sup.wes", "03", NEW_LIST, 0), 409, "version_conflict")
assert stale["current"]["version"] == 1 and stale["current"]["custom"] is True
refused(put(shop, "sup.wes", "03", NEW_LIST, None), 428, "if_match_required")
r = shop("sup.wes").put(f"{URL}/03", json={"items": NEW_LIST}, headers={"If-Match": '"1"'})
assert ok(r)["version"] == 2
bad = refused(put(shop, "sup.wes", "03", [{"group": "G", "key": "Bad-Key", "text": "t"}], 2), 422, "bad_checklist")
assert "key" in bad["detail"]
refused(put(shop, "sup.wes", "03", [], 2), 422, "bad_checklist")
refused(put(shop, "sup.wes", "03", [{"group": "G", "key": "x", "text": "{stamp}"}], 2), 422, "bad_checklist")
# null sets the department back to the default for its kind
back = ok(put(shop, "sup.wes", "03", None, 2))
assert back["custom"] is False and back["version"] == 3 and [i["key"] for i in back["items"]] == list(WELD_KEYS)
def test_a_change_reaches_only_stages_not_started(shop: Shop) -> None:
andy = shop("weld.andy")
started = start(shop, to_station(shop, ready_packet(shop, drawing="D-7007", rev="A")))
assigned = to_station(shop, ready_packet(shop), station="Stall 2")
waiting = ready_packet(shop)
ok(put(shop, "sup.wes", "03", NEW_LIST, 0))
# the started stage keeps the default it started with
assert keys(fresh(andy, started["id"])) == list(WELD_KEYS)
refused(andy.put(f"/api/packets/{started['id']}/stage/checklist/purge", json={"done": True}), 422, "invalid_key")
# the stages not yet started read the new list
assert keys(fresh(andy, assigned["id"])) == ["dwg", "purge", "vt"]
assert keys(fresh(shop("sup.wes"), waiting["id"])) == ["dwg", "purge", "vt"]
# START freezes: a later change does not reach it
later = start(shop, assigned)
ok(put(shop, "sup.wes", "03", [*NEW_LIST, {"group": "After welding", "key": "tag", "text": "Tagged"}], 1))
assert keys(fresh(andy, later["id"])) == ["dwg", "purge", "vt"]
assert keys(fresh(shop("sup.wes"), waiting["id"])) == ["dwg", "purge", "vt", "tag"]
stage = one(
shop.settings,
"SELECT checklist_template FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL",
(later["id"],),
)
assert stage is not None and [i["key"] for i in stage[0]] == ["dwg", "purge", "vt"]
# completeness reads the frozen list: three ticks, a weld and the filler let it submit
out = tick_all(shop, later, keys=("dwg", "purge", "vt"))
out = set_filler(shop, add_weld(shop, out))
assert "submit" in fresh(andy, out["id"])["can"]
# the started packet still needs the default keys
assert "submit" not in fresh(andy, started["id"])["can"]
# the data book's checklist section reads the frozen list too
with shop_conn(shop) as conn:
t = checklists.stage_templates(conn, stage_id(shop, later["id"]), "03")
assert [i["key"] for i in t["weld"]] == ["dwg", "purge", "vt"]
live = checklists.stage_templates(conn, None, "03")
assert [i["key"] for i in live["weld"]] == ["dwg", "purge", "vt", "tag"]
def stage_id(shop: Shop, packet_id: int) -> int:
row = one(shop.settings, "SELECT id FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (packet_id,))
assert row is not None
return int(row[0])
def shop_conn(shop: Shop) -> Any:
from pshelpers import app_conn
return app_conn(shop.settings)
+45 -32
View File
@@ -1,7 +1,7 @@
"""Phase 7 close and file through the API: the VISUAL close and the filing as every role, the feed's open work
orders (the note suffix, the 409 and the confirm), the data book filed with the seal, the reopen that keeps the
broken seal and its data book, the refile with a second seal, a failed data book build that files nothing, and a
hand packet with no feed state.
orders (a block on both routes since C5; the feed closes a VISUAL packet), the data book filed with the seal, the
reopen that keeps the broken seal and its data book, the refile with a second seal, a failed data book build that
files nothing, and a hand packet with no feed state.
The feed state is written by SQL in the shape feed/apply.py writes it. Nothing here reads VISUAL or M-Files."""
@@ -132,6 +132,7 @@ def test_vclosed_and_file_refused_outside_complete(shop: Shop) -> None:
def test_shipping_route_files_without_the_visual_close(shop: Shop) -> None:
"""A hand packet on the Shipping route: no feed state, no Clerical close."""
pkt = at_complete(shop, route="shipping")
r = shop("clerk.clara").post(f"/api/packets/{pkt['id']}/vclosed", json={"closed": True})
assert r.status_code in (403, 409) and r.json()["code"] in ("wrong_status", "precondition"), r.text
@@ -140,10 +141,18 @@ def test_shipping_route_files_without_the_visual_close(shop: Shop) -> None:
assert out["seal"]["databook"]["pages"] > 0
# ---------------------------------------------------------------- the feed's open work orders
# ---------------------------------------------------------------- the feed's open work orders (C5)
MSG_FEED_CLOSES = "The VISUAL feed marks this job closed when it sees every work order closed."
MSG_FEED_WAIT = "Filing waits until the VISUAL feed sees the job closed."
def test_vclose_check_and_the_open_wo_note(shop: Shop) -> None:
def open_msg(wos: str) -> str:
return f"VISUAL still shows work orders open on this packet: {wos}. Filing waits until VISUAL closes them."
def test_visual_packet_open_in_visual_cannot_be_closed_or_filed(shop: Shop) -> None:
"""C5: Clerical cannot mark a VISUAL packet closed; the open work orders block the file, and say why."""
pkt = at_complete(shop)
pid = pkt["id"]
set_feed_legs(shop.settings, pid, [leg("L670001", st="C"), leg("L670002", st="R")])
@@ -151,54 +160,58 @@ def test_vclose_check_and_the_open_wo_note(shop: Shop) -> None:
vc = view["vclose_check"]
assert vc["open_wos"] == ["L670002/001"] and vc["closed_at"] is None and vc["observed_at"] is not None
assert vc["agrees"] is True # neither closed
pkt = vclose(shop, view)
assert pkt["vclose_check"]["agrees"] is False
row = find_log(shop("clerk.clara"), pid, "Job closed in VISUAL")
assert row["note"] == "L670001, L670002 · VISUAL still shows open: L670002/001", row
assert "set_vclosed" not in view["can"] and "file" not in view["can"]
assert view["blocked"]["file"] == [open_msg("L670002/001")]
refused(shop("clerk.clara").post(f"/api/packets/{pid}/vclosed", json={"closed": True}), 403, "wrong_status")
refused(file_packet(shop, view), 403, "precondition", open_msg("L670002/001"))
def test_vclose_check_closed_by_the_feed(shop: Shop) -> None:
pkt = at_complete(shop)
pid = pkt["id"]
set_feed_legs(shop.settings, pid, [leg("L670001", st="C"), leg("L670002", st="C")], closed=True)
vc = fresh(shop("clerk.clara"), pid)["vclose_check"]
assert vc["open_wos"] == [] and vc["closed_at"] is not None and vc["agrees"] is False
pkt = vclose(shop, fresh(shop("clerk.clara"), pid))
assert pkt["vclose_check"]["agrees"] is True
assert find_log(shop("clerk.clara"), pid, "Job closed in VISUAL")["note"] == "L670001, L670002"
out = ok(file_packet(shop, pkt))
assert out["seal"]["open_wos"] is None
view = fresh(shop("clerk.clara"), pid)
vc = view["vclose_check"]
assert vc["open_wos"] == [] and vc["closed_at"] is not None and vc["agrees"] is True
assert view["vclosed"]["at"] is not None and view["vclosed"]["by"] is None # the feed, not a person
assert "file" in view["can"] and "file" not in view["blocked"]
out = ok(file_packet(shop, view))
assert out["status"] == "filed" and out["seal"]["open_wos"] is None
def test_vclose_check_before_the_feed_looked(shop: Shop) -> None:
pkt = at_complete(shop)
set_feed_legs(shop.settings, pkt["id"], None)
vc = fresh(shop("clerk.clara"), pkt["id"])["vclose_check"]
view = fresh(shop("clerk.clara"), pkt["id"])
vc = view["vclose_check"]
assert vc is not None and vc["observed_at"] is None and vc["open_wos"] == []
assert view["blocked"]["file"] == [MSG_FEED_WAIT]
def test_file_with_open_work_orders_needs_the_confirm(shop: Shop) -> None:
pkt = at_complete(shop)
@pytest.mark.parametrize("route", ["databook", "shipping"])
def test_file_blocked_while_visual_shows_work_orders_open(shop: Shop, route: str) -> None:
"""C5: a block on both routes, not a warning. The old confirm_open_wos is ignored. A close set before C5
(vclosed by hand on a VISUAL packet) does not pass while the feed shows work orders open."""
pkt = at_complete(shop, route=route)
pid = pkt["id"]
set_feed_legs(shop.settings, pid, [leg("L670001", st="R"), leg("L670002", lot="7A", st="R")])
pkt = vclose(shop, fresh(shop("clerk.clara"), pid))
with_hand_close = route == "databook"
if with_hand_close:
q1(shop.settings, "UPDATE packets SET vclosed_at = now() WHERE id = %s RETURNING id", (pid,))
pkt = fresh(shop("clerk.clara"), pid)
sigs = q1(shop.settings, "SELECT count(*) FROM signatures")[0]
body = refused(file_packet(shop, pkt), 409, "open_work_orders")
assert body["work_orders"] == ["L670001/001", "L670002/7A"]
assert body["detail"] == (
"VISUAL still shows 2 work orders open on this packet: L670001/001, L670002/7A. File anyway?"
)
msg = open_msg("L670001/001, L670002/7A")
refused(file_packet(shop, pkt), 403, "precondition", msg)
refused(file_packet(shop, pkt, confirm_open_wos=True), 403, "precondition", msg)
# nothing written, no signature taken
assert q1(shop.settings, "SELECT count(*) FROM signatures")[0] == sigs
assert q1(shop.settings, "SELECT count(*) FROM packet_seals WHERE packet_id = %s", (pid,))[0] == 0
assert fresh(shop("clerk.clara"), pid)["status"] == "complete"
refused(file_packet(shop, pkt, confirm_open_wos=False), 409, "open_work_orders")
out = ok(file_packet(shop, pkt, confirm_open_wos=True, note="Customer pickup today"))
assert out["status"] == "filed" and out["seal"]["open_wos"] == ["L670001/001", "L670002/7A"]
row = q1(shop.settings, "SELECT open_wos FROM packet_seals WHERE packet_id = %s", (pid,))
assert row[0] == ["L670001/001", "L670002/7A"]
note = find_log(shop("clerk.clara"), pid, "Filed in the data book")["note"]
assert note == "Customer pickup today · Filed with work orders open in VISUAL: L670001/001, L670002/7A", note
# the feed sees VISUAL closed: the packet files, on either route
set_feed_legs(shop.settings, pid, [leg("L670001", st="C"), leg("L670002", lot="7A", st="X")], closed=True)
out = ok(file_packet(shop, fresh(shop("clerk.clara"), pid), confirm_open_wos=True, note="Customer pickup today"))
assert out["status"] == "filed" and out["seal"]["open_wos"] is None
assert find_log(shop("clerk.clara"), pid, "Filed in the data book")["note"] == "Customer pickup today"
def test_cancelled_legs_do_not_count_as_open(shop: Shop) -> None:
+29 -1
View File
@@ -204,7 +204,12 @@ def test_mine_per_screen(world: FilterWorld) -> None:
assert ids(world, "multi.mia", flags="mine") == clerical # Clerical comes first in the screen order
stall1 = shop.stall("03", "Stall 1")
assert ids(world, "weld.andy", flags="mine", mine_screen="stall", mine_station_id=stall1) == world.ids("a1")
assert ids(world, "weld.andy", flags="mine", mine_screen="stall") == set() # no station picked
# change C11: the stall's Mine is the packets assigned to me, at any station; a picked station is not needed
assert ids(world, "weld.andy", flags="mine", mine_screen="stall") == world.ids("a1")
stall2 = shop.stall("03", "Stall 2")
assert ids(world, "weld.andy", flags="mine", mine_screen="stall", mine_station_id=stall2) == world.ids("a1")
assert ids(world, "dup.dave", flags="mine", mine_screen="stall") == world.ids("a2")
assert ids(world, "admin.ann", flags="mine", mine_screen="stall") == set() # nothing assigned to her
assert ids(world, "qc.quinn", flags="mine", mine_screen="qc") == world.ids("q1")
assert ids(world, "pm.paula", flags="mine") == world.every
assert ids(world, "admin.ann", flags="mine", mine_screen="admin") == world.every
@@ -367,6 +372,29 @@ def test_stall_board_filters(world: FilterWorld) -> None:
assert get_ok(andy, "/api/shop/stall", station_id=stall1, status="review")["mine"] == []
def test_stall_mine_across_stations(world: FilterWorld) -> None:
"""Change C11: GET /api/stall/mine, the packets assigned to the signed-in person, each with its station."""
shop = world.shop
andy = get_ok(shop("weld.andy"), "/api/stall/mine")
assert andy["station"] is None and andy["empty"] is None and "facets" in andy
assert [c["id"] for c in andy["mine"]] == [world.k["a1"]] and andy["submitted"] == []
card = andy["mine"][0]
assert card["station_id"] == shop.stall("03", "Stall 1") and card["station_name"] == "Stall 1"
dave = get_ok(shop("dup.dave"), "/api/stall/mine")
got = [(c["id"], c["station_name"], c["status"]) for c in dave["mine"]]
assert got == [(world.k["a2"], "Stall 2", "in_progress")]
# the find bar narrows
assert get_ok(shop("weld.andy"), "/api/stall/mine", q="nothing-matches-this")["mine"] == []
# nobody assigned: the empty state names the supervisor
for sam in ("lead.lou", "qc.quinn"):
none = get_ok(shop(sam), "/api/stall/mine")
assert none["mine"] == [] and none["submitted"] == []
assert none["empty"] == "Nothing is assigned to you now. Your supervisor assigns packets to you."
# a station in the request is a find bar narrowing, never a pick that widens
other = get_ok(shop("weld.andy"), "/api/stall/mine", station_id=shop.stall("03", "Stall 2"))
assert other["mine"] == []
def test_warehouse_board_filters_keep_the_kpis(world: FilterWorld) -> None:
wade = world.shop("wh.wade")
base = get_ok(wade, "/api/warehouse/board")
+22 -8
View File
@@ -2,9 +2,9 @@
copy of sheet 1, a heat table, two welds, an MTR PDF and an MTR PNG, a short with a receipt, a packet document, a
Q.C. folder document, the closed stage and the Q.C. accept.
The index (seven sections in order, the expected rows), the page count, the cover, the index page, the void
sheet, the traceability page, the warning rows, the metadata, the stamp on every page and its signature, and the
stamp per mode. Nothing here reads VISUAL or M-Files."""
The index (six sections in order, the expected rows; no history pages since change C6), the page count, the cover,
the index page, the void sheet, the traceability page, the warning rows, the metadata, the stamp on every page and
its signature, and the stamp per mode. Nothing here reads VISUAL or M-Files."""
from __future__ import annotations
@@ -21,6 +21,7 @@ import pytest
from closehelpers import (
HEATS,
RECEIPT_HEAT,
add_doc,
builder_conn,
delete_asset_file,
flat,
@@ -43,7 +44,7 @@ pytestmark = needs_pg
NOW = datetime(2026, 10, 17, 21, 4, tzinfo=UTC)
SEAL = {"hash": "ab12cd34ef56" + "0" * 52, "key_id": "k1234567", "sealed_at": NOW}
SECTIONS = [1, 2, 3, 4, 5, 6, 7]
SECTIONS = [1, 2, 3, 4, 5, 6]
SIG_RE = re.compile(r"stamp ([0-9a-f]{16})")
DUMP_DIR = os.environ.get("DATABOOK_DUMP_DIR") # off unless set
@@ -98,13 +99,14 @@ def test_data_book_preview(shop: Shop) -> None:
assert len(reader.pages) == built.pages
rows = index(built)
# the seven sections, in order, each present
# the six sections, in order, each present
seen = [r["section"] for r in rows]
assert seen == sorted(seen) and sorted(set(seen)) == SECTIONS, seen
s1 = docs_in(rows, 1)
assert sum("approved drawing" in d for d in s1) == 2, s1
assert any(d.startswith("SUPERSEDED - VOID by CO-1") for d in s1), s1
assert s1[-1] == "Change order record", s1
drawing_rows = [r for r in rows if r["section"] == 1 and "approved drawing" in str(r["doc"])]
assert all(r["ref"] == "D-5001" and r["rev"] == "RB" for r in drawing_rows), drawing_rows
@@ -131,7 +133,8 @@ def test_data_book_preview(shop: Shop) -> None:
mtr_rows = [r for r in rows if r["section"] == 6]
assert mtr_rows[0]["ref"] == HEATS[1]
assert docs_in(rows, 7) == ["Packet record history"]
# change C6: no history pages; the book is the one copy and goes to the customer
assert not any("history" in str(r["doc"]).lower() for r in rows), rows
# pages: the parts plus the front matter (cover and index); page numbers point into the book
parts = sum(int(r["count"] or 0) for r in rows)
@@ -167,8 +170,10 @@ def test_data_book_preview(shop: Shop) -> None:
qc_page = next(r for r in rows if r["section"] == 4)["page"]
qc_lines = texts[int(qc_page) - 1].splitlines()
assert "AM" not in qc_lines and "PM" not in qc_lines, qc_lines # the date and time stay on one line
hist_page = next(r for r in rows if r["section"] == 7)["page"]
assert "CO-1 " in flat(texts[int(hist_page) - 1]) and "documents changed" in flat(texts[int(hist_page) - 1])
co_page = next(r for r in rows if r["doc"] == "Change order record")["page"]
assert "CO-1 " in flat(texts[int(co_page) - 1]) and "documents changed" in flat(texts[int(co_page) - 1])
whole = flat(" ".join(texts))
assert "RECORD HISTORY" not in whole.upper() and "Every action taken on this packet" not in whole
# metadata
meta = reader.metadata
@@ -230,6 +235,15 @@ def test_data_book_with_an_itp_document_has_no_itp_warning(shop: Shop) -> None:
assert not any(r["doc"] == "No signed ITP filed for this unit" for r in s5)
def test_data_book_carries_an_ncr_document(shop: Shop) -> None:
"""Change C2 asks the NCR document Q.C. uploads to be in the data book: every packet document kind is in
section 5, the NCR kind included."""
pkt = rich_complete(shop, mtrs=False)
add_doc(shop, pkt, title="NCR 2026-0017", kind="ncr")
s5 = docs_in(index(build(shop, pkt["id"])), 5)
assert any(d.startswith("NCR") and "NCR 2026-0017" in d for d in s5), s5
# ---------------------------------------------------------------- phase 8: against the old build's book
+5 -2
View File
@@ -95,6 +95,8 @@ def test_find_bar_boards_and_pm_reports(shop: Shop) -> None:
assert qc["kpis"]["waiting"] == 0 and "facets" in qc
stall = ok(shop("weld.andy").get("/api/shop/stall", params={"station_id": shop.stall(), "flags": "mine"}))
assert [c["id"] for c in stall["mine"]] == [p_st["id"]]
mine = ok(shop("weld.andy").get("/api/stall/mine")) # change C11: no station picked
assert [c["id"] for c in mine["mine"]] == [p_st["id"]] and mine["mine"][0]["station_id"] == shop.stall()
# Project Management
pmc = shop("pm.paula")
@@ -111,7 +113,8 @@ def test_find_bar_boards_and_pm_reports(shop: Shop) -> None:
assert [t["key"] for t in ag["tables"]] == ["aging_steps", "aging_open", "aging_history"]
hr = ok(pmc.get("/api/pm/hours"))
over_rows = hr["tables"][3]["rows"]
assert [r["pid"] for r in over_rows] == [p_st["id"]], hr
# change C1: the timer is over its target but there are no ticket hours, so it is not over target
assert over_rows == [], hr
ql = ok(pmc.get("/api/pm/quality"))
assert [len(t["rows"]) for t in ql["tables"]][:4] == [1, 1, 1, 1], ql
fd = ok(pmc.get("/api/pm/find", params={"sort": "project", "project_id": project_id}))
@@ -134,4 +137,4 @@ def test_find_bar_boards_and_pm_reports(shop: Shop) -> None:
kinds = [x[0] for x in conn.execute("SELECT kind FROM print_log ORDER BY id")]
acts = [x[0] for x in conn.execute("SELECT action FROM audit_events WHERE action = 'report exported'")]
assert kinds == ["report-csv", "report-pdf"] and len(acts) == 2
assert shop("clerk.clara").get("/api/pm/overview").status_code == 200
assert shop("clerk.clara").get("/api/pm/overview").status_code == 403 # change C10: PM and Admin only
+16 -5
View File
@@ -119,7 +119,7 @@ def test_overview_summary(world: PmWorld) -> None:
slow = [x for x in lines if x.startswith("Slowest step right now: ")]
assert slow and slow[0].startswith(f'Slowest step right now: "{STEP_LABEL["wh_hold"]}", averaging 5')
assert "Needs attention: 1 warehouse hold, 1 open change order, 1 open short, 1 Q.C. reject." in lines
assert "1 packet is over target hours." in lines
assert "1 packet is over target hours on the VISUAL tickets." in lines
def test_overview_attention(world: PmWorld) -> None:
@@ -217,7 +217,7 @@ def test_hours(world: PmWorld) -> None:
assert len(dept) == 1 and "Weld Parts" in dept[0]["cells"][0]
# counted: the running packet, and every packet past review with target hours
assert dept[0]["cells"][1] == "6"
assert dept[0]["cells"][3] == "3.2" # ticket hours: the running packet's VISUAL rows only
assert dept[0]["cells"][3] == "5.2" # ticket hours: the running packet's VISUAL rows only
assert dept[0]["cells"][4] == "27.0" # 6 x 4.5
assert dept[0]["cells"][7] == "1"
over = table(out, "hours_over")["rows"]
@@ -226,7 +226,8 @@ def test_hours(world: PmWorld) -> None:
# 4 h stored plus the running span (2 h when the world was built, more by now)
timer = float(cells[3])
assert 6.0 <= timer < 8.0, cells
assert cells[4] == "3.2" and cells[5] == "4.5" and cells[6] == f"+{timer - 4.5:.1f} h"
# over by the ticket hours (C1), not the timer
assert cells[4] == "5.2" and cells[5] == "4.5" and cells[6] == "+0.7 h"
assert "Andy Welder" in cells[1] or "Stall 1" in cells[1]
workers = {r["cells"][0]: r for r in table(out, "hours_worker")["rows"]}
assert set(workers) == {"Andy Welder", "—"}
@@ -343,13 +344,23 @@ def test_scope_follows_the_project(world: PmWorld) -> None:
assert all(table(q, k)["rows"] == [] for k in TAB_TABLES["quality"])
@pytest.mark.parametrize("sam", ["pm.paula", "clerk.clara", "weld.andy"])
def test_every_signed_in_role_reads(world: PmWorld, sam: str) -> None:
@pytest.mark.parametrize("sam", ["pm.paula", "admin.ann"])
def test_pm_and_admin_read(world: PmWorld, sam: str) -> None:
for what in ENDPOINTS:
r = world.shop(sam).get(f"/api/pm/{what}")
assert r.status_code == 200, (sam, what, r.text)
@pytest.mark.parametrize("sam", ["clerk.clara", "weld.andy", "qc.quinn", "wh.wade", "sup.wes"])
def test_other_roles_are_refused(world: PmWorld, sam: str) -> None:
# change C10: PS-ProjectMgmt and Admin only
for what in [*ENDPOINTS, "export.csv", "report.pdf"]:
r = world.shop(sam).get(f"/api/pm/{what}")
assert r.status_code == 403, (sam, what, r.text)
me = get_ok(world.shop(sam), "/api/auth/me")
assert "pm" not in me.get("screens", []), me
def test_unsigned_is_refused_and_nothing_writes(world: PmWorld) -> None:
c = anon(world.shop)
for what in ENDPOINTS:
+2 -1
View File
@@ -178,7 +178,8 @@ def test_export_needs_a_session(world: PmWorld) -> None:
c = anon(world.shop)
assert c.get("/api/pm/export.csv", params={"table": "all"}).status_code == 401
assert c.get("/api/pm/report.pdf").status_code == 401
assert world.shop("clerk.clara").get("/api/pm/export.csv", params={"table": "jobs"}).status_code == 200
assert world.shop("clerk.clara").get("/api/pm/export.csv", params={"table": "jobs"}).status_code == 403
assert world.shop("clerk.clara").get("/api/pm/report.pdf").status_code == 403
# ================================================================ PDF
+87 -2
View File
@@ -376,7 +376,12 @@ PRECONDITION_CASES: list[tuple[Action, str, str, dict[str, Any], dict[str, Any],
(A.QC_ACCEPT, "qc", "qc", {"qc_missing": ("weld exam",)}, {}, "qc_missing_empty"),
(A.QC_ACCEPT, "qc", "qc", {}, {"routed_to": "scrap"}, "route_given"),
(A.QC_ACCEPT_AND_ROUTE, "qc", "qc", {}, {"dept": "WH"}, "target_dept_given"),
(A.FILE, "complete", "clerical", {"vclosed": False}, {}, "vclosed_if_databook"),
(A.FILE, "complete", "clerical", {"vclosed": False}, {}, "closed_in_visual"),
(A.FILE, "complete", "clerical", {"itp_open": True}, {}, "itp_complete"),
(A.FILE, "complete", "clerical", {"shorts_unapproved": 1}, {}, "shorts_approved"),
(A.QC_ACCEPT, "qc", "qc", {"shorts_unapproved": 2}, {}, "shorts_approved"),
(A.QC_ACCEPT, "qc", "qc", {"ncr_required": True}, {}, "ncr_given"),
(A.QC_ACCEPT_AND_ROUTE, "qc", "qc", {"ncr_required": True}, {"ncr": "NCR-26-1"}, "ncr_given"),
(A.WH_SEND_BACK, "warehouse", "warehouse", {}, {"reason": "short"}, "reason_given"),
(A.QC_REJECT, "qc", "qc", {}, {"reason": None}, "reason_given"),
(A.REMOVE, "draft", "clerical", {}, {"reason": " x "}, "reason_given"),
@@ -396,7 +401,7 @@ def test_each_precondition_fails_alone(
x = {**FULL_EXTRA, **extra}
assert check(ROLES[role], state(status), action, extra=FULL_EXTRA).ok
d = check(ROLES[role], s, action, extra=x)
want = "reason_required" if name == "reason_given" else "precondition"
want = {"reason_given": "reason_required", "ncr_given": "ncr_required"}.get(name, "precondition")
assert (d.ok, d.code) == (False, want)
if name == "qc_missing_empty":
assert d.why == "Q.C. still needs: weld exam."
@@ -423,6 +428,76 @@ def test_vclosed_only_needed_for_the_data_book_route() -> None:
assert check(ROLES["clerical"], state("complete", routed_to="shipping", vclosed=False), A.FILE).ok
@pytest.mark.parametrize("route", ["databook", "shipping"])
def test_visual_packet_files_only_once_the_feed_saw_it_closed(route: str) -> None:
"""C5: a VISUAL packet waits for the feed on both routes; open work orders are named."""
clerk = ROLES["clerical"]
vis = state("complete", source_kind="visual", routed_to=route)
assert check(clerk, vis, A.FILE).ok
d = check(clerk, dataclasses.replace(vis, vclosed=False), A.FILE)
assert (d.code, d.why) == ("precondition", "Filing waits until the VISUAL feed sees the job closed.")
opened = dataclasses.replace(vis, visual_open_wos=("L670001/001", "L670002/7A"))
d = check(clerk, opened, A.FILE)
assert d.code == "precondition"
assert d.why == (
"VISUAL still shows work orders open on this packet: L670001/001, L670002/7A."
" Filing waits until VISUAL closes them."
)
assert can(clerk, opened)[1][A.FILE] == [d.why]
# never bypassed
assert check(ROLES["admin"], opened, A.FILE, it_reason=IT).code == "precondition"
def test_file_blocked_shows_every_close_reason() -> None:
s = state("complete", vclosed=False, itp_open=True, shorts_unapproved=1)
allowed, blocked = can(ROLES["clerical"], s)
assert A.FILE not in allowed
assert blocked[A.FILE] == [MESSAGES["closed_in_visual"], MESSAGES["itp_complete"], MESSAGES["shorts_approved"]]
assert MESSAGES["itp_complete"] == "Complete the unit's ITP first. Q.C. signs it off on the ITP page."
assert MESSAGES["shorts_approved"] == "Every short must be received and approved by the supervisor first."
def test_ncr_given_is_input_and_skipped_by_can() -> None:
"""C2: after a reject with no NCR, the accept carries a well-formed NCR. can() does not block on it."""
s = state("qc", ncr_required=True)
allowed, blocked = can(ROLES["qc"], s)
assert {A.QC_ACCEPT, A.QC_ACCEPT_AND_ROUTE} <= set(allowed) and A.QC_ACCEPT not in blocked
d = check(ROLES["qc"], s, A.QC_ACCEPT, extra=FULL_EXTRA)
assert (d.ok, d.code, d.why) == (False, "ncr_required", "Enter the NCR number of the reject first.")
assert check(ROLES["qc"], s, A.QC_ACCEPT, extra={**FULL_EXTRA, "ncr": "NCR-2026-0021"}).ok
assert check(ROLES["qc"], s, A.QC_ACCEPT_AND_ROUTE, extra={**FULL_EXTRA, "ncr": "ncr-2026-0021"}).ok
# not needed when no reject is waiting for one
assert check(ROLES["qc"], state("qc"), A.QC_ACCEPT, extra=FULL_EXTRA).ok
def test_shorts_block_the_accept_but_not_the_accept_and_route() -> None:
s = state("qc", shorts_unapproved=1)
_, blocked = can(ROLES["qc"], s)
assert blocked[A.QC_ACCEPT] == [MESSAGES["shorts_approved"]]
assert A.QC_ACCEPT_AND_ROUTE not in blocked
assert check(ROLES["qc"], s, A.QC_ACCEPT_AND_ROUTE, extra=FULL_EXTRA).ok
@pytest.mark.parametrize("status", STATUSES)
def test_short_approve_is_the_department_supervisor(status: str) -> None:
"""C7: the supervisor of the packet's department (a lead is not enough), any status but filed; Admin through
the bypass with an IT reason. A signature is required."""
s = state(status)
d = check(ROLES["sup03"], s, A.SHORT_APPROVE)
if status == "filed":
assert d.code == "filed"
return
assert d.ok and d.signature == "Approve a received short"
assert A.SHORT_APPROVE in can(ROLES["sup03"], s)[0]
for role in ("lead03", "worker03", "welder", "warehouse_sup", "qc_sup", "clerical_sup", "pm", "worker06"):
d = check(ROLES[role], s, A.SHORT_APPROVE)
assert (d.code, d.why) == ("forbidden", "Your access doesn't allow this step."), role
assert check(P(["shop:06:supervisor"]), s, A.SHORT_APPROVE).code == "forbidden"
assert check(ROLES["admin"], s, A.SHORT_APPROVE).code == "it_reason_required"
a = check(ROLES["admin"], s, A.SHORT_APPROVE, it_reason=IT)
assert a.ok and a.admin_bypass
def test_co_staged_if_dwg_change() -> None:
from packetstation.policy.preconditions import co_staged_if_dwg_change
@@ -468,6 +543,16 @@ def test_set_vclosed_needs_the_data_book_route() -> None:
assert check(ROLES["qc"], state("complete"), A.SET_VCLOSED).code == "forbidden"
def test_set_vclosed_is_for_a_hand_packet_only() -> None:
"""C5: the feed closes a VISUAL packet; Clerical confirms a hand packet only."""
for route in ("databook", "shipping"):
d = check(ROLES["clerical"], state("complete", source_kind="visual", routed_to=route), A.SET_VCLOSED)
assert (d.code, d.why) == (
"wrong_status",
"The VISUAL feed marks this job closed when it sees every work order closed.",
)
@pytest.mark.parametrize("status", STATUSES)
def test_set_databook_at_is_clerical_or_qc(status: str) -> None:
s = state(status)
+1 -1
View File
@@ -541,7 +541,7 @@ def test_api_co_hold_blocks_accept_not_reject(shop: Shop) -> None:
assert "qc_reject" in mine["can"]
refused(step(shop("qc.quinn"), mine, "qc_accept", routed_to="databook", sign=SIGN), 403, "co_hold", held)
refused(step(shop("qc.quinn"), mine, "qc_accept_and_route", dept="09", sign=SIGN), 403, "co_hold", held)
out = ok(step(shop("qc.quinn"), mine, "qc_reject", ncr=NCR, sign=SIGN))
out = ok(step(shop("qc.quinn"), mine, "qc_reject", reason="Weld 2 undercut too deep", ncr=NCR, sign=SIGN))
assert out["status"] == "review"
+9 -5
View File
@@ -289,16 +289,20 @@ def test_reject_with_reason_only(shop: Shop) -> None:
assert out["qc_last"]["ncr"] is None and out["qc_last"]["rejected_welds"] == [f"W{w2}"]
def test_reject_with_ncr_only(shop: Shop) -> None:
def test_reject_needs_a_reason_even_with_an_ncr(shop: Shop) -> None:
"""C2: an NCR number no longer stands in for the reason."""
pkt = at_qc(shop)
q = shop("qc.quinn")
refused(reject(shop, pkt, ncr="NCR-26-1"), 422, "invalid_ncr", "Enter an NCR number like NCR-2026-0021.")
refused(reject(shop, pkt, REASON, ncr="NCR-26-1"), 422, "invalid_ncr", "Enter an NCR number like NCR-2026-0021.")
refused(reject(shop, pkt), 400, "reason_required")
refused(reject(shop, pkt, "bad"), 400, "reason_required")
refused(reject(shop, pkt, ncr=NCR), 400, "reason_required", "Give a reason of at least 8 characters.")
refused(reject(shop, pkt, "bad", ncr=NCR), 400, "reason_required")
assert fresh(q, pkt["id"])["status"] == "qc"
out = ok(reject(shop, pkt, ncr=" ncr-2026-0021 "))
_rejected(shop, pkt, out, NCR, NCR)
out = ok(reject(shop, pkt, REASON, ncr=" ncr-2026-0021 "))
_rejected(shop, pkt, out, f"{NCR} · {REASON}", f"{NCR} · {REASON}")
assert out["qc_last"]["ncr"] == NCR and out["qc_last"]["rejected_welds"] == []
assert out["ncr_required"] is False
row = one(
shop.settings, "SELECT ncr FROM qc_inspections WHERE packet_id = %s ORDER BY id DESC LIMIT 1", (pkt["id"],)
)
@@ -361,7 +365,7 @@ def test_qc_home(shop: Shop) -> None:
assert "itp_hold_count" in card
assert home["columns"]["accepted"] == [] and home["columns"]["rejected"] == []
ok(reject(shop, to_reject, ncr=NCR))
ok(reject(shop, to_reject, REASON, ncr=NCR))
accepted = ok(accept(shop, finish_inspection(shop, waiting), "databook"))
home = ok(q.get("/api/qc/home"))
assert home["kpis"] == {"waiting": 0, "accepted_7d": 1, "rejected_7d": 1}
+5
View File
@@ -55,6 +55,11 @@ APPEND_INSERTS = {
"short_receipts": (
"INSERT INTO short_receipts (short_id, packet_id, qty, by) VALUES (%(short)s, %(packet)s, 1, %(user)s)"
),
# C7: after short_receipts, whose row it approves
"short_approvals": (
"INSERT INTO short_approvals (receipt_id, short_id, packet_id, dept_code, by)"
" SELECT id, short_id, packet_id, '03', %(user)s FROM short_receipts WHERE short_id = %(short)s LIMIT 1"
),
"packet_transitions": (
"INSERT INTO packet_transitions (packet_id, action, by) VALUES (%(packet)s, 'send_to_warehouse', %(user)s)"
),
+8 -4
View File
@@ -420,12 +420,16 @@ def test_checklist_templates_from_settings(shop: Shop) -> None:
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
# change C8: the started stage keeps the list it started with, whatever the settings say now
custom_keys = ["dwg", "wps", "vt"]
set_templates(s, {"weld": "nonsense"})
pkt = fresh(andy, pkt["id"])
assert [i["key"] for i in pkt["checklist_items"]] == list(WELD_KEYS)
assert [i["key"] for i in fresh(andy, pkt["id"])["checklist_items"]] == custom_keys
# a malformed row falls back to the defaults in code for a stage not yet started
later = to_station(shop, ready_packet(shop))
assert [i["key"] for i in fresh(andy, later["id"])["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)
assert [i["key"] for i in fresh(andy, later["id"])["checklist_items"]] == list(WELD_KEYS)
assert [i["key"] for i in fresh(andy, pkt["id"])["checklist_items"]] == custom_keys
# ================================================================ stage fields