2026-10-17 23:49:14 -06:00
|
|
|
"""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,
|
2026-10-18 15:08:34 -06:00
|
|
|
fmt_clock_hhmm,
|
|
|
|
|
fmt_clock_hms,
|
2026-10-17 23:49:14 -06:00
|
|
|
fmt_d,
|
|
|
|
|
fmt_dt,
|
|
|
|
|
fmt_num,
|
2026-10-18 15:08:34 -06:00
|
|
|
round_half_up,
|
2026-10-17 23:49:14 -06:00
|
|
|
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")
|
2026-10-18 15:08:34 -06:00
|
|
|
return ", ".join(lines) or "documents changed" # the old coSummary(): the reason has its own column
|
2026-10-17 23:49:14 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-10-18 15:08:34 -06:00
|
|
|
return f'{round_half_up(w / 72)}" x {round_half_up(h / 72)}"'
|
2026-10-17 23:49:14 -06:00
|
|
|
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))
|
2026-10-18 15:08:34 -06:00
|
|
|
elif recs:
|
|
|
|
|
# Q.C. accept closes the stage here; the old build kept it as the packet's live record, which shows
|
|
|
|
|
# the work order target beside the time. The last closed stage plays that part.
|
|
|
|
|
recs[-1]["last"] = True
|
2026-10-17 23:49:14 -06:00
|
|
|
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 {}
|
2026-10-18 15:08:34 -06:00
|
|
|
time = fmt_clock_hms(clock.get("elapsed_ms"))
|
|
|
|
|
if rec.get("current") or rec.get("last"):
|
2026-10-17 23:49:14 -06:00
|
|
|
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"),
|
2026-10-18 15:08:34 -06:00
|
|
|
(fmt_clock_hhmm(elapsed), "Weld time (h:mm)"),
|
2026-10-17 23:49:14 -06:00
|
|
|
]
|
|
|
|
|
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"]
|