The Project Management screen (/pm, view only) with Overview, Projects & units, Bottlenecks & aging, Hours vs target, Quality & issues and Find jobs, the period picker, health per unit and per project, drill down, Excel (CSV) per table and for all, and the PESCO production status report PDF with the controlled-copy stamp; every export logged as "report exported". The find bar on every home screen: the full search set, the dropdowns, quick flags with Mine, the flag kinds, Board and List with sorts, filters saved per browser; every filter applied by the server on the list and the boards, with facets. Presence in packets with role, lock and revoke drop; the motion switch with reduced motion honoured, card glides, glow and count-up; Back restores the scroll spot; sheet placement and the iPad layout pass. The data book compared page by page with the old build and six layout differences fixed. Tests for the PM aggregates, search, filters, presence and exports; Playwright scenarios for the PM screen, the find bar and presence at desktop and iPad viewports. Docs updated; PLAN.md questions 38 to 42. Co-Authored-By: Claude Fable 5.1 <[email protected]>
445 lines
17 KiB
Python
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",
|
|
"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)
|