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

The Q.C. screen (KPIs, boards, project folders), the Q.C. tab on the packet
page (check items from the qc_check_items settings row, visual exam per weld,
NDE, hardness with the NACE label, notes), accept with a route and a
signature gated by qc_missing_empty, reject with a reason or an NCR number
closing the inspection into qc_history, Q.C. folder documents per project and
unit with superseding revisions, packet documents as a closed set with
take-out reasons, the digital ITP per unit from the frozen template (line
initials with CAS, unsign, N/A, customer codes, rejects with NCR, block
signatures, sign-off with the PDF hook, admin reopen, the ITP log), hold-point
warnings by department page group, the scoped policy check for unit and
project writes, migration 0007, tests, Playwright scenarios and docs.

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