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:
@@ -1,14 +1,19 @@
|
||||
"""The department checklists and the Q.C. checks, ported from the old checklistFor(), qcChecksFor() and qcMissing().
|
||||
|
||||
Weld departments get the welding list, the others the plain list. The drawing, rev and WPS are filled into the text.
|
||||
The lists live in the settings row `checklist_templates` (migration 0006); the defaults here are the fallback.
|
||||
The keys are the old keys, so a checklist stored as {key: {done, at, by, stamp}} reads the same.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Iterable, Mapping
|
||||
from typing import Any
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
NO_REV = "-"
|
||||
NDE_NONE = ("", "none", "none required")
|
||||
NDE_OK = ("acceptable", "accept", "accepted")
|
||||
@@ -19,31 +24,95 @@ def _item(group: str, key: str, text: str) -> dict[str, str]:
|
||||
return {"group": group, "key": key, "text": text}
|
||||
|
||||
|
||||
def checklist_for(weld: bool, drawing: str | None, rev: str | None, wps: str | None) -> list[dict[str, str]]:
|
||||
DWG_TEXT = "Drawing {drawing} Rev {rev} matches the packet"
|
||||
|
||||
# The defaults. The settings row `checklist_templates` holds the same lists (migration 0006); these are the fallback
|
||||
# when that row is missing or does not parse.
|
||||
DEFAULT_TEMPLATES: dict[str, list[dict[str, str]]] = {
|
||||
"weld": [
|
||||
_item("Before welding", "dwg", DWG_TEXT),
|
||||
_item("Before welding", "heat", "Heat numbers on material match the Materials tab"),
|
||||
_item("Before welding", "wps", "WPS reviewed: {wps}"),
|
||||
_item("Before welding", "fit", "Fit-up and dimensions checked against the drawing"),
|
||||
_item("Before welding", "pre", "Preheat and interpass temperature per WPS"),
|
||||
_item("After welding", "vt", "Visual inspection of all welds acceptable"),
|
||||
_item("After welding", "stamp", "Welds stamped with welder ID"),
|
||||
_item("After welding", "clean", "Slag and spatter removed"),
|
||||
_item("After welding", "qty", "Completed quantity matches each work order"),
|
||||
],
|
||||
"plain": [
|
||||
_item("Before starting", "dwg", DWG_TEXT),
|
||||
_item("Before starting", "prev", "Previous department's work complete and released"),
|
||||
_item("Before starting", "mat", "Material and parts on hand match the packet"),
|
||||
_item("When done", "work", "Work completed per drawing and procedure"),
|
||||
_item("When done", "insp", "Self-inspection done; no damage to finished surfaces"),
|
||||
_item("When done", "qty", "Completed quantity matches each work order"),
|
||||
],
|
||||
}
|
||||
SETTINGS_KEY = "checklist_templates"
|
||||
RX_KEY = re.compile(r"^[a-z][a-z0-9_]{0,29}$")
|
||||
RX_PLACEHOLDER = re.compile(r"\{(drawing|rev|wps)\}")
|
||||
TEXT_MAX = 200
|
||||
ITEMS_MAX = 40
|
||||
|
||||
|
||||
def _valid_list(value: Any) -> list[dict[str, str]] | None:
|
||||
"""A template list from settings, or None when it does not have the right shape."""
|
||||
if not isinstance(value, list) or not value or len(value) > ITEMS_MAX:
|
||||
return None
|
||||
out: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for it in value:
|
||||
if not isinstance(it, Mapping):
|
||||
return None
|
||||
group, key, text = it.get("group"), it.get("key"), it.get("text")
|
||||
if not (isinstance(group, str) and isinstance(key, str) and isinstance(text, str)):
|
||||
return None
|
||||
if not RX_KEY.match(key) or key in seen or not group.strip() or not text.strip():
|
||||
return None
|
||||
seen.add(key)
|
||||
out.append(_item(group.strip()[:TEXT_MAX], key, text.strip()[:TEXT_MAX]))
|
||||
return out
|
||||
|
||||
|
||||
def parse_templates(value: Any) -> dict[str, list[dict[str, str]]] | None:
|
||||
"""The `checklist_templates` settings value, checked. None when it is malformed."""
|
||||
if not isinstance(value, Mapping):
|
||||
return None
|
||||
weld, plain = _valid_list(value.get("weld")), _valid_list(value.get("plain"))
|
||||
if weld is None or plain is None:
|
||||
return None
|
||||
return {"weld": weld, "plain": plain}
|
||||
|
||||
|
||||
def load_templates(conn: Any) -> dict[str, list[dict[str, str]]]:
|
||||
"""The department checklists from settings, else the defaults in code (with a warning when the row is bad)."""
|
||||
row = conn.execute("SELECT value FROM settings WHERE key = %s", (SETTINGS_KEY,)).fetchone()
|
||||
if row is None:
|
||||
return DEFAULT_TEMPLATES
|
||||
parsed = parse_templates(row[0])
|
||||
if parsed is None:
|
||||
log.warning("settings %s does not parse; using the default checklists", SETTINGS_KEY)
|
||||
return DEFAULT_TEMPLATES
|
||||
return parsed
|
||||
|
||||
|
||||
def _fill(text: str, drawing: str | None, rev: str | None, wps: str | None) -> str:
|
||||
values = {"drawing": drawing or "", "rev": rev or NO_REV, "wps": wps or "per drawing"}
|
||||
return " ".join(RX_PLACEHOLDER.sub(lambda m: values[m.group(1)], text).split())
|
||||
|
||||
|
||||
def checklist_for(
|
||||
weld: bool,
|
||||
drawing: str | None,
|
||||
rev: str | None,
|
||||
wps: str | None,
|
||||
templates: Mapping[str, list[dict[str, str]]] | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
"""The stage checklist for a department, as a flat list of {group, key, text} in display order."""
|
||||
dwg = f"Drawing {drawing or ''} Rev {rev or NO_REV} matches the packet".replace(" ", " ")
|
||||
if not weld:
|
||||
g1, g2 = "Before starting", "When done"
|
||||
return [
|
||||
_item(g1, "dwg", dwg),
|
||||
_item(g1, "prev", "Previous department's work complete and released"),
|
||||
_item(g1, "mat", "Material and parts on hand match the packet"),
|
||||
_item(g2, "work", "Work completed per drawing and procedure"),
|
||||
_item(g2, "insp", "Self-inspection done; no damage to finished surfaces"),
|
||||
_item(g2, "qty", "Completed quantity matches each work order"),
|
||||
]
|
||||
g1, g2 = "Before welding", "After welding"
|
||||
return [
|
||||
_item(g1, "dwg", dwg),
|
||||
_item(g1, "heat", "Heat numbers on material match the Materials tab"),
|
||||
_item(g1, "wps", f"WPS reviewed: {wps or 'per drawing'}"),
|
||||
_item(g1, "fit", "Fit-up and dimensions checked against the drawing"),
|
||||
_item(g1, "pre", "Preheat and interpass temperature per WPS"),
|
||||
_item(g2, "vt", "Visual inspection of all welds acceptable"),
|
||||
_item(g2, "stamp", "Welds stamped with welder ID"),
|
||||
_item(g2, "clean", "Slag and spatter removed"),
|
||||
_item(g2, "qty", "Completed quantity matches each work order"),
|
||||
]
|
||||
t = templates or DEFAULT_TEMPLATES
|
||||
items = t.get("weld" if weld else "plain") or DEFAULT_TEMPLATES["weld" if weld else "plain"]
|
||||
return [_item(i["group"], i["key"], _fill(i["text"], drawing, rev, wps)) for i in items]
|
||||
|
||||
|
||||
def qc_items_for(
|
||||
@@ -66,8 +135,8 @@ def qc_items_for(
|
||||
]
|
||||
|
||||
|
||||
def checklist_keys(weld: bool) -> list[str]:
|
||||
return [i["key"] for i in checklist_for(weld, None, None, None)]
|
||||
def checklist_keys(weld: bool, templates: Mapping[str, list[dict[str, str]]] | None = None) -> list[str]:
|
||||
return [i["key"] for i in checklist_for(weld, None, None, None, templates)]
|
||||
|
||||
|
||||
def qc_keys() -> list[str]:
|
||||
@@ -81,10 +150,12 @@ def is_done(value: Any) -> bool:
|
||||
return isinstance(value, Mapping) and value.get("done") is True
|
||||
|
||||
|
||||
def checklist_complete(checklist: Mapping[str, Any] | None, weld: bool) -> bool:
|
||||
def checklist_complete(
|
||||
checklist: Mapping[str, Any] | None, weld: bool, templates: Mapping[str, list[dict[str, str]]] | None = None
|
||||
) -> bool:
|
||||
"""Every key of the department checklist is done. An empty list counts as complete."""
|
||||
ck = checklist or {}
|
||||
return all(is_done(ck.get(k)) for k in checklist_keys(weld))
|
||||
return all(is_done(ck.get(k)) for k in checklist_keys(weld, templates))
|
||||
|
||||
|
||||
def _plural(n: int, word: str) -> str:
|
||||
|
||||
@@ -39,6 +39,10 @@ SEAL_EXCLUDE = (
|
||||
"qc_items",
|
||||
"flag",
|
||||
"visual_state",
|
||||
"server_now",
|
||||
"target_hrs",
|
||||
"labor",
|
||||
"heat_src",
|
||||
)
|
||||
|
||||
|
||||
@@ -173,14 +177,24 @@ def policy_principal_for(conn: Any, p: deps.Principal) -> policy.Principal:
|
||||
return policy.principal_from_roles(str(p.user_id), p.roles, station_limits=limits, session_kind=p.kind)
|
||||
|
||||
|
||||
def co_hold_why(open_cos: Iterable[Mapping[str, Any]], dept_names: Mapping[str, str]) -> str:
|
||||
def co_label(co_no: Any) -> str:
|
||||
"""The change order id shown everywhere: CO-2."""
|
||||
return f"CO-{co_no}"
|
||||
|
||||
|
||||
def co_hold_why(open_cos: Iterable[Mapping[str, Any]], labels: Mapping[str, str]) -> str:
|
||||
"""The CO hold text, one sentence per open change order. `labels` maps a department code to its display name
|
||||
(dept_labels: "03 Weld Parts", "Warehouse")."""
|
||||
parts: list[str] = []
|
||||
for co in open_cos:
|
||||
label = co_label(co["co_no"])
|
||||
if str(co.get("review")) == "pending":
|
||||
parts.append(f"Change order {co['co_no']} is on hold.")
|
||||
parts.append(
|
||||
f"{label}: Clerical is reviewing a change from VISUAL. Held until Clerical approves or rejects it."
|
||||
)
|
||||
else:
|
||||
names = ", ".join(dept_names.get(c, c) for c in co.get("pending_depts") or [])
|
||||
parts.append(f"Change order {co['co_no']} is waiting for verification by {names}.")
|
||||
names = ", ".join(labels.get(c, c) for c in co.get("pending_depts") or [])
|
||||
parts.append(f"Change order {label} must be verified first by: {names}.")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
@@ -188,6 +202,17 @@ def dept_names(conn: Any) -> dict[str, str]:
|
||||
return {r[0]: r[1] for r in conn.execute("SELECT code, name FROM departments")}
|
||||
|
||||
|
||||
def dept_label(code: str, name: str | None, kind: str | None) -> str:
|
||||
"""A department in texts: a shop department as code and name ("03 Weld Parts"), a support one by name."""
|
||||
if kind == "shop":
|
||||
return f"{code} {name or ''}".strip()
|
||||
return name or code
|
||||
|
||||
|
||||
def dept_labels(conn: Any) -> dict[str, str]:
|
||||
return {r[0]: dept_label(r[0], r[1], r[2]) for r in conn.execute("SELECT code, name, kind::text FROM departments")}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the child rows
|
||||
|
||||
|
||||
@@ -215,6 +240,8 @@ class Children:
|
||||
docs: list[dict[str, Any]] = field(default_factory=list)
|
||||
seal: dict[str, Any] | None = None
|
||||
dept_names: dict[str, str] = field(default_factory=dict)
|
||||
dept_labels: dict[str, str] = field(default_factory=dict)
|
||||
templates: dict[str, list[dict[str, str]]] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def open_stage(self) -> dict[str, Any] | None:
|
||||
@@ -233,7 +260,10 @@ def load_children(conn: Any, row: Mapping[str, Any], *, full: bool = True) -> Ch
|
||||
one(conn, "SELECT id, name FROM stations WHERE id = %s", (row["station_id"],)) if row["station_id"] else None
|
||||
)
|
||||
ch = Children(dept=dept, station=station)
|
||||
ch.dept_names = dept_names(conn)
|
||||
for code, name, kind in conn.execute("SELECT code, name, kind::text FROM departments"):
|
||||
ch.dept_names[code] = name
|
||||
ch.dept_labels[code] = dept_label(code, name, kind)
|
||||
ch.templates = checklists.load_templates(conn)
|
||||
ch.pages = rows(conn, "SELECT * FROM packet_pages WHERE packet_id = %s ORDER BY n", (pid,))
|
||||
ch.bom = rows(conn, "SELECT * FROM bom_lines WHERE packet_id = %s ORDER BY item", (pid,))
|
||||
shorts = rows(conn, "SELECT * FROM bom_shorts WHERE packet_id = %s AND closed_at IS NULL", (pid,))
|
||||
@@ -305,6 +335,13 @@ def needs_heat(line: Mapping[str, Any]) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def dept_welds(row: Mapping[str, Any], ch: Children) -> int:
|
||||
"""The live welds of the packet's current department. A weld row without a department (written outside the API)
|
||||
counts for the current one. Welds an earlier department logged are carried, not counted (the old missing())."""
|
||||
dept = str(row["dept_code"])
|
||||
return sum(1 for w in ch.welds if w.get("dept_code") in (None, dept))
|
||||
|
||||
|
||||
def state_from(row: Mapping[str, Any], ch: Children) -> policy.PacketState:
|
||||
weld = bool(ch.dept.get("weld"))
|
||||
live_bom = [b for b in ch.bom if not b["na"]]
|
||||
@@ -322,15 +359,16 @@ def state_from(row: Mapping[str, Any], ch: Children) -> policy.PacketState:
|
||||
source_kind=str(row["source_kind"]),
|
||||
weld_dept=weld,
|
||||
co_hold=bool(ch.open_cos),
|
||||
co_hold_why=co_hold_why(ch.open_cos, ch.dept_names),
|
||||
co_hold_why=co_hold_why(ch.open_cos, ch.dept_labels),
|
||||
open_shorts=len(ch.shorts),
|
||||
drawing_attached=any(pg["type"] == "drawing" and pg["void_at"] is None for pg in ch.pages),
|
||||
heat_map=row["heat_map"],
|
||||
bom_total=len(live_bom),
|
||||
bom_ready=sum(1 for b in live_bom if b["pulled"] or b["id"] in ch.shorts),
|
||||
bom_missing_heat=sum(1 for b in live_bom if needs_heat(b)),
|
||||
welds=len(ch.welds),
|
||||
checklist_complete=stage is not None and checklists.checklist_complete(stage["checklist"], weld),
|
||||
welds=dept_welds(row, ch),
|
||||
checklist_complete=stage is not None
|
||||
and checklists.checklist_complete(stage["checklist"], weld, ch.templates or None),
|
||||
filler_set=stage is not None and bool((stage["filler"] or "").strip()),
|
||||
qc_missing=tuple(checklists.qc_missing(row, ch.qc, ch.welds)),
|
||||
has_qc_sent=ch.qc is not None,
|
||||
@@ -369,6 +407,19 @@ class People:
|
||||
return {"id": str(uid)}
|
||||
return {"id": str(uid), "display_name": self.names.get(str(uid))}
|
||||
|
||||
def name(self, uid: Any) -> str | None:
|
||||
return None if uid is None else self.names.get(str(uid))
|
||||
|
||||
|
||||
def as_uuid(value: Any) -> uuid.UUID | None:
|
||||
"""A uuid from stored text (a checklist entry's `by`), or None when it is not one."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(str(value))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _station_ref(conn: Any, station_id: Any, cache: dict[Any, dict[str, Any] | None]) -> dict[str, Any] | None:
|
||||
if station_id is None:
|
||||
@@ -378,7 +429,40 @@ def _station_ref(conn: Any, station_id: Any, cache: dict[Any, dict[str, Any] | N
|
||||
return cache[station_id]
|
||||
|
||||
|
||||
def _stage(conn: Any, s: Mapping[str, Any], people: People, cache: dict[Any, Any], *, closed: bool) -> dict[str, Any]:
|
||||
def clock_of(s: Mapping[str, Any], now: Any) -> dict[str, Any]:
|
||||
"""A stage clock. elapsed_ms is live: the stored time plus the running span up to the database now. Only the
|
||||
server writes the clock columns; the client adds the time since its fetch for the ticking display only."""
|
||||
stored = int(s["clock_elapsed_ms"] or 0)
|
||||
since = s["clock_running_since"]
|
||||
running = since is not None and s.get("closed_at") is None
|
||||
live = stored
|
||||
if running and now is not None:
|
||||
live += max(0, int((now - since).total_seconds() * 1000))
|
||||
return {
|
||||
"elapsed_ms": live,
|
||||
"stored_ms": stored,
|
||||
"running": running,
|
||||
"running_since": since,
|
||||
"started_at": s["clock_started_at"],
|
||||
}
|
||||
|
||||
|
||||
def _checklist(value: Any, people: People) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {}
|
||||
for k, v in (value or {}).items():
|
||||
if isinstance(v, Mapping):
|
||||
entry = dict(v)
|
||||
if entry.get("by") is not None:
|
||||
entry["by_name"] = people.name(as_uuid(entry["by"]))
|
||||
out[k] = entry
|
||||
else:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _stage(
|
||||
conn: Any, s: Mapping[str, Any], people: People, cache: dict[Any, Any], *, closed: bool, now: Any = None
|
||||
) -> dict[str, Any]:
|
||||
welder = people.ref(s["welder_id"])
|
||||
if welder is not None:
|
||||
welder["stamp"] = s["welder_stamp"]
|
||||
@@ -387,16 +471,12 @@ def _stage(conn: Any, s: Mapping[str, Any], people: People, cache: dict[Any, Any
|
||||
"dept_code": s["dept_code"],
|
||||
"station": _station_ref(conn, s["station_id"], cache),
|
||||
"welder": welder,
|
||||
"checklist": s["checklist"] or {},
|
||||
"checklist": _checklist(s["checklist"], people),
|
||||
"filler": s["filler"],
|
||||
"filler_lot": s["filler_lot"],
|
||||
"preheat": s["preheat"],
|
||||
"notes": s["notes"],
|
||||
"clock": {
|
||||
"elapsed_ms": int(s["clock_elapsed_ms"] or 0),
|
||||
"running_since": s["clock_running_since"],
|
||||
"started_at": s["clock_started_at"],
|
||||
},
|
||||
"clock": clock_of(s, now),
|
||||
"opened_at": s["opened_at"],
|
||||
"row_version": s["row_version"],
|
||||
}
|
||||
@@ -436,8 +516,13 @@ def _qc(q: Mapping[str, Any], people: People) -> dict[str, Any]:
|
||||
|
||||
def _who_ids(row: Mapping[str, Any], ch: Children) -> list[Any]:
|
||||
ids: list[Any] = [row["welder_id"], row["for_sup_id"], row["vclosed_by"], row["removed_by"]]
|
||||
ids += [w["welder_id"] for w in ch.welds]
|
||||
ids += [w["welder_id"] for w in ch.welds] + [w["by"] for w in ch.welds]
|
||||
ids += [s["welder_id"] for s in ch.stages]
|
||||
for s in ch.stages:
|
||||
for v in (s["checklist"] or {}).values():
|
||||
if isinstance(v, Mapping):
|
||||
ids.append(as_uuid(v.get("by")))
|
||||
ids += [h["by"] for h in ch.heat_table] + [pg["void_by"] for pg in ch.pages]
|
||||
if ch.wh:
|
||||
ids.append(ch.wh["by"])
|
||||
if ch.qc:
|
||||
@@ -545,6 +630,7 @@ def build(
|
||||
state = state_from(row, ch)
|
||||
people = People(conn, _who_ids(row, ch), seal=seal)
|
||||
cache: dict[Any, Any] = {}
|
||||
now = None if seal else conn.execute("SELECT now()").fetchone()[0]
|
||||
welder = people.ref(row["welder_id"])
|
||||
if welder is not None:
|
||||
welder["stamp"] = row["welder_stamp"]
|
||||
@@ -585,6 +671,10 @@ def build(
|
||||
"open_cos": [c["co_no"] for c in ch.open_cos],
|
||||
"open_shorts": len(ch.shorts),
|
||||
}
|
||||
if not seal:
|
||||
out["server_now"] = now
|
||||
out["target_hrs"] = target_hrs(ch.work_orders)
|
||||
out["labor"] = labor_of(conn, row)
|
||||
if include_children:
|
||||
out["work_orders"] = [
|
||||
{
|
||||
@@ -608,30 +698,19 @@ def build(
|
||||
| {k: w.get(k) for k in ("dwg", "rev", "partspecs")}
|
||||
for w in ch.work_orders
|
||||
]
|
||||
out["pages"] = [
|
||||
{
|
||||
"n": pg["n"],
|
||||
"type": pg["type"],
|
||||
"asset_id": uid_text(pg["asset_id"]),
|
||||
"pn": pg["pn"],
|
||||
"dwg": pg["dwg"],
|
||||
"rev": pg["rev"],
|
||||
"void": None
|
||||
if pg["void_at"] is None
|
||||
else {"co_id": pg["void_co_id"], "at": pg["void_at"], "new_n": pg["void_new_n"]},
|
||||
"url": f"/_blob/{pg['asset_id']}" if pg["asset_id"] else None,
|
||||
}
|
||||
for pg in ch.pages
|
||||
]
|
||||
out["pages"] = [_page(pg, ch, people) for pg in ch.pages]
|
||||
out["bom"] = [_bom(b, ch, people) for b in ch.bom]
|
||||
out["welds"] = [_weld(w, people) for w in ch.welds]
|
||||
out["heat_tags"] = [
|
||||
{k: t[k] for k in ("tag_no", "page", "item", "fx", "fy", "row_version")} for t in ch.heat_tags
|
||||
]
|
||||
out["heat_table"] = [{k: h[k] for k in ("page", "x", "y", "s")} for h in ch.heat_table]
|
||||
st = ch.open_stage
|
||||
out["stage"] = _stage(conn, st, people, cache, closed=False) if st else None
|
||||
out["stages"] = [_stage(conn, s, people, cache, closed=True) for s in ch.stages if s["closed_at"] is not None]
|
||||
out["welds"] = [_weld(w, people, st) for w in ch.welds]
|
||||
out["heat_tags"] = [_heat_tag(t, st) for t in ch.heat_tags]
|
||||
out["heat_table"] = [
|
||||
{k: h[k] for k in ("page", "x", "y", "s", "carried_from", "at")} | {"by": people.ref(h["by"])}
|
||||
for h in ch.heat_table
|
||||
]
|
||||
out["stage"] = _stage(conn, st, people, cache, closed=False, now=now) if st else None
|
||||
out["stages"] = [
|
||||
_stage(conn, s, people, cache, closed=True, now=now) for s in ch.stages if s["closed_at"] is not None
|
||||
]
|
||||
out["wh"] = (
|
||||
None
|
||||
if ch.wh is None
|
||||
@@ -666,11 +745,15 @@ def build(
|
||||
if ch.seal is None
|
||||
else {"sealed_at": ch.seal["sealed_at"], "key_id": ch.seal["key_id"], "broken_at": ch.seal["broken_at"]}
|
||||
)
|
||||
out["checklist_items"] = checklists.checklist_for(weld, row["drawing"], row["rev"], row["wps"])
|
||||
out["checklist_items"] = checklists.checklist_for(weld, row["drawing"], row["rev"], row["wps"], ch.templates)
|
||||
out["qc_items"] = checklists.qc_items_for(row["drawing"], row["rev"], row["wps"], row["welder_stamp"])
|
||||
out["flag"] = flag_of(row["status"], row["source_kind"], row["flags"], row["drawing_state"], row["visual"])
|
||||
if not seal:
|
||||
out["visual_state"] = visual_state(conn, row)
|
||||
if include_children:
|
||||
from . import shop # shop builds on this module
|
||||
|
||||
out["heat_src"] = shop.heat_src(conn, row, ch)
|
||||
if pp is not None:
|
||||
allowed, blocked = policy.can(pp, state)
|
||||
out["can"] = [str(a) for a in allowed]
|
||||
@@ -682,6 +765,122 @@ def build(
|
||||
# `sub` joined the BOM lines in phase 4; seals made before that do not cover it.
|
||||
for b in out.get("bom") or []:
|
||||
b.pop("sub", None)
|
||||
_seal_strip(out)
|
||||
return out
|
||||
|
||||
|
||||
# Keys phase 5 added inside the child rows. Seals made before phase 5 do not cover them, and they are derived
|
||||
# (carried, labels, pending lists) or volatile (the live clock), so the seal leaves them out.
|
||||
SEAL_PHASE5 = {
|
||||
"welds": ("bfx", "bfy", "dept_code", "stage_id", "carried", "by"),
|
||||
"heat_tags": ("bfx", "bfy", "carried", "carried_from"),
|
||||
"heat_table": ("carried_from", "by", "at"),
|
||||
"change_orders": ("label", "pending_depts", "open"),
|
||||
}
|
||||
SEAL_CLOCK = ("stored_ms", "running")
|
||||
SEAL_VOID = ("co_no", "by", "new")
|
||||
|
||||
|
||||
def _seal_strip(out: dict[str, Any]) -> None:
|
||||
for key, names in SEAL_PHASE5.items():
|
||||
for r in out.get(key) or []:
|
||||
for n in names:
|
||||
r.pop(n, None)
|
||||
for co in out.get("change_orders") or []:
|
||||
for v in (co.get("verified") or {}).values():
|
||||
v.pop("signed", None)
|
||||
for pg in out.get("pages") or []:
|
||||
if pg.get("void"):
|
||||
for n in SEAL_VOID:
|
||||
pg["void"].pop(n, None)
|
||||
for st in [out.get("stage"), *(out.get("stages") or [])]:
|
||||
if not st:
|
||||
continue
|
||||
for n in SEAL_CLOCK:
|
||||
st["clock"].pop(n, None)
|
||||
for v in (st.get("checklist") or {}).values():
|
||||
if isinstance(v, dict):
|
||||
v.pop("by_name", None)
|
||||
|
||||
|
||||
def target_hrs(work_orders: Iterable[Mapping[str, Any]]) -> float:
|
||||
"""The packet's target hours: the sum of its work orders' target hours (0 when none)."""
|
||||
total = 0.0
|
||||
for w in work_orders:
|
||||
try:
|
||||
total += float(w.get("target_hrs") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return round(total, 3)
|
||||
|
||||
|
||||
LABOR_SQL = """
|
||||
SELECT l.wo_base, l.lot, l.split, l.sub, l.seq, l.hours, l.tickets, l.last_ticket, l.observed_at
|
||||
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 = %s
|
||||
ORDER BY w.sort, w.id
|
||||
"""
|
||||
|
||||
|
||||
def labor_of(conn: Any, row: Mapping[str, Any]) -> dict[str, Any] | None:
|
||||
"""VISUAL's labor ticket hours on the packet's work orders, or None for a hand packet or when nothing matches.
|
||||
The station timer stays the clock of record; these hours are shown beside it."""
|
||||
if row["source_kind"] != "visual":
|
||||
return None
|
||||
found = rows(conn, LABOR_SQL, (row["id"],))
|
||||
if not found:
|
||||
return None
|
||||
per = [
|
||||
{
|
||||
"wo_base": r["wo_base"],
|
||||
"lot": r["lot"],
|
||||
"split": r["split"],
|
||||
"sub": r["sub"],
|
||||
"seq": r["seq"],
|
||||
"hours": float(r["hours"] or 0),
|
||||
"tickets": int(r["tickets"] or 0),
|
||||
"last_ticket": r["last_ticket"],
|
||||
}
|
||||
for r in found
|
||||
]
|
||||
return {
|
||||
"hours": round(sum(x["hours"] for x in per), 3),
|
||||
"tickets": sum(x["tickets"] for x in per),
|
||||
"per_wo": per,
|
||||
"observed_at": max(r["observed_at"] for r in found),
|
||||
}
|
||||
|
||||
|
||||
def _page(pg: Mapping[str, Any], ch: Children, people: People) -> dict[str, Any]:
|
||||
void: dict[str, Any] | None = None
|
||||
if pg["void_at"] is not None:
|
||||
co_no = next((c["co_no"] for c in ch.change_orders if c["id"] == pg["void_co_id"]), None)
|
||||
new = next((x for x in ch.pages if pg["void_new_n"] is not None and x["n"] == pg["void_new_n"]), None)
|
||||
void = {
|
||||
"co_id": pg["void_co_id"],
|
||||
"at": pg["void_at"],
|
||||
"new_n": pg["void_new_n"],
|
||||
"co_no": co_no,
|
||||
"by": people.ref(pg["void_by"]),
|
||||
"new": None if new is None else {"n": new["n"], "dwg": new["dwg"], "rev": new["rev"]},
|
||||
}
|
||||
return {
|
||||
"n": pg["n"],
|
||||
"type": pg["type"],
|
||||
"asset_id": uid_text(pg["asset_id"]),
|
||||
"pn": pg["pn"],
|
||||
"dwg": pg["dwg"],
|
||||
"rev": pg["rev"],
|
||||
"void": void,
|
||||
"url": f"/_blob/{pg['asset_id']}" if pg["asset_id"] else None,
|
||||
}
|
||||
|
||||
|
||||
def _heat_tag(t: Mapping[str, Any], open_stage: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
out = {k: t[k] for k in ("tag_no", "page", "item", "fx", "fy", "bfx", "bfy", "carried_from", "row_version")}
|
||||
out["carried"] = open_stage is not None and t["at"] is not None and t["at"] < open_stage["opened_at"]
|
||||
return out
|
||||
|
||||
|
||||
@@ -737,11 +936,33 @@ def _bom(b: Mapping[str, Any], ch: Children, people: People) -> dict[str, Any]:
|
||||
return out
|
||||
|
||||
|
||||
def _weld(w: Mapping[str, Any], people: People) -> dict[str, Any]:
|
||||
keys = ("weld_no", "page", "fx", "fy", "wo_base", "piece", "type", "size", "process", "stamp", "at", "row_version")
|
||||
out = {k: w[k] for k in keys}
|
||||
WELD_KEYS = (
|
||||
"weld_no",
|
||||
"page",
|
||||
"fx",
|
||||
"fy",
|
||||
"bfx",
|
||||
"bfy",
|
||||
"wo_base",
|
||||
"piece",
|
||||
"type",
|
||||
"size",
|
||||
"process",
|
||||
"stamp",
|
||||
"dept_code",
|
||||
"stage_id",
|
||||
"at",
|
||||
"row_version",
|
||||
)
|
||||
|
||||
|
||||
def _weld(w: Mapping[str, Any], people: People, open_stage: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
||||
out = {k: w[k] for k in WELD_KEYS}
|
||||
out["items"] = list(w["items"] or [])
|
||||
out["welder"] = people.ref(w["welder_id"])
|
||||
out["by"] = people.ref(w["by"])
|
||||
open_id = open_stage["id"] if open_stage else None
|
||||
out["carried"] = w["stage_id"] is not None and w["stage_id"] != open_id
|
||||
return out
|
||||
|
||||
|
||||
@@ -773,11 +994,23 @@ def _co(c: Mapping[str, Any], ch: Children, people: People) -> dict[str, Any]:
|
||||
{k: i[k] for k in ("old_page", "old_dwg", "old_rev", "new_page", "new_dwg", "new_rev", "added")}
|
||||
for i in ch.co_items.get(c["id"], [])
|
||||
]
|
||||
out["required"] = ch.co_required.get(c["id"], [])
|
||||
out["verified"] = {
|
||||
v["dept_code"]: {"by": people.ref(v["by"]), "at": v["at"], "note": v["note"]}
|
||||
required = ch.co_required.get(c["id"], [])
|
||||
verified: dict[str, Any] = {
|
||||
v["dept_code"]: {
|
||||
"by": people.ref(v["by"]),
|
||||
"at": v["at"],
|
||||
"note": v["note"],
|
||||
"signed": v["signature_id"] is not None,
|
||||
}
|
||||
for v in ch.co_verified.get(c["id"], [])
|
||||
}
|
||||
out["required"] = required
|
||||
out["verified"] = verified
|
||||
out["label"] = co_label(c["co_no"])
|
||||
out["pending_depts"] = [d for d in required if d not in verified]
|
||||
out["open"] = (c["review"] == "approved" and bool(out["pending_depts"])) or (
|
||||
c["review"] == "pending" and bool(c["hold"])
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@@ -955,7 +1188,7 @@ def list_cards(conn: Any, policy_principal: policy.Principal, filters: Mapping[s
|
||||
sql = CARD_SQL + (" WHERE " + " AND ".join(where) if where else "")
|
||||
sql += " ORDER BY p.updated_at DESC, p.id DESC LIMIT %s"
|
||||
params.append(int(filters.get("limit") or 500))
|
||||
names = dept_names(conn)
|
||||
names = dept_labels(conn)
|
||||
transitions = {str(a) for a in policy.TRANSITIONS}
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows(conn, sql, params):
|
||||
|
||||
@@ -63,7 +63,7 @@ LABELS: dict[policy.Action, str] = {
|
||||
A.UNASSIGN: "Taken off the station",
|
||||
A.PULL_BACK: "Pulled back from the station",
|
||||
A.START: "Work started",
|
||||
A.SUBMIT: "Submitted to the supervisor",
|
||||
A.SUBMIT: "Submitted to supervisor",
|
||||
A.RETURN_TO_STATION: "Returned to the station",
|
||||
A.SEND_TO_QC: "Sent to Q.C.",
|
||||
A.ROUTE_FROM_REVIEW: "Routed to another department",
|
||||
@@ -127,6 +127,8 @@ class Write:
|
||||
noop: bool = False
|
||||
from_status: str = ""
|
||||
from_dept: str = ""
|
||||
children: Any = None # packets.Children loaded under the lock (full=False)
|
||||
more_logs: list[tuple[str, str | None]] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def id(self) -> int:
|
||||
@@ -146,6 +148,10 @@ class Write:
|
||||
if note is not None:
|
||||
self.note = note
|
||||
|
||||
def log_also(self, action_label: str, note: str | None = None) -> None:
|
||||
"""One more packet_log line for this write, written after the main one (same user, no signature)."""
|
||||
self.more_logs.append((action_label, note))
|
||||
|
||||
def changed(self, *names: str) -> None:
|
||||
"""Record the fields this write changed (they go into the audit detail)."""
|
||||
for n in names:
|
||||
@@ -331,6 +337,7 @@ def packet_write(
|
||||
request=request,
|
||||
from_status=str(row["status"]),
|
||||
from_dept=str(row["dept_code"]),
|
||||
children=ch,
|
||||
)
|
||||
meaning = decision.signature
|
||||
needs = bool(meaning) if require_sign is None else require_sign
|
||||
@@ -398,6 +405,20 @@ def _finish(w: Write) -> None:
|
||||
w.transition_id,
|
||||
),
|
||||
)
|
||||
for extra_label, extra_note in w.more_logs:
|
||||
conn.execute(
|
||||
"INSERT INTO packet_log (packet_id, by, by_name, role, action, note, it_reason)"
|
||||
" VALUES (%s, %s, %s, %s, %s, %s, %s)",
|
||||
(
|
||||
w.id,
|
||||
w.user_id,
|
||||
w.principal.user["display_name"],
|
||||
w.principal.role_snapshot,
|
||||
extra_label,
|
||||
clean_opt(extra_note, LIMITS["note"]) if extra_note else None,
|
||||
w.it_reason,
|
||||
),
|
||||
)
|
||||
detail: dict[str, Any] = {"from": w.from_status, "to": status, "dept": dept}
|
||||
if w.from_dept != dept:
|
||||
detail["from_dept"] = w.from_dept
|
||||
@@ -563,6 +584,64 @@ def _release_text(w: Write) -> None:
|
||||
w.log(LABELS[A.WH_RELEASE], note)
|
||||
|
||||
|
||||
def hours_text(ms: Any) -> str:
|
||||
"""Stage clock hours with one decimal: 1.5 h."""
|
||||
return f"{int(ms or 0) / 3600000:.1f} h"
|
||||
|
||||
|
||||
def _station_name(w: Write, station_id: Any) -> str | None:
|
||||
if station_id is None:
|
||||
return None
|
||||
ch = w.children
|
||||
if ch is not None and ch.station and ch.station.get("id") == station_id:
|
||||
return str(ch.station["name"])
|
||||
r = w.conn.execute("SELECT name FROM stations WHERE id = %s", (station_id,)).fetchone()
|
||||
return str(r[0]) if r else None
|
||||
|
||||
|
||||
def _dept_text(w: Write, code: str) -> str:
|
||||
labels = getattr(w.children, "dept_labels", None) or packets.dept_labels(w.conn)
|
||||
return str(labels.get(code, code))
|
||||
|
||||
|
||||
def _shop_texts(w: Write, a: policy.Action, to_dept: str, stamp: str | None = None) -> None:
|
||||
"""The packet_log label and note of a shop step (the old click handlers' texts)."""
|
||||
ex = w.extra
|
||||
here = _station_name(w, w.packet["station_id"]) or "the station"
|
||||
note = _note(w)
|
||||
if a is A.ASSIGN:
|
||||
station = _station_name(w, int(ex["station_id"])) or "a station"
|
||||
r = w.conn.execute("SELECT display_name FROM users WHERE id = %s", (str(ex["welder_id"]),)).fetchone()
|
||||
who = str(r[0]) if r else ""
|
||||
w.log(f"Sent to {station}", f"{who} [{stamp}]" if stamp else who)
|
||||
elif a is A.UNASSIGN:
|
||||
w.log(f"Taken off {here}", note)
|
||||
elif a is A.PULL_BACK:
|
||||
w.log(f"Pulled back from {here}", note)
|
||||
elif a is A.START:
|
||||
w.log(LABELS[A.START], " · ".join(x for x in (here if w.packet["station_id"] else None, note) if x) or None)
|
||||
elif a is A.SUBMIT:
|
||||
r = w.conn.execute(
|
||||
"SELECT clock_elapsed_ms FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (w.id,)
|
||||
).fetchone()
|
||||
n = packets.dept_welds(w.packet, w.children) if w.children is not None else 0
|
||||
text = f"{n} welds · {hours_text(r[0] if r else 0)}"
|
||||
w.log(LABELS[A.SUBMIT], f"{text} · {note}" if note else text)
|
||||
elif a is A.RETURN_TO_STATION:
|
||||
w.log(f"Returned to {here}", _reason(w))
|
||||
elif a is A.SEND_TO_QC:
|
||||
loc = clean_opt(ex.get("location"), LIMITS["location"])
|
||||
w.log(LABELS[A.SEND_TO_QC], " · ".join(x for x in (f"Spool at {loc}" if loc else None, note) if x) or None)
|
||||
elif a is A.ROUTE_FROM_REVIEW:
|
||||
text = f"Supervisor, Q.C. skipped · from {_dept_text(w, w.from_dept)}"
|
||||
w.log(f"Routed to {_dept_text(w, to_dept)}", f"{text} · {note}" if note else text)
|
||||
|
||||
|
||||
SHOP_TEXTS = frozenset(
|
||||
{A.ASSIGN, A.UNASSIGN, A.PULL_BACK, A.START, A.SUBMIT, A.RETURN_TO_STATION, A.SEND_TO_QC, A.ROUTE_FROM_REVIEW}
|
||||
)
|
||||
|
||||
|
||||
def apply_transition(w: Write) -> None:
|
||||
"""Set status, status_at and dept, write the packet_transitions row and run the action's side effects."""
|
||||
if w.transitioned:
|
||||
@@ -579,6 +658,10 @@ def apply_transition(w: Write) -> None:
|
||||
if rule.dept_change != "no" and isinstance(ex.get("dept"), str) and ex.get("dept"):
|
||||
to_dept = str(ex["dept"])
|
||||
|
||||
stamp: str | None = None
|
||||
if a in SHOP_TEXTS and a is not A.ASSIGN and a is not A.SUBMIT:
|
||||
_shop_texts(w, a, to_dept) # before the side effects clear the station
|
||||
|
||||
if a in (A.SEND_TO_WAREHOUSE, A.VIS_AUTO_RELEASE):
|
||||
_set_packet(w, "wh_skipped = false, wh_sent_at = now(), return_note = NULL")
|
||||
_wh_incoming(w)
|
||||
@@ -623,14 +706,21 @@ def apply_transition(w: Write) -> None:
|
||||
if not packets.one(w.conn, "SELECT id FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (w.id,)):
|
||||
_open_stage(w, from_dept)
|
||||
_stage_update(w, "station_id = %s, welder_id = %s, welder_stamp = %s", (station_id, welder_id, stamp))
|
||||
_shop_texts(w, a, to_dept, stamp)
|
||||
elif a in (A.UNASSIGN, A.PULL_BACK):
|
||||
_set_packet(w, "station_id = NULL, welder_id = NULL, welder_stamp = NULL")
|
||||
_stage_update(w, f"{CLOCK_STOP}, station_id = NULL, welder_id = NULL, welder_stamp = NULL")
|
||||
elif a is A.START:
|
||||
_stage_update(w, "clock_running_since = now(), clock_started_at = coalesce(clock_started_at, now())")
|
||||
# A clock that is already running keeps its start; only the server sets it.
|
||||
_stage_update(
|
||||
w,
|
||||
"clock_running_since = coalesce(clock_running_since, now()),"
|
||||
" clock_started_at = coalesce(clock_started_at, now())",
|
||||
)
|
||||
elif a is A.SUBMIT:
|
||||
_set_packet(w, "return_note = NULL")
|
||||
_stage_update(w, CLOCK_STOP)
|
||||
_shop_texts(w, a, to_dept)
|
||||
elif a is A.RETURN_TO_STATION:
|
||||
_set_packet(w, "return_note = %s", (reason,))
|
||||
elif a is A.SEND_TO_QC:
|
||||
|
||||
@@ -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