# Plan: Alertas de empresa (primer caso: eventos SIFEN inconsistentes)

**Fecha**: 15 de septiembre de 2026
**Estado**: Fase 1 implementada y verificada en local (15/09/2026) — sin commitear. Ver "Resultado de la verificación" al final.
**Módulos afectados**: Middleware SIFEN (sync de eventos), Facturas, Notas de Crédito, Notas de Remisión, Dashboard IA (`ai_alertas`), Sidebar / Inicio
**Docs relacionados**: `plan-rubros-multi-sucursal.md` (alcance por usuario)
**Repos**: `novasispy-backend-api` (NestJS + Prisma) y `novasispy-erp` (React)

---

## Problema

Un evento SIFEN (ECAN cancelación, EINU inutilización, EINO nominación) queda en `estado_evento = 'Pendiente'`
hasta que el sync lo resuelve. Desde el 15/09/2026 `enviarEvento` **bloquea** mandar otro evento a un documento
con uno pendiente (evita duplicarlo en SIFEN). Pero si el evento nunca se resuelve el documento queda trabado
para siempre, y nadie se entera:

- Hay 4 facturas en `EINU Pendiente` sin `fecha_evento` que el sync nunca resolvió (empresas `266cd466…` y
  `40539a9d…`); una (`001-002-0000637`) tiene `estado_sifen = 'Aprobado'`, incoherente con una inutilización.
- El sync de eventos no cubre notas de remisión.
- No existe forma de avisarle al usuario de una inconsistencia. La campana del sidebar muestra sólo novedades
  del sistema (release notes, en localStorage). `ai_alertas` existe, pero sólo se ve en el Dashboard IA, se
  duplica en cada corrida y el "leída" es por empresa.

Objetivo: **detectar la inconsistencia al consultar el middleware, notificarla y asegurar que el usuario que
corresponde la vea**, con acciones para destrabar el documento. La misma base tiene que servir a futuro para
alertas de cualquier módulo y para las que genere la IA.

---

## Decisiones (cerradas 15/09/2026)

| # | Decisión | Resolución |
|---|---|---|
| 1 | Almacén | **Una sola bandeja de alertas por empresa extendiendo `ai_alertas`** (opción A). No una tabla nueva paralela ni un campo específico de SIFEN. |
| 2 | Quién ve / resuelve | Usuarios con permiso sobre el módulo (SIFEN: `FIS_SIF_SIFEN_VER`) y admins. **Leída por usuario.** Liberar un evento exige un privilegio propio (`FIS_SIF_EVENTO_LIBERAR`). |
| 3 | Cuándo alertar | Inmediato para "el middleware no tiene el evento" e incoherencias (criticidad alta). "Sin respuesta" y "middleware falla" sólo si el evento tiene **más de 1 hora**. Se **auto-resuelve** cuando SIFEN responde. |
| 4 | Visibilidad | Campana (alertas + novedades), bloque "Atención" en Inicio, banner en las pantallas de Facturación, y **popup para criticidad alta** una vez por sesión hasta marcarla leída. |
| 5 | Salida del bloqueo | No se reenvía a ciegas. Acciones **Reconsultar** y **Liberar evento** (motivo + auditoría + reconsulta previa obligatoria). Reemplaza la idea de liberar automáticamente a las 24 h. |

---

## Parte 1 — Modelo de datos y servicio central

### Migración idempotente sobre `ai_alertas`

