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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user