Files
PacketStation/server/packetstation/domain/children.py
T
spencermandClaude Fable 5.1 37d9a0cb50 Phase 2: core domain, policy engine, packet API, Clerical screen
Migration 0003: projects, aliases, renumbers, units, packets and every child table,
change orders, assets, documents, ITPs, station limits, department weld flag.
Policy package: Principal, PacketState, Action rules, custody, preconditions,
CO hold, order of checks, can/blocked/holder on reads. docs/POLICY.md with the
tightenings table for Seth. Write pipeline: FOR UPDATE, CAS on status and dept,
If-Match row versions, server-assigned numbers, field CAS, version bump, packet
log, audit, NOTIFY, signatures, IT reasons, filed lock and HMAC seal.
API: /api/packets (header, transitions, work orders, pages, docs, move, remove,
BOM, shorts, welds, heat tags, heat table, stage, checklist, clock, warehouse
stage, Q.C., change orders, log, seal), /api/projects, /api/units, /api/assets,
/_blob. Frontend: Clerical home, projects and units, packet page with pdf.js
and QR codes, hand draft, heat map, send to warehouse, department or Q.C.
Tests: policy matrix, API, concurrency, schema, projects; Playwright Clerical
draft scenario. Verified on docker-1 with the CI stack and the Samba DC.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-16 09:31:45 -06:00

112 lines
4.2 KiB
Python

"""Shared SQL for the packet child rows: guarded row updates, the warehouse stage history, and the departments a
packet touched (who verifies a change order).
Every function here runs inside `packet_write`, so the packet row is already locked FOR UPDATE and child writes on
the same packet serialise on it.
"""
from __future__ import annotations
from collections.abc import Iterable, Mapping
from typing import Any
from psycopg import sql
from psycopg.rows import dict_row
from . import packets
# Tables update_row may touch. The column names come from the handlers' fixed field lists, never from a request.
TABLES = frozenset(
{"bom_lines", "bom_shorts", "welds", "heat_tags", "packet_stages", "qc_inspections", "change_orders"}
)
# Statuses past the warehouse: the packet has been in its department.
IN_DEPT = frozenset({"ready", "assigned", "in_progress", "review", "qc", "complete", "filed"})
QC_STATUSES = frozenset({"qc", "complete", "filed"})
# Departments that never verify a change order: Clerical issues them, PM has no supervisor.
NEVER_VERIFY = frozenset({"CL", "PM"})
def update_row(
conn: Any, table: str, row_id: Any, values: Mapping[str, Any], *, bump: bool = True
) -> dict[str, Any] | None:
"""UPDATE one row by id with the given column values (and row_version + 1 when bump). Returns the new row."""
if table not in TABLES:
raise ValueError(f"update_row: table {table!r} is not allowed")
parts: list[sql.Composable] = [sql.SQL("{} = {}").format(sql.Identifier(k), sql.Placeholder()) for k in values]
if bump:
parts.append(sql.SQL("row_version = row_version + 1"))
if not parts:
raise ValueError("update_row: nothing to set")
query = sql.SQL("UPDATE {} SET {} WHERE id = {} RETURNING *").format(
sql.Identifier(table), sql.SQL(", ").join(parts), sql.Placeholder()
)
with conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(query, (*values.values(), row_id)).fetchone()
return dict(r) if r is not None else None
def current_wh(conn: Any, packet_id: int) -> dict[str, Any] | None:
return packets.one(
conn, "SELECT *, stage::text AS stage FROM packet_wh WHERE packet_id = %s AND closed_at IS NULL", (packet_id,)
)
def wh_move(
conn: Any,
packet_id: int,
stage: str,
by: Any,
*,
reason: str | None = None,
note: str | None = None,
auto: bool = False,
) -> None:
"""Close the current warehouse stage row and insert the new one."""
conn.execute("UPDATE packet_wh SET closed_at = now() WHERE packet_id = %s AND closed_at IS NULL", (packet_id,))
conn.execute(
"INSERT INTO packet_wh (packet_id, stage, by, reason, note, auto) VALUES (%s, %s::wh_stage, %s, %s, %s, %s)",
(packet_id, stage, by, reason, note, auto),
)
def co_touched(conn: Any, row: Mapping[str, Any]) -> list[str]:
"""The departments a packet has touched, in the old coTouched() order, without Clerical and PM.
WH once it left draft; its department once it reached the shop; every stage's department; QC once it reached
Q.C. (status qc, complete or filed, a Q.C. history row, or any inspection).
"""
pid = row["id"]
status = str(row["status"])
out: list[str] = []
def add(codes: Iterable[Any]) -> None:
for c in codes:
if c and str(c) not in out:
out.append(str(c))
if status != "draft":
add(["WH"])
if status in IN_DEPT:
add([row["dept_code"]])
add(r[0] for r in conn.execute("SELECT dept_code FROM packet_stages WHERE packet_id = %s ORDER BY id", (pid,)))
reached_qc = status in QC_STATUSES or bool(
conn.execute(
"SELECT EXISTS (SELECT 1 FROM qc_history WHERE packet_id = %s)"
" OR EXISTS (SELECT 1 FROM qc_inspections WHERE packet_id = %s)",
(pid, pid),
).fetchone()[0]
)
if reached_qc:
add(["QC"])
return [c for c in out if c not in NEVER_VERIFY]
def active_depts(conn: Any, codes: Iterable[str]) -> set[str]:
"""The given codes that are active departments."""
want = sorted({str(c) for c in codes})
if not want:
return set()
return {r[0] for r in conn.execute("SELECT code FROM departments WHERE code = ANY(%s) AND active", (want,))}