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]>
745 lines
30 KiB
Python
745 lines
30 KiB
Python
"""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)
|