Phase 6: Q.C. inspection, project folders, packet documents and the digital ITP
ci / frontend (push) Successful in 37s
ci / server (push) Successful in 9m18s
ci / image (push) Successful in 35s
ci / e2e (push) Successful in 2m17s

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]>
This commit is contained in:
2026-10-17 20:08:53 -06:00
co-authored by Claude Fable 5.1
parent 8bb1f7ae4d
commit 00531cb329
64 changed files with 11715 additions and 260 deletions
+74 -22
View File
@@ -1,7 +1,8 @@
"""The department checklists and the Q.C. checks, ported from the old checklistFor(), qcChecksFor() and qcMissing().
Weld departments get the welding list, the others the plain list. The drawing, rev and WPS are filled into the text.
The lists live in the settings row `checklist_templates` (migration 0006); the defaults here are the fallback.
The lists live in the settings rows `checklist_templates` (migration 0006) and `qc_check_items` (migration 0007); the
defaults here are the fallback.
The keys are the old keys, so a checklist stored as {key: {done, at, by, stamp}} reads the same.
"""
@@ -115,32 +116,79 @@ def checklist_for(
return [_item(i["group"], i["key"], _fill(i["text"], drawing, rev, wps)) for i in items]
QC_SETTINGS_KEY = "qc_check_items"
RX_QC_PLACEHOLDER = re.compile(r"\{(drawing|rev|wps|stamp)\}")
G_BOOK, G_SPOOL = "Data book package", "Physical spool"
# The Q.C. check items, with {wps}, {stamp}, {drawing} and {rev} placeholders. The settings row `qc_check_items`
# (migration 0007) holds the same list; this is the fallback when that row is missing or does not parse.
QC_DEFAULT_ITEMS: list[dict[str, str]] = [
_item(G_BOOK, "db_heats", "Heat number recorded for every BOM item"),
_item(G_BOOK, "db_mtr", "MTRs on file and match the heat numbers"),
_item(G_BOOK, "db_weldmap", "Weld map complete: every weld identified"),
_item(G_BOOK, "db_wps", "Welds made to {wps}; welder {stamp}qualified"),
_item(G_BOOK, "db_ck", "Stall checklist and weld record complete"),
_item(G_SPOOL, "ph_dim", "Dimensions verified against {drawing} Rev {rev}"),
_item(G_SPOOL, "ph_heat", "Heat numbers on material match the heat map"),
_item(G_SPOOL, "ph_stamp", "Welds stamped with welder ID"),
_item(G_SPOOL, "ph_clean", "Clean: no slag, spatter or arc strikes"),
_item(G_SPOOL, "ph_qty", "Quantity matches the work orders"),
]
def parse_qc_items(value: Any) -> list[dict[str, str]] | None:
"""The `qc_check_items` settings value ({"groups": [{"group", "items": [{"key", "text"}]}]}) as a flat list of
{group, key, text}. None when it is malformed."""
if not isinstance(value, Mapping) or not isinstance(value.get("groups"), list):
return None
flat: list[dict[str, Any]] = []
for g in value["groups"]:
if not isinstance(g, Mapping) or not isinstance(g.get("items"), list):
return None
flat += [{**i, "group": g.get("group")} if isinstance(i, Mapping) else i for i in g["items"]]
return _valid_list(flat)
def load_qc_items(conn: Any) -> list[dict[str, str]]:
"""The Q.C. check items from settings, else the defaults in code (with a warning when the row is bad)."""
row = conn.execute("SELECT value FROM settings WHERE key = %s", (QC_SETTINGS_KEY,)).fetchone()
if row is None:
return QC_DEFAULT_ITEMS
parsed = parse_qc_items(row[0])
if parsed is None:
log.warning("settings %s does not parse; using the default Q.C. items", QC_SETTINGS_KEY)
return QC_DEFAULT_ITEMS
return parsed
def qc_fill(text: str, drawing: str | None, rev: str | None, wps: str | None, welder_stamp: str | None) -> str:
values = {
"drawing": drawing or "the drawing",
"rev": rev or NO_REV,
"wps": wps or "the WPS",
"stamp": f"[{welder_stamp}] " if welder_stamp else "",
}
return " ".join(RX_QC_PLACEHOLDER.sub(lambda m: values[m.group(1)], text).split())
def qc_items_for(
drawing: str | None, rev: str | None, wps: str | None, welder_stamp: str | None
drawing: str | None,
rev: str | None,
wps: str | None,
welder_stamp: str | None,
items: Iterable[Mapping[str, str]] | None = None,
) -> list[dict[str, str]]:
"""The Q.C. inspection items, as a flat list of {group, key, text}."""
g1, g2 = "Data book package", "Physical spool"
stamp = f"[{welder_stamp}] " if welder_stamp else ""
return [
_item(g1, "db_heats", "Heat number recorded for every BOM item"),
_item(g1, "db_mtr", "MTRs on file and match the heat numbers"),
_item(g1, "db_weldmap", "Weld map complete: every weld identified"),
_item(g1, "db_wps", f"Welds made to {wps or 'the WPS'}; welder {stamp}qualified"),
_item(g1, "db_ck", "Stall checklist and weld record complete"),
_item(g2, "ph_dim", f"Dimensions verified against {drawing or 'the drawing'} Rev {rev or NO_REV}"),
_item(g2, "ph_heat", "Heat numbers on material match the heat map"),
_item(g2, "ph_stamp", "Welds stamped with welder ID"),
_item(g2, "ph_clean", "Clean: no slag, spatter or arc strikes"),
_item(g2, "ph_qty", "Quantity matches the work orders"),
]
"""The Q.C. inspection items, as a flat list of {group, key, text} with the packet's values filled in."""
src = list(items) if items is not None else QC_DEFAULT_ITEMS
return [_item(i["group"], i["key"], qc_fill(i["text"], drawing, rev, wps, welder_stamp)) for i in src]
def checklist_keys(weld: bool, templates: Mapping[str, list[dict[str, str]]] | None = None) -> list[str]:
return [i["key"] for i in checklist_for(weld, None, None, None, templates)]
def qc_keys() -> list[str]:
return [i["key"] for i in qc_items_for(None, None, None, None)]
def qc_keys(items: Iterable[Mapping[str, str]] | None = None) -> list[str]:
return [i["key"] for i in (items if items is not None else QC_DEFAULT_ITEMS)]
def is_done(value: Any) -> bool:
@@ -163,19 +211,23 @@ def _plural(n: int, word: str) -> str:
def qc_missing(
packet: Mapping[str, Any], qc: Mapping[str, Any] | None, welds: Iterable[Mapping[str, Any]]
packet: Mapping[str, Any],
qc: Mapping[str, Any] | None,
welds: Iterable[Mapping[str, Any]],
items: Iterable[Mapping[str, str]] | None = None,
) -> list[str]:
"""What Q.C. still has to record before an accept. The old qcMissing().
`packet` is unused today (the item list does not depend on it for the keys) but kept for the old signature.
`qc` is the open inspection row (checks, weld_exam, nde_method, nde_result, inspector) or None.
`welds` are the live weld rows (weld_no).
`welds` are the live weld rows (weld_no). `items` are the loaded Q.C. items (load_qc_items); None means the
defaults.
"""
del packet
q = qc or {}
checks = q.get("checks") or {}
missing: list[str] = []
open_items = sum(1 for k in qc_keys() if not is_done(checks.get(k)))
open_items = sum(1 for k in qc_keys(items) if not is_done(checks.get(k)))
if open_items:
missing.append(_plural(open_items, "inspection item"))
exam = q.get("weld_exam") or {}
+597
View File
@@ -0,0 +1,597 @@
"""The digital ITP (Inspection and Test Plan), one per unit.
Starting an ITP freezes a copy of the Q.C. template (domain/itp_template.py) into itps.template, so a later template
change never alters an ITP in use. Q.C. initials each line (approve), marks lines N/A, rejects a line with an NCR
number (now or later), records the customer's code and witness, signs each block of sections with a signature, and
signs the ITP off with a signature. Open hold (H) and witness (W) points, and rejected lines, show as a warning on the
unit's packets for the department's pages. Nothing is blocked by them.
The pure functions here (items, is_done, is_hold_code, open_holds, blocks, block_ready, progress, dept_pages,
holds_for_packet) port the old itp.js helpers. They take the frozen template and the lines as a mapping of line key to
a row with `result` ("approve", "reject", "na" or None), `cust_code` and `ncr`.
Writes go through `itp_write`: check_scoped with the action, then the ITP row locked FOR UPDATE, the complete lock,
the signature when the rule needs one, then the version bump, the itp_log rows, the audit rows and the NOTIFY. ITP
writes never look at a packet's status: a filed packet in the unit does not lock the unit's ITP.
"""
from __future__ import annotations
import re
from collections.abc import Iterable, Iterator, Mapping, Sequence
from contextlib import contextmanager
from dataclasses import dataclass, field
from datetime import date, datetime
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from .. import audit, events, policy
from ..api import deps
from ..api.errors import ApiError
from ..auth import signatures
from ..auth.signatures import Signature
from ..policy.actions import SCOPED_LABELS
from ..policy.engine import check_scoped
from ..text import LIMITS, clean_opt, reason_ok
from . import packets
A = policy.Action
PAGE_NAMES: dict[str, str] = {"WP": "Weld Parts", "C": "Code", "FA": "Final Assembly"}
PAGE_ORDER: tuple[str, ...] = ("WP", "C", "FA")
# The pages each shop department works to (the old ITP_DEPT_PAGES). A support department or an unknown code sees all.
DEPT_PAGES: dict[str, tuple[str, ...]] = {
"01": ("WP",),
"02": ("WP",),
"03": ("WP",),
"04": ("WP",),
"05": ("WP",),
"10": ("WP",),
"06": ("C",),
"07": ("C",),
"13": ("C",),
"09": ("WP", "C"),
"08": ("FA",),
"11": ("FA",),
"12": ("FA",),
"14": ("FA",),
}
CUST_CODES: tuple[str, ...] = ("R", "R/AU", "AU", "W", "H", "NA")
RX_HOLD = re.compile(r"(^|/)(H|W)$")
LOG_MAX = 600
MSG_COMPLETE = "The ITP is signed off and locked. An admin can reopen it."
MSG_NO_ITP = "No such ITP."
# ---------------------------------------------------------------- pure functions over the template and lines
def items(template: Mapping[str, Any]) -> list[dict[str, Any]]:
"""Every line of the template in order, with its section number (`sec`) and page."""
out: list[dict[str, Any]] = []
for s in template.get("sections") or []:
for i in s.get("items") or []:
out.append({**i, "sec": s.get("n"), "page": s.get("page")})
return out
def _line(lines: Mapping[str, Any], key: str) -> Mapping[str, Any]:
v = lines.get(key)
return v if isinstance(v, Mapping) else {}
def is_done(line: Mapping[str, Any] | None) -> bool:
"""A line is done when it is N/A or approved."""
return bool(line) and (line or {}).get("result") in ("approve", "na")
def is_rejected(line: Mapping[str, Any] | None) -> bool:
"""An open reject: the line is rejected and not approved since."""
return bool(line) and (line or {}).get("result") == "reject"
def is_hold_code(code: Any) -> bool:
"""H or W, alone or after a slash (R/H). The old isHoldCode."""
return bool(RX_HOLD.search(str(code or "")))
def cust_code(item: Mapping[str, Any], line: Mapping[str, Any] | None) -> str:
"""The customer code of a line: its own when set, else the template's."""
own = (line or {}).get("cust_code")
return str(own) if own else str(item.get("cust") or "")
def hold_codes(item: Mapping[str, Any], line: Mapping[str, Any] | None) -> list[str]:
return [c for c in (str(item.get("code") or ""), cust_code(item, line)) if is_hold_code(c)]
def open_holds(
template: Mapping[str, Any], lines: Mapping[str, Any], pages: Sequence[str] | None = None
) -> list[dict[str, Any]]:
"""The hold, witness or rejected points not done yet, on the given pages (None: every page)."""
out: list[dict[str, Any]] = []
for i in items(template):
if pages is not None and i["page"] not in pages:
continue
line = _line(lines, str(i["n"]))
codes = hold_codes(i, line)
rej = is_rejected(line)
if (codes or rej) and not is_done(line):
out.append(
{
"n": i["n"],
"t": i["t"],
"page": i["page"],
"codes": codes,
"rejected": rej,
"ncr": line.get("ncr") if rej else None,
}
)
return out
def blocks(template: Mapping[str, Any]) -> list[dict[str, Any]]:
"""The signature blocks: one closes at every section with `sig` and covers the sections since the last block."""
out: list[dict[str, Any]] = []
acc: list[Mapping[str, Any]] = []
for s in template.get("sections") or []:
acc.append(s)
if s.get("sig"):
out.append({"key": str(s["n"]), "sections": acc, "final": bool(s.get("final"))})
acc = []
if acc:
out.append({"key": str(acc[-1]["n"]), "sections": acc, "final": True})
return out
def block_scope(block: Mapping[str, Any]) -> str:
secs = block["sections"]
if len(secs) > 1:
return f"Sections {secs[0]['n']}–{secs[-1]['n']}"
return f"Section {block['key']}"
def block_of_line(template: Mapping[str, Any], key: str) -> dict[str, Any] | None:
for b in blocks(template):
if any(str(i.get("n")) == key for s in b["sections"] for i in s.get("items") or []):
return b
return None
def _block_lines(block: Mapping[str, Any]) -> list[str]:
return [str(i.get("n")) for s in block["sections"] for i in s.get("items") or []]
def block_ready(block: Mapping[str, Any], lines: Mapping[str, Any]) -> bool:
"""Every line of the block is done (approved or N/A)."""
return all(is_done(_line(lines, k)) for k in _block_lines(block))
def block_all_na(block: Mapping[str, Any], lines: Mapping[str, Any]) -> bool:
return all(_line(lines, k).get("result") == "na" for k in _block_lines(block))
def progress(template: Mapping[str, Any], lines: Mapping[str, Any]) -> dict[str, int]:
it = items(template)
return {
"done": sum(1 for i in it if is_done(_line(lines, str(i["n"])))),
"total": len(it),
"holds": len(open_holds(template, lines)),
}
def dept_pages(code: Any) -> list[str] | None:
"""The pages a department works to, or None for a support department or an unknown code (every page)."""
pages = DEPT_PAGES.get(str(code or ""))
return list(pages) if pages is not None else None
def holds_for_packet(
itp_row: Mapping[str, Any] | None, lines: Mapping[str, Any], dept_code: Any
) -> dict[str, Any] | None:
"""The packet's ITP warning: open hold, witness or rejected points on its department's pages, while the unit's
ITP is open. None when there is none."""
if itp_row is None or str(itp_row.get("status")) == "complete":
return None
pages = dept_pages(dept_code)
hs = open_holds(itp_row["template"] or {}, lines, pages)
if not hs:
return None
return {
"no": itp_row.get("no"),
"count": len(hs),
"rejected": any(h["rejected"] for h in hs),
"pages": pages,
"items": [{k: h[k] for k in ("n", "t", "codes", "rejected", "ncr")} for h in hs],
}
def initials(name: Any) -> str:
"""The first letter of each word of a name, upper case, at most three. The old initials()."""
return "".join(w[0].upper() for w in str(name or "").split() if w)[:3]
def site_zone(tz: str | None) -> ZoneInfo:
try:
return ZoneInfo(tz or "America/Denver")
except (ZoneInfoNotFoundError, ValueError):
return ZoneInfo("America/Denver")
def date_text(value: Any, tz: str | None = None) -> str:
"""M/D/YYYY in the site time zone (10/17/2026)."""
if value is None:
return ""
if isinstance(value, datetime):
d = value.astimezone(site_zone(tz)).date() if value.tzinfo else value.date()
elif isinstance(value, date):
d = value
else:
return str(value)
return f"{d.month}/{d.day}/{d.year}"
def today(tz: str | None = None) -> date:
return datetime.now(site_zone(tz)).date()
# ---------------------------------------------------------------- loading
def load_row(conn: Any, itp_id: int, *, lock: bool = False) -> dict[str, Any] | None:
sql = "SELECT *, status::text AS status FROM itps WHERE id = %s"
if lock:
sql += " FOR UPDATE"
return packets.one(conn, sql, (itp_id,))
def row_for_unit(conn: Any, unit_id: int) -> dict[str, Any] | None:
return packets.one(conn, "SELECT *, status::text AS status FROM itps WHERE unit_id = %s", (unit_id,))
def load_lines(conn: Any, itp_id: int) -> dict[str, dict[str, Any]]:
return {r["key"]: r for r in packets.rows(conn, "SELECT * FROM itp_lines WHERE itp_id = %s", (itp_id,))}
def load_blocks(conn: Any, itp_id: int) -> dict[str, dict[str, Any]]:
return {r["block_key"]: r for r in packets.rows(conn, "SELECT * FROM itp_blocks WHERE itp_id = %s", (itp_id,))}
def summary(itp_row: Mapping[str, Any], lines: Mapping[str, Any]) -> dict[str, Any]:
pr = progress(itp_row["template"] or {}, lines)
return {
"id": itp_row["id"],
"no": itp_row["no"],
"status": str(itp_row["status"]),
"done": pr["done"],
"total": pr["total"],
"holds": pr["holds"],
}
def unit_itps(conn: Any, unit_ids: Iterable[int]) -> dict[int, tuple[dict[str, Any], dict[str, dict[str, Any]]]]:
"""The ITP row and its lines per unit, for the given units, in two queries."""
ids = sorted({int(u) for u in unit_ids})
if not ids:
return {}
itps = packets.rows(conn, "SELECT *, status::text AS status FROM itps WHERE unit_id = ANY(%s)", (ids,))
if not itps:
return {}
lines: dict[int, dict[str, dict[str, Any]]] = {r["id"]: {} for r in itps}
for ln in packets.rows(
conn,
"SELECT itp_id, key, result, cust_code, ncr FROM itp_lines WHERE itp_id = ANY(%s)",
([r["id"] for r in itps],),
):
lines[ln["itp_id"]][ln["key"]] = ln
return {int(r["unit_id"]): (r, lines[r["id"]]) for r in itps}
def summaries(conn: Any, unit_ids: Iterable[int]) -> dict[int, dict[str, Any]]:
return {u: summary(row, lines) for u, (row, lines) in unit_itps(conn, unit_ids).items()}
# ---------------------------------------------------------------- the read
def can_of(pp: policy.Principal, itp_row: Mapping[str, Any], blocks_signed: bool) -> dict[str, bool]:
open_ = str(itp_row["status"]) != "complete"
edit = open_ and check_scoped(pp, A.ITP_EDIT_LINE, assume_it_reason=True).ok
sign_off = open_ and blocks_signed and check_scoped(pp, A.ITP_SIGN_OFF, assume_it_reason=True).ok
reopen = (not open_) and check_scoped(pp, A.ITP_REOPEN, extra={"reason": "x" * 8}, assume_it_reason=True).ok
return {"edit": edit, "sign_off": sign_off, "reopen": reopen}
def read(conn: Any, itp_row: Mapping[str, Any], pp: policy.Principal) -> dict[str, Any]:
"""The full ITP answer (GET /api/units/{unit_id}/itp and every ITP write)."""
iid = itp_row["id"]
template = itp_row["template"] or {}
lines = load_lines(conn, iid)
brows = load_blocks(conn, iid)
rejects: dict[str, list[dict[str, Any]]] = {}
for r in packets.rows(conn, "SELECT * FROM itp_rejects WHERE itp_id = %s ORDER BY id", (iid,)):
rejects.setdefault(r["line_key"], []).append(r)
log_rows = packets.rows(conn, "SELECT * FROM itp_log WHERE itp_id = %s ORDER BY id DESC LIMIT %s", (iid, LOG_MAX))
ids: list[Any] = [itp_row["signoff_by"], itp_row["reopen_by"], itp_row["created_by"]]
for ln in lines.values():
ids += [ln["by"], ln["cust_rec"]]
for b in brows.values():
ids += [b["signed_by"], b["cust_by"]]
for rs in rejects.values():
ids += [r["by"] for r in rs]
ids += [r["by"] for r in log_rows]
people = packets.People(conn, ids)
unit = packets.one(
conn,
"SELECT u.id, u.number, u.project_id, u.project_number FROM v_units u WHERE u.id = %s",
(itp_row["unit_id"],),
)
out_lines: dict[str, Any] = {}
for i in items(template):
key = str(i["n"])
ln = lines.get(key) or {}
result = ln.get("result")
out_lines[key] = {
"key": key,
"result": result,
"na": result == "na",
"by": people.ref(ln.get("by")) if result == "approve" else None,
"initials": ln.get("initials") if result == "approve" else None,
"at": ln.get("at") if result == "approve" else None,
"ncr": ln.get("ncr"),
"ncr_missing": result == "reject" and not ln.get("ncr"),
"note": ln.get("note"),
"cust_code": ln.get("cust_code"),
"cust_by": ln.get("cust_by"),
"cust_at": ln.get("cust_at"),
"cust_rec": people.ref(ln.get("cust_rec")),
"rejects": [
{"at": r["at"], "by": people.ref(r["by"]), "ncr": r["ncr"], "note": r["note"], "kind": r["kind"]}
for r in rejects.get(key, [])
],
"row_version": ln.get("row_version", 0),
}
out_blocks: list[dict[str, Any]] = []
for b in blocks(template):
br = brows.get(b["key"]) or {}
out_blocks.append(
{
"key": b["key"],
"sections": [str(s["n"]) for s in b["sections"]],
"scope": block_scope(b),
"final": b["final"],
"ready": block_ready(b, lines),
"all_na": block_all_na(b, lines),
"signed": br.get("signed_at") is not None,
"signed_at": br.get("signed_at"),
"signed_by": people.ref(br.get("signed_by")) if br.get("signed_at") is not None else None,
"qn": br.get("qn"),
"remarks": br.get("remarks"),
"cust_name": br.get("cust_name"),
"cust_at": br.get("cust_at"),
"cust_by": people.ref(br.get("cust_by")),
"row_version": br.get("row_version", 0),
}
)
all_signed = bool(out_blocks) and all(b["signed"] for b in out_blocks)
pages_present = {s.get("page") for s in template.get("sections") or []}
status = str(itp_row["status"])
return {
"id": iid,
"unit_id": itp_row["unit_id"],
"unit": None
if unit is None
else {
"id": unit["id"],
"number": unit["number"],
"project": {"id": unit["project_id"], "number": unit["project_number"]},
},
"no": itp_row["no"],
"hdr": {k: (itp_row["hdr"] or {}).get(k) for k in HDR_KEYS},
"status": "complete" if status == "complete" else "open",
"version": itp_row["version"],
"template": template,
"pages": [{"code": c, "name": PAGE_NAMES[c]} for c in PAGE_ORDER if c in pages_present],
"lines": out_lines,
"blocks": out_blocks,
"progress": progress(template, lines),
"holds": open_holds(template, lines),
"signoff": None
if itp_row["signoff_at"] is None
else {
"at": itp_row["signoff_at"],
"by": people.ref(itp_row["signoff_by"]),
"asset_id": packets.uid_text(itp_row["signoff_asset_id"]),
},
"reopen": None
if itp_row["reopen_at"] is None
else {"at": itp_row["reopen_at"], "by": people.ref(itp_row["reopen_by"]), "reason": itp_row["reopen_reason"]},
"created_at": itp_row["created_at"],
"created_by": people.ref(itp_row["created_by"]),
"can": can_of(pp, itp_row, all_signed),
"log": [
{
"id": r["id"],
"at": r["at"],
"by": people.ref(r["by"]),
"by_name": r["by_name"],
"action": r["action"],
"note": r["note"],
"signed": r["signature_id"] is not None,
}
for r in log_rows
],
}
HDR_KEYS: tuple[str, ...] = ("client", "pname", "pno", "po", "unit")
# ---------------------------------------------------------------- the write
@dataclass
class ItpWrite:
conn: Any
principal: deps.Principal
policy_principal: policy.Principal
itp: dict[str, Any]
action: policy.Action
decision: policy.Decision
request: Any
it_reason: str | None # the cleaned IT reason, kept only when the admin bypass was used
signature: Signature | None = None
logs: list[tuple[str, str | None]] = field(default_factory=list)
version: int = 0
@property
def id(self) -> int:
return int(self.itp["id"])
@property
def user_id(self) -> Any:
return self.principal.user_id
@property
def tz(self) -> str:
return str(deps.settings_of(self.request).site_tz)
def log(self, action: str, note: str | None = None) -> None:
"""One itp_log row (and audit row) for this write. A write that logs nothing changes nothing."""
self.logs.append((action, note))
def refuse(decision: policy.Decision) -> None:
if decision.ok:
return
status = 400 if decision.code in ("it_reason_required", "reason_required") else 403
raise ApiError(status, decision.code, decision.why)
def scoped_decision(
p: deps.Principal,
pp: policy.Principal,
action: policy.Action,
*,
extra: Mapping[str, Any] | None = None,
it_reason: str | None = None,
) -> tuple[policy.Decision, str | None]:
"""check_scoped, raising the refusal. Returns the decision and the IT reason to record (bypass only)."""
del p
decision = check_scoped(pp, action, extra=extra, it_reason=it_reason)
refuse(decision)
return decision, (reason_ok(it_reason) if decision.admin_bypass else None)
@contextmanager
def itp_write(
d: deps.Db,
p: deps.Principal,
itp_id: int,
action: policy.Action,
*,
request: Any,
extra: Mapping[str, Any] | None = None,
it_reason: str | None = None,
sign: Any = None,
allow_complete: bool = False,
sign_extra: Mapping[str, Any] | None = None,
) -> Iterator[ItpWrite]:
"""One ITP write, in one transaction. See the module docstring for the order of steps.
A wrong signature secret commits the step-up failure records and then raises, like the packet pipeline.
"""
conn = d.conn
pp = packets.policy_principal_for(conn, p)
decision, it_kept = scoped_decision(p, pp, action, extra=extra, it_reason=it_reason)
failure: ApiError | None = None
with conn.transaction():
row = load_row(conn, itp_id, lock=True)
if row is None:
raise ApiError(404, "not_found", MSG_NO_ITP)
if str(row["status"]) == "complete" and not allow_complete:
raise ApiError(409, "itp_complete", MSG_COMPLETE)
w = ItpWrite(
conn=conn,
principal=p,
policy_principal=pp,
itp=row,
action=action,
decision=decision,
request=request,
it_reason=it_kept,
version=int(row["version"]),
)
if decision.signature:
if sign is None:
raise ApiError(
400, "signature_required", "This step needs your signature.", extra={"meaning": decision.signature}
)
try:
w.signature = signatures.sign(
conn,
deps.directory_of(request),
p.actor,
factor=sign.factor,
secret=sign.secret,
action=f"itp.{action}",
meaning=decision.signature,
packet_id=None,
subject_type="itp",
subject_id=itp_id,
content={
"action": str(action),
"itp_id": itp_id,
"version": int(row["version"]),
**(sign_extra or {}),
},
ip=p.ip,
settings=deps.settings_of(request),
)
except ApiError as e:
failure = e
if failure is None:
yield w
_finish(w)
if failure is not None:
raise failure
def _finish(w: ItpWrite) -> None:
if not w.logs:
return
conn = w.conn
w.version = int(
conn.execute("UPDATE itps SET version = version + 1 WHERE id = %s RETURNING version", (w.id,)).fetchone()[0]
)
sig_id = w.signature.id if w.signature else None
name = w.principal.user["display_name"]
for n, (action, note) in enumerate(w.logs):
text = clean_opt(note, LIMITS["note"]) if note else None
sid = sig_id if n == 0 else None
conn.execute(
"INSERT INTO itp_log (itp_id, by, by_name, action, note, signature_id, it_reason)"
" VALUES (%s, %s, %s, %s, %s, %s, %s)",
(w.id, w.user_id, name, action, text, sid, w.it_reason),
)
detail: dict[str, Any] = {"text": action, "note": text, "unit_id": w.itp["unit_id"]}
if sid:
detail["signature_id"] = sid
audit.append(
conn,
action=f"itp.{w.action}",
user_id=w.user_id,
session_id=w.principal.session_id,
role_snapshot=w.principal.role_snapshot,
subject_type="itp",
subject_id=w.id,
detail=detail,
it_reason=w.it_reason,
ip=w.principal.ip,
)
events.publish(conn, {"event": "changed", "kind": "itp", "unit_id": w.itp["unit_id"], "itp_id": w.id})
def label(action: policy.Action) -> str:
return SCOPED_LABELS.get(action, str(action))
+659
View File
@@ -0,0 +1,659 @@
"""The Q.C. ITP template, ITP_Generation_Revised_2.
Copied verbatim from the old build's src/js/itp.js line 4 (ITP_TMPL), which Q.C. took from the workbook
ITP_Generation_Revised_2.xlsm (COP Template sheet). Starting an ITP freezes a copy of it into itps.template, so a
later change here never alters an ITP already in use.
Sections: n, t (title), page (WP Weld Parts, C Code, FA Final Assembly), sig (a QN # / remarks / PESCO Q.C. signature
block follows the section), final (the last block, COP complete) and items. Items: n, t (title), code (the PESCO
code), cust (the customer code), crit (acceptance criteria).
"""
from __future__ import annotations
from typing import Any
ITP_TEMPLATE: dict[str, Any] = {
"ver": "ITP_Generation_Revised_2",
"legend": "O = Observe R = Review A = Approve I = Inspect AU = Audit W = Witness (Hold) "
"H = Hold NA = Not Applicable",
"sections": [
{
"n": "1",
"t": "GENERAL FABRICATION and DATA",
"page": "WP",
"sig": False,
"items": [
{
"n": "1.1",
"t": "Material Test Reports",
"code": "R",
"cust": "R",
"crit": "MTR chemistry and mechanical properties meet PO/material spec; heat "
"numbers on MTR match material received. (API Q1 5.5.3, 5.6.5 / ISO 9001 "
"8.4.2, 8.5.2)",
},
{
"n": "1.2",
"t": "Welding Procedures",
"code": "A",
"cust": "R",
"crit": "WPS/PQR qualified per ASME IX and approved before use; current revision "
"matches the weld map assignment. (API Q1 5.6.3 / ISO 9001 8.5.1)",
},
{
"n": "1.3",
"t": "Welder Qualifications Records",
"code": "A",
"cust": "R",
"crit": "Welder current on the qualified welder log for the process, position and "
"material used; qualification not expired. (API Q1 4.3.2 / ISO 9001 7.2)",
},
{
"n": "1.4",
"t": "QA/QC Manual",
"code": "A",
"cust": "R",
"crit": "Manual reflects current API Q1 / ISO 9001 requirements and is the "
"controlled, released revision. (API Q1 4.4 / ISO 9001 7.5.3)",
},
{
"n": "1.5",
"t": "Weld Map(s)/Fabrication Drawings",
"code": "A",
"cust": "R",
"crit": "Released revision used; as-built weld numbers, WPS and welder ID recorded "
"on the map before the final documentation package is assembled. (API Q1 "
"5.6.3, 5.6.5 / ISO 9001 8.5.2)",
},
{
"n": "1.6",
"t": "Radiographic Testing and Reports",
"code": "R",
"cust": "R",
"crit": "RT reports reference weld map location and heat number; results accepted "
"to governing code/spec; technician currently certified. (API Q1 5.6.9.4 / "
"ISO 9001 7.1.5, 8.5.2)",
},
{
"n": "1.7",
"t": "Ultrasonic Testing and Reports",
"code": "R",
"cust": "R",
"crit": "UT reports reference weld map location and heat number; results accepted "
"to governing code/spec; technician currently certified. (API Q1 5.6.9.4 / "
"ISO 9001 7.1.5, 8.5.2)",
},
{
"n": "1.8",
"t": "Hydrostatic Testing and Reports",
"code": "A",
"cust": "R",
"crit": "Test pressure and hold time meet code/spec; no leaks or pressure drop; "
"gauges in current calibration. (API Q1 5.6.9.3, 5.8)",
},
{
"n": "1.9",
"t": "Pneumatic Testing and Reports",
"code": "R",
"cust": "R",
"crit": "Used only where hydrotest is not permitted by code/client spec; test "
"pressure and hold time meet code/spec; mark NA if hydrotest performed.",
},
{
"n": "1.10",
"t": "Insulation Application and Test Reports",
"code": "O",
"cust": "R",
"crit": "Insulation type and thickness per spec; test reports (where required) "
"meet the acceptance values in the spec.",
},
{
"n": "1.11",
"t": "Nonconformance Reports (NCR/QN Log)",
"code": "R",
"cust": "R",
"crit": "All QNs/NCRs from this build are dispositioned and closed, or carried to "
"the customer as an open item before shipment; concessions are "
"customer-approved. (API Q1 5.9 / ISO 9001 8.7)",
},
{
"n": "1.12",
"t": "Export Packing Plan",
"code": "R",
"cust": "R",
"crit": "Packing plan meets PO, ISPM-15 and destination country requirements.",
},
{
"n": "1.13",
"t": 'Certified "As-Built" Data and Drawings',
"code": "A",
"cust": "R",
"crit": "As-built drawings match the final fabricated configuration; all field "
"changes/redlines incorporated and approved.",
},
{
"n": "1.14",
"t": "Nameplate/Tagging Etching/Stenciling",
"code": "R",
"cust": "R",
"crit": "Nameplate data matches the approved drawing/data sheet and code stamp (if "
"applicable); stamped/etched by a qualified person.",
},
],
},
{
"n": "2",
"t": "STRUCTURAL",
"page": "WP",
"sig": False,
"items": [
{
"n": "2.1",
"t": "Structural Fit-up/Dimensional Check prior to Weldout",
"code": "I",
"cust": "R/AU",
"crit": "Fit-up dimensions per approved drawing tolerance; heat numbers "
"transferred from mill cert to cut members; welder ID/WPS recorded against "
"the weld map before weld-out. (API Q1 5.6.5 / ISO 9001 8.5.2)",
},
{
"n": "2.2",
"t": "Post Welding Dimensional Check",
"code": "I",
"cust": "R/AU",
"crit": "Final dimensions within drawing tolerance; visual weld quality meets AWS "
"D1.1 (or governing code) acceptance criteria.",
},
{
"n": "2.3",
"t": "Interface Structural and ICP Dimensional Locations",
"code": "I",
"cust": "R/AU",
"crit": "Interface/ICP dimensional locations within drawing tolerance; verified "
"and recorded before release to the next department.",
},
],
},
{
"n": "3",
"t": "PIPING",
"page": "WP",
"sig": True,
"items": [
{
"n": "3.1",
"t": "Visual Quality Inspection",
"code": "O",
"cust": "R/AU",
"crit": "Welds free of surface defects (undercut, porosity, cracking) per code "
"acceptance criteria; weld map and heat numbers current.",
},
{
"n": "3.2",
"t": "Post Welding Dimensional Check",
"code": "O",
"cust": "R/AU",
"crit": "Dimensions and alignment per isometric/drawing tolerance.",
},
{
"n": "3.3",
"t": "Ultrasonic, Radiographic and Magnetic Particle Testing and Reports",
"code": "R",
"cust": "R/AU",
"crit": "NDE method and extent per code/spec; results reference weld map location "
"and welder ID; NDE technician currently certified. (API Q1 5.6.9.4 / ISO "
"9001 8.5.2)",
},
{
"n": "3.4",
"t": "Final inspection/Hydrostatic test",
"code": "A",
"cust": "W",
"crit": "Test pressure and hold time per code/spec; no leaks or pressure drop; "
"witnessed where required by the PO. (API Q1 5.6.9.3)",
},
],
},
{
"n": "4",
"t": "VESSELS",
"page": "C",
"sig": True,
"items": [
{
"n": "4.1",
"t": "Seam/Joint Fit-up and Dimensional Check prior to Weldout",
"code": "O",
"cust": "R/AU",
"crit": "Fit-up/gap/alignment per WPS and drawing; heat numbers transferred to "
"shell/head components; welder ID/WPS recorded against the weld map.",
},
{
"n": "4.2",
"t": "Prior to vessel closing Heads Fit-up and Weldout (Internal Inspection)",
"code": "O",
"cust": "H",
"crit": "Internal surfaces, welds and attachments accepted before the vessel "
"becomes inaccessible. Hold point justified: internal access is "
"permanently lost after closure.",
},
{
"n": "4.3",
"t": "Prior to Stress Relieving",
"code": "O",
"cust": "R/AU",
"crit": "Vessel meets drawing configuration and code requirements before heat "
"treatment; PWHT procedure/chart approved.",
},
{
"n": "4.4",
"t": "Final Inspection/Pre-hydrotest",
"code": "O",
"cust": "H",
"crit": "All welds, NDE and dimensional checks complete and accepted before the "
"vessel is pressurized. Hold point justified: an unverified vessel "
"presents a personnel-safety risk under test pressure.",
},
{
"n": "4.5",
"t": "Hydrostatic Testing and Reports",
"code": "A",
"cust": "H",
"crit": "Test pressure and hold time per ASME code/spec; no leaks, no permanent "
"deformation; customer witness where required by the PO. (API Q1 5.6.9.3)",
},
{
"n": "4.6",
"t": "Ultrasonic, Radiographic and Magnetic Particle Testing and Reports",
"code": "R",
"cust": "R/AU",
"crit": "NDE method/extent per code; results reference weld map location, heat "
"number and welder ID. (API Q1 5.6.9.4 / ISO 9001 8.5.2)",
},
],
},
{
"n": "5",
"t": "INTERNAL COATING",
"page": "C",
"sig": True,
"items": [
{
"n": "5.1",
"t": "Post Surface Preparation/Blasting",
"code": "W",
"cust": "W",
"crit": "Surface profile and cleanliness (e.g., SSPC-SP/NACE) meet the coating "
"spec. Witness point justified: surface condition cannot be re-verified "
"once coated.",
},
{
"n": "5.2",
"t": "Post Application of Primer Coat",
"code": "W",
"cust": "R/AU",
"crit": "DFT within spec range; no holidays, runs or contamination before overcoat.",
},
{
"n": "5.3",
"t": "Post Application of Intermediate Coat(s)",
"code": "W",
"cust": "R/AU",
"crit": "Cumulative DFT within spec range; recoat window per data sheet met.",
},
{
"n": "5.4",
"t": "Post Application of Top Coat",
"code": "A",
"cust": "R/AU",
"crit": "Total DFT, color and finish per coating spec/data sheet.",
},
{
"n": "5.5",
"t": "Holiday Testing",
"code": "H",
"cust": "H",
"crit": "Holiday (spark) test at voltage per NACE SP0188/coating data sheet; zero "
"holidays before service. Hold point justified: final acceptance test for "
"lining integrity.",
},
],
},
{
"n": "6",
"t": "EXTERNAL COATING",
"page": "C",
"sig": True,
"items": [
{
"n": "6.1",
"t": "Post Surface Preparation/Blasting",
"code": "W",
"cust": "W",
"crit": "Surface profile and cleanliness meet the coating spec. Witness point "
"justified: surface condition cannot be re-verified once coated.",
},
{
"n": "6.2",
"t": "Post Application of Primer Coat",
"code": "W",
"cust": "R/AU",
"crit": "DFT within spec range; no holidays, runs or contamination before overcoat.",
},
{
"n": "6.3",
"t": "Post Application of Intermediate Coat(s)",
"code": "W",
"cust": "R/AU",
"crit": "Cumulative DFT within spec range; recoat window per data sheet met.",
},
{
"n": "6.4",
"t": "Post Application of Top Coat",
"code": "A",
"cust": "R/AU",
"crit": "Total DFT, color and gloss per coating spec/data sheet.",
},
{
"n": "6.5",
"t": "Final External Coating Inspection",
"code": "A",
"cust": "R/AU",
"crit": "Visual inspection (holidays, runs, mechanical damage) and DFT survey meet "
"spec; touch-ups accepted before release.",
},
{
"n": "6.6",
"t": "Holiday Testing Under Insulation (If Applicable)",
"code": "H",
"cust": "H",
"crit": "Low-voltage holiday test per spec where insulation is applied over "
"coating. Hold point justified: coating is inaccessible once insulated. "
"Mark NA if not insulated.",
},
],
},
{
"n": "7",
"t": "INSTRUMENTATION, ELECTRICAL and CONTROLS",
"page": "FA",
"sig": True,
"items": [
{
"n": "7.1",
"t": "Verify Component Parts and Model Numbers to Specs/Data Sheets",
"code": "R",
"cust": "R/AU",
"crit": "Model, range, materials and area classification match the approved "
"instrument data sheet, cross-checked to the instrument index.",
},
{
"n": "7.2",
"t": "Contractor's Inspection Records",
"code": "R",
"cust": "R",
"crit": "Sub-tier inspection records complete and signed; sub-tier NCRs closed "
"before acceptance. (API Q1 5.6 / ISO 9001 8.4)",
},
{
"n": "7.3",
"t": "Calibration Verification – Test/Measuring Equipment Used",
"code": "R",
"cust": "R",
"crit": "Megger, torque wrench and other test/measuring equipment used for "
"acceptance are within current calibration; cal-due dates recorded on the "
"inspection record. (API Q1 5.8 / ISO 9001 7.1.5)",
},
{
"n": "7.4",
"t": "Verify Grounding and Design Documents/Specs",
"code": "R",
"cust": "R/AU",
"crit": "Grounding installed per design/one-line drawing and NEC Article 250; "
"ground resistance recorded.",
},
{
"n": "7.5",
"t": "Megger / Insulation Resistance and Continuity Check of Cables",
"code": "W",
"cust": "R/AU",
"crit": "Continuity end-to-end, cores correctly identified, insulation resistance "
"within limits. (Ref: NETA ATS; cable test schedule)",
},
{
"n": "7.6",
"t": "Static checks of control switches (as required)",
"code": "O",
"cust": "R/AU",
"crit": "Switch actuation and fail-safe position verified against the loop "
"diagram; set points and fail-safe action per data sheet. (Static check "
"record)",
},
{
"n": "7.7",
"t": "Verify Nameplate Layout, Lettering and Size",
"code": "I",
"cust": "R/AU",
"crit": "Text, size, material and mounting per client labeling spec. (Nameplate checklist)",
},
{
"n": "7.8",
"t": "Verify Wire Size, Terminations and Tagging",
"code": "I",
"cust": "R/AU",
"crit": "Wire size per load and NEC Art. 310; terminations torqued; tags match "
"wiring drawings. (Termination check sheet)",
},
{
"n": "7.9",
"t": "Electrical Motor Testing",
"code": "W",
"cust": "R/AU",
"crit": "IR, winding resistance and rotation within NEMA MG-1/NETA ATS limits. (Motor test report)",
},
{
"n": "7.10",
"t": "Instrument loop checks",
"code": "I",
"cust": "R/AU",
"crit": "Signal proven at 0/50/100% of range; alarms and trips actuate at set "
"point per loop diagram/C&E. (Signed loop check sheets)",
},
{
"n": "7.11",
"t": "Factory Acceptance Test (FAT)",
"code": "W",
"cust": "W",
"crit": "All FAT steps pass per the approved FAT procedure; A-items cleared before "
"shipment. (API Q1 5.6.9.3 / signed FAT report)",
},
],
},
{
"n": "8",
"t": "FINAL ASSEMBLY",
"page": "FA",
"sig": True,
"items": [
{
"n": "8.1",
"t": "Equipment Assembly, Mounting and/or Alignment",
"code": "I",
"cust": "R/AU",
"crit": "Alignment within vendor/GA drawing tolerance; mounting hardware and "
"anchor bolts torqued to spec. (Alignment record)",
},
{
"n": "8.2",
"t": "Functional Testing and Reports",
"code": "O",
"cust": "R/AU",
"crit": "All functions operate per the approved functional test procedure with no "
"defects. (Functional test report)",
},
{
"n": "8.3",
"t": "Full Operation Testing and Reports (PRESSURE UP CONNECTIONS)",
"code": "O",
"cust": "R/AU",
"crit": "System holds test pressure with zero leaks at all connections per the "
"approved test procedure. (ASME B31.3 §345 / operational test report)",
},
{
"n": "8.4",
"t": "Stud Bolt & Nut Torqueing",
"code": "I",
"cust": "R/AU",
"crit": "Torqued in pattern to the specified value with a calibrated wrench. (ASME "
"PCC-1 / torque record)",
},
{
"n": "8.5",
"t": "Stud Bolt Length & Grade",
"code": "I",
"cust": "R/AU",
"crit": "Correct grade marks; minimum 2 threads past the nut. (ASME B16.5, "
"A193/A194 / bolting checklist)",
},
{
"n": "8.6",
"t": "Relief Valve(s) Set Pressure",
"code": "I",
"cust": "R/AU",
"crit": "Set pressure verified per vendor data sheet / ASME Section VIII (or API "
"526 for PRVs) on a calibrated test bench; seal/wire intact after test. "
"(PRV test/certification record)",
},
{
"n": "8.7",
"t": "Weir Nipple Installation",
"code": "O",
"cust": "R/AU",
"crit": "Weir nipple size and orientation per P&ID and vendor requirement; "
"installed per manufacturer instructions.",
},
{
"n": "8.8",
"t": "Pressure Gauge Inspection",
"code": "O",
"cust": "R/AU",
"crit": "Gauge range 25–75% of span at normal operating pressure; gauge in current "
"calibration. (ASME B40.100 / gauge list + certs)",
},
{
"n": "8.9",
"t": "Regulator(s) Set Pressure",
"code": "I",
"cust": "R/AU",
"crit": "Outlet pressure within the stated tolerance of the vendor data sheet. (Set-point record)",
},
{
"n": "8.10",
"t": "Pressure Controller(s) Set Pressure",
"code": "I",
"cust": "R/AU",
"crit": "Set point and control action per the approved instrument data sheet. (Set-point record)",
},
{
"n": "8.11",
"t": "Flow Direction of all Valves, etc.",
"code": "I",
"cust": "R/AU",
"crit": "Flow arrows and valve orientation match the approved P&ID. (P&ID walk-down checklist)",
},
{
"n": "8.12",
"t": "Gasket Specification",
"code": "I",
"cust": "R/AU",
"crit": "Gasket type, material, rating and size per spec; never reused. (ASME "
"B16.20/B16.21 / gasket verification record)",
},
{
"n": "8.13",
"t": "Valve Actuation",
"code": "O",
"cust": "R/AU",
"crit": "Full stroke, travel time and fail position per the vendor manual/C&E "
"matrix. (Valve stroke record)",
},
{
"n": "8.14",
"t": "Nonconformance Check – Final Assembly",
"code": "R",
"cust": "R/AU",
"crit": "No open QNs/NCRs from the assembly stage before release to Paint/Final "
"Inspection. (API Q1 5.9)",
},
],
},
{
"n": "9",
"t": "FINAL INSPECTION/ SHIPPING PREPARATION",
"page": "FA",
"sig": True,
"items": [
{
"n": "9.1",
"t": "Verify Tagging/Markings are complete",
"code": "I",
"cust": "R/AU",
"crit": "Everything tagged per the index; legible and permanent. (Client tagging "
"spec, UG-116 / tagging checklist)",
},
{
"n": "9.2",
"t": "Flange Face Protection",
"code": "I",
"cust": "R/AU",
"crit": "All flanges protected, threaded connections plugged; lifting points and "
"shipping restraints verified per the packing plan. (API Q1 5.6.8; PO "
"packing spec / ISO 9001 8.5.4 / preservation checklist)",
},
{
"n": "9.3",
"t": "Verify Punch List Items are complete",
"code": "I",
"cust": "R/AU",
"crit": "All A-items closed; B/C items agreed in writing with the customer. "
"(Client punch list procedure / signed punch list)",
},
{
"n": "9.4",
"t": "Packing of Ship Loose Items",
"code": "I",
"cust": "R/AU",
"crit": "Ship-loose items tagged, protected and listed on the packing list. "
"(Approved packing plan, ISPM-15 / signed packing list)",
},
{
"n": "9.5",
"t": "Final Pre-shipping Inspection",
"code": "A",
"cust": "R/AU",
"crit": "Every ITP line signed off; no open holds or QNs. (API Q1 5.6.9.3, 5.7 / "
"final inspection report)",
},
{
"n": "9.6",
"t": "RELEASE TO SHIP (RTS)",
"code": "A",
"cust": "H",
"crit": "Written release issued by PESCO QA and the client; QN register closed "
"with all concessions customer-approved before shipment. (API Q1 5.7, 5.9 "
"/ ISO 9001 8.6, 8.7 / signed RTS certificate)",
},
{
"n": "9.7",
"t": "DATABOOK SIGNED OFF BY QA",
"code": "R",
"cust": "H",
"crit": "Data book complete and indexed per PO requirements. (API Q1 4.5 / ISO "
"9001 7.5.3 / signed data book)",
},
],
"final": True,
},
],
}
+34
View File
@@ -0,0 +1,34 @@
"""NCR numbers: the form check and the link to PESCO's NCR system (pesco-ncr).
An NCR number reads NCR-YYYY-NNNN (NCR-2026-0021). Q.C. gives one on a packet reject and on a rejected ITP line.
"""
from __future__ import annotations
import re
from typing import Any
from ..api.errors import ApiError
from ..text import LIMITS, clean_text
RX_NCR = re.compile(r"^NCR-\d{4}-\d{4}$")
NCR_URL = "https://ncr.pescoinc.biz/ncrs/"
MSG_INVALID = "Enter an NCR number like NCR-2026-0021."
NCR_MAX = LIMITS["ncr"]
def validate_ncr(text: Any) -> str:
"""The normalised NCR number (trimmed, upper case), or 422 invalid_ncr.
This is the hook for checking the number against pesco-ncr, which is a later phase. Today it accepts any
well-formed number and does not look it up.
"""
value = clean_text(text, NCR_MAX).upper()
if not RX_NCR.match(value):
raise ApiError(422, "invalid_ncr", MSG_INVALID)
return value
def ncr_url(number: str) -> str:
"""The pesco-ncr page of an NCR number."""
return NCR_URL + number
+143 -24
View File
@@ -43,6 +43,10 @@ SEAL_EXCLUDE = (
"target_hrs",
"labor",
"heat_src",
"qc_last",
"qc_folder_docs",
"itp",
"itp_hold",
)
@@ -242,6 +246,7 @@ class Children:
dept_names: dict[str, str] = field(default_factory=dict)
dept_labels: dict[str, str] = field(default_factory=dict)
templates: dict[str, list[dict[str, str]]] = field(default_factory=dict)
qc_items: list[dict[str, str]] = field(default_factory=list)
@property
def open_stage(self) -> dict[str, Any] | None:
@@ -264,6 +269,7 @@ def load_children(conn: Any, row: Mapping[str, Any], *, full: bool = True) -> Ch
ch.dept_names[code] = name
ch.dept_labels[code] = dept_label(code, name, kind)
ch.templates = checklists.load_templates(conn)
ch.qc_items = checklists.load_qc_items(conn)
ch.pages = rows(conn, "SELECT * FROM packet_pages WHERE packet_id = %s ORDER BY n", (pid,))
ch.bom = rows(conn, "SELECT * FROM bom_lines WHERE packet_id = %s ORDER BY item", (pid,))
shorts = rows(conn, "SELECT * FROM bom_shorts WHERE packet_id = %s AND closed_at IS NULL", (pid,))
@@ -311,8 +317,12 @@ def load_children(conn: Any, row: Mapping[str, Any], *, full: bool = True) -> Ch
ch.co_required.setdefault(r["co_id"], []).append(r["dept_code"])
for r in rows(conn, "SELECT * FROM co_verifications WHERE co_id = ANY(%s) ORDER BY at", (co_ids,)):
ch.co_verified.setdefault(r["co_id"], []).append(r)
# Every document, the ones taken out of the data book too (a removed row is a record).
ch.docs = rows(
conn, "SELECT * FROM packet_docs WHERE packet_id = %s AND removed_at IS NULL ORDER BY added_at, id", (pid,)
conn,
"SELECT d.*, a.original_name AS file_name, a.size AS file_size FROM packet_docs d"
" JOIN assets a ON a.id = d.asset_id WHERE d.packet_id = %s ORDER BY d.added_at, d.id",
(pid,),
)
ch.seal = one(conn, "SELECT * FROM packet_seals WHERE packet_id = %s ORDER BY id DESC LIMIT 1", (pid,))
return ch
@@ -370,7 +380,7 @@ def state_from(row: Mapping[str, Any], ch: Children) -> policy.PacketState:
checklist_complete=stage is not None
and checklists.checklist_complete(stage["checklist"], weld, ch.templates or None),
filler_set=stage is not None and bool((stage["filler"] or "").strip()),
qc_missing=tuple(checklists.qc_missing(row, ch.qc, ch.welds)),
qc_missing=tuple(checklists.qc_missing(row, ch.qc, ch.welds, ch.qc_items or None)),
has_qc_sent=ch.qc is not None,
)
@@ -485,7 +495,10 @@ def _stage(
return out
def _qc(q: Mapping[str, Any], people: People) -> dict[str, Any]:
RX_NACE = re.compile(r"NACE", re.IGNORECASE)
def _qc(q: Mapping[str, Any], people: People, row: Mapping[str, Any], missing: list[str]) -> dict[str, Any]:
keys = (
"id",
"sent_at",
@@ -504,16 +517,57 @@ def _qc(q: Mapping[str, Any], people: People) -> dict[str, Any]:
"result",
"result_at",
"result_note",
"ncr",
"row_version",
)
out = {k: q[k] for k in keys}
out = {k: q.get(k) for k in keys}
out["sent_by"] = people.ref(q["sent_by"])
out["result_by"] = people.ref(q["result_by"])
out["checks"] = q["checks"] or {}
out["weld_exam"] = q["weld_exam"] or {}
out["checks"] = _checklist(q["checks"], people) # entries gain by_name
out["weld_exam"] = _checklist(q["weld_exam"], people)
out["nace_label"] = bool(RX_NACE.search(str(row.get("wps") or "")))
out["missing"] = missing
return out
def _entry_ids(value: Any) -> list[Any]:
return [as_uuid(v.get("by")) for v in (value or {}).values() if isinstance(v, Mapping)]
def qc_history_row(h: Mapping[str, Any], people: People) -> dict[str, Any]:
"""A closed inspection: the qc_history row with the parts of its snapshot the screens show. `weld_exam` keeps
each weld's result and note so the screens can show the defect next to a rejected weld."""
from .qc import rejected_welds
snap = h.get("snapshot")
if not isinstance(snap, Mapping):
snap = {}
raw = snap.get("weld_exam")
exam: Mapping[str, Any] = raw if isinstance(raw, Mapping) else {}
return {
"id": h["id"],
"at": h["at"],
"result": h["result"],
"inspector": h["inspector"],
"note": h["note"],
"dept_code": h["dept_code"],
"ncr": snap.get("ncr"),
"rejected_welds": rejected_welds(exam),
"weld_exam": {
str(k): {"result": v.get("result"), "note": v.get("note")}
for k, v in exam.items()
if isinstance(v, Mapping)
},
"by": people.ref(as_uuid(snap.get("result_by"))),
"location": snap.get("location"),
"sent_at": snap.get("sent_at"),
}
# Keys phase 6 added to qc_history rows. Seals made before phase 6 do not cover them, so the seal leaves them out.
SEAL_QC_HISTORY = ("ncr", "rejected_welds", "weld_exam", "by", "location", "sent_at")
def _who_ids(row: Mapping[str, Any], ch: Children) -> list[Any]:
ids: list[Any] = [row["welder_id"], row["for_sup_id"], row["vclosed_by"], row["removed_by"]]
ids += [w["welder_id"] for w in ch.welds] + [w["by"] for w in ch.welds]
@@ -527,7 +581,12 @@ def _who_ids(row: Mapping[str, Any], ch: Children) -> list[Any]:
ids.append(ch.wh["by"])
if ch.qc:
ids += [ch.qc["sent_by"], ch.qc["result_by"]]
ids += [d["added_by"] for d in ch.docs]
ids += _entry_ids(ch.qc["checks"]) + _entry_ids(ch.qc["weld_exam"])
for h in ch.qc_history:
snap = h.get("snapshot")
if isinstance(snap, Mapping):
ids.append(as_uuid(snap.get("result_by")))
ids += [d["added_by"] for d in ch.docs] + [d["removed_by"] for d in ch.docs]
for c in ch.change_orders:
ids += [c["issued_by"], c["approved_by"], c["rejected_by"]]
for vs in ch.co_verified.values():
@@ -723,30 +782,18 @@ def build(
"auto": ch.wh["auto"],
}
)
out["qc"] = _qc(ch.qc, people) if ch.qc else None
out["qc_history"] = [
{k: h[k] for k in ("id", "at", "result", "inspector", "note", "dept_code")} for h in ch.qc_history
]
out["qc"] = _qc(ch.qc, people, row, list(state.qc_missing)) if ch.qc else None
out["qc_history"] = [qc_history_row(h, people) for h in ch.qc_history]
out["qc_last"] = out["qc_history"][-1] if out["qc_history"] else None
out["change_orders"] = [_co(c, ch, people) for c in ch.change_orders]
out["docs"] = [
{
"id": d["id"],
"asset_id": str(d["asset_id"]),
"kind": d["kind"],
"title": d["title"],
"added_at": d["added_at"],
"added_by": people.ref(d["added_by"]),
"url": f"/_blob/{d['asset_id']}",
}
for d in ch.docs
]
out["docs"] = [_doc(d, people, seal=seal) for d in ch.docs]
out["seal"] = (
None
if ch.seal is None
else {"sealed_at": ch.seal["sealed_at"], "key_id": ch.seal["key_id"], "broken_at": ch.seal["broken_at"]}
)
out["checklist_items"] = checklists.checklist_for(weld, row["drawing"], row["rev"], row["wps"], ch.templates)
out["qc_items"] = checklists.qc_items_for(row["drawing"], row["rev"], row["wps"], row["welder_stamp"])
out["qc_items"] = qc_items_view(row, ch)
out["flag"] = flag_of(row["status"], row["source_kind"], row["flags"], row["drawing_state"], row["visual"])
if not seal:
out["visual_state"] = visual_state(conn, row)
@@ -754,6 +801,7 @@ def build(
from . import shop # shop builds on this module
out["heat_src"] = shop.heat_src(conn, row, ch)
out.update(qc_extras(conn, row))
if pp is not None:
allowed, blocked = policy.can(pp, state)
out["can"] = [str(a) for a in allowed]
@@ -786,6 +834,9 @@ def _seal_strip(out: dict[str, Any]) -> None:
for r in out.get(key) or []:
for n in names:
r.pop(n, None)
for h in out.get("qc_history") or []:
for n in SEAL_QC_HISTORY:
h.pop(n, None)
for co in out.get("change_orders") or []:
for v in (co.get("verified") or {}).values():
v.pop("signed", None)
@@ -803,6 +854,74 @@ def _seal_strip(out: dict[str, Any]) -> None:
v.pop("by_name", None)
def _doc(d: Mapping[str, Any], people: People, *, seal: bool) -> dict[str, Any]:
"""A packet document. Seal mode keeps the phase 2 keys, plus the note and the take-out when they are set, so a
seal made before phase 6 still verifies for a packet whose documents were never taken out."""
from .qc import DOC_KINDS
removed = (
None
if d["removed_at"] is None
else {"at": d["removed_at"], "by": people.ref(d["removed_by"]), "reason": d["removed_reason"]}
)
out: dict[str, Any] = {
"id": d["id"],
"asset_id": str(d["asset_id"]),
"kind": d["kind"],
"title": d["title"],
"added_at": d["added_at"],
"added_by": people.ref(d["added_by"]),
"url": f"/_blob/{d['asset_id']}",
}
if seal:
if d.get("note"):
out["note"] = d["note"]
if removed is not None:
out["removed"] = removed
return out
out["kind_label"] = DOC_KINDS.get(str(d["kind"]), str(d["kind"]))
out["note"] = d.get("note")
out["file_name"] = d.get("file_name")
out["size"] = d.get("file_size")
out["removed"] = removed
return out
def qc_items_view(row: Mapping[str, Any], ch: Children) -> list[dict[str, Any]]:
"""The Q.C. items with the old qcTab hints: heats and MTRs over the BOM lines, the welds on the map, and the open
(or last) stage's checklist progress with its filler."""
items = checklists.qc_items_for(row["drawing"], row["rev"], row["wps"], row["welder_stamp"], ch.qc_items or None)
nb = len(ch.bom)
heats = sum(1 for b in ch.bom if str(b.get("heat") or "").strip() or b.get("na"))
mtrs = sum(1 for b in ch.bom if b.get("mtr_asset_id"))
stage = ch.open_stage or (ch.stages[-1] if ch.stages else None)
keys = checklists.checklist_keys(bool(ch.dept.get("weld")), ch.templates or None)
ck = (stage or {}).get("checklist") or {}
done = sum(1 for k in keys if checklists.is_done(ck.get(k)))
filler = str((stage or {}).get("filler") or "").strip()
hints = {
"db_heats": f"{heats}/{nb} recorded",
"db_mtr": f"{mtrs}/{nb} MTRs attached",
"db_weldmap": f"{len(ch.welds)} welds on map",
"db_ck": f"checklist {done}/{len(keys)}" + (f" · filler {filler}" if filler else ""),
}
return [{**i, "hint": hints.get(i["key"])} for i in items]
def qc_extras(conn: Any, row: Mapping[str, Any]) -> dict[str, Any]:
"""The unit's Q.C. folder documents, its ITP summary and the packet's ITP hold warning. Outside the seal."""
from . import itp, qcdocs
unit_id = int(row["unit_id"])
r = conn.execute("SELECT project_id FROM units WHERE id = %s", (unit_id,)).fetchone()
found = itp.unit_itps(conn, [unit_id]).get(unit_id)
return {
"qc_folder_docs": qcdocs.packet_folder_docs(conn, unit_id, int(r[0]) if r else None),
"itp": itp.summary(found[0], found[1]) if found else None,
"itp_hold": itp.holds_for_packet(found[0], found[1], row["dept_code"]) if found else None,
}
def target_hrs(work_orders: Iterable[Mapping[str, Any]]) -> float:
"""The packet's target hours: the sum of its work orders' target hours (0 when none)."""
total = 0.0
+10 -1
View File
@@ -35,6 +35,7 @@ from ..policy.engine import MSG_FILED
from ..settings import SecretFileError
from ..text import LIMITS, clean_opt, reason_ok
from . import packets
from . import qc as qc_dom
log = logging.getLogger(__name__)
@@ -640,6 +641,7 @@ def _shop_texts(w: Write, a: policy.Action, to_dept: str, stamp: str | None = No
SHOP_TEXTS = frozenset(
{A.ASSIGN, A.UNASSIGN, A.PULL_BACK, A.START, A.SUBMIT, A.RETURN_TO_STATION, A.SEND_TO_QC, A.ROUTE_FROM_REVIEW}
)
QC_TEXTS = frozenset({A.QC_ACCEPT, A.QC_ACCEPT_AND_ROUTE, A.QC_REJECT})
def apply_transition(w: Write) -> None:
@@ -661,6 +663,8 @@ def apply_transition(w: Write) -> None:
stamp: str | None = None
if a in SHOP_TEXTS and a is not A.ASSIGN and a is not A.SUBMIT:
_shop_texts(w, a, to_dept) # before the side effects clear the station
if a in QC_TEXTS:
qc_dom.qc_texts(w, a, to_dept) # before the side effects close the inspection
if a in (A.SEND_TO_WAREHOUSE, A.VIS_AUTO_RELEASE):
_set_packet(w, "wh_skipped = false, wh_sent_at = now(), return_note = NULL")
@@ -748,7 +752,12 @@ def apply_transition(w: Write) -> None:
_open_stage(w, to_dept)
_set_packet(w, "station_id = NULL, welder_id = NULL, welder_stamp = NULL, return_note = NULL, routed_to = NULL")
elif a is A.QC_REJECT:
_set_result(w, _open_inspection(w), "rejected", reason)
# The reject closes the inspection into qc_history at once; the next SEND_TO_QC opens a fresh one.
insp = _open_inspection(w)
if insp is not None:
w.conn.execute("UPDATE qc_inspections SET ncr = %s WHERE id = %s", (ex.get("ncr") or None, insp["id"]))
insp = _set_result(w, insp, "rejected", reason)
_close_inspection(w, insp, from_dept)
_set_packet(w, "return_note = %s", (reason,))
if not packets.one(w.conn, "SELECT id FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (w.id,)):
_open_stage(w, from_dept) # a draft sent straight to Q.C. has no stage yet
+210
View File
@@ -0,0 +1,210 @@
"""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
from . import itp, packets, qcdocs
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"
)
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
) -> dict[str, Any]:
"""GET /api/qc/home: the KPIs, the three boards and the active project folders."""
filters: dict[str, Any] = {"project_id": project_id, "unit_id": unit_id}
ready = sorted(
packets.list_cards(conn, pp, {**filters, "status": "qc"}),
key=lambda c: (c["status_at"], c["id"]),
)
where, params = _scope_where(project_id, unit_id)
acc_ids = _ids_with_last(conn, ("complete", "filed"), "accepted", where, params)[:ACCEPTED_ON_BOARD]
rej_ids = _ids_with_last(conn, ("review",), "rejected", where, params)
by_id = {c["id"]: c for c in packets.list_cards(conn, pp, {"ids": acc_ids + rej_ids})} if acc_ids or rej_ids else {}
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)
return {
"kpis": {
"waiting": len(ready),
"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"),
}
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
+198
View File
@@ -0,0 +1,198 @@
"""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