# Alertas de empresa — Fase 2: reglas de negocio en la bandeja única

**Fecha**: 15 de septiembre de 2026
**Estado**: Diseño aprobado — pendiente plan de implementación
**Fase anterior**: `docs/plan-alertas-empresa.md` (Fase 1: eventos SIFEN, implementada y verificada el 15/09/2026)
**Repos**: `novasispy-backend-api` (NestJS + Prisma) y `novasispy-erp` (React)

---

## Problema

Las reglas de negocio del Dashboard IA (`AlertsService.detectarYGuardarAlertas`) corren cada hora por
empresa y hacen `createMany` sin deduplicar ni resolver. En la base local hay 3.156 alertas abiertas:
2.918 `stock_bajo` y 216 `mora_critica` son los mismos 20 casos repetidos en cada corrida. Además:

- El `LIMIT 20` de cada consulta hace que el conjunto rote: empresas con 2.502 clientes en mora o 3.917
  productos con stock bajo nunca ven el cuadro completo.
- "Leída" es por empresa; no hay privilegio por módulo (sólo RRHH se filtra por permiso); no hay ruta a la
  pantalla; nada se cierra solo cuando la condición desaparece.
- Estas alertas sólo se ven en el Dashboard IA. La campana, el Inicio y el popup de Fase 1 no las conocen.

Objetivo: **las reglas escriben en la bandeja única de Fase 1** (`AlertasEmpresaService.registrar` /
`resolver`), una alerta agregada por regla y empresa, con auto-resolución, privilegio por módulo, ruta a la
pantalla y lecturas por usuario; y **el Dashboard IA lee esa misma bandeja**.

## Decisiones (cerradas 15/09/2026)

| # | Decisión | Resolución |
|---|---|---|
| 1 | Dashboard IA | Lee la bandeja nueva (`GET /alertas-empresa`). Una sola bandeja para campana, Inicio, Dashboard y popup. |
| 2 | Visibilidad | Por privilegio "ver" del módulo de cada regla (tabla abajo), como las alertas SIFEN. |
| 3 | Resolución | Automática (`auto`) cuando la condición desaparece en la corrida siguiente. "Resolver" manual sigue existiendo como descarte. |
| 4 | Granularidad | **Una alerta por regla y empresa**, con resumen (conteo, total, 5 peores casos). Sin `LIMIT`. |
| 5 | Dónde vive la detección | Se queda en `AlertsService` (tiene los umbrales de `ai_empresa_config` y `RrhhContextService`); sólo cambia cómo persiste. |

---

## Parte 1 — Reglas como alertas agregadas

### Catálogo de reglas

Cada regla produce **cero o una** alerta por empresa. `origen = 'regla'`, `clave = <tipo>`.

| tipo (clave) | modulo | privilegio_requerido | ruta | criticidad |
|---|---|---|---|---|
| `mora_critica` | COBRANZAS | `COB_CC_CUENTA_COBRAR_VER` | `/cobranzas/cuentas-cobrar` | alta si algún cliente ≥ 60 días, si no media |
| `limite_credito_superado` | COBRANZAS | `COB_CC_CUENTA_COBRAR_VER` | `/cobranzas/cuentas-cobrar` | alta |
| `stock_bajo` | INVENTARIO | `INV_STK_STOCK_VER` | `/reportes/inventario/stock` | alta si algún producto en 0, si no media |
| `cheque_por_vencer` | TESORERIA | `TES_CHQ_CHEQUE_VER` | `/tesoreria-bancos?tab=tes_cheques` | alta si alguno vence en ≤ 1 día, si no media |
| `saldo_cuenta_bajo` | TESORERIA | `TES_CTA_CUENTA_BANCARIA_VER` | `/tesoreria-bancos?tab=tes_cuentas` | alta si alguna < Gs. 100.000, si no media |
| `rrhh_liquidacion_no_cerrada` | RRHH | `RH_LIQ_LIQUIDACION_VER` | `/rrhh?tab=liquidaciones` | alta |
| `rrhh_empleados_sin_ips` | RRHH | `RH_EMP_EMPLEADO_VER` | `/rrhh?tab=empleados` | media |
| `rrhh_empleados_sin_cbu` | RRHH | `RH_EMP_EMPLEADO_VER` | `/rrhh?tab=empleados` | media |
| `rrhh_prestamos_sin_descuento` | RRHH | `RH_ANT_PRESTAMO_VER` | `/rrhh?tab=prestamos` | media |
| `rrhh_vacaciones_vencidas` | RRHH | `RH_VAC_VACACION_VER` | `/rrhh?tab=vacaciones` | baja |

