diff --git a/.env.example b/.env.example index e72a01e..d556926 100644 --- a/.env.example +++ b/.env.example @@ -1,41 +1,12 @@ # ============================================ -# MONDAY.COM # ============================================ -MONDAY_API_KEY="PONER_API_KEY_DE_MONDAY" -MONDAY_API_URL="https://api.monday.com/v2" -MONDAY_WORKSPACE="Frasa-CAS" - -# Board IDs del workspace Frasa-CAS (ya creados) -MONDAY_BOARD_CONTACTOS="18427678476" -MONDAY_BOARD_DESARROLLOS="18427678485" -MONDAY_BOARD_UNIDADES="18427678487" -MONDAY_BOARD_LEADS="18427678493" -MONDAY_BOARD_OPORTUNIDADES="18427678497" -MONDAY_BOARD_APARTADO="18427678515" -MONDAY_BOARD_VENTAS="18427678520" -# Tableros creados por api-gateway/scripts/setup-crm.ts (npm run setup:crm) -MONDAY_BOARD_MOTIVOS="18427702505" -MONDAY_BOARD_ACTIVIDADES="18427702517" -# Tableros de obra/escrituras alimentados por api-gateway/scripts/sync-obra-estatus.py -# (referencia; el script los trae como constantes) -MONDAY_BOARD_FLUJO_ESCRITURAS="18429252054" -MONDAY_BOARD_SALDOS_FAVOR="18429252069" -# 'false' desactiva la creación de actividades en Monday (default true) -ACTIVIDADES_ENABLED="true" # Automatizaciones del tablero Leads (api-gateway) -# IDs de usuario Monday separados por coma para el round-robin; -# vacío = auto-detectar usuarios no-guest del workspace -MONDAY_RR_USER_IDS="" # Días sin actividad para recordar un lead (update automático en el item) INACTIVIDAD_DIAS="10" # Cron de polling de respaldo (asigna vendedor a leads nuevos sin asignar) POLL_CRON="*/5 * * * *" -# 'false' desactiva los crons (los endpoints /api/automatizaciones siguen vivos) -MONDAY_AUTOMATIONS_ENABLED="true" -# 'true' fuerza el modo local aunque Monday responda (pruebas del modo degradado): -# /api/sistema/estado reporta mondayOk=false y el sync worker no procesa la cola -MODO_LOCAL_FORZADO="false" +# 'false' desactiva los crons de automatizaciones (los endpoints /api/automatizaciones siguen vivos) # ============================================ # BASE DE DATOS (fase API Gateway) @@ -77,12 +48,6 @@ AI_MIN_SCORE="0.3" # Ronda 2: horas antes de escalar un lead asignado que sigue en NUEVO/Intento de contacto LEAD_ESCALADO_HORAS=48 -# User ID Monday del coordinador para escalados; vacio = actividad al vendedor asignado -MONDAY_COORDINADOR_USER_ID= -# User ID Monday de la cuenta COMPARTIDA de vendedores de piso (usan solo la -# app, todos ligados a una sola cuenta Monday). Con ella, Mi dia filtra "lo mio" -# por captura local y no por la columna people. 0/vacio = desactivado -MONDAY_VENDEDOR_COMPARTIDO_ID= # Recordatorios de cobranza (pagos por vencer / vencidos) # Cron del escaneo diario (default "30 9 * * *") diff --git a/api-gateway/package.json b/api-gateway/package.json index bfc3373..aab68ac 100644 --- a/api-gateway/package.json +++ b/api-gateway/package.json @@ -16,9 +16,6 @@ "seed:usuarios": "tsx scripts/seed-usuarios.ts", "seed:config": "tsx scripts/seed-config.js", "seed:unidades": "tsx scripts/seed-unidades-locales.js", - "setup:crm": "tsx scripts/setup-crm.ts", - "sync:desarrollos": "tsx scripts/sync-desarrollos.ts", - "sync:etapas": "tsx scripts/sync-etapas.ts", "prisma:migrate": "prisma migrate dev" }, "dependencies": { diff --git a/api-gateway/scripts/importar-unidades.ts b/api-gateway/scripts/importar-unidades.ts deleted file mode 100644 index b0701a7..0000000 --- a/api-gateway/scripts/importar-unidades.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * Importa los inventarios de ATELIER, ESTANZA, SENDA y SOLANA al board - * "Unidades" de Monday, desde reference/cotizadores/{DEV}.json. - * - * - Idempotente: salta items cuyo nombre (código prefijado) ya existe. - * - Crea la columna "Meses para firma" (numbers) si falta (la usa SOLANA). - * - create_labels_if_missing: la columna status "Disponibilidad" gana las - * etiquetas nuevas (VENDIDO, ESCRITURADO, APARTADO, BLOQUEADO). - * - Delay entre mutaciones para cuidar el rate limit. - * - * Uso: npx tsx scripts/importar-unidades.ts (desde api-gateway/) - */ -import { readFile } from 'node:fs/promises' -import path from 'node:path' -import { config } from '../src/config/index.js' -import { MondayService } from '../src/services/monday.service.js' - -const REF_DIR = path.resolve(process.cwd(), '..', 'reference', 'cotizadores') -const DELAY_MS = 400 - -const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)) - -interface UnidadJson { - codigo: string - modelo: string | null - fechaEntrega: string | null - supEstacionamiento: number | null - supConstruccion: number | null - supExcedente: number | null - status: string - extras: Record -} - -interface DefinicionDesarrollo { - id: string - archivo: string - codigoMonday: (u: UnidadJson) => string - precio: (u: UnidadJson) => number | null - mesesParaFirma?: (u: UnidadJson) => number | null -} - -const DESARROLLOS: DefinicionDesarrollo[] = [ - { - id: 'ATELIER', - archivo: 'ATELIER.json', - codigoMonday: (u) => `ATELIER-${u.codigo}`, - precio: (u) => (typeof u.extras['precioLista'] === 'number' ? u.extras['precioLista'] : null), - }, - { - id: 'ESTANZA', - archivo: 'ESTANZA.json', - codigoMonday: (u) => `ESTANZA-${u.codigo.replace(/^CASA-/, '')}`, - precio: (u) => (typeof u.extras['precioLista'] === 'number' ? u.extras['precioLista'] : null), - }, - { - id: 'SENDA', - archivo: 'SENDA.json', - codigoMonday: (u) => `SENDA-${u.codigo}`, - precio: (u) => (typeof u.extras['precioPreventa'] === 'number' ? u.extras['precioPreventa'] : null), - }, - { - id: 'SOLANA', - archivo: 'SOLANA.json', - codigoMonday: (u) => `SOLANA-${u.codigo}`, - precio: (u) => (typeof u.extras['precioPreventa'] === 'number' ? u.extras['precioPreventa'] : null), - mesesParaFirma: (u) => - typeof u.extras['mesesParaFirma'] === 'number' ? u.extras['mesesParaFirma'] : null, - }, -] - -const svc = new MondayService() -const boardId = config.monday.boards.unidades - -/** Columnas del board mapeadas por título */ -async function columnasBoard(): Promise> { - const data = await svc.graphql<{ boards: { columns: { id: string; title: string; type: string }[] }[] }>( - `query ($ids: [ID!]) { boards(ids: $ids) { columns { id title type } } }`, - { ids: [boardId] }, - ) - return new Map((data.boards[0]?.columns ?? []).map((c) => [c.title, c])) -} - -async function asegurarColumnaMeses(): Promise { - const cols = await columnasBoard() - if (cols.has('Meses para firma')) return - const data = await svc.graphql<{ create_column: { id: string } }>( - `mutation ($boardId: ID!, $title: String!) { - create_column(board_id: $boardId, title: $title, column_type: numbers) { id } - }`, - { boardId, title: 'Meses para firma' }, - ) - console.log(`Columna "Meses para firma" creada (${data.create_column.id})`) -} - -/** Nombres de TODOS los items del board (paginado por cursor) */ -async function itemsExistentes(): Promise> { - const nombres = new Set() - let cursor: string | null = null - do { - const data: { boards: { items_page: { cursor: string | null; items: { name: string }[] } }[] } = - cursor === null - ? await svc.graphql( - `query ($ids: [ID!]) { boards(ids: $ids) { items_page(limit: 500) { cursor items { name } } } }`, - { ids: [boardId] }, - ) - : await svc.graphql( - `query ($ids: [ID!], $cursor: String!) { - boards(ids: $ids) { items_page(limit: 500, cursor: $cursor) { cursor items { name } } } - }`, - { ids: [boardId], cursor }, - ) - const page = data.boards[0]?.items_page - if (!page) break - for (const item of page.items) nombres.add(item.name) - cursor = page.cursor - } while (cursor !== null) - return nombres -} - -function columnValuesDe( - def: DefinicionDesarrollo, - u: UnidadJson, - cols: Map, -): string { - const valores: Record = {} - const poner = (titulo: string, valor: unknown) => { - const col = cols.get(titulo) - if (!col) throw new Error(`Columna "${titulo}" no existe en el board`) - valores[col.id] = valor - } - const precio = def.precio(u) - poner('Desarrollo', def.id) - poner('Modelo', u.modelo ?? '') - if (u.fechaEntrega) poner('Fecha entrega', { date: u.fechaEntrega }) - if (u.supConstruccion !== null) poner('Sup. construcción', u.supConstruccion) - if (u.supExcedente !== null) poner('Sup. excedente', u.supExcedente) - if (u.supEstacionamiento !== null) poner('Sup. estacionamiento', u.supEstacionamiento) - if (precio !== null) { - // Desarrollos de precio único: ambos precios replican el precio de lista - poner('Precio sin acabados', precio) - poner('Precio con acabados', precio) - } - poner('Disponibilidad', { label: u.status }) - const meses = def.mesesParaFirma?.(u) - if (meses !== null && meses !== undefined) poner('Meses para firma', meses) - return JSON.stringify(valores) -} - -async function main(): Promise { - await asegurarColumnaMeses() - const cols = await columnasBoard() - const existentes = await itemsExistentes() - console.log(`Board ${boardId}: ${existentes.size} items existentes`) - - for (const def of DESARROLLOS) { - const raw = JSON.parse(await readFile(path.join(REF_DIR, def.archivo), 'utf8')) as { - unidades: UnidadJson[] - } - let creados = 0 - let saltados = 0 - for (const u of raw.unidades) { - const nombre = def.codigoMonday(u) - if (existentes.has(nombre)) { - saltados++ - continue - } - const columnValues = columnValuesDe(def, u, cols) - await svc.graphql<{ create_item: { id: string } }>( - `mutation ($boardId: ID!, $itemName: String!, $columnValues: JSON!) { - create_item(board_id: $boardId, item_name: $itemName, column_values: $columnValues, create_labels_if_missing: true) { id } - }`, - { boardId, itemName: nombre, columnValues }, - ) - existentes.add(nombre) - creados++ - if (creados % 25 === 0) console.log(` ${def.id}: ${creados} creados…`) - await sleep(DELAY_MS) - } - console.log(`${def.id}: ${creados} creados, ${saltados} ya existían (total ${raw.unidades.length})`) - } - svc.invalidarCacheUnidades() - console.log('Importación terminada.') - process.exit(0) -} - -main().catch((err) => { - console.error('Falló la importación:', err instanceof Error ? err.message : err) - process.exit(1) -}) diff --git a/api-gateway/scripts/setup-crm.ts b/api-gateway/scripts/setup-crm.ts deleted file mode 100644 index 5137f04..0000000 --- a/api-gateway/scripts/setup-crm.ts +++ /dev/null @@ -1,240 +0,0 @@ -/** - * Setup idempotente del workspace Frasa-CAS para alinearlo con el CRM FRASA real: - * - * - Leads: labels 'Intento de contacto' y 'Abandono' en el status Estado. - * - Oportunidades: labels de Etapa del funnel CRM + dropdown 'Motivo No Compra'. - * - Apartado: labels del pipeline Enkontrol + checkboxes CURP / RFC / Comprobante. - * - Contactos: dropdowns del perfil demográfico + date Fecha Nacimiento. - * - Tableros nuevos 'Motivos No Compra' y 'Actividades' (si MONDAY_BOARD_MOTIVOS / - * MONDAY_BOARD_ACTIVIDADES están vacíos en .env). Imprime los IDs creados. - * - * Uso: npm run setup:crm (desde api-gateway) - * Es seguro re-ejecutarlo: solo crea lo que falta. - */ -import { config } from '../src/config/index.js' -import { MondayService } from '../src/services/monday.service.js' - -const monday = new MondayService() - -// ---------- Vocabulario CRM FRASA (normalizado, sin los typos del original) ---------- - -const ETIQUETAS_LEADS_ESTADO = ['Intento de contacto', 'Abandono'] - -const ETIQUETAS_ETAPA_OPORTUNIDAD = [ - 'Afluencia', - 'Cotizador', - 'Agendo Cita', - 'Aparto', - 'Ganado', - 'Perdido', - 'Abandono', - 'No Asistio', -] - -export const MOTIVOS_NO_COMPRA = [ - 'Precio', - 'Producto', - 'Ubicacion', - 'Financiamiento', - 'Competencia', - 'Cliente', - 'Timing', -] - -const ETIQUETAS_APARTADO_ESTADO = [ - 'Entrego Documentacion', - 'Pago Apartado', - 'Alta Enkontrol', - 'Cita Clientes', - 'Contrato Enkontrol', - 'Firma Cliente (Venta)', - 'Cancelacion', - 'En Definicion', - // Postventa (cobranza): al liquidarse el plan de pagos - 'X Escriturar', - 'Escriturado', -] - -const PERFIL_CONTACTOS: { titulo: string; tipo: string; labels?: string[] }[] = [ - { titulo: 'Sexo', tipo: 'dropdown', labels: ['Hombre', 'Mujer', 'No define'] }, - { titulo: 'Estado Civil', tipo: 'dropdown', labels: ['Soltero', 'Casado', 'Union Libre', 'Divorciado', 'Viudo'] }, - { titulo: 'Rango de Edad', tipo: 'dropdown', labels: ['Menor de 24', 'Entre 24 y 40', 'Mayor de 40'] }, - { - titulo: 'Actividad Economica', - tipo: 'dropdown', - labels: ['Empleado', 'Empresario', 'Pensionado', 'Comerciante'], - }, - { - titulo: 'Vivienda Actual', - tipo: 'dropdown', - labels: ['Propia', 'Rentada', 'Prestada', 'Comparte con familia'], - }, - { titulo: 'Proposito de Compra', tipo: 'dropdown', labels: ['Habitarla', 'Inversion'] }, - { titulo: 'Fecha Nacimiento', tipo: 'date' }, -] - -// ---------- Helpers ---------- - -interface Columna { - id: string - title: string - type: string - settings_str: string -} - -async function columnas(boardId: string): Promise { - const data = await monday.graphql<{ boards: { columns: Columna[] }[] }>( - `query ($ids: [ID!]) { boards(ids: $ids) { columns { id title type settings_str } } }`, - { ids: [boardId] }, - ) - return data.boards[0]?.columns ?? [] -} - -/** Labels actuales de una columna status/dropdown (parsea settings_str) */ -function labelsDe(col: Columna): string[] { - try { - const s = JSON.parse(col.settings_str) as { labels?: unknown } - if (Array.isArray(s.labels)) { - // dropdown: [{id, name}] - return s.labels.map((l) => (typeof l === 'object' && l !== null ? String((l as { name?: string }).name) : String(l))) - } - if (s.labels !== null && typeof s.labels === 'object') { - // status: {"0": "Label", ...} - return Object.values(s.labels as Record) - } - } catch { - // settings no parseable: sin labels conocidos - } - return [] -} - -async function asegurarColumna(boardId: string, titulo: string, tipo: string, defaults?: unknown): Promise { - const cols = await columnas(boardId) - if (cols.some((c) => c.title === titulo)) { - console.log(` = columna "${titulo}" ya existe (${boardId})`) - return - } - await monday.graphql( - `mutation ($boardId: ID!, $title: String!, $tipo: ColumnType!, $defaults: JSON) { - create_column(board_id: $boardId, title: $title, column_type: $tipo, defaults: $defaults) { id } - }`, - { boardId, title: titulo, tipo, ...(defaults !== undefined ? { defaults: JSON.stringify(defaults) } : {}) }, - ) - console.log(` + columna "${titulo}" (${tipo}) creada en ${boardId}`) -} - -/** - * Agrega labels faltantes a una columna status/dropdown usando un item scratch - * con create_labels_if_missing (change_column_metadata no siempre está disponible). - */ -async function asegurarEtiquetas(boardId: string, tituloColumna: string, etiquetas: string[]): Promise { - const cols = await columnas(boardId) - const col = cols.find((c) => c.title === tituloColumna) - if (!col) throw new Error(`Columna "${tituloColumna}" no existe en ${boardId}`) - const actuales = new Set(labelsDe(col).map((l) => l.toLowerCase())) - const faltantes = etiquetas.filter((e) => !actuales.has(e.toLowerCase())) - if (faltantes.length === 0) { - console.log(` = "${tituloColumna}" ya tiene todas las etiquetas (${boardId})`) - return - } - // Item scratch: se crea, se le escriben los labels nuevos y se borra - const creado = await monday.graphql<{ create_item: { id: string } }>( - `mutation ($boardId: ID!, $name: String!) { create_item(board_id: $boardId, item_name: $name) { id } }`, - { boardId, name: '__scratch_labels__' }, - ) - const itemId = creado.create_item.id - try { - for (const etiqueta of faltantes) { - const valor = col.type === 'dropdown' ? { labels: [etiqueta] } : { label: etiqueta } - await monday.graphql( - `mutation ($boardId: ID!, $itemId: ID!, $values: JSON!) { - change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $values, create_labels_if_missing: true) { id } - }`, - { boardId, itemId, values: JSON.stringify({ [col.id]: valor }) }, - ) - console.log(` + etiqueta "${etiqueta}" en "${tituloColumna}" (${boardId})`) - } - } finally { - await monday.graphql(`mutation ($itemId: ID!) { delete_item(item_id: $itemId) { id } }`, { itemId }) - } -} - -async function crearTablero(nombre: string): Promise { - const workspaces = await monday.graphql<{ workspaces: { id: string; name: string }[] }>( - `query { workspaces { id name } }`, - ) - const ws = workspaces.workspaces.find((w) => w.name === config.monday.workspace) - const data = await monday.graphql<{ create_board: { id: string } }>( - `mutation ($name: String!, $kind: BoardKind!, $ws: ID) { - create_board(board_name: $name, board_kind: $kind, workspace_id: $ws) { id } - }`, - { name: nombre, kind: 'public', ...(ws ? { ws: ws.id } : {}) }, - ) - return data.create_board.id -} - -// ---------- Main ---------- - -async function main(): Promise { - const b = config.monday.boards - - console.log('== Leads: etiquetas de Estado ==') - await asegurarEtiquetas(b.leads, 'Estado', ETIQUETAS_LEADS_ESTADO) - - console.log('== Oportunidades: Etapa (columna status "Estado") + Motivo No Compra ==') - await asegurarEtiquetas(b.oportunidades, 'Estado', ETIQUETAS_ETAPA_OPORTUNIDAD) - await asegurarColumna(b.oportunidades, 'Motivo No Compra', 'dropdown') - await asegurarEtiquetas(b.oportunidades, 'Motivo No Compra', MOTIVOS_NO_COMPRA) - - console.log('== Apartado: pipeline Enkontrol + checkboxes de documentos ==') - await asegurarEtiquetas(b.apartado, 'Estado', ETIQUETAS_APARTADO_ESTADO) - for (const titulo of ['CURP', 'RFC', 'Comprobante']) { - await asegurarColumna(b.apartado, titulo, 'checkbox') - } - - console.log('== Contactos: perfil demográfico ==') - for (const col of PERFIL_CONTACTOS) { - await asegurarColumna(b.contactos, col.titulo, col.tipo) - if (col.labels) await asegurarEtiquetas(b.contactos, col.titulo, col.labels) - } - - console.log('== Tablero Motivos No Compra ==') - let motivosId = b.motivos - if (motivosId === '') { - motivosId = await crearTablero('Motivos No Compra') - console.log(` + tablero creado: ${motivosId} → MONDAY_BOARD_MOTIVOS="${motivosId}" en .env`) - } else { - console.log(` = ya configurado: ${motivosId}`) - } - await asegurarColumna(motivosId, 'Categoria', 'status') - await asegurarEtiquetas(motivosId, 'Categoria', MOTIVOS_NO_COMPRA) - await asegurarColumna(motivosId, 'Descripcion', 'long_text') - await asegurarColumna(motivosId, 'Accion Recomendada', 'long_text') - await asegurarColumna(motivosId, 'Responsable', 'people') - - console.log('== Tablero Actividades ==') - let actividadesId = b.actividades - if (actividadesId === '') { - actividadesId = await crearTablero('Actividades') - console.log(` + tablero creado: ${actividadesId} → MONDAY_BOARD_ACTIVIDADES="${actividadesId}" en .env`) - } else { - console.log(` = ya configurado: ${actividadesId}`) - } - await asegurarColumna(actividadesId, 'Tipo', 'status') - await asegurarEtiquetas(actividadesId, 'Tipo', ['Llamada', 'Reunion', 'Tarea']) - await asegurarColumna(actividadesId, 'Responsable', 'people') - await asegurarColumna(actividadesId, 'Fecha', 'date') - await asegurarColumna(actividadesId, 'Estado', 'status') - await asegurarEtiquetas(actividadesId, 'Estado', ['Abierto', 'Listo']) - // board_relation NO se puede crear vía API ("column type is not supported yet"): - // la columna "Lead" (relación al tablero Leads) debe crearse a mano en la UI. - // El servicio la usa si existe y la omite si no. - console.log(' ! crea a mano la columna "Lead" (Conectar tableros → Leads) en el tablero Actividades') - - console.log('\nListo. Si se crearon tableros nuevos, copia los IDs a .env y .env.example.') -} - -main().catch((err) => { - console.error('setup-crm falló:', err) - process.exit(1) -}) diff --git a/api-gateway/scripts/sync-desarrollos.ts b/api-gateway/scripts/sync-desarrollos.ts deleted file mode 100644 index f1556dc..0000000 --- a/api-gateway/scripts/sync-desarrollos.ts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * Sincroniza el tablero Desarrollos (catálogo maestro, estilo CRM FRASA) con - * los inventarios reales: - * - DIAGONAL: apps/cotizador/src/data/unidades.ts - * - ATELIER / ESTANZA / SENDA / SOLANA: reference/cotizadores/*.json - * - * Calcula (solo unidades DISPONIBLE): Precio Desde/Hasta, M2 Construccion - * (rango), Fecha Entrega (la más próxima) y Modelos; y fija el Tipo de - * Inmueble (DIAGONAL/ATELIER = Departamentos; el resto = Casas). - * - * Idempotente: crea las columnas y los items que falten; actualiza valores. - * Uso: npm run sync:desarrollos (desde api-gateway) - */ -import { readFile } from 'node:fs/promises' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { config } from '../src/config/index.js' -import { MondayService } from '../src/services/monday.service.js' - -const RAIZ = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') - -const monday = new MondayService() - -const TIPO_POR_DESARROLLO: Record = { - DIAGONAL: 'Departamentos', - ATELIER: 'Departamentos', - ESTANZA: 'Casas', - SENDA: 'Casas', - SOLANA: 'Casas', -} - -interface UnidadInventario { - codigo: string - modelo: string - fechaEntrega: string | null - supConstruccion: number | null - precioSinAcabados: number | null - precioConAcabados: number | null - status: string - extras?: { precioLista?: number | null; precioPreventa?: number | null } & Record -} - -interface StatsDesarrollo { - nombre: string - tipoInmueble: string - precioDesde: number | null - precioHasta: number | null - m2Construccion: string // 'min - max' o '' - fechaEntrega: string | null // la más próxima entre DISPONIBLEs - modelos: string // únicos, separados por coma - disponibles: number -} - -/** Precio de referencia: con acabados, si no sin acabados, si no lista/preventa (extras) */ -function precioDe(u: UnidadInventario): number | null { - return u.precioConAcabados ?? u.precioSinAcabados ?? u.extras?.precioLista ?? u.extras?.precioPreventa ?? null -} - -export function calcularStats(nombre: string, unidades: UnidadInventario[]): StatsDesarrollo { - const disponibles = unidades.filter((u) => u.status.toUpperCase() === 'DISPONIBLE') - const precios = disponibles.map(precioDe).filter((p): p is number => p !== null && p > 0) - const m2 = disponibles.map((u) => u.supConstruccion).filter((s): s is number => s !== null && s > 0) - const fechas = disponibles - .map((u) => u.fechaEntrega) - .filter((f): f is string => f !== null && f !== '') - .sort() - const modelos = [...new Set(disponibles.map((u) => u.modelo).filter((m) => m !== ''))].sort() - const redondeo = (n: number): number => Math.round(n * 100) / 100 - return { - nombre, - tipoInmueble: TIPO_POR_DESARROLLO[nombre] ?? 'Casas', - precioDesde: precios.length > 0 ? Math.min(...precios) : null, - precioHasta: precios.length > 0 ? Math.max(...precios) : null, - m2Construccion: m2.length > 0 ? `${redondeo(Math.min(...m2))} - ${redondeo(Math.max(...m2))}` : '', - fechaEntrega: fechas[0] ?? null, - modelos: modelos.join(', '), - disponibles: disponibles.length, - } -} - -async function cargarInventarios(): Promise> { - const mapa = new Map() - // DIAGONAL vive en el seed del cotizador (TS) - const mod = (await import( - pathToFileURL2('../../apps/cotizador/src/data/unidades.ts') - )) as { UNIDADES_SEED: UnidadInventario[] } - mapa.set('DIAGONAL', mod.UNIDADES_SEED) - for (const nombre of ['ATELIER', 'ESTANZA', 'SENDA', 'SOLANA']) { - const crudo = await readFile(path.join(RAIZ, `reference/cotizadores/${nombre}.json`), 'utf8') - const json = JSON.parse(crudo) as { unidades: UnidadInventario[] } - mapa.set(nombre, json.unidades) - } - return mapa -} - -/** Ruta file:// absoluta para import dinámico de TS fuera del paquete */ -function pathToFileURL2(relDesdeScript: string): string { - const abs = path.resolve(path.dirname(fileURLToPath(import.meta.url)), relDesdeScript) - return `file://${abs}` -} - -// ---------- Monday ---------- - -interface Columna { - id: string - title: string - type: string -} - -async function asegurarColumna(boardId: string, titulo: string, tipo: string, existentes: Columna[]): Promise { - if (existentes.some((c) => c.title === titulo)) return - await monday.graphql( - `mutation ($boardId: ID!, $title: String!, $tipo: ColumnType!) { - create_column(board_id: $boardId, title: $title, column_type: $tipo) { id } - }`, - { boardId, title: titulo, tipo }, - ) - console.log(` + columna "${titulo}" (${tipo})`) -} - -async function main(): Promise { - const boardId = config.monday.boards.desarrollos - - const colsData = await monday.graphql<{ boards: { columns: Columna[] }[] }>( - `query ($ids: [ID!]) { boards(ids: $ids) { columns { id title type } } }`, - { ids: [boardId] }, - ) - const columnas = colsData.boards[0]?.columns ?? [] - await asegurarColumna(boardId, 'Precio Desde', 'numbers', columnas) - await asegurarColumna(boardId, 'Precio Hasta', 'numbers', columnas) - await asegurarColumna(boardId, 'M2 Construccion', 'text', columnas) - await asegurarColumna(boardId, 'Tipo de Inmueble', 'dropdown', columnas) - await asegurarColumna(boardId, 'Fecha Entrega', 'date', columnas) - await asegurarColumna(boardId, 'Modelos', 'text', columnas) - - // Recargar columnas tras posibles creaciones y mapear por título - const colsFrescas = await monday.graphql<{ boards: { columns: Columna[] }[] }>( - `query ($ids: [ID!]) { boards(ids: $ids) { columns { id title type } } }`, - { ids: [boardId] }, - ) - const porTitulo = new Map((colsFrescas.boards[0]?.columns ?? []).map((c) => [c.title, c])) - - const itemsData = await monday.graphql<{ boards: { items_page: { items: { id: string; name: string }[] } }[] }>( - `query ($ids: [ID!]) { boards(ids: $ids) { items_page(limit: 100) { items { id name } } } }`, - { ids: [boardId] }, - ) - const itemsPorNombre = new Map( - (itemsData.boards[0]?.items_page.items ?? []).map((i) => [i.name.trim().toUpperCase(), i.id]), - ) - - const inventarios = await cargarInventarios() - for (const [nombre, unidades] of inventarios) { - const stats = calcularStats(nombre, unidades) - const valores: Record = {} - const poner = (titulo: string, valor: unknown): void => { - const col = porTitulo.get(titulo) - if (col) valores[col.id] = valor - } - if (stats.precioDesde !== null) poner('Precio Desde', stats.precioDesde) - if (stats.precioHasta !== null) poner('Precio Hasta', stats.precioHasta) - if (stats.m2Construccion !== '') poner('M2 Construccion', stats.m2Construccion) - poner('Tipo de Inmueble', { labels: [stats.tipoInmueble] }) - if (stats.fechaEntrega !== null) poner('Fecha Entrega', { date: stats.fechaEntrega }) - if (stats.modelos !== '') poner('Modelos', stats.modelos) - const columnValues = JSON.stringify(valores) - - const itemId = itemsPorNombre.get(nombre) - if (itemId === undefined) { - await monday.graphql( - `mutation ($boardId: ID!, $name: String!, $values: JSON!) { - create_item(board_id: $boardId, item_name: $name, column_values: $values, create_labels_if_missing: true) { id } - }`, - { boardId, name: nombre, values: columnValues }, - ) - console.log(`+ ${nombre}: item creado (${stats.disponibles} disponibles)`) - } else { - await monday.graphql( - `mutation ($boardId: ID!, $itemId: ID!, $values: JSON!) { - change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $values, create_labels_if_missing: true) { id } - }`, - { boardId, itemId, values: columnValues }, - ) - console.log(`= ${nombre}: actualizado (${stats.disponibles} disponibles)`) - } - console.log( - ` ${stats.tipoInmueble} | $${stats.precioDesde ?? '-'} - $${stats.precioHasta ?? '-'} | ` + - `m2 ${stats.m2Construccion || '-'} | entrega ${stats.fechaEntrega ?? '-'} | modelos: ${stats.modelos || '-'}`, - ) - } - console.log('\nSincronización de Desarrollos completa.') -} - -main().catch((err) => { - console.error('sync-desarrollos falló:', err) - process.exit(1) -}) diff --git a/api-gateway/scripts/sync-etapas.ts b/api-gateway/scripts/sync-etapas.ts deleted file mode 100644 index 0574300..0000000 --- a/api-gateway/scripts/sync-etapas.ts +++ /dev/null @@ -1,368 +0,0 @@ -/** - * Sincroniza la columna status "Etapa" del tablero Unidades (pipeline de 10 - * etapas: Apartado → Reserva → Vendido → Integración → En Análisis → Autorizado - * → Notaría → Escriturado → Entrega → PostVenta) desde un JSON de etapas. - * - * Formatos de entrada aceptados: - * - Simple: [{ "codigo": "ATELIER-1", "etapa": "NOTARIA" }, ...] - * - Maestro: reference/maestro/maestro-unidades.json (codigo_nuestro + - * etapa_maestro + mapeo.confianza; solo se usan confianza - * alta/media). Es el default. - * - * Idempotente: solo escribe en Monday si la etapa cambió. - * - * Traspasos de departamento (dedup en AutomatizacionState, clave - * `traspaso::`): - * - al llegar a Vendido → actividad "Venta de {codigo} lista para Escrituración" - * - al llegar a Escriturado → actividad "{codigo} escriturado: pasa a SAC" - * Una etapa alta implica las anteriores (p. ej. Escriturado cubre Vendido). - * - * Flags: - * --archivo JSON de etapas (default: reference/maestro/maestro-unidades.json) - * --dry-run no escribe en Monday ni en la base; solo reporta - * --semilla registra el dedup de traspasos SIN crear actividades. - * Usarlo en la PRIMERA corrida (carga histórica): las ~50 - * unidades ya vendidas/escrituradas no deben generar - * actividades retroactivas. Las corridas siguientes (sin - * --semilla) solo crean actividad en transiciones nuevas. - * - * Uso: npm run sync:etapas -- [--archivo ] [--dry-run] [--semilla] - */ -import { readFile } from 'node:fs/promises' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { config } from '../src/config/index.js' -import { MondayService } from '../src/services/monday.service.js' -import { ETAPAS_PIPELINE, normalizarEtapa, rangoEtapa, TRASPASOS, type EtapaPipeline } from '../src/services/etapas.js' -import { prisma } from '../src/services/prisma.js' - -const RAIZ = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') -const ARCHIVO_DEFAULT = path.join(RAIZ, 'reference/maestro/maestro-unidades.json') -const PAUSA_MS = 400 // entre mutaciones, para no pegarle al rate limit de Monday - -const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)) - -// ---------- Argumentos ---------- - -interface Opciones { - archivo: string - dryRun: boolean - semilla: boolean -} - -function parseArgs(argv: string[]): Opciones { - const ops: Opciones = { archivo: ARCHIVO_DEFAULT, dryRun: false, semilla: false } - for (let i = 0; i < argv.length; i++) { - const arg = argv[i] - if (arg === '--dry-run') ops.dryRun = true - else if (arg === '--semilla') ops.semilla = true - else if (arg === '--archivo') { - const valor = argv[++i] - if (!valor) throw new Error('--archivo requiere una ruta') - ops.archivo = path.resolve(process.cwd(), valor) - } else { - throw new Error(`Argumento desconocido: ${arg}`) - } - } - return ops -} - -// ---------- Entrada ---------- - -interface EtapaUnidad { - codigo: string - etapa: EtapaPipeline // ya normalizada al label oficial -} - -interface EntradaMaestro { - codigo_nuestro?: string | null - etapa_maestro?: string | null - mapeo?: { confianza?: string } -} - -interface EntradaSimple { - codigo?: string - etapa?: string | null -} - -/** Lee el JSON de etapas y lo deja en pares {codigo, etapa oficial}; avisa lo que descarta */ -async function cargarEtapas(archivo: string): Promise { - const json = JSON.parse(await readFile(archivo, 'utf8')) as unknown[] - if (!Array.isArray(json)) throw new Error(`${archivo} no es un array JSON`) - const esMaestro = json.some((e) => typeof e === 'object' && e !== null && 'codigo_nuestro' in e) - const out: EtapaUnidad[] = [] - let descartadas = 0 - for (const cruda of json) { - if (typeof cruda !== 'object' || cruda === null) { - descartadas++ - continue - } - let codigo: string | undefined - let etapaCruda: string | null | undefined - if (esMaestro) { - const e = cruda as EntradaMaestro - const confianza = e.mapeo?.confianza ?? 'ninguna' - if (confianza !== 'alta' && confianza !== 'media') { - descartadas++ - continue - } - codigo = e.codigo_nuestro ?? undefined - etapaCruda = e.etapa_maestro - } else { - const e = cruda as EntradaSimple - codigo = e.codigo - etapaCruda = e.etapa - } - if (!codigo) { - descartadas++ - continue - } - const etapa = normalizarEtapa(etapaCruda) - if (etapa === null) { - // DISPONIBLE / vacío / desconocido: la unidad no tiene etapa del pipeline; - // no se escribe nada (nunca se borra una etapa existente desde este script) - descartadas++ - continue - } - out.push({ codigo, etapa }) - } - if (descartadas > 0) console.log(` (${descartadas} entradas descartadas: sin match, sin etapa o confianza baja)`) - return out -} - -// ---------- Monday: columna Etapa ---------- - -const monday = new MondayService() - -interface ColumnaBoard { - id: string - title: string - type: string - settings_str?: string -} - -async function columnasDe(boardId: string): Promise { - const data = await monday.graphql<{ boards: { columns: ColumnaBoard[] }[] }>( - `query ($ids: [ID!]) { boards(ids: $ids) { columns { id title type settings_str } } }`, - { ids: [boardId] }, - ) - return data.boards[0]?.columns ?? [] -} - -/** Labels actuales de una columna status (de settings_str: {"labels":{"0":"Apartado",...}}) */ -function labelsDe(col: ColumnaBoard): Set { - try { - const settings = JSON.parse(col.settings_str ?? '{}') as { labels?: Record } - return new Set(Object.values(settings.labels ?? {})) - } catch { - return new Set() - } -} - -/** - * Garantiza que exista la columna status "Etapa" con los 10 labels oficiales. - * Monday no crea labels vía defaults de create_column en este workspace, así - * que los labels faltantes se siembran escribiéndolos en un item scratch con - * create_labels_if_missing:true y borrando el item al final. - */ -async function asegurarColumnaEtapa(boardId: string, dryRun: boolean): Promise { - let col = (await columnasDe(boardId)).find((c) => c.title === 'Etapa') - if (col && col.type !== 'status') throw new Error(`La columna "Etapa" existe pero es tipo ${col.type}, no status`) - if (!col) { - if (dryRun) { - console.log(' [dry-run] se crearía la columna "Etapa" (status) con los 10 labels') - return - } - await monday.graphql( - `mutation ($boardId: ID!, $title: String!, $tipo: ColumnType!) { - create_column(board_id: $boardId, title: $title, column_type: $tipo) { id } - }`, - { boardId, title: 'Etapa', tipo: 'status' }, - ) - console.log(' + columna "Etapa" creada') - col = (await columnasDe(boardId)).find((c) => c.title === 'Etapa') - if (!col) throw new Error('No se encontró la columna "Etapa" recién creada') - } - - const faltantes = ETAPAS_PIPELINE.filter((l) => !labelsDe(col).has(l)) - if (faltantes.length === 0) { - console.log(' columna "Etapa" ya tiene los 10 labels') - return - } - if (dryRun) { - console.log(` [dry-run] faltarían labels en "Etapa": ${faltantes.join(', ')}`) - return - } - const creado = await monday.graphql<{ create_item: { id: string } }>( - `mutation ($boardId: ID!, $name: String!) { create_item(board_id: $boardId, item_name: $name) { id } }`, - { boardId, name: 'tmp-sync-etapas (borrar)' }, - ) - const scratchId = creado.create_item.id - try { - for (const label of faltantes) { - await monday.graphql( - `mutation ($boardId: ID!, $itemId: ID!, $values: JSON!) { - change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $values, create_labels_if_missing: true) { id } - }`, - { boardId, itemId: scratchId, values: JSON.stringify({ [col.id]: { label } }) }, - ) - await sleep(PAUSA_MS) - } - } finally { - await monday.graphql(`mutation ($itemId: ID!) { delete_item(item_id: $itemId) { id } }`, { itemId: scratchId }) - } - console.log(` + labels sembrados en "Etapa": ${faltantes.join(', ')}`) -} - -// ---------- Traspasos de departamento ---------- - -const claveDedup = (codigo: string, etapa: string): string => `traspaso:${codigo}:${etapa}` - -const nombreActividad = (codigo: string, etapa: EtapaPipeline): string => - etapa === 'Vendido' ? `Venta de ${codigo} lista para Escrituración` : `${codigo} escriturado: pasa a SAC` - -const fechaHoy = (): string => new Date().toISOString().slice(0, 10) - -interface ResumenTraspasos { - actividadesCreadas: number - registradasSemilla: number - yaRegistradas: number - errores: number -} - -/** - * Para la etapa vigente de una unidad, cubre los traspasos (Vendido → - * Escrituración, Escriturado → SAC) con rango <= la etapa, con dedup persistente. - */ -async function cubrirTraspasos( - codigo: string, - etapaVigente: EtapaPipeline, - ops: Opciones, - resumen: ResumenTraspasos, -): Promise { - const rango = rangoEtapa(etapaVigente) - for (const [etapaTraspaso, depto] of Object.entries(TRASPASOS) as [EtapaPipeline, string][]) { - if (rangoEtapa(etapaTraspaso) > rango) continue - const clave = claveDedup(codigo, etapaTraspaso) - const ya = await prisma.automatizacionState.findUnique({ where: { clave } }) - if (ya) { - resumen.yaRegistradas++ - continue - } - if (ops.dryRun) { - console.log(` [dry-run] traspaso pendiente: ${codigo} → ${depto} (${ops.semilla ? 'se registraría sin actividad' : 'crearía actividad'})`) - continue - } - if (ops.semilla) { - await prisma.automatizacionState.upsert({ - where: { clave }, - update: { valor: 'semilla' }, - create: { clave, valor: 'semilla' }, - }) - resumen.registradasSemilla++ - continue - } - try { - await monday.crearActividad({ - nombre: nombreActividad(codigo, etapaTraspaso), - tipoLabel: 'Tarea', - fecha: fechaHoy(), - estadoLabel: 'Abierto', - responsableId: config.automatizaciones.coordinadorUserId > 0 ? config.automatizaciones.coordinadorUserId : undefined, - }) - await prisma.automatizacionState.upsert({ - where: { clave }, - update: { valor: 'actividad-creada' }, - create: { clave, valor: 'actividad-creada' }, - }) - resumen.actividadesCreadas++ - console.log(` + actividad: ${nombreActividad(codigo, etapaTraspaso)}`) - await sleep(PAUSA_MS) - } catch (err) { - // Sin dedup: la próxima corrida reintenta la actividad - resumen.errores++ - console.error(` ! no se pudo crear la actividad de traspaso de ${codigo}:`, err instanceof Error ? err.message : err) - } - } -} - -// ---------- Main ---------- - -async function main(): Promise { - const ops = parseArgs(process.argv.slice(2)) - const boardId = config.monday.boards.unidades - console.log(`sync-etapas: ${ops.archivo}${ops.dryRun ? ' [dry-run]' : ''}${ops.semilla ? ' [semilla]' : ''}`) - - await asegurarColumnaEtapa(boardId, ops.dryRun) - - const entradas = await cargarEtapas(ops.archivo) - console.log(` ${entradas.length} unidades con etapa en el archivo`) - - const unidades = await monday.getUnidades() - const porCodigo = new Map(unidades.map((u) => [u.codigo, u])) - - let escritas = 0 - let sinCambio = 0 - const noEncontradas: string[] = [] - const resumenTraspasos: ResumenTraspasos = { actividadesCreadas: 0, registradasSemilla: 0, yaRegistradas: 0, errores: 0 } - - for (const { codigo, etapa } of entradas) { - const unidad = porCodigo.get(codigo) - if (!unidad) { - noEncontradas.push(codigo) - continue - } - const etapaActual = normalizarEtapa(unidad.etapa) - if (etapaActual !== etapa) { - if (ops.dryRun) { - console.log(` [dry-run] ${codigo}: ${unidad.etapa ?? '(vacío)'} → ${etapa}`) - } else { - const itemId = await monday.itemIdDeUnidad(codigo) - if (!itemId) { - noEncontradas.push(codigo) - continue - } - await monday.cambiarValorColumna(boardId, itemId, 'Etapa', etapa) - escritas++ - console.log(` = ${codigo}: ${unidad.etapa ?? '(vacío)'} → ${etapa}`) - await sleep(PAUSA_MS) - } - } else { - sinCambio++ - } - await cubrirTraspasos(codigo, etapa, ops, resumenTraspasos) - } - - console.log('\nResumen:') - console.log(` etapas escritas: ${escritas}${ops.dryRun ? ' (dry-run: 0 reales)' : ''}`) - console.log(` sin cambio: ${sinCambio}`) - console.log(` no encontradas: ${noEncontradas.length}${noEncontradas.length > 0 ? ` (${noEncontradas.join(', ')})` : ''}`) - console.log(` actividades creadas: ${resumenTraspasos.actividadesCreadas}`) - console.log(` traspasos sembrados: ${resumenTraspasos.registradasSemilla}`) - console.log(` traspasos ya dedup: ${resumenTraspasos.yaRegistradas}`) - if (resumenTraspasos.errores > 0) console.log(` ERRORES en actividades: ${resumenTraspasos.errores}`) - - // Conteo final por etapa (lectura fresca del board) - if (!ops.dryRun) { - monday.invalidarCacheUnidades() - const frescas = await monday.getUnidades() - const conteo = new Map() - for (const u of frescas) { - const e = normalizarEtapa(u.etapa) - if (e) conteo.set(e, (conteo.get(e) ?? 0) + 1) - } - console.log('\nUnidades por etapa (board):') - for (const etapa of ETAPAS_PIPELINE) { - const n = conteo.get(etapa) ?? 0 - if (n > 0) console.log(` ${etapa}: ${n}`) - } - const sinEtapa = frescas.length - [...conteo.values()].reduce((a, b) => a + b, 0) - console.log(` (sin etapa): ${sinEtapa}`) - } - await prisma.$disconnect() -} - -main().catch((err) => { - console.error('sync-etapas falló:', err) - process.exit(1) -}) diff --git a/api-gateway/src/config/index.ts b/api-gateway/src/config/index.ts index 8f4b575..658fb08 100644 --- a/api-gateway/src/config/index.ts +++ b/api-gateway/src/config/index.ts @@ -19,44 +19,12 @@ export const config = { // host (sync de estatus → POST /api/cotizaciones/alertas-inventario). Vacío = // desactivado: el endpoint exige JWT de usuario como siempre. internalSyncToken: env('INTERNAL_SYNC_TOKEN', ''), - monday: { - apiKey: env('MONDAY_API_KEY'), - apiUrl: env('MONDAY_API_URL', 'https://api.monday.com/v2'), - apiVersion: '2024-10', - workspace: env('MONDAY_WORKSPACE', ''), - boards: { - contactos: env('MONDAY_BOARD_CONTACTOS'), - desarrollos: env('MONDAY_BOARD_DESARROLLOS'), - unidades: env('MONDAY_BOARD_UNIDADES'), - leads: env('MONDAY_BOARD_LEADS'), - oportunidades: env('MONDAY_BOARD_OPORTUNIDADES'), - apartado: env('MONDAY_BOARD_APARTADO'), - ventas: env('MONDAY_BOARD_VENTAS'), - // Tableros creados por scripts/setup-crm.ts; '' = aún no configurado - motivos: env('MONDAY_BOARD_MOTIVOS', ''), - actividades: env('MONDAY_BOARD_ACTIVIDADES', ''), - }, - }, automatizaciones: { // 'false' desactiva los crons (útil en tests/CI); los endpoints admin siguen vivos - enabled: env('MONDAY_AUTOMATIONS_ENABLED', 'true') !== 'false', - // 'false' desactiva la creación de actividades en Monday (default true; false en tests) - actividadesEnabled: env('ACTIVIDADES_ENABLED', 'true') !== 'false', - // IDs de usuario Monday separados por coma; vacío = usar el equipo rrEquipo - rrUserIds: env('MONDAY_RR_USER_IDS', ''), - // Equipo (team) de Monday cuyos miembros entran al round-robin - rrEquipo: env('MONDAY_RR_TEAM', 'vendedores'), + enabled: env('AUTOMATIONS_ENABLED', 'true') !== 'false', inactividadDias: Number(env('INACTIVIDAD_DIAS', '10')), // Horas que un lead asignado puede seguir en NUEVO/Intento de contacto antes de escalar escaladoHoras: Number(env('LEAD_ESCALADO_HORAS', '48')), - // User ID de Monday del coordinador que recibe los escalados; vacío = al vendedor asignado - coordinadorUserId: Number(env('MONDAY_COORDINADOR_USER_ID', '0')), - // User ID de Monday de la cuenta COMPARTIDA de vendedores (los vendedores - // de piso operan solo la app, todos ligados a una sola cuenta Monday). - // Cuando un usuario del ecosistema está ligado a esa cuenta, sus listas - // personales de Mi día se filtran por su captura local, NO por la columna - // people (si no, todos verían los leads de todos). 0 = desactivado. - vendedorCompartidoId: Number(env('MONDAY_VENDEDOR_COMPARTIDO_ID', '0')), pollCron: env('POLL_CRON', '*/5 * * * *'), recordatorioCron: env('RECORDATORIO_CRON', '0 9 * * *'), }, diff --git a/api-gateway/src/config/vocabulario.ts b/api-gateway/src/config/vocabulario.ts index d0a1e49..ed2e0b2 100644 --- a/api-gateway/src/config/vocabulario.ts +++ b/api-gateway/src/config/vocabulario.ts @@ -55,9 +55,9 @@ export const VOCABULARIO = { Timing: ['Compra Posterior', 'Esperando Credito', 'Esperando Venta de Propiedad', 'Esperando Proyecto'], }, // Labels de la columna 'Origen' (board Leads) / 'Origen del Lead' - // (Oportunidades), verificados y sembrados en Monday el 2026-09-04 + // (afluencia), verificados y sembrados el 2026-09-04 ORIGENES: ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'BROCKER', 'OTRO'], - // Labels reales de la columna status 'Desarrollo' del board Leads (verificados en Monday) + // Labels oficiales de desarrollo (verificados el 2026-09-04) DESARROLLOS: ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'], } as const diff --git a/api-gateway/src/controllers/apartados.controller.test.ts b/api-gateway/src/controllers/apartados.controller.test.ts index fd33b27..ea1f4db 100644 --- a/api-gateway/src/controllers/apartados.controller.test.ts +++ b/api-gateway/src/controllers/apartados.controller.test.ts @@ -1,13 +1,11 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' import type { Request, Response } from 'express' import { - cambiarEstadoApartado, cambiarEstadoApartadoEndpoint, FASES_APARTADO_PERMITIDAS, subirDocumentosApartado, TIPOS_DOCUMENTO_APARTADO, } from './apartados.controller.js' -import { saludMonday } from '../services/salud-monday.service.js' // ---------- Prisma mockeado (los tests no tocan la BD) ---------- @@ -25,9 +23,6 @@ const prismaMock = vi.hoisted(() => ({ documento: { create: vi.fn(), }, - syncPendiente: { - create: vi.fn(), - }, auditoriaLog: { create: vi.fn().mockResolvedValue({}), }, @@ -36,75 +31,7 @@ const prismaMock = vi.hoisted(() => ({ vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) -/** Monday mockeado: registra cambios de columna y movimientos de grupo */ -function mondayMock(grupoActual = 'topics') { - return { - cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[], - movimientos: [] as { itemId: string; grupo: string }[], - async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) { - this.cambios.push({ boardId, itemId, columna, valor }) - }, - async columnaIdPorTitulo(_boardId: string, titulo: string) { - return titulo === 'Estado' ? 'status' : null - }, - async getItemConGrupo(itemId: string) { - return { - id: itemId, - name: 'Cliente Demo', - groupId: grupoActual, - column_values: [ - { id: 'status', text: (this.cambios.find((c) => c.columna === 'Estado')?.valor as string) ?? '', value: '{}' }, - ], - } - }, - async moverItemAGrupo(itemId: string, grupo: string) { - this.movimientos.push({ itemId, grupo }) - }, - } -} - -describe('cambiarEstadoApartado (POST /api/apartados/:id/estado)', () => { - it('cambia la fase (board 666 = MONDAY_BOARD_APARTADO de test)', async () => { - const monday = mondayMock() - await cambiarEstadoApartado(monday, 'apt-1', 'Alta Enkontrol') - expect(monday.cambios).toEqual([ - { boardId: '666', itemId: 'apt-1', columna: 'Estado', valor: 'Alta Enkontrol' }, - ]) - // 'Alta Enkontrol' mapea al grupo topics: ya estaba ahí → no se mueve - expect(monday.movimientos).toEqual([]) - }) - - it('Alta Enkontrol se queda en "Nuevos Clientes" (grupo topics)', async () => { - const monday = mondayMock() - await cambiarEstadoApartado(monday, 'apt-2', 'Alta Enkontrol') - expect(monday.movimientos).toEqual([]) - }) - - it('Cancelacion Apartado mueve el item a "Historico"', async () => { - const monday = mondayMock() - await cambiarEstadoApartado(monday, 'apt-3', 'Cancelacion Apartado') - expect(monday.movimientos).toEqual([{ itemId: 'apt-3', grupo: 'group_mm6s2yyx' }]) - }) - - it('las fases permitidas son los labels reales del board Apartado', () => { - expect(FASES_APARTADO_PERMITIDAS).toEqual([ - 'Pago Apartado', - 'Alta Enkontrol', - 'Entrego Documentacion', - 'Cita Clientes', - 'Contrato Enkontrol', - 'Cancelacion Apartado', - ]) - }) - - it('los tipos de documento de la precarga son capturas obligatorias + generales', () => { - expect(TIPOS_DOCUMENTO_APARTADO).toEqual(['CAPTURA_COTIZADOR', 'CAPTURA_PAGO_APARTADO', 'OTRO']) - }) -}) - -// ---------- Modo local (saludMonday caído / ids loc_*) ---------- - -describe('ruteo modo local (apartados)', () => { +describe('cambiarEstadoApartadoEndpoint (POST /api/apartados/:id/estado) — local', () => { function resFake() { const json = vi.fn() const status = vi.fn().mockReturnValue({ json }) @@ -113,14 +40,10 @@ describe('ruteo modo local (apartados)', () => { beforeEach(() => { vi.clearAllMocks() + prismaMock.auditoriaLog.create.mockResolvedValue({}) prismaMock.leadCaptura.findUnique.mockResolvedValue(null) - prismaMock.leadCaptura.findFirst.mockResolvedValue(null) prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data })) prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data })) - prismaMock.cliente.findFirst.mockResolvedValue(null) - prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-local', ...data })) - prismaMock.documento.create.mockImplementation(async ({ data }) => ({ id: 'doc-1', ...data })) - prismaMock.syncPendiente.create.mockResolvedValue({}) prismaMock.$transaction.mockImplementation(async (ops: Promise[]) => Promise.all(ops)) }) @@ -128,8 +51,7 @@ describe('ruteo modo local (apartados)', () => { vi.restoreAllMocks() }) - it('cambiarEstadoApartadoEndpoint con id loc_*: fase local + CAMBIO_ETAPA encolado', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true) // el id loc_* manda aunque Monday viva + it('con id loc_*: fase en la fila local', async () => { prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_apt', board: 'APARTADO' }) const { res, json } = resFake() @@ -142,14 +64,77 @@ describe('ruteo modo local (apartados)', () => { where: { leadId: 'loc_apt' }, data: { etapa: 'Alta Enkontrol', pendienteSync: true }, }) - expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ tipo: 'CAMBIO_ETAPA', referenciaLocal: 'loc_apt' }), - }) expect(json).toHaveBeenCalledWith({ itemId: 'loc_apt', estado: 'Alta Enkontrol' }) }) - it('subirDocumentosApartado con Monday caído: nombre desde LeadCaptura local y checkbox encolado (CHECKBOX_DOC)', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) + it('con id heredado sin fila local: crea la sombra (mondayItemId)', async () => { + const { res, json } = resFake() + + await cambiarEstadoApartadoEndpoint( + { params: { itemId: '99887766' }, body: { estado: 'Cancelacion Apartado' } } as unknown as Request, + res, + ) + + expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ leadId: '99887766', board: 'APARTADO', mondayItemId: '99887766' }), + }) + expect(json).toHaveBeenCalledWith({ itemId: '99887766', estado: 'Cancelacion Apartado' }) + }) + + it('rechaza una fase fuera del vocabulario (zod 400)', async () => { + const { res } = resFake() + await expect( + cambiarEstadoApartadoEndpoint( + { params: { itemId: 'loc_apt' }, body: { estado: 'Fase Inventada' } } as unknown as Request, + res, + ), + ).rejects.toThrow() + expect(prismaMock.leadCaptura.update).not.toHaveBeenCalled() + }) + + it('las fases permitidas son el vocabulario canónico del CRM (pipeline Enkontrol)', () => { + expect(FASES_APARTADO_PERMITIDAS).toEqual([ + 'Pago Apartado', + 'Alta Enkontrol', + 'Entrego Documentacion', + 'Cita Clientes', + 'Contrato Enkontrol', + 'Firma Cliente', + 'Cancelacion Apartado', + 'Venta', + 'Seguimiento', + 'Integracion', + 'Llamada Escrituras', + 'X Escriturar', + 'Banco/Infonavit', + 'X Autorizar', + 'Autorizado', + 'Escriturado', + 'Titulado', + 'Cancelo', + ]) + }) + + it('los tipos de documento de la precarga son capturas obligatorias + generales', () => { + expect(TIPOS_DOCUMENTO_APARTADO).toEqual(['CAPTURA_COTIZADOR', 'CAPTURA_PAGO_APARTADO', 'OTRO']) + }) +}) + +describe('subirDocumentosApartado (POST /api/apartados/:id/documentos) — solo local', () => { + beforeEach(() => { + vi.clearAllMocks() + prismaMock.leadCaptura.findUnique.mockResolvedValue(null) + prismaMock.leadCaptura.findFirst.mockResolvedValue(null) + prismaMock.cliente.findFirst.mockResolvedValue(null) + prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-local', ...data })) + prismaMock.documento.create.mockImplementation(async ({ data }) => ({ id: 'doc-1', ...data })) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('crea el cliente mínimo con el nombre de la fila local y guarda el documento', async () => { // El apartado local nació del "Aparto" de la oportunidad: su fila tiene el nombre prismaMock.leadCaptura.findFirst.mockResolvedValue({ leadId: 'loc_apt', nombreLead: 'Luis Demo' }) const archivo = { @@ -158,27 +143,46 @@ describe('ruteo modo local (apartados)', () => { mimetype: 'application/pdf', size: 1234, } as Express.Multer.File - const { res, status } = resFake() + const json = vi.fn() + const status = vi.fn().mockReturnValue({ json }) + const res = { json, status } as unknown as Response await subirDocumentosApartado( { params: { itemId: 'loc_apt' }, body: { tipo: 'CURP' }, files: [archivo], usuarioToken: { sub: 'u-1' } } as unknown as Request, res, ) - // Cliente mínimo creado con el nombre de la fila local (no truena por obtenerNombreItem) expect(prismaMock.cliente.create).toHaveBeenCalledWith({ data: expect.objectContaining({ nombreCompleto: 'Luis Demo', mondayApartadoId: 'loc_apt' }), }) expect(prismaMock.documento.create).toHaveBeenCalledWith({ data: expect.objectContaining({ clienteId: 'cli-local', tipo: 'CURP' }), }) - // El checkbox no se intenta en Monday: queda encolado para el worker - expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ - tipo: 'CHECKBOX_DOC', - payloadJson: JSON.stringify({ checkbox: 'CURP', itemId: 'loc_apt' }), - referenciaLocal: 'loc_apt', - }), + // Solo local: nada en la cola de réplica + expect(status).toHaveBeenCalledWith(201) + }) + + it('sin fila local usa "Cliente" como nombre y guarda igual', async () => { + const archivo = { + originalname: 'ine.pdf', + path: '/tmp/uploads/apartados/loc_x/OTRO_2.pdf', + mimetype: 'application/pdf', + size: 42, + } as Express.Multer.File + const json = vi.fn() + const status = vi.fn().mockReturnValue({ json }) + const res = { json, status } as unknown as Response + + await subirDocumentosApartado( + { params: { itemId: 'loc_x' }, body: {}, files: [archivo] } as unknown as Request, + res, + ) + + expect(prismaMock.cliente.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ nombreCompleto: 'Cliente' }), + }) + expect(prismaMock.documento.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ tipo: 'OTRO' }), }) expect(status).toHaveBeenCalledWith(201) }) diff --git a/api-gateway/src/controllers/apartados.controller.ts b/api-gateway/src/controllers/apartados.controller.ts index 66d682e..0d708b0 100644 --- a/api-gateway/src/controllers/apartados.controller.ts +++ b/api-gateway/src/controllers/apartados.controller.ts @@ -5,41 +5,17 @@ import path from 'node:path' import multer from 'multer' import { z } from 'zod' import { config } from '../config/index.js' -import { mondayService, type MondayService } from '../services/monday.service.js' -import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js' +import { VOCABULARIO } from '../config/vocabulario.js' import { prisma } from '../services/prisma.js' -import { saludMonday } from '../services/salud-monday.service.js' -import { crmLocal, encolarSyncSeguro, esIdLocal } from '../services/crm-local.service.js' -import { esFalloMonday } from '../services/sync-monday.service.js' -import { checkboxParaTipoDocumento } from './clientes.controller.js' +import { crmLocal, esIdLocal } from '../services/crm-local.service.js' import * as auditoria from '../services/auditoria.service.js' /** * Pipeline Enkontrol del tablero Apartado: las fases que el vendedor puede - * mover desde los botones de "Mis apartados" en Mi día. Son los labels REALES - * de la columna status 'Estado' del board (verificados en Monday) — el - * frontend los manda tal cual. + * mover desde los botones de "Mis apartados" en Mi día. Es el vocabulario + * canónico del CRM (config/vocabulario.ts) — el frontend los manda tal cual. */ -export const FASES_APARTADO_PERMITIDAS = [ - 'Pago Apartado', - 'Alta Enkontrol', - 'Entrego Documentacion', - 'Cita Clientes', - 'Contrato Enkontrol', - 'Firma Cliente', - 'Cancelacion Apartado', - 'Venta', - 'Seguimiento', - 'Integracion', - 'Llamada Escrituras', - 'X Escriturar', - 'Banco/Infonavit', - 'X Autorizar', - 'Autorizado', - 'Escriturado', - 'Titulado', - 'Cancelo', -] as const +export const FASES_APARTADO_PERMITIDAS = VOCABULARIO.FASES_APARTADO const cambiarEstadoSchema = z.object({ estado: z.enum(FASES_APARTADO_PERMITIDAS), @@ -48,48 +24,18 @@ const cambiarEstadoSchema = z.object({ comentario: z.string().optional(), }) -type MondayApartados = Pick< - MondayService, - 'cambiarValorColumna' | 'columnaIdPorTitulo' | 'getItemConGrupo' | 'moverItemAGrupo' -> - /** - * Cambia la fase del apartado (columna 'Estado') y mueve el item al grupo que - * corresponde (idempotente; el webhook de change haría lo mismo, pero así la - * UI responde al instante). + * Cambia la fase del apartado (etapa de la fila local) y encola el cambio para + * la réplica. SIEMPRE local: la fila LeadCaptura board=APARTADO es la verdad; + * ids heredados del CRM anterior sin fila crean sombra. */ -export async function cambiarEstadoApartado( - monday: MondayApartados, - itemId: string, - estado: (typeof FASES_APARTADO_PERMITIDAS)[number], -): Promise { - const boardApartado = config.monday.boards.apartado - await monday.cambiarValorColumna(boardApartado, itemId, 'Estado', estado) - await moverAGrupoSegunEstado(monday, boardApartado, itemId) -} - -/** POST /api/apartados/:itemId/estado { estado } */ export async function cambiarEstadoApartadoEndpoint(req: Request, res: Response): Promise { const body = cambiarEstadoSchema.parse(req.body) const itemId = String(req.params['itemId']) - // Modo local (id loc_* o Monday caído / fallback 502): fase en la fila local - // + CAMBIO_ETAPA encolado; ids Monday viejos sin fila crean sombra - if (esIdLocal(itemId) || !saludMonday.estaDisponible()) { - await crmLocal.cambiarEstado(itemId, body.estado, 'APARTADO', { - usuarioId: req.usuarioToken?.sub, - sombra: !esIdLocal(itemId), - }) - } else { - try { - await cambiarEstadoApartado(mondayService, itemId, body.estado) - } catch (err) { - if (!esFalloMonday(err)) throw err - await crmLocal.cambiarEstado(itemId, body.estado, 'APARTADO', { - usuarioId: req.usuarioToken?.sub, - sombra: true, - }) - } - } + await crmLocal.cambiarEstado(itemId, body.estado, 'APARTADO', { + usuarioId: req.usuarioToken?.sub, + sombra: !esIdLocal(itemId), + }) res.json({ itemId, estado: body.estado }) const detalle = body.motivo ? `${body.estado} — motivo: ${body.motivo}${body.subcategoria ? ` / ${body.subcategoria}` : ''}${body.comentario ? ` (${body.comentario})` : ''}` @@ -114,7 +60,7 @@ const EXT_POR_MIME_APARTADO: Record = { 'image/png': 'png', } -// Storage propio: archiva por item de Monday (uploads/apartados//) +// Storage propio: archiva por apartado (uploads/apartados//) const storageApartados = multer.diskStorage({ destination: (req, _file, cb) => { const dir = path.join(config.uploadsDir, 'apartados', String(req.params['itemId'])) @@ -141,30 +87,18 @@ export const uploadDocumentosApartado = multer({ }) /** - * Cliente local ligado al apartado de Monday; si aún no existe (apartado - * creado sin pasar por el alta progresiva), se crea la ficha mínima para que - * los documentos tengan dónde vivir. El nombre se toma de Monday; si Monday - * está caído o el id es local (loc_*), se usa el de la fila LeadCaptura del - * apartado (board APARTADO, creada por el "Aparto" local) como fallback. + * Cliente local ligado al apartado; si aún no existe (apartado creado sin + * pasar por el alta progresiva), se crea la ficha mínima para que los + * documentos tengan dónde vivir. El nombre sale de la fila LeadCaptura del + * apartado (creada por el "Aparto" local o por la sombra de un item viejo). */ async function clienteDeApartado(itemId: string, capturadoPorId?: string) { const existente = await prisma.cliente.findFirst({ where: { mondayApartadoId: itemId } }) if (existente) return existente - let nombre: string | null = null - if (!esIdLocal(itemId) && saludMonday.estaDisponible()) { - try { - nombre = await mondayService.obtenerNombreItem(itemId) - } catch (err) { - // eslint-disable-next-line no-console - console.error(`No se pudo leer el nombre del apartado ${itemId} en Monday; se usa el nombre local:`, err) - } - } - if (nombre === null) { - const captura = await prisma.leadCaptura.findFirst({ - where: { OR: [{ leadId: itemId }, { mondayItemId: itemId }] }, - }) - nombre = captura?.nombreLead ?? 'Cliente' - } + const captura = await prisma.leadCaptura.findFirst({ + where: { OR: [{ leadId: itemId }, { mondayItemId: itemId }] }, + }) + const nombre = captura?.nombreLead ?? 'Cliente' return prisma.cliente.create({ data: { nombreCompleto: nombre, @@ -180,9 +114,8 @@ async function clienteDeApartado(itemId: string, capturadoPorId?: string) { /** * POST /api/apartados/:itemId/documentos — multipart, campo `archivos` (hasta - * 10) + campo `tipo`. Los documentos se guardan en el CLIENTE ligado al - * apartado (Documento.clienteId); si el tipo tiene checkbox en el tablero - * Apartado (CURP/RFC/Comprobante), se prende en Monday. + * 10) + campo `tipo`. SOLO local: los documentos se guardan en el CLIENTE + * ligado al apartado (Documento.clienteId). */ export async function subirDocumentosApartado(req: Request, res: Response): Promise { const itemId = String(req.params['itemId']) @@ -204,24 +137,6 @@ export async function subirDocumentosApartado(req: Request, res: Response): Prom }), ), ) - - // Prende el checkbox del documento en el item de Apartado (si el tipo tiene - // checkbox); un fallo de Monday no rompe la subida: queda encolado - // (CHECKBOX_DOC) y el worker lo aplica al sincronizar. - const checkbox = checkboxParaTipoDocumento(tipo) - if (checkbox !== null) { - if (esIdLocal(itemId) || !saludMonday.estaDisponible()) { - await encolarSyncSeguro('CHECKBOX_DOC', { checkbox, itemId }, itemId) - } else { - try { - await mondayService.cambiarValorColumna(config.monday.boards.apartado, itemId, checkbox, true) - } catch (err) { - // eslint-disable-next-line no-console - console.error(`No se pudo marcar el checkbox "${checkbox}" del apartado ${itemId}:`, err) - await encolarSyncSeguro('CHECKBOX_DOC', { checkbox, itemId }, itemId) - } - } - } res.status(201).json(documentos) } diff --git a/api-gateway/src/controllers/automatizaciones.controller.ts b/api-gateway/src/controllers/automatizaciones.controller.ts index 0d1943f..78ea69b 100644 --- a/api-gateway/src/controllers/automatizaciones.controller.ts +++ b/api-gateway/src/controllers/automatizaciones.controller.ts @@ -6,12 +6,12 @@ export async function estadoAutomatizaciones(_req: Request, res: Response): Prom res.json(await automatizacionesService.estado()) } -/** POST /api/automatizaciones/round-robin/run — pasada manual de asignación */ +/** POST /api/automatizaciones/round-robin/run — pasada manual de asignación (local) */ export async function correrRoundRobin(_req: Request, res: Response): Promise { - res.json(await automatizacionesService.runRoundRobin()) + res.json(await automatizacionesService.runRoundRobinLocal()) } /** GET /api/automatizaciones/inactividad/preview — leads que serían recordados hoy (sin escribir) */ export async function previewInactividad(_req: Request, res: Response): Promise { - res.json(await automatizacionesService.escaneoInactividad({ preview: true })) + res.json(await automatizacionesService.escaneoInactividadLocal({ preview: true })) } diff --git a/api-gateway/src/controllers/clientes-progreso.controller.test.ts b/api-gateway/src/controllers/clientes-progreso.controller.test.ts deleted file mode 100644 index f0e3725..0000000 --- a/api-gateway/src/controllers/clientes-progreso.controller.test.ts +++ /dev/null @@ -1,356 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Request, Response } from 'express' -import { contactoDesdeDatos, textoDireccion } from './clientes.controller.js' - -// ---------- Mocks de módulo (los tests no tocan BD ni red) ---------- - -const prismaMock = vi.hoisted(() => ({ - cliente: { - create: vi.fn(), - findUnique: vi.fn(), - update: vi.fn(), - }, - usuario: { - findUnique: vi.fn(), - }, -})) - -const mondayMock = vi.hoisted(() => ({ - crearContacto: vi.fn(), - actualizarContacto: vi.fn(), - crearApartado: vi.fn(), -})) - -const funnelMock = vi.hoisted(() => ({ - moverAApartoConDedup: vi.fn(), -})) - -vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) -vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock })) -vi.mock('../services/funnel.service.js', () => ({ funnelService: funnelMock })) - -const reqFake = (body: unknown, params: Record = {}): Request => - ({ body, params }) as unknown as Request - -const resFake = () => { - const json = vi.fn() - const status = vi.fn().mockReturnValue({ json }) - return { res: { json, status } as unknown as Response, status, json } -} - -const datosWizard = { - datosGenerales: { - nombreCompleto: 'Ana Demo Progreso', - curp: 'AEDA900101MBCXXX00', - rfc: 'AEDA900101XX0', - email: 'ana@correo.com', - celular: '6649990003', - estadoCivil: 'SOLTERO', - tipoVivienda: 'RENTADA', - tipoCompra: 'VIVIR_AHI', - fechaNacimiento: '1990-01-01', - }, - empleo: { tipoEmpleo: 'ASALARIADO' }, - viviendaCredito: { - proyecto: 'DIAGONAL', - domicilioFiscal: { - calle: 'Av. Siempre Viva', - numExt: '123', - numInt: '4', - colonia: 'Col. Centro', - cp: '22000', - municipio: 'Tijuana', - estado: 'Baja California', - }, - }, -} - -beforeEach(() => { - vi.clearAllMocks() - prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data })) - prismaMock.cliente.update.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data })) - prismaMock.usuario.findUnique.mockResolvedValue(null) // default: capturista sin Monday vinculado - mondayMock.crearContacto.mockResolvedValue('cont-1') - mondayMock.actualizarContacto.mockResolvedValue(undefined) - mondayMock.crearApartado.mockResolvedValue('ap-1') - funnelMock.moverAApartoConDedup.mockResolvedValue('op-1') -}) - -describe('textoDireccion (domicilio del wizard → columna Dirección)', () => { - it('arma calle con ext/int, colonia, CP, municipio y estado', () => { - expect( - textoDireccion({ - calle: 'Av. Siempre Viva', - numExt: '123', - numInt: '4', - colonia: 'Col. Centro', - cp: '22000', - municipio: 'Tijuana', - estado: 'Baja California', - }), - ).toBe('Av. Siempre Viva 123, Int. 4, Col. Centro, CP 22000, Tijuana, Baja California') - }) - - it('omite las partes vacías y devuelve undefined si no hay nada', () => { - expect(textoDireccion({ calle: 'Calle Uno', municipio: 'Tijuana' })).toBe('Calle Uno, Tijuana') - expect(textoDireccion({})).toBeUndefined() - expect(textoDireccion({ calle: ' ' })).toBeUndefined() - }) -}) - -describe('contactoDesdeDatos (estado del wizard → campos del Contacto)', () => { - it('deriva todos los campos mapeables presentes', () => { - const c = contactoDesdeDatos(datosWizard) - expect(c).toMatchObject({ - nombre: 'Ana Demo Progreso', - curp: 'AEDA900101MBCXXX00', - rfc: 'AEDA900101XX0', - email: 'ana@correo.com', - telefono: '6649990003', - direccion: 'Av. Siempre Viva 123, Int. 4, Col. Centro, CP 22000, Tijuana, Baja California', - estadoCivilLabel: 'Soltero', - viviendaActualLabel: 'Rentada', - propositoCompraLabel: 'Habitarla', - rangoEdadLabel: 'Entre 24 y 40', - fechaNacimiento: '1990-01-01', - actividadEconomicaLabel: 'Empleado', - }) - }) - - it('solo incluye campos presentes (no inventa datos)', () => { - const c = contactoDesdeDatos({ datosGenerales: { nombreCompleto: 'Solo Nombre' } }) - expect(c).toEqual({ nombre: 'Solo Nombre' }) - }) - - it('sin domicilioFiscal usa el texto libre datosGenerales.direccion', () => { - const c = contactoDesdeDatos({ - datosGenerales: { nombreCompleto: 'Ana', direccion: 'Calle Libre 1' }, - }) - expect(c.direccion).toBe('Calle Libre 1') - }) -}) - -describe('iniciarProgreso (POST /api/clientes/progreso/iniciar)', () => { - it('crea el Cliente local y el Contacto en Monday con Estado PROSPECTO', async () => { - const { iniciarProgreso } = await import('./clientes.controller.js') - const { res, status, json } = resFake() - await iniciarProgreso( - reqFake({ - datosGenerales: { - nombreCompleto: 'Ana Demo Progreso', - curp: 'AEDA900101MBCXXX00', - rfc: 'AEDA900101XX0', - celular: '6649990003', - }, - }), - res, - ) - expect(status).toHaveBeenCalledWith(201) - expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondayContactoId: 'cont-1' }) - expect(mondayMock.crearContacto).toHaveBeenCalledWith({ - nombre: 'Ana Demo Progreso', - estadoLabel: 'PROSPECTO', - curp: 'AEDA900101MBCXXX00', - rfc: 'AEDA900101XX0', - telefono: '6649990003', - }) - expect(prismaMock.cliente.update).toHaveBeenCalledWith({ - where: { id: 'cli-1' }, - data: { mondayContactoId: 'cont-1' }, - }) - }) - - it('requiere solo datosGenerales.nombreCompleto', async () => { - const { iniciarProgreso } = await import('./clientes.controller.js') - const { res, status } = resFake() - await iniciarProgreso(reqFake({ datosGenerales: { nombreCompleto: 'Solo Nombre' } }), res) - expect(status).toHaveBeenCalledWith(201) - expect(mondayMock.crearContacto).toHaveBeenCalledWith({ - nombre: 'Solo Nombre', - estadoLabel: 'PROSPECTO', - }) - await expect(iniciarProgreso(reqFake({ datosGenerales: {} }), resFake().res)).rejects.toThrow() - }) - - it('fallo de Monday: crea el local igual y devuelve mondayContactoId null', async () => { - const { iniciarProgreso } = await import('./clientes.controller.js') - mondayMock.crearContacto.mockRejectedValue(new Error('Monday caído')) - const { res, status, json } = resFake() - await iniciarProgreso(reqFake({ datosGenerales: { nombreCompleto: 'Ana Demo' } }), res) - expect(status).toHaveBeenCalledWith(201) - expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondayContactoId: null }) - }) - - it('guarda capturadoPorId con el sub del JWT (null sin token)', async () => { - const { iniciarProgreso } = await import('./clientes.controller.js') - const req = reqFake({ datosGenerales: { nombreCompleto: 'Ana Demo' } }) - ;(req as unknown as { usuarioToken: { sub: string } }).usuarioToken = { sub: 'usr-1' } - await iniciarProgreso(req, resFake().res) - expect(prismaMock.cliente.create).toHaveBeenCalledWith( - expect.objectContaining({ data: expect.objectContaining({ capturadoPorId: 'usr-1' }) }), - ) - await iniciarProgreso(reqFake({ datosGenerales: { nombreCompleto: 'Ana Demo' } }), resFake().res) - expect(prismaMock.cliente.create).toHaveBeenLastCalledWith( - expect.objectContaining({ data: expect.objectContaining({ capturadoPorId: null }) }), - ) - }) -}) - -describe('actualizarProgreso (PATCH /api/clientes/progreso/:id)', () => { - it('deriva los campos presentes y actualiza el Contacto (incl. Dirección armada)', async () => { - const { actualizarProgreso } = await import('./clientes.controller.js') - prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1', mondayContactoId: 'cont-1' }) - const { res, json } = resFake() - await actualizarProgreso(reqFake(datosWizard, { id: 'cli-1' }), res) - expect(prismaMock.cliente.update).toHaveBeenCalledWith({ - where: { id: 'cli-1' }, - data: { payloadJson: JSON.stringify(datosWizard) }, - }) - expect(mondayMock.actualizarContacto).toHaveBeenCalledWith( - 'cont-1', - expect.objectContaining({ - nombre: 'Ana Demo Progreso', - direccion: 'Av. Siempre Viva 123, Int. 4, Col. Centro, CP 22000, Tijuana, Baja California', - actividadEconomicaLabel: 'Empleado', - telefono: '6649990003', - }), - ) - expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondaySync: true }) - }) - - it('sin mondayContactoId responde mondaySync: false y no toca Monday', async () => { - const { actualizarProgreso } = await import('./clientes.controller.js') - prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1', mondayContactoId: null }) - const { res, json } = resFake() - await actualizarProgreso(reqFake(datosWizard, { id: 'cli-1' }), res) - expect(mondayMock.actualizarContacto).not.toHaveBeenCalled() - expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondaySync: false }) - }) - - it('fallo de Monday: 200 igual con mondaySync: false', async () => { - const { actualizarProgreso } = await import('./clientes.controller.js') - prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1', mondayContactoId: 'cont-1' }) - mondayMock.actualizarContacto.mockRejectedValue(new Error('Monday caído')) - const { res, json } = resFake() - await actualizarProgreso(reqFake(datosWizard, { id: 'cli-1' }), res) - expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondaySync: false }) - }) - - it('rechaza un body que no sea objeto', async () => { - const { actualizarProgreso } = await import('./clientes.controller.js') - await expect(actualizarProgreso(reqFake('no-objeto', { id: 'cli-1' }), resFake().res)).rejects.toThrow() - }) -}) - -describe('completarProgreso (POST /api/clientes/progreso/:id/completar)', () => { - const altaCompleta = { - ...datosWizard, - viviendaCredito: { - ...datosWizard.viviendaCredito, - ubicacion: 'U-999-DEMO2', - valorVenta: 1500000, - enganche: 20000, - }, - firma: { firmaDataUrl: 'data:image/png;base64,AAAA' }, - leadMondayId: 'lead-9', - } - - it('pasa el Contacto a CLIENTE, crea el Apartado y mueve el lead a Aparto (dedup)', async () => { - const { completarProgreso } = await import('./clientes.controller.js') - const { prismaStateStore } = await import('../services/automatizaciones.service.js') - prismaMock.cliente.findUnique.mockResolvedValue({ - id: 'cli-1', - mondayContactoId: 'cont-1', - mondayApartadoId: null, - }) - const { res, status, json } = resFake() - await completarProgreso(reqFake(altaCompleta, { id: 'cli-1' }), res) - expect(status).toHaveBeenCalledWith(201) - expect(mondayMock.actualizarContacto).toHaveBeenCalledWith( - 'cont-1', - expect.objectContaining({ nombre: 'Ana Demo Progreso', estadoLabel: 'CLIENTE' }), - ) - expect(mondayMock.crearApartado).toHaveBeenCalledWith( - expect.objectContaining({ - clienteNombre: 'Ana Demo Progreso', - unidadCodigo: 'U-999-DEMO2', - montoApartado: 20000, - estadoLabel: 'Pago Apartado', - }), - ) - expect(funnelMock.moverAApartoConDedup).toHaveBeenCalledWith('lead-9', prismaStateStore) - expect(json.mock.calls[0]?.[0]).toMatchObject({ - id: 'cli-1', - mondayContactoId: 'cont-1', - mondayApartadoId: 'ap-1', - }) - }) - - it('el Apartado lleva la ficha completa: contacto, precio, Forma de Pago y Vendedor del capturista original', async () => { - const { completarProgreso } = await import('./clientes.controller.js') - prismaMock.cliente.findUnique.mockResolvedValue({ - id: 'cli-1', - mondayContactoId: 'cont-1', - mondayApartadoId: null, - capturadoPorId: 'usr-9', // quien inició el alta progresiva - }) - prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 }) - const altaConCredito = { - ...altaCompleta, - viviendaCredito: { ...altaCompleta.viviendaCredito, tiposCredito: ['COFI_FOVISSSTE'] }, - } - const { res, status } = resFake() - await completarProgreso(reqFake(altaConCredito, { id: 'cli-1' }), res) - expect(status).toHaveBeenCalledWith(201) - expect(mondayMock.crearApartado).toHaveBeenCalledWith( - expect.objectContaining({ - clienteNombre: 'Ana Demo Progreso', - unidadCodigo: 'U-999-DEMO2', - telefono: '6649990003', - email: 'ana@correo.com', - curp: 'AEDA900101MBCXXX00', - precioFinal: 1500000, - enganche: 20000, - vendedorId: 12345, - formaPagoLabel: 'Cofi-Fovissste', - fechaCompra: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), - }), - ) - }) - - it('sin Contacto previo lo crea directo como CLIENTE (alta sin Monday al iniciar)', async () => { - const { completarProgreso } = await import('./clientes.controller.js') - prismaMock.cliente.findUnique.mockResolvedValue({ - id: 'cli-1', - mondayContactoId: null, - mondayApartadoId: null, - }) - const { res, status } = resFake() - await completarProgreso(reqFake(altaCompleta, { id: 'cli-1' }), res) - expect(status).toHaveBeenCalledWith(201) - expect(mondayMock.actualizarContacto).not.toHaveBeenCalled() - expect(mondayMock.crearContacto).toHaveBeenCalledWith( - expect.objectContaining({ nombre: 'Ana Demo Progreso', estadoLabel: 'CLIENTE' }), - ) - }) - - it('rechaza un payload incompleto (misma validación que POST /clientes)', async () => { - const { completarProgreso } = await import('./clientes.controller.js') - prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1', mondayContactoId: 'cont-1' }) - await expect( - completarProgreso( - reqFake({ datosGenerales: { nombreCompleto: 'Sin curp ni proyecto' } }, { id: 'cli-1' }), - resFake().res, - ), - ).rejects.toThrow() - expect(prismaMock.cliente.update).not.toHaveBeenCalled() - }) - - it('404 si el progreso no existe', async () => { - const { completarProgreso } = await import('./clientes.controller.js') - prismaMock.cliente.findUnique.mockResolvedValue(null) - await expect( - completarProgreso(reqFake(altaCompleta, { id: 'no-existe' }), resFake().res), - ).rejects.toThrow('Cliente no encontrado') - }) -}) diff --git a/api-gateway/src/controllers/clientes.controller.test.ts b/api-gateway/src/controllers/clientes.controller.test.ts index 6d9084a..3ec5fd3 100644 --- a/api-gateway/src/controllers/clientes.controller.test.ts +++ b/api-gateway/src/controllers/clientes.controller.test.ts @@ -1,45 +1,40 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Request, Response } from 'express' -import { - actividadEconomicaLabel, - checkboxParaTipoDocumento, - perfilDemografico, - rangoEdadLabel, -} from './clientes.controller.js' +import { checkboxParaTipoDocumento, formaPagoLabel } from './clientes.controller.js' // ---------- Mocks de módulo (los tests no tocan BD ni red) ---------- const prismaMock = vi.hoisted(() => ({ cliente: { create: vi.fn(), + findUnique: vi.fn(), update: vi.fn(), }, + leadCaptura: { + create: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + }, + leadOportunidad: { + findUnique: vi.fn(), + }, usuario: { findUnique: vi.fn(), }, - syncPendiente: { - create: vi.fn(), + automatizacionState: { + findUnique: vi.fn(), + upsert: vi.fn(), }, auditoriaLog: { create: vi.fn().mockResolvedValue({}), }, -})) - -const mondayMock = vi.hoisted(() => ({ - crearContacto: vi.fn(), - crearApartado: vi.fn(), -})) - -const funnelMock = vi.hoisted(() => ({ - moverAApartoConDedup: vi.fn(), + $transaction: vi.fn(), })) vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) -vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock })) -vi.mock('../services/funnel.service.js', () => ({ funnelService: funnelMock })) -describe('checkboxParaTipoDocumento (Apartado → checkboxes de documentos)', () => { - it('mapea los tipos con checkbox al título de la columna', () => { +describe('checkboxParaTipoDocumento (tipos de documento del expediente)', () => { + it('mapea los tipos con checkbox al nombre del checkbox', () => { expect(checkboxParaTipoDocumento('CURP')).toBe('CURP') expect(checkboxParaTipoDocumento('RFC')).toBe('RFC') expect(checkboxParaTipoDocumento('CONSTANCIA_RFC')).toBe('RFC') @@ -59,50 +54,26 @@ describe('checkboxParaTipoDocumento (Apartado → checkboxes de documentos)', () }) }) -describe('rangoEdadLabel', () => { - const hoy = new Date('2026-08-22T12:00:00') - - it('deriva el rango del CRM a partir de la fecha de nacimiento', () => { - expect(rangoEdadLabel('2003-01-01', hoy)).toBe('Menor de 24') // 23 años - expect(rangoEdadLabel('2002-08-22', hoy)).toBe('Entre 24 y 40') // cumple 24 hoy - expect(rangoEdadLabel('1990-05-10', hoy)).toBe('Entre 24 y 40') - expect(rangoEdadLabel('1986-08-21', hoy)).toBe('Entre 24 y 40') // 40 años: límite inclusivo - expect(rangoEdadLabel('1985-01-01', hoy)).toBe('Mayor de 40') +describe('formaPagoLabel (tipos de crédito del alta)', () => { + it('mapea los 8 valores del enum del formato', async () => { + const { formaPagoLabel } = await import('./clientes.controller.js') + expect(formaPagoLabel(['INFONAVIT'])).toBe('Infonavit') + expect(formaPagoLabel(['APOYO_INFONAVIT'])).toBe('Apoyo Infonavit') + expect(formaPagoLabel(['COFINAVIT'])).toBe('Cofinavit') + expect(formaPagoLabel(['SEGUNDO_CREDITO'])).toBe('Segundo crédito') + expect(formaPagoLabel(['CONTADO'])).toBe('Contado') + expect(formaPagoLabel(['FOVISSSTE'])).toBe('Fovissste') + expect(formaPagoLabel(['COFI_FOVISSSTE'])).toBe('Cofi-Fovissste') + expect(formaPagoLabel(['BANCARIO'])).toBe('Bancario') }) - it('devuelve undefined con fechas inválidas o ausentes', () => { - expect(rangoEdadLabel(undefined, hoy)).toBeUndefined() - expect(rangoEdadLabel('no-es-fecha', hoy)).toBeUndefined() - expect(rangoEdadLabel('2999-01-01', hoy)).toBeUndefined() - }) -}) - -describe('perfilDemografico (alta → Contacto Monday)', () => { - it('mapea los valores del formato a los labels del CRM', () => { - const perfil = perfilDemografico({ - estadoCivil: 'UNION_LIBRE', - tipoVivienda: 'FAMILIAR', - tipoCompra: 'VIVIR_AHI', - fechaNacimiento: '1990-05-10', - }) - expect(perfil.estadoCivilLabel).toBe('Union Libre') - expect(perfil.viviendaActualLabel).toBe('Comparte con familia') - expect(perfil.propositoCompraLabel).toBe('Habitarla') - expect(perfil.rangoEdadLabel).toBe('Entre 24 y 40') - expect(perfil.fechaNacimiento).toBe('1990-05-10') - }) - - it('omite campos vacíos o desconocidos (no inventa datos)', () => { - expect(perfilDemografico({})).toEqual({}) - expect(perfilDemografico({ estadoCivil: '', tipoVivienda: 'OTRA_COSA' })).toEqual({}) - }) -}) - -describe('actividadEconomicaLabel', () => { - it('solo ASALARIADO tiene equivalencia inequívoca (Empleado)', () => { - expect(actividadEconomicaLabel('ASALARIADO')).toBe('Empleado') - expect(actividadEconomicaLabel('INDEPENDIENTE')).toBeUndefined() // ambiguo: no se adivina - expect(actividadEconomicaLabel(undefined)).toBeUndefined() + it('toma el PRIMERO reconocido del arreglo y tolera desconocidos', async () => { + const { formaPagoLabel } = await import('./clientes.controller.js') + expect(formaPagoLabel(['OTRA_COSA', 'CONTADO'])).toBe('Contado') + expect(formaPagoLabel(['OTRA_COSA'])).toBeUndefined() + expect(formaPagoLabel([])).toBeUndefined() + expect(formaPagoLabel(undefined)).toBeUndefined() + expect(formaPagoLabel('CONTADO')).toBeUndefined() // no es arreglo }) }) @@ -125,53 +96,141 @@ describe('crearClienteSchema', () => { }) }) -describe('crearCliente (POST /api/clientes) — leadMondayId del funnel', () => { - // Import diferido para que los vi.mock de arriba apliquen al controller - const altaBase = { - datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123' }, - viviendaCredito: { proyecto: 'SENDA' }, - } +// ---------- Alta local (POST /api/clientes) ---------- - const reqFake = (body: unknown): Request => ({ body } as Request) - const resFake = () => { - const json = vi.fn() - const status = vi.fn().mockReturnValue({ json }) - return { res: { json, status } as unknown as Response, status, json } - } +const altaBase = { + datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123' }, + viviendaCredito: { proyecto: 'SENDA' }, +} +const reqFake = (body: unknown, params: Record = {}, usuarioToken?: { sub: string }): Request => + ({ body, params, usuarioToken } as unknown as Request) + +const resFake = () => { + const json = vi.fn() + const status = vi.fn().mockReturnValue({ json }) + return { res: { json, status } as unknown as Response, status, json } +} + +const oportunidadLocal = { + leadId: 'loc_op_9', + board: 'OPORTUNIDADES', + nombreLead: 'Lead Prueba', + usuarioId: 'u-1', + telefono: '4771234567', + email: null, + desarrollo: 'SENDA', + modelo: 'SOHO', + datosJson: null, +} + +describe('crearCliente (POST /api/clientes) — alta local', () => { beforeEach(() => { vi.clearAllMocks() prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data })) - prismaMock.cliente.update.mockResolvedValue({}) - prismaMock.usuario.findUnique.mockResolvedValue(null) // default: capturista sin Monday vinculado - mondayMock.crearContacto.mockResolvedValue('cont-1') - funnelMock.moverAApartoConDedup.mockResolvedValue('op-1') + prismaMock.cliente.update.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data })) + prismaMock.leadCaptura.findUnique.mockResolvedValue(oportunidadLocal) + prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data })) + prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data })) + prismaMock.leadOportunidad.findUnique.mockResolvedValue(null) + prismaMock.automatizacionState.findUnique.mockResolvedValue(null) + prismaMock.automatizacionState.upsert.mockResolvedValue({}) + prismaMock.$transaction.mockImplementation(async (ops: Promise[]) => Promise.all(ops)) }) - it('con leadMondayId mueve la oportunidad a Aparto (con dedup) antes de replicar en Monday', async () => { + it('201: persiste el Cliente en SQLite y responde mondayContactoId null', async () => { const { crearCliente } = await import('./clientes.controller.js') - const { prismaStateStore } = await import('../services/automatizaciones.service.js') - const { res, status } = resFake() - await crearCliente(reqFake({ ...altaBase, leadMondayId: 'lead-9' }), res) + const { res, status, json } = resFake() + + await crearCliente(reqFake(altaBase), res) + expect(status).toHaveBeenCalledWith(201) - expect(funnelMock.moverAApartoConDedup).toHaveBeenCalledWith('lead-9', prismaStateStore) + expect(prismaMock.cliente.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ nombreCompleto: 'Cliente Demo', proyecto: 'SENDA' }), + }) + expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayContactoId: null, mondayApartadoId: null }) + }) + + it('con leadMondayId mueve la oportunidad a Aparto y crea el apartado local (dedup)', async () => { + const { crearCliente } = await import('./clientes.controller.js') + // El id del picker es una oportunidad directa (modelo nuevo) + prismaMock.leadOportunidad.findUnique + .mockResolvedValueOnce({ oportunidadId: 'loc_op_9' }) // buscarPorOportunidadId + .mockResolvedValue(null) + const { res, status, json } = resFake() + + await crearCliente(reqFake({ ...altaBase, leadMondayId: 'loc_op_9' }), res) + + expect(status).toHaveBeenCalledWith(201) + // Apartado local creado (board APARTADO, primer paso del pipeline) y encolado + expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ board: 'APARTADO', etapa: 'Pago Apartado' }), + }) + // La ficha del cliente queda ligada al apartado local + expect(prismaMock.cliente.update).toHaveBeenCalledWith({ + where: { id: 'cli-1' }, + data: { mondayApartadoId: expect.stringMatching(/^loc_/) }, + }) + expect(json.mock.calls[0]?.[0]).toMatchObject({ + id: 'cli-1', + mondayContactoId: null, + mondayApartadoId: expect.stringMatching(/^loc_/), + }) + }) + + it('con leadMondayId de un lead histórico resuelve la oportunidad vía mapping', async () => { + const { crearCliente } = await import('./clientes.controller.js') + prismaMock.leadOportunidad.findUnique + .mockResolvedValueOnce(null) // no es oportunidad directa + .mockResolvedValueOnce({ oportunidadId: 'loc_op_9' }) // mapping lead → oportunidad + const { res, status } = resFake() + + await crearCliente(reqFake({ ...altaBase, leadMondayId: 'loc_lead_1' }), res) + + expect(status).toHaveBeenCalledWith(201) + expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ board: 'APARTADO' }), + }) }) it('sin leadMondayId no toca el funnel', async () => { const { crearCliente } = await import('./clientes.controller.js') const { res, status } = resFake() + await crearCliente(reqFake(altaBase), res) + expect(status).toHaveBeenCalledWith(201) - expect(funnelMock.moverAApartoConDedup).not.toHaveBeenCalled() + expect(prismaMock.leadOportunidad.findUnique).not.toHaveBeenCalled() + expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled() + }) + + it('con apartadoMondayId (botón "Alta Enkontrol" desde Mis apartados) LIGA ese apartado sin crear otro', async () => { + const { crearCliente } = await import('./clientes.controller.js') + const { res, status, json } = resFake() + + await crearCliente(reqFake({ ...altaBase, apartadoMondayId: 'loc_apt_existente' }), res) + + expect(status).toHaveBeenCalledWith(201) + expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled() + expect(prismaMock.cliente.update).toHaveBeenCalledWith({ + where: { id: 'cli-1' }, + data: { mondayApartadoId: 'loc_apt_existente' }, + }) + expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayApartadoId: 'loc_apt_existente' }) }) it('un fallo del funnel no rompe el alta (201 igual)', async () => { const { crearCliente } = await import('./clientes.controller.js') - funnelMock.moverAApartoConDedup.mockRejectedValue(new Error('Monday caído')) + prismaMock.leadOportunidad.findUnique.mockResolvedValue({ oportunidadId: 'loc_op_9' }) + prismaMock.leadCaptura.findUnique.mockResolvedValue(null) // apartar → NotFoundError + const espia = vi.spyOn(console, 'error').mockImplementation(() => {}) const { res, status, json } = resFake() - await crearCliente(reqFake({ ...altaBase, leadMondayId: 'lead-9' }), res) + + await crearCliente(reqFake({ ...altaBase, leadMondayId: 'loc_op_9' }), res) + + espia.mockRestore() expect(status).toHaveBeenCalledWith(201) - expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayContactoId: 'cont-1' }) + expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayApartadoId: null }) }) it('guarda capturadoPorId con el sub del JWT (null sin token)', async () => { @@ -187,193 +246,159 @@ describe('crearCliente (POST /api/clientes) — leadMondayId del funnel', () => }) }) -describe('formaPagoLabel (tipos de crédito del alta → dropdown Forma de Pago)', () => { - it('mapea los 8 valores del enum del formato a los labels sembrados en Monday', async () => { - const { formaPagoLabel } = await import('./clientes.controller.js') - expect(formaPagoLabel(['INFONAVIT'])).toBe('Infonavit') - expect(formaPagoLabel(['APOYO_INFONAVIT'])).toBe('Apoyo Infonavit') - expect(formaPagoLabel(['COFINAVIT'])).toBe('Cofinavit') - expect(formaPagoLabel(['SEGUNDO_CREDITO'])).toBe('Segundo crédito') - expect(formaPagoLabel(['CONTADO'])).toBe('Contado') - expect(formaPagoLabel(['FOVISSSTE'])).toBe('Fovissste') - expect(formaPagoLabel(['COFI_FOVISSSTE'])).toBe('Cofi-Fovissste') - expect(formaPagoLabel(['BANCARIO'])).toBe('Bancario') - }) - - it('toma el PRIMERO reconocido del arreglo y tolera desconocidos', async () => { - const { formaPagoLabel } = await import('./clientes.controller.js') - expect(formaPagoLabel(['OTRA_COSA', 'CONTADO'])).toBe('Contado') - expect(formaPagoLabel(['OTRA_COSA'])).toBeUndefined() - expect(formaPagoLabel([])).toBeUndefined() - expect(formaPagoLabel(undefined)).toBeUndefined() - expect(formaPagoLabel('CONTADO')).toBeUndefined() // no es arreglo - }) -}) - -describe('crearCliente — Apartado con ficha completa', () => { - const altaConVenta = { - datosGenerales: { - nombreCompleto: 'Cliente Demo', - curp: 'CURP123', - rfc: 'RFC123', - email: 'cli@correo.com', - celular: '6641112233', - }, - viviendaCredito: { - proyecto: 'SENDA', - ubicacion: 'U-101', - valorVenta: 1500000, - enganche: 20000, - tiposCredito: ['INFONAVIT'], - }, - } - - const reqFake = (body: unknown, usuarioToken?: { sub: string }): Request => - ({ body, usuarioToken } as unknown as Request) - const resFake = () => { - const json = vi.fn() - const status = vi.fn().mockReturnValue({ json }) - return { res: { json, status } as unknown as Response, status, json } - } - +describe('alta progresiva — local', () => { beforeEach(() => { vi.clearAllMocks() prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data })) - prismaMock.cliente.update.mockResolvedValue({}) - prismaMock.usuario.findUnique.mockResolvedValue(null) - mondayMock.crearContacto.mockResolvedValue('cont-1') - mondayMock.crearApartado.mockResolvedValue('ap-1') - funnelMock.moverAApartoConDedup.mockResolvedValue('op-1') + prismaMock.cliente.update.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data })) + prismaMock.cliente.findUnique.mockResolvedValue(null) + prismaMock.leadCaptura.findUnique.mockResolvedValue(oportunidadLocal) + prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data })) + prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data })) + prismaMock.leadOportunidad.findUnique.mockResolvedValue({ oportunidadId: 'loc_op_9' }) + prismaMock.automatizacionState.findUnique.mockResolvedValue(null) + prismaMock.automatizacionState.upsert.mockResolvedValue({}) + prismaMock.$transaction.mockImplementation(async (ops: Promise[]) => Promise.all(ops)) }) - it('el Apartado lleva contacto, precio, enganche, Fecha Compra (hoy) y Forma de Pago', async () => { - const { crearCliente } = await import('./clientes.controller.js') + it('iniciarProgreso crea el alta en progreso (sin réplica externa)', async () => { + const { iniciarProgreso } = await import('./clientes.controller.js') const { res, status, json } = resFake() - await crearCliente(reqFake(altaConVenta), res) - expect(status).toHaveBeenCalledWith(201) - expect(mondayMock.crearApartado).toHaveBeenCalledWith( - expect.objectContaining({ - clienteNombre: 'Cliente Demo', - unidadCodigo: 'U-101', - montoApartado: 20000, - estadoLabel: 'Pago Apartado', - telefono: '6641112233', - email: 'cli@correo.com', - curp: 'CURP123', - precioFinal: 1500000, - enganche: 20000, - formaPagoLabel: 'Infonavit', - fechaCompra: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), + + await iniciarProgreso( + reqFake({ + datosGenerales: { + nombreCompleto: 'Ana Demo Progreso', + curp: 'AEDA900101MBCXXX00', + rfc: 'AEDA900101XX0', + celular: '6649990003', + }, }), + res, ) - expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayApartadoId: 'ap-1' }) - }) - - it('con capturista vinculado a Monday, el Apartado lo lleva como Vendedor (people)', async () => { - const { crearCliente } = await import('./clientes.controller.js') - prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 }) - await crearCliente(reqFake(altaConVenta, { sub: 'usr-1' }), resFake().res) - expect(mondayMock.crearApartado).toHaveBeenCalledWith(expect.objectContaining({ vendedorId: 12345 })) - }) - - it('sin capturista vinculado, el Apartado se crea sin Vendedor', async () => { - const { crearCliente } = await import('./clientes.controller.js') - await crearCliente(reqFake(altaConVenta), resFake().res) - expect(mondayMock.crearApartado).toHaveBeenCalledWith(expect.objectContaining({ vendedorId: undefined })) - }) -}) - -describe('réplica diferida (encolado cuando Monday falla)', () => { - const altaConVenta = { - datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123', celular: '6641112233' }, - viviendaCredito: { proyecto: 'SENDA', ubicacion: 'U-101', valorVenta: 1500000, enganche: 20000 }, - } - - const reqFake = (body: unknown): Request => ({ body } as Request) - const resFake = () => { - const json = vi.fn() - const status = vi.fn().mockReturnValue({ json }) - return { res: { json, status } as unknown as Response, status, json } - } - - function tiposEncolados(): string[] { - return prismaMock.syncPendiente.create.mock.calls.map((c) => (c[0] as { data: { tipo: string } }).data.tipo) - } - - beforeEach(() => { - vi.clearAllMocks() - prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data })) - prismaMock.cliente.update.mockResolvedValue({}) - prismaMock.usuario.findUnique.mockResolvedValue(null) - prismaMock.syncPendiente.create.mockResolvedValue({}) - funnelMock.moverAApartoConDedup.mockResolvedValue('op-1') - vi.spyOn(console, 'error').mockImplementation(() => {}) - }) - - it('falla crearContacto: 201 con ids null y CREAR_CONTACTO + CREAR_APARTADO encolados', async () => { - const { crearCliente } = await import('./clientes.controller.js') - mondayMock.crearContacto.mockRejectedValue(new Error('Monday caído')) - const { res, status, json } = resFake() - - await crearCliente(reqFake(altaConVenta), res) expect(status).toHaveBeenCalledWith(201) - expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayContactoId: null, mondayApartadoId: null }) - expect(tiposEncolados()).toEqual(['CREAR_CONTACTO', 'CREAR_APARTADO']) - // El payload del Contacto lleva los datos del alta (el worker no necesita nada más) - const llamadaContacto = prismaMock.syncPendiente.create.mock.calls[0]?.[0] as { - data: { payloadJson: string; referenciaLocal: string } - } - expect(JSON.parse(llamadaContacto.data.payloadJson)['datos']).toMatchObject({ - nombre: 'Cliente Demo', - estadoLabel: 'CLIENTE', - curp: 'CURP123', - }) - expect(llamadaContacto.data.referenciaLocal).toBe('cli-1') - // El del Apartado referencia al cliente para ligar mondayApartadoId al sincronizar - const llamadaApartado = prismaMock.syncPendiente.create.mock.calls[1]?.[0] as { - data: { payloadJson: string; referenciaLocal: string } - } - expect(JSON.parse(llamadaApartado.data.payloadJson)).toMatchObject({ - clienteId: 'cli-1', - datos: { clienteNombre: 'Cliente Demo', unidadCodigo: 'U-101', estadoLabel: 'Pago Apartado' }, + expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondayContactoId: null }) + expect(prismaMock.cliente.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ nombreCompleto: 'Ana Demo Progreso' }), }) }) - it('falla solo el Apartado: conserva mondayContactoId (lo persiste) y encola CREAR_APARTADO', async () => { - const { crearCliente } = await import('./clientes.controller.js') - mondayMock.crearContacto.mockResolvedValue('cont-1') - mondayMock.crearApartado.mockRejectedValue(new Error('Monday caído')) - const { res, status, json } = resFake() - - await crearCliente(reqFake(altaConVenta), res) - - expect(status).toHaveBeenCalledWith(201) - expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayContactoId: 'cont-1', mondayApartadoId: null }) - expect(tiposEncolados()).toEqual(['CREAR_APARTADO']) // el Contacto ya existe: no se duplica - expect(prismaMock.cliente.update).toHaveBeenCalledWith({ - where: { id: 'cli-1' }, - data: { mondayContactoId: 'cont-1', mondayApartadoId: null }, - }) - }) - - it('falla moverAApartoConDedup: encola APARTO_OPORTUNIDAD con el leadMondayId', async () => { - const { crearCliente } = await import('./clientes.controller.js') - mondayMock.crearContacto.mockResolvedValue('cont-1') - mondayMock.crearApartado.mockResolvedValue('ap-1') - funnelMock.moverAApartoConDedup.mockRejectedValue(new Error('Monday caído')) + it('iniciarProgreso requiere solo datosGenerales.nombreCompleto', async () => { + const { iniciarProgreso } = await import('./clientes.controller.js') const { res, status } = resFake() - await crearCliente(reqFake({ ...altaConVenta, leadMondayId: 'lead-9' }), res) - + await iniciarProgreso(reqFake({ datosGenerales: { nombreCompleto: 'Solo Nombre' } }), res) expect(status).toHaveBeenCalledWith(201) - expect(tiposEncolados()).toEqual(['APARTO_OPORTUNIDAD']) - expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ - tipo: 'APARTO_OPORTUNIDAD', - payloadJson: JSON.stringify({ leadId: 'lead-9' }), - referenciaLocal: 'lead-9', - }), + + await expect(iniciarProgreso(reqFake({ datosGenerales: {} }), resFake().res)).rejects.toThrow() + }) + + it('guarda capturadoPorId con el sub del JWT (null sin token)', async () => { + const { iniciarProgreso } = await import('./clientes.controller.js') + const req = reqFake({ datosGenerales: { nombreCompleto: 'Ana Demo' } }) + ;(req as unknown as { usuarioToken: { sub: string } }).usuarioToken = { sub: 'usr-1' } + await iniciarProgreso(req, resFake().res) + expect(prismaMock.cliente.create).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ capturadoPorId: 'usr-1' }) }), + ) + await iniciarProgreso(reqFake({ datosGenerales: { nombreCompleto: 'Ana Demo' } }), resFake().res) + expect(prismaMock.cliente.create).toHaveBeenLastCalledWith( + expect.objectContaining({ data: expect.objectContaining({ capturadoPorId: null }) }), + ) + }) + + it('actualizarProgreso guarda el avance del wizard localmente (mondaySync false)', async () => { + const { actualizarProgreso } = await import('./clientes.controller.js') + const datosWizard = { datosGenerales: { nombreCompleto: 'Ana', celular: '6649990003' } } + prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1' }) + const { res, json } = resFake() + + await actualizarProgreso({ body: datosWizard, params: { id: 'cli-1' } } as unknown as Request, res) + + expect(prismaMock.cliente.update).toHaveBeenCalledWith({ + where: { id: 'cli-1' }, + data: { payloadJson: JSON.stringify(datosWizard) }, + }) + expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondaySync: false }) + }) + + it('actualizarProgreso con id desconocido → 404', async () => { + const { actualizarProgreso } = await import('./clientes.controller.js') + prismaMock.cliente.findUnique.mockResolvedValue(null) + + await expect( + actualizarProgreso({ body: {}, params: { id: 'no-existe' } } as unknown as Request, resFake().res), + ).rejects.toThrow('Cliente no encontrado') + }) + + it('actualizarProgreso rechaza un body que no sea objeto', async () => { + const { actualizarProgreso } = await import('./clientes.controller.js') + prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1' }) + + await expect( + actualizarProgreso({ body: 'no-objeto', params: { id: 'cli-1' } } as unknown as Request, resFake().res), + ).rejects.toThrow() + }) + + it('completarProgreso cierra el alta y mueve la oportunidad del funnel a Aparto', async () => { + const { completarProgreso } = await import('./clientes.controller.js') + prismaMock.cliente.findUnique.mockResolvedValue({ + id: 'cli-1', + capturadoPorId: 'usr-9', + mondayApartadoId: null, + }) + const altaCompleta = { + datosGenerales: { nombreCompleto: 'Ana Demo Progreso', curp: 'AEDA900101MBCXXX00', rfc: 'AEDA900101XX0' }, + viviendaCredito: { proyecto: 'SENDA', ubicacion: 'U-999', valorVenta: 1500000, enganche: 20000 }, + leadMondayId: 'loc_op_9', + } + const { res, status, json } = resFake() + + await completarProgreso(reqFake(altaCompleta, { id: 'cli-1' }), res) + expect(status).toHaveBeenCalledWith(201) + expect(prismaMock.cliente.update).toHaveBeenCalledWith({ + where: { id: 'cli-1' }, + data: expect.objectContaining({ proyecto: 'SENDA', unidadCodigo: 'U-999' }), + }) + expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ board: 'APARTADO', etapa: 'Pago Apartado' }), + }) + // La ficha del apartado local se enriquece con lo capturado en el alta + const enriquecimiento = prismaMock.leadCaptura.update.mock.calls.find( + (c) => (c[0] as { where: { leadId: string } }).where.leadId !== 'loc_op_9', + ) + expect(enriquecimiento).toBeDefined() + const datos = JSON.parse( + (enriquecimiento![0] as { data: { datosJson: string } }).data.datosJson, + ) as Record + expect(datos).toMatchObject({ unidadCodigo: 'U-999', precioFinal: 1500000, enganche: 20000, estadoLabel: 'Pago Apartado' }) + expect(json.mock.calls[0]?.[0]).toMatchObject({ + id: 'cli-1', + mondayContactoId: null, + mondayApartadoId: expect.stringMatching(/^loc_/), }) }) + + it('completarProgreso rechaza un payload incompleto (misma validación que POST /clientes)', async () => { + const { completarProgreso } = await import('./clientes.controller.js') + prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1' }) + + await expect( + completarProgreso( + reqFake({ datosGenerales: { nombreCompleto: 'Sin curp ni proyecto' } }, { id: 'cli-1' }), + resFake().res, + ), + ).rejects.toThrow() + expect(prismaMock.cliente.update).not.toHaveBeenCalled() + }) + + it('completarProgreso 404 si el progreso no existe', async () => { + const { completarProgreso } = await import('./clientes.controller.js') + prismaMock.cliente.findUnique.mockResolvedValue(null) + + await expect( + completarProgreso(reqFake(altaBase, { id: 'no-existe' }), resFake().res), + ).rejects.toThrow('Cliente no encontrado') + }) }) diff --git a/api-gateway/src/controllers/clientes.controller.ts b/api-gateway/src/controllers/clientes.controller.ts index 5c1aa6d..9920893 100644 --- a/api-gateway/src/controllers/clientes.controller.ts +++ b/api-gateway/src/controllers/clientes.controller.ts @@ -5,13 +5,8 @@ import { randomUUID } from 'node:crypto' import multer from 'multer' import { z } from 'zod' import { config } from '../config/index.js' -import { mondayService, type DatosApartado, type DatosContacto } from '../services/monday.service.js' -import { funnelService } from '../services/funnel.service.js' -import { prismaStateStore } from '../services/automatizaciones.service.js' import { prisma } from '../services/prisma.js' -import { encolarSyncSeguro, esIdLocal } from '../services/crm-local.service.js' -import { saludMonday } from '../services/salud-monday.service.js' -import { esFalloMonday } from '../services/sync-monday.service.js' +import { crmLocal } from '../services/crm-local.service.js' import { NotFoundError, BadRequestError } from '../utils/errors.js' import * as auditoria from '../services/auditoria.service.js' @@ -27,7 +22,7 @@ export const crearClienteSchema = z email: z.string().optional(), celular: z.string().optional(), direccion: z.string().optional(), - // Perfil demográfico (opcionales; se replican al Contacto en Monday) + // Perfil demográfico (opcionales; viajan en el payload del alta) fechaNacimiento: z.string().optional(), estadoCivil: z.string().optional(), tipoVivienda: z.string().optional(), @@ -48,90 +43,9 @@ export const crearClienteSchema = z }) .passthrough() // el resto del payload pasa tal cual a payloadJson -// ---------- Mapeos al vocabulario del CRM FRASA (lógica pura, testeable) ---------- - -const ESTADO_CIVIL_LABEL: Record = { - SOLTERO: 'Soltero', - CASADO: 'Casado', - UNION_LIBRE: 'Union Libre', - DIVORCIADO: 'Divorciado', - VIUDO: 'Viudo', -} - -const VIVIENDA_ACTUAL_LABEL: Record = { - PRESTADA: 'Prestada', - RENTADA: 'Rentada', - FAMILIAR: 'Comparte con familia', - PROPIA: 'Propia', -} - -const PROPOSITO_COMPRA_LABEL: Record = { - INVERSION: 'Inversion', - VIVIR_AHI: 'Habitarla', -} - -// El formato de alta solo permite inferir 'Empleado' (ASALARIADO); un -// INDEPENDIENTE puede ser Comerciante o Empresario: no se adivina. -const ACTIVIDAD_ECONOMICA_LABEL: Record = { - ASALARIADO: 'Empleado', -} - -/** Label del dropdown "Rango de Edad" derivado de la fecha de nacimiento */ -export function rangoEdadLabel(fechaNacimiento: string | undefined, hoy: Date = new Date()): string | undefined { - if (!fechaNacimiento) return undefined - const nacimiento = new Date(`${fechaNacimiento.slice(0, 10)}T00:00:00`) - if (Number.isNaN(nacimiento.getTime())) return undefined - let edad = hoy.getFullYear() - nacimiento.getFullYear() - const aunNoCumple = - hoy.getMonth() < nacimiento.getMonth() || - (hoy.getMonth() === nacimiento.getMonth() && hoy.getDate() < nacimiento.getDate()) - if (aunNoCumple) edad-- - if (edad < 0 || edad > 120) return undefined - if (edad < 24) return 'Menor de 24' - if (edad <= 40) return 'Entre 24 y 40' - return 'Mayor de 40' -} - -/** Labels demográficos del Contacto a partir del payload del formato de alta */ -export function perfilDemografico(dg: { - estadoCivil?: string | undefined - tipoVivienda?: string | undefined - tipoCompra?: string | undefined - fechaNacimiento?: string | undefined -}): { - estadoCivilLabel?: string | undefined - viviendaActualLabel?: string | undefined - propositoCompraLabel?: string | undefined - rangoEdadLabel?: string | undefined - fechaNacimiento?: string | undefined -} { - const limpio = (v: string | undefined): string => (v ?? '').trim().toUpperCase() - const fecha = /^\d{4}-\d{2}-\d{2}/.test(dg.fechaNacimiento ?? '') - ? dg.fechaNacimiento!.slice(0, 10) - : undefined - return { - ...(ESTADO_CIVIL_LABEL[limpio(dg.estadoCivil)] !== undefined - ? { estadoCivilLabel: ESTADO_CIVIL_LABEL[limpio(dg.estadoCivil)] } - : {}), - ...(VIVIENDA_ACTUAL_LABEL[limpio(dg.tipoVivienda)] !== undefined - ? { viviendaActualLabel: VIVIENDA_ACTUAL_LABEL[limpio(dg.tipoVivienda)] } - : {}), - ...(PROPOSITO_COMPRA_LABEL[limpio(dg.tipoCompra)] !== undefined - ? { propositoCompraLabel: PROPOSITO_COMPRA_LABEL[limpio(dg.tipoCompra)] } - : {}), - ...(rangoEdadLabel(fecha) !== undefined ? { rangoEdadLabel: rangoEdadLabel(fecha) } : {}), - ...(fecha !== undefined ? { fechaNacimiento: fecha } : {}), - } -} - -/** Label de "Actividad Economica" derivado del tipo de empleo del formato */ -export function actividadEconomicaLabel(tipoEmpleo: string | undefined): string | undefined { - return ACTIVIDAD_ECONOMICA_LABEL[(tipoEmpleo ?? '').trim().toUpperCase()] -} - /** - * Checkbox del tablero Apartado que corresponde a un tipo de documento. - * INE y el resto no tienen checkbox (devuelve null). + * Checkbox que corresponde a un tipo de documento en el expediente del + * apartado. INE y el resto no tienen checkbox (devuelve null). */ export function checkboxParaTipoDocumento(tipo: string): string | null { switch (tipo.trim().toUpperCase()) { @@ -162,33 +76,7 @@ const hoyMas = (dias: number): string => { return `${d.getFullYear()}-${m}-${dia}` } -// ---------- Helpers compartidos (alta directa y alta progresiva) ---------- - -/** - * Si el alta viene de una afluencia del funnel (la app alta-clientes manda - * leadMondayId en la raíz del payload), su oportunidad pasa a 'Aparto'. - * El id puede ser una oportunidad directa (modelo nuevo: la afluencia ES la - * oportunidad, fuente 'oportunidad' del picker) o un lead histórico del - * tablero Leads; moverAApartoConDedup resuelve ambos vía LeadOportunidad. - * moverAApartoConDedup escribe el dedup `apartado-creado:` ANTES del - * cambio de etapa, para que el webhook/cron no cree un Apartado duplicado. - * Un fallo aquí no rompe el alta. - */ -async function moverLeadAApartoSiAplica(body: Record): Promise { - const leadMondayId = body['leadMondayId'] - if (typeof leadMondayId === 'string' && leadMondayId !== '') { - try { - await funnelService.moverAApartoConDedup(leadMondayId, prismaStateStore) - } catch (err) { - // eslint-disable-next-line no-console - console.error(`No se pudo mover a 'Aparto' la oportunidad del lead ${leadMondayId}:`, err) - // El worker lo reintenta cuando Monday vuelva (con el mismo dedup) - await encolarSyncSeguro('APARTO_OPORTUNIDAD', { leadId: leadMondayId }, leadMondayId) - } - } -} - -/** Label del dropdown "Forma de Pago" (tablero Apartado) a partir del enum del formato de alta */ +/** Label de "Forma de Pago" a partir del enum del formato de alta */ const FORMA_PAGO_LABEL: Record = { INFONAVIT: 'Infonavit', APOYO_INFONAVIT: 'Apoyo Infonavit', @@ -201,9 +89,8 @@ const FORMA_PAGO_LABEL: Record = { } /** - * Label de "Forma de Pago" a partir de viviendaCredito.tiposCredito (arreglo - * del wizard; se toma el PRIMERO reconocido — la columna del board es de una - * sola forma de pago). undefined si no hay ninguno reconocible. + * "Forma de Pago" a partir de viviendaCredito.tiposCredito (arreglo del + * wizard; se toma el PRIMERO reconocido). undefined si no hay ninguno. */ export function formaPagoLabel(tiposCredito: unknown): string | undefined { if (!Array.isArray(tiposCredito)) return undefined @@ -214,80 +101,54 @@ export function formaPagoLabel(tiposCredito: unknown): string | undefined { return undefined } +// ---------- Helpers locales compartidos (alta directa y alta progresiva) ---------- + +const apartadoLigadoDeBody = (body: Record): string | null => { + const id = body['apartadoMondayId'] + return typeof id === 'string' && id !== '' ? id : null +} + /** - * Datos del item en el tablero Apartado (pipeline Enkontrol) para el alta; - * null si no hay valor de venta (no aplica apartado). Lo usan tanto la réplica - * directa (crearApartadoSiAplica) como el encolado diferido (CREAR_APARTADO). + * Si el alta viene de una afluencia del funnel (la app alta-clientes manda + * leadMondayId en la raíz del payload), su oportunidad pasa a 'Aparto' y se + * crea el apartado local (crmLocal.apartar; idempotente por dedup). El id + * puede ser una oportunidad directa (modelo nuevo: la afluencia ES la + * oportunidad, fuente 'oportunidad' del picker) o un lead histórico: se + * resuelve ambos vía LeadOportunidad, como hacía moverAApartoConDedup. + * Devuelve el id de la fila de apartado local creada (null si no aplica). + * Un fallo aquí no rompe el alta. */ -async function datosApartadoDeAlta( - dg: { nombreCompleto: string; celular?: string | undefined; email?: string | undefined; curp?: string | undefined }, - vc: { - ubicacion?: string | undefined - valorVenta?: string | number | undefined - enganche?: string | number | undefined - tiposCredito?: unknown - }, +async function moverLeadAApartoSiAplica( + body: Record, capturadoPorId: string | null, -): Promise { - const valorVenta = aNumero(vc.valorVenta) - if (valorVenta <= 0) return null - const enganche = aNumero(vc.enganche) - // Vendedor del apartado: el usuario Monday vinculado de quien capturó el alta - let vendedorId: number | undefined - if (capturadoPorId !== null) { - const capturista = await prisma.usuario.findUnique({ - where: { id: capturadoPorId }, - select: { mondayUserId: true }, +): Promise { + const leadId = body['leadMondayId'] + if (typeof leadId !== 'string' || leadId === '') return null + try { + const comoOportunidad = await prisma.leadOportunidad.findUnique({ + where: { oportunidadId: leadId }, }) - vendedorId = capturista?.mondayUserId ?? undefined + const mapping = + comoOportunidad ?? + (await prisma.leadOportunidad.findUnique({ where: { leadId } })) + const oportunidadId = comoOportunidad?.oportunidadId ?? mapping?.oportunidadId ?? null + if (oportunidadId === null) return null + const apartado = await crmLocal.apartar(oportunidadId, capturadoPorId ?? undefined) + return apartado.leadId + } catch (err) { + // eslint-disable-next-line no-console + console.error(`No se pudo mover a 'Aparto' la oportunidad del lead ${leadId}:`, err) + return null } - return { - clienteNombre: dg.nombreCompleto, - unidadCodigo: vc.ubicacion ?? '', - ...(enganche > 0 ? { montoApartado: enganche } : {}), - fechaApartado: hoyMas(0), - fechaLimite: hoyMas(15), - estadoLabel: 'Pago Apartado', // primer paso del pipeline Enkontrol - telefono: dg.celular, - email: dg.email, - curp: dg.curp, - precioFinal: valorVenta, - enganche: enganche > 0 ? enganche : undefined, - fechaCompra: hoyMas(0), - vendedorId, - formaPagoLabel: formaPagoLabel(vc.tiposCredito), - } -} - -/** Item en el tablero Apartado (pipeline Enkontrol) con la ficha completa; solo si hay valor de venta */ -async function crearApartadoSiAplica( - dg: { nombreCompleto: string; celular?: string | undefined; email?: string | undefined; curp?: string | undefined }, - vc: { - ubicacion?: string | undefined - valorVenta?: string | number | undefined - enganche?: string | number | undefined - tiposCredito?: unknown - }, - capturadoPorId: string | null, -): Promise { - const datos = await datosApartadoDeAlta(dg, vc, capturadoPorId) - if (datos === null) return null - return mondayService.crearApartado(datos) } /** - * Réplica diferida del alta (modo local): cuando la réplica Monday falla a - * media operación, encola en SyncPendiente solo lo que no alcanzó a crearse - * (Contacto y/o Apartado) para que el worker lo aplique cuando Monday vuelva. - * Si el alta venía ligada a un apartado Monday existente (apartadoMondayId), - * no se encola la creación: el item ya existe (solo falló enriquecerlo). - * Devuelve el mondayApartadoId efectivo tras el fallback. + * Enriquece la fila de apartado local con lo capturado en el alta (unidad, + * precio, enganche, forma de pago, contacto): el "Alta Enkontrol" desde un + * apartado encuentra así la ficha completa al prellenar. Best-effort. */ -async function encolarReplicasPendientesCliente( - clienteId: string, - datosContacto: DatosContacto, - mondayContactoId: string | null, - mondayApartadoId: string | null, +async function enriquecerApartadoLocal( + apartadoId: string, dg: { nombreCompleto: string; celular?: string | undefined; email?: string | undefined; curp?: string | undefined }, vc: { ubicacion?: string | undefined @@ -295,57 +156,60 @@ async function encolarReplicasPendientesCliente( enganche?: string | number | undefined tiposCredito?: unknown }, - capturadoPorId: string | null, - apartadoMondayId: unknown, -): Promise { - if (mondayContactoId === null) { - await encolarSyncSeguro('CREAR_CONTACTO', { datos: datosContacto }, clienteId) +): Promise { + try { + const captura = await prisma.leadCaptura.findUnique({ where: { leadId: apartadoId } }) + if (!captura) return + let datos: Record = {} + if (captura.datosJson) { + try { datos = JSON.parse(captura.datosJson) as Record } catch { /* ignorar */ } + } + const valorVenta = aNumero(vc.valorVenta) + const enganche = aNumero(vc.enganche) + datos['unidadCodigo'] = vc.ubicacion ?? datos['unidadCodigo'] ?? '' + if (valorVenta > 0) datos['precioFinal'] = valorVenta + if (enganche > 0) { + datos['enganche'] = enganche + datos['montoApartado'] = enganche + } + datos['estadoLabel'] = 'Pago Apartado' + datos['fechaApartado'] = datos['fechaApartado'] ?? hoyMas(0) + datos['fechaLimite'] = datos['fechaLimite'] ?? hoyMas(15) + datos['fechaCompra'] = hoyMas(0) + const formaPago = formaPagoLabel(vc.tiposCredito) + if (formaPago !== undefined) datos['formaPagoLabel'] = formaPago + if (dg.celular !== undefined && dg.celular !== '') datos['telefono'] = dg.celular + if (dg.email !== undefined && dg.email !== '') datos['email'] = dg.email + if (dg.curp !== undefined && dg.curp !== '') datos['curp'] = dg.curp + await prisma.leadCaptura.update({ + where: { leadId: apartadoId }, + data: { + datosJson: JSON.stringify(datos), + ...(dg.celular !== undefined && dg.celular !== '' && captura.telefono === null ? { telefono: dg.celular } : {}), + ...(dg.email !== undefined && dg.email !== '' && captura.email === null ? { email: dg.email } : {}), + }, + }) + } catch (err) { + // eslint-disable-next-line no-console + console.error(`No se pudo enriquecer el apartado local ${apartadoId}:`, err) } - if (mondayApartadoId !== null) return mondayApartadoId - const existente = typeof apartadoMondayId === 'string' && apartadoMondayId !== '' ? apartadoMondayId : null - if (existente !== null) return existente - const datosApartado = await datosApartadoDeAlta(dg, vc, capturadoPorId) - if (datosApartado !== null) { - await encolarSyncSeguro('CREAR_APARTADO', { datos: datosApartado, clienteId }, clienteId) - } - return null } /** - * Apartado Monday del alta: con apartadoMondayId (alta arrancada desde un - * apartado existente, botón "Alta Enkontrol" de Mis apartados) se - * LIGA y enriquece ese item; sin él, se crea uno nuevo como siempre. + * Liga el apartado del alta a la ficha del Cliente (mondayApartadoId conserva + * el nombre histórico: es el id de la fila de apartado, local o heredado). + * Best-effort: un fallo no rompe el alta. */ -async function apartadoDeAlta( - dg: { nombreCompleto: string; celular?: string | undefined; email?: string | undefined; curp?: string | undefined }, - vc: { - ubicacion?: string | undefined - valorVenta?: string | number | undefined - enganche?: string | number | undefined - tiposCredito?: unknown - }, - capturadoPorId: string | null, - apartadoMondayId: unknown, -): Promise { - const existente = typeof apartadoMondayId === 'string' && apartadoMondayId !== '' ? apartadoMondayId : null - if (existente === null) return crearApartadoSiAplica(dg, vc, capturadoPorId) - const valorVenta = aNumero(vc.valorVenta) - const enganche = aNumero(vc.enganche) - // Solo se mandan los campos con valor (exactOptionalPropertyTypes) - await mondayService.actualizarApartado(existente, { - clienteNombre: dg.nombreCompleto, - ...(vc.ubicacion !== undefined && vc.ubicacion !== '' ? { unidadCodigo: vc.ubicacion } : {}), - ...(dg.celular !== undefined && dg.celular !== '' ? { telefono: dg.celular } : {}), - ...(dg.email !== undefined && dg.email !== '' ? { email: dg.email } : {}), - ...(dg.curp !== undefined && dg.curp !== '' ? { curp: dg.curp } : {}), - ...(valorVenta > 0 ? { precioFinal: valorVenta } : {}), - ...(enganche > 0 ? { enganche } : {}), - ...(formaPagoLabel(vc.tiposCredito) !== undefined ? { formaPagoLabel: formaPagoLabel(vc.tiposCredito) } : {}), - }) - return existente +async function ligarApartadoACliente(clienteId: string, apartadoId: string): Promise { + try { + await prisma.cliente.update({ where: { id: clienteId }, data: { mondayApartadoId: apartadoId } }) + } catch (err) { + // eslint-disable-next-line no-console + console.error(`No se pudo ligar el apartado ${apartadoId} al cliente ${clienteId}:`, err) + } } -/** POST /api/clientes — persiste el alta y la replica en Monday (Contactos y Apartado) */ +/** POST /api/clientes — persiste el alta en SQLite y liga el apartado del funnel */ export async function crearCliente(req: Request, res: Response): Promise { const body = crearClienteSchema.parse(req.body) const dg = body.datosGenerales @@ -366,62 +230,22 @@ export async function crearCliente(req: Request, res: Response): Promise { }, }) - await moverLeadAApartoSiAplica(body as Record) - - // Réplica en Monday; si falla, el alta ya quedó persistida y se reportan ids null - let mondayContactoId: string | null = null - let mondayApartadoId: string | null = null - const datosContacto: DatosContacto = { - nombre: dg.nombreCompleto, - estadoLabel: 'CLIENTE', - ...(dg.celular !== undefined ? { telefono: dg.celular } : {}), - ...(dg.email !== undefined ? { email: dg.email } : {}), - curp: dg.curp, - rfc: dg.rfc, - ...(dg.direccion !== undefined ? { direccion: dg.direccion } : {}), - // Perfil demográfico: solo lo que el formato de alta captura. - // Sin fuente en el formulario: Sexo (no se pregunta) y Actividad - // Economica distinta de 'Empleado' (INDEPENDIENTE es ambiguo). - ...perfilDemografico(dg), - ...{ actividadEconomicaLabel: actividadEconomicaLabel(body.empleo?.tipoEmpleo) }, - } - try { - mondayContactoId = await mondayService.crearContacto(datosContacto) - - mondayApartadoId = await apartadoDeAlta(dg, vc, req.usuarioToken?.sub ?? null, body.apartadoMondayId) - - await prisma.cliente.update({ - where: { id: cliente.id }, - data: { mondayContactoId, mondayApartadoId }, - }) - } catch (err) { - // eslint-disable-next-line no-console - console.error('No se pudo replicar el cliente en Monday:', err) - // Encola la réplica diferida de lo que faltó y conserva los ids que sí se crearon - mondayApartadoId = await encolarReplicasPendientesCliente( - cliente.id, - datosContacto, - mondayContactoId, - mondayApartadoId, - dg, - vc, - req.usuarioToken?.sub ?? null, - body.apartadoMondayId, - ) - if (mondayContactoId !== null || mondayApartadoId !== null) { - try { - await prisma.cliente.update({ - where: { id: cliente.id }, - data: { mondayContactoId, mondayApartadoId }, - }) - } catch (err2) { - // eslint-disable-next-line no-console - console.error('No se pudieron guardar los ids Monday del cliente:', err2) - } + // Apartado del alta: el existente (botón "Alta Enkontrol" desde Mis + // apartados) se LIGA tal cual; si el alta viene del funnel se mueve la + // oportunidad a 'Aparto' y se crea/enriquece el apartado local. + let mondayApartadoId = apartadoLigadoDeBody(body as Record) + if (mondayApartadoId === null) { + const apartadoFunnelId = await moverLeadAApartoSiAplica(body as Record, req.usuarioToken?.sub ?? null) + if (apartadoFunnelId !== null) { + mondayApartadoId = apartadoFunnelId + await enriquecerApartadoLocal(apartadoFunnelId, dg, vc) } } + if (mondayApartadoId !== null) { + await ligarApartadoACliente(cliente.id, mondayApartadoId) + } - res.status(201).json({ ...cliente, mondayContactoId, mondayApartadoId }) + res.status(201).json({ ...cliente, mondayContactoId: null, mondayApartadoId }) auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CREAR_CLIENTE', 'cliente', cliente.id) } @@ -429,7 +253,7 @@ export async function crearCliente(req: Request, res: Response): Promise { /** * POST /progreso/iniciar — solo exige el nombre; curp/rfc/email/celular son - * opcionales porque el Contacto PROSPECTO se crea en cuanto hay nombre. + * opcionales porque se completan en los pasos siguientes. */ export const iniciarProgresoSchema = z .object({ @@ -455,62 +279,7 @@ export const iniciarProgresoSchema = z /** PATCH /progreso/:id — passthrough libre; solo se valida que sea un objeto */ const actualizarProgresoSchema = z.object({}).passthrough() -const seccionDe = (datos: Record, clave: string): Record => { - const v = datos[clave] - return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record) : {} -} - -const textoDe = (v: unknown): string | undefined => - typeof v === 'string' && v.trim() !== '' ? v : undefined - -/** Texto de la columna Dirección del Contacto a partir del domicilio capturado */ -export function textoDireccion(dom: Record): string | undefined { - const t = (k: string): string => textoDe(dom[k])?.trim() ?? '' - const calleNum = [t('calle'), t('numExt')].filter((p) => p !== '').join(' ') - const numInt = t('numInt') - const linea1 = [calleNum, numInt !== '' ? `Int. ${numInt}` : ''].filter((p) => p !== '').join(', ') - const cp = t('cp') - const partes = [linea1, t('colonia'), cp !== '' ? `CP ${cp}` : '', t('municipio'), t('estado')].filter( - (p) => p !== '', - ) - return partes.length > 0 ? partes.join(', ') : undefined -} - -/** - * Deriva TODOS los campos mapeables del Contacto de Monday presentes en el - * estado del wizard (idempotente: se manda el estado completo en cada avance). - */ -export function contactoDesdeDatos(datos: Record): Omit { - const dg = seccionDe(datos, 'datosGenerales') - const empleo = seccionDe(datos, 'empleo') - const vc = seccionDe(datos, 'viviendaCredito') - // El domicilio puede venir suelto ('domicilio') o como domicilioFiscal del paso Vivienda - const domRaiz = seccionDe(datos, 'domicilio') - const dom = Object.keys(domRaiz).length > 0 ? domRaiz : seccionDe(vc, 'domicilioFiscal') - const curp = textoDe(dg['curp']) - const rfc = textoDe(dg['rfc']) - const email = textoDe(dg['email']) - const telefono = textoDe(dg['celular']) - const direccion = textoDireccion(dom) ?? textoDe(dg['direccion']) - const actividadEconomica = actividadEconomicaLabel(textoDe(empleo['tipoEmpleo'])) - return { - nombre: textoDe(dg['nombreCompleto']) ?? '', - ...(curp !== undefined ? { curp } : {}), - ...(rfc !== undefined ? { rfc } : {}), - ...(email !== undefined ? { email } : {}), - ...(telefono !== undefined ? { telefono } : {}), - ...(direccion !== undefined ? { direccion } : {}), - ...perfilDemografico({ - estadoCivil: textoDe(dg['estadoCivil']), - tipoVivienda: textoDe(dg['tipoVivienda']), - tipoCompra: textoDe(dg['tipoCompra']), - fechaNacimiento: textoDe(dg['fechaNacimiento']), - }), - ...(actividadEconomica !== undefined ? { actividadEconomicaLabel: actividadEconomica } : {}), - } -} - -/** POST /api/clientes/progreso/iniciar — crea el alta en progreso + Contacto PROSPECTO */ +/** POST /api/clientes/progreso/iniciar — crea el alta en progreso */ export async function iniciarProgreso(req: Request, res: Response): Promise { const body = iniciarProgresoSchema.parse(req.body) const dg = body.datosGenerales @@ -528,26 +297,10 @@ export async function iniciarProgreso(req: Request, res: Response): Promise), - estadoLabel: 'PROSPECTO', - } - try { - mondayContactoId = await mondayService.crearContacto(datosContactoProspecto) - await prisma.cliente.update({ where: { id: cliente.id }, data: { mondayContactoId } }) - } catch (err) { - // eslint-disable-next-line no-console - console.error('No se pudo crear el Contacto PROSPECTO en Monday:', err) - await encolarSyncSeguro('CREAR_CONTACTO', { datos: datosContactoProspecto }, cliente.id) - } - - res.status(201).json({ id: cliente.id, mondayContactoId }) + res.status(201).json({ id: cliente.id, mondayContactoId: null }) } -/** PATCH /api/clientes/progreso/:id — guarda el avance y lo replica en el Contacto */ +/** PATCH /api/clientes/progreso/:id — guarda el avance del wizard (local) */ export async function actualizarProgreso(req: Request, res: Response): Promise { const id = String(req.params['id']) const body = actualizarProgresoSchema.parse(req.body) @@ -556,21 +309,7 @@ export async function actualizarProgreso(req: Request, res: Response): Promise) - - // El Contacto (creado como PROSPECTO al iniciar) pasa a CLIENTE con los - // datos finales; si no existe (nunca hubo Monday), se crea como en el alta directa - let mondayContactoId: string | null = cliente.mondayContactoId - let mondayApartadoId: string | null = cliente.mondayApartadoId - const datosContactoCliente: DatosContacto = { - ...contactoDesdeDatos(body as Record), - estadoLabel: 'CLIENTE', - } - try { - if (mondayContactoId !== null) { - await mondayService.actualizarContacto(mondayContactoId, datosContactoCliente) - } else { - mondayContactoId = await mondayService.crearContacto(datosContactoCliente) - } - - if (mondayApartadoId === null) { - // Con apartadoMondayId (alta desde un apartado existente) se liga y - // enriquece ese item; si no, se crea. El capturista original del alta - // progresiva es quien queda como Vendedor. - mondayApartadoId = await apartadoDeAlta(dg, vc, cliente.capturadoPorId, body.apartadoMondayId) - } - - await prisma.cliente.update({ - where: { id }, - data: { mondayContactoId, mondayApartadoId }, - }) - } catch (err) { - // eslint-disable-next-line no-console - console.error('No se pudo replicar el cliente en Monday:', err) - // Encola la réplica diferida: actualización del Contacto si ya existía - // (es idempotente) o su creación; y el Apartado si no se creó - if (mondayContactoId !== null) { - await encolarSyncSeguro('ACTUALIZAR_CONTACTO', { datos: datosContactoCliente }, id) - } - mondayApartadoId = await encolarReplicasPendientesCliente( - id, - datosContactoCliente, - mondayContactoId, - mondayApartadoId, - dg, - vc, - cliente.capturadoPorId, - body.apartadoMondayId, - ) - if (mondayContactoId !== null || mondayApartadoId !== null) { - try { - await prisma.cliente.update({ - where: { id }, - data: { mondayContactoId, mondayApartadoId }, - }) - } catch (err2) { - // eslint-disable-next-line no-console - console.error('No se pudieron guardar los ids Monday del cliente:', err2) - } + // Apartado del alta (misma lógica que el alta directa) + let mondayApartadoId = apartadoLigadoDeBody(body as Record) + if (mondayApartadoId === null) { + const apartadoFunnelId = await moverLeadAApartoSiAplica(body as Record, cliente.capturadoPorId) + if (apartadoFunnelId !== null) { + mondayApartadoId = apartadoFunnelId + await enriquecerApartadoLocal(apartadoFunnelId, dg, vc) } } + if (mondayApartadoId !== null) { + await ligarApartadoACliente(id, mondayApartadoId) + } - res.status(201).json({ ...actualizado, mondayContactoId, mondayApartadoId }) + res.status(201).json({ ...actualizado, mondayContactoId: null, mondayApartadoId }) } /** GET /api/clientes/:id */ @@ -729,29 +422,6 @@ export async function subirDocumentos(req: Request, res: Response): Promise { const itemId = String(req.params['itemId']) - - // Monday caído (circuit breaker): prefill 100% local - if (!saludMonday.estaDisponible()) { - res.json(await prefillApartadoLocal(itemId)) - return - } - - try { - const apartado = (await mondayService.getApartados()).find((a) => a.id === itemId) - if (!apartado) throw new NotFoundError('Apartado no encontrado') - - const tel = (apartado.telefono ?? '').replace(/\D/g, '').slice(-10) - const email = (apartado.email ?? '').trim().toLowerCase() - const coincide = (t: string, e: string): boolean => - (tel !== '' && t.replace(/\D/g, '').length >= 10 && t.replace(/\D/g, '').slice(-10) === tel) || - (email !== '' && e.trim().toLowerCase() === email) - - const [contactos, oportunidades] = await Promise.all([ - mondayService.getContactos(), - mondayService.getOportunidades(), - ]) - const contactoBase = contactos.find((c) => coincide(c.telefono, c.email)) - const contacto = contactoBase ? await mondayService.getContactoDetalle(contactoBase.id) : null - const oportunidad = oportunidades.find((o) => coincide(o.telefono, o.email)) ?? null - - // Cotización local más reciente: vinculada a la oportunidad o por teléfono - let cotizacion = null - if (oportunidad) { - cotizacion = await prisma.cotizacion.findFirst({ - where: { mondayItemId: oportunidad.id }, - orderBy: { createdAt: 'desc' }, - }) - } - if (!cotizacion && tel !== '') { - cotizacion = await prisma.cotizacion.findFirst({ - where: { clienteTelefono: { contains: tel } }, - orderBy: { createdAt: 'desc' }, - }) - } - - res.json({ - apartado, - contacto, - oportunidad, - cotizacion: cotizacion - ? { - tipoPropuesta: cotizacion.tipoPropuesta, - acabados: cotizacion.acabados, - apartado: cotizacion.apartado, - precioLista: cotizacion.precioLista, - precioFinal: cotizacion.precioFinal, - enganche: cotizacion.enganche, - numMensualidades: cotizacion.numMensualidades, - mensualidad: cotizacion.mensualidad, - pagoFinal: cotizacion.pagoFinal, - } - : null, - }) - } catch (err) { - // Monday se cayó a media lectura: prefill local (el 404 de "apartado no - // encontrado" NO es fallo de Monday y se propaga intacto) - if (!esFalloMonday(err)) throw err - res.json(await prefillApartadoLocal(itemId)) - } + res.json(await prefillApartadoLocal(itemId)) } -/** Shape de la cotización en la respuesta del prefill (compartido Monday/local) */ +/** Shape de la cotización en la respuesta del prefill */ function cotizacionPrefill(cotizacion: { tipoPropuesta: string acabados: boolean @@ -869,11 +476,10 @@ function cotizacionPrefill(cotizacion: { } /** - * Prefill local (Monday caído): el apartado es la fila LeadCaptura board= - * APARTADO (id loc_* o sombra); el contacto sale del Cliente ligado, la - * oportunidad de la captura board=OPORTUNIDADES con el mismo teléfono (su - * perfil va en datosJson) y la cotización de la tabla local. Mismo contrato - * que el camino Monday. 404 si no existe el apartado local. + * Prefill local: el apartado es la fila LeadCaptura board=APARTADO (id loc_* o + * sombra); el contacto sale del Cliente ligado, la oportunidad de la captura + * board=OPORTUNIDADES con el mismo teléfono (su perfil va en datosJson) y la + * cotización de la tabla local. */ async function prefillApartadoLocal(itemId: string) { const captura = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } }) diff --git a/api-gateway/src/controllers/contactos-fallback.test.ts b/api-gateway/src/controllers/contactos-fallback.test.ts deleted file mode 100644 index d33410c..0000000 --- a/api-gateway/src/controllers/contactos-fallback.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Request, Response } from 'express' -import { AppError, NotFoundError } from '../utils/errors.js' - -// ---------- Prisma y Monday mockeados (los tests no tocan BD ni red) ---------- - -const prismaMock = vi.hoisted(() => ({ - cliente: { - findFirst: vi.fn(), - findMany: vi.fn(), - }, - leadCaptura: { - findUnique: vi.fn(), - findMany: vi.fn(), - }, - cotizacion: { - findMany: vi.fn(), - }, - documento: { - findMany: vi.fn(), - }, - usuario: { - findMany: vi.fn(), - }, -})) - -const mondayMock = vi.hoisted(() => ({ - getContactos: vi.fn(), - getContactoDetalle: vi.fn(), - getUsuariosWorkspace: vi.fn(), -})) - -vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) -vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock })) - -import { detalleContactoEndpoint, listarContactosEndpoint } from './contactos.controller.js' -import { saludMonday } from '../services/salud-monday.service.js' - -function resFake() { - const json = vi.fn() - const status = vi.fn().mockReturnValue({ json }) - return { res: { json, status } as unknown as Response, status, json } -} - -const capturaLocal = { - id: 'row-1', - leadId: 'loc_1', - nombreLead: 'ANA DEMO', - telefono: '6649998877', - email: null, - origen: 'WhatsApp', - desarrollo: 'SENDA', - modelo: '1R', - board: 'OPORTUNIDADES', - etapa: 'Afluencia', - motivo: null, - submotivo: null, - datosJson: null, - mondayItemId: null, - pendienteSync: true, - usuarioId: 'u-1', - createdAt: new Date('2026-09-02T10:00:00'), - updatedAt: new Date('2026-09-03T10:00:00'), -} - -beforeEach(() => { - vi.clearAllMocks() - vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible') - saludMonday.restablecer() - prismaMock.cliente.findFirst.mockResolvedValue(null) - prismaMock.cliente.findMany.mockResolvedValue([]) - prismaMock.leadCaptura.findUnique.mockResolvedValue(null) - prismaMock.leadCaptura.findMany.mockResolvedValue([capturaLocal]) - prismaMock.cotizacion.findMany.mockResolvedValue([]) - prismaMock.documento.findMany.mockResolvedValue([]) - prismaMock.usuario.findMany.mockResolvedValue([{ id: 'u-1', nombre: 'Martin Zavala' }]) - mondayMock.getUsuariosWorkspace.mockResolvedValue([]) -}) - -describe('listarContactosEndpoint (fallback local)', () => { - it('Monday caído (circuit breaker): lista local sin llamar a Monday', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) - const { res, json } = resFake() - - await listarContactosEndpoint({} as Request, res) - - expect(mondayMock.getContactos).not.toHaveBeenCalled() - const lista = json.mock.calls[0]?.[0] as Record[] - expect(lista[0]).toMatchObject({ id: 'loc_1', nombre: 'ANA DEMO', estado: 'Afluencia', fuente: 'local' }) - }) - - it('Monday lanza 502: fallback local', async () => { - mondayMock.getContactos.mockRejectedValue(new AppError(502, 'Monday no respondió')) - const { res, json } = resFake() - - await listarContactosEndpoint({} as Request, res) - - const lista = json.mock.calls[0]?.[0] as Record[] - expect(lista[0]).toMatchObject({ id: 'loc_1', fuente: 'local' }) - }) -}) - -describe('detalleContactoEndpoint (fallback local)', () => { - it('Monday caído: detalle 100% local con la misma forma', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) - prismaMock.leadCaptura.findUnique.mockResolvedValue(capturaLocal) - const { res, json } = resFake() - - await detalleContactoEndpoint({ params: { id: 'loc_1' } } as unknown as Request, res) - - expect(mondayMock.getContactoDetalle).not.toHaveBeenCalled() - const detalle = json.mock.calls[0]?.[0] as Record - expect(detalle['contacto']).toMatchObject({ nombre: 'ANA DEMO', telefono: '6649998877' }) - expect(detalle['propiedad']).toBeNull() - expect((detalle['relaciones'] as Record)['oportunidades']).toHaveLength(1) - expect(Array.isArray(detalle['historial'])).toBe(true) - }) - - it('Monday caído y el id no existe en local → 404', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) - await expect( - detalleContactoEndpoint({ params: { id: 'nope' } } as unknown as Request, resFake().res), - ).rejects.toThrow(NotFoundError) - }) - - it('Monday lanza 502: cae al detalle local; el 404 de Monday se propaga intacto', async () => { - mondayMock.getContactoDetalle.mockRejectedValue(new AppError(502, 'Monday no respondió')) - prismaMock.leadCaptura.findUnique.mockResolvedValue(capturaLocal) - const { res, json } = resFake() - await detalleContactoEndpoint({ params: { id: 'loc_1' } } as unknown as Request, res) - expect(json).toHaveBeenCalledOnce() - - mondayMock.getContactoDetalle.mockRejectedValue(new NotFoundError('Contacto no encontrado')) - await expect( - detalleContactoEndpoint({ params: { id: 'x' } } as unknown as Request, resFake().res), - ).rejects.toThrow(NotFoundError) - }) -}) diff --git a/api-gateway/src/controllers/contactos.controller.test.ts b/api-gateway/src/controllers/contactos.controller.test.ts index 21b8bc9..32e6cd1 100644 --- a/api-gateway/src/controllers/contactos.controller.test.ts +++ b/api-gateway/src/controllers/contactos.controller.test.ts @@ -1,23 +1,58 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Request, Response } from 'express' -import { listarContactosEndpoint } from './contactos.controller.js' -// ---------- Monday mockeado (los tests no tocan la red) ---------- +// ---------- Prisma mockeado (los tests no tocan la BD) ---------- -const mondayMock = vi.hoisted(() => ({ getContactos: vi.fn() })) - -vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock })) - -const CONTACTOS = [ - { - id: '1', - nombre: 'JUAN PÉREZ', - telefono: '5512345678', - email: 'juan@correo.mx', - estado: 'CLIENTE', - updatedAt: '2026-09-01T10:00:00Z', +const prismaMock = vi.hoisted(() => ({ + cliente: { + findFirst: vi.fn(), + findMany: vi.fn(), }, -] + leadCaptura: { + findUnique: vi.fn(), + findMany: vi.fn(), + }, + cotizacion: { + findMany: vi.fn(), + }, + documento: { + findMany: vi.fn(), + }, + usuario: { + findMany: vi.fn(), + }, +})) + +vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) + +import { detalleContactoEndpoint, listarContactosEndpoint } from './contactos.controller.js' + +/** + * Contactos 100% locales: la lista y el detalle se arman desde las tablas + * locales (Cliente + LeadCaptura + Documento + Cotizacion) con la misma forma + * de DTO que consumen los fronts. + */ + +const capturaLocal = { + id: 'row-1', + leadId: 'loc_1', + nombreLead: 'ANA DEMO', + telefono: '6649998877', + email: null, + origen: 'WhatsApp', + desarrollo: 'SENDA', + modelo: '1R', + board: 'OPORTUNIDADES', + etapa: 'Afluencia', + motivo: null, + submotivo: null, + datosJson: null, + mondayItemId: null, + pendienteSync: true, + usuarioId: 'u-1', + createdAt: new Date('2026-09-02T10:00:00'), + updatedAt: new Date('2026-09-03T10:00:00'), +} function resFake() { const json = vi.fn() @@ -27,21 +62,56 @@ function resFake() { beforeEach(() => { vi.clearAllMocks() - mondayMock.getContactos.mockResolvedValue(CONTACTOS) + prismaMock.cliente.findFirst.mockResolvedValue(null) + prismaMock.cliente.findMany.mockResolvedValue([]) + prismaMock.leadCaptura.findUnique.mockResolvedValue(null) + prismaMock.leadCaptura.findMany.mockResolvedValue([capturaLocal]) + prismaMock.cotizacion.findMany.mockResolvedValue([]) + prismaMock.documento.findMany.mockResolvedValue([]) + prismaMock.usuario.findMany.mockResolvedValue([{ id: 'u-1', nombre: 'Martin Zavala' }]) }) -describe('listarContactosEndpoint (GET /api/contactos)', () => { - it('devuelve los contactos del tablero de Monday', async () => { +describe('listarContactosEndpoint (GET /api/contactos) — local', () => { + it('lista desde LeadCaptura/Cliente con la misma forma (fuente local)', async () => { const { res, json } = resFake() - await listarContactosEndpoint({} as Request, res) + await listarContactosEndpoint({ usuarioToken: { sub: 'u-1' } } as unknown as Request, res) - expect(json).toHaveBeenCalledWith(CONTACTOS) + const lista = json.mock.calls[0]?.[0] as Record[] + expect(lista[0]).toMatchObject({ id: 'loc_1', nombre: 'ANA DEMO', estado: 'Afluencia', fuente: 'local' }) }) - it('propaga el error si Monday falla (el error handler responde 5xx)', async () => { - mondayMock.getContactos.mockRejectedValue(new Error('Monday caído')) + it('filtra por el usuario del token (sus clientes y sus capturas)', async () => { const { res } = resFake() - await expect(listarContactosEndpoint({} as Request, res)).rejects.toThrow('Monday caído') + + await listarContactosEndpoint({ usuarioToken: { sub: 'u-1' } } as unknown as Request, res) + + expect(prismaMock.cliente.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { capturadoPorId: 'u-1' } }), + ) + expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ usuarioId: 'u-1' }) }), + ) + }) +}) + +describe('detalleContactoEndpoint (GET /api/contactos/:id/detalle) — local', () => { + it('detalle 100% local con la misma forma', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue(capturaLocal) + const { res, json } = resFake() + + await detalleContactoEndpoint({ params: { id: 'loc_1' } } as unknown as Request, res) + + const detalle = json.mock.calls[0]?.[0] as Record + expect(detalle['contacto']).toMatchObject({ nombre: 'ANA DEMO', telefono: '6649998877' }) + expect(detalle['propiedad']).toBeNull() + expect((detalle['relaciones'] as Record)['oportunidades']).toHaveLength(1) + expect(Array.isArray(detalle['historial'])).toBe(true) + }) + + it('el id no existe en local → 404', async () => { + await expect( + detalleContactoEndpoint({ params: { id: 'nope' } } as unknown as Request, resFake().res), + ).rejects.toMatchObject({ status: 404 }) }) }) diff --git a/api-gateway/src/controllers/contactos.controller.ts b/api-gateway/src/controllers/contactos.controller.ts index a8be44f..b125b6e 100644 --- a/api-gateway/src/controllers/contactos.controller.ts +++ b/api-gateway/src/controllers/contactos.controller.ts @@ -1,128 +1,28 @@ import type { Request, Response } from 'express' -import { mondayService } from '../services/monday.service.js' -import { detalleContacto, claveTelefono } from '../services/contactos-detalle.service.js' import { detalleContactoLocal, listarContactosLocal, exportarCSV } from '../services/contactos-local.service.js' import { perfilCompleto } from '../services/contactos-detalle.service.js' -import { saludMonday } from '../services/salud-monday.service.js' -import { esFalloMonday } from '../services/sync-monday.service.js' -import { prisma } from '../services/prisma.js' import { NotFoundError } from '../utils/errors.js' /** - * GET /api/contactos (Bearer) → contactos del tablero Contactos de Monday - * (nombre, teléfono, email, estado), lo más reciente primero, máx 100. - * Monday caído (o 502) → lista local (Cliente + LeadCaptura), mismo shape. + * GET /api/contactos (Bearer) → contactos locales (tabla Cliente + LeadCaptura: + * nombre, teléfono, email, estado), lo más reciente primero, máx 100. Filtra + * por usuario actual: clientes capturados por él + leads asignados a él. */ export async function listarContactosEndpoint(req: Request, res: Response): Promise { - const usuarioId = req.usuarioToken?.sub - if (!saludMonday.estaDisponible()) { - res.json(await listarContactosLocal(usuarioId)) - return - } - try { - res.json(await mondayService.getContactos()) - } catch (err) { - if (!esFalloMonday(err)) throw err - res.json(await listarContactosLocal(usuarioId)) - } + res.json(await listarContactosLocal(req.usuarioToken?.sub)) } /** - * GET /api/contactos/:id/detalle (Bearer) → ficha completa del contacto: - * todas sus columnas, la propiedad comprada/en proceso (apartado relacionado), - * quién atiende cada item relacionado (leads, oportunidades, apartados) y el - * historial completo de atención (activity log de Monday + capturas locales). + * GET /api/contactos/:id/detalle (Bearer) → ficha completa del contacto armada + * 100% local: sus datos, la propiedad comprada/en proceso (apartado + * relacionado), quién atiende cada item relacionado (leads, oportunidades, + * apartados) y el historial de atención (capturas, altas, documentos y + * cotizaciones locales). */ export async function detalleContactoEndpoint(req: Request, res: Response): Promise { - const contactoId = String(req.params['id']) - - // Monday caído (circuit breaker): detalle 100% local - if (!saludMonday.estaDisponible()) { - const detalle = await detalleContactoLocal(contactoId) - if (detalle === null) throw new NotFoundError('Contacto no encontrado') - res.json(detalle) - return - } - - try { - const contacto = await mondayService.getContactoDetalle(contactoId) - if (contacto === null) throw new NotFoundError('Contacto no encontrado') - - // Nombres para "atendidoPor"/historial: usuarios del ecosistema + workspace - const mapeados = await prisma.usuario.findMany({ - where: { mondayUserId: { not: null }, activo: true }, - select: { nombre: true, mondayUserId: true }, - }) - const nombresPorMondayId = new Map( - mapeados.filter((m) => m.mondayUserId !== null).map((m) => [m.mondayUserId!, m.nombre]), - ) - try { - for (const u of await mondayService.getUsuariosWorkspace()) { - if (!nombresPorMondayId.has(u.id)) nombresPorMondayId.set(u.id, u.nombre) - } - } catch (err) { - // eslint-disable-next-line no-console - console.error('No se pudieron listar los usuarios del workspace de Monday:', err) - } - - // Registro local: capturas con el mismo teléfono (Monday no guarda quién - // capturó) y la ficha de cliente ligada a este contacto (o por celular). - // LeadCaptura/Cliente no tienen relación Prisma a Usuario: se resuelve a mano. - const clave = claveTelefono(contacto['Teléfono'] ?? '') - const capturasTodas = await prisma.leadCaptura.findMany({ - where: { telefono: { not: null } }, - orderBy: { createdAt: 'desc' }, - }) - const capturasCrudas = capturasTodas.filter((c) => c.telefono !== null && clave !== '' && claveTelefono(c.telefono) === clave) - - const clienteCrudo = await prisma.cliente.findFirst({ - where: { - OR: [ - { mondayContactoId: contactoId }, - ...(clave !== '' ? [{ celular: { contains: clave } }] : []), - ], - }, - include: { _count: { select: { documentos: true } } }, - orderBy: { createdAt: 'desc' }, - }) - - // Nombres de capturistas (ids locales → nombre para mostrar) - const idsUsuarios = [ - ...new Set([ - ...capturasCrudas.map((c) => c.usuarioId), - ...(clienteCrudo?.capturadoPorId ? [clienteCrudo.capturadoPorId] : []), - ]), - ] - const usuariosLocal = idsUsuarios.length - ? await prisma.usuario.findMany({ where: { id: { in: idsUsuarios } }, select: { id: true, nombre: true } }) - : [] - const nombreLocal = new Map(usuariosLocal.map((u) => [u.id, u.nombre])) - - const capturas = capturasCrudas.map((c) => ({ - leadId: c.leadId, - nombreLead: c.nombreLead, - createdAt: c.createdAt, - usuario: nombreLocal.has(c.usuarioId) ? { nombre: nombreLocal.get(c.usuarioId)! } : null, - })) - const clienteLocal = clienteCrudo - ? { - ...clienteCrudo, - capturadoPor: - clienteCrudo.capturadoPorId && nombreLocal.has(clienteCrudo.capturadoPorId) - ? { nombre: nombreLocal.get(clienteCrudo.capturadoPorId)! } - : null, - } - : null - - res.json(await detalleContacto(mondayService, contactoId, nombresPorMondayId, capturas, clienteLocal)) - } catch (err) { - // Monday se cayó a media lectura: detalle local (el 404 de "no existe en - // Monday" NO es fallo de Monday y se propaga intacto) - if (!esFalloMonday(err)) throw err - const detalle = await detalleContactoLocal(contactoId) - if (detalle === null) throw new NotFoundError('Contacto no encontrado') - res.json(detalle) - } + const detalle = await detalleContactoLocal(String(req.params['id'])) + if (detalle === null) throw new NotFoundError('Contacto no encontrado') + res.json(detalle) } /** diff --git a/api-gateway/src/controllers/cotizaciones-fallback.test.ts b/api-gateway/src/controllers/cotizaciones-fallback.test.ts deleted file mode 100644 index 6857ab1..0000000 --- a/api-gateway/src/controllers/cotizaciones-fallback.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Request, Response } from 'express' -import { AppError, NotFoundError } from '../utils/errors.js' - -// ---------- Prisma, Monday y Funnel mockeados (los tests no tocan BD ni red) ---------- - -const prismaMock = vi.hoisted(() => ({ - unidad: { - findUnique: vi.fn(), - }, - cotizacion: { - create: vi.fn(), - update: vi.fn(), - }, - syncPendiente: { - create: vi.fn(), - }, -})) - -const mondayMock = vi.hoisted(() => ({ - getUnidad: vi.fn(), - crearOportunidad: vi.fn(), - actualizarOportunidadCotizacion: vi.fn(), -})) - -const funnelMock = vi.hoisted(() => ({ - oportunidadPorTelefono: vi.fn(), -})) - -vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) -vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock })) -vi.mock('../services/funnel.service.js', () => ({ funnelService: funnelMock })) -vi.mock('../services/auditoria.service.js', () => ({ - registrar: vi.fn(), - buscar: vi.fn(), -})) - -import { crearCotizacion } from './cotizaciones.controller.js' -import { saludMonday } from '../services/salud-monday.service.js' - -/** - * Fallback del getUnidad obligatorio al guardar cotización: con Monday caído - * (o 502) la unidad sale del espejo local; si no existe en ninguno → 404. - */ - -const filaUnidad = { - codigo: 'U-101', - desarrollo: 'DIAGONAL', - modelo: '2RA', - supConstruccion: 60, - supExcedente: 0, - supEstacionamiento: 0, - precioLista: 1_000_000, - precioAcabados: 1_100_000, - status: 'DISPONIBLE', - etapa: '', -} - -const bodyBase = { - unidadCodigo: 'U-101', - acabados: false, - clienteNombre: 'Cliente Demo', - clienteTelefono: '4771234567', - tipoPropuesta: 'BANCARIO' as const, -} - -function resFake() { - const json = vi.fn() - const status = vi.fn().mockReturnValue({ json }) - return { res: { json, status } as unknown as Response, status, json } -} - -beforeEach(() => { - vi.clearAllMocks() - vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible') - saludMonday.restablecer() - prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad) - prismaMock.cotizacion.create.mockImplementation(async ({ data }) => ({ id: 'cot-1', ...data })) - prismaMock.cotizacion.update.mockResolvedValue({}) - prismaMock.syncPendiente.create.mockResolvedValue({}) - mondayMock.crearOportunidad.mockResolvedValue('monday-op-1') - mondayMock.actualizarOportunidadCotizacion.mockResolvedValue(undefined) - funnelMock.oportunidadPorTelefono.mockResolvedValue(null) -}) - -describe('crearCotizacion (getUnidad con fallback local)', () => { - it('Monday caído: cotiza con la unidad del espejo local (sin llamar a getUnidad)', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) - const { res, status, json } = resFake() - - await crearCotizacion({ body: bodyBase } as Request, res) - - expect(mondayMock.getUnidad).not.toHaveBeenCalled() - expect(prismaMock.unidad.findUnique).toHaveBeenCalledWith({ where: { codigo: 'U-101' } }) - expect(status).toHaveBeenCalledWith(201) - const respuesta = json.mock.calls[0]?.[0] as Record - expect(respuesta).toMatchObject({ unidadCodigo: 'U-101', desarrollo: 'DIAGONAL' }) - expect(respuesta['propuesta']).toBeDefined() - }) - - it('Monday lanza 502 en getUnidad: mismo fallback local', async () => { - mondayMock.getUnidad.mockRejectedValue(new AppError(502, 'Monday no respondió')) - const { res, status } = resFake() - - await crearCotizacion({ body: bodyBase } as Request, res) - - expect(status).toHaveBeenCalledWith(201) - }) - - it('la unidad no existe ni en Monday ni en local → 404 como hoy', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) - prismaMock.unidad.findUnique.mockResolvedValue(null) - const { res } = resFake() - - await expect(crearCotizacion({ body: bodyBase } as Request, res)).rejects.toThrow(NotFoundError) - expect(prismaMock.cotizacion.create).not.toHaveBeenCalled() - }) -}) diff --git a/api-gateway/src/controllers/cotizaciones.controller.test.ts b/api-gateway/src/controllers/cotizaciones.controller.test.ts index b01ec6c..3b2a9a6 100644 --- a/api-gateway/src/controllers/cotizaciones.controller.test.ts +++ b/api-gateway/src/controllers/cotizaciones.controller.test.ts @@ -1,48 +1,59 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Request, Response } from 'express' -// ---------- Prisma, Monday y Funnel mockeados (los tests no tocan BD ni red) ---------- +// ---------- Prisma mockeado (los tests no tocan BD ni red) ---------- const prismaMock = vi.hoisted(() => ({ + unidad: { + findUnique: vi.fn(), + }, + leadCaptura: { + findFirst: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + }, + leadOportunidad: { + findUnique: vi.fn(), + }, cotizacion: { create: vi.fn(), update: vi.fn(), + findMany: vi.fn(), }, - syncPendiente: { + actividadLocal: { create: vi.fn(), }, + automatizacionState: { + findUnique: vi.fn(), + upsert: vi.fn(), + }, auditoriaLog: { create: vi.fn().mockResolvedValue({}), }, -})) - -const mondayMock = vi.hoisted(() => ({ - getUnidad: vi.fn(), - crearOportunidad: vi.fn(), - actualizarOportunidadCotizacion: vi.fn(), -})) - -const funnelMock = vi.hoisted(() => ({ - oportunidadPorTelefono: vi.fn(), + $transaction: vi.fn(), })) vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) -vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock })) -vi.mock('../services/funnel.service.js', () => ({ funnelService: funnelMock })) -import { crearCotizacion } from './cotizaciones.controller.js' +import { alertasInventarioEndpoint, crearCotizacion } from './cotizaciones.controller.js' -const unidadFake = { +/** + * Cotizaciones 100% locales: la unidad sale del espejo (tabla Unidad), la + * cotización se persiste en SQLite y se liga a la oportunidad local (que pasa + * a etapa 'Cotizador'). + */ + +const filaUnidad = { codigo: 'U-101', - modelo: 'M1', - fechaEntrega: null, - supEstacionamiento: 0, + desarrollo: 'DIAGONAL', + modelo: '2RA', supConstruccion: 60, supExcedente: 0, - precioSinAcabados: 1_000_000, - precioConAcabados: 1_100_000, + supEstacionamiento: 0, + precioLista: 1_000_000, + precioAcabados: 1_100_000, status: 'DISPONIBLE', - desarrollo: 'DIAGONAL', + etapa: '', } const bodyBase = { @@ -54,7 +65,7 @@ const bodyBase = { } function reqFake(body: unknown): Request { - return { body } as Request + return { body, usuarioToken: { sub: 'u-1' } } as unknown as Request } function resFake() { @@ -65,111 +76,164 @@ function resFake() { beforeEach(() => { vi.clearAllMocks() - mondayMock.getUnidad.mockResolvedValue(unidadFake) - mondayMock.crearOportunidad.mockResolvedValue('op-nueva') - mondayMock.actualizarOportunidadCotizacion.mockResolvedValue(undefined) - funnelMock.oportunidadPorTelefono.mockResolvedValue(null) + prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad) + prismaMock.leadCaptura.findFirst.mockResolvedValue(null) + prismaMock.leadCaptura.findUnique.mockResolvedValue(null) + prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data })) + prismaMock.leadOportunidad.findUnique.mockResolvedValue(null) prismaMock.cotizacion.create.mockImplementation(async ({ data }) => ({ id: 'cot-1', ...data })) prismaMock.cotizacion.update.mockResolvedValue({}) + prismaMock.cotizacion.findMany.mockResolvedValue([]) + prismaMock.actividadLocal.create.mockImplementation(async ({ data }) => ({ id: 'act-1', ...data })) + prismaMock.automatizacionState.findUnique.mockResolvedValue(null) + prismaMock.automatizacionState.upsert.mockResolvedValue({}) + prismaMock.$transaction.mockImplementation(async (ops: Promise[]) => Promise.all(ops)) }) -describe('crearCotizacion (POST /api/cotizaciones) — match con el funnel', () => { - it('sin oportunidad mapeada para el teléfono: crea una nueva (comportamiento actual)', async () => { +describe('crearCotizacion (POST /api/cotizaciones) — local', () => { + it('cotiza con la unidad del espejo local (tabla Unidad)', async () => { const { res, status, json } = resFake() + await crearCotizacion(reqFake(bodyBase), res) + + expect(prismaMock.unidad.findUnique).toHaveBeenCalledWith({ where: { codigo: 'U-101' } }) expect(status).toHaveBeenCalledWith(201) - expect(mondayMock.crearOportunidad).toHaveBeenCalledOnce() - expect(mondayMock.actualizarOportunidadCotizacion).not.toHaveBeenCalled() - expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'op-nueva' }) + const respuesta = json.mock.calls[0]?.[0] as Record + expect(respuesta).toMatchObject({ unidadCodigo: 'U-101', desarrollo: 'DIAGONAL', mondayItemId: null }) + expect(respuesta['propuesta']).toBeDefined() + expect(respuesta['folio']).toBeDefined() }) - it('con teléfono de lead mapeado: ACTUALIZA esa oportunidad (no duplica) y la pasa a Cotizador', async () => { - funnelMock.oportunidadPorTelefono.mockResolvedValue('op-del-lead') - const { res, status, json } = resFake() - await crearCotizacion(reqFake(bodyBase), res) - expect(status).toHaveBeenCalledWith(201) - expect(funnelMock.oportunidadPorTelefono).toHaveBeenCalledWith('4771234567') - expect(mondayMock.crearOportunidad).not.toHaveBeenCalled() - expect(mondayMock.actualizarOportunidadCotizacion).toHaveBeenCalledOnce() - const [opId, datos] = mondayMock.actualizarOportunidadCotizacion.mock.calls[0] as [string, Record] - expect(opId).toBe('op-del-lead') - expect(datos).toMatchObject({ unidadCodigo: 'U-101', tipoPropuestaLabel: 'CREDITO BANCARIO', estadoLabel: 'Cotizador' }) - expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'op-del-lead' }) - expect(prismaMock.cotizacion.update).toHaveBeenCalledWith({ - where: { id: 'cot-1' }, - data: { mondayItemId: 'op-del-lead' }, - }) - }) - - it('sin teléfono en el body no consulta el funnel', async () => { + it('la unidad no existe en el espejo → 404 sin crear cotización', async () => { + prismaMock.unidad.findUnique.mockResolvedValue(null) const { res } = resFake() - const { clienteTelefono: _omitido, ...sinTelefono } = bodyBase - await crearCotizacion(reqFake(sinTelefono), res) - expect(funnelMock.oportunidadPorTelefono).not.toHaveBeenCalled() - expect(mondayMock.crearOportunidad).toHaveBeenCalledOnce() + + await expect(crearCotizacion(reqFake(bodyBase), res)).rejects.toMatchObject({ status: 404 }) + expect(prismaMock.cotizacion.create).not.toHaveBeenCalled() }) - it('si Monday falla, la cotización igual se crea (201 con mondayItemId null) y la vinculación queda encolada', async () => { - mondayMock.crearOportunidad.mockRejectedValue(new Error('Monday caído')) - vi.spyOn(console, 'error').mockImplementation(() => {}) - prismaMock.syncPendiente.create.mockResolvedValue({}) + it('sin oportunidad mapeada para el teléfono: mondayItemId null (no crea oportunidades)', async () => { const { res, status, json } = resFake() + await crearCotizacion(reqFake(bodyBase), res) + expect(status).toHaveBeenCalledWith(201) expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: null }) - expect(prismaMock.cotizacion.create).toHaveBeenCalledOnce() - // COTIZACION_OPORTUNIDAD con los datos completos + teléfono para el match del worker - expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ - tipo: 'COTIZACION_OPORTUNIDAD', - referenciaLocal: 'cot-1', - }), - }) - const llamada = prismaMock.syncPendiente.create.mock.calls[0]?.[0] as { data: { payloadJson: string } } - expect(JSON.parse(llamada.data.payloadJson)).toMatchObject({ - clienteTelefono: '4771234567', - datos: { unidadCodigo: 'U-101', estadoLabel: 'Cotizador' }, - }) }) - it('si falla la actualización con oportunidadId explícito, se encola con ESE id', async () => { - mondayMock.actualizarOportunidadCotizacion.mockRejectedValue(new Error('Monday caído')) - vi.spyOn(console, 'error').mockImplementation(() => {}) - prismaMock.syncPendiente.create.mockResolvedValue({}) - const { res, status } = resFake() - await crearCotizacion(reqFake({ ...bodyBase, oportunidadId: 'opp-midia-1' }), res) - expect(status).toHaveBeenCalledWith(201) - const llamada = prismaMock.syncPendiente.create.mock.calls[0]?.[0] as { data: { payloadJson: string } } - expect(JSON.parse(llamada.data.payloadJson)).toMatchObject({ oportunidadId: 'opp-midia-1' }) - }) - - it('con oportunidadId explícito (botón "Cotizar" de Mi día): actualiza ESE item y no consulta el funnel', async () => { + it('con teléfono de una afluencia local: LIGA esa oportunidad y la pasa a Cotizador', async () => { + prismaMock.leadCaptura.findFirst.mockResolvedValue({ + leadId: 'loc_op_1', + board: 'OPORTUNIDADES', + telefono: '4771234567', + }) + prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_op_1', board: 'OPORTUNIDADES' }) const { res, status, json } = resFake() - await crearCotizacion(reqFake({ ...bodyBase, oportunidadId: 'opp-midia-1' }), res) + + await crearCotizacion(reqFake(bodyBase), res) + expect(status).toHaveBeenCalledWith(201) - expect(funnelMock.oportunidadPorTelefono).not.toHaveBeenCalled() - expect(mondayMock.crearOportunidad).not.toHaveBeenCalled() - expect(mondayMock.actualizarOportunidadCotizacion).toHaveBeenCalledOnce() - const [opId, datos] = mondayMock.actualizarOportunidadCotizacion.mock.calls[0] as [string, Record] - expect(opId).toBe('opp-midia-1') - expect(datos).toMatchObject({ estadoLabel: 'Cotizador' }) - // La cotización queda vinculada al item del cliente + expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'loc_op_1' }) + expect(prismaMock.cotizacion.update).toHaveBeenCalledWith({ + where: { id: 'cot-1' }, + data: { mondayItemId: 'loc_op_1' }, + }) + // La oportunidad pasa a etapa 'Cotizador' localmente + expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ + where: { leadId: 'loc_op_1' }, + data: { etapa: 'Cotizador', pendienteSync: true }, + }) + }) + + it('con teléfono de un lead histórico: usa la oportunidad del mapping lead→oportunidad', async () => { + prismaMock.leadCaptura.findFirst.mockResolvedValue({ + leadId: 'loc_lead_1', + board: 'LEADS', + telefono: '4771234567', + }) + prismaMock.leadOportunidad.findUnique.mockResolvedValue({ oportunidadId: 'loc_op_mapping' }) + prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_op_mapping', board: 'OPORTUNIDADES' }) + const { res, status, json } = resFake() + + await crearCotizacion(reqFake(bodyBase), res) + + expect(status).toHaveBeenCalledWith(201) + expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'loc_op_mapping' }) + }) + + it('con oportunidadId explícito (botón "Cotizar" de Mi día): liga ESE id sin buscar por teléfono', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'opp-midia-1', board: 'OPORTUNIDADES' }) + const { res, status, json } = resFake() + + await crearCotizacion(reqFake({ ...bodyBase, oportunidadId: 'opp-midia-1' }), res) + + expect(status).toHaveBeenCalledWith(201) + expect(prismaMock.leadCaptura.findFirst).not.toHaveBeenCalled() expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'opp-midia-1' }) expect(prismaMock.cotizacion.update).toHaveBeenCalledWith({ where: { id: 'cot-1' }, data: { mondayItemId: 'opp-midia-1' }, }) }) -}) -describe('LABEL_PROPUESTA_MONDAY (códigos del cotizador → labels reales del board)', () => { - it('los 5 códigos del enum mapean a los labels sembrados en la columna "Tipo propuesta"', async () => { - const { LABEL_PROPUESTA_MONDAY } = await import('./cotizaciones.controller.js') - // Labels reales verificados en Monday (difieren en guiones/espacios) - expect(LABEL_PROPUESTA_MONDAY['BANCARIO']).toBe('CREDITO BANCARIO') - expect(LABEL_PROPUESTA_MONDAY['INFONAVIT']).toBe('INFONAVIT-COFINAVIT') - expect(LABEL_PROPUESTA_MONDAY['COFINAVIT']).toBe('INFONAVIT-COFINAVIT') - expect(LABEL_PROPUESTA_MONDAY['CONTADO']).toBe('CONTADO') - expect(LABEL_PROPUESTA_MONDAY['SEMI_CONTADO']).toBe('SEMI CONTADO') + it('sin teléfono en el body no busca oportunidades', async () => { + const { res } = resFake() + const { clienteTelefono: _omitido, ...sinTelefono } = bodyBase + + await crearCotizacion(reqFake(sinTelefono), res) + + expect(prismaMock.leadCaptura.findFirst).not.toHaveBeenCalled() + }) +}) + +describe('alertasInventarioEndpoint (POST /api/cotizaciones/alertas-inventario) — actividades locales', () => { + it('crea una ActividadLocal al vendedor de cada cotización reciente, con dedup', async () => { + prismaMock.cotizacion.findMany.mockResolvedValue([{ id: 'cot-9', vendedorId: 'usr-1' }]) + const { res, json } = resFake() + + await alertasInventarioEndpoint( + { body: { cambios: [{ unidadCodigo: 'U-101', statusNuevo: 'APARTADA' }] } } as unknown as Request, + res, + ) + + expect(json).toHaveBeenCalledWith({ cambiosProcesados: 1, alertasCreadas: 1, omitidas: 0, fallidas: 0 }) + expect(prismaMock.actividadLocal.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + titulo: 'La unidad U-101 que cotizaste pasó a APARTADA', + usuarioId: 'usr-1', + }), + }) + expect(prismaMock.automatizacionState.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { clave: 'alerta-inv:U-101:cot-9:APARTADA' }, + }), + ) + }) + + it('sin vendedor en la cotización: omitida (sin actividad)', async () => { + prismaMock.cotizacion.findMany.mockResolvedValue([{ id: 'cot-9', vendedorId: null }]) + const { res, json } = resFake() + + await alertasInventarioEndpoint( + { body: { cambios: [{ unidadCodigo: 'U-101', statusNuevo: 'VENDIDA' }] } } as unknown as Request, + res, + ) + + expect(json).toHaveBeenCalledWith({ cambiosProcesados: 1, alertasCreadas: 0, omitidas: 1, fallidas: 0 }) + expect(prismaMock.actividadLocal.create).not.toHaveBeenCalled() + }) + + it('ya alertada (dedup persistente): omitida', async () => { + prismaMock.cotizacion.findMany.mockResolvedValue([{ id: 'cot-9', vendedorId: 'usr-1' }]) + prismaMock.automatizacionState.findUnique.mockResolvedValue({ clave: 'x', valor: 'y' }) + const { res, json } = resFake() + + await alertasInventarioEndpoint( + { body: { cambios: [{ unidadCodigo: 'U-101', statusNuevo: 'APARTADA' }] } } as unknown as Request, + res, + ) + + expect(json).toHaveBeenCalledWith({ cambiosProcesados: 1, alertasCreadas: 0, omitidas: 1, fallidas: 0 }) + expect(prismaMock.actividadLocal.create).not.toHaveBeenCalled() }) }) diff --git a/api-gateway/src/controllers/cotizaciones.controller.ts b/api-gateway/src/controllers/cotizaciones.controller.ts index 615a263..ce33d4d 100644 --- a/api-gateway/src/controllers/cotizaciones.controller.ts +++ b/api-gateway/src/controllers/cotizaciones.controller.ts @@ -1,15 +1,14 @@ import type { Request, Response } from 'express' import { z } from 'zod' -import { mondayService, type DatosOportunidad } from '../services/monday.service.js' -import { obtenerUnidadConFallback } from '../services/unidades-local.service.js' -import { funnelService } from '../services/funnel.service.js' +import { getUnidadLocal } from '../services/unidades-local.service.js' import { calcularPropuesta, obtenerPrecioLista } from '../services/cotizacion.service.js' import { procesarAlertasInventario, type ResultadoAlertas, } from '../services/alertas-inventario.service.js' import { prisma } from '../services/prisma.js' -import { encolarSyncSeguro } from '../services/crm-local.service.js' +import { crmLocal, esIdLocal } from '../services/crm-local.service.js' +import { actividadLocalService } from '../services/actividad-local.service.js' import { NotFoundError } from '../utils/errors.js' import * as auditoria from '../services/auditoria.service.js' import { folioCotizacion, vigenciaCotizacion } from '../utils/folio.js' @@ -26,35 +25,36 @@ const crearCotizacionSchema = z.object({ // Enganche elegido por el asesor (fracción, mínimo 2.5%); solo aplica a // esquemas con crédito (bancario/Infonavit) enganchePct: z.number().min(0.025, 'El enganche mínimo es 2.5%').max(1).optional(), - // Item del tablero Oportunidades al que queda vinculada la cotización - // (botón "Cotizar" de Mi día manda el id; sin él se busca por teléfono) + // Oportunidad a la que queda vinculada la cotización (botón "Cotizar" de Mi + // día manda el id; sin él se busca por teléfono) oportunidadId: z.string().trim().min(1).optional(), }) -const hoyISO = (): string => { - const d = new Date() - const m = String(d.getMonth() + 1).padStart(2, '0') - const dia = String(d.getDate()).padStart(2, '0') - return `${d.getFullYear()}-${m}-${dia}` +/** + * Oportunidad local que tenga ese teléfono (match de cotizaciones). Mismo + * criterio que el funnel: primero el mapping histórico (lead → su + * oportunidad); si no hay lead con ese número, la captura directa en + * Oportunidades (modelo nuevo: la afluencia ES la oportunidad). + */ +async function oportunidadLocalPorTelefono(telefono: string): Promise { + const tel = telefono.replace(/\D/g, '').slice(-10) + if (tel.length < 7) return null + const captura = await prisma.leadCaptura.findFirst({ + where: { telefono: { contains: tel } }, + orderBy: { updatedAt: 'desc' }, + }) + if (!captura) return null + const mapeada = await prisma.leadOportunidad.findUnique({ where: { leadId: captura.leadId } }) + if (mapeada) return mapeada.oportunidadId + return captura.board === 'OPORTUNIDADES' ? captura.leadId : null } -// Labels reales de la columna status "Tipo de Propuesta" del tablero -// Oportunidades (verificados en Monday: difieren de los códigos del cotizador -// en guiones/espacios — el resolver case-insensitive solo no alcanza) -export const LABEL_PROPUESTA_MONDAY: Record = { - BANCARIO: 'CREDITO BANCARIO', - INFONAVIT: 'INFONAVIT-COFINAVIT', - COFINAVIT: 'INFONAVIT-COFINAVIT', - CONTADO: 'CONTADO', - SEMI_CONTADO: 'SEMI CONTADO', -} - -/** POST /api/cotizaciones — calcula, persiste y registra la oportunidad en Monday */ +/** POST /api/cotizaciones — calcula, persiste en SQLite y liga la oportunidad local */ export async function crearCotizacion(req: Request, res: Response): Promise { const body = crearCotizacionSchema.parse(req.body) - // Monday caído (o 502) → espejo local (tabla Unidad); 404 solo si no existe en ninguno - const unidad = await obtenerUnidadConFallback(body.unidadCodigo) + // Inventario: SIEMPRE el espejo local (tabla Unidad); 404 si no existe + const unidad = await getUnidadLocal(body.unidadCodigo) if (!unidad) throw new NotFoundError(`Unidad ${body.unidadCodigo} no encontrada`) const apartado = body.apartado ?? 20000 // APARTADO_DEFAULT @@ -82,52 +82,27 @@ export async function crearCotizacion(req: Request, res: Response): Promise { const { cambios } = alertasInventarioSchema.parse(req.body) @@ -224,14 +199,14 @@ export async function alertasInventarioEndpoint(req: Request, res: Response): Pr where: { unidadCodigo, createdAt: { gte: desde } }, select: { id: true, vendedorId: true }, }), - mondayUserIdDeVendedor: async (vendedorId) => { - const usuario = await prisma.usuario.findUnique({ - where: { id: vendedorId }, - select: { mondayUserId: true }, + crearActividad: async (datos) => { + const actividad = await actividadLocalService.crearActividad({ + titulo: datos.nombre, + usuarioId: datos.vendedorId, + tipo: 'RECORDATORIO', }) - return usuario?.mondayUserId ?? null + return actividad.id }, - crearActividad: (datos) => mondayService.crearActividad(datos), yaAlertada: async (clave) => { const estado = await prisma.automatizacionState.findUnique({ where: { clave } }) return estado !== null diff --git a/api-gateway/src/controllers/health.controller.ts b/api-gateway/src/controllers/health.controller.ts index 9ce3701..ab5283f 100644 --- a/api-gateway/src/controllers/health.controller.ts +++ b/api-gateway/src/controllers/health.controller.ts @@ -1,8 +1,6 @@ import type { Request, Response } from 'express' -import { mondayService } from '../services/monday.service.js' -/** GET /api/health — ping barato a Monday */ +/** GET /api/health — verificación de vida del API */ export async function health(_req: Request, res: Response): Promise { - const mondayUp = await mondayService.ping() - res.json({ status: 'ok', monday: mondayUp ? 'up' : 'down' }) + res.json({ status: 'ok' }) } diff --git a/api-gateway/src/controllers/leads.controller.test.ts b/api-gateway/src/controllers/leads.controller.test.ts index 38916eb..692b3e7 100644 --- a/api-gateway/src/controllers/leads.controller.test.ts +++ b/api-gateway/src/controllers/leads.controller.test.ts @@ -1,24 +1,17 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' import type { Request, Response } from 'express' import { - buscarLeads, buscarLeadsEndpoint, - cambiarEstadoLead, cambiarEstadoLeadEndpoint, - convertirLead, convertirLeadEndpoint, - crearAfluencia, crearAfluenciaEndpoint, - crearLead, crearLeadEndpoint, - descartarLead, descartarLeadEndpoint, + eliminarLeadEndpoint, + asignarLeadEndpoint, + moverLeadEndpoint, ESTADOS_LEAD_PERMITIDOS, } from './leads.controller.js' -import { mondayService, type LeadMonday, type OportunidadMonday } from '../services/monday.service.js' -import { automatizacionesService } from '../services/automatizaciones.service.js' -import { saludMonday } from '../services/salud-monday.service.js' -import { AppError } from '../utils/errors.js' // ---------- Prisma mockeado (los tests no tocan la BD) ---------- @@ -29,6 +22,8 @@ const prismaMock = vi.hoisted(() => ({ findUnique: vi.fn(), findMany: vi.fn(), update: vi.fn(), + updateMany: vi.fn(), + delete: vi.fn(), }, leadOportunidad: { create: vi.fn(), @@ -36,14 +31,12 @@ const prismaMock = vi.hoisted(() => ({ }, usuario: { findUnique: vi.fn(), + findMany: vi.fn(), }, automatizacionState: { findUnique: vi.fn(), upsert: vi.fn(), }, - syncPendiente: { - create: vi.fn(), - }, cliente: { findMany: vi.fn(), }, @@ -55,286 +48,190 @@ const prismaMock = vi.hoisted(() => ({ vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) -const LABELS_DESARROLLO = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'] -const LABELS_ORIGEN = ['WhatsApp', 'Web', 'Recomendado', 'Facebook', 'Otro', 'Instagram', 'Broker', 'Base de datos', 'Prospección'] -const LABELS_MOTIVO = ['Precio', 'Producto', 'Ubicacion', 'Financiamiento', 'Competencia', 'Cliente', 'Timing'] -const LABELS_MODELO = ['1R', '2RA', '2RB', '2RC', 'HIMALAYA', 'SANTIAGO', 'SOHO', 'MIRADOR', 'PANORAMA'] - -/** Monday mockeado: registra escrituras y devuelve catálogos controlados */ -function mondayMock( - opts: { - labelsOrigen?: string[] - labelsDesarrollo?: string[] - labelsMotivo?: string[] - duplicadoLead?: LeadMonday | null - duplicadoOportunidad?: OportunidadMonday | null - leads?: LeadMonday[] - } = {}, -) { - return { - cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[], - motivos: [] as { nombre: string; categoria: string; descripcion?: string }[], - leadsCreados: [] as Record[], - oportunidadesCreadas: [] as Record[], - movimientosGrupo: [] as { itemId: string; groupId: string }[], - async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) { - this.cambios.push({ boardId, itemId, columna, valor }) - }, - async crearLead(datos: Record) { - this.leadsCreados.push(datos) - return 'lead-nuevo-1' - }, - async crearOportunidadAfluencia(datos: Record) { - this.oportunidadesCreadas.push(datos) - return 'op-nueva-1' - }, - async getLeads() { - return opts.leads ?? [] - }, - oportunidadesActualizadas: [] as { id: string; datos: Record }[], - async actualizarOportunidadAfluencia(id: string, datos: Record) { - this.oportunidadesActualizadas.push({ id, datos }) - }, - async buscarLeadPorTelefono(_telefono: string) { - return opts.duplicadoLead ?? null - }, - async buscarOportunidadPorTelefono(_telefono: string) { - return opts.duplicadoOportunidad ?? null - }, - async labelsDeColumna(_boardId: string, titulo: string) { - if (titulo === 'Desarrollo') return opts.labelsDesarrollo ?? LABELS_DESARROLLO - if (titulo === 'Motivo No Compra') return opts.labelsMotivo ?? LABELS_MOTIVO - if (titulo === 'Modelo de Interés') return LABELS_MODELO - if (titulo === 'Origen del Lead') return opts.labelsOrigen ?? ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'OTRO'] - return opts.labelsOrigen ?? LABELS_ORIGEN - }, - async obtenerNombreItem(_itemId: string) { - return 'Lead Prueba' - }, - async crearMotivoNoCompra(datos: { nombre: string; categoria: string; descripcion?: string }) { - this.motivos.push(datos) - return 'motivo-item-1' - }, - // Para moverAGrupoSegunEstado tras la creación: el item nace en el grupo - // top del tablero (como en Monday real) con Estado 'Afluencia' - async columnaIdPorTitulo(_boardId: string, _titulo: string) { - return 'col_estado' - }, - async getItemConGrupo(itemId: string) { - return { - id: itemId, - name: 'Lead Prueba', - groupId: 'group_top', - column_values: [{ id: 'col_estado', text: 'Afluencia', value: '{"index":1}' }], - } - }, - async moverItemAGrupo(itemId: string, groupId: string) { - this.movimientosGrupo.push({ itemId, groupId }) - }, - } -} - const datosBase = { nombre: 'Lead Prueba', telefono: '4771234567' } -const leadMonday = (overrides: Partial): LeadMonday => ({ - id: 'lead-1', - nombre: 'Lead Prueba', - createdAt: '2026-08-01T00:00:00Z', - updatedAt: '2026-08-01T00:00:00Z', - estado: 'Nuevo', - origen: '', - telefono: '', - email: '', - vendedorIds: [], - ...overrides, +function resFake() { + const json = vi.fn() + const status = vi.fn().mockReturnValue({ json }) + return { res: { json, status } as unknown as Response, status, json } +} + +const reqCon = (overrides: Record): Request => + ({ + body: {}, + params: {}, + query: {}, + usuarioToken: { sub: 'u-1', username: 'asesor uno', rol: 'vendedor' }, + ...overrides, + }) as unknown as Request + +beforeEach(() => { + vi.clearAllMocks() + prismaMock.auditoriaLog.create.mockResolvedValue({}) + prismaMock.leadCaptura.findFirst.mockResolvedValue(null) // sin duplicados locales + prismaMock.leadCaptura.findUnique.mockResolvedValue(null) + prismaMock.leadCaptura.findMany.mockResolvedValue([]) + prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data })) + prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data })) + prismaMock.leadCaptura.updateMany.mockResolvedValue({ count: 1 }) + prismaMock.leadCaptura.delete.mockResolvedValue({}) + prismaMock.leadOportunidad.create.mockResolvedValue({}) + prismaMock.leadOportunidad.findUnique.mockResolvedValue(null) + prismaMock.usuario.findUnique.mockResolvedValue(null) + prismaMock.cliente.findMany.mockResolvedValue([]) + prismaMock.automatizacionState.findUnique.mockResolvedValue(null) + prismaMock.automatizacionState.upsert.mockResolvedValue({}) + prismaMock.$transaction.mockImplementation(async (ops: Promise[]) => Promise.all(ops)) }) -const oportunidadMonday = (overrides: Partial): OportunidadMonday => ({ - id: 'op-1', - nombre: 'Lead Prueba', - createdAt: '2026-08-01T00:00:00Z', - updatedAt: '2026-08-01T00:00:00Z', - etapa: 'Afluencia', - unidadCodigo: '', - precioFinal: 0, - asesorIds: [], - telefono: '', - email: '', - origen: '', - ...overrides, +afterEach(() => { + vi.restoreAllMocks() }) -describe('crearLead (POST /api/leads) — captura rápida en el tablero Leads', () => { - it('crea el item en Leads (ligero, SIN oportunidad) y devuelve su id', async () => { - const monday = mondayMock() - const r = await crearLead(monday, { ...datosBase, origen: 'Facebook', notas: 'Vio el volante' }) - expect(r).toEqual({ id: 'lead-nuevo-1', oportunidadId: null }) - expect(monday.leadsCreados).toEqual([ - { - nombre: 'Lead Prueba', - telefono: '4771234567', - email: undefined, - origenLabel: 'Facebook', - desarrolloLabel: undefined, - notas: 'Vio el volante', - }, - ]) - expect(monday.oportunidadesCreadas).toEqual([]) - expect(monday.movimientosGrupo).toEqual([]) // el grupo lo arregla el webhook de change +describe('crearLeadEndpoint (POST /api/leads) — captura rápida local (tablero Leads)', () => { + it('201: fila LeadCaptura board=LEADS etapa NUEVO', async () => { + const { res, status, json } = resFake() + + await crearLeadEndpoint(reqCon({ body: { nombre: 'Lead Local', telefono: '4771234567' } }), res) + + expect(status).toHaveBeenCalledWith(201) + const cuerpo = json.mock.calls[0]?.[0] as { id: string; asignacion: string } + expect(cuerpo.id).toMatch(/^loc_/) + expect(cuerpo).toMatchObject({ estado: 'NUEVO', oportunidadId: null, asignacion: 'capturista' }) + expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ board: 'LEADS', etapa: 'NUEVO', pendienteSync: true }), + }) + // Los leads no generan oportunidad ni mapping + expect(prismaMock.leadOportunidad.create).not.toHaveBeenCalled() }) - it('pasa email y desarrollo al item de Monday cuando vienen en la captura', async () => { - const monday = mondayMock() - const r = await crearLead(monday, { ...datosBase, email: 'lead@correo.com', desarrollo: 'SENDA' }) - expect(r).toEqual({ id: 'lead-nuevo-1', oportunidadId: null }) - expect(monday.leadsCreados[0]).toMatchObject({ email: 'lead@correo.com', desarrolloLabel: 'SENDA' }) + it('sin token: asignación round-robin y capturista desconocido', async () => { + const { res, status, json } = resFake() + + await crearLeadEndpoint(reqCon({ body: datosBase, usuarioToken: undefined }), res) + + expect(status).toHaveBeenCalledWith(201) + expect(json.mock.calls[0]?.[0]).toMatchObject({ asignacion: 'round-robin' }) }) - it('duplicado por teléfono contra Leads: no crea nada y devuelve el item existente', async () => { - const existente = leadMonday({ id: 'lead-viejo-9', telefono: '4771234567' }) - const monday = mondayMock({ duplicadoLead: existente }) - const r = await crearLead(monday, datosBase) - expect(r).toEqual({ duplicado: { id: 'lead-viejo-9', nombre: 'Lead Prueba' } }) - expect(monday.leadsCreados).toEqual([]) + it('409 si ya hay un lead con ese teléfono (dedup local)', async () => { + prismaMock.leadCaptura.findFirst.mockResolvedValue({ leadId: 'loc_viejo', nombreLead: 'Lead Viejo' }) + const { res, status, json } = resFake() + + await crearLeadEndpoint(reqCon({ body: datosBase }), res) + + expect(status).toHaveBeenCalledWith(409) + expect(json.mock.calls[0]?.[0]).toMatchObject({ leadId: 'loc_viejo', nombre: 'Lead Viejo' }) + expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled() }) - it('rechaza un desarrollo que no sea label real de la columna Desarrollo (board Leads)', async () => { - const monday = mondayMock() - await expect(crearLead(monday, { ...datosBase, desarrollo: 'MONTEBELLO' })).rejects.toThrow(/Desarrollo inválido/) - expect(monday.leadsCreados).toEqual([]) + it('rechaza un origen fuera del vocabulario local (400, sin crear nada)', async () => { + const { res } = resFake() + + await expect(crearLeadEndpoint(reqCon({ body: { ...datosBase, origen: 'Volanteo' } }), res)).rejects.toMatchObject({ + status: 400, + }) + expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled() }) - it('rechaza un origen que no sea label real de la columna Origen (board Leads)', async () => { - const monday = mondayMock() - await expect(crearLead(monday, { ...datosBase, origen: 'Volanteo' })).rejects.toThrow(/Origen inválido/) - expect(monday.leadsCreados).toEqual([]) + it('rechaza un desarrollo fuera del vocabulario local (400)', async () => { + const { res } = resFake() + + await expect( + crearLeadEndpoint(reqCon({ body: { ...datosBase, desarrollo: 'MONTEBELLO' } }), res), + ).rejects.toMatchObject({ status: 400 }) + expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled() + }) + + it('acepta origen y desarrollo del vocabulario (FACEBOOK FRASA / SENDA)', async () => { + const { res, status } = resFake() + + await crearLeadEndpoint(reqCon({ body: { ...datosBase, origen: 'FACEBOOK FRASA', desarrollo: 'SENDA' } }), res) + + expect(status).toHaveBeenCalledWith(201) + expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ origen: 'FACEBOOK FRASA', desarrollo: 'SENDA' }), + }) }) }) -describe('crearAfluencia (POST /api/afluencias) — la afluencia ES la oportunidad', () => { - it('crea UN item en Oportunidades (Estado Afluencia) y devuelve su id como id y oportunidadId', async () => { - const monday = mondayMock() - const r = await crearAfluencia(monday, { ...datosBase, origen: 'PASE', notas: 'Vio el volante' }) - expect(r).toEqual({ id: 'op-nueva-1', oportunidadId: 'op-nueva-1' }) - expect(monday.oportunidadesCreadas).toEqual([ - { - nombre: 'Lead Prueba', - telefono: '4771234567', - email: undefined, - desarrolloLabel: undefined, - origenLabel: 'PASE', - modeloLabel: undefined, - medioContacto: undefined, - edad: undefined, - estadoCivilLabel: undefined, - tipoEmpleoLabel: undefined, - ocupacion: undefined, - rangoSalarial: undefined, - creditoLabel: undefined, - tipoEconomiaLabel: undefined, - calificaLabel: undefined, - loQuierePara: undefined, - notas: 'Vio el volante', - }, - ]) - expect(monday.leadsCreados).toEqual([]) // NO se crea item en Leads - // Y la mueve al grupo de su etapa (Monday la crea en el grupo top, hoy "Perdidos") - expect(monday.movimientosGrupo).toEqual([{ itemId: 'op-nueva-1', groupId: 'topics' }]) - }) +describe('crearAfluenciaEndpoint (POST /api/afluencias) — la afluencia ES la oportunidad', () => { + it('201: fila board=OPORTUNIDADES etapa Afluencia + auto-mapping', async () => { + const { res, status, json } = resFake() - it('pasa los campos de calificación al item de Monday (labels listos para el board)', async () => { - const monday = mondayMock() - const r = await crearAfluencia(monday, { - ...datosBase, - modelo: 'HIMALAYA', - medioContacto: 'WhatsApp', - edad: 35, - estadoCivil: 'Casado(a)', - tipoEmpleo: 'Asalariado', - ocupacion: 'Ingeniero', - rangoSalarial: '$20,000 - $30,000', - credito: 'Infonavit', - tipoEconomia: 'Economía mexicana', - califica: 'Sí', - loQuierePara: 'Vivir ahí', - }) - expect(r).toEqual({ id: 'op-nueva-1', oportunidadId: 'op-nueva-1' }) - expect(monday.oportunidadesCreadas[0]).toMatchObject({ - modeloLabel: 'HIMALAYA', - medioContacto: 'WhatsApp', - edad: 35, - estadoCivilLabel: 'Casado(a)', - tipoEmpleoLabel: 'Asalariado', - ocupacion: 'Ingeniero', - rangoSalarial: '$20,000 - $30,000', - creditoLabel: 'Infonavit', - tipoEconomiaLabel: 'Economía mexicana', - calificaLabel: 'Sí', - loQuierePara: 'Vivir ahí', - }) - }) - - it('duplicado por teléfono contra Oportunidades: no crea nada y devuelve el item existente', async () => { - const existente = oportunidadMonday({ id: 'op-vieja-9', telefono: '4771234567' }) - const monday = mondayMock({ duplicadoOportunidad: existente }) - const r = await crearAfluencia(monday, datosBase) - expect(r).toEqual({ duplicado: { id: 'op-vieja-9', nombre: 'Lead Prueba' } }) - expect(monday.oportunidadesCreadas).toEqual([]) - expect(monday.movimientosGrupo).toEqual([]) - }) - - it('un fallo al mover de grupo no rompe la captura (la oportunidad ya quedó)', async () => { - const monday = mondayMock() - vi.spyOn(monday, 'moverItemAGrupo').mockRejectedValueOnce(new Error('Monday caído')) - const espia = vi.spyOn(console, 'error').mockImplementation(() => {}) - const r = await crearAfluencia(monday, datosBase) - expect(r).toEqual({ id: 'op-nueva-1', oportunidadId: 'op-nueva-1' }) - espia.mockRestore() - }) - - it('con oportunidadId (botón "Afluencia" de Mi día): ACTUALIZA la oportunidad, sin duplicado ni validación de teléfono', async () => { - const monday = mondayMock() - const r = await crearAfluencia(monday, { - ...datosBase, - oportunidadId: 'op-existente-3', - desarrollo: 'SENDA', - modelo: 'SOHO', - edad: 34, - notas: 'Perfil completado en punto de venta', - }) - expect(r).toEqual({ id: 'op-existente-3', oportunidadId: 'op-existente-3', actualizada: true }) - expect(monday.oportunidadesCreadas).toEqual([]) // no se duplica - expect(monday.oportunidadesActualizadas).toEqual([ - { - id: 'op-existente-3', - datos: expect.objectContaining({ - nombre: 'Lead Prueba', - telefono: '4771234567', - desarrolloLabel: 'SENDA', - modeloLabel: 'SOHO', - edad: 34, - notas: 'Perfil completado en punto de venta', - }), - }, - ]) - }) - - it('rechaza un desarrollo que no sea label real de la columna Desarrollo (board Oportunidades)', async () => { - const monday = mondayMock() - await expect(crearAfluencia(monday, { ...datosBase, desarrollo: 'MONTEBELLO' })).rejects.toThrow( - /Desarrollo inválido/, + await crearAfluenciaEndpoint( + reqCon({ body: { nombre: 'Afluencia Local', telefono: '6641112233', modelo: 'SOHO' } }), + res, ) - expect(monday.oportunidadesCreadas).toEqual([]) + + expect(status).toHaveBeenCalledWith(201) + const cuerpo = json.mock.calls[0]?.[0] as { id: string; oportunidadId: string; asignacion: string } + expect(cuerpo.id).toMatch(/^loc_/) + expect(cuerpo.oportunidadId).toBe(cuerpo.id) + expect(cuerpo.asignacion).toBe('capturista') + // Auto-mapping local leadId = oportunidadId + expect(prismaMock.leadOportunidad.create).toHaveBeenCalledWith({ + data: { leadId: cuerpo.id, oportunidadId: cuerpo.id }, + }) }) - it('rechaza un origen que no sea label real del dropdown Origen del Lead', async () => { - const monday = mondayMock() - await expect(crearAfluencia(monday, { ...datosBase, origen: 'Volanteo' })).rejects.toThrow(/Origen inválido/) - expect(monday.oportunidadesCreadas).toEqual([]) + it('409 si ya hay una oportunidad con ese teléfono', async () => { + prismaMock.leadCaptura.findFirst.mockResolvedValue({ leadId: 'loc_op_vieja', nombreLead: 'Op Vieja' }) + const { res, status } = resFake() + + await crearAfluenciaEndpoint(reqCon({ body: datosBase }), res) + + expect(status).toHaveBeenCalledWith(409) + expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled() + }) + + it('validación tolerante: origen/desarrollo fuera del vocabulario se omiten (no 400)', async () => { + const espia = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { res, status } = resFake() + + await crearAfluenciaEndpoint( + reqCon({ body: { nombre: 'Afluencia', telefono: '6641112233', origen: 'Volanteo', desarrollo: 'MONTEBELLO' } }), + res, + ) + + espia.mockRestore() + expect(status).toHaveBeenCalledWith(201) + expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ origen: null, desarrollo: null }), + }) + }) + + it('con oportunidadId (botón "Afluencia" de Mi día): ACTUALIZA la fila local, sin duplicar', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_op_3', board: 'OPORTUNIDADES' }) + const { res, json } = resFake() + + await crearAfluenciaEndpoint( + reqCon({ + body: { + nombre: 'Lead Prueba', + oportunidadId: 'loc_op_3', + desarrollo: 'SENDA', + modelo: 'SOHO', + edad: 34, + }, + }), + res, + ) + + expect(json.mock.calls[0]?.[0]).toEqual({ + id: 'loc_op_3', + estado: 'Afluencia', + oportunidadId: 'loc_op_3', + actualizada: true, + }) + expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ + where: { leadId: 'loc_op_3' }, + data: expect.objectContaining({ etapa: 'Afluencia', modelo: 'SOHO', desarrollo: 'SENDA' }), + }) }) }) -describe('cambiarEstadoLead (POST /api/leads/:itemId/estado)', () => { +describe('cambiarEstadoLeadEndpoint (POST /api/leads/:itemId/estado) — local', () => { it('la whitelist solo permite las transiciones del vendedor', () => { expect(ESTADOS_LEAD_PERMITIDOS).toEqual(['CONTACTADO', 'Intento de contacto', 'INTERESADO', 'AGENDA CITA', 'CALIFICADO']) // El descarte tiene su propio endpoint (lleva motivo) @@ -342,594 +239,50 @@ describe('cambiarEstadoLead (POST /api/leads/:itemId/estado)', () => { expect(ESTADOS_LEAD_PERMITIDOS).not.toContain('NUEVO') }) - it('default (fuente oportunidad): escribe Estado en Oportunidades (555) con el label real del board', async () => { - const monday = mondayMock() - await cambiarEstadoLead(monday, 'op-1', 'CONTACTADO') - expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Contactado' }]) - }) - - it('AGENDA CITA se traduce a "Agendo Cita" (Oportunidades) y con fecha también setea la columna Fecha', async () => { - const monday = mondayMock() - await cambiarEstadoLead(monday, 'op-1', 'AGENDA CITA', '2026-09-01') - expect(monday.cambios).toEqual([ - { boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Agendo Cita' }, - { boardId: '555', itemId: 'op-1', columna: 'Fecha', valor: '2026-09-01' }, - ]) - }) - - it('INTERESADO escribe el label en el tablero Leads (444)', async () => { - const monday = mondayMock() - await cambiarEstadoLead(monday, 'lead-1', 'INTERESADO', undefined, 'lead') - expect(monday.cambios).toEqual([{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'Interesado' }]) - }) - - it('INTERESADO no aplica al tablero Oportunidades (no es etapa del funnel)', async () => { - const monday = mondayMock() - await expect(cambiarEstadoLead(monday, 'op-1', 'INTERESADO', undefined, 'oportunidad')).rejects.toThrow( - /Interesado solo aplica a leads/, - ) - expect(monday.cambios).toEqual([]) - }) - - it('AGENDA CITA sin fecha no toca la columna Fecha', async () => { - const monday = mondayMock() - await cambiarEstadoLead(monday, 'op-1', 'AGENDA CITA') - expect(monday.cambios).toHaveLength(1) - }) - - it('fuente lead: escribe en el tablero Leads (444) con labels title case ("Agenda Cita")', async () => { - const monday = mondayMock() - await cambiarEstadoLead(monday, 'lead-1', 'AGENDA CITA', '2026-09-01', 'lead') - expect(monday.cambios).toEqual([ - { boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'Agenda Cita' }, - { boardId: '444', itemId: 'lead-1', columna: 'Fecha', valor: '2026-09-01' }, - ]) - }) - - it('fuente lead: CONTACTADO / CALIFICADO van title case al board Leads', async () => { - const monday = mondayMock() - await cambiarEstadoLead(monday, 'lead-1', 'CONTACTADO', undefined, 'lead') - await cambiarEstadoLead(monday, 'lead-1', 'CALIFICADO', undefined, 'lead') - expect(monday.cambios).toEqual([ - { boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'Contactado' }, - { boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'Calificado' }, - ]) - }) -}) - -describe('descartarLead (POST /api/leads/:itemId/descartar)', () => { - it('default (oportunidad): Estado Perdido + Motivo No Compra + COMENTARIOS + registro en catálogo', async () => { - const monday = mondayMock() - const motivoItemId = await descartarLead(monday, 'op-1', 'Precio', 'Se le hizo caro') - expect(motivoItemId).toBe('motivo-item-1') - expect(monday.cambios).toEqual([ - { boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Perdido' }, - { boardId: '555', itemId: 'op-1', columna: 'COMENTARIOS', valor: 'Se le hizo caro' }, - { boardId: '555', itemId: 'op-1', columna: 'Motivo No Compra', valor: 'Precio' }, - ]) - expect(monday.motivos).toEqual([ - { nombre: 'Precio — Lead Prueba', categoria: 'Precio', descripcion: 'Se le hizo caro' }, - ]) - }) - - it('sin comentario no escribe COMENTARIOS', async () => { - const monday = mondayMock() - await descartarLead(monday, 'op-1', 'Timing') - expect(monday.cambios).toEqual([ - { boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Perdido' }, - { boardId: '555', itemId: 'op-1', columna: 'Motivo No Compra', valor: 'Timing' }, - ]) - expect(monday.motivos[0]).toEqual({ nombre: 'Timing — Lead Prueba', categoria: 'Timing' }) - }) - - it('fuente lead: Estado Descartado + Notas (sin dropdown Motivo) + registro en catálogo', async () => { - const monday = mondayMock() - const motivoItemId = await descartarLead(monday, 'lead-1', 'Precio', 'Se le hizo caro', 'lead') - expect(motivoItemId).toBe('motivo-item-1') - expect(monday.cambios).toEqual([ - { boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'Descartado' }, - { boardId: '444', itemId: 'lead-1', columna: 'Notas', valor: 'Se le hizo caro' }, - ]) - expect(monday.cambios.find((c) => c.columna === 'Motivo No Compra')).toBeUndefined() - expect(monday.motivos).toEqual([ - { nombre: 'Precio — Lead Prueba', categoria: 'Precio', descripcion: 'Se le hizo caro' }, - ]) - }) - - it('fuente lead sin comentario no escribe Notas', async () => { - const monday = mondayMock() - await descartarLead(monday, 'lead-1', 'Timing', undefined, 'lead') - expect(monday.cambios).toEqual([{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'Descartado' }]) - expect(monday.motivos[0]).toEqual({ nombre: 'Timing — Lead Prueba', categoria: 'Timing' }) - }) - - it('con subcategoría: va en el nombre y en la columna Subcategoria del catálogo', async () => { - const monday = mondayMock() - await descartarLead(monday, 'lead-1', 'Precio', undefined, 'lead', 'Precio Total') - expect(monday.motivos).toEqual([ - { nombre: 'Precio / Precio Total — Lead Prueba', categoria: 'Precio', subcategoria: 'Precio Total' }, - ]) - }) - - it('sin subcategoría el nombre del catálogo queda como antes', async () => { - const monday = mondayMock() - await descartarLead(monday, 'lead-1', 'Timing', undefined, 'lead') - expect(monday.motivos[0]).toEqual({ nombre: 'Timing — Lead Prueba', categoria: 'Timing' }) - }) - - it('propaga el error si Monday falla (el error handler responde 5xx, no truena el server)', async () => { - const monday = mondayMock() - vi.spyOn(monday, 'cambiarValorColumna').mockRejectedValueOnce(new Error('Monday caído')) - await expect(descartarLead(monday, 'op-1', 'Cliente')).rejects.toThrow('Monday caído') - expect(monday.motivos).toEqual([]) - }) - - it('si el motivo no es label del dropdown, solo mueve a Perdido (sin dropdown)', async () => { - const monday = mondayMock({ labelsMotivo: ['Otro'] }) - await descartarLead(monday, 'op-1', 'Precio') - expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Perdido' }]) - expect(monday.cambios.find((c) => c.columna === 'Motivo No Compra')).toBeUndefined() - expect(monday.motivos).toHaveLength(1) // el registro en el catálogo se crea igual - }) -}) - -describe('crearLeadEndpoint (POST /api/leads)', () => { - beforeEach(() => { - vi.clearAllMocks() - prismaMock.auditoriaLog.create.mockResolvedValue({}) - // El singleton Monday responde como si creara el lead en el tablero Leads - vi.spyOn(mondayService, 'buscarLeadPorTelefono').mockResolvedValue(null) - vi.spyOn(mondayService, 'crearLead').mockResolvedValue('lead-nuevo-9') - vi.spyOn(automatizacionesService, 'asignarACapturista').mockResolvedValue(undefined) - // Default: el capturista NO tiene Monday vinculado → asignación round-robin - prismaMock.usuario.findUnique.mockResolvedValue(null) - prismaMock.leadCaptura.create.mockResolvedValue({}) - prismaMock.automatizacionState.findUnique.mockResolvedValue(null) - prismaMock.automatizacionState.upsert.mockResolvedValue({}) - }) - - function reqFake(overrides: Partial = {}): Request { - return { - body: { nombre: 'Lead Prueba', telefono: '4771234567' }, - usuarioToken: { sub: 'u-1', username: 'asesor uno', rol: 'vendedor' }, - ...overrides, - } as Request - } - - function resFake() { - const json = vi.fn() - const status = vi.fn().mockReturnValue({ json }) - return { res: { json, status } as unknown as Response, status, json } - } - - it('201: registra la captura local (sin oportunidad) con el usuario del token', async () => { - const { res, status, json } = resFake() - - await crearLeadEndpoint(reqFake(), res) - - expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ - data: { - leadId: 'lead-nuevo-9', - oportunidadId: null, - usuarioId: 'u-1', - nombreLead: 'Lead Prueba', - telefono: '4771234567', - }, - }) - expect(prismaMock.leadOportunidad.create).not.toHaveBeenCalled() // los leads ya no generan oportunidad - expect(status).toHaveBeenCalledWith(201) - expect(json).toHaveBeenCalledWith({ id: 'lead-nuevo-9', estado: 'NUEVO', oportunidadId: null, asignacion: 'round-robin' }) - }) - - it('con Monday vinculado, el lead queda asignado al capturista (columna Vendedor del tablero Leads)', async () => { - prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 }) - const { res, status, json } = resFake() - - await crearLeadEndpoint(reqFake(), res) - - // Bandera anti-carrera ANTES de asignar + asignación al capturista - expect(prismaMock.automatizacionState.upsert).toHaveBeenCalledWith( - expect.objectContaining({ where: { clave: 'rr:skip:lead-nuevo-9' }, create: { clave: 'rr:skip:lead-nuevo-9', valor: 'capturista' } }), - ) - expect(automatizacionesService.asignarACapturista).toHaveBeenCalledWith('lead-nuevo-9', 'Lead Prueba', 12345, 'leads') - expect(status).toHaveBeenCalledWith(201) - expect(json).toHaveBeenCalledWith({ id: 'lead-nuevo-9', estado: 'NUEVO', oportunidadId: null, asignacion: 'capturista' }) - }) - - it('un fallo al asignar al capturista no rompe la captura (cae al round-robin)', async () => { - prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 }) - vi.spyOn(automatizacionesService, 'asignarACapturista').mockRejectedValue(new Error('Monday caído')) - const espia = vi.spyOn(console, 'error').mockImplementation(() => {}) - const { res, status, json } = resFake() - - await crearLeadEndpoint(reqFake(), res) - - expect(status).toHaveBeenCalledWith(201) - expect(json).toHaveBeenCalledWith({ id: 'lead-nuevo-9', estado: 'NUEVO', oportunidadId: null, asignacion: 'round-robin' }) - espia.mockRestore() - }) - - it('sin token (AUTH_ENABLED=false) no registra captura y responde 201 igual', async () => { - const { res, status } = resFake() - - await crearLeadEndpoint(reqFake({ usuarioToken: undefined }), res) - - expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled() - expect(status).toHaveBeenCalledWith(201) - }) - - it('un fallo del registro local no rompe la respuesta (el lead ya quedó en Monday)', async () => { - prismaMock.leadCaptura.create.mockRejectedValue(new Error('SQLite bloqueado')) - const espia = vi.spyOn(console, 'error').mockImplementation(() => {}) - const { res, status } = resFake() - - await crearLeadEndpoint(reqFake(), res) - - expect(status).toHaveBeenCalledWith(201) - espia.mockRestore() - }) -}) - -describe('crearAfluenciaEndpoint (POST /api/afluencias)', () => { - beforeEach(() => { - vi.clearAllMocks() - prismaMock.auditoriaLog.create.mockResolvedValue({}) - // El singleton Monday responde como si creara la afluencia en Oportunidades - vi.spyOn(mondayService, 'buscarOportunidadPorTelefono').mockResolvedValue(null) - vi.spyOn(mondayService, 'crearOportunidadAfluencia').mockResolvedValue('op-nueva-9') - // moverAGrupoSegunEstado tras la creación (mocks de sus dependencias) - vi.spyOn(mondayService, 'columnaIdPorTitulo').mockResolvedValue('col_estado') - vi.spyOn(mondayService, 'getItemConGrupo').mockResolvedValue({ - id: 'op-nueva-9', - name: 'Lead Prueba', - groupId: 'group_top', - column_values: [{ id: 'col_estado', text: 'Afluencia', value: '{"index":1}' }], - }) - vi.spyOn(mondayService, 'moverItemAGrupo').mockResolvedValue(undefined) - vi.spyOn(automatizacionesService, 'asignarACapturista').mockResolvedValue(undefined) - // Default: el capturista NO tiene Monday vinculado → asignación round-robin - prismaMock.usuario.findUnique.mockResolvedValue(null) - prismaMock.leadCaptura.create.mockResolvedValue({}) - prismaMock.leadOportunidad.create.mockResolvedValue({}) - prismaMock.automatizacionState.findUnique.mockResolvedValue(null) - prismaMock.automatizacionState.upsert.mockResolvedValue({}) - }) - - function reqFake(overrides: Partial = {}): Request { - return { - body: { nombre: 'Lead Prueba', telefono: '4771234567' }, - usuarioToken: { sub: 'u-1', username: 'asesor uno', rol: 'vendedor' }, - ...overrides, - } as Request - } - - function resFake() { - const json = vi.fn() - const status = vi.fn().mockReturnValue({ json }) - return { res: { json, status } as unknown as Response, status, json } - } - - it('201: registra la captura local y el auto-mapping con el usuario del token', async () => { - const { res, status, json } = resFake() - - await crearAfluenciaEndpoint(reqFake(), res) - - expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ - data: { - leadId: 'op-nueva-9', - oportunidadId: 'op-nueva-9', - usuarioId: 'u-1', - nombreLead: 'Lead Prueba', - telefono: '4771234567', - }, - }) - // Auto-mapping LeadOportunidad (leadId = oportunidadId): el alta de - // clientes encuentra así la oportunidad a mover a 'Aparto' - expect(prismaMock.leadOportunidad.create).toHaveBeenCalledWith({ - data: { leadId: 'op-nueva-9', oportunidadId: 'op-nueva-9' }, - }) - expect(status).toHaveBeenCalledWith(201) - expect(json).toHaveBeenCalledWith({ id: 'op-nueva-9', estado: 'Afluencia', oportunidadId: 'op-nueva-9', asignacion: 'round-robin' }) - }) - - it('con Monday vinculado, la afluencia queda asignada al capturista (columna Asesor)', async () => { - prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 }) - const { res, status, json } = resFake() - - await crearAfluenciaEndpoint(reqFake(), res) - - // Bandera anti-carrera ANTES de asignar + asignación al capturista - expect(prismaMock.automatizacionState.upsert).toHaveBeenCalledWith( - expect.objectContaining({ where: { clave: 'rr:skip:op-nueva-9' }, create: { clave: 'rr:skip:op-nueva-9', valor: 'capturista' } }), - ) - expect(automatizacionesService.asignarACapturista).toHaveBeenCalledWith('op-nueva-9', 'Lead Prueba', 12345, 'oportunidades') - expect(status).toHaveBeenCalledWith(201) - expect(json).toHaveBeenCalledWith({ id: 'op-nueva-9', estado: 'Afluencia', oportunidadId: 'op-nueva-9', asignacion: 'capturista' }) - }) - - it('un fallo al asignar al capturista no rompe la captura (cae al round-robin)', async () => { - prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 }) - vi.spyOn(automatizacionesService, 'asignarACapturista').mockRejectedValue(new Error('Monday caído')) - const espia = vi.spyOn(console, 'error').mockImplementation(() => {}) - const { res, status, json } = resFake() - - await crearAfluenciaEndpoint(reqFake(), res) - - expect(status).toHaveBeenCalledWith(201) - expect(json).toHaveBeenCalledWith({ id: 'op-nueva-9', estado: 'Afluencia', oportunidadId: 'op-nueva-9', asignacion: 'round-robin' }) - espia.mockRestore() - }) - - it('sin token (AUTH_ENABLED=false) no registra captura y responde 201 igual', async () => { - const { res, status } = resFake() - - await crearAfluenciaEndpoint(reqFake({ usuarioToken: undefined }), res) - - expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled() - // El auto-mapping sí se escribe (no depende del usuario) - expect(prismaMock.leadOportunidad.create).toHaveBeenCalled() - expect(status).toHaveBeenCalledWith(201) - }) - - it('un fallo del registro local no rompe la respuesta (la afluencia ya quedó en Monday)', async () => { - prismaMock.leadCaptura.create.mockRejectedValue(new Error('SQLite bloqueado')) - const espia = vi.spyOn(console, 'error').mockImplementation(() => {}) - const { res, status } = resFake() - - await crearAfluenciaEndpoint(reqFake(), res) - - expect(status).toHaveBeenCalledWith(201) - espia.mockRestore() - }) -}) - -describe('buscarLeads (GET /api/leads/buscar) — búsqueda dual Leads + Oportunidades', () => { - it('matchea por nombre, teléfono y email (case-insensitive) en ambos tableros, con su fuente', () => { - const leads = [ - leadMonday({ id: 'a', nombre: 'MARÍA FERNANDA LÓPEZ' }), - leadMonday({ id: 'b', telefono: '477 123 4567' }), - leadMonday({ id: 'd', nombre: 'Otro', telefono: '333', email: '' }), - ] - const ops = [ - oportunidadMonday({ id: 'op-c', email: 'Cliente@Correo.com' }), - oportunidadMonday({ id: 'op-x', nombre: 'Nadie' }), - ] - expect(buscarLeads(leads, ops, 'fernanda').map((r) => r.id)).toEqual(['a']) - expect(buscarLeads(leads, ops, '123').map((r) => r.id)).toEqual(['b']) - const porEmail = buscarLeads(leads, ops, 'cliente@') - expect(porEmail.map((r) => r.id)).toEqual(['op-c']) - expect(porEmail[0]?.fuente).toBe('oportunidad') - expect(buscarLeads(leads, ops, 'fernanda')[0]?.fuente).toBe('lead') - }) - - it('ignora acentos tanto en el item como en la búsqueda', () => { - const leads = [leadMonday({ id: 'a', nombre: 'MARÍA FERNANDA LÓPEZ', email: 'andres@correo.com' })] - expect(buscarLeads(leads, [], 'maria')).toHaveLength(1) - expect(buscarLeads(leads, [], 'andrés')).toHaveLength(1) - }) - - it('ordena por updatedAt descendente (mezclando tableros) y limita a 15', () => { - const leads = Array.from({ length: 10 }, (_, i) => - leadMonday({ id: `l-${i}`, nombre: `Cliente Común ${i}`, updatedAt: `2026-08-${String(i + 1).padStart(2, '0')}T00:00:00Z` }), - ) - const ops = Array.from({ length: 10 }, (_, i) => - oportunidadMonday({ id: `op-${i}`, nombre: `Cliente Común ${i}`, updatedAt: `2026-08-${String(i + 11).padStart(2, '0')}T00:00:00Z` }), - ) - const r = buscarLeads(leads, ops, 'común') - expect(r).toHaveLength(15) - expect(r[0]?.id).toBe('op-9') // el más reciente primero (de Oportunidades) - expect(r[14]?.id).toBe('l-5') // se corta en 15 entre ambos tableros (10 ops + 5 leads) - }) - - it('la oportunidad expone su etapa como estado y su origen del dropdown', () => { - const ops = [oportunidadMonday({ id: 'op-1', nombre: 'Cliente Común', etapa: 'Agendo Cita', origen: 'WhatsApp' })] - const r = buscarLeads([], ops, 'común') - expect(r[0]).toMatchObject({ id: 'op-1', estado: 'Agendo Cita', origen: 'WhatsApp', fuente: 'oportunidad' }) - }) - - it('el endpoint responde 400 con q de menos de 2 caracteres', async () => { - const res = { json: vi.fn(), status: vi.fn() } as unknown as Parameters[1] - await expect( - buscarLeadsEndpoint({ query: { q: 'a' } } as unknown as Parameters[0], res), - ).rejects.toMatchObject({ status: 400 }) - await expect( - buscarLeadsEndpoint({ query: {} } as unknown as Parameters[0], res), - ).rejects.toMatchObject({ status: 400 }) - }) -}) - -describe('convertirLead (POST /api/leads/:itemId/convertir) — botón "Agendo" de Mis leads', () => { - const leadConvertible = leadMonday({ - id: 'lead-conv-1', - nombre: 'Ana Demo', - telefono: '+52 6641234567', - email: 'ana@correo.com', - origen: 'PASE', - desarrollo: 'SENDA', - modeloInteres: 'SOHO', - vendedorIds: [106358096], - }) - - beforeEach(() => { - vi.clearAllMocks() - prismaMock.auditoriaLog.create.mockResolvedValue({}) - prismaMock.leadOportunidad.findUnique.mockResolvedValue(null) - prismaMock.leadOportunidad.create.mockResolvedValue({}) - prismaMock.automatizacionState.upsert.mockResolvedValue({}) - }) - - it('crea la oportunidad en Agendo Cita con los datos del lead, asigna al vendedor, califica el lead y guarda el mapping', async () => { - const monday = mondayMock({ leads: [leadConvertible] }) - const r = await convertirLead(monday, 'lead-conv-1') - - expect(r).toEqual({ oportunidadId: 'op-nueva-1' }) - expect(monday.oportunidadesCreadas).toEqual([ - expect.objectContaining({ - nombre: 'Ana Demo', - estadoLabel: 'Agendo Cita', - telefono: '+52 6641234567', - email: 'ana@correo.com', - origenLabel: 'PASE', - desarrolloLabel: 'SENDA', - modeloLabel: 'SOHO', - }), - ]) - // Asesor = vendedor del lead; lead calificado - expect(monday.cambios).toEqual([ - { boardId: '555', itemId: 'op-nueva-1', columna: 'Asesor', valor: 106358096 }, - { boardId: '444', itemId: 'lead-conv-1', columna: 'Estado', valor: 'Calificado' }, - ]) - expect(prismaMock.leadOportunidad.create).toHaveBeenCalledWith({ - data: { leadId: 'lead-conv-1', oportunidadId: 'op-nueva-1' }, - }) - }) - - it('sin vendedor en el lead, asigna al usuario que convierte (vía Monday vinculado)', async () => { - prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 111 }) - const monday = mondayMock({ leads: [{ ...leadConvertible, vendedorIds: [] }] }) - await convertirLead(monday, 'lead-conv-1', 'usr-9') - expect(monday.cambios[0]).toEqual({ boardId: '555', itemId: 'op-nueva-1', columna: 'Asesor', valor: 111 }) - }) - - it('lead ya convertido → 409 sin crear nada', async () => { - prismaMock.leadOportunidad.findUnique.mockResolvedValue({ oportunidadId: 'op-vieja' }) - const monday = mondayMock({ leads: [leadConvertible] }) - await expect(convertirLead(monday, 'lead-conv-1')).rejects.toMatchObject({ status: 409 }) - expect(monday.oportunidadesCreadas).toEqual([]) - }) - - it('lead inexistente en el tablero → 404', async () => { - const monday = mondayMock({ leads: [] }) - await expect(convertirLead(monday, 'no-existe')).rejects.toMatchObject({ status: 404 }) - }) - - it('omite labels que no existen en el board Oportunidades (dropdown no auto-crea)', async () => { - const monday = mondayMock({ leads: [{ ...leadConvertible, origen: 'WhatsApp' }] }) // WhatsApp no está en Origen del Lead - await convertirLead(monday, 'lead-conv-1') - expect(monday.oportunidadesCreadas[0]?.['origenLabel']).toBeUndefined() - }) -}) - -// ---------- Modo local (saludMonday caído / ids loc_* / fallback 502) ---------- - -describe('ruteo modo local (Monday caído o fallback 502)', () => { - function resFake() { - const json = vi.fn() - const status = vi.fn().mockReturnValue({ json }) - return { res: { json, status } as unknown as Response, status, json } - } - - const reqCon = (overrides: Record): Request => - ({ - body: {}, - params: {}, - usuarioToken: { sub: 'u-1', username: 'asesor uno', rol: 'vendedor' }, - ...overrides, - }) as unknown as Request - - function tiposEncolados(): string[] { - return prismaMock.syncPendiente.create.mock.calls.map((c) => (c[0] as { data: { tipo: string } }).data.tipo) - } - - beforeEach(() => { - vi.clearAllMocks() - prismaMock.auditoriaLog.create.mockResolvedValue({}) - prismaMock.leadCaptura.findFirst.mockResolvedValue(null) // sin duplicados locales - prismaMock.leadCaptura.findUnique.mockResolvedValue(null) - prismaMock.leadCaptura.findMany.mockResolvedValue([]) - prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data })) - prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data })) - prismaMock.leadOportunidad.create.mockResolvedValue({}) - prismaMock.syncPendiente.create.mockResolvedValue({}) - prismaMock.cliente.findMany.mockResolvedValue([]) - prismaMock.$transaction.mockImplementation(async (ops: Promise[]) => Promise.all(ops)) - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - it('crearLeadEndpoint con Monday caído: captura local 201 (id loc_*) y encola CREAR_LEAD sin tocar Monday', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) - const spyCrear = vi.spyOn(mondayService, 'crearLead') - const { res, status, json } = resFake() - - await crearLeadEndpoint(reqCon({ body: { nombre: 'Lead Local', telefono: '4771234567' } }), res) - - expect(spyCrear).not.toHaveBeenCalled() - expect(status).toHaveBeenCalledWith(201) - const cuerpo = json.mock.calls[0]?.[0] as { id: string; asignacion: string } - expect(cuerpo.id).toMatch(/^loc_/) - expect(cuerpo).toMatchObject({ estado: 'NUEVO', oportunidadId: null, asignacion: 'capturista' }) - expect(tiposEncolados()).toEqual(['CREAR_LEAD', 'ASIGNAR_PERSONA']) - // La fila local quedó board=LEADS etapa NUEVO pendiente de sincronizar - expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ board: 'LEADS', etapa: 'NUEVO', pendienteSync: true }), - }) - }) - - it('crearLeadEndpoint con Monday vivo que lanza 502: fallback a captura local (misma forma de respuesta)', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true) - vi.spyOn(mondayService, 'buscarLeadPorTelefono').mockRejectedValue(new AppError(502, 'Monday no respondió')) - const { res, status, json } = resFake() - - await crearLeadEndpoint(reqCon({ body: { nombre: 'Lead Fallback', telefono: '4771234567' } }), res) - - expect(status).toHaveBeenCalledWith(201) - expect((json.mock.calls[0]?.[0] as { id: string }).id).toMatch(/^loc_/) - expect(tiposEncolados()).toContain('CREAR_LEAD') - }) - - it('crearLeadEndpoint con error NO-502 de Monday: se propaga (no hay fallback)', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true) - vi.spyOn(mondayService, 'buscarLeadPorTelefono').mockRejectedValue(new AppError(400, 'Origen inválido')) - await expect( - crearLeadEndpoint(reqCon({ body: { nombre: 'X', telefono: '4771234567' } }), resFake().res), - ).rejects.toMatchObject({ status: 400 }) - }) - - it('crearAfluenciaEndpoint con Monday caído: afluencia local 201 y encola CREAR_AFLUENCIA', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) - const spyCrear = vi.spyOn(mondayService, 'crearOportunidadAfluencia') - const { res, status, json } = resFake() - - await crearAfluenciaEndpoint(reqCon({ body: { nombre: 'Afluencia Local', telefono: '6641112233', modelo: 'SOHO' } }), res) - - expect(spyCrear).not.toHaveBeenCalled() - expect(status).toHaveBeenCalledWith(201) - const cuerpo = json.mock.calls[0]?.[0] as { id: string; oportunidadId: string } - expect(cuerpo.id).toMatch(/^loc_/) - expect(cuerpo.oportunidadId).toBe(cuerpo.id) - expect(tiposEncolados()).toEqual(['CREAR_AFLUENCIA', 'ASIGNAR_PERSONA']) - // Auto-mapping local leadId = oportunidadId - expect(prismaMock.leadOportunidad.create).toHaveBeenCalledWith({ - data: { leadId: cuerpo.id, oportunidadId: cuerpo.id }, - }) - }) - - it('cambiarEstadoLeadEndpoint con id loc_* siempre va a local (aunque Monday esté vivo)', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true) - const spyCambiar = vi.spyOn(mondayService, 'cambiarValorColumna') - prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', board: 'LEADS' }) + it('fuente oportunidad: guarda el label del board ("Agendo Cita")', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', board: 'OPORTUNIDADES' }) const { res, json } = resFake() await cambiarEstadoLeadEndpoint( - reqCon({ params: { itemId: 'loc_1' }, body: { estado: 'CONTACTADO', fuente: 'lead' } }), + reqCon({ params: { itemId: 'loc_1' }, body: { estado: 'AGENDA CITA', fecha: '2026-09-01' } }), res, ) - expect(spyCambiar).not.toHaveBeenCalled() expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ where: { leadId: 'loc_1' }, - data: { etapa: 'Contactado', pendienteSync: true }, + data: { etapa: 'Agendo Cita', pendienteSync: true, datosJson: JSON.stringify({ fechaCita: '2026-09-01' }) }, }) - expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA']) - expect(json).toHaveBeenCalledWith({ itemId: 'loc_1', estado: 'CONTACTADO' }) + expect(json).toHaveBeenCalledWith({ itemId: 'loc_1', estado: 'AGENDA CITA', fecha: '2026-09-01' }) }) - it('cambiarEstadoLeadEndpoint con id Monday y 502: crea fila sombra y encola CAMBIO_ETAPA', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true) - vi.spyOn(mondayService, 'cambiarValorColumna').mockRejectedValue(new AppError(502, 'Monday no respondió')) + it('fuente lead: labels title case ("Agenda Cita", "Interesado")', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_l', board: 'LEADS' }) + const { res } = resFake() + + await cambiarEstadoLeadEndpoint( + reqCon({ params: { itemId: 'loc_l' }, body: { estado: 'INTERESADO', fuente: 'lead' } }), + res, + ) + + expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ + where: { leadId: 'loc_l' }, + data: { etapa: 'Interesado', pendienteSync: true }, + }) + }) + + it('INTERESADO no aplica a Oportunidades (no es etapa del funnel) → 400', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', board: 'OPORTUNIDADES' }) + const { res } = resFake() + + await expect( + cambiarEstadoLeadEndpoint( + reqCon({ params: { itemId: 'loc_1' }, body: { estado: 'INTERESADO' } }), + res, + ), + ).rejects.toMatchObject({ status: 400 }) + }) + + it('id heredado sin fila local: crea la sombra y guarda el cambio', async () => { const { res, json } = resFake() await cambiarEstadoLeadEndpoint( @@ -937,16 +290,15 @@ describe('ruteo modo local (Monday caído o fallback 502)', () => { res, ) - // Sombra: el item Monday viejo queda reflejado localmente (Mi día) con su id como mondayItemId expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ data: expect.objectContaining({ leadId: '99887766', board: 'LEADS', mondayItemId: '99887766' }), }) - expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA']) expect(json).toHaveBeenCalledWith({ itemId: '99887766', estado: 'CONTACTADO' }) }) +}) - it('descartarLeadEndpoint con id loc_*: descarte local, motivoItemId null y DESCARTE encolado', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true) +describe('descartarLeadEndpoint (POST /api/leads/:itemId/descartar) — local', () => { + it('default (oportunidad): etapa Perdido + motivo guardado', async () => { prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_2', nombreLead: 'Lead Local' }) const { res, json } = resFake() @@ -959,24 +311,53 @@ describe('ruteo modo local (Monday caído o fallback 502)', () => { where: { leadId: 'loc_2' }, data: expect.objectContaining({ etapa: 'Perdido', motivo: 'Precio', submotivo: 'Enganche' }), }) - expect(tiposEncolados()).toEqual(['DESCARTE']) expect(json).toHaveBeenCalledWith({ itemId: 'loc_2', estado: 'Perdido', motivo: 'Precio', motivoItemId: null }) }) - it('convertirLeadEndpoint con id loc_*: conversión local 201 y CONVERSION encolada', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true) - prismaMock.leadCaptura.findUnique.mockResolvedValue({ - leadId: 'loc_lead', - oportunidadId: null, - usuarioId: 'u-1', - nombreLead: 'Lead Local', - telefono: '4771234567', - email: null, - origen: null, - desarrollo: null, - modelo: null, - datosJson: null, + it('fuente lead: etapa Descartado', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_l', nombreLead: 'Lead Local' }) + const { res, json } = resFake() + + await descartarLeadEndpoint( + reqCon({ params: { itemId: 'loc_l' }, body: { motivo: 'Timing', fuente: 'lead' } }), + res, + ) + + expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ + where: { leadId: 'loc_l' }, + data: expect.objectContaining({ etapa: 'Descartado', motivo: 'Timing' }), }) + expect(json.mock.calls[0]?.[0]).toMatchObject({ estado: 'Descartado', motivoItemId: null }) + }) + + it('la subcategoría debe pertenecer a la categoría elegida (400)', async () => { + const { res } = resFake() + + await expect( + descartarLeadEndpoint( + reqCon({ params: { itemId: 'loc_2' }, body: { motivo: 'Precio', subcategoria: 'Esperando Credito' } }), + res, + ), + ).rejects.toThrow(/subcategoría/i) + }) +}) + +describe('convertirLeadEndpoint (POST /api/leads/:itemId/convertir) — botón "Agendo" de Mis leads', () => { + const leadConvertible = { + leadId: 'loc_lead', + oportunidadId: null, + usuarioId: 'u-1', + nombreLead: 'Lead Local', + telefono: '4771234567', + email: null, + origen: 'PASE', + desarrollo: 'SENDA', + modelo: null, + datosJson: null, + } + + it('201: lead Calificado + oportunidad local en Agendo Cita', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue(leadConvertible) const { res, status, json } = resFake() await convertirLeadEndpoint(reqCon({ params: { itemId: 'loc_lead' }, body: {} }), res) @@ -986,20 +367,39 @@ describe('ruteo modo local (Monday caído o fallback 502)', () => { expect(cuerpo.leadId).toBe('loc_lead') expect(cuerpo.oportunidadId).toMatch(/^loc_/) expect(cuerpo.etapa).toBe('Agendo Cita') - expect(tiposEncolados()).toEqual(['CONVERSION']) + expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ + where: { leadId: 'loc_lead' }, + data: expect.objectContaining({ etapa: 'Calificado' }), + }) }) - it('convertirLeadEndpoint local con lead ya convertido → 409', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) - prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_lead', oportunidadId: 'loc_op_previa' }) + it('lead ya convertido → 409 sin crear nada', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue({ ...leadConvertible, oportunidadId: 'loc_op_previa' }) + const { res } = resFake() + await expect( - convertirLeadEndpoint(reqCon({ params: { itemId: 'loc_lead' }, body: {} }), resFake().res), + convertirLeadEndpoint(reqCon({ params: { itemId: 'loc_lead' }, body: {} }), res), ).rejects.toMatchObject({ status: 409 }) }) - it('buscarLeadsEndpoint con Monday caído busca en local (LeadCaptura + Cliente)', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) - const spyLeads = vi.spyOn(mondayService, 'getLeads') + it('lead inexistente → 404', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue(null) + const { res } = resFake() + + await expect( + convertirLeadEndpoint(reqCon({ params: { itemId: 'no-existe' }, body: {} }), res), + ).rejects.toMatchObject({ status: 404 }) + }) +}) + +describe('buscarLeadsEndpoint (GET /api/leads/buscar) — búsqueda local LeadCaptura + Cliente', () => { + it('responde 400 con q de menos de 2 caracteres', async () => { + const { res } = resFake() + await expect(buscarLeadsEndpoint(reqCon({ query: { q: 'a' } }), res)).rejects.toMatchObject({ status: 400 }) + await expect(buscarLeadsEndpoint(reqCon({ query: {} }), res)).rejects.toMatchObject({ status: 400 }) + }) + + it('devuelve capturas y clientes locales con su fuente, más recientes primero', async () => { prismaMock.leadCaptura.findMany.mockResolvedValue([ { leadId: 'loc_1', @@ -1014,13 +414,69 @@ describe('ruteo modo local (Monday caído o fallback 502)', () => { updatedAt: new Date('2026-09-05T10:00:00Z'), }, ]) + prismaMock.cliente.findMany.mockResolvedValue([ + { + id: 'cli-1', + nombreCompleto: 'Ana Cliente', + celular: '4779998877', + email: 'ana@correo.mx', + proyecto: 'SENDA', + createdAt: new Date('2026-09-06T10:00:00Z'), + }, + ]) const { res, json } = resFake() await buscarLeadsEndpoint(reqCon({ query: { q: 'ana' } }), res) - expect(spyLeads).not.toHaveBeenCalled() - expect(json).toHaveBeenCalledWith([ - expect.objectContaining({ id: 'loc_1', nombre: 'Ana Local', fuente: 'oportunidad', estado: 'Afluencia' }), - ]) + const lista = json.mock.calls[0]?.[0] as Record[] + expect(lista).toHaveLength(2) + expect(lista[0]).toMatchObject({ id: 'cli-1', nombre: 'Ana Cliente', fuente: 'oportunidad' }) + expect(lista[1]).toMatchObject({ id: 'loc_1', nombre: 'Ana Local', fuente: 'oportunidad', estado: 'Afluencia' }) + }) +}) + +describe('admin CRUD de leads — local', () => { + it('eliminarLeadEndpoint borra la fila local (sin réplica externa)', async () => { + const { res, json } = resFake() + + await eliminarLeadEndpoint(reqCon({ params: { itemId: 'loc_1' } }), res) + + expect(prismaMock.leadCaptura.delete).toHaveBeenCalledWith({ where: { leadId: 'loc_1' } }) + expect(json).toHaveBeenCalledWith({ eliminado: true, itemId: 'loc_1' }) + }) + + it('asignarLeadEndpoint reasigna el lead localmente y responde con el nombre', async () => { + prismaMock.usuario.findUnique.mockResolvedValue({ nombre: 'Martin Zavala' }) + const { res, json } = resFake() + + await asignarLeadEndpoint(reqCon({ params: { itemId: 'loc_1' }, body: { usuarioId: 'usr-9' } }), res) + + expect(prismaMock.leadCaptura.updateMany).toHaveBeenCalledWith({ + where: { leadId: 'loc_1' }, + data: { usuarioId: 'usr-9' }, + }) + expect(json).toHaveBeenCalledWith({ itemId: 'loc_1', usuarioId: 'usr-9', nombre: 'Martin Zavala' }) + }) + + it('asignarLeadEndpoint con usuario inexistente → 404', async () => { + prismaMock.usuario.findUnique.mockResolvedValue(null) + const { res } = resFake() + + await expect( + asignarLeadEndpoint(reqCon({ params: { itemId: 'loc_1' }, body: { usuarioId: 'no-existe' } }), res), + ).rejects.toMatchObject({ status: 404 }) + }) + + it('moverLeadEndpoint cambia el board de la fila local', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', board: 'LEADS' }) + const { res, json } = resFake() + + await moverLeadEndpoint(reqCon({ params: { itemId: 'loc_1' }, body: { board: 'OPORTUNIDADES' } }), res) + + expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ + where: { leadId: 'loc_1' }, + data: { board: 'OPORTUNIDADES' }, + }) + expect(json).toHaveBeenCalledWith({ itemId: 'loc_1', board: 'OPORTUNIDADES' }) }) }) diff --git a/api-gateway/src/controllers/leads.controller.ts b/api-gateway/src/controllers/leads.controller.ts index 8beb1d5..40652ef 100644 --- a/api-gateway/src/controllers/leads.controller.ts +++ b/api-gateway/src/controllers/leads.controller.ts @@ -1,37 +1,32 @@ import type { Request, Response } from 'express' import { z } from 'zod' -import { config } from '../config/index.js' -import { mondayService, type LeadMonday, type MondayService, type OportunidadMonday } from '../services/monday.service.js' -import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js' -import { automatizacionesService, CLAVE_RR_SKIP, prismaStateStore } from '../services/automatizaciones.service.js' +import { VOCABULARIO } from '../config/vocabulario.js' import { prisma } from '../services/prisma.js' -import { saludMonday } from '../services/salud-monday.service.js' -import { crmLocal, encolarSyncSeguro, esIdLocal } from '../services/crm-local.service.js' +import { crmLocal, esIdLocal } from '../services/crm-local.service.js' import * as auditoria from '../services/auditoria.service.js' -import { esFalloMonday } from '../services/sync-monday.service.js' import { MOTIVOS_NO_COMPRA, SUBCATEGORIAS_NO_COMPRA } from './oportunidades.controller.js' -import { AppError } from '../utils/errors.js' -import { NotFoundError } from '../utils/errors.js' +import { AppError, NotFoundError } from '../utils/errors.js' /** - * Captura y acciones del CRM ligero, sobre DOS tableros: - * - Tablero Leads (POST /api/leads): captura rápida de /hoy y captura manual - * desde la UI de Monday. Item ligero (nombre, teléfono, email, origen, - * desarrollo, notas). Los leads NO crean oportunidad. - * - Tablero Oportunidades (POST /api/afluencias): el Alta Afluencia (forma - * completa de /client) — la afluencia ES la oportunidad en etapa - * 'Afluencia', con todo el perfil. + * Captura y acciones del CRM ligero, sobre DOS tableros locales (tabla + * LeadCaptura de Prisma/SQLite): + * - Leads (POST /api/leads): captura rápida de /hoy y captura manual desde la + * UI. Item ligero (nombre, teléfono, email, origen, desarrollo, notas). Los + * leads NO crean oportunidad. + * - Oportunidades (POST /api/afluencias): el Alta Afluencia (forma completa de + * /client) — la afluencia ES la oportunidad en etapa 'Afluencia', con todo + * el perfil. * Las acciones del vendedor (/estado, /descartar) y la búsqueda del picker del * alta conviven con ambos tableros vía el parámetro `fuente`. - * Los labels se validan/resuelven contra los reales de cada board (los - * dropdown y status de Monday no auto-crean labels al escribir). + * Los labels de origen/desarrollo se validan contra el vocabulario canónico + * del CRM (config/vocabulario.ts). */ // Transiciones que el vendedor puede hacer desde la app Mi día export const ESTADOS_LEAD_PERMITIDOS = ['CONTACTADO', 'Intento de contacto', 'INTERESADO', 'AGENDA CITA', 'CALIFICADO'] as const -// Traducción de la whitelist (valores que mandan los frontends) a los labels -// reales de la columna status 'Estado' de cada board +// Traducción de la whitelist (valores que mandan los frontends) a las etapas +// locales de cada board const LABEL_ESTADO_LEADS: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], string> = { CONTACTADO: 'Contactado', 'Intento de contacto': 'Intento de contacto', @@ -42,7 +37,7 @@ const LABEL_ESTADO_LEADS: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], strin const LABEL_ESTADO_OPORTUNIDAD: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], string> = { CONTACTADO: 'Contactado', 'Intento de contacto': 'Intento de contacto', - INTERESADO: 'Interesado', // no existe en el board Oportunidades: se rechaza abajo + INTERESADO: 'Interesado', // no existe en Oportunidades: se rechaza abajo 'AGENDA CITA': 'Agendo Cita', CALIFICADO: 'Calificado', } @@ -72,8 +67,7 @@ const crearAfluenciaSchema = z.object({ // Con oportunidadId (botón "Afluencia" de Mis oportunidades en Mi día): se // ACTUALIZA esa oportunidad con el perfil capturado en vez de crear una nueva oportunidadId: z.string().trim().min(1).optional(), - // Campos de calificación de la afluencia (opcionales; los labels fijos del - // front coinciden con los sembrados en Monday, no se validan contra el board) + // Campos de calificación de la afluencia (opcionales) modelo: z.string().trim().min(1).optional(), numDependientes: z.string().optional(), edad: z.coerce.number().int().min(15).max(120).optional(), @@ -100,7 +94,7 @@ const cambiarEstadoSchema = z.object({ const descartarSchema = z.object({ motivo: z.enum(MOTIVOS_NO_COMPRA), - // Subcategoría del catálogo de Monday (debe pertenecer a la categoría elegida) + // Subcategoría del catálogo (debe pertenecer a la categoría elegida) subcategoria: z.string().trim().min(1).optional(), comentario: z.string().trim().min(1).optional(), fuente: fuenteSchema, @@ -109,377 +103,57 @@ const descartarSchema = z.object({ { message: 'La subcategoría no pertenece a la categoría elegida', path: ['subcategoria'] }, ) -// ---------- Búsqueda dual (prellenado del alta de clientes) ---------- - -const LIMITE_BUSQUEDA_LEADS = 15 - -/** Minúsculas y sin diacríticos, para comparar nombres/teléfonos con acentos */ -const sinAcentos = (s: string): string => - s - .normalize('NFD') - .replace(/[̀-ͯ]/g, '') // diacríticos U+0300–U+036F - .toLowerCase() - -/** Resultado del picker del alta: conviven Leads (captura ligera) y Oportunidades (alta afluencia) */ -export interface ResultadoBusquedaLead { - id: string - nombre: string - telefono: string - email: string - estado: string - origen: string - fuente: FuenteLead - /** Desarrollo de interés ('' si no tiene); prellena afluencia/cotizador */ - desarrollo: string - /** Modelo de interés ('' si no tiene) */ - modeloInteres: string -} - -/** - * Filtra por q en nombre/teléfono/email (case-insensitive, sin acentos) en - * AMBOS tableros — Leads y Oportunidades —, ordena por actualización reciente - * y limita a 15 resultados combinados. - */ -export function buscarLeads(leads: LeadMonday[], oportunidades: OportunidadMonday[], q: string): ResultadoBusquedaLead[] { - const needle = sinAcentos(q) - const candidatos = [ - ...leads.map((l) => ({ - id: l.id, - nombre: l.nombre, - telefono: l.telefono, - email: l.email, - estado: l.estado, - origen: l.origen, - desarrollo: l.desarrollo ?? '', - modeloInteres: l.modeloInteres ?? '', - updatedAt: l.updatedAt, - fuente: 'lead' as const, - })), - ...oportunidades.map((o) => ({ - id: o.id, - nombre: o.nombre, - telefono: o.telefono, - email: o.email, - estado: o.etapa, - origen: o.origen, - desarrollo: o.desarrollo ?? '', - modeloInteres: o.modeloInteres ?? '', - updatedAt: o.updatedAt, - fuente: 'oportunidad' as const, - })), - ] - return candidatos - .filter((c) => [c.nombre, c.telefono, c.email].some((campo) => sinAcentos(campo).includes(needle))) - .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) - .slice(0, LIMITE_BUSQUEDA_LEADS) - .map(({ updatedAt: _updatedAt, ...resto }) => resto) -} - -type MondayLeads = Pick< - MondayService, - | 'cambiarValorColumna' - | 'crearLead' - | 'crearOportunidadAfluencia' - | 'actualizarOportunidadAfluencia' - | 'crearMotivoNoCompra' - | 'buscarLeadPorTelefono' - | 'buscarOportunidadPorTelefono' - | 'getLeads' - | 'getOportunidades' - | 'labelsDeColumna' - | 'obtenerNombreItem' - | 'columnaIdPorTitulo' - | 'getItemConGrupo' - | 'moverItemAGrupo' -> - export type ResultadoCrearLead = | { id: string; oportunidadId: string | null; actualizada?: boolean } | { duplicado: { id: string; nombre: string } } /** - * Crea un lead en el tablero Leads (Estado NUEVO + Fecha de hoy; captura - * rápida ligera, SIN oportunidad). Si viene teléfono y ya existe un lead con - * ese número, no crea nada y devuelve { duplicado } (el endpoint → 409). - * Origen y desarrollo se validan contra los labels reales del board Leads. + * Validación de origen/desarrollo contra el vocabulario canónico del CRM + * (config/vocabulario.ts). Modo estricto (captura rápida): 400 con los labels + * válidos. Modo tolerante (alta afluencia): el label desconocido se omite en + * vez de fallar. */ -export async function crearLead( - monday: MondayLeads, - datos: z.infer, -): Promise { - const boardLeads = config.monday.boards.leads - - if (datos.telefono !== undefined) { - const existente = await monday.buscarLeadPorTelefono(datos.telefono) - if (existente) return { duplicado: { id: existente.id, nombre: existente.nombre } } - } - // Dedup por email en ambos tableros - if (datos.email !== undefined && datos.email.trim() !== '') { - const [leads, oportunidades] = await Promise.all([ - monday.getLeads(), - monday.getOportunidades(), - ]) - const emailLower = datos.email.trim().toLowerCase() - const existenteLead = leads.find((l) => l.email?.toLowerCase() === emailLower) - if (existenteLead) return { duplicado: { id: existenteLead.id, nombre: existenteLead.nombre } } - const existenteOp = oportunidades.find((o) => o.email?.toLowerCase() === emailLower) - if (existenteOp) return { duplicado: { id: existenteOp.id, nombre: existenteOp.nombre } } - } - - if (datos.origen !== undefined) { - const labels = await monday.labelsDeColumna(boardLeads, 'Origen') - if (!labels.includes(datos.origen)) { - throw new AppError(400, `Origen inválido: "${datos.origen}". Labels válidos: ${labels.join(', ')}`) +function validarOrigenDesarrollo( + datos: { origen?: string | undefined; desarrollo?: string | undefined }, + modo: 'estricto' | 'tolerante', +): { origen?: string | undefined; desarrollo?: string | undefined } { + let { origen, desarrollo } = datos + if (origen !== undefined && !(VOCABULARIO.ORIGENES as readonly string[]).includes(origen)) { + if (modo === 'estricto') { + throw new AppError( + 400, + `Origen inválido: "${origen}". Labels válidos: ${VOCABULARIO.ORIGENES.join(', ')}`, + ) } - } - - if (datos.desarrollo !== undefined) { - const labels = await monday.labelsDeColumna(boardLeads, 'Desarrollo') - if (!labels.includes(datos.desarrollo)) { - throw new AppError(400, `Desarrollo inválido: "${datos.desarrollo}". Labels válidos: ${labels.join(', ')}`) - } - } - - const id = await monday.crearLead({ - nombre: datos.nombre, - telefono: datos.telefono, - email: datos.email, - origenLabel: datos.origen, - desarrolloLabel: datos.desarrollo, - notas: datos.notas, - }) - return { id, oportunidadId: null } -} - -/** - * Crea la afluencia en Monday: UN item en el tablero Oportunidades con Estado - * 'Afluencia' y todo el perfil capturado. Si viene teléfono y ya existe una - * oportunidad con ese número, no crea nada y devuelve { duplicado } (el - * endpoint → 409). Origen (dropdown 'Origen del Lead') y desarrollo (status - * 'Desarrollo') se validan contra los labels reales del board. - * Tras crearla se mueve al grupo que corresponde a su etapa (Monday crea el - * item en el grupo top del tablero, hoy "Perdidos", y el webhook de change no - * dispara en la creación); un fallo ahí solo se loguea. - */ -export async function crearAfluencia( - monday: MondayLeads, - datos: z.infer, -): Promise { - const boardOportunidades = config.monday.boards.oportunidades - - // Con oportunidadId: el formulario venía prellenado desde Mis oportunidades - // (Mi día) → se ACTUALIZA esa oportunidad con el perfil completo y queda en - // etapa 'Afluencia'; no se crea duplicado ni se valida teléfono. - if (datos.oportunidadId !== undefined) { - await monday.actualizarOportunidadAfluencia(datos.oportunidadId, { - nombre: datos.nombre, - telefono: datos.telefono, - email: datos.email, - desarrolloLabel: datos.desarrollo, - origenLabel: datos.origen, - modeloLabel: datos.modelo, - numDependientes: datos.numDependientes, - edad: datos.edad, - estadoCivilLabel: datos.estadoCivil, - tipoEmpleoLabel: datos.tipoEmpleo, - ocupacion: datos.ocupacion, - rangoSalarial: datos.rangoSalarial, - creditoLabel: datos.credito, - tipoEconomiaLabel: datos.tipoEconomia, - calificaLabel: datos.califica, - loQuierePara: datos.loQuierePara, - notas: datos.notas, - }) - try { - await moverAGrupoSegunEstado(monday, boardOportunidades, datos.oportunidadId) - } catch (err) { - // eslint-disable-next-line no-console - console.error(`No se pudo mover al grupo correcto la oportunidad ${datos.oportunidadId}:`, err) - } - return { id: datos.oportunidadId, oportunidadId: datos.oportunidadId, actualizada: true } - } - - if (datos.telefono !== undefined) { - const existente = await monday.buscarOportunidadPorTelefono(datos.telefono) - if (existente) return { duplicado: { id: existente.id, nombre: existente.nombre } } - } - - // Validación tolerante: si el label no existe en el board, se omite en vez - // de fallar. Esto evita 400 cuando Monday aún no propagó un label nuevo. - let origenSafe = datos.origen - if (origenSafe !== undefined) { - const labels = await monday.labelsDeColumna(boardOportunidades, 'Origen del Lead') - if (!labels.includes(origenSafe)) { - console.warn(`Origen "${origenSafe}" no es label del board; se omite`) - origenSafe = undefined - } - } - - let desarrolloSafe = datos.desarrollo - if (desarrolloSafe !== undefined) { - const labels = await monday.labelsDeColumna(boardOportunidades, 'Desarrollo') - if (!labels.includes(desarrolloSafe)) { - console.warn(`Desarrollo "${desarrolloSafe}" no es label del board; se omite`) - desarrolloSafe = undefined - } - } - - const id = await monday.crearOportunidadAfluencia({ - nombre: datos.nombre, - telefono: datos.telefono, - email: datos.email, - desarrolloLabel: desarrolloSafe, - origenLabel: origenSafe, - modeloLabel: datos.modelo, - numDependientes: datos.numDependientes, - edad: datos.edad, - estadoCivilLabel: datos.estadoCivil, - tipoEmpleoLabel: datos.tipoEmpleo, - ocupacion: datos.ocupacion, - rangoSalarial: datos.rangoSalarial, - creditoLabel: datos.credito, - tipoEconomiaLabel: datos.tipoEconomia, - calificaLabel: datos.califica, - loQuierePara: datos.loQuierePara, - notas: datos.notas, - }) - - try { - await moverAGrupoSegunEstado(monday, boardOportunidades, id) - } catch (err) { // eslint-disable-next-line no-console - console.error(`No se pudo mover al grupo correcto la oportunidad ${id}:`, err) + console.warn(`Origen "${origen}" no está en el vocabulario; se omite`) + origen = undefined } - return { id, oportunidadId: id } -} - -/** - * Cambia el estado del item (whitelist → label real del board correspondiente - * según `fuente`); en AGENDA CITA puede setear la columna Fecha. - */ -export async function cambiarEstadoLead( - monday: MondayLeads, - itemId: string, - estado: (typeof ESTADOS_LEAD_PERMITIDOS)[number], - fecha?: string, - fuente: FuenteLead = 'oportunidad', -): Promise { - const esLead = fuente === 'lead' - // 'Interesado' solo existe en el tablero Leads (no es etapa del funnel) - if (estado === 'INTERESADO' && !esLead) { - throw new AppError(400, 'La etapa Interesado solo aplica a leads del tablero Leads') - } - const boardId = esLead ? config.monday.boards.leads : config.monday.boards.oportunidades - const label = esLead ? LABEL_ESTADO_LEADS[estado] : LABEL_ESTADO_OPORTUNIDAD[estado] - await monday.cambiarValorColumna(boardId, itemId, 'Estado', label) - if (estado === 'AGENDA CITA' && fecha !== undefined) { - await monday.cambiarValorColumna(boardId, itemId, 'Fecha', fecha) - } -} - -/** - * Descarte del item según `fuente`: - * - 'oportunidad': Estado 'Perdido' + dropdown Motivo No Compra (label ya - * sembrado; si no existiera se omite) + comentario en COMENTARIOS. - * - 'lead': Estado 'Descartado' + comentario en Notas. - * Ambos registran el motivo (y la subcategoría si viene) en el tablero - * catálogo Motivos No Compra. - */ -export async function descartarLead( - monday: MondayLeads, - itemId: string, - motivo: string, - comentario?: string, - fuente: FuenteLead = 'oportunidad', - subcategoria?: string, -): Promise { - const nombreItem = await monday.obtenerNombreItem(itemId) - if (fuente === 'lead') { - const boardLeads = config.monday.boards.leads - await monday.cambiarValorColumna(boardLeads, itemId, 'Estado', 'Descartado') - if (comentario !== undefined) { - await monday.cambiarValorColumna(boardLeads, itemId, 'Notas', comentario) + if (desarrollo !== undefined && !(VOCABULARIO.DESARROLLOS as readonly string[]).includes(desarrollo)) { + if (modo === 'estricto') { + throw new AppError( + 400, + `Desarrollo inválido: "${desarrollo}". Labels válidos: ${VOCABULARIO.DESARROLLOS.join(', ')}`, + ) } - } else { - const boardOportunidades = config.monday.boards.oportunidades - await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', 'Perdido') - if (comentario !== undefined) { - await monday.cambiarValorColumna(boardOportunidades, itemId, 'COMENTARIOS', comentario) - } - const labels = await monday.labelsDeColumna(boardOportunidades, 'Motivo No Compra') - if (labels.includes(motivo)) { - await monday.cambiarValorColumna(boardOportunidades, itemId, 'Motivo No Compra', motivo) - } else { - // eslint-disable-next-line no-console - console.log(`El motivo "${motivo}" no es label del dropdown 'Motivo No Compra'; se omite`) - } - } - return monday.crearMotivoNoCompra({ - nombre: `${motivo}${subcategoria !== undefined ? ` / ${subcategoria}` : ''} — ${nombreItem}`, - categoria: motivo, - ...(subcategoria !== undefined ? { subcategoria } : {}), - ...(comentario !== undefined ? { descripcion: comentario } : {}), - }) -} - -/** - * Self-assign del capturista con bandera anti-carrera rr:skip (para que el - * webhook create_item no lo reparta por round-robin mientras Monday propaga la - * escritura; si la asignación falla, se apaga y cae al reparto — y queda - * encolada como ASIGNAR_PERSONA para reintentarla cuando Monday vuelva). - * Devuelve cómo quedó la asignación. - */ -async function asignarCapturistaSiAplica( - usuarioId: string | undefined, - itemId: string, - nombre: string, - board: 'leads' | 'oportunidades', -): Promise<'capturista' | 'round-robin'> { - if (usuarioId === undefined) return 'round-robin' - try { - const capturista = await prisma.usuario.findUnique({ - where: { id: usuarioId }, - select: { mondayUserId: true }, - }) - if (capturista?.mondayUserId != null) { - await prismaStateStore.set(`${CLAVE_RR_SKIP}${itemId}`, 'capturista') - try { - await automatizacionesService.asignarACapturista(itemId, nombre, capturista.mondayUserId, board) - return 'capturista' - } catch (err) { - await prismaStateStore.set(`${CLAVE_RR_SKIP}${itemId}`, 'round-robin') - // Best-effort: el worker la reintenta al drenar la cola - await encolarSyncSeguro('ASIGNAR_PERSONA', { board, mondayUserId: capturista.mondayUserId }, itemId) - throw err - } - } - } catch (err) { // eslint-disable-next-line no-console - console.error(`No se pudo asignar el item ${itemId} al capturista:`, err) + console.warn(`Desarrollo "${desarrollo}" no está en el vocabulario; se omite`) + desarrollo = undefined } - return 'round-robin' + return { origen, desarrollo } } /** POST /api/leads { nombre, telefono?, email?, origen?, desarrollo?, notas? } — captura rápida (tablero Leads) */ export async function crearLeadEndpoint(req: Request, res: Response): Promise { const body = crearLeadSchema.parse(req.body) + const { origen, desarrollo } = validarOrigenDesarrollo(body, 'estricto') const usuarioSub = req.usuarioToken?.sub - // Modo local: Monday caído (flag) → directo a crmLocal; Monday vivo → flujo - // actual con fallback a local si la llamada lanza 502. La respuesta mantiene - // la misma forma en ambos caminos (el front no se entera). - let modoLocal = !saludMonday.estaDisponible() - let resultado: ResultadoCrearLead - if (modoLocal) { - resultado = await crmLocal.crearLead(body, usuarioSub) - } else { - try { - resultado = await crearLead(mondayService, body) - } catch (err) { - if (!esFalloMonday(err)) throw err - modoLocal = true - resultado = await crmLocal.crearLead(body, usuarioSub) - } - } + // SIEMPRE local: fila LeadCaptura board=LEADS etapa NUEVO + CREAR_LEAD + // encolado. Sin usuario el capturista queda para el reparto (round-robin). + const resultado: ResultadoCrearLead = await crmLocal.crearLead( + { ...body, origen, desarrollo }, + usuarioSub, + ) if ('duplicado' in resultado) { res.status(409).json({ error: 'Ya existe un lead con ese teléfono', @@ -488,60 +162,26 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise { const body = crearAfluenciaSchema.parse(req.body) + const { origen, desarrollo } = validarOrigenDesarrollo(body, 'tolerante') const usuarioSub = req.usuarioToken?.sub - // Modo local: Monday caído (flag) → crmLocal (crear o actualizar); fallback - // a local tras 502. Misma forma de respuesta en ambos caminos. - let modoLocal = !saludMonday.estaDisponible() - let resultado: ResultadoCrearLead - if (modoLocal) { - resultado = await crmLocal.crearAfluencia(body, usuarioSub) - } else { - try { - resultado = await crearAfluencia(mondayService, body) - } catch (err) { - if (!esFalloMonday(err)) throw err - modoLocal = true - resultado = await crmLocal.crearAfluencia(body, usuarioSub) - } - } + // SIEMPRE local: con oportunidadId se ACTUALIZA esa fila; sin él se crea la + // afluencia (que ES la oportunidad) con auto-mapping LeadOportunidad. + const resultado: ResultadoCrearLead = await crmLocal.crearAfluencia( + { ...body, origen, desarrollo }, + usuarioSub, + ) if ('duplicado' in resultado) { res.status(409).json({ error: 'Ya existe un lead con ese teléfono', @@ -551,58 +191,24 @@ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promi return } // Actualización de oportunidad existente (botón "Afluencia" de Mi día): no - // hay captura nueva que registrar ni round-robin que correr + // hay captura nueva que registrar if (resultado.actualizada === true) { res.json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, actualizada: true }) return } - if (modoLocal) { - res.status(201).json({ - id: resultado.id, - estado: 'Afluencia', - oportunidadId: resultado.oportunidadId, - asignacion: usuarioSub !== undefined ? 'capturista' : 'round-robin', - }) - return - } - // Registro local del capturista para "Mis capturas" del panel Mi día. - // leadId = el id de la oportunidad (la afluencia ES la oportunidad). Un - // fallo aquí no rompe la respuesta: la afluencia ya quedó creada en Monday. - if (usuarioSub) { - try { - await prisma.leadCaptura.create({ - data: { - leadId: resultado.id, - oportunidadId: resultado.oportunidadId, - usuarioId: usuarioSub, - nombreLead: body.nombre, - telefono: body.telefono ?? null, - }, - }) - } catch (err) { - // eslint-disable-next-line no-console - console.error(`No se pudo registrar la captura de la afluencia ${resultado.id}:`, err) - } - } - // Auto-mapping leadId = oportunidadId en LeadOportunidad: el alta de - // clientes (moverAApartoConDedup) encuentra así la oportunidad a mover a - // 'Aparto' tanto en ligas históricas como en el modelo nuevo. - try { - await prisma.leadOportunidad.create({ - data: { leadId: resultado.id, oportunidadId: resultado.oportunidadId ?? resultado.id }, - }) - } catch (err) { - // eslint-disable-next-line no-console - console.error(`No se pudo guardar el mapping de la oportunidad ${resultado.id}:`, err) - } - const asignacion = await asignarCapturistaSiAplica(usuarioSub, resultado.id, body.nombre, 'oportunidades') - res.status(201).json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, asignacion }) + res.status(201).json({ + id: resultado.id, + estado: 'Afluencia', + oportunidadId: resultado.oportunidadId, + asignacion: usuarioSub !== undefined ? 'capturista' : 'round-robin', + }) + auditoria.registrar(usuarioSub ?? 'sistema', 'CREAR_AFLUENCIA', 'afluencia', resultado.id) } /** - * Cambio de estado en modo local: guarda la etapa (label real del board) en la - * fila local y encola CAMBIO_ETAPA. Con id Monday sin fila local (fallback 502 - * sobre items viejos) crea la fila "sombra" para que Mi día lo refleje. + * Cambio de estado en modo local: guarda la etapa (label del board) en la fila + * local. Con id heredado sin fila local (items viejos del CRM anterior) + * crea la fila "sombra" para que Mi día lo refleje. */ async function cambiarEstadoLocal( itemId: string, @@ -627,19 +233,7 @@ async function cambiarEstadoLocal( export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Promise { const body = cambiarEstadoSchema.parse(req.body) const itemId = String(req.params['itemId']) - const fuente = body.fuente ?? 'oportunidad' - // Los ids loc_* siempre van al path local (aún no existen en Monday); con el - // flag caído todo va a local; con Monday vivo, fallback a local tras 502. - if (esIdLocal(itemId) || !saludMonday.estaDisponible()) { - await cambiarEstadoLocal(itemId, body.estado, body.fecha, fuente, req.usuarioToken?.sub) - } else { - try { - await cambiarEstadoLead(mondayService, itemId, body.estado, body.fecha, body.fuente) - } catch (err) { - if (!esFalloMonday(err)) throw err - await cambiarEstadoLocal(itemId, body.estado, body.fecha, fuente, req.usuarioToken?.sub) - } - } + await cambiarEstadoLocal(itemId, body.estado, body.fecha, body.fuente ?? 'oportunidad', req.usuarioToken?.sub) res.json({ itemId, estado: body.estado, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) }) auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CAMBIAR_ESTADO', 'lead', itemId, body.estado) } @@ -648,31 +242,19 @@ export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Pr export async function descartarLeadEndpoint(req: Request, res: Response): Promise { const body = descartarSchema.parse(req.body) const itemId = String(req.params['itemId']) - const fuente = body.fuente ?? 'oportunidad' - let motivoItemId: string | null = null - if (esIdLocal(itemId) || !saludMonday.estaDisponible()) { - await crmLocal.descartar(itemId, body.motivo, { - fuente, - subcategoria: body.subcategoria, - comentario: body.comentario, - usuarioId: req.usuarioToken?.sub, - sombra: !esIdLocal(itemId), - }) - } else { - try { - motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario, body.fuente, body.subcategoria) - } catch (err) { - if (!esFalloMonday(err)) throw err - await crmLocal.descartar(itemId, body.motivo, { - fuente, - subcategoria: body.subcategoria, - comentario: body.comentario, - usuarioId: req.usuarioToken?.sub, - sombra: !esIdLocal(itemId), - }) - } - } - res.json({ itemId, estado: body.fuente === 'lead' ? 'Descartado' : 'Perdido', motivo: body.motivo, motivoItemId }) + await crmLocal.descartar(itemId, body.motivo, { + fuente: body.fuente ?? 'oportunidad', + subcategoria: body.subcategoria, + comentario: body.comentario, + usuarioId: req.usuarioToken?.sub, + sombra: !esIdLocal(itemId), + }) + res.json({ + itemId, + estado: body.fuente === 'lead' ? 'Descartado' : 'Perdido', + motivo: body.motivo, + motivoItemId: null, + }) auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'DESCARTAR_LEAD', 'lead', itemId, body.motivo) } @@ -686,117 +268,19 @@ const convertirSchema = z.object({ }) /** - * Convierte un lead del tablero Leads en oportunidad (botón "Agendo" de Mis - * leads): crea el item en Oportunidades con etapa 'Agendo Cita' y los datos - * del lead (los labels se omiten si no existen en el board destino), asigna - * como Asesor al vendedor del lead (o a quien convierte), marca el lead como - * 'Calificado' (sale de Mis leads) y guarda el mapping LeadOportunidad para - * que el alta de clientes la encuentre. Si el lead ya fue convertido → 409. + * Convierte un lead en oportunidad (botón "Agendo" de Mis leads): el lead + * queda 'Calificado', se crea la oportunidad local en 'Agendo Cita' copiando + * los datos y se guarda el mapping LeadOportunidad. Si el lead ya fue + * convertido → 409. La fecha de la cita viaja en el CAMBIO_ETAPA posterior del + * front. */ -export async function convertirLead( - monday: MondayLeads, - itemId: string, - usuarioId?: string, - fecha?: string, -): Promise<{ oportunidadId: string }> { - const existente = await prisma.leadOportunidad.findUnique({ where: { leadId: itemId } }) - if (existente) throw new AppError(409, 'El lead ya fue convertido en oportunidad') - - const lead = (await monday.getLeads()).find((l) => l.id === itemId) - if (!lead) throw new NotFoundError('Lead no encontrado en el tablero Leads') - - const boardOportunidades = config.monday.boards.oportunidades - // Los labels solo se copian si existen en el board destino (dropdown/status - // no auto-crean labels al escribir) - const [labelsOrigen, labelsDesarrollo, labelsModelo] = await Promise.all([ - monday.labelsDeColumna(boardOportunidades, 'Origen del Lead'), - monday.labelsDeColumna(boardOportunidades, 'Desarrollo'), - monday.labelsDeColumna(boardOportunidades, 'Modelo de Interés'), - ]) - const oportunidadId = await monday.crearOportunidadAfluencia({ - nombre: lead.nombre, - estadoLabel: 'Agendo Cita', - telefono: lead.telefono || undefined, - email: lead.email || undefined, - origenLabel: lead.origen && labelsOrigen.includes(lead.origen) ? lead.origen : undefined, - desarrolloLabel: lead.desarrollo && labelsDesarrollo.includes(lead.desarrollo) ? lead.desarrollo : undefined, - modeloLabel: lead.modeloInteres && labelsModelo.includes(lead.modeloInteres) ? lead.modeloInteres : undefined, - }) - - if (fecha !== undefined) { - await monday.cambiarValorColumna(boardOportunidades, oportunidadId, 'Fecha', fecha) - } - - // Asesor de la oportunidad: el vendedor del lead, o quien la convierte - let asesorId = lead.vendedorIds[0] - if (asesorId === undefined && usuarioId !== undefined) { - const usuario = await prisma.usuario.findUnique({ where: { id: usuarioId }, select: { mondayUserId: true } }) - asesorId = usuario?.mondayUserId ?? undefined - } - if (asesorId !== undefined) { - // Bandera anti-carrera: el webhook create_item no debe round-robinnearla - await prismaStateStore.set(`${CLAVE_RR_SKIP}${oportunidadId}`, 'capturista') - try { - await monday.cambiarValorColumna(boardOportunidades, oportunidadId, 'Asesor', asesorId) - } catch (err) { - await prismaStateStore.set(`${CLAVE_RR_SKIP}${oportunidadId}`, 'round-robin') - throw err - } - } - - // El lead queda Calificado (convertido: sale de Mis leads) y ambos items a su grupo - await monday.cambiarValorColumna(config.monday.boards.leads, itemId, 'Estado', 'Calificado') - await moverAGrupoSegunEstado(monday, config.monday.boards.leads, itemId) - await moverAGrupoSegunEstado(monday, boardOportunidades, oportunidadId) - - await prisma.leadOportunidad.create({ data: { leadId: itemId, oportunidadId } }) - return { oportunidadId } -} - -/** POST /api/leads/:itemId/convertir { fecha? } */ export async function convertirLeadEndpoint(req: Request, res: Response): Promise { - const body = convertirSchema.parse(req.body ?? {}) + convertirSchema.parse(req.body ?? {}) const itemId = String(req.params['itemId']) const usuarioSub = req.usuarioToken?.sub - // Modo local (id loc_* o Monday caído / fallback 502): la conversión crea la - // oportunidad local en 'Agendo Cita' y encola CONVERSION. Nota: en el - // fallback la fecha de la cita viaja en el CAMBIO_ETAPA posterior del front, - // como en el flujo normal. - if (esIdLocal(itemId) || !saludMonday.estaDisponible()) { - const oportunidad = await crmLocal.convertirLead(itemId, usuarioSub) - res.status(201).json({ leadId: itemId, oportunidadId: oportunidad.leadId, etapa: 'Agendo Cita' }) - auditoria.registrar(usuarioSub ?? 'sistema', 'CONVERTIR_LEAD', 'lead', itemId) - return - } - try { - const { oportunidadId } = await convertirLead(mondayService, itemId, usuarioSub, body.fecha) - // Crear LeadCaptura local para la oportunidad (Mi día las necesita) - try { - const leadLocal = await prisma.leadCaptura.findFirst({ where: { leadId: itemId } }) - await prisma.leadCaptura.upsert({ - where: { leadId: oportunidadId }, - update: { board: 'OPORTUNIDADES', etapa: 'Agendo Cita' }, - create: { - leadId: oportunidadId, - board: 'OPORTUNIDADES', - etapa: 'Agendo Cita', - nombreLead: leadLocal?.nombreLead ?? '', - telefono: leadLocal?.telefono ?? null, - email: leadLocal?.email ?? null, - usuarioId: usuarioSub ?? 'sistema', - mondayItemId: oportunidadId, - pendienteSync: false, - }, - }) - } catch { /* best effort */ } - res.status(201).json({ leadId: itemId, oportunidadId, etapa: 'Agendo Cita' }) - auditoria.registrar(usuarioSub ?? 'sistema', 'CONVERTIR_LEAD', 'lead', itemId) - } catch (err) { - if (!esFalloMonday(err)) throw err - const oportunidad = await crmLocal.convertirLead(itemId, usuarioSub) - res.status(201).json({ leadId: itemId, oportunidadId: oportunidad.leadId, etapa: 'Agendo Cita' }) - auditoria.registrar(usuarioSub ?? 'sistema', 'CONVERTIR_LEAD', 'lead', itemId) - } + const oportunidad = await crmLocal.convertirLead(itemId, usuarioSub) + res.status(201).json({ leadId: itemId, oportunidadId: oportunidad.leadId, etapa: 'Agendo Cita' }) + auditoria.registrar(usuarioSub ?? 'sistema', 'CONVERTIR_LEAD', 'lead', itemId) } // ── Admin CRUD de leads ──────────────────────────────────────────────── @@ -804,19 +288,8 @@ export async function convertirLeadEndpoint(req: Request, res: Response): Promis /** DELETE /api/leads/:itemId — eliminar lead (solo admin) */ export async function eliminarLeadEndpoint(req: Request, res: Response): Promise { const itemId = String(req.params['itemId']) - const row = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } }) - // Eliminar de Monday si es item remoto y Monday está disponible - if (row && row.mondayItemId && !esIdLocal(itemId) && saludMonday.estaDisponible()) { - try { - await mondayService.eliminarItem(row.mondayItemId) - } catch (err) { - if (!esFalloMonday(err)) throw err - } - } // Eliminar la fila local (si existe) - if (row) { - await prisma.leadCaptura.delete({ where: { leadId: itemId } }).catch(() => {}) - } + await prisma.leadCaptura.delete({ where: { leadId: itemId } }).catch(() => {}) res.json({ eliminado: true, itemId }) auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'ELIMINAR_LEAD', 'lead', itemId) } @@ -831,27 +304,14 @@ export async function asignarLeadEndpoint(req: Request, res: Response): Promise< const { usuarioId } = asignarLeadSchema.parse(req.body) const usuario = await prisma.usuario.findUnique({ where: { id: usuarioId }, - select: { mondayUserId: true, nombre: true }, + select: { nombre: true }, }) if (!usuario) throw new NotFoundError('Usuario no encontrado') - // Actualizar LeadCaptura + // La asignación local es la verdad: se actualiza la fila del lead await prisma.leadCaptura.updateMany({ where: { leadId: itemId }, data: { usuarioId }, }) - // En Monday: determinar si es lead u oportunidad - const row = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } }) - const board: 'leads' | 'oportunidades' = row?.board === 'OPORTUNIDADES' ? 'oportunidades' : 'leads' - if (usuario.mondayUserId != null && saludMonday.estaDisponible() && !esIdLocal(itemId)) { - try { - await automatizacionesService.asignarACapturista(itemId, row?.nombreLead ?? '', usuario.mondayUserId, board) - } catch (err) { - if (!esFalloMonday(err)) throw err - await encolarSyncSeguro('ASIGNAR_PERSONA', { board, mondayUserId: usuario.mondayUserId }, itemId) - } - } else if (usuario.mondayUserId != null) { - await encolarSyncSeguro('ASIGNAR_PERSONA', { board, mondayUserId: usuario.mondayUserId }, itemId) - } res.json({ itemId, usuarioId, nombre: usuario.nombre }) auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'ASIGNAR_LEAD', 'lead', itemId, usuarioId) } @@ -876,17 +336,11 @@ export async function moverLeadEndpoint(req: Request, res: Response): Promise { const q = String(req.query['q'] ?? '').trim() if (q.length < 2) throw new AppError(400, 'La búsqueda requiere al menos 2 caracteres') - if (!saludMonday.estaDisponible()) { - res.json(await crmLocal.buscarLocales(q)) - return - } - const [leads, oportunidades] = await Promise.all([mondayService.getLeads(), mondayService.getOportunidades()]) - res.json(buscarLeads(leads, oportunidades, q)) + res.json(await crmLocal.buscarLocales(q)) } diff --git a/api-gateway/src/controllers/liga.controller.test.ts b/api-gateway/src/controllers/liga.controller.test.ts index d23c7e6..a163d90 100644 --- a/api-gateway/src/controllers/liga.controller.test.ts +++ b/api-gateway/src/controllers/liga.controller.test.ts @@ -10,7 +10,7 @@ import { verificarLigaToken, } from './liga.controller.js' -// ---------- Prisma y Monday mockeados (los tests no tocan BD ni red) ---------- +// ---------- Prisma mockeado (los tests no tocan BD) ---------- const prismaMock = vi.hoisted(() => ({ cliente: { @@ -23,12 +23,8 @@ const prismaMock = vi.hoisted(() => ({ }, })) -const mondayMock = vi.hoisted(() => ({ - cambiarValorColumna: vi.fn(), -})) vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) -vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock })) const SECRET = 'test-jwt-secret' // JWT_SECRET fijado en vitest.config.ts @@ -220,10 +216,9 @@ describe('subirDocumentosPublico (POST /clientes/publico/:token/documentos)', () expect(status).toHaveBeenCalledWith(201) }) - it('prende el checkbox en Apartado cuando el cliente tiene mondayApartadoId', async () => { + it('persiste el documento aunque el cliente tenga mondayApartadoId (sin efectos Monday)', async () => { prismaMock.cliente.findUnique.mockResolvedValue(clienteFake({ mondayApartadoId: 'ap-9' })) prismaMock.documento.create.mockResolvedValue({ id: 'd-1' }) - mondayMock.cambiarValorColumna.mockResolvedValue(undefined) const token = generarLigaToken('c-1') await subirDocumentosPublico( @@ -231,10 +226,10 @@ describe('subirDocumentosPublico (POST /clientes/publico/:token/documentos)', () resFake().res, ) - expect(mondayMock.cambiarValorColumna).toHaveBeenCalledWith(expect.any(String), 'ap-9', 'CURP', true) + expect(prismaMock.documento.create).toHaveBeenCalled() }) - it('no llama a Monday sin mondayApartadoId', async () => { + it('acepta tipos con cliente sin apartado ligado', async () => { prismaMock.cliente.findUnique.mockResolvedValue(clienteFake()) prismaMock.documento.create.mockResolvedValue({ id: 'd-1' }) const token = generarLigaToken('c-1') @@ -244,6 +239,5 @@ describe('subirDocumentosPublico (POST /clientes/publico/:token/documentos)', () resFake().res, ) - expect(mondayMock.cambiarValorColumna).not.toHaveBeenCalled() }) }) diff --git a/api-gateway/src/controllers/liga.controller.ts b/api-gateway/src/controllers/liga.controller.ts index 7a48487..ebe1d75 100644 --- a/api-gateway/src/controllers/liga.controller.ts +++ b/api-gateway/src/controllers/liga.controller.ts @@ -6,10 +6,8 @@ import jwt from 'jsonwebtoken' import multer from 'multer' import { z } from 'zod' import { config } from '../config/index.js' -import { mondayService } from '../services/monday.service.js' import { prisma } from '../services/prisma.js' import { AppError, BadRequestError, NotFoundError } from '../utils/errors.js' -import { checkboxParaTipoDocumento } from './clientes.controller.js' // ---------- Liga remota de firma/documentos (modo público con token) ---------- @@ -214,21 +212,5 @@ export async function subirDocumentosPublico(req: Request, res: Response): Promi ), ) - // Mismo efecto que la subida interna: checkbox del documento en Apartado. - // Un fallo de Monday no rompe la subida. - const checkbox = checkboxParaTipoDocumento(tipo) - if (checkbox !== null && cliente.mondayApartadoId !== null) { - try { - await mondayService.cambiarValorColumna( - config.monday.boards.apartado, - cliente.mondayApartadoId, - checkbox, - true, - ) - } catch (err) { - // eslint-disable-next-line no-console - console.error(`No se pudo marcar el checkbox "${checkbox}" del apartado ${cliente.mondayApartadoId}:`, err) - } - } res.status(201).json(documentos) } diff --git a/api-gateway/src/controllers/mi-dia.controller.test.ts b/api-gateway/src/controllers/mi-dia.controller.test.ts index 9971190..6be974f 100644 --- a/api-gateway/src/controllers/mi-dia.controller.test.ts +++ b/api-gateway/src/controllers/mi-dia.controller.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Request, Response } from 'express' import { AppError } from '../utils/errors.js' -// ---------- Prisma y Monday mockeados (los tests no tocan BD ni red) ---------- +// ---------- Prisma mockeado (los tests no tocan BD) ---------- const prismaMock = vi.hoisted(() => ({ usuario: { @@ -20,17 +20,7 @@ const prismaMock = vi.hoisted(() => ({ }, })) -const mondayMock = vi.hoisted(() => ({ - getUsuariosWorkspace: vi.fn(), - getLeads: vi.fn(), - getOportunidades: vi.fn(), - getActividades: vi.fn(), - getApartados: vi.fn(), - getUltimosCambios: vi.fn(), -})) - vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) -vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock })) import { resumenMiDiaEndpoint } from './mi-dia.controller.js' @@ -81,16 +71,10 @@ beforeEach(() => { prismaMock.leadCaptura.findMany.mockResolvedValue([]) prismaMock.cliente.findMany.mockResolvedValue([]) prismaMock.actividadLocal.findMany.mockResolvedValue([]) - mondayMock.getUsuariosWorkspace.mockResolvedValue([]) - mondayMock.getLeads.mockResolvedValue([]) - mondayMock.getOportunidades.mockResolvedValue([]) - mondayMock.getActividades.mockResolvedValue([]) - mondayMock.getApartados.mockResolvedValue([]) - mondayMock.getUltimosCambios.mockResolvedValue(new Map()) }) -describe('resumenMiDiaEndpoint (siempre local, Monday purgado)', () => { - it('responde 100% desde LeadCaptura: Monday no se llama nunca', async () => { +describe('resumenMiDiaEndpoint (100% local)', () => { + it('responde 100% desde LeadCaptura', async () => { prismaMock.leadCaptura.findMany.mockResolvedValue([ capturaFake({ leadId: 'loc_l1', board: 'LEADS', etapa: 'NUEVO', pendienteSync: true }), capturaFake({ leadId: 'loc_o1', board: 'OPORTUNIDADES', etapa: 'Afluencia', pendienteSync: true }), @@ -99,10 +83,6 @@ describe('resumenMiDiaEndpoint (siempre local, Monday purgado)', () => { await resumenMiDiaEndpoint(reqFake, res) - expect(mondayMock.getLeads).not.toHaveBeenCalled() - expect(mondayMock.getOportunidades).not.toHaveBeenCalled() - expect(mondayMock.getApartados).not.toHaveBeenCalled() - expect(mondayMock.getUsuariosWorkspace).not.toHaveBeenCalled() const resumen = json.mock.calls[0]?.[0] as Record expect((resumen['misLeads'] as unknown[]).length).toBe(1) // solo leads, no oportunidades expect((resumen['misOportunidades'] as { items: unknown[] }).items).toHaveLength(1) diff --git a/api-gateway/src/controllers/mi-dia.controller.ts b/api-gateway/src/controllers/mi-dia.controller.ts index 656fa03..632000b 100644 --- a/api-gateway/src/controllers/mi-dia.controller.ts +++ b/api-gateway/src/controllers/mi-dia.controller.ts @@ -59,7 +59,7 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise usuarios = usuariosRaw.map((u) => ({ id: u.id, nombre: u.nombre, rol: u.rol, activo: u.activo })) } - const usuarioMiDia = { nombre: usuario.nombre, rol: usuario.rol, mondayUserId: usuario.mondayUserId } + const usuarioMiDia = { nombre: usuario.nombre, rol: usuario.rol } res.json(resumenMiDiaLocal(usuarioMiDia, capturas, clientes, new Date(), actividades, usuarios)) } diff --git a/api-gateway/src/controllers/oportunidades.controller.test.ts b/api-gateway/src/controllers/oportunidades.controller.test.ts index 3478121..12b8d52 100644 --- a/api-gateway/src/controllers/oportunidades.controller.test.ts +++ b/api-gateway/src/controllers/oportunidades.controller.test.ts @@ -1,15 +1,10 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' import type { Request, Response } from 'express' import { - cambiarEtapaOportunidad, cambiarEtapaOportunidadEndpoint, ETAPAS_OPORTUNIDAD_PERMITIDAS, - perderOportunidad, perderOportunidadEndpoint, } from './oportunidades.controller.js' -import { mondayService } from '../services/monday.service.js' -import { saludMonday } from '../services/salud-monday.service.js' -import { AppError } from '../utils/errors.js' // ---------- Prisma mockeado (los tests no tocan la BD) ---------- @@ -19,8 +14,8 @@ const prismaMock = vi.hoisted(() => ({ findUnique: vi.fn(), update: vi.fn(), }, - syncPendiente: { - create: vi.fn(), + unidad: { + updateMany: vi.fn(), }, automatizacionState: { findUnique: vi.fn(), @@ -34,110 +29,81 @@ const prismaMock = vi.hoisted(() => ({ vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) -/** Monday mockeado: registra los cambios de columna y los motivos creados */ -function mondayMock() { - return { - cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[], - motivos: [] as { nombre: string; categoria: string; descripcion?: string }[], - async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) { - this.cambios.push({ boardId, itemId, columna, valor }) - }, - async obtenerNombreItem(_itemId: string) { - return 'Juan Pérez' - }, - async crearMotivoNoCompra(datos: { nombre: string; categoria: string; descripcion?: string }) { - this.motivos.push(datos) - return 'motivo-item-1' - }, - } +function resFake() { + const json = vi.fn() + const status = vi.fn().mockReturnValue({ json }) + return { res: { json, status } as unknown as Response, status, json } } -describe('perderOportunidad (POST /api/oportunidades/:id/perder)', () => { - it('pone etapa Perdido + dropdown motivo y registra en el tablero Motivos No Compra', async () => { - const monday = mondayMock() - const motivoItemId = await perderOportunidad(monday, 'opp-123', 'Precio', 'Se le hizo caro') +const reqCon = (overrides: Record): Request => + ({ + body: {}, + params: {}, + usuarioToken: { sub: 'u-1', username: 'asesor uno', rol: 'vendedor' }, + ...overrides, + }) as unknown as Request - expect(motivoItemId).toBe('motivo-item-1') - // Etapa y motivo sobre la oportunidad (board 555 = MONDAY_BOARD_OPORTUNIDADES de test) - expect(monday.cambios).toEqual([ - { boardId: '555', itemId: 'opp-123', columna: 'Estado', valor: 'Perdido' }, - { boardId: '555', itemId: 'opp-123', columna: 'Motivo No Compra', valor: 'Precio' }, - ]) - // Item en el catálogo: nombre = motivo + item de origen - expect(monday.motivos).toEqual([ - { nombre: 'Precio — Juan Pérez', categoria: 'Precio', descripcion: 'Se le hizo caro' }, - ]) +beforeEach(() => { + vi.clearAllMocks() + prismaMock.auditoriaLog.create.mockResolvedValue({}) + prismaMock.leadCaptura.findUnique.mockResolvedValue(null) + prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data })) + prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data })) + prismaMock.unidad.updateMany.mockResolvedValue({ count: 1 }) + prismaMock.automatizacionState.findUnique.mockResolvedValue(null) + prismaMock.automatizacionState.upsert.mockResolvedValue({}) + prismaMock.$transaction.mockImplementation(async (ops: Promise[]) => Promise.all(ops)) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('perderOportunidadEndpoint (POST /api/oportunidades/:id/perder) — local', () => { + it('etapa Perdido + motivo/submotivo en la fila local', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_9', nombreLead: 'Luis Demo' }) + const { res, json } = resFake() + + await perderOportunidadEndpoint( + reqCon({ params: { mondayItemId: 'loc_9' }, body: { motivo: 'Precio', comentario: 'Caro' } }), + res, + ) + + expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ + where: { leadId: 'loc_9' }, + data: expect.objectContaining({ etapa: 'Perdido', motivo: 'Precio' }), + }) + expect(json).toHaveBeenCalledWith({ mondayItemId: 'loc_9', etapa: 'Perdido', motivo: 'Precio', motivoItemId: null }) }) - it('el comentario es opcional', async () => { - const monday = mondayMock() - await perderOportunidad(monday, 'opp-9', 'Competencia') - expect(monday.motivos[0]).toEqual({ nombre: 'Competencia — Juan Pérez', categoria: 'Competencia' }) + it('el motivo debe ser del vocabulario (400 si no)', async () => { + const { res } = resFake() + + await expect( + perderOportunidadEndpoint( + reqCon({ params: { mondayItemId: 'loc_9' }, body: { motivo: 'No le gustó' } }), + res, + ), + ).rejects.toThrow() + expect(prismaMock.leadCaptura.update).not.toHaveBeenCalled() }) - it('propaga el error si Monday falla (el endpoint responde 5xx)', async () => { - const monday = mondayMock() - vi.spyOn(monday, 'cambiarValorColumna').mockRejectedValueOnce(new Error('Monday caído')) - await expect(perderOportunidad(monday, 'opp-1', 'Timing')).rejects.toThrow('Monday caído') - // No se registró motivo si falló el cambio de etapa - expect(monday.motivos).toEqual([]) + it('id heredado sin fila local: crea la sombra y guarda el descarte', async () => { + const { res, json } = resFake() + + await perderOportunidadEndpoint( + reqCon({ params: { mondayItemId: 'opp-77' }, body: { motivo: 'Timing' } }), + res, + ) + + expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ leadId: 'opp-77', board: 'OPORTUNIDADES', mondayItemId: 'opp-77' }), + }) + expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'opp-77', etapa: 'Perdido', motivoItemId: null }) }) }) -/** Monday mockeado para cambio de etapa: registra cambios y movimientos de grupo */ -function mondayEtapaMock(grupoActual = 'topics') { - return { - cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[], - movimientos: [] as { itemId: string; grupo: string }[], - async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) { - this.cambios.push({ boardId, itemId, columna, valor }) - }, - async columnaIdPorTitulo(_boardId: string, titulo: string) { - return titulo === 'Estado' ? 'status' : null - }, - async getItemConGrupo(itemId: string) { - return { - id: itemId, - name: 'Cliente Demo', - groupId: grupoActual, - column_values: [{ id: 'status', text: this.cambios.find((c) => c.columna === 'Estado')?.valor as string ?? '', value: '{}' }], - } - }, - async moverItemAGrupo(itemId: string, grupo: string) { - this.movimientos.push({ itemId, grupo }) - }, - } -} - -describe('cambiarEtapaOportunidad (POST /api/oportunidades/:id/etapa)', () => { - it('cambia la etapa y mueve al grupo destino (Abandono → Perdidos)', async () => { - const monday = mondayEtapaMock() - await cambiarEtapaOportunidad(monday, 'opp-1', 'Abandono') - expect(monday.cambios).toEqual([ - { boardId: '555', itemId: 'opp-1', columna: 'Estado', valor: 'Abandono' }, - ]) - expect(monday.movimientos).toEqual([{ itemId: 'opp-1', grupo: 'group_mm6s4cgq' }]) - }) - - it('Agendo Cita setea también la columna Fecha', async () => { - const monday = mondayEtapaMock() - await cambiarEtapaOportunidad(monday, 'opp-2', 'Agendo Cita', '2026-09-10') - expect(monday.cambios).toEqual([ - { boardId: '555', itemId: 'opp-2', columna: 'Estado', valor: 'Agendo Cita' }, - { boardId: '555', itemId: 'opp-2', columna: 'Fecha', valor: '2026-09-10' }, - ]) - // 'Agendo Cita' mapea al grupo topics: ya estaba ahí → no se mueve - expect(monday.movimientos).toEqual([]) - }) - - it('sin fecha no toca la columna Fecha', async () => { - const monday = mondayEtapaMock() - await cambiarEtapaOportunidad(monday, 'opp-3', 'Cotizador') - expect(monday.cambios).toEqual([ - { boardId: '555', itemId: 'opp-3', columna: 'Estado', valor: 'Cotizador' }, - ]) - }) - +describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa) — local', () => { it('las etapas permitidas son las fases del funnel definidas por el cliente', () => { expect(ETAPAS_OPORTUNIDAD_PERMITIDAS).toEqual([ 'Agendo Cita', @@ -148,64 +114,39 @@ describe('cambiarEtapaOportunidad (POST /api/oportunidades/:id/etapa)', () => { 'Abandono', ]) }) -}) -// ---------- Modo local (saludMonday caído / ids loc_* / fallback 502) ---------- - -describe('ruteo modo local (endpoints de oportunidades)', () => { - function resFake() { - const json = vi.fn() - const status = vi.fn().mockReturnValue({ json }) - return { res: { json, status } as unknown as Response, status, json } - } - - const reqCon = (overrides: Record): Request => - ({ - body: {}, - params: {}, - usuarioToken: { sub: 'u-1', username: 'asesor uno', rol: 'vendedor' }, - ...overrides, - }) as unknown as Request - - function tiposEncolados(): string[] { - return prismaMock.syncPendiente.create.mock.calls.map((c) => (c[0] as { data: { tipo: string } }).data.tipo) - } - - beforeEach(() => { - vi.clearAllMocks() - prismaMock.auditoriaLog.create.mockResolvedValue({}) - prismaMock.leadCaptura.findUnique.mockResolvedValue(null) - prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data })) - prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data })) - prismaMock.syncPendiente.create.mockResolvedValue({}) - prismaMock.automatizacionState.findUnique.mockResolvedValue(null) - prismaMock.automatizacionState.upsert.mockResolvedValue({}) - prismaMock.$transaction.mockImplementation(async (ops: Promise[]) => Promise.all(ops)) - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - it('cambiarEtapaOportunidadEndpoint con id loc_*: etapa local + CAMBIO_ETAPA encolado, sin tocar Monday', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true) // Monday vivo: el id loc_* manda - const spyCambiar = vi.spyOn(mondayService, 'cambiarValorColumna') + it('cambia la etapa en la fila local y encola CAMBIO_ETAPA', async () => { prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', board: 'OPORTUNIDADES' }) const { res, json } = resFake() await cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'loc_1' }, body: { etapa: 'Cotizador' } }), res) - expect(spyCambiar).not.toHaveBeenCalled() expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ where: { leadId: 'loc_1' }, data: { etapa: 'Cotizador', pendienteSync: true }, }) - expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA']) expect(json).toHaveBeenCalledWith({ itemId: 'loc_1', etapa: 'Cotizador' }) }) - it("cambiarEtapaOportunidadEndpoint 'Aparto' con Monday caído: crea el apartado local (crmLocal.apartar)", async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) + it("'Agendo Cita' con fecha: la fecha queda en datosJson", async () => { + prismaMock.leadCaptura.findUnique + .mockResolvedValueOnce({ leadId: 'loc_2', board: 'OPORTUNIDADES' }) // filaLocal + .mockResolvedValueOnce({ leadId: 'loc_2', datosJson: null }) // guardar fechaCita + const { res, json } = resFake() + + await cambiarEtapaOportunidadEndpoint( + reqCon({ params: { itemId: 'loc_2' }, body: { etapa: 'Agendo Cita', fecha: '2026-09-10' } }), + res, + ) + + expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ + where: { leadId: 'loc_2' }, + data: expect.objectContaining({ etapa: 'Agendo Cita', datosJson: JSON.stringify({ fechaCita: '2026-09-10' }) }), + }) + expect(json).toHaveBeenCalledWith({ itemId: 'loc_2', etapa: 'Agendo Cita', fecha: '2026-09-10' }) + }) + + it("'Aparto': crea el apartado local (crmLocal.apartar) y bloquea la unidad del inventario", async () => { prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_op', usuarioId: 'u-1', @@ -214,7 +155,7 @@ describe('ruteo modo local (endpoints de oportunidades)', () => { email: null, desarrollo: 'ATELIER', modelo: 'SOHO', - datosJson: null, + datosJson: JSON.stringify({ unidadCodigo: 'U-101' }), }) const { res, json } = resFake() @@ -228,39 +169,37 @@ describe('ruteo modo local (endpoints de oportunidades)', () => { expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ data: expect.objectContaining({ board: 'APARTADO', etapa: 'Pago Apartado', nombreLead: 'Luis Demo' }), }) - expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA', 'CREAR_APARTADO']) + // La unidad asignada queda APARTADA en el espejo local + expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({ + where: { codigo: 'U-101' }, + data: { status: 'APARTADA' }, + }) expect(json).toHaveBeenCalledWith({ itemId: 'loc_op', etapa: 'Aparto' }) }) - it('cambiarEtapaOportunidadEndpoint con id Monday y 502: fallback local con fila sombra', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true) - vi.spyOn(mondayService, 'cambiarValorColumna').mockRejectedValue(new AppError(502, 'Monday no respondió')) - const { res, json } = resFake() - - await cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'opp-88' }, body: { etapa: 'No Asistio' } }), res) - - expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ leadId: 'opp-88', board: 'OPORTUNIDADES', mondayItemId: 'opp-88' }), + it("'Aparto' sin unidad asignada no toca el inventario", async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue({ + leadId: 'loc_op', + usuarioId: 'u-1', + nombreLead: 'Luis Demo', + telefono: null, + email: null, + desarrollo: null, + modelo: null, + datosJson: null, }) - expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA']) - expect(json).toHaveBeenCalledWith({ itemId: 'opp-88', etapa: 'No Asistio' }) + const { res } = resFake() + + await cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'loc_op' }, body: { etapa: 'Aparto' } }), res) + + expect(prismaMock.unidad.updateMany).not.toHaveBeenCalled() }) - it('perderOportunidadEndpoint con Monday caído: descarte local (motivoItemId null) + DESCARTE encolado', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) - prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_9', nombreLead: 'Luis Demo' }) - const { res, json } = resFake() + it('etapa fuera del vocabulario → 400 (zod)', async () => { + const { res } = resFake() - await perderOportunidadEndpoint( - reqCon({ params: { mondayItemId: 'loc_9' }, body: { motivo: 'Precio', comentario: 'Caro' } }), - res, - ) - - expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ - where: { leadId: 'loc_9' }, - data: expect.objectContaining({ etapa: 'Perdido', motivo: 'Precio' }), - }) - expect(tiposEncolados()).toEqual(['DESCARTE']) - expect(json).toHaveBeenCalledWith({ mondayItemId: 'loc_9', etapa: 'Perdido', motivo: 'Precio', motivoItemId: null }) + await expect( + cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'loc_1' }, body: { etapa: 'Vendido' } }), res), + ).rejects.toThrow() }) }) diff --git a/api-gateway/src/controllers/oportunidades.controller.ts b/api-gateway/src/controllers/oportunidades.controller.ts index ab998df..d191cb4 100644 --- a/api-gateway/src/controllers/oportunidades.controller.ts +++ b/api-gateway/src/controllers/oportunidades.controller.ts @@ -1,103 +1,39 @@ import type { Request, Response } from 'express' import { z } from 'zod' -import { config } from '../config/index.js' -import { mondayService, type MondayService } from '../services/monday.service.js' -import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js' -import { automatizacionesService } from '../services/automatizaciones.service.js' -import { saludMonday } from '../services/salud-monday.service.js' -import { crmLocal, esIdLocal } from '../services/crm-local.service.js' -import { esFalloMonday } from '../services/sync-monday.service.js' +import { VOCABULARIO, type MotivoNoCompra } from '../config/vocabulario.js' import { prisma } from '../services/prisma.js' +import { crmLocal, esIdLocal } from '../services/crm-local.service.js' import * as auditoria from '../services/auditoria.service.js' /** Categorías del dropdown "Motivo No Compra" (vocabulario CRM FRASA) */ -export const MOTIVOS_NO_COMPRA = [ - 'Precio', - 'Producto', - 'Ubicacion', - 'Financiamiento', - 'Competencia', - 'Cliente', - 'Timing', -] as const +export const MOTIVOS_NO_COMPRA = VOCABULARIO.MOTIVOS_NO_COMPRA /** - * Subcategorías por categoría, tomadas del tablero de referencia "Motivos No - * Compra" del workspace CRM FRASA (los items de cada grupo son el catálogo). - * Sembradas como labels de la columna status 'Subcategoria' del tablero - * Motivos No Compra de Frasa-CAS. NOTA: 'Mesualidad' y 'Cambo de Planes' - * llevan las faltas de ortografía originales del tablero del cliente. + * Subcategorías por categoría, tomadas del catálogo del tablero de referencia + * "Motivos No Compra" del workspace CRM FRASA. NOTA: 'Mesualidad' y 'Cambo de + * Planes' llevan las faltas de ortografía originales del catálogo del cliente. */ -export const SUBCATEGORIAS_NO_COMPRA: Record<(typeof MOTIVOS_NO_COMPRA)[number], string[]> = { - Precio: ['Precio Total', 'Enganche', 'Mesualidad'], - Producto: ['Distribucion', 'Tamaño', 'Recamaras', 'Baños', 'Amenidades', 'Acabados', 'Estacionamiento', 'Seguridad', 'Inventario'], - Ubicacion: ['Zona', 'Accesos', 'Distancia', 'Servicios', 'Seguridad', 'Escuela', 'Trabajo'], - Financiamiento: ['No Califica', 'Credito Insuficiente', 'Tasa de interes', 'Mensualidad Alta', 'Enganche Alto', 'Forma de Pago', 'Infonavit Insuficiente', 'No tiene liquidez'], - Competencia: ['Precio', 'Producto', 'Ubicacion', 'Promocion', 'Financiamiento'], - Cliente: ['Cambo de Planes', 'Decision Familiar', 'No estaba Listo', 'Emergencia Personal'], - Timing: ['Compra Posterior', 'Esperando Credito', 'Esperando Venta de Propiedad', 'Esperando Proyecto'], -} +export const SUBCATEGORIAS_NO_COMPRA: Record = VOCABULARIO.SUBCATEGORIAS const perderSchema = z.object({ motivo: z.enum(MOTIVOS_NO_COMPRA), comentario: z.string().optional(), }) -type MondayOportunidades = Pick< - MondayService, - 'cambiarValorColumna' | 'crearMotivoNoCompra' | 'obtenerNombreItem' -> - /** - * Marca una oportunidad como perdida: etapa 'Perdido' + dropdown motivo, y - * registra el motivo en el tablero catálogo Motivos No Compra. - * Devuelve el ID del item creado en Motivos No Compra. + * Marca una oportunidad como perdida (etapa 'Perdido' + motivo/submotivo en la + * fila local) y encola el DESCARTE. SIEMPRE local. */ -export async function perderOportunidad( - monday: MondayOportunidades, - mondayItemId: string, - motivo: string, - comentario?: string, -): Promise { - const boardOportunidades = config.monday.boards.oportunidades - const nombreItem = await monday.obtenerNombreItem(mondayItemId) - await monday.cambiarValorColumna(boardOportunidades, mondayItemId, 'Estado', 'Perdido') - await monday.cambiarValorColumna(boardOportunidades, mondayItemId, 'Motivo No Compra', motivo) - return monday.crearMotivoNoCompra({ - nombre: `${motivo} — ${nombreItem}`, - categoria: motivo, - ...(comentario !== undefined ? { descripcion: comentario } : {}), - }) -} - -/** POST /api/oportunidades/:mondayItemId/perder { motivo, comentario? } */ export async function perderOportunidadEndpoint(req: Request, res: Response): Promise { const body = perderSchema.parse(req.body) const mondayItemId = String(req.params['mondayItemId']) - let motivoItemId: string | null = null - // Modo local (id loc_* o Monday caído / fallback 502): descarte local + - // DESCARTE encolado; el motivo del tablero catálogo lo crea el worker - if (esIdLocal(mondayItemId) || !saludMonday.estaDisponible()) { - await crmLocal.descartar(mondayItemId, body.motivo, { - fuente: 'oportunidad', - comentario: body.comentario, - usuarioId: req.usuarioToken?.sub, - sombra: !esIdLocal(mondayItemId), - }) - } else { - try { - motivoItemId = await perderOportunidad(mondayService, mondayItemId, body.motivo, body.comentario) - } catch (err) { - if (!esFalloMonday(err)) throw err - await crmLocal.descartar(mondayItemId, body.motivo, { - fuente: 'oportunidad', - comentario: body.comentario, - usuarioId: req.usuarioToken?.sub, - sombra: !esIdLocal(mondayItemId), - }) - } - } - res.json({ mondayItemId, etapa: 'Perdido', motivo: body.motivo, motivoItemId }) + await crmLocal.descartar(mondayItemId, body.motivo, { + fuente: 'oportunidad', + comentario: body.comentario, + usuarioId: req.usuarioToken?.sub, + sombra: !esIdLocal(mondayItemId), + }) + res.json({ mondayItemId, etapa: 'Perdido', motivo: body.motivo, motivoItemId: null }) auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'PERDER_OPORTUNIDAD', 'oportunidad', mondayItemId, body.motivo) } @@ -105,53 +41,20 @@ export async function perderOportunidadEndpoint(req: Request, res: Response): Pr /** * Etapas del funnel que el asesor puede mover desde los botones de - * "Mis oportunidades" (labels REALES de la columna status 'Estado' del board - * Oportunidades — el frontend los manda tal cual). + * "Mis oportunidades" (vocabulario canónico del CRM — el frontend las manda + * tal cual). */ -export const ETAPAS_OPORTUNIDAD_PERMITIDAS = [ - 'Agendo Cita', - 'Afluencia', - 'Cotizador', - 'Aparto', - 'No Asistio', - 'Abandono', -] as const +export const ETAPAS_OPORTUNIDAD_PERMITIDAS = VOCABULARIO.FASES_OPORTUNIDAD const cambiarEtapaSchema = z.object({ etapa: z.enum(ETAPAS_OPORTUNIDAD_PERMITIDAS), - // En 'Agendo Cita' puede setear la columna date 'Fecha' + // En 'Agendo Cita' puede setear la fecha de la cita fecha: z.string().optional(), }) -type MondayEtapa = Pick< - MondayService, - 'cambiarValorColumna' | 'columnaIdPorTitulo' | 'getItemConGrupo' | 'moverItemAGrupo' -> - /** - * Cambia la etapa de la oportunidad (columna 'Estado'), setea la fecha de la - * cita si aplica y mueve el item al grupo que corresponde a la etapa nueva - * (idempotente; el webhook de change haría lo mismo, pero así la UI responde - * al instante). - */ -export async function cambiarEtapaOportunidad( - monday: MondayEtapa, - itemId: string, - etapa: (typeof ETAPAS_OPORTUNIDAD_PERMITIDAS)[number], - fecha?: string, -): Promise { - const boardOportunidades = config.monday.boards.oportunidades - await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', etapa) - if (etapa === 'Agendo Cita' && fecha !== undefined) { - await monday.cambiarValorColumna(boardOportunidades, itemId, 'Fecha', fecha) - } - await moverAGrupoSegunEstado(monday, boardOportunidades, itemId) -} - -/** - * Cambio de etapa en modo local: guarda la etapa en la fila local y encola - * CAMBIO_ETAPA. En 'Aparto' crea además el apartado local (crmLocal.apartar), - * el equivalente al asegurarApartado del flujo Monday. + * Cambio de etapa local: guarda la etapa en la fila local y encola + * CAMBIO_ETAPA. En 'Aparto' crea además el apartado local (crmLocal.apartar). * Devuelve el unidadCodigo de la oportunidad si existe (para bloquear la unidad). */ async function cambiarEtapaLocal( @@ -185,67 +88,16 @@ async function cambiarEtapaLocal( export async function cambiarEtapaOportunidadEndpoint(req: Request, res: Response): Promise { const body = cambiarEtapaSchema.parse(req.body) const itemId = String(req.params['itemId']) - const usuarioSub = req.usuarioToken?.sub - if (esIdLocal(itemId) || !saludMonday.estaDisponible()) { - const unidadCodigo = await cambiarEtapaLocal(itemId, body.etapa, body.fecha, usuarioSub) - // Bloquear la unidad del inventario si la oportunidad tiene una asignada - if (body.etapa === 'Aparto' && unidadCodigo !== null) { - try { - await mondayService.actualizarDisponibilidadUnidad(unidadCodigo, 'APARTADA') - } catch (err) { - // eslint-disable-next-line no-console - console.error(`No se pudo bloquear la unidad ${unidadCodigo} de la oportunidad ${itemId}:`, err) - } - } - } else { + const unidadCodigo = await cambiarEtapaLocal(itemId, body.etapa, body.fecha, req.usuarioToken?.sub) + // Bloquear la unidad del inventario local si la oportunidad tiene una asignada + if (body.etapa === 'Aparto' && unidadCodigo !== null) { try { - await cambiarEtapaOportunidad(mondayService, itemId, body.etapa, body.fecha) + await prisma.unidad.updateMany({ where: { codigo: unidadCodigo }, data: { status: 'APARTADA' } }) } catch (err) { - if (!esFalloMonday(err)) throw err - const unidadCodigo = await cambiarEtapaLocal(itemId, body.etapa, body.fecha, usuarioSub) - // Bloquear la unidad del inventario si la oportunidad tiene una asignada - if (body.etapa === 'Aparto' && unidadCodigo !== null) { - try { - await mondayService.actualizarDisponibilidadUnidad(unidadCodigo, 'APARTADA') - } catch (err2) { - // eslint-disable-next-line no-console - console.error(`No se pudo bloquear la unidad ${unidadCodigo} de la oportunidad ${itemId}:`, err2) - } - } - res.json({ itemId, etapa: body.etapa, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) }) - return - } - // En 'Aparto' el apartado debe existir YA (Mis apartados recarga al volver): - // asegurarApartado reintenta unos segundos (Monday tarda en propagar la - // etapa) y es idempotente (dedup); el webhook queda como red de seguridad - // para cambios hechos directo en Monday. - if (body.etapa === 'Aparto') { - try { - await automatizacionesService.asegurarApartado(itemId) - } catch (err) { - // eslint-disable-next-line no-console - console.error(`No se pudo crear el apartado de la oportunidad ${itemId}:`, err) - } - // Bloquear la unidad del inventario (colocar en APARTADA) - try { - const boardOportunidades = config.monday.boards.oportunidades - const svc = mondayService as MondayService - const item = await svc.getItemConGrupo(itemId) - if (item) { - const colUnidad = await svc.columnaIdPorTitulo(boardOportunidades, 'Unidad') - const unidadCodigo = colUnidad - ? (item.column_values.find((cv) => cv.id === colUnidad)?.text ?? '').trim() - : '' - if (unidadCodigo !== '') { - await svc.actualizarDisponibilidadUnidad(unidadCodigo, 'APARTADA') - } - } - } catch (err) { - // eslint-disable-next-line no-console - console.error(`No se pudo bloquear la unidad de la oportunidad ${itemId}:`, err) - } + // eslint-disable-next-line no-console + console.error(`No se pudo bloquear la unidad ${unidadCodigo} de la oportunidad ${itemId}:`, err) } } res.json({ itemId, etapa: body.etapa, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) }) - auditoria.registrar(usuarioSub ?? 'sistema', 'CAMBIAR_ETAPA', 'oportunidad', itemId, body.etapa) + auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CAMBIAR_ETAPA', 'oportunidad', itemId, body.etapa) } diff --git a/api-gateway/src/controllers/planes.controller.test.ts b/api-gateway/src/controllers/planes.controller.test.ts index 1c8d15b..f20e0de 100644 --- a/api-gateway/src/controllers/planes.controller.test.ts +++ b/api-gateway/src/controllers/planes.controller.test.ts @@ -1,68 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { notificarEscrituracionSiLiquidado, resolverFiltroMios } from './planes.controller.js' - -// ---------- Prisma mockeado: el encolado de la réplica no toca la BD ---------- - -const prismaMock = vi.hoisted(() => ({ - syncPendiente: { - create: vi.fn(), - }, -})) - -vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) - -/** Monday mockeado: registra cambios de columna */ -function mondayMock() { - return { - cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[], - async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) { - this.cambios.push({ boardId, itemId, columna, valor }) - }, - } -} - -describe('notificarEscrituracionSiLiquidado (puente cobranza → postventa)', () => { - beforeEach(() => { - vi.clearAllMocks() - prismaMock.syncPendiente.create.mockResolvedValue({}) - }) - - it('plan LIQUIDADO con mondayItemId: mueve el apartado a "X Escriturar"', async () => { - const monday = mondayMock() - const ok = await notificarEscrituracionSiLiquidado({ mondayItemId: 'apt-55' }, true, monday) - expect(ok).toBe(true) - // board 666 = MONDAY_BOARD_APARTADO de test - expect(monday.cambios).toEqual([ - { boardId: '666', itemId: 'apt-55', columna: 'Estado', valor: 'X Escriturar' }, - ]) - }) - - it('no hace nada si el plan no está liquidado o no tiene item ligado', async () => { - const monday = mondayMock() - expect(await notificarEscrituracionSiLiquidado({ mondayItemId: 'apt-55' }, false, monday)).toBe(false) - expect(await notificarEscrituracionSiLiquidado({ mondayItemId: null }, true, monday)).toBe(false) - expect(await notificarEscrituracionSiLiquidado({}, true, monday)).toBe(false) - expect(monday.cambios).toEqual([]) - }) - - it('un fallo de Monday no rompe el abono: loguea, encola LIQUIDADO_ESCRITURAR y devuelve false', async () => { - const monday = mondayMock() - vi.spyOn(monday, 'cambiarValorColumna').mockRejectedValueOnce(new Error('Monday caído')) - const spyError = vi.spyOn(console, 'error').mockImplementation(() => {}) - const ok = await notificarEscrituracionSiLiquidado({ mondayItemId: 'apt-55' }, true, monday) - expect(ok).toBe(false) - expect(spyError).toHaveBeenCalled() - // La escrituración queda encolada para que el worker la aplique al volver Monday - expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ - tipo: 'LIQUIDADO_ESCRITURAR', - payloadJson: JSON.stringify({ mondayItemId: 'apt-55' }), - referenciaLocal: 'apt-55', - }), - }) - spyError.mockRestore() - }) -}) +import { describe, expect, it } from 'vitest' +import { resolverFiltroMios } from './planes.controller.js' describe('resolverFiltroMios (?mios=1)', () => { it('sin ?mios=1 no filtra nada', () => { diff --git a/api-gateway/src/controllers/planes.controller.ts b/api-gateway/src/controllers/planes.controller.ts index 50617dd..4dab372 100644 --- a/api-gateway/src/controllers/planes.controller.ts +++ b/api-gateway/src/controllers/planes.controller.ts @@ -1,7 +1,6 @@ import type { Request, Response } from 'express' import { z } from 'zod' import { prisma } from '../services/prisma.js' -import { encolarSyncSeguro } from '../services/crm-local.service.js' import { alCorriente, avancePlan, @@ -20,8 +19,6 @@ import { type PlanPago as PlanPagoDominio, } from '../services/cobranza.service.js' import { BadRequestError, NotFoundError } from '../utils/errors.js' -import { config } from '../config/index.js' -import { mondayService, type MondayService } from '../services/monday.service.js' import type { AplicacionPago, NotaPlan, Pago, PlanPago } from '@prisma/client' // ---------- Mapeo Prisma → dominio (funciones puras del servicio) ---------- @@ -117,7 +114,7 @@ const crearPlanSchema = z.object({ engancheTotal: z.number().positive(), numPagos: z.number().int().min(1), tasaMoraMensual: z.number().min(0).optional(), - // Item del tablero Apartado (Monday) ligado al plan; también ligable después con PATCH + // Item del tablero Apartado ligado al plan (compatibilidad de datos) mondayItemId: z.string().min(1).optional(), }) @@ -213,31 +210,6 @@ const abonoSchema = z.object({ referencia: z.string().min(1), }) -type MondayApartado = Pick - -/** - * Puente cobranza → postventa: cuando el plan queda LIQUIDADO y está ligado a - * un item del tablero Apartado, ese item pasa a 'X Escriturar'. - * Un fallo de Monday NUNCA rompe el abono: se loguea y devuelve false. - */ -export async function notificarEscrituracionSiLiquidado( - plan: { mondayItemId?: string | null }, - liquidado: boolean, - monday: MondayApartado = mondayService, -): Promise { - if (!liquidado || plan.mondayItemId == null || plan.mondayItemId === '') return false - try { - await monday.cambiarValorColumna(config.monday.boards.apartado, plan.mondayItemId, 'Estado', 'X Escriturar') - return true - } catch (err) { - // eslint-disable-next-line no-console - console.error(`No se pudo mover el apartado ${plan.mondayItemId} a 'X Escriturar':`, err) - // La escrituración queda encolada (LIQUIDADO_ESCRITURAR) para el worker - await encolarSyncSeguro('LIQUIDADO_ESCRITURAR', { mondayItemId: plan.mondayItemId }, plan.mondayItemId) - return false - } -} - /** POST /api/planes/:id/abonos — aplica un abono (estricto al centavo) */ export async function registrarAbonoEndpoint(req: Request, res: Response): Promise { const body = abonoSchema.parse(req.body) @@ -270,24 +242,7 @@ export async function registrarAbonoEndpoint(req: Request, res: Response): Promi ]) const actualizado = await cargarPlan(plan.id) - const dominioActualizado = aDominio(actualizado) - const calculado = conCalculados(dominioActualizado) - - // Si el abono liquidó el plan, avisar a postventa (no bloquea la respuesta) - await notificarEscrituracionSiLiquidado(dominioActualizado, calculado.status === 'LIQUIDADO') - - res.status(201).json(calculado) -} - -const ligarMondaySchema = z.object({ mondayItemId: z.string().min(1).nullable() }) - -/** PATCH /api/planes/:id/monday — liga (o desliga con null) el item de Apartado en Monday */ -export async function ligarMondayItem(req: Request, res: Response): Promise { - const body = ligarMondaySchema.parse(req.body) - const plan = await cargarPlan(String(req.params['id'])) - await prisma.planPago.update({ where: { id: plan.id }, data: { mondayItemId: body.mondayItemId } }) - const actualizado = await cargarPlan(plan.id) - res.json(conCalculados(aDominio(actualizado))) + res.status(201).json(conCalculados(aDominio(actualizado))) } const notaSchema = z.object({ texto: z.string() }) diff --git a/api-gateway/src/controllers/prefill-fallback.test.ts b/api-gateway/src/controllers/prefill-fallback.test.ts index ca05564..3d97e3a 100644 --- a/api-gateway/src/controllers/prefill-fallback.test.ts +++ b/api-gateway/src/controllers/prefill-fallback.test.ts @@ -1,8 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Request, Response } from 'express' -import { AppError, NotFoundError } from '../utils/errors.js' -// ---------- Prisma y Monday mockeados (los tests no tocan BD ni red) ---------- +// ---------- Prisma mockeado (los tests no tocan la BD) ---------- const prismaMock = vi.hoisted(() => ({ leadCaptura: { @@ -17,23 +16,14 @@ const prismaMock = vi.hoisted(() => ({ }, })) -const mondayMock = vi.hoisted(() => ({ - getApartados: vi.fn(), - getContactos: vi.fn(), - getContactoDetalle: vi.fn(), - getOportunidades: vi.fn(), -})) - vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) -vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock })) import { prefillApartadoEndpoint } from './clientes.controller.js' -import { saludMonday } from '../services/salud-monday.service.js' /** - * Fallback del prefill del alta (GET /api/altas/prefill-apartado/:itemId): - * Monday caído/502 → el prefill se arma desde LeadCaptura (apartado local) + - * Cliente + cotización local, con los campos que el front ya consume. + * Prefill del alta (GET /api/altas/prefill-apartado/:itemId) 100% local: + * el prefill se arma desde LeadCaptura (apartado local) + Cliente + cotización + * local, con los campos que el front ya consume. */ const apartadoLocal = { @@ -101,22 +91,18 @@ function resFake() { beforeEach(() => { vi.clearAllMocks() - vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible') - saludMonday.restablecer() prismaMock.leadCaptura.findUnique.mockResolvedValue(apartadoLocal) prismaMock.leadCaptura.findFirst.mockResolvedValue(oportunidadLocal) prismaMock.cliente.findFirst.mockResolvedValue(clienteLocal) prismaMock.cotizacion.findFirst.mockResolvedValue(cotizacionLocal) }) -describe('prefillApartadoEndpoint (fallback local)', () => { - it('Monday caído: prefill desde apartado local + Cliente + oportunidad local + cotización local', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) +describe('prefillApartadoEndpoint (GET /api/altas/prefill-apartado/:itemId) — local', () => { + it('prefill desde apartado local + Cliente + oportunidad local + cotización local', async () => { const { res, json } = resFake() await prefillApartadoEndpoint({ params: { itemId: 'loc_a1' } } as unknown as Request, res) - expect(mondayMock.getApartados).not.toHaveBeenCalled() const prefill = json.mock.calls[0]?.[0] as Record expect(prefill['apartado']).toMatchObject({ id: 'loc_a1', @@ -142,30 +128,23 @@ describe('prefillApartadoEndpoint (fallback local)', () => { expect(prefill['cotizacion']).toMatchObject({ tipoPropuesta: 'INFONAVIT', acabados: true, precioFinal: 4_400_000 }) }) - it('Monday caído y el apartado no existe en local → 404', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) + it('el apartado no existe en local → 404', async () => { prismaMock.leadCaptura.findUnique.mockResolvedValue(null) await expect( prefillApartadoEndpoint({ params: { itemId: 'nope' } } as unknown as Request, resFake().res), - ).rejects.toThrow(NotFoundError) + ).rejects.toMatchObject({ status: 404 }) }) - it('Monday lanza 502: cae al prefill local; el 404 de Monday se propaga intacto', async () => { - mondayMock.getApartados.mockRejectedValue(new AppError(502, 'Monday no respondió')) - const { res, json } = resFake() - await prefillApartadoEndpoint({ params: { itemId: 'loc_a1' } } as unknown as Request, res) - expect((json.mock.calls[0]?.[0] as Record)['apartado']).toMatchObject({ id: 'loc_a1' }) + it('un id que no es del board APARTADO → 404 (el prefill solo aplica a apartados)', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue({ ...apartadoLocal, board: 'LEADS' }) - // Apartado inexistente en Monday (404): NO es fallo de Monday, se propaga - mondayMock.getApartados.mockResolvedValue([]) await expect( - prefillApartadoEndpoint({ params: { itemId: 'x' } } as unknown as Request, resFake().res), - ).rejects.toThrow(NotFoundError) + prefillApartadoEndpoint({ params: { itemId: 'loc_lead' } } as unknown as Request, resFake().res), + ).rejects.toMatchObject({ status: 404 }) }) it('sin Cliente ni oportunidad local: contacto/oportunidad van en null (el front los tolera)', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) prismaMock.cliente.findFirst.mockResolvedValue(null) prismaMock.leadCaptura.findFirst.mockResolvedValue(null) prismaMock.cotizacion.findFirst.mockResolvedValue(null) diff --git a/api-gateway/src/controllers/recordatorio-vendedor.controller.test.ts b/api-gateway/src/controllers/recordatorio-vendedor.controller.test.ts index db18c2d..25e3f38 100644 --- a/api-gateway/src/controllers/recordatorio-vendedor.controller.test.ts +++ b/api-gateway/src/controllers/recordatorio-vendedor.controller.test.ts @@ -47,6 +47,7 @@ describe('recordatorio-vendedor.controller', () => { titulo: 'Test', descripcion: null, leadId: null, + clienteClave: null, fechaRecordar: '2026-09-10T10:00:00', }) expect(res.status).toHaveBeenCalledWith(201) @@ -66,6 +67,7 @@ describe('recordatorio-vendedor.controller', () => { completados: true, desde: '2026-09-01', hasta: null, + clienteClave: null, }) expect(res.json).toHaveBeenCalledWith([]) }) diff --git a/api-gateway/src/controllers/reportes.controller.ts b/api-gateway/src/controllers/reportes.controller.ts index 064ee82..b0511f7 100644 --- a/api-gateway/src/controllers/reportes.controller.ts +++ b/api-gateway/src/controllers/reportes.controller.ts @@ -1,12 +1,11 @@ import type { Request, Response } from 'express' -import { mondayService } from '../services/monday.service.js' import { ReportesService } from '../services/reportes.service.js' import { cargarPlanesDominio } from './planes.controller.js' import { obtenerScores } from '../services/lead-scoring.service.js' import { resumenMora } from '../services/mora.service.js' /** Singleton del agregador (cache en memoria de 60 s; los tests instancian el suyo) */ -export const reportesService = new ReportesService(mondayService, cargarPlanesDominio) +export const reportesService = new ReportesService(cargarPlanesDominio) /** GET /api/reportes/resumen — todos los KPIs del dashboard en una sola respuesta * ?dias=7|30|90 acota leads/oportunidades al rango (default: todo el histórico) */ diff --git a/api-gateway/src/controllers/sistema.controller.ts b/api-gateway/src/controllers/sistema.controller.ts index 0b040b5..c7693f9 100644 --- a/api-gateway/src/controllers/sistema.controller.ts +++ b/api-gateway/src/controllers/sistema.controller.ts @@ -1,15 +1,9 @@ import type { Request, Response } from 'express' -import { prisma } from '../services/prisma.js' -import { saludMonday } from '../services/salud-monday.service.js' /** - * GET /api/sistema/estado — salud del modo local para el banner del front: - * `mondayOk` (false = operar en modo local) y `pendientesSync` (registros de - * la cola SyncPendiente esperando réplica a Monday, incluidos los en ERROR). + * GET /api/sistema/estado — estado del modo local para el banner del front. + * Monday ya no participa: la API siempre opera con datos locales. */ export async function estadoSistema(_req: Request, res: Response): Promise { - const pendientesSync = await prisma.syncPendiente.count({ - where: { estado: { in: ['PENDIENTE', 'ERROR'] } }, - }) - res.json({ mondayOk: saludMonday.estaDisponible(), pendientesSync }) + res.json({ mondayOk: false, pendientesSync: 0 }) } diff --git a/api-gateway/src/controllers/unidades.controller.test.ts b/api-gateway/src/controllers/unidades.controller.test.ts index f415259..ef3ac66 100644 --- a/api-gateway/src/controllers/unidades.controller.test.ts +++ b/api-gateway/src/controllers/unidades.controller.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Request, Response } from 'express' -import { AppError, NotFoundError } from '../utils/errors.js' +import { NotFoundError } from '../utils/errors.js' -// ---------- Prisma y Monday mockeados (los tests no tocan BD ni red) ---------- +// ---------- Prisma mockeado (los tests no tocan BD) ---------- const prismaMock = vi.hoisted(() => ({ unidad: { @@ -11,16 +11,9 @@ const prismaMock = vi.hoisted(() => ({ }, })) -const mondayMock = vi.hoisted(() => ({ - getUnidades: vi.fn(), - getUnidad: vi.fn(), -})) - vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) -vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock })) import { listarUnidades, obtenerUnidad } from './unidades.controller.js' -import { saludMonday } from '../services/salud-monday.service.js' const filaLocal = { codigo: 'U-101', @@ -42,30 +35,16 @@ function resFake() { beforeEach(() => { vi.clearAllMocks() - vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible') - saludMonday.restablecer() prismaMock.unidad.findMany.mockResolvedValue([filaLocal]) prismaMock.unidad.findUnique.mockResolvedValue(filaLocal) }) -describe('unidades.controller (fallback local)', () => { - it('Monday vivo: devuelve Monday y no toca la tabla local', async () => { - mondayMock.getUnidades.mockResolvedValue([{ codigo: 'M-1', status: 'DISPONIBLE', desarrollo: 'SENDA' }]) +describe('unidades.controller (local)', () => { + it('lista desde la tabla Unidad con la forma de DTO completa', async () => { const { res, json } = resFake() await listarUnidades({ query: {} } as unknown as Request, res) - expect(json).toHaveBeenCalledWith([{ codigo: 'M-1', status: 'DISPONIBLE', desarrollo: 'SENDA' }]) - expect(prismaMock.unidad.findMany).not.toHaveBeenCalled() - }) - - it('Monday caído (circuit breaker): lista desde la tabla Unidad con la misma forma de DTO', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) - const { res, json } = resFake() - - await listarUnidades({ query: {} } as unknown as Request, res) - - expect(mondayMock.getUnidades).not.toHaveBeenCalled() const lista = json.mock.calls[0]?.[0] as Record[] expect(lista).toHaveLength(1) expect(lista[0]).toMatchObject({ @@ -79,8 +58,7 @@ describe('unidades.controller (fallback local)', () => { }) }) - it('Monday caído: los filtros ?disponibles y ?desarrollo se aplican igual', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) + it('los filtros ?disponibles y ?desarrollo se aplican igual', async () => { prismaMock.unidad.findMany.mockResolvedValue([ filaLocal, { ...filaLocal, codigo: 'U-102', status: 'APARTADO' }, @@ -94,18 +72,7 @@ describe('unidades.controller (fallback local)', () => { expect(lista.map((u) => u['codigo'])).toEqual(['S-1']) }) - it('Monday lanza 502: fallback local; otro error se propaga', async () => { - mondayMock.getUnidades.mockRejectedValue(new AppError(502, 'Monday no respondió')) - const { res, json } = resFake() - await listarUnidades({ query: {} } as unknown as Request, res) - expect((json.mock.calls[0]?.[0] as unknown[]).length).toBe(1) - - mondayMock.getUnidades.mockRejectedValue(new AppError(500, 'bug')) - await expect(listarUnidades({ query: {} } as unknown as Request, resFake().res)).rejects.toThrow('bug') - }) - - it('obtenerUnidad: Monday caído → local; inexistente en ambos → 404', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) + it('obtenerUnidad: devuelve el DTO; inexistente → 404', async () => { const { res, json } = resFake() await obtenerUnidad({ params: { codigo: 'U-101' } } as unknown as Request, res) diff --git a/api-gateway/src/controllers/unidades.controller.ts b/api-gateway/src/controllers/unidades.controller.ts index 75bb09c..5fd56f9 100644 --- a/api-gateway/src/controllers/unidades.controller.ts +++ b/api-gateway/src/controllers/unidades.controller.ts @@ -1,13 +1,13 @@ import type { Request, Response } from 'express' -import { listarUnidadesConFallback, obtenerUnidadConFallback } from '../services/unidades-local.service.js' +import { getUnidadesLocal, getUnidadLocal } from '../services/unidades-local.service.js' import { NotFoundError } from '../utils/errors.js' /** * GET /api/unidades — ?disponibles=true filtra solo DISPONIBLE; ?desarrollo= filtra por desarrollo. - * Monday caído (o 502) → espejo local (tabla Unidad), mismo contrato de respuesta. + * Lee el espejo local (tabla Unidad), mismo contrato de respuesta. */ export async function listarUnidades(req: Request, res: Response): Promise { - const unidades = await listarUnidadesConFallback() + const unidades = await getUnidadesLocal() const disponibles = req.query['disponibles'] === 'true' const desarrollo = req.query['desarrollo']?.toString().toUpperCase() let lista = disponibles ? unidades.filter((u) => u.status === 'DISPONIBLE') : unidades @@ -17,9 +17,9 @@ export async function listarUnidades(req: Request, res: Response): Promise res.json(lista) } -/** GET /api/unidades/:codigo — 404 si no existe ni en Monday ni en el espejo local */ +/** GET /api/unidades/:codigo — 404 si no existe en el espejo local */ export async function obtenerUnidad(req: Request, res: Response): Promise { - const unidad = await obtenerUnidadConFallback(String(req.params['codigo'])) + const unidad = await getUnidadLocal(String(req.params['codigo'])) if (!unidad) throw new NotFoundError(`Unidad ${String(req.params['codigo'])} no encontrada`) res.json(unidad) } diff --git a/api-gateway/src/controllers/usuarios.controller.test.ts b/api-gateway/src/controllers/usuarios.controller.test.ts index c70efa7..8e9ff51 100644 --- a/api-gateway/src/controllers/usuarios.controller.test.ts +++ b/api-gateway/src/controllers/usuarios.controller.test.ts @@ -1,26 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Request, Response } from 'express' import type { Usuario } from '@prisma/client' -import { - actualizarRolEndpoint, - actualizarVinculoMondayEndpoint, - listarUsuariosMondayEndpoint, -} from './usuarios.controller.js' -import { AppError } from '../utils/errors.js' +import { actualizarRolEndpoint } from './usuarios.controller.js' -// ---------- Monday y Prisma mockeados (los tests no tocan red ni BD) ---------- +// ---------- Prisma mockeado (los tests no tocan BD) ---------- -const mondayMock = vi.hoisted(() => ({ getUsuariosWorkspace: vi.fn() })) const prismaMock = vi.hoisted(() => ({ usuario: { update: vi.fn() } })) -vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock })) vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) -const USUARIOS_WORKSPACE = [ - { id: 13676, nombre: 'Erick Peña', email: 'erick@frasa.mx' }, - { id: 20400, nombre: 'Cristina Barrera', email: 'cristina@frasa.mx' }, -] - function usuarioFake(overrides: Partial = {}): Usuario { return { id: 'u-1', @@ -54,91 +42,6 @@ function resFake() { beforeEach(() => { vi.clearAllMocks() - mondayMock.getUsuariosWorkspace.mockResolvedValue(USUARIOS_WORKSPACE) -}) - -describe('listarUsuariosMondayEndpoint (GET /api/monday/usuarios)', () => { - it('devuelve los usuarios del workspace con id numérico', async () => { - const { res, json } = resFake() - - await listarUsuariosMondayEndpoint(reqFake(), res) - - expect(json).toHaveBeenCalledWith([ - { id: 13676, nombre: 'Erick Peña', email: 'erick@frasa.mx' }, - { id: 20400, nombre: 'Cristina Barrera', email: 'cristina@frasa.mx' }, - ]) - }) - - it('propaga el error si Monday falla (el error handler responde 5xx)', async () => { - mondayMock.getUsuariosWorkspace.mockRejectedValue(new Error('Monday caído')) - const { res } = resFake() - await expect(listarUsuariosMondayEndpoint(reqFake(), res)).rejects.toThrow('Monday caído') - }) -}) - -describe('actualizarVinculoMondayEndpoint (PATCH /api/usuarios/me/monday)', () => { - it('con id válido guarda el vínculo y devuelve el perfil actualizado', async () => { - prismaMock.usuario.update.mockResolvedValue(usuarioFake({ mondayUserId: 13676 })) - const { res, json } = resFake() - - await actualizarVinculoMondayEndpoint(reqFake({ body: { mondayUserId: 13676 } }), res) - - expect(prismaMock.usuario.update).toHaveBeenCalledWith({ - where: { id: 'u-1' }, - data: { mondayUserId: 13676 }, - }) - expect(json).toHaveBeenCalledWith({ - nombre: 'ERICK ISRAEL PEÑA RAMOS', - rol: 'vendedor', - asignacion: 'MONTEBELLO', - mondayUserId: 13676, - modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro'], - acciones: [], - }) - }) - - it('con id inexistente en el workspace responde 400 y no toca la BD', async () => { - const { res } = resFake() - - const error = await actualizarVinculoMondayEndpoint( - reqFake({ body: { mondayUserId: 99999 } }), - res, - ).catch((e: unknown) => e) - - expect(error).toBeInstanceOf(AppError) - expect(error).toMatchObject({ status: 400 }) - expect(prismaMock.usuario.update).not.toHaveBeenCalled() - }) - - it('con null desvincula sin consultar Monday', async () => { - prismaMock.usuario.update.mockResolvedValue(usuarioFake({ mondayUserId: null })) - const { res, json } = resFake() - - await actualizarVinculoMondayEndpoint(reqFake({ body: { mondayUserId: null } }), res) - - expect(mondayMock.getUsuariosWorkspace).not.toHaveBeenCalled() - expect(prismaMock.usuario.update).toHaveBeenCalledWith({ - where: { id: 'u-1' }, - data: { mondayUserId: null }, - }) - expect(json).toHaveBeenCalledWith(expect.objectContaining({ mondayUserId: null })) - }) - - it('sin token verificado: 400', async () => { - const { res } = resFake() - await expect( - actualizarVinculoMondayEndpoint(reqFake({ usuarioToken: undefined }), res), - ).rejects.toThrow('Falta el token verificado') - expect(prismaMock.usuario.update).not.toHaveBeenCalled() - }) - - it('body inválido (string): ZodError (el error handler lo traduce a 400)', async () => { - const { res } = resFake() - await expect( - actualizarVinculoMondayEndpoint(reqFake({ body: { mondayUserId: '13676' } }), res), - ).rejects.toThrow() - expect(prismaMock.usuario.update).not.toHaveBeenCalled() - }) }) describe('actualizarRolEndpoint (PATCH /api/usuarios/:id/rol)', () => { @@ -193,4 +96,3 @@ describe('actualizarRolEndpoint (PATCH /api/usuarios/:id/rol)', () => { expect(prismaMock.usuario.update).not.toHaveBeenCalled() }) }) - diff --git a/api-gateway/src/controllers/usuarios.controller.ts b/api-gateway/src/controllers/usuarios.controller.ts index 1ba748f..52512fe 100644 --- a/api-gateway/src/controllers/usuarios.controller.ts +++ b/api-gateway/src/controllers/usuarios.controller.ts @@ -1,49 +1,10 @@ import type { Request, Response } from 'express' import { z } from 'zod' -import { mondayService } from '../services/monday.service.js' import { prisma } from '../services/prisma.js' import { aPublico } from '../services/auth.service.js' import { esRolValido } from '../config/roles.js' import { AppError, BadRequestError } from '../utils/errors.js' -/** - * Vínculo usuario del ecosistema ↔ usuario de Monday (people). Es el mapeo - * que alimenta round-robin, mis-leads, actividades y alertas; el usuario lo - * elige él mismo desde la landing (Configuración de cuenta). - */ - -/** GET /api/monday/usuarios (Bearer) → usuarios activos del workspace */ -export async function listarUsuariosMondayEndpoint(_req: Request, res: Response): Promise { - const usuarios = await mondayService.getUsuariosWorkspace() - res.json(usuarios.map((u) => ({ id: u.id, nombre: u.nombre, email: u.email }))) -} - -const vinculoMondaySchema = z.object({ - mondayUserId: z.number().int().positive().nullable(), -}) - -/** - * PATCH /api/usuarios/me/monday (Bearer) { mondayUserId: number | null } - * null desvincula. Un id que no existe en el workspace → 400. - */ -export async function actualizarVinculoMondayEndpoint(req: Request, res: Response): Promise { - if (!req.usuarioToken) throw new BadRequestError('Falta el token verificado') - const { mondayUserId } = vinculoMondaySchema.parse(req.body) - - if (mondayUserId !== null) { - const usuarios = await mondayService.getUsuariosWorkspace() - if (!usuarios.some((u) => u.id === mondayUserId)) { - throw new BadRequestError(`El usuario de Monday ${mondayUserId} no existe en el workspace`) - } - } - - const usuario = await prisma.usuario.update({ - where: { id: req.usuarioToken.sub }, - data: { mondayUserId }, - }) - res.json(await aPublico(usuario)) -} - const cambiarRolSchema = z.object({ rol: z.string().min(1), }) diff --git a/api-gateway/src/controllers/webhooks.controller.test.ts b/api-gateway/src/controllers/webhooks.controller.test.ts deleted file mode 100644 index 0a062ec..0000000 --- a/api-gateway/src/controllers/webhooks.controller.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Request, Response } from 'express' - -const mocks = vi.hoisted(() => ({ - runRoundRobin: vi.fn(), - escaneoOportunidades: vi.fn(), - moverAGrupo: vi.fn(), -})) - -vi.mock('../services/automatizaciones.service.js', () => ({ - automatizacionesService: { - runRoundRobin: mocks.runRoundRobin, - escaneoOportunidades: mocks.escaneoOportunidades, - }, -})) - -vi.mock('../services/grupos-estado.service.js', () => ({ - // Leads (111) y Oportunidades (222) con mapeo; el resto de boards, sin mapeo - GRUPOS_POR_ESTADO: { - '111': { columna: 'Estado', destinos: { NUEVO: 'topics' } }, - '222': { columna: 'Estado', destinos: { Ganado: 'group_mm6scqz3' } }, - }, - moverAGrupoSegunEstado: mocks.moverAGrupo, -})) - -vi.mock('../config/index.js', () => ({ - config: { - monday: { - boards: { leads: '111', oportunidades: '222' }, - }, - }, -})) - -import { webhookMonday } from './webhooks.controller.js' - -function resFake(): { res: Response; json: ReturnType; sendStatus: ReturnType } { - const json = vi.fn() - const sendStatus = vi.fn() - return { res: { json, sendStatus } as unknown as Response, json, sendStatus } -} - -const reqCon = (body: unknown): Request => ({ body } as Request) - -beforeEach(() => { - vi.clearAllMocks() - mocks.runRoundRobin.mockResolvedValue({ asignados: 1 }) - mocks.escaneoOportunidades.mockResolvedValue({ apartadosCreados: ['x'], ventasCreadas: [], errores: [] }) - mocks.moverAGrupo.mockResolvedValue({ movido: true, grupo: 'topics' }) -}) - -describe('webhookMonday', () => { - it('responde el challenge del handshake tal cual', () => { - const { res, json, sendStatus } = resFake() - webhookMonday(reqCon({ challenge: 'abc-123' }), res) - expect(json).toHaveBeenCalledWith({ challenge: 'abc-123' }) - expect(sendStatus).not.toHaveBeenCalled() - }) - - it('ignora payloads sin estructura de evento (200 sin disparar nada)', () => { - const { res, sendStatus } = resFake() - webhookMonday(reqCon({ foo: 'bar' }), res) - expect(sendStatus).toHaveBeenCalledWith(200) - expect(mocks.runRoundRobin).not.toHaveBeenCalled() - expect(mocks.escaneoOportunidades).not.toHaveBeenCalled() - }) - - it('create_item en Oportunidades dispara el round-robin del item (tablero oportunidades)', () => { - const { res, sendStatus } = resFake() - webhookMonday(reqCon({ event: { type: 'create_item', boardId: 222, itemId: 999 } }), res) - expect(sendStatus).toHaveBeenCalledWith(200) - expect(mocks.runRoundRobin).toHaveBeenCalledWith('999', 'oportunidades') - }) - - it('create_pulse (nombre real del evento) con pulseId también dispara el round-robin', () => { - const { res, sendStatus } = resFake() - webhookMonday(reqCon({ event: { type: 'create_pulse', boardId: 222, pulseId: 777 } }), res) - expect(sendStatus).toHaveBeenCalledWith(200) - expect(mocks.runRoundRobin).toHaveBeenCalledWith('777', 'oportunidades') - }) - - it('create_item en Leads dispara el round-robin del tablero Leads (SIN crear oportunidad)', () => { - const { res, sendStatus } = resFake() - webhookMonday(reqCon({ event: { type: 'create_item', boardId: 111, itemId: 999 } }), res) - expect(sendStatus).toHaveBeenCalledWith(200) - expect(mocks.runRoundRobin).toHaveBeenCalledWith('999', 'leads') - }) - - it('create_item en otro board no dispara round-robin', () => { - const { res, sendStatus } = resFake() - webhookMonday(reqCon({ event: { type: 'create_item', boardId: 555, itemId: 999 } }), res) - expect(sendStatus).toHaveBeenCalledWith(200) - expect(mocks.runRoundRobin).not.toHaveBeenCalled() - }) - - it('change_status_column_value en Oportunidades dispara el escaneo', () => { - const { res, sendStatus } = resFake() - webhookMonday(reqCon({ event: { type: 'change_status_column_value', boardId: '222', itemId: 1 } }), res) - expect(sendStatus).toHaveBeenCalledWith(200) - expect(mocks.escaneoOportunidades).toHaveBeenCalledOnce() - }) - - it('change_column_value en Oportunidades también dispara el escaneo', () => { - const { res } = resFake() - webhookMonday(reqCon({ event: { type: 'change_column_value', boardId: 222, itemId: 1 } }), res) - expect(mocks.escaneoOportunidades).toHaveBeenCalledOnce() - }) - - it('update_column_value (nombre real del evento) en Oportunidades dispara el escaneo', () => { - const { res, sendStatus } = resFake() - webhookMonday(reqCon({ event: { type: 'update_column_value', boardId: 222, pulseId: 5 } }), res) - expect(sendStatus).toHaveBeenCalledWith(200) - expect(mocks.escaneoOportunidades).toHaveBeenCalledOnce() - }) - - it('cambio de status en otro board no dispara el escaneo de oportunidades', () => { - const { res, sendStatus } = resFake() - webhookMonday(reqCon({ event: { type: 'change_status_column_value', boardId: 333, itemId: 1 } }), res) - expect(sendStatus).toHaveBeenCalledWith(200) - expect(mocks.escaneoOportunidades).not.toHaveBeenCalled() - }) - - it('update_column_value en Leads dispara el mover a grupo según estado', () => { - const { res, sendStatus } = resFake() - webhookMonday(reqCon({ event: { type: 'update_column_value', boardId: 111, itemId: 999 } }), res) - expect(sendStatus).toHaveBeenCalledWith(200) - expect(mocks.moverAGrupo).toHaveBeenCalledWith(expect.anything(), '111', '999') - }) - - it('update_column_value en Oportunidades dispara escaneo Y mover a grupo', () => { - const { res } = resFake() - webhookMonday(reqCon({ event: { type: 'update_column_value', boardId: 222, itemId: 5 } }), res) - expect(mocks.escaneoOportunidades).toHaveBeenCalledOnce() - expect(mocks.moverAGrupo).toHaveBeenCalledWith(expect.anything(), '222', '5') - }) - - it('update_column_value en un board sin mapeo no dispara el mover a grupo', () => { - const { res, sendStatus } = resFake() - webhookMonday(reqCon({ event: { type: 'update_column_value', boardId: 333, itemId: 1 } }), res) - expect(sendStatus).toHaveBeenCalledWith(200) - expect(mocks.moverAGrupo).not.toHaveBeenCalled() - }) - - it('un error del servicio no rompe la respuesta 200 (fire-and-forget)', async () => { - mocks.runRoundRobin.mockRejectedValue(new Error('monday caído')) - const { res, sendStatus } = resFake() - webhookMonday(reqCon({ event: { type: 'create_item', boardId: 222, itemId: 1 } }), res) - expect(sendStatus).toHaveBeenCalledWith(200) - // Deja que el rechazo se procese para no ensuciar otros tests - await new Promise((r) => setImmediate(r)) - }) -}) diff --git a/api-gateway/src/controllers/webhooks.controller.ts b/api-gateway/src/controllers/webhooks.controller.ts deleted file mode 100644 index 4189945..0000000 --- a/api-gateway/src/controllers/webhooks.controller.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { Request, Response } from 'express' -import { z } from 'zod' -import { config } from '../config/index.js' -import { automatizacionesService } from '../services/automatizaciones.service.js' -import { GRUPOS_POR_ESTADO, moverAGrupoSegunEstado } from '../services/grupos-estado.service.js' -import { mondayService } from '../services/monday.service.js' - -const eventoSchema = z - .object({ - type: z.string(), - boardId: z.union([z.string(), z.number()]), - // Monday manda pulseId en los eventos reales (create_pulse) y itemId en - // las pruebas del panel de webhooks; se aceptan ambos. - itemId: z.union([z.string(), z.number()]).optional(), - pulseId: z.union([z.string(), z.number()]).optional(), - }) - .passthrough() - -const payloadSchema = z.object({ event: eventoSchema }).passthrough() - -/** - * POST /api/webhooks/monday - * Handshake: Monday envía { challenge } al registrar el webhook y exige que - * se responda tal cual. Eventos reales: se responde 200 de inmediato y se - * procesan en segundo plano. - * - * OJO: Monday NO firma sus webhooks → el payload no es confiable. Por eso el - * evento solo se usa como DISPARADOR: el round-robin re-consulta el tablero - * vía API antes de escribir nada. - */ -export function webhookMonday(req: Request, res: Response): void { - const body = req.body as Record - - if (typeof body?.['challenge'] === 'string') { - res.json({ challenge: body['challenge'] }) - return - } - - const parsed = payloadSchema.safeParse(body) - if (!parsed.success) { - // eslint-disable-next-line no-console - console.log('[webhook monday] payload sin estructura de evento reconocida; se ignora') - res.sendStatus(200) - return - } - - const { type, boardId, itemId, pulseId } = parsed.data.event - const esBoardLeads = String(boardId) === config.monday.boards.leads - const esBoardOportunidades = String(boardId) === config.monday.boards.oportunidades - const idEvento = itemId ?? pulseId - - // eslint-disable-next-line no-console - console.log(`[webhook monday] tipo=${type} board=${boardId} item=${idEvento ?? '?'}`) - - // Monday registra el webhook como 'create_item' pero el payload real llega - // como 'create_pulse'; se aceptan ambos. - // Item nuevo en Leads u Oportunidades → solo round-robin del tablero - // correspondiente (los leads NO crean oportunidad; el Alta Afluencia entra - // directo a Oportunidades vía POST /api/afluencias). El grupo NO se arregla - // aquí: recién creado el item aún no tiene Estado (moverAGrupoSegunEstado no - // haría nada) y el create con column_values no dispara el webhook de change — - // para items creados a mano en la UI, el grupo lo corrige la próxima vez que - // cambie una columna. - if ((type === 'create_item' || type === 'create_pulse') && (esBoardLeads || esBoardOportunidades) && idEvento !== undefined) { - const id = String(idEvento) - const board = esBoardLeads ? 'leads' : 'oportunidades' - // Fire-and-forget: la respuesta 200 no espera a que termine la asignación - void automatizacionesService - .runRoundRobin(id, board) - .then((r) => { - // eslint-disable-next-line no-console - console.log(`[webhook monday] round-robin ${board} item=${id}: ${r.asignados} asignado(s)`) - }) - .catch((err: unknown) => { - // eslint-disable-next-line no-console - console.error(`[webhook monday] error en round-robin ${board} item=${id}:`, err) - }) - } - - // Cambio de etapa en Oportunidades → crear Apartado/Venta si aplica. - // El payload de Monday no es confiable (no va firmado), así que solo se usa - // como disparador: el escaneo re-consulta el tablero y es idempotente (dedup - // en AutomatizacionState). El cron de polling queda como red de seguridad. - // OJO: el webhook se registra como 'change_column_value' pero el payload - // real llega como 'update_column_value'; se aceptan los tres nombres. - const esEventoColumna = - type === 'change_status_column_value' || type === 'change_column_value' || type === 'update_column_value' - if (esEventoColumna && esBoardOportunidades) { - void automatizacionesService - .escaneoOportunidades() - .then((r) => { - // eslint-disable-next-line no-console - console.log( - `[webhook monday] oportunidades: ${r.apartadosCreados.length} apartado(s), ` + - `${r.ventasCreadas.length} venta(s), ${r.errores.length} error(es)`, - ) - }) - .catch((err: unknown) => { - // eslint-disable-next-line no-console - console.error('[webhook monday] error en escaneo de oportunidades:', err) - }) - } - - // Cambio de status en cualquier tablero con mapeo estado → grupo: el item se - // mueve al grupo que corresponde a su nuevo estado (regla de negocio: los - // grupos reflejan la etapa del pipeline). Mismo criterio que arriba: el - // payload solo dispara y el servicio re-consulta el item antes de mover. - // En Oportunidades convive con el escaneo (ambos disparan). - if (esEventoColumna && idEvento !== undefined && GRUPOS_POR_ESTADO[String(boardId)]) { - const board = String(boardId) - const id = String(idEvento) - void moverAGrupoSegunEstado(mondayService, board, id) - .then((r) => { - // eslint-disable-next-line no-console - console.log(`[webhook monday] grupo-estado board=${board} item=${id}: ${r.movido ? `movido a ${r.grupo}` : 'sin cambio'}`) - }) - .catch((err: unknown) => { - // eslint-disable-next-line no-console - console.error(`[webhook monday] error en grupo-estado board=${board} item=${id}:`, err) - }) - } - - res.sendStatus(200) -} diff --git a/api-gateway/src/index.ts b/api-gateway/src/index.ts index 8d2a579..b6378ed 100644 --- a/api-gateway/src/index.ts +++ b/api-gateway/src/index.ts @@ -3,137 +3,51 @@ import { crearApp } from './app.js' import { config } from './config/index.js' import { automatizacionesService } from './services/automatizaciones.service.js' import * as configService from './services/config.service.js' -import { mondayService } from './services/monday.service.js' import { recordatoriosCobranzaService } from './services/recordatorios-cobranza.service.js' -import { saludMonday } from './services/salud-monday.service.js' -import { syncMondayService } from './services/sync-monday.service.js' -// Registra los handlers de cada tipo de SyncPendiente sobre el worker (efecto lateral) -import './services/sync-handlers.js' const app = crearApp() -// Wiring del circuit breaker: el ping vive en monday.service; se inyecta aquí -// para que salud-monday.service no lo importe (ciclo de imports). -saludMonday.registrarPing(() => mondayService.ping()) - -// Sondeo de salud de Monday (cada 60 s): tras FALLOS_PARA_CAER fallos GraphQL -// seguidos el flag cae y la app opera en modo local; cuando el ping vuelve a -// responder se recupera el flag y se drena la cola de sincronización. -// Corre siempre (aunque MONDAY_AUTOMATIONS_ENABLED=false): es la infra del modo local. -cron.schedule('* * * * *', () => { - void (async () => { - if (process.env.MODO_LOCAL_FORZADO === 'true') return // modo local forzado: no sondear - if (saludMonday.estaDisponible()) return // Monday sano: nada que hacer - const recuperado = await saludMonday.sondear() - if (recuperado && saludMonday.estaDisponible()) { - // eslint-disable-next-line no-console - console.log('[salud] Monday recuperado; drenando cola de sincronización') - void syncMondayService.procesarPendientes() // fire-and-forget (mutex interno) - } - })().catch((err: unknown) => console.error('[cron salud] error en sondeo:', err)) -}) - -// Sync worker del modo local (cada 2 min): drena SyncPendiente FIFO hacia -// Monday. No-op mientras Monday esté caído. Secuencial por diseño (SQLite). -cron.schedule('*/2 * * * *', () => { - syncMondayService - .procesarPendientes() - .catch((err: unknown) => console.error('[cron sync] error en la pasada:', err)) -}) - -// Crons de respaldo: los webhooks de Monday (registrados sobre -// https://frasa.consultoria-as.com/api/webhooks/monday) disparan estas mismas -// reglas en tiempo real; estos barridos quedan como red de seguridad por si un -// evento se pierde. Se desactivan con MONDAY_AUTOMATIONS_ENABLED=false. +// Crons de automatizaciones sobre tablas locales (round-robin, escalado, +// inactividad). Cada regla tiene su propio catch: un fallo no rompe las demás. +// SECUENCIAL a propósito: SQLite serializa las escrituras y lanzar los escaneos +// en paralelo provoca timeouts P1008 en AutomatizacionState. if (config.automatizaciones.enabled) { - // Polling: asigna vendedor (round-robin) a leads nuevos sin persona asignada, - // escala leads sin contactar y crea Apartados/Ventas al moverse Oportunidades. - // Cada regla tiene su propio catch: un fallo de Monday no rompe las demás. - // SECUENCIAL a propósito: SQLite serializa las escrituras y lanzar los tres - // escaneos en paralelo provoca timeouts P1008 en AutomatizacionState. cron.schedule(config.automatizaciones.pollCron, () => { void (async () => { - // Modo local: Monday caído → ejecutar versiones locales de las automatizaciones - if (!saludMonday.estaDisponible()) { - try { - const r = await automatizacionesService.runRoundRobinLocal() - if (r.asignados > 0) { - // eslint-disable-next-line no-console - console.log(`[cron poll-local] round-robin local: ${r.asignados} lead(s) asignado(s)`) - } - } catch (err: unknown) { - console.error('[cron poll-local] error en round-robin local:', err) - } - try { - const r = await automatizacionesService.escaneoEscaladoLocal() - if (r.escalados > 0) { - // eslint-disable-next-line no-console - console.log(`[cron poll-local] escalado local: ${r.escalados} lead(s) escalado(s)`) - } - } catch (err: unknown) { - console.error('[cron poll-local] error en escalado local:', err) - } - return - } try { - const r = await automatizacionesService.runRoundRobin() + const r = await automatizacionesService.runRoundRobinLocal() if (r.asignados > 0) { // eslint-disable-next-line no-console - console.log(`[cron poll] round-robin: ${r.asignados} lead(s) asignado(s)`) + console.log(`[cron poll-local] round-robin local: ${r.asignados} lead(s) asignado(s)`) } } catch (err: unknown) { - console.error('[cron poll] error en round-robin:', err) + console.error('[cron poll-local] error en round-robin local:', err) } - try { - const r = await automatizacionesService.escaneoEscalado() + const r = await automatizacionesService.escaneoEscaladoLocal() if (r.escalados > 0) { // eslint-disable-next-line no-console - console.log(`[cron poll] escalado: ${r.escalados} lead(s) escalado(s)`) + console.log(`[cron poll-local] escalado local: ${r.escalados} lead(s) escalado(s)`) } } catch (err: unknown) { - console.error('[cron poll] error en escalado:', err) - } - - try { - const r = await automatizacionesService.escaneoOportunidades() - if (r.apartadosCreados.length > 0 || r.ventasCreadas.length > 0 || r.errores.length > 0) { - // eslint-disable-next-line no-console - console.log( - `[cron poll] oportunidades: ${r.apartadosCreados.length} apartado(s), ` + - `${r.ventasCreadas.length} venta(s), ${r.errores.length} error(es)`, - ) - } - } catch (err: unknown) { - console.error('[cron poll] error en oportunidades:', err) + console.error('[cron poll-local] error en escalado local:', err) } })() }) - // Escaneo diario de leads inactivos (recordatorio vía update en el item) + // Escaneo diario de leads inactivos (recordatorio como actividad local) cron.schedule(config.automatizaciones.recordatorioCron, () => { - if (!saludMonday.estaDisponible()) { - // Modo local: escaneo de inactividad sobre tablas locales - automatizacionesService - .escaneoInactividadLocal({ preview: false }) - .then((r) => { - // eslint-disable-next-line no-console - console.log(`[cron inactividad-local] ${r.recordatoriosEnviados} recordatorio(s) local(es)`) - }) - .catch((err: unknown) => console.error('[cron inactividad-local] error en escaneo local:', err)) - return - } automatizacionesService - .escaneoInactividad({ preview: false }) + .escaneoInactividadLocal({ preview: false }) .then((r) => { // eslint-disable-next-line no-console - console.log(`[cron inactividad] ${r.recordatoriosEnviados} recordatorio(s) enviado(s)`) + console.log(`[cron inactividad-local] ${r.recordatoriosEnviados} recordatorio(s) local(es)`) }) - .catch((err: unknown) => console.error('[cron inactividad] error en escaneo:', err)) + .catch((err: unknown) => console.error('[cron inactividad-local] error en escaneo local:', err)) }) // eslint-disable-next-line no-console - console.log(`Automatizaciones Monday activas (poll: "${config.automatizaciones.pollCron}", inactividad: "${config.automatizaciones.recordatorioCron}")`) + console.log(`Automatizaciones locales activas (poll: "${config.automatizaciones.pollCron}", inactividad: "${config.automatizaciones.recordatorioCron}")`) // Escaneo diario de recordatorios de cobranza (pagos por vencer / vencidos) cron.schedule(config.recordatoriosCobranza.cron, () => { @@ -153,11 +67,7 @@ if (config.automatizaciones.enabled) { } // Precarga de configuraciones desde DB (modo_local_forzado, etc.) -void configService.refrescarCache().then(() => { - const modoLocal = configService.obtenerConfigSync('modo_local_forzado') === 'true' - saludMonday.setModoLocalDb(modoLocal) - if (modoLocal) console.log('[startup] MODO LOCAL FORZADO activo (DB)') -}).catch((err: unknown) => +void configService.refrescarCache().catch((err: unknown) => console.error('[startup] Error precargando config cache:', err), ) diff --git a/api-gateway/src/middleware/auth.middleware.test.ts b/api-gateway/src/middleware/auth.middleware.test.ts index e8bd9af..b38c017 100644 --- a/api-gateway/src/middleware/auth.middleware.test.ts +++ b/api-gateway/src/middleware/auth.middleware.test.ts @@ -58,11 +58,10 @@ describe('authMiddleware (AUTH_ENABLED=true)', () => { expect(req.usuarioToken?.username).toBe('admin') }) - it('deja pasar las rutas públicas sin token (health, login, webhook monday)', () => { + it('deja pasar las rutas públicas sin token (health, login)', () => { const casos: Partial[] = [ { method: 'GET', path: '/health' }, { method: 'POST', path: '/auth/login' }, - { method: 'POST', path: '/webhooks/monday' }, ] for (const c of casos) { vi.clearAllMocks() @@ -125,12 +124,6 @@ describe('authMiddleware (AUTH_ENABLED=true)', () => { } }) - it('protege POST /webhooks/monday con otro método no aplica: GET /webhooks/monday pide token', () => { - const { res, status } = resFake() - authMiddleware(reqFake({ method: 'GET', path: '/webhooks/monday' }), res, next) - expect(status).toHaveBeenCalledWith(401) - }) - it('el token interno abre solo POST /cotizaciones/alertas-inventario', () => { authMiddleware( reqFake({ diff --git a/api-gateway/src/middleware/auth.middleware.ts b/api-gateway/src/middleware/auth.middleware.ts index 126f2f9..2cbff2c 100644 --- a/api-gateway/src/middleware/auth.middleware.ts +++ b/api-gateway/src/middleware/auth.middleware.ts @@ -4,12 +4,9 @@ import { verificarToken, type TokenPayload } from '../services/auth.service.js' // Rutas públicas: no requieren token aunque AUTH_ENABLED=true. Se comparan // contra req.path, que aquí ya está relativo al mount /api. -// Monday no puede mandar JWT, así que su webhook queda abierto (se valida por -// firma/challenge dentro del controlador). const RUTAS_PUBLICAS: ReadonlyArray<{ metodo: string; ruta: string }> = [ { metodo: 'GET', ruta: '/health' }, { metodo: 'POST', ruta: '/auth/login' }, - { metodo: 'POST', ruta: '/webhooks/monday' }, { metodo: 'GET', ruta: '/push/vapid-key' }, ] diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts index c76b214..6560002 100644 --- a/api-gateway/src/routes/index.ts +++ b/api-gateway/src/routes/index.ts @@ -39,7 +39,6 @@ import { cancelarPlan, cobranzaHoy, crearPlan, - ligarMondayItem, listarPlanes, obtenerPlan, registrarAbonoEndpoint, @@ -64,8 +63,6 @@ import { } from '../controllers/leads.controller.js' import { actualizarRolEndpoint, - actualizarVinculoMondayEndpoint, - listarUsuariosMondayEndpoint, } from '../controllers/usuarios.controller.js' import { resumenMiDiaEndpoint } from '../controllers/mi-dia.controller.js' import { @@ -74,7 +71,6 @@ import { perfilCompletoEndpoint, exportarContactosEndpoint, } from '../controllers/contactos.controller.js' -import { webhookMonday } from '../controllers/webhooks.controller.js' import { correrRoundRobin, estadoAutomatizaciones, @@ -159,7 +155,7 @@ apiRouter.get('/cotizaciones/:id/publico', obtenerCotizacionPublica) apiRouter.get('/cotizaciones/:id', obtenerCotizacion) apiRouter.post('/clientes', crearCliente) -// Prefill del alta desde un apartado de Monday (botón "Alta Enkontrol" +// Prefill del alta desde un apartado existente (botón "Alta Enkontrol" // de Mis apartados). Va ANTES de /clientes/:id para no colisionar. apiRouter.get('/altas/prefill-apartado/:itemId', prefillApartadoEndpoint) // Alta progresiva: guardado etapa por etapa del wizard (Contacto PROSPECTO → CLIENTE). @@ -195,7 +191,6 @@ apiRouter.get('/planes/:id/timeline', timelinePlan) apiRouter.post('/planes/:id/abonos', registrarAbonoEndpoint) apiRouter.post('/planes/:id/notas', agregarNota) apiRouter.post('/planes/:id/cancelar', cancelarPlan) -apiRouter.patch('/planes/:id/monday', ligarMondayItem) apiRouter.get('/cobranza/recordatorios', listarRecordatorios) apiRouter.post('/cobranza/recordatorios/run', correrRecordatorios) @@ -241,8 +236,6 @@ apiRouter.post('/leads/:itemId/mover', requireAdmin, moverLeadEndpoint) // Alta Afluencia (forma completa de /client): vive en el tablero Oportunidades apiRouter.post('/afluencias', crearAfluenciaEndpoint) -apiRouter.get('/monday/usuarios', listarUsuariosMondayEndpoint) -apiRouter.patch('/usuarios/me/monday', actualizarVinculoMondayEndpoint) apiRouter.patch('/usuarios/:id/rol', actualizarRolEndpoint) apiRouter.get('/reportes/resumen', resumenReportes) @@ -253,11 +246,9 @@ apiRouter.get('/reportes/mi-tablero', miTableroEndpoint) // Métricas de uso (admin/coordinador): leads por usuario, tasa de descarte, actividades apiRouter.get('/reportes/metricas-uso', requireAdminOrCoordinador, metricasUsoEndpoint) -// Estado del modo local (banner del front): salud de Monday + cola de sync +// Estado del modo local (banner del front) apiRouter.get('/sistema/estado', estadoSistema) -apiRouter.post('/webhooks/monday', webhookMonday) - apiRouter.get('/automatizaciones/estado', estadoAutomatizaciones) apiRouter.post('/automatizaciones/round-robin/run', correrRoundRobin) apiRouter.get('/automatizaciones/inactividad/preview', previewInactividad) diff --git a/api-gateway/src/services/actividad-local.service.ts b/api-gateway/src/services/actividad-local.service.ts index 6ffa2ef..8056cc3 100644 --- a/api-gateway/src/services/actividad-local.service.ts +++ b/api-gateway/src/services/actividad-local.service.ts @@ -3,7 +3,7 @@ import { prisma } from './prisma.js' /** * CRUD de actividades locales (tabla ActividadLocal). - * Reemplaza las actividades de Monday en modo local: historial de llamadas, + * Actividades locales: historial de llamadas, * reuniones, tareas pendientes. Mi día las muestra en "Mis actividades hoy". */ diff --git a/api-gateway/src/services/actualizar-disponibilidad.test.ts b/api-gateway/src/services/actualizar-disponibilidad.test.ts deleted file mode 100644 index 68487dd..0000000 --- a/api-gateway/src/services/actualizar-disponibilidad.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// ---------- Prisma mockeado (los tests no tocan la BD) ---------- - -const prismaMock = vi.hoisted(() => ({ - unidad: { - updateMany: vi.fn(), - }, - syncPendiente: { - create: vi.fn(), - }, -})) - -vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) - -import { MondayService } from './monday.service.js' -import { saludMonday } from './salud-monday.service.js' - -/** - * Fallback de actualizarDisponibilidadUnidad (modo local): con Monday caído o - * lanzando 502, el status se actualiza en el espejo local (tabla Unidad) y la - * réplica queda encolada (DISPONIBILIDAD_UNIDAD) para el sync worker. - */ - -const crearServicio = (): MondayService => - new MondayService({ apiKey: 'test', apiUrl: 'https://api.test/v2', baseDelayMs: 1 }) - -const respuesta500 = (): Response => - new Response('Internal Server Error', { status: 500, statusText: 'Internal Server Error' }) - -beforeEach(() => { - vi.clearAllMocks() - vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible') - vi.unstubAllGlobals() - saludMonday.restablecer() - prismaMock.unidad.updateMany.mockResolvedValue({ count: 1 }) - prismaMock.syncPendiente.create.mockResolvedValue({}) -}) - -describe('actualizarDisponibilidadUnidad (fallback local)', () => { - it('Monday caído (circuit breaker): actualiza el espejo local y encola DISPONIBILIDAD_UNIDAD sin fetch', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) - const fetchSpy = vi.fn() - vi.stubGlobal('fetch', fetchSpy) - - await crearServicio().actualizarDisponibilidadUnidad('U-101', 'APARTADA') - - expect(fetchSpy).not.toHaveBeenCalled() - expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({ - where: { codigo: 'U-101' }, - data: { status: 'APARTADA' }, - }) - expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({ - data: { - tipo: 'DISPONIBILIDAD_UNIDAD', - payloadJson: JSON.stringify({ codigo: 'U-101', status: 'APARTADA' }), - referenciaLocal: 'U-101', - }, - }) - }) - - it('Monday lanza 502 a media operación: mismo fallback local', async () => { - vi.stubGlobal('fetch', async () => respuesta500()) - - await crearServicio().actualizarDisponibilidadUnidad('U-102', 'VENDIDA') - - expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({ - where: { codigo: 'U-102' }, - data: { status: 'VENDIDA' }, - }) - expect(prismaMock.syncPendiente.create).toHaveBeenCalledOnce() - }) - - it('unidad inexistente en el espejo local → 404 y no encola', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) - prismaMock.unidad.updateMany.mockResolvedValue({ count: 0 }) - - await expect(crearServicio().actualizarDisponibilidadUnidad('U-X', 'APARTADA')).rejects.toThrow( - 'Unidad U-X no encontrada', - ) - expect(prismaMock.syncPendiente.create).not.toHaveBeenCalled() - }) - - it('un fallo al encolar no rompe la escritura local (best-effort)', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) - prismaMock.syncPendiente.create.mockRejectedValue(new Error('DB llena')) - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - await crearServicio().actualizarDisponibilidadUnidad('U-101', 'APARTADA') - - expect(prismaMock.unidad.updateMany).toHaveBeenCalledOnce() - consoleSpy.mockRestore() - }) -}) diff --git a/api-gateway/src/services/ai.service.test.ts b/api-gateway/src/services/ai.service.test.ts index 44a1a69..6661882 100644 --- a/api-gateway/src/services/ai.service.test.ts +++ b/api-gateway/src/services/ai.service.test.ts @@ -400,7 +400,7 @@ describe('ejecutarToolCall', () => { return { ok: true, args } }, async buscar_unidad(_args: ArgsBuscarUnidad) { - throw new Error('Monday caído') + throw new Error('proveedor caído') }, async buscar_leads(_args: ArgsBuscarLeads) { return [] @@ -430,7 +430,7 @@ describe('ejecutarToolCall', () => { expect(JSON.parse(await ejecutarToolCall({ id: 't', nombre: 'borrar_todo', argumentosJson: '{}' }, ejecutores))) .toEqual({ error: 'Herramienta desconocida: borrar_todo' }) expect(JSON.parse(await ejecutarToolCall({ id: 't', nombre: 'buscar_unidad', argumentosJson: '{}' }, ejecutores))) - .toEqual({ error: 'Monday caído' }) + .toEqual({ error: 'proveedor caído' }) }) }) diff --git a/api-gateway/src/services/ai.service.ts b/api-gateway/src/services/ai.service.ts index b9803b5..0f816a9 100644 --- a/api-gateway/src/services/ai.service.ts +++ b/api-gateway/src/services/ai.service.ts @@ -2,7 +2,7 @@ import crypto from 'node:crypto' import { prisma } from './prisma.js' import { config } from '../config/index.js' import { AppError, NotFoundError } from '../utils/errors.js' -import { mondayService } from './monday.service.js' +import { getUnidadesLocal } from './unidades-local.service.js' import { APARTADO_DEFAULT, calcularPropuesta, @@ -489,11 +489,12 @@ export interface EjecutoresHerramientas { const r2 = (x: number | null): number | null => (x === null ? null : Math.round(x * 100) / 100) /** - * Ejecutores reales: inventario desde Monday (o el buscador inyectado en tests) - * y cálculo con el motor de cotización portado de los Excel. + * Ejecutores reales: inventario desde el espejo local de unidades (o el + * buscador inyectado en tests) y cálculo con el motor de cotización portado de + * los Excel. */ export function crearEjecutoresHerramientas( - buscarUnidades: () => Promise = () => mondayService.getUnidades(), + buscarUnidades: () => Promise = () => getUnidadesLocal(), ): EjecutoresHerramientas { return { async cotizar_unidad(args) { @@ -719,7 +720,7 @@ export function dedupeFuentes(chunks: ChunkRecuperado[]): FuenteCitada[] { export interface OpcionesChat { /** sub del JWT: la conversación nueva queda ligada al usuario (memoria por usuario) */ usuarioId?: string | null - /** Ejecutores de herramientas (default: los reales contra Monday + motor de cotización) */ + /** Ejecutores de herramientas (default: inventario local + motor de cotización) */ ejecutores?: EjecutoresHerramientas } diff --git a/api-gateway/src/services/alertas-inventario.service.test.ts b/api-gateway/src/services/alertas-inventario.service.test.ts index f4b3449..9cd2abd 100644 --- a/api-gateway/src/services/alertas-inventario.service.test.ts +++ b/api-gateway/src/services/alertas-inventario.service.test.ts @@ -8,17 +8,16 @@ import { interface Opciones { cotizaciones?: CotizacionParaAlerta[] - mondayUserId?: number | null yaAlertada?: boolean - fallaMonday?: boolean + fallaActividad?: boolean } -/** Dependencias mockeadas: registra actividades y claves dedup marcadas */ +/** Dependencias mockeadas: registra actividades locales y claves dedup marcadas */ function depsMock(opts: Opciones = {}) { const dedup = new Set() if (opts.yaAlertada) dedup.add('*') // comodín: todo ya alertado const deps: AlertasInventarioDeps & { - actividades: { nombre: string; responsableId: number }[] + actividades: { nombre: string; vendedorId: string }[] consultas: { unidadCodigo: string; desde: Date }[] } = { actividades: [], @@ -27,12 +26,9 @@ function depsMock(opts: Opciones = {}) { this.consultas.push({ unidadCodigo, desde }) return opts.cotizaciones ?? [] }, - async mondayUserIdDeVendedor() { - return opts.mondayUserId === undefined ? 12345 : opts.mondayUserId - }, async crearActividad(datos) { - if (opts.fallaMonday) throw new Error('Monday caído') - this.actividades.push({ nombre: datos.nombre, responsableId: datos.responsableId }) + if (opts.fallaActividad) throw new Error('SQLite bloqueado') + this.actividades.push({ nombre: datos.nombre, vendedorId: datos.vendedorId }) return 'act-1' }, async yaAlertada(clave) { @@ -48,16 +44,16 @@ function depsMock(opts: Opciones = {}) { const AHORA = new Date('2026-08-28T12:00:00') describe('procesarAlertasInventario', () => { - it('crea la actividad al vendedor con mondayUserId y texto de estatus', async () => { + it('crea la actividad local al vendedor con texto de estatus', async () => { const deps = depsMock({ cotizaciones: [{ id: 'cot-1', vendedorId: 'ven-1' }] }) const r = await procesarAlertasInventario( deps, - [{ unidadCodigo: 'U-101', statusNuevo: 'APARTADO' }], + [{ unidadCodigo: 'U-101', statusNuevo: 'APARTADA' }], AHORA, ) expect(r).toEqual({ cambiosProcesados: 1, alertasCreadas: 1, omitidas: 0, fallidas: 0 }) expect(deps.actividades).toEqual([ - { nombre: 'La unidad U-101 que cotizaste pasó a APARTADO', responsableId: 12345 }, + { nombre: 'La unidad U-101 que cotizaste pasó a APARTADA', vendedorId: 'ven-1' }, ]) }) @@ -70,7 +66,7 @@ describe('procesarAlertasInventario', () => { it('dedup por unidad+cotización+estatus: no repite la alerta', async () => { const deps = depsMock({ cotizaciones: [{ id: 'cot-1', vendedorId: 'ven-1' }] }) - const cambio = { unidadCodigo: 'U-101', statusNuevo: 'APARTADO' } + const cambio = { unidadCodigo: 'U-101', statusNuevo: 'APARTADA' } await procesarAlertasInventario(deps, [cambio], AHORA) const r = await procesarAlertasInventario(deps, [cambio], AHORA) expect(r.alertasCreadas).toBe(0) @@ -78,35 +74,23 @@ describe('procesarAlertasInventario', () => { expect(deps.actividades).toHaveLength(1) }) - it('omite cotizaciones sin vendedor o sin mapeo Monday', async () => { + it('omite cotizaciones sin vendedor', async () => { const sinVendedor = depsMock({ cotizaciones: [{ id: 'cot-1', vendedorId: null }] }) const r1 = await procesarAlertasInventario( sinVendedor, - [{ unidadCodigo: 'U-101', statusNuevo: 'APARTADO' }], + [{ unidadCodigo: 'U-101', statusNuevo: 'APARTADA' }], AHORA, ) expect(r1.omitidas).toBe(1) expect(sinVendedor.actividades).toHaveLength(0) - - const sinMapeo = depsMock({ - cotizaciones: [{ id: 'cot-2', vendedorId: 'ven-2' }], - mondayUserId: null, - }) - const r2 = await procesarAlertasInventario( - sinMapeo, - [{ unidadCodigo: 'U-101', statusNuevo: 'APARTADO' }], - AHORA, - ) - expect(r2.omitidas).toBe(1) - expect(sinMapeo.actividades).toHaveLength(0) }) - it('si Monday falla no marca dedup (reintentable) y cuenta fallidas', async () => { + it('si la creación falla no marca dedup (reintentable) y cuenta fallidas', async () => { const deps = depsMock({ cotizaciones: [{ id: 'cot-1', vendedorId: 'ven-1' }], - fallaMonday: true, + fallaActividad: true, }) - const cambio = { unidadCodigo: 'U-101', statusNuevo: 'APARTADO' } + const cambio = { unidadCodigo: 'U-101', statusNuevo: 'APARTADA' } const r1 = await procesarAlertasInventario(deps, [cambio], AHORA) expect(r1.fallidas).toBe(1) // Segundo intento vuelve a intentarlo (no quedó deduplicada) @@ -124,7 +108,7 @@ describe('procesarAlertasInventario', () => { const r = await procesarAlertasInventario( deps, [ - { unidadCodigo: 'U-101', statusNuevo: 'APARTADO' }, + { unidadCodigo: 'U-101', statusNuevo: 'APARTADA' }, { unidadCodigo: 'U-102', statusNuevo: 'VENDIDA' }, ], AHORA, @@ -137,7 +121,7 @@ describe('procesarAlertasInventario', () => { const deps = depsMock({ cotizaciones: [] }) const r = await procesarAlertasInventario( deps, - [{ unidadCodigo: 'U-999', statusNuevo: 'APARTADO' }], + [{ unidadCodigo: 'U-999', statusNuevo: 'APARTADA' }], AHORA, ) expect(r).toEqual({ cambiosProcesados: 1, alertasCreadas: 0, omitidas: 0, fallidas: 0 }) diff --git a/api-gateway/src/services/alertas-inventario.service.ts b/api-gateway/src/services/alertas-inventario.service.ts index c7f05bb..21a0544 100644 --- a/api-gateway/src/services/alertas-inventario.service.ts +++ b/api-gateway/src/services/alertas-inventario.service.ts @@ -1,15 +1,15 @@ /** * Alerta de inventario: cuando una unidad cambia de estatus, se busca si hay - * cotizaciones recientes (≤30 días) de esa unidad y se crea una Actividad en - * Monday al vendedor de cada cotización (solo si tiene mondayUserId mapeado). + * cotizaciones recientes (≤30 días) de esa unidad y se crea una Actividad + * local (tabla ActividadLocal) al vendedor de cada cotización. * * Lógica pura con dependencias inyectadas para poder probarla con mocks; el - * controller la cablea con Prisma y MondayService. + * controller la cablea con Prisma y ActividadLocalService. */ export interface CambioEstatusUnidad { unidadCodigo: string - statusNuevo: string // p. ej. APARTADO | VENDIDA | BLOQUEADO + statusNuevo: string // p. ej. APARTADA | VENDIDA | BLOQUEADO } export interface CotizacionParaAlerta { @@ -20,15 +20,10 @@ export interface CotizacionParaAlerta { export interface AlertasInventarioDeps { /** Cotizaciones de la unidad creadas desde `desde` (ventana de 30 días) */ buscarCotizacionesRecientes(unidadCodigo: string, desde: Date): Promise - /** mondayUserId del vendedor; null si no existe o no está mapeado */ - mondayUserIdDeVendedor(vendedorId: string): Promise - /** Crea la Actividad en Monday y devuelve su item id */ + /** Crea la Actividad local al vendedor y devuelve su id */ crearActividad(datos: { nombre: string - tipoLabel: string - responsableId: number - fecha: string // 'YYYY-MM-DD' - estadoLabel: string + vendedorId: string }): Promise /** Dedup persistente por clave (unidad+cotización+estatus) */ yaAlertada(clave: string): Promise @@ -38,8 +33,8 @@ export interface AlertasInventarioDeps { export interface ResultadoAlertas { cambiosProcesados: number alertasCreadas: number - omitidas: number // sin vendedor, sin mapeo Monday o ya alertadas - fallidas: number // Monday falló; NO quedan deduplicadas (reintentables) + omitidas: number // sin vendedor o ya alertadas + fallidas: number // la creación falló; NO quedan deduplicadas (reintentables) } export const VENTANA_ALERTA_DIAS = 30 @@ -47,9 +42,6 @@ export const VENTANA_ALERTA_DIAS = 30 const claveDedup = (cambio: CambioEstatusUnidad, cotizacionId: string): string => `alerta-inv:${cambio.unidadCodigo}:${cotizacionId}:${cambio.statusNuevo}` -const hoyISO = (d: Date): string => - `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` - /** Procesa los cambios de estatus y crea las actividades correspondientes */ export async function procesarAlertasInventario( deps: AlertasInventarioDeps, @@ -78,23 +70,15 @@ export async function procesarAlertasInventario( resultado.omitidas += 1 continue } - const mondayUserId = await deps.mondayUserIdDeVendedor(cotizacion.vendedorId) - if (mondayUserId === null) { - resultado.omitidas += 1 - continue - } try { await deps.crearActividad({ nombre: `La unidad ${cambio.unidadCodigo} que cotizaste pasó a ${cambio.statusNuevo}`, - tipoLabel: 'Tarea', - responsableId: mondayUserId, - fecha: hoyISO(ahora), - estadoLabel: 'Abierto', + vendedorId: cotizacion.vendedorId, }) await deps.marcarAlertada(clave) resultado.alertasCreadas += 1 } catch { - // Monday falló: no se marca dedup para que el siguiente sync reintente + // La creación falló: no se marca dedup para que el siguiente sync reintente resultado.fallidas += 1 } } diff --git a/api-gateway/src/services/auth.service.ts b/api-gateway/src/services/auth.service.ts index 603ea4b..4d4bff6 100644 --- a/api-gateway/src/services/auth.service.ts +++ b/api-gateway/src/services/auth.service.ts @@ -11,7 +11,7 @@ export interface UsuarioPublico { nombre: string rol: string asignacion: string | null - // User ID de Monday vinculado (null = sin vincular; ver PATCH /usuarios/me/monday) + // User ID del CRM anterior vinculado (null = sin vincular; se mantiene por compatibilidad de datos) mondayUserId: number | null // Módulos del ecosistema visibles para el rol (matriz de config/roles.ts) modulos: Modulo[] diff --git a/api-gateway/src/services/automatizaciones-local.test.ts b/api-gateway/src/services/automatizaciones-local.test.ts index df2f713..998d717 100644 --- a/api-gateway/src/services/automatizaciones-local.test.ts +++ b/api-gateway/src/services/automatizaciones-local.test.ts @@ -10,6 +10,11 @@ vi.mock('./prisma.js', () => ({ }, })) +vi.mock('./config.service.js', () => ({ + obtenerConfigSync: vi.fn(() => 'true'), // round-robin habilitado en tests + refrescarCache: vi.fn(), +})) + vi.mock('./actividad-local.service.js', () => ({ actividadLocalService: { crearActividad: vi.fn(), @@ -22,28 +27,14 @@ vi.mock('./actividad-local.service.js', () => ({ }, })) -vi.mock('./salud-monday.service.js', () => ({ - saludMonday: { - estaDisponible: vi.fn(() => false), - registrarPing: vi.fn(), - registrarExito: vi.fn(), - registrarFallo: vi.fn(), - ok: vi.fn(), - sondear: vi.fn(), - restablecer: vi.fn(), - }, -})) - import { prisma } from './prisma.js' import { actividadLocalService } from './actividad-local.service.js' -import { saludMonday } from './salud-monday.service.js' import { AutomatizacionesService, type StateStore } from './automatizaciones.service.js' // ── Fixtures ── const prismaMock = vi.mocked(prisma) const actividadMock = vi.mocked(actividadLocalService) -const saludMock = vi.mocked(saludMonday) function leadLocal(id: string, overrides: Record = {}) { return { @@ -97,27 +88,11 @@ function stateEnMemoria(): StateStore & { mapa: Map } { } function crearServicio(state: StateStore) { - // Mock Monday service stub (unused in local path but needed by constructor) - const mondayStub = { - getLeads: async () => [], - getOportunidades: async () => [], - asignarVendedorALead: async () => {}, - asignarAsesorAOportunidad: async () => {}, - crearUpdateEnItem: async () => '', - crearActividad: async () => '', - crearApartado: async () => '', - crearVenta: async () => '', - getUsuariosEquipo: async () => [], - } - return new AutomatizacionesService(mondayStub, state) + return new AutomatizacionesService(state) } // ── Tests ── -beforeEach(() => { - saludMock.estaDisponible.mockReturnValue(false) -}) - afterEach(() => { vi.clearAllMocks() }) @@ -412,38 +387,3 @@ describe('AutomatizacionesService.escaneoEscaladoLocal', () => { spyError.mockRestore() }) }) - -describe('runRoundRobin delega a runRoundRobinLocal cuando Monday no está disponible', () => { - it('si saludMonday.estaDisponible() es false, llama a runRoundRobinLocal', async () => { - saludMock.estaDisponible.mockReturnValue(false) - - const leads = [leadLocal('a1')] - prismaMock.leadCaptura.findMany.mockResolvedValueOnce(leads as never) - prismaMock.asignacionLocal.findMany.mockResolvedValueOnce([] as never) - prismaMock.usuario.findMany.mockResolvedValueOnce([usuarioLocal('u1', 'Ana')] as never) - prismaMock.usuario.findFirst.mockResolvedValueOnce(usuarioLocal('u1', 'Ana') as never) - prismaMock.asignacionLocal.create.mockResolvedValueOnce({} as never) - actividadMock.crearActividad.mockResolvedValueOnce({} as never) - prismaMock.leadCaptura.update.mockResolvedValueOnce({} as never) - - const mondayStub = { - getLeads: vi.fn(), - getOportunidades: vi.fn(), - getUsuariosEquipo: vi.fn(), - asignarVendedorALead: vi.fn(), - asignarAsesorAOportunidad: vi.fn(), - crearUpdateEnItem: vi.fn(), - crearActividad: vi.fn(), - crearApartado: vi.fn(), - crearVenta: vi.fn(), - } - - const state = stateEnMemoria() - const svc = new AutomatizacionesService(mondayStub, state) - const r = await svc.runRoundRobin() - - // Monday methods should NOT be called (local path used) - expect(mondayStub.getLeads).not.toHaveBeenCalled() - expect(r.asignados).toBe(1) - }) -}) diff --git a/api-gateway/src/services/automatizaciones.service.test.ts b/api-gateway/src/services/automatizaciones.service.test.ts deleted file mode 100644 index 92d491c..0000000 --- a/api-gateway/src/services/automatizaciones.service.test.ts +++ /dev/null @@ -1,759 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { config } from '../config/index.js' -import { - AutomatizacionesService, - claveApartadoCreado, - claveEscalado, - claveRecordatorio, - claveVentaCreada, - esEstadoTerminal, - esEtapa, - ESTADOS_TERMINALES_LEAD, - oportunidadesInactivas, - oportunidadesSinContactar, - parseRrUserIds, - vendedorEnTurno, - type StateStore, -} from './automatizaciones.service.js' -import type { LeadMonday, OportunidadMonday, UsuarioMonday } from './monday.service.js' - -// ---------- Fixtures ---------- - -const VENDEDORES: UsuarioMonday[] = [ - { id: 101, nombre: 'Ana', email: 'ana@x.com' }, - { id: 102, nombre: 'Beto', email: 'beto@x.com' }, - { id: 103, nombre: 'Carla', email: 'carla@x.com' }, -] - -function lead(id: string, overrides: Partial = {}): LeadMonday { - return { - id, - nombre: `Lead ${id}`, - createdAt: '2026-08-01T09:00:00Z', - updatedAt: '2026-08-01T10:00:00Z', - estado: 'Nuevo', - origen: '', - vendedorIds: [], - telefono: '', - email: '', - ...overrides, - } -} - -function oportunidad(id: string, overrides: Partial = {}): OportunidadMonday { - return { - id, - nombre: `Cliente ${id}`, - createdAt: '2026-08-01T09:00:00Z', - updatedAt: '2026-08-01T10:00:00Z', - etapa: 'Afluencia', - unidadCodigo: 'U-101', - precioFinal: 2500000, - asesorIds: [], - telefono: '', - email: '', - origen: '', - ...overrides, - } -} - -/** StateStore en memoria para tests (sin Prisma) */ -function stateEnMemoria(): StateStore & { mapa: Map } { - const mapa = new Map() - return { - mapa, - async get(clave) { - return mapa.get(clave) ?? null - }, - async set(clave, valor) { - mapa.set(clave, valor) - }, - } -} - -/** Monday mockeado: leads y oportunidades fijos, registra asignaciones, updates y actividades */ -function mondayMock(leads: LeadMonday[] = [], oportunidades: OportunidadMonday[] = []) { - return { - asignaciones: [] as { itemId: string; userId: number; tablero: string }[], - updates: [] as { itemId: string; body: string }[], - actividades: [] as { - nombre: string - tipoLabel: string - responsableId: number - fecha: string - estadoLabel: string - leadItemId: string - }[], - apartados: [] as Record[], - ventas: [] as Record[], - async getLeads() { - return leads - }, - async getOportunidades() { - return oportunidades - }, - async asignarVendedorALead(itemId: string, userId: number) { - this.asignaciones.push({ itemId, userId, tablero: 'leads' }) - }, - async asignarAsesorAOportunidad(itemId: string, userId: number) { - this.asignaciones.push({ itemId, userId, tablero: 'oportunidades' }) - }, - async crearUpdateEnItem(itemId: string, body: string) { - this.updates.push({ itemId, body }) - return 'upd-1' - }, - async crearActividad(datos: { - nombre: string - tipoLabel: string - responsableId: number - fecha: string - estadoLabel: string - leadItemId: string - }) { - this.actividades.push(datos) - return 'act-1' - }, - async crearApartado(datos: Record) { - this.apartados.push(datos) - return `apt-${this.apartados.length}` - }, - async crearVenta(datos: Record) { - this.ventas.push(datos) - return `vta-${this.ventas.length}` - }, - async getUsuariosEquipo(_nombre: string) { - return VENDEDORES - }, - } -} - -const crearServicio = (monday: ReturnType, state: StateStore) => - new AutomatizacionesService(monday, state) - -// ---------- Lógica pura ---------- - -describe('parseRrUserIds', () => { - it('parsea IDs separados por coma ignorando espacios y vacíos', () => { - expect(parseRrUserIds(' 106735975, 106735976 ,, ')).toEqual([ - { id: 106735975, nombre: 'Usuario 106735975', email: '' }, - { id: 106735976, nombre: 'Usuario 106735976', email: '' }, - ]) - expect(parseRrUserIds('')).toEqual([]) - }) - - it('lanza error con IDs inválidos', () => { - expect(() => parseRrUserIds('123,abc')).toThrow(/inválido/) - expect(() => parseRrUserIds('-5')).toThrow(/inválido/) - }) -}) - -describe('esEstadoTerminal (terminales por tablero)', () => { - it('Ganado / Perdido / Abandono son terminales en Oportunidades (default)', () => { - expect(esEstadoTerminal('GANADO')).toBe(true) - expect(esEstadoTerminal(' perdido ')).toBe(true) - expect(esEstadoTerminal('Abandono')).toBe(true) - expect(esEstadoTerminal('Afluencia')).toBe(false) - expect(esEstadoTerminal('Contactado')).toBe(false) - expect(esEstadoTerminal('Intento de contacto')).toBe(false) - expect(esEstadoTerminal('')).toBe(false) - }) - - it('Descartado / Calificado / Abandono son terminales en Leads (title case, case-insensitive)', () => { - expect(esEstadoTerminal('Descartado', ESTADOS_TERMINALES_LEAD)).toBe(true) - expect(esEstadoTerminal('calificado', ESTADOS_TERMINALES_LEAD)).toBe(true) - expect(esEstadoTerminal('ABANDONO', ESTADOS_TERMINALES_LEAD)).toBe(true) - expect(esEstadoTerminal('Nuevo', ESTADOS_TERMINALES_LEAD)).toBe(false) - expect(esEstadoTerminal('Contactado', ESTADOS_TERMINALES_LEAD)).toBe(false) - }) -}) - -describe('vendedorEnTurno (rotación round-robin)', () => { - it('rota circularmente por la lista', () => { - expect(vendedorEnTurno(VENDEDORES, 0)?.id).toBe(101) - expect(vendedorEnTurno(VENDEDORES, 1)?.id).toBe(102) - expect(vendedorEnTurno(VENDEDORES, 2)?.id).toBe(103) - expect(vendedorEnTurno(VENDEDORES, 3)?.id).toBe(101) // wraparound - expect(vendedorEnTurno(VENDEDORES, 7)?.id).toBe(102) - }) - - it('devuelve undefined con lista vacía', () => { - expect(vendedorEnTurno([], 0)).toBeUndefined() - }) -}) - -describe('oportunidadesInactivas', () => { - const ahora = new Date('2026-08-22T00:00:00Z') - - it('incluye oportunidades en etapa temprana CON asesor que superan el umbral de días', () => { - const ops = [ - oportunidad('1', { updatedAt: '2026-08-10T00:00:00Z', asesorIds: [101] }), // 12 días → inactiva (umbral 10) - oportunidad('2', { updatedAt: '2026-08-20T00:00:00Z', asesorIds: [101] }), // 2 días → activa - oportunidad('3', { updatedAt: '2026-07-01T00:00:00Z', asesorIds: [101], etapa: 'Perdido' }), // terminal → excluida - oportunidad('4', { updatedAt: '2026-07-01T00:00:00Z' }), // sin asesor → excluida - oportunidad('5', { updatedAt: '2026-07-01T00:00:00Z', asesorIds: [101], etapa: 'Cotizador' }), // etapa avanzada → excluida - oportunidad('6', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [101], etapa: 'Contactado' }), // 21 días → inactiva - ] - const inactivas = oportunidadesInactivas(ops, ahora, 10) - expect(inactivas.map((o) => o.id)).toEqual(['1', '6']) - }) - - it('una oportunidad actualizada hace exactamente N días aún no supera el umbral', () => { - const ops = [oportunidad('1', { updatedAt: '2026-08-12T00:00:00Z', asesorIds: [101] })] // justo 10 días - expect(oportunidadesInactivas(ops, ahora, 10)).toEqual([]) - }) - - it('ignora fechas inválidas', () => { - expect(oportunidadesInactivas([oportunidad('1', { updatedAt: 'no-es-fecha', asesorIds: [101] })], ahora, 10)).toEqual([]) - }) -}) - -describe('claveRecordatorio (dedup por día)', () => { - it('una clave por oportunidad por día', () => { - expect(claveRecordatorio('123', new Date('2026-08-22T09:00:00Z'))).toBe('recordatorio:123:2026-08-22') - expect(claveRecordatorio('123', new Date('2026-08-22T23:59:00Z'))).toBe('recordatorio:123:2026-08-22') - expect(claveRecordatorio('123', new Date('2026-08-23T00:00:00Z'))).toBe('recordatorio:123:2026-08-23') - }) -}) - -// ---------- Servicio (monday mockeado, sin red) ---------- - -describe('AutomatizacionesService.runRoundRobin', () => { - it('asigna en rotación sobre AMBOS tableros y persiste el índice (Leads → Vendedor, Oportunidades → Asesor)', async () => { - const monday = mondayMock([lead('l1'), lead('l2')], [oportunidad('o1'), oportunidad('o2')]) - const state = stateEnMemoria() - const svc = crearServicio(monday, state) - - const r1 = await svc.runRoundRobin() - expect(r1.asignados).toBe(4) - expect(monday.asignaciones).toEqual([ - { itemId: 'l1', userId: 101, tablero: 'leads' }, - { itemId: 'l2', userId: 102, tablero: 'leads' }, - { itemId: 'o1', userId: 103, tablero: 'oportunidades' }, - { itemId: 'o2', userId: 101, tablero: 'oportunidades' }, - ]) - expect(r1.detalle.map((d) => d.tablero)).toEqual(['leads', 'leads', 'oportunidades', 'oportunidades']) - expect(state.mapa.get('rr:indice')).toBe('1') // 4 asignaciones % 3 vendedores - - // Segunda pasada con una afluencia nueva: continúa donde quedó (no reinicia en 0) - const monday2 = mondayMock([], [oportunidad('o5')]) - const svc2 = new AutomatizacionesService(monday2, state) - const r2 = await svc2.runRoundRobin() - expect(r2.asignados).toBe(1) - expect(monday2.asignaciones[0]?.userId).toBe(102) - expect(state.mapa.get('rr:indice')).toBe('2') - }) - - it('Leads: reparte los sin Vendedor y no terminales (labels title case, case-insensitive)', async () => { - const monday = mondayMock([ - lead('1'), - lead('2', { vendedorIds: [101] }), // ya asignado - lead('3', { estado: 'Descartado' }), // terminal - lead('4', { estado: 'Calificado' }), // terminal - lead('5', { estado: 'Contactado' }), // en seguimiento: sí entra - ]) - const svc = crearServicio(monday, stateEnMemoria()) - const r = await svc.runRoundRobin(undefined, 'leads') - expect(r.asignados).toBe(2) - expect(monday.asignaciones).toEqual([ - { itemId: '1', userId: 101, tablero: 'leads' }, - { itemId: '5', userId: 102, tablero: 'leads' }, - ]) - }) - - it('Oportunidades: solo reparte etapa Afluencia (Cotizador/Contactado NO entran)', async () => { - const monday = mondayMock([], [ - oportunidad('1'), - oportunidad('2', { asesorIds: [101] }), // ya asignada - oportunidad('3', { etapa: 'Cotizador' }), // creada por el cotizador: no entra - oportunidad('4', { etapa: 'Contactado' }), // ya en seguimiento: no entra - oportunidad('5', { etapa: 'Perdido' }), // terminal: no entra - oportunidad('6', { etapa: 'afluencia' }), // case-insensitive: sí entra - ]) - const svc = crearServicio(monday, stateEnMemoria()) - const r = await svc.runRoundRobin(undefined, 'oportunidades') - expect(r.asignados).toBe(2) - expect(monday.asignaciones).toEqual([ - { itemId: '1', userId: 101, tablero: 'oportunidades' }, - { itemId: '6', userId: 102, tablero: 'oportunidades' }, - ]) - }) - - it('omite items con bandera rr:skip=capturista (los asigna su capturista)', async () => { - const monday = mondayMock([lead('1')], [oportunidad('2')]) - const state = stateEnMemoria() - state.mapa.set('rr:skip:1', 'capturista') // el capturista lo asigna - state.mapa.set('rr:skip:2', 'round-robin') // bandera liberada: sí entra al reparto - const svc = crearServicio(monday, state) - const r = await svc.runRoundRobin() - expect(r.asignados).toBe(1) - expect(monday.asignaciones).toEqual([{ itemId: '2', userId: 101, tablero: 'oportunidades' }]) - }) - - it('con soloItemId + board solo evalúa ese item (ruta de los webhooks create_item)', async () => { - const monday = mondayMock([lead('1'), lead('2')], [oportunidad('9')]) - const svc = crearServicio(monday, stateEnMemoria()) - const r = await svc.runRoundRobin('2', 'leads') - expect(r.asignados).toBe(1) - expect(monday.asignaciones).toEqual([{ itemId: '2', userId: 101, tablero: 'leads' }]) - }) - - it('con soloItemId de otro tablero no asigna nada', async () => { - const monday = mondayMock([lead('1')], [oportunidad('9')]) - const svc = crearServicio(monday, stateEnMemoria()) - const r = await svc.runRoundRobin('9', 'leads') // '9' no está en Leads - expect(r.asignados).toBe(0) - expect(monday.asignaciones).toEqual([]) - }) - - it('cachea los vendedores del equipo en el estado (una sola llamada dentro del TTL)', async () => { - const monday = mondayMock() - const spy = vi.spyOn(monday, 'getUsuariosEquipo') - const state = stateEnMemoria() - const svc = crearServicio(monday, state) - const v1 = await svc.obtenerVendedores() - const v2 = await svc.obtenerVendedores() - expect(v1.map((v) => v.id)).toEqual([101, 102, 103]) - expect(v2).toEqual(v1) - expect(spy).toHaveBeenCalledTimes(1) - expect(spy.mock.calls[0]?.[0]).toBe('vendedores') // equipo por default - }) - - it('cache con formato viejo (arreglo sin ts) se descarta y re-consulta', async () => { - const monday = mondayMock() - const spy = vi.spyOn(monday, 'getUsuariosEquipo') - const state = stateEnMemoria() - state.mapa.set('rr:vendedores', JSON.stringify([{ id: 999, nombre: 'Viejo', email: '' }])) - const svc = crearServicio(monday, state) - const v = await svc.obtenerVendedores() - expect(v.map((x) => x.id)).toEqual([101, 102, 103]) - expect(spy).toHaveBeenCalledTimes(1) - }) -}) - -describe('AutomatizacionesService.escaneoInactividad', () => { - const ahora = new Date('2026-08-22T09:00:00Z') - const oportunidadesViejas = () => [ - oportunidad('1', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [101] }), // 21 días inactiva - oportunidad('2', { updatedAt: '2026-08-20T00:00:00Z', asesorIds: [101] }), // activa - ] - - it('preview: lista candidatos sin escribir en Monday ni en el estado', async () => { - const monday = mondayMock([], oportunidadesViejas()) - const state = stateEnMemoria() - const svc = crearServicio(monday, state) - - const r = await svc.escaneoInactividad({ preview: true, ahora }) - expect(r.preview).toBe(true) - expect(r.candidatos.map((c) => c.itemId)).toEqual(['1']) - expect(r.candidatos[0]?.diasInactivo).toBe(21) - expect(r.recordatoriosEnviados).toBe(0) - expect(monday.updates).toEqual([]) - expect(state.mapa.size).toBe(0) // preview no persiste nada - }) - - it('ejecución real: crea update, marca dedup y no repite el mismo día', async () => { - const monday = mondayMock([], oportunidadesViejas()) - const state = stateEnMemoria() - const svc = crearServicio(monday, state) - - const r1 = await svc.escaneoInactividad({ preview: false, ahora }) - expect(r1.recordatoriosEnviados).toBe(1) - expect(monday.updates).toHaveLength(1) - expect(monday.updates[0]?.body).toContain('21 días') - expect(state.mapa.get('recordatorio:1:2026-08-22')).toBeDefined() - - // Mismo día: no repite aunque la oportunidad siga inactiva - const r2 = await svc.escaneoInactividad({ preview: false, ahora: new Date('2026-08-22T18:00:00Z') }) - expect(r2.recordatoriosEnviados).toBe(0) - expect(r2.candidatos[0]?.yaRecordadoHoy).toBe(true) - expect(monday.updates).toHaveLength(1) - - // Al día siguiente vuelve a recordar - const r3 = await svc.escaneoInactividad({ preview: false, ahora: new Date('2026-08-23T09:00:00Z') }) - expect(r3.recordatoriosEnviados).toBe(1) - expect(monday.updates).toHaveLength(2) - }) -}) - -// ---------- Actividades automáticas (tablero Actividades del CRM) ---------- - -describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => { - const ahora = new Date('2026-08-22T09:00:00Z') - - it('el round-robin crea "Contactar a {nombre}" tipo Llamada, Abierto, ligada a la oportunidad', async () => { - const monday = mondayMock([], [oportunidad('1'), oportunidad('2')]) - const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true }) - - const r = await svc.runRoundRobin() - expect(r.asignados).toBe(2) - expect(monday.actividades).toHaveLength(2) - expect(monday.actividades[0]).toMatchObject({ - nombre: 'Contactar a Cliente 1', - tipoLabel: 'Llamada', - responsableId: 101, - estadoLabel: 'Abierto', - leadItemId: '1', - }) - // Fecha compromiso: hoy + 1 día (fecha local, como la construye el servicio) - const esperada = new Date() - esperada.setDate(esperada.getDate() + 1) - const m = String(esperada.getMonth() + 1).padStart(2, '0') - const d = String(esperada.getDate()).padStart(2, '0') - expect(monday.actividades[0]?.fecha).toBe(`${esperada.getFullYear()}-${m}-${d}`) - expect(monday.actividades[1]?.responsableId).toBe(102) - }) - - it('con actividadesEnabled=false el round-robin no crea actividades', async () => { - const monday = mondayMock([], [oportunidad('1')]) - const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: false }) - await svc.runRoundRobin() - expect(monday.actividades).toEqual([]) - }) - - it('asignarACapturista: asigna al capturista (columna Asesor) y le crea su actividad de contacto', async () => { - const monday = mondayMock([]) - const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true }) - - await svc.asignarACapturista('op-77', 'Lead Propio', 555) - - expect(monday.asignaciones).toEqual([{ itemId: 'op-77', userId: 555, tablero: 'oportunidades' }]) - expect(monday.actividades[0]).toMatchObject({ - nombre: 'Contactar a Lead Propio', - responsableId: 555, - estadoLabel: 'Abierto', - leadItemId: 'op-77', - }) - }) - - it('asignarACapturista con board "leads" escribe la columna Vendedor del tablero Leads', async () => { - const monday = mondayMock([]) - const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true }) - - await svc.asignarACapturista('lead-77', 'Lead Propio', 555, 'leads') - - expect(monday.asignaciones).toEqual([{ itemId: 'lead-77', userId: 555, tablero: 'leads' }]) - expect(monday.actividades[0]).toMatchObject({ nombre: 'Contactar a Lead Propio', leadItemId: 'lead-77' }) - }) - - it('asignarACapturista con actividadesEnabled=false solo asigna', async () => { - const monday = mondayMock([]) - const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: false }) - - await svc.asignarACapturista('op-78', 'Lead Propio', 555) - - expect(monday.asignaciones).toEqual([{ itemId: 'op-78', userId: 555, tablero: 'oportunidades' }]) - expect(monday.actividades).toEqual([]) - }) - - it('inactividad: crea "Retomar contacto" al asesor asignado; las candidatas siempre tienen asesor', async () => { - const monday = mondayMock([], [oportunidad('1', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [103] })]) - const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true }) - - const r = await svc.escaneoInactividad({ preview: false, ahora }) - expect(r.recordatoriosEnviados).toBe(1) - expect(monday.actividades).toHaveLength(1) - expect(monday.actividades[0]).toMatchObject({ - nombre: 'Retomar contacto con Cliente 1', - tipoLabel: 'Llamada', - responsableId: 103, - estadoLabel: 'Abierto', - leadItemId: '1', - }) - }) - - it('preview de inactividad no crea actividades', async () => { - const monday = mondayMock([], [oportunidad('1', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [101] })]) - const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true }) - await svc.escaneoInactividad({ preview: true, ahora }) - expect(monday.actividades).toEqual([]) - }) - - it('un fallo al crear la actividad no rompe la asignación', async () => { - const monday = mondayMock([], [oportunidad('1')]) - vi.spyOn(monday, 'crearActividad').mockRejectedValueOnce(new Error('Monday caído')) - const spyError = vi.spyOn(console, 'error').mockImplementation(() => {}) - const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true }) - - const r = await svc.runRoundRobin() - expect(r.asignados).toBe(1) // la asignación se hizo igual - expect(monday.asignaciones).toEqual([{ itemId: '1', userId: 101, tablero: 'oportunidades' }]) - spyError.mockRestore() - }) -}) - -// ---------- Escalado de afluencias sin contactar ---------- - -describe('oportunidadesSinContactar (regla de escalado por horas)', () => { - const ahora = new Date('2026-08-22T12:00:00Z') - - it('incluye oportunidades asignadas en Afluencia / Intento de contacto que superan el umbral', () => { - const ops = [ - oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-19T12:00:00Z' }), // 72 h → escala - oportunidad('2', { asesorIds: [102], etapa: 'Intento de contacto', updatedAt: '2026-08-20T12:00:00Z' }), // 48 h justo → no - oportunidad('3', { asesorIds: [103], etapa: 'Contactado', updatedAt: '2026-08-01T12:00:00Z' }), // ya contactada - oportunidad('4', { etapa: 'Afluencia', updatedAt: '2026-08-01T12:00:00Z' }), // sin asesor → no aplica - oportunidad('5', { asesorIds: [101], etapa: 'Perdido', updatedAt: '2026-08-01T12:00:00Z' }), // terminal - ] - expect(oportunidadesSinContactar(ops, ahora, 48).map((o) => o.id)).toEqual(['1']) - }) - - it('una oportunidad con exactamente N horas aún no supera el umbral', () => { - const ops = [oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-20T12:00:00Z' })] - expect(oportunidadesSinContactar(ops, ahora, 48)).toEqual([]) - }) - - it('ignora fechas inválidas', () => { - const ops = [oportunidad('1', { asesorIds: [101], updatedAt: 'no-es-fecha' })] - expect(oportunidadesSinContactar(ops, ahora, 48)).toEqual([]) - }) -}) - -describe('claves de dedup', () => { - it('una clave por oportunidad', () => { - expect(claveEscalado('123')).toBe('escalado:123') - expect(claveApartadoCreado('456')).toBe('apartado-creado:456') - expect(claveVentaCreada('456')).toBe('venta-creada:456') - }) -}) - -describe('esEtapa (detección de transición de etapa)', () => { - it('compara tolerando mayúsculas y espacios', () => { - expect(esEtapa('Aparto', 'APARTO')).toBe(true) - expect(esEtapa(' ganado ', 'Ganado')).toBe(true) - expect(esEtapa('Cotizador', 'Aparto')).toBe(false) - expect(esEtapa('', 'Ganado')).toBe(false) - }) -}) - -describe('AutomatizacionesService.escaneoEscalado', () => { - const ahora = new Date('2026-08-22T12:00:00Z') - const configAuto = config.automatizaciones as Record - - it('escala al asesor asignado cuando no hay coordinador configurado', async () => { - const anterior = configAuto['coordinadorUserId'] - configAuto['coordinadorUserId'] = 0 - try { - const monday = mondayMock([], [oportunidad('1', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' })]) - const state = stateEnMemoria() - const svc = new AutomatizacionesService(monday, state, { actividadesEnabled: true }) - - const r = await svc.escaneoEscalado({ ahora }) - expect(r.escalados).toBe(1) - expect(r.candidatos[0]).toMatchObject({ itemId: '1', horasSinContactar: 72, responsableId: 102, yaEscalado: false }) - expect(monday.actividades[0]).toMatchObject({ - nombre: 'URGENTE: oportunidad sin contactar — Cliente 1', - responsableId: 102, - estadoLabel: 'Abierto', - leadItemId: '1', - }) - expect(monday.updates[0]?.body).toContain('72 h') - expect(state.mapa.get('escalado:1')).toBeDefined() - expect(state.mapa.get('contador:escalados')).toBe('1') - } finally { - configAuto['coordinadorUserId'] = anterior - } - }) - - it('escala al coordinador cuando MONDAY_COORDINADOR_USER_ID está configurado', async () => { - const anterior = configAuto['coordinadorUserId'] - configAuto['coordinadorUserId'] = 999 - try { - const monday = mondayMock([], [oportunidad('1', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' })]) - const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true }) - const r = await svc.escaneoEscalado({ ahora }) - expect(r.escalados).toBe(1) - expect(monday.actividades[0]?.responsableId).toBe(999) - expect(r.candidatos[0]?.responsableId).toBe(999) - } finally { - configAuto['coordinadorUserId'] = anterior - } - }) - - it('dedup: no vuelve a escalar una oportunidad ya escalada', async () => { - const anterior = configAuto['coordinadorUserId'] - configAuto['coordinadorUserId'] = 0 - try { - const monday = mondayMock([], [oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-19T12:00:00Z' })]) - const state = stateEnMemoria() - const svc = new AutomatizacionesService(monday, state, { actividadesEnabled: true }) - - const r1 = await svc.escaneoEscalado({ ahora }) - expect(r1.escalados).toBe(1) - const r2 = await svc.escaneoEscalado({ ahora: new Date('2026-08-23T12:00:00Z') }) - expect(r2.escalados).toBe(0) - expect(r2.candidatos[0]?.yaEscalado).toBe(true) - expect(monday.actividades).toHaveLength(1) - expect(state.mapa.get('contador:escalados')).toBe('1') - } finally { - configAuto['coordinadorUserId'] = anterior - } - }) - - it('un fallo de Monday en una oportunidad no detiene el escalado de las demás', async () => { - const anterior = configAuto['coordinadorUserId'] - configAuto['coordinadorUserId'] = 0 - const spyError = vi.spyOn(console, 'error').mockImplementation(() => {}) - try { - const monday = mondayMock([], [ - oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-19T12:00:00Z' }), - oportunidad('2', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' }), - ]) - vi.spyOn(monday, 'crearUpdateEnItem') - .mockRejectedValueOnce(new Error('Monday caído')) - .mockResolvedValue('upd-ok') - const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true }) - - const r = await svc.escaneoEscalado({ ahora }) - expect(r.escalados).toBe(1) // la segunda oportunidad sí se escaló - expect(r.candidatos).toHaveLength(2) - } finally { - configAuto['coordinadorUserId'] = anterior - spyError.mockRestore() - } - }) -}) - -// ---------- Oportunidad → Aparto / Ganado ---------- - -describe('AutomatizacionesService.escaneoOportunidades', () => { - it('una oportunidad en Aparto crea su item en Apartado (estado Pago Apartado)', async () => { - const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' })]) - const state = stateEnMemoria() - const svc = crearServicio(monday, state) - - const r = await svc.escaneoOportunidades() - expect(r.apartadosCreados).toEqual([{ oportunidadId: '10', nombre: 'Cliente 10', apartadoItemId: 'apt-1' }]) - expect(monday.apartados[0]).toMatchObject({ - clienteNombre: 'Cliente 10', - unidadCodigo: 'U-101', - estadoLabel: 'Pago Apartado', - }) - expect(state.mapa.get('apartado-creado:10')).toBe('apt-1') - expect(state.mapa.get('contador:apartadosCreados')).toBe('1') - }) - - it('el Apartado hereda de la oportunidad: contacto, Precio Final y Vendedor (primer asesor)', async () => { - const monday = mondayMock([], [ - oportunidad('10', { etapa: 'Aparto', telefono: '6641112233', email: 'c@c.com', asesorIds: [101, 102] }), - ]) - const state = stateEnMemoria() - const svc = crearServicio(monday, state) - - const r = await svc.escaneoOportunidades() - expect(r.apartadosCreados).toHaveLength(1) - expect(monday.apartados[0]).toMatchObject({ - clienteNombre: 'Cliente 10', - unidadCodigo: 'U-101', - telefono: '6641112233', - email: 'c@c.com', - precioFinal: 2500000, - vendedorId: 101, - }) - }) - - it('una oportunidad en Ganado crea su item en Ventas con unidad y valor', async () => { - const monday = mondayMock([], [oportunidad('11', { etapa: 'GANADO' })]) - const state = stateEnMemoria() - const svc = crearServicio(monday, state) - - const r = await svc.escaneoOportunidades() - expect(r.ventasCreadas).toEqual([{ oportunidadId: '11', nombre: 'Cliente 11', ventaItemId: 'vta-1' }]) - expect(monday.ventas[0]).toMatchObject({ clienteNombre: 'Cliente 11', unidadCodigo: 'U-101', valorVenta: 2500000 }) - expect(state.mapa.get('venta-creada:11')).toBe('vta-1') - }) - - it('dedup: la segunda pasada no duplica apartados ni ventas', async () => { - const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' }), oportunidad('11', { etapa: 'Ganado' })]) - const state = stateEnMemoria() - const svc = crearServicio(monday, state) - - await svc.escaneoOportunidades() - const r2 = await svc.escaneoOportunidades() - expect(r2.apartadosCreados).toEqual([]) - expect(r2.ventasCreadas).toEqual([]) - expect(monday.apartados).toHaveLength(1) - expect(monday.ventas).toHaveLength(1) - expect(state.mapa.get('contador:apartadosCreados')).toBe('1') - expect(state.mapa.get('contador:ventasCreadas')).toBe('1') - }) - - it('ignora oportunidades en otras etapas', async () => { - const monday = mondayMock([], [ - oportunidad('10', { etapa: 'Cotizador' }), - oportunidad('11', { etapa: 'Perdido' }), - oportunidad('12', { etapa: 'Afluencia' }), - ]) - const svc = crearServicio(monday, stateEnMemoria()) - const r = await svc.escaneoOportunidades() - expect(r.apartadosCreados).toEqual([]) - expect(r.ventasCreadas).toEqual([]) - expect(monday.apartados).toEqual([]) - expect(monday.ventas).toEqual([]) - }) - - it('un fallo de Monday en una oportunidad no detiene a las demás', async () => { - const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' }), oportunidad('11', { etapa: 'Ganado' })]) - vi.spyOn(monday, 'crearApartado').mockRejectedValueOnce(new Error('Monday caído')) - const state = stateEnMemoria() - const svc = crearServicio(monday, state) - - const r = await svc.escaneoOportunidades() - expect(r.errores).toHaveLength(1) - expect(r.errores[0]?.oportunidadId).toBe('10') - expect(r.ventasCreadas).toHaveLength(1) // la segunda oportunidad sí se procesó - expect(state.mapa.get('apartado-creado:10')).toBeUndefined() // sin dedup: se reintenta en la próxima pasada - }) -}) - -describe('AutomatizacionesService.asegurarApartado (botón Aparto de Mi día)', () => { - it('dedup: si ya existe el apartado, lo devuelve sin tocar Monday', async () => { - const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' })]) - const state = stateEnMemoria() - state.mapa.set('apartado-creado:10', 'apt-viejo') - const svc = crearServicio(monday, state) - - const r = await svc.asegurarApartado('10', 3, 0) - expect(r).toBe('apt-viejo') - expect(monday.apartados).toEqual([]) - }) - - it('crea el apartado en la primera pasada si la etapa ya se ve', async () => { - const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' })]) - const svc = crearServicio(monday, stateEnMemoria()) - - const r = await svc.asegurarApartado('10', 3, 0) - expect(r).toBe('apt-1') - expect(monday.apartados).toHaveLength(1) - }) - - it('reintenta cuando Monday aún no propaga la etapa (read-after-write)', async () => { - // La oportunidad se lee en 'Afluencia' en la primera pasada y en 'Aparto' - // en la segunda (así se comporta Monday ~1s después del cambio) - const op = oportunidad('10', { etapa: 'Afluencia' }) - const oportunidades = [op] - const monday = mondayMock([], oportunidades) - const svc = crearServicio(monday, stateEnMemoria()) - - const original = monday.getOportunidades.bind(monday) - let llamadas = 0 - monday.getOportunidades = async () => { - llamadas++ - if (llamadas >= 2) op.etapa = 'Aparto' - return original() - } - - const r = await svc.asegurarApartado('10', 3, 0) - expect(r).toBe('apt-1') - expect(llamadas).toBeGreaterThanOrEqual(2) - expect(monday.apartados).toHaveLength(1) - }) - - it('agota los reintentos y devuelve null si nunca se ve en Aparto', async () => { - const monday = mondayMock([], [oportunidad('10', { etapa: 'Cotizador' })]) - const svc = crearServicio(monday, stateEnMemoria()) - - const r = await svc.asegurarApartado('10', 2, 0) - expect(r).toBeNull() - expect(monday.apartados).toEqual([]) - }) -}) diff --git a/api-gateway/src/services/automatizaciones.service.ts b/api-gateway/src/services/automatizaciones.service.ts index 1a2a00a..eb8d0d9 100644 --- a/api-gateway/src/services/automatizaciones.service.ts +++ b/api-gateway/src/services/automatizaciones.service.ts @@ -2,68 +2,34 @@ import { config } from '../config/index.js' import * as configService from './config.service.js' import { AppError } from '../utils/errors.js' import { actividadLocalService } from './actividad-local.service.js' -import { - mondayService, - type LeadMonday, - type MondayService, - type OportunidadMonday, - type UsuarioMonday, -} from './monday.service.js' import { prisma } from './prisma.js' -import { saludMonday } from './salud-monday.service.js' -import { enviarPush, usuarioIdPorMondayUserId } from './push.service.js' +import { enviarPush } from './push.service.js' /** - * Automatizaciones sobre Monday: - * (a) asignación round-robin en AMBOS tableros del embudo: Leads (captura - * ligera, columna Vendedor, cualquier estado no terminal sin asignar) y - * Oportunidades (Alta Afluencia, columna Asesor, solo etapa 'Afluencia' - * sin asignar — las del cotizador nacen en 'Cotizador' y no entran) + * Automatizaciones sobre tablas locales: + * (a) asignación round-robin de leads locales (board LEADS, etapa NUEVO, sin + * registro en AsignacionLocal) al siguiente vendedor en rotación * (b) recordatorio de oportunidades inactivas (> INACTIVIDAD_DIAS sin actualizarse) - * (c) escalado de afluencias asignadas sin contactar - * (d) Oportunidad → Apartado / Ventas al entrar a 'Aparto' / 'Ganado' + * (c) escalado de afluencias asignadas sin contactar (> LEAD_ESCALADO_HORAS horas) + * (d) push de apartados por vencer * - * El estado (índice RR, cache de vendedores, recordatorios enviados) vive en la - * tabla clave-valor AutomatizacionState para sobrevivir reinicios y no repetir - * recordatorios el mismo día. + * El estado (índice RR, recordatorios enviados) vive en la tabla clave-valor + * AutomatizacionState para sobrevivir reinicios y no repetir recordatorios. */ -// Estados del tablero Leads en los que el lead ya no entra al reparto ni es -// trabajo activo (labels title case en el board; se comparan case-insensitive) -export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'CALIFICADO', 'ABANDONO'] as const - -// Etapas del tablero Oportunidades en las que la oportunidad ya no es trabajo -// activo del asesor (fuera de seguimiento). +// Etapas del embudo en las que la oportunidad ya no es trabajo activo del +// asesor (fuera de seguimiento). export const ETAPAS_TERMINALES_OPORTUNIDAD = ['GANADO', 'PERDIDO', 'ABANDONO'] as const -// Etapas tempranas que vigilan los escaneos de inactividad/escalado -export const ETAPAS_SEGUIMIENTO = ['AFLUENCIA', 'CONTACTADO', 'INTENTO DE CONTACTO'] as const - -// Etapas en las que una oportunidad asignada aún no ha sido contactada de -// verdad; si permanece así más de LEAD_ESCALADO_HORAS horas se escala al -// coordinador. -export const ETAPAS_SIN_CONTACTAR = ['AFLUENCIA', 'INTENTO DE CONTACTO'] as const - -// Etapas del tablero Oportunidades que disparan las reglas de ronda 2 -export const ETAPA_APARTO = 'APARTO' -export const ETAPA_GANADO = 'GANADO' - const CLAVE_RR_INDICE = 'rr:indice' -const CLAVE_RR_VENDEDORES = 'rr:vendedores' const CLAVE_RR_ULTIMA_PASADA = 'rr:ultimaPasada' -/** Prefijo de la bandera "este lead lo asigna su capturista, no el round-robin" */ -export const CLAVE_RR_SKIP = 'rr:skip:' const CLAVE_INACTIVIDAD_ULTIMO_ESCANEO = 'inactividad:ultimoEscaneo' const CLAVE_ESCALADO_ULTIMO_ESCANEO = 'escalado:ultimoEscaneo' -const CLAVE_OPORTUNIDADES_ULTIMO_ESCANEO = 'oportunidades:ultimoEscaneo' const CLAVE_CONTADOR_ESCALADOS = 'contador:escalados' -const CLAVE_CONTADOR_APARTADOS = 'contador:apartadosCreados' -const CLAVE_CONTADOR_VENTAS = 'contador:ventasCreadas' +const CLAVE_APARTADO_CREADO = 'apartado-creado' const DIA_MS = 24 * 60 * 60 * 1000 const HORA_MS = 60 * 60 * 1000 -/** TTL del cache de la lista de vendedores del equipo (1 h) */ -const CACHE_VENDEDORES_MS = HORA_MS /** Store clave-valor inyectable (Prisma en prod, Map en tests) */ export interface StateStore { @@ -71,136 +37,23 @@ export interface StateStore { set(clave: string, valor: string): Promise } -// ---------- Lógica pura (testeable sin red ni DB) ---------- - -/** Parsea MONDAY_RR_USER_IDS ("106735975,106735976") a la lista de vendedores */ -export function parseRrUserIds(raw: string): UsuarioMonday[] { - return raw - .split(',') - .map((s) => s.trim()) - .filter((s) => s !== '') - .map((s) => { - const id = Number(s) - if (!Number.isInteger(id) || id <= 0) { - throw new AppError(500, `MONDAY_RR_USER_IDS contiene un ID inválido: "${s}"`) - } - return { id, nombre: `Usuario ${id}`, email: '' } - }) -} - -export function esEstadoTerminal(estado: string, terminales: readonly string[] = ETAPAS_TERMINALES_OPORTUNIDAD): boolean { - const normalizado = estado.trim().toUpperCase() - return terminales.some((t) => t.toUpperCase() === normalizado) -} - -/** Vendedor al que le toca el turno `indice` (rotación circular) */ -export function vendedorEnTurno(vendedores: readonly UsuarioMonday[], indice: number): UsuarioMonday | undefined { - if (vendedores.length === 0) return undefined - return vendedores[((indice % vendedores.length) + vendedores.length) % vendedores.length] -} - -/** - * Oportunidades en etapa temprana (Afluencia / Contactado / Intento de - * contacto) CON asesor asignado cuya última actualización supera - * `diasInactividad` días respecto a `ahora`. - */ -export function oportunidadesInactivas( - oportunidades: readonly OportunidadMonday[], - ahora: Date, - diasInactividad: number, -): OportunidadMonday[] { - const limiteMs = diasInactividad * DIA_MS - return oportunidades.filter((op) => { - if (op.asesorIds.length === 0) return false - if (!ETAPAS_SEGUIMIENTO.some((e) => e === op.etapa.trim().toUpperCase())) return false - const t = Date.parse(op.updatedAt) - if (Number.isNaN(t)) return false - return ahora.getTime() - t > limiteMs - }) -} - -/** Clave de dedup: un recordatorio por oportunidad por día */ -export function claveRecordatorio(itemId: string, fecha: Date): string { - return `recordatorio:${itemId}:${fecha.toISOString().slice(0, 10)}` -} - -export function diasSinActividad(op: Pick, ahora: Date): number { - return Math.floor((ahora.getTime() - Date.parse(op.updatedAt)) / DIA_MS) -} - -/** - * Oportunidades ASIGNADAS (con asesor) que siguen en Afluencia / - * Intento de contacto y llevan más de `horasUmbral` horas sin actualizarse: - * candidatas a escalar. (El round-robin actualiza el item al asignar, así que - * el reloj arranca ahí.) - */ -export function oportunidadesSinContactar( - oportunidades: readonly OportunidadMonday[], - ahora: Date, - horasUmbral: number, -): OportunidadMonday[] { - const limiteMs = horasUmbral * HORA_MS - return oportunidades.filter((op) => { - if (op.asesorIds.length === 0) return false - if (!ETAPAS_SIN_CONTACTAR.some((e) => e === op.etapa.trim().toUpperCase())) return false - const t = Date.parse(op.updatedAt) - if (Number.isNaN(t)) return false - return ahora.getTime() - t > limiteMs - }) -} - -/** Clave de dedup del escalado: un escalado por lead (una sola vez) */ -export function claveEscalado(itemId: string): string { - return `escalado:${itemId}` -} - -/** Dedup: un item en Apartado por oportunidad que entra a 'Aparto' */ +/** Clave de dedup del apartado local creado a partir de una oportunidad */ export function claveApartadoCreado(oportunidadId: string): string { - return `apartado-creado:${oportunidadId}` -} - -/** Dedup: un item en Ventas por oportunidad que entra a 'Ganado' */ -export function claveVentaCreada(oportunidadId: string): string { - return `venta-creada:${oportunidadId}` -} - -/** Comparación de etapa tolerante a mayúsculas y espacios ('Aparto' === 'APARTO') */ -export function esEtapa(etapa: string, esperada: string): boolean { - return etapa.trim().toUpperCase() === esperada.trim().toUpperCase() + return `${CLAVE_APARTADO_CREADO}:${oportunidadId}` } // ---------- Servicio ---------- -type MondayLeads = Pick< - MondayService, - | 'getLeads' - | 'getOportunidades' - | 'asignarVendedorALead' - | 'asignarAsesorAOportunidad' - | 'crearUpdateEnItem' - | 'getUsuariosEquipo' - | 'crearActividad' - | 'crearApartado' - | 'crearVenta' -> - -export interface OpcionesAutomatizaciones { - /** Crear actividades en el tablero Actividades al asignar/recordar (default: config) */ - actividadesEnabled?: boolean -} - export interface AsignacionRR { itemId: string lead: string - vendedorId: number + /** Usuario del ecosistema al que se asignó el lead */ + usuarioId: string vendedor: string - /** Tablero del item asignado: 'leads' (Vendedor) u 'oportunidades' (Asesor) */ - tablero: TableroRoundRobin + /** Tablero del item asignado (siempre 'leads' en el modo local) */ + tablero: 'leads' } -/** Tablero del embudo que cubre el round-robin / la asignación al capturista */ -export type TableroRoundRobin = 'leads' | 'oportunidades' - export interface ResultadoRoundRobin { asignados: number detalle: AsignacionRR[] @@ -227,7 +80,6 @@ export interface CandidatoEscalado { lead: string estado: string horasSinContactar: number - responsableId: number | null // coordinador o vendedor asignado; null si no hay a quién asignar yaEscalado: boolean } @@ -237,246 +89,14 @@ export interface ResultadoEscalado { escalados: number } -export interface ResultadoOportunidades { - apartadosCreados: { oportunidadId: string; nombre: string; apartadoItemId: string }[] - ventasCreadas: { oportunidadId: string; nombre: string; ventaItemId: string }[] - errores: { oportunidadId: string; mensaje: string }[] +/** Vendedor local en la rotación del round-robin */ +interface VendedorLocal { + usuarioId: string + nombre: string } export class AutomatizacionesService { - private readonly actividadesEnabled: boolean - - constructor( - private readonly monday: MondayLeads, - private readonly state: StateStore, - opciones: OpcionesAutomatizaciones = {}, - ) { - this.actividadesEnabled = opciones.actividadesEnabled ?? config.automatizaciones.actividadesEnabled - } - - /** 'YYYY-MM-DD' de hoy + N días (fecha compromiso de la actividad) */ - private static fechaHoyMas(dias: number): string { - const d = new Date() - d.setDate(d.getDate() + dias) - const m = String(d.getMonth() + 1).padStart(2, '0') - const dia = String(d.getDate()).padStart(2, '0') - return `${d.getFullYear()}-${m}-${dia}` - } - - /** - * Crea una actividad en Monday sin interrumpir la automatización: - * un fallo aquí solo se loguea (la asignación/recordatorio ya se hizo). - */ - private async crearActividadSegura(datos: { - nombre: string - responsableId: number - leadItemId: string - }): Promise { - try { - await this.monday.crearActividad({ - nombre: datos.nombre, - tipoLabel: 'Llamada', - responsableId: datos.responsableId, - fecha: AutomatizacionesService.fechaHoyMas(1), - estadoLabel: 'Abierto', - leadItemId: datos.leadItemId, - }) - } catch (err) { - // eslint-disable-next-line no-console - console.error(`No se pudo crear la actividad "${datos.nombre}":`, err) - } - } - - /** - * Lista rotativa de vendedores: env MONDAY_RR_USER_IDS si está definida - * (override manual); si no, los miembros del equipo de Monday MONDAY_RR_TEAM - * (default "vendedores"). El cache dura 1 h: altas/bajas del equipo se - * reflejan solas sin redeploy. - */ - async obtenerVendedores(): Promise { - if (config.automatizaciones.rrUserIds.trim() !== '') { - return parseRrUserIds(config.automatizaciones.rrUserIds) - } - const cacheado = await this.state.get(CLAVE_RR_VENDEDORES) - if (cacheado) { - try { - const parsed = JSON.parse(cacheado) as { ts?: number; usuarios?: UsuarioMonday[] } - if (parsed.ts !== undefined && parsed.usuarios !== undefined && Date.now() - parsed.ts < CACHE_VENDEDORES_MS) { - return parsed.usuarios - } - } catch { - // formato viejo (arreglo sin ts) → re-consulta abajo - } - } - const usuarios = await this.monday.getUsuariosEquipo(config.automatizaciones.rrEquipo) - if (usuarios.length === 0) { - throw new AppError(502, `El equipo "${config.automatizaciones.rrEquipo}" no tiene usuarios asignables`) - } - await this.state.set(CLAVE_RR_VENDEDORES, JSON.stringify({ ts: Date.now(), usuarios })) - return usuarios - } - - /** - * Una pasada de round-robin sobre AMBOS tableros del embudo: - * - Leads: todo lead sin Vendedor asignado y con estado no terminal - * (Descartado/Calificado/Abandono quedan fuera). - * - Oportunidades: solo etapa 'Afluencia' sin Asesor (las creadas por el - * cotizador nacen en 'Cotizador' y NO entran al reparto). - * Si se pasa `soloItemId`, solo se evalúa ese item, y `board` limita la - * pasada a un tablero (rutas de los webhooks create_item). - */ - async runRoundRobin(soloItemId?: string, board?: TableroRoundRobin): Promise { - // Round-robin deshabilitado vía config (default false en seed) - if (soloItemId === undefined && configService.obtenerConfigSync('round_robin_habilitado') !== 'true') { - return { asignados: 0, detalle: [] } - } - // Modo local: Monday caído → round-robin sobre tablas locales - if (!saludMonday.estaDisponible() && soloItemId === undefined) { - return this.runRoundRobinLocal() - } - - interface Candidato { - id: string - nombre: string - tablero: TableroRoundRobin - } - const candidatos: Candidato[] = [] - if (board === undefined || board === 'leads') { - const leads: LeadMonday[] = await this.monday.getLeads() - for (const l of leads) { - if (l.vendedorIds.length > 0) continue - if (esEstadoTerminal(l.estado, ESTADOS_TERMINALES_LEAD)) continue - if (soloItemId !== undefined && l.id !== soloItemId) continue - candidatos.push({ id: l.id, nombre: l.nombre, tablero: 'leads' }) - } - } - if (board === undefined || board === 'oportunidades') { - const oportunidades = await this.monday.getOportunidades() - for (const o of oportunidades) { - if (o.asesorIds.length > 0) continue - if (o.etapa.trim().toUpperCase() !== 'AFLUENCIA') continue - if (soloItemId !== undefined && o.id !== soloItemId) continue - candidatos.push({ id: o.id, nombre: o.nombre, tablero: 'oportunidades' }) - } - } - // Los items capturados desde una app con sesión se asignan a su capturista - // (bandera rr:skip='capturista'): sin este filtro, la latencia de Monday - // hacía que el webhook los viera sin persona asignada y pisara la asignación. - const pendientes: Candidato[] = [] - for (const c of candidatos) { - if ((await this.state.get(`${CLAVE_RR_SKIP}${c.id}`)) !== 'capturista') pendientes.push(c) - } - if (pendientes.length === 0) { - await this.state.set(CLAVE_RR_ULTIMA_PASADA, new Date().toISOString()) - return { asignados: 0, detalle: [] } - } - - const vendedores = await this.obtenerVendedores() - let indice = Number((await this.state.get(CLAVE_RR_INDICE)) ?? '0') - if (!Number.isInteger(indice) || indice < 0) indice = 0 - - const detalle: AsignacionRR[] = [] - for (const c of pendientes) { - const vendedor = vendedorEnTurno(vendedores, indice) - if (!vendedor) throw new AppError(500, 'La lista de vendedores está vacía') - if (c.tablero === 'leads') await this.monday.asignarVendedorALead(c.id, vendedor.id) - else await this.monday.asignarAsesorAOportunidad(c.id, vendedor.id) - if (this.actividadesEnabled) { - await this.crearActividadSegura({ - nombre: `Contactar a ${c.nombre}`, - responsableId: vendedor.id, - leadItemId: c.id, - }) - } - // Push notification al vendedor asignado - const usuarioId = await usuarioIdPorMondayUserId(vendedor.id) - if (usuarioId) { - void enviarPush(usuarioId, 'Nuevo lead asignado', `${c.nombre} — ${c.tablero}`, `/hoy/`) - } - detalle.push({ itemId: c.id, lead: c.nombre, vendedorId: vendedor.id, vendedor: vendedor.nombre, tablero: c.tablero }) - indice++ - } - - await this.state.set(CLAVE_RR_INDICE, String(indice % vendedores.length)) - await this.state.set(CLAVE_RR_ULTIMA_PASADA, new Date().toISOString()) - return { asignados: detalle.length, detalle } - } - - /** - * Asigna el item a quien lo capturó desde una app del ecosistema (su usuario - * Monday vinculado) y le crea la actividad de contacto — el mismo paquete - * que haría el round-robin, pero para el capturista. `board` decide la - * columna: Vendedor en Leads, Asesor en Oportunidades. No hay carrera con el - * webhook create_item: runRoundRobin solo toma items sin persona asignada. - */ - async asignarACapturista( - itemId: string, - nombre: string, - vendedorId: number, - board: TableroRoundRobin = 'oportunidades', - ): Promise { - if (board === 'leads') await this.monday.asignarVendedorALead(itemId, vendedorId) - else await this.monday.asignarAsesorAOportunidad(itemId, vendedorId) - if (this.actividadesEnabled) { - await this.crearActividadSegura({ - nombre: `Contactar a ${nombre}`, - responsableId: vendedorId, - leadItemId: itemId, - }) - } - } - - /** - * Escaneo de inactividad. Con `preview: true` no escribe nada en Monday ni en - * el estado (sirve para el endpoint de preview). - */ - async escaneoInactividad(opciones: { preview: boolean; ahora?: Date }): Promise { - // Modo local: Monday caído → escaneo sobre tablas locales - if (!saludMonday.estaDisponible()) { - return this.escaneoInactividadLocal(opciones) - } - - const ahora = opciones.ahora ?? new Date() - const diasUmbral = config.automatizaciones.inactividadDias - const oportunidades = await this.monday.getOportunidades() - const inactivas = oportunidadesInactivas(oportunidades, ahora, diasUmbral) - - const candidatos: CandidatoInactividad[] = [] - let recordatoriosEnviados = 0 - for (const op of inactivas) { - const clave = claveRecordatorio(op.id, ahora) - const yaEnviado = (await this.state.get(clave)) !== null - const dias = diasSinActividad(op, ahora) - candidatos.push({ - itemId: op.id, - lead: op.nombre, - estado: op.etapa, - updatedAt: op.updatedAt, - diasInactivo: dias, - yaRecordadoHoy: yaEnviado, - }) - if (opciones.preview || yaEnviado) continue - const body = - `⚠️ Recordatorio automático: esta oportunidad lleva ${dias} días sin actualizarse ` + - `(última actividad: ${op.updatedAt.slice(0, 10)}). ` + - `Favor de darle seguimiento o marcarla como Perdido.` - await this.monday.crearUpdateEnItem(op.id, body) - await this.state.set(clave, ahora.toISOString()) - recordatoriosEnviados++ - // Además del update, deja una actividad al asesor asignado (si tiene) - const asesorId = op.asesorIds[0] - if (this.actividadesEnabled && asesorId !== undefined) { - await this.crearActividadSegura({ - nombre: `Retomar contacto con ${op.nombre}`, - responsableId: asesorId, - leadItemId: op.id, - }) - } - } - - if (!opciones.preview) await this.state.set(CLAVE_INACTIVIDAD_ULTIMO_ESCANEO, ahora.toISOString()) - return { preview: opciones.preview, diasUmbral, candidatos, recordatoriosEnviados } - } + constructor(private readonly state: StateStore) {} /** Suma `n` a un contador acumulado en el estado (para el endpoint admin) */ private async incrementarContador(clave: string, n: number): Promise { @@ -485,173 +105,6 @@ export class AutomatizacionesService { await this.state.set(clave, String((Number.isFinite(actual) ? actual : 0) + n)) } - /** Responsable del escalado: coordinador (env) o, si no hay, el asesor asignado */ - private responsableEscalado(op: OportunidadMonday): number | null { - const coordinador = config.automatizaciones.coordinadorUserId - if (Number.isInteger(coordinador) && coordinador > 0) return coordinador - return op.asesorIds[0] ?? null - } - - /** - * Escalado de afluencias sin contactar: una oportunidad asignada que sigue en - * Afluencia / Intento de contacto más de LEAD_ESCALADO_HORAS horas genera una - * actividad URGENTE al coordinador (o al asesor) + update en el item. - * Un escalado por oportunidad (dedup `escalado:`); un fallo de Monday - * en una oportunidad no detiene a las demás. - */ - async escaneoEscalado(opciones: { ahora?: Date } = {}): Promise { - // Modo local: Monday caído → escalado sobre tablas locales - if (!saludMonday.estaDisponible()) { - return this.escaneoEscaladoLocal(opciones) - } - - const ahora = opciones.ahora ?? new Date() - const horasUmbral = config.automatizaciones.escaladoHoras - const oportunidades = await this.monday.getOportunidades() - const pendientes = oportunidadesSinContactar(oportunidades, ahora, horasUmbral) - - const candidatos: CandidatoEscalado[] = [] - let escalados = 0 - for (const op of pendientes) { - const responsableId = this.responsableEscalado(op) - const horas = Math.floor((ahora.getTime() - Date.parse(op.updatedAt)) / HORA_MS) - const yaEscalado = (await this.state.get(claveEscalado(op.id))) !== null - candidatos.push({ - itemId: op.id, - lead: op.nombre, - estado: op.etapa, - horasSinContactar: horas, - responsableId, - yaEscalado, - }) - if (yaEscalado) continue - try { - if (this.actividadesEnabled && responsableId !== null) { - await this.monday.crearActividad({ - nombre: `URGENTE: oportunidad sin contactar — ${op.nombre}`, - tipoLabel: 'Llamada', - responsableId, - fecha: AutomatizacionesService.fechaHoyMas(0), - estadoLabel: 'Abierto', - leadItemId: op.id, - }) - } - await this.monday.crearUpdateEnItem( - op.id, - `🚨 Escalado automático: la oportunidad lleva ${horas} h asignada en etapa "${op.etapa}" sin contacto. ` + - `Se notificó a ${responsableId !== null && responsableId !== op.asesorIds[0] ? 'la coordinación' : 'el asesor asignado'}.`, - ) - await this.state.set(claveEscalado(op.id), ahora.toISOString()) - escalados++ - } catch (err) { - // eslint-disable-next-line no-console - console.error(`[escalado] falló el escalado de la oportunidad ${op.id} (${op.nombre}):`, err) - } - } - - await this.incrementarContador(CLAVE_CONTADOR_ESCALADOS, escalados) - await this.state.set(CLAVE_ESCALADO_ULTIMO_ESCANEO, ahora.toISOString()) - return { horasUmbral, candidatos, escalados } - } - - /** - * Oportunidades → Apartado / Ventas (polling): - * - etapa 'Aparto' sin item en Apartado → crea el apartado (estado inicial - * 'Pago Apartado', vigencia de 7 días). Dedup `apartado-creado:`. - * - etapa 'Ganado' sin item en Ventas → crea la venta con unidad y valor. - * Dedup `venta-creada:`. - * Un fallo de Monday en una oportunidad no detiene a las demás. - */ - async escaneoOportunidades(): Promise { - const oportunidades = await this.monday.getOportunidades() - const resultado: ResultadoOportunidades = { apartadosCreados: [], ventasCreadas: [], errores: [] } - - for (const op of oportunidades) { - if (esEtapa(op.etapa, ETAPA_APARTO) && (await this.state.get(claveApartadoCreado(op.id))) === null) { - try { - // El Apartado hereda lo que la oportunidad ya trae (contacto, precio, - // asesor como Vendedor); lo que no exista se omite - const apartadoItemId = await this.monday.crearApartado({ - clienteNombre: op.nombre, - unidadCodigo: op.unidadCodigo, - fechaApartado: AutomatizacionesService.fechaHoyMas(0), - fechaLimite: AutomatizacionesService.fechaHoyMas(7), - estadoLabel: 'Pago Apartado', - telefono: op.telefono !== '' ? op.telefono : undefined, - email: op.email !== '' ? op.email : undefined, - precioFinal: op.precioFinal > 0 ? op.precioFinal : undefined, - vendedorId: op.asesorIds[0], - }) - await this.state.set(claveApartadoCreado(op.id), apartadoItemId) - resultado.apartadosCreados.push({ oportunidadId: op.id, nombre: op.nombre, apartadoItemId }) - // Crear LeadCaptura local para el apartado (Mi día los necesita) - try { - // Buscar el usuarioId de la oportunidad original para vincular el apartado - const opLocal = await prisma.leadCaptura.findFirst({ where: { leadId: op.id } }) - await prisma.leadCaptura.create({ - data: { - leadId: apartadoItemId, - board: 'APARTADO', - etapa: 'Pago Apartado', - nombreLead: op.nombre, - telefono: op.telefono || null, - email: op.email || null, - usuarioId: opLocal?.usuarioId ?? 'sistema', - oportunidadId: op.id, - mondayItemId: apartadoItemId, - pendienteSync: false, - }, - }) - } catch (e) { console.error('[APARTADO-ERR]', e instanceof Error ? e.message.slice(0, 150) : e) } - } catch (err) { - resultado.errores.push({ oportunidadId: op.id, mensaje: err instanceof Error ? err.message : String(err) }) - } - continue // una oportunidad no puede estar en dos etapas a la vez - } - if (esEtapa(op.etapa, ETAPA_GANADO) && (await this.state.get(claveVentaCreada(op.id))) === null) { - try { - const ventaItemId = await this.monday.crearVenta({ - clienteNombre: op.nombre, - ...(op.unidadCodigo !== '' ? { unidadCodigo: op.unidadCodigo } : {}), - ...(op.precioFinal > 0 ? { valorVenta: op.precioFinal } : {}), - }) - await this.state.set(claveVentaCreada(op.id), ventaItemId) - resultado.ventasCreadas.push({ oportunidadId: op.id, nombre: op.nombre, ventaItemId }) - } catch (err) { - resultado.errores.push({ oportunidadId: op.id, mensaje: err instanceof Error ? err.message : String(err) }) - } - } - } - - await this.incrementarContador(CLAVE_CONTADOR_APARTADOS, resultado.apartadosCreados.length) - await this.incrementarContador(CLAVE_CONTADOR_VENTAS, resultado.ventasCreadas.length) - await this.state.set(CLAVE_OPORTUNIDADES_ULTIMO_ESCANEO, new Date().toISOString()) - return resultado - } - - /** - * Garantiza el apartado de UNA oportunidad que acaba de pasar a 'Aparto' - * (botón de Mi día). Monday tarda ~1s en propagar el cambio de etapa - * (read-after-write), así que el escaneo se repite unas veces hasta que la - * oportunidad se lea en 'Aparto' o el dedup confirme la creación. Devuelve - * el apartadoItemId o null si no se pudo. - */ - async asegurarApartado(oportunidadId: string, reintentos = 3, esperaMs = 1200): Promise { - const previo = await this.state.get(claveApartadoCreado(oportunidadId)) - if (previo !== null) return previo - for (let intento = 0; intento < reintentos; intento++) { - const r = await this.escaneoOportunidades() - const creado = r.apartadosCreados.find((a) => a.oportunidadId === oportunidadId) - if (creado) return creado.apartadoItemId - const tras = await this.state.get(claveApartadoCreado(oportunidadId)) - if (tras !== null) return tras - if (intento < reintentos - 1) await new Promise((res) => setTimeout(res, esperaMs)) - } - return null - } - - // ---------- Round-robin / inactividad / escalado LOCAL (modo sin Monday) ---------- - /** * Round-robin local: asigna leads locales (board='LEADS', etapa='NUEVO') sin * registro en AsignacionLocal al siguiente vendedor en rotación. @@ -678,31 +131,12 @@ export class AutomatizacionesService { return { asignados: 0, detalle: [] } } - // Vendedores locales - let vendedores: UsuarioMonday[] - if (config.automatizaciones.rrUserIds.trim() !== '') { - vendedores = parseRrUserIds(config.automatizaciones.rrUserIds) - } else { - const usuarios = await prisma.usuario.findMany({ - where: { rol: 'vendedor', activo: true }, - select: { id: true, nombre: true, mondayUserId: true }, - }) - vendedores = usuarios.map((u) => ({ - id: u.mondayUserId ?? 0, - nombre: u.nombre, - email: '', - // Guardamos el id local en el campo id para usarlo en AsignacionLocal - // El id local se infiere del usuarioId en LeadCaptura - })) - // Si los vendedores no tienen mondayUserId, usamos el id local directamente - if (vendedores.every((v) => v.id === 0)) { - vendedores = usuarios.map((u) => ({ - id: 0, - nombre: u.nombre, - email: u.id, // Guardamos el id local aquí temporalmente - })) - } - } + // Vendedores locales activos + const usuarios = await prisma.usuario.findMany({ + where: { rol: 'vendedor', activo: true }, + select: { id: true, nombre: true }, + }) + const vendedores: VendedorLocal[] = usuarios.map((u) => ({ usuarioId: u.id, nombre: u.nombre })) if (vendedores.length === 0) return { asignados: 0, detalle: [] } let indice = Number((await this.state.get(CLAVE_RR_INDICE)) ?? '0') @@ -710,56 +144,33 @@ export class AutomatizacionesService { const detalle: AsignacionRR[] = [] for (const lead of candidatos) { - const vendedorTurno = vendedorEnTurno(vendedores, indice) + const vendedorTurno = vendedores[((indice % vendedores.length) + vendedores.length) % vendedores.length] if (!vendedorTurno) break - // Determinar el usuarioId local: si vendedorTurno.email tiene un uuid, usarlo; - // si no, buscar por nombre en la tabla Usuario - let usuarioLocalId: string | null = null - if (vendedorTurno.email !== '' && vendedorTurno.id === 0) { - // email fue usado para guardar el id local temporalmente - const usuarioLocal = await prisma.usuario.findFirst({ - where: { nombre: vendedorTurno.nombre, rol: 'vendedor', activo: true }, - select: { id: true }, - }) - usuarioLocalId = usuarioLocal?.id ?? null - } - if (!usuarioLocalId && vendedorTurno.id > 0) { - // Tiene mondayUserId; buscar el usuario local por mondayUserId - const usuarioLocal = await prisma.usuario.findFirst({ - where: { mondayUserId: vendedorTurno.id }, - select: { id: true }, - }) - usuarioLocalId = usuarioLocal?.id ?? null - } - - if (usuarioLocalId) { - // Push notification al vendedor asignado - void enviarPush(usuarioLocalId, 'Nuevo lead asignado', lead.nombreLead, '/hoy/') - await prisma.asignacionLocal.create({ - data: { - leadCapturaId: lead.id, - usuarioId: usuarioLocalId, - asignadoPor: 'round-robin', - }, - }) - await actividadLocalService.crearActividad({ - titulo: `Contactar a ${lead.nombreLead}`, - usuarioId: usuarioLocalId, + void enviarPush(vendedorTurno.usuarioId, 'Nuevo lead asignado', lead.nombreLead, '/hoy/') + await prisma.asignacionLocal.create({ + data: { leadCapturaId: lead.id, - fechaVencimiento: new Date(Date.now() + 48 * 60 * 60 * 1000), - tipo: 'SEGUIMIENTO', - }) - await prisma.leadCaptura.update({ - where: { id: lead.id }, - data: { usuarioId: usuarioLocalId }, - }) - } + usuarioId: vendedorTurno.usuarioId, + asignadoPor: 'round-robin', + }, + }) + await actividadLocalService.crearActividad({ + titulo: `Contactar a ${lead.nombreLead}`, + usuarioId: vendedorTurno.usuarioId, + leadCapturaId: lead.id, + fechaVencimiento: new Date(Date.now() + 48 * 60 * 60 * 1000), + tipo: 'SEGUIMIENTO', + }) + await prisma.leadCaptura.update({ + where: { id: lead.id }, + data: { usuarioId: vendedorTurno.usuarioId }, + }) detalle.push({ itemId: lead.leadId, lead: lead.nombreLead, - vendedorId: vendedorTurno.id, + usuarioId: vendedorTurno.usuarioId, vendedor: vendedorTurno.nombre, tablero: 'leads', }) @@ -867,7 +278,6 @@ export class AutomatizacionesService { lead: op.nombreLead, estado: op.etapa, horasSinContactar: horas, - responsableId: null, // se resuelve abajo yaEscalado: tieneActividadUrgente !== null, }) @@ -882,7 +292,7 @@ export class AutomatizacionesService { fechaVencimiento: ahora, tipo: 'SEGUIMIENTO', }) - await this.state.set(claveEscalado(op.leadId), ahora.toISOString()) + await this.state.set(`escalado:${op.leadId}`, ahora.toISOString()) escalados++ } catch (err) { // eslint-disable-next-line no-console @@ -932,16 +342,9 @@ export class AutomatizacionesService { /** Estado actual para el endpoint admin */ async estado(): Promise> { - const vendedoresCache = await this.state.get(CLAVE_RR_VENDEDORES) return { roundRobin: { indiceActual: Number((await this.state.get(CLAVE_RR_INDICE)) ?? '0'), - vendedoresDeEnv: config.automatizaciones.rrUserIds.trim() !== '', - vendedores: config.automatizaciones.rrUserIds.trim() !== '' - ? parseRrUserIds(config.automatizaciones.rrUserIds) - : vendedoresCache - ? (JSON.parse(vendedoresCache) as UsuarioMonday[]) - : [], ultimaPasada: await this.state.get(CLAVE_RR_ULTIMA_PASADA), }, inactividad: { @@ -950,15 +353,9 @@ export class AutomatizacionesService { }, escalado: { horasUmbral: config.automatizaciones.escaladoHoras, - coordinadorUserId: config.automatizaciones.coordinadorUserId > 0 ? config.automatizaciones.coordinadorUserId : null, totalEscalados: Number((await this.state.get(CLAVE_CONTADOR_ESCALADOS)) ?? '0'), ultimoEscaneo: await this.state.get(CLAVE_ESCALADO_ULTIMO_ESCANEO), }, - oportunidades: { - apartadosCreados: Number((await this.state.get(CLAVE_CONTADOR_APARTADOS)) ?? '0'), - ventasCreadas: Number((await this.state.get(CLAVE_CONTADOR_VENTAS)) ?? '0'), - ultimoEscaneo: await this.state.get(CLAVE_OPORTUNIDADES_ULTIMO_ESCANEO), - }, crons: { habilitados: config.automatizaciones.enabled, pollCron: config.automatizaciones.pollCron, @@ -980,4 +377,4 @@ export const prismaStateStore: StateStore = { } /** Singleton usado por controllers y crons (los tests instancian el suyo) */ -export const automatizacionesService = new AutomatizacionesService(mondayService, prismaStateStore) +export const automatizacionesService = new AutomatizacionesService(prismaStateStore) diff --git a/api-gateway/src/services/cobranza.service.ts b/api-gateway/src/services/cobranza.service.ts index ce2b2bd..4f59db6 100644 --- a/api-gateway/src/services/cobranza.service.ts +++ b/api-gateway/src/services/cobranza.service.ts @@ -44,7 +44,7 @@ export interface PlanPago { mensualidad: number tasaMoraMensual?: number status: StatusPlan - /** Item del tablero Apartado (Monday) ligado al plan (puente a postventa) */ + /** Item del apartado ligado al plan (puente a postventa) */ mondayItemId?: string | null /** Vendedor dueño del plan (sub del JWT); null en planes históricos */ vendedorId?: string | null diff --git a/api-gateway/src/services/contactos-detalle.service.test.ts b/api-gateway/src/services/contactos-detalle.service.test.ts index 3d6fefd..0869381 100644 --- a/api-gateway/src/services/contactos-detalle.service.test.ts +++ b/api-gateway/src/services/contactos-detalle.service.test.ts @@ -1,49 +1,5 @@ import { describe, expect, it } from 'vitest' -import { claveTelefono, detalleContacto } from './contactos-detalle.service.js' -import type { LeadMonday, OportunidadMonday, ApartadoMonday } from './monday.service.js' - -const MI_ID = 106736727 - -const leadBase: LeadMonday = { - id: 'l1', nombre: 'Ana Demo', createdAt: '', updatedAt: '', estado: 'Nuevo', origen: 'PASE', - telefono: '+52 6641234567', email: 'ana@correo.com', vendedorIds: [MI_ID], -} - -const opBase: OportunidadMonday = { - id: 'o1', nombre: 'Ana Demo', createdAt: '', updatedAt: '', etapa: 'Cotizador', unidadCodigo: '', - precioFinal: 1500000, asesorIds: [MI_ID], telefono: '6641234567', email: '', origen: 'PASE', - desarrollo: 'SENDA', modeloInteres: 'SOHO', -} - -const apBase: ApartadoMonday = { - id: 'a1', nombre: 'Ana Demo', unidadCodigo: 'SENDA-409-1-2', fechaLimite: '2026-09-10', - estado: 'Pago Apartado', vendedorIds: [MI_ID], telefono: '6641234567', email: '', - precioFinal: 2646000, fechaCompra: '2026-09-01', -} - -function mondayMock(overrides: Record = {}) { - return { - async getContactoDetalle() { - return { nombre: 'Ana Demo', Teléfono: '+52 6641234567', Email: 'ana@correo.com', Estado: 'CLIENTE', CURP: 'XAXX010101XXXXXXX0' } - }, - async getLeads() { return [leadBase] }, - async getOportunidades() { return [opBase] }, - async getApartados() { return [apBase] }, - async getHistorialItems(_b: string, ids: string[]) { - return ids.map((id, i) => ({ - itemId: id, - fechaISO: `2026-09-0${(i % 9) + 1}T10:00:00.000Z`, - usuarioId: MI_ID, - evento: 'update_column_value', - })) - }, - async getUnidad() { return { desarrollo: 'SENDA' } }, - ...overrides, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any -} - -const nombres = new Map([[MI_ID, 'Martin Zavala']]) +import { claveTelefono } from './contactos-detalle.service.js' describe('claveTelefono', () => { it('últimos 10 dígitos, tolerante a lada, espacios y símbolos', () => { @@ -54,69 +10,3 @@ describe('claveTelefono', () => { expect(claveTelefono('123')).toBe('') }) }) - -describe('detalleContacto (GET /api/contactos/:id/detalle)', () => { - it('arma contacto + propiedad + relaciones + historial con nombres resueltos', async () => { - const d = await detalleContacto( - mondayMock(), - 'c1', - nombres, - [{ leadId: 'l1', nombreLead: 'Ana Demo', createdAt: new Date('2026-09-02T08:00:00Z'), usuario: { nombre: 'Martin Zavala' } }], - { - id: 'cl1', nombreCompleto: 'Ana Demo', proyecto: 'SENDA', unidadCodigo: 'SENDA-409-1-2', - createdAt: new Date('2026-09-03T08:00:00Z'), capturadoPor: { nombre: 'Martin Zavala' }, - }, - ) - expect(d).not.toBeNull() - // El contacto del detalle se devuelve con campos nombrados del modelo local - expect(d!.contacto['curp']).toBe('XAXX010101XXXXXXX0') - // Propiedad comprada (apartado relacionado, enriquecido con el desarrollo) - expect(d!.propiedad).toMatchObject({ - unidadCodigo: 'SENDA-409-1-2', - desarrollo: 'SENDA', - estado: 'Pago Apartado', - precioFinal: 2646000, - atendidoPor: 'Martin Zavala', - }) - // Relaciones por teléfono/email con quién atiende - expect(d!.relaciones.leads[0]).toMatchObject({ id: 'l1', atendidoPor: 'Martin Zavala' }) - expect(d!.relaciones.oportunidades[0]).toMatchObject({ id: 'o1', atendidoPor: 'Martin Zavala' }) - expect(d!.relaciones.apartados[0]).toMatchObject({ id: 'a1', atendidoPor: 'Martin Zavala' }) - // Historial: activity log + captura + alta local, ordenado desc - expect(d!.historial.some((h) => h.fuente === 'App' && h.detalle.includes('Captura'))).toBe(true) - expect(d!.historial.some((h) => h.detalle.includes('Alta de cliente'))).toBe(true) - expect(d!.historial.every((h) => h.usuario === 'Martin Zavala')).toBe(true) - const fechas = d!.historial.map((h) => h.fechaISO) - expect(fechas).toEqual([...fechas].sort((a, b) => b.localeCompare(a))) - }) - - it('sin relaciones ni registros locales: listas vacías y propiedad null', async () => { - const d = await detalleContacto( - mondayMock({ - async getLeads() { return [] }, - async getOportunidades() { return [] }, - async getApartados() { return [] }, - }), - 'c1', - new Map(), - [], - null, - ) - expect(d!.propiedad).toBeNull() - expect(d!.relaciones).toEqual({ leads: [], oportunidades: [], apartados: [] }) - // El historial al menos tiene los eventos del propio contacto - expect(d!.historial).toHaveLength(1) - expect(d!.historial[0]?.fuente).toBe('Contactos') - }) - - it('contacto inexistente devuelve null', async () => { - const d = await detalleContacto( - mondayMock({ async getContactoDetalle() { return null } }), - 'no-existe', - new Map(), - [], - null, - ) - expect(d).toBeNull() - }) -}) diff --git a/api-gateway/src/services/contactos-detalle.service.ts b/api-gateway/src/services/contactos-detalle.service.ts index d510aeb..d4b9415 100644 --- a/api-gateway/src/services/contactos-detalle.service.ts +++ b/api-gateway/src/services/contactos-detalle.service.ts @@ -1,38 +1,29 @@ import { prisma } from './prisma.js' import { calcularScore } from './lead-scoring.service.js' -import { config } from '../config/index.js' -import { detalleEventoUltimoCambio, esEstadoFinalApartado } from './mi-dia.service.js' -import type { MondayService } from './monday.service.js' /** - * Detalle del módulo Contactos: toda la información del contacto, la propiedad - * comprada (apartado relacionado), quién lo atiende (asesor/vendedor actual de - * cada item relacionado) y el historial completo de atención. - * - * Las relaciones se arman por teléfono (últimos 10 dígitos) o email a través de - * los tableros Leads, Oportunidades y Apartado; el historial mezcla el activity - * log de Monday de esos items + el registro local (quién capturó qué en las apps). + * Perfil Completo del Contacto: datos locales del cliente/lead, historial de + * pagos, cotizaciones, documentos, lead score y última actividad. */ export interface RelacionCrm { id: string nombre: string estado: string // estado/etapa actual - /** Quién lo atiende ahora (columna people del tablero); null si sin asignar */ + /** Quién lo atiende ahora; null si sin asignar */ atendidoPor: string | null detalle: string // origen/desarrollo/unidad según el tablero ('' si no aplica) } export interface EventoHistorialContacto { fechaISO: string - usuario: string | null // nombre resuelto; null si fue automatización o desconocido + usuario: string | null detalle: string - fuente: string // 'Contactos' | 'Leads' | 'Oportunidades' | 'Apartado' | 'App' + fuente: string } export interface DetalleContacto { - contacto: Record // campos nombrados del modelo local (nombre, telefono, email, …) - /** Apartado relacionado más reciente (la propiedad comprada o en proceso) */ + contacto: Record propiedad: { id: string unidadCodigo: string @@ -57,173 +48,6 @@ export function claveTelefono(telefono: string): string { return digitos.length >= 10 ? digitos.slice(-10) : '' } -/** - * Mapeo título de columna del board Contactos (Monday) → campo nombrado del - * modelo local. Los títulos sin entrada se conservan tal cual. - */ -const CAMPOS_CONTACTO: Record = { - nombre: 'nombre', - 'Teléfono': 'telefono', - Email: 'email', - Estado: 'estado', - CURP: 'curp', - RFC: 'rfc', - 'Estado Civil': 'estadoCivil', - 'Fecha Nacimiento': 'fechaNacimiento', - 'Actividad Economica': 'actividadEconomica', - 'Dirección': 'direccion', -} - -/** Normaliza un record por título de columna a campos nombrados del modelo local */ -export function normalizarCamposContacto(contacto: Record): Record { - const normalizado: Record = {} - for (const [titulo, valor] of Object.entries(contacto)) { - normalizado[CAMPOS_CONTACTO[titulo] ?? titulo] = valor - } - return normalizado -} - -type MondayDetalle = Pick< - MondayService, - | 'getContactoDetalle' - | 'getLeads' - | 'getOportunidades' - | 'getApartados' - | 'getHistorialItems' - | 'getUnidad' -> - -export interface CapturaLocal { - leadId: string - nombreLead: string - createdAt: Date - usuario: { nombre: string } | null -} - -export interface ClienteLocal { - id: string - nombreCompleto: string - proyecto: string - unidadCodigo: string | null - createdAt: Date - capturadoPor: { nombre: string } | null - _count?: { documentos: number } -} - -export async function detalleContacto( - monday: MondayDetalle, - contactoId: string, - nombresPorMondayId: Map, - capturas: CapturaLocal[], - clienteLocal: ClienteLocal | null, -): Promise { - const contacto = await monday.getContactoDetalle(contactoId) - if (contacto === null) return null - - const nombre = (id: number | null): string | null => - id !== null ? (nombresPorMondayId.get(id) ?? null) : null - - const tel = claveTelefono(contacto['Teléfono'] ?? '') - const email = (contacto['Email'] ?? '').trim().toLowerCase() - - const coincide = (t: string, e: string): boolean => - (tel !== '' && claveTelefono(t) === tel) || (email !== '' && e.trim().toLowerCase() === email) - - const [leads, oportunidades, apartados] = await Promise.all([ - monday.getLeads(), - monday.getOportunidades(), - monday.getApartados(), - ]) - - const relLeads = leads.filter((l) => coincide(l.telefono, l.email)) - const relOportunidades = oportunidades.filter((o) => coincide(o.telefono, o.email)) - const relApartados = apartados.filter((a) => coincide(a.telefono ?? '', a.email ?? '')) - - // Propiedad: el apartado relacionado "vivo" (no final) o el último del board; - // se enriquece con el desarrollo de la unidad (inventario) - let propiedad: DetalleContacto['propiedad'] = null - const aptoReciente = relApartados.find((a) => !esEstadoFinalApartado(a.estado)) ?? relApartados[relApartados.length - 1] - if (aptoReciente) { - let desarrollo = '' - if (aptoReciente.unidadCodigo !== '') { - try { - desarrollo = (await monday.getUnidad(aptoReciente.unidadCodigo))?.desarrollo ?? '' - } catch { - desarrollo = '' - } - } - propiedad = { - id: aptoReciente.id, - unidadCodigo: aptoReciente.unidadCodigo, - desarrollo, - estado: aptoReciente.estado, - precioFinal: aptoReciente.precioFinal ?? 0, - fechaCompra: aptoReciente.fechaCompra ?? '', - fechaLimite: aptoReciente.fechaLimite, - atendidoPor: nombre(aptoReciente.vendedorIds[0] ?? null), - } - } - - // Historial: activity log de los items relacionados + del propio contacto, - // más los registros locales de captura/alta (Monday no guarda quién capturó) - const [evContactos, evLeads, evOportunidades, evApartado] = await Promise.all([ - monday.getHistorialItems(config.monday.boards.contactos, [contactoId]), - monday.getHistorialItems(config.monday.boards.leads, relLeads.map((l) => l.id)), - monday.getHistorialItems(config.monday.boards.oportunidades, relOportunidades.map((o) => o.id)), - monday.getHistorialItems(config.monday.boards.apartado, relApartados.map((a) => a.id)), - ]) - - const historial: EventoHistorialContacto[] = [ - ...evContactos.map((e) => ({ fechaISO: e.fechaISO, usuario: nombre(e.usuarioId), detalle: detalleEventoUltimoCambio(e.evento), fuente: 'Contactos' })), - ...evLeads.map((e) => ({ fechaISO: e.fechaISO, usuario: nombre(e.usuarioId), detalle: detalleEventoUltimoCambio(e.evento), fuente: 'Leads' })), - ...evOportunidades.map((e) => ({ fechaISO: e.fechaISO, usuario: nombre(e.usuarioId), detalle: detalleEventoUltimoCambio(e.evento), fuente: 'Oportunidades' })), - ...evApartado.map((e) => ({ fechaISO: e.fechaISO, usuario: nombre(e.usuarioId), detalle: detalleEventoUltimoCambio(e.evento), fuente: 'Apartado' })), - ...capturas.map((c) => ({ - fechaISO: c.createdAt.toISOString(), - usuario: c.usuario?.nombre ?? null, - detalle: `Captura en la app (${c.nombreLead})`, - fuente: 'App', - })), - ...(clienteLocal - ? [{ - fechaISO: clienteLocal.createdAt.toISOString(), - usuario: clienteLocal.capturadoPor?.nombre ?? null, - detalle: `Alta de cliente registrada${clienteLocal.unidadCodigo ? ` (${clienteLocal.unidadCodigo})` : ''}`, - fuente: 'App', - }] - : []), - ].sort((a, b) => b.fechaISO.localeCompare(a.fechaISO)) - - return { - contacto: normalizarCamposContacto(contacto), - propiedad, - relaciones: { - leads: relLeads.map((l) => ({ - id: l.id, - nombre: l.nombre, - estado: l.estado, - atendidoPor: nombre(l.vendedorIds[0] ?? null), - detalle: l.origen, - })), - oportunidades: relOportunidades.map((o) => ({ - id: o.id, - nombre: o.nombre, - estado: o.etapa, - atendidoPor: nombre(o.asesorIds[0] ?? null), - detalle: [o.desarrollo, o.modeloInteres].filter((p) => p).join(' · '), - })), - apartados: relApartados.map((a) => ({ - id: a.id, - nombre: a.nombre, - estado: a.estado, - atendidoPor: nombre(a.vendedorIds[0] ?? null), - detalle: a.unidadCodigo, - })), - }, - historial, - } -} - // ── Perfil Completo del Contacto (modo local) ───────────────────────── export interface PerfilCompletoContacto { @@ -267,7 +91,7 @@ export interface PerfilCompletoContacto { export async function perfilCompleto(contactoId: string): Promise { // Buscar el cliente const cliente = await prisma.cliente.findFirst({ - where: { OR: [{ id: contactoId }, { mondayContactoId: contactoId }] }, + where: { OR: [{ id: contactoId }, { mondayContactoId: contactoId }] }, // mondayContactoId: compatibilidad de datos }) // Buscar la captura del lead diff --git a/api-gateway/src/services/contactos-local.service.test.ts b/api-gateway/src/services/contactos-local.service.test.ts index bb305cd..41743f0 100644 --- a/api-gateway/src/services/contactos-local.service.test.ts +++ b/api-gateway/src/services/contactos-local.service.test.ts @@ -77,7 +77,7 @@ beforeEach(() => { }) describe('listarContactosLocal', () => { - it('arma la lista desde Cliente + capturas (OPORTUNIDADES/APARTADO) con la forma de ContactoMonday + fuente local', async () => { + it('arma la lista desde Cliente + capturas (OPORTUNIDADES/APARTADO) con fuente local', async () => { prismaMock.cliente.findMany.mockResolvedValue([ clienteFake({ id: 'c1', nombreCompleto: 'JUAN PÉREZ', celular: '4771234567', email: 'juan@correo.mx', proyecto: 'SENDA' }), ]) diff --git a/api-gateway/src/services/contactos-local.service.ts b/api-gateway/src/services/contactos-local.service.ts index 6d44fdd..f30a66f 100644 --- a/api-gateway/src/services/contactos-local.service.ts +++ b/api-gateway/src/services/contactos-local.service.ts @@ -1,17 +1,22 @@ -import type { ContactoMonday } from './monday.service.js' import { claveTelefono, type DetalleContacto, type EventoHistorialContacto, type RelacionCrm } from './contactos-detalle.service.js' import { prisma } from './prisma.js' /** - * Fallback de LECTURA del módulo Contactos (modo local): cuando Monday está - * caído, la lista y el detalle se arman desde las tablas locales (Cliente + - * LeadCaptura + Documento + Cotizacion) con la MISMA forma de DTO que el - * camino Monday. Lo que no existe en local (activity log, columnas del board) - * va vacío; el historial se deriva de las capturas/altas/cotizaciones locales. + * Módulo Contactos 100% local: la lista y el detalle se arman desde las + * tablas locales (Cliente + LeadCaptura + Documento + Cotizacion). El + * historial se deriva de las capturas/altas/cotizaciones locales. */ /** Contacto de la lista con flag aditivo de procedencia (el front lo ignora o lo muestra) */ -export type ContactoListaLocal = ContactoMonday & { fuente?: 'local' } +export interface ContactoListaLocal { + id: string + nombre: string + telefono: string + email: string + estado: string + updatedAt: string + fuente?: 'local' +} /** * Lista local: Clientes (alta de clientes) + capturas del embudo en boards @@ -72,10 +77,10 @@ export async function listarContactosLocal(usuarioId?: string): Promise { let cliente = await prisma.cliente.findFirst({ diff --git a/api-gateway/src/services/crm-local.service.test.ts b/api-gateway/src/services/crm-local.service.test.ts index b5e9e58..4443af8 100644 --- a/api-gateway/src/services/crm-local.service.test.ts +++ b/api-gateway/src/services/crm-local.service.test.ts @@ -15,9 +15,6 @@ const prismaMock = vi.hoisted(() => ({ create: vi.fn(), findUnique: vi.fn(), }, - syncPendiente: { - create: vi.fn(), - }, automatizacionState: { findUnique: vi.fn(), upsert: vi.fn(), @@ -34,19 +31,6 @@ import { CrmLocalService, esIdLocal } from './crm-local.service.js' const svc = new CrmLocalService() -/** Tipos de sync encolados, en orden, a partir de las llamadas al mock */ -function tiposEncolados(): string[] { - return prismaMock.syncPendiente.create.mock.calls.map((c) => (c[0] as { data: { tipo: string } }).data.tipo) -} - -function payloadEncolado(tipo: string): Record { - const llamada = prismaMock.syncPendiente.create.mock.calls.find( - (c) => (c[0] as { data: { tipo: string } }).data.tipo === tipo, - ) - if (!llamada) throw new Error(`No se encoló ${tipo}`) - return JSON.parse((llamada[0] as { data: { payloadJson: string } }).data.payloadJson) as Record -} - beforeEach(() => { vi.clearAllMocks() prismaMock.$transaction.mockImplementation(async (ops: Promise[]) => Promise.all(ops)) @@ -55,20 +39,19 @@ beforeEach(() => { prismaMock.leadCaptura.findUnique.mockResolvedValue(null) prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => data) prismaMock.leadOportunidad.create.mockResolvedValue({}) - prismaMock.syncPendiente.create.mockResolvedValue({}) prismaMock.automatizacionState.findUnique.mockResolvedValue(null) prismaMock.automatizacionState.upsert.mockResolvedValue({}) }) describe('esIdLocal', () => { - it('distingue ids locales de ids de Monday', () => { + it('distingue ids locales de ids heredados', () => { expect(esIdLocal('loc_123e4567-e89b-12d3-a456-426614174000')).toBe(true) expect(esIdLocal('1234567890')).toBe(false) }) }) describe('CrmLocalService.crearLead', () => { - it('crea la captura board=LEADS etapa NUEVO pendienteSync y encola CREAR_LEAD + ASIGNAR_PERSONA', async () => { + it('crea la captura board=LEADS etapa NUEVO pendienteSync', async () => { const r = await svc.crearLead( { nombre: 'Ana Prueba', telefono: '4771234567', origen: 'WhatsApp', desarrollo: 'SENDA', notas: 'Quiere 2R' }, 'u-1', @@ -90,12 +73,6 @@ describe('CrmLocalService.crearLead', () => { pendienteSync: true, }), }) - expect(tiposEncolados()).toEqual(['CREAR_LEAD', 'ASIGNAR_PERSONA']) - const payload = payloadEncolado('CREAR_LEAD') - expect(payload['datos']).toMatchObject({ nombre: 'Ana Prueba', telefono: '4771234567', origenLabel: 'WhatsApp' }) - expect(payloadEncolado('ASIGNAR_PERSONA')).toEqual({ board: 'leads', usuarioId: 'u-1' }) - // Todo quedó en una sola transacción (cambio local + cola atómicos) - expect(prismaMock.$transaction).toHaveBeenCalledOnce() }) it('sin teléfono repetido no hay duplicado; con teléfono existente devuelve { duplicado }', async () => { @@ -103,12 +80,11 @@ describe('CrmLocalService.crearLead', () => { const r = await svc.crearLead({ nombre: 'Ana Prueba', telefono: '4771234567' }) expect(r).toEqual({ duplicado: { id: 'loc_viejo', nombre: 'Ana Prueba' } }) expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled() - expect(prismaMock.syncPendiente.create).not.toHaveBeenCalled() }) }) describe('CrmLocalService.crearAfluencia', () => { - it('crea la oportunidad local (board=OPORTUNIDADES, etapa Afluencia) con perfil en datosJson y encola CREAR_AFLUENCIA', async () => { + it('crea la oportunidad local (board=OPORTUNIDADES, etapa Afluencia) con perfil en datosJson', async () => { const r = await svc.crearAfluencia( { nombre: 'Luis Demo', @@ -135,18 +111,11 @@ describe('CrmLocalService.crearAfluencia', () => { }) // Perfilamiento completo en datosJson expect(JSON.parse(creada.data['datosJson'] as string)).toMatchObject({ edad: 35, credito: 'Infonavit' }) - // Auto-mapping leadId = oportunidadId (como el flujo Monday) + // Auto-mapping leadId = oportunidadId expect(prismaMock.leadOportunidad.create).toHaveBeenCalledWith({ data: { leadId: r.id, oportunidadId: r.id } }) - expect(tiposEncolados()).toEqual(['CREAR_AFLUENCIA', 'ASIGNAR_PERSONA']) - expect(payloadEncolado('CREAR_AFLUENCIA')['datos']).toMatchObject({ - nombre: 'Luis Demo', - modeloLabel: 'SOHO', - creditoLabel: 'Infonavit', - loQuierePara: 'Habitarla', - }) }) - it('con oportunidadId ACTUALIZA la fila local y encola ACTUALIZAR_AFLUENCIA (no crea duplicado)', async () => { + it('con oportunidadId ACTUALIZA la fila local (no crea duplicado)', async () => { prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'op-55', nombreLead: 'Luis Demo' }) const r = await svc.crearAfluencia({ nombre: 'Luis Demo', oportunidadId: 'op-55', califica: 'Si' }, 'u-2') expect(r).toEqual({ id: 'op-55', oportunidadId: 'op-55', actualizada: true }) @@ -155,8 +124,6 @@ describe('CrmLocalService.crearAfluencia', () => { data: expect.objectContaining({ etapa: 'Afluencia', pendienteSync: true }), }) expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled() - expect(tiposEncolados()).toEqual(['ACTUALIZAR_AFLUENCIA']) - expect(payloadEncolado('ACTUALIZAR_AFLUENCIA')['datos']).toMatchObject({ nombre: 'Luis Demo', calificaLabel: 'Si' }) }) it('con teléfono duplicado en OPORTUNIDADES devuelve { duplicado }', async () => { @@ -167,44 +134,35 @@ describe('CrmLocalService.crearAfluencia', () => { }) describe('CrmLocalService.cambiarEstado', () => { - it('actualiza la etapa local y encola CAMBIO_ETAPA con board y label', async () => { + it('actualiza la etapa local y guarda la fecha de la cita en datosJson', async () => { prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', board: 'OPORTUNIDADES' }) await svc.cambiarEstado('loc_1', 'Agendo Cita', 'OPORTUNIDADES', { fecha: '2026-09-10' }) expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ where: { leadId: 'loc_1' }, - data: { etapa: 'Agendo Cita', pendienteSync: true }, + data: { etapa: 'Agendo Cita', pendienteSync: true, datosJson: JSON.stringify({ fechaCita: '2026-09-10' }) }, }) - expect(payloadEncolado('CAMBIO_ETAPA')).toEqual({ board: 'oportunidades', label: 'Agendo Cita', fecha: '2026-09-10' }) }) it('sin fila local y sin sombra → 404', async () => { await expect(svc.cambiarEstado('loc_inexistente', 'Contactado', 'LEADS')).rejects.toMatchObject({ status: 404 }) }) - it('con sombra crea la fila mínima (id Monday viejo) y encola igual', async () => { + it('con sombra crea la fila mínima (id heredado)', async () => { await svc.cambiarEstado('998877', 'Contactado', 'LEADS', { sombra: true, usuarioId: 'u-1' }) expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ data: expect.objectContaining({ leadId: '998877', board: 'LEADS', mondayItemId: '998877', pendienteSync: true }), }) - expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA']) }) }) describe('CrmLocalService.descartar', () => { - it('marca etapa terminal Perdido con motivo/submotivo y encola DESCARTE', async () => { + it('marca etapa terminal Perdido con motivo/submotivo', async () => { prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_2', nombreLead: 'Luis Demo' }) await svc.descartar('loc_2', 'Precio', { subcategoria: 'Enganche', comentario: 'Se le hizo caro' }) expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ where: { leadId: 'loc_2' }, data: { etapa: 'Perdido', motivo: 'Precio', submotivo: 'Enganche', pendienteSync: true }, }) - expect(payloadEncolado('DESCARTE')).toEqual({ - fuente: 'oportunidad', - motivo: 'Precio', - nombreItem: 'Luis Demo', - subcategoria: 'Enganche', - comentario: 'Se le hizo caro', - }) }) it('fuente lead: etapa terminal Descartado', async () => { @@ -231,7 +189,7 @@ describe('CrmLocalService.convertirLead', () => { datosJson: '{"notas":null}', } - it('marca el lead Calificado, crea la oportunidad en Agendo Cita, liga y encola CONVERSION', async () => { + it('marca el lead Calificado, crea la oportunidad en Agendo Cita y la liga', async () => { prismaMock.leadCaptura.findUnique.mockResolvedValue(filaLead) const op = await svc.convertirLead('loc_lead', 'u-1') expect(op.leadId).toMatch(/^loc_/) @@ -251,12 +209,9 @@ describe('CrmLocalService.convertirLead', () => { expect(prismaMock.leadOportunidad.create).toHaveBeenCalledWith({ data: { leadId: 'loc_lead', oportunidadId: op.leadId }, }) - const payload = payloadEncolado('CONVERSION') - expect(payload['oportunidadIdLocal']).toBe(op.leadId) - expect(payload['datos']).toMatchObject({ nombre: 'Ana Prueba', estadoLabel: 'Agendo Cita', telefono: '4771234567' }) }) - it('si el lead ya fue convertido → 409 como el flujo Monday', async () => { + it('si el lead ya fue convertido → 409', async () => { prismaMock.leadCaptura.findUnique.mockResolvedValue({ ...filaLead, oportunidadId: 'loc_op_previa' }) await expect(svc.convertirLead('loc_lead')).rejects.toMatchObject({ status: 409 }) expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled() @@ -279,7 +234,7 @@ describe('CrmLocalService.apartar', () => { datosJson: '{"edad":35}', } - it('pone la oportunidad en Aparto, crea el apartado local (Pago Apartado) y encola CAMBIO_ETAPA + CREAR_APARTADO', async () => { + it('pone la oportunidad en Aparto y crea el apartado local (Pago Apartado)', async () => { prismaMock.leadCaptura.findUnique.mockResolvedValue(filaOportunidad) const apartado = await svc.apartar('loc_op', 'u-1') expect(apartado.leadId).toMatch(/^loc_/) @@ -299,13 +254,6 @@ describe('CrmLocalService.apartar', () => { expect(prismaMock.automatizacionState.upsert).toHaveBeenCalledWith( expect.objectContaining({ where: { clave: 'apartado-creado:loc_op' } }), ) - expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA', 'CREAR_APARTADO']) - expect(payloadEncolado('CREAR_APARTADO')['datos']).toMatchObject({ - clienteNombre: 'Luis Demo', - estadoLabel: 'Pago Apartado', - telefono: '6641112233', - email: 'luis@correo.com', - }) }) it('idempotente: con el dedup escrito devuelve el apartado existente sin encolar de nuevo', async () => { @@ -316,7 +264,6 @@ describe('CrmLocalService.apartar', () => { const apartado = await svc.apartar('loc_op', 'u-1') expect(apartado.leadId).toBe('loc_ap_previo') expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled() - expect(prismaMock.syncPendiente.create).not.toHaveBeenCalled() }) }) diff --git a/api-gateway/src/services/crm-local.service.ts b/api-gateway/src/services/crm-local.service.ts index 043c678..b759c0e 100644 --- a/api-gateway/src/services/crm-local.service.ts +++ b/api-gateway/src/services/crm-local.service.ts @@ -2,36 +2,20 @@ import { randomUUID } from 'node:crypto' import { AppError, NotFoundError } from '../utils/errors.js' import { claveApartadoCreado } from './automatizaciones.service.js' import { prisma } from './prisma.js' -import type { TipoSync } from './sync-monday.service.js' /** - * CRM local (modo degradado): implementa el embudo completo contra SQLite - * mientras Monday está caído y encola cada operación en SyncPendiente para que - * el sync worker la replique cuando Monday vuelva. + * CRM local: implementa el embudo completo contra SQLite (tabla LeadCaptura). * * - Entidad local del embudo: LeadCaptura (board LEADS | OPORTUNIDADES | * APARTADO + etapa). Ids locales: `loc_` (ver `esIdLocal`). - * - Las operaciones que tocan varias tablas van en $transaction; el encolado - * viaja en la MISMA transacción para que nunca quede un cambio local sin su - * réplica pendiente. + * - Las operaciones que tocan varias tablas van en $transaction. * - SECUENCIAL a propósito (SQLite P1008): nada de Promise.all con escrituras. - * - * Los controllers rutean aquí cuando `saludMonday.estaDisponible()` es false, - * cuando el id es `loc_*` (siempre local hasta que se sincronice) o como - * fallback tras un AppError 502 de Monday. */ /** Board local del embudo (columna LeadCaptura.board) */ export type BoardLocal = 'LEADS' | 'OPORTUNIDADES' | 'APARTADO' -/** Board local → valor del payload CAMBIO_ETAPA (boards de config.monday.boards) */ -const BOARD_PAYLOAD: Record = { - LEADS: 'leads', - OPORTUNIDADES: 'oportunidades', - APARTADO: 'apartado', -} - -/** true si el id es local (aún no existe en Monday) */ +/** true si el id es local (`loc_`) en lugar de un id heredado del CRM anterior */ export function esIdLocal(id: string): boolean { return id.startsWith('loc_') } @@ -40,14 +24,6 @@ function idLocal(): string { return `loc_${randomUUID()}` } -const hoyMas = (dias: number): string => { - const d = new Date() - d.setDate(d.getDate() + dias) - const m = String(d.getMonth() + 1).padStart(2, '0') - const dia = String(d.getDate()).padStart(2, '0') - return `${d.getFullYear()}-${m}-${dia}` -} - /** Captura rápida de lead (forma corta de /hoy) */ export interface DatosLeadLocal { nombre: string @@ -93,50 +69,10 @@ export interface ResultadoBusquedaLocal { modeloInteres: string } -/** - * Crea un registro en la cola SyncPendiente. Es el ÚNICO punto de encolado del - * modo local: los handlers del worker (sync-handlers.ts) consumen `tipo` + - * `payloadJson` y actualizan los ids Monday usando `referenciaLocal`. - */ -export async function encolarSync( - tipo: TipoSync, - payload: Record, - referenciaLocal?: string | null, -): Promise { - await prisma.syncPendiente.create({ - data: { - tipo, - payloadJson: JSON.stringify(payload), - referenciaLocal: referenciaLocal ?? null, - }, - }) -} - -/** - * Versión best-effort para las réplicas que hoy solo se loguean (alta cliente, - * cotización, checkbox de documento, liquidación): un fallo al encolar NUNCA - * rompe el flujo del caller. - */ -export async function encolarSyncSeguro( - tipo: TipoSync, - payload: Record, - referenciaLocal?: string | null, -): Promise { - try { - await encolarSync(tipo, payload, referenciaLocal) - } catch (err) { - // eslint-disable-next-line no-console - console.error(`[sync] no se pudo encolar ${tipo}:`, err) - } -} - export class CrmLocalService { /** - * Captura rápida local: LeadCaptura board=LEADS etapa 'NUEVO' + CREAR_LEAD - * encolado. Dedup por teléfono entre los leads locales (mismo contrato 409 - * que el flujo Monday). Con usuarioId también se encola la auto-asignación - * del capturista (ASIGNAR_PERSONA) para que quede como su vendedor al - * sincronizarse. + * Captura rápida local: LeadCaptura board=LEADS etapa 'NUEVO'. Dedup por + * teléfono y email entre los leads locales (mismo contrato 409 de siempre). */ async crearLead(datos: DatosLeadLocal, usuarioId?: string): Promise { // Dedup por teléfono (normalizando: últimos 10 dígitos) @@ -157,111 +93,52 @@ export class CrmLocalService { if (existenteEmail) return { duplicado: { id: existenteEmail.leadId, nombre: existenteEmail.nombreLead } } } const id = idLocal() - const datosMonday = { - nombre: datos.nombre, - telefono: datos.telefono, - email: datos.email, - origenLabel: datos.origen, - desarrolloLabel: datos.desarrollo, - notas: datos.notas, - } - const operaciones = [ - prisma.leadCaptura.create({ - data: { - leadId: id, - oportunidadId: null, // los leads no generan oportunidad - usuarioId: usuarioId ?? 'desconocido', - nombreLead: datos.nombre, - telefono: datos.telefono ?? null, - email: datos.email ?? null, - origen: datos.origen ?? null, - desarrollo: datos.desarrollo ?? null, - board: 'LEADS', - etapa: 'NUEVO', - datosJson: JSON.stringify({ notas: datos.notas ?? null }), - pendienteSync: true, - }, - }), - prisma.syncPendiente.create({ - data: { tipo: 'CREAR_LEAD', payloadJson: JSON.stringify({ datos: datosMonday }), referenciaLocal: id }, - }), - ] - if (usuarioId !== undefined) { - operaciones.push( - prisma.syncPendiente.create({ - data: { - tipo: 'ASIGNAR_PERSONA', - payloadJson: JSON.stringify({ board: 'leads', usuarioId }), - referenciaLocal: id, - }, - }), - ) - } - await prisma.$transaction(operaciones) - return { id, oportunidadId: null } + const fila = await prisma.leadCaptura.create({ + data: { + leadId: id, + oportunidadId: null, // los leads no generan oportunidad + usuarioId: usuarioId ?? 'desconocido', + nombreLead: datos.nombre, + telefono: datos.telefono ?? null, + email: datos.email ?? null, + origen: datos.origen ?? null, + desarrollo: datos.desarrollo ?? null, + board: 'LEADS', + etapa: 'NUEVO', + datosJson: JSON.stringify({ notas: datos.notas ?? null }), + pendienteSync: true, + }, + }) + return { id: fila.leadId, oportunidadId: null } } /** * Alta Afluencia local. Sin oportunidadId: LeadCaptura board=OPORTUNIDADES * etapa 'Afluencia' con el perfilamiento en datosJson (además de los campos - * planos) + auto-mapping LeadOportunidad + CREAR_AFLUENCIA encolado. Con - * oportunidadId (botón "Afluencia" de Mis oportunidades): actualiza la fila - * local si existe y encola ACTUALIZAR_AFLUENCIA (el id puede ser de Monday). + * planos) + auto-mapping LeadOportunidad. Con oportunidadId (botón + * "Afluencia" de Mis oportunidades): actualiza la fila local si existe. */ async crearAfluencia(datos: DatosAfluenciaLocal, usuarioId?: string): Promise { - const { oportunidadId, modelo, medioContacto, edad, estadoCivil, tipoEmpleo, ocupacion } = datos - const { rangoSalarial, credito, tipoEconomia, califica, loQuierePara } = datos - const datosMonday = { - nombre: datos.nombre, - telefono: datos.telefono, - email: datos.email, - desarrolloLabel: datos.desarrollo, - origenLabel: datos.origen, - modeloLabel: modelo, - medioContacto, - edad, - estadoCivilLabel: estadoCivil, - tipoEmpleoLabel: tipoEmpleo, - ocupacion, - rangoSalarial, - creditoLabel: credito, - tipoEconomiaLabel: tipoEconomia, - calificaLabel: califica, - loQuierePara, - notas: datos.notas, - } + const { oportunidadId, modelo } = datos if (oportunidadId !== undefined) { const existente = await prisma.leadCaptura.findUnique({ where: { leadId: oportunidadId } }) - const operaciones = [] if (existente) { - operaciones.push( - prisma.leadCaptura.update({ - where: { leadId: oportunidadId }, - data: { - nombreLead: datos.nombre, - telefono: datos.telefono ?? null, - email: datos.email ?? null, - origen: datos.origen ?? null, - desarrollo: datos.desarrollo ?? null, - modelo: modelo ?? null, - etapa: 'Afluencia', - datosJson: JSON.stringify(datos), - pendienteSync: true, - }, - }), - ) - } - operaciones.push( - prisma.syncPendiente.create({ + await prisma.leadCaptura.update({ + where: { leadId: oportunidadId }, data: { - tipo: 'ACTUALIZAR_AFLUENCIA', - payloadJson: JSON.stringify({ oportunidadId, datos: datosMonday }), - referenciaLocal: oportunidadId, + nombreLead: datos.nombre, + telefono: datos.telefono ?? null, + email: datos.email ?? null, + origen: datos.origen ?? null, + desarrollo: datos.desarrollo ?? null, + modelo: modelo ?? null, + etapa: 'Afluencia', + datosJson: JSON.stringify(datos), + pendienteSync: true, }, - }), - ) - await prisma.$transaction(operaciones) + }) + } return { id: oportunidadId, oportunidadId, actualizada: true } } @@ -273,7 +150,7 @@ export class CrmLocalService { } const id = idLocal() - const operaciones = [ + await prisma.$transaction([ prisma.leadCaptura.create({ data: { leadId: id, @@ -291,33 +168,17 @@ export class CrmLocalService { pendienteSync: true, }, }), - // Auto-mapping leadId = oportunidadId (como el flujo Monday): el alta de - // clientes encuentra así la oportunidad a mover a 'Aparto' + // Auto-mapping leadId = oportunidadId: el alta de clientes encuentra así + // la oportunidad a mover a 'Aparto' prisma.leadOportunidad.create({ data: { leadId: id, oportunidadId: id } }), - prisma.syncPendiente.create({ - data: { tipo: 'CREAR_AFLUENCIA', payloadJson: JSON.stringify({ datos: datosMonday }), referenciaLocal: id }, - }), - ] - if (usuarioId !== undefined) { - operaciones.push( - prisma.syncPendiente.create({ - data: { - tipo: 'ASIGNAR_PERSONA', - payloadJson: JSON.stringify({ board: 'oportunidades', usuarioId }), - referenciaLocal: id, - }, - }), - ) - } - await prisma.$transaction(operaciones) + ]) return { id, oportunidadId: id } } /** * Fila local del item, creándola "sombra" si no existe y `sombra` es true. - * Las sombras cubren el fallback 502 sobre ids de Monday antiguos (creados - * antes del modo local): la acción queda reflejada en Mi día y el cambio se - * encola igual. Sin `sombra` → 404. + * Las sombras cubren ids heredados del CRM anterior sin fila local: la + * acción queda reflejada en Mi día. Sin `sombra` → 404. */ private async filaLocal(itemId: string, board: BoardLocal, usuarioId: string | undefined, sombra: boolean) { const row = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } }) @@ -327,7 +188,7 @@ export class CrmLocalService { data: { leadId: itemId, usuarioId: usuarioId ?? 'desconocido', - nombreLead: 'Registro previo (Monday)', + nombreLead: 'Registro previo (CRM anterior)', board, etapa: 'NUEVO', mondayItemId: esIdLocal(itemId) ? null : itemId, @@ -338,8 +199,8 @@ export class CrmLocalService { /** * Cambio de estado/etapa local (boards LEADS, OPORTUNIDADES y APARTADO): - * actualiza `etapa` (+updatedAt vía Prisma) y encola CAMBIO_ETAPA con el - * label real de la columna 'Estado' (y la fecha de la cita si aplica). + * actualiza `etapa` (+updatedAt vía Prisma) y guarda la fecha de la cita en + * datosJson si aplica. */ async cambiarEstado( itemId: string, @@ -348,8 +209,6 @@ export class CrmLocalService { opciones: { fecha?: string | undefined; usuarioId?: string | undefined; sombra?: boolean | undefined } = {}, ): Promise { await this.filaLocal(itemId, board, opciones.usuarioId, opciones.sombra ?? false) - const payload: Record = { board: BOARD_PAYLOAD[board], label } - if (opciones.fecha !== undefined) payload['fecha'] = opciones.fecha // Guardar fechaCita en datosJson para que la oportunidad la muestre en el calendario const updateData: Record = { etapa: label, pendienteSync: true } if (opciones.fecha !== undefined && board === 'OPORTUNIDADES') { @@ -361,21 +220,15 @@ export class CrmLocalService { datos['fechaCita'] = opciones.fecha updateData['datosJson'] = JSON.stringify(datos) } - await prisma.$transaction([ - prisma.leadCaptura.update({ - where: { leadId: itemId }, - data: updateData, - }), - prisma.syncPendiente.create({ - data: { tipo: 'CAMBIO_ETAPA', payloadJson: JSON.stringify(payload), referenciaLocal: itemId }, - }), - ]) + await prisma.leadCaptura.update({ + where: { leadId: itemId }, + data: updateData, + }) } /** * Descarte local: etapa terminal ('Descartado' en Leads, 'Perdido' en - * Oportunidades), guarda motivo/submotivo y encola DESCARTE (el handler - * replica el estado + el item del tablero Motivos No Compra). + * Oportunidades); guarda motivo/submotivo. */ async descartar( itemId: string, @@ -392,31 +245,22 @@ export class CrmLocalService { const board: BoardLocal = fuente === 'lead' ? 'LEADS' : 'OPORTUNIDADES' const row = await this.filaLocal(itemId, board, opciones.usuarioId, opciones.sombra ?? false) const etapaTerminal = fuente === 'lead' ? 'Descartado' : 'Perdido' - const payload: Record = { fuente, motivo, nombreItem: row.nombreLead } - if (opciones.subcategoria !== undefined) payload['subcategoria'] = opciones.subcategoria - if (opciones.comentario !== undefined) payload['comentario'] = opciones.comentario - await prisma.$transaction([ - prisma.leadCaptura.update({ - where: { leadId: itemId }, - data: { - etapa: etapaTerminal, - motivo, - submotivo: opciones.subcategoria ?? null, - pendienteSync: true, - }, - }), - prisma.syncPendiente.create({ - data: { tipo: 'DESCARTE', payloadJson: JSON.stringify(payload), referenciaLocal: itemId }, - }), - ]) + await prisma.leadCaptura.update({ + where: { leadId: itemId }, + data: { + etapa: etapaTerminal, + motivo, + submotivo: opciones.subcategoria ?? null, + pendienteSync: true, + }, + }) } /** * Conversión local lead → oportunidad (botón "Agendo" de Mis leads): el lead * queda 'Calificado', se crea la oportunidad local en 'Agendo Cita' copiando - * los datos, se liga LeadOportunidad + `oportunidadId` en la captura y se - * encola CONVERSION. Si el lead ya fue convertido → 409 (como el flujo - * Monday). Devuelve la fila local de la oportunidad. + * los datos y se liga LeadOportunidad + `oportunidadId` en la captura. Si el + * lead ya fue convertido → 409. Devuelve la fila local de la oportunidad. */ async convertirLead(leadIdLocal: string, _usuarioId?: string) { const lead = await prisma.leadCaptura.findUnique({ where: { leadId: leadIdLocal } }) @@ -424,15 +268,6 @@ export class CrmLocalService { if (lead.oportunidadId !== null) throw new AppError(409, 'El lead ya fue convertido en oportunidad') const oportunidadId = idLocal() - const datosOportunidad = { - nombre: lead.nombreLead, - estadoLabel: 'Agendo Cita', - telefono: lead.telefono ?? undefined, - email: lead.email ?? undefined, - origenLabel: lead.origen ?? undefined, - desarrolloLabel: lead.desarrollo ?? undefined, - modeloLabel: lead.modelo ?? undefined, - } const resultados = await prisma.$transaction([ prisma.leadCaptura.update({ where: { leadId: leadIdLocal }, @@ -456,13 +291,6 @@ export class CrmLocalService { }, }), prisma.leadOportunidad.create({ data: { leadId: leadIdLocal, oportunidadId } }), - prisma.syncPendiente.create({ - data: { - tipo: 'CONVERSION', - payloadJson: JSON.stringify({ oportunidadIdLocal: oportunidadId, datos: datosOportunidad }), - referenciaLocal: leadIdLocal, - }, - }), ]) return resultados[1] } @@ -471,11 +299,10 @@ export class CrmLocalService { * "Aparto" local: la oportunidad pasa a etapa 'Aparto' y se crea el apartado * local (board=APARTADO, etapa 'Pago Apartado', primer paso del pipeline * Enkontrol) con los datos conocidos. Idempotente con el mismo dedup - * `apartado-creado:` que las automatizaciones. Encola CAMBIO_ETAPA + - * CREAR_APARTADO (en ese orden: la etapa primero, como en Monday). + * `apartado-creado:` de las automatizaciones. */ async apartar(oportunidadIdLocal: string, usuarioId?: string) { - // Sombra para ids Monday sin fila local (fallback 502 sobre items viejos) + // Sombra para ids heredados sin fila local const oportunidad = await this.filaLocal( oportunidadIdLocal, 'OPORTUNIDADES', @@ -491,15 +318,6 @@ export class CrmLocalService { } const apartadoId = idLocal() - const datosApartado = { - clienteNombre: oportunidad.nombreLead, - unidadCodigo: '', // la unidad se captura en la cotización/alta - fechaApartado: hoyMas(0), - fechaLimite: hoyMas(15), - estadoLabel: 'Pago Apartado', - telefono: oportunidad.telefono ?? undefined, - email: oportunidad.email ?? undefined, - } const resultados = await prisma.$transaction([ prisma.leadCaptura.update({ where: { leadId: oportunidadIdLocal }, @@ -525,28 +343,13 @@ export class CrmLocalService { update: { valor: apartadoId }, create: { clave, valor: apartadoId }, }), - prisma.syncPendiente.create({ - data: { - tipo: 'CAMBIO_ETAPA', - payloadJson: JSON.stringify({ board: 'oportunidades', label: 'Aparto' }), - referenciaLocal: oportunidadIdLocal, - }, - }), - prisma.syncPendiente.create({ - data: { - tipo: 'CREAR_APARTADO', - payloadJson: JSON.stringify({ datos: datosApartado }), - referenciaLocal: apartadoId, - }, - }), ]) return resultados[1] } /** * Búsqueda local para el picker del alta de clientes (y el buscador del - * cotizador): LeadCaptura por nombre/teléfono + Cliente por nombre/celular, - * con la misma forma de respuesta que la búsqueda Monday. + * cotizador): LeadCaptura por nombre/teléfono + Cliente por nombre/celular. */ async buscarLocales(termino: string): Promise { const capturas = await prisma.leadCaptura.findMany({ diff --git a/api-gateway/src/services/etapas.ts b/api-gateway/src/services/etapas.ts index 1d7b841..a24f719 100644 --- a/api-gateway/src/services/etapas.ts +++ b/api-gateway/src/services/etapas.ts @@ -55,7 +55,7 @@ const ALIAS: Record = { } /** - * Normaliza una etapa cruda (maestro ERP o label de Monday) al label oficial. + * Normaliza una etapa cruda (maestro ERP o label del CRM anterior) al label oficial. * Devuelve null si está vacía o no es una etapa del pipeline (p. ej. DISPONIBLE). */ export function normalizarEtapa(cruda: string | null | undefined): EtapaPipeline | null { diff --git a/api-gateway/src/services/funnel.service.test.ts b/api-gateway/src/services/funnel.service.test.ts deleted file mode 100644 index 7f02ee2..0000000 --- a/api-gateway/src/services/funnel.service.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { FunnelService, type LeadOportunidadStore } from './funnel.service.js' -import type { LeadMonday, OportunidadMonday } from './monday.service.js' - -/** Monday mockeado: registra cambios de columna y devuelve búsquedas controladas */ -function mondayMock( - opts: { leadPorTelefono?: LeadMonday | null; oportunidadPorTelefono?: OportunidadMonday | null } = {}, -) { - return { - cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[], - async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) { - this.cambios.push({ boardId, itemId, columna, valor }) - }, - async buscarLeadPorTelefono(_telefono: string) { - return opts.leadPorTelefono ?? null - }, - async buscarOportunidadPorTelefono(_telefono: string) { - return opts.oportunidadPorTelefono ?? null - }, - } -} - -/** Store en memoria (equivalente al LeadOportunidadStore de Prisma) */ -function storeMock(): LeadOportunidadStore & { mapping: Map } { - const mapping = new Map() - return { - mapping, - async buscarPorLeadId(leadId: string) { - return mapping.get(leadId) ?? null - }, - async buscarPorOportunidadId(oportunidadId: string) { - for (const opId of mapping.values()) { - if (opId === oportunidadId) return opId - } - return null - }, - async guardar(leadId: string, oportunidadId: string) { - mapping.set(leadId, oportunidadId) - }, - } -} - -const leadMonday = (overrides: Partial): LeadMonday => ({ - id: 'lead-1', - nombre: 'Lead Prueba', - createdAt: '', - updatedAt: '', - estado: 'NUEVO', - origen: '', - telefono: '', - email: '', - vendedorIds: [], - ...overrides, -}) - -const oportunidadMonday = (overrides: Partial): OportunidadMonday => ({ - id: 'op-1', - nombre: 'Cliente Prueba', - createdAt: '', - updatedAt: '', - etapa: 'Afluencia', - unidadCodigo: '', - precioFinal: 0, - asesorIds: [], - telefono: '4771234567', - email: '', - origen: '', - ...overrides, -}) - -describe('FunnelService.moverOportunidadDeLead', () => { - it('mueve la oportunidad mapeada a la etapa indicada (board 555 en tests)', async () => { - const monday = mondayMock() - const store = storeMock() - store.mapping.set('lead-1', 'op-9') - const funnel = new FunnelService(monday, store) - await expect(funnel.moverOportunidadDeLead('lead-1', 'Agendo Cita')).resolves.toBe(true) - expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-9', columna: 'Estado', valor: 'Agendo Cita' }]) - }) - - it('sin mapping es no-op (false, sin llamadas a Monday)', async () => { - const monday = mondayMock() - const funnel = new FunnelService(monday, storeMock()) - await expect(funnel.moverOportunidadDeLead('lead-sin-op', 'Perdido')).resolves.toBe(false) - expect(monday.cambios).toEqual([]) - }) -}) - -describe('FunnelService.oportunidadPorTelefono', () => { - it('resuelve lead histórico por teléfono y devuelve la oportunidad mapeada', async () => { - const monday = mondayMock({ leadPorTelefono: leadMonday({ id: 'lead-7' }) }) - const store = storeMock() - store.mapping.set('lead-7', 'op-7') - const funnel = new FunnelService(monday, store) - await expect(funnel.oportunidadPorTelefono('4771234567')).resolves.toBe('op-7') - }) - - it('sin lead histórico, busca el teléfono directo en Oportunidades (modelo nuevo)', async () => { - const monday = mondayMock({ oportunidadPorTelefono: oportunidadMonday({ id: 'op-directa' }) }) - const funnel = new FunnelService(monday, storeMock()) - await expect(funnel.oportunidadPorTelefono('4771234567')).resolves.toBe('op-directa') - }) - - it('lead histórico sin mapping cae a la búsqueda directa en Oportunidades', async () => { - const monday = mondayMock({ - leadPorTelefono: leadMonday({ id: 'sin-mapping' }), - oportunidadPorTelefono: oportunidadMonday({ id: 'op-directa' }), - }) - const funnel = new FunnelService(monday, storeMock()) - await expect(funnel.oportunidadPorTelefono('4771234567')).resolves.toBe('op-directa') - }) - - it('sin lead ni oportunidad con ese teléfono devuelve null', async () => { - const funnel = new FunnelService(mondayMock(), storeMock()) - await expect(funnel.oportunidadPorTelefono('4770000000')).resolves.toBeNull() - }) -}) - -describe('FunnelService.moverAApartoConDedup', () => { - it('escribe el dedup apartado-creado ANTES de mover la oportunidad a Aparto', async () => { - const monday = mondayMock() - const store = storeMock() - store.mapping.set('lead-1', 'op-1') - const funnel = new FunnelService(monday, store) - - const orden: string[] = [] - const state = { - get: vi.fn(async () => null), - set: vi.fn(async (clave: string) => { - orden.push(`dedup:${clave}`) - }), - } - const cambiarSpy = vi.spyOn(monday, 'cambiarValorColumna').mockImplementation(async () => { - orden.push('monday:Aparto') - }) - - const opId = await funnel.moverAApartoConDedup('lead-1', state) - expect(opId).toBe('op-1') - expect(state.set).toHaveBeenCalledWith('apartado-creado:op-1', expect.any(String)) - expect(cambiarSpy).toHaveBeenCalledWith('555', 'op-1', 'Estado', 'Aparto') - expect(orden).toEqual(['dedup:apartado-creado:op-1', 'monday:Aparto']) - }) - - it('con id de oportunidad directa (modelo nuevo) busca por oportunidadId y mueve ESA', async () => { - const monday = mondayMock() - const store = storeMock() - // Auto-mapping de la captura nueva: leadId = oportunidadId - store.mapping.set('op-77', 'op-77') - const funnel = new FunnelService(monday, store) - const state = { get: vi.fn(async () => null), set: vi.fn(async () => {}) } - - const opId = await funnel.moverAApartoConDedup('op-77', state) - expect(opId).toBe('op-77') - expect(state.set).toHaveBeenCalledWith('apartado-creado:op-77', expect.any(String)) - expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-77', columna: 'Estado', valor: 'Aparto' }]) - }) - - it('un id que es a la vez lead de otro mapping y oportunidad: gana la oportunidad directa', async () => { - const monday = mondayMock() - const store = storeMock() - store.mapping.set('lead-1', 'op-1') // liga histórica cuya oportunidad es op-1 - store.mapping.set('op-1', 'op-1') // auto-mapping de op-1 (modelo nuevo) - const funnel = new FunnelService(monday, store) - const state = { get: vi.fn(async () => null), set: vi.fn(async () => {}) } - - // 'op-1' como entrada: se resuelve como oportunidad directa - await expect(funnel.moverAApartoConDedup('op-1', state)).resolves.toBe('op-1') - // 'lead-1' como entrada: se resuelve vía la liga histórica - await expect(funnel.moverAApartoConDedup('lead-1', state)).resolves.toBe('op-1') - }) - - it('sin mapping no escribe dedup ni mueve nada (null)', async () => { - const monday = mondayMock() - const funnel = new FunnelService(monday, storeMock()) - const state = { get: vi.fn(async () => null), set: vi.fn(async () => {}) } - await expect(funnel.moverAApartoConDedup('sin-mapping', state)).resolves.toBeNull() - expect(state.set).not.toHaveBeenCalled() - expect(monday.cambios).toEqual([]) - }) -}) diff --git a/api-gateway/src/services/funnel.service.ts b/api-gateway/src/services/funnel.service.ts deleted file mode 100644 index 4a596f6..0000000 --- a/api-gateway/src/services/funnel.service.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { config } from '../config/index.js' -import { claveApartadoCreado, type StateStore } from './automatizaciones.service.js' -import { mondayService, type MondayService } from './monday.service.js' -import { prisma } from './prisma.js' - -/** - * Funnel comercial sobre el tablero Oportunidades: - * la captura de afluencia crea la oportunidad directamente en etapa - * 'Afluencia' (ver leads.controller), y la oportunidad avanza de etapa sola - * conforme avanza el proceso (Agendo Cita, Cotizador, Aparto, Ganado / - * Perdido). - * - * La tabla LeadOportunidad (mapping lead ↔ oportunidad) queda para las ligas - * históricas (leads del tablero Leads de agosto) y como auto-mapping - * (leadId = oportunidadId) de las afluencias nuevas, para que el alta de - * clientes encuentre la oportunidad a mover a 'Aparto' en ambos esquemas. - * Los errores de Monday se propagan al caller, que decide loguear y continuar - * (la acción principal — la cotización o el alta — no se rompe). - */ - -/** Store del mapping lead ↔ oportunidad (Prisma en prod, memoria en tests) */ -export interface LeadOportunidadStore { - buscarPorLeadId(leadId: string): Promise // oportunidadId - buscarPorOportunidadId(oportunidadId: string): Promise // oportunidadId si hay mapping - guardar(leadId: string, oportunidadId: string): Promise -} - -type MondayFunnel = Pick< - MondayService, - 'cambiarValorColumna' | 'buscarLeadPorTelefono' | 'buscarOportunidadPorTelefono' -> - -export class FunnelService { - constructor( - private readonly monday: MondayFunnel, - private readonly store: LeadOportunidadStore, - ) {} - - /** Mueve la oportunidad del lead a otra etapa. Sin mapping → no-op (false). */ - async moverOportunidadDeLead(leadId: string, etapa: string): Promise { - const oportunidadId = await this.store.buscarPorLeadId(leadId) - if (oportunidadId === null) return false - await this.monday.cambiarValorColumna(config.monday.boards.oportunidades, oportunidadId, 'Estado', etapa) - return true - } - - /** Oportunidad ligada al lead (para el match de cotizaciones); null si no hay */ - async oportunidadDeLead(leadId: string): Promise { - return this.store.buscarPorLeadId(leadId) - } - - /** - * Oportunidad que tenga ese teléfono (match de cotizaciones). Convierte con - * ambos esquemas: primero el mapping histórico (lead del tablero Leads → su - * oportunidad); si no hay lead con ese número, busca el teléfono directo en - * Oportunidades (modelo nuevo: la afluencia ES la oportunidad). - */ - async oportunidadPorTelefono(telefono: string): Promise { - const lead = await this.monday.buscarLeadPorTelefono(telefono) - if (lead) { - const mapeada = await this.store.buscarPorLeadId(lead.id) - if (mapeada !== null) return mapeada - } - const directa = await this.monday.buscarOportunidadPorTelefono(telefono) - return directa?.id ?? null - } - - /** - * Alta de cliente: escribe el dedup `apartado-creado:` ANTES de mover - * la oportunidad a 'Aparto'. Este orden evita la carrera con el webhook/cron - * (escaneoOportunidades vería 'Aparto' y crearía un Apartado duplicado; con - * el dedup previo lo salta). Devuelve la oportunidadId movida o null si no - * hay mapping. - * - * El id puede ser una oportunidad directa (modelo nuevo: la afluencia ES la - * oportunidad; el picker del alta la manda con fuente 'oportunidad') o un - * lead histórico del tablero Leads: se busca primero como oportunidad y - * luego como lead. - */ - async moverAApartoConDedup(id: string, state: StateStore): Promise { - const comoOportunidad = await this.store.buscarPorOportunidadId(id) - const oportunidadId = comoOportunidad ?? (await this.store.buscarPorLeadId(id)) - if (oportunidadId === null) return null - await state.set(claveApartadoCreado(oportunidadId), new Date().toISOString()) - await this.monday.cambiarValorColumna(config.monday.boards.oportunidades, oportunidadId, 'Estado', 'Aparto') - return oportunidadId - } -} - -/** Store respaldado por la tabla LeadOportunidad de Prisma */ -export const prismaLeadOportunidadStore: LeadOportunidadStore = { - async buscarPorLeadId(leadId) { - const row = await prisma.leadOportunidad.findUnique({ where: { leadId } }) - return row?.oportunidadId ?? null - }, - async buscarPorOportunidadId(oportunidadId) { - const row = await prisma.leadOportunidad.findUnique({ where: { oportunidadId } }) - return row?.oportunidadId ?? null - }, - async guardar(leadId, oportunidadId) { - await prisma.leadOportunidad.create({ data: { leadId, oportunidadId } }) - }, -} - -/** Singleton usado por los controllers (los tests instancian el suyo) */ -export const funnelService = new FunnelService(mondayService, prismaLeadOportunidadStore) diff --git a/api-gateway/src/services/grupos-estado.service.test.ts b/api-gateway/src/services/grupos-estado.service.test.ts deleted file mode 100644 index 5753ee9..0000000 --- a/api-gateway/src/services/grupos-estado.service.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -vi.mock('../config/index.js', () => ({ - config: { - monday: { - boards: { - leads: '111', - oportunidades: '222', - contactos: '333', - unidades: '444', - apartado: '555', - }, - }, - }, -})) - -import { GRUPOS_POR_ESTADO, grupoDestino, moverAGrupoSegunEstado } from './grupos-estado.service.js' - -interface ItemFake { - id: string - name: string - groupId: string - column_values: { id: string; text: string | null; value: string | null }[] -} - -/** Monday mockeado: registra los movimientos de grupo solicitados */ -function mondayMock(opts: { item?: ItemFake | null; columnaId?: string | null } = {}) { - return { - movimientos: [] as { itemId: string; groupId: string }[], - async columnaIdPorTitulo(_boardId: string, _titulo: string) { - return opts.columnaId !== undefined ? opts.columnaId : 'status_col' - }, - async getItemConGrupo(_itemId: string) { - return opts.item ?? null - }, - async moverItemAGrupo(itemId: string, groupId: string) { - this.movimientos.push({ itemId, groupId }) - }, - } -} - -const itemCon = (groupId: string, estado: string): ItemFake => ({ - id: '999', - name: 'Item Prueba', - groupId, - column_values: [{ id: 'status_col', text: estado, value: null }], -}) - -describe('grupoDestino', () => { - it('mapea labels conocidos de cada tablero', () => { - expect(grupoDestino('111', 'NUEVO')).toBe('topics') - expect(grupoDestino('111', 'CONTACTADO')).toBe('group_mm6s1jct') - expect(grupoDestino('111', 'DESCARTADO')).toBe('group_mm6s96mc') - expect(grupoDestino('222', 'Ganado')).toBe('group_mm6scqz3') - expect(grupoDestino('222', 'Afluencia')).toBe('topics') - expect(grupoDestino('333', 'INACTIVO')).toBe('group_mm6sqqq2') - expect(grupoDestino('444', 'Notaría')).toBe('group_mm6syf9c') - expect(grupoDestino('555', 'LIBERADO')).toBe('group_mm6s2yyx') - }) - - it('label desconocido → null', () => { - expect(grupoDestino('111', 'INVENTADO')).toBeNull() - }) - - it('board sin mapeo → null', () => { - expect(grupoDestino('999', 'NUEVO')).toBeNull() - }) - - it('etapa vacía en Unidades → null (no se mueve)', () => { - expect(grupoDestino('444', '')).toBeNull() - expect(grupoDestino('444', ' ')).toBeNull() - }) - - it('los 5 tableros del CRM tienen mapeo', () => { - expect(Object.keys(GRUPOS_POR_ESTADO).sort()).toEqual(['111', '222', '333', '444', '555']) - }) -}) - -describe('moverAGrupoSegunEstado', () => { - it('mueve el item cuando el grupo actual difiere del destino', async () => { - const monday = mondayMock({ item: itemCon('topics', 'DESCARTADO') }) - const r = await moverAGrupoSegunEstado(monday, '111', '999') - expect(r).toEqual({ movido: true, grupo: 'group_mm6s96mc' }) - expect(monday.movimientos).toEqual([{ itemId: '999', groupId: 'group_mm6s96mc' }]) - }) - - it('no mueve cuando el item ya está en el grupo destino', async () => { - const monday = mondayMock({ item: itemCon('group_mm6s96mc', 'DESCARTADO') }) - const r = await moverAGrupoSegunEstado(monday, '111', '999') - expect(r).toEqual({ movido: false }) - expect(monday.movimientos).toEqual([]) - }) - - it('item inexistente (borrado tras el webhook) no truena ni mueve', async () => { - const monday = mondayMock({ item: null }) - const r = await moverAGrupoSegunEstado(monday, '111', '999') - expect(r).toEqual({ movido: false }) - expect(monday.movimientos).toEqual([]) - }) - - it('board sin mapeo no consulta ni mueve nada', async () => { - const monday = mondayMock({ item: itemCon('topics', 'NUEVO') }) - const r = await moverAGrupoSegunEstado(monday, '999', '999') - expect(r).toEqual({ movido: false }) - expect(monday.movimientos).toEqual([]) - }) - - it('label no mapeado no mueve', async () => { - const monday = mondayMock({ item: itemCon('topics', 'INVENTADO') }) - const r = await moverAGrupoSegunEstado(monday, '111', '999') - expect(r).toEqual({ movido: false }) - expect(monday.movimientos).toEqual([]) - }) -}) diff --git a/api-gateway/src/services/grupos-estado.service.ts b/api-gateway/src/services/grupos-estado.service.ts deleted file mode 100644 index e35d758..0000000 --- a/api-gateway/src/services/grupos-estado.service.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { config } from '../config/index.js' -import type { MondayService } from './monday.service.js' - -/** - * Automatización "cambio de estado → mover item al grupo correcto". - * - * Regla de negocio: cada tablero del CRM tiene grupos que representan la etapa - * del pipeline (p. ej. Leads nuevos / Leads Progreso / Leads Descalificado) y - * una columna status que manda (en Unidades es 'Etapa', no 'Estado'). Cuando el - * status cambia, el item debe moverse al grupo que corresponde a su nuevo - * estado; el webhook de change_column_value solo dispara y este servicio - * re-consulta el item en Monday antes de mover (el payload no es confiable). - * - * Labels verificados contra Monday el 2026-09-02; se comparan tal cual los - * devuelve column_values.text. El script scripts/mover-a-grupos.py duplica este - * mapeo para el backfill de items existentes (mantener ambos sincronizados). - */ -export const GRUPOS_POR_ESTADO: Record }> = { - // Leads: nuevo → "Leads nuevos"; en seguimiento → "Leads Progreso"; - // descartado/abandono → "Leads Descalificado" - [config.monday.boards.leads]: { - columna: 'Estado', - destinos: { - NUEVO: 'topics', - 'Intento de contacto': 'group_mm6s1jct', - CONTACTADO: 'group_mm6s1jct', - Interesado: 'group_mm6s1jct', - 'AGENDA CITA': 'group_mm6s1jct', - CALIFICADO: 'group_mm6s1jct', - DESCARTADO: 'group_mm6s96mc', - Abandono: 'group_mm6s96mc', - }, - }, - // Oportunidades: ganada → "Ganados"; perdida/abandono/no asistió → - // "Perdidos"; cualquier etapa activa del funnel → "Oportunidad" - [config.monday.boards.oportunidades]: { - columna: 'Estado', - destinos: { - Ganado: 'group_mm6scqz3', - GANADA: 'group_mm6scqz3', - Perdido: 'group_mm6s4cgq', - PERDIDA: 'group_mm6s4cgq', - Abandono: 'group_mm6s4cgq', - 'No Asistio': 'group_mm6s4cgq', - Afluencia: 'topics', - Contactado: 'topics', - 'Intento de contacto': 'topics', - Calificado: 'topics', - Cotizador: 'topics', - 'Agendo Cita': 'topics', - Aparto: 'topics', - COTIZADA: 'topics', - ENVIADA: 'topics', - NEGOCIANDO: 'topics', - }, - }, - // Contactos: prospecto/cliente → "Contactos activos"; inactivo → inactivos - [config.monday.boards.contactos]: { - columna: 'Estado', - destinos: { - PROSPECTO: 'topics', - CLIENTE: 'topics', - INACTIVO: 'group_mm6sqqq2', - }, - }, - // Unidades: la columna que manda es 'Etapa' (pipeline de 10 etapas). - // Apartado/Reserva → "Nuevos Clientes"; en proceso de venta → "Clientes - // Activos"; escriturado en adelante → "PostVenta". Etapa vacía → NO mover. - [config.monday.boards.unidades]: { - columna: 'Etapa', - destinos: { - Apartado: 'group_mm6sn41r', - Reserva: 'group_mm6sn41r', - Vendido: 'group_mm6syf9c', - Integración: 'group_mm6syf9c', - 'En Análisis': 'group_mm6syf9c', - Autorizado: 'group_mm6syf9c', - Notaría: 'group_mm6syf9c', - Escriturado: 'group_mm6sjh7f', - Entrega: 'group_mm6sjh7f', - PostVenta: 'group_mm6sjh7f', - }, - }, - // Apartado (pipeline Enkontrol): en trámite → "Nuevos Clientes"; firma/por - // escriturar → "Clientes Activos"; escriturado/formalizado → "PostVenta"; - // cancelado/liberado → "Historico" - [config.monday.boards.apartado]: { - columna: 'Estado', - destinos: { - 'Pago Apartado': 'topics', - 'Alta Enkontrol': 'topics', - 'En Definicion': 'topics', - 'Entrego Documentacion': 'topics', - 'Cita Clientes': 'topics', - 'Contrato Enkontrol': 'topics', - VIGENTE: 'topics', - 'X Escriturar': 'group_mm6sx3rn', - Escriturado: 'group_mm6s40br', - FORMALIZADO: 'group_mm6s40br', - CANCELADO: 'group_mm6s2yyx', - Cancelacion: 'group_mm6s2yyx', - 'Cancelacion Apartado': 'group_mm6s2yyx', - LIBERADO: 'group_mm6s2yyx', - }, - }, -} - -/** - * Grupo destino para un status dado en un tablero. null si el tablero no tiene - * mapeo, si el label no está mapeado o si el status viene vacío (en Unidades - * la etapa vacía significa "sin clasificar": no se mueve). - */ -export function grupoDestino(boardId: string, statusActual: string): string | null { - const mapeo = GRUPOS_POR_ESTADO[boardId] - if (!mapeo) return null - const estado = statusActual.trim() - if (estado === '') return null - return mapeo.destinos[estado] ?? null -} - -type MondayGrupos = Pick - -/** - * Mueve un item al grupo que corresponde a su status actual, según - * GRUPOS_POR_ESTADO. Idempotente: si ya está en ese grupo no hace nada, y si el - * item ya no existe (borrado entre el webhook y la consulta) tampoco truena. - */ -export async function moverAGrupoSegunEstado( - monday: MondayGrupos, - boardId: string, - itemId: string, -): Promise<{ movido: boolean; grupo?: string }> { - const mapeo = GRUPOS_POR_ESTADO[boardId] - if (!mapeo) return { movido: false } - const item = await monday.getItemConGrupo(itemId) - if (!item) return { movido: false } - const columnaId = await monday.columnaIdPorTitulo(boardId, mapeo.columna) - if (columnaId === null) return { movido: false } - const estado = item.column_values.find((cv) => cv.id === columnaId)?.text ?? '' - const destino = grupoDestino(boardId, estado) - if (destino === null || item.groupId === destino) return { movido: false } - await monday.moverItemAGrupo(itemId, destino) - return { movido: true, grupo: destino } -} diff --git a/api-gateway/src/services/local-canal.ts b/api-gateway/src/services/local-canal.ts index 8149bae..a944bbe 100644 --- a/api-gateway/src/services/local-canal.ts +++ b/api-gateway/src/services/local-canal.ts @@ -1,18 +1,15 @@ import { actividadLocalService } from './actividad-local.service.js' -import { saludMonday } from './salud-monday.service.js' import type { CanalRecordatorio, DestinatarioRecordatorio } from './recordatorios-cobranza.service.js' /** - * Canal local para recordatorios de cobranza. - * Crea ActividadLocal en vez de Actividad en Monday. - * Se usa cuando Monday no está disponible (modo local). + * Canal local para recordatorios de cobranza: crea una ActividadLocal + * (RECORDATORIO) para el vendedor responsable del plan. */ export class LocalCanal implements CanalRecordatorio { readonly nombre = 'local-actividad' disponible(_destinatario: DestinatarioRecordatorio): boolean { - // El canal local está disponible cuando Monday NO está disponible - return !saludMonday.estaDisponible() + return true } async enviar( @@ -23,7 +20,7 @@ export class LocalCanal implements CanalRecordatorio { const actividad = await actividadLocalService.crearActividad({ titulo: mensaje.length > 250 ? `${mensaje.slice(0, 247)}...` : mensaje, descripcion: mensaje, - usuarioId: 'sistema', + usuarioId: destinatario.vendedorId ?? 'sistema', leadCapturaId: null, fechaVencimiento: new Date(), tipo: 'RECORDATORIO', diff --git a/api-gateway/src/services/mi-dia-local.test.ts b/api-gateway/src/services/mi-dia-local.test.ts index bc375f1..2b6cd93 100644 --- a/api-gateway/src/services/mi-dia-local.test.ts +++ b/api-gateway/src/services/mi-dia-local.test.ts @@ -1,25 +1,23 @@ import { describe, expect, it } from 'vitest' import { resumenMiDiaLocal, - unirPendientesLocales, type ActividadLocalMiDia, type ClienteLocalMiDia, type FilaLocalMiDia, - type ResumenMiDia, type UsuarioLocal, type UsuarioMiDia, } from './mi-dia.service.js' /** - * Mi día en modo local (Monday caído) y la unión durante la transición - * (Monday vivo + capturas locales pendientes de sincronizar). + * Mi día 100% local: resumen construido desde LeadCaptura, Cliente y + * ActividadLocal. */ const AHORA = new Date('2026-09-07T12:00:00') -const vendedor: UsuarioMiDia = { nombre: 'Martin Zavala', rol: 'vendedor', mondayUserId: 123 } -const coordinador: UsuarioMiDia = { nombre: 'Coord General', rol: 'coordinador', mondayUserId: 456 } -const sinNombre: UsuarioMiDia = { nombre: ' ', rol: 'vendedor', mondayUserId: null } +const vendedor: UsuarioMiDia = { nombre: 'Martin Zavala', rol: 'vendedor' } +const coordinador: UsuarioMiDia = { nombre: 'Coord General', rol: 'coordinador' } +const sinNombre: UsuarioMiDia = { nombre: ' ', rol: 'vendedor' } let consecutivo = 0 function fila(parcial: Partial & { leadId: string }): FilaLocalMiDia { @@ -55,7 +53,7 @@ function cliente(parcial: Partial & { id: string }): ClienteL } } -describe('resumenMiDiaLocal (Monday caído)', () => { +describe('resumenMiDiaLocal', () => { it('arma el ResumenMiDia completo desde LeadCaptura por board, sin terminales', () => { const capturas = [ fila({ leadId: 'loc_l1', board: 'LEADS', etapa: 'NUEVO' }), @@ -67,10 +65,9 @@ describe('resumenMiDiaLocal (Monday caído)', () => { ] const r = resumenMiDiaLocal(vendedor, capturas, [], AHORA) - expect(r.sinMapeoMonday).toBe(false) expect(r.misLeads.map((l) => l.id).sort()).toEqual(['loc_l1']) // solo leads, no oportunidades expect(r.misLeads.find((l) => l.id === 'loc_l1')?.fuente).toBe('lead') - // porEtapa cuenta TODAS las oportunidades (incluye terminales, como Monday) + // porEtapa cuenta TODAS las oportunidades (incluye terminales) expect(r.misOportunidades.porEtapa).toEqual({ Afluencia: 1, Perdido: 1 }) expect(r.misOportunidades.items.map((o) => o.id)).toEqual(['loc_o1']) expect(r.misOportunidades.items[0]).toMatchObject({ etapa: 'Afluencia', desarrollo: 'SENDA', modeloInteres: '1R' }) @@ -94,7 +91,6 @@ describe('resumenMiDiaLocal (Monday caído)', () => { const rSinNombre = resumenMiDiaLocal(sinNombre, capturas, [], AHORA) expect(rSinNombre.misLeads[0]?.ultimoCambio?.usuario).toBe('app') - expect(rSinNombre.sinMapeoMonday).toBe(true) }) it('la unidad del apartado sale del Cliente ligado; estadoApartado de Mis clientes cruza con el apartado local', () => { @@ -193,9 +189,9 @@ describe('resumenMiDiaLocal (Monday caído)', () => { const porVendedor = r.equipo!.porVendedor expect(porVendedor).toHaveLength(2) // Martin: 1 lead activo (loc_l1), 2 leads nuevos semana (loc_l1 y loc_l2 ambos en semana) - expect(porVendedor.find((v) => v.nombre === 'Martin Zavala')).toMatchObject({ mondayUserId: 0, leadsAsignados: 1, leadsNuevosSemana: 2 }) + expect(porVendedor.find((v) => v.nombre === 'Martin Zavala')).toMatchObject({ usuarioId: 'u1', leadsAsignados: 1, leadsNuevosSemana: 2 }) // Ana: 1 lead activo (loc_l3, 1 día atrás = semana), 1 oportunidad no cuenta en leads - expect(porVendedor.find((v) => v.nombre === 'Ana Perez')).toMatchObject({ mondayUserId: 0, leadsAsignados: 1, leadsNuevosSemana: 1 }) + expect(porVendedor.find((v) => v.nombre === 'Ana Perez')).toMatchObject({ usuarioId: 'u2', leadsAsignados: 1, leadsNuevosSemana: 1 }) }) it('equipo no se genera para vendedor', () => { @@ -206,97 +202,3 @@ describe('resumenMiDiaLocal (Monday caído)', () => { expect(r.equipo).toBeUndefined() }) }) - -// ---------- Unión (Monday vivo + pendientes locales) ---------- - -function resumenBase(): ResumenMiDia { - return { - generadoEn: AHORA.toISOString(), - sinMapeoMonday: false, - misLeads: [ - { - id: '9001', - nombre: 'Lead Monday', - telefono: '', - email: '', - estado: 'Nuevo', - origen: 'Web', - updatedAt: '2026-09-04T10:00:00.000Z', - fuente: 'lead', - ultimoCambio: null, - }, - ], - misCapturas: [ - { leadId: 'loc_n1', nombre: 'Cap Local', telefono: '', estado: '', origen: '', capturadoEn: '2026-09-03T10:00:00.000Z' }, - { leadId: '9001', nombre: 'Lead Monday', telefono: '', estado: 'Nuevo', origen: 'Web', capturadoEn: '2026-09-02T10:00:00.000Z' }, - ], - misClientes: [], - misActividadesHoy: [], - apartadosPorVencer: [], - misOportunidades: { - porEtapa: { Afluencia: 1 }, - items: [ - { - id: '9002', - nombre: 'Op Monday', - telefono: '', - email: '', - etapa: 'Afluencia', - desarrollo: '', - modeloInteres: '', - unidadCodigo: '', - updatedAt: '2026-09-04T09:00:00.000Z', - ultimoCambio: null, - }, - ], - }, - misApartados: [ - { id: '9003', nombre: 'Apt Monday', unidadCodigo: 'U-1', estado: 'Pago Apartado', fechaLimite: '2026-09-10', ultimoCambio: null }, - ], - conteos: { leadsNuevosSemana: 1, actividadesAbiertas: 0 }, - } -} - -describe('unirPendientesLocales (Monday vivo)', () => { - it('agrega los items loc_* a la lista de su board con pendienteSync y re-ordena', () => { - const pendientes = [ - fila({ leadId: 'loc_n1', board: 'LEADS', etapa: 'NUEVO', updatedAt: new Date('2026-09-05T11:00:00') }), - fila({ leadId: 'loc_n2', board: 'OPORTUNIDADES', etapa: 'Afluencia', updatedAt: new Date('2026-09-05T10:00:00') }), - fila({ leadId: 'loc_n3', board: 'APARTADO', etapa: 'Pago Apartado' }), - fila({ leadId: 'loc_n4', board: 'LEADS', etapa: 'Descartado' }), // terminal: no se agrega - ] - const r = unirPendientesLocales(resumenBase(), pendientes, 'Martin Zavala') - - // loc_n1 es más reciente que el de Monday: queda primero - expect(r.misLeads.map((l) => l.id)).toEqual(['loc_n1', 'loc_n2', '9001']) - expect(r.misLeads[0]).toMatchObject({ pendienteSync: true, fuente: 'lead' }) - expect(r.misLeads[1]).toMatchObject({ pendienteSync: true, fuente: 'oportunidad' }) - expect(r.misOportunidades.items.map((o) => o.id)).toEqual(['loc_n2', '9002']) - expect(r.misOportunidades.porEtapa).toEqual({ Afluencia: 2 }) - expect(r.misApartados.map((a) => a.id)).toEqual(['9003', 'loc_n3']) // sin fecha límite al final - expect(r.misApartados[1]).toMatchObject({ pendienteSync: true }) - }) - - it('fila con mondayItemId (cambio pendiente sobre item Monday): no duplica, solo marca el existente', () => { - const pendientes = [ - fila({ leadId: '9001', board: 'LEADS', etapa: 'Contactado', mondayItemId: '9001' }), - fila({ leadId: '9003', board: 'APARTADO', etapa: 'Documentos', mondayItemId: '9003' }), - ] - const r = unirPendientesLocales(resumenBase(), pendientes, 'Martin Zavala') - - expect(r.misLeads).toHaveLength(1) - expect(r.misLeads[0]).toMatchObject({ id: '9001', pendienteSync: true }) - expect(r.misApartados).toHaveLength(1) - expect(r.misApartados[0]).toMatchObject({ id: '9003', pendienteSync: true }) - }) - - it('marca Mis capturas y rellena el estado local cuando Monday aún no lo tiene', () => { - const pendientes = [fila({ leadId: 'loc_n1', board: 'LEADS', etapa: 'NUEVO', origen: 'WhatsApp' })] - const r = unirPendientesLocales(resumenBase(), pendientes, 'Martin Zavala') - - const captura = r.misCapturas.find((c) => c.leadId === 'loc_n1') - expect(captura).toMatchObject({ pendienteSync: true, estado: 'NUEVO', origen: 'WhatsApp' }) - // La captura ya conocida por Monday conserva su estado - expect(r.misCapturas.find((c) => c.leadId === '9001')?.estado).toBe('Nuevo') - }) -}) diff --git a/api-gateway/src/services/mi-dia.service.test.ts b/api-gateway/src/services/mi-dia.service.test.ts index af74b6e..73b6a1c 100644 --- a/api-gateway/src/services/mi-dia.service.test.ts +++ b/api-gateway/src/services/mi-dia.service.test.ts @@ -1,520 +1,9 @@ import { describe, expect, it } from 'vitest' import { - detalleEventoUltimoCambio, esEstadoFinalApartado, esEstadoTerminalLead, esEtapaTerminal, - resumenMiDia, - type UsuarioMiDia, } from './mi-dia.service.js' -import type { - ActividadMonday, - ApartadoMonday, - LeadMonday, - OportunidadMonday, - UltimoCambioMonday, -} from './monday.service.js' - -const MI_ID = 106736727 -const OTRO_ID = 111111111 - -const AHORA = new Date('2026-08-28T12:00:00') // viernes -const HOY = '2026-08-28' -const MANANA = '2026-08-29' - -function lead(parcial: Partial & { id: string }): LeadMonday { - return { - nombre: `Lead ${parcial.id}`, - createdAt: '2026-08-25T10:00:00Z', - updatedAt: '2026-08-27T10:00:00Z', - estado: 'Nuevo', - origen: 'Facebook', - telefono: '', - email: '', - vendedorIds: [MI_ID], - ...parcial, - } -} - -function oportunidad(parcial: Partial & { id: string }): OportunidadMonday { - return { - nombre: `Lead ${parcial.id}`, - createdAt: '2026-08-25T10:00:00Z', - updatedAt: '2026-08-27T10:00:00Z', - etapa: 'Afluencia', - origen: 'Facebook', - unidadCodigo: '', - precioFinal: 0, - asesorIds: [MI_ID], - telefono: '', - email: '', - ...parcial, - } -} - -function actividad(parcial: Partial & { id: string }): ActividadMonday { - return { - nombre: `Actividad ${parcial.id}`, - tipo: 'Llamada', - fecha: HOY, - estado: 'Abierto', - responsableIds: [MI_ID], - ...parcial, - } -} - -function apartado(parcial: Partial & { id: string }): ApartadoMonday { - return { - nombre: `Cliente ${parcial.id}`, - unidadCodigo: 'U-101', - fechaLimite: '2026-09-02', // dentro de 7 días - estado: 'Pago Apartado', - vendedorIds: [MI_ID], - ...parcial, - } -} - -function mondayMock(datos: { - leads?: LeadMonday[] - oportunidades?: OportunidadMonday[] - actividades?: ActividadMonday[] - apartados?: ApartadoMonday[] - /** boardId → (itemId → último cambio); boards de test: Leads '444', Oportunidades '555' */ - ultimosCambios?: Record> -}) { - return { - async getLeads() { - return datos.leads ?? [] - }, - async getOportunidades() { - return datos.oportunidades ?? [] - }, - async getActividades() { - return datos.actividades ?? [] - }, - async getApartados() { - return datos.apartados ?? [] - }, - async getUltimosCambios(boardId: string, _itemIds: string[]) { - return datos.ultimosCambios?.[boardId] ?? new Map() - }, - } -} - -const vendedor: UsuarioMiDia = { nombre: 'Martin Zavala', rol: 'vendedor', mondayUserId: MI_ID } -const sinMapeo: UsuarioMiDia = { nombre: 'Erick Peña', rol: 'vendedor', mondayUserId: null } -const coordinador: UsuarioMiDia = { nombre: 'Daniel Alvarado', rol: 'coordinador', mondayUserId: null } - -describe('resumenMiDia (GET /api/mi-dia/resumen)', () => { - it('misLeads: merge de Leads (Vendedor=mío) y Oportunidades (Asesor=mío), sin terminales, con fuente, por updatedAt desc', async () => { - const monday = mondayMock({ - leads: [ - lead({ id: 'l1', estado: 'Nuevo', updatedAt: '2026-08-26T10:00:00Z' }), - lead({ id: 'l2', estado: 'Contactado', telefono: '4771234567', updatedAt: '2026-08-28T08:00:00Z' }), - lead({ id: 'l3', estado: 'Descartado' }), // terminal Leads: fuera - lead({ id: 'l4', estado: 'Calificado' }), // terminal Leads: fuera - lead({ id: 'l5', estado: 'Nuevo', vendedorIds: [OTRO_ID] }), // de otro: fuera - ], - oportunidades: [ - oportunidad({ id: 'o1', etapa: 'Afluencia', updatedAt: '2026-08-27T10:00:00Z' }), - oportunidad({ id: 'o2', etapa: 'Calificado' }), // NO es terminal en Oportunidades - oportunidad({ id: 'o3', etapa: 'Perdido' }), - oportunidad({ id: 'o4', etapa: 'Abandono' }), - oportunidad({ id: 'o5', etapa: 'Ganado' }), - oportunidad({ id: 'o6', etapa: 'Afluencia', asesorIds: [OTRO_ID] }), - ], - }) - const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA) - expect(r.sinMapeoMonday).toBe(false) - expect(r.misLeads.map((l) => l.id)).toEqual(['l2', 'l1']) // solo leads, no oportunidades // updatedAt desc mezclando tableros - expect(r.misLeads.map((l) => l.fuente)).toEqual(['lead', 'lead']) // solo leads - expect(r.misLeads[0]?.telefono).toBe('4771234567') - }) - - it('ultimoCambio: último evento por item, agrupando ids por fuente y resolviendo el nombre', async () => { - const monday = mondayMock({ - leads: [lead({ id: 'l1', updatedAt: '2026-08-28T08:00:00Z' })], - oportunidades: [oportunidad({ id: 'o1', updatedAt: '2026-08-27T08:00:00Z' })], - ultimosCambios: { - '444': new Map([['l1', { fechaISO: '2026-08-28T10:00:00.000Z', usuarioId: MI_ID, evento: 'update_status' }]]), - '555': new Map([['o1', { fechaISO: '2026-08-28T09:00:00.000Z', usuarioId: OTRO_ID, evento: 'create_pulse' }]]), - }, - }) - const nombres = new Map([[MI_ID, 'Martin Zavala']]) // OTRO_ID no está mapeado - const r = await resumenMiDia(monday, vendedor, nombres, [], [], AHORA) - expect(r.misLeads.find((l) => l.id === 'l1')?.ultimoCambio).toEqual({ - fechaISO: '2026-08-28T10:00:00.000Z', - usuario: 'Martin Zavala', - detalle: 'Actualización', - }) - // 'o1' ya no esta en misLeads (es oportunidad) - }) - - it('ultimoCambio: null cuando el item no tiene eventos en el activity log', async () => { - const monday = mondayMock({ - leads: [lead({ id: 'l1' })], - ultimosCambios: { '444': new Map() }, // sin eventos - }) - const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA) - expect(r.misLeads[0]?.ultimoCambio).toBeNull() - }) - - it('ultimoCambio: usuarioId null (evento de automatización) → usuario null', async () => { - const monday = mondayMock({ - leads: [lead({ id: 'l1' })], - ultimosCambios: { - '444': new Map([['l1', { fechaISO: '2026-08-28T10:00:00.000Z', usuarioId: null, evento: 'move_to_group' }]]), - }, - }) - const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA) - expect(r.misLeads[0]?.ultimoCambio).toEqual({ - fechaISO: '2026-08-28T10:00:00.000Z', - usuario: null, - detalle: 'Cambio de grupo', - }) - }) - - it('detalleEventoUltimoCambio: humaniza los eventos conocidos y pasa el crudo si no', () => { - expect(detalleEventoUltimoCambio('create_pulse')).toBe('Alta') - expect(detalleEventoUltimoCambio('create_update')).toBe('Nota agregada') - expect(detalleEventoUltimoCambio('update_column_value')).toBe('Actualización') - expect(detalleEventoUltimoCambio('update_status')).toBe('Actualización') - expect(detalleEventoUltimoCambio('move_to_group')).toBe('Cambio de grupo') - expect(detalleEventoUltimoCambio('update_name')).toBe('update_name') - }) - - it('misOportunidades: conteo de MIS oportunidades por etapa (incluye terminales)', async () => { - const monday = mondayMock({ - oportunidades: [ - oportunidad({ id: '1', etapa: 'Afluencia' }), - oportunidad({ id: '2', etapa: 'Afluencia' }), - oportunidad({ id: '3', etapa: 'Ganado' }), - oportunidad({ id: '4', etapa: 'Afluencia', asesorIds: [OTRO_ID] }), // de otro: no cuenta - ], - }) - const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA) - expect(r.misOportunidades.porEtapa).toEqual({ Afluencia: 2, Ganado: 1 }) - }) - - it('misOportunidades.items: lista de trabajo sin terminales, mías, por updatedAt desc, con perfil y último cambio', async () => { - const monday = mondayMock({ - oportunidades: [ - oportunidad({ id: 'o1', etapa: 'Afluencia', updatedAt: '2026-08-27T10:00:00Z', desarrollo: 'SENDA', modeloInteres: 'SOHO' }), - oportunidad({ id: 'o2', etapa: 'Cotizador', updatedAt: '2026-08-28T08:00:00Z', telefono: '4771234567', email: 'a@b.c', unidadCodigo: 'U-9' }), - oportunidad({ id: 'o3', etapa: 'Ganado' }), // terminal: fuera de la lista (sí cuenta en porEtapa) - oportunidad({ id: 'o4', etapa: 'Abandono' }), // terminal: fuera - oportunidad({ id: 'o5', etapa: 'Afluencia', asesorIds: [OTRO_ID] }), // de otro: fuera - oportunidad({ id: 'o6', etapa: 'Aparto' }), // ya es apartado: sale de la lista (sigue en Mis apartados) - ], - ultimosCambios: { - '555': new Map([['o2', { fechaISO: '2026-08-28T09:00:00.000Z', usuarioId: MI_ID, evento: 'update_column_value' }]]), - }, - }) - const nombres = new Map([[MI_ID, 'Martin Zavala']]) - const r = await resumenMiDia(monday, vendedor, nombres, [], [], AHORA) - expect(r.misOportunidades.items.map((o) => o.id)).toEqual(['o2', 'o1']) - const o2 = r.misOportunidades.items[0] - expect(o2?.etapa).toBe('Cotizador') - expect(o2?.telefono).toBe('4771234567') - expect(o2?.email).toBe('a@b.c') - expect(o2?.unidadCodigo).toBe('U-9') - expect(o2?.ultimoCambio).toEqual({ - fechaISO: '2026-08-28T09:00:00.000Z', - usuario: 'Martin Zavala', - detalle: 'Actualización', - }) - const o1 = r.misOportunidades.items[1] - expect(o1?.desarrollo).toBe('SENDA') - expect(o1?.modeloInteres).toBe('SOHO') - expect(o1?.ultimoCambio).toBeNull() - }) - - it('misApartados: Vendedor = yo, sin estados finales, por fecha límite asc (sin fecha al final), con último cambio', async () => { - const monday = mondayMock({ - apartados: [ - apartado({ id: 'p1', fechaLimite: '2026-09-05' }), - apartado({ id: 'p2', fechaLimite: '2026-09-01', estado: 'Cita Clientes' }), - apartado({ id: 'p3', fechaLimite: '', estado: 'Alta Enkontrol' }), - apartado({ id: 'p4', estado: 'Escriturado' }), // final: fuera - apartado({ id: 'p5', estado: 'Cancelacion Apartado' }), // final: fuera - apartado({ id: 'p6', vendedorIds: [OTRO_ID] }), // de otro: fuera - ], - ultimosCambios: { - '666': new Map([['p1', { fechaISO: '2026-08-28T09:00:00.000Z', usuarioId: OTRO_ID, evento: 'create_update' }]]), - }, - }) - const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA) - expect(r.misApartados.map((a) => a.id)).toEqual(['p2', 'p1', 'p3']) - expect(r.misApartados[0]?.estado).toBe('Cita Clientes') - expect(r.misApartados[1]?.ultimoCambio).toEqual({ - fechaISO: '2026-08-28T09:00:00.000Z', - usuario: null, // OTRO_ID no está en el mapa de nombres - detalle: 'Nota agregada', - }) - }) - - it('esEstadoFinalApartado: venta y cancelación cierran el pipeline', () => { - expect(esEstadoFinalApartado('Alta Enkontrol')).toBe(false) - expect(esEstadoFinalApartado('Cancelacion Apartado')).toBe(true) - expect(esEstadoFinalApartado('Escriturado')).toBe(true) - expect(esEstadoFinalApartado('Pago Apartado')).toBe(false) - expect(esEstadoFinalApartado('Contrato Enkontrol')).toBe(false) - }) - - it('actividades de hoy: solo abiertas con fecha <= mañana y mías', async () => { - const monday = mondayMock({ - actividades: [ - actividad({ id: 'a1', fecha: HOY }), - actividad({ id: 'a2', fecha: MANANA, tipo: 'Reunion' }), - actividad({ id: 'a3', fecha: '2026-08-20' }), // vencida: también entra - actividad({ id: 'a4', fecha: '2026-09-05' }), // futura lejana: no - actividad({ id: 'a5', fecha: HOY, estado: 'Listo' }), // cerrada: no - actividad({ id: 'a6', fecha: HOY, responsableIds: [OTRO_ID] }), // de otro: no - actividad({ id: 'a7', fecha: '' }), // sin fecha: no - ], - }) - const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA) - // Ordenadas por fecha: vencida primero - expect(r.misActividadesHoy.map((a) => a.id)).toEqual(['a3', 'a1', 'a2']) - expect(r.conteos.actividadesAbiertas).toBe(5) // abiertas mías: a1,a2,a3,a4,a7 - }) - - it('apartados por vencer: próximos 7 días, excluye estados finales', async () => { - const monday = mondayMock({ - apartados: [ - apartado({ id: 'p1', fechaLimite: HOY }), // vence hoy - apartado({ id: 'p2', fechaLimite: '2026-09-04' }), // justo en 7 días - apartado({ id: 'p3', fechaLimite: '2026-09-05' }), // fuera de rango - apartado({ id: 'p4', fechaLimite: '2026-08-27' }), // ya venció - apartado({ id: 'p5', fechaLimite: '2026-09-01', estado: 'Escriturado' }), - apartado({ id: 'p6', fechaLimite: '2026-09-01', estado: 'Cancelacion' }), - ], - }) - const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA) - expect(r.apartadosPorVencer.map((a) => a.id)).toEqual(['p1', 'p2']) - expect(r.apartadosPorVencer[0]?.diasRestantes).toBe(0) - expect(r.apartadosPorVencer[1]?.diasRestantes).toBe(7) - }) - - it('sin mapeo Monday: listas personales vacías y sinMapeoMonday: true', async () => { - const monday = mondayMock({ - oportunidades: [oportunidad({ id: '1' })], - actividades: [actividad({ id: 'a1' })], - apartados: [apartado({ id: 'p1' })], - }) - const r = await resumenMiDia(monday, sinMapeo, new Map(), [], [], AHORA) - expect(r.sinMapeoMonday).toBe(true) - expect(r.misLeads).toEqual([]) - expect(r.misOportunidades).toEqual({ porEtapa: {}, items: [] }) - expect(r.misApartados).toEqual([]) - expect(r.misActividadesHoy).toEqual([]) - expect(r.conteos).toEqual({ leadsNuevosSemana: 0, actividadesAbiertas: 0 }) - // Los apartados por vencer son del pipeline completo: sí se muestran - expect(r.apartadosPorVencer).toHaveLength(0) // solo apartados propios - }) - - it('conteos: leads nuevos de los últimos 7 días suman AMBOS tableros (incluye terminales recientes)', async () => { - const monday = mondayMock({ - leads: [ - lead({ id: 'l1', createdAt: '2026-08-28T08:00:00' }), // hoy - lead({ id: 'l2', createdAt: '2026-08-10T08:00:00' }), // viejo - ], - oportunidades: [ - oportunidad({ id: '1', createdAt: '2026-08-28T08:00:00' }), // hoy - oportunidad({ id: '2', createdAt: '2026-08-22T08:00:00' }), // hace 6 días - oportunidad({ id: '3', createdAt: '2026-08-10T08:00:00' }), // viejo - oportunidad({ id: '4', createdAt: '2026-08-27T08:00:00', asesorIds: [OTRO_ID] }), // de otro - ], - }) - const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA) - expect(r.conteos.leadsNuevosSemana).toBe(3) // 1 lead + 2 oportunidades - }) - - it('vendedor no ve sección equipo; coordinador sí (conteos por persona de AMBOS tableros y sin asignar)', async () => { - const monday = mondayMock({ - leads: [ - lead({ id: 'l1', vendedorIds: [] }), // sin asignar, Nuevo - lead({ id: 'l2', vendedorIds: [], estado: 'Contactado' }), // sin asignar, no Nuevo - lead({ id: 'l3', vendedorIds: [OTRO_ID] }), - ], - oportunidades: [ - oportunidad({ id: '1', asesorIds: [] }), // sin asignar, Afluencia - oportunidad({ id: '2', asesorIds: [], etapa: 'Contactado' }), // sin asignar, no Afluencia - oportunidad({ id: '3', asesorIds: [MI_ID] }), - oportunidad({ id: '4', asesorIds: [MI_ID], etapa: 'Perdido' }), // terminal: no cuenta - oportunidad({ id: '5', asesorIds: [OTRO_ID] }), - oportunidad({ id: '6', asesorIds: [OTRO_ID], createdAt: '2026-08-10T08:00:00' }), // viejo: no es nuevo de la semana - ], - }) - const nombres = new Map([[MI_ID, 'Martin Zavala']]) - - const rVendedor = await resumenMiDia(monday, vendedor, nombres, [], [], AHORA) - expect(rVendedor.equipo).toBeUndefined() - - const rCoord = await resumenMiDia(monday, coordinador, nombres, [], [], AHORA) - expect(rCoord.equipo?.leadsNuevosSinAsignar).toBe(2) // 1 lead Nuevo + 1 afluencia sin asignar - expect(rCoord.equipo?.porVendedor).toEqual([ - { mondayUserId: OTRO_ID, nombre: null, leadsAsignados: 3, leadsNuevosSemana: 2 }, // 1 lead + 2 ops (1 vieja) - { mondayUserId: MI_ID, nombre: 'Martin Zavala', leadsAsignados: 1, leadsNuevosSemana: 1 }, - ]) - }) - - it('corporativo y admin también ven equipo', async () => { - const monday = mondayMock({ oportunidades: [oportunidad({ id: '1' })] }) - for (const rol of ['corporativo', 'admin']) { - const r = await resumenMiDia(monday, { nombre: 'X', rol, mondayUserId: null }, new Map(), [], [], AHORA) - expect(r.equipo).toBeDefined() - } - }) - - it('mis capturas: items en misLeads/misOportunidades no se duplican en misCapturas', async () => { - const monday = mondayMock({ - oportunidades: [oportunidad({ id: '100', nombre: 'Ana López', etapa: 'Contactado', origen: 'WhatsApp', asesorIds: [OTRO_ID] })], - }) - const capturas = [ - { leadId: '100', nombreLead: 'Ana López', telefono: '6641112233', createdAt: new Date('2026-08-27T09:00:00Z') }, - ] - const r = await resumenMiDia(monday, vendedor, new Map(), capturas, [], AHORA) - // La captura ya aparece en misLeads (via esMio → idsCapturados), asi que no se duplica - expect(r.misCapturas).toEqual([]) - }) - - it('mis capturas: captura rapida no se duplica si ya esta en misLeads', async () => { - const monday = mondayMock({ - leads: [lead({ id: 'lead-9', nombre: 'Pepe Rápido', estado: 'Contactado', origen: 'Web', vendedorIds: [OTRO_ID] })], - }) - const capturas = [ - { leadId: 'lead-9', nombreLead: 'Pepe Rápido', telefono: '4771112233', createdAt: new Date('2026-08-27T09:00:00Z') }, - ] - const r = await resumenMiDia(monday, vendedor, new Map(), capturas, [], AHORA) - // La captura ya aparece en misLeads (via esMio → idsCapturados), asi que no se duplica - expect(r.misCapturas).toEqual([]) - }) - - it('mis capturas: si el item ya no existe en ningún tablero, estado y origen van vacíos', async () => { - const monday = mondayMock({}) - const capturas = [ - { leadId: 'borrado-1', nombreLead: 'Lead Borrado', telefono: null, createdAt: new Date('2026-08-26T09:00:00Z') }, - ] - const r = await resumenMiDia(monday, vendedor, new Map(), capturas, [], AHORA) - expect(r.misCapturas).toEqual([ - { leadId: 'borrado-1', nombre: 'Lead Borrado', telefono: '', estado: '', origen: '', capturadoEn: '2026-08-26T09:00:00.000Z' }, - ]) - }) - - it('mis capturas: ordenadas por fecha de captura descendente', async () => { - const monday = mondayMock({}) - const capturas = [ - { leadId: '1', nombreLead: 'Antiguo', telefono: null, createdAt: new Date('2026-08-20T09:00:00Z') }, - { leadId: '2', nombreLead: 'Reciente', telefono: null, createdAt: new Date('2026-08-27T09:00:00Z') }, - ] - const r = await resumenMiDia(monday, vendedor, new Map(), capturas, [], AHORA) - expect(r.misCapturas.map((c) => c.leadId)).toEqual(['2', '1']) - }) - - it('sin capturas: misCapturas va vacío', async () => { - const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], [], AHORA) - expect(r.misCapturas).toEqual([]) - }) - - it('mis clientes: mapeo con completado según proyecto (vacío = en captura)', async () => { - const clientes = [ - { - id: 'c1', - nombreCompleto: 'Ana En Captura', - proyecto: '', - unidadCodigo: null, - mondayApartadoId: null, - createdAt: new Date('2026-08-26T09:00:00Z'), - }, - { - id: 'c2', - nombreCompleto: 'Luis Completado', - proyecto: 'DIAGONAL', - unidadCodigo: 'U-101', - mondayApartadoId: null, - createdAt: new Date('2026-08-27T09:00:00Z'), - }, - ] - const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], clientes, AHORA) - expect(r.misClientes).toEqual([ - { - id: 'c2', - nombre: 'Luis Completado', - proyecto: 'DIAGONAL', - unidadCodigo: 'U-101', - completado: true, - capturadoEn: '2026-08-27T09:00:00.000Z', - estadoApartado: null, - }, - { - id: 'c1', - nombre: 'Ana En Captura', - proyecto: '', - unidadCodigo: '', - completado: false, - capturadoEn: '2026-08-26T09:00:00.000Z', - estadoApartado: null, - }, - ]) - }) - - it('mis clientes: estadoApartado cruza mondayApartadoId contra el tablero Apartado', async () => { - const clientes = [ - { - id: 'c1', - nombreCompleto: 'Con Apartado', - proyecto: 'DIAGONAL', - unidadCodigo: 'U-101', - mondayApartadoId: 'ap-1', - createdAt: new Date('2026-08-27T09:00:00Z'), - }, - { - id: 'c2', - nombreCompleto: 'Apartado Borrado', - proyecto: 'DIAGONAL', - unidadCodigo: 'U-102', - mondayApartadoId: 'ap-fantasma', // ya no existe en Monday - createdAt: new Date('2026-08-26T09:00:00Z'), - }, - { - id: 'c3', - nombreCompleto: 'Sin Apartado', - proyecto: '', - unidadCodigo: null, - mondayApartadoId: null, - createdAt: new Date('2026-08-25T09:00:00Z'), - }, - ] - const monday = mondayMock({ apartados: [apartado({ id: 'ap-1', estado: 'Alta Enkontrol' })] }) - const r = await resumenMiDia(monday, vendedor, new Map(), [], clientes, AHORA) - expect(r.misClientes.map((c) => [c.id, c.estadoApartado])).toEqual([ - ['c1', 'Alta Enkontrol'], // estado real del item en Monday - ['c2', null], // el apartado ya no existe - ['c3', null], // sin apartado ligado - ]) - }) - - it('mis clientes: proyecto con solo espacios cuenta como en captura', async () => { - const clientes = [ - { - id: 'c1', - nombreCompleto: 'Espacios', - proyecto: ' ', - unidadCodigo: null, - mondayApartadoId: null, - createdAt: new Date('2026-08-26T09:00:00Z'), - }, - ] - const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], clientes, AHORA) - expect(r.misClientes[0]?.completado).toBe(false) - }) - - it('sin clientes: misClientes va vacío (no requiere Monday)', async () => { - const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], [], AHORA) - expect(r.misClientes).toEqual([]) - }) -}) describe('clasificadores de estado', () => { it('etapas terminales de oportunidad (case-insensitive)', () => { @@ -526,7 +15,7 @@ describe('clasificadores de estado', () => { expect(esEtapaTerminal('Intento de contacto')).toBe(false) }) - it('estados terminales de lead (tablero Leads, case-insensitive)', () => { + it('estados terminales de lead (captura ligera, case-insensitive)', () => { expect(esEstadoTerminalLead('DESCARTADO')).toBe(true) expect(esEstadoTerminalLead('calificado')).toBe(true) expect(esEstadoTerminalLead('Abandono')).toBe(true) @@ -540,65 +29,3 @@ describe('clasificadores de estado', () => { expect(esEstadoFinalApartado('Pago Apartado')).toBe(false) }) }) - -describe('resumenMiDia — cuenta compartida / uso solo-app (sin Monday)', () => { - const COMPARTIDO = 999000111 - - it('sin vínculo Monday: ve lo que capturó en la app (registro local)', async () => { - const monday = mondayMock({ - leads: [ - lead({ id: 'l1' }), // capturado por mí (abajo) - lead({ id: 'l2' }), // no es mío - ], - oportunidades: [oportunidad({ id: 'o1', updatedAt: '2026-08-28T10:00:00Z' })], // capturada por mí - }) - const capturas = [ - { leadId: 'l1', nombreLead: 'Lead l1', telefono: null, createdAt: new Date('2026-08-27') }, - { leadId: 'o1', nombreLead: 'Lead o1', telefono: null, createdAt: new Date('2026-08-27') }, - ] - const r = await resumenMiDia(monday, sinMapeo, new Map(), capturas, [], AHORA) - expect(r.misLeads.map((l) => l.id)).toEqual(['l1']) // solo leads - expect(r.misOportunidades.items.map((o) => o.id)).toEqual(['o1']) - }) - - it('cuenta compartida: NO ve lo asignado a la cuenta compartida, solo su captura local', async () => { - const compartido: UsuarioMiDia = { nombre: 'Vendedor Piso', rol: 'vendedor', mondayUserId: COMPARTIDO } - const monday = mondayMock({ - leads: [ - lead({ id: 'l1', vendedorIds: [COMPARTIDO] }), // asignado a la cuenta compartida, capturado por otro - lead({ id: 'l2', vendedorIds: [COMPARTIDO] }), // asignado a la compartida, capturado por MÍ - ], - }) - const capturas = [{ leadId: 'l2', nombreLead: 'Lead l2', telefono: null, createdAt: new Date('2026-08-27') }] - const r = await resumenMiDia(monday, compartido, new Map(), capturas, [], AHORA, COMPARTIDO) - expect(r.misLeads.map((l) => l.id)).toEqual(['l2']) // l1 queda fuera - }) - - it('cuenta NO compartida: ve capturas + asignados (comportamiento de siempre)', async () => { - const monday = mondayMock({ - leads: [ - lead({ id: 'l1', vendedorIds: [MI_ID] }), // asignado a mí - lead({ id: 'l2', vendedorIds: [OTRO_ID] }), // capturado por mí (abajo) - lead({ id: 'l3', vendedorIds: [OTRO_ID] }), // ni asignado ni capturado - ], - }) - const capturas = [{ leadId: 'l2', nombreLead: 'Lead l2', telefono: null, createdAt: new Date('2026-08-27') }] - const r = await resumenMiDia(monday, vendedor, new Map(), capturas, [], AHORA, COMPARTIDO) - expect(r.misLeads.map((l) => l.id).sort()).toEqual(['l1', 'l2']) - }) - - it('mis apartados incluye los ligados a mis altas de cliente aunque el Vendedor sea la cuenta compartida', async () => { - const compartido: UsuarioMiDia = { nombre: 'Vendedor Piso', rol: 'vendedor', mondayUserId: COMPARTIDO } - const monday = mondayMock({ - apartados: [ - apartado({ id: 'p1', vendedorIds: [COMPARTIDO] }), // apartado de MI alta (abajo) - apartado({ id: 'p2', vendedorIds: [COMPARTIDO] }), // apartado de otro - ], - }) - const clientes = [ - { id: 'c1', nombreCompleto: 'Cliente p1', proyecto: 'SENDA', unidadCodigo: 'U-1', mondayApartadoId: 'p1', createdAt: new Date('2026-08-27') }, - ] - const r = await resumenMiDia(monday, compartido, new Map(), [], clientes, AHORA, COMPARTIDO) - expect(r.misApartados.map((a) => a.id)).toEqual(['p1']) - }) -}) diff --git a/api-gateway/src/services/mi-dia.service.ts b/api-gateway/src/services/mi-dia.service.ts index 94eae5e..5f6c451 100644 --- a/api-gateway/src/services/mi-dia.service.ts +++ b/api-gateway/src/services/mi-dia.service.ts @@ -1,16 +1,8 @@ -import type { MondayService } from './monday.service.js' -import { config } from '../config/index.js' - /** * Agregador "Mi día": lo que un vendedor necesita al abrir la app /hoy. - * Todo se filtra por su mondayUserId (columna people de cada tablero); si el - * usuario aún no tiene cuenta Monday (mondayUserId null), las listas personales - * van vacías con sinMapeoMonday: true (las acciones del CRM siguen funcionando). - * "Mis leads" mezcla AMBOS tableros del embudo: Leads (captura ligera, columna - * Vendedor) y Oportunidades (Alta Afluencia, columna Asesor — la afluencia ES - * la oportunidad en etapa 'Afluencia'). "Mis capturas" viene del registro - * local (tabla LeadCaptura), no de Monday: es la lista de items que el usuario - * dio de alta, con su estado actual (cruza contra ambos tableros por id). + * 100% local: todo se construye desde la tabla LeadCaptura (los tabs del + * embudo), Cliente (mis clientes) y ActividadLocal (agenda). El "mío" se + * resuelve por el usuarioId de cada fila. */ // Estados del board Leads en los que el lead ya no es trabajo activo del vendedor @@ -42,10 +34,32 @@ export const ESTADOS_FINALES_APARTADO = [ const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin'] +const MS_DIA = 24 * 60 * 60 * 1000 + +/** Fecha local 'YYYY-MM-DD' desplazada n días desde `base` */ +function fechaLocal(base: Date, dias: number): string { + const d = new Date(base.getTime() + dias * MS_DIA) + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const dd = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${dd}` +} + +export function esEstadoTerminalLead(estado: string): boolean { + return ESTADOS_TERMINALES_LEAD.includes(estado.trim().toUpperCase()) +} + +export function esEtapaTerminal(etapa: string): boolean { + return ETAPAS_TERMINALES_OPORTUNIDAD.includes(etapa.trim().toUpperCase()) +} + +export function esEstadoFinalApartado(estado: string): boolean { + return ESTADOS_FINALES_APARTADO.includes(estado.trim().toUpperCase()) +} + export interface UsuarioMiDia { nombre: string rol: string - mondayUserId: number | null } export interface LeadMiDia { @@ -57,12 +71,10 @@ export interface LeadMiDia { origen: string desarrollo?: string updatedAt: string - /** Tablero del item: 'lead' (tablero Leads) u 'oportunidad' (Oportunidades) */ + /** Tablero del item: 'lead' (captura ligera) u 'oportunidad' (afluencia) */ fuente: 'lead' | 'oportunidad' - /** Último evento del activity log del board; null si el item no tiene eventos */ + /** Último cambio conocido; null si no hay */ ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null - /** true = captura local aún no sincronizada a Monday (badge ⏳ del front; aditivo) */ - pendienteSync?: boolean } export interface ActividadMiDia { @@ -86,11 +98,9 @@ export interface CapturaMiDia { leadId: string nombre: string telefono: string - estado: string // estado ACTUAL en Monday; '' si el lead ya no existe en el board + estado: string origen: string capturadoEn: string // ISO - /** true = captura local aún no sincronizada a Monday (badge ⏳ del front; aditivo) */ - pendienteSync?: boolean } export interface ClienteMiDia { @@ -100,7 +110,7 @@ export interface ClienteMiDia { unidadCodigo: string completado: boolean // true cuando el alta ya tiene proyecto (pasó a CLIENTE) capturadoEn: string // ISO - /** Estado actual del item en el tablero Apartado (bidireccional Monday→app); null si no hay apartado ligado o ya no existe */ + /** Estado actual del apartado ligado; null si no hay apartado ligado */ estadoApartado: string | null } @@ -115,12 +125,10 @@ export interface OportunidadMiDia { modeloInteres: string unidadCodigo: string updatedAt: string - /** Último evento del activity log del board; null si el item no tiene eventos */ + /** Último cambio conocido; null si no hay */ ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null /** Fecha y hora de la cita agendada (ej. '2026-09-10T14:30' o '2026-09-10') */ fechaCita?: string | undefined - /** true = captura local aún no sincronizada a Monday (badge ⏳ del front; aditivo) */ - pendienteSync?: boolean } /** Apartado asignado al vendedor (tab "Mis apartados" de Mi día) */ @@ -130,15 +138,12 @@ export interface ApartadoMiDia { unidadCodigo: string estado: string fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene) - /** Último evento del activity log del board; null si el item no tiene eventos */ + /** Último cambio conocido; null si no hay */ ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null - /** true = captura local aún no sincronizada a Monday (badge ⏳ del front; aditivo) */ - pendienteSync?: boolean } export interface ResumenMiDia { generadoEn: string - sinMapeoMonday: boolean misLeads: LeadMiDia[] misCapturas: CapturaMiDia[] misClientes: ClienteMiDia[] @@ -158,7 +163,7 @@ export interface ResumenMiDia { equipo?: { leadsNuevosSinAsignar: number porVendedor: { - mondayUserId: number + usuarioId: string nombre: string | null leadsAsignados: number /** Leads del vendedor creados en los últimos 7 días naturales */ @@ -167,407 +172,6 @@ export interface ResumenMiDia { } } -type MondayMiDia = Pick< - MondayService, - 'getLeads' | 'getOportunidades' | 'getActividades' | 'getApartados' | 'getUltimosCambios' -> - -const MS_DIA = 24 * 60 * 60 * 1000 - -/** Texto legible del evento del activity log (los nombres de columna no vienen legibles en el payload) */ -export function detalleEventoUltimoCambio(evento: string): string { - switch (evento) { - case 'create_pulse': - return 'Alta' - case 'create_update': - return 'Nota agregada' - case 'update_column_value': - case 'update_status': - return 'Actualización' - case 'move_to_group': - return 'Cambio de grupo' - default: - return evento - } -} - -/** Fecha local 'YYYY-MM-DD' desplazada n días desde `base` */ -function fechaLocal(base: Date, dias: number): string { - const d = new Date(base.getTime() + dias * MS_DIA) - const y = d.getFullYear() - const m = String(d.getMonth() + 1).padStart(2, '0') - const dd = String(d.getDate()).padStart(2, '0') - return `${y}-${m}-${dd}` -} - -export function esEstadoTerminalLead(estado: string): boolean { - return ESTADOS_TERMINALES_LEAD.includes(estado.trim().toUpperCase()) -} - -export function esEtapaTerminal(etapa: string): boolean { - return ETAPAS_TERMINALES_OPORTUNIDAD.includes(etapa.trim().toUpperCase()) -} - -export function esEstadoFinalApartado(estado: string): boolean { - return ESTADOS_FINALES_APARTADO.includes(estado.trim().toUpperCase()) -} - -export async function resumenMiDia( - monday: MondayMiDia, - usuario: UsuarioMiDia, - /** mondayUserId -> nombre para mostrar (usuarios del ecosistema ya mapeados) */ - nombresPorMondayId: Map, - /** Capturas locales del usuario (tabla LeadCaptura), ya filtradas por él */ - capturas: { - leadId: string - nombreLead: string - telefono: string | null - createdAt: Date - /** Oportunidad ligada (LeadOportunidad); null si la captura aún no convertió */ - oportunidadId?: string | null - }[] = [], - /** Clientes cuyo alta inició el usuario (tabla Cliente), ya filtrados por él */ - clientes: { - id: string - nombreCompleto: string - proyecto: string - unidadCodigo: string | null - mondayApartadoId: string | null - createdAt: Date - }[] = [], - ahora: Date = new Date(), - /** User ID de la cuenta Monday COMPARTIDA de vendedores (config); con ella la - * columna people ya no distingue personas y "lo mío" viene de la captura local */ - compartidoId: number | null = null, - /** ids de items Monday capturados por el usuario (leads + oportunidades - * mapeadas); default: los leadId de `capturas` */ - capturadosIds?: Set, -): Promise { - const miId = usuario.mondayUserId - const sinMapeo = miId === null - - // "Mío" = lo que capturé en la app (registro local) o lo que Monday me tiene - // asignado (columna people). Con la cuenta compartida, people ya no distingue - // personas: solo cuenta la captura local. Funciona SIN vínculo Monday. - const idsCapturados = capturadosIds ?? new Set(capturas.map((c) => c.leadId)) - const esCompartido = miId !== null && compartidoId !== null && compartidoId > 0 && miId === compartidoId - const esAdmin = usuario.rol === 'admin' || usuario.rol === 'direccion_general' || usuario.rol === 'director_ventas' - const esMio = (peopleIds: number[], itemId: string): boolean => { - if (esAdmin) return true - if (idsCapturados.has(itemId)) return true - if (esCompartido || miId === null) return false - return peopleIds.includes(miId) - } - - const [leads, oportunidades, actividades, apartados] = await Promise.all([ - monday.getLeads(), - monday.getOportunidades(), - monday.getActividades(), - monday.getApartados(), - ]) - - const hoy = fechaLocal(ahora, 0) - const manana = fechaLocal(ahora, 1) - const en7Dias = fechaLocal(ahora, 7) - const hace7DiasMs = ahora.getTime() - 7 * MS_DIA - - // --- Listas personales (por captura local + asignación Monday; ver esMio) --- - // "Mis leads" mezcla AMBOS tableros del embudo: Leads (captura ligera, columna - // Vendedor) y Oportunidades (Alta Afluencia, columna Asesor — la afluencia ES - // la oportunidad en etapa 'Afluencia'). "Mis capturas" viene del registro - // local (tabla LeadCaptura), no de Monday: es la lista de items que el usuario - // dio de alta, con su estado actual (cruza contra ambos tableros por id). - const misLeadsLeads: LeadMiDia[] = leads - .filter((l) => esMio(l.vendedorIds, l.id) && !esEstadoTerminalLead(l.estado)) - .map((l) => ({ - id: l.id, - nombre: l.nombre, - telefono: l.telefono, - email: l.email, - estado: l.estado, - origen: l.origen, - desarrollo: l.desarrollo ?? '', - updatedAt: l.updatedAt, - fuente: 'lead', - ultimoCambio: null, - })) - const mios = oportunidades.filter((o) => esMio(o.asesorIds, o.id)) - const misLeadsOportunidades: LeadMiDia[] = mios - .filter((o) => !esEtapaTerminal(o.etapa)) - .map((o) => ({ - id: o.id, - nombre: o.nombre, - telefono: o.telefono, - email: o.email, - estado: o.etapa, - origen: o.origen, - desarrollo: o.desarrollo ?? '', - updatedAt: o.updatedAt, - fuente: 'oportunidad', - ultimoCambio: null, - })) - // "Mis leads" solo muestra items del tablero Leads (captura ligera). - // Las oportunidades van exclusivamente en "Mis oportunidades". - const misLeads: LeadMiDia[] = misLeadsLeads.sort((a, b) => - b.updatedAt.localeCompare(a.updatedAt), - ) - - // "Último cambio" por item: UNA llamada al activity log por tablero (máx 3), - // agrupando los ids por fuente. El usuario se resuelve con el mapa - // mondayUserId → nombre que pasa el controller (ecosistema + workspace). - const itemsOportunidades: OportunidadMiDia[] = mios - .filter((o) => !esEtapaTerminal(o.etapa)) - .map((o) => ({ - id: o.id, - nombre: o.nombre, - telefono: o.telefono, - email: o.email, - etapa: o.etapa, - desarrollo: o.desarrollo ?? '', - modeloInteres: o.modeloInteres ?? '', - unidadCodigo: o.unidadCodigo, - updatedAt: o.updatedAt, - ultimoCambio: null, - fechaCita: o.fechaCita, - })) - .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) - - // Apartados ligados a MIS altas de cliente (registro local): con la cuenta - // compartida o sin vínculo Monday, esta liga local es la que personaliza - const apartadosDeMisClientes = new Set( - clientes.map((c) => c.mondayApartadoId).filter((id): id is string => id !== null), - ) - // Set de oportunidades mías (por LeadCaptura o por vendedorIds) - const miosOportunidadesIds = new Set( - capturas - .filter((c) => c.oportunidadId) - .map((c) => c.oportunidadId) - .filter((id): id is string => id != null), - ) - const miosApartadosTodos = apartados.filter((a) => - esMio(a.vendedorIds, a.id) || apartadosDeMisClientes.has(a.id) || miosOportunidadesIds.has(a.id), - ) - const miosApartados = miosApartadosTodos.filter((a) => !esEstadoFinalApartado(a.estado)) - - const [cambiosLeads, cambiosOportunidades, cambiosApartados] = await Promise.all([ - monday.getUltimosCambios( - config.monday.boards.leads, - misLeads.filter((l) => l.fuente === 'lead').map((l) => l.id), - ), - monday.getUltimosCambios( - config.monday.boards.oportunidades, - itemsOportunidades.map((o) => o.id), - ), - monday.getUltimosCambios( - config.monday.boards.apartado, - miosApartados.map((a) => a.id), - ), - ]) - const cambiosPorFuente = { lead: cambiosLeads, oportunidad: cambiosOportunidades } - for (const lead of misLeads) { - const cambio = cambiosPorFuente[lead.fuente].get(lead.id) - lead.ultimoCambio = cambio - ? { - fechaISO: cambio.fechaISO, - usuario: cambio.usuarioId !== null ? (nombresPorMondayId.get(cambio.usuarioId) ?? null) : null, - detalle: detalleEventoUltimoCambio(cambio.evento), - } - : null - } - for (const op of itemsOportunidades) { - const cambio = cambiosOportunidades.get(op.id) - op.ultimoCambio = cambio - ? { - fechaISO: cambio.fechaISO, - usuario: cambio.usuarioId !== null ? (nombresPorMondayId.get(cambio.usuarioId) ?? null) : null, - detalle: detalleEventoUltimoCambio(cambio.evento), - } - : null - } - - // "Mis apartados": Vendedor = yo, estado no final del pipeline Enkontrol, - // por fecha límite ascendente (sin fecha al final) - // Todos los apartados míos incluyendo terminales (para pestaña Escrituración); - // se calcula desde la lista ANTES de excluir estados finales - const todosApartados: ApartadoMiDia[] = miosApartadosTodos - .map((a) => ({ - id: a.id, - nombre: a.nombre, - unidadCodigo: a.unidadCodigo, - estado: a.estado, - fechaLimite: a.fechaLimite, - ultimoCambio: null as { fechaISO: string; usuario: string | null; detalle: string } | null, - // Los apartados leídos de Monday ya están sincronizados por definición - pendienteSync: false, - })) - - const misApartados: ApartadoMiDia[] = miosApartados - .map((a) => { - const cambio = cambiosApartados.get(a.id) - return { - id: a.id, - nombre: a.nombre, - unidadCodigo: a.unidadCodigo, - estado: a.estado, - fechaLimite: a.fechaLimite, - ultimoCambio: cambio - ? { - fechaISO: cambio.fechaISO, - usuario: cambio.usuarioId !== null ? (nombresPorMondayId.get(cambio.usuarioId) ?? null) : null, - detalle: detalleEventoUltimoCambio(cambio.evento), - } - : null, - } - }) - .sort((a, b) => (a.fechaLimite || '9999-12-31').localeCompare(b.fechaLimite || '9999-12-31')) - - // Conteo de MIS oportunidades por etapa (incluye terminales: es el resumen - // del pipeline personal, no la lista de trabajo activo) - const porEtapa: Record = {} - for (const o of mios) { - const etapa = o.etapa.trim() === '' ? '(sin etapa)' : o.etapa.trim() - porEtapa[etapa] = (porEtapa[etapa] ?? 0) + 1 - } - - // "Mis capturas" se enriquece con el estado/origen ACTUAL en Monday, - // cruzando contra AMBOS tableros por id (las capturas rápidas apuntan a - // Leads; las de Alta Afluencia, a Oportunidades), más reciente primero - const leadsPorId = new Map(leads.map((l) => [l.id, l])) - const oportunidadesPorId = new Map(oportunidades.map((o) => [o.id, o])) - const misCapturas: CapturaMiDia[] = capturas - .map((c) => { - const lead = leadsPorId.get(c.leadId) - const op = oportunidadesPorId.get(c.leadId) - return { - leadId: c.leadId, - nombre: c.nombreLead, - telefono: c.telefono ?? '', - estado: lead?.estado ?? op?.etapa ?? '', - origen: lead?.origen ?? op?.origen ?? '', - capturadoEn: c.createdAt.toISOString(), - } - }) - .sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn)) - // Excluir de "Mis capturas" los que ya aparecen en "Mis leads" o "Mis - // oportunidades" para no duplicar visualmente cuando el lead existe en - // Monday Y tambien en LeadCaptura (caso creacion con Monday activo). - const idsEnOtrosTabs = new Set([ - ...misLeads.map((l) => l.id), - ...mios.map((o) => o.id), - ]) - const misCapturasSinDuplicados = misCapturas.filter((c) => !idsEnOtrosTabs.has(c.leadId)) - - // "Mis clientes" es local + cruce con Monday: el alta en progreso lleva - // proyecto '', el alta completada lleva el proyecto lleno. estadoApartado - // cruza Cliente.mondayApartadoId contra el tablero Apartado (null si no hay - // apartado ligado o ya no existe en Monday) - const apartadosPorId = new Map(apartados.map((a) => [a.id, a])) - const misClientes: ClienteMiDia[] = clientes - .map((c) => ({ - id: c.id, - nombre: c.nombreCompleto, - proyecto: c.proyecto, - unidadCodigo: c.unidadCodigo ?? '', - completado: c.proyecto.trim() !== '', - capturadoEn: c.createdAt.toISOString(), - estadoApartado: - c.mondayApartadoId != null ? (apartadosPorId.get(c.mondayApartadoId)?.estado ?? null) : null, - })) - .sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn)) - - const misActividades = sinMapeo ? [] : actividades.filter((a) => a.responsableIds.includes(miId)) - const misActividadesHoy: ActividadMiDia[] = misActividades - .filter((a) => a.estado.trim().toUpperCase() === 'ABIERTO' && a.fecha !== '' && a.fecha <= manana) - .sort((a, b) => a.fecha.localeCompare(b.fecha)) - .map((a) => ({ id: a.id, nombre: a.nombre, tipo: a.tipo, fecha: a.fecha, estado: a.estado })) - - // Apartados por vencer es del pipeline completo (Enkontrol no lleva asesor people) - const apartadosPorVencer: ApartadoPorVencer[] = apartados - .filter( - (a) => - esMio(a.vendedorIds, a.id) && - a.fechaLimite !== '' && - a.fechaLimite >= hoy && - a.fechaLimite <= en7Dias && - !esEstadoFinalApartado(a.estado), - ) - .sort((a, b) => a.fechaLimite.localeCompare(b.fechaLimite)) - .map((a) => ({ - id: a.id, - nombre: a.nombre, - unidadCodigo: a.unidadCodigo, - fechaLimite: a.fechaLimite, - estado: a.estado, - diasRestantes: Math.round((Date.parse(`${a.fechaLimite}T00:00:00`) - Date.parse(`${hoy}T00:00:00`)) / MS_DIA), - })) - - // Conteo de la semana: míos en AMBOS tableros (leads + oportunidades) - const miosLeadRows = leads.filter((l) => esMio(l.vendedorIds, l.id)) - const conteos = { - leadsNuevosSemana: - miosLeadRows.filter((l) => Date.parse(l.createdAt) >= hace7DiasMs).length + - mios.filter((o) => Date.parse(o.createdAt) >= hace7DiasMs).length, - actividadesAbiertas: misActividades.filter((a) => a.estado.trim().toUpperCase() === 'ABIERTO').length, - } - - const resumen: ResumenMiDia = { - generadoEn: ahora.toISOString(), - sinMapeoMonday: sinMapeo, - misLeads, - misCapturas: misCapturasSinDuplicados, - misClientes, - misActividadesHoy, - apartadosPorVencer, - misOportunidades: { porEtapa, items: itemsOportunidades }, - misApartados, - todosApartados, - conteos, - } - - if (ROLES_CON_EQUIPO.includes(usuario.rol)) { - // Sin asignar: leads sin Vendedor (estado NUEVO) + afluencias sin Asesor - // (etapa Afluencia) — lo que el round-robin aún no reparte - const sinAsignar = - leads.filter((l) => l.vendedorIds.length === 0 && !esEstadoTerminalLead(l.estado) && l.estado.trim().toUpperCase() === 'NUEVO') - .length + - oportunidades.filter((o) => o.asesorIds.length === 0 && o.etapa.trim().toUpperCase() === 'AFLUENCIA').length - const conteoPorId = new Map() - const nuevosPorId = new Map() - // Leads y oportunidades comparten el conteo por vendedor (misma persona Monday) - for (const l of leads) { - if (esEstadoTerminalLead(l.estado)) continue - const esNuevoSemana = Date.parse(l.createdAt) >= hace7DiasMs - for (const id of l.vendedorIds) { - conteoPorId.set(id, (conteoPorId.get(id) ?? 0) + 1) - if (esNuevoSemana) nuevosPorId.set(id, (nuevosPorId.get(id) ?? 0) + 1) - } - } - for (const o of oportunidades) { - if (esEtapaTerminal(o.etapa)) continue - const esNuevoSemana = Date.parse(o.createdAt) >= hace7DiasMs - for (const id of o.asesorIds) { - conteoPorId.set(id, (conteoPorId.get(id) ?? 0) + 1) - if (esNuevoSemana) nuevosPorId.set(id, (nuevosPorId.get(id) ?? 0) + 1) - } - } - resumen.equipo = { - leadsNuevosSinAsignar: sinAsignar, - porVendedor: [...conteoPorId.entries()] - .map(([mondayUserId, leadsAsignados]) => ({ - mondayUserId, - nombre: nombresPorMondayId.get(mondayUserId) ?? null, - leadsAsignados, - leadsNuevosSemana: nuevosPorId.get(mondayUserId) ?? 0, - })) - .sort((a, b) => b.leadsAsignados - a.leadsAsignados), - } - } - - return resumen -} - - -// ---------- Modo local (Monday caído) y unión durante la transición ---------- /** * Fila local del embudo (tabla LeadCaptura) tal como la necesita Mi día. El @@ -590,14 +194,14 @@ export interface FilaLocalMiDia { datosJson: string | null /** Usuario del ecosistema que capturó el lead (LeadCaptura.usuarioId) */ usuarioId: string - /** Item de Monday una vez sincronizado (null en capturas puramente locales) */ + /** Item heredado del CRM anterior (compatibilidad de datos; null en capturas locales) */ mondayItemId: string | null pendienteSync: boolean createdAt: Date updatedAt: Date } -/** Cliente local tal como lo necesita el modo local (misma forma que en resumenMiDia) */ +/** Cliente local tal como lo necesita el resumen */ export interface ClienteLocalMiDia { id: string nombreCompleto: string @@ -643,12 +247,12 @@ export interface UsuarioLocal { } /** - * ResumenMiDia COMPLETO construido solo con datos locales (Monday caído). - * Misma forma que resumenMiDia: Mis leads (board LEADS, etapa no terminal), - * Mis oportunidades (board OPORTUNIDADES), Mis apartados (board APARTADO + - * unidad ligada vía Cliente), Mis capturas, Mis clientes. + * ResumenMiDia COMPLETO construido solo con datos locales: Mis leads (board + * LEADS, etapa no terminal), Mis oportunidades (board OPORTUNIDADES), Mis + * apartados (board APARTADO + unidad ligada vía Cliente), Mis capturas, Mis + * clientes. * - * Parámetros opcionales para el modo local 100%: + * Parámetros opcionales: * - `actividades`: actividades locales del usuario (tabla ActividadLocal) * - `usuarios`: todos los usuarios activos (para la sección equipo) */ @@ -685,7 +289,7 @@ export function resumenMiDiaLocal( .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) // (oportunidades se muestran exclusivamente en misOportunidades) - // Conteo por etapa de TODAS las oportunidades (incluye terminales, como Monday) + // Conteo por etapa de TODAS las oportunidades (incluye terminales) const porEtapa: Record = {} for (const o of oportunidadesLocales) { const etapa = o.etapa.trim() === '' ? '(sin etapa)' : o.etapa.trim() @@ -855,7 +459,7 @@ export function resumenMiDiaLocal( (c) => c.usuarioId === u.id && c.createdAt.getTime() >= hace7DiasMs, ).length return { - mondayUserId: 0, + usuarioId: u.id, nombre: u.nombre, leadsAsignados, leadsNuevosSemana, @@ -871,7 +475,6 @@ export function resumenMiDiaLocal( return { generadoEn: ahora.toISOString(), - sinMapeoMonday: usuario.mondayUserId === null, misLeads, misCapturas: misCapturasSinDuplicadosLocal, misClientes, @@ -900,105 +503,3 @@ export function resumenMiDiaLocal( ...(equipo !== undefined ? { equipo } : {}), } } - -/** - * Unión durante la transición (Monday vivo): agrega a las listas del resumen - * los items locales con pendienteSync=true que Monday aún no conoce (ids - * loc_*), con el flag pendienteSync para el badge ⏳ del front. Si la fila - * local ya tiene mondayItemId (cambio encolado sobre un item de Monday), no se - * duplica: solo se marca el item existente. También marca/rellena Mis capturas - * (estado local cuando Monday aún no lo tiene). Muta y devuelve `resumen`. - */ -export function unirPendientesLocales( - resumen: ResumenMiDia, - pendientes: FilaLocalMiDia[], - nombreUsuario: string, -): ResumenMiDia { - const leadsPorId = new Map(resumen.misLeads.map((l) => [l.id, l])) - const oportunidadesPorId = new Map(resumen.misOportunidades.items.map((o) => [o.id, o])) - const apartadosPorId = new Map(resumen.misApartados.map((a) => [a.id, a])) - const capturasPorId = new Map(resumen.misCapturas.map((c) => [c.leadId, c])) - - for (const p of pendientes) { - // Mis capturas: flag + estado/origen local cuando Monday aún no lo refleja - const captura = capturasPorId.get(p.leadId) - if (captura) { - captura.pendienteSync = true - if (captura.estado === '') captura.estado = p.etapa - if (captura.origen === '') captura.origen = p.origen ?? '' - } - - if (p.mondayItemId !== null) { - // Cambio pendiente sobre un item que Monday ya muestra: solo se marca - const existente = - leadsPorId.get(p.mondayItemId) ?? - oportunidadesPorId.get(p.mondayItemId) ?? - apartadosPorId.get(p.mondayItemId) - if (existente) existente.pendienteSync = true - continue - } - - // Item puramente local (loc_*): se agrega a la lista de su board - if (p.board === 'LEADS' && !esEstadoTerminalLead(p.etapa)) { - resumen.misLeads.push({ - id: p.leadId, - nombre: p.nombreLead, - telefono: p.telefono ?? '', - email: p.email ?? '', - estado: p.etapa, - origen: p.origen ?? '', - updatedAt: p.updatedAt.toISOString(), - fuente: 'lead', - ultimoCambio: ultimoCambioLocal(p, nombreUsuario), - pendienteSync: true, - }) - } else if (p.board === 'OPORTUNIDADES') { - // porEtapa cuenta TODAS las oportunidades (incluye terminales, como Monday) - const etapa = p.etapa.trim() === '' ? '(sin etapa)' : p.etapa.trim() - resumen.misOportunidades.porEtapa[etapa] = (resumen.misOportunidades.porEtapa[etapa] ?? 0) + 1 - if (!esEtapaTerminal(p.etapa)) { - resumen.misLeads.push({ - id: p.leadId, - nombre: p.nombreLead, - telefono: p.telefono ?? '', - email: p.email ?? '', - estado: p.etapa, - origen: p.origen ?? '', - updatedAt: p.updatedAt.toISOString(), - fuente: 'oportunidad', - ultimoCambio: ultimoCambioLocal(p, nombreUsuario), - pendienteSync: true, - }) - resumen.misOportunidades.items.push({ - id: p.leadId, - nombre: p.nombreLead, - telefono: p.telefono ?? '', - email: p.email ?? '', - etapa: p.etapa, - desarrollo: p.desarrollo ?? '', - modeloInteres: p.modelo ?? '', - unidadCodigo: '', - updatedAt: p.updatedAt.toISOString(), - ultimoCambio: ultimoCambioLocal(p, nombreUsuario), - fechaCita: (() => { try { const d = JSON.parse(p.datosJson ?? '{}') as Record; return typeof d.fechaCita === 'string' ? d.fechaCita : undefined } catch { return undefined } })(), - pendienteSync: true, - }) - } - } else if (p.board === 'APARTADO' && !esEstadoFinalApartado(p.etapa)) { - resumen.misApartados.push({ - id: p.leadId, - nombre: p.nombreLead, - unidadCodigo: '', - estado: p.etapa, - fechaLimite: '', - ultimoCambio: ultimoCambioLocal(p, nombreUsuario), - pendienteSync: true, - }) - } - } - - resumen.misLeads.sort(porUpdatedAtDesc) - resumen.misOportunidades.items.sort(porUpdatedAtDesc) - resumen.misApartados.sort((a, b) => (a.fechaLimite || '9999-12-31').localeCompare(b.fechaLimite || '9999-12-31')) - return resumen -} diff --git a/api-gateway/src/services/monday.service.test.ts b/api-gateway/src/services/monday.service.test.ts deleted file mode 100644 index 9f7d2dd..0000000 --- a/api-gateway/src/services/monday.service.test.ts +++ /dev/null @@ -1,1040 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { MondayService } from './monday.service.js' - -// Columnas con slugs autogenerados (como las reales de Monday); el servicio -// debe resolverlas por TÍTULO, nunca hardcodear el id. -const COLUMNAS_UNIDADES = [ - { id: 'text_mm6eaaa', title: 'Desarrollo', type: 'text' }, - { id: 'text_mm6ebbb', title: 'Modelo', type: 'text' }, - { id: 'date_mm6eccc', title: 'Fecha entrega', type: 'date' }, - { id: 'numeric_mm6eddd', title: 'Sup. construcción', type: 'numbers' }, - { id: 'numeric_mm6eeee', title: 'Sup. excedente', type: 'numbers' }, - { id: 'numeric_mm6efff', title: 'Sup. estacionamiento', type: 'numbers' }, - { id: 'numeric_mm6eggg', title: 'Precio sin acabados', type: 'numbers' }, - { id: 'numeric_mm6ehhh', title: 'Precio con acabados', type: 'numbers' }, - { id: 'color_mm6eiii', title: 'Disponibilidad', type: 'status' }, -] - -function itemUnidad(id: string, codigo: string, disponibilidad: string) { - return { - id, - name: codigo, - column_values: [ - { id: 'text_mm6eaaa', text: 'SENDA', value: null }, - { id: 'text_mm6ebbb', text: '1R', value: null }, - { id: 'date_mm6eccc', text: '2027-10-08', value: null }, - { id: 'numeric_mm6eddd', text: '69.485', value: null }, - { id: 'numeric_mm6eeee', text: '0', value: null }, - { id: 'numeric_mm6efff', text: '12.5', value: null }, - { id: 'numeric_mm6eggg', text: '4415000', value: null }, - { id: 'numeric_mm6ehhh', text: '4579000', value: null }, - { id: 'color_mm6eiii', text: disponibilidad, value: null }, - ], - } -} - -const respuestaOk = (data: unknown): Response => - new Response(JSON.stringify({ data }), { status: 200, headers: { 'Content-Type': 'application/json' } }) - -interface LlamadaFetch { - body: { query: string; variables: Record } -} - -/** Instala un fetch mockeado que responde según el query; devuelve las llamadas registradas */ -function mockearFetch(handler: (body: LlamadaFetch['body']) => Response): LlamadaFetch[] { - const llamadas: LlamadaFetch[] = [] - vi.stubGlobal('fetch', async (_url: unknown, init: { body: string }) => { - const body = JSON.parse(init.body) as LlamadaFetch['body'] - llamadas.push({ body }) - return handler(body) - }) - return llamadas -} - -const crearServicio = (): MondayService => - new MondayService({ apiKey: 'test', apiUrl: 'https://api.test/v2', baseDelayMs: 1 }) - -describe('MondayService', () => { - beforeEach(() => vi.useRealTimers()) - afterEach(() => vi.unstubAllGlobals()) - - it('resuelve column IDs por TÍTULO y mapea unidades al tipo del cotizador', async () => { - mockearFetch((body) => { - if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] }) - return respuestaOk({ - boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE')] } }], - }) - }) - - const unidades = await crearServicio().getUnidades() - expect(unidades).toHaveLength(1) - expect(unidades[0]).toEqual({ - codigo: 'U-101', - modelo: '1R', - fechaEntrega: '2027-10-08', - supConstruccion: 69.485, - supExcedente: 0, - supEstacionamiento: 12.5, - precioSinAcabados: 4415000, - precioConAcabados: 4579000, - status: 'DISPONIBLE', - desarrollo: 'SENDA', - mesesParaFirma: null, - etapa: null, - }) - }) - - it('mapea la columna opcional "Etapa" (pipeline de venta) cuando existe', async () => { - const columnasConEtapa = [...COLUMNAS_UNIDADES, { id: 'color_mm6ejjj', title: 'Etapa', type: 'status' }] - const item = itemUnidad('1', 'U-101', 'VENDIDO') - item.column_values.push({ id: 'color_mm6ejjj', text: 'Notaría', value: null }) - mockearFetch((body) => { - if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: columnasConEtapa }] }) - return respuestaOk({ boards: [{ items_page: { cursor: null, items: [item] } }] }) - }) - - const unidades = await crearServicio().getUnidades() - expect(unidades[0]?.etapa).toBe('Notaría') - }) - - it('excluye items placeholder sin datos (p. ej. "Task 1" default de Monday)', async () => { - const placeholder = { - id: '99', - name: 'Task 1', - column_values: COLUMNAS_UNIDADES.map((c) => ({ id: c.id, text: '', value: null })), - } - mockearFetch((body) => { - if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] }) - return respuestaOk({ - boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE'), placeholder] } }], - }) - }) - - const unidades = await crearServicio().getUnidades() - expect(unidades.map((u) => u.codigo)).toEqual(['U-101']) - }) - - it('pagina con cursor hasta agotar las páginas', async () => { - const llamadas = mockearFetch((body) => { - if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] }) - if (body.variables['cursor'] === 'pagina-2') { - return respuestaOk({ - boards: [{ items_page: { cursor: null, items: [itemUnidad('2', 'U-102', 'APARTADA')] } }], - }) - } - return respuestaOk({ - boards: [{ items_page: { cursor: 'pagina-2', items: [itemUnidad('1', 'U-101', 'VENDIDA')] } }], - }) - }) - - const unidades = await crearServicio().getUnidades() - expect(unidades.map((u) => u.codigo)).toEqual(['U-101', 'U-102']) - expect(unidades[0]?.status).toBe('VENDIDA') - expect(unidades[1]?.status).toBe('APARTADA') - // 1 llamada de columnas + 2 de items_page - expect(llamadas).toHaveLength(3) - expect(llamadas[2]?.body.variables['cursor']).toBe('pagina-2') - }) - - it('usa el cache de 5 minutos para no repetir llamadas', async () => { - const llamadas = mockearFetch((body) => { - if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] }) - return respuestaOk({ - boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE')] } }], - }) - }) - const svc = crearServicio() - await svc.getUnidades() - await svc.getUnidades() - expect(llamadas).toHaveLength(2) // columnas + una sola página de items - }) - - it('reintenta ante HTTP 429 con backoff y logra responder', async () => { - let intentos = 0 - mockearFetch((body) => { - if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] }) - intentos++ - if (intentos === 1) return new Response('rate limited', { status: 429 }) - return respuestaOk({ - boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE')] } }], - }) - }) - - const unidades = await crearServicio().getUnidades() - expect(unidades).toHaveLength(1) - expect(intentos).toBe(2) - }) - - it('reintenta ante error de complejidad (error_code COMPLEXITY_BUDGET_EXCEEDED)', async () => { - let intentos = 0 - mockearFetch((body) => { - if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] }) - intentos++ - if (intentos < 3) { - return new Response( - JSON.stringify({ error_code: 'COMPLEXITY_BUDGET_EXCEEDED', error_message: 'Complexity budget exceeded' }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ) - } - return respuestaOk({ - boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE')] } }], - }) - }) - - const unidades = await crearServicio().getUnidades() - expect(unidades).toHaveLength(1) - expect(intentos).toBe(3) - }) - - it('agota los reintentos y lanza error', async () => { - mockearFetch(() => new Response('rate limited', { status: 429 })) - await expect(crearServicio().getUnidades()).rejects.toThrow(/no respondió/) - }) - - it('lanza en errores GraphQL no retryables sin reintentar', async () => { - let llamadasTotal = 0 - mockearFetch(() => { - llamadasTotal++ - return new Response(JSON.stringify({ errors: [{ message: 'Invalid board ID' }] }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - }) - await expect(crearServicio().getUnidades()).rejects.toThrow(/Invalid board ID/) - expect(llamadasTotal).toBe(1) - }) - - it('crearOportunidad envía column_values como JSON.stringify en variables', async () => { - const columnasOportunidades = [ - { id: 'text_u1', title: 'Unidad', type: 'text' }, - { id: 'text_e1', title: 'Cliente email', type: 'text' }, - { id: 'text_t1', title: 'Cliente teléfono', type: 'text' }, - { id: 'color_tp', title: 'Tipo propuesta', type: 'status' }, - { id: 'numeric_pl', title: 'Precio de lista', type: 'numbers' }, - { id: 'numeric_dp', title: 'Descuento %', type: 'numbers' }, - { id: 'numeric_pf', title: 'Precio final', type: 'numbers' }, - { id: 'date_fc', title: 'Fecha cotización', type: 'date' }, - { id: 'color_es', title: 'Estado', type: 'status' }, - ] - const llamadas = mockearFetch((body) => { - if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: columnasOportunidades }] }) - return respuestaOk({ create_item: { id: '987654321' } }) - }) - - const id = await crearServicio().crearOportunidad({ - clienteNombre: 'Juan Pérez', - unidadCodigo: 'U-101', - tipoPropuestaLabel: 'BANCARIO', - precioLista: 4579000, - descuentoPct: 0, - precioFinal: 4579000, - fechaCotizacion: '2026-08-21', - estadoLabel: 'COTIZADA', - clienteEmail: 'juan@example.com', - }) - expect(id).toBe('987654321') - - const mutacion = llamadas.find((l) => l.body.query.includes('create_item')) - expect(mutacion).toBeDefined() - const columnValues = mutacion?.body.variables['columnValues'] - expect(typeof columnValues).toBe('string') // JSON.stringify, nunca interpolado en el query - const parsed = JSON.parse(columnValues as string) as Record - expect(parsed['text_u1']).toBe('U-101') - expect(parsed['text_e1']).toBe('juan@example.com') - expect(parsed['numeric_pl']).toBe(4579000) - expect(parsed['color_tp']).toEqual({ label: 'BANCARIO' }) - expect(parsed['color_es']).toEqual({ label: 'COTIZADA' }) - expect(parsed['date_fc']).toEqual({ date: '2026-08-21' }) - expect(parsed['text_t1']).toBeUndefined() // no se envían columnas vacías - }) - - describe('getLeads / mapearLead (perfilamiento)', () => { - const COLUMNAS_LEADS_BASE = [ - { id: 'people_v', title: 'Vendedor', type: 'people' }, - { id: 'color_e', title: 'Estado', type: 'status' }, - ] - const COLUMNAS_PERFIL = [ - { id: 'color_des', title: 'Desarrollo', type: 'status' }, - { id: 'color_mod', title: 'Modelo de Interés', type: 'status' }, - { id: 'text_mc', title: 'Medio de Contacto', type: 'text' }, - { id: 'numeric_edad', title: 'Edad', type: 'numbers' }, - { id: 'color_ec', title: 'Estado Civil', type: 'status' }, - { id: 'color_te', title: 'Tipo de Empleo', type: 'status' }, - { id: 'color_cr', title: 'Crédito', type: 'status' }, - { id: 'color_teco', title: 'Tipo de Economía', type: 'status' }, - { id: 'color_cal', title: 'Califica', type: 'status' }, - { id: 'text_rs', title: 'Rango Salarial', type: 'text' }, - { id: 'text_lqp', title: 'Lo Quiere Para', type: 'text' }, - ] - const itemLead = (id: string, extras: { id: string; text: string }[] = []) => ({ - id, - name: `Lead ${id}`, - created_at: '2026-08-20T10:00:00Z', - updated_at: '2026-08-21T10:00:00Z', - column_values: [ - { id: 'people_v', text: '', value: '{"personsAndTeams":[{"id":106736727,"kind":"person"}]}' }, - { id: 'color_e', text: 'NUEVO', value: null }, - ...extras.map((e) => ({ ...e, value: null })), - ], - }) - - it('mapea las columnas de perfilamiento cuando existen en el board', async () => { - mockearFetch((body) => { - if (body.query.includes('columns')) { - return respuestaOk({ boards: [{ columns: [...COLUMNAS_LEADS_BASE, ...COLUMNAS_PERFIL] }] }) - } - return respuestaOk({ - boards: [ - { - items_page: { - cursor: null, - items: [ - itemLead('1', [ - { id: 'color_des', text: 'ATELIER' }, - { id: 'color_mod', text: '1R' }, - { id: 'text_mc', text: 'WhatsApp' }, - { id: 'numeric_edad', text: '32' }, - { id: 'color_ec', text: 'Soltero' }, - { id: 'color_te', text: 'Empleado' }, - { id: 'color_cr', text: 'Bancario' }, - { id: 'color_teco', text: 'Asalariado' }, - { id: 'color_cal', text: 'Si' }, - { id: 'text_rs', text: '20-30 mil' }, - { id: 'text_lqp', text: 'Habitarla' }, - ]), - ], - }, - }, - ], - }) - }) - - const leads = await crearServicio().getLeads() - expect(leads[0]).toMatchObject({ - desarrollo: 'ATELIER', - modeloInteres: '1R', - medioContacto: 'WhatsApp', - edad: 32, - estadoCivil: 'Soltero', - tipoEmpleo: 'Empleado', - credito: 'Bancario', - tipoEconomia: 'Asalariado', - califica: 'Si', - rangoSalarial: '20-30 mil', - loQuierePara: 'Habitarla', - }) - }) - - it('sin las columnas de perfilamiento devuelve \'\' / null y no truena', async () => { - mockearFetch((body) => { - if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_LEADS_BASE }] }) - return respuestaOk({ boards: [{ items_page: { cursor: null, items: [itemLead('1')] } }] }) - }) - - const leads = await crearServicio().getLeads() - expect(leads[0]).toMatchObject({ - desarrollo: '', - modeloInteres: '', - medioContacto: '', - edad: null, - estadoCivil: '', - tipoEmpleo: '', - credito: '', - tipoEconomia: '', - califica: '', - rangoSalarial: '', - loQuierePara: '', - }) - }) - - it('edad no numérica o vacía mapea a null', async () => { - mockearFetch((body) => { - if (body.query.includes('columns')) { - return respuestaOk({ boards: [{ columns: [...COLUMNAS_LEADS_BASE, ...COLUMNAS_PERFIL] }] }) - } - return respuestaOk({ - boards: [ - { - items_page: { - cursor: null, - items: [ - itemLead('1', [{ id: 'numeric_edad', text: 'N/A' }]), - itemLead('2', [{ id: 'numeric_edad', text: '' }]), - ], - }, - }, - ], - }) - }) - - const leads = await crearServicio().getLeads() - expect(leads.map((l) => l.edad)).toEqual([null, null]) - }) - - it('estado con value null (default del tablero, nunca escrito) se lee como vacío', async () => { - mockearFetch((body) => { - if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_LEADS_BASE }] }) - return respuestaOk({ - boards: [ - { - items_page: { - cursor: null, - items: [ - // text trae el label default del board, pero value es null: no hay estado real - { ...itemLead('1'), column_values: [{ id: 'people_v', text: '', value: null }, { id: 'color_e', text: 'Descartado', value: null }] }, - // estado real escrito: value presente → se usa text - { ...itemLead('2'), column_values: [{ id: 'people_v', text: '', value: null }, { id: 'color_e', text: 'Nuevo', value: '{"index":1}' }] }, - ], - }, - }, - ], - }) - }) - - const leads = await crearServicio().getLeads() - expect(leads.map((l) => l.estado)).toEqual(['', 'Nuevo']) - }) - }) - - describe('escrituras status tolerantes a case (rename de labels en la UI)', () => { - it('cambiarValorColumna resuelve el label real sin importar mayúsculas', async () => { - const llamadas = mockearFetch((body) => { - if (body.query.includes('settings_str')) { - return respuestaOk({ - boards: [{ columns: [{ id: 'color_e', title: 'Estado', settings_str: '{"labels":{"1":"Nuevo","5":"Descartado"}}' }] }], - }) - } - if (body.query.includes('columns')) { - return respuestaOk({ boards: [{ columns: [{ id: 'color_e', title: 'Estado', type: 'status' }] }] }) - } - return respuestaOk({ change_column_value: { id: 'it-1' } }) - }) - - await crearServicio().cambiarValorColumna('b1', 'it-1', 'Estado', 'NUEVO') - - const mutacion = llamadas.find((l) => l.body.query.includes('change_column_value')) - expect(JSON.parse(String(mutacion?.body.variables['value']))).toEqual({ label: 'Nuevo' }) - }) - - it('cambiarValorColumna resuelve el label de un DROPDOWN case-insensitive (los dropdown no auto-crean labels)', async () => { - const llamadas = mockearFetch((body) => { - if (body.query.includes('settings_str')) { - return respuestaOk({ - boards: [{ columns: [{ id: 'dropdown_m', title: 'Motivo No Compra', settings_str: '{"labels":[{"name":"Precio"},{"name":"Timing"}]}' }] }], - }) - } - if (body.query.includes('columns')) { - return respuestaOk({ boards: [{ columns: [{ id: 'dropdown_m', title: 'Motivo No Compra', type: 'dropdown' }] }] }) - } - return respuestaOk({ change_column_value: { id: 'it-1' } }) - }) - - await crearServicio().cambiarValorColumna('b1', 'it-1', 'Motivo No Compra', 'PRECIO') - - const mutacion = llamadas.find((l) => l.body.query.includes('change_column_value')) - expect(JSON.parse(String(mutacion?.body.variables['value']))).toEqual({ labels: ['Precio'] }) - }) - }) - - // Columnas del board Oportunidades con los tipos reales (verificadas en Monday) - const COLUMNAS_OPORTUNIDADES = [ - { id: 'color_estado', title: 'Estado', type: 'status' }, - { id: 'date_fecha', title: 'Fecha', type: 'date' }, - { id: 'phone_ct', title: 'Cliente teléfono', type: 'phone' }, - { id: 'email_ce', title: 'Cliente email', type: 'email' }, - { id: 'color_des', title: 'Desarrollo', type: 'status' }, - { id: 'color_mod', title: 'Modelo de Interés', type: 'status' }, - { id: 'dropdown_or', title: 'Origen del Lead', type: 'dropdown' }, - { id: 'text_mc', title: 'Medio de Contacto', type: 'text' }, - { id: 'numeric_edad', title: 'Edad', type: 'numbers' }, - { id: 'dropdown_ec', title: 'Estado Civil', type: 'dropdown' }, - { id: 'color_te', title: 'Tipo de Empleo', type: 'status' }, - { id: 'text_ocu', title: 'Ocupación', type: 'text' }, - { id: 'text_rs', title: 'Rango Salarial', type: 'text' }, - { id: 'color_cr', title: 'Crédito', type: 'status' }, - { id: 'color_teco', title: 'Tipo de Econimia', type: 'status' }, // SIC: typo real - { id: 'dropdown_cal', title: 'Lead Califica', type: 'dropdown' }, - { id: 'dropdown_lqp', title: 'Proposito de Compra', type: 'dropdown' }, - { id: 'long_com', title: 'COMENTARIOS', type: 'long_text' }, - { id: 'people_ase', title: 'Asesor', type: 'people' }, - { id: 'text_u1', title: 'Unidad', type: 'text' }, - { id: 'numeric_pf', title: 'Precio final', type: 'numbers' }, - ] - - const itemOportunidad = (id: string, extras: { id: string; text: string; value?: string | null }[] = []) => ({ - id, - name: `Cliente ${id}`, - created_at: '2026-08-20T10:00:00Z', - updated_at: '2026-08-21T10:00:00Z', - column_values: [ - { id: 'color_estado', text: 'Afluencia', value: '{"index":1}' }, - { id: 'phone_ct', text: '4771234567', value: null }, - { id: 'people_ase', text: '', value: '{"personsAndTeams":[{"id":106736727,"kind":"person"}]}' }, - ...extras.map((e) => ({ id: e.id, text: e.text, value: e.value ?? null })), - ], - }) - - const mockTableroOportunidades = (items: unknown[]) => - mockearFetch((body) => { - if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_OPORTUNIDADES }] }) - return respuestaOk({ boards: [{ items_page: { cursor: null, items } }] }) - }) - - describe('getOportunidades / mapearOportunidad (afluencia en Oportunidades)', () => { - it('mapea asesor (people), contacto, origen y perfilamiento cuando existen en el board', async () => { - mockTableroOportunidades([ - itemOportunidad('1', [ - { id: 'email_ce', text: 'a@b.com' }, - { id: 'dropdown_or', text: 'WhatsApp' }, - { id: 'color_des', text: 'ATELIER' }, - { id: 'color_mod', text: 'HIMALAYA' }, - { id: 'text_mc', text: 'Cabina' }, - { id: 'numeric_edad', text: '32' }, - { id: 'dropdown_ec', text: 'Soltero(a)' }, - { id: 'color_te', text: 'Asalariado' }, - { id: 'color_cr', text: 'Infonavit' }, - { id: 'color_teco', text: 'Economía mexicana' }, - { id: 'dropdown_cal', text: 'Sí' }, - { id: 'text_rs', text: '20-30 mil' }, - { id: 'dropdown_lqp', text: 'Vivir' }, - { id: 'text_u1', text: 'U-101' }, - { id: 'numeric_pf', text: '2500000' }, - ]), - ]) - - const ops = await crearServicio().getOportunidades() - expect(ops[0]).toMatchObject({ - id: '1', - etapa: 'Afluencia', - asesorIds: [106736727], - telefono: '4771234567', - email: 'a@b.com', - origen: 'WhatsApp', - desarrollo: 'ATELIER', - modeloInteres: 'HIMALAYA', - medioContacto: 'Cabina', - edad: 32, - estadoCivil: 'Soltero(a)', - tipoEmpleo: 'Asalariado', - credito: 'Infonavit', - tipoEconomia: 'Economía mexicana', - califica: 'Sí', - rangoSalarial: '20-30 mil', - loQuierePara: 'Vivir', - unidadCodigo: 'U-101', - precioFinal: 2500000, - updatedAt: '2026-08-21T10:00:00Z', - }) - }) - - it('etapa con value null (default del tablero, nunca escrito) se lee como vacía', async () => { - mockTableroOportunidades([ - // text trae el label default del board, pero value es null: no hay etapa real - itemOportunidad('1', [{ id: 'color_estado', text: 'Perdido', value: null }]), - ]) - // itemOportunidad ya pone color_estado con value; aquí el extra lo pisa en el mapa por id - const ops = await crearServicio().getOportunidades() - expect(ops[0]?.etapa).toBe('') - }) - - it('sin la columna Asesor devuelve asesorIds vacío (lectura tolerante)', async () => { - mockearFetch((body) => { - if (body.query.includes('columns')) { - return respuestaOk({ boards: [{ columns: COLUMNAS_OPORTUNIDADES.filter((c) => c.title !== 'Asesor') }] }) - } - return respuestaOk({ boards: [{ items_page: { cursor: null, items: [itemOportunidad('1')] } }] }) - }) - const ops = await crearServicio().getOportunidades() - expect(ops[0]?.asesorIds).toEqual([]) - }) - - it('buscarOportunidadPorTelefono: dedup por últimos 10 dígitos', async () => { - mockTableroOportunidades([itemOportunidad('1'), itemOportunidad('2', [{ id: 'phone_ct', text: '' }])]) - const svc = crearServicio() - await expect(svc.buscarOportunidadPorTelefono('+52 477 123 4567')).resolves.toMatchObject({ id: '1' }) - await expect(svc.buscarOportunidadPorTelefono('4770000000')).resolves.toBeNull() - await expect(svc.buscarOportunidadPorTelefono('123')).resolves.toBeNull() // menos de 10 dígitos - }) - }) - - describe('asignarVendedorALead (columna people Vendedor del tablero Leads)', () => { - it('escribe personsAndTeams con el user id en el board Leads (444 en tests)', async () => { - const llamadas = mockearFetch((body) => { - if (body.query.includes('columns')) { - return respuestaOk({ boards: [{ columns: [{ id: 'people_v', title: 'Vendedor', type: 'people' }] }] }) - } - return respuestaOk({ change_column_value: { id: 'lead-1' } }) - }) - - await crearServicio().asignarVendedorALead('lead-1', 12345) - - const mutacion = llamadas.find((l) => l.body.query.includes('change_column_value')) - expect(mutacion?.body.variables['boardId']).toBe('444') - expect(mutacion?.body.variables['columnId']).toBe('people_v') - expect(JSON.parse(String(mutacion?.body.variables['value']))).toEqual({ - personsAndTeams: [{ id: 12345, kind: 'person' }], - }) - }) - }) - - describe('crearLead (captura ligera en el tablero Leads)', () => { - const COLUMNAS_LEADS_LIGERO = [ - { id: 'color_estado', title: 'Estado', type: 'status' }, - { id: 'date_fecha', title: 'Fecha', type: 'date' }, - { id: 'phone_tel', title: 'Teléfono', type: 'phone' }, - { id: 'email_em', title: 'Email', type: 'email' }, - { id: 'color_or', title: 'Origen', type: 'status' }, - { id: 'color_des', title: 'Desarrollo', type: 'status' }, - { id: 'text_notas', title: 'Notas', type: 'text' }, - ] - - it('crea el item ligero con Estado NUEVO y Fecha de hoy (sin perfil ni oportunidad)', async () => { - const llamadas = mockearFetch((body) => { - if (body.query.includes('settings_str')) { - return respuestaOk({ - boards: [ - { - columns: COLUMNAS_LEADS_LIGERO.filter((c) => c.type === 'status').map((c) => ({ - id: c.id, - title: c.title, - settings_str: '{"labels":{"1":"Nuevo"}}', - })), - }, - ], - }) - } - if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_LEADS_LIGERO }] }) - return respuestaOk({ create_item: { id: 'lead-nuevo-1' } }) - }) - - const id = await crearServicio().crearLead({ - nombre: 'Lead Prueba', - telefono: '4771234567', - email: 'lead@correo.com', - origenLabel: 'Facebook', - desarrolloLabel: 'SENDA', - notas: 'Vio el volante', - }) - expect(id).toBe('lead-nuevo-1') - - const mutacion = llamadas.find((l) => l.body.query.includes('create_item')) - expect(mutacion?.body.variables['boardId']).toBe('444') // tablero Leads - const parsed = JSON.parse(String(mutacion?.body.variables['columnValues'])) as Record - expect(parsed['color_estado']).toEqual({ label: 'Nuevo' }) // 'NUEVO' resuelto al label real - expect(parsed['date_fecha']).toMatchObject({ date: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) }) - expect(parsed['phone_tel']).toEqual({ phone: '4771234567', countryShortName: 'MX' }) - expect(parsed['email_em']).toEqual({ email: 'lead@correo.com', text: 'lead@correo.com' }) - expect(parsed['color_or']).toEqual({ label: 'Facebook' }) - expect(parsed['color_des']).toEqual({ label: 'SENDA' }) - expect(parsed['text_notas']).toBe('Vio el volante') - }) - - it('solo envía las columnas capturadas (las vacías se omiten)', async () => { - const llamadas = mockearFetch((body) => { - if (body.query.includes('settings_str')) { - return respuestaOk({ - boards: [{ columns: [{ id: 'color_estado', title: 'Estado', settings_str: '{"labels":{"1":"Nuevo"}}' }] }], - }) - } - if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_LEADS_LIGERO }] }) - return respuestaOk({ create_item: { id: 'lead-nuevo-2' } }) - }) - - await crearServicio().crearLead({ nombre: 'Solo Nombre' }) - - const mutacion = llamadas.find((l) => l.body.query.includes('create_item')) - const parsed = JSON.parse(String(mutacion?.body.variables['columnValues'])) as Record - expect(Object.keys(parsed).sort()).toEqual(['color_estado', 'date_fecha']) - }) - }) - - describe('asignarAsesorAOportunidad (columna people Asesor)', () => { - it('escribe personsAndTeams con el user id en el board Oportunidades (555 en tests)', async () => { - const llamadas = mockearFetch((body) => { - if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_OPORTUNIDADES }] }) - return respuestaOk({ change_column_value: { id: 'op-1' } }) - }) - - await crearServicio().asignarAsesorAOportunidad('op-1', 12345) - - const mutacion = llamadas.find((l) => l.body.query.includes('change_column_value')) - expect(mutacion?.body.variables['boardId']).toBe('555') - expect(mutacion?.body.variables['columnId']).toBe('people_ase') - expect(JSON.parse(String(mutacion?.body.variables['value']))).toEqual({ - personsAndTeams: [{ id: 12345, kind: 'person' }], - }) - }) - }) - - describe('crearOportunidadAfluencia (captura completa en Oportunidades)', () => { - it('serializa cada campo según su tipo real (dropdown, status, phone, numbers, long_text, date)', async () => { - const llamadas = mockearFetch((body) => { - if (body.query.includes('settings_str')) { - // El resolver de labels consulta los settings de cada status/dropdown escrito - return respuestaOk({ - boards: [ - { - columns: COLUMNAS_OPORTUNIDADES - .filter((c) => c.type === 'status' || c.type === 'dropdown') - .map((c) => ({ id: c.id, title: c.title, settings_str: '{"labels":{"1":"Afluencia"}}' })), - }, - ], - }) - } - if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_OPORTUNIDADES }] }) - return respuestaOk({ create_item: { id: 'op-nueva-1' } }) - }) - - const id = await crearServicio().crearOportunidadAfluencia({ - nombre: 'Lead Prueba', - telefono: '4771234567', - email: 'lead@correo.com', - desarrolloLabel: 'SENDA', - origenLabel: 'WhatsApp', - modeloLabel: 'HIMALAYA', - medioContacto: 'Cabina', - edad: 35, - estadoCivilLabel: 'Casado(a)', - tipoEmpleoLabel: 'Asalariado', - ocupacion: 'Ingeniero', - rangoSalarial: '$20,000 - $30,000', - creditoLabel: 'Infonavit', - tipoEconomiaLabel: 'Economía mexicana', - calificaLabel: 'Sí', - loQuierePara: 'Vivir', - notas: 'Vio el volante', - }) - expect(id).toBe('op-nueva-1') - - const mutacion = llamadas.find((l) => l.body.query.includes('create_item')) - const parsed = JSON.parse(String(mutacion?.body.variables['columnValues'])) as Record - expect(parsed['color_estado']).toEqual({ label: 'Afluencia' }) // nace en etapa Afluencia - expect(parsed['date_fecha']).toMatchObject({ date: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) }) // fecha de captura = hoy - expect(parsed['phone_ct']).toEqual({ phone: '4771234567', countryShortName: 'MX' }) - expect(parsed['email_ce']).toEqual({ email: 'lead@correo.com', text: 'lead@correo.com' }) - expect(parsed['dropdown_or']).toEqual({ labels: ['WhatsApp'] }) - expect(parsed['dropdown_ec']).toEqual({ labels: ['Casado(a)'] }) - expect(parsed['dropdown_cal']).toEqual({ labels: ['Sí'] }) - expect(parsed['dropdown_lqp']).toEqual({ labels: ['Vivir'] }) - expect(parsed['color_mod']).toEqual({ label: 'HIMALAYA' }) - expect(parsed['color_teco']).toEqual({ label: 'Economía mexicana' }) // columna con typo 'Tipo de Econimia' - expect(parsed['numeric_edad']).toBe(35) - expect(parsed['text_mc']).toBe('Cabina') - expect(parsed['text_ocu']).toBe('Ingeniero') - expect(parsed['text_rs']).toBe('$20,000 - $30,000') - expect(parsed['long_com']).toEqual({ text: 'Vio el volante' }) - }) - - it('solo envía las columnas capturadas (las vacías se omiten)', async () => { - const llamadas = mockearFetch((body) => { - if (body.query.includes('settings_str')) { - return respuestaOk({ - boards: [{ columns: [{ id: 'color_estado', title: 'Estado', settings_str: '{"labels":{"1":"Afluencia"}}' }] }], - }) - } - if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_OPORTUNIDADES }] }) - return respuestaOk({ create_item: { id: 'op-nueva-2' } }) - }) - - await crearServicio().crearOportunidadAfluencia({ nombre: 'Solo Nombre' }) - - const mutacion = llamadas.find((l) => l.body.query.includes('create_item')) - const parsed = JSON.parse(String(mutacion?.body.variables['columnValues'])) as Record - expect(Object.keys(parsed).sort()).toEqual(['color_estado', 'date_fecha']) - }) - }) - - describe('crearApartado (ficha completa del tablero Apartado)', () => { - // Columnas reales del board (verificadas en Monday 2026-09-04) - const COLUMNAS_APARTADO = [ - { id: 'text_u', title: 'Unidad', type: 'text' }, - { id: 'numeric_ma', title: 'Monto apartado', type: 'numbers' }, - { id: 'date_fa', title: 'Fecha apartado', type: 'date' }, - { id: 'date_fl', title: 'Fecha límite', type: 'date' }, - { id: 'color_e', title: 'Estado', type: 'status' }, - { id: 'phone_t', title: 'Teléfono', type: 'phone' }, - { id: 'email_c', title: 'Correo electrónico', type: 'email' }, - { id: 'text_curp', title: 'curp', type: 'text' }, // minúscula en el board - { id: 'numeric_pf', title: 'Precio Final', type: 'numbers' }, - { id: 'numeric_en', title: 'Enganche', type: 'numbers' }, - { id: 'date_fc', title: 'Fecha Compra', type: 'date' }, - { id: 'people_v', title: 'Vendedor', type: 'people' }, - { id: 'dropdown_fp', title: 'Forma de Pago', type: 'dropdown' }, - { id: 'long_obs', title: 'Observaciones', type: 'long_text' }, - ] - - it('serializa cada columna según su tipo (phone, email, numbers, date, people, dropdown, long_text)', async () => { - const llamadas = mockearFetch((body) => { - if (body.query.includes('settings_str')) { - return respuestaOk({ - boards: [ - { - columns: [ - { id: 'color_e', title: 'Estado', settings_str: '{"labels":{"1":"Pago Apartado"}}' }, - { id: 'dropdown_fp', title: 'Forma de Pago', settings_str: '{"labels":[{"name":"Infonavit"},{"name":"Contado"}]}' }, - ], - }, - ], - }) - } - if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_APARTADO }] }) - return respuestaOk({ create_item: { id: 'ap-1' } }) - }) - - const id = await crearServicio().crearApartado({ - clienteNombre: 'Cliente Demo', - unidadCodigo: 'U-101', - montoApartado: 20000, - fechaApartado: '2026-09-04', - fechaLimite: '2026-09-19', - estadoLabel: 'Pago Apartado', - telefono: '6641112233', - email: 'cli@correo.com', - curp: 'CURP123', - precioFinal: 1500000, - enganche: 20000, - fechaCompra: '2026-09-04', - vendedorId: 12345, - formaPagoLabel: 'INFONAVIT', // el resolver lo ajusta al label real - observaciones: 'Venta con descuento autorizado', - }) - expect(id).toBe('ap-1') - - const mutacion = llamadas.find((l) => l.body.query.includes('create_item')) - expect(mutacion?.body.variables['boardId']).toBe('666') // tablero Apartado - const parsed = JSON.parse(String(mutacion?.body.variables['columnValues'])) as Record - expect(parsed['phone_t']).toEqual({ phone: '6641112233', countryShortName: 'MX' }) - expect(parsed['email_c']).toEqual({ email: 'cli@correo.com', text: 'cli@correo.com' }) - expect(parsed['text_curp']).toBe('CURP123') - expect(parsed['numeric_pf']).toBe(1500000) - expect(parsed['numeric_en']).toBe(20000) - expect(parsed['date_fc']).toEqual({ date: '2026-09-04' }) - expect(parsed['people_v']).toEqual({ personsAndTeams: [{ id: 12345, kind: 'person' }] }) - expect(parsed['dropdown_fp']).toEqual({ labels: ['Infonavit'] }) // dropdown resuelto case-insensitive - expect(parsed['long_obs']).toEqual({ text: 'Venta con descuento autorizado' }) - }) - - it('omite las columnas opcionales no capturadas', async () => { - const llamadas = mockearFetch((body) => { - if (body.query.includes('settings_str')) { - return respuestaOk({ - boards: [{ columns: [{ id: 'color_e', title: 'Estado', settings_str: '{"labels":{"1":"Pago Apartado"}}' }] }], - }) - } - if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_APARTADO }] }) - return respuestaOk({ create_item: { id: 'ap-2' } }) - }) - - await crearServicio().crearApartado({ - clienteNombre: 'Cliente Demo', - unidadCodigo: 'U-101', - fechaApartado: '2026-09-04', - fechaLimite: '2026-09-19', - estadoLabel: 'Pago Apartado', - }) - - const mutacion = llamadas.find((l) => l.body.query.includes('create_item')) - const parsed = JSON.parse(String(mutacion?.body.variables['columnValues'])) as Record - expect(Object.keys(parsed).sort()).toEqual(['color_e', 'date_fa', 'date_fl', 'text_u']) - }) - }) - - describe('getUltimosCambios (activity log por item)', () => { - const log = (id: string, event: string, createdAt: string, userId: string, pulseId: number) => ({ - id, - event, - created_at: createdAt, - user_id: userId, - data: JSON.stringify({ pulse_id: pulseId }), - }) - - it('itemIds vacío: Map vacío sin llamar a Monday', async () => { - const llamadas = mockearFetch(() => respuestaOk({})) - const resultado = await crearServicio().getUltimosCambios('444', []) - expect(resultado.size).toBe(0) - expect(llamadas).toHaveLength(0) - }) - - it('una query con todos los itemIds; se queda con el evento MÁS RECIENTE por item', async () => { - const llamadas = mockearFetch(() => - respuestaOk({ - boards: [ - { - activity_logs: [ - log('a1', 'create_pulse', '1757000000000000', '106736727', 1), - log('a2', 'update_status', '1757001000000000', '111111111', 1), - log('a3', 'create_update', '1757000500000000', '106736727', 2), - ], - }, - ], - }), - ) - const resultado = await crearServicio().getUltimosCambios('444', ['1', '2']) - expect(llamadas).toHaveLength(1) - expect(llamadas[0]?.body.variables).toMatchObject({ ids: ['444'], itemIds: ['1', '2'], limit: 10 }) - // created_at en µs (> 1e15) → se divide entre 1e6 - expect(resultado.get('1')).toEqual({ - fechaISO: new Date(1757001000000).toISOString(), - usuarioId: 111111111, - evento: 'update_status', - }) - expect(resultado.get('2')).toEqual({ - fechaISO: new Date(1757000500000).toISOString(), - usuarioId: 106736727, - evento: 'create_update', - }) - }) - - it('created_at en ms (≤ 1e15) se usa directo; user_id vacío → null; payload sin pulse_id se ignora', async () => { - mockearFetch(() => - respuestaOk({ - boards: [ - { - activity_logs: [ - log('a1', 'update_column_value', '1757000000000', '', 7), - { id: 'a2', event: 'x', created_at: '1757000000000000', user_id: '5', data: 'no-json' }, - ], - }, - ], - }), - ) - const resultado = await crearServicio().getUltimosCambios('444', ['7']) - expect(resultado.size).toBe(1) - expect(resultado.get('7')).toEqual({ - fechaISO: new Date(1757000000000).toISOString(), - usuarioId: null, - evento: 'update_column_value', - }) - }) - - it('created_at de 17 dígitos (100ns, formato real de Monday) → /1e4 a ms', async () => { - mockearFetch(() => - respuestaOk({ - boards: [{ activity_logs: [log('a1', 'create_pulse', '17888408206077900', '106736727', 3)] }], - }), - ) - const resultado = await crearServicio().getUltimosCambios('444', ['3']) - const fecha = resultado.get('3')?.fechaISO - expect(fecha?.slice(0, 4)).toBe('2026') // sin la regla de 17 dígitos saldría año 2558+ - }) - }) - - describe('crearMotivoNoCompra (grupo por categoría)', () => { - const COLUMNAS_MOTIVOS = [ - { id: 'color_cat', title: 'Categoria', type: 'status' }, - { id: 'text_des', title: 'Descripcion', type: 'text' }, - ] - - function mockearBoardMotivos(): LlamadaFetch[] { - return mockearFetch((body) => { - if (body.query.includes('settings_str')) { - return respuestaOk({ - boards: [ - { columns: [{ id: 'color_cat', title: 'Categoria', settings_str: '{"labels":{"1":"Precio","2":"Timing"}}' }] }, - ], - }) - } - if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_MOTIVOS }] }) - return respuestaOk({ create_item: { id: 'motivo-1' } }) - }) - } - - it('crea el item directo en el grupo de su categoría', async () => { - const llamadas = mockearBoardMotivos() - const id = await crearServicio().crearMotivoNoCompra({ - nombre: 'Precio — Lead Prueba', - categoria: 'Precio', - descripcion: 'Lo encontró caro', - }) - expect(id).toBe('motivo-1') - const mutacion = llamadas.find((l) => l.body.query.includes('create_item')) - expect(mutacion?.body.variables['boardId']).toBe('888') // tablero Motivos No Compra - expect(mutacion?.body.variables['groupId']).toBe('group_mm6sze0z') // grupo Precio - const parsed = JSON.parse(String(mutacion?.body.variables['columnValues'])) as Record - expect(parsed['color_cat']).toEqual({ label: 'Precio' }) - expect(parsed['text_des']).toBe('Lo encontró caro') - }) - - it('normaliza trim/case al buscar el grupo (Ubicacion sin acento)', async () => { - const llamadas = mockearBoardMotivos() - await crearServicio().crearMotivoNoCompra({ nombre: 'U — Lead', categoria: ' ubicacion ' }) - const mutacion = llamadas.find((l) => l.body.query.includes('create_item')) - expect(mutacion?.body.variables['groupId']).toBe('group_mm6sqern') - }) - - it('categoría sin grupo mapeado: group_id null (grupo default) sin tronar', async () => { - const llamadas = mockearBoardMotivos() - const id = await crearServicio().crearMotivoNoCompra({ nombre: 'Otro — Lead', categoria: 'Otro raro' }) - expect(id).toBe('motivo-1') - const mutacion = llamadas.find((l) => l.body.query.includes('create_item')) - expect(mutacion?.body.variables['groupId']).toBeNull() - }) - }) - - describe('getContactos (módulo Contactos de la landing)', () => { - const COLUMNAS_CONTACTOS = [ - { id: 'phone_tel', title: 'Teléfono', type: 'phone' }, - { id: 'email_em', title: 'Email', type: 'email' }, - { id: 'color_est', title: 'Estado', type: 'status' }, - ] - const itemContacto = (id: string, updatedAt: string, estado: { text: string; value: string | null }) => ({ - id, - name: `Contacto ${id}`, - updated_at: updatedAt, - column_values: [ - { id: 'phone_tel', text: `551111000${id}`, value: '{"phone":"5511110000"}' }, - { id: 'email_em', text: `c${id}@correo.mx`, value: '{"email":"c@correo.mx"}' }, - { id: 'color_est', text: estado.text, value: estado.value }, - ], - }) - - it('mapea nombre/teléfono/email/estado y ordena por updated_at desc', async () => { - mockearFetch((body) => { - if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_CONTACTOS }] }) - return respuestaOk({ - boards: [ - { - items_page: { - cursor: null, - items: [ - itemContacto('1', '2026-08-01T10:00:00Z', { text: 'CLIENTE', value: '{"index":1}' }), - itemContacto('2', '2026-09-01T10:00:00Z', { text: 'PROSPECTO', value: '{"index":2}' }), - ], - }, - }, - ], - }) - }) - - const contactos = await crearServicio().getContactos() - expect(contactos.map((c) => c.id)).toEqual(['2', '1']) - expect(contactos[0]).toEqual({ - id: '2', - nombre: 'Contacto 2', - telefono: '5511110002', - email: 'c2@correo.mx', - estado: 'PROSPECTO', - updatedAt: '2026-09-01T10:00:00Z', - }) - }) - - it('estado con value null (nunca escrito) se lee como vacío, no el default del tablero', async () => { - mockearFetch((body) => { - if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_CONTACTOS }] }) - return respuestaOk({ - boards: [ - { - items_page: { - cursor: null, - items: [itemContacto('1', '2026-09-01T10:00:00Z', { text: 'PROSPECTO', value: null })], - }, - }, - ], - }) - }) - - const contactos = await crearServicio().getContactos() - expect(contactos[0]?.estado).toBe('') - }) - }) -}) diff --git a/api-gateway/src/services/monday.service.ts b/api-gateway/src/services/monday.service.ts deleted file mode 100644 index b36b0ad..0000000 --- a/api-gateway/src/services/monday.service.ts +++ /dev/null @@ -1,1752 +0,0 @@ -import { config } from '../config/index.js' -import type { UnidadCotizable } from './cotizacion.service.js' -import { AppError } from '../utils/errors.js' -import { prisma } from './prisma.js' -import { saludMonday } from './salud-monday.service.js' - -/** Unidad tal como la consume el API (y el frontend del cotizador) */ -export type Unidad = UnidadCotizable - -interface ColumnaMeta { - id: string - title: string - type: string -} - -interface ColumnValue { - id: string - text: string | null - value: string | null -} - -interface ItemMonday { - id: string - name: string - column_values: ColumnValue[] -} - -interface ItemConFechas extends ItemMonday { - created_at: string - updated_at: string -} - -/** Item de cualquier tablero con el grupo donde está (automatización estado → grupo) */ -export interface ItemConGrupo { - id: string - name: string - groupId: string - column_values: ColumnValue[] -} - -/** Lead del tablero Leads tal como lo consumen las automatizaciones */ -export interface LeadMonday { - id: string - nombre: string - createdAt: string // ISO (created_at del item en Monday) - updatedAt: string // ISO (updated_at del item en Monday) - estado: string // label de la columna status 'Estado' ('' si no tiene) - origen: string // label de la columna status 'Origen' ('' si no tiene) - telefono: string // columna phone 'Teléfono' ('' si no tiene) - email: string // columna email 'Email' ('' si no tiene) - vendedorIds: number[] // user IDs asignados en la columna people 'Vendedor' - // Perfilamiento (alimenta las gráficas de /dash/); todos tolerantes: si la - // columna no existe en el board o viene vacía, quedan '' / null - desarrollo?: string // status 'Desarrollo' - modeloInteres?: string // status 'Modelo de Interés' - medioContacto?: string // text 'Medio de Contacto' - edad?: number | null // numbers 'Edad' (null si vacía o no numérica) - estadoCivil?: string // status 'Estado Civil' - tipoEmpleo?: string // status 'Tipo de Empleo' - credito?: string // status 'Crédito' - tipoEconomia?: string // status 'Tipo de Economía' - califica?: string // status 'Califica' - rangoSalarial?: string // text 'Rango Salarial' - loQuierePara?: string // text 'Lo Quiere Para' -} - -/** Actividad del tablero Actividades (agenda de seguimiento del CRM) */ -export interface ActividadMonday { - id: string - nombre: string - tipo: string // label de la columna status 'Tipo' (Llamada | Reunion | Tarea | ...) - fecha: string // 'YYYY-MM-DD' de la columna date 'Fecha' ('' si no tiene) - estado: string // label de la columna status 'Estado' (Abierto | Listo | ...) - responsableIds: number[] // user IDs de la columna people 'Responsable' -} - -/** Apartado del tablero Apartado (pipeline Enkontrol) */ -export interface ApartadoMonday { - id: string - nombre: string // nombre del cliente - unidadCodigo: string // columna text 'Unidad' ('' si no tiene) - fechaLimite: string // 'YYYY-MM-DD' de 'Fecha límite' ('' si no tiene) - estado: string // label de la columna status 'Estado' - vendedorIds: number[] // user IDs asignados en la columna people 'Vendedor' - telefono?: string // columna phone 'Teléfono' ('' si no tiene) - email?: string // columna email 'Correo electrónico' ('' si no tiene) - precioFinal?: number // columna numbers 'Precio Final' (0 si no tiene) - fechaCompra?: string // 'YYYY-MM-DD' de 'Fecha Compra' ('' si no tiene) - curp?: string // columna text 'curp' ('' si no tiene) - formaPago?: string // dropdown 'Forma de Pago' ('' si no tiene) - enganche?: number // columna numbers 'Enganche' (0 si no tiene) - montoApartado?: number // columna numbers 'Monto apartado' (0 si no tiene) -} - -/** Contacto del tablero Contactos (módulo Contactos de la landing) */ -export interface ContactoMonday { - id: string - nombre: string - telefono: string // columna phone 'Teléfono' ('' si no tiene) - email: string // columna email 'Email' ('' si no tiene) - estado: string // label de la columna status 'Estado' ('' si nunca se escribió) - updatedAt: string // ISO (updated_at del item en Monday) -} - -/** Oportunidad del tablero Oportunidades (pipeline comercial) */ -export interface OportunidadMonday { - id: string - nombre: string // nombre del cliente - createdAt: string // ISO - updatedAt: string // ISO (updated_at del item en Monday) - etapa: string // label de la columna status 'Estado' - unidadCodigo: string // columna text 'Unidad' ('' si no tiene) - precioFinal: number // columna numbers 'Precio final' (0 si no tiene) - asesorIds: number[] // user IDs asignados en la columna people 'Asesor' - telefono: string // columna phone 'Cliente teléfono' ('' si no tiene) - email: string // columna email 'Cliente email' ('' si no tiene) - origen: string // dropdown 'Origen del Lead' ('' si no tiene) - // Perfilamiento de la afluencia (alimenta las gráficas de /dash/); todos - // tolerantes: si la columna no existe en el board o viene vacía, quedan '' / null - desarrollo?: string // status 'Desarrollo' - modeloInteres?: string // status 'Modelo de Interés' - medioContacto?: string // text 'Medio de Contacto' - edad?: number | null // numbers 'Edad' (null si vacía o no numérica) - estadoCivil?: string // dropdown 'Estado Civil' - tipoEmpleo?: string // status 'Tipo de Empleo' - credito?: string // status 'Crédito' - tipoEconomia?: string // status 'Tipo de Econimia' (SIC: el título tiene typo en Monday) - califica?: string // dropdown 'Lead Califica' - rangoSalarial?: string // text 'Rango Salarial' - loQuierePara?: string // dropdown 'Proposito de Compra' - fechaCita?: string | undefined // text 'Fecha' — fecha/hora de la cita agendada -} - -/** Usuario del workspace (para la lista rotativa de vendedores) */ -export interface UsuarioMonday { - id: number - nombre: string - email: string -} - -/** Entrada del activity log de Monday (boards { activity_logs }) */ -interface ActivityLogMonday { - id: string - event: string - created_at: string // string numérico, normalmente en microsegundos - user_id: string - data: string // JSON string; pulse_id identifica el item -} - -/** Evento del activity log de un item (historial completo, no solo el último) */ -export interface EventoHistorialMonday { - itemId: string - fechaISO: string - usuarioId: number | null // user_id de Monday; null si no es numérico (automatizaciones) - evento: string // event crudo (create_pulse, update_column_value, ...) -} - -/** Último evento del activity log de un item (para "Último cambio" de Mi día) */ -export interface UltimoCambioMonday { - fechaISO: string - usuarioId: number | null // user_id de Monday; null si no es numérico - evento: string // event crudo del activity log (create_pulse, update_status, ...) -} - -interface GraphQLErrorItem { - message: string - extensions?: { code?: string } -} - -interface GraphQLResponse { - data?: T - errors?: GraphQLErrorItem[] - error_code?: string - error_message?: string -} - -/** Error retryable de Monday (rate limit / presupuesto de complejidad / 5xx) */ -class MondayRetryableError extends Error {} - -const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)) - -const CACHE_UNIDADES_MS = 5 * 60 * 1000 // 5 minutos - -/** - * Grupo del tablero Motivos No Compra por categoría (ids verificados sep-2026). - * El descarte crea el motivo directo en su grupo; si la categoría no está - * mapeada, el item cae en el grupo default del tablero. - */ -export const GRUPOS_MOTIVOS_NO_COMPRA: Record = { - Timing: 'group_mm6spcsj', - Cliente: 'group_mm6sp7nr', - Competencia: 'group_mm6sbdfy', - Financiamiento: 'group_mm6ssymc', - Ubicacion: 'group_mm6sqern', // sin acento: así está el grupo en Monday - Producto: 'group_mm6s7j0b', - Precio: 'group_mm6sze0z', -} - -export interface MondayServiceOptions { - apiKey?: string - apiUrl?: string - maxReintentos?: number - baseDelayMs?: number -} - -export class MondayService { - private readonly apiKey: string - private readonly apiUrl: string - private readonly maxReintentos: number - private readonly baseDelayMs: number - - // Cache de metadatos de columnas: boardId -> (título -> meta) - private readonly columnasCache = new Map>() - // Cache de unidades + mapa codigo -> itemId (para escrituras) - private unidadesCache: { unidades: Unidad[]; itemIds: Map; expira: number } | null = null - - constructor(opts: MondayServiceOptions = {}) { - this.apiKey = opts.apiKey ?? config.monday.apiKey - this.apiUrl = opts.apiUrl ?? config.monday.apiUrl - this.maxReintentos = opts.maxReintentos ?? 3 - this.baseDelayMs = opts.baseDelayMs ?? 500 - } - - /** - * Helper GraphQL contra la API de Monday. - * - Headers Authorization / API-Version. - * - Lanza en errores GraphQL. - * - Retry con backoff exponencial ante 429, errores de complejidad y 5xx - * (máx `maxReintentos` reintentos). - */ - async graphql(query: string, variables: Record = {}): Promise { - let ultimoError: Error | null = null - for (let intento = 0; intento <= this.maxReintentos; intento++) { - if (intento > 0) await sleep(this.baseDelayMs * 2 ** (intento - 1)) - try { - const data = await this.graphqlOnce(query, variables) - saludMonday.registrarExito() - return data - } catch (err) { - if (!(err instanceof MondayRetryableError)) { - // Fallo definitivo (AppError 502 de Monday): alimenta el circuit breaker - saludMonday.registrarFallo() - throw err - } - ultimoError = err - } - } - saludMonday.registrarFallo() - throw new AppError(502, `Monday no respondió tras ${this.maxReintentos + 1} intentos: ${ultimoError?.message}`) - } - - private async graphqlOnce(query: string, variables: Record): Promise { - const res = await fetch(this.apiUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: this.apiKey, - 'API-Version': config.monday.apiVersion, - }, - body: JSON.stringify({ query, variables }), - }) - - if (res.status === 429) throw new MondayRetryableError('HTTP 429 (rate limit)') - if (res.status >= 500) throw new MondayRetryableError(`HTTP ${res.status}`) - if (!res.ok) throw new AppError(502, `Monday respondió HTTP ${res.status}`) - - const body = (await res.json()) as GraphQLResponse - - // Monday devuelve errores de complejidad como 200 con error_code - if (body.error_code) { - if (/complexity/i.test(body.error_code) || /complexity/i.test(body.error_message ?? '')) { - throw new MondayRetryableError(`Complejidad excedida: ${body.error_code}`) - } - throw new AppError(502, `Error de Monday: ${body.error_code} ${body.error_message ?? ''}`) - } - if (body.errors?.length) { - const msg = body.errors.map((e) => e.message).join('; ') - const esComplejidad = body.errors.some( - (e) => /complexity/i.test(e.message) || /complexity/i.test(e.extensions?.code ?? ''), - ) - if (esComplejidad) throw new MondayRetryableError(`Complejidad excedida: ${msg}`) - throw new AppError(502, `Errores GraphQL de Monday: ${msg}`) - } - if (!body.data) throw new AppError(502, 'Monday respondió sin data') - return body.data - } - - /** Metadatos de columnas de un tablero, mapeadas por TÍTULO (cache en memoria) */ - private async columnasDe(boardId: string): Promise> { - const cached = this.columnasCache.get(boardId) - if (cached) return cached - const data = await this.graphql<{ boards: { columns: ColumnaMeta[] }[] }>( - `query ($ids: [ID!]) { boards(ids: $ids) { columns { id title type } } }`, - { ids: [boardId] }, - ) - const columnas = data.boards[0]?.columns ?? [] - const mapa = new Map(columnas.map((c) => [c.title, c])) - this.columnasCache.set(boardId, mapa) - return mapa - } - - /** Resuelve el column ID por título exacto; lanza si no existe en el tablero */ - private async columnaId(boardId: string, titulo: string): Promise { - const cols = await this.columnasDe(boardId) - const col = cols.get(titulo) - if (!col) throw new AppError(500, `Columna "${titulo}" no existe en el tablero ${boardId}`) - return col - } - - /** Ping barato para /api/health */ - async ping(): Promise { - try { - const data = await this.graphql<{ boards: { id: string }[] }>( - `query ($ids: [ID!]) { boards(ids: $ids) { id } }`, - { ids: [config.monday.boards.unidades] }, - ) - return (data.boards?.length ?? 0) > 0 - } catch { - return false - } - } - - /** Invalida el cache de unidades (llamar tras cualquier escritura que las afecte) */ - invalidarCacheUnidades(): void { - this.unidadesCache = null - } - - /** Todas las unidades del tablero, con paginación por cursor y cache de 5 min */ - async getUnidades(): Promise { - const ahora = Date.now() - if (this.unidadesCache && this.unidadesCache.expira > ahora) { - return this.unidadesCache.unidades - } - const boardId = config.monday.boards.unidades - const cols = await this.columnasDe(boardId) - - const items: ItemMonday[] = [] - let cursor: string | null = null - do { - // La primera página no lleva cursor; las siguientes sí (nunca asumir una sola página) - const data: { boards: { items_page: { cursor: string | null; items: ItemMonday[] } }[] } = - cursor === null - ? await this.graphql( - `query ($ids: [ID!]) { - boards(ids: $ids) { - items_page(limit: 500) { cursor items { id name column_values { id text value } } } - } - }`, - { ids: [boardId] }, - ) - : await this.graphql( - `query ($ids: [ID!], $cursor: String!) { - boards(ids: $ids) { - items_page(limit: 500, cursor: $cursor) { cursor items { id name column_values { id text value } } } - } - }`, - { ids: [boardId], cursor }, - ) - const page = data.boards[0]?.items_page - if (!page) break - items.push(...page.items) - cursor = page.cursor - } while (cursor !== null) - - const itemIds = new Map() - const unidades: Unidad[] = [] - for (const item of items) { - const unidad = this.mapearUnidad(item, cols) - // Items placeholder (sin modelo ni precios, p. ej. el "Task 1" default de - // Monday) no son inventario: se excluyen del listado - if (unidad.modelo === '' && unidad.precioSinAcabados === 0 && unidad.precioConAcabados === 0) continue - itemIds.set(item.name, item.id) - unidades.push(unidad) - } - this.unidadesCache = { unidades, itemIds, expira: ahora + CACHE_UNIDADES_MS } - // Write-through al espejo local (tabla Unidad): mantiene fresco el - // inventario para el modo local. Best-effort: nunca rompe la lectura. - void this.persistirUnidadesLocal(unidades) - return unidades - } - - /** - * Upsert best-effort de las unidades leídas de Monday a la tabla local - * `Unidad` (fallback del cotizador cuando Monday cae). Protegido para poder - * apagarlo en tests; los errores solo se loguean. - */ - protected async persistirUnidadesLocal(unidades: Unidad[]): Promise { - // Los tests mockean fetch y no deben escribir en la base real - if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') return - try { - for (const u of unidades) { - const datos = { - desarrollo: u.desarrollo, - modelo: u.modelo, - supConstruccion: u.supConstruccion, - supExcedente: u.supExcedente, - supEstacionamiento: u.supEstacionamiento, - precioLista: u.precioSinAcabados, - precioAcabados: u.precioConAcabados, - status: u.status, - etapa: u.etapa ?? '', - } - await prisma.unidad.upsert({ - where: { codigo: u.codigo }, - update: datos, - create: { codigo: u.codigo, ...datos }, - }) - } - } catch (err) { - // eslint-disable-next-line no-console - console.error('[unidades] falló el write-through local (se ignora):', err) - } - } - - /** Una unidad por código (U-101, U-310...); null si no existe */ - async getUnidad(codigo: string): Promise { - const unidades = await this.getUnidades() - return unidades.find((u) => u.codigo === codigo) ?? null - } - - /** Item ID de una unidad por código (para escrituras); null si no existe */ - async itemIdDeUnidad(codigo: string): Promise { - await this.getUnidades() // asegura cache fresco - return this.unidadesCache?.itemIds.get(codigo) ?? null - } - - private mapearUnidad(item: ItemMonday, cols: Map): Unidad { - const porId = new Map(item.column_values.map((cv) => [cv.id, cv])) - const textoDe = (titulo: string): string => { - const col = cols.get(titulo) - if (!col) throw new AppError(500, `Columna "${titulo}" no existe en el tablero Unidades`) - return porId.get(col.id)?.text ?? '' - } - const numeroDe = (titulo: string): number => { - const n = Number(textoDe(titulo)) - return Number.isFinite(n) ? n : 0 - } - const status = textoDe('Disponibilidad').toUpperCase() - // La columna "Meses para firma" es opcional (solo la usa SOLANA) - const colMeses = cols.get('Meses para firma') - const mesesTexto = colMeses ? (porId.get(colMeses.id)?.text ?? '') : '' - const mesesNum = Number(mesesTexto) - // La columna status "Etapa" (pipeline de 10 etapas) es opcional - const colEtapa = cols.get('Etapa') - const etapaTexto = colEtapa ? (porId.get(colEtapa.id)?.text ?? '') : '' - return { - codigo: item.name, - modelo: textoDe('Modelo'), - fechaEntrega: textoDe('Fecha entrega') || null, - supEstacionamiento: numeroDe('Sup. estacionamiento'), - supConstruccion: numeroDe('Sup. construcción'), - supExcedente: numeroDe('Sup. excedente'), - precioSinAcabados: numeroDe('Precio sin acabados'), - precioConAcabados: numeroDe('Precio con acabados'), - // Se conserva el status real del board (VENDIDO, ESCRITURADO, BLOQUEADO, ...) - status: status || 'DISPONIBLE', - desarrollo: textoDe('Desarrollo'), - mesesParaFirma: mesesTexto !== '' && Number.isFinite(mesesNum) ? mesesNum : null, - etapa: etapaTexto !== '' ? etapaTexto : null, - } - } - - /** - * Serializa un valor crudo al formato que espera el tipo de columna de Monday. - * column_values SIEMPRE se envía con JSON.stringify en variables (nunca - * template literals dentro del query). - */ - private valorPorTipo(col: ColumnaMeta, crudo: string | number | boolean): unknown { - switch (col.type) { - case 'status': - return { label: String(crudo) } - case 'dropdown': - return { labels: [String(crudo)] } - case 'date': - return { date: String(crudo) } - case 'numbers': - return typeof crudo === 'number' ? crudo : Number(crudo) - case 'email': - return { email: String(crudo), text: String(crudo) } - case 'phone': { - // LADA explícita: '+1...' (o 11 dígitos empezando en 1) = USA; el resto = México. - // Monday rechaza el número con espacios ("+52 664..."): se envía compacto. - const tel = String(crudo).replace(/\s+/g, '') - const digitos = tel.replace(/\D/g, '') - const esUsa = tel.startsWith('+1') || (digitos.length === 11 && digitos.startsWith('1')) - return { phone: tel, countryShortName: esUsa ? 'US' : 'MX' } - } - case 'checkbox': - return { checked: crudo === true || crudo === 'true' ? 'true' : 'false' } - case 'people': - return { personsAndTeams: [{ id: Number(crudo), kind: 'person' }] } - case 'board_relation': - return { item_ids: [Number(crudo)] } - case 'long_text': - return { text: String(crudo) } - default: - return String(crudo) - } - } - - /** - * Resuelve el label real de una columna status/dropdown tolerando mayúsculas - * y minúsculas (el código escribe 'NUEVO' aunque el board diga 'Nuevo' tras - * un rename en la UI; los dropdown NO auto-crean labels al escribir). Sin - * match devuelve el valor tal cual (Monday dirá que el label no existe). - */ - private async resolverLabel( - boardId: string, - col: ColumnaMeta, - valor: string | number | boolean, - ): Promise { - if (col.type !== 'status' && col.type !== 'dropdown') return valor - const buscado = String(valor).trim().toUpperCase() - const labels = await this.labelsDeColumna(boardId, col.title) - return labels.find((l) => l.trim().toUpperCase() === buscado) ?? valor - } - - private async construirColumnValues( - boardId: string, - valores: Record, - ): Promise { - const out: Record = {} - for (const [titulo, crudo] of Object.entries(valores)) { - if (crudo === undefined || crudo === '') continue - const col = await this.columnaId(boardId, titulo) - out[col.id] = this.valorPorTipo(col, await this.resolverLabel(boardId, col, crudo)) - } - return JSON.stringify(out) - } - - private async crearItem(boardId: string, nombre: string, columnValues: string, groupId?: string): Promise { - const data = await this.graphql<{ create_item: { id: string } }>( - `mutation ($boardId: ID!, $itemName: String!, $columnValues: JSON!, $groupId: String) { - create_item(board_id: $boardId, item_name: $itemName, column_values: $columnValues, group_id: $groupId) { id } - }`, - { boardId, itemName: nombre, columnValues, groupId: groupId ?? null }, - ) - return data.create_item.id - } - - // ---------- Escrituras de negocio ---------- - - async crearOportunidad(datos: DatosOportunidad): Promise { - const boardId = config.monday.boards.oportunidades - const columnValues = await this.construirColumnValues(boardId, { - Unidad: datos.unidadCodigo, - 'Cliente email': datos.clienteEmail, - 'Cliente teléfono': datos.clienteTelefono, - 'Tipo propuesta': datos.tipoPropuestaLabel, - 'Precio de lista': datos.precioLista, - 'Descuento %': datos.descuentoPct, - 'Precio final': datos.precioFinal, - 'Fecha cotización': datos.fechaCotizacion, - Estado: datos.estadoLabel, - }) - return this.crearItem(boardId, datos.clienteNombre, columnValues) - } - - /** - * Oportunidad en etapa 'Afluencia' con todo el perfil de captura (la - * afluencia ES la oportunidad: no hay item aparte en el tablero Leads). - * La columna date 'Fecha' lleva la fecha de captura (hoy, hora local). - * estadoLabel permite crearla en otra etapa (p. ej. 'Agendo Cita' al - * convertir un lead del tablero Leads). - */ - async crearOportunidadAfluencia(datos: DatosOportunidadAfluencia): Promise { - const boardId = config.monday.boards.oportunidades - const hoy = new Date() - const fechaHoy = `${hoy.getFullYear()}-${String(hoy.getMonth() + 1).padStart(2, '0')}-${String(hoy.getDate()).padStart(2, '0')}` - const columnValues = await this.construirColumnValues(boardId, { - Estado: datos.estadoLabel ?? 'Afluencia', - Fecha: fechaHoy, - 'Cliente teléfono': datos.telefono, - 'Cliente email': datos.email, - Desarrollo: datos.desarrolloLabel, - 'Modelo de Interés': datos.modeloLabel, - 'Origen del Lead': datos.origenLabel, - 'Medio de Contacto': datos.medioContacto, - Edad: datos.edad, - 'Estado Civil': datos.estadoCivilLabel, - 'Tipo de Empleo': datos.tipoEmpleoLabel, - Ocupación: datos.ocupacion, - 'Rango Salarial': datos.rangoSalarial, - Crédito: datos.creditoLabel, - 'Tipo de Econimia': datos.tipoEconomiaLabel, // SIC: typo en el título real de la columna - 'Lead Califica': datos.calificaLabel, - 'Proposito de Compra': datos.loQuierePara, - COMENTARIOS: datos.notas, - }) - return this.crearItem(boardId, datos.nombre, columnValues) - } - - /** - * Actualiza una oportunidad existente con el perfil completo de la afluencia - * (mismo builder que crearOportunidadAfluencia) y la pone en etapa - * 'Afluencia'. La usa el botón "Afluencia" de Mis oportunidades en Mi día: - * el formulario se manda prellenado y el envío enriquece el item en vez de - * duplicarlo. El nombre del item se actualiza por el column id 'name'. - */ - async actualizarOportunidadAfluencia(oportunidadId: string, datos: DatosOportunidadAfluencia): Promise { - const boardId = config.monday.boards.oportunidades - const hoy = new Date() - const fechaHoy = `${hoy.getFullYear()}-${String(hoy.getMonth() + 1).padStart(2, '0')}-${String(hoy.getDate()).padStart(2, '0')}` - const valores = JSON.parse( - await this.construirColumnValues(boardId, { - Estado: 'Afluencia', - Fecha: fechaHoy, - 'Cliente teléfono': datos.telefono, - 'Cliente email': datos.email, - Desarrollo: datos.desarrolloLabel, - 'Modelo de Interés': datos.modeloLabel, - 'Origen del Lead': datos.origenLabel, - 'Medio de Contacto': datos.medioContacto, - Edad: datos.edad, - 'Estado Civil': datos.estadoCivilLabel, - 'Tipo de Empleo': datos.tipoEmpleoLabel, - Ocupación: datos.ocupacion, - 'Rango Salarial': datos.rangoSalarial, - Crédito: datos.creditoLabel, - 'Tipo de Econimia': datos.tipoEconomiaLabel, // SIC: typo en el título real - 'Lead Califica': datos.calificaLabel, - 'Proposito de Compra': datos.loQuierePara, - COMENTARIOS: datos.notas, - }), - ) as Record - if (datos.nombre !== '') valores['name'] = datos.nombre - await this.graphql( - `mutation ($boardId: ID!, $itemId: ID!, $columnValues: JSON!) { - change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $columnValues) { id } - }`, - { boardId, itemId: oportunidadId, columnValues: JSON.stringify(valores) }, - ) - } - - /** - * Actualiza una oportunidad existente con los datos de una cotización - * (mismo builder de valores que crearOportunidad) y la mueve a 'Cotizador'. - */ - async actualizarOportunidadCotizacion(oportunidadId: string, datos: DatosOportunidad): Promise { - const boardId = config.monday.boards.oportunidades - const columnValues = await this.construirColumnValues(boardId, { - Unidad: datos.unidadCodigo, - 'Tipo propuesta': datos.tipoPropuestaLabel, - 'Precio de lista': datos.precioLista, - 'Descuento %': datos.descuentoPct, - 'Precio final': datos.precioFinal, - 'Fecha cotización': datos.fechaCotizacion, - Estado: datos.estadoLabel, - }) - await this.graphql( - `mutation ($boardId: ID!, $itemId: ID!, $columnValues: JSON!) { - change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $columnValues) { id } - }`, - { boardId, itemId: oportunidadId, columnValues }, - ) - } - - async crearContacto(datos: DatosContacto): Promise { - const boardId = config.monday.boards.contactos - const columnValues = await this.construirColumnValues(boardId, { - Teléfono: datos.telefono, - Email: datos.email, - CURP: datos.curp, - RFC: datos.rfc, - Dirección: datos.direccion, - Estado: datos.estadoLabel, - // Perfil demográfico (vocabulario CRM FRASA); solo se envía lo capturado - Sexo: datos.sexoLabel, - 'Estado Civil': datos.estadoCivilLabel, - 'Rango de Edad': datos.rangoEdadLabel, - 'Actividad Economica': datos.actividadEconomicaLabel, - 'Vivienda Actual': datos.viviendaActualLabel, - 'Proposito de Compra': datos.propositoCompraLabel, - 'Fecha Nacimiento': datos.fechaNacimiento, - }) - return this.crearItem(boardId, datos.nombre, columnValues) - } - - /** - * Actualiza un Contacto existente con los campos presentes (mismo builder de - * valores que crearContacto) — usado por el guardado progresivo del alta. - * El nombre del item se actualiza por el column id especial 'name'. - */ - async actualizarContacto(itemId: string, datos: Partial): Promise { - const boardId = config.monday.boards.contactos - const valores = JSON.parse( - await this.construirColumnValues(boardId, { - Teléfono: datos.telefono, - Email: datos.email, - CURP: datos.curp, - RFC: datos.rfc, - Dirección: datos.direccion, - Estado: datos.estadoLabel, - Sexo: datos.sexoLabel, - 'Estado Civil': datos.estadoCivilLabel, - 'Rango de Edad': datos.rangoEdadLabel, - 'Actividad Economica': datos.actividadEconomicaLabel, - 'Vivienda Actual': datos.viviendaActualLabel, - 'Proposito de Compra': datos.propositoCompraLabel, - 'Fecha Nacimiento': datos.fechaNacimiento, - }), - ) as Record - if (datos.nombre !== undefined && datos.nombre !== '') valores['name'] = datos.nombre - await this.graphql( - `mutation ($boardId: ID!, $itemId: ID!, $columnValues: JSON!) { - change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $columnValues) { id } - }`, - { boardId, itemId, columnValues: JSON.stringify(valores) }, - ) - } - - async crearApartado(datos: DatosApartado): Promise { - const boardId = config.monday.boards.apartado - // Mapeo a las columnas reales del tablero Apartado (verificadas en Monday): - // contacto (Teléfono/Correo/curp), operación (Precio Final/Enganche/ - // Fecha Compra/Forma de Pago), asignación (Vendedor) y Observaciones. - // Todo lo opcional se omite si viene undefined (mismo patrón de siempre). - const columnValues = await this.construirColumnValues(boardId, { - Unidad: datos.unidadCodigo, - 'Monto apartado': datos.montoApartado, - 'Fecha apartado': datos.fechaApartado, - 'Fecha límite': datos.fechaLimite, - Estado: datos.estadoLabel, - Teléfono: datos.telefono, - 'Correo electrónico': datos.email, - curp: datos.curp, // el título de la columna es minúscula en el board - 'Precio Final': datos.precioFinal, - Enganche: datos.enganche, - 'Fecha Compra': datos.fechaCompra, - Vendedor: datos.vendedorId, - 'Forma de Pago': datos.formaPagoLabel, // dropdown; el resolver ajusta case - Observaciones: datos.observaciones, - }) - return this.crearItem(boardId, datos.clienteNombre, columnValues) - } - - /** - * Actualiza un apartado existente con los campos presentes (mismo builder de - * valores que crearApartado) — usado cuando el alta de cliente arranca desde - * un apartado ya creado (botón "Alta Enkontrol" de Mis apartados): - * se enriquece el item en vez de crear uno duplicado. - */ - async actualizarApartado(itemId: string, datos: Partial): Promise { - const boardId = config.monday.boards.apartado - const valores = JSON.parse( - await this.construirColumnValues(boardId, { - Unidad: datos.unidadCodigo, - 'Monto apartado': datos.montoApartado, - 'Fecha apartado': datos.fechaApartado, - 'Fecha límite': datos.fechaLimite, - Estado: datos.estadoLabel, - Teléfono: datos.telefono, - 'Correo electrónico': datos.email, - curp: datos.curp, - 'Precio Final': datos.precioFinal, - Enganche: datos.enganche, - 'Fecha Compra': datos.fechaCompra, - Vendedor: datos.vendedorId, - 'Forma de Pago': datos.formaPagoLabel, - Observaciones: datos.observaciones, - }), - ) as Record - if (datos.clienteNombre !== undefined && datos.clienteNombre !== '') valores['name'] = datos.clienteNombre - await this.graphql( - `mutation ($boardId: ID!, $itemId: ID!, $columnValues: JSON!) { - change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $columnValues) { id } - }`, - { boardId, itemId, columnValues: JSON.stringify(valores) }, - ) - } - - async actualizarDisponibilidadUnidad(codigo: string, status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA'): Promise { - // Modo local: Monday caído (circuit breaker) → espejo local + réplica encolada - if (!saludMonday.estaDisponible()) { - await this.actualizarDisponibilidadUnidadLocal(codigo, status) - return - } - try { - const boardId = config.monday.boards.unidades - // Asegura cache fresco para resolver el itemId - await this.getUnidades() - const itemId = this.unidadesCache?.itemIds.get(codigo) - if (!itemId) throw new AppError(404, `Unidad ${codigo} no encontrada en Monday`) - const col = await this.columnaId(boardId, 'Disponibilidad') - await this.graphql( - `mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) { - change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id } - }`, - { boardId, itemId, columnId: col.id, value: JSON.stringify({ label: status }) }, - ) - this.invalidarCacheUnidades() - } catch (err) { - // Monday se cayó a media operación: mismo fallback local (el 404 de - // "no existe en Monday" NO es fallo de Monday y se propaga intacto) - if (err instanceof AppError && err.status === 502) { - await this.actualizarDisponibilidadUnidadLocal(codigo, status) - return - } - throw err - } - } - - /** - * Fallback local de actualizarDisponibilidadUnidad: actualiza el status en el - * espejo (tabla Unidad) y encola la réplica DISPONIBILIDAD_UNIDAD para que el - * sync worker la aplique en Monday cuando vuelva. 404 si la unidad no está en - * el espejo. El encolado es best-effort (nunca rompe la escritura local). - */ - private async actualizarDisponibilidadUnidadLocal( - codigo: string, - status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA', - ): Promise { - const resultado = await prisma.unidad.updateMany({ where: { codigo }, data: { status } }) - if (resultado.count === 0) throw new AppError(404, `Unidad ${codigo} no encontrada`) - try { - await prisma.syncPendiente.create({ - data: { - tipo: 'DISPONIBILIDAD_UNIDAD', - payloadJson: JSON.stringify({ codigo, status }), - referenciaLocal: codigo, - }, - }) - } catch (err) { - // eslint-disable-next-line no-console - console.error(`[sync] no se pudo encolar DISPONIBILIDAD_UNIDAD de ${codigo}:`, err) - } - } - - // ---------- Escrituras genéricas ---------- - - /** - * Cambia UNA columna de un item, resolviendo el column ID por título y - * serializando según el tipo de columna (status, dropdown, checkbox, date...). - */ - async cambiarValorColumna( - boardId: string, - itemId: string, - tituloColumna: string, - valor: string | number | boolean, - ): Promise { - const col = await this.columnaId(boardId, tituloColumna) - const resuelto = await this.resolverLabel(boardId, col, valor) - await this.graphql( - `mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) { - change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id } - }`, - { boardId, itemId, columnId: col.id, value: JSON.stringify(this.valorPorTipo(col, resuelto)) }, - ) - } - - /** ID de una columna por título exacto; null si no existe (lectura tolerante) */ - async columnaIdPorTitulo(boardId: string, titulo: string): Promise { - const cols = await this.columnasDe(boardId) - return cols.get(titulo)?.id ?? null - } - - /** Item de cualquier tablero con su grupo y column_values; null si no existe */ - async getItemConGrupo(itemId: string): Promise { - const data = await this.graphql<{ items: (ItemMonday & { group: { id: string } } | null)[] }>( - `query ($ids: [ID!]) { - items(ids: $ids) { id name group { id } column_values { id text value } } - }`, - { ids: [itemId] }, - ) - const item = data.items[0] - if (!item) return null - return { id: item.id, name: item.name, groupId: item.group.id, column_values: item.column_values } - } - - /** Mueve un item a otro grupo de su mismo tablero */ - async moverItemAGrupo(itemId: string, groupId: string): Promise { - await this.graphql<{ move_item_to_group: { id: string } }>( - `mutation ($itemId: ID!, $groupId: String!) { - move_item_to_group(item_id: $itemId, group_id: $groupId) { id } - }`, - { itemId, groupId }, - ) - } - - /** Nombre de un item por ID (cualquier tablero) */ - async obtenerNombreItem(itemId: string): Promise { - const data = await this.graphql<{ items: { name: string }[] }>( - `query ($ids: [ID!]) { items(ids: $ids) { name } }`, - { ids: [itemId] }, - ) - const nombre = data.items[0]?.name - if (nombre === undefined) throw new AppError(404, `Item ${itemId} no encontrado en Monday`) - return nombre - } - - // ---------- Motivos No Compra / Actividades (tableros CRM FRASA) ---------- - - /** Item en el tablero Motivos No Compra (nombre = " — "), - * creado directo en el grupo de su categoría (si está mapeada) */ - async crearMotivoNoCompra(datos: DatosMotivoNoCompra): Promise { - const boardId = config.monday.boards.motivos - if (boardId === '') throw new AppError(500, 'MONDAY_BOARD_MOTIVOS no está configurado') - const columnValues = await this.construirColumnValues(boardId, { - Categoria: datos.categoria, - Subcategoria: datos.subcategoria, - Descripcion: datos.descripcion, - }) - const buscada = datos.categoria.trim().toUpperCase() - const groupId = Object.entries(GRUPOS_MOTIVOS_NO_COMPRA).find( - ([categoria]) => categoria.toUpperCase() === buscada, - )?.[1] - return this.crearItem(boardId, datos.nombre, columnValues, groupId) - } - - /** Actividad de seguimiento ligada a un lead (bitácora del CRM) */ - async crearActividad(datos: DatosActividad): Promise { - const boardId = config.monday.boards.actividades - if (boardId === '') throw new AppError(500, 'MONDAY_BOARD_ACTIVIDADES no está configurado') - // La columna board_relation "Lead" no puede crearse vía API (limitación de - // Monday): si existe en el tablero se liga, si no, la actividad se crea igual. - let lead: string | undefined - try { - await this.columnaId(boardId, 'Lead') - lead = datos.leadItemId - } catch { - lead = undefined - } - const columnValues = await this.construirColumnValues(boardId, { - Tipo: datos.tipoLabel, - Responsable: datos.responsableId, - Fecha: datos.fecha, - Estado: datos.estadoLabel, - Lead: lead, - }) - return this.crearItem(boardId, datos.nombre, columnValues) - } - - // ---------- Leads (automatizaciones) ---------- - - /** Items del tablero Leads con estado, fecha de actualización y vendedor asignado */ - async getLeads(): Promise { - const boardId = config.monday.boards.leads - const cols = await this.columnasDe(boardId) - - const items: ItemConFechas[] = [] - let cursor: string | null = null - do { - const data: { boards: { items_page: { cursor: string | null; items: ItemConFechas[] } }[] } = - cursor === null - ? await this.graphql( - `query ($ids: [ID!]) { - boards(ids: $ids) { - items_page(limit: 200) { - cursor - items { id name created_at updated_at column_values { id text value } } - } - } - }`, - { ids: [boardId] }, - ) - : await this.graphql( - `query ($ids: [ID!], $cursor: String!) { - boards(ids: $ids) { - items_page(limit: 200, cursor: $cursor) { - cursor - items { id name created_at updated_at column_values { id text value } } - } - } - }`, - { ids: [boardId], cursor }, - ) - const page = data.boards[0]?.items_page - if (!page) break - items.push(...page.items) - cursor = page.cursor - } while (cursor !== null) - - return items.map((item) => this.mapearLead(item, cols)) - } - - private mapearLead(item: ItemConFechas, cols: Map): LeadMonday { - const colVendedor = cols.get('Vendedor') - if (!colVendedor || colVendedor.type !== 'people') { - throw new AppError(500, `Columna people "Vendedor" no existe en el tablero Leads`) - } - const colEstado = cols.get('Estado') - const porId = new Map(item.column_values.map((cv) => [cv.id, cv])) - const rawVendedor = porId.get(colVendedor.id)?.value - let vendedorIds: number[] = [] - if (rawVendedor) { - try { - const parsed = JSON.parse(rawVendedor) as { personsAndTeams?: { id: number; kind: string }[] } - vendedorIds = (parsed.personsAndTeams ?? []).filter((p) => p.kind === 'person').map((p) => p.id) - } catch { - vendedorIds = [] - } - } - const colOrigen = cols.get('Origen') - const colTelefono = cols.get('Teléfono') - const colEmail = cols.get('Email') - // Cuando el status nunca se ha escrito, Monday devuelve value=null pero - // `text` muestra el label default del tablero (hoy "Descartado"): el estado - // real es vacío, no el default. Se lee `text` SOLO si hay value. - const estadoCv = colEstado ? porId.get(colEstado.id) : undefined - const estado = estadoCv?.value ? (estadoCv.text ?? '') : '' - // Perfilamiento: lectura tolerante por título ('' / null si falta la columna) - const textoDe = (titulo: string): string => { - const col = cols.get(titulo) - return col ? (porId.get(col.id)?.text ?? '') : '' - } - const edadRaw = textoDe('Edad') - const edadNum = Number(edadRaw) - return { - id: item.id, - nombre: item.name, - createdAt: item.created_at, - updatedAt: item.updated_at, - estado, - origen: colOrigen ? (porId.get(colOrigen.id)?.text ?? '') : '', - telefono: colTelefono ? (porId.get(colTelefono.id)?.text ?? '') : '', - email: colEmail ? (porId.get(colEmail.id)?.text ?? '') : '', - vendedorIds, - desarrollo: textoDe('Desarrollo'), - modeloInteres: textoDe('Modelo de Interés'), - medioContacto: textoDe('Medio de Contacto'), - edad: edadRaw.trim() === '' || Number.isNaN(edadNum) ? null : edadNum, - estadoCivil: textoDe('Estado Civil'), - tipoEmpleo: textoDe('Tipo de Empleo'), - credito: textoDe('Crédito'), - tipoEconomia: textoDe('Tipo de Economía'), - califica: textoDe('Califica'), - rangoSalarial: textoDe('Rango Salarial'), - loQuierePara: textoDe('Lo Quiere Para'), - } - } - - /** - * Items del tablero Oportunidades con etapa, unidad, precio final, asesor - * asignado y perfil de afluencia (los consumen las automatizaciones - * Aparto/Ganado, el round-robin, Mi día y los reportes). - */ - async getOportunidades(): Promise { - const boardId = config.monday.boards.oportunidades - const cols = await this.columnasDe(boardId) - if (!cols.has('Estado')) { - throw new AppError(500, `Columna "Estado" no existe en el tablero Oportunidades (${boardId})`) - } - - const items: ItemConFechas[] = [] - let cursor: string | null = null - do { - const data: { boards: { items_page: { cursor: string | null; items: ItemConFechas[] } }[] } = - cursor === null - ? await this.graphql( - `query ($ids: [ID!]) { - boards(ids: $ids) { - items_page(limit: 200) { - cursor - items { id name created_at updated_at column_values { id text value } } - } - } - }`, - { ids: [boardId] }, - ) - : await this.graphql( - `query ($ids: [ID!], $cursor: String!) { - boards(ids: $ids) { - items_page(limit: 200, cursor: $cursor) { - cursor - items { id name created_at updated_at column_values { id text value } } - } - } - }`, - { ids: [boardId], cursor }, - ) - const page = data.boards[0]?.items_page - if (!page) break - items.push(...page.items) - cursor = page.cursor - } while (cursor !== null) - - return items.map((item) => this.mapearOportunidad(item, cols)) - } - - private mapearOportunidad(item: ItemConFechas, cols: Map): OportunidadMonday { - const porId = new Map(item.column_values.map((cv) => [cv.id, cv])) - // Lectura tolerante por título ('' si falta la columna o el valor) - const textoDe = (titulo: string): string => { - const col = cols.get(titulo) - return col ? (porId.get(col.id)?.text ?? '') : '' - } - // Misma corrección que mapearLead: cuando el status nunca se ha escrito, - // Monday devuelve value=null pero `text` muestra el default del tablero; - // la etapa real es vacía. Se lee `text` SOLO si hay value. - const colEstado = cols.get('Estado') - const estadoCv = colEstado ? porId.get(colEstado.id) : undefined - const etapa = estadoCv?.value ? (estadoCv.text ?? '') : '' - const colAsesor = cols.get('Asesor') - const precio = Number(textoDe('Precio final')) - const edadRaw = textoDe('Edad') - const edadNum = Number(edadRaw) - return { - id: item.id, - nombre: item.name, - createdAt: item.created_at, - updatedAt: item.updated_at, - etapa, - unidadCodigo: textoDe('Unidad'), - precioFinal: Number.isFinite(precio) ? precio : 0, - asesorIds: colAsesor ? this.peopleIds(porId.get(colAsesor.id)?.value) : [], - telefono: textoDe('Cliente teléfono'), - email: textoDe('Cliente email'), - origen: textoDe('Origen del Lead'), - desarrollo: textoDe('Desarrollo'), - modeloInteres: textoDe('Modelo de Interés'), - medioContacto: textoDe('Medio de Contacto'), - edad: edadRaw.trim() === '' || Number.isNaN(edadNum) ? null : edadNum, - estadoCivil: textoDe('Estado Civil'), - tipoEmpleo: textoDe('Tipo de Empleo'), - credito: textoDe('Crédito'), - tipoEconomia: textoDe('Tipo de Econimia'), // SIC: typo en el título real - califica: textoDe('Lead Califica'), - rangoSalarial: textoDe('Rango Salarial'), - loQuierePara: textoDe('Proposito de Compra'), - fechaCita: textoDe('Fecha') || undefined, - } - } - - - /** - * Oportunidad con el mismo teléfono (dedup de la captura de afluencia, que - * ahora vive en Oportunidades). Compara solo dígitos y los últimos 10 - * (tolerante a lada/prefijo 52). null si no hay duplicado. - */ - async buscarOportunidadPorTelefono(telefono: string): Promise { - const digitos = telefono.replace(/\D/g, '') - if (digitos.length < 10) return null - const clave = digitos.slice(-10) - const oportunidades = await this.getOportunidades() - return ( - oportunidades.find((o) => { - const d = o.telefono.replace(/\D/g, '') - return d.length >= 10 && d.slice(-10) === clave - }) ?? null - ) - } - - // ---------- Mi día (CRM ligero para vendedores) ---------- - - /** - * Último evento del activity log por item: UNA query por tablero con todos - * los itemIds (limit = items × 5, por si hay varios eventos por item). El - * item se identifica por el pulse_id del payload `data` (JSON string). El - * created_at viene como string numérico; por magnitud se normaliza a ms: - * > 1e16 ⇒ centésimas de µs (así llega hoy, 17 dígitos), > 1e14 ⇒ µs, - * si no ms. itemIds vacío ⇒ Map vacío sin llamar a Monday. - */ - async getUltimosCambios(boardId: string, itemIds: string[]): Promise> { - const resultado = new Map() - if (itemIds.length === 0) return resultado - const data = await this.graphql<{ boards: { activity_logs: ActivityLogMonday[] }[] }>( - `query ($ids: [ID!], $itemIds: [ID!], $limit: Int!) { - boards(ids: $ids) { - activity_logs(item_ids: $itemIds, limit: $limit) { id event created_at user_id data } - } - }`, - { ids: [boardId], itemIds, limit: itemIds.length * 5 }, - ) - for (const log of data.boards[0]?.activity_logs ?? []) { - let itemId: string | undefined - try { - const datos = JSON.parse(log.data) as { pulse_id?: number | string } - itemId = datos.pulse_id === undefined ? undefined : String(datos.pulse_id) - } catch { - continue - } - if (itemId === undefined) continue - const crudo = Number(log.created_at) - if (!Number.isFinite(crudo)) continue - // Normaliza a ms por magnitud: 100ns (17 dígitos) /1e4, µs /1e3, ms directo - const ms = crudo > 1e16 ? crudo / 1e4 : crudo > 1e14 ? crudo / 1e3 : crudo - const fechaISO = new Date(ms).toISOString() - // Las fechas ISO comparan bien como strings: solo nos quedamos con el más reciente - const actual = resultado.get(itemId) - if (actual && actual.fechaISO >= fechaISO) continue - const userId = Number(log.user_id) - resultado.set(itemId, { - fechaISO, - usuarioId: Number.isFinite(userId) && userId > 0 ? userId : null, - evento: log.event, - }) - } - return resultado - } - - /** - * TODOS los eventos del activity log de un conjunto de items (historial del - * módulo Contactos), más reciente primero. Misma normalización de created_at - * que getUltimosCambios. - */ - async getHistorialItems(boardId: string, itemIds: string[]): Promise { - if (itemIds.length === 0) return [] - const data = await this.graphql<{ boards: { activity_logs: ActivityLogMonday[] }[] }>( - `query ($ids: [ID!], $itemIds: [ID!], $limit: Int!) { - boards(ids: $ids) { - activity_logs(item_ids: $itemIds, limit: $limit) { id event created_at user_id data } - } - }`, - { ids: [boardId], itemIds, limit: Math.min(itemIds.length * 10, 200) }, - ) - const eventos: EventoHistorialMonday[] = [] - for (const log of data.boards[0]?.activity_logs ?? []) { - let itemId: string | undefined - try { - const datos = JSON.parse(log.data) as { pulse_id?: number | string } - itemId = datos.pulse_id === undefined ? undefined : String(datos.pulse_id) - } catch { - continue - } - if (itemId === undefined) continue - const crudo = Number(log.created_at) - if (!Number.isFinite(crudo)) continue - const ms = crudo > 1e16 ? crudo / 1e4 : crudo > 1e14 ? crudo / 1e3 : crudo - const userId = Number(log.user_id) - eventos.push({ - itemId, - fechaISO: new Date(ms).toISOString(), - usuarioId: Number.isFinite(userId) && userId > 0 ? userId : null, - evento: log.event, - }) - } - return eventos.sort((a, b) => b.fechaISO.localeCompare(a.fechaISO)) - } - - /** Item del tablero Contactos con TODAS sus columnas por título ('' si vacía) */ - async getContactoDetalle(itemId: string): Promise | null> { - const boardId = config.monday.boards.contactos - const [cols, item] = await Promise.all([this.columnasDe(boardId), this.getItemConGrupo(itemId)]) - if (!item) return null - const porTitulo: Record = { nombre: item.name } - for (const [titulo, col] of cols) { - const cv = item.column_values.find((c) => c.id === col.id) - // status nunca escrito: value null pero text muestra el default del tablero - porTitulo[titulo] = cv?.value ? (cv.text ?? '') : '' - } - return porTitulo - } - - /** Parsea el value JSON de una columna people a los user IDs (solo personas) */ - private peopleIds(raw: string | null | undefined): number[] { - if (!raw) return [] - try { - const parsed = JSON.parse(raw) as { personsAndTeams?: { id: number; kind: string }[] } - return (parsed.personsAndTeams ?? []).filter((p) => p.kind === 'person').map((p) => p.id) - } catch { - return [] - } - } - - /** Todas las actividades del tablero Actividades, con tipo, fecha, estado y responsables */ - async getActividades(): Promise { - const boardId = config.monday.boards.actividades - if (boardId === '') return [] - const cols = await this.columnasDe(boardId) - const colTipo = cols.get('Tipo') - const colFecha = cols.get('Fecha') - const colEstado = cols.get('Estado') - const colResponsable = cols.get('Responsable') - - const items: ItemMonday[] = [] - let cursor: string | null = null - do { - const data: { boards: { items_page: { cursor: string | null; items: ItemMonday[] } }[] } = - cursor === null - ? await this.graphql( - `query ($ids: [ID!]) { - boards(ids: $ids) { - items_page(limit: 500) { - cursor - items { id name column_values { id text value } } - } - } - }`, - { ids: [boardId] }, - ) - : await this.graphql( - `query ($ids: [ID!], $cursor: String!) { - boards(ids: $ids) { - items_page(limit: 500, cursor: $cursor) { - cursor - items { id name column_values { id text value } } - } - } - }`, - { ids: [boardId], cursor }, - ) - const page = data.boards[0]?.items_page - if (!page) break - items.push(...page.items) - cursor = page.cursor - } while (cursor !== null) - - return items.map((item) => { - const porId = new Map(item.column_values.map((cv) => [cv.id, cv])) - return { - id: item.id, - nombre: item.name, - tipo: colTipo ? (porId.get(colTipo.id)?.text ?? '') : '', - fecha: colFecha ? (porId.get(colFecha.id)?.text ?? '') : '', - estado: colEstado ? (porId.get(colEstado.id)?.text ?? '') : '', - responsableIds: colResponsable ? this.peopleIds(porId.get(colResponsable.id)?.value) : [], - } - }) - } - - /** Todos los apartados del tablero Apartado, con unidad, fecha límite, estado y vendedor */ - async getApartados(): Promise { - const boardId = config.monday.boards.apartado - const cols = await this.columnasDe(boardId) - const colUnidad = cols.get('Unidad') - const colFechaLimite = cols.get('Fecha límite') - const colEstado = cols.get('Estado') - const colVendedor = cols.get('Vendedor') - const colTelefono = cols.get('Teléfono') - const colEmail = cols.get('Correo electrónico') - const colPrecioFinal = cols.get('Precio Final') - const colFechaCompra = cols.get('Fecha Compra') - const colCurp = cols.get('curp') - const colFormaPago = cols.get('Forma de Pago') - const colEnganche = cols.get('Enganche') - const colMontoApartado = cols.get('Monto apartado') - - const items: ItemMonday[] = [] - let cursor: string | null = null - do { - const data: { boards: { items_page: { cursor: string | null; items: ItemMonday[] } }[] } = - cursor === null - ? await this.graphql( - `query ($ids: [ID!]) { - boards(ids: $ids) { - items_page(limit: 500) { - cursor - items { id name column_values { id text value } } - } - } - }`, - { ids: [boardId] }, - ) - : await this.graphql( - `query ($ids: [ID!], $cursor: String!) { - boards(ids: $ids) { - items_page(limit: 500, cursor: $cursor) { - cursor - items { id name column_values { id text value } } - } - } - }`, - { ids: [boardId], cursor }, - ) - const page = data.boards[0]?.items_page - if (!page) break - items.push(...page.items) - cursor = page.cursor - } while (cursor !== null) - - return items.map((item) => { - const porId = new Map(item.column_values.map((cv) => [cv.id, cv])) - // Misma corrección que mapearLead: status nunca escrito → value null y - // `text` muestra el default del tablero; el estado real es vacío - const estadoCv = colEstado ? porId.get(colEstado.id) : undefined - const estado = estadoCv?.value ? (estadoCv.text ?? '') : '' - return { - id: item.id, - nombre: item.name, - unidadCodigo: colUnidad ? (porId.get(colUnidad.id)?.text ?? '') : '', - fechaLimite: colFechaLimite ? (porId.get(colFechaLimite.id)?.text ?? '') : '', - estado, - vendedorIds: colVendedor ? this.peopleIds(porId.get(colVendedor.id)?.value) : [], - telefono: colTelefono ? (porId.get(colTelefono.id)?.text ?? '') : '', - email: colEmail ? (porId.get(colEmail.id)?.text ?? '') : '', - precioFinal: colPrecioFinal ? Number(porId.get(colPrecioFinal.id)?.text ?? 0) || 0 : 0, - fechaCompra: colFechaCompra ? (porId.get(colFechaCompra.id)?.text ?? '') : '', - curp: colCurp ? (porId.get(colCurp.id)?.text ?? '') : '', - formaPago: colFormaPago ? (porId.get(colFormaPago.id)?.text ?? '') : '', - enganche: colEnganche ? Number(porId.get(colEnganche.id)?.text ?? 0) || 0 : 0, - montoApartado: colMontoApartado ? Number(porId.get(colMontoApartado.id)?.text ?? 0) || 0 : 0, - } - }) - } - - /** - * Contactos del tablero Contactos (módulo Contactos de la landing): nombre, - * teléfono, email y estado, ordenados por updated_at desc, máx 100. - */ - async getContactos(): Promise { - const boardId = config.monday.boards.contactos - const cols = await this.columnasDe(boardId) - const colTelefono = cols.get('Teléfono') - const colEmail = cols.get('Email') - const colEstado = cols.get('Estado') - - const items: ItemConFechas[] = [] - let cursor: string | null = null - do { - const data: { boards: { items_page: { cursor: string | null; items: ItemConFechas[] } }[] } = - cursor === null - ? await this.graphql( - `query ($ids: [ID!]) { - boards(ids: $ids) { - items_page(limit: 100) { - cursor - items { id name updated_at column_values { id text value } } - } - } - }`, - { ids: [boardId] }, - ) - : await this.graphql( - `query ($ids: [ID!], $cursor: String!) { - boards(ids: $ids) { - items_page(limit: 100, cursor: $cursor) { - cursor - items { id name updated_at column_values { id text value } } - } - } - }`, - { ids: [boardId], cursor }, - ) - const page = data.boards[0]?.items_page - if (!page) break - items.push(...page.items) - cursor = page.cursor - } while (cursor !== null) - - return items - .map((item) => { - const porId = new Map(item.column_values.map((cv) => [cv.id, cv])) - // Misma corrección que mapearLead: status nunca escrito → value null y - // `text` muestra el default del tablero; el estado real es vacío - const estadoCv = colEstado ? porId.get(colEstado.id) : undefined - const estado = estadoCv?.value ? (estadoCv.text ?? '') : '' - return { - id: item.id, - nombre: item.name, - telefono: colTelefono ? (porId.get(colTelefono.id)?.text ?? '') : '', - email: colEmail ? (porId.get(colEmail.id)?.text ?? '') : '', - estado, - updatedAt: item.updated_at, - } - }) - .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) - .slice(0, 100) - } - - /** - * Labels de una columna status/dropdown (settings_str del board). - * Monday los devuelve como objeto {index: label} o como arreglo [{id, name}]. - */ - async labelsDeColumna(boardId: string, titulo: string): Promise { - const data = await this.graphql<{ boards: { columns: { id: string; title: string; settings_str: string }[] }[] }>( - `query ($ids: [ID!]) { boards(ids: $ids) { columns { id title settings_str } } }`, - { ids: [boardId] }, - ) - const col = data.boards[0]?.columns.find((c) => c.title === titulo) - if (!col) throw new AppError(500, `Columna "${titulo}" no existe en el tablero ${boardId}`) - try { - const settings = JSON.parse(col.settings_str || '{}') as { labels?: Record | { name: string }[] } - const labels = settings.labels - if (Array.isArray(labels)) return labels.map((l) => l.name) - if (labels && typeof labels === 'object') return Object.values(labels) - return [] - } catch { - return [] - } - } - - /** - * Item en el tablero Leads (Estado NUEVO; captura rápida del vendedor desde - * /hoy o captura manual). La columna date 'Fecha' lleva la fecha de captura - * (hoy, hora local). Los leads NO crean oportunidad (eso es /api/afluencias). - */ - async crearLead(datos: DatosLead): Promise { - const boardId = config.monday.boards.leads - const hoy = new Date() - const fechaHoy = `${hoy.getFullYear()}-${String(hoy.getMonth() + 1).padStart(2, '0')}-${String(hoy.getDate()).padStart(2, '0')}` - const columnValues = await this.construirColumnValues(boardId, { - Estado: 'NUEVO', - Fecha: fechaHoy, - Teléfono: datos.telefono, - Email: datos.email, - Origen: datos.origenLabel, - Desarrollo: datos.desarrolloLabel, - Notas: datos.notas, - }) - return this.crearItem(boardId, datos.nombre, columnValues) - } - - /** - * Lead con el mismo teléfono (dedup de captura rápida). Compara solo dígitos - * y los últimos 10 (tolerante a lada/prefijo 52). null si no hay duplicado. - */ - async buscarLeadPorTelefono(telefono: string): Promise { - const digitos = telefono.replace(/\D/g, '') - if (digitos.length < 10) return null - const clave = digitos.slice(-10) - const leads = await this.getLeads() - return ( - leads.find((l) => { - const d = l.telefono.replace(/\D/g, '') - return d.length >= 10 && d.slice(-10) === clave - }) ?? null - ) - } - - /** Borra un item de cualquier tablero (limpieza de datos de prueba) */ - async eliminarItem(itemId: string): Promise { - await this.graphql<{ delete_item: { id: string } }>( - `mutation ($itemId: ID!) { delete_item(item_id: $itemId) { id } }`, - { itemId }, - ) - } - - /** Item en el tablero Ventas (se crea cuando una oportunidad pasa a 'Ganado') */ - async crearVenta(datos: DatosVenta): Promise { - const boardId = config.monday.boards.ventas - const columnValues = await this.construirColumnValues(boardId, { - Unidad: datos.unidadCodigo, - 'Valor venta': datos.valorVenta, - }) - return this.crearItem(boardId, datos.clienteNombre, columnValues) - } - - /** - * Número de actividades con Estado 'Abierto' en el tablero Actividades. - * Si el tablero no está configurado devuelve 0 (los reportes no deben caer por eso). - */ - async getActividadesAbiertas(): Promise { - const boardId = config.monday.boards.actividades - if (boardId === '') return 0 - const cols = await this.columnasDe(boardId) - const colEstado = cols.get('Estado') - if (!colEstado) return 0 - - let abiertas = 0 - let cursor: string | null = null - do { - const data: { boards: { items_page: { cursor: string | null; items: ItemMonday[] } }[] } = - cursor === null - ? await this.graphql( - `query ($ids: [ID!]) { - boards(ids: $ids) { - items_page(limit: 500) { cursor items { id column_values { id text } } } - } - }`, - { ids: [boardId] }, - ) - : await this.graphql( - `query ($ids: [ID!], $cursor: String!) { - boards(ids: $ids) { - items_page(limit: 500, cursor: $cursor) { cursor items { id column_values { id text } } } - } - }`, - { ids: [boardId], cursor }, - ) - const page = data.boards[0]?.items_page - if (!page) break - for (const item of page.items) { - const estado = item.column_values.find((cv) => cv.id === colEstado.id)?.text ?? '' - if (estado.trim().toUpperCase() === 'ABIERTO') abiertas++ - } - cursor = page.cursor - } while (cursor !== null) - return abiertas - } - - /** Asigna una persona (user ID de Monday) a una columna people de un item */ - private async asignarPersona(boardId: string, itemId: string, tituloColumna: string, userId: number): Promise { - const col = await this.columnaId(boardId, tituloColumna) - await this.graphql( - `mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) { - change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id } - }`, - { - boardId, - itemId, - columnId: col.id, - value: JSON.stringify({ personsAndTeams: [{ id: userId, kind: 'person' }] }), - }, - ) - } - - /** Asigna un asesor (user ID de Monday) a la columna people "Asesor" de una oportunidad */ - async asignarAsesorAOportunidad(itemId: string, userId: number): Promise { - return this.asignarPersona(config.monday.boards.oportunidades, itemId, 'Asesor', userId) - } - - /** Asigna un vendedor (user ID de Monday) a la columna people "Vendedor" de un lead */ - async asignarVendedorALead(itemId: string, userId: number): Promise { - return this.asignarPersona(config.monday.boards.leads, itemId, 'Vendedor', userId) - } - - /** Crea un update (comentario) en un item — usado para los recordatorios de inactividad */ - async crearUpdateEnItem(itemId: string, body: string): Promise { - const data = await this.graphql<{ create_update: { id: string } }>( - `mutation ($itemId: ID!, $body: String!) { - create_update(item_id: $itemId, body: $body) { id } - }`, - { itemId, body }, - ) - return data.create_update.id - } - - /** - * Usuarios activos no-guest del workspace (excluye las cuentas bot de Monday, - * cuyo email termina en @agent.monday.com). - */ - async getUsuariosWorkspace(): Promise { - const data = await this.graphql<{ - users: { id: string; name: string; email: string; is_guest: boolean; enabled: boolean }[] - }>(`query { users(limit: 50) { id name email is_guest enabled } }`) - return data.users - .filter((u) => !u.is_guest && u.enabled && !u.email.endsWith('@agent.monday.com')) - .map((u) => ({ id: Number(u.id), nombre: u.name, email: u.email })) - } - - /** - * Miembros de un equipo (team) de Monday por nombre, case-insensitive - * (p. ej. "vendedores" para el round-robin). Mismo filtro que el workspace. - */ - async getUsuariosEquipo(nombre: string): Promise { - const data = await this.graphql<{ - teams: { name: string; users: { id: string; name: string; email: string; is_guest: boolean; enabled: boolean }[] }[] | null - }>(`query { teams { name users { id name email is_guest enabled } } }`) - const equipo = (data.teams ?? []).find( - (t) => t.name.trim().toUpperCase() === nombre.trim().toUpperCase(), - ) - if (!equipo) throw new AppError(500, `El equipo "${nombre}" no existe en Monday`) - return equipo.users - .filter((u) => !u.is_guest && u.enabled && !u.email.endsWith('@agent.monday.com')) - .map((u) => ({ id: Number(u.id), nombre: u.name, email: u.email })) - } -} - -// ---------- DTOs de escritura ---------- - -export interface DatosOportunidad { - clienteNombre: string - unidadCodigo: string - tipoPropuestaLabel: string - precioLista: number - descuentoPct: number // porcentaje (2 = 2%) - precioFinal: number - fechaCotizacion: string // 'YYYY-MM-DD' - estadoLabel: string // p. ej. 'COTIZADA' - clienteEmail?: string - clienteTelefono?: string -} - -export interface DatosOportunidadAfluencia { - nombre: string // name del item = nombre del interesado - /** Etapa inicial (default 'Afluencia'; 'Agendo Cita' al convertir un lead) */ - estadoLabel?: string | undefined - telefono?: string | undefined - email?: string | undefined - desarrolloLabel?: string | undefined // label tal cual de la columna status 'Desarrollo' - // Campos de calificación de la afluencia (labels reales de las columnas del - // board Oportunidades; los dropdown no auto-crean labels, deben venir sembrados) - origenLabel?: string | undefined // dropdown 'Origen del Lead' - modeloLabel?: string | undefined // status 'Modelo de Interés' - medioContacto?: string | undefined // text 'Medio de Contacto' - edad?: number | undefined - estadoCivilLabel?: string | undefined // dropdown 'Estado Civil' - tipoEmpleoLabel?: string | undefined // status 'Tipo de Empleo' - ocupacion?: string | undefined // text 'Ocupación' - rangoSalarial?: string | undefined // text 'Rango Salarial' - creditoLabel?: string | undefined // status 'Crédito' - tipoEconomiaLabel?: string | undefined // status 'Tipo de Econimia' (SIC: typo real) - calificaLabel?: string | undefined // dropdown 'Lead Califica' - loQuierePara?: string | undefined // dropdown 'Proposito de Compra' - /** text '# Dependientes' (si el board aún no tiene la columna, Monday lo ignora) */ - numDependientes?: string | undefined - notas?: string | undefined // long_text 'COMENTARIOS' -} - -export interface DatosContacto { - nombre: string - telefono?: string - email?: string - curp?: string - rfc?: string - direccion?: string - estadoLabel: string // p. ej. 'CLIENTE' - // Perfil demográfico (labels del dropdown correspondiente) - sexoLabel?: string | undefined - estadoCivilLabel?: string | undefined - rangoEdadLabel?: string | undefined - actividadEconomicaLabel?: string | undefined - viviendaActualLabel?: string | undefined - propositoCompraLabel?: string | undefined - fechaNacimiento?: string | undefined // 'YYYY-MM-DD' -} - -export interface DatosApartado { - clienteNombre: string - unidadCodigo: string - montoApartado?: number - fechaApartado: string // 'YYYY-MM-DD' - fechaLimite: string // 'YYYY-MM-DD' - estadoLabel: string // p. ej. 'Pago Apartado' (pipeline Enkontrol) - // Contacto y operación (opcionales; columnas reales del tablero Apartado) - telefono?: string | undefined // phone 'Teléfono' - email?: string | undefined // email 'Correo electrónico' - curp?: string | undefined // text 'curp' (título minúscula en el board) - precioFinal?: number | undefined // numbers 'Precio Final' - enganche?: number | undefined // numbers 'Enganche' - fechaCompra?: string | undefined // date 'Fecha Compra' ('YYYY-MM-DD') - vendedorId?: number | undefined // people 'Vendedor' (user ID de Monday) - formaPagoLabel?: string | undefined // dropdown 'Forma de Pago' (labels ya sembrados) - observaciones?: string | undefined // long_text 'Observaciones' -} - -export interface DatosVenta { - clienteNombre: string - unidadCodigo?: string - valorVenta?: number -} - -export interface DatosMotivoNoCompra { - nombre: string // ' — ' - categoria: string // una de MOTIVOS_NO_COMPRA - /** Label de la columna status 'Subcategoria' (catálogo del board de referencia) */ - subcategoria?: string - descripcion?: string -} - -export interface DatosActividad { - nombre: string - tipoLabel: string // 'Llamada' | 'Reunion' | 'Tarea' - responsableId?: number // user ID de Monday (se omite si no hay responsable claro) - fecha: string // 'YYYY-MM-DD' - estadoLabel: string // 'Abierto' | 'Listo' - leadItemId?: string // item del tablero Leads relacionado (se omite si no aplica) -} - -/** Captura ligera de lead (tablero Leads): la forma corta de /hoy y Monday UI */ -export interface DatosLead { - nombre: string - telefono?: string | undefined - email?: string | undefined - origenLabel?: string | undefined // label real de la columna status 'Origen' del board Leads - desarrolloLabel?: string | undefined // label real de la columna status 'Desarrollo' del board Leads - notas?: string | undefined -} - -/** Singleton usado por los controllers (los tests instancian el suyo) */ -export const mondayService = new MondayService() - diff --git a/api-gateway/src/services/push.service.test.ts b/api-gateway/src/services/push.service.test.ts index 26fab29..2090486 100644 --- a/api-gateway/src/services/push.service.test.ts +++ b/api-gateway/src/services/push.service.test.ts @@ -37,7 +37,6 @@ import { registrarSuscripcion, eliminarSuscripcion, enviarPush, - usuarioIdPorMondayUserId, obtenerVapidPublicKey, } from './push.service.js' @@ -118,20 +117,6 @@ describe('push.service', () => { }) }) - describe('usuarioIdPorMondayUserId', () => { - it('devuelve el usuarioId si encuentra el usuario', async () => { - prismaMock.usuario.findFirst.mockResolvedValue({ id: 'local-user-1' }) - const result = await usuarioIdPorMondayUserId(12345) - expect(result).toBe('local-user-1') - }) - - it('devuelve null si no encuentra el usuario', async () => { - prismaMock.usuario.findFirst.mockResolvedValue(null) - const result = await usuarioIdPorMondayUserId(99999) - expect(result).toBeNull() - }) - }) - describe('obtenerVapidPublicKey', () => { it('devuelve la VAPID public key del config', () => { expect(obtenerVapidPublicKey()).toBe('test-public-key') diff --git a/api-gateway/src/services/push.service.ts b/api-gateway/src/services/push.service.ts index 5866cab..fa12e40 100644 --- a/api-gateway/src/services/push.service.ts +++ b/api-gateway/src/services/push.service.ts @@ -45,15 +45,6 @@ export function obtenerVapidPublicKey(): string { return config.push.vapidPublicKey } -/** Busca el usuarioId local a partir de un mondayUserId */ -export async function usuarioIdPorMondayUserId(mondayUserId: number): Promise { - const usuario = await prisma.usuario.findFirst({ - where: { mondayUserId }, - select: { id: true }, - }) - return usuario?.id ?? null -} - /** Envía una notificación push a un usuario. Silencioso si no tiene suscripción o si web-push no está configurado. */ export async function enviarPush( usuarioId: string, diff --git a/api-gateway/src/services/recordatorio-vendedor.service.test.ts b/api-gateway/src/services/recordatorio-vendedor.service.test.ts index e3feec7..76bf4d3 100644 --- a/api-gateway/src/services/recordatorio-vendedor.service.test.ts +++ b/api-gateway/src/services/recordatorio-vendedor.service.test.ts @@ -51,6 +51,7 @@ describe('RecordatorioVendedorService', () => { titulo: 'Llamar a Juan', descripcion: 'Sobre cotización', leadId: null, + clienteClave: null, fechaRecordar: new Date('2026-09-10T10:00:00'), }, }) diff --git a/api-gateway/src/services/recordatorios-cobranza.service.test.ts b/api-gateway/src/services/recordatorios-cobranza.service.test.ts index b4e8758..f0e7715 100644 --- a/api-gateway/src/services/recordatorios-cobranza.service.test.ts +++ b/api-gateway/src/services/recordatorios-cobranza.service.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { Pago, PlanPago } from './cobranza.service.js' import { - MondayActividadCanal, RecordatoriosCobranzaService, WhatsAppCanal, candidatosRecordatorio, @@ -136,34 +135,18 @@ describe('debeEnviar', () => { // ---------- Selección de destinatario ---------- describe('elegirDestinatario', () => { - it('vendedor mapeado → vendedor; si no, coordinador; si no, ninguno', () => { - expect(elegirDestinatario(123, 999)).toEqual({ mondayUserId: 123, origen: 'vendedor' }) - expect(elegirDestinatario(null, 999)).toEqual({ mondayUserId: 999, origen: 'coordinador' }) - expect(elegirDestinatario(null, 0)).toEqual({ mondayUserId: null, origen: 'ninguno' }) + it('vendedor del plan → vendedor; si no, ninguno', () => { + expect(elegirDestinatario('vend-1')).toEqual({ vendedorId: 'vend-1', origen: 'vendedor' }) + expect(elegirDestinatario(null)).toEqual({ vendedorId: null, origen: 'ninguno' }) }) }) // ---------- Canales ---------- describe('canales', () => { - it('MondayActividadCanal solo está disponible con mondayUserId y crea la actividad', async () => { - const creadas: { nombre: string; responsableId: number }[] = [] - const canal = new MondayActividadCanal({ - async crearActividad(datos) { - creadas.push({ nombre: datos.nombre, responsableId: datos.responsableId }) - return 'item-1' - }, - }) - expect(canal.disponible({ mondayUserId: null, origen: 'ninguno' })).toBe(false) - expect(canal.disponible({ mondayUserId: 42, origen: 'vendedor' })).toBe(true) - const id = await canal.enviar({ mondayUserId: 42, origen: 'vendedor' }, 'mensaje de prueba', '2026-08-28') - expect(id).toBe('item-1') - expect(creadas).toEqual([{ nombre: 'mensaje de prueba', responsableId: 42 }]) - }) - it('WhatsAppCanal es un stub inactivo: nunca disponible y solo loguea', async () => { const canal = new WhatsAppCanal() - expect(canal.disponible({ mondayUserId: 42, origen: 'vendedor' })).toBe(false) + expect(canal.disponible({ vendedorId: 'vend-1', origen: 'vendedor' })).toBe(false) const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) await expect(canal.enviar()).resolves.toBe('pendiente-wa-crm') expect(spy).toHaveBeenCalledWith(expect.stringContaining('pendiente WA CRM')) @@ -173,12 +156,12 @@ describe('canales', () => { // ---------- Pasada del motor (store y canal inyectados) ---------- -/** Canal fake que captura envíos; solo disponible con destinatario (como Monday) */ +/** Canal fake que captura envíos; solo disponible con destinatario */ function canalFake() { const envios: { destinatario: DestinatarioRecordatorio; mensaje: string }[] = [] const canal: CanalRecordatorio = { nombre: 'fake', - disponible: (d) => d.mondayUserId !== null, + disponible: (d) => d.vendedorId !== null, async enviar(destinatario, mensaje) { envios.push({ destinatario, mensaje }) return 'ok' @@ -187,12 +170,11 @@ function canalFake() { return { canal, envios } } -function storeFake(planes: PlanPago[], mondayId: number | null) { +function storeFake(planes: PlanPago[]) { const log: RecordatorioEnviado[] = [] const ultimos = new Map() const store: RecordatoriosStore = { cargarPlanes: async () => planes, - mondayUserIdDeVendedor: async () => mondayId, ultimoRecordatorio: async (pagoId, tipo) => ultimos.get(`${pagoId}:${tipo}`) ?? null, registrarLog: async (r) => { log.push(r) @@ -212,7 +194,7 @@ describe('RecordatoriosCobranzaService.ejecutarPasada', () => { ] it('dry-run: lista lo que enviaría sin enviar ni escribir en el log', async () => { - const { store, log } = storeFake(planes, 555) + const { store, log } = storeFake(planes) const { canal, envios } = canalFake() const svc = new RecordatoriosCobranzaService(store, [canal]) @@ -223,7 +205,7 @@ describe('RecordatoriosCobranzaService.ejecutarPasada', () => { expect(r.evaluados[0]).toMatchObject({ pagoId: 'pg1', decision: 'enviado', - destinatario: { mondayUserId: 555, origen: 'vendedor' }, + destinatario: { vendedorId: 'vend-1', origen: 'vendedor' }, }) expect(envios).toHaveLength(0) expect(log).toHaveLength(0) @@ -231,13 +213,13 @@ describe('RecordatoriosCobranzaService.ejecutarPasada', () => { }) it('pasada real: envía por el canal, registra el log y la segunda pasada deduplica', async () => { - const { store, log } = storeFake(planes, 555) + const { store, log } = storeFake(planes) const { canal, envios } = canalFake() const svc = new RecordatoriosCobranzaService(store, [canal]) const r1 = await svc.ejecutarPasada({ ahora: HOY }) expect(r1.enviados).toHaveLength(1) - expect(r1.enviados[0]).toMatchObject({ pagoId: 'pg1', tipo: 'PROXIMO_VENCER', canal: 'fake', destinatario: '555' }) + expect(r1.enviados[0]).toMatchObject({ pagoId: 'pg1', tipo: 'PROXIMO_VENCER', canal: 'fake', destinatario: 'vend-1' }) expect(envios).toHaveLength(1) expect(log).toHaveLength(1) @@ -249,12 +231,18 @@ describe('RecordatoriosCobranzaService.ejecutarPasada', () => { expect(log).toHaveLength(1) }) - it('sin destinatario (sin mapeo ni coordinador): solo registra en el log, sin canal', async () => { - const { store, log } = storeFake(planes, null) + it('sin destinatario (plan sin vendedor): solo registra en el log, sin canal', async () => { + const planesSinVendedor = [ + plan({ + id: 'p1', + vendedorId: null, + pagos: [pago({ id: 'pg1', fechaVencimiento: isoMasDias(HOY, 2) })], + }), + ] + const { store, log } = storeFake(planesSinVendedor) const { canal, envios } = canalFake() const svc = new RecordatoriosCobranzaService(store, [canal]) - // config de test no define MONDAY_COORDINADOR_USER_ID → 0 → 'ninguno' const r = await svc.ejecutarPasada({ ahora: HOY }) expect(r.enviados).toHaveLength(1) expect(r.enviados[0]).toMatchObject({ canal: 'log', destinatario: 'sin-destinatario' }) @@ -263,19 +251,19 @@ describe('RecordatoriosCobranzaService.ejecutarPasada', () => { }) it('un fallo del canal no escribe en el log (reintentable) y se reporta en fallidos', async () => { - const { store, log } = storeFake(planes, 555) + const { store, log } = storeFake(planes) const canalRoto: CanalRecordatorio = { nombre: 'roto', disponible: () => true, enviar: async () => { - throw new Error('Monday caído') + throw new Error('canal caído') }, } const svc = new RecordatoriosCobranzaService(store, [canalRoto]) const r = await svc.ejecutarPasada({ ahora: HOY }) expect(r.enviados).toHaveLength(0) - expect(r.fallidos).toEqual([{ pagoId: 'pg1', error: 'Monday caído' }]) + expect(r.fallidos).toEqual([{ pagoId: 'pg1', error: 'canal caído' }]) expect(log).toHaveLength(0) }) }) diff --git a/api-gateway/src/services/recordatorios-cobranza.service.ts b/api-gateway/src/services/recordatorios-cobranza.service.ts index c8df849..10a6a41 100644 --- a/api-gateway/src/services/recordatorios-cobranza.service.ts +++ b/api-gateway/src/services/recordatorios-cobranza.service.ts @@ -1,7 +1,6 @@ import { config } from '../config/index.js' import { parseFechaLocal, round2, statusPago, type Pago, type PlanPago } from './cobranza.service.js' import { LocalCanal } from './local-canal.js' -import { mondayService, type MondayService } from './monday.service.js' import { prisma } from './prisma.js' import { enviarPush } from './push.service.js' @@ -12,10 +11,10 @@ import { enviarPush } from './push.service.js' * - pagos que vencen en ≤ RECORDATORIO_DIAS_ANTES días → "próximo a vencer" * - pagos vencidos → "vencido", re-recordatorio cada RECORDATORIO_REVOLVER_DIAS * - * Canal actual: Actividad en Monday al vendedor del plan (si tiene - * mondayUserId; si no, al coordinador de MONDAY_COORDINADOR_USER_ID; si no, - * solo se registra en el log). La interfaz CanalRecordatorio permite enchufar - * WhatsApp después (stub WhatsAppCanal incluido, inactivo hasta el WA CRM). + * Canal actual: ActividadLocal al vendedor del plan (si el plan tiene + * vendedorId; si no, solo se registra en el log). La interfaz + * CanalRecordatorio permite enchufar WhatsApp después (stub WhatsAppCanal + * incluido, inactivo hasta el WA CRM). * * Dedup e historial: tabla RecordatorioLog (no se repite el mismo recordatorio * el mismo día; los vencidos respetan la ventana de re-recordatorio). @@ -129,21 +128,15 @@ export function debeEnviar( // ---------- Destinatario ---------- export interface DestinatarioRecordatorio { - /** mondayUserId del responsable de la actividad; null = no hay a quién asignar */ - mondayUserId: number | null - origen: 'vendedor' | 'coordinador' | 'ninguno' + /** usuarioId del vendedor responsable; null = no hay a quién asignar */ + vendedorId: string | null + origen: 'vendedor' | 'ninguno' } -/** Regla pura de selección: vendedor mapeado → coordinador (env) → nadie */ -export function elegirDestinatario( - mondayUserIdVendedor: number | null, - coordinadorUserId: number, -): DestinatarioRecordatorio { - if (mondayUserIdVendedor !== null) return { mondayUserId: mondayUserIdVendedor, origen: 'vendedor' } - if (Number.isInteger(coordinadorUserId) && coordinadorUserId > 0) { - return { mondayUserId: coordinadorUserId, origen: 'coordinador' } - } - return { mondayUserId: null, origen: 'ninguno' } +/** Regla pura de selección: vendedor del plan → nadie */ +export function elegirDestinatario(vendedorId: string | null): DestinatarioRecordatorio { + if (vendedorId !== null) return { vendedorId, origen: 'vendedor' } + return { vendedorId: null, origen: 'ninguno' } } // ---------- Canales ---------- @@ -152,30 +145,10 @@ export interface CanalRecordatorio { readonly nombre: string /** true si el canal puede entregar a este destinatario */ disponible(destinatario: DestinatarioRecordatorio): boolean - /** Entrega el recordatorio; devuelve un detalle (p. ej. el item id de Monday) */ + /** Entrega el recordatorio; devuelve un identificador del envío */ enviar(destinatario: DestinatarioRecordatorio, mensaje: string, fecha: string): Promise } -type MondayActividades = Pick - -/** Canal actual: Actividad en Monday asignada al responsable (tablero Actividades) */ -export class MondayActividadCanal implements CanalRecordatorio { - readonly nombre = 'monday-actividad' - constructor(private readonly monday: MondayActividades) {} - disponible(destinatario: DestinatarioRecordatorio): boolean { - return destinatario.mondayUserId !== null - } - async enviar(destinatario: DestinatarioRecordatorio, mensaje: string, fecha: string): Promise { - return this.monday.crearActividad({ - nombre: mensaje.length > 250 ? `${mensaje.slice(0, 247)}...` : mensaje, - tipoLabel: 'Tarea', - responsableId: destinatario.mondayUserId!, - fecha, - estadoLabel: 'Abierto', - }) - } -} - /** * Stub del canal WhatsApp: inactivo hasta que exista la integración del WA CRM. * `disponible` siempre false (nunca se elige); `enviar` solo loguea. @@ -219,8 +192,6 @@ export interface ResultadoPasada { export interface RecordatoriosStore { /** Planes ACTIVO con pagos y aplicaciones, en forma de dominio */ cargarPlanes(): Promise - /** mondayUserId del vendedor; null si no existe o no está mapeado */ - mondayUserIdDeVendedor(vendedorId: string): Promise /** Fecha del último recordatorio registrado para ese pago y tipo */ ultimoRecordatorio(pagoId: string, tipo: TipoRecordatorio): Promise registrarLog(r: RecordatorioEnviado): Promise @@ -249,8 +220,7 @@ export class RecordatoriosCobranzaService { const resultado: ResultadoPasada = { dryRun, evaluados: [], enviados: [], fallidos: [] } for (const c of candidatos) { - const mondayVendedor = c.vendedorId ? await this.store.mondayUserIdDeVendedor(c.vendedorId) : null - const destinatario = elegirDestinatario(mondayVendedor, config.automatizaciones.coordinadorUserId) + const destinatario = elegirDestinatario(c.vendedorId) const ultimo = await this.store.ultimoRecordatorio(c.pagoId, c.tipo) if (!debeEnviar(ultimo, c.tipo, ahora, revolverDias)) { @@ -265,8 +235,7 @@ export class RecordatoriosCobranzaService { pagoId: c.pagoId, tipo: c.tipo, canal: canal?.nombre ?? 'log', - destinatario: - destinatario.mondayUserId !== null ? String(destinatario.mondayUserId) : 'sin-destinatario', + destinatario: destinatario.vendedorId ?? 'sin-destinatario', mensaje: c.mensaje, } try { @@ -286,7 +255,7 @@ export class RecordatoriosCobranzaService { } } -// ---------- Cableado con Prisma + Monday (singleton para crons y endpoints) ---------- +// ---------- Cableado con Prisma (singleton para crons y endpoints) ---------- export const prismaRecordatoriosStore: RecordatoriosStore = { async cargarPlanes() { @@ -324,13 +293,6 @@ export const prismaRecordatoriosStore: RecordatoriosStore = { ), })) }, - async mondayUserIdDeVendedor(vendedorId) { - const usuario = await prisma.usuario.findUnique({ - where: { id: vendedorId }, - select: { mondayUserId: true }, - }) - return usuario?.mondayUserId ?? null - }, async ultimoRecordatorio(pagoId, tipo) { const ultimo = await prisma.recordatorioLog.findFirst({ where: { pagoId, tipo }, @@ -346,7 +308,6 @@ export const prismaRecordatoriosStore: RecordatoriosStore = { /** Singleton usado por el cron y los endpoints (los tests instancian el suyo) */ export const recordatoriosCobranzaService = new RecordatoriosCobranzaService(prismaRecordatoriosStore, [ - new MondayActividadCanal(mondayService), new LocalCanal(), new WhatsAppCanal(), ]) diff --git a/api-gateway/src/services/reportes-local.test.ts b/api-gateway/src/services/reportes-local.test.ts index e3ec578..4dacf1c 100644 --- a/api-gateway/src/services/reportes-local.test.ts +++ b/api-gateway/src/services/reportes-local.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { PlanPago } from './cobranza.service.js' -import type { LeadMonday, OportunidadMonday, Unidad } from './monday.service.js' -import { AppError } from '../utils/errors.js' +import type { LeadFila, OportunidadFila, Unidad } from './reportes.service.js' // ---------- Prisma mockeado (los tests no tocan la BD) ---------- @@ -22,10 +21,9 @@ vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) import { cargarDatosLocalesPrisma, ReportesService, type DatosLocalesReportes } from './reportes.service.js' /** - * Fallback de reportes (modo local): Monday caído o 502 → el resumen se - * calcula desde tablas locales (LeadCaptura por board, Unidad por status, - * PlanPago como siempre). `parcial: false` porque en modo local los datos - * son completos (no parciales). + * Reportes 100% locales: el resumen se calcula desde tablas locales + * (LeadCaptura por board, Unidad por status, PlanPago como siempre). + * `parcial: false` porque los datos locales son completos. */ function pago(id: string, fechaVencimiento: string, monto: number, montoPagado = 0): PlanPago['pagos'][number] { @@ -50,7 +48,7 @@ function plan(id: string, overrides: Partial = {}): PlanPago { } } -function leadMonday(id: string, estado = 'Nuevo'): LeadMonday { +function leadFila(id: string, estado = 'Nuevo'): LeadFila { return { id, nombre: `Lead ${id}`, @@ -64,7 +62,7 @@ function leadMonday(id: string, estado = 'Nuevo'): LeadMonday { } } -function unidadMonday(codigo: string, status = 'DISPONIBLE'): Unidad { +function unidadFila(codigo: string, status = 'DISPONIBLE'): Unidad { return { codigo, modelo: '1R', @@ -81,18 +79,8 @@ function unidadMonday(codigo: string, status = 'DISPONIBLE'): Unidad { } } -function mondayMock(overrides: Record = {}) { - return { - getLeads: vi.fn(async () => [leadMonday('m1')]), - getOportunidades: vi.fn(async () => [] as OportunidadMonday[]), - getUnidades: vi.fn(async () => [unidadMonday('M-1')]), - getActividadesAbiertas: vi.fn(async () => 3), - ...overrides, - } -} - const datosLocales: DatosLocalesReportes = { - leads: [leadMonday('loc_l1'), leadMonday('loc_l2', 'Descartado')], + leads: [leadFila('loc_l1'), leadFila('loc_l2', 'Descartado')], oportunidades: [ { id: 'loc_o1', @@ -111,7 +99,7 @@ const datosLocales: DatosLocalesReportes = { credito: 'Infonavit', }, ], - unidades: [unidadMonday('U-1'), unidadMonday('U-2', 'APARTADO')], + unidades: [unidadFila('U-1'), unidadFila('U-2', 'APARTADO')], } beforeEach(() => { @@ -121,74 +109,62 @@ beforeEach(() => { prismaMock.actividadLocal.count.mockResolvedValue(0) }) -describe('ReportesService (fallback local)', () => { - it('Monday caído (salud): resumen desde datos locales con parcial: true, sin llamar a Monday', async () => { - const monday = mondayMock() - const svc = new ReportesService(monday, async () => [plan('a')], { - salud: { estaDisponible: () => false }, +describe('ReportesService (resumen desde tablas locales)', () => { + it('resumen desde datos locales: leads, oportunidades, unidades y planes en una respuesta', async () => { + const svc = new ReportesService(async () => [plan('a')], { cargarDatosLocales: async () => datosLocales, }) const r = await svc.resumen() - expect(r.parcial).toBe(false) // modo local completo: datos no son parciales + expect(r.parcial).toBe(false) expect(r.leads.total).toBe(2) expect(r.leads.porEstado).toEqual({ Nuevo: 1, Descartado: 1 }) expect(r.oportunidades.total).toBe(1) expect(r.oportunidades.porEtapa).toEqual({ Afluencia: 1 }) expect(r.unidades.total).toBe(2) expect(r.unidades.disponibles).toBe(1) - expect(r.cobranza.planes.activos).toBe(1) // planes locales como siempre + expect(r.cobranza.planes.activos).toBe(1) expect(r.actividadesAbiertas).toBe(0) // no hay actividades en el fixture - expect(monday.getLeads).not.toHaveBeenCalled() }) - it('Monday vivo: camino normal intacto (sin parcial) y cache de 60 s', async () => { - const monday = mondayMock() - const cargarPlanes = vi.fn(async () => [plan('a')]) - let ahora = 1_000_000 - const svc = new ReportesService(monday, cargarPlanes, { - now: () => ahora, - salud: { estaDisponible: () => true }, - cargarDatosLocales: async () => datosLocales, - }) - - const r1 = await svc.resumen() - expect(r1.parcial).toBeUndefined() - expect(r1.leads.total).toBe(1) - expect(r1.actividadesAbiertas).toBe(3) - - ahora += 30_000 // dentro del TTL: segunda llamada sale del cache - const r2 = await svc.resumen() - expect(r2).toBe(r1) - expect(monday.getLeads).toHaveBeenCalledTimes(1) - expect(cargarPlanes).toHaveBeenCalledTimes(1) - }) - - it('Monday lanza AppError 502 a media lectura: cae al resumen local con parcial', async () => { - const monday = mondayMock({ getLeads: vi.fn(async () => { throw new AppError(502, 'Monday no respondió') }) }) - const svc = new ReportesService(monday, async () => [], { - salud: { estaDisponible: () => true }, - cargarDatosLocales: async () => datosLocales, + it('cuenta actividades abiertas locales cuando el fixture las trae', async () => { + const svc = new ReportesService(async () => [], { + cargarDatosLocales: async () => ({ ...datosLocales, actividadesAbiertas: 3 }), }) const r = await svc.resumen() - expect(r.parcial).toBe(false) // modo local completo: datos no son parciales - expect(r.leads.total).toBe(2) + + expect(r.actividadesAbiertas).toBe(3) }) - it('otro error de Monday se propaga (no se traga nada)', async () => { - const monday = mondayMock({ getLeads: vi.fn(async () => { throw new AppError(500, 'bug') }) }) - const svc = new ReportesService(monday, async () => [], { - salud: { estaDisponible: () => true }, - cargarDatosLocales: async () => datosLocales, + it('usa el cargador local por defecto (cargarDatosLocalesPrisma) cuando no se inyecta', async () => { + const svc = new ReportesService(async () => []) + + const r = await svc.resumen() + + // Con las tablas vacías el resumen sale en ceros pero completo + expect(r.parcial).toBe(false) + expect(r.leads.total).toBe(0) + expect(r.oportunidades.total).toBe(0) + expect(r.unidades.total).toBe(0) + expect(prismaMock.leadCaptura.findMany).toHaveBeenCalled() + expect(prismaMock.unidad.findMany).toHaveBeenCalled() + expect(prismaMock.actividadLocal.count).toHaveBeenCalled() + }) + + it('un error del cargador local se propaga (no se traga nada)', async () => { + const svc = new ReportesService(async () => [], { + cargarDatosLocales: async () => { + throw new Error('bug') + }, }) await expect(svc.resumen()).rejects.toThrow('bug') }) }) -describe('cargarDatosLocalesPrisma (mapeo LeadCaptura/Unidad → DTOs Monday)', () => { - it('mapea boards a LeadMonday/OportunidadMonday con el perfil de datosJson y unidades al DTO', async () => { +describe('cargarDatosLocalesPrisma (mapeo LeadCaptura/Unidad → DTOs del embudo)', () => { + it('mapea boards a leads/oportunidades con el perfil de datosJson y unidades al DTO', async () => { prismaMock.leadCaptura.findMany.mockResolvedValue([ { leadId: 'loc_l1', diff --git a/api-gateway/src/services/reportes.service.test.ts b/api-gateway/src/services/reportes.service.test.ts index 823b46d..8447237 100644 --- a/api-gateway/src/services/reportes.service.test.ts +++ b/api-gateway/src/services/reportes.service.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it, vi } from 'vitest' import { construirResumen, ReportesService, type DatosResumen } from './reportes.service.js' -import type { LeadMonday, OportunidadMonday, Unidad } from './monday.service.js' +import type { LeadFila, OportunidadFila, Unidad } from './reportes.service.js' import type { PlanPago } from './cobranza.service.js' // ---------- Fixtures ---------- /** Lead del tablero Leads (captura ligera; fuente de la sección leads) */ -function lead(id: string, overrides: Partial = {}): LeadMonday { +function lead(id: string, overrides: Partial = {}): LeadFila { return { id, nombre: `Lead ${id}`, @@ -22,7 +22,7 @@ function lead(id: string, overrides: Partial = {}): LeadMonday { } /** Afluencia del board Oportunidades (fuente del perfilamiento de afluencia) */ -function afluencia(id: string, overrides: Partial = {}): OportunidadMonday { +function afluencia(id: string, overrides: Partial = {}): OportunidadFila { return { id, nombre: `Lead ${id}`, @@ -39,7 +39,7 @@ function afluencia(id: string, overrides: Partial = {}): Opor } } -function op(id: string, etapa: string): OportunidadMonday { +function op(id: string, etapa: string): OportunidadFila { return afluencia(id, { nombre: `Cliente ${id}`, createdAt: '2026-08-01T09:00:00Z', etapa, unidadCodigo: 'U-101' }) } @@ -311,61 +311,51 @@ describe('construirResumen', () => { }) }) -// ---------- ReportesService (cache en memoria) ---------- +// ---------- ReportesService (resumen desde tablas locales, cache en memoria) ---------- describe('ReportesService', () => { - function mondayMock() { + function datosLocales() { return { - llamadas: { leads: 0, oportunidades: 0, unidades: 0, actividades: 0 }, - async getLeads(): Promise { - this.llamadas.leads++ - return [lead('1')] - }, - async getOportunidades(): Promise { - this.llamadas.oportunidades++ - return [afluencia('1')] - }, - async getUnidades(): Promise { - this.llamadas.unidades++ - return [unidad('U-101', 'ATELIER', 'DISPONIBLE')] - }, - async getActividadesAbiertas(): Promise { - this.llamadas.actividades++ - return 7 - }, + leads: [lead('1')], + oportunidades: [afluencia('1')], + unidades: [unidad('U-101', 'ATELIER', 'DISPONIBLE')], + actividadesAbiertas: 7, } } - it('agrega todas las fuentes en una sola respuesta', async () => { - const monday = mondayMock() - const svc = new ReportesService(monday, async () => [plan('a', { pagos: [pago('p1', '2026-09-01', 50000)] })]) + it('agrega todas las fuentes (tablas locales) en una sola respuesta', async () => { + const cargarDatos = vi.fn(async () => datosLocales()) + const svc = new ReportesService( + async () => [plan('a', { pagos: [pago('p1', '2026-09-01', 50000)] })], + { cargarDatosLocales: cargarDatos }, + ) const r = await svc.resumen() - expect(r.leads.total).toBe(1) // la sección leads se alimenta del tablero Leads + expect(r.leads.total).toBe(1) // la sección leads se alimenta de LeadCaptura board LEADS expect(r.oportunidades.total).toBe(1) expect(r.unidades.total).toBe(1) expect(r.cobranza.planes.activos).toBe(1) expect(r.actividadesAbiertas).toBe(7) - expect(monday.llamadas.leads).toBe(1) - expect(monday.llamadas.oportunidades).toBe(1) + expect(r.parcial).toBe(false) // los datos locales son completos + expect(cargarDatos).toHaveBeenCalledTimes(1) }) - it('cachea 60 s: la segunda llamada no vuelve a Monday ni a Prisma', async () => { + it('cachea 60 s: la segunda llamada no vuelve a leer las tablas locales', async () => { vi.useFakeTimers() try { vi.setSystemTime(new Date('2026-08-22T12:00:00')) - const monday = mondayMock() + const cargarDatos = vi.fn(async () => datosLocales()) const cargarPlanes = vi.fn(async () => [plan('a', { pagos: [pago('p1', '2026-09-01', 50000)] })]) - const svc = new ReportesService(monday, cargarPlanes) + const svc = new ReportesService(cargarPlanes, { cargarDatosLocales: cargarDatos }) await svc.resumen() await svc.resumen() - expect(monday.llamadas.oportunidades).toBe(1) + expect(cargarDatos).toHaveBeenCalledTimes(1) expect(cargarPlanes).toHaveBeenCalledTimes(1) // 61 s después el cache expira y se vuelve a consultar vi.setSystemTime(new Date('2026-08-22T12:01:01')) await svc.resumen() - expect(monday.llamadas.oportunidades).toBe(2) + expect(cargarDatos).toHaveBeenCalledTimes(2) expect(cargarPlanes).toHaveBeenCalledTimes(2) } finally { vi.useRealTimers() diff --git a/api-gateway/src/services/reportes.service.ts b/api-gateway/src/services/reportes.service.ts index 65855fe..228b3c1 100644 --- a/api-gateway/src/services/reportes.service.ts +++ b/api-gateway/src/services/reportes.service.ts @@ -1,18 +1,59 @@ import { statusPago, statusPlan, type PlanPago } from './cobranza.service.js' -import type { LeadMonday, MondayService, OportunidadMonday, Unidad } from './monday.service.js' +import type { UnidadCotizable } from './cotizacion.service.js' import { ETAPAS_PIPELINE } from './etapas.js' import { prisma } from './prisma.js' -import { saludMonday, type SaludMondayService } from './salud-monday.service.js' import { unidadLocalADto } from './unidades-local.service.js' -import { AppError } from '../utils/errors.js' + +/** Lead del embudo tal como lo consume el agregador (fila de LeadCaptura mapeada) */ +export type LeadFila = { + id: string + nombre: string + createdAt: string // ISO + updatedAt: string // ISO + estado: string + origen: string + telefono: string + email: string + vendedorIds: number[] + desarrollo?: string + modeloInteres?: string +} + +/** Oportunidad/afluencia tal como la consume el agregador (fila de LeadCaptura mapeada) */ +export type OportunidadFila = { + id: string + nombre: string + createdAt: string // ISO + updatedAt: string // ISO + etapa: string + unidadCodigo: string + precioFinal: number + asesorIds: number[] + telefono: string + email: string + origen: string + desarrollo?: string + modeloInteres?: string + medioContacto?: string + edad?: number | null + estadoCivil?: string + tipoEmpleo?: string + credito?: string + tipoEconomia?: string + califica?: string + rangoSalarial?: string + loQuierePara?: string +} + +export type Unidad = UnidadCotizable /** * Agregador de reportes para la app de tableros (/dash/). - * En UNA respuesta junta: leads (Monday, tablero Leads — captura ligera), - * afluencia/perfilamiento (Monday, tablero Oportunidades — el Alta Afluencia - * vive ahí), oportunidades (Monday), unidades (Monday), cobranza (Prisma - * local) y actividades abiertas (Monday). - * Cache en memoria de 60 s para no martillar la API de Monday. + * En UNA respuesta junta: leads (captura ligera, tabla LeadCaptura board + * LEADS), afluencia/perfilamiento (board OPORTUNIDADES — el Alta Afluencia + * vive ahí), oportunidades, unidades (espejo local), cobranza (Prisma local) + * y actividades abiertas (ActividadLocal). + * Cache en memoria de 60 s para no martillar SQLite en cada refresh. */ export const CACHE_REPORTES_MS = 60 * 1000 @@ -77,7 +118,7 @@ export interface ResumenReportes { montoVencido: number } actividadesAbiertas: number - /** true cuando el resumen se calculó desde tablas locales (Monday caído): métricas parciales */ + /** false: el resumen se calculó desde tablas locales completas */ parcial?: boolean } @@ -86,8 +127,8 @@ export interface DatosResumen { // La sección `leads` (total/nuevos/porEstado/porOrigen) lee el tablero Leads // (captura ligera); el `perfil` de la afluencia lee Oportunidades (Alta // Afluencia), que es también la fuente de la sección `oportunidades` - leads: LeadMonday[] - oportunidades: OportunidadMonday[] + leads: LeadFila[] + oportunidades: OportunidadFila[] unidades: Unidad[] planes: PlanPago[] // dominio de cobranza (con pagos) actividadesAbiertas: number @@ -301,15 +342,13 @@ export function construirResumen(datos: DatosResumen, ahora: Date, dias?: number // ---------- Servicio con cache ---------- -type MondayReportes = Pick - /** Carga los planes de cobranza en forma de dominio (Prisma en prod, fixture en tests) */ export type CargadorPlanes = () => Promise /** Datos del embudo/inventario leídos de tablas locales (modo degradado) */ export interface DatosLocalesReportes { - leads: LeadMonday[] - oportunidades: OportunidadMonday[] + leads: LeadFila[] + oportunidades: OportunidadFila[] unidades: Unidad[] actividadesAbiertas?: number } @@ -322,9 +361,7 @@ export interface OpcionesReportes { now?: () => number /** TTL del cache en ms (default 60 s) */ cacheMs?: number - /** Salud de Monday (default: singleton saludMonday) */ - salud?: Pick - /** Datos locales para el fallback (default: tablas LeadCaptura/Unidad vía Prisma) */ + /** Datos locales del embudo/inventario (default: tablas LeadCaptura/Unidad vía Prisma) */ cargarDatosLocales?: CargadorDatosLocales } @@ -337,7 +374,7 @@ function campoTexto(datos: Record, campo: string): string { /** * Cargador local por defecto: leads/oportunidades desde LeadCaptura (board + * etapa; el perfil de la afluencia va en datosJson) e inventario desde la - * tabla Unidad (espejo). Misma forma de DTO que las lecturas Monday. + * tabla Unidad (espejo). */ export async function cargarDatosLocalesPrisma(): Promise { const capturas = await prisma.leadCaptura.findMany({ @@ -345,7 +382,7 @@ export async function cargarDatosLocalesPrisma(): Promise }) const unidadesRows = await prisma.unidad.findMany() - const leads: LeadMonday[] = capturas + const leads: LeadFila[] = capturas .filter((c) => c.board === 'LEADS') .map((c) => ({ id: c.leadId, @@ -358,7 +395,7 @@ export async function cargarDatosLocalesPrisma(): Promise email: c.email ?? '', vendedorIds: [], })) - const oportunidades: OportunidadMonday[] = capturas + const oportunidades: OportunidadFila[] = capturas .filter((c) => c.board === 'OPORTUNIDADES') .map((c) => { let datos: Record = {} @@ -404,19 +441,16 @@ export async function cargarDatosLocalesPrisma(): Promise export class ReportesService { private readonly now: () => number private readonly cacheMs: number - private readonly salud: Pick private readonly cargarDatosLocales: CargadorDatosLocales /** Cache por rango (clave '0' = todo el histórico) para no mezclar ventanas */ private cache: Map = new Map() constructor( - private readonly monday: MondayReportes, private readonly cargarPlanes: CargadorPlanes, opciones: OpcionesReportes = {}, ) { this.now = opciones.now ?? Date.now this.cacheMs = opciones.cacheMs ?? CACHE_REPORTES_MS - this.salud = opciones.salud ?? saludMonday this.cargarDatosLocales = opciones.cargarDatosLocales ?? cargarDatosLocalesPrisma } @@ -438,36 +472,17 @@ export class ReportesService { } /** - * Camino normal: Monday + planes locales en paralelo (con el cache de 60 s - * de siempre). Camino degradado: Monday caído (circuit breaker) o AppError - * 502 a media lectura → resumen desde tablas locales con `parcial: true`. + * Resumen desde tablas locales (LeadCaptura por board, Unidad espejo, + * PlanPago y ActividadLocal), en paralelo y con el cache de 60 s de siempre. */ private async cargarResumen(ahoraMs: number, dias?: number): Promise { - if (this.salud.estaDisponible()) { - try { - // Las fuentes de Monday se piden en paralelo; Prisma va por su lado - const [leads, oportunidades, unidades, actividadesAbiertas, planes] = await Promise.all([ - this.monday.getLeads(), - this.monday.getOportunidades(), - this.monday.getUnidades(), - this.monday.getActividadesAbiertas(), - this.cargarPlanes(), - ]) - return construirResumen({ leads, oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs), dias) - } catch (err) { - if (!(err instanceof AppError && err.status === 502)) throw err - // Monday se cayó a media lectura: cae al camino local - } - } const [locales, planes] = await Promise.all([this.cargarDatosLocales(), this.cargarPlanes()]) const resumen = construirResumen( { ...locales, planes, actividadesAbiertas: locales.actividadesAbiertas ?? 0 }, new Date(ahoraMs), dias, ) - // En modo local, los datos son completos (no parciales): todas las métricas - // vienen de tablas locales. `parcial` solo se marca true cuando Monday cayó - // a media lectura (ya no aplica aquí). + // Los datos son completos (todas las métricas vienen de tablas locales) return { ...resumen, parcial: false } } } diff --git a/api-gateway/src/services/salud-monday.service.test.ts b/api-gateway/src/services/salud-monday.service.test.ts deleted file mode 100644 index 527970a..0000000 --- a/api-gateway/src/services/salud-monday.service.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { FALLOS_PARA_CAER, SaludMondayService } from './salud-monday.service.js' - -describe('SaludMondayService', () => { - afterEach(() => { - delete process.env.MODO_LOCAL_FORZADO - }) - - it('arranca disponible (optimista)', () => { - const salud = new SaludMondayService() - expect(salud.ok()).toBe(true) - expect(salud.estaDisponible()).toBe(true) - }) - - it('cae tras FALLOS_PARA_CAER fallos consecutivos', () => { - const salud = new SaludMondayService() - for (let i = 0; i < FALLOS_PARA_CAER - 1; i++) salud.registrarFallo() - expect(salud.estaDisponible()).toBe(true) // aún aguanta - salud.registrarFallo() - expect(salud.ok()).toBe(false) - expect(salud.estaDisponible()).toBe(false) - }) - - it('un éxito resetea el contador de fallos', () => { - const salud = new SaludMondayService() - salud.registrarFallo() - salud.registrarFallo() - salud.registrarExito() - salud.registrarFallo() - salud.registrarFallo() - expect(salud.estaDisponible()).toBe(true) // nunca llegó a 3 seguidos - }) - - it('un éxito recupera el flag una vez caído', () => { - const salud = new SaludMondayService() - for (let i = 0; i < FALLOS_PARA_CAER; i++) salud.registrarFallo() - expect(salud.ok()).toBe(false) - salud.registrarExito() - expect(salud.estaDisponible()).toBe(true) - }) - - it('MODO_LOCAL_FORZADO=true fuerza modo local aunque Monday esté sano', () => { - const salud = new SaludMondayService() - process.env.MODO_LOCAL_FORZADO = 'true' - expect(salud.ok()).toBe(true) // el flag crudo no cambia - expect(salud.estaDisponible()).toBe(false) - process.env.MODO_LOCAL_FORZADO = 'false' - expect(salud.estaDisponible()).toBe(true) - }) - - it('sondear() con ping exitoso recupera el flag y devuelve true', async () => { - const salud = new SaludMondayService() - for (let i = 0; i < FALLOS_PARA_CAER; i++) salud.registrarFallo() - salud.registrarPing(async () => true) - await expect(salud.sondear()).resolves.toBe(true) - expect(salud.estaDisponible()).toBe(true) - }) - - it('sondear() con ping fallido deja el flag caído', async () => { - const salud = new SaludMondayService() - for (let i = 0; i < FALLOS_PARA_CAER; i++) salud.registrarFallo() - salud.registrarPing(async () => false) - await expect(salud.sondear()).resolves.toBe(false) - expect(salud.ok()).toBe(false) - }) - - it('sondear() sin ping registrado devuelve el flag actual', async () => { - const salud = new SaludMondayService() - await expect(salud.sondear()).resolves.toBe(true) - for (let i = 0; i < FALLOS_PARA_CAER; i++) salud.registrarFallo() - await expect(salud.sondear()).resolves.toBe(false) - }) -}) diff --git a/api-gateway/src/services/salud-monday.service.ts b/api-gateway/src/services/salud-monday.service.ts deleted file mode 100644 index ca5d5a7..0000000 --- a/api-gateway/src/services/salud-monday.service.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Salud de Monday (circuit breaker ligero). - * - * Estado en memoria: `mondayOk` arranca en true (optimista) y pasa a false tras - * FALLOS_PARA_CAER fallos GraphQL consecutivos reportados por monday.service. - * Mientras está caído, los callers deben saltarse Monday de inmediato (modo - * local) en lugar de pagar los reintentos por request; un cron sondea cada 60 s - * y al recuperarse dispara el sync worker. - * - * MODO_LOCAL_FORZADO=true (env var o DB) fuerza modo local aunque Monday - * responda (pruebas / toggle admin). La DB se cachea 30 s en configService. - * - * El ping se inyecta con `registrarPing()` (wiring en index.ts) para no - * importar monday.service aquí y evitar ciclos de imports. - */ - -/** Fallos GraphQL consecutivos que tumban el flag */ -export const FALLOS_PARA_CAER = 3 - -export type PingMonday = () => Promise - -export class SaludMondayService { - private _modoLocalDb = false - - setModoLocalDb(v: boolean) { this._modoLocalDb = v } - private mondayOk = true - private fallosConsecutivos = 0 - private pingFn: PingMonday | null = null - - /** Registra el callback de ping (en index.ts: () => mondayService.ping()) */ - registrarPing(fn: PingMonday): void { - this.pingFn = fn - } - - /** Llamar tras cada llamada GraphQL exitosa */ - registrarExito(): void { - this.fallosConsecutivos = 0 - this.mondayOk = true - } - - /** Llamar tras cada fallo definitivo de Monday (AppError 502 de graphql()) */ - registrarFallo(): void { - this.fallosConsecutivos += 1 - if (this.fallosConsecutivos >= FALLOS_PARA_CAER) this.mondayOk = false - } - - /** Flag crudo (sin el override MODO_LOCAL_FORZADO); útil para el cron de sondeo */ - ok(): boolean { - return this.mondayOk - } - - /** - * true si los callers pueden hablar con Monday ahora mismo. - * Lee modo_local_forzado de la DB (via cache 30 s en configService) y de - * process.env como fallback. Síncrono: la DB se precarga al iniciar y se - * invalida al cambiar configs. - */ - estaDisponible(): boolean { - // Lazy import para no crear ciclos en tests (configService importa prisma) - let forzadoDb = false - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const cs = require('./config.service.js') as typeof import('./config.service.js') - const syncVal = cs.obtenerConfigSync('modo_local_forzado') - if (syncVal !== undefined) { - forzadoDb = syncVal === 'true' - } else { - // Cache no cargado aún: intentar async (fire-and-forget para la próxima vez) - cs.obtenerConfig('modo_local_forzado').then((v) => { - if (v === 'true') this._modoLocalDb = true - }).catch(() => {}) - } - } catch { - // configService no disponible (tests aislados, DB no inicializada) - } - const forzadoEnv = process.env.MODO_LOCAL_FORZADO === 'true' - return this.mondayOk && !forzadoDb && !this._modoLocalDb && !forzadoEnv - } - - /** - * Ping a Monday. Si responde, recupera el flag y devuelve true (el caller - * decide si dispara el sync). Sin ping registrado devuelve el flag actual. - */ - async sondear(): Promise { - if (this.pingFn === null) return this.mondayOk - const vivo = await this.pingFn() - if (vivo) this.registrarExito() - return vivo - } - - /** Reset completo (tests) */ - restablecer(): void { - this.mondayOk = true - this.fallosConsecutivos = 0 - this.pingFn = null - } -} - -/** Singleton compartido por monday.service, controllers y crons */ -export const saludMonday = new SaludMondayService() diff --git a/api-gateway/src/services/sync-handlers.test.ts b/api-gateway/src/services/sync-handlers.test.ts deleted file mode 100644 index 7934e34..0000000 --- a/api-gateway/src/services/sync-handlers.test.ts +++ /dev/null @@ -1,427 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// ---------- Prisma y Funnel mockeados (los tests no tocan BD ni red) ---------- - -const prismaMock = vi.hoisted(() => ({ - leadCaptura: { - findUnique: vi.fn(), - updateMany: vi.fn(), - }, - leadOportunidad: { - create: vi.fn(), - findUnique: vi.fn(), - }, - cliente: { - findUnique: vi.fn(), - updateMany: vi.fn(), - }, - cotizacion: { - updateMany: vi.fn(), - }, - usuario: { - findUnique: vi.fn(), - }, - automatizacionState: { - findUnique: vi.fn(), - upsert: vi.fn(), - }, -})) - -const funnelMock = vi.hoisted(() => ({ - oportunidadPorTelefono: vi.fn(), -})) - -vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) -vi.mock('../services/funnel.service.js', () => ({ funnelService: funnelMock })) - -import type { StateStore } from './automatizaciones.service.js' -import type { MondayService } from './monday.service.js' -import { registrarHandlersSync } from './sync-handlers.js' -import { SyncMondayService, type SyncRegistro, type SyncStore } from './sync-monday.service.js' - -// ---------- Fixtures (mismo patrón que sync-monday.service.test.ts) ---------- - -let consecutivo = 0 -function registro(parcial: Partial = {}): SyncRegistro { - consecutivo += 1 - return { - id: parcial.id ?? `reg-${consecutivo}`, - tipo: 'CREAR_LEAD', - payloadJson: '{}', - referenciaLocal: null, - estado: 'PENDIENTE', - intentos: 0, - createdAt: new Date(Date.UTC(2026, 8, 5, 12, 0, consecutivo)), - ...parcial, - } -} - -function stateEnMemoria(): StateStore & { mapa: Map } { - const mapa = new Map() - return { - mapa, - async get(clave) { - return mapa.get(clave) ?? null - }, - async set(clave, valor) { - mapa.set(clave, valor) - }, - } -} - -function storeEnMemoria(registros: SyncRegistro[]) { - const rows = new Map(registros.map((r) => [r.id, { ...r }])) - const store: SyncStore & { rows: Map; ultimo: SyncRegistro } = { - rows, - ultimo: registros[registros.length - 1]!, - async pendientes(limite) { - return [...rows.values()] - .filter((r) => r.estado === 'PENDIENTE') - .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) - .slice(0, limite) - }, - async marcar(id, datos) { - const row = rows.get(id) - if (!row) throw new Error(`Registro inexistente: ${id}`) - row.estado = datos.estado - if (datos.intentos !== undefined) row.intentos = datos.intentos - if (datos.ultimoError !== undefined) row.ultimoError = datos.ultimoError - }, - } - return store -} - -/** Monday mockeado: registra llamadas y devuelve ids controlados */ -function mondayMock() { - return { - cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[], - leadsCreados: [] as Record[], - oportunidadesCreadas: [] as Record[], - contactosCreados: [] as Record[], - apartadosCreados: [] as Record[], - motivos: [] as Record[], - asignaciones: [] as { itemId: string; userId: number }[], - async crearLead(datos: Record) { - this.leadsCreados.push(datos) - return 'monday-lead-1' - }, - async crearOportunidadAfluencia(datos: Record) { - this.oportunidadesCreadas.push(datos) - return 'monday-op-1' - }, - async actualizarOportunidadAfluencia(_id: string, _datos: Record) {}, - async crearContacto(datos: Record) { - this.contactosCreados.push(datos) - return 'monday-cont-1' - }, - async actualizarContacto(_id: string, _datos: Record) {}, - async crearApartado(datos: Record) { - this.apartadosCreados.push(datos) - return 'monday-apt-1' - }, - async crearOportunidad(datos: Record) { - this.oportunidadesCreadas.push(datos) - return 'monday-op-cot-1' - }, - async actualizarOportunidadCotizacion(_id: string, _datos: Record) {}, - async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: unknown) { - this.cambios.push({ boardId, itemId, columna, valor }) - }, - async crearMotivoNoCompra(datos: Record) { - this.motivos.push(datos) - return 'monday-motivo-1' - }, - async asignarVendedorALead(itemId: string, userId: number) { - this.asignaciones.push({ itemId, userId }) - }, - async asignarAsesorAOportunidad(itemId: string, userId: number) { - this.asignaciones.push({ itemId, userId }) - }, - } as unknown as MondayService & { - cambios: { boardId: string; itemId: string; columna: string; valor: unknown }[] - leadsCreados: Record[] - oportunidadesCreadas: Record[] - contactosCreados: Record[] - apartadosCreados: Record[] - motivos: Record[] - asignaciones: { itemId: string; userId: number }[] - } -} - -const saludSana = { estaDisponible: () => true } - -function crearServicio(monday: MondayService, store: SyncStore, state: StateStore) { - const svc = new SyncMondayService(monday, state, store, saludSana) - registrarHandlersSync(svc) - return svc -} - -beforeEach(() => { - vi.clearAllMocks() - prismaMock.leadCaptura.findUnique.mockResolvedValue(null) - prismaMock.leadCaptura.updateMany.mockResolvedValue({}) - prismaMock.leadOportunidad.create.mockResolvedValue({}) - prismaMock.leadOportunidad.findUnique.mockResolvedValue(null) - prismaMock.cliente.findUnique.mockResolvedValue(null) - prismaMock.cliente.updateMany.mockResolvedValue({}) - prismaMock.cotizacion.updateMany.mockResolvedValue({}) - prismaMock.usuario.findUnique.mockResolvedValue(null) - prismaMock.automatizacionState.upsert.mockResolvedValue({}) - funnelMock.oportunidadPorTelefono.mockResolvedValue(null) -}) - -describe('handler CREAR_LEAD', () => { - it('crea el lead en Monday y actualiza la fila local (mondayItemId + pendienteSync=false)', async () => { - const monday = mondayMock() - const store = storeEnMemoria([ - registro({ - tipo: 'CREAR_LEAD', - payloadJson: JSON.stringify({ datos: { nombre: 'Ana Prueba', telefono: '4771234567' } }), - referenciaLocal: 'loc_ana', - }), - ]) - const svc = crearServicio(monday, store, stateEnMemoria()) - const r = await svc.procesarPendientes() - expect(r).toEqual({ procesados: 1, fallos: 0 }) - expect(monday.leadsCreados).toEqual([{ nombre: 'Ana Prueba', telefono: '4771234567' }]) - expect(prismaMock.leadCaptura.updateMany).toHaveBeenCalledWith({ - where: { leadId: 'loc_ana' }, - data: { mondayItemId: 'monday-lead-1', pendienteSync: false }, - }) - expect(store.rows.get(store.ultimo.id)?.estado).toBe('HECHO') - }) -}) - -describe('handler CONVERSION', () => { - const payloadConversion = { - oportunidadIdLocal: 'loc_op', - datos: { nombre: 'Ana Prueba', estadoLabel: 'Agendo Cita', telefono: '4771234567' }, - } - - it('sin mondayItemId en el lead (CREATE aún en cola): dependencia pendiente → PENDIENTE sin sumar intento', async () => { - prismaMock.leadCaptura.findUnique.mockResolvedValue({ - leadId: 'loc_lead', - mondayItemId: null, // su CREAR_LEAD aún no se sincroniza - }) - const monday = mondayMock() - const store = storeEnMemoria([ - registro({ tipo: 'CONVERSION', payloadJson: JSON.stringify(payloadConversion), referenciaLocal: 'loc_lead' }), - ]) - const svc = crearServicio(monday, store, stateEnMemoria()) - const r = await svc.procesarPendientes() - expect(r).toEqual({ procesados: 0, fallos: 0 }) // no cuenta como fallo - const row = store.rows.get(store.ultimo.id) - expect(row?.estado).toBe('PENDIENTE') - expect(row?.intentos).toBe(0) // sin sumar intento - expect(row?.ultimoError).toContain('conversión') - expect(monday.oportunidadesCreadas).toEqual([]) // no tocó Monday - }) - - it('con el lead ya sincronizado: crea la oportunidad, marca el lead Calificado y actualiza ambos ids locales', async () => { - prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_lead', mondayItemId: 'monday-lead-9' }) - const monday = mondayMock() - const store = storeEnMemoria([ - registro({ tipo: 'CONVERSION', payloadJson: JSON.stringify(payloadConversion), referenciaLocal: 'loc_lead' }), - ]) - const svc = crearServicio(monday, store, stateEnMemoria()) - const r = await svc.procesarPendientes() - expect(r).toEqual({ procesados: 1, fallos: 0 }) - expect(monday.oportunidadesCreadas).toEqual([payloadConversion.datos]) - // board 444 = MONDAY_BOARD_LEADS de test - expect(monday.cambios).toEqual([ - { boardId: '444', itemId: 'monday-lead-9', columna: 'Estado', valor: 'Calificado' }, - ]) - // Oportunidad local actualizada con su id Monday - expect(prismaMock.leadCaptura.updateMany).toHaveBeenCalledWith({ - where: { leadId: 'loc_op' }, - data: { mondayItemId: 'monday-op-1', pendienteSync: false }, - }) - // Mapping con ids Monday para el alta de clientes - expect(prismaMock.leadOportunidad.create).toHaveBeenCalledWith({ - data: { leadId: 'monday-lead-9', oportunidadId: 'monday-op-1' }, - }) - }) -}) - -describe('handler CREAR_CONTACTO', () => { - it('crea el Contacto y liga Cliente.mondayContactoId', async () => { - const monday = mondayMock() - const store = storeEnMemoria([ - registro({ - tipo: 'CREAR_CONTACTO', - payloadJson: JSON.stringify({ datos: { nombre: 'Cliente Demo', estadoLabel: 'CLIENTE', curp: 'CURP1' } }), - referenciaLocal: 'cli-1', - }), - ]) - const svc = crearServicio(monday, store, stateEnMemoria()) - const r = await svc.procesarPendientes() - expect(r).toEqual({ procesados: 1, fallos: 0 }) - expect(monday.contactosCreados).toHaveLength(1) - expect(prismaMock.cliente.updateMany).toHaveBeenCalledWith({ - where: { id: 'cli-1' }, - data: { mondayContactoId: 'monday-cont-1' }, - }) - }) -}) - -describe('handler COTIZACION_OPORTUNIDAD', () => { - const datosCot = { - clienteNombre: 'Cliente Demo', - unidadCodigo: 'U-101', - tipoPropuestaLabel: 'CREDITO BANCARIO', - precioLista: 1_000_000, - descuentoPct: 0, - precioFinal: 1_000_000, - fechaCotizacion: '2026-09-05', - estadoLabel: 'Cotizador', - } - - it('con oportunidadId Monday: ACTUALIZA esa oportunidad y liga Cotizacion.mondayItemId', async () => { - const monday = mondayMock() - const store = storeEnMemoria([ - registro({ - tipo: 'COTIZACION_OPORTUNIDAD', - payloadJson: JSON.stringify({ datos: datosCot, oportunidadId: 'op-77' }), - referenciaLocal: 'cot-1', - }), - ]) - const svc = crearServicio(monday, store, stateEnMemoria()) - const r = await svc.procesarPendientes() - expect(r).toEqual({ procesados: 1, fallos: 0 }) - expect(monday.oportunidadesCreadas).toEqual([]) // no duplica - expect(prismaMock.cotizacion.updateMany).toHaveBeenCalledWith({ - where: { id: 'cot-1' }, - data: { mondayItemId: 'op-77' }, - }) - }) - - it('con oportunidadId local (loc_*) sin sincronizar: dependencia pendiente', async () => { - prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_op', mondayItemId: null }) - const monday = mondayMock() - const store = storeEnMemoria([ - registro({ - tipo: 'COTIZACION_OPORTUNIDAD', - payloadJson: JSON.stringify({ datos: datosCot, oportunidadId: 'loc_op' }), - referenciaLocal: 'cot-2', - }), - ]) - const svc = crearServicio(monday, store, stateEnMemoria()) - const r = await svc.procesarPendientes() - expect(r).toEqual({ procesados: 0, fallos: 0 }) - expect(store.rows.get(store.ultimo.id)?.estado).toBe('PENDIENTE') - expect(store.rows.get(store.ultimo.id)?.intentos).toBe(0) - }) - - it('sin oportunidad: match por teléfono como el controller; sin match crea una nueva', async () => { - funnelMock.oportunidadPorTelefono.mockResolvedValue(null) - const monday = mondayMock() - const store = storeEnMemoria([ - registro({ - tipo: 'COTIZACION_OPORTUNIDAD', - payloadJson: JSON.stringify({ datos: datosCot, clienteTelefono: '4771234567' }), - referenciaLocal: 'cot-3', - }), - ]) - const svc = crearServicio(monday, store, stateEnMemoria()) - await svc.procesarPendientes() - expect(funnelMock.oportunidadPorTelefono).toHaveBeenCalledWith('4771234567') - expect(monday.oportunidadesCreadas).toEqual([datosCot]) - expect(prismaMock.cotizacion.updateMany).toHaveBeenCalledWith({ - where: { id: 'cot-3' }, - data: { mondayItemId: 'monday-op-cot-1' }, - }) - }) -}) - -describe('handler CAMBIO_ETAPA', () => { - it('aplica el cambio al mondayItemId resuelto desde la referencia local', async () => { - prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', mondayItemId: 'op-55' }) - const monday = mondayMock() - const store = storeEnMemoria([ - registro({ - tipo: 'CAMBIO_ETAPA', - payloadJson: JSON.stringify({ board: 'oportunidades', label: 'Cotizador' }), - referenciaLocal: 'loc_1', - }), - ]) - const svc = crearServicio(monday, store, stateEnMemoria()) - await svc.procesarPendientes() - // board 555 = MONDAY_BOARD_OPORTUNIDADES de test - expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-55', columna: 'Estado', valor: 'Cotizador' }]) - expect(prismaMock.leadCaptura.updateMany).toHaveBeenCalledWith({ - where: { leadId: 'loc_1' }, - data: { pendienteSync: false }, - }) - }) -}) - -describe('handler APARTO_OPORTUNIDAD', () => { - it('mueve la oportunidad a Aparto con dedup previo (como moverAApartoConDedup)', async () => { - prismaMock.leadOportunidad.findUnique.mockResolvedValue({ leadId: 'lead-9', oportunidadId: 'op-9' }) - const monday = mondayMock() - const state = stateEnMemoria() - const store = storeEnMemoria([ - registro({ tipo: 'APARTO_OPORTUNIDAD', payloadJson: JSON.stringify({ leadId: 'lead-9' }), referenciaLocal: 'lead-9' }), - ]) - const svc = crearServicio(monday, store, state) - await svc.procesarPendientes() - // Dedup ANTES del cambio de etapa (prismaStateStore, como moverAApartoConDedup) - expect(prismaMock.automatizacionState.upsert).toHaveBeenCalledWith( - expect.objectContaining({ where: { clave: 'apartado-creado:op-9' } }), - ) - expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-9', columna: 'Estado', valor: 'Aparto' }]) - }) -}) - -describe('handler LIQUIDADO_ESCRITURAR', () => { - it('mueve el apartado a X Escriturar (board 666 de test)', async () => { - const monday = mondayMock() - const store = storeEnMemoria([ - registro({ - tipo: 'LIQUIDADO_ESCRITURAR', - payloadJson: JSON.stringify({ mondayItemId: 'apt-55' }), - referenciaLocal: 'apt-55', - }), - ]) - const svc = crearServicio(monday, store, stateEnMemoria()) - await svc.procesarPendientes() - expect(monday.cambios).toEqual([{ boardId: '666', itemId: 'apt-55', columna: 'Estado', valor: 'X Escriturar' }]) - }) -}) - -describe('handler ASIGNAR_PERSONA', () => { - it('resuelve el mondayUserId del usuario local y asigna en el board correcto', async () => { - prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', mondayItemId: 'lead-55' }) - prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 777 }) - const monday = mondayMock() - const store = storeEnMemoria([ - registro({ - tipo: 'ASIGNAR_PERSONA', - payloadJson: JSON.stringify({ board: 'leads', usuarioId: 'u-1' }), - referenciaLocal: 'loc_1', - }), - ]) - const svc = crearServicio(monday, store, stateEnMemoria()) - const r = await svc.procesarPendientes() - expect(r).toEqual({ procesados: 1, fallos: 0 }) - expect(monday.asignaciones).toEqual([{ itemId: 'lead-55', userId: 777 }]) - }) - - it('sin mapeo Monday del usuario: no-op exitoso (reintentar no crearía el mapeo)', async () => { - prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', mondayItemId: 'lead-55' }) - prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: null }) - const monday = mondayMock() - const store = storeEnMemoria([ - registro({ - tipo: 'ASIGNAR_PERSONA', - payloadJson: JSON.stringify({ board: 'leads', usuarioId: 'u-1' }), - referenciaLocal: 'loc_1', - }), - ]) - const svc = crearServicio(monday, store, stateEnMemoria()) - const r = await svc.procesarPendientes() - expect(r).toEqual({ procesados: 1, fallos: 0 }) - expect(monday.asignaciones).toEqual([]) - expect(store.rows.get(store.ultimo.id)?.estado).toBe('HECHO') - }) -}) diff --git a/api-gateway/src/services/sync-handlers.ts b/api-gateway/src/services/sync-handlers.ts deleted file mode 100644 index 87381d4..0000000 --- a/api-gateway/src/services/sync-handlers.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { config } from '../config/index.js' -import { claveApartadoCreado, prismaStateStore } from './automatizaciones.service.js' -import { esIdLocal } from './crm-local.service.js' -import { funnelService } from './funnel.service.js' -import type { - DatosApartado, - DatosContacto, - DatosLead, - DatosOportunidad, - DatosOportunidadAfluencia, -} from './monday.service.js' -import { prisma } from './prisma.js' -import { - DependenciaPendienteError, - syncMondayService, - type SyncMondayService, -} from './sync-monday.service.js' - -/** - * Handlers del sync worker (modo local): una réplica Monday por cada tipo de - * SyncPendiente que encolan crm-local.service y los controllers. - * - * Convenciones: - * - `ctx.referenciaLocal` es el id local de la entidad (leadId loc_* / - * clienteId / cotizacionId); los payloads traen TODO lo necesario para la - * llamada Monday (los mismos datos que mandan hoy los controllers). - * - Cuando la réplica necesita el mondayItemId de algo que aún no se ha creado - * (su CREATE sigue en la cola), se lanza DependenciaPendienteError: el worker - * lo deja PENDIENTE sin sumar intento y lo reintenta en la próxima pasada. - * - Tras el éxito se actualizan los ids locales (LeadCaptura.mondayItemId + - * pendienteSync=false, Cliente.mondayContactoId/mondayApartadoId, - * Cotizacion.mondayItemId). - * - * Wiring: index.ts importa este módulo por su efecto lateral (registro sobre - * el singleton). Los tests llaman `registrarHandlersSync(svcPropio)`. - */ - -/** - * Resuelve el id Monday de un item del embudo: - * - id Monday directo (o fila local ya sincronizada): se devuelve tal cual. - * - id local loc_*: el mondayItemId de su fila LeadCaptura; null si su CREATE - * aún no se sincroniza (→ DependenciaPendienteError en el caller). - */ -async function resolverMondayId(id: string | null | undefined): Promise { - if (id === null || id === undefined) return null - const row = await prisma.leadCaptura.findUnique({ where: { leadId: id } }) - if (row) return row.mondayItemId ?? (esIdLocal(row.leadId) ? null : row.leadId) - // Sin fila local: un id Monday viejo se usa tal cual; un loc_* huérfano no - // tiene destino (null → dependencia pendiente) - return esIdLocal(id) ? null : id -} - -/** Lanza DependenciaPendienteError si el id Monday aún no se puede resolver */ -function requerirMondayId(id: string | null, que: string): string { - if (id === null) { - throw new DependenciaPendienteError(`${que}: el item aún no existe en Monday (su CREATE sigue en la cola)`) - } - return id -} - -/** Marca la fila local del embudo como sincronizada (updateMany: no truena si no existe) */ -async function marcarCapturaSincronizada(leadId: string, mondayItemId?: string): Promise { - await prisma.leadCaptura.updateMany({ - where: { leadId }, - data: { ...(mondayItemId !== undefined ? { mondayItemId } : {}), pendienteSync: false }, - }) -} - -/** Registra todos los handlers sobre el servicio dado (singleton en prod) */ -export function registrarHandlersSync(svc: SyncMondayService = syncMondayService): void { - // ---------- Captura (tableros Leads y Oportunidades) ---------- - - svc.registrarHandler('CREAR_LEAD', async (payload, ctx) => { - const id = await ctx.monday.crearLead(payload['datos'] as DatosLead) - if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal, id) - return id - }) - - svc.registrarHandler('CREAR_AFLUENCIA', async (payload, ctx) => { - const id = await ctx.monday.crearOportunidadAfluencia(payload['datos'] as DatosOportunidadAfluencia) - if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal, id) - return id - }) - - svc.registrarHandler('ACTUALIZAR_AFLUENCIA', async (payload, ctx) => { - const id = requerirMondayId( - await resolverMondayId(ctx.referenciaLocal ?? (payload['oportunidadId'] as string | undefined)), - 'actualizar afluencia', - ) - await ctx.monday.actualizarOportunidadAfluencia(id, payload['datos'] as DatosOportunidadAfluencia) - if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal) - return id - }) - - svc.registrarHandler('CAMBIO_ETAPA', async (payload, ctx) => { - const board = payload['board'] as 'leads' | 'oportunidades' | 'apartado' - const label = payload['label'] as string - const fecha = payload['fecha'] as string | undefined - const id = requerirMondayId(await resolverMondayId(ctx.referenciaLocal), `cambio de etapa a "${label}"`) - const boardId = config.monday.boards[board] - await ctx.monday.cambiarValorColumna(boardId, id, 'Estado', label) - if (fecha !== undefined) await ctx.monday.cambiarValorColumna(boardId, id, 'Fecha', fecha) - if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal) - return id - }) - - svc.registrarHandler('DESCARTE', async (payload, ctx) => { - const fuente = (payload['fuente'] as 'lead' | 'oportunidad' | undefined) ?? 'oportunidad' - const motivo = payload['motivo'] as string - const subcategoria = payload['subcategoria'] as string | undefined - const comentario = payload['comentario'] as string | undefined - const nombreItem = (payload['nombreItem'] as string | undefined) ?? '' - const id = requerirMondayId(await resolverMondayId(ctx.referenciaLocal), 'descarte') - if (fuente === 'lead') { - const boardLeads = config.monday.boards.leads - await ctx.monday.cambiarValorColumna(boardLeads, id, 'Estado', 'Descartado') - if (comentario !== undefined) await ctx.monday.cambiarValorColumna(boardLeads, id, 'Notas', comentario) - } else { - const boardOportunidades = config.monday.boards.oportunidades - await ctx.monday.cambiarValorColumna(boardOportunidades, id, 'Estado', 'Perdido') - if (comentario !== undefined) await ctx.monday.cambiarValorColumna(boardOportunidades, id, 'COMENTARIOS', comentario) - await ctx.monday.cambiarValorColumna(boardOportunidades, id, 'Motivo No Compra', motivo) - } - const motivoItemId = await ctx.monday.crearMotivoNoCompra({ - nombre: `${motivo}${subcategoria !== undefined ? ` / ${subcategoria}` : ''} — ${nombreItem}`, - categoria: motivo, - ...(subcategoria !== undefined ? { subcategoria } : {}), - ...(comentario !== undefined ? { descripcion: comentario } : {}), - }) - if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal) - return motivoItemId - }) - - svc.registrarHandler('CONVERSION', async (payload, ctx) => { - const oportunidadIdLocal = payload['oportunidadIdLocal'] as string - const datos = payload['datos'] as DatosOportunidadAfluencia - const leadMondayId = requerirMondayId(await resolverMondayId(ctx.referenciaLocal), 'conversión (lead)') - const oportunidadId = await ctx.monday.crearOportunidadAfluencia(datos) - // El lead queda Calificado (convertido: sale de Mis leads) - await ctx.monday.cambiarValorColumna(config.monday.boards.leads, leadMondayId, 'Estado', 'Calificado') - if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal) - await marcarCapturaSincronizada(oportunidadIdLocal, oportunidadId) - // Mapping con los ids de Monday para el alta de clientes (moverAApartoConDedup) - try { - await prisma.leadOportunidad.create({ data: { leadId: leadMondayId, oportunidadId } }) - } catch { - // ya existe (reintento tras pasada a medias): el dedup sync: lo cubre - } - return oportunidadId - }) - - // ---------- Alta de clientes (tableros Contactos y Apartado) ---------- - - svc.registrarHandler('CREAR_CONTACTO', async (payload, ctx) => { - const id = await ctx.monday.crearContacto(payload['datos'] as DatosContacto) - if (ctx.referenciaLocal !== null) { - await prisma.cliente.updateMany({ where: { id: ctx.referenciaLocal }, data: { mondayContactoId: id } }) - } - return id - }) - - svc.registrarHandler('ACTUALIZAR_CONTACTO', async (payload, ctx) => { - const cliente = ctx.referenciaLocal !== null - ? await prisma.cliente.findUnique({ where: { id: ctx.referenciaLocal } }) - : null - const id = cliente?.mondayContactoId ?? null - if (id === null) { - throw new DependenciaPendienteError('actualizar contacto: el Contacto aún no existe en Monday') - } - await ctx.monday.actualizarContacto(id, payload['datos'] as Partial) - return id - }) - - svc.registrarHandler('CREAR_APARTADO', async (payload, ctx) => { - const id = await ctx.monday.crearApartado(payload['datos'] as DatosApartado) - // Alta de clientes: la referencia es el cliente local - if (typeof payload['clienteId'] === 'string') { - await prisma.cliente.updateMany({ where: { id: payload['clienteId'] }, data: { mondayApartadoId: id } }) - } - if (ctx.referenciaLocal !== null) { - // "Aparto" local: la referencia es la fila LeadCaptura board=APARTADO - await marcarCapturaSincronizada(ctx.referenciaLocal, id) - // Clientes mínimos creados durante la caída (subida de documentos) ligados - // al id local del apartado: apuntan ahora al item real de Monday - await prisma.cliente.updateMany({ - where: { mondayApartadoId: ctx.referenciaLocal }, - data: { mondayApartadoId: id }, - }) - } - return id - }) - - // ---------- Cotizador ---------- - - svc.registrarHandler('COTIZACION_OPORTUNIDAD', async (payload, ctx) => { - const datos = payload['datos'] as DatosOportunidad - const oportunidadIdPayload = payload['oportunidadId'] as string | undefined - const clienteTelefono = payload['clienteTelefono'] as string | undefined - let oportunidadId: string | null = null - if (oportunidadIdPayload !== undefined) { - oportunidadId = requerirMondayId(await resolverMondayId(oportunidadIdPayload), 'cotización (oportunidad)') - } else if (clienteTelefono !== undefined) { - // Monday ya está vivo en la pasada: mismo match por teléfono que el controller - oportunidadId = await funnelService.oportunidadPorTelefono(clienteTelefono) - } - if (oportunidadId !== null) { - await ctx.monday.actualizarOportunidadCotizacion(oportunidadId, datos) - } else { - oportunidadId = await ctx.monday.crearOportunidad(datos) - } - if (ctx.referenciaLocal !== null) { - await prisma.cotizacion.updateMany({ where: { id: ctx.referenciaLocal }, data: { mondayItemId: oportunidadId } }) - } - return oportunidadId - }) - - // ---------- Documentos y pipeline de apartados ---------- - - svc.registrarHandler('CHECKBOX_DOC', async (payload, ctx) => { - const checkbox = payload['checkbox'] as string - // El item Monday del apartado viaja en el payload (puede ser loc_* si el - // apartado nació en modo local: se resuelve por su fila LeadCaptura) - const itemId = (payload['itemId'] as string | undefined) ?? ctx.referenciaLocal - const id = requerirMondayId(await resolverMondayId(itemId), `checkbox "${checkbox}"`) - await ctx.monday.cambiarValorColumna(config.monday.boards.apartado, id, checkbox, true) - return id - }) - - svc.registrarHandler('APARTO_OPORTUNIDAD', async (payload, ctx) => { - const leadId = payload['leadId'] as string - // Misma resolución que funnel.moverAApartoConDedup: primero como - // oportunidad directa (modelo nuevo), luego como lead con mapping; los ids - // pueden ser locales loc_* (alta hecha en modo local) - let oportunidadId: string | null = null - const porOportunidad = await prisma.leadOportunidad.findUnique({ where: { oportunidadId: leadId } }) - if (porOportunidad) { - oportunidadId = porOportunidad.oportunidadId - } else { - const porLead = await prisma.leadOportunidad.findUnique({ where: { leadId } }) - oportunidadId = porLead?.oportunidadId ?? null - } - if (oportunidadId === null) { - const captura = await prisma.leadCaptura.findUnique({ where: { leadId } }) - if (captura?.board === 'OPORTUNIDADES') oportunidadId = captura.leadId - } - if (oportunidadId === null) throw new Error(`APARTO_OPORTUNIDAD sin mapping para ${leadId}`) - const opMonday = requerirMondayId(await resolverMondayId(oportunidadId), 'mover a Aparto') - // Dedup ANTES del cambio de etapa (el webhook/cron no debe crear el Apartado) - await prismaStateStore.set(claveApartadoCreado(opMonday), new Date().toISOString()) - await ctx.monday.cambiarValorColumna(config.monday.boards.oportunidades, opMonday, 'Estado', 'Aparto') - return opMonday - }) - - svc.registrarHandler('LIQUIDADO_ESCRITURAR', async (payload, ctx) => { - const id = requerirMondayId(await resolverMondayId(payload['mondayItemId'] as string), 'liquidado → X Escriturar') - await ctx.monday.cambiarValorColumna(config.monday.boards.apartado, id, 'Estado', 'X Escriturar') - return id - }) - - // Réplica del status de una unidad cambiado en modo local (espejo → Monday) - svc.registrarHandler('DISPONIBILIDAD_UNIDAD', async (payload, ctx) => { - const codigo = payload['codigo'] as string - const status = payload['status'] as 'DISPONIBLE' | 'APARTADA' | 'VENDIDA' - await ctx.monday.actualizarDisponibilidadUnidad(codigo, status) - return codigo - }) - - svc.registrarHandler('ASIGNAR_PERSONA', async (payload, ctx) => { - const board = payload['board'] as 'leads' | 'oportunidades' - const itemId = requerirMondayId(await resolverMondayId(ctx.referenciaLocal), 'asignación de persona') - let userId = (payload['mondayUserId'] as number | undefined) ?? null - if (userId === null && typeof payload['usuarioId'] === 'string') { - const usuario = await prisma.usuario.findUnique({ - where: { id: payload['usuarioId'] }, - select: { mondayUserId: true }, - }) - userId = usuario?.mondayUserId ?? null - } - // Sin cuenta Monday vinculada no hay a quién asignar: no es un error - // (reintentar no crearía el mapeo), queda como no-op exitoso - if (userId === null) return 'sin-mapeo' - if (board === 'leads') await ctx.monday.asignarVendedorALead(itemId, userId) - else await ctx.monday.asignarAsesorAOportunidad(itemId, userId) - return itemId - }) -} - -// Wiring en prod: registrar sobre el singleton al importar el módulo (index.ts) -registrarHandlersSync() diff --git a/api-gateway/src/services/sync-monday.service.test.ts b/api-gateway/src/services/sync-monday.service.test.ts deleted file mode 100644 index 20b17af..0000000 --- a/api-gateway/src/services/sync-monday.service.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { AppError } from '../utils/errors.js' -import type { StateStore } from './automatizaciones.service.js' -import type { MondayService } from './monday.service.js' -import { - claveSync, - esFalloMonday, - MAX_INTENTOS_SYNC, - SyncMondayService, - type SyncRegistro, - type SyncStore, -} from './sync-monday.service.js' - -// ---------- Fixtures ---------- - -let consecutivo = 0 -function registro(parcial: Partial = {}): SyncRegistro { - consecutivo += 1 - return { - id: parcial.id ?? `reg-${consecutivo}`, - tipo: 'CREAR_LEAD', - payloadJson: JSON.stringify({ nombre: 'Lead de prueba' }), - referenciaLocal: null, - estado: 'PENDIENTE', - intentos: 0, - // createdAt creciente: el orden del arreglo ES el orden FIFO - createdAt: new Date(Date.UTC(2026, 8, 5, 12, 0, consecutivo)), - ...parcial, - } -} - -/** StateStore en memoria (mismo patrón que automatizaciones.service.test.ts) */ -function stateEnMemoria(): StateStore & { mapa: Map } { - const mapa = new Map() - return { - mapa, - async get(clave) { - return mapa.get(clave) ?? null - }, - async set(clave, valor) { - mapa.set(clave, valor) - }, - } -} - -/** SyncStore en memoria: guarda los registros y el historial de marcados */ -function storeEnMemoria(registros: SyncRegistro[]) { - const rows = new Map(registros.map((r) => [r.id, { ...r }])) - const marcados: { id: string; datos: { estado: string; intentos?: number; ultimoError?: string | null } }[] = [] - const store: SyncStore & { rows: Map; marcados: typeof marcados } = { - rows, - marcados, - async pendientes(limite) { - return [...rows.values()] - .filter((r) => r.estado === 'PENDIENTE') - .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) - .slice(0, limite) - }, - async marcar(id, datos) { - marcados.push({ id, datos }) - const row = rows.get(id) - if (!row) throw new Error(`Registro inexistente: ${id}`) - row.estado = datos.estado - if (datos.intentos !== undefined) row.intentos = datos.intentos - if (datos.ultimoError !== undefined) row.ultimoError = datos.ultimoError - }, - } - return store -} - -const saludSana = { estaDisponible: () => true } -const saludCaida = { estaDisponible: () => false } - -// En fase A no hay handlers por defecto; monday solo se pasa al contexto -const mondayDummy = {} as MondayService - -function crearServicio(store: SyncStore, state: StateStore, salud = saludSana) { - return new SyncMondayService(mondayDummy, state, store, salud) -} - -// ---------- Tests ---------- - -describe('esFalloMonday', () => { - it('solo los AppError 502 cuentan como fallo de Monday', () => { - expect(esFalloMonday(new AppError(502, 'Monday no respondió'))).toBe(true) - expect(esFalloMonday(new AppError(500, 'otro'))).toBe(false) - expect(esFalloMonday(new Error('x'))).toBe(false) - }) -}) - -describe('SyncMondayService.procesarPendientes', () => { - it('no-op cuando Monday no está disponible (no toca la cola)', async () => { - const store = storeEnMemoria([registro()]) - const state = stateEnMemoria() - const svc = crearServicio(store, state, saludCaida) - const r = await svc.procesarPendientes() - expect(r).toEqual({ procesados: 0, fallos: 0 }) - expect(store.marcados).toHaveLength(0) - expect(store.rows.get('reg-1')?.estado).toBe('PENDIENTE') - }) - - it('procesa FIFO y marca HECHO tras ejecutar el handler', async () => { - const store = storeEnMemoria([ - registro({ id: 'a', createdAt: new Date('2026-09-05T12:00:02Z') }), - registro({ id: 'b', createdAt: new Date('2026-09-05T12:00:01Z') }), - ]) - const state = stateEnMemoria() - const svc = crearServicio(store, state) - const orden: string[] = [] - svc.registrarHandler('CREAR_LEAD', async (payload) => { - orden.push(String(payload.orden)) - return 'item-1' - }) - // Reescribo payloads para distinguir orden de ejecución - store.rows.get('a')!.payloadJson = JSON.stringify({ orden: 'segundo-en-fila' }) - store.rows.get('b')!.payloadJson = JSON.stringify({ orden: 'primero-en-fila' }) - - const r = await svc.procesarPendientes() - expect(r).toEqual({ procesados: 2, fallos: 0 }) - expect(orden).toEqual(['primero-en-fila', 'segundo-en-fila']) // b (más viejo) primero - expect(store.rows.get('a')?.estado).toBe('HECHO') - expect(store.rows.get('b')?.estado).toBe('HECHO') - // Dedup persistido con el id devuelto por el handler - expect(state.mapa.get(claveSync('a'))).toBe('item-1') - expect(state.mapa.get(claveSync('b'))).toBe('item-1') - }) - - it('marca ERROR con mensaje claro cuando el tipo no tiene handler', async () => { - const store = storeEnMemoria([registro({ id: 'x', tipo: 'DESCARTE' })]) - const svc = crearServicio(store, stateEnMemoria()) - const r = await svc.procesarPendientes() - expect(r).toEqual({ procesados: 0, fallos: 1 }) - const row = store.rows.get('x') - expect(row?.estado).toBe('ERROR') - expect(row?.ultimoError).toMatch(/sin handler registrado: "DESCARTE"/) - }) - - it('marca ERROR cuando el payloadJson no es JSON válido', async () => { - const store = storeEnMemoria([registro({ id: 'x', payloadJson: '{roto' })]) - const svc = crearServicio(store, stateEnMemoria()) - svc.registrarHandler('CREAR_LEAD', async () => {}) - const r = await svc.procesarPendientes() - expect(r.fallos).toBe(1) - expect(store.rows.get('x')?.estado).toBe('ERROR') - expect(store.rows.get('x')?.ultimoError).toMatch(/payloadJson inválido/) - }) - - it('fallo Monday (502): vuelve a PENDIENTE con intentos+1 (backoff)', async () => { - const store = storeEnMemoria([registro({ id: 'x', intentos: 2 })]) - const svc = crearServicio(store, stateEnMemoria()) - svc.registrarHandler('CREAR_LEAD', async () => { - throw new AppError(502, 'Monday no respondió tras 4 intentos') - }) - const r = await svc.procesarPendientes() - expect(r).toEqual({ procesados: 0, fallos: 1 }) - const row = store.rows.get('x') - expect(row?.estado).toBe('PENDIENTE') - expect(row?.intentos).toBe(3) - expect(row?.ultimoError).toMatch(/Monday no respondió/) - }) - - it('al superar MAX_INTENTOS_SYNC el fallo Monday queda ERROR definitivo', async () => { - const store = storeEnMemoria([registro({ id: 'x', intentos: MAX_INTENTOS_SYNC })]) - const svc = crearServicio(store, stateEnMemoria()) - svc.registrarHandler('CREAR_LEAD', async () => { - throw new AppError(502, 'Monday no respondió') - }) - await svc.procesarPendientes() - const row = store.rows.get('x') - expect(row?.estado).toBe('ERROR') - expect(row?.intentos).toBe(MAX_INTENTOS_SYNC + 1) - }) - - it('un error que NO es de Monday marca ERROR sin reintento', async () => { - const store = storeEnMemoria([registro({ id: 'x' })]) - const svc = crearServicio(store, stateEnMemoria()) - svc.registrarHandler('CREAR_LEAD', async () => { - throw new Error('payload incompleto: falta nombre') - }) - await svc.procesarPendientes() - expect(store.rows.get('x')?.estado).toBe('ERROR') - expect(store.rows.get('x')?.ultimoError).toMatch(/payload incompleto/) - }) - - it('idempotencia: con sync: ya en el state no reejecuta el handler', async () => { - const store = storeEnMemoria([registro({ id: 'x' })]) - const state = stateEnMemoria() - state.mapa.set(claveSync('x'), 'item-ya-creado') - const svc = crearServicio(store, state) - let llamadas = 0 - svc.registrarHandler('CREAR_LEAD', async () => { - llamadas++ - }) - const r = await svc.procesarPendientes() - expect(r).toEqual({ procesados: 1, fallos: 0 }) - expect(llamadas).toBe(0) // no duplica el item en Monday - expect(store.rows.get('x')?.estado).toBe('HECHO') - }) - - it('respeta el límite por pasada', async () => { - const store = storeEnMemoria([registro({ id: 'a' }), registro({ id: 'b' }), registro({ id: 'c' })]) - const svc = crearServicio(store, stateEnMemoria()) - svc.registrarHandler('CREAR_LEAD', async () => {}) - const r = await svc.procesarPendientes({ limite: 2 }) - expect(r.procesados).toBe(2) - expect(store.rows.get('c')?.estado).toBe('PENDIENTE') - }) - - it('una pasada en curso bloquea la siguiente (mutex)', async () => { - const store = storeEnMemoria([registro({ id: 'x' })]) - const svc = crearServicio(store, stateEnMemoria()) - let liberar!: () => void - const bloqueo = new Promise((res) => { - liberar = res - }) - svc.registrarHandler('CREAR_LEAD', async () => { - await bloqueo - }) - const pasada1 = svc.procesarPendientes() - // Segunda pasada mientras la primera sigue: no-op inmediato - await expect(svc.procesarPendientes()).resolves.toEqual({ procesados: 0, fallos: 0 }) - liberar() - await expect(pasada1).resolves.toEqual({ procesados: 1, fallos: 0 }) - }) -}) diff --git a/api-gateway/src/services/sync-monday.service.ts b/api-gateway/src/services/sync-monday.service.ts deleted file mode 100644 index 8707abe..0000000 --- a/api-gateway/src/services/sync-monday.service.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { AppError } from '../utils/errors.js' -import { prismaStateStore, type StateStore } from './automatizaciones.service.js' -import { mondayService, type MondayService } from './monday.service.js' -import { prisma } from './prisma.js' -import { saludMonday, type SaludMondayService } from './salud-monday.service.js' - -/** - * Sync worker del modo local: drena la cola SyncPendiente hacia Monday. - * - * - Solo corre cuando Monday está disponible (saludMonday); si está caído es no-op. - * - FIFO por createdAt (la captura encoló en orden: lead → conversión → - * afluencia → apartado → contacto) y SECUENCIAL: SQLite serializa las - * escrituras (P1008) si se procesa en paralelo. - * - Idempotencia con AutomatizacionState, clave `sync:` (mismo patrón que - * `apartado-creado:` de automatizaciones): si el proceso muere a media - * pasada, al reintentar no duplica el item en Monday. - * - Fallo Monday (AppError 502): vuelve a PENDIENTE con intentos+1; al superar - * MAX_INTENTOS queda ERROR (visible en /api/sistema/estado). Cualquier otro - * error (tipo sin handler, payload inválido) marca ERROR de inmediato. - * - * Los handlers por tipo los registra la fase B (con los payloads reales de - * cada flujo) vía `registrarHandler`; el mapa queda extensible y defensivo. - */ - -export const TIPOS_SYNC = [ - 'CREAR_LEAD', - 'CREAR_AFLUENCIA', - 'ACTUALIZAR_AFLUENCIA', - 'CAMBIO_ETAPA', - 'DESCARTE', - 'CONVERSION', - 'CREAR_CONTACTO', - 'ACTUALIZAR_CONTACTO', - 'CREAR_APARTADO', - 'COTIZACION_OPORTUNIDAD', - 'CHECKBOX_DOC', - 'APARTO_OPORTUNIDAD', - 'LIQUIDADO_ESCRITURAR', - 'ASIGNAR_PERSONA', - 'DISPONIBILIDAD_UNIDAD', -] as const - -export type TipoSync = (typeof TIPOS_SYNC)[number] - -/** Intentos máximos antes de declarar ERROR definitivo */ -export const MAX_INTENTOS_SYNC = 10 - -/** Registro de la cola tal como lo consume el worker */ -export interface SyncRegistro { - id: string - tipo: string - payloadJson: string - referenciaLocal: string | null - estado: string - intentos: number - createdAt: Date -} - -/** Acceso mínimo a la tabla SyncPendiente (Prisma en prod, memoria en tests) */ -export interface SyncStore { - /** Hasta `limite` registros PENDIENTE en orden FIFO (createdAt asc) */ - pendientes(limite: number): Promise - marcar( - id: string, - datos: { estado: string; intentos?: number; ultimoError?: string | null }, - ): Promise -} - -export interface ContextoSync { - monday: MondayService - referenciaLocal: string | null -} - -/** - * Ejecuta la réplica en Monday. Devuelve el id creado (o cualquier cadena) para - * guardarlo en el dedup `sync:`; void se persiste como 'ok'. - */ -export type HandlerSync = ( - payload: Record, - ctx: ContextoSync, -) => Promise - -export interface ResultadoSync { - procesados: number - fallos: number -} - -/** true si el error es un fallo de Monday retryable (AppError 502 de graphql) */ -export function esFalloMonday(err: unknown): boolean { - return err instanceof AppError && err.status === 502 -} - -/** - * El handler no puede ejecutarse TODAVÍA porque una operación previa de la - * cola aún no se sincroniza (p.ej. CAMBIO_ETAPA sobre un item cuyo CREATE no - * ha corrido: no hay mondayItemId). El worker lo deja PENDIENTE SIN sumar - * intento: se reintenta en la siguiente pasada, cuando su dependencia ya se - * haya aplicado (la cola es FIFO). - */ -export class DependenciaPendienteError extends Error { - constructor(message: string) { - super(message) - this.name = 'DependenciaPendienteError' - } -} - -/** Clave de dedup/idempotencia del registro en AutomatizacionState */ -export function claveSync(id: string): string { - return `sync:${id}` -} - -export class SyncMondayService { - private readonly handlers = new Map() - /** Mutex en memoria: evita solapar el cron de 2 min con el disparo al recuperar salud */ - private enEjecucion = false - - constructor( - private readonly monday: MondayService, - private readonly state: StateStore, - private readonly db: SyncStore, - private readonly salud: Pick = saludMonday, - ) {} - - /** Registra el handler de un tipo de réplica (lo usa la fase B por flujo) */ - registrarHandler(tipo: TipoSync, handler: HandlerSync): void { - this.handlers.set(tipo, handler) - } - - /** - * Una pasada por la cola (máx `limite` registros). No-op si Monday está - * caído / MODO_LOCAL_FORZADO, o si ya hay una pasada en curso. - */ - async procesarPendientes({ limite = 25 }: { limite?: number } = {}): Promise { - if (!this.salud.estaDisponible()) return { procesados: 0, fallos: 0 } - if (this.enEjecucion) return { procesados: 0, fallos: 0 } - this.enEjecucion = true - try { - const pendientes = await this.db.pendientes(limite) - let procesados = 0 - let fallos = 0 - for (const registro of pendientes) { - await this.db.marcar(registro.id, { estado: 'PROCESANDO' }) - try { - await this.ejecutar(registro) - await this.db.marcar(registro.id, { estado: 'HECHO', ultimoError: null }) - procesados++ - } catch (err) { - const mensaje = err instanceof Error ? err.message : String(err) - if (err instanceof DependenciaPendienteError) { - // Falta una operación previa de la cola: queda PENDIENTE sin sumar - // intento (no es un fallo; se reintenta en la próxima pasada) - await this.db.marcar(registro.id, { estado: 'PENDIENTE', ultimoError: mensaje }) - } else { - fallos++ - const intentos = registro.intentos + 1 - if (esFalloMonday(err)) { - // Monday volvió a caer a media pasada: reintenta en la siguiente - await this.db.marcar(registro.id, { - estado: intentos > MAX_INTENTOS_SYNC ? 'ERROR' : 'PENDIENTE', - intentos, - ultimoError: mensaje, - }) - } else { - // Error de datos/código: reintentar no lo arregla - await this.db.marcar(registro.id, { estado: 'ERROR', intentos, ultimoError: mensaje }) - } - } - } - } - if (procesados + fallos > 0) { - // eslint-disable-next-line no-console - console.log(`[sync] procesados ${procesados}, fallos ${fallos}`) - } - return { procesados, fallos } - } finally { - this.enEjecucion = false - } - } - - /** Ejecuta un registro con dedup `sync:`; lanza para que el caller clasifique */ - private async ejecutar(registro: SyncRegistro): Promise { - const handler = this.handlers.get(registro.tipo) - if (!handler) throw new Error(`Tipo de sync sin handler registrado: "${registro.tipo}"`) - let payload: Record - try { - payload = JSON.parse(registro.payloadJson) as Record - } catch { - throw new Error(`payloadJson inválido (no es JSON): registro ${registro.id}`) - } - const clave = claveSync(registro.id) - const previo = await this.state.get(clave) - if (previo !== null) return // ya se aplicó en una pasada previa (dedup) - const resultado = await handler(payload, { - monday: this.monday, - referenciaLocal: registro.referenciaLocal, - }) - await this.state.set(clave, typeof resultado === 'string' && resultado !== '' ? resultado : 'ok') - } -} - -/** Store respaldado por la tabla SyncPendiente de Prisma */ -export const prismaSyncStore: SyncStore = { - pendientes(limite) { - return prisma.syncPendiente.findMany({ - where: { estado: 'PENDIENTE' }, - orderBy: { createdAt: 'asc' }, - take: limite, - }) - }, - async marcar(id, datos) { - await prisma.syncPendiente.update({ where: { id }, data: datos }) - }, -} - -/** Singleton usado por los crons de index.ts (los tests instancian el suyo) */ -export const syncMondayService = new SyncMondayService(mondayService, prismaStateStore, prismaSyncStore) diff --git a/api-gateway/src/services/unidades-local.service.test.ts b/api-gateway/src/services/unidades-local.service.test.ts index bfea782..1ff2e0f 100644 --- a/api-gateway/src/services/unidades-local.service.test.ts +++ b/api-gateway/src/services/unidades-local.service.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { AppError } from '../utils/errors.js' -// ---------- Prisma y Monday mockeados (los tests no tocan BD ni red) ---------- +// ---------- Prisma mockeado (los tests no tocan BD) ---------- const prismaMock = vi.hoisted(() => ({ unidad: { @@ -10,23 +9,14 @@ const prismaMock = vi.hoisted(() => ({ }, })) -const mondayMock = vi.hoisted(() => ({ - getUnidades: vi.fn(), - getUnidad: vi.fn(), -})) - vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) -vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock })) import { getUnidadesLocal, getUnidadLocal, - listarUnidadesConFallback, - obtenerUnidadConFallback, unidadLocalADto, type FilaUnidad, } from './unidades-local.service.js' -import { saludMonday } from './salud-monday.service.js' function fila(parcial: Partial & { codigo: string }): FilaUnidad { return { @@ -45,14 +35,12 @@ function fila(parcial: Partial & { codigo: string }): FilaUnidad { beforeEach(() => { vi.clearAllMocks() - vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible') - saludMonday.restablecer() prismaMock.unidad.findMany.mockResolvedValue([]) prismaMock.unidad.findUnique.mockResolvedValue(null) }) describe('unidadLocalADto', () => { - it('mapea la fila local al DTO Unidad (misma forma que Monday)', () => { + it('mapea la fila local al DTO Unidad', () => { const dto = unidadLocalADto(fila({ codigo: 'SENDA-101' })) expect(dto).toEqual({ codigo: 'SENDA-101', @@ -70,7 +58,7 @@ describe('unidadLocalADto', () => { }) }) - it('etapa vacía → null (como el mapeo Monday)', () => { + it('etapa vacía → null', () => { expect(unidadLocalADto(fila({ codigo: 'U-1', etapa: '' })).etapa).toBeNull() }) }) @@ -100,42 +88,3 @@ describe('getUnidadesLocal / getUnidadLocal', () => { expect(await getUnidadLocal('U-X')).toBeNull() }) }) - -describe('fallbacks con circuit breaker', () => { - it('Monday caído (salud) → lee local sin llamar a Monday', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) - prismaMock.unidad.findMany.mockResolvedValue([fila({ codigo: 'U-1' })]) - prismaMock.unidad.findUnique.mockResolvedValue(fila({ codigo: 'U-1' })) - - const lista = await listarUnidadesConFallback() - expect(lista.map((u) => u.codigo)).toEqual(['U-1']) - expect(mondayMock.getUnidades).not.toHaveBeenCalled() - - const unidad = await obtenerUnidadConFallback('U-1') - expect(unidad?.codigo).toBe('U-1') - expect(mondayMock.getUnidad).not.toHaveBeenCalled() - }) - - it('Monday vivo → camino normal (sin tocar local)', async () => { - mondayMock.getUnidades.mockResolvedValue([{ codigo: 'M-1' }]) - mondayMock.getUnidad.mockResolvedValue({ codigo: 'M-1' }) - expect(await listarUnidadesConFallback()).toEqual([{ codigo: 'M-1' }]) - expect(await obtenerUnidadConFallback('M-1')).toEqual({ codigo: 'M-1' }) - expect(prismaMock.unidad.findMany).not.toHaveBeenCalled() - }) - - it('Monday lanza AppError 502 → fallback local', async () => { - mondayMock.getUnidades.mockRejectedValue(new AppError(502, 'Monday no respondió')) - mondayMock.getUnidad.mockRejectedValue(new AppError(502, 'Monday no respondió')) - prismaMock.unidad.findMany.mockResolvedValue([fila({ codigo: 'U-1' })]) - prismaMock.unidad.findUnique.mockResolvedValue(fila({ codigo: 'U-1' })) - - expect((await listarUnidadesConFallback()).map((u) => u.codigo)).toEqual(['U-1']) - expect((await obtenerUnidadConFallback('U-1'))?.codigo).toBe('U-1') - }) - - it('otro error de Monday se propaga (no se traga nada)', async () => { - mondayMock.getUnidades.mockRejectedValue(new AppError(500, 'bug')) - await expect(listarUnidadesConFallback()).rejects.toThrow('bug') - }) -}) diff --git a/api-gateway/src/services/unidades-local.service.ts b/api-gateway/src/services/unidades-local.service.ts index d319b12..fa4ac0f 100644 --- a/api-gateway/src/services/unidades-local.service.ts +++ b/api-gateway/src/services/unidades-local.service.ts @@ -1,14 +1,10 @@ -import { mondayService, type Unidad } from './monday.service.js' import { prisma } from './prisma.js' -import { saludMonday } from './salud-monday.service.js' -import { AppError } from '../utils/errors.js' +import type { UnidadCotizable } from './cotizacion.service.js' /** - * Fallback de LECTURA del inventario (modo local): la tabla `Unidad` es el - * espejo local del tablero Unidades de Monday (la siembra - * scripts/seed-unidades-locales.js y la mantiene fresco el write-through de - * monday.service.getUnidades). Cuando Monday está caído (saludMonday) o lanza - * AppError 502, el cotizador lee de aquí con la MISMA forma de DTO. + * Espejo local del inventario de unidades (tabla `Unidad`, sembrada por + * scripts/seed-unidades-locales.js). Es la única fuente de lectura del + * inventario: misma forma de DTO que consumen controllers y fronts. * * La tabla local no guarda fechaEntrega ni mesesParaFirma (no están en el * seed curado): salen en null, que el cotizador ya tolera (plazo desconocido). @@ -29,7 +25,7 @@ export interface FilaUnidad { } /** Fila local → DTO `Unidad` (UnidadCotizable) que consumen controllers y fronts */ -export function unidadLocalADto(u: FilaUnidad): Unidad { +export function unidadLocalADto(u: FilaUnidad): UnidadCotizable { return { codigo: u.codigo, modelo: u.modelo, @@ -46,15 +42,10 @@ export function unidadLocalADto(u: FilaUnidad): Unidad { } } -/** true si el error es un fallo de Monday (AppError 502) que amerita fallback local */ -function esFalloMonday502(err: unknown): boolean { - return err instanceof AppError && err.status === 502 -} - /** Unidades del espejo local; filtros equivalentes a los del controller */ export async function getUnidadesLocal( filtro: { desarrollo?: string | undefined; soloDisponibles?: boolean | undefined } = {}, -): Promise { +): Promise { const rows = await prisma.unidad.findMany({ orderBy: { codigo: 'asc' } }) let lista = rows.map(unidadLocalADto) if (filtro.soloDisponibles === true) { @@ -68,33 +59,7 @@ export async function getUnidadesLocal( } /** Una unidad local por código; null si no existe en el espejo */ -export async function getUnidadLocal(codigo: string): Promise { +export async function getUnidadLocal(codigo: string): Promise { const row = await prisma.unidad.findUnique({ where: { codigo } }) return row === null ? null : unidadLocalADto(row) } - -/** - * Lectura con fallback: Monday si está disponible; espejo local si el circuit - * breaker lo da por caído o si la llamada lanza AppError 502. Cualquier otro - * error se propaga intacto (no se traga nada del camino feliz). - */ -export async function listarUnidadesConFallback(): Promise { - if (!saludMonday.estaDisponible()) return getUnidadesLocal() - try { - return await mondayService.getUnidades() - } catch (err) { - if (esFalloMonday502(err)) return getUnidadesLocal() - throw err - } -} - -/** Igual que listarUnidadesConFallback pero para una unidad por código */ -export async function obtenerUnidadConFallback(codigo: string): Promise { - if (!saludMonday.estaDisponible()) return getUnidadLocal(codigo) - try { - return await mondayService.getUnidad(codigo) - } catch (err) { - if (esFalloMonday502(err)) return getUnidadLocal(codigo) - throw err - } -} diff --git a/api-gateway/vitest.config.ts b/api-gateway/vitest.config.ts index e3268d5..af7194f 100644 --- a/api-gateway/vitest.config.ts +++ b/api-gateway/vitest.config.ts @@ -3,19 +3,8 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { env: { - // config/index.ts exige estas vars al importarse; los tests nunca llaman a Monday real - MONDAY_API_KEY: 'test-key', - MONDAY_BOARD_CONTACTOS: '111', - MONDAY_BOARD_DESARROLLOS: '222', - MONDAY_BOARD_UNIDADES: '333', - MONDAY_BOARD_LEADS: '444', - MONDAY_BOARD_OPORTUNIDADES: '555', - MONDAY_BOARD_APARTADO: '666', - MONDAY_BOARD_VENTAS: '777', - MONDAY_BOARD_MOTIVOS: '888', - MONDAY_BOARD_ACTIVIDADES: '999', - // Las automatizaciones no deben crear actividades salvo que el test lo pida - ACTIVIDADES_ENABLED: 'false', + // Las automatizaciones no deben correr crons salvo que el test lo pida + AUTOMATIONS_ENABLED: 'false', // Auth: desactivada por defecto en tests (los tests de auth la activan // explícitamente); JWT_SECRET fijo para poder firmar/verificar tokens AUTH_ENABLED: 'false', diff --git a/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx b/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx index 5a23899..0269166 100644 --- a/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx +++ b/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx @@ -20,7 +20,7 @@ interface Props { onPasarAAltaCliente: (lead: LeadResumen) => void /** Prefill desde Mis oportunidades (Mi día): valores iniciales del formulario */ valoresIniciales?: Partial - /** Oportunidad de Monday a ACTUALIZAR al enviar (sin duplicar el item) */ + /** Oportunidad a ACTUALIZAR al enviar (sin duplicar el registro) */ oportunidadId?: string } @@ -62,7 +62,7 @@ const FORM_INICIAL = { /** * Captura ligera del interesado al entrar al embudo (POST /leads): crea el - * lead en Monday y su oportunidad en etapa Afluencia. El alta completa de + * lead y su oportunidad en etapa Afluencia. El alta completa de * cliente sigue siendo el wizard del picker. */ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valoresIniciales, oportunidadId }: Props) { @@ -90,7 +90,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor MODELOS_POR_PROYECTO[form.desarrollo as keyof typeof MODELOS_POR_PROYECTO] ?? [] /** - * Jala los datos de un lead u oportunidad ya capturados en Monday: + * Jala los datos de un lead u oportunidad ya capturados: * nombre/contacto siempre; origen, desarrollo y modelo solo si el valor del * lead existe entre las opciones del formulario. */ @@ -267,13 +267,13 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor className="mb-6 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-xs font-medium text-blue-800" > Estás completando la afluencia de {form.nombre || 'esta oportunidad'} — al enviar se actualiza su - oportunidad en Monday (no se duplica). + oportunidad ya capturada (no se duplica).

) : (

- Si el interesado ya está en Monday (lead u oportunidad), sus datos se prellenan solos. + Si el interesado ya está capturado (lead u oportunidad), sus datos se prellenan solos.

)} diff --git a/apps/alta-clientes/src/components/BannerModoLocal.tsx b/apps/alta-clientes/src/components/BannerModoLocal.tsx deleted file mode 100644 index fefd817..0000000 --- a/apps/alta-clientes/src/components/BannerModoLocal.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useEffect, useState } from 'react' -import { fetchEstadoSistema } from '../services/sistema' - -const INTERVALO_POLL_MS = 60_000 - -export function useModoLocal(): boolean { - const [modoLocal, setModoLocal] = useState(false) - useEffect(() => { - let activo = true - const consultar = () => { - void fetchEstadoSistema().then((estado) => { - if (activo) setModoLocal(estado !== null && !estado.mondayOk) - }) - } - consultar() - const id = setInterval(consultar, INTERVALO_POLL_MS) - return () => { activo = false; clearInterval(id) } - }, []) - return modoLocal -} - -export default function BannerModoLocal() { - const modoLocal = useModoLocal() - if (!modoLocal) return null - return ( -
-

- Modo local — Monday no disponible. Tus capturas se guardan y se sincronizarán automáticamente. -

-
- ) -} diff --git a/apps/alta-clientes/src/components/BusquedaLead.test.tsx b/apps/alta-clientes/src/components/BusquedaLead.test.tsx index fd9512b..c6ec27f 100644 --- a/apps/alta-clientes/src/components/BusquedaLead.test.tsx +++ b/apps/alta-clientes/src/components/BusquedaLead.test.tsx @@ -67,6 +67,6 @@ describe('BusquedaLead', () => { fetchMock.mockRejectedValueOnce(new Error('red caída')) fireEvent.change(screen.getByLabelText(/Buscar lead/), { target: { value: 'otro' } }) - expect((await screen.findByRole('alert')).textContent).toContain('No se pudo buscar en Monday') + expect((await screen.findByRole('alert')).textContent).toContain('No se pudo buscar') }) }) diff --git a/apps/alta-clientes/src/components/BusquedaLead.tsx b/apps/alta-clientes/src/components/BusquedaLead.tsx index af1cbee..fb3e0a1 100644 --- a/apps/alta-clientes/src/components/BusquedaLead.tsx +++ b/apps/alta-clientes/src/components/BusquedaLead.tsx @@ -8,7 +8,7 @@ interface Props { } /** - * Buscador con debounce de leads/afluencias de Monday (GET /leads/buscar, + * Buscador con debounce de leads/afluencias (GET /leads/buscar, * búsqueda dual: tablero Leads histórico + Oportunidades). No llama al API * con menos de 2 caracteres; al elegir un resultado avisa con onSeleccion. */ @@ -35,7 +35,7 @@ export default function BusquedaLead({ onSeleccion }: Props) { }) .catch(() => { setResultados(null) - setError('No se pudo buscar en Monday. Intenta de nuevo.') + setError('No se pudo buscar. Intenta de nuevo.') }) .finally(() => setBuscando(false)) }, DEBOUNCE_MS) diff --git a/apps/alta-clientes/src/data/constantes.ts b/apps/alta-clientes/src/data/constantes.ts index 9bc2f5e..00f33ca 100644 --- a/apps/alta-clientes/src/data/constantes.ts +++ b/apps/alta-clientes/src/data/constantes.ts @@ -94,7 +94,7 @@ export const SLOTS_DOCUMENTOS: { slot: SlotDocumento; etiqueta: string }[] = [ export const TAMANO_MAX_DOCUMENTO = 10 * 1024 * 1024 // 10 MB -// Catálogos de Monday (labels exactos). Origen: vocabulario FRASA sembrado en +// Catálogos (labels exactos). Origen: vocabulario FRASA sembrado en // los tableros Leads y Oportunidades el 2026-09-04 (reemplaza al set anterior) export const ORIGENES_LEAD = ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'BROCKER', 'OTRO'] as const export const DESARROLLOS_LEAD = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'] as const diff --git a/apps/alta-clientes/src/lib/apartadoAlta.test.ts b/apps/alta-clientes/src/lib/apartadoAlta.test.ts index 327d58d..4bfe796 100644 --- a/apps/alta-clientes/src/lib/apartadoAlta.test.ts +++ b/apps/alta-clientes/src/lib/apartadoAlta.test.ts @@ -25,7 +25,7 @@ const base: PrefillApartado = { cotizacion: { tipoPropuesta: 'INFONAVIT', acabados: true, precioFinal: 2646000, enganche: 132300 }, } -describe('apartadoAPrellenado (alta desde apartado de Monday)', () => { +describe('apartadoAPrellenado (alta desde apartado)', () => { it('prellena datos generales (contacto manda) y vivienda/crédito (apartado > oportunidad > cotización)', () => { const p = apartadoAPrellenado(base) expect(p.datosGenerales).toMatchObject({ diff --git a/apps/alta-clientes/src/lib/apartadoAlta.ts b/apps/alta-clientes/src/lib/apartadoAlta.ts index a663e6e..91c6e14 100644 --- a/apps/alta-clientes/src/lib/apartadoAlta.ts +++ b/apps/alta-clientes/src/lib/apartadoAlta.ts @@ -2,7 +2,7 @@ import type { DatosGenerales, EstadoCivil, TipoCredito, ViviendaCredito } from ' import { PROYECTOS } from '../data/constantes' /** - * Transformación pura: todo lo recolectado de un apartado de Monday (ficha del + * Transformación pura: todo lo recolectado de un apartado (ficha del * apartado + contacto + oportunidad + cotización vinculada) → campos del * wizard de alta. Prellena solo lo que exista (nunca inventa datos). */ @@ -42,7 +42,7 @@ export interface PrellenadoDesdeApartado { leadMondayId: string | null } -// Label de Monday (Contacto/Oportunidad) → enum del wizard +// Label de etapa (Contacto/Oportunidad) → enum del wizard const ESTADO_CIVIL_ENUM: Record = { 'SOLTERO(A)': 'SOLTERO', SOLTERO: 'SOLTERO', diff --git a/apps/alta-clientes/src/lib/leadAlta.ts b/apps/alta-clientes/src/lib/leadAlta.ts index 1013852..7a0ea23 100644 --- a/apps/alta-clientes/src/lib/leadAlta.ts +++ b/apps/alta-clientes/src/lib/leadAlta.ts @@ -2,7 +2,7 @@ import type { DatosGenerales } from '../types' import type { LeadResumen } from '../services/api' /** - * Transformación pura lead de Monday → campos del wizard de alta. + * Transformación pura lead → campos del wizard de alta. * Prellena solo lo que el lead tenga (nombre en mayúsculas, como el formato). */ export function leadAPrellenado( diff --git a/apps/alta-clientes/src/pages/AltaClientePage.progreso.test.tsx b/apps/alta-clientes/src/pages/AltaClientePage.progreso.test.tsx index e2ed7aa..f019b23 100644 --- a/apps/alta-clientes/src/pages/AltaClientePage.progreso.test.tsx +++ b/apps/alta-clientes/src/pages/AltaClientePage.progreso.test.tsx @@ -113,7 +113,7 @@ describe('AltaClientePage — guardado progresivo (etapa por etapa)', () => { expect(await screen.findByLabelText(/Tipo de empleo/)).toBeTruthy() await waitFor(() => { const avisos = screen.getAllByRole('status').map((el) => el.textContent ?? '') - expect(avisos.some((a) => a.includes('El avance quedó guardado local'))).toBe(true) + expect(avisos.some((a) => a.includes('El avance quedó guardado en este dispositivo'))).toBe(true) }) }) diff --git a/apps/alta-clientes/src/pages/AltaClientePage.test.tsx b/apps/alta-clientes/src/pages/AltaClientePage.test.tsx index 5802258..4918901 100644 --- a/apps/alta-clientes/src/pages/AltaClientePage.test.tsx +++ b/apps/alta-clientes/src/pages/AltaClientePage.test.tsx @@ -68,7 +68,7 @@ describe('AltaClientePage — pantalla de entrada', () => { }) }) -describe('AltaClientePage — alta desde lead de Monday', () => { +describe('AltaClientePage — alta desde lead capturado', () => { it('al elegir un lead el wizard arranca con datosGenerales prellenados', async () => { render() diff --git a/apps/alta-clientes/src/pages/AltaClientePage.tsx b/apps/alta-clientes/src/pages/AltaClientePage.tsx index 8c9e820..b2835c2 100644 --- a/apps/alta-clientes/src/pages/AltaClientePage.tsx +++ b/apps/alta-clientes/src/pages/AltaClientePage.tsx @@ -216,7 +216,7 @@ export default function AltaClientePage() { setPantalla('wizard') } - // Alta nueva prellenada desde un lead de Monday (nombre, email, celular) + // Alta nueva prellenada desde un lead ya capturado (nombre, email, celular) const empezarDesdeLead = (lead: LeadResumen) => { reiniciar() setBorradorId(null) @@ -282,7 +282,7 @@ export default function AltaClientePage() { irAPaso(destino) } - // ---------- Guardado progresivo (borrador local + Contacto en Monday) ---------- + // ---------- Guardado progresivo (borrador local + alta en progreso) ---------- // Guard in-flight: con clicks rápidos se omite el sync en curso; el siguiente // avance manda el estado completo (el PATCH es idempotente). const syncEnCurso = useRef(false) @@ -304,13 +304,13 @@ export default function AltaClientePage() { setAvisoProgreso('') } catch (err) { console.warn('[alta] no se pudo sincronizar el avance con el servidor', err) - setAvisoProgreso('El avance quedó guardado local; no se pudo subir a Monday') + setAvisoProgreso('El avance quedó guardado en este dispositivo; no se pudo subir al servidor') } finally { syncEnCurso.current = false } } - /** Al avanzar de paso: guarda el borrador en silencio y sube el avance a Monday */ + /** Al avanzar de paso: guarda el borrador en silencio y sube el avance al servidor */ const guardarAvance = (datosNuevos: typeof datos, pasoDestino: number) => { guardarBorrador({ ...(borradorId ? { id: borradorId } : {}), paso: pasoDestino, datos: datosNuevos }) .then((b) => { @@ -519,7 +519,7 @@ export default function AltaClientePage() {

- Desde un lead de Monday + Desde un lead ya capturado

Empieza un alta nueva con el nombre, email y celular del lead ya capturados. @@ -646,7 +646,7 @@ export default function AltaClientePage() { )} {datos.apartadoMondayId && (

- Alta ligada al apartado del cliente en Monday — al completarla se actualiza ese item (no se duplica). + Alta ligada al apartado del cliente — al completarla se actualiza ese apartado (no se duplica).

)} diff --git a/apps/alta-clientes/src/services/api.ts b/apps/alta-clientes/src/services/api.ts index bc9369f..7984224 100644 --- a/apps/alta-clientes/src/services/api.ts +++ b/apps/alta-clientes/src/services/api.ts @@ -72,7 +72,7 @@ async function enviarAlta(ruta: string, payload: AltaCliente): Promise { return enviarAlta('/clientes', payload) @@ -85,7 +85,7 @@ export interface ProgresoIniciado { mondayContactoId: string | null } -/** POST /clientes/progreso/iniciar — crea el alta en progreso (Contacto PROSPECTO en Monday) */ +/** POST /clientes/progreso/iniciar — crea el alta en progreso (Contacto PROSPECTO) */ export async function iniciarProgreso(payload: { datosGenerales: unknown leadMondayId?: string @@ -216,7 +216,7 @@ export async function obtenerCotizacion(id: string): Promise) */ +/** GET /altas/prefill-apartado/:itemId — todo lo recolectado de un apartado (?apartado=) */ export async function obtenerPrefillApartado(itemId: string): Promise { const res = await fetch(`${API_URL}/altas/prefill-apartado/${itemId}`, conAuth()) manejar401(res) @@ -224,7 +224,7 @@ export async function obtenerPrefillApartado(itemId: string): Promise { - console.warn('[monday] enviarAMonday está obsoleto: el backend ya replica en Monday', { - nombre: payload.datosGenerales.nombreCompleto, - proyecto: payload.viviendaCredito.proyecto, - ubicacion: payload.viviendaCredito.ubicacion, - }) -} diff --git a/apps/alta-clientes/src/services/sistema.ts b/apps/alta-clientes/src/services/sistema.ts deleted file mode 100644 index f2c2225..0000000 --- a/apps/alta-clientes/src/services/sistema.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { conAuth, getToken } from './auth' - -const API_URL = import.meta.env.VITE_API_URL || '/api' - -export interface EstadoSistema { - mondayOk: boolean - pendientesSync: number -} - -export async function fetchEstadoSistema(): Promise { - if (!getToken()) return null - try { - const res = await fetch(`${API_URL}/sistema/estado`, conAuth()) - if (!res.ok) return null - return (await res.json()) as EstadoSistema - } catch { - return null - } -} diff --git a/apps/alta-clientes/src/types/index.ts b/apps/alta-clientes/src/types/index.ts index ab22e02..06f03e8 100644 --- a/apps/alta-clientes/src/types/index.ts +++ b/apps/alta-clientes/src/types/index.ts @@ -147,10 +147,10 @@ export interface DatosFirma { } export interface AltaCliente { - // ID del item del tablero Leads de Monday cuando el alta se prellenó desde + // ID del lead cuando el alta se prellenó desde // un lead (null = captura manual). Passthrough hasta payloadJson del API. leadMondayId: string | null - // ID del item del tablero Apartado de Monday cuando el alta arrancó desde un + // ID del apartado cuando el alta arrancó desde un // apartado existente (botón "Alta Enkontrol" de Mis apartados): el // API lo LIGA y enriquece en vez de crear un apartado duplicado. apartadoMondayId: string | null diff --git a/apps/cobranza/src/types/index.ts b/apps/cobranza/src/types/index.ts index 73062e4..82f23da 100644 --- a/apps/cobranza/src/types/index.ts +++ b/apps/cobranza/src/types/index.ts @@ -3,7 +3,7 @@ export type StatusPago = 'PENDIENTE' | 'PAGADO' | 'VENCIDO' | 'PARCIAL' export interface PlanPago { id: string // uuid - ventaRef: string // referencia de la venta (en el futuro: mondayItemId) + ventaRef: string // referencia de la venta clienteNombre: string clienteTelefono?: string // teléfono de contacto (opcional) unidadCodigo: string // p. ej. 'U-101' diff --git a/apps/cotizador/src/components/BannerModoLocal.tsx b/apps/cotizador/src/components/BannerModoLocal.tsx deleted file mode 100644 index fefd817..0000000 --- a/apps/cotizador/src/components/BannerModoLocal.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useEffect, useState } from 'react' -import { fetchEstadoSistema } from '../services/sistema' - -const INTERVALO_POLL_MS = 60_000 - -export function useModoLocal(): boolean { - const [modoLocal, setModoLocal] = useState(false) - useEffect(() => { - let activo = true - const consultar = () => { - void fetchEstadoSistema().then((estado) => { - if (activo) setModoLocal(estado !== null && !estado.mondayOk) - }) - } - consultar() - const id = setInterval(consultar, INTERVALO_POLL_MS) - return () => { activo = false; clearInterval(id) } - }, []) - return modoLocal -} - -export default function BannerModoLocal() { - const modoLocal = useModoLocal() - if (!modoLocal) return null - return ( -
-

- Modo local — Monday no disponible. Tus capturas se guardan y se sincronizarán automáticamente. -

-
- ) -} diff --git a/apps/cotizador/src/components/BuscadorCliente.tsx b/apps/cotizador/src/components/BuscadorCliente.tsx index 2a27c88..a9a9a26 100644 --- a/apps/cotizador/src/components/BuscadorCliente.tsx +++ b/apps/cotizador/src/components/BuscadorCliente.tsx @@ -8,7 +8,7 @@ interface Props { } /** - * Buscador con debounce de leads/oportunidades de Monday (GET /leads/buscar): + * Buscador con debounce de leads/oportunidades (GET /leads/buscar): * jala nombre, teléfono y email del cliente al cotizador; si el resultado es * una oportunidad, la cotización queda vinculada a ella al guardar. */ @@ -35,7 +35,7 @@ export function BuscadorCliente({ onSeleccion }: Props) { }) .catch(() => { setResultados(null) - setError('No se pudo buscar en Monday. Intenta de nuevo.') + setError('No se pudo buscar. Intenta de nuevo.') }) .finally(() => setBuscando(false)) }, DEBOUNCE_MS) @@ -45,7 +45,7 @@ export function BuscadorCliente({ onSeleccion }: Props) { return (
- Cotizando para {nombreCliente} — al guardar, la cotización queda vinculada a su oportunidad en Monday. + Cotizando para {nombreCliente} — al guardar, la cotización queda ligada a su oportunidad.

)} diff --git a/apps/cotizador/src/services/api.ts b/apps/cotizador/src/services/api.ts index d737f8a..46039d5 100644 --- a/apps/cotizador/src/services/api.ts +++ b/apps/cotizador/src/services/api.ts @@ -96,7 +96,7 @@ export function urlPublicaCotizacion(id: string): string { return `${API_URL}/cotizaciones/${id}/publico` } -/** POST /cotizaciones — persiste y registra la oportunidad en Monday */ +/** POST /cotizaciones — persiste y liga la oportunidad del cliente */ export async function guardarCotizacion(payload: GuardarCotizacionPayload): Promise { const resp = await fetch(`${API_URL}/cotizaciones`, conAuth({ method: 'POST', @@ -116,7 +116,7 @@ export async function getMisCotizaciones(): Promise { return (await resp.json()) as CotizacionGuardada[] } -// ---------- Búsqueda de clientes en Monday (prellenado del cotizador) ---------- +// ---------- Búsqueda de clientes para el prellenado del cotizador ---------- export interface LeadResumen { id: string diff --git a/apps/cotizador/src/services/sistema.ts b/apps/cotizador/src/services/sistema.ts deleted file mode 100644 index f2c2225..0000000 --- a/apps/cotizador/src/services/sistema.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { conAuth, getToken } from './auth' - -const API_URL = import.meta.env.VITE_API_URL || '/api' - -export interface EstadoSistema { - mondayOk: boolean - pendientesSync: number -} - -export async function fetchEstadoSistema(): Promise { - if (!getToken()) return null - try { - const res = await fetch(`${API_URL}/sistema/estado`, conAuth()) - if (!res.ok) return null - return (await res.json()) as EstadoSistema - } catch { - return null - } -} diff --git a/apps/dashboards/src/components/BannerModoLocal.tsx b/apps/dashboards/src/components/BannerModoLocal.tsx deleted file mode 100644 index 466067c..0000000 --- a/apps/dashboards/src/components/BannerModoLocal.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { useEffect, useState } from 'react' -import { fetchEstadoSistema, type EstadoSistema } from '../services/sistema' - -const INTERVALO_POLL_MS = 60_000 - -export function useEstadoSistema(): EstadoSistema | null | undefined { - const [estado, setEstado] = useState(undefined) - useEffect(() => { - let activo = true - const consultar = () => { - void fetchEstadoSistema().then((e) => { if (activo) setEstado(e) }) - } - consultar() - const id = setInterval(consultar, INTERVALO_POLL_MS) - return () => { activo = false; clearInterval(id) } - }, []) - return estado -} - -export function useModoLocal(): boolean { - const estado = useEstadoSistema() - return estado !== undefined && estado !== null && !estado.mondayOk -} - -export default function BannerModoLocal({ parcial }: { parcial?: boolean }) { - const modoLocal = useModoLocal() - if (!modoLocal && !parcial) return null - return ( -
-

- {modoLocal - ? 'Modo local — Monday no disponible. Tus capturas se guardan y se sincronizarán automáticamente.' - : 'Datos parciales (modo local)'} -

-
- ) -} diff --git a/apps/dashboards/src/components/SeccionEquipo.tsx b/apps/dashboards/src/components/SeccionEquipo.tsx index 49a951d..88292ef 100644 --- a/apps/dashboards/src/components/SeccionEquipo.tsx +++ b/apps/dashboards/src/components/SeccionEquipo.tsx @@ -57,8 +57,8 @@ export default function SeccionEquipo() { {equipo.porVendedor.map((v) => ( - - {v.nombre ?? `Monday #${v.mondayUserId}`} + + {v.nombre ?? 'Sin nombre'} {v.leadsAsignados} {v.leadsNuevosSemana} diff --git a/apps/dashboards/src/lib/mi-tablero.ts b/apps/dashboards/src/lib/mi-tablero.ts index adddb32..5e8d212 100644 --- a/apps/dashboards/src/lib/mi-tablero.ts +++ b/apps/dashboards/src/lib/mi-tablero.ts @@ -29,7 +29,7 @@ export interface ApartadoPorVencer { } export interface VendedorEquipo { - mondayUserId: number + usuarioId: string nombre: string | null leadsAsignados: number leadsNuevosSemana: number @@ -37,7 +37,6 @@ export interface VendedorEquipo { export interface ResumenMiDia { generadoEn: string - sinMapeoMonday: boolean misLeads: LeadMiDia[] misActividadesHoy: ActividadMiDia[] apartadosPorVencer: ApartadoPorVencer[] diff --git a/apps/dashboards/src/services/sistema.ts b/apps/dashboards/src/services/sistema.ts deleted file mode 100644 index f2c2225..0000000 --- a/apps/dashboards/src/services/sistema.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { conAuth, getToken } from './auth' - -const API_URL = import.meta.env.VITE_API_URL || '/api' - -export interface EstadoSistema { - mondayOk: boolean - pendientesSync: number -} - -export async function fetchEstadoSistema(): Promise { - if (!getToken()) return null - try { - const res = await fetch(`${API_URL}/sistema/estado`, conAuth()) - if (!res.ok) return null - return (await res.json()) as EstadoSistema - } catch { - return null - } -} diff --git a/apps/landing/src/components/BannerModoLocal.tsx b/apps/landing/src/components/BannerModoLocal.tsx deleted file mode 100644 index fefd817..0000000 --- a/apps/landing/src/components/BannerModoLocal.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useEffect, useState } from 'react' -import { fetchEstadoSistema } from '../services/sistema' - -const INTERVALO_POLL_MS = 60_000 - -export function useModoLocal(): boolean { - const [modoLocal, setModoLocal] = useState(false) - useEffect(() => { - let activo = true - const consultar = () => { - void fetchEstadoSistema().then((estado) => { - if (activo) setModoLocal(estado !== null && !estado.mondayOk) - }) - } - consultar() - const id = setInterval(consultar, INTERVALO_POLL_MS) - return () => { activo = false; clearInterval(id) } - }, []) - return modoLocal -} - -export default function BannerModoLocal() { - const modoLocal = useModoLocal() - if (!modoLocal) return null - return ( -
-

- Modo local — Monday no disponible. Tus capturas se guardan y se sincronizarán automáticamente. -

-
- ) -} diff --git a/apps/landing/src/components/ConfiguracionCuenta.test.tsx b/apps/landing/src/components/ConfiguracionCuenta.test.tsx index 062065d..e15e266 100644 --- a/apps/landing/src/components/ConfiguracionCuenta.test.tsx +++ b/apps/landing/src/components/ConfiguracionCuenta.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { ConfiguracionCuenta } from './ConfiguracionCuenta' type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise @@ -20,11 +20,6 @@ function crearStorageStub(): Storage { } as Storage } -const USUARIOS_MONDAY = [ - { id: 13676, nombre: 'Erick Peña', email: 'erick@frasa.mx' }, - { id: 20400, nombre: 'Cristina Barrera', email: 'cristina@frasa.mx' }, -] - const sesionFake = { token: 'jwt-de-prueba', usuario: { @@ -78,64 +73,29 @@ describe('ConfiguracionCuenta con sesión', () => { localStorage.setItem('frasa_auth', JSON.stringify(sesionFake)) }) - it('muestra el perfil, el dropdown de usuarios Monday y guarda el vínculo con PATCH', async () => { - fetchMock.mockImplementation((input, init) => { - const url = String(input) - if (url.endsWith('/auth/me')) return Promise.resolve(respuestaOk(sesionFake.usuario)) - if (url.endsWith('/monday/usuarios')) return Promise.resolve(respuestaOk(USUARIOS_MONDAY)) - if (url.endsWith('/usuarios/me/monday') && init?.method === 'PATCH') { - return Promise.resolve(respuestaOk({ ...sesionFake.usuario, mondayUserId: 13676 })) - } - return Promise.reject(new Error(`fetch inesperado: ${url}`)) - }) - - render() - - // Perfil y dropdown cargados - expect(await screen.findByText('ERICK ISRAEL PEÑA RAMOS')).toBeTruthy() - expect(screen.getByText('Rol: Vendedor')).toBeTruthy() - const dropdown = await screen.findByLabelText('Tu usuario de Monday') - expect(screen.getByText('sin vincular')).toBeTruthy() - - fireEvent.change(dropdown, { target: { value: '13676' } }) - fireEvent.click(screen.getByRole('button', { name: 'Guardar' })) - - await waitFor(() => { - const llamadaPatch = fetchMock.mock.calls.find( - ([url, init]) => String(url).endsWith('/usuarios/me/monday') && init?.method === 'PATCH', - ) - expect(llamadaPatch).toBeTruthy() - expect(JSON.parse(String(llamadaPatch?.[1]?.body))).toEqual({ mondayUserId: 13676 }) - }) - expect((await screen.findByRole('status')).textContent).toContain('Vínculo con Monday guardado') - // El PATCH fue con Bearer - const [, initPatch] = fetchMock.mock.calls.find( - ([url, init]) => String(url).endsWith('/usuarios/me/monday') && init?.method === 'PATCH', - ) as [string, RequestInit] - expect((initPatch.headers as Record)['Authorization']).toBe('Bearer jwt-de-prueba') - }) - - it('si falla /monday/usuarios muestra error reintentable y el reintento carga la lista', async () => { - let intentos = 0 + it('muestra el perfil (nombre y rol) y el botón de cerrar sesión', async () => { fetchMock.mockImplementation((input) => { const url = String(input) if (url.endsWith('/auth/me')) return Promise.resolve(respuestaOk(sesionFake.usuario)) - if (url.endsWith('/monday/usuarios')) { - intentos += 1 - return intentos === 1 - ? Promise.resolve(new Response('{}', { status: 500 })) - : Promise.resolve(respuestaOk(USUARIOS_MONDAY)) - } return Promise.reject(new Error(`fetch inesperado: ${url}`)) }) render() - expect( - await screen.findByText('No se pudo cargar la lista de usuarios de Monday.'), - ).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Reintentar' })) - expect(await screen.findByLabelText('Tu usuario de Monday')).toBeTruthy() - expect(intentos).toBe(2) + expect(await screen.findByText('ERICK ISRAEL PEÑA RAMOS')).toBeTruthy() + expect(screen.getByText('Rol: Vendedor')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Cerrar sesión' })).toBeTruthy() + }) + + it('si falla la carga del perfil la sección sigue usable (logout disponible)', async () => { + fetchMock.mockImplementation((input) => { + const url = String(input) + if (url.endsWith('/auth/me')) return Promise.resolve(new Response('{}', { status: 500 })) + return Promise.reject(new Error(`fetch inesperado: ${url}`)) + }) + + render() + + expect(await screen.findByRole('button', { name: 'Cerrar sesión' })).toBeTruthy() }) }) diff --git a/apps/landing/src/components/ConfiguracionCuenta.tsx b/apps/landing/src/components/ConfiguracionCuenta.tsx index 94bb9b6..f151c6d 100644 --- a/apps/landing/src/components/ConfiguracionCuenta.tsx +++ b/apps/landing/src/components/ConfiguracionCuenta.tsx @@ -15,8 +15,8 @@ function formatearRol(rol: string): string { } /** - * Sección "Configuración de cuenta": login inline y, con sesión, el vínculo - * con el usuario de Monday (alimenta round-robin, mis-leads y alertas). + * Sección "Configuración de cuenta": login inline y, con sesión, el perfil del + * usuario. */ export function ConfiguracionCuenta() { const [conSesion, setConSesion] = useState(() => getSesion() !== null) diff --git a/apps/landing/src/components/VistaContactos.tsx b/apps/landing/src/components/VistaContactos.tsx index d1c9618..00d0a83 100644 --- a/apps/landing/src/components/VistaContactos.tsx +++ b/apps/landing/src/components/VistaContactos.tsx @@ -10,7 +10,7 @@ interface Props { } /** - * Vista interna "Contactos": lista del tablero Contactos de Monday (lo más + * Vista interna "Contactos": lista de contactos locales (lo más * reciente primero). Vive dentro de la landing (no hay router); se abre desde * la tarjeta Contactos del grid. Click en un contacto → modal con toda su * información, propiedad, quién lo atiende e historial de atención. diff --git a/apps/landing/src/components/config/ConfigPage.test.tsx b/apps/landing/src/components/config/ConfigPage.test.tsx index 6672b62..a337049 100644 --- a/apps/landing/src/components/config/ConfigPage.test.tsx +++ b/apps/landing/src/components/config/ConfigPage.test.tsx @@ -31,7 +31,7 @@ beforeEach(() => { // Mockear las llamadas iniciales que los tabs pueden hacer fetchMock.mockImplementation((input) => { const url = String(input) - if (url.endsWith('/sistema/estado')) return Promise.resolve(respuestaOk({ mondayOk: true, pendientesSync: 0 })) + if (url.endsWith('/sistema/estado')) return Promise.resolve(respuestaOk({ mondayOk: false, pendientesSync: 0 })) if (url.endsWith('/config/sistema')) return Promise.resolve(respuestaOk({ modo_local_forzado: 'false' })) if (url.endsWith('/config/roles')) return Promise.resolve(respuestaOk([])) if (url.endsWith('/config/usuarios')) return Promise.resolve(respuestaOk([])) diff --git a/apps/landing/src/services/api.ts b/apps/landing/src/services/api.ts index b68123c..fa3c07b 100644 --- a/apps/landing/src/services/api.ts +++ b/apps/landing/src/services/api.ts @@ -1,12 +1,6 @@ import { API_URL, conAuth, manejar401, type UsuarioSesion } from './auth' -export interface UsuarioMonday { - id: number - nombre: string - email: string -} - -/** GET /auth/me — perfil del usuario autenticado (incluye mondayUserId y modulos) */ +/** GET /auth/me — perfil del usuario autenticado (incluye modulos y acciones) */ export async function getMe(): Promise { const resp = await fetch(`${API_URL}/auth/me`, conAuth()) manejar401(resp) @@ -23,7 +17,7 @@ export interface Contacto { updatedAt: string } -/** GET /contactos — contactos del tablero Contactos de Monday (máx 100) */ +/** GET /contactos — contactos locales (máx 100) */ export async function getContactos(): Promise { const resp = await fetch(`${API_URL}/contactos`, conAuth()) manejar401(resp) @@ -31,36 +25,13 @@ export async function getContactos(): Promise { return (await resp.json()) as Contacto[] } -/** GET /monday/usuarios — usuarios activos del workspace para el dropdown */ -export async function getUsuariosMonday(): Promise { - const resp = await fetch(`${API_URL}/monday/usuarios`, conAuth()) - manejar401(resp) - if (!resp.ok) throw new Error(`HTTP ${resp.status}`) - return (await resp.json()) as UsuarioMonday[] -} - -/** PATCH /usuarios/me/monday — vincula (number) o desvincula (null) la cuenta Monday */ -export async function guardarVinculoMonday(mondayUserId: number | null): Promise { - const resp = await fetch( - `${API_URL}/usuarios/me/monday`, - conAuth({ - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mondayUserId }), - }), - ) - manejar401(resp) - if (!resp.ok) throw new Error(`HTTP ${resp.status}`) - return (await resp.json()) as UsuarioSesion -} - // ---------- Detalle de contacto (modal del módulo Contactos) ---------- export interface RelacionCrm { id: string nombre: string estado: string - /** Quién lo atiende ahora (columna people del tablero); null si sin asignar */ + /** Quién lo atiende ahora; null si sin asignar */ atendidoPor: string | null detalle: string } @@ -88,7 +59,7 @@ export interface DatosContacto { } export interface DetalleContacto { - /** Campos nombrados del modelo local (antes: títulos de columna de Monday) */ + /** Campos nombrados del modelo local */ contacto: DatosContacto propiedad: { id: string diff --git a/apps/landing/src/services/sistema.ts b/apps/landing/src/services/sistema.ts deleted file mode 100644 index 7340616..0000000 --- a/apps/landing/src/services/sistema.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { API_URL, conAuth, getToken } from './auth' - -export interface EstadoSistema { - mondayOk: boolean - pendientesSync: number -} - -export async function fetchEstadoSistema(): Promise { - if (!getToken()) return null - try { - const res = await fetch(`${API_URL}/sistema/estado`, conAuth()) - if (!res.ok) return null - return (await res.json()) as EstadoSistema - } catch { - return null - } -} diff --git a/apps/mi-dia/src/components/BannerModoLocal.test.tsx b/apps/mi-dia/src/components/BannerModoLocal.test.tsx deleted file mode 100644 index fd6a018..0000000 --- a/apps/mi-dia/src/components/BannerModoLocal.test.tsx +++ /dev/null @@ -1,85 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { cleanup, render, screen, waitFor } from '@testing-library/react' -import BannerModoLocal from './BannerModoLocal' - -type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise - -/** Stub sencillo de localStorage (jsdom no lo expone como global con vitest 2) */ -function crearStorageStub(): Storage { - const mapa = new Map() - return { - getItem: (k: string) => mapa.get(k) ?? null, - setItem: (k: string, v: string) => void mapa.set(k, v), - removeItem: (k: string) => void mapa.delete(k), - clear: () => mapa.clear(), - key: () => null, - get length() { - return mapa.size - }, - } as Storage -} - -const sesionFake = { - token: 'jwt-de-prueba', - usuario: { nombre: 'ERICK PEÑA', rol: 'vendedor', asignacion: null }, -} - -const respuestaOk = (data: unknown) => - new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }) - -const TEXTO_BANNER = /Modo local — Monday no disponible/ - -let fetchMock: ReturnType> - -beforeEach(() => { - fetchMock = vi.fn() - vi.stubGlobal('fetch', fetchMock) - vi.stubGlobal('localStorage', crearStorageStub()) -}) - -afterEach(() => { - cleanup() - vi.unstubAllGlobals() -}) - -describe('BannerModoLocal', () => { - it('se muestra cuando /sistema/estado reporta Monday caído', async () => { - localStorage.setItem('frasa_auth', JSON.stringify(sesionFake)) - fetchMock.mockResolvedValue(respuestaOk({ mondayOk: false, pendientesSync: 3 })) - - render() - - expect(await screen.findByText(TEXTO_BANNER)).toBeTruthy() - expect(fetchMock).toHaveBeenCalledOnce() - expect(String(fetchMock.mock.calls[0]?.[0])).toContain('/sistema/estado') - }) - - it('no se muestra cuando Monday está bien', async () => { - localStorage.setItem('frasa_auth', JSON.stringify(sesionFake)) - fetchMock.mockResolvedValue(respuestaOk({ mondayOk: true, pendientesSync: 0 })) - - render() - - await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()) - expect(screen.queryByText(TEXTO_BANNER)).toBeNull() - }) - - it('no se muestra si la consulta falla (el banner nunca rompe la app)', async () => { - localStorage.setItem('frasa_auth', JSON.stringify(sesionFake)) - fetchMock.mockRejectedValue(new TypeError('fetch failed')) - - render() - - await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()) - expect(screen.queryByText(TEXTO_BANNER)).toBeNull() - }) - - it('sin sesión no consulta el API ni se muestra', async () => { - render() - - await new Promise((r) => setTimeout(r, 20)) - expect(fetchMock).not.toHaveBeenCalled() - expect(screen.queryByText(TEXTO_BANNER)).toBeNull() - }) -}) diff --git a/apps/mi-dia/src/components/BannerModoLocal.tsx b/apps/mi-dia/src/components/BannerModoLocal.tsx deleted file mode 100644 index b90e471..0000000 --- a/apps/mi-dia/src/components/BannerModoLocal.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { useEffect, useState } from 'react' -import { fetchEstadoSistema } from '../services/sistema' - -const INTERVALO_POLL_MS = 60_000 - -/** true mientras Monday esté caído (modo local); poll al montar y cada 60 s */ -export function useModoLocal(): boolean { - const [modoLocal, setModoLocal] = useState(false) - - useEffect(() => { - let activo = true - const consultar = () => { - void fetchEstadoSistema().then((estado) => { - if (activo) setModoLocal(estado !== null && !estado.mondayOk) - }) - } - consultar() - const id = setInterval(consultar, INTERVALO_POLL_MS) - return () => { - activo = false - clearInterval(id) - } - }, []) - - return modoLocal -} - -/** - * Aviso discreto de "modo local": solo aparece cuando /sistema/estado reporta - * Monday caído. No bloquea; todo sigue funcionando contra el espejo local. - */ -export default function BannerModoLocal() { - const modoLocal = useModoLocal() - if (!modoLocal) return null - return ( -
-

- Modo local — Monday no disponible. Tus capturas se guardan y se sincronizarán automáticamente. -

-
- ) -} diff --git a/apps/mi-dia/src/components/ChipPendienteSync.tsx b/apps/mi-dia/src/components/ChipPendienteSync.tsx deleted file mode 100644 index ffb338a..0000000 --- a/apps/mi-dia/src/components/ChipPendienteSync.tsx +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Badge "⏳ por sincronizar": el item se creó en modo local y aún no sube a - * Monday (el API lo marca con pendienteSync: true). Las acciones funcionan - * igual: el backend rutea los ids locales (loc_*). - */ -export default function ChipPendienteSync() { - return ( - - ⏳ por sincronizar - - ) -} diff --git a/apps/mi-dia/src/components/FiltrosAvanzados.tsx b/apps/mi-dia/src/components/FiltrosAvanzados.tsx index 4438f0d..3bb92f8 100644 --- a/apps/mi-dia/src/components/FiltrosAvanzados.tsx +++ b/apps/mi-dia/src/components/FiltrosAvanzados.tsx @@ -151,7 +151,7 @@ export function aplicarFiltros( if (accessor.origen(item) !== filtros.origen) return false } - // Estado (comparación case-insensitive: Monday devuelve labels con mayúsculas variables) + // Estado (comparación case-insensitive: los labels pueden variar en mayúsculas) if (filtros.estado !== '' && accessor.estado) { if (accessor.estado(item).trim().toLowerCase() !== filtros.estado.trim().toLowerCase()) return false } diff --git a/apps/mi-dia/src/lib/mi-dia.test.ts b/apps/mi-dia/src/lib/mi-dia.test.ts index 969b229..7cd464b 100644 --- a/apps/mi-dia/src/lib/mi-dia.test.ts +++ b/apps/mi-dia/src/lib/mi-dia.test.ts @@ -14,7 +14,7 @@ import { } from './mi-dia' describe('catálogos', () => { - it('orígenes = vocabulario FRASA sembrado en Monday (Leads y Oportunidades)', () => { + it('orígenes = vocabulario FRASA (captura ligera y afluencia)', () => { expect([...ORIGENES_LEAD]).toEqual(['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'BROCKER', 'OTRO']) }) diff --git a/apps/mi-dia/src/lib/mi-dia.ts b/apps/mi-dia/src/lib/mi-dia.ts index 656cc53..ddc9df1 100644 --- a/apps/mi-dia/src/lib/mi-dia.ts +++ b/apps/mi-dia/src/lib/mi-dia.ts @@ -9,12 +9,10 @@ export interface LeadMiDia { origen: string desarrollo?: string updatedAt: string - /** Tablero del item: 'lead' (tablero Leads) u 'oportunidad' (Oportunidades) */ + /** Tablero del item: 'lead' (captura ligera) u 'oportunidad' (afluencia) */ fuente: 'lead' | 'oportunidad' - /** Último evento del activity log del board; null si el item no tiene eventos */ + /** Último cambio conocido; null si no hay */ ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null - /** true cuando el item se creó en modo local y aún no se sincroniza a Monday */ - pendienteSync?: boolean } export interface ActividadMiDia { @@ -38,11 +36,9 @@ export interface CapturaMiDia { leadId: string nombre: string telefono: string - estado: string // estado actual en Monday; '' si el lead ya no existe en el board + estado: string origen: string capturadoEn: string // ISO - /** true cuando la captura se creó en modo local y aún no se sincroniza a Monday */ - pendienteSync?: boolean } export interface ClienteMiDia { @@ -52,7 +48,7 @@ export interface ClienteMiDia { unidadCodigo: string completado: boolean capturadoEn: string // ISO - /** Estado actual del item en el tablero Apartado (Monday); null si no hay apartado ligado */ + /** Estado actual del apartado ligado; null si no hay apartado ligado */ estadoApartado: string | null } @@ -70,8 +66,6 @@ export interface OportunidadMiDia { ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null /** Fecha y hora de la cita agendada (ISO local, ej. '2026-09-09T14:30') */ fechaCita?: string - /** true cuando el item se creó en modo local y aún no se sincroniza a Monday */ - pendienteSync?: boolean } /** Apartado asignado al vendedor (tab "Mis apartados") */ @@ -82,13 +76,10 @@ export interface ApartadoMiDia { estado: string fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene) ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null - /** true cuando el item se creó en modo local y aún no se sincroniza a Monday */ - pendienteSync?: boolean } export interface ResumenMiDia { generadoEn: string - sinMapeoMonday: boolean misLeads: LeadMiDia[] misCapturas: CapturaMiDia[] misClientes: ClienteMiDia[] @@ -104,7 +95,7 @@ export interface ResumenMiDia { equipo?: { leadsNuevosSinAsignar: number porVendedor: { - mondayUserId: number + usuarioId: string nombre: string | null leadsAsignados: number leadsNuevosSemana: number @@ -143,10 +134,9 @@ export const VOCABULARIO = { Cliente: ['Cambo de Planes', 'Decision Familiar', 'No estaba Listo', 'Emergencia Personal'], Timing: ['Compra Posterior', 'Esperando Credito', 'Esperando Venta de Propiedad', 'Esperando Proyecto'], }, - // Labels de la columna 'Origen' (board Leads) / 'Origen del Lead' - // (Oportunidades), verificados y sembrados en Monday el 2026-09-04 + // Labels de origen (captura ligera y afluencia), verificados el 2026-09-04 ORIGENES: ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'BROCKER', 'OTRO'], - // Labels reales de la columna status 'Desarrollo' del board Leads (verificados en Monday) + // Labels oficiales de desarrollo DESARROLLOS: ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'], } as const @@ -255,8 +245,7 @@ export type SlotDocApartado = (typeof SLOTS_DOCS_APARTADO)[number]['tipo'] /** * URL del cotizador (/quote/) con los datos del cliente prellenados y el id de - * la oportunidad: al guardar, la cotización queda vinculada a ese cliente en - * Monday (el API actualiza ese item en vez de crear uno nuevo). + * la oportunidad: al guardar, la cotización queda ligada a ese cliente. */ export function urlCotizadorParaOportunidad(op: OportunidadMiDia): string { const params = new URLSearchParams() diff --git a/apps/mi-dia/src/pages/MiDiaPage.tsx b/apps/mi-dia/src/pages/MiDiaPage.tsx index 54f4dfd..654d030 100644 --- a/apps/mi-dia/src/pages/MiDiaPage.tsx +++ b/apps/mi-dia/src/pages/MiDiaPage.tsx @@ -901,7 +901,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones /** * Botón "Cotizar": pasa la oportunidad a etapa Cotizador y abre el cotizador * con los datos del cliente prellenados y la oportunidad vinculada (al - * guardar, la cotización actualiza ESE item en Monday). Si el cambio de etapa + * guardar, la cotización queda ligada a ese cliente). Si el cambio de etapa * falla, el cotizador se abre igual: el guardado reintenta la actualización. */ async function cotizarOportunidad(op: OportunidadMiDia) { @@ -910,7 +910,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones try { await cambiarEtapaOportunidad(op.id, 'Cotizador') } catch { - toast.error('No se pudo actualizar la etapa en Monday; el cotizador se abrirá de todos modos.') + toast.error('No se pudo actualizar la etapa; el cotizador se abrirá de todos modos.') } window.open(urlCotizadorParaOportunidad(op), '_blank') } @@ -1065,12 +1065,6 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones {aviso}

)} - {resumen?.sinMapeoMonday && ( -

- Tu usuario aún no está vinculado a Monday; las acciones siguen funcionando -

- )} - {!resumen && cargando && } {resumen && ( @@ -1392,7 +1386,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones

- {/* Estado del apartado en Monday (bidireccional); solo si hay apartado ligado */} + {/* Estado del apartado; solo si hay apartado ligado */} {c.estadoApartado !== null && ( {c.estadoApartado} @@ -1616,10 +1610,10 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
    {resumen.equipo.porVendedor.map((v) => (
  • -

    {v.nombre ?? `Monday #${v.mondayUserId}`}

    +

    {v.nombre}

    {v.leadsAsignados} lead(s) diff --git a/apps/mi-dia/src/services/sistema.ts b/apps/mi-dia/src/services/sistema.ts deleted file mode 100644 index 0773c69..0000000 --- a/apps/mi-dia/src/services/sistema.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Estado del sistema (GET /sistema/estado): salud de Monday y cola de sincronización -import { conAuth, getToken } from './auth' - -const API_URL = import.meta.env.VITE_API_URL || '/api' - -export interface EstadoSistema { - mondayOk: boolean - pendientesSync: number -} - -/** - * GET /sistema/estado. Devuelve null si no hay sesión o si la consulta falla: - * el banner de modo local es informativo y nunca debe romper la app (ni - * provocar logout/reload por un 401 del poll). - */ -export async function fetchEstadoSistema(): Promise { - if (!getToken()) return null - try { - const res = await fetch(`${API_URL}/sistema/estado`, conAuth()) - if (!res.ok) return null - return (await res.json()) as EstadoSistema - } catch { - return null - } -}