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,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"
|
||||
Reference in New Issue
Block a user