Files

2310 lines
91 KiB
Python
Raw Permalink 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 import qc as qc_dom
from ..domain.children import NEVER_VERIFY, active_depts, co_touched, current_wh, update_row, wh_move
from ..domain.pipeline import (
SignBody,
Write,
apply_field_cas,
check_row_version,
jsonable,
next_counter,
packet_write,
parse_if_match,
qty_text,
require_if_match,
)
from ..policy.actions import STATUS_LABELS
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 = LIMITS["weld_text"] # weld type, size, process, wo_base
QC_FIELD_MAX = 120
# The weld vocabulary (the old WELD_TYPES and PROCESSES).
WELD_TYPES = ("Fillet", "Groove", "Fill", "Seal", "Tack")
PROCESSES = ("GMAW", "FCAW", "SMAW", "GTAW", "SAW")
DEFAULT_SIZE = '5/16"'
POSITION = frozenset({"fx", "fy", "bfx", "bfy"})
# The weld fields in the order the change note lists them, with their note names.
WELD_NOTE_NAMES = {
"type": "type",
"size": "size",
"process": "process",
"items": "items",
"wo_base": "wo",
"piece": "piece",
"stamp": "stamp",
"page": "sheet",
}
# The heat table bounds (the old HT_MIN, HT_MAX and the drag clamp).
HT_XY_MAX = 0.97
HT_S_MIN, HT_S_MAX = 0.6, 2.1
STAGE_FIELDS = {
"filler": LIMITS["filler"],
"filler_lot": LIMITS["filler"],
"preheat": LIMITS["preheat"],
"notes": LIMITS["notes"],
}
STAGE_NOTE_NAMES = {"filler": "filler", "filler_lot": "filler lot", "preheat": "preheat", "notes": "notes"}
# The warehouse hold reasons, a closed set (the old build took any text). "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 short reasons, a closed set (the old build took any text).
SHORT_REASONS = (
"Not in stock",
"On order",
"Back-ordered",
"Damaged / rejected",
"Wrong part received",
"Needs cutting / prep",
"Other",
)
WH_NOTE_MAX = 200
# The file types an MTR may be.
MTR_MIMES = frozenset({"application/pdf", "image/png", "image/jpeg"})
# 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",
}
# Labels for a move into pulling from a stage other than incoming.
WH_PULLING_FROM = {"hold": "Pulling resumed", "pulled": "Back to pulling"}
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."
MSG_VOID = "This sheet is VOID. Mark the current sheet."
# ---------------------------------------------------------------- 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 ShortApproveBody(It):
sign: SignBody | None = None
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)
bfx: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
bfy: 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)
bfx: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
bfy: 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)
bfx: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
bfy: 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)
bfx: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
bfy: 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(allow_inf_nan=False)
carried_from: int | None = Field(default=None, ge=1, le=100_000)
class StageBody(It):
"""The flat body (with If-Match on the stage row), or `{set, base}` for field-level compare-and-set."""
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)
set: dict[str, str | None] | None = Field(default=None, max_length=10)
base: dict[str, str | None] | None = Field(default=None, max_length=10)
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):
"""`{set, base}` for field-level compare-and-set of the scalar fields, or the phase 2 flat body with If-Match on
the inspection's row_version (checks and weld_exam merge per key)."""
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)
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)
set: dict[str, str | None] | None = Field(default=None, max_length=10)
base: dict[str, str | None] | None = Field(default=None, max_length=10)
class QcCheckBody(It):
done: bool
class QcWeldBody(It):
result: Literal["accept", "reject", ""] | None = None
note: str | None = Field(default=None, max_length=2000)
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.
Trailing white space is trimmed (a scanner may add it). A leading space is refused, as the pattern says.
"""
text = clean_text(value, 200)
if not text:
return None
lead = isinstance(value, str) and value[:1].isspace()
if lead or len(text) > LIMITS["heat"] or not RX_HEAT.match(text):
raise ApiError(
422,
"invalid_heat",
f'Heat number "{text}" isn\'t valid: letters, numbers, space . / # _ - only, up to 40 characters.',
)
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 _status_tail(conn: Any, row: dict[str, Any]) -> str:
"""The end of the receive note: " · packet ready to assign", plus " in {station}" when at a station."""
out = f" · packet {STATUS_LABELS.get(str(row['status']), str(row['status'])).lower()}"
if row["status"] in policy.ACTIVE and row["station_id"] is not None:
st = conn.execute("SELECT name FROM stations WHERE id = %s", (row["station_id"],)).fetchone()
if st and st[0]:
out += f" in {st[0]}"
return out
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 _page_row(conn: Any, packet_id: int, page: int) -> dict[str, Any] | None:
return packets.one(
conn, "SELECT n, type, dwg, rev, void_at FROM packet_pages WHERE packet_id = %s AND n = %s", (packet_id, page)
)
def _drawing_page(conn: Any, packet_id: int, page: int) -> dict[str, Any]:
"""A drawing page of the packet, live or VOID. 422 invalid_page otherwise."""
pg = _page_row(conn, packet_id, page)
if pg is None or pg["type"] != "drawing":
raise ApiError(422, "invalid_page", f"There is no drawing sheet {page} on this packet.")
return pg
def _live_page(conn: Any, packet_id: int, page: int | None) -> None:
"""A mark goes on a live drawing page. 422 void_page on a VOID sheet; nothing is checked without a page."""
if page is None:
return
if _drawing_page(conn, packet_id, page)["void_at"] is not None:
raise ApiError(422, "void_page", MSG_VOID)
def _not_void(conn: Any, packet_id: int, page: int | None) -> None:
"""An existing mark on a VOID sheet is never changed."""
if page is None:
return
pg = _page_row(conn, packet_id, page)
if pg is not None and pg["void_at"] is not None:
raise ApiError(422, "void_page", MSG_VOID)
def _page_text(pg: dict[str, Any] | None, page: int) -> str:
"""A sheet in a log note: its drawing number, else "sheet 2"."""
dwg = (pg or {}).get("dwg")
return str(dwg) if dwg else f"sheet {page}"
def _num_text(value: Any) -> str:
return f"{round(float(value), 2):g}"
def _co_no_text(no: int) -> str:
return packets.co_label(no)
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 {_co_no_text(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 {_co_no_text(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:
asset = conn.execute("SELECT mime FROM assets WHERE id = %s", (values["mtr_asset_id"],)).fetchone()
if asset is None:
raise ApiError(422, "invalid_asset", "That MTR file is not on the server. Upload it first.")
if asset[0] not in MTR_MIMES:
raise ApiError(422, "invalid_asset", "An MTR is a PDF, PNG or JPG file.")
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
_log_line(w, item, line, changes, given)
if newly_pulled:
_auto_pull(w)
return _read(d, pp, packet_id)
def _log_line(w: Write, item: int, line: dict[str, Any], changes: dict[str, Any], given: set[str]) -> None:
"""The packet_log text of a line edit. Fields the server set along the way (issued on a Pulled tick, the heat
cleared by N/A) do not count as a second field."""
kinds = {k for k in changes if k not in ("pulled_at", "pulled_by")}
if "pulled" in kinds and "issued" not in given:
kinds.discard("issued")
if "na" in kinds and "heat" not in given:
kinds.discard("heat")
if len(kinds) != 1:
w.log(f"Line changed · item {item}")
return
kind = next(iter(kinds))
if kind == "pulled":
w.log(f"Pulled · item {item}" if changes["pulled"] else f"Pulled undone · item {item}")
elif kind == "heat":
if changes["heat"]:
w.log(f"Heat recorded · item {item}", changes["heat"])
else:
w.log(f"Heat cleared · item {item}")
elif kind == "na":
w.log(f"Marked N/A · item {item}" if changes["na"] else f"Traceable again · item {item}")
elif kind == "issued":
w.log(f"Issued qty changed · item {item}", f"{qty_text(changes['issued'])} of {qty_text(line['qty'])}")
elif kind == "loc":
w.log(f"Location set · item {item}", changes["loc"] or None)
elif kind == "mtr_asset_id":
w.log(f"MTR attached · item {item}" if changes["mtr_asset_id"] else f"MTR removed · item {item}")
else:
w.log(f"Line changed · item {item}")
@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"])
if reason not in SHORT_REASONS:
raise ApiError(400, "invalid_reason", "Pick one of the short reasons.")
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 · 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 · 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.update({"item": item, "qty": body.qty})
parts = [f"{body.qty} of {qty_text(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.update({"item": item, "qty": float(short["qty_open"])})
w.log(f"Short cleared · 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]:
"""Receive short parts. The short closes when nothing is open.
Receive by custody: a warehouse member receives in any status but filed (WH_RECEIVE_SHORT); anyone else
receives as the holder of the packet (EDIT_BOM), and the policy refuses everyone who is neither.
"""
heat = _heat(body.heat)
note = clean_opt(body.note, LIMITS["note"])
pp = _pp(d, p)
conn = d.conn
action = A.WH_RECEIVE_SHORT if "WH" in pp.support else A.EDIT_BOM
with packet_write(d, p, packet_id, action, 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"{qty_text(still)} still short" if still > 0 else "short closed"
w.log(
f"Short received · item {item}",
" · ".join(x for x in (f"{body.qty} pc", heat and f"heat {heat}", tail) if x)
+ _status_tail(conn, w.packet),
)
return _read(d, pp, packet_id)
def _not_approved(conn: Any, receipt_id: int) -> None:
if conn.execute("SELECT 1 FROM short_approvals WHERE receipt_id = %s", (receipt_id,)).fetchone():
raise ApiError(409, "already_approved", "This receipt is already approved.")
@router.post("/{packet_id}/shorts/receipts/{receipt_id}/approve", dependencies=WRITE)
def approve_short_receipt(
packet_id: int,
receipt_id: int,
body: ShortApproveBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""The department supervisor approves one received short (C7): the part and its heat number match what the
warehouse issued. One approval per receipt; a second answers 409 already_approved."""
note = clean_opt(body.note, LIMITS["note"])
pp = _pp(d, p)
conn = d.conn
receipt = packets.one(
conn,
"SELECT r.*, b.item FROM short_receipts r JOIN bom_shorts s ON s.id = r.short_id"
" JOIN bom_lines b ON b.id = s.bom_line_id WHERE r.id = %s AND r.packet_id = %s",
(receipt_id, packet_id),
)
if receipt is None:
raise ApiError(404, "not_found", "No such short receipt on this packet.")
_not_approved(conn, receipt_id) # before the signature: a second approval costs no attempt
content = {
"action": str(A.SHORT_APPROVE),
"packet_id": packet_id,
"receipt_id": receipt_id,
"item": receipt["item"],
"qty": qty_text(receipt["qty"]),
"heat": receipt["heat"],
"note": note,
}
with packet_write(
d,
p,
packet_id,
A.SHORT_APPROVE,
request=request,
it_reason=body.it_reason,
sign=body.sign,
sign_content=content,
policy_principal=pp,
) as w:
_not_approved(conn, receipt_id) # again under the packet lock: a racing approval waits, then sees the first
conn.execute(
"INSERT INTO short_approvals (receipt_id, short_id, packet_id, dept_code, note, signature_id, by)"
" VALUES (%s, %s, %s, %s, %s, %s, %s)",
(
receipt_id,
receipt["short_id"],
packet_id,
w.state.dept,
note,
w.signature.id if w.signature else None,
w.user_id,
),
)
w.changed("bom.short")
w.detail.update({"item": receipt["item"], "receipt_id": receipt_id})
parts = [f"{qty_text(receipt['qty'])} pc", receipt["heat"] and f"heat {receipt['heat']}", note]
w.log(f"Short approved · item {receipt['item']}", " · ".join(x for x in parts 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", "bfx", "bfy", "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 values.get("type") is not None and values["type"] not in WELD_TYPES:
raise ApiError(422, "invalid_weld", "Pick a weld type from the list.")
if values.get("process") is not None and values["process"] not in PROCESSES:
raise ApiError(422, "invalid_weld", "Pick a process from the list.")
if values.get("wo_base") is not None:
bases = {
r[0] for r in conn.execute("SELECT wo_base FROM packet_work_orders WHERE packet_id = %s", (packet_id,))
}
if values["wo_base"] not in bases:
raise ApiError(422, "invalid_wo", "Pick one of this packet's work orders.")
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
def _items_text(items: Any) -> str:
return "items " + " + ".join(map(str, items or []))
def _weld_note(values: dict[str, Any]) -> str:
"""The note of a new weld: Fillet 5/16" · items 1 + 2 · L662001 #1."""
desc = " ".join(str(x) for x in (values.get("type"), values.get("size")) if x)
wo = values.get("wo_base")
if wo and values.get("piece") is not None:
wo = f"{wo} #{values['piece']}"
return " · ".join(x for x in (desc, _items_text(values["items"]), wo) if x)
def _change_note(changes: dict[str, Any]) -> str | None:
"""The changed weld fields: type Groove · items 1 + 3."""
parts: list[str] = []
for k, name in WELD_NOTE_NAMES.items():
if k not in changes:
continue
v = changes[k]
if k == "items":
parts.append(_items_text(v))
else:
parts.append(f"{name} {v}" if v not in (None, "") else f"{name} cleared")
return " · ".join(parts) or None
@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)
_live_page(conn, packet_id, values.get("page"))
if values.get("size") is None:
values["size"] = DEFAULT_SIZE
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, bfx, bfy, 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, %s, %s)",
(
packet_id,
no,
values.get("page"),
values.get("fx"),
values.get("fy"),
values.get("bfx"),
values.get("bfy"),
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
w.log(f"Weld W{no} marked", _weld_note(values))
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)
_not_void(conn, packet_id, row["page"])
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)
if "page" in changes:
_live_page(conn, packet_id, changes["page"])
update_row(conn, "welds", row["id"], changes)
w.changed(*[f"weld.{k}" for k in changes])
w.detail["weld_no"] = no
if set(changes) <= POSITION:
w.log(f"Weld W{no} moved")
else:
w.log(f"Weld W{no} changed", _change_note(changes))
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)
_not_void(conn, packet_id, row["page"])
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"Weld W{no} removed", 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", "bfx", "bfy") 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"})
_live_page(conn, packet_id, values.get("page"))
no = next_counter(conn, packet_id, "heat")
conn.execute(
"INSERT INTO heat_tags (packet_id, tag_no, page, item, fx, fy, bfx, bfy, by)"
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)",
(
packet_id,
no,
values.get("page"),
values["item"],
values.get("fx"),
values.get("fy"),
values.get("bfx"),
values.get("bfy"),
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)
_not_void(conn, packet_id, row["page"])
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)
if "page" in changes:
_live_page(conn, packet_id, changes["page"])
update_row(conn, "heat_tags", row["id"], changes)
w.changed(*[f"heat_tag.{k}" for k in changes])
w.detail["tag_no"] = no
item = changes.get("item", row["item"])
w.log(f"Heat tag H{no} {'moved' if set(changes) <= POSITION else 'changed'}", f"item {item}")
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)
_not_void(conn, packet_id, row["page"])
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", " · ".join(x for x in (f"item {row['item']}", reason) if x))
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, move or resize the heat table on a drawing page. The same placement again changes nothing."""
if page < 1:
raise ApiError(422, "invalid_page", "Pages start at 1.")
if not (0 <= body.x <= HT_XY_MAX and 0 <= body.y <= HT_XY_MAX and HT_S_MIN <= body.s <= HT_S_MAX):
raise ApiError(
422,
"invalid_heat_table",
f"Keep the heat table on the sheet: x and y from 0 to {HT_XY_MAX}, size from {HT_S_MIN} to {HT_S_MAX}.",
)
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:
pg = _drawing_page(conn, packet_id, page)
if pg["void_at"] is not None:
raise ApiError(422, "void_page", MSG_VOID)
if body.carried_from is not None and _page_row(conn, packet_id, body.carried_from) is None:
raise ApiError(422, "invalid_page", f"There is no sheet {body.carried_from} on this packet.")
cur = packets.one(
conn,
"SELECT x, y, s, carried_from 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)
carried = body.carried_from if "carried_from" in body.model_fields_set else (cur or {}).get("carried_from")
conn.execute(
"INSERT INTO heat_table_placements (packet_id, page, x, y, s, carried_from, by)"
" VALUES (%s, %s, %s, %s, %s, %s, %s)"
" ON CONFLICT (packet_id, page) DO UPDATE SET x = EXCLUDED.x, y = EXCLUDED.y, s = EXCLUDED.s,"
" carried_from = EXCLUDED.carried_from, by = EXCLUDED.by, at = now()",
(packet_id, page, body.x, body.y, body.s, carried, w.user_id),
)
w.changed("heat_table")
w.detail["page"] = page
where = _page_text(pg, page)
if cur is None:
w.log("Heat table placed on drawing", where)
elif (cur["x"], cur["y"]) != (body.x, body.y):
w.log("Heat table moved", where)
else:
w.log("Heat table resized", f"{'A+' if body.s > cur['s'] else 'A-'} {_num_text(body.s)}")
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:
_not_void(conn, packet_id, page)
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 sheet {page}.")
w.changed("heat_table")
w.detail["page"] = page
w.log("Heat table removed from drawing", _page_text(_page_row(conn, packet_id, 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 (the weld record).
Either the flat body with If-Match (the stage's row_version), or `{set, base}`: field-level compare-and-set,
409 field_conflict with `current` for a field someone else changed since `base`.
"""
cas = body.set is not None
if cas:
assert body.set is not None
bad = sorted(k for k in body.set if k not in STAGE_FIELDS)
if bad:
raise ApiError(422, "invalid_field", f"Not a weld record field: {', '.join(bad)}.")
values = {k: clean_opt(v, STAGE_FIELDS[k]) for k, v in body.set.items()}
base = {k: clean_opt(v, STAGE_FIELDS[k]) for k, v in (body.base or {}).items() if k in STAGE_FIELDS}
if_match = None
else:
values = {k: clean_opt(getattr(body, k), n) for k, n in STAGE_FIELDS.items() if k in body.model_fields_set}
base = {}
if_match = parse_if_match(request)
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)
if cas:
changes = apply_field_cas({k: stage[k] for k in STAGE_FIELDS}, values, base)
else:
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("Weld record changed", _stage_note(changes))
return _read(d, pp, packet_id)
def _stage_note(changes: dict[str, Any]) -> str | None:
"""The changed weld record fields: filler ER70S-6 · filler lot 1234."""
parts: list[str] = []
for k, name in STAGE_NOTE_NAMES.items():
if k not in changes:
continue
v = changes[k]
if k == "notes":
parts.append("notes changed" if v else "notes cleared")
else:
parts.append(f"{name} {v}" if v else f"{name} cleared")
return " · ".join(parts) or None
@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)
templates = w.children.templates if w.children is not None else None
if key not in checklists.checklist_keys(weld, templates):
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"]),
)
items = checklists.checklist_for(weld, w.packet["drawing"], w.packet["rev"], w.packet["wps"], templates)
text = next((i["text"] for i in items 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, WH_NOTE_MAX)
if body.stage == "hold":
if reason not in WH_HOLD_REASONS:
raise ApiError(400, "invalid_reason", "Pick one of the hold reasons.")
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,
)
if body.stage == "pulling" and stage in WH_PULLING_FROM:
label = WH_PULLING_FROM[stage]
else:
label = 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.
QC_FIELDS = ("inspector", "stamp", "nde_method", "nde_result", "nde_report", "hardness", "notes")
QC_NOTE_NAMES = {"inspector": "inspector", "stamp": "stamp", "hardness": "hardness", "nde_report": "report"}
def _open_inspection(conn: Any, packet_id: int) -> dict[str, Any]:
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)
return insp
def _qc_value(key: str, value: Any) -> Any:
"""One scalar Q.C. field, cleaned and checked."""
if key == "notes":
return clean_opt(value, LIMITS["notes"])
if key == "stamp":
return _stamp(value)
text = clean_opt(value, QC_FIELD_MAX)
if key == "nde_method" and text is not None and text not in qc_dom.NDE_METHODS:
raise ApiError(422, "invalid_field", "Pick an NDE method from the list.")
if key == "nde_result" and text is not None and text not in qc_dom.NDE_RESULTS:
raise ApiError(422, "invalid_field", "Pick an NDE result from the list.")
return text
def _qc_logs(w: Write, changes: dict[str, Any]) -> None:
"""The log lines of a Q.C. field change: "NDE method: RT", "NDE result: Acceptable", then "Q.C. record changed"
with the other fields. A method set to None required clears the result and report without a line of their own."""
none = changes.get("nde_method") == qc_dom.NDE_NONE
lines: list[tuple[str, str | None]] = []
if "nde_method" in changes:
lines.append((f"NDE method: {changes['nde_method'] or '-'}", None))
if "nde_result" in changes and not (none and not changes["nde_result"]):
lines.append((f"NDE result: {changes['nde_result'] or '-'}", None))
parts: list[str] = []
for k in ("inspector", "stamp", "hardness", "nde_report", "notes"):
if k not in changes or (k == "nde_report" and none and not changes[k]):
continue
v = changes[k]
if k == "notes":
parts.append("notes changed" if v else "notes cleared")
else:
parts.append(f"{QC_NOTE_NAMES[k]} {v}" if v else f"{QC_NOTE_NAMES[k]} cleared")
if parts:
lines.append(("Q.C. record changed", " · ".join(parts)))
if not lines:
lines.append(("Q.C. record changed", None))
w.log(*lines[0])
for more in lines[1:]:
w.log_also(*more)
@router.put("/{packet_id}/qc/checks/{key}", dependencies=WRITE)
def put_qc_check(
packet_id: int,
key: str,
body: QcCheckBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""Tick or untick one Q.C. check. 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_QC, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
items = w.children.qc_items if w.children is not None and w.children.qc_items else None
if key not in checklists.qc_keys(items):
raise ApiError(422, "invalid_key", "That is not a Q.C. check item.")
insp = _open_inspection(conn, packet_id)
if checklists.is_done((insp["checks"] or {}).get(key)) == body.done:
w.skip()
return _read(d, pp, packet_id)
if body.done:
conn.execute(
"UPDATE qc_inspections SET checks = jsonb_set(checks, ARRAY[%s]::text[],"
" jsonb_build_object('done', true, 'at', now(), 'by', %s::text, 'stamp', %s::text)),"
" row_version = row_version + 1 WHERE id = %s",
(key, str(w.user_id), insp["stamp"], insp["id"]),
)
else:
conn.execute(
"UPDATE qc_inspections SET checks = jsonb_set(checks, ARRAY[%s]::text[], %s),"
" row_version = row_version + 1 WHERE id = %s",
(key, Jsonb({"done": False}), insp["id"]),
)
pk = w.packet
texts = checklists.qc_items_for(pk["drawing"], pk["rev"], pk["wps"], pk["welder_stamp"], items)
text = next((i["text"] for i in texts if i["key"] == key), key)
w.changed(f"qc.checks.{key}")
w.log(("Q.C. checked: " if body.done else "Q.C. unchecked: ") + text)
return _read(d, pp, packet_id)
@router.put("/{packet_id}/qc/welds/{no}", dependencies=WRITE)
def put_qc_weld(
packet_id: int,
no: int,
body: QcWeldBody,
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""The visual exam of one weld: accept, reject or cleared (empty), and a note."""
given = body.model_fields_set
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:
live = conn.execute(
"SELECT 1 FROM welds WHERE packet_id = %s AND weld_no = %s AND removed_at IS NULL", (packet_id, no)
).fetchone()
if live is None:
raise ApiError(422, "invalid_weld", "No such weld on this packet.")
insp = _open_inspection(conn, packet_id)
k = str(no)
cur = dict((insp["weld_exam"] or {}).get(k) or {})
result = (body.result or "") if "result" in given else str(cur.get("result") or "")
note = clean_text(body.note, LIMITS["note"]) if "note" in given else str(cur.get("note") or "")
result_changed = result != str(cur.get("result") or "")
note_changed = note != str(cur.get("note") or "")
if not result_changed and not note_changed:
w.skip()
return _read(d, pp, packet_id)
conn.execute(
"UPDATE qc_inspections SET weld_exam = jsonb_set(weld_exam, ARRAY[%s]::text[],"
" %s::jsonb || jsonb_build_object('at', now(), 'by', %s::text)), row_version = row_version + 1"
" WHERE id = %s",
(k, Jsonb({"result": result, "note": note}), str(w.user_id), insp["id"]),
)
label = qc_dom.weld_label(no)
w.changed(f"qc.weld_exam.{k}")
if result_changed:
w.log(f"VT {result} · {label}" if result else f"VT cleared · {label}", note if result and note else None)
else:
w.log("Q.C. record changed", f"{label} note")
return _read(d, pp, packet_id)
@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's fields.
`{set, base}`: inspector, stamp, nde_method, nde_result, nde_report, hardness and notes with field-level
compare-and-set (409 field_conflict with `current`). The phase 2 flat body with If-Match (the inspection's
row_version) still works and merges checks and weld_exam per key. Setting the NDE method to None required clears
the result and the report. The NACE flag is not written: the read model computes it from the WPS.
"""
cas = body.set is not None
values: dict[str, Any] = {}
base: dict[str, Any] = {}
if_match: int | None = None
if cas:
assert body.set is not None
bad = sorted(k for k in body.set if k not in QC_FIELDS)
if bad:
raise ApiError(422, "invalid_field", f"Not a Q.C. field: {', '.join(bad)}.")
values = {k: _qc_value(k, v) for k, v in body.set.items()}
for k, v in (body.base or {}).items():
if k in QC_FIELDS:
base[k] = clean_opt(v, LIMITS["notes"] if k == "notes" else QC_FIELD_MAX)
else:
given = body.model_fields_set
if body.checks:
bad = sorted(set(body.checks) - set(checklists.qc_keys(checklists.load_qc_items(d.conn))))
if bad:
raise ApiError(422, "invalid_key", f"Not a Q.C. item: {', '.join(bad)}.")
values = {k: _qc_value(k, getattr(body, k)) for k in QC_FIELDS if k in given}
if_match = parse_if_match(request)
if values.get("nde_method") == qc_dom.NDE_NONE:
values["nde_result"] = None
values["nde_report"] = None
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 = _open_inspection(conn, packet_id)
if cas:
changes = apply_field_cas({k: insp[k] for k in QC_FIELDS}, values, base)
else:
check_row_version(insp, if_match)
changes = _diff(insp, values)
scalar = dict(changes)
at = conn.execute("SELECT now()").fetchone()[0].isoformat()
by = str(w.user_id)
if not cas and 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 not cas and 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])
if scalar:
_qc_logs(w, scalar)
else:
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 {_co_no_text(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
)
_carry_heat_tables(w, old, first, count)
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
def _carry_heat_tables(w: Write, old: list[dict[str, Any]], first: int, count: int) -> None:
"""Copy each heat table placement from a voided sheet to the sheet that replaces it (the old htCarry), with
carried_from set to the old sheet. Welds and heat tags stay on the VOID sheet."""
for i, o in enumerate(old):
new_n = first + i if i < count else first
cur = packets.one(
w.conn, "SELECT x, y, s FROM heat_table_placements WHERE packet_id = %s AND page = %s", (w.id, o["n"])
)
if cur is None:
continue
done = w.conn.execute(
"INSERT INTO heat_table_placements (packet_id, page, x, y, s, carried_from, by)"
" VALUES (%s, %s, %s, %s, %s, %s, %s) ON CONFLICT (packet_id, page) DO NOTHING RETURNING id",
(w.id, new_n, cur["x"], cur["y"], cur["s"], o["n"], w.user_id),
).fetchone()
if done is not None:
w.changed("heat_table")
w.log_also("Heat table copied to new sheet", "from the VOID copy, check its position on the new revision")
@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 {_co_no_text(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 {_co_no_text(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 {_co_no_text(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 {_co_no_text(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 {_co_no_text(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 {_co_no_text(no)}."
) from None
_co_log_detail(w, no)
w.detail["dept"] = dept
names = packets.dept_names(conn)
verified = {r[0] for r in conn.execute("SELECT dept_code FROM co_verifications WHERE co_id = %s", (co["id"],))}
left = [names.get(c, c) for c in sorted(required) if c not in verified]
tail = f"still waiting on {', '.join(left)}" if left else "change order fully verified"
w.log(
f"Change order {_co_no_text(no)} verified by {names.get(dept, dept)}",
" · ".join(x for x in (note, tail) if x),
)
return _read(d, pp, packet_id)