- C1 (issue 1): over target follows VISUAL ticket hours, not the timer. - C2 (issue 24): a Q.C. reject always needs a reason; accept needs an NCR number after a reject without one. - C3 (issue 25): an incomplete unit ITP blocks filing. - C4 (issue 27): prints open the print dialog on the page, no new tab. - C5 (issue 31): filing is blocked while VISUAL shows a work order open; the feed sets vclosed on VISUAL packets. - C6 (issue 8): the data book leaves out the history pages. - C7 (issue 18): the supervisor approves each received short; accept and filing wait for it. - C8 (issue 23): per-department checklists, edited by the department supervisor or Admin, frozen at START. - C9 (issue 32): Q.C. and Clerical may run the stamp check. - C10 (issue 33): the PM screen and reports are for PS-ProjectMgmt and Admin only. - C11 (issue 36): the Stall screen lists the packets assigned to the welder, at the supervisor's stall. Migration 0011. Docs: PLAN (also issue 9 confirmed, issue 4 asked), POLICY, SECURITY, SCHEMA, FEED, README, CHANGELOG. Co-Authored-By: Claude Opus 5.5 <[email protected]>
262 lines
10 KiB
Python
262 lines
10 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 ..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
|