Files
PacketStation/server/packetstation/domain/packets.py
T
spencermandClaude Opus 5.5 1cd6688247
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
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]>
2026-10-30 13:33:51 -06:00

1513 lines
60 KiB
Python

"""The packet read model, the policy's PacketState, and the board cards.
`read_packet` is the full answer of GET /api/packets/{id} and of every packet mutation. `packet_state` builds the
policy's view from the same child rows, so a write and a read decide from the same facts. `list_cards` serves the
boards from header columns, v_open_cos and the open short count only.
"""
from __future__ import annotations
import re
import uuid
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field
from typing import Any
from psycopg.rows import dict_row
from .. import policy
from ..api import deps
from ..api.errors import ApiError
from . import checklists, search
HAND_FLAG = "Made by hand - check it and send it on"
DRAWING_NEEDED_FLAG = "Made from VISUAL - drawing not attached yet"
DRAWING_STATES_ATTACHED = ("attached", "hand")
# Keys left out of the sealed content: they change without the packet changing (who can do what, the counter,
# the seal itself, the data book export date) or come from code and AD rather than the packet (checklist texts,
# the flag, the unit and project names, display names).
SEAL_EXCLUDE = (
"can",
"blocked",
"holder",
"version",
"log",
"seal",
"databook_at",
"checklist_items",
"qc_items",
"flag",
"visual_state",
"server_now",
"target_hrs",
"labor",
"heat_src",
"qc_last",
"qc_folder_docs",
"itp",
"itp_hold",
"seals",
"vclose_check",
"prints",
)
# ---------------------------------------------------------------- small helpers
def rows(conn: Any, query: str, params: Any = ()) -> list[dict[str, Any]]:
with conn.cursor(row_factory=dict_row) as cur:
return list(cur.execute(query, params).fetchall())
def one(conn: Any, query: str, params: Any = ()) -> dict[str, Any] | None:
with conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(query, params).fetchone()
return dict(r) if r is not None else None
def uid_text(value: Any) -> str | None:
return None if value is None else str(value)
def load_row(conn: Any, packet_id: int, *, lock: bool = False) -> dict[str, Any] | None:
"""The packets row, or None. With lock=True it is taken FOR UPDATE (inside a transaction)."""
sql = "SELECT *, status::text AS status, source_kind::text AS source_kind, routed_to::text AS routed_to,"
sql += " drawing_state::text AS drawing_state FROM packets WHERE id = %s"
if lock:
sql += " FOR UPDATE"
return one(conn, sql, (packet_id,))
# Flags the drawing job owns; a hand drawing or an approved rev clears them.
DRAWING_FLAGS = ("drawing_needed", "rev_mismatch", "not_released", "not_found", "lookup_failed", "bad_rev")
# drawing_state values that mean the drawing still needs attention.
DRAWING_STATES_FLAGGED = ("needed", "rev_mismatch", "not_released", "not_found", "lookup_failed")
HAND_UPLOAD_FLAGS = ("hand_drawing", "hand_bom")
# Flag kinds in display priority (the first one present gives the text).
FLAG_PRIORITY = (
"hand_drawing",
"hand_bom",
"rev_mismatch",
"not_released",
"not_found",
"lookup_failed",
"bad_rev",
"drawing_needed",
"no_drawing",
"wo_gone",
"cancelled",
)
FLAG_TEXTS = {
"not_found": "Drawing not in M-Files - check the drawing number in VISUAL",
"bad_rev": "VISUAL rev is not a number - drawing not attached",
"lookup_failed": "M-Files lookup failed",
"drawing_needed": DRAWING_NEEDED_FLAG,
"no_drawing": "No drawing number in VISUAL - add it there or upload the drawing by hand",
"wo_gone": "Work order no longer in VISUAL",
"cancelled": "Work order cancelled in VISUAL",
}
# drawing_state -> flag kind, for a visual draft whose flags list does not name the problem.
STATE_KINDS = {
"needed": "drawing_needed",
"rev_mismatch": "rev_mismatch",
"not_released": "not_released",
"not_found": "not_found",
"lookup_failed": "lookup_failed",
}
RX_FLAG_KIND = re.compile(r"^[a-z_]{1,30}$")
def _appr_note(visual: Mapping[str, Any] | None) -> str:
appr = (visual or {}).get("appr") if isinstance(visual, Mapping) else None
if isinstance(appr, Mapping):
note = appr.get("note")
if isinstance(note, str) and note.strip():
return note.strip()
return ""
def flag_of(
status: str,
source_kind: str,
flags: Iterable[str] | None,
drawing_state: str | None = None,
visual: Mapping[str, Any] | None = None,
) -> str | None:
"""The draft flag shown on cards and the packet page: the old packetFlag(), handFlag() and revMismatch() texts.
Only drafts carry a flag. A hand draft keeps the phase 2 text. A VISUAL draft shows its most important flag kind.
"""
if status != "draft":
return None
if source_kind == "hand":
return HAND_FLAG
kinds = list(flags or ())
if not kinds and drawing_state in STATE_KINDS:
kinds = [STATE_KINDS[drawing_state]]
if not kinds:
return None
dwg, bom = "hand_drawing" in kinds, "hand_bom" in kinds
if dwg or bom:
what = "Drawing and BOM" if dwg and bom else "Drawing" if dwg else "BOM"
return f"{what} uploaded by hand - check it and send it on"
note = _appr_note(visual)
for kind in FLAG_PRIORITY:
if kind not in kinds:
continue
if kind == "rev_mismatch":
return note or "Rev mismatch: VISUAL and M-Files show different revs"
if kind == "not_released":
return note or "Not released in M-Files yet"
if kind in ("lookup_failed", "bad_rev"):
# The drawing job writes the whole text ("M-Files lookup failed: ...", "VISUAL rev A is not a number;
# M-Files has rev 3 - not attached").
return note or FLAG_TEXTS[kind]
return FLAG_TEXTS[kind]
return "Flagged: " + ", ".join(k.replace("_", " ") for k in kinds)
# SQL for the same rule, for the list filter.
FLAG_SQL = "(p.status = 'draft' AND (p.source_kind = 'hand' OR cardinality(p.flags) > 0))"
# A pending change order from the feed (VISUAL or M-Files): the "Changes to approve" board.
CO_PENDING_SQL = (
"EXISTS (SELECT 1 FROM change_orders c WHERE c.packet_id = p.id AND c.review = 'pending'"
" AND c.origin IN ('visual', 'drawing'))"
)
def policy_principal_for(conn: Any, p: deps.Principal) -> policy.Principal:
"""The policy Principal for a session: its roles, the user's station limits and the session kind."""
limits = [r[0] for r in conn.execute("SELECT station_id FROM user_station_limits WHERE user_id = %s", (p.user_id,))]
return policy.principal_from_roles(str(p.user_id), p.roles, station_limits=limits, session_kind=p.kind)
def co_label(co_no: Any) -> str:
"""The change order id shown everywhere: CO-2."""
return f"CO-{co_no}"
def co_hold_why(open_cos: Iterable[Mapping[str, Any]], labels: Mapping[str, str]) -> str:
"""The CO hold text, one sentence per open change order. `labels` maps a department code to its display name
(dept_labels: "03 Weld Parts", "Warehouse")."""
parts: list[str] = []
for co in open_cos:
label = co_label(co["co_no"])
if str(co.get("review")) == "pending":
parts.append(
f"{label}: Clerical is reviewing a change from VISUAL. Held until Clerical approves or rejects it."
)
else:
names = ", ".join(labels.get(c, c) for c in co.get("pending_depts") or [])
parts.append(f"Change order {label} must be verified first by: {names}.")
return " ".join(parts)
def dept_names(conn: Any) -> dict[str, str]:
return {r[0]: r[1] for r in conn.execute("SELECT code, name FROM departments")}
def dept_label(code: str, name: str | None, kind: str | None) -> str:
"""A department in texts: a shop department as code and name ("03 Weld Parts"), a support one by name."""
if kind == "shop":
return f"{code} {name or ''}".strip()
return name or code
def dept_labels(conn: Any) -> dict[str, str]:
return {r[0]: dept_label(r[0], r[1], r[2]) for r in conn.execute("SELECT code, name, kind::text FROM departments")}
# ---------------------------------------------------------------- the child rows
@dataclass
class Children:
dept: dict[str, Any]
station: dict[str, Any] | None
work_orders: list[dict[str, Any]] = field(default_factory=list)
pages: list[dict[str, Any]] = field(default_factory=list)
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)
stages: list[dict[str, Any]] = field(default_factory=list)
wh: dict[str, Any] | None = None
qc: dict[str, Any] | None = None
qc_history: list[dict[str, Any]] = field(default_factory=list)
change_orders: list[dict[str, Any]] = field(default_factory=list)
co_items: dict[int, list[dict[str, Any]]] = field(default_factory=dict)
co_required: dict[int, list[str]] = field(default_factory=dict)
co_verified: dict[int, list[dict[str, Any]]] = field(default_factory=dict)
open_cos: list[dict[str, Any]] = field(default_factory=list)
docs: list[dict[str, Any]] = field(default_factory=list)
seal: dict[str, Any] | None = None
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:
return next((s for s in self.stages if s["closed_at"] is None), None)
def load_children(conn: Any, row: Mapping[str, Any], *, full: bool = True) -> Children:
"""Every child row the read model and the policy need. full=False skips what only the read model shows."""
pid = row["id"]
dept = one(conn, "SELECT code, name, weld FROM departments WHERE code = %s", (row["dept_code"],)) or {
"code": row["dept_code"],
"name": row["dept_code"],
"weld": False,
}
station = (
one(conn, "SELECT id, name FROM stations WHERE id = %s", (row["station_id"],)) if row["station_id"] else None
)
ch = Children(dept=dept, station=station)
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.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,))
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,
"SELECT co_id, co_no, review::text AS review, hold, pending_depts FROM v_open_cos WHERE packet_id = %s"
" 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,))
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,)
)
ch.heat_table = rows(conn, "SELECT * FROM heat_table_placements WHERE packet_id = %s ORDER BY page", (pid,))
ch.wh = one(
conn, "SELECT *, stage::text AS stage FROM packet_wh WHERE packet_id = %s AND closed_at IS NULL", (pid,)
)
ch.qc_history = rows(conn, "SELECT * FROM qc_history WHERE packet_id = %s ORDER BY id", (pid,))
ch.change_orders = rows(
conn,
"SELECT *, origin::text AS origin, review::text AS review FROM change_orders WHERE packet_id = %s"
" ORDER BY co_no",
(pid,),
)
co_ids = [c["id"] for c in ch.change_orders]
if co_ids:
for r in rows(conn, "SELECT * FROM co_items WHERE co_id = ANY(%s) ORDER BY id", (co_ids,)):
ch.co_items.setdefault(r["co_id"], []).append(r)
for r in rows(
conn, "SELECT co_id, dept_code FROM co_required_depts WHERE co_id = ANY(%s) ORDER BY dept_code", (co_ids,)
):
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 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
# 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"
def needs_heat(line: Mapping[str, Any]) -> bool:
"""The old needsHeat for a pulled line: not N/A, not a sub-assembly, pieces issued and no heat."""
if line.get("na") or is_sub(line) or not line.get("pulled"):
return False
if str(line.get("heat") or "").strip():
return False
try:
return float(line.get("issued") or 0) > 0
except (TypeError, ValueError):
return False
def dept_welds(row: Mapping[str, Any], ch: Children) -> int:
"""The live welds of the packet's current department. A weld row without a department (written outside the API)
counts for the current one. Welds an earlier department logged are carried, not counted (the old missing())."""
dept = str(row["dept_code"])
return sum(1 for w in ch.welds if w.get("dept_code") in (None, dept))
def state_from(row: Mapping[str, Any], ch: Children) -> policy.PacketState:
weld = bool(ch.dept.get("weld"))
live_bom = [b for b in ch.bom if not b["na"]]
stage = ch.open_stage
return policy.PacketState(
id=int(row["id"]),
status=str(row["status"]),
dept=str(row["dept_code"]),
station_id=row["station_id"],
welder_id=uid_text(row["welder_id"]),
wh_skipped=bool(row["wh_skipped"]),
routed_to=row["routed_to"],
vclosed=row["vclosed_at"] is not None,
removed=row["removed_at"] is not None,
source_kind=str(row["source_kind"]),
weld_dept=weld,
co_hold=bool(ch.open_cos),
co_hold_why=co_hold_why(ch.open_cos, ch.dept_labels),
open_shorts=len(ch.shorts),
drawing_attached=any(pg["type"] == "drawing" and pg["void_at"] is None for pg in ch.pages),
heat_map=row["heat_map"],
bom_total=len(live_bom),
bom_ready=sum(1 for b in live_bom if b["pulled"] or b["id"] in ch.shorts),
bom_missing_heat=sum(1 for b in live_bom if needs_heat(b)),
welds=dept_welds(row, ch),
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, 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),
)
def packet_state(conn: Any, row: Mapping[str, Any]) -> policy.PacketState:
"""The policy's view of a packet row, from its child rows."""
return state_from(row, load_children(conn, row, full=False))
def holder_text(state: policy.PacketState, ch: Children) -> str:
return policy.holder_name(state, ch.dept.get("name"), (ch.station or {}).get("name"))
# ---------------------------------------------------------------- shaping
class People:
"""Display names for who references. In seal mode a reference is the id only."""
def __init__(self, conn: Any, ids: Iterable[Any], *, seal: bool = False):
self.seal = seal
want = sorted({str(i) for i in ids if i is not None})
self.names: dict[str, str] = {}
if want and not seal:
for r in conn.execute(
"SELECT id, display_name FROM users WHERE id = ANY(%s)", ([uuid.UUID(i) for i in want],)
):
self.names[str(r[0])] = r[1]
def ref(self, uid: Any) -> dict[str, Any] | None:
if uid is None:
return None
if self.seal:
return {"id": str(uid)}
return {"id": str(uid), "display_name": self.names.get(str(uid))}
def name(self, uid: Any) -> str | None:
return None if uid is None else self.names.get(str(uid))
def as_uuid(value: Any) -> uuid.UUID | None:
"""A uuid from stored text (a checklist entry's `by`), or None when it is not one."""
if value is None:
return None
try:
return uuid.UUID(str(value))
except ValueError:
return None
def _station_ref(conn: Any, station_id: Any, cache: dict[Any, dict[str, Any] | None]) -> dict[str, Any] | None:
if station_id is None:
return None
if station_id not in cache:
cache[station_id] = one(conn, "SELECT id, name FROM stations WHERE id = %s", (station_id,))
return cache[station_id]
def clock_of(s: Mapping[str, Any], now: Any) -> dict[str, Any]:
"""A stage clock. elapsed_ms is live: the stored time plus the running span up to the database now. Only the
server writes the clock columns; the client adds the time since its fetch for the ticking display only."""
stored = int(s["clock_elapsed_ms"] or 0)
since = s["clock_running_since"]
running = since is not None and s.get("closed_at") is None
live = stored
if running and now is not None:
live += max(0, int((now - since).total_seconds() * 1000))
return {
"elapsed_ms": live,
"stored_ms": stored,
"running": running,
"running_since": since,
"started_at": s["clock_started_at"],
}
def _checklist(value: Any, people: People) -> dict[str, Any]:
out: dict[str, Any] = {}
for k, v in (value or {}).items():
if isinstance(v, Mapping):
entry = dict(v)
if entry.get("by") is not None:
entry["by_name"] = people.name(as_uuid(entry["by"]))
out[k] = entry
else:
out[k] = v
return out
def _stage(
conn: Any, s: Mapping[str, Any], people: People, cache: dict[Any, Any], *, closed: bool, now: Any = None
) -> dict[str, Any]:
welder = people.ref(s["welder_id"])
if welder is not None:
welder["stamp"] = s["welder_stamp"]
out = {
"id": s["id"],
"dept_code": s["dept_code"],
"station": _station_ref(conn, s["station_id"], cache),
"welder": welder,
"checklist": _checklist(s["checklist"], people),
"filler": s["filler"],
"filler_lot": s["filler_lot"],
"preheat": s["preheat"],
"notes": s["notes"],
"clock": clock_of(s, now),
"opened_at": s["opened_at"],
"row_version": s["row_version"],
}
if closed:
out["closed_at"] = s["closed_at"]
return out
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",
"book",
"spool",
"location",
"sent_note",
"nde_method",
"nde_result",
"nde_report",
"hardness",
"nace",
"inspector",
"stamp",
"notes",
"result",
"result_at",
"result_note",
"ncr",
"row_version",
)
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"] = _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]
ids += [s["welder_id"] for s in ch.stages]
for s in ch.stages:
for v in (s["checklist"] or {}).values():
if isinstance(v, Mapping):
ids.append(as_uuid(v.get("by")))
ids += [h["by"] for h in ch.heat_table] + [pg["void_by"] for pg in ch.pages]
if ch.wh:
ids.append(ch.wh["by"])
if ch.qc:
ids += [ch.qc["sent_by"], ch.qc["result_by"]]
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():
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
def _unit(conn: Any, unit_id: int, *, seal: bool) -> dict[str, Any]:
if seal:
return {"id": unit_id}
u = one(
conn,
"SELECT u.id, u.lot, u.name, u.number, u.due_effective, p.id AS project_id, p.number AS project_number,"
" p.name AS project_name, p.customer, p.po, p.status::text AS project_status"
" FROM v_units u JOIN projects p ON p.id = u.project_id WHERE u.id = %s",
(unit_id,),
)
if u is None:
return {"id": unit_id, "number": None, "lot": None, "name": None, "project": None, "due_effective": None}
return {
"id": u["id"],
"number": u["number"],
"lot": u["lot"],
"name": u["name"],
"project": {
"id": u["project_id"],
"number": u["project_number"],
"name": u["project_name"],
"customer": u["customer"],
"po": u["po"],
"status": u["project_status"],
},
"due_effective": u["due_effective"],
}
def _visual(conn: Any, row: Mapping[str, Any], *, seal: bool) -> dict[str, Any]:
"""The packet's visual jsonb. On a VISUAL draft (outside seal mode) it gains `staged_rev`: the M-Files copy the
drawing job staged for this drawing ({rev, name}), or None. It changes without the packet changing, so the seal
leaves it out."""
out = dict(row["visual"] or {})
if seal or row["source_kind"] != "visual" or row["status"] != "draft":
return out
out["staged_rev"] = staged_rev(conn, row["drawing"])
return out
def staged_rev(conn: Any, drawing: Any) -> dict[str, Any] | None:
lk = staged_lookup(conn, drawing)
if lk is None:
return None
return {"rev": lk["rev"], "name": lk["name"], "asset_id": str(lk["asset_id"])}
def staged_lookup(conn: Any, drawing: Any) -> dict[str, Any] | None:
"""The drawing_lookups row for a drawing number when it holds a fetched copy (asset and rev set)."""
dwg = str(drawing or "").strip()
if not dwg:
return None
return one(
conn,
"SELECT * FROM drawing_lookups WHERE dwg = %s AND asset_id IS NOT NULL AND rev IS NOT NULL",
(dwg,),
)
def visual_state(conn: Any, row: Mapping[str, Any]) -> dict[str, Any] | None:
"""What the feed last observed for a VISUAL packet: leg statuses, ticket hours, the chosen order, and the closed,
cancelled and gone times. None for a hand packet."""
if row["source_kind"] != "visual":
return None
vs = one(
conn,
"SELECT legs, ticket_hours, order_ref, observed_at FROM packet_visual_state WHERE packet_id = %s",
(row["id"],),
)
return {
"legs": (vs or {}).get("legs") or [],
"ticket_hours": (vs or {}).get("ticket_hours"),
"order_ref": (vs or {}).get("order_ref"),
"observed_at": (vs or {}).get("observed_at"),
"closed_at": row.get("visual_closed_at"),
"cancelled_at": row.get("visual_cancelled_at"),
"wo_gone_at": row.get("wo_gone_at"),
}
# Leg status codes the feed records that mean the work order is no longer open in VISUAL: closed and cancelled.
LEG_DONE = ("C", "X")
def open_wos(vs: Mapping[str, Any] | None) -> list[str]:
"""The work orders the feed last saw open in VISUAL, as "wo_base/lot" text, in leg order without repeats.
A leg is open when the feed recorded a status for it and that status is not closed (C) or cancelled (X). A leg
with no recorded status is left out: the feed has not seen it. The IDs stay text exactly as stored."""
out: list[str] = []
for leg in (vs or {}).get("legs") or []:
if not isinstance(leg, Mapping):
continue
st = str(leg.get("st") or leg.get("status") or "").strip().upper()
if not st or st in LEG_DONE:
continue
parts = str(leg.get("key") or "").split("/")
if len(parts) >= 4:
base, lot = "/".join(parts[:-3]), parts[-3]
else:
base, lot = parts[0], (parts[1] if len(parts) > 1 else "")
if not base:
continue
text = f"{base}/{lot}" if lot else base
if text not in out:
out.append(text)
return out
def vclose_check(row: Mapping[str, Any], vs: Mapping[str, Any] | None) -> dict[str, Any] | None:
"""What the feed says about closing the job in VISUAL, next to the Clerical confirm. None for a hand packet.
`agrees` is true when the Clerical confirm (vclosed) and the feed's closed time are both set or both unset."""
if vs is None:
return None
closed_at = vs.get("closed_at")
return {
"observed_at": vs.get("observed_at"),
"closed_at": closed_at,
"open_wos": open_wos(vs),
"agrees": (row.get("vclosed_at") is not None) == (closed_at is not None),
}
def packet_open_wos(conn: Any, row: Mapping[str, Any]) -> list[str]:
"""The open work orders of `vclose_check` for one packets row (empty for a hand packet)."""
return open_wos(visual_state(conn, row))
def seal_count(conn: Any, packet_id: int) -> int:
r = conn.execute("SELECT count(*) FROM packet_seals WHERE packet_id = %s", (packet_id,)).fetchone()
return int(r[0]) if r else 0
def seal_view(conn: Any, s: Mapping[str, Any] | None) -> dict[str, Any] | None:
"""The newest seal row, broken or not, with who sealed it and the data book filed with it."""
if s is None:
return None
people = People(conn, [s["sealed_by"], s["broken_by"]])
book: dict[str, Any] | None = None
aid = s.get("databook_asset_id")
if aid is not None:
a = one(conn, "SELECT id, size, original_name FROM assets WHERE id = %s", (aid,))
book = {
"asset_id": str(aid),
"url": f"/_blob/{aid}",
"download_url": f"/_blob/{aid}?download=1",
"pages": s.get("databook_pages"),
"size": None if a is None else a["size"],
"file_name": None if a is None else a["original_name"],
}
wos = s.get("open_wos")
return {
"id": s["id"],
"sealed_at": s["sealed_at"],
"sealed_by": people.ref(s["sealed_by"]),
"key_id": s["key_id"],
"signed": s["signature_id"] is not None,
"broken_at": s["broken_at"],
"broken_by": people.ref(s["broken_by"]),
"broken_reason": s["broken_reason"],
"open_wos": None if wos is None else list(wos),
"databook": book,
}
def build(
conn: Any,
row: Mapping[str, Any],
pp: policy.Principal | None,
*,
include_children: bool = True,
seal: bool = False,
) -> dict[str, Any]:
"""The read model of one loaded packets row. pp None (seal mode) leaves out can, blocked and holder."""
ch = load_children(conn, row, full=True)
state = state_from(row, ch)
people = People(conn, _who_ids(row, ch), seal=seal)
cache: dict[Any, Any] = {}
now = None if seal else conn.execute("SELECT now()").fetchone()[0]
welder = people.ref(row["welder_id"])
if welder is not None:
welder["stamp"] = row["welder_stamp"]
weld = bool(ch.dept.get("weld"))
out: dict[str, Any] = {
"id": row["id"],
"version": row["version"],
"unit": _unit(conn, row["unit_id"], seal=seal),
"dept": {"code": ch.dept["code"], "name": ch.dept["name"], "weld": weld},
"op": row["op"],
"title": row["title"],
"customer": row["customer"],
"drawing": row["drawing"],
"rev": row["rev"],
"wps": row["wps"],
"status": row["status"],
"status_at": row["status_at"],
"station": ch.station,
"welder": welder,
"for_sup": people.ref(row["for_sup_id"]),
"heat_map": row["heat_map"],
"wh_skipped": row["wh_skipped"],
"wh_sent_at": row["wh_sent_at"],
"routed_to": row["routed_to"],
"return_note": row["return_note"],
"vclosed": None
if row["vclosed_at"] is None
else {"at": row["vclosed_at"], "by": people.ref(row["vclosed_by"])},
"databook_at": row["databook_at"],
"source_kind": row["source_kind"],
"source": row["source"] or {},
"drawing_state": row["drawing_state"],
"flags": list(row["flags"] or []),
"visual": _visual(conn, row, seal=seal),
"removed": None
if row["removed_at"] is None
else {"at": row["removed_at"], "by": people.ref(row["removed_by"]), "reason": row["removed_reason"]},
"open_cos": [c["co_no"] for c in ch.open_cos],
"open_shorts": len(ch.shorts),
}
if not seal:
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"] = [
{
k: w[k]
for k in (
"id",
"wo_base",
"lot",
"split",
"sub",
"seq",
"qty",
"target_hrs",
"op",
"description",
"visual_status",
"gone_at",
"row_version",
)
}
| {k: w.get(k) for k in ("dwg", "rev", "partspecs")}
for w in ch.work_orders
]
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]
out["heat_table"] = [
{k: h[k] for k in ("page", "x", "y", "s", "carried_from", "at")} | {"by": people.ref(h["by"])}
for h in ch.heat_table
]
out["stage"] = _stage(conn, st, people, cache, closed=False, now=now) if st else None
out["stages"] = [
_stage(conn, s, people, cache, closed=True, now=now) for s in ch.stages if s["closed_at"] is not None
]
out["wh"] = (
None
if ch.wh is None
else {
"stage": ch.wh["stage"],
"by": people.ref(ch.wh["by"]),
"at": ch.wh["at"],
"reason": ch.wh["reason"],
"note": ch.wh["note"],
"auto": ch.wh["auto"],
}
)
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"] = [_doc(d, people, seal=seal) for d in ch.docs]
if not seal:
out["seal"] = seal_view(conn, ch.seal)
out["seals"] = seal_count(conn, row["id"]) if ch.seal is not None else 0
out["checklist_items"] = checklists.checklist_for(weld, row["drawing"], row["rev"], row["wps"], ch.templates)
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)
out["vclose_check"] = vclose_check(row, out["visual_state"])
if include_children:
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]
out["blocked"] = {str(a): r for a, r in blocked.items()}
out["holder"] = holder_text(state, ch)
if seal:
for k in SEAL_EXCLUDE:
out.pop(k, None)
# `sub` joined the BOM lines in phase 4; seals made before that do not cover it.
for b in out.get("bom") or []:
b.pop("sub", None)
_seal_strip(out)
return out
# Keys phase 5 added inside the child rows. Seals made before phase 5 do not cover them, and they are derived
# (carried, labels, pending lists) or volatile (the live clock), so the seal leaves them out.
SEAL_PHASE5 = {
"welds": ("bfx", "bfy", "dept_code", "stage_id", "carried", "by"),
"heat_tags": ("bfx", "bfy", "carried", "carried_from"),
"heat_table": ("carried_from", "by", "at"),
"change_orders": ("label", "pending_depts", "open"),
}
SEAL_CLOCK = ("stored_ms", "running")
SEAL_VOID = ("co_no", "by", "new")
def _seal_strip(out: dict[str, Any]) -> None:
for key, names in SEAL_PHASE5.items():
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)
for pg in out.get("pages") or []:
if pg.get("void"):
for n in SEAL_VOID:
pg["void"].pop(n, None)
for st in [out.get("stage"), *(out.get("stages") or [])]:
if not st:
continue
for n in SEAL_CLOCK:
st["clock"].pop(n, None)
for v in (st.get("checklist") or {}).values():
if isinstance(v, dict):
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
for w in work_orders:
try:
total += float(w.get("target_hrs") or 0)
except (TypeError, ValueError):
continue
return round(total, 3)
LABOR_SQL = """
SELECT l.wo_base, l.lot, l.split, l.sub, l.seq, l.hours, l.tickets, l.last_ticket, l.observed_at
FROM packet_work_orders w
JOIN visual_labor l ON l.wo_base = w.wo_base AND l.lot = w.lot AND l.split = w.split AND l.sub = w.sub
AND l.seq = w.seq
WHERE w.packet_id = %s
ORDER BY w.sort, w.id
"""
def labor_of(conn: Any, row: Mapping[str, Any]) -> dict[str, Any] | None:
"""VISUAL's labor ticket hours on the packet's work orders, or None for a hand packet or when nothing matches.
The station timer stays the clock of record; these hours are shown beside it."""
if row["source_kind"] != "visual":
return None
found = rows(conn, LABOR_SQL, (row["id"],))
if not found:
return None
per = [
{
"wo_base": r["wo_base"],
"lot": r["lot"],
"split": r["split"],
"sub": r["sub"],
"seq": r["seq"],
"hours": float(r["hours"] or 0),
"tickets": int(r["tickets"] or 0),
"last_ticket": r["last_ticket"],
}
for r in found
]
return {
"hours": round(sum(x["hours"] for x in per), 3),
"tickets": sum(x["tickets"] for x in per),
"per_wo": per,
"observed_at": max(r["observed_at"] for r in found),
}
def _page(pg: Mapping[str, Any], ch: Children, people: People) -> dict[str, Any]:
void: dict[str, Any] | None = None
if pg["void_at"] is not None:
co_no = next((c["co_no"] for c in ch.change_orders if c["id"] == pg["void_co_id"]), None)
new = next((x for x in ch.pages if pg["void_new_n"] is not None and x["n"] == pg["void_new_n"]), None)
void = {
"co_id": pg["void_co_id"],
"at": pg["void_at"],
"new_n": pg["void_new_n"],
"co_no": co_no,
"by": people.ref(pg["void_by"]),
"new": None if new is None else {"n": new["n"], "dwg": new["dwg"], "rev": new["rev"]},
}
return {
"n": pg["n"],
"type": pg["type"],
"asset_id": uid_text(pg["asset_id"]),
"pn": pg["pn"],
"dwg": pg["dwg"],
"rev": pg["rev"],
"void": void,
"url": f"/_blob/{pg['asset_id']}" if pg["asset_id"] else None,
}
def _heat_tag(t: Mapping[str, Any], open_stage: Mapping[str, Any] | None) -> dict[str, Any]:
out = {k: t[k] for k in ("tag_no", "page", "item", "fx", "fy", "bfx", "bfy", "carried_from", "row_version")}
out["carried"] = open_stage is not None and t["at"] is not None and t["at"] < open_stage["opened_at"]
return out
def _bom(b: Mapping[str, Any], ch: Children, people: People) -> dict[str, Any]:
keys = (
"id",
"item",
"qty",
"part_id",
"description",
"dwg_ref",
"cl_item",
"length",
"material",
"heat",
"na",
"issued",
"loc",
"pulled",
"src",
"sub",
"row_version",
)
out = {k: b[k] for k in keys}
out["mtr_asset_id"] = uid_text(b["mtr_asset_id"])
s = ch.shorts.get(b["id"])
out["short"] = (
None
if s is None
else {
"id": s["id"],
"qty": s["qty"],
"qty_open": s["qty_open"],
"reason": s["reason"],
"ref": s["ref"],
"eta": s["eta"],
"note": s["note"],
"opened_at": s["opened_at"],
"receipts": [
{
"id": r["id"],
"qty": r["qty"],
"heat": r["heat"],
"note": r["note"],
"at": r["at"],
"by": people.ref(r["by"]),
}
for r in ch.receipts.get(s["id"], [])
],
"row_version": s["row_version"],
}
)
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",
"fx",
"fy",
"bfx",
"bfy",
"wo_base",
"piece",
"type",
"size",
"process",
"stamp",
"dept_code",
"stage_id",
"at",
"row_version",
)
def _weld(w: Mapping[str, Any], people: People, open_stage: Mapping[str, Any] | None = None) -> dict[str, Any]:
out = {k: w[k] for k in WELD_KEYS}
out["items"] = list(w["items"] or [])
out["welder"] = people.ref(w["welder_id"])
out["by"] = people.ref(w["by"])
open_id = open_stage["id"] if open_stage else None
out["carried"] = w["stage_id"] is not None and w["stage_id"] != open_id
return out
def _co(c: Mapping[str, Any], ch: Children, people: People) -> dict[str, Any]:
keys = (
"id",
"co_no",
"origin",
"review",
"minor",
"hold",
"ecn",
"reason",
"issued_at",
"approved_at",
"rejected_at",
"rejected_reason",
"row_version",
"vis",
"waiting",
"staged",
"target_fp",
)
out = {k: c.get(k) for k in keys}
out["issued_by"] = people.ref(c["issued_by"])
out["approved_by"] = people.ref(c["approved_by"])
out["rejected_by"] = people.ref(c["rejected_by"])
out["items"] = [
{k: i[k] for k in ("old_page", "old_dwg", "old_rev", "new_page", "new_dwg", "new_rev", "added")}
for i in ch.co_items.get(c["id"], [])
]
required = ch.co_required.get(c["id"], [])
verified: dict[str, Any] = {
v["dept_code"]: {
"by": people.ref(v["by"]),
"at": v["at"],
"note": v["note"],
"signed": v["signature_id"] is not None,
}
for v in ch.co_verified.get(c["id"], [])
}
out["required"] = required
out["verified"] = verified
out["label"] = co_label(c["co_no"])
out["pending_depts"] = [d for d in required if d not in verified]
out["open"] = (c["review"] == "approved" and bool(out["pending_depts"])) or (
c["review"] == "pending" and bool(c["hold"])
)
return out
def read_packet(
conn: Any, packet_id: int, policy_principal: policy.Principal | None, *, include_children: bool = True
) -> dict[str, Any]:
"""The read model, or 404 not_found. A removed packet reads fine, with `removed` set."""
row = load_row(conn, packet_id)
if row is None:
raise ApiError(404, "not_found", "No such packet.")
return build(conn, row, policy_principal, include_children=include_children)
def seal_content(conn: Any, row: Mapping[str, Any]) -> dict[str, Any]:
"""What a filing seal covers: the read model without the volatile and derived keys (SEAL_EXCLUDE)."""
return build(conn, row, None, include_children=True, seal=True)
# ---------------------------------------------------------------- board cards
CARD_SQL = """
SELECT p.id, p.version, p.unit_id, u.lot, pr.number || u.lot AS unit_number, pr.id AS project_id,
pr.number AS project_number, pr.name AS project_name, p.dept_code, d.name AS dept_name, d.weld,
p.op, p.title, p.customer, p.drawing, p.rev, p.status::text AS status, p.status_at, p.station_id,
st.name AS station_name, p.welder_id, wu.display_name AS welder_name, p.welder_stamp,
p.routed_to::text AS routed_to, p.vclosed_at, p.vclosed_by, vu.display_name AS vclosed_name,
p.source_kind::text AS source_kind, p.flags, p.drawing_state::text AS drawing_state, p.wh_skipped,
p.heat_map, p.removed_at, coalesce(u.due, pr.due) AS due_effective, p.updated_at, p.created_at, p.visual,
p.visual_closed_at, p.visual_cancelled_at, p.wo_gone_at,
EXISTS (SELECT 1 FROM change_orders c WHERE c.packet_id = p.id AND c.review = 'pending'
AND c.origin IN ('visual', 'drawing')) AS co_pending,
pw.stage::text AS wh_stage, pw.by AS wh_by, whu.display_name AS wh_by_name, pw.at AS wh_at,
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), '[]')
FROM v_open_cos c WHERE c.packet_id = p.id) AS open_co_rows
FROM packets p
JOIN units u ON u.id = p.unit_id
JOIN projects pr ON pr.id = u.project_id
JOIN departments d ON d.code = p.dept_code
LEFT JOIN stations st ON st.id = p.station_id
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
WHERE s.bom_line_id = b.id AND s.closed_at IS NULL))) AS bom_pulled,
count(*) FILTER (WHERE NOT b.na AND upper(btrim(coalesce(b.sub, ''))) <> 'Y' AND b.pulled
AND coalesce(b.issued, 0) > 0 AND btrim(coalesce(b.heat, '')) = '') AS bom_missing_heat
FROM bom_lines b WHERE b.packet_id = p.id
) bc ON true
"""
def card_state(r: Mapping[str, Any], names: Mapping[str, str]) -> policy.PacketState:
"""PacketState from header columns and counts. Child-row facts the board does not load pass (the packet
page decides them): checklist, welds, filler, BOM pulled, Q.C. items."""
cos = r["open_co_rows"] or []
return policy.PacketState(
id=int(r["id"]),
status=str(r["status"]),
dept=str(r["dept_code"]),
station_id=r["station_id"],
welder_id=uid_text(r["welder_id"]),
wh_skipped=bool(r["wh_skipped"]),
routed_to=r["routed_to"],
vclosed=r["vclosed_at"] is not None,
removed=r["removed_at"] is not None,
source_kind=str(r["source_kind"]),
weld_dept=bool(r["weld"]),
co_hold=bool(cos),
co_hold_why=co_hold_why(cos, names),
open_shorts=int(r["open_shorts"] or 0),
drawing_attached=r["drawing_state"] in DRAWING_STATES_ATTACHED,
heat_map=r["heat_map"],
bom_total=0,
bom_ready=0,
welds=1,
checklist_complete=True,
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"]),
)
def wh_card(r: Mapping[str, Any]) -> dict[str, Any] | None:
"""The current warehouse stage row of a card, in the read model's `wh` shape."""
if r["wh_stage"] is None:
return None
return {
"stage": r["wh_stage"],
"by": None if r["wh_by"] is None else {"id": str(r["wh_by"]), "display_name": r["wh_by_name"]},
"at": r["wh_at"],
"reason": r["wh_reason"],
"note": r["wh_note"],
"auto": bool(r["wh_auto"]),
}
def card_where(filters: Mapping[str, Any]) -> tuple[list[str], list[Any]]:
"""The WHERE terms and parameters of the card filters, over CARD_SQL's aliases (p, u, pr, d, st, wu). The find
bar terms (status words, q, flag, flags, station_id, welder_id) come from domain/search.py."""
where: list[str] = []
params: list[Any] = []
if filters.get("ids") is not None:
where.append("p.id = ANY(%s)")
params.append([int(i) for i in filters["ids"]])
if not filters.get("removed"):
where.append("p.removed_at IS NULL")
status = search.status_terms(str(filters.get("status") or ""))
if status:
where.append("p.status::text = ANY(%s)")
params.append(status)
if filters.get("dept"):
where.append("p.dept_code = %s")
params.append(str(filters["dept"]))
if filters.get("unit_id") is not None:
where.append("p.unit_id = %s")
params.append(int(filters["unit_id"]))
if filters.get("project_id") is not None:
where.append("u.project_id = %s")
params.append(int(filters["project_id"]))
co = str(filters.get("co") or "").strip().lower()
if co == "pending":
where.append(CO_PENDING_SQL)
w, p = search.filter_where(filters)
return where + w, params + p
def list_cards(conn: Any, policy_principal: policy.Principal, filters: Mapping[str, Any]) -> list[dict[str, Any]]:
"""The board cards. Filters: status (comma list of raw statuses or find bar words), dept, unit_id, project_id,
q, flag (a flag kind value or the old raw values), flags (quick flags; mine needs a MineScope in `mine`),
station_id, welder_id, co (pending), ids (packet ids), sort (updated|project|due|status|dept), limit, removed,
lite (skip `can` and `blocked`, for the reports)."""
where, params = card_where(filters)
sql = CARD_SQL + (" WHERE " + " AND ".join(where) if where else "")
sql += " ORDER BY " + search.order_by(filters.get("sort")) + " LIMIT %s"
params.append(int(filters.get("limit") or 500))
lite = bool(filters.get("lite"))
names = dept_labels(conn)
transitions = {str(a) for a in policy.TRANSITIONS}
out: list[dict[str, Any]] = []
for r in rows(conn, sql, params):
state = card_state(r, names)
allowed, blocked = ([], {}) if lite else policy.can(policy_principal, state)
welder = (
None
if r["welder_id"] is None
else {"id": str(r["welder_id"]), "display_name": r["welder_name"], "stamp": r["welder_stamp"]}
)
out.append(
{
"id": r["id"],
"version": r["version"],
"unit": {"id": r["unit_id"], "number": r["unit_number"]},
"project": {"id": r["project_id"], "number": r["project_number"], "name": r["project_name"]},
"dept": {"code": r["dept_code"], "name": r["dept_name"]},
"op": r["op"],
"title": r["title"],
"customer": r["customer"],
"drawing": r["drawing"],
"rev": r["rev"],
"status": r["status"],
"status_at": r["status_at"],
"station": None if r["station_id"] is None else {"id": r["station_id"], "name": r["station_name"]},
"welder": welder,
"routed_to": r["routed_to"],
"vclosed": None
if r["vclosed_at"] is None
else {
"at": r["vclosed_at"],
"by": None
if r["vclosed_by"] is None
else {"id": str(r["vclosed_by"]), "display_name": r["vclosed_name"]},
},
"source_kind": r["source_kind"],
"flag": flag_of(r["status"], r["source_kind"], r["flags"], r["drawing_state"], r["visual"]),
"flag_kinds": list(r["flags"] or []),
"co_pending": bool(r["co_pending"]),
"visual_closed": r["visual_closed_at"] is not None,
"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"],
"visual_rel": r["visual_rel"],
"bom_total": int(r["bom_total"] or 0),
"bom_pulled": int(r["bom_pulled"] or 0),
"bom_missing_heat": int(r["bom_missing_heat"] or 0),
"due_effective": r["due_effective"],
"updated_at": r["updated_at"],
"created_at": r["created_at"],
"holder": policy.holder_name(state, r["dept_name"], r["station_name"]),
"can": [str(a) for a in allowed if str(a) in transitions],
"blocked": {str(a): v for a, v in blocked.items() if str(a) in transitions},
"wo_count": int(r["wo_count"] or 0),
"removed": r["removed_at"] is not None,
}
)
return out