# CRM 360 — CLAUDE.md

> **Estado**: ❌ Fase 0 pendiente (módulo nuevo)
> **Spec funcional**: `docs/spec-crm-360.md` · **Plan de tickets**: `docs/plan-crm-360.md`
> **Backend**: `src/crm/` (prefijo de tablas `crm_`) · **Frontend**: repo `novasispy-erp`, rama `crm`
> **Rama**: `crm` (backend desde `pos`, frontend desde `main`)

## Qué es

Add-on de CRM de ventas y atención para las PyMEs que usan NOVASIS. Capta leads (WhatsApp,
email, IG/FB, web, Shop, TURNE), gestiona oportunidades en pipeline, atiende con tickets y SLA,
y al ganar una venta genera los datos de facturación **única, en cuotas o recurrente** usando
los servicios existentes del ERP.

Niveles: `CRM_ESENCIAL` (ventas + bandeja WhatsApp de 1 número) y `CRM_PRO`
(= `CRM_OMNICANAL` + `CRM_ATENCION` + `CRM_RECURRENTE` + `CRM_IA`).

---

## ⚠️ Correcciones verificadas contra el código

**La spec y el plan fueron escritos sin leer el código.** Estos nombres **no existen**. Cada
fila está verificada contra la rama `crm` (backend `b6ca79c`). Usá siempre la columna derecha.

| La spec / el plan dice | Realidad en el código | Verificado en |
|---|---|---|
| `ListaPreciosService.getPrecio()` | `obtenerPrecioProducto({...})` y `obtenerPreciosProductosBatch({...})` | `src/lista-precios/lista-precios.service.ts:39,294` |
| `INTEGRACION_IA`, `src/ia/*` | `ai_empresa_config` (`proveedor`, `api_key_encrypted`) y `ayuda_ia_config`. Módulos: `src/ai-usage`, `src/ayuda-ia`, `src/ai-dashboard` | `prisma/schema.prisma` |
| `suscripciones.limites_personalizados` | `suscripciones.limite_override` (Json), + `override_expira_at`, `override_nota` | `prisma/schema.prisma` |
| `PedidosService.convertirAFactura()` | **No existe.** Es exactamente el prerrequisito PRE-01 | `src/pedidos/pedidos.service.ts` |
| tabla `ordenes_venta` | No existe. Las órdenes de venta son `pedidos` con `estado_orden`, `tipo_facturacion`, `stock_reservado`, `origen_tipo` | `prisma/schema.prisma` |
| `ALTER TABLE presupuestos` | **`presupuesto_cab`** | `prisma/schema.prisma` |
| `ALTER TABLE nota_credito` | **`nota_credito_cab`** | `prisma/schema.prisma` |
| `CONFIG_NUMERACION` | `numeraciones_documento` (`punto_expedicion_id`, `timbrado_id`, `numero_actual`, `es_default`) | `prisma/schema.prisma` |
| `navigationConfig.ts` (frontend) | `src/components/organismos/sidebar/Sidebar.jsx` + `src/hooks/useMenuAcceso.js` + `src/routers/routes.jsx` | repo `novasispy-erp` |
| `src/stores/crmStore.js` (frontend) | El patrón es `src/store/*.jsx` (Zustand), p. ej. `AuthStore.jsx` | repo `novasispy-erp` |
| `@GetEmpresa()` | **No existe.** Es `@GetUser() user: LoginUserInfo` → `user.empresa_id` (1.285 usos) | `src/auth/get-user.decorator.ts` |
| Permisos en `seed.service.ts` | `src/seguridad/seeds/seguridad.seed-data.ts`, auto-aplicado en `onApplicationBootstrap` | `seguridad-seed.bootstrap.ts` |
| `gen_random_uuid()` en migraciones | `uuid_generate_v4()` | migraciones 2026-09 |
| Crear un `CrmModuloGuard` | Ya existen `ModuleGuard`, `SubmoduloGuard` y `PermissionGuard`; este último **ya valida la suscripción** | `src/auth/guards/` |

**Sí existen y se usan tal cual**: `FacturasService`, `PedidosService`, `ClientesService`,
`MonedasService.getUltimaCotizacion()`, `verificarCredito()`, `planes_cuotas`, `config_mora`,
`autorizaciones_caja`, `vendedores_cobradores`, `plan_modulos`, `suscripcion_modulos`,
`ContabilidadIntegracionService`, `AuditInterceptor`, `empresas_correo`, Bancard, PosGateway.

### Dependencias a instalar

- **Drag & drop** para el Kanban (CRM-17): **no hay ninguna librería**. Elegir e instalar.
- `nock` para los tests de los proveedores de WhatsApp (CRM-22).
- Cliente IMAP para CRM-25 (ya está `nodemailer` para el envío).
- Ya instalado y en uso: `bullmq`, `@nestjs/bullmq`, `@nestjs/schedule`, `@nestjs/websockets`,
  `socket.io`, `ioredis`, `nodemailer`, `pgvector` (migración `20260526_ayuda_ia_init`).
  Frontend: `zustand`, `dexie`, `socket.io-client`, `recharts`, MUI 7.

