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]>
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
"""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,))}
|
||||
Reference in New Issue
Block a user