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
+50 -15
View File
@@ -17,7 +17,9 @@ from pydantic import BaseModel, Field
from .. import audit, events, policy
from ..domain import assets as asset_store
from ..domain import ncr as ncrs
from ..domain import packets, shop
from ..domain import qc as qc_dom
from ..domain.pipeline import (
Expect,
SignBody,
@@ -110,6 +112,7 @@ class TransitionBody(BaseModel):
spool: bool | None = None
location: str | None = Field(default=None, max_length=500)
for_sup_id: uuid.UUID | None = None
ncr: str | None = Field(default=None, max_length=100)
sign: SignBody | None = None
it_reason: str | None = Field(default=None, max_length=1000)
@@ -137,6 +140,7 @@ class DocBody(BaseModel):
asset_id: uuid.UUID
kind: str = Field(min_length=1, max_length=100)
title: str | None = Field(default=None, max_length=500)
note: str | None = Field(default=None, max_length=2000)
it_reason: str | None = Field(default=None, max_length=1000)
@@ -494,7 +498,7 @@ TRANSITION_FIELDS: dict[policy.Action, tuple[str, ...]] = {
A.RETURN_TO_STATION: ("reason",),
A.SEND_TO_QC: ("book", "spool", "location", "note"),
A.ROUTE_FROM_REVIEW: ("dept", "note"),
A.QC_REJECT: ("reason",),
A.QC_REJECT: ("reason", "ncr"),
A.QC_ACCEPT: ("routed_to", "note"),
A.QC_ACCEPT_AND_ROUTE: ("dept", "note"),
A.FILE: ("note",),
@@ -534,6 +538,8 @@ def transition(
extra["dept"] = shop_dept(conn, extra["dept"])
if "for_sup_id" in extra:
extra["for_sup_id"] = active_user(conn, extra["for_sup_id"], "supervisor")
if action is A.QC_REJECT:
_reject_reason(extra)
if "stamp" in extra:
# The assign sheet's stamp overrides the welder's own for this assignment; empty means use theirs.
stamp = clean_text(extra["stamp"], 40)
@@ -570,6 +576,21 @@ def transition(
return _read(d, pp, packet_id)
def _reject_reason(extra: dict[str, Any]) -> None:
"""A Q.C. reject takes a reason, an NCR number, or both. The NCR stands in for the reason text: the reason the
policy checks is "NCR-2026-0021" alone, or "NCR-2026-0021 · <reason>". Without an NCR the reason needs its usual
8 characters."""
text = clean_text(extra.pop("reason", None), LIMITS["reason"])
raw = clean_text(extra.pop("ncr", None), 100)
ncr = ncrs.validate_ncr(raw) if raw else None
if ncr:
extra["ncr"] = ncr
extra["reason"] = f"{ncr} · {text}" if text else ncr
elif text:
extra["reason"] = text
extra["reason_text"] = text
@router.post("/{packet_id}/reopen", dependencies=WRITE)
def reopen(
packet_id: int,
@@ -750,27 +771,35 @@ def add_doc(
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""A report for the data book (NDE, hydrotest, certificates, NCRs). Never a drawing: drawings come from M-Files."""
conn = d.conn
kind = clean_text(body.kind, 40)
if kind not in qc_dom.DOC_KINDS:
raise ApiError(422, "invalid_kind", "Pick a document type from the list.")
asset = asset_store.get(conn, body.asset_id)
if asset is None:
raise ApiError(404, "not_found", "No such file.")
kind = clean_text(body.kind, 40)
if not kind:
raise ApiError(422, "invalid_request", "Say what kind of document this is.")
title = clean_opt(body.title, LIMITS["doc_title"])
if asset["mime"] not in qc_dom.DOC_MIMES:
raise ApiError(422, "invalid_asset", "The file must be a PDF, PNG or JPEG.")
label = qc_dom.DOC_KINDS[kind]
title = clean_opt(body.title, LIMITS["doc_title"]) or label
note = clean_opt(body.note, qc_dom.DOC_NOTE_MAX)
pp = _pp(d, p)
with packet_write(d, p, packet_id, A.DOC_ADD, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
doc_id = conn.execute(
"INSERT INTO packet_docs (packet_id, asset_id, kind, title, added_by) VALUES (%s, %s, %s, %s, %s)"
" RETURNING id",
(packet_id, asset["id"], kind, title, p.user_id),
"INSERT INTO packet_docs (packet_id, asset_id, kind, title, note, added_by)"
" VALUES (%s, %s, %s, %s, %s, %s) RETURNING id",
(packet_id, asset["id"], kind, title, note, p.user_id),
).fetchone()[0]
w.changed("docs")
w.note = title or kind
w.log("Document added", " · ".join(x for x in (label, title, asset.get("original_name")) if x))
w.detail.update({"doc_id": int(doc_id), "asset_id": str(asset["id"]), "kind": kind})
return _read(d, pp, packet_id)
DOC_REASON_MIN = 3
@router.delete("/{packet_id}/docs/{doc_id}", dependencies=WRITE)
def remove_doc(
packet_id: int,
@@ -780,23 +809,29 @@ def remove_doc(
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Take a document out of the data book. The row and the file stay, with who, when and why."""
body = body or ReasonBody()
conn = d.conn
pp = _pp(d, p)
with packet_write(
d, p, packet_id, A.DOC_REMOVE, request=request, it_reason=body.it_reason, policy_principal=pp
) as w:
reason = clean_opt(body.reason, LIMITS["reason"])
reason = clean_text(body.reason, LIMITS["reason"])
doc = packets.one(
conn,
"UPDATE packet_docs SET removed_at = now(), removed_by = %s, removed_reason = %s"
" WHERE id = %s AND packet_id = %s AND removed_at IS NULL RETURNING title, kind",
(p.user_id, reason, doc_id, packet_id),
conn, "SELECT * FROM packet_docs WHERE id = %s AND packet_id = %s FOR UPDATE", (doc_id, packet_id)
)
if doc is None:
raise ApiError(404, "not_found", "No such document.")
if doc["removed_at"] is not None:
raise ApiError(409, "already_removed", "This document is already out of the data book.")
if len(reason) < DOC_REASON_MIN:
raise ApiError(400, "reason_required", "Say why it comes out of the data book.")
conn.execute(
"UPDATE packet_docs SET removed_at = now(), removed_by = %s, removed_reason = %s WHERE id = %s",
(p.user_id, reason, doc_id),
)
w.changed("docs")
w.note = ", ".join(x for x in (doc["title"] or doc["kind"], reason) if x)
w.log("Document taken out of the data book", " · ".join(x for x in (doc["title"] or doc["kind"], reason) if x))
w.detail["doc_id"] = doc_id
return _read(d, pp, packet_id)