FILE checks the archive folder and copies the packet inside the filing transaction. A failed check or copy rolls the filing back, so the packet stays in its previous stage; the person filing gets 503 archive_failed, and in its own transaction the server writes an archive.filing_blocked audit row and raises the critical archive_failed condition to Admin for that packet (who tried, folder, error). The next successful filing clears it. The archive-copy job now only catches up filings made while the archive was off. PLAN question 53, CHANGELOG, FEED and SECURITY updated. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1049 lines
39 KiB
Python
1049 lines
39 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 datetime import UTC, date, datetime
|
|
from decimal import Decimal
|
|
from typing import Any, Literal
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
|
|
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 archive, packets, search, shop
|
|
from ..domain import assets as asset_store
|
|
from ..domain import ncr as ncrs
|
|
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)
|
|
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
|
|
|
|
|
|
def site_today(request: Request) -> date:
|
|
"""Today in the site time zone (settings.site_tz), once per request."""
|
|
try:
|
|
tz: Any = ZoneInfo(settings_of(request).site_tz)
|
|
except (ZoneInfoNotFoundError, ValueError):
|
|
tz = UTC
|
|
return datetime.now(tz).date()
|
|
|
|
|
|
def find_query(
|
|
q: str = Query("", max_length=200),
|
|
status: str = Query("", max_length=200),
|
|
dept: str = Query("", max_length=10),
|
|
station_id: int | None = Query(None, ge=1, le=2**62),
|
|
welder_id: str = Query("", max_length=40),
|
|
flags: str = Query("", max_length=100),
|
|
flag: str = Query("", max_length=20),
|
|
sort: str = Query("", max_length=20),
|
|
mine_screen: str = Query("", max_length=20),
|
|
mine_station_id: int | None = Query(None, ge=1, le=2**62),
|
|
) -> dict[str, Any]:
|
|
"""The find bar's query values, shared by GET /api/packets, the boards and the PM reports."""
|
|
return {
|
|
"q": q,
|
|
"status": status,
|
|
"dept": dept,
|
|
"station_id": station_id,
|
|
"welder_id": welder_id,
|
|
"flags": flags,
|
|
"flag": flag,
|
|
"sort": sort,
|
|
"mine_screen": mine_screen,
|
|
"mine_station_id": mine_station_id,
|
|
}
|
|
|
|
|
|
FIND = Depends(find_query)
|
|
|
|
|
|
@router.get("")
|
|
def list_packets(
|
|
request: Request,
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
find: dict[str, Any] = FIND,
|
|
unit_id: int | None = Query(None),
|
|
project_id: int | None = Query(None),
|
|
co: str = Query("", max_length=20),
|
|
facets: int = Query(0, ge=0, le=1),
|
|
limit: int = Query(500, ge=1, le=2000),
|
|
removed: int = Query(0, ge=0, le=1),
|
|
) -> dict[str, Any]:
|
|
"""The cards for a list. The find bar's filters are applied here; `facets=1` adds the stations and workers
|
|
among the packets in the project and unit scope."""
|
|
pp = _pp(d, p)
|
|
filters = search.parse_filters(
|
|
{**find, "unit_id": unit_id, "project_id": project_id}, pp, screen=None, today=site_today(request)
|
|
)
|
|
filters.update({"co": co.strip().lower() or None, "limit": limit, "removed": bool(removed) and p.is_admin})
|
|
out: dict[str, Any] = {"packets": packets.list_cards(d.conn, pp, filters)}
|
|
if facets:
|
|
out["facets"] = search.facets(d.conn, project_id=project_id, unit_id=unit_id)
|
|
return out
|
|
|
|
|
|
@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", "ncr"),
|
|
A.QC_ACCEPT_AND_ROUTE: ("dept", "note", "ncr"),
|
|
A.FILE: ("note",),
|
|
A.ADMIN_REOPEN: ("reason",),
|
|
}
|
|
MSG_ARCHIVE = (
|
|
"The packet could not be copied to the archive: {reason}. Nothing was filed, and Admin has been told."
|
|
" File it again once the archive works."
|
|
)
|
|
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)
|
|
elif "ncr" in extra:
|
|
_accept_ncr(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}
|
|
try:
|
|
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)
|
|
except archive.FilingCopyFailed as e:
|
|
# The filing rolled back (Gitea issue 57); record it and tell Admin in a transaction of its own.
|
|
archive.record_filing_failure(conn, p, packet_id, e)
|
|
raise ApiError(503, "archive_failed", MSG_ARCHIVE.format(reason=e.message.rstrip("."))) from None
|
|
return _read(d, pp, packet_id)
|
|
|
|
|
|
def _reject_reason(extra: dict[str, Any]) -> None:
|
|
"""A Q.C. reject always needs a detailed reason (8 characters or more, C2); an NCR number does not stand in for
|
|
it. The reject may carry an NCR number too: the return note then reads "NCR-2026-0021 · <reason>"."""
|
|
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
|
|
if text:
|
|
extra["reason"] = text
|
|
extra["reason_text"] = text
|
|
|
|
|
|
def _accept_ncr(extra: dict[str, Any]) -> None:
|
|
"""The NCR number an accept carries after a reject without one (C2). An empty one counts as not given."""
|
|
raw = clean_text(extra.pop("ncr", None), 100)
|
|
if raw:
|
|
extra["ncr"] = ncrs.validate_ncr(raw)
|
|
|
|
|
|
@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,)
|
|
)
|
|
]
|
|
# Only a hand packet gets here (C5): the feed closes a VISUAL packet, so there is no feed state to compare.
|
|
w.note = ", ".join(wos) 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)
|
|
]
|