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]>
211 lines
8.3 KiB
Python
211 lines
8.3 KiB
Python
"""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
|