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:
@@ -47,6 +47,9 @@ SEAL_EXCLUDE = (
|
||||
"qc_folder_docs",
|
||||
"itp",
|
||||
"itp_hold",
|
||||
"seals",
|
||||
"vclose_check",
|
||||
"prints",
|
||||
)
|
||||
|
||||
|
||||
@@ -676,6 +679,92 @@ def visual_state(conn: Any, row: Mapping[str, Any]) -> dict[str, Any] | None:
|
||||
}
|
||||
|
||||
|
||||
# Leg status codes the feed records that mean the work order is no longer open in VISUAL: closed and cancelled.
|
||||
LEG_DONE = ("C", "X")
|
||||
|
||||
|
||||
def open_wos(vs: Mapping[str, Any] | None) -> list[str]:
|
||||
"""The work orders the feed last saw open in VISUAL, as "wo_base/lot" text, in leg order without repeats.
|
||||
|
||||
A leg is open when the feed recorded a status for it and that status is not closed (C) or cancelled (X). A leg
|
||||
with no recorded status is left out: the feed has not seen it. The IDs stay text exactly as stored."""
|
||||
out: list[str] = []
|
||||
for leg in (vs or {}).get("legs") or []:
|
||||
if not isinstance(leg, Mapping):
|
||||
continue
|
||||
st = str(leg.get("st") or leg.get("status") or "").strip().upper()
|
||||
if not st or st in LEG_DONE:
|
||||
continue
|
||||
parts = str(leg.get("key") or "").split("/")
|
||||
if len(parts) >= 4:
|
||||
base, lot = "/".join(parts[:-3]), parts[-3]
|
||||
else:
|
||||
base, lot = parts[0], (parts[1] if len(parts) > 1 else "")
|
||||
if not base:
|
||||
continue
|
||||
text = f"{base}/{lot}" if lot else base
|
||||
if text not in out:
|
||||
out.append(text)
|
||||
return out
|
||||
|
||||
|
||||
def vclose_check(row: Mapping[str, Any], vs: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
||||
"""What the feed says about closing the job in VISUAL, next to the Clerical confirm. None for a hand packet.
|
||||
|
||||
`agrees` is true when the Clerical confirm (vclosed) and the feed's closed time are both set or both unset."""
|
||||
if vs is None:
|
||||
return None
|
||||
closed_at = vs.get("closed_at")
|
||||
return {
|
||||
"observed_at": vs.get("observed_at"),
|
||||
"closed_at": closed_at,
|
||||
"open_wos": open_wos(vs),
|
||||
"agrees": (row.get("vclosed_at") is not None) == (closed_at is not None),
|
||||
}
|
||||
|
||||
|
||||
def packet_open_wos(conn: Any, row: Mapping[str, Any]) -> list[str]:
|
||||
"""The open work orders of `vclose_check` for one packets row (empty for a hand packet)."""
|
||||
return open_wos(visual_state(conn, row))
|
||||
|
||||
|
||||
def seal_count(conn: Any, packet_id: int) -> int:
|
||||
r = conn.execute("SELECT count(*) FROM packet_seals WHERE packet_id = %s", (packet_id,)).fetchone()
|
||||
return int(r[0]) if r else 0
|
||||
|
||||
|
||||
def seal_view(conn: Any, s: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
||||
"""The newest seal row, broken or not, with who sealed it and the data book filed with it."""
|
||||
if s is None:
|
||||
return None
|
||||
people = People(conn, [s["sealed_by"], s["broken_by"]])
|
||||
book: dict[str, Any] | None = None
|
||||
aid = s.get("databook_asset_id")
|
||||
if aid is not None:
|
||||
a = one(conn, "SELECT id, size, original_name FROM assets WHERE id = %s", (aid,))
|
||||
book = {
|
||||
"asset_id": str(aid),
|
||||
"url": f"/_blob/{aid}",
|
||||
"download_url": f"/_blob/{aid}?download=1",
|
||||
"pages": s.get("databook_pages"),
|
||||
"size": None if a is None else a["size"],
|
||||
"file_name": None if a is None else a["original_name"],
|
||||
}
|
||||
wos = s.get("open_wos")
|
||||
return {
|
||||
"id": s["id"],
|
||||
"sealed_at": s["sealed_at"],
|
||||
"sealed_by": people.ref(s["sealed_by"]),
|
||||
"key_id": s["key_id"],
|
||||
"signed": s["signature_id"] is not None,
|
||||
"broken_at": s["broken_at"],
|
||||
"broken_by": people.ref(s["broken_by"]),
|
||||
"broken_reason": s["broken_reason"],
|
||||
"open_wos": None if wos is None else list(wos),
|
||||
"databook": book,
|
||||
}
|
||||
|
||||
|
||||
def build(
|
||||
conn: Any,
|
||||
row: Mapping[str, Any],
|
||||
@@ -787,16 +876,15 @@ def build(
|
||||
out["qc_last"] = out["qc_history"][-1] if out["qc_history"] else None
|
||||
out["change_orders"] = [_co(c, ch, people) for c in ch.change_orders]
|
||||
out["docs"] = [_doc(d, people, seal=seal) for d in ch.docs]
|
||||
out["seal"] = (
|
||||
None
|
||||
if ch.seal is None
|
||||
else {"sealed_at": ch.seal["sealed_at"], "key_id": ch.seal["key_id"], "broken_at": ch.seal["broken_at"]}
|
||||
)
|
||||
if not seal:
|
||||
out["seal"] = seal_view(conn, ch.seal)
|
||||
out["seals"] = seal_count(conn, row["id"]) if ch.seal is not None else 0
|
||||
out["checklist_items"] = checklists.checklist_for(weld, row["drawing"], row["rev"], row["wps"], ch.templates)
|
||||
out["qc_items"] = qc_items_view(row, ch)
|
||||
out["flag"] = flag_of(row["status"], row["source_kind"], row["flags"], row["drawing_state"], row["visual"])
|
||||
if not seal:
|
||||
out["visual_state"] = visual_state(conn, row)
|
||||
out["vclose_check"] = vclose_check(row, out["visual_state"])
|
||||
if include_children:
|
||||
from . import shop # shop builds on this module
|
||||
|
||||
|
||||
Reference in New Issue
Block a user