Phase 5: Supervisor and Stall screens, weld map, heat table, clock and CO verification
The shop at parity with the old build. Supervisor screen (department chips, CO headline, numbers, Change orders
on hold, station grid with clocks and CO tags, boards) and Stall screen (station picker, hero, My packets,
Submitted). Shop steps on the packet page: assign with station, welder picker and stamp; pull back; Start, Pause,
Resume; Submit with a PIN signature and the missing steps as links; review with Send to Q.C., Return to station
and Skip Q.C. route; a lead's read-only review. Weld map (pdf.js viewer with pan, pinch, zoom, full screen; marks
with leader lines; weld sheet; weld record with field CAS; carried marks). Heat map (heat table placed, dragged,
sized, removed; legacy amber tags; borrowed heat map read-only). Checklist tab with templates from settings.
VOID stamps on voided pages. Change orders: CO-n labels, supervisor verify with signature, approval copies the
heat table to the new sheet and the BOM merge keeps the shop's values. Server clock of record with VISUAL ticket
hours beside it. Presence per packet over SSE. Migration 0006 (bfx, bfy, carried_from, checklist_templates).
New endpoints /api/shop/board, /api/shop/stations, /api/shop/stall, /api/departments/{code}/people.
Tests: policy oracle for every shop action, role and status; clock arithmetic; concurrent inserts; CO apply;
frontend geometry, heat table, clock, checklist, weld sheet, screens; seven Playwright scenarios. Docs updated.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -25,6 +25,7 @@ from ..domain.children import NEVER_VERIFY, active_depts, co_touched, current_wh
|
||||
from ..domain.pipeline import (
|
||||
SignBody,
|
||||
Write,
|
||||
apply_field_cas,
|
||||
check_row_version,
|
||||
jsonable,
|
||||
next_counter,
|
||||
@@ -43,10 +44,36 @@ router = APIRouter(prefix="/api/packets", tags=["packet-children"])
|
||||
|
||||
A = policy.Action
|
||||
|
||||
TEXT_SHORT = 40 # weld type, size, process, wo_base
|
||||
FILLER_MAX = 120
|
||||
TEXT_SHORT = LIMITS["weld_text"] # weld type, size, process, wo_base
|
||||
QC_FIELD_MAX = 120
|
||||
|
||||
# The weld vocabulary (the old WELD_TYPES and PROCESSES).
|
||||
WELD_TYPES = ("Fillet", "Groove", "Fill", "Seal", "Tack")
|
||||
PROCESSES = ("GMAW", "FCAW", "SMAW", "GTAW", "SAW")
|
||||
DEFAULT_SIZE = '5/16"'
|
||||
POSITION = frozenset({"fx", "fy", "bfx", "bfy"})
|
||||
# The weld fields in the order the change note lists them, with their note names.
|
||||
WELD_NOTE_NAMES = {
|
||||
"type": "type",
|
||||
"size": "size",
|
||||
"process": "process",
|
||||
"items": "items",
|
||||
"wo_base": "wo",
|
||||
"piece": "piece",
|
||||
"stamp": "stamp",
|
||||
"page": "sheet",
|
||||
}
|
||||
# The heat table bounds (the old HT_MIN, HT_MAX and the drag clamp).
|
||||
HT_XY_MAX = 0.97
|
||||
HT_S_MIN, HT_S_MAX = 0.6, 2.1
|
||||
STAGE_FIELDS = {
|
||||
"filler": LIMITS["filler"],
|
||||
"filler_lot": LIMITS["filler"],
|
||||
"preheat": LIMITS["preheat"],
|
||||
"notes": LIMITS["notes"],
|
||||
}
|
||||
STAGE_NOTE_NAMES = {"filler": "filler", "filler_lot": "filler lot", "preheat": "preheat", "notes": "notes"}
|
||||
|
||||
# The warehouse hold reasons, a closed set (the old build took any text). "Other" needs a note.
|
||||
WH_HOLD_REASONS = (
|
||||
"Waiting on material (not in stock)",
|
||||
@@ -86,6 +113,7 @@ WH_PULLING_FROM = {"hold": "Pulling resumed", "pulled": "Back to pulling"}
|
||||
MSG_NO_STAGE = "This packet has no open stage."
|
||||
MSG_NO_INSPECTION = "This packet has no open Q.C. inspection."
|
||||
MSG_SHORT_OPEN = "This line has an open short. Use the short to change heat, issued or location."
|
||||
MSG_VOID = "This sheet is VOID. Mark the current sheet."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- bodies
|
||||
@@ -145,6 +173,8 @@ class WeldBody(It):
|
||||
page: int | None = Field(default=None, ge=1, le=100_000)
|
||||
fx: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
|
||||
fy: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
|
||||
bfx: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
|
||||
bfy: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
|
||||
wo_base: str | None = Field(default=None, max_length=200)
|
||||
piece: int | None = Field(default=None, ge=1, le=100_000)
|
||||
items: list[int] = Field(min_length=1, max_length=200)
|
||||
@@ -158,6 +188,8 @@ class WeldPatchBody(It):
|
||||
page: int | None = Field(default=None, ge=1, le=100_000)
|
||||
fx: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
|
||||
fy: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
|
||||
bfx: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
|
||||
bfy: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
|
||||
wo_base: str | None = Field(default=None, max_length=200)
|
||||
piece: int | None = Field(default=None, ge=1, le=100_000)
|
||||
items: list[int] | None = Field(default=None, min_length=1, max_length=200)
|
||||
@@ -176,6 +208,8 @@ class TagBody(It):
|
||||
item: int = Field(ge=1, le=100_000)
|
||||
fx: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
|
||||
fy: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
|
||||
bfx: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
|
||||
bfy: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
|
||||
|
||||
|
||||
class TagPatchBody(It):
|
||||
@@ -183,19 +217,26 @@ class TagPatchBody(It):
|
||||
item: int | None = Field(default=None, ge=1, le=100_000)
|
||||
fx: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
|
||||
fy: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
|
||||
bfx: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
|
||||
bfy: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
|
||||
|
||||
|
||||
class HeatTableBody(It):
|
||||
x: float = Field(allow_inf_nan=False)
|
||||
y: float = Field(allow_inf_nan=False)
|
||||
s: float = Field(gt=0, le=100, allow_inf_nan=False)
|
||||
s: float = Field(allow_inf_nan=False)
|
||||
carried_from: int | None = Field(default=None, ge=1, le=100_000)
|
||||
|
||||
|
||||
class StageBody(It):
|
||||
"""The flat body (with If-Match on the stage row), or `{set, base}` for field-level compare-and-set."""
|
||||
|
||||
filler: str | None = Field(default=None, max_length=500)
|
||||
filler_lot: str | None = Field(default=None, max_length=500)
|
||||
preheat: str | None = Field(default=None, max_length=500)
|
||||
notes: str | None = Field(default=None, max_length=5000)
|
||||
set: dict[str, str | None] | None = Field(default=None, max_length=10)
|
||||
base: dict[str, str | None] | None = Field(default=None, max_length=10)
|
||||
|
||||
|
||||
class ChecklistBody(It):
|
||||
@@ -420,6 +461,51 @@ def _tag(conn: Any, packet_id: int, no: int) -> dict[str, Any]:
|
||||
return row
|
||||
|
||||
|
||||
def _page_row(conn: Any, packet_id: int, page: int) -> dict[str, Any] | None:
|
||||
return packets.one(
|
||||
conn, "SELECT n, type, dwg, rev, void_at FROM packet_pages WHERE packet_id = %s AND n = %s", (packet_id, page)
|
||||
)
|
||||
|
||||
|
||||
def _drawing_page(conn: Any, packet_id: int, page: int) -> dict[str, Any]:
|
||||
"""A drawing page of the packet, live or VOID. 422 invalid_page otherwise."""
|
||||
pg = _page_row(conn, packet_id, page)
|
||||
if pg is None or pg["type"] != "drawing":
|
||||
raise ApiError(422, "invalid_page", f"There is no drawing sheet {page} on this packet.")
|
||||
return pg
|
||||
|
||||
|
||||
def _live_page(conn: Any, packet_id: int, page: int | None) -> None:
|
||||
"""A mark goes on a live drawing page. 422 void_page on a VOID sheet; nothing is checked without a page."""
|
||||
if page is None:
|
||||
return
|
||||
if _drawing_page(conn, packet_id, page)["void_at"] is not None:
|
||||
raise ApiError(422, "void_page", MSG_VOID)
|
||||
|
||||
|
||||
def _not_void(conn: Any, packet_id: int, page: int | None) -> None:
|
||||
"""An existing mark on a VOID sheet is never changed."""
|
||||
if page is None:
|
||||
return
|
||||
pg = _page_row(conn, packet_id, page)
|
||||
if pg is not None and pg["void_at"] is not None:
|
||||
raise ApiError(422, "void_page", MSG_VOID)
|
||||
|
||||
|
||||
def _page_text(pg: dict[str, Any] | None, page: int) -> str:
|
||||
"""A sheet in a log note: its drawing number, else "sheet 2"."""
|
||||
dwg = (pg or {}).get("dwg")
|
||||
return str(dwg) if dwg else f"sheet {page}"
|
||||
|
||||
|
||||
def _num_text(value: Any) -> str:
|
||||
return f"{round(float(value), 2):g}"
|
||||
|
||||
|
||||
def _co_no_text(no: int) -> str:
|
||||
return packets.co_label(no)
|
||||
|
||||
|
||||
def _co(conn: Any, packet_id: int, no: int) -> dict[str, Any]:
|
||||
row = packets.one(
|
||||
conn,
|
||||
@@ -428,7 +514,7 @@ def _co(conn: Any, packet_id: int, no: int) -> dict[str, Any]:
|
||||
(packet_id, no),
|
||||
)
|
||||
if row is None:
|
||||
raise ApiError(404, "not_found", f"There is no change order {no} on this packet.")
|
||||
raise ApiError(404, "not_found", f"There is no change order {_co_no_text(no)} on this packet.")
|
||||
return row
|
||||
|
||||
|
||||
@@ -437,7 +523,7 @@ def _co_state(row: dict[str, Any], want: str, what: str) -> None:
|
||||
raise ApiError(
|
||||
409,
|
||||
"co_state",
|
||||
f"Change order {row['co_no']} is {row['review']}. Only a {want} change order can be {what}.",
|
||||
f"Change order {_co_no_text(row['co_no'])} is {row['review']}. Only a {want} change order can be {what}.",
|
||||
extra={"current": {"co_no": row["co_no"], "review": row["review"], "hold": row["hold"]}},
|
||||
)
|
||||
|
||||
@@ -785,12 +871,22 @@ def receive_short(
|
||||
|
||||
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"):
|
||||
for k in ("page", "fx", "fy", "bfx", "bfy", "piece"):
|
||||
if k in given:
|
||||
values[k] = getattr(body, k)
|
||||
for k in ("wo_base", "type", "size", "process"):
|
||||
if k in given:
|
||||
values[k] = clean_opt(getattr(body, k), TEXT_SHORT)
|
||||
if values.get("type") is not None and values["type"] not in WELD_TYPES:
|
||||
raise ApiError(422, "invalid_weld", "Pick a weld type from the list.")
|
||||
if values.get("process") is not None and values["process"] not in PROCESSES:
|
||||
raise ApiError(422, "invalid_weld", "Pick a process from the list.")
|
||||
if values.get("wo_base") is not None:
|
||||
bases = {
|
||||
r[0] for r in conn.execute("SELECT wo_base FROM packet_work_orders WHERE packet_id = %s", (packet_id,))
|
||||
}
|
||||
if values["wo_base"] not in bases:
|
||||
raise ApiError(422, "invalid_wo", "Pick one of this packet's work orders.")
|
||||
if "stamp" in given:
|
||||
values["stamp"] = _stamp(body.stamp)
|
||||
if "items" in given:
|
||||
@@ -800,6 +896,33 @@ def _weld_values(conn: Any, packet_id: int, body: WeldBody | WeldPatchBody, give
|
||||
return values
|
||||
|
||||
|
||||
def _items_text(items: Any) -> str:
|
||||
return "items " + " + ".join(map(str, items or []))
|
||||
|
||||
|
||||
def _weld_note(values: dict[str, Any]) -> str:
|
||||
"""The note of a new weld: Fillet 5/16" · items 1 + 2 · L662001 #1."""
|
||||
desc = " ".join(str(x) for x in (values.get("type"), values.get("size")) if x)
|
||||
wo = values.get("wo_base")
|
||||
if wo and values.get("piece") is not None:
|
||||
wo = f"{wo} #{values['piece']}"
|
||||
return " · ".join(x for x in (desc, _items_text(values["items"]), wo) if x)
|
||||
|
||||
|
||||
def _change_note(changes: dict[str, Any]) -> str | None:
|
||||
"""The changed weld fields: type Groove · items 1 + 3."""
|
||||
parts: list[str] = []
|
||||
for k, name in WELD_NOTE_NAMES.items():
|
||||
if k not in changes:
|
||||
continue
|
||||
v = changes[k]
|
||||
if k == "items":
|
||||
parts.append(_items_text(v))
|
||||
else:
|
||||
parts.append(f"{name} {v}" if v not in (None, "") else f"{name} cleared")
|
||||
return " · ".join(parts) or None
|
||||
|
||||
|
||||
@router.post("/{packet_id}/welds", dependencies=WRITE)
|
||||
def add_weld(
|
||||
packet_id: int,
|
||||
@@ -816,6 +939,9 @@ def add_weld(
|
||||
d, p, packet_id, A.EDIT_WELDS, request=request, it_reason=body.it_reason, policy_principal=pp
|
||||
) as w:
|
||||
values = _weld_values(conn, packet_id, body, given)
|
||||
_live_page(conn, packet_id, values.get("page"))
|
||||
if values.get("size") is None:
|
||||
values["size"] = DEFAULT_SIZE
|
||||
dept = str(w.packet["dept_code"])
|
||||
worker = pp.level(dept) == 1
|
||||
welder_id = w.user_id if worker else w.packet["welder_id"]
|
||||
@@ -824,15 +950,17 @@ def add_weld(
|
||||
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)",
|
||||
"INSERT INTO welds (packet_id, weld_no, page, fx, fy, bfx, bfy, wo_base, piece, items, type, size, process,"
|
||||
" stamp, welder_id, dept_code, stage_id, by) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,"
|
||||
" %s, %s, %s, %s, %s)",
|
||||
(
|
||||
packet_id,
|
||||
no,
|
||||
values.get("page"),
|
||||
values.get("fx"),
|
||||
values.get("fy"),
|
||||
values.get("bfx"),
|
||||
values.get("bfy"),
|
||||
values.get("wo_base"),
|
||||
values.get("piece"),
|
||||
values["items"],
|
||||
@@ -848,8 +976,7 @@ def add_weld(
|
||||
)
|
||||
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))
|
||||
w.log(f"Weld W{no} marked", _weld_note(values))
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
@@ -870,6 +997,7 @@ def patch_weld(
|
||||
) as w:
|
||||
row = _weld(conn, packet_id, no)
|
||||
check_row_version(row, if_match)
|
||||
_not_void(conn, packet_id, row["page"])
|
||||
values = _weld_values(conn, packet_id, body, set(body.model_fields_set))
|
||||
changes = _diff(row, values)
|
||||
if "items" in changes and sorted(row["items"] or []) == changes["items"]:
|
||||
@@ -877,10 +1005,15 @@ def patch_weld(
|
||||
if not changes:
|
||||
w.skip()
|
||||
return _read(d, pp, packet_id)
|
||||
if "page" in changes:
|
||||
_live_page(conn, packet_id, changes["page"])
|
||||
update_row(conn, "welds", row["id"], changes)
|
||||
w.changed(*[f"weld.{k}" for k in changes])
|
||||
w.detail["weld_no"] = no
|
||||
w.log(f"Edited W{no}")
|
||||
if set(changes) <= POSITION:
|
||||
w.log(f"Weld W{no} moved")
|
||||
else:
|
||||
w.log(f"Weld W{no} changed", _change_note(changes))
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
@@ -905,6 +1038,7 @@ def remove_weld(
|
||||
row = _weld(conn, packet_id, no)
|
||||
if if_match is not None:
|
||||
check_row_version(row, if_match)
|
||||
_not_void(conn, packet_id, row["page"])
|
||||
conn.execute(
|
||||
"UPDATE welds SET removed_at = now(), removed_by = %s, removed_reason = %s, row_version = row_version + 1"
|
||||
" WHERE id = %s",
|
||||
@@ -912,7 +1046,7 @@ def remove_weld(
|
||||
)
|
||||
w.changed("welds")
|
||||
w.detail["weld_no"] = no
|
||||
w.log(f"Removed W{no}", reason)
|
||||
w.log(f"Weld W{no} removed", reason)
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
@@ -920,7 +1054,7 @@ def remove_weld(
|
||||
|
||||
|
||||
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}
|
||||
values: dict[str, Any] = {k: getattr(body, k) for k in ("page", "fx", "fy", "bfx", "bfy") if k in given}
|
||||
if "item" in given:
|
||||
if body.item is None:
|
||||
raise ApiError(422, "invalid_items", "Pick the BOM item this tag marks.")
|
||||
@@ -942,10 +1076,22 @@ def add_tag(
|
||||
d, p, packet_id, A.EDIT_HEATS, request=request, it_reason=body.it_reason, policy_principal=pp
|
||||
) as w:
|
||||
values = _tag_values(conn, packet_id, body, set(body.model_fields_set) | {"item"})
|
||||
_live_page(conn, packet_id, values.get("page"))
|
||||
no = next_counter(conn, packet_id, "heat")
|
||||
conn.execute(
|
||||
"INSERT INTO heat_tags (packet_id, tag_no, page, item, fx, fy, 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),
|
||||
"INSERT INTO heat_tags (packet_id, tag_no, page, item, fx, fy, bfx, bfy, by)"
|
||||
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)",
|
||||
(
|
||||
packet_id,
|
||||
no,
|
||||
values.get("page"),
|
||||
values["item"],
|
||||
values.get("fx"),
|
||||
values.get("fy"),
|
||||
values.get("bfx"),
|
||||
values.get("bfy"),
|
||||
w.user_id,
|
||||
),
|
||||
)
|
||||
w.changed("heat_tags")
|
||||
w.detail["tag_no"] = no
|
||||
@@ -970,14 +1116,18 @@ def patch_tag(
|
||||
) as w:
|
||||
row = _tag(conn, packet_id, no)
|
||||
check_row_version(row, if_match)
|
||||
_not_void(conn, packet_id, row["page"])
|
||||
changes = _diff(row, _tag_values(conn, packet_id, body, set(body.model_fields_set)))
|
||||
if not changes:
|
||||
w.skip()
|
||||
return _read(d, pp, packet_id)
|
||||
if "page" in changes:
|
||||
_live_page(conn, packet_id, changes["page"])
|
||||
update_row(conn, "heat_tags", row["id"], changes)
|
||||
w.changed(*[f"heat_tag.{k}" for k in changes])
|
||||
w.detail["tag_no"] = no
|
||||
w.log(f"Heat tag H{no} changed", f"item {changes['item']}" if "item" in changes else None)
|
||||
item = changes.get("item", row["item"])
|
||||
w.log(f"Heat tag H{no} {'moved' if set(changes) <= POSITION else 'changed'}", f"item {item}")
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
@@ -1001,6 +1151,7 @@ def remove_tag(
|
||||
row = _tag(conn, packet_id, no)
|
||||
if if_match is not None:
|
||||
check_row_version(row, if_match)
|
||||
_not_void(conn, packet_id, row["page"])
|
||||
conn.execute(
|
||||
"UPDATE heat_tags SET removed_at = now(), removed_by = %s, removed_reason = %s,"
|
||||
" row_version = row_version + 1 WHERE id = %s",
|
||||
@@ -1008,7 +1159,7 @@ def remove_tag(
|
||||
)
|
||||
w.changed("heat_tags")
|
||||
w.detail["tag_no"] = no
|
||||
w.log(f"Heat tag H{no} removed", reason)
|
||||
w.log(f"Heat tag H{no} removed", " · ".join(x for x in (f"item {row['item']}", reason) if x))
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
@@ -1021,29 +1172,50 @@ def put_heat_table(
|
||||
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."""
|
||||
"""Place, move or resize the heat table on a drawing page. The same placement again changes nothing."""
|
||||
if page < 1:
|
||||
raise ApiError(422, "invalid_page", "Pages start at 1.")
|
||||
if not (0 <= body.x <= HT_XY_MAX and 0 <= body.y <= HT_XY_MAX and HT_S_MIN <= body.s <= HT_S_MAX):
|
||||
raise ApiError(
|
||||
422,
|
||||
"invalid_heat_table",
|
||||
f"Keep the heat table on the sheet: x and y from 0 to {HT_XY_MAX}, size from {HT_S_MIN} to {HT_S_MAX}.",
|
||||
)
|
||||
pp = _pp(d, p)
|
||||
conn = d.conn
|
||||
with packet_write(
|
||||
d, p, packet_id, A.EDIT_HEAT_TABLE, request=request, it_reason=body.it_reason, policy_principal=pp
|
||||
) as w:
|
||||
pg = _drawing_page(conn, packet_id, page)
|
||||
if pg["void_at"] is not None:
|
||||
raise ApiError(422, "void_page", MSG_VOID)
|
||||
if body.carried_from is not None and _page_row(conn, packet_id, body.carried_from) is None:
|
||||
raise ApiError(422, "invalid_page", f"There is no sheet {body.carried_from} on this packet.")
|
||||
cur = packets.one(
|
||||
conn, "SELECT x, y, s FROM heat_table_placements WHERE packet_id = %s AND page = %s", (packet_id, page)
|
||||
conn,
|
||||
"SELECT x, y, s, carried_from FROM heat_table_placements WHERE packet_id = %s AND page = %s",
|
||||
(packet_id, page),
|
||||
)
|
||||
if cur is not None and (cur["x"], cur["y"], cur["s"]) == (body.x, body.y, body.s):
|
||||
w.skip()
|
||||
return _read(d, pp, packet_id)
|
||||
carried = body.carried_from if "carried_from" in body.model_fields_set else (cur or {}).get("carried_from")
|
||||
conn.execute(
|
||||
"INSERT INTO heat_table_placements (packet_id, page, x, y, s, by) VALUES (%s, %s, %s, %s, %s, %s)"
|
||||
"INSERT INTO heat_table_placements (packet_id, page, x, y, s, carried_from, by)"
|
||||
" VALUES (%s, %s, %s, %s, %s, %s, %s)"
|
||||
" ON CONFLICT (packet_id, page) DO UPDATE SET x = EXCLUDED.x, y = EXCLUDED.y, s = EXCLUDED.s,"
|
||||
" by = EXCLUDED.by, at = now()",
|
||||
(packet_id, page, body.x, body.y, body.s, w.user_id),
|
||||
" carried_from = EXCLUDED.carried_from, by = EXCLUDED.by, at = now()",
|
||||
(packet_id, page, body.x, body.y, body.s, carried, w.user_id),
|
||||
)
|
||||
w.changed("heat_table")
|
||||
w.detail["page"] = page
|
||||
w.log(f"Heat table placed on page {page}")
|
||||
where = _page_text(pg, page)
|
||||
if cur is None:
|
||||
w.log("Heat table placed on drawing", where)
|
||||
elif (cur["x"], cur["y"]) != (body.x, body.y):
|
||||
w.log("Heat table moved", where)
|
||||
else:
|
||||
w.log("Heat table resized", f"{'A+' if body.s > cur['s'] else 'A-'} {_num_text(body.s)}")
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
@@ -1062,14 +1234,15 @@ def remove_heat_table(
|
||||
with packet_write(
|
||||
d, p, packet_id, A.EDIT_HEAT_TABLE, request=request, it_reason=body.it_reason, policy_principal=pp
|
||||
) as w:
|
||||
_not_void(conn, packet_id, page)
|
||||
gone = conn.execute(
|
||||
"DELETE FROM heat_table_placements WHERE packet_id = %s AND page = %s RETURNING id", (packet_id, page)
|
||||
).fetchone()
|
||||
if gone is None:
|
||||
raise ApiError(404, "not_found", f"There is no heat table on page {page}.")
|
||||
raise ApiError(404, "not_found", f"There is no heat table on sheet {page}.")
|
||||
w.changed("heat_table")
|
||||
w.detail["page"] = page
|
||||
w.log(f"Heat table removed from page {page}")
|
||||
w.log("Heat table removed from drawing", _page_text(_page_row(conn, packet_id, page), page))
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
@@ -1084,27 +1257,58 @@ def patch_stage(
|
||||
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}
|
||||
"""Filler, filler lot, preheat and notes of the open stage (the weld record).
|
||||
|
||||
Either the flat body with If-Match (the stage's row_version), or `{set, base}`: field-level compare-and-set,
|
||||
409 field_conflict with `current` for a field someone else changed since `base`.
|
||||
"""
|
||||
cas = body.set is not None
|
||||
if cas:
|
||||
assert body.set is not None
|
||||
bad = sorted(k for k in body.set if k not in STAGE_FIELDS)
|
||||
if bad:
|
||||
raise ApiError(422, "invalid_field", f"Not a weld record field: {', '.join(bad)}.")
|
||||
values = {k: clean_opt(v, STAGE_FIELDS[k]) for k, v in body.set.items()}
|
||||
base = {k: clean_opt(v, STAGE_FIELDS[k]) for k, v in (body.base or {}).items() if k in STAGE_FIELDS}
|
||||
if_match = None
|
||||
else:
|
||||
values = {k: clean_opt(getattr(body, k), n) for k, n in STAGE_FIELDS.items() if k in body.model_fields_set}
|
||||
base = {}
|
||||
if_match = parse_if_match(request)
|
||||
pp = _pp(d, p)
|
||||
conn = d.conn
|
||||
with packet_write(
|
||||
d, p, packet_id, A.EDIT_STAGE_FIELDS, request=request, it_reason=body.it_reason, policy_principal=pp
|
||||
) as w:
|
||||
stage = _need_stage(conn, packet_id)
|
||||
check_row_version(stage, if_match)
|
||||
changes = _diff(stage, values)
|
||||
if cas:
|
||||
changes = apply_field_cas({k: stage[k] for k in STAGE_FIELDS}, values, base)
|
||||
else:
|
||||
check_row_version(stage, if_match)
|
||||
changes = _diff(stage, values)
|
||||
if not changes:
|
||||
w.skip()
|
||||
return _read(d, pp, packet_id)
|
||||
update_row(conn, "packet_stages", stage["id"], changes)
|
||||
w.changed(*[f"stage.{k}" for k in changes])
|
||||
w.log("Stage details changed", f"filler {changes['filler']}" if changes.get("filler") else None)
|
||||
w.log("Weld record changed", _stage_note(changes))
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
def _stage_note(changes: dict[str, Any]) -> str | None:
|
||||
"""The changed weld record fields: filler ER70S-6 · filler lot 1234."""
|
||||
parts: list[str] = []
|
||||
for k, name in STAGE_NOTE_NAMES.items():
|
||||
if k not in changes:
|
||||
continue
|
||||
v = changes[k]
|
||||
if k == "notes":
|
||||
parts.append("notes changed" if v else "notes cleared")
|
||||
else:
|
||||
parts.append(f"{name} {v}" if v else f"{name} cleared")
|
||||
return " · ".join(parts) or None
|
||||
|
||||
|
||||
@router.put("/{packet_id}/stage/checklist/{key}", dependencies=WRITE)
|
||||
def put_checklist(
|
||||
packet_id: int,
|
||||
@@ -1121,7 +1325,8 @@ def put_checklist(
|
||||
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):
|
||||
templates = w.children.templates if w.children is not None else None
|
||||
if key not in checklists.checklist_keys(weld, templates):
|
||||
raise ApiError(422, "invalid_key", "That is not an item on this department's checklist.")
|
||||
stage = _need_stage(conn, packet_id)
|
||||
cur = (stage["checklist"] or {}).get(key)
|
||||
@@ -1140,7 +1345,8 @@ def put_checklist(
|
||||
"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)
|
||||
items = checklists.checklist_for(weld, w.packet["drawing"], w.packet["rev"], w.packet["wps"], templates)
|
||||
text = next((i["text"] for i in items if i["key"] == key), key)
|
||||
w.changed(f"checklist.{key}")
|
||||
w.log(("Checked: " if body.done else "Unchecked: ") + text)
|
||||
return _read(d, pp, packet_id)
|
||||
@@ -1453,7 +1659,7 @@ def issue_co(
|
||||
_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))
|
||||
w.log(f"Change order {_co_no_text(no)} issued", " · ".join(x for x in (reason, ecn, verify) if x))
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
@@ -1605,6 +1811,7 @@ def _apply_feed_co(w: Write, co: dict[str, Any]) -> str | None:
|
||||
first, count, old = pv.replace_drawing_pages(
|
||||
conn, pid, asset, dwg=dwg, rev=rev, co_id=int(co["id"]), void_by=w.user_id
|
||||
)
|
||||
_carry_heat_tables(w, old, first, count)
|
||||
for i in range(max(len(old), count)):
|
||||
o = old[i] if i < len(old) else None
|
||||
new = i < count
|
||||
@@ -1642,6 +1849,26 @@ def _apply_feed_co(w: Write, co: dict[str, Any]) -> str | None:
|
||||
return "; ".join(notes) or None
|
||||
|
||||
|
||||
def _carry_heat_tables(w: Write, old: list[dict[str, Any]], first: int, count: int) -> None:
|
||||
"""Copy each heat table placement from a voided sheet to the sheet that replaces it (the old htCarry), with
|
||||
carried_from set to the old sheet. Welds and heat tags stay on the VOID sheet."""
|
||||
for i, o in enumerate(old):
|
||||
new_n = first + i if i < count else first
|
||||
cur = packets.one(
|
||||
w.conn, "SELECT x, y, s FROM heat_table_placements WHERE packet_id = %s AND page = %s", (w.id, o["n"])
|
||||
)
|
||||
if cur is None:
|
||||
continue
|
||||
done = w.conn.execute(
|
||||
"INSERT INTO heat_table_placements (packet_id, page, x, y, s, carried_from, by)"
|
||||
" VALUES (%s, %s, %s, %s, %s, %s, %s) ON CONFLICT (packet_id, page) DO NOTHING RETURNING id",
|
||||
(w.id, new_n, cur["x"], cur["y"], cur["s"], o["n"], w.user_id),
|
||||
).fetchone()
|
||||
if done is not None:
|
||||
w.changed("heat_table")
|
||||
w.log_also("Heat table copied to new sheet", "from the VOID copy, check its position on the new revision")
|
||||
|
||||
|
||||
@router.post("/{packet_id}/change-orders/{no}/approve", dependencies=WRITE)
|
||||
def approve_co(
|
||||
packet_id: int,
|
||||
@@ -1699,7 +1926,7 @@ def approve_co(
|
||||
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)
|
||||
w.log(f"Change order {_co_no_text(no)} approved", note)
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
@@ -1743,7 +1970,7 @@ def reject_co(
|
||||
},
|
||||
)
|
||||
_co_log_detail(w, no)
|
||||
w.log(f"Change order {no} rejected", reason)
|
||||
w.log(f"Change order {_co_no_text(no)} rejected", reason)
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
@@ -1775,7 +2002,7 @@ def hold_co(
|
||||
{"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'}")
|
||||
w.log(f"Change order {_co_no_text(no)} {'holds the packet' if body.hold else 'no longer holds the packet'}")
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
|
||||
@@ -1811,12 +2038,12 @@ def verify_co(
|
||||
_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}.")
|
||||
raise ApiError(422, "invalid_dept", f"Change order {_co_no_text(no)} does not need verification by {dept}.")
|
||||
done = conn.execute(
|
||||
"SELECT 1 FROM co_verifications WHERE co_id = %s AND dept_code = %s", (co["id"], dept)
|
||||
).fetchone()
|
||||
if done is not None:
|
||||
raise ApiError(409, "already_verified", f"{dept} already verified change order {no}.")
|
||||
raise ApiError(409, "already_verified", f"{dept} already verified change order {_co_no_text(no)}.")
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO co_verifications (co_id, dept_code, by, note, signature_id, it_reason)"
|
||||
@@ -1824,9 +2051,17 @@ def verify_co(
|
||||
(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
|
||||
raise ApiError(
|
||||
409, "already_verified", f"{dept} already verified change order {_co_no_text(no)}."
|
||||
) from None
|
||||
_co_log_detail(w, no)
|
||||
w.detail["dept"] = dept
|
||||
name = packets.dept_names(conn).get(dept, dept)
|
||||
w.log(f"Change order {no} verified for {name}", note)
|
||||
names = packets.dept_names(conn)
|
||||
verified = {r[0] for r in conn.execute("SELECT dept_code FROM co_verifications WHERE co_id = %s", (co["id"],))}
|
||||
left = [names.get(c, c) for c in sorted(required) if c not in verified]
|
||||
tail = f"still waiting on {', '.join(left)}" if left else "change order fully verified"
|
||||
w.log(
|
||||
f"Change order {_co_no_text(no)} verified by {names.get(dept, dept)}",
|
||||
" · ".join(x for x in (note, tail) if x),
|
||||
)
|
||||
return _read(d, pp, packet_id)
|
||||
|
||||
Reference in New Issue
Block a user