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,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