# Alertas de empresa — Fase 2 (reglas de negocio en la bandeja única) — 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:** Las 10 reglas de `AlertsService` (mora, límite de crédito, stock, cheques, saldo bancario, RRHH) escriben una alerta agregada por regla en la bandeja de Fase 1 con auto-resolución, privilegio por módulo y ruta; el Dashboard IA lee esa bandeja.

**Architecture:** `AlertsService` conserva la detección (umbrales de `ai_empresa_config`, `RrhhContextService`) pero cada regla pasa a devolver `RegistrarAlertaInput | null`; un ciclo registra las que dieron resultado y resuelve en `auto` las claves del catálogo que no. `AlertasEmpresaService` gana `resolverPorId` (descarte manual). Los endpoints de alertas del Dashboard IA quedan como fachada sobre la bandeja nueva. En el frontend, `AlertasPanel` reutiliza `useAlertasEmpresaQuery` + `AlertaEmpresaItem`, que gana "Resolver" y la lista de `datos.items`.

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

**Spec:** `docs/superpowers/specs/2026-09-15-alertas-fase2-design.md`

## Global Constraints

- Idioma de UI, comentarios y mensajes: español (voseo, como el resto del ERP).
- **Sin commits**: el usuario maneja git. Cada paso "Commit" se reemplaza por una verificación.
- Migraciones: `prisma/migrations/YYYYMMDD_nombre/migration.sql`, SQL idempotente; aplicar con `npx prisma db execute --file … --schema prisma/schema.prisma` y `npx prisma migrate resolve --applied "…"`.
- Tests backend: `npx jest <ruta>`; typecheck `npx tsc --noEmit -p tsconfig.json`. Con `rtk` instalado, anteponer `rtk proxy` para ver la salida completa.
- Frontend: `docs/ui-standards.md`; componentes genéricos en `src/components/_standards/`; estados vía enums de `_standards/enums` (nunca strings literales en JSX); fechas con `fmtFechaHora`.
- Una alerta por regla y empresa: `clave = tipo`, `origen = 'regla'`, `sucursal_id = null`, `entidad_tipo/entidad_id = null`.
- Umbrales siguen saliendo de `ai_empresa_config` (`umbral_alerta_mora` def. 30, `umbral_stock_bajo` def. 5, `rrhh_dia_corte_liquidacion` def. 5, `rrhh_alertas_habilitadas` def. true).
- Montos formateados con `Math.round(n).toLocaleString('es-PY')` precedidos de `Gs. ` (las reglas actuales son sólo en guaraníes).
- Catálogo de reglas (clave → módulo, privilegio, ruta): ver Task 1; es la única fuente de verdad.

---

## File Structure

**Backend (`novasispy-backend-api`)**
- Create `src/ai-dashboard/alerts/reglas-catalogo.ts` — `REGLAS_ALERTA` (clave, módulo, privilegio, ruta, accionSugerida) y `armarAlertaRegla()` (helper que completa `RegistrarAlertaInput` desde el catálogo).
- Modify `src/ai-dashboard/alerts/alerts.service.ts` — reglas como métodos que devuelven `RegistrarAlertaInput | null`; ciclo registrar/resolver; se eliminan `getAlertas/marcarLeida/marcarResuelta`.
- Create `src/ai-dashboard/alerts/reglas-catalogo.spec.ts`, `src/ai-dashboard/alerts/alerts.service.spec.ts`.
- Modify `src/ai-dashboard/ai-dashboard.module.ts` — importa `AlertasEmpresaModule`.
- Modify `src/alertas-empresa/alertas-empresa.service.ts` (+ spec) — `resolverPorId`.
- Modify `src/alertas-empresa/alertas-empresa.controller.ts` — `PATCH :id/resolver`.
- Modify `src/ai-dashboard/ai-dashboard.controller.ts` — los 3 endpoints de alertas como fachada.
- Create `prisma/migrations/20260915_alertas_reglas_resolver_viejas/migration.sql`.

**Frontend (`novasispy-erp`)**
- Modify `src/api/alertas-empresa.service.js` — `resolverAlerta`.
- Modify `src/api/ai-dashboard.service.js` — quitar `getAlertas`, `marcarAlertaLeida`, `resolverAlerta`.
- Modify `src/tanstack/AlertasEmpresaStack.jsx` — `useResolverAlertaMutation`.
- Modify `src/components/_standards/AlertaEmpresaItem.jsx` — botón Resolver + lista `datos.items`.
- Modify `src/components/organismos/AIDashboard/AlertasPanel.jsx` — usa la bandeja nueva.
- Modify `src/components/organismos/AIDashboard/AIDashboardTemplate.jsx` — badge de altas desde `useAlertasEmpresaQuery`.

---

### Task 1: Catálogo de reglas y helper `armarAlertaRegla`

**Files:**
- Create: `src/ai-dashboard/alerts/reglas-catalogo.ts`
- Test: `src/ai-dashboard/alerts/reglas-catalogo.spec.ts`

**Interfaces:**
- Consumes: `RegistrarAlertaInput`, `CriticidadAlerta` de `src/alertas-empresa/alertas-empresa.types.ts`.
- Produces:

```ts
export type ClaveRegla =
  | 'mora_critica' | 'limite_credito_superado' | 'stock_bajo' | 'cheque_por_vencer' | 'saldo_cuenta_bajo'
  | 'rrhh_liquidacion_no_cerrada' | 'rrhh_empleados_sin_ips' | 'rrhh_empleados_sin_cbu'
  | 'rrhh_prestamos_sin_descuento' | 'rrhh_vacaciones_vencidas';
export interface ReglaAlerta { clave: ClaveRegla; modulo: string; privilegio: string; ruta: string; accionSugerida: string }
export const REGLAS_ALERTA: Record<ClaveRegla, ReglaAlerta>;
export const CLAVES_RRHH: ClaveRegla[];   // las 5 rrhh_*
export interface ItemAlerta { id: string; nombre: string; valor: number | null; detalle: string }
export function armarAlertaRegla(p: { empresaId: string; clave: ClaveRegla; criticidad: CriticidadAlerta; titulo: string; descripcion: string; cantidad: number; total?: number | null; items?: ItemAlerta[] }): RegistrarAlertaInput;
export const fmtGs = (n: number) => `Gs. ${Math.round(n).toLocaleString('es-PY')}`;
```

- [ ] **Step 1: Escribir el test**

```ts
// src/ai-dashboard/alerts/reglas-catalogo.spec.ts
import { CLAVES_RRHH, REGLAS_ALERTA, armarAlertaRegla, fmtGs } from './reglas-catalogo';

describe('reglas-catalogo', () => {
  it('cada regla tiene módulo, privilegio y ruta', () => {
    for (const [clave, r] of Object.entries(REGLAS_ALERTA)) {
      expect(r.clave).toBe(clave);
      expect(r.modulo).toMatch(/^(COBRANZAS|INVENTARIO|TESORERIA|RRHH)$/);
      expect(r.privilegio).toMatch(/_VER$/);
      expect(r.ruta.startsWith('/')).toBe(true);
    }
    expect(Object.keys(REGLAS_ALERTA)).toHaveLength(10);
    expect(CLAVES_RRHH).toEqual(Object.keys(REGLAS_ALERTA).filter((c) => c.startsWith('rrhh_')));
  });

  it('armarAlertaRegla completa módulo/privilegio/ruta/origen y recorta items a 5', () => {
    const items = Array.from({ length: 7 }, (_, i) => ({ id: `p${i}`, nombre: `Prod ${i}`, valor: i, detalle: `${i} u.` }));
    const a = armarAlertaRegla({
      empresaId: 'emp-1', clave: 'stock_bajo', criticidad: 'alta',
      titulo: '7 productos con stock bajo', descripcion: 'x', cantidad: 7, items,
    });
    expect(a).toMatchObject({
      empresaId: 'emp-1', clave: 'stock_bajo', tipo: 'stock_bajo', origen: 'regla',
      modulo: 'INVENTARIO', privilegioRequerido: 'INV_STK_STOCK_VER', ruta: '/reportes/inventario/stock',
      accionSugerida: REGLAS_ALERTA.stock_bajo.accionSugerida, sucursalId: null, entidadTipo: null, entidadId: null,
    });
    expect((a.datos as any).cantidad).toBe(7);
    expect((a.datos as any).items).toHaveLength(5);
    expect((a.datos as any).total).toBeNull();
  });

  it('fmtGs redondea y usa separador de miles', () => {
    expect(fmtGs(1234567.6)).toBe(`Gs. ${(1234568).toLocaleString('es-PY')}`);
  });
});
```

- [ ] **Step 2: Correr y ver que falla** — `rtk proxy npx jest src/ai-dashboard/alerts/reglas-catalogo.spec.ts` → "Cannot find module './reglas-catalogo'".

- [ ] **Step 3: Implementar**

