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,392 @@
|
||||
"""Helpers for the phase 5 shop tests: one signed-in client per user, a packet brought to `ready` in 03 through
|
||||
the warehouse, and the shop steps (assign, start, finish the stage, submit, review).
|
||||
|
||||
The packet: a hand draft in 03 on a fresh unit, two work orders (target 2 h and 2.5 h), three BOM lines, a
|
||||
two-page PDF drawing, heat map answered, sent to the warehouse, every line pulled with a heat by wh.wade, released.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from fakedir import FAKE_PASSWORD, FakeDirectory
|
||||
from fastapi.testclient import TestClient
|
||||
from pkthelpers import phase2_client, seed_users, step, tiny_pdf, upload
|
||||
from pshelpers import ORIGIN_HEADERS, app_conn, sign_in
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from packetstation.settings import Settings
|
||||
|
||||
SIGN = {"factor": "password", "secret": FAKE_PASSWORD}
|
||||
IT = "Ticket 5501: shop lead is out today"
|
||||
STALE = "Someone else already took this step."
|
||||
|
||||
USERS = (
|
||||
"admin.ann",
|
||||
"clerk.clara",
|
||||
"clerk.leo",
|
||||
"wh.wade",
|
||||
"qc.quinn",
|
||||
"pm.paula",
|
||||
"weld.andy",
|
||||
"lead.lou",
|
||||
"sup.wes",
|
||||
"multi.mia",
|
||||
"both.bo",
|
||||
"dup.dave",
|
||||
)
|
||||
|
||||
WOS = [
|
||||
{"wo_base": "L670001", "lot": "001", "split": "0", "sub": "0", "qty": 2, "target_hrs": 2},
|
||||
{"wo_base": "L670002", "lot": "001", "split": "0", "sub": "0", "qty": 1, "target_hrs": 2.5},
|
||||
]
|
||||
BOM3 = [
|
||||
{"item": 1, "qty": 1, "part_id": "PIPE-4", "description": "4in pipe SCH40", "material": "A106-B"},
|
||||
{"item": 2, "qty": 2, "part_id": "GSKT-4", "description": "4in gasket"},
|
||||
{"item": 3, "qty": 4, "part_id": "STUD-58", "description": "5/8 stud bolt"},
|
||||
]
|
||||
WELD_KEYS = ("dwg", "heat", "wps", "fit", "pre", "vt", "stamp", "clean", "qty")
|
||||
PLAIN_KEYS = ("dwg", "prev", "mat", "work", "insp", "qty")
|
||||
|
||||
MSG_CHECKLIST = "Finish the checklist first."
|
||||
MSG_WELDS = "Record at least one weld first."
|
||||
MSG_FILLER = "Enter the filler metal first."
|
||||
MSG_QC = "Confirm the book and the spool, and give the location."
|
||||
MSG_OTHER_DEPT = "Choose a different department."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- clients
|
||||
|
||||
|
||||
@dataclass
|
||||
class Shop:
|
||||
"""One app, one signed-in TestClient per user (separate cookie jars)."""
|
||||
|
||||
main: TestClient
|
||||
settings: Settings
|
||||
clients: dict[str, TestClient] = field(default_factory=dict)
|
||||
|
||||
def __call__(self, sam: str) -> TestClient:
|
||||
c = self.clients.get(sam)
|
||||
if c is None:
|
||||
c = self.fresh(sam)
|
||||
self.clients[sam] = c
|
||||
return c
|
||||
|
||||
def fresh(self, sam: str) -> TestClient:
|
||||
"""A new client with its own session (two sessions of one user, or a thread of its own)."""
|
||||
c = TestClient(self.main.app, headers=ORIGIN_HEADERS)
|
||||
r = sign_in(c, sam)
|
||||
assert r.status_code == 200, r.text
|
||||
return c
|
||||
|
||||
def uid(self, sam: str) -> str:
|
||||
return user_id(self.settings, sam)
|
||||
|
||||
def stall(self, dept: str = "03", name: str = "Stall 1") -> int:
|
||||
return station_id(self.settings, dept, name)
|
||||
|
||||
|
||||
def open_shop(settings: Settings, fake_dir: FakeDirectory) -> Iterator[Shop]:
|
||||
"""The body of a `shop` fixture: the app, every test user signed in once (their users rows exist)."""
|
||||
with phase2_client(settings, fake_dir) as client:
|
||||
seed_users(client, USERS)
|
||||
yield Shop(client, settings)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- SQL
|
||||
|
||||
|
||||
def one(settings: Settings, sql: str, params: Any = ()) -> Any:
|
||||
with app_conn(settings) as conn:
|
||||
return conn.execute(sql, params).fetchone()
|
||||
|
||||
|
||||
def all_rows(settings: Settings, sql: str, params: Any = ()) -> list[Any]:
|
||||
with app_conn(settings) as conn:
|
||||
return list(conn.execute(sql, params).fetchall())
|
||||
|
||||
|
||||
def run(settings: Settings, sql: str, params: Any = ()) -> None:
|
||||
with app_conn(settings) as conn:
|
||||
conn.execute(sql, params)
|
||||
|
||||
|
||||
def user_id(settings: Settings, sam: str) -> str:
|
||||
row = one(settings, "SELECT id FROM users WHERE sam_account = %s", (sam,))
|
||||
assert row is not None, sam
|
||||
return str(row[0])
|
||||
|
||||
|
||||
def station_id(settings: Settings, dept: str = "03", name: str = "Stall 1") -> int:
|
||||
row = one(settings, "SELECT id FROM stations WHERE dept_code = %s AND name = %s", (dept, name))
|
||||
assert row is not None, (dept, name)
|
||||
return int(row[0])
|
||||
|
||||
|
||||
def set_stamp(settings: Settings, sam: str, stamp: str | None) -> None:
|
||||
run(settings, "UPDATE users SET welder_stamp = %s WHERE sam_account = %s", (stamp, sam))
|
||||
|
||||
|
||||
def set_limits(settings: Settings, sam: str, station_ids: list[int]) -> None:
|
||||
"""Replace a user's station limits (an empty list removes them)."""
|
||||
with app_conn(settings) as conn:
|
||||
uid = conn.execute("SELECT id FROM users WHERE sam_account = %s", (sam,)).fetchone()[0]
|
||||
conn.execute("DELETE FROM user_station_limits WHERE user_id = %s", (uid,))
|
||||
for sid in station_ids:
|
||||
conn.execute("INSERT INTO user_station_limits (user_id, station_id) VALUES (%s, %s)", (uid, sid))
|
||||
|
||||
|
||||
def make_unit(settings: Settings, number: str | None = None, lot: str = "001") -> tuple[int, int]:
|
||||
"""A project with one unit by SQL. Returns (project_id, unit_id)."""
|
||||
number = number or "L5" + secrets.token_hex(3).upper()
|
||||
with app_conn(settings) as conn:
|
||||
pid = conn.execute(
|
||||
"INSERT INTO projects (number, name, customer, po) VALUES (%s, 'Shop project', 'Acme Refining', 'PO-55')"
|
||||
" RETURNING id",
|
||||
(number,),
|
||||
).fetchone()[0]
|
||||
uid = conn.execute(
|
||||
"INSERT INTO units (project_id, lot, name, status) VALUES (%s, %s, 'Unit one', 'open') RETURNING id",
|
||||
(pid, lot),
|
||||
).fetchone()[0]
|
||||
return int(pid), int(uid)
|
||||
|
||||
|
||||
def set_running_since(settings: Settings, packet_id: int, seconds_ago: float) -> None:
|
||||
"""Move the open stage's running clock back in time, as if it had run that long."""
|
||||
run(
|
||||
settings,
|
||||
"UPDATE packet_stages SET clock_running_since = now() - make_interval(secs => %s)"
|
||||
" WHERE packet_id = %s AND closed_at IS NULL AND clock_running_since IS NOT NULL",
|
||||
(seconds_ago, packet_id),
|
||||
)
|
||||
|
||||
|
||||
def set_templates(settings: Settings, value: Any) -> None:
|
||||
run(
|
||||
settings,
|
||||
"INSERT INTO settings (key, value, version, updated_at) VALUES ('checklist_templates', %s, 1, now())"
|
||||
" ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, version = settings.version + 1",
|
||||
(Jsonb(value),),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- reads
|
||||
|
||||
|
||||
def fresh(c: TestClient, pid: int) -> dict[str, Any]:
|
||||
r = c.get(f"/api/packets/{pid}")
|
||||
assert r.status_code == 200, r.text
|
||||
out: dict[str, Any] = r.json()
|
||||
return out
|
||||
|
||||
|
||||
def log_rows(c: TestClient, pid: int) -> list[dict[str, Any]]:
|
||||
r = c.get(f"/api/packets/{pid}/log")
|
||||
assert r.status_code == 200, r.text
|
||||
out: list[dict[str, Any]] = r.json()
|
||||
return out
|
||||
|
||||
|
||||
def last_log(c: TestClient, pid: int) -> dict[str, Any]:
|
||||
return log_rows(c, pid)[0]
|
||||
|
||||
|
||||
def find_log(c: TestClient, pid: int, action: str) -> dict[str, Any]:
|
||||
"""The newest log row with this action text."""
|
||||
rows = [r for r in log_rows(c, pid) if r["action"] == action]
|
||||
assert rows, (action, [r["action"] for r in log_rows(c, pid)])
|
||||
return rows[0]
|
||||
|
||||
|
||||
def ok(r: Any) -> dict[str, Any]:
|
||||
assert r.status_code == 200, r.text
|
||||
out: dict[str, Any] = r.json()
|
||||
return out
|
||||
|
||||
|
||||
def refused(r: Any, status: int, code: str, detail: str | None = None) -> dict[str, Any]:
|
||||
assert r.status_code == status, r.text
|
||||
body: dict[str, Any] = r.json()
|
||||
assert body["code"] == code, body
|
||||
if detail is not None:
|
||||
assert body["detail"] == detail, body
|
||||
return body
|
||||
|
||||
|
||||
def weld_of(pkt: dict[str, Any], no: int) -> dict[str, Any]:
|
||||
return next(w for w in pkt["welds"] if w["weld_no"] == no)
|
||||
|
||||
|
||||
def tag_of(pkt: dict[str, Any], no: int) -> dict[str, Any]:
|
||||
return next(t for t in pkt["heat_tags"] if t["tag_no"] == no)
|
||||
|
||||
|
||||
def line_of(pkt: dict[str, Any], item: int) -> dict[str, Any]:
|
||||
return next(b for b in pkt["bom"] if b["item"] == item)
|
||||
|
||||
|
||||
def im(v: int) -> dict[str, str]:
|
||||
return {"If-Match": str(v)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the packet
|
||||
|
||||
|
||||
def draft(
|
||||
shop: Shop,
|
||||
unit_id: int | None = None,
|
||||
*,
|
||||
title: str = "Spool 7 outlet",
|
||||
drawing: str = "D-5001",
|
||||
rev: str = "B",
|
||||
bom: list[dict[str, Any]] | None = None,
|
||||
wos: list[dict[str, Any]] | None = None,
|
||||
dept: str = "03",
|
||||
pages: int = 2,
|
||||
) -> dict[str, Any]:
|
||||
"""A hand draft by clerk.clara with the heat map answered and a PDF drawing of `pages` pages."""
|
||||
if unit_id is None:
|
||||
_, unit_id = make_unit(shop.settings)
|
||||
c = shop("clerk.clara")
|
||||
body = {
|
||||
"unit_id": unit_id,
|
||||
"dept_code": dept,
|
||||
"op": "WELD-PART",
|
||||
"title": title,
|
||||
"customer": "Acme Refining",
|
||||
"drawing": drawing,
|
||||
"rev": rev,
|
||||
"wps": "WPS-11",
|
||||
"work_orders": WOS if wos is None else wos,
|
||||
"bom": BOM3 if bom is None else bom,
|
||||
}
|
||||
r = c.post("/api/packets", json=body)
|
||||
assert r.status_code == 201, r.text
|
||||
pid = r.json()["id"]
|
||||
assert c.patch(f"/api/packets/{pid}", json={"set": {"heat_map": True}, "base": {}}).status_code == 200
|
||||
a = upload(c, tiny_pdf(pages))
|
||||
assert a.status_code == 201, a.text
|
||||
r = c.post(f"/api/packets/{pid}/pages", json={"asset_id": a.json()["id"], "type": "drawing", "sign": SIGN})
|
||||
return ok(r)
|
||||
|
||||
|
||||
def to_warehouse(shop: Shop, pkt: dict[str, Any]) -> dict[str, Any]:
|
||||
out = ok(step(shop("clerk.clara"), pkt, "send_to_warehouse"))
|
||||
assert out["status"] == "warehouse"
|
||||
return out
|
||||
|
||||
|
||||
def pull_all(shop: Shop, pkt: dict[str, Any]) -> dict[str, Any]:
|
||||
"""wh.wade ticks every line pulled and gives it a heat."""
|
||||
c = shop("wh.wade")
|
||||
for b in list(pkt["bom"]):
|
||||
item = b["item"]
|
||||
cur = line_of(pkt, item)
|
||||
pkt = ok(c.patch(f"/api/packets/{pkt['id']}/bom/{item}", json={"pulled": True}, headers=im(cur["row_version"])))
|
||||
cur = line_of(pkt, item)
|
||||
pkt = ok(
|
||||
c.patch(
|
||||
f"/api/packets/{pkt['id']}/bom/{item}", json={"heat": f"HT-{item}0"}, headers=im(cur["row_version"])
|
||||
)
|
||||
)
|
||||
return pkt
|
||||
|
||||
|
||||
def release(shop: Shop, pkt: dict[str, Any]) -> dict[str, Any]:
|
||||
out = ok(step(shop("wh.wade"), pkt, "wh_release"))
|
||||
assert out["status"] == "ready" and out["dept"]["code"] == "03", out["status"]
|
||||
return out
|
||||
|
||||
|
||||
def ready_packet(shop: Shop, unit_id: int | None = None, **kw: Any) -> dict[str, Any]:
|
||||
"""Draft, warehouse, every line pulled with a heat, released: `ready` in 03 with an open stage."""
|
||||
pkt = to_warehouse(shop, draft(shop, unit_id, **kw))
|
||||
return release(shop, pull_all(shop, pkt))
|
||||
|
||||
|
||||
def ready_skipped(shop: Shop, unit_id: int | None = None, dept: str = "03", **kw: Any) -> dict[str, Any]:
|
||||
"""A draft sent straight to a department (wh_skipped true)."""
|
||||
pkt = draft(shop, unit_id, **kw)
|
||||
out = ok(step(shop("clerk.clara"), pkt, "release_to_shop", dept=dept))
|
||||
assert out["status"] == "ready" and out["wh_skipped"] is True
|
||||
return out
|
||||
|
||||
|
||||
def to_station(
|
||||
shop: Shop,
|
||||
pkt: dict[str, Any],
|
||||
*,
|
||||
by: str = "sup.wes",
|
||||
welder: str = "weld.andy",
|
||||
dept: str = "03",
|
||||
station: str = "Stall 1",
|
||||
**kw: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""ASSIGN the packet to a station and a welder."""
|
||||
out = ok(step(shop(by), pkt, "assign", station_id=shop.stall(dept, station), welder_id=shop.uid(welder), **kw))
|
||||
assert out["status"] == "assigned"
|
||||
return out
|
||||
|
||||
|
||||
def start(shop: Shop, pkt: dict[str, Any], by: str = "weld.andy") -> dict[str, Any]:
|
||||
out = ok(step(shop(by), pkt, "start"))
|
||||
assert out["status"] == "in_progress"
|
||||
return out
|
||||
|
||||
|
||||
def add_weld(shop: Shop, pkt: dict[str, Any], by: str = "weld.andy", **over: Any) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {"page": 1, "fx": 0.5, "fy": 0.5, "items": [1, 2], "type": "Fillet", "process": "GMAW"}
|
||||
body.update(over)
|
||||
return ok(shop(by).post(f"/api/packets/{pkt['id']}/welds", json=body))
|
||||
|
||||
|
||||
def tick_all(
|
||||
shop: Shop, pkt: dict[str, Any], by: str = "weld.andy", keys: tuple[str, ...] = WELD_KEYS
|
||||
) -> dict[str, Any]:
|
||||
for k in keys:
|
||||
pkt = ok(shop(by).put(f"/api/packets/{pkt['id']}/stage/checklist/{k}", json={"done": True}))
|
||||
return pkt
|
||||
|
||||
|
||||
def set_filler(shop: Shop, pkt: dict[str, Any], by: str = "weld.andy", filler: str = "ER70S-6") -> dict[str, Any]:
|
||||
return ok(shop(by).patch(f"/api/packets/{pkt['id']}/stage", json={"set": {"filler": filler}, "base": {}}))
|
||||
|
||||
|
||||
def finish_stage(shop: Shop, pkt: dict[str, Any], by: str = "weld.andy", welds: int = 1) -> dict[str, Any]:
|
||||
"""Every checklist key ticked, `welds` welds marked, the filler set: SUBMIT can pass."""
|
||||
keys = WELD_KEYS if pkt["dept"]["weld"] else PLAIN_KEYS
|
||||
pkt = tick_all(shop, pkt, by, keys)
|
||||
for _ in range(welds):
|
||||
pkt = add_weld(shop, pkt, by)
|
||||
return set_filler(shop, pkt, by)
|
||||
|
||||
|
||||
def submit(shop: Shop, pkt: dict[str, Any], by: str = "weld.andy") -> dict[str, Any]:
|
||||
out = ok(step(shop(by), pkt, "submit", sign=SIGN))
|
||||
assert out["status"] == "review"
|
||||
return out
|
||||
|
||||
|
||||
def in_progress(shop: Shop, unit_id: int | None = None, **kw: Any) -> dict[str, Any]:
|
||||
return start(shop, to_station(shop, ready_packet(shop, unit_id, **kw)))
|
||||
|
||||
|
||||
def in_review(shop: Shop, unit_id: int | None = None, **kw: Any) -> dict[str, Any]:
|
||||
pkt = in_progress(shop, unit_id, **kw)
|
||||
return submit(shop, finish_stage(shop, pkt))
|
||||
|
||||
|
||||
def issue_co(shop: Shop, pkt: dict[str, Any], required: list[str], **over: Any) -> dict[str, Any]:
|
||||
"""A manual change order by clerk.clara (approved at once, holds until every required department verifies)."""
|
||||
body = {"reason": "Nozzle moved 2 in on rev C", "required_depts": required, "sign": SIGN, **over}
|
||||
return ok(shop("clerk.clara").post(f"/api/packets/{pkt['id']}/change-orders", json=body))
|
||||
|
||||
|
||||
def verify(shop: Shop, pkt: dict[str, Any], no: int, dept: str, by: str, **over: Any) -> Any:
|
||||
body = {"dept": dept, "note": "Reviewed with the crew", "sign": SIGN, **over}
|
||||
return shop(by).post(f"/api/packets/{pkt['id']}/change-orders/{no}/verify", json=body)
|
||||
Reference in New Issue
Block a user