Los umbrales (`umbral_alerta_mora`, `umbral_stock_bajo`, `rrhh_dia_corte_liquidacion`,
`rrhh_alertas_habilitadas`) siguen saliendo de `ai_empresa_config`, como hoy.

### Contenido de cada alerta

- `titulo`: conteo + magnitud. Ej.: `2.502 clientes con más de 30 días de mora (Gs. 184.300.000)`,
  `37 productos con stock bajo (5 sin stock)`, `Liquidación 08/2026 sin cerrar`.
- `descripcion`: los **5 peores casos** en una línea cada uno (`RAMÍREZ, LAURA — 94 días — Gs. 1.200.000`).
  Para las reglas RRHH de conteo, el texto actual.
- `accion_sugerida`: el texto actual de cada regla.
- `datos`: `{ cantidad, total?, items: [{ id, nombre, valor, detalle }] (≤ 5) }` para que el frontend
  pueda listar los ítems sin parsear la descripción.
- `entidad_tipo` / `entidad_id`: se dejan en NULL (la alerta es agregada; la entidad va en `datos.items`).
- `sucursal_id`: NULL (de toda la empresa). Alertas por sucursal quedan fuera de esta fase.
- Montos formateados con `es-PY` como hoy; las reglas de mora y crédito están en guaraníes (las consultas
  actuales no discriminan moneda y se mantiene así).

### Ciclo de vida en cada corrida

`AlertsService.detectarYGuardarAlertas(empresaId)` (invocado por `RecalculoService.ejecutar`, cron horario
sin cambios):

1. Evalúa todas las reglas y arma la lista de `RegistrarAlertaInput` (0 o 1 por regla).
2. `registrar()` cada una. Si ya había una abierta con esa clave se actualiza (texto, `datos`, criticidad,
   `updated_at`); si la criticidad sube, `registrar` ya borra las lecturas (vuelve a no-leída).
3. Para cada clave del catálogo que **no** produjo alerta en esta corrida → `resolver(empresaId, clave,
   'auto')` (no-op si no había abierta).
4. Reglas apagadas por configuración (RRHH con `rrhh_alertas_habilitadas = false` o empresa sin módulo RRHH)
   cuentan como "sin alerta": se resuelven en auto.
5. Regla que **falla** (excepción en la consulta, p. ej. tabla inexistente): se loguea y su clave se excluye
   tanto de registrar como de resolver. Sin evidencia de que la condición desapareció, la alerta abierta queda
   como está.
6. Devuelve `{ registradas, resueltas, errores }` (hoy devuelve un número).

**Descarte manual**: "Resolver" desde el Dashboard o la campana marca `resolucion = 'manual'`. Como
`registrar` sólo busca alertas **abiertas**, en la corrida siguiente la regla la vuelve a crear si la condición
persiste. El descarte dura como mucho una hora; silenciar una regla por más tiempo no está en esta fase.

### Limpieza de datos viejos

Migración SQL idempotente `20260915_alertas_reglas_resolver_viejas`:

```sql
UPDATE ai_alertas
   SET resuelta = true, resuelta_at = now(), resolucion = 'auto', updated_at = now()
 WHERE resuelta = false AND origen = 'regla' AND clave IS NULL;
```

No se borra nada: quedan como histórico. En la primera corrida después del deploy cada regla crea su alerta
agregada.

---

## Parte 2 — Backend: servicio y endpoints

### `AlertasEmpresaService` (existente) gana

- `resolverPorId(alertaId, empresaId, usuarioId): Promise<boolean>` — `resolucion = 'manual'`,
  `resuelta_por = usuarioId`. `NotFoundException` si la alerta no es de la empresa. Lo usan el Dashboard IA
  y la campana.

### `AlertsService` (`src/ai-dashboard/alerts/`) cambia

- Inyecta `AlertasEmpresaService` (el módulo `AiDashboardModule` importa `AlertasEmpresaModule`).
- Cada regla pasa a ser un método privado `regla<Nombre>(empresaId, config): Promise<RegistrarAlertaInput |
  null>`; `detectarYGuardarAlertas` los recorre con el ciclo de vida de arriba.
