Files
PacketStation/server/packetstation/domain/qc.py
T

262 lines
10 KiB
Python
Raw Normal View History

"""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 ..notify import raise_event
from ..text import LIMITS
from . import itp, packets, qcdocs, search
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 _notify_reject(w: Any, why: str) -> None:
"""qc_reject for the packet's department, in the write's transaction (notify-send mails it)."""
pkt = w.packet
dept = str(w.from_dept or pkt.get("dept_code") or "") or None
what = str(pkt.get("title") or pkt.get("drawing") or "")
who = (w.principal.user or {}).get("display_name") or ""
raise_event(
w.conn,
"qc_reject",
subject=f"packet:{w.id}",
title=f"Q.C. rejected packet {w.id}" + (f" ({what})" if what else "") + (f", dept {dept}" if dept else ""),
body="\n".join(x for x in (f"Rejected by {who}." if who else "", why) if x),
detail={"packet_id": w.id, "dept_code": dept, "by": str(w.user_id)},
dept_code=dept,
)
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, and `ncr` for an accept that
follows a reject without one.
"""
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
ncr = str(w.extra.get("ncr") 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)
_notify_reject(w, " · ".join(x for x in parts if x))
return
if a is A.QC_ACCEPT_AND_ROUTE:
labels = getattr(w.children, "dept_labels", None) or packets.dept_labels(w.conn)
bits = (inspector_text(insp), f"from {labels.get(w.from_dept, w.from_dept)}", ncr, note)
text = " · ".join(x for x in bits 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 ncr:
parts.append(ncr)
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"
)
WAITING_SQL = "SELECT count(*) FROM packets p WHERE p.removed_at IS NULL AND p.status = 'qc'"
BOARD_LIMIT = 5000
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,
filters: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""GET /api/qc/home: the KPIs, the three boards and the active project folders. `filters` (the find bar's)
narrow the cards of the three boards; the KPIs follow the project and unit only."""
scope: dict[str, Any] = {"project_id": project_id, "unit_id": unit_id}
narrow = search.board_filters(filters or {})
narrow.update(scope)
ready = sorted(
packets.list_cards(conn, pp, {**narrow, "status": "qc", "limit": BOARD_LIMIT}),
key=lambda c: (c["status_at"], c["id"]),
)
where, params = _scope_where(project_id, unit_id)
acc_all = _ids_with_last(conn, ("complete", "filed"), "accepted", where, params)
if search.narrowing(narrow) and acc_all:
# The newest accepted packets that match the find bar (a light pass, no `can`), then the board's eight.
hits = {
c["id"]
for c in packets.list_cards(conn, pp, {**narrow, "ids": acc_all, "lite": True, "limit": BOARD_LIMIT})
}
acc_all = [i for i in acc_all if i in hits]
acc_ids = acc_all[:ACCEPTED_ON_BOARD]
rej_ids = _ids_with_last(conn, ("review",), "rejected", where, params)
by_id: dict[Any, dict[str, Any]] = {}
if acc_ids or rej_ids:
by_id = {
c["id"]: c for c in packets.list_cards(conn, pp, {**narrow, "ids": acc_ids + rej_ids, "limit": BOARD_LIMIT})
}
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)
waiting = len(ready)
if search.narrowing(filters or {}):
row = conn.execute(WAITING_SQL + where, params).fetchone() # where: fixed fragments from _scope_where
waiting = int(row[0] or 0)
return {
"kpis": {
"waiting": waiting,
"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"),
"facets": search.facets(conn, project_id=project_id, unit_id=unit_id),
}
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