```ts
// src/ai-dashboard/alerts/reglas-catalogo.ts
import { CriticidadAlerta, RegistrarAlertaInput } from 'src/alertas-empresa/alertas-empresa.types';

/**
 * Reglas de negocio del Dashboard IA que escriben en la bandeja de alertas de
 * empresa (docs/superpowers/specs/2026-09-15-alertas-fase2-design.md).
 * Una alerta agregada por regla y empresa; `clave === tipo`.
 */
export type ClaveRegla =
  | 'mora_critica'
  | 'limite_credito_superado'
  | 'stock_bajo'
  | 'cheque_por_vencer'
  | 'saldo_cuenta_bajo'
  | 'rrhh_liquidacion_no_cerrada'
  | 'rrhh_empleados_sin_ips'
  | 'rrhh_empleados_sin_cbu'
  | 'rrhh_prestamos_sin_descuento'
  | 'rrhh_vacaciones_vencidas';

export interface ReglaAlerta {
  clave: ClaveRegla;
  modulo: string;
  privilegio: string;
  ruta: string;
  accionSugerida: string;
}

export const REGLAS_ALERTA: Record<ClaveRegla, ReglaAlerta> = {
  mora_critica: {
    clave: 'mora_critica', modulo: 'COBRANZAS', privilegio: 'COB_CC_CUENTA_COBRAR_VER', ruta: '/cobranzas/cuentas-cobrar',
    accionSugerida: 'Coordinar visita del cobrador esta semana. Si supera 60 días considerar gestión judicial.',
  },
  limite_credito_superado: {
    clave: 'limite_credito_superado', modulo: 'COBRANZAS', privilegio: 'COB_CC_CUENTA_COBRAR_VER', ruta: '/cobranzas/cuentas-cobrar',
    accionSugerida: 'Bloquear nuevas ventas a crédito hasta regularizar el saldo.',
  },
  stock_bajo: {
    clave: 'stock_bajo', modulo: 'INVENTARIO', privilegio: 'INV_STK_STOCK_VER', ruta: '/reportes/inventario/stock',
    accionSugerida: 'Realizar pedido de reposición al proveedor.',
  },
  cheque_por_vencer: {
    clave: 'cheque_por_vencer', modulo: 'TESORERIA', privilegio: 'TES_CHQ_CHEQUE_VER', ruta: '/tesoreria-bancos?tab=tes_cheques',
    accionSugerida: 'Depositar los cheques en cuenta bancaria antes de la fecha de vencimiento.',
  },
  saldo_cuenta_bajo: {
    clave: 'saldo_cuenta_bajo', modulo: 'TESORERIA', privilegio: 'TES_CTA_CUENTA_BANCARIA_VER', ruta: '/tesoreria-bancos?tab=tes_cuentas',
    accionSugerida: 'Realizar una transferencia para reponer el saldo antes de emitir pagos.',
  },
  rrhh_liquidacion_no_cerrada: {
    clave: 'rrhh_liquidacion_no_cerrada', modulo: 'RRHH', privilegio: 'RH_LIQ_LIQUIDACION_VER', ruta: '/rrhh?tab=liquidaciones',
    accionSugerida: 'Revisar novedades pendientes y cerrar la liquidación mensual para emitir el reporte IPS y la acreditación bancaria.',
  },
  rrhh_empleados_sin_ips: {
    clave: 'rrhh_empleados_sin_ips', modulo: 'RRHH', privilegio: 'RH_EMP_EMPLEADO_VER', ruta: '/rrhh?tab=empleados',
    accionSugerida: 'Cargar el número de IPS en la ficha del empleado para que aparezca correctamente en el reporte mensual.',
  },
  rrhh_empleados_sin_cbu: {
    clave: 'rrhh_empleados_sin_cbu', modulo: 'RRHH', privilegio: 'RH_EMP_EMPLEADO_VER', ruta: '/rrhh?tab=empleados',
    accionSugerida: 'Cargar el número de cuenta bancaria en la ficha del empleado antes de generar el archivo de acreditación.',
  },
  rrhh_prestamos_sin_descuento: {
    clave: 'rrhh_prestamos_sin_descuento', modulo: 'RRHH', privilegio: 'RH_ANT_PRESTAMO_VER', ruta: '/rrhh?tab=prestamos',
    accionSugerida: 'Revisar si la cuota debe aplicarse o si el préstamo está suspendido.',
  },
  rrhh_vacaciones_vencidas: {
    clave: 'rrhh_vacaciones_vencidas', modulo: 'RRHH', privilegio: 'RH_VAC_VACACION_VER', ruta: '/rrhh?tab=vacaciones',
    accionSugerida: 'Programar el goce de vacaciones para evitar acumulación según Art. 219 del Código Laboral.',
  },
};

export const CLAVES_RRHH = (Object.keys(REGLAS_ALERTA) as ClaveRegla[]).filter((c) => c.startsWith('rrhh_'));

/** Un caso concreto de la regla (cliente, producto, cheque…); va en `datos.items` (≤ 5). */
export interface ItemAlerta {
  id: string;
  nombre: string;
  valor: number | null;
  detalle: string;
}

export const MAX_ITEMS = 5;

export const fmtGs = (n: number) => `Gs. ${Math.round(n).toLocaleString('es-PY')}`;

export function armarAlertaRegla(p: {
  empresaId: string;
  clave: ClaveRegla;
  criticidad: CriticidadAlerta;
  titulo: string;
  descripcion: string;
  cantidad: number;
  total?: number | null;
  items?: ItemAlerta[];
}): RegistrarAlertaInput {
  const regla = REGLAS_ALERTA[p.clave];
  return {
    empresaId: p.empresaId,
    clave: p.clave,
    tipo: p.clave,
    criticidad: p.criticidad,
    titulo: p.titulo,
    descripcion: p.descripcion,
    accionSugerida: regla.accionSugerida,
    modulo: regla.modulo,
    privilegioRequerido: regla.privilegio,
    ruta: regla.ruta,
    sucursalId: null,
    entidadTipo: null,
    entidadId: null,
    datos: { cantidad: p.cantidad, total: p.total ?? null, items: (p.items ?? []).slice(0, MAX_ITEMS) },
    origen: 'regla',
  };
}
```

- [ ] **Step 4: Correr** — `rtk proxy npx jest src/ai-dashboard/alerts/reglas-catalogo.spec.ts` → 3 PASS.

---

### Task 2: `AlertasEmpresaService.resolverPorId` + endpoint

**Files:**
- Modify: `src/alertas-empresa/alertas-empresa.service.ts`
- Modify: `src/alertas-empresa/alertas-empresa.controller.ts`
- Test: `src/alertas-empresa/alertas-empresa.service.spec.ts`

**Interfaces:**
- Produces: `resolverPorId(alertaId: string, empresaId: string, usuarioId: string): Promise<boolean>` — marca `resuelta`, `resolucion = 'manual'`, `resuelta_por = usuarioId`; `false` si ya estaba resuelta; `NotFoundException` si no es de la empresa. Endpoint `PATCH /v1/alertas-empresa/:id/resolver` → `{ ok: true }`.

- [ ] **Step 1: Agregar tests al spec existente** (dentro del `describe('AlertasEmpresaService')`, reutilizando `crearService`):

```ts
  describe('resolverPorId', () => {
    it('marca resuelta manual con el usuario', async () => {
      const { service, prisma } = crearService({
        ai_alertas: {
          findFirst: jest.fn().mockResolvedValue({ id: 'al-1' }),
          updateMany: jest.fn().mockResolvedValue({ count: 1 }),
        },
      });
      await expect(service.resolverPorId('al-1', 'emp-1', 'usr-1')).resolves.toBe(true);
      expect(prisma.ai_alertas.updateMany).toHaveBeenCalledWith(
        expect.objectContaining({
          where: { id: 'al-1', empresa_id: 'emp-1', resuelta: false },
          data: expect.objectContaining({ resuelta: true, resolucion: 'manual', resuelta_por: 'usr-1' }),
        }),
      );
    });

    it('otra empresa → NotFound', async () => {
      const { service } = crearService({ ai_alertas: { findFirst: jest.fn().mockResolvedValue(null), updateMany: jest.fn() } });
      await expect(service.resolverPorId('al-1', 'emp-2', 'usr-1')).rejects.toBeInstanceOf(NotFoundException);
    });
  });
```

Nota: si `crearService` hace `{...prismaBase, ...overrides}` a nivel de modelo, el override de `ai_alertas` reemplaza todo el objeto; incluir `findFirst` y `updateMany` como arriba alcanza para estos dos tests.

- [ ] **Step 2: Correr** — `rtk proxy npx jest src/alertas-empresa` → los 2 nuevos FAIL ("resolverPorId is not a function").

- [ ] **Step 3: Implementar** en `alertas-empresa.service.ts`, después de `resolver(...)`:

```ts
  /** Descarte manual desde el Dashboard o la campana. Una regla la vuelve a crear si la condición persiste. */
  async resolverPorId(alertaId: string, empresaId: string, usuarioId: string): Promise<boolean> {
    await this.obtenerDeEmpresa(alertaId, empresaId);
    const { count } = await this.prisma.ai_alertas.updateMany({
      where: { id: alertaId, empresa_id: empresaId, resuelta: false },
      data: {
        resuelta: true,
        resuelta_at: new Date(),
        resuelta_por: usuarioId,
        resolucion: 'manual',
        updated_at: new Date(),
      },
    });
    return count > 0;
  }
```

Y en `alertas-empresa.controller.ts`, después de `marcarLeida`:

```ts
  @Patch(':id/resolver')
  @ApiOperation({ summary: 'Descartar una alerta (resolución manual)' })
  async resolver(@GetUser() user: LoginUserInfo, @Param('id', ParseUUIDPipe) id: string) {
    await this.alertasService.resolverPorId(id, user.empresa_id, user.id);
    return { ok: true };
  }
```

- [ ] **Step 4: Correr** — `rtk proxy npx jest src/alertas-empresa` → todo PASS; `rtk proxy npx tsc --noEmit -p tsconfig.json` sin errores.

---

### Task 3: `AlertsService` — ciclo registrar/resolver con reglas mockeadas

