Files
mercadodevida/work/artifacts/NOTIF-INVENTORY-ALERTS/architect.md

12 KiB
Raw Blame History

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.tsinventoryOverviewFilterClause(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.tsinventoryOverviewQuerySchema 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 admin role (mismo patrón que GET /inventory/admin/overview).
  • Counts se calculan con la misma CTE ya probada en /inventory/admin/overview, con un COUNT(*) 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:

  1. Nuevo inventoryApi.notificationCounts() en api-client.ts.
  2. Estado inventoryItems, inventoryTotal y tab='inventory'.
  3. Tercer botón de tab con icono 🏷️ y badge con inventoryTotal.
  4. Función TabInventory() que renderiza la lista de items: badge de color según tone (amber/red), label, count, y enlace href (que va a /inventory?filter=...).
  5. El grandTotal ahora suma staleTotal + awaitingTotal + inventoryTotal.

2.4 Tests

  • Backend: unit test del helper inventoryOverviewFilterClause con un caso para expired (siguiendo el patrón de los tests existentes).
  • Backend: integration test del nuevo endpoint /inventory/admin/notifications con 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.tsx más allá de la nueva tab.

3 · Alcance (scope)

Dentro:

  • project/src/modules/inventory/api/inventory.routes.ts: nuevo branch expired en inventoryOverviewFilterClause + inventoryOverviewOrderBy + inventoryOverviewFilterSchema + endpoint GET /inventory/admin/notifications.
  • project/apps/admin/src/lib/api-client.ts: nuevo método inventoryApi.notificationCounts().
  • project/apps/admin/src/components/OrderNotifications.tsx: tercera tab "Inventario".
  • Tests: unit del helper + integration del endpoint.
  • work/artifacts/NOTIF-INVENTORY-ALERTS/implementer.md con evidencia.

Fuera (deliberado):

  • BellNotifications.tsx (componente dead, no se reactiva).
  • Umbrales configurables (futuro ticket).
  • Cambios a schema de BD (la columna expiration_date ya existe, migración 033).

4 · Decisiones de diseño y por qué

  • Reutilizar inventoryOverviewFilterClause en vez de escribir SQL inline. Cero duplicación; los counts del campanita y los items del inventario siempre coinciden.
  • Endpoint separado inventory/admin/notifications en 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 OrderNotifications en 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

  1. GET /api/inventory/admin/notifications requiere rol admin y devuelve { items: [{id, count, href, label, tone}], total } con solo items con count > 0.
  2. Los counts son consistentes con GET /api/inventory/admin/overview?filter=<X> (mismo query base).
  3. La campanita muestra un tercer tab "🏷️ Inventario" con badge = sum de los counts.
  4. Cada item del tab Inventario es clickable y navega a /inventory?filter=<X> donde <X>low_stock, out_of_stock, expired, expiring, low_margin.
  5. inventoryOverviewFilterSchema acepta expired como filtro válido.
  6. Backend unit test del helper inventoryOverviewFilterClause cubre los 5 filtros (4 existentes + expired).
  7. Backend integration test del nuevo endpoint con DB real valida los counts contra una fixture conocida.
  8. Frontend typecheck + build verde.
  9. ./scripts/verify.sh → exit 0.

6 · Verificación esperada

  • npx tsc --noEmit en 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

  1. Editar project/src/modules/inventory/api/inventory.routes.ts:
    • Añadir 'expired' a inventoryOverviewFilterSchema.
    • Añadir branch if (filter === 'expired') return 'expiration_date IS NOT NULL AND expiration_date < current_date'; en inventoryOverviewFilterClause.
    • Añadir branch de ordenación para expired (por expiration_date DESC NULLS LAST).
    • Añadir nuevo handler GET /inventory/admin/notifications con auth admin, que ejecute 5 COUNT(*) sobre la misma CTE base y devuelva { items, total }.
  2. Editar project/apps/admin/src/lib/api-client.ts:
    • Añadir inventoryApi.notificationCounts(): Promise<{ items: ...; total: number }>.
  3. Editar project/apps/admin/src/components/OrderNotifications.tsx:
    • Importar inventoryApi desde @/lib/api-client.
    • Estado inventoryItems/inventoryTotal + tipo 'inventory' para tab.
    • Función TabInventory() con 5 filas: badge de color + label + count + Link a /inventory?filter=....
    • Tercer botón de tab con icono 🏷️ y badge.
    • grandTotal suma los 3 totales.
    • load() llama a los 3 endpoints en Promise.all.
  4. Tests:
    • Backend unit: añadir caso expired en el test existente del helper.
    • Backend integration: inventory-notifications.itest.ts con DB real (siguiendo patrón de inventory.itest.ts si existe).
  5. ./scripts/verify.sh y ./scripts/run_stage.py (si aplica) verde.

9 · Próximo stage

build (implementer): ejecutar §8.