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]>
112 lines
4.2 KiB
Python
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,))}
|