---

## Decisión de arquitectura: SIN bus de eventos

La spec (§6.11) y el plan asumen `@nestjs/event-emitter` con 11 eventos de dominio.
**No se va a usar.** Razones verificadas:

- `@nestjs/event-emitter` **no está instalado** y hay **cero** usos de `EventEmitter2` o
  `@OnEvent` en los 100+ módulos del backend. Todo el ERP resuelve por llamada directa.
- Ya existe el mecanismo de desacople que el bus daría: **5 colas BullMQ en producción**
  (`email`, `sifen`, `sifen-nc`, `sifen-sync`, `middleware-empresa`).
- Introducir un tercer patrón en un equipo de 2 devs genera una discusión por PR sobre cuál usar.

**La regla:**

| Situación | Patrón |
|---|---|
| Tiene que pasar ahora y el resultado depende de ello | Llamada directa al servicio |
| Puede pasar después y no debe frenar al usuario | Job en una cola BullMQ |

Consecuencias concretas:

- **CRM-03** no instala el event emitter. Queda numeración + E.164 + colas.
- **CRM-14** (cierre) llama directo a lo que necesite. En F1 son dos cosas.
- **`presupuesto.aceptado`**: en vez de refactorizar `presupuestos.service.ts` a eventos, el
  CRM expone un método y `presupuestos` lo llama. Una línea agregada a código de producción,
  en lugar de cambiarle el patrón a un servicio que ya funciona.
- **CRM-31** (automatizaciones, F2): acá sí va una cola `crm-automatizaciones`. Para entonces
  se sabrá qué disparadores hacen falta de verdad, en vez de adivinar 11 ahora.

Si más adelante se decide meter el bus, que sea **solo** para eventos `crm.*` entre módulos
del CRM, nunca para hablar con el resto del ERP.

---

## Decisiones de diseño (no reabrir sin consultar)

| # | Decisión |
|---|---|
| 1 | El CRM **no duplica** clientes, productos, precios ni documentos. Referencia por FK |
| 2 | Facturar = construir `PayloadFacturacion` (`cierre/payload-facturacion.ts`) y delegar en `FacturasService` / `PedidosService`. **Nunca** XML SIFEN, stock ni asientos directos |
| 3 | WhatsApp vía `IWhatsAppProvider`: `meta_cloud` (default producción), `evolution_cloud`, `evolution_baileys` (solo demo). Twilio solo SMS |
| 4 | Con `evolution_baileys`, todo envío **automático** lanza `BaileysAutomaticoBloqueadoError` |
| 5 | Facturas recurrentes: el job solo crea ciclos `pendiente_aprobacion`. **Solo** el endpoint de aprobación emite. Aprueba el **Supervisor** (`CRM_CONTRATOS_APROBAR`), fallback Administrador |
| 6 | Fecha de emisión recurrente = fecha de aprobación. USD → cotización del día de aprobación |
| 7 | El cobro (link de Novasis Pay) se dispara solo después de DE aprobado por SIFEN |
| 8 | IA siempre con API key del cliente (BYOK, `ai_empresa_config`). Agente con herramientas read-only |
| 9 | Período de prueba del add-on: días configurables, override en `suscripciones.limite_override`. Vencido → solo lectura 30 días → bloqueado |
| 10 | "Contrato recurrente" ≠ `suscripciones` (esa tabla es la facturación SaaS de NOVASIS) |
| 11 | Ganar una oportunidad solo vía asistente de cierre (`PATCH /etapa` a etapa ganada → 409) |
| 12 | **Sin bus de eventos** (ver sección anterior) |

---

## Estado por fase

| Fase | Alcance | Estado |
|---|---|---|
| PRE | Facturar desde Orden de Venta, control de crédito, Cobrar Pedido en POS, Meta verificado, Bancard real, incidencias DOBASA | ❌ |
| F0 | Config, módulos, permisos, guard, prueba, numeración, E.164, seed, menú y pantalla | **✅ completa** (CRM-01..05) |
| F1 | Leads, oportunidades, actividades, cierre → factura única/cuotas, timeline 360, fechas especiales, reportes básicos | **✅ completa** (CRM-10..21 + ABM de catálogos) |
| F2 | WhatsApp/Email, bandeja, plantillas, tickets/SLA, NC desde ticket, CSAT, automatizaciones | ❌ |
| F3 | Contratos, ciclos, aprobación, débito, dunning, MRR, portal OTP, comisiones, PWA | ❌ |
| F4 | IA (copiloto, agente, scoring, churn), IG/FB, web chat, KB, secuencias, calendario | ❌ |

---

## Tablas (prefijo `crm_`)

Definición completa de columnas en `docs/spec-crm-360.md` §8.1 y §8.2.

