788 lines
31 KiB
Python
788 lines
31 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 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 = "Drawing needed - looking it up in the vault"
|
||
|
|
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",
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- 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,))
|
||
|
|
|
||
|
|
|
||
|
|
def flag_of(status: str, source_kind: str, flags: Iterable[str] | None, drawing_state: str | None = None) -> str | None:
|
||
|
|
"""The draft flag shown on cards and the packet page. The old packetFlag(), phase 2 part."""
|
||
|
|
if status != "draft":
|
||
|
|
return None
|
||
|
|
if source_kind == "hand":
|
||
|
|
return HAND_FLAG
|
||
|
|
if "drawing_needed" in (flags or ()):
|
||
|
|
return DRAWING_NEEDED_FLAG
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
# SQL for the same rule, for the list filter.
|
||
|
|
FLAG_SQL = "(p.status = 'draft' AND (p.source_kind = 'hand' OR 'drawing_needed' = ANY(p.flags)))"
|
||
|
|
|
||
|
|
|
||
|
|
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 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),
|
||
|
|
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 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": row["visual"] or {},
|
||
|
|
"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",
|
||
|
|
)
|
||
|
|
}
|
||
|
|
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"])
|
||
|
|
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)
|
||
|
|
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",
|
||
|
|
"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",
|
||
|
|
)
|
||
|
|
out = {k: c[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,
|
||
|
|
(SELECT w.stage::text FROM packet_wh w WHERE w.packet_id = p.id AND w.closed_at IS NULL) AS wh_stage,
|
||
|
|
(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
|
||
|
|
"""
|
||
|
|
|
||
|
|
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 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, limit, removed."""
|
||
|
|
where: list[str] = []
|
||
|
|
params: list[Any] = []
|
||
|
|
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":
|
||
|
|
where.append("(p.status = 'draft' AND p.source_kind = 'hand')")
|
||
|
|
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
|
||
|
|
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"]),
|
||
|
|
"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"],
|
||
|
|
"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
|