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 -2
View File
@@ -1,4 +1,5 @@
"""Admin: users and their AD view (read-only), PIN clearing, devices, the group map, AD refresh, attempts.
"""Admin: users and their AD view (read-only), PIN clearing, devices, the group map, AD refresh, attempts, storage
and alerts.
Every route needs the admin role and a password session. Roles cannot be changed here: they come from AD.
"""
@@ -21,8 +22,9 @@ from ..auth import groups as groupmap
from ..auth import pin as pins
from ..auth.ldap import LdapUnavailable
from ..auth.signin import ldap_down
from ..domain import assets, scan
from .account import active_sessions
from .deps import Db, Principal, admin_principal, directory_of, get_db
from .deps import Db, Principal, admin_principal, directory_of, get_db, settings_of
from .errors import ApiError
router = APIRouter(prefix="/api/admin", tags=["admin"])
@@ -307,3 +309,102 @@ def signin_attempts(
}
for r in rows
]
# ---------------------------------------------------------------- storage and alerts
PING_MAX_SECONDS = 5.0
ALERT_SELECT = (
"SELECT n.*, u.display_name AS acknowledged_name FROM notification_events n"
" LEFT JOIN users u ON u.id = n.acknowledged_by"
)
def _alert_row(r: dict[str, Any]) -> dict[str, Any]:
by = r.get("acknowledged_by")
return {
"id": r["id"],
"kind": r["kind"],
"severity": r["severity"],
"subject": r["subject"],
"detail": r["detail"] or {},
"created_at": r["created_at"],
"resolved_at": r["resolved_at"],
"acknowledged_at": r["acknowledged_at"],
"acknowledged_by": {"id": str(by), "display_name": r.get("acknowledged_name")} if by else None,
}
@router.get("/storage")
def storage(
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
s = settings_of(request)
usage = assets.usage_for(d.conn, s)
with d.conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(
ALERT_SELECT + " WHERE n.kind = %s AND coalesce(n.subject, '') = %s AND n.resolved_at IS NULL"
" ORDER BY n.id LIMIT 1",
(assets.QUOTA_KIND, assets.QUOTA_SUBJECT),
).fetchone()
scanner = scan.scanner_for(s)
clamav: dict[str, Any] = {"configured": scanner is not None, "host": None, "port": None, "reachable": None}
if scanner is not None:
clamav.update(
host=scanner.host,
port=scanner.port,
reachable=scanner.ping(timeout=min(scanner.timeout, PING_MAX_SECONDS)),
)
return {
"usage": usage,
"quota_warn_pct": int(s.assets_quota_warn_pct),
"quota_bytes": int(s.assets_quota_bytes),
"alert": _alert_row(dict(r)) if r else None,
"clamav": clamav,
}
@router.get("/alerts")
def alerts(
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
with d.conn.cursor(row_factory=dict_row) as cur:
rows = cur.execute(
ALERT_SELECT + " WHERE n.resolved_at IS NULL ORDER BY n.created_at DESC, n.id DESC"
).fetchall()
return {"alerts": [_alert_row(dict(r)) for r in rows]}
@router.post("/alerts/{alert_id}/ack")
def ack_alert(
alert_id: int,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
conn = d.conn
with conn.transaction():
done = conn.execute(
"UPDATE notification_events SET acknowledged_at = now(), acknowledged_by = %s"
" WHERE id = %s AND acknowledged_at IS NULL RETURNING kind, subject",
(p.user_id, alert_id),
).fetchone()
if done is not None:
audit.append(
conn,
action="alert.acknowledged",
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
subject_type="notification",
subject_id=str(alert_id),
detail={"kind": done[0], "subject": done[1]},
ip=p.ip,
)
with conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(ALERT_SELECT + " WHERE n.id = %s", (alert_id,)).fetchone()
if r is None:
raise ApiError(404, "not_found", "No such alert.")
return _alert_row(dict(r))
+11 -7
View File
@@ -10,7 +10,6 @@ from typing import Any
from fastapi import APIRouter, Depends, Query, Request, Response
from fastapi.responses import FileResponse
from psycopg.types.json import Jsonb
from starlette.concurrency import run_in_threadpool
from starlette.datastructures import UploadFile
@@ -32,9 +31,12 @@ async def upload(
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
s = settings_of(request)
# The purpose field is inside the body, so the pre-check uses the largest kind limit. The store applies the
# kind's own limit while it streams.
pre = assets.max_upload_limit(s)
length = request.headers.get("content-length")
if length is not None and length.isdigit() and int(length) > s.upload_max_bytes + FORM_SLACK:
raise ApiError(413, "too_large", f"The file is over the {s.upload_max_bytes // (1024 * 1024)} MB limit.")
if length is not None and length.isdigit() and int(length) > pre + FORM_SLACK:
raise assets.too_large(pre)
form = await request.form(max_files=1, max_fields=8)
try:
file = form.get("file")
@@ -51,8 +53,10 @@ async def upload(
)
if exists is None:
raise ApiError(404, "not_found", "No such packet.")
purpose = form.get("purpose")
ref = {"purpose": str(purpose)[:40]} if isinstance(purpose, str) and purpose.strip() else None
raw_purpose = form.get("purpose")
if raw_purpose is not None and not isinstance(raw_purpose, str):
raise ApiError(422, "invalid_request", "purpose must be text.", extra={"field": "purpose"})
kind = assets.kind_of(raw_purpose)
upload_file = file
return await run_in_threadpool(
lambda: assets.store_upload(
@@ -62,8 +66,8 @@ async def upload(
original_name=upload_file.filename,
user=p.user_id,
packet_id=packet_id,
source_ref=None if ref is None else Jsonb(ref),
actor=p,
purpose=kind.name,
)
)
finally:
@@ -96,7 +100,7 @@ def blob(
path = assets.open_blob(settings_of(request), row)
etag = '"' + bytes(row["sha256"]).hex() + '"'
headers = {
"Content-Disposition": f'{"attachment" if download else "inline"}; filename="{assets.download_name(row)}"',
"Content-Disposition": assets.disposition(row, bool(download)),
"X-Content-Type-Options": "nosniff",
"Cache-Control": "private, max-age=3600",
"ETag": etag,
+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(
+185
View File
@@ -0,0 +1,185 @@
"""The warehouse board: KPIs, the four stage columns, the released column and the open shorts.
Any signed-in session reads it. It reads PostgreSQL only. The cards are the list_cards shape, so the pill, the wh
line and the pull progress read the same on every screen.
"""
from __future__ import annotations
from datetime import UTC, date, datetime
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import APIRouter, Depends, Query, Request
from ..domain import packets
from ..text import clean_text
from .deps import Db, Principal, current_principal, get_db, settings_of
router = APIRouter(prefix="/api/warehouse", tags=["warehouse"])
STAGES = ("incoming", "pulling", "hold", "pulled")
RELEASED_DAYS = 3
BOARD_LIMIT = 5000
# Packets with a wh_release transition in the window; a packet back in the warehouse is on a stage column instead.
RELEASED_SQL = """
SELECT t.packet_id, max(t.at) AS released_at
FROM packet_transitions t JOIN packets p ON p.id = t.packet_id JOIN units u ON u.id = p.unit_id
WHERE t.action = 'wh_release' AND t.at > now() - %s * interval '1 day' AND p.status <> 'warehouse'
AND p.removed_at IS NULL {scope}
GROUP BY t.packet_id
"""
SHORTS_SQL = """
SELECT s.id AS short_id, s.packet_id, s.bom_line_id, s.qty_open, s.reason, s.ref, s.eta, s.note, s.opened_at,
s.row_version, b.item, b.description, b.part_id, b.material, b.length, b.qty, b.na
FROM bom_shorts s JOIN bom_lines b ON b.id = s.bom_line_id
WHERE s.closed_at IS NULL AND s.packet_id = ANY(%s)
ORDER BY s.eta NULLS LAST, s.opened_at, s.id
"""
# The KPIs. {scope} is the fixed project and unit terms from _scope, never request text.
KPI_NAMES = {"incoming": "waiting", "pulling": "pulling", "hold": "hold", "pulled": "pulled"}
KPI_STAGES_SQL = """
SELECT coalesce(w.stage::text, 'incoming'), count(*)
FROM packets p JOIN units u ON u.id = p.unit_id LEFT JOIN packet_wh w ON w.packet_id = p.id AND w.closed_at IS NULL
WHERE p.status = 'warehouse' AND p.removed_at IS NULL {scope}
GROUP BY 1
"""
KPI_SHORTS_SQL = """
SELECT count(*) FROM bom_shorts s JOIN packets p ON p.id = s.packet_id JOIN units u ON u.id = p.unit_id
WHERE s.closed_at IS NULL AND p.removed_at IS NULL {scope}
"""
KPI_RELEASED_SQL = """
SELECT count(DISTINCT t.packet_id)
FROM packet_transitions t JOIN packets p ON p.id = t.packet_id JOIN units u ON u.id = p.unit_id
WHERE t.action = 'wh_release' AND t.at > now() - interval '24 hours' AND p.status <> 'warehouse'
AND p.removed_at IS NULL {scope}
"""
def _tz(request: Request) -> Any:
try:
return ZoneInfo(settings_of(request).site_tz)
except (ZoneInfoNotFoundError, ValueError):
return UTC
def _scope(filters: dict[str, Any]) -> tuple[str, list[Any]]:
"""The project and unit terms (the KPIs follow these only), over packets p and units u."""
terms: list[str] = []
params: list[Any] = []
if filters.get("unit_id") is not None:
terms.append("p.unit_id = %s")
params.append(int(filters["unit_id"]))
if filters.get("project_id") is not None:
terms.append("u.project_id = %s")
params.append(int(filters["project_id"]))
return "".join(f" AND {t}" for t in terms), params
def _order_key(card: dict[str, Any], tz: Any) -> tuple[str, str, float]:
"""The old whOrder: the day it was sent, then VISUAL's release date (none last), then the exact time."""
sent = card.get("wh_sent_at")
day = sent.astimezone(tz).date().isoformat() if isinstance(sent, datetime) else "9999"
rel = card.get("visual_rel")
rel_text = rel.isoformat() if isinstance(rel, date) else "9999"
exact = sent.timestamp() if isinstance(sent, datetime) else float("inf")
return day, rel_text, exact
def _kpis(conn: Any, filters: dict[str, Any]) -> dict[str, int]:
scope, params = _scope(filters)
out = {"waiting": 0, "pulling": 0, "hold": 0, "pulled": 0, "open_shorts": 0, "released_24h": 0}
for stage, n in conn.execute(KPI_STAGES_SQL.format(scope=scope), params):
if stage in KPI_NAMES:
out[KPI_NAMES[stage]] = int(n)
out["open_shorts"] = int(conn.execute(KPI_SHORTS_SQL.format(scope=scope), params).fetchone()[0])
out["released_24h"] = int(conn.execute(KPI_RELEASED_SQL.format(scope=scope), params).fetchone()[0])
return out
def _short_row(s: dict[str, Any], card: dict[str, Any], today: date, now: datetime) -> dict[str, Any]:
eta = s["eta"]
return {
"packet": {
"id": card["id"],
"unit_number": card["unit"]["number"],
"op": card["op"],
"drawing": card["drawing"],
"status": card["status"],
"wh_stage": card["wh_stage"],
"station": card["station"],
"holder": card["holder"],
"dept": card["dept"],
},
"item": s["item"],
"bom_line_id": s["bom_line_id"],
"description": s["description"],
"part_id": s["part_id"],
"material": s["material"],
"length": s["length"],
"qty": s["qty"],
"qty_open": s["qty_open"],
"na": bool(s["na"]),
"reason": s["reason"],
"ref": s["ref"],
"eta": eta,
"note": s["note"],
"opened_at": s["opened_at"],
"age_days": max(0, (now - s["opened_at"]).days),
"late": eta is not None and eta < today,
"short_id": s["short_id"],
"row_version": s["row_version"],
}
@router.get("/board")
def board(
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
q: str = Query("", max_length=200),
project_id: int | None = Query(None),
unit_id: int | None = Query(None),
dept: str = Query("", max_length=10),
) -> dict[str, Any]:
conn = d.conn
pp = packets.policy_principal_for(conn, p)
tz = _tz(request)
filters: dict[str, Any] = {
"q": clean_text(q, 200),
"project_id": project_id,
"unit_id": unit_id,
"dept": dept.strip() or None,
"limit": BOARD_LIMIT,
}
columns: dict[str, list[dict[str, Any]]] = {st: [] for st in (*STAGES, "released")}
for card in packets.list_cards(conn, pp, {**filters, "status": "warehouse"}):
columns[card["wh_stage"] if card["wh_stage"] in STAGES else "incoming"].append(card)
for st in STAGES:
columns[st].sort(key=lambda c: _order_key(c, tz))
scope, params = _scope(filters)
released = {int(r[0]): r[1] for r in conn.execute(RELEASED_SQL.format(scope=scope), [RELEASED_DAYS, *params])}
if released:
cards = packets.list_cards(conn, pp, {**filters, "ids": list(released)})
for card in cards:
card["released_at"] = released[int(card["id"])]
cards.sort(key=lambda c: (c["released_at"], c["id"]), reverse=True)
columns["released"] = cards
short_ids = [int(r[0]) for r in conn.execute("SELECT DISTINCT packet_id FROM bom_shorts WHERE closed_at IS NULL")]
shorts: list[dict[str, Any]] = []
if short_ids:
by_id = {int(c["id"]): c for c in packets.list_cards(conn, pp, {**filters, "ids": short_ids})}
if by_id:
now = datetime.now(UTC)
today = now.astimezone(tz).date()
for sr in packets.rows(conn, SHORTS_SQL, (list(by_id),)):
shorts.append(_short_row(sr, by_id[int(sr["packet_id"])], today, now))
return {"kpis": _kpis(conn, filters), "columns": columns, "shorts": shorts}