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:
@@ -0,0 +1,210 @@
|
||||
"""Q.C.: the home board, the accept and reject log texts, and the packet document kinds.
|
||||
|
||||
The home board ports the old homeQC: packets waiting for inspection, the latest accepted, and the rejected ones back
|
||||
with their supervisor, plus the project folders. The texts port the old qcAccept and qcReject handlers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from .. import policy
|
||||
from ..text import LIMITS
|
||||
from . import itp, packets, qcdocs
|
||||
|
||||
A = policy.Action
|
||||
|
||||
# Packet document kinds (the old PDOC_TYPES): kind -> label.
|
||||
DOC_KINDS: dict[str, str] = {
|
||||
"nde": "NDE report (RT / UT / MT / PT)",
|
||||
"hydro": "Hydrotest report",
|
||||
"dimensional": "Dimensional report",
|
||||
"pmi": "PMI report",
|
||||
"coating": "Coating / paint report",
|
||||
"coc": "Certificate of conformance",
|
||||
"customer": "Customer inspection report",
|
||||
"ncr": "NCR",
|
||||
"other": "Other",
|
||||
}
|
||||
DOC_MIMES = qcdocs.DOC_MIMES
|
||||
DOC_NOTE_MAX = LIMITS["doc_note"]
|
||||
|
||||
NDE_METHODS: tuple[str, ...] = ("None required", "RT", "UT", "MT", "PT")
|
||||
NDE_NONE = "None required"
|
||||
NDE_RESULTS: tuple[str, ...] = ("", "Acceptable", "Rejectable")
|
||||
|
||||
# The accept routes (the old ROUTES). The en dash is the old text.
|
||||
ROUTE_TEXTS = {"databook": "Clerical – Data Book", "shipping": "Shipping"}
|
||||
ACCEPTED_ON_BOARD = 8
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- texts
|
||||
|
||||
|
||||
def weld_label(no: Any) -> str:
|
||||
return f"W{no}"
|
||||
|
||||
|
||||
def rejected_welds(exam: Mapping[str, Any] | None) -> list[str]:
|
||||
"""The weld ids with a reject result, sorted by number: ["W1", "W3"]."""
|
||||
nos: list[int] = []
|
||||
for k, v in (exam or {}).items():
|
||||
if isinstance(v, Mapping) and str(v.get("result") or "").lower() == "reject":
|
||||
try:
|
||||
nos.append(int(k))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return [weld_label(n) for n in sorted(nos)]
|
||||
|
||||
|
||||
def inspector_text(insp: Mapping[str, Any] | None) -> str:
|
||||
q = insp or {}
|
||||
who = str(q.get("inspector") or "").strip()
|
||||
stamp = str(q.get("stamp") or "").strip()
|
||||
return f"{who} [{stamp}]" if stamp else who
|
||||
|
||||
|
||||
def qc_texts(w: Any, a: policy.Action, to_dept: str) -> None:
|
||||
"""The packet_log label and note of a Q.C. accept or reject. Called before the side effects close the inspection.
|
||||
|
||||
`w` is the pipeline Write; its extra holds `reason_text` and `ncr` for a reject.
|
||||
"""
|
||||
insp = packets.one(w.conn, "SELECT * FROM qc_inspections WHERE packet_id = %s AND closed_at IS NULL", (w.id,))
|
||||
note = str(w.extra.get("note") or "").strip() or None
|
||||
if a is A.QC_REJECT:
|
||||
exam = (insp or {}).get("weld_exam") or {}
|
||||
parts = [str(w.extra.get("ncr") or ""), str(w.extra.get("reason_text") or "")]
|
||||
welds = rejected_welds(exam)
|
||||
if welds:
|
||||
parts.append("welds " + ", ".join(welds))
|
||||
w.log("Q.C. rejected → supervisor", " · ".join(x for x in parts if x) or None)
|
||||
return
|
||||
if a is A.QC_ACCEPT_AND_ROUTE:
|
||||
labels = getattr(w.children, "dept_labels", None) or packets.dept_labels(w.conn)
|
||||
text = " · ".join(x for x in (inspector_text(insp), f"from {labels.get(w.from_dept, w.from_dept)}", note) if x)
|
||||
w.log(f"Q.C. accepted → {labels.get(to_dept, to_dept)}", text)
|
||||
return
|
||||
if a is A.QC_ACCEPT:
|
||||
n = len(w.children.welds) if w.children is not None else 0
|
||||
parts = [inspector_text(insp), f"{n} welds VT accepted"]
|
||||
method = str((insp or {}).get("nde_method") or "").strip()
|
||||
if method and method != NDE_NONE:
|
||||
parts.append(f"{method} {(insp or {}).get('nde_result') or ''}".strip())
|
||||
if note:
|
||||
parts.append(note)
|
||||
route = ROUTE_TEXTS.get(str(w.extra.get("routed_to")), str(w.extra.get("routed_to")))
|
||||
w.log(f"Q.C. accepted → {route}", " · ".join(x for x in parts if x))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the home board
|
||||
|
||||
|
||||
def _last_results(conn: Any, packet_ids: list[int]) -> dict[int, dict[str, Any]]:
|
||||
if not packet_ids:
|
||||
return {}
|
||||
rows = packets.rows(
|
||||
conn,
|
||||
"SELECT DISTINCT ON (packet_id) * FROM qc_history WHERE packet_id = ANY(%s) ORDER BY packet_id, id DESC",
|
||||
(packet_ids,),
|
||||
)
|
||||
return {int(r["packet_id"]): r for r in rows}
|
||||
|
||||
|
||||
def _scope_where(project_id: int | None, unit_id: int | None) -> tuple[str, list[Any]]:
|
||||
"""Fixed SQL fragments over the packets alias p, and their parameters."""
|
||||
where, params = "", []
|
||||
if unit_id is not None:
|
||||
where += " AND p.unit_id = %s"
|
||||
params.append(unit_id)
|
||||
if project_id is not None:
|
||||
where += " AND p.unit_id IN (SELECT id FROM units WHERE project_id = %s)"
|
||||
params.append(project_id)
|
||||
return where, params
|
||||
|
||||
|
||||
LAST_SQL = (
|
||||
"SELECT p.id FROM packets p JOIN LATERAL (SELECT h.result, h.at, h.id FROM qc_history h"
|
||||
" WHERE h.packet_id = p.id ORDER BY h.id DESC LIMIT 1) h ON true"
|
||||
" WHERE p.removed_at IS NULL AND p.status::text = ANY(%s) AND h.result = %s"
|
||||
)
|
||||
COUNT_SQL = (
|
||||
"SELECT count(DISTINCT l.packet_id) FROM packet_log l JOIN packets p ON p.id = l.packet_id"
|
||||
" WHERE l.at > now() - interval '7 days' AND l.action LIKE %s AND p.removed_at IS NULL"
|
||||
)
|
||||
|
||||
|
||||
def _ids_with_last(conn: Any, statuses: tuple[str, ...], result: str, where: str, params: list[Any]) -> list[int]:
|
||||
"""Packets in the statuses whose newest qc_history result is `result`, newest result first."""
|
||||
sql = LAST_SQL + where + " ORDER BY h.id DESC" # where: fixed fragments from _scope_where
|
||||
rows = conn.execute(sql, (list(statuses), result, *params)).fetchall()
|
||||
return [int(r[0]) for r in rows]
|
||||
|
||||
|
||||
def _count_7d(conn: Any, prefix: str, where: str, params: list[Any]) -> int:
|
||||
row = conn.execute(COUNT_SQL + where, (prefix + "%", *params)).fetchone()
|
||||
return int(row[0] or 0)
|
||||
|
||||
|
||||
def home(
|
||||
conn: Any, pp: policy.Principal, *, project_id: int | None = None, unit_id: int | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""GET /api/qc/home: the KPIs, the three boards and the active project folders."""
|
||||
filters: dict[str, Any] = {"project_id": project_id, "unit_id": unit_id}
|
||||
ready = sorted(
|
||||
packets.list_cards(conn, pp, {**filters, "status": "qc"}),
|
||||
key=lambda c: (c["status_at"], c["id"]),
|
||||
)
|
||||
where, params = _scope_where(project_id, unit_id)
|
||||
acc_ids = _ids_with_last(conn, ("complete", "filed"), "accepted", where, params)[:ACCEPTED_ON_BOARD]
|
||||
rej_ids = _ids_with_last(conn, ("review",), "rejected", where, params)
|
||||
by_id = {c["id"]: c for c in packets.list_cards(conn, pp, {"ids": acc_ids + rej_ids})} if acc_ids or rej_ids else {}
|
||||
accepted = [by_id[i] for i in acc_ids if i in by_id]
|
||||
rejected = [by_id[i] for i in rej_ids if i in by_id]
|
||||
_add_qc(conn, ready + accepted + rejected)
|
||||
return {
|
||||
"kpis": {
|
||||
"waiting": len(ready),
|
||||
"accepted_7d": _count_7d(conn, "Q.C. accepted", where, params),
|
||||
"rejected_7d": _count_7d(conn, "Q.C. rejected", where, params),
|
||||
},
|
||||
"columns": {"ready": ready, "accepted": accepted, "rejected": rejected},
|
||||
"folders": qcdocs.folders(conn, "active"),
|
||||
}
|
||||
|
||||
|
||||
def _add_qc(conn: Any, cards: list[dict[str, Any]]) -> None:
|
||||
"""Each card gains `qc` (the open inspection's location and sent time, or the last result) and itp_hold_count."""
|
||||
if not cards:
|
||||
return
|
||||
ids = [int(c["id"]) for c in cards]
|
||||
open_rows = {
|
||||
int(r["packet_id"]): r
|
||||
for r in packets.rows(
|
||||
conn,
|
||||
"SELECT packet_id, location, sent_at, inspector, result FROM qc_inspections"
|
||||
" WHERE packet_id = ANY(%s) AND closed_at IS NULL",
|
||||
(ids,),
|
||||
)
|
||||
}
|
||||
last = _last_results(conn, ids)
|
||||
units = itp.unit_itps(conn, [int(c["unit"]["id"]) for c in cards])
|
||||
for c in cards:
|
||||
pid = int(c["id"])
|
||||
o = open_rows.get(pid)
|
||||
if o is not None:
|
||||
c["qc"] = {k: o[k] for k in ("location", "sent_at", "inspector", "result")}
|
||||
elif pid in last:
|
||||
h = last[pid]
|
||||
snap = h.get("snapshot") or {}
|
||||
c["qc"] = {
|
||||
"location": snap.get("location"),
|
||||
"sent_at": snap.get("sent_at"),
|
||||
"inspector": h.get("inspector"),
|
||||
"result": h.get("result"),
|
||||
}
|
||||
else:
|
||||
c["qc"] = None
|
||||
u = units.get(int(c["unit"]["id"]))
|
||||
hold = itp.holds_for_packet(u[0], u[1], c["dept"]["code"]) if u else None
|
||||
c["itp_hold_count"] = hold["count"] if hold else 0
|
||||
Reference in New Issue
Block a user