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