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,948 @@
|
||||
"""Phase 5 shop API: the assign flow and its validations, the shop transitions with their exact log texts, the
|
||||
SUBMIT preconditions one by one, routing with carried marks, the checklist and its templates, stage field CAS,
|
||||
welds, heat tags and the heat table with their texts and validation, heat map borrowing, VISUAL labor hours, and
|
||||
the shop board, stations, stall and people endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fakedir import FakeDirectory
|
||||
from fastapi.testclient import TestClient
|
||||
from pkthelpers import step
|
||||
from pshelpers import ORIGIN_HEADERS, app_conn, needs_pg
|
||||
from shophelpers import (
|
||||
IT,
|
||||
MSG_CHECKLIST,
|
||||
MSG_FILLER,
|
||||
MSG_OTHER_DEPT,
|
||||
MSG_QC,
|
||||
MSG_WELDS,
|
||||
PLAIN_KEYS,
|
||||
SIGN,
|
||||
WELD_KEYS,
|
||||
Shop,
|
||||
add_weld,
|
||||
all_rows,
|
||||
draft,
|
||||
find_log,
|
||||
finish_stage,
|
||||
fresh,
|
||||
im,
|
||||
in_progress,
|
||||
in_review,
|
||||
issue_co,
|
||||
last_log,
|
||||
log_rows,
|
||||
make_unit,
|
||||
ok,
|
||||
one,
|
||||
open_shop,
|
||||
pull_all,
|
||||
ready_packet,
|
||||
ready_skipped,
|
||||
refused,
|
||||
release,
|
||||
run,
|
||||
set_filler,
|
||||
set_limits,
|
||||
set_running_since,
|
||||
set_stamp,
|
||||
set_templates,
|
||||
start,
|
||||
submit,
|
||||
tag_of,
|
||||
tick_all,
|
||||
to_station,
|
||||
to_warehouse,
|
||||
weld_of,
|
||||
)
|
||||
|
||||
from packetstation.settings import Settings
|
||||
|
||||
pytestmark = needs_pg
|
||||
|
||||
MSG_WELDER = "Pick a welder in this department."
|
||||
MSG_VOID = "This sheet is VOID. Mark the current sheet."
|
||||
MSG_TYPE = "Pick a weld type from the list."
|
||||
MSG_PROCESS = "Pick a process from the list."
|
||||
MSG_NOT_LEAD = "You do not lead a department."
|
||||
SHOP_DEPTS = ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "13", "14"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
|
||||
yield from open_shop(pg_settings, fake_dir)
|
||||
|
||||
|
||||
def page_label(pkt: dict[str, Any], n: int) -> str:
|
||||
page = next(p for p in pkt["pages"] if p["n"] == n)
|
||||
return page["dwg"] or f"sheet {n}"
|
||||
|
||||
|
||||
# ================================================================ assign
|
||||
|
||||
|
||||
def test_assign_validations(shop: Shop) -> None:
|
||||
s = shop.settings
|
||||
set_stamp(s, "weld.andy", "75")
|
||||
pkt = ready_packet(shop)
|
||||
wes = shop("sup.wes")
|
||||
andy = shop.uid("weld.andy")
|
||||
|
||||
# a station of another department
|
||||
r = step(wes, pkt, "assign", station_id=shop.stall("06", "Stall 1"), welder_id=andy)
|
||||
refused(r, 422, "invalid_station")
|
||||
# welders outside the department: Q.C., a supervisor of 06, Clerical
|
||||
for sam in ("qc.quinn", "multi.mia", "clerk.clara"):
|
||||
r = step(wes, pkt, "assign", station_id=shop.stall(), welder_id=shop.uid(sam))
|
||||
refused(r, 422, "invalid_welder", MSG_WELDER)
|
||||
# no welder or no station
|
||||
refused(step(wes, pkt, "assign", station_id=shop.stall()), 403, "precondition", "Pick a station and a welder.")
|
||||
# a worker limited to Stall 2
|
||||
set_limits(s, "weld.andy", [shop.stall("03", "Stall 2")])
|
||||
r = step(wes, pkt, "assign", station_id=shop.stall(), welder_id=andy)
|
||||
refused(r, 422, "station_limit", "Andy Welder is limited to Stall 2.")
|
||||
set_limits(s, "weld.andy", [shop.stall("03", "Stall 2"), shop.stall("03", "Stall 3")])
|
||||
r = step(wes, pkt, "assign", station_id=shop.stall(), welder_id=andy)
|
||||
refused(r, 422, "station_limit", "Andy Welder is limited to Stall 2, Stall 3.")
|
||||
# a bad stamp override
|
||||
set_limits(s, "weld.andy", [])
|
||||
r = step(wes, pkt, "assign", station_id=shop.stall(), welder_id=andy, stamp="no good!")
|
||||
refused(r, 422, "invalid_stamp")
|
||||
assert fresh(wes, pkt["id"])["status"] == "ready"
|
||||
|
||||
# the stamp defaults from users.welder_stamp; the stage gets the same three values
|
||||
out = to_station(shop, pkt)
|
||||
assert out["station"]["name"] == "Stall 1" and out["welder"]["id"] == andy and out["welder"]["stamp"] == "75"
|
||||
st = out["stage"]
|
||||
assert st["station"]["id"] == shop.stall() and st["welder"]["id"] == andy and st["welder"]["stamp"] == "75"
|
||||
log = last_log(wes, pkt["id"])
|
||||
assert (log["action"], log["note"]) == ("Sent to Stall 1", "Andy Welder [75]")
|
||||
assert log["by"]["display_name"] == "Wes Super"
|
||||
|
||||
# unassign clears them; a stamp override wins over the user's stamp
|
||||
out = ok(step(wes, out, "unassign"))
|
||||
assert out["status"] == "ready" and out["station"] is None and out["welder"] is None
|
||||
assert out["stage"]["station"] is None and out["stage"]["welder"] is None
|
||||
assert last_log(wes, pkt["id"])["action"] == "Taken off Stall 1"
|
||||
out = to_station(shop, out, station="Stall 4", stamp="A7")
|
||||
assert out["welder"]["stamp"] == "A7" and out["stage"]["welder"]["stamp"] == "A7"
|
||||
assert (last_log(wes, pkt["id"])["action"], last_log(wes, pkt["id"])["note"]) == (
|
||||
"Sent to Stall 4",
|
||||
"Andy Welder [A7]",
|
||||
)
|
||||
assert one(s, "SELECT welder_stamp FROM users WHERE sam_account = 'weld.andy'")[0] == "75"
|
||||
|
||||
# station limits bind workers only: lead.lou limited to Stall 2 may still be put on Stall 1
|
||||
out = ok(step(wes, out, "unassign"))
|
||||
set_limits(s, "lead.lou", [shop.stall("03", "Stall 2")])
|
||||
out = to_station(shop, out, welder="lead.lou")
|
||||
assert out["welder"]["display_name"] == "Lou Lead"
|
||||
log = last_log(wes, pkt["id"])
|
||||
assert (log["action"], log["note"]) == ("Sent to Stall 1", "Lou Lead")
|
||||
# the lead assigns too
|
||||
out = ok(step(shop("lead.lou"), out, "unassign"))
|
||||
to_station(shop, out, by="lead.lou")
|
||||
|
||||
|
||||
def test_people(shop: Shop) -> None:
|
||||
s = shop.settings
|
||||
set_stamp(s, "weld.andy", "75")
|
||||
set_limits(s, "weld.andy", [shop.stall("03", "Stall 2")])
|
||||
r = shop("weld.andy").get("/api/departments/03/people")
|
||||
people = ok(r)
|
||||
names = [p["display_name"] for p in people]
|
||||
assert names == sorted(names)
|
||||
by = {p["display_name"]: p for p in people}
|
||||
assert {"Andy Welder", "Lou Lead", "Wes Super", "Bo Both", "Dave Dup"} <= set(by)
|
||||
assert not {"Mia Multi", "Quinn QC", "Clara Clerk", "Ann Admin"} & set(by)
|
||||
assert by["Andy Welder"] == {
|
||||
"id": shop.uid("weld.andy"),
|
||||
"display_name": "Andy Welder",
|
||||
"stamp": "75",
|
||||
"level": "worker",
|
||||
"station_limits": [shop.stall("03", "Stall 2")],
|
||||
}
|
||||
assert by["Lou Lead"]["level"] == "lead" and by["Wes Super"]["level"] == "supervisor"
|
||||
assert by["Bo Both"]["level"] == "supervisor" and by["Lou Lead"]["station_limits"] == []
|
||||
six = ok(shop("sup.wes").get("/api/departments/06/people"))
|
||||
assert [p["display_name"] for p in six] == ["Mia Multi"]
|
||||
for code in ("ZZ", "WH", "CL", "QC"):
|
||||
assert shop("sup.wes").get(f"/api/departments/{code}/people").status_code == 404, code
|
||||
# a session is needed
|
||||
anon = TestClient(shop.main.app, headers=ORIGIN_HEADERS)
|
||||
assert anon.get("/api/departments/03/people").status_code == 401
|
||||
|
||||
|
||||
# ================================================================ the shop transitions and their texts
|
||||
|
||||
|
||||
def test_unassign_and_pull_back_freeze_the_clock(shop: Shop) -> None:
|
||||
wes = shop("sup.wes")
|
||||
pkt = to_station(shop, ready_packet(shop))
|
||||
pkt = start(shop, pkt)
|
||||
log = last_log(wes, pkt["id"])
|
||||
assert (log["action"], log["note"]) == ("Work started", "Stall 1")
|
||||
assert pkt["stage"]["clock"]["running"] is True and pkt["stage"]["clock"]["running_since"]
|
||||
assert pkt["stage"]["clock"]["started_at"]
|
||||
set_running_since(shop.settings, pkt["id"], 90)
|
||||
out = ok(step(wes, pkt, "pull_back"))
|
||||
assert out["status"] == "ready" and out["station"] is None and out["welder"] is None
|
||||
assert last_log(wes, pkt["id"])["action"] == "Pulled back from Stall 1"
|
||||
clock = out["stage"]["clock"]
|
||||
assert clock["running"] is False and clock["running_since"] is None
|
||||
assert 90_000 <= clock["stored_ms"] < 120_000 and clock["elapsed_ms"] == clock["stored_ms"]
|
||||
frozen = clock["stored_ms"]
|
||||
time.sleep(1.1)
|
||||
again = fresh(wes, pkt["id"])["stage"]["clock"]
|
||||
assert again["elapsed_ms"] == frozen and again["running"] is False
|
||||
|
||||
# assigned again and unassigned before a start: the clock stays frozen at the same value
|
||||
out = to_station(shop, out)
|
||||
out = ok(step(wes, out, "unassign"))
|
||||
assert out["stage"]["clock"]["elapsed_ms"] == frozen
|
||||
assert last_log(wes, pkt["id"])["action"] == "Taken off Stall 1"
|
||||
|
||||
|
||||
def test_submit_preconditions_one_by_one(shop: Shop) -> None:
|
||||
andy = shop("weld.andy")
|
||||
pkt = in_progress(shop)
|
||||
mine = fresh(andy, pkt["id"])
|
||||
assert mine["blocked"]["submit"] == [MSG_CHECKLIST, MSG_WELDS, MSG_FILLER]
|
||||
refused(step(andy, pkt, "submit", sign=SIGN), 403, "precondition", MSG_CHECKLIST)
|
||||
pkt = tick_all(shop, pkt)
|
||||
assert fresh(andy, pkt["id"])["blocked"]["submit"] == [MSG_WELDS, MSG_FILLER]
|
||||
refused(step(andy, pkt, "submit", sign=SIGN), 403, "precondition", MSG_WELDS)
|
||||
pkt = add_weld(shop, pkt)
|
||||
pkt = add_weld(shop, pkt, fx=0.6, fy=0.4)
|
||||
refused(step(andy, pkt, "submit", sign=SIGN), 403, "precondition", MSG_FILLER)
|
||||
pkt = set_filler(shop, pkt)
|
||||
mine = fresh(andy, pkt["id"])
|
||||
assert "submit" in mine["can"] and "submit" not in mine["blocked"]
|
||||
r = refused(step(andy, pkt, "submit"), 400, "signature_required")
|
||||
assert r["meaning"] == "Submit work to the supervisor"
|
||||
set_running_since(shop.settings, pkt["id"], 54 * 60)
|
||||
out = ok(step(andy, pkt, "submit", sign=SIGN))
|
||||
assert out["status"] == "review" and out["return_note"] is None
|
||||
assert out["stage"]["clock"]["running"] is False and out["station"]["name"] == "Stall 1"
|
||||
log = last_log(andy, pkt["id"])
|
||||
assert (log["action"], log["note"], log["signed"]) == ("Submitted to supervisor", "2 welds · 0.9 h", True)
|
||||
sig = one(
|
||||
shop.settings,
|
||||
"SELECT signature_id FROM packet_transitions WHERE packet_id = %s AND action = 'submit'",
|
||||
(pkt["id"],),
|
||||
)
|
||||
assert sig[0] is not None
|
||||
|
||||
|
||||
def test_return_to_station(shop: Shop) -> None:
|
||||
wes = shop("sup.wes")
|
||||
pkt = in_review(shop)
|
||||
refused(step(wes, pkt, "return_to_station"), 400, "reason_required")
|
||||
reason = "Weld 2 undercut, grind and reweld"
|
||||
out = ok(step(wes, pkt, "return_to_station", reason=reason))
|
||||
assert out["status"] == "assigned" and out["return_note"] == reason
|
||||
assert out["station"]["name"] == "Stall 1" and out["welder"]["id"] == shop.uid("weld.andy")
|
||||
log = last_log(wes, pkt["id"])
|
||||
assert (log["action"], log["note"]) == ("Returned to Stall 1", reason)
|
||||
# weld.andy starts again and submits: the return note clears
|
||||
out = start(shop, out)
|
||||
out = submit(shop, out)
|
||||
assert out["return_note"] is None
|
||||
|
||||
|
||||
def test_send_to_qc(shop: Shop) -> None:
|
||||
wes = shop("sup.wes")
|
||||
pkt = in_review(shop)
|
||||
for body in (
|
||||
{"book": True, "spool": True},
|
||||
{"book": True, "spool": False, "location": "Bay 2"},
|
||||
{"book": False, "spool": True, "location": "Bay 2"},
|
||||
{"book": True, "spool": True, "location": " "},
|
||||
):
|
||||
refused(step(wes, pkt, "send_to_qc", sign=SIGN, **body), 403, "precondition", MSG_QC)
|
||||
refused(
|
||||
step(shop("lead.lou"), pkt, "send_to_qc", book=True, spool=True, location="Bay 2", sign=SIGN), 403, "forbidden"
|
||||
)
|
||||
out = ok(step(wes, pkt, "send_to_qc", book=True, spool=True, location="Bay 2", sign=SIGN))
|
||||
assert out["status"] == "qc" and out["qc"]["location"] == "Bay 2"
|
||||
assert out["qc"]["book"] is True and out["qc"]["spool"] is True and out["qc"]["sent_at"]
|
||||
log = last_log(wes, pkt["id"])
|
||||
assert (log["action"], log["note"], log["signed"]) == ("Sent to Q.C.", "Spool at Bay 2", True)
|
||||
|
||||
|
||||
def test_send_to_qc_note(shop: Shop) -> None:
|
||||
wes = shop("sup.wes")
|
||||
pkt = in_review(shop)
|
||||
ok(step(wes, pkt, "send_to_qc", book=True, spool=True, location="Bay 2", note="Top rack", sign=SIGN))
|
||||
assert last_log(wes, pkt["id"])["note"] == "Spool at Bay 2 · Top rack"
|
||||
|
||||
|
||||
def test_route_from_review_to_hydro(shop: Shop) -> None:
|
||||
wes = shop("sup.wes")
|
||||
pkt = in_progress(shop)
|
||||
pkt = ok(
|
||||
shop("weld.andy").post(f"/api/packets/{pkt['id']}/heat-tags", json={"page": 1, "item": 3, "fx": 0.3, "fy": 0.3})
|
||||
)
|
||||
pkt = submit(shop, finish_stage(shop, pkt, welds=2))
|
||||
first_stage = pkt["stage"]["id"]
|
||||
assert [w["carried"] for w in pkt["welds"]] == [False, False]
|
||||
assert [t["carried"] for t in pkt["heat_tags"]] == [False]
|
||||
|
||||
refused(step(wes, pkt, "route_from_review", dept="03", sign=SIGN), 403, "precondition", MSG_OTHER_DEPT)
|
||||
refused(step(wes, pkt, "route_from_review", dept="09"), 400, "signature_required")
|
||||
out = ok(step(wes, pkt, "route_from_review", dept="09", sign=SIGN))
|
||||
assert out["status"] == "ready" and out["dept"]["code"] == "09"
|
||||
assert out["station"] is None and out["welder"] is None and out["return_note"] is None
|
||||
assert out["stage"]["dept_code"] == "09" and out["stage"]["id"] != first_stage
|
||||
assert out["stage"]["station"] is None and out["stage"]["welder"] is None
|
||||
closed = [x for x in out["stages"] if x["id"] == first_stage]
|
||||
assert len(closed) == 1 and closed[0]["closed_at"] and closed[0]["dept_code"] == "03"
|
||||
assert closed[0]["clock"]["running"] is False
|
||||
log = last_log(wes, pkt["id"])
|
||||
assert (log["action"], log["note"], log["signed"]) == (
|
||||
"Routed to 09 Hydro",
|
||||
"Supervisor, Q.C. skipped · from 03 Weld Parts",
|
||||
True,
|
||||
)
|
||||
# the marks stay on the packet and read as carried
|
||||
assert len(out["welds"]) == 2 and all(w["carried"] is True for w in out["welds"])
|
||||
assert all(w["dept_code"] == "03" and w["stage_id"] == first_stage for w in out["welds"])
|
||||
assert len(out["heat_tags"]) == 1 and out["heat_tags"][0]["carried"] is True
|
||||
# 09 is not a weld department: the plain checklist
|
||||
assert [i["key"] for i in out["checklist_items"]] == list(PLAIN_KEYS)
|
||||
assert out["stage"]["checklist"] == {}
|
||||
|
||||
|
||||
def test_route_to_a_weld_department_needs_its_own_weld(shop: Shop) -> None:
|
||||
"""welds_recorded counts the current department's welds only."""
|
||||
pkt = in_review(shop)
|
||||
out = ok(step(shop("sup.wes"), pkt, "route_from_review", dept="06", sign=SIGN))
|
||||
assert out["dept"]["code"] == "06" and all(w["carried"] for w in out["welds"])
|
||||
mia = shop("multi.mia")
|
||||
out = to_station(shop, out, by="multi.mia", welder="multi.mia", dept="06", station="Stall 2")
|
||||
out = start(shop, out, by="multi.mia")
|
||||
out = tick_all(shop, out, by="multi.mia")
|
||||
out = set_filler(shop, out, by="multi.mia")
|
||||
mine = fresh(mia, out["id"])
|
||||
assert mine["blocked"]["submit"] == [MSG_WELDS]
|
||||
refused(step(mia, out, "submit", sign=SIGN), 403, "precondition", MSG_WELDS)
|
||||
out = add_weld(shop, out, by="multi.mia")
|
||||
w = max(out["welds"], key=lambda x: x["weld_no"])
|
||||
assert w["dept_code"] == "06" and w["carried"] is False and w["weld_no"] == 2
|
||||
out = submit(shop, out, by="multi.mia")
|
||||
assert last_log(mia, out["id"])["note"].startswith("1 weld")
|
||||
|
||||
|
||||
# ================================================================ checklist and templates
|
||||
|
||||
|
||||
def test_checklist_per_key(shop: Shop) -> None:
|
||||
s = shop.settings
|
||||
set_stamp(s, "weld.andy", "75")
|
||||
andy = shop("weld.andy")
|
||||
pkt = in_progress(shop, drawing="D-5001", rev="B")
|
||||
items = {i["key"]: i for i in pkt["checklist_items"]}
|
||||
assert list(items) == list(WELD_KEYS)
|
||||
assert items["dwg"]["text"] == "Drawing D-5001 Rev B matches the packet"
|
||||
assert items["dwg"]["group"] == "Before welding" and items["vt"]["group"] == "After welding"
|
||||
out = ok(andy.put(f"/api/packets/{pkt['id']}/stage/checklist/dwg", json={"done": True}))
|
||||
entry = out["stage"]["checklist"]["dwg"]
|
||||
assert entry["done"] is True and entry["at"] and entry["by"] == shop.uid("weld.andy")
|
||||
assert entry["stamp"] == "75" and entry["by_name"] == "Andy Welder"
|
||||
assert last_log(andy, pkt["id"])["action"] == "Checked: Drawing D-5001 Rev B matches the packet"
|
||||
stored = one(s, "SELECT checklist FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (pkt["id"],))[0]
|
||||
assert stored["dwg"]["done"] is True and stored["dwg"]["stamp"] == "75"
|
||||
# the same tick again writes nothing
|
||||
before = len(log_rows(andy, pkt["id"]))
|
||||
ok(andy.put(f"/api/packets/{pkt['id']}/stage/checklist/dwg", json={"done": True}))
|
||||
assert len(log_rows(andy, pkt["id"])) == before
|
||||
out = ok(andy.put(f"/api/packets/{pkt['id']}/stage/checklist/dwg", json={"done": False}))
|
||||
assert out["stage"]["checklist"]["dwg"]["done"] is False
|
||||
assert last_log(andy, pkt["id"])["action"] == "Unchecked: Drawing D-5001 Rev B matches the packet"
|
||||
refused(andy.put(f"/api/packets/{pkt['id']}/stage/checklist/zzz", json={"done": True}), 422, "invalid_key")
|
||||
refused(andy.put(f"/api/packets/{pkt['id']}/stage/checklist/prev", json={"done": True}), 422, "invalid_key")
|
||||
# the stamp falls back to the packet's stamp when the user has none
|
||||
set_stamp(s, "lead.lou", None)
|
||||
out = ok(shop("lead.lou").put(f"/api/packets/{pkt['id']}/stage/checklist/fit", json={"done": True}))
|
||||
assert out["stage"]["checklist"]["fit"]["stamp"] == "75"
|
||||
assert out["stage"]["checklist"]["fit"]["by_name"] == "Lou Lead"
|
||||
# someone who does not hold it
|
||||
refused(shop("qc.quinn").put(f"/api/packets/{pkt['id']}/stage/checklist/vt", json={"done": True}), 403, "forbidden")
|
||||
|
||||
|
||||
def test_checklist_plain_department(shop: Shop) -> None:
|
||||
pkt = ready_skipped(shop, dept="09")
|
||||
assert [i["key"] for i in pkt["checklist_items"]] == list(PLAIN_KEYS)
|
||||
groups = {i["key"]: i["group"] for i in pkt["checklist_items"]}
|
||||
assert groups["prev"] == "Before starting" and groups["work"] == "When done"
|
||||
ann = shop("admin.ann")
|
||||
url = f"/api/packets/{pkt['id']}/stage/checklist"
|
||||
refused(ann.put(f"{url}/prev", json={"done": True}), 400, "it_reason_required")
|
||||
out = ok(ann.put(f"{url}/prev", json={"done": True, "it_reason": IT}))
|
||||
assert out["stage"]["checklist"]["prev"]["done"] is True
|
||||
refused(ann.put(f"{url}/heat", json={"done": True, "it_reason": IT}), 422, "invalid_key")
|
||||
|
||||
|
||||
def test_checklist_templates_from_settings(shop: Shop) -> None:
|
||||
s = shop.settings
|
||||
row = one(s, "SELECT value FROM settings WHERE key = 'checklist_templates'")
|
||||
assert row is not None, "migration 0006 seeds checklist_templates"
|
||||
defaults = row[0]
|
||||
assert [i["key"] for i in defaults["weld"]] == list(WELD_KEYS)
|
||||
assert [i["key"] for i in defaults["plain"]] == list(PLAIN_KEYS)
|
||||
custom = {
|
||||
"weld": [
|
||||
{"group": "Before welding", "key": "dwg", "text": "Sheet {drawing} at Rev {rev} checked"},
|
||||
{"group": "Before welding", "key": "wps", "text": "Procedure {wps} read"},
|
||||
{"group": "After welding", "key": "vt", "text": "Visual done"},
|
||||
],
|
||||
"plain": defaults["plain"],
|
||||
}
|
||||
set_templates(s, custom)
|
||||
andy = shop("weld.andy")
|
||||
pkt = in_progress(shop, drawing="D-6006", rev="C")
|
||||
pkt = fresh(andy, pkt["id"])
|
||||
assert [(i["key"], i["text"]) for i in pkt["checklist_items"]] == [
|
||||
("dwg", "Sheet D-6006 at Rev C checked"),
|
||||
("wps", "Procedure WPS-11 read"),
|
||||
("vt", "Visual done"),
|
||||
]
|
||||
refused(andy.put(f"/api/packets/{pkt['id']}/stage/checklist/fit", json={"done": True}), 422, "invalid_key")
|
||||
out = ok(andy.put(f"/api/packets/{pkt['id']}/stage/checklist/wps", json={"done": True}))
|
||||
assert last_log(andy, pkt["id"])["action"] == "Checked: Procedure WPS-11 read"
|
||||
# three keys complete the custom checklist
|
||||
out = tick_all(shop, out, keys=("dwg", "vt"))
|
||||
out = set_filler(shop, add_weld(shop, out))
|
||||
assert "submit" in fresh(andy, out["id"])["can"]
|
||||
|
||||
# a malformed row falls back to the defaults in code
|
||||
set_templates(s, {"weld": "nonsense"})
|
||||
pkt = fresh(andy, pkt["id"])
|
||||
assert [i["key"] for i in pkt["checklist_items"]] == list(WELD_KEYS)
|
||||
run(s, "DELETE FROM settings WHERE key = 'checklist_templates'")
|
||||
assert [i["key"] for i in fresh(andy, pkt["id"])["checklist_items"]] == list(WELD_KEYS)
|
||||
|
||||
|
||||
# ================================================================ stage fields
|
||||
|
||||
|
||||
def test_stage_fields_field_cas(shop: Shop) -> None:
|
||||
andy = shop("weld.andy")
|
||||
pkt = in_progress(shop)
|
||||
url = f"/api/packets/{pkt['id']}/stage"
|
||||
out = ok(andy.patch(url, json={"set": {"filler": "ER70S-6", "filler_lot": "1234"}, "base": {"filler": None}}))
|
||||
assert out["stage"]["filler"] == "ER70S-6" and out["stage"]["filler_lot"] == "1234"
|
||||
log = last_log(andy, pkt["id"])
|
||||
assert (log["action"], log["note"]) == ("Weld record changed", "filler ER70S-6 · filler lot 1234")
|
||||
# another session saw filler empty and writes over it: a conflict on that field only
|
||||
r = refused(
|
||||
shop("sup.wes").patch(url, json={"set": {"filler": "E7018"}, "base": {"filler": None}}), 409, "field_conflict"
|
||||
)
|
||||
assert r["current"] == {"filler": "ER70S-6"}
|
||||
# a base that matches, and a key without a base, are written
|
||||
out = ok(
|
||||
shop("sup.wes").patch(url, json={"set": {"filler": "E7018", "preheat": "150F"}, "base": {"filler": "ER70S-6"}})
|
||||
)
|
||||
assert out["stage"]["filler"] == "E7018" and out["stage"]["preheat"] == "150F"
|
||||
assert last_log(andy, pkt["id"])["note"] == "filler E7018 · preheat 150F"
|
||||
# the flat body with If-Match still works
|
||||
rv = out["stage"]["row_version"]
|
||||
out = ok(andy.patch(url, json={"notes": "Root pass by Andy"}, headers=im(rv)))
|
||||
assert out["stage"]["notes"] == "Root pass by Andy"
|
||||
refused(andy.patch(url, json={"notes": "stale"}, headers=im(rv)), 409, "row_conflict")
|
||||
# limits: 40 for the short fields, 2000 for notes
|
||||
r = andy.patch(url, json={"set": {"filler_lot": "X" * 60, "notes": "n" * 2500}, "base": {}})
|
||||
if r.status_code == 200:
|
||||
assert len(r.json()["stage"]["filler_lot"]) == 40 and len(r.json()["stage"]["notes"]) == 2000
|
||||
else:
|
||||
assert r.status_code == 422, r.text
|
||||
refused(shop("qc.quinn").patch(url, json={"set": {"filler": "x"}, "base": {}}), 403, "forbidden")
|
||||
|
||||
|
||||
# ================================================================ welds
|
||||
|
||||
|
||||
def test_welds(shop: Shop) -> None:
|
||||
s = shop.settings
|
||||
set_stamp(s, "weld.andy", "75")
|
||||
andy = shop("weld.andy")
|
||||
pkt = in_progress(shop)
|
||||
url = f"/api/packets/{pkt['id']}/welds"
|
||||
body = {
|
||||
"page": 1,
|
||||
"fx": 0.5,
|
||||
"fy": 0.5,
|
||||
"bfx": 0.55,
|
||||
"bfy": 0.43,
|
||||
"items": [2, 1],
|
||||
"wo_base": "L670001",
|
||||
"piece": 1,
|
||||
"type": "Fillet",
|
||||
"process": "GMAW",
|
||||
}
|
||||
out = ok(andy.post(url, json=body))
|
||||
w1 = weld_of(out, 1)
|
||||
assert (w1["size"], w1["type"], w1["process"], w1["items"]) == ('5/16"', "Fillet", "GMAW", [1, 2])
|
||||
assert (w1["bfx"], w1["bfy"], w1["fx"], w1["fy"]) == (0.55, 0.43, 0.5, 0.5)
|
||||
assert w1["dept_code"] == "03" and w1["stage_id"] == out["stage"]["id"] and w1["carried"] is False
|
||||
assert w1["by"]["display_name"] == "Andy Welder" and w1["welder"]["id"] == shop.uid("weld.andy")
|
||||
assert w1["stamp"] == "75"
|
||||
log = last_log(andy, pkt["id"])
|
||||
assert (log["action"], log["note"]) == ("Weld W1 marked", 'Fillet 5/16" · items 1 + 2 · L670001 #1')
|
||||
# the client never picks the number
|
||||
out = ok(andy.post(url, json={**body, "weld_no": 9, "size": '1/4"', "bfx": None, "bfy": None}))
|
||||
w2 = weld_of(out, 2)
|
||||
assert w2["size"] == '1/4"' and w2["bfx"] is None and w2["bfy"] is None
|
||||
assert sorted(w["weld_no"] for w in out["welds"]) == [1, 2]
|
||||
|
||||
# validation
|
||||
refused(andy.post(url, json={**body, "type": "Butt"}), 422, "invalid_weld", MSG_TYPE)
|
||||
refused(andy.post(url, json={**body, "process": "TIG"}), 422, "invalid_weld", MSG_PROCESS)
|
||||
refused(andy.post(url, json={**body, "wo_base": "L999999"}), 422, "invalid_wo")
|
||||
for bad in ({"fx": 1.5}, {"fy": -0.1}, {"bfx": 1.2}, {"bfy": -0.5}, {"piece": 0}):
|
||||
r = andy.post(url, json={**body, **bad})
|
||||
assert r.status_code == 422, (bad, r.text)
|
||||
refused(andy.post(url, json={**body, "items": [7]}), 422, "invalid_items")
|
||||
for t in ("Fillet", "Groove", "Fill", "Seal", "Tack"):
|
||||
assert andy.post(url, json={**body, "type": t, "page": 2}).status_code == 200, t
|
||||
for p in ("GMAW", "FCAW", "SMAW", "GTAW", "SAW"):
|
||||
assert andy.post(url, json={**body, "process": p, "page": 2}).status_code == 200, p
|
||||
out = fresh(andy, pkt["id"])
|
||||
n = max(w["weld_no"] for w in out["welds"])
|
||||
assert n == 12 and len(out["welds"]) == 12
|
||||
|
||||
# a position-only patch logs "moved"; a field change logs the changed fields
|
||||
rv = weld_of(out, 1)["row_version"]
|
||||
out = ok(andy.patch(f"{url}/1", json={"fx": 0.6, "fy": 0.62, "bfx": 0.7, "bfy": 0.5}, headers=im(rv)))
|
||||
assert (weld_of(out, 1)["fx"], weld_of(out, 1)["bfy"]) == (0.6, 0.5)
|
||||
log = last_log(andy, pkt["id"])
|
||||
assert (log["action"], log["note"]) == ("Weld W1 moved", None)
|
||||
rv = weld_of(out, 1)["row_version"]
|
||||
out = ok(andy.patch(f"{url}/1", json={"type": "Groove", "items": [1, 3]}, headers=im(rv)))
|
||||
log = last_log(andy, pkt["id"])
|
||||
assert (log["action"], log["note"]) == ("Weld W1 changed", "type Groove · items 1 + 3")
|
||||
refused(
|
||||
andy.patch(f"{url}/1", json={"type": "Butt"}, headers=im(weld_of(out, 1)["row_version"])), 422, "invalid_weld"
|
||||
)
|
||||
refused(andy.patch(f"{url}/1", json={"fx": 0.1}, headers=im(rv)), 409, "row_conflict")
|
||||
|
||||
# removed with a reason
|
||||
out = ok(andy.request("DELETE", f"{url}/2", json={"reason": "Logged twice"}))
|
||||
assert 2 not in [w["weld_no"] for w in out["welds"]]
|
||||
log = last_log(andy, pkt["id"])
|
||||
assert (log["action"], log["note"]) == ("Weld W2 removed", "Logged twice")
|
||||
# numbers are never reused
|
||||
out = ok(andy.post(url, json=body))
|
||||
assert max(w["weld_no"] for w in out["welds"]) == 13
|
||||
|
||||
# a voided sheet refuses new marks and moving old ones
|
||||
run(s, "UPDATE packet_pages SET void_at = now() WHERE packet_id = %s AND n = 2", (pkt["id"],))
|
||||
refused(andy.post(url, json={**body, "page": 2}), 422, "void_page", MSG_VOID)
|
||||
on_two = next(w for w in fresh(andy, pkt["id"])["welds"] if w["page"] == 2)
|
||||
r = andy.patch(f"{url}/{on_two['weld_no']}", json={"fx": 0.2}, headers=im(on_two["row_version"]))
|
||||
refused(r, 422, "void_page", MSG_VOID)
|
||||
# moving a page-1 weld onto the void sheet is refused too
|
||||
w1 = weld_of(fresh(andy, pkt["id"]), 1)
|
||||
refused(andy.patch(f"{url}/1", json={"page": 2}, headers=im(w1["row_version"])), 422, "void_page", MSG_VOID)
|
||||
# who: Q.C. and the warehouse may not
|
||||
for sam in ("qc.quinn", "wh.wade", "clerk.clara"):
|
||||
refused(shop(sam).post(url, json=body), 403, "forbidden")
|
||||
|
||||
|
||||
# ================================================================ heat tags and the heat table
|
||||
|
||||
|
||||
def test_heat_tags(shop: Shop) -> None:
|
||||
andy = shop("weld.andy")
|
||||
pkt = in_progress(shop)
|
||||
url = f"/api/packets/{pkt['id']}/heat-tags"
|
||||
out = ok(andy.post(url, json={"page": 1, "item": 3, "fx": 0.3, "fy": 0.3, "bfx": 0.35, "bfy": 0.24}))
|
||||
t = tag_of(out, 1)
|
||||
assert (t["item"], t["bfx"], t["bfy"], t["carried"]) == (3, 0.35, 0.24, False)
|
||||
log = last_log(andy, pkt["id"])
|
||||
assert (log["action"], log["note"]) == ("Heat tag H1 placed", "item 3")
|
||||
out = ok(andy.patch(f"{url}/1", json={"fx": 0.4, "bfx": 0.45}, headers=im(t["row_version"])))
|
||||
assert (last_log(andy, pkt["id"])["action"], last_log(andy, pkt["id"])["note"]) == ("Heat tag H1 moved", "item 3")
|
||||
out = ok(andy.patch(f"{url}/1", json={"item": 2}, headers=im(tag_of(out, 1)["row_version"])))
|
||||
log = last_log(andy, pkt["id"])
|
||||
assert (log["action"], log["note"]) == ("Heat tag H1 changed", "item 2")
|
||||
for bad in ({"bfx": 1.01}, {"bfy": -0.01}, {"fx": 2}):
|
||||
assert andy.post(url, json={"page": 1, "item": 1, "fx": 0.2, "fy": 0.2, **bad}).status_code == 422, bad
|
||||
out = ok(andy.post(url, json={"page": 1, "item": 1, "fx": 0.2, "fy": 0.2}))
|
||||
assert tag_of(out, 2)["bfx"] is None
|
||||
out = ok(andy.request("DELETE", f"{url}/1", json={"reason": "Wrong item"}))
|
||||
log = last_log(andy, pkt["id"])
|
||||
assert log["action"] == "Heat tag H1 removed" and log["note"] in ("Wrong item", "item 2 · Wrong item")
|
||||
run(shop.settings, "UPDATE packet_pages SET void_at = now() WHERE packet_id = %s AND n = 2", (pkt["id"],))
|
||||
refused(andy.post(url, json={"page": 2, "item": 1, "fx": 0.2, "fy": 0.2}), 422, "void_page", MSG_VOID)
|
||||
|
||||
|
||||
def test_heat_tag_by_the_warehouse_reads_carried(shop: Shop) -> None:
|
||||
pkt = draft(shop)
|
||||
pkt = to_warehouse(shop, pkt)
|
||||
pkt = ok(
|
||||
shop("wh.wade").post(f"/api/packets/{pkt['id']}/heat-tags", json={"page": 1, "item": 1, "fx": 0.2, "fy": 0.2})
|
||||
)
|
||||
pkt = release(shop, pull_all(shop, pkt))
|
||||
assert tag_of(pkt, 1)["carried"] is True
|
||||
|
||||
|
||||
def test_heat_table(shop: Shop) -> None:
|
||||
andy = shop("weld.andy")
|
||||
pkt = in_progress(shop)
|
||||
url = f"/api/packets/{pkt['id']}/heat-table"
|
||||
sheet1 = page_label(pkt, 1)
|
||||
out = ok(andy.put(f"{url}/1", json={"x": 0.1, "y": 0.1, "s": 1.3}))
|
||||
ht = out["heat_table"][0]
|
||||
assert (ht["page"], ht["x"], ht["y"], ht["s"], ht["carried_from"]) == (1, 0.1, 0.1, 1.3, None)
|
||||
assert ht["by"]["display_name"] == "Andy Welder" and ht["at"]
|
||||
assert (last_log(andy, pkt["id"])["action"], last_log(andy, pkt["id"])["note"]) == (
|
||||
"Heat table placed on drawing",
|
||||
sheet1,
|
||||
)
|
||||
ok(andy.put(f"{url}/1", json={"x": 0.4, "y": 0.2, "s": 1.3}))
|
||||
assert (last_log(andy, pkt["id"])["action"], last_log(andy, pkt["id"])["note"]) == ("Heat table moved", sheet1)
|
||||
ok(andy.put(f"{url}/1", json={"x": 0.4, "y": 0.2, "s": 1.45}))
|
||||
assert (last_log(andy, pkt["id"])["action"], last_log(andy, pkt["id"])["note"]) == ("Heat table resized", "A+ 1.45")
|
||||
ok(andy.put(f"{url}/1", json={"x": 0.4, "y": 0.2, "s": 1.3}))
|
||||
assert (last_log(andy, pkt["id"])["action"], last_log(andy, pkt["id"])["note"]) == ("Heat table resized", "A- 1.3")
|
||||
# the same placement again writes nothing
|
||||
before = len(log_rows(andy, pkt["id"]))
|
||||
ok(andy.put(f"{url}/1", json={"x": 0.4, "y": 0.2, "s": 1.3}))
|
||||
assert len(log_rows(andy, pkt["id"])) == before
|
||||
# validation
|
||||
for bad in (
|
||||
{"x": 0.98, "y": 0.1, "s": 1},
|
||||
{"x": -0.01, "y": 0.1, "s": 1},
|
||||
{"x": 0.1, "y": 0.971, "s": 1},
|
||||
{"x": 0.1, "y": 0.1, "s": 0.59},
|
||||
{"x": 0.1, "y": 0.1, "s": 2.11},
|
||||
):
|
||||
refused(andy.put(f"{url}/1", json=bad), 422, "invalid_heat_table")
|
||||
ok(andy.put(f"{url}/1", json={"x": 0.97, "y": 0.97, "s": 2.1}))
|
||||
ok(andy.put(f"{url}/1", json={"x": 0, "y": 0, "s": 0.6}))
|
||||
refused(andy.put(f"{url}/99", json={"x": 0.1, "y": 0.1, "s": 1}), 422, "invalid_page")
|
||||
# carried_from is accepted and read back
|
||||
out = ok(andy.put(f"{url}/2", json={"x": 0.1, "y": 0.1, "s": 1, "carried_from": 1}))
|
||||
assert next(h for h in out["heat_table"] if h["page"] == 2)["carried_from"] == 1
|
||||
# removed
|
||||
ok(andy.request("DELETE", f"{url}/2"))
|
||||
assert (last_log(andy, pkt["id"])["action"], last_log(andy, pkt["id"])["note"]) == (
|
||||
"Heat table removed from drawing",
|
||||
page_label(pkt, 2),
|
||||
)
|
||||
refused(andy.request("DELETE", f"{url}/2"), 404, "not_found")
|
||||
# Q.C. reads it but may not place it
|
||||
q = fresh(shop("qc.quinn"), pkt["id"])
|
||||
assert [h["page"] for h in q["heat_table"]] == [1]
|
||||
assert "edit_heat_table" not in q["can"]
|
||||
refused(shop("qc.quinn").put(f"{url}/1", json={"x": 0.1, "y": 0.1, "s": 1}), 403, "forbidden")
|
||||
assert "edit_heat_table" in fresh(andy, pkt["id"])["can"]
|
||||
|
||||
|
||||
# ================================================================ heat map borrowing
|
||||
|
||||
|
||||
def test_heat_src_borrowing(shop: Shop) -> None:
|
||||
s = shop.settings
|
||||
_, unit = make_unit(s)
|
||||
_, other_unit = make_unit(s)
|
||||
sub_only = [{"item": 1, "qty": 1, "part_id": "SUB-1", "description": "Nozzle sub-assembly"}]
|
||||
# the sibling with a heat map, rev B, made first
|
||||
old_b = draft(shop, unit, title="Sibling rev B", drawing="D-77 01", rev="B")
|
||||
run(s, "INSERT INTO heat_table_placements (packet_id, page, x, y, s) VALUES (%s, 1, 0.1, 0.1, 1)", (old_b["id"],))
|
||||
# a newer sibling with the same drawing, rev C, with a heat tag only
|
||||
new_c = draft(shop, unit, title="Sibling rev C", drawing="d-7701", rev="C")
|
||||
run(
|
||||
s,
|
||||
"INSERT INTO heat_tags (packet_id, tag_no, page, item, fx, fy) VALUES (%s, 1, 1, 1, 0.2, 0.2)",
|
||||
(new_c["id"],),
|
||||
)
|
||||
# a sibling with no heat map is never a source
|
||||
draft(shop, unit, title="Sibling no map", drawing="D-7701", rev="C")
|
||||
|
||||
# only sub-assembly lines, rev B: borrows rev B (same rev)
|
||||
b = draft(shop, unit, title="Borrower B", drawing="D-7701", rev="B", bom=sub_only)
|
||||
run(s, "UPDATE bom_lines SET sub = 'Y' WHERE packet_id = %s", (b["id"],))
|
||||
src = fresh(shop("sup.wes"), b["id"])["heat_src"]
|
||||
assert src is not None and src["packet_id"] == old_b["id"]
|
||||
assert (src["drawing"], src["rev"], src["status"]) == ("D-77 01", "B", "draft")
|
||||
assert [h["page"] for h in src["heat_table"]] == [1]
|
||||
assert {"n", "type", "asset_id", "pn", "dwg", "rev", "void", "url"} <= set(src["pages"][0])
|
||||
assert src["bom"] and {"item", "heat", "na"} <= set(src["bom"][0])
|
||||
|
||||
# rev C: the same rev wins over the older sibling
|
||||
c = draft(shop, unit, title="Borrower C", drawing="D-7701", rev="C", bom=sub_only)
|
||||
run(s, "UPDATE bom_lines SET sub = 'Y' WHERE packet_id = %s", (c["id"],))
|
||||
assert fresh(shop("sup.wes"), c["id"])["heat_src"]["packet_id"] == new_c["id"]
|
||||
# rev D: no same rev, the oldest sibling
|
||||
d = draft(shop, unit, title="Borrower D", drawing="D-7701", rev="D", bom=[])
|
||||
assert fresh(shop("sup.wes"), d["id"])["heat_src"]["packet_id"] == old_b["id"]
|
||||
# every line N/A borrows too
|
||||
na = draft(shop, unit, title="Borrower NA", drawing="D-7701", rev="B")
|
||||
run(s, "UPDATE bom_lines SET na = true WHERE packet_id = %s", (na["id"],))
|
||||
assert fresh(shop("sup.wes"), na["id"])["heat_src"]["packet_id"] == old_b["id"]
|
||||
|
||||
# its own raw material: no borrowing
|
||||
own = draft(shop, unit, title="Own material", drawing="D-7701", rev="B")
|
||||
assert fresh(shop("sup.wes"), own["id"])["heat_src"] is None
|
||||
# its own heat map: no borrowing
|
||||
mapped = draft(shop, unit, title="Own map", drawing="D-7701", rev="B", bom=[])
|
||||
run(s, "INSERT INTO heat_table_placements (packet_id, page, x, y, s) VALUES (%s, 1, 0.5, 0.5, 1)", (mapped["id"],))
|
||||
assert fresh(shop("sup.wes"), mapped["id"])["heat_src"] is None
|
||||
# another unit or another drawing: no borrowing
|
||||
far = draft(shop, other_unit, title="Other unit", drawing="D-7701", rev="B", bom=[])
|
||||
assert fresh(shop("sup.wes"), far["id"])["heat_src"] is None
|
||||
diff = draft(shop, unit, title="Other drawing", drawing="D-7702", rev="B", bom=[])
|
||||
assert fresh(shop("sup.wes"), diff["id"])["heat_src"] is None
|
||||
# a removed sibling is not a source
|
||||
run(s, "UPDATE packets SET removed_at = now(), removed_reason = 'test' WHERE id = %s", (old_b["id"],))
|
||||
assert fresh(shop("sup.wes"), d["id"])["heat_src"]["packet_id"] == new_c["id"]
|
||||
# read-only: nothing on the source is written through the borrower
|
||||
pk = ready_skipped(shop, unit, drawing="D-7701", rev="C", bom=[])
|
||||
src = fresh(shop("sup.wes"), pk["id"])["heat_src"]
|
||||
assert src["packet_id"] == new_c["id"]
|
||||
before = all_rows(s, "SELECT tag_no, fx FROM heat_tags WHERE packet_id = %s", (new_c["id"],))
|
||||
shop("sup.wes").post(f"/api/packets/{pk['id']}/heat-tags", json={"page": 1, "item": 1, "fx": 0.9, "fy": 0.9})
|
||||
assert all_rows(s, "SELECT tag_no, fx FROM heat_tags WHERE packet_id = %s", (new_c["id"],)) == before
|
||||
|
||||
|
||||
# ================================================================ labor from VISUAL
|
||||
|
||||
|
||||
def test_labor_from_visual_rows(shop: Shop) -> None:
|
||||
s = shop.settings
|
||||
pkt = ready_packet(shop)
|
||||
assert pkt["labor"] is None # a hand packet
|
||||
assert float(pkt["target_hrs"]) == pytest.approx(4.5)
|
||||
with app_conn(s) as conn:
|
||||
conn.execute("UPDATE packets SET source_kind = 'visual' WHERE id = %s", (pkt["id"],))
|
||||
conn.execute(
|
||||
"UPDATE packet_work_orders SET seq = 10 WHERE packet_id = %s AND wo_base = 'L670001'", (pkt["id"],)
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE packet_work_orders SET seq = 20 WHERE packet_id = %s AND wo_base = 'L670002'", (pkt["id"],)
|
||||
)
|
||||
assert fresh(shop("sup.wes"), pkt["id"])["labor"] is None # no rows match yet
|
||||
with app_conn(s) as conn:
|
||||
for base, seq, hours, tickets, last in (
|
||||
("L670001", 10, "1.5", 2, "2026-10-10"),
|
||||
("L670002", 20, "1.7", 1, "2026-10-12"),
|
||||
("L670002", 30, "9.0", 4, "2026-10-12"), # another operation: not this packet's
|
||||
("L670009", 10, "5.0", 1, "2026-10-12"),
|
||||
):
|
||||
conn.execute(
|
||||
"INSERT INTO visual_labor (wo_base, lot, split, sub, seq, hours, tickets, last_ticket)"
|
||||
" VALUES (%s, '001', '0', '0', %s, %s, %s, %s)",
|
||||
(base, seq, hours, tickets, last),
|
||||
)
|
||||
labor = fresh(shop("sup.wes"), pkt["id"])["labor"]
|
||||
assert labor is not None
|
||||
assert float(labor["hours"]) == pytest.approx(3.2) and int(labor["tickets"]) == 3 and labor["observed_at"]
|
||||
per = {w["wo_base"]: w for w in labor["per_wo"]}
|
||||
assert set(per) == {"L670001", "L670002"}
|
||||
assert float(per["L670001"]["hours"]) == pytest.approx(1.5) and per["L670001"]["tickets"] == 2
|
||||
assert per["L670002"]["seq"] == 20 and str(per["L670002"]["last_ticket"]).startswith("2026-10-12")
|
||||
assert (per["L670001"]["lot"], per["L670001"]["split"], per["L670001"]["sub"]) == ("001", "0", "0")
|
||||
|
||||
|
||||
# ================================================================ the board, stations, stall
|
||||
|
||||
|
||||
def _board(shop: Shop, sam: str, **params: Any) -> Any:
|
||||
return shop(sam).get("/api/shop/board", params=params)
|
||||
|
||||
|
||||
def _ids(cards: list[dict[str, Any]]) -> set[int]:
|
||||
return {c["id"] for c in cards}
|
||||
|
||||
|
||||
def test_shop_board(shop: Shop) -> None:
|
||||
s = shop.settings
|
||||
set_stamp(s, "weld.andy", "75")
|
||||
at_stall = in_progress(shop, title="At the stall")
|
||||
set_running_since(s, at_stall["id"], 600)
|
||||
waiting = ready_packet(shop, title="Waiting to assign")
|
||||
review = in_review(shop, title="Waiting on review")
|
||||
six = ready_skipped(shop, dept="06", title="In code bldg 2")
|
||||
at_stall = issue_co(shop, at_stall, ["03"])
|
||||
|
||||
b = ok(_board(shop, "sup.wes"))
|
||||
assert [d["code"] for d in b["depts"]] == ["03"]
|
||||
d03 = b["depts"][0]
|
||||
assert (d03["name"], d03["weld"], d03["level"]) == ("Weld Parts", True, "supervisor")
|
||||
assert [st["name"] for st in d03["stations"]] == [f"Stall {n}" for n in range(1, 7)]
|
||||
stall1 = d03["stations"][0]
|
||||
assert [p["id"] for p in stall1["packets"]] == [at_stall["id"]]
|
||||
tile = stall1["packets"][0]
|
||||
assert tile["welder"]["display_name"] == "Andy Welder" and tile["welder_stamp"] == "75"
|
||||
assert tile["station"]["name"] == "Stall 1" and tile["status"] == "in_progress"
|
||||
assert tile["clock"]["running"] is True and tile["clock"]["elapsed_ms"] >= 600_000
|
||||
assert float(tile["target_hrs"]) == pytest.approx(4.5)
|
||||
assert [{k: c[k] for k in ("label", "review")} for c in tile["open_cos"]] == [
|
||||
{"label": "CO-1", "review": "approved"}
|
||||
]
|
||||
assert all(st["packets"] == [] for st in d03["stations"][1:])
|
||||
|
||||
k = b["kpis"]
|
||||
assert (k["ready"], k["at_stations"], k["review"]) == (1, 1, 1)
|
||||
assert k["elapsed_ms"] >= 600_000 and float(k["target_hrs"]) == pytest.approx(4.5)
|
||||
cols = b["columns"]
|
||||
assert set(cols) == {"ready", "review", "qc", "warehouse", "complete"}
|
||||
assert _ids(cols["ready"]) == {waiting["id"]} and _ids(cols["review"]) == {review["id"]}
|
||||
assert cols["qc"] == [] and cols["complete"] == [] and cols["warehouse"] == []
|
||||
every = set().union(*(_ids(c) for c in cols.values()))
|
||||
assert six["id"] not in every
|
||||
assert len(b["cos"]) == 1
|
||||
co = b["cos"][0]
|
||||
assert (co["packet_id"], co["co_no"], co["label"], co["review"], co["mine"]) == (
|
||||
at_stall["id"],
|
||||
1,
|
||||
"CO-1",
|
||||
"approved",
|
||||
True,
|
||||
)
|
||||
assert co["pending_depts"] == ["03"] and co["dept_code"] == "03" and co["op"] == "WELD-PART"
|
||||
assert co["title"] == "At the stall" and co["reason"] and co["issued_at"]
|
||||
|
||||
# dept narrows within the scope; outside it is refused
|
||||
assert [d["code"] for d in ok(_board(shop, "sup.wes", dept="03"))["depts"]] == ["03"]
|
||||
assert [d["code"] for d in ok(_board(shop, "sup.wes", dept="all"))["depts"]] == ["03"]
|
||||
refused(_board(shop, "sup.wes", dept="06"), 403, "forbidden")
|
||||
# project and unit narrow everything
|
||||
other = ok(_board(shop, "sup.wes", unit_id=waiting["unit"]["id"]))
|
||||
assert _ids(other["columns"]["ready"]) == {waiting["id"]} and other["columns"]["review"] == []
|
||||
assert other["depts"][0]["stations"][0]["packets"] == [] and other["cos"] == []
|
||||
assert other["kpis"]["at_stations"] == 0
|
||||
proj = ok(_board(shop, "sup.wes", project_id=review["unit"]["project"]["id"]))
|
||||
assert _ids(proj["columns"]["review"]) == {review["id"]} and proj["columns"]["ready"] == []
|
||||
|
||||
# the lead sees the same scope at lead level
|
||||
lou = ok(_board(shop, "lead.lou"))
|
||||
assert [(d["code"], d["level"]) for d in lou["depts"]] == [("03", "lead")]
|
||||
assert lou["cos"][0]["mine"] is False
|
||||
|
||||
# multi.mia leads 06 only
|
||||
mia = ok(_board(shop, "multi.mia"))
|
||||
assert [(d["code"], d["level"]) for d in mia["depts"]] == [("06", "supervisor")]
|
||||
assert _ids(mia["columns"]["ready"]) == {six["id"]} and mia["cos"] == []
|
||||
every = set().union(*(_ids(c) for c in mia["columns"].values()))
|
||||
assert not {at_stall["id"], waiting["id"], review["id"]} & every
|
||||
refused(_board(shop, "multi.mia", dept="03"), 403, "forbidden")
|
||||
|
||||
# a worker leads nothing; nor do support roles
|
||||
for sam in ("weld.andy", "dup.dave", "qc.quinn", "wh.wade", "clerk.clara", "pm.paula"):
|
||||
refused(_board(shop, sam), 403, "forbidden", MSG_NOT_LEAD)
|
||||
|
||||
# admin: every active shop department, at admin level
|
||||
ann = ok(_board(shop, "admin.ann"))
|
||||
assert [d["code"] for d in ann["depts"]] == SHOP_DEPTS
|
||||
assert {d["level"] for d in ann["depts"]} == {"admin"}
|
||||
assert _ids(ann["columns"]["ready"]) == {waiting["id"], six["id"]}
|
||||
assert ok(_board(shop, "admin.ann", dept="09"))["depts"][0]["code"] == "09"
|
||||
|
||||
|
||||
def test_shop_board_cos_follow_my_departments(shop: Shop) -> None:
|
||||
"""A CO pending 03 shows on the 03 board when the packet has moved on to Q.C."""
|
||||
pkt = in_review(shop)
|
||||
pkt = ok(step(shop("sup.wes"), pkt, "send_to_qc", book=True, spool=True, location="Bay 2", sign=SIGN))
|
||||
pkt = issue_co(shop, pkt, ["03", "QC"])
|
||||
b = ok(_board(shop, "sup.wes"))
|
||||
assert _ids(b["columns"]["qc"]) == {pkt["id"]}
|
||||
assert [(c["packet_id"], c["mine"], c["pending_depts"]) for c in b["cos"]] == [(pkt["id"], True, ["03", "QC"])]
|
||||
mia = ok(_board(shop, "multi.mia"))
|
||||
assert mia["cos"] == []
|
||||
# a pending feed CO with hold shows as "review" pending, hold true
|
||||
run(shop.settings, "UPDATE packets SET next_co_no = next_co_no + 1 WHERE id = %s", (pkt["id"],))
|
||||
run(
|
||||
shop.settings,
|
||||
"INSERT INTO change_orders (packet_id, co_no, origin, review, hold, ecn, reason, vis)"
|
||||
" VALUES (%s, 2, 'visual', 'pending', true, 'VISUAL', 'VISUAL changed the packet: qty', '{}')",
|
||||
(pkt["id"],),
|
||||
)
|
||||
b = ok(_board(shop, "sup.wes"))
|
||||
held = [c for c in b["cos"] if c["co_no"] == 2]
|
||||
assert held and held[0]["review"] == "pending" and held[0]["hold"] is True
|
||||
|
||||
|
||||
def test_shop_stations(shop: Shop) -> None:
|
||||
s = shop.settings
|
||||
|
||||
def names(sam: str) -> list[tuple[str, str]]:
|
||||
return [(x["dept_code"], x["name"]) for x in ok(shop(sam).get("/api/shop/stations"))]
|
||||
|
||||
stalls = [("03", f"Stall {n}") for n in range(1, 7)]
|
||||
assert names("weld.andy") == stalls
|
||||
first = ok(shop("weld.andy").get("/api/shop/stations"))[0]
|
||||
assert first["dept_name"] == "Weld Parts" and first["id"] == shop.stall()
|
||||
set_limits(s, "weld.andy", [shop.stall("03", "Stall 2"), shop.stall("03", "Stall 5")])
|
||||
assert names("weld.andy") == [("03", "Stall 2"), ("03", "Stall 5")]
|
||||
# limits bind workers only
|
||||
set_limits(s, "sup.wes", [shop.stall("03", "Stall 2")])
|
||||
assert names("sup.wes") == stalls
|
||||
assert names("lead.lou") == stalls
|
||||
assert names("multi.mia") == [("06", f"Stall {n}") for n in range(1, 7)]
|
||||
for sam in ("qc.quinn", "wh.wade", "clerk.clara", "pm.paula"):
|
||||
assert names(sam) == [], sam
|
||||
total = one(s, "SELECT count(*) FROM stations WHERE active")[0]
|
||||
assert len(names("admin.ann")) == total
|
||||
|
||||
|
||||
def test_shop_stall(shop: Shop) -> None:
|
||||
s = shop.settings
|
||||
mine = in_progress(shop, title="On the stall")
|
||||
waiting = to_station(shop, ready_packet(shop, title="Queued at the stall"))
|
||||
sent = in_review(shop, title="Sent to review")
|
||||
elsewhere = to_station(shop, ready_packet(shop, title="Stall 3 job"), station="Stall 3")
|
||||
stall1 = shop.stall()
|
||||
|
||||
out = ok(shop("weld.andy").get("/api/shop/stall", params={"station_id": stall1}))
|
||||
assert out["station"] == {"id": stall1, "name": "Stall 1", "dept_code": "03", "dept_name": "Weld Parts"}
|
||||
assert _ids(out["mine"]) == {mine["id"], waiting["id"]}
|
||||
assert _ids(out["submitted"]) == {sent["id"]}
|
||||
assert elsewhere["id"] not in _ids(out["mine"]) | _ids(out["submitted"])
|
||||
card = next(c for c in out["mine"] if c["id"] == mine["id"])
|
||||
assert card["status"] == "in_progress" and card["clock"]["running"] is True
|
||||
|
||||
# a station outside the user's list
|
||||
set_limits(s, "weld.andy", [shop.stall("03", "Stall 2")])
|
||||
refused(shop("weld.andy").get("/api/shop/stall", params={"station_id": stall1}), 403, "forbidden")
|
||||
refused(shop("multi.mia").get("/api/shop/stall", params={"station_id": stall1}), 403, "forbidden")
|
||||
refused(shop("qc.quinn").get("/api/shop/stall", params={"station_id": stall1}), 403, "forbidden")
|
||||
ok(shop("admin.ann").get("/api/shop/stall", params={"station_id": stall1}))
|
||||
ok(shop("sup.wes").get("/api/shop/stall", params={"station_id": stall1}))
|
||||
# after routing on, the packet leaves the stall
|
||||
ok(step(shop("sup.wes"), sent, "route_from_review", dept="09", sign=SIGN))
|
||||
out = ok(shop("sup.wes").get("/api/shop/stall", params={"station_id": stall1}))
|
||||
assert sent["id"] not in _ids(out["submitted"])
|
||||
|
||||
|
||||
def test_server_now_and_target_hours(shop: Shop) -> None:
|
||||
pkt = ready_packet(shop)
|
||||
assert isinstance(pkt["server_now"], str) and "T" in pkt["server_now"]
|
||||
assert float(pkt["target_hrs"]) == pytest.approx(4.5)
|
||||
empty = ready_skipped(shop, wos=[])
|
||||
assert float(empty["target_hrs"]) == 0
|
||||
|
||||
|
||||
def test_change_order_shape(shop: Shop) -> None:
|
||||
pkt = issue_co(shop, ready_packet(shop), ["03"])
|
||||
co = pkt["change_orders"][0]
|
||||
assert co["label"] == "CO-1" and co["pending_depts"] == ["03"] and co["open"] is True
|
||||
assert last_log(shop("sup.wes"), pkt["id"])["action"] == "Change order CO-1 issued"
|
||||
out = ok(
|
||||
shop("sup.wes").post(
|
||||
f"/api/packets/{pkt['id']}/change-orders/1/verify", json={"dept": "03", "note": "OK", "sign": SIGN}
|
||||
)
|
||||
)
|
||||
co = out["change_orders"][0]
|
||||
assert co["pending_depts"] == [] and co["open"] is False
|
||||
v = co["verified"]["03"]
|
||||
assert v["signed"] is True and v["by"]["display_name"] == "Wes Super" and v["note"] == "OK"
|
||||
log = find_log(shop("sup.wes"), pkt["id"], "Change order CO-1 verified by Weld Parts")
|
||||
assert log["note"] == "OK · change order fully verified"
|
||||
Reference in New Issue
Block a user