The shop at parity with the old build. Supervisor screen (department chips, CO headline, numbers, Change orders
on hold, station grid with clocks and CO tags, boards) and Stall screen (station picker, hero, My packets,
Submitted). Shop steps on the packet page: assign with station, welder picker and stamp; pull back; Start, Pause,
Resume; Submit with a PIN signature and the missing steps as links; review with Send to Q.C., Return to station
and Skip Q.C. route; a lead's read-only review. Weld map (pdf.js viewer with pan, pinch, zoom, full screen; marks
with leader lines; weld sheet; weld record with field CAS; carried marks). Heat map (heat table placed, dragged,
sized, removed; legacy amber tags; borrowed heat map read-only). Checklist tab with templates from settings.
VOID stamps on voided pages. Change orders: CO-n labels, supervisor verify with signature, approval copies the
heat table to the new sheet and the BOM merge keeps the shop's values. Server clock of record with VISUAL ticket
hours beside it. Presence per packet over SSE. Migration 0006 (bfx, bfy, carried_from, checklist_templates).
New endpoints /api/shop/board, /api/shop/stations, /api/shop/stall, /api/departments/{code}/people.
Tests: policy oracle for every shop action, role and status; clock arithmetic; concurrent inserts; CO apply;
frontend geometry, heat table, clock, checklist, weld sheet, screens; seven Playwright scenarios. Docs updated.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
1252 lines
48 KiB
Python
1252 lines
48 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",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------- 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)
|
|
|
|
@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.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)
|
|
ch.docs = rows(
|
|
conn, "SELECT * FROM packet_docs WHERE packet_id = %s AND removed_at IS NULL ORDER BY added_at, 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)),
|
|
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
|
|
|
|
|
|
def _qc(q: Mapping[str, Any], people: People) -> 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",
|
|
"row_version",
|
|
)
|
|
out = {k: q[k] for k in keys}
|
|
out["sent_by"] = people.ref(q["sent_by"])
|
|
out["result_by"] = people.ref(q["result_by"])
|
|
out["checks"] = q["checks"] or {}
|
|
out["weld_exam"] = q["weld_exam"] or {}
|
|
return out
|
|
|
|
|
|
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 += [d["added_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) if ch.qc else None
|
|
out["qc_history"] = [
|
|
{k: h[k] for k in ("id", "at", "result", "inspector", "note", "dept_code")} for h in ch.qc_history
|
|
]
|
|
out["change_orders"] = [_co(c, ch, people) for c in ch.change_orders]
|
|
out["docs"] = [
|
|
{
|
|
"id": d["id"],
|
|
"asset_id": str(d["asset_id"]),
|
|
"kind": d["kind"],
|
|
"title": d["title"],
|
|
"added_at": d["added_at"],
|
|
"added_by": people.ref(d["added_by"]),
|
|
"url": f"/_blob/{d['asset_id']}",
|
|
}
|
|
for d in ch.docs
|
|
]
|
|
out["seal"] = (
|
|
None
|
|
if ch.seal is None
|
|
else {"sealed_at": ch.seal["sealed_at"], "key_id": ch.seal["key_id"], "broken_at": ch.seal["broken_at"]}
|
|
)
|
|
out["checklist_items"] = checklists.checklist_for(weld, row["drawing"], row["rev"], row["wps"], ch.templates)
|
|
out["qc_items"] = checklists.qc_items_for(row["drawing"], row["rev"], row["wps"], row["welder_stamp"])
|
|
out["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)
|
|
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 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 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
|