Files
PacketStation/server/packetstation/domain/shop.py
T
spencermandClaude Fable 5.1 6b6db0230f
ci / frontend (push) Successful in 43s
ci / server (push) Successful in 11m6s
ci / image (push) Successful in 38s
ci / e2e (push) Successful in 3m55s
Phase 8: Project Management, find bar, presence and motion
The Project Management screen (/pm, view only) with Overview, Projects & units, Bottlenecks & aging, Hours vs target,
Quality & issues and Find jobs, the period picker, health per unit and per project, drill down, Excel (CSV) per table
and for all, and the PESCO production status report PDF with the controlled-copy stamp; every export logged as
"report exported". The find bar on every home screen: the full search set, the dropdowns, quick flags with Mine, the
flag kinds, Board and List with sorts, filters saved per browser; every filter applied by the server on the list and
the boards, with facets. Presence in packets with role, lock and revoke drop; the motion switch with reduced motion
honoured, card glides, glow and count-up; Back restores the scroll spot; sheet placement and the iPad layout pass. The
data book compared page by page with the old build and six layout differences fixed. Tests for the PM aggregates,
search, filters, presence and exports; Playwright scenarios for the PM screen, the find bar and presence at desktop and
iPad viewports. Docs updated; PLAN.md questions 38 to 42.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-18 15:08:34 -06:00

460 lines
18 KiB
Python

