Files
PacketStation/server/packetstation/pdf/pmreport.py
T
spencermandClaude Opus 5.5 70a97398f1
ci / server (pull_request) Successful in 12m48s
ci / frontend (pull_request) Successful in 54s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 4m38s
Build Seth's changes C1 to C11 (Gitea issues 1, 8, 18, 23, 24, 25, 27, 31, 32, 33, 36)
- C1 (issue 1): over target follows VISUAL ticket hours, not the timer.
- C2 (issue 24): a Q.C. reject always needs a reason; accept needs an NCR number after a reject without one.
- C3 (issue 25): an incomplete unit ITP blocks filing.
- C4 (issue 27): prints open the print dialog on the page, no new tab.
- C5 (issue 31): filing is blocked while VISUAL shows a work order open; the feed sets vclosed on VISUAL packets.
- C6 (issue 8): the data book leaves out the history pages.
- C7 (issue 18): the supervisor approves each received short; accept and filing wait for it.
- C8 (issue 23): per-department checklists, edited by the department supervisor or Admin, frozen at START.
- C9 (issue 32): Q.C. and Clerical may run the stamp check.
- C10 (issue 33): the PM screen and reports are for PS-ProjectMgmt and Admin only.
- C11 (issue 36): the Stall screen lists the packets assigned to the welder, at the supervisor's stall.

Migration 0011. Docs: PLAN (also issue 9 confirmed, issue 4 asked), POLICY, SECURITY, SCHEMA, FEED, README, CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 16:36:49 -06:00

445 lines
17 KiB
Python

"""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",
"VISUAL ticket hours against work-order target hours decide over target. The station clock is shown too.",
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)