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
+98 -5
View File
@@ -6,10 +6,11 @@ answer is the full ITP read. ITP writes never look at a packet: a filed packet i
from __future__ import annotations
import logging
from datetime import date
from typing import Any
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, Query, Request, Response
from psycopg.types.json import Jsonb
from pydantic import BaseModel, Field
@@ -17,12 +18,16 @@ from .. import policy
from ..domain import itp as dom
from ..domain import ncr as ncrs
from ..domain import packets
from ..domain import prints as prints_dom
from ..domain.itp_template import ITP_TEMPLATE
from ..domain.pipeline import SignBody, apply_field_cas, jsonable, parse_if_match
from ..text import LIMITS, clean_opt, clean_text
from .deps import Db, Principal, current_principal, get_db
from .deps import Db, Principal, current_principal, get_db, settings_of
from .errors import ApiError
from .packets import WRITE
from .prints import file_response, now_utc, pdf_failed, stamp_of
log = logging.getLogger(__name__)
router = APIRouter(tags=["itp"])
@@ -658,8 +663,10 @@ def sign_off(
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Sign the ITP off: every block signed. The ITP locks; the PDF hook (phase 7) may file a copy."""
"""Sign the ITP off: every block signed. The ITP locks and the signed PDF is filed in the unit's Q.C. folder.
A PDF that cannot be built answers 500 itp_pdf_failed and nothing is signed off."""
from ..pdf import itp as itp_pdf
from ..pdf.style import PdfError
conn = d.conn
row0 = dom.load_row(conn, itp_id)
@@ -676,12 +683,19 @@ def sign_off(
)
row = dom.load_row(conn, itp_id)
assert row is not None
asset_id = itp_pdf.file_signed_itp_pdf(conn, row, p.actor)
try:
asset_id = itp_pdf.file_signed_itp_pdf(conn, row, p.actor, settings=settings_of(request))
except PdfError as e:
log.warning("signed ITP PDF for ITP %s failed: %s", itp_id, e)
text = " ".join(str(e).split()).rstrip(".")[:300] or "an unknown error"
raise ApiError(
500, "itp_pdf_failed", f"The signed ITP PDF could not be built: {text}. The ITP was not signed off."
) from None
if asset_id is not None:
_file_auto_doc(w, row, asset_id)
w.log("ITP signed off", "PDF filed")
else:
w.log("ITP signed off", "PDF filing reserved for phase 7")
w.log("ITP signed off")
return _answer(d, p, itp_id)
@@ -742,3 +756,82 @@ def reopen(
)
w.log("ITP reopened", reason)
return _answer(d, p, itp_id)
# ---------------------------------------------------------------- the ITP PDF and its printed copy
def _itp_row(d: Db, itp_id: int) -> dict[str, Any]:
row = dom.load_row(d.conn, itp_id)
if row is None:
raise ApiError(404, "not_found", dom.MSG_NO_ITP)
return row
@router.get("/api/itps/{itp_id}/pdf")
def itp_pdf_file(
itp_id: int,
request: Request,
download: int = Query(0, ge=0, le=1),
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> Response:
"""The ITP PDF built now, open or complete; the stamp says which. Logged as an export."""
from ..pdf import itp as itp_pdf
from ..pdf.style import PdfError
conn = d.conn
row = _itp_row(d, itp_id)
try:
built = itp_pdf.build(conn, settings_of(request), row, who=prints_dom.who_of(p), now=now_utc())
except PdfError as e:
raise pdf_failed(e) from None
stamp, sig = stamp_of(built)
prints_dom.record(
conn,
p,
action="export",
kind="itp-pdf",
what=f"ITP {row['no'] or ''}".strip(),
pages=built.pages,
unit_id=row["unit_id"],
itp_id=itp_id,
stamp=stamp,
stamp_sig=sig,
)
return file_response(built.bytes, prints_dom.itp_name(row["no"]), download=bool(download))
@router.get("/api/itps/{itp_id}/print")
def itp_print(
itp_id: int,
request: Request,
download: int = Query(0, ge=0, le=1),
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> Response:
"""The ITP PDF stamped PRINTED COPY. Logged as a print."""
from ..pdf import prints as print_pdf
from ..pdf.style import PdfError
conn = d.conn
row = _itp_row(d, itp_id)
try:
built = print_pdf.print_itp(conn, settings_of(request), row, who=prints_dom.who_of(p), now=now_utc())
except PdfError as e:
raise pdf_failed(e) from None
stamp, sig = stamp_of(built)
what = str(getattr(built, "what", None) or f"ITP {row['no'] or ''}".strip())
prints_dom.record(
conn,
p,
action="print",
kind="itp",
what=what,
pages=built.pages,
unit_id=row["unit_id"],
itp_id=itp_id,
stamp=stamp,
stamp_sig=sig,
)
return file_response(built.bytes, prints_dom.itp_name(row["no"]), download=bool(download))