2026-10-17 20:08:53 -06:00
|
|
|
|
"""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
|
2026-10-18 15:08:34 -06:00
|
|
|
|
from . import itp, packets, qcdocs, search
|
2026-10-17 20:08:53 -06:00
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
)
|
2026-10-18 15:08:34 -06:00
|
|
|
|
WAITING_SQL = "SELECT count(*) FROM packets p WHERE p.removed_at IS NULL AND p.status = 'qc'"
|
|
|
|
|
|
BOARD_LIMIT = 5000
|
2026-10-17 20:08:53 -06:00
|
|
|
|
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(
|
2026-10-18 15:08:34 -06:00
|
|
|
|
conn: Any,
|
|
|
|
|
|
pp: policy.Principal,
|
|
|
|
|
|
*,
|
|
|
|
|
|
project_id: int | None = None,
|
|
|
|
|
|
unit_id: int | None = None,
|
|
|
|
|
|
filters: Mapping[str, Any] | None = None,
|
2026-10-17 20:08:53 -06:00
|
|
|
|
) -> dict[str, Any]:
|
2026-10-18 15:08:34 -06:00
|
|
|
|
"""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)
|
2026-10-17 20:08:53 -06:00
|
|
|
|
ready = sorted(
|
2026-10-18 15:08:34 -06:00
|
|
|
|
packets.list_cards(conn, pp, {**narrow, "status": "qc", "limit": BOARD_LIMIT}),
|
2026-10-17 20:08:53 -06:00
|
|
|
|
key=lambda c: (c["status_at"], c["id"]),
|
|
|
|
|
|
)
|
|
|
|
|
|
where, params = _scope_where(project_id, unit_id)
|
2026-10-18 15:08:34 -06:00
|
|
|
|
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]
|
2026-10-17 20:08:53 -06:00
|
|
|
|
rej_ids = _ids_with_last(conn, ("review",), "rejected", where, params)
|
2026-10-18 15:08:34 -06:00
|
|
|
|
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})
|
|
|
|
|
|
}
|
2026-10-17 20:08:53 -06:00
|
|
|
|
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)
|
2026-10-18 15:08:34 -06:00
|
|
|
|
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)
|
2026-10-17 20:08:53 -06:00
|
|
|
|
return {
|
|
|
|
|
|
"kpis": {
|
2026-10-18 15:08:34 -06:00
|
|
|
|
"waiting": waiting,
|
2026-10-17 20:08:53 -06:00
|
|
|
|
"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"),
|
2026-10-18 15:08:34 -06:00
|
|
|
|
"facets": search.facets(conn, project_id=project_id, unit_id=unit_id),
|
2026-10-17 20:08:53 -06:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|