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
+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)