Primero el ciclo, con las reglas como métodos privados que el test espía. Las consultas reales se reescriben en las Tasks 4 y 5.

**Files:**
- Modify: `src/ai-dashboard/alerts/alerts.service.ts`
- Modify: `src/ai-dashboard/ai-dashboard.module.ts`
- Test: `src/ai-dashboard/alerts/alerts.service.spec.ts`

**Interfaces:**
- Consumes: Task 1 (`REGLAS_ALERTA`, `CLAVES_RRHH`, `ClaveRegla`), `AlertasEmpresaService.registrar/resolver`, `RrhhContextService.tieneModuloRRHH`.
- Produces:

```ts
export interface ConfigReglas { umbralMora: number; umbralStock: number; rrhhDiaCorte: number; rrhhAlertasOn: boolean }
export interface ResultadoDeteccion { registradas: number; resueltas: number; errores: string[] }
// AlertsService
async detectarYGuardarAlertas(empresaId: string): Promise<ResultadoDeteccion>;
// métodos privados, uno por clave, mismo nombre que la clave en camelCase:
private async reglaMoraCritica(empresaId, cfg): Promise<RegistrarAlertaInput | null>;           // mora_critica
private async reglaLimiteCreditoSuperado(empresaId, cfg)                                          // limite_credito_superado
private async reglaStockBajo(empresaId, cfg)                                                       // stock_bajo
private async reglaChequePorVencer(empresaId, cfg)                                                 // cheque_por_vencer
private async reglaSaldoCuentaBajo(empresaId, cfg)                                                 // saldo_cuenta_bajo
private async reglaRrhhLiquidacionNoCerrada(empresaId, cfg)                                        // rrhh_liquidacion_no_cerrada
private async reglaRrhhEmpleadosSinIps(empresaId, cfg)                                             // rrhh_empleados_sin_ips
private async reglaRrhhEmpleadosSinCbu(empresaId, cfg)                                             // rrhh_empleados_sin_cbu
private async reglaRrhhPrestamosSinDescuento(empresaId, cfg)                                       // rrhh_prestamos_sin_descuento
private async reglaRrhhVacacionesVencidas(empresaId, cfg)                                          // rrhh_vacaciones_vencidas
```

- [ ] **Step 1: Escribir el test del ciclo**

```ts
// src/ai-dashboard/alerts/alerts.service.spec.ts
import { AlertsService } from './alerts.service';
import { REGLAS_ALERTA, armarAlertaRegla } from './reglas-catalogo';

/**
 * Ciclo de vida por corrida: registrar lo que dio resultado, resolver en auto
 * lo que no, y no tocar la clave de una regla que falló.
 */
describe('AlertsService.detectarYGuardarAlertas - ciclo', () => {
  const alerta = (clave: keyof typeof REGLAS_ALERTA) =>
    armarAlertaRegla({ empresaId: 'emp-1', clave, criticidad: 'media', titulo: 't', descripcion: 'd', cantidad: 1 });

  const crearService = ({ rrhhOn = true, tieneRrhh = true } = {}) => {
    const prisma: any = {
      ai_empresa_config: {
        findUnique: jest.fn().mockResolvedValue({ umbral_alerta_mora: 30, umbral_stock_bajo: 5, rrhh_alertas_habilitadas: rrhhOn }),
      },
    };
    const rrhh = { tieneModuloRRHH: jest.fn().mockResolvedValue(tieneRrhh) };
    const bandeja = {
      registrar: jest.fn().mockResolvedValue({ id: 'al', creada: true }),
      resolver: jest.fn().mockResolvedValue(false),
    };
    const service = new AlertsService(prisma, rrhh as any, bandeja as any);
    // Todas las reglas sin resultado salvo que el test diga otra cosa.
    const reglas = {
      mora: jest.spyOn(service as any, 'reglaMoraCritica').mockResolvedValue(null),
      limite: jest.spyOn(service as any, 'reglaLimiteCreditoSuperado').mockResolvedValue(null),
      stock: jest.spyOn(service as any, 'reglaStockBajo').mockResolvedValue(null),
      cheque: jest.spyOn(service as any, 'reglaChequePorVencer').mockResolvedValue(null),
      saldo: jest.spyOn(service as any, 'reglaSaldoCuentaBajo').mockResolvedValue(null),
      liq: jest.spyOn(service as any, 'reglaRrhhLiquidacionNoCerrada').mockResolvedValue(null),
      ips: jest.spyOn(service as any, 'reglaRrhhEmpleadosSinIps').mockResolvedValue(null),
      cbu: jest.spyOn(service as any, 'reglaRrhhEmpleadosSinCbu').mockResolvedValue(null),
      prest: jest.spyOn(service as any, 'reglaRrhhPrestamosSinDescuento').mockResolvedValue(null),
      vac: jest.spyOn(service as any, 'reglaRrhhVacacionesVencidas').mockResolvedValue(null),
    };
    return { service, bandeja, reglas, rrhh };
  };

  it('registra las reglas con resultado y resuelve en auto el resto', async () => {
    const { service, bandeja, reglas } = crearService();
    reglas.stock.mockResolvedValue(alerta('stock_bajo'));

    const r = await service.detectarYGuardarAlertas('emp-1');

    expect(bandeja.registrar).toHaveBeenCalledTimes(1);
    expect(bandeja.registrar).toHaveBeenCalledWith(expect.objectContaining({ clave: 'stock_bajo', origen: 'regla' }));
    const resueltas = bandeja.resolver.mock.calls.map((c) => c[1]).sort();
    expect(resueltas).toEqual(Object.keys(REGLAS_ALERTA).filter((c) => c !== 'stock_bajo').sort());
    expect(bandeja.resolver.mock.calls.every((c) => c[0] === 'emp-1' && c[2] === 'auto')).toBe(true);
    expect(r).toEqual({ registradas: 1, resueltas: 0, errores: [] });
  });

  it('una regla que falla no registra ni resuelve su clave; las demás siguen', async () => {
    const { service, bandeja, reglas } = crearService();
    reglas.mora.mockRejectedValue(new Error('relation tes_x does not exist'));
    reglas.stock.mockResolvedValue(alerta('stock_bajo'));

    const r = await service.detectarYGuardarAlertas('emp-1');

    expect(bandeja.registrar).toHaveBeenCalledTimes(1);
    expect(bandeja.resolver.mock.calls.map((c) => c[1])).not.toContain('mora_critica');
    expect(bandeja.resolver).toHaveBeenCalledTimes(8);
    expect(r.errores).toHaveLength(1);
    expect(r.errores[0]).toContain('mora_critica');
  });

  it('RRHH apagado por config: no evalúa esas reglas y las resuelve', async () => {
    const { service, bandeja, reglas } = crearService({ rrhhOn: false });
    reglas.liq.mockResolvedValue(alerta('rrhh_liquidacion_no_cerrada'));

    await service.detectarYGuardarAlertas('emp-1');

    expect(reglas.liq).not.toHaveBeenCalled();
    expect(bandeja.registrar).not.toHaveBeenCalled();
    expect(bandeja.resolver.mock.calls.map((c) => c[1])).toEqual(expect.arrayContaining(['rrhh_liquidacion_no_cerrada', 'rrhh_vacaciones_vencidas']));
  });

  it('empresa sin módulo RRHH: igual que apagado', async () => {
    const { service, reglas, bandeja } = crearService({ tieneRrhh: false });
    await service.detectarYGuardarAlertas('emp-1');
    expect(reglas.ips).not.toHaveBeenCalled();
    expect(bandeja.resolver.mock.calls.map((c) => c[1])).toContain('rrhh_empleados_sin_ips');
  });

  it('cuenta resueltas cuando resolver devuelve true', async () => {
    const { service, bandeja } = crearService();
    bandeja.resolver.mockResolvedValue(true);
    const r = await service.detectarYGuardarAlertas('emp-1');
    expect(r.resueltas).toBe(10);
  });
});
```

- [ ] **Step 2: Correr** — `rtk proxy npx jest src/ai-dashboard/alerts/alerts.service.spec.ts` → FAIL (constructor con 2 args, métodos inexistentes).

- [ ] **Step 3: Reescribir el esqueleto de `alerts.service.ts`** (las reglas quedan como stubs que devuelven `null` hasta las Tasks 4 y 5; el código SQL viejo se conserva en el archivo mientras tanto para copiarlo, pero **no** se ejecuta):