- Un catálogo `REGLAS: { clave, modulo, privilegio, ruta }[]` es la fuente de verdad de las claves a resolver.
- Se eliminan `getAlertas`, `marcarLeida`, `marcarResuelta` (nadie más los usa).

### `ai-dashboard.controller.ts`

Los tres endpoints quedan como fachada sobre `AlertasEmpresaService`, para no romper llamadas existentes:

| Endpoint | Antes | Ahora |
|---|---|---|
| `GET /ai-dashboard/alertas` | `findMany` por empresa + filtros de dominio y RRHH a mano | `listarParaUsuario(user.id, user.empresa_id)` |
| `PATCH /ai-dashboard/alertas/:id/leer` | `leida = true` por empresa | `marcarLeida(id, user.id, user.empresa_id)` |
| `PATCH /ai-dashboard/alertas/:id/resolver` | `resuelta = true` | `resolverPorId(id, user.empresa_id, user.id)` |

Y `AlertasEmpresaController` suma `PATCH /alertas-empresa/:id/resolver` (mismo `resolverPorId`).

---

## Parte 3 — Frontend (`novasispy-erp`)

- `src/api/alertas-empresa.service.js`: `resolverAlerta(id)` → `PATCH /alertas-empresa/:id/resolver`.
  `src/api/ai-dashboard.service.js` pierde `getAlertas`, `marcarAlertaLeida`, `resolverAlerta`.
- `src/tanstack/AlertasEmpresaStack.jsx`: `useResolverAlertaMutation()` (invalida `ALERTAS_EMPRESA_KEY`).
- `AlertaEmpresaItem` (`_standards`):
  - Botón **Resolver** (con confirmación breve: "Se vuelve a generar si la condición persiste") para toda
    alerta que **no** sea `sifen_evento` (esas se cierran con Reconsultar / Liberar).
  - Si `datos.items` existe, la descripción se muestra como lista corta (nombre — valor — detalle) en lugar
    del texto plano.
- `AlertasPanel.jsx` (Dashboard IA): reemplaza su query y sus tarjetas propias por `useAlertasEmpresaQuery`
  + `AlertaEmpresaItem`, manteniendo la separación "Alta" / "Otras" y el estado vacío actual. Se borran las
  tarjetas estilizadas propias (`Card`, `CriticidadBadge`, `Btn`).
- Campana, Inicio, banners y popup no cambian: las alertas de regla entran solas por la bandeja compartida.
  El popup de criticidad alta también las muestra (una vez por sesión).

Estándares: enums de `_standards/enums` (`CRITICIDAD_ALERTA`), fechas con `fmtFechaHora`, layout por
contenedor.

---

## Pruebas

**Backend (jest)**
- `AlertsService` con Prisma y `AlertasEmpresaService` mockeados:
  - regla con resultados → `registrar` con clave, módulo, privilegio, ruta, criticidad por el peor caso y
    `datos.items` ≤ 5;
  - regla sin resultados → `resolver(empresa, clave, 'auto')`;
  - regla que lanza → ni registra ni resuelve esa clave, las demás siguen;
  - RRHH apagado / sin módulo → resuelve las 5 claves `rrhh_*`.
- `AlertasEmpresaService.resolverPorId`: marca `manual` con usuario; otra empresa → NotFound.
- `ai-dashboard.controller` (o el servicio que use): `GET /alertas` devuelve lo de `listarParaUsuario`.

**Frontend / E2E (Playwright, usuario `taller`)**
- Correr el recálculo para la empresa → alerta agregada de `stock_bajo` (3.752 productos) en Dashboard IA,
  campana e Inicio, con la misma marca de leída en los tres.
- "Ir" navega a `/reportes/inventario/stock`.
- "Resolver" la saca de todos lados; volver a correr el recálculo la crea de nuevo.
- Un usuario sin `INV_STK_STOCK_VER` no la ve.
- Las 3.156 alertas viejas quedan resueltas tras la migración y no aparecen.

---

## Fuera de alcance (Fase 2)

- Alertas de regla por sucursal.
- Silenciar una regla por más tiempo que una corrida (snooze).
- Alertas generadas por la IA (`origen = 'ia'`) — Fase 3.
- Canales email / WhatsApp (`canales_enviados` deja de escribirse; la columna queda) — Fase 4.
- Configurar umbrales desde la bandeja (siguen en `AIConfigPanel`).
