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]>
199 lines
7.8 KiB
Python
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
|