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:
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user