```ts
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { AlertasEmpresaService } from 'src/alertas-empresa/alertas-empresa.service';
import { RegistrarAlertaInput } from 'src/alertas-empresa/alertas-empresa.types';
import { RrhhContextService } from '../shared/rrhh-context.service';
import { CLAVES_RRHH, ClaveRegla, REGLAS_ALERTA } from './reglas-catalogo';

export interface ConfigReglas {
  umbralMora: number;
  umbralStock: number;
  rrhhDiaCorte: number;
  rrhhAlertasOn: boolean;
}

export interface ResultadoDeteccion {
  registradas: number;
  resueltas: number;
  errores: string[];
}

type Regla = (empresaId: string, cfg: ConfigReglas) => Promise<RegistrarAlertaInput | null>;

/**
 * Reglas de negocio del Dashboard IA. Detectan y escriben en la bandeja única
 * de alertas (AlertasEmpresaService): una alerta agregada por regla y empresa,
 * que se resuelve sola cuando la condición desaparece.
 * Ver docs/superpowers/specs/2026-09-15-alertas-fase2-design.md.
 */
@Injectable()
export class AlertsService {
  private readonly logger = new Logger(AlertsService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly rrhhContext: RrhhContextService,
    private readonly bandeja: AlertasEmpresaService,
  ) {}

  async detectarYGuardarAlertas(empresaId: string): Promise<ResultadoDeteccion> {
    this.logger.log(`Detectando alertas para empresa ${empresaId}`);
    const cfg = await this.configReglas(empresaId);
    const rrhhActivo = cfg.rrhhAlertasOn && (await this.rrhhContext.tieneModuloRRHH(empresaId));

    const reglas: Record<ClaveRegla, Regla> = {
      mora_critica: (e, c) => this.reglaMoraCritica(e, c),
      limite_credito_superado: (e, c) => this.reglaLimiteCreditoSuperado(e, c),
      stock_bajo: (e, c) => this.reglaStockBajo(e, c),
      cheque_por_vencer: (e, c) => this.reglaChequePorVencer(e, c),
      saldo_cuenta_bajo: (e, c) => this.reglaSaldoCuentaBajo(e, c),
      rrhh_liquidacion_no_cerrada: (e, c) => this.reglaRrhhLiquidacionNoCerrada(e, c),
      rrhh_empleados_sin_ips: (e, c) => this.reglaRrhhEmpleadosSinIps(e, c),
      rrhh_empleados_sin_cbu: (e, c) => this.reglaRrhhEmpleadosSinCbu(e, c),
      rrhh_prestamos_sin_descuento: (e, c) => this.reglaRrhhPrestamosSinDescuento(e, c),
      rrhh_vacaciones_vencidas: (e, c) => this.reglaRrhhVacacionesVencidas(e, c),
    };

    const resultado: ResultadoDeteccion = { registradas: 0, resueltas: 0, errores: [] };

    for (const clave of Object.keys(REGLAS_ALERTA) as ClaveRegla[]) {
      // Regla apagada por configuración o sin módulo: cuenta como "sin alerta".
      if (CLAVES_RRHH.includes(clave) && !rrhhActivo) {
        if (await this.bandeja.resolver(empresaId, clave, 'auto')) resultado.resueltas++;
        continue;
      }
      let alerta: RegistrarAlertaInput | null;
      try {
        alerta = await reglas[clave](empresaId, cfg);
      } catch (error) {
        // Sin evidencia de que la condición desapareció, la alerta abierta queda como está.
        const msg = `${clave}: ${error instanceof Error ? error.message : 'Error desconocido'}`;
        this.logger.warn(`Regla con error, se deja su alerta como está | ${msg}`);
        resultado.errores.push(msg);
        continue;
      }
      if (alerta) {
        await this.bandeja.registrar(alerta);
        resultado.registradas++;
      } else if (await this.bandeja.resolver(empresaId, clave, 'auto')) {
        resultado.resueltas++;
      }
    }

    this.logger.log(
      `Alertas empresa ${empresaId}: ${resultado.registradas} registradas, ${resultado.resueltas} resueltas, ${resultado.errores.length} errores`,
    );
    return resultado;
  }

  private async configReglas(empresaId: string): Promise<ConfigReglas> {
    const config = await this.prisma.ai_empresa_config.findUnique({ where: { empresa_id: empresaId } });
    return {
      umbralMora: config?.umbral_alerta_mora ?? 30,
      umbralStock: config?.umbral_stock_bajo ?? 5,
      rrhhDiaCorte: config?.rrhh_dia_corte_liquidacion ?? 5,
      rrhhAlertasOn: config?.rrhh_alertas_habilitadas ?? true,
    };
  }

  // ── Reglas (se implementan en las tareas siguientes) ─────────────────────
  private async reglaMoraCritica(_e: string, _c: ConfigReglas): Promise<RegistrarAlertaInput | null> { return null; }
  private async reglaLimiteCreditoSuperado(_e: string, _c: ConfigReglas): Promise<RegistrarAlertaInput | null> { return null; }
  private async reglaStockBajo(_e: string, _c: ConfigReglas): Promise<RegistrarAlertaInput | null> { return null; }
  private async reglaChequePorVencer(_e: string, _c: ConfigReglas): Promise<RegistrarAlertaInput | null> { return null; }
  private async reglaSaldoCuentaBajo(_e: string, _c: ConfigReglas): Promise<RegistrarAlertaInput | null> { return null; }
  private async reglaRrhhLiquidacionNoCerrada(_e: string, _c: ConfigReglas): Promise<RegistrarAlertaInput | null> { return null; }
  private async reglaRrhhEmpleadosSinIps(_e: string, _c: ConfigReglas): Promise<RegistrarAlertaInput | null> { return null; }
  private async reglaRrhhEmpleadosSinCbu(_e: string, _c: ConfigReglas): Promise<RegistrarAlertaInput | null> { return null; }
  private async reglaRrhhPrestamosSinDescuento(_e: string, _c: ConfigReglas): Promise<RegistrarAlertaInput | null> { return null; }
  private async reglaRrhhVacacionesVencidas(_e: string, _c: ConfigReglas): Promise<RegistrarAlertaInput | null> { return null; }
}
```

Eliminar del archivo `getAlertas`, `marcarLeida`, `marcarResuelta` y el viejo `detectarAlertasRRHH` (su SQL se reusa en Task 5; copiarlo antes de borrar o leerlo de git: `git show HEAD:src/ai-dashboard/alerts/alerts.service.ts`).

En `ai-dashboard.module.ts` agregar `AlertasEmpresaModule` a `imports`:

```ts
import { AlertasEmpresaModule } from 'src/alertas-empresa/alertas-empresa.module';
// ...
  imports: [PrismaModule, AiConfigModule, AiUsageModule, AlertasEmpresaModule, ScheduleModule.forRoot()],
```

`RecalculoService.ejecutar` llama `await this.alerts.detectarYGuardarAlertas(empresaId)` sin usar el retorno: no requiere cambio.

- [ ] **Step 4: Correr** — `rtk proxy npx jest src/ai-dashboard/alerts` → PASS. `tsc` va a fallar en `ai-dashboard.controller.ts` por `alertsService.getAlertas`... (se arregla en Task 6); verificar que **sólo** falle ahí: `rtk proxy npx tsc --noEmit -p tsconfig.json 2>&1 | grep "error TS"`.

---

### Task 4: Reglas de cobranzas, inventario y tesorería (agregadas, sin LIMIT)

**Files:**
- Modify: `src/ai-dashboard/alerts/alerts.service.ts` (los 5 stubs)
- Test: `src/ai-dashboard/alerts/alerts.service.spec.ts` (nuevo `describe`)

**Interfaces:**
- Consumes: Task 1 (`armarAlertaRegla`, `fmtGs`, `ItemAlerta`), Task 3 (`ConfigReglas`).
- Produces: las 5 reglas implementadas. Cada una hace **una** consulta que devuelve los casos ordenados del peor al mejor y agrega en memoria (cantidad, total, 5 peores). Sin `LIMIT`.

- [ ] **Step 1: Test de las reglas con `$queryRaw` mockeado**

