12 KiB
NOTIF-INVENTORY-ALERTS — Diseño técnico
Arquitecto: design · Estado: ready for build
1 · Diagnóstico
1.1 Estado actual
| Pieza | Estado |
|---|---|
OrderNotifications.tsx (campanita activa, mounted en admin layout) |
Solo pedidos: tab "Pendientes" (PENDING + SHIPPED stale >24h) y tab "Esperando pago" (AWAITING_PAYMENT). Llama a /api/orders/notifications/stale y /api/orders/notifications/awaiting-payment. |
BellNotifications.tsx (más simple) |
No montado (dead code). Llama a /api/backoffice/notifications que solo tiene 3 tipos de pedido. |
/api/backoffice/notifications y /api/admin/notifications |
Ambas solo devuelven items de pedidos (pending_orders, stale_shipped, awaiting_payment). |
inventory.routes.ts → inventoryOverviewFilterClause(filter) |
Ya tiene la lógica de filtros low_stock, out_of_stock, expiring, low_margin con umbrales hardcoded: < 5 para low_stock, <= today+30 para expiring, < 30% para low_margin. Reusable tal cual. |
inventory.routes.ts → inventoryOverviewQuerySchema |
Acepta filter con enum. Counts se devuelven en stats.{lowStock, outOfStock}. Reusable. |
1.2 Lo que falta
El campanita actual NO incluye ninguna alerta operativa de inventario. El operador
que quiere saber "qué productos están sin stock, caducados o con margen bajo"
tiene que entrar a /inventory y aplicar filtros manualmente.
Los 4 tipos de alerta que el operador quiere ver en el campanita:
| Alerta | Lógica SQL ya implementada en inventoryOverviewFilterClause |
|---|---|
| Stock bajo | available_quantity > 0 AND available_quantity < 5 |
| Sin stock | available_quantity = 0 |
| Caducados | expiration_date IS NOT NULL AND expiration_date < current_date (NUEVO — no existe aún como filtro) |
| Por caducar | expiration_date IS NOT NULL AND expiration_date >= current_date AND expiration_date <= current_date + 30 (similar a expiring existente) |
| Margen <30% | margin_percent IS NOT NULL AND margin_percent < 30 (idéntico a low_margin existente) |
Nota: el usuario pidió 4 alertas; añado una quinta ("Por caducar") porque es la mitad "positiva" de "Caducados" y permite gestión proactiva. Si el operador la considera ruido, podemos quitarla en QA.
1.3 Gap de "caducados"
El inventario actual solo tiene el filtro expiring (dentro de 30 días), no
filtra productos ya caducados. Es un caso real que el operador debería ver
en el campanita: productos vencidos no deben venderse y aparecen en el listado
de inventario como "Caducado (fecha)" con badge rojo.
Esto se resuelve con un nuevo branch en inventoryOverviewFilterClause para
expired (con la lógica expiration_date < current_date), y reutilizar el
mismo helper en el endpoint de notificaciones.
2 · Enfoque
2.1 Backend: nuevo endpoint de notificaciones de inventario
Añadir GET /api/inventory/admin/notifications en
project/src/modules/inventory/api/inventory.routes.ts. Devuelve counts y
hrefs para los 5 tipos de alerta. Reutiliza inventoryOverviewFilterClause (con
un branch nuevo para expired) y la misma CTE que ya existe.
// GET /api/inventory/admin/notifications
{
"items": [
{ "id": "low_stock", "count": 7, "href": "/inventory?filter=low_stock", "label": "Stock bajo", "tone": "amber" },
{ "id": "out_of_stock", "count": 3, "href": "/inventory?filter=out_of_stock", "label": "Sin stock", "tone": "red" },
{ "id": "expired", "count": 2, "href": "/inventory?filter=expired", "label": "Caducados", "tone": "red" },
{ "id": "expiring_soon", "count": 5, "href": "/inventory?filter=expiring", "label": "Por caducar (≤30d)", "tone": "amber" },
{ "id": "low_margin", "count": 4, "href": "/inventory?filter=low_margin", "label": "Margen <30%", "tone": "red" }
],
"total": 21
}
- Solo se incluyen los items con
count > 0. - Auth: requiere
adminrole (mismo patrón queGET /inventory/admin/overview). - Counts se calculan con la misma CTE ya probada en
/inventory/admin/overview, con unCOUNT(*)por filtro en lugar de paginación.
2.2 Backend: branch expired en inventoryOverviewFilterClause
Añadir:
if (filter === 'expired') return 'expiration_date IS NOT NULL AND expiration_date < current_date';
Esto añade el filtro al schema (z.enum([..., 'expired'])), al inventoryOverviewFilterClause,
y al inventoryOverviewOrderBy (ordenar por expiration_date DESC NULLS LAST
para que los más recientes caducados aparezcan primero).
2.3 Frontend: nueva tab "Inventario" en OrderNotifications.tsx
Extender OrderNotifications.tsx con:
- Nuevo
inventoryApi.notificationCounts()enapi-client.ts. - Estado
inventoryItems,inventoryTotalytab='inventory'. - Tercer botón de tab con icono 🏷️ y badge con
inventoryTotal. - Función
TabInventory()que renderiza la lista de items: badge de color segúntone(amber/red), label, count, y enlacehref(que va a/inventory?filter=...). - El
grandTotalahora sumastaleTotal + awaitingTotal + inventoryTotal.
2.4 Tests
- Backend: unit test del helper
inventoryOverviewFilterClausecon un caso paraexpired(siguiendo el patrón de los tests existentes). - Backend: integration test del nuevo endpoint
/inventory/admin/notificationscon DB real: seed de 3 productos en estados (low, out, expired, expiring, low_margin, healthy) → asserts sobre counts. - Frontend: typecheck + manual smoke (UI renderiza los 5 items con conteos correctos; click navega al filtro correcto).
2.5 No se hace (scope out)
- No se añaden umbrales configurables via store_settings. Los umbrales quedan hardcoded (5, 30 días, 30%) por consistencia con los filtros existentes. Si en el futuro el operador pide flexibilidad, se añaden en otro ticket sin tocar este.
- No se cambia el componente
BellNotifications.tsx(dead code). Si se reactiva en el futuro, el nuevo endpoint estará disponible. - No se cambia
OrderNotifications.tsxmás allá de la nueva tab.
3 · Alcance (scope)
Dentro:
project/src/modules/inventory/api/inventory.routes.ts: nuevo branchexpiredeninventoryOverviewFilterClause+inventoryOverviewOrderBy+inventoryOverviewFilterSchema+ endpointGET /inventory/admin/notifications.project/apps/admin/src/lib/api-client.ts: nuevo métodoinventoryApi.notificationCounts().project/apps/admin/src/components/OrderNotifications.tsx: tercera tab "Inventario".- Tests: unit del helper + integration del endpoint.
work/artifacts/NOTIF-INVENTORY-ALERTS/implementer.mdcon evidencia.
Fuera (deliberado):
BellNotifications.tsx(componente dead, no se reactiva).- Umbrales configurables (futuro ticket).
- Cambios a schema de BD (la columna
expiration_dateya existe, migración 033).
4 · Decisiones de diseño y por qué
- Reutilizar
inventoryOverviewFilterClauseen vez de escribir SQL inline. Cero duplicación; los counts del campanita y los items del inventario siempre coinciden. - Endpoint separado
inventory/admin/notificationsen lugar de extender/api/backoffice/notifications. Pertenencia clara: las alertas de inventario viven en el módulo inventory. Si backoffice también quiere usarlas, hace un proxy simple (fuera de scope). - Tercer tab en
OrderNotificationsen vez de un componente separado. El operador ya está acostumbrado al dropdown con tabs; añadir un tercero es consistente. - 5 items en la lista (no 4): el operador dijo "caducados" pero "por caducar" es la versión proactiva del mismo problema. Coste marginal (1 row más en el dropdown), beneficio alto (gestión antes de la fecha). Si molesta, se quita en QA.
- Tono (amber/red) por item en lugar de un solo color: ayuda a escanear visualmente; stock bajo y por caducar son amber (atención), sin stock, caducados y margen bajo son red (actuar ya).
- Sin umbrales configurables: la urgencia del feature es "ver las alertas", no "configurar umbrales". El operador pidió "margen menor a 30%" con un valor concreto. Mantener hardcoded evita una UI nueva y un settings API que serían la mayor parte del esfuerzo.
5 · Acceptance criteria
GET /api/inventory/admin/notificationsrequiere roladminy devuelve{ items: [{id, count, href, label, tone}], total }con solo items con count > 0.- Los counts son consistentes con
GET /api/inventory/admin/overview?filter=<X>(mismo query base). - La campanita muestra un tercer tab "🏷️ Inventario" con badge = sum de los counts.
- Cada item del tab Inventario es clickable y navega a
/inventory?filter=<X>donde<X>∈low_stock,out_of_stock,expired,expiring,low_margin. inventoryOverviewFilterSchemaaceptaexpiredcomo filtro válido.- Backend unit test del helper
inventoryOverviewFilterClausecubre los 5 filtros (4 existentes +expired). - Backend integration test del nuevo endpoint con DB real valida los counts contra una fixture conocida.
- Frontend typecheck + build verde.
./scripts/verify.sh→ exit 0.
6 · Verificación esperada
npx tsc --noEmiten backend + admin → 0 errores.TEST_DATABASE_URL=... npx vitest run src/modules/inventory/tests/inventory.routes.test.ts(o el equivalente) → 1 unit test nuevo + 1 integration test nuevo pasando.- Smoke manual: con DB sembrada con productos en los 5 estados, abrir
/admin, abrir campanita, ver 5 items con counts correctos, click en cada uno →/inventory?filter=...con la lista filtrada.
7 · Riesgos y mitigaciones
| Riesgo | Mitigación |
|---|---|
Counts inconsistentes con /inventory?filter=X |
Reutilizar inventoryOverviewFilterClause (mismo WHERE clause); integration test compara ambos endpoints. |
| Umbral hardcoded disgusta al operador | Documentar en implementer.md como follow-up. Coste de cambiarlo: 1 endpoint nuevo + 3 inputs en settings. |
| Polling cada 5 min × 3 endpoints + bell abierto = 3 requests | El componente ya hace 2; añadir el tercero es trivial. Si la latencia sube, agrupar en un único endpoint /notifications/all. |
Tab "Inventario" oculto cuando inventoryTotal === 0 |
Decisión UX: lo mostramos siempre para que el operador sepa que existe la sección y vea "Todo en orden" cuando no hay alertas. |
Endpoint requiere admin pero OrderNotifications se muestra solo a admins (user.role === 'admin') |
Coherente: ya está gated por rol en layout. Sin cambio. |
8 · Resumen para el implementer
- Editar
project/src/modules/inventory/api/inventory.routes.ts:- Añadir
'expired'ainventoryOverviewFilterSchema. - Añadir branch
if (filter === 'expired') return 'expiration_date IS NOT NULL AND expiration_date < current_date';eninventoryOverviewFilterClause. - Añadir branch de ordenación para
expired(porexpiration_date DESC NULLS LAST). - Añadir nuevo handler
GET /inventory/admin/notificationscon authadmin, que ejecute 5 COUNT(*) sobre la misma CTE base y devuelva{ items, total }.
- Añadir
- Editar
project/apps/admin/src/lib/api-client.ts:- Añadir
inventoryApi.notificationCounts(): Promise<{ items: ...; total: number }>.
- Añadir
- Editar
project/apps/admin/src/components/OrderNotifications.tsx:- Importar
inventoryApidesde@/lib/api-client. - Estado
inventoryItems/inventoryTotal+ tipo'inventory'paratab. - Función
TabInventory()con 5 filas: badge de color + label + count + Link a/inventory?filter=.... - Tercer botón de tab con icono 🏷️ y badge.
grandTotalsuma los 3 totales.load()llama a los 3 endpoints enPromise.all.
- Importar
- Tests:
- Backend unit: añadir caso
expireden el test existente del helper. - Backend integration:
inventory-notifications.itest.tscon DB real (siguiendo patrón deinventory.itest.tssi existe).
- Backend unit: añadir caso
./scripts/verify.shy./scripts/run_stage.py(si aplica) verde.
9 · Próximo stage
→ build (implementer): ejecutar §8.