Phase 4: Warehouse screen, pull table, shorts, MTRs and the upload pipeline
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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user