```ts
describe('AlertsService - reglas de negocio', () => {
  const cfg = { umbralMora: 30, umbralStock: 5, rrhhDiaCorte: 5, rrhhAlertasOn: true };
  const crear = (filas: unknown[]) => {
    const prisma: any = { $queryRaw: jest.fn().mockResolvedValue(filas) };
    return new AlertsService(prisma, {} as any, {} as any) as any;
  };

  it('mora_critica: agrega clientes, alta si alguno ≥ 60 días, 5 peores en items', async () => {
    const filas = Array.from({ length: 8 }, (_, i) => ({ cliente_id: `c${i}`, nombre: `Cliente ${i}`, dias_mora: 90 - i * 10, deuda: 1000 * (i + 1) }));
    const a = await crear(filas).reglaMoraCritica('emp-1', cfg);
    expect(a.clave).toBe('mora_critica');
    expect(a.criticidad).toBe('alta');
    expect(a.titulo).toBe(`8 clientes con más de 30 días de mora (${fmtGs(36000)})`);
    expect(a.datos.cantidad).toBe(8);
    expect(a.datos.total).toBe(36000);
    expect(a.datos.items).toHaveLength(5);
    expect(a.datos.items[0]).toEqual({ id: 'c0', nombre: 'Cliente 0', valor: 1000, detalle: '90 días' });
    expect(a.descripcion).toContain('Cliente 0 — 90 días — Gs. 1.000');
  });

  it('mora_critica: media si el peor está entre umbral y 59 días; null sin filas', async () => {
    const a = await crear([{ cliente_id: 'c1', nombre: 'X', dias_mora: 45, deuda: 10 }]).reglaMoraCritica('emp-1', cfg);
    expect(a.criticidad).toBe('media');
    expect(a.titulo).toBe(`1 cliente con más de 30 días de mora (${fmtGs(10)})`);
    expect(await crear([]).reglaMoraCritica('emp-1', cfg)).toBeNull();
  });

  it('limite_credito_superado: siempre alta, total = suma de excesos', async () => {
    const a = await crear([
      { cliente_id: 'c1', nombre: 'A', saldo: 1500, limite: 1000 },
      { cliente_id: 'c2', nombre: 'B', saldo: 300, limite: 100 },
    ]).reglaLimiteCreditoSuperado('emp-1', cfg);
    expect(a).toMatchObject({ clave: 'limite_credito_superado', criticidad: 'alta' });
    expect(a.titulo).toBe(`2 clientes superan su límite de crédito (${fmtGs(700)} en exceso)`);
    expect(a.datos.items[0]).toEqual({ id: 'c1', nombre: 'A', valor: 500, detalle: `saldo ${fmtGs(1500)} / límite ${fmtGs(1000)}` });
  });

  it('stock_bajo: alta si hay productos en 0; título con cantidad sin stock', async () => {
    const a = await crear([
      { producto_id: 'p1', descripcion: 'Tornillo', cantidad: 0 },
      { producto_id: 'p2', descripcion: 'Tuerca', cantidad: 3 },
    ]).reglaStockBajo('emp-1', cfg);
    expect(a).toMatchObject({ clave: 'stock_bajo', criticidad: 'alta' });
    expect(a.titulo).toBe('2 productos con stock bajo (1 sin stock)');
    expect(a.datos.items[0]).toEqual({ id: 'p1', nombre: 'Tornillo', valor: 0, detalle: 'sin stock' });
    expect(a.datos.items[1].detalle).toBe('3 unidades');
    const b = await crear([{ producto_id: 'p2', descripcion: 'Tuerca', cantidad: 3 }]).reglaStockBajo('emp-1', cfg);
    expect(b.criticidad).toBe('media');
    expect(b.titulo).toBe('1 producto con stock bajo');
  });

  it('cheque_por_vencer: alta si alguno vence en ≤ 1 día', async () => {
    const a = await crear([
      { id: 'ch1', numero_cheque: '123', banco_emisor: 'Itaú', monto: 500000, fecha_vencimiento: new Date(), dias_restantes: 1 },
      { id: 'ch2', numero_cheque: '124', banco_emisor: 'GNB', monto: 250000, fecha_vencimiento: new Date(), dias_restantes: 3 },
    ]).reglaChequePorVencer('emp-1', cfg);
    expect(a).toMatchObject({ clave: 'cheque_por_vencer', criticidad: 'alta' });
    expect(a.titulo).toBe(`2 cheques vencen en los próximos 3 días (${fmtGs(750000)})`);
    expect(a.datos.items[0]).toEqual({ id: 'ch1', nombre: 'Itaú Nro 123', valor: 500000, detalle: 'vence en 1 día' });
    expect(a.datos.items[1].detalle).toBe('vence en 3 días');
  });

  it('saldo_cuenta_bajo: alta si alguna < 100.000', async () => {
    const a = await crear([{ id: 'cu1', nombre: 'Caja Itaú', saldo_actual: 80000 }]).reglaSaldoCuentaBajo('emp-1', cfg);
    expect(a).toMatchObject({ clave: 'saldo_cuenta_bajo', criticidad: 'alta' });
    expect(a.titulo).toBe('1 cuenta bancaria con saldo bajo');
    expect(a.datos.items[0]).toEqual({ id: 'cu1', nombre: 'Caja Itaú', valor: 80000, detalle: fmtGs(80000) });
  });
});
```

Agregar `import { fmtGs } from './reglas-catalogo';` al spec.

- [ ] **Step 2: Correr** → FAIL (todas devuelven `null`).

- [ ] **Step 3: Implementar las 5 reglas** reemplazando los stubs. Las consultas son las actuales sin `LIMIT`:

```ts
  private async reglaMoraCritica(empresaId: string, cfg: ConfigReglas): Promise<RegistrarAlertaInput | null> {
    const filas = await this.prisma.$queryRaw<{ cliente_id: string; nombre: string; dias_mora: number; deuda: Prisma.Decimal | number }[]>`
      SELECT c.id AS cliente_id, p.razon_social AS nombre,
             MAX(EXTRACT(DAY FROM NOW() - fc.dvenccuo))::int AS dias_mora,
             COALESCE(SUM(fc.saldo_pendiente), 0) AS deuda
      FROM factura_cuotas fc
      JOIN factura_cab cab ON cab.id = fc.factura_cab_id
      JOIN clientes c ON c.id = cab.cliente_id
      JOIN personas p ON p.id = c.persona_id
      WHERE cab.empresa_id = ${empresaId}::uuid AND fc.estado = 'pendiente' AND fc.dvenccuo < NOW()
      GROUP BY c.id, p.razon_social
      HAVING MAX(EXTRACT(DAY FROM NOW() - fc.dvenccuo)) >= ${cfg.umbralMora}
      ORDER BY dias_mora DESC`;
    if (filas.length === 0) return null;
    const total = filas.reduce((s, f) => s + Number(f.deuda), 0);
    const items: ItemAlerta[] = filas.slice(0, MAX_ITEMS).map((f) => ({
      id: f.cliente_id, nombre: f.nombre, valor: Number(f.deuda), detalle: `${f.dias_mora} días`,
    }));
    return armarAlertaRegla({
      empresaId, clave: 'mora_critica',
      criticidad: filas[0].dias_mora >= 60 ? 'alta' : 'media',
      titulo: `${plural(filas.length, 'cliente')} con más de ${cfg.umbralMora} días de mora (${fmtGs(total)})`,
      descripcion: items.map((i) => `${i.nombre} — ${i.detalle} — ${fmtGs(i.valor ?? 0)}`).join('\n'),
      cantidad: filas.length, total, items,
    });
  }

  private async reglaLimiteCreditoSuperado(empresaId: string, _cfg: ConfigReglas): Promise<RegistrarAlertaInput | null> {
    const filas = await this.prisma.$queryRaw<{ cliente_id: string; nombre: string; saldo: Prisma.Decimal | number; limite: Prisma.Decimal | number }[]>`
      SELECT c.id AS cliente_id, p.razon_social AS nombre, c.saldo_pendiente AS saldo, c.limite_credito AS limite
      FROM clientes c JOIN personas p ON p.id = c.persona_id
      WHERE p.empresa_id = ${empresaId}::uuid AND c.deleted = false
        AND c.limite_credito IS NOT NULL AND c.limite_credito > 0 AND c.saldo_pendiente > c.limite_credito
      ORDER BY (c.saldo_pendiente - c.limite_credito) DESC`;
    if (filas.length === 0) return null;
    const excesos = filas.map((f) => Number(f.saldo) - Number(f.limite));
    const total = excesos.reduce((s, e) => s + e, 0);
    const items: ItemAlerta[] = filas.slice(0, MAX_ITEMS).map((f, i) => ({
      id: f.cliente_id, nombre: f.nombre, valor: excesos[i], detalle: `saldo ${fmtGs(Number(f.saldo))} / límite ${fmtGs(Number(f.limite))}`,
    }));
    return armarAlertaRegla({
      empresaId, clave: 'limite_credito_superado', criticidad: 'alta',
      titulo: `${plural(filas.length, 'cliente')} ${filas.length === 1 ? 'supera' : 'superan'} su límite de crédito (${fmtGs(total)} en exceso)`,
      descripcion: items.map((i) => `${i.nombre} — ${i.detalle}`).join('\n'),
      cantidad: filas.length, total, items,
    });
  }

  private async reglaStockBajo(empresaId: string, cfg: ConfigReglas): Promise<RegistrarAlertaInput | null> {
    const filas = await this.prisma.$queryRaw<{ producto_id: string; descripcion: string; cantidad: number }[]>`
      SELECT p.id AS producto_id, p.descripcion, COALESCE(sd.cantidad_disponible, 0)::int AS cantidad
      FROM productos p
      JOIN stock_deposito sd ON sd.producto_id = p.id
      JOIN depositos d ON d.id = sd.deposito_id
      WHERE d.empresa_id = ${empresaId}::uuid AND p.active = true AND p.deleted = false
        AND COALESCE(sd.cantidad_disponible, 0) <= ${cfg.umbralStock}
      ORDER BY sd.cantidad_disponible ASC`;
    if (filas.length === 0) return null;
    const sinStock = filas.filter((f) => f.cantidad <= 0).length;
    const items: ItemAlerta[] = filas.slice(0, MAX_ITEMS).map((f) => ({
      id: f.producto_id, nombre: f.descripcion, valor: f.cantidad,
      detalle: f.cantidad <= 0 ? 'sin stock' : `${f.cantidad} ${f.cantidad === 1 ? 'unidad' : 'unidades'}`,
    }));
    return armarAlertaRegla({
      empresaId, clave: 'stock_bajo', criticidad: sinStock > 0 ? 'alta' : 'media',
      titulo: `${plural(filas.length, 'producto')} con stock bajo${sinStock > 0 ? ` (${sinStock} sin stock)` : ''}`,
      descripcion: items.map((i) => `${i.nombre} — ${i.detalle}`).join('\n'),
      cantidad: filas.length, items,
    });
  }

  private async reglaChequePorVencer(empresaId: string, _cfg: ConfigReglas): Promise<RegistrarAlertaInput | null> {
    const filas = await this.prisma.$queryRaw<{ id: string; numero_cheque: string; banco_emisor: string; monto: Prisma.Decimal | number; dias_restantes: number }[]>`
      SELECT id, numero_cheque, banco_emisor, monto, (fecha_vencimiento - CURRENT_DATE)::int AS dias_restantes
      FROM tes_cheques
      WHERE empresa_id = ${empresaId}::uuid AND tipo = 'RECIBIDO' AND estado = 'EN_CARTERA'
        AND fecha_vencimiento BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '3 days'
      ORDER BY fecha_vencimiento ASC`;
    if (filas.length === 0) return null;
    const total = filas.reduce((s, f) => s + Number(f.monto), 0);
    const items: ItemAlerta[] = filas.slice(0, MAX_ITEMS).map((f) => ({
      id: f.id, nombre: `${f.banco_emisor} Nro ${f.numero_cheque}`, valor: Number(f.monto),
      detalle: `vence en ${f.dias_restantes} ${f.dias_restantes === 1 ? 'día' : 'días'}`,
    }));
    return armarAlertaRegla({
      empresaId, clave: 'cheque_por_vencer', criticidad: filas[0].dias_restantes <= 1 ? 'alta' : 'media',
      titulo: `${plural(filas.length, 'cheque')} ${filas.length === 1 ? 'vence' : 'vencen'} en los próximos 3 días (${fmtGs(total)})`,
      descripcion: items.map((i) => `${i.nombre} — ${fmtGs(i.valor ?? 0)} — ${i.detalle}`).join('\n'),
      cantidad: filas.length, total, items,
    });
  }

  private async reglaSaldoCuentaBajo(empresaId: string, _cfg: ConfigReglas): Promise<RegistrarAlertaInput | null> {
    const filas = await this.prisma.$queryRaw<{ id: string; nombre: string; saldo_actual: Prisma.Decimal | number }[]>`
      SELECT id, nombre, saldo_actual FROM tes_cuentas
      WHERE empresa_id = ${empresaId}::uuid AND activo = true AND moneda = 'PYG'
        AND saldo_actual < 500000 AND saldo_actual >= 0
      ORDER BY saldo_actual ASC`;
    if (filas.length === 0) return null;
    const items: ItemAlerta[] = filas.slice(0, MAX_ITEMS).map((f) => ({
      id: f.id, nombre: f.nombre, valor: Number(f.saldo_actual), detalle: fmtGs(Number(f.saldo_actual)),
    }));
    return armarAlertaRegla({
      empresaId, clave: 'saldo_cuenta_bajo', criticidad: Number(filas[0].saldo_actual) < 100_000 ? 'alta' : 'media',
      titulo: `${plural(filas.length, 'cuenta bancaria', 'cuentas bancarias')} con saldo bajo`,
      descripcion: items.map((i) => `${i.nombre} — ${i.detalle}`).join('\n'),
      cantidad: filas.length, items,
    });
  }
```