```sql
ALTER TABLE ai_alertas
  ADD COLUMN IF NOT EXISTS origen               VARCHAR(10)  NOT NULL DEFAULT 'regla',  -- sistema | regla | ia
  ADD COLUMN IF NOT EXISTS modulo               VARCHAR(30),                            -- FISCAL, COBRANZAS, ...
  ADD COLUMN IF NOT EXISTS clave                VARCHAR(150),                           -- deduplicación
  ADD COLUMN IF NOT EXISTS privilegio_requerido VARCHAR(60),                            -- ej. FIS_SIF_SIFEN_VER
  ADD COLUMN IF NOT EXISTS ruta                 VARCHAR(255),                           -- link a pantalla/documento
  ADD COLUMN IF NOT EXISTS sucursal_id          UUID,                                   -- null = de toda la empresa
  ADD COLUMN IF NOT EXISTS datos                JSONB,                                  -- contexto para acciones
  ADD COLUMN IF NOT EXISTS resuelta_at          TIMESTAMP(6),
  ADD COLUMN IF NOT EXISTS resuelta_por         UUID,
  ADD COLUMN IF NOT EXISTS resolucion           VARCHAR(10),                            -- auto | manual | liberada
  ADD COLUMN IF NOT EXISTS updated_at           TIMESTAMP(6) NOT NULL DEFAULT now();

-- Una sola alerta ABIERTA por clave: la misma inconsistencia actualiza, no duplica.
CREATE UNIQUE INDEX IF NOT EXISTS uq_ai_alertas_empresa_clave_abierta
  ON ai_alertas (empresa_id, clave) WHERE resuelta = false AND clave IS NOT NULL;

CREATE TABLE IF NOT EXISTS ai_alertas_lecturas (
  id                UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  alerta_id         UUID NOT NULL REFERENCES ai_alertas(id) ON DELETE CASCADE,
  usuario_id        UUID NOT NULL,
  leida_at          TIMESTAMP(6),
  mostrada_popup_at TIMESTAMP(6),
  CONSTRAINT uq_ai_alertas_lecturas UNIQUE (alerta_id, usuario_id)
);
CREATE INDEX IF NOT EXISTS idx_ai_alertas_lecturas_usuario ON ai_alertas_lecturas(usuario_id);
```

- Las alertas existentes (mora, stock, saldo, RRHH) quedan `origen = 'regla'`, `clave = NULL` y siguen
  funcionando igual. Se pasan a `registrar()` en una fase posterior (ver Fases).
- `ai_alertas.leida` (por empresa) queda en desuso para la bandeja nueva; el Dashboard IA la sigue leyendo
  hasta migrarlo.
- Prisma: agregar los campos y el modelo `ai_alertas_lecturas`. El índice parcial va sólo en SQL (Prisma no
  expresa `WHERE`); el upsert se hace con `findFirst` + `update`/`create` dentro de una transacción, y el índice
  garantiza que una carrera no duplique (se reintenta como update ante violación de unicidad).

### `AlertasEmpresaService` (nuevo, `src/alertas-empresa/`)

Único punto de escritura para alertas nuevas (SIFEN hoy; otros módulos e IA después).

| Método | Comportamiento |
|---|---|
| `registrar({ empresaId, clave, tipo, criticidad, titulo, descripcion, accionSugerida?, modulo, privilegioRequerido?, ruta?, entidadTipo?, entidadId?, datos?, origen })` | Si hay alerta abierta con esa `clave`: actualiza texto/datos/`updated_at`. Si la criticidad **sube** (media → alta) borra las lecturas: vuelve a no-leída para todos. Si no hay: la crea. |
| `resolver(empresaId, clave, resolucion, usuarioId?)` | Marca `resuelta`, `resuelta_at`, `resuelta_por`, `resolucion`. No-op si no hay abierta. |
| `listarParaUsuario(usuario, { modulo?, entidadTipo? })` | Abiertas de la empresa, filtradas por módulos activos (`filtrarPorDominios`), `privilegio_requerido` del usuario y **alcance por sucursal** (`alcanceSucursalUsuario`: con `sucursal_id` fuera de sus sucursales no la ve; `sucursal_id` null la ven todos los que tengan el privilegio). Incluye `leida` y `mostrada_popup` **del usuario**. Orden: criticidad alta primero, luego `updated_at desc`. |
| `marcarLeida(alertaId, usuario)` / `marcarMostradaPopup(ids, usuario)` | Upsert en `ai_alertas_lecturas`, validando empresa. |

Endpoints (`AlertasEmpresaController`, `/v1/alertas-empresa`): `GET /` (con `modulo`, `entidad_tipo`),
`PATCH /:id/leida`, `PATCH /popup-mostrada` (body `ids[]`).

---

## Parte 2 — Detección en el sync SIFEN y acciones

### Dónde

