"""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:` 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