"""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)