Helper a nivel de módulo (arriba de la clase) e imports:

```ts
import { Prisma } from '@prisma/client';
import { ItemAlerta, MAX_ITEMS, armarAlertaRegla, fmtGs } from './reglas-catalogo';

/** "1 cliente" / "8 clientes"; plural explícito cuando no alcanza con la "s". */
const plural = (n: number, singular: string, pluralForm = `${singular}s`) =>
  `${n.toLocaleString('es-PY')} ${n === 1 ? singular : pluralForm}`;
```

Nota: `plural` usa `toLocaleString('es-PY')` para el número, así "2.502 clientes"; los tests de arriba usan cantidades < 1000 y no se ven afectados.

- [ ] **Step 4: Correr** — `rtk proxy npx jest src/ai-dashboard/alerts` → PASS.

---

### Task 5: Reglas RRHH (5) agregadas

**Files:**
- Modify: `src/ai-dashboard/alerts/alerts.service.ts` (los 5 stubs `reglaRrhh*`)
- Test: `src/ai-dashboard/alerts/alerts.service.spec.ts` (nuevo `describe`)

**Interfaces:**
- Consumes: Task 1, Task 3. Las consultas son las actuales (R1–R5 del `detectarAlertasRRHH` viejo; recuperar con `git show HEAD:src/ai-dashboard/alerts/alerts.service.ts`).

- [ ] **Step 1: Test**

```ts
describe('AlertsService - reglas RRHH', () => {
  const cfg = { umbralMora: 30, umbralStock: 5, rrhhDiaCorte: 5, rrhhAlertasOn: true };
  const crear = (filas: unknown[]) => new AlertsService({ $queryRaw: jest.fn().mockResolvedValue(filas) } as any, {} as any, {} as any) as any;

  it('liquidación no cerrada: sólo pasado el día de corte y sin liquidación CERRADA del mes anterior', async () => {
    jest.useFakeTimers().setSystemTime(new Date('2026-09-15T12:00:00'));
    const a = await crear([{ cant: 0 }]).reglaRrhhLiquidacionNoCerrada('emp-1', cfg);
    expect(a).toMatchObject({ clave: 'rrhh_liquidacion_no_cerrada', criticidad: 'alta', titulo: 'Liquidación 08/2026 sin cerrar' });
    expect(a.datos).toEqual({ cantidad: 1, total: null, items: [{ id: '2026-08', nombre: '08/2026', valor: null, detalle: 'sin cerrar' }] });
    expect(await crear([{ cant: 1 }]).reglaRrhhLiquidacionNoCerrada('emp-1', cfg)).toBeNull();
    jest.setSystemTime(new Date('2026-09-03T12:00:00'));
    expect(await crear([{ cant: 0 }]).reglaRrhhLiquidacionNoCerrada('emp-1', cfg)).toBeNull();
    jest.useRealTimers();
  });

  it('empleados sin IPS / sin CBU: conteo, media', async () => {
    const ips = await crear([{ cant: 3 }]).reglaRrhhEmpleadosSinIps('emp-1', cfg);
    expect(ips).toMatchObject({ clave: 'rrhh_empleados_sin_ips', criticidad: 'media', titulo: '3 empleados sin número de IPS' });
    expect(ips.datos.cantidad).toBe(3);
    const cbu = await crear([{ cant: 1 }]).reglaRrhhEmpleadosSinCbu('emp-1', cfg);
    expect(cbu.titulo).toBe('1 empleado sin cuenta bancaria');
    expect(await crear([{ cant: 0 }]).reglaRrhhEmpleadosSinCbu('emp-1', cfg)).toBeNull();
  });

  it('préstamos sin descuento y vacaciones vencidas', async () => {
    const pr = await crear([{ cant: 2 }]).reglaRrhhPrestamosSinDescuento('emp-1', cfg);
    expect(pr).toMatchObject({ clave: 'rrhh_prestamos_sin_descuento', criticidad: 'media', titulo: '2 préstamos vigentes sin descuento en la última liquidación' });
    const vac = await crear([{ cant: 4, dias: 37 }]).reglaRrhhVacacionesVencidas('emp-1', cfg);
    expect(vac).toMatchObject({ clave: 'rrhh_vacaciones_vencidas', criticidad: 'baja', titulo: '37 días de vacaciones vencidas sin tomar' });
    expect(vac.datos).toMatchObject({ cantidad: 4, total: 37 });
    expect(await crear([{ cant: 0, dias: 0 }]).reglaRrhhVacacionesVencidas('emp-1', cfg)).toBeNull();
  });
});
```

- [ ] **Step 2: Correr** → FAIL.

- [ ] **Step 3: Implementar** reemplazando los 5 stubs (SQL igual al actual):

