The Warehouse home with the numbers, the Open shorts table, the find bar and the five-column task board (waiting, being pulled, on hold, pulled, released in the last 3 days) with buttons, hold reasons and drag. Every role sees the warehouse stage on a packet as the WH pill and the line under it. The pull table on the Materials tab: Pulled with auto-start, issued, location, heat with Enter to the next line, shorts with receipts, N/A, MTR per line with a viewer. Send back to Clerical with a reason. Release to supervisor stays blocked until every line is pulled or short and every pulled line has a heat. Server: the heats_recorded precondition on WH_RELEASE, receive by custody, server-validated hold and short reasons, the old packet history texts, GET /api/warehouse/board, cards with the warehouse stage and pull counts. Uploads typed by content per kind (drawing, mtr, document, bom with XLSX and CSV), limits per kind, optional ClamAV behind the clamav compose profile, a storage quota alert on the new notification_events table (migration 0005) shown on the Admin Storage tab. Tests: every warehouse action as every role, the release gate, the short-receive exception, the heat pattern, upload sniffing and limits, the ClamAV path with a fake scanner, the quota alert; frontend board and pull table tests; Playwright scenarios for the board and the pull flow. Verified on docker-1 in the CI stack: 3557 server tests, 106 frontend tests, 17 Playwright scenarios. Docs updated; D5 closed. Co-Authored-By: Claude Fable 5.1 <[email protected]>
1019 lines
40 KiB
Python
1019 lines
40 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",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------- 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_hold_why(open_cos: Iterable[Mapping[str, Any]], dept_names: Mapping[str, str]) -> str:
|
|
parts: list[str] = []
|
|
for co in open_cos:
|
|
if str(co.get("review")) == "pending":
|
|
parts.append(f"Change order {co['co_no']} is on hold.")
|
|
else:
|
|
names = ", ".join(dept_names.get(c, c) for c in co.get("pending_depts") or [])
|
|
parts.append(f"Change order {co['co_no']} is waiting for verification 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")}
|
|
|
|
|
|
# ---------------------------------------------------------------- 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)
|
|
|
|
@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)
|
|
ch.dept_names = dept_names(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 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_names),
|
|
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=len(ch.welds),
|
|
checklist_complete=stage is not None and checklists.checklist_complete(stage["checklist"], weld),
|
|
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 _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 _stage(conn: Any, s: Mapping[str, Any], people: People, cache: dict[Any, Any], *, closed: bool) -> 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": s["checklist"] or {},
|
|
"filler": s["filler"],
|
|
"filler_lot": s["filler_lot"],
|
|
"preheat": s["preheat"],
|
|
"notes": s["notes"],
|
|
"clock": {
|
|
"elapsed_ms": int(s["clock_elapsed_ms"] or 0),
|
|
"running_since": s["clock_running_since"],
|
|
"started_at": s["clock_started_at"],
|
|
},
|
|
"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]
|
|
ids += [s["welder_id"] for s in ch.stages]
|
|
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] = {}
|
|
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 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"] = [
|
|
{
|
|
"n": pg["n"],
|
|
"type": pg["type"],
|
|
"asset_id": uid_text(pg["asset_id"]),
|
|
"pn": pg["pn"],
|
|
"dwg": pg["dwg"],
|
|
"rev": pg["rev"],
|
|
"void": None
|
|
if pg["void_at"] is None
|
|
else {"co_id": pg["void_co_id"], "at": pg["void_at"], "new_n": pg["void_new_n"]},
|
|
"url": f"/_blob/{pg['asset_id']}" if pg["asset_id"] else None,
|
|
}
|
|
for pg in ch.pages
|
|
]
|
|
out["bom"] = [_bom(b, ch, people) for b in ch.bom]
|
|
out["welds"] = [_weld(w, people) for w in ch.welds]
|
|
out["heat_tags"] = [
|
|
{k: t[k] for k in ("tag_no", "page", "item", "fx", "fy", "row_version")} for t in ch.heat_tags
|
|
]
|
|
out["heat_table"] = [{k: h[k] for k in ("page", "x", "y", "s")} for h in ch.heat_table]
|
|
st = ch.open_stage
|
|
out["stage"] = _stage(conn, st, people, cache, closed=False) if st else None
|
|
out["stages"] = [_stage(conn, s, people, cache, closed=True) 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"])
|
|
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 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)
|
|
return out
|
|
|
|
|
|
def _bom(b: Mapping[str, Any], ch: Children, people: People) -> dict[str, Any]:
|
|
keys = (
|
|
"id",
|
|
"item",
|
|
"qty",
|
|
"part_id",
|
|
"description",
|
|
"dwg_ref",
|
|
"cl_item",
|
|
"length",
|
|
"material",
|
|
"heat",
|
|
"na",
|
|
"issued",
|
|
"loc",
|
|
"pulled",
|
|
"src",
|
|
"sub",
|
|
"row_version",
|
|
)
|
|
out = {k: b[k] for k in keys}
|
|
out["mtr_asset_id"] = uid_text(b["mtr_asset_id"])
|
|
s = ch.shorts.get(b["id"])
|
|
out["short"] = (
|
|
None
|
|
if s is None
|
|
else {
|
|
"id": s["id"],
|
|
"qty": s["qty"],
|
|
"qty_open": s["qty_open"],
|
|
"reason": s["reason"],
|
|
"ref": s["ref"],
|
|
"eta": s["eta"],
|
|
"note": s["note"],
|
|
"opened_at": s["opened_at"],
|
|
"receipts": [
|
|
{
|
|
"id": r["id"],
|
|
"qty": r["qty"],
|
|
"heat": r["heat"],
|
|
"note": r["note"],
|
|
"at": r["at"],
|
|
"by": people.ref(r["by"]),
|
|
}
|
|
for r in ch.receipts.get(s["id"], [])
|
|
],
|
|
"row_version": s["row_version"],
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def _weld(w: Mapping[str, Any], people: People) -> dict[str, Any]:
|
|
keys = ("weld_no", "page", "fx", "fy", "wo_base", "piece", "type", "size", "process", "stamp", "at", "row_version")
|
|
out = {k: w[k] for k in keys}
|
|
out["items"] = list(w["items"] or [])
|
|
out["welder"] = people.ref(w["welder_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"], [])
|
|
]
|
|
out["required"] = ch.co_required.get(c["id"], [])
|
|
out["verified"] = {
|
|
v["dept_code"]: {"by": people.ref(v["by"]), "at": v["at"], "note": v["note"]}
|
|
for v in ch.co_verified.get(c["id"], [])
|
|
}
|
|
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_names(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
|