Clerical close and file: the VISUAL observed state beside the close confirm, a 409 with a second confirm when the feed still shows work orders open, filing with a signature and the HMAC seal, the data book built on the server and stored on the seal in the filing transaction, admin reopen keeping the broken seal with its data book. Server PDFs with reportlab, pypdf and pypdfium2: the manufacturing data book page for page after the old build (cover, index, drawings with VOID stamps, traceability and heat map rasters, weld maps and welding records, Q.C. inspections, Q.C. and test documents, MTRs, record history), the job traveler with work order QR codes, the ITP PDF filed automatically at sign-off, every print kind with the PRINTED COPY stamp, the controlled-copy stamp signed with the seal key, PDF metadata, the traceability CSV with the formula guard, and the append-only print_log with an audit row for every print and export (migration 0008). Frontend: the close and file panel, the print menu and its entry points, the printed copies list on the History tab, the ITP PDF buttons. Tests: server (close, file, reopen, seal, the builders, prints, CSV, stamps), frontend, and the Playwright close and print scenarios. Docs updated; SECURITY closes M2, S6, S7 and S8. Co-Authored-By: Claude Fable 5.1 <[email protected]>
996 lines
37 KiB
Python
996 lines
37 KiB
Python
"""Packets: the boards, the read model, the hand draft, header edits, transitions, pages, documents, move, remove,
|
|
the VISUAL close, the data book date, reopen, the seal and the log.
|
|
|
|
Every write goes through domain.pipeline.packet_write: one transaction, the packet locked first, the policy decides.
|
|
Every mutating answer is the full read model.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from decimal import Decimal
|
|
from typing import Any, Literal
|
|
|
|
from fastapi import APIRouter, Depends, Query, Request
|
|
from psycopg.types.json import Jsonb
|
|
from pydantic import BaseModel, Field
|
|
|
|
from .. import audit, events, policy
|
|
from ..domain import assets as asset_store
|
|
from ..domain import ncr as ncrs
|
|
from ..domain import packets, shop
|
|
from ..domain import qc as qc_dom
|
|
from ..domain.pipeline import (
|
|
Expect,
|
|
SignBody,
|
|
apply_field_cas,
|
|
apply_transition,
|
|
next_counter,
|
|
packet_write,
|
|
require_if_match,
|
|
verify_seal,
|
|
)
|
|
from ..text import LIMITS, MAX_BODY_BYTES, RX_STAMP, clean_opt, clean_text, reason_ok
|
|
from .deps import Db, Principal, current_principal, get_db, settings_of
|
|
from .errors import ApiError
|
|
|
|
router = APIRouter(prefix="/api/packets", tags=["packets"])
|
|
|
|
A = policy.Action
|
|
|
|
MSG_FORBIDDEN = "Your access doesn't allow this step."
|
|
WO_PART_MAX = 40
|
|
|
|
|
|
def body_limit(request: Request) -> None:
|
|
"""413 too_large for a JSON body over 200 000 bytes, by its Content-Length, before it is read."""
|
|
length = request.headers.get("content-length")
|
|
if length is not None and length.strip().isdigit() and int(length) > MAX_BODY_BYTES:
|
|
raise ApiError(413, "too_large", "The request is too large.")
|
|
|
|
|
|
WRITE = [Depends(body_limit)]
|
|
|
|
|
|
# ---------------------------------------------------------------- bodies
|
|
|
|
|
|
class WorkOrderIn(BaseModel):
|
|
wo_base: str = Field(min_length=1, max_length=WO_PART_MAX)
|
|
lot: str = Field(default="", max_length=WO_PART_MAX)
|
|
split: str = Field(default="", max_length=WO_PART_MAX)
|
|
sub: str = Field(default="", max_length=WO_PART_MAX)
|
|
seq: int | None = None
|
|
qty: Decimal | None = None
|
|
target_hrs: Decimal | None = None
|
|
op: str | None = Field(default=None, max_length=200)
|
|
description: str | None = Field(default=None, max_length=1000)
|
|
|
|
|
|
class BomIn(BaseModel):
|
|
item: int = Field(ge=1, le=100_000)
|
|
qty: Decimal | None = None
|
|
part_id: str | None = Field(default=None, max_length=200)
|
|
description: str | None = Field(default=None, max_length=1000)
|
|
material: str | None = Field(default=None, max_length=500)
|
|
length: str | None = Field(default=None, max_length=200)
|
|
dwg_ref: str | None = Field(default=None, max_length=200)
|
|
|
|
|
|
class CreateBody(BaseModel):
|
|
unit_id: int
|
|
dept_code: str = Field(max_length=10)
|
|
op: str | None = Field(default=None, max_length=200)
|
|
title: str | None = Field(default=None, max_length=1000)
|
|
customer: str | None = Field(default=None, max_length=500)
|
|
drawing: str | None = Field(default=None, max_length=200)
|
|
rev: str | None = Field(default=None, max_length=100)
|
|
wps: str | None = Field(default=None, max_length=200)
|
|
heat_map: bool | None = None
|
|
work_orders: list[WorkOrderIn] = Field(default_factory=list, max_length=300)
|
|
bom: list[BomIn] = Field(default_factory=list, max_length=1000)
|
|
it_reason: str | None = Field(default=None, max_length=1000)
|
|
|
|
|
|
class PatchBody(BaseModel):
|
|
set: dict[str, Any]
|
|
base: dict[str, Any] = Field(default_factory=dict)
|
|
it_reason: str | None = Field(default=None, max_length=1000)
|
|
|
|
|
|
class TransitionBody(BaseModel):
|
|
action: str = Field(max_length=40)
|
|
expect: Expect
|
|
reason: str | None = Field(default=None, max_length=2000)
|
|
note: str | None = Field(default=None, max_length=2000)
|
|
dept: str | None = Field(default=None, max_length=10)
|
|
station_id: int | None = None
|
|
welder_id: uuid.UUID | None = None
|
|
stamp: str | None = Field(default=None, max_length=40)
|
|
routed_to: Literal["databook", "shipping"] | None = None
|
|
book: bool | None = None
|
|
spool: bool | None = None
|
|
location: str | None = Field(default=None, max_length=500)
|
|
for_sup_id: uuid.UUID | None = None
|
|
ncr: str | None = Field(default=None, max_length=100)
|
|
confirm_open_wos: bool | None = None
|
|
sign: SignBody | None = None
|
|
it_reason: str | None = Field(default=None, max_length=1000)
|
|
|
|
|
|
class WorkOrdersBody(BaseModel):
|
|
work_orders: list[WorkOrderIn] = Field(max_length=300)
|
|
it_reason: str | None = Field(default=None, max_length=1000)
|
|
|
|
|
|
class PagesBody(BaseModel):
|
|
asset_id: uuid.UUID
|
|
type: Literal["drawing"] = "drawing"
|
|
dwg: str | None = Field(default=None, max_length=200)
|
|
rev: str | None = Field(default=None, max_length=100)
|
|
sign: SignBody | None = None
|
|
it_reason: str | None = Field(default=None, max_length=1000)
|
|
|
|
|
|
class ReasonBody(BaseModel):
|
|
reason: str | None = Field(default=None, max_length=2000)
|
|
it_reason: str | None = Field(default=None, max_length=1000)
|
|
|
|
|
|
class DocBody(BaseModel):
|
|
asset_id: uuid.UUID
|
|
kind: str = Field(min_length=1, max_length=100)
|
|
title: str | None = Field(default=None, max_length=500)
|
|
note: str | None = Field(default=None, max_length=2000)
|
|
it_reason: str | None = Field(default=None, max_length=1000)
|
|
|
|
|
|
class MoveBody(BaseModel):
|
|
unit_id: int
|
|
reason: str | None = Field(default=None, max_length=2000)
|
|
it_reason: str | None = Field(default=None, max_length=1000)
|
|
|
|
|
|
class VclosedBody(BaseModel):
|
|
closed: bool
|
|
it_reason: str | None = Field(default=None, max_length=1000)
|
|
|
|
|
|
class ItBody(BaseModel):
|
|
it_reason: str | None = Field(default=None, max_length=1000)
|
|
|
|
|
|
class ReopenBody(BaseModel):
|
|
reason: str | None = Field(default=None, max_length=2000)
|
|
sign: SignBody | None = None
|
|
it_reason: str | None = Field(default=None, max_length=1000)
|
|
|
|
|
|
# ---------------------------------------------------------------- validation helpers
|
|
|
|
|
|
def shop_dept(conn: Any, code: Any) -> str:
|
|
"""An active shop department code, or 422 invalid_dept."""
|
|
if isinstance(code, str):
|
|
r = conn.execute(
|
|
"SELECT code FROM departments WHERE code = %s AND kind = 'shop' AND active", (code.strip(),)
|
|
).fetchone()
|
|
if r is not None:
|
|
return str(r[0])
|
|
raise ApiError(422, "invalid_dept", "Choose an active shop department.")
|
|
|
|
|
|
def active_user(conn: Any, uid: Any, what: str) -> str:
|
|
if uid is not None:
|
|
try:
|
|
u = uuid.UUID(str(uid))
|
|
except ValueError:
|
|
u = None
|
|
if u is not None and conn.execute("SELECT 1 FROM users WHERE id = %s AND active", (u,)).fetchone():
|
|
return str(u)
|
|
raise ApiError(422, "invalid_user", f"Choose an active user for the {what}.")
|
|
|
|
|
|
def open_unit(conn: Any, unit_id: int) -> dict[str, Any]:
|
|
"""A live unit that is open in a project that is not closed. 404 unknown, 409 unit_closed."""
|
|
u = packets.one(
|
|
conn,
|
|
"SELECT u.id, u.status::text AS status, u.removed_at, pr.status::text AS project_status,"
|
|
" pr.removed_at AS project_removed, pr.number || u.lot AS number"
|
|
" FROM units u JOIN projects pr ON pr.id = u.project_id WHERE u.id = %s",
|
|
(unit_id,),
|
|
)
|
|
if u is None or u["removed_at"] is not None or u["project_removed"] is not None:
|
|
raise ApiError(404, "not_found", "No such unit.")
|
|
if u["status"] != "open" or u["project_status"] == "closed":
|
|
raise ApiError(409, "unit_closed", "That unit is not open. Open it or pick another unit.")
|
|
return u
|
|
|
|
|
|
def clean_work_orders(items: list[WorkOrderIn]) -> list[dict[str, Any]]:
|
|
"""Work order keys stay text (leading zeros kept). Duplicate keys are 422."""
|
|
out: list[dict[str, Any]] = []
|
|
seen: set[tuple[str, str, str, str]] = set()
|
|
for i, w in enumerate(items):
|
|
key = (
|
|
clean_text(w.wo_base, WO_PART_MAX).upper(),
|
|
clean_text(w.lot, WO_PART_MAX).upper(),
|
|
clean_text(w.split, WO_PART_MAX).upper(),
|
|
clean_text(w.sub, WO_PART_MAX).upper(),
|
|
)
|
|
if not key[0]:
|
|
raise ApiError(422, "invalid_request", f"Work order {i + 1} needs a work order number.")
|
|
if key in seen:
|
|
raise ApiError(422, "duplicate_work_order", f"Work order {'/'.join(key)} is listed twice.")
|
|
seen.add(key)
|
|
out.append(
|
|
{
|
|
"wo_base": key[0],
|
|
"lot": key[1],
|
|
"split": key[2],
|
|
"sub": key[3],
|
|
"seq": w.seq,
|
|
"qty": w.qty,
|
|
"target_hrs": w.target_hrs,
|
|
"op": clean_opt(w.op, LIMITS["op"]),
|
|
"description": clean_opt(w.description, LIMITS["description"]),
|
|
"sort": i,
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def insert_work_orders(conn: Any, packet_id: int, wos: list[dict[str, Any]]) -> None:
|
|
for w in wos:
|
|
conn.execute(
|
|
"INSERT INTO packet_work_orders (packet_id, wo_base, lot, split, sub, seq, qty, target_hrs, op,"
|
|
" description, sort) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)",
|
|
(
|
|
packet_id,
|
|
w["wo_base"],
|
|
w["lot"],
|
|
w["split"],
|
|
w["sub"],
|
|
w["seq"],
|
|
w["qty"],
|
|
w["target_hrs"],
|
|
w["op"],
|
|
w["description"],
|
|
w["sort"],
|
|
),
|
|
)
|
|
|
|
|
|
def clean_bom(items: list[BomIn]) -> list[dict[str, Any]]:
|
|
out: list[dict[str, Any]] = []
|
|
seen: set[int] = set()
|
|
for i, b in enumerate(items):
|
|
if b.item in seen:
|
|
raise ApiError(422, "duplicate_item", f"BOM item {b.item} is listed twice.")
|
|
seen.add(b.item)
|
|
out.append(
|
|
{
|
|
"item": b.item,
|
|
"qty": b.qty,
|
|
"part_id": clean_opt(b.part_id, LIMITS["part_id"]),
|
|
"description": clean_opt(b.description, LIMITS["description"]),
|
|
"material": clean_opt(b.material, LIMITS["material"]),
|
|
"length": clean_opt(b.length, LIMITS["length"]),
|
|
"dwg_ref": clean_opt(b.dwg_ref, LIMITS["drawing"]),
|
|
"sort": i,
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def _read(d: Db, pp: policy.Principal, packet_id: int) -> dict[str, Any]:
|
|
return packets.read_packet(d.conn, packet_id, pp)
|
|
|
|
|
|
def _pp(d: Db, p: Principal) -> policy.Principal:
|
|
return packets.policy_principal_for(d.conn, p)
|
|
|
|
|
|
# ---------------------------------------------------------------- list and read
|
|
|
|
|
|
@router.get("")
|
|
def list_packets(
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
status: str = Query("", max_length=200),
|
|
dept: str = Query("", max_length=10),
|
|
unit_id: int | None = Query(None),
|
|
project_id: int | None = Query(None),
|
|
q: str = Query("", max_length=200),
|
|
flag: str = Query("", max_length=20),
|
|
co: str = Query("", max_length=20),
|
|
limit: int = Query(500, ge=1, le=2000),
|
|
removed: int = Query(0, ge=0, le=1),
|
|
) -> dict[str, Any]:
|
|
filters = {
|
|
"status": status,
|
|
"dept": dept.strip() or None,
|
|
"unit_id": unit_id,
|
|
"project_id": project_id,
|
|
"q": clean_text(q, 200),
|
|
"flag": flag,
|
|
"co": co.strip().lower() or None,
|
|
"limit": limit,
|
|
"removed": bool(removed) and p.is_admin,
|
|
}
|
|
return {"packets": packets.list_cards(d.conn, _pp(d, p), filters)}
|
|
|
|
|
|
@router.post("", status_code=201, dependencies=WRITE)
|
|
def create(
|
|
body: CreateBody,
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
) -> dict[str, Any]:
|
|
"""A hand draft (Clerical; Admin with an IT reason)."""
|
|
conn = d.conn
|
|
pp = _pp(d, p)
|
|
it_reason: str | None = None
|
|
if "CL" not in pp.support:
|
|
if not pp.admin:
|
|
raise ApiError(403, "forbidden", MSG_FORBIDDEN)
|
|
it_reason = reason_ok(body.it_reason)
|
|
if it_reason is None:
|
|
raise ApiError(400, "it_reason_required", "Give an IT reason of at least 8 characters.")
|
|
dept = shop_dept(conn, body.dept_code)
|
|
wos = clean_work_orders(body.work_orders)
|
|
bom = clean_bom(body.bom)
|
|
with conn.transaction():
|
|
open_unit(conn, body.unit_id)
|
|
pid = int(
|
|
conn.execute(
|
|
"INSERT INTO packets (unit_id, dept_code, op, title, customer, drawing, rev, wps, heat_map,"
|
|
" source_kind, source, drawing_state, flags, created_by)"
|
|
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'hand', %s, 'none', %s, %s) RETURNING id",
|
|
(
|
|
body.unit_id,
|
|
dept,
|
|
clean_opt(body.op, LIMITS["op"]),
|
|
clean_opt(body.title, LIMITS["title"]),
|
|
clean_opt(body.customer, LIMITS["customer"]),
|
|
clean_opt(body.drawing, LIMITS["drawing"]),
|
|
clean_opt(body.rev, LIMITS["rev"]),
|
|
clean_opt(body.wps, LIMITS["wps"]),
|
|
body.heat_map,
|
|
Jsonb({"kind": "hand"}),
|
|
["hand"],
|
|
p.user_id,
|
|
),
|
|
).fetchone()[0]
|
|
)
|
|
insert_work_orders(conn, pid, wos)
|
|
for b in bom:
|
|
conn.execute(
|
|
"INSERT INTO bom_lines (packet_id, item, qty, part_id, description, material, length, dwg_ref, src,"
|
|
" sort) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'hand', %s)",
|
|
(
|
|
pid,
|
|
b["item"],
|
|
b["qty"],
|
|
b["part_id"],
|
|
b["description"],
|
|
b["material"],
|
|
b["length"],
|
|
b["dwg_ref"],
|
|
b["sort"],
|
|
),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO packet_log (packet_id, by, by_name, role, action, it_reason) VALUES (%s, %s, %s, %s, %s, %s)",
|
|
(pid, p.user_id, p.user["display_name"], p.role_snapshot, "Packet created by hand", it_reason),
|
|
)
|
|
audit.append(
|
|
conn,
|
|
action="packet.created",
|
|
user_id=p.user_id,
|
|
session_id=p.session_id,
|
|
role_snapshot=p.role_snapshot,
|
|
subject_type="packet",
|
|
subject_id=pid,
|
|
packet_id=pid,
|
|
detail={"source": "hand", "unit_id": body.unit_id, "dept": dept, "work_orders": len(wos), "bom": len(bom)},
|
|
it_reason=it_reason,
|
|
ip=p.ip,
|
|
)
|
|
events.publish(conn, {"event": "changed", "packet_id": pid, "version": 1, "status": "draft", "dept": dept})
|
|
return _read(d, pp, pid)
|
|
|
|
|
|
@router.get("/{packet_id}")
|
|
def read(
|
|
packet_id: int,
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
) -> dict[str, Any]:
|
|
return _read(d, _pp(d, p), packet_id)
|
|
|
|
|
|
# ---------------------------------------------------------------- header edits
|
|
|
|
|
|
HEADER_FIELDS: dict[str, tuple[policy.Action, str]] = {
|
|
"title": (A.EDIT_HEADER, "title"),
|
|
"customer": (A.EDIT_HEADER, "customer"),
|
|
"wps": (A.EDIT_HEADER, "wps"),
|
|
"op": (A.EDIT_HEADER, "op"),
|
|
"heat_map": (A.EDIT_HEADER, ""),
|
|
"for_sup_id": (A.EDIT_HEADER, ""),
|
|
"drawing": (A.EDIT_DRAWING_REF, "drawing"),
|
|
"rev": (A.EDIT_DRAWING_REF, "rev"),
|
|
"dept_code": (A.EDIT_DEPT, ""),
|
|
}
|
|
ACTION_ORDER = (A.EDIT_HEADER, A.EDIT_DEPT, A.EDIT_DRAWING_REF)
|
|
|
|
|
|
def _clean_header(conn: Any, key: str, value: Any) -> Any:
|
|
if key == "heat_map":
|
|
if value is None or isinstance(value, bool):
|
|
return value
|
|
raise ApiError(422, "invalid_request", "heat_map must be true, false or null.")
|
|
if key == "for_sup_id":
|
|
return None if value in (None, "") else active_user(conn, value, "supervisor")
|
|
if key == "dept_code":
|
|
return shop_dept(conn, value)
|
|
if value is not None and not isinstance(value, str):
|
|
raise ApiError(422, "invalid_request", f"{key} must be text.")
|
|
return clean_opt(value, LIMITS[HEADER_FIELDS[key][1]])
|
|
|
|
|
|
@router.patch("/{packet_id}", dependencies=WRITE)
|
|
def patch_header(
|
|
packet_id: int,
|
|
body: PatchBody,
|
|
request: Request,
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
) -> dict[str, Any]:
|
|
if not body.set:
|
|
raise ApiError(422, "invalid_request", "Nothing to change.")
|
|
unknown = sorted(k for k in body.set if k not in HEADER_FIELDS)
|
|
if unknown:
|
|
raise ApiError(422, "invalid_request", f"These fields cannot be changed here: {', '.join(unknown)}.")
|
|
conn = d.conn
|
|
pp = _pp(d, p)
|
|
wanted = {HEADER_FIELDS[k][0] for k in body.set}
|
|
actions = [a for a in ACTION_ORDER if a in wanted]
|
|
with packet_write(
|
|
d, p, packet_id, actions[0], request=request, it_reason=body.it_reason, also=actions[1:], policy_principal=pp
|
|
) as w:
|
|
cleaned = {k: _clean_header(conn, k, v) for k, v in body.set.items()}
|
|
row = w.packet
|
|
current = {k: (packets.uid_text(row[k]) if k == "for_sup_id" else row[k]) for k in HEADER_FIELDS}
|
|
changes = apply_field_cas(current, cleaned, body.base)
|
|
if not changes:
|
|
w.skip()
|
|
else:
|
|
cols = ", ".join(f"{k} = %s" for k in changes)
|
|
conn.execute(f"UPDATE packets SET {cols} WHERE id = %s", (*changes.values(), packet_id)) # noqa: S608
|
|
w.changed(*changes)
|
|
if set(changes) == {"heat_map"}:
|
|
w.log("Heat map required" if changes["heat_map"] else "Heat map not required")
|
|
else:
|
|
w.note = ", ".join(sorted(changes))
|
|
return _read(d, pp, packet_id)
|
|
|
|
|
|
# ---------------------------------------------------------------- transitions
|
|
|
|
|
|
# The request fields each transition carries. Anything else in the body is ignored.
|
|
TRANSITION_FIELDS: dict[policy.Action, tuple[str, ...]] = {
|
|
A.SEND_TO_WAREHOUSE: ("note",),
|
|
A.RELEASE_TO_SHOP: ("dept", "for_sup_id", "note"),
|
|
A.SEND_DRAFT_TO_QC: ("note",),
|
|
A.WH_SEND_BACK: ("reason",),
|
|
A.RETURN_TO_CLERICAL: ("reason",),
|
|
A.WH_RELEASE: ("note",),
|
|
A.SEND_BACK_TO_WAREHOUSE: ("reason",),
|
|
A.ASSIGN: ("station_id", "welder_id", "stamp"),
|
|
A.UNASSIGN: ("note",),
|
|
A.PULL_BACK: ("note",),
|
|
A.START: ("note",),
|
|
A.SUBMIT: ("note",),
|
|
A.RETURN_TO_STATION: ("reason",),
|
|
A.SEND_TO_QC: ("book", "spool", "location", "note"),
|
|
A.ROUTE_FROM_REVIEW: ("dept", "note"),
|
|
A.QC_REJECT: ("reason", "ncr"),
|
|
A.QC_ACCEPT: ("routed_to", "note"),
|
|
A.QC_ACCEPT_AND_ROUTE: ("dept", "note"),
|
|
A.FILE: ("note", "confirm_open_wos"),
|
|
A.ADMIN_REOPEN: ("reason",),
|
|
}
|
|
DEPT_ACTIONS = (A.RELEASE_TO_SHOP, A.ROUTE_FROM_REVIEW, A.QC_ACCEPT_AND_ROUTE)
|
|
|
|
|
|
def _transition_extra(action: policy.Action, body: TransitionBody) -> dict[str, Any]:
|
|
extra: dict[str, Any] = {}
|
|
for name in TRANSITION_FIELDS[action]:
|
|
value = getattr(body, name)
|
|
if value is None:
|
|
continue
|
|
extra[name] = str(value) if isinstance(value, uuid.UUID) else value
|
|
return extra
|
|
|
|
|
|
@router.post("/{packet_id}/transitions", dependencies=WRITE)
|
|
def transition(
|
|
packet_id: int,
|
|
body: TransitionBody,
|
|
request: Request,
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
) -> dict[str, Any]:
|
|
try:
|
|
action = A(body.action)
|
|
except ValueError:
|
|
action = None
|
|
if action is None or action not in TRANSITION_FIELDS:
|
|
raise ApiError(422, "invalid_action", "That is not a packet step.")
|
|
conn = d.conn
|
|
pp = _pp(d, p)
|
|
extra = _transition_extra(action, body)
|
|
if action in DEPT_ACTIONS and "dept" in extra:
|
|
extra["dept"] = shop_dept(conn, extra["dept"])
|
|
if "for_sup_id" in extra:
|
|
extra["for_sup_id"] = active_user(conn, extra["for_sup_id"], "supervisor")
|
|
if action is A.QC_REJECT:
|
|
_reject_reason(extra)
|
|
if "stamp" in extra:
|
|
# The assign sheet's stamp overrides the welder's own for this assignment; empty means use theirs.
|
|
stamp = clean_text(extra["stamp"], 40)
|
|
if stamp and not RX_STAMP.match(stamp):
|
|
raise ApiError(422, "invalid_stamp", "A stamp is up to 10 letters, digits or dashes.")
|
|
if stamp:
|
|
extra["stamp"] = stamp
|
|
else:
|
|
del extra["stamp"]
|
|
content = {"action": str(action), "packet_id": packet_id, "expect": body.expect.model_dump(), **extra}
|
|
with packet_write(
|
|
d,
|
|
p,
|
|
packet_id,
|
|
action,
|
|
request=request,
|
|
extra=extra,
|
|
it_reason=body.it_reason,
|
|
sign=body.sign,
|
|
expect=body.expect,
|
|
sign_content=content,
|
|
policy_principal=pp,
|
|
) as w:
|
|
if action is A.ASSIGN:
|
|
ok = conn.execute(
|
|
"SELECT 1 FROM stations WHERE id = %s AND dept_code = %s AND active",
|
|
(extra["station_id"], w.state.dept),
|
|
).fetchone()
|
|
if ok is None:
|
|
raise ApiError(422, "invalid_station", "Choose a station in this department.")
|
|
active_user(conn, extra["welder_id"], "welder")
|
|
shop.welder_check(conn, w.state.dept, str(extra["welder_id"]), int(extra["station_id"]))
|
|
apply_transition(w)
|
|
return _read(d, pp, packet_id)
|
|
|
|
|
|
def _reject_reason(extra: dict[str, Any]) -> None:
|
|
"""A Q.C. reject takes a reason, an NCR number, or both. The NCR stands in for the reason text: the reason the
|
|
policy checks is "NCR-2026-0021" alone, or "NCR-2026-0021 · <reason>". Without an NCR the reason needs its usual
|
|
8 characters."""
|
|
text = clean_text(extra.pop("reason", None), LIMITS["reason"])
|
|
raw = clean_text(extra.pop("ncr", None), 100)
|
|
ncr = ncrs.validate_ncr(raw) if raw else None
|
|
if ncr:
|
|
extra["ncr"] = ncr
|
|
extra["reason"] = f"{ncr} · {text}" if text else ncr
|
|
elif text:
|
|
extra["reason"] = text
|
|
extra["reason_text"] = text
|
|
|
|
|
|
@router.post("/{packet_id}/reopen", dependencies=WRITE)
|
|
def reopen(
|
|
packet_id: int,
|
|
body: ReopenBody,
|
|
request: Request,
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
) -> dict[str, Any]:
|
|
pp = _pp(d, p)
|
|
extra = {} if body.reason is None else {"reason": body.reason}
|
|
content = {"action": str(A.ADMIN_REOPEN), "packet_id": packet_id, "reason": reason_ok(body.reason)}
|
|
with packet_write(
|
|
d,
|
|
p,
|
|
packet_id,
|
|
A.ADMIN_REOPEN,
|
|
request=request,
|
|
extra=extra,
|
|
it_reason=body.it_reason,
|
|
sign=body.sign,
|
|
sign_content=content,
|
|
policy_principal=pp,
|
|
) as w:
|
|
apply_transition(w)
|
|
return _read(d, pp, packet_id)
|
|
|
|
|
|
# ---------------------------------------------------------------- work orders
|
|
|
|
|
|
@router.put("/{packet_id}/work-orders", dependencies=WRITE)
|
|
def put_work_orders(
|
|
packet_id: int,
|
|
body: WorkOrdersBody,
|
|
request: Request,
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
) -> dict[str, Any]:
|
|
if_match = require_if_match(request)
|
|
wos = clean_work_orders(body.work_orders)
|
|
pp = _pp(d, p)
|
|
with packet_write(
|
|
d, p, packet_id, A.EDIT_WOS, request=request, it_reason=body.it_reason, if_match=if_match, policy_principal=pp
|
|
) as w:
|
|
d.conn.execute("DELETE FROM packet_work_orders WHERE packet_id = %s", (packet_id,))
|
|
insert_work_orders(d.conn, packet_id, wos)
|
|
w.changed("work_orders")
|
|
w.note = f"{len(wos)} work order{'s' if len(wos) != 1 else ''}"
|
|
return _read(d, pp, packet_id)
|
|
|
|
|
|
# ---------------------------------------------------------------- drawing pages
|
|
|
|
|
|
@router.post("/{packet_id}/pages", dependencies=WRITE)
|
|
def attach_pages(
|
|
packet_id: int,
|
|
body: PagesBody,
|
|
request: Request,
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
) -> dict[str, Any]:
|
|
conn = d.conn
|
|
asset = asset_store.get(conn, body.asset_id)
|
|
if asset is None:
|
|
raise ApiError(404, "not_found", "No such file.")
|
|
if asset["mime"] != asset_store.PDF or not asset["pages"]:
|
|
raise ApiError(422, "not_pdf", "Attach a PDF drawing.")
|
|
pp = _pp(d, p)
|
|
dwg = clean_opt(body.dwg, LIMITS["drawing"])
|
|
rev = clean_opt(body.rev, LIMITS["rev"])
|
|
content = {
|
|
"action": str(A.EDIT_DOCUMENT_SET),
|
|
"packet_id": packet_id,
|
|
"asset_id": str(asset["id"]),
|
|
"sha256": bytes(asset["sha256"]).hex(),
|
|
"pages": asset["pages"],
|
|
"dwg": dwg,
|
|
"rev": rev,
|
|
}
|
|
with packet_write(
|
|
d,
|
|
p,
|
|
packet_id,
|
|
A.EDIT_DOCUMENT_SET,
|
|
request=request,
|
|
it_reason=body.it_reason,
|
|
sign=body.sign,
|
|
sign_content=content,
|
|
policy_principal=pp,
|
|
) as w:
|
|
dup = conn.execute(
|
|
"SELECT 1 FROM packet_pages WHERE packet_id = %s AND asset_id = %s AND void_at IS NULL",
|
|
(packet_id, asset["id"]),
|
|
).fetchone()
|
|
if dup is not None:
|
|
raise ApiError(409, "already_attached", "That drawing is already attached.")
|
|
count = int(asset["pages"])
|
|
first = next_counter(conn, packet_id, "page", count)
|
|
for i in range(count):
|
|
conn.execute(
|
|
"INSERT INTO packet_pages (packet_id, n, type, asset_id, pn, dwg, rev)"
|
|
" VALUES (%s, %s, 'drawing', %s, %s, %s, %s)",
|
|
(packet_id, first + i, asset["id"], i + 1, dwg or w.packet["drawing"], rev or w.packet["rev"]),
|
|
)
|
|
state = "hand" if w.packet["source_kind"] == "hand" else "attached"
|
|
conn.execute(
|
|
"UPDATE packets SET drawing_state = %s::drawing_state, flags = array_remove(flags, 'drawing_needed')"
|
|
" WHERE id = %s",
|
|
(state, packet_id),
|
|
)
|
|
w.changed("pages", "drawing_state")
|
|
name = asset["original_name"] or "drawing"
|
|
w.note = f"{name}, {count} page{'s' if count != 1 else ''}"
|
|
w.detail["asset_id"] = str(asset["id"])
|
|
return _read(d, pp, packet_id)
|
|
|
|
|
|
@router.delete("/{packet_id}/pages/{n}", dependencies=WRITE)
|
|
def remove_pages(
|
|
packet_id: int,
|
|
n: int,
|
|
request: Request,
|
|
body: ReasonBody | None = None,
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
) -> dict[str, Any]:
|
|
body = body or ReasonBody()
|
|
conn = d.conn
|
|
pp = _pp(d, p)
|
|
with packet_write(
|
|
d,
|
|
p,
|
|
packet_id,
|
|
A.EDIT_DOCUMENT_SET,
|
|
request=request,
|
|
it_reason=body.it_reason,
|
|
require_sign=False,
|
|
policy_principal=pp,
|
|
) as w:
|
|
if w.packet["status"] != "draft":
|
|
raise ApiError(403, "wrong_status", "Drawing pages can be removed only while the packet is a draft.")
|
|
page = packets.one(
|
|
conn,
|
|
"SELECT * FROM packet_pages WHERE packet_id = %s AND n = %s AND void_at IS NULL AND co_id IS NULL",
|
|
(packet_id, n),
|
|
)
|
|
if page is None or page["asset_id"] is None:
|
|
raise ApiError(404, "not_found", "No such page.")
|
|
gone = conn.execute(
|
|
"DELETE FROM packet_pages WHERE packet_id = %s AND asset_id = %s AND void_at IS NULL AND co_id IS NULL",
|
|
(packet_id, page["asset_id"]),
|
|
).rowcount
|
|
left = conn.execute(
|
|
"SELECT 1 FROM packet_pages WHERE packet_id = %s AND type = 'drawing' AND void_at IS NULL", (packet_id,)
|
|
).fetchone()
|
|
if left is None:
|
|
conn.execute("UPDATE packets SET drawing_state = 'none' WHERE id = %s", (packet_id,))
|
|
w.changed("pages")
|
|
w.log(
|
|
"Drawing removed",
|
|
", ".join(
|
|
x for x in (f"{gone} page{'s' if gone != 1 else ''}", clean_opt(body.reason, LIMITS["reason"])) if x
|
|
),
|
|
)
|
|
w.detail["asset_id"] = str(page["asset_id"])
|
|
return _read(d, pp, packet_id)
|
|
|
|
|
|
# ---------------------------------------------------------------- documents
|
|
|
|
|
|
@router.post("/{packet_id}/docs", dependencies=WRITE)
|
|
def add_doc(
|
|
packet_id: int,
|
|
body: DocBody,
|
|
request: Request,
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
) -> dict[str, Any]:
|
|
"""A report for the data book (NDE, hydrotest, certificates, NCRs). Never a drawing: drawings come from M-Files."""
|
|
conn = d.conn
|
|
kind = clean_text(body.kind, 40)
|
|
if kind not in qc_dom.DOC_KINDS:
|
|
raise ApiError(422, "invalid_kind", "Pick a document type from the list.")
|
|
asset = asset_store.get(conn, body.asset_id)
|
|
if asset is None:
|
|
raise ApiError(404, "not_found", "No such file.")
|
|
if asset["mime"] not in qc_dom.DOC_MIMES:
|
|
raise ApiError(422, "invalid_asset", "The file must be a PDF, PNG or JPEG.")
|
|
label = qc_dom.DOC_KINDS[kind]
|
|
title = clean_opt(body.title, LIMITS["doc_title"]) or label
|
|
note = clean_opt(body.note, qc_dom.DOC_NOTE_MAX)
|
|
pp = _pp(d, p)
|
|
with packet_write(d, p, packet_id, A.DOC_ADD, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
|
|
doc_id = conn.execute(
|
|
"INSERT INTO packet_docs (packet_id, asset_id, kind, title, note, added_by)"
|
|
" VALUES (%s, %s, %s, %s, %s, %s) RETURNING id",
|
|
(packet_id, asset["id"], kind, title, note, p.user_id),
|
|
).fetchone()[0]
|
|
w.changed("docs")
|
|
w.log("Document added", " · ".join(x for x in (label, title, asset.get("original_name")) if x))
|
|
w.detail.update({"doc_id": int(doc_id), "asset_id": str(asset["id"]), "kind": kind})
|
|
return _read(d, pp, packet_id)
|
|
|
|
|
|
DOC_REASON_MIN = 3
|
|
|
|
|
|
@router.delete("/{packet_id}/docs/{doc_id}", dependencies=WRITE)
|
|
def remove_doc(
|
|
packet_id: int,
|
|
doc_id: int,
|
|
request: Request,
|
|
body: ReasonBody | None = None,
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
) -> dict[str, Any]:
|
|
"""Take a document out of the data book. The row and the file stay, with who, when and why."""
|
|
body = body or ReasonBody()
|
|
conn = d.conn
|
|
pp = _pp(d, p)
|
|
with packet_write(
|
|
d, p, packet_id, A.DOC_REMOVE, request=request, it_reason=body.it_reason, policy_principal=pp
|
|
) as w:
|
|
reason = clean_text(body.reason, LIMITS["reason"])
|
|
doc = packets.one(
|
|
conn, "SELECT * FROM packet_docs WHERE id = %s AND packet_id = %s FOR UPDATE", (doc_id, packet_id)
|
|
)
|
|
if doc is None:
|
|
raise ApiError(404, "not_found", "No such document.")
|
|
if doc["removed_at"] is not None:
|
|
raise ApiError(409, "already_removed", "This document is already out of the data book.")
|
|
if len(reason) < DOC_REASON_MIN:
|
|
raise ApiError(400, "reason_required", "Say why it comes out of the data book.")
|
|
conn.execute(
|
|
"UPDATE packet_docs SET removed_at = now(), removed_by = %s, removed_reason = %s WHERE id = %s",
|
|
(p.user_id, reason, doc_id),
|
|
)
|
|
w.changed("docs")
|
|
w.log("Document taken out of the data book", " · ".join(x for x in (doc["title"] or doc["kind"], reason) if x))
|
|
w.detail["doc_id"] = doc_id
|
|
return _read(d, pp, packet_id)
|
|
|
|
|
|
# ---------------------------------------------------------------- move, remove, close, data book
|
|
|
|
|
|
@router.post("/{packet_id}/move", dependencies=WRITE)
|
|
def move(
|
|
packet_id: int,
|
|
body: MoveBody,
|
|
request: Request,
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
) -> dict[str, Any]:
|
|
conn = d.conn
|
|
pp = _pp(d, p)
|
|
extra = {} if body.reason is None else {"reason": body.reason}
|
|
with packet_write(
|
|
d, p, packet_id, A.MOVE_UNIT, request=request, extra=extra, it_reason=body.it_reason, policy_principal=pp
|
|
) as w:
|
|
target = open_unit(conn, body.unit_id)
|
|
old = w.packet["unit_id"]
|
|
if int(old) == int(body.unit_id):
|
|
raise ApiError(422, "same_unit", "The packet is already in that unit.")
|
|
old_number = conn.execute(
|
|
"SELECT pr.number || u.lot FROM units u JOIN projects pr ON pr.id = u.project_id WHERE u.id = %s", (old,)
|
|
).fetchone()[0]
|
|
conn.execute("UPDATE packets SET unit_id = %s WHERE id = %s", (body.unit_id, packet_id))
|
|
w.changed("unit_id")
|
|
reason = clean_opt(body.reason, LIMITS["reason"])
|
|
w.note = f"From {old_number} to {target['number']}" + (f". {reason}" if reason else "")
|
|
w.detail.update({"from_unit": int(old), "to_unit": int(body.unit_id)})
|
|
return _read(d, pp, packet_id)
|
|
|
|
|
|
@router.delete("/{packet_id}", dependencies=WRITE)
|
|
def remove(
|
|
packet_id: int,
|
|
request: Request,
|
|
body: ReasonBody | None = None,
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
) -> dict[str, Any]:
|
|
body = body or ReasonBody()
|
|
pp = _pp(d, p)
|
|
extra = {} if body.reason is None else {"reason": body.reason}
|
|
with packet_write(
|
|
d, p, packet_id, A.REMOVE, request=request, extra=extra, it_reason=body.it_reason, policy_principal=pp
|
|
) as w:
|
|
reason = reason_ok(body.reason)
|
|
d.conn.execute(
|
|
"UPDATE packets SET removed_at = now(), removed_by = %s, removed_reason = %s WHERE id = %s",
|
|
(p.user_id, reason, packet_id),
|
|
)
|
|
w.changed("removed")
|
|
w.note = reason
|
|
return _read(d, pp, packet_id)
|
|
|
|
|
|
@router.post("/{packet_id}/vclosed", dependencies=WRITE)
|
|
def set_vclosed(
|
|
packet_id: int,
|
|
body: VclosedBody,
|
|
request: Request,
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
) -> dict[str, Any]:
|
|
pp = _pp(d, p)
|
|
with packet_write(
|
|
d, p, packet_id, A.SET_VCLOSED, request=request, it_reason=body.it_reason, policy_principal=pp
|
|
) as w:
|
|
is_closed = w.packet["vclosed_at"] is not None
|
|
if body.closed == is_closed:
|
|
w.skip()
|
|
elif body.closed:
|
|
d.conn.execute(
|
|
"UPDATE packets SET vclosed_at = now(), vclosed_by = %s WHERE id = %s", (p.user_id, packet_id)
|
|
)
|
|
w.changed("vclosed")
|
|
wos = [
|
|
r[0]
|
|
for r in d.conn.execute(
|
|
"SELECT DISTINCT wo_base FROM packet_work_orders WHERE packet_id = %s ORDER BY 1", (packet_id,)
|
|
)
|
|
]
|
|
still_open = packets.packet_open_wos(d.conn, w.packet)
|
|
suffix = "VISUAL still shows open: " + ", ".join(still_open) if still_open else None
|
|
w.note = " · ".join(x for x in (", ".join(wos), suffix) if x) or None
|
|
else:
|
|
d.conn.execute("UPDATE packets SET vclosed_at = NULL, vclosed_by = NULL WHERE id = %s", (packet_id,))
|
|
w.changed("vclosed")
|
|
w.log("Moved back to ready to close")
|
|
return _read(d, pp, packet_id)
|
|
|
|
|
|
@router.post("/{packet_id}/databook", dependencies=WRITE)
|
|
def set_databook(
|
|
packet_id: int,
|
|
request: Request,
|
|
body: ItBody | None = None,
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
) -> dict[str, Any]:
|
|
body = body or ItBody()
|
|
pp = _pp(d, p)
|
|
with packet_write(
|
|
d, p, packet_id, A.SET_DATABOOK_AT, request=request, it_reason=body.it_reason, policy_principal=pp
|
|
) as w:
|
|
d.conn.execute("UPDATE packets SET databook_at = now() WHERE id = %s", (packet_id,))
|
|
w.changed("databook_at")
|
|
return _read(d, pp, packet_id)
|
|
|
|
|
|
# ---------------------------------------------------------------- seal and log
|
|
|
|
|
|
@router.get("/{packet_id}/seal")
|
|
def seal(
|
|
packet_id: int,
|
|
request: Request,
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
) -> dict[str, Any]:
|
|
if packets.load_row(d.conn, packet_id) is None:
|
|
raise ApiError(404, "not_found", "No such packet.")
|
|
return verify_seal(d.conn, settings_of(request), packet_id)
|
|
|
|
|
|
@router.get("/{packet_id}/log")
|
|
def packet_log(
|
|
packet_id: int,
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
limit: int = Query(300, ge=1, le=1000),
|
|
before: int | None = Query(None, ge=1),
|
|
) -> list[dict[str, Any]]:
|
|
conn = d.conn
|
|
if conn.execute("SELECT 1 FROM packets WHERE id = %s", (packet_id,)).fetchone() is None:
|
|
raise ApiError(404, "not_found", "No such packet.")
|
|
sql = "SELECT id, at, by, by_name, role, action, note, it_reason, signature_id FROM packet_log WHERE packet_id = %s"
|
|
params: list[Any] = [packet_id]
|
|
if before is not None:
|
|
sql += " AND id < %s"
|
|
params.append(before)
|
|
sql += " ORDER BY id DESC LIMIT %s"
|
|
params.append(limit)
|
|
return [
|
|
{
|
|
"id": r["id"],
|
|
"at": r["at"],
|
|
"by": None if r["by"] is None else {"id": str(r["by"]), "display_name": r["by_name"]},
|
|
"role": r["role"],
|
|
"action": r["action"],
|
|
"note": r["note"],
|
|
"it_reason": r["it_reason"],
|
|
"signed": r["signature_id"] is not None,
|
|
}
|
|
for r in packets.rows(conn, sql, params)
|
|
]
|