"""The shop: the supervisor board, the stations a person may work at, the stall board, and the borrowed heat map.
Everything here reads PostgreSQL only. The cards are the `list_cards` shape plus the shop fields (welder stamp, the
live stage clock, target hours, open change orders as labels, the return note), so a card reads the same on every
screen.
"""
from __future__ import annotations
from collections.abc import Iterable, Mapping
from typing import Any
from .. import policy
from ..api.errors import ApiError
from . import packets, search
LEAD, SUPERVISOR = 2, 3
LEVEL_NAMES = {1: "worker", 2: "lead", 3: "supervisor"}
BOARD_LIMIT = 5000
# The columns of the supervisor board, by packet status. Packets at a station are on the station grid.
COLUMNS = {"ready": "ready", "review": "review", "qc": "qc", "warehouse": "warehouse", "complete": "complete"}
BOARD_STATUSES = ("ready", "assigned", "in_progress", "review", "qc", "warehouse", "complete")
MSG_NO_LEAD = "You do not lead a department."
MSG_NOT_YOURS = "You do not lead that department."
MSG_NO_STATION = "That station is not one of yours."
# ---------------------------------------------------------------- the borrowed heat map
def _has_heat_map(conn: Any, packet_id: int) -> bool:
return bool(
conn.execute(
"SELECT EXISTS (SELECT 1 FROM heat_table_placements WHERE packet_id = %s)"
" OR EXISTS (SELECT 1 FROM heat_tags WHERE packet_id = %s AND removed_at IS NULL)",
(packet_id, packet_id),
).fetchone()[0]
)
SIBLING_SQL = """
SELECT p.id
FROM packets p
WHERE p.unit_id = %(unit)s AND p.id <> %(id)s AND p.removed_at IS NULL
AND upper(regexp_replace(coalesce(p.drawing, ''), '\\s', '', 'g')) = %(dwg)s
AND (EXISTS (SELECT 1 FROM heat_table_placements h WHERE h.packet_id = p.id)
OR EXISTS (SELECT 1 FROM heat_tags t WHERE t.packet_id = p.id AND t.removed_at IS NULL))
ORDER BY (coalesce(p.rev, '') = %(rev)s) DESC, p.created_at, p.id
LIMIT 1
"""
def dwg_key(drawing: Any) -> str:
"""The drawing number compared between packets: white space removed, upper case (the old dwgKey)."""
return "".join(str(drawing or "").split()).upper()
def heat_src(conn: Any, row: Mapping[str, Any], ch: packets.Children) -> dict[str, Any] | None:
"""The heat map this packet borrows from a sibling, or None (the old heatSrc).
A packet borrows when it has no heat map of its own (no heat table, no heat tag), pulls no raw material of its
own (every BOM line is N/A or a sub-assembly, or the BOM is empty) and has a drawing. The sibling is a live
packet in the same unit with the same drawing and a heat map; the same rev first, then the oldest. Read-only.
"""
if ch.heat_table or ch.heat_tags:
return None
if any(not b["na"] and not packets.is_sub(b) for b in ch.bom):
return None
dwg = dwg_key(row["drawing"])
if not dwg:
return None
found = conn.execute(
SIBLING_SQL, {"unit": row["unit_id"], "id": row["id"], "dwg": dwg, "rev": str(row["rev"] or "")}
).fetchone()
if found is None:
return None
sib = packets.load_row(conn, int(found[0]))
if sib is None:
return None
sch = packets.load_children(conn, sib, full=True)
people = packets.People(conn, [h["by"] for h in sch.heat_table] + [pg["void_by"] for pg in sch.pages])
st = sch.open_stage
return {
"packet_id": sib["id"],
"op": sib["op"],
"status": sib["status"],
"drawing": sib["drawing"],
"rev": sib["rev"],
"pages": [packets._page(pg, sch, people) for pg in sch.pages if pg["type"] == "drawing"],
"heat_table": [
{k: h[k] for k in ("page", "x", "y", "s", "carried_from", "at")} | {"by": people.ref(h["by"])}
for h in sch.heat_table
],
"heat_tags": [packets._heat_tag(t, st) for t in sch.heat_tags],
"bom": [{"item": b["item"], "heat": b["heat"], "na": b["na"]} for b in sch.bom],
}
# ---------------------------------------------------------------- shop cards
EXTRAS_SQL = """
SELECT p.id, p.return_note, p.welder_stamp, s.clock_elapsed_ms, s.clock_running_since, s.clock_started_at,
(SELECT coalesce(sum(w.target_hrs), 0) FROM packet_work_orders w WHERE w.packet_id = p.id) AS target_hrs
FROM packets p
LEFT JOIN packet_stages s ON s.packet_id = p.id AND s.closed_at IS NULL
WHERE p.id = ANY(%s)
"""
KPI_SQL = """
SELECT p.id, p.status::text AS status, s.clock_elapsed_ms, s.clock_running_since, s.clock_started_at,
(SELECT coalesce(sum(w.target_hrs), 0) FROM packet_work_orders w WHERE w.packet_id = p.id) AS target_hrs
FROM packets p
LEFT JOIN packet_stages s ON s.packet_id = p.id AND s.closed_at IS NULL
WHERE p.id = ANY(%s)
"""
def add_shop_fields(conn: Any, cards: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Add the shop fields to list_cards cards, in place: welder_stamp, clock {elapsed_ms, running}, target_hrs,
open_cos [{co_no, label, review}] and return_note."""
if not cards:
return cards
ids = [int(c["id"]) for c in cards]
now = conn.execute("SELECT now()").fetchone()[0]
extras = {int(r["id"]): r for r in packets.rows(conn, EXTRAS_SQL, (ids,))}
cos: dict[int, list[dict[str, Any]]] = {}
for r in packets.rows(
conn,
"SELECT packet_id, co_no, review::text AS review FROM v_open_cos WHERE packet_id = ANY(%s) ORDER BY co_no",
(ids,),
):
cos.setdefault(int(r["packet_id"]), []).append(
{"co_no": r["co_no"], "label": packets.co_label(r["co_no"]), "review": r["review"]}
)
for c in cards:
x = extras.get(int(c["id"])) or {}
if x.get("clock_elapsed_ms") is not None:
clock = packets.clock_of({**x, "closed_at": None}, now)
c["clock"] = {"elapsed_ms": clock["elapsed_ms"], "running": clock["running"]}
else:
c["clock"] = {"elapsed_ms": 0, "running": False}
c["welder_stamp"] = x.get("welder_stamp")
c["target_hrs"] = float(x.get("target_hrs") or 0)
c["return_note"] = x.get("return_note")
c["open_cos"] = cos.get(int(c["id"]), [])
return cards
def _cards(conn: Any, pp: policy.Principal, ids: Iterable[int], **filters: Any) -> list[dict[str, Any]]:
want = list(ids)
if not want:
return []
cards = packets.list_cards(conn, pp, {**filters, "ids": want, "limit": BOARD_LIMIT})
cards.sort(key=lambda c: (c["updated_at"], c["id"]))
return add_shop_fields(conn, cards)
# ---------------------------------------------------------------- scope
def shop_departments(conn: Any) -> list[dict[str, Any]]:
return packets.rows(
conn, "SELECT code, name, weld FROM departments WHERE kind = 'shop' AND active ORDER BY sort, code"
)
def board_scope(conn: Any, pp: policy.Principal) -> list[dict[str, Any]]:
"""The shop departments the principal leads or supervises (admin: every active shop department), each with
its level name."""
out: list[dict[str, Any]] = []
for d in shop_departments(conn):
level = pp.level(d["code"])
if level >= LEAD:
out.append({**d, "level": LEVEL_NAMES[min(level, SUPERVISOR)]})
elif pp.admin:
out.append({**d, "level": "admin"})
return out
# ---------------------------------------------------------------- the supervisor board
SCOPE_SQL = """
SELECT p.id FROM packets p JOIN units u ON u.id = p.unit_id
WHERE p.removed_at IS NULL AND p.dept_code = ANY(%(depts)s) AND p.status::text = ANY(%(statuses)s) {extra}
ORDER BY p.updated_at, p.id
LIMIT %(limit)s
"""
COS_SQL = """
SELECT c.packet_id, c.co_no, c.review::text AS review, c.hold, c.pending_depts, co.reason, co.ecn, co.issued_at,
p.dept_code, p.op, p.title, pr.number || u.lot AS unit_number
FROM v_open_cos c
JOIN change_orders co ON co.id = c.co_id
JOIN packets p ON p.id = c.packet_id
JOIN units u ON u.id = p.unit_id
JOIN projects pr ON pr.id = u.project_id
WHERE p.removed_at IS NULL AND (p.dept_code = ANY(%(depts)s) OR c.pending_depts && %(depts)s::text[]) {extra}
ORDER BY co.issued_at NULLS LAST, c.packet_id, c.co_no
LIMIT %(limit)s
"""
def _filter_sql(project_id: int | None, unit_id: int | None) -> tuple[str, dict[str, Any]]:
terms: list[str] = []
params: dict[str, Any] = {}
if project_id is not None:
terms.append("u.project_id = %(project_id)s")
params["project_id"] = int(project_id)
if unit_id is not None:
terms.append("p.unit_id = %(unit_id)s")
params["unit_id"] = int(unit_id)
return "".join(f" AND {t}" for t in terms), params
def board(
conn: Any,
pp: policy.Principal,
*,
dept: str | None = None,
project_id: int | None = None,
unit_id: int | None = None,
filters: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""GET /api/shop/board. 403 forbidden when the principal leads no department, or names one outside the scope.
`filters` are the find bar's (search.parse_filters): they narrow the cards of every column and the station
grid. The KPIs follow the department, project and unit only."""
scope = board_scope(conn, pp)
if not scope:
raise ApiError(403, "forbidden", MSG_NO_LEAD)
if dept and dept.lower() != "all":
scope = [d for d in scope if d["code"] == dept]
if not scope:
raise ApiError(403, "forbidden", MSG_NOT_YOURS)
codes = [d["code"] for d in scope]
extra, fparams = _filter_sql(project_id, unit_id)
ids = [
int(r[0])
for r in conn.execute(
SCOPE_SQL.format(extra=extra),
{"depts": codes, "statuses": list(BOARD_STATUSES), "limit": BOARD_LIMIT, **fparams},
)
]
narrow = search.narrowing(filters or {})
cards = _cards(conn, pp, ids, **search.board_filters(filters or {}))
columns: dict[str, list[dict[str, Any]]] = {k: [] for k in COLUMNS.values()}
at_station: list[dict[str, Any]] = []
for c in cards:
if c["status"] in policy.ACTIVE:
at_station.append(c)
elif c["status"] in COLUMNS:
columns[COLUMNS[c["status"]]].append(c)
stations = packets.rows(
conn,
"SELECT id, name, dept_code FROM stations WHERE active AND dept_code = ANY(%s) ORDER BY dept_code, sort, name",
(codes,),
)
by_station: dict[Any, list[dict[str, Any]]] = {}
for c in at_station:
sid = (c.get("station") or {}).get("id")
by_station.setdefault(sid, []).append(c)
depts = []
for d in scope:
depts.append(
{
"code": d["code"],
"name": d["name"],
"weld": bool(d["weld"]),
"level": d["level"],
"stations": [
{"id": s["id"], "name": s["name"], "packets": by_station.get(s["id"], [])}
for s in stations
if s["dept_code"] == d["code"]
],
}
)
cos = []
for r in packets.rows(conn, COS_SQL.format(extra=extra), {"depts": codes, "limit": BOARD_LIMIT, **fparams}):
pending = list(r["pending_depts"] or [])
cos.append(
{
"packet_id": r["packet_id"],
"unit_number": r["unit_number"],
"op": r["op"],
"title": r["title"],
"dept_code": r["dept_code"],
"co_no": r["co_no"],
"label": packets.co_label(r["co_no"]),
"review": r["review"],
"hold": bool(r["hold"]),
"pending_depts": pending,
"mine": r["review"] == "approved" and any(can_verify(pp, d) for d in pending),
"reason": r["reason"],
"ecn": r["ecn"],
"issued_at": r["issued_at"],
}
)
if narrow:
kpis = _board_kpis(conn, ids)
else:
kpis = {
"ready": len(columns["ready"]),
"at_stations": len(at_station),
"review": len(columns["review"]),
"elapsed_ms": sum(int(c["clock"]["elapsed_ms"]) for c in at_station),
"target_hrs": round(sum(float(c["target_hrs"]) for c in at_station), 3),
}
return {
"depts": depts,
"kpis": kpis,
"cos": cos,
"columns": columns,
"facets": search.facets(conn, ids=ids),
}
def _board_kpis(conn: Any, ids: list[int]) -> dict[str, Any]:
"""The supervisor KPIs over the board's whole scope, when the find bar narrows the cards."""
counts = {"ready": 0, "review": 0, "at_stations": 0}
elapsed = 0
target = 0.0
if ids:
now = conn.execute("SELECT now()").fetchone()[0]
for r in packets.rows(conn, KPI_SQL, (ids,)):
st = r["status"]
if st in ("ready", "review"):
counts[st] += 1
elif st in policy.ACTIVE:
counts["at_stations"] += 1
if r.get("clock_elapsed_ms") is not None:
elapsed += int(packets.clock_of({**r, "closed_at": None}, now)["elapsed_ms"])
target += float(r.get("target_hrs") or 0)
return {**counts, "elapsed_ms": elapsed, "target_hrs": round(target, 3)}
def can_verify(pp: policy.Principal, dept: str) -> bool:
"""A supervisor of the department (admin too: the verify itself asks for the IT reason)."""
if pp.admin:
return True
if dept in ("WH", "QC"):
return dept in pp.support_sup
return pp.level(dept) >= SUPERVISOR
# ---------------------------------------------------------------- stations and the stall board
def stations_for(conn: Any, pp: policy.Principal) -> list[dict[str, Any]]:
"""The stations the principal may pick on the Stall screen: every station of each shop department they belong
to at any level, honouring a worker's station limits. Admin: every active station."""
out: list[dict[str, Any]] = []
rows = packets.rows(
conn,
"SELECT s.id, s.name, s.dept_code, d.name AS dept_name FROM stations s"
" JOIN departments d ON d.code = s.dept_code WHERE s.active AND d.active AND d.kind = 'shop'"
" ORDER BY d.sort, d.code, s.sort, s.name",
)
for r in rows:
level = pp.level(r["dept_code"])
if not pp.admin:
if level == 0:
continue
if level < LEAD and pp.station_limits and int(r["id"]) not in pp.station_limits:
continue
out.append({"dept_code": r["dept_code"], "dept_name": r["dept_name"], "id": r["id"], "name": r["name"]})
return out
def stall(conn: Any, pp: policy.Principal, station_id: int, filters: Mapping[str, Any] | None = None) -> dict[str, Any]:
"""GET /api/shop/stall: the packets at one station. 403 when the station is not in the principal's list.
`filters` (the find bar's) narrow the station's cards; Mine is the station's own work."""
mine_list = {int(s["id"]): s for s in stations_for(conn, pp)}
st = mine_list.get(int(station_id))
if st is None:
raise ApiError(403, "forbidden", MSG_NO_STATION)
found = packets.rows(
conn,
"SELECT id, status::text AS status FROM packets WHERE station_id = %s AND removed_at IS NULL"
" AND status IN ('assigned', 'in_progress', 'review') ORDER BY updated_at, id LIMIT %s",
(int(station_id), BOARD_LIMIT),
)
ids = [int(r["id"]) for r in found]
cards = _cards(conn, pp, ids, **search.board_filters(filters or {}))
return {
"station": {"id": st["id"], "name": st["name"], "dept_code": st["dept_code"], "dept_name": st["dept_name"]},
"mine": [c for c in cards if c["status"] in policy.ACTIVE],
"submitted": [c for c in cards if c["status"] == "review"],
"facets": search.facets(conn, ids=ids),
}
# ---------------------------------------------------------------- the assign sheet's people
PEOPLE_SQL = """
SELECT u.id, u.display_name, u.welder_stamp, max(CASE r.role::text WHEN 'shop_supervisor' THEN 3
WHEN 'shop_lead' THEN 2 ELSE 1 END) AS level
FROM users u JOIN user_roles r ON r.user_id = u.id
WHERE u.active AND r.dept_code = %s AND r.role::text IN ('shop_worker', 'shop_lead', 'shop_supervisor')
GROUP BY u.id, u.display_name, u.welder_stamp
ORDER BY lower(u.display_name), u.id
"""
def people(conn: Any, dept: str) -> list[dict[str, Any]]:
"""The active users with a shop role in a department, for the welder picker."""
found = packets.rows(conn, PEOPLE_SQL, (dept,))
limits: dict[str, list[int]] = {}
if found:
for uid, sid in conn.execute(
"SELECT user_id, station_id FROM user_station_limits WHERE user_id = ANY(%s) ORDER BY station_id",
([r["id"] for r in found],),
):
limits.setdefault(str(uid), []).append(int(sid))
return [
{
"id": str(r["id"]),
"display_name": r["display_name"],
"stamp": r["welder_stamp"],
"level": LEVEL_NAMES[int(r["level"])],
"station_limits": limits.get(str(r["id"]), []),
}
for r in found
]
def welder_check(conn: Any, dept: str, welder_id: str, station_id: int) -> None:
"""ASSIGN: the welder has a shop role in the department, and a worker with station limits is limited to a set
that holds the station. 422 invalid_welder or station_limit."""
level_row = conn.execute(
"SELECT max(CASE r.role::text WHEN 'shop_supervisor' THEN 3 WHEN 'shop_lead' THEN 2 ELSE 1 END)"
" FROM users u JOIN user_roles r ON r.user_id = u.id WHERE u.id = %s AND u.active AND r.dept_code = %s"
" AND r.role::text IN ('shop_worker', 'shop_lead', 'shop_supervisor')",
(welder_id, dept),
).fetchone()
level = int(level_row[0]) if level_row and level_row[0] is not None else 0
if level == 0:
raise ApiError(422, "invalid_welder", "Pick a welder in this department.")
if level >= LEAD:
return
limits = packets.rows(
conn,
"SELECT s.id, s.name FROM user_station_limits l JOIN stations s ON s.id = l.station_id"
" WHERE l.user_id = %s ORDER BY s.dept_code, s.sort, s.name",
(welder_id,),
)
if limits and int(station_id) not in {int(x["id"]) for x in limits}:
name = conn.execute("SELECT display_name FROM users WHERE id = %s", (welder_id,)).fetchone()[0]
names = ", ".join(str(x["name"]) for x in limits)
raise ApiError(422, "station_limit", f"{name} is limited to {names}.")