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
+318 -7
View File
@@ -1,17 +1,328 @@
"""The signed ITP PDF. Phase 7 builds it; until then the hook files nothing.
"""The ITP PDF, after the old itpPdfBytes: one run of pages per ITP page group (WP, C, FA), each with the header
table, the code legend, a table per section and the block signature table after a section that closes a block.
`file_signed_itp_pdf` is called inside the sign-off transaction, after the ITP row is set complete. When it returns an
asset id, the sign-off stores it as `signoff_asset_id` and files an automatic ITP document in the unit's Q.C. folder.
`file_signed_itp_pdf` is called inside the sign-off transaction, after the ITP row is set complete. It builds the
PDF, stores it as an asset and returns the asset id. Without settings it files nothing and returns None.
"""
from __future__ import annotations
import contextlib
import io
import os
import tempfile
import uuid
from collections.abc import Mapping
from datetime import datetime
from pathlib import Path
from typing import Any
from pypdf import PdfReader, PdfWriter
def file_signed_itp_pdf(conn: Any, itp_row: Mapping[str, Any], actor: Any) -> uuid.UUID | None:
"""Build and store the signed ITP PDF, and return its asset id. Phase 7 implements it; today it returns None."""
del conn, itp_row, actor
return None
from .. import policy
from ..domain import assets
from ..domain import itp as dom
from . import parts, stamps
from .doc import Doc
from .style import (
CW,
GREEN,
GREY,
PANEL,
REMARK,
Built,
L,
PdfError,
R,
WhoRef,
ascii_only,
clean,
fmt_d,
fmt_dt,
site_tz,
utc_now,
utc_stamp,
who_text,
)
def read_itp(conn: Any, itp_row: Mapping[str, Any]) -> dict[str, Any]:
"""The full ITP read (domain/itp.py read) with a principal that may do nothing; `can` is not used here."""
pp = policy.principal_from_roles(None, [])
return dom.read(conn, itp_row, pp)
def _sig_text(users: Mapping[str, Mapping[str, Any]], ref: Any) -> str:
return parts.name_emp(users, ref)
def _ids(r: Mapping[str, Any]) -> list[Any]:
ids: list[Any] = []
for ln in (r.get("lines") or {}).values():
ids.append(ln.get("by"))
for b in r.get("blocks") or []:
ids += [b.get("signed_by"), b.get("cust_by")]
if r.get("signoff"):
ids.append(r["signoff"].get("by"))
return ids
def pages_of(conn: Any, settings: Any, itp_row: Mapping[str, Any]) -> tuple[bytes, int, dict[str, Any]]:
"""The ITP pages without page numbers or stamp, their count, and the read."""
try:
r = read_itp(conn, itp_row)
d = _draw(r, parts.users_of(conn, _ids(r)), site_tz(settings))
return d.finish(), d.pages, r
except PdfError:
raise
except Exception as e:
raise PdfError(f"The ITP PDF could not be built: {type(e).__name__}.") from e
def reject_lines(rows: list[Mapping[str, Any]], tz: str) -> list[str]:
"""'Rejected M/D/YYYY - NCR n' per reject. An NCR entered later (kind ncr) fills the reject before it."""
out: list[tuple[Any, str | None]] = []
for x in rows:
if x.get("kind") == "ncr":
if out and not out[-1][1]:
out[-1] = (out[-1][0], x.get("ncr"))
continue
out.append((x.get("at"), x.get("ncr")))
return [f"Rejected {fmt_d(at, tz)} - NCR {ncr or 'pending'}" for at, ncr in out]
def _draw(r: Mapping[str, Any], users: Mapping[str, Mapping[str, Any]], tz: str) -> Doc:
template = r.get("template") or {}
hdr = r.get("hdr") or {}
lines = r.get("lines") or {}
blocks = {str(b["key"]): b for b in r.get("blocks") or []}
no = str(r.get("no") or "")
d: Doc | None = None
for pi, code in enumerate(dom.PAGE_ORDER):
secs = [s for s in template.get("sections") or [] if s.get("page") == code]
if not secs:
continue
header = ("Inspection & Test Plan", f"{no} • {pi + 1} of 3: {code} - {dom.PAGE_NAMES[code]}")
if d is None:
d = Doc(header)
else:
d.set_header(*header)
d.add_page()
y = 126.0
y = d.table(
None,
[
["Inspection plan no.", no or "—", "Client", hdr.get("client") or "—"],
["Project name", hdr.get("pname") or "—", "Project no.", hdr.get("pno") or "—"],
["Purchase order", hdr.get("po") or "—", "Unit no.", hdr.get("unit") or "—"],
],
y,
{0: {"w": 86, "color": GREY}, 1: {"w": 172, "bold": True}, 2: {"w": 70, "color": GREY}, 3: {"bold": True}},
size=8,
)
y += 4
n = d.tx("Codes: " + str(template.get("legend") or ""), L, y + 8, size=6.4, color=GREY, max_width=CW)
y += 20 + (n - 1) * 7.4
for s in secs:
body: list[list[Any]] = [
[
{
"content": f"{s.get('n')} {s.get('t') or ''}",
"colspan": 7,
"bold": True,
"fill": PANEL,
"size": 7.6,
}
]
]
for it in s.get("items") or []:
ln = lines.get(str(it.get("n"))) or {}
result = ln.get("result")
rejects = "\n".join(reject_lines(ln.get("rejects") or [], tz))
if result == "na":
appr = "N/A"
elif result == "approve":
by = ln.get("by") or {}
ini = ln.get("initials") or dom.initials(parts.name_of(users, by))
emp = (users.get(str(by.get("id"))) or {}).get("emp_no") if isinstance(by, Mapping) else None
appr = f"Approved {ini} ({emp or ''})"
else:
appr = ""
if rejects:
appr = f"{appr}\n{rejects}" if appr else rejects
cust = dom.cust_code(it, ln)
if ln.get("cust_by"):
cust += f"\n{ln['cust_by']} {fmt_d(ln.get('cust_at'), tz, empty='')}".rstrip()
crit = str(it.get("crit") or "") + (f"\nNote: {ln['note']}" if ln.get("note") else "")
body.append(
[
str(it.get("n") or ""),
it.get("t") or "",
it.get("code") or "",
appr,
fmt_d(ln.get("at"), tz, empty="") if result == "approve" else "",
cust,
crit,
]
)
y = d.table(
["NO", "INSPECTION ITEM", "CODE", "QC APPROVAL", "DATE", "CUSTOMER", "REMARKS / ACCEPTANCE"],
body,
y,
{
0: {"w": 24},
1: {"w": 108, "bold": True},
2: {"w": 30, "align": "center"},
3: {"w": 74},
4: {"w": 44},
5: {"w": 48},
6: {"size": 6.3, "color": REMARK},
},
size=6.8,
)
b = blocks.get(str(s.get("n")))
if b is not None:
signed = (
f"{_sig_text(users, b.get('signed_by'))} {fmt_d(b.get('signed_at'), tz)} - signed with PIN"
if b.get("signed")
else ""
)
cust = (
f"{b.get('cust_name') or ''} {fmt_d(b.get('cust_at'), tz)} (recorded by"
f" {parts.name_of(users, b.get('cust_by'))})"
if b.get("cust_at")
else ""
)
y = d.table(
None,
[
["QN #", b.get("qn") or "", "PESCO QC signature", signed],
[
"Remarks / notes",
b.get("remarks") or "",
"COP complete" if b.get("final") else "Customer complete",
cust,
],
],
y + 2,
{0: {"w": 70, "color": GREY}, 1: {"w": 150}, 2: {"w": 96, "color": GREY}, 3: {"bold": True}},
size=7.4,
)
y += 10
if d is None:
d = Doc(("Inspection & Test Plan", no))
d.tx("The ITP template has no sections.", L, 130, size=9, color=GREY)
if r.get("status") == "complete" and r.get("signoff"):
so = r["signoff"]
d.tx(
f"ITP signed off {fmt_dt(so.get('at'), tz)} by {_sig_text(users, so.get('by'))} - PIN",
L,
748,
size=7.5,
bold=True,
color=GREEN,
)
return d
def finish(data: bytes, r: Mapping[str, Any], stamp: str, now: datetime, *, box: bool) -> bytes:
writer = PdfWriter(clone_from=PdfReader(io.BytesIO(data)))
n = len(writer.pages)
def draw(c: Any, i: int, w: float, h: float) -> None:
del h
c.setFont("Helvetica", 7)
c.setFillColorRGB(GREY[0] / 255, GREY[1] / 255, GREY[2] / 255)
c.drawRightString(R, 20, f"Page {i + 1} of {n}")
if not stamp:
return
if box:
stamps.draw_box_stamp(c, w, stamp)
else:
stamps.draw_line_stamp(c, w, stamp, own=True)
stamps.overlay_writer(writer, draw)
hdr = r.get("hdr") or {}
meta = stamps.metadata(
f"ITP {r.get('no') or ''}",
f"Inspection and test plan {r.get('no') or ''}" + (" - signed off" if r.get("status") == "complete" else ""),
", ".join(str(x) for x in (r.get("no"), hdr.get("pno"), hdr.get("unit"), hdr.get("client"), "ITP") if x),
"Packet Station - controlled copy",
now=now,
)
return stamps.write(writer, meta)
def itp_stamp(who: WhoRef | None, now: datetime, status: Any) -> str:
"""Controlled copy - by Name (emp) UTC - signed off (or OPEN, not signed off)."""
state = "signed off" if status == "complete" else "OPEN, not signed off"
return ascii_only(f"Controlled copy - by {who_text(who)} {utc_stamp(now)} - {state}")
def build(conn: Any, settings: Any, itp_row: Mapping[str, Any], *, who: WhoRef, now: datetime | None = None) -> Built:
"""The ITP PDF as it is now (open or complete; the stamp says which)."""
now = utc_now(now)
data, pages, r = pages_of(conn, settings, itp_row)
warnings: list[str] = []
stamp, sig = stamps.signed_stamp(
settings,
itp_stamp(who, now, r.get("status")),
packet_id=f"itp-{itp_row['id']}",
kind="itp-pdf",
seal_hash_hex=None,
who=who,
now=now,
warnings=warnings,
)
data = finish(data, r, stamp, now, box=False)
return Built(
bytes=data, pages=pages, warnings=warnings, stamp=stamp, stamp_sig=sig, what=f"ITP {clean(r.get('no'))}"
)
def who_of_actor(conn: Any, actor: Any) -> WhoRef:
"""The WhoRef of a session actor (sessions.Actor, or a mapping with id or user_id and display_name)."""
if isinstance(actor, Mapping):
uid = actor.get("user_id") or actor.get("id")
name = actor.get("display_name")
else:
uid = getattr(actor, "user_id", None)
name = getattr(actor, "display_name", None)
info = parts.users_of(conn, [uid]).get(str(uid)) if uid else None
return {
"id": None if uid is None else str(uid),
"display_name": name or (info or {}).get("display_name"),
"emp_no": (info or {}).get("emp_no"),
}
def file_signed_itp_pdf(conn: Any, itp_row: Mapping[str, Any], actor: Any, settings: Any = None) -> uuid.UUID | None:
"""Build the signed ITP PDF, store it as an asset and return its id. Without settings it files nothing (None).
A build failure raises PdfError; the sign-off then answers 500 and nothing is signed off."""
if settings is None:
return None
who = who_of_actor(conn, actor)
built = build(conn, settings, itp_row, who=who)
tmp_dir = Path(settings.assets_dir) / "tmp"
tmp_dir.mkdir(parents=True, exist_ok=True)
fd, name = tempfile.mkstemp(prefix="itp-", suffix=".pdf", dir=tmp_dir)
with os.fdopen(fd, "wb") as f:
f.write(built.bytes)
try:
row = assets.store_file(
conn,
settings,
Path(name),
mime=assets.PDF,
original_name=f"{itp_row['no']}_signed.pdf",
source="itp_signoff",
source_ref={"itp_id": int(itp_row["id"]), "no": str(itp_row["no"])},
uploaded_by=who["id"],
)
except Exception as e:
raise PdfError(f"The signed ITP PDF could not be stored: {type(e).__name__}.") from e
finally:
with contextlib.suppress(OSError):
Path(name).unlink()
aid = row["id"]
return aid if isinstance(aid, uuid.UUID) else uuid.UUID(str(aid))