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

222 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.