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
+289
View File
@@ -0,0 +1,289 @@
"""The traceability CSV: the old exportCsv, row for row, from the packet read model.
Every cell goes through `csv_cell`, the formula guard: a value that starts with =, +, -, @, a tab or a CR gets a
leading apostrophe, so a spreadsheet shows it as text and never runs it. Lines end in CRLF. UTF-8, no BOM.
"""
from __future__ import annotations
from collections.abc import Iterable, Mapping, Sequence
from datetime import date, datetime
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from ..domain import packets as packets_dom
from ..domain.pipeline import qty_text
FORMULA_START = ("=", "+", "-", "@", "\t", "\r")
TITLE = "PESCO weld & heat traceability record"
BOM_HEAD = (
"BOM item",
"Qty",
"Issued",
"Location",
"Part ID",
"Description",
"Length",
"Material",
"Heat no.",
"MTR on file",
"Open short qty",
"Short reason",
"Short received",
)
WELD_HEAD = (
"Weld",
"Work order",
"Piece",
"Joins items",
"Heats joined",
"Type",
"Size",
"Process",
"WPS",
"Stamp",
"Logged",
)
CHECK_HEAD = ("Checklist item", "Done", "Stamp", "When")
def csv_cell(value: Any) -> str:
"""One CSV cell with the formula guard and RFC 4180 quoting."""
text = "" if value is None else str(value)
if text.startswith(FORMULA_START):
text = "'" + text
if any(c in text for c in ('"', ",", "\n", "\r")):
text = '"' + text.replace('"', '""') + '"'
return text
def to_csv(rows: Iterable[Sequence[Any]]) -> str:
return "\r\n".join(",".join(csv_cell(v) for v in r) for r in rows)
def _zone(tz: str | None) -> ZoneInfo:
try:
return ZoneInfo(tz or "America/Denver")
except (ZoneInfoNotFoundError, ValueError):
return ZoneInfo("America/Denver")
def _when(value: Any, tz: str | None) -> datetime | date | None:
if value is None or value == "":
return None
if isinstance(value, datetime):
return value.astimezone(_zone(tz)) if value.tzinfo else value
if isinstance(value, date):
return value
try:
d = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
except ValueError:
return None
return d.astimezone(_zone(tz)) if d.tzinfo else d
def fmt_dt(value: Any, tz: str | None) -> str:
"""M/D/YYYY h:mm AM in the site time zone; empty for no value."""
d = _when(value, tz)
if d is None:
return ""
if not isinstance(d, datetime):
return f"{d.month}/{d.day}/{d.year}"
hour = d.hour % 12 or 12
return f"{d.month}/{d.day}/{d.year} {hour}:{d.minute:02d} {'AM' if d.hour < 12 else 'PM'}"
def fmt_d(value: Any, tz: str | None) -> str:
"""M/D/YYYY in the site time zone; empty for no value."""
d = _when(value, tz)
if d is None:
return ""
return f"{d.month}/{d.day}/{d.year}"
def _num(value: Any) -> str:
return "" if value is None else qty_text(value)
def _done(entry: Any) -> bool:
return entry is True or (isinstance(entry, Mapping) and entry.get("done") is True)
def _entry(entry: Any) -> Mapping[str, Any]:
return entry if isinstance(entry, Mapping) else {}
def _shorts(conn: Any, packet_id: int) -> dict[int, dict[str, Any]]:
"""The newest short per BOM line, open or closed, with its receipts (a closed short still has its record)."""
out: dict[int, dict[str, Any]] = {}
for s in packets_dom.rows(conn, "SELECT * FROM bom_shorts WHERE packet_id = %s ORDER BY id", (packet_id,)):
out[int(s["bom_line_id"])] = {**s, "receipts": []}
ids = {int(s["id"]): line for line, s in out.items()}
if ids:
for r in packets_dom.rows(
conn, "SELECT * FROM short_receipts WHERE short_id = ANY(%s) ORDER BY id", (list(ids),)
):
out[ids[int(r["short_id"])]]["receipts"].append(r)
return out
def _stage(read: Mapping[str, Any]) -> Mapping[str, Any]:
"""The open stage, else the last closed one, else nothing."""
if read.get("stage"):
return read["stage"]
stages = read.get("stages") or []
return stages[-1] if stages else {}
def _inspection(conn: Any, read: Mapping[str, Any]) -> Mapping[str, Any] | None:
"""The open inspection, else the newest closed one (its snapshot carries every field)."""
if read.get("qc"):
return read["qc"]
r = packets_dom.one(
conn,
"SELECT result, inspector, at, snapshot FROM qc_history WHERE packet_id = %s ORDER BY id DESC LIMIT 1",
(read["id"],),
)
if r is None:
return None
snap = r["snapshot"] if isinstance(r["snapshot"], Mapping) else {}
return {
**snap,
"result": r["result"] or snap.get("result"),
"inspector": r["inspector"] or snap.get("inspector"),
"result_at": snap.get("result_at") or r["at"],
}
def rows_for(conn: Any, read: Mapping[str, Any], tz: str | None) -> list[list[Any]]:
"""Every CSV row for one packet read model."""
unit = read.get("unit") or {}
project = (unit.get("project") or {}).get("number") or ""
lot = unit.get("lot") or unit.get("number") or ""
stage = _stage(read)
welder = stage.get("welder") or {}
station = stage.get("station") or {}
out: list[list[Any]] = [
[TITLE],
["Project", project, "Lot", lot, "Operation", read.get("op")],
[
"Drawing",
read.get("drawing"),
"Rev",
read.get("rev"),
"Title",
read.get("title"),
"Customer",
read.get("customer"),
],
[
"WPS",
read.get("wps"),
"Station",
station.get("name"),
"Welder",
welder.get("display_name"),
"Stamp",
welder.get("stamp"),
],
["Filler", stage.get("filler"), "Filler lot", stage.get("filler_lot"), "Preheat", stage.get("preheat")],
[],
list(BOM_HEAD),
]
bom = read.get("bom") or []
shorts = _shorts(conn, int(read["id"]))
for b in bom:
s = shorts.get(int(b["id"])) if b.get("id") is not None else None
open_short = b.get("short")
heat = b.get("heat") or ("N/A" if b.get("na") else "")
received = "; ".join(
f"{_num(r['qty'])} pc {r['heat'] or ''} {fmt_d(r['at'], tz)}" for r in (s or {}).get("receipts", [])
)
out.append(
[
b.get("item"),
_num(b.get("qty")),
_num(b.get("issued")),
b.get("loc") or "",
b.get("part_id"),
b.get("description"),
b.get("length"),
b.get("material"),
heat,
"Yes" if b.get("mtr_asset_id") else "No",
_num(open_short.get("qty_open")) if open_short else "",
(s.get("reason") or "") if s else "",
received,
]
)
out += [[], list(WELD_HEAD)]
heat_of = {str(b.get("item")): b.get("heat") for b in bom}
for w in read.get("welds") or []:
items = [str(i) for i in (w.get("items") or [])]
out.append(
[
f"W{w.get('weld_no')}",
w.get("wo_base"),
w.get("piece"),
" + ".join(items),
" + ".join((heat_of.get(i) or "-") for i in items),
w.get("type"),
w.get("size"),
w.get("process"),
read.get("wps"),
w.get("stamp"),
fmt_dt(w.get("at"), tz),
]
)
out += [[], list(CHECK_HEAD)]
checklist = stage.get("checklist") or {}
for item in read.get("checklist_items") or []:
c = checklist.get(item["key"])
e = _entry(c)
out.append([item["text"], "Yes" if _done(c) else "No", e.get("stamp") or "", fmt_dt(e.get("at"), tz)])
insp = _inspection(conn, read)
if insp is not None:
out += [
[],
[
"Q.C. inspection",
insp.get("result") or "open",
"Inspector",
insp.get("inspector"),
"Stamp",
insp.get("stamp"),
"Date",
fmt_dt(insp.get("result_at"), tz),
],
[
"NDE",
insp.get("nde_method"),
insp.get("nde_result"),
insp.get("nde_report"),
"Hardness",
insp.get("hardness"),
],
]
checks = _entry(insp.get("checks"))
for item in read.get("qc_items") or []:
c = checks.get(item["key"])
e = _entry(c)
out.append(
[
"Q.C.: " + str(item["text"]),
"Yes" if _done(c) else "No",
e.get("stamp") or "",
fmt_dt(e.get("at"), tz),
]
)
exam = _entry(insp.get("weld_exam"))
for w in read.get("welds") or []:
v = _entry(exam.get(str(w.get("weld_no"))))
out.append([f"VT W{w.get('weld_no')}", v.get("result") or "", v.get("note") or ""])
return out
def build(conn: Any, settings: Any, packet_id: int) -> tuple[str, dict[str, Any]]:
"""The CSV text and the read model it came from. 404 not_found for an unknown packet."""
read = packets_dom.read_packet(conn, packet_id, None)
return to_csv(rows_for(conn, read, getattr(settings, "site_tz", None))), read