Phase 7: close, file, PDFs and prints
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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user