949 lines
46 KiB
Python
949 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"]
|
||
|
|
|
||
|
|
# 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"
|