222 lines
12 KiB
Markdown
222 lines
12 KiB
Markdown
# 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=<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. |