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]>
1063 lines
42 KiB
Python
1063 lines
42 KiB
Python
"""The manufacturing data book, page for page after the old databook.js.
|
|
|
|
Sections, in order: 1 Drawings, 2 Material traceability, 3 Welding record, 4 Q.C. inspection, 5 Q.C. and test
|
|
documents, 6 Material test reports, 7 Record history. Front matter (cover and index) is ours; drawings, documents
|
|
and MTRs are copied at their own size; the heat and weld maps are rasters at the sheet's size.
|
|
|
|
A part that cannot be read (a missing file, a file that is not a PDF or an image, a pypdf error) becomes a warning row
|
|
in the index and in `Built.warnings`. Only a failure to assemble the whole book raises PdfError.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import logging
|
|
from collections.abc import Callable, Mapping, Sequence
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from functools import partial
|
|
from typing import Any
|
|
|
|
from pypdf import PdfReader, PdfWriter
|
|
|
|
from ..domain import checklists, packets
|
|
from ..domain.qc import ROUTE_TEXTS
|
|
from . import maps, parts, stamps
|
|
from .doc import Doc
|
|
from .style import (
|
|
AMBER,
|
|
AMBER_BG,
|
|
CW,
|
|
GREEN,
|
|
GREEN_BG,
|
|
GREY,
|
|
NAVY,
|
|
PANEL,
|
|
PESCO_ADDR,
|
|
RED,
|
|
RED_BG,
|
|
TEAL,
|
|
WHITE,
|
|
Built,
|
|
IndexRow,
|
|
L,
|
|
PdfError,
|
|
R,
|
|
WhoRef,
|
|
clean,
|
|
fmt_clock,
|
|
fmt_d,
|
|
fmt_dt,
|
|
fmt_num,
|
|
site_tz,
|
|
utc_now,
|
|
)
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
MODES = ("preview", "filed", "print")
|
|
SECTION_NAMES = (
|
|
"Drawings",
|
|
"Material traceability",
|
|
"Welding record",
|
|
"Q.C. inspection",
|
|
"Q.C. and test documents",
|
|
"Material test reports",
|
|
"Record history",
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class Part:
|
|
"""One index row and the pages behind it."""
|
|
|
|
section: int
|
|
doc: str
|
|
ref: str | None = None
|
|
rev: str | None = None
|
|
warn: bool = False
|
|
pdf: bytes | None = None # our generated pages, a raster page or an image page
|
|
reader: PdfReader | None = None # a copied PDF
|
|
pages: list[int] = field(default_factory=list) # page indices of `reader`
|
|
own: bool = False # our generated pages (page numbers and the stamp at the left)
|
|
void: str | None = None # the VOID text for a copied void drawing page
|
|
count: int = 0
|
|
page: int | None = None
|
|
|
|
def row(self) -> IndexRow:
|
|
return {
|
|
"section": self.section,
|
|
"doc": self.doc,
|
|
"ref": self.ref,
|
|
"rev": self.rev,
|
|
"page": self.page,
|
|
"warn": self.warn,
|
|
"count": self.count,
|
|
}
|
|
|
|
|
|
def build(
|
|
conn: Any,
|
|
settings: Any,
|
|
packet_id: int,
|
|
*,
|
|
who: WhoRef,
|
|
now: datetime | None = None,
|
|
seal: Mapping[str, Any] | None = None,
|
|
mode: str = "preview",
|
|
) -> Built:
|
|
"""The data book of a packet. mode "filed" (with `seal` {hash, key_id, sealed_at}) names the seal in the stamp,
|
|
"preview" says not yet filed, "print" says printed by. Raises PdfError only when the book cannot be assembled."""
|
|
if mode not in MODES:
|
|
raise PdfError(f"Unknown data book mode {mode}.")
|
|
p = parts.load_packet(conn, packet_id)
|
|
book = Book(conn, settings, p, who, utc_now(now), mode, seal)
|
|
try:
|
|
return book.run()
|
|
except PdfError:
|
|
raise
|
|
except Exception as e:
|
|
log.exception("data book assembly failed for packet %s", packet_id)
|
|
raise PdfError(f"{type(e).__name__}: {clean(e)[:160]}") from e
|
|
|
|
|
|
def heats_for(bom: Sequence[Mapping[str, Any]], items: Sequence[Any] | None) -> str:
|
|
"""The heats of the joined items: "HT-10 + HT-20", N/A on an N/A line, "-" when unknown."""
|
|
out = []
|
|
for i in items or []:
|
|
b = next((x for x in bom if str(x.get("item")) == str(i)), None)
|
|
if b is None:
|
|
out.append("-")
|
|
else:
|
|
out.append(str(b.get("heat") or "").strip() or ("N/A" if b.get("na") else "-"))
|
|
return " + ".join(out)
|
|
|
|
|
|
def piece_text(w: Mapping[str, Any]) -> str:
|
|
"""The weld's piece: "{wo} #{piece}", or what of it is known."""
|
|
wo, piece = str(w.get("wo_base") or ""), str(w.get("piece") or "")
|
|
if not wo and not piece:
|
|
return ""
|
|
return f"{wo} #{piece}".strip() if piece else wo
|
|
|
|
|
|
def co_summary(co: Mapping[str, Any]) -> str:
|
|
"""The old coSummary(): "M00543 Rev 1 -> Rev 2 + 2 other changes", or the replaced sheets."""
|
|
feed = co.get("origin") in ("visual", "drawing")
|
|
if feed and co.get("review") != "approved":
|
|
v = co.get("vis") or {}
|
|
ch = list(v.get("changes") or [])
|
|
if v.get("dwg_change"):
|
|
fd, td = v.get("from_dwg"), v.get("to_dwg")
|
|
same = fd and td and "".join(str(fd).split()).upper() == "".join(str(td).split()).upper()
|
|
frm = f"{fd} -> " if fd and td and not same else ""
|
|
more = f" + {len(ch)} other change{'s' if len(ch) > 1 else ''}" if ch else ""
|
|
return f"{frm}{td or ''} Rev {v.get('from_rev') or '?'} -> Rev {v.get('to_rev') or '?'}{more}"
|
|
text = "; ".join(str(c) for c in ch[:2]) + (f" (+{len(ch) - 2} more)" if len(ch) > 2 else "")
|
|
return text or str(co.get("reason") or "") or "change from VISUAL"
|
|
items = co.get("items") or []
|
|
lines = [
|
|
f"{i.get('old_dwg') or 'sheet'} Rev {i.get('old_rev') or '?'} -> Rev {i.get('new_rev') or '?'}"
|
|
for i in items
|
|
if not i.get("added")
|
|
]
|
|
added = sum(1 for i in items if i.get("added"))
|
|
if added:
|
|
lines.append(f"{added} sheet{'s' if added > 1 else ''} added")
|
|
return ", ".join(lines) or str(co.get("reason") or "") or "documents changed"
|
|
|
|
|
|
class Book:
|
|
def __init__(
|
|
self,
|
|
conn: Any,
|
|
settings: Any,
|
|
p: dict[str, Any],
|
|
who: WhoRef,
|
|
now: datetime,
|
|
mode: str,
|
|
seal: Mapping[str, Any] | None,
|
|
) -> None:
|
|
self.conn = conn
|
|
self.settings = settings
|
|
self.p = p
|
|
self.who = who
|
|
self.now = now
|
|
self.mode = mode
|
|
self.seal = seal or {}
|
|
self.tz = site_tz(settings)
|
|
self.project, self.lot = parts.tag_of(p)
|
|
self.depts = parts.departments(conn)
|
|
self.warnings: list[str] = []
|
|
self.parts: list[Part] = []
|
|
self.readers: dict[str, PdfReader] = {}
|
|
ids: list[Any] = [p.get("welder")]
|
|
for st in [*(p.get("stages") or []), p.get("stage")]:
|
|
if st:
|
|
ids.append(st.get("welder"))
|
|
for co in p.get("change_orders") or []:
|
|
ids.append(co.get("issued_by"))
|
|
ids += [v.get("by") for v in (co.get("verified") or {}).values()]
|
|
self.users = parts.users_of(conn, ids)
|
|
|
|
# ------------------------------------------------------------ helpers
|
|
|
|
def dt(self, v: Any) -> str:
|
|
return fmt_dt(v, self.tz)
|
|
|
|
def d(self, v: Any) -> str:
|
|
return fmt_d(v, self.tz)
|
|
|
|
def dept_name(self, code: Any) -> str:
|
|
info = self.depts.get(str(code or ""))
|
|
return str(info["label"]) if info else str(code or "")
|
|
|
|
def dept_weld(self, code: Any) -> bool:
|
|
info = self.depts.get(str(code or ""))
|
|
return bool(info and info["weld"])
|
|
|
|
def warn(self, section: int, doc: str, why: str = "") -> None:
|
|
self.parts.append(Part(section, doc, warn=True))
|
|
self.warnings.append(doc + (f" ({why})" if why else ""))
|
|
|
|
def reader_for(self, asset_id: Any) -> PdfReader:
|
|
key = str(asset_id)
|
|
if key not in self.readers:
|
|
_row, path = maps.asset_file(self.conn, self.settings, asset_id)
|
|
self.readers[key] = parts.open_pdf(path)
|
|
return self.readers[key]
|
|
|
|
# ------------------------------------------------------------ the run
|
|
|
|
def run(self) -> Built:
|
|
p = self.p
|
|
live, void = parts.drawing_pages(p), parts.void_pages(p)
|
|
self.s1_drawings(live, void)
|
|
self.s2_traceability(live, void)
|
|
self.s3_welding(live, void)
|
|
self.s4_qc()
|
|
self.s5_documents()
|
|
self.s6_mtrs()
|
|
self.s7_history()
|
|
size = self.drawing_size(live or void)
|
|
|
|
front_n = 2
|
|
total = self.number(front_n)
|
|
front, pages = self.front(total, size)
|
|
if pages != front_n:
|
|
front_n = pages
|
|
total = self.number(front_n)
|
|
front, pages = self.front(total, size)
|
|
return self.assemble(front, pages, total)
|
|
|
|
def number(self, front_n: int) -> int:
|
|
page_no = front_n + 1
|
|
for pt in self.parts:
|
|
if pt.count:
|
|
pt.page = page_no
|
|
page_no += pt.count
|
|
else:
|
|
pt.page = None
|
|
return page_no - 1
|
|
|
|
def drawing_size(self, pages: Sequence[Mapping[str, Any]]) -> str:
|
|
for pg in pages:
|
|
try:
|
|
rd = self.reader_for(pg.get("asset_id"))
|
|
page = rd.pages[int(pg.get("pn") or 1) - 1]
|
|
w, h = float(page.mediabox.width), float(page.mediabox.height)
|
|
if int(page.get("/Rotate", 0) or 0) % 180 == 90:
|
|
w, h = h, w
|
|
return f'{round(w / 72)}" x {round(h / 72)}"'
|
|
except Exception as e: # an unreadable sheet is reported in section 1; try the next one
|
|
log.debug("sheet size not read: %s", type(e).__name__)
|
|
continue
|
|
return "D-size"
|
|
|
|
# ------------------------------------------------------------ section 1
|
|
|
|
def copy_part(
|
|
self,
|
|
section: int,
|
|
doc: str,
|
|
ref: str | None,
|
|
rev: str | None,
|
|
pg: Mapping[str, Any],
|
|
*,
|
|
void: str | None = None,
|
|
) -> None:
|
|
try:
|
|
rd = self.reader_for(pg.get("asset_id"))
|
|
idx = int(pg.get("pn") or 1) - 1
|
|
if idx < 0 or idx >= len(rd.pages):
|
|
raise PdfError(f"The PDF has no page {idx + 1}.")
|
|
self.parts.append(Part(section, doc, ref, rev, reader=rd, pages=[idx], void=void, count=1))
|
|
except Exception as e: # per-part failure: a warning row, never an exception
|
|
self.warn(section, f"{doc} could not be read", type(e).__name__)
|
|
|
|
def s1_drawings(self, live: list[dict[str, Any]], void: list[dict[str, Any]]) -> None:
|
|
p = self.p
|
|
for pg in live:
|
|
co = parts.page_co(p, int(pg["n"]))
|
|
doc = f"{p.get('title') or 'Drawing'} - approved drawing" + (f" (CO-{co})" if co is not None else "")
|
|
self.copy_part(1, doc, pg.get("dwg") or p.get("drawing"), f"R{pg.get('rev') or p.get('rev') or ''}", pg)
|
|
for pg in void:
|
|
v = pg.get("void") or {}
|
|
label = f"CO-{v['co_no']}" if v.get("co_no") is not None else "a change order"
|
|
self.copy_part(
|
|
1,
|
|
f"SUPERSEDED - VOID by {label} - kept for the record",
|
|
pg.get("dwg") or p.get("drawing"),
|
|
f"R{pg.get('rev') or ''}",
|
|
pg,
|
|
void=maps.void_text(pg, self.tz),
|
|
)
|
|
|
|
# ------------------------------------------------------------ section 2
|
|
|
|
def s2_traceability(self, live: list[dict[str, Any]], void: list[dict[str, Any]]) -> None:
|
|
self.gen(2, "Material traceability record — heat numbers at issue", self.traceability)
|
|
p = self.p
|
|
any_heat = False
|
|
for pg in [*live, *void]:
|
|
n = int(pg["n"])
|
|
table = maps.heat_placement(p, n)
|
|
tags = maps.legacy_tags(p, n)
|
|
if table is None and not tags:
|
|
continue
|
|
any_heat = True
|
|
what = "heat number table on the drawing" if table is not None else f"{len(tags)} heat tags on the drawing"
|
|
doc = f"Heat map — {what}" + (" (VOID copy)" if pg.get("void") else "")
|
|
self.raster(2, doc, pg, maps.heat_map_page)
|
|
if not any_heat:
|
|
self.warn(2, "Heat map — no heat table was placed on the drawing")
|
|
|
|
def raster(
|
|
self, section: int, doc: str, pg: Mapping[str, Any], fn: Callable[..., tuple[bytes, float, float]]
|
|
) -> None:
|
|
p = self.p
|
|
ref = pg.get("dwg") or p.get("drawing")
|
|
rev = f"R{pg.get('rev') or p.get('rev') or ''}"
|
|
try:
|
|
jpeg, w, h = fn(self.conn, self.settings, p, pg)
|
|
self.parts.append(Part(section, doc, ref, rev, pdf=parts.raster_page_pdf(jpeg, w, h), count=1))
|
|
except Exception as e: # per-part failure
|
|
self.warn(section, f"{doc} could not be read", type(e).__name__)
|
|
|
|
def gen(self, section: int, doc: str, fn: Callable[[], Doc], ref: str | None = None) -> None:
|
|
d = fn()
|
|
pdf = d.finish()
|
|
self.parts.append(Part(section, doc, ref, None, pdf=pdf, own=True, count=d.pages))
|
|
|
|
def traceability(self) -> Doc:
|
|
p = self.p
|
|
bom = p.get("bom") or []
|
|
d = Doc(("Material Traceability", "Section 2 • Manufacturing data book"))
|
|
y = 128.0
|
|
lead = (
|
|
"Heat numbers were recorded by the warehouse when material was issued to the job. Section 2 also locates"
|
|
" each heat on the drawing."
|
|
)
|
|
y += (d.tx(lead, L, y, size=8.5, color=GREY, max_width=CW) - 1) * 9.8 + 22
|
|
y = d.section_label("Bill of materials and heat numbers", y)
|
|
rows = []
|
|
for b in bom:
|
|
heat = str(b.get("heat") or "").strip() or ("N/A (non-traceable)" if b.get("na") else "NOT RECORDED")
|
|
pd = (f"{b['part_id']}\n" if b.get("part_id") else "") + str(b.get("description") or "")
|
|
rows.append(
|
|
[
|
|
str(b.get("item")),
|
|
fmt_num(b.get("qty")),
|
|
pd,
|
|
b.get("length") or "",
|
|
b.get("material") or "",
|
|
heat,
|
|
"On file" if b.get("mtr_asset_id") else "—",
|
|
]
|
|
)
|
|
y = d.table(
|
|
["ITEM", "QTY", "PART ID / DESCRIPTION", "LENGTH", "MATERIAL", "HEAT NO.", "MTR"],
|
|
rows,
|
|
y,
|
|
{
|
|
0: {"w": 30, "align": "center"},
|
|
1: {"w": 30, "align": "center"},
|
|
3: {"w": 52},
|
|
4: {"w": 78},
|
|
5: {"w": 92, "bold": True},
|
|
6: {"w": 44},
|
|
},
|
|
)
|
|
shorts = parts.shorts_of(self.conn, int(p["id"]))
|
|
if shorts:
|
|
y += 16
|
|
y = d.section_label("Material received after issue (shorts)", y)
|
|
srows = []
|
|
for s in shorts:
|
|
rc = s.get("receipts") or []
|
|
srows.append(
|
|
[
|
|
str(s.get("item")),
|
|
fmt_num(s.get("qty")),
|
|
s.get("reason") or "",
|
|
"\n".join(f"{fmt_num(r.get('qty'))} pc {self.d(r.get('at'))}" for r in rc) or "—",
|
|
"\n".join(str(r.get("heat") or "") for r in rc) or "—",
|
|
f"OPEN - {fmt_num(s.get('qty_open'))} still short" if s.get("closed_at") is None else "Closed",
|
|
]
|
|
)
|
|
y = d.table(
|
|
["ITEM", "SHORT", "REASON", "RECEIVED", "HEAT NO.", "STATUS"],
|
|
srows,
|
|
y,
|
|
{0: {"w": 30, "align": "center"}, 1: {"w": 40, "align": "center"}},
|
|
)
|
|
open_items = [f"item {b.get('item')}" for b in bom if not str(b.get("heat") or "").strip() and not b.get("na")]
|
|
open_items += [
|
|
f"item {s.get('item')} short {fmt_num(s.get('qty_open'))}" for s in shorts if s.get("closed_at") is None
|
|
]
|
|
if open_items:
|
|
y += 12
|
|
d.note_box("Open items: " + "; ".join(open_items) + ". Resolve before release to the customer.", y, True)
|
|
return d
|
|
|
|
# ------------------------------------------------------------ section 3
|
|
|
|
def stage_records(self) -> list[dict[str, Any]]:
|
|
p = self.p
|
|
closed = [dict(s, current=False) for s in p.get("stages") or []]
|
|
recs = list(closed)
|
|
if p.get("stage"):
|
|
recs.append(dict(p["stage"], current=True))
|
|
if not recs:
|
|
recs.append(
|
|
{
|
|
"dept_code": (p.get("dept") or {}).get("code"),
|
|
"station": p.get("station"),
|
|
"welder": p.get("welder"),
|
|
"checklist": {},
|
|
"clock": {"elapsed_ms": 0, "started_at": None},
|
|
"current": True,
|
|
}
|
|
)
|
|
return recs
|
|
|
|
def record_welds(self, rec: Mapping[str, Any], single: bool) -> list[dict[str, Any]]:
|
|
welds = self.p.get("welds") or []
|
|
if single:
|
|
return list(welds)
|
|
cur = (self.p.get("dept") or {}).get("code")
|
|
return [w for w in welds if (w.get("dept_code") or cur) == rec.get("dept_code")]
|
|
|
|
def s3_welding(self, live: list[dict[str, Any]], void: list[dict[str, Any]]) -> None:
|
|
p = self.p
|
|
for pg in [*live, *void]:
|
|
on = maps.page_welds(p, int(pg["n"]))
|
|
if not on:
|
|
continue
|
|
ids = ", ".join(f"W{w.get('weld_no')}" for w in on)
|
|
doc = f"Weld map — welds {ids}" + (" (VOID copy)" if pg.get("void") else "")
|
|
self.raster(3, doc, pg, maps.weld_map_page)
|
|
recs = self.stage_records()
|
|
no_closed = not (p.get("stages") or [])
|
|
single = len(recs) == 1
|
|
for rec in recs:
|
|
welds = self.record_welds(rec, single)
|
|
keep = rec.get("station") or rec.get("welder") or welds or (rec.get("current") and no_closed)
|
|
if not keep:
|
|
continue
|
|
weld = self.dept_weld(rec.get("dept_code"))
|
|
name = self.dept_name(rec.get("dept_code"))
|
|
doc = f"{'Weld log, welding record' if weld else 'Work record'} and checklist — {name}"
|
|
self.gen(3, doc, partial(self.weld_record, rec, welds), ref=(p.get("wps") or None) if weld else None)
|
|
|
|
def weld_record(self, rec: Mapping[str, Any], welds: list[dict[str, Any]]) -> Doc:
|
|
p = self.p
|
|
bom = p.get("bom") or []
|
|
code = rec.get("dept_code")
|
|
weld = self.dept_weld(code)
|
|
name = self.dept_name(code)
|
|
d = Doc(("Welding Record" if weld else "Work Record", f"Section 3 • {name}"))
|
|
y = 128.0
|
|
y = d.section_label("Welding record" if weld else "Work record", y)
|
|
welder = rec.get("welder") or {}
|
|
stamp = welder.get("stamp") if isinstance(welder, Mapping) else None
|
|
wname = parts.name_of(self.users, welder) if welder else ""
|
|
clock = rec.get("clock") or {}
|
|
time = fmt_clock(clock.get("elapsed_ms"))
|
|
if rec.get("current"):
|
|
time += f" (target {fmt_num(p.get('target_hrs') or 0)} h)"
|
|
rows: list[tuple[str, Any]] = [
|
|
("Department", name),
|
|
("Welder" if weld else "Worker", f"{wname}{' [' + str(stamp) + ']' if stamp else ''}"),
|
|
("Station", (rec.get("station") or {}).get("name") if rec.get("station") else ""),
|
|
]
|
|
if weld:
|
|
rows += [
|
|
("Welding procedure (WPS)", p.get("wps")),
|
|
("Filler metal", rec.get("filler")),
|
|
("Filler lot / heat", rec.get("filler_lot")),
|
|
("Preheat", rec.get("preheat")),
|
|
]
|
|
rows += [
|
|
("Time recorded", time),
|
|
("Work started", self.dt(clock.get("started_at"))),
|
|
("Drawing", f"{p.get('drawing') or ''} R{p.get('rev') or ''}"),
|
|
]
|
|
y = d.kv_table(rows, y)
|
|
if weld or welds:
|
|
y += 18
|
|
y = d.section_label(f"Weld log — {len(welds)} welds (locations on the weld map, Section 3)", y)
|
|
body = [
|
|
[
|
|
f"W{w.get('weld_no')}",
|
|
piece_text(w),
|
|
" + ".join(str(i) for i in w.get("items") or []),
|
|
heats_for(bom, w.get("items")),
|
|
w.get("type") or "",
|
|
w.get("size") or "",
|
|
w.get("process") or "",
|
|
w.get("stamp") or "",
|
|
self.d(w.get("at")),
|
|
]
|
|
for w in welds
|
|
]
|
|
y = d.table(
|
|
["WELD", "PIECE", "JOINS ITEMS", "HEATS JOINED", "TYPE", "SIZE", "PROCESS", "STAMP", "LOGGED"],
|
|
body,
|
|
y,
|
|
{
|
|
0: {"w": 34, "bold": True},
|
|
1: {"w": 82},
|
|
2: {"w": 48},
|
|
4: {"w": 40},
|
|
5: {"w": 36},
|
|
6: {"w": 44},
|
|
7: {"w": 36},
|
|
8: {"w": 50},
|
|
},
|
|
size=7.8,
|
|
)
|
|
y += 18
|
|
y = d.section_label("Station checklist", y)
|
|
items = checklists.checklist_for(
|
|
weld, p.get("drawing"), p.get("rev"), p.get("wps"), checklists.load_templates(self.conn)
|
|
)
|
|
ck = rec.get("checklist") or {}
|
|
body = []
|
|
for it in items:
|
|
e = ck.get(it["key"])
|
|
e = e if isinstance(e, Mapping) else ({"done": True} if e is True else {})
|
|
body.append(
|
|
[
|
|
f"{it['group']}: {it['text']}",
|
|
"Yes" if checklists.is_done(e) else "No",
|
|
e.get("stamp") or "",
|
|
self.dt(e.get("at")) if e.get("at") else "—",
|
|
]
|
|
)
|
|
y = d.table(
|
|
["CHECK", "DONE", "STAMP", "DATE / TIME"],
|
|
body,
|
|
y,
|
|
{1: {"w": 40, "align": "center"}, 2: {"w": 50}, 3: {"w": 100}},
|
|
)
|
|
if rec.get("notes"):
|
|
y += 12
|
|
d.note_box("Notes: " + str(rec["notes"]), y)
|
|
return d
|
|
|
|
# ------------------------------------------------------------ section 4
|
|
|
|
def inspections(self) -> list[dict[str, Any]]:
|
|
"""Every closed inspection with a result (its snapshot), oldest first, then the open one with a result."""
|
|
out: list[dict[str, Any]] = []
|
|
for h in packets.rows(self.conn, "SELECT * FROM qc_history WHERE packet_id = %s ORDER BY id", (self.p["id"],)):
|
|
snap = h.get("snapshot") if isinstance(h.get("snapshot"), Mapping) else {}
|
|
q = dict(snap or {})
|
|
q.setdefault("result", h.get("result"))
|
|
q.setdefault("inspector", h.get("inspector"))
|
|
q["result_at"] = q.get("result_at") or h.get("at")
|
|
q["note"] = h.get("note")
|
|
q["dept_code"] = h.get("dept_code")
|
|
if q.get("result"):
|
|
out.append(q)
|
|
cur = self.p.get("qc")
|
|
if cur and cur.get("result"):
|
|
q = dict(cur)
|
|
q["note"] = cur.get("result_note")
|
|
q["dept_code"] = (self.p.get("dept") or {}).get("code")
|
|
out.append(q)
|
|
return out
|
|
|
|
def s4_qc(self) -> None:
|
|
sets = self.inspections()
|
|
for i, q in enumerate(sets):
|
|
res = str(q.get("result") or "open").upper()
|
|
doc = f"Q.C. inspection — {self.dept_name(q.get('dept_code'))} — {res} {self.d(q.get('result_at'))}"
|
|
earlier = sets[:i] if i == len(sets) - 1 else None
|
|
self.gen(4, doc, partial(self.qc_set, q, earlier))
|
|
if not sets:
|
|
self.warn(4, "Not inspected by Q.C.")
|
|
|
|
def qc_set(self, q: Mapping[str, Any], earlier: list[dict[str, Any]] | None) -> Doc:
|
|
p = self.p
|
|
d = Doc(("Q.C. Inspection", "Section 4 • Manufacturing data book"))
|
|
y = 128.0
|
|
result = str(q.get("result") or "")
|
|
bg = GREEN_BG if result == "accepted" else RED_BG if result == "rejected" else AMBER_BG
|
|
fg = GREEN if result == "accepted" else RED if result == "rejected" else AMBER
|
|
d.rect(L, y - 12, CW, 34, fill=bg)
|
|
d.tx((result or "open").upper(), L + 12, y + 9, size=15, bold=True, color=fg, cs=1.5)
|
|
insp = str(q.get("inspector") or "").strip() or "—"
|
|
stamp = f" [{q['stamp']}]" if q.get("stamp") else ""
|
|
route = ""
|
|
if result == "accepted" and p.get("routed_to"):
|
|
route = f" • released to {ROUTE_TEXTS.get(str(p['routed_to']), str(p['routed_to']))}"
|
|
d.tx(f"Inspector {insp}{stamp} • {self.dt(q.get('result_at'))}{route}", L + 140, y + 8, size=8.5, color=GREY)
|
|
y += 42
|
|
y = d.section_label("Inspection checks", y)
|
|
checks = q.get("checks") or {}
|
|
body = []
|
|
for it in p.get("qc_items") or []:
|
|
e = checks.get(it.get("key"))
|
|
e = e if isinstance(e, Mapping) else ({"done": True} if e is True else {})
|
|
body.append(
|
|
[
|
|
it.get("group") or "",
|
|
it.get("text") or "",
|
|
"Acceptable" if checklists.is_done(e) else "Not checked",
|
|
e.get("stamp") or "",
|
|
self.dt(e.get("at")) if e.get("at") else "—",
|
|
]
|
|
)
|
|
y = d.table(
|
|
["AREA", "CHECK", "RESULT", "STAMP", "DATE / TIME"],
|
|
body,
|
|
y,
|
|
{0: {"w": 96, "color": GREY}, 2: {"w": 60}, 3: {"w": 40}, 4: {"w": 86}},
|
|
)
|
|
y += 18
|
|
y = d.section_label("Visual weld examination", y)
|
|
exam = q.get("weld_exam") or {}
|
|
body = []
|
|
for w in p.get("welds") or []:
|
|
v = exam.get(str(w.get("weld_no")))
|
|
v = v if isinstance(v, Mapping) else {}
|
|
r = str(v.get("result") or "").lower()
|
|
body.append(
|
|
[
|
|
f"W{w.get('weld_no')}",
|
|
f"{w.get('type') or ''} {w.get('size') or ''}".strip(),
|
|
" + ".join(str(i) for i in w.get("items") or []),
|
|
"Accept" if r == "accept" else "Reject" if r == "reject" else "—",
|
|
v.get("note") or "",
|
|
]
|
|
)
|
|
y = d.table(
|
|
["WELD", "TYPE / SIZE", "JOINS ITEMS", "RESULT", "NOTE"],
|
|
body,
|
|
y,
|
|
{0: {"w": 40, "bold": True}, 1: {"w": 80}, 2: {"w": 66}, 3: {"w": 56, "bold": True}},
|
|
)
|
|
y += 18
|
|
y = d.section_label("NDE and testing", y)
|
|
method = str(q.get("nde_method") or "").strip()
|
|
none = not method or method.lower() in ("none", "none required")
|
|
rows = [
|
|
("NDE method", method or "None required"),
|
|
("NDE result", q.get("nde_result") or ("N/A" if none else "—")),
|
|
("NDE report no.", q.get("nde_report")),
|
|
("Hardness (NACE)" if q.get("nace") else "Hardness", q.get("hardness")),
|
|
("Spool location at inspection", q.get("location")),
|
|
("Sent to Q.C.", self.dt(q.get("sent_at"))),
|
|
]
|
|
y = d.kv_table(rows, y)
|
|
if q.get("notes"):
|
|
y += 12
|
|
y = d.note_box("Inspection notes: " + str(q["notes"]), y)
|
|
if earlier:
|
|
y += 16
|
|
y = d.section_label("Earlier inspections", y)
|
|
body = [
|
|
[
|
|
self.dt(e.get("result_at")),
|
|
str(e.get("result") or "").upper(),
|
|
e.get("inspector") or "",
|
|
e.get("note") or "",
|
|
]
|
|
for e in earlier
|
|
]
|
|
d.table(["DATE", "RESULT", "INSPECTOR", "REASON"], body, y, {0: {"w": 96}, 1: {"w": 70}, 2: {"w": 90}})
|
|
return d
|
|
|
|
# ------------------------------------------------------------ sections 5 and 6
|
|
|
|
def file_part(self, section: int, doc: str, asset_id: Any, ref: str | None = None, rev: str | None = None) -> None:
|
|
try:
|
|
_row, path = maps.asset_file(self.conn, self.settings, asset_id)
|
|
kind = parts.sniff(path)
|
|
if kind == "pdf":
|
|
rd = parts.open_pdf(path)
|
|
self.parts.append(
|
|
Part(section, doc, ref, rev, reader=rd, pages=list(range(len(rd.pages))), count=len(rd.pages))
|
|
)
|
|
elif kind in ("png", "jpeg"):
|
|
self.parts.append(Part(section, doc, ref, rev, pdf=parts.image_page_pdf(path), count=1))
|
|
else:
|
|
raise PdfError("The file is not a PDF, PNG or JPEG.")
|
|
except Exception as e: # per-part failure: a warning row
|
|
self.warn(section, f"{doc} could not be read", type(e).__name__)
|
|
|
|
@staticmethod
|
|
def asset_of(url: Any) -> str | None:
|
|
s = str(url or "")
|
|
if not s.startswith("/_blob/"):
|
|
return None
|
|
return s.rsplit("/", 1)[-1] or None
|
|
|
|
def s5_documents(self) -> None:
|
|
p = self.p
|
|
folder = list(p.get("qc_folder_docs") or [])
|
|
folder.sort(key=lambda x: 0 if x.get("type") == "ITP" else 1)
|
|
for f in folder:
|
|
doc = f"{f.get('type')} — {f.get('title') or ''}".rstrip() + (
|
|
" (project)" if f.get("scope") == "project" else ""
|
|
)
|
|
rev = f"R{f['rev']}" if f.get("rev") else None
|
|
self.file_part(5, doc, self.asset_of(f.get("url")), None, rev)
|
|
for dd in p.get("docs") or []:
|
|
if dd.get("removed"):
|
|
continue
|
|
title = dd.get("title") or dd.get("file_name") or ""
|
|
doc = f"{dd.get('kind_label') or dd.get('kind')} — {title}".rstrip(" —")
|
|
if dd.get("note"):
|
|
doc += f" · {dd['note']}"
|
|
self.file_part(5, doc, dd.get("asset_id"))
|
|
if not any(f.get("type") == "ITP" for f in folder):
|
|
self.warn(5, "No signed ITP filed for this unit")
|
|
|
|
def s6_mtrs(self) -> None:
|
|
lines = [b for b in self.p.get("bom") or [] if b.get("mtr_asset_id")]
|
|
for b in lines:
|
|
doc = f"MTR — item {b.get('item')} {b.get('description') or ''}".rstrip()
|
|
self.file_part(6, doc, b.get("mtr_asset_id"), str(b.get("heat") or "").strip() or "—")
|
|
if not lines:
|
|
self.warn(6, "No MTRs attached in Packet Station — add from the warehouse files")
|
|
|
|
# ------------------------------------------------------------ section 7
|
|
|
|
def s7_history(self) -> None:
|
|
self.gen(7, "Packet record history", self.history)
|
|
|
|
def history(self) -> Doc:
|
|
p = self.p
|
|
d = Doc(("Record History", "Section 6 • Manufacturing data book"))
|
|
y = 128.0
|
|
d.tx(
|
|
"Every action taken on this packet in Packet Station, oldest first, with the person who took it.",
|
|
L,
|
|
y,
|
|
size=8.5,
|
|
color=GREY,
|
|
)
|
|
y += 18
|
|
cos = p.get("change_orders") or []
|
|
if cos:
|
|
d.lbl("Change orders", L, y)
|
|
y += 8
|
|
body = []
|
|
for co in cos:
|
|
verified = co.get("verified") or {}
|
|
req = co.get("required") or []
|
|
ver = "\n".join(
|
|
f"{self.dept_name(dp)}: "
|
|
+ (
|
|
f"{parts.name_of(self.users, verified[dp].get('by'))} {self.d(verified[dp].get('at'))}"
|
|
if dp in verified
|
|
else "PENDING"
|
|
)
|
|
for dp in req
|
|
)
|
|
body.append(
|
|
[
|
|
f"CO-{co.get('co_no')}" + (f"\n{co['ecn']}" if co.get("ecn") else ""),
|
|
f"{self.dt(co.get('issued_at'))}\n{parts.name_emp(self.users, co.get('issued_by'))}",
|
|
co_summary(co),
|
|
co.get("reason") or "",
|
|
ver or "No departments to verify",
|
|
]
|
|
)
|
|
y = d.table(
|
|
["CHANGE", "ISSUED", "DOCUMENTS", "REASON", "VERIFIED BY"],
|
|
body,
|
|
y,
|
|
{0: {"w": 56, "bold": True}, 1: {"w": 96}, 4: {"w": 130}},
|
|
size=7.4,
|
|
)
|
|
y += 16
|
|
y = d.ensure_space(y, 40)
|
|
d.lbl("Record history", L, y)
|
|
y += 8
|
|
body = []
|
|
for e in parts.log_rows(self.conn, int(p["id"])):
|
|
who = str(e.get("by_name") or "")
|
|
if who and e.get("by") is not None:
|
|
who += f" ({e.get('emp_no') or '?'})"
|
|
role = parts.role_text(e.get("role"))
|
|
body.append(
|
|
[self.dt(e.get("at")), e.get("action") or "", who + (f"\n{role}" if role else ""), e.get("note") or ""]
|
|
)
|
|
d.table(
|
|
["DATE / TIME", "EVENT", "BY", "DETAIL"],
|
|
body,
|
|
y,
|
|
{0: {"w": 92}, 1: {"w": 150, "bold": True}, 2: {"w": 96}},
|
|
size=7.6,
|
|
)
|
|
return d
|
|
|
|
# ------------------------------------------------------------ front matter
|
|
|
|
def last_stage(self) -> Mapping[str, Any]:
|
|
p = self.p
|
|
if p.get("stage"):
|
|
return p["stage"]
|
|
stages = p.get("stages") or []
|
|
return stages[-1] if stages else {}
|
|
|
|
def last_qc(self) -> Mapping[str, Any] | None:
|
|
sets = self.inspections()
|
|
return sets[-1] if sets else None
|
|
|
|
def front(self, total: int, size: str) -> tuple[bytes, int]:
|
|
del total
|
|
p = self.p
|
|
project = parts.project_of(p)
|
|
unit = p.get("unit") or {}
|
|
q = self.last_qc() or {}
|
|
st = self.last_stage()
|
|
bom = p.get("bom") or []
|
|
welds = p.get("welds") or []
|
|
d = Doc(None)
|
|
d.logo()
|
|
for i, s in enumerate(PESCO_ADDR):
|
|
d.tx(s, R, 44 + i * 10, size=7.3, color=GREY, align="right")
|
|
d.rule(96)
|
|
d.tx("MANUFACTURING", L, 150, size=33, bold=True, cs=4.2)
|
|
d.tx("DATA BOOK", L, 188, size=33, bold=True, cs=4.2)
|
|
d.lbl(f"{p.get('op') or 'Fabrication'} • welded fabrication record", L, 212, color=TEAL, size=7.5, cs=2)
|
|
d.rect(L, 228, CW, 96, fill=PANEL)
|
|
d.tx(p.get("title") or "", L + 14, 252, size=14, bold=True)
|
|
d.tx(
|
|
f"Customer {p.get('customer') or project.get('customer') or '—'} • Project {self.project} Lot {self.lot}"
|
|
f" • Drawing {p.get('drawing') or ''} R{p.get('rev') or ''} • WPS {p.get('wps') or '—'}",
|
|
L + 14,
|
|
268,
|
|
size=8.3,
|
|
color=GREY,
|
|
max_width=CW - 28,
|
|
)
|
|
d.hair(286, L + 14, R - 14)
|
|
d.lbl("Project / unit", L + 14, 309, color=TEAL, size=6.3)
|
|
d.tx(f"{self.project}-Lot {self.lot} {p.get('drawing') or ''}", L + 100, 310, size=16, bold=True, mono=True)
|
|
traced = sum(1 for b in bom if str(b.get("heat") or "").strip() or b.get("na"))
|
|
elapsed = sum(
|
|
int(((s or {}).get("clock") or {}).get("elapsed_ms") or 0)
|
|
for s in [*(p.get("stages") or []), p.get("stage")]
|
|
)
|
|
figures = [
|
|
(str(len(welds)), "Welds mapped"),
|
|
(f"{traced}/{len(bom)}", "BOM heats traced"),
|
|
(str(q.get("result") or "open").upper(), "Q.C. result"),
|
|
(fmt_clock(elapsed), "Weld time (h:mm)"),
|
|
]
|
|
kw = (CW - 3 * 16) / 4
|
|
for i, (n, label) in enumerate(figures):
|
|
x = L + i * (kw + 16)
|
|
d.line(x, 344, x + kw, 344, NAVY, 3)
|
|
d.tx(n, x, 368, size=15.5, bold=True)
|
|
d.lbl(label, x, 381, color=TEAL, size=6.1, cs=1.2)
|
|
d.lbl("Equipment & design data", L, 410, cs=2)
|
|
welder = st.get("welder") or p.get("welder") or {}
|
|
wname = parts.name_of(self.users, welder) if welder else ""
|
|
wstamp = welder.get("stamp") if isinstance(welder, Mapping) else None
|
|
filler = " • lot ".join(x for x in (st.get("filler"), st.get("filler_lot")) if x)
|
|
qc_line = (
|
|
f"{str(q['result']).upper()} — {q.get('inspector') or ''} {self.d(q.get('result_at'))}"
|
|
if q.get("result")
|
|
else "Not inspected"
|
|
)
|
|
rows: list[tuple[str, Any]] = [
|
|
("Project", f"{project.get('number') or ''}" + (f" - {project['name']}" if project.get("name") else "")),
|
|
("Unit", f"{unit.get('number') or ''}" + (f" - {unit['name']}" if unit.get("name") else "")),
|
|
("Lot", self.lot),
|
|
("Customer", p.get("customer") or project.get("customer")),
|
|
("Item", p.get("title")),
|
|
("Operation", p.get("op")),
|
|
("Governing drawing", f"{p.get('drawing') or ''} R{p.get('rev') or ''}"),
|
|
("Welding procedure", p.get("wps")),
|
|
("Welder", f"{wname}{' [' + str(wstamp) + ']' if wstamp else ''}"),
|
|
("Filler metal / lot", filler),
|
|
("Q.C. inspection", qc_line),
|
|
]
|
|
y = d.kv_table(rows, 416)
|
|
res = f" and recorded the result as {q['result']}" if q.get("result") else ""
|
|
wps = f" under WPS {p['wps']}" if p.get("wps") else ""
|
|
cert = (
|
|
f"CERTIFICATION. The items described above were fabricated by PESCO to drawing {p.get('drawing') or ''}"
|
|
f" Rev {p.get('rev') or ''}{wps}. Material heat numbers were recorded at issue and located on the heat map"
|
|
" (Section 2). Each weld was identified on the weld map and logged with the welder's stamp (Section 3)."
|
|
f" Q.C. examined the spool and this record{res} (Section 4). The records assembled in this data book are"
|
|
" true copies of the originals retained by PESCO."
|
|
)
|
|
y = max(y + 14, 590.0)
|
|
d.note_box(cert, y)
|
|
d.line(L, 716, 330, 716, NAVY, 0.7)
|
|
d.line(360, 716, R, 716, NAVY, 0.7)
|
|
d.lbl(f"Prepared by — {self.who.get('display_name') or 'PESCO'}, PESCO", L, 728, color=GREY, size=6.3)
|
|
d.lbl("Date", 360, 728, color=GREY, size=6.3)
|
|
|
|
# the index
|
|
d.set_header("Index", "Manufacturing data book")
|
|
d.add_page()
|
|
d.lbl("Table of contents", L, 124, cs=2)
|
|
for text, x, align in (
|
|
("DOCUMENT", L + 30, "left"),
|
|
("DWG. / REF. NO.", 440, "center"),
|
|
("REV.", 505, "center"),
|
|
("PAGE", R - 4, "right"),
|
|
):
|
|
d.tx(text, x, 140, size=6.2, bold=True, color=GREY, cs=1.0, align=align)
|
|
y = 146.0
|
|
|
|
def more(yy: float, need: float) -> float:
|
|
if yy + need > 738 + 19:
|
|
d.set_header("Index (continued)", "Manufacturing data book")
|
|
d.add_page()
|
|
return 124.0
|
|
return yy
|
|
|
|
for si, name in enumerate(SECTION_NAMES, start=1):
|
|
y = more(y, 17 + 19)
|
|
d.rect(L, y, CW, 17, fill=NAVY)
|
|
d.rect(L, y, 22, 17, fill=TEAL)
|
|
d.tx(str(si), L + 11, y + 11.5, size=7.5, bold=True, color=WHITE, align="center")
|
|
d.lbl(name, L + 30, y + 11.5, color=WHITE, size=6.8, cs=1.4)
|
|
y += 17
|
|
for pt in (x for x in self.parts if x.section == si):
|
|
lines = len(_split(pt.doc, 330))
|
|
rh = 19 + (lines - 1) * 9.5
|
|
y = more(y, rh)
|
|
d.tx(pt.doc, L + 30, y + 12.5, size=8.3, color=AMBER if pt.warn else NAVY, max_width=330)
|
|
d.tx(pt.ref or "—", 440, y + 12.5, size=8.3, align="center")
|
|
d.tx(pt.rev or "—", 505, y + 12.5, size=8.3, align="center")
|
|
d.tx(str(pt.page) if pt.page else "—", R - 4, y + 12.5, size=8.3, bold=True, align="right")
|
|
y += rh
|
|
d.hair(y)
|
|
y += 16
|
|
y = more(y, 30)
|
|
d.tx(
|
|
f"Drawings, the heat map and the weld map are reproduced at their original sheet size ({size}) and are"
|
|
" legible when printed full size or viewed on screen. Other sections are letter size. Shop QR codes and"
|
|
f" work order overlays are not included. Prepared {self.d(self.now)} from Packet Station records.",
|
|
L,
|
|
y,
|
|
size=7.5,
|
|
color=GREY,
|
|
max_width=CW,
|
|
)
|
|
pages = d.pages
|
|
return d.finish(), pages
|
|
|
|
# ------------------------------------------------------------ assembly
|
|
|
|
def stamp_text(self) -> tuple[str, str]:
|
|
seal_hash = str(self.seal.get("hash") or "") or None
|
|
mode = "print" if self.mode == "print" else ("filed" if seal_hash else "preview")
|
|
text = stamps.controlled_stamp(self.who, self.now, mode=mode, seal_hash_hex=seal_hash)
|
|
return stamps.signed_stamp(
|
|
self.settings,
|
|
text,
|
|
packet_id=self.p["id"],
|
|
kind="databook",
|
|
seal_hash_hex=seal_hash,
|
|
who=self.who,
|
|
now=self.now,
|
|
warnings=self.warnings,
|
|
)
|
|
|
|
def assemble(self, front: bytes, front_n: int, total: int) -> Built:
|
|
p = self.p
|
|
writer = PdfWriter()
|
|
own: set[int] = set()
|
|
voids: dict[int, str] = {}
|
|
fr = PdfReader(io.BytesIO(front))
|
|
for pg in fr.pages:
|
|
writer.add_page(pg)
|
|
own.add(len(writer.pages) - 1)
|
|
for pt in self.parts:
|
|
if not pt.count:
|
|
continue
|
|
if pt.reader is not None:
|
|
for i in pt.pages:
|
|
writer.add_page(pt.reader.pages[i])
|
|
if pt.void:
|
|
voids[len(writer.pages) - 1] = pt.void
|
|
elif pt.pdf is not None:
|
|
for pg in PdfReader(io.BytesIO(pt.pdf)).pages:
|
|
writer.add_page(pg)
|
|
if pt.own:
|
|
own.add(len(writer.pages) - 1)
|
|
n = len(writer.pages)
|
|
if n != total:
|
|
raise PdfError(f"The page count came out as {n}, not {total}.")
|
|
stamp, sig = self.stamp_text()
|
|
tag = f"Project {self.project} Lot {self.lot}"
|
|
|
|
def draw(c: Any, i: int, w: float, h: float) -> None:
|
|
if i in voids:
|
|
stamps.draw_void(c, w, h, voids[i])
|
|
if i in own:
|
|
text = f"{tag} - Page {i + 1} of {n}"
|
|
c.setFont("Helvetica", 7)
|
|
c.setFillColorRGB(GREY[0] / 255, GREY[1] / 255, GREY[2] / 255)
|
|
c.drawRightString(564, 20, clean(text))
|
|
stamps.draw_line_stamp(c, w, stamp, own=i in own)
|
|
|
|
stamps.overlay_writer(writer, draw)
|
|
seal_hash = str(self.seal.get("hash") or "")
|
|
meta = stamps.metadata(
|
|
f"{self.project} Lot {self.lot} Manufacturing Data Book - {p.get('title') or p.get('drawing') or ''}",
|
|
f"Manufacturing data book, drawing {p.get('drawing') or ''} R{p.get('rev') or ''}",
|
|
", ".join(
|
|
x
|
|
for x in (
|
|
self.project,
|
|
f"Lot {self.lot}",
|
|
str(p.get("drawing") or ""),
|
|
str(p.get("customer") or ""),
|
|
"data book",
|
|
f"stamp {sig}" if sig else "",
|
|
)
|
|
if x
|
|
),
|
|
f"Packet Station - controlled copy exported by {self.who.get('emp_no') or '?'}"
|
|
+ (f" seal {seal_hash}" if seal_hash else ""),
|
|
now=self.now,
|
|
)
|
|
data = stamps.write(writer, meta)
|
|
index = [pt.row() for pt in self.parts]
|
|
del front_n
|
|
return Built(
|
|
bytes=data, pages=n, index=index, warnings=list(self.warnings), stamp=stamp, stamp_sig=sig, what="Data book"
|
|
)
|
|
|
|
|
|
def _split(text: str, width: float) -> list[str]:
|
|
from .doc import split_lines
|
|
|
|
return split_lines(text, "Helvetica", 8.3, width)
|
|
|
|
|
|
__all__ = ["MODES", "SECTION_NAMES", "Book", "build", "co_summary", "heats_for"]
|