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
|