`sync-sifen-status` corre cada 5 minutos (`queues.module.ts`) y llama a `sincronizarSifen(…, 'evento')`, que
consulta cada documento con `estado_evento = 'Pendiente'`. La clasificación se agrega en ese loop mediante una
función **pura** `clasificarEventoPendiente(doc, respuestaMiddleware, ahora)`.

### Clasificación

| Caso | Condición | Acción | Criticidad |
|---|---|---|---|
| **Incoherente** (se evalúa antes de consultar) | `fecha_evento` null · EINU sobre `estado_sifen = 'Aprobado'` · ECAN/EINO sobre documento no `Aprobado` | `registrar` "Evento inconsistente con el estado del documento" | alta |
| **a) Respondido** | `retorno_sifen.estado` presente | Actualizar documento como hoy + `resolver(…, 'auto')` | — |
| **b) Sin respuesta** | El middleware tiene el evento, `retorno_sifen.estado` null, y `ahora − fecha_evento > 1 h` | `registrar` "Evento sin respuesta de SIFEN" | media |
| **c) No registrado** | `status = success` pero sin entrada para el CDC / número | `registrar` "SIFEN no registró el evento" | alta |
| **d) Middleware falla** | Error de red o `status ≠ success`, y `ahora − fecha_evento > 1 h` | `registrar` "No se pudo consultar el evento" | media |

- `b`/`d` con menos de 1 hora: no se alerta (demora normal / caída breve). Un `d` que luego consulta bien pasa a
  `a`, `b` o `c` y la alerta se actualiza o resuelve. No se guarda estado extra: la antigüedad sale de `fecha_evento`.
- Clave: `sifen_evento:<tipo_documento>:<documento_id>`. `modulo = 'FISCAL'`,
  `privilegio_requerido = 'FIS_SIF_SIFEN_VER'`, `sucursal_id` = sucursal cuyo `punto_establecimiento` es el
  `dest` del documento (mismo eje que el alcance fiscal), `ruta` a la pantalla del documento,
  `datos = { tipoDocumento, documentoId, numero, evento, caso }`.
- Mismo documento cambia de caso (ej. b → c): misma clave, se actualiza (y si sube a alta, vuelve a no-leída).

### Corrección al sync

`sifen-sync.processor` sincroniza eventos sólo de facturas y NC: **agregar notas de remisión**
(`getEmpresasConEventosPendientes('remision')` + `sincronizarSifen(empresaId, 'nota_remision', 'evento')`).

### Acciones

1. **Reconsultar** — `POST /v1/middleware-sifen/evento/reconsultar/:tipoDocumento/:id`
   (`FIS_SIF_SIFEN_VER`). Ejecuta la consulta + clasificación sólo para ese documento y devuelve el caso resultante.
2. **Liberar evento** — `POST /v1/middleware-sifen/evento/liberar/:tipoDocumento/:id`, body `{ motivo }`
   (privilegio nuevo `FIS_SIF_EVENTO_LIBERAR` en `seguridad.seed-data.ts`, submódulo `FIS_SIFEN`).
   - Motivo obligatorio.
   - **Reconsulta primero.** Si SIFEN tiene el evento (caso a/b), no libera: actualiza estado/alerta y lo informa.
   - Sólo libera en caso **c** o **incoherente**: `estado_evento = 'Liberado'`,
     `mensaje_evento = "Liberado por <usuario> el <fecha>: <motivo>"`, conserva `evento_aplicado` como rastro.
   - `resolver(…, 'liberada', usuarioId)` + `AuditService` (acción `UPDATE`, `entity_type` del documento).
3. **Marcar leída** — por usuario; no resuelve.

### Compatibilidad

- El bloqueo de envío duplicado de `enviarEvento` se mantiene; la salida es Liberar.
- `'Liberado'` no es `Pendiente` (no bloquea) ni `Aprobado`: los filtros de ventas válidas
  (`estado_evento: { not: 'Aprobado' }`) y el Libro IVA no cambian.
- El privilegio nuevo se sincroniza solo al arrancar el backend (`SeguridadSeedBootstrap`).

---

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

### Datos

