2026-10-16 17:13:31 -06:00
|
|
|
"""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
|
|
|
|
|
|
2026-10-18 15:08:34 -06:00
|
|
|
from ..domain import packets, search
|
2026-10-16 17:13:31 -06:00
|
|
|
from .deps import Db, Principal, current_principal, get_db, settings_of
|
2026-10-18 15:08:34 -06:00
|
|
|
from .packets import FIND
|
2026-10-16 17:13:31 -06:00
|
|
|
|
|
|
|
|
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
|
2026-10-18 15:08:34 -06:00
|
|
|
find: dict[str, Any] = FIND,
|
2026-10-16 17:13:31 -06:00
|
|
|
project_id: int | None = Query(None),
|
|
|
|
|
unit_id: int | None = Query(None),
|
|
|
|
|
) -> dict[str, Any]:
|
2026-10-18 15:08:34 -06:00
|
|
|
"""The warehouse board. The find bar values narrow the cards of every column and the shorts list; the KPIs
|
|
|
|
|
follow the project and unit only."""
|
2026-10-16 17:13:31 -06:00
|
|
|
conn = d.conn
|
|
|
|
|
pp = packets.policy_principal_for(conn, p)
|
|
|
|
|
tz = _tz(request)
|
2026-10-18 15:08:34 -06:00
|
|
|
parsed = search.parse_filters(
|
|
|
|
|
{**find, "project_id": project_id, "unit_id": unit_id},
|
|
|
|
|
pp,
|
|
|
|
|
screen="warehouse",
|
|
|
|
|
today=datetime.now(tz).date(),
|
|
|
|
|
)
|
|
|
|
|
filters: dict[str, Any] = {**search.board_filters(parsed), "limit": BOARD_LIMIT}
|
2026-10-16 17:13:31 -06:00
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
|
2026-10-18 15:08:34 -06:00
|
|
|
return {
|
|
|
|
|
"kpis": _kpis(conn, filters),
|
|
|
|
|
"columns": columns,
|
|
|
|
|
"shorts": shorts,
|
|
|
|
|
"facets": search.facets(conn, project_id=project_id, unit_id=unit_id),
|
|
|
|
|
}
|