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:
@@ -0,0 +1,744 @@
|
||||
"""The digital ITP: read, start, header, lines, sections, blocks, sign-off and reopen.
|
||||
|
||||
Reads need any session. Every write runs policy.engine.check_scoped with its action, then locks the ITP row; the
|
||||
answer is the full ITP read. ITP writes never look at a packet: a filed packet in the unit does not lock the ITP.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from psycopg.types.json import Jsonb
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .. import policy
|
||||
from ..domain import itp as dom
|
||||
from ..domain import ncr as ncrs
|
||||
from ..domain import packets
|
||||
from ..domain.itp_template import ITP_TEMPLATE
|
||||
from ..domain.pipeline import SignBody, apply_field_cas, jsonable, parse_if_match
|
||||
from ..text import LIMITS, clean_opt, clean_text
|
||||
from .deps import Db, Principal, current_principal, get_db
|
||||
from .errors import ApiError
|
||||
from .packets import WRITE
|
||||
|
||||
router = APIRouter(tags=["itp"])
|
||||
|
||||
A = policy.Action
|
||||
|
||||
HDR_MAX = 120
|
||||
CUST_BY_MAX = 80
|
||||
NOTE_MAX = 500
|
||||
QN_MAX = 40
|
||||
REMARKS_MAX = 400
|
||||
CUST_NAME_MAX = 80
|
||||
HDR_FIELDS = ("no", *dom.HDR_KEYS)
|
||||
|
||||
MSG_NO_LINE = "No such ITP line."
|
||||
MSG_NO_SECTION = "No such ITP section."
|
||||
MSG_NO_BLOCK = "No such ITP block."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- bodies
|
||||
|
||||
|
||||
class It(BaseModel):
|
||||
it_reason: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class HeaderBody(It):
|
||||
set: dict[str, str | None] = Field(max_length=10)
|
||||
base: dict[str, str | None] = Field(default_factory=dict, max_length=10)
|
||||
|
||||
|
||||
class LineBody(It):
|
||||
na: bool | None = None
|
||||
cust_code: str | None = Field(default=None, max_length=20)
|
||||
cust_by: str | None = Field(default=None, max_length=500)
|
||||
cust_at: date | None = None
|
||||
note: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
class SectionNaBody(It):
|
||||
na: bool
|
||||
|
||||
|
||||
class RejectBody(It):
|
||||
ncr: str | None = Field(default=None, max_length=100)
|
||||
note: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
class NcrBody(It):
|
||||
ncr: str = Field(max_length=100)
|
||||
|
||||
|
||||
class BlockBody(It):
|
||||
qn: str | None = Field(default=None, max_length=500)
|
||||
remarks: str | None = Field(default=None, max_length=2000)
|
||||
cust_name: str | None = Field(default=None, max_length=500)
|
||||
cust_at: date | None = None
|
||||
cust_clear: bool | None = None
|
||||
|
||||
|
||||
class SignBodyOnly(It):
|
||||
sign: SignBody | None = None
|
||||
|
||||
|
||||
class ReopenBody(It):
|
||||
reason: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- helpers
|
||||
|
||||
|
||||
def _pp(d: Db, p: Principal) -> policy.Principal:
|
||||
return packets.policy_principal_for(d.conn, p)
|
||||
|
||||
|
||||
def _answer(d: Db, p: Principal, itp_id: int) -> dict[str, Any]:
|
||||
row = dom.load_row(d.conn, itp_id)
|
||||
if row is None:
|
||||
raise ApiError(404, "not_found", dom.MSG_NO_ITP)
|
||||
return dom.read(d.conn, row, _pp(d, p))
|
||||
|
||||
|
||||
def _item(w: dom.ItpWrite, key: str) -> dict[str, Any]:
|
||||
it = next((i for i in dom.items(w.itp["template"] or {}) if str(i["n"]) == key), None)
|
||||
if it is None:
|
||||
raise ApiError(404, "not_found", MSG_NO_LINE)
|
||||
return it
|
||||
|
||||
|
||||
def _line(w: dom.ItpWrite, key: str) -> dict[str, Any]:
|
||||
_item(w, key)
|
||||
row = packets.one(
|
||||
w.conn, "SELECT * FROM itp_lines WHERE itp_id = %s AND key = %s FOR UPDATE", (w.id, key)
|
||||
) or packets.one(
|
||||
w.conn,
|
||||
"INSERT INTO itp_lines (itp_id, key) VALUES (%s, %s) RETURNING *",
|
||||
(w.id, key),
|
||||
)
|
||||
assert row is not None
|
||||
return row
|
||||
|
||||
|
||||
def _update_line(w: dom.ItpWrite, line_id: int, values: dict[str, Any]) -> None:
|
||||
sets = ", ".join(f"{k} = %s" for k in values)
|
||||
w.conn.execute(
|
||||
f"UPDATE itp_lines SET {sets}, row_version = row_version + 1 WHERE id = %s", # noqa: S608 (fixed field names)
|
||||
(*values.values(), line_id),
|
||||
)
|
||||
|
||||
|
||||
def _block(w: dom.ItpWrite, key: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
b = next((x for x in dom.blocks(w.itp["template"] or {}) if x["key"] == key), None)
|
||||
if b is None:
|
||||
raise ApiError(404, "not_found", MSG_NO_BLOCK)
|
||||
row = packets.one(
|
||||
w.conn, "SELECT * FROM itp_blocks WHERE itp_id = %s AND block_key = %s FOR UPDATE", (w.id, key)
|
||||
) or packets.one(
|
||||
w.conn,
|
||||
"INSERT INTO itp_blocks (itp_id, block_key) VALUES (%s, %s) RETURNING *",
|
||||
(w.id, key),
|
||||
)
|
||||
assert row is not None
|
||||
return b, row
|
||||
|
||||
|
||||
def _update_block(w: dom.ItpWrite, block_id: int, values: dict[str, Any]) -> None:
|
||||
sets = ", ".join(f"{k} = %s" for k in values)
|
||||
w.conn.execute(
|
||||
f"UPDATE itp_blocks SET {sets}, row_version = row_version + 1 WHERE id = %s", # noqa: S608 (fixed field names)
|
||||
(*values.values(), block_id),
|
||||
)
|
||||
|
||||
|
||||
def _lines(w: dom.ItpWrite) -> dict[str, dict[str, Any]]:
|
||||
return dom.load_lines(w.conn, w.id)
|
||||
|
||||
|
||||
def _is_admin(p: Principal) -> bool:
|
||||
return bool(p.is_admin)
|
||||
|
||||
|
||||
def _same_user(a: Any, b: Any) -> bool:
|
||||
return a is not None and b is not None and str(a) == str(b)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- read and start
|
||||
|
||||
|
||||
@router.get("/api/units/{unit_id}/itp")
|
||||
def get_itp(
|
||||
unit_id: int,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""The unit's ITP, or {"itp": null, "unit": {id, number}} when it has none. 404 for an unknown unit."""
|
||||
conn = d.conn
|
||||
unit = packets.one(conn, "SELECT id, number FROM v_units WHERE id = %s AND removed_at IS NULL", (unit_id,))
|
||||
if unit is None:
|
||||
raise ApiError(404, "not_found", "No such unit.")
|
||||
row = dom.row_for_unit(conn, unit_id)
|
||||
if row is None:
|
||||
return {"itp": None, "unit": {"id": unit["id"], "number": unit["number"]}}
|
||||
return dom.read(conn, row, _pp(d, p))
|
||||
|
||||
|
||||
@router.post("/api/units/{unit_id}/itp", dependencies=WRITE)
|
||||
def start_itp(
|
||||
unit_id: int,
|
||||
request: Request,
|
||||
body: It | None = None,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""Start the unit's ITP from the template: the template is frozen, the header filled from the unit and project."""
|
||||
body = body or It()
|
||||
conn = d.conn
|
||||
pp = _pp(d, p)
|
||||
decision, it_kept = dom.scoped_decision(p, pp, A.ITP_START, it_reason=body.it_reason)
|
||||
with conn.transaction():
|
||||
unit = packets.one(
|
||||
conn,
|
||||
"SELECT u.id, u.po, u.lot, p.number AS pno, p.name AS pname, p.customer, p.po AS project_po"
|
||||
" FROM units u JOIN projects p ON p.id = u.project_id"
|
||||
" WHERE u.id = %s AND u.removed_at IS NULL FOR UPDATE OF u",
|
||||
(unit_id,),
|
||||
)
|
||||
if unit is None:
|
||||
raise ApiError(404, "not_found", "No such unit.")
|
||||
if dom.row_for_unit(conn, unit_id) is not None:
|
||||
raise ApiError(409, "itp_exists", "This unit already has an ITP.")
|
||||
number = f"{unit['pno']}{unit['lot']}"
|
||||
no = f"{number}-ITP"
|
||||
hdr = {
|
||||
"client": unit["customer"] or "",
|
||||
"pname": unit["pname"] or "",
|
||||
"pno": unit["pno"] or "",
|
||||
"po": unit["po"] or unit["project_po"] or "",
|
||||
"unit": number,
|
||||
}
|
||||
template = jsonable(ITP_TEMPLATE)
|
||||
itp_id = int(
|
||||
conn.execute(
|
||||
"INSERT INTO itps (unit_id, template, status, no, hdr, created_by) VALUES (%s, %s, 'open', %s, %s, %s)"
|
||||
" RETURNING id",
|
||||
(unit_id, Jsonb(template), no, Jsonb(hdr), p.user_id),
|
||||
).fetchone()[0]
|
||||
)
|
||||
keys = [str(i["n"]) for i in dom.items(template)]
|
||||
with conn.cursor() as cur:
|
||||
cur.executemany("INSERT INTO itp_lines (itp_id, key) VALUES (%s, %s)", [(itp_id, k) for k in keys])
|
||||
cur.executemany(
|
||||
"INSERT INTO itp_blocks (itp_id, block_key) VALUES (%s, %s)",
|
||||
[(itp_id, b["key"]) for b in dom.blocks(template)],
|
||||
)
|
||||
row = dom.load_row(conn, itp_id, lock=True)
|
||||
assert row is not None
|
||||
w = dom.ItpWrite(
|
||||
conn=conn,
|
||||
principal=p,
|
||||
policy_principal=pp,
|
||||
itp=row,
|
||||
action=A.ITP_START,
|
||||
decision=decision,
|
||||
request=request,
|
||||
it_reason=it_kept,
|
||||
version=int(row["version"]),
|
||||
)
|
||||
w.log("ITP started", f"{no} · template {template.get('ver')}")
|
||||
dom._finish(w)
|
||||
return _answer(d, p, itp_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- header
|
||||
|
||||
|
||||
@router.patch("/api/itps/{itp_id}", dependencies=WRITE)
|
||||
def patch_header(
|
||||
itp_id: int,
|
||||
body: HeaderBody,
|
||||
request: Request,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""The plan number and header fields, with field-level compare-and-set (409 field_conflict)."""
|
||||
bad = sorted(k for k in body.set if k not in HDR_FIELDS)
|
||||
if bad:
|
||||
raise ApiError(422, "invalid_field", f"Not an ITP header field: {', '.join(bad)}.")
|
||||
values = {k: clean_text(v, HDR_MAX) for k, v in body.set.items()}
|
||||
base = {k: clean_text(v, HDR_MAX) for k, v in body.base.items() if k in HDR_FIELDS}
|
||||
with dom.itp_write(d, p, itp_id, A.ITP_EDIT_LINE, request=request, it_reason=body.it_reason) as w:
|
||||
hdr = dict(w.itp["hdr"] or {})
|
||||
current = {"no": w.itp["no"] or "", **{k: hdr.get(k) or "" for k in dom.HDR_KEYS}}
|
||||
changes = apply_field_cas(current, values, base)
|
||||
if "no" in changes and not changes["no"]:
|
||||
raise ApiError(422, "invalid_field", "The plan number cannot be empty.")
|
||||
if not changes:
|
||||
return _answer(d, p, itp_id)
|
||||
for k in HDR_FIELDS:
|
||||
if k not in changes:
|
||||
continue
|
||||
if k == "no":
|
||||
w.log("ITP plan no. changed", changes[k])
|
||||
else:
|
||||
hdr[k] = changes[k]
|
||||
w.log("ITP header changed", f"{k}: {changes[k]}")
|
||||
w.conn.execute(
|
||||
"UPDATE itps SET no = %s, hdr = %s WHERE id = %s", (changes.get("no", w.itp["no"]), Jsonb(hdr), itp_id)
|
||||
)
|
||||
return _answer(d, p, itp_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- lines
|
||||
|
||||
|
||||
@router.post("/api/itps/{itp_id}/lines/{key}/sign", dependencies=WRITE)
|
||||
def sign_line(
|
||||
itp_id: int,
|
||||
key: str,
|
||||
request: Request,
|
||||
body: It | None = None,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""Approve a line (Q.C. initials). Optional If-Match is the line's row_version."""
|
||||
body = body or It()
|
||||
if_match = parse_if_match(request)
|
||||
with dom.itp_write(d, p, itp_id, A.ITP_LINE_SIGN, request=request, it_reason=body.it_reason) as w:
|
||||
line = _line(w, key)
|
||||
if if_match is not None and int(line["row_version"]) != if_match:
|
||||
raise ApiError(
|
||||
409,
|
||||
"row_conflict",
|
||||
"Someone else changed this line. Reload and try again.",
|
||||
extra={"current": {"row_version": line["row_version"], "result": line["result"]}},
|
||||
)
|
||||
if line["result"] == "approve":
|
||||
raise ApiError(409, "already_signed", "This line is already approved.")
|
||||
if line["result"] == "na":
|
||||
raise ApiError(409, "line_na", "This line is marked N/A.")
|
||||
rejected = line["result"] == "reject"
|
||||
if rejected and not line["ncr"]:
|
||||
raise ApiError(409, "ncr_required", "Enter the NCR # before approving this line.")
|
||||
name = p.user["display_name"]
|
||||
w.conn.execute(
|
||||
"UPDATE itp_lines SET result = 'approve', by = %s, at = now(), initials = %s,"
|
||||
" row_version = row_version + 1 WHERE id = %s",
|
||||
(p.user_id, dom.initials(name), line["id"]),
|
||||
)
|
||||
w.log(f"ITP line {key} approved", f"after NCR {line['ncr']}" if rejected else None)
|
||||
return _answer(d, p, itp_id)
|
||||
|
||||
|
||||
@router.post("/api/itps/{itp_id}/lines/{key}/unsign", dependencies=WRITE)
|
||||
def unsign_line(
|
||||
itp_id: int,
|
||||
key: str,
|
||||
request: Request,
|
||||
body: It | None = None,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""Remove an approval: the inspector who initialed it, or an admin, while the line's block is not signed."""
|
||||
body = body or It()
|
||||
with dom.itp_write(d, p, itp_id, A.ITP_LINE_UNSIGN, request=request, it_reason=body.it_reason) as w:
|
||||
line = _line(w, key)
|
||||
if line["result"] != "approve":
|
||||
raise ApiError(409, "not_signed", "This line is not approved.")
|
||||
if not (_same_user(line["by"], p.user_id) or _is_admin(p)):
|
||||
raise ApiError(403, "forbidden", "Only the inspector who initialed this line, or an admin, can remove it.")
|
||||
blk = dom.block_of_line(w.itp["template"] or {}, key)
|
||||
if blk is not None:
|
||||
brow = packets.one(
|
||||
w.conn, "SELECT signed_at FROM itp_blocks WHERE itp_id = %s AND block_key = %s", (w.id, blk["key"])
|
||||
)
|
||||
if brow is not None and brow["signed_at"] is not None:
|
||||
raise ApiError(409, "block_signed", "Undo the section signature first.")
|
||||
who = packets.People(w.conn, [line["by"]]).name(line["by"]) or ""
|
||||
was = f"was {who} {dom.date_text(line['at'], w.tz)}".strip()
|
||||
w.conn.execute(
|
||||
"UPDATE itp_lines SET result = NULL, by = NULL, at = NULL, initials = NULL,"
|
||||
" row_version = row_version + 1 WHERE id = %s",
|
||||
(line["id"],),
|
||||
)
|
||||
w.log(f"ITP line {key} initial removed", was)
|
||||
return _answer(d, p, itp_id)
|
||||
|
||||
|
||||
@router.patch("/api/itps/{itp_id}/lines/{key}", dependencies=WRITE)
|
||||
def patch_line(
|
||||
itp_id: int,
|
||||
key: str,
|
||||
body: LineBody,
|
||||
request: Request,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""N/A, the customer code and witness, and the note of a line."""
|
||||
given = body.model_fields_set
|
||||
cc: str | None = None
|
||||
if "cust_code" in given:
|
||||
cc = clean_text(body.cust_code, 20).upper()
|
||||
if cc and cc not in dom.CUST_CODES:
|
||||
raise ApiError(422, "invalid_field", "Pick a customer code from the list.")
|
||||
with dom.itp_write(d, p, itp_id, A.ITP_EDIT_LINE, request=request, it_reason=body.it_reason) as w:
|
||||
line = _line(w, key)
|
||||
values: dict[str, Any] = {}
|
||||
parts: list[str] = []
|
||||
if "na" in given and body.na is not None and body.na != (line["result"] == "na"):
|
||||
if body.na:
|
||||
if line["result"] == "approve":
|
||||
raise ApiError(409, "already_signed", "Undo the approval first.")
|
||||
if line["result"] == "reject":
|
||||
raise ApiError(409, "line_rejected", "This line is rejected.")
|
||||
values["result"] = "na"
|
||||
parts.append("marked N/A")
|
||||
else:
|
||||
values["result"] = None
|
||||
parts.append("applies again")
|
||||
if "cust_code" in given and (cc or None) != (line["cust_code"] or None):
|
||||
values["cust_code"] = cc or None
|
||||
parts.append(f"customer code {cc}" if cc else "customer code as template")
|
||||
if "cust_by" in given:
|
||||
who = clean_opt(body.cust_by, CUST_BY_MAX)
|
||||
if who != (line["cust_by"] or None):
|
||||
values["cust_by"] = who
|
||||
parts.append(f"customer {who}" if who else "customer cleared")
|
||||
if who:
|
||||
values["cust_at"] = body.cust_at or dom.today(w.tz)
|
||||
values["cust_rec"] = p.user_id
|
||||
values["cust_rec_at"] = w.conn.execute("SELECT now()").fetchone()[0]
|
||||
else:
|
||||
values.update({"cust_at": None, "cust_rec": None, "cust_rec_at": None})
|
||||
elif who and "cust_at" in given and body.cust_at and body.cust_at != line["cust_at"]:
|
||||
values["cust_at"] = body.cust_at
|
||||
parts.append(f"customer date {dom.date_text(body.cust_at)}")
|
||||
elif "cust_at" in given and line["cust_by"] and body.cust_at and body.cust_at != line["cust_at"]:
|
||||
values["cust_at"] = body.cust_at
|
||||
parts.append(f"customer date {dom.date_text(body.cust_at)}")
|
||||
if "note" in given:
|
||||
note = clean_opt(body.note, NOTE_MAX)
|
||||
if note != (line["note"] or None):
|
||||
values["note"] = note
|
||||
parts.append("note")
|
||||
if not values:
|
||||
return _answer(d, p, itp_id)
|
||||
_update_line(w, line["id"], values)
|
||||
w.log(f"ITP line {key} updated", ", ".join(parts))
|
||||
return _answer(d, p, itp_id)
|
||||
|
||||
|
||||
@router.post("/api/itps/{itp_id}/sections/{n}/na", dependencies=WRITE)
|
||||
def section_na(
|
||||
itp_id: int,
|
||||
n: str,
|
||||
body: SectionNaBody,
|
||||
request: Request,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""Mark every line of a section N/A, or make it apply again. Refused once a line of it is approved."""
|
||||
with dom.itp_write(d, p, itp_id, A.ITP_EDIT_LINE, request=request, it_reason=body.it_reason) as w:
|
||||
sec = next((s for s in (w.itp["template"] or {}).get("sections") or [] if str(s.get("n")) == n), None)
|
||||
if sec is None:
|
||||
raise ApiError(404, "not_found", MSG_NO_SECTION)
|
||||
rows = [_line(w, str(i["n"])) for i in sec.get("items") or []]
|
||||
if any(r["result"] == "approve" for r in rows):
|
||||
raise ApiError(409, "already_signed", "A line of this section is approved. Undo it first.")
|
||||
if body.na and any(r["result"] == "reject" for r in rows):
|
||||
raise ApiError(409, "line_rejected", "A line of this section is rejected.")
|
||||
want = "na" if body.na else None
|
||||
todo = [r for r in rows if (r["result"] == "na") != body.na]
|
||||
if not todo:
|
||||
return _answer(d, p, itp_id)
|
||||
for r in todo:
|
||||
_update_line(w, r["id"], {"result": want})
|
||||
w.log(f"ITP section {n} {'marked N/A' if body.na else 'applies again'}", str(sec.get("t") or ""))
|
||||
return _answer(d, p, itp_id)
|
||||
|
||||
|
||||
@router.post("/api/itps/{itp_id}/lines/{key}/reject", dependencies=WRITE)
|
||||
def reject_line(
|
||||
itp_id: int,
|
||||
key: str,
|
||||
body: RejectBody,
|
||||
request: Request,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""Reject a line, with its NCR number now or later. The line cannot be approved until the NCR is entered."""
|
||||
ncr = ncrs.validate_ncr(body.ncr) if clean_text(body.ncr, 100) else None
|
||||
note = clean_opt(body.note, NOTE_MAX)
|
||||
with dom.itp_write(d, p, itp_id, A.ITP_EDIT_LINE, request=request, it_reason=body.it_reason) as w:
|
||||
line = _line(w, key)
|
||||
if line["result"] == "approve":
|
||||
raise ApiError(409, "already_signed", "This line is approved. Undo the approval first.")
|
||||
if line["result"] == "na":
|
||||
raise ApiError(409, "line_na", "This line is marked N/A.")
|
||||
if line["result"] == "reject":
|
||||
raise ApiError(409, "line_rejected", "This line is already rejected.")
|
||||
_update_line(w, line["id"], {"result": "reject", "ncr": ncr})
|
||||
w.conn.execute(
|
||||
"INSERT INTO itp_rejects (itp_id, line_key, ncr, note, by, kind) VALUES (%s, %s, %s, %s, %s, 'reject')",
|
||||
(w.id, key, ncr, note, p.user_id),
|
||||
)
|
||||
head = f"NCR {ncr}" if ncr else "NCR # pending"
|
||||
w.log(f"ITP line {key} rejected", f"{head} · {note}" if note else head)
|
||||
return _answer(d, p, itp_id)
|
||||
|
||||
|
||||
@router.post("/api/itps/{itp_id}/lines/{key}/ncr", dependencies=WRITE)
|
||||
def enter_ncr(
|
||||
itp_id: int,
|
||||
key: str,
|
||||
body: NcrBody,
|
||||
request: Request,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""The NCR number of an open reject that has none yet."""
|
||||
ncr = ncrs.validate_ncr(body.ncr)
|
||||
with dom.itp_write(d, p, itp_id, A.ITP_EDIT_LINE, request=request, it_reason=body.it_reason) as w:
|
||||
line = _line(w, key)
|
||||
if line["result"] != "reject" or line["ncr"]:
|
||||
raise ApiError(409, "no_open_reject", "This line has no open reject waiting for an NCR #.")
|
||||
_update_line(w, line["id"], {"ncr": ncr})
|
||||
w.conn.execute(
|
||||
"INSERT INTO itp_rejects (itp_id, line_key, ncr, by, kind) VALUES (%s, %s, %s, %s, 'ncr')",
|
||||
(w.id, key, ncr, p.user_id),
|
||||
)
|
||||
w.log(f"ITP line {key} NCR number entered", f"NCR {ncr}")
|
||||
return _answer(d, p, itp_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- blocks
|
||||
|
||||
|
||||
@router.patch("/api/itps/{itp_id}/blocks/{key}", dependencies=WRITE)
|
||||
def patch_block(
|
||||
itp_id: int,
|
||||
key: str,
|
||||
body: BlockBody,
|
||||
request: Request,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""QN #, remarks and the customer complete record of a block. No signature."""
|
||||
given = body.model_fields_set
|
||||
with dom.itp_write(d, p, itp_id, A.ITP_EDIT_LINE, request=request, it_reason=body.it_reason) as w:
|
||||
_b, row = _block(w, key)
|
||||
values: dict[str, Any] = {}
|
||||
if "qn" in given:
|
||||
qn = clean_opt(body.qn, QN_MAX)
|
||||
if qn != (row["qn"] or None):
|
||||
values["qn"] = qn
|
||||
w.log(f"ITP block {key} QN # changed", qn or "")
|
||||
if "remarks" in given:
|
||||
rem = clean_opt(body.remarks, REMARKS_MAX)
|
||||
if rem != (row["remarks"] or None):
|
||||
values["remarks"] = rem
|
||||
w.log(f"ITP block {key} remarks changed", rem or "")
|
||||
if body.cust_clear:
|
||||
if row["cust_name"] or row["cust_at"]:
|
||||
values.update({"cust_name": None, "cust_at": None, "cust_by": None, "cust_rec_at": None})
|
||||
w.log(f"ITP block {key} customer complete cleared", row["cust_name"] or "")
|
||||
elif "cust_name" in given:
|
||||
name = clean_opt(body.cust_name, CUST_NAME_MAX)
|
||||
if name is None:
|
||||
raise ApiError(422, "invalid_field", "Enter the customer rep's name.")
|
||||
at = body.cust_at or dom.today(w.tz)
|
||||
if name != row["cust_name"] or at != row["cust_at"]:
|
||||
values.update(
|
||||
{
|
||||
"cust_name": name,
|
||||
"cust_at": at,
|
||||
"cust_by": p.user_id,
|
||||
"cust_rec_at": w.conn.execute("SELECT now()").fetchone()[0],
|
||||
}
|
||||
)
|
||||
w.log(f"ITP block {key} customer complete recorded", name)
|
||||
if not values:
|
||||
return _answer(d, p, itp_id)
|
||||
_update_block(w, row["id"], values)
|
||||
return _answer(d, p, itp_id)
|
||||
|
||||
|
||||
@router.post("/api/itps/{itp_id}/blocks/{key}/sign", dependencies=WRITE)
|
||||
def sign_block(
|
||||
itp_id: int,
|
||||
key: str,
|
||||
body: SignBodyOnly,
|
||||
request: Request,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""Sign a block of sections. Every line in it must be approved or N/A."""
|
||||
conn = d.conn
|
||||
row0 = dom.load_row(conn, itp_id)
|
||||
if row0 is None:
|
||||
raise ApiError(404, "not_found", dom.MSG_NO_ITP)
|
||||
# Who first, then the ready and unsigned checks before the signature, so a refused step costs no PIN attempt.
|
||||
# Both run again under the lock below.
|
||||
dom.scoped_decision(p, _pp(d, p), A.ITP_BLOCK_SIGN, it_reason=body.it_reason)
|
||||
_block_gate(conn, row0, key)
|
||||
with dom.itp_write(
|
||||
d,
|
||||
p,
|
||||
itp_id,
|
||||
A.ITP_BLOCK_SIGN,
|
||||
request=request,
|
||||
it_reason=body.it_reason,
|
||||
sign=body.sign,
|
||||
sign_extra={"block_key": key},
|
||||
) as w:
|
||||
b, row = _block(w, key)
|
||||
_block_gate(conn, w.itp, key)
|
||||
_update_block(
|
||||
w,
|
||||
row["id"],
|
||||
{
|
||||
"signed_at": conn.execute("SELECT now()").fetchone()[0],
|
||||
"signed_by": p.user_id,
|
||||
"signature_id": w.signature.id if w.signature else None,
|
||||
},
|
||||
)
|
||||
w.log(f"ITP block {key} signed", dom.block_scope(b))
|
||||
return _answer(d, p, itp_id)
|
||||
|
||||
|
||||
def _block_gate(conn: Any, itp_row: dict[str, Any], key: str) -> None:
|
||||
if str(itp_row["status"]) == "complete":
|
||||
raise ApiError(409, "itp_complete", dom.MSG_COMPLETE)
|
||||
b = next((x for x in dom.blocks(itp_row["template"] or {}) if x["key"] == key), None)
|
||||
if b is None:
|
||||
raise ApiError(404, "not_found", MSG_NO_BLOCK)
|
||||
brow = packets.one(
|
||||
conn, "SELECT signed_at FROM itp_blocks WHERE itp_id = %s AND block_key = %s", (itp_row["id"], key)
|
||||
)
|
||||
if brow is not None and brow["signed_at"] is not None:
|
||||
raise ApiError(409, "already_signed", "This block is already signed.")
|
||||
if not dom.block_ready(b, dom.load_lines(conn, int(itp_row["id"]))):
|
||||
raise ApiError(409, "block_not_ready", "Every line must be initialed or N/A first.")
|
||||
|
||||
|
||||
@router.delete("/api/itps/{itp_id}/blocks/{key}/sign", dependencies=WRITE)
|
||||
def unsign_block(
|
||||
itp_id: int,
|
||||
key: str,
|
||||
request: Request,
|
||||
body: It | None = None,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""Remove a block signature: the signer or an admin, while the ITP is open. The block's fields stay."""
|
||||
body = body or It()
|
||||
with dom.itp_write(d, p, itp_id, A.ITP_BLOCK_UNSIGN, request=request, it_reason=body.it_reason) as w:
|
||||
_b, row = _block(w, key)
|
||||
if row["signed_at"] is None:
|
||||
raise ApiError(409, "not_signed", "This block is not signed.")
|
||||
if not (_same_user(row["signed_by"], p.user_id) or _is_admin(p)):
|
||||
raise ApiError(403, "forbidden", "Only the inspector who signed this block, or an admin, can remove it.")
|
||||
_update_block(w, row["id"], {"signed_at": None, "signed_by": None, "signature_id": None})
|
||||
w.log(f"ITP block {key} signature removed")
|
||||
return _answer(d, p, itp_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- sign-off and reopen
|
||||
|
||||
|
||||
@router.post("/api/itps/{itp_id}/signoff", dependencies=WRITE)
|
||||
def sign_off(
|
||||
itp_id: int,
|
||||
body: SignBodyOnly,
|
||||
request: Request,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""Sign the ITP off: every block signed. The ITP locks; the PDF hook (phase 7) may file a copy."""
|
||||
from ..pdf import itp as itp_pdf
|
||||
|
||||
conn = d.conn
|
||||
row0 = dom.load_row(conn, itp_id)
|
||||
if row0 is None:
|
||||
raise ApiError(404, "not_found", dom.MSG_NO_ITP)
|
||||
dom.scoped_decision(p, _pp(d, p), A.ITP_SIGN_OFF, it_reason=body.it_reason)
|
||||
_signoff_gate(conn, row0)
|
||||
with dom.itp_write(d, p, itp_id, A.ITP_SIGN_OFF, request=request, it_reason=body.it_reason, sign=body.sign) as w:
|
||||
_signoff_gate(conn, w.itp)
|
||||
conn.execute(
|
||||
"UPDATE itps SET status = 'complete', signoff_at = now(), signoff_by = %s, signoff_signature_id = %s"
|
||||
" WHERE id = %s",
|
||||
(p.user_id, w.signature.id if w.signature else None, itp_id),
|
||||
)
|
||||
row = dom.load_row(conn, itp_id)
|
||||
assert row is not None
|
||||
asset_id = itp_pdf.file_signed_itp_pdf(conn, row, p.actor)
|
||||
if asset_id is not None:
|
||||
_file_auto_doc(w, row, asset_id)
|
||||
w.log("ITP signed off", "PDF filed")
|
||||
else:
|
||||
w.log("ITP signed off", "PDF filing reserved for phase 7")
|
||||
return _answer(d, p, itp_id)
|
||||
|
||||
|
||||
def _signoff_gate(conn: Any, itp_row: dict[str, Any]) -> None:
|
||||
if str(itp_row["status"]) == "complete":
|
||||
raise ApiError(409, "itp_complete", dom.MSG_COMPLETE)
|
||||
keys = [b["key"] for b in dom.blocks(itp_row["template"] or {})]
|
||||
signed = {
|
||||
r[0]
|
||||
for r in conn.execute(
|
||||
"SELECT block_key FROM itp_blocks WHERE itp_id = %s AND signed_at IS NOT NULL", (itp_row["id"],)
|
||||
)
|
||||
}
|
||||
if not keys or any(k not in signed for k in keys):
|
||||
raise ApiError(409, "blocks_unsigned", "Every section must be signed first.")
|
||||
|
||||
|
||||
AUTO_NOTE = "Filed automatically when the digital ITP was signed off."
|
||||
|
||||
|
||||
def _file_auto_doc(w: dom.ItpWrite, row: dict[str, Any], asset_id: Any) -> None:
|
||||
"""Store the signed PDF on the ITP and file it in the unit's Q.C. folder, superseding the earlier automatic copy."""
|
||||
conn = w.conn
|
||||
conn.execute("UPDATE itps SET signoff_asset_id = %s WHERE id = %s", (asset_id, row["id"]))
|
||||
new_id = conn.execute(
|
||||
"INSERT INTO qc_docs (unit_id, type, title, rev, note, asset_id, auto, added_by)"
|
||||
" VALUES (%s, 'ITP', %s, NULL, %s, %s, true, %s) RETURNING id",
|
||||
(row["unit_id"], f"{row['no']} — signed off", AUTO_NOTE, asset_id, w.user_id),
|
||||
).fetchone()[0]
|
||||
conn.execute(
|
||||
"UPDATE qc_docs SET superseded_by = %s, superseded_at = now(), superseded_by_user = %s"
|
||||
" WHERE unit_id = %s AND type = 'ITP' AND auto AND superseded_by IS NULL AND id <> %s",
|
||||
(new_id, w.user_id, row["unit_id"], new_id),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/itps/{itp_id}/reopen", dependencies=WRITE)
|
||||
def reopen(
|
||||
itp_id: int,
|
||||
body: ReopenBody,
|
||||
request: Request,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""Reopen a signed-off ITP: a Q.C. supervisor, or an admin (the reason serves as the IT reason when none is given).
|
||||
The blocks and lines keep their signatures."""
|
||||
reason = clean_opt(body.reason, LIMITS["reason"])
|
||||
it_reason = body.it_reason or (body.reason if p.is_admin else None)
|
||||
extra = {"reason": reason} if reason else {}
|
||||
with dom.itp_write(
|
||||
d, p, itp_id, A.ITP_REOPEN, request=request, extra=extra, it_reason=it_reason, allow_complete=True
|
||||
) as w:
|
||||
if str(w.itp["status"]) != "complete":
|
||||
raise ApiError(409, "itp_open", "The ITP is open already.")
|
||||
w.conn.execute(
|
||||
"UPDATE itps SET status = 'open', reopen_at = now(), reopen_by = %s, reopen_reason = %s WHERE id = %s",
|
||||
(p.user_id, reason, itp_id),
|
||||
)
|
||||
w.log("ITP reopened", reason)
|
||||
return _answer(d, p, itp_id)
|
||||
@@ -21,6 +21,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from .. import policy
|
||||
from ..domain import checklists, packets
|
||||
from ..domain import qc as qc_dom
|
||||
from ..domain.children import NEVER_VERIFY, active_depts, co_touched, current_wh, update_row, wh_move
|
||||
from ..domain.pipeline import (
|
||||
SignBody,
|
||||
@@ -260,16 +261,29 @@ class WeldExamIn(BaseModel):
|
||||
|
||||
|
||||
class QcBody(It):
|
||||
"""`{set, base}` for field-level compare-and-set of the scalar fields, or the phase 2 flat body with If-Match on
|
||||
the inspection's row_version (checks and weld_exam merge per key)."""
|
||||
|
||||
checks: dict[str, bool] | None = Field(default=None, max_length=50)
|
||||
weld_exam: dict[str, WeldExamIn] | None = Field(default=None, max_length=2000)
|
||||
nde_method: str | None = Field(default=None, max_length=500)
|
||||
nde_result: str | None = Field(default=None, max_length=500)
|
||||
nde_report: str | None = Field(default=None, max_length=500)
|
||||
hardness: str | None = Field(default=None, max_length=500)
|
||||
nace: bool | None = None
|
||||
inspector: str | None = Field(default=None, max_length=500)
|
||||
stamp: str | None = Field(default=None, max_length=40)
|
||||
notes: str | None = Field(default=None, max_length=5000)
|
||||
set: dict[str, str | None] | None = Field(default=None, max_length=10)
|
||||
base: dict[str, str | None] | None = Field(default=None, max_length=10)
|
||||
|
||||
|
||||
class QcCheckBody(It):
|
||||
done: bool
|
||||
|
||||
|
||||
class QcWeldBody(It):
|
||||
result: Literal["accept", "reject", ""] | None = None
|
||||
note: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
class CoItemIn(BaseModel):
|
||||
@@ -1457,6 +1471,143 @@ def set_wh_stage(
|
||||
# ---------------------------------------------------------------- Q.C.
|
||||
|
||||
|
||||
QC_FIELDS = ("inspector", "stamp", "nde_method", "nde_result", "nde_report", "hardness", "notes")
|
||||
QC_NOTE_NAMES = {"inspector": "inspector", "stamp": "stamp", "hardness": "hardness", "nde_report": "report"}
|
||||
|
||||
|
||||
def _open_inspection(conn: Any, packet_id: int) -> dict[str, Any]:
|
||||
insp = packets.one(conn, "SELECT * FROM qc_inspections WHERE packet_id = %s AND closed_at IS NULL", (packet_id,))
|
||||
if insp is None:
|
||||
raise ApiError(409, "no_inspection", MSG_NO_INSPECTION)
|
||||
return insp
|
||||
|
||||
|
||||
def _qc_value(key: str, value: Any) -> Any:
|
||||
"""One scalar Q.C. field, cleaned and checked."""
|
||||
if key == "notes":
|
||||
return clean_opt(value, LIMITS["notes"])
|
||||
if key == "stamp":
|
||||
return _stamp(value)
|
||||
text = clean_opt(value, QC_FIELD_MAX)
|
||||
if key == "nde_method" and text is not None and text not in qc_dom.NDE_METHODS:
|
||||
raise ApiError(422, "invalid_field", "Pick an NDE method from the list.")
|
||||
if key == "nde_result" and text is not None and text not in qc_dom.NDE_RESULTS:
|
||||
raise ApiError(422, "invalid_field", "Pick an NDE result from the list.")
|
||||
return text
|
||||
|
||||
|
||||
def _qc_logs(w: Write, changes: dict[str, Any]) -> None:
|
||||
"""The log lines of a Q.C. field change: "NDE method: RT", "NDE result: Acceptable", then "Q.C. record changed"
|
||||
with the other fields. A method set to None required clears the result and report without a line of their own."""
|
||||
none = changes.get("nde_method") == qc_dom.NDE_NONE
|
||||
lines: list[tuple[str, str | None]] = []
|
||||
if "nde_method" in changes:
|
||||
lines.append((f"NDE method: {changes['nde_method'] or '-'}", None))
|
||||
if "nde_result" in changes and not (none and not changes["nde_result"]):
|
||||
lines.append((f"NDE result: {changes['nde_result'] or '-'}", None))
|
||||
parts: list[str] = []
|
||||
for k in ("inspector", "stamp", "hardness", "nde_report", "notes"):
|
||||
if k not in changes or (k == "nde_report" and none and not changes[k]):
|
||||
continue
|
||||
v = changes[k]
|
||||
if k == "notes":
|
||||
parts.append("notes changed" if v else "notes cleared")
|
||||
else:
|
||||
parts.append(f"{QC_NOTE_NAMES[k]} {v}" if v else f"{QC_NOTE_NAMES[k]} cleared")
|
||||
if parts:
|
||||
lines.append(("Q.C. record changed", " · ".join(parts)))
|
||||
if not lines:
|
||||
lines.append(("Q.C. record changed", None))
|
||||
w.log(*lines[0])
|
||||
for more in lines[1:]:
|
||||
w.log_also(*more)
|
||||
|
||||
|
||||
@router.put("/{packet_id}/qc/checks/{key}", dependencies=WRITE)
|
||||
def put_qc_check(
|
||||
packet_id: int,
|
||||
key: str,
|
||||
body: QcCheckBody,
|
||||
request: Request,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""Tick or untick one Q.C. check. Per-key writes never conflict, so there is no If-Match."""
|
||||
pp = _pp(d, p)
|
||||
conn = d.conn
|
||||
with packet_write(d, p, packet_id, A.EDIT_QC, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
|
||||
items = w.children.qc_items if w.children is not None and w.children.qc_items else None
|
||||
if key not in checklists.qc_keys(items):
|
||||
raise ApiError(422, "invalid_key", "That is not a Q.C. check item.")
|
||||
insp = _open_inspection(conn, packet_id)
|
||||
if checklists.is_done((insp["checks"] or {}).get(key)) == body.done:
|
||||
w.skip()
|
||||
return _read(d, pp, packet_id)
|
||||
if body.done:
|
||||
conn.execute(
|
||||
"UPDATE qc_inspections SET checks = jsonb_set(checks, ARRAY[%s]::text[],"
|
||||
" jsonb_build_object('done', true, 'at', now(), 'by', %s::text, 'stamp', %s::text)),"
|
||||
" row_version = row_version + 1 WHERE id = %s",
|
||||
(key, str(w.user_id), insp["stamp"], insp["id"]),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE qc_inspections SET checks = jsonb_set(checks, ARRAY[%s]::text[], %s),"
|
||||
" row_version = row_version + 1 WHERE id = %s",
|
||||
(key, Jsonb({"done": False}), insp["id"]),
|
||||
)
|
||||
pk = w.packet
|
||||
texts = checklists.qc_items_for(pk["drawing"], pk["rev"], pk["wps"], pk["welder_stamp"], items)
|
||||
text = next((i["text"] for i in texts if i["key"] == key), key)
|
||||
w.changed(f"qc.checks.{key}")
|
||||
w.log(("Q.C. checked: " if body.done else "Q.C. unchecked: ") + text)
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
@router.put("/{packet_id}/qc/welds/{no}", dependencies=WRITE)
|
||||
def put_qc_weld(
|
||||
packet_id: int,
|
||||
no: int,
|
||||
body: QcWeldBody,
|
||||
request: Request,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""The visual exam of one weld: accept, reject or cleared (empty), and a note."""
|
||||
given = body.model_fields_set
|
||||
pp = _pp(d, p)
|
||||
conn = d.conn
|
||||
with packet_write(d, p, packet_id, A.EDIT_QC, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
|
||||
live = conn.execute(
|
||||
"SELECT 1 FROM welds WHERE packet_id = %s AND weld_no = %s AND removed_at IS NULL", (packet_id, no)
|
||||
).fetchone()
|
||||
if live is None:
|
||||
raise ApiError(422, "invalid_weld", "No such weld on this packet.")
|
||||
insp = _open_inspection(conn, packet_id)
|
||||
k = str(no)
|
||||
cur = dict((insp["weld_exam"] or {}).get(k) or {})
|
||||
result = (body.result or "") if "result" in given else str(cur.get("result") or "")
|
||||
note = clean_text(body.note, LIMITS["note"]) if "note" in given else str(cur.get("note") or "")
|
||||
result_changed = result != str(cur.get("result") or "")
|
||||
note_changed = note != str(cur.get("note") or "")
|
||||
if not result_changed and not note_changed:
|
||||
w.skip()
|
||||
return _read(d, pp, packet_id)
|
||||
conn.execute(
|
||||
"UPDATE qc_inspections SET weld_exam = jsonb_set(weld_exam, ARRAY[%s]::text[],"
|
||||
" %s::jsonb || jsonb_build_object('at', now(), 'by', %s::text)), row_version = row_version + 1"
|
||||
" WHERE id = %s",
|
||||
(k, Jsonb({"result": result, "note": note}), str(w.user_id), insp["id"]),
|
||||
)
|
||||
label = qc_dom.weld_label(no)
|
||||
w.changed(f"qc.weld_exam.{k}")
|
||||
if result_changed:
|
||||
w.log(f"VT {result} · {label}" if result else f"VT cleared · {label}", note if result and note else None)
|
||||
else:
|
||||
w.log("Q.C. record changed", f"{label} note")
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
@router.patch("/{packet_id}/qc", dependencies=WRITE)
|
||||
def patch_qc(
|
||||
packet_id: int,
|
||||
@@ -1465,36 +1616,50 @@ def patch_qc(
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""The open inspection. checks and weld_exam merge per key; If-Match is the inspection's row_version."""
|
||||
if_match = parse_if_match(request)
|
||||
given = body.model_fields_set
|
||||
if body.checks:
|
||||
bad = sorted(set(body.checks) - set(checklists.qc_keys()))
|
||||
if bad:
|
||||
raise ApiError(422, "invalid_key", f"Not a Q.C. item: {', '.join(bad)}.")
|
||||
"""The open inspection's fields.
|
||||
|
||||
`{set, base}`: inspector, stamp, nde_method, nde_result, nde_report, hardness and notes with field-level
|
||||
compare-and-set (409 field_conflict with `current`). The phase 2 flat body with If-Match (the inspection's
|
||||
row_version) still works and merges checks and weld_exam per key. Setting the NDE method to None required clears
|
||||
the result and the report. The NACE flag is not written: the read model computes it from the WPS.
|
||||
"""
|
||||
cas = body.set is not None
|
||||
values: dict[str, Any] = {}
|
||||
for k in ("nde_method", "nde_result", "nde_report", "hardness", "inspector"):
|
||||
if k in given:
|
||||
values[k] = clean_opt(getattr(body, k), QC_FIELD_MAX)
|
||||
if "notes" in given:
|
||||
values["notes"] = clean_opt(body.notes, LIMITS["notes"])
|
||||
if "stamp" in given:
|
||||
values["stamp"] = _stamp(body.stamp)
|
||||
if "nace" in given:
|
||||
values["nace"] = body.nace
|
||||
base: dict[str, Any] = {}
|
||||
if_match: int | None = None
|
||||
if cas:
|
||||
assert body.set is not None
|
||||
bad = sorted(k for k in body.set if k not in QC_FIELDS)
|
||||
if bad:
|
||||
raise ApiError(422, "invalid_field", f"Not a Q.C. field: {', '.join(bad)}.")
|
||||
values = {k: _qc_value(k, v) for k, v in body.set.items()}
|
||||
for k, v in (body.base or {}).items():
|
||||
if k in QC_FIELDS:
|
||||
base[k] = clean_opt(v, LIMITS["notes"] if k == "notes" else QC_FIELD_MAX)
|
||||
else:
|
||||
given = body.model_fields_set
|
||||
if body.checks:
|
||||
bad = sorted(set(body.checks) - set(checklists.qc_keys(checklists.load_qc_items(d.conn))))
|
||||
if bad:
|
||||
raise ApiError(422, "invalid_key", f"Not a Q.C. item: {', '.join(bad)}.")
|
||||
values = {k: _qc_value(k, getattr(body, k)) for k in QC_FIELDS if k in given}
|
||||
if_match = parse_if_match(request)
|
||||
if values.get("nde_method") == qc_dom.NDE_NONE:
|
||||
values["nde_result"] = None
|
||||
values["nde_report"] = None
|
||||
pp = _pp(d, p)
|
||||
conn = d.conn
|
||||
with packet_write(d, p, packet_id, A.EDIT_QC, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
|
||||
insp = packets.one(
|
||||
conn, "SELECT * FROM qc_inspections WHERE packet_id = %s AND closed_at IS NULL", (packet_id,)
|
||||
)
|
||||
if insp is None:
|
||||
raise ApiError(409, "no_inspection", MSG_NO_INSPECTION)
|
||||
check_row_version(insp, if_match)
|
||||
changes = _diff(insp, values)
|
||||
insp = _open_inspection(conn, packet_id)
|
||||
if cas:
|
||||
changes = apply_field_cas({k: insp[k] for k in QC_FIELDS}, values, base)
|
||||
else:
|
||||
check_row_version(insp, if_match)
|
||||
changes = _diff(insp, values)
|
||||
scalar = dict(changes)
|
||||
at = conn.execute("SELECT now()").fetchone()[0].isoformat()
|
||||
by = str(w.user_id)
|
||||
if body.checks:
|
||||
if not cas and body.checks:
|
||||
stamp = values.get("stamp", insp["stamp"])
|
||||
checks = dict(insp["checks"] or {})
|
||||
for k, done in body.checks.items():
|
||||
@@ -1503,7 +1668,7 @@ def patch_qc(
|
||||
checks[k] = {"done": True, "at": at, "by": by, "stamp": stamp} if done else {"done": False}
|
||||
if checks != (insp["checks"] or {}):
|
||||
changes["checks"] = Jsonb(checks)
|
||||
if body.weld_exam:
|
||||
if not cas and body.weld_exam:
|
||||
live = {
|
||||
str(r[0])
|
||||
for r in conn.execute(
|
||||
@@ -1531,7 +1696,10 @@ def patch_qc(
|
||||
return _read(d, pp, packet_id)
|
||||
update_row(conn, "qc_inspections", insp["id"], changes)
|
||||
w.changed(*[f"qc.{k}" for k in changes])
|
||||
w.log("Q.C. record changed")
|
||||
if scalar:
|
||||
_qc_logs(w, scalar)
|
||||
else:
|
||||
w.log("Q.C. record changed")
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,9 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from .. import audit, events, policy
|
||||
from ..domain import assets as asset_store
|
||||
from ..domain import ncr as ncrs
|
||||
from ..domain import packets, shop
|
||||
from ..domain import qc as qc_dom
|
||||
from ..domain.pipeline import (
|
||||
Expect,
|
||||
SignBody,
|
||||
@@ -110,6 +112,7 @@ class TransitionBody(BaseModel):
|
||||
spool: bool | None = None
|
||||
location: str | None = Field(default=None, max_length=500)
|
||||
for_sup_id: uuid.UUID | None = None
|
||||
ncr: str | None = Field(default=None, max_length=100)
|
||||
sign: SignBody | None = None
|
||||
it_reason: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
@@ -137,6 +140,7 @@ class DocBody(BaseModel):
|
||||
asset_id: uuid.UUID
|
||||
kind: str = Field(min_length=1, max_length=100)
|
||||
title: str | None = Field(default=None, max_length=500)
|
||||
note: str | None = Field(default=None, max_length=2000)
|
||||
it_reason: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
@@ -494,7 +498,7 @@ TRANSITION_FIELDS: dict[policy.Action, tuple[str, ...]] = {
|
||||
A.RETURN_TO_STATION: ("reason",),
|
||||
A.SEND_TO_QC: ("book", "spool", "location", "note"),
|
||||
A.ROUTE_FROM_REVIEW: ("dept", "note"),
|
||||
A.QC_REJECT: ("reason",),
|
||||
A.QC_REJECT: ("reason", "ncr"),
|
||||
A.QC_ACCEPT: ("routed_to", "note"),
|
||||
A.QC_ACCEPT_AND_ROUTE: ("dept", "note"),
|
||||
A.FILE: ("note",),
|
||||
@@ -534,6 +538,8 @@ def transition(
|
||||
extra["dept"] = shop_dept(conn, extra["dept"])
|
||||
if "for_sup_id" in extra:
|
||||
extra["for_sup_id"] = active_user(conn, extra["for_sup_id"], "supervisor")
|
||||
if action is A.QC_REJECT:
|
||||
_reject_reason(extra)
|
||||
if "stamp" in extra:
|
||||
# The assign sheet's stamp overrides the welder's own for this assignment; empty means use theirs.
|
||||
stamp = clean_text(extra["stamp"], 40)
|
||||
@@ -570,6 +576,21 @@ def transition(
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
def _reject_reason(extra: dict[str, Any]) -> None:
|
||||
"""A Q.C. reject takes a reason, an NCR number, or both. The NCR stands in for the reason text: the reason the
|
||||
policy checks is "NCR-2026-0021" alone, or "NCR-2026-0021 · <reason>". Without an NCR the reason needs its usual
|
||||
8 characters."""
|
||||
text = clean_text(extra.pop("reason", None), LIMITS["reason"])
|
||||
raw = clean_text(extra.pop("ncr", None), 100)
|
||||
ncr = ncrs.validate_ncr(raw) if raw else None
|
||||
if ncr:
|
||||
extra["ncr"] = ncr
|
||||
extra["reason"] = f"{ncr} · {text}" if text else ncr
|
||||
elif text:
|
||||
extra["reason"] = text
|
||||
extra["reason_text"] = text
|
||||
|
||||
|
||||
@router.post("/{packet_id}/reopen", dependencies=WRITE)
|
||||
def reopen(
|
||||
packet_id: int,
|
||||
@@ -750,27 +771,35 @@ def add_doc(
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""A report for the data book (NDE, hydrotest, certificates, NCRs). Never a drawing: drawings come from M-Files."""
|
||||
conn = d.conn
|
||||
kind = clean_text(body.kind, 40)
|
||||
if kind not in qc_dom.DOC_KINDS:
|
||||
raise ApiError(422, "invalid_kind", "Pick a document type from the list.")
|
||||
asset = asset_store.get(conn, body.asset_id)
|
||||
if asset is None:
|
||||
raise ApiError(404, "not_found", "No such file.")
|
||||
kind = clean_text(body.kind, 40)
|
||||
if not kind:
|
||||
raise ApiError(422, "invalid_request", "Say what kind of document this is.")
|
||||
title = clean_opt(body.title, LIMITS["doc_title"])
|
||||
if asset["mime"] not in qc_dom.DOC_MIMES:
|
||||
raise ApiError(422, "invalid_asset", "The file must be a PDF, PNG or JPEG.")
|
||||
label = qc_dom.DOC_KINDS[kind]
|
||||
title = clean_opt(body.title, LIMITS["doc_title"]) or label
|
||||
note = clean_opt(body.note, qc_dom.DOC_NOTE_MAX)
|
||||
pp = _pp(d, p)
|
||||
with packet_write(d, p, packet_id, A.DOC_ADD, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
|
||||
doc_id = conn.execute(
|
||||
"INSERT INTO packet_docs (packet_id, asset_id, kind, title, added_by) VALUES (%s, %s, %s, %s, %s)"
|
||||
" RETURNING id",
|
||||
(packet_id, asset["id"], kind, title, p.user_id),
|
||||
"INSERT INTO packet_docs (packet_id, asset_id, kind, title, note, added_by)"
|
||||
" VALUES (%s, %s, %s, %s, %s, %s) RETURNING id",
|
||||
(packet_id, asset["id"], kind, title, note, p.user_id),
|
||||
).fetchone()[0]
|
||||
w.changed("docs")
|
||||
w.note = title or kind
|
||||
w.log("Document added", " · ".join(x for x in (label, title, asset.get("original_name")) if x))
|
||||
w.detail.update({"doc_id": int(doc_id), "asset_id": str(asset["id"]), "kind": kind})
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
DOC_REASON_MIN = 3
|
||||
|
||||
|
||||
@router.delete("/{packet_id}/docs/{doc_id}", dependencies=WRITE)
|
||||
def remove_doc(
|
||||
packet_id: int,
|
||||
@@ -780,23 +809,29 @@ def remove_doc(
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""Take a document out of the data book. The row and the file stay, with who, when and why."""
|
||||
body = body or ReasonBody()
|
||||
conn = d.conn
|
||||
pp = _pp(d, p)
|
||||
with packet_write(
|
||||
d, p, packet_id, A.DOC_REMOVE, request=request, it_reason=body.it_reason, policy_principal=pp
|
||||
) as w:
|
||||
reason = clean_opt(body.reason, LIMITS["reason"])
|
||||
reason = clean_text(body.reason, LIMITS["reason"])
|
||||
doc = packets.one(
|
||||
conn,
|
||||
"UPDATE packet_docs SET removed_at = now(), removed_by = %s, removed_reason = %s"
|
||||
" WHERE id = %s AND packet_id = %s AND removed_at IS NULL RETURNING title, kind",
|
||||
(p.user_id, reason, doc_id, packet_id),
|
||||
conn, "SELECT * FROM packet_docs WHERE id = %s AND packet_id = %s FOR UPDATE", (doc_id, packet_id)
|
||||
)
|
||||
if doc is None:
|
||||
raise ApiError(404, "not_found", "No such document.")
|
||||
if doc["removed_at"] is not None:
|
||||
raise ApiError(409, "already_removed", "This document is already out of the data book.")
|
||||
if len(reason) < DOC_REASON_MIN:
|
||||
raise ApiError(400, "reason_required", "Say why it comes out of the data book.")
|
||||
conn.execute(
|
||||
"UPDATE packet_docs SET removed_at = now(), removed_by = %s, removed_reason = %s WHERE id = %s",
|
||||
(p.user_id, reason, doc_id),
|
||||
)
|
||||
w.changed("docs")
|
||||
w.note = ", ".join(x for x in (doc["title"] or doc["kind"], reason) if x)
|
||||
w.log("Document taken out of the data book", " · ".join(x for x in (doc["title"] or doc["kind"], reason) if x))
|
||||
w.detail["doc_id"] = doc_id
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""The Q.C. screen: the home board and the project and unit folders with their documents.
|
||||
|
||||
Any signed-in session reads. Filing a folder document is QC_DOC_ADD (Clerical or Q.C.; admin with an IT reason),
|
||||
decided by policy.engine.check_scoped because a folder belongs to a project or a unit, not a packet. Screens read
|
||||
PostgreSQL only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .. import audit, events, policy
|
||||
from ..domain import assets as asset_store
|
||||
from ..domain import itp as itp_dom
|
||||
from ..domain import packets, qcdocs
|
||||
from ..domain import qc as dom
|
||||
from ..text import clean_opt, clean_text
|
||||
from .deps import Db, Principal, current_principal, get_db
|
||||
from .errors import ApiError
|
||||
from .packets import WRITE
|
||||
|
||||
router = APIRouter(prefix="/api/qc", tags=["qc"])
|
||||
|
||||
A = policy.Action
|
||||
|
||||
|
||||
class QcDocBody(BaseModel):
|
||||
project_id: int | None = None
|
||||
unit_id: int | None = None
|
||||
type: str = Field(min_length=1, max_length=100)
|
||||
title: str | None = Field(default=None, max_length=1000)
|
||||
rev: str | None = Field(default=None, max_length=100)
|
||||
note: str | None = Field(default=None, max_length=5000)
|
||||
asset_id: uuid.UUID
|
||||
replaces: int | None = None
|
||||
it_reason: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
@router.get("/home")
|
||||
def home(
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
project_id: int | None = Query(None),
|
||||
unit_id: int | None = Query(None),
|
||||
) -> dict[str, Any]:
|
||||
conn = d.conn
|
||||
return dom.home(conn, packets.policy_principal_for(conn, p), project_id=project_id, unit_id=unit_id)
|
||||
|
||||
|
||||
@router.get("/folders")
|
||||
def folders(
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
show: str = Query("active", pattern="^(active|all)$"),
|
||||
) -> list[dict[str, Any]]:
|
||||
return qcdocs.folders(d.conn, show)
|
||||
|
||||
|
||||
@router.get("/folders/projects/{project_id}")
|
||||
def project_folder(
|
||||
project_id: int,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
out = qcdocs.project_folder(d.conn, project_id)
|
||||
if out is None:
|
||||
raise ApiError(404, "not_found", "No such project.")
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/folders/units/{unit_id}")
|
||||
def unit_folder(
|
||||
unit_id: int,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
out = qcdocs.unit_folder(d.conn, unit_id)
|
||||
if out is None:
|
||||
raise ApiError(404, "not_found", "No such unit.")
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/docs", dependencies=WRITE)
|
||||
def add_doc(
|
||||
body: QcDocBody,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""File a document in a project or unit folder, or a new revision of one (`replaces`). Nothing is deleted: the
|
||||
old revision points at the new one. The answer is the folder read."""
|
||||
conn = d.conn
|
||||
if (body.project_id is None) == (body.unit_id is None):
|
||||
raise ApiError(422, "invalid_request", "File the document in one project or one unit.")
|
||||
doc_type = clean_text(body.type, 100)
|
||||
if doc_type not in qcdocs.QC_TYPES:
|
||||
raise ApiError(422, "invalid_type", "Pick a document type from the list.")
|
||||
asset = asset_store.get(conn, body.asset_id)
|
||||
if asset is None:
|
||||
raise ApiError(404, "not_found", "No such file.")
|
||||
if asset["mime"] not in qcdocs.DOC_MIMES:
|
||||
raise ApiError(422, "invalid_asset", "The file must be a PDF, PNG or JPEG.")
|
||||
title = clean_opt(body.title, qcdocs.TITLE_MAX) or doc_type
|
||||
rev = clean_opt(body.rev, qcdocs.REV_MAX)
|
||||
note = clean_opt(body.note, qcdocs.NOTE_MAX)
|
||||
pp = packets.policy_principal_for(conn, p)
|
||||
decision, it_kept = itp_dom.scoped_decision(p, pp, A.QC_DOC_ADD, it_reason=body.it_reason)
|
||||
with conn.transaction():
|
||||
if body.unit_id is not None:
|
||||
folder = packets.one(
|
||||
conn,
|
||||
"SELECT u.id, pr.number || u.lot AS number FROM units u JOIN projects pr ON pr.id = u.project_id"
|
||||
" WHERE u.id = %s AND u.removed_at IS NULL FOR UPDATE OF u",
|
||||
(body.unit_id,),
|
||||
)
|
||||
scope_col = "unit_id"
|
||||
else:
|
||||
folder = packets.one(
|
||||
conn,
|
||||
"SELECT id, number FROM projects WHERE id = %s AND removed_at IS NULL FOR UPDATE",
|
||||
(body.project_id,),
|
||||
)
|
||||
scope_col = "project_id"
|
||||
if folder is None:
|
||||
raise ApiError(404, "not_found", "No such unit." if body.unit_id is not None else "No such project.")
|
||||
old = None
|
||||
if body.replaces is not None:
|
||||
old = packets.one(conn, "SELECT * FROM qc_docs WHERE id = %s FOR UPDATE", (body.replaces,))
|
||||
if old is None or old[scope_col] != folder["id"]:
|
||||
raise ApiError(422, "invalid_replace", "That document is not in this folder.")
|
||||
if old["superseded_by"] is not None:
|
||||
raise ApiError(409, "already_superseded", "That document already has a newer revision.")
|
||||
new_id = int(
|
||||
conn.execute(
|
||||
"INSERT INTO qc_docs (project_id, unit_id, type, title, rev, note, asset_id, added_by)"
|
||||
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING id",
|
||||
(body.project_id, body.unit_id, doc_type, title, rev, note, asset["id"], p.user_id),
|
||||
).fetchone()[0]
|
||||
)
|
||||
if old is not None:
|
||||
conn.execute(
|
||||
"UPDATE qc_docs SET superseded_by = %s, superseded_at = now(), superseded_by_user = %s WHERE id = %s",
|
||||
(new_id, p.user_id, old["id"]),
|
||||
)
|
||||
title_rev = f"{title} Rev {rev}" if rev else title
|
||||
text_note = " · ".join(x for x in (folder["number"], doc_type, title_rev, asset.get("original_name")) if x)
|
||||
revise = old is not None
|
||||
detail: dict[str, Any] = {
|
||||
"text": "Q.C. document revised" if revise else "Q.C. document added",
|
||||
"note": text_note,
|
||||
"doc_id": new_id,
|
||||
"project_id": body.project_id,
|
||||
"unit_id": body.unit_id,
|
||||
}
|
||||
if revise:
|
||||
detail["replaces"] = old["id"] if old else None
|
||||
audit.append(
|
||||
conn,
|
||||
action="qc_doc.revise" if revise else "qc_doc.add",
|
||||
user_id=p.user_id,
|
||||
session_id=p.session_id,
|
||||
role_snapshot=p.role_snapshot,
|
||||
subject_type="qc_doc",
|
||||
subject_id=new_id,
|
||||
detail=detail,
|
||||
it_reason=it_kept,
|
||||
ip=p.ip,
|
||||
)
|
||||
events.publish(
|
||||
conn,
|
||||
{"event": "changed", "kind": "qc_doc", "project_id": body.project_id, "unit_id": body.unit_id},
|
||||
)
|
||||
del decision
|
||||
if body.unit_id is not None:
|
||||
out = qcdocs.unit_folder(conn, body.unit_id)
|
||||
else:
|
||||
assert body.project_id is not None
|
||||
out = qcdocs.project_folder(conn, body.project_id)
|
||||
assert out is not None
|
||||
return out
|
||||
@@ -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
|
||||
@@ -101,10 +101,12 @@ def create_app(
|
||||
auth,
|
||||
departments,
|
||||
feed_settings,
|
||||
itp,
|
||||
jobs,
|
||||
packet_children,
|
||||
packets,
|
||||
projects,
|
||||
qc,
|
||||
shop,
|
||||
units,
|
||||
warehouse,
|
||||
@@ -127,6 +129,8 @@ def create_app(
|
||||
feed_settings,
|
||||
warehouse,
|
||||
shop,
|
||||
qc,
|
||||
itp,
|
||||
):
|
||||
app.include_router(module.router)
|
||||
app.include_router(assets.blob_router)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
-- 0007_qc: the Q.C. phase. The inspection's NCR, packet document notes and kinds, Q.C. folder metadata, the ITP
|
||||
-- header, line and block details, the ITP log, and the Q.C. check items as a settings row. Runs as ps_migrate, in
|
||||
-- one transaction like every migration file.
|
||||
|
||||
-- The NCR number a Q.C. reject gave (NCR-2026-0021), kept on the inspection and in its qc_history snapshot.
|
||||
ALTER TABLE qc_inspections ADD COLUMN ncr text;
|
||||
|
||||
-- Packet documents: an optional note, and the kind is a closed set (the old PDOC_TYPES).
|
||||
ALTER TABLE packet_docs ADD COLUMN note text;
|
||||
ALTER TABLE packet_docs ADD CONSTRAINT packet_docs_kind
|
||||
CHECK (kind IN ('nde', 'hydro', 'dimensional', 'pmi', 'coating', 'coc', 'customer', 'ncr', 'other'));
|
||||
|
||||
-- Q.C. folder documents belong to exactly one project or one unit. A new revision files a new row and points the old
|
||||
-- one at it (superseded_by, superseded_at, superseded_by_user). Nothing is deleted.
|
||||
ALTER TABLE qc_docs
|
||||
ADD COLUMN title text,
|
||||
ADD COLUMN note text,
|
||||
ADD COLUMN superseded_at timestamptz,
|
||||
ADD COLUMN superseded_by_user uuid REFERENCES users (id);
|
||||
ALTER TABLE qc_docs ADD CONSTRAINT qc_docs_scope CHECK ((project_id IS NULL) <> (unit_id IS NULL));
|
||||
ALTER TABLE qc_docs ADD CONSTRAINT qc_docs_type
|
||||
CHECK (type IN ('ITP', 'WPS / PQR', 'NDE procedure', 'Hydrotest procedure', 'PMI / material',
|
||||
'Calibration certificate', 'Customer spec', 'Other'));
|
||||
CREATE INDEX qc_docs_project ON qc_docs (project_id) WHERE superseded_by IS NULL;
|
||||
CREATE INDEX qc_docs_unit ON qc_docs (unit_id) WHERE superseded_by IS NULL;
|
||||
|
||||
-- An ITP is open or complete (signed off). 'signed' and 'reopened' stay in the enum, unused. The new value is not
|
||||
-- used in this migration, so adding it inside the transaction is fine (PostgreSQL 12 and later).
|
||||
ALTER TYPE itp_status ADD VALUE IF NOT EXISTS 'complete';
|
||||
|
||||
-- The ITP number (<unit number>-ITP) and header (client, pname, pno, po, unit) as Q.C. may edit them.
|
||||
ALTER TABLE itps
|
||||
ADD COLUMN no text,
|
||||
ADD COLUMN hdr jsonb NOT NULL DEFAULT '{}',
|
||||
ADD COLUMN created_at timestamptz NOT NULL DEFAULT now(),
|
||||
ADD COLUMN created_by uuid REFERENCES users (id);
|
||||
|
||||
-- A line: Q.C.'s initials, and the customer's code, witness name and date with who recorded them.
|
||||
ALTER TABLE itp_lines
|
||||
ADD COLUMN initials text,
|
||||
ADD COLUMN cust_code text,
|
||||
ADD COLUMN cust_by text,
|
||||
ADD COLUMN cust_at date,
|
||||
ADD COLUMN cust_rec uuid REFERENCES users (id),
|
||||
ADD COLUMN cust_rec_at timestamptz;
|
||||
|
||||
-- A reject may come before its NCR number. kind 'reject' is the rejection; kind 'ncr' records the number entered later.
|
||||
ALTER TABLE itp_rejects ALTER COLUMN ncr DROP NOT NULL;
|
||||
ALTER TABLE itp_rejects ADD COLUMN kind text NOT NULL DEFAULT 'reject' CHECK (kind IN ('reject', 'ncr'));
|
||||
|
||||
-- A block row exists before it is signed: QN #, remarks and the customer complete record live on it.
|
||||
ALTER TABLE itp_blocks ALTER COLUMN signed_at DROP NOT NULL, ALTER COLUMN signed_at DROP DEFAULT;
|
||||
ALTER TABLE itp_blocks
|
||||
ADD COLUMN qn text,
|
||||
ADD COLUMN remarks text,
|
||||
ADD COLUMN cust_name text,
|
||||
ADD COLUMN cust_at date,
|
||||
ADD COLUMN cust_by uuid REFERENCES users (id),
|
||||
ADD COLUMN cust_rec_at timestamptz,
|
||||
ADD COLUMN row_version int NOT NULL DEFAULT 1;
|
||||
|
||||
-- The ITP's own history, append-only like packet_log.
|
||||
CREATE TABLE itp_log (
|
||||
id bigserial PRIMARY KEY,
|
||||
itp_id bigint NOT NULL REFERENCES itps (id),
|
||||
at timestamptz NOT NULL DEFAULT now(),
|
||||
by uuid REFERENCES users (id),
|
||||
by_name text,
|
||||
action text NOT NULL,
|
||||
note text,
|
||||
signature_id bigint REFERENCES signatures (id),
|
||||
it_reason text
|
||||
);
|
||||
CREATE INDEX itp_log_itp ON itp_log (itp_id, id);
|
||||
|
||||
CREATE TRIGGER itp_log_append_only
|
||||
BEFORE UPDATE OR DELETE ON itp_log
|
||||
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
|
||||
CREATE TRIGGER itp_log_no_truncate
|
||||
BEFORE TRUNCATE ON itp_log
|
||||
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
|
||||
|
||||
-- Explicit, after the default privileges: the app may only read and append the ITP log.
|
||||
REVOKE UPDATE, DELETE, TRUNCATE ON itp_log FROM ps_app;
|
||||
GRANT SELECT, INSERT ON itp_log TO ps_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ps_app;
|
||||
|
||||
-- The Q.C. check items (the old qcChecksFor). {wps}, {stamp} ("[75] " or empty), {drawing} and {rev} are filled in
|
||||
-- from the packet. The code keeps the same list as its fallback when this row is missing or does not parse.
|
||||
INSERT INTO settings (key, value) VALUES ('qc_check_items', '{
|
||||
"groups": [
|
||||
{"group": "Data book package", "items": [
|
||||
{"key": "db_heats", "text": "Heat number recorded for every BOM item"},
|
||||
{"key": "db_mtr", "text": "MTRs on file and match the heat numbers"},
|
||||
{"key": "db_weldmap", "text": "Weld map complete: every weld identified"},
|
||||
{"key": "db_wps", "text": "Welds made to {wps}; welder {stamp}qualified"},
|
||||
{"key": "db_ck", "text": "Stall checklist and weld record complete"}
|
||||
]},
|
||||
{"group": "Physical spool", "items": [
|
||||
{"key": "ph_dim", "text": "Dimensions verified against {drawing} Rev {rev}"},
|
||||
{"key": "ph_heat", "text": "Heat numbers on material match the heat map"},
|
||||
{"key": "ph_stamp", "text": "Welds stamped with welder ID"},
|
||||
{"key": "ph_clean", "text": "Clean: no slag, spatter or arc strikes"},
|
||||
{"key": "ph_qty", "text": "Quantity matches the work orders"}
|
||||
]}
|
||||
]
|
||||
}'::jsonb) ON CONFLICT (key) DO NOTHING;
|
||||
@@ -0,0 +1,2 @@
|
||||
"""PDF output: the data book, the traveler, the ITP, prints and stamps. Phase 7 builds them; phase 6 holds the ITP
|
||||
filing hook only."""
|
||||
@@ -0,0 +1,17 @@
|
||||
"""The signed ITP PDF. Phase 7 builds it; until then the hook files nothing.
|
||||
|
||||
`file_signed_itp_pdf` is called inside the sign-off transaction, after the ITP row is set complete. When it returns an
|
||||
asset id, the sign-off stores it as `signoff_asset_id` and files an automatic ITP document in the unit's Q.C. folder.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
|
||||
def file_signed_itp_pdf(conn: Any, itp_row: Mapping[str, Any], actor: Any) -> uuid.UUID | None:
|
||||
"""Build and store the signed ITP PDF, and return its asset id. Phase 7 implements it; today it returns None."""
|
||||
del conn, itp_row, actor
|
||||
return None
|
||||
@@ -91,6 +91,11 @@ class Action(StrEnum):
|
||||
CO_REJECT = "co_reject"
|
||||
CO_HOLD_TOGGLE = "co_hold_toggle"
|
||||
CO_VERIFY = "co_verify"
|
||||
QC_DOC_ADD = "qc_doc_add"
|
||||
ITP_START = "itp_start"
|
||||
ITP_LINE_SIGN = "itp_line_sign"
|
||||
ITP_LINE_UNSIGN = "itp_line_unsign"
|
||||
ITP_BLOCK_UNSIGN = "itp_block_unsign"
|
||||
ITP_EDIT_LINE = "itp_edit_line"
|
||||
ITP_BLOCK_SIGN = "itp_block_sign"
|
||||
ITP_SIGN_OFF = "itp_sign_off"
|
||||
@@ -270,6 +275,12 @@ RULES: dict[Action, Rule] = {
|
||||
),
|
||||
A.CO_HOLD_TOGGLE: Rule(Who.CL, ANY, None),
|
||||
A.CO_VERIFY: Rule(Who.CO_VERIFIER, ANY, None, signature="Verify a change order for the department"),
|
||||
# Q.C. folder documents and the ITP belong to a project or a unit, not a packet: engine.check_scoped decides them.
|
||||
A.QC_DOC_ADD: Rule(Who.CL_OR_QC, ANY, None),
|
||||
A.ITP_START: Rule(Who.QC, ANY, None),
|
||||
A.ITP_LINE_SIGN: Rule(Who.QC, ANY, None),
|
||||
A.ITP_LINE_UNSIGN: Rule(Who.QC, ANY, None),
|
||||
A.ITP_BLOCK_UNSIGN: Rule(Who.QC, ANY, None),
|
||||
A.ITP_EDIT_LINE: Rule(Who.QC, ANY, None),
|
||||
A.ITP_BLOCK_SIGN: Rule(Who.QC, ANY, None, signature="Sign an ITP block"),
|
||||
A.ITP_SIGN_OFF: Rule(Who.QC, ANY, None, signature="Sign off the ITP"),
|
||||
@@ -313,8 +324,41 @@ EDIT_WHAT: dict[Action, str] = {
|
||||
A.CO_REJECT: "change orders",
|
||||
A.CO_HOLD_TOGGLE: "change orders",
|
||||
A.CO_VERIFY: "change orders",
|
||||
A.QC_DOC_ADD: "the Q.C. folder",
|
||||
A.ITP_START: "the ITP",
|
||||
A.ITP_LINE_SIGN: "the ITP",
|
||||
A.ITP_LINE_UNSIGN: "the ITP",
|
||||
A.ITP_BLOCK_UNSIGN: "the ITP",
|
||||
A.ITP_EDIT_LINE: "the ITP",
|
||||
A.ITP_BLOCK_SIGN: "the ITP",
|
||||
A.ITP_SIGN_OFF: "the ITP",
|
||||
A.ITP_REOPEN: "the ITP",
|
||||
}
|
||||
|
||||
# The actions on a project or unit rather than a packet (check them with engine.check_scoped).
|
||||
SCOPED: frozenset[Action] = frozenset(
|
||||
{
|
||||
A.QC_DOC_ADD,
|
||||
A.ITP_START,
|
||||
A.ITP_LINE_SIGN,
|
||||
A.ITP_LINE_UNSIGN,
|
||||
A.ITP_EDIT_LINE,
|
||||
A.ITP_BLOCK_SIGN,
|
||||
A.ITP_BLOCK_UNSIGN,
|
||||
A.ITP_SIGN_OFF,
|
||||
A.ITP_REOPEN,
|
||||
}
|
||||
)
|
||||
|
||||
# Their labels (the audit and log texts name the event more exactly).
|
||||
SCOPED_LABELS: dict[Action, str] = {
|
||||
A.QC_DOC_ADD: "Q.C. document added",
|
||||
A.ITP_START: "ITP started",
|
||||
A.ITP_LINE_SIGN: "ITP line approved",
|
||||
A.ITP_LINE_UNSIGN: "ITP line initial removed",
|
||||
A.ITP_BLOCK_UNSIGN: "ITP block signature removed",
|
||||
A.ITP_EDIT_LINE: "ITP changed",
|
||||
A.ITP_BLOCK_SIGN: "ITP block signed",
|
||||
A.ITP_SIGN_OFF: "ITP signed off",
|
||||
A.ITP_REOPEN: "ITP reopened",
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ Order of checks, exactly:
|
||||
6. filed lock: only SET_DATABOOK_AT and ADMIN_REOPEN, Admin included.
|
||||
7. who and custody.
|
||||
8. preconditions in rule order, never bypassed.
|
||||
|
||||
`check_scoped` decides the actions on a project or unit (Q.C. folder documents, the ITP) the same way without a
|
||||
packet: system, admin bypass, who, then the input preconditions. There is no status, CO hold, filed lock or custody.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -37,6 +40,24 @@ MSG_FILED = "This packet is filed and locked. IT can reopen it if a correction i
|
||||
MSG_FORBIDDEN_STEP = "Your access doesn't allow this step."
|
||||
MSG_NOT_WH_SKIPPED = "This packet came through the warehouse. Send it back to the warehouse instead."
|
||||
MSG_NOT_DATABOOK = "Only packets routed to the data book are closed in VISUAL here."
|
||||
MSG_FORBIDDEN_SCOPED = "Your access doesn't allow this."
|
||||
|
||||
# The packet view check_scoped hands the who predicates. Only predicates that ignore the packet (CL, QC, QC_SUP,
|
||||
# CL_OR_QC and the like) can pass with it; HOLDER and the department predicates fail.
|
||||
_NO_PACKET = PacketState(
|
||||
id=0,
|
||||
status="",
|
||||
dept="",
|
||||
station_id=None,
|
||||
welder_id=None,
|
||||
wh_skipped=False,
|
||||
routed_to=None,
|
||||
vclosed=False,
|
||||
removed=False,
|
||||
source_kind="hand",
|
||||
weld_dept=False,
|
||||
co_hold=False,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -181,3 +202,44 @@ def can(
|
||||
elif d.reasons:
|
||||
blocked[action] = list(d.reasons)
|
||||
return allowed, blocked
|
||||
|
||||
|
||||
def check_scoped(
|
||||
principal: Principal,
|
||||
action: Action,
|
||||
*,
|
||||
extra: Mapping[str, Any] | None = None,
|
||||
it_reason: str | None = None,
|
||||
assume_it_reason: bool = False,
|
||||
) -> Decision:
|
||||
"""Decide an action on a project or unit (no packet): the steps of check() that do not need one.
|
||||
|
||||
System principals fail `forbidden` (a system-only rule fails `system_only` for people). An admin the rule does not
|
||||
allow passes with an IT reason of at least 8 characters (`admin_bypass`), else `it_reason_required`. Then who
|
||||
(`forbidden`), then the input preconditions in rule order (`reason_given` fails `reason_required`). Custody is not
|
||||
checked: nobody holds a project or unit. `assume_it_reason` is the read mode for an admin (what the screen offers).
|
||||
"""
|
||||
rule = RULES[action]
|
||||
ex = extra or EMPTY
|
||||
if rule.system_only and principal.system is None:
|
||||
return Decision(False, "system_only", MSG_SYSTEM_ONLY)
|
||||
if principal.system is not None:
|
||||
return Decision(False, "forbidden", MSG_SYSTEM_FORBIDDEN)
|
||||
who_ok = allows(rule.who, principal, _NO_PACKET, ex)
|
||||
bypass = False
|
||||
if not who_ok and principal.admin:
|
||||
if assume_it_reason or reason_ok(it_reason) is not None:
|
||||
bypass = True
|
||||
else:
|
||||
return Decision(False, "it_reason_required", MSG_IT_REASON)
|
||||
if not who_ok and not bypass:
|
||||
return Decision(False, "forbidden", MSG_FORBIDDEN_SCOPED)
|
||||
for name in rule.preconditions:
|
||||
if name not in INPUT_ONLY:
|
||||
continue # a state precondition needs a packet; no scoped rule has one
|
||||
failed = PRECONDITIONS[name](_NO_PACKET, ex)
|
||||
if failed is None:
|
||||
continue
|
||||
code = "reason_required" if rule.reason_required and name == "reason_given" else "precondition"
|
||||
return Decision(False, code, failed, admin_bypass=bypass)
|
||||
return Decision(True, admin_bypass=bypass, signature=rule.signature)
|
||||
|
||||
@@ -52,6 +52,12 @@ LIMITS: dict[str, int] = {
|
||||
"filler": 40,
|
||||
"preheat": 40,
|
||||
"weld_text": 40,
|
||||
"doc_note": 300,
|
||||
"qc_doc_title": 140,
|
||||
"qc_doc_rev": 12,
|
||||
"qc_doc_note": 600,
|
||||
"ncr": 40,
|
||||
"itp_field": 120,
|
||||
}
|
||||
REASON_MIN = 8
|
||||
MAX_BODY_BYTES = 200_000
|
||||
|
||||
Reference in New Issue
Block a user