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

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

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-17 20:08:53 -06:00
co-authored by Claude Fable 5.1
parent 8bb1f7ae4d
commit 00531cb329
64 changed files with 11715 additions and 260 deletions
+44
View File
@@ -91,6 +91,11 @@ class Action(StrEnum):
CO_REJECT = "co_reject"
CO_HOLD_TOGGLE = "co_hold_toggle"
CO_VERIFY = "co_verify"
QC_DOC_ADD = "qc_doc_add"
ITP_START = "itp_start"
ITP_LINE_SIGN = "itp_line_sign"
ITP_LINE_UNSIGN = "itp_line_unsign"
ITP_BLOCK_UNSIGN = "itp_block_unsign"
ITP_EDIT_LINE = "itp_edit_line"
ITP_BLOCK_SIGN = "itp_block_sign"
ITP_SIGN_OFF = "itp_sign_off"
@@ -270,6 +275,12 @@ RULES: dict[Action, Rule] = {
),
A.CO_HOLD_TOGGLE: Rule(Who.CL, ANY, None),
A.CO_VERIFY: Rule(Who.CO_VERIFIER, ANY, None, signature="Verify a change order for the department"),
# Q.C. folder documents and the ITP belong to a project or a unit, not a packet: engine.check_scoped decides them.
A.QC_DOC_ADD: Rule(Who.CL_OR_QC, ANY, None),
A.ITP_START: Rule(Who.QC, ANY, None),
A.ITP_LINE_SIGN: Rule(Who.QC, ANY, None),
A.ITP_LINE_UNSIGN: Rule(Who.QC, ANY, None),
A.ITP_BLOCK_UNSIGN: Rule(Who.QC, ANY, None),
A.ITP_EDIT_LINE: Rule(Who.QC, ANY, None),
A.ITP_BLOCK_SIGN: Rule(Who.QC, ANY, None, signature="Sign an ITP block"),
A.ITP_SIGN_OFF: Rule(Who.QC, ANY, None, signature="Sign off the ITP"),
@@ -313,8 +324,41 @@ EDIT_WHAT: dict[Action, str] = {
A.CO_REJECT: "change orders",
A.CO_HOLD_TOGGLE: "change orders",
A.CO_VERIFY: "change orders",
A.QC_DOC_ADD: "the Q.C. folder",
A.ITP_START: "the ITP",
A.ITP_LINE_SIGN: "the ITP",
A.ITP_LINE_UNSIGN: "the ITP",
A.ITP_BLOCK_UNSIGN: "the ITP",
A.ITP_EDIT_LINE: "the ITP",
A.ITP_BLOCK_SIGN: "the ITP",
A.ITP_SIGN_OFF: "the ITP",
A.ITP_REOPEN: "the ITP",
}
# The actions on a project or unit rather than a packet (check them with engine.check_scoped).
SCOPED: frozenset[Action] = frozenset(
{
A.QC_DOC_ADD,
A.ITP_START,
A.ITP_LINE_SIGN,
A.ITP_LINE_UNSIGN,
A.ITP_EDIT_LINE,
A.ITP_BLOCK_SIGN,
A.ITP_BLOCK_UNSIGN,
A.ITP_SIGN_OFF,
A.ITP_REOPEN,
}
)
# Their labels (the audit and log texts name the event more exactly).
SCOPED_LABELS: dict[Action, str] = {
A.QC_DOC_ADD: "Q.C. document added",
A.ITP_START: "ITP started",
A.ITP_LINE_SIGN: "ITP line approved",
A.ITP_LINE_UNSIGN: "ITP line initial removed",
A.ITP_BLOCK_UNSIGN: "ITP block signature removed",
A.ITP_EDIT_LINE: "ITP changed",
A.ITP_BLOCK_SIGN: "ITP block signed",
A.ITP_SIGN_OFF: "ITP signed off",
A.ITP_REOPEN: "ITP reopened",
}
+62
View File
@@ -10,6 +10,9 @@ Order of checks, exactly:
6. filed lock: only SET_DATABOOK_AT and ADMIN_REOPEN, Admin included.
7. who and custody.
8. preconditions in rule order, never bypassed.
`check_scoped` decides the actions on a project or unit (Q.C. folder documents, the ITP) the same way without a
packet: system, admin bypass, who, then the input preconditions. There is no status, CO hold, filed lock or custody.
"""
from __future__ import annotations
@@ -37,6 +40,24 @@ MSG_FILED = "This packet is filed and locked. IT can reopen it if a correction i
MSG_FORBIDDEN_STEP = "Your access doesn't allow this step."
MSG_NOT_WH_SKIPPED = "This packet came through the warehouse. Send it back to the warehouse instead."
MSG_NOT_DATABOOK = "Only packets routed to the data book are closed in VISUAL here."
MSG_FORBIDDEN_SCOPED = "Your access doesn't allow this."
# The packet view check_scoped hands the who predicates. Only predicates that ignore the packet (CL, QC, QC_SUP,
# CL_OR_QC and the like) can pass with it; HOLDER and the department predicates fail.
_NO_PACKET = PacketState(
id=0,
status="",
dept="",
station_id=None,
welder_id=None,
wh_skipped=False,
routed_to=None,
vclosed=False,
removed=False,
source_kind="hand",
weld_dept=False,
co_hold=False,
)
@dataclass(frozen=True)
@@ -181,3 +202,44 @@ def can(
elif d.reasons:
blocked[action] = list(d.reasons)
return allowed, blocked
def check_scoped(
principal: Principal,
action: Action,
*,
extra: Mapping[str, Any] | None = None,
it_reason: str | None = None,
assume_it_reason: bool = False,
) -> Decision:
"""Decide an action on a project or unit (no packet): the steps of check() that do not need one.
System principals fail `forbidden` (a system-only rule fails `system_only` for people). An admin the rule does not
allow passes with an IT reason of at least 8 characters (`admin_bypass`), else `it_reason_required`. Then who
(`forbidden`), then the input preconditions in rule order (`reason_given` fails `reason_required`). Custody is not
checked: nobody holds a project or unit. `assume_it_reason` is the read mode for an admin (what the screen offers).
"""
rule = RULES[action]
ex = extra or EMPTY
if rule.system_only and principal.system is None:
return Decision(False, "system_only", MSG_SYSTEM_ONLY)
if principal.system is not None:
return Decision(False, "forbidden", MSG_SYSTEM_FORBIDDEN)
who_ok = allows(rule.who, principal, _NO_PACKET, ex)
bypass = False
if not who_ok and principal.admin:
if assume_it_reason or reason_ok(it_reason) is not None:
bypass = True
else:
return Decision(False, "it_reason_required", MSG_IT_REASON)
if not who_ok and not bypass:
return Decision(False, "forbidden", MSG_FORBIDDEN_SCOPED)
for name in rule.preconditions:
if name not in INPUT_ONLY:
continue # a state precondition needs a packet; no scoped rule has one
failed = PRECONDITIONS[name](_NO_PACKET, ex)
if failed is None:
continue
code = "reason_required" if rule.reason_required and name == "reason_given" else "precondition"
return Decision(False, code, failed, admin_bypass=bypass)
return Decision(True, admin_bypass=bypass, signature=rule.signature)