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
+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