`useAlertasEmpresa({ modulo?, entidadTipo? })` (tanstack) sobre `GET /alertas-empresa`: `refetchInterval` 60 s,
refetch al volver el foco e invalidación tras cada acción. Una sola query compartida (misma `queryKey` base)
alimenta campana, Inicio, banners y popup.

Componentes nuevos en `src/components/_standards/` (exportados en el barrel):
- `AlertaEmpresaItem` — criticidad (color/ícono), título, descripción, acción sugerida, botones
  (Ir, Marcar leída, acciones SIFEN si `tipo` las admite).
- `AlertasEmpresaBanner` — banner compacto con contador, filtrable por módulo / tipo de documento.
- Enums en `_standards/enums`: `CRITICIDAD_ALERTA` (label, color, ícono), `RESOLUCION_ALERTA`; y
  `ESTADO_EVENTO_SIFEN` suma `LIBERADO`.

### 1. Campana (`Sidebar`, `MenuMovil`)

- Deja de ser un link a `/novedades`: abre un **panel** con **Alertas** (las que el usuario puede ver, altas
  primero) y **Novedades** (lo actual, con "Ver todas" → `/novedades`).
- Contador = alertas no leídas del usuario + novedades no leídas. Rojo si hay alguna alta no leída.

### 2. Inicio (`WelcomeDashboard`)

Bloque **"Atención"** arriba de "Tu trabajo": hasta 3 alertas abiertas y "Ver todas (N)" que abre el panel de la
campana. Sin alertas, no se renderiza.

### 3. Facturación (Facturas, Notas de Crédito, Remisiones)

- `AlertasEmpresaBanner` filtrado a SIFEN + tipo de documento: "3 documentos con eventos SIFEN inconsistentes —
  Ver" (filtra la lista a esos documentos).
- Chip "Estado evento" de la fila: ícono de advertencia + tooltip con el caso.
- Menú de eventos: el `EventoSifenPendienteItem` existente suma **Reconsultar** y, con
  `FIS_SIF_EVENTO_LIBERAR`, **Liberar evento** (diálogo con motivo obligatorio que explica el efecto).

### 4. Popup de criticidad alta

- Al entrar a la app, si hay alertas **altas** no leídas por el usuario y no mostradas en esta sesión: diálogo
  con la lista, "Ir al documento" o "Entendido" (marca leídas). Registra `mostrada_popup_at`.
- Una vez por sesión (sessionStorage con los ids mostrados) hasta marcarlas leídas.
- **No se muestra dentro del POS** (no interrumpir una venta).

### Estándares

Enums de `_standards/enums` (nunca strings literales en JSX), `fmtFechaHora` para fechas, layout por
contenedor (sin `@media` de viewport dentro de tarjetas).

---

## Pruebas

**Backend (jest)**
- `clasificarEventoPendiente`: cada caso, límite exacto de 1 h, `fecha_evento` null, cada incoherencia.
- `AlertasEmpresaService`: dedupe por clave, re-no-leída al subir criticidad, resolver no-op, filtro por
  privilegio / módulo / sucursal, lecturas por usuario.
- Liberar: rechaza sin privilegio o sin motivo; no libera si la reconsulta encuentra el evento; libera en c /
  incoherente, deja `Liberado`, resuelve la alerta y audita.
- Sync: remisiones incluidas; caso a resuelve alerta existente.

**Frontend / E2E (Playwright)**
- Usuarios `taller` y `agogo`; un usuario sin `FIS_SIF_SIFEN_VER` no ve alertas SIFEN.
- Reproducir en la base local un evento incoherente → campana, bloque Inicio, banner Facturación, popup (una vez
  por sesión), Reconsultar y Liberar (motivo obligatorio, desbloquea el envío).

---

## Fases

1. **Fase 1 (este plan)**: modelo + `AlertasEmpresaService` + detección SIFEN (facturas, NC, remisiones) +
   Reconsultar / Liberar + campana, Inicio, banners, popup.
2. **Fase 2**: pasar `AlertsService` (mora, stock, saldo, RRHH) a `registrar()` con claves, auto-resolución y
   lecturas por usuario; el Dashboard IA lee la bandeja nueva.
3. **Fase 3**: alertas generadas por la IA (`origen = 'ia'`) sobre el mismo servicio.
4. **Fase 4**: canales (email / WhatsApp) usando `canales_enviados`.

## Fuera de alcance (Fase 1)

- Envío por email / WhatsApp.
- Migrar las reglas existentes del `AlertsService`.
- Alertas en tiempo real por websocket (alcanza el polling de 60 s).
- Documentos (no eventos) trabados en `Enviado` / `Documento firmado`: mismo patrón, se agrega después.

---

## Resultado de la verificación (15/09/2026, entorno local)

**Backend** — `npx jest src/alertas-empresa src/middleware-sifen src/nota-remision src/utils/numero-documento.spec.ts`:
55 tests OK; `tsc --noEmit` sin errores. Privilegio `FIS_SIF_EVENTO_LIBERAR` sembrado al arrancar.

**Detección real**: al recompilar el backend, el job de 5 min registró solo las 4 alertas de las facturas
`EINU Pendiente` sin `fecha_evento` mencionadas en "Problema" (2 de `266cd466…`, 2 de `40539a9d…`), como
`incoherente` / criticidad alta. Quedan abiertas en la base local hasta que alguien las reconsulte o libere.

**Recorrido Playwright** (factura `001-001-0000002` de `taller` puesta en `EINU Pendiente` sin fecha, luego
restaurada; alertas de prueba borradas):

| Paso | Resultado |
|---|---|
| Popup de criticidad alta al entrar | Aparece una vez; "Recordarme después" lo cierra y no vuelve al recargar (misma sesión). |
| Inicio, bloque "Atención" | Visible con la alerta. |
| Campana | Contador 5 (1 alerta + 4 novedades), panel con la alerta y acciones Ir / Reconsultar / Liberar / Marcar leída. |
| Facturas | Banner "1 factura con un evento SIFEN inconsistente"; `?buscar=001-001-0000002` deja el buscador cargado y muestra la factura. |
| Reconsultar | `POST /middleware-sifen/evento/reconsultar/factura/:id` → 201, caso `incoherente` (el middleware local rechaza el login de esa empresa, y la incoherencia igual se detecta). |
| Liberar | Botón deshabilitado sin motivo; con motivo → `estado_evento = 'Liberado'`, `mensaje_evento = "Liberado por taller el …: <motivo>"`, alerta `resuelta` con `resolucion = 'liberada'`, lectura registrada, banner desaparece. Sin errores de consola. |
| Usuario `agogo` (otra sucursal, misma empresa) | No ve la alerta (campana "Sin alertas", sin popup ni banner) ni la factura. |

**Ajustes que salieron de la verificación**
- `AlertasCriticasPopup`: el popup reaparecía al cerrarlo (las alertas mostradas se guardaban en un `ref` y el
  memo de pendientes no se recalculaba). Ahora son estado.
- `sincronizarSifen`: si el login al middleware falla y hay eventos pendientes, ya no aborta: los clasifica
  igual (`incoherente` sin consultar; `middleware_falla` si tienen más de 1 h). Sin eventos pendientes el
  error se propaga como antes. Test: `sync-login-falla.spec.ts`.
- Búsqueda por número completo (`001-002-0000552`) en facturas, NC y remisiones: antes `contains` por columna no
  lo encontraba, y es el formato del link de la alerta. Helper `src/utils/numero-documento.ts`.
- Eventos de remisiones: además del sync, al aprobarse un ECAN/EINU se anula la remisión y se revierten stock y
  cantidades aplicadas a facturas (`ReversionRemisionService`, compartido con la anulación manual). Ver
  `evento-remision.spec.ts`.

**Pendiente de decisión**: el detalle de estado SIFEN de las tres pantallas de ventas (`getSifenPipeline`)
muestra "Cancelado" / "Inutilizado" sólo cuando `estado_sifen === "Aprobado"`, pero tras un evento aprobado
el sync deja `estado_sifen = 'Cancelado'` / `'Inutilizado'`, así que esos documentos se ven como
"Pendiente aprobación". Es previo a este plan; conviene corregirlo en las tres pantallas.
