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,188 @@
|
||||
"""Phase 5 station clock: every value is the server's. START starts it, the clock endpoint pauses and resumes it,
|
||||
PULL_BACK and SUBMIT fold the running time in, the live elapsed grows without a write, and no request field sets
|
||||
it. Time is moved by updating clock_running_since by SQL, so the tests do not wait."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fakedir import FakeDirectory
|
||||
from pkthelpers import step
|
||||
from pshelpers import needs_pg
|
||||
from shophelpers import (
|
||||
SIGN,
|
||||
Shop,
|
||||
finish_stage,
|
||||
fresh,
|
||||
im,
|
||||
in_progress,
|
||||
in_review,
|
||||
last_log,
|
||||
ok,
|
||||
one,
|
||||
open_shop,
|
||||
ready_packet,
|
||||
refused,
|
||||
set_running_since,
|
||||
start,
|
||||
to_station,
|
||||
)
|
||||
|
||||
from packetstation.settings import Settings
|
||||
|
||||
pytestmark = needs_pg
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
|
||||
yield from open_shop(pg_settings, fake_dir)
|
||||
|
||||
|
||||
def stored(shop: Shop, pid: int) -> tuple[int, bool]:
|
||||
row = one(
|
||||
shop.settings,
|
||||
"SELECT clock_elapsed_ms, clock_running_since IS NOT NULL FROM packet_stages"
|
||||
" WHERE packet_id = %s AND closed_at IS NULL",
|
||||
(pid,),
|
||||
)
|
||||
return int(row[0]), bool(row[1])
|
||||
|
||||
|
||||
def clock_op(shop: Shop, pkt: dict[str, Any], op: str, by: str = "weld.andy", **extra: Any) -> Any:
|
||||
return shop(by).post(f"/api/packets/{pkt['id']}/stage/clock", json={"op": op, **extra})
|
||||
|
||||
|
||||
def ts(value: str) -> datetime:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
|
||||
|
||||
def test_start_pause_resume_pull_back(shop: Shop) -> None:
|
||||
pkt = to_station(shop, ready_packet(shop))
|
||||
c = pkt["stage"]["clock"]
|
||||
assert (c["running"], c["stored_ms"], c["elapsed_ms"], c["running_since"], c["started_at"]) == (
|
||||
False,
|
||||
0,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
pkt = start(shop, pkt)
|
||||
c = pkt["stage"]["clock"]
|
||||
assert c["running"] is True and c["stored_ms"] == 0 and c["running_since"] and c["started_at"]
|
||||
started_at = c["started_at"]
|
||||
|
||||
# pause after 90 s of running
|
||||
set_running_since(shop.settings, pkt["id"], 90)
|
||||
out = ok(clock_op(shop, pkt, "pause"))
|
||||
c = out["stage"]["clock"]
|
||||
assert c["running"] is False and c["running_since"] is None
|
||||
assert 90_000 <= c["stored_ms"] < 95_000 and c["elapsed_ms"] == c["stored_ms"]
|
||||
assert stored(shop, pkt["id"]) == (c["stored_ms"], False)
|
||||
assert last_log(shop("weld.andy"), pkt["id"])["action"] == "Paused"
|
||||
paused_at = c["stored_ms"]
|
||||
|
||||
# resume, run 30 s, then the supervisor pulls it back: the running time is folded in
|
||||
out = ok(clock_op(shop, out, "resume"))
|
||||
assert out["stage"]["clock"]["running"] is True and out["stage"]["clock"]["started_at"] == started_at
|
||||
assert last_log(shop("weld.andy"), pkt["id"])["action"] == "Resumed"
|
||||
set_running_since(shop.settings, pkt["id"], 30)
|
||||
out = ok(step(shop("sup.wes"), out, "pull_back"))
|
||||
c = out["stage"]["clock"]
|
||||
assert c["running"] is False and paused_at + 30_000 <= c["stored_ms"] < paused_at + 35_000
|
||||
assert stored(shop, pkt["id"])[1] is False
|
||||
|
||||
# assigned and started again: it carries on from the stored value
|
||||
out = start(shop, to_station(shop, out))
|
||||
assert out["stage"]["clock"]["stored_ms"] == c["stored_ms"] and out["stage"]["clock"]["running"] is True
|
||||
assert out["stage"]["clock"]["started_at"] == started_at
|
||||
|
||||
|
||||
def test_live_elapsed_grows_without_a_write(shop: Shop) -> None:
|
||||
pkt = in_progress(shop)
|
||||
set_running_since(shop.settings, pkt["id"], 10)
|
||||
a = fresh(shop("weld.andy"), pkt["id"])
|
||||
time.sleep(1.1)
|
||||
b = fresh(shop("weld.andy"), pkt["id"])
|
||||
assert a["version"] == b["version"]
|
||||
assert a["stage"]["clock"]["stored_ms"] == b["stage"]["clock"]["stored_ms"] == 0
|
||||
assert 10_000 <= a["stage"]["clock"]["elapsed_ms"] < b["stage"]["clock"]["elapsed_ms"]
|
||||
assert b["stage"]["clock"]["elapsed_ms"] - a["stage"]["clock"]["elapsed_ms"] >= 1_000
|
||||
# elapsed = stored + (server_now - running_since), from the server's own clock
|
||||
since = ts(b["stage"]["clock"]["running_since"])
|
||||
now = ts(b["server_now"])
|
||||
want = (now - since).total_seconds() * 1000
|
||||
assert abs(b["stage"]["clock"]["elapsed_ms"] - want) < 1_000
|
||||
|
||||
|
||||
def test_submit_stops_the_clock(shop: Shop) -> None:
|
||||
pkt = finish_stage(shop, in_progress(shop))
|
||||
set_running_since(shop.settings, pkt["id"], 3 * 3600)
|
||||
out = ok(step(shop("weld.andy"), pkt, "submit", sign=SIGN))
|
||||
c = out["stage"]["clock"]
|
||||
assert c["running"] is False and 3 * 3_600_000 <= c["stored_ms"] < 3 * 3_600_000 + 10_000
|
||||
assert last_log(shop("weld.andy"), pkt["id"])["note"] in ("1 weld · 3.0 h", "1 welds · 3.0 h")
|
||||
|
||||
|
||||
def test_clock_state_conflicts(shop: Shop) -> None:
|
||||
pkt = to_station(shop, ready_packet(shop))
|
||||
# never started: pause and resume are refused
|
||||
r = refused(clock_op(shop, pkt, "pause"), 409, "clock_state")
|
||||
assert r["current"] == {"running": False, "started": False}
|
||||
refused(clock_op(shop, pkt, "resume"), 409, "clock_state")
|
||||
pkt = start(shop, pkt)
|
||||
r = refused(clock_op(shop, pkt, "start"), 409, "clock_state")
|
||||
assert r["current"]["running"] is True
|
||||
refused(clock_op(shop, pkt, "resume"), 409, "clock_state")
|
||||
ok(clock_op(shop, pkt, "pause"))
|
||||
refused(clock_op(shop, pkt, "pause"), 409, "clock_state")
|
||||
assert clock_op(shop, pkt, "stop").status_code == 422
|
||||
# who: Q.C. and the warehouse may not touch it
|
||||
for sam in ("qc.quinn", "wh.wade", "clerk.clara", "multi.mia"):
|
||||
refused(clock_op(shop, pkt, "resume", by=sam), 403, "forbidden")
|
||||
|
||||
|
||||
def test_no_field_sets_elapsed(shop: Shop) -> None:
|
||||
pkt = in_progress(shop)
|
||||
set_running_since(shop.settings, pkt["id"], 60)
|
||||
big = 999_999_999
|
||||
|
||||
# the clock endpoint takes only op
|
||||
r = clock_op(shop, pkt, "pause", elapsed_ms=big, clock_elapsed_ms=big, stored_ms=big)
|
||||
assert r.status_code in (200, 422), r.text
|
||||
if r.status_code == 422:
|
||||
ok(clock_op(shop, pkt, "pause"))
|
||||
ms, running = stored(shop, pkt["id"])
|
||||
assert 60_000 <= ms < 70_000 and running is False
|
||||
|
||||
# the stage fields refuse or ignore clock columns, in both body shapes
|
||||
url = f"/api/packets/{pkt['id']}/stage"
|
||||
for body in (
|
||||
{"set": {"clock_elapsed_ms": big, "clock_running_since": None}, "base": {}},
|
||||
{"set": {"filler": "ER70S-6", "clock_elapsed_ms": big}, "base": {}},
|
||||
):
|
||||
r = shop("weld.andy").patch(url, json=body)
|
||||
assert r.status_code in (200, 422), r.text
|
||||
rv = fresh(shop("weld.andy"), pkt["id"])["stage"]["row_version"]
|
||||
r = shop("weld.andy").patch(url, json={"clock_elapsed_ms": big, "notes": "x"}, headers=im(rv))
|
||||
assert r.status_code in (200, 422), r.text
|
||||
assert stored(shop, pkt["id"])[0] == ms
|
||||
|
||||
# the transitions carry no clock either
|
||||
r = step(shop("sup.wes"), fresh(shop("sup.wes"), pkt["id"]), "pull_back", elapsed_ms=big, clock_elapsed_ms=big)
|
||||
assert r.status_code in (200, 422), r.text
|
||||
assert stored(shop, pkt["id"])[0] == ms
|
||||
# and the read model's value is the stored one
|
||||
assert fresh(shop("sup.wes"), pkt["id"])["stage"]["clock"]["elapsed_ms"] == ms
|
||||
|
||||
|
||||
def test_closed_stage_clock(shop: Shop) -> None:
|
||||
pkt = in_review(shop)
|
||||
out = ok(step(shop("sup.wes"), pkt, "route_from_review", dept="09", sign=SIGN))
|
||||
old = out["stages"][-1]
|
||||
assert old["clock"]["running"] is False and old["clock"]["elapsed_ms"] == old["clock"]["stored_ms"]
|
||||
new = out["stage"]["clock"]
|
||||
assert (new["running"], new["stored_ms"], new["elapsed_ms"]) == (False, 0, 0)
|
||||
Reference in New Issue
Block a user