| Tabla | Fase | Notas |
|---|---|---|
| `crm_pipelines`, `crm_etapas` | F0 | etapa: `orden`, `probabilidad`, `campos_obligatorios` JSONB, `dias_estancamiento`, `es_ganada`, `es_perdida` |
| `crm_origenes`, `crm_motivos` | F0 | `motivo.tipo`: `perdida \| descarte \| churn` |
| `crm_parametros` | F0 | 1 por empresa: `rec_dias_anticipacion`(3), `rec_dia_emision`(1), `rec_dias_gracia`(10), `envios_habilitados` |
| `crm_sla_politicas`, `crm_calendarios` | F0 | |
| `crm_numeracion` | F0 | OPP- / CTR- / TCK- por empresa |
| `crm_leads` | F1 | único parcial `(empresa_id, telefono_e164)` si no descartado |
| `crm_oportunidades`, `crm_oportunidad_items` | F1 | `item.tipo_cobro`: `unico \| recurrente` |
| `crm_actividades` | F1 | `geo_lat`/`geo_lng` para visitas |
| `crm_fechas_especiales` | F1 | cumpleaños/aniversario/comercial |
| `crm_canales` | F2 | `proveedor`: `meta_cloud \| evolution_cloud \| evolution_baileys`; credenciales AES |
| `crm_conversaciones`, `crm_mensajes`, `crm_plantillas`, `crm_tarifas_whatsapp` | F2 | `ventana_24h_hasta` |
| `crm_tickets`, `crm_ticket_eventos`, `crm_encuestas` | F2 | |
| `crm_automatizaciones`, `crm_automatizacion_log` | F2 | |
| `crm_contratos`, `crm_contrato_lineas`, `crm_contrato_consumos` | F3 | |
| `crm_contrato_ciclos` | F3 | **`UNIQUE(contrato_id, periodo_desde)`**; estado: `pendiente_aprobacion \| aprobado \| omitido \| emitido \| rechazado_sifen \| cobrado \| cobro_fallido` |
| `crm_tokens_cobro` | F3 | solo token de pasarela, nunca PAN |
| `crm_kb_articulos` | F4 | embedding pgvector |

**Columnas a agregar a tablas existentes** (nombres corregidos — ver tabla de correcciones):

```sql
ALTER TABLE factura_cab      ADD COLUMN IF NOT EXISTS oportunidad_id UUID,
                             ADD COLUMN IF NOT EXISTS contrato_id UUID,
                             ADD COLUMN IF NOT EXISTS contrato_ciclo_id UUID;
ALTER TABLE presupuesto_cab  ADD COLUMN IF NOT EXISTS oportunidad_id UUID;
ALTER TABLE pedidos          ADD COLUMN IF NOT EXISTS oportunidad_id UUID;
ALTER TABLE clientes         ADD COLUMN IF NOT EXISTS lead_origen_id UUID,
                             ADD COLUMN IF NOT EXISTS telefono_e164 VARCHAR(20),
                             ADD COLUMN IF NOT EXISTS etiquetas TEXT[],
                             ADD COLUMN IF NOT EXISTS segmento VARCHAR(50);
ALTER TABLE nota_credito_cab ADD COLUMN IF NOT EXISTS ticket_id UUID;
```

Más `suscripcion_modulos.fecha_fin_prueba` y `planes.crm_dias_prueba`.

---

## Permisos — modelo de 3 niveles

El repo **no** usa una lista plana de permisos. Usa `modulos` → `submodulos` → `privilegios`,
y el gating comercial vive en tablas aparte. La spec §10 está escrita como lista plana: **no la
sigas**, seguí esto.

```
Módulo        CRM                        ← lo que gatea la suscripción
 └ Submódulo  CRM_CONFIG                 ← capacidad; puede ser addon cobrable
    └ Privilegio CRM_CFG_PIPELINE_CREAR  (recurso: PIPELINE, acción: CREAR)
```

**Los niveles comerciales NO son módulos.** Esencial y Pro son filas en `plan_submodulos`;
`CRM_IA` es un submódulo con `es_addon = true`. Y lo que la spec llama "Esencial = 1 número"
son **límites** (`plan-limits` + `suscripciones.limite_override`), no gating de submódulo.

Submódulos previstos, uno por módulo funcional de la spec. Cada ticket agrega el suyo al seed
junto con sus privilegios; hoy sólo existe `CRM_CONFIG`.

| Submódulo | Spec | Ticket que lo crea | Esencial | Pro |
|---|---|---|---|---|
| `CRM_CONFIG` ✅ | M90 | CRM-01 | ✓ | ✓ |
| `CRM_LEADS` ✅ | M91 | CRM-10 | ✓ | ✓ |
| `CRM_CLIENTES_360` ✅ | M92 | CRM-16 | ✓ | ✓ |
| `CRM_OPORTUNIDADES` ✅ | M93 | CRM-12 | ✓ | ✓ |
| `CRM_ACTIVIDADES` ✅ | M94 | CRM-13 | ✓ | ✓ |
| `CRM_CIERRE` | M98 | CRM-14 | ✓ | ✓ |
| `CRM_BANDEJA` | M95 | CRM-23 | ✓ | ✓ |
| `CRM_AUTOMATIZACIONES` | M100 | CRM-31 | ✓ | ✓ |
| `CRM_REPORTES` | M102 | CRM-19 | ✓ | ✓ |
| `CRM_TICKETS` | M96 | CRM-28 | — | ✓ |
| `CRM_PORTAL` | M97 | CRM-40 | — | ✓ |
| `CRM_CONTRATOS` | M99 | CRM-33 | — | ✓ |
| `CRM_IA` (`es_addon`) | M101 | CRM-44 | — | addon |