```ts
  private async reglaRrhhLiquidacionNoCerrada(empresaId: string, cfg: ConfigReglas): Promise<RegistrarAlertaInput | null> {
    const hoy = new Date();
    if (hoy.getDate() <= cfg.rrhhDiaCorte) return null;
    const mesAnt = hoy.getMonth() === 0 ? 12 : hoy.getMonth();
    const anioAnt = hoy.getMonth() === 0 ? hoy.getFullYear() - 1 : hoy.getFullYear();
    const [fila] = await this.prisma.$queryRaw<{ cant: number }[]>`
      SELECT COUNT(*)::int AS cant FROM rrhh_liquidaciones_cabecera
      WHERE empresa_id = ${empresaId}::uuid AND tipo = 'MENSUAL'
        AND periodo_anio = ${anioAnt} AND periodo_mes = ${mesAnt} AND estado = 'CERRADA'`;
    if ((fila?.cant ?? 0) > 0) return null;
    const mm = String(mesAnt).padStart(2, '0');
    return armarAlertaRegla({
      empresaId, clave: 'rrhh_liquidacion_no_cerrada', criticidad: 'alta',
      titulo: `Liquidación ${mm}/${anioAnt} sin cerrar`,
      descripcion: `Pasó el día de corte (día ${cfg.rrhhDiaCorte}) y la liquidación mensual de ${mm}/${anioAnt} todavía no está cerrada.`,
      cantidad: 1, items: [{ id: `${anioAnt}-${mm}`, nombre: `${mm}/${anioAnt}`, valor: null, detalle: 'sin cerrar' }],
    });
  }

  private async reglaRrhhEmpleadosSinIps(empresaId: string, _cfg: ConfigReglas): Promise<RegistrarAlertaInput | null> {
    const [fila] = await this.prisma.$queryRaw<{ cant: number }[]>`
      SELECT COUNT(*)::int AS cant FROM rrhh_empleados
      WHERE empresa_id = ${empresaId}::uuid AND estado = 'ACTIVO' AND aporta_ips = true
        AND (numero_ips IS NULL OR LENGTH(TRIM(numero_ips)) = 0)`;
    const cant = fila?.cant ?? 0;
    if (cant === 0) return null;
    return armarAlertaRegla({
      empresaId, clave: 'rrhh_empleados_sin_ips', criticidad: 'media',
      titulo: `${plural(cant, 'empleado')} sin número de IPS`,
      descripcion: `Hay ${cant} empleado${cant !== 1 ? 's activos' : ' activo'} con aporte IPS marcado pero sin número de patronal cargado.`,
      cantidad: cant,
    });
  }

  private async reglaRrhhEmpleadosSinCbu(empresaId: string, _cfg: ConfigReglas): Promise<RegistrarAlertaInput | null> {
    const [fila] = await this.prisma.$queryRaw<{ cant: number }[]>`
      SELECT COUNT(*)::int AS cant FROM rrhh_empleados
      WHERE empresa_id = ${empresaId}::uuid AND estado = 'ACTIVO'
        AND (cuenta_bancaria IS NULL OR LENGTH(TRIM(cuenta_bancaria)) = 0)`;
    const cant = fila?.cant ?? 0;
    if (cant === 0) return null;
    return armarAlertaRegla({
      empresaId, clave: 'rrhh_empleados_sin_cbu', criticidad: 'media',
      titulo: `${plural(cant, 'empleado')} sin cuenta bancaria`,
      descripcion: `${cant} empleado${cant !== 1 ? 's activos no tienen' : ' activo no tiene'} cuenta bancaria cargada. La acreditación bancaria los va a omitir.`,
      cantidad: cant,
    });
  }

  private async reglaRrhhPrestamosSinDescuento(empresaId: string, _cfg: ConfigReglas): Promise<RegistrarAlertaInput | null> {
    const [fila] = await this.prisma.$queryRaw<{ cant: number }[]>`
      WITH ultima AS (
        SELECT id FROM rrhh_liquidaciones_cabecera
        WHERE empresa_id = ${empresaId}::uuid AND tipo = 'MENSUAL' AND estado = 'CERRADA'
        ORDER BY periodo_anio DESC, periodo_mes DESC LIMIT 1
      )
      SELECT COUNT(*)::int AS cant FROM rrhh_prestamos pr
      WHERE pr.empresa_id = ${empresaId}::uuid AND pr.estado = 'ACTIVO' AND pr.saldo_pendiente > 0
        AND EXISTS (SELECT 1 FROM ultima)
        AND NOT EXISTS (
          SELECT 1 FROM rrhh_liquidaciones_detalle ld JOIN ultima u ON u.id = ld.liquidacion_id
          WHERE ld.empleado_id = pr.empleado_id AND ld.monto > 0
            AND ld.concepto_id IN (
              SELECT id FROM rrhh_conceptos_liquidacion
              WHERE empresa_id = ${empresaId}::uuid AND (categoria ILIKE '%PRESTAMO%' OR codigo ILIKE '%PREST%')
            )
        )`;
    const cant = fila?.cant ?? 0;
    if (cant === 0) return null;
    return armarAlertaRegla({
      empresaId, clave: 'rrhh_prestamos_sin_descuento', criticidad: 'media',
      titulo: `${plural(cant, 'préstamo vigente', 'préstamos vigentes')} sin descuento en la última liquidación`,
      descripcion: `${cant} préstamo${cant !== 1 ? 's' : ''} con saldo pendiente no recibieron cuota descontada en la última liquidación mensual cerrada.`,
      cantidad: cant,
    });
  }

  private async reglaRrhhVacacionesVencidas(empresaId: string, _cfg: ConfigReglas): Promise<RegistrarAlertaInput | null> {
    const anioLimite = new Date().getFullYear() - 1;
    const [fila] = await this.prisma.$queryRaw<{ cant: number; dias: number }[]>`
      SELECT COUNT(*)::int AS cant, COALESCE(SUM(dias_pendientes), 0)::int AS dias
      FROM rrhh_vacaciones_saldo
      WHERE empresa_id = ${empresaId}::uuid AND anio <= ${anioLimite} AND dias_pendientes > 0`;
    const cant = fila?.cant ?? 0;
    const dias = fila?.dias ?? 0;
    if (cant === 0) return null;
    return armarAlertaRegla({
      empresaId, clave: 'rrhh_vacaciones_vencidas', criticidad: 'baja',
      titulo: `${dias} días de vacaciones vencidas sin tomar`,
      descripcion: `${cant} empleado${cant !== 1 ? 's tienen' : ' tiene'} saldo de vacaciones generado en ${anioLimite} o antes (${dias} días en total).`,
      cantidad: cant, total: dias,
    });
  }
```

- [ ] **Step 4: Correr** — `rtk proxy npx jest src/ai-dashboard/alerts` → PASS. Confirmar que no quedó SQL viejo suelto: `grep -n "createMany\|detectarAlertasRRHH\|LIMIT 20" src/ai-dashboard/alerts/alerts.service.ts` → sin resultados.

---

### Task 6: Dashboard IA como fachada de la bandeja + migración de datos viejos

**Files:**
- Modify: `src/ai-dashboard/ai-dashboard.controller.ts:64-98`
- Create: `prisma/migrations/20260915_alertas_reglas_resolver_viejas/migration.sql`

**Interfaces:**
- Consumes: Task 2 (`resolverPorId`), `AlertasEmpresaService.listarParaUsuario/marcarLeida`.
- Produces: mismos 3 endpoints (`GET /ai-dashboard/alertas`, `PATCH …/leer`, `PATCH …/resolver`) con la bandeja nueva.

- [ ] **Step 1: Controller** — reemplazar los tres métodos:

```ts
  @Get('alertas')
  @ApiOperation({ summary: 'Alertas abiertas de la bandeja de empresa que el usuario puede ver' })
  getAlertas(@GetUser() user: LoginUserInfo) {
    return this.alertasEmpresa.listarParaUsuario(user.id, user.empresa_id);
  }

  @Patch('alertas/:id/leer')
  @ApiOperation({ summary: 'Marcar alerta como leída (por usuario)' })
  async marcarLeida(@Param('id') id: string, @GetUser() user: LoginUserInfo) {
    await this.alertasEmpresa.marcarLeida(id, user.id, user.empresa_id);
    return { ok: true };
  }

  @Patch('alertas/:id/resolver')
  @ApiOperation({ summary: 'Descartar alerta (resolución manual)' })
  async marcarResuelta(@Param('id') id: string, @GetUser() user: LoginUserInfo) {
    await this.alertasEmpresa.resolverPorId(id, user.empresa_id, user.id);
    return { ok: true };
  }
```

En el constructor: quitar `private readonly alertsService: AlertsService` y agregar `private readonly alertasEmpresa: AlertasEmpresaService` (import desde `src/alertas-empresa/alertas-empresa.service`). Quitar el import de `AlertsService` si no queda otro uso. Si `filtrarPorDominios` o `rrhhContext` quedan sin uso en el archivo, quitar esos imports/inyecciones también (verificar con `grep -n "filtrarPorDominios\|rrhhContext\." src/ai-dashboard/ai-dashboard.controller.ts`; `rrhhContext` sí se usa en `metricas/rrhh`, dejarlo).

- [ ] **Step 2: Migración**

```sql
-- Fase 2 de alertas: las reglas del Dashboard IA pasan a la bandeja única
-- (docs/superpowers/specs/2026-09-15-alertas-fase2-design.md). Las alertas
-- viejas (sin clave, duplicadas en cada corrida) se cierran como histórico;
-- la primera corrida crea la alerta agregada de cada regla.
UPDATE ai_alertas
   SET resuelta = true, resuelta_at = now(), resolucion = 'auto', updated_at = now()
 WHERE resuelta = false AND origen = 'regla' AND clave IS NULL;
```

Aplicar: `npx prisma db execute --file prisma/migrations/20260915_alertas_reglas_resolver_viejas/migration.sql --schema prisma/schema.prisma && npx prisma migrate resolve --applied "20260915_alertas_reglas_resolver_viejas"`.

- [ ] **Step 3: Verificar** — `rtk proxy npx tsc --noEmit -p tsconfig.json` sin errores; `rtk proxy npx jest src/ai-dashboard src/alertas-empresa` PASS. En la base: `SELECT count(*) FROM ai_alertas WHERE resuelta = false AND clave IS NULL` → 0.

- [ ] **Step 4: Corrida real** (backend local en watch): `POST /v1/ai-dashboard/recalcular` con el token de `taller` (o desde el botón "Recalcular" del Dashboard IA), luego `SELECT clave, criticidad, titulo FROM ai_alertas WHERE resuelta = false AND origen = 'regla' AND empresa_id = '9ec23228-946b-4ac3-8d9f-d547ce464c28'` → una fila por regla con condición (se espera al menos `stock_bajo` con ~3.752 productos y `mora_critica` con 1 cliente). Repetir el recálculo: mismas filas, `updated_at` nuevo, sin duplicados.

---

### Task 7: Frontend — API, hook, `AlertaEmpresaItem` con Resolver e items

**Files:**
- Modify: `src/api/alertas-empresa.service.js`, `src/api/ai-dashboard.service.js`
- Modify: `src/tanstack/AlertasEmpresaStack.jsx`
- Modify: `src/components/_standards/AlertaEmpresaItem.jsx`

**Interfaces:**
- Produces: `resolverAlerta(id)` → `PATCH /alertas-empresa/:id/resolver`; `useResolverAlertaMutation()`; `AlertaEmpresaItem` muestra `datos.items` y botón **Resolver** (no para `sifen_evento`).

- [ ] **Step 1: API** — en `alertas-empresa.service.js` agregar:

```js
/** Descarta la alerta (resolución manual). Una regla la vuelve a crear si la condición persiste. */
export const resolverAlerta = async (id) => {
  const response = await api.patch(`/alertas-empresa/${id}/resolver`);
  return response.data;
};
```

En `ai-dashboard.service.js` borrar `getAlertas`, `marcarAlertaLeida`, `resolverAlerta` (Task 8 quita sus usos; hacer ambas en el mismo bloque para que el dev server no quede roto).

- [ ] **Step 2: Hook** — en `AlertasEmpresaStack.jsx`, importar `resolverAlerta` y agregar:

```js
export const useResolverAlertaMutation = () => {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (id) => resolverAlerta(id),
    onSuccess: () => {
      toast.success("Alerta descartada. Si la situación persiste, se vuelve a generar en la próxima revisión.");
      queryClient.invalidateQueries({ queryKey: ALERTAS_EMPRESA_KEY });
    },
  });
};
```

- [ ] **Step 3: `AlertaEmpresaItem`** — cambios:

```jsx
// imports: agregar useResolverAlertaMutation al import de ../../tanstack/AlertasEmpresaStack
// dentro del componente:
  const resolver = useResolverAlertaMutation();
  const [confirmarResolver, setConfirmarResolver] = useState(false);
  const items = Array.isArray(alerta.datos?.items) ? alerta.datos.items : [];
  const puedeResolver = !esEvento; // los eventos SIFEN se cierran con Reconsultar / Liberar
```

Reemplazar el bloque de descripción (`{!compacto && (<Typography ...>{alerta.descripcion}</Typography>)}`) por:

```jsx
        {!compacto && items.length === 0 && (
          <Typography variant="caption" color="text.secondary" component="p" sx={{ mt: 0.5 }}>
            {alerta.descripcion}
          </Typography>
        )}
        {!compacto && items.length > 0 && (
          <Box component="ul" sx={{ m: 0, mt: 0.5, pl: 2, display: "grid", gap: 0.25 }}>
            {items.map((it) => (
              <Typography key={it.id} component="li" variant="caption" color="text.secondary">
                {it.nombre}
                {it.detalle ? ` — ${it.detalle}` : ""}
              </Typography>
            ))}
            {alerta.datos?.cantidad > items.length && (
              <Typography component="li" variant="caption" color="text.disabled" sx={{ listStyle: "none", ml: -2 }}>
                … y {alerta.datos.cantidad - items.length} más
              </Typography>
            )}
          </Box>
        )}
```

Agregar en la fila de botones, después de "Marcar leída":

```jsx
          {puedeResolver && !confirmarResolver && (
            <Button size="small" color="inherit" onClick={() => setConfirmarResolver(true)} startIcon={<Icon icon="mdi:check-circle-outline" />}>
              Resolver
            </Button>
          )}
          {puedeResolver && confirmarResolver && (
            <Box sx={{ display: "flex", alignItems: "center", gap: 0.5, flexWrap: "wrap" }}>
              <Typography variant="caption" color="text.secondary">
                Se vuelve a generar si la situación persiste.
              </Typography>
              <Button size="small" color="success" variant="contained" onClick={() => resolver.mutate(alerta.id)} disabled={resolver.isPending}>
                Confirmar
              </Button>
              <Button size="small" color="inherit" onClick={() => setConfirmarResolver(false)}>
                Cancelar
              </Button>
            </Box>
          )}
```

Y en `propTypes.alerta` agregar `datos: PropTypes.object` si no está (ya está) — sin cambio.

- [ ] **Step 4: Verificar** — `npx eslint src/api/alertas-empresa.service.js src/tanstack/AlertasEmpresaStack.jsx src/components/_standards/AlertaEmpresaItem.jsx` limpio; `curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/src/components/_standards/AlertaEmpresaItem.jsx` → 200.

---

### Task 8: Frontend — `AlertasPanel` y badge del Dashboard IA sobre la bandeja

**Files:**
- Modify: `src/components/organismos/AIDashboard/AlertasPanel.jsx`
- Modify: `src/components/organismos/AIDashboard/AIDashboardTemplate.jsx:491-503`

**Interfaces:**
- Consumes: `useAlertasEmpresaQuery` (`src/tanstack/AlertasEmpresaStack.jsx`), `AlertaEmpresaItem` (`src/components/_standards`), `CRITICIDAD_ALERTA` (`_standards/enums`).

- [ ] **Step 1: Reescribir `AlertasPanel.jsx`** conservando `Wrap`, `SkeletonItem` y `Vacio` (styled-components ya definidos en el archivo) y borrando `Card`, `Top`, `Titulo`, `Desc`, `CriticidadBadge`, `Accion`, `Actions`, `Btn`, `CRITICIDAD_ICON` y los imports de `useMutation/useQueryClient` y de `ai-dashboard.service`:

```jsx
import { Icon } from "@iconify/react";
import { Box } from "@mui/material";
import styled, { keyframes } from "styled-components";
import { AlertaEmpresaItem, CRITICIDAD_ALERTA } from "../../../components/_standards";
import { useAlertasEmpresaQuery } from "../../../tanstack/AlertasEmpresaStack";

// ... Wrap, SkeletonItem, Vacio como están ...

/**
 * Pestaña "Alertas" del Dashboard IA: la misma bandeja que la campana y el
 * Inicio (docs/superpowers/specs/2026-09-15-alertas-fase2-design.md).
 */
export const AlertasPanel = () => {
  const { data: alertas = [], isLoading } = useAlertasEmpresaQuery();

  if (isLoading) return (
    <Wrap>
      {[1, 2, 3].map((i) => <SkeletonItem key={i} />)}
    </Wrap>
  );

  const altas = alertas.filter((a) => a.criticidad === CRITICIDAD_ALERTA.ALTA.value);
  const otras = alertas.filter((a) => a.criticidad !== CRITICIDAD_ALERTA.ALTA.value);

  if (alertas.length === 0) return (
    <Vacio>
      <Icon icon="mdi:shield-check" style={{ fontSize: 48, marginBottom: 10, color: "#22c55e" }} />
      <div style={{ fontWeight: 600, marginBottom: 4 }}>Sin alertas activas</div>
      <div style={{ fontSize: 13 }}>Todo parece estar en orden. El sistema revisa las reglas cada hora.</div>
    </Vacio>
  );

  return (
    <Wrap>
      {altas.length > 0 && (
        <div style={{ fontSize: 11, fontWeight: 700, color: "#dc2626", textTransform: "uppercase", letterSpacing: 0.5, display: "flex", alignItems: "center", gap: 4 }}>
          <Icon icon="mdi:alert-circle" /> {altas.length} alerta{altas.length > 1 ? "s" : ""} crítica{altas.length > 1 ? "s" : ""}
        </div>
      )}
      <Box sx={{ display: "grid", gap: 1 }}>
        {[...altas, ...otras].map((a) => (
          <AlertaEmpresaItem key={a.id} alerta={a} />
        ))}
      </Box>
    </Wrap>
  );
};
```

Verificar que `CRITICIDAD_ALERTA` se exporta desde el barrel `_standards` (está en `enums/estados.js` y el barrel re-exporta `./enums`); si no, importar desde `"../../../components/_standards/enums"`.

- [ ] **Step 2: Badge en `AIDashboardTemplate.jsx`** — reemplazar la query `["ai-alertas"]` (líneas 491–495) por:

```jsx
  const { data: alertas = [] } = useAlertasEmpresaQuery();
```

con `import { useAlertasEmpresaQuery } from "../../../tanstack/AlertasEmpresaStack";`, quitar `getAlertas` del import de `ai-dashboard.service` (dejar `getAiConfig`), y dejar `alertasAltas` como `alertas.filter((a) => a.criticidad === "alta").length` → cambiar a `CRITICIDAD_ALERTA.ALTA.value` importando el enum desde `_standards`. Si `useQuery` queda sin otro uso en el archivo, quitar el import.

- [ ] **Step 3: Verificar** — `npx eslint src/components/organismos/AIDashboard/AlertasPanel.jsx src/components/organismos/AIDashboard/AIDashboardTemplate.jsx` sin errores nuevos; `grep -rn "ai-alertas\|getAlertas\b" src` → sin resultados; ambos archivos responden 200 en el dev server.

---

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

- [ ] **Backend**: `rtk proxy npx jest src/alertas-empresa src/ai-dashboard src/middleware-sifen src/nota-remision` PASS; `tsc` limpio; migración aplicada (`SELECT count(*) FROM ai_alertas WHERE resuelta = false AND clave IS NULL` = 0).
- [ ] **Recálculo real** para la empresa de `taller` (`POST /v1/ai-dashboard/recalcular` o botón del Dashboard): aparece `stock_bajo` (alta, ~3.752 productos) y `mora_critica` (1 cliente). Segunda corrida: sin duplicados.
- [ ] **Playwright con `taller`** (reutilizar `ui-alertas.cjs` de Fase 1 como base; ocultar `.tsqd-parent-container` y `.driver-overlay`, guardar `storageState` por el límite de 5 logins/min):
  - Dashboard IA → pestaña Alertas muestra las mismas alertas que la campana; badge de la pestaña = cantidad de altas.
  - "Ir al documento" en `stock_bajo` navega a `/reportes/inventario/stock`.
  - "Marcar leída" en el Dashboard la deja leída también en la campana (misma query).
  - "Resolver" → Confirmar: desaparece de Dashboard, campana e Inicio; `SELECT resolucion, resuelta_por FROM ai_alertas WHERE clave = 'stock_bajo' AND empresa_id = …` → `manual`, id de taller. Recalcular de nuevo → vuelve a aparecer (nueva fila).
  - Usuario `agogo`: ve `stock_bajo` sólo si tiene `INV_STK_STOCK_VER` (comprobar con `perfiles_privilegios`); documentar el resultado.
- [ ] **Docs**: actualizar `docs/plan-alertas-empresa.md` (sección Fases: "Fase 2 implementada", con fecha) y el spec de Fase 2 (Estado: implementada + resultados de la verificación, como se hizo en Fase 1).
