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

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

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-17 20:08:53 -06:00
co-authored by Claude Fable 5.1
parent 8bb1f7ae4d
commit 00531cb329
64 changed files with 11715 additions and 260 deletions
+597
View File
@@ -0,0 +1,597 @@
"""The digital ITP (Inspection and Test Plan), one per unit.
Starting an ITP freezes a copy of the Q.C. template (domain/itp_template.py) into itps.template, so a later template
change never alters an ITP in use. Q.C. initials each line (approve), marks lines N/A, rejects a line with an NCR
number (now or later), records the customer's code and witness, signs each block of sections with a signature, and
signs the ITP off with a signature. Open hold (H) and witness (W) points, and rejected lines, show as a warning on the
unit's packets for the department's pages. Nothing is blocked by them.
The pure functions here (items, is_done, is_hold_code, open_holds, blocks, block_ready, progress, dept_pages,
holds_for_packet) port the old itp.js helpers. They take the frozen template and the lines as a mapping of line key to
a row with `result` ("approve", "reject", "na" or None), `cust_code` and `ncr`.
Writes go through `itp_write`: check_scoped with the action, then the ITP row locked FOR UPDATE, the complete lock,
the signature when the rule needs one, then the version bump, the itp_log rows, the audit rows and the NOTIFY. ITP
writes never look at a packet's status: a filed packet in the unit does not lock the unit's ITP.
"""
from __future__ import annotations
import re
from collections.abc import Iterable, Iterator, Mapping, Sequence
from contextlib import contextmanager
from dataclasses import dataclass, field
from datetime import date, datetime
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from .. import audit, events, policy
from ..api import deps
from ..api.errors import ApiError
from ..auth import signatures
from ..auth.signatures import Signature
from ..policy.actions import SCOPED_LABELS
from ..policy.engine import check_scoped
from ..text import LIMITS, clean_opt, reason_ok
from . import packets
A = policy.Action
PAGE_NAMES: dict[str, str] = {"WP": "Weld Parts", "C": "Code", "FA": "Final Assembly"}
PAGE_ORDER: tuple[str, ...] = ("WP", "C", "FA")
# The pages each shop department works to (the old ITP_DEPT_PAGES). A support department or an unknown code sees all.
DEPT_PAGES: dict[str, tuple[str, ...]] = {
"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",),
}
CUST_CODES: tuple[str, ...] = ("R", "R/AU", "AU", "W", "H", "NA")
RX_HOLD = re.compile(r"(^|/)(H|W)$")
LOG_MAX = 600
MSG_COMPLETE = "The ITP is signed off and locked. An admin can reopen it."
MSG_NO_ITP = "No such ITP."
# ---------------------------------------------------------------- pure functions over the template and lines
def items(template: Mapping[str, Any]) -> list[dict[str, Any]]:
"""Every line of the template in order, with its section number (`sec`) and page."""
out: list[dict[str, Any]] = []
for s in template.get("sections") or []:
for i in s.get("items") or []:
out.append({**i, "sec": s.get("n"), "page": s.get("page")})
return out
def _line(lines: Mapping[str, Any], key: str) -> Mapping[str, Any]:
v = lines.get(key)
return v if isinstance(v, Mapping) else {}
def is_done(line: Mapping[str, Any] | None) -> bool:
"""A line is done when it is N/A or approved."""
return bool(line) and (line or {}).get("result") in ("approve", "na")
def is_rejected(line: Mapping[str, Any] | None) -> bool:
"""An open reject: the line is rejected and not approved since."""
return bool(line) and (line or {}).get("result") == "reject"
def is_hold_code(code: Any) -> bool:
"""H or W, alone or after a slash (R/H). The old isHoldCode."""
return bool(RX_HOLD.search(str(code or "")))
def cust_code(item: Mapping[str, Any], line: Mapping[str, Any] | None) -> str:
"""The customer code of a line: its own when set, else the template's."""
own = (line or {}).get("cust_code")
return str(own) if own else str(item.get("cust") or "")
def hold_codes(item: Mapping[str, Any], line: Mapping[str, Any] | None) -> list[str]:
return [c for c in (str(item.get("code") or ""), cust_code(item, line)) if is_hold_code(c)]
def open_holds(
template: Mapping[str, Any], lines: Mapping[str, Any], pages: Sequence[str] | None = None
) -> list[dict[str, Any]]:
"""The hold, witness or rejected points not done yet, on the given pages (None: every page)."""
out: list[dict[str, Any]] = []
for i in items(template):
if pages is not None and i["page"] not in pages:
continue
line = _line(lines, str(i["n"]))
codes = hold_codes(i, line)
rej = is_rejected(line)
if (codes or rej) and not is_done(line):
out.append(
{
"n": i["n"],
"t": i["t"],
"page": i["page"],
"codes": codes,
"rejected": rej,
"ncr": line.get("ncr") if rej else None,
}
)
return out
def blocks(template: Mapping[str, Any]) -> list[dict[str, Any]]:
"""The signature blocks: one closes at every section with `sig` and covers the sections since the last block."""
out: list[dict[str, Any]] = []
acc: list[Mapping[str, Any]] = []
for s in template.get("sections") or []:
acc.append(s)
if s.get("sig"):
out.append({"key": str(s["n"]), "sections": acc, "final": bool(s.get("final"))})
acc = []
if acc:
out.append({"key": str(acc[-1]["n"]), "sections": acc, "final": True})
return out
def block_scope(block: Mapping[str, Any]) -> str:
secs = block["sections"]
if len(secs) > 1:
return f"Sections {secs[0]['n']}–{secs[-1]['n']}"
return f"Section {block['key']}"
def block_of_line(template: Mapping[str, Any], key: str) -> dict[str, Any] | None:
for b in blocks(template):
if any(str(i.get("n")) == key for s in b["sections"] for i in s.get("items") or []):
return b
return None
def _block_lines(block: Mapping[str, Any]) -> list[str]:
return [str(i.get("n")) for s in block["sections"] for i in s.get("items") or []]
def block_ready(block: Mapping[str, Any], lines: Mapping[str, Any]) -> bool:
"""Every line of the block is done (approved or N/A)."""
return all(is_done(_line(lines, k)) for k in _block_lines(block))
def block_all_na(block: Mapping[str, Any], lines: Mapping[str, Any]) -> bool:
return all(_line(lines, k).get("result") == "na" for k in _block_lines(block))
def progress(template: Mapping[str, Any], lines: Mapping[str, Any]) -> dict[str, int]:
it = items(template)
return {
"done": sum(1 for i in it if is_done(_line(lines, str(i["n"])))),
"total": len(it),
"holds": len(open_holds(template, lines)),
}
def dept_pages(code: Any) -> list[str] | None:
"""The pages a department works to, or None for a support department or an unknown code (every page)."""
pages = DEPT_PAGES.get(str(code or ""))
return list(pages) if pages is not None else None
def holds_for_packet(
itp_row: Mapping[str, Any] | None, lines: Mapping[str, Any], dept_code: Any
) -> dict[str, Any] | None:
"""The packet's ITP warning: open hold, witness or rejected points on its department's pages, while the unit's
ITP is open. None when there is none."""
if itp_row is None or str(itp_row.get("status")) == "complete":
return None
pages = dept_pages(dept_code)
hs = open_holds(itp_row["template"] or {}, lines, pages)
if not hs:
return None
return {
"no": itp_row.get("no"),
"count": len(hs),
"rejected": any(h["rejected"] for h in hs),
"pages": pages,
"items": [{k: h[k] for k in ("n", "t", "codes", "rejected", "ncr")} for h in hs],
}
def initials(name: Any) -> str:
"""The first letter of each word of a name, upper case, at most three. The old initials()."""
return "".join(w[0].upper() for w in str(name or "").split() if w)[:3]
def site_zone(tz: str | None) -> ZoneInfo:
try:
return ZoneInfo(tz or "America/Denver")
except (ZoneInfoNotFoundError, ValueError):
return ZoneInfo("America/Denver")
def date_text(value: Any, tz: str | None = None) -> str:
"""M/D/YYYY in the site time zone (10/17/2026)."""
if value is None:
return ""
if isinstance(value, datetime):
d = value.astimezone(site_zone(tz)).date() if value.tzinfo else value.date()
elif isinstance(value, date):
d = value
else:
return str(value)
return f"{d.month}/{d.day}/{d.year}"
def today(tz: str | None = None) -> date:
return datetime.now(site_zone(tz)).date()
# ---------------------------------------------------------------- loading
def load_row(conn: Any, itp_id: int, *, lock: bool = False) -> dict[str, Any] | None:
sql = "SELECT *, status::text AS status FROM itps WHERE id = %s"
if lock:
sql += " FOR UPDATE"
return packets.one(conn, sql, (itp_id,))
def row_for_unit(conn: Any, unit_id: int) -> dict[str, Any] | None:
return packets.one(conn, "SELECT *, status::text AS status FROM itps WHERE unit_id = %s", (unit_id,))
def load_lines(conn: Any, itp_id: int) -> dict[str, dict[str, Any]]:
return {r["key"]: r for r in packets.rows(conn, "SELECT * FROM itp_lines WHERE itp_id = %s", (itp_id,))}
def load_blocks(conn: Any, itp_id: int) -> dict[str, dict[str, Any]]:
return {r["block_key"]: r for r in packets.rows(conn, "SELECT * FROM itp_blocks WHERE itp_id = %s", (itp_id,))}
def summary(itp_row: Mapping[str, Any], lines: Mapping[str, Any]) -> dict[str, Any]:
pr = progress(itp_row["template"] or {}, lines)
return {
"id": itp_row["id"],
"no": itp_row["no"],
"status": str(itp_row["status"]),
"done": pr["done"],
"total": pr["total"],
"holds": pr["holds"],
}
def unit_itps(conn: Any, unit_ids: Iterable[int]) -> dict[int, tuple[dict[str, Any], dict[str, dict[str, Any]]]]:
"""The ITP row and its lines per unit, for the given units, in two queries."""
ids = sorted({int(u) for u in unit_ids})
if not ids:
return {}
itps = packets.rows(conn, "SELECT *, status::text AS status FROM itps WHERE unit_id = ANY(%s)", (ids,))
if not itps:
return {}
lines: dict[int, dict[str, dict[str, Any]]] = {r["id"]: {} for r in itps}
for ln in packets.rows(
conn,
"SELECT itp_id, key, result, cust_code, ncr FROM itp_lines WHERE itp_id = ANY(%s)",
([r["id"] for r in itps],),
):
lines[ln["itp_id"]][ln["key"]] = ln
return {int(r["unit_id"]): (r, lines[r["id"]]) for r in itps}
def summaries(conn: Any, unit_ids: Iterable[int]) -> dict[int, dict[str, Any]]:
return {u: summary(row, lines) for u, (row, lines) in unit_itps(conn, unit_ids).items()}
# ---------------------------------------------------------------- the read
def can_of(pp: policy.Principal, itp_row: Mapping[str, Any], blocks_signed: bool) -> dict[str, bool]:
open_ = str(itp_row["status"]) != "complete"
edit = open_ and check_scoped(pp, A.ITP_EDIT_LINE, assume_it_reason=True).ok
sign_off = open_ and blocks_signed and check_scoped(pp, A.ITP_SIGN_OFF, assume_it_reason=True).ok
reopen = (not open_) and check_scoped(pp, A.ITP_REOPEN, extra={"reason": "x" * 8}, assume_it_reason=True).ok
return {"edit": edit, "sign_off": sign_off, "reopen": reopen}
def read(conn: Any, itp_row: Mapping[str, Any], pp: policy.Principal) -> dict[str, Any]:
"""The full ITP answer (GET /api/units/{unit_id}/itp and every ITP write)."""
iid = itp_row["id"]
template = itp_row["template"] or {}
lines = load_lines(conn, iid)
brows = load_blocks(conn, iid)
rejects: dict[str, list[dict[str, Any]]] = {}
for r in packets.rows(conn, "SELECT * FROM itp_rejects WHERE itp_id = %s ORDER BY id", (iid,)):
rejects.setdefault(r["line_key"], []).append(r)
log_rows = packets.rows(conn, "SELECT * FROM itp_log WHERE itp_id = %s ORDER BY id DESC LIMIT %s", (iid, LOG_MAX))
ids: list[Any] = [itp_row["signoff_by"], itp_row["reopen_by"], itp_row["created_by"]]
for ln in lines.values():
ids += [ln["by"], ln["cust_rec"]]
for b in brows.values():
ids += [b["signed_by"], b["cust_by"]]
for rs in rejects.values():
ids += [r["by"] for r in rs]
ids += [r["by"] for r in log_rows]
people = packets.People(conn, ids)
unit = packets.one(
conn,
"SELECT u.id, u.number, u.project_id, u.project_number FROM v_units u WHERE u.id = %s",
(itp_row["unit_id"],),
)
out_lines: dict[str, Any] = {}
for i in items(template):
key = str(i["n"])
ln = lines.get(key) or {}
result = ln.get("result")
out_lines[key] = {
"key": key,
"result": result,
"na": result == "na",
"by": people.ref(ln.get("by")) if result == "approve" else None,
"initials": ln.get("initials") if result == "approve" else None,
"at": ln.get("at") if result == "approve" else None,
"ncr": ln.get("ncr"),
"ncr_missing": result == "reject" and not ln.get("ncr"),
"note": ln.get("note"),
"cust_code": ln.get("cust_code"),
"cust_by": ln.get("cust_by"),
"cust_at": ln.get("cust_at"),
"cust_rec": people.ref(ln.get("cust_rec")),
"rejects": [
{"at": r["at"], "by": people.ref(r["by"]), "ncr": r["ncr"], "note": r["note"], "kind": r["kind"]}
for r in rejects.get(key, [])
],
"row_version": ln.get("row_version", 0),
}
out_blocks: list[dict[str, Any]] = []
for b in blocks(template):
br = brows.get(b["key"]) or {}
out_blocks.append(
{
"key": b["key"],
"sections": [str(s["n"]) for s in b["sections"]],
"scope": block_scope(b),
"final": b["final"],
"ready": block_ready(b, lines),
"all_na": block_all_na(b, lines),
"signed": br.get("signed_at") is not None,
"signed_at": br.get("signed_at"),
"signed_by": people.ref(br.get("signed_by")) if br.get("signed_at") is not None else None,
"qn": br.get("qn"),
"remarks": br.get("remarks"),
"cust_name": br.get("cust_name"),
"cust_at": br.get("cust_at"),
"cust_by": people.ref(br.get("cust_by")),
"row_version": br.get("row_version", 0),
}
)
all_signed = bool(out_blocks) and all(b["signed"] for b in out_blocks)
pages_present = {s.get("page") for s in template.get("sections") or []}
status = str(itp_row["status"])
return {
"id": iid,
"unit_id": itp_row["unit_id"],
"unit": None
if unit is None
else {
"id": unit["id"],
"number": unit["number"],
"project": {"id": unit["project_id"], "number": unit["project_number"]},
},
"no": itp_row["no"],
"hdr": {k: (itp_row["hdr"] or {}).get(k) for k in HDR_KEYS},
"status": "complete" if status == "complete" else "open",
"version": itp_row["version"],
"template": template,
"pages": [{"code": c, "name": PAGE_NAMES[c]} for c in PAGE_ORDER if c in pages_present],
"lines": out_lines,
"blocks": out_blocks,
"progress": progress(template, lines),
"holds": open_holds(template, lines),
"signoff": None
if itp_row["signoff_at"] is None
else {
"at": itp_row["signoff_at"],
"by": people.ref(itp_row["signoff_by"]),
"asset_id": packets.uid_text(itp_row["signoff_asset_id"]),
},
"reopen": None
if itp_row["reopen_at"] is None
else {"at": itp_row["reopen_at"], "by": people.ref(itp_row["reopen_by"]), "reason": itp_row["reopen_reason"]},
"created_at": itp_row["created_at"],
"created_by": people.ref(itp_row["created_by"]),
"can": can_of(pp, itp_row, all_signed),
"log": [
{
"id": r["id"],
"at": r["at"],
"by": people.ref(r["by"]),
"by_name": r["by_name"],
"action": r["action"],
"note": r["note"],
"signed": r["signature_id"] is not None,
}
for r in log_rows
],
}
HDR_KEYS: tuple[str, ...] = ("client", "pname", "pno", "po", "unit")
# ---------------------------------------------------------------- the write
@dataclass
class ItpWrite:
conn: Any
principal: deps.Principal
policy_principal: policy.Principal
itp: dict[str, Any]
action: policy.Action
decision: policy.Decision
request: Any
it_reason: str | None # the cleaned IT reason, kept only when the admin bypass was used
signature: Signature | None = None
logs: list[tuple[str, str | None]] = field(default_factory=list)
version: int = 0
@property
def id(self) -> int:
return int(self.itp["id"])
@property
def user_id(self) -> Any:
return self.principal.user_id
@property
def tz(self) -> str:
return str(deps.settings_of(self.request).site_tz)
def log(self, action: str, note: str | None = None) -> None:
"""One itp_log row (and audit row) for this write. A write that logs nothing changes nothing."""
self.logs.append((action, note))
def refuse(decision: policy.Decision) -> None:
if decision.ok:
return
status = 400 if decision.code in ("it_reason_required", "reason_required") else 403
raise ApiError(status, decision.code, decision.why)
def scoped_decision(
p: deps.Principal,
pp: policy.Principal,
action: policy.Action,
*,
extra: Mapping[str, Any] | None = None,
it_reason: str | None = None,
) -> tuple[policy.Decision, str | None]:
"""check_scoped, raising the refusal. Returns the decision and the IT reason to record (bypass only)."""
del p
decision = check_scoped(pp, action, extra=extra, it_reason=it_reason)
refuse(decision)
return decision, (reason_ok(it_reason) if decision.admin_bypass else None)
@contextmanager
def itp_write(
d: deps.Db,
p: deps.Principal,
itp_id: int,
action: policy.Action,
*,
request: Any,
extra: Mapping[str, Any] | None = None,
it_reason: str | None = None,
sign: Any = None,
allow_complete: bool = False,
sign_extra: Mapping[str, Any] | None = None,
) -> Iterator[ItpWrite]:
"""One ITP write, in one transaction. See the module docstring for the order of steps.
A wrong signature secret commits the step-up failure records and then raises, like the packet pipeline.
"""
conn = d.conn
pp = packets.policy_principal_for(conn, p)
decision, it_kept = scoped_decision(p, pp, action, extra=extra, it_reason=it_reason)
failure: ApiError | None = None
with conn.transaction():
row = load_row(conn, itp_id, lock=True)
if row is None:
raise ApiError(404, "not_found", MSG_NO_ITP)
if str(row["status"]) == "complete" and not allow_complete:
raise ApiError(409, "itp_complete", MSG_COMPLETE)
w = ItpWrite(
conn=conn,
principal=p,
policy_principal=pp,
itp=row,
action=action,
decision=decision,
request=request,
it_reason=it_kept,
version=int(row["version"]),
)
if decision.signature:
if sign is None:
raise ApiError(
400, "signature_required", "This step needs your signature.", extra={"meaning": decision.signature}
)
try:
w.signature = signatures.sign(
conn,
deps.directory_of(request),
p.actor,
factor=sign.factor,
secret=sign.secret,
action=f"itp.{action}",
meaning=decision.signature,
packet_id=None,
subject_type="itp",
subject_id=itp_id,
content={
"action": str(action),
"itp_id": itp_id,
"version": int(row["version"]),
**(sign_extra or {}),
},
ip=p.ip,
settings=deps.settings_of(request),
)
except ApiError as e:
failure = e
if failure is None:
yield w
_finish(w)
if failure is not None:
raise failure
def _finish(w: ItpWrite) -> None:
if not w.logs:
return
conn = w.conn
w.version = int(
conn.execute("UPDATE itps SET version = version + 1 WHERE id = %s RETURNING version", (w.id,)).fetchone()[0]
)
sig_id = w.signature.id if w.signature else None
name = w.principal.user["display_name"]
for n, (action, note) in enumerate(w.logs):
text = clean_opt(note, LIMITS["note"]) if note else None
sid = sig_id if n == 0 else None
conn.execute(
"INSERT INTO itp_log (itp_id, by, by_name, action, note, signature_id, it_reason)"
" VALUES (%s, %s, %s, %s, %s, %s, %s)",
(w.id, w.user_id, name, action, text, sid, w.it_reason),
)
detail: dict[str, Any] = {"text": action, "note": text, "unit_id": w.itp["unit_id"]}
if sid:
detail["signature_id"] = sid
audit.append(
conn,
action=f"itp.{w.action}",
user_id=w.user_id,
session_id=w.principal.session_id,
role_snapshot=w.principal.role_snapshot,
subject_type="itp",
subject_id=w.id,
detail=detail,
it_reason=w.it_reason,
ip=w.principal.ip,
)
events.publish(conn, {"event": "changed", "kind": "itp", "unit_id": w.itp["unit_id"], "itp_id": w.id})
def label(action: policy.Action) -> str:
return SCOPED_LABELS.get(action, str(action))