Phase 8: Project Management, find bar, presence and motion
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]>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
"""PDF output: the data book, the job traveler, the ITP, the printed copies, their stamps and metadata.
|
||||
"""PDF output: the data book, the job traveler, the ITP, the printed copies, the PM production status report
|
||||
(pmreport), their stamps and metadata.
|
||||
|
||||
Pure builders: they read through the connection they are given and the stored asset files, and return bytes. They
|
||||
never write to the database and never log a print; the endpoints and the filing pipeline do that.
|
||||
|
||||
@@ -45,10 +45,12 @@ from .style import (
|
||||
R,
|
||||
WhoRef,
|
||||
clean,
|
||||
fmt_clock,
|
||||
fmt_clock_hhmm,
|
||||
fmt_clock_hms,
|
||||
fmt_d,
|
||||
fmt_dt,
|
||||
fmt_num,
|
||||
round_half_up,
|
||||
site_tz,
|
||||
utc_now,
|
||||
)
|
||||
@@ -164,7 +166,7 @@ def co_summary(co: Mapping[str, Any]) -> str:
|
||||
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 str(co.get("reason") or "") or "documents changed"
|
||||
return ", ".join(lines) or "documents changed" # the old coSummary(): the reason has its own column
|
||||
|
||||
|
||||
class Book:
|
||||
@@ -268,7 +270,7 @@ class Book:
|
||||
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(w / 72)}" x {round(h / 72)}"'
|
||||
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
|
||||
@@ -428,6 +430,10 @@ class Book:
|
||||
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(
|
||||
{
|
||||
@@ -483,8 +489,8 @@ class Book:
|
||||
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(clock.get("elapsed_ms"))
|
||||
if rec.get("current"):
|
||||
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),
|
||||
@@ -869,7 +875,7 @@ class Book:
|
||||
(str(len(welds)), "Welds mapped"),
|
||||
(f"{traced}/{len(bom)}", "BOM heats traced"),
|
||||
(str(q.get("result") or "open").upper(), "Q.C. result"),
|
||||
(fmt_clock(elapsed), "Weld time (h:mm)"),
|
||||
(fmt_clock_hhmm(elapsed), "Weld time (h:mm)"),
|
||||
]
|
||||
kw = (CW - 3 * 16) / 4
|
||||
for i, (n, label) in enumerate(figures):
|
||||
|
||||
@@ -31,6 +31,11 @@ SPACE_LIMIT = 740.0
|
||||
LINE_FACTOR = 1.15
|
||||
PAD_V = 4.2
|
||||
PAD_H = 4.0
|
||||
# The old tables wrapped cells with jsPDF's splitTextToSize. autotable gives it the cell width less the padding plus
|
||||
# 1 pt, and jsPDF's Helvetica widths run about 0.8 % narrower than the AFM widths reportlab measures with. Both are
|
||||
# kept here so a cell breaks where the old book broke it ("10/18/2026 11:32 AM" stays on one line in 86 pt).
|
||||
WRAP_SLACK = 1.0
|
||||
JSPDF_WIDTH = 0.992
|
||||
|
||||
|
||||
def rgb(c: Sequence[int]) -> Color:
|
||||
@@ -348,7 +353,8 @@ class Doc:
|
||||
) -> tuple[list[str], str, float]:
|
||||
font = self._font(cell, spec)
|
||||
sz = float(cell.get("size", spec.get("size", size)))
|
||||
return split_lines(cell.get("content"), font, sz, width - 2 * PAD_H), font, sz
|
||||
room = (width - 2 * PAD_H + WRAP_SLACK) / JSPDF_WIDTH
|
||||
return split_lines(cell.get("content"), font, sz, room), font, sz
|
||||
|
||||
def _row_height(
|
||||
self, row: Sequence[Any], widths: Sequence[float], cols: Mapping[int, Mapping[str, Any]], size: float
|
||||
@@ -372,9 +378,7 @@ class Doc:
|
||||
) -> None:
|
||||
c = self.c
|
||||
for col, span, cell in self._cells(row):
|
||||
spec = {} if head else cols.get(col, {})
|
||||
if head and col in cols and cols[col].get("align"):
|
||||
spec = {"align": cols[col]["align"]}
|
||||
spec = {} if head else cols.get(col, {}) # autotable column styles style the body only: heads sit left
|
||||
x = L + sum(widths[:col])
|
||||
w = sum(widths[col : col + span])
|
||||
if cell.get("fill") is not None:
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
"""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)
|
||||
@@ -35,7 +35,10 @@ Draw = Callable[[Canvas, int, float, float], None]
|
||||
|
||||
def controlled_stamp(who: WhoRef | None, now: datetime | None, *, mode: str, seal_hash_hex: str | None = None) -> str:
|
||||
"""The controlled-copy line. mode "filed" names the seal (first 12 hex), "preview" says not yet filed, "print"
|
||||
says printed by (and the seal or not yet filed), "traveler" carries no seal part."""
|
||||
says printed by (and the seal or not yet filed), "traveler" carries no seal part, "report" (the PM production
|
||||
status report) names Packet Station and carries no seal part."""
|
||||
if mode == "report":
|
||||
return ascii_only(f"Controlled copy - Packet Station - printed by {who_text(who)} {utc_stamp(now)}")
|
||||
verb = "printed" if mode == "print" else "exported"
|
||||
text = f"Controlled copy - {verb} by {who_text(who)} {utc_stamp(now)}"
|
||||
if mode != "traveler":
|
||||
|
||||
@@ -5,6 +5,7 @@ The colours, the page frame and the address lines are the old databook.js values
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, date, datetime
|
||||
@@ -148,6 +149,31 @@ def fmt_clock(ms: Any) -> str:
|
||||
return f"{t // 3600}:{t % 3600 // 60:02d}"
|
||||
|
||||
|
||||
def _hms(ms: Any) -> tuple[int, int, int]:
|
||||
try:
|
||||
t = max(0, int(ms or 0) // 1000)
|
||||
except (TypeError, ValueError):
|
||||
t = 0
|
||||
return t // 3600, t % 3600 // 60, t % 60
|
||||
|
||||
|
||||
def fmt_clock_hms(ms: Any) -> str:
|
||||
"""Elapsed milliseconds as the old fmtClock(): HH:MM:SS, zero padded (the weld record's time recorded)."""
|
||||
h, m, s = _hms(ms)
|
||||
return f"{h:02d}:{m:02d}:{s:02d}"
|
||||
|
||||
|
||||
def fmt_clock_hhmm(ms: Any) -> str:
|
||||
"""Elapsed milliseconds as HH:MM, zero padded (the cover's weld time, the old fmtClock().slice(0,5))."""
|
||||
h, m, _s = _hms(ms)
|
||||
return f"{h:02d}:{m:02d}"
|
||||
|
||||
|
||||
def round_half_up(x: float) -> int:
|
||||
"""JavaScript's Math.round for positive numbers: 8.5 gives 9 (Python's round gives 8)."""
|
||||
return math.floor(x + 0.5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- dates
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user