"""The manufacturing data book, page for page after the old databook.js. Sections, in order: 1 Drawings (with the change order record when the packet has change orders), 2 Material traceability, 3 Welding record, 4 Q.C. inspection, 5 Q.C. and test documents (the packet's documents, NCRs included), 6 Material test reports. 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. The packet history pages are left out (change C6, Gitea issue 8): the book is the one copy and goes to the customer. History stays readable in Packet Station. 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_hhmm, fmt_clock_hms, fmt_d, fmt_dt, fmt_num, round_half_up, 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", ) @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 "documents changed" # the old coSummary(): the reason has its own column 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() 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_half_up(w / 72)}" x {round_half_up(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), ) if p.get("change_orders"): self.gen(1, "Change order record", self.change_orders) # ------------------------------------------------------------ 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)) 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 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_hms(clock.get("elapsed_ms")) if rec.get("current") or rec.get("last"): 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) # change C8: the stage's checklist frozen at START, else its department's live list templates = checklists.stage_templates(self.conn, rec.get("id"), str(code)) items = checklists.checklist_for(weld, p.get("drawing"), p.get("rev"), p.get("wps"), templates) 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") # ------------------------------------------------------------ change orders (section 1) def change_orders(self) -> Doc: """The change orders on this packet: who issued each, what it changed and who verified it. The packet's history pages are not in the book (change C6); they stay readable in Packet Station.""" p = self.p d = Doc(("Change Order Record", "Section 1 • Manufacturing data book")) y = 128.0 d.tx( "The change orders issued on this packet, with the departments that verified each one.", L, y, size=8.5, color=GREY, ) y += 18 body = [] for co in p.get("change_orders") or []: 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", ] ) d.table( ["CHANGE", "ISSUED", "DOCUMENTS", "REASON", "VERIFIED BY"], body, y, {0: {"w": 56, "bold": True}, 1: {"w": 96}, 4: {"w": 130}}, size=7.4, ) 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_hhmm(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"]