"""The PESCO production status report: the old pmPdf, report format C (dark). Letter, portrait: a cover with the headline numbers, PART 01 SUMMARY (the plain-words lines and the pipeline bars), PART 02 PROJECTS (every unit), PART 03 FLOW (the aging tables), PART 04 LABOR (the hours tables) and PART 05 ISSUES (the quality tables). Every page carries the PESCO foot, CONFIDENTIAL & PROPRIETARY and its number, and the controlled-copy stamp. Everything comes from domain/pm.py; nothing here reads VISUAL or writes the database. Drawn on a reportlab Canvas directly. Every y here is measured from the top of the page, as jsPDF did. Text always goes through `clean`, and cells are wrapped with simpleSplit on plain strings, so no markup is interpreted. """ from __future__ import annotations import contextlib import io from collections.abc import Mapping, Sequence from dataclasses import dataclass from datetime import UTC, datetime from functools import lru_cache from pathlib import Path from typing import Any from reportlab.lib.colors import Color from reportlab.lib.utils import ImageReader, simpleSplit from reportlab.pdfbase.pdfmetrics import stringWidth from reportlab.pdfgen.canvas import Canvas from ..domain import pm from . import stamps from .style import WhoRef, clean, who_text W, H, M = 612.0, 792.0, 40.0 CW = W - 2 * M LOGO_PATH = Path(__file__).parent / "assets" / "logo_color.png" # The old pmPdf colour map. C: dict[str, tuple[int, int, int]] = { "bg": (13, 22, 27), "panel": (26, 37, 43), "alt": (22, 41, 49), "cyan": (73, 192, 222), "pink": (229, 88, 159), "amber": (231, 179, 60), "white": (255, 255, 255), "near": (239, 245, 248), "body": (169, 186, 195), "muted": (119, 135, 143), "sub": (191, 228, 240), "line": (38, 54, 62), "zebra": (17, 29, 35), } TABLE_TOP = 70.0 TABLE_BOTTOM = 40.0 BODY_SIZE = 7.2 HEAD_SIZE = 6.8 PAD = 3.2 LINE_FACTOR = 1.15 @dataclass(frozen=True) class Built: pdf: bytes pages: int stamp: str stamp_sig: str | None def rgb(c: Sequence[int]) -> Color: return Color(c[0] / 255, c[1] / 255, c[2] / 255) def font_of(bold: bool = False) -> str: return "Helvetica-Bold" if bold else "Helvetica" def split_lines(text: Any, font: str, size: float, width: float) -> list[str]: """The text wrapped to `width`, keeping its own line breaks. An empty text is one empty line.""" out: list[str] = [] for part in clean(text).split("\n"): out += simpleSplit(part, font, size, max(width, 1.0)) or [""] return out or [""] @lru_cache(maxsize=1) def _logo() -> ImageReader | None: try: return ImageReader(io.BytesIO(LOGO_PATH.read_bytes())) except OSError: return None class Pages: """The report canvas. Pages are numbered as they start; each gets the dark background and the foot.""" def __init__(self) -> None: self.buf = io.BytesIO() self.c = Canvas(self.buf, pagesize=(W, H), pageCompression=1) self.page = 1 self.bg() # ------------------------------------------------------------ primitives def Y(self, y: float) -> float: return H - y def fill(self, color: Sequence[int]) -> None: self.c.setFillColor(rgb(color)) def rect(self, x: float, y: float, w: float, h: float, color: Sequence[int], *, radius: float = 0) -> None: self.fill(color) if radius: self.c.roundRect(x, self.Y(y + h), w, h, radius, stroke=0, fill=1) else: self.c.rect(x, self.Y(y + h), w, h, stroke=0, fill=1) def text( self, s: Any, x: float, y: float, *, size: float = 9.5, bold: bool = False, color: Sequence[int] = C["body"], align: str = "left", max_width: float | None = None, cs: float = 0.0, ) -> int: """jsPDF's text(): `y` is the first baseline; with max_width the text wraps. Returns the line count.""" font = font_of(bold) lines = split_lines(s, font, size, max_width) if max_width else clean(s).split("\n") self.fill(color) for i, ln in enumerate(lines): yy = self.Y(y + i * size * LINE_FACTOR) width = stringWidth(ln, font, size) + cs * len(ln) xx = x - width if align == "right" else x - width / 2 if align == "center" else x t = self.c.beginText(xx, yy) t.setFont(font, size) t.setCharSpace(cs) t.textOut(ln) self.c.drawText(t) return len(lines) # ------------------------------------------------------------ pages def bg(self) -> None: self.rect(0, 0, W, H, C["bg"]) def foot(self) -> None: self.text("PESCO · PROCESS EQUIPMENT & SERVICE COMPANY, INC.", M, H - 22, size=7, color=C["muted"]) self.text("CONFIDENTIAL & PROPRIETARY", W / 2, H - 22, size=7, color=C["amber"], align="center", bold=True) self.text(str(self.page), W - M, H - 22, size=7, color=C["muted"], align="right") def new_page(self) -> None: self.c.showPage() self.page += 1 self.bg() self.foot() def body(self, eyebrow: str, title: str, stand: str) -> float: """A body page: the eyebrow, "Production status report", a rule, the title and the stand-first.""" self.new_page() self.text(eyebrow, M, 44, size=7.6, color=C["cyan"], bold=True, cs=1.2) self.text("Production status report", W - M, 44, size=7.6, color=C["muted"], align="right") self.c.setStrokeColor(rgb(C["line"])) self.c.setLineWidth(0.6) self.c.line(M, self.Y(52), W - M, self.Y(52)) self.text(title, M, 82, size=21, bold=True, color=C["near"]) if stand: self.text(stand, M, 100, size=9.5, color=C["body"], max_width=CW) return 118.0 if stand else 100.0 def finish(self) -> bytes: self.c.showPage() self.c.save() return self.buf.getvalue() # ------------------------------------------------------------ tables def _widths(self, head: Sequence[str], body: Sequence[Sequence[str]]) -> list[float]: n = len(head) natural = [2 * PAD + 10.0] * n minw = [2 * PAD + 10.0] * n rows: list[tuple[Sequence[str], str, float]] = [(head, font_of(True), HEAD_SIZE)] rows += [(r, font_of(), BODY_SIZE) for r in body] for cells, font, size in rows: for i in range(min(n, len(cells))): for part in clean(cells[i]).split("\n"): natural[i] = max(natural[i], stringWidth(part, font, size) + 2 * PAD) for word in part.split(): minw[i] = max(minw[i], min(stringWidth(word, font, size) + 2 * PAD, CW / 3)) total = sum(natural) if total <= CW: return [w * CW / total for w in natural] base = sum(minw) if base >= CW: return [w * CW / base for w in minw] extra = [natural[i] - minw[i] for i in range(n)] spare = sum(extra) or 1.0 return [minw[i] + (CW - base) * extra[i] / spare for i in range(n)] def _row_height(self, cells: Sequence[str], widths: Sequence[float], font: str, size: float) -> float: lines = max( len(split_lines(cells[i] if i < len(cells) else "", font, size, widths[i] - 2 * PAD)) for i in range(len(widths)) ) return lines * size * LINE_FACTOR + 2 * PAD def _draw_row( self, cells: Sequence[str], widths: Sequence[float], y: float, h: float, *, font: str, size: float, color: Sequence[int], fill: Sequence[int], ) -> None: x = M self.c.setStrokeColor(rgb(C["line"])) self.c.setLineWidth(0.4) for i, w in enumerate(widths): self.fill(fill) self.c.rect(x, self.Y(y + h), w, h, stroke=1, fill=1) lines = split_lines(cells[i] if i < len(cells) else "", font, size, w - 2 * PAD) self.fill(color) self.c.setFont(font, size) for j, ln in enumerate(lines): self.c.drawString(x + PAD, self.Y(y + PAD + j * size * LINE_FACTOR + size * 0.85), ln) x += w def table(self, t: Mapping[str, Any], start_y: float) -> float: """The old autoTable look: the head in cyan on the alt fill, zebra rows, flagged rows in pink, project rows bold. The head repeats on every page. Returns the y under the table.""" head = [str(h) for h in t["head"]] rows = list(t["rows"]) or [{"cells": ["Nothing in scope.", *[""] * (len(head) - 1)]}] body = [[str(c) for c in r["cells"]] for r in rows] widths = self._widths(head, body) limit = H - TABLE_BOTTOM is_projects = t.get("key") == "projects" head_h = self._row_height(head, widths, font_of(True), HEAD_SIZE) def draw_head(yy: float) -> float: self._draw_row(head, widths, yy, head_h, font=font_of(True), size=HEAD_SIZE, color=C["cyan"], fill=C["alt"]) return yy + head_h y = start_y first = self._row_height(body[0], widths, font_of(), BODY_SIZE) if y + head_h + first > limit: self.new_page() y = TABLE_TOP y = draw_head(y) for i, (r, cells) in enumerate(zip(rows, body, strict=True)): bold = is_projects and r.get("lvl") == 0 and r.get("project_id") is not None font = font_of(bold) h = self._row_height(cells, widths, font, BODY_SIZE) if y + h > limit: self.new_page() y = draw_head(TABLE_TOP) color = C["pink"] if r.get("flag") else C["near"] if bold else C["body"] fill = C["zebra"] if i % 2 == 1 else C["bg"] self._draw_row(cells, widths, y, h, font=font, size=BODY_SIZE, color=color, fill=fill) y += h return y def section(self, eyebrow: str, title: str, stand: str, tables: Sequence[Mapping[str, Any]]) -> None: y = self.body(eyebrow, title, stand) for t in tables: if y > H - 140: y = self.body(eyebrow, title + " (continued)", "") self.text(t["title"], M, y + 6, size=11, bold=True, color=C["near"]) if t.get("note"): self.text(t["note"], M, y + 20, size=7.8, color=C["muted"], max_width=CW) y = self.table(t, y + (30 if t.get("note") else 16)) + 22 # ---------------------------------------------------------------- the pages def _cover(r: Pages, rep: pm.Report, kpis: list[dict[str, Any]], who: WhoRef | None) -> None: for i in range(60): t = i / 60 r.rect(0, i * 4.4, W, 4.6, (round(15 + 10 * (1 - t)), round(48 + 20 * (1 - t)), round(58 + 22 * (1 - t)))) logo = _logo() if logo is not None: # A logo that does not draw is left out, as the old build did. with contextlib.suppress(Exception): r.c.drawImage(logo, M, r.Y(46 + 34), width=150, height=34, mask="auto", preserveAspectRatio=True) r.text("PRODUCTION STATUS REPORT", M, 150, size=30, bold=True, color=C["white"]) r.text(f"Packet Station · {rep.scope_label()}", M, 176, size=12, color=C["sub"], max_width=CW) for i in range(100): t = i / 100 col = (round(73 + (229 - 73) * t), round(192 + (88 - 192) * t), round(222 + (159 - 222) * t)) r.rect(M + i * CW / 100, 196, CW / 100 + 0.5, 3, col) cw = (CW - 3 * 10) / 4 for i, k in enumerate(kpis[:8]): cx = M + (i % 4) * (cw + 10) cy = 300 + (i // 4) * 96 hot = bool(k["alert"]) and str(k["n"]) != "0" r.rect(cx, cy, cw, 84, C["panel"], radius=6) r.rect(cx, cy, cw, 3, C["pink"] if hot else C["cyan"]) r.text(str(k["n"]), cx + 12, cy + 44, size=30, bold=True, color=C["white"]) r.text( str(k["label"]).upper(), cx + 12, cy + 68, size=7.2, bold=True, color=C["pink"] if hot else C["cyan"], max_width=cw - 20, ) meta = [ ("Prepared by", str(who["display_name"]) if who and who.get("display_name") else who_text(who)), ("Source", "Packet Station live data"), ("History period", rep.period_label), ("Issued", pm.fmt_dt(rep.now, rep.tz)), ] for i, (a, b) in enumerate(meta): x = M + i * (CW / 4) r.text(a.upper(), x, 560, size=7, color=C["muted"], bold=True, cs=0.6) r.text(b, x, 575, size=9.5, color=C["near"], max_width=CW / 4 - 8) r.text( "Live snapshot of the shop's job packets at the time of issue. Figures follow the scope shown above.", M, 610, size=8.5, color=C["muted"], max_width=CW, ) r.foot() def _summary(r: Pages, lines: list[str], pipe: list[dict[str, Any]]) -> None: y = r.body("PART 01 · SUMMARY", "Where things stand", "The short version first; the detail follows.") for ln in lines: r.fill(C["cyan"]) r.c.circle(M + 4, r.Y(y - 3), 2, stroke=0, fill=1) n = r.text(ln, M + 14, y, size=10, color=C["near"], max_width=CW - 14) y += n * 13 + 6 y += 10 top = max(1, *(s["count"] for s in pipe)) slow = next((s for s in pipe if s["slowest"]), None) r.rect(M, y, CW, 34 + len(pipe) * 22, C["alt"], radius=6) r.text("Open packets by step", M + 14, y + 20, size=10.5, bold=True, color=C["white"]) r.text( "bar = packets in the step · label = average time there", W - M - 14, y + 20, size=7.5, color=C["muted"], align="right", ) y += 36 track = CW - 190 - 110 for s in pipe: is_slow = s is slow r.text(s["label"], M + 14, y + 9, size=8.2, color=C["body"]) bx = M + 190 r.rect(bx, y + 1, track, 10, C["line"]) if s["count"]: r.rect(bx, y + 1, max(2.0, track * s["count"] / top), 10, C["pink"] if is_slow else C["cyan"]) label = f"{s['count']}" + (f" · avg {pm.fmt_days(s['avg_s'])}" if s["count"] else "") label += " · slowest" if is_slow else "" r.text(label, W - M - 104, y + 9, size=8, color=C["pink"] if is_slow else C["near"], bold=is_slow) y += 22 y += 12 r.rect(M, y, CW, 40, C["panel"]) r.rect(M, y, 3, 40, C["cyan"]) r.text("What this says:", M + 12, y + 16, size=9.5, bold=True, color=C["white"]) says = ( f'"{slow["label"]}" is where packets wait longest right now' f" (average {pm.fmt_days(slow['avg_s'])}). Start there." if slow else "Nothing is waiting in any step." ) r.text(says, M + 12, y + 30, size=9, color=C["body"], max_width=CW - 24) def build( conn: Any, settings: Any, *, scope_params: Mapping[str, Any], who: WhoRef | None, now: datetime | None = None, report: pm.Report | None = None, ) -> Built: """The report as PDF bytes, its page count and the stamp. `scope_params` are the find bar values plus project_id, unit_id and period; a `report` already read for the same scope is used as it is.""" now = now or datetime.now(UTC) rep = report if rep is None: rep = pm.load( conn, None, scope_params, now=now, tz=str(getattr(settings, "site_tz", None) or "America/Denver"), period=scope_params.get("period"), ) pipe = pm.pipeline(rep) kpis = pm.kpis(rep) lines = pm.summary(rep, kpis, pipe) r = Pages() _cover(r, rep, kpis, who) _summary(r, lines, pipe) r.section( "PART 02 · PROJECTS", "Projects and units", "Every unit's packets by step, how much is done, and whether it's on track.", [pm.projects_table(rep, expand_all=True)], ) r.section( "PART 03 · FLOW", "Bottlenecks and aging", "How long packets sit in each step, oldest first.", pm.aging_tables(rep, pipe), ) r.section( "PART 04 · LABOR", "Hours vs target", "Station clock hours and VISUAL ticket hours against work-order target hours.", pm.hours_tables(rep), ) r.section( "PART 05 · ISSUES", "Quality and issues", "Q.C. rejects, shorts, change orders, holds and send-backs.", pm.quality_tables(rep), ) raw = r.finish() pages = r.page text = stamps.controlled_stamp(who, now, mode="report") line, sig = stamps.signed_stamp(settings, text, packet_id=None, kind="report", seal_hash_hex=None, who=who, now=now) out = stamps.apply_stamp(raw, line) out = stamps.set_metadata( out, "Production status report", rep.scope_label(), "Packet Station report", "Packet Station", now=now ) return Built(pdf=out, pages=pages, stamp=line, stamp_sig=sig or None)