Build Seth's changes C1 to C11 (Gitea issues 1, 8, 18, 23, 24, 25, 27, 31, 32, 33, 36)
ci / frontend (pull_request) Successful in 57s
ci / server (pull_request) Successful in 12m33s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 5m4s

- 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:
2026-10-30 13:33:51 -06:00
co-authored by Claude Opus 5.5
parent 1ae6819289
commit 1cd6688247
101 changed files with 3871 additions and 1098 deletions
+8 -41
View File
@@ -1,11 +1,11 @@
"""Admin: departments and stations, people's profiles (one at a time and the CSV import), checklist templates.
"""Admin: departments and stations, people's profiles (one at a time and the CSV import), the default checklists by
kind (weld and plain; each department's own list is at /api/checklists, change C8).
Every route needs the admin role and a password session. Every change is audited (`admin.*`).
"""
from __future__ import annotations
import re
import uuid
from typing import Any
@@ -171,9 +171,6 @@ def profile_import(
# ---------------------------------------------------------------- checklists
RX_BRACES = re.compile(r"\{[^{}]*\}")
ALLOWED_PLACEHOLDERS = ("{drawing}", "{rev}", "{wps}")
class ChecklistsPut(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -183,7 +180,8 @@ class ChecklistsPut(BaseModel):
def check_templates(value: Any) -> tuple[dict[str, list[dict[str, str]]], list[str]]:
"""The checklist templates cleaned, and one error line per problem."""
"""The default checklists by kind (weld and plain) cleaned, and one error line per problem. A department with
its own list (change C8, /api/checklists) does not read these."""
errors: list[str] = []
out: dict[str, list[dict[str, str]]] = {}
if not isinstance(value, dict):
@@ -192,41 +190,10 @@ def check_templates(value: Any) -> tuple[dict[str, list[dict[str, str]]], list[s
if extra:
errors.append(f"unknown list: {', '.join(extra)}")
for name in ("weld", "plain"):
items = value.get(name)
if not isinstance(items, list) or not items:
errors.append(f"{name}: at least one item")
continue
if len(items) > checklists.ITEMS_MAX:
errors.append(f"{name}: at most {checklists.ITEMS_MAX} items")
seen: set[str] = set()
clean: list[dict[str, str]] = []
for i, it in enumerate(items, start=1):
where = f"{name} item {i}"
if not isinstance(it, dict):
errors.append(f"{where}: must be an object with group, key and text")
continue
group = " ".join(str(it.get("group") or "").split())
key = str(it.get("key") or "").strip()
text = " ".join(str(it.get("text") or "").split())
if not checklists.RX_KEY.match(key):
errors.append(f"{where}: the key is a lower-case letter, then letters, digits or _ (at most 30)")
elif key in seen:
errors.append(f"{where}: key {key} is used twice")
seen.add(key)
if not group:
errors.append(f"{where}: the group is empty")
if not text:
errors.append(f"{where}: the text is empty")
if len(group) > checklists.TEXT_MAX or len(text) > checklists.TEXT_MAX:
errors.append(f"{where}: at most {checklists.TEXT_MAX} characters")
bad = [m for m in RX_BRACES.findall(text) if m not in ALLOWED_PLACEHOLDERS]
stray = RX_BRACES.sub("", text)
if bad:
errors.append(f"{where}: unknown placeholder {', '.join(bad)} (use {{drawing}}, {{rev}} or {{wps}})")
elif "{" in stray or "}" in stray:
errors.append(f"{where}: a brace without its pair")
clean.append({"group": group, "key": key, "text": text})
out[name] = clean
clean, errs = checklists.check_list(name, value.get(name))
errors += errs
if clean is not None:
out[name] = clean
return out, errors
+26 -4
View File
@@ -1,7 +1,8 @@
"""Admin: the Security tab (settings, alerts, the integrity result), the access log and its CSV, who is in now, the
stamp check, and the directory flags.
Every route needs the admin role and a password session. Every change is audited (`admin.*`).
Every route needs the admin role and a password session, except the stamp check: Admin, Q.C. and Clerical on a password
session, at /api/stamp-verify (change C9) and at the old /api/admin paths. Every change is audited (`admin.*`).
"""
from __future__ import annotations
@@ -26,12 +27,23 @@ from ..auth import secsettings
from ..domain import security_alerts
from ..pdf import stamps, tracecsv
from .account import LOG_PREFIXES
from .deps import Db, Principal, admin_principal, get_db, idle_minutes, settings_of
from .deps import (
Db,
Principal,
admin_principal,
current_principal,
get_db,
idle_minutes,
require_password_session,
settings_of,
)
from .errors import ApiError
from .jobs import run_out
from .packets import WRITE
router = APIRouter(prefix="/api/admin", tags=["admin"])
# The stamp check outside /admin (change C9): Admin, Q.C. and Clerical. The /api/admin paths stay as aliases.
stamp_router = APIRouter(prefix="/api", tags=["stamp"])
CSV = "text/csv; charset=utf-8"
PDF_MAX_BYTES = 25 * 1024 * 1024
@@ -499,6 +511,14 @@ def check_stamp(conn: Any, key: bytes | None, sig: str | None, shown: str | None
return out
def stamp_principal(p: Principal = Depends(current_principal)) -> Principal: # noqa: B008
"""Admin, every Q.C. role and every Clerical role (change C9, Gitea issue 32), on a password session as the Admin
path always needed."""
if not (p.is_admin or any(r.startswith(("qc", "clerical")) for r in p.roles)):
raise ApiError(403, "forbidden", "The stamp check is for Admin, Q.C. and Clerical.")
return require_password_session(p)
def _seal_key(request: Request) -> bytes | None:
try:
return settings_of(request).seal_key()
@@ -526,10 +546,11 @@ def _audit_verify(conn: Any, p: Principal, results: list[dict[str, Any]], source
@router.post("/stamp-verify", dependencies=WRITE)
@stamp_router.post("/stamp-verify", dependencies=WRITE)
def stamp_verify(
body: StampText,
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
p: Principal = Depends(stamp_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
text = body.text
@@ -569,9 +590,10 @@ def pdf_stamps(data: bytes) -> list[tuple[str, str]]:
@router.post("/stamp-verify/pdf")
@stamp_router.post("/stamp-verify/pdf")
async def stamp_verify_pdf(
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
p: Principal = Depends(stamp_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
length = request.headers.get("content-length")
+155
View File
@@ -0,0 +1,155 @@
"""Per-department checklists (change C8, Gitea issue 23).
Every signed-in session reads them. A department's supervisor edits that department's list and Admin edits any;
a lead, a worker, Q.C. and the office roles do not. A save needs a password session, carries the version it started
from (`base`, or If-Match) and writes one audit row (`checklists.dept_updated`). A change reaches only stages that
have not started: a stage freezes its list at START. `items: null` sets the department back to the default for its
kind (Admin's weld or plain list at /api/admin/checklists). The Q.C. check items are not here.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Request
from fastapi.encoders import jsonable_encoder
from psycopg.types.json import Jsonb
from pydantic import BaseModel, ConfigDict, Field
from .. import audit, policy
from ..domain import checklists, packets
from ..policy.custody import SUPERVISOR
from .deps import Db, Principal, current_principal, get_db, require_password_session
from .errors import ApiError
from .packets import WRITE
router = APIRouter(prefix="/api/checklists", tags=["checklists"])
MSG_NOT_YOURS = "Only this department's supervisor or an Admin can change its checklist."
MSG_STALE = "Someone else changed this checklist. Reload and try again."
MSG_NO_DEPT = "No such shop department."
class DeptChecklistPut(BaseModel):
model_config = ConfigDict(extra="forbid")
items: list[Any] | None = Field(max_length=checklists.ITEMS_MAX * 2)
base: int | None = Field(None, ge=0)
def can_edit(pp: policy.Principal, dept_code: str) -> bool:
"""Admin, or the department's Supervisor (a lead is not enough). Q.C. never edits a department list."""
return pp.admin or pp.level(dept_code) >= SUPERVISOR
def _shop_departments(conn: Any) -> list[dict[str, Any]]:
return packets.rows(
conn,
"SELECT code, name, weld FROM departments WHERE kind = 'shop' AND active ORDER BY sort, code",
)
def _dept(conn: Any, code: str) -> dict[str, Any]:
d = packets.one(conn, "SELECT code, name, weld FROM departments WHERE code = %s AND kind = 'shop'", (code,))
if d is None:
raise ApiError(404, "not_found", MSG_NO_DEPT)
return d
def _summary(conn: Any, pp: policy.Principal, d: dict[str, Any]) -> dict[str, Any]:
own, version = checklists.load_dept(conn, d["code"])
return {
"dept_code": d["code"],
"dept_name": d["name"],
"weld": bool(d["weld"]),
"custom": own is not None,
"version": version,
"editable": can_edit(pp, d["code"]),
}
def _one(conn: Any, pp: policy.Principal, d: dict[str, Any]) -> dict[str, Any]:
out = _summary(conn, pp, d)
out["items"] = checklists.live_list(conn, d["code"], bool(d["weld"]))
out["defaults"] = checklists.default_for(conn, bool(d["weld"]))
return out
@router.get("")
def list_checklists(
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> list[dict[str, Any]]:
"""Every active shop department: whether it has its own list, the version and whether the caller may edit."""
conn = d.conn
pp = packets.policy_principal_for(conn, p)
return [_summary(conn, pp, x) for x in _shop_departments(conn)]
@router.get("/{dept}")
def get_checklist(
dept: str,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""One department's live list (its own, else the default for its kind), unfilled: {drawing}, {rev} and {wps}
stay as placeholders. `defaults` is the default for its kind."""
conn = d.conn
pp = packets.policy_principal_for(conn, p)
return _one(conn, pp, _dept(conn, dept[:10]))
@router.put("/{dept}", dependencies=WRITE)
def put_checklist(
dept: str,
body: DeptChecklistPut,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
conn = d.conn
pp = packets.policy_principal_for(conn, p)
row = _dept(conn, dept[:10])
code = str(row["code"])
if not can_edit(pp, code):
raise ApiError(403, "forbidden", MSG_NOT_YOURS)
require_password_session(p)
base = body.base
if base is None:
tag = (request.headers.get("if-match") or "").strip().strip('"').removeprefix("W/").strip('"')
if not tag.isdigit():
raise ApiError(428, "if_match_required", "Send the version you started from.")
base = int(tag)
value: dict[str, Any]
if body.items is None:
value = {"items": None}
else:
clean, errors = checklists.check_list("checklist", body.items)
if errors or clean is None:
raise ApiError(422, "bad_checklist", errors[0], extra={"errors": errors})
value = {"items": clean}
key = checklists.dept_key(code)
with conn.transaction():
cur = conn.execute("SELECT version FROM settings WHERE key = %s FOR UPDATE", (key,)).fetchone()
current = int(cur[0]) if cur else 0
if base != current:
raise ApiError(409, "version_conflict", MSG_STALE, extra={"current": jsonable_encoder(_one(conn, pp, row))})
before = checklists.load_dept(conn, code)[0]
conn.execute(
"INSERT INTO settings (key, value, version, updated_at, updated_by) VALUES (%s, %s, 1, now(), %s)"
" ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, version = settings.version + 1,"
" updated_at = now(), updated_by = EXCLUDED.updated_by",
(key, Jsonb(value), p.user_id),
)
audit.append(
conn,
action="checklists.dept_updated",
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
subject_type="settings",
subject_id=key,
detail={"dept": code, "before": before, "after": value["items"]},
ip=p.ip,
)
return _one(conn, pp, row)
@@ -170,6 +170,11 @@ class ReceiptBody(It):
note: str | None = Field(default=None, max_length=2000)
class ShortApproveBody(It):
sign: SignBody | None = None
note: str | None = Field(default=None, max_length=2000)
class WeldBody(It):
page: int | None = Field(default=None, ge=1, le=100_000)
fx: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
@@ -880,6 +885,75 @@ def receive_short(
return _read(d, pp, packet_id)
def _not_approved(conn: Any, receipt_id: int) -> None:
if conn.execute("SELECT 1 FROM short_approvals WHERE receipt_id = %s", (receipt_id,)).fetchone():
raise ApiError(409, "already_approved", "This receipt is already approved.")
@router.post("/{packet_id}/shorts/receipts/{receipt_id}/approve", dependencies=WRITE)
def approve_short_receipt(
packet_id: int,
receipt_id: int,
body: ShortApproveBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""The department supervisor approves one received short (C7): the part and its heat number match what the
warehouse issued. One approval per receipt; a second answers 409 already_approved."""
note = clean_opt(body.note, LIMITS["note"])
pp = _pp(d, p)
conn = d.conn
receipt = packets.one(
conn,
"SELECT r.*, b.item FROM short_receipts r JOIN bom_shorts s ON s.id = r.short_id"
" JOIN bom_lines b ON b.id = s.bom_line_id WHERE r.id = %s AND r.packet_id = %s",
(receipt_id, packet_id),
)
if receipt is None:
raise ApiError(404, "not_found", "No such short receipt on this packet.")
_not_approved(conn, receipt_id) # before the signature: a second approval costs no attempt
content = {
"action": str(A.SHORT_APPROVE),
"packet_id": packet_id,
"receipt_id": receipt_id,
"item": receipt["item"],
"qty": qty_text(receipt["qty"]),
"heat": receipt["heat"],
"note": note,
}
with packet_write(
d,
p,
packet_id,
A.SHORT_APPROVE,
request=request,
it_reason=body.it_reason,
sign=body.sign,
sign_content=content,
policy_principal=pp,
) as w:
_not_approved(conn, receipt_id) # again under the packet lock: a racing approval waits, then sees the first
conn.execute(
"INSERT INTO short_approvals (receipt_id, short_id, packet_id, dept_code, note, signature_id, by)"
" VALUES (%s, %s, %s, %s, %s, %s, %s)",
(
receipt_id,
receipt["short_id"],
packet_id,
w.state.dept,
note,
w.signature.id if w.signature else None,
w.user_id,
),
)
w.changed("bom.short")
w.detail.update({"item": receipt["item"], "receipt_id": receipt_id})
parts = [f"{qty_text(receipt['qty'])} pc", receipt["heat"] and f"heat {receipt['heat']}", note]
w.log(f"Short approved · item {receipt['item']}", " · ".join(x for x in parts if x))
return _read(d, pp, packet_id)
# ---------------------------------------------------------------- welds
+17 -12
View File
@@ -115,7 +115,6 @@ class TransitionBody(BaseModel):
location: str | None = Field(default=None, max_length=500)
for_sup_id: uuid.UUID | None = None
ncr: str | None = Field(default=None, max_length=100)
confirm_open_wos: bool | None = None
sign: SignBody | None = None
it_reason: str | None = Field(default=None, max_length=1000)
@@ -539,9 +538,9 @@ TRANSITION_FIELDS: dict[policy.Action, tuple[str, ...]] = {
A.SEND_TO_QC: ("book", "spool", "location", "note"),
A.ROUTE_FROM_REVIEW: ("dept", "note"),
A.QC_REJECT: ("reason", "ncr"),
A.QC_ACCEPT: ("routed_to", "note"),
A.QC_ACCEPT_AND_ROUTE: ("dept", "note"),
A.FILE: ("note", "confirm_open_wos"),
A.QC_ACCEPT: ("routed_to", "note", "ncr"),
A.QC_ACCEPT_AND_ROUTE: ("dept", "note", "ncr"),
A.FILE: ("note",),
A.ADMIN_REOPEN: ("reason",),
}
DEPT_ACTIONS = (A.RELEASE_TO_SHOP, A.ROUTE_FROM_REVIEW, A.QC_ACCEPT_AND_ROUTE)
@@ -580,6 +579,8 @@ def transition(
extra["for_sup_id"] = active_user(conn, extra["for_sup_id"], "supervisor")
if action is A.QC_REJECT:
_reject_reason(extra)
elif "ncr" in extra:
_accept_ncr(extra)
if "stamp" in extra:
# The assign sheet's stamp overrides the welder's own for this assignment; empty means use theirs.
stamp = clean_text(extra["stamp"], 40)
@@ -617,20 +618,25 @@ def transition(
def _reject_reason(extra: dict[str, Any]) -> None:
"""A Q.C. reject takes a reason, an NCR number, or both. The NCR stands in for the reason text: the reason the
policy checks is "NCR-2026-0021" alone, or "NCR-2026-0021 · <reason>". Without an NCR the reason needs its usual
8 characters."""
"""A Q.C. reject always needs a detailed reason (8 characters or more, C2); an NCR number does not stand in for
it. The reject may carry an NCR number too: the return note then reads "NCR-2026-0021 · <reason>"."""
text = clean_text(extra.pop("reason", None), LIMITS["reason"])
raw = clean_text(extra.pop("ncr", None), 100)
ncr = ncrs.validate_ncr(raw) if raw else None
if ncr:
extra["ncr"] = ncr
extra["reason"] = f"{ncr} · {text}" if text else ncr
elif text:
if text:
extra["reason"] = text
extra["reason_text"] = text
def _accept_ncr(extra: dict[str, Any]) -> None:
"""The NCR number an accept carries after a reject without one (C2). An empty one counts as not given."""
raw = clean_text(extra.pop("ncr", None), 100)
if raw:
extra["ncr"] = ncrs.validate_ncr(raw)
@router.post("/{packet_id}/reopen", dependencies=WRITE)
def reopen(
packet_id: int,
@@ -958,9 +964,8 @@ def set_vclosed(
"SELECT DISTINCT wo_base FROM packet_work_orders WHERE packet_id = %s ORDER BY 1", (packet_id,)
)
]
still_open = packets.packet_open_wos(d.conn, w.packet)
suffix = "VISUAL still shows open: " + ", ".join(still_open) if still_open else None
w.note = " · ".join(x for x in (", ".join(wos), suffix) if x) or None
# Only a hand packet gets here (C5): the feed closes a VISUAL packet, so there is no feed state to compare.
w.note = ", ".join(wos) or None
else:
d.conn.execute("UPDATE packets SET vclosed_at = NULL, vclosed_by = NULL WHERE id = %s", (packet_id,))
w.changed("vclosed")
+11 -2
View File
@@ -1,6 +1,7 @@
"""Project Management: the read-only reports, the CSV exports and the production status PDF.
Any signed-in session reads (every role may open every packet, PM included). Nothing here writes a packet; an export
PS-ProjectMgmt and Admin only (change C10, Gitea issue 33); every other role gets 403 on every endpoint here. No other
screen calls /api/pm: the find bar and the boards read /api/packets. Nothing here writes a packet; an export
writes one print_log row and one audit row (`report exported`). Every endpoint takes the find bar's scope: the
project and unit pick, dept, status, station_id, welder_id, flags, flag, q, and the history period (30, 90, 365 or
all days; default 90). Mine is ignored here. The reports read PostgreSQL only.
@@ -21,7 +22,15 @@ from .deps import Db, Principal, current_principal, get_db, settings_of
from .errors import ApiError
from .packets import FIND
router = APIRouter(prefix="/api/pm", tags=["pm"])
def pm_only(p: Principal = Depends(current_principal)) -> Principal: # noqa: B008
"""PS-ProjectMgmt (role `pm`) or Admin. Others get 403."""
if not (p.is_admin or "pm" in p.roles):
raise ApiError(403, "forbidden", "Project Management only.")
return p
router = APIRouter(prefix="/api/pm", tags=["pm"], dependencies=[Depends(pm_only)])
PERIOD = Query(pm.DEFAULT_PERIOD, pattern="^(30|90|365|all)$")
+19 -1
View File
@@ -1,4 +1,5 @@
"""The shop screens: the supervisor board, the stations a person may work at, and the stall board.
"""The shop screens: the supervisor board, the stations a person may work at, the stall board of one station, and
the Stall screen's own list (/api/stall/mine: the packets assigned to me, across stations).
Any signed-in session reads them; the board answers 403 for someone who leads no department. They read
PostgreSQL only.
@@ -16,6 +17,7 @@ from .deps import Db, Principal, current_principal, get_db
from .packets import FIND, site_today
router = APIRouter(prefix="/api/shop", tags=["shop"])
stall_router = APIRouter(prefix="/api/stall", tags=["shop"])
@router.get("/board")
@@ -65,3 +67,19 @@ def stall(
raw = {**find, "station_id": None, "mine_station_id": find.get("mine_station_id") or station_id}
filters = search.parse_filters(raw, pp, screen="stall", today=site_today(request))
return shop.stall(conn, pp, station_id, filters)
@stall_router.get("/mine")
def stall_mine(
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
find: dict[str, Any] = FIND,
) -> dict[str, Any]:
"""The Stall screen (change C11): the packets assigned to the signed-in person, each at the station the
supervisor picked, across stations. No station in the request; the session decides. The find bar values narrow
the cards (Mine is these cards already)."""
conn = d.conn
pp = packets.policy_principal_for(conn, p)
filters = search.parse_filters(find, pp, screen="stall", today=site_today(request))
return shop.stall_mine(conn, pp, filters)
+149 -3
View File
@@ -1,8 +1,12 @@
"""The department checklists and the Q.C. checks, ported from the old checklistFor(), qcChecksFor() and qcMissing().
Weld departments get the welding list, the others the plain list. The drawing, rev and WPS are filled into the text.
The lists live in the settings rows `checklist_templates` (migration 0006) and `qc_check_items` (migration 0007); the
defaults here are the fallback.
Each shop department has its own checklist (change C8, Gitea issue 23): the settings row `checklist:<dept code>` holds
{"items": [{group, key, text}]}, edited by that department's supervisor or by Admin. A department without its own
list uses the default for its kind: the welding list for a weld department, else the plain list, from the settings row
`checklist_templates` (migration 0006, Admin edits it) and, when that row is missing or bad, from the defaults here.
A stage freezes its department's list when work starts on it (`packet_stages.checklist_template`, migration 0011), so
a change reaches only stages not yet started. The drawing, rev and WPS are filled into the text at read time.
The Q.C. check items live in the settings row `qc_check_items` (migration 0007); they are not per department.
The keys are the old keys, so a checklist stored as {key: {done, at, by, stamp}} reads the same.
"""
@@ -13,6 +17,8 @@ import re
from collections.abc import Iterable, Mapping
from typing import Any
from psycopg.types.json import Jsonb
log = logging.getLogger(__name__)
NO_REV = "-"
@@ -98,6 +104,146 @@ def load_templates(conn: Any) -> dict[str, list[dict[str, str]]]:
return parsed
RX_BRACES = re.compile(r"\{[^{}]*\}")
ALLOWED_PLACEHOLDERS = ("{drawing}", "{rev}", "{wps}")
def check_list(name: str, items: Any) -> tuple[list[dict[str, str]] | None, list[str]]:
"""One checklist from an editor, cleaned, and one error line per problem (`name` starts each line). None when
it is not a list."""
errors: list[str] = []
if not isinstance(items, list) or not items:
return None, [f"{name}: at least one item"]
if len(items) > ITEMS_MAX:
errors.append(f"{name}: at most {ITEMS_MAX} items")
seen: set[str] = set()
clean: list[dict[str, str]] = []
for i, it in enumerate(items, start=1):
where = f"{name} item {i}"
if not isinstance(it, Mapping):
errors.append(f"{where}: must be an object with group, key and text")
continue
group = " ".join(str(it.get("group") or "").split())
key = str(it.get("key") or "").strip()
text = " ".join(str(it.get("text") or "").split())
if not RX_KEY.match(key):
errors.append(f"{where}: the key is a lower-case letter, then letters, digits or _ (at most 30)")
elif key in seen:
errors.append(f"{where}: key {key} is used twice")
seen.add(key)
if not group:
errors.append(f"{where}: the group is empty")
if not text:
errors.append(f"{where}: the text is empty")
if len(group) > TEXT_MAX or len(text) > TEXT_MAX:
errors.append(f"{where}: at most {TEXT_MAX} characters")
bad = [m for m in RX_BRACES.findall(text) if m not in ALLOWED_PLACEHOLDERS]
stray = RX_BRACES.sub("", text)
if bad:
errors.append(f"{where}: unknown placeholder {', '.join(bad)} (use {{drawing}}, {{rev}} or {{wps}})")
elif "{" in stray or "}" in stray:
errors.append(f"{where}: a brace without its pair")
clean.append(_item(group, key, text))
return clean, errors
# ---------------------------------------------------------------- per department (change C8)
DEPT_PREFIX = "checklist:"
def dept_key(code: str) -> str:
"""The settings key of one department's own checklist."""
return f"{DEPT_PREFIX}{code}"
def load_dept(conn: Any, code: str) -> tuple[list[dict[str, str]] | None, int]:
"""A department's own checklist and the row's version: (None, 0) when it never had one, (None, version) when it
was set back to the default (`{"items": null}`) or the row does not parse (with a warning). The default for its
kind applies then."""
row = conn.execute("SELECT value, version FROM settings WHERE key = %s", (dept_key(code),)).fetchone()
if row is None:
return None, 0
value = row[0]
if isinstance(value, Mapping) and value.get("items") is None:
return None, int(row[1]) # set back to the default for its kind
items = _valid_list(value.get("items")) if isinstance(value, Mapping) else None
if items is None:
log.warning("settings %s does not parse; using the default checklist", dept_key(code))
return items, int(row[1])
def dept_weld(conn: Any, code: str) -> bool:
row = conn.execute("SELECT weld FROM departments WHERE code = %s", (code,)).fetchone()
return bool(row and row[0])
def default_for(conn: Any, weld: bool) -> list[dict[str, str]]:
"""The default list for a department kind (weld or plain): Admin's `checklist_templates`, else the code's."""
return load_templates(conn)["weld" if weld else "plain"]
def live_list(conn: Any, code: str, weld: bool | None = None) -> list[dict[str, str]]:
"""The department's live checklist: its own list, else the default for its kind."""
own, _ = load_dept(conn, code)
if own is not None:
return own
return default_for(conn, dept_weld(conn, code) if weld is None else weld)
def as_templates(items: list[dict[str, str]]) -> dict[str, list[dict[str, str]]]:
"""One list in the {weld, plain} shape checklist_for() takes: both kinds read the same list, so the department's
weld flag no longer picks it."""
return {"weld": items, "plain": items}
def stage_list(conn: Any, stage: Mapping[str, Any]) -> list[dict[str, str]]:
"""The checklist of one stage: the list frozen at START when set, else its department's live list."""
frozen = _valid_list(stage.get("checklist_template"))
if frozen is not None:
return frozen
return live_list(conn, str(stage["dept_code"]))
def stage_templates(conn: Any, stage_id: Any, dept_code: str) -> dict[str, list[dict[str, str]]]:
"""The checklist of a stage by id (the data book's stage records): frozen when set, else the department's live
list. A record with no stage id reads the live list."""
frozen = None
if stage_id is not None:
row = conn.execute("SELECT checklist_template FROM packet_stages WHERE id = %s", (stage_id,)).fetchone()
frozen = row[0] if row else None
return as_templates(stage_list(conn, {"dept_code": dept_code, "checklist_template": frozen}))
def templates_for_packet(
conn: Any, dept_code: str, stages: Iterable[Mapping[str, Any]]
) -> dict[str, list[dict[str, str]]]:
"""The checklist the packet read, its completeness and the tick check use: the open stage's (frozen or live),
else the last stage's when it is in the packet's department, else the department's live list."""
stages = list(stages)
stage = next((s for s in stages if s.get("closed_at") is None), None)
if stage is None and stages and stages[-1].get("dept_code") == dept_code:
stage = stages[-1]
if stage is not None:
return as_templates(stage_list(conn, stage))
return as_templates(live_list(conn, dept_code))
def freeze_on_start(conn: Any, packet_id: int) -> None:
"""START: freeze the open stage's checklist, once. Later edits to the department's list do not reach it."""
row = conn.execute(
"SELECT id, dept_code FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL"
" AND checklist_template IS NULL",
(packet_id,),
).fetchone()
if row is None:
return
conn.execute(
"UPDATE packet_stages SET checklist_template = %s WHERE id = %s AND checklist_template IS NULL",
(Jsonb(live_list(conn, str(row[1]))), row[0]),
)
def _fill(text: str, drawing: str | None, rev: str | None, wps: str | None) -> str:
values = {"drawing": drawing or "", "rev": rev or NO_REV, "wps": wps or "per drawing"}
return " ".join(RX_PLACEHOLDER.sub(lambda m: values[m.group(1)], text).split())
+92 -7
View File
@@ -232,6 +232,12 @@ class Children:
bom: list[dict[str, Any]] = field(default_factory=list)
shorts: dict[int, dict[str, Any]] = field(default_factory=dict) # bom_line_id -> open short
receipts: dict[int, list[dict[str, Any]]] = field(default_factory=dict) # short id -> receipts
all_shorts: list[dict[str, Any]] = field(default_factory=list) # every short, open or closed, with its item
approvals: dict[int, dict[str, Any]] = field(default_factory=dict) # receipt id -> supervisor approval (C7)
receipts_unapproved: int = 0 # receipts with no supervisor approval (C7)
ncr_required: bool = False # a Q.C. reject since the last accept has no NCR number (C2)
itp_open: bool = False # the unit has an ITP that is not complete (C3)
visual_open_wos: list[str] = field(default_factory=list) # what the feed last saw open in VISUAL (C5)
welds: list[dict[str, Any]] = field(default_factory=list)
heat_tags: list[dict[str, Any]] = field(default_factory=list)
heat_table: list[dict[str, Any]] = field(default_factory=list)
@@ -271,7 +277,6 @@ def load_children(conn: Any, row: Mapping[str, Any], *, full: bool = True) -> Ch
for code, name, kind in conn.execute("SELECT code, name, kind::text FROM departments"):
ch.dept_names[code] = name
ch.dept_labels[code] = dept_label(code, name, kind)
ch.templates = checklists.load_templates(conn)
ch.qc_items = checklists.load_qc_items(conn)
ch.pages = rows(conn, "SELECT * FROM packet_pages WHERE packet_id = %s ORDER BY n", (pid,))
ch.bom = rows(conn, "SELECT * FROM bom_lines WHERE packet_id = %s ORDER BY item", (pid,))
@@ -279,6 +284,8 @@ def load_children(conn: Any, row: Mapping[str, Any], *, full: bool = True) -> Ch
ch.shorts = {s["bom_line_id"]: s for s in shorts}
ch.welds = rows(conn, "SELECT * FROM welds WHERE packet_id = %s AND removed_at IS NULL ORDER BY weld_no", (pid,))
ch.stages = rows(conn, "SELECT * FROM packet_stages WHERE packet_id = %s ORDER BY id", (pid,))
# Change C8: the stage's checklist frozen at START, else the department's live list
ch.templates = checklists.templates_for_packet(conn, row["dept_code"], ch.stages)
ch.qc = one(conn, "SELECT * FROM qc_inspections WHERE packet_id = %s AND closed_at IS NULL", (pid,))
ch.open_cos = rows(
conn,
@@ -286,16 +293,22 @@ def load_children(conn: Any, row: Mapping[str, Any], *, full: bool = True) -> Ch
" ORDER BY co_no",
(pid,),
)
_load_close_facts(conn, row, ch)
if not full:
return ch
ch.work_orders = rows(conn, "SELECT * FROM packet_work_orders WHERE packet_id = %s ORDER BY sort, id", (pid,))
if shorts:
for r in rows(
conn,
"SELECT * FROM short_receipts WHERE short_id = ANY(%s) ORDER BY id",
([s["id"] for s in shorts],),
):
ch.all_shorts = rows(
conn,
"SELECT s.*, b.item FROM bom_shorts s JOIN bom_lines b ON b.id = s.bom_line_id WHERE s.packet_id = %s"
" ORDER BY s.id",
(pid,),
)
if ch.all_shorts:
for r in rows(conn, "SELECT * FROM short_receipts WHERE packet_id = %s ORDER BY id", (pid,)):
ch.receipts.setdefault(r["short_id"], []).append(r)
ch.approvals = {
int(r["receipt_id"]): r for r in rows(conn, "SELECT * FROM short_approvals WHERE packet_id = %s", (pid,))
}
ch.heat_tags = rows(
conn, "SELECT * FROM heat_tags WHERE packet_id = %s AND removed_at IS NULL ORDER BY tag_no", (pid,)
)
@@ -331,6 +344,31 @@ def load_children(conn: Any, row: Mapping[str, Any], *, full: bool = True) -> Ch
return ch
# A Q.C. reject after the packet's last accept that has no NCR number (C2).
NCR_REQUIRED_SQL = """
SELECT EXISTS (
SELECT 1 FROM qc_history h
WHERE h.packet_id = %(p)s AND h.result = 'rejected' AND btrim(coalesce(h.snapshot->>'ncr', '')) = ''
AND h.id > coalesce((SELECT max(a.id) FROM qc_history a WHERE a.packet_id = %(p)s AND a.result = 'accepted'), 0)
)
"""
RECEIPTS_UNAPPROVED_SQL = (
"SELECT count(*) FROM short_receipts r WHERE r.packet_id = %s"
" AND NOT EXISTS (SELECT 1 FROM short_approvals a WHERE a.receipt_id = r.id)"
)
def _load_close_facts(conn: Any, row: Mapping[str, Any], ch: Children) -> None:
"""The policy facts of C2, C3, C5 and C7: unapproved short receipts, the NCR an accept needs, the unit's open ITP
and the work orders the feed last saw open."""
pid = row["id"]
ch.receipts_unapproved = int(conn.execute(RECEIPTS_UNAPPROVED_SQL, (pid,)).fetchone()[0])
ch.ncr_required = bool(conn.execute(NCR_REQUIRED_SQL, {"p": pid}).fetchone()[0])
itp_sql = "SELECT 1 FROM itps WHERE unit_id = %s AND status <> 'complete'"
ch.itp_open = conn.execute(itp_sql, (row["unit_id"],)).fetchone() is not None
ch.visual_open_wos = open_wos(visual_state(conn, row))
def is_sub(line: Mapping[str, Any]) -> bool:
"""A sub-assembly line: made on its own packet, so its heats are recorded there."""
return str(line.get("sub") or "").strip().upper() == "Y"
@@ -385,6 +423,10 @@ def state_from(row: Mapping[str, Any], ch: Children) -> policy.PacketState:
filler_set=stage is not None and bool((stage["filler"] or "").strip()),
qc_missing=tuple(checklists.qc_missing(row, ch.qc, ch.welds, ch.qc_items or None)),
has_qc_sent=ch.qc is not None,
shorts_unapproved=len(ch.shorts) + ch.receipts_unapproved,
itp_open=ch.itp_open,
ncr_required=ch.ncr_required,
visual_open_wos=tuple(ch.visual_open_wos),
)
@@ -596,6 +638,7 @@ def _who_ids(row: Mapping[str, Any], ch: Children) -> list[Any]:
ids += [v["by"] for v in vs]
for rs in ch.receipts.values():
ids += [r["by"] for r in rs]
ids += [s["opened_by"] for s in ch.all_shorts] + [a["by"] for a in ch.approvals.values()]
return ids
@@ -823,6 +866,8 @@ def build(
out["server_now"] = now
out["target_hrs"] = target_hrs(ch.work_orders)
out["labor"] = labor_of(conn, row)
out["shorts_unapproved"] = state.shorts_unapproved
out["ncr_required"] = state.ncr_required
if include_children:
out["work_orders"] = [
{
@@ -848,6 +893,12 @@ def build(
]
out["pages"] = [_page(pg, ch, people) for pg in ch.pages]
out["bom"] = [_bom(b, ch, people) for b in ch.bom]
if not seal:
# C7: who approved each receipt. Not in the seal: seals made before C7 do not cover it.
for b in out["bom"]:
for r in (b["short"] or {}).get("receipts") or []:
r["approval"] = _approval(ch.approvals.get(int(r["id"])), people)
out["shorts"] = [_short(sh, ch, people) for sh in ch.all_shorts]
st = ch.open_stage
out["welds"] = [_weld(w, people, st) for w in ch.welds]
out["heat_tags"] = [_heat_tag(t, st) for t in ch.heat_tags]
@@ -1143,6 +1194,32 @@ def _bom(b: Mapping[str, Any], ch: Children, people: People) -> dict[str, Any]:
return out
def _approval(a: Mapping[str, Any] | None, people: People) -> dict[str, Any] | None:
if a is None:
return None
return {"at": a["at"], "by": people.ref(a["by"]), "note": a["note"], "dept_code": a["dept_code"]}
def _short(s: Mapping[str, Any], ch: Children, people: People) -> dict[str, Any]:
"""One short, open or closed, with each receipt: who received it and who approved it (C7)."""
keys = ("id", "item", "qty", "qty_open", "reason", "ref", "eta", "note", "opened_at", "closed_at")
out = {k: s[k] for k in keys}
out["opened_by"] = people.ref(s["opened_by"])
out["receipts"] = [
{
"id": r["id"],
"qty": r["qty"],
"heat": r["heat"],
"note": r["note"],
"at": r["at"],
"by": people.ref(r["by"]),
"approval": _approval(ch.approvals.get(int(r["id"])), people),
}
for r in ch.receipts.get(s["id"], [])
]
return out
WELD_KEYS = (
"weld_no",
"page",
@@ -1254,6 +1331,7 @@ SELECT p.id, p.version, p.unit_id, u.lot, pr.number || u.lot AS unit_number, pr.
pw.reason AS wh_reason, pw.note AS wh_note, pw.auto AS wh_auto, p.wh_sent_at, p.visual_rel,
bc.bom_total, bc.bom_pulled, bc.bom_missing_heat,
(SELECT count(*) FROM bom_shorts s WHERE s.packet_id = p.id AND s.closed_at IS NULL) AS open_shorts,
coalesce(ur.n, 0) AS receipts_unapproved, coalesce(itp.status::text <> 'complete', false) AS itp_open,
(SELECT count(*) FROM packet_work_orders wo WHERE wo.packet_id = p.id) AS wo_count,
(SELECT coalesce(json_agg(json_build_object('co_no', c.co_no, 'review', c.review,
'pending_depts', c.pending_depts) ORDER BY c.co_no), '[]')
@@ -1267,6 +1345,10 @@ LEFT JOIN users wu ON wu.id = p.welder_id
LEFT JOIN users vu ON vu.id = p.vclosed_by
LEFT JOIN packet_wh pw ON pw.packet_id = p.id AND pw.closed_at IS NULL
LEFT JOIN users whu ON whu.id = pw.by
LEFT JOIN itps itp ON itp.unit_id = p.unit_id
LEFT JOIN (SELECT r.packet_id, count(*) AS n FROM short_receipts r
WHERE NOT EXISTS (SELECT 1 FROM short_approvals a WHERE a.receipt_id = r.id)
GROUP BY r.packet_id) ur ON ur.packet_id = p.id
LEFT JOIN LATERAL (
SELECT count(*) FILTER (WHERE NOT b.na) AS bom_total,
count(*) FILTER (WHERE NOT b.na AND (b.pulled OR EXISTS (SELECT 1 FROM bom_shorts s
@@ -1306,6 +1388,8 @@ def card_state(r: Mapping[str, Any], names: Mapping[str, str]) -> policy.PacketS
filler_set=True,
qc_missing=(),
has_qc_sent=r["status"] == "qc",
shorts_unapproved=int(r["open_shorts"] or 0) + int(r["receipts_unapproved"] or 0),
itp_open=bool(r["itp_open"]),
)
@@ -1407,6 +1491,7 @@ def list_cards(conn: Any, policy_principal: policy.Principal, filters: Mapping[s
"drawing_state": r["drawing_state"],
"open_cos": [c["co_no"] for c in r["open_co_rows"] or []],
"open_shorts": int(r["open_shorts"] or 0),
"shorts_unapproved": int(r["open_shorts"] or 0) + int(r["receipts_unapproved"] or 0),
"wh_stage": r["wh_stage"],
"wh": wh_card(r),
"wh_sent_at": r["wh_sent_at"],
+22 -26
View File
@@ -2,7 +2,7 @@
Inside one transaction: lock the packet row (SELECT ... FOR UPDATE), build the policy's PacketState, check the
caller's expected step (409 stale_step), check the packet version when If-Match is given (409 version_conflict),
ask the policy (403 or 400 with the decision's code), take the signature when the rule needs one, hand the caller a
ask the policy (403, 400 or 422 with the decision's code), take the signature when the rule needs one, hand the caller a
`Write`, then bump the version, write the packet_log row, the transition row and its side effects, the audit row and
the NOTIFY. The policy decides; a handler never does.
@@ -38,7 +38,7 @@ from ..auth.signatures import Signature
from ..policy.engine import MSG_FILED
from ..settings import SecretFileError
from ..text import LIMITS, clean_opt, reason_ok
from . import packets
from . import checklists, packets
from . import qc as qc_dom
log = logging.getLogger(__name__)
@@ -52,14 +52,6 @@ MSG_FIELD = "Someone else changed this since you opened it."
MSG_DATABOOK = "The data book could not be built: {reason}. Nothing was filed."
def open_wos_text(wos: Sequence[str]) -> str:
"""The FILE confirm question: "VISUAL still shows 2 work orders open on this packet: L489052/1, L489052/2. File
anyway?" """
n = len(wos)
what = "1 work order" if n == 1 else f"{n} work orders"
return f"VISUAL still shows {what} open on this packet: {', '.join(wos)}. File anyway?"
# Codes that answer 400 (the UI asks for input and retries); every other refusal answers 403.
ASK_CODES = frozenset({"it_reason_required", "reason_required"})
@@ -202,7 +194,7 @@ def refuse(decision: policy.Decision) -> None:
"""Raise the API error for a failed policy decision."""
if decision.ok:
return
status = 400 if decision.code in ASK_CODES else 403
status = 400 if decision.code in ASK_CODES else 422 if decision.code == "ncr_required" else 403
raise ApiError(status, decision.code, decision.why)
@@ -348,11 +340,6 @@ def packet_write(
bypass = bypass or dd.admin_bypass
if if_match is not None and int(if_match) != int(row["version"]):
raise ApiError(409, "version_conflict", MSG_VERSION, extra={"current": current_of(row)})
if action is A.FILE and extra.get("confirm_open_wos") is not True:
# Before the signature: a cancelled confirm costs no attempt and writes nothing.
wos = packets.packet_open_wos(conn, row)
if wos:
raise ApiError(409, "open_work_orders", open_wos_text(wos), extra={"work_orders": wos})
w = Write(
conn=conn,
principal=p,
@@ -555,6 +542,13 @@ def _set_result(w: Write, insp: Mapping[str, Any] | None, result: str, note: str
)
def _set_ncr(w: Write, insp: Mapping[str, Any] | None) -> None:
"""The NCR number the step carries (a reject, or the accept after a reject without one), on the inspection."""
ncr = w.extra.get("ncr") or None
if insp is not None and (ncr or w.action is A.QC_REJECT):
w.conn.execute("UPDATE qc_inspections SET ncr = %s WHERE id = %s", (ncr, insp["id"]))
def _set_packet(w: Write, assignments: str, params: Sequence[Any] = ()) -> None:
w.conn.execute(f"UPDATE packets SET {assignments} WHERE id = %s", (*params, w.id)) # noqa: S608 (fixed fragments)
@@ -685,6 +679,8 @@ def apply_transition(w: Write) -> None:
assert to_status is not None
ex = w.extra
reason = _reason(w)
if a is A.QC_REJECT and reason and ex.get("ncr"):
reason = f"{ex['ncr']} · {reason}" # the supervisor's return note leads with the NCR number
from_dept = w.from_dept
to_dept = from_dept
if rule.dept_change != "no" and isinstance(ex.get("dept"), str) and ex.get("dept"):
@@ -751,6 +747,8 @@ def apply_transition(w: Write) -> None:
"clock_running_since = coalesce(clock_running_since, now()),"
" clock_started_at = coalesce(clock_started_at, now())",
)
# change C8: the stage keeps the checklist it started with
checklists.freeze_on_start(w.conn, w.id)
elif a is A.SUBMIT:
_set_packet(w, "return_note = NULL")
_stage_update(w, CLOCK_STOP)
@@ -776,6 +774,7 @@ def apply_transition(w: Write) -> None:
elif a in (A.ROUTE_FROM_REVIEW, A.QC_ACCEPT_AND_ROUTE):
insp = _open_inspection(w)
if a is A.QC_ACCEPT_AND_ROUTE:
_set_ncr(w, insp)
insp = _set_result(w, insp, "accepted", _note(w))
_close_stage(w, qc_snapshot=insp)
_close_inspection(w, insp, from_dept)
@@ -784,15 +783,16 @@ def apply_transition(w: Write) -> None:
elif a is A.QC_REJECT:
# The reject closes the inspection into qc_history at once; the next SEND_TO_QC opens a fresh one.
insp = _open_inspection(w)
if insp is not None:
w.conn.execute("UPDATE qc_inspections SET ncr = %s WHERE id = %s", (ex.get("ncr") or None, insp["id"]))
_set_ncr(w, insp)
insp = _set_result(w, insp, "rejected", reason)
_close_inspection(w, insp, from_dept)
_set_packet(w, "return_note = %s", (reason,))
if not packets.one(w.conn, "SELECT id FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (w.id,)):
_open_stage(w, from_dept) # a draft sent straight to Q.C. has no stage yet
elif a is A.QC_ACCEPT:
insp = _set_result(w, _open_inspection(w), "accepted", _note(w))
insp = _open_inspection(w)
_set_ncr(w, insp)
insp = _set_result(w, insp, "accepted", _note(w))
_close_inspection(w, insp, from_dept)
_close_stage(w, qc_snapshot=insp)
_set_packet(w, "routed_to = %s::route_kind", (str(ex["routed_to"]),))
@@ -838,20 +838,16 @@ def _file(w: Write) -> None:
_set_packet(w, "databook_at = now()")
row = packets.load_row(w.conn, w.id)
assert row is not None
wos = packets.packet_open_wos(w.conn, row) if w.extra.get("confirm_open_wos") is True else []
content = packets.seal_content(w.conn, row)
digest = seal_hash(key, content)
kid = seal_key_id(key)
seal_id, sealed_at = w.conn.execute(
"INSERT INTO packet_seals (packet_id, hash, key_id, sealed_by, signature_id, open_wos)"
" VALUES (%s, %s, %s, %s, %s, %s) RETURNING id, sealed_at",
(w.id, digest, kid, w.user_id, w.signature.id if w.signature else None, wos or None),
"INSERT INTO packet_seals (packet_id, hash, key_id, sealed_by, signature_id)"
" VALUES (%s, %s, %s, %s, %s) RETURNING id, sealed_at",
(w.id, digest, kid, w.user_id, w.signature.id if w.signature else None),
).fetchone()
pages = _file_databook(w, row, {"hash": digest.hex(), "key_id": kid, "sealed_at": sealed_at}, int(seal_id))
w.log_also("Data book filed", f"{pages} pages")
if wos:
w.note = " · ".join(x for x in (_note(w), "Filed with work orders open in VISUAL: " + ", ".join(wos)) if x)
w.detail["open_wos"] = wos
w.detail["seal_id"] = int(seal_id)
w.detail["databook_pages"] = pages
+31 -15
View File
@@ -88,7 +88,8 @@ NOTE_AGING_STEPS = "How long packets have been sitting where they are right now.
NOTE_AGING_OPEN = "Over 3 days in one step is flagged."
NOTE_AGING_HISTORY = "From the step history recorded on every move."
NOTE_HOURS_OVER = (
"Station timer hours and VISUAL ticket hours against the work-order target hours. The timer is the clock of record."
"VISUAL ticket hours against the work-order target hours decide over target. The station timer is shown beside"
" them. A packet with no ticket hours is not over target."
)
PROJECTS_HEAD = [
@@ -108,7 +109,16 @@ PROJECTS_HEAD = [
"Health",
]
ATTENTION_HEAD = ["Unit", "Description", "Due", "Left", "Packets", "% done", "Health"]
HOURS_HEAD = ["Packets", "Timer h", "Ticket h", "Target h", "Variance h", "Timer / target", "Over target"]
HOURS_HEAD = [
"Packets",
"Timer h",
"Ticket h",
"Target h",
"Ticket variance h",
"Ticket h / target",
"Over target (tickets)",
]
OVER_HEAD = ["Job", "Station / worker", "Status", "Timer h", "Ticket h", "Target h", "Over by (tickets)"]
JOBS_HEAD = [
"Job",
"Project",
@@ -849,6 +859,12 @@ def _counts_in_hours(c: Mapping[str, Any], h: Mapping[str, float]) -> bool:
return h["target_h"] > 0 and (h["timer_h"] > 0 or h["ticket_h"] > 0 or c["status"] in HOURS_STATUSES)
def is_over(h: Mapping[str, float]) -> bool:
"""Over target: the VISUAL ticket hours are over the work-order target (change C1). The timer never decides, and
a packet with no ticket hours is not over."""
return h["target_h"] > 0 and h["ticket_h"] > 0 and h["ticket_h"] > h["target_h"]
def station_worker(c: Mapping[str, Any]) -> str:
parts = [(c.get("station") or {}).get("name"), (c.get("welder") or {}).get("display_name")]
return " · ".join(str(x) for x in parts if x) or DASH
@@ -856,7 +872,7 @@ def station_worker(c: Mapping[str, Any]) -> str:
def hours_tables(rep: Report) -> list[dict[str, Any]]:
"""The old rptHours, with the station timer and the VISUAL ticket hours side by side against the target. The
timer decides "over target"."""
ticket hours decide "over target" (change C1); the timer is shown as today."""
hrs = rep.hours()
zero = {"timer_h": 0.0, "ticket_h": 0.0, "target_h": 0.0}
hp = [(c, hrs.get(int(c["id"]), zero)) for c in rep.cards]
@@ -871,9 +887,9 @@ def hours_tables(rep: Report) -> list[dict[str, Any]]:
g["ticket"] += h["ticket_h"]
g["target"] += h["target_h"]
g["n"] += 1
if h["timer_h"] > h["target_h"]:
if is_over(h):
g["over"] += 1
return sorted(m.items(), key=lambda kv: -(kv[1]["timer"] - kv[1]["target"]))
return sorted(m.items(), key=lambda kv: -(kv[1]["ticket"] - kv[1]["target"]))
def rows_of(groups: list[tuple[str, dict[str, float]]]) -> list[dict[str, Any]]:
return [
@@ -884,11 +900,11 @@ def hours_tables(rep: Report) -> list[dict[str, Any]]:
fmt_h(v["timer"]),
fmt_h(v["ticket"]),
fmt_h(v["target"]),
signed_h(v["timer"] - v["target"]),
f"{half_up(v['timer'] / v['target'] * 100)}%" if v["target"] else DASH,
signed_h(v["ticket"] - v["target"]),
f"{half_up(v['ticket'] / v['target'] * 100)}%" if v["target"] else DASH,
int(v["over"]),
],
flag=v["timer"] > v["target"],
flag=v["ticket"] > v["target"],
)
for k, v in groups
]
@@ -911,13 +927,13 @@ def hours_tables(rep: Report) -> list[dict[str, Any]]:
)
t3 = table("hours_project", "By project", ["Project", *HOURS_HEAD], rows_of(group(project_key)))
over = sorted(
((c, h) for c, h in hp if h["timer_h"] > h["target_h"]),
key=lambda ch: -(ch[1]["timer_h"] - ch[1]["target_h"]),
((c, h) for c, h in hp if is_over(h)),
key=lambda ch: -(ch[1]["ticket_h"] - ch[1]["target_h"]),
)
t4 = table(
"hours_over",
"Packets over target",
["Job", "Station / worker", "Status", "Timer h", "Ticket h", "Target h", "Over by"],
OVER_HEAD,
[
row(
[
@@ -927,7 +943,7 @@ def hours_tables(rep: Report) -> list[dict[str, Any]]:
fmt_h(h["timer_h"]),
fmt_h(h["ticket_h"]),
fmt_h(h["target_h"]),
"+" + fmt_h(h["timer_h"] - h["target_h"]) + " h",
"+" + fmt_h(h["ticket_h"] - h["target_h"]) + " h",
],
pid=c["id"],
flag=True,
@@ -944,7 +960,7 @@ def over_target_count(rep: Report) -> int:
n = 0
for c in rep.cards:
h = hrs.get(int(c["id"]))
if h and h["target_h"] > 0 and h["timer_h"] > h["target_h"]:
if h and is_over(h):
n += 1
return n
@@ -1177,7 +1193,7 @@ def kpis(rep: Report) -> list[dict[str, Any]]:
{
"key": "over",
"n": over_target_count(rep),
"label": "Packets over target hours",
"label": "Packets over target hours (tickets)",
"tab": "hours",
"alert": True,
},
@@ -1230,7 +1246,7 @@ def summary(rep: Report, k: list[dict[str, Any]] | None = None, pipe: list[dict[
)
over = int(n["over"])
if over:
out.append(f"{over} packet{' is' if over == 1 else 's are'} over target hours.")
out.append(f"{over} packet{' is' if over == 1 else 's are'} over target hours on the VISUAL tickets.")
return out
+7 -2
View File
@@ -86,10 +86,12 @@ def _notify_reject(w: Any, why: str) -> None:
def qc_texts(w: Any, a: policy.Action, to_dept: str) -> None:
"""The packet_log label and note of a Q.C. accept or reject. Called before the side effects close the inspection.
`w` is the pipeline Write; its extra holds `reason_text` and `ncr` for a reject.
`w` is the pipeline Write; its extra holds `reason_text` and `ncr` for a reject, and `ncr` for an accept that
follows a reject without one.
"""
insp = packets.one(w.conn, "SELECT * FROM qc_inspections WHERE packet_id = %s AND closed_at IS NULL", (w.id,))
note = str(w.extra.get("note") or "").strip() or None
ncr = str(w.extra.get("ncr") or "").strip() or None
if a is A.QC_REJECT:
exam = (insp or {}).get("weld_exam") or {}
parts = [str(w.extra.get("ncr") or ""), str(w.extra.get("reason_text") or "")]
@@ -101,7 +103,8 @@ def qc_texts(w: Any, a: policy.Action, to_dept: str) -> None:
return
if a is A.QC_ACCEPT_AND_ROUTE:
labels = getattr(w.children, "dept_labels", None) or packets.dept_labels(w.conn)
text = " · ".join(x for x in (inspector_text(insp), f"from {labels.get(w.from_dept, w.from_dept)}", note) if x)
bits = (inspector_text(insp), f"from {labels.get(w.from_dept, w.from_dept)}", ncr, note)
text = " · ".join(x for x in bits if x)
w.log(f"Q.C. accepted → {labels.get(to_dept, to_dept)}", text)
return
if a is A.QC_ACCEPT:
@@ -110,6 +113,8 @@ def qc_texts(w: Any, a: policy.Action, to_dept: str) -> None:
method = str((insp or {}).get("nde_method") or "").strip()
if method and method != NDE_NONE:
parts.append(f"{method} {(insp or {}).get('nde_result') or ''}".strip())
if ncr:
parts.append(ncr)
if note:
parts.append(note)
route = ROUTE_TEXTS.get(str(w.extra.get("routed_to")), str(w.extra.get("routed_to")))
+8 -5
View File
@@ -250,6 +250,10 @@ def mine_scope(pp: policy.Principal, screen: str | None, station_id: int | None)
return MineScope(screen=s, principal=pp, station_id=station_id)
# The statuses a packet shows on the Stall screen: assigned, in progress and submitted for review.
STALL_STATUSES = ("assigned", "in_progress", "review")
def mine_term(m: MineScope) -> tuple[str | None, list[Any]]:
"""The Mine rule (the old mineFilter) for the screen, or None when it is everything (PM, admin)."""
pp = m.principal
@@ -269,12 +273,11 @@ def mine_term(m: MineScope) -> tuple[str | None, list[Any]]:
return (None, []) if pp.admin else ("false", [])
return "p.dept_code = ANY(%s)", [depts]
if m.screen == "stall":
depts = sorted(pp.shop)
if m.station_id is None:
# Change C11 (Gitea issue 36): the packets assigned to me, at whatever station the supervisor picked. No
# picked station is needed; `station_id` is ignored here.
if not pp.user_id:
return "false", []
if not depts and pp.admin:
return "p.station_id = %s", [int(m.station_id)]
return "(p.dept_code = ANY(%s) AND p.station_id = %s)", [depts, int(m.station_id)]
return "(p.welder_id = %s::uuid AND p.status::text = ANY(%s))", [str(pp.user_id), list(STALL_STATUSES)]
return None, []
+31
View File
@@ -398,6 +398,37 @@ def stall(conn: Any, pp: policy.Principal, station_id: int, filters: Mapping[str
}
MSG_NONE_ASSIGNED = "Nothing is assigned to you now. Your supervisor assigns packets to you."
def stall_mine(conn: Any, pp: policy.Principal, filters: Mapping[str, Any] | None = None) -> dict[str, Any]:
"""GET /api/stall/mine: the packets assigned to the signed-in person (welder_id = me), each at the station the
supervisor picked when assigning, across stations (change C11, Gitea issue 36). Nobody picks a station; moving a
welder is a re-assign by the supervisor. The stall board's shape, with `station` None and `station_id` and
`station_name` on every card. `filters` (the find bar's) narrow the cards."""
ids: list[int] = []
if pp.user_id:
found = packets.rows(
conn,
"SELECT id FROM packets WHERE welder_id = %s::uuid AND removed_at IS NULL"
" AND status::text = ANY(%s) ORDER BY updated_at, id LIMIT %s",
(str(pp.user_id), list(search.STALL_STATUSES), BOARD_LIMIT),
)
ids = [int(r["id"]) for r in found]
cards = _cards(conn, pp, ids, **search.board_filters(filters or {}))
for c in cards:
st = c.get("station") or {}
c["station_id"] = st.get("id")
c["station_name"] = st.get("name")
return {
"station": None,
"mine": [c for c in cards if c["status"] in policy.ACTIVE],
"submitted": [c for c in cards if c["status"] == "review"],
"facets": search.facets(conn, ids=ids),
"empty": None if ids else MSG_NONE_ASSIGNED,
}
# ---------------------------------------------------------------- the assign sheet's people
+35 -5
View File
@@ -23,6 +23,7 @@ from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from .. import audit, events, policy
from ..domain import packets as packets_mod
from ..domain import projects as projects_domain
from ..text import LIMITS, RX_PROJECT, clean_opt
from . import changes, materials
@@ -1038,7 +1039,7 @@ def _observe(run: _Run, seen: Mapping[WoKey, str], labor: LaborMap | None) -> No
with conn.cursor(row_factory=dict_row) as cur:
legs = cur.execute(
"SELECT w.id, w.packet_id, w.wo_base, w.lot, w.split, w.sub, w.seq, w.visual_status," # noqa: S608
" p.status::text AS status, p.flags, p.visual_closed_at FROM packet_work_orders w"
" p.status::text AS status, p.flags, p.visual_closed_at, p.vclosed_at FROM packet_work_orders w"
f" JOIN packets p ON p.id = w.packet_id WHERE {_LIVE} AND w.packet_id IN (SELECT w2.packet_id"
f" FROM packet_work_orders w2, {_UNNEST.replace('k(', 'k2(')} WHERE w2.wo_base = k2.b AND w2.lot = k2.l"
" AND w2.split = k2.sp AND w2.sub = k2.su) ORDER BY w.packet_id, w.sort, w.id",
@@ -1069,11 +1070,20 @@ def _observe(run: _Run, seen: Mapping[WoKey, str], labor: LaborMap | None) -> No
head = pl[0]
status = head["status"]
flags = list(head["flags"] or [])
if sts and all(s == "C" for s in sts) and head["visual_closed_at"] is None:
# C5: the feed is the one writer of vclosed on a VISUAL packet. Every leg closed (or cancelled) sets it, and
# the Clerical board moves the packet to "Ready for data book"; a leg seen open again clears it.
done = bool(sts) and all(s in packets_mod.LEG_DONE for s in sts)
all_closed = bool(sts) and all(s == "C" for s in sts)
if (done and head["vclosed_at"] is None) or (all_closed and head["visual_closed_at"] is None):
def closed(sw: SysWrite) -> bool:
sw.conn.execute("UPDATE packets SET visual_closed_at = now() WHERE id = %s", (sw.id,))
sw.changed("visual_closed_at")
def closed(sw: SysWrite, all_closed: bool = all_closed) -> bool:
sw.conn.execute(
"UPDATE packets SET vclosed_at = coalesce(vclosed_at, now()), vclosed_by = CASE WHEN vclosed_at"
" IS NULL THEN NULL ELSE vclosed_by END, visual_closed_at = CASE WHEN %s THEN"
" coalesce(visual_closed_at, now()) ELSE visual_closed_at END WHERE id = %s",
(all_closed, sw.id),
)
sw.changed("vclosed", "visual_closed_at")
return True
if run.guarded(
@@ -1087,6 +1097,26 @@ def _observe(run: _Run, seen: Mapping[WoKey, str], labor: LaborMap | None) -> No
closed,
):
run.counts["closed_observed"] += 1
elif not done and any(s and s not in packets_mod.LEG_DONE for s in sts) and head["vclosed_at"] is not None:
def reopened(sw: SysWrite) -> bool:
sw.conn.execute(
"UPDATE packets SET vclosed_at = NULL, vclosed_by = NULL, visual_closed_at = NULL WHERE id = %s",
(sw.id,),
)
sw.changed("vclosed", "visual_closed_at")
return True
run.guarded(
f"reopened {pid}",
_flag_write,
run,
pid,
status,
"Open again in VISUAL (observed)",
", ".join(packets_mod.open_wos({"legs": state_legs})) or None,
reopened,
)
cancelled = any(s == "X" for s in sts)
if cancelled and "cancelled" not in flags:
+4
View File
@@ -101,6 +101,7 @@ def create_app(
admin_security,
assets,
auth,
checklists,
departments,
feed_settings,
itp,
@@ -139,9 +140,12 @@ def create_app(
qc,
itp,
pm,
checklists,
):
app.include_router(module.router)
app.include_router(assets.blob_router)
app.include_router(admin_security.stamp_router)
app.include_router(shop.stall_router)
for name in ("feed", "packet_visual", "notifications"):
modname = f"{__package__}.api.{name}"
try:
@@ -0,0 +1,47 @@
-- 0011: schema for Seth's changes of 2026-10-26 and 2026-10-28 (docs/PLAN.md, changes C7 and C8). Runs as ps_migrate.
-- ---------------------------------------------------------------- C7: supervisor approval of received shorts
-- Gitea issue 18. The warehouse records a receipt (short_receipts); the department supervisor then approves that
-- receipt, checking the part and its heat number against what the warehouse issued. One approval per receipt.
-- A packet cannot be accepted as complete while a short is open or a receipt has no approval.
CREATE TABLE short_approvals (
id bigserial PRIMARY KEY,
receipt_id bigint NOT NULL UNIQUE REFERENCES short_receipts (id),
short_id bigint NOT NULL REFERENCES bom_shorts (id),
packet_id bigint NOT NULL REFERENCES packets (id),
-- the department the packet was in when the supervisor approved
dept_code text NOT NULL REFERENCES departments (code),
note text,
signature_id bigint REFERENCES signatures (id),
at timestamptz NOT NULL DEFAULT now(),
by uuid NOT NULL REFERENCES users (id)
);
CREATE INDEX short_approvals_packet ON short_approvals (packet_id);
CREATE TRIGGER short_approvals_append_only
BEFORE UPDATE OR DELETE ON short_approvals
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER short_approvals_no_truncate
BEFORE TRUNCATE ON short_approvals
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
-- ---------------------------------------------------------------- C8: per-department checklists
-- Gitea issue 23. The checklist a stage uses is frozen when work starts on it. NULL means the stage has not started
-- and reads its department's live template, so a template change reaches packets not yet started only.
ALTER TABLE packet_stages ADD COLUMN checklist_template jsonb;
-- Stages already started before this migration freeze the list they use today (the default for their kind), so a
-- later department edit does not reach them. No department has its own list yet.
UPDATE packet_stages s
SET checklist_template = t.value -> (CASE WHEN d.weld THEN 'weld' ELSE 'plain' END)
FROM packets p, departments d, settings t
WHERE p.id = s.packet_id AND d.code = s.dept_code AND t.key = 'checklist_templates'
AND s.closed_at IS NULL AND p.status IN ('in_progress', 'review', 'qc')
AND jsonb_typeof(t.value -> (CASE WHEN d.weld THEN 'weld' ELSE 'plain' END)) = 'array';
-- Explicit, after the default privileges: the app may only read and append the approvals.
REVOKE UPDATE, DELETE, TRUNCATE ON short_approvals FROM ps_app;
GRANT SELECT, INSERT ON short_approvals TO ps_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ps_app;
+38 -61
View File
@@ -1,8 +1,10 @@
"""The manufacturing data book, page for page after the old databook.js.
Sections, in order: 1 Drawings, 2 Material traceability, 3 Welding record, 4 Q.C. inspection, 5 Q.C. and test
documents, 6 Material test reports, 7 Record history. Front matter (cover and index) is ours; drawings, documents
and MTRs are copied at their own size; the heat and weld maps are rasters at the sheet's size.
Sections, in order: 1 Drawings (with the change order record when the packet has change orders), 2 Material
traceability, 3 Welding record, 4 Q.C. inspection, 5 Q.C. and test documents (the packet's documents, NCRs included),
6 Material test reports. Front matter (cover and index) is ours; drawings, documents and MTRs are copied at their own
size; the heat and weld maps are rasters at the sheet's size. The packet history pages are left out (change C6, Gitea
issue 8): the book is the one copy and goes to the customer. History stays readable in Packet Station.
A part that cannot be read (a missing file, a file that is not a PDF or an image, a pypdf error) becomes a warning row
in the index and in `Built.warnings`. Only a failure to assemble the whole book raises PdfError.
@@ -65,7 +67,6 @@ SECTION_NAMES = (
"Q.C. inspection",
"Q.C. and test documents",
"Material test reports",
"Record history",
)
@@ -240,7 +241,6 @@ class Book:
self.s4_qc()
self.s5_documents()
self.s6_mtrs()
self.s7_history()
size = self.drawing_size(live or void)
front_n = 2
@@ -314,6 +314,8 @@ class Book:
pg,
void=maps.void_text(pg, self.tz),
)
if p.get("change_orders"):
self.gen(1, "Change order record", self.change_orders)
# ------------------------------------------------------------ section 2
@@ -545,9 +547,9 @@ class Book:
)
y += 18
y = d.section_label("Station checklist", y)
items = checklists.checklist_for(
weld, p.get("drawing"), p.get("rev"), p.get("wps"), checklists.load_templates(self.conn)
)
# change C8: the stage's checklist frozen at START, else its department's live list
templates = checklists.stage_templates(self.conn, rec.get("id"), str(code))
items = checklists.checklist_for(weld, p.get("drawing"), p.get("rev"), p.get("wps"), templates)
ck = rec.get("checklist") or {}
body = []
for it in items:
@@ -750,75 +752,50 @@ class Book:
if not lines:
self.warn(6, "No MTRs attached in Packet Station — add from the warehouse files")
# ------------------------------------------------------------ section 7
# ------------------------------------------------------------ change orders (section 1)
def s7_history(self) -> None:
self.gen(7, "Packet record history", self.history)
def history(self) -> Doc:
def change_orders(self) -> Doc:
"""The change orders on this packet: who issued each, what it changed and who verified it. The packet's
history pages are not in the book (change C6); they stay readable in Packet Station."""
p = self.p
d = Doc(("Record History", "Section 6 • Manufacturing data book"))
d = Doc(("Change Order Record", "Section 1 • Manufacturing data book"))
y = 128.0
d.tx(
"Every action taken on this packet in Packet Station, oldest first, with the person who took it.",
"The change orders issued on this packet, with the departments that verified each one.",
L,
y,
size=8.5,
color=GREY,
)
y += 18
cos = p.get("change_orders") or []
if cos:
d.lbl("Change orders", L, y)
y += 8
body = []
for co in cos:
verified = co.get("verified") or {}
req = co.get("required") or []
ver = "\n".join(
f"{self.dept_name(dp)}: "
+ (
f"{parts.name_of(self.users, verified[dp].get('by'))} {self.d(verified[dp].get('at'))}"
if dp in verified
else "PENDING"
)
for dp in req
)
body.append(
[
f"CO-{co.get('co_no')}" + (f"\n{co['ecn']}" if co.get("ecn") else ""),
f"{self.dt(co.get('issued_at'))}\n{parts.name_emp(self.users, co.get('issued_by'))}",
co_summary(co),
co.get("reason") or "",
ver or "No departments to verify",
]
)
y = d.table(
["CHANGE", "ISSUED", "DOCUMENTS", "REASON", "VERIFIED BY"],
body,
y,
{0: {"w": 56, "bold": True}, 1: {"w": 96}, 4: {"w": 130}},
size=7.4,
)
y += 16
y = d.ensure_space(y, 40)
d.lbl("Record history", L, y)
y += 8
body = []
for e in parts.log_rows(self.conn, int(p["id"])):
who = str(e.get("by_name") or "")
if who and e.get("by") is not None:
who += f" ({e.get('emp_no') or '?'})"
role = parts.role_text(e.get("role"))
for co in p.get("change_orders") or []:
verified = co.get("verified") or {}
req = co.get("required") or []
ver = "\n".join(
f"{self.dept_name(dp)}: "
+ (
f"{parts.name_of(self.users, verified[dp].get('by'))} {self.d(verified[dp].get('at'))}"
if dp in verified
else "PENDING"
)
for dp in req
)
body.append(
[self.dt(e.get("at")), e.get("action") or "", who + (f"\n{role}" if role else ""), e.get("note") or ""]
[
f"CO-{co.get('co_no')}" + (f"\n{co['ecn']}" if co.get("ecn") else ""),
f"{self.dt(co.get('issued_at'))}\n{parts.name_emp(self.users, co.get('issued_by'))}",
co_summary(co),
co.get("reason") or "",
ver or "No departments to verify",
]
)
d.table(
["DATE / TIME", "EVENT", "BY", "DETAIL"],
["CHANGE", "ISSUED", "DOCUMENTS", "REASON", "VERIFIED BY"],
body,
y,
{0: {"w": 92}, 1: {"w": 150, "bold": True}, 2: {"w": 96}},
size=7.6,
{0: {"w": 56, "bold": True}, 1: {"w": 96}, 4: {"w": 130}},
size=7.4,
)
return d
+1 -1
View File
@@ -423,7 +423,7 @@ def build(
r.section(
"PART 04 · LABOR",
"Hours vs target",
"Station clock hours and VISUAL ticket hours against work-order target hours.",
"VISUAL ticket hours against work-order target hours decide over target. The station clock is shown too.",
pm.hours_tables(rep),
)
r.section(
+6 -3
View File
@@ -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),
+21 -7
View File
@@ -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"
+41 -4
View File
@@ -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,
+4
View File
@@ -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")