Files
PacketStation/server/packetstation/api/packet_children.py
T

1768 lines
68 KiB
Python
Raw Normal View History

"""Packet child rows: the BOM and its shorts, welds, heat tags, the heat table, the open stage (fields, checklist,
clock), the warehouse stage, the Q.C. inspection and change orders.
Every write goes through domain.pipeline.packet_write with its policy action: one transaction, the packet locked
first, the policy decides, then the version bump, packet_log, audit and NOTIFY. Row edits check the row's
row_version against If-Match (409 row_conflict); state changes check the state the caller expected (409). Every
mutating answer is the full read model.
"""
from __future__ import annotations
import uuid
from datetime import date
from decimal import Decimal
from typing import Any, Literal
import psycopg
from fastapi import APIRouter, Depends, Request
from psycopg.types.json import Jsonb
from pydantic import BaseModel, Field
from .. import policy
from ..domain import checklists, packets
from ..domain.children import NEVER_VERIFY, active_depts, co_touched, current_wh, update_row, wh_move
from ..domain.pipeline import (
SignBody,
Write,
check_row_version,
jsonable,
next_counter,
packet_write,
parse_if_match,
require_if_match,
)
from ..text import LIMITS, RX_HEAT, RX_STAMP, clean_opt, clean_text
from .deps import Db, Principal, current_principal, get_db
from .errors import ApiError
from .packets import WRITE
router = APIRouter(prefix="/api/packets", tags=["packet-children"])
A = policy.Action
TEXT_SHORT = 40 # weld type, size, process, wo_base
FILLER_MAX = 120
QC_FIELD_MAX = 120
# The old warehouse hold reasons. Any other text is taken as given; "Other" needs a note.
WH_HOLD_REASONS = (
"Waiting on material (not in stock)",
"Waiting on purchasing / PO",
"Question for engineering or clerical",
"Drawing / BOM problem",
"Other",
)
# The old warehouse board: which stage may follow which.
WH_NEXT: dict[str, frozenset[str]] = {
"incoming": frozenset({"pulling", "hold"}),
"pulling": frozenset({"pulled", "hold"}),
"hold": frozenset({"pulling"}),
"pulled": frozenset({"pulling"}),
}
WH_LABELS = {
"pulling": "Started pulling",
"hold": "Put on hold",
"pulled": "Marked pulled, ready for production",
}
MSG_NO_STAGE = "This packet has no open stage."
MSG_NO_INSPECTION = "This packet has no open Q.C. inspection."
MSG_SHORT_OPEN = "This line has an open short. Use the short to change heat, issued or location."
# ---------------------------------------------------------------- bodies
class It(BaseModel):
it_reason: str | None = Field(default=None, max_length=1000)
class BomLineIn(BaseModel):
item: int = Field(ge=1, le=100_000)
qty: Decimal | None = Field(default=None, ge=0)
part_id: str | None = Field(default=None, max_length=200)
description: str | None = Field(default=None, max_length=1000)
dwg_ref: str | None = Field(default=None, max_length=200)
cl_item: str | None = Field(default=None, max_length=200)
length: str | None = Field(default=None, max_length=200)
material: str | None = Field(default=None, max_length=500)
class BomPutBody(It):
bom: list[BomLineIn] = Field(max_length=1000)
class BomPatchBody(It):
heat: str | None = Field(default=None, max_length=200)
na: bool | None = None
issued: Decimal | None = Field(default=None, ge=0)
loc: str | None = Field(default=None, max_length=500)
pulled: bool | None = None
mtr_asset_id: uuid.UUID | None = None
description: str | None = Field(default=None, max_length=1000)
qty: Decimal | None = Field(default=None, ge=0)
class ShortBody(It):
qty: int
reason: str | None = Field(default=None, max_length=2000)
ref: str | None = Field(default=None, max_length=500)
eta: str | None = Field(default=None, max_length=40)
note: str | None = Field(default=None, max_length=2000)
heat: str | None = Field(default=None, max_length=200)
expect_open: bool
class ShortCloseBody(It):
expect_open: bool = True
class ReceiptBody(It):
qty: int
heat: str | None = Field(default=None, max_length=200)
note: str | None = Field(default=None, max_length=2000)
class WeldBody(It):
page: int | None = Field(default=None, ge=1, le=100_000)
fx: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
fy: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
wo_base: str | None = Field(default=None, max_length=200)
piece: int | None = Field(default=None, ge=1, le=100_000)
items: list[int] = Field(min_length=1, max_length=200)
type: str | None = Field(default=None, max_length=200)
size: str | None = Field(default=None, max_length=200)
process: str | None = Field(default=None, max_length=200)
stamp: str | None = Field(default=None, max_length=40)
class WeldPatchBody(It):
page: int | None = Field(default=None, ge=1, le=100_000)
fx: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
fy: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
wo_base: str | None = Field(default=None, max_length=200)
piece: int | None = Field(default=None, ge=1, le=100_000)
items: list[int] | None = Field(default=None, min_length=1, max_length=200)
type: str | None = Field(default=None, max_length=200)
size: str | None = Field(default=None, max_length=200)
process: str | None = Field(default=None, max_length=200)
stamp: str | None = Field(default=None, max_length=40)
class RemoveBody(It):
reason: str | None = Field(default=None, max_length=2000)
class TagBody(It):
page: int | None = Field(default=None, ge=1, le=100_000)
item: int = Field(ge=1, le=100_000)
fx: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
fy: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
class TagPatchBody(It):
page: int | None = Field(default=None, ge=1, le=100_000)
item: int | None = Field(default=None, ge=1, le=100_000)
fx: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
fy: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
class HeatTableBody(It):
x: float = Field(allow_inf_nan=False)
y: float = Field(allow_inf_nan=False)
s: float = Field(gt=0, le=100, allow_inf_nan=False)
class StageBody(It):
filler: str | None = Field(default=None, max_length=500)
filler_lot: str | None = Field(default=None, max_length=500)
preheat: str | None = Field(default=None, max_length=500)
notes: str | None = Field(default=None, max_length=5000)
class ChecklistBody(It):
done: bool
class ClockBody(It):
op: Literal["start", "pause", "resume"]
class WhBody(It):
stage: Literal["incoming", "pulling", "hold", "pulled"]
expect_stage: Literal["incoming", "pulling", "hold", "pulled"]
reason: str | None = Field(default=None, max_length=2000)
note: str | None = Field(default=None, max_length=2000)
class WeldExamIn(BaseModel):
result: Literal["accept", "reject", ""] | None = None
note: str | None = Field(default=None, max_length=2000)
class QcBody(It):
checks: dict[str, bool] | None = Field(default=None, max_length=50)
weld_exam: dict[str, WeldExamIn] | None = Field(default=None, max_length=2000)
nde_method: str | None = Field(default=None, max_length=500)
nde_result: str | None = Field(default=None, max_length=500)
nde_report: str | None = Field(default=None, max_length=500)
hardness: str | None = Field(default=None, max_length=500)
nace: bool | None = None
inspector: str | None = Field(default=None, max_length=500)
stamp: str | None = Field(default=None, max_length=40)
notes: str | None = Field(default=None, max_length=5000)
class CoItemIn(BaseModel):
old_page: int | None = Field(default=None, ge=1, le=100_000)
new_page: int | None = Field(default=None, ge=1, le=100_000)
added: bool = False
class CoIssueBody(It):
reason: str | None = Field(default=None, max_length=2000)
ecn: str | None = Field(default=None, max_length=200)
required_depts: list[str] | None = Field(default=None, max_length=30)
items: list[CoItemIn] = Field(default_factory=list, max_length=200)
hold: bool = False
sign: SignBody | None = None
class SignOnly(It):
sign: SignBody | None = None
class CoRejectBody(It):
reason: str | None = Field(default=None, max_length=2000)
sign: SignBody | None = None
class CoHoldBody(It):
hold: bool
class CoVerifyBody(It):
dept: str = Field(min_length=1, max_length=10)
note: str | None = Field(default=None, max_length=2000)
sign: SignBody | None = None
# ---------------------------------------------------------------- helpers
def _pp(d: Db, p: Principal) -> policy.Principal:
return packets.policy_principal_for(d.conn, p)
def _read(d: Db, pp: policy.Principal, packet_id: int) -> dict[str, Any]:
return packets.read_packet(d.conn, packet_id, pp)
def _heat(value: Any) -> str | None:
"""A cleaned heat number, None when empty, 422 invalid_heat when it does not fit RX_HEAT."""
text = clean_text(value, 200)
if not text:
return None
if len(text) > LIMITS["heat"] or not RX_HEAT.match(text):
raise ApiError(422, "invalid_heat", "Heat numbers are letters, digits, spaces and . / # _ - only.")
return text
def _stamp(value: Any) -> str | None:
text = clean_text(value, 40)
if not text:
return None
if not RX_STAMP.match(text):
raise ApiError(422, "invalid_stamp", "A stamp is up to 10 letters, digits or dashes.")
return text
def _user_stamp(conn: Any, user_id: Any) -> str | None:
r = conn.execute("SELECT welder_stamp FROM users WHERE id = %s", (user_id,)).fetchone()
return r[0] if r and r[0] else None
def _bom_line(conn: Any, packet_id: int, item: int) -> dict[str, Any]:
row = packets.one(conn, "SELECT * FROM bom_lines WHERE packet_id = %s AND item = %s", (packet_id, item))
if row is None:
raise ApiError(404, "not_found", f"There is no BOM item {item} on this packet.")
return row
def _open_short(conn: Any, line_id: int) -> dict[str, Any] | None:
return packets.one(conn, "SELECT * FROM bom_shorts WHERE bom_line_id = %s AND closed_at IS NULL", (line_id,))
def _bom_items(conn: Any, packet_id: int) -> set[int]:
return {int(r[0]) for r in conn.execute("SELECT item FROM bom_lines WHERE packet_id = %s", (packet_id,))}
def _check_items(conn: Any, packet_id: int, items: list[int]) -> list[int]:
have = _bom_items(conn, packet_id)
bad = sorted({i for i in items if i not in have})
if bad:
raise ApiError(
422, "invalid_items", f"Item {', '.join(map(str, bad))} is not on this packet's BOM.", extra={"items": bad}
)
return sorted(set(items))
def _open_stage(conn: Any, packet_id: int) -> dict[str, Any] | None:
return packets.one(conn, "SELECT * FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (packet_id,))
def _need_stage(conn: Any, packet_id: int) -> dict[str, Any]:
st = _open_stage(conn, packet_id)
if st is None:
raise ApiError(409, "no_stage", MSG_NO_STAGE)
return st
def _num(value: Any) -> Decimal | None:
return None if value is None else Decimal(str(value))
def _diff(row: dict[str, Any], values: dict[str, Any]) -> dict[str, Any]:
"""The values that differ from the row."""
out: dict[str, Any] = {}
for k, v in values.items():
cur = row.get(k)
if isinstance(v, Decimal) or isinstance(cur, Decimal):
if _num(v) != _num(cur):
out[k] = v
elif (str(v) if isinstance(v, uuid.UUID) else v) != (str(cur) if isinstance(cur, uuid.UUID) else cur):
out[k] = v
return out
def _auto_pull(w: Write) -> None:
"""Ticking a line Pulled while the warehouse is waiting or on hold starts pulling, as the old build did."""
if w.packet["status"] != "warehouse":
return
cur = current_wh(w.conn, w.id)
stage = cur["stage"] if cur else "incoming"
if stage not in ("incoming", "hold"):
return
note = "pulling resumed" if stage == "hold" else "started pulling"
wh_move(w.conn, w.id, "pulling", w.user_id, note=note, auto=True)
w.changed("wh")
w.note = f"{w.note} · {note}" if w.note else note
def _set_pulled(w: Write, line: dict[str, Any], values: dict[str, Any]) -> None:
"""values gets pulled true, pulled_at and pulled_by (kept when already pulled), issued = qty when unset."""
values["pulled"] = True
if not line["pulled"]:
values["pulled_at"] = "now"
values["pulled_by"] = w.user_id
def _apply_line(w: Write, line: dict[str, Any], values: dict[str, Any]) -> dict[str, Any]:
"""Write the changed values to a BOM line (row_version + 1). pulled_at 'now' is the database clock."""
if values.get("pulled_at") == "now":
values["pulled_at"] = w.conn.execute("SELECT now()").fetchone()[0]
row = update_row(w.conn, "bom_lines", line["id"], values)
assert row is not None
return row
def _weld(conn: Any, packet_id: int, no: int) -> dict[str, Any]:
row = packets.one(
conn, "SELECT * FROM welds WHERE packet_id = %s AND weld_no = %s AND removed_at IS NULL", (packet_id, no)
)
if row is None:
raise ApiError(404, "not_found", f"There is no weld W{no} on this packet.")
return row
def _tag(conn: Any, packet_id: int, no: int) -> dict[str, Any]:
row = packets.one(
conn, "SELECT * FROM heat_tags WHERE packet_id = %s AND tag_no = %s AND removed_at IS NULL", (packet_id, no)
)
if row is None:
raise ApiError(404, "not_found", f"There is no heat tag H{no} on this packet.")
return row
def _co(conn: Any, packet_id: int, no: int) -> dict[str, Any]:
row = packets.one(
conn,
"SELECT *, origin::text AS origin, review::text AS review FROM change_orders"
" WHERE packet_id = %s AND co_no = %s",
(packet_id, no),
)
if row is None:
raise ApiError(404, "not_found", f"There is no change order {no} on this packet.")
return row
def _co_state(row: dict[str, Any], want: str, what: str) -> None:
if row["review"] != want:
raise ApiError(
409,
"co_state",
f"Change order {row['co_no']} is {row['review']}. Only a {want} change order can be {what}.",
extra={"current": {"co_no": row["co_no"], "review": row["review"], "hold": row["hold"]}},
)
# ---------------------------------------------------------------- BOM
@router.put("/{packet_id}/bom", dependencies=WRITE)
def put_bom(
packet_id: int,
body: BomPutBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Full replace of a draft's BOM (Clerical). If-Match is the packet version."""
if_match = require_if_match(request)
seen: set[int] = set()
lines: list[dict[str, Any]] = []
for i, bl in enumerate(body.bom):
if bl.item in seen:
raise ApiError(422, "duplicate_item", f"BOM item {bl.item} is listed twice.")
seen.add(bl.item)
lines.append(
{
"item": bl.item,
"qty": bl.qty,
"part_id": clean_opt(bl.part_id, LIMITS["part_id"]),
"description": clean_opt(bl.description, LIMITS["description"]),
"dwg_ref": clean_opt(bl.dwg_ref, LIMITS["drawing"]),
"cl_item": clean_opt(bl.cl_item, LIMITS["part_id"]),
"length": clean_opt(bl.length, LIMITS["length"]),
"material": clean_opt(bl.material, LIMITS["material"]),
"sort": i,
}
)
pp = _pp(d, p)
conn = d.conn
with packet_write(
d, p, packet_id, A.EDIT_BOM, request=request, it_reason=body.it_reason, if_match=if_match, policy_principal=pp
) as w:
if w.packet["status"] != "draft":
raise ApiError(403, "wrong_status", "The whole BOM is replaced only while the packet is a draft.")
conn.execute("DELETE FROM bom_lines WHERE packet_id = %s", (packet_id,))
for b in lines:
conn.execute(
"INSERT INTO bom_lines (packet_id, item, qty, part_id, description, dwg_ref, cl_item, length, material,"
" src, sort) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'hand', %s)",
(
packet_id,
b["item"],
b["qty"],
b["part_id"],
b["description"],
b["dwg_ref"],
b["cl_item"],
b["length"],
b["material"],
b["sort"],
),
)
w.changed("bom")
w.log("BOM replaced", f"{len(lines)} line{'s' if len(lines) != 1 else ''}")
return _read(d, pp, packet_id)
@router.patch("/{packet_id}/bom/{item}", dependencies=WRITE)
def patch_bom_line(
packet_id: int,
item: int,
body: BomPatchBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""One BOM line: heat, na, issued, loc, pulled, mtr_asset_id, description, qty. If-Match is the line's
row_version."""
if_match = parse_if_match(request)
given = body.model_fields_set - {"it_reason"}
values: dict[str, Any] = {}
if "heat" in given:
values["heat"] = _heat(body.heat)
if "na" in given:
if body.na is None:
raise ApiError(422, "invalid_request", "N/A is true or false.")
values["na"] = body.na
if body.na:
values["heat"] = None
if "issued" in given:
values["issued"] = body.issued
if "loc" in given:
values["loc"] = clean_opt(body.loc, LIMITS["location"])
if "description" in given:
values["description"] = clean_opt(body.description, LIMITS["description"])
if "qty" in given:
values["qty"] = body.qty
if "mtr_asset_id" in given:
values["mtr_asset_id"] = body.mtr_asset_id
if "pulled" in given and body.pulled is None:
raise ApiError(422, "invalid_request", "Pulled is true or false.")
pp = _pp(d, p)
conn = d.conn
with packet_write(d, p, packet_id, A.EDIT_BOM, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
line = _bom_line(conn, packet_id, item)
check_row_version(line, if_match)
if _open_short(conn, line["id"]) is not None and given & {"heat", "issued", "loc"}:
raise ApiError(409, "short_open", MSG_SHORT_OPEN)
if values.get("mtr_asset_id") is not None and (
conn.execute("SELECT 1 FROM assets WHERE id = %s", (values["mtr_asset_id"],)).fetchone() is None
):
raise ApiError(422, "invalid_asset", "That MTR file is not on the server. Upload it first.")
newly_pulled = False
if "pulled" in given:
if body.pulled:
if not line["pulled"]:
newly_pulled = True
_set_pulled(w, line, values)
issued = values.get("issued", line["issued"])
if issued is None:
values["issued"] = line["qty"]
else:
values["pulled"] = False
values["pulled_at"] = None
values["pulled_by"] = None
changes = _diff(line, values)
if not changes:
w.skip()
return _read(d, pp, packet_id)
_apply_line(w, line, changes)
w.changed(*[f"bom.{k}" for k in changes if k not in ("pulled_at", "pulled_by")])
w.detail["item"] = item
if "pulled" in changes:
w.log(f"Item {item} pulled" if changes["pulled"] else f"Item {item} no longer pulled")
elif "heat" in changes and len(changes) == 1:
w.log(f"Heat recorded on item {item}" if changes["heat"] else f"Heat cleared on item {item}")
w.note = changes["heat"]
elif "na" in changes:
w.log(f"Item {item} marked N/A (not traceable)" if changes["na"] else f"Item {item} marked traceable")
elif "issued" in changes and len(changes) == 1:
w.log(f"Issued qty changed on item {item}", f"{changes['issued']} of {line['qty']}")
elif "mtr_asset_id" in changes and len(changes) == 1:
w.log(f"MTR attached to item {item}" if changes["mtr_asset_id"] else f"MTR removed from item {item}")
else:
w.log(f"Item {item} changed")
if newly_pulled:
_auto_pull(w)
return _read(d, pp, packet_id)
@router.post("/{packet_id}/bom/{item}/short", dependencies=WRITE)
def save_short(
packet_id: int,
item: int,
body: ShortBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Open a short on a line, or update the open one. expect_open says which the caller saw."""
reason = clean_opt(body.reason, LIMITS["reason"])
ref = clean_opt(body.ref, LIMITS["po"])
note = clean_opt(body.note, LIMITS["note"])
eta = clean_opt(body.eta, 40)
if eta is not None:
try:
date.fromisoformat(eta)
except ValueError:
raise ApiError(422, "invalid_date", "The ETA is a date (YYYY-MM-DD).") from None
heat = _heat(body.heat) if body.heat is not None else None
pp = _pp(d, p)
conn = d.conn
with packet_write(d, p, packet_id, A.EDIT_BOM, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
line = _bom_line(conn, packet_id, item)
short = _open_short(conn, line["id"])
if (short is not None) != body.expect_open:
raise ApiError(
409,
"short_conflict",
"Someone else changed this short. Reload and try again.",
extra={"current": jsonable({"open": short is not None, "short": short})},
)
need = _num(line["qty"])
if need is None or need < 1:
raise ApiError(422, "invalid_qty", "This line has no quantity, so it cannot be short.")
if not (1 <= body.qty <= need):
raise ApiError(422, "invalid_qty", f"Short qty must be 1 to {need}.")
if short is None:
conn.execute(
"INSERT INTO bom_shorts (packet_id, bom_line_id, qty, qty_open, reason, ref, eta, note, opened_by)"
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)",
(packet_id, line["id"], body.qty, body.qty, reason, ref, eta, note, w.user_id),
)
label = f"Short recorded on item {item}"
else:
received = (_num(short["qty"]) or Decimal(0)) - (_num(short["qty_open"]) or Decimal(0))
update_row(
conn,
"bom_shorts",
short["id"],
{
"qty": received + body.qty,
"qty_open": body.qty,
"reason": reason,
"ref": ref,
"eta": eta,
"note": note,
},
)
label = f"Short updated on item {item}"
values: dict[str, Any] = {"issued": need - body.qty}
_set_pulled(w, line, values)
if heat is not None and not line["na"]:
values["heat"] = heat
newly_pulled = not line["pulled"]
changes = _diff(line, values)
if changes:
_apply_line(w, line, changes)
w.changed("bom.short")
w.detail["item"] = item
parts = [f"{body.qty} of {need} short"]
parts += [x for x in (reason, ref, f"ETA {eta}" if eta else None) if x]
w.log(label, " · ".join(parts))
if newly_pulled:
_auto_pull(w)
return _read(d, pp, packet_id)
@router.delete("/{packet_id}/bom/{item}/short", dependencies=WRITE)
def clear_short(
packet_id: int,
item: int,
request: Request,
body: ShortCloseBody | None = None,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Close the open short: the whole quantity counts as issued."""
body = body or ShortCloseBody()
pp = _pp(d, p)
conn = d.conn
with packet_write(d, p, packet_id, A.EDIT_BOM, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
line = _bom_line(conn, packet_id, item)
short = _open_short(conn, line["id"])
if short is None or not body.expect_open:
raise ApiError(
409,
"short_conflict",
"Someone else changed this short. Reload and try again.",
extra={"current": jsonable({"open": short is not None, "short": short})},
)
conn.execute(
"UPDATE bom_shorts SET closed_at = now(), row_version = row_version + 1 WHERE id = %s", (short["id"],)
)
changes = _diff(line, {"issued": line["qty"]})
if changes:
_apply_line(w, line, changes)
w.changed("bom.short")
w.detail["item"] = item
w.log(f"Short cleared on item {item}")
return _read(d, pp, packet_id)
@router.post("/{packet_id}/bom/{item}/short/receipts", dependencies=WRITE)
def receive_short(
packet_id: int,
item: int,
body: ReceiptBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""The warehouse receives short parts, in any status but filed. The short closes when nothing is open."""
heat = _heat(body.heat)
note = clean_opt(body.note, LIMITS["note"])
pp = _pp(d, p)
conn = d.conn
with packet_write(
d, p, packet_id, A.WH_RECEIVE_SHORT, request=request, it_reason=body.it_reason, policy_principal=pp
) as w:
line = _bom_line(conn, packet_id, item)
short = _open_short(conn, line["id"])
if short is None:
raise ApiError(404, "not_found", f"Item {item} has no open short.")
left = _num(short["qty_open"]) or Decimal(0)
if not (1 <= body.qty <= left):
raise ApiError(422, "invalid_qty", f"Received qty must be 1 to {left}.")
if heat is None and not line["na"]:
raise ApiError(422, "heat_required", "Scan or type the heat number of the part received.")
conn.execute(
"INSERT INTO short_receipts (short_id, packet_id, qty, heat, note, by) VALUES (%s, %s, %s, %s, %s, %s)",
(short["id"], packet_id, body.qty, heat, note, w.user_id),
)
still = left - body.qty
conn.execute(
"UPDATE bom_shorts SET qty_open = %s, closed_at = CASE WHEN %s <= 0 THEN now() END,"
" row_version = row_version + 1 WHERE id = %s",
(still, still, short["id"]),
)
values: dict[str, Any] = {"issued": (_num(line["issued"]) or Decimal(0)) + body.qty}
if heat:
heats = [h for h in (line["heat"] or "").split(" / ") if h]
if heat not in heats:
heats.append(heat)
values["heat"] = " / ".join(heats)
_apply_line(w, line, values)
w.changed("bom.short")
w.detail.update({"item": item, "qty": body.qty})
tail = f"{still} still short" if still > 0 else "short closed"
w.log(
f"Short received on item {item}",
" · ".join(x for x in (f"{body.qty} pc", heat and f"heat {heat}", tail) if x),
)
return _read(d, pp, packet_id)
# ---------------------------------------------------------------- welds
def _weld_values(conn: Any, packet_id: int, body: WeldBody | WeldPatchBody, given: set[str]) -> dict[str, Any]:
values: dict[str, Any] = {}
for k in ("page", "fx", "fy", "piece"):
if k in given:
values[k] = getattr(body, k)
for k in ("wo_base", "type", "size", "process"):
if k in given:
values[k] = clean_opt(getattr(body, k), TEXT_SHORT)
if "stamp" in given:
values["stamp"] = _stamp(body.stamp)
if "items" in given:
if body.items is None:
raise ApiError(422, "invalid_items", "Pick the BOM items this weld joins.")
values["items"] = _check_items(conn, packet_id, body.items)
return values
@router.post("/{packet_id}/welds", dependencies=WRITE)
def add_weld(
packet_id: int,
body: WeldBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""A new weld. The weld number comes from the packet counter."""
pp = _pp(d, p)
conn = d.conn
given = set(body.model_fields_set) | {"items"}
with packet_write(
d, p, packet_id, A.EDIT_WELDS, request=request, it_reason=body.it_reason, policy_principal=pp
) as w:
values = _weld_values(conn, packet_id, body, given)
dept = str(w.packet["dept_code"])
worker = pp.level(dept) == 1
welder_id = w.user_id if worker else w.packet["welder_id"]
if values.get("stamp") is None:
values["stamp"] = (_user_stamp(conn, w.user_id) if worker else None) or w.packet["welder_stamp"]
stage = _open_stage(conn, packet_id)
no = next_counter(conn, packet_id, "weld")
conn.execute(
"INSERT INTO welds (packet_id, weld_no, page, fx, fy, wo_base, piece, items, type, size, process, stamp,"
" welder_id, dept_code, stage_id, by) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,"
" %s)",
(
packet_id,
no,
values.get("page"),
values.get("fx"),
values.get("fy"),
values.get("wo_base"),
values.get("piece"),
values["items"],
values.get("type"),
values.get("size"),
values.get("process"),
values.get("stamp"),
welder_id,
dept,
stage["id"] if stage else None,
w.user_id,
),
)
w.changed("welds")
w.detail["weld_no"] = no
desc = " ".join(x for x in (values.get("type"), values.get("size")) if x)
w.log(f"Logged W{no}", " · ".join(x for x in (desc, "items " + " + ".join(map(str, values["items"]))) if x))
return _read(d, pp, packet_id)
@router.patch("/{packet_id}/welds/{no}", dependencies=WRITE)
def patch_weld(
packet_id: int,
no: int,
body: WeldPatchBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
if_match = parse_if_match(request)
pp = _pp(d, p)
conn = d.conn
with packet_write(
d, p, packet_id, A.EDIT_WELDS, request=request, it_reason=body.it_reason, policy_principal=pp
) as w:
row = _weld(conn, packet_id, no)
check_row_version(row, if_match)
values = _weld_values(conn, packet_id, body, set(body.model_fields_set))
changes = _diff(row, values)
if "items" in changes and sorted(row["items"] or []) == changes["items"]:
del changes["items"]
if not changes:
w.skip()
return _read(d, pp, packet_id)
update_row(conn, "welds", row["id"], changes)
w.changed(*[f"weld.{k}" for k in changes])
w.detail["weld_no"] = no
w.log(f"Edited W{no}")
return _read(d, pp, packet_id)
@router.delete("/{packet_id}/welds/{no}", dependencies=WRITE)
def remove_weld(
packet_id: int,
no: int,
request: Request,
body: RemoveBody | None = None,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Soft delete. If-Match is checked when given."""
body = body or RemoveBody()
if_match = parse_if_match(request)
reason = clean_opt(body.reason, LIMITS["reason"])
pp = _pp(d, p)
conn = d.conn
with packet_write(
d, p, packet_id, A.EDIT_WELDS, request=request, it_reason=body.it_reason, policy_principal=pp
) as w:
row = _weld(conn, packet_id, no)
if if_match is not None:
check_row_version(row, if_match)
conn.execute(
"UPDATE welds SET removed_at = now(), removed_by = %s, removed_reason = %s, row_version = row_version + 1"
" WHERE id = %s",
(w.user_id, reason, row["id"]),
)
w.changed("welds")
w.detail["weld_no"] = no
w.log(f"Removed W{no}", reason)
return _read(d, pp, packet_id)
# ---------------------------------------------------------------- heat tags and the heat table
def _tag_values(conn: Any, packet_id: int, body: TagBody | TagPatchBody, given: set[str]) -> dict[str, Any]:
values: dict[str, Any] = {k: getattr(body, k) for k in ("page", "fx", "fy") if k in given}
if "item" in given:
if body.item is None:
raise ApiError(422, "invalid_items", "Pick the BOM item this tag marks.")
values["item"] = _check_items(conn, packet_id, [body.item])[0]
return values
@router.post("/{packet_id}/heat-tags", dependencies=WRITE)
def add_tag(
packet_id: int,
body: TagBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
pp = _pp(d, p)
conn = d.conn
with packet_write(
d, p, packet_id, A.EDIT_HEATS, request=request, it_reason=body.it_reason, policy_principal=pp
) as w:
values = _tag_values(conn, packet_id, body, set(body.model_fields_set) | {"item"})
no = next_counter(conn, packet_id, "heat")
conn.execute(
"INSERT INTO heat_tags (packet_id, tag_no, page, item, fx, fy, by) VALUES (%s, %s, %s, %s, %s, %s, %s)",
(packet_id, no, values.get("page"), values["item"], values.get("fx"), values.get("fy"), w.user_id),
)
w.changed("heat_tags")
w.detail["tag_no"] = no
w.log(f"Heat tag H{no} placed", f"item {values['item']}")
return _read(d, pp, packet_id)
@router.patch("/{packet_id}/heat-tags/{no}", dependencies=WRITE)
def patch_tag(
packet_id: int,
no: int,
body: TagPatchBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
if_match = parse_if_match(request)
pp = _pp(d, p)
conn = d.conn
with packet_write(
d, p, packet_id, A.EDIT_HEATS, request=request, it_reason=body.it_reason, policy_principal=pp
) as w:
row = _tag(conn, packet_id, no)
check_row_version(row, if_match)
changes = _diff(row, _tag_values(conn, packet_id, body, set(body.model_fields_set)))
if not changes:
w.skip()
return _read(d, pp, packet_id)
update_row(conn, "heat_tags", row["id"], changes)
w.changed(*[f"heat_tag.{k}" for k in changes])
w.detail["tag_no"] = no
w.log(f"Heat tag H{no} changed", f"item {changes['item']}" if "item" in changes else None)
return _read(d, pp, packet_id)
@router.delete("/{packet_id}/heat-tags/{no}", dependencies=WRITE)
def remove_tag(
packet_id: int,
no: int,
request: Request,
body: RemoveBody | None = None,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
body = body or RemoveBody()
if_match = parse_if_match(request)
reason = clean_opt(body.reason, LIMITS["reason"])
pp = _pp(d, p)
conn = d.conn
with packet_write(
d, p, packet_id, A.EDIT_HEATS, request=request, it_reason=body.it_reason, policy_principal=pp
) as w:
row = _tag(conn, packet_id, no)
if if_match is not None:
check_row_version(row, if_match)
conn.execute(
"UPDATE heat_tags SET removed_at = now(), removed_by = %s, removed_reason = %s,"
" row_version = row_version + 1 WHERE id = %s",
(w.user_id, reason, row["id"]),
)
w.changed("heat_tags")
w.detail["tag_no"] = no
w.log(f"Heat tag H{no} removed", reason)
return _read(d, pp, packet_id)
@router.put("/{packet_id}/heat-table/{page}", dependencies=WRITE)
def put_heat_table(
packet_id: int,
page: int,
body: HeatTableBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Place the heat table on a page. The same placement again changes nothing."""
if page < 1:
raise ApiError(422, "invalid_page", "Pages start at 1.")
pp = _pp(d, p)
conn = d.conn
with packet_write(
d, p, packet_id, A.EDIT_HEAT_TABLE, request=request, it_reason=body.it_reason, policy_principal=pp
) as w:
cur = packets.one(
conn, "SELECT x, y, s FROM heat_table_placements WHERE packet_id = %s AND page = %s", (packet_id, page)
)
if cur is not None and (cur["x"], cur["y"], cur["s"]) == (body.x, body.y, body.s):
w.skip()
return _read(d, pp, packet_id)
conn.execute(
"INSERT INTO heat_table_placements (packet_id, page, x, y, s, by) VALUES (%s, %s, %s, %s, %s, %s)"
" ON CONFLICT (packet_id, page) DO UPDATE SET x = EXCLUDED.x, y = EXCLUDED.y, s = EXCLUDED.s,"
" by = EXCLUDED.by, at = now()",
(packet_id, page, body.x, body.y, body.s, w.user_id),
)
w.changed("heat_table")
w.detail["page"] = page
w.log(f"Heat table placed on page {page}")
return _read(d, pp, packet_id)
@router.delete("/{packet_id}/heat-table/{page}", dependencies=WRITE)
def remove_heat_table(
packet_id: int,
page: int,
request: Request,
body: It | None = None,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
body = body or It()
pp = _pp(d, p)
conn = d.conn
with packet_write(
d, p, packet_id, A.EDIT_HEAT_TABLE, request=request, it_reason=body.it_reason, policy_principal=pp
) as w:
gone = conn.execute(
"DELETE FROM heat_table_placements WHERE packet_id = %s AND page = %s RETURNING id", (packet_id, page)
).fetchone()
if gone is None:
raise ApiError(404, "not_found", f"There is no heat table on page {page}.")
w.changed("heat_table")
w.detail["page"] = page
w.log(f"Heat table removed from page {page}")
return _read(d, pp, packet_id)
# ---------------------------------------------------------------- the open stage
@router.patch("/{packet_id}/stage", dependencies=WRITE)
def patch_stage(
packet_id: int,
body: StageBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Filler, filler lot, preheat and notes of the open stage. If-Match is the stage's row_version."""
if_match = parse_if_match(request)
limits = {"filler": FILLER_MAX, "filler_lot": FILLER_MAX, "preheat": FILLER_MAX, "notes": LIMITS["notes"]}
values = {k: clean_opt(getattr(body, k), n) for k, n in limits.items() if k in body.model_fields_set}
pp = _pp(d, p)
conn = d.conn
with packet_write(
d, p, packet_id, A.EDIT_STAGE_FIELDS, request=request, it_reason=body.it_reason, policy_principal=pp
) as w:
stage = _need_stage(conn, packet_id)
check_row_version(stage, if_match)
changes = _diff(stage, values)
if not changes:
w.skip()
return _read(d, pp, packet_id)
update_row(conn, "packet_stages", stage["id"], changes)
w.changed(*[f"stage.{k}" for k in changes])
w.log("Stage details changed", f"filler {changes['filler']}" if changes.get("filler") else None)
return _read(d, pp, packet_id)
@router.put("/{packet_id}/stage/checklist/{key}", dependencies=WRITE)
def put_checklist(
packet_id: int,
key: str,
body: ChecklistBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Tick or untick one checklist key. Per-key writes never conflict, so there is no If-Match."""
pp = _pp(d, p)
conn = d.conn
with packet_write(
d, p, packet_id, A.EDIT_CHECKLIST, request=request, it_reason=body.it_reason, policy_principal=pp
) as w:
weld = bool(w.state.weld_dept)
if key not in checklists.checklist_keys(weld):
raise ApiError(422, "invalid_key", "That is not an item on this department's checklist.")
stage = _need_stage(conn, packet_id)
cur = (stage["checklist"] or {}).get(key)
if checklists.is_done(cur) == body.done:
w.skip()
return _read(d, pp, packet_id)
if body.done:
stamp = _user_stamp(conn, w.user_id) or w.packet["welder_stamp"]
conn.execute(
"UPDATE packet_stages SET checklist = jsonb_set(checklist, ARRAY[%s]::text[],"
" jsonb_build_object('done', true, 'at', now(), 'by', %s::text, 'stamp', %s::text)) WHERE id = %s",
(key, str(w.user_id), stamp, stage["id"]),
)
else:
conn.execute(
"UPDATE packet_stages SET checklist = jsonb_set(checklist, ARRAY[%s]::text[], %s) WHERE id = %s",
(key, Jsonb({"done": False}), stage["id"]),
)
text = next((i["text"] for i in checklists.checklist_for(weld, None, None, None) if i["key"] == key), key)
w.changed(f"checklist.{key}")
w.log(("Checked: " if body.done else "Unchecked: ") + text)
return _read(d, pp, packet_id)
@router.post("/{packet_id}/stage/clock", dependencies=WRITE)
def clock(
packet_id: int,
body: ClockBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Start, pause or resume the stage clock. No version check: the clock state itself is the check."""
pp = _pp(d, p)
conn = d.conn
with packet_write(d, p, packet_id, A.CLOCK, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
stage = _need_stage(conn, packet_id)
running = stage["clock_running_since"] is not None
bad = (
(body.op == "start" and running)
or (body.op == "pause" and not running)
or (body.op == "resume" and (running or stage["clock_started_at"] is None))
)
if bad:
raise ApiError(
409,
"clock_state",
"The clock is running." if running else "The clock is stopped.",
extra={"current": {"running": running, "started": stage["clock_started_at"] is not None}},
)
if body.op == "start":
sets = "clock_running_since = now(), clock_started_at = coalesce(clock_started_at, now())"
label = "Clock started"
elif body.op == "pause":
sets = (
"clock_elapsed_ms = clock_elapsed_ms"
" + (extract(epoch FROM (now() - clock_running_since)) * 1000)::bigint, clock_running_since = NULL"
)
label = "Paused"
else:
sets = "clock_running_since = now()"
label = "Resumed"
conn.execute(f"UPDATE packet_stages SET {sets} WHERE id = %s", (stage["id"],)) # noqa: S608 (fixed fragments)
w.changed("stage.clock")
w.log(label)
return _read(d, pp, packet_id)
# ---------------------------------------------------------------- the warehouse stage
@router.post("/{packet_id}/wh", dependencies=WRITE)
def set_wh_stage(
packet_id: int,
body: WhBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Move the warehouse board: incoming, pulling, hold, pulled. expect_stage is what the caller saw."""
reason = clean_opt(body.reason, LIMITS["reason"])
note = clean_opt(body.note, LIMITS["note"])
if body.stage == "hold":
if reason is None:
raise ApiError(400, "reason_required", "Say why it is on hold.")
if reason == "Other" and note is None:
raise ApiError(400, "reason_required", "Say what it's waiting on.")
pp = _pp(d, p)
conn = d.conn
extra = {"reason": reason, "note": note}
with packet_write(
d, p, packet_id, A.SET_WH_STAGE, request=request, extra=extra, it_reason=body.it_reason, policy_principal=pp
) as w:
cur = current_wh(conn, packet_id)
stage = cur["stage"] if cur else "incoming"
if stage != body.expect_stage:
raise ApiError(
409,
"stale_step",
"Someone else already moved this packet on the warehouse board.",
extra={"current": {"stage": stage, "version": int(w.packet["version"])}},
)
if body.stage not in WH_NEXT[stage]:
raise ApiError(409, "wh_transition", f"A packet that is {stage} cannot move to {body.stage}.")
wh_move(
conn,
packet_id,
body.stage,
w.user_id,
reason=reason if body.stage == "hold" else None,
note=note,
auto=False,
)
label = "Pulling resumed" if stage == "hold" and body.stage == "pulling" else WH_LABELS.get(body.stage, "")
w.changed("wh")
w.detail.update({"wh_from": stage, "wh_to": body.stage})
w.log(
f"Warehouse: {label}",
" · ".join(x for x in (reason, note) if x) if body.stage == "hold" else note,
)
return _read(d, pp, packet_id)
# ---------------------------------------------------------------- Q.C.
@router.patch("/{packet_id}/qc", dependencies=WRITE)
def patch_qc(
packet_id: int,
body: QcBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""The open inspection. checks and weld_exam merge per key; If-Match is the inspection's row_version."""
if_match = parse_if_match(request)
given = body.model_fields_set
if body.checks:
bad = sorted(set(body.checks) - set(checklists.qc_keys()))
if bad:
raise ApiError(422, "invalid_key", f"Not a Q.C. item: {', '.join(bad)}.")
values: dict[str, Any] = {}
for k in ("nde_method", "nde_result", "nde_report", "hardness", "inspector"):
if k in given:
values[k] = clean_opt(getattr(body, k), QC_FIELD_MAX)
if "notes" in given:
values["notes"] = clean_opt(body.notes, LIMITS["notes"])
if "stamp" in given:
values["stamp"] = _stamp(body.stamp)
if "nace" in given:
values["nace"] = body.nace
pp = _pp(d, p)
conn = d.conn
with packet_write(d, p, packet_id, A.EDIT_QC, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
insp = packets.one(
conn, "SELECT * FROM qc_inspections WHERE packet_id = %s AND closed_at IS NULL", (packet_id,)
)
if insp is None:
raise ApiError(409, "no_inspection", MSG_NO_INSPECTION)
check_row_version(insp, if_match)
changes = _diff(insp, values)
at = conn.execute("SELECT now()").fetchone()[0].isoformat()
by = str(w.user_id)
if body.checks:
stamp = values.get("stamp", insp["stamp"])
checks = dict(insp["checks"] or {})
for k, done in body.checks.items():
if checklists.is_done(checks.get(k)) == done:
continue
checks[k] = {"done": True, "at": at, "by": by, "stamp": stamp} if done else {"done": False}
if checks != (insp["checks"] or {}):
changes["checks"] = Jsonb(checks)
if body.weld_exam:
live = {
str(r[0])
for r in conn.execute(
"SELECT weld_no FROM welds WHERE packet_id = %s AND removed_at IS NULL", (packet_id,)
)
}
bad = sorted(set(body.weld_exam) - live)
if bad:
raise ApiError(422, "invalid_weld", f"No such weld on this packet: {', '.join(bad)}.")
exam = dict(insp["weld_exam"] or {})
for no, entry in body.weld_exam.items():
cur = dict(exam.get(no) or {})
new = dict(cur)
if "result" in entry.model_fields_set:
new["result"] = entry.result or ""
if "note" in entry.model_fields_set:
new["note"] = clean_text(entry.note, LIMITS["note"])
if new != cur:
new.update({"at": at, "by": by})
exam[no] = new
if exam != (insp["weld_exam"] or {}):
changes["weld_exam"] = Jsonb(exam)
if not changes:
w.skip()
return _read(d, pp, packet_id)
update_row(conn, "qc_inspections", insp["id"], changes)
w.changed(*[f"qc.{k}" for k in changes])
w.log("Q.C. record changed")
return _read(d, pp, packet_id)
# ---------------------------------------------------------------- change orders
@router.get("/{packet_id}/change-orders")
def list_change_orders(
packet_id: int,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = _read(d, _pp(d, p), packet_id)["change_orders"]
return out
def _required(conn: Any, codes: list[str]) -> list[str]:
want: list[str] = []
for c in codes:
c = clean_text(c, 10).upper()
if c and c not in want:
want.append(c)
bad = [c for c in want if c in NEVER_VERIFY or c not in active_depts(conn, want)]
if bad:
raise ApiError(
422,
"invalid_dept",
f"Not a department that verifies change orders: {', '.join(bad)}.",
extra={"depts": bad},
)
return want
def _co_log_detail(w: Write, no: int) -> None:
w.changed("change_orders")
w.detail["co_no"] = no
@router.post("/{packet_id}/change-orders", dependencies=WRITE)
def issue_co(
packet_id: int,
body: CoIssueBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Clerical issues a change order by hand. It is approved at once and holds the packet until every required
department verifies it."""
reason = clean_opt(body.reason, LIMITS["reason"])
if reason is None:
raise ApiError(400, "reason_required", "Say what changed and why.")
ecn = clean_opt(body.ecn, LIMITS["ecn"])
pp = _pp(d, p)
conn = d.conn
content = {
"action": str(A.CO_ISSUE),
"packet_id": packet_id,
"reason": reason,
"ecn": ecn,
"required_depts": body.required_depts,
"items": [i.model_dump() for i in body.items],
}
with packet_write(
d,
p,
packet_id,
A.CO_ISSUE,
request=request,
it_reason=body.it_reason,
sign=body.sign,
sign_content=content,
policy_principal=pp,
) as w:
required = (
_required(conn, body.required_depts) if body.required_depts is not None else co_touched(conn, w.packet)
)
pages = {
int(r["n"]): r
for r in packets.rows(conn, "SELECT n, dwg, rev FROM packet_pages WHERE packet_id = %s", (packet_id,))
}
for it in body.items:
for n in (it.old_page, it.new_page):
if n is not None and n not in pages:
raise ApiError(422, "invalid_page", f"There is no page {n} on this packet.")
no = next_counter(conn, packet_id, "co")
sig = w.signature.id if w.signature else None
co_id = conn.execute(
"INSERT INTO change_orders (packet_id, co_no, origin, review, hold, held_by, held_at, ecn, reason,"
" issued_by, issued_signature_id, approved_at, approved_by, approved_signature_id)"
" VALUES (%s, %s, 'manual', 'approved', %s, %s, CASE WHEN %s THEN now() END, %s, %s, %s, %s, now(), %s, %s)"
" RETURNING id",
(
packet_id,
no,
body.hold,
w.user_id if body.hold else None,
body.hold,
ecn,
reason,
w.user_id,
sig,
w.user_id,
sig,
),
).fetchone()[0]
for it in body.items:
old = pages.get(it.old_page) if it.old_page is not None else None
new = pages.get(it.new_page) if it.new_page is not None else None
conn.execute(
"INSERT INTO co_items (co_id, old_page, old_dwg, old_rev, new_page, new_dwg, new_rev, added)"
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s)",
(
co_id,
it.old_page,
old["dwg"] if old else None,
old["rev"] if old else None,
it.new_page,
new["dwg"] if new else None,
new["rev"] if new else None,
it.added,
),
)
for code in required:
conn.execute("INSERT INTO co_required_depts (co_id, dept_code) VALUES (%s, %s)", (co_id, code))
_co_log_detail(w, no)
w.detail["required"] = required
verify = f"verify: {', '.join(required)}" if required else "no departments to verify"
w.log(f"Change order {no} issued", " · ".join(x for x in (reason, ecn, verify) if x))
return _read(d, pp, packet_id)
FEED_ORIGINS = ("visual", "drawing")
MSG_CO_NOT_STAGED = "The new drawing has not been fetched from M-Files yet."
def _vis_text(leg: Any, *names: str) -> str:
for n in names:
v = leg.get(n) if isinstance(leg, dict) else None
if v is not None and v != "":
return str(v)
return ""
def _leg_row(leg: Any) -> dict[str, Any]:
"""One work order row from a change order's `vis.legs` entry. Accepts a flat leg ({wo|wo_base|base, lot, split,
sub, seq, ...}) or one with a nested key ({key: {base, lot, split, sub}, seq, ...})."""
if not isinstance(leg, dict):
raise ApiError(422, "bad_change_order", "The change order's work order list cannot be read.")
key = leg.get("key") if isinstance(leg.get("key"), dict) else leg
base = _vis_text(key, "base", "wo_base", "wo")
if not base:
raise ApiError(422, "bad_change_order", "The change order's work order list cannot be read.")
seq_raw = leg.get("seq")
return {
"wo_base": base,
"lot": _vis_text(key, "lot"),
"split": _vis_text(key, "split"),
"sub": _vis_text(key, "sub"),
"seq": int(seq_raw) if seq_raw not in (None, "") else None,
"qty": _num(leg.get("qty")),
"target_hrs": _num(leg.get("tgt", leg.get("target_hrs"))),
"op": clean_opt(leg.get("op"), LIMITS["op"]),
"dwg": clean_opt(leg.get("dwg"), LIMITS["drawing"]),
"rev": clean_opt(leg.get("rev"), LIMITS["rev"]),
"partspecs": clean_opt(leg.get("partspecs"), 4000),
"visual_status": clean_opt(leg.get("st", leg.get("visual_status")), 10),
}
def _apply_legs(conn: Any, packet_id: int, legs: list[Any], title: str | None) -> dict[str, int]:
"""Replace the packet's work orders with the change order's legs. Rows whose key and seq match are updated in
place (their ids and gone marks stay), new ones are added, the rest are deleted."""
want = [_leg_row(lg) for lg in legs]
have = packets.rows(conn, "SELECT * FROM packet_work_orders WHERE packet_id = %s", (packet_id,))
def k(r: dict[str, Any]) -> tuple[Any, ...]:
return (r["wo_base"], r["lot"], r["split"], r["sub"], r["seq"])
by_key = {k(r): r for r in have}
seen: set[tuple[Any, ...]] = set()
counts = {"added": 0, "updated": 0, "removed": 0}
for sort, r in enumerate(want):
key = k(r)
if key in seen:
continue
seen.add(key)
cur = by_key.get(key)
if cur is not None:
conn.execute(
"UPDATE packet_work_orders SET qty = %s, target_hrs = %s, op = %s, dwg = %s, rev = %s, partspecs = %s,"
" visual_status = %s, sort = %s, row_version = row_version + 1 WHERE id = %s",
(
r["qty"],
r["target_hrs"],
r["op"],
r["dwg"],
r["rev"],
r["partspecs"],
r["visual_status"],
sort,
cur["id"],
),
)
counts["updated"] += 1
else:
conn.execute(
"INSERT INTO packet_work_orders (packet_id, wo_base, lot, split, sub, seq, qty, target_hrs, op,"
" description, visual_status, dwg, rev, partspecs, sort)"
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)",
(
packet_id,
r["wo_base"],
r["lot"],
r["split"],
r["sub"],
r["seq"],
r["qty"],
r["target_hrs"],
r["op"],
title,
r["visual_status"],
r["dwg"],
r["rev"],
r["partspecs"],
sort,
),
)
counts["added"] += 1
gone = [r["id"] for r in have if k(r) not in seen]
if gone:
conn.execute("DELETE FROM packet_work_orders WHERE id = ANY(%s)", (gone,))
counts["removed"] = len(gone)
return counts
def _plural(n: int, word: str) -> str:
return f"{n} {word}{'' if n == 1 else 's'}"
def _apply_feed_co(w: Write, co: dict[str, Any]) -> str | None:
"""Apply a VISUAL or M-Files change order on approval: work orders, BOM, header drawing and rev, the fingerprint,
and the staged drawing (old pages voided, new pages carry the change order). Returns a note for the log."""
from . import packet_visual as pv
conn = w.conn
pid = w.id
vis = co["vis"] if isinstance(co["vis"], dict) else {}
staged = co["staged"] if isinstance(co["staged"], dict) else None
row = w.packet
visual = dict(row["visual"] or {})
notes: list[str] = []
sets: dict[str, Any] = {}
if isinstance(vis.get("legs"), list) and vis["legs"]:
c = _apply_legs(conn, pid, vis["legs"], row["title"])
notes.append(f"work orders: {c['added']} added, {c['updated']} kept, {c['removed']} removed")
if isinstance(vis.get("bom"), list) and not visual.get("manual_bom"):
c = pv.merge_visual_bom(conn, pid, vis["bom"])
notes.append(f"BOM: {c['added']} added, {c['updated']} kept, {c['removed'] + c['na']} dropped")
if co["origin"] == "visual":
if vis.get("to_dwg") not in (None, ""):
sets["drawing"] = clean_opt(vis["to_dwg"], LIMITS["drawing"])
if vis.get("to_rev") not in (None, ""):
sets["rev"] = clean_opt(vis["to_rev"], LIMITS["rev"])
if co["target_fp"]:
sets["visual_fp_applied"] = co["target_fp"]
for col, key in (("visual_want", "want"), ("visual_rel", "rel")):
if key in vis:
sets[col] = vis[key] or None
for key in ("fp_parts", "folded", "partspecs"):
if key in vis:
visual[key] = vis[key]
flags = list(row["flags"] or [])
if staged and staged.get("asset_id"):
asset = pv.pdf_asset(conn, staged["asset_id"])
dwg = sets.get("drawing") or staged.get("dwg") or row["drawing"]
rev = str(staged["rev"]) if staged.get("rev") not in (None, "") else (sets.get("rev") or row["rev"])
first, count, old = pv.replace_drawing_pages(
conn, pid, asset, dwg=dwg, rev=rev, co_id=int(co["id"]), void_by=w.user_id
)
for i in range(max(len(old), count)):
o = old[i] if i < len(old) else None
new = i < count
conn.execute(
"INSERT INTO co_items (co_id, old_page, old_dwg, old_rev, new_page, new_dwg, new_rev, added)"
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s)",
(
co["id"],
o["n"] if o else None,
o["dwg"] if o else None,
o["rev"] if o else None,
first + i if new else None,
dwg if new else None,
rev if new else None,
o is None,
),
)
sets["drawing_state"] = "attached"
flags = pv.flags_without(flags, (*packets.DRAWING_FLAGS, "hand_drawing"))
visual["rev_confirmed"] = True
visual["vault"] = {
"asset_id": str(asset["id"]),
"name": staged.get("name"),
"rev": rev,
"at": pv.now_iso(),
"co_no": co["co_no"],
}
visual.pop("manual_dwg", None)
notes.append(f"drawing {dwg} rev {rev}, {_plural(count, 'page')}; {_plural(len(old), 'old page')} voided")
sets["flags"] = flags
sets["visual"] = Jsonb(visual)
cols = ", ".join(f"{c} = %s" + ("::drawing_state" if c == "drawing_state" else "") for c in sets)
conn.execute(f"UPDATE packets SET {cols} WHERE id = %s", (*sets.values(), pid)) # noqa: S608 (fixed names)
w.changed(*(c for c in sets if c not in ("flags", "visual")), "change_orders")
return "; ".join(notes) or None
@router.post("/{packet_id}/change-orders/{no}/approve", dependencies=WRITE)
def approve_co(
packet_id: int,
no: int,
body: SignOnly,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Approve a pending change order. A VISUAL or M-Files one is applied to the packet here; a minor one (target
hours or want date only) needs no department verification."""
pp = _pp(d, p)
conn = d.conn
pre = packets.one(
conn,
"SELECT origin::text AS origin, staged, target_fp FROM change_orders WHERE packet_id = %s AND co_no = %s",
(packet_id, no),
)
content: dict[str, Any] = {"action": str(A.CO_APPROVE), "packet_id": packet_id, "co_no": no}
if pre is not None and pre["origin"] in FEED_ORIGINS:
content["target_fp"] = pre["target_fp"]
content["staged_sha256"] = pre["staged"].get("sha256") if isinstance(pre["staged"], dict) else None
with packet_write(
d,
p,
packet_id,
A.CO_APPROVE,
request=request,
it_reason=body.it_reason,
sign=body.sign,
sign_content=content,
policy_principal=pp,
) as w:
co = _co(conn, packet_id, no)
_co_state(co, "pending", "approved")
note: str | None = None
if co["origin"] in FEED_ORIGINS:
vis = co["vis"] if isinstance(co["vis"], dict) else {}
if vis.get("dwg_change") is True and not co["staged"]:
raise ApiError(409, "co_not_staged", MSG_CO_NOT_STAGED, extra={"waiting": co["waiting"]})
note = _apply_feed_co(w, co)
update_row(
conn,
"change_orders",
co["id"],
{
"review": "approved",
"approved_at": conn.execute("SELECT now()").fetchone()[0],
"approved_by": w.user_id,
"approved_signature_id": w.signature.id if w.signature else None,
},
)
has_required = conn.execute("SELECT 1 FROM co_required_depts WHERE co_id = %s LIMIT 1", (co["id"],)).fetchone()
if has_required is None and not co["minor"]:
for code in co_touched(conn, w.packet):
conn.execute("INSERT INTO co_required_depts (co_id, dept_code) VALUES (%s, %s)", (co["id"], code))
_co_log_detail(w, no)
w.log(f"Change order {no} approved", note)
return _read(d, pp, packet_id)
@router.post("/{packet_id}/change-orders/{no}/reject", dependencies=WRITE)
def reject_co(
packet_id: int,
no: int,
body: CoRejectBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
pp = _pp(d, p)
conn = d.conn
reason = clean_opt(body.reason, LIMITS["reason"])
content = {"action": str(A.CO_REJECT), "packet_id": packet_id, "co_no": no, "reason": reason}
with packet_write(
d,
p,
packet_id,
A.CO_REJECT,
request=request,
extra={"reason": reason},
it_reason=body.it_reason,
sign=body.sign,
sign_content=content,
policy_principal=pp,
) as w:
co = _co(conn, packet_id, no)
_co_state(co, "pending", "rejected")
update_row(
conn,
"change_orders",
co["id"],
{
"review": "rejected",
"rejected_at": conn.execute("SELECT now()").fetchone()[0],
"rejected_by": w.user_id,
"rejected_reason": reason,
"rejected_signature_id": w.signature.id if w.signature else None,
},
)
_co_log_detail(w, no)
w.log(f"Change order {no} rejected", reason)
return _read(d, pp, packet_id)
@router.post("/{packet_id}/change-orders/{no}/hold", dependencies=WRITE)
def hold_co(
packet_id: int,
no: int,
body: CoHoldBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Hold the packet on a pending change order, or release it."""
pp = _pp(d, p)
conn = d.conn
with packet_write(
d, p, packet_id, A.CO_HOLD_TOGGLE, request=request, it_reason=body.it_reason, policy_principal=pp
) as w:
co = _co(conn, packet_id, no)
_co_state(co, "pending", "held or released")
if bool(co["hold"]) == body.hold:
w.skip()
return _read(d, pp, packet_id)
now = conn.execute("SELECT now()").fetchone()[0]
update_row(
conn,
"change_orders",
co["id"],
{"hold": body.hold, "held_by": w.user_id if body.hold else None, "held_at": now if body.hold else None},
)
_co_log_detail(w, no)
w.log(f"Change order {no} {'holds the packet' if body.hold else 'no longer holds the packet'}")
return _read(d, pp, packet_id)
@router.post("/{packet_id}/change-orders/{no}/verify", dependencies=WRITE)
def verify_co(
packet_id: int,
no: int,
body: CoVerifyBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""A supervisor of a required department verifies an approved change order. Verifications are never
withdrawn."""
dept = clean_text(body.dept, 10).upper()
note = clean_opt(body.note, LIMITS["note"])
pp = _pp(d, p)
conn = d.conn
content = {"action": str(A.CO_VERIFY), "packet_id": packet_id, "co_no": no, "dept": dept, "note": note}
with packet_write(
d,
p,
packet_id,
A.CO_VERIFY,
request=request,
extra={"dept": dept},
it_reason=body.it_reason,
sign=body.sign,
sign_content=content,
policy_principal=pp,
) as w:
co = _co(conn, packet_id, no)
_co_state(co, "approved", "verified")
required = {r[0] for r in conn.execute("SELECT dept_code FROM co_required_depts WHERE co_id = %s", (co["id"],))}
if dept not in required:
raise ApiError(422, "invalid_dept", f"Change order {no} does not need verification by {dept}.")
done = conn.execute(
"SELECT 1 FROM co_verifications WHERE co_id = %s AND dept_code = %s", (co["id"], dept)
).fetchone()
if done is not None:
raise ApiError(409, "already_verified", f"{dept} already verified change order {no}.")
try:
conn.execute(
"INSERT INTO co_verifications (co_id, dept_code, by, note, signature_id, it_reason)"
" VALUES (%s, %s, %s, %s, %s, %s)",
(co["id"], dept, w.user_id, note, w.signature.id if w.signature else None, w.it_reason),
)
except psycopg.errors.UniqueViolation:
raise ApiError(409, "already_verified", f"{dept} already verified change order {no}.") from None
_co_log_detail(w, no)
w.detail["dept"] = dept
name = packets.dept_names(conn).get(dept, dept)
w.log(f"Change order {no} verified for {name}", note)
return _read(d, pp, packet_id)