Phase 4: Warehouse screen, pull table, shorts, MTRs and the upload pipeline
ci / frontend (push) Successful in 30s
ci / server (push) Successful in 4m30s
ci / image (push) Successful in 35s
ci / e2e (push) Successful in 1m40s

The Warehouse home with the numbers, the Open shorts table, the find bar and the five-column task board
(waiting, being pulled, on hold, pulled, released in the last 3 days) with buttons, hold reasons and drag.
Every role sees the warehouse stage on a packet as the WH pill and the line under it. The pull table on the
Materials tab: Pulled with auto-start, issued, location, heat with Enter to the next line, shorts with
receipts, N/A, MTR per line with a viewer. Send back to Clerical with a reason. Release to supervisor stays
blocked until every line is pulled or short and every pulled line has a heat.

Server: the heats_recorded precondition on WH_RELEASE, receive by custody, server-validated hold and short
reasons, the old packet history texts, GET /api/warehouse/board, cards with the warehouse stage and pull
counts. Uploads typed by content per kind (drawing, mtr, document, bom with XLSX and CSV), limits per kind,
optional ClamAV behind the clamav compose profile, a storage quota alert on the new notification_events
table (migration 0005) shown on the Admin Storage tab.

Tests: every warehouse action as every role, the release gate, the short-receive exception, the heat
pattern, upload sniffing and limits, the ClamAV path with a fake scanner, the quota alert; frontend board
and pull table tests; Playwright scenarios for the board and the pull flow. Verified on docker-1 in the CI
stack: 3557 server tests, 106 frontend tests, 17 Playwright scenarios. Docs updated; D5 closed.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-16 17:13:31 -06:00
co-authored by Claude Fable 5.1
parent 591fe90076
commit 39a4eb5213
65 changed files with 6213 additions and 130 deletions
+103 -38
View File
@@ -30,8 +30,10 @@ from ..domain.pipeline import (
next_counter,
packet_write,
parse_if_match,
qty_text,
require_if_match,
)
from ..policy.actions import STATUS_LABELS
from ..text import LIMITS, RX_HEAT, RX_STAMP, clean_opt, clean_text
from .deps import Db, Principal, current_principal, get_db
from .errors import ApiError
@@ -45,7 +47,7 @@ TEXT_SHORT = 40 # weld type, size, process, wo_base
FILLER_MAX = 120
QC_FIELD_MAX = 120
# The old warehouse hold reasons. Any other text is taken as given; "Other" needs a note.
# The warehouse hold reasons, a closed set (the old build took any text). "Other" needs a note.
WH_HOLD_REASONS = (
"Waiting on material (not in stock)",
"Waiting on purchasing / PO",
@@ -53,6 +55,19 @@ WH_HOLD_REASONS = (
"Drawing / BOM problem",
"Other",
)
# The short reasons, a closed set (the old build took any text).
SHORT_REASONS = (
"Not in stock",
"On order",
"Back-ordered",
"Damaged / rejected",
"Wrong part received",
"Needs cutting / prep",
"Other",
)
WH_NOTE_MAX = 200
# The file types an MTR may be.
MTR_MIMES = frozenset({"application/pdf", "image/png", "image/jpeg"})
# The old warehouse board: which stage may follow which.
WH_NEXT: dict[str, frozenset[str]] = {
"incoming": frozenset({"pulling", "hold"}),
@@ -65,6 +80,8 @@ WH_LABELS = {
"hold": "Put on hold",
"pulled": "Marked pulled, ready for production",
}
# Labels for a move into pulling from a stage other than incoming.
WH_PULLING_FROM = {"hold": "Pulling resumed", "pulled": "Back to pulling"}
MSG_NO_STAGE = "This packet has no open stage."
MSG_NO_INSPECTION = "This packet has no open Q.C. inspection."
@@ -260,12 +277,20 @@ def _read(d: Db, pp: policy.Principal, packet_id: int) -> dict[str, Any]:
def _heat(value: Any) -> str | None:
"""A cleaned heat number, None when empty, 422 invalid_heat when it does not fit RX_HEAT."""
"""A cleaned heat number, None when empty, 422 invalid_heat when it does not fit RX_HEAT.
Trailing white space is trimmed (a scanner may add it). A leading space is refused, as the pattern says.
"""
text = clean_text(value, 200)
if not text:
return None
if len(text) > LIMITS["heat"] or not RX_HEAT.match(text):
raise ApiError(422, "invalid_heat", "Heat numbers are letters, digits, spaces and . / # _ - only.")
lead = isinstance(value, str) and value[:1].isspace()
if lead or len(text) > LIMITS["heat"] or not RX_HEAT.match(text):
raise ApiError(
422,
"invalid_heat",
f'Heat number "{text}" isn\'t valid: letters, numbers, space . / # _ - only, up to 40 characters.',
)
return text
@@ -323,6 +348,16 @@ def _num(value: Any) -> Decimal | None:
return None if value is None else Decimal(str(value))
def _status_tail(conn: Any, row: dict[str, Any]) -> str:
"""The end of the receive note: " · packet ready to assign", plus " in {station}" when at a station."""
out = f" · packet {STATUS_LABELS.get(str(row['status']), str(row['status'])).lower()}"
if row["status"] in policy.ACTIVE and row["station_id"] is not None:
st = conn.execute("SELECT name FROM stations WHERE id = %s", (row["station_id"],)).fetchone()
if st and st[0]:
out += f" in {st[0]}"
return out
def _diff(row: dict[str, Any], values: dict[str, Any]) -> dict[str, Any]:
"""The values that differ from the row."""
out: dict[str, Any] = {}
@@ -510,10 +545,12 @@ def patch_bom_line(
check_row_version(line, if_match)
if _open_short(conn, line["id"]) is not None and given & {"heat", "issued", "loc"}:
raise ApiError(409, "short_open", MSG_SHORT_OPEN)
if values.get("mtr_asset_id") is not None and (
conn.execute("SELECT 1 FROM assets WHERE id = %s", (values["mtr_asset_id"],)).fetchone() is None
):
raise ApiError(422, "invalid_asset", "That MTR file is not on the server. Upload it first.")
if values.get("mtr_asset_id") is not None:
asset = conn.execute("SELECT mime FROM assets WHERE id = %s", (values["mtr_asset_id"],)).fetchone()
if asset is None:
raise ApiError(422, "invalid_asset", "That MTR file is not on the server. Upload it first.")
if asset[0] not in MTR_MIMES:
raise ApiError(422, "invalid_asset", "An MTR is a PDF, PNG or JPG file.")
newly_pulled = False
if "pulled" in given:
if body.pulled:
@@ -534,24 +571,43 @@ def patch_bom_line(
_apply_line(w, line, changes)
w.changed(*[f"bom.{k}" for k in changes if k not in ("pulled_at", "pulled_by")])
w.detail["item"] = item
if "pulled" in changes:
w.log(f"Item {item} pulled" if changes["pulled"] else f"Item {item} no longer pulled")
elif "heat" in changes and len(changes) == 1:
w.log(f"Heat recorded on item {item}" if changes["heat"] else f"Heat cleared on item {item}")
w.note = changes["heat"]
elif "na" in changes:
w.log(f"Item {item} marked N/A (not traceable)" if changes["na"] else f"Item {item} marked traceable")
elif "issued" in changes and len(changes) == 1:
w.log(f"Issued qty changed on item {item}", f"{changes['issued']} of {line['qty']}")
elif "mtr_asset_id" in changes and len(changes) == 1:
w.log(f"MTR attached to item {item}" if changes["mtr_asset_id"] else f"MTR removed from item {item}")
else:
w.log(f"Item {item} changed")
_log_line(w, item, line, changes, given)
if newly_pulled:
_auto_pull(w)
return _read(d, pp, packet_id)
def _log_line(w: Write, item: int, line: dict[str, Any], changes: dict[str, Any], given: set[str]) -> None:
"""The packet_log text of a line edit. Fields the server set along the way (issued on a Pulled tick, the heat
cleared by N/A) do not count as a second field."""
kinds = {k for k in changes if k not in ("pulled_at", "pulled_by")}
if "pulled" in kinds and "issued" not in given:
kinds.discard("issued")
if "na" in kinds and "heat" not in given:
kinds.discard("heat")
if len(kinds) != 1:
w.log(f"Line changed · item {item}")
return
kind = next(iter(kinds))
if kind == "pulled":
w.log(f"Pulled · item {item}" if changes["pulled"] else f"Pulled undone · item {item}")
elif kind == "heat":
if changes["heat"]:
w.log(f"Heat recorded · item {item}", changes["heat"])
else:
w.log(f"Heat cleared · item {item}")
elif kind == "na":
w.log(f"Marked N/A · item {item}" if changes["na"] else f"Traceable again · item {item}")
elif kind == "issued":
w.log(f"Issued qty changed · item {item}", f"{qty_text(changes['issued'])} of {qty_text(line['qty'])}")
elif kind == "loc":
w.log(f"Location set · item {item}", changes["loc"] or None)
elif kind == "mtr_asset_id":
w.log(f"MTR attached · item {item}" if changes["mtr_asset_id"] else f"MTR removed · item {item}")
else:
w.log(f"Line changed · item {item}")
@router.post("/{packet_id}/bom/{item}/short", dependencies=WRITE)
def save_short(
packet_id: int,
@@ -563,6 +619,8 @@ def save_short(
) -> dict[str, Any]:
"""Open a short on a line, or update the open one. expect_open says which the caller saw."""
reason = clean_opt(body.reason, LIMITS["reason"])
if reason not in SHORT_REASONS:
raise ApiError(400, "invalid_reason", "Pick one of the short reasons.")
ref = clean_opt(body.ref, LIMITS["po"])
note = clean_opt(body.note, LIMITS["note"])
eta = clean_opt(body.eta, 40)
@@ -595,7 +653,7 @@ def save_short(
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)",
(packet_id, line["id"], body.qty, body.qty, reason, ref, eta, note, w.user_id),
)
label = f"Short recorded on item {item}"
label = f"Short recorded · item {item}"
else:
received = (_num(short["qty"]) or Decimal(0)) - (_num(short["qty_open"]) or Decimal(0))
update_row(
@@ -611,7 +669,7 @@ def save_short(
"note": note,
},
)
label = f"Short updated on item {item}"
label = f"Short updated · item {item}"
values: dict[str, Any] = {"issued": need - body.qty}
_set_pulled(w, line, values)
if heat is not None and not line["na"]:
@@ -621,8 +679,8 @@ def save_short(
if changes:
_apply_line(w, line, changes)
w.changed("bom.short")
w.detail["item"] = item
parts = [f"{body.qty} of {need} short"]
w.detail.update({"item": item, "qty": body.qty})
parts = [f"{body.qty} of {qty_text(need)} short"]
parts += [x for x in (reason, ref, f"ETA {eta}" if eta else None) if x]
w.log(label, " · ".join(parts))
if newly_pulled:
@@ -660,8 +718,8 @@ def clear_short(
if changes:
_apply_line(w, line, changes)
w.changed("bom.short")
w.detail["item"] = item
w.log(f"Short cleared on item {item}")
w.detail.update({"item": item, "qty": float(short["qty_open"])})
w.log(f"Short cleared · item {item}")
return _read(d, pp, packet_id)
@@ -674,14 +732,17 @@ def receive_short(
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
"""The warehouse receives short parts, in any status but filed. The short closes when nothing is open."""
"""Receive short parts. The short closes when nothing is open.
Receive by custody: a warehouse member receives in any status but filed (WH_RECEIVE_SHORT); anyone else
receives as the holder of the packet (EDIT_BOM), and the policy refuses everyone who is neither.
"""
heat = _heat(body.heat)
note = clean_opt(body.note, LIMITS["note"])
pp = _pp(d, p)
conn = d.conn
with packet_write(
d, p, packet_id, A.WH_RECEIVE_SHORT, request=request, it_reason=body.it_reason, policy_principal=pp
) as w:
action = A.WH_RECEIVE_SHORT if "WH" in pp.support else A.EDIT_BOM
with packet_write(d, p, packet_id, action, request=request, it_reason=body.it_reason, policy_principal=pp) as w:
line = _bom_line(conn, packet_id, item)
short = _open_short(conn, line["id"])
if short is None:
@@ -710,10 +771,11 @@ def receive_short(
_apply_line(w, line, values)
w.changed("bom.short")
w.detail.update({"item": item, "qty": body.qty})
tail = f"{still} still short" if still > 0 else "short closed"
tail = f"{qty_text(still)} still short" if still > 0 else "short closed"
w.log(
f"Short received on item {item}",
" · ".join(x for x in (f"{body.qty} pc", heat and f"heat {heat}", tail) if x),
f"Short received · item {item}",
" · ".join(x for x in (f"{body.qty} pc", heat and f"heat {heat}", tail) if x)
+ _status_tail(conn, w.packet),
)
return _read(d, pp, packet_id)
@@ -1141,10 +1203,10 @@ def set_wh_stage(
) -> dict[str, Any]:
"""Move the warehouse board: incoming, pulling, hold, pulled. expect_stage is what the caller saw."""
reason = clean_opt(body.reason, LIMITS["reason"])
note = clean_opt(body.note, LIMITS["note"])
note = clean_opt(body.note, WH_NOTE_MAX)
if body.stage == "hold":
if reason is None:
raise ApiError(400, "reason_required", "Say why it is on hold.")
if reason not in WH_HOLD_REASONS:
raise ApiError(400, "invalid_reason", "Pick one of the hold reasons.")
if reason == "Other" and note is None:
raise ApiError(400, "reason_required", "Say what it's waiting on.")
pp = _pp(d, p)
@@ -1173,7 +1235,10 @@ def set_wh_stage(
note=note,
auto=False,
)
label = "Pulling resumed" if stage == "hold" and body.stage == "pulling" else WH_LABELS.get(body.stage, "")
if body.stage == "pulling" and stage in WH_PULLING_FROM:
label = WH_PULLING_FROM[stage]
else:
label = WH_LABELS.get(body.stage, "")
w.changed("wh")
w.detail.update({"wh_from": stage, "wh_to": body.stage})
w.log(