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]>
1333 lines
46 KiB
Python
1333 lines
46 KiB
Python
"""Project Management: see everything, change nothing, report on it. A port of the old pm.js.
|
|
|
|
Every report is built from one card list (list_cards over the find bar's scope, sorted by project) plus a few
|
|
scoped reads: the stage clocks, the work-order target and ticket hours, the transitions, Q.C. history, the open
|
|
shorts and change orders. Nothing here reads VISUAL; the ticket hours come from the visual_labor mirror the feed
|
|
keeps. Nothing here writes, except the export log the API adds.
|
|
|
|
Shapes:
|
|
scope = {label, count, period: {value, label, start}, project: {id, number, name} | None, unit: {id, number} | None}
|
|
table = {key, title, note, head, rows}
|
|
row = {cells, pid, project_id, unit_id, lvl, health, flag}
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from collections.abc import Iterable, Mapping, Sequence
|
|
from dataclasses import dataclass, field
|
|
from datetime import UTC, date, datetime, timedelta
|
|
from typing import Any
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
|
|
from .. import policy
|
|
from . import packets, search
|
|
|
|
DAY_S = 86400.0
|
|
CARD_LIMIT = 20000
|
|
|
|
PERIODS: dict[str, tuple[str, int | None]] = {
|
|
"30": ("Last 30 days", 30),
|
|
"90": ("Last 90 days", 90),
|
|
"365": ("Last 12 months", 365),
|
|
"all": ("All time", None),
|
|
}
|
|
DEFAULT_PERIOD = "90"
|
|
|
|
PM_STEPS: tuple[tuple[str, str], ...] = (
|
|
("draft", "Ready to release (Clerical)"),
|
|
("wh_incoming", "Warehouse: waiting"),
|
|
("wh_pulling", "Warehouse: being pulled"),
|
|
("wh_hold", "Warehouse: on hold"),
|
|
("wh_pulled", "Warehouse: pulled, ready for production"),
|
|
("ready", "Ready to assign"),
|
|
("station", "At a station"),
|
|
("review", "Supervisor review"),
|
|
("qc", "At Q.C."),
|
|
("complete", "Back for data book"),
|
|
)
|
|
PM_STEP_N = dict(PM_STEPS)
|
|
|
|
HEALTH: dict[str, tuple[str, str]] = {
|
|
"ok": ("On track", "st-in_progress"),
|
|
"risk": ("At risk", "st-warehouse"),
|
|
"late": ("Late", "st-review"),
|
|
"done": ("Complete", "st-filed"),
|
|
"empty": ("No packets yet", "st-draft"),
|
|
}
|
|
SEVERITY = {"late": 4, "risk": 3, "ok": 2, "done": 1, "empty": 0}
|
|
|
|
SENT_BACK = ("return_to_clerical", "wh_send_back", "send_back_to_warehouse", "return_to_station", "qc_reject")
|
|
HOURS_STATUSES = ("review", "qc", "complete", "filed")
|
|
MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
|
DASH = "—"
|
|
|
|
# Table keys, in the order of the "all" export.
|
|
TABLE_KEYS = (
|
|
"projects",
|
|
"aging_steps",
|
|
"aging_open",
|
|
"aging_history",
|
|
"hours_dept",
|
|
"hours_worker",
|
|
"hours_project",
|
|
"hours_over",
|
|
"quality_rejects",
|
|
"quality_shorts",
|
|
"quality_cos",
|
|
"quality_holds",
|
|
"quality_sentback",
|
|
"jobs",
|
|
)
|
|
|
|
NOTE_PROJECTS = (
|
|
"Tap a project to show its units. Done = accepted by Q.C. or filed. At risk = due within 7 days and under 75%"
|
|
" done, or a hold or open change order."
|
|
)
|
|
NOTE_AGING_STEPS = "How long packets have been sitting where they are right now."
|
|
NOTE_AGING_OPEN = "Over 3 days in one step is flagged."
|
|
NOTE_AGING_HISTORY = "From the step history recorded on every move."
|
|
NOTE_HOURS_OVER = (
|
|
"Station timer hours and VISUAL ticket hours against the work-order target hours. The timer is the clock of record."
|
|
)
|
|
|
|
PROJECTS_HEAD = [
|
|
"Project / unit",
|
|
"Name",
|
|
"Customer",
|
|
"Due",
|
|
"Left",
|
|
"Units",
|
|
"Packets",
|
|
"Draft",
|
|
"Warehouse",
|
|
"Shop",
|
|
"Q.C.",
|
|
"Done",
|
|
"% done",
|
|
"Health",
|
|
]
|
|
ATTENTION_HEAD = ["Unit", "Description", "Due", "Left", "Packets", "% done", "Health"]
|
|
HOURS_HEAD = ["Packets", "Timer h", "Ticket h", "Target h", "Variance h", "Timer / target", "Over target"]
|
|
JOBS_HEAD = [
|
|
"Job",
|
|
"Project",
|
|
"Unit",
|
|
"Operation",
|
|
"Title",
|
|
"Department",
|
|
"Step",
|
|
"With",
|
|
"Station",
|
|
"Worker",
|
|
"Drawing",
|
|
"Rev",
|
|
"Unit due",
|
|
"Days in step",
|
|
"Timer h",
|
|
"Ticket h",
|
|
"Target h",
|
|
"Created",
|
|
"Updated",
|
|
"Change orders open",
|
|
"Shorts open",
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------- small texts
|
|
|
|
|
|
def half_up(x: float) -> int:
|
|
"""Math.round: halves go up."""
|
|
return int(x + 0.5) if x >= 0 else -int(-x + 0.5)
|
|
|
|
|
|
def fmt_days(seconds: float) -> str:
|
|
"""The old fmtDays: under a day in whole hours, then days with one decimal under 10 and whole above."""
|
|
d = seconds / DAY_S
|
|
if d < 1:
|
|
return f"{max(0, half_up(seconds / 3600))} h"
|
|
return f"{d:.1f} d" if d < 10 else f"{half_up(d)} d"
|
|
|
|
|
|
def fmt_h(hours: float) -> str:
|
|
"""Hours with one decimal (the old fmtH)."""
|
|
v = round(hours, 1)
|
|
return f"{0.0 if v == 0 else v:.1f}"
|
|
|
|
|
|
def signed_h(hours: float) -> str:
|
|
v = round(hours, 1)
|
|
return ("+" if v >= 0 else "") + fmt_h(v)
|
|
|
|
|
|
def day_str(d: Any) -> str:
|
|
"""Oct 20 '26 (the old dayStr); empty for no date."""
|
|
if not isinstance(d, date):
|
|
return ""
|
|
return f"{MONTHS[d.month - 1]} {d.day} '{str(d.year)[2:]}"
|
|
|
|
|
|
def left_text(days_left: int | None) -> str:
|
|
if days_left is None:
|
|
return DASH
|
|
return f"{-days_left} d over" if days_left < 0 else f"{days_left} d"
|
|
|
|
|
|
def due_text(due: Any, today: date, closed: bool = False) -> str:
|
|
"""The due label without "Due ": "Oct 20 '26 · 3 d left", "· 2 d overdue", "· today"."""
|
|
if not isinstance(due, date):
|
|
return DASH
|
|
lab = day_str(due)
|
|
if closed:
|
|
return lab
|
|
days = (due - today).days
|
|
if days < 0:
|
|
return f"{lab} · {-days} d overdue"
|
|
if days <= 7:
|
|
return f"{lab} · {'today' if days == 0 else f'{days} d left'}"
|
|
return f"{lab} · {days} d left"
|
|
|
|
|
|
def plural(n: int, word: str) -> str:
|
|
return f"{n} {word}{'' if n == 1 else 's'}"
|
|
|
|
|
|
def job_name(c: Mapping[str, Any]) -> str:
|
|
unit = (c.get("unit") or {}).get("number") or ""
|
|
return f"{unit} · {c['op']}" if c.get("op") else str(unit)
|
|
|
|
|
|
def zone(tz: str | None) -> Any:
|
|
try:
|
|
return ZoneInfo(tz or "America/Denver")
|
|
except (ZoneInfoNotFoundError, ValueError):
|
|
return UTC
|
|
|
|
|
|
def as_dt(value: Any) -> datetime | None:
|
|
if isinstance(value, datetime):
|
|
return value if value.tzinfo else value.replace(tzinfo=UTC)
|
|
if isinstance(value, str) and value.strip():
|
|
try:
|
|
d = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
|
except ValueError:
|
|
return None
|
|
return d if d.tzinfo else d.replace(tzinfo=UTC)
|
|
return None
|
|
|
|
|
|
def fmt_dt(value: Any, tz: str | None) -> str:
|
|
"""M/D/YYYY h:mm AM in the site time zone; a dash for no value."""
|
|
d = as_dt(value)
|
|
if d is None:
|
|
return DASH
|
|
d = d.astimezone(zone(tz))
|
|
h = d.hour % 12 or 12
|
|
return f"{d.month}/{d.day}/{d.year} {h}:{d.minute:02d} {'AM' if d.hour < 12 else 'PM'}"
|
|
|
|
|
|
def fmt_d(value: Any, tz: str | None) -> str:
|
|
d = as_dt(value)
|
|
if d is None:
|
|
return value.isoformat() if isinstance(value, date) else ""
|
|
d = d.astimezone(zone(tz))
|
|
return f"{d.month}/{d.day}/{d.year}"
|
|
|
|
|
|
def row(
|
|
cells: Sequence[Any],
|
|
*,
|
|
pid: int | None = None,
|
|
project_id: int | None = None,
|
|
unit_id: int | None = None,
|
|
lvl: int = 0,
|
|
health: str | None = None,
|
|
flag: bool = False,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"cells": ["" if c is None else str(c) for c in cells],
|
|
"pid": pid,
|
|
"project_id": project_id,
|
|
"unit_id": unit_id,
|
|
"lvl": lvl,
|
|
"health": health,
|
|
"flag": flag,
|
|
}
|
|
|
|
|
|
def table(key: str, title: str, head: Sequence[str], rows: list[dict[str, Any]], note: str | None = None) -> dict:
|
|
return {"key": key, "title": title, "note": note, "head": list(head), "rows": rows}
|
|
|
|
|
|
# ---------------------------------------------------------------- facts about one packet
|
|
|
|
|
|
def step_of(c: Mapping[str, Any]) -> str:
|
|
st = str(c["status"])
|
|
if st == "warehouse":
|
|
return "wh_" + str(c.get("wh_stage") or "incoming")
|
|
if st in policy.ACTIVE:
|
|
return "station"
|
|
return st
|
|
|
|
|
|
def step_since(c: Mapping[str, Any]) -> datetime:
|
|
wh = c.get("wh") or {}
|
|
if c["status"] == "warehouse" and wh.get("at") is not None:
|
|
return as_dt(wh["at"]) or datetime.now(UTC)
|
|
return as_dt(c.get("status_at")) or as_dt(c.get("created_at")) or datetime.now(UTC)
|
|
|
|
|
|
def step_label(c: Mapping[str, Any]) -> str:
|
|
if c["status"] == "filed":
|
|
return "Filed"
|
|
return PM_STEP_N.get(step_of(c), str(c["status"]))
|
|
|
|
|
|
def on_hold(c: Mapping[str, Any]) -> bool:
|
|
return c["status"] == "warehouse" and c.get("wh_stage") == "hold"
|
|
|
|
|
|
def risky(c: Mapping[str, Any]) -> bool:
|
|
return bool(c.get("open_cos")) or on_hold(c)
|
|
|
|
|
|
def health_of(cards: Sequence[Mapping[str, Any]], due: date | None, today: date) -> dict[str, Any]:
|
|
"""The old healthOf: n, filed, done, pct, days_left, health, due."""
|
|
n = len(cards)
|
|
filed = sum(1 for c in cards if c["status"] == "filed")
|
|
done = sum(1 for c in cards if c["status"] in ("complete", "filed"))
|
|
days_left = (due - today).days if isinstance(due, date) else None
|
|
if not n:
|
|
h = "empty"
|
|
elif filed == n:
|
|
h = "done"
|
|
elif days_left is not None and days_left < 0:
|
|
h = "late"
|
|
elif (days_left is not None and days_left <= 7 and done / n < 0.75) or any(risky(c) for c in cards):
|
|
h = "risk"
|
|
else:
|
|
h = "ok"
|
|
return {
|
|
"n": n,
|
|
"filed": filed,
|
|
"done": done,
|
|
"pct": half_up(done / n * 100) if n else 0,
|
|
"days_left": days_left,
|
|
"health": h,
|
|
"due": due,
|
|
}
|
|
|
|
|
|
def stage_buckets(cards: Iterable[Mapping[str, Any]]) -> dict[str, int]:
|
|
b = {"draft": 0, "wh": 0, "shop": 0, "qc": 0, "done": 0}
|
|
for c in cards:
|
|
s = c["status"]
|
|
if s == "draft":
|
|
b["draft"] += 1
|
|
elif s == "warehouse":
|
|
b["wh"] += 1
|
|
elif s in policy.SHOP:
|
|
b["shop"] += 1
|
|
elif s == "qc":
|
|
b["qc"] += 1
|
|
else:
|
|
b["done"] += 1
|
|
return b
|
|
|
|
|
|
# ---------------------------------------------------------------- the report context
|
|
|
|
|
|
HOURS_SQL = """
|
|
SELECT p.id,
|
|
coalesce((SELECT sum(s.clock_elapsed_ms
|
|
+ CASE WHEN s.clock_running_since IS NOT NULL
|
|
THEN greatest(0, extract(epoch FROM (%(now)s - s.clock_running_since)) * 1000)
|
|
ELSE 0 END)
|
|
FROM packet_stages s WHERE s.packet_id = p.id), 0) AS timer_ms,
|
|
coalesce((SELECT sum(w.target_hrs) FROM packet_work_orders w WHERE w.packet_id = p.id), 0) AS target_hrs,
|
|
CASE WHEN p.source_kind = 'visual' THEN
|
|
(SELECT sum(l.hours) FROM packet_work_orders w
|
|
JOIN visual_labor l ON l.wo_base = w.wo_base AND l.lot = w.lot AND l.split = w.split
|
|
AND l.sub = w.sub AND l.seq = w.seq
|
|
WHERE w.packet_id = p.id)
|
|
END AS ticket_hrs
|
|
FROM packets p WHERE p.id = ANY(%(ids)s)
|
|
"""
|
|
|
|
TRANSITIONS_SQL = """
|
|
SELECT t.packet_id, t.at, t.action, t.from_status::text AS from_status, t.to_status::text AS to_status, t.reason,
|
|
u.display_name AS by_name
|
|
FROM packet_transitions t LEFT JOIN users u ON u.id = t.by
|
|
WHERE t.packet_id = ANY(%s)
|
|
ORDER BY t.packet_id, t.at, t.id
|
|
"""
|
|
|
|
REJECTS_SQL = """
|
|
SELECT h.packet_id, h.at, h.inspector, h.note, h.snapshot->>'result_at' AS result_at, h.snapshot->>'ncr' AS ncr
|
|
FROM qc_history h WHERE h.packet_id = ANY(%s) AND h.result = 'rejected'
|
|
"""
|
|
OPEN_REJECTS_SQL = """
|
|
SELECT q.packet_id, coalesce(q.result_at, q.sent_at) AS at, q.inspector, q.result_note AS note, NULL AS result_at,
|
|
q.ncr
|
|
FROM qc_inspections q WHERE q.packet_id = ANY(%s) AND q.closed_at IS NULL AND q.result = 'rejected'
|
|
"""
|
|
|
|
SHORTS_SQL = """
|
|
SELECT s.id, s.packet_id, s.qty_open, s.reason, s.ref, s.eta, s.opened_at, b.item, b.description
|
|
FROM bom_shorts s JOIN bom_lines b ON b.id = s.bom_line_id
|
|
WHERE s.closed_at IS NULL AND s.packet_id = ANY(%s)
|
|
ORDER BY s.opened_at, s.id
|
|
"""
|
|
|
|
COS_SQL = """
|
|
SELECT c.packet_id, c.co_id, c.co_no, c.pending_depts, co.origin::text AS origin, co.review::text AS review, co.ecn,
|
|
co.reason, co.vis, co.issued_at
|
|
FROM v_open_cos c JOIN change_orders co ON co.id = c.co_id
|
|
WHERE c.packet_id = ANY(%s)
|
|
ORDER BY co.issued_at NULLS LAST, c.packet_id, c.co_no
|
|
"""
|
|
|
|
|
|
@dataclass
|
|
class Report:
|
|
"""Everything one PM answer needs, read once."""
|
|
|
|
conn: Any
|
|
filters: dict[str, Any]
|
|
period: str
|
|
now: datetime
|
|
tz: str
|
|
today: date
|
|
start: datetime | None
|
|
cards: list[dict[str, Any]]
|
|
projects: list[dict[str, Any]]
|
|
units: list[dict[str, Any]]
|
|
dept_labels: dict[str, str]
|
|
expand: set[int] | None = None # None: every project open (expand=all)
|
|
_hours: dict[int, dict[str, float]] | None = field(default=None, repr=False)
|
|
_transitions: dict[int, list[dict[str, Any]]] | None = field(default=None, repr=False)
|
|
_projects_by_id: dict[int, dict[str, Any]] = field(default_factory=dict, repr=False)
|
|
_units_by_id: dict[int, dict[str, Any]] = field(default_factory=dict, repr=False)
|
|
|
|
def __post_init__(self) -> None:
|
|
self._projects_by_id = {int(p["id"]): p for p in self.projects}
|
|
self._units_by_id = {int(u["id"]): u for u in self.units}
|
|
|
|
# ------------------------------------------------------------ lookups
|
|
|
|
@property
|
|
def ids(self) -> list[int]:
|
|
return [int(c["id"]) for c in self.cards]
|
|
|
|
@property
|
|
def open_cards(self) -> list[dict[str, Any]]:
|
|
return [c for c in self.cards if c["status"] != "filed"]
|
|
|
|
@property
|
|
def period_label(self) -> str:
|
|
return PERIODS[self.period][0]
|
|
|
|
def project_by_id(self, pid: int | None) -> dict[str, Any] | None:
|
|
return None if pid is None else self._projects_by_id.get(int(pid))
|
|
|
|
def unit_by_id(self, uid: int | None) -> dict[str, Any] | None:
|
|
return None if uid is None else self._units_by_id.get(int(uid))
|
|
|
|
def hours(self) -> dict[int, dict[str, float]]:
|
|
"""Per packet: timer_h (every stage's clock, the running span included), ticket_h (VISUAL ticket hours on
|
|
its work orders, 0 when none) and target_h (the work orders' target hours)."""
|
|
if self._hours is None:
|
|
out: dict[int, dict[str, float]] = {}
|
|
if self.cards:
|
|
for r in packets.rows(self.conn, HOURS_SQL, {"now": self.now, "ids": self.ids}):
|
|
out[int(r["id"])] = {
|
|
"timer_h": float(r["timer_ms"] or 0) / 3_600_000,
|
|
"ticket_h": float(r["ticket_hrs"] or 0),
|
|
"target_h": float(r["target_hrs"] or 0),
|
|
}
|
|
self._hours = out
|
|
return self._hours
|
|
|
|
def transitions(self) -> dict[int, list[dict[str, Any]]]:
|
|
if self._transitions is None:
|
|
out: dict[int, list[dict[str, Any]]] = {}
|
|
if self.cards:
|
|
for r in packets.rows(self.conn, TRANSITIONS_SQL, (self.ids,)):
|
|
out.setdefault(int(r["packet_id"]), []).append(r)
|
|
self._transitions = out
|
|
return self._transitions
|
|
|
|
def in_period(self, at: Any) -> bool:
|
|
d = as_dt(at)
|
|
return d is not None and (self.start is None or d >= self.start)
|
|
|
|
def unit_due(self, c: Mapping[str, Any]) -> date | None:
|
|
d = c.get("due_effective")
|
|
return d if isinstance(d, date) else None
|
|
|
|
def unit_closed(self, c: Mapping[str, Any]) -> bool:
|
|
u = self.unit_by_id(int(c["unit"]["id"]))
|
|
if u is None:
|
|
return False
|
|
return u["status"] == "closed" or u["project_status"] == "closed"
|
|
|
|
def dept_short(self, code: str) -> str:
|
|
return self.dept_labels.get(code, code)
|
|
|
|
# ------------------------------------------------------------ scope
|
|
|
|
def scope_label(self) -> str:
|
|
f = self.filters
|
|
parts: list[str] = []
|
|
u = self.unit_by_id(f.get("unit_id"))
|
|
pr = self.project_by_id(f.get("project_id"))
|
|
if u is not None:
|
|
parts.append(f"Unit {u['number']}")
|
|
elif pr is not None:
|
|
parts.append(f"Project {pr['number']}")
|
|
else:
|
|
parts.append("All projects")
|
|
if f.get("dept"):
|
|
parts.append(self.dept_short(str(f["dept"])))
|
|
if f.get("status"):
|
|
st = str(f["status"])
|
|
parts.append(search.FLT_STATUS_LABELS.get(st, st))
|
|
q = str(f.get("q") or "").strip()
|
|
if q:
|
|
parts.append(f'search "{q}"')
|
|
flags = [x for x in f.get("flags") or [] if x != "mine"]
|
|
if flags:
|
|
parts.append(", ".join(flags))
|
|
return " · ".join(parts)
|
|
|
|
def scope(self) -> dict[str, Any]:
|
|
f = self.filters
|
|
pr = self.project_by_id(f.get("project_id"))
|
|
u = self.unit_by_id(f.get("unit_id"))
|
|
if pr is None and u is not None:
|
|
pr = self.project_by_id(u["project_id"])
|
|
return {
|
|
"label": self.scope_label(),
|
|
"count": len(self.cards),
|
|
"period": {
|
|
"value": self.period,
|
|
"label": self.period_label,
|
|
"start": self.start.isoformat() if self.start else None,
|
|
},
|
|
"project": None if pr is None else {"id": pr["id"], "number": pr["number"], "name": pr["name"]},
|
|
"unit": None if u is None else {"id": u["id"], "number": u["number"]},
|
|
}
|
|
|
|
|
|
def period_of(value: Any) -> str:
|
|
v = str(value or "").strip().lower()
|
|
return v if v in PERIODS else DEFAULT_PERIOD
|
|
|
|
|
|
def load(
|
|
conn: Any,
|
|
pp: policy.Principal | None,
|
|
params: Mapping[str, Any],
|
|
*,
|
|
now: datetime | None = None,
|
|
tz: str | None = None,
|
|
period: Any = None,
|
|
expand: str | None = None,
|
|
) -> Report:
|
|
"""Read the scope once. `params` are the find bar values plus project_id and unit_id; Mine is ignored here (the
|
|
chip is hidden on the PM screen)."""
|
|
now = (now or datetime.now(UTC)).astimezone(UTC)
|
|
tz = tz or "America/Denver"
|
|
today = now.astimezone(zone(tz)).date()
|
|
principal = pp or policy.Principal(user_id=None, pm=True)
|
|
filters = search.parse_filters(params, principal, screen="pm", today=today)
|
|
filters["flags"] = [f for f in filters["flags"] if f != "mine"]
|
|
filters["mine"] = None
|
|
per = period_of(period)
|
|
days = PERIODS[per][1]
|
|
start = None if days is None else now - timedelta(days=days)
|
|
cards = packets.list_cards(conn, principal, {**filters, "sort": "project", "limit": CARD_LIMIT, "lite": True})
|
|
projects = packets.rows(
|
|
conn,
|
|
"SELECT id, number, name, customer, due, status::text AS status FROM projects WHERE removed_at IS NULL"
|
|
' ORDER BY number COLLATE "C", id',
|
|
)
|
|
units = packets.rows(
|
|
conn,
|
|
"SELECT u.id, u.project_id, u.lot, pr.number || u.lot AS number, u.name, u.due, u.status::text AS status,"
|
|
" pr.status::text AS project_status FROM units u JOIN projects pr ON pr.id = u.project_id"
|
|
' WHERE u.removed_at IS NULL ORDER BY u.lot COLLATE "C", u.id',
|
|
)
|
|
rep = Report(
|
|
conn=conn,
|
|
filters=filters,
|
|
period=per,
|
|
now=now,
|
|
tz=tz,
|
|
today=today,
|
|
start=start,
|
|
cards=cards,
|
|
projects=projects,
|
|
units=units,
|
|
dept_labels=packets.dept_labels(conn),
|
|
)
|
|
rep.expand = parse_expand(expand)
|
|
return rep
|
|
|
|
|
|
def parse_expand(expand: str | None) -> set[int] | None:
|
|
"""`all` opens every project (None); a comma list of project ids opens those."""
|
|
text = str(expand or "").strip().lower()
|
|
if text == "all":
|
|
return None
|
|
out: set[int] = set()
|
|
for part in text.split(","):
|
|
part = part.strip()
|
|
if part.isdigit():
|
|
out.add(int(part))
|
|
return out
|
|
|
|
|
|
# ---------------------------------------------------------------- projects and units
|
|
|
|
|
|
def _units_by_project(rep: Report) -> dict[int, list[dict[str, Any]]]:
|
|
out: dict[int, list[dict[str, Any]]] = {}
|
|
for u in rep.units:
|
|
out.setdefault(int(u["project_id"]), []).append(u)
|
|
return out
|
|
|
|
|
|
def projects_table(rep: Report, *, expand_all: bool = False) -> dict[str, Any]:
|
|
"""The old rptProjects."""
|
|
f = rep.filters
|
|
any_filter = search.narrowing(f) or f.get("project_id") is not None or f.get("unit_id") is not None
|
|
by_project: dict[int, list[dict[str, Any]]] = {}
|
|
by_unit: dict[int, list[dict[str, Any]]] = {}
|
|
for c in rep.cards:
|
|
by_project.setdefault(int(c["project"]["id"]), []).append(c)
|
|
by_unit.setdefault(int(c["unit"]["id"]), []).append(c)
|
|
picked = f.get("project_id")
|
|
units_of = _units_by_project(rep)
|
|
rows: list[dict[str, Any]] = []
|
|
for t in rep.projects:
|
|
tid = int(t["id"])
|
|
cards = by_project.get(tid, [])
|
|
if t["status"] == "closed" and not cards:
|
|
continue
|
|
if any_filter and not cards:
|
|
continue
|
|
hl = health_of(cards, t["due"], rep.today)
|
|
b = stage_buckets(cards)
|
|
us = units_of.get(tid, [])
|
|
for u in us:
|
|
uc = by_unit.get(int(u["id"]), [])
|
|
if not uc:
|
|
continue
|
|
uh = health_of(uc, u["due"] or t["due"], rep.today)
|
|
if SEVERITY[uh["health"]] > SEVERITY[hl["health"]] and hl["health"] != "empty":
|
|
hl["health"] = uh["health"]
|
|
rows.append(
|
|
row(
|
|
[
|
|
t["number"],
|
|
t["name"] or "",
|
|
t["customer"] or "",
|
|
day_str(t["due"]) or DASH,
|
|
left_text(hl["days_left"]),
|
|
len(us),
|
|
hl["n"],
|
|
b["draft"],
|
|
b["wh"],
|
|
b["shop"],
|
|
b["qc"],
|
|
b["done"],
|
|
f"{hl['pct']}%",
|
|
HEALTH[hl["health"]][0],
|
|
],
|
|
project_id=tid,
|
|
lvl=0,
|
|
health=hl["health"],
|
|
)
|
|
)
|
|
opened = expand_all or rep.expand is None or tid in rep.expand or picked == tid
|
|
if not opened:
|
|
continue
|
|
for u in us:
|
|
uc = by_unit.get(int(u["id"]), [])
|
|
if any_filter and not uc and picked != tid:
|
|
continue
|
|
due = u["due"] or t["due"]
|
|
uh = health_of(uc, due, rep.today)
|
|
ub = stage_buckets(uc)
|
|
due_txt = (day_str(due) + ("" if u["due"] else " (project)")) if due else DASH
|
|
rows.append(
|
|
row(
|
|
[
|
|
u["number"],
|
|
u["name"] or "",
|
|
"",
|
|
due_txt,
|
|
left_text(uh["days_left"]),
|
|
"",
|
|
uh["n"],
|
|
ub["draft"],
|
|
ub["wh"],
|
|
ub["shop"],
|
|
ub["qc"],
|
|
ub["done"],
|
|
f"{uh['pct']}%",
|
|
HEALTH[uh["health"]][0],
|
|
],
|
|
project_id=tid,
|
|
unit_id=int(u["id"]),
|
|
lvl=1,
|
|
health=uh["health"],
|
|
)
|
|
)
|
|
return table("projects", "Projects and units", PROJECTS_HEAD, rows, NOTE_PROJECTS)
|
|
|
|
|
|
def unit_healths(rep: Report) -> list[dict[str, Any]]:
|
|
"""Health of every unit with packets in scope (the KPIs count them)."""
|
|
by_unit: dict[int, list[dict[str, Any]]] = {}
|
|
for c in rep.cards:
|
|
by_unit.setdefault(int(c["unit"]["id"]), []).append(c)
|
|
out: list[dict[str, Any]] = []
|
|
for uid, cards in by_unit.items():
|
|
u = rep.unit_by_id(uid)
|
|
t = rep.project_by_id(u["project_id"]) if u else None
|
|
due = (u["due"] if u else None) or (t["due"] if t else None)
|
|
out.append(health_of(cards, due, rep.today))
|
|
return out
|
|
|
|
|
|
def attention_table(rep: Report) -> dict[str, Any]:
|
|
"""The units late or at risk, from the projects table with every unit open."""
|
|
rows = []
|
|
for r in projects_table(rep, expand_all=True)["rows"]:
|
|
if r["lvl"] == 1 and r["health"] in ("late", "risk"):
|
|
c = r["cells"]
|
|
rows.append(
|
|
row(
|
|
[c[0], c[1], c[3], c[4], c[6], c[12], c[13]],
|
|
project_id=r["project_id"],
|
|
unit_id=r["unit_id"],
|
|
lvl=1,
|
|
health=r["health"],
|
|
)
|
|
)
|
|
return table("attention", "Units late or at risk", ATTENTION_HEAD, rows)
|
|
|
|
|
|
# ---------------------------------------------------------------- aging
|
|
|
|
|
|
def pipeline(rep: Report) -> list[dict[str, Any]]:
|
|
"""One entry per PM step: the open packets in it, the average and longest time there, the oldest packet, and
|
|
`slowest` on the step with the highest average among the steps with packets."""
|
|
out: list[dict[str, Any]] = []
|
|
open_cards = rep.open_cards
|
|
for key, label in PM_STEPS:
|
|
here = [c for c in open_cards if step_of(c) == key]
|
|
ages = [(rep.now - step_since(c)).total_seconds() for c in here]
|
|
oldest = min(here, key=lambda c: step_since(c)) if here else None
|
|
out.append(
|
|
{
|
|
"key": key,
|
|
"label": label,
|
|
"count": len(here),
|
|
"avg_s": round(sum(ages) / len(ages), 1) if ages else 0,
|
|
"max_s": round(max(ages), 1) if ages else 0,
|
|
"oldest": None if oldest is None else {"pid": oldest["id"], "job": job_name(oldest)},
|
|
"slowest": False,
|
|
}
|
|
)
|
|
busy = [e for e in out if e["count"]]
|
|
if busy:
|
|
max(busy, key=lambda e: e["avg_s"])["slowest"] = True
|
|
return out
|
|
|
|
|
|
def aging_tables(rep: Report, pipe: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]:
|
|
"""The old rptAging: time in the current step, every open packet, and the completed stays in the period."""
|
|
pipe = pipe if pipe is not None else pipeline(rep)
|
|
steps = table(
|
|
"aging_steps",
|
|
"Time in the current step (open packets)",
|
|
["Step", "Packets", "Average", "Longest", "Oldest packet"],
|
|
[
|
|
row(
|
|
[
|
|
s["label"],
|
|
s["count"],
|
|
fmt_days(s["avg_s"]) if s["count"] else DASH,
|
|
fmt_days(s["max_s"]) if s["count"] else DASH,
|
|
s["oldest"]["job"] if s["oldest"] else DASH,
|
|
],
|
|
pid=s["oldest"]["pid"] if s["oldest"] else None,
|
|
)
|
|
for s in pipe
|
|
],
|
|
NOTE_AGING_STEPS,
|
|
)
|
|
|
|
open_rows = []
|
|
for c in sorted(rep.open_cards, key=lambda c: (step_since(c), c["id"])):
|
|
age = (rep.now - step_since(c)).total_seconds()
|
|
created = as_dt(c.get("created_at")) or step_since(c)
|
|
flags = [
|
|
"Change order" if c.get("open_cos") else "",
|
|
f"{c['open_shorts']} short" if c.get("open_shorts") else "",
|
|
"On hold: " + str((c.get("wh") or {}).get("reason") or "") if on_hold(c) else "",
|
|
]
|
|
u = rep.unit_by_id(int(c["unit"]["id"]))
|
|
open_rows.append(
|
|
row(
|
|
[
|
|
job_name(c),
|
|
(u["name"] if u else "") or "",
|
|
step_label(c),
|
|
c.get("holder") or "",
|
|
fmt_days(age),
|
|
fmt_days((rep.now - created).total_seconds()),
|
|
due_text(rep.unit_due(c), rep.today, rep.unit_closed(c)),
|
|
" · ".join(x for x in flags if x),
|
|
],
|
|
pid=c["id"],
|
|
flag=age > 3 * DAY_S,
|
|
)
|
|
)
|
|
every = table(
|
|
"aging_open",
|
|
"Every open packet, longest waiting first",
|
|
["Job", "Unit", "Step", "With", "In this step", "Total age", "Unit due", "Flags"],
|
|
open_rows,
|
|
NOTE_AGING_OPEN,
|
|
)
|
|
|
|
done: dict[str, list[float]] = {}
|
|
trans = rep.transitions()
|
|
for c in rep.cards:
|
|
prev = as_dt(c.get("created_at"))
|
|
for t in trans.get(int(c["id"]), []):
|
|
at = as_dt(t["at"])
|
|
if at is None:
|
|
continue
|
|
if prev is not None and rep.in_period(at) and t["from_status"]:
|
|
done.setdefault(str(t["from_status"]), []).append(max(0.0, (at - prev).total_seconds()))
|
|
prev = at
|
|
order = {s: i for i, s in enumerate(policy.STATUSES)}
|
|
hist_rows = [
|
|
row(
|
|
[
|
|
search.STATUS_LABEL.get(k, k),
|
|
len(a),
|
|
fmt_days(sum(a) / len(a)),
|
|
fmt_days(max(a)),
|
|
]
|
|
)
|
|
for k, a in sorted(done.items(), key=lambda kv: order.get(kv[0], 99))
|
|
]
|
|
history = table(
|
|
"aging_history",
|
|
f"Average time per step, moves completed ({rep.period_label})",
|
|
["Step", "Moves", "Average", "Longest"],
|
|
hist_rows,
|
|
NOTE_AGING_HISTORY,
|
|
)
|
|
return [steps, every, history]
|
|
|
|
|
|
# ---------------------------------------------------------------- hours
|
|
|
|
|
|
def _counts_in_hours(c: Mapping[str, Any], h: Mapping[str, float]) -> bool:
|
|
return h["target_h"] > 0 and (h["timer_h"] > 0 or h["ticket_h"] > 0 or c["status"] in HOURS_STATUSES)
|
|
|
|
|
|
def station_worker(c: Mapping[str, Any]) -> str:
|
|
parts = [(c.get("station") or {}).get("name"), (c.get("welder") or {}).get("display_name")]
|
|
return " · ".join(str(x) for x in parts if x) or DASH
|
|
|
|
|
|
def hours_tables(rep: Report) -> list[dict[str, Any]]:
|
|
"""The old rptHours, with the station timer and the VISUAL ticket hours side by side against the target. The
|
|
timer decides "over target"."""
|
|
hrs = rep.hours()
|
|
zero = {"timer_h": 0.0, "ticket_h": 0.0, "target_h": 0.0}
|
|
hp = [(c, hrs.get(int(c["id"]), zero)) for c in rep.cards]
|
|
hp = [(c, h) for c, h in hp if _counts_in_hours(c, h)]
|
|
|
|
def group(key_of: Any) -> list[tuple[str, dict[str, float]]]:
|
|
m: dict[str, dict[str, float]] = {}
|
|
for c, h in hp:
|
|
k = key_of(c) or DASH
|
|
g = m.setdefault(k, {"timer": 0.0, "ticket": 0.0, "target": 0.0, "n": 0, "over": 0})
|
|
g["timer"] += h["timer_h"]
|
|
g["ticket"] += h["ticket_h"]
|
|
g["target"] += h["target_h"]
|
|
g["n"] += 1
|
|
if h["timer_h"] > h["target_h"]:
|
|
g["over"] += 1
|
|
return sorted(m.items(), key=lambda kv: -(kv[1]["timer"] - kv[1]["target"]))
|
|
|
|
def rows_of(groups: list[tuple[str, dict[str, float]]]) -> list[dict[str, Any]]:
|
|
return [
|
|
row(
|
|
[
|
|
k,
|
|
int(v["n"]),
|
|
fmt_h(v["timer"]),
|
|
fmt_h(v["ticket"]),
|
|
fmt_h(v["target"]),
|
|
signed_h(v["timer"] - v["target"]),
|
|
f"{half_up(v['timer'] / v['target'] * 100)}%" if v["target"] else DASH,
|
|
int(v["over"]),
|
|
],
|
|
flag=v["timer"] > v["target"],
|
|
)
|
|
for k, v in groups
|
|
]
|
|
|
|
def project_key(c: Mapping[str, Any]) -> str:
|
|
p = c.get("project") or {}
|
|
return f"{p.get('number') or ''} {p.get('name') or ''}".strip()
|
|
|
|
t1 = table(
|
|
"hours_dept",
|
|
"By department",
|
|
["Department", *HOURS_HEAD],
|
|
rows_of(group(lambda c: rep.dept_short(c["dept"]["code"]))),
|
|
)
|
|
t2 = table(
|
|
"hours_worker",
|
|
"By welder / worker",
|
|
["Worker", *HOURS_HEAD],
|
|
rows_of(group(lambda c: (c.get("welder") or {}).get("display_name"))),
|
|
)
|
|
t3 = table("hours_project", "By project", ["Project", *HOURS_HEAD], rows_of(group(project_key)))
|
|
over = sorted(
|
|
((c, h) for c, h in hp if h["timer_h"] > h["target_h"]),
|
|
key=lambda ch: -(ch[1]["timer_h"] - ch[1]["target_h"]),
|
|
)
|
|
t4 = table(
|
|
"hours_over",
|
|
"Packets over target",
|
|
["Job", "Station / worker", "Status", "Timer h", "Ticket h", "Target h", "Over by"],
|
|
[
|
|
row(
|
|
[
|
|
job_name(c),
|
|
station_worker(c),
|
|
search.STATUS_LABEL.get(str(c["status"]), str(c["status"])),
|
|
fmt_h(h["timer_h"]),
|
|
fmt_h(h["ticket_h"]),
|
|
fmt_h(h["target_h"]),
|
|
"+" + fmt_h(h["timer_h"] - h["target_h"]) + " h",
|
|
],
|
|
pid=c["id"],
|
|
flag=True,
|
|
)
|
|
for c, h in over
|
|
],
|
|
NOTE_HOURS_OVER,
|
|
)
|
|
return [t1, t2, t3, t4]
|
|
|
|
|
|
def over_target_count(rep: Report) -> int:
|
|
hrs = rep.hours()
|
|
n = 0
|
|
for c in rep.cards:
|
|
h = hrs.get(int(c["id"]))
|
|
if h and h["target_h"] > 0 and h["timer_h"] > h["target_h"]:
|
|
n += 1
|
|
return n
|
|
|
|
|
|
# ---------------------------------------------------------------- quality
|
|
|
|
|
|
def rejects(rep: Report) -> list[dict[str, Any]]:
|
|
"""Q.C. rejects in the period: closed inspections from qc_history and an open inspection still showing its
|
|
reject, newest first."""
|
|
if not rep.cards:
|
|
return []
|
|
found = packets.rows(rep.conn, REJECTS_SQL, (rep.ids,)) + packets.rows(rep.conn, OPEN_REJECTS_SQL, (rep.ids,))
|
|
out = []
|
|
for r in found:
|
|
at = as_dt(r.get("result_at")) or as_dt(r["at"])
|
|
if at is None or not rep.in_period(at):
|
|
continue
|
|
out.append({**r, "at": at})
|
|
out.sort(key=lambda r: r["at"], reverse=True)
|
|
return out
|
|
|
|
|
|
def open_cos(rep: Report) -> list[dict[str, Any]]:
|
|
if not rep.cards:
|
|
return []
|
|
cos = packets.rows(rep.conn, COS_SQL, (rep.ids,))
|
|
if cos:
|
|
items: dict[int, list[dict[str, Any]]] = {}
|
|
for it in packets.rows(
|
|
rep.conn,
|
|
"SELECT co_id, old_dwg, old_rev, new_dwg, new_rev, added FROM co_items WHERE co_id = ANY(%s) ORDER BY id",
|
|
([int(c["co_id"]) for c in cos],),
|
|
):
|
|
items.setdefault(int(it["co_id"]), []).append(it)
|
|
for c in cos:
|
|
c["items"] = items.get(int(c["co_id"]), [])
|
|
return cos
|
|
|
|
|
|
def co_summary(co: Mapping[str, Any]) -> str:
|
|
"""The old coSummary, through the data book's port."""
|
|
from ..pdf import databook
|
|
|
|
return str(databook.co_summary(co))
|
|
|
|
|
|
def quality_tables(rep: Report) -> list[dict[str, Any]]:
|
|
by_id = {int(c["id"]): c for c in rep.cards}
|
|
now = rep.now
|
|
|
|
t1_rows = []
|
|
for r in rejects(rep):
|
|
c = by_id[int(r["packet_id"])]
|
|
note = str(r.get("note") or "").strip()
|
|
ncr = str(r.get("ncr") or "").strip()
|
|
text = note or DASH
|
|
if ncr and ncr not in note:
|
|
text = f"{ncr} · {note}" if note else ncr
|
|
t1_rows.append(
|
|
row(
|
|
[job_name(c), fmt_dt(r["at"], rep.tz), r.get("inspector") or DASH, text, station_worker(c)],
|
|
pid=c["id"],
|
|
flag=True,
|
|
)
|
|
)
|
|
t1 = table(
|
|
"quality_rejects",
|
|
f"Q.C. rejects ({rep.period_label})",
|
|
["Job", "Date", "Inspector", "Reason / NCR", "Station / worker"],
|
|
t1_rows,
|
|
)
|
|
|
|
t2_rows = []
|
|
if rep.cards:
|
|
from .pipeline import qty_text
|
|
|
|
for s in packets.rows(rep.conn, SHORTS_SQL, (rep.ids,)):
|
|
c = by_id[int(s["packet_id"])]
|
|
eta = s["eta"]
|
|
late = isinstance(eta, date) and eta < rep.today
|
|
opened = as_dt(s["opened_at"]) or now
|
|
t2_rows.append(
|
|
row(
|
|
[
|
|
job_name(c),
|
|
f"{s['item']} {s['description'] or ''}".strip(),
|
|
qty_text(s["qty_open"]),
|
|
s["reason"] or "",
|
|
s["ref"] or DASH,
|
|
(day_str(eta) + (" (late)" if late else "")) if isinstance(eta, date) else DASH,
|
|
fmt_days((now - opened).total_seconds()),
|
|
step_label(c),
|
|
],
|
|
pid=c["id"],
|
|
flag=late,
|
|
)
|
|
)
|
|
t2 = table(
|
|
"quality_shorts",
|
|
"Open shorts",
|
|
["Job", "Item", "Short qty", "Reason", "PO / req", "ETA", "Age", "Packet is"],
|
|
t2_rows,
|
|
)
|
|
|
|
t3_rows = []
|
|
for co in open_cos(rep):
|
|
c = by_id[int(co["packet_id"])]
|
|
issued = as_dt(co["issued_at"])
|
|
age = (now - issued).total_seconds() if issued else None
|
|
label = packets.co_label(co["co_no"]) + (f" · {co['ecn']}" if co.get("ecn") else "")
|
|
waiting = ", ".join(rep.dept_short(d) for d in co.get("pending_depts") or []) or DASH
|
|
t3_rows.append(
|
|
row(
|
|
[job_name(c), label, co_summary(co), waiting, fmt_days(age) if age is not None else DASH],
|
|
pid=c["id"],
|
|
flag=age is not None and age > 2 * DAY_S,
|
|
)
|
|
)
|
|
t3 = table(
|
|
"quality_cos", "Open change orders", ["Job", "Change order", "Change", "Waiting on", "Open for"], t3_rows
|
|
)
|
|
|
|
t4_rows = []
|
|
for c in rep.cards:
|
|
if not on_hold(c):
|
|
continue
|
|
wh = c.get("wh") or {}
|
|
at = as_dt(wh.get("at")) or now
|
|
t4_rows.append(
|
|
row(
|
|
[
|
|
job_name(c),
|
|
wh.get("reason") or DASH,
|
|
wh.get("note") or "",
|
|
(wh.get("by") or {}).get("display_name") or DASH,
|
|
fmt_days((now - at).total_seconds()),
|
|
],
|
|
pid=c["id"],
|
|
flag=True,
|
|
)
|
|
)
|
|
t4 = table("quality_holds", "Warehouse holds", ["Job", "Reason", "Note", "Put on hold by", "On hold for"], t4_rows)
|
|
|
|
from .pipeline import LABELS
|
|
|
|
sent: list[tuple[datetime, dict[str, Any], dict[str, Any]]] = []
|
|
for pid, ts in rep.transitions().items():
|
|
for t in ts:
|
|
when = as_dt(t["at"])
|
|
if t["action"] in SENT_BACK and when is not None and rep.in_period(when):
|
|
sent.append((when, by_id[pid], t))
|
|
sent.sort(key=lambda x: x[0], reverse=True)
|
|
t5_rows = []
|
|
for at, c, t in sent:
|
|
try:
|
|
label = LABELS.get(policy.Action(t["action"]), str(t["action"]))
|
|
except ValueError:
|
|
label = str(t["action"])
|
|
t5_rows.append(
|
|
row([job_name(c), fmt_dt(at, rep.tz), label, t.get("by_name") or DASH, t.get("reason") or ""], pid=c["id"])
|
|
)
|
|
t5 = table(
|
|
"quality_sentback", f"Sent back ({rep.period_label})", ["Job", "When", "Action", "By", "Reason"], t5_rows
|
|
)
|
|
return [t1, t2, t3, t4, t5]
|
|
|
|
|
|
# ---------------------------------------------------------------- KPIs, summary, jobs
|
|
|
|
|
|
def filed_in_period(rep: Report) -> list[tuple[dict[str, Any], datetime]]:
|
|
"""Packets in scope with their latest transition to filed, when it is in the period."""
|
|
by_id = {int(c["id"]): c for c in rep.cards}
|
|
out = []
|
|
for pid, ts in rep.transitions().items():
|
|
filed = [as_dt(t["at"]) for t in ts if t["to_status"] == "filed"]
|
|
last = max((f for f in filed if f is not None), default=None)
|
|
if last is not None and rep.in_period(last):
|
|
out.append((by_id[pid], last))
|
|
return out
|
|
|
|
|
|
def kpis(rep: Report) -> list[dict[str, Any]]:
|
|
"""The old pmKpis, in order: each names the tab it opens; `alert` marks the ones shown hot when not zero."""
|
|
open_cards = rep.open_cards
|
|
uh = unit_healths(rep)
|
|
filed = filed_in_period(rep)
|
|
lead = [(f - (as_dt(c.get("created_at")) or f)).total_seconds() for c, f in filed]
|
|
per = rep.period_label.lower()
|
|
lead_text = f"{sum(lead) / len(lead) / DAY_S:.1f} d" if lead else DASH
|
|
return [
|
|
{"key": "open", "n": len(open_cards), "label": "Open packets", "tab": "aging", "alert": False},
|
|
{
|
|
"key": "late_units",
|
|
"n": sum(1 for h in uh if h["health"] == "late"),
|
|
"label": "Late units",
|
|
"tab": "projects",
|
|
"alert": True,
|
|
},
|
|
{
|
|
"key": "risk_units",
|
|
"n": sum(1 for h in uh if h["health"] == "risk"),
|
|
"label": "Units at risk",
|
|
"tab": "projects",
|
|
"alert": True,
|
|
},
|
|
{
|
|
"key": "holds",
|
|
"n": sum(1 for c in open_cards if on_hold(c)),
|
|
"label": "Warehouse holds",
|
|
"tab": "quality",
|
|
"alert": True,
|
|
},
|
|
{
|
|
"key": "cos",
|
|
"n": sum(len(c.get("open_cos") or []) for c in rep.cards),
|
|
"label": "Open change orders",
|
|
"tab": "quality",
|
|
"alert": True,
|
|
},
|
|
{
|
|
"key": "shorts",
|
|
"n": sum(int(c.get("open_shorts") or 0) for c in rep.cards),
|
|
"label": "Open shorts",
|
|
"tab": "quality",
|
|
"alert": True,
|
|
},
|
|
{"key": "rejects", "n": len(rejects(rep)), "label": f"Q.C. rejects · {per}", "tab": "quality", "alert": True},
|
|
{
|
|
"key": "over",
|
|
"n": over_target_count(rep),
|
|
"label": "Packets over target hours",
|
|
"tab": "hours",
|
|
"alert": True,
|
|
},
|
|
{"key": "filed", "n": len(filed), "label": f"Filed · {per}", "tab": "projects", "alert": False},
|
|
{
|
|
"key": "lead",
|
|
"n": lead_text,
|
|
"label": "Average lead time, created → filed",
|
|
"tab": "aging",
|
|
"alert": False,
|
|
},
|
|
]
|
|
|
|
|
|
def summary(rep: Report, k: list[dict[str, Any]] | None = None, pipe: list[dict[str, Any]] | None = None) -> list[str]:
|
|
"""The old pmSummaryLines: the plain-words panel."""
|
|
k = k if k is not None else kpis(rep)
|
|
pipe = pipe if pipe is not None else pipeline(rep)
|
|
n = {x["key"]: x["n"] for x in k}
|
|
busy = [s for s in pipe if s["count"]]
|
|
slow = max(busy, key=lambda s: s["avg_s"]) if busy else None
|
|
big = max(busy, key=lambda s: s["count"]) if busy else None
|
|
late = [
|
|
r["cells"][0] for r in projects_table(rep, expand_all=True)["rows"] if r["lvl"] == 1 and r["health"] == "late"
|
|
]
|
|
out = []
|
|
o = int(n["open"])
|
|
out.append(f"{o} open packet{'' if o == 1 else 's'} in scope ({rep.scope_label()}).")
|
|
if late:
|
|
more = f" and {len(late) - 6} more" if len(late) > 6 else ""
|
|
out.append(f"Late: {', '.join(late[:6])}{more}.")
|
|
else:
|
|
out.append("No units are past their due date.")
|
|
if big:
|
|
out.append(f'Most packets are at "{big["label"]}" ({big["count"]}).')
|
|
if slow:
|
|
out.append(
|
|
f'Slowest step right now: "{slow["label"]}", averaging {fmt_days(slow["avg_s"])}'
|
|
f" (longest {fmt_days(slow['max_s'])})."
|
|
)
|
|
issues = [
|
|
plural(int(n["holds"]), "warehouse hold") if n["holds"] else "",
|
|
plural(int(n["cos"]), "open change order") if n["cos"] else "",
|
|
plural(int(n["shorts"]), "open short") if n["shorts"] else "",
|
|
plural(int(n["rejects"]), "Q.C. reject") if n["rejects"] else "",
|
|
]
|
|
issues = [x for x in issues if x]
|
|
out.append(
|
|
f"Needs attention: {', '.join(issues)}." if issues else "No holds, open change orders, shorts or Q.C. rejects."
|
|
)
|
|
over = int(n["over"])
|
|
if over:
|
|
out.append(f"{over} packet{' is' if over == 1 else 's are'} over target hours.")
|
|
return out
|
|
|
|
|
|
def jobs_table(rep: Report) -> dict[str, Any]:
|
|
"""The old pmJobsTable: every job in scope, with the hours."""
|
|
hrs = rep.hours()
|
|
rows = []
|
|
for c in rep.cards:
|
|
h = hrs.get(int(c["id"]), {"timer_h": 0.0, "ticket_h": 0.0, "target_h": 0.0})
|
|
filed = c["status"] == "filed"
|
|
due = rep.unit_due(c)
|
|
rows.append(
|
|
row(
|
|
[
|
|
job_name(c),
|
|
(c.get("project") or {}).get("number") or "",
|
|
(c.get("unit") or {}).get("number") or "",
|
|
c.get("op") or "",
|
|
c.get("title") or "",
|
|
rep.dept_short(c["dept"]["code"]),
|
|
step_label(c),
|
|
c.get("holder") or "",
|
|
(c.get("station") or {}).get("name") or "",
|
|
(c.get("welder") or {}).get("display_name") or "",
|
|
c.get("drawing") or "",
|
|
c.get("rev") or "",
|
|
due.isoformat() if due else "",
|
|
"" if filed else f"{(rep.now - step_since(c)).total_seconds() / DAY_S:.1f}",
|
|
fmt_h(h["timer_h"]),
|
|
fmt_h(h["ticket_h"]),
|
|
fmt_h(h["target_h"]),
|
|
fmt_d(c.get("created_at"), rep.tz),
|
|
fmt_dt(c.get("updated_at"), rep.tz),
|
|
len(c.get("open_cos") or []),
|
|
int(c.get("open_shorts") or 0),
|
|
],
|
|
pid=c["id"],
|
|
project_id=(c.get("project") or {}).get("id"),
|
|
unit_id=(c.get("unit") or {}).get("id"),
|
|
)
|
|
)
|
|
return table("jobs", "All jobs in scope", JOBS_HEAD, rows)
|
|
|
|
|
|
# ---------------------------------------------------------------- the answers
|
|
|
|
|
|
def overview(rep: Report) -> dict[str, Any]:
|
|
pipe = pipeline(rep)
|
|
k = kpis(rep)
|
|
return {
|
|
"scope": rep.scope(),
|
|
"kpis": k,
|
|
"summary": summary(rep, k, pipe),
|
|
"pipeline": pipe,
|
|
"attention": attention_table(rep),
|
|
}
|
|
|
|
|
|
def tables_for(rep: Report, key: str) -> tuple[str, list[dict[str, Any]]] | None:
|
|
"""The CSV export's name and tables for a table key, `all`, or None for an unknown key."""
|
|
if key == "all":
|
|
return "All reports", all_tables(rep)
|
|
if key == "jobs":
|
|
return "Jobs", [jobs_table(rep)]
|
|
if key not in TABLE_KEYS:
|
|
return None
|
|
for t in all_tables(rep, jobs=False):
|
|
if t["key"] == key:
|
|
return str(t["title"]), [t]
|
|
return None
|
|
|
|
|
|
def all_tables(rep: Report, *, jobs: bool = True) -> list[dict[str, Any]]:
|
|
"""The old pmAllTables: every report with every unit open, then the jobs."""
|
|
out = [projects_table(rep, expand_all=True), *aging_tables(rep), *hours_tables(rep), *quality_tables(rep)]
|
|
if jobs:
|
|
out.append(jobs_table(rep))
|
|
return out
|
|
|
|
|
|
def file_stem(name: str) -> str:
|
|
return re.sub(r"[^A-Za-z0-9]+", "_", name).strip("_") or "Report"
|
|
|
|
|
|
def csv_rows(rep: Report, name: str, tables: Sequence[Mapping[str, Any]], who_name: str) -> list[list[str]]:
|
|
"""The old pmSaveCsv rows: the report name, the scope, the period, who exported it and when, then each table."""
|
|
out: list[list[str]] = [
|
|
[f"PESCO Packet Station — {name}"],
|
|
["Scope", rep.scope_label()],
|
|
["History period", rep.period_label],
|
|
["Exported", fmt_dt(rep.now, rep.tz), "by", who_name],
|
|
]
|
|
for t in tables:
|
|
out.append([])
|
|
out.append([str(t["title"])])
|
|
out.append([str(h) for h in t["head"]])
|
|
out += [list(r["cells"]) for r in t["rows"]]
|
|
return out
|