Files

1349 lines
46 KiB
Python
Raw Permalink Normal View History

"""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 = (
"VISUAL ticket hours against the work-order target hours decide over target. The station timer is shown beside"
" them. A packet with no ticket hours is not over target."
)
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",
"Ticket variance h",
"Ticket h / target",
"Over target (tickets)",
]
OVER_HEAD = ["Job", "Station / worker", "Status", "Timer h", "Ticket h", "Target h", "Over by (tickets)"]
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 is_over(h: Mapping[str, float]) -> bool:
"""Over target: the VISUAL ticket hours are over the work-order target (change C1). The timer never decides, and
a packet with no ticket hours is not over."""
return h["target_h"] > 0 and h["ticket_h"] > 0 and h["ticket_h"] > h["target_h"]
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
ticket hours decide "over target" (change C1); the timer is shown as today."""
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 is_over(h):
g["over"] += 1
return sorted(m.items(), key=lambda kv: -(kv[1]["ticket"] - 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["ticket"] - v["target"]),
f"{half_up(v['ticket'] / v['target'] * 100)}%" if v["target"] else DASH,
int(v["over"]),
],
flag=v["ticket"] > 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 is_over(h)),
key=lambda ch: -(ch[1]["ticket_h"] - ch[1]["target_h"]),
)
t4 = table(
"hours_over",
"Packets over target",
OVER_HEAD,
[
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["ticket_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 is_over(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 (tickets)",
"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 on the VISUAL tickets.")
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