Phase 6: Q.C. inspection, project folders, packet documents and the digital ITP
The Q.C. screen (KPIs, boards, project folders), the Q.C. tab on the packet page (check items from the qc_check_items settings row, visual exam per weld, NDE, hardness with the NACE label, notes), accept with a route and a signature gated by qc_missing_empty, reject with a reason or an NCR number closing the inspection into qc_history, Q.C. folder documents per project and unit with superseding revisions, packet documents as a closed set with take-out reasons, the digital ITP per unit from the frozen template (line initials with CAS, unsign, N/A, customer codes, rejects with NCR, block signatures, sign-off with the PDF hook, admin reopen, the ITP log), hold-point warnings by department page group, the scoped policy check for unit and project writes, migration 0007, tests, Playwright scenarios and docs. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -0,0 +1,744 @@
|
||||
"""The digital ITP: read, start, header, lines, sections, blocks, sign-off and reopen.
|
||||
|
||||
Reads need any session. Every write runs policy.engine.check_scoped with its action, then locks the ITP row; the
|
||||
answer is the full ITP read. ITP writes never look at a packet: a filed packet in the unit does not lock the ITP.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from psycopg.types.json import Jsonb
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .. import policy
|
||||
from ..domain import itp as dom
|
||||
from ..domain import ncr as ncrs
|
||||
from ..domain import packets
|
||||
from ..domain.itp_template import ITP_TEMPLATE
|
||||
from ..domain.pipeline import SignBody, apply_field_cas, jsonable, parse_if_match
|
||||
from ..text import LIMITS, clean_opt, clean_text
|
||||
from .deps import Db, Principal, current_principal, get_db
|
||||
from .errors import ApiError
|
||||
from .packets import WRITE
|
||||
|
||||
router = APIRouter(tags=["itp"])
|
||||
|
||||
A = policy.Action
|
||||
|
||||
HDR_MAX = 120
|
||||
CUST_BY_MAX = 80
|
||||
NOTE_MAX = 500
|
||||
QN_MAX = 40
|
||||
REMARKS_MAX = 400
|
||||
CUST_NAME_MAX = 80
|
||||
HDR_FIELDS = ("no", *dom.HDR_KEYS)
|
||||
|
||||
MSG_NO_LINE = "No such ITP line."
|
||||
MSG_NO_SECTION = "No such ITP section."
|
||||
MSG_NO_BLOCK = "No such ITP block."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- bodies
|
||||
|
||||
|
||||
class It(BaseModel):
|
||||
it_reason: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class HeaderBody(It):
|
||||
set: dict[str, str | None] = Field(max_length=10)
|
||||
base: dict[str, str | None] = Field(default_factory=dict, max_length=10)
|
||||
|
||||
|
||||
class LineBody(It):
|
||||
na: bool | None = None
|
||||
cust_code: str | None = Field(default=None, max_length=20)
|
||||
cust_by: str | None = Field(default=None, max_length=500)
|
||||
cust_at: date | None = None
|
||||
note: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
class SectionNaBody(It):
|
||||
na: bool
|
||||
|
||||
|
||||
class RejectBody(It):
|
||||
ncr: str | None = Field(default=None, max_length=100)
|
||||
note: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
class NcrBody(It):
|
||||
ncr: str = Field(max_length=100)
|
||||
|
||||
|
||||
class BlockBody(It):
|
||||
qn: str | None = Field(default=None, max_length=500)
|
||||
remarks: str | None = Field(default=None, max_length=2000)
|
||||
cust_name: str | None = Field(default=None, max_length=500)
|
||||
cust_at: date | None = None
|
||||
cust_clear: bool | None = None
|
||||
|
||||
|
||||
class SignBodyOnly(It):
|
||||
sign: SignBody | None = None
|
||||
|
||||
|
||||
class ReopenBody(It):
|
||||
reason: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- helpers
|
||||
|
||||
|
||||
def _pp(d: Db, p: Principal) -> policy.Principal:
|
||||
return packets.policy_principal_for(d.conn, p)
|
||||
|
||||
|
||||
def _answer(d: Db, p: Principal, itp_id: int) -> dict[str, Any]:
|
||||
row = dom.load_row(d.conn, itp_id)
|
||||
if row is None:
|
||||
raise ApiError(404, "not_found", dom.MSG_NO_ITP)
|
||||
return dom.read(d.conn, row, _pp(d, p))
|
||||
|
||||
|
||||
def _item(w: dom.ItpWrite, key: str) -> dict[str, Any]:
|
||||
it = next((i for i in dom.items(w.itp["template"] or {}) if str(i["n"]) == key), None)
|
||||
if it is None:
|
||||
raise ApiError(404, "not_found", MSG_NO_LINE)
|
||||
return it
|
||||
|
||||
|
||||
def _line(w: dom.ItpWrite, key: str) -> dict[str, Any]:
|
||||
_item(w, key)
|
||||
row = packets.one(
|
||||
w.conn, "SELECT * FROM itp_lines WHERE itp_id = %s AND key = %s FOR UPDATE", (w.id, key)
|
||||
) or packets.one(
|
||||
w.conn,
|
||||
"INSERT INTO itp_lines (itp_id, key) VALUES (%s, %s) RETURNING *",
|
||||
(w.id, key),
|
||||
)
|
||||
assert row is not None
|
||||
return row
|
||||
|
||||
|
||||
def _update_line(w: dom.ItpWrite, line_id: int, values: dict[str, Any]) -> None:
|
||||
sets = ", ".join(f"{k} = %s" for k in values)
|
||||
w.conn.execute(
|
||||
f"UPDATE itp_lines SET {sets}, row_version = row_version + 1 WHERE id = %s", # noqa: S608 (fixed field names)
|
||||
(*values.values(), line_id),
|
||||
)
|
||||
|
||||
|
||||
def _block(w: dom.ItpWrite, key: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
b = next((x for x in dom.blocks(w.itp["template"] or {}) if x["key"] == key), None)
|
||||
if b is None:
|
||||
raise ApiError(404, "not_found", MSG_NO_BLOCK)
|
||||
row = packets.one(
|
||||
w.conn, "SELECT * FROM itp_blocks WHERE itp_id = %s AND block_key = %s FOR UPDATE", (w.id, key)
|
||||
) or packets.one(
|
||||
w.conn,
|
||||
"INSERT INTO itp_blocks (itp_id, block_key) VALUES (%s, %s) RETURNING *",
|
||||
(w.id, key),
|
||||
)
|
||||
assert row is not None
|
||||
return b, row
|
||||
|
||||
|
||||
def _update_block(w: dom.ItpWrite, block_id: int, values: dict[str, Any]) -> None:
|
||||
sets = ", ".join(f"{k} = %s" for k in values)
|
||||
w.conn.execute(
|
||||
f"UPDATE itp_blocks SET {sets}, row_version = row_version + 1 WHERE id = %s", # noqa: S608 (fixed field names)
|
||||
(*values.values(), block_id),
|
||||
)
|
||||
|
||||
|
||||
def _lines(w: dom.ItpWrite) -> dict[str, dict[str, Any]]:
|
||||
return dom.load_lines(w.conn, w.id)
|
||||
|
||||
|
||||
def _is_admin(p: Principal) -> bool:
|
||||
return bool(p.is_admin)
|
||||
|
||||
|
||||
def _same_user(a: Any, b: Any) -> bool:
|
||||
return a is not None and b is not None and str(a) == str(b)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- read and start
|
||||
|
||||
|
||||
@router.get("/api/units/{unit_id}/itp")
|
||||
def get_itp(
|
||||
unit_id: int,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""The unit's ITP, or {"itp": null, "unit": {id, number}} when it has none. 404 for an unknown unit."""
|
||||
conn = d.conn
|
||||
unit = packets.one(conn, "SELECT id, number FROM v_units WHERE id = %s AND removed_at IS NULL", (unit_id,))
|
||||
if unit is None:
|
||||
raise ApiError(404, "not_found", "No such unit.")
|
||||
row = dom.row_for_unit(conn, unit_id)
|
||||
if row is None:
|
||||
return {"itp": None, "unit": {"id": unit["id"], "number": unit["number"]}}
|
||||
return dom.read(conn, row, _pp(d, p))
|
||||
|
||||
|
||||
@router.post("/api/units/{unit_id}/itp", dependencies=WRITE)
|
||||
def start_itp(
|
||||
unit_id: int,
|
||||
request: Request,
|
||||
body: It | None = None,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""Start the unit's ITP from the template: the template is frozen, the header filled from the unit and project."""
|
||||
body = body or It()
|
||||
conn = d.conn
|
||||
pp = _pp(d, p)
|
||||
decision, it_kept = dom.scoped_decision(p, pp, A.ITP_START, it_reason=body.it_reason)
|
||||
with conn.transaction():
|
||||
unit = packets.one(
|
||||
conn,
|
||||
"SELECT u.id, u.po, u.lot, p.number AS pno, p.name AS pname, p.customer, p.po AS project_po"
|
||||
" FROM units u JOIN projects p ON p.id = u.project_id"
|
||||
" WHERE u.id = %s AND u.removed_at IS NULL FOR UPDATE OF u",
|
||||
(unit_id,),
|
||||
)
|
||||
if unit is None:
|
||||
raise ApiError(404, "not_found", "No such unit.")
|
||||
if dom.row_for_unit(conn, unit_id) is not None:
|
||||
raise ApiError(409, "itp_exists", "This unit already has an ITP.")
|
||||
number = f"{unit['pno']}{unit['lot']}"
|
||||
no = f"{number}-ITP"
|
||||
hdr = {
|
||||
"client": unit["customer"] or "",
|
||||
"pname": unit["pname"] or "",
|
||||
"pno": unit["pno"] or "",
|
||||
"po": unit["po"] or unit["project_po"] or "",
|
||||
"unit": number,
|
||||
}
|
||||
template = jsonable(ITP_TEMPLATE)
|
||||
itp_id = int(
|
||||
conn.execute(
|
||||
"INSERT INTO itps (unit_id, template, status, no, hdr, created_by) VALUES (%s, %s, 'open', %s, %s, %s)"
|
||||
" RETURNING id",
|
||||
(unit_id, Jsonb(template), no, Jsonb(hdr), p.user_id),
|
||||
).fetchone()[0]
|
||||
)
|
||||
keys = [str(i["n"]) for i in dom.items(template)]
|
||||
with conn.cursor() as cur:
|
||||
cur.executemany("INSERT INTO itp_lines (itp_id, key) VALUES (%s, %s)", [(itp_id, k) for k in keys])
|
||||
cur.executemany(
|
||||
"INSERT INTO itp_blocks (itp_id, block_key) VALUES (%s, %s)",
|
||||
[(itp_id, b["key"]) for b in dom.blocks(template)],
|
||||
)
|
||||
row = dom.load_row(conn, itp_id, lock=True)
|
||||
assert row is not None
|
||||
w = dom.ItpWrite(
|
||||
conn=conn,
|
||||
principal=p,
|
||||
policy_principal=pp,
|
||||
itp=row,
|
||||
action=A.ITP_START,
|
||||
decision=decision,
|
||||
request=request,
|
||||
it_reason=it_kept,
|
||||
version=int(row["version"]),
|
||||
)
|
||||
w.log("ITP started", f"{no} · template {template.get('ver')}")
|
||||
dom._finish(w)
|
||||
return _answer(d, p, itp_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- header
|
||||
|
||||
|
||||
@router.patch("/api/itps/{itp_id}", dependencies=WRITE)
|
||||
def patch_header(
|
||||
itp_id: int,
|
||||
body: HeaderBody,
|
||||
request: Request,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""The plan number and header fields, with field-level compare-and-set (409 field_conflict)."""
|
||||
bad = sorted(k for k in body.set if k not in HDR_FIELDS)
|
||||
if bad:
|
||||
raise ApiError(422, "invalid_field", f"Not an ITP header field: {', '.join(bad)}.")
|
||||
values = {k: clean_text(v, HDR_MAX) for k, v in body.set.items()}
|
||||
base = {k: clean_text(v, HDR_MAX) for k, v in body.base.items() if k in HDR_FIELDS}
|
||||
with dom.itp_write(d, p, itp_id, A.ITP_EDIT_LINE, request=request, it_reason=body.it_reason) as w:
|
||||
hdr = dict(w.itp["hdr"] or {})
|
||||
current = {"no": w.itp["no"] or "", **{k: hdr.get(k) or "" for k in dom.HDR_KEYS}}
|
||||
changes = apply_field_cas(current, values, base)
|
||||
if "no" in changes and not changes["no"]:
|
||||
raise ApiError(422, "invalid_field", "The plan number cannot be empty.")
|
||||
if not changes:
|
||||
return _answer(d, p, itp_id)
|
||||
for k in HDR_FIELDS:
|
||||
if k not in changes:
|
||||
continue
|
||||
if k == "no":
|
||||
w.log("ITP plan no. changed", changes[k])
|
||||
else:
|
||||
hdr[k] = changes[k]
|
||||
w.log("ITP header changed", f"{k}: {changes[k]}")
|
||||
w.conn.execute(
|
||||
"UPDATE itps SET no = %s, hdr = %s WHERE id = %s", (changes.get("no", w.itp["no"]), Jsonb(hdr), itp_id)
|
||||
)
|
||||
return _answer(d, p, itp_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- lines
|
||||
|
||||
|
||||
@router.post("/api/itps/{itp_id}/lines/{key}/sign", dependencies=WRITE)
|
||||
def sign_line(
|
||||
itp_id: int,
|
||||
key: str,
|
||||
request: Request,
|
||||
body: It | None = None,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""Approve a line (Q.C. initials). Optional If-Match is the line's row_version."""
|
||||
body = body or It()
|
||||
if_match = parse_if_match(request)
|
||||
with dom.itp_write(d, p, itp_id, A.ITP_LINE_SIGN, request=request, it_reason=body.it_reason) as w:
|
||||
line = _line(w, key)
|
||||
if if_match is not None and int(line["row_version"]) != if_match:
|
||||
raise ApiError(
|
||||
409,
|
||||
"row_conflict",
|
||||
"Someone else changed this line. Reload and try again.",
|
||||
extra={"current": {"row_version": line["row_version"], "result": line["result"]}},
|
||||
)
|
||||
if line["result"] == "approve":
|
||||
raise ApiError(409, "already_signed", "This line is already approved.")
|
||||
if line["result"] == "na":
|
||||
raise ApiError(409, "line_na", "This line is marked N/A.")
|
||||
rejected = line["result"] == "reject"
|
||||
if rejected and not line["ncr"]:
|
||||
raise ApiError(409, "ncr_required", "Enter the NCR # before approving this line.")
|
||||
name = p.user["display_name"]
|
||||
w.conn.execute(
|
||||
"UPDATE itp_lines SET result = 'approve', by = %s, at = now(), initials = %s,"
|
||||
" row_version = row_version + 1 WHERE id = %s",
|
||||
(p.user_id, dom.initials(name), line["id"]),
|
||||
)
|
||||
w.log(f"ITP line {key} approved", f"after NCR {line['ncr']}" if rejected else None)
|
||||
return _answer(d, p, itp_id)
|
||||
|
||||
|
||||
@router.post("/api/itps/{itp_id}/lines/{key}/unsign", dependencies=WRITE)
|
||||
def unsign_line(
|
||||
itp_id: int,
|
||||
key: str,
|
||||
request: Request,
|
||||
body: It | None = None,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""Remove an approval: the inspector who initialed it, or an admin, while the line's block is not signed."""
|
||||
body = body or It()
|
||||
with dom.itp_write(d, p, itp_id, A.ITP_LINE_UNSIGN, request=request, it_reason=body.it_reason) as w:
|
||||
line = _line(w, key)
|
||||
if line["result"] != "approve":
|
||||
raise ApiError(409, "not_signed", "This line is not approved.")
|
||||
if not (_same_user(line["by"], p.user_id) or _is_admin(p)):
|
||||
raise ApiError(403, "forbidden", "Only the inspector who initialed this line, or an admin, can remove it.")
|
||||
blk = dom.block_of_line(w.itp["template"] or {}, key)
|
||||
if blk is not None:
|
||||
brow = packets.one(
|
||||
w.conn, "SELECT signed_at FROM itp_blocks WHERE itp_id = %s AND block_key = %s", (w.id, blk["key"])
|
||||
)
|
||||
if brow is not None and brow["signed_at"] is not None:
|
||||
raise ApiError(409, "block_signed", "Undo the section signature first.")
|
||||
who = packets.People(w.conn, [line["by"]]).name(line["by"]) or ""
|
||||
was = f"was {who} {dom.date_text(line['at'], w.tz)}".strip()
|
||||
w.conn.execute(
|
||||
"UPDATE itp_lines SET result = NULL, by = NULL, at = NULL, initials = NULL,"
|
||||
" row_version = row_version + 1 WHERE id = %s",
|
||||
(line["id"],),
|
||||
)
|
||||
w.log(f"ITP line {key} initial removed", was)
|
||||
return _answer(d, p, itp_id)
|
||||
|
||||
|
||||
@router.patch("/api/itps/{itp_id}/lines/{key}", dependencies=WRITE)
|
||||
def patch_line(
|
||||
itp_id: int,
|
||||
key: str,
|
||||
body: LineBody,
|
||||
request: Request,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""N/A, the customer code and witness, and the note of a line."""
|
||||
given = body.model_fields_set
|
||||
cc: str | None = None
|
||||
if "cust_code" in given:
|
||||
cc = clean_text(body.cust_code, 20).upper()
|
||||
if cc and cc not in dom.CUST_CODES:
|
||||
raise ApiError(422, "invalid_field", "Pick a customer code from the list.")
|
||||
with dom.itp_write(d, p, itp_id, A.ITP_EDIT_LINE, request=request, it_reason=body.it_reason) as w:
|
||||
line = _line(w, key)
|
||||
values: dict[str, Any] = {}
|
||||
parts: list[str] = []
|
||||
if "na" in given and body.na is not None and body.na != (line["result"] == "na"):
|
||||
if body.na:
|
||||
if line["result"] == "approve":
|
||||
raise ApiError(409, "already_signed", "Undo the approval first.")
|
||||
if line["result"] == "reject":
|
||||
raise ApiError(409, "line_rejected", "This line is rejected.")
|
||||
values["result"] = "na"
|
||||
parts.append("marked N/A")
|
||||
else:
|
||||
values["result"] = None
|
||||
parts.append("applies again")
|
||||
if "cust_code" in given and (cc or None) != (line["cust_code"] or None):
|
||||
values["cust_code"] = cc or None
|
||||
parts.append(f"customer code {cc}" if cc else "customer code as template")
|
||||
if "cust_by" in given:
|
||||
who = clean_opt(body.cust_by, CUST_BY_MAX)
|
||||
if who != (line["cust_by"] or None):
|
||||
values["cust_by"] = who
|
||||
parts.append(f"customer {who}" if who else "customer cleared")
|
||||
if who:
|
||||
values["cust_at"] = body.cust_at or dom.today(w.tz)
|
||||
values["cust_rec"] = p.user_id
|
||||
values["cust_rec_at"] = w.conn.execute("SELECT now()").fetchone()[0]
|
||||
else:
|
||||
values.update({"cust_at": None, "cust_rec": None, "cust_rec_at": None})
|
||||
elif who and "cust_at" in given and body.cust_at and body.cust_at != line["cust_at"]:
|
||||
values["cust_at"] = body.cust_at
|
||||
parts.append(f"customer date {dom.date_text(body.cust_at)}")
|
||||
elif "cust_at" in given and line["cust_by"] and body.cust_at and body.cust_at != line["cust_at"]:
|
||||
values["cust_at"] = body.cust_at
|
||||
parts.append(f"customer date {dom.date_text(body.cust_at)}")
|
||||
if "note" in given:
|
||||
note = clean_opt(body.note, NOTE_MAX)
|
||||
if note != (line["note"] or None):
|
||||
values["note"] = note
|
||||
parts.append("note")
|
||||
if not values:
|
||||
return _answer(d, p, itp_id)
|
||||
_update_line(w, line["id"], values)
|
||||
w.log(f"ITP line {key} updated", ", ".join(parts))
|
||||
return _answer(d, p, itp_id)
|
||||
|
||||
|
||||
@router.post("/api/itps/{itp_id}/sections/{n}/na", dependencies=WRITE)
|
||||
def section_na(
|
||||
itp_id: int,
|
||||
n: str,
|
||||
body: SectionNaBody,
|
||||
request: Request,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""Mark every line of a section N/A, or make it apply again. Refused once a line of it is approved."""
|
||||
with dom.itp_write(d, p, itp_id, A.ITP_EDIT_LINE, request=request, it_reason=body.it_reason) as w:
|
||||
sec = next((s for s in (w.itp["template"] or {}).get("sections") or [] if str(s.get("n")) == n), None)
|
||||
if sec is None:
|
||||
raise ApiError(404, "not_found", MSG_NO_SECTION)
|
||||
rows = [_line(w, str(i["n"])) for i in sec.get("items") or []]
|
||||
if any(r["result"] == "approve" for r in rows):
|
||||
raise ApiError(409, "already_signed", "A line of this section is approved. Undo it first.")
|
||||
if body.na and any(r["result"] == "reject" for r in rows):
|
||||
raise ApiError(409, "line_rejected", "A line of this section is rejected.")
|
||||
want = "na" if body.na else None
|
||||
todo = [r for r in rows if (r["result"] == "na") != body.na]
|
||||
if not todo:
|
||||
return _answer(d, p, itp_id)
|
||||
for r in todo:
|
||||
_update_line(w, r["id"], {"result": want})
|
||||
w.log(f"ITP section {n} {'marked N/A' if body.na else 'applies again'}", str(sec.get("t") or ""))
|
||||
return _answer(d, p, itp_id)
|
||||
|
||||
|
||||
@router.post("/api/itps/{itp_id}/lines/{key}/reject", dependencies=WRITE)
|
||||
def reject_line(
|
||||
itp_id: int,
|
||||
key: str,
|
||||
body: RejectBody,
|
||||
request: Request,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""Reject a line, with its NCR number now or later. The line cannot be approved until the NCR is entered."""
|
||||
ncr = ncrs.validate_ncr(body.ncr) if clean_text(body.ncr, 100) else None
|
||||
note = clean_opt(body.note, NOTE_MAX)
|
||||
with dom.itp_write(d, p, itp_id, A.ITP_EDIT_LINE, request=request, it_reason=body.it_reason) as w:
|
||||
line = _line(w, key)
|
||||
if line["result"] == "approve":
|
||||
raise ApiError(409, "already_signed", "This line is approved. Undo the approval first.")
|
||||
if line["result"] == "na":
|
||||
raise ApiError(409, "line_na", "This line is marked N/A.")
|
||||
if line["result"] == "reject":
|
||||
raise ApiError(409, "line_rejected", "This line is already rejected.")
|
||||
_update_line(w, line["id"], {"result": "reject", "ncr": ncr})
|
||||
w.conn.execute(
|
||||
"INSERT INTO itp_rejects (itp_id, line_key, ncr, note, by, kind) VALUES (%s, %s, %s, %s, %s, 'reject')",
|
||||
(w.id, key, ncr, note, p.user_id),
|
||||
)
|
||||
head = f"NCR {ncr}" if ncr else "NCR # pending"
|
||||
w.log(f"ITP line {key} rejected", f"{head} · {note}" if note else head)
|
||||
return _answer(d, p, itp_id)
|
||||
|
||||
|
||||
@router.post("/api/itps/{itp_id}/lines/{key}/ncr", dependencies=WRITE)
|
||||
def enter_ncr(
|
||||
itp_id: int,
|
||||
key: str,
|
||||
body: NcrBody,
|
||||
request: Request,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""The NCR number of an open reject that has none yet."""
|
||||
ncr = ncrs.validate_ncr(body.ncr)
|
||||
with dom.itp_write(d, p, itp_id, A.ITP_EDIT_LINE, request=request, it_reason=body.it_reason) as w:
|
||||
line = _line(w, key)
|
||||
if line["result"] != "reject" or line["ncr"]:
|
||||
raise ApiError(409, "no_open_reject", "This line has no open reject waiting for an NCR #.")
|
||||
_update_line(w, line["id"], {"ncr": ncr})
|
||||
w.conn.execute(
|
||||
"INSERT INTO itp_rejects (itp_id, line_key, ncr, by, kind) VALUES (%s, %s, %s, %s, 'ncr')",
|
||||
(w.id, key, ncr, p.user_id),
|
||||
)
|
||||
w.log(f"ITP line {key} NCR number entered", f"NCR {ncr}")
|
||||
return _answer(d, p, itp_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- blocks
|
||||
|
||||
|
||||
@router.patch("/api/itps/{itp_id}/blocks/{key}", dependencies=WRITE)
|
||||
def patch_block(
|
||||
itp_id: int,
|
||||
key: str,
|
||||
body: BlockBody,
|
||||
request: Request,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""QN #, remarks and the customer complete record of a block. No signature."""
|
||||
given = body.model_fields_set
|
||||
with dom.itp_write(d, p, itp_id, A.ITP_EDIT_LINE, request=request, it_reason=body.it_reason) as w:
|
||||
_b, row = _block(w, key)
|
||||
values: dict[str, Any] = {}
|
||||
if "qn" in given:
|
||||
qn = clean_opt(body.qn, QN_MAX)
|
||||
if qn != (row["qn"] or None):
|
||||
values["qn"] = qn
|
||||
w.log(f"ITP block {key} QN # changed", qn or "")
|
||||
if "remarks" in given:
|
||||
rem = clean_opt(body.remarks, REMARKS_MAX)
|
||||
if rem != (row["remarks"] or None):
|
||||
values["remarks"] = rem
|
||||
w.log(f"ITP block {key} remarks changed", rem or "")
|
||||
if body.cust_clear:
|
||||
if row["cust_name"] or row["cust_at"]:
|
||||
values.update({"cust_name": None, "cust_at": None, "cust_by": None, "cust_rec_at": None})
|
||||
w.log(f"ITP block {key} customer complete cleared", row["cust_name"] or "")
|
||||
elif "cust_name" in given:
|
||||
name = clean_opt(body.cust_name, CUST_NAME_MAX)
|
||||
if name is None:
|
||||
raise ApiError(422, "invalid_field", "Enter the customer rep's name.")
|
||||
at = body.cust_at or dom.today(w.tz)
|
||||
if name != row["cust_name"] or at != row["cust_at"]:
|
||||
values.update(
|
||||
{
|
||||
"cust_name": name,
|
||||
"cust_at": at,
|
||||
"cust_by": p.user_id,
|
||||
"cust_rec_at": w.conn.execute("SELECT now()").fetchone()[0],
|
||||
}
|
||||
)
|
||||
w.log(f"ITP block {key} customer complete recorded", name)
|
||||
if not values:
|
||||
return _answer(d, p, itp_id)
|
||||
_update_block(w, row["id"], values)
|
||||
return _answer(d, p, itp_id)
|
||||
|
||||
|
||||
@router.post("/api/itps/{itp_id}/blocks/{key}/sign", dependencies=WRITE)
|
||||
def sign_block(
|
||||
itp_id: int,
|
||||
key: str,
|
||||
body: SignBodyOnly,
|
||||
request: Request,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""Sign a block of sections. Every line in it must be approved or N/A."""
|
||||
conn = d.conn
|
||||
row0 = dom.load_row(conn, itp_id)
|
||||
if row0 is None:
|
||||
raise ApiError(404, "not_found", dom.MSG_NO_ITP)
|
||||
# Who first, then the ready and unsigned checks before the signature, so a refused step costs no PIN attempt.
|
||||
# Both run again under the lock below.
|
||||
dom.scoped_decision(p, _pp(d, p), A.ITP_BLOCK_SIGN, it_reason=body.it_reason)
|
||||
_block_gate(conn, row0, key)
|
||||
with dom.itp_write(
|
||||
d,
|
||||
p,
|
||||
itp_id,
|
||||
A.ITP_BLOCK_SIGN,
|
||||
request=request,
|
||||
it_reason=body.it_reason,
|
||||
sign=body.sign,
|
||||
sign_extra={"block_key": key},
|
||||
) as w:
|
||||
b, row = _block(w, key)
|
||||
_block_gate(conn, w.itp, key)
|
||||
_update_block(
|
||||
w,
|
||||
row["id"],
|
||||
{
|
||||
"signed_at": conn.execute("SELECT now()").fetchone()[0],
|
||||
"signed_by": p.user_id,
|
||||
"signature_id": w.signature.id if w.signature else None,
|
||||
},
|
||||
)
|
||||
w.log(f"ITP block {key} signed", dom.block_scope(b))
|
||||
return _answer(d, p, itp_id)
|
||||
|
||||
|
||||
def _block_gate(conn: Any, itp_row: dict[str, Any], key: str) -> None:
|
||||
if str(itp_row["status"]) == "complete":
|
||||
raise ApiError(409, "itp_complete", dom.MSG_COMPLETE)
|
||||
b = next((x for x in dom.blocks(itp_row["template"] or {}) if x["key"] == key), None)
|
||||
if b is None:
|
||||
raise ApiError(404, "not_found", MSG_NO_BLOCK)
|
||||
brow = packets.one(
|
||||
conn, "SELECT signed_at FROM itp_blocks WHERE itp_id = %s AND block_key = %s", (itp_row["id"], key)
|
||||
)
|
||||
if brow is not None and brow["signed_at"] is not None:
|
||||
raise ApiError(409, "already_signed", "This block is already signed.")
|
||||
if not dom.block_ready(b, dom.load_lines(conn, int(itp_row["id"]))):
|
||||
raise ApiError(409, "block_not_ready", "Every line must be initialed or N/A first.")
|
||||
|
||||
|
||||
@router.delete("/api/itps/{itp_id}/blocks/{key}/sign", dependencies=WRITE)
|
||||
def unsign_block(
|
||||
itp_id: int,
|
||||
key: str,
|
||||
request: Request,
|
||||
body: It | None = None,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""Remove a block signature: the signer or an admin, while the ITP is open. The block's fields stay."""
|
||||
body = body or It()
|
||||
with dom.itp_write(d, p, itp_id, A.ITP_BLOCK_UNSIGN, request=request, it_reason=body.it_reason) as w:
|
||||
_b, row = _block(w, key)
|
||||
if row["signed_at"] is None:
|
||||
raise ApiError(409, "not_signed", "This block is not signed.")
|
||||
if not (_same_user(row["signed_by"], p.user_id) or _is_admin(p)):
|
||||
raise ApiError(403, "forbidden", "Only the inspector who signed this block, or an admin, can remove it.")
|
||||
_update_block(w, row["id"], {"signed_at": None, "signed_by": None, "signature_id": None})
|
||||
w.log(f"ITP block {key} signature removed")
|
||||
return _answer(d, p, itp_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- sign-off and reopen
|
||||
|
||||
|
||||
@router.post("/api/itps/{itp_id}/signoff", dependencies=WRITE)
|
||||
def sign_off(
|
||||
itp_id: int,
|
||||
body: SignBodyOnly,
|
||||
request: Request,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""Sign the ITP off: every block signed. The ITP locks; the PDF hook (phase 7) may file a copy."""
|
||||
from ..pdf import itp as itp_pdf
|
||||
|
||||
conn = d.conn
|
||||
row0 = dom.load_row(conn, itp_id)
|
||||
if row0 is None:
|
||||
raise ApiError(404, "not_found", dom.MSG_NO_ITP)
|
||||
dom.scoped_decision(p, _pp(d, p), A.ITP_SIGN_OFF, it_reason=body.it_reason)
|
||||
_signoff_gate(conn, row0)
|
||||
with dom.itp_write(d, p, itp_id, A.ITP_SIGN_OFF, request=request, it_reason=body.it_reason, sign=body.sign) as w:
|
||||
_signoff_gate(conn, w.itp)
|
||||
conn.execute(
|
||||
"UPDATE itps SET status = 'complete', signoff_at = now(), signoff_by = %s, signoff_signature_id = %s"
|
||||
" WHERE id = %s",
|
||||
(p.user_id, w.signature.id if w.signature else None, itp_id),
|
||||
)
|
||||
row = dom.load_row(conn, itp_id)
|
||||
assert row is not None
|
||||
asset_id = itp_pdf.file_signed_itp_pdf(conn, row, p.actor)
|
||||
if asset_id is not None:
|
||||
_file_auto_doc(w, row, asset_id)
|
||||
w.log("ITP signed off", "PDF filed")
|
||||
else:
|
||||
w.log("ITP signed off", "PDF filing reserved for phase 7")
|
||||
return _answer(d, p, itp_id)
|
||||
|
||||
|
||||
def _signoff_gate(conn: Any, itp_row: dict[str, Any]) -> None:
|
||||
if str(itp_row["status"]) == "complete":
|
||||
raise ApiError(409, "itp_complete", dom.MSG_COMPLETE)
|
||||
keys = [b["key"] for b in dom.blocks(itp_row["template"] or {})]
|
||||
signed = {
|
||||
r[0]
|
||||
for r in conn.execute(
|
||||
"SELECT block_key FROM itp_blocks WHERE itp_id = %s AND signed_at IS NOT NULL", (itp_row["id"],)
|
||||
)
|
||||
}
|
||||
if not keys or any(k not in signed for k in keys):
|
||||
raise ApiError(409, "blocks_unsigned", "Every section must be signed first.")
|
||||
|
||||
|
||||
AUTO_NOTE = "Filed automatically when the digital ITP was signed off."
|
||||
|
||||
|
||||
def _file_auto_doc(w: dom.ItpWrite, row: dict[str, Any], asset_id: Any) -> None:
|
||||
"""Store the signed PDF on the ITP and file it in the unit's Q.C. folder, superseding the earlier automatic copy."""
|
||||
conn = w.conn
|
||||
conn.execute("UPDATE itps SET signoff_asset_id = %s WHERE id = %s", (asset_id, row["id"]))
|
||||
new_id = conn.execute(
|
||||
"INSERT INTO qc_docs (unit_id, type, title, rev, note, asset_id, auto, added_by)"
|
||||
" VALUES (%s, 'ITP', %s, NULL, %s, %s, true, %s) RETURNING id",
|
||||
(row["unit_id"], f"{row['no']} — signed off", AUTO_NOTE, asset_id, w.user_id),
|
||||
).fetchone()[0]
|
||||
conn.execute(
|
||||
"UPDATE qc_docs SET superseded_by = %s, superseded_at = now(), superseded_by_user = %s"
|
||||
" WHERE unit_id = %s AND type = 'ITP' AND auto AND superseded_by IS NULL AND id <> %s",
|
||||
(new_id, w.user_id, row["unit_id"], new_id),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/itps/{itp_id}/reopen", dependencies=WRITE)
|
||||
def reopen(
|
||||
itp_id: int,
|
||||
body: ReopenBody,
|
||||
request: Request,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""Reopen a signed-off ITP: a Q.C. supervisor, or an admin (the reason serves as the IT reason when none is given).
|
||||
The blocks and lines keep their signatures."""
|
||||
reason = clean_opt(body.reason, LIMITS["reason"])
|
||||
it_reason = body.it_reason or (body.reason if p.is_admin else None)
|
||||
extra = {"reason": reason} if reason else {}
|
||||
with dom.itp_write(
|
||||
d, p, itp_id, A.ITP_REOPEN, request=request, extra=extra, it_reason=it_reason, allow_complete=True
|
||||
) as w:
|
||||
if str(w.itp["status"]) != "complete":
|
||||
raise ApiError(409, "itp_open", "The ITP is open already.")
|
||||
w.conn.execute(
|
||||
"UPDATE itps SET status = 'open', reopen_at = now(), reopen_by = %s, reopen_reason = %s WHERE id = %s",
|
||||
(p.user_id, reason, itp_id),
|
||||
)
|
||||
w.log("ITP reopened", reason)
|
||||
return _answer(d, p, itp_id)
|
||||
@@ -21,6 +21,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from .. import policy
|
||||
from ..domain import checklists, packets
|
||||
from ..domain import qc as qc_dom
|
||||
from ..domain.children import NEVER_VERIFY, active_depts, co_touched, current_wh, update_row, wh_move
|
||||
from ..domain.pipeline import (
|
||||
SignBody,
|
||||
@@ -260,16 +261,29 @@ class WeldExamIn(BaseModel):
|
||||
|
||||
|
||||
class QcBody(It):
|
||||
"""`{set, base}` for field-level compare-and-set of the scalar fields, or the phase 2 flat body with If-Match on
|
||||
the inspection's row_version (checks and weld_exam merge per key)."""
|
||||
|
||||
checks: dict[str, bool] | None = Field(default=None, max_length=50)
|
||||
weld_exam: dict[str, WeldExamIn] | None = Field(default=None, max_length=2000)
|
||||
nde_method: str | None = Field(default=None, max_length=500)
|
||||
nde_result: str | None = Field(default=None, max_length=500)
|
||||
nde_report: str | None = Field(default=None, max_length=500)
|
||||
hardness: str | None = Field(default=None, max_length=500)
|
||||
nace: bool | None = None
|
||||
inspector: str | None = Field(default=None, max_length=500)
|
||||
stamp: str | None = Field(default=None, max_length=40)
|
||||
notes: str | None = Field(default=None, max_length=5000)
|
||||
set: dict[str, str | None] | None = Field(default=None, max_length=10)
|
||||
base: dict[str, str | None] | None = Field(default=None, max_length=10)
|
||||
|
||||
|
||||
class QcCheckBody(It):
|
||||
done: bool
|
||||
|
||||
|
||||
class QcWeldBody(It):
|
||||
result: Literal["accept", "reject", ""] | None = None
|
||||
note: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
class CoItemIn(BaseModel):
|
||||
@@ -1457,6 +1471,143 @@ def set_wh_stage(
|
||||
# ---------------------------------------------------------------- Q.C.
|
||||
|
||||
|
||||
QC_FIELDS = ("inspector", "stamp", "nde_method", "nde_result", "nde_report", "hardness", "notes")
|
||||
QC_NOTE_NAMES = {"inspector": "inspector", "stamp": "stamp", "hardness": "hardness", "nde_report": "report"}
|
||||
|
||||
|
||||
def _open_inspection(conn: Any, packet_id: int) -> dict[str, Any]:
|
||||
insp = packets.one(conn, "SELECT * FROM qc_inspections WHERE packet_id = %s AND closed_at IS NULL", (packet_id,))
|
||||
if insp is None:
|
||||
raise ApiError(409, "no_inspection", MSG_NO_INSPECTION)
|
||||
return insp
|
||||
|
||||
|
||||
def _qc_value(key: str, value: Any) -> Any:
|
||||
"""One scalar Q.C. field, cleaned and checked."""
|
||||
if key == "notes":
|
||||
return clean_opt(value, LIMITS["notes"])
|
||||
if key == "stamp":
|
||||
return _stamp(value)
|
||||
text = clean_opt(value, QC_FIELD_MAX)
|
||||
if key == "nde_method" and text is not None and text not in qc_dom.NDE_METHODS:
|
||||
raise ApiError(422, "invalid_field", "Pick an NDE method from the list.")
|
||||
if key == "nde_result" and text is not None and text not in qc_dom.NDE_RESULTS:
|
||||
raise ApiError(422, "invalid_field", "Pick an NDE result from the list.")
|
||||
return text
|
||||
|
||||
|
||||
def _qc_logs(w: Write, changes: dict[str, Any]) -> None:
|
||||
"""The log lines of a Q.C. field change: "NDE method: RT", "NDE result: Acceptable", then "Q.C. record changed"
|
||||
with the other fields. A method set to None required clears the result and report without a line of their own."""
|
||||
none = changes.get("nde_method") == qc_dom.NDE_NONE
|
||||
lines: list[tuple[str, str | None]] = []
|
||||
if "nde_method" in changes:
|
||||
lines.append((f"NDE method: {changes['nde_method'] or '-'}", None))
|
||||
if "nde_result" in changes and not (none and not changes["nde_result"]):
|
||||
lines.append((f"NDE result: {changes['nde_result'] or '-'}", None))
|
||||
parts: list[str] = []
|
||||
for k in ("inspector", "stamp", "hardness", "nde_report", "notes"):
|
||||
if k not in changes or (k == "nde_report" and none and not changes[k]):
|
||||
continue
|
||||
v = changes[k]
|
||||
if k == "notes":
|
||||
parts.append("notes changed" if v else "notes cleared")
|
||||
else:
|
||||
parts.append(f"{QC_NOTE_NAMES[k]} {v}" if v else f"{QC_NOTE_NAMES[k]} cleared")
|
||||
if parts:
|
||||
lines.append(("Q.C. record changed", " · ".join(parts)))
|
||||
if not lines:
|
||||
lines.append(("Q.C. record changed", None))
|
||||
w.log(*lines[0])
|
||||
for more in lines[1:]:
|
||||
w.log_also(*more)
|
||||
|
||||
|
||||
@router.put("/{packet_id}/qc/checks/{key}", dependencies=WRITE)
|
||||
def put_qc_check(
|
||||
packet_id: int,
|
||||
key: str,
|
||||
body: QcCheckBody,
|
||||
request: Request,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""Tick or untick one Q.C. check. Per-key writes never conflict, so there is no If-Match."""
|
||||
pp = _pp(d, p)
|
||||
conn = d.conn
|
||||
with packet_write(d, p, packet_id, A.EDIT_QC, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
|
||||
items = w.children.qc_items if w.children is not None and w.children.qc_items else None
|
||||
if key not in checklists.qc_keys(items):
|
||||
raise ApiError(422, "invalid_key", "That is not a Q.C. check item.")
|
||||
insp = _open_inspection(conn, packet_id)
|
||||
if checklists.is_done((insp["checks"] or {}).get(key)) == body.done:
|
||||
w.skip()
|
||||
return _read(d, pp, packet_id)
|
||||
if body.done:
|
||||
conn.execute(
|
||||
"UPDATE qc_inspections SET checks = jsonb_set(checks, ARRAY[%s]::text[],"
|
||||
" jsonb_build_object('done', true, 'at', now(), 'by', %s::text, 'stamp', %s::text)),"
|
||||
" row_version = row_version + 1 WHERE id = %s",
|
||||
(key, str(w.user_id), insp["stamp"], insp["id"]),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE qc_inspections SET checks = jsonb_set(checks, ARRAY[%s]::text[], %s),"
|
||||
" row_version = row_version + 1 WHERE id = %s",
|
||||
(key, Jsonb({"done": False}), insp["id"]),
|
||||
)
|
||||
pk = w.packet
|
||||
texts = checklists.qc_items_for(pk["drawing"], pk["rev"], pk["wps"], pk["welder_stamp"], items)
|
||||
text = next((i["text"] for i in texts if i["key"] == key), key)
|
||||
w.changed(f"qc.checks.{key}")
|
||||
w.log(("Q.C. checked: " if body.done else "Q.C. unchecked: ") + text)
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
@router.put("/{packet_id}/qc/welds/{no}", dependencies=WRITE)
|
||||
def put_qc_weld(
|
||||
packet_id: int,
|
||||
no: int,
|
||||
body: QcWeldBody,
|
||||
request: Request,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""The visual exam of one weld: accept, reject or cleared (empty), and a note."""
|
||||
given = body.model_fields_set
|
||||
pp = _pp(d, p)
|
||||
conn = d.conn
|
||||
with packet_write(d, p, packet_id, A.EDIT_QC, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
|
||||
live = conn.execute(
|
||||
"SELECT 1 FROM welds WHERE packet_id = %s AND weld_no = %s AND removed_at IS NULL", (packet_id, no)
|
||||
).fetchone()
|
||||
if live is None:
|
||||
raise ApiError(422, "invalid_weld", "No such weld on this packet.")
|
||||
insp = _open_inspection(conn, packet_id)
|
||||
k = str(no)
|
||||
cur = dict((insp["weld_exam"] or {}).get(k) or {})
|
||||
result = (body.result or "") if "result" in given else str(cur.get("result") or "")
|
||||
note = clean_text(body.note, LIMITS["note"]) if "note" in given else str(cur.get("note") or "")
|
||||
result_changed = result != str(cur.get("result") or "")
|
||||
note_changed = note != str(cur.get("note") or "")
|
||||
if not result_changed and not note_changed:
|
||||
w.skip()
|
||||
return _read(d, pp, packet_id)
|
||||
conn.execute(
|
||||
"UPDATE qc_inspections SET weld_exam = jsonb_set(weld_exam, ARRAY[%s]::text[],"
|
||||
" %s::jsonb || jsonb_build_object('at', now(), 'by', %s::text)), row_version = row_version + 1"
|
||||
" WHERE id = %s",
|
||||
(k, Jsonb({"result": result, "note": note}), str(w.user_id), insp["id"]),
|
||||
)
|
||||
label = qc_dom.weld_label(no)
|
||||
w.changed(f"qc.weld_exam.{k}")
|
||||
if result_changed:
|
||||
w.log(f"VT {result} · {label}" if result else f"VT cleared · {label}", note if result and note else None)
|
||||
else:
|
||||
w.log("Q.C. record changed", f"{label} note")
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
@router.patch("/{packet_id}/qc", dependencies=WRITE)
|
||||
def patch_qc(
|
||||
packet_id: int,
|
||||
@@ -1465,36 +1616,50 @@ def patch_qc(
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""The open inspection. checks and weld_exam merge per key; If-Match is the inspection's row_version."""
|
||||
if_match = parse_if_match(request)
|
||||
given = body.model_fields_set
|
||||
if body.checks:
|
||||
bad = sorted(set(body.checks) - set(checklists.qc_keys()))
|
||||
if bad:
|
||||
raise ApiError(422, "invalid_key", f"Not a Q.C. item: {', '.join(bad)}.")
|
||||
"""The open inspection's fields.
|
||||
|
||||
`{set, base}`: inspector, stamp, nde_method, nde_result, nde_report, hardness and notes with field-level
|
||||
compare-and-set (409 field_conflict with `current`). The phase 2 flat body with If-Match (the inspection's
|
||||
row_version) still works and merges checks and weld_exam per key. Setting the NDE method to None required clears
|
||||
the result and the report. The NACE flag is not written: the read model computes it from the WPS.
|
||||
"""
|
||||
cas = body.set is not None
|
||||
values: dict[str, Any] = {}
|
||||
for k in ("nde_method", "nde_result", "nde_report", "hardness", "inspector"):
|
||||
if k in given:
|
||||
values[k] = clean_opt(getattr(body, k), QC_FIELD_MAX)
|
||||
if "notes" in given:
|
||||
values["notes"] = clean_opt(body.notes, LIMITS["notes"])
|
||||
if "stamp" in given:
|
||||
values["stamp"] = _stamp(body.stamp)
|
||||
if "nace" in given:
|
||||
values["nace"] = body.nace
|
||||
base: dict[str, Any] = {}
|
||||
if_match: int | None = None
|
||||
if cas:
|
||||
assert body.set is not None
|
||||
bad = sorted(k for k in body.set if k not in QC_FIELDS)
|
||||
if bad:
|
||||
raise ApiError(422, "invalid_field", f"Not a Q.C. field: {', '.join(bad)}.")
|
||||
values = {k: _qc_value(k, v) for k, v in body.set.items()}
|
||||
for k, v in (body.base or {}).items():
|
||||
if k in QC_FIELDS:
|
||||
base[k] = clean_opt(v, LIMITS["notes"] if k == "notes" else QC_FIELD_MAX)
|
||||
else:
|
||||
given = body.model_fields_set
|
||||
if body.checks:
|
||||
bad = sorted(set(body.checks) - set(checklists.qc_keys(checklists.load_qc_items(d.conn))))
|
||||
if bad:
|
||||
raise ApiError(422, "invalid_key", f"Not a Q.C. item: {', '.join(bad)}.")
|
||||
values = {k: _qc_value(k, getattr(body, k)) for k in QC_FIELDS if k in given}
|
||||
if_match = parse_if_match(request)
|
||||
if values.get("nde_method") == qc_dom.NDE_NONE:
|
||||
values["nde_result"] = None
|
||||
values["nde_report"] = None
|
||||
pp = _pp(d, p)
|
||||
conn = d.conn
|
||||
with packet_write(d, p, packet_id, A.EDIT_QC, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
|
||||
insp = packets.one(
|
||||
conn, "SELECT * FROM qc_inspections WHERE packet_id = %s AND closed_at IS NULL", (packet_id,)
|
||||
)
|
||||
if insp is None:
|
||||
raise ApiError(409, "no_inspection", MSG_NO_INSPECTION)
|
||||
check_row_version(insp, if_match)
|
||||
changes = _diff(insp, values)
|
||||
insp = _open_inspection(conn, packet_id)
|
||||
if cas:
|
||||
changes = apply_field_cas({k: insp[k] for k in QC_FIELDS}, values, base)
|
||||
else:
|
||||
check_row_version(insp, if_match)
|
||||
changes = _diff(insp, values)
|
||||
scalar = dict(changes)
|
||||
at = conn.execute("SELECT now()").fetchone()[0].isoformat()
|
||||
by = str(w.user_id)
|
||||
if body.checks:
|
||||
if not cas and body.checks:
|
||||
stamp = values.get("stamp", insp["stamp"])
|
||||
checks = dict(insp["checks"] or {})
|
||||
for k, done in body.checks.items():
|
||||
@@ -1503,7 +1668,7 @@ def patch_qc(
|
||||
checks[k] = {"done": True, "at": at, "by": by, "stamp": stamp} if done else {"done": False}
|
||||
if checks != (insp["checks"] or {}):
|
||||
changes["checks"] = Jsonb(checks)
|
||||
if body.weld_exam:
|
||||
if not cas and body.weld_exam:
|
||||
live = {
|
||||
str(r[0])
|
||||
for r in conn.execute(
|
||||
@@ -1531,7 +1696,10 @@ def patch_qc(
|
||||
return _read(d, pp, packet_id)
|
||||
update_row(conn, "qc_inspections", insp["id"], changes)
|
||||
w.changed(*[f"qc.{k}" for k in changes])
|
||||
w.log("Q.C. record changed")
|
||||
if scalar:
|
||||
_qc_logs(w, scalar)
|
||||
else:
|
||||
w.log("Q.C. record changed")
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,9 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from .. import audit, events, policy
|
||||
from ..domain import assets as asset_store
|
||||
from ..domain import ncr as ncrs
|
||||
from ..domain import packets, shop
|
||||
from ..domain import qc as qc_dom
|
||||
from ..domain.pipeline import (
|
||||
Expect,
|
||||
SignBody,
|
||||
@@ -110,6 +112,7 @@ class TransitionBody(BaseModel):
|
||||
spool: bool | None = None
|
||||
location: str | None = Field(default=None, max_length=500)
|
||||
for_sup_id: uuid.UUID | None = None
|
||||
ncr: str | None = Field(default=None, max_length=100)
|
||||
sign: SignBody | None = None
|
||||
it_reason: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
@@ -137,6 +140,7 @@ class DocBody(BaseModel):
|
||||
asset_id: uuid.UUID
|
||||
kind: str = Field(min_length=1, max_length=100)
|
||||
title: str | None = Field(default=None, max_length=500)
|
||||
note: str | None = Field(default=None, max_length=2000)
|
||||
it_reason: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
@@ -494,7 +498,7 @@ TRANSITION_FIELDS: dict[policy.Action, tuple[str, ...]] = {
|
||||
A.RETURN_TO_STATION: ("reason",),
|
||||
A.SEND_TO_QC: ("book", "spool", "location", "note"),
|
||||
A.ROUTE_FROM_REVIEW: ("dept", "note"),
|
||||
A.QC_REJECT: ("reason",),
|
||||
A.QC_REJECT: ("reason", "ncr"),
|
||||
A.QC_ACCEPT: ("routed_to", "note"),
|
||||
A.QC_ACCEPT_AND_ROUTE: ("dept", "note"),
|
||||
A.FILE: ("note",),
|
||||
@@ -534,6 +538,8 @@ def transition(
|
||||
extra["dept"] = shop_dept(conn, extra["dept"])
|
||||
if "for_sup_id" in extra:
|
||||
extra["for_sup_id"] = active_user(conn, extra["for_sup_id"], "supervisor")
|
||||
if action is A.QC_REJECT:
|
||||
_reject_reason(extra)
|
||||
if "stamp" in extra:
|
||||
# The assign sheet's stamp overrides the welder's own for this assignment; empty means use theirs.
|
||||
stamp = clean_text(extra["stamp"], 40)
|
||||
@@ -570,6 +576,21 @@ def transition(
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
def _reject_reason(extra: dict[str, Any]) -> None:
|
||||
"""A Q.C. reject takes a reason, an NCR number, or both. The NCR stands in for the reason text: the reason the
|
||||
policy checks is "NCR-2026-0021" alone, or "NCR-2026-0021 · <reason>". Without an NCR the reason needs its usual
|
||||
8 characters."""
|
||||
text = clean_text(extra.pop("reason", None), LIMITS["reason"])
|
||||
raw = clean_text(extra.pop("ncr", None), 100)
|
||||
ncr = ncrs.validate_ncr(raw) if raw else None
|
||||
if ncr:
|
||||
extra["ncr"] = ncr
|
||||
extra["reason"] = f"{ncr} · {text}" if text else ncr
|
||||
elif text:
|
||||
extra["reason"] = text
|
||||
extra["reason_text"] = text
|
||||
|
||||
|
||||
@router.post("/{packet_id}/reopen", dependencies=WRITE)
|
||||
def reopen(
|
||||
packet_id: int,
|
||||
@@ -750,27 +771,35 @@ def add_doc(
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""A report for the data book (NDE, hydrotest, certificates, NCRs). Never a drawing: drawings come from M-Files."""
|
||||
conn = d.conn
|
||||
kind = clean_text(body.kind, 40)
|
||||
if kind not in qc_dom.DOC_KINDS:
|
||||
raise ApiError(422, "invalid_kind", "Pick a document type from the list.")
|
||||
asset = asset_store.get(conn, body.asset_id)
|
||||
if asset is None:
|
||||
raise ApiError(404, "not_found", "No such file.")
|
||||
kind = clean_text(body.kind, 40)
|
||||
if not kind:
|
||||
raise ApiError(422, "invalid_request", "Say what kind of document this is.")
|
||||
title = clean_opt(body.title, LIMITS["doc_title"])
|
||||
if asset["mime"] not in qc_dom.DOC_MIMES:
|
||||
raise ApiError(422, "invalid_asset", "The file must be a PDF, PNG or JPEG.")
|
||||
label = qc_dom.DOC_KINDS[kind]
|
||||
title = clean_opt(body.title, LIMITS["doc_title"]) or label
|
||||
note = clean_opt(body.note, qc_dom.DOC_NOTE_MAX)
|
||||
pp = _pp(d, p)
|
||||
with packet_write(d, p, packet_id, A.DOC_ADD, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
|
||||
doc_id = conn.execute(
|
||||
"INSERT INTO packet_docs (packet_id, asset_id, kind, title, added_by) VALUES (%s, %s, %s, %s, %s)"
|
||||
" RETURNING id",
|
||||
(packet_id, asset["id"], kind, title, p.user_id),
|
||||
"INSERT INTO packet_docs (packet_id, asset_id, kind, title, note, added_by)"
|
||||
" VALUES (%s, %s, %s, %s, %s, %s) RETURNING id",
|
||||
(packet_id, asset["id"], kind, title, note, p.user_id),
|
||||
).fetchone()[0]
|
||||
w.changed("docs")
|
||||
w.note = title or kind
|
||||
w.log("Document added", " · ".join(x for x in (label, title, asset.get("original_name")) if x))
|
||||
w.detail.update({"doc_id": int(doc_id), "asset_id": str(asset["id"]), "kind": kind})
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
DOC_REASON_MIN = 3
|
||||
|
||||
|
||||
@router.delete("/{packet_id}/docs/{doc_id}", dependencies=WRITE)
|
||||
def remove_doc(
|
||||
packet_id: int,
|
||||
@@ -780,23 +809,29 @@ def remove_doc(
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""Take a document out of the data book. The row and the file stay, with who, when and why."""
|
||||
body = body or ReasonBody()
|
||||
conn = d.conn
|
||||
pp = _pp(d, p)
|
||||
with packet_write(
|
||||
d, p, packet_id, A.DOC_REMOVE, request=request, it_reason=body.it_reason, policy_principal=pp
|
||||
) as w:
|
||||
reason = clean_opt(body.reason, LIMITS["reason"])
|
||||
reason = clean_text(body.reason, LIMITS["reason"])
|
||||
doc = packets.one(
|
||||
conn,
|
||||
"UPDATE packet_docs SET removed_at = now(), removed_by = %s, removed_reason = %s"
|
||||
" WHERE id = %s AND packet_id = %s AND removed_at IS NULL RETURNING title, kind",
|
||||
(p.user_id, reason, doc_id, packet_id),
|
||||
conn, "SELECT * FROM packet_docs WHERE id = %s AND packet_id = %s FOR UPDATE", (doc_id, packet_id)
|
||||
)
|
||||
if doc is None:
|
||||
raise ApiError(404, "not_found", "No such document.")
|
||||
if doc["removed_at"] is not None:
|
||||
raise ApiError(409, "already_removed", "This document is already out of the data book.")
|
||||
if len(reason) < DOC_REASON_MIN:
|
||||
raise ApiError(400, "reason_required", "Say why it comes out of the data book.")
|
||||
conn.execute(
|
||||
"UPDATE packet_docs SET removed_at = now(), removed_by = %s, removed_reason = %s WHERE id = %s",
|
||||
(p.user_id, reason, doc_id),
|
||||
)
|
||||
w.changed("docs")
|
||||
w.note = ", ".join(x for x in (doc["title"] or doc["kind"], reason) if x)
|
||||
w.log("Document taken out of the data book", " · ".join(x for x in (doc["title"] or doc["kind"], reason) if x))
|
||||
w.detail["doc_id"] = doc_id
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""The Q.C. screen: the home board and the project and unit folders with their documents.
|
||||
|
||||
Any signed-in session reads. Filing a folder document is QC_DOC_ADD (Clerical or Q.C.; admin with an IT reason),
|
||||
decided by policy.engine.check_scoped because a folder belongs to a project or a unit, not a packet. Screens read
|
||||
PostgreSQL only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .. import audit, events, policy
|
||||
from ..domain import assets as asset_store
|
||||
from ..domain import itp as itp_dom
|
||||
from ..domain import packets, qcdocs
|
||||
from ..domain import qc as dom
|
||||
from ..text import clean_opt, clean_text
|
||||
from .deps import Db, Principal, current_principal, get_db
|
||||
from .errors import ApiError
|
||||
from .packets import WRITE
|
||||
|
||||
router = APIRouter(prefix="/api/qc", tags=["qc"])
|
||||
|
||||
A = policy.Action
|
||||
|
||||
|
||||
class QcDocBody(BaseModel):
|
||||
project_id: int | None = None
|
||||
unit_id: int | None = None
|
||||
type: str = Field(min_length=1, max_length=100)
|
||||
title: str | None = Field(default=None, max_length=1000)
|
||||
rev: str | None = Field(default=None, max_length=100)
|
||||
note: str | None = Field(default=None, max_length=5000)
|
||||
asset_id: uuid.UUID
|
||||
replaces: int | None = None
|
||||
it_reason: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
@router.get("/home")
|
||||
def home(
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
project_id: int | None = Query(None),
|
||||
unit_id: int | None = Query(None),
|
||||
) -> dict[str, Any]:
|
||||
conn = d.conn
|
||||
return dom.home(conn, packets.policy_principal_for(conn, p), project_id=project_id, unit_id=unit_id)
|
||||
|
||||
|
||||
@router.get("/folders")
|
||||
def folders(
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
show: str = Query("active", pattern="^(active|all)$"),
|
||||
) -> list[dict[str, Any]]:
|
||||
return qcdocs.folders(d.conn, show)
|
||||
|
||||
|
||||
@router.get("/folders/projects/{project_id}")
|
||||
def project_folder(
|
||||
project_id: int,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
out = qcdocs.project_folder(d.conn, project_id)
|
||||
if out is None:
|
||||
raise ApiError(404, "not_found", "No such project.")
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/folders/units/{unit_id}")
|
||||
def unit_folder(
|
||||
unit_id: int,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
out = qcdocs.unit_folder(d.conn, unit_id)
|
||||
if out is None:
|
||||
raise ApiError(404, "not_found", "No such unit.")
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/docs", dependencies=WRITE)
|
||||
def add_doc(
|
||||
body: QcDocBody,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""File a document in a project or unit folder, or a new revision of one (`replaces`). Nothing is deleted: the
|
||||
old revision points at the new one. The answer is the folder read."""
|
||||
conn = d.conn
|
||||
if (body.project_id is None) == (body.unit_id is None):
|
||||
raise ApiError(422, "invalid_request", "File the document in one project or one unit.")
|
||||
doc_type = clean_text(body.type, 100)
|
||||
if doc_type not in qcdocs.QC_TYPES:
|
||||
raise ApiError(422, "invalid_type", "Pick a document type from the list.")
|
||||
asset = asset_store.get(conn, body.asset_id)
|
||||
if asset is None:
|
||||
raise ApiError(404, "not_found", "No such file.")
|
||||
if asset["mime"] not in qcdocs.DOC_MIMES:
|
||||
raise ApiError(422, "invalid_asset", "The file must be a PDF, PNG or JPEG.")
|
||||
title = clean_opt(body.title, qcdocs.TITLE_MAX) or doc_type
|
||||
rev = clean_opt(body.rev, qcdocs.REV_MAX)
|
||||
note = clean_opt(body.note, qcdocs.NOTE_MAX)
|
||||
pp = packets.policy_principal_for(conn, p)
|
||||
decision, it_kept = itp_dom.scoped_decision(p, pp, A.QC_DOC_ADD, it_reason=body.it_reason)
|
||||
with conn.transaction():
|
||||
if body.unit_id is not None:
|
||||
folder = packets.one(
|
||||
conn,
|
||||
"SELECT u.id, pr.number || u.lot AS number FROM units u JOIN projects pr ON pr.id = u.project_id"
|
||||
" WHERE u.id = %s AND u.removed_at IS NULL FOR UPDATE OF u",
|
||||
(body.unit_id,),
|
||||
)
|
||||
scope_col = "unit_id"
|
||||
else:
|
||||
folder = packets.one(
|
||||
conn,
|
||||
"SELECT id, number FROM projects WHERE id = %s AND removed_at IS NULL FOR UPDATE",
|
||||
(body.project_id,),
|
||||
)
|
||||
scope_col = "project_id"
|
||||
if folder is None:
|
||||
raise ApiError(404, "not_found", "No such unit." if body.unit_id is not None else "No such project.")
|
||||
old = None
|
||||
if body.replaces is not None:
|
||||
old = packets.one(conn, "SELECT * FROM qc_docs WHERE id = %s FOR UPDATE", (body.replaces,))
|
||||
if old is None or old[scope_col] != folder["id"]:
|
||||
raise ApiError(422, "invalid_replace", "That document is not in this folder.")
|
||||
if old["superseded_by"] is not None:
|
||||
raise ApiError(409, "already_superseded", "That document already has a newer revision.")
|
||||
new_id = int(
|
||||
conn.execute(
|
||||
"INSERT INTO qc_docs (project_id, unit_id, type, title, rev, note, asset_id, added_by)"
|
||||
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING id",
|
||||
(body.project_id, body.unit_id, doc_type, title, rev, note, asset["id"], p.user_id),
|
||||
).fetchone()[0]
|
||||
)
|
||||
if old is not None:
|
||||
conn.execute(
|
||||
"UPDATE qc_docs SET superseded_by = %s, superseded_at = now(), superseded_by_user = %s WHERE id = %s",
|
||||
(new_id, p.user_id, old["id"]),
|
||||
)
|
||||
title_rev = f"{title} Rev {rev}" if rev else title
|
||||
text_note = " · ".join(x for x in (folder["number"], doc_type, title_rev, asset.get("original_name")) if x)
|
||||
revise = old is not None
|
||||
detail: dict[str, Any] = {
|
||||
"text": "Q.C. document revised" if revise else "Q.C. document added",
|
||||
"note": text_note,
|
||||
"doc_id": new_id,
|
||||
"project_id": body.project_id,
|
||||
"unit_id": body.unit_id,
|
||||
}
|
||||
if revise:
|
||||
detail["replaces"] = old["id"] if old else None
|
||||
audit.append(
|
||||
conn,
|
||||
action="qc_doc.revise" if revise else "qc_doc.add",
|
||||
user_id=p.user_id,
|
||||
session_id=p.session_id,
|
||||
role_snapshot=p.role_snapshot,
|
||||
subject_type="qc_doc",
|
||||
subject_id=new_id,
|
||||
detail=detail,
|
||||
it_reason=it_kept,
|
||||
ip=p.ip,
|
||||
)
|
||||
events.publish(
|
||||
conn,
|
||||
{"event": "changed", "kind": "qc_doc", "project_id": body.project_id, "unit_id": body.unit_id},
|
||||
)
|
||||
del decision
|
||||
if body.unit_id is not None:
|
||||
out = qcdocs.unit_folder(conn, body.unit_id)
|
||||
else:
|
||||
assert body.project_id is not None
|
||||
out = qcdocs.project_folder(conn, body.project_id)
|
||||
assert out is not None
|
||||
return out
|
||||
Reference in New Issue
Block a user