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