- 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]>
396 lines
18 KiB
Python
396 lines
18 KiB
Python
"""The department checklists and the Q.C. checks, ported from the old checklistFor(), qcChecksFor() and qcMissing().
|
|
|
|
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.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from collections.abc import Iterable, Mapping
|
|
from typing import Any
|
|
|
|
from psycopg.types.json import Jsonb
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
NO_REV = "-"
|
|
NDE_NONE = ("", "none", "none required")
|
|
NDE_OK = ("acceptable", "accept", "accepted")
|
|
REJECTS = ("reject", "rejected")
|
|
|
|
|
|
def _item(group: str, key: str, text: str) -> dict[str, str]:
|
|
return {"group": group, "key": key, "text": text}
|
|
|
|
|
|
DWG_TEXT = "Drawing {drawing} Rev {rev} matches the packet"
|
|
|
|
# The defaults. The settings row `checklist_templates` holds the same lists (migration 0006); these are the fallback
|
|
# when that row is missing or does not parse.
|
|
DEFAULT_TEMPLATES: dict[str, list[dict[str, str]]] = {
|
|
"weld": [
|
|
_item("Before welding", "dwg", DWG_TEXT),
|
|
_item("Before welding", "heat", "Heat numbers on material match the Materials tab"),
|
|
_item("Before welding", "wps", "WPS reviewed: {wps}"),
|
|
_item("Before welding", "fit", "Fit-up and dimensions checked against the drawing"),
|
|
_item("Before welding", "pre", "Preheat and interpass temperature per WPS"),
|
|
_item("After welding", "vt", "Visual inspection of all welds acceptable"),
|
|
_item("After welding", "stamp", "Welds stamped with welder ID"),
|
|
_item("After welding", "clean", "Slag and spatter removed"),
|
|
_item("After welding", "qty", "Completed quantity matches each work order"),
|
|
],
|
|
"plain": [
|
|
_item("Before starting", "dwg", DWG_TEXT),
|
|
_item("Before starting", "prev", "Previous department's work complete and released"),
|
|
_item("Before starting", "mat", "Material and parts on hand match the packet"),
|
|
_item("When done", "work", "Work completed per drawing and procedure"),
|
|
_item("When done", "insp", "Self-inspection done; no damage to finished surfaces"),
|
|
_item("When done", "qty", "Completed quantity matches each work order"),
|
|
],
|
|
}
|
|
SETTINGS_KEY = "checklist_templates"
|
|
RX_KEY = re.compile(r"^[a-z][a-z0-9_]{0,29}$")
|
|
RX_PLACEHOLDER = re.compile(r"\{(drawing|rev|wps)\}")
|
|
TEXT_MAX = 200
|
|
ITEMS_MAX = 40
|
|
|
|
|
|
def _valid_list(value: Any) -> list[dict[str, str]] | None:
|
|
"""A template list from settings, or None when it does not have the right shape."""
|
|
if not isinstance(value, list) or not value or len(value) > ITEMS_MAX:
|
|
return None
|
|
out: list[dict[str, str]] = []
|
|
seen: set[str] = set()
|
|
for it in value:
|
|
if not isinstance(it, Mapping):
|
|
return None
|
|
group, key, text = it.get("group"), it.get("key"), it.get("text")
|
|
if not (isinstance(group, str) and isinstance(key, str) and isinstance(text, str)):
|
|
return None
|
|
if not RX_KEY.match(key) or key in seen or not group.strip() or not text.strip():
|
|
return None
|
|
seen.add(key)
|
|
out.append(_item(group.strip()[:TEXT_MAX], key, text.strip()[:TEXT_MAX]))
|
|
return out
|
|
|
|
|
|
def parse_templates(value: Any) -> dict[str, list[dict[str, str]]] | None:
|
|
"""The `checklist_templates` settings value, checked. None when it is malformed."""
|
|
if not isinstance(value, Mapping):
|
|
return None
|
|
weld, plain = _valid_list(value.get("weld")), _valid_list(value.get("plain"))
|
|
if weld is None or plain is None:
|
|
return None
|
|
return {"weld": weld, "plain": plain}
|
|
|
|
|
|
def load_templates(conn: Any) -> dict[str, list[dict[str, str]]]:
|
|
"""The department checklists from settings, else the defaults in code (with a warning when the row is bad)."""
|
|
row = conn.execute("SELECT value FROM settings WHERE key = %s", (SETTINGS_KEY,)).fetchone()
|
|
if row is None:
|
|
return DEFAULT_TEMPLATES
|
|
parsed = parse_templates(row[0])
|
|
if parsed is None:
|
|
log.warning("settings %s does not parse; using the default checklists", SETTINGS_KEY)
|
|
return DEFAULT_TEMPLATES
|
|
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())
|
|
|
|
|
|
def checklist_for(
|
|
weld: bool,
|
|
drawing: str | None,
|
|
rev: str | None,
|
|
wps: str | None,
|
|
templates: Mapping[str, list[dict[str, str]]] | None = None,
|
|
) -> list[dict[str, str]]:
|
|
"""The stage checklist for a department, as a flat list of {group, key, text} in display order."""
|
|
t = templates or DEFAULT_TEMPLATES
|
|
items = t.get("weld" if weld else "plain") or DEFAULT_TEMPLATES["weld" if weld else "plain"]
|
|
return [_item(i["group"], i["key"], _fill(i["text"], drawing, rev, wps)) for i in items]
|
|
|
|
|
|
QC_SETTINGS_KEY = "qc_check_items"
|
|
RX_QC_PLACEHOLDER = re.compile(r"\{(drawing|rev|wps|stamp)\}")
|
|
G_BOOK, G_SPOOL = "Data book package", "Physical spool"
|
|
|
|
# The Q.C. check items, with {wps}, {stamp}, {drawing} and {rev} placeholders. The settings row `qc_check_items`
|
|
# (migration 0007) holds the same list; this is the fallback when that row is missing or does not parse.
|
|
QC_DEFAULT_ITEMS: list[dict[str, str]] = [
|
|
_item(G_BOOK, "db_heats", "Heat number recorded for every BOM item"),
|
|
_item(G_BOOK, "db_mtr", "MTRs on file and match the heat numbers"),
|
|
_item(G_BOOK, "db_weldmap", "Weld map complete: every weld identified"),
|
|
_item(G_BOOK, "db_wps", "Welds made to {wps}; welder {stamp}qualified"),
|
|
_item(G_BOOK, "db_ck", "Stall checklist and weld record complete"),
|
|
_item(G_SPOOL, "ph_dim", "Dimensions verified against {drawing} Rev {rev}"),
|
|
_item(G_SPOOL, "ph_heat", "Heat numbers on material match the heat map"),
|
|
_item(G_SPOOL, "ph_stamp", "Welds stamped with welder ID"),
|
|
_item(G_SPOOL, "ph_clean", "Clean: no slag, spatter or arc strikes"),
|
|
_item(G_SPOOL, "ph_qty", "Quantity matches the work orders"),
|
|
]
|
|
|
|
|
|
def parse_qc_items(value: Any) -> list[dict[str, str]] | None:
|
|
"""The `qc_check_items` settings value ({"groups": [{"group", "items": [{"key", "text"}]}]}) as a flat list of
|
|
{group, key, text}. None when it is malformed."""
|
|
if not isinstance(value, Mapping) or not isinstance(value.get("groups"), list):
|
|
return None
|
|
flat: list[dict[str, Any]] = []
|
|
for g in value["groups"]:
|
|
if not isinstance(g, Mapping) or not isinstance(g.get("items"), list):
|
|
return None
|
|
flat += [{**i, "group": g.get("group")} if isinstance(i, Mapping) else i for i in g["items"]]
|
|
return _valid_list(flat)
|
|
|
|
|
|
def load_qc_items(conn: Any) -> list[dict[str, str]]:
|
|
"""The Q.C. check items from settings, else the defaults in code (with a warning when the row is bad)."""
|
|
row = conn.execute("SELECT value FROM settings WHERE key = %s", (QC_SETTINGS_KEY,)).fetchone()
|
|
if row is None:
|
|
return QC_DEFAULT_ITEMS
|
|
parsed = parse_qc_items(row[0])
|
|
if parsed is None:
|
|
log.warning("settings %s does not parse; using the default Q.C. items", QC_SETTINGS_KEY)
|
|
return QC_DEFAULT_ITEMS
|
|
return parsed
|
|
|
|
|
|
def qc_fill(text: str, drawing: str | None, rev: str | None, wps: str | None, welder_stamp: str | None) -> str:
|
|
values = {
|
|
"drawing": drawing or "the drawing",
|
|
"rev": rev or NO_REV,
|
|
"wps": wps or "the WPS",
|
|
"stamp": f"[{welder_stamp}] " if welder_stamp else "",
|
|
}
|
|
return " ".join(RX_QC_PLACEHOLDER.sub(lambda m: values[m.group(1)], text).split())
|
|
|
|
|
|
def qc_items_for(
|
|
drawing: str | None,
|
|
rev: str | None,
|
|
wps: str | None,
|
|
welder_stamp: str | None,
|
|
items: Iterable[Mapping[str, str]] | None = None,
|
|
) -> list[dict[str, str]]:
|
|
"""The Q.C. inspection items, as a flat list of {group, key, text} with the packet's values filled in."""
|
|
src = list(items) if items is not None else QC_DEFAULT_ITEMS
|
|
return [_item(i["group"], i["key"], qc_fill(i["text"], drawing, rev, wps, welder_stamp)) for i in src]
|
|
|
|
|
|
def checklist_keys(weld: bool, templates: Mapping[str, list[dict[str, str]]] | None = None) -> list[str]:
|
|
return [i["key"] for i in checklist_for(weld, None, None, None, templates)]
|
|
|
|
|
|
def qc_keys(items: Iterable[Mapping[str, str]] | None = None) -> list[str]:
|
|
return [i["key"] for i in (items if items is not None else QC_DEFAULT_ITEMS)]
|
|
|
|
|
|
def is_done(value: Any) -> bool:
|
|
"""A checklist or Q.C. check entry: `true`, or `{"done": true, ...}`."""
|
|
if value is True:
|
|
return True
|
|
return isinstance(value, Mapping) and value.get("done") is True
|
|
|
|
|
|
def checklist_complete(
|
|
checklist: Mapping[str, Any] | None, weld: bool, templates: Mapping[str, list[dict[str, str]]] | None = None
|
|
) -> bool:
|
|
"""Every key of the department checklist is done. An empty list counts as complete."""
|
|
ck = checklist or {}
|
|
return all(is_done(ck.get(k)) for k in checklist_keys(weld, templates))
|
|
|
|
|
|
def _plural(n: int, word: str) -> str:
|
|
return f"{n} {word}{'s' if n > 1 else ''}"
|
|
|
|
|
|
def qc_missing(
|
|
packet: Mapping[str, Any],
|
|
qc: Mapping[str, Any] | None,
|
|
welds: Iterable[Mapping[str, Any]],
|
|
items: Iterable[Mapping[str, str]] | None = None,
|
|
) -> list[str]:
|
|
"""What Q.C. still has to record before an accept. The old qcMissing().
|
|
|
|
`packet` is unused today (the item list does not depend on it for the keys) but kept for the old signature.
|
|
`qc` is the open inspection row (checks, weld_exam, nde_method, nde_result, inspector) or None.
|
|
`welds` are the live weld rows (weld_no). `items` are the loaded Q.C. items (load_qc_items); None means the
|
|
defaults.
|
|
"""
|
|
del packet
|
|
q = qc or {}
|
|
checks = q.get("checks") or {}
|
|
missing: list[str] = []
|
|
open_items = sum(1 for k in qc_keys(items) if not is_done(checks.get(k)))
|
|
if open_items:
|
|
missing.append(_plural(open_items, "inspection item"))
|
|
exam = q.get("weld_exam") or {}
|
|
results = []
|
|
for w in welds:
|
|
entry = exam.get(str(w.get("weld_no")))
|
|
result = entry.get("result") if isinstance(entry, Mapping) else None
|
|
results.append(str(result).lower() if result else "")
|
|
no_result = sum(1 for r in results if not r)
|
|
if no_result:
|
|
missing.append(_plural(no_result, "weld") + " without a visual result")
|
|
if any(r in REJECTS for r in results):
|
|
missing.append("a rejected weld (reject the packet instead)")
|
|
method = str(q.get("nde_method") or "").strip().lower()
|
|
if method not in NDE_NONE and str(q.get("nde_result") or "").strip().lower() not in NDE_OK:
|
|
missing.append("NDE result")
|
|
if not str(q.get("inspector") or "").strip():
|
|
missing.append("inspector name")
|
|
return missing
|