2026-10-18 15:08:34 -06:00
|
|
|
"""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",
|
Build Seth's changes C1 to C11 (Gitea issues 1, 8, 18, 23, 24, 25, 27, 31, 32, 33, 36)
2026-10-30 13:33:51 -06:00
|
|
|
"VISUAL ticket hours against work-order target hours decide over target. The station clock is shown too.",
|
2026-10-18 15:08:34 -06:00
|
|
|
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)
|