Phase 7: close, file, PDFs and prints
Clerical close and file: the VISUAL observed state beside the close confirm, a 409 with a second confirm when the feed still shows work orders open, filing with a signature and the HMAC seal, the data book built on the server and stored on the seal in the filing transaction, admin reopen keeping the broken seal with its data book. Server PDFs with reportlab, pypdf and pypdfium2: the manufacturing data book page for page after the old build (cover, index, drawings with VOID stamps, traceability and heat map rasters, weld maps and welding records, Q.C. inspections, Q.C. and test documents, MTRs, record history), the job traveler with work order QR codes, the ITP PDF filed automatically at sign-off, every print kind with the PRINTED COPY stamp, the controlled-copy stamp signed with the seal key, PDF metadata, the traceability CSV with the formula guard, and the append-only print_log with an audit row for every print and export (migration 0008). Frontend: the close and file panel, the print menu and its entry points, the printed copies list on the History tab, the ITP PDF buttons. Tests: server (close, file, reopen, seal, the builders, prints, CSV, stamps), frontend, and the Playwright close and print scenarios. Docs updated; SECURITY closes M2, S6, S7 and S8. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -6,10 +6,11 @@ answer is the full ITP read. ITP writes never look at a packet: a filed packet i
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||
from psycopg.types.json import Jsonb
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -17,12 +18,16 @@ from .. import policy
|
||||
from ..domain import itp as dom
|
||||
from ..domain import ncr as ncrs
|
||||
from ..domain import packets
|
||||
from ..domain import prints as prints_dom
|
||||
from ..domain.itp_template import ITP_TEMPLATE
|
||||
from ..domain.pipeline import SignBody, apply_field_cas, jsonable, parse_if_match
|
||||
from ..text import LIMITS, clean_opt, clean_text
|
||||
from .deps import Db, Principal, current_principal, get_db
|
||||
from .deps import Db, Principal, current_principal, get_db, settings_of
|
||||
from .errors import ApiError
|
||||
from .packets import WRITE
|
||||
from .prints import file_response, now_utc, pdf_failed, stamp_of
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["itp"])
|
||||
|
||||
@@ -658,8 +663,10 @@ def sign_off(
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""Sign the ITP off: every block signed. The ITP locks; the PDF hook (phase 7) may file a copy."""
|
||||
"""Sign the ITP off: every block signed. The ITP locks and the signed PDF is filed in the unit's Q.C. folder.
|
||||
A PDF that cannot be built answers 500 itp_pdf_failed and nothing is signed off."""
|
||||
from ..pdf import itp as itp_pdf
|
||||
from ..pdf.style import PdfError
|
||||
|
||||
conn = d.conn
|
||||
row0 = dom.load_row(conn, itp_id)
|
||||
@@ -676,12 +683,19 @@ def sign_off(
|
||||
)
|
||||
row = dom.load_row(conn, itp_id)
|
||||
assert row is not None
|
||||
asset_id = itp_pdf.file_signed_itp_pdf(conn, row, p.actor)
|
||||
try:
|
||||
asset_id = itp_pdf.file_signed_itp_pdf(conn, row, p.actor, settings=settings_of(request))
|
||||
except PdfError as e:
|
||||
log.warning("signed ITP PDF for ITP %s failed: %s", itp_id, e)
|
||||
text = " ".join(str(e).split()).rstrip(".")[:300] or "an unknown error"
|
||||
raise ApiError(
|
||||
500, "itp_pdf_failed", f"The signed ITP PDF could not be built: {text}. The ITP was not signed off."
|
||||
) from None
|
||||
if asset_id is not None:
|
||||
_file_auto_doc(w, row, asset_id)
|
||||
w.log("ITP signed off", "PDF filed")
|
||||
else:
|
||||
w.log("ITP signed off", "PDF filing reserved for phase 7")
|
||||
w.log("ITP signed off")
|
||||
return _answer(d, p, itp_id)
|
||||
|
||||
|
||||
@@ -742,3 +756,82 @@ def reopen(
|
||||
)
|
||||
w.log("ITP reopened", reason)
|
||||
return _answer(d, p, itp_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the ITP PDF and its printed copy
|
||||
|
||||
|
||||
def _itp_row(d: Db, itp_id: int) -> dict[str, Any]:
|
||||
row = dom.load_row(d.conn, itp_id)
|
||||
if row is None:
|
||||
raise ApiError(404, "not_found", dom.MSG_NO_ITP)
|
||||
return row
|
||||
|
||||
|
||||
@router.get("/api/itps/{itp_id}/pdf")
|
||||
def itp_pdf_file(
|
||||
itp_id: int,
|
||||
request: Request,
|
||||
download: int = Query(0, ge=0, le=1),
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> Response:
|
||||
"""The ITP PDF built now, open or complete; the stamp says which. Logged as an export."""
|
||||
from ..pdf import itp as itp_pdf
|
||||
from ..pdf.style import PdfError
|
||||
|
||||
conn = d.conn
|
||||
row = _itp_row(d, itp_id)
|
||||
try:
|
||||
built = itp_pdf.build(conn, settings_of(request), row, who=prints_dom.who_of(p), now=now_utc())
|
||||
except PdfError as e:
|
||||
raise pdf_failed(e) from None
|
||||
stamp, sig = stamp_of(built)
|
||||
prints_dom.record(
|
||||
conn,
|
||||
p,
|
||||
action="export",
|
||||
kind="itp-pdf",
|
||||
what=f"ITP {row['no'] or ''}".strip(),
|
||||
pages=built.pages,
|
||||
unit_id=row["unit_id"],
|
||||
itp_id=itp_id,
|
||||
stamp=stamp,
|
||||
stamp_sig=sig,
|
||||
)
|
||||
return file_response(built.bytes, prints_dom.itp_name(row["no"]), download=bool(download))
|
||||
|
||||
|
||||
@router.get("/api/itps/{itp_id}/print")
|
||||
def itp_print(
|
||||
itp_id: int,
|
||||
request: Request,
|
||||
download: int = Query(0, ge=0, le=1),
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> Response:
|
||||
"""The ITP PDF stamped PRINTED COPY. Logged as a print."""
|
||||
from ..pdf import prints as print_pdf
|
||||
from ..pdf.style import PdfError
|
||||
|
||||
conn = d.conn
|
||||
row = _itp_row(d, itp_id)
|
||||
try:
|
||||
built = print_pdf.print_itp(conn, settings_of(request), row, who=prints_dom.who_of(p), now=now_utc())
|
||||
except PdfError as e:
|
||||
raise pdf_failed(e) from None
|
||||
stamp, sig = stamp_of(built)
|
||||
what = str(getattr(built, "what", None) or f"ITP {row['no'] or ''}".strip())
|
||||
prints_dom.record(
|
||||
conn,
|
||||
p,
|
||||
action="print",
|
||||
kind="itp",
|
||||
what=what,
|
||||
pages=built.pages,
|
||||
unit_id=row["unit_id"],
|
||||
itp_id=itp_id,
|
||||
stamp=stamp,
|
||||
stamp_sig=sig,
|
||||
)
|
||||
return file_response(built.bytes, prints_dom.itp_name(row["no"]), download=bool(download))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
]
|
||||
@@ -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[];
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user