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:
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user