- 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]>
170 lines
8.3 KiB
Python
170 lines
8.3 KiB
Python
"""Change C7 (Gitea issue 18): every received short needs the department supervisor's approval, one receipt at a time.
|
|
|
|
SHORT_APPROVE through POST /api/packets/{id}/shorts/receipts/{receipt_id}/approve: who (the supervisor of the
|
|
packet's department, Admin with an IT reason), the signature, one approval per receipt (409), the read model (who
|
|
received and who approved each receipt, shorts_unapproved, can), and the shorts_approved precondition on QC_ACCEPT
|
|
and FILE (not on QC_ACCEPT_AND_ROUTE). Nothing here reads VISUAL or M-Files."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Iterator
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from closehelpers import at_complete, file_packet, q1, vclose
|
|
from fakedir import FakeDirectory
|
|
from pshelpers import app_conn, needs_pg
|
|
from qchelpers import accept, accept_route, finish_inspection, to_qc
|
|
from shophelpers import (
|
|
IT,
|
|
SIGN,
|
|
Shop,
|
|
draft,
|
|
find_log,
|
|
finish_stage,
|
|
fresh,
|
|
im,
|
|
line_of,
|
|
ok,
|
|
open_shop,
|
|
refused,
|
|
release,
|
|
start,
|
|
submit,
|
|
to_station,
|
|
to_warehouse,
|
|
)
|
|
|
|
from packetstation.settings import Settings
|
|
|
|
pytestmark = needs_pg
|
|
|
|
MSG = "Every short must be received and approved by the supervisor first."
|
|
|
|
|
|
@pytest.fixture
|
|
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
|
|
yield from open_shop(pg_settings, fake_dir)
|
|
|
|
|
|
def approve(shop: Shop, pkt: dict[str, Any], receipt_id: int, by: str = "sup.wes", **body: Any) -> Any:
|
|
body.setdefault("sign", SIGN)
|
|
return shop(by).post(f"/api/packets/{pkt['id']}/shorts/receipts/{receipt_id}/approve", json=body)
|
|
|
|
|
|
def receive(shop: Shop, pkt: dict[str, Any], item: int, qty: int, heat: str, by: str = "wh.wade") -> dict[str, Any]:
|
|
return ok(shop(by).post(f"/api/packets/{pkt['id']}/bom/{item}/short/receipts", json={"qty": qty, "heat": heat}))
|
|
|
|
|
|
def short_at_qc(shop: Shop) -> dict[str, Any]:
|
|
"""Items 1 and 2 pulled; item 3 short 2 of 4, one piece received (HT-31), one still open; through the shop
|
|
and at Q.C. with the inspection finished."""
|
|
pkt = to_warehouse(shop, draft(shop))
|
|
wade = shop("wh.wade")
|
|
pid = pkt["id"]
|
|
for item in (1, 2):
|
|
cur = line_of(pkt, item)
|
|
pkt = ok(wade.patch(f"/api/packets/{pid}/bom/{item}", json={"pulled": True}, headers=im(cur["row_version"])))
|
|
cur = line_of(pkt, item)
|
|
pkt = ok(wade.patch(f"/api/packets/{pid}/bom/{item}", json={"heat": "HT-1"}, headers=im(cur["row_version"])))
|
|
pkt = ok(wade.post(f"/api/packets/{pid}/bom/3/short", json={"qty": 2, "reason": "On order", "expect_open": False}))
|
|
pkt = receive(shop, pkt, 3, 1, "HT-31")
|
|
pkt = start(shop, to_station(shop, release(shop, pkt)))
|
|
pkt = submit(shop, finish_stage(shop, pkt, welds=1))
|
|
return finish_inspection(shop, to_qc(shop, pkt))
|
|
|
|
|
|
def test_approve_receipts_then_accept(shop: Shop) -> None:
|
|
pkt = short_at_qc(shop)
|
|
pid = pkt["id"]
|
|
assert pkt["shorts_unapproved"] == 2 # one open short and one receipt without approval
|
|
assert pkt["blocked"]["qc_accept"] == [MSG]
|
|
assert "qc_accept_and_route" in pkt["can"] and "qc_accept_and_route" not in pkt["blocked"]
|
|
refused(accept(shop, pkt), 403, "precondition", MSG)
|
|
|
|
[short] = pkt["shorts"]
|
|
assert (short["item"], float(short["qty"]), float(short["qty_open"]), short["closed_at"]) == (3, 2, 1, None)
|
|
[r1] = short["receipts"]
|
|
assert float(r1["qty"]) == 1 and r1["heat"] == "HT-31" and r1["by"]["display_name"] and r1["approval"] is None
|
|
assert line_of(pkt, 3)["short"]["receipts"][0]["approval"] is None
|
|
|
|
# who: the supervisor of the packet's department; a lead, Q.C., the warehouse and Clerical are refused
|
|
wes = fresh(shop("sup.wes"), pid)
|
|
assert "short_approve" in wes["can"]
|
|
for sam in ("lead.lou", "weld.andy", "qc.quinn", "wh.wade", "clerk.leo"):
|
|
assert "short_approve" not in fresh(shop(sam), pid)["can"], sam
|
|
refused(approve(shop, pkt, r1["id"], by=sam), 403, "forbidden", "Your access doesn't allow this step.")
|
|
refused(approve(shop, pkt, r1["id"], by="admin.ann"), 400, "it_reason_required")
|
|
# a signature is required; an unknown receipt is 404
|
|
refused(approve(shop, pkt, r1["id"], sign=None), 400, "signature_required")
|
|
refused(approve(shop, pkt, r1["id"] + 100000), 404, "not_found")
|
|
|
|
out = ok(approve(shop, pkt, r1["id"], note="Heat matches the issue ticket"))
|
|
a = out["shorts"][0]["receipts"][0]["approval"]
|
|
assert a["by"]["id"] == str(shop.uid("sup.wes")) and a["by"]["display_name"], a
|
|
assert a["note"] == "Heat matches the issue ticket" and a["at"] and a["dept_code"] == "03"
|
|
assert line_of(out, 3)["short"]["receipts"][0]["approval"]["note"] == "Heat matches the issue ticket"
|
|
assert out["shorts_unapproved"] == 1 # the short is still open
|
|
log = find_log(shop("sup.wes"), pid, "Short approved · item 3")
|
|
assert log["note"] == "1 pc · heat HT-31 · Heat matches the issue ticket" and log["signed"] is True
|
|
row = q1(shop.settings, "SELECT dept_code, signature_id FROM short_approvals WHERE receipt_id = %s", (r1["id"],))
|
|
assert row[0] == "03" and row[1] is not None
|
|
# one approval per receipt
|
|
refused(approve(shop, pkt, r1["id"]), 409, "already_approved", "This receipt is already approved.")
|
|
|
|
# the warehouse receives the last piece: the short closes, the new receipt waits for approval
|
|
out = receive(shop, out, 3, 1, "HT-32")
|
|
assert out["shorts"][0]["closed_at"] is not None and line_of(out, 3)["short"] is None
|
|
assert out["shorts_unapproved"] == 1 and fresh(shop("qc.quinn"), pid)["blocked"]["qc_accept"] == [MSG]
|
|
r2 = out["shorts"][0]["receipts"][1]
|
|
assert r2["heat"] == "HT-32" and r2["approval"] is None
|
|
# Admin approves through the bypass with an IT reason
|
|
out = ok(approve(shop, out, r2["id"], by="admin.ann", it_reason=IT))
|
|
assert out["shorts_unapproved"] == 0 and "qc_accept" not in fresh(shop("qc.quinn"), pid)["blocked"]
|
|
done = ok(accept(shop, fresh(shop("qc.quinn"), pid)))
|
|
assert done["status"] == "complete"
|
|
# an approval needs no open step: still open to the supervisor at complete, but nothing is left to approve
|
|
refused(approve(shop, done, r2["id"]), 409, "already_approved")
|
|
|
|
|
|
def test_accept_and_route_does_not_wait_for_the_shorts(shop: Shop) -> None:
|
|
pkt = short_at_qc(shop)
|
|
out = ok(accept_route(shop, pkt, "09"))
|
|
assert out["status"] == "ready" and out["dept"]["code"] == "09" and out["shorts_unapproved"] == 2
|
|
# the packet's department is now 09: the 03 supervisor no longer approves, a 09 supervisor would
|
|
r1 = out["shorts"][0]["receipts"][0]
|
|
refused(approve(shop, out, r1["id"]), 403, "forbidden")
|
|
|
|
|
|
def test_file_waits_for_the_approval_and_the_filed_lock(shop: Shop) -> None:
|
|
"""A packet completed before C7 with a receipt nobody approved: FILE shows and refuses it until approved."""
|
|
pkt = at_complete(shop)
|
|
pid = pkt["id"]
|
|
with app_conn(shop.settings) as conn:
|
|
line = conn.execute("SELECT id FROM bom_lines WHERE packet_id = %s AND item = 3", (pid,)).fetchone()[0]
|
|
sid = conn.execute(
|
|
"INSERT INTO bom_shorts (packet_id, bom_line_id, qty, qty_open, reason, closed_at)"
|
|
" VALUES (%s, %s, 1, 0, 'On order', now()) RETURNING id",
|
|
(pid, line),
|
|
).fetchone()[0]
|
|
rid = conn.execute(
|
|
"INSERT INTO short_receipts (short_id, packet_id, qty, heat) VALUES (%s, %s, 1, 'HT-77') RETURNING id",
|
|
(sid, pid),
|
|
).fetchone()[0]
|
|
pkt = vclose(shop, fresh(shop("clerk.clara"), pid))
|
|
assert pkt["shorts_unapproved"] == 1 and pkt["blocked"]["file"] == [MSG]
|
|
refused(file_packet(shop, pkt), 403, "precondition", MSG)
|
|
[card] = ok(shop("clerk.clara").get(f"/api/packets?unit_id={pkt['unit']['id']}"))["packets"]
|
|
assert card["shorts_unapproved"] == 1 and card["blocked"]["file"] == [MSG]
|
|
ok(approve(shop, pkt, rid))
|
|
out = ok(file_packet(shop, fresh(shop("clerk.clara"), pid)))
|
|
assert out["status"] == "filed"
|
|
# filed: nobody approves any more
|
|
refused(approve(shop, out, rid, by="admin.ann", it_reason=IT), 409, "already_approved")
|
|
with app_conn(shop.settings) as conn:
|
|
rid2 = conn.execute(
|
|
"INSERT INTO short_receipts (short_id, packet_id, qty, heat) VALUES (%s, %s, 1, 'HT-78') RETURNING id",
|
|
(sid, pid),
|
|
).fetchone()[0]
|
|
refused(approve(shop, out, rid2), 403, "filed")
|