Phase 6: Q.C. inspection, project folders, packet documents and the digital ITP
ci / frontend (push) Successful in 37s
ci / server (push) Successful in 9m18s
ci / image (push) Successful in 35s
ci / e2e (push) Successful in 2m17s

The Q.C. screen (KPIs, boards, project folders), the Q.C. tab on the packet
page (check items from the qc_check_items settings row, visual exam per weld,
NDE, hardness with the NACE label, notes), accept with a route and a
signature gated by qc_missing_empty, reject with a reason or an NCR number
closing the inspection into qc_history, Q.C. folder documents per project and
unit with superseding revisions, packet documents as a closed set with
take-out reasons, the digital ITP per unit from the frozen template (line
initials with CAS, unsign, N/A, customer codes, rejects with NCR, block
signatures, sign-off with the PDF hook, admin reopen, the ITP log), hold-point
warnings by department page group, the scoped policy check for unit and
project writes, migration 0007, tests, Playwright scenarios and docs.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-17 20:08:53 -06:00
co-authored by Claude Fable 5.1
parent 8bb1f7ae4d
commit 00531cb329
64 changed files with 11715 additions and 260 deletions
+504
View File
@@ -0,0 +1,504 @@
"""Helpers for the phase 6 Q.C. tests: a packet brought to status qc with two welds, the Q.C. inspection endpoints,
accept and reject, the digital ITP, and Q.C. folder documents.
Built on shophelpers (imported, never edited). The packet is the shop packet: a hand draft in 03 (drawing D-5001
rev B, WPS-11, three BOM lines with heats, two work orders), assigned to weld.andy, two welds, submitted, and sent
to Q.C. by sup.wes with the book and the spool confirmed at `Bay 2`.
"""
from __future__ import annotations
import importlib
import uuid
from collections.abc import Iterator
from datetime import datetime
from typing import Any
from zoneinfo import ZoneInfo
import pytest
from fakedir import FakeDirectory, FakeUser
from fastapi.testclient import TestClient
from pkthelpers import step
from shophelpers import (
SIGN,
USERS,
Shop,
finish_stage,
in_progress,
ok,
open_shop,
submit,
)
from packetstation.settings import Settings
NCR = "NCR-2026-0021"
NCR2 = "NCR-2026-0022"
NCR_URL = "https://ncr.pescoinc.biz/ncrs/"
MSG_NCR = "Enter an NCR number like NCR-2026-0021."
MSG_ITP_COMPLETE = "The ITP is signed off and locked. An admin can reopen it."
MSG_NCR_REQUIRED = "Enter the NCR # before approving this line."
MSG_UNSIGN = "Only the inspector who initialed this line, or an admin, can remove it."
MSG_BLOCK_SIGNED = "Undo the section signature first."
MSG_BLOCK_NOT_READY = "Every line must be initialed or N/A first."
MSG_BLOCKS_UNSIGNED = "Every section must be signed first."
MSG_DOC_KIND = "Pick a document type from the list."
MSG_TAKE_OUT = "Say why it comes out of the data book."
MSG_NDE = "Pick an NDE method from the list."
ROUTE_DB = "Clerical – Data Book" # the en dash is the old text
TEMPLATE_VER = "ITP_Generation_Revised_2"
QC_KEYS = (
"db_heats",
"db_mtr",
"db_weldmap",
"db_wps",
"db_ck",
"ph_dim",
"ph_heat",
"ph_stamp",
"ph_clean",
"ph_qty",
)
DOC_KINDS = {
"nde": "NDE report (RT / UT / MT / PT)",
"hydro": "Hydrotest report",
"dimensional": "Dimensional report",
"pmi": "PMI report",
"coating": "Coating / paint report",
"coc": "Certificate of conformance",
"customer": "Customer inspection report",
"ncr": "NCR",
"other": "Other",
}
QC_TYPES = (
"ITP",
"WPS / PQR",
"NDE procedure",
"Hydrotest procedure",
"PMI / material",
"Calibration certificate",
"Customer spec",
"Other",
)
# The template, counted from src/js/itp.js line 4 (ITP_TMPL): 70 items in 9 sections.
TEMPLATE_ITEMS = 70
SECTION_SIZES = {"1": 14, "2": 3, "3": 4, "4": 6, "5": 5, "6": 6, "7": 11, "8": 14, "9": 7}
SECTION_PAGE = {"1": "WP", "2": "WP", "3": "WP", "4": "C", "5": "C", "6": "C", "7": "FA", "8": "FA", "9": "FA"}
SECTION_TITLE_5 = "INTERNAL COATING"
# Hold (H) and witness (W) lines on the PESCO or the template customer code, by page.
HOLDS = {
"WP": ("3.4",),
"C": ("4.2", "4.4", "4.5", "5.1", "5.2", "5.3", "5.5", "6.1", "6.2", "6.3", "6.6"),
"FA": ("7.5", "7.9", "7.11", "9.6", "9.7"),
}
ALL_HOLDS = HOLDS["WP"] + HOLDS["C"] + HOLDS["FA"]
BLOCKS = {
"3": ["1", "2", "3"],
"4": ["4"],
"5": ["5"],
"6": ["6"],
"7": ["7"],
"8": ["8"],
"9": ["9"],
}
DEPT_PAGES = {
"01": ["WP"],
"02": ["WP"],
"03": ["WP"],
"04": ["WP"],
"05": ["WP"],
"10": ["WP"],
"06": ["C"],
"07": ["C"],
"13": ["C"],
"09": ["WP", "C"],
"08": ["FA"],
"11": ["FA"],
"12": ["FA"],
"14": ["FA"],
}
# Two extra people for the "another Q.C. user" and Q.C. supervisor cases. FakeDirectory takes a user list, so a
# test module adds them through its own fake_dir fixture; fakedir.py stays as it is.
QC_SUE = FakeUser("qc.sue", "Sue Second", "5002", ["PS-QC"])
QC_SAM = FakeUser("qcsup.sam", "Sam Qcsup", "5003", ["PS-QC-Supervisor"])
QC_USERS = (*USERS, "qc.sue", "qcsup.sam")
def qc_directory() -> FakeDirectory:
d = FakeDirectory()
d.users.extend(
[
FakeUser(QC_SUE.sam, QC_SUE.display, QC_SUE.emp_no, list(QC_SUE.groups)),
FakeUser(QC_SAM.sam, QC_SAM.display, QC_SAM.emp_no, list(QC_SAM.groups)),
]
)
return d
def open_qc_shop(settings: Settings, fake_dir: FakeDirectory) -> Iterator[Shop]:
"""open_shop, plus the two extra Q.C. people signed in once when the directory has them."""
for shop in open_shop(settings, fake_dir):
for sam in ("qc.sue", "qcsup.sam"):
if any(u.sam == sam for u in fake_dir.users):
shop(sam)
yield shop
def today_us() -> str:
"""M/D/YYYY in America/Denver, the date form of the ITP notes."""
d = datetime.now(ZoneInfo("America/Denver"))
return f"{d.month}/{d.day}/{d.year}"
def today_iso() -> str:
return datetime.now(ZoneInfo("America/Denver")).date().isoformat()
def ok_any(r: Any) -> dict[str, Any]:
"""200 or 201 (the contract does not fix the create code)."""
assert r.status_code in (200, 201), r.text
out: dict[str, Any] = r.json()
return out
def g(obj: Any, key: str) -> Any:
"""A field of a dict or an object (the pure helpers may return either)."""
return obj[key] if isinstance(obj, dict) else getattr(obj, key)
def delete_json(c: TestClient, url: str, body: dict[str, Any] | None = None, **kw: Any) -> Any:
return c.request("DELETE", url, json=body, **kw)
# ---------------------------------------------------------------- the packet
def to_qc(shop: Shop, pkt: dict[str, Any], location: str = "Bay 2", by: str = "sup.wes", **kw: Any) -> dict[str, Any]:
out = ok(step(shop(by), pkt, "send_to_qc", book=True, spool=True, location=location, sign=SIGN, **kw))
assert out["status"] == "qc", out["status"]
return out
def at_qc(shop: Shop, unit_id: int | None = None, welds: int = 2, **kw: Any) -> dict[str, Any]:
"""A shop packet with `welds` welds, submitted, and sent to Q.C. at Bay 2 by sup.wes."""
pkt = in_progress(shop, unit_id, **kw)
pkt = finish_stage(shop, pkt, welds=welds)
pkt = submit(shop, pkt)
return to_qc(shop, pkt)
def live_welds(pkt: dict[str, Any]) -> list[int]:
return sorted(int(w["weld_no"]) for w in pkt["welds"] if not w.get("removed_at") and not w.get("removed"))
def qc_check(c: TestClient, pid: int, key: str, done: bool = True) -> Any:
return c.put(f"/api/packets/{pid}/qc/checks/{key}", json={"done": done})
def qc_weld(c: TestClient, pid: int, no: int, **body: Any) -> Any:
return c.put(f"/api/packets/{pid}/qc/welds/{no}", json=body)
def qc_patch(c: TestClient, pid: int, set_: dict[str, Any], base: dict[str, Any] | None = None, **kw: Any) -> Any:
return c.patch(f"/api/packets/{pid}/qc", json={"set": set_, "base": base or {}, **kw})
def finish_inspection(
shop: Shop,
pkt: dict[str, Any],
by: str = "qc.quinn",
inspector: str = "Quinn QC",
stamp: str = "Q1",
nde: tuple[str, str] | None = None,
) -> dict[str, Any]:
"""Every check ticked, every live weld accepted, the inspector and stamp set (and the NDE when given)."""
c = shop(by)
pid = pkt["id"]
fields: dict[str, Any] = {"inspector": inspector, "stamp": stamp}
pkt = ok(qc_patch(c, pid, fields))
for k in QC_KEYS:
pkt = ok(qc_check(c, pid, k))
for no in live_welds(pkt):
pkt = ok(qc_weld(c, pid, no, result="accept"))
if nde is not None:
pkt = ok(qc_patch(c, pid, {"nde_method": nde[0]}))
pkt = ok(qc_patch(c, pid, {"nde_result": nde[1]}))
assert pkt["qc"]["missing"] == [], pkt["qc"]["missing"]
return pkt
def accept(shop: Shop, pkt: dict[str, Any], routed_to: str = "databook", by: str = "qc.quinn") -> Any:
return step(shop(by), pkt, "qc_accept", routed_to=routed_to, sign=SIGN)
def accept_route(shop: Shop, pkt: dict[str, Any], dept: str = "09", by: str = "qc.quinn") -> Any:
return step(shop(by), pkt, "qc_accept_and_route", dept=dept, sign=SIGN)
def reject(
shop: Shop, pkt: dict[str, Any], reason: str | None = None, ncr: str | None = None, by: str = "qc.quinn"
) -> Any:
body: dict[str, Any] = {"sign": SIGN}
if reason is not None:
body["reason"] = reason
if ncr is not None:
body["ncr"] = ncr
return step(shop(by), pkt, "qc_reject", **body)
def accepted_packet(shop: Shop, unit_id: int | None = None, routed_to: str = "databook") -> dict[str, Any]:
pkt = finish_inspection(shop, at_qc(shop, unit_id))
out = ok(accept(shop, pkt, routed_to))
assert out["status"] == "complete"
return out
def filed_packet(shop: Shop, unit_id: int | None = None) -> dict[str, Any]:
"""Accepted to the data book, closed in VISUAL and filed by clerk.clara."""
pkt = accepted_packet(shop, unit_id)
c = shop("clerk.clara")
pkt = ok(c.post(f"/api/packets/{pkt['id']}/vclosed", json={"closed": True}))
out = ok(step(c, pkt, "file", sign=SIGN))
assert out["status"] == "filed"
return out
# ---------------------------------------------------------------- assets and documents
def unique_pdf() -> bytes:
"""A one-page PDF with its own bytes. The asset store returns the existing row for the same bytes (and its
original name), so each upload that names a file needs bytes of its own."""
import io
import secrets
from pypdf import PdfWriter
w = PdfWriter()
w.add_blank_page(width=612, height=792)
w.add_metadata({"/Title": secrets.token_hex(8)})
buf = io.BytesIO()
w.write(buf)
return buf.getvalue()
def upload_doc(
c: TestClient,
name: str = "doc.pdf",
data: bytes | None = None,
mime: str = "application/pdf",
purpose: str = "document",
) -> dict[str, Any]:
r = c.post(
"/api/assets", files={"file": (name, unique_pdf() if data is None else data, mime)}, data={"purpose": purpose}
)
assert r.status_code == 201, r.text
out: dict[str, Any] = r.json()
return out
def csv_asset(c: TestClient) -> dict[str, Any]:
"""A stored CSV (through the BOM purpose), to try as a document."""
return upload_doc(c, "parts.csv", b"item,qty,part\n1,2,PIPE-4\n2,1,FLG-4\n", "text/csv", purpose="bom")
def add_packet_doc(
c: TestClient,
pid: int,
kind: str = "nde",
title: str | None = "RT report W1-W4",
note: str | None = None,
file_name: str = "rt.pdf",
asset_id: str | None = None,
**kw: Any,
) -> Any:
if asset_id is None:
asset_id = upload_doc(c, file_name)["id"]
body: dict[str, Any] = {"asset_id": asset_id, "kind": kind, **kw}
if title is not None:
body["title"] = title
if note is not None:
body["note"] = note
return c.post(f"/api/packets/{pid}/docs", json=body)
def take_out(c: TestClient, pid: int, doc_id: int, reason: str | None = "wrong packet") -> Any:
return delete_json(c, f"/api/packets/{pid}/docs/{doc_id}", {} if reason is None else {"reason": reason})
def make_qc_doc(
shop: Shop,
*,
unit_id: int | None = None,
project_id: int | None = None,
type: str = "ITP",
title: str | None = None,
rev: str | None = None,
note: str | None = None,
replaces: int | None = None,
by: str = "qc.quinn",
file_name: str = "doc.pdf",
asset_id: str | None = None,
**kw: Any,
) -> Any:
"""POST /api/qc/docs with a fresh tiny PDF (purpose document). Returns the response."""
c = shop(by)
if asset_id is None:
asset_id = upload_doc(c, file_name)["id"]
body: dict[str, Any] = {"type": type, "asset_id": asset_id, **kw}
for k, v in (
("unit_id", unit_id),
("project_id", project_id),
("title", title),
("rev", rev),
("note", note),
("replaces", replaces),
):
if v is not None:
body[k] = v
return c.post("/api/qc/docs", json=body)
def folder_unit(c: TestClient, unit_id: int) -> dict[str, Any]:
return ok(c.get(f"/api/qc/folders/units/{unit_id}"))
def folder_project(c: TestClient, project_id: int) -> dict[str, Any]:
return ok(c.get(f"/api/qc/folders/projects/{project_id}"))
# ---------------------------------------------------------------- the ITP
def get_itp(c: TestClient, unit_id: int) -> dict[str, Any]:
return ok(c.get(f"/api/units/{unit_id}/itp"))
def start_itp(shop: Shop, unit_id: int, by: str = "qc.quinn") -> dict[str, Any]:
itp = ok_any(shop(by).post(f"/api/units/{unit_id}/itp", json={}))
assert itp["status"] == "open", itp
return itp
def item_keys(itp: dict[str, Any]) -> list[str]:
return [str(i["n"]) for s in itp["template"]["sections"] for i in s["items"]]
def section_keys(itp: dict[str, Any], n: str) -> list[str]:
sec = next(s for s in itp["template"]["sections"] if str(s["n"]) == n)
return [str(i["n"]) for i in sec["items"]]
def line(itp: dict[str, Any], key: str) -> dict[str, Any]:
out: dict[str, Any] = itp["lines"][key]
return out
def block(itp: dict[str, Any], key: str) -> dict[str, Any]:
return next(b for b in itp["blocks"] if str(b["key"]) == key)
def is_done(ln: dict[str, Any]) -> bool:
return bool(ln.get("na")) or ln.get("result") in ("approve", "na")
def sign_line(c: TestClient, itp: dict[str, Any], key: str, if_match: int | None = None, **body: Any) -> Any:
headers = {} if if_match is None else {"If-Match": str(if_match)}
return c.post(f"/api/itps/{itp['id']}/lines/{key}/sign", json=body, headers=headers)
def unsign_line(c: TestClient, itp: dict[str, Any], key: str, **body: Any) -> Any:
return c.post(f"/api/itps/{itp['id']}/lines/{key}/unsign", json=body)
def patch_line(c: TestClient, itp: dict[str, Any], key: str, **body: Any) -> Any:
return c.patch(f"/api/itps/{itp['id']}/lines/{key}", json=body)
def section_na(c: TestClient, itp: dict[str, Any], n: str, na: bool = True) -> Any:
return c.post(f"/api/itps/{itp['id']}/sections/{n}/na", json={"na": na})
def reject_line(c: TestClient, itp: dict[str, Any], key: str, **body: Any) -> Any:
return c.post(f"/api/itps/{itp['id']}/lines/{key}/reject", json=body)
def enter_ncr(c: TestClient, itp: dict[str, Any], key: str, ncr: str) -> Any:
return c.post(f"/api/itps/{itp['id']}/lines/{key}/ncr", json={"ncr": ncr})
def patch_block(c: TestClient, itp: dict[str, Any], key: str, **body: Any) -> Any:
return c.patch(f"/api/itps/{itp['id']}/blocks/{key}", json=body)
def sign_block(c: TestClient, itp: dict[str, Any], key: str, sign: dict[str, Any] | None = SIGN, **body: Any) -> Any:
if sign is not None:
body["sign"] = sign
return c.post(f"/api/itps/{itp['id']}/blocks/{key}/sign", json=body)
def unsign_block(c: TestClient, itp: dict[str, Any], key: str, **body: Any) -> Any:
return delete_json(c, f"/api/itps/{itp['id']}/blocks/{key}/sign", body)
def sign_off(c: TestClient, itp: dict[str, Any], sign: dict[str, Any] | None = SIGN, **body: Any) -> Any:
if sign is not None:
body["sign"] = sign
return c.post(f"/api/itps/{itp['id']}/signoff", json=body)
def reopen_itp(c: TestClient, itp: dict[str, Any], reason: str, **body: Any) -> Any:
return c.post(f"/api/itps/{itp['id']}/reopen", json={"reason": reason, **body})
def patch_itp(c: TestClient, itp: dict[str, Any], set_: dict[str, Any], base: dict[str, Any] | None = None) -> Any:
return c.patch(f"/api/itps/{itp['id']}", json={"set": set_, "base": base or {}})
def approve_all(shop: Shop, itp: dict[str, Any], by: str = "qc.quinn", keys: list[str] | None = None) -> dict[str, Any]:
"""Approve every line that is still open (not N/A, not approved). A rejected line needs its NCR first."""
c = shop(by)
for key in keys if keys is not None else item_keys(itp):
ln = line(itp, key)
if is_done(ln):
continue
itp = ok(sign_line(c, itp, key))
return itp
def na_lines(shop: Shop, itp: dict[str, Any], keys: list[str], by: str = "qc.quinn") -> dict[str, Any]:
c = shop(by)
for key in keys:
itp = ok(patch_line(c, itp, key, na=True))
return itp
def sign_all_blocks(shop: Shop, itp: dict[str, Any], by: str = "qc.quinn") -> dict[str, Any]:
c = shop(by)
for b in list(itp["blocks"]):
if not block(itp, str(b["key"]))["signed"]:
itp = ok(sign_block(c, itp, str(b["key"])))
return itp
def itp_log(itp: dict[str, Any]) -> list[tuple[str, str | None]]:
"""(action, note) newest first."""
return [(r["action"], r["note"]) for r in itp["log"]]
def patch_hook(monkeypatch: pytest.MonkeyPatch, fn: Any) -> None:
"""Replace pdf.itp.file_signed_itp_pdf, also where a module imported it by name."""
for name in ("packetstation.pdf.itp", "packetstation.api.itp", "packetstation.domain.itp"):
try:
mod = importlib.import_module(name)
except ImportError:
continue
if hasattr(mod, "file_signed_itp_pdf"):
monkeypatch.setattr(mod, "file_signed_itp_pdf", fn)
def as_uuid(value: Any) -> uuid.UUID:
return value if isinstance(value, uuid.UUID) else uuid.UUID(str(value))
+550
View File
@@ -0,0 +1,550 @@
"""Phase 6 digital ITP through the API: start from the frozen template, line initials with their compare-and-set,
unsign by the same inspector or an admin, N/A on a line and a section, customer codes and witnesses, reject and the
NCR, block fields and signatures, sign-off with the PDF hook, the lock after sign-off, reopen, and the log texts.
The directory adds two people to the fakedir roster through this module's fixture: qc.sue (a second Q.C. user) and
qcsup.sam (a Q.C. supervisor). fakedir.py itself has neither."""
from __future__ import annotations
import uuid
from collections.abc import Iterator
from typing import Any
import pytest
from pshelpers import needs_pg
from qchelpers import (
MSG_BLOCK_NOT_READY,
MSG_BLOCK_SIGNED,
MSG_BLOCKS_UNSIGNED,
MSG_ITP_COMPLETE,
MSG_NCR,
MSG_NCR_REQUIRED,
MSG_UNSIGN,
NCR,
NCR2,
SECTION_SIZES,
SECTION_TITLE_5,
TEMPLATE_ITEMS,
TEMPLATE_VER,
approve_all,
block,
enter_ncr,
get_itp,
item_keys,
itp_log,
line,
na_lines,
open_qc_shop,
patch_block,
patch_hook,
patch_itp,
patch_line,
qc_directory,
reject_line,
reopen_itp,
section_keys,
section_na,
sign_all_blocks,
sign_block,
sign_line,
sign_off,
start_itp,
today_iso,
today_us,
unsign_block,
unsign_line,
upload_doc,
)
from shophelpers import IT, Shop, all_rows, make_unit, ok, one, refused
from packetstation.settings import Settings
pytestmark = needs_pg
@pytest.fixture
def qc_shop(pg_settings: Settings, dev_visual: None) -> Iterator[Shop]:
"""A Shop on a directory that also has qc.sue (Q.C.) and qcsup.sam (Q.C. supervisor)."""
yield from open_qc_shop(pg_settings, qc_directory())
def numbers(shop: Shop, unit_id: int) -> tuple[str, str]:
row = one(
shop.settings,
"SELECT p.number, p.number || u.lot FROM units u JOIN projects p ON p.id = u.project_id WHERE u.id = %s",
(unit_id,),
)
return str(row[0]), str(row[1])
def new_itp(shop: Shop) -> tuple[int, dict[str, Any]]:
_, unit_id = make_unit(shop.settings)
return unit_id, start_itp(shop, unit_id)
def done_section(shop: Shop, itp: dict[str, Any], n: str) -> dict[str, Any]:
"""The first line of section n approved, the rest N/A: its block is ready."""
keys = section_keys(itp, n)
itp = ok(sign_line(shop("qc.quinn"), itp, keys[0]))
return na_lines(shop, itp, keys[1:])
# ================================================================ start
def test_start(qc_shop: Shop) -> None:
shop = qc_shop
from packetstation.domain.itp_template import ITP_TEMPLATE
project_id, unit_id = make_unit(shop.settings)
pno, uno = numbers(shop, unit_id)
empty = get_itp(shop("qc.quinn"), unit_id)
assert empty["itp"] is None and empty["unit"]["id"] == unit_id and empty["unit"]["number"] == uno
assert shop("qc.quinn").get("/api/units/999999/itp").status_code == 404
refused(shop("wh.wade").post(f"/api/units/{unit_id}/itp", json={}), 403, "forbidden")
refused(shop("clerk.clara").post(f"/api/units/{unit_id}/itp", json={}), 403, "forbidden")
itp = start_itp(shop, unit_id)
assert itp["unit_id"] == unit_id and itp["no"] == f"{uno}-ITP" and itp["status"] == "open"
assert itp["hdr"] == {"client": "Acme Refining", "pname": "Shop project", "pno": pno, "po": "PO-55", "unit": uno}
assert itp["template"] == ITP_TEMPLATE and itp["template"]["ver"] == TEMPLATE_VER
assert len(item_keys(itp)) == TEMPLATE_ITEMS
assert {str(s["n"]): len(s["items"]) for s in itp["template"]["sections"]} == SECTION_SIZES
assert itp["pages"] == [
{"code": "WP", "name": "Weld Parts"},
{"code": "C", "name": "Code"},
{"code": "FA", "name": "Final Assembly"},
]
assert set(itp["lines"]) == set(item_keys(itp))
assert all(ln["result"] is None and ln["na"] is False for ln in itp["lines"].values())
assert itp["progress"] == {"done": 0, "total": TEMPLATE_ITEMS, "holds": 17}
assert len(itp["holds"]) == 17
assert [b["key"] for b in itp["blocks"]] == ["3", "4", "5", "6", "7", "8", "9"]
assert block(itp, "3")["sections"] == ["1", "2", "3"] and block(itp, "3")["scope"] == "Sections 1–3"
assert block(itp, "4")["scope"] == "Section 4" and block(itp, "9")["final"] is True
assert not any(b["signed"] or b["ready"] for b in itp["blocks"])
assert itp["signoff"] is None and itp["reopen"] is None
assert itp["created_by"]["display_name"] == "Quinn QC" and itp["created_at"]
assert itp["can"] == {"edit": True, "sign_off": False, "reopen": False}
assert itp_log(itp) == [("ITP started", f"{uno}-ITP · template {TEMPLATE_VER}")]
# the template is frozen in the row
row = one(shop.settings, "SELECT template, no FROM itps WHERE id = %s", (itp["id"],))
assert row[0] == ITP_TEMPLATE and row[1] == f"{uno}-ITP"
# a second start
refused(shop("qc.quinn").post(f"/api/units/{unit_id}/itp", json={}), 409, "itp_exists")
# other people read it, without edit rights
other = get_itp(shop("wh.wade"), unit_id)
assert other["id"] == itp["id"] and other["can"]["edit"] is False
# the audit row
au = one(shop.settings, "SELECT subject_type, subject_id, detail FROM audit_events WHERE action = 'itp.itp_start'")
assert au is not None and au[0] == "itp" and str(au[1]) == str(itp["id"])
assert au[2]["text"] == "ITP started" and au[2]["unit_id"] == unit_id
del project_id
def test_header_fields(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
v = itp["version"]
itp = ok(patch_itp(q, itp, {"client": "ACME"}, {"client": "Acme Refining"}))
assert itp["hdr"]["client"] == "ACME" and itp["version"] == v + 1
assert itp_log(itp)[0] == ("ITP header changed", "client: ACME")
itp = ok(patch_itp(q, itp, {"no": "L9-ITP-B"}))
assert itp["no"] == "L9-ITP-B" and itp_log(itp)[0][0] == "ITP plan no. changed"
r = refused(patch_itp(q, itp, {"client": "Other"}, {"client": "Acme Refining"}), 409, "field_conflict")
assert r["current"] == {"client": "ACME"}
refused(patch_itp(shop("clerk.clara"), itp, {"client": "X"}), 403, "forbidden")
# ================================================================ lines
def test_line_sign_and_cas(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
itp = ok(sign_line(q, itp, "1.1"))
ln = line(itp, "1.1")
assert ln["result"] == "approve" and ln["by"]["display_name"] == "Quinn QC" and ln["initials"] == "QQ"
assert ln["at"] and itp["progress"]["done"] == 1
assert itp_log(itp)[0][0] == "ITP line 1.1 approved" and not itp_log(itp)[0][1]
refused(sign_line(q, itp, "1.1"), 409, "already_signed")
# a stale If-Match
rv = line(itp, "1.2")["row_version"]
refused(sign_line(q, itp, "1.2", if_match=rv + 5), 409, "row_conflict")
itp = ok(sign_line(q, itp, "1.2", if_match=rv))
assert line(itp, "1.2")["row_version"] > rv
# another Q.C. user signs with their own initials
itp = ok(sign_line(shop("qc.sue"), itp, "1.3"))
assert line(itp, "1.3")["initials"] == "SS" and line(itp, "1.3")["by"]["display_name"] == "Sue Second"
refused(sign_line(shop("sup.wes"), itp, "1.4"), 403, "forbidden")
# a key that is not in the template
assert sign_line(q, itp, "9.99").status_code in (404, 422)
def test_line_unsign(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
itp = ok(sign_line(q, itp, "1.1"))
# another Q.C. user may not remove it
refused(unsign_line(shop("qc.sue"), itp, "1.1"), 403, "forbidden", MSG_UNSIGN)
itp = ok(unsign_line(q, itp, "1.1"))
assert line(itp, "1.1")["result"] is None and line(itp, "1.1")["by"] is None
assert itp_log(itp)[0] == ("ITP line 1.1 initial removed", f"was Quinn QC {today_us()}")
# an admin, with an IT reason
itp = ok(sign_line(q, itp, "1.1"))
refused(unsign_line(shop("admin.ann"), itp, "1.1"), 400, "it_reason_required")
itp = ok(unsign_line(shop("admin.ann"), itp, "1.1", it_reason=IT))
assert line(itp, "1.1")["result"] is None
log_row = itp["log"][0]
assert log_row["by"]["display_name"] == "Ann Admin"
# refused once the block is signed
itp = done_section(shop, itp, "4")
first = section_keys(itp, "4")[0]
itp = ok(sign_block(q, itp, "4"))
refused(unsign_line(q, itp, first), 409, "block_signed", MSG_BLOCK_SIGNED)
# an open line has nothing to remove
r = unsign_line(q, itp, "1.5")
assert r.status_code in (404, 409, 422), r.text
def test_line_na_and_section_na(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
itp = ok(patch_line(q, itp, "1.2", na=True))
assert line(itp, "1.2")["na"] is True and itp["progress"]["done"] == 1
assert itp_log(itp)[0] == ("ITP line 1.2 updated", "marked N/A")
refused(sign_line(q, itp, "1.2"), 409, "line_na")
itp = ok(patch_line(q, itp, "1.2", na=False))
assert line(itp, "1.2")["na"] is False and itp_log(itp)[0] == ("ITP line 1.2 updated", "applies again")
itp = ok(sign_line(q, itp, "1.1"))
refused(patch_line(q, itp, "1.1", na=True), 409, "already_signed")
# a whole section
v = itp["version"]
itp = ok(section_na(q, itp, "5"))
assert all(line(itp, k)["na"] for k in section_keys(itp, "5"))
assert itp_log(itp)[0] == ("ITP section 5 marked N/A", SECTION_TITLE_5) and itp["version"] > v
assert block(itp, "5")["ready"] is True and block(itp, "5")["all_na"] is True
itp = ok(section_na(q, itp, "5", na=False))
assert not any(line(itp, k)["na"] for k in section_keys(itp, "5"))
assert itp_log(itp)[0] == ("ITP section 5 applies again", SECTION_TITLE_5)
# refused once a line of the section is approved
refused(section_na(q, itp, "1"), 409, "already_signed")
refused(section_na(shop("clerk.clara"), itp, "5"), 403, "forbidden")
def test_line_customer_and_note(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
refused(patch_line(q, itp, "1.3", cust_code="X"), 422, "invalid_field")
itp = ok(patch_line(q, itp, "1.3", cust_code="W"))
assert line(itp, "1.3")["cust_code"] == "W"
assert itp_log(itp)[0] == ("ITP line 1.3 updated", "customer code W")
itp = ok(patch_line(q, itp, "1.3", cust_code=""))
assert not line(itp, "1.3")["cust_code"]
assert itp_log(itp)[0] == ("ITP line 1.3 updated", "customer code as template")
itp = ok(patch_line(q, itp, "1.3", cust_by="Rick Inspector"))
ln = line(itp, "1.3")
assert ln["cust_by"] == "Rick Inspector" and ln["cust_at"] == today_iso()
assert ln["cust_rec"]["display_name"] == "Quinn QC"
assert itp_log(itp)[0] == ("ITP line 1.3 updated", "customer Rick Inspector")
itp = ok(patch_line(q, itp, "1.3", cust_by="Rick Inspector", cust_at="2026-10-01"))
assert line(itp, "1.3")["cust_at"] == "2026-10-01"
itp = ok(patch_line(q, itp, "1.3", cust_by=""))
assert not line(itp, "1.3")["cust_by"] and line(itp, "1.3")["cust_at"] is None
assert itp_log(itp)[0] == ("ITP line 1.3 updated", "customer cleared")
itp = ok(patch_line(q, itp, "1.3", cust_code="H", note="Reviewed WPS-101 R2"))
assert line(itp, "1.3")["note"] == "Reviewed WPS-101 R2"
assert itp_log(itp)[0] == ("ITP line 1.3 updated", "customer code H, note")
for code in ("R", "R/AU", "AU", "W", "H", "NA"):
assert ok(patch_line(q, itp, "1.4", cust_code=code))["lines"]["1.4"]["cust_code"] == code
# nothing changed: no log, no version bump
itp = ok(patch_line(q, itp, "1.4", cust_code="NA"))
before = (len(itp["log"]), itp["version"])
itp = ok(patch_line(q, itp, "1.4", cust_code="NA"))
assert (len(itp["log"]), itp["version"]) == before
# limits
r = patch_line(q, itp, "1.5", note="x" * 501)
assert r.status_code == 422 or len(r.json()["lines"]["1.5"]["note"]) <= 500, r.text
def test_reject_and_ncr(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
# reject without an NCR
itp = ok(reject_line(q, itp, "1.4", note="Manual is rev C"))
ln = line(itp, "1.4")
assert ln["result"] == "reject" and ln["ncr"] is None and ln["ncr_missing"] is True
assert [r["kind"] for r in ln["rejects"]] == ["reject"] and ln["rejects"][0]["note"] == "Manual is rev C"
assert itp_log(itp)[0] == ("ITP line 1.4 rejected", "NCR # pending · Manual is rev C")
assert any(h["n"] == "1.4" and h["rejected"] for h in itp["holds"])
refused(sign_line(q, itp, "1.4"), 409, "ncr_required", MSG_NCR_REQUIRED)
refused(patch_line(q, itp, "1.4", na=True), 409, "line_rejected")
r = reject_line(q, itp, "1.4", note="again")
assert r.status_code == 409, r.text
# the NCR later
refused(enter_ncr(q, itp, "1.4", "NCR-26-1"), 422, "invalid_ncr", MSG_NCR)
itp = ok(enter_ncr(q, itp, "1.4", " ncr-2026-0021"))
ln = line(itp, "1.4")
assert ln["ncr"] == NCR and ln["ncr_missing"] is False
assert [r["kind"] for r in ln["rejects"]] == ["reject", "ncr"]
assert itp_log(itp)[0] == ("ITP line 1.4 NCR number entered", f"NCR {NCR}")
refused(enter_ncr(q, itp, "1.4", NCR2), 409, "no_open_reject")
refused(enter_ncr(q, itp, "1.5", NCR2), 409, "no_open_reject")
itp = ok(sign_line(q, itp, "1.4"))
assert line(itp, "1.4")["result"] == "approve"
assert itp_log(itp)[0] == ("ITP line 1.4 approved", f"after NCR {NCR}")
assert not any(h["n"] == "1.4" for h in itp["holds"])
# the reject history survives the approve
assert len(line(itp, "1.4")["rejects"]) == 2
rows = all_rows(shop.settings, "SELECT kind, ncr FROM itp_rejects WHERE itp_id = %s ORDER BY id", (itp["id"],))
assert [tuple(r) for r in rows] == [("reject", None), ("ncr", NCR)]
# reject with the NCR at once
itp = ok(reject_line(q, itp, "1.5", ncr="ncr-2026-0022", note="Map missing W4"))
assert line(itp, "1.5")["ncr"] == NCR2 and line(itp, "1.5")["ncr_missing"] is False
assert itp_log(itp)[0] == ("ITP line 1.5 rejected", f"NCR {NCR2} · Map missing W4")
itp = ok(sign_line(q, itp, "1.5"))
assert itp_log(itp)[0] == ("ITP line 1.5 approved", f"after NCR {NCR2}")
# refused on an approved line and on an N/A line
assert reject_line(q, itp, "1.5", note="x").status_code == 409
itp = ok(patch_line(q, itp, "1.6", na=True))
assert reject_line(q, itp, "1.6", note="x").status_code == 409
refused(reject_line(q, itp, "1.7", ncr="bad one"), 422, "invalid_ncr")
# ================================================================ blocks
def test_block_fields(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
itp = ok(patch_block(q, itp, "3", qn="QN-17"))
assert block(itp, "3")["qn"] == "QN-17" and itp_log(itp)[0] == ("ITP block 3 QN # changed", "QN-17")
itp = ok(patch_block(q, itp, "3", remarks="Fit-up per drawing"))
assert itp_log(itp)[0] == ("ITP block 3 remarks changed", "Fit-up per drawing")
itp = ok(patch_block(q, itp, "3", cust_name="Rick Inspector"))
b = block(itp, "3")
assert b["cust_name"] == "Rick Inspector" and b["cust_at"] == today_iso()
assert b["cust_by"]["display_name"] == "Quinn QC" and b["signed"] is False
assert itp_log(itp)[0] == ("ITP block 3 customer complete recorded", "Rick Inspector")
itp = ok(patch_block(q, itp, "3", cust_clear=True))
b = block(itp, "3")
assert not b["cust_name"] and b["cust_at"] is None and b["cust_by"] is None
assert itp_log(itp)[0][0] == "ITP block 3 customer complete cleared"
assert b["qn"] == "QN-17"
refused(patch_block(shop("sup.wes"), itp, "3", qn="QN-1"), 403, "forbidden")
def test_block_sign_gate(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
refused(sign_block(q, itp, "4"), 409, "block_not_ready", MSG_BLOCK_NOT_READY)
itp = done_section(shop, itp, "4")
assert block(itp, "4")["ready"] is True and block(itp, "4")["all_na"] is False
r = refused(sign_block(q, itp, "4", sign=None), 400, "signature_required")
assert r.get("meaning") == "Sign an ITP block"
r = sign_block(q, itp, "4", sign={"factor": "password", "secret": "wrong-one"})
assert 400 <= r.status_code < 500, r.text
itp = ok(sign_block(q, itp, "4"))
b = block(itp, "4")
assert b["signed"] is True and b["signed_by"]["display_name"] == "Quinn QC" and b["signed_at"]
assert itp["log"][0]["action"] == "ITP block 4 signed" and itp["log"][0]["note"] == "Section 4"
assert itp["log"][0]["signed"] is True
refused(sign_block(q, itp, "4"), 409, "already_signed")
sig = one(
shop.settings, "SELECT subject_type, subject_id, packet_id, meaning FROM signatures ORDER BY id DESC LIMIT 1"
)
assert (sig[0], str(sig[1]), sig[2], sig[3]) == ("itp", str(itp["id"]), None, "Sign an ITP block")
# a block of all N/A lines signs
itp = ok(section_na(q, itp, "5"))
itp = ok(sign_block(q, itp, "5"))
assert block(itp, "5")["signed"] is True and block(itp, "5")["all_na"] is True
# block 3 covers sections 1 to 3
itp = ok(section_na(q, itp, "2"))
itp = ok(section_na(q, itp, "3"))
assert block(itp, "3")["ready"] is False
itp = ok(section_na(q, itp, "1"))
assert block(itp, "3")["ready"] is True
itp = ok(sign_block(q, itp, "3"))
assert itp["log"][0]["note"] == "Sections 1–3"
refused(sign_block(shop("sup.wes"), itp, "6"), 403, "forbidden")
def test_block_unsign(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
itp = done_section(shop, itp, "4")
itp = ok(patch_block(q, itp, "4", qn="QN-4"))
itp = ok(sign_block(q, itp, "4"))
refused(unsign_block(shop("qc.sue"), itp, "4"), 403, "forbidden")
itp = ok(unsign_block(q, itp, "4"))
b = block(itp, "4")
assert b["signed"] is False and b["signed_by"] is None and b["signed_at"] is None and b["qn"] == "QN-4"
assert itp_log(itp)[0][0] == "ITP block 4 signature removed"
# an admin may, with an IT reason
itp = ok(sign_block(q, itp, "4"))
itp = ok(unsign_block(shop("admin.ann"), itp, "4", it_reason=IT))
assert block(itp, "4")["signed"] is False
# the line can be unsigned again now
itp = ok(unsign_line(q, itp, section_keys(itp, "4")[0]))
# ================================================================ sign-off, lock, reopen
def finish_all(shop: Shop, itp: dict[str, Any]) -> dict[str, Any]:
return sign_all_blocks(shop, approve_all(shop, itp))
def test_signoff_without_pdf(qc_shop: Shop) -> None:
shop = qc_shop
unit_id, itp = new_itp(shop)
q = shop("qc.quinn")
refused(sign_off(q, itp), 409, "blocks_unsigned", MSG_BLOCKS_UNSIGNED)
itp = approve_all(shop, itp)
assert itp["progress"] == {"done": TEMPLATE_ITEMS, "total": TEMPLATE_ITEMS, "holds": 0}
assert itp["holds"] == [] and itp["can"]["sign_off"] is False
blocks = [str(b["key"]) for b in itp["blocks"]]
for key in blocks[:-1]:
itp = ok(sign_block(q, itp, key))
refused(sign_off(q, itp), 409, "blocks_unsigned")
itp = ok(sign_block(q, itp, blocks[-1]))
assert itp["can"]["sign_off"] is True
r = refused(sign_off(q, itp, sign=None), 400, "signature_required")
assert r.get("meaning") == "Sign off the ITP"
refused(sign_off(shop("clerk.clara"), itp), 403, "forbidden")
itp = ok(sign_off(q, itp))
assert itp["status"] == "complete" and itp["can"] == {"edit": False, "sign_off": False, "reopen": False}
assert itp["signoff"]["by"]["display_name"] == "Quinn QC" and itp["signoff"]["at"]
assert itp["signoff"]["asset_id"] is None
assert itp_log(itp)[0] == ("ITP signed off", "PDF filing reserved for phase 7") and itp["log"][0]["signed"]
row = one(
shop.settings, "SELECT signoff_asset_id, signoff_signature_id IS NOT NULL FROM itps WHERE id = %s", (itp["id"],)
)
assert tuple(row) == (None, True)
assert all_rows(shop.settings, "SELECT count(*) FROM qc_docs WHERE unit_id = %s", (unit_id,))[0][0] == 0
# the admin may reopen; qc.quinn and qc.sue may not
assert get_itp(shop("admin.ann"), unit_id)["can"]["reopen"] is True
assert get_itp(q, unit_id)["can"]["reopen"] is False
def test_locked_after_signoff_and_reopen(qc_shop: Shop) -> None:
shop = qc_shop
_, itp = new_itp(shop)
q = shop("qc.quinn")
itp = ok(sign_off(q, finish_all(shop, itp)))
first = section_keys(itp, "1")[0]
for r in (
patch_itp(q, itp, {"client": "X"}),
sign_line(q, itp, first),
unsign_line(q, itp, first),
patch_line(q, itp, first, note="late"),
section_na(q, itp, "5"),
reject_line(q, itp, first, ncr=NCR),
enter_ncr(q, itp, first, NCR),
patch_block(q, itp, "3", qn="QN-9"),
sign_block(q, itp, "3"),
unsign_block(q, itp, "3"),
sign_off(q, itp),
):
refused(r, 409, "itp_complete", MSG_ITP_COMPLETE)
# reopen: Q.C. users may not; the Q.C. supervisor and the admin may
refused(reopen_itp(q, itp, "Customer asked for 4.2 again"), 403, "forbidden")
refused(reopen_itp(shop("qc.sue"), itp, "Customer asked for 4.2 again"), 403, "forbidden")
r = reopen_itp(shop("admin.ann"), itp, "short")
assert r.status_code == 400 and r.json()["code"] in ("reason_required", "it_reason_required"), r.text
itp = ok(reopen_itp(shop("admin.ann"), itp, "Customer asked for 4.2 again"))
assert itp["status"] == "open" and itp["reopen"]["reason"] == "Customer asked for 4.2 again"
assert itp["reopen"]["by"]["display_name"] == "Ann Admin" and itp["reopen"]["at"]
assert itp_log(itp)[0] == ("ITP reopened", "Customer asked for 4.2 again")
au = one(shop.settings, "SELECT it_reason FROM audit_events WHERE action = 'itp.itp_reopen'")
assert au is not None and au[0] == "Customer asked for 4.2 again"
# the signatures stay; the inspector undoes what must change
assert all(b["signed"] for b in itp["blocks"]) and itp["can"]["sign_off"] is True
refused(reopen_itp(shop("admin.ann"), itp, "Customer asked again"), 409, "itp_open")
itp = ok(patch_itp(q, itp, {"client": "ACME"}))
# a Q.C. supervisor reopens too
itp = ok(sign_off(q, itp))
itp = ok(reopen_itp(shop("qcsup.sam"), itp, "Re-inspect the nozzle welds"))
assert itp["status"] == "open" and itp["reopen"]["by"]["display_name"] == "Sam Qcsup"
def test_signoff_with_pdf_hook(qc_shop: Shop, monkeypatch: pytest.MonkeyPatch) -> None:
shop = qc_shop
unit_id, itp = new_itp(shop)
q = shop("qc.quinn")
calls: list[Any] = []
asset_ids = [upload_doc(q, "itp-1.pdf")["id"], upload_doc(q, "itp-2.pdf")["id"]]
def fake_hook(conn: Any, itp_row: Any, actor: Any) -> uuid.UUID:
calls.append(itp_row)
return uuid.UUID(asset_ids[len(calls) - 1])
patch_hook(monkeypatch, fake_hook)
itp = ok(sign_off(q, finish_all(shop, itp)))
assert len(calls) == 1
assert itp["signoff"]["asset_id"] == asset_ids[0]
assert itp_log(itp)[0] == ("ITP signed off", "PDF filed")
row = one(shop.settings, "SELECT signoff_asset_id FROM itps WHERE id = %s", (itp["id"],))
assert str(row[0]) == asset_ids[0]
docs = all_rows(
shop.settings,
"SELECT id, type, title, coalesce(rev, ''), note, auto, superseded_by, asset_id"
" FROM qc_docs WHERE unit_id = %s",
(unit_id,),
)
assert len(docs) == 1
d = docs[0]
assert (d[1], d[2], d[3], d[5], d[6]) == ("ITP", f"{itp['no']} — signed off", "", True, None)
assert d[4] == "Filed automatically when the digital ITP was signed off." and str(d[7]) == asset_ids[0]
# a second sign-off (after a reopen) supersedes the earlier auto document
itp = ok(reopen_itp(shop("admin.ann"), itp, "Customer asked for 4.2 again"))
itp = ok(sign_off(q, itp))
assert itp["signoff"]["asset_id"] == asset_ids[1]
docs = all_rows(
shop.settings, "SELECT id, superseded_by, asset_id FROM qc_docs WHERE unit_id = %s ORDER BY id", (unit_id,)
)
assert len(docs) == 2 and docs[0][1] == docs[1][0] and docs[1][1] is None
def test_log_order_and_filed_packet(qc_shop: Shop) -> None:
"""The ITP belongs to the unit: a filed packet in the unit does not stop any ITP write. The log is newest
first, at most 600 rows, and every write wrote one row."""
shop = qc_shop
from qchelpers import filed_packet
_, unit_id = make_unit(shop.settings)
filed_packet(shop, unit_id)
itp = start_itp(shop, unit_id)
q = shop("qc.quinn")
itp = ok(sign_line(q, itp, "1.1"))
itp = ok(patch_line(q, itp, "1.2", na=True))
itp = ok(reject_line(q, itp, "1.3", ncr=NCR))
itp = ok(patch_block(q, itp, "3", qn="QN-1"))
itp = ok(patch_itp(q, itp, {"po": "PO-99"}))
assert [a for a, _ in itp_log(itp)] == [
"ITP header changed",
"ITP block 3 QN # changed",
"ITP line 1.3 rejected",
"ITP line 1.2 updated",
"ITP line 1.1 approved",
"ITP started",
]
ids = [r["id"] for r in itp["log"]]
assert ids == sorted(ids, reverse=True)
assert all(r["by"]["display_name"] == "Quinn QC" and r["by_name"] == "Quinn QC" for r in itp["log"])
count = all_rows(shop.settings, "SELECT count(*) FROM itp_log WHERE itp_id = %s", (itp["id"],))[0][0]
assert count == 6
# the rest of the ITP still works next to the filed packet: unsign, sign a block, sign off
itp = ok(unsign_line(q, itp, "1.1"))
itp = ok(sign_off(q, finish_all(shop, itp)))
assert itp["status"] == "complete"
+242
View File
@@ -0,0 +1,242 @@
"""Phase 6 ITP hold points: the pure functions in domain/itp.py (hold codes, blocks, department pages, open holds),
and the packet read model's `itp_hold` filtered by the packet's department page group. The hold is a warning only:
no precondition reads it, so `blocked` never mentions it."""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
import pytest
from fakedir import FakeDirectory
from pshelpers import needs_pg
from qchelpers import (
ALL_HOLDS,
BLOCKS,
DEPT_PAGES,
HOLDS,
NCR,
g,
patch_line,
reject_line,
sign_line,
start_itp,
)
from shophelpers import Shop, draft, fresh, make_unit, ok, open_shop, ready_packet, ready_skipped, run
from packetstation.settings import Settings
SHOP_CODES = ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "13", "14"]
def itpd() -> Any:
from packetstation.domain import itp
return itp
def template() -> dict[str, Any]:
from packetstation.domain.itp_template import ITP_TEMPLATE
out: dict[str, Any] = ITP_TEMPLATE
return out
def hold_keys(holds: Any) -> list[str]:
return [str(g(h, "n")) for h in holds]
# ================================================================ pure
@pytest.mark.parametrize(
("code", "hold"),
[
("H", True),
("W", True),
("R/W", True),
("R/H", True),
("AU/W", True),
("R/AU", False),
("NA", False),
("R", False),
("A", False),
("AU", False),
("O", False),
("I", False),
("WH", False),
("H/R", False),
("", False),
],
)
def test_is_hold_code(code: str, hold: bool) -> None:
assert bool(itpd().is_hold_code(code)) is hold
def test_template_shape() -> None:
t = template()
assert t["ver"] == "ITP_Generation_Revised_2"
assert "O = Observe" in t["legend"] and "NA = Not Applicable" in t["legend"]
assert [str(s["n"]) for s in t["sections"]] == [str(n) for n in range(1, 10)]
assert [s["page"] for s in t["sections"]] == ["WP"] * 3 + ["C"] * 3 + ["FA"] * 3
assert [str(s["n"]) for s in t["sections"] if s.get("sig")] == ["3", "4", "5", "6", "7", "8", "9"]
assert [str(s["n"]) for s in t["sections"] if s.get("final")] == ["9"]
items = list(itpd().items(t))
assert len(items) == 70
codes = {str(i["n"]): (i["code"], i["cust"]) for s in t["sections"] for i in s["items"]}
assert codes["3.4"] == ("A", "W") and codes["4.2"] == ("O", "H") and codes["9.7"] == ("R", "H")
def test_blocks() -> None:
out = list(itpd().blocks(template()))
assert [str(g(b, "key")) for b in out] == list(BLOCKS)
# sections may be numbers or the section dicts themselves (the server gives the dicts)
def sec_no(s: Any) -> str:
return str(s["n"]) if isinstance(s, dict) else str(s)
assert {str(g(b, "key")): [sec_no(s) for s in g(b, "sections")] for b in out} == BLOCKS
assert [str(g(b, "key")) for b in out if g(b, "final")] == ["9"]
@pytest.mark.parametrize("code", SHOP_CODES)
def test_dept_pages_shop(code: str) -> None:
assert list(itpd().dept_pages(code)) == DEPT_PAGES[code]
@pytest.mark.parametrize("code", ["CL", "WH", "QC", "PM", "ZZ", ""])
def test_dept_pages_support_or_unknown(code: str) -> None:
assert itpd().dept_pages(code) is None
def test_open_holds_from_the_template() -> None:
t = template()
holds = itpd().open_holds(t, {})
assert sorted(hold_keys(holds), key=_key) == sorted(ALL_HOLDS, key=_key)
assert len(holds) == 17
for page, keys in HOLDS.items():
assert sorted(hold_keys(itpd().open_holds(t, {}, pages=[page])), key=_key) == sorted(keys, key=_key), page
wp_c = itpd().open_holds(t, {}, pages=["WP", "C"])
assert len(wp_c) == 12
def test_open_holds_drop_done_lines_and_add_rejected() -> None:
t = template()
lines = {
"3.4": {"key": "3.4", "result": "approve", "na": False},
"4.2": {"key": "4.2", "result": "na", "na": True},
"1.1": {"key": "1.1", "result": "reject", "ncr": None, "na": False},
"1.2": {"key": "1.2", "result": "reject", "ncr": NCR, "na": False},
"1.3": {"key": "1.3", "result": None, "cust_code": "H", "na": False},
"4.4": {"key": "4.4", "result": None, "cust_code": "R", "na": False}, # a line code R: 4.4's PESCO code is O
}
keys = hold_keys(itpd().open_holds(t, lines))
assert "3.4" not in keys and "4.2" not in keys
assert {"1.1", "1.2", "1.3"} <= set(keys)
# 4.4: the template customer code is H; the line's own code R replaces it, and the PESCO code is O
assert "4.4" not in keys
assert len(keys) == 17 - 3 + 3
wp = itpd().open_holds(t, lines, pages=["WP"])
assert sorted(hold_keys(wp), key=_key) == ["1.1", "1.2", "1.3"]
rej = {str(g(h, "n")): h for h in wp}
assert g(rej["1.1"], "rejected") is True and g(rej["1.1"], "ncr") is None
assert g(rej["1.2"], "ncr") == NCR and g(rej["1.3"], "rejected") is False
def _key(n: str) -> tuple[int, ...]:
return tuple(int(x) for x in n.split("."))
# ================================================================ through the API
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def _hold(shop: Shop, pid: int, user: str = "sup.wes") -> Any:
return fresh(shop(user), pid)["itp_hold"]
@needs_pg
def test_itp_hold_by_department(shop: Shop) -> None:
_, unit_id = make_unit(shop.settings)
in_03 = ready_packet(shop, unit_id)
assert _hold(shop, in_03["id"]) is None # no ITP yet
itp = start_itp(shop, unit_id)
in_11 = ready_skipped(shop, unit_id, dept="11")
in_09 = ready_skipped(shop, unit_id, dept="09")
support = draft(shop, unit_id)
run(shop.settings, "UPDATE packets SET dept_code = 'QC' WHERE id = %s", (support["id"],))
h = _hold(shop, in_03["id"])
assert h["no"] == itp["no"] and h["pages"] == ["WP"] and h["count"] == 1 and h["rejected"] is False
assert hold_keys(h["items"]) == ["3.4"]
item = h["items"][0]
assert "W" in item["codes"] and item["t"] and item["rejected"] is False and item["ncr"] is None
h = _hold(shop, in_11["id"])
assert h["pages"] == ["FA"] and sorted(hold_keys(h["items"]), key=_key) == list(HOLDS["FA"])
assert "9.6" in hold_keys(h["items"]) and "4.2" not in hold_keys(h["items"])
h = _hold(shop, in_09["id"])
assert h["pages"] == ["WP", "C"] and h["count"] == 12
h = _hold(shop, support["id"], "clerk.clara")
assert h["pages"] is None and h["count"] == 17 and len(h["items"]) == 17
# the summary on the packet
s = fresh(shop("sup.wes"), in_03["id"])["itp"]
assert s["id"] == itp["id"] and s["no"] == itp["no"] and s["status"] == "open"
assert s["done"] == 0 and s["total"] == 70 and s["holds"] == 17
# a warning only: nothing in blocked names the ITP
for pid in (in_03["id"], in_11["id"]):
for user in ("sup.wes", "lead.lou", "qc.quinn", "clerk.clara", "wh.wade"):
blocked = fresh(shop(user), pid)["blocked"]
assert not any("ITP" in why for reasons in blocked.values() for why in reasons), (user, blocked)
@needs_pg
def test_itp_hold_follows_the_lines(shop: Shop) -> None:
_, unit_id = make_unit(shop.settings)
in_03 = ready_packet(shop, unit_id)
itp = start_itp(shop, unit_id)
q = shop("qc.quinn")
# approving the only WP hold clears the 03 packet's hold
itp = ok(sign_line(q, itp, "3.4"))
assert _hold(shop, in_03["id"]) is None
assert fresh(shop("sup.wes"), in_03["id"])["itp"]["holds"] == 16
# a rejected WP line comes back as a rejected point, with its NCR when given
itp = ok(reject_line(q, itp, "1.1", note="MTR heat does not match"))
h = _hold(shop, in_03["id"])
assert h["count"] == 1 and h["rejected"] is True
assert h["items"][0]["n"] == "1.1" and h["items"][0]["rejected"] is True and h["items"][0]["ncr"] is None
itp = ok(reject_line(q, itp, "1.2", ncr=NCR, note="WPS rev"))
h = _hold(shop, in_03["id"])
assert h["count"] == 2 and next(i for i in h["items"] if i["n"] == "1.2")["ncr"] == NCR
# a customer hold code on a WP line adds a point; N/A takes a point away
itp = ok(patch_line(q, itp, "2.1", cust_code="H"))
assert _hold(shop, in_03["id"])["count"] == 3
itp = ok(patch_line(q, itp, "2.1", na=True))
assert _hold(shop, in_03["id"])["count"] == 2
# the ITP read lists every open point, unfiltered
assert itp["progress"]["holds"] == len(itp["holds"])
assert {"1.1", "1.2", "4.2", "9.7"} <= set(hold_keys(itp["holds"]))
# once the ITP is complete the hold is gone
run(shop.settings, "UPDATE itps SET status = 'complete' WHERE id = %s", (itp["id"],))
assert _hold(shop, in_03["id"]) is None
run(shop.settings, "UPDATE itps SET status = 'open' WHERE id = %s", (itp["id"],))
assert _hold(shop, in_03["id"])["count"] == 2
@needs_pg
def test_itp_hold_count_on_the_qc_home(shop: Shop) -> None:
from qchelpers import at_qc
_, unit_id = make_unit(shop.settings)
start_itp(shop, unit_id)
pkt = at_qc(shop, unit_id)
home = ok(shop("qc.quinn").get("/api/qc/home"))
card = next(c for c in home["columns"]["ready"] if c["id"] == pkt["id"])
# dept 03 sees the WP page: 3.4
assert card["itp_hold_count"] == 1
+2 -2
View File
@@ -464,7 +464,7 @@ def test_work_orders_put_and_docs(pg_client2: TestClient, unit: tuple[int, int])
wo = r.json()["work_orders"]
assert [(w["wo_base"], w["lot"], w["sub"]) for w in wo] == [("0070001", "7A", "01")]
a = upload(pg_client2, tiny_pdf(1)).json()
r = pg_client2.post(f"/api/packets/{pkt['id']}/docs", json={"asset_id": a["id"], "kind": "mtr", "title": "MTR 1"})
r = pg_client2.post(f"/api/packets/{pkt['id']}/docs", json={"asset_id": a["id"], "kind": "other", "title": "MTR 1"})
assert r.status_code == 200 and r.json()["docs"][0]["title"] == "MTR 1"
doc_id = r.json()["docs"][0]["id"]
as_user(pg_client2, "wh.wade")
@@ -472,7 +472,7 @@ def test_work_orders_put_and_docs(pg_client2: TestClient, unit: tuple[int, int])
assert r.status_code == 403
as_user(pg_client2, "qc.quinn")
r = pg_client2.request("DELETE", f"/api/packets/{pkt['id']}/docs/{doc_id}", json={"reason": "wrong file"})
assert r.status_code == 200 and r.json()["docs"] == []
assert r.status_code == 200 and r.json()["docs"][0]["removed"]["reason"] == "wrong file"
def test_seal_detects_tampering(pg_client2: TestClient, pg_settings: Settings, unit: tuple[int, int]) -> None:
+553
View File
@@ -0,0 +1,553 @@
"""Phase 6 Q.C. policy: the packet Q.C. actions (QC_ACCEPT, QC_ACCEPT_AND_ROUTE, QC_REJECT, EDIT_QC, DOC_ADD,
DOC_REMOVE) as every test user from every status the helpers reach, the scoped actions (QC_DOC_ADD and the ITP
actions) through `check_scoped`, and the qc_missing_empty gate one reason at a time.
The expectations come from an oracle written from the phase 6 contract (who, from-status, CO hold, filed lock,
preconditions), not from the engine. Part one runs the pure policy; part two runs the API."""
from __future__ import annotations
import dataclasses
import itertools
from collections.abc import Iterator
from typing import Any
import pytest
from fakedir import FakeDirectory
from pkthelpers import step
from pshelpers import needs_pg
from qchelpers import (
QC_KEYS,
accepted_packet,
add_packet_doc,
at_qc,
filed_packet,
finish_inspection,
qc_check,
qc_patch,
qc_weld,
take_out,
upload_doc,
)
from shophelpers import (
IT,
SIGN,
USERS,
Shop,
draft,
fresh,
in_progress,
in_review,
issue_co,
ok,
open_shop,
ready_packet,
refused,
to_station,
to_warehouse,
)
from packetstation.policy import SYSTEM_FEED, Action, PacketState, Principal, can, check, principal_from_roles
from packetstation.settings import Settings
ANDY = "00000000-0000-0000-0000-00000000a001"
REASON = "Weld 2 undercut too deep"
NCR = "NCR-2026-0021"
PACKET_ACTIONS = ("qc_accept", "qc_accept_and_route", "qc_reject", "edit_qc", "doc_add", "doc_remove")
TRANSITIONS = ("qc_accept", "qc_accept_and_route", "qc_reject")
FROM: dict[str, frozenset[str]] = {
"qc_accept": frozenset({"qc"}),
"qc_accept_and_route": frozenset({"qc"}),
"qc_reject": frozenset({"qc"}),
"edit_qc": frozenset({"qc"}),
"doc_add": frozenset(), # any status; the filed lock applies
"doc_remove": frozenset(),
}
WHO = {
"qc_accept": "QC",
"qc_accept_and_route": "QC",
"qc_reject": "QC",
"edit_qc": "QC",
"doc_add": "CL_OR_QC",
"doc_remove": "CL_OR_QC",
}
CO_HELD = frozenset({"qc_accept", "qc_accept_and_route"})
GATED = frozenset({"qc_accept", "qc_accept_and_route"}) # qc_missing_empty
SIGNED = {
"qc_accept": "Q.C. accept",
"qc_accept_and_route": "Q.C. accept and route",
"qc_reject": "Q.C. reject",
}
ROLES = {
"admin.ann": ["admin"],
"clerk.clara": ["clerical"],
"clerk.leo": ["clerical_supervisor"],
"wh.wade": ["warehouse"],
"qc.quinn": ["qc"],
"pm.paula": ["pm"],
"weld.andy": ["shop:03:worker"],
"lead.lou": ["shop:03:lead"],
"sup.wes": ["shop:03:supervisor"],
"multi.mia": ["clerical", "shop:06:supervisor"],
"both.bo": ["shop:03:worker", "shop:03:supervisor"],
"dup.dave": ["shop:03:worker"],
}
QC_PEOPLE = frozenset({"qc.quinn"})
CL_OR_QC_PEOPLE = frozenset({"clerk.clara", "clerk.leo", "multi.mia", "qc.quinn"})
@dataclasses.dataclass(frozen=True)
class Case:
name: str
status: str
qc_done: bool = False # the open inspection has nothing missing
CASES = {
"draft": Case("draft", "draft"),
"warehouse": Case("warehouse", "warehouse"),
"ready": Case("ready", "ready"),
"assigned": Case("assigned", "assigned"),
"in_progress": Case("in_progress", "in_progress"),
"review": Case("review", "review"),
"qc": Case("qc", "qc"),
"qc_done": Case("qc_done", "qc", qc_done=True),
"complete": Case("complete", "complete"),
"filed": Case("filed", "filed"),
}
def who_ok(user: str, action: str) -> bool:
if WHO[action] == "QC":
return user in QC_PEOPLE
return user in CL_OR_QC_PEOPLE
def expected(user: str, case: Case, action: str, *, it_reason: bool = False, co_hold: bool = False) -> tuple[int, str]:
"""The answer with every request input given (route, dept, reason, signature). The order is the contract's:
status, CO hold, admin bypass, filed lock, who, preconditions."""
if FROM[action] and case.status not in FROM[action]:
return 403, "wrong_status"
if co_hold and action in CO_HELD:
return 403, "co_hold"
allowed = who_ok(user, action)
if not allowed and user == "admin.ann":
if not it_reason:
return 400, "it_reason_required"
allowed = True
if case.status == "filed":
return 403, "filed"
if not allowed:
return 403, "forbidden"
if action in GATED and not case.qc_done:
return 403, "precondition"
return 200, ""
def in_can(user: str, case: Case, action: str, **kw: Any) -> bool:
return expected(user, case, action, it_reason=True, **kw)[0] == 200
def in_blocked(user: str, case: Case, action: str, **kw: Any) -> bool:
"""`blocked`: status, who and the filed lock pass, but qc_missing_empty or the CO hold fails."""
code = expected(user, case, action, it_reason=True, **kw)[1]
if code == "co_hold":
return who_ok(user, action) or user == "admin.ann"
return code == "precondition"
# ================================================================ part one: the pure policy
MISSING = ("10 inspection items", "2 welds without a visual result", "inspector name")
def P(user: str) -> Principal:
uid = ANDY if user == "weld.andy" else f"00000000-0000-0000-0000-{abs(hash(user)) % 10**12:012d}"
return principal_from_roles(uid, ROLES[user])
def state(case: Case, **kw: Any) -> PacketState:
active = case.status in ("assigned", "in_progress", "review")
base = PacketState(
id=1,
status=case.status,
dept="03",
station_id=101 if active else None,
welder_id=ANDY if active else None,
wh_skipped=False,
routed_to="databook" if case.status in ("complete", "filed") else None,
vclosed=case.status == "filed",
removed=False,
source_kind="hand",
weld_dept=True,
co_hold=False,
drawing_attached=True,
heat_map=True,
bom_total=3,
bom_ready=3,
welds=2,
checklist_complete=True,
filler_set=True,
qc_missing=() if case.qc_done or case.status != "qc" else MISSING,
has_qc_sent=case.status == "qc",
)
return dataclasses.replace(base, **kw)
EXTRA: dict[str, dict[str, Any]] = {
"qc_accept": {"routed_to": "databook"},
"qc_accept_and_route": {"dept": "09"},
"qc_reject": {"reason": REASON},
}
@pytest.mark.parametrize(("user", "case", "action"), list(itertools.product(ROLES, CASES, PACKET_ACTIONS)))
def test_policy_matrix(user: str, case: str, action: str) -> None:
c = CASES[case]
s = state(c)
d = check(P(user), s, Action(action), extra=EXTRA.get(action, {}))
want = expected(user, c, action)
assert ("" if d.ok else d.code) == want[1], (user, case, action, d)
if d.ok and action in SIGNED:
assert d.signature == SIGNED[action]
allowed, blocked = can(P(user), s)
assert (Action(action) in allowed) == in_can(user, c, action), (user, case, action, allowed)
assert (Action(action) in blocked) == in_blocked(user, c, action), (user, case, action, blocked)
if Action(action) in blocked and action in GATED:
assert blocked[Action(action)] == ["Q.C. still needs: " + ", ".join(MISSING) + "."]
@pytest.mark.parametrize("user", list(ROLES))
@pytest.mark.parametrize("case", ["qc", "qc_done"])
def test_policy_co_hold_on_accept_not_reject(user: str, case: str) -> None:
c = CASES[case]
why = "Change order CO-1 must be verified first by: 03 Weld Parts."
s = state(c, co_hold=True, co_hold_why=why)
allowed, blocked = can(P(user), s)
for action in PACKET_ACTIONS:
d = check(P(user), s, Action(action), extra=EXTRA.get(action, {}))
want = expected(user, c, action, co_hold=True)
assert ("" if d.ok else d.code) == want[1], (user, case, action, d)
if want[1] == "co_hold":
assert d.why == why
assert (Action(action) in allowed) == in_can(user, c, action, co_hold=True), (user, case, action)
assert (Action(action) in blocked) == in_blocked(user, c, action, co_hold=True), (user, case, action)
if Action(action) in blocked and action in CO_HELD:
assert blocked[Action(action)][0] == why
if case == "qc":
assert blocked[Action(action)][1] == "Q.C. still needs: " + ", ".join(MISSING) + "."
def test_policy_reject_needs_a_reason() -> None:
s = state(CASES["qc"])
for extra in ({}, {"reason": ""}, {"reason": "bad"}):
d = check(P("qc.quinn"), s, Action.QC_REJECT, extra=extra)
assert d.code == "reason_required", (extra, d)
assert check(P("qc.quinn"), s, Action.QC_REJECT, extra={"reason": NCR}).ok
# the accept routes are inputs
assert check(P("qc.quinn"), state(CASES["qc_done"]), Action.QC_ACCEPT, extra={}).code == "precondition"
d = check(P("qc.quinn"), state(CASES["qc_done"]), Action.QC_ACCEPT_AND_ROUTE, extra={"dept": "03"})
assert d.code == "precondition"
# ---------------------------------------------------------------- scoped actions (no packet)
SCOPED: dict[str, str] = {
"qc_doc_add": "CL_OR_QC",
"itp_start": "QC",
"itp_line_sign": "QC",
"itp_line_unsign": "QC",
"itp_edit_line": "QC",
"itp_block_sign": "QC",
"itp_block_unsign": "QC",
"itp_sign_off": "QC",
"itp_reopen": "QC_SUP",
}
SCOPED_SIGNED = {"itp_block_sign": "Sign an ITP block", "itp_sign_off": "Sign off the ITP"}
SCOPED_ROLES = {**ROLES, "qc.sup": ["qc_supervisor"]}
def scoped_who(user: str, who: str) -> bool:
if who == "QC":
return user in ("qc.quinn", "qc.sup")
if who == "QC_SUP":
return user == "qc.sup"
return user in CL_OR_QC_PEOPLE or user == "qc.sup"
def scoped_expected(user: str, action: str, *, it_reason: bool, reason: bool = True) -> tuple[bool, str, bool]:
"""(ok, code, admin_bypass) for check_scoped."""
allowed = scoped_who(user, SCOPED[action])
bypass = False
if not allowed and user == "admin.ann":
if not it_reason:
return False, "it_reason_required", False
allowed, bypass = True, True
if not allowed:
return False, "forbidden", False
if action == "itp_reopen" and not reason:
return False, "reason_required", bypass
return True, "", bypass
def _check_scoped() -> Any:
from packetstation.policy.engine import check_scoped
return check_scoped
def SP(user: str) -> Principal:
return principal_from_roles(f"00000000-0000-0000-0000-{abs(hash(user)) % 10**12:012d}", SCOPED_ROLES[user])
@pytest.mark.parametrize(("user", "action"), list(itertools.product(SCOPED_ROLES, SCOPED)))
@pytest.mark.parametrize("it", [False, True])
def test_check_scoped(user: str, action: str, it: bool) -> None:
check_scoped = _check_scoped()
extra = {"reason": "Customer asked to re-inspect 4.2"} if action == "itp_reopen" else {}
d = check_scoped(SP(user), Action(action), extra=extra, it_reason=IT if it else None)
want = scoped_expected(user, action, it_reason=it)
assert (d.ok, "" if d.ok else d.code) == want[:2], (user, action, it, d)
if d.ok:
assert d.admin_bypass == want[2], (user, action, d)
if action in SCOPED_SIGNED:
assert d.signature == SCOPED_SIGNED[action]
def test_check_scoped_special_cases() -> None:
check_scoped = _check_scoped()
# the system principal never passes a people action
for action in SCOPED:
d = check_scoped(SYSTEM_FEED, Action(action), extra={"reason": "Customer asked again"})
assert d.code == "forbidden", (action, d)
# admin: an IT reason shorter than 8 characters is no IT reason
d = check_scoped(SP("admin.ann"), Action("itp_start"), it_reason="short")
assert d.code == "it_reason_required"
d = check_scoped(SP("admin.ann"), Action("itp_start"), it_reason=IT)
assert d.ok and d.admin_bypass
# reopen: the reason is required for the Q.C. supervisor and for the admin
d = check_scoped(SP("qc.sup"), Action("itp_reopen"), extra={})
assert d.code == "reason_required"
d = check_scoped(SP("admin.ann"), Action("itp_reopen"), extra={"reason": "no"}, it_reason=IT)
assert d.code == "reason_required"
d = check_scoped(SP("qc.quinn"), Action("itp_reopen"), extra={"reason": "Customer asked again"})
assert d.code == "forbidden"
# PM reads only
for action in SCOPED:
assert check_scoped(SP("pm.paula"), Action(action), extra={"reason": "Customer asked"}).code == "forbidden"
def test_scoped_actions_exist_with_labels() -> None:
for name in ("qc_doc_add", "itp_start", "itp_line_sign", "itp_line_unsign", "itp_block_unsign"):
a = Action(name)
assert a.value == name
from packetstation.policy.actions import EDIT_WHAT, RULES
for name in SCOPED:
rule = RULES[Action(name)]
assert rule.to_status is None and not rule.system_only, name
assert name in {str(a) for a in EDIT_WHAT}, name
assert EDIT_WHAT[Action("qc_doc_add")] == "the Q.C. folder"
assert EDIT_WHAT[Action("itp_start")] == "the ITP"
# ================================================================ part two: the API
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def body_for(action: str) -> dict[str, Any]:
return {
"qc_accept": {"routed_to": "databook", "sign": SIGN},
"qc_accept_and_route": {"dept": "09", "sign": SIGN},
"qc_reject": {"reason": REASON, "sign": SIGN},
}[action]
def build(shop: Shop, case: str) -> dict[str, Any]:
"""A packet in the case's status with one packet document (by clerk.clara), made through the API."""
if case == "filed":
pkt = accepted_packet(shop)
ok(add_packet_doc(shop("clerk.clara"), pkt["id"]))
c = shop("clerk.clara")
pkt = ok(c.post(f"/api/packets/{pkt['id']}/vclosed", json={"closed": True}))
return ok(step(c, pkt, "file", sign=SIGN))
if case == "draft":
pkt = draft(shop)
elif case == "warehouse":
pkt = to_warehouse(shop, draft(shop))
elif case == "ready":
pkt = ready_packet(shop)
elif case == "assigned":
pkt = to_station(shop, ready_packet(shop))
elif case == "in_progress":
pkt = in_progress(shop)
elif case == "review":
pkt = in_review(shop)
elif case == "qc":
pkt = at_qc(shop)
elif case == "qc_done":
pkt = finish_inspection(shop, at_qc(shop))
elif case == "complete":
pkt = accepted_packet(shop)
else:
raise AssertionError(case)
return ok(add_packet_doc(shop("clerk.clara"), pkt["id"]))
def live_doc(pkt: dict[str, Any]) -> int:
return int(next(d for d in pkt["docs"] if not d.get("removed"))["id"])
@needs_pg
@pytest.mark.parametrize("case", list(CASES))
def test_api_matrix(shop: Shop, case: str) -> None:
"""Every refused Q.C. step answers the oracle's code and changes nothing; `can` and `blocked` agree for every
user. Allowed steps are not sent here (test_qc_api and test_qc_docs send them)."""
c = CASES[case]
pkt = build(shop, case)
assert pkt["status"] == c.status
# Building a filed packet takes minutes through the tunnel; sessions idle that long lock. Sign in afresh.
shop.clients.clear()
doc_id = live_doc(pkt)
asset = upload_doc(shop("clerk.clara"))["id"]
for user in USERS:
mine = fresh(shop(user), pkt["id"])
for action in PACKET_ACTIONS:
assert (action in mine["can"]) == in_can(user, c, action), (user, case, action, mine["can"])
assert (action in mine["blocked"]) == in_blocked(user, c, action), (user, case, action, mine["blocked"])
want = expected(user, c, action)
if c.status == "filed":
# The write pipeline answers `filed` before it runs the policy (domain/pipeline.packet_write), so
# through the API every write on a filed packet is 403 filed, whatever the engine order says.
want = (403, "filed")
if want[0] == 200:
continue
if action in TRANSITIONS:
r = step(shop(user), pkt, action, **body_for(action))
elif action == "doc_add":
r = add_packet_doc(shop(user), pkt["id"], asset_id=asset)
elif action == "doc_remove":
r = take_out(shop(user), pkt["id"], doc_id)
else:
r = qc_patch(shop(user), pkt["id"], {"notes": "Looks fine"})
if c.status != "qc" and r.status_code == 409:
# no open inspection: the contract does not fix whether this or wrong_status answers first
assert r.json()["code"] == "no_inspection", r.text
continue
assert (r.status_code, r.json().get("code")) == want, (user, case, action, r.text)
if want[1] == "precondition" and action in GATED:
assert r.json()["detail"].startswith("Q.C. still needs: ")
assert mine["blocked"][action] == [r.json()["detail"]]
after = fresh(shop("admin.ann"), pkt["id"])
assert (after["status"], after["version"]) == (pkt["status"], pkt["version"])
@needs_pg
def test_api_admin_with_it_reason(shop: Shop) -> None:
"""The admin passes who with an IT reason but never the Q.C. gate or the filed lock."""
pkt = at_qc(shop)
ann = shop("admin.ann")
r = refused(step(ann, pkt, "qc_accept", routed_to="databook", sign=SIGN, it_reason=IT), 403, "precondition")
assert r["detail"].startswith("Q.C. still needs: ")
out = ok(qc_patch(ann, pkt["id"], {"notes": "Fixed by IT"}, it_reason=IT))
assert out["qc"]["notes"] == "Fixed by IT"
out = ok(add_packet_doc(ann, pkt["id"], it_reason=IT))
refused(add_packet_doc(ann, pkt["id"]), 400, "it_reason_required")
out = ok(step(ann, out, "qc_reject", reason=REASON, sign=SIGN, it_reason=IT))
assert out["status"] == "review"
@needs_pg
def test_api_signature_required(shop: Shop) -> None:
pkt = finish_inspection(shop, at_qc(shop))
q = shop("qc.quinn")
for action, body in (
("qc_accept", {"routed_to": "databook"}),
("qc_accept_and_route", {"dept": "09"}),
("qc_reject", {"reason": REASON}),
):
r = refused(step(q, pkt, action, **body), 400, "signature_required")
assert r.get("meaning") == SIGNED[action], r
assert fresh(q, pkt["id"])["status"] == "qc"
def _missing(pkt: dict[str, Any]) -> list[str]:
out: list[str] = pkt["qc"]["missing"]
return out
def _gate(shop: Shop, pkt: dict[str, Any], parts: list[str]) -> None:
"""The read model, the blocked list and a refused accept all say the same thing."""
msg = "Q.C. still needs: " + ", ".join(parts) + "."
mine = fresh(shop("qc.quinn"), pkt["id"])
assert _missing(mine) == parts
assert "qc_accept" not in mine["can"]
assert mine["blocked"]["qc_accept"] == [msg]
assert mine["blocked"]["qc_accept_and_route"] == [msg]
r = refused(step(shop("qc.quinn"), mine, "qc_accept", routed_to="databook", sign=SIGN), 403, "precondition")
assert r["detail"] == msg
@needs_pg
def test_api_qc_missing_one_by_one(shop: Shop) -> None:
pkt = at_qc(shop)
q = shop("qc.quinn")
pid = pkt["id"]
welds = sorted(int(w["weld_no"]) for w in pkt["welds"])
assert len(welds) == 2
_gate(shop, pkt, ["10 inspection items", "2 welds without a visual result", "inspector name"])
for k in QC_KEYS[:7]:
ok(qc_check(q, pid, k))
_gate(shop, pkt, ["3 inspection items", "2 welds without a visual result", "inspector name"])
for k in QC_KEYS[7:9]:
ok(qc_check(q, pid, k))
_gate(shop, pkt, ["1 inspection item", "2 welds without a visual result", "inspector name"])
ok(qc_check(q, pid, QC_KEYS[9]))
_gate(shop, pkt, ["2 welds without a visual result", "inspector name"])
ok(qc_weld(q, pid, welds[0], result="accept"))
_gate(shop, pkt, ["1 weld without a visual result", "inspector name"])
ok(qc_weld(q, pid, welds[1], result="reject", note="undercut"))
_gate(shop, pkt, ["a rejected weld (reject the packet instead)", "inspector name"])
ok(qc_weld(q, pid, welds[1], result="accept"))
_gate(shop, pkt, ["inspector name"])
ok(qc_patch(q, pid, {"nde_method": "RT"}))
_gate(shop, pkt, ["NDE result", "inspector name"])
ok(qc_patch(q, pid, {"nde_result": "Rejectable"}))
_gate(shop, pkt, ["NDE result", "inspector name"])
ok(qc_patch(q, pid, {"nde_result": "Acceptable"}))
_gate(shop, pkt, ["inspector name"])
out = ok(qc_patch(q, pid, {"inspector": "Quinn QC"}))
assert _missing(out) == []
assert "qc_accept" in out["can"] and "qc_accept_and_route" in out["can"]
assert "qc_accept" not in out["blocked"]
# ticking one key off again brings the gate back
ok(qc_check(q, pid, "ph_qty", done=False))
_gate(shop, pkt, ["1 inspection item"])
@needs_pg
def test_api_co_hold_blocks_accept_not_reject(shop: Shop) -> None:
pkt = finish_inspection(shop, at_qc(shop))
pkt = issue_co(shop, pkt, ["03"])
mine = fresh(shop("qc.quinn"), pkt["id"])
held = "Change order CO-1 must be verified first by: 03 Weld Parts."
assert mine["blocked"]["qc_accept"] == [held]
assert "qc_reject" in mine["can"]
refused(step(shop("qc.quinn"), mine, "qc_accept", routed_to="databook", sign=SIGN), 403, "co_hold", held)
refused(step(shop("qc.quinn"), mine, "qc_accept_and_route", dept="09", sign=SIGN), 403, "co_hold", held)
out = ok(step(shop("qc.quinn"), mine, "qc_reject", ncr=NCR, sign=SIGN))
assert out["status"] == "review"
@needs_pg
def test_api_filed_lock_on_documents(shop: Shop) -> None:
pkt = filed_packet(shop)
for user in ("qc.quinn", "clerk.clara"):
refused(add_packet_doc(shop(user), pkt["id"]), 403, "filed")
refused(add_packet_doc(shop("admin.ann"), pkt["id"], it_reason=IT), 403, "filed")
+386
View File
@@ -0,0 +1,386 @@
"""Phase 6 Q.C. inspection API: the check and weld PUTs, the field CAS PATCH, the qc_items hints, nace_label, accept
to the data book, to shipping and to a department, reject with a reason, an NCR or both, the second inspection
after a reject, and the Q.C. home board. Every log text is the contract's, exactly."""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
import pytest
from fakedir import FakeDirectory
from pkthelpers import step
from pshelpers import needs_pg
from qchelpers import (
NCR,
NCR_URL,
QC_KEYS,
ROUTE_DB,
accept,
accept_route,
at_qc,
finish_inspection,
qc_check,
qc_patch,
qc_weld,
reject,
to_qc,
)
from shophelpers import (
SIGN,
Shop,
all_rows,
fresh,
issue_co,
last_log,
log_rows,
make_unit,
ok,
one,
open_shop,
refused,
run,
set_stamp,
)
from packetstation.settings import Settings
pytestmark = needs_pg
REASON = "Weld 2 undercut too deep"
ITEM_TEXT = {
"db_heats": "Heat number recorded for every BOM item",
"db_mtr": "MTRs on file and match the heat numbers",
"db_weldmap": "Weld map complete: every weld identified",
"db_wps": "Welds made to WPS-11; welder [75] qualified",
"db_ck": "Stall checklist and weld record complete",
"ph_dim": "Dimensions verified against D-5001 Rev B",
"ph_heat": "Heat numbers on material match the heat map",
"ph_stamp": "Welds stamped with welder ID",
"ph_clean": "Clean: no slag, spatter or arc strikes",
"ph_qty": "Quantity matches the work orders",
}
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def actions(shop: Shop, pid: int) -> list[str]:
return [r["action"] for r in log_rows(shop("qc.quinn"), pid)]
# ================================================================ checks, welds, fields
def test_check_put_per_key(shop: Shop) -> None:
set_stamp(shop.settings, "weld.andy", "75")
pkt = at_qc(shop)
q = shop("qc.quinn")
pid = pkt["id"]
ok(qc_patch(q, pid, {"stamp": "Q1"}))
# the item texts carry the drawing, the rev, the WPS and the welder stamp
texts = {i["key"]: i["text"] for i in fresh(q, pid)["qc_items"]}
assert texts == ITEM_TEXT
for key in QC_KEYS:
before = len(log_rows(q, pid))
out = ok(qc_check(q, pid, key))
entry = out["qc"]["checks"][key]
assert entry["done"] is True and entry["at"] and entry["by"], entry
assert entry["stamp"] == "Q1" and entry["by_name"] == "Quinn QC", entry
log = last_log(q, pid)
assert log["action"] == f"Q.C. checked: {ITEM_TEXT[key]}" and not log["note"], log
assert len(log_rows(q, pid)) == before + 1
# a tick that changes nothing writes nothing
before = len(log_rows(q, pid))
ok(qc_check(q, pid, "ph_dim"))
assert len(log_rows(q, pid)) == before
# untick
out = ok(qc_check(q, pid, "ph_dim", done=False))
assert out["qc"]["checks"]["ph_dim"].get("done") is False
assert last_log(q, pid)["action"] == "Q.C. unchecked: Dimensions verified against D-5001 Rev B"
# an unknown key
refused(qc_check(q, pid, "ph_paint"), 422, "invalid_key")
# Q.C. only
refused(qc_check(shop("sup.wes"), pid, "ph_dim"), 403, "forbidden")
def test_check_put_without_inspection(shop: Shop) -> None:
pkt = at_qc(shop)
out = ok(reject(shop, pkt, REASON))
assert out["qc"] is None
# back with the supervisor: no inspection is open
r = qc_check(shop("qc.quinn"), pkt["id"], "ph_dim")
assert r.status_code in (403, 409), r.text
assert r.json()["code"] in ("wrong_status", "no_inspection"), r.text
def test_weld_put(shop: Shop) -> None:
pkt = at_qc(shop)
q = shop("qc.quinn")
pid = pkt["id"]
w1, w2 = sorted(int(w["weld_no"]) for w in pkt["welds"])
out = ok(qc_weld(q, pid, w1, result="accept"))
e = out["qc"]["weld_exam"][str(w1)]
assert e["result"] == "accept" and e["at"] and e["by"] and e["by_name"] == "Quinn QC", e
assert last_log(q, pid)["action"] == f"VT accept · W{w1}" and not last_log(q, pid)["note"]
out = ok(qc_weld(q, pid, w2, result="reject", note="undercut"))
assert out["qc"]["weld_exam"][str(w2)]["note"] == "undercut"
assert (last_log(q, pid)["action"], last_log(q, pid)["note"]) == (f"VT reject · W{w2}", "undercut")
# a note-only change
ok(qc_weld(q, pid, w2, note="undercut 1/32"))
assert (last_log(q, pid)["action"], last_log(q, pid)["note"]) == ("Q.C. record changed", f"W{w2} note")
# cleared
out = ok(qc_weld(q, pid, w2, result=""))
assert not out["qc"]["weld_exam"].get(str(w2), {}).get("result")
assert last_log(q, pid)["action"] == f"VT cleared · W{w2}"
# a weld that is not on the packet
refused(qc_weld(q, pid, 99, result="accept"), 422, "invalid_weld")
refused(qc_weld(shop("clerk.clara"), pid, w1, result="reject"), 403, "forbidden")
def test_field_cas_patch(shop: Shop) -> None:
pkt = at_qc(shop)
q = shop("qc.quinn")
pid = pkt["id"]
out = ok(qc_patch(q, pid, {"inspector": "Quinn QC", "stamp": "Q1"}, {"inspector": None, "stamp": None}))
assert out["qc"]["inspector"] == "Quinn QC" and out["qc"]["stamp"] == "Q1"
assert (last_log(q, pid)["action"], last_log(q, pid)["note"]) == (
"Q.C. record changed",
"inspector Quinn QC · stamp Q1",
)
# a stale base
r = refused(qc_patch(q, pid, {"inspector": "Q. Quinn"}, {"inspector": "Somebody"}), 409, "field_conflict")
assert r["current"] == {"inspector": "Quinn QC"}
# NDE method and result validation
r = refused(qc_patch(q, pid, {"nde_method": "XR"}), 422, "invalid_field")
assert r["detail"] == "Pick an NDE method from the list."
refused(qc_patch(q, pid, {"nde_result": "Maybe"}), 422, "invalid_field")
ok(qc_patch(q, pid, {"nde_method": "RT"}))
assert last_log(q, pid)["action"] == "NDE method: RT"
out = ok(qc_patch(q, pid, {"nde_result": "Acceptable"}))
assert out["qc"]["nde_result"] == "Acceptable"
assert last_log(q, pid)["action"] == "NDE result: Acceptable"
out = ok(qc_patch(q, pid, {"hardness": "198 HB", "nde_report": "RPT-1"}))
assert (last_log(q, pid)["action"], last_log(q, pid)["note"]) == (
"Q.C. record changed",
"hardness 198 HB · report RPT-1",
)
ok(qc_patch(q, pid, {"notes": "Fit-up good"}))
assert (last_log(q, pid)["action"], last_log(q, pid)["note"]) == ("Q.C. record changed", "notes changed")
ok(qc_patch(q, pid, {"nde_result": ""}))
assert last_log(q, pid)["action"] == "NDE result: -"
ok(qc_patch(q, pid, {"nde_result": "Acceptable"}))
# None required clears the result and the report
out = ok(qc_patch(q, pid, {"nde_method": "None required"}))
assert out["qc"]["nde_method"] == "None required"
assert not out["qc"]["nde_result"] and not out["qc"]["nde_report"]
assert "NDE method: None required" in actions(shop, pid)
for method in ("UT", "MT", "PT"):
assert ok(qc_patch(q, pid, {"nde_method": method}))["qc"]["nde_method"] == method
# the old flat body with If-Match still merges checks (phase 2), or the new endpoints replaced it
cur = fresh(q, pid)["qc"]
r = q.patch(
f"/api/packets/{pid}/qc",
json={"checks": {"ph_qty": {"done": True}}},
headers={"If-Match": str(cur["row_version"])},
)
assert r.status_code < 500, r.text
# the nace field is computed, not written
r = qc_patch(q, pid, {"nace": True})
assert r.status_code < 500, r.text
assert fresh(q, pid)["qc"]["nace_label"] is False
def test_qc_items_hints_and_nace_label(shop: Shop) -> None:
pkt = at_qc(shop)
q = shop("qc.quinn")
items = fresh(q, pkt["id"])["qc_items"]
assert [i["key"] for i in items] == list(QC_KEYS)
assert [i["group"] for i in items] == ["Data book package"] * 5 + ["Physical spool"] * 5
hints = {i["key"]: i["hint"] for i in items}
assert hints["db_heats"] == "3/3 recorded"
assert hints["db_mtr"] == "0/3 MTRs attached"
assert hints["db_weldmap"] == "2 welds on map"
assert hints["db_ck"] == "checklist 9/9 · filler ER70S-6"
assert all(hints[k] is None for k in QC_KEYS[5:]), hints
assert fresh(q, pkt["id"])["qc"]["nace_label"] is False
run(shop.settings, "UPDATE packets SET wps = 'WPS-12 nace sour' WHERE id = %s", (pkt["id"],))
assert fresh(q, pkt["id"])["qc"]["nace_label"] is True
# ================================================================ accept
def _snapshot(shop: Shop, pid: int) -> Any:
row = one(
shop.settings,
"SELECT qc_snapshot FROM packet_stages WHERE packet_id = %s AND closed_at IS NOT NULL ORDER BY id DESC LIMIT 1",
(pid,),
)
assert row is not None
return row[0]
def test_accept_to_data_book(shop: Shop) -> None:
pkt = finish_inspection(shop, at_qc(shop), nde=("RT", "Acceptable"))
out = ok(accept(shop, pkt, "databook"))
assert out["status"] == "complete" and out["routed_to"] == "databook" and out["qc"] is None
assert out["stage"] is None
h = out["qc_history"][-1]
assert h["result"] == "accepted" and h["inspector"] == "Quinn QC"
assert h["by"]["display_name"] == "Quinn QC" and h["location"] == "Bay 2" and h["sent_at"]
assert h["rejected_welds"] == []
assert out["qc_last"]["id"] == h["id"] and out["qc_last"]["result"] == "accepted"
snap = _snapshot(shop, pkt["id"])
assert snap and snap.get("inspector") == "Quinn QC" and snap.get("result") == "accepted", snap
log = last_log(shop("qc.quinn"), pkt["id"])
assert log["action"] == f"Q.C. accepted → {ROUTE_DB}"
assert log["note"] == "Quinn QC [Q1] · 2 welds VT accepted · RT Acceptable"
assert log["signed"] is True
def test_accept_to_shipping(shop: Shop) -> None:
pkt = finish_inspection(shop, at_qc(shop))
out = ok(accept(shop, pkt, "shipping"))
assert out["status"] == "complete" and out["routed_to"] == "shipping"
log = last_log(shop("qc.quinn"), pkt["id"])
assert (log["action"], log["note"]) == ("Q.C. accepted → Shipping", "Quinn QC [Q1] · 2 welds VT accepted")
def test_accept_note_without_stamp(shop: Shop) -> None:
pkt = finish_inspection(shop, at_qc(shop, welds=1), stamp="")
ok(accept(shop, pkt, "shipping"))
log = last_log(shop("qc.quinn"), pkt["id"])
assert log["note"] == "Quinn QC · 1 weld VT accepted" or log["note"] == "Quinn QC · 1 welds VT accepted", log
def test_accept_and_route_to_hydro(shop: Shop) -> None:
pkt = finish_inspection(shop, at_qc(shop))
refused(accept_route(shop, pkt, "03"), 403, "precondition")
out = ok(accept_route(shop, pkt, "09"))
assert out["status"] == "ready" and out["dept"]["code"] == "09"
assert out["stage"] is not None and out["stage"]["dept_code"] == "09"
assert out["qc"] is None and out["qc_last"]["result"] == "accepted"
log = last_log(shop("qc.quinn"), pkt["id"])
assert (log["action"], log["note"]) == ("Q.C. accepted → 09 Hydro", "Quinn QC [Q1] · from 03 Weld Parts")
# ================================================================ reject
def _rejected(shop: Shop, pkt: dict[str, Any], out: dict[str, Any], note: str, return_note: str) -> None:
assert out["status"] == "review" and out["qc"] is None
last = out["qc_last"]
assert last["result"] == "rejected"
assert out["return_note"] == return_note
log = last_log(shop("sup.wes"), pkt["id"])
assert (log["action"], log["note"]) == ("Q.C. rejected → supervisor", note)
assert log["signed"] is True
def test_reject_with_reason_only(shop: Shop) -> None:
pkt = at_qc(shop)
w2 = max(int(w["weld_no"]) for w in pkt["welds"])
ok(qc_weld(shop("qc.quinn"), pkt["id"], w2, result="reject", note="undercut"))
out = ok(reject(shop, pkt, REASON))
_rejected(shop, pkt, out, f"{REASON} · welds W{w2}", REASON)
assert out["qc_last"]["ncr"] is None and out["qc_last"]["rejected_welds"] == [f"W{w2}"]
def test_reject_with_ncr_only(shop: Shop) -> None:
pkt = at_qc(shop)
q = shop("qc.quinn")
refused(reject(shop, pkt, ncr="NCR-26-1"), 422, "invalid_ncr", "Enter an NCR number like NCR-2026-0021.")
refused(reject(shop, pkt), 400, "reason_required")
refused(reject(shop, pkt, "bad"), 400, "reason_required")
assert fresh(q, pkt["id"])["status"] == "qc"
out = ok(reject(shop, pkt, ncr=" ncr-2026-0021 "))
_rejected(shop, pkt, out, NCR, NCR)
assert out["qc_last"]["ncr"] == NCR and out["qc_last"]["rejected_welds"] == []
row = one(
shop.settings, "SELECT ncr FROM qc_inspections WHERE packet_id = %s ORDER BY id DESC LIMIT 1", (pkt["id"],)
)
assert row[0] == NCR
assert NCR_URL.endswith("/ncrs/")
def test_reject_with_both_then_send_again(shop: Shop) -> None:
pkt = at_qc(shop)
q = shop("qc.quinn")
w1, w2 = sorted(int(w["weld_no"]) for w in pkt["welds"])
ok(qc_check(q, pkt["id"], "ph_dim"))
ok(qc_weld(q, pkt["id"], w2, result="reject", note="porosity"))
ok(qc_weld(q, pkt["id"], w1, result="reject", note="undercut"))
out = ok(reject(shop, pkt, "Weld 2 undercut", ncr=NCR))
_rejected(shop, pkt, out, f"{NCR} · Weld 2 undercut · welds W{w1}, W{w2}", f"{NCR} · Weld 2 undercut")
first = out["qc_last"]
assert first["ncr"] == NCR and first["rejected_welds"] == [f"W{w1}", f"W{w2}"]
assert first["by"]["display_name"] == "Quinn QC" and first["location"] == "Bay 2"
assert len(out["qc_history"]) == 1
# the supervisor sees it and sends it to Q.C. again: a fresh empty inspection
wes = fresh(shop("sup.wes"), pkt["id"])
assert wes["qc_last"]["result"] == "rejected"
again = to_qc(shop, wes, location="Bay 4")
assert again["qc"]["checks"] == {} and again["qc"]["weld_exam"] == {}
assert again["qc"]["location"] == "Bay 4" and again["qc"]["ncr"] is None
assert len(again["qc_history"]) == 1 and again["qc_history"][0]["ncr"] == NCR
assert again["qc_last"]["id"] == first["id"]
rows = all_rows(shop.settings, "SELECT count(*) FROM qc_inspections WHERE packet_id = %s", (pkt["id"],))
assert rows[0][0] == 2
def test_co_hold_blocks_accept_but_not_reject(shop: Shop) -> None:
pkt = finish_inspection(shop, at_qc(shop))
pkt = issue_co(shop, pkt, ["03"])
refused(accept(shop, pkt), 403, "co_hold")
out = ok(reject(shop, fresh(shop("qc.quinn"), pkt["id"]), REASON))
assert out["status"] == "review"
# ================================================================ the Q.C. home
def _ids(cards: list[dict[str, Any]]) -> list[int]:
return [int(c["id"]) for c in cards]
def test_qc_home(shop: Shop) -> None:
q = shop("qc.quinn")
_, unit_a = make_unit(shop.settings)
_, unit_b = make_unit(shop.settings)
waiting = at_qc(shop, unit_a)
to_reject = at_qc(shop, unit_b)
home = ok(q.get("/api/qc/home"))
assert set(home) >= {"kpis", "columns", "folders"}
assert home["kpis"] == {"waiting": 2, "accepted_7d": 0, "rejected_7d": 0}
assert _ids(home["columns"]["ready"]) == [waiting["id"], to_reject["id"]] # oldest status_at first
card = home["columns"]["ready"][0]
assert card["qc"]["location"] == "Bay 2" and card["qc"]["sent_at"]
assert "itp_hold_count" in card
assert home["columns"]["accepted"] == [] and home["columns"]["rejected"] == []
ok(reject(shop, to_reject, ncr=NCR))
accepted = ok(accept(shop, finish_inspection(shop, waiting), "databook"))
home = ok(q.get("/api/qc/home"))
assert home["kpis"] == {"waiting": 0, "accepted_7d": 1, "rejected_7d": 1}
assert _ids(home["columns"]["accepted"]) == [accepted["id"]]
assert _ids(home["columns"]["rejected"]) == [to_reject["id"]]
assert home["columns"]["rejected"][0]["qc"]["result"] == "rejected"
assert home["columns"]["accepted"][0]["qc"]["result"] == "accepted"
# the unit filter narrows the request
narrowed = ok(q.get(f"/api/qc/home?unit_id={unit_b}"))
assert _ids(narrowed["columns"]["rejected"]) == [to_reject["id"]] and narrowed["columns"]["accepted"] == []
# pm.paula reads it; a session is needed
assert ok(shop("pm.paula").get("/api/qc/home"))["kpis"]["accepted_7d"] == 1
assert ok(shop("wh.wade").get("/api/qc/home"))["kpis"]["rejected_7d"] == 1
def test_transition_stale_expect(shop: Shop) -> None:
pkt = finish_inspection(shop, at_qc(shop))
ok(reject(shop, pkt, REASON))
r = refused(step(shop("qc.quinn"), pkt, "qc_accept", routed_to="databook", sign=SIGN), 409, "stale_step")
assert r["current"]["status"] == "review"
+334
View File
@@ -0,0 +1,334 @@
"""Phase 6 documents: packet documents for the data book (add, the closed kind set, the file type, the filed lock,
take out with a reason and the row kept) and Q.C. folder documents on a unit or a project (add, the closed type
set, who may add, new revisions that supersede and never delete, the folder reads and counts, the audit texts)."""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
import pytest
from fakedir import FakeDirectory
from pshelpers import needs_pg
from qchelpers import (
DOC_KINDS,
MSG_DOC_KIND,
MSG_TAKE_OUT,
QC_TYPES,
add_packet_doc,
csv_asset,
filed_packet,
folder_project,
folder_unit,
make_qc_doc,
start_itp,
take_out,
)
from shophelpers import (
IT,
Shop,
all_rows,
draft,
fresh,
last_log,
make_unit,
ok,
one,
open_shop,
refused,
run,
)
from packetstation.settings import Settings
pytestmark = needs_pg
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def numbers(shop: Shop, unit_id: int) -> tuple[str, str]:
"""(project number, unit number) of a unit."""
row = one(
shop.settings,
"SELECT p.number, p.number || u.lot FROM units u JOIN projects p ON p.id = u.project_id WHERE u.id = %s",
(unit_id,),
)
return str(row[0]), str(row[1])
def add_unit(shop: Shop, project_id: int, lot: str) -> int:
row = one(
shop.settings,
"INSERT INTO units (project_id, lot, name, status) VALUES (%s, %s, 'Unit two', 'open') RETURNING id",
(project_id, lot),
)
return int(row[0])
def doc_of(pkt: dict[str, Any], doc_id: int) -> dict[str, Any]:
return next(d for d in pkt["docs"] if int(d["id"]) == doc_id)
# ================================================================ packet documents
def test_packet_doc_add(shop: Shop) -> None:
pkt = draft(shop)
q = shop("qc.quinn")
out = ok(add_packet_doc(q, pkt["id"], "nde", "RT report W1-W4", "RPT-2210, accept", "rt.pdf"))
d = out["docs"][-1]
assert d["kind"] == "nde" and d["kind_label"] == DOC_KINDS["nde"]
assert d["title"] == "RT report W1-W4" and d["note"] == "RPT-2210, accept"
assert d["file_name"] == "rt.pdf" and d["size"] > 0 and d["removed"] is None
log = last_log(q, pkt["id"])
assert (log["action"], log["note"]) == (
"Document added",
"NDE report (RT / UT / MT / PT) · RT report W1-W4 · rt.pdf",
)
# clerk.clara adds one with no title: the title is the kind label
out = ok(add_packet_doc(shop("clerk.clara"), pkt["id"], "hydro", None, None, "hydro.pdf"))
d = out["docs"][-1]
assert d["title"] == "Hydrotest report" and d["kind_label"] == "Hydrotest report"
assert last_log(q, pkt["id"])["note"] == "Hydrotest report · Hydrotest report · hydro.pdf"
# every kind of the closed set is accepted
for kind, label in DOC_KINDS.items():
out = ok(add_packet_doc(q, pkt["id"], kind, f"Doc {kind}"))
assert out["docs"][-1]["kind_label"] == label
assert [d["kind"] for d in out["docs"]][:2] == ["nde", "hydro"] # ordered added_at, id
def test_packet_doc_refusals(shop: Shop) -> None:
pkt = draft(shop)
q = shop("qc.quinn")
refused(add_packet_doc(q, pkt["id"], "xray"), 422, "invalid_kind", MSG_DOC_KIND)
refused(add_packet_doc(q, pkt["id"], asset_id=csv_asset(q)["id"]), 422, "invalid_asset")
refused(add_packet_doc(shop("wh.wade"), pkt["id"]), 403, "forbidden")
refused(add_packet_doc(shop("sup.wes"), pkt["id"]), 403, "forbidden")
assert fresh(q, pkt["id"])["docs"] == []
# over the note limit: refused, or cut to the limit (the contract gives the limit, not the answer)
r = add_packet_doc(q, pkt["id"], note="x" * 301)
assert r.status_code == 422 or len(r.json()["docs"][-1]["note"]) <= 300, r.text
def test_packet_doc_note_limit(shop: Shop) -> None:
pkt = draft(shop)
r = add_packet_doc(shop("qc.quinn"), pkt["id"], note="x" * 300)
if r.status_code == 200:
assert len(r.json()["docs"][-1]["note"]) == 300
else:
assert r.status_code == 422, r.text
def test_packet_doc_filed(shop: Shop) -> None:
pkt = filed_packet(shop)
refused(add_packet_doc(shop("qc.quinn"), pkt["id"]), 403, "filed")
refused(add_packet_doc(shop("clerk.clara"), pkt["id"]), 403, "filed")
def test_packet_doc_take_out(shop: Shop) -> None:
pkt = draft(shop)
q = shop("qc.quinn")
out = ok(add_packet_doc(q, pkt["id"], "nde", "RT report W1-W4", "RPT-2210, accept"))
doc_id = int(out["docs"][-1]["id"])
for reason in (None, "", "ab"):
refused(take_out(q, pkt["id"], doc_id, reason), 400, "reason_required", MSG_TAKE_OUT)
refused(take_out(shop("wh.wade"), pkt["id"], doc_id), 403, "forbidden")
out = ok(take_out(shop("clerk.clara"), pkt["id"], doc_id, "wrong packet"))
d = doc_of(out, doc_id)
assert d["removed"]["reason"] == "wrong packet" and d["removed"]["at"]
assert d["removed"]["by"]["display_name"] == "Clara Clerk"
log = last_log(q, pkt["id"])
assert (log["action"], log["note"]) == ("Document taken out of the data book", "RT report W1-W4 · wrong packet")
refused(take_out(q, pkt["id"], doc_id, "again please"), 409, "already_removed")
row = one(shop.settings, "SELECT removed_reason, removed_by IS NOT NULL FROM packet_docs WHERE id = %s", (doc_id,))
assert tuple(row) == ("wrong packet", True)
# nothing is deleted
assert all_rows(shop.settings, "SELECT count(*) FROM packet_docs WHERE packet_id = %s", (pkt["id"],))[0][0] == 1
def test_qc_folder_docs_on_the_packet(shop: Shop) -> None:
project_id, unit_id = make_unit(shop.settings)
pkt = draft(shop, unit_id)
assert fresh(shop("qc.quinn"), pkt["id"])["qc_folder_docs"] == []
ok(make_qc_doc(shop, unit_id=unit_id, type="ITP", title="ITP unit one"))
ok(make_qc_doc(shop, project_id=project_id, type="WPS / PQR", title="WPS-101 GTAW/SMAW", rev="2"))
docs = fresh(shop("wh.wade"), pkt["id"])["qc_folder_docs"]
by_scope = {d["scope"]: d for d in docs}
assert set(by_scope) == {"unit", "project"}
assert by_scope["unit"]["type"] == "ITP" and by_scope["unit"]["title"] == "ITP unit one"
assert by_scope["project"]["rev"] == "2" and by_scope["project"]["url"].startswith("/_blob/")
# ================================================================ Q.C. folder documents
def test_qc_doc_add_unit_and_project(shop: Shop) -> None:
project_id, unit_id = make_unit(shop.settings)
pno, uno = numbers(shop, unit_id)
out = ok(make_qc_doc(shop, unit_id=unit_id, type="ITP", title="ITP unit one", file_name="itp.pdf"))
assert out["unit"]["id"] == unit_id and out["project"]["id"] == project_id
d = out["docs"][0]
assert d["type"] == "ITP" and d["unit_id"] == unit_id and d["project_id"] is None
assert d["file_name"] == "itp.pdf" and d["url"] == f"/_blob/{d['asset_id']}" and d["auto"] is False
assert d["added_by"]["display_name"] == "Quinn QC" and d["superseded_by"] is None
out = ok(
make_qc_doc(
shop, project_id=project_id, type="WPS / PQR", title="WPS-101 GTAW/SMAW", rev="2", file_name="wps.pdf"
)
)
assert out["project"]["id"] == project_id and "units" in out
assert [x["title"] for x in out["docs"]] == ["WPS-101 GTAW/SMAW"]
# the audit rows and their texts
rows = all_rows(
shop.settings, "SELECT action, subject_type, detail FROM audit_events WHERE action LIKE 'qc_doc.%%' ORDER BY id"
)
assert [r[0] for r in rows] == ["qc_doc.add", "qc_doc.add"]
assert rows[0][2]["text"] == "Q.C. document added"
assert rows[0][2]["note"] == f"{uno} · ITP · ITP unit one · itp.pdf"
assert rows[1][2]["note"] == f"{pno} · WPS / PQR · WPS-101 GTAW/SMAW Rev 2 · wps.pdf"
# the title defaults to the type
out = ok(make_qc_doc(shop, unit_id=unit_id, type="NDE procedure"))
assert out["docs"][-1]["title"] == "NDE procedure"
for t in QC_TYPES:
assert ok(make_qc_doc(shop, unit_id=unit_id, type=t))["docs"][-1]["type"] == t
def test_qc_doc_refusals(shop: Shop) -> None:
project_id, unit_id = make_unit(shop.settings)
refused(make_qc_doc(shop, unit_id=unit_id, project_id=project_id), 422, "invalid_request")
refused(make_qc_doc(shop), 422, "invalid_request")
refused(
make_qc_doc(shop, unit_id=unit_id, type="Drawing"), 422, "invalid_type", "Pick a document type from the list."
)
refused(make_qc_doc(shop, unit_id=unit_id, asset_id=csv_asset(shop("qc.quinn"))["id"]), 422, "invalid_asset")
refused(make_qc_doc(shop, unit_id=unit_id, by="wh.wade"), 403, "forbidden")
refused(make_qc_doc(shop, unit_id=unit_id, by="weld.andy"), 403, "forbidden")
refused(make_qc_doc(shop, unit_id=unit_id, by="pm.paula"), 403, "forbidden")
refused(make_qc_doc(shop, unit_id=unit_id, by="admin.ann"), 400, "it_reason_required")
assert folder_unit(shop("qc.quinn"), unit_id)["docs"] == []
# who may add: Clerical, Q.C., and an admin with an IT reason
ok(make_qc_doc(shop, unit_id=unit_id, by="clerk.clara"))
ok(make_qc_doc(shop, unit_id=unit_id, by="multi.mia"))
out = ok(make_qc_doc(shop, unit_id=unit_id, by="admin.ann", it_reason=IT))
assert len(out["docs"]) == 3
row = one(shop.settings, "SELECT it_reason FROM audit_events WHERE action = 'qc_doc.add' ORDER BY id DESC LIMIT 1")
assert row[0] == IT
# over the limits (title 140, rev 12): refused, or cut to the limit
for field, n in (("title", 140), ("rev", 12)):
r = make_qc_doc(shop, unit_id=unit_id, **{field: "x" * (n + 1)})
assert r.status_code == 422 or len(r.json()["docs"][-1][field]) <= n, (field, r.text)
def test_qc_doc_new_revision(shop: Shop) -> None:
project_id, unit_id = make_unit(shop.settings)
other_unit = add_unit(shop, project_id, "002")
uno = numbers(shop, unit_id)[1]
first = ok(make_qc_doc(shop, unit_id=unit_id, type="ITP", title="ITP L1", rev="0"))["docs"][0]
# a revision from another folder or of a missing row is refused
refused(make_qc_doc(shop, unit_id=other_unit, replaces=first["id"]), 422, "invalid_replace")
refused(make_qc_doc(shop, project_id=project_id, replaces=first["id"]), 422, "invalid_replace")
refused(make_qc_doc(shop, unit_id=unit_id, replaces=999999), 422, "invalid_replace")
out = ok(
make_qc_doc(
shop, unit_id=unit_id, type="ITP", title="ITP L1", rev="1", replaces=first["id"], file_name="itp1.pdf"
)
)
assert [d["rev"] for d in out["docs"]] == ["1"]
assert [d["id"] for d in out["superseded"]] == [first["id"]]
old = out["superseded"][0]
new_id = out["docs"][0]["id"]
assert old["superseded_by"] == new_id and old["superseded_at"]
assert old["superseded_by_user"]["display_name"] == "Quinn QC"
# a second revision of the same old row
refused(make_qc_doc(shop, unit_id=unit_id, rev="1b", replaces=first["id"]), 409, "already_superseded")
# the new one can be revised again; superseded is newest first
out = ok(make_qc_doc(shop, unit_id=unit_id, type="ITP", title="ITP L1", rev="2", replaces=new_id, by="clerk.clara"))
assert [d["rev"] for d in out["docs"]] == ["2"]
assert [d["rev"] for d in out["superseded"]] == ["1", "0"]
# nothing is ever deleted
assert all_rows(shop.settings, "SELECT count(*) FROM qc_docs WHERE unit_id = %s", (unit_id,))[0][0] == 3
rows = all_rows(shop.settings, "SELECT action, detail FROM audit_events WHERE action LIKE 'qc_doc.%%' ORDER BY id")
assert [r[0] for r in rows] == ["qc_doc.add", "qc_doc.revise", "qc_doc.revise"]
assert rows[1][1]["text"] == "Q.C. document revised"
assert rows[1][1]["note"] == f"{uno} · ITP · ITP L1 Rev 1 · itp1.pdf"
def test_qc_folders_and_counts(shop: Shop) -> None:
q = shop("qc.quinn")
project_id, unit_1 = make_unit(shop.settings)
unit_2 = add_unit(shop, project_id, "002")
closed_id, _ = make_unit(shop.settings)
run(shop.settings, "UPDATE projects SET status = 'closed' WHERE id = %s", (closed_id,))
def row(show: str, pid: int) -> dict[str, Any] | None:
rows = ok(q.get(f"/api/qc/folders?show={show}"))
return next((r for r in rows if r["id"] == pid), None)
r = row("active", project_id)
assert r is not None and r["units"] == 2 and r["docs"] == 0 and r["units_without_itp"] == 2
assert {"number", "name", "customer", "po", "status"} <= set(r)
assert row("active", closed_id) is None
closed = row("all", closed_id)
assert closed is not None and closed["units_without_itp"] == 0 and closed["status"] == "closed"
# an ITP document on unit 1, then a digital ITP on unit 2
ok(make_qc_doc(shop, unit_id=unit_1, type="ITP"))
r = row("active", project_id)
assert r is not None and r["units_without_itp"] == 1 and r["docs"] == 1
start_itp(shop, unit_2)
r = row("active", project_id)
assert r is not None and r["units_without_itp"] == 0
# a project document counts too; a WPS is not an ITP
ok(make_qc_doc(shop, project_id=project_id, type="WPS / PQR"))
r = row("active", project_id)
assert r is not None and r["docs"] == 2 and r["units_without_itp"] == 0
# the home lists the active folders
home = ok(q.get("/api/qc/home"))
assert any(f["id"] == project_id for f in home["folders"])
assert not any(f["id"] == closed_id for f in home["folders"])
numbers_in_order = [f["number"] for f in ok(q.get("/api/qc/folders?show=all"))]
assert numbers_in_order == sorted(numbers_in_order)
def test_project_itp_document_covers_every_unit(shop: Shop) -> None:
project_id, unit_1 = make_unit(shop.settings)
add_unit(shop, project_id, "002")
ok(make_qc_doc(shop, project_id=project_id, type="ITP", title="Project ITP"))
rows = ok(shop("qc.quinn").get("/api/qc/folders?show=active"))
r = next(x for x in rows if x["id"] == project_id)
assert r["units_without_itp"] == 0
assert folder_unit(shop("qc.quinn"), unit_1)["no_itp"] is False
def test_project_and_unit_folder_reads(shop: Shop) -> None:
q = shop("qc.quinn")
project_id, unit_1 = make_unit(shop.settings)
unit_2 = add_unit(shop, project_id, "002")
pno, uno = numbers(shop, unit_1)
ok(make_qc_doc(shop, project_id=project_id, type="WPS / PQR", title="WPS-101", rev="2"))
ok(make_qc_doc(shop, unit_id=unit_1, type="Customer spec", title="Spec A"))
itp = start_itp(shop, unit_2)
pf = folder_project(q, project_id)
assert pf["project"]["number"] == pno and pf["project"]["customer"] == "Acme Refining"
assert [d["title"] for d in pf["docs"]] == ["WPS-101"] and pf["superseded"] == []
units = {u["id"]: u for u in pf["units"]}
assert set(units) == {unit_1, unit_2}
assert units[unit_1]["number"] == uno and units[unit_1]["docs"] == 1 and units[unit_1]["itp"] is None
assert units[unit_1]["has_itp_doc"] is False
assert units[unit_2]["itp"]["id"] == itp["id"] and units[unit_2]["itp"]["no"] == itp["no"]
assert units[unit_2]["itp"]["total"] == 70 and units[unit_2]["itp"]["done"] == 0
uf = folder_unit(q, unit_1)
assert uf["unit"]["number"] == uno and uf["project"]["id"] == project_id
assert [d["title"] for d in uf["docs"]] == ["Spec A"]
assert [d["title"] for d in uf["inherited"]] == ["WPS-101"]
assert uf["itp"] is None and uf["no_itp"] is True
uf2 = folder_unit(q, unit_2)
assert uf2["itp"]["status"] == "open" and uf2["no_itp"] is False
# any session reads; unknown ids are 404
assert shop("wh.wade").get(f"/api/qc/folders/units/{unit_1}").status_code == 200
assert q.get("/api/qc/folders/units/999999").status_code == 404
assert q.get("/api/qc/folders/projects/999999").status_code == 404
+86 -4
View File
@@ -43,7 +43,7 @@ ENUMS = {
"lookup_failed",
"hand",
],
"itp_status": ["open", "signed", "reopened"],
"itp_status": ["open", "signed", "reopened", "complete"],
}
# One minimal INSERT per append-only table, with ids from seed().
@@ -63,6 +63,7 @@ APPEND_INSERTS = {
"INSERT INTO packet_log (packet_id, action, by) VALUES (%(packet)s, 'Packet created by hand', %(user)s)"
),
"itp_rejects": "INSERT INTO itp_rejects (itp_id, line_key, ncr) VALUES (%(itp)s, 'k1', 'NCR-1')",
"itp_log": "INSERT INTO itp_log (itp_id, action, by) VALUES (%(itp)s, 'ITP started', %(user)s)",
"co_verifications": (
"INSERT INTO co_verifications (co_id, dept_code, by, signature_id) VALUES (%(co)s, 'WH', %(user)s, %(sig)s)"
),
@@ -347,9 +348,8 @@ def test_app_can_write_every_table_and_read_every_view(pg_settings: Settings) ->
"INSERT INTO packet_seals (packet_id, hash, key_id, sealed_by) "
"VALUES (%(packet)s, '\\x01', 'abcd1234', %(user)s)",
"INSERT INTO packet_visual_state (packet_id) VALUES (%(packet)s)",
"INSERT INTO packet_docs (packet_id, asset_id, kind, title) VALUES (%(packet)s, %(asset)s, 'mtr', 'MTR')",
"INSERT INTO qc_docs (project_id, unit_id, type, asset_id) "
"VALUES (%(project)s, %(unit)s, 'wps', %(asset)s)",
"INSERT INTO packet_docs (packet_id, asset_id, kind, title) VALUES (%(packet)s, %(asset)s, 'nde', 'RT')",
"INSERT INTO qc_docs (unit_id, type, asset_id) VALUES (%(unit)s, 'WPS / PQR', %(asset)s)",
"INSERT INTO itp_lines (itp_id, key, result, by) VALUES (%(itp)s, 'k1', 'approve', %(user)s)",
"INSERT INTO itp_blocks (itp_id, block_key, signed_by, signature_id) "
"VALUES (%(itp)s, 'b1', %(user)s, %(sig)s)",
@@ -527,3 +527,85 @@ def test_shop_columns_and_checklist_templates(pg_settings: Settings) -> None:
assert [i["key"] for i in value["weld"]] == ["dwg", "heat", "wps", "fit", "pre", "vt", "stamp", "clean", "qty"]
assert [i["key"] for i in value["plain"]] == ["dwg", "prev", "mat", "work", "insp", "qty"]
assert value["weld"][0]["text"] == "Drawing {drawing} Rev {rev} matches the packet"
def test_qc_columns_checks_and_items(pg_settings: Settings) -> None:
"""0007: the inspection NCR, document notes and kinds, Q.C. folder scope and types, the ITP details, the ITP log
and the Q.C. check items row."""
with db.connect_as("migrate", pg_settings, autocommit=True) as conn:
names = [r[0] for r in conn.execute("SELECT name FROM schema_migrations ORDER BY name").fetchall()]
assert "0007_qc.sql" in names
with app_conn(pg_settings) as conn:
for t, c, kind in (
("qc_inspections", "ncr", "text"),
("packet_docs", "note", "text"),
("qc_docs", "title", "text"),
("qc_docs", "note", "text"),
("qc_docs", "superseded_at", "timestamp with time zone"),
("qc_docs", "superseded_by_user", "uuid"),
("itps", "no", "text"),
("itps", "hdr", "jsonb"),
("itps", "created_at", "timestamp with time zone"),
("itps", "created_by", "uuid"),
("itp_lines", "initials", "text"),
("itp_lines", "cust_code", "text"),
("itp_lines", "cust_by", "text"),
("itp_lines", "cust_at", "date"),
("itp_lines", "cust_rec", "uuid"),
("itp_lines", "cust_rec_at", "timestamp with time zone"),
("itp_rejects", "kind", "text"),
("itp_blocks", "qn", "text"),
("itp_blocks", "remarks", "text"),
("itp_blocks", "cust_name", "text"),
("itp_blocks", "cust_at", "date"),
("itp_blocks", "cust_by", "uuid"),
("itp_blocks", "cust_rec_at", "timestamp with time zone"),
("itp_blocks", "row_version", "integer"),
("itp_log", "it_reason", "text"),
):
got = one(
conn,
"SELECT data_type FROM information_schema.columns WHERE table_name = %s AND column_name = %s",
(t, c),
)
assert got == kind, (t, c)
ids = seed(conn)
ids["asset"] = one(
conn,
"INSERT INTO assets (sha256, size, mime, storage_key, pages) VALUES ('\\x00', 10, 'application/pdf',"
" 'ab/q7', 1) RETURNING id",
)
with pytest.raises(psycopg.errors.CheckViolation), conn.transaction():
conn.execute(
"INSERT INTO packet_docs (packet_id, asset_id, kind) VALUES (%(packet)s, %(asset)s, 'mtr')", ids
)
with pytest.raises(psycopg.errors.CheckViolation), conn.transaction():
conn.execute(
"INSERT INTO qc_docs (project_id, unit_id, type, asset_id) VALUES (%(project)s, %(unit)s, 'ITP',"
" %(asset)s)",
ids,
)
with pytest.raises(psycopg.errors.CheckViolation), conn.transaction():
conn.execute("INSERT INTO qc_docs (unit_id, type, asset_id) VALUES (%(unit)s, 'wps', %(asset)s)", ids)
conn.execute("INSERT INTO qc_docs (project_id, type, asset_id) VALUES (%(project)s, 'ITP', %(asset)s)", ids)
# A reject may come before its NCR number; a block row exists before it is signed.
conn.execute("INSERT INTO itp_rejects (itp_id, line_key) VALUES (%(itp)s, '1.1')", ids)
assert one(conn, "SELECT kind FROM itp_rejects WHERE itp_id = %(itp)s", ids) == "reject"
conn.execute("INSERT INTO itp_blocks (itp_id, block_key) VALUES (%(itp)s, '3')", ids)
assert one(conn, "SELECT signed_at FROM itp_blocks WHERE itp_id = %(itp)s", ids) is None
conn.execute("UPDATE itps SET status = 'complete' WHERE id = %(itp)s", ids)
value = one(conn, "SELECT value FROM settings WHERE key = 'qc_check_items'")
assert [g["group"] for g in value["groups"]] == ["Data book package", "Physical spool"]
keys = [i["key"] for g in value["groups"] for i in g["items"]]
assert keys == [
"db_heats",
"db_mtr",
"db_weldmap",
"db_wps",
"db_ck",
"ph_dim",
"ph_heat",
"ph_stamp",
"ph_clean",
"ph_qty",
]