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]>
This commit is contained in:
@@ -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 {}
|
||||
|
||||
@@ -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))
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user