Phase 6: Q.C. inspection, project folders, packet documents and the digital ITP
The Q.C. screen (KPIs, boards, project folders), the Q.C. tab on the packet page (check items from the qc_check_items settings row, visual exam per weld, NDE, hardness with the NACE label, notes), accept with a route and a signature gated by qc_missing_empty, reject with a reason or an NCR number closing the inspection into qc_history, Q.C. folder documents per project and unit with superseding revisions, packet documents as a closed set with take-out reasons, the digital ITP per unit from the frozen template (line initials with CAS, unsign, N/A, customer codes, rejects with NCR, block signatures, sign-off with the PDF hook, admin reopen, the ITP log), hold-point warnings by department page group, the scoped policy check for unit and project writes, migration 0007, tests, Playwright scenarios and docs. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -43,6 +43,10 @@ SEAL_EXCLUDE = (
|
||||
"target_hrs",
|
||||
"labor",
|
||||
"heat_src",
|
||||
"qc_last",
|
||||
"qc_folder_docs",
|
||||
"itp",
|
||||
"itp_hold",
|
||||
)
|
||||
|
||||
|
||||
@@ -242,6 +246,7 @@ class Children:
|
||||
dept_names: dict[str, str] = field(default_factory=dict)
|
||||
dept_labels: dict[str, str] = field(default_factory=dict)
|
||||
templates: dict[str, list[dict[str, str]]] = field(default_factory=dict)
|
||||
qc_items: list[dict[str, str]] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def open_stage(self) -> dict[str, Any] | None:
|
||||
@@ -264,6 +269,7 @@ def load_children(conn: Any, row: Mapping[str, Any], *, full: bool = True) -> Ch
|
||||
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,))
|
||||
shorts = rows(conn, "SELECT * FROM bom_shorts WHERE packet_id = %s AND closed_at IS NULL", (pid,))
|
||||
@@ -311,8 +317,12 @@ def load_children(conn: Any, row: Mapping[str, Any], *, full: bool = True) -> Ch
|
||||
ch.co_required.setdefault(r["co_id"], []).append(r["dept_code"])
|
||||
for r in rows(conn, "SELECT * FROM co_verifications WHERE co_id = ANY(%s) ORDER BY at", (co_ids,)):
|
||||
ch.co_verified.setdefault(r["co_id"], []).append(r)
|
||||
# Every document, the ones taken out of the data book too (a removed row is a record).
|
||||
ch.docs = rows(
|
||||
conn, "SELECT * FROM packet_docs WHERE packet_id = %s AND removed_at IS NULL ORDER BY added_at, id", (pid,)
|
||||
conn,
|
||||
"SELECT d.*, a.original_name AS file_name, a.size AS file_size FROM packet_docs d"
|
||||
" JOIN assets a ON a.id = d.asset_id WHERE d.packet_id = %s ORDER BY d.added_at, d.id",
|
||||
(pid,),
|
||||
)
|
||||
ch.seal = one(conn, "SELECT * FROM packet_seals WHERE packet_id = %s ORDER BY id DESC LIMIT 1", (pid,))
|
||||
return ch
|
||||
@@ -370,7 +380,7 @@ def state_from(row: Mapping[str, Any], ch: Children) -> policy.PacketState:
|
||||
checklist_complete=stage is not None
|
||||
and checklists.checklist_complete(stage["checklist"], weld, ch.templates or None),
|
||||
filler_set=stage is not None and bool((stage["filler"] or "").strip()),
|
||||
qc_missing=tuple(checklists.qc_missing(row, ch.qc, ch.welds)),
|
||||
qc_missing=tuple(checklists.qc_missing(row, ch.qc, ch.welds, ch.qc_items or None)),
|
||||
has_qc_sent=ch.qc is not None,
|
||||
)
|
||||
|
||||
@@ -485,7 +495,10 @@ def _stage(
|
||||
return out
|
||||
|
||||
|
||||
def _qc(q: Mapping[str, Any], people: People) -> dict[str, Any]:
|
||||
RX_NACE = re.compile(r"NACE", re.IGNORECASE)
|
||||
|
||||
|
||||
def _qc(q: Mapping[str, Any], people: People, row: Mapping[str, Any], missing: list[str]) -> dict[str, Any]:
|
||||
keys = (
|
||||
"id",
|
||||
"sent_at",
|
||||
@@ -504,16 +517,57 @@ def _qc(q: Mapping[str, Any], people: People) -> dict[str, Any]:
|
||||
"result",
|
||||
"result_at",
|
||||
"result_note",
|
||||
"ncr",
|
||||
"row_version",
|
||||
)
|
||||
out = {k: q[k] for k in keys}
|
||||
out = {k: q.get(k) for k in keys}
|
||||
out["sent_by"] = people.ref(q["sent_by"])
|
||||
out["result_by"] = people.ref(q["result_by"])
|
||||
out["checks"] = q["checks"] or {}
|
||||
out["weld_exam"] = q["weld_exam"] or {}
|
||||
out["checks"] = _checklist(q["checks"], people) # entries gain by_name
|
||||
out["weld_exam"] = _checklist(q["weld_exam"], people)
|
||||
out["nace_label"] = bool(RX_NACE.search(str(row.get("wps") or "")))
|
||||
out["missing"] = missing
|
||||
return out
|
||||
|
||||
|
||||
def _entry_ids(value: Any) -> list[Any]:
|
||||
return [as_uuid(v.get("by")) for v in (value or {}).values() if isinstance(v, Mapping)]
|
||||
|
||||
|
||||
def qc_history_row(h: Mapping[str, Any], people: People) -> dict[str, Any]:
|
||||
"""A closed inspection: the qc_history row with the parts of its snapshot the screens show. `weld_exam` keeps
|
||||
each weld's result and note so the screens can show the defect next to a rejected weld."""
|
||||
from .qc import rejected_welds
|
||||
|
||||
snap = h.get("snapshot")
|
||||
if not isinstance(snap, Mapping):
|
||||
snap = {}
|
||||
raw = snap.get("weld_exam")
|
||||
exam: Mapping[str, Any] = raw if isinstance(raw, Mapping) else {}
|
||||
return {
|
||||
"id": h["id"],
|
||||
"at": h["at"],
|
||||
"result": h["result"],
|
||||
"inspector": h["inspector"],
|
||||
"note": h["note"],
|
||||
"dept_code": h["dept_code"],
|
||||
"ncr": snap.get("ncr"),
|
||||
"rejected_welds": rejected_welds(exam),
|
||||
"weld_exam": {
|
||||
str(k): {"result": v.get("result"), "note": v.get("note")}
|
||||
for k, v in exam.items()
|
||||
if isinstance(v, Mapping)
|
||||
},
|
||||
"by": people.ref(as_uuid(snap.get("result_by"))),
|
||||
"location": snap.get("location"),
|
||||
"sent_at": snap.get("sent_at"),
|
||||
}
|
||||
|
||||
|
||||
# Keys phase 6 added to qc_history rows. Seals made before phase 6 do not cover them, so the seal leaves them out.
|
||||
SEAL_QC_HISTORY = ("ncr", "rejected_welds", "weld_exam", "by", "location", "sent_at")
|
||||
|
||||
|
||||
def _who_ids(row: Mapping[str, Any], ch: Children) -> list[Any]:
|
||||
ids: list[Any] = [row["welder_id"], row["for_sup_id"], row["vclosed_by"], row["removed_by"]]
|
||||
ids += [w["welder_id"] for w in ch.welds] + [w["by"] for w in ch.welds]
|
||||
@@ -527,7 +581,12 @@ def _who_ids(row: Mapping[str, Any], ch: Children) -> list[Any]:
|
||||
ids.append(ch.wh["by"])
|
||||
if ch.qc:
|
||||
ids += [ch.qc["sent_by"], ch.qc["result_by"]]
|
||||
ids += [d["added_by"] for d in ch.docs]
|
||||
ids += _entry_ids(ch.qc["checks"]) + _entry_ids(ch.qc["weld_exam"])
|
||||
for h in ch.qc_history:
|
||||
snap = h.get("snapshot")
|
||||
if isinstance(snap, Mapping):
|
||||
ids.append(as_uuid(snap.get("result_by")))
|
||||
ids += [d["added_by"] for d in ch.docs] + [d["removed_by"] for d in ch.docs]
|
||||
for c in ch.change_orders:
|
||||
ids += [c["issued_by"], c["approved_by"], c["rejected_by"]]
|
||||
for vs in ch.co_verified.values():
|
||||
@@ -723,30 +782,18 @@ def build(
|
||||
"auto": ch.wh["auto"],
|
||||
}
|
||||
)
|
||||
out["qc"] = _qc(ch.qc, people) if ch.qc else None
|
||||
out["qc_history"] = [
|
||||
{k: h[k] for k in ("id", "at", "result", "inspector", "note", "dept_code")} for h in ch.qc_history
|
||||
]
|
||||
out["qc"] = _qc(ch.qc, people, row, list(state.qc_missing)) if ch.qc else None
|
||||
out["qc_history"] = [qc_history_row(h, people) for h in ch.qc_history]
|
||||
out["qc_last"] = out["qc_history"][-1] if out["qc_history"] else None
|
||||
out["change_orders"] = [_co(c, ch, people) for c in ch.change_orders]
|
||||
out["docs"] = [
|
||||
{
|
||||
"id": d["id"],
|
||||
"asset_id": str(d["asset_id"]),
|
||||
"kind": d["kind"],
|
||||
"title": d["title"],
|
||||
"added_at": d["added_at"],
|
||||
"added_by": people.ref(d["added_by"]),
|
||||
"url": f"/_blob/{d['asset_id']}",
|
||||
}
|
||||
for d in ch.docs
|
||||
]
|
||||
out["docs"] = [_doc(d, people, seal=seal) for d in ch.docs]
|
||||
out["seal"] = (
|
||||
None
|
||||
if ch.seal is None
|
||||
else {"sealed_at": ch.seal["sealed_at"], "key_id": ch.seal["key_id"], "broken_at": ch.seal["broken_at"]}
|
||||
)
|
||||
out["checklist_items"] = checklists.checklist_for(weld, row["drawing"], row["rev"], row["wps"], ch.templates)
|
||||
out["qc_items"] = checklists.qc_items_for(row["drawing"], row["rev"], row["wps"], row["welder_stamp"])
|
||||
out["qc_items"] = qc_items_view(row, ch)
|
||||
out["flag"] = flag_of(row["status"], row["source_kind"], row["flags"], row["drawing_state"], row["visual"])
|
||||
if not seal:
|
||||
out["visual_state"] = visual_state(conn, row)
|
||||
@@ -754,6 +801,7 @@ def build(
|
||||
from . import shop # shop builds on this module
|
||||
|
||||
out["heat_src"] = shop.heat_src(conn, row, ch)
|
||||
out.update(qc_extras(conn, row))
|
||||
if pp is not None:
|
||||
allowed, blocked = policy.can(pp, state)
|
||||
out["can"] = [str(a) for a in allowed]
|
||||
@@ -786,6 +834,9 @@ def _seal_strip(out: dict[str, Any]) -> None:
|
||||
for r in out.get(key) or []:
|
||||
for n in names:
|
||||
r.pop(n, None)
|
||||
for h in out.get("qc_history") or []:
|
||||
for n in SEAL_QC_HISTORY:
|
||||
h.pop(n, None)
|
||||
for co in out.get("change_orders") or []:
|
||||
for v in (co.get("verified") or {}).values():
|
||||
v.pop("signed", None)
|
||||
@@ -803,6 +854,74 @@ def _seal_strip(out: dict[str, Any]) -> None:
|
||||
v.pop("by_name", None)
|
||||
|
||||
|
||||
def _doc(d: Mapping[str, Any], people: People, *, seal: bool) -> dict[str, Any]:
|
||||
"""A packet document. Seal mode keeps the phase 2 keys, plus the note and the take-out when they are set, so a
|
||||
seal made before phase 6 still verifies for a packet whose documents were never taken out."""
|
||||
from .qc import DOC_KINDS
|
||||
|
||||
removed = (
|
||||
None
|
||||
if d["removed_at"] is None
|
||||
else {"at": d["removed_at"], "by": people.ref(d["removed_by"]), "reason": d["removed_reason"]}
|
||||
)
|
||||
out: dict[str, Any] = {
|
||||
"id": d["id"],
|
||||
"asset_id": str(d["asset_id"]),
|
||||
"kind": d["kind"],
|
||||
"title": d["title"],
|
||||
"added_at": d["added_at"],
|
||||
"added_by": people.ref(d["added_by"]),
|
||||
"url": f"/_blob/{d['asset_id']}",
|
||||
}
|
||||
if seal:
|
||||
if d.get("note"):
|
||||
out["note"] = d["note"]
|
||||
if removed is not None:
|
||||
out["removed"] = removed
|
||||
return out
|
||||
out["kind_label"] = DOC_KINDS.get(str(d["kind"]), str(d["kind"]))
|
||||
out["note"] = d.get("note")
|
||||
out["file_name"] = d.get("file_name")
|
||||
out["size"] = d.get("file_size")
|
||||
out["removed"] = removed
|
||||
return out
|
||||
|
||||
|
||||
def qc_items_view(row: Mapping[str, Any], ch: Children) -> list[dict[str, Any]]:
|
||||
"""The Q.C. items with the old qcTab hints: heats and MTRs over the BOM lines, the welds on the map, and the open
|
||||
(or last) stage's checklist progress with its filler."""
|
||||
items = checklists.qc_items_for(row["drawing"], row["rev"], row["wps"], row["welder_stamp"], ch.qc_items or None)
|
||||
nb = len(ch.bom)
|
||||
heats = sum(1 for b in ch.bom if str(b.get("heat") or "").strip() or b.get("na"))
|
||||
mtrs = sum(1 for b in ch.bom if b.get("mtr_asset_id"))
|
||||
stage = ch.open_stage or (ch.stages[-1] if ch.stages else None)
|
||||
keys = checklists.checklist_keys(bool(ch.dept.get("weld")), ch.templates or None)
|
||||
ck = (stage or {}).get("checklist") or {}
|
||||
done = sum(1 for k in keys if checklists.is_done(ck.get(k)))
|
||||
filler = str((stage or {}).get("filler") or "").strip()
|
||||
hints = {
|
||||
"db_heats": f"{heats}/{nb} recorded",
|
||||
"db_mtr": f"{mtrs}/{nb} MTRs attached",
|
||||
"db_weldmap": f"{len(ch.welds)} welds on map",
|
||||
"db_ck": f"checklist {done}/{len(keys)}" + (f" · filler {filler}" if filler else ""),
|
||||
}
|
||||
return [{**i, "hint": hints.get(i["key"])} for i in items]
|
||||
|
||||
|
||||
def qc_extras(conn: Any, row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""The unit's Q.C. folder documents, its ITP summary and the packet's ITP hold warning. Outside the seal."""
|
||||
from . import itp, qcdocs
|
||||
|
||||
unit_id = int(row["unit_id"])
|
||||
r = conn.execute("SELECT project_id FROM units WHERE id = %s", (unit_id,)).fetchone()
|
||||
found = itp.unit_itps(conn, [unit_id]).get(unit_id)
|
||||
return {
|
||||
"qc_folder_docs": qcdocs.packet_folder_docs(conn, unit_id, int(r[0]) if r else None),
|
||||
"itp": itp.summary(found[0], found[1]) if found else None,
|
||||
"itp_hold": itp.holds_for_packet(found[0], found[1], row["dept_code"]) if found else None,
|
||||
}
|
||||
|
||||
|
||||
def target_hrs(work_orders: Iterable[Mapping[str, Any]]) -> float:
|
||||
"""The packet's target hours: the sum of its work orders' target hours (0 when none)."""
|
||||
total = 0.0
|
||||
|
||||
Reference in New Issue
Block a user