Convención de privilegio: `CRM_<SUB3>_<RECURSO>_<ACCION>`, con acciones del vocabulario que ya
existe (`VER`, `CREAR`, `EDITAR`, `ELIMINAR`, `PROCESAR`, `APROBAR`, `EXPORTAR`, `IMPORTAR`,
`CONVERTIR`, `ASIGNAR`, `CANCELAR`, `VER_AJENAS`). No inventes acciones nuevas.

`CRM_OPORTUNIDADES_TODAS` de la spec se escribe `CRM_OPO_OPORTUNIDAD_VER_AJENAS`.

En el controller: `@UseGuards(AuthGuard('jwt'), SubmoduloGuard)` + `@RequireSubmodulo('<SUB>')`
a nivel de clase, y `@UseGuards(PermissionGuard)` + `@RequirePermission('CRM', '<PRIVILEGIO>')`
por endpoint.

## Infraestructura (CRM-03)

- **`NumeracionCrmService.siguiente(empresaId, tipo, tx?)`** → `OPP-0000001`, `CTR-…`, `TCK-…`.
  Nada que ver con `numeraciones_documento`, que es de SIFEN y lleva timbrado; esto es un
  código interno legible. **Pedilo siempre dentro de la transacción que crea la entidad**
  pasando `tx`: si la creación falla, el número vuelve al pool en vez de dejar un hueco.
  Usa `SELECT … FOR UPDATE`, igual que presupuestos, porque sin eso dos vendedores creando
  una oportunidad a la vez se llevan el mismo número.
- **`aE164()`** en `common/telefono.util.ts`: base de la deduplicación de leads. El mismo
  cliente escribe su número de cinco formas según por dónde entre. Devuelve `null` cuando no
  puede interpretarlo, a propósito: inventar un prefijo crearía un choque en el índice único
  de `crm_leads` contra el teléfono de otro cliente real.

### Lo que CRM-03 NO hizo, y por qué

El plan pedía también registrar las colas `crm-automatizaciones`, `crm-recurrente` y
`crm-mensajeria`, y un lock Redis por empresa. Las dos cosas quedan para el ticket que las
necesite de verdad, por el mismo criterio que con el bus de eventos:

- **Colas sin consumidor** acumulan jobs que nadie procesa. Cada una entra con su processor:
  `crm-mensajeria` en CRM-23, `crm-automatizaciones` en CRM-31, `crm-recurrente` en CRM-34.
  El patrón del repo es registrarlas en `src/queues/queues.module.ts`, junto a las otras cinco.
- **El lock Redis** protegería contra doble ejecución con varias instancias PM2. Hoy prod
  corre en modo `fork`, una sola instancia. Y el job que realmente podría duplicar algo
  —`crm.facturacion-recurrente`— ya está protegido por `UNIQUE(contrato_id, periodo_desde)`,
  que es una garantía de la base y no depende de que Redis esté vivo. El cron de avisos de
  CRM-02 ni siquiera lo necesita: `AlertasEmpresaService.registrar` deduplica por clave, así
  que una corrida doble no produce nada. Entra en CRM-34 si ahí se decide escalar a `cluster`.

## Configuración por defecto (CRM-04)

`CrmSeedService.asegurarConfiguracion(empresaId)` deja la empresa lista para usar: el pipeline
"Ventas" con las 6 etapas de la spec §5.3, 11 orígenes, 13 motivos, el calendario comercial
(L–V 08–18, Sáb 08–12), las 3 políticas de SLA y la fila de `crm_parametros`.

**Se dispara solo** la primera vez que alguien abre el CRM: `findAllPipelines` siembra si la
empresa no tiene ningún pipeline. Elegido así en vez de un barrido al arrancar el backend
porque no siembra empresas que no usan el CRM y funciona igual para las que lo contraten
mañana, sin cron ni paso manual post-deploy.

Es **idempotente**: todos los `createMany` van con `skipDuplicates` contra los índices únicos,
así que agrega lo que falta y nunca pisa lo que el cliente cambió. El endpoint
`POST /v1/crm/config/seed` (`CRM_CFG_CONFIG_SEMBRAR`) lo vuelve a correr — sirve para
re-sembrar cuando una versión agregue catálogos nuevos, mismo criterio que `seedMapeoFaltantes`
de contabilidad.

Los valores viven en `seeds/crm-config-default.seed.ts` y hay tests que los comparan contra la
spec: si alguien cambia una probabilidad o un minuto de SLA, el test lo marca.

## Pasarela de cobro: Novasis Pay, no Bancard

La spec habla de Bancard en todo F3. **Se usa Novasis Pay**, que es propio y ya está en
producción (`src/novasis-pay`, pm2 `novasispay-gateway` en test y prod).

