# Alertas de empresa (Fase 1: eventos SIFEN) — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Detectar eventos SIFEN inconsistentes durante el sync, registrarlos como alertas de empresa (bandeja única sobre `ai_alertas`, leída por usuario) y mostrarlas en campana, Inicio, Facturación y popup, con acciones Reconsultar / Liberar evento.

**Architecture:** Backend: migración sobre `ai_alertas` + tabla de lecturas; `AlertasEmpresaService` como único punto de escritura/lectura; clasificador puro `clasificarEventoPendiente` invocado desde el loop de eventos de `sincronizarSifen`; endpoints de reconsulta y liberación en el controller SIFEN. Frontend: un hook tanstack compartido alimenta componentes estándar (`_standards`) montados en Sidebar/MenuMovil, WelcomeDashboard, tabs de Ventas y Layout (popup).

**Tech Stack:** NestJS 10 + Prisma 6 + PostgreSQL + Jest (backend `novasispy-backend-api`); React 18 + MUI + styled-components + TanStack Query + Zustand (frontend `novasispy-erp`).

**Spec:** `docs/plan-alertas-empresa.md`

## Global Constraints

- Idioma de UI, comentarios y mensajes: español (voseo rioplatense como el resto del ERP).
- Migraciones: directorio `prisma/migrations/YYYYMMDD_nombre/migration.sql`, SQL idempotente; aplicar con `npx prisma db execute --file … --schema prisma/schema.prisma`, `npx prisma migrate resolve --applied "…"`, `npx prisma generate`.
- Scripts/tests backend: `npx jest <ruta>`; typecheck `npx tsc --noEmit -p tsconfig.json`.
- Frontend: seguir `docs/ui-standards.md`; componentes genéricos en `src/components/_standards/` y exportados en el barrel; estados vía enums de `_standards/enums` (nunca strings literales en JSX); fechas con `src/utils/fecha.js` (`fmtFechaHora`); layout por contenedor, sin `@media` de viewport dentro de tarjetas.
- **Sin commits**: el usuario maneja git manualmente. Reemplazar cada paso "Commit" por verificación.
- Clave de alerta SIFEN: `sifen_evento:<tipo_documento>:<documento_id>`. `modulo = 'FISCAL'`, `privilegio_requerido = 'FIS_SIF_SIFEN_VER'`.
- Umbral de antigüedad para casos "sin respuesta" y "middleware falla": **1 hora** desde `fecha_evento`.
- Privilegio nuevo: `FIS_SIF_EVENTO_LIBERAR` (submódulo `FIS_SIFEN`).
- Estado nuevo de evento: `'Liberado'`.

---

## File Structure

**Backend (`novasispy-backend-api`)**
- Create `prisma/migrations/20260915_alertas_empresa/migration.sql` — columnas nuevas + índice parcial + `ai_alertas_lecturas`.
- Modify `prisma/schema.prisma` — campos en `ai_alertas`, modelo `ai_alertas_lecturas`.
- Create `src/alertas-empresa/alertas-empresa.types.ts` — tipos compartidos (`RegistrarAlertaInput`, `CriticidadAlerta`, `ResolucionAlerta`).
- Create `src/alertas-empresa/permisos-usuario.ts` — `contextoPermisosUsuario(prisma, usuarioId, empresaId)`.
- Create `src/alertas-empresa/alertas-empresa.service.ts` (+ `.spec.ts`).
- Create `src/alertas-empresa/alertas-empresa.controller.ts`, `alertas-empresa.module.ts`; register in `src/app.module.ts`.
- Create `src/middleware-sifen/clasificar-evento-pendiente.ts` (+ `.spec.ts`) — función pura.
- Create `src/middleware-sifen/alerta-evento-sifen.ts` — arma el `RegistrarAlertaInput` de un caso.
- Modify `src/middleware-sifen/middleware-sifen.service.ts` — extraer consulta de un evento, clasificar y registrar/resolver en el loop; `reconsultarEvento`, `liberarEvento`.
- Modify `src/middleware-sifen/middleware-sifen.controller.ts` — endpoints reconsultar/liberar.
- Modify `src/middleware-sifen/middleware-sifen.module.ts` — importar `AlertasEmpresaModule`.
- Modify `src/queues/processors/sifen-sync.processor.ts` — sincronizar eventos de remisiones.
- Modify `src/seguridad/seeds/seguridad.seed-data.ts` — privilegio `FIS_SIF_EVENTO_LIBERAR`.
- Create `src/middleware-sifen/liberar-evento.spec.ts`.

