2026-10-17 23:49:14 -06:00
|
|
|
"""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.
|
2026-10-17 20:08:53 -06:00
|
|
|
|
2026-10-17 23:49:14 -06:00
|
|
|
`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.
|
2026-10-17 20:08:53 -06:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-10-17 23:49:14 -06:00
|
|
|
import contextlib
|
|
|
|
|
import io
|
|
|
|
|
import os
|
|
|
|
|
import tempfile
|
2026-10-17 20:08:53 -06:00
|
|
|
import uuid
|
|
|
|
|
from collections.abc import Mapping
|
2026-10-17 23:49:14 -06:00
|
|
|
from datetime import datetime
|
|
|
|
|
from pathlib import Path
|
2026-10-17 20:08:53 -06:00
|
|
|
from typing import Any
|
|
|
|
|
|
2026-10-17 23:49:14 -06:00
|
|
|
from pypdf import PdfReader, PdfWriter
|
2026-10-17 20:08:53 -06:00
|
|
|
|
2026-10-17 23:49:14 -06:00
|
|
|
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))
|