- 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]>
193 lines
7.9 KiB
Python
193 lines
7.9 KiB
Python
"""Named preconditions. Each takes (state, extra) and returns the reason when it fails, else None.
|
|
|
|
They are checked on the server and never bypassed, including by Admin. Some read request input (extra) rather
|
|
than packet state; `can()` skips those, because they are inputs the user gives with the step.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from collections.abc import Callable, Mapping
|
|
from typing import Any
|
|
|
|
from ..text import clean_text, reason_ok
|
|
from .actions import SUPPORT_CODES
|
|
from .principal import PacketState
|
|
|
|
Precondition = Callable[[PacketState, Mapping[str, Any]], str | None]
|
|
|
|
MESSAGES: dict[str, str] = {
|
|
"heat_map_answered": "Say whether this packet needs a heat map.",
|
|
"drawing_attached": "Attach the drawing first.",
|
|
"bom_all_pulled_or_short": "Pull every material line or mark it short first.",
|
|
"heats_recorded": (
|
|
"Scan or type the heat number of every pulled line first. Use N/A for a part with no traceability."
|
|
),
|
|
"short_open": "No part is short on this packet.",
|
|
"welder_set": "Pick a station and a welder.",
|
|
"checklist_complete": "Finish the checklist first.",
|
|
"welds_recorded": "Record at least one weld first.",
|
|
"filler_set": "Enter the filler metal first.",
|
|
"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.",
|
|
"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.",
|
|
}
|
|
|
|
# Preconditions that read request input, not packet state. can() skips them.
|
|
INPUT_ONLY: frozenset[str] = frozenset(
|
|
{
|
|
"welder_set",
|
|
"target_dept_given",
|
|
"route_given",
|
|
"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:
|
|
return None if ok else MESSAGES[name]
|
|
|
|
|
|
def heat_map_answered(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
|
return _fail("heat_map_answered", state.heat_map is not None)
|
|
|
|
|
|
def drawing_attached(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
|
return _fail("drawing_attached", state.drawing_attached)
|
|
|
|
|
|
def bom_all_pulled_or_short(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
|
return _fail("bom_all_pulled_or_short", state.bom_ready >= state.bom_total)
|
|
|
|
|
|
def heats_recorded(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
|
"""Every pulled line that issued pieces has a heat, unless it is N/A or a sub-assembly."""
|
|
return _fail("heats_recorded", state.bom_missing_heat <= 0)
|
|
|
|
|
|
def short_open(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
|
"""The old whCanReceive: the warehouse may only receive into a packet with an open short."""
|
|
return _fail("short_open", state.open_shorts > 0)
|
|
|
|
|
|
def welder_set(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
|
return _fail("welder_set", extra.get("station_id") not in (None, "") and extra.get("welder_id") not in (None, ""))
|
|
|
|
|
|
def checklist_complete(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
|
return _fail("checklist_complete", state.checklist_complete)
|
|
|
|
|
|
def welds_recorded(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
|
return _fail("welds_recorded", not state.weld_dept or state.welds > 0)
|
|
|
|
|
|
def filler_set(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
|
return _fail("filler_set", not state.weld_dept or state.filler_set)
|
|
|
|
|
|
def qc_book_spool_location(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
|
ok = extra.get("book") is True and extra.get("spool") is True and bool(clean_text(extra.get("location"), 120))
|
|
return _fail("qc_book_spool_location", ok)
|
|
|
|
|
|
def qc_missing_empty(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
|
if not state.qc_missing:
|
|
return None
|
|
return "Q.C. still needs: " + ", ".join(state.qc_missing) + "."
|
|
|
|
|
|
def reason_given(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
|
return _fail("reason_given", reason_ok(extra.get("reason")) is not None)
|
|
|
|
|
|
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:
|
|
return _fail("co_staged_if_dwg_change", extra.get("dwg_change") is not True or extra.get("staged") is True)
|
|
|
|
|
|
def route_given(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
|
return _fail("route_given", extra.get("routed_to") in ROUTES)
|
|
|
|
|
|
def is_shop_code(value: Any) -> bool:
|
|
"""A non-empty department code that is not a support code. The API checks it exists."""
|
|
return isinstance(value, str) and bool(value.strip()) and value not in SUPPORT_CODES
|
|
|
|
|
|
def target_dept_given(state: PacketState, extra: Mapping[str, Any]) -> str | None:
|
|
"""A shop department different from the packet's. For a draft (RELEASE_TO_SHOP) any shop department."""
|
|
dept = extra.get("dept")
|
|
if not is_shop_code(dept):
|
|
return "Choose a department."
|
|
if state.status != "draft" and dept == state.dept:
|
|
return MESSAGES["target_dept_given"]
|
|
return None
|
|
|
|
|
|
PRECONDITIONS: dict[str, Precondition] = {
|
|
"heat_map_answered": heat_map_answered,
|
|
"drawing_attached": drawing_attached,
|
|
"bom_all_pulled_or_short": bom_all_pulled_or_short,
|
|
"heats_recorded": heats_recorded,
|
|
"short_open": short_open,
|
|
"welder_set": welder_set,
|
|
"checklist_complete": checklist_complete,
|
|
"welds_recorded": welds_recorded,
|
|
"filler_set": filler_set,
|
|
"qc_book_spool_location": qc_book_spool_location,
|
|
"qc_missing_empty": qc_missing_empty,
|
|
"reason_given": reason_given,
|
|
"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,
|
|
}
|