2026-10-16 09:31:45 -06:00
|
|
|
"""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
|
2026-10-17 20:08:53 -06:00
|
|
|
from ..domain import qc as qc_dom
|
2026-10-16 09:31:45 -06:00
|
|
|
from ..domain.children import NEVER_VERIFY, active_depts, co_touched, current_wh, update_row, wh_move
|
|
|
|
|
from ..domain.pipeline import (
|
|
|
|
|
SignBody,
|
|
|
|
|
Write,
|
2026-10-16 22:46:56 -06:00
|
|
|
apply_field_cas,
|
2026-10-16 09:31:45 -06:00
|
|
|
check_row_version,
|
|
|
|
|
jsonable,
|
|
|
|
|
next_counter,
|
|
|
|
|
packet_write,
|
|
|
|
|
parse_if_match,
|
2026-10-16 17:13:31 -06:00
|
|
|
qty_text,
|
2026-10-16 09:31:45 -06:00
|
|
|
require_if_match,
|
|
|
|
|
)
|
2026-10-16 17:13:31 -06:00
|
|
|
from ..policy.actions import STATUS_LABELS
|
2026-10-16 09:31:45 -06:00
|
|
|
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
|
|
|
|
|
|
2026-10-16 22:46:56 -06:00
|
|
|
TEXT_SHORT = LIMITS["weld_text"] # weld type, size, process, wo_base
|
2026-10-16 09:31:45 -06:00
|
|
|
QC_FIELD_MAX = 120
|
|
|
|
|
|
2026-10-16 22:46:56 -06:00
|
|
|
# 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"}
|
|
|
|
|
|
2026-10-16 17:13:31 -06:00
|
|
|
# The warehouse hold reasons, a closed set (the old build took any text). "Other" needs a note.
|
2026-10-16 09:31:45 -06:00
|
|
|
WH_HOLD_REASONS = (
|
|
|
|
|
"Waiting on material (not in stock)",
|
|
|
|
|
"Waiting on purchasing / PO",
|
|
|
|
|
"Question for engineering or clerical",
|
|
|
|
|
"Drawing / BOM problem",
|
|
|
|
|
"Other",
|
|
|
|
|
)
|
2026-10-16 17:13:31 -06:00
|
|
|
# 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"})
|
2026-10-16 09:31:45 -06:00
|
|
|
# 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",
|
|
|
|
|
}
|
2026-10-16 17:13:31 -06:00
|
|
|
# Labels for a move into pulling from a stage other than incoming.
|
|
|
|
|
WH_PULLING_FROM = {"hold": "Pulling resumed", "pulled": "Back to pulling"}
|
2026-10-16 09:31:45 -06:00
|
|
|
|
|
|
|
|
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."
|
2026-10-16 22:46:56 -06:00
|
|
|
MSG_VOID = "This sheet is VOID. Mark the current sheet."
|
2026-10-16 09:31:45 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- 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)
|
2026-10-16 22:46:56 -06:00
|
|
|
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)
|
2026-10-16 09:31:45 -06:00
|
|
|
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)
|
2026-10-16 22:46:56 -06:00
|
|
|
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)
|
2026-10-16 09:31:45 -06:00
|
|
|
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)
|
2026-10-16 22:46:56 -06:00
|
|
|
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)
|
2026-10-16 09:31:45 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-10-16 22:46:56 -06:00
|
|
|
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)
|
2026-10-16 09:31:45 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class HeatTableBody(It):
|
|
|
|
|
x: float = Field(allow_inf_nan=False)
|
|
|
|
|
y: float = Field(allow_inf_nan=False)
|
2026-10-16 22:46:56 -06:00
|
|
|
s: float = Field(allow_inf_nan=False)
|
|
|
|
|
carried_from: int | None = Field(default=None, ge=1, le=100_000)
|
2026-10-16 09:31:45 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class StageBody(It):
|
2026-10-16 22:46:56 -06:00
|
|
|
"""The flat body (with If-Match on the stage row), or `{set, base}` for field-level compare-and-set."""
|
|
|
|
|
|
2026-10-16 09:31:45 -06:00
|
|
|
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)
|
2026-10-16 22:46:56 -06:00
|
|
|
set: dict[str, str | None] | None = Field(default=None, max_length=10)
|
|
|
|
|
base: dict[str, str | None] | None = Field(default=None, max_length=10)
|
2026-10-16 09:31:45 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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):
|
2026-10-17 20:08:53 -06:00
|
|
|
"""`{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)."""
|
|
|
|
|
|
2026-10-16 09:31:45 -06:00
|
|
|
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)
|
2026-10-17 20:08:53 -06:00
|
|
|
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)
|
2026-10-16 09:31:45 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
2026-10-16 17:13:31 -06:00
|
|
|
"""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.
|
|
|
|
|
"""
|
2026-10-16 09:31:45 -06:00
|
|
|
text = clean_text(value, 200)
|
|
|
|
|
if not text:
|
|
|
|
|
return None
|
2026-10-16 17:13:31 -06:00
|
|
|
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.',
|
|
|
|
|
)
|
2026-10-16 09:31:45 -06:00
|
|
|
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))
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 17:13:31 -06:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 09:31:45 -06:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 22:46:56 -06:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 09:31:45 -06:00
|
|
|
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:
|
2026-10-16 22:46:56 -06:00
|
|
|
raise ApiError(404, "not_found", f"There is no change order {_co_no_text(no)} on this packet.")
|
2026-10-16 09:31:45 -06:00
|
|
|
return row
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _co_state(row: dict[str, Any], want: str, what: str) -> None:
|
|
|
|
|
if row["review"] != want:
|
|
|
|
|
raise ApiError(
|
|
|
|
|
409,
|
|
|
|
|
"co_state",
|
2026-10-16 22:46:56 -06:00
|
|
|
f"Change order {_co_no_text(row['co_no'])} is {row['review']}. Only a {want} change order can be {what}.",
|
2026-10-16 09:31:45 -06:00
|
|
|
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)
|
2026-10-16 17:13:31 -06:00
|
|
|
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.")
|
2026-10-16 09:31:45 -06:00
|
|
|
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
|
2026-10-16 17:13:31 -06:00
|
|
|
_log_line(w, item, line, changes, given)
|
2026-10-16 09:31:45 -06:00
|
|
|
if newly_pulled:
|
|
|
|
|
_auto_pull(w)
|
|
|
|
|
return _read(d, pp, packet_id)
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 17:13:31 -06:00
|
|
|
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}")
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 09:31:45 -06:00
|
|
|
@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"])
|
2026-10-16 17:13:31 -06:00
|
|
|
if reason not in SHORT_REASONS:
|
|
|
|
|
raise ApiError(400, "invalid_reason", "Pick one of the short reasons.")
|
2026-10-16 09:31:45 -06:00
|
|
|
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),
|
|
|
|
|
)
|
2026-10-16 17:13:31 -06:00
|
|
|
label = f"Short recorded · item {item}"
|
2026-10-16 09:31:45 -06:00
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-10-16 17:13:31 -06:00
|
|
|
label = f"Short updated · item {item}"
|
2026-10-16 09:31:45 -06:00
|
|
|
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")
|
2026-10-16 17:13:31 -06:00
|
|
|
w.detail.update({"item": item, "qty": body.qty})
|
|
|
|
|
parts = [f"{body.qty} of {qty_text(need)} short"]
|
2026-10-16 09:31:45 -06:00
|
|
|
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")
|
2026-10-16 17:13:31 -06:00
|
|
|
w.detail.update({"item": item, "qty": float(short["qty_open"])})
|
|
|
|
|
w.log(f"Short cleared · item {item}")
|
2026-10-16 09:31:45 -06:00
|
|
|
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]:
|
2026-10-16 17:13:31 -06:00
|
|
|
"""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.
|
|
|
|
|
"""
|
2026-10-16 09:31:45 -06:00
|
|
|
heat = _heat(body.heat)
|
|
|
|
|
note = clean_opt(body.note, LIMITS["note"])
|
|
|
|
|
pp = _pp(d, p)
|
|
|
|
|
conn = d.conn
|
2026-10-16 17:13:31 -06:00
|
|
|
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:
|
2026-10-16 09:31:45 -06:00
|
|
|
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})
|
2026-10-16 17:13:31 -06:00
|
|
|
tail = f"{qty_text(still)} still short" if still > 0 else "short closed"
|
2026-10-16 09:31:45 -06:00
|
|
|
w.log(
|
2026-10-16 17:13:31 -06:00
|
|
|
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),
|
2026-10-16 09:31:45 -06:00
|
|
|
)
|
|
|
|
|
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] = {}
|
2026-10-16 22:46:56 -06:00
|
|
|
for k in ("page", "fx", "fy", "bfx", "bfy", "piece"):
|
2026-10-16 09:31:45 -06:00
|
|
|
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)
|
2026-10-16 22:46:56 -06:00
|
|
|
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.")
|
2026-10-16 09:31:45 -06:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 22:46:56 -06:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 09:31:45 -06:00
|
|
|
@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)
|
2026-10-16 22:46:56 -06:00
|
|
|
_live_page(conn, packet_id, values.get("page"))
|
|
|
|
|
if values.get("size") is None:
|
|
|
|
|
values["size"] = DEFAULT_SIZE
|
2026-10-16 09:31:45 -06:00
|
|
|
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(
|
2026-10-16 22:46:56 -06:00
|
|
|
"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)",
|
2026-10-16 09:31:45 -06:00
|
|
|
(
|
|
|
|
|
packet_id,
|
|
|
|
|
no,
|
|
|
|
|
values.get("page"),
|
|
|
|
|
values.get("fx"),
|
|
|
|
|
values.get("fy"),
|
2026-10-16 22:46:56 -06:00
|
|
|
values.get("bfx"),
|
|
|
|
|
values.get("bfy"),
|
2026-10-16 09:31:45 -06:00
|
|
|
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
|
2026-10-16 22:46:56 -06:00
|
|
|
w.log(f"Weld W{no} marked", _weld_note(values))
|
2026-10-16 09:31:45 -06:00
|
|
|
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)
|
2026-10-16 22:46:56 -06:00
|
|
|
_not_void(conn, packet_id, row["page"])
|
2026-10-16 09:31:45 -06:00
|
|
|
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)
|
2026-10-16 22:46:56 -06:00
|
|
|
if "page" in changes:
|
|
|
|
|
_live_page(conn, packet_id, changes["page"])
|
2026-10-16 09:31:45 -06:00
|
|
|
update_row(conn, "welds", row["id"], changes)
|
|
|
|
|
w.changed(*[f"weld.{k}" for k in changes])
|
|
|
|
|
w.detail["weld_no"] = no
|
2026-10-16 22:46:56 -06:00
|
|
|
if set(changes) <= POSITION:
|
|
|
|
|
w.log(f"Weld W{no} moved")
|
|
|
|
|
else:
|
|
|
|
|
w.log(f"Weld W{no} changed", _change_note(changes))
|
2026-10-16 09:31:45 -06:00
|
|
|
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)
|
2026-10-16 22:46:56 -06:00
|
|
|
_not_void(conn, packet_id, row["page"])
|
2026-10-16 09:31:45 -06:00
|
|
|
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
|
2026-10-16 22:46:56 -06:00
|
|
|
w.log(f"Weld W{no} removed", reason)
|
2026-10-16 09:31:45 -06:00
|
|
|
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]:
|
2026-10-16 22:46:56 -06:00
|
|
|
values: dict[str, Any] = {k: getattr(body, k) for k in ("page", "fx", "fy", "bfx", "bfy") if k in given}
|
2026-10-16 09:31:45 -06:00
|
|
|
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"})
|
2026-10-16 22:46:56 -06:00
|
|
|
_live_page(conn, packet_id, values.get("page"))
|
2026-10-16 09:31:45 -06:00
|
|
|
no = next_counter(conn, packet_id, "heat")
|
|
|
|
|
conn.execute(
|
2026-10-16 22:46:56 -06:00
|
|
|
"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,
|
|
|
|
|
),
|
2026-10-16 09:31:45 -06:00
|
|
|
)
|
|
|
|
|
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)
|
2026-10-16 22:46:56 -06:00
|
|
|
_not_void(conn, packet_id, row["page"])
|
2026-10-16 09:31:45 -06:00
|
|
|
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)
|
2026-10-16 22:46:56 -06:00
|
|
|
if "page" in changes:
|
|
|
|
|
_live_page(conn, packet_id, changes["page"])
|
2026-10-16 09:31:45 -06:00
|
|
|
update_row(conn, "heat_tags", row["id"], changes)
|
|
|
|
|
w.changed(*[f"heat_tag.{k}" for k in changes])
|
|
|
|
|
w.detail["tag_no"] = no
|
2026-10-16 22:46:56 -06:00
|
|
|
item = changes.get("item", row["item"])
|
|
|
|
|
w.log(f"Heat tag H{no} {'moved' if set(changes) <= POSITION else 'changed'}", f"item {item}")
|
2026-10-16 09:31:45 -06:00
|
|
|
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)
|
2026-10-16 22:46:56 -06:00
|
|
|
_not_void(conn, packet_id, row["page"])
|
2026-10-16 09:31:45 -06:00
|
|
|
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
|
2026-10-16 22:46:56 -06:00
|
|
|
w.log(f"Heat tag H{no} removed", " · ".join(x for x in (f"item {row['item']}", reason) if x))
|
2026-10-16 09:31:45 -06:00
|
|
|
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]:
|
2026-10-16 22:46:56 -06:00
|
|
|
"""Place, move or resize the heat table on a drawing page. The same placement again changes nothing."""
|
2026-10-16 09:31:45 -06:00
|
|
|
if page < 1:
|
|
|
|
|
raise ApiError(422, "invalid_page", "Pages start at 1.")
|
2026-10-16 22:46:56 -06:00
|
|
|
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}.",
|
|
|
|
|
)
|
2026-10-16 09:31:45 -06:00
|
|
|
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:
|
2026-10-16 22:46:56 -06:00
|
|
|
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.")
|
2026-10-16 09:31:45 -06:00
|
|
|
cur = packets.one(
|
2026-10-16 22:46:56 -06:00
|
|
|
conn,
|
|
|
|
|
"SELECT x, y, s, carried_from FROM heat_table_placements WHERE packet_id = %s AND page = %s",
|
|
|
|
|
(packet_id, page),
|
2026-10-16 09:31:45 -06:00
|
|
|
)
|
|
|
|
|
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)
|
2026-10-16 22:46:56 -06:00
|
|
|
carried = body.carried_from if "carried_from" in body.model_fields_set else (cur or {}).get("carried_from")
|
2026-10-16 09:31:45 -06:00
|
|
|
conn.execute(
|
2026-10-16 22:46:56 -06:00
|
|
|
"INSERT INTO heat_table_placements (packet_id, page, x, y, s, carried_from, by)"
|
|
|
|
|
" VALUES (%s, %s, %s, %s, %s, %s, %s)"
|
2026-10-16 09:31:45 -06:00
|
|
|
" ON CONFLICT (packet_id, page) DO UPDATE SET x = EXCLUDED.x, y = EXCLUDED.y, s = EXCLUDED.s,"
|
2026-10-16 22:46:56 -06:00
|
|
|
" carried_from = EXCLUDED.carried_from, by = EXCLUDED.by, at = now()",
|
|
|
|
|
(packet_id, page, body.x, body.y, body.s, carried, w.user_id),
|
2026-10-16 09:31:45 -06:00
|
|
|
)
|
|
|
|
|
w.changed("heat_table")
|
|
|
|
|
w.detail["page"] = page
|
2026-10-16 22:46:56 -06:00
|
|
|
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)}")
|
2026-10-16 09:31:45 -06:00
|
|
|
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:
|
2026-10-16 22:46:56 -06:00
|
|
|
_not_void(conn, packet_id, page)
|
2026-10-16 09:31:45 -06:00
|
|
|
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:
|
2026-10-16 22:46:56 -06:00
|
|
|
raise ApiError(404, "not_found", f"There is no heat table on sheet {page}.")
|
2026-10-16 09:31:45 -06:00
|
|
|
w.changed("heat_table")
|
|
|
|
|
w.detail["page"] = page
|
2026-10-16 22:46:56 -06:00
|
|
|
w.log("Heat table removed from drawing", _page_text(_page_row(conn, packet_id, page), page))
|
2026-10-16 09:31:45 -06:00
|
|
|
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]:
|
2026-10-16 22:46:56 -06:00
|
|
|
"""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)
|
2026-10-16 09:31:45 -06:00
|
|
|
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)
|
2026-10-16 22:46:56 -06:00
|
|
|
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)
|
2026-10-16 09:31:45 -06:00
|
|
|
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])
|
2026-10-16 22:46:56 -06:00
|
|
|
w.log("Weld record changed", _stage_note(changes))
|
2026-10-16 09:31:45 -06:00
|
|
|
return _read(d, pp, packet_id)
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 22:46:56 -06:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 09:31:45 -06:00
|
|
|
@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)
|
2026-10-16 22:46:56 -06:00
|
|
|
templates = w.children.templates if w.children is not None else None
|
|
|
|
|
if key not in checklists.checklist_keys(weld, templates):
|
2026-10-16 09:31:45 -06:00
|
|
|
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"]),
|
|
|
|
|
)
|
2026-10-16 22:46:56 -06:00
|
|
|
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)
|
2026-10-16 09:31:45 -06:00
|
|
|
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"])
|
2026-10-16 17:13:31 -06:00
|
|
|
note = clean_opt(body.note, WH_NOTE_MAX)
|
2026-10-16 09:31:45 -06:00
|
|
|
if body.stage == "hold":
|
2026-10-16 17:13:31 -06:00
|
|
|
if reason not in WH_HOLD_REASONS:
|
|
|
|
|
raise ApiError(400, "invalid_reason", "Pick one of the hold reasons.")
|
2026-10-16 09:31:45 -06:00
|
|
|
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,
|
|
|
|
|
)
|
2026-10-16 17:13:31 -06:00
|
|
|
if body.stage == "pulling" and stage in WH_PULLING_FROM:
|
|
|
|
|
label = WH_PULLING_FROM[stage]
|
|
|
|
|
else:
|
|
|
|
|
label = WH_LABELS.get(body.stage, "")
|
2026-10-16 09:31:45 -06:00
|
|
|
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.
|
|
|
|
|
|
|
|
|
|
|
2026-10-17 20:08:53 -06:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 09:31:45 -06:00
|
|
|
@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]:
|
2026-10-17 20:08:53 -06:00
|
|
|
"""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
|
2026-10-16 09:31:45 -06:00
|
|
|
values: dict[str, Any] = {}
|
2026-10-17 20:08:53 -06:00
|
|
|
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
|
2026-10-16 09:31:45 -06:00
|
|
|
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:
|
2026-10-17 20:08:53 -06:00
|
|
|
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)
|
2026-10-16 09:31:45 -06:00
|
|
|
at = conn.execute("SELECT now()").fetchone()[0].isoformat()
|
|
|
|
|
by = str(w.user_id)
|
2026-10-17 20:08:53 -06:00
|
|
|
if not cas and body.checks:
|
2026-10-16 09:31:45 -06:00
|
|
|
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)
|
2026-10-17 20:08:53 -06:00
|
|
|
if not cas and body.weld_exam:
|
2026-10-16 09:31:45 -06:00
|
|
|
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])
|
2026-10-17 20:08:53 -06:00
|
|
|
if scalar:
|
|
|
|
|
_qc_logs(w, scalar)
|
|
|
|
|
else:
|
|
|
|
|
w.log("Q.C. record changed")
|
2026-10-16 09:31:45 -06:00
|
|
|
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"
|
2026-10-16 22:46:56 -06:00
|
|
|
w.log(f"Change order {_co_no_text(no)} issued", " · ".join(x for x in (reason, ecn, verify) if x))
|
2026-10-16 09:31:45 -06:00
|
|
|
return _read(d, pp, packet_id)
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 14:45:38 -06:00
|
|
|
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
|
|
|
|
|
)
|
2026-10-16 22:46:56 -06:00
|
|
|
_carry_heat_tables(w, old, first, count)
|
2026-10-16 14:45:38 -06:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 22:46:56 -06:00
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 09:31:45 -06:00
|
|
|
@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]:
|
2026-10-16 14:45:38 -06:00
|
|
|
"""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."""
|
2026-10-16 09:31:45 -06:00
|
|
|
pp = _pp(d, p)
|
|
|
|
|
conn = d.conn
|
2026-10-16 14:45:38 -06:00
|
|
|
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
|
2026-10-16 09:31:45 -06:00
|
|
|
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")
|
2026-10-16 14:45:38 -06:00
|
|
|
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)
|
2026-10-16 09:31:45 -06:00
|
|
|
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)
|
2026-10-16 22:46:56 -06:00
|
|
|
w.log(f"Change order {_co_no_text(no)} approved", note)
|
2026-10-16 09:31:45 -06:00
|
|
|
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)
|
2026-10-16 22:46:56 -06:00
|
|
|
w.log(f"Change order {_co_no_text(no)} rejected", reason)
|
2026-10-16 09:31:45 -06:00
|
|
|
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)
|
2026-10-16 22:46:56 -06:00
|
|
|
w.log(f"Change order {_co_no_text(no)} {'holds the packet' if body.hold else 'no longer holds the packet'}")
|
2026-10-16 09:31:45 -06:00
|
|
|
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:
|
2026-10-16 22:46:56 -06:00
|
|
|
raise ApiError(422, "invalid_dept", f"Change order {_co_no_text(no)} does not need verification by {dept}.")
|
2026-10-16 09:31:45 -06:00
|
|
|
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:
|
2026-10-16 22:46:56 -06:00
|
|
|
raise ApiError(409, "already_verified", f"{dept} already verified change order {_co_no_text(no)}.")
|
2026-10-16 09:31:45 -06:00
|
|
|
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:
|
2026-10-16 22:46:56 -06:00
|
|
|
raise ApiError(
|
|
|
|
|
409, "already_verified", f"{dept} already verified change order {_co_no_text(no)}."
|
|
|
|
|
) from None
|
2026-10-16 09:31:45 -06:00
|
|
|
_co_log_detail(w, no)
|
|
|
|
|
w.detail["dept"] = dept
|
2026-10-16 22:46:56 -06:00
|
|
|
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),
|
|
|
|
|
)
|
2026-10-16 09:31:45 -06:00
|
|
|
return _read(d, pp, packet_id)
|