Phase 6: Q.C. inspection, project folders, packet documents and the digital ITP
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:
@@ -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))
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user