# 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. ```jsonc // 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: ```ts 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=` (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=` donde `` ∈ `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.