Files
PacketStation/server/packetstation/domain/qcdocs.py
T
spencermandClaude Fable 5.1 00531cb329
ci / frontend (push) Successful in 37s
ci / server (push) Successful in 9m18s
ci / image (push) Successful in 35s
ci / e2e (push) Successful in 2m17s
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]>
2026-10-17 20:08:53 -06:00

199 lines
7.8 KiB
Python

"""Q.C. folders: the documents Q.C. files for a project or a unit (ITP, WPS / PQR, procedures, certificates).
The folders are the projects and units Clerical sets up; nothing is created on the Q.C. side. A project document
covers every unit of the project (a unit folder lists it as inherited). Nothing is ever deleted: a new revision is
a new row, and the old row points at it (superseded_by, superseded_at, superseded_by_user) and stays readable under
"Superseded". The old build's qcfolders.js.
"""
from __future__ import annotations
from collections.abc import Iterable, Mapping
from typing import Any
from ..text import LIMITS
from . import itp, packets
# The document types (the old QC_TYPES), stored as the label itself.
QC_TYPES: tuple[str, ...] = (
"ITP",
"WPS / PQR",
"NDE procedure",
"Hydrotest procedure",
"PMI / material",
"Calibration certificate",
"Customer spec",
"Other",
)
DOC_MIMES = frozenset({"application/pdf", "image/png", "image/jpeg"})
TITLE_MAX = LIMITS["qc_doc_title"]
REV_MAX = LIMITS["qc_doc_rev"]
NOTE_MAX = LIMITS["qc_doc_note"]
DOC_SQL = """
SELECT d.*, a.original_name AS file_name, a.size AS file_size
FROM qc_docs d JOIN assets a ON a.id = d.asset_id
"""
PROJECT_SQL = (
"SELECT id, number, name, customer, po, status::text AS status FROM projects WHERE id = %s AND removed_at IS NULL"
)
def doc_rows(conn: Any, where: str, params: Any = ()) -> list[dict[str, Any]]:
return packets.rows(conn, DOC_SQL + " WHERE " + where + " ORDER BY d.added_at, d.id", params)
def shape(rows: Iterable[Mapping[str, Any]], conn: Any) -> list[dict[str, Any]]:
rows = list(rows)
people = packets.People(conn, [r["added_by"] for r in rows] + [r["superseded_by_user"] for r in rows])
return [
{
"id": r["id"],
"project_id": r["project_id"],
"unit_id": r["unit_id"],
"type": r["type"],
"title": r["title"],
"rev": r["rev"],
"note": r["note"],
"asset_id": str(r["asset_id"]),
"url": f"/_blob/{r['asset_id']}",
"file_name": r["file_name"],
"size": r["file_size"],
"auto": r["auto"],
"added_at": r["added_at"],
"added_by": people.ref(r["added_by"]),
"superseded_by": r["superseded_by"],
"superseded_at": r["superseded_at"],
"superseded_by_user": people.ref(r["superseded_by_user"]),
}
for r in rows
]
def _split(rows: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
current = [r for r in rows if r["superseded_by"] is None]
old = sorted((r for r in rows if r["superseded_by"] is not None), key=lambda r: r["superseded_at"], reverse=True)
return current, old
def project_ref(conn: Any, project_id: int) -> dict[str, Any] | None:
return packets.one(conn, PROJECT_SQL, (project_id,))
def _has_itp_doc(rows: Iterable[Mapping[str, Any]]) -> bool:
return any(r["type"] == "ITP" and r["superseded_by"] is None for r in rows)
def project_folder(conn: Any, project_id: int) -> dict[str, Any] | None:
"""GET /api/qc/folders/projects/{id}, or None for an unknown project."""
project = project_ref(conn, project_id)
if project is None:
return None
current, old = _split(doc_rows(conn, "d.project_id = %s", (project_id,)))
units = packets.rows(
conn,
"SELECT id, number, lot, name, status::text AS status FROM v_units"
" WHERE project_id = %s AND removed_at IS NULL ORDER BY lot",
(project_id,),
)
unit_docs: dict[int, list[dict[str, Any]]] = {}
if units:
for r in doc_rows(conn, "d.unit_id = ANY(%s) AND d.superseded_by IS NULL", ([u["id"] for u in units],)):
unit_docs.setdefault(r["unit_id"], []).append(r)
sums = itp.summaries(conn, [u["id"] for u in units])
return {
"project": project,
"docs": shape(current, conn),
"superseded": shape(old, conn),
"units": [
{
"id": u["id"],
"number": u["number"],
"lot": u["lot"],
"name": u["name"],
"status": u["status"],
"docs": len(unit_docs.get(u["id"], [])),
"itp": sums.get(u["id"]),
"has_itp_doc": _has_itp_doc(unit_docs.get(u["id"], [])),
}
for u in units
],
}
def unit_folder(conn: Any, unit_id: int) -> dict[str, Any] | None:
"""GET /api/qc/folders/units/{id}, or None for an unknown unit."""
unit = packets.one(
conn,
"SELECT id, number, lot, name, status::text AS status, po, project_id FROM v_units"
" WHERE id = %s AND removed_at IS NULL",
(unit_id,),
)
if unit is None:
return None
project = project_ref(conn, unit["project_id"])
if project is None:
return None
current, old = _split(doc_rows(conn, "d.unit_id = %s", (unit_id,)))
inherited = doc_rows(conn, "d.project_id = %s AND d.superseded_by IS NULL", (project["id"],))
summary = itp.summaries(conn, [unit_id]).get(unit_id)
open_unit = unit["status"] != "closed" and project["status"] != "closed"
no_itp = open_unit and summary is None and not _has_itp_doc(current) and not _has_itp_doc(inherited)
return {
"unit": {k: unit[k] for k in ("id", "number", "lot", "name", "status", "po")},
"project": project,
"docs": shape(current, conn),
"inherited": shape(inherited, conn),
"superseded": shape(old, conn),
"itp": summary,
"no_itp": no_itp,
}
FOLDERS_SQL = """
SELECT p.id, p.number, p.name, p.customer, p.po, p.status::text AS status,
(SELECT count(*) FROM units u WHERE u.project_id = p.id AND u.removed_at IS NULL) AS units,
(SELECT count(*) FROM qc_docs d WHERE d.superseded_by IS NULL AND (d.project_id = p.id OR d.unit_id IN
(SELECT u.id FROM units u WHERE u.project_id = p.id AND u.removed_at IS NULL))) AS docs,
CASE WHEN p.status = 'closed' OR EXISTS (SELECT 1 FROM qc_docs d WHERE d.project_id = p.id
AND d.type = 'ITP' AND d.superseded_by IS NULL) THEN 0
ELSE (SELECT count(*) FROM units u WHERE u.project_id = p.id AND u.removed_at IS NULL
AND u.status <> 'closed'
AND NOT EXISTS (SELECT 1 FROM itps i WHERE i.unit_id = u.id)
AND NOT EXISTS (SELECT 1 FROM qc_docs d WHERE d.unit_id = u.id AND d.type = 'ITP'
AND d.superseded_by IS NULL)) END AS units_without_itp
FROM projects p
WHERE p.removed_at IS NULL
"""
def folders(conn: Any, show: str = "active") -> list[dict[str, Any]]:
"""GET /api/qc/folders: one row per live project, by number. `active` leaves out closed projects."""
sql = FOLDERS_SQL + (" AND p.status <> 'closed'" if show != "all" else "") + " ORDER BY p.number"
return [
{**r, "units": int(r["units"]), "docs": int(r["docs"]), "units_without_itp": int(r["units_without_itp"])}
for r in packets.rows(conn, sql)
]
def packet_folder_docs(conn: Any, unit_id: int, project_id: int | None) -> list[dict[str, Any]]:
"""The current Q.C. folder documents of a packet's unit and of its project, unit first."""
out: list[dict[str, Any]] = []
for scope, where, ident in (("unit", "d.unit_id = %s", unit_id), ("project", "d.project_id = %s", project_id)):
if ident is None:
continue
for r in doc_rows(conn, where + " AND d.superseded_by IS NULL", (ident,)):
out.append(
{
"id": r["id"],
"type": r["type"],
"title": r["title"],
"rev": r["rev"],
"url": f"/_blob/{r['asset_id']}",
"scope": scope,
"added_at": r["added_at"],
}
)
return out