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)
+99 -28
View File
@@ -1,14 +1,19 @@
"""The department checklists and the Q.C. checks, ported from the old checklistFor(), qcChecksFor() and qcMissing().
Weld departments get the welding list, the others the plain list. The drawing, rev and WPS are filled into the text.
The lists live in the settings row `checklist_templates` (migration 0006); the defaults here are the fallback.
The keys are the old keys, so a checklist stored as {key: {done, at, by, stamp}} reads the same.
"""
from __future__ import annotations
import logging
import re
from collections.abc import Iterable, Mapping
from typing import Any
log = logging.getLogger(__name__)
NO_REV = "-"
NDE_NONE = ("", "none", "none required")
NDE_OK = ("acceptable", "accept", "accepted")
@@ -19,31 +24,95 @@ def _item(group: str, key: str, text: str) -> dict[str, str]:
return {"group": group, "key": key, "text": text}
def checklist_for(weld: bool, drawing: str | None, rev: str | None, wps: str | None) -> list[dict[str, str]]:
DWG_TEXT = "Drawing {drawing} Rev {rev} matches the packet"
# The defaults. The settings row `checklist_templates` holds the same lists (migration 0006); these are the fallback
# when that row is missing or does not parse.
DEFAULT_TEMPLATES: dict[str, list[dict[str, str]]] = {
"weld": [
_item("Before welding", "dwg", DWG_TEXT),
_item("Before welding", "heat", "Heat numbers on material match the Materials tab"),
_item("Before welding", "wps", "WPS reviewed: {wps}"),
_item("Before welding", "fit", "Fit-up and dimensions checked against the drawing"),
_item("Before welding", "pre", "Preheat and interpass temperature per WPS"),
_item("After welding", "vt", "Visual inspection of all welds acceptable"),
_item("After welding", "stamp", "Welds stamped with welder ID"),
_item("After welding", "clean", "Slag and spatter removed"),
_item("After welding", "qty", "Completed quantity matches each work order"),
],
"plain": [
_item("Before starting", "dwg", DWG_TEXT),
_item("Before starting", "prev", "Previous department's work complete and released"),
_item("Before starting", "mat", "Material and parts on hand match the packet"),
_item("When done", "work", "Work completed per drawing and procedure"),
_item("When done", "insp", "Self-inspection done; no damage to finished surfaces"),
_item("When done", "qty", "Completed quantity matches each work order"),
],
}
SETTINGS_KEY = "checklist_templates"
RX_KEY = re.compile(r"^[a-z][a-z0-9_]{0,29}$")
RX_PLACEHOLDER = re.compile(r"\{(drawing|rev|wps)\}")
TEXT_MAX = 200
ITEMS_MAX = 40
def _valid_list(value: Any) -> list[dict[str, str]] | None:
"""A template list from settings, or None when it does not have the right shape."""
if not isinstance(value, list) or not value or len(value) > ITEMS_MAX:
return None
out: list[dict[str, str]] = []
seen: set[str] = set()
for it in value:
if not isinstance(it, Mapping):
return None
group, key, text = it.get("group"), it.get("key"), it.get("text")
if not (isinstance(group, str) and isinstance(key, str) and isinstance(text, str)):
return None
if not RX_KEY.match(key) or key in seen or not group.strip() or not text.strip():
return None
seen.add(key)
out.append(_item(group.strip()[:TEXT_MAX], key, text.strip()[:TEXT_MAX]))
return out
def parse_templates(value: Any) -> dict[str, list[dict[str, str]]] | None:
"""The `checklist_templates` settings value, checked. None when it is malformed."""
if not isinstance(value, Mapping):
return None
weld, plain = _valid_list(value.get("weld")), _valid_list(value.get("plain"))
if weld is None or plain is None:
return None
return {"weld": weld, "plain": plain}
def load_templates(conn: Any) -> dict[str, list[dict[str, str]]]:
"""The department checklists from settings, else the defaults in code (with a warning when the row is bad)."""
row = conn.execute("SELECT value FROM settings WHERE key = %s", (SETTINGS_KEY,)).fetchone()
if row is None:
return DEFAULT_TEMPLATES
parsed = parse_templates(row[0])
if parsed is None:
log.warning("settings %s does not parse; using the default checklists", SETTINGS_KEY)
return DEFAULT_TEMPLATES
return parsed
def _fill(text: str, drawing: str | None, rev: str | None, wps: str | None) -> str:
values = {"drawing": drawing or "", "rev": rev or NO_REV, "wps": wps or "per drawing"}
return " ".join(RX_PLACEHOLDER.sub(lambda m: values[m.group(1)], text).split())
def checklist_for(
weld: bool,
drawing: str | None,
rev: str | None,
wps: str | None,
templates: Mapping[str, list[dict[str, str]]] | None = None,
) -> list[dict[str, str]]:
"""The stage checklist for a department, as a flat list of {group, key, text} in display order."""
dwg = f"Drawing {drawing or ''} Rev {rev or NO_REV} matches the packet".replace(" ", " ")
if not weld:
g1, g2 = "Before starting", "When done"
return [
_item(g1, "dwg", dwg),
_item(g1, "prev", "Previous department's work complete and released"),
_item(g1, "mat", "Material and parts on hand match the packet"),
_item(g2, "work", "Work completed per drawing and procedure"),
_item(g2, "insp", "Self-inspection done; no damage to finished surfaces"),
_item(g2, "qty", "Completed quantity matches each work order"),
]
g1, g2 = "Before welding", "After welding"
return [
_item(g1, "dwg", dwg),
_item(g1, "heat", "Heat numbers on material match the Materials tab"),
_item(g1, "wps", f"WPS reviewed: {wps or 'per drawing'}"),
_item(g1, "fit", "Fit-up and dimensions checked against the drawing"),
_item(g1, "pre", "Preheat and interpass temperature per WPS"),
_item(g2, "vt", "Visual inspection of all welds acceptable"),
_item(g2, "stamp", "Welds stamped with welder ID"),
_item(g2, "clean", "Slag and spatter removed"),
_item(g2, "qty", "Completed quantity matches each work order"),
]
t = templates or DEFAULT_TEMPLATES
items = t.get("weld" if weld else "plain") or DEFAULT_TEMPLATES["weld" if weld else "plain"]
return [_item(i["group"], i["key"], _fill(i["text"], drawing, rev, wps)) for i in items]
def qc_items_for(
@@ -66,8 +135,8 @@ def qc_items_for(
]
def checklist_keys(weld: bool) -> list[str]:
return [i["key"] for i in checklist_for(weld, None, None, None)]
def checklist_keys(weld: bool, templates: Mapping[str, list[dict[str, str]]] | None = None) -> list[str]:
return [i["key"] for i in checklist_for(weld, None, None, None, templates)]
def qc_keys() -> list[str]:
@@ -81,10 +150,12 @@ def is_done(value: Any) -> bool:
return isinstance(value, Mapping) and value.get("done") is True
def checklist_complete(checklist: Mapping[str, Any] | None, weld: bool) -> bool:
def checklist_complete(
checklist: Mapping[str, Any] | None, weld: bool, templates: Mapping[str, list[dict[str, str]]] | None = None
) -> bool:
"""Every key of the department checklist is done. An empty list counts as complete."""
ck = checklist or {}
return all(is_done(ck.get(k)) for k in checklist_keys(weld))
return all(is_done(ck.get(k)) for k in checklist_keys(weld, templates))
def _plural(n: int, word: str) -> str:
+279 -46
View File
@@ -39,6 +39,10 @@ SEAL_EXCLUDE = (
"qc_items",
"flag",
"visual_state",
"server_now",
"target_hrs",
"labor",
"heat_src",
)
@@ -173,14 +177,24 @@ def policy_principal_for(conn: Any, p: deps.Principal) -> policy.Principal:
return policy.principal_from_roles(str(p.user_id), p.roles, station_limits=limits, session_kind=p.kind)
def co_hold_why(open_cos: Iterable[Mapping[str, Any]], dept_names: Mapping[str, str]) -> str:
def co_label(co_no: Any) -> str:
"""The change order id shown everywhere: CO-2."""
return f"CO-{co_no}"
def co_hold_why(open_cos: Iterable[Mapping[str, Any]], labels: Mapping[str, str]) -> str:
"""The CO hold text, one sentence per open change order. `labels` maps a department code to its display name
(dept_labels: "03 Weld Parts", "Warehouse")."""
parts: list[str] = []
for co in open_cos:
label = co_label(co["co_no"])
if str(co.get("review")) == "pending":
parts.append(f"Change order {co['co_no']} is on hold.")
parts.append(
f"{label}: Clerical is reviewing a change from VISUAL. Held until Clerical approves or rejects it."
)
else:
names = ", ".join(dept_names.get(c, c) for c in co.get("pending_depts") or [])
parts.append(f"Change order {co['co_no']} is waiting for verification by {names}.")
names = ", ".join(labels.get(c, c) for c in co.get("pending_depts") or [])
parts.append(f"Change order {label} must be verified first by: {names}.")
return " ".join(parts)
@@ -188,6 +202,17 @@ def dept_names(conn: Any) -> dict[str, str]:
return {r[0]: r[1] for r in conn.execute("SELECT code, name FROM departments")}
def dept_label(code: str, name: str | None, kind: str | None) -> str:
"""A department in texts: a shop department as code and name ("03 Weld Parts"), a support one by name."""
if kind == "shop":
return f"{code} {name or ''}".strip()
return name or code
def dept_labels(conn: Any) -> dict[str, str]:
return {r[0]: dept_label(r[0], r[1], r[2]) for r in conn.execute("SELECT code, name, kind::text FROM departments")}
# ---------------------------------------------------------------- the child rows
@@ -215,6 +240,8 @@ class Children:
docs: list[dict[str, Any]] = field(default_factory=list)
seal: dict[str, Any] | None = None
dept_names: dict[str, str] = field(default_factory=dict)
dept_labels: dict[str, str] = field(default_factory=dict)
templates: dict[str, list[dict[str, str]]] = field(default_factory=dict)
@property
def open_stage(self) -> dict[str, Any] | None:
@@ -233,7 +260,10 @@ def load_children(conn: Any, row: Mapping[str, Any], *, full: bool = True) -> Ch
one(conn, "SELECT id, name FROM stations WHERE id = %s", (row["station_id"],)) if row["station_id"] else None
)
ch = Children(dept=dept, station=station)
ch.dept_names = dept_names(conn)
for code, name, kind in conn.execute("SELECT code, name, kind::text FROM departments"):
ch.dept_names[code] = name
ch.dept_labels[code] = dept_label(code, name, kind)
ch.templates = checklists.load_templates(conn)
ch.pages = rows(conn, "SELECT * FROM packet_pages WHERE packet_id = %s ORDER BY n", (pid,))
ch.bom = rows(conn, "SELECT * FROM bom_lines WHERE packet_id = %s ORDER BY item", (pid,))
shorts = rows(conn, "SELECT * FROM bom_shorts WHERE packet_id = %s AND closed_at IS NULL", (pid,))
@@ -305,6 +335,13 @@ def needs_heat(line: Mapping[str, Any]) -> bool:
return False
def dept_welds(row: Mapping[str, Any], ch: Children) -> int:
"""The live welds of the packet's current department. A weld row without a department (written outside the API)
counts for the current one. Welds an earlier department logged are carried, not counted (the old missing())."""
dept = str(row["dept_code"])
return sum(1 for w in ch.welds if w.get("dept_code") in (None, dept))
def state_from(row: Mapping[str, Any], ch: Children) -> policy.PacketState:
weld = bool(ch.dept.get("weld"))
live_bom = [b for b in ch.bom if not b["na"]]
@@ -322,15 +359,16 @@ def state_from(row: Mapping[str, Any], ch: Children) -> policy.PacketState:
source_kind=str(row["source_kind"]),
weld_dept=weld,
co_hold=bool(ch.open_cos),
co_hold_why=co_hold_why(ch.open_cos, ch.dept_names),
co_hold_why=co_hold_why(ch.open_cos, ch.dept_labels),
open_shorts=len(ch.shorts),
drawing_attached=any(pg["type"] == "drawing" and pg["void_at"] is None for pg in ch.pages),
heat_map=row["heat_map"],
bom_total=len(live_bom),
bom_ready=sum(1 for b in live_bom if b["pulled"] or b["id"] in ch.shorts),
bom_missing_heat=sum(1 for b in live_bom if needs_heat(b)),
welds=len(ch.welds),
checklist_complete=stage is not None and checklists.checklist_complete(stage["checklist"], weld),
welds=dept_welds(row, ch),
checklist_complete=stage is not None
and checklists.checklist_complete(stage["checklist"], weld, ch.templates or None),
filler_set=stage is not None and bool((stage["filler"] or "").strip()),
qc_missing=tuple(checklists.qc_missing(row, ch.qc, ch.welds)),
has_qc_sent=ch.qc is not None,
@@ -369,6 +407,19 @@ class People:
return {"id": str(uid)}
return {"id": str(uid), "display_name": self.names.get(str(uid))}
def name(self, uid: Any) -> str | None:
return None if uid is None else self.names.get(str(uid))
def as_uuid(value: Any) -> uuid.UUID | None:
"""A uuid from stored text (a checklist entry's `by`), or None when it is not one."""
if value is None:
return None
try:
return uuid.UUID(str(value))
except ValueError:
return None
def _station_ref(conn: Any, station_id: Any, cache: dict[Any, dict[str, Any] | None]) -> dict[str, Any] | None:
if station_id is None:
@@ -378,7 +429,40 @@ def _station_ref(conn: Any, station_id: Any, cache: dict[Any, dict[str, Any] | N
return cache[station_id]
def _stage(conn: Any, s: Mapping[str, Any], people: People, cache: dict[Any, Any], *, closed: bool) -> dict[str, Any]:
def clock_of(s: Mapping[str, Any], now: Any) -> dict[str, Any]:
"""A stage clock. elapsed_ms is live: the stored time plus the running span up to the database now. Only the
server writes the clock columns; the client adds the time since its fetch for the ticking display only."""
stored = int(s["clock_elapsed_ms"] or 0)
since = s["clock_running_since"]
running = since is not None and s.get("closed_at") is None
live = stored
if running and now is not None:
live += max(0, int((now - since).total_seconds() * 1000))
return {
"elapsed_ms": live,
"stored_ms": stored,
"running": running,
"running_since": since,
"started_at": s["clock_started_at"],
}
def _checklist(value: Any, people: People) -> dict[str, Any]:
out: dict[str, Any] = {}
for k, v in (value or {}).items():
if isinstance(v, Mapping):
entry = dict(v)
if entry.get("by") is not None:
entry["by_name"] = people.name(as_uuid(entry["by"]))
out[k] = entry
else:
out[k] = v
return out
def _stage(
conn: Any, s: Mapping[str, Any], people: People, cache: dict[Any, Any], *, closed: bool, now: Any = None
) -> dict[str, Any]:
welder = people.ref(s["welder_id"])
if welder is not None:
welder["stamp"] = s["welder_stamp"]
@@ -387,16 +471,12 @@ def _stage(conn: Any, s: Mapping[str, Any], people: People, cache: dict[Any, Any
"dept_code": s["dept_code"],
"station": _station_ref(conn, s["station_id"], cache),
"welder": welder,
"checklist": s["checklist"] or {},
"checklist": _checklist(s["checklist"], people),
"filler": s["filler"],
"filler_lot": s["filler_lot"],
"preheat": s["preheat"],
"notes": s["notes"],
"clock": {
"elapsed_ms": int(s["clock_elapsed_ms"] or 0),
"running_since": s["clock_running_since"],
"started_at": s["clock_started_at"],
},
"clock": clock_of(s, now),
"opened_at": s["opened_at"],
"row_version": s["row_version"],
}
@@ -436,8 +516,13 @@ def _qc(q: Mapping[str, Any], people: People) -> dict[str, Any]:
def _who_ids(row: Mapping[str, Any], ch: Children) -> list[Any]:
ids: list[Any] = [row["welder_id"], row["for_sup_id"], row["vclosed_by"], row["removed_by"]]
ids += [w["welder_id"] for w in ch.welds]
ids += [w["welder_id"] for w in ch.welds] + [w["by"] for w in ch.welds]
ids += [s["welder_id"] for s in ch.stages]
for s in ch.stages:
for v in (s["checklist"] or {}).values():
if isinstance(v, Mapping):
ids.append(as_uuid(v.get("by")))
ids += [h["by"] for h in ch.heat_table] + [pg["void_by"] for pg in ch.pages]
if ch.wh:
ids.append(ch.wh["by"])
if ch.qc:
@@ -545,6 +630,7 @@ def build(
state = state_from(row, ch)
people = People(conn, _who_ids(row, ch), seal=seal)
cache: dict[Any, Any] = {}
now = None if seal else conn.execute("SELECT now()").fetchone()[0]
welder = people.ref(row["welder_id"])
if welder is not None:
welder["stamp"] = row["welder_stamp"]
@@ -585,6 +671,10 @@ def build(
"open_cos": [c["co_no"] for c in ch.open_cos],
"open_shorts": len(ch.shorts),
}
if not seal:
out["server_now"] = now
out["target_hrs"] = target_hrs(ch.work_orders)
out["labor"] = labor_of(conn, row)
if include_children:
out["work_orders"] = [
{
@@ -608,30 +698,19 @@ def build(
| {k: w.get(k) for k in ("dwg", "rev", "partspecs")}
for w in ch.work_orders
]
out["pages"] = [
{
"n": pg["n"],
"type": pg["type"],
"asset_id": uid_text(pg["asset_id"]),
"pn": pg["pn"],
"dwg": pg["dwg"],
"rev": pg["rev"],
"void": None
if pg["void_at"] is None
else {"co_id": pg["void_co_id"], "at": pg["void_at"], "new_n": pg["void_new_n"]},
"url": f"/_blob/{pg['asset_id']}" if pg["asset_id"] else None,
}
for pg in ch.pages
]
out["pages"] = [_page(pg, ch, people) for pg in ch.pages]
out["bom"] = [_bom(b, ch, people) for b in ch.bom]
out["welds"] = [_weld(w, people) for w in ch.welds]
out["heat_tags"] = [
{k: t[k] for k in ("tag_no", "page", "item", "fx", "fy", "row_version")} for t in ch.heat_tags
]
out["heat_table"] = [{k: h[k] for k in ("page", "x", "y", "s")} for h in ch.heat_table]
st = ch.open_stage
out["stage"] = _stage(conn, st, people, cache, closed=False) if st else None
out["stages"] = [_stage(conn, s, people, cache, closed=True) for s in ch.stages if s["closed_at"] is not None]
out["welds"] = [_weld(w, people, st) for w in ch.welds]
out["heat_tags"] = [_heat_tag(t, st) for t in ch.heat_tags]
out["heat_table"] = [
{k: h[k] for k in ("page", "x", "y", "s", "carried_from", "at")} | {"by": people.ref(h["by"])}
for h in ch.heat_table
]
out["stage"] = _stage(conn, st, people, cache, closed=False, now=now) if st else None
out["stages"] = [
_stage(conn, s, people, cache, closed=True, now=now) for s in ch.stages if s["closed_at"] is not None
]
out["wh"] = (
None
if ch.wh is None
@@ -666,11 +745,15 @@ def build(
if ch.seal is None
else {"sealed_at": ch.seal["sealed_at"], "key_id": ch.seal["key_id"], "broken_at": ch.seal["broken_at"]}
)
out["checklist_items"] = checklists.checklist_for(weld, row["drawing"], row["rev"], row["wps"])
out["checklist_items"] = checklists.checklist_for(weld, row["drawing"], row["rev"], row["wps"], ch.templates)
out["qc_items"] = checklists.qc_items_for(row["drawing"], row["rev"], row["wps"], row["welder_stamp"])
out["flag"] = flag_of(row["status"], row["source_kind"], row["flags"], row["drawing_state"], row["visual"])
if not seal:
out["visual_state"] = visual_state(conn, row)
if include_children:
from . import shop # shop builds on this module
out["heat_src"] = shop.heat_src(conn, row, ch)
if pp is not None:
allowed, blocked = policy.can(pp, state)
out["can"] = [str(a) for a in allowed]
@@ -682,6 +765,122 @@ def build(
# `sub` joined the BOM lines in phase 4; seals made before that do not cover it.
for b in out.get("bom") or []:
b.pop("sub", None)
_seal_strip(out)
return out
# Keys phase 5 added inside the child rows. Seals made before phase 5 do not cover them, and they are derived
# (carried, labels, pending lists) or volatile (the live clock), so the seal leaves them out.
SEAL_PHASE5 = {
"welds": ("bfx", "bfy", "dept_code", "stage_id", "carried", "by"),
"heat_tags": ("bfx", "bfy", "carried", "carried_from"),
"heat_table": ("carried_from", "by", "at"),
"change_orders": ("label", "pending_depts", "open"),
}
SEAL_CLOCK = ("stored_ms", "running")
SEAL_VOID = ("co_no", "by", "new")
def _seal_strip(out: dict[str, Any]) -> None:
for key, names in SEAL_PHASE5.items():
for r in out.get(key) or []:
for n in names:
r.pop(n, None)
for co in out.get("change_orders") or []:
for v in (co.get("verified") or {}).values():
v.pop("signed", None)
for pg in out.get("pages") or []:
if pg.get("void"):
for n in SEAL_VOID:
pg["void"].pop(n, None)
for st in [out.get("stage"), *(out.get("stages") or [])]:
if not st:
continue
for n in SEAL_CLOCK:
st["clock"].pop(n, None)
for v in (st.get("checklist") or {}).values():
if isinstance(v, dict):
v.pop("by_name", None)
def target_hrs(work_orders: Iterable[Mapping[str, Any]]) -> float:
"""The packet's target hours: the sum of its work orders' target hours (0 when none)."""
total = 0.0
for w in work_orders:
try:
total += float(w.get("target_hrs") or 0)
except (TypeError, ValueError):
continue
return round(total, 3)
LABOR_SQL = """
SELECT l.wo_base, l.lot, l.split, l.sub, l.seq, l.hours, l.tickets, l.last_ticket, l.observed_at
FROM packet_work_orders w
JOIN visual_labor l ON l.wo_base = w.wo_base AND l.lot = w.lot AND l.split = w.split AND l.sub = w.sub
AND l.seq = w.seq
WHERE w.packet_id = %s
ORDER BY w.sort, w.id
"""
def labor_of(conn: Any, row: Mapping[str, Any]) -> dict[str, Any] | None:
"""VISUAL's labor ticket hours on the packet's work orders, or None for a hand packet or when nothing matches.
The station timer stays the clock of record; these hours are shown beside it."""
if row["source_kind"] != "visual":
return None
found = rows(conn, LABOR_SQL, (row["id"],))
if not found:
return None
per = [
{
"wo_base": r["wo_base"],
"lot": r["lot"],
"split": r["split"],
"sub": r["sub"],
"seq": r["seq"],
"hours": float(r["hours"] or 0),
"tickets": int(r["tickets"] or 0),
"last_ticket": r["last_ticket"],
}
for r in found
]
return {
"hours": round(sum(x["hours"] for x in per), 3),
"tickets": sum(x["tickets"] for x in per),
"per_wo": per,
"observed_at": max(r["observed_at"] for r in found),
}
def _page(pg: Mapping[str, Any], ch: Children, people: People) -> dict[str, Any]:
void: dict[str, Any] | None = None
if pg["void_at"] is not None:
co_no = next((c["co_no"] for c in ch.change_orders if c["id"] == pg["void_co_id"]), None)
new = next((x for x in ch.pages if pg["void_new_n"] is not None and x["n"] == pg["void_new_n"]), None)
void = {
"co_id": pg["void_co_id"],
"at": pg["void_at"],
"new_n": pg["void_new_n"],
"co_no": co_no,
"by": people.ref(pg["void_by"]),
"new": None if new is None else {"n": new["n"], "dwg": new["dwg"], "rev": new["rev"]},
}
return {
"n": pg["n"],
"type": pg["type"],
"asset_id": uid_text(pg["asset_id"]),
"pn": pg["pn"],
"dwg": pg["dwg"],
"rev": pg["rev"],
"void": void,
"url": f"/_blob/{pg['asset_id']}" if pg["asset_id"] else None,
}
def _heat_tag(t: Mapping[str, Any], open_stage: Mapping[str, Any] | None) -> dict[str, Any]:
out = {k: t[k] for k in ("tag_no", "page", "item", "fx", "fy", "bfx", "bfy", "carried_from", "row_version")}
out["carried"] = open_stage is not None and t["at"] is not None and t["at"] < open_stage["opened_at"]
return out
@@ -737,11 +936,33 @@ def _bom(b: Mapping[str, Any], ch: Children, people: People) -> dict[str, Any]:
return out
def _weld(w: Mapping[str, Any], people: People) -> dict[str, Any]:
keys = ("weld_no", "page", "fx", "fy", "wo_base", "piece", "type", "size", "process", "stamp", "at", "row_version")
out = {k: w[k] for k in keys}
WELD_KEYS = (
"weld_no",
"page",
"fx",
"fy",
"bfx",
"bfy",
"wo_base",
"piece",
"type",
"size",
"process",
"stamp",
"dept_code",
"stage_id",
"at",
"row_version",
)
def _weld(w: Mapping[str, Any], people: People, open_stage: Mapping[str, Any] | None = None) -> dict[str, Any]:
out = {k: w[k] for k in WELD_KEYS}
out["items"] = list(w["items"] or [])
out["welder"] = people.ref(w["welder_id"])
out["by"] = people.ref(w["by"])
open_id = open_stage["id"] if open_stage else None
out["carried"] = w["stage_id"] is not None and w["stage_id"] != open_id
return out
@@ -773,11 +994,23 @@ def _co(c: Mapping[str, Any], ch: Children, people: People) -> dict[str, Any]:
{k: i[k] for k in ("old_page", "old_dwg", "old_rev", "new_page", "new_dwg", "new_rev", "added")}
for i in ch.co_items.get(c["id"], [])
]
out["required"] = ch.co_required.get(c["id"], [])
out["verified"] = {
v["dept_code"]: {"by": people.ref(v["by"]), "at": v["at"], "note": v["note"]}
required = ch.co_required.get(c["id"], [])
verified: dict[str, Any] = {
v["dept_code"]: {
"by": people.ref(v["by"]),
"at": v["at"],
"note": v["note"],
"signed": v["signature_id"] is not None,
}
for v in ch.co_verified.get(c["id"], [])
}
out["required"] = required
out["verified"] = verified
out["label"] = co_label(c["co_no"])
out["pending_depts"] = [d for d in required if d not in verified]
out["open"] = (c["review"] == "approved" and bool(out["pending_depts"])) or (
c["review"] == "pending" and bool(c["hold"])
)
return out
@@ -955,7 +1188,7 @@ def list_cards(conn: Any, policy_principal: policy.Principal, filters: Mapping[s
sql = CARD_SQL + (" WHERE " + " AND ".join(where) if where else "")
sql += " ORDER BY p.updated_at DESC, p.id DESC LIMIT %s"
params.append(int(filters.get("limit") or 500))
names = dept_names(conn)
names = dept_labels(conn)
transitions = {str(a) for a in policy.TRANSITIONS}
out: list[dict[str, Any]] = []
for r in rows(conn, sql, params):
+92 -2
View File
@@ -63,7 +63,7 @@ LABELS: dict[policy.Action, str] = {
A.UNASSIGN: "Taken off the station",
A.PULL_BACK: "Pulled back from the station",
A.START: "Work started",
A.SUBMIT: "Submitted to the supervisor",
A.SUBMIT: "Submitted to supervisor",
A.RETURN_TO_STATION: "Returned to the station",
A.SEND_TO_QC: "Sent to Q.C.",
A.ROUTE_FROM_REVIEW: "Routed to another department",
@@ -127,6 +127,8 @@ class Write:
noop: bool = False
from_status: str = ""
from_dept: str = ""
children: Any = None # packets.Children loaded under the lock (full=False)
more_logs: list[tuple[str, str | None]] = field(default_factory=list)
@property
def id(self) -> int:
@@ -146,6 +148,10 @@ class Write:
if note is not None:
self.note = note
def log_also(self, action_label: str, note: str | None = None) -> None:
"""One more packet_log line for this write, written after the main one (same user, no signature)."""
self.more_logs.append((action_label, note))
def changed(self, *names: str) -> None:
"""Record the fields this write changed (they go into the audit detail)."""
for n in names:
@@ -331,6 +337,7 @@ def packet_write(
request=request,
from_status=str(row["status"]),
from_dept=str(row["dept_code"]),
children=ch,
)
meaning = decision.signature
needs = bool(meaning) if require_sign is None else require_sign
@@ -398,6 +405,20 @@ def _finish(w: Write) -> None:
w.transition_id,
),
)
for extra_label, extra_note in w.more_logs:
conn.execute(
"INSERT INTO packet_log (packet_id, by, by_name, role, action, note, it_reason)"
" VALUES (%s, %s, %s, %s, %s, %s, %s)",
(
w.id,
w.user_id,
w.principal.user["display_name"],
w.principal.role_snapshot,
extra_label,
clean_opt(extra_note, LIMITS["note"]) if extra_note else None,
w.it_reason,
),
)
detail: dict[str, Any] = {"from": w.from_status, "to": status, "dept": dept}
if w.from_dept != dept:
detail["from_dept"] = w.from_dept
@@ -563,6 +584,64 @@ def _release_text(w: Write) -> None:
w.log(LABELS[A.WH_RELEASE], note)
def hours_text(ms: Any) -> str:
"""Stage clock hours with one decimal: 1.5 h."""
return f"{int(ms or 0) / 3600000:.1f} h"
def _station_name(w: Write, station_id: Any) -> str | None:
if station_id is None:
return None
ch = w.children
if ch is not None and ch.station and ch.station.get("id") == station_id:
return str(ch.station["name"])
r = w.conn.execute("SELECT name FROM stations WHERE id = %s", (station_id,)).fetchone()
return str(r[0]) if r else None
def _dept_text(w: Write, code: str) -> str:
labels = getattr(w.children, "dept_labels", None) or packets.dept_labels(w.conn)
return str(labels.get(code, code))
def _shop_texts(w: Write, a: policy.Action, to_dept: str, stamp: str | None = None) -> None:
"""The packet_log label and note of a shop step (the old click handlers' texts)."""
ex = w.extra
here = _station_name(w, w.packet["station_id"]) or "the station"
note = _note(w)
if a is A.ASSIGN:
station = _station_name(w, int(ex["station_id"])) or "a station"
r = w.conn.execute("SELECT display_name FROM users WHERE id = %s", (str(ex["welder_id"]),)).fetchone()
who = str(r[0]) if r else ""
w.log(f"Sent to {station}", f"{who} [{stamp}]" if stamp else who)
elif a is A.UNASSIGN:
w.log(f"Taken off {here}", note)
elif a is A.PULL_BACK:
w.log(f"Pulled back from {here}", note)
elif a is A.START:
w.log(LABELS[A.START], " · ".join(x for x in (here if w.packet["station_id"] else None, note) if x) or None)
elif a is A.SUBMIT:
r = w.conn.execute(
"SELECT clock_elapsed_ms FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (w.id,)
).fetchone()
n = packets.dept_welds(w.packet, w.children) if w.children is not None else 0
text = f"{n} welds · {hours_text(r[0] if r else 0)}"
w.log(LABELS[A.SUBMIT], f"{text} · {note}" if note else text)
elif a is A.RETURN_TO_STATION:
w.log(f"Returned to {here}", _reason(w))
elif a is A.SEND_TO_QC:
loc = clean_opt(ex.get("location"), LIMITS["location"])
w.log(LABELS[A.SEND_TO_QC], " · ".join(x for x in (f"Spool at {loc}" if loc else None, note) if x) or None)
elif a is A.ROUTE_FROM_REVIEW:
text = f"Supervisor, Q.C. skipped · from {_dept_text(w, w.from_dept)}"
w.log(f"Routed to {_dept_text(w, to_dept)}", f"{text} · {note}" if note else text)
SHOP_TEXTS = frozenset(
{A.ASSIGN, A.UNASSIGN, A.PULL_BACK, A.START, A.SUBMIT, A.RETURN_TO_STATION, A.SEND_TO_QC, A.ROUTE_FROM_REVIEW}
)
def apply_transition(w: Write) -> None:
"""Set status, status_at and dept, write the packet_transitions row and run the action's side effects."""
if w.transitioned:
@@ -579,6 +658,10 @@ def apply_transition(w: Write) -> None:
if rule.dept_change != "no" and isinstance(ex.get("dept"), str) and ex.get("dept"):
to_dept = str(ex["dept"])
stamp: str | None = None
if a in SHOP_TEXTS and a is not A.ASSIGN and a is not A.SUBMIT:
_shop_texts(w, a, to_dept) # before the side effects clear the station
if a in (A.SEND_TO_WAREHOUSE, A.VIS_AUTO_RELEASE):
_set_packet(w, "wh_skipped = false, wh_sent_at = now(), return_note = NULL")
_wh_incoming(w)
@@ -623,14 +706,21 @@ def apply_transition(w: Write) -> None:
if not packets.one(w.conn, "SELECT id FROM packet_stages WHERE packet_id = %s AND closed_at IS NULL", (w.id,)):
_open_stage(w, from_dept)
_stage_update(w, "station_id = %s, welder_id = %s, welder_stamp = %s", (station_id, welder_id, stamp))
_shop_texts(w, a, to_dept, stamp)
elif a in (A.UNASSIGN, A.PULL_BACK):
_set_packet(w, "station_id = NULL, welder_id = NULL, welder_stamp = NULL")
_stage_update(w, f"{CLOCK_STOP}, station_id = NULL, welder_id = NULL, welder_stamp = NULL")
elif a is A.START:
_stage_update(w, "clock_running_since = now(), clock_started_at = coalesce(clock_started_at, now())")
# A clock that is already running keeps its start; only the server sets it.
_stage_update(
w,
"clock_running_since = coalesce(clock_running_since, now()),"
" clock_started_at = coalesce(clock_started_at, now())",
)
elif a is A.SUBMIT:
_set_packet(w, "return_note = NULL")
_stage_update(w, CLOCK_STOP)
_shop_texts(w, a, to_dept)
elif a is A.RETURN_TO_STATION:
_set_packet(w, "return_note = %s", (reason,))
elif a is A.SEND_TO_QC:
+414
View File
@@ -0,0 +1,414 @@
"""The shop: the supervisor board, the stations a person may work at, the stall board, and the borrowed heat map.
Everything here reads PostgreSQL only. The cards are the `list_cards` shape plus the shop fields (welder stamp, the
live stage clock, target hours, open change orders as labels, the return note), so a card reads the same on every
screen.
"""
from __future__ import annotations
from collections.abc import Iterable, Mapping
from typing import Any
from .. import policy
from ..api.errors import ApiError
from . import packets
LEAD, SUPERVISOR = 2, 3
LEVEL_NAMES = {1: "worker", 2: "lead", 3: "supervisor"}
BOARD_LIMIT = 5000
# The columns of the supervisor board, by packet status. Packets at a station are on the station grid.
COLUMNS = {"ready": "ready", "review": "review", "qc": "qc", "warehouse": "warehouse", "complete": "complete"}
BOARD_STATUSES = ("ready", "assigned", "in_progress", "review", "qc", "warehouse", "complete")
MSG_NO_LEAD = "You do not lead a department."
MSG_NOT_YOURS = "You do not lead that department."
MSG_NO_STATION = "That station is not one of yours."
# ---------------------------------------------------------------- the borrowed heat map
def _has_heat_map(conn: Any, packet_id: int) -> bool:
return bool(
conn.execute(
"SELECT EXISTS (SELECT 1 FROM heat_table_placements WHERE packet_id = %s)"
" OR EXISTS (SELECT 1 FROM heat_tags WHERE packet_id = %s AND removed_at IS NULL)",
(packet_id, packet_id),
).fetchone()[0]
)
SIBLING_SQL = """
SELECT p.id
FROM packets p
WHERE p.unit_id = %(unit)s AND p.id <> %(id)s AND p.removed_at IS NULL
AND upper(regexp_replace(coalesce(p.drawing, ''), '\\s', '', 'g')) = %(dwg)s
AND (EXISTS (SELECT 1 FROM heat_table_placements h WHERE h.packet_id = p.id)
OR EXISTS (SELECT 1 FROM heat_tags t WHERE t.packet_id = p.id AND t.removed_at IS NULL))
ORDER BY (coalesce(p.rev, '') = %(rev)s) DESC, p.created_at, p.id
LIMIT 1
"""
def dwg_key(drawing: Any) -> str:
"""The drawing number compared between packets: white space removed, upper case (the old dwgKey)."""
return "".join(str(drawing or "").split()).upper()
def heat_src(conn: Any, row: Mapping[str, Any], ch: packets.Children) -> dict[str, Any] | None:
"""The heat map this packet borrows from a sibling, or None (the old heatSrc).
A packet borrows when it has no heat map of its own (no heat table, no heat tag), pulls no raw material of its
own (every BOM line is N/A or a sub-assembly, or the BOM is empty) and has a drawing. The sibling is a live
packet in the same unit with the same drawing and a heat map; the same rev first, then the oldest. Read-only.
"""
if ch.heat_table or ch.heat_tags:
return None
if any(not b["na"] and not packets.is_sub(b) for b in ch.bom):
return None
dwg = dwg_key(row["drawing"])
if not dwg:
return None
found = conn.execute(
SIBLING_SQL, {"unit": row["unit_id"], "id": row["id"], "dwg": dwg, "rev": str(row["rev"] or "")}
).fetchone()
if found is None:
return None
sib = packets.load_row(conn, int(found[0]))
if sib is None:
return None
sch = packets.load_children(conn, sib, full=True)
people = packets.People(conn, [h["by"] for h in sch.heat_table] + [pg["void_by"] for pg in sch.pages])
st = sch.open_stage
return {
"packet_id": sib["id"],
"op": sib["op"],
"status": sib["status"],
"drawing": sib["drawing"],
"rev": sib["rev"],
"pages": [packets._page(pg, sch, people) for pg in sch.pages if pg["type"] == "drawing"],
"heat_table": [
{k: h[k] for k in ("page", "x", "y", "s", "carried_from", "at")} | {"by": people.ref(h["by"])}
for h in sch.heat_table
],
"heat_tags": [packets._heat_tag(t, st) for t in sch.heat_tags],
"bom": [{"item": b["item"], "heat": b["heat"], "na": b["na"]} for b in sch.bom],
}
# ---------------------------------------------------------------- shop cards
EXTRAS_SQL = """
SELECT p.id, p.return_note, p.welder_stamp, s.clock_elapsed_ms, s.clock_running_since, s.clock_started_at,
(SELECT coalesce(sum(w.target_hrs), 0) FROM packet_work_orders w WHERE w.packet_id = p.id) AS target_hrs
FROM packets p
LEFT JOIN packet_stages s ON s.packet_id = p.id AND s.closed_at IS NULL
WHERE p.id = ANY(%s)
"""
def add_shop_fields(conn: Any, cards: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Add the shop fields to list_cards cards, in place: welder_stamp, clock {elapsed_ms, running}, target_hrs,
open_cos [{co_no, label, review}] and return_note."""
if not cards:
return cards
ids = [int(c["id"]) for c in cards]
now = conn.execute("SELECT now()").fetchone()[0]
extras = {int(r["id"]): r for r in packets.rows(conn, EXTRAS_SQL, (ids,))}
cos: dict[int, list[dict[str, Any]]] = {}
for r in packets.rows(
conn,
"SELECT packet_id, co_no, review::text AS review FROM v_open_cos WHERE packet_id = ANY(%s) ORDER BY co_no",
(ids,),
):
cos.setdefault(int(r["packet_id"]), []).append(
{"co_no": r["co_no"], "label": packets.co_label(r["co_no"]), "review": r["review"]}
)
for c in cards:
x = extras.get(int(c["id"])) or {}
if x.get("clock_elapsed_ms") is not None:
clock = packets.clock_of({**x, "closed_at": None}, now)
c["clock"] = {"elapsed_ms": clock["elapsed_ms"], "running": clock["running"]}
else:
c["clock"] = {"elapsed_ms": 0, "running": False}
c["welder_stamp"] = x.get("welder_stamp")
c["target_hrs"] = float(x.get("target_hrs") or 0)
c["return_note"] = x.get("return_note")
c["open_cos"] = cos.get(int(c["id"]), [])
return cards
def _cards(conn: Any, pp: policy.Principal, ids: Iterable[int], **filters: Any) -> list[dict[str, Any]]:
want = list(ids)
if not want:
return []
cards = packets.list_cards(conn, pp, {**filters, "ids": want, "limit": BOARD_LIMIT})
cards.sort(key=lambda c: (c["updated_at"], c["id"]))
return add_shop_fields(conn, cards)
# ---------------------------------------------------------------- scope
def shop_departments(conn: Any) -> list[dict[str, Any]]:
return packets.rows(
conn, "SELECT code, name, weld FROM departments WHERE kind = 'shop' AND active ORDER BY sort, code"
)
def board_scope(conn: Any, pp: policy.Principal) -> list[dict[str, Any]]:
"""The shop departments the principal leads or supervises (admin: every active shop department), each with
its level name."""
out: list[dict[str, Any]] = []
for d in shop_departments(conn):
level = pp.level(d["code"])
if level >= LEAD:
out.append({**d, "level": LEVEL_NAMES[min(level, SUPERVISOR)]})
elif pp.admin:
out.append({**d, "level": "admin"})
return out
# ---------------------------------------------------------------- the supervisor board
SCOPE_SQL = """
SELECT p.id FROM packets p JOIN units u ON u.id = p.unit_id
WHERE p.removed_at IS NULL AND p.dept_code = ANY(%(depts)s) AND p.status::text = ANY(%(statuses)s) {extra}
ORDER BY p.updated_at, p.id
LIMIT %(limit)s
"""
COS_SQL = """
SELECT c.packet_id, c.co_no, c.review::text AS review, c.hold, c.pending_depts, co.reason, co.ecn, co.issued_at,
p.dept_code, p.op, p.title, pr.number || u.lot AS unit_number
FROM v_open_cos c
JOIN change_orders co ON co.id = c.co_id
JOIN packets p ON p.id = c.packet_id
JOIN units u ON u.id = p.unit_id
JOIN projects pr ON pr.id = u.project_id
WHERE p.removed_at IS NULL AND (p.dept_code = ANY(%(depts)s) OR c.pending_depts && %(depts)s::text[]) {extra}
ORDER BY co.issued_at NULLS LAST, c.packet_id, c.co_no
LIMIT %(limit)s
"""
def _filter_sql(project_id: int | None, unit_id: int | None) -> tuple[str, dict[str, Any]]:
terms: list[str] = []
params: dict[str, Any] = {}
if project_id is not None:
terms.append("u.project_id = %(project_id)s")
params["project_id"] = int(project_id)
if unit_id is not None:
terms.append("p.unit_id = %(unit_id)s")
params["unit_id"] = int(unit_id)
return "".join(f" AND {t}" for t in terms), params
def board(
conn: Any,
pp: policy.Principal,
*,
dept: str | None = None,
project_id: int | None = None,
unit_id: int | None = None,
) -> dict[str, Any]:
"""GET /api/shop/board. 403 forbidden when the principal leads no department, or names one outside the scope."""
scope = board_scope(conn, pp)
if not scope:
raise ApiError(403, "forbidden", MSG_NO_LEAD)
if dept and dept.lower() != "all":
scope = [d for d in scope if d["code"] == dept]
if not scope:
raise ApiError(403, "forbidden", MSG_NOT_YOURS)
codes = [d["code"] for d in scope]
extra, fparams = _filter_sql(project_id, unit_id)
ids = [
int(r[0])
for r in conn.execute(
SCOPE_SQL.format(extra=extra),
{"depts": codes, "statuses": list(BOARD_STATUSES), "limit": BOARD_LIMIT, **fparams},
)
]
cards = _cards(conn, pp, ids)
columns: dict[str, list[dict[str, Any]]] = {k: [] for k in COLUMNS.values()}
at_station: list[dict[str, Any]] = []
for c in cards:
if c["status"] in policy.ACTIVE:
at_station.append(c)
elif c["status"] in COLUMNS:
columns[COLUMNS[c["status"]]].append(c)
stations = packets.rows(
conn,
"SELECT id, name, dept_code FROM stations WHERE active AND dept_code = ANY(%s) ORDER BY dept_code, sort, name",
(codes,),
)
by_station: dict[Any, list[dict[str, Any]]] = {}
for c in at_station:
sid = (c.get("station") or {}).get("id")
by_station.setdefault(sid, []).append(c)
depts = []
for d in scope:
depts.append(
{
"code": d["code"],
"name": d["name"],
"weld": bool(d["weld"]),
"level": d["level"],
"stations": [
{"id": s["id"], "name": s["name"], "packets": by_station.get(s["id"], [])}
for s in stations
if s["dept_code"] == d["code"]
],
}
)
cos = []
for r in packets.rows(conn, COS_SQL.format(extra=extra), {"depts": codes, "limit": BOARD_LIMIT, **fparams}):
pending = list(r["pending_depts"] or [])
cos.append(
{
"packet_id": r["packet_id"],
"unit_number": r["unit_number"],
"op": r["op"],
"title": r["title"],
"dept_code": r["dept_code"],
"co_no": r["co_no"],
"label": packets.co_label(r["co_no"]),
"review": r["review"],
"hold": bool(r["hold"]),
"pending_depts": pending,
"mine": r["review"] == "approved" and any(can_verify(pp, d) for d in pending),
"reason": r["reason"],
"ecn": r["ecn"],
"issued_at": r["issued_at"],
}
)
kpis = {
"ready": len(columns["ready"]),
"at_stations": len(at_station),
"review": len(columns["review"]),
"elapsed_ms": sum(int(c["clock"]["elapsed_ms"]) for c in at_station),
"target_hrs": round(sum(float(c["target_hrs"]) for c in at_station), 3),
}
return {"depts": depts, "kpis": kpis, "cos": cos, "columns": columns}
def can_verify(pp: policy.Principal, dept: str) -> bool:
"""A supervisor of the department (admin too: the verify itself asks for the IT reason)."""
if pp.admin:
return True
if dept in ("WH", "QC"):
return dept in pp.support_sup
return pp.level(dept) >= SUPERVISOR
# ---------------------------------------------------------------- stations and the stall board
def stations_for(conn: Any, pp: policy.Principal) -> list[dict[str, Any]]:
"""The stations the principal may pick on the Stall screen: every station of each shop department they belong
to at any level, honouring a worker's station limits. Admin: every active station."""
out: list[dict[str, Any]] = []
rows = packets.rows(
conn,
"SELECT s.id, s.name, s.dept_code, d.name AS dept_name FROM stations s"
" JOIN departments d ON d.code = s.dept_code WHERE s.active AND d.active AND d.kind = 'shop'"
" ORDER BY d.sort, d.code, s.sort, s.name",
)
for r in rows:
level = pp.level(r["dept_code"])
if not pp.admin:
if level == 0:
continue
if level < LEAD and pp.station_limits and int(r["id"]) not in pp.station_limits:
continue
out.append({"dept_code": r["dept_code"], "dept_name": r["dept_name"], "id": r["id"], "name": r["name"]})
return out
def stall(conn: Any, pp: policy.Principal, station_id: int) -> dict[str, Any]:
"""GET /api/shop/stall: the packets at one station. 403 when the station is not in the principal's list."""
mine_list = {int(s["id"]): s for s in stations_for(conn, pp)}
st = mine_list.get(int(station_id))
if st is None:
raise ApiError(403, "forbidden", MSG_NO_STATION)
found = packets.rows(
conn,
"SELECT id, status::text AS status FROM packets WHERE station_id = %s AND removed_at IS NULL"
" AND status IN ('assigned', 'in_progress', 'review') ORDER BY updated_at, id LIMIT %s",
(int(station_id), BOARD_LIMIT),
)
cards = _cards(conn, pp, [int(r["id"]) for r in found])
return {
"station": {"id": st["id"], "name": st["name"], "dept_code": st["dept_code"], "dept_name": st["dept_name"]},
"mine": [c for c in cards if c["status"] in policy.ACTIVE],
"submitted": [c for c in cards if c["status"] == "review"],
}
# ---------------------------------------------------------------- the assign sheet's people
PEOPLE_SQL = """
SELECT u.id, u.display_name, u.welder_stamp, max(CASE r.role::text WHEN 'shop_supervisor' THEN 3
WHEN 'shop_lead' THEN 2 ELSE 1 END) AS level
FROM users u JOIN user_roles r ON r.user_id = u.id
WHERE u.active AND r.dept_code = %s AND r.role::text IN ('shop_worker', 'shop_lead', 'shop_supervisor')
GROUP BY u.id, u.display_name, u.welder_stamp
ORDER BY lower(u.display_name), u.id
"""
def people(conn: Any, dept: str) -> list[dict[str, Any]]:
"""The active users with a shop role in a department, for the welder picker."""
found = packets.rows(conn, PEOPLE_SQL, (dept,))
limits: dict[str, list[int]] = {}
if found:
for uid, sid in conn.execute(
"SELECT user_id, station_id FROM user_station_limits WHERE user_id = ANY(%s) ORDER BY station_id",
([r["id"] for r in found],),
):
limits.setdefault(str(uid), []).append(int(sid))
return [
{
"id": str(r["id"]),
"display_name": r["display_name"],
"stamp": r["welder_stamp"],
"level": LEVEL_NAMES[int(r["level"])],
"station_limits": limits.get(str(r["id"]), []),
}
for r in found
]
def welder_check(conn: Any, dept: str, welder_id: str, station_id: int) -> None:
"""ASSIGN: the welder has a shop role in the department, and a worker with station limits is limited to a set
that holds the station. 422 invalid_welder or station_limit."""
level_row = conn.execute(
"SELECT max(CASE r.role::text WHEN 'shop_supervisor' THEN 3 WHEN 'shop_lead' THEN 2 ELSE 1 END)"
" FROM users u JOIN user_roles r ON r.user_id = u.id WHERE u.id = %s AND u.active AND r.dept_code = %s"
" AND r.role::text IN ('shop_worker', 'shop_lead', 'shop_supervisor')",
(welder_id, dept),
).fetchone()
level = int(level_row[0]) if level_row and level_row[0] is not None else 0
if level == 0:
raise ApiError(422, "invalid_welder", "Pick a welder in this department.")
if level >= LEAD:
return
limits = packets.rows(
conn,
"SELECT s.id, s.name FROM user_station_limits l JOIN stations s ON s.id = l.station_id"
" WHERE l.user_id = %s ORDER BY s.dept_code, s.sort, s.name",
(welder_id,),
)
if limits and int(station_id) not in {int(x["id"]) for x in limits}:
name = conn.execute("SELECT display_name FROM users WHERE id = %s", (welder_id,)).fetchone()[0]
names = ", ".join(str(x["name"]) for x in limits)
raise ApiError(422, "station_limit", f"{name} is limited to {names}.")
+15 -4
View File
@@ -43,6 +43,7 @@ class Subscriber:
user_id: str
display_name: str
screen: str | None = None
packet_id: int | None = None # the packet the subscriber has open, for "Also here" on the packet page
id: str = field(default_factory=lambda: uuid.uuid4().hex)
closed: bool = False
@@ -65,7 +66,15 @@ class EventHub:
# ------------------------------------------------ subscribers
def subscribe(self, *, session_id: Any, user_id: Any, display_name: str, screen: str | None = None) -> Subscriber:
def subscribe(
self,
*,
session_id: Any,
user_id: Any,
display_name: str,
screen: str | None = None,
packet_id: int | None = None,
) -> Subscriber:
"""Register a subscriber on the running loop and broadcast presence."""
sub = Subscriber(
queue=asyncio.Queue(maxsize=QUEUE_MAX),
@@ -74,6 +83,7 @@ class EventHub:
user_id=str(user_id),
display_name=display_name,
screen=screen,
packet_id=packet_id,
)
with self._lock:
self._subs[sub.id] = sub
@@ -90,12 +100,13 @@ class EventHub:
def presence(self) -> dict[str, Any]:
with self._lock:
subs = list(self._subs.values())
seen: dict[tuple[str, str | None], dict[str, Any]] = {}
seen: dict[tuple[str, str | None, int | None], dict[str, Any]] = {}
for s in subs:
seen.setdefault(
(s.user_id, s.screen), {"user_id": s.user_id, "display_name": s.display_name, "screen": s.screen}
(s.user_id, s.screen, s.packet_id),
{"user_id": s.user_id, "display_name": s.display_name, "screen": s.screen, "packet_id": s.packet_id},
)
users = sorted(seen.values(), key=lambda u: (u["display_name"].lower(), u["screen"] or ""))
users = sorted(seen.values(), key=lambda u: (u["display_name"].lower(), u["screen"] or "", u["packet_id"] or 0))
return {"users": users}
def _send(self, sub: Subscriber, item: Item) -> None:
+6 -6
View File
@@ -112,7 +112,7 @@ def open_or_reaim(
" WHERE id = %s",
(new_reason, _j(new_vis), _j(staged), _j(staged), pend["id"]),
)
label = f"Change order {pend['co_no']}: drawing from M-Files staged"
label = f"Change order CO-{pend['co_no']}: drawing from M-Files staged"
else:
new_vis = dict(vis)
new_vis["moved"] = [*moved, {"at": _now(), "rev": vis.get("to_rev"), "dwg": vis.get("to_dwg")}]
@@ -135,9 +135,9 @@ def open_or_reaim(
),
)
label = (
f"Change order {pend['co_no']} updated: VISUAL changed again before Clerical approved"
f"Change order CO-{pend['co_no']} updated: VISUAL changed again before Clerical approved"
if origin == "visual"
else f"Change order {pend['co_no']} updated: new drawing from M-Files"
else f"Change order CO-{pend['co_no']} updated: new drawing from M-Files"
)
sw.log(label, reason)
sw.changed("change_orders")
@@ -151,9 +151,9 @@ def open_or_reaim(
(sw.id, co_no, origin, minor, ECN[origin], reason, _j(vis), _j(waiting), _j(staged), target_fp),
).fetchone()
label = (
f"Change order {co_no}: change from VISUAL waiting on Clerical approval"
f"Change order CO-{co_no}: change from VISUAL waiting on Clerical approval"
if origin == "visual"
else f"Change order {co_no}: new drawing from M-Files waiting on Clerical approval"
else f"Change order CO-{co_no}: new drawing from M-Files waiting on Clerical approval"
)
sw.log(label, reason)
sw.changed("change_orders")
@@ -172,7 +172,7 @@ def withdraw(sw: SysWrite, co_id: int, reason: str) -> None:
if r is None:
sw.skip()
return
sw.log(f"Change order {r[0]} withdrawn", reason)
sw.log(f"Change order CO-{r[0]} withdrawn", reason)
sw.changed("change_orders")
sw.detail.update({"co_no": int(r[0]), "co_id": co_id, "withdrawn": True})
+2
View File
@@ -105,6 +105,7 @@ def create_app(
packet_children,
packets,
projects,
shop,
units,
warehouse,
)
@@ -125,6 +126,7 @@ def create_app(
jobs,
feed_settings,
warehouse,
shop,
):
app.include_router(module.router)
app.include_router(assets.blob_router)
@@ -0,0 +1,39 @@
-- 0006_shop: the shop phase. Label positions for weld and heat tag marks, the page a heat table was copied from
-- on a change order, and the default department checklists as a settings row. Runs as ps_migrate.
-- bfx, bfy: where the label (bubble) of a mark sits, as fractions of the page. fx, fy stay where the arrow points.
-- NULL means the client places the label at the old default offset from the arrow.
ALTER TABLE welds
ADD COLUMN bfx double precision CHECK (bfx >= 0 AND bfx <= 1),
ADD COLUMN bfy double precision CHECK (bfy >= 0 AND bfy <= 1);
ALTER TABLE heat_tags
ADD COLUMN bfx double precision CHECK (bfx >= 0 AND bfx <= 1),
ADD COLUMN bfy double precision CHECK (bfy >= 0 AND bfy <= 1);
-- The page the placement was copied from when a change order replaced the sheet (the old htCarry).
ALTER TABLE heat_table_placements ADD COLUMN carried_from int;
-- The department checklists. {drawing}, {rev} and {wps} are filled in from the packet. The code keeps the same
-- lists as its fallback when this row is missing or does not parse. Admin edits it in phase 9.
INSERT INTO settings (key, value) VALUES ('checklist_templates', '{
"weld": [
{"group": "Before welding", "key": "dwg", "text": "Drawing {drawing} Rev {rev} matches the packet"},
{"group": "Before welding", "key": "heat", "text": "Heat numbers on material match the Materials tab"},
{"group": "Before welding", "key": "wps", "text": "WPS reviewed: {wps}"},
{"group": "Before welding", "key": "fit", "text": "Fit-up and dimensions checked against the drawing"},
{"group": "Before welding", "key": "pre", "text": "Preheat and interpass temperature per WPS"},
{"group": "After welding", "key": "vt", "text": "Visual inspection of all welds acceptable"},
{"group": "After welding", "key": "stamp", "text": "Welds stamped with welder ID"},
{"group": "After welding", "key": "clean", "text": "Slag and spatter removed"},
{"group": "After welding", "key": "qty", "text": "Completed quantity matches each work order"}
],
"plain": [
{"group": "Before starting", "key": "dwg", "text": "Drawing {drawing} Rev {rev} matches the packet"},
{"group": "Before starting", "key": "prev", "text": "Previous department''s work complete and released"},
{"group": "Before starting", "key": "mat", "text": "Material and parts on hand match the packet"},
{"group": "When done", "key": "work", "text": "Work completed per drawing and procedure"},
{"group": "When done", "key": "insp", "text": "Self-inspection done; no damage to finished surfaces"},
{"group": "When done", "key": "qty", "text": "Completed quantity matches each work order"}
]
}'::jsonb) ON CONFLICT (key) DO NOTHING;
+3
View File
@@ -49,6 +49,9 @@ LIMITS: dict[str, int] = {
"order_pin": 40,
"file_name": 160,
"job_note": 200,
"filler": 40,
"preheat": 40,
"weld_text": 40,
}
REASON_MIN = 8
MAX_BODY_BYTES = 200_000