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,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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user