Lo que verifiqué que ofrece hoy:

| | |
|---|---|
| `modo: 'link'` | Checkout hospedado; se comparte `hosted_url`. **Es lo que sirve para el ciclo recurrente** |
| `modo: 'directo'` | QR, requiere `platform_id` |
| `external_reference` | Idempotencia y enganche con el cobro contable. Único por `(empresa, referencia)` |
| Webhooks | `novasis-pay-webhooks`, con `novasis_pay_webhook_event_in` para no reprocesar |
| `novasis_pay_customer_ref` | Mapea el cliente local a un `cus_…` del gateway, scopeado por merchant |

**Lo que NO hay: tokenización de tarjeta ni cobro recurrente sin el cliente presente.** No
existe un método "cobrar contra este customer". El `customer_ref` es la pieza sobre la que se
construiría, pero el cobro guardado no está implementado.

Consecuencia para CRM-36: el ciclo recurrente aprobado genera un **link de pago** que se manda
por WhatsApp o email, y la conciliación llega por webhook. Es exactamente el "Plan B" que la
propia spec propone en §14.1. El débito automático de verdad queda pendiente de que Novasis Pay
agregue el cobro contra un medio guardado — pregunta para quien lo mantiene, no trabajo del CRM.

Lo bueno: F3 **deja de depender de una gestión con Bancard**. Contratos, ciclos, aprobación,
dunning y MRR se pueden construir enteros; lo único condicionado es cómo se captura la plata.

## Período de prueba de add-ons

Implementado en CRM-02, pero **el mecanismo no es del CRM**: las columnas viven en
`submodulos.dias_prueba` y `suscripcion_submodulos.fecha_fin_prueba`, no en tablas `crm_`.
Cualquier add-on futuro lo usa sin migrar nada. Cuando un segundo add-on lo consuma, mover
`CrmPruebaService` a `src/suscripciones` y dejar un re-export.

**El estado no se persiste: se deriva** de `fecha_fin_prueba` contra hoy
(`src/common/prueba-submodulo.ts`). Guardarlo obligaría a un job que mueva estados y que, el
día que no corra, deja empresas en un estado que no es el real.

```
|<------- prueba ------->|<--- solo_lectura (30 d) --->|  bloqueado
                         ^ fecha_fin_prueba
```

`fecha_fin_prueba IS NULL` → `activo`, que es el caso de **todas** las empresas hoy. Hasta que
alguien arranque una prueba, el comportamiento del ERP no cambia para nadie.

El corte lo aplica `SubmoduloGuard`, no un guard nuevo: en `solo_lectura` pasan sólo
GET/HEAD/OPTIONS, en `bloqueado` no pasa nada. `GET /v1/crm/prueba/estado` **no** lleva
`@RequireSubmodulo` a propósito: tiene que seguir respondiendo con la prueba vencida para que
el panel muestre el cartel de contratación en vez de un 403 mudo.

Diferencias con la spec §12, deliberadas:

| Spec | Implementado | Por qué |
|---|---|---|
| `planes.crm_dias_prueba` | `submodulos.dias_prueba` | El submódulo es el nivel al que el repo ya gatea y cobra addons |
| Columnas en `suscripcion_modulos` | En `suscripcion_submodulos` | Mismo motivo |
| `limites_personalizados` | `suscripciones.limite_override.dias_prueba[<CODIGO>]` | Es la columna que existe |
| Job que transiciona estados | Job que **sólo avisa** | El estado se deriva; no hay nada que transicionar |

## Jobs

Todos con lock Redis por empresa (`SET NX`), porque prod corre varias instancias PM2.

| Job | Frecuencia |
|---|---|
| `crm.prueba-avisos` | diario |
| `crm.estancadas` | diario |
| `crm.fechas-especiales` | diario 08:00 |
| `crm.sla` | cada 5 min |
| `crm.facturacion-recurrente` (solo borradores) | diario 05:00 |
| `crm.recordatorio-aprobacion` | diario 08:00 |
| `crm.cobro-debito` | diario 07:00 |
| `crm.dunning` | diario 09:00 |
| `crm.renovaciones` | diario |
| `crm.metricas` | diario 02:00 |
| `crm.ia-scoring` | nocturno |

---

## Asistente de cierre (CRM-14)

`src/crm/cierre/` — convierte una oportunidad abierta en Orden de Venta y, si corresponde,
en factura SIFEN. Dos endpoints: `POST /crm/oportunidades/:id/cierre/preview` (no persiste
nada) y `POST /crm/oportunidades/:id/cierre`.

**No existe transacción única y no la busques.** `FacturasService.create()` abre la suya y al
volver la factura ya está encolada a SIFEN: no hay rollback posible. El contrato real es
*validar todo antes de emitir*. Si agregás una validación nueva, va en `preview()` —
`ejecutar()` la reusa entera antes de tocar un documento.

Orden de los pasos, que importa:

1. `preview()`: oportunidad abierta → ítems → cliente fiscal + DV → crédito → payload.
2. Orden de Venta vía `PedidosService.crearOrden` + `confirmarOrden`. **Siempre**, incluso
   cuando el destino es factura directa: así la emisión tiene un solo camino (PRE-01).
3. Factura vía `OrdenFacturacionService.convertirAFactura()` si `destino === 'factura'`.
4. Recién ahí la oportunidad pasa a `ganada`. Al revés quedaría una oportunidad ganada sin
   nada detrás — lo mismo que el 409 de `moverEtapa` intenta impedir.

`destino` no lo elige una pantalla: es `orden_venta` si `requiere_entrega` o `anticipo > 0`,
y `factura` en el resto de los casos.

Respuestas de error que el frontend debe saber leer:

| Situación | Status | Cuerpo |
|---|---|---|
| Ítems recurrentes (F1 no los soporta) | 422 | `{requiere:'CRM_RECURRENTE'}` |
| Crédito excedido sin PIN | 400 | `{requiere:'override_supervisor'}` |
| DV del RUC no cierra | 400 | mensaje |
| Oportunidad ya ganada o perdida | 409 | mensaje |

`ruc.util.ts` calcula el DV por módulo 11 — verificado contra RUCs reales (DOBA 80144315-6,
MISTERHOME 80159925-3, BLUE DRAGON 80100092-0, Kety 2156747-6). No lo reimplementes en otro
lado.

**El override de crédito se valida de verdad** (`override-credito.service.ts`): bcrypt contra
`usuario.pin_supervisor`, igual que el POS, más el privilegio `CRM_CIE_CREDITO_AUTORIZAR`.

Tres cosas que no hay que "simplificar":

1. **Exige `supervisor_id` junto al PIN.** Sin eso habría que probar el PIN contra todos los
   usuarios de la empresa: lento y una invitación a adivinar PINs de cuatro dígitos.
2. **El PIN correcto no alcanza** sin el privilegio. Son dos controles distintos.
3. **La fila de `autorizaciones_caja` se crea recién con el documento emitido.** Al revés
   quedarían overrides aprobados de ventas que nunca existieron.

---

## Presupuesto desde la oportunidad (CRM-15)

Dos piezas que conviene no confundir:

- `src/crm/presupuesto/` — `POST /crm/oportunidades/:id/presupuesto`. Traduce los ítems al DTO
  de M36 y deja que `PresupuestosService` numere, calcule y versione. Lo propio del CRM es sólo
  el vínculo en las dos direcciones: `presupuesto_cab.oportunidad_id` ↔
  `crm_oportunidades.presupuesto_id`.
- `src/crm/sync/` — lo que **presupuestos llama al CRM** cuando cambia de estado (D13: sin bus
  de eventos). Vive en su propio módulo sin dependencias; si estuviera en `CrmModule` —que
  importa `PresupuestosModule`— habría un ciclo.

**Las etapas se resuelven por semántica, no por nombre**, porque el cliente las renombra:

| Rol | Cómo se encuentra |
|---|---|
| Propuesta enviada | la etapa abierta que exige `presupuesto_id` en `campos_obligatorios` |
| Negociación | la última etapa abierta por `orden` |

Qué hace cada transición:

| Estado | Efecto |
|---|---|
| SENT | vincula el presupuesto como vigente y avanza a la etapa de propuesta |
| ACCEPTED | avanza a la última etapa abierta + tarea "ejecutar el cierre" |
| REJECTED | **no** cambia etapa ni marca perdida; tarea con el motivo del cliente |

Tres invariantes que no se negocian:

1. **Sólo avanza.** Nunca devuelve una oportunidad a una etapa anterior.
2. **Aceptar no gana.** Ganar emite documentos: eso es del asistente de cierre y de nadie más.
3. **No propaga errores.** El cliente que acepta en el portal no puede comerse un 500 porque la
   etapa del CRM falló. Se loguea y listo.

Si al destino le faltan `campos_obligatorios`, **no fuerza el movimiento**: crea una tarea con
la lista de lo que falta. Forzar vaciaría de sentido la regla que sostiene el embudo.

Al mapear ítems a M36, un ítem sin `producto_id` va como **SERVICIO**. `TEXTO_LIBRE` no suma al
total (`presupuestos.calc.ts`), así que un "Flete" mal tipado se cotizaría en cero.

---

## Importación de leads (CRM-11)

`src/crm/leads/importacion/`. Dos pasos **sin estado intermedio**: el preview devuelve el mapeo
sugerido y el diagnóstico por fila, la importación recibe el mismo archivo con el mapeo
confirmado. Tope 5.000 filas; por encima se pide partir el archivo.

No copies el patrón del importador de gastos (tabla de jobs + cola): ese procesa libros de
compras de decenas de miles de filas. Acá el parseo tarda menos que la subida.

**El dedupe no se reimplementa**: cada fila pasa por `LeadsService.create`. Eso también detecta
los duplicados dentro del archivo, porque las filas van en orden y la anterior ya está en la
base.

Dos cosas que es fácil romper:

- Un ítem sin `producto_id` va a M36 como `SERVICIO`. En la importación el equivalente es que
  **una fila que falla no cancela las demás**: si abortara todo, importar 500 contactos reales
  sería imposible.
- `aTexto()` devuelve `''` para objetos. xlsx entrega celdas con error como objetos y
  `String(obj)` daría `"[object Object]"` como nombre del lead.

---

## Fechas especiales (CRM-20)

Dos tablas, no una: `crm_fechas_especiales` cuelga de un cliente o un lead (CHECK lo obliga),
`crm_fechas_comerciales` es el calendario de la empresa. El Día de la Madre no es de un cliente;
mezclarlos obligaba a repetirlo una vez por cliente.

En Paraguay el Día de la Madre es el **15 de mayo**, no el segundo domingo. El seed ya lo trae.

Lo delicado está en `fechas-especiales.util.ts`:

| Regla | Por qué |
|---|---|
| El 29/02 se corre al 1/03 en años no bisiestos | saltearlo dejaba a esa persona sin saludo 3 de cada 4 años |
| La ventana se evalúa con `>=`, no con `===` días | si el job no corre un día, el aviso igual sale al siguiente |
| `ultimo_aviso_anio` se compara contra el año de la **ocurrencia** | un aviso de Año Nuevo cae en diciembre del año anterior |

La actividad se programa para el **día del evento**, no para hoy. Una fecha comercial genera
**una** actividad por empresa, sin cliente.

---

## Catálogos: orígenes, motivos, calendarios, SLA

`src/crm/catalogos/`. **Baja lógica siempre**: lo histórico apunta a estos catálogos y borrar un
motivo rompe el reporte de pérdidas del año pasado.

Un código dado de baja **se reactiva** en vez de fallar: dar de baja "FERIA" y volver a
necesitarlo el año siguiente es el caso real, y crear "FERIA_2" partiría los reportes en dos.
El `codigo` de un origen y el `tipo` de un motivo no se editan.

---

## Reportes (CRM-19)

`src/crm/reportes/`. Cinco reportes de Ventas; `GET /crm/reportes` los devuelve juntos.

Tres criterios de cálculo que cambian lo que el número significa — no los "simplifiques":

1. **Valor ponderado por probabilidad de etapa.** El total crudo del embudo no se parece a lo
   que se cobra.
2. **La conversión se mide sobre cerradas.** Las abiertas no fracasaron; contarlas hunde el %.
3. **Ciclo de venta con mediana además de promedio.** Una venta de ocho meses duplica el
   promedio de veinte ventas de una semana.

Respetan el alcance de CRM-12: sin `VER_AJENAS`, el embudo propio; sin ficha de vendedor, nada.

---

## Frontend de F1 y multiempresa

Repo `novasispy-erp`, rama `crm`. Siete pantallas bajo `/crm`: tablero, detalle de
oportunidad, leads, clientes + ficha 360, reportes, fechas y catálogos.

**El kanban no usa librería de drag & drop.** API nativa del navegador para el
mouse + menú "Mover a" en cada tarjeta para teclado y celular. No instales
`@dnd-kit` "para mejorarlo": las dos vías llaman al mismo handler y el menú es lo
que hace la pantalla accesible. Las columnas de resultado no aceptan drop.

**El wizard de cierre no calcula el resumen.** Sale de `/cierre/preview`, que es
el mismo código que emite. Si agregás una validación, va en `preview()` del
backend: `ejecutar()` la reusa entera antes de tocar un documento.

**La ficha 360 es pantalla, no pestaña**, y tiene su propio buscador en
`/crm/clientes`. La pantalla de clientes del ERP lee de Supabase
(`clientes_proveedores`) y sus ids **no** son los de la tabla `clientes`: un
enlace desde ahí abre la ficha de otro cliente. No lo "arregles" enlazando.

Cuando agregues una ruta con guard `modulo`/`submodulo`, replicala en
`src/utils/screenSubmoduloMap.js` o los breadcrumbs quedan vacíos.

Antes de cerrar un cambio del módulo:

```bash
pnpm run test:smoke:crm-f1          # frontend: 21 tests, las 7 pantallas
npx jest src/crm                    # backend: incluye crm-multitenancy.spec.ts
```

`crm-multitenancy.spec.ts` comprueba que el `empresa_id` llegue al `where` de
Prisma en cada servicio de F1. Es un test de forma: si agregás una consulta nueva
no la ve sola, así que sumá el servicio a ese archivo.

---

## Lo que encontró cargar datos de prueba (08/10/2026)

Cargar un set de demostración por la API —no por SQL— sacó a la luz cinco cosas que
ni los tests unitarios ni el deploy habían mostrado. Vale leerlas antes de tocar
estos caminos:

| Qué | Por qué no se veía |
|---|---|
| `convertir` devolvía 500 | la llamada a `ClientesService` estaba escrita con `as any` contra un DTO imaginado |
| faltaban los tres datos fiscales | `PersonasService` los pedía de a uno, con 409 desde el fondo |
| el superadmin no veía sus propias oportunidades | sin perfil no tiene `VER_AJENAS` y sin ficha de vendedor el filtro lo deja sin nada |
| se creaban clientes imposibles de facturar | nadie cruzaba el tipo de operación con la naturaleza hasta emitir |
| cada cierre fallido dejaba una orden huérfana | la orden se crea antes de facturar, y el reintento crea otra |

Dos lecciones que conviene no perder:

1. **`as any` en una llamada entre módulos esconde exactamente este tipo de error.**
   Si hay que castear, casteá al DTO real: `as CreateClienteDto` habría marcado los
   campos faltantes en tiempo de compilación.
2. **Un mock que no exige lo que exige el servicio real convierte un test verde en
   una afirmación falsa.** El test "crea el cliente vía ClientesService" pasaba
   mientras en producción daba 409.

El cierre ahora valida al cliente con `PersonasService.validatePersonaById` —el mismo
validador que usa `FacturasService`— **en el preview**, antes de crear la orden.

---

## Reglas críticas — NO hacer

1. No emitir facturas recurrentes sin `aprobado_por` (test automatizado lo verifica).
2. No llamar a `FacturasService` desde el job recurrente.
3. No enviar mensajes automáticos por canales `evolution_baileys`.
4. No tomar precios del body sin validarlos contra `lista-precios`.
5. No crear clientes insertando directo: usar `ClientesService`.
6. No mover una oportunidad a ganada fuera del asistente de cierre.
7. No guardar números de tarjeta: solo la **referencia al token** del proveedor. El
   débito automático de CRM-36 necesita tokenización en Dpago y trabajo en el gateway
   (repo aparte): el alcance de los tres frentes está en `docs/plan-crm-360.md`.
8. No exponer al agente IA herramientas que escriban datos.
9. No usar la palabra/tabla `suscripciones` para contratos del CRM.
10. No instalar `@nestjs/event-emitter` (ver decisión de arquitectura).

---

## Pilotos

- **AGOGO** — CRM Esencial. Tienda de regalos, vende por WhatsApp y en el local, 2 usuarios,
  **sin envíos** (`envios_habilitados = false`, flujo "listo para retirar"). Requiere PRE-04
  (Cobrar Pedido en POS). WhatsApp por Meta Cloud API, **nunca Baileys**: es su canal principal.
- **DOBASA** — CRM Pro. Mayorista a supermercados: pipeline mayorista, bandeja, reclamos → NC.
  Requiere PRE-07 (cerrar incidencias del ERP abiertas).

---

## Frontend (CRM-05)

Repo `novasispy-erp`, rama `crm`, commit `969bb2c`. Archivos:

| | |
|---|---|
| Servicio | `src/api/crm-config.service.js` (plano, no `src/api/crm/`) |
| Store | `src/store/crmStore.jsx` — sólo estado de UI; los datos del servidor van en react-query |
| Pantalla | `src/pages/crm/CrmConfig.jsx` + `components/templates/crm/` + `components/organismos/crm/` |
| Menú | `utils/dataEstatica.jsx` (la entrada) + `hooks/useMenuAcceso.js` (`MODULO_RAIZ_META.CRM`) + `routers/routes.jsx` (la ruta) |

Gating de la entrada: `modulo: "CRM"` + `submodulo: "CRM_CONFIG"` + `permission:
"CRM_CFG_PIPELINE_VER"`. Es el mismo trío que usa el backend, así que el menú no ofrece lo
que el guard después va a rechazar.

**Falta elegir librería de drag & drop.** El reordenamiento de etapas va con flechas porque el
repo no tiene ninguna instalada. El kanban de CRM-17 va a necesitarla sí o sí; cuando entre,
esta pantalla puede adoptarla.

El frontend tiene estándares obligatorios en su `CLAUDE.md` y `docs/ui-standards.md`: toda
pantalla arranca con `ScreenGuia`, las listas vacías usan `EmptyState`, los montos van con
`MonedaInput` y nunca `Gs.` hardcodeado. Ojo con dos componentes que se usan distinto de lo
que el nombre sugiere: `FieldHint` **reemplaza** al `TextField` y recibe `hint`, y
`InlineValidationBanner` recibe `errors` (no `errores`).

## Instrucciones para Claude

- Antes de implementar un ticket, leé su sección en `docs/plan-crm-360.md` y la sección
  correspondiente de `docs/spec-crm-360.md`. Mostrá el plan de archivos antes de escribir código.
- **Leé primero la tabla de correcciones de este archivo.** Si un prompt del plan nombra
  `getPrecio`, `INTEGRACION_IA`, `limites_personalizados`, `presupuestos` o `nota_credito`,
  está mal: usá el nombre real.
- Reutilizá servicios existentes (Clientes, ListaPrecios, Pedidos, Facturas, Monedas, Cobros,
  Bancard) — leé sus DTOs antes de mapear.
- DTOs: `@IsUUID()` en todo id; nunca aceptar `empresa_id` desde el body.
- Al terminar un ticket, actualizá la tabla "Estado por fase" de este archivo en el mismo commit.
