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]>
1371 lines
53 KiB
Python
1371 lines
53 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
|
|
|
|
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",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------- 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
|
|
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.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,))
|
|
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,))
|
|
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,),
|
|
)
|
|
if not full:
|
|
return ch
|
|
ch.work_orders = rows(conn, "SELECT * FROM packet_work_orders WHERE packet_id = %s ORDER BY sort, id", (pid,))
|
|
if shorts:
|
|
for r in rows(
|
|
conn,
|
|
"SELECT * FROM short_receipts WHERE short_id = ANY(%s) ORDER BY id",
|
|
([s["id"] for s in shorts],),
|
|
):
|
|
ch.receipts.setdefault(r["short_id"], []).append(r)
|
|
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
|
|
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
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]
|
|
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"]}
|
|
|
|
|
|
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"),
|
|
}
|
|
|
|
|
|
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)
|
|
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]
|
|
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]
|
|
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"] = 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)
|
|
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
|
|
|
|
|
|
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.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,
|
|
(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 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
|
|
"""
|
|
|
|
FLAG_ON = ("1", "true", "yes", "any", "flagged")
|
|
FLAG_OFF = ("0", "false", "no", "none")
|
|
|
|
|
|
def _like(word: str) -> str:
|
|
return "%" + word.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
|
|
|
|
|
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",
|
|
)
|
|
|
|
|
|
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)."""
|
|
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 = [s.strip() for s in str(filters.get("status") or "").split(",") if s.strip()]
|
|
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"]))
|
|
flag = str(filters.get("flag") or "").strip().lower()
|
|
if flag in FLAG_ON:
|
|
where.append(FLAG_SQL)
|
|
elif flag in FLAG_OFF:
|
|
where.append("NOT " + FLAG_SQL)
|
|
elif flag == "hand":
|
|
# Made by hand, or a VISUAL draft with a drawing or BOM uploaded by hand.
|
|
where.append("(p.status = 'draft' AND (p.source_kind = 'hand' OR p.flags && %s::text[]))")
|
|
params.append(list(HAND_UPLOAD_FLAGS))
|
|
elif flag in ("hand_upload", "hand_uploads"):
|
|
where.append("(p.status = 'draft' AND p.flags && %s::text[])")
|
|
params.append(list(HAND_UPLOAD_FLAGS))
|
|
elif flag and RX_FLAG_KIND.match(flag):
|
|
# One flag kind on the Flagged board (drafts). A visual draft whose flags are empty but whose drawing state
|
|
# still names the problem counts too, the same way flag_of reads it.
|
|
states = [st for st, k in STATE_KINDS.items() if k == flag]
|
|
where.append(
|
|
"(p.status = 'draft' AND (%s = ANY(p.flags) OR (cardinality(p.flags) = 0 AND p.source_kind = 'visual'"
|
|
" AND p.drawing_state::text = ANY(%s))))"
|
|
)
|
|
params += [flag, states]
|
|
co = str(filters.get("co") or "").strip().lower()
|
|
if co == "pending":
|
|
where.append(CO_PENDING_SQL)
|
|
for word in str(filters.get("q") or "").split()[:8]:
|
|
pat = _like(word)
|
|
where.append(
|
|
"(pr.number ILIKE %s OR pr.name ILIKE %s OR (pr.number || u.lot) ILIKE %s OR u.lot ILIKE %s"
|
|
" OR p.drawing ILIKE %s OR p.customer ILIKE %s OR pr.customer ILIKE %s OR pr.po ILIKE %s"
|
|
" OR p.title ILIKE %s OR EXISTS (SELECT 1 FROM packet_work_orders wo WHERE wo.packet_id = p.id"
|
|
" AND wo.wo_base ILIKE %s))"
|
|
)
|
|
params += [pat] * 10
|
|
return where, params
|
|
|
|
|
|
def list_cards(conn: Any, policy_principal: policy.Principal, filters: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
"""The board cards. Filters: status (comma list), dept, unit_id, project_id, q, flag (1|0|hand|hand_upload|
|
|
<kind>), co (pending), ids (packet ids), limit, removed."""
|
|
where, params = card_where(filters)
|
|
sql = CARD_SQL + (" WHERE " + " AND ".join(where) if where else "")
|
|
sql += " ORDER BY p.updated_at DESC, p.id DESC LIMIT %s"
|
|
params.append(int(filters.get("limit") or 500))
|
|
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 = 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),
|
|
"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"],
|
|
"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
|