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))
+5 -2
View File
@@ -113,6 +113,7 @@ class TransitionBody(BaseModel):
location: str | None = Field(default=None, max_length=500)
for_sup_id: uuid.UUID | None = None
ncr: str | None = Field(default=None, max_length=100)
confirm_open_wos: bool | None = None
sign: SignBody | None = None
it_reason: str | None = Field(default=None, max_length=1000)
@@ -501,7 +502,7 @@ TRANSITION_FIELDS: dict[policy.Action, tuple[str, ...]] = {
A.QC_REJECT: ("reason", "ncr"),
A.QC_ACCEPT: ("routed_to", "note"),
A.QC_ACCEPT_AND_ROUTE: ("dept", "note"),
A.FILE: ("note",),
A.FILE: ("note", "confirm_open_wos"),
A.ADMIN_REOPEN: ("reason",),
}
DEPT_ACTIONS = (A.RELEASE_TO_SHOP, A.ROUTE_FROM_REVIEW, A.QC_ACCEPT_AND_ROUTE)
@@ -918,7 +919,9 @@ def set_vclosed(
"SELECT DISTINCT wo_base FROM packet_work_orders WHERE packet_id = %s ORDER BY 1", (packet_id,)
)
]
w.note = ", ".join(wos) or None
still_open = packets.packet_open_wos(d.conn, w.packet)
suffix = "VISUAL still shows open: " + ", ".join(still_open) if still_open else None
w.note = " · ".join(x for x in (", ".join(wos), suffix) if x) or None
else:
d.conn.execute("UPDATE packets SET vclosed_at = NULL, vclosed_by = NULL WHERE id = %s", (packet_id,))
w.changed("vclosed")
+346
View File
@@ -0,0 +1,346 @@
"""Printed copies and exports of a packet: the data book and traveler PDFs, the traceability CSV, the print kinds and
the print log.
Reads only: any signed-in role that can open the packet may view and download them (the PM role too). Each answer
writes one print_log row and one audit row (`print` or `export`) after the bytes were built. A removed or unknown
packet is 404. The PDF builders live in packetstation.pdf and are imported when a request needs them.
"""
from __future__ import annotations
import logging
import uuid
from collections.abc import Mapping
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Depends, Query, Request, Response
from ..domain import assets as asset_store
from ..domain import packets
from ..domain import prints as prints_dom
from .deps import Db, Principal, current_principal, get_db, settings_of
from .errors import ApiError
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/packets", tags=["prints"])
PDF = "application/pdf"
CSV = "text/csv; charset=utf-8"
PRINT_KINDS = ("traveler", "map-h", "map-w", "clean", "void", "databook", "doc")
SHEET_KINDS = ("map-h", "map-w", "clean")
WHAT = {
"traveler": "Job traveler",
"map-h": "Drawing with heat map",
"map-w": "Drawing with weld map",
"clean": "Drawing (no markup)",
"void": "Superseded drawings (VOID)",
"databook": "Data book",
}
BOOK_STATUSES = ("complete", "filed")
MSG_BOOK_STATUS = "The data book is built once the packet is back from Q.C."
MSG_NOTHING = "Nothing to print."
MSG_PDF = "The PDF could not be built: {reason}."
# ---------------------------------------------------------------- shared helpers (api/itp.py uses them too)
def file_response(data: bytes, filename: str, *, download: bool, media_type: str = PDF) -> Response:
"""The bytes with Content-Disposition (inline unless download), nosniff and no-store."""
return Response(
content=data,
media_type=media_type,
headers={
"Content-Disposition": prints_dom.disposition(filename, download),
"X-Content-Type-Options": "nosniff",
"Cache-Control": "no-store",
},
)
def pdf_failed(e: BaseException) -> ApiError:
text = " ".join(str(e).split()).rstrip(".")[:300] or "an unknown error"
return ApiError(500, "pdf_failed", MSG_PDF.format(reason=text))
def now_utc() -> datetime:
return datetime.now(UTC)
def stamp_of(built: Any) -> tuple[str | None, str | None]:
"""The stamp line and its signature, when the builder reports them."""
stamp = getattr(built, "stamp", None) or None
sig = getattr(built, "stamp_sig", None) or None
return (None if stamp is None else str(stamp)), (None if sig is None else str(sig))
def _packet(conn: Any, packet_id: int) -> dict[str, Any]:
"""The packets row; 404 for an unknown or removed packet."""
row = packets.load_row(conn, packet_id)
if row is None or row["removed_at"] is not None:
raise ApiError(404, "not_found", "No such packet.")
return row
def _read(conn: Any, packet_id: int) -> dict[str, Any]:
return packets.read_packet(conn, packet_id, None)
def _filed_book(conn: Any, packet_id: int) -> dict[str, Any] | None:
"""The unbroken seal's stored data book asset row, with the page count, or None."""
s = packets.one(
conn,
"SELECT databook_asset_id, databook_pages FROM packet_seals WHERE packet_id = %s AND broken_at IS NULL"
" AND databook_asset_id IS NOT NULL ORDER BY id DESC LIMIT 1",
(packet_id,),
)
if s is None:
return None
a = asset_store.get(conn, s["databook_asset_id"])
if a is None:
return None
return {**a, "pages": s["databook_pages"]}
def _asset_bytes(request: Request, row: dict[str, Any]) -> bytes:
return asset_store.open_blob(settings_of(request), row).read_bytes()
def _who(p: Principal) -> Any:
return prints_dom.who_of(p)
# ---------------------------------------------------------------- exports
@router.get("/{packet_id}/pdf/databook")
def databook_pdf(
packet_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 filed data book as stored, or (before filing) one built now and stamped "not yet filed"."""
conn = d.conn
row = _packet(conn, packet_id)
if row["status"] not in BOOK_STATUSES:
raise ApiError(409, "wrong_status", MSG_BOOK_STATUS)
read = _read(conn, packet_id)
name = prints_dom.databook_name(read)
book = _filed_book(conn, packet_id) if row["status"] == "filed" else None
if book is not None:
data = _asset_bytes(request, book)
pages = book["pages"]
stamp, sig = None, None
asset_id: Any = book["id"]
else:
from ..pdf import databook
from ..pdf.style import PdfError
try:
built = databook.build(conn, settings_of(request), packet_id, who=_who(p), now=now_utc(), mode="preview")
except PdfError as e:
raise pdf_failed(e) from None
data, pages, asset_id = built.bytes, built.pages, None
stamp, sig = stamp_of(built)
prints_dom.record(
conn,
p,
action="export",
kind="databook-export",
what="Data book" if book is not None else "Data book (not yet filed)",
pages=pages,
packet_id=packet_id,
unit_id=row["unit_id"],
asset_id=asset_id,
stamp=stamp,
stamp_sig=sig,
)
return file_response(data, name, download=bool(download))
@router.get("/{packet_id}/pdf/traveler")
def traveler_pdf(
packet_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 job traveler with its work order QR codes, stamped as a controlled copy."""
from ..pdf import traveler
from ..pdf.style import PdfError
conn = d.conn
row = _packet(conn, packet_id)
try:
built = traveler.build(conn, settings_of(request), packet_id, who=_who(p), now=now_utc())
except PdfError as e:
raise pdf_failed(e) from None
stamp, sig = stamp_of(built)
read = _read(conn, packet_id)
prints_dom.record(
conn,
p,
action="export",
kind="traveler",
what="Job traveler",
pages=built.pages,
packet_id=packet_id,
unit_id=row["unit_id"],
stamp=stamp,
stamp_sig=sig,
)
return file_response(built.bytes, prints_dom.traveler_name(read), download=bool(download))
@router.get("/{packet_id}/export/traceability.csv")
def traceability_csv(
packet_id: int,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> Response:
"""The weld and heat traceability record as CSV (always a download)."""
from ..pdf import tracecsv
conn = d.conn
row = _packet(conn, packet_id)
text, read = tracecsv.build(conn, settings_of(request), packet_id)
prints_dom.record(
conn,
p,
action="export",
kind="csv",
what="Traceability record (CSV)",
pages=None,
packet_id=packet_id,
unit_id=row["unit_id"],
)
return file_response(text.encode("utf-8"), prints_dom.csv_name(read), download=True, media_type=CSV)
# ---------------------------------------------------------------- prints
def _doc_label(conn: Any, packet_id: int, unit_id: int, asset_id: uuid.UUID) -> str | None:
"""The label of a document this packet may print, or None when the asset is not one of its documents."""
for b in packets.rows(
conn,
"SELECT item, heat FROM bom_lines WHERE packet_id = %s AND mtr_asset_id = %s ORDER BY item",
(packet_id, asset_id),
):
return " ".join(x for x in (f"MTR - item {b['item']}", b["heat"] or "") if x)
r = packets.one(
conn,
"SELECT title FROM packet_docs WHERE packet_id = %s AND asset_id = %s ORDER BY id DESC LIMIT 1",
(packet_id, asset_id),
)
if r is not None:
return str(r["title"] or "Document")
r = packets.one(
conn,
"SELECT d.type, d.title FROM qc_docs d JOIN units u ON u.id = %s"
" WHERE d.asset_id = %s AND (d.unit_id = u.id OR d.project_id = u.project_id) ORDER BY d.id DESC LIMIT 1",
(unit_id, asset_id),
)
if r is not None:
return " - ".join(str(x) for x in (r["type"], r["title"]) if x)
r = packets.one(
conn,
"SELECT id FROM packet_seals WHERE packet_id = %s AND databook_asset_id = %s LIMIT 1",
(packet_id, asset_id),
)
if r is not None:
return "Data book"
return None
def _check_sheet(read: Mapping[str, Any], sheet: int) -> None:
if not any(int(pg["n"]) == sheet and pg.get("type") == "drawing" for pg in read.get("pages") or []):
raise ApiError(404, "not_found", "No such drawing sheet.")
@router.get("/{packet_id}/print/{kind}")
def print_copy(
packet_id: int,
kind: str,
request: Request,
sheet: int | None = Query(None, ge=1, le=100_000),
asset: uuid.UUID | None = Query(None), # noqa: B008
download: int = Query(0, ge=0, le=1),
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> Response:
"""A PRINTED COPY: the pages stamped with who printed them and when, and "verify revision before use"."""
if kind not in PRINT_KINDS:
raise ApiError(404, "not_found", "No such print.")
conn = d.conn
row = _packet(conn, packet_id)
read = _read(conn, packet_id)
if kind not in SHEET_KINDS:
sheet = None
what = WHAT.get(kind, "")
asset_id: uuid.UUID | None = None
if kind == "doc":
label = _doc_label(conn, packet_id, int(row["unit_id"]), asset) if asset is not None else None
if asset is None or label is None:
raise ApiError(404, "not_found", "No such document on this packet.")
what, asset_id = label, asset
elif kind == "databook" and row["status"] not in BOOK_STATUSES:
raise ApiError(409, "wrong_status", MSG_BOOK_STATUS)
elif kind == "void" and not any((pg.get("void") is not None) for pg in read.get("pages") or []):
raise ApiError(404, "nothing_to_print", MSG_NOTHING)
if sheet is not None:
_check_sheet(read, sheet)
what = f"{what} (sheet {sheet})"
if kind == "databook" and row["status"] == "filed":
book = _filed_book(conn, packet_id)
asset_id = None if book is None else book["id"]
from ..pdf import prints as print_pdf
from ..pdf.style import NothingToPrint, PdfError
try:
built = print_pdf.build_print(
conn, settings_of(request), packet_id, kind, who=_who(p), now=now_utc(), sheet=sheet, asset_id=asset_id
)
except NothingToPrint:
raise ApiError(404, "nothing_to_print", MSG_NOTHING) from None
except PdfError as e:
raise pdf_failed(e) from None
what = str(getattr(built, "what", None) or what)
stamp, sig = stamp_of(built)
detail: dict[str, Any] = {}
if sheet is not None:
detail["sheet"] = sheet
prints_dom.record(
conn,
p,
action="print",
kind=kind,
what=what,
pages=built.pages,
packet_id=packet_id,
unit_id=row["unit_id"],
asset_id=asset_id,
stamp=stamp,
stamp_sig=sig,
detail=detail,
)
return file_response(built.bytes, prints_dom.print_name(read, what), download=bool(download))
@router.get("/{packet_id}/prints")
def list_prints(
packet_id: int,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""The packet's printed copies and exports, newest first (at most 200)."""
_packet(d.conn, packet_id)
return {"prints": prints_dom.list_prints(d.conn, packet_id)}
+93 -5
View File
@@ -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
+112 -8
View File
@@ -14,12 +14,16 @@ Child-row endpoints build on the same pieces: `check_row_version`, `next_counter
from __future__ import annotations
import contextlib
import hashlib
import hmac
import logging
import os
import tempfile
from collections.abc import Iterator, Mapping, Sequence
from contextlib import contextmanager
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal
from fastapi import Request
@@ -45,6 +49,16 @@ MSG_STALE = "Someone else already took this step."
MSG_VERSION = "The packet changed since you opened it. Reload and try again."
MSG_ROW = "Someone else changed this line. Reload and try again."
MSG_FIELD = "Someone else changed this since you opened it."
MSG_DATABOOK = "The data book could not be built: {reason}. Nothing was filed."
def open_wos_text(wos: Sequence[str]) -> str:
"""The FILE confirm question: "VISUAL still shows 2 work orders open on this packet: L489052/1, L489052/2. File
anyway?" """
n = len(wos)
what = "1 work order" if n == 1 else f"{n} work orders"
return f"VISUAL still shows {what} open on this packet: {', '.join(wos)}. File anyway?"
# Codes that answer 400 (the UI asks for input and retries); every other refusal answers 403.
ASK_CODES = frozenset({"it_reason_required", "reason_required"})
@@ -143,6 +157,17 @@ class Write:
def rule(self) -> policy.Rule:
return policy.RULES[self.action]
@property
def settings(self) -> Any:
return _settings(self)
@property
def actor_ref(self) -> Any:
"""The signed-in person as the PDF builders' WhoRef: id, display name and employee number."""
from . import prints
return prints.who_of(self.principal)
def log(self, action_label: str, note: str | None = None) -> None:
"""Override the packet_log label (and note) for this write."""
self.label = action_label
@@ -323,6 +348,11 @@ def packet_write(
bypass = bypass or dd.admin_bypass
if if_match is not None and int(if_match) != int(row["version"]):
raise ApiError(409, "version_conflict", MSG_VERSION, extra={"current": current_of(row)})
if action is A.FILE and extra.get("confirm_open_wos") is not True:
# Before the signature: a cancelled confirm costs no attempt and writes nothing.
wos = packets.packet_open_wos(conn, row)
if wos:
raise ApiError(409, "open_work_orders", open_wos_text(wos), extra={"work_orders": wos})
w = Write(
conn=conn,
principal=p,
@@ -770,14 +800,7 @@ def apply_transition(w: Write) -> None:
_set_packet(w, "status = %s::packet_status, status_at = now(), dept_code = %s", (to_status, to_dept))
if a is A.FILE:
key = _seal_key(w)
row = packets.load_row(w.conn, w.id)
assert row is not None
content = packets.seal_content(w.conn, row)
w.conn.execute(
"INSERT INTO packet_seals (packet_id, hash, key_id, sealed_by, signature_id) VALUES (%s, %s, %s, %s, %s)",
(w.id, seal_hash(key, content), seal_key_id(key), w.user_id, w.signature.id if w.signature else None),
)
_file(w)
elif a is A.ADMIN_REOPEN:
w.conn.execute(
"UPDATE packet_seals SET broken_at = now(), broken_by = %s, broken_reason = %s"
@@ -807,6 +830,84 @@ def apply_transition(w: Write) -> None:
w.note = reason or _note(w)
def _file(w: Write) -> None:
"""FILE: the data book date, the seal, and the data book built and stored on the seal, all in this transaction.
A data book that cannot be built answers 500 databook_failed and the whole filing rolls back."""
key = _seal_key(w)
_set_packet(w, "databook_at = now()")
row = packets.load_row(w.conn, w.id)
assert row is not None
wos = packets.packet_open_wos(w.conn, row) if w.extra.get("confirm_open_wos") is True else []
content = packets.seal_content(w.conn, row)
digest = seal_hash(key, content)
kid = seal_key_id(key)
seal_id, sealed_at = w.conn.execute(
"INSERT INTO packet_seals (packet_id, hash, key_id, sealed_by, signature_id, open_wos)"
" VALUES (%s, %s, %s, %s, %s, %s) RETURNING id, sealed_at",
(w.id, digest, kid, w.user_id, w.signature.id if w.signature else None, wos or None),
).fetchone()
pages = _file_databook(w, row, {"hash": digest.hex(), "key_id": kid, "sealed_at": sealed_at}, int(seal_id))
w.log_also("Data book filed", f"{pages} pages")
if wos:
w.note = " · ".join(x for x in (_note(w), "Filed with work orders open in VISUAL: " + ", ".join(wos)) if x)
w.detail["open_wos"] = wos
w.detail["seal_id"] = int(seal_id)
w.detail["databook_pages"] = pages
def _file_databook(w: Write, row: Mapping[str, Any], seal: dict[str, Any], seal_id: int) -> int:
"""Build the filed data book, store it as an asset and put it on the seal row. Returns its page count."""
from ..pdf import databook
from ..pdf.style import PdfError
from . import assets
from . import prints as prints_dom
settings = _settings(w)
try:
built = databook.build(w.conn, settings, w.id, who=w.actor_ref, seal=seal, mode="filed")
except PdfError as e:
log.warning("data book for packet %s failed: %s", w.id, e)
raise ApiError(500, "databook_failed", MSG_DATABOOK.format(reason=_reason_text(e))) from None
read = packets.read_packet(w.conn, w.id, None, include_children=False)
name = prints_dom.databook_name(read)
tmp_dir = Path(settings.assets_dir) / "tmp"
tmp_dir.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(prefix="databook-", suffix=".pdf", dir=tmp_dir)
with os.fdopen(fd, "wb") as f:
f.write(built.bytes)
try:
stored = assets.store_file(
w.conn,
settings,
Path(tmp_name),
mime=assets.PDF,
original_name=name,
source="databook",
source_ref={"packet_id": w.id, "seal_id": seal_id},
uploaded_by=w.user_id,
packet_id=w.id,
)
except ApiError as e:
log.warning("data book for packet %s was not stored: %s", w.id, e.detail)
raise ApiError(500, "databook_failed", MSG_DATABOOK.format(reason=e.detail.rstrip("."))) from None
finally:
with contextlib.suppress(OSError):
Path(tmp_name).unlink()
pages = int(built.pages)
w.conn.execute(
"UPDATE packet_seals SET databook_asset_id = %s, databook_pages = %s WHERE id = %s",
(stored["id"], pages, seal_id),
)
w.detail["databook_asset_id"] = str(stored["id"])
return pages
def _reason_text(e: BaseException) -> str:
text = " ".join(str(e).split()).rstrip(".")
return text[:300] or "an unknown error"
def verify_seal(conn: Any, settings: Any, packet_id: int) -> dict[str, Any]:
"""The seal history of a packet and whether the current unbroken seal still matches the packet."""
seals = packets.rows(conn, "SELECT * FROM packet_seals WHERE packet_id = %s ORDER BY id DESC", (packet_id,))
@@ -834,6 +935,9 @@ def verify_seal(conn: Any, settings: Any, packet_id: int) -> dict[str, Any]:
"broken_at": s["broken_at"],
"broken_by": people.ref(s["broken_by"]),
"broken_reason": s["broken_reason"],
"open_wos": None if s.get("open_wos") is None else list(s["open_wos"]),
"databook_asset_id": None if s.get("databook_asset_id") is None else str(s["databook_asset_id"]),
"databook_pages": s.get("databook_pages"),
}
return {"seal": shape(current) if current else None, "valid": valid, "seals": [shape(s) for s in seals]}
+182
View File
@@ -0,0 +1,182 @@
"""Printed copies and exports: who the PDF builders stamp, the file names, and the append-only print log.
Every print and every export writes one print_log row and one audit row, in its own transaction, after the bytes
were built. Prints and exports are not packet changes: they write no packet_log row and do not bump the version.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, Literal, cast
from urllib.parse import quote
from .. import audit
from ..api import deps
from . import assets
from . import packets as packets_dom
if TYPE_CHECKING:
from ..pdf.style import WhoRef
PRINTS_LIMIT = 200
# The print_log kinds of the exports (prints use the print kind itself), and the audit kind of each.
EXPORT_KINDS = {"databook-export": "databook", "traveler": "traveler", "csv": "csv", "itp-pdf": "itp"}
def who_of(p: deps.Principal) -> WhoRef:
"""The signed-in person as the builders' WhoRef: {"id", "display_name", "emp_no"}."""
emp = p.user.get("emp_no")
return cast(
"WhoRef",
{
"id": str(p.user_id),
"display_name": str(p.user.get("display_name") or p.user.get("sam_account") or ""),
"emp_no": None if emp is None or str(emp).strip() == "" else str(emp),
},
)
def who_of_user(conn: Any, user_id: Any) -> WhoRef:
"""The same WhoRef for a user id (the ITP hook gets an actor, not a principal)."""
r = conn.execute("SELECT display_name, emp_no FROM users WHERE id = %s", (user_id,)).fetchone()
name = str(r[0] or "") if r else ""
emp = r[1] if r else None
return cast(
"WhoRef",
{"id": str(user_id), "display_name": name, "emp_no": None if emp is None or str(emp) == "" else str(emp)},
)
# ---------------------------------------------------------------- file names
def job_base(read: Mapping[str, Any]) -> str:
"""{project}-Lot{lot}: the top project number and the unit's lot, as the old build named its files."""
unit = read.get("unit") or {}
project = (unit.get("project") or {}).get("number") or ""
lot = unit.get("lot") or unit.get("number") or ""
return f"{project}-Lot{lot}"
def _named(text: str, fallback: str) -> str:
return assets.safe_name(text) or fallback
def databook_name(read: Mapping[str, Any]) -> str:
return _named(f"{job_base(read)}_{read.get('drawing') or ''}_Manufacturing_Data_Book.pdf", "Data_Book.pdf")
def traveler_name(read: Mapping[str, Any]) -> str:
return _named(f"{job_base(read)}_Job_Traveler.pdf", "Job_Traveler.pdf")
def print_name(read: Mapping[str, Any], what: str) -> str:
return _named(f"{job_base(read)} {what}.pdf", "Print.pdf")
def csv_name(read: Mapping[str, Any]) -> str:
return _named(f"{job_base(read)}_{read.get('drawing') or ''}_traceability.csv", "traceability.csv")
def itp_name(no: Any) -> str:
return _named(f"{no or 'ITP'}.pdf", "ITP.pdf")
def disposition(filename: str, download: bool = False) -> str:
"""Content-Disposition: inline unless a download is asked for. An ASCII filename, plus filename* (RFC 5987) when
the name has characters outside ASCII."""
kind = "attachment" if download else "inline"
plain = filename.encode("ascii", "replace").decode("ascii").replace("?", "_")
plain = plain.replace('"', "").replace(";", "_").replace("\\", "_").strip() or "download"
out = f'{kind}; filename="{plain}"'
if plain != filename:
out += f"; filename*=UTF-8''{quote(filename, safe='')}"
return out
# ---------------------------------------------------------------- the log
def record(
conn: Any,
p: deps.Principal,
*,
action: Literal["print", "export"],
kind: str,
what: str,
pages: int | None,
packet_id: int | None = None,
unit_id: int | None = None,
itp_id: int | None = None,
asset_id: Any = None,
stamp: str | None = None,
stamp_sig: str | None = None,
detail: Mapping[str, Any] | None = None,
) -> int:
"""One print_log row and one audit row (action `print` or `export`), in one transaction. The audit detail of an
export names its kind as databook, traveler, csv or itp."""
audit_detail: dict[str, Any] = {
"kind": EXPORT_KINDS.get(kind, kind) if action == "export" else kind,
"what": what,
"pages": pages,
"stamp_sig": stamp_sig,
}
audit_detail.update({k: v for k, v in (detail or {}).items() if v is not None})
if asset_id is not None:
audit_detail["asset_id"] = str(asset_id)
if itp_id is not None:
audit_detail["itp_id"] = itp_id
with conn.transaction():
new_id = conn.execute(
"INSERT INTO print_log (by, by_name, session_id, packet_id, unit_id, itp_id, kind, what, pages, asset_id,"
" stamp, stamp_sig, ip) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id",
(
p.user_id,
p.user.get("display_name"),
p.session_id,
packet_id,
unit_id,
itp_id,
kind,
what,
pages,
asset_id,
stamp,
stamp_sig,
p.ip,
),
).fetchone()[0]
audit.append(
conn,
action=action,
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
subject_type="itp" if itp_id is not None and packet_id is None else "packet",
subject_id=itp_id if itp_id is not None and packet_id is None else packet_id,
packet_id=packet_id,
detail=audit_detail,
ip=p.ip,
)
return int(new_id)
def list_prints(conn: Any, packet_id: int, limit: int = PRINTS_LIMIT) -> list[dict[str, Any]]:
"""A packet's printed copies and exports, newest first."""
return [
{
"id": r["id"],
"at": r["at"],
"by": None if r["by"] is None else {"id": str(r["by"]), "display_name": r["by_name"]},
"kind": r["kind"],
"what": r["what"],
"pages": r["pages"],
}
for r in packets_dom.rows(
conn,
"SELECT id, at, by, by_name, kind, what, pages FROM print_log WHERE packet_id = %s"
" ORDER BY id DESC LIMIT %s",
(packet_id, limit),
)
]
+2
View File
@@ -105,6 +105,7 @@ def create_app(
jobs,
packet_children,
packets,
prints,
projects,
qc,
shop,
@@ -124,6 +125,7 @@ def create_app(
units,
packets,
packet_children,
prints,
assets,
jobs,
feed_settings,
@@ -0,0 +1,43 @@
-- Phase 7: close, file, PDFs and prints.
-- Every printed copy and every export, append-only. Who, when, what and how many pages.
CREATE TABLE print_log (
id bigserial PRIMARY KEY,
at timestamptz NOT NULL DEFAULT now(),
by uuid REFERENCES users (id),
by_name text,
session_id uuid,
packet_id bigint REFERENCES packets (id),
unit_id bigint REFERENCES units (id),
itp_id bigint REFERENCES itps (id),
-- traveler, map-h, map-w, clean, void, databook, doc, itp, csv, databook-export, itp-pdf
kind text NOT NULL,
-- the human label: "Job traveler", "Drawing with heat map (sheet 2)", "MTR - item 3"
what text NOT NULL,
pages int,
-- the document printed, for kind doc; the filed data book for databook
asset_id uuid REFERENCES assets (id),
-- the stamp line as printed, and the 16 hex characters signed into it
stamp text,
stamp_sig text,
ip text
);
CREATE INDEX print_log_packet ON print_log (packet_id, id);
CREATE INDEX print_log_itp ON print_log (itp_id, id);
CREATE TRIGGER print_log_append_only
BEFORE UPDATE OR DELETE ON print_log
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER print_log_no_truncate
BEFORE TRUNCATE ON print_log
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
-- Explicit, after the default privileges: the app may only read and append the print log.
REVOKE UPDATE, DELETE, TRUNCATE ON print_log FROM ps_app;
GRANT SELECT, INSERT ON print_log TO ps_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ps_app;
-- The data book filed with a seal, and the work orders the feed still showed open at filing.
ALTER TABLE packet_seals ADD COLUMN databook_asset_id uuid REFERENCES assets (id);
ALTER TABLE packet_seals ADD COLUMN databook_pages int;
ALTER TABLE packet_seals ADD COLUMN open_wos text[];
+5 -2
View File
@@ -1,2 +1,5 @@
"""PDF output: the data book, the traveler, the ITP, prints and stamps. Phase 7 builds them; phase 6 holds the ITP
filing hook only."""
"""PDF output: the data book, the job traveler, the ITP, the printed copies, their stamps and metadata.
Pure builders: they read through the connection they are given and the stored asset files, and return bytes. They
never write to the database and never log a print; the endpoints and the filing pipeline do that.
"""
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

File diff suppressed because it is too large Load Diff
+408
View File
@@ -0,0 +1,408 @@
"""The letter-page toolkit over a reportlab canvas: a port of the old databook.js text helpers and its autotable look.
Every y here is measured from the top of the page, as jsPDF did, and turned into reportlab's bottom-up y on drawing.
Text is always passed through `clean`. Cells are wrapped with reportlab's simpleSplit on plain strings, so no markup
is ever interpreted.
"""
from __future__ import annotations
import io
from collections.abc import Callable, Mapping, Sequence
from functools import lru_cache
from pathlib import Path
from typing import Any
from reportlab.lib.colors import Color
from reportlab.lib.utils import ImageReader, simpleSplit
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfgen.canvas import Canvas
from .style import AMBER, AMBER_BG, CW, GREY, LINE, NAVY, PAGE_H, PAGE_W, PESCO_NAME, TEAL, WHITE, L, R, clean
LOGO_PATH = Path(__file__).parent / "assets" / "logo_color.png"
LOGO_W = 132.0
LOGO_H = 132.0 / 3.5
TABLE_TOP = 118.0 # autotable margin top, where a table continues after a page break
TABLE_BOTTOM = 60.0 # autotable margin bottom
BODY_TOP = 124.0 # where ensure_space starts a new page
SPACE_LIMIT = 740.0
LINE_FACTOR = 1.15
PAD_V = 4.2
PAD_H = 4.0
def rgb(c: Sequence[int]) -> Color:
return Color(c[0] / 255, c[1] / 255, c[2] / 255)
@lru_cache(maxsize=1)
def _logo() -> ImageReader | None:
try:
return ImageReader(io.BytesIO(LOGO_PATH.read_bytes()))
except OSError:
return None
def font_of(bold: bool = False, mono: bool = False) -> str:
if mono:
return "Courier-Bold" if bold else "Courier"
return "Helvetica-Bold" if bold else "Helvetica"
def split_lines(text: Any, font: str, size: float, width: float) -> list[str]:
"""The text wrapped to `width`, keeping its own line breaks. An empty text is one empty line."""
out: list[str] = []
for part in clean(text).split("\n"):
out += simpleSplit(part, font, size, max(width, 1.0)) or [""]
return out or [""]
class Doc:
"""A letter document drawn page by page. The header (logo, title, sub, rule) and the foot line are drawn when a
page starts, so every page carries them (the old decorate()). Page numbers and stamps are added after assembly
as an overlay (stamps.py)."""
def __init__(self, header: tuple[str, str] | None = None, *, foot: bool = True) -> None:
self.buf = io.BytesIO()
self.c = Canvas(self.buf, pagesize=(PAGE_W, PAGE_H), pageCompression=1)
self.c.setAuthor("PESCO - Process Equipment & Service Company, Inc.")
self.header = header
self.foot = foot
self.pages = 1
self.on_page: Callable[[Doc], None] | None = None
self._decorate()
# ------------------------------------------------------------ pages
def _decorate(self) -> None:
if self.header is not None:
self.page_header(*self.header)
if self.foot:
self.foot_left()
if self.on_page is not None:
self.on_page(self)
def add_page(self) -> None:
self.c.showPage()
self.pages += 1
self._decorate()
def set_header(self, title: str, sub: str) -> None:
"""The header for the pages started from now on."""
self.header = (title, sub)
def finish(self) -> bytes:
self.c.showPage()
self.c.save()
return self.buf.getvalue()
# ------------------------------------------------------------ primitives
def Y(self, y: float) -> float:
"""A top-down y as reportlab's bottom-up y."""
return PAGE_H - y
def tx(
self,
s: Any,
x: float,
y: float,
*,
size: float = 9,
bold: bool = False,
color: Sequence[int] = NAVY,
align: str = "left",
cs: float = 0,
max_width: float | None = None,
mono: bool = False,
) -> int:
"""Text at baseline y. With max_width it wraps (line height 1.15). Returns the number of lines drawn."""
font = font_of(bold, mono)
c = self.c
c.setFont(font, size)
c.setFillColor(rgb(color))
lines = split_lines(s, font, size, max_width) if max_width else clean(s).split("\n")
for i, line in enumerate(lines):
yy = self.Y(y + i * size * LINE_FACTOR)
w = stringWidth(line, font, size) + cs * max(0, len(line) - 1)
if align == "right":
xx = x - w
elif align == "center":
xx = x - w / 2
else:
xx = x
if cs:
c.drawString(xx, yy, line, charSpace=cs)
else:
c.drawString(xx, yy, line)
return len(lines)
def lbl(
self,
s: Any,
x: float,
y: float,
*,
size: float = 6.5,
cs: float = 1.6,
color: Sequence[int] = NAVY,
align: str = "left",
) -> None:
"""A label: bold tracked capitals."""
self.tx(clean(s).upper(), x, y, size=size, bold=True, cs=cs, color=color, align=align)
def line(self, x1: float, y1: float, x2: float, y2: float, color: Sequence[int], width: float) -> None:
c = self.c
c.setStrokeColor(rgb(color))
c.setLineWidth(width)
c.line(x1, self.Y(y1), x2, self.Y(y2))
def rect(
self,
x: float,
y: float,
w: float,
h: float,
*,
fill: Sequence[int] | None = None,
stroke: Sequence[int] | None = None,
width: float = 0.8,
) -> None:
"""A rectangle whose top-left corner is (x, y)."""
c = self.c
if fill is not None:
c.setFillColor(rgb(fill))
if stroke is not None:
c.setStrokeColor(rgb(stroke))
c.setLineWidth(width)
c.rect(x, self.Y(y + h), w, h, stroke=1 if stroke is not None else 0, fill=1 if fill is not None else 0)
def rule(self, y: float) -> None:
self.line(L, y, R, y, NAVY, 1.2)
self.line(L, y + 3.2, L + CW * 0.26, y + 3.2, TEAL, 2.6)
def hair(self, y: float, x1: float = L, x2: float = R) -> None:
self.line(x1, y, x2, y, LINE, 0.6)
def logo(self) -> None:
img = _logo()
if img is not None:
self.c.drawImage(img, L, self.Y(34 + LOGO_H), LOGO_W, LOGO_H, mask="auto")
def page_header(self, title: str, sub: str) -> None:
self.logo()
self.tx(clean(title).upper(), R, 62, size=20, bold=True, cs=2.6, align="right")
self.tx(clean(sub).upper(), R, 76, size=6.5, cs=1.6, color=GREY, align="right")
self.rule(94)
def foot_left(self) -> None:
self.hair(758)
self.tx(PESCO_NAME, L, 772, size=7, color=GREY)
# ------------------------------------------------------------ flow
def ensure_space(self, y: float, need: float) -> float:
if y + need > SPACE_LIMIT:
self.add_page()
return BODY_TOP
return y
def section_label(self, s: str, y: float) -> float:
y = self.ensure_space(y, 60)
self.lbl(s, L, y)
return y + 8
def note_box(self, text: str, y: float, warn: bool = False) -> float:
size = 8.0
lines = split_lines(text, "Helvetica", size, CW - 24)
h = len(lines) * 10.5 + 16
y = self.ensure_space(y, h + 8)
if warn:
self.rect(L, y, CW, h, fill=AMBER_BG, stroke=AMBER, width=0.8)
else:
self.rect(L, y, CW, h, stroke=LINE, width=0.8)
self.c.setFont("Helvetica", size)
self.c.setFillColor(rgb(AMBER if warn else NAVY))
for i, ln in enumerate(lines):
self.c.drawString(L + 12, self.Y(y + 15 + i * size * LINE_FACTOR), ln)
return y + h + 10
# ------------------------------------------------------------ tables
def table(
self,
head: Sequence[str] | None,
body: Sequence[Sequence[Any]],
start_y: float,
cols: Mapping[int, Mapping[str, Any]] | None = None,
*,
size: float = 8.3,
head_size: float = 6.3,
) -> float:
"""The autotable look: navy 8.3 pt body, the head 6.3 pt bold grey over a 0.9 pt navy rule, hairlines
between rows, cell padding 4.2 and 4. Cells wrap; the head repeats after a page break. A cell may be a dict
{"content", "colspan", "bold", "fill", "size", "color"}. Returns the y under the table."""
cols = cols or {}
ncols = len(head) if head else 0
for row in body:
ncols = max(ncols, sum(int(c.get("colspan", 1)) if isinstance(c, Mapping) else 1 for c in row))
if ncols == 0:
return start_y
widths = self._widths(head, body, cols, ncols, size)
y = start_y
limit = PAGE_H - TABLE_BOTTOM
head_cells = [{"content": h, "bold": True, "size": head_size, "color": GREY} for h in head] if head else None
head_h = self._row_height(head_cells, widths, cols, head_size) if head_cells else 0.0
def draw_head(yy: float) -> float:
if not head_cells:
return yy
self._draw_row(head_cells, widths, cols, yy, head_h, head_size, head=True)
self.line(L, yy + head_h, L + sum(widths), yy + head_h, NAVY, 0.9)
return yy + head_h
first_h = self._row_height(body[0], widths, cols, size) if body else 0.0
if y + head_h + first_h > limit:
self.add_page()
y = TABLE_TOP
y = draw_head(y)
for row in body:
h = self._row_height(row, widths, cols, size)
if y + h > limit:
self.add_page()
y = draw_head(TABLE_TOP)
self._draw_row(row, widths, cols, y, h, size)
self.line(L, y + h, L + sum(widths), y + h, LINE, 0.5)
y += h
return y
def kv_table(self, rows: Sequence[tuple[str, Any]], start_y: float) -> float:
body = [[k, v if (v is not None and str(v).strip() != "") else "—"] for k, v in rows]
return self.table(None, body, start_y, {0: {"w": CW * 0.36, "color": GREY}, 1: {"bold": True}}, size=8.5)
# internals
@staticmethod
def _cells(row: Sequence[Any]) -> list[tuple[int, int, dict[str, Any]]]:
"""(first column, span, cell) per cell."""
out: list[tuple[int, int, dict[str, Any]]] = []
col = 0
for c in row:
cell = dict(c) if isinstance(c, Mapping) else {"content": c}
span = int(cell.get("colspan", 1))
out.append((col, span, cell))
col += span
return out
@staticmethod
def _font(cell: Mapping[str, Any], colspec: Mapping[str, Any]) -> str:
bold = cell.get("bold", colspec.get("bold", False))
return font_of(bool(bold), bool(cell.get("mono", colspec.get("mono", False))))
def _widths(
self,
head: Sequence[str] | None,
body: Sequence[Sequence[Any]],
cols: Mapping[int, Mapping[str, Any]],
ncols: int,
size: float,
) -> list[float]:
fixed = {i: float(cols[i]["w"]) for i in range(ncols) if i in cols and cols[i].get("w")}
free = [i for i in range(ncols) if i not in fixed]
widths = [fixed.get(i, 0.0) for i in range(ncols)]
if not free:
return widths
avail = max(CW - sum(fixed.values()), 20.0 * len(free))
natural = dict.fromkeys(free, 2 * PAD_H + 10)
rows: list[Sequence[Any]] = [list(head)] if head else []
rows += list(body)
for row in rows:
for col, span, cell in self._cells(row):
if span != 1 or col not in natural:
continue
spec = cols.get(col, {})
font = self._font(cell, spec)
sz = float(cell.get("size", spec.get("size", size)))
for part in clean(cell.get("content")).split("\n"):
natural[col] = max(natural[col], stringWidth(part, font, sz) + 2 * PAD_H)
total = sum(natural.values())
for i in free:
widths[i] = avail * natural[i] / total if total else avail / len(free)
# Keep every free column at least 28 pt by taking from the widest ones.
short = [i for i in free if widths[i] < 28]
if short and len(short) < len(free):
need = sum(28 - widths[i] for i in short)
wide = [i for i in free if i not in short]
wide_total = sum(widths[i] for i in wide)
for i in short:
widths[i] = 28
for i in wide:
widths[i] -= need * widths[i] / wide_total
return widths
def _cell_lines(
self, cell: Mapping[str, Any], spec: Mapping[str, Any], width: float, size: float
) -> tuple[list[str], str, float]:
font = self._font(cell, spec)
sz = float(cell.get("size", spec.get("size", size)))
return split_lines(cell.get("content"), font, sz, width - 2 * PAD_H), font, sz
def _row_height(
self, row: Sequence[Any], widths: Sequence[float], cols: Mapping[int, Mapping[str, Any]], size: float
) -> float:
h = 0.0
for col, span, cell in self._cells(row):
w = sum(widths[col : col + span])
lines, _font, sz = self._cell_lines(cell, cols.get(col, {}), w, size)
h = max(h, len(lines) * sz * LINE_FACTOR + 2 * PAD_V)
return h
def _draw_row(
self,
row: Sequence[Any],
widths: Sequence[float],
cols: Mapping[int, Mapping[str, Any]],
y: float,
h: float,
size: float,
head: bool = False,
) -> None:
c = self.c
for col, span, cell in self._cells(row):
spec = {} if head else cols.get(col, {})
if head and col in cols and cols[col].get("align"):
spec = {"align": cols[col]["align"]}
x = L + sum(widths[:col])
w = sum(widths[col : col + span])
if cell.get("fill") is not None:
self.rect(x, y, w, h, fill=cell["fill"])
lines, font, sz = self._cell_lines(cell, spec, w, size)
lh = sz * LINE_FACTOR
top = y + (h - len(lines) * lh) / 2
color = cell.get("color", spec.get("color", NAVY))
c.setFont(font, sz)
c.setFillColor(rgb(color))
align = cell.get("align", spec.get("align", "left"))
for i, ln in enumerate(lines):
base = top + i * lh + (lh - sz) / 2 + sz * 0.78
if align == "center":
c.drawCentredString(x + w / 2, self.Y(base), ln)
elif align == "right":
c.drawRightString(x + w - PAD_H, self.Y(base), ln)
else:
c.drawString(x + PAD_H, self.Y(base), ln)
def page_x_of_n(tag: str) -> Callable[[int, int], str]:
"""The page number text of our pages: 'Project P Lot U - Page i of N'."""
def text(i: int, n: int) -> str:
return f"{tag} - Page {i} of {n}"
return text
__all__ = ["WHITE", "Doc", "page_x_of_n", "rgb", "split_lines"]
+318 -7
View File
@@ -1,17 +1,328 @@
"""The signed ITP PDF. Phase 7 builds it; until then the hook files nothing.
"""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.
`file_signed_itp_pdf` is called inside the sign-off transaction, after the ITP row is set complete. When it returns an
asset id, the sign-off stores it as `signoff_asset_id` and files an automatic ITP document in the unit's Q.C. folder.
`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.
"""
from __future__ import annotations
import contextlib
import io
import os
import tempfile
import uuid
from collections.abc import Mapping
from datetime import datetime
from pathlib import Path
from typing import Any
from pypdf import PdfReader, PdfWriter
def file_signed_itp_pdf(conn: Any, itp_row: Mapping[str, Any], actor: Any) -> uuid.UUID | None:
"""Build and store the signed ITP PDF, and return its asset id. Phase 7 implements it; today it returns None."""
del conn, itp_row, actor
return None
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))
+99
View File
@@ -0,0 +1,99 @@
"""The heat map and the weld map of one drawing page, as a JPEG at the sheet's own size (the old renderMapCanvas)."""
from __future__ import annotations
from collections.abc import Mapping
from pathlib import Path
from typing import Any
from ..domain import assets
from . import raster
from .style import fmt_d, site_tz
def asset_file(conn: Any, settings: Any, asset_id: Any) -> tuple[dict[str, Any], Path]:
"""The assets row and its stored file. Raises when the row or the file is missing."""
row = assets.get(conn, asset_id) if asset_id else None
if row is None:
raise FileNotFoundError("The file is not in the asset store.")
return row, assets.open_blob(settings, row)
def page_file(conn: Any, settings: Any, page: Mapping[str, Any]) -> tuple[Path, int]:
"""The stored PDF of a packet page and the page number inside it."""
_row, path = asset_file(conn, settings, page.get("asset_id"))
return path, int(page.get("pn") or 1)
def void_text(page: Mapping[str, Any], tz: str | None = None) -> str:
"""VOID - superseded by CO-2 (dwg Rev B) 10/16/2026. Empty for a live page (the old voidText)."""
v = page.get("void")
if not v:
return ""
co = f"CO-{v['co_no']}" if v.get("co_no") is not None else "a change order"
new = v.get("new")
sheet = f" ({new.get('dwg') or 'new sheet'} Rev {new.get('rev') or '?'})" if new else ""
day = fmt_d(v.get("at"), tz, empty="")
return f"VOID - superseded by {co}{sheet}" + (f" {day}" if day else "")
def heat_placement(packet: Mapping[str, Any], page_n: int) -> Mapping[str, Any] | None:
"""The heat table placed on a page, or None."""
for h in packet.get("heat_table") or []:
if h.get("page") == page_n and h.get("x") is not None:
return h
return None
def legacy_tags(packet: Mapping[str, Any], page_n: int) -> list[Mapping[str, Any]]:
"""The older heat tags of a page. They show only while no table is placed on it."""
if heat_placement(packet, page_n) is not None:
return []
return [t for t in packet.get("heat_tags") or [] if t.get("page") == page_n]
def page_welds(packet: Mapping[str, Any], page_n: int) -> list[Mapping[str, Any]]:
return [w for w in packet.get("welds") or [] if w.get("page") == page_n]
def _render(conn: Any, settings: Any, page: Mapping[str, Any]) -> tuple[Any, float, float]:
path, pn = page_file(conn, settings, page)
return raster.render_page(path, pn)
def heat_map_page(
conn: Any, settings: Any, packet: Mapping[str, Any], page: Mapping[str, Any]
) -> tuple[bytes, float, float]:
"""The sheet with its heat table (or its older heat tags when no table is placed), VOID when the page is void.
Returns (jpeg bytes, width pt, height pt)."""
img, w, h = _render(conn, settings, page)
n = int(page["n"])
bom = packet.get("bom") or []
placement = heat_placement(packet, n)
if placement is not None:
raster.draw_heat_table(img, placement, raster.heat_rows(bom))
else:
raster.draw_heat_tags(img, packet.get("heat_tags") or [], bom, n)
if page.get("void"):
img = raster.stamp_void(img, void_text(page, site_tz(settings)))
return raster.to_jpeg(img), w, h
def weld_map_page(
conn: Any, settings: Any, packet: Mapping[str, Any], page: Mapping[str, Any]
) -> tuple[bytes, float, float]:
"""The sheet with its weld bubbles, VOID when the page is void. Returns (jpeg bytes, width pt, height pt)."""
img, w, h = _render(conn, settings, page)
raster.draw_weld_marks(img, packet.get("welds") or [], int(page["n"]))
if page.get("void"):
img = raster.stamp_void(img, void_text(page, site_tz(settings)))
return raster.to_jpeg(img), w, h
def clean_page(conn: Any, settings: Any, page: Mapping[str, Any]) -> tuple[bytes, float, float]:
"""The sheet with no marks (VOID when void), as a raster. Prints copy the vector page instead; this is for a
page whose PDF cannot be copied."""
img, w, h = _render(conn, settings, page)
if page.get("void"):
img = raster.stamp_void(img, void_text(page, site_tz(settings)))
return raster.to_jpeg(img), w, h
+235
View File
@@ -0,0 +1,235 @@
"""What the builders share: the packet read model, people with their employee numbers, departments, the packet
log, and single pages made from a raster or an image.
Everything here only reads. The read model comes from domain/packets.py `build` (outside seal mode, no principal).
"""
from __future__ import annotations
import io
import uuid
from collections.abc import Iterable, Mapping
from pathlib import Path
from typing import Any
from pypdf import PdfReader
from reportlab.lib.utils import ImageReader
from reportlab.pdfgen.canvas import Canvas
from ..domain import packets
from .style import PAGE_H, PAGE_W, PdfError
# The status texts of the frontend's lib/packets.ts STATUS_LABEL.
STATUS_LABEL = {
"draft": "Ready to release",
"warehouse": "With warehouse",
"ready": "Ready to assign",
"assigned": "Sent to station",
"in_progress": "Working",
"review": "Supervisor review",
"qc": "Q.C. inspection",
"complete": "Routed",
"filed": "Filed in data book",
}
# The old ROLE_N texts, extended to the role kinds of auth/groups.py.
ROLE_TEXT = {
"admin": "Admin",
"clerical": "Clerical",
"clerical_supervisor": "Clerical supervisor",
"warehouse": "Warehouse",
"warehouse_supervisor": "Warehouse supervisor",
"qc": "Q.C.",
"qc_supervisor": "Q.C. supervisor",
"pm": "Project Mgmt",
"shop_worker": "Worker",
"shop_lead": "Shop lead",
"shop_supervisor": "Supervisor",
"supervisor": "Supervisor",
"stall": "Weld Stall",
}
def load_packet(conn: Any, packet_id: int) -> dict[str, Any]:
"""The packet read model (outside seal mode, no principal). PdfError when there is no such packet."""
row = packets.load_row(conn, int(packet_id))
if row is None:
raise PdfError("No such packet.")
return packets.build(conn, row, None)
def _uuid(v: Any) -> uuid.UUID | None:
if v is None:
return None
if isinstance(v, Mapping):
v = v.get("id")
try:
return uuid.UUID(str(v))
except ValueError:
return None
def users_of(conn: Any, ids: Iterable[Any]) -> dict[str, dict[str, Any]]:
"""{user id text: {display_name, emp_no, stamp}} for the given ids (user refs or ids)."""
want = sorted({u for u in (_uuid(i) for i in ids) if u is not None}, key=str)
if not want:
return {}
out: dict[str, dict[str, Any]] = {}
for r in conn.execute("SELECT id, display_name, emp_no, welder_stamp FROM users WHERE id = ANY(%s)", (want,)):
out[str(r[0])] = {"display_name": r[1], "emp_no": r[2], "stamp": r[3]}
return out
def name_emp(users: Mapping[str, Mapping[str, Any]], ref: Any, fallback: str = "") -> str:
"""'Name (emp)' for a user ref or id; the fallback name when the user is unknown."""
u = _uuid(ref)
info = users.get(str(u)) if u else None
name = (info or {}).get("display_name") or (ref.get("display_name") if isinstance(ref, Mapping) else None)
name = name or fallback
if not name:
return ""
emp = (info or {}).get("emp_no") or "?"
return f"{name} ({emp})"
def name_of(users: Mapping[str, Mapping[str, Any]], ref: Any, fallback: str = "") -> str:
u = _uuid(ref)
info = users.get(str(u)) if u else None
name = (info or {}).get("display_name") or (ref.get("display_name") if isinstance(ref, Mapping) else None)
return str(name or fallback)
def departments(conn: Any) -> dict[str, dict[str, Any]]:
"""{code: {name, label, weld}}. The label is "03 Weld Parts" for a shop department, the name otherwise."""
out: dict[str, dict[str, Any]] = {}
for code, name, kind, weld in conn.execute("SELECT code, name, kind::text, weld FROM departments"):
out[code] = {"name": name, "label": packets.dept_label(code, name, kind), "weld": bool(weld)}
return out
def role_text(role: Any) -> str:
"""The role snapshot of a log row in words."""
parts = []
for r in str(role or "").split(","):
r = r.strip()
if not r:
continue
bits = r.split(":")
if bits[0] == "shop" and len(bits) == 3: # shop:03:supervisor
parts.append(f"{bits[1]} {ROLE_TEXT.get('shop_' + bits[2], bits[2].replace('_', ' '))}")
else:
parts.append(ROLE_TEXT.get(bits[0], bits[0].replace("_", " ")))
return ", ".join(dict.fromkeys(parts))
def log_rows(conn: Any, packet_id: int) -> list[dict[str, Any]]:
"""Every packet_log row oldest first, with the signer's name and employee number on signed rows (the log
endpoint's columns, no limit)."""
return packets.rows(
conn,
"SELECT l.id, l.at, l.by, l.by_name, l.role, l.action, l.note, l.it_reason, l.signature_id,"
" u.emp_no, s.display_name AS sig_name, su.emp_no AS sig_emp, su.welder_stamp AS sig_stamp"
" FROM packet_log l LEFT JOIN users u ON u.id = l.by"
" LEFT JOIN signatures s ON s.id = l.signature_id LEFT JOIN users su ON su.id = s.user_id"
" WHERE l.packet_id = %s ORDER BY l.id",
(int(packet_id),),
)
def shorts_of(conn: Any, packet_id: int) -> list[dict[str, Any]]:
"""Every short of the packet, open and closed, with its BOM item and its receipts."""
found = packets.rows(
conn,
"SELECT s.*, b.item FROM bom_shorts s JOIN bom_lines b ON b.id = s.bom_line_id"
" WHERE s.packet_id = %s ORDER BY b.item, s.id",
(int(packet_id),),
)
if not found:
return []
rec: dict[int, list[dict[str, Any]]] = {}
for r in packets.rows(
conn, "SELECT * FROM short_receipts WHERE short_id = ANY(%s) ORDER BY id", ([s["id"] for s in found],)
):
rec.setdefault(r["short_id"], []).append(r)
for s in found:
s["receipts"] = rec.get(s["id"], [])
return found
def project_of(p: Mapping[str, Any]) -> dict[str, Any]:
return dict(((p.get("unit") or {}).get("project")) or {})
def tag_of(p: Mapping[str, Any]) -> tuple[str, str]:
"""(project number, lot) as text."""
unit = p.get("unit") or {}
return str(project_of(p).get("number") or ""), str(unit.get("lot") or "")
# ---------------------------------------------------------------- pages
def drawing_pages(p: Mapping[str, Any]) -> list[dict[str, Any]]:
"""The live drawing pages in page order."""
return [pg for pg in p.get("pages") or [] if pg.get("type") == "drawing" and not pg.get("void")]
def void_pages(p: Mapping[str, Any]) -> list[dict[str, Any]]:
return [pg for pg in p.get("pages") or [] if pg.get("type") == "drawing" and pg.get("void")]
def page_co(p: Mapping[str, Any], n: int) -> int | None:
"""The change order a live page came with, from the change order items (new_page)."""
for co in p.get("change_orders") or []:
for it in co.get("items") or []:
if it.get("new_page") == n:
return co.get("co_no")
return None
def open_pdf(path: Path) -> PdfReader:
"""A PdfReader for a stored file. An encrypted file is opened with an empty password or refused."""
reader = PdfReader(str(path))
if reader.is_encrypted and not reader.decrypt(""):
raise PdfError("The PDF is encrypted.")
if len(reader.pages) < 1:
raise PdfError("The PDF has no pages.")
return reader
def raster_page_pdf(jpeg: bytes, w: float, h: float) -> bytes:
"""One page of the sheet's size with the JPEG filling it."""
buf = io.BytesIO()
c = Canvas(buf, pagesize=(w, h), pageCompression=1)
c.drawImage(ImageReader(io.BytesIO(jpeg)), 0, 0, w, h)
c.showPage()
c.save()
return buf.getvalue()
def image_page_pdf(path: Path) -> bytes:
"""A PNG or JPEG on a letter page, fit inside 540 x 700 and centred."""
img = ImageReader(str(path))
iw, ih = img.getSize()
if iw <= 0 or ih <= 0:
raise PdfError("The image is empty.")
sc = min(540 / iw, 700 / ih)
buf = io.BytesIO()
c = Canvas(buf, pagesize=(PAGE_W, PAGE_H), pageCompression=1)
c.drawImage(img, (PAGE_W - iw * sc) / 2, (PAGE_H - ih * sc) / 2, iw * sc, ih * sc, mask="auto")
c.showPage()
c.save()
return buf.getvalue()
def sniff(path: Path) -> str | None:
"""pdf, png or jpeg from the first bytes, or None."""
with path.open("rb") as f:
head = f.read(8)
if head.startswith(b"%PDF"):
return "pdf"
if head.startswith(b"\x89PNG"):
return "png"
if head.startswith(b"\xff\xd8"):
return "jpeg"
return None
+272
View File
@@ -0,0 +1,272 @@
"""PRINTED COPY PDFs: the packet print kinds and the ITP print, each page stamped with the old print stamp box.
Kinds: traveler, map-h (drawing with heat map), map-w (drawing with weld map), clean (the drawing pages as attached,
void pages stamped VOID), void (the weld-map raster of every void page), databook (the filed book when there is one,
else built now) and doc (one document of the packet: an MTR, a packet document, a Q.C. folder document or the filed
data book). The person prints the PDF from the viewer.
"""
from __future__ import annotations
import io
from collections.abc import Callable, Mapping
from datetime import datetime
from typing import Any
from pypdf import PdfReader, PdfWriter
from . import databook, maps, parts, stamps, traveler
from . import itp as itp_pdf
from .style import Built, NothingToPrint, PdfError, WhoRef, clean, utc_now
KINDS: dict[str, str] = {
"traveler": "Job traveler",
"map-h": "Drawing with heat map",
"map-w": "Drawing with weld map",
"clean": "Drawing (no markup)",
"void": "Superseded drawings (VOID)",
"databook": "Data book",
"doc": "Document",
}
SHEET_KINDS = ("map-h", "map-w", "clean")
def filed_book_asset(conn: Any, packet_id: int) -> Any:
"""The data book stored on the packet's unbroken seal, or None."""
r = conn.execute(
"SELECT databook_asset_id FROM packet_seals WHERE packet_id = %s AND broken_at IS NULL"
" AND databook_asset_id IS NOT NULL ORDER BY id DESC LIMIT 1",
(int(packet_id),),
).fetchone()
return None if r is None else r[0]
def doc_label(conn: Any, p: Mapping[str, Any], asset_id: Any) -> str:
"""The label of one of the packet's documents, or NothingToPrint when the asset is not one of them."""
want = str(asset_id or "")
if not want:
raise NothingToPrint("No document was named.")
for b in p.get("bom") or []:
if str(b.get("mtr_asset_id") or "") == want:
heat = str(b.get("heat") or "").strip()
return f"MTR - item {b.get('item')} {heat}".rstrip()
for d in p.get("docs") or []:
if str(d.get("asset_id") or "") == want:
return str(d.get("title") or d.get("kind_label") or d.get("file_name") or "Document")
for f in p.get("qc_folder_docs") or []:
if str(f.get("url") or "").rsplit("/", 1)[-1] == want:
return f"{f.get('type')} - {f.get('title') or ''}".rstrip(" -")
r = conn.execute(
"SELECT 1 FROM packet_seals WHERE packet_id = %s AND databook_asset_id::text = %s", (int(p["id"]), want)
).fetchone()
if r is not None:
return "Data book"
raise NothingToPrint("The document is not one of this packet's.")
def _sheets(p: Mapping[str, Any], kind: str, sheet: int | None) -> list[dict[str, Any]]:
drawing = [pg for pg in p.get("pages") or [] if pg.get("type") == "drawing"]
if sheet is not None:
found = [pg for pg in drawing if int(pg["n"]) == int(sheet)]
if not found:
raise NothingToPrint(f"The packet has no drawing sheet {sheet}.")
return found
if kind == "clean":
return drawing
return [pg for pg in drawing if not pg.get("void")]
def _add_pdf(writer: PdfWriter, data: bytes) -> None:
for pg in PdfReader(io.BytesIO(data)).pages:
writer.add_page(pg)
def _rasters(
conn: Any, settings: Any, p: Mapping[str, Any], pages: list[dict[str, Any]], fn: Callable[..., Any]
) -> PdfWriter:
writer = PdfWriter()
for pg in pages:
try:
jpeg, w, h = fn(conn, settings, p, pg)
except Exception as e:
raise PdfError(f"Drawing sheet {pg['n']} could not be read ({type(e).__name__}).") from e
_add_pdf(writer, parts.raster_page_pdf(jpeg, w, h))
return writer
def _clean(
conn: Any, settings: Any, p: Mapping[str, Any], pages: list[dict[str, Any]]
) -> tuple[PdfWriter, dict[int, str]]:
writer = PdfWriter()
voids: dict[int, str] = {}
readers: dict[str, PdfReader] = {}
tz = str(getattr(settings, "site_tz", "") or "America/Denver")
for pg in pages:
try:
key = str(pg.get("asset_id"))
if key not in readers:
_row, path = maps.asset_file(conn, settings, pg.get("asset_id"))
readers[key] = parts.open_pdf(path)
writer.add_page(readers[key].pages[int(pg.get("pn") or 1) - 1])
except Exception as e:
raise PdfError(f"Drawing sheet {pg['n']} could not be read ({type(e).__name__}).") from e
if pg.get("void"):
voids[len(writer.pages) - 1] = maps.void_text(pg, tz)
return writer, voids
def _doc(conn: Any, settings: Any, asset_id: Any) -> PdfWriter:
try:
_row, path = maps.asset_file(conn, settings, asset_id)
kind = parts.sniff(path)
writer = PdfWriter()
if kind == "pdf":
rd = parts.open_pdf(path)
for pg in rd.pages:
writer.add_page(pg)
elif kind in ("png", "jpeg"):
_add_pdf(writer, parts.image_page_pdf(path))
else:
raise PdfError("The file is not a PDF, PNG or JPEG.")
return writer
except PdfError:
raise
except Exception as e:
raise PdfError(f"The document could not be read ({type(e).__name__}).") from e
def _stamped(
writer: PdfWriter,
settings: Any,
*,
who: WhoRef,
now: datetime,
what: str,
kind: str,
packet: Mapping[str, Any] | None,
packet_id: Any,
voids: Mapping[int, str] | None = None,
title: str,
warnings: list[str] | None = None,
) -> Built:
if len(writer.pages) == 0:
raise NothingToPrint("Nothing to print.")
warnings = [] if warnings is None else warnings
text = stamps.print_stamp(who, now, what, packet)
stamp, sig = stamps.signed_stamp(
settings,
text,
packet_id=packet_id,
kind=f"print:{kind}",
seal_hash_hex=None,
who=who,
now=now,
warnings=warnings,
)
voids = voids or {}
def draw(c: Any, i: int, w: float, h: float) -> None:
if i in voids:
stamps.draw_void(c, w, h, voids[i])
stamps.draw_box_stamp(c, w, stamp)
stamps.overlay_writer(writer, draw)
keywords = ", ".join(x for x in ("printed copy", kind, f"stamp {sig}" if sig else "") if x)
meta = stamps.metadata(title, f"Printed copy - {what}", keywords, "Packet Station - printed copy", now=now)
data = stamps.write(writer, meta)
return Built(
bytes=data,
pages=len(PdfReader(io.BytesIO(data)).pages),
warnings=warnings,
stamp=stamp,
stamp_sig=sig,
what=what,
)
def build_print(
conn: Any,
settings: Any,
packet_id: int,
kind: str,
*,
who: WhoRef,
now: datetime | None = None,
sheet: int | None = None,
asset_id: Any = None,
) -> Built:
"""A PRINTED COPY of one packet print kind. NothingToPrint when the kind has nothing for this packet (no void
pages, an unknown sheet, a document that is not the packet's); PdfError when it cannot be built."""
if kind not in KINDS:
raise NothingToPrint(f"Unknown print kind {kind}.")
now = utc_now(now)
p = parts.load_packet(conn, packet_id)
project, lot = parts.tag_of(p)
what = KINDS[kind]
voids: dict[int, str] = {}
warnings: list[str] = []
if kind == "traveler":
data, _n = traveler.pages_of(conn, settings, p)
# Page numbers as on the exported traveler, then the print box.
writer = PdfWriter(clone_from=PdfReader(io.BytesIO(traveler.finish(data, p, "", now, box=False))))
elif kind in ("map-h", "map-w"):
pages = _sheets(p, kind, sheet)
fn = maps.heat_map_page if kind == "map-h" else maps.weld_map_page
writer = _rasters(conn, settings, p, pages, fn)
elif kind == "clean":
writer, voids = _clean(conn, settings, p, _sheets(p, kind, sheet))
elif kind == "void":
pages = parts.void_pages(p)
if not pages:
raise NothingToPrint("The packet has no void pages.")
writer = _rasters(conn, settings, p, pages, maps.weld_map_page)
elif kind == "databook":
stored = asset_id or (filed_book_asset(conn, packet_id) if p.get("status") == "filed" else None)
if stored is not None:
writer = _doc(conn, settings, stored)
else:
if p.get("status") not in ("complete", "filed"):
raise NothingToPrint("The data book is built once the packet is back from Q.C.")
book = databook.build(conn, settings, packet_id, who=who, now=now, mode="print")
warnings += book.warnings
writer = PdfWriter(clone_from=PdfReader(io.BytesIO(book.bytes)))
else:
what = doc_label(conn, p, asset_id)
writer = _doc(conn, settings, asset_id)
if sheet is not None and kind in SHEET_KINDS:
what = f"{what} (sheet {sheet})"
return _stamped(
writer,
settings,
who=who,
now=now,
what=clean(what),
kind=kind,
packet=p,
packet_id=p["id"],
voids=voids,
title=f"{project} Lot {lot} {clean(what)}",
warnings=warnings,
)
def print_itp(
conn: Any, settings: Any, itp_row: Mapping[str, Any], *, who: WhoRef, now: datetime | None = None
) -> Built:
"""The ITP PDF stamped PRINTED COPY, with its page numbers."""
now = utc_now(now)
data, _n, r = itp_pdf.pages_of(conn, settings, itp_row)
num = itp_pdf.finish(data, r, "", now, box=False)
writer = PdfWriter(clone_from=PdfReader(io.BytesIO(num)))
what = f"ITP {clean(r.get('no'))}"
return _stamped(
writer,
settings,
who=who,
now=now,
what=what,
kind="itp",
packet=None,
packet_id=f"itp-{itp_row['id']}",
title=what,
)
+320
View File
@@ -0,0 +1,320 @@
"""Page rasters for the heat map, the weld map and the VOID stamp, drawn with Pillow.
Ports of the old renderMapCanvas, drawArrow, htDrawCanvas and stampVoidCanvas, and of the new frontend's
lib/heatTable.ts drawHeatTable, lib/voidStamp.ts and lib/geometry.ts bpos. Pillow's built-in sized font stands in
for Arial and Courier New (no font files are shipped); bold is drawn with a thin stroke of the same colour.
"""
from __future__ import annotations
import io
import math
from collections.abc import Iterable, Mapping, Sequence
from functools import lru_cache
from pathlib import Path
from typing import Any
from PIL import Image, ImageDraw, ImageFont
HEAT = (0xB0, 0x78, 0x00)
WELD = (0xC2, 0x18, 0x5B)
HEAT_MISSING = (0xB0, 0x00, 0x20)
HEAT_NA = (0x6B, 0x5A, 0x3A)
INK = (0x2B, 0x1D, 0x00)
HEAD_INK = (0x3D, 0x2A, 0x00)
HEAD_BG = (0xF3, 0xE2, 0xB8)
PILL_BG = (0xFF, 0xF8, 0xE6)
NO_HEAT_W = "MM" # the width of the red dash a missing heat shows (Pillow's font has no em dash glyph)
HT_ROWS = 12
HT_FONT = 0.0075
Font = Any # ImageFont.FreeTypeFont, or the bitmap default when FreeType is missing
@lru_cache(maxsize=256)
def font(size: float) -> Font:
px = max(6, round(size))
try:
return ImageFont.load_default(size=px)
except (TypeError, OSError, ImportError): # Pillow without FreeType: the fixed bitmap font
return ImageFont.load_default()
def text_w(f: Font, s: str) -> float:
try:
return float(f.getlength(s))
except AttributeError:
box = f.getbbox(s)
return float(box[2] - box[0])
def _text(
d: ImageDraw.ImageDraw,
xy: tuple[float, float],
s: str,
f: Font,
fill: Sequence[int],
anchor: str = "mm",
bold: bool = True,
) -> None:
size = getattr(f, "size", 12)
sw = max(1, round(size / 22)) if bold else 0
d.text(xy, s, font=f, fill=tuple(fill), anchor=anchor, stroke_width=sw, stroke_fill=tuple(fill))
# ---------------------------------------------------------------- the page
def page_points(page: Any) -> tuple[float, float]:
"""A pdfium page's MediaBox size in points, after /Rotate."""
try:
llx, lly, urx, ury = page.get_mediabox()
w, h = abs(urx - llx), abs(ury - lly)
except Exception: # an odd box: fall back to pdfium's own size
w, h = page.get_size()
if int(page.get_rotation() or 0) % 180 == 90:
w, h = h, w
return float(w), float(h)
def render_page(
path: Path | str, pn: int, max_px: int = 3400, max_scale: float = 200 / 72
) -> tuple[Image.Image, float, float]:
"""Page `pn` (1-based) of a PDF as an RGB image on white, rotation applied, at most `max_px` on the long side and
at most `max_scale` pixels per point. Returns the image and the page size in points."""
import pypdfium2 as pdfium
doc = pdfium.PdfDocument(str(path))
try:
if pn < 1 or pn > len(doc):
raise ValueError(f"The PDF has no page {pn}.")
page = doc[pn - 1]
try:
w_pt, h_pt = page_points(page)
scale = min(max_scale, max_px / max(w_pt, h_pt, 1.0))
bitmap = page.render(scale=scale, fill_color=(255, 255, 255, 255))
img = bitmap.to_pil().convert("RGB")
finally:
page.close()
finally:
doc.close()
return img, w_pt, h_pt
def to_jpeg(img: Image.Image, quality: int = 90) -> bytes:
buf = io.BytesIO()
img.convert("RGB").save(buf, "JPEG", quality=quality, optimize=True)
return buf.getvalue()
# ---------------------------------------------------------------- marks
def clamp01(v: float) -> float:
return max(0.01, min(0.99, v))
def bpos(m: Mapping[str, Any]) -> tuple[float, float, float, float]:
"""(fx, fy, bfx, bfy): the arrow point and the bubble. A stored bubble wins; else the old default spot."""
fx = float(m["fx"]) if m.get("fx") is not None else 0.5
fy = float(m["fy"]) if m.get("fy") is not None else 0.5
bfx = float(m["bfx"]) if m.get("bfx") is not None else clamp01(fx + (-0.05 if fx > 0.85 else 0.05))
bfy = float(m["bfy"]) if m.get("bfy") is not None else clamp01(fy + (0.07 if fy < 0.12 else -0.07))
return fx, fy, bfx, bfy
def mark_radius(width: float) -> float:
return max(18.0, width / 150)
def _arrow(
d: ImageDraw.ImageDraw, ax: float, ay: float, bx: float, by: float, size: float, color: Sequence[int]
) -> None:
"""The old drawArrow: a filled head at (ax, ay) pointing away from (bx, by)."""
ang = math.atan2(ay - by, ax - bx)
ca, sa = math.cos(ang), math.sin(ang)
pts = [(0.0, 0.0), (-size * 1.6, -size * 0.6), (-size * 1.6, size * 0.6)]
d.polygon([(ax + x * ca - y * sa, ay + x * sa + y * ca) for x, y in pts], fill=tuple(color))
def _leaders(d: ImageDraw.ImageDraw, marks: Iterable[Mapping[str, Any]], w: int, h: int, color: Sequence[int]) -> None:
r = mark_radius(w)
for m in marks:
fx, fy, bfx, bfy = bpos(m)
ax, ay, bx, by = fx * w, fy * h, bfx * w, bfy * h
d.line([(bx, by), (ax, ay)], fill=tuple(color), width=max(2, round(r / 7)))
_arrow(d, ax, ay, bx, by, r / 2.4, color)
def draw_weld_marks(img: Image.Image, welds: Iterable[Mapping[str, Any]], page_n: int) -> Image.Image:
"""The weld bubbles of one page: the leader to the arrow point, the arrowhead, a white disc with a pink ring of
r/6 and the id W{n} bold in the middle."""
on = [wd for wd in welds if wd.get("page") == page_n]
if not on:
return img
w, h = img.size
d = ImageDraw.Draw(img)
r = mark_radius(w)
_leaders(d, on, w, h, WELD)
f = font(round(r * 0.85))
for wd in on:
_fx, _fy, bfx, bfy = bpos(wd)
x, y = bfx * w, bfy * h
d.ellipse([x - r, y - r, x + r, y + r], fill=(255, 255, 255), outline=WELD, width=max(1, round(r / 6)))
label = f"W{wd.get('weld_no')}"
fl = f if text_w(f, label) <= r * 1.6 else font(r * 0.85 * r * 1.6 / max(text_w(f, label), 1.0))
_text(d, (x, y), label, fl, WELD)
return img
def heat_of(bom: Iterable[Mapping[str, Any]], item: Any) -> str:
for b in bom:
if str(b.get("item")) == str(item):
heat = str(b.get("heat") or "").strip()
return heat or ("N/A" if b.get("na") else "")
return ""
def draw_heat_tags(
img: Image.Image, tags: Iterable[Mapping[str, Any]], bom: Sequence[Mapping[str, Any]], page_n: int
) -> Image.Image:
"""The older heat tags of one page: an amber pill with the item in a filled circle and the heat (or NO HEAT)."""
on = [t for t in tags if t.get("page") == page_n]
if not on:
return img
w, h = img.size
d = ImageDraw.Draw(img)
r = mark_radius(w)
_leaders(d, on, w, h, HEAT)
f_txt = font(round(r * 0.8))
f_item = font(round(r * 0.75))
for t in on:
_fx, _fy, bfx, bfy = bpos(t)
x, y = bfx * w, bfy * h
txt = heat_of(bom, t.get("item")) or "NO HEAT"
tw = text_w(f_txt, txt)
ph = r * 1.7
cr = ph * 0.42
pw = tw + ph + r * 0.9
x0, y0 = x - pw / 2, y - ph / 2
d.rounded_rectangle(
[x0, y0, x0 + pw, y0 + ph], radius=ph / 2, fill=PILL_BG, outline=HEAT, width=max(1, round(r / 6))
)
cx = x0 + ph / 2
d.ellipse([cx - cr, y - cr, cx + cr, y + cr], fill=HEAT)
_text(d, (cx, y), str(t.get("item")), f_item, (255, 255, 255))
_text(d, (x0 + ph + r * 0.2, y), txt, f_txt, HEAD_INK, anchor="lm")
return img
# ---------------------------------------------------------------- the heat table
def heat_rows(bom: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]:
"""ITEM and HEAT NO. per BOM line: the heat, N/A on an N/A line, or empty when missing."""
out = []
for b in bom:
heat = str(b.get("heat") or "").strip()
out.append(
{"item": str(b.get("item")), "heat": heat or ("N/A" if b.get("na") else ""), "na": bool(b.get("na"))}
)
return out
def heat_groups(rows: Sequence[Any]) -> list[list[Any]]:
"""At most 12 rows per group, the groups as even as they can be. Always at least one group."""
n = len(rows)
per = math.ceil(n / math.ceil(n / HT_ROWS)) if n > HT_ROWS else HT_ROWS
out = [list(rows[i : i + per]) for i in range(0, n, per)]
return out or [[]]
def draw_heat_table(img: Image.Image, placement: Mapping[str, Any], rows: Sequence[Mapping[str, Any]]) -> Image.Image:
"""The heat table on a page raster: HEAT NUMBERS on an amber band, then ITEM | HEAT NO. groups side by side.
Font size W x 0.0075 x s; a missing heat is a red dash, N/A is brown."""
W, H = img.size
s = float(placement.get("s") or 1)
fs = W * HT_FONT * s
pad_x = fs * 0.5
pad_y = fs * 0.2
row_h = fs * 1.15 + pad_y * 2
bw = max(1, round(max(1.5, fs * 0.08)))
groups = heat_groups(rows)
th_f, td_f, cap_f = font(round(fs * 0.8)), font(round(fs)), font(round(fs * 0.85))
widths = []
for g in groups:
wi = max([text_w(th_f, "ITEM")] + [text_w(td_f, r["item"]) for r in g])
wh = max(
[text_w(th_f, "HEAT NO.")] + [text_w(td_f, r["heat"] or NO_HEAT_W) * (1 if r["heat"] else 0.5) for r in g]
)
widths.append((wi + pad_x * 2, wh + pad_x * 2))
total_w = sum(a + b for a, b in widths)
cap_h = row_h
max_rows = max(len(g) for g in groups)
total_h = cap_h + row_h * (max_rows + 1)
x0 = float(placement.get("x") or 0) * W
y0 = float(placement.get("y") or 0) * H
d = ImageDraw.Draw(img)
d.rectangle([x0, y0, x0 + total_w, y0 + total_h], fill=(255, 255, 255))
d.rectangle([x0, y0, x0 + total_w, y0 + cap_h], fill=HEAT)
_text(d, (x0 + total_w / 2, y0 + cap_h / 2), "HEAT NUMBERS", cap_f, (255, 255, 255))
gx = x0
for gi, g in enumerate(groups):
wi, wh = widths[gi]
y = y0 + cap_h
d.rectangle([gx, y, gx + wi + wh, y + row_h], fill=HEAD_BG)
_text(d, (gx + wi / 2, y + row_h / 2), "ITEM", th_f, HEAD_INK)
_text(d, (gx + wi + wh / 2, y + row_h / 2), "HEAT NO.", th_f, HEAD_INK)
d.rectangle([gx, y, gx + wi, y + row_h], outline=HEAT, width=bw)
d.rectangle([gx + wi, y, gx + wi + wh, y + row_h], outline=HEAT, width=bw)
y += row_h
for r in g:
_text(d, (gx + wi / 2, y + row_h / 2), r["item"], td_f, INK)
color = (HEAT_NA if r["na"] else INK) if r["heat"] else HEAT_MISSING
if r["heat"]:
_text(d, (gx + wi + pad_x, y + row_h / 2), r["heat"], td_f, color, anchor="lm")
else:
dash = text_w(td_f, NO_HEAT_W) * 0.5
ym = y + row_h / 2
d.line([(gx + wi + pad_x, ym), (gx + wi + pad_x + dash, ym)], fill=color, width=max(1, round(fs / 10)))
d.rectangle([gx, y, gx + wi, y + row_h], outline=HEAT, width=bw)
d.rectangle([gx + wi, y, gx + wi + wh, y + row_h], outline=HEAT, width=bw)
y += row_h
gx += wi + wh
d.rectangle([x0, y0, x0 + total_w, y0 + total_h], outline=HEAT, width=bw * 2)
return img
# ---------------------------------------------------------------- VOID
def stamp_void(img: Image.Image, text: str) -> Image.Image:
"""A big diagonal VOID at low opacity, the void text under it, and a red border (voidStamp.ts)."""
base = img.convert("RGBA")
w, h = base.size
layer = Image.new("RGBA", (w, h), (0, 0, 0, 0))
d = ImageDraw.Draw(layer)
big = font(round(w / 5.5))
d.text(
(w / 2, h / 2),
"VOID",
font=big,
anchor="mm",
fill=(220, 0, 0, round(255 * 0.3)),
stroke_width=max(4, round(w / 260)),
stroke_fill=(200, 0, 0, round(255 * 0.55)),
)
if text:
small = font(round(w / 55))
d.text((w / 2, h / 2 + w / 9), text, font=small, anchor="mm", fill=(200, 0, 0, round(255 * 0.8)))
layer = layer.rotate(
math.degrees(math.atan2(h, w) * 0.85), center=(w / 2, h / 2), resample=Image.Resampling.BICUBIC
)
out = Image.alpha_composite(base, layer)
lw = max(6, round(w / 200))
border = Image.new("RGBA", (w, h), (0, 0, 0, 0))
ImageDraw.Draw(border).rectangle(
[lw / 2, lw / 2, w - lw / 2, h - lw / 2], outline=(200, 0, 0, round(255 * 0.7)), width=lw
)
out = Image.alpha_composite(out, border)
return out.convert("RGB")
+280
View File
@@ -0,0 +1,280 @@
"""Stamps and metadata: the controlled-copy line, the PRINTED COPY box, the stamp signature, the VOID overlay on a
copied drawing page, and the document information.
The stamp signature is 16 hex characters of HMAC-SHA256 with the seal key over
"stamp|{packet_id}|{kind}|{seal hash}|{user id}|{minute}". The minute is the one the stamp line names, as
YYYY-MM-DDTHH:MMZ (style.stamp_at), so a printed copy can be checked from its own stamp and the print log.
"""
from __future__ import annotations
import hashlib
import hmac
import io
import math
from collections.abc import Callable, Iterable, Mapping
from datetime import datetime
from typing import Any
from pypdf import PdfReader, PdfWriter, Transformation
from reportlab.lib.colors import Color
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfgen.canvas import Canvas
from .style import GREY, WhoRef, as_dt, ascii_only, pdf_date, stamp_at, utc_stamp, who_text
AUTHOR = "PESCO - Process Equipment & Service Company, Inc."
PRODUCER = "Packet Station"
SIG_LEN = 16
Draw = Callable[[Canvas, int, float, float], None]
# ---------------------------------------------------------------- texts
def controlled_stamp(who: WhoRef | None, now: datetime | None, *, mode: str, seal_hash_hex: str | None = None) -> str:
"""The controlled-copy line. mode "filed" names the seal (first 12 hex), "preview" says not yet filed, "print"
says printed by (and the seal or not yet filed), "traveler" carries no seal part."""
verb = "printed" if mode == "print" else "exported"
text = f"Controlled copy - {verb} by {who_text(who)} {utc_stamp(now)}"
if mode != "traveler":
text += f" - seal {seal_hash_hex[:12]}" if seal_hash_hex else " - not yet filed"
return ascii_only(text)
def project_lot(packet: Mapping[str, Any] | None) -> tuple[str, str]:
"""The project number and the lot of a packet read model, as text."""
unit = (packet or {}).get("unit") or {}
project = unit.get("project") or {}
return str(project.get("number") or ""), str(unit.get("lot") or "")
def print_stamp(who: WhoRef | None, now: datetime | None, what: str, packet: Mapping[str, Any] | None) -> str:
"""PRINTED COPY - {what} - printed by Name (emp) {UTC} - {project} Lot {unit} dwg {drawing} R{rev} - verify
revision before use. Anything outside printable ASCII becomes "-"."""
text = f"PRINTED COPY - {what} - printed by {who_text(who)} {utc_stamp(now)}"
if packet is not None:
project, lot = project_lot(packet)
text += f" - {project} Lot {lot} dwg {packet.get('drawing') or ''} R{packet.get('rev') or ''}"
text += " - verify revision before use"
return ascii_only(text, "-")
def minute_of(at_iso: Any) -> str:
"""The signed minute: any ISO time (or datetime) as YYYY-MM-DDTHH:MMZ in UTC. Text that does not parse is
signed as it is."""
v = as_dt(at_iso)
if isinstance(v, datetime):
return stamp_at(v)
return str(at_iso or "")
def stamp_sig(key: bytes, packet_id: Any, kind: str, seal_hash_hex: str | None, user_id: Any, at_iso: Any) -> str:
"""16 hex characters of HMAC-SHA256 over "stamp|" + the fields joined with "|". The time is signed to the
minute the stamp line shows (minute_of), so a stamp can be checked from its own text."""
parts = [
str(packet_id if packet_id is not None else ""),
str(kind),
seal_hash_hex or "",
str(user_id or ""),
minute_of(at_iso),
]
msg = ("stamp|" + "|".join(parts)).encode("utf-8")
return hmac.new(key, msg, hashlib.sha256).hexdigest()[:SIG_LEN]
def verify_stamp(
key: bytes, packet_id: Any, kind: str, seal_hash_hex: str | None, user_id: Any, at_iso: Any, sig: str
) -> bool:
"""True when `sig` is the stamp signature of these fields (the stamp_sig arguments, then the signature)."""
want = stamp_sig(key, packet_id, kind, seal_hash_hex, user_id, at_iso)
return hmac.compare_digest(want, str(sig or "").strip().lower())
def with_sig(text: str, sig: str) -> str:
return f"{text} - stamp {sig}" if sig else text
def signed_stamp(
settings: Any,
text: str,
*,
packet_id: Any,
kind: str,
seal_hash_hex: str | None,
who: WhoRef | None,
now: datetime | None,
warnings: list[str] | None = None,
) -> tuple[str, str]:
"""The stamp line with its signature, and the signature. Without a readable seal key the line goes out unsigned
and a warning is added."""
try:
key = settings.seal_key()
except Exception: # the key file is missing or unreadable: stamp without a signature
if warnings is not None:
warnings.append("The seal key is not available, so the stamp is not signed.")
return text, ""
sig = stamp_sig(key, packet_id, kind, seal_hash_hex, who.get("id") if who else None, stamp_at(now))
return with_sig(text, sig), sig
# ---------------------------------------------------------------- drawing on pages
def draw_line_stamp(c: Canvas, w: float, text: str, *, own: bool) -> None:
"""The controlled-copy line: on our pages at x=48, y=9 in 6.5 pt grey; on copied pages centred at y=3 in 6 pt."""
c.setFillColor(Color(GREY[0] / 255, GREY[1] / 255, GREY[2] / 255))
if own:
c.setFont("Helvetica", 6.5)
c.drawString(48, 9, text)
else:
c.setFont("Helvetica", 6)
c.drawString(max(2.0, (w - stringWidth(text, "Helvetica", 6)) / 2), 3, text)
def draw_box_stamp(c: Canvas, w: float, text: str) -> None:
"""The old print stamp: a white box at 92 % opacity at the bottom centre, the text in #333."""
fs = min(12.0, max(7.0, w / 150))
tw = stringWidth(text, "Helvetica", fs)
room = w - fs * 1.2 - 4
if tw > room:
fs = max(3.5, fs * room / tw)
tw = stringWidth(text, "Helvetica", fs)
pad = fs * 0.5
bh = fs * 1.6
x = max(pad, (w - tw) / 2)
y0 = pad * 0.4
c.saveState()
c.setFillColor(Color(1, 1, 1))
c.setFillAlpha(0.92)
c.rect(x - pad, y0, tw + pad * 2, bh, stroke=0, fill=1)
c.restoreState()
c.setFont("Helvetica", fs)
c.setFillColor(Color(0x33 / 255, 0x33 / 255, 0x33 / 255))
c.drawString(x, y0 + bh / 2 - fs * 0.35, text)
def draw_void(c: Canvas, w: float, h: float, sub: str) -> None:
"""VOID on a copied drawing page: HelveticaBold w/5.5 along the diagonal at 30 % red, the void text at 12 %
height in w/70, a red border inset 4 pt, width max(3, w/250), at 70 %."""
size = w / 5.5
tw = stringWidth("VOID", "Helvetica-Bold", size)
ang = math.atan2(h, w) * 0.85
x = w / 2 - math.cos(ang) * tw / 2 + math.sin(ang) * size * 0.35
y = h / 2 - math.sin(ang) * tw / 2 - math.cos(ang) * size * 0.35
red = Color(0.8, 0, 0)
c.saveState()
c.setFillColor(red)
c.setFillAlpha(0.3)
c.translate(x, y)
c.rotate(math.degrees(ang))
c.setFont("Helvetica-Bold", size)
c.drawString(0, 0, "VOID")
c.restoreState()
text = ascii_only(sub, "-")
ss = w / 70
c.setFillColor(red)
c.setFont("Helvetica-Bold", ss)
c.drawString(w / 2 - stringWidth(text, "Helvetica-Bold", ss) / 2, h * 0.12, text)
c.saveState()
c.setStrokeColor(red)
c.setStrokeAlpha(0.7)
c.setLineWidth(max(3.0, w / 250))
c.rect(4, 4, w - 8, h - 8, stroke=1, fill=0)
c.restoreState()
# ---------------------------------------------------------------- overlay
def page_size(page: Any) -> tuple[float, float, float, float]:
"""(llx, lly, width, height) of a pypdf page's MediaBox."""
box = page.mediabox
return float(box.left), float(box.bottom), float(box.width), float(box.height)
def overlay_writer(writer: PdfWriter, draw: Draw, pages: Iterable[int] | None = None) -> None:
"""Draw on every page (or the given page indices) of a writer: one reportlab page per page, merged on top.
A page with /Rotate has its rotation moved into its content first, so the overlay reads upright."""
wanted = list(range(len(writer.pages))) if pages is None else sorted(set(pages))
if not wanted:
return
buf = io.BytesIO()
c = Canvas(buf, pageCompression=1)
boxes: list[tuple[float, float]] = []
for i in wanted:
page = writer.pages[i]
if int(page.get("/Rotate", 0) or 0) % 360:
page.transfer_rotation_to_content()
llx, lly, w, h = page_size(page)
boxes.append((llx, lly))
c.setPageSize((w, h))
draw(c, i, w, h)
c.showPage()
c.save()
ov = PdfReader(io.BytesIO(buf.getvalue()))
for n, i in enumerate(wanted):
llx, lly = boxes[n]
if llx or lly:
writer.pages[i].merge_transformed_page(ov.pages[n], Transformation().translate(llx, lly))
else:
writer.pages[i].merge_page(ov.pages[n])
def overlay(pdf_bytes: bytes, draw: Draw, *, metadata: Mapping[str, str] | None = None) -> bytes:
writer = PdfWriter(clone_from=PdfReader(io.BytesIO(pdf_bytes)))
overlay_writer(writer, draw)
if metadata:
writer.add_metadata(dict(metadata))
out = io.BytesIO()
writer.write(out)
return out.getvalue()
def apply_stamp(pdf_bytes: bytes, text: str, *, ours: set[int] | None = None, box: bool = False) -> bytes:
"""Draw the stamp line on every page. `ours` are the 0-based indices of our generated pages (None: all are
ours). box=True draws the PRINTED COPY box instead."""
def draw(c: Canvas, i: int, w: float, h: float) -> None:
del h
if box:
draw_box_stamp(c, w, text)
else:
draw_line_stamp(c, w, text, own=ours is None or i in ours)
return overlay(pdf_bytes, draw)
def metadata(title: str, subject: str, keywords: str, creator: str, *, now: datetime | None = None) -> dict[str, str]:
stamp = pdf_date(now)
return {
"/Title": ascii_only(title, "-"),
"/Author": AUTHOR,
"/Subject": ascii_only(subject, "-"),
"/Keywords": ascii_only(keywords, "-"),
"/Creator": ascii_only(creator, "-"),
"/Producer": PRODUCER,
"/CreationDate": stamp,
"/ModDate": stamp,
}
def set_metadata(
pdf_bytes: bytes, title: str, subject: str, keywords: str, creator: str, *, now: datetime | None = None
) -> bytes:
"""The document information: title, subject, keywords, creator, Author PESCO, Producer Packet Station and the
creation date."""
writer = PdfWriter(clone_from=PdfReader(io.BytesIO(pdf_bytes)))
writer.add_metadata(metadata(title, subject, keywords, creator, now=now))
out = io.BytesIO()
writer.write(out)
return out.getvalue()
def write(writer: PdfWriter, meta: Mapping[str, str] | None = None) -> bytes:
if meta:
writer.add_metadata(dict(meta))
out = io.BytesIO()
writer.write(out)
return out.getvalue()
+227
View File
@@ -0,0 +1,227 @@
"""Shared look and helpers for the PDF builders: colours, page geometry, text cleaning, dates and the result types.
The colours, the page frame and the address lines are the old databook.js values (DBC, L, R, CW, PESCO_ADDR).
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import UTC, date, datetime
from decimal import Decimal
from typing import Any, TypedDict
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
# ---------------------------------------------------------------- colours (RGB 0..255)
NAVY = (31, 43, 69)
TEAL = (13, 127, 146)
GREY = (107, 114, 128)
LINE = (213, 217, 223)
PANEL = (244, 246, 249)
AMBER = (138, 97, 0)
AMBER_BG = (253, 246, 230)
RED = (154, 43, 43)
GREEN = (30, 110, 70)
WHITE = (255, 255, 255)
GREEN_BG = (232, 245, 238)
RED_BG = (250, 234, 234)
REMARK = (55, 65, 81)
# ---------------------------------------------------------------- page geometry (points, letter portrait)
PAGE_W = 612.0
PAGE_H = 792.0
L = 48.0
R = 564.0
CW = R - L
PESCO_ADDR = ("5680 Highway 64", "Farmington, NM 87401", "505.327.2222 • pescoinc.biz")
PESCO_NAME = "PESCO - Process Equipment & Service Company, Inc."
# ---------------------------------------------------------------- results
class PdfError(Exception):
"""A PDF could not be assembled. Per-part problems never raise this; they become warnings."""
class NothingToPrint(PdfError):
"""The print kind has nothing to print for this packet, or the sheet or document is not one of its own."""
class WhoRef(TypedDict):
"""Who builds the PDF: the user id, the display name and the employee number (text, may be None)."""
id: str | None
display_name: str | None
emp_no: str | None
class IndexRow(TypedDict):
"""One row of the data book index. section is 1..7; page is the first page number or None."""
section: int
doc: str
ref: str | None
rev: str | None
page: int | None
warn: bool
count: int
@dataclass
class Built:
"""A built PDF. `stamp` is the stamp line as printed, `stamp_sig` the 16 hex signed into it, `what` the label."""
bytes: bytes
pages: int
index: list[IndexRow] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
stamp: str = ""
stamp_sig: str = ""
what: str = ""
# ---------------------------------------------------------------- text
_MAP = str.maketrans(
{
"‘": "'",
"’": "'",
"“": '"',
"”": '"',
"–": "-",
"—": "-",
"→": "->",
"\t": " ",
}
)
_BULLET = "•"
_DROP = re.compile(r"[\x00-\x09\x0b-\x1f\x7f-\x9f]")
def clean(text: Any) -> str:
"""The old clean(): curly quotes to straight, dashes to "-", arrows to "->". Anything outside Latin-1 is
dropped except the bullet. Control characters are dropped too; a newline stays (it breaks a table cell)."""
s = "" if text is None else str(text)
s = s.replace("\r\n", "\n").replace("\r", "\n").translate(_MAP)
s = "".join(ch for ch in s if ord(ch) <= 0xFF or ch == _BULLET)
return _DROP.sub("", s)
def ascii_only(text: Any, repl: str = "") -> str:
"""Printable ASCII only: everything else becomes `repl`."""
return re.sub(r"[^\x20-\x7e]", repl, clean(text).replace("\n", " "))
def who_text(who: WhoRef | None) -> str:
"""'Name (emp)'. A missing employee number reads 'Name (?)'."""
if not who:
return "unknown"
name = str(who.get("display_name") or "").strip() or "unknown"
emp = str(who.get("emp_no") or "").strip() or "?"
return f"{name} ({emp})"
def fmt_num(value: Any) -> str:
"""A quantity or hours without trailing zeros: 2, 2.5. Text stays as it is."""
if value is None or value == "":
return ""
if isinstance(value, bool):
return str(value)
if isinstance(value, int):
return str(value)
if isinstance(value, Decimal | float):
d = Decimal(str(value)).normalize()
out = format(d, "f")
return out.rstrip("0").rstrip(".") if "." in out else out
return str(value)
def fmt_clock(ms: Any) -> str:
"""Elapsed milliseconds as h:mm."""
try:
t = max(0, int(ms or 0) // 1000)
except (TypeError, ValueError):
t = 0
return f"{t // 3600}:{t % 3600 // 60:02d}"
# ---------------------------------------------------------------- dates
def zone(tz: str | None) -> ZoneInfo:
try:
return ZoneInfo(tz or "America/Denver")
except (ZoneInfoNotFoundError, ValueError):
return ZoneInfo("America/Denver")
def as_dt(value: Any) -> datetime | date | None:
"""A datetime or date from a column value or an ISO string (jsonb snapshots store ISO text)."""
if value is None or value == "":
return None
if isinstance(value, datetime | date):
return value
if isinstance(value, int | float):
try:
return datetime.fromtimestamp(float(value) / 1000, UTC)
except (OverflowError, OSError, ValueError):
return None
try:
return datetime.fromisoformat(str(value).strip().replace("Z", "+00:00"))
except ValueError:
return None
def _local(value: Any, tz: str | None) -> datetime | date | None:
v = as_dt(value)
if isinstance(v, datetime) and v.tzinfo is not None:
return v.astimezone(zone(tz))
return v
def fmt_d(value: Any, tz: str | None = None, empty: str = "—") -> str:
"""M/D/YYYY in the site time zone."""
v = _local(value, tz)
if v is None:
return empty
return f"{v.month}/{v.day}/{v.year}"
def fmt_dt(value: Any, tz: str | None = None, empty: str = "—") -> str:
"""M/D/YYYY h:mm AM in the site time zone. A plain date gives the date only."""
v = _local(value, tz)
if v is None:
return empty
if not isinstance(v, datetime):
return f"{v.month}/{v.day}/{v.year}"
h = v.hour % 12 or 12
return f"{v.month}/{v.day}/{v.year} {h}:{v.minute:02d} {'AM' if v.hour < 12 else 'PM'}"
def utc_now(now: datetime | None = None) -> datetime:
n = now or datetime.now(UTC)
if n.tzinfo is None:
n = n.replace(tzinfo=UTC)
return n.astimezone(UTC)
def utc_stamp(now: datetime | None = None) -> str:
"""YYYY-MM-DD HH:MM UTC."""
return utc_now(now).strftime("%Y-%m-%d %H:%M UTC")
def stamp_at(now: datetime | None = None) -> str:
"""The minute a stamp names, as signed into the stamp signature: YYYY-MM-DDTHH:MMZ."""
return utc_now(now).strftime("%Y-%m-%dT%H:%MZ")
def pdf_date(now: datetime | None = None) -> str:
"""A PDF date string: D:YYYYMMDDHHmmSSZ."""
return utc_now(now).strftime("D:%Y%m%d%H%M%SZ")
def site_tz(settings: Any) -> str:
return str(getattr(settings, "site_tz", None) or "America/Denver")
+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
+329
View File
@@ -0,0 +1,329 @@
"""The job traveler, letter portrait, after the old buildTraveler: job info, the work orders with QR codes for
clocking on at the labor kiosk, the BOM and heat numbers, the checklist with blanks to stamp, the welds, the weld
record, the sign-offs and a notes box.
The QR text is exactly `*%2{wo_base}${lot}${split or "0"}${sub or "0"}${seq}%*`, built from the text columns as they
are (never cast).
"""
from __future__ import annotations
import io
from collections.abc import Mapping
from datetime import datetime
from typing import Any
from pypdf import PdfReader, PdfWriter
from reportlab.graphics import renderPDF
from reportlab.graphics.barcode.qr import QrCodeWidget
from reportlab.graphics.shapes import Drawing
from . import parts, stamps
from .databook import heats_for
from .doc import Doc
from .style import CW, GREY, LINE, Built, L, PdfError, R, WhoRef, clean, fmt_d, fmt_dt, fmt_num, site_tz, utc_now
BLANK = "________"
BLANK_LONG = "________________"
QR_BOX_H = 96.0
QR_SIZE = 84.0
def qr_text(wo: Mapping[str, Any]) -> str:
"""The labor kiosk code of a work order (the old qrText)."""
split = str(wo.get("split") or "") or "0"
sub = str(wo.get("sub") or "") or "0"
seq = "" if wo.get("seq") is None else str(wo.get("seq"))
return f"*%2{wo.get('wo_base') or ''}${wo.get('lot') or ''}${split}${sub}${seq}%*"
def draw_qr(d: Doc, text: str, x: float, y_top: float, size: float) -> None:
"""A QR code (error correction M) `size` points square with its top-left corner at (x, y_top)."""
w = QrCodeWidget(text, barLevel="M", barBorder=0)
x0, y0, x1, y1 = w.getBounds()
bw, bh = x1 - x0, y1 - y0
drawing = Drawing(size, size, transform=[size / bw, 0, 0, size / bh, -x0 * size / bw, -y0 * size / bh])
drawing.add(w)
renderPDF.draw(drawing, d.c, x, d.Y(y_top + size))
def build(conn: Any, settings: Any, packet_id: int, *, who: WhoRef, now: datetime | None = None) -> Built:
"""The traveler with the controlled-copy stamp (mode traveler) and page numbers."""
now = utc_now(now)
p = parts.load_packet(conn, packet_id)
data, pages = pages_of(conn, settings, p)
warnings: list[str] = []
text = stamps.controlled_stamp(who, now, mode="traveler")
stamp, sig = stamps.signed_stamp(
settings, text, packet_id=p["id"], kind="traveler", seal_hash_hex=None, who=who, now=now, warnings=warnings
)
data = finish(data, p, stamp, now, box=False)
return Built(bytes=data, pages=pages, warnings=warnings, stamp=stamp, stamp_sig=sig, what="Job traveler")
def finish(data: bytes, p: Mapping[str, Any], stamp: str, now: datetime, *, box: bool) -> bytes:
"""Page numbers, the stamp (the line, or the PRINTED COPY box) and the metadata."""
project, lot = parts.tag_of(p)
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, clean(f"{project} Lot {lot} - 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)
meta = stamps.metadata(
f"{project} Lot {lot} Job Traveler",
f"Job traveler, drawing {p.get('drawing') or ''} R{p.get('rev') or ''}",
", ".join(x for x in (project, f"Lot {lot}", str(p.get("drawing") or ""), "traveler") if x),
"Packet Station - controlled copy",
now=now,
)
return stamps.write(writer, meta)
def pages_of(conn: Any, settings: Any, p: Mapping[str, Any]) -> tuple[bytes, int]:
"""The traveler pages without page numbers or stamp, and their count."""
try:
d = _draw(conn, settings, p)
return d.finish(), d.pages
except PdfError:
raise
except Exception as e:
raise PdfError(f"The traveler could not be built: {type(e).__name__}.") from e
def _draw(conn: Any, settings: Any, p: Mapping[str, Any]) -> Doc:
tz = site_tz(settings)
project, lot = parts.tag_of(p)
proj = parts.project_of(p)
unit = p.get("unit") or {}
depts = parts.departments(conn)
dept_code = (p.get("dept") or {}).get("code")
bom = p.get("bom") or []
stage = p.get("stage") or ((p.get("stages") or [None])[-1] if p.get("stages") else None) or {}
d = Doc(
(
"Job Traveler",
f"{project} Lot {lot} - {p.get('op') or ''} - drawing {p.get('drawing') or ''} R{p.get('rev') or ''}",
)
)
y = 124.0
welder = p.get("welder") or {}
station = (p.get("station") or {}).get("name") if p.get("station") else ""
worker = [
str(station or ""),
str(welder.get("display_name") or ""),
f"stamp {welder['stamp']}" if welder.get("stamp") else "",
]
rows: list[tuple[str, Any]] = [
("Job / lot", f"{project} · Lot {lot}"),
("Project", f"{proj.get('number') or ''}" + (f" - {proj['name']}" if proj.get("name") else "")),
("Unit", f"{unit.get('number') or ''}" + (f" - {unit['name']}" if unit.get("name") else "")),
]
if proj.get("po"):
rows.append(("Customer PO", proj["po"]))
if unit.get("due_effective"):
rows.append(("Due / ship date", fmt_d(unit["due_effective"], tz)))
rows += [
("Operation", p.get("op")),
("Title", p.get("title")),
("Customer", p.get("customer")),
("Drawing", f"{p.get('drawing') or ''} Rev {p.get('rev') or ''}"),
("WPS", p.get("wps")),
("Department", (depts.get(str(dept_code)) or {}).get("label") or dept_code),
("Station / worker", " · ".join(x for x in worker if x)),
("Status", parts.STATUS_LABEL.get(str(p.get("status")), str(p.get("status") or ""))),
("Target hours", fmt_num(p.get("target_hrs") or 0)),
]
y = d.kv_table(rows, y)
# work orders with QR codes
y += 16
y = d.section_label("Work orders - scan at the labor kiosk to clock on in Visual", y)
wos = p.get("work_orders") or []
bw = (CW - 12) / 2
for i, w in enumerate(wos):
col = i % 2
if col == 0:
y = d.ensure_space(y, QR_BOX_H + 8)
x = L + col * (bw + 12)
d.rect(x, y, bw, QR_BOX_H, stroke=LINE, width=0.8)
draw_qr(d, qr_text(w), x + 6, y + 6, QR_SIZE)
tx0 = x + QR_BOX_H + 2
d.tx(w.get("wo_base") or "", tx0, y + 20, size=12, bold=True)
d.tx(
f"Lot {w.get('lot') or ''} Seq {'' if w.get('seq') is None else w.get('seq')} {w.get('op') or ''}",
tx0,
y + 36,
size=8.5,
)
d.tx(f"Target {fmt_num(w.get('target_hrs'))} h Qty {fmt_num(w.get('qty'))}", tx0, y + 50, size=8.5)
d.tx(
f"Drawing {w.get('dwg') or p.get('drawing') or ''} Rev {w.get('rev') or p.get('rev') or ''}",
tx0,
y + 64,
size=7.5,
color=GREY,
)
if col == 1 or i == len(wos) - 1:
y += QR_BOX_H + 8
if not wos:
y = d.note_box("No work orders were read from this packet.", y, True)
# BOM, the job pull
y += 10
y = d.section_label("Bill of materials and heat numbers (job pull)", y)
body = []
for b in bom:
short = b.get("short") or {}
body.append(
[
str(b.get("item")),
fmt_num(b.get("qty")),
fmt_num(b.get("issued")),
b.get("loc") or "",
(f"{b['part_id']}\n" if b.get("part_id") else "") + str(b.get("description") or ""),
b.get("material") or "",
str(b.get("heat") or "").strip() or ("N/A" if b.get("na") else BLANK),
"Yes" if b.get("mtr_asset_id") else "",
fmt_num(short.get("qty_open")) if short else "",
]
)
y = d.table(
["ITEM", "QTY", "ISSUED", "LOC", "PART ID / DESCRIPTION", "MATERIAL", "HEAT NO.", "MTR", "SHORT"],
body,
y + 4,
{
0: {"w": 28, "align": "center"},
1: {"w": 28, "align": "center"},
2: {"w": 34, "align": "center"},
3: {"w": 34},
5: {"w": 66},
6: {"w": 78, "bold": True},
7: {"w": 28},
8: {"w": 32, "align": "center"},
},
)
# checklist of the current department
y += 16
y = d.section_label("Checklist - stamp each line when done", y)
ck = stage.get("checklist") or {}
body = []
for it in p.get("checklist_items") or []:
e = ck.get(it.get("key"))
e = e if isinstance(e, Mapping) else ({"done": True} if e is True else {})
done = bool(e.get("done"))
body.append(
[
it.get("text") or "",
"YES" if done else "[ ]",
(e.get("stamp") or "") if done else BLANK,
fmt_d(e.get("at"), tz, empty="") if done else BLANK,
]
)
y = d.table(
["CHECK", "DONE", "STAMP", "DATE"], body, y + 4, {1: {"w": 40, "align": "center"}, 2: {"w": 70}, 3: {"w": 70}}
)
# welds
welds = p.get("welds") or []
if welds:
y += 16
y = d.section_label("Weld log", y)
body = [
[
f"W{w.get('weld_no')}",
" + ".join(str(i) for i in w.get("items") or []),
heats_for(bom, w.get("items")),
f"{w.get('type') or ''} {w.get('size') or ''}".strip(),
w.get("process") or "",
w.get("stamp") or "",
]
for w in welds
]
y = d.table(
["WELD", "JOINS ITEMS", "HEATS", "TYPE / SIZE", "PROCESS", "STAMP"],
body,
y + 4,
{0: {"w": 38, "bold": True}},
)
# filler and preheat
y += 16
y = d.section_label("Weld record", y)
y = d.kv_table(
[
("Filler metal", stage.get("filler") or BLANK_LONG),
("Filler lot / heat", stage.get("filler_lot") or BLANK_LONG),
("Preheat", stage.get("preheat") or BLANK_LONG),
],
y + 4,
)
# sign-offs
y += 16
y = d.section_label("Sign-offs", y)
signed = [e for e in parts.log_rows(conn, int(p["id"])) if e.get("signature_id") is not None]
if signed:
body = [
[
fmt_dt(e.get("at"), tz),
e.get("action") or "",
f"{e.get('sig_name') or e.get('by_name') or ''} ({e.get('sig_emp') or e.get('emp_no') or '?'})",
e.get("sig_stamp") or "",
]
for e in signed
]
y = d.table(["WHEN", "STEP", "SIGNED BY", "STAMP"], body, y + 4, {0: {"w": 96}, 3: {"w": 50}})
else:
body = [
[s, "____________________", BLANK]
for s in ("Work complete (stall)", "Supervisor review", "Q.C. inspection")
]
y = d.table(["STEP", "SIGNED BY", "DATE"], body, y + 4)
q = _last_qc(p)
if q:
y += 10
text = f"Q.C. {str(q['result']).upper()} {fmt_dt(q.get('at'), tz)}"
if q.get("inspector"):
text += f" by {q['inspector']}"
if q.get("note"):
text += f" - {q['note']}"
y = d.note_box(text, y, q["result"] != "accepted")
# notes box for the shop floor
y += 12
y = d.ensure_space(y, 130)
d.lbl("Shop notes", L, y)
y += 8
d.rect(L, y, CW, 110, stroke=LINE, width=0.8)
for k in range(1, 5):
d.line(L + 8, y + k * 22, R - 8, y + k * 22, LINE, 0.8)
return d
def _last_qc(p: Mapping[str, Any]) -> dict[str, Any] | None:
"""The open inspection when it has a result, else the newest closed one: {result, at, inspector, note}."""
cur = p.get("qc")
if cur and cur.get("result"):
return {
"result": cur["result"],
"at": cur.get("result_at"),
"inspector": cur.get("inspector"),
"note": cur.get("result_note"),
}
hist = [h for h in p.get("qc_history") or [] if h.get("result")]
if hist:
h = hist[-1]
return {"result": h.get("result"), "at": h.get("at"), "inspector": h.get("inspector"), "note": h.get("note")}
return None
+3
View File
@@ -19,6 +19,8 @@ pypdf==6.21.0
python-multipart==0.0.32
pypdfium2==5.14.0
openpyxl==3.1.5
reportlab==5.0.1
pillow==13.0.0
# needed by visualkit 0.3.0 and mfileskit 0.1.2
pyodbc==5.3.0
@@ -31,6 +33,7 @@ anyio==4.15.1
argon2-cffi-bindings==26.1.0
certifi==2026.7.22
cffi==2.1.1
charset-normalizer==3.5.2
click==8.5.0
colorama==0.4.6; platform_system == "Windows"
cryptography==50.0.2; sys_platform == "linux"