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
+195 -27
View File
@@ -21,6 +21,7 @@ from pydantic import BaseModel, Field
from .. import policy
from ..domain import checklists, packets
from ..domain import qc as qc_dom
from ..domain.children import NEVER_VERIFY, active_depts, co_touched, current_wh, update_row, wh_move
from ..domain.pipeline import (
SignBody,
@@ -260,16 +261,29 @@ class WeldExamIn(BaseModel):
class QcBody(It):
"""`{set, base}` for field-level compare-and-set of the scalar fields, or the phase 2 flat body with If-Match on
the inspection's row_version (checks and weld_exam merge per key)."""
checks: dict[str, bool] | None = Field(default=None, max_length=50)
weld_exam: dict[str, WeldExamIn] | None = Field(default=None, max_length=2000)
nde_method: str | None = Field(default=None, max_length=500)
nde_result: str | None = Field(default=None, max_length=500)
nde_report: str | None = Field(default=None, max_length=500)
hardness: str | None = Field(default=None, max_length=500)
nace: bool | None = None
inspector: str | None = Field(default=None, max_length=500)
stamp: str | None = Field(default=None, max_length=40)
notes: str | None = Field(default=None, max_length=5000)
set: dict[str, str | None] | None = Field(default=None, max_length=10)
base: dict[str, str | None] | None = Field(default=None, max_length=10)
class QcCheckBody(It):
done: bool
class QcWeldBody(It):
result: Literal["accept", "reject", ""] | None = None
note: str | None = Field(default=None, max_length=2000)
class CoItemIn(BaseModel):
@@ -1457,6 +1471,143 @@ def set_wh_stage(
# ---------------------------------------------------------------- Q.C.
QC_FIELDS = ("inspector", "stamp", "nde_method", "nde_result", "nde_report", "hardness", "notes")
QC_NOTE_NAMES = {"inspector": "inspector", "stamp": "stamp", "hardness": "hardness", "nde_report": "report"}
def _open_inspection(conn: Any, packet_id: int) -> dict[str, Any]:
insp = packets.one(conn, "SELECT * FROM qc_inspections WHERE packet_id = %s AND closed_at IS NULL", (packet_id,))
if insp is None:
raise ApiError(409, "no_inspection", MSG_NO_INSPECTION)
return insp
def _qc_value(key: str, value: Any) -> Any:
"""One scalar Q.C. field, cleaned and checked."""
if key == "notes":
return clean_opt(value, LIMITS["notes"])
if key == "stamp":
return _stamp(value)
text = clean_opt(value, QC_FIELD_MAX)
if key == "nde_method" and text is not None and text not in qc_dom.NDE_METHODS:
raise ApiError(422, "invalid_field", "Pick an NDE method from the list.")
if key == "nde_result" and text is not None and text not in qc_dom.NDE_RESULTS:
raise ApiError(422, "invalid_field", "Pick an NDE result from the list.")
return text
def _qc_logs(w: Write, changes: dict[str, Any]) -> None:
"""The log lines of a Q.C. field change: "NDE method: RT", "NDE result: Acceptable", then "Q.C. record changed"
with the other fields. A method set to None required clears the result and report without a line of their own."""
none = changes.get("nde_method") == qc_dom.NDE_NONE
lines: list[tuple[str, str | None]] = []
if "nde_method" in changes:
lines.append((f"NDE method: {changes['nde_method'] or '-'}", None))
if "nde_result" in changes and not (none and not changes["nde_result"]):
lines.append((f"NDE result: {changes['nde_result'] or '-'}", None))
parts: list[str] = []
for k in ("inspector", "stamp", "hardness", "nde_report", "notes"):
if k not in changes or (k == "nde_report" and none and not changes[k]):
continue
v = changes[k]
if k == "notes":
parts.append("notes changed" if v else "notes cleared")
else:
parts.append(f"{QC_NOTE_NAMES[k]} {v}" if v else f"{QC_NOTE_NAMES[k]} cleared")
if parts:
lines.append(("Q.C. record changed", " · ".join(parts)))
if not lines:
lines.append(("Q.C. record changed", None))
w.log(*lines[0])
for more in lines[1:]:
w.log_also(*more)
@router.put("/{packet_id}/qc/checks/{key}", dependencies=WRITE)
def put_qc_check(
packet_id: int,
key: str,
body: QcCheckBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Tick or untick one Q.C. check. Per-key writes never conflict, so there is no If-Match."""
pp = _pp(d, p)
conn = d.conn
with packet_write(d, p, packet_id, A.EDIT_QC, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
items = w.children.qc_items if w.children is not None and w.children.qc_items else None
if key not in checklists.qc_keys(items):
raise ApiError(422, "invalid_key", "That is not a Q.C. check item.")
insp = _open_inspection(conn, packet_id)
if checklists.is_done((insp["checks"] or {}).get(key)) == body.done:
w.skip()
return _read(d, pp, packet_id)
if body.done:
conn.execute(
"UPDATE qc_inspections SET checks = jsonb_set(checks, ARRAY[%s]::text[],"
" jsonb_build_object('done', true, 'at', now(), 'by', %s::text, 'stamp', %s::text)),"
" row_version = row_version + 1 WHERE id = %s",
(key, str(w.user_id), insp["stamp"], insp["id"]),
)
else:
conn.execute(
"UPDATE qc_inspections SET checks = jsonb_set(checks, ARRAY[%s]::text[], %s),"
" row_version = row_version + 1 WHERE id = %s",
(key, Jsonb({"done": False}), insp["id"]),
)
pk = w.packet
texts = checklists.qc_items_for(pk["drawing"], pk["rev"], pk["wps"], pk["welder_stamp"], items)
text = next((i["text"] for i in texts if i["key"] == key), key)
w.changed(f"qc.checks.{key}")
w.log(("Q.C. checked: " if body.done else "Q.C. unchecked: ") + text)
return _read(d, pp, packet_id)
@router.put("/{packet_id}/qc/welds/{no}", dependencies=WRITE)
def put_qc_weld(
packet_id: int,
no: int,
body: QcWeldBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""The visual exam of one weld: accept, reject or cleared (empty), and a note."""
given = body.model_fields_set
pp = _pp(d, p)
conn = d.conn
with packet_write(d, p, packet_id, A.EDIT_QC, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
live = conn.execute(
"SELECT 1 FROM welds WHERE packet_id = %s AND weld_no = %s AND removed_at IS NULL", (packet_id, no)
).fetchone()
if live is None:
raise ApiError(422, "invalid_weld", "No such weld on this packet.")
insp = _open_inspection(conn, packet_id)
k = str(no)
cur = dict((insp["weld_exam"] or {}).get(k) or {})
result = (body.result or "") if "result" in given else str(cur.get("result") or "")
note = clean_text(body.note, LIMITS["note"]) if "note" in given else str(cur.get("note") or "")
result_changed = result != str(cur.get("result") or "")
note_changed = note != str(cur.get("note") or "")
if not result_changed and not note_changed:
w.skip()
return _read(d, pp, packet_id)
conn.execute(
"UPDATE qc_inspections SET weld_exam = jsonb_set(weld_exam, ARRAY[%s]::text[],"
" %s::jsonb || jsonb_build_object('at', now(), 'by', %s::text)), row_version = row_version + 1"
" WHERE id = %s",
(k, Jsonb({"result": result, "note": note}), str(w.user_id), insp["id"]),
)
label = qc_dom.weld_label(no)
w.changed(f"qc.weld_exam.{k}")
if result_changed:
w.log(f"VT {result} · {label}" if result else f"VT cleared · {label}", note if result and note else None)
else:
w.log("Q.C. record changed", f"{label} note")
return _read(d, pp, packet_id)
@router.patch("/{packet_id}/qc", dependencies=WRITE)
def patch_qc(
packet_id: int,
@@ -1465,36 +1616,50 @@ def patch_qc(
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""The open inspection. checks and weld_exam merge per key; If-Match is the inspection's row_version."""
if_match = parse_if_match(request)
given = body.model_fields_set
if body.checks:
bad = sorted(set(body.checks) - set(checklists.qc_keys()))
if bad:
raise ApiError(422, "invalid_key", f"Not a Q.C. item: {', '.join(bad)}.")
"""The open inspection's fields.
`{set, base}`: inspector, stamp, nde_method, nde_result, nde_report, hardness and notes with field-level
compare-and-set (409 field_conflict with `current`). The phase 2 flat body with If-Match (the inspection's
row_version) still works and merges checks and weld_exam per key. Setting the NDE method to None required clears
the result and the report. The NACE flag is not written: the read model computes it from the WPS.
"""
cas = body.set is not None
values: dict[str, Any] = {}
for k in ("nde_method", "nde_result", "nde_report", "hardness", "inspector"):
if k in given:
values[k] = clean_opt(getattr(body, k), QC_FIELD_MAX)
if "notes" in given:
values["notes"] = clean_opt(body.notes, LIMITS["notes"])
if "stamp" in given:
values["stamp"] = _stamp(body.stamp)
if "nace" in given:
values["nace"] = body.nace
base: dict[str, Any] = {}
if_match: int | None = None
if cas:
assert body.set is not None
bad = sorted(k for k in body.set if k not in QC_FIELDS)
if bad:
raise ApiError(422, "invalid_field", f"Not a Q.C. field: {', '.join(bad)}.")
values = {k: _qc_value(k, v) for k, v in body.set.items()}
for k, v in (body.base or {}).items():
if k in QC_FIELDS:
base[k] = clean_opt(v, LIMITS["notes"] if k == "notes" else QC_FIELD_MAX)
else:
given = body.model_fields_set
if body.checks:
bad = sorted(set(body.checks) - set(checklists.qc_keys(checklists.load_qc_items(d.conn))))
if bad:
raise ApiError(422, "invalid_key", f"Not a Q.C. item: {', '.join(bad)}.")
values = {k: _qc_value(k, getattr(body, k)) for k in QC_FIELDS if k in given}
if_match = parse_if_match(request)
if values.get("nde_method") == qc_dom.NDE_NONE:
values["nde_result"] = None
values["nde_report"] = None
pp = _pp(d, p)
conn = d.conn
with packet_write(d, p, packet_id, A.EDIT_QC, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
insp = packets.one(
conn, "SELECT * FROM qc_inspections WHERE packet_id = %s AND closed_at IS NULL", (packet_id,)
)
if insp is None:
raise ApiError(409, "no_inspection", MSG_NO_INSPECTION)
check_row_version(insp, if_match)
changes = _diff(insp, values)
insp = _open_inspection(conn, packet_id)
if cas:
changes = apply_field_cas({k: insp[k] for k in QC_FIELDS}, values, base)
else:
check_row_version(insp, if_match)
changes = _diff(insp, values)
scalar = dict(changes)
at = conn.execute("SELECT now()").fetchone()[0].isoformat()
by = str(w.user_id)
if body.checks:
if not cas and body.checks:
stamp = values.get("stamp", insp["stamp"])
checks = dict(insp["checks"] or {})
for k, done in body.checks.items():
@@ -1503,7 +1668,7 @@ def patch_qc(
checks[k] = {"done": True, "at": at, "by": by, "stamp": stamp} if done else {"done": False}
if checks != (insp["checks"] or {}):
changes["checks"] = Jsonb(checks)
if body.weld_exam:
if not cas and body.weld_exam:
live = {
str(r[0])
for r in conn.execute(
@@ -1531,7 +1696,10 @@ def patch_qc(
return _read(d, pp, packet_id)
update_row(conn, "qc_inspections", insp["id"], changes)
w.changed(*[f"qc.{k}" for k in changes])
w.log("Q.C. record changed")
if scalar:
_qc_logs(w, scalar)
else:
w.log("Q.C. record changed")
return _read(d, pp, packet_id)