Build Seth's changes C1 to C11 (Gitea issues 1, 8, 18, 23, 24, 25, 27, 31, 32, 33, 36)
- C1 (issue 1): over target follows VISUAL ticket hours, not the timer. - C2 (issue 24): a Q.C. reject always needs a reason; accept needs an NCR number after a reject without one. - C3 (issue 25): an incomplete unit ITP blocks filing. - C4 (issue 27): prints open the print dialog on the page, no new tab. - C5 (issue 31): filing is blocked while VISUAL shows a work order open; the feed sets vclosed on VISUAL packets. - C6 (issue 8): the data book leaves out the history pages. - C7 (issue 18): the supervisor approves each received short; accept and filing wait for it. - C8 (issue 23): per-department checklists, edited by the department supervisor or Admin, frozen at START. - C9 (issue 32): Q.C. and Clerical may run the stamp check. - C10 (issue 33): the PM screen and reports are for PS-ProjectMgmt and Admin only. - C11 (issue 36): the Stall screen lists the packets assigned to the welder, at the supervisor's stall. Migration 0011. Docs: PLAN (also issue 9 confirmed, issue 4 asked), POLICY, SECURITY, SCHEMA, FEED, README, CHANGELOG. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -71,6 +71,7 @@ class Action(StrEnum):
|
||||
EDIT_WOS = "edit_wos"
|
||||
EDIT_BOM = "edit_bom"
|
||||
WH_RECEIVE_SHORT = "wh_receive_short"
|
||||
SHORT_APPROVE = "short_approve"
|
||||
EDIT_HEATS = "edit_heats"
|
||||
EDIT_HEAT_TABLE = "edit_heat_table"
|
||||
EDIT_WELDS = "edit_welds"
|
||||
@@ -209,7 +210,7 @@ RULES: dict[Action, Rule] = {
|
||||
_s("qc"),
|
||||
"complete",
|
||||
co_hold=True,
|
||||
preconditions=("qc_missing_empty", "route_given"),
|
||||
preconditions=("qc_missing_empty", "shorts_approved", "route_given", "ncr_given"),
|
||||
signature="Q.C. accept",
|
||||
),
|
||||
A.QC_ACCEPT_AND_ROUTE: Rule(
|
||||
@@ -218,7 +219,7 @@ RULES: dict[Action, Rule] = {
|
||||
"ready",
|
||||
dept_change="required",
|
||||
co_hold=True,
|
||||
preconditions=("qc_missing_empty", "target_dept_given"),
|
||||
preconditions=("qc_missing_empty", "target_dept_given", "ncr_given"),
|
||||
signature="Q.C. accept and route",
|
||||
),
|
||||
A.FILE: Rule(
|
||||
@@ -226,7 +227,7 @@ RULES: dict[Action, Rule] = {
|
||||
_s("complete"),
|
||||
"filed",
|
||||
co_hold=True,
|
||||
preconditions=("vclosed_if_databook",),
|
||||
preconditions=("closed_in_visual", "itp_complete", "shorts_approved"),
|
||||
signature="File in the data book",
|
||||
),
|
||||
A.ADMIN_REOPEN: Rule(
|
||||
@@ -246,6 +247,8 @@ RULES: dict[Action, Rule] = {
|
||||
A.EDIT_WOS: Rule(Who.CL, _s("draft"), None),
|
||||
A.EDIT_BOM: Rule(Who.HOLDER, _s("draft", "warehouse", SHOP), None),
|
||||
A.WH_RECEIVE_SHORT: Rule(Who.WH, ANY, None, preconditions=("short_open",)),
|
||||
# The department supervisor (a lead is not enough) approves each received short; the filed lock still applies.
|
||||
A.SHORT_APPROVE: Rule(Who.DEPT_SUP, ANY, None, signature="Approve a received short"),
|
||||
A.EDIT_HEATS: Rule(Who.HOLDER, _s("warehouse", SHOP), None),
|
||||
A.EDIT_HEAT_TABLE: Rule(Who.HOLDER, _s("warehouse", SHOP), None),
|
||||
A.EDIT_WELDS: Rule(Who.HOLDER, SHOP, None),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Order of checks, exactly:
|
||||
1. removed packet: nothing passes, Admin included.
|
||||
2. status: the rule's from statuses and the status conditions (RETURN_TO_CLERICAL needs wh_skipped,
|
||||
SET_VCLOSED needs routed_to databook). An unknown status fails here.
|
||||
SET_VCLOSED needs a hand packet routed to the data book). An unknown status fails here.
|
||||
3. change-order hold, Admin included.
|
||||
4. system-only rules refuse people; the system principal passes only system-only rules.
|
||||
5. admin bypass of who and custody, with an IT reason (SET_DATABOOK_AT needs none).
|
||||
@@ -40,6 +40,7 @@ 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_FEED_CLOSES = "The VISUAL feed marks this job closed when it sees every work order closed."
|
||||
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,
|
||||
@@ -60,11 +61,15 @@ _NO_PACKET = PacketState(
|
||||
)
|
||||
|
||||
|
||||
# Preconditions with their own refusal code (the API maps reason_required to 400 and ncr_required to 422).
|
||||
PRECONDITION_CODES: dict[str, str] = {"reason_given": "reason_required", "ncr_given": "ncr_required"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Decision:
|
||||
ok: bool
|
||||
code: str = "" # removed | wrong_status | co_hold | filed | it_reason_required | forbidden | precondition
|
||||
# | reason_required | system_only
|
||||
# | reason_required | ncr_required | system_only
|
||||
why: str = "" # the message for the user
|
||||
admin_bypass: bool = False # an admin passed who and custody only through the bypass (an IT edit)
|
||||
signature: str | None = None # the rule's signature meaning, when one is required
|
||||
@@ -78,15 +83,22 @@ STATUS_CONDITIONS: dict[Action, tuple[Callable[[PacketState], bool], str]] = {
|
||||
}
|
||||
|
||||
|
||||
def _status_condition(state: PacketState, action: Action) -> str | None:
|
||||
# C5: the feed alone closes a VISUAL packet in VISUAL; Clerical confirms a hand packet only.
|
||||
if action is Action.SET_VCLOSED and state.source_kind == "visual":
|
||||
return MSG_FEED_CLOSES
|
||||
cond = STATUS_CONDITIONS.get(action)
|
||||
if cond is not None and not cond[0](state):
|
||||
return cond[1]
|
||||
return None
|
||||
|
||||
|
||||
def _wrong_status(state: PacketState, action: Action, rule: Rule) -> str | None:
|
||||
if state.status not in STATUSES:
|
||||
return MSG_UNKNOWN_STATUS
|
||||
if rule.from_status and state.status not in rule.from_status:
|
||||
return f"This step isn't open while the packet is {STATUS_LABELS[state.status]}."
|
||||
cond = STATUS_CONDITIONS.get(action)
|
||||
if cond is not None and not cond[0](state):
|
||||
return cond[1]
|
||||
return None
|
||||
return _status_condition(state, action)
|
||||
|
||||
|
||||
def _forbidden_why(action: Action, rule: Rule, state: PacketState, extra: Mapping[str, Any]) -> str:
|
||||
@@ -155,7 +167,9 @@ def _evaluate(
|
||||
if read:
|
||||
blockers.append(failed)
|
||||
continue
|
||||
code = "reason_required" if rule.reason_required and name == "reason_given" else "precondition"
|
||||
code = PRECONDITION_CODES.get(name, "precondition")
|
||||
if name == "reason_given" and not rule.reason_required:
|
||||
code = "precondition"
|
||||
return Decision(False, code, failed, admin_bypass=bypass)
|
||||
if blockers:
|
||||
code = "co_hold" if rule.co_hold and state.co_hold else "precondition"
|
||||
|
||||
@@ -6,6 +6,7 @@ than packet state; `can()` skips those, because they are inputs the user gives w
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
@@ -30,7 +31,10 @@ MESSAGES: dict[str, str] = {
|
||||
"qc_book_spool_location": "Confirm the book and the spool, and give the location.",
|
||||
"qc_missing_empty": "Q.C. is not finished.",
|
||||
"reason_given": "Give a reason of at least 8 characters.",
|
||||
"vclosed_if_databook": "Close the job in VISUAL first.",
|
||||
"closed_in_visual": "Close the job in VISUAL first.",
|
||||
"itp_complete": "Complete the unit's ITP first. Q.C. signs it off on the ITP page.",
|
||||
"shorts_approved": "Every short must be received and approved by the supervisor first.",
|
||||
"ncr_given": "Enter the NCR number of the reject first.",
|
||||
"co_staged_if_dwg_change": "Stage the new drawing for the change order first.",
|
||||
"route_given": "Choose Data Book or Shipping.",
|
||||
"target_dept_given": "Choose a different department.",
|
||||
@@ -45,10 +49,12 @@ INPUT_ONLY: frozenset[str] = frozenset(
|
||||
"reason_given",
|
||||
"qc_book_spool_location",
|
||||
"co_staged_if_dwg_change",
|
||||
"ncr_given",
|
||||
}
|
||||
)
|
||||
|
||||
ROUTES = ("databook", "shipping")
|
||||
RX_NCR = re.compile(r"^NCR-\d{4}-\d{4}$") # domain/ncr.py validates and normalises; this only checks the form
|
||||
|
||||
|
||||
def _fail(name: str, ok: bool) -> str | None:
|
||||
@@ -108,8 +114,36 @@ def reason_given(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
||||
return _fail("reason_given", reason_ok(extra.get("reason")) is not None)
|
||||
|
||||
|
||||
def vclosed_if_databook(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
||||
return _fail("vclosed_if_databook", state.routed_to != "databook" or state.vclosed)
|
||||
def closed_in_visual(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
||||
"""FILE waits for the job to be closed in VISUAL (C5).
|
||||
|
||||
A VISUAL packet, on either route: the feed sets vclosed when it sees every work order closed, and no work order
|
||||
may show open. A hand packet has no feed state: on the data book route Clerical confirms the close by hand."""
|
||||
if state.source_kind != "visual":
|
||||
return _fail("closed_in_visual", state.routed_to != "databook" or state.vclosed)
|
||||
if state.visual_open_wos:
|
||||
wos = ", ".join(state.visual_open_wos)
|
||||
return f"VISUAL still shows work orders open on this packet: {wos}. Filing waits until VISUAL closes them."
|
||||
if not state.vclosed:
|
||||
return "Filing waits until the VISUAL feed sees the job closed."
|
||||
return None
|
||||
|
||||
|
||||
def itp_complete(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
||||
"""A unit with no ITP does not block (C3)."""
|
||||
return _fail("itp_complete", not state.itp_open)
|
||||
|
||||
|
||||
def shorts_approved(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
||||
"""No open short and no received short without the supervisor's approval (C7)."""
|
||||
return _fail("shorts_approved", state.shorts_unapproved <= 0)
|
||||
|
||||
|
||||
def ncr_given(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
||||
"""After a Q.C. reject with no NCR number, the accept carries one (C2). State plus input, so can() skips it."""
|
||||
if not state.ncr_required:
|
||||
return None
|
||||
return _fail("ncr_given", bool(RX_NCR.match(clean_text(extra.get("ncr"), 100).upper())))
|
||||
|
||||
|
||||
def co_staged_if_dwg_change(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
||||
@@ -148,7 +182,10 @@ PRECONDITIONS: dict[str, Precondition] = {
|
||||
"qc_book_spool_location": qc_book_spool_location,
|
||||
"qc_missing_empty": qc_missing_empty,
|
||||
"reason_given": reason_given,
|
||||
"vclosed_if_databook": vclosed_if_databook,
|
||||
"closed_in_visual": closed_in_visual,
|
||||
"itp_complete": itp_complete,
|
||||
"shorts_approved": shorts_approved,
|
||||
"ncr_given": ncr_given,
|
||||
"co_staged_if_dwg_change": co_staged_if_dwg_change,
|
||||
"route_given": route_given,
|
||||
"target_dept_given": target_dept_given,
|
||||
|
||||
@@ -55,6 +55,10 @@ class PacketState:
|
||||
filler_set: bool = False
|
||||
qc_missing: tuple[str, ...] = () # the old qcMissing() list, computed by the read model
|
||||
has_qc_sent: bool = False # qc_inspections row exists for the current stage
|
||||
shorts_unapproved: int = 0 # open shorts plus received shorts the supervisor has not approved (C7)
|
||||
itp_open: bool = False # the packet's unit has an ITP that is not complete (C3)
|
||||
ncr_required: bool = False # a Q.C. reject since the last accept has no NCR number (C2)
|
||||
visual_open_wos: tuple[str, ...] = () # the work orders the feed last saw open in VISUAL (C5)
|
||||
|
||||
|
||||
SYSTEM_FEED = Principal(user_id=None, system="visual-feed")
|
||||
|
||||
Reference in New Issue
Block a user