**Frontend (`novasispy-erp`)**
- Create `src/api/alertas-empresa.service.js`; Modify `src/api/facturas.service.js` (reconsultar/liberar).
- Create `src/tanstack/AlertasEmpresaStack.jsx` — `useAlertasEmpresaQuery`, `useMarcarAlertaLeidaMutation`, `useMarcarPopupMostradaMutation`, `useReconsultarEventoMutation`, `useLiberarEventoMutation`.
- Modify `src/components/_standards/enums/estados.js` — `CRITICIDAD_ALERTA`, `getCriticidadAlerta`, `ESTADO_EVENTO_SIFEN.LIBERADO`.
- Create `src/components/_standards/AlertaEmpresaItem.jsx`, `AlertasEmpresaBanner.jsx`, `LiberarEventoDialog.jsx`, `AlertasCampanaPanel.jsx`, `AlertasCriticasPopup.jsx`; export in `src/components/_standards/index.js`.
- Modify `src/components/_standards/EventoSifenPendienteItem.jsx` — acciones Reconsultar / Liberar.
- Modify `src/components/organismos/sidebar/Sidebar.jsx`, `MenuMovil.jsx` — campana con panel.
- Modify `src/components/organismos/Dashboard/WelcomeDashboard.jsx` — bloque "Atención".
- Modify `src/components/ventas/FacturasTab.jsx`, `NotasCreditoTab.jsx`, `RemisionesTab.jsx` — banner, `?buscar=`, acciones.
- Modify `src/hooks/Layout.jsx` — montar `AlertasCriticasPopup` (fuera de rutas POS).

---

### Task 1: Migración y modelo Prisma

**Files:**
- Create: `prisma/migrations/20260915_alertas_empresa/migration.sql`
- Modify: `prisma/schema.prisma` (model `ai_alertas`, nuevo `ai_alertas_lecturas`, back-relation en `usuario` no requerida)

**Interfaces:**
- Produces: `prisma.ai_alertas` con `origen, modulo, clave, privilegio_requerido, ruta, sucursal_id, datos, resuelta_at, resuelta_por, resolucion, updated_at`; `prisma.ai_alertas_lecturas { id, alerta_id, usuario_id, leida_at, mostrada_popup_at }` con unique `alerta_id_usuario_id`.

- [x] **Step 1: SQL**

```sql
-- Alertas de empresa — docs/plan-alertas-empresa.md
ALTER TABLE ai_alertas
  ADD COLUMN IF NOT EXISTS origen               VARCHAR(10)  NOT NULL DEFAULT 'regla',
  ADD COLUMN IF NOT EXISTS modulo               VARCHAR(30),
  ADD COLUMN IF NOT EXISTS clave                VARCHAR(150),
  ADD COLUMN IF NOT EXISTS privilegio_requerido VARCHAR(60),
  ADD COLUMN IF NOT EXISTS ruta                 VARCHAR(255),
  ADD COLUMN IF NOT EXISTS sucursal_id          UUID,
  ADD COLUMN IF NOT EXISTS datos                JSONB,
  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),
  ADD COLUMN IF NOT EXISTS updated_at           TIMESTAMP(6) NOT NULL DEFAULT now();

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,
  usuario_id        UUID NOT NULL,
  leida_at          TIMESTAMP(6),
  mostrada_popup_at TIMESTAMP(6),
  CONSTRAINT uq_ai_alertas_lecturas UNIQUE (alerta_id, usuario_id)
);
DO $$ BEGIN
  ALTER TABLE ai_alertas_lecturas ADD CONSTRAINT ai_alertas_lecturas_alerta_fkey
    FOREIGN KEY (alerta_id) REFERENCES ai_alertas(id) ON DELETE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
CREATE INDEX IF NOT EXISTS idx_ai_alertas_lecturas_usuario ON ai_alertas_lecturas(usuario_id);
```

- [x] **Step 2: Prisma schema** — agregar en `model ai_alertas` los campos (`origen String @default("regla") @db.VarChar(10)`, `modulo String? @db.VarChar(30)`, `clave String? @db.VarChar(150)`, `privilegio_requerido String? @db.VarChar(60)`, `ruta String? @db.VarChar(255)`, `sucursal_id String? @db.Uuid`, `datos Json?`, `resuelta_at DateTime? @db.Timestamp(6)`, `resuelta_por String? @db.Uuid`, `resolucion String? @db.VarChar(10)`, `updated_at DateTime @default(now()) @db.Timestamp(6)`, `lecturas ai_alertas_lecturas[]`) y el modelo:

```prisma
model ai_alertas_lecturas {
  id                String     @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
  alerta_id         String     @db.Uuid
  usuario_id        String     @db.Uuid
  leida_at          DateTime?  @db.Timestamp(6)
  mostrada_popup_at DateTime?  @db.Timestamp(6)
  alerta            ai_alertas @relation(fields: [alerta_id], references: [id], onDelete: Cascade, map: "ai_alertas_lecturas_alerta_fkey")

  @@unique([alerta_id, usuario_id], map: "uq_ai_alertas_lecturas")
  @@index([usuario_id], map: "idx_ai_alertas_lecturas_usuario")
}
```

- [x] **Step 3: Aplicar** — `npx prisma db execute --file prisma/migrations/20260915_alertas_empresa/migration.sql --schema prisma/schema.prisma && npx prisma migrate resolve --applied "20260915_alertas_empresa" && npx prisma generate`. Expected: sin error; re-ejecutar el `db execute` también sin error (idempotente).
- [x] **Step 4: Verificar** — `npx tsc --noEmit -p tsconfig.json` sin errores nuevos.

---

### Task 2: Clasificador puro de eventos pendientes

**Files:**
- Create: `src/middleware-sifen/clasificar-evento-pendiente.ts`
- Test: `src/middleware-sifen/clasificar-evento-pendiente.spec.ts`

**Interfaces:**
- Produces:

```ts
export type CasoEventoPendiente = 'incoherente' | 'respondido' | 'sin_respuesta' | 'no_registrado' | 'middleware_falla' | 'en_espera';
export interface DocEventoPendiente { evento_aplicado: string | null; estado_sifen: string | null; fecha_evento: Date | null }
export type RespuestaConsultaEvento =
  | { ok: true; registro: { retorno_sifen?: { estado?: string | null } | null } | null } // null = middleware no lo tiene
  | { ok: false; error: string };
export const UMBRAL_EVENTO_MS = 60 * 60 * 1000;
export function incoherenciaEvento(doc: DocEventoPendiente): string | null;
export function clasificarEventoPendiente(doc: DocEventoPendiente, respuesta: RespuestaConsultaEvento | null, ahora: Date): { caso: CasoEventoPendiente; detalle: string };
```

- [x] **Step 1: Tests** (casos: fecha null → incoherente; EINU sobre Aprobado → incoherente; ECAN/EINO sobre no Aprobado → incoherente; retorno con estado → respondido; registro null → no_registrado; retorno sin estado con 59 min → en_espera y con 61 min → sin_respuesta; error con 30 min → en_espera y con 2 h → middleware_falla; `respuesta === null` (no consultado) con doc coherente → en_espera).
- [x] **Step 2:** `npx jest src/middleware-sifen/clasificar-evento-pendiente.spec.ts` → FAIL (módulo inexistente).
- [x] **Step 3: Implementar** — `incoherenciaEvento` primero; si hay incoherencia gana siempre. Luego: error → `middleware_falla` si antigüedad > umbral, si no `en_espera`; `registro === null` → `no_registrado`; `retorno_sifen.estado` → `respondido`; si no, `sin_respuesta` si > umbral, si no `en_espera`.
- [x] **Step 4:** tests PASS.

---

### Task 3: `AlertasEmpresaService`, permisos y endpoints

**Files:**
- Create: `src/alertas-empresa/alertas-empresa.types.ts`, `permisos-usuario.ts`, `alertas-empresa.service.ts`, `alertas-empresa.controller.ts`, `alertas-empresa.module.ts`
- Modify: `src/app.module.ts`
- Test: `src/alertas-empresa/alertas-empresa.service.spec.ts`

**Interfaces:**
- Consumes: `alcanceSucursalUsuario` (`src/utils/alcance-sucursal.ts`), `getActiveDomains` no — el filtro de módulo usa `contextoPermisosUsuario`.
- Produces:

```ts
export type CriticidadAlerta = 'alta' | 'media' | 'baja';
export type ResolucionAlerta = 'auto' | 'manual' | 'liberada';
export interface RegistrarAlertaInput {
  empresaId: string; clave: string; tipo: string; criticidad: CriticidadAlerta;
  titulo: string; descripcion: string; accionSugerida?: string | null;
  modulo: string; privilegioRequerido?: string | null; ruta?: string | null;
  sucursalId?: string | null; entidadTipo?: string | null; entidadId?: string | null;
  datos?: Record<string, unknown> | null; origen: 'sistema' | 'regla' | 'ia';
}
// permisos-usuario.ts
export interface ContextoPermisos { accesoTotal: boolean; modulos: Set<string>; privilegios: Set<string> }
export async function contextoPermisosUsuario(prisma, usuarioId: string, empresaId: string): Promise<ContextoPermisos>;
export function puedeVerAlerta(ctx: ContextoPermisos, alerta: { modulo: string | null; privilegio_requerido: string | null }): boolean;
// service
registrar(input: RegistrarAlertaInput): Promise<{ id: string; creada: boolean }>;
resolver(empresaId: string, clave: string, resolucion: ResolucionAlerta, usuarioId?: string | null): Promise<boolean>;
listarParaUsuario(usuarioId: string, empresaId: string, filtros?: { modulo?: string; entidadTipo?: string }): Promise<AlertaParaUsuario[]>;
marcarLeida(alertaId: string, usuarioId: string, empresaId: string): Promise<void>;
marcarMostradaPopup(ids: string[], usuarioId: string, empresaId: string): Promise<void>;
// AlertaParaUsuario = fila ai_alertas + { leida: boolean; mostrada_popup: boolean }
```

- Endpoints `/v1/alertas-empresa` (guard `AuthGuard('jwt')`): `GET /` (query `modulo`, `entidad_tipo`), `PATCH /:id/leida`, `PATCH /popup-mostrada` body `{ ids: string[] }`.

Reglas de `contextoPermisosUsuario`: superadmin o empresa holding/reseller → `accesoTotal` sobre módulos activos (holding/reseller: todos). Módulos activos = `suscripcion_modulos` activos de suscripción `Activo|EnGracia`. Privilegios = códigos de `perfiles_privilegios` activos del usuario. `puedeVerAlerta`: si `modulo` y no está activo (y no holding/reseller) → false; si `privilegio_requerido` y no `accesoTotal` y no lo tiene → false.

- [x] **Step 1: Tests** — `registrar` crea si no hay abierta; con abierta actualiza (no crea) y si la criticidad sube de media a alta hace `ai_alertas_lecturas.deleteMany({ where: { alerta_id } })`; ante error de unicidad (`P2002`) en `create` reintenta como update; `resolver` no-op devuelve false; `listarParaUsuario` excluye alertas de sucursal fuera de alcance, sin privilegio, y marca `leida` por usuario; `marcarLeida` rechaza alerta de otra empresa (NotFound).
- [x] **Step 2:** FAIL.
- [x] **Step 3: Implementar** service, helpers, controller, module (`providers: [AlertasEmpresaService]`, `exports`), registrar en `AppModule`.
- [x] **Step 4:** `npx jest src/alertas-empresa` PASS; `tsc` limpio.

---

### Task 4: Detección en el sync SIFEN (+ remisiones)

**Files:**
- Create: `src/middleware-sifen/alerta-evento-sifen.ts`
- Modify: `src/middleware-sifen/middleware-sifen.service.ts` (loop `pendientesEvent` de `sincronizarSifen`), `middleware-sifen.module.ts`, `src/queues/processors/sifen-sync.processor.ts`
- Test: `src/middleware-sifen/alerta-evento-sifen.spec.ts`

**Interfaces:**
- Consumes: Task 2 (`clasificarEventoPendiente`, `RespuestaConsultaEvento`), Task 3 (`AlertasEmpresaService.registrar/resolver`).
- Produces:

```ts
export const claveAlertaEvento = (tipoDocumento: TipoDocumento, documentoId: string) => `sifen_evento:${tipoDocumento}:${documentoId}`;
export function alertaDeEvento(params: { empresaId: string; tipoDocumento: TipoDocumento; doc: { id: string; dest: string|null; dpunexp: string|null; dnumdoc: string|null; evento_aplicado: string|null }; caso: 'incoherente'|'sin_respuesta'|'no_registrado'|'middleware_falla'; detalle: string; sucursalId: string | null }): RegistrarAlertaInput;
// en MiddlewareSifenService:
private async consultarEventoEnMiddleware(tipoDocumento, doc, token): Promise<RespuestaConsultaEvento>;
private async procesarAlertaEvento(empresaId, tipoDocumento, doc, clasificacion): Promise<void>;
```

Textos por caso (título / criticidad / acción sugerida):
- `incoherente` — "Evento {nombre} inconsistente en {label} {numero}" / alta / "Reconsultá el evento; si SIFEN no lo tiene, liberalo con un motivo."
- `no_registrado` — "SIFEN no registró el evento {nombre} de {label} {numero}" / alta / "Liberá el evento para poder reenviarlo."
- `sin_respuesta` — "Evento {nombre} sin respuesta de SIFEN ({label} {numero})" / media / "Reconsultá más tarde; si persiste, contactá a soporte."
- `middleware_falla` — "No se pudo consultar el evento {nombre} de {label} {numero}" / media / "Se reintenta solo cada 5 minutos."
- `ruta`: factura `/ventas?tab=facturas&buscar=<numero>`, nota_credito `/ventas?tab=notas-credito&buscar=<numero>`, nota_remision `/ventas?tab=remisiones&buscar=<numero>`.

- [x] **Step 1: Test** `alertaDeEvento` (clave, criticidad por caso, ruta por tipo, `privilegioRequerido`, `modulo`, `datos.caso`).
- [x] **Step 2:** FAIL → **Step 3:** implementar.
- [x] **Step 4: Integrar en el loop** — extraer el `fetch` + parseo actual a `consultarEventoEnMiddleware` (devuelve `{ok:false}` ante excepción o `status !== 'success'`; `{ok:true, registro: data.response[key] ?? null}` — sin fallback a `Object.values(...)[0]`, que confundía documentos). Por cada doc: `incoherenciaEvento` antes de consultar; clasificar; `respondido` → lógica existente + `resolver(…, 'auto')`; `sin_respuesta|no_registrado|middleware_falla|incoherente` → `registrar`; `en_espera` → nada. La sucursal: mapa `punto_establecimiento → id` de la empresa. Errores de alertas se loguean y no cortan el sync.
- [x] **Step 5: Remisiones** — en `sifen-sync.processor.ts` agregar `getEmpresasConEventosPendientes('remision')` sobre `nota_remision_cab` y `sincronizarSifen(empresaId, 'nota_remision', 'evento')`.
- [x] **Step 6:** `npx jest src/middleware-sifen` PASS; `tsc` limpio.

---

### Task 5: Reconsultar y Liberar evento

**Files:**
- Modify: `src/middleware-sifen/middleware-sifen.service.ts`, `middleware-sifen.controller.ts`, `src/seguridad/seeds/seguridad.seed-data.ts`
- Create: `src/middleware-sifen/dto/liberar-evento.dto.ts`
- Test: `src/middleware-sifen/liberar-evento.spec.ts`

**Interfaces:**
- Consumes: Tasks 2–4.
- Produces:

```ts
reconsultarEvento(empresaId: string, tipoDocumento: TipoDocumento, documentoId: string): Promise<{ success: boolean; caso: CasoEventoPendiente | 'sin_evento_pendiente'; message: string }>;
liberarEvento(empresaId: string, tipoDocumento: TipoDocumento, documentoId: string, motivo: string, usuarioId: string): Promise<{ success: boolean; caso: CasoEventoPendiente | 'sin_evento_pendiente'; message: string }>;
```

- Endpoints: `POST /v1/middleware-sifen/evento/reconsultar/:tipoDocumento/:id` (`@RequirePermission('FISCAL','FIS_SIF_SIFEN_VER')`), `POST /v1/middleware-sifen/evento/liberar/:tipoDocumento/:id` body `LiberarEventoDto { motivo: string (min 5) }` (`@RequirePermission('FISCAL','FIS_SIF_EVENTO_LIBERAR')`). Declararlos **antes** de `@Post('evento/:tipoEvento/:tipoDocumento')` para que Nest no los capture como `tipoEvento`.
- Seed: `{ codigo: 'FIS_SIF_EVENTO_LIBERAR', desc: 'Liberar evento SIFEN trabado', recurso: 'SIFEN', accion: 'LIBERAR' }` en submódulo `FIS_SIFEN`.

Reglas de `liberarEvento`: documento de la empresa; `estado_evento !== 'Pendiente'` → `sin_evento_pendiente`; motivo vacío → error; ejecuta la misma consulta+clasificación de `reconsultarEvento` (que aplica `respondido` y registra/resuelve alertas); sólo libera si el caso es `no_registrado` o `incoherente`: `updateMany where { id, estado_evento: 'Pendiente' }` → `estado_evento: 'Liberado'`, `mensaje_evento: "Liberado por <username> el <dd/mm/aaaa hh:mm>: <motivo>"`; `resolver(…, 'liberada', usuarioId)`; `auditService.log({ action: 'UPDATE', entity_type: tipoDocumento, entity_id, descripcion: 'Evento SIFEN liberado …', old_value, new_value })`. En otros casos devuelve `success:false` explicando el caso.

- [x] **Step 1: Tests** — sin pendiente; caso respondido no libera; sin_respuesta no libera; no_registrado libera (update, resolver 'liberada', audit); incoherente libera; motivo vacío rechaza; `reconsultarEvento` devuelve caso.
- [x] **Step 2:** FAIL → **Step 3:** implementar → **Step 4:** PASS + `tsc`.

---

### Task 6: Frontend — API, hook y enums

**Files:**
- Create: `src/api/alertas-empresa.service.js`, `src/tanstack/AlertasEmpresaStack.jsx`
- Modify: `src/api/facturas.service.js`, `src/components/_standards/enums/estados.js`

**Interfaces:**
- Produces:

```js
// api
getAlertasEmpresa(params?: { modulo?, entidad_tipo? }) -> Promise<Alerta[]>
marcarAlertaLeida(id) ; marcarAlertasPopupMostradas(ids)
reconsultarEventoSifen(tipoDocumento, id) ; liberarEventoSifen(tipoDocumento, id, motivo)
// tanstack
ALERTAS_EMPRESA_KEY = ["alertas-empresa"]
useAlertasEmpresaQuery({ enabled = true } = {}) // refetchInterval 60_000, refetchOnWindowFocus true
useMarcarAlertaLeidaMutation() ; useMarcarPopupMostradaMutation()
useReconsultarEventoMutation() ; useLiberarEventoMutation()  // invalidan ALERTAS_EMPRESA_KEY, ["facturas"], ["notas-credito"], ["notas-remision"]
// enums
CRITICIDAD_ALERTA = { ALTA:{value:"alta",label:"Alta",color:"error",icon:"mdi:alert-octagon"}, MEDIA:{…warning,"mdi:alert"}, BAJA:{…info,"mdi:information"} }
getCriticidadAlerta(value) ; ESTADO_EVENTO_SIFEN.LIBERADO = "Liberado"
esAlertaEventoSifen(alerta) // alerta.tipo === "sifen_evento"
```

- [x] Steps: escribir, `npx eslint` limpio.

### Task 7: Componentes estándar de alertas

**Files:** Create `AlertaEmpresaItem.jsx`, `AlertasEmpresaBanner.jsx`, `LiberarEventoDialog.jsx`, `AlertasCampanaPanel.jsx`, `AlertasCriticasPopup.jsx`; Modify `EventoSifenPendienteItem.jsx`, `_standards/index.js`.

**Interfaces:**
- `AlertaEmpresaItem({ alerta, compacto?, onIr? })` — criticidad, título, descripción, acción sugerida, `fmtFechaHora(updated_at)`; botones Ir (navega a `alerta.ruta`), Marcar leída (si no leída), y si `esAlertaEventoSifen`: Reconsultar (`hasPermission('FISCAL','FIS_SIF_SIFEN_VER')`) y Liberar (`hasPermission('FISCAL','FIS_SIF_EVENTO_LIBERAR')`, sólo casos `no_registrado|incoherente`).
- `AlertasEmpresaBanner({ tipoDocumento })` — filtra `tipo === 'sifen_evento' && datos.tipoDocumento === tipoDocumento`; oculto si 0; "N documentos con eventos SIFEN inconsistentes" + botón Ver que despliega la lista de `AlertaEmpresaItem compacto`.
- `LiberarEventoDialog({ open, onClose, tipoDocumento, documentoId, numero })` — explica efecto, motivo obligatorio (≥5), llama `useLiberarEventoMutation`, muestra resultado con toast.
- `AlertasCampanaPanel({ anchorEl, open, onClose, novedades })` — Popover con sección Alertas (orden alta primero) y Novedades (lista + "Ver todas" → `/novedades`).
- `AlertasCriticasPopup()` — lee query; filtra `criticidad==='alta' && !leida` y no mostradas en esta sesión (`sessionStorage` key `alertas_popup_mostradas`, try/catch); al abrir llama `useMarcarPopupMostradaMutation(ids)`; "Entendido" marca leídas.
- `EventoSifenPendienteItem({ documento, tipoDocumento, onCerrar })` — agrega botones Reconsultar y Liberar (mismo permiso), abre `LiberarEventoDialog`.

- [x] Steps: implementar, exportar en barrel, eslint limpio.

### Task 8: Campana en Sidebar y MenuMovil

**Files:** Modify `src/components/organismos/sidebar/Sidebar.jsx`, `MenuMovil.jsx`.
- `BellButton` deja de ser `Link` a `/novedades`: `onClick` abre `AlertasCampanaPanel`. Contador = alertas no leídas (`useAlertasEmpresaQuery`) + `unreadNovedades`; `BellCount` con `$critica` (fondo rojo) si hay alta no leída.
- [x] Steps: implementar, eslint, recorrido UI.

### Task 9: Bloque "Atención" en Inicio

**Files:** Modify `src/components/organismos/Dashboard/WelcomeDashboard.jsx`.
- Arriba de "Tu trabajo": si hay alertas abiertas, título "Atención" + hasta 3 `AlertaEmpresaItem compacto` + "Ver todas (N)" que abre `AlertasCampanaPanel`. Sin alertas no se renderiza.
- [x] Steps: implementar, eslint.

### Task 10: Facturación (Facturas, NC, Remisiones)

**Files:** Modify los tres tabs.
- `AlertasEmpresaBanner tipoDocumento="factura|nota_credito|nota_remision"` arriba de la lista.
- Leer `?buscar=` (`useSearchParams`) para inicializar `search`/`searchDebounced` una vez.
- Chip "Estado evento": si `estado_evento === ESTADO_EVENTO_SIFEN.PENDIENTE` y hay alerta con `datos.documentoId === doc.id`, ícono `mdi:alert` + tooltip con el título.
- Menús de eventos: pasar `tipoDocumento` a `EventoSifenPendienteItem`.
- [x] Steps: implementar, eslint.

### Task 11: Popup de alertas críticas

**Files:** Modify `src/hooks/Layout.jsx`.
- Montar `<AlertasCriticasPopup />` salvo en rutas `/pos`, `/pos-admin`, `/pos-retail*`.
- [x] Steps: implementar, eslint.

### Task 12: Verificación end-to-end y docs

- [x] Backend: `npx jest src/alertas-empresa src/middleware-sifen src/common/utils/rubros.util src/rubros src/productos src/proveedores src/compras` PASS; `tsc` sin errores en archivos tocados.
- [x] Reinicio/rebuild del backend confirmado (watch) y privilegio sembrado (`privilegios.codigo = 'FIS_SIF_EVENTO_LIBERAR'`).
- [x] Datos locales: poner un evento incoherente en una factura de agogo (guardar valores previos) → correr sync de eventos para la empresa → alerta creada con clave/sucursal correctas.
- [x] Playwright con `taller` y `agogo`: campana (contador rojo), Inicio "Atención", banner en Facturas, popup una vez por sesión, Reconsultar, Liberar (motivo obligatorio) → evento `Liberado`, alerta resuelta, menú vuelve a ofrecer eventos. Usuario de la otra sucursal no ve la alerta.
- [x] Restaurar los datos de prueba.
- [x] Actualizar `docs/plan-alertas-empresa.md` (Estado: Fase 1 implementada + resultados de verificación).
