Phase 5: Supervisor and Stall screens, weld map, heat table, clock and CO verification
ci / frontend (push) Successful in 34s
ci / server (push) Successful in 7m15s
ci / image (push) Successful in 39s
ci / e2e (push) Successful in 2m4s

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:
2026-10-16 22:46:56 -06:00
co-authored by Claude Fable 5.1
parent 39a4eb5213
commit 5a4174e0c2
81 changed files with 11532 additions and 352 deletions
+20 -1
View File
@@ -1,4 +1,4 @@
"""Departments and their stations (read-only in phase 1)."""
"""Departments and their stations, and the people of a shop department (read-only)."""
from __future__ import annotations
@@ -6,7 +6,9 @@ from typing import Any
from fastapi import APIRouter, Depends
from ..domain import shop
from .deps import Db, Principal, current_principal, get_db
from .errors import ApiError
router = APIRouter(prefix="/api", tags=["departments"])
@@ -27,3 +29,20 @@ def departments(_: Principal = Depends(current_principal), d: Db = Depends(get_d
{"code": r[0], "slug": r[1], "name": r[2], "kind": r[3], "weld": bool(r[4]), "stations": by_dept.get(r[0], [])}
for r in rows
]
@router.get("/departments/{code}/people")
def department_people(
code: str,
_: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> list[dict[str, Any]]:
"""The active users with a shop role in a shop department: the assign sheet's welder picker. 404 for an
unknown or support department."""
conn = d.conn
found = conn.execute(
"SELECT code FROM departments WHERE code = %s AND kind = 'shop' AND active", (code[:10],)
).fetchone()
if found is None:
raise ApiError(404, "not_found", "No such shop department.")
return shop.people(conn, str(found[0]))
+8 -1
View File
@@ -16,7 +16,13 @@ SSE_HEADERS = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connecti
@router.get("/events")
async def events(request: Request, screen: str | None = Query(None, max_length=32)) -> StreamingResponse:
async def events(
request: Request,
screen: str | None = Query(None, max_length=32),
packet_id: int | None = Query(None, ge=1, le=2**62),
) -> StreamingResponse:
"""The event stream. `packet_id` is the packet the page has open; presence carries it, so the packet page can
show who else is looking at the same packet. The client reopens the stream when the open packet changes."""
# The session check runs on its own pooled connection, returned before the stream starts
p = await run_in_threadpool(resolve_standalone, request)
hub: EventHub = request.app.state.events
@@ -25,6 +31,7 @@ async def events(request: Request, screen: str | None = Query(None, max_length=3
user_id=p.user_id,
display_name=p.user["display_name"],
screen=screen if screen in SCREENS and screen in p.screens else None,
packet_id=packet_id,
)
hello = {"session_id": str(p.session_id), "build": settings_of(request).ps_build}
return StreamingResponse(
+279 -44
View File
@@ -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)
+15 -4
View File
@@ -504,7 +504,10 @@ def merge_visual_bom(conn: Any, packet_id: int, raw_lines: list[Any]) -> dict[st
When nothing has been recorded on the lines (no pulled mark, heat, MTR or short) the BOM is replaced through
feed.materials.insert_bom, exactly as the feed writes it. Otherwise lines are matched on vkey and updated in
place, so pulled marks, heats and shorts stay. New lines are added with the same encoding (item numbers from
place, so pulled marks, heats and shorts stay. A line with no vkey match falls back to the same VISUAL item label
(cl_item) and part id, as the old coKeepShop did. On a matched line the recorded work wins: heat, N/A, issued,
location, pulled, pulled_at, pulled_by, the MTR and the short are kept (heat, pulled and the MTR are never
written here). New lines are added with the same encoding (item numbers from
feed.materials.db_items, the label in cl_item, cons Y/N, sub Y or NULL). Lines VISUAL dropped are deleted, or
marked n/a when they were pulled or have a short.
"""
@@ -525,6 +528,10 @@ def merge_visual_bom(conn: Any, packet_id: int, raw_lines: list[Any]) -> dict[st
counts["removed"] = len(existing)
return counts
by_vkey = {e["vkey"]: e for e in existing if e["vkey"]}
by_label: dict[tuple[str, str], dict[str, Any]] = {}
for e in existing:
if e["cl_item"] is not None:
by_label.setdefault((str(e["cl_item"]), str(e["part_id"] or "")), e)
used = {int(e["item"]) for e in existing}
wanted = db_items(lines)
kept: set[int] = set()
@@ -544,13 +551,17 @@ def merge_visual_bom(conn: Any, packet_id: int, raw_lines: list[Any]) -> dict[st
sort,
)
match = by_vkey.get(b.vkey) if b.vkey else None
if match is None or int(match["id"]) in kept:
match = by_label.get((str(b.item), b.part_id[:60]))
if match is not None and int(match["id"]) not in kept:
kept.add(int(match["id"]))
conn.execute(
"UPDATE bom_lines SET qty = %s, part_id = %s, description = %s, loc = %s, whs = %s, comm = %s,"
" cons = %s, sub = %s, na = %s, grp = %s, issued = coalesce(%s, issued), sort = %s, cl_item = %s,"
"UPDATE bom_lines SET qty = %s, part_id = %s, description = %s,"
" loc = coalesce(nullif(loc, ''), %s), whs = %s, comm = %s, cons = coalesce(cons, %s), sub = %s,"
" na = CASE WHEN pulled OR heat IS NOT NULL OR na THEN na ELSE %s END, grp = %s,"
" issued = coalesce(issued, %s), sort = %s, cl_item = %s, vkey = coalesce(%s, vkey),"
" row_version = row_version + 1 WHERE id = %s",
(*values, b.item, match["id"]),
(*values, b.item, b.vkey or None, match["id"]),
)
counts["updated"] += 1
continue
+12 -2
View File
@@ -17,7 +17,7 @@ from pydantic import BaseModel, Field
from .. import audit, events, policy
from ..domain import assets as asset_store
from ..domain import packets
from ..domain import packets, shop
from ..domain.pipeline import (
Expect,
SignBody,
@@ -28,7 +28,7 @@ from ..domain.pipeline import (
require_if_match,
verify_seal,
)
from ..text import LIMITS, MAX_BODY_BYTES, clean_opt, clean_text, reason_ok
from ..text import LIMITS, MAX_BODY_BYTES, RX_STAMP, clean_opt, clean_text, reason_ok
from .deps import Db, Principal, current_principal, get_db, settings_of
from .errors import ApiError
@@ -534,6 +534,15 @@ def transition(
extra["dept"] = shop_dept(conn, extra["dept"])
if "for_sup_id" in extra:
extra["for_sup_id"] = active_user(conn, extra["for_sup_id"], "supervisor")
if "stamp" in extra:
# The assign sheet's stamp overrides the welder's own for this assignment; empty means use theirs.
stamp = clean_text(extra["stamp"], 40)
if stamp and not RX_STAMP.match(stamp):
raise ApiError(422, "invalid_stamp", "A stamp is up to 10 letters, digits or dashes.")
if stamp:
extra["stamp"] = stamp
else:
del extra["stamp"]
content = {"action": str(action), "packet_id": packet_id, "expect": body.expect.model_dump(), **extra}
with packet_write(
d,
@@ -556,6 +565,7 @@ def transition(
if ok is None:
raise ApiError(422, "invalid_station", "Choose a station in this department.")
active_user(conn, extra["welder_id"], "welder")
shop.welder_check(conn, w.state.dept, str(extra["welder_id"]), int(extra["station_id"]))
apply_transition(w)
return _read(d, pp, packet_id)
+49
View File
@@ -0,0 +1,49 @@
"""The shop screens: the supervisor board, the stations a person may work at, and the stall board.
Any signed-in session reads them; the board answers 403 for someone who leads no department. They read
PostgreSQL only.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query
from ..domain import packets, shop
from ..text import clean_text
from .deps import Db, Principal, current_principal, get_db
router = APIRouter(prefix="/api/shop", tags=["shop"])
@router.get("/board")
def board(
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
dept: str = Query("", max_length=10),
project_id: int | None = Query(None),
unit_id: int | None = Query(None),
) -> dict[str, Any]:
conn = d.conn
pp = packets.policy_principal_for(conn, p)
return shop.board(conn, pp, dept=clean_text(dept, 10) or None, project_id=project_id, unit_id=unit_id)
@router.get("/stations")
def stations(
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> list[dict[str, Any]]:
conn = d.conn
return shop.stations_for(conn, packets.policy_principal_for(conn, p))
@router.get("/stall")
def stall(
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
station_id: int = Query(ge=1),
) -> dict[str, Any]:
conn = d.conn
return shop.stall(conn, packets.policy_principal_for(conn, p), station_id)