Files
PacketStation/tests/server/test_shop_api.py
T
spencermandClaude Opus 5.5 70a97398f1
ci / server (pull_request) Successful in 12m48s
ci / frontend (pull_request) Successful in 54s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 4m38s
Build Seth's changes C1 to C11 (Gitea issues 1, 8, 18, 23, 24, 25, 27, 31, 32, 33, 36)
- C1 (issue 1): over target follows VISUAL ticket hours, not the timer.
- C2 (issue 24): a Q.C. reject always needs a reason; accept needs an NCR number after a reject without one.
- C3 (issue 25): an incomplete unit ITP blocks filing.
- C4 (issue 27): prints open the print dialog on the page, no new tab.
- C5 (issue 31): filing is blocked while VISUAL shows a work order open; the feed sets vclosed on VISUAL packets.
- C6 (issue 8): the data book leaves out the history pages.
- C7 (issue 18): the supervisor approves each received short; accept and filing wait for it.
- C8 (issue 23): per-department checklists, edited by the department supervisor or Admin, frozen at START.
- C9 (issue 32): Q.C. and Clerical may run the stamp check.
- C10 (issue 33): the PM screen and reports are for PS-ProjectMgmt and Admin only.
- C11 (issue 36): the Stall screen lists the packets assigned to the welder, at the supervisor's stall.

Migration 0011. Docs: PLAN (also issue 9 confirmed, issue 4 asked), POLICY, SECURITY, SCHEMA, FEED, README, CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 16:36:49 -06:00

953 lines
46 KiB
Python

"""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"]
# change C8: the started stage keeps the list it started with, whatever the settings say now
custom_keys = ["dwg", "wps", "vt"]
set_templates(s, {"weld": "nonsense"})
assert [i["key"] for i in fresh(andy, pkt["id"])["checklist_items"]] == custom_keys
# a malformed row falls back to the defaults in code for a stage not yet started
later = to_station(shop, ready_packet(shop))
assert [i["key"] for i in fresh(andy, later["id"])["checklist_items"]] == list(WELD_KEYS)
run(s, "DELETE FROM settings WHERE key = 'checklist_templates'")
assert [i["key"] for i in fresh(andy, later["id"])["checklist_items"]] == list(WELD_KEYS)
assert [i["key"] for i in fresh(andy, pkt["id"])["checklist_items"]] == custom_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"