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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)$")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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")))
|
||||
|
||||
@@ -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, []
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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