Phase 7: close, file, PDFs and prints
ci / frontend (push) Successful in 40s
ci / server (push) Successful in 10m56s
ci / image (push) Successful in 48s
ci / e2e (push) Successful in 2m44s

Clerical close and file: the VISUAL observed state beside the close confirm, a 409 with a second confirm when the
feed still shows work orders open, filing with a signature and the HMAC seal, the data book built on the server
and stored on the seal in the filing transaction, admin reopen keeping the broken seal with its data book.

Server PDFs with reportlab, pypdf and pypdfium2: the manufacturing data book page for page after the old build
(cover, index, drawings with VOID stamps, traceability and heat map rasters, weld maps and welding records, Q.C.
inspections, Q.C. and test documents, MTRs, record history), the job traveler with work order QR codes, the ITP
PDF filed automatically at sign-off, every print kind with the PRINTED COPY stamp, the controlled-copy stamp
signed with the seal key, PDF metadata, the traceability CSV with the formula guard, and the append-only
print_log with an audit row for every print and export (migration 0008).

Frontend: the close and file panel, the print menu and its entry points, the printed copies list on the History
tab, the ITP PDF buttons. Tests: server (close, file, reopen, seal, the builders, prints, CSV, stamps), frontend,
and the Playwright close and print scenarios. Docs updated; SECURITY closes M2, S6, S7 and S8.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-17 23:49:14 -06:00
co-authored by Claude Fable 5.1
parent 00531cb329
commit 904437153d
59 changed files with 8226 additions and 136 deletions
+282
View File
@@ -0,0 +1,282 @@
"""Helpers for the phase 7 tests: a packet at complete on either route, the feed state written by SQL, a rich
packet for the data book (two drawing sheets, a void sheet, a heat table, two welds, MTRs, a short with a receipt,
a packet document and a Q.C. folder document), and PDF text helpers.
Built on shophelpers and qchelpers (imported, never edited). Nothing here reads VISUAL or M-Files.
"""
from __future__ import annotations
import io
import secrets
from collections.abc import Iterator
from typing import Any
from fakedir import FakeDirectory
from pkthelpers import step
from pshelpers import app_conn
from psycopg.types.json import Jsonb
from qchelpers import accept, add_packet_doc, at_qc, finish_inspection, make_qc_doc, upload_doc
from shophelpers import (
SIGN,
Shop,
draft,
finish_stage,
im,
line_of,
make_unit,
ok,
open_shop,
release,
start,
submit,
to_station,
to_warehouse,
)
from packetstation.settings import Settings
PDF = "application/pdf"
HEATS = {1: "HT-10", 2: "HT-20", 3: "HT-30"}
RECEIPT_HEAT = "HT-31"
def open_close_shop(settings: Settings, fake_dir: FakeDirectory) -> Iterator[Shop]:
yield from open_shop(settings, fake_dir)
# ---------------------------------------------------------------- PDFs and files
def pdf_reader(data: bytes) -> Any:
from pypdf import PdfReader
return PdfReader(io.BytesIO(data))
def pdf_pages(data: bytes) -> int:
return len(pdf_reader(data).pages)
def page_texts(data: bytes) -> list[str]:
return [p.extract_text() or "" for p in pdf_reader(data).pages]
def pdf_text(data: bytes) -> str:
"""The text of every page, joined with newlines."""
return "\n".join(page_texts(data))
def flat(text: str) -> str:
"""Whitespace folded to single spaces (text extraction breaks lines where the layout wraps)."""
return " ".join(text.split())
def tiny_png(color: tuple[int, int, int] | None = None) -> bytes:
from PIL import Image
buf = io.BytesIO()
c = color or (secrets.randbelow(256), secrets.randbelow(256), secrets.randbelow(256))
Image.new("RGB", (4, 4), c).save(buf, "PNG")
return buf.getvalue()
def is_pdf(data: bytes) -> bool:
return data[:5] == b"%PDF-"
# ---------------------------------------------------------------- SQL
def q1(settings: Settings, sql: str, params: Any = ()) -> Any:
with app_conn(settings) as conn:
return conn.execute(sql, params).fetchone()
def qall(settings: Settings, sql: str, params: Any = ()) -> list[Any]:
with app_conn(settings) as conn:
return list(conn.execute(sql, params).fetchall())
def set_feed_legs(
settings: Settings,
packet_id: int,
legs: list[dict[str, Any]] | None,
*,
closed: bool = False,
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)."""
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),
)
if legs is None:
conn.execute("DELETE FROM packet_visual_state WHERE packet_id = %s", (packet_id,))
return
conn.execute(
"INSERT INTO packet_visual_state (packet_id, legs, observed_at)"
" VALUES (%s, %s, CASE WHEN %s THEN now() END)"
" ON CONFLICT (packet_id) DO UPDATE SET legs = EXCLUDED.legs, observed_at = EXCLUDED.observed_at",
(packet_id, Jsonb(legs), observed),
)
def leg(base: str, lot: str = "001", st: str = "R", seq: int = 10, split: str = "0", sub: str = "0") -> dict[str, Any]:
"""One leg as the feed stores it, with `status` beside `st` (the contract names the code `status`)."""
return {"key": f"{base}/{lot}/{split}/{sub}", "seq": seq, "st": st, "status": st}
def void_page_by_sql(settings: Settings, packet_id: int, of_n: int = 1, co_no: int = 1) -> int:
"""A change order (approved, minor, no hold) and a void copy of sheet `of_n` as a new page row, the shape a
change order approval leaves (void_co_id, void_at, void_by, void_new_n). Returns the void page's n."""
with app_conn(settings) as conn:
by = conn.execute("SELECT id FROM users WHERE sam_account = 'clerk.clara'").fetchone()[0]
co = conn.execute(
"INSERT INTO change_orders (packet_id, co_no, origin, review, minor, hold, reason, issued_by,"
" approved_at, approved_by) VALUES (%s, %s, 'manual', 'approved', true, false, 'Nozzle moved', %s,"
" now(), %s) RETURNING id",
(packet_id, co_no, by, by),
).fetchone()[0]
src = conn.execute(
"SELECT asset_id, pn, dwg, rev FROM packet_pages WHERE packet_id = %s AND n = %s", (packet_id, of_n)
).fetchone()
n = conn.execute("SELECT max(n) + 1 FROM packet_pages WHERE packet_id = %s", (packet_id,)).fetchone()[0]
conn.execute(
"INSERT INTO packet_pages (packet_id, n, type, asset_id, pn, dwg, rev, void_co_id, void_at, void_by,"
" void_new_n) VALUES (%s, %s, 'drawing', %s, %s, %s, %s, %s, now(), %s, %s)",
(packet_id, n, src[0], src[1], src[2], "A", co, by, of_n),
)
return int(n)
def remove_by_sql(settings: Settings, packet_id: int) -> None:
with app_conn(settings) as conn:
conn.execute(
"UPDATE packets SET removed_at = now(), removed_reason = 'test' WHERE id = %s",
(packet_id,),
)
def delete_asset_file(settings: Settings, asset_id: str) -> None:
"""Remove a stored file from disk (the asset row stays): a part that cannot be read."""
from packetstation.domain import assets
row = q1(settings, "SELECT storage_key FROM assets WHERE id = %s", (asset_id,))
assets.path_of(settings, str(row[0])).unlink()
# ---------------------------------------------------------------- packets
def at_complete(shop: Shop, unit_id: int | None = None, route: str = "databook", welds: int = 2) -> dict[str, Any]:
"""A shop packet taken through Q.C. and accepted with `route` (databook or shipping): status complete."""
pkt = finish_inspection(shop, at_qc(shop, unit_id, welds=welds))
out = ok(accept(shop, pkt, route))
assert out["status"] == "complete" and out["routed_to"] == route, out["status"]
return out
def vclose(shop: Shop, pkt: dict[str, Any], by: str = "clerk.clara") -> dict[str, Any]:
return ok(shop(by).post(f"/api/packets/{pkt['id']}/vclosed", json={"closed": True}))
def file_packet(shop: Shop, pkt: dict[str, Any], by: str = "clerk.clara", **body: Any) -> Any:
return step(shop(by), pkt, "file", sign=SIGN, **body)
def filed(shop: Shop, unit_id: int | None = None) -> dict[str, Any]:
pkt = vclose(shop, at_complete(shop, unit_id))
out = ok(file_packet(shop, pkt))
assert out["status"] == "filed"
return out
def attach_mtr(shop: Shop, pkt: dict[str, Any], item: int, data: bytes, name: str, mime: str, by: str) -> Any:
"""Upload an MTR (purpose mtr) and set it on a BOM line. Returns the packet answer."""
a = upload_doc(shop(by), name, data, mime, purpose="mtr")
cur = line_of(pkt, item)
return ok(
shop(by).patch(
f"/api/packets/{pkt['id']}/bom/{item}", json={"mtr_asset_id": a["id"]}, headers=im(cur["row_version"])
)
)
def add_doc(shop: Shop, pkt: dict[str, Any], title: str = "RT report W1-W2", by: str = "qc.quinn", **kw: Any) -> Any:
return ok(add_packet_doc(shop(by), pkt["id"], title=title, **kw))
def rich_complete(shop: Shop, *, itp_doc: bool = False, mtrs: bool = True) -> dict[str, Any]:
"""The data book packet, complete on the data book route:
- drawing D-5001 rev B, two sheets, BOM items 1 to 3, two work orders;
- in the warehouse: items 1 and 2 pulled with heats HT-10 and HT-20; item 3 short 2 of 4 (On order) with heat
HT-30, then 1 pc received with heat HT-31 (one still open); an MTR PDF on item 1 and an MTR PNG on item 2
(when `mtrs`); the heat table on sheet 1;
- the shop: two welds, the checklist and the filler, submitted, sent to Q.C., inspected and accepted;
- a packet document (RT report) and a Q.C. folder document on the unit (WPS / PQR, or ITP when `itp_doc`);
- sheet 1 voided by change order 1 as a new sheet 3.
Returns the fresh packet read. `pkt["_unit_id"]` and `pkt["_project_id"]` carry the ids."""
project_id, unit_id = make_unit(shop.settings)
pkt = to_warehouse(shop, draft(shop, unit_id, pages=2))
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": HEATS[item]}, headers=im(cur["row_version"]))
)
pkt = ok(
wade.post(
f"/api/packets/{pid}/bom/3/short",
json={"qty": 2, "reason": "On order", "heat": HEATS[3], "expect_open": False},
)
)
pkt = ok(wade.post(f"/api/packets/{pid}/bom/3/short/receipts", json={"qty": 1, "heat": RECEIPT_HEAT}))
if mtrs:
pkt = attach_mtr(shop, pkt, 1, upload_bytes_pdf(), "mtr-1.pdf", PDF, "wh.wade")
pkt = attach_mtr(shop, pkt, 2, tiny_png(), "mtr-2.png", "image/png", "wh.wade")
pkt = ok(wade.put(f"/api/packets/{pid}/heat-table/1", json={"x": 0.6, "y": 0.6, "s": 1.0}))
pkt = release(shop, pkt)
pkt = start(shop, to_station(shop, pkt))
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"))
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
void_page_by_sql(shop.settings, pid, of_n=1)
out = ok(shop("clerk.clara").get(f"/api/packets/{pid}"))
out["_unit_id"] = unit_id
out["_project_id"] = project_id
return out
def upload_bytes_pdf() -> bytes:
"""A one-page PDF with its own bytes (the store dedupes equal bytes)."""
from qchelpers import unique_pdf
return unique_pdf()
# ---------------------------------------------------------------- the builders' inputs
def who(shop: Shop, sam: str = "clerk.clara") -> dict[str, Any]:
"""The WhoRef of a test user: {id, display_name, emp_no}."""
row = q1(shop.settings, "SELECT id, display_name, emp_no FROM users WHERE sam_account = %s", (sam,))
return {"id": str(row[0]), "display_name": row[1], "emp_no": row[2]}
def builder_conn(settings: Settings) -> Any:
"""A connection for calling a builder directly (autocommit, app role)."""
return app_conn(settings)
+302
View File
@@ -0,0 +1,302 @@
"""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.
The feed state is written by SQL in the shape feed/apply.py writes it. 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,
filed,
flat,
leg,
pdf_text,
q1,
qall,
set_feed_legs,
vclose,
)
from fakedir import FakeDirectory
from pshelpers import needs_pg
from qchelpers import at_qc
from shophelpers import SIGN, Shop, find_log, fresh, log_rows, ok, open_shop, refused
from packetstation.policy import Action, check, principal_from_roles
from packetstation.settings import Settings
pytestmark = needs_pg
MSG_VCLOSED = "Close the job in VISUAL first."
CLERICAL = ("clerk.clara", "clerk.leo")
NOT_CLERICAL = ("admin.ann", "wh.wade", "qc.quinn", "pm.paula", "weld.andy", "lead.lou", "sup.wes")
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def oracle(sam: str, status: str, action: Action, routed_to: str = "databook", vclosed: bool = False) -> str:
"""The expected code for SET_VCLOSED and FILE, from the phase 2 rule table: Clerical only, status complete;
FILE on the data book route needs the VISUAL close; an admin outside the role needs an IT reason."""
if status != "complete":
return "wrong_status"
if action is Action.SET_VCLOSED and routed_to != "databook":
return "wrong_status"
if sam not in CLERICAL:
return "it_reason_required" if sam == "admin.ann" else "forbidden"
if action is Action.FILE and routed_to == "databook" and not vclosed:
return "precondition"
return ""
# ---------------------------------------------------------------- the pure rule, every role and status
ROLES = {
"clerk.clara": ["clerical"],
"clerk.leo": ["clerical_supervisor"],
"admin.ann": ["admin"],
"wh.wade": ["warehouse"],
"qc.quinn": ["qc"],
"pm.paula": ["pm"],
"weld.andy": ["shop:03:worker"],
"lead.lou": ["shop:03:lead"],
"sup.wes": ["shop:03:supervisor"],
}
def test_rule_table_matches_the_oracle_for_every_role_and_status() -> None:
import dataclasses
from test_policy_matrix import state
from packetstation.policy import STATUSES
for sam, roles in ROLES.items():
p = principal_from_roles("00000000-0000-0000-0000-0000000000b0", roles)
for status in STATUSES:
for action in (Action.SET_VCLOSED, Action.FILE):
for route in ("databook", "shipping"):
for vclosed in (False, True):
s = dataclasses.replace(state(status), routed_to=route, vclosed=vclosed)
d = check(p, s, action)
want = oracle(sam, status, action, route, vclosed)
if want == "precondition":
assert not d.ok and d.why == MSG_VCLOSED, (sam, status, action, route, d)
elif want == "wrong_status" and action is Action.SET_VCLOSED and status == "complete":
# SET_VCLOSED on the Shipping route: refused, whatever code the engine picks
assert not d.ok, (sam, status, action, route)
else:
assert d.ok is (want == ""), (sam, status, action, route, vclosed, d.code)
assert d.code == want, (sam, status, action, route, vclosed, d.code)
# ---------------------------------------------------------------- the API, every role
def test_vclosed_and_file_as_every_role_at_complete(shop: Shop) -> None:
pkt = at_complete(shop)
pid = pkt["id"]
for sam in NOT_CLERICAL:
r = shop(sam).post(f"/api/packets/{pid}/vclosed", json={"closed": True})
want = oracle(sam, "complete", Action.SET_VCLOSED)
assert r.status_code in (400, 403) and r.json()["code"] == want, (sam, r.text)
r = file_packet(shop, fresh(shop("clerk.clara"), pid), by=sam)
assert r.json()["code"] in (oracle(sam, "complete", Action.FILE), "signature_required"), (sam, r.text)
assert r.status_code in (400, 403), (sam, r.text)
# Clerical: the file waits for the VISUAL close on the data book route
refused(file_packet(shop, pkt), 403, "precondition", MSG_VCLOSED)
pkt = vclose(shop, pkt, by="clerk.leo")
assert pkt["vclosed"]["by"]["display_name"] == "Leo Lead"
out = ok(file_packet(shop, pkt, by="clerk.clara"))
assert out["status"] == "filed"
def test_vclosed_and_file_refused_outside_complete(shop: Shop) -> None:
pkt = at_qc(shop)
c = shop("clerk.clara")
refused(c.post(f"/api/packets/{pkt['id']}/vclosed", json={"closed": True}), 403, "wrong_status")
refused(file_packet(shop, pkt), 403, "wrong_status")
done = filed(shop)
refused(c.post(f"/api/packets/{done['id']}/vclosed", json={"closed": False}), 403, "filed")
r = file_packet(shop, done)
assert r.status_code == 403 and r.json()["code"] in ("filed", "wrong_status"), r.text
def test_shipping_route_files_without_the_visual_close(shop: Shop) -> None:
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
out = ok(file_packet(shop, pkt))
assert out["status"] == "filed" and out["vclosed"] is None
assert out["seal"]["databook"]["pages"] > 0
# ---------------------------------------------------------------- the feed's open work orders
def test_vclose_check_and_the_open_wo_note(shop: Shop) -> None:
pkt = at_complete(shop)
pid = pkt["id"]
set_feed_legs(shop.settings, pid, [leg("L670001", st="C"), leg("L670002", st="R")])
view = fresh(shop("clerk.clara"), pid)
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
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
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"]
assert vc is not None and vc["observed_at"] is None and vc["open_wos"] == []
def test_file_with_open_work_orders_needs_the_confirm(shop: Shop) -> None:
pkt = at_complete(shop)
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))
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?"
)
# 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
def test_cancelled_legs_do_not_count_as_open(shop: Shop) -> None:
pkt = at_complete(shop)
set_feed_legs(shop.settings, pkt["id"], [leg("L670001", st="C"), leg("L670002", st="X")])
assert fresh(shop("clerk.clara"), pkt["id"])["vclose_check"]["open_wos"] == []
def test_hand_packet_has_no_feed_check(shop: Shop) -> None:
pkt = at_complete(shop)
assert pkt["vclose_check"] is None
pkt = vclose(shop, pkt)
assert find_log(shop("clerk.clara"), pkt["id"], "Job closed in VISUAL")["note"] == "L670001, L670002"
out = ok(file_packet(shop, pkt))
assert out["status"] == "filed" and out["seal"]["open_wos"] is None
# ---------------------------------------------------------------- the filing, the seal, the data book
def test_filing_builds_and_stores_the_data_book(shop: Shop) -> None:
pkt = vclose(shop, at_complete(shop))
pid = pkt["id"]
out = ok(file_packet(shop, pkt))
assert out["status"] == "filed" and out["databook_at"] is not None
seal = out["seal"]
assert seal["broken_at"] is None and seal["signed"] is True and out["seals"] == 1
db = seal["databook"]
assert db["pages"] > 0 and db["url"] == f"/_blob/{db['asset_id']}"
assert db["download_url"] == f"/_blob/{db['asset_id']}?download=1"
assert db["file_name"].endswith("_Manufacturing_Data_Book.pdf") and db["size"] > 0
rows = log_rows(shop("clerk.clara"), pid)
actions = [r["action"] for r in rows]
i = actions.index("Filed in the data book")
j = actions.index("Data book filed")
assert j < i, actions # newest first: the data book row comes after the filing row
assert rows[j]["note"] == f"{db['pages']} pages"
# the seal checks out and the stored copy is the filed data book
view = ok(shop("clerk.clara").get(f"/api/packets/{pid}/seal"))
assert view["valid"] is True
row = q1(shop.settings, "SELECT databook_asset_id, databook_pages FROM packet_seals WHERE packet_id = %s", (pid,))
assert str(row[0]) == db["asset_id"] and row[1] == db["pages"]
r = shop("clerk.clara").get(db["url"])
assert r.status_code == 200 and r.headers["content-type"].startswith("application/pdf")
text = flat(pdf_text(r.content))
assert "MANUFACTURING" in text
project = out["unit"]["project"]["number"]
assert project in text
assert f"seal {view['seal']['hash'][:12]}" in text
src = q1(shop.settings, "SELECT source, source_ref, packet_id FROM assets WHERE id = %s", (db["asset_id"],))
assert src[0] == "databook" and src[1]["packet_id"] == pid and src[2] == pid
# the export answers with the stored copy, unchanged
r2 = shop("pm.paula").get(f"/api/packets/{pid}/pdf/databook")
assert r2.status_code == 200 and r2.content == r.content
def test_reopen_keeps_the_broken_seal_and_refile_adds_a_second(shop: Shop) -> None:
pkt = filed(shop)
pid = pkt["id"]
first = pkt["seal"]["databook"]
r = shop("admin.ann").post(f"/api/packets/{pid}/reopen", json={"reason": "Wrong heat number filed", "sign": SIGN})
assert r.status_code == 200, r.text
out = r.json()
assert out["status"] == "complete" and out["seal"]["broken_at"] is not None
assert out["seal"]["broken_reason"] == "Wrong heat number filed"
assert out["seal"]["databook"]["asset_id"] == first["asset_id"] and out["seals"] == 1
assert shop("clerk.clara").get(first["url"]).status_code == 200
# complete again: the export builds a preview, not the broken seal's copy
r = shop("clerk.clara").get(f"/api/packets/{pid}/pdf/databook")
assert r.status_code == 200 and "not yet filed" in flat(pdf_text(r.content))
# refile: a second seal, a second data book, the first kept
again = ok(file_packet(shop, fresh(shop("clerk.clara"), pid)))
assert again["status"] == "filed" and again["seals"] == 2
assert again["seal"]["broken_at"] is None and again["seal"]["databook"]["asset_id"] != first["asset_id"]
view = ok(shop("clerk.clara").get(f"/api/packets/{pid}/seal"))
assert view["valid"] is True and len(view["seals"]) == 2
assert view["seals"][1]["broken_reason"] == "Wrong heat number filed"
rows = qall(
shop.settings,
"SELECT databook_asset_id, broken_at IS NOT NULL FROM packet_seals WHERE packet_id = %s ORDER BY id",
(pid,),
)
assert [r[1] for r in rows] == [True, False] and all(r[0] is not None for r in rows)
def test_failed_data_book_files_nothing(shop: Shop, monkeypatch: pytest.MonkeyPatch) -> None:
from packetstation.pdf import databook
from packetstation.pdf.style import PdfError
def boom(*a: Any, **k: Any) -> Any:
raise PdfError("the drawing could not be read")
monkeypatch.setattr(databook, "build", boom)
pkt = vclose(shop, at_complete(shop))
pid = pkt["id"]
body = refused(file_packet(shop, pkt), 500, "databook_failed")
assert body["detail"].startswith("The data book could not be built:") and "Nothing was filed." in body["detail"]
view = fresh(shop("clerk.clara"), pid)
assert view["status"] == "complete" and view["seal"] is None and view["seals"] == 0
assert q1(shop.settings, "SELECT count(*) FROM packet_seals WHERE packet_id = %s", (pid,))[0] == 0
assert "Filed in the data book" not in [r["action"] for r in log_rows(shop("clerk.clara"), pid)]
+12 -6
View File
@@ -405,7 +405,8 @@ def finish_all(shop: Shop, itp: dict[str, Any]) -> dict[str, Any]:
return sign_all_blocks(shop, approve_all(shop, itp))
def test_signoff_without_pdf(qc_shop: Shop) -> None:
def test_signoff_files_pdf(qc_shop: Shop) -> None:
"""Phase 7: the sign-off builds the signed ITP PDF and files it in the unit's Q.C. folder."""
shop = qc_shop
unit_id, itp = new_itp(shop)
q = shop("qc.quinn")
@@ -425,13 +426,17 @@ def test_signoff_without_pdf(qc_shop: Shop) -> None:
itp = ok(sign_off(q, itp))
assert itp["status"] == "complete" and itp["can"] == {"edit": False, "sign_off": False, "reopen": False}
assert itp["signoff"]["by"]["display_name"] == "Quinn QC" and itp["signoff"]["at"]
assert itp["signoff"]["asset_id"] is None
assert itp_log(itp)[0] == ("ITP signed off", "PDF filing reserved for phase 7") and itp["log"][0]["signed"]
asset_id = itp["signoff"]["asset_id"]
assert asset_id is not None
assert itp_log(itp)[0] == ("ITP signed off", "PDF filed") and itp["log"][0]["signed"]
row = one(
shop.settings, "SELECT signoff_asset_id, signoff_signature_id IS NOT NULL FROM itps WHERE id = %s", (itp["id"],)
)
assert tuple(row) == (None, True)
assert all_rows(shop.settings, "SELECT count(*) FROM qc_docs WHERE unit_id = %s", (unit_id,))[0][0] == 0
assert (str(row[0]), row[1]) == (asset_id, True)
asset = one(shop.settings, "SELECT mime, source, original_name FROM assets WHERE id = %s", (asset_id,))
assert tuple(asset) == ("application/pdf", "itp_signoff", f"{itp['no']}_signed.pdf")
docs = all_rows(shop.settings, "SELECT type, auto, asset_id FROM qc_docs WHERE unit_id = %s", (unit_id,))
assert [(d[0], d[1], str(d[2])) for d in docs] == [("ITP", True, asset_id)]
# the admin may reopen; qc.quinn and qc.sue may not
assert get_itp(shop("admin.ann"), unit_id)["can"]["reopen"] is True
assert get_itp(q, unit_id)["can"]["reopen"] is False
@@ -485,7 +490,8 @@ def test_signoff_with_pdf_hook(qc_shop: Shop, monkeypatch: pytest.MonkeyPatch) -
calls: list[Any] = []
asset_ids = [upload_doc(q, "itp-1.pdf")["id"], upload_doc(q, "itp-2.pdf")["id"]]
def fake_hook(conn: Any, itp_row: Any, actor: Any) -> uuid.UUID:
def fake_hook(conn: Any, itp_row: Any, actor: Any, settings: Any = None) -> uuid.UUID:
assert settings is not None
calls.append(itp_row)
return uuid.UUID(asset_ids[len(calls) - 1])
+2 -1
View File
@@ -264,7 +264,8 @@ def test_happy_path_file_and_reopen(pg_client2: TestClient, pg_settings: Setting
"qc_accept",
"file",
]
assert pg_client2.get(f"/api/packets/{pid}/log").json()[0]["action"] == "Filed in the data book"
actions = [e["action"] for e in pg_client2.get(f"/api/packets/{pid}/log").json()[:2]]
assert actions == ["Data book filed", "Filed in the data book"]
# filed: every write is refused, Admin included
r = pg_client2.patch(f"/api/packets/{pid}", json={"set": {"drawing": "D-2"}})
assert r.status_code == 403 and r.json()["code"] == "filed"
+201
View File
@@ -0,0 +1,201 @@
"""Phase 7 data book builder, called directly on a packet built through the API: two drawing sheets and a void
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."""
from __future__ import annotations
import re
from collections.abc import Iterator
from datetime import UTC, datetime
from typing import Any
import pytest
from closehelpers import (
HEATS,
RECEIPT_HEAT,
builder_conn,
delete_asset_file,
flat,
page_texts,
pdf_reader,
rich_complete,
who,
)
from fakedir import FakeDirectory
from pshelpers import TEST_SEAL_KEY, needs_pg
from qchelpers import g
from shophelpers import Shop, open_shop, run
from packetstation.pdf import databook, stamps
from packetstation.pdf.style import clean
from packetstation.settings import Settings
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]
SIG_RE = re.compile(r"stamp ([0-9a-f]{16})")
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def build(shop: Shop, pid: int, mode: str = "preview", **kw: Any) -> Any:
with builder_conn(shop.settings) as conn:
return databook.build(
conn,
shop.settings,
pid,
who=who(shop),
now=NOW,
seal=SEAL if mode == "filed" else None,
mode=mode,
**kw,
)
def index(built: Any) -> list[dict[str, Any]]:
keys = ("section", "doc", "ref", "rev", "page", "warn", "count")
return [{k: g(r, k) for k in keys} for r in built.index]
def docs_in(rows: list[dict[str, Any]], section: int) -> list[str]:
return [str(r["doc"]) for r in rows if r["section"] == section]
def test_data_book_preview(shop: Shop) -> None:
pkt = rich_complete(shop)
pid = pkt["id"]
built = build(shop, pid)
data: bytes = built.bytes
assert data[:5] == b"%PDF-"
reader = pdf_reader(data)
assert len(reader.pages) == built.pages
rows = index(built)
# the seven 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
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
s2 = docs_in(rows, 2)
assert s2[0].startswith("Material traceability record"), s2
assert any(d.startswith("Heat map") and "heat number table" in d for d in s2), s2
s3 = docs_in(rows, 3)
assert any(d.startswith("Weld map") and "W1, W2" in d for d in s3), s3
assert any(d.startswith("Weld log, welding record and checklist") for d in s3), s3
s4 = docs_in(rows, 4)
assert len(s4) == 1 and s4[0].startswith("Q.C. inspection") and "ACCEPTED" in s4[0], s4
s5 = docs_in(rows, 5)
assert any("WPS / PQR" in d and "Unit folder doc" in d for d in s5), s5
assert any("RT report W1-W2" in d for d in s5), s5
itp_warn = [r for r in rows if r["section"] == 5 and r["doc"] == "No signed ITP filed for this unit"]
assert len(itp_warn) == 1 and itp_warn[0]["warn"] is True
s6 = docs_in(rows, 6)
assert len(s6) == 2 and all(d.startswith("MTR") for d in s6), s6
assert "item 1" in s6[0] and "item 2" in s6[1]
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"]
# 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)
front = built.pages - parts
assert front == 2, (built.pages, parts) # the cover and one index page
numbered = [r for r in rows if r["page"] is not None]
assert all(front < int(r["page"]) <= built.pages for r in numbered), numbered
assert all(r["page"] is None and not r["count"] for r in rows if r["warn"] and "could not" in str(r["doc"]))
texts = page_texts(data)
assert "MANUFACTURING" in texts[0] and "DATA BOOK" in flat(texts[0])
assert pkt["unit"]["project"]["number"] in flat(texts[0])
index_text = flat(" ".join(texts[1:front]))
assert "INDEX" in index_text and "TABLE OF CONTENTS" in index_text
for r in rows:
words = flat(clean(str(r["doc"])))[:30]
assert words in index_text, (words, index_text[:2000])
# the void sheet carries the void text, the traceability page the heats
void_page = next(r for r in rows if str(r["doc"]).startswith("SUPERSEDED"))["page"]
assert "VOID - superseded by CO-1" in flat(texts[int(void_page) - 1])
trace_page = next(r for r in rows if str(r["doc"]).startswith("Material traceability"))["page"]
trace = flat(" ".join(texts[int(trace_page) - 1 : int(trace_page) + 1]))
for heat in (HEATS[1], HEATS[2], RECEIPT_HEAT):
assert heat in trace, heat
assert "open items" in trace.lower() and "item 3" in trace
# metadata
meta = reader.metadata
assert meta.title == f"{pkt['unit']['project']['number']} Lot 001 Manufacturing Data Book - Spool 7 outlet"
assert meta.producer == "Packet Station"
assert meta.get("/Author") == "PESCO - Process Equipment & Service Company, Inc."
# the stamp on every page, preview mode, and its signature verifies
sigs = set()
for i, t in enumerate(texts):
ft = flat(t)
assert "Controlled copy - exported by Clara Clerk (4001)" in ft, (i, ft[-300:])
assert "not yet filed" in ft, i
m = SIG_RE.search(ft)
assert m, (i, ft[-300:])
sigs.add(m.group(1))
assert len(sigs) == 1
sig = sigs.pop()
user = who(shop)["id"]
key = TEST_SEAL_KEY.encode()
assert stamps.verify_stamp(key, pid, "databook", "", user, NOW.isoformat(), sig)
assert not stamps.verify_stamp(key, pid, "traveler", "", user, NOW.isoformat(), sig)
def test_data_book_filed_mode_and_warnings(shop: Shop) -> None:
pkt = rich_complete(shop)
pid = pkt["id"]
filed = build(shop, pid, mode="filed")
texts = page_texts(filed.bytes)
assert all("seal ab12cd34ef56" in flat(t) for t in texts)
assert not any("not yet filed" in flat(t) for t in texts)
m = SIG_RE.search(flat(texts[0]))
assert m and filed.stamp_sig == m.group(1)
assert stamps.verify_stamp(TEST_SEAL_KEY.encode(), pid, "databook", SEAL["hash"], who(shop)["id"], NOW, m.group(1))
meta = pdf_reader(filed.bytes).metadata
assert "seal" in (meta.creator or "") and "4001" in (meta.creator or "")
# no MTRs: the MTR warning row; a deleted packet document file: could not be read
run(shop.settings, "UPDATE bom_lines SET mtr_asset_id = NULL WHERE packet_id = %s", (pid,))
doc = next(d for d in pkt["docs"] if d.get("removed") is None)
asset_id = doc.get("asset_id") or doc["url"].rsplit("/", 1)[-1]
delete_asset_file(shop.settings, asset_id)
built = build(shop, pid)
rows = index(built)
mtr = [r for r in rows if r["section"] == 6]
assert [r["doc"] for r in mtr] == ["No MTRs attached in Packet Station — add from the warehouse files"]
assert mtr[0]["warn"] is True and mtr[0]["page"] is None
bad = [r for r in rows if r["section"] == 5 and str(r["doc"]).endswith("could not be read")]
assert len(bad) == 1 and "RT report W1-W2" in bad[0]["doc"] and bad[0]["warn"] is True
# the index still fits on one page: the cover, the index and the parts
assert built.pages == 2 + sum(int(r["count"] or 0) for r in rows)
def test_data_book_with_an_itp_document_has_no_itp_warning(shop: Shop) -> None:
pkt = rich_complete(shop, itp_doc=True, mtrs=False)
rows = index(build(shop, pkt["id"]))
s5 = [r for r in rows if r["section"] == 5]
assert s5[0]["doc"].startswith("ITP"), s5
assert not any(r["doc"] == "No signed ITP filed for this unit" for r in s5)
+111
View File
@@ -0,0 +1,111 @@
"""Phase 7 ITP PDF: the builder on an open and a complete ITP (the number, a section title, the approvals, the
stamp, three page groups), and the sign-off through the API that now files the signed PDF (the asset on the ITP,
the automatic Q.C. folder document, the log note), superseded by a second sign-off after a reopen."""
from __future__ import annotations
from collections.abc import Iterator
from datetime import UTC, datetime
from typing import Any
import pytest
from closehelpers import builder_conn, flat, page_texts, pdf_reader, q1, qall, who
from pshelpers import needs_pg
from psycopg.rows import dict_row
from qchelpers import (
SECTION_TITLE_5,
approve_all,
itp_log,
open_qc_shop,
qc_directory,
reopen_itp,
sign_all_blocks,
sign_line,
sign_off,
start_itp,
)
from shophelpers import Shop, make_unit, ok
from packetstation.pdf import itp as itp_pdf
from packetstation.settings import Settings
pytestmark = needs_pg
NOW = datetime(2026, 10, 17, 21, 4, tzinfo=UTC)
PAGE_HEADS = ("1 OF 3: WP", "2 OF 3: C", "3 OF 3: FA") # the header sub is drawn in capitals
@pytest.fixture
def qc_shop(pg_settings: Settings, dev_visual: None) -> Iterator[Shop]:
yield from open_qc_shop(pg_settings, qc_directory())
def build(shop: Shop, itp_id: int) -> Any:
with builder_conn(shop.settings) as conn:
with conn.cursor(row_factory=dict_row) as cur:
row = cur.execute("SELECT * FROM itps WHERE id = %s", (itp_id,)).fetchone()
return itp_pdf.build(conn, shop.settings, row, who=who(shop, "qc.quinn"), now=NOW)
def test_open_itp_pdf(qc_shop: Shop) -> None:
shop = qc_shop
_, unit_id = make_unit(shop.settings)
itp = start_itp(shop, unit_id)
itp = ok(sign_line(shop("qc.quinn"), itp, "1.1"))
built = build(shop, itp["id"])
assert built.bytes[:5] == b"%PDF-"
assert len(pdf_reader(built.bytes).pages) == built.pages >= 3
texts = [flat(t) for t in page_texts(built.bytes)]
text = " ".join(texts)
assert itp["no"] in text and SECTION_TITLE_5 in text
assert "Approved" in text and "INSPECTION & TEST PLAN" in text
# three page groups, in order WP, C, FA
firsts = [next(i for i, t in enumerate(texts) if head in t) for head in PAGE_HEADS]
assert firsts == sorted(firsts) and firsts[0] == 0, firsts
for t in texts:
assert "OPEN, not signed off" in t
assert "Controlled copy - by Quinn QC (5001) 2026-10-17 21:04 UTC - OPEN, not signed off - stamp " in t
assert pdf_reader(built.bytes).metadata.title == f"ITP {itp['no']}"
def test_signoff_files_the_signed_pdf(qc_shop: Shop) -> None:
shop = qc_shop
_, unit_id = make_unit(shop.settings)
q = shop("qc.quinn")
itp = sign_all_blocks(shop, approve_all(shop, start_itp(shop, unit_id)))
itp = ok(sign_off(q, itp))
assert itp["status"] == "complete"
asset_id = itp["signoff"]["asset_id"]
assert asset_id is not None
assert itp_log(itp)[0] == ("ITP signed off", "PDF filed")
row = q1(shop.settings, "SELECT signoff_asset_id FROM itps WHERE id = %s", (itp["id"],))
assert str(row[0]) == asset_id
a = q1(shop.settings, "SELECT original_name, mime, source FROM assets WHERE id = %s", (asset_id,))
assert a[0] == f"{itp['no']}_signed.pdf" and a[1] == "application/pdf" and a[2] == "itp_signoff"
docs = qall(
shop.settings,
"SELECT id, type, title, auto, superseded_by, asset_id FROM qc_docs WHERE unit_id = %s ORDER BY id",
(unit_id,),
)
assert len(docs) == 1
assert tuple(docs[0][1:5]) == ("ITP", f"{itp['no']} — signed off", True, None)
assert str(docs[0][5]) == asset_id
r = q.get(f"/_blob/{asset_id}")
assert r.status_code == 200 and r.content[:5] == b"%PDF-"
text = flat(" ".join(page_texts(r.content)))
assert itp["no"] in text and "signed off" in text and "OPEN, not signed off" not in text
assert "ITP signed off" in text and "Quinn QC (5001)" in text
# the builder on the complete ITP says signed off on every page
for t in page_texts(build(shop, itp["id"]).bytes):
assert "signed off" in flat(t) and "OPEN, not signed off" not in flat(t)
# reopen and sign off again: a second PDF supersedes the first automatic document
itp = ok(reopen_itp(shop("admin.ann"), itp, "Customer asked for 4.2 again"))
itp = ok(sign_off(q, itp))
second = itp["signoff"]["asset_id"]
assert second is not None and second != asset_id
docs = qall(
shop.settings, "SELECT id, superseded_by, asset_id FROM qc_docs WHERE unit_id = %s ORDER BY id", (unit_id,)
)
assert len(docs) == 2 and docs[0][1] == docs[1][0] and docs[1][1] is None and str(docs[1][2]) == second
+78
View File
@@ -0,0 +1,78 @@
"""Phase 7 job traveler builder: the page count, the work order boxes (the QR text is a barcode, never text; the
work order, lot and seq are text), the blanks for a packet with no heats, the sign-off rows after signed steps, the
note for a packet with no work orders, the stamp and the metadata."""
from __future__ import annotations
from collections.abc import Iterator
from datetime import UTC, datetime
from typing import Any
import pytest
from closehelpers import at_complete, builder_conn, flat, page_texts, pdf_reader, who
from fakedir import FakeDirectory
from pkthelpers import make_draft
from pshelpers import needs_pg
from shophelpers import BOM3, WOS, Shop, draft, make_unit, open_shop
from packetstation.pdf import traveler
from packetstation.settings import Settings
pytestmark = needs_pg
NOW = datetime(2026, 10, 17, 21, 4, tzinfo=UTC)
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def build(shop: Shop, pid: int) -> Any:
with builder_conn(shop.settings) as conn:
return traveler.build(conn, shop.settings, pid, who=who(shop), now=NOW)
def test_traveler_of_a_draft(shop: Shop) -> None:
"""A draft with no signed step yet (no drawing attached, so no signed attach row): the blank sign-off rows."""
_, unit_id = make_unit(shop.settings)
pkt = make_draft(shop("clerk.clara"), unit_id, bom=BOM3, work_orders=WOS)
built = build(shop, pkt["id"])
assert built.bytes[:5] == b"%PDF-"
reader = pdf_reader(built.bytes)
assert len(reader.pages) == built.pages >= 1
texts = page_texts(built.bytes)
text = flat(" ".join(texts))
assert "JOB TRAVELER" in text.upper()
for wo in ("L670001", "L670002"):
assert wo in text
assert "Lot 001" in text and "Seq" in text
# the QR payload is drawn as a barcode, never as text
assert "*%2" not in text and "$001$0$0$" not in text
# no heats yet: blanks
assert "________" in text
assert "Work complete (stall)" in text and "Supervisor review" in text and "Q.C. inspection" in text
project = pkt["unit"]["project"]["number"]
for i, t in enumerate(texts):
ft = flat(t)
assert "Controlled copy - exported by Clara Clerk (4001)" in ft, i
assert f"Page {i + 1} of {len(texts)}" in ft, i
assert reader.metadata.title == f"{project} Lot 001 Job Traveler", reader.metadata.title
assert reader.metadata.producer == "Packet Station"
def test_traveler_after_signed_steps(shop: Shop) -> None:
pkt = at_complete(shop)
text = flat(" ".join(page_texts(build(shop, pkt["id"]).bytes)))
assert "Andy Welder (3003)" in text # signed the submit
assert "Wes Super (2003)" in text # signed the send to Q.C.
assert "Quinn QC (5001)" in text # signed the accept
assert "Work complete (stall)" not in text
assert "HT-10" in text # the heats pulled in the warehouse
assert "Q.C. ACCEPTED" in text
def test_traveler_without_work_orders(shop: Shop) -> None:
pkt = draft(shop, wos=[])
text = flat(" ".join(page_texts(build(shop, pkt["id"]).bytes)))
assert "No work orders were read from this packet." in text
+255
View File
@@ -0,0 +1,255 @@
"""Phase 7 printed copies and exports through the API: every print kind with its stamp on every page, the print log
and the audit, one sheet, a document print and a foreign asset, nothing to print, the data book before complete,
the prints list, the ITP print and PDF, the exports, the signed-out call, the PM role, a removed packet, and the
append-only print log."""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
import psycopg
import pytest
from closehelpers import (
at_complete,
flat,
is_pdf,
page_texts,
pdf_pages,
q1,
qall,
remove_by_sql,
rich_complete,
)
from fakedir import FakeDirectory
from fastapi.testclient import TestClient
from pshelpers import ORIGIN_HEADERS, app_conn, needs_pg
from qchelpers import approve_all, at_qc, sign_all_blocks, sign_off, start_itp, upload_doc
from shophelpers import Shop, draft, ok, open_shop, refused
from packetstation.settings import Settings
pytestmark = needs_pg
WHAT = {
"traveler": "Job traveler",
"map-h": "Drawing with heat map",
"map-w": "Drawing with weld map",
"clean": "Drawing (no markup)",
"void": "Superseded drawings (VOID)",
"databook": "Data book",
}
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def get_pdf(c: TestClient, url: str) -> bytes:
r = c.get(url)
assert r.status_code == 200, (url, r.text[:500])
assert r.headers["content-type"].startswith("application/pdf"), url
assert r.headers.get("x-content-type-options") == "nosniff"
assert r.headers.get("cache-control") == "no-store"
assert r.headers["content-disposition"].startswith("inline;"), r.headers["content-disposition"]
assert is_pdf(r.content)
return r.content
def assert_print_stamp(data: bytes, what: str, by: str = "Clara Clerk (4001)") -> None:
for i, t in enumerate(page_texts(data)):
ft = flat(t)
assert f"PRINTED COPY - {what} - printed by {by}" in ft, (i, what, ft[-400:])
assert "verify revision before use" in ft, (i, what)
def last_print(settings: Settings, pid: int) -> Any:
return q1(
settings,
"SELECT kind, what, pages, by_name, stamp_sig, stamp, asset_id FROM print_log WHERE packet_id = %s"
" ORDER BY id DESC LIMIT 1",
(pid,),
)
def last_audit(settings: Settings, pid: int, action: str) -> Any:
row = q1(
settings,
"SELECT detail, user_id FROM audit_events WHERE action = %s AND packet_id = %s ORDER BY id DESC LIMIT 1",
(action, pid),
)
assert row is not None, (action, pid)
return row
def test_every_print_kind(shop: Shop) -> None:
pkt = rich_complete(shop)
pid = pkt["id"]
c = shop("clerk.clara")
for kind, what in WHAT.items():
data = get_pdf(c, f"/api/packets/{pid}/print/{kind}")
assert_print_stamp(data, what)
row = last_print(shop.settings, pid)
assert (row[0], row[1], row[2], row[3]) == (kind, what, pdf_pages(data), "Clara Clerk"), row
assert row[4] and len(row[4]) == 16 and row[4] in row[5]
au = last_audit(shop.settings, pid, "print")
assert au[0]["kind"] == kind and au[0]["what"] == what and au[0]["pages"] == pdf_pages(data)
assert au[0]["stamp_sig"] == row[4]
# page counts: two live sheets, one void sheet
assert pdf_pages(get_pdf(c, f"/api/packets/{pid}/print/clean")) == 3
assert pdf_pages(get_pdf(c, f"/api/packets/{pid}/print/void")) == 1
# one sheet
for kind in ("map-h", "map-w", "clean"):
data = get_pdf(c, f"/api/packets/{pid}/print/{kind}?sheet=2")
assert pdf_pages(data) == 1
assert_print_stamp(data, f"{WHAT[kind]} (sheet 2)")
assert last_print(shop.settings, pid)[1] == f"{WHAT[kind]} (sheet 2)"
# the download form
r = c.get(f"/api/packets/{pid}/print/traveler?download=1")
assert r.status_code == 200 and r.headers["content-disposition"].startswith("attachment;")
assert '-Lot001 Job traveler.pdf"' in r.headers["content-disposition"], r.headers["content-disposition"]
def test_document_prints(shop: Shop) -> None:
pkt = rich_complete(shop)
pid = pkt["id"]
c = shop("clerk.clara")
mtr = next(b for b in pkt["bom"] if b["item"] == 1)
mtr_id = (mtr.get("mtr") or {}).get("asset_id") or mtr.get("mtr_asset_id")
png = next(b for b in pkt["bom"] if b["item"] == 2)
png_id = (png.get("mtr") or {}).get("asset_id") or png.get("mtr_asset_id")
doc = next(d for d in pkt["docs"] if d.get("removed") is None)
doc_id = doc.get("asset_id") or doc["url"].rsplit("/", 1)[-1]
folder = pkt["qc_folder_docs"][0]
folder_id = folder.get("asset_id") or folder["url"].rsplit("/", 1)[-1]
for asset, what_starts in (
(mtr_id, "MTR - item 1"),
(png_id, "MTR - item 2"),
(doc_id, "RT report W1-W2"),
(folder_id, "WPS / PQR - Unit folder doc"),
):
data = get_pdf(c, f"/api/packets/{pid}/print/doc?asset={asset}")
row = last_print(shop.settings, pid)
assert row[0] == "doc" and row[1].startswith(what_starts) and str(row[6]) == str(asset), row
assert_print_stamp(data, row[1])
assert last_audit(shop.settings, pid, "print")[0].get("asset_id") in (str(asset), None)
# a PNG MTR prints on one letter page
assert pdf_pages(get_pdf(c, f"/api/packets/{pid}/print/doc?asset={png_id}")) == 1
# an asset that is not the packet's
foreign = upload_doc(c, "other.pdf")["id"]
refused(c.get(f"/api/packets/{pid}/print/doc?asset={foreign}"), 404, "not_found")
r = c.get(f"/api/packets/{pid}/print/doc")
assert r.status_code in (404, 422), r.text
r = c.get(f"/api/packets/{pid}/print/nonsense")
assert r.status_code in (404, 422), r.text
def test_nothing_to_print_and_wrong_status(shop: Shop) -> None:
pkt = at_qc(shop)
c = shop("clerk.clara")
refused(c.get(f"/api/packets/{pkt['id']}/print/void"), 404, "nothing_to_print", "Nothing to print.")
refused(c.get(f"/api/packets/{pkt['id']}/print/databook"), 409, "wrong_status")
refused(
c.get(f"/api/packets/{pkt['id']}/pdf/databook"),
409,
"wrong_status",
"The data book is built once the packet is back from Q.C.",
)
assert q1(shop.settings, "SELECT count(*) FROM print_log WHERE packet_id = %s", (pkt["id"],))[0] == 0
# the traveler prints in any status
get_pdf(c, f"/api/packets/{pkt['id']}/print/traveler")
def test_prints_list_newest_first_and_exports(shop: Shop) -> None:
pkt = at_complete(shop)
pid = pkt["id"]
c = shop("clerk.clara")
assert ok(c.get(f"/api/packets/{pid}/prints")) == {"prints": []}
get_pdf(c, f"/api/packets/{pid}/print/traveler")
get_pdf(shop("pm.paula"), f"/api/packets/{pid}/pdf/traveler")
data = get_pdf(shop("pm.paula"), f"/api/packets/{pid}/pdf/databook")
assert "not yet filed" in flat(page_texts(data)[0])
assert shop("pm.paula").get(f"/api/packets/{pid}/export/traceability.csv").status_code == 200
out = ok(shop("qc.quinn").get(f"/api/packets/{pid}/prints"))["prints"]
assert [p["kind"] for p in out] == ["csv", "databook-export", "traveler", "traveler"]
assert out[0]["by"]["display_name"] == "Paula PM" and out[-1]["by"]["display_name"] == "Clara Clerk"
assert out[1]["pages"] == pdf_pages(data) and out[1]["what"]
assert set(out[0]) >= {"id", "at", "by", "kind", "what", "pages"}
ids = [p["id"] for p in out]
assert ids == sorted(ids, reverse=True)
kinds = [
r[0]["kind"]
for r in qall(
shop.settings,
"SELECT detail FROM audit_events WHERE action = 'export' AND packet_id = %s ORDER BY id",
(pid,),
)
]
assert kinds == ["traveler", "databook", "csv"]
assert (
q1(shop.settings, "SELECT count(*) FROM audit_events WHERE action = 'print' AND packet_id = %s", (pid,))[0] == 1
)
# prints and exports write no packet log rows
actions = [r[0] for r in qall(shop.settings, "SELECT action FROM packet_log WHERE packet_id = %s", (pid,))]
assert not any("print" in a.lower() or "export" in a.lower() for a in actions)
def test_itp_print_and_pdf(shop: Shop) -> None:
pkt = at_complete(shop)
unit_id = pkt["unit"]["id"]
itp = start_itp(shop, unit_id)
q = shop("qc.quinn")
data = get_pdf(q, f"/api/itps/{itp['id']}/pdf")
assert "OPEN, not signed off" in flat(page_texts(data)[0])
row = q1(shop.settings, "SELECT kind, unit_id, itp_id, packet_id FROM print_log ORDER BY id DESC LIMIT 1")
assert tuple(row) == ("itp-pdf", unit_id, itp["id"], None)
r = q.get(f"/api/itps/{itp['id']}/pdf?download=1")
assert r.status_code == 200 and r.headers["content-disposition"].startswith("attachment;")
assert f"{itp['no']}.pdf" in r.headers["content-disposition"]
data = get_pdf(shop("pm.paula"), f"/api/itps/{itp['id']}/print")
for t in page_texts(data):
assert "PRINTED COPY" in flat(t) and "verify revision before use" in flat(t)
assert q1(shop.settings, "SELECT kind FROM print_log ORDER BY id DESC LIMIT 1")[0] == "itp"
itp = ok(sign_off(q, sign_all_blocks(shop, approve_all(shop, itp))))
data = get_pdf(q, f"/api/itps/{itp['id']}/pdf")
assert "signed off" in flat(page_texts(data)[0]) and "OPEN, not signed off" not in flat(page_texts(data)[0])
refused(q.get("/api/itps/999999/pdf"), 404, "not_found")
def test_signed_out_pm_removed_and_append_only(shop: Shop) -> None:
gone = draft(shop)
remove_by_sql(shop.settings, gone["id"])
pkt = at_complete(shop)
pid = pkt["id"]
anon = TestClient(shop.main.app, headers=ORIGIN_HEADERS)
for url in (
f"/api/packets/{pid}/pdf/databook",
f"/api/packets/{pid}/pdf/traveler",
f"/api/packets/{pid}/export/traceability.csv",
f"/api/packets/{pid}/print/traveler",
f"/api/packets/{pid}/prints",
):
assert anon.get(url).status_code == 401, url
# the PM role reads every one
pm = shop("pm.paula")
for kind in ("traveler", "map-h", "map-w", "clean", "databook"):
get_pdf(pm, f"/api/packets/{pid}/print/{kind}")
# a missing packet and a removed packet are 404
refused(pm.get("/api/packets/999999/print/traveler"), 404, "not_found")
# a fresh session: the PDFs above can take longer than the idle lock (15 minutes over the tunnel)
clara = shop.fresh("clerk.clara")
for url in (
f"/api/packets/{gone['id']}/print/traveler",
f"/api/packets/{gone['id']}/pdf/traveler",
f"/api/packets/{gone['id']}/export/traceability.csv",
):
refused(clara.get(url), 404, "not_found")
# the print log is append-only
with app_conn(shop.settings) as conn:
with pytest.raises(psycopg.errors.InsufficientPrivilege):
conn.execute("UPDATE print_log SET what = 'x' WHERE packet_id = %s", (pid,))
with app_conn(shop.settings) as conn:
with pytest.raises(psycopg.errors.InsufficientPrivilege):
conn.execute("DELETE FROM print_log WHERE packet_id = %s", (pid,))
assert q1(shop.settings, "SELECT count(*) FROM print_log WHERE packet_id = %s", (pid,))[0] == 5
+3
View File
@@ -67,6 +67,9 @@ APPEND_INSERTS = {
"co_verifications": (
"INSERT INTO co_verifications (co_id, dept_code, by, signature_id) VALUES (%(co)s, 'WH', %(user)s, %(sig)s)"
),
"print_log": (
"INSERT INTO print_log (packet_id, kind, what, by) VALUES (%(packet)s, 'traveler', 'Job traveler', %(user)s)"
),
}
APPEND_ONLY = list(APPEND_INSERTS)
+119
View File
@@ -0,0 +1,119 @@
"""Phase 7 stamps: the controlled-copy and print stamp texts, the short stamp signature made with the seal key, and
its check. Pure functions; no database."""
from __future__ import annotations
import re
from datetime import UTC, datetime
from typing import Any
from pshelpers import TEST_SEAL_KEY
from packetstation.pdf import stamps
KEY = TEST_SEAL_KEY.encode()
NOW = datetime(2026, 10, 17, 21, 4, tzinfo=UTC)
WHO = {"id": "00000000-0000-0000-0000-000000000c1a", "display_name": "Clara Clerk", "emp_no": "4001"}
SEAL = "0123456789abcdef" * 4
USER = WHO["id"]
AT = NOW.isoformat()
def sig(packet_id: int = 7, kind: str = "databook", seal: str = SEAL, user: str = USER, at: str = AT) -> str:
return stamps.stamp_sig(KEY, packet_id, kind, seal, user, at)
def test_stamp_sig_is_16_hex_and_stable() -> None:
s = sig()
assert re.fullmatch(r"[0-9a-f]{16}", s), s
assert sig() == s
def test_stamp_sig_differs_per_kind_packet_user_time_seal_and_key() -> None:
base = sig()
assert sig(kind="traveler") != base
assert sig(packet_id=8) != base
assert sig(user="00000000-0000-0000-0000-000000000bbb") != base
assert sig(at="2026-10-17T21:05:00+00:00") != base
assert sig(seal="f" * 64) != base
assert stamps.stamp_sig(b"another-key-0123456789abcdef", 7, "databook", SEAL, USER, AT) != base
def test_verify_stamp_accepts_and_rejects() -> None:
good = sig()
assert stamps.verify_stamp(KEY, 7, "databook", SEAL, USER, AT, good) is True
assert stamps.verify_stamp(KEY, 7, "databook", SEAL, USER, AT, "0" * 16) is False
assert stamps.verify_stamp(KEY, 8, "databook", SEAL, USER, AT, good) is False
assert stamps.verify_stamp(KEY, 7, "traveler", SEAL, USER, AT, good) is False
# the time is signed to the minute, in any ISO form
assert stamps.verify_stamp(KEY, 7, "databook", SEAL, USER, "2026-10-17T21:04:59+00:00", good) is True
assert stamps.verify_stamp(KEY, 7, "databook", SEAL, USER, "2026-10-17T21:05:00+00:00", good) is False
def test_controlled_stamp_texts() -> None:
filed = stamps.controlled_stamp(WHO, NOW, mode="filed", seal_hash_hex=SEAL)
assert filed.startswith("Controlled copy - exported by Clara Clerk (4001) 2026-10-17 21:04 UTC")
assert "seal 0123456789ab" in filed and "seal 0123456789abc" not in filed
preview = stamps.controlled_stamp(WHO, NOW, mode="preview", seal_hash_hex=None)
assert preview.startswith("Controlled copy - exported by Clara Clerk (4001) 2026-10-17 21:04 UTC")
assert "not yet filed" in preview
printed = stamps.controlled_stamp(WHO, NOW, mode="print", seal_hash_hex=None)
assert "printed by Clara Clerk (4001)" in printed
trav = stamps.controlled_stamp(WHO, NOW, mode="traveler")
assert trav == "Controlled copy - exported by Clara Clerk (4001) 2026-10-17 21:04 UTC"
for text in (filed, preview, printed, trav):
assert text.isascii(), text
assert "—" not in text and "–" not in text
def test_missing_emp_number_reads_question_mark() -> None:
text = stamps.controlled_stamp({**WHO, "emp_no": None}, NOW, mode="preview", seal_hash_hex=None)
assert "Clara Clerk (?)" in text
def packet_like() -> Any:
"""The fields print_stamp reads, as a dict (the read model) and as an object, so either shape works."""
return {
"id": 7,
"drawing": "D-5001",
"rev": "B",
"unit": {
"id": 3,
"number": "L5A1B2C001",
"lot": "001",
"project": {"id": 2, "number": "L5A1B2C", "name": "Shop project"},
},
"project": {"number": "L5A1B2C"},
}
def test_print_stamp_text_is_ascii_and_carries_the_parts() -> None:
text = stamps.print_stamp(WHO, NOW, "Drawing with weld map — sheet 2", packet_like())
assert text.startswith("PRINTED COPY - Drawing with weld map")
assert "printed by Clara Clerk (4001) 2026-10-17 21:04 UTC" in text
assert "L5A1B2C Lot 001 dwg D-5001 RB" in text, text
assert text.endswith(" - verify revision before use"), text
assert text.isascii(), text
def test_print_stamp_replaces_non_ascii() -> None:
who = {**WHO, "display_name": "Zoë Łukasz"}
text = stamps.print_stamp(who, NOW, "MTR → item 3", packet_like())
assert text.isascii(), text
def test_apply_stamp_and_metadata() -> None:
from closehelpers import flat, page_texts, pdf_reader
from pkthelpers import tiny_pdf
line = "Controlled copy - exported by Clara Clerk (4001) 2026-10-17 21:04 UTC - stamp 0123456789abcdef"
for box in (False, True):
out = stamps.apply_stamp(tiny_pdf(2), line, ours={0}, box=box)
texts = page_texts(out)
assert len(texts) == 2 and all(line in flat(t) for t in texts), (box, texts)
out = stamps.set_metadata(tiny_pdf(1), "Title A", "Subject B", "k1, k2", "Packet Station - test")
meta = pdf_reader(out).metadata
assert meta.title == "Title A" and meta.subject == "Subject B" and meta.creator == "Packet Station - test"
assert meta.producer == "Packet Station"
assert meta.get("/Author") == "PESCO - Process Equipment & Service Company, Inc."
assert meta.get("/CreationDate")
+143
View File
@@ -0,0 +1,143 @@
"""Phase 7 traceability CSV: the formula guard of csv_cell, and the export endpoint (content type, file name, CRLF
line ends, no BOM, the header rows, a BOM row with a short and its receipts, the weld rows, the Q.C. rows, the
audit row)."""
from __future__ import annotations
import csv
import io
from collections.abc import Iterator
import pytest
from closehelpers import HEATS, RECEIPT_HEAT, at_complete, q1, qall, rich_complete
from fakedir import FakeDirectory
from pshelpers import app_conn, needs_pg
from shophelpers import Shop, open_shop
from packetstation.pdf.tracecsv import csv_cell
from packetstation.settings import Settings
@pytest.mark.parametrize(
("value", "want"),
[
("=1+1", "'=1+1"),
("+31", "'+31"),
("-2", "'-2"),
("@SUM(A1)", "'@SUM(A1)"),
("\tTAB", "'\tTAB"),
("\rCR", '"\'\rCR"'),
('say "hi"', '"say ""hi"""'),
("a,b", '"a,b"'),
("two\nlines", '"two\nlines"'),
('=HYPERLINK("x")', '"\'=HYPERLINK(""x"")"'),
("HT-10", "HT-10"),
("", ""),
(None, ""),
(3, "3"),
],
)
def test_csv_cell(value: object, want: str) -> None:
assert csv_cell(value) == want
# ---------------------------------------------------------------- the endpoint
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def rows_of(text: str) -> list[list[str]]:
return list(csv.reader(io.StringIO(text, newline="")))
@needs_pg
def test_traceability_csv(shop: Shop) -> None:
pkt = rich_complete(shop)
pid = pkt["id"]
project = pkt["unit"]["project"]["number"]
r = shop("pm.paula").get(f"/api/packets/{pid}/export/traceability.csv")
assert r.status_code == 200, r.text
assert r.headers["content-type"].startswith("text/csv")
cd = r.headers["content-disposition"]
assert "_D-5001_traceability.csv" in cd and project in cd, cd
assert r.headers.get("cache-control") == "no-store"
raw = r.content
assert not raw.startswith(b"\xef\xbb\xbf")
assert b"\r\n" in raw and b"\n" not in raw.replace(b"\r\n", b"")
rows = rows_of(raw.decode("utf-8"))
assert rows[0] == ["PESCO weld & heat traceability record"]
assert rows[1] == ["Project", project, "Lot", "001", "Operation", "WELD-PART"], rows[1]
flat_rows = [",".join(x) for x in rows]
assert any(x.startswith("Drawing,D-5001,Rev,B") for x in flat_rows), flat_rows[:6]
assert any(x.startswith("WPS,WPS-11,Station,Stall 1") for x in flat_rows), flat_rows[:6]
assert any(x.startswith("Filler,ER70S-6") for x in flat_rows), flat_rows[:6]
head = rows.index(
[
"BOM item",
"Qty",
"Issued",
"Location",
"Part ID",
"Description",
"Length",
"Material",
"Heat no.",
"MTR on file",
"Open short qty",
"Short reason",
"Short received",
]
)
bom = {r[0]: r for r in rows[head + 1 : head + 4]}
assert bom["1"][4] == "PIPE-4" and bom["1"][8] == HEATS[1] and bom["1"][9] == "Yes"
assert bom["2"][9] == "Yes"
short = bom["3"]
assert HEATS[3] in short[8] and RECEIPT_HEAT in short[8]
assert short[10] == "1" and short[11] == "On order"
assert short[12].startswith(f"1 pc {RECEIPT_HEAT} "), short
weld_head = next(i for i, x in enumerate(rows) if x[:2] == ["Weld", "Work order"])
assert rows[weld_head] == [
"Weld",
"Work order",
"Piece",
"Joins items",
"Heats joined",
"Type",
"Size",
"Process",
"WPS",
"Stamp",
"Logged",
]
welds = rows[weld_head + 1 : weld_head + 3]
assert [w[0] for w in welds] == ["W1", "W2"]
assert welds[0][3] == "1 + 2" and welds[0][4] == f"{HEATS[1]} + {HEATS[2]}" and welds[0][5] == "Fillet"
assert any(x[:2] == ["Checklist item", "Done"] for x in rows)
qc = next(x for x in rows if x and x[0] == "Q.C. inspection")
assert qc[:4] == ["Q.C. inspection", "accepted", "Inspector", "Quinn QC"], qc
assert "Quinn QC" in qc and "Q1" in qc
assert any(x and x[0] == "NDE" for x in rows)
assert any(x and x[0].startswith("Q.C.: ") for x in rows)
vt = [x for x in rows if x and x[0].startswith("VT W")]
assert [x[0] for x in vt] == ["VT W1", "VT W2"] and all(x[1] == "accept" for x in vt)
au = q1(
shop.settings,
"SELECT detail FROM audit_events WHERE action = 'export' AND packet_id = %s ORDER BY id DESC",
(pid,),
)
assert au is not None and au[0]["kind"] == "csv"
log = qall(shop.settings, "SELECT kind, by_name FROM print_log WHERE packet_id = %s", (pid,))
assert ("csv", "Paula PM") in [tuple(x) for x in log]
@needs_pg
def test_formula_values_are_guarded_in_the_export(shop: Shop) -> None:
pkt = at_complete(shop)
with app_conn(shop.settings) as conn:
conn.execute("UPDATE bom_lines SET description = '=cmd|calc' WHERE packet_id = %s AND item = 1", (pkt["id"],))
r = shop("clerk.clara").get(f"/api/packets/{pkt['id']}/export/traceability.csv")
assert r.status_code == 200
assert "'=cmd|calc" in r.text and ",=cmd" not in r.text