Phase 5: Supervisor and Stall screens, weld map, heat table, clock and CO verification
The shop at parity with the old build. Supervisor screen (department chips, CO headline, numbers, Change orders
on hold, station grid with clocks and CO tags, boards) and Stall screen (station picker, hero, My packets,
Submitted). Shop steps on the packet page: assign with station, welder picker and stamp; pull back; Start, Pause,
Resume; Submit with a PIN signature and the missing steps as links; review with Send to Q.C., Return to station
and Skip Q.C. route; a lead's read-only review. Weld map (pdf.js viewer with pan, pinch, zoom, full screen; marks
with leader lines; weld sheet; weld record with field CAS; carried marks). Heat map (heat table placed, dragged,
sized, removed; legacy amber tags; borrowed heat map read-only). Checklist tab with templates from settings.
VOID stamps on voided pages. Change orders: CO-n labels, supervisor verify with signature, approval copies the
heat table to the new sheet and the BOM merge keeps the shop's values. Server clock of record with VISUAL ticket
hours beside it. Presence per packet over SSE. Migration 0006 (bfx, bfy, carried_from, checklist_templates).
New endpoints /api/shop/board, /api/shop/stations, /api/shop/stall, /api/departments/{code}/people.
Tests: policy oracle for every shop action, role and status; clock arithmetic; concurrent inserts; CO apply;
frontend geometry, heat table, clock, checklist, weld sheet, screens; seven Playwright scenarios. Docs updated.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
"""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
|
||||
|
||||
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)
|
||||
"""
|
||||
|
||||
|
||||
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,
|
||||
) -> dict[str, Any]:
|
||||
"""GET /api/shop/board. 403 forbidden when the principal leads no department, or names one outside the scope."""
|
||||
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},
|
||||
)
|
||||
]
|
||||
cards = _cards(conn, pp, ids)
|
||||
|
||||
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"],
|
||||
}
|
||||
)
|
||||
|
||||
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}
|
||||
|
||||
|
||||
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) -> dict[str, Any]:
|
||||
"""GET /api/shop/stall: the packets at one station. 403 when the station is not in the principal's list."""
|
||||
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),
|
||||
)
|
||||
cards = _cards(conn, pp, [int(r["id"]) for r in found])
|
||||
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"],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 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}.")
|
||||
Reference in New Issue
Block a user