diff --git a/api-gateway/scripts/migrar-afluencias-a-oportunidades.py b/api-gateway/scripts/migrar-afluencias-a-oportunidades.py new file mode 100644 index 0000000..9d4b036 --- /dev/null +++ b/api-gateway/scripts/migrar-afluencias-a-oportunidades.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +"""Migra las afluencias históricas del tablero Leads al tablero Oportunidades. + +Las afluencias ahora viven en Oportunidades (etapa Afluencia y afines) con todo +el perfil; el tablero Leads queda como archivo (se vacía tras migrar). + +Uso: + python3 migrar-afluencias-a-oportunidades.py # dry-run + python3 migrar-afluencias-a-oportunidades.py --apply # migra y BORRA del board Leads + python3 migrar-afluencias-a-oportunidades.py --apply --no-delete # migra sin borrar + +- Estado: CONTACTADO→Contactado, AGENDA CITA→Agendo Cita, CALIFICADO→Calificado, + DESCARTADO→Perdido, Abandono→Abandono, NUEVO/vacío→Afluencia. +- Grupo destino: Perdidos si Perdido/Abandono, Ganados si Ganado, si no topics. +- Vendedor (people) → Asesor (people), si lo tiene. +- Crea la fila LeadOportunidad {leadId: opId, oportunidadId: opId} para que el + alta de clientes pueda mover la oportunidad migrada a 'Aparto'. +""" +import json +import re +import sqlite3 +import sys +import time +import urllib.request +from pathlib import Path + +ENV = Path(__file__).resolve().parents[2] / '.env' +DB = Path(__file__).resolve().parents[1] / 'prisma' / 'dev.db' +BOARD_LEADS = '18427678493' +BOARD_OPS = '18427678497' +GRUPO_PERDIDOS = 'group_mm6s4cgq' +GRUPO_GANADOS = 'group_mm6scqz3' +GRUPO_TOPICS = 'topics' + +TOKEN = re.search(r'^MONDAY_API_KEY="?([^"\n]+)"?', ENV.read_text(), re.M).group(1) + +def gql(query, variables=None): + req = urllib.request.Request( + 'https://api.monday.com/v2', + data=json.dumps({'query': query, 'variables': variables or {}}).encode(), + headers={'Authorization': TOKEN, 'Content-Type': 'application/json'}, + ) + with urllib.request.urlopen(req) as r: + res = json.loads(r.read()) + if 'errors' in res: + raise RuntimeError(res['errors']) + return res['data'] + +ESTADO_MAP = { + 'CONTACTADO': 'Contactado', + 'AGENDA CITA': 'Agendo Cita', + 'CALIFICADO': 'Calificado', + 'DESCARTADO': 'Perdido', + 'ABANDONO': 'Abandono', + 'NUEVO': 'Afluencia', + '': 'Afluencia', +} + +def todos_los_items(board): + items, cursor = [], None + while True: + if cursor is None: + q = 'query { boards(ids: [%s]) { items_page(limit: 200) { cursor items { id name created_at column_values { id text value } } } } }' % board + else: + q = 'query { boards(ids: [%s]) { items_page(limit: 200, cursor: "%s") { cursor items { id name created_at column_values { id text value } } } } }' % (board, cursor) + page = gql(q)['boards'][0]['items_page'] + items.extend(page['items']) + cursor = page['cursor'] + if not cursor: + return items + +def main(): + apply = '--apply' in sys.argv + borrar = apply and '--no-delete' not in sys.argv + + cols_leads = {c['title']: c for c in gql('query { boards(ids: [%s]) { columns { id title type } } }' % BOARD_LEADS)['boards'][0]['columns']} + cols_ops = {c['title']: c for c in gql('query { boards(ids: [%s]) { columns { id title type } } }' % BOARD_OPS)['boards'][0]['columns']} + + # Mapeo de columnas Leads → Oportunidades (por título) + MAPA = [ + ('Teléfono', 'Cliente teléfono'), + ('Email', 'Cliente email'), + ('Desarrollo', 'Desarrollo'), + ('Modelo de Interés', 'Modelo de Interés'), + ('Origen', 'Origen del Lead'), + ('Medio de Contacto', 'Medio de Contacto'), + ('Edad', 'Edad'), + ('Estado Civil', 'Estado Civil'), + ('Tipo de Empleo', 'Tipo de Empleo'), + ('Ocupación', 'Ocupación'), + ('Rango Salarial', 'Rango Salarial'), + ('Crédito', 'Crédito'), + ('Tipo de Economía', 'Tipo de Econimia'), + ('Califica', 'Lead Califica'), + ('Lo Quiere Para', 'Proposito de Compra'), + ('Notas', 'COMENTARIOS'), + ('Fecha', 'Fecha'), + ] + + items = todos_los_items(BOARD_LEADS) + # existentes en Oportunidades (por teléfono) para no duplicar si se corre 2 veces + existentes = todos_los_items(BOARD_OPS) + col_tel_ops = cols_ops['Cliente teléfono']['id'] + tels_ops = set() + for it in existentes: + for cv in it['column_values']: + if cv['id'] == col_tel_ops and cv['text']: + d = re.sub(r'\D', '', cv['text']) + if len(d) >= 10: + tels_ops.add(d[-10:]) + + print(f'Leads a migrar: {len(items)} · oportunidades existentes: {len(existentes)}') + migrados, saltados, errores = 0, 0, 0 + detalles = [] + + db = sqlite3.connect(DB) if apply else None + + for it in items: + cvs = {cv['id']: cv for cv in it['column_values']} + # teléfono para dedup + tel_txt = (cvs.get(cols_leads['Teléfono']['id']) or {}).get('text') or '' + dig = re.sub(r'\D', '', tel_txt) + if dig and dig[-10:] in tels_ops: + saltados += 1 + detalles.append(f" skip (ya existe): {it['name'][:45]}") + continue + + estado_leads = (cvs.get(cols_leads['Estado']['id']) or {}) + estado_txt = (estado_leads.get('text') or '') if estado_leads.get('value') else '' + estado_destino = ESTADO_MAP.get(estado_txt.strip().upper(), 'Afluencia') + grupo = GRUPO_PERDIDOS if estado_destino in ('Perdido', 'Abandono') else (GRUPO_GANADOS if estado_destino == 'Ganado' else GRUPO_TOPICS) + + valores = {} + for titulo_leads, titulo_ops in MAPA: + cl, co = cols_leads.get(titulo_leads), cols_ops.get(titulo_ops) + if not cl or not co: + continue + cv = cvs.get(cl['id']) + if not cv or not cv.get('text'): + continue + txt = cv['text'] + if co['type'] == 'status': + valores[co['id']] = {'label': txt} + elif co['type'] == 'dropdown': + valores[co['id']] = {'labels': [txt]} + elif co['type'] == 'numbers': + try: + valores[co['id']] = float(txt) + except ValueError: + pass + elif co['type'] == 'date': + if re.match(r'^\d{4}-\d{2}-\d{2}$', txt): + valores[co['id']] = {'date': txt} + elif co['type'] == 'phone': + valores[co['id']] = {'phone': txt, 'countryShortName': 'MX'} + elif co['type'] == 'email': + if re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', txt): + valores[co['id']] = {'email': txt, 'text': txt} + elif co['type'] == 'long_text': + valores[co['id']] = {'text': txt[:1900]} + else: + valores[co['id']] = txt + valores[cols_ops['Estado']['id']] = {'label': estado_destino} + # Asesor si el lead tenía vendedor + raw_vend = (cvs.get(cols_leads['Vendedor']['id']) or {}).get('value') + if raw_vend: + try: + persons = [p for p in json.loads(raw_vend).get('personsAndTeams', []) if p.get('kind') == 'person'] + if persons: + valores[cols_ops['Asesor']['id']] = {'personsAndTeams': persons} + except Exception: + pass + + if not apply: + migrados += 1 + continue + try: + res = gql( + 'mutation ($b: ID!, $g: String!, $n: String!, $v: JSON!) { create_item(board_id: $b, group_id: $g, item_name: $n, column_values: $v) { id } }', + {'b': BOARD_OPS, 'g': grupo, 'n': it['name'], 'v': json.dumps(valores)}, + ) + op_id = res['create_item']['id'] + db.execute( + 'INSERT OR IGNORE INTO "LeadOportunidad" (id, leadId, oportunidadId, createdAt) VALUES (lower(hex(randomblob(16))), ?, ?, datetime("now"))', + (op_id, op_id), + ) + migrados += 1 + print(f"OK {it['name'][:45]} → op {op_id} [{estado_destino}]") + if borrar: + gql('mutation { delete_item(item_id: %s) { id } }' % it['id']) + time.sleep(0.45) + except Exception as e: + errores += 1 + print(f"ERROR {it['name'][:45]}: {e}") + time.sleep(0.5) + + if db: + db.commit() + db.close() + + for d in detalles: + print(d) + print(f"\n{'MIGRADOS' if apply else 'DRY-RUN'}: {migrados} · saltados (dup): {saltados} · errores: {errores} · borrados de Leads: {migrados if borrar else 0}") + +if __name__ == '__main__': + main() diff --git a/api-gateway/src/controllers/clientes.controller.ts b/api-gateway/src/controllers/clientes.controller.ts index bbe4026..e93d2b5 100644 --- a/api-gateway/src/controllers/clientes.controller.ts +++ b/api-gateway/src/controllers/clientes.controller.ts @@ -160,8 +160,11 @@ const hoyMas = (dias: number): string => { // ---------- Helpers compartidos (alta directa y alta progresiva) ---------- /** - * Si el alta viene de un lead del funnel (la app alta-clientes manda + * 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. diff --git a/api-gateway/src/controllers/leads.controller.test.ts b/api-gateway/src/controllers/leads.controller.test.ts index 3c80ecf..736417d 100644 --- a/api-gateway/src/controllers/leads.controller.test.ts +++ b/api-gateway/src/controllers/leads.controller.test.ts @@ -9,8 +9,7 @@ import { descartarLead, ESTADOS_LEAD_PERMITIDOS, } from './leads.controller.js' -import { mondayService, type LeadMonday } from '../services/monday.service.js' -import { funnelService } from '../services/funnel.service.js' +import { mondayService, type LeadMonday, type OportunidadMonday } from '../services/monday.service.js' import { automatizacionesService } from '../services/automatizaciones.service.js' // ---------- Prisma mockeado (los tests no tocan la BD) ---------- @@ -19,6 +18,9 @@ const prismaMock = vi.hoisted(() => ({ leadCaptura: { create: vi.fn(), }, + leadOportunidad: { + create: vi.fn(), + }, usuario: { findUnique: vi.fn(), }, @@ -30,37 +32,38 @@ 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'] + /** Monday mockeado: registra escrituras y devuelve catálogos controlados */ function mondayMock( opts: { labelsOrigen?: string[] labelsDesarrollo?: string[] labelsMotivo?: string[] - duplicado?: LeadMonday | null + duplicado?: OportunidadMonday | null } = {}, ) { 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 buscarLeadPorTelefono(_telefono: string) { + async buscarOportunidadPorTelefono(_telefono: string) { return opts.duplicado ?? null }, async labelsDeColumna(_boardId: string, titulo: string) { - if (titulo === 'Desarrollo') { - return opts.labelsDesarrollo ?? ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'] - } - if (titulo === 'Motivo No Compra') { - return opts.labelsMotivo ?? ['Precio', 'Producto', 'Ubicacion', 'Financiamiento', 'Competencia', 'Cliente', 'Timing'] - } - return opts.labelsOrigen ?? ['WhatsApp', 'Web', 'Recomendado', 'Facebook', 'Otro'] + if (titulo === 'Desarrollo') return opts.labelsDesarrollo ?? LABELS_DESARROLLO + if (titulo === 'Motivo No Compra') return opts.labelsMotivo ?? LABELS_MOTIVO + return opts.labelsOrigen ?? LABELS_ORIGEN }, async obtenerNombreItem(_itemId: string) { return 'Lead Prueba' @@ -69,46 +72,70 @@ function mondayMock( this.motivos.push(datos) return 'motivo-item-1' }, - } -} - -/** Funnel mockeado: registra asegurados/movimientos; configurable para fallar */ -function funnelMock(opts: { oportunidadId?: string | null; fallaAsegurar?: boolean } = {}) { - return { - asegurados: [] as Record[], - movimientos: [] as { leadId: string; etapa: string }[], - async asegurarOportunidadAfluencia(lead: Record) { - if (opts.fallaAsegurar === true) throw new Error('Monday caído') - this.asegurados.push(lead) - return 'op-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 moverOportunidadDeLead(leadId: string, etapa: string) { - this.movimientos.push({ leadId, etapa }) - return true + async getItemConGrupo(itemId: string) { + return { + id: itemId, + name: 'Lead Prueba', + groupId: 'group_top', + column_values: [{ id: 'col_estado', text: 'Afluencia', value: '{"index":1}' }], + } }, - async oportunidadDeLead(_leadId: string) { - return opts.oportunidadId === undefined ? 'op-1' : opts.oportunidadId + async moverItemAGrupo(itemId: string, groupId: string) { + this.movimientosGrupo.push({ itemId, groupId }) }, } } const datosBase = { nombre: 'Lead Prueba', telefono: '4771234567' } -describe('crearLead (POST /api/leads)', () => { - it('crea el item en Monday con Estado NUEVO y devuelve su id', async () => { +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, +}) + +describe('crearLead (POST /api/leads) — 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 crearLead(monday, { ...datosBase, origen: 'Facebook', notas: 'Vio el volante' }) - expect(r).toEqual({ id: 'lead-nuevo-1', oportunidadId: null }) - expect(monday.leadsCreados).toEqual([ + expect(r).toEqual({ id: 'op-nueva-1', oportunidadId: 'op-nueva-1' }) + expect(monday.oportunidadesCreadas).toEqual([ { nombre: 'Lead Prueba', telefono: '4771234567', email: undefined, - origenLabel: 'Facebook', desarrolloLabel: undefined, + origenLabel: 'Facebook', + 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', }, ]) + // 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' }]) }) it('pasa email y desarrollo al item de Monday cuando vienen en la captura', async () => { @@ -118,8 +145,8 @@ describe('crearLead (POST /api/leads)', () => { 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' }) + expect(r).toEqual({ id: 'op-nueva-1', oportunidadId: 'op-nueva-1' }) + expect(monday.oportunidadesCreadas[0]).toMatchObject({ email: 'lead@correo.com', desarrolloLabel: 'SENDA' }) }) it('pasa los campos de calificación al item de Monday (labels listos para el board)', async () => { @@ -138,8 +165,8 @@ describe('crearLead (POST /api/leads)', () => { califica: 'Sí', loQuierePara: 'Vivir ahí', }) - expect(r).toEqual({ id: 'lead-nuevo-1', oportunidadId: null }) - expect(monday.leadsCreados[0]).toMatchObject({ + expect(r).toEqual({ id: 'op-nueva-1', oportunidadId: 'op-nueva-1' }) + expect(monday.oportunidadesCreadas[0]).toMatchObject({ modeloLabel: 'HIMALAYA', medioContacto: 'WhatsApp', edad: 35, @@ -154,189 +181,136 @@ describe('crearLead (POST /api/leads)', () => { }) }) - it('con funnel: asegura la oportunidad de Afluencia con los datos del lead', async () => { - const monday = mondayMock() - const funnel = funnelMock() - const r = await crearLead(monday, { ...datosBase, email: 'lead@correo.com', desarrollo: 'SENDA' }, funnel) - expect(r).toEqual({ id: 'lead-nuevo-1', oportunidadId: 'op-1' }) - expect(funnel.asegurados).toEqual([ - { id: 'lead-nuevo-1', nombre: 'Lead Prueba', telefono: '4771234567', email: 'lead@correo.com', desarrolloLabel: 'SENDA' }, - ]) - }) - - it('si falla la oportunidad de Afluencia, el lead igual se crea (oportunidadId null)', async () => { - const monday = mondayMock() - const funnel = funnelMock({ fallaAsegurar: true }) - const r = await crearLead(monday, datosBase, funnel) - expect(r).toEqual({ id: 'lead-nuevo-1', oportunidadId: null }) - expect(monday.leadsCreados).toHaveLength(1) - }) - - it('duplicado por teléfono: no crea lead ni oportunidad de Afluencia', async () => { - const existente: LeadMonday = { - id: 'lead-viejo-9', - nombre: 'Lead Prueba', - createdAt: '', - updatedAt: '', - estado: 'NUEVO', - origen: '', - telefono: '4771234567', - email: '', - vendedorIds: [], - } + 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({ duplicado: existente }) - const funnel = funnelMock() - const r = await crearLead(monday, datosBase, funnel) + const r = await crearLead(monday, datosBase) expect(r).toEqual({ duplicado: existente }) - expect(monday.leadsCreados).toEqual([]) - expect(funnel.asegurados).toEqual([]) + expect(monday.oportunidadesCreadas).toEqual([]) + expect(monday.movimientosGrupo).toEqual([]) }) - it('rechaza un desarrollo que no sea label real de la columna Desarrollo', async () => { + 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 crearLead(monday, datosBase) + expect(r).toEqual({ id: 'op-nueva-1', oportunidadId: 'op-nueva-1' }) + espia.mockRestore() + }) + + it('rechaza un desarrollo que no sea label real de la columna Desarrollo (board Oportunidades)', async () => { const monday = mondayMock() await expect(crearLead(monday, { ...datosBase, desarrollo: 'MONTEBELLO' })).rejects.toThrow( /Desarrollo inválido/, ) - expect(monday.leadsCreados).toEqual([]) + expect(monday.oportunidadesCreadas).toEqual([]) }) - it('rechaza un origen que no sea label real de la columna Origen', async () => { + it('rechaza un origen que no sea label real del dropdown Origen del Lead', async () => { const monday = mondayMock() await expect(crearLead(monday, { ...datosBase, origen: 'Volanteo' })).rejects.toThrow(/Origen inválido/) - expect(monday.leadsCreados).toEqual([]) + expect(monday.oportunidadesCreadas).toEqual([]) }) }) describe('cambiarEstadoLead (POST /api/leads/:itemId/estado)', () => { it('la whitelist solo permite las transiciones del vendedor', () => { expect(ESTADOS_LEAD_PERMITIDOS).toEqual(['CONTACTADO', 'Intento de contacto', 'AGENDA CITA', 'CALIFICADO']) - // DESCARTADO tiene su propio endpoint (lleva motivo); NUEVO no es acción + // Perdido tiene su propio endpoint (lleva motivo) expect(ESTADOS_LEAD_PERMITIDOS).not.toContain('DESCARTADO') expect(ESTADOS_LEAD_PERMITIDOS).not.toContain('NUEVO') }) - it('cambia solo la columna Estado en el board Leads (444 en tests)', async () => { + it('escribe la columna Estado en Oportunidades (555 en tests) con el label real del board', async () => { const monday = mondayMock() - await cambiarEstadoLead(monday, 'lead-1', 'CONTACTADO') - expect(monday.cambios).toEqual([{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'CONTACTADO' }]) + await cambiarEstadoLead(monday, 'op-1', 'CONTACTADO') + expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Contactado' }]) }) - it('AGENDA CITA con fecha también setea la columna Fecha', async () => { + it('AGENDA CITA se traduce al label "Agendo Cita" y con fecha también setea la columna Fecha', async () => { const monday = mondayMock() - await cambiarEstadoLead(monday, 'lead-1', 'AGENDA CITA', '2026-09-01') + await cambiarEstadoLead(monday, 'op-1', 'AGENDA CITA', '2026-09-01') 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' }, + { boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Agendo Cita' }, + { boardId: '555', itemId: 'op-1', columna: 'Fecha', valor: '2026-09-01' }, ]) }) it('AGENDA CITA sin fecha no toca la columna Fecha', async () => { const monday = mondayMock() - await cambiarEstadoLead(monday, 'lead-1', 'AGENDA CITA') + await cambiarEstadoLead(monday, 'op-1', 'AGENDA CITA') expect(monday.cambios).toHaveLength(1) }) - it('AGENDA CITA mueve la oportunidad del lead a "Agendo Cita"', async () => { + it('CALIFICADO se escribe como "Calificado" (label real del board)', async () => { const monday = mondayMock() - const funnel = funnelMock() - await cambiarEstadoLead(monday, 'lead-1', 'AGENDA CITA', '2026-09-01', funnel) - expect(funnel.movimientos).toEqual([{ leadId: 'lead-1', etapa: 'Agendo Cita' }]) - }) - - it('otros estados NO mueven la oportunidad del lead', async () => { - const monday = mondayMock() - const funnel = funnelMock() - await cambiarEstadoLead(monday, 'lead-1', 'CONTACTADO', undefined, funnel) - await cambiarEstadoLead(monday, 'lead-1', 'CALIFICADO', undefined, funnel) - expect(funnel.movimientos).toEqual([]) - }) - - it('un fallo del funnel en AGENDA CITA no rompe el cambio de estado', async () => { - const monday = mondayMock() - const funnel = funnelMock() - vi.spyOn(funnel, 'moverOportunidadDeLead').mockRejectedValueOnce(new Error('Monday caído')) - await expect(cambiarEstadoLead(monday, 'lead-1', 'AGENDA CITA', undefined, funnel)).resolves.toBeUndefined() - expect(monday.cambios).toEqual([{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'AGENDA CITA' }]) + await cambiarEstadoLead(monday, 'op-1', 'CALIFICADO') + expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Calificado' }]) }) }) describe('descartarLead (POST /api/leads/:itemId/descartar)', () => { - it('Estado DESCARTADO + Notas + registro en Motivos No Compra', async () => { + it('Estado Perdido + Motivo No Compra + COMENTARIOS + registro en Motivos No Compra', async () => { const monday = mondayMock() - const motivoItemId = await descartarLead(monday, 'lead-1', 'Precio', 'Se le hizo caro') + const motivoItemId = await descartarLead(monday, 'op-1', 'Precio', 'Se le hizo caro') 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' }, + { 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 Notas', async () => { + it('sin comentario no escribe COMENTARIOS', async () => { const monday = mondayMock() - await descartarLead(monday, 'lead-1', 'Timing') - expect(monday.cambios).toEqual([{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'DESCARTADO' }]) + 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('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, 'lead-1', 'Cliente')).rejects.toThrow('Monday caído') + await expect(descartarLead(monday, 'op-1', 'Cliente')).rejects.toThrow('Monday caído') expect(monday.motivos).toEqual([]) }) - it('con funnel: la oportunidad pasa a "Perdido" y hereda el motivo en el dropdown', async () => { - const monday = mondayMock() - const funnel = funnelMock({ oportunidadId: 'op-9' }) - await descartarLead(monday, 'lead-1', 'Precio', undefined, funnel) - expect(monday.cambios).toContainEqual({ boardId: '555', itemId: 'op-9', columna: 'Estado', valor: 'Perdido' }) - expect(monday.cambios).toContainEqual({ - boardId: '555', - itemId: 'op-9', - columna: 'Motivo No Compra', - valor: 'Precio', - }) - }) - it('si el motivo no es label del dropdown, solo mueve a Perdido (sin dropdown)', async () => { const monday = mondayMock({ labelsMotivo: ['Otro'] }) - const funnel = funnelMock({ oportunidadId: 'op-9' }) - await descartarLead(monday, 'lead-1', 'Precio', undefined, funnel) - expect(monday.cambios).toContainEqual({ boardId: '555', itemId: 'op-9', columna: 'Estado', valor: 'Perdido' }) + 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() - }) - - it('sin mapping lead ↔ oportunidad no toca el tablero Oportunidades', async () => { - const monday = mondayMock() - const funnel = funnelMock({ oportunidadId: null }) - const motivoItemId = await descartarLead(monday, 'lead-1', 'Timing', undefined, funnel) - expect(motivoItemId).toBe('motivo-item-1') - expect(monday.cambios.filter((c) => c.boardId === '555')).toEqual([]) - }) - - it('un fallo del funnel no rompe el descarte (DESCARTADO + motivo ya quedaron)', async () => { - const monday = mondayMock() - const funnel = funnelMock({ oportunidadId: 'op-9' }) - vi.spyOn(funnel, 'oportunidadDeLead').mockRejectedValueOnce(new Error('Monday caído')) - const motivoItemId = await descartarLead(monday, 'lead-1', 'Precio', undefined, funnel) - expect(motivoItemId).toBe('motivo-item-1') - expect(monday.cambios).toContainEqual({ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'DESCARTADO' }) + expect(monday.motivos).toHaveLength(1) // el registro en el catálogo se crea igual }) }) describe('crearLeadEndpoint (POST /api/leads)', () => { beforeEach(() => { vi.clearAllMocks() - // El singleton Monday responde como si creara el lead; el funnel liga la oportunidad - vi.spyOn(mondayService, 'buscarLeadPorTelefono').mockResolvedValue(null) - vi.spyOn(mondayService, 'crearLead').mockResolvedValue('lead-nuevo-9') - vi.spyOn(funnelService, 'asegurarOportunidadAfluencia').mockResolvedValue('op-9') + // 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({}) }) @@ -355,27 +329,30 @@ describe('crearLeadEndpoint (POST /api/leads)', () => { return { res: { json, status } as unknown as Response, status, json } } - it('201: registra la captura local con el usuario del token', async () => { - prismaMock.leadCaptura.create.mockResolvedValue({}) + it('201: registra la captura local y el auto-mapping 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: 'op-9', + 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: 'lead-nuevo-9', estado: 'NUEVO', oportunidadId: 'op-9', asignacion: 'round-robin' }) + expect(json).toHaveBeenCalledWith({ id: 'op-nueva-9', estado: 'Afluencia', oportunidadId: 'op-nueva-9', asignacion: 'round-robin' }) }) - it('con Monday vinculado, el lead queda asignado al capturista (no al round-robin)', async () => { - prismaMock.leadCaptura.create.mockResolvedValue({}) + it('con Monday vinculado, la afluencia queda asignada al capturista (columna Asesor)', async () => { prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 }) const { res, status, json } = resFake() @@ -383,15 +360,14 @@ describe('crearLeadEndpoint (POST /api/leads)', () => { // 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.objectContaining({ where: { clave: 'rr:skip:op-nueva-9' }, create: { clave: 'rr:skip:op-nueva-9', valor: 'capturista' } }), ) - expect(automatizacionesService.asignarACapturista).toHaveBeenCalledWith('lead-nuevo-9', 'Lead Prueba', 12345) + expect(automatizacionesService.asignarACapturista).toHaveBeenCalledWith('op-nueva-9', 'Lead Prueba', 12345) expect(status).toHaveBeenCalledWith(201) - expect(json).toHaveBeenCalledWith({ id: 'lead-nuevo-9', estado: 'NUEVO', oportunidadId: 'op-9', asignacion: 'capturista' }) + 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.leadCaptura.create.mockResolvedValue({}) prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 }) vi.spyOn(automatizacionesService, 'asignarACapturista').mockRejectedValue(new Error('Monday caído')) const espia = vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -400,7 +376,7 @@ describe('crearLeadEndpoint (POST /api/leads)', () => { await crearLeadEndpoint(reqFake(), res) expect(status).toHaveBeenCalledWith(201) - expect(json).toHaveBeenCalledWith({ id: 'lead-nuevo-9', estado: 'NUEVO', oportunidadId: 'op-9', asignacion: 'round-robin' }) + expect(json).toHaveBeenCalledWith({ id: 'op-nueva-9', estado: 'Afluencia', oportunidadId: 'op-nueva-9', asignacion: 'round-robin' }) espia.mockRestore() }) @@ -410,10 +386,12 @@ describe('crearLeadEndpoint (POST /api/leads)', () => { await crearLeadEndpoint(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 (el lead ya quedó en Monday)', async () => { + 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() @@ -425,7 +403,7 @@ describe('crearLeadEndpoint (POST /api/leads)', () => { }) }) -describe('buscarLeads (GET /api/leads/buscar)', () => { +describe('buscarLeads (GET /api/leads/buscar) — búsqueda dual Leads + Oportunidades', () => { const lead = (overrides: Partial): LeadMonday => ({ id: 'lead-1', nombre: 'Lead Prueba', @@ -439,31 +417,47 @@ describe('buscarLeads (GET /api/leads/buscar)', () => { ...overrides, }) - it('matchea por nombre, teléfono y email (case-insensitive)', () => { + it('matchea por nombre, teléfono y email (case-insensitive) en ambos tableros, con su fuente', () => { const leads = [ lead({ id: 'a', nombre: 'MARÍA FERNANDA LÓPEZ' }), lead({ id: 'b', telefono: '477 123 4567' }), - lead({ id: 'c', email: 'Cliente@Correo.com' }), lead({ id: 'd', nombre: 'Otro', telefono: '333', email: '' }), ] - expect(buscarLeads(leads, 'fernanda').map((l) => l.id)).toEqual(['a']) - expect(buscarLeads(leads, '123').map((l) => l.id)).toEqual(['b']) - expect(buscarLeads(leads, 'cliente@').map((l) => l.id)).toEqual(['c']) + 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 lead como en la búsqueda', () => { + it('ignora acentos tanto en el item como en la búsqueda', () => { const leads = [lead({ 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) + expect(buscarLeads(leads, [], 'maria')).toHaveLength(1) + expect(buscarLeads(leads, [], 'andrés')).toHaveLength(1) }) - it('ordena por updatedAt descendente y limita a 15', () => { - const leads = Array.from({ length: 20 }, (_, i) => + it('ordena por updatedAt descendente (mezclando tableros) y limita a 15', () => { + const leads = Array.from({ length: 10 }, (_, i) => lead({ id: `l-${i}`, nombre: `Cliente Común ${i}`, updatedAt: `2026-08-${String(i + 1).padStart(2, '0')}T00:00:00Z` }), ) - const r = buscarLeads(leads, 'común') + 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('l-19') // el más reciente primero + 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 () => { diff --git a/api-gateway/src/controllers/leads.controller.ts b/api-gateway/src/controllers/leads.controller.ts index b0848d1..cc6bdec 100644 --- a/api-gateway/src/controllers/leads.controller.ts +++ b/api-gateway/src/controllers/leads.controller.ts @@ -1,22 +1,35 @@ import type { Request, Response } from 'express' import { z } from 'zod' import { config } from '../config/index.js' -import { mondayService, type LeadMonday, type MondayService } from '../services/monday.service.js' -import { funnelService, type FunnelService } from '../services/funnel.service.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 { prisma } from '../services/prisma.js' import { MOTIVOS_NO_COMPRA } from './oportunidades.controller.js' import { AppError } from '../utils/errors.js' /** - * CRM ligero sobre el tablero Leads: captura rápida y acciones del vendedor. + * Captura y acciones del CRM ligero sobre el tablero Oportunidades: la + * afluencia ES la oportunidad en etapa 'Afluencia' (UN solo registro con todo + * el perfil). El tablero Leads queda como archivo histórico: solo se consulta + * en la búsqueda dual del picker del alta de clientes. * Los labels de estado/origen son los reales del board (verificados contra - * Monday); la whitelist evita escribir labels que el status no tenga dados de alta. + * Monday); la whitelist evita escribir labels que el status no tenga dados de + * alta, y cada valor de la whitelist se traduce al label real del board. */ // Transiciones que el vendedor puede hacer desde la app Mi día export const ESTADOS_LEAD_PERMITIDOS = ['CONTACTADO', 'Intento de contacto', 'AGENDA CITA', 'CALIFICADO'] as const +// Traducción de la whitelist (valores que mandan los frontends) a los labels +// reales de la columna status 'Estado' del board Oportunidades +const LABEL_ESTADO_OPORTUNIDAD: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], string> = { + CONTACTADO: 'Contactado', + 'Intento de contacto': 'Intento de contacto', + 'AGENDA CITA': 'Agendo Cita', + CALIFICADO: 'Calificado', +} + const crearLeadSchema = z.object({ nombre: z.string().trim().min(1, 'El nombre es obligatorio'), telefono: z.string().trim().min(1).optional(), @@ -52,7 +65,7 @@ const descartarSchema = z.object({ comentario: z.string().trim().min(1).optional(), }) -// ---------- Búsqueda de leads (prellenado del alta de clientes) ---------- +// ---------- Búsqueda dual (prellenado del alta de clientes) ---------- const LIMITE_BUSQUEDA_LEADS = 15 @@ -63,70 +76,109 @@ const sinAcentos = (s: string): string => .replace(/[̀-ͯ]/g, '') // diacríticos U+0300–U+036F .toLowerCase() +/** Resultado del picker del alta: conviven el archivo histórico (Leads) y el modelo nuevo (Oportunidades) */ +export interface ResultadoBusquedaLead { + id: string + nombre: string + telefono: string + email: string + estado: string + origen: string + fuente: 'lead' | 'oportunidad' +} + /** - * Filtra leads por q en nombre/teléfono/email (case-insensitive, sin acentos), - * ordena por actualización reciente y limita a 15 resultados. + * Filtra por q en nombre/teléfono/email (case-insensitive, sin acentos) en + * AMBOS tableros — Leads (histórico) y Oportunidades (modelo nuevo) —, ordena + * por actualización reciente y limita a 15 resultados combinados. */ -export function buscarLeads(leads: LeadMonday[], q: string): LeadMonday[] { +export function buscarLeads(leads: LeadMonday[], oportunidades: OportunidadMonday[], q: string): ResultadoBusquedaLead[] { const needle = sinAcentos(q) - return leads - .filter((l) => [l.nombre, l.telefono, l.email].some((campo) => sinAcentos(campo).includes(needle))) + const candidatos = [ + ...leads.map((l) => ({ + id: l.id, + nombre: l.nombre, + telefono: l.telefono, + email: l.email, + estado: l.estado, + origen: l.origen, + 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, + 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' | 'crearMotivoNoCompra' - | 'buscarLeadPorTelefono' + | 'buscarOportunidadPorTelefono' | 'labelsDeColumna' | 'obtenerNombreItem' + | 'columnaIdPorTitulo' + | 'getItemConGrupo' + | 'moverItemAGrupo' > -export type ResultadoCrearLead = { id: string; oportunidadId: string | null } | { duplicado: LeadMonday } +export type ResultadoCrearLead = { id: string; oportunidadId: string } | { duplicado: OportunidadMonday } /** - * Crea un lead en Monday (Estado NUEVO). Si viene teléfono y ya existe un lead - * con ese número, no crea nada y devuelve { duplicado } (el endpoint → 409). - * El origen se valida contra los labels reales de la columna del board. - * Con `funnel`, el lead entra además al tablero Oportunidades en 'Afluencia'; - * un fallo de Monday en la oportunidad solo se loguea (el lead ya quedó). + * 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 crearLead( monday: MondayLeads, datos: z.infer, - funnel?: Pick, ): Promise { - const boardLeads = config.monday.boards.leads + const boardOportunidades = config.monday.boards.oportunidades if (datos.telefono !== undefined) { - const existente = await monday.buscarLeadPorTelefono(datos.telefono) + const existente = await monday.buscarOportunidadPorTelefono(datos.telefono) if (existente) return { duplicado: existente } } if (datos.origen !== undefined) { - const labels = await monday.labelsDeColumna(boardLeads, 'Origen') + const labels = await monday.labelsDeColumna(boardOportunidades, 'Origen del Lead') if (!labels.includes(datos.origen)) { throw new AppError(400, `Origen inválido: "${datos.origen}". Labels válidos: ${labels.join(', ')}`) } } if (datos.desarrollo !== undefined) { - const labels = await monday.labelsDeColumna(boardLeads, 'Desarrollo') + const labels = await monday.labelsDeColumna(boardOportunidades, '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({ + const id = await monday.crearOportunidadAfluencia({ nombre: datos.nombre, telefono: datos.telefono, email: datos.email, - origenLabel: datos.origen, desarrolloLabel: datos.desarrollo, - notas: datos.notas, + origenLabel: datos.origen, modeloLabel: datos.modelo, medioContacto: datos.medioContacto, edad: datos.edad, @@ -138,98 +190,67 @@ export async function crearLead( tipoEconomiaLabel: datos.tipoEconomia, calificaLabel: datos.califica, loQuierePara: datos.loQuierePara, + notas: datos.notas, }) - let oportunidadId: string | null = null - if (funnel !== undefined) { - try { - oportunidadId = await funnel.asegurarOportunidadAfluencia({ - id, - nombre: datos.nombre, - telefono: datos.telefono, - email: datos.email, - desarrolloLabel: datos.desarrollo, - }) - } catch (err) { - // eslint-disable-next-line no-console - console.error(`No se pudo crear la oportunidad de Afluencia del lead ${id}:`, err) - } + 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) } - return { id, oportunidadId } + return { id, oportunidadId: id } } -/** Cambia el estado del lead (whitelist) y, en AGENDA CITA, puede setear la Fecha */ +/** Cambia el estado de la afluencia (whitelist → label real del board); en AGENDA CITA puede setear la Fecha */ export async function cambiarEstadoLead( monday: MondayLeads, itemId: string, estado: (typeof ESTADOS_LEAD_PERMITIDOS)[number], fecha?: string, - funnel?: Pick, ): Promise { - const boardLeads = config.monday.boards.leads - await monday.cambiarValorColumna(boardLeads, itemId, 'Estado', estado) + const boardOportunidades = config.monday.boards.oportunidades + await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', LABEL_ESTADO_OPORTUNIDAD[estado]) if (estado === 'AGENDA CITA' && fecha !== undefined) { - await monday.cambiarValorColumna(boardLeads, itemId, 'Fecha', fecha) - } - // La oportunidad del lead avanza a 'Agendo Cita'; un fallo no rompe el cambio - if (estado === 'AGENDA CITA' && funnel !== undefined) { - try { - await funnel.moverOportunidadDeLead(itemId, 'Agendo Cita') - } catch (err) { - // eslint-disable-next-line no-console - console.error(`No se pudo mover a 'Agendo Cita' la oportunidad del lead ${itemId}:`, err) - } + await monday.cambiarValorColumna(boardOportunidades, itemId, 'Fecha', fecha) } } -/** Estado DESCARTADO + registro en el catálogo Motivos No Compra (+ Notas opcional) */ +/** + * Estado 'Perdido' + dropdown Motivo No Compra (label ya sembrado; si no + * existiera se omite) + registro en el catálogo Motivos No Compra. El + * comentario opcional va a la columna long_text COMENTARIOS. + */ export async function descartarLead( monday: MondayLeads, itemId: string, motivo: string, comentario?: string, - funnel?: Pick, ): Promise { - const boardLeads = config.monday.boards.leads + const boardOportunidades = config.monday.boards.oportunidades const nombreItem = await monday.obtenerNombreItem(itemId) - await monday.cambiarValorColumna(boardLeads, itemId, 'Estado', 'DESCARTADO') + await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', 'Perdido') if (comentario !== undefined) { - await monday.cambiarValorColumna(boardLeads, itemId, 'Notas', comentario) + await monday.cambiarValorColumna(boardOportunidades, itemId, 'COMENTARIOS', comentario) } - const motivoItemId = await monday.crearMotivoNoCompra({ + 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} — ${nombreItem}`, categoria: motivo, ...(comentario !== undefined ? { descripcion: comentario } : {}), }) - - // La oportunidad del lead pasa a 'Perdido' y hereda el motivo en su dropdown - // (solo si el label existe en la columna). Fallos → solo log. - if (funnel !== undefined) { - try { - const oportunidadId = await funnel.oportunidadDeLead(itemId) - if (oportunidadId !== null) { - const boardOportunidades = config.monday.boards.oportunidades - await monday.cambiarValorColumna(boardOportunidades, oportunidadId, 'Estado', 'Perdido') - const labels = await monday.labelsDeColumna(boardOportunidades, 'Motivo No Compra') - if (labels.includes(motivo)) { - await monday.cambiarValorColumna(boardOportunidades, oportunidadId, '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`) - } - } - } catch (err) { - // eslint-disable-next-line no-console - console.error(`No se pudo mover a 'Perdido' la oportunidad del lead ${itemId}:`, err) - } - } - return motivoItemId } /** POST /api/leads { nombre, telefono?, email?, origen?, notas? } */ export async function crearLeadEndpoint(req: Request, res: Response): Promise { const body = crearLeadSchema.parse(req.body) - const resultado = await crearLead(mondayService, body, funnelService) + const resultado = await crearLead(mondayService, body) if ('duplicado' in resultado) { res.status(409).json({ error: 'Ya existe un lead con ese teléfono', @@ -239,8 +260,9 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise { const body = cambiarEstadoSchema.parse(req.body) const itemId = String(req.params['itemId']) - await cambiarEstadoLead(mondayService, itemId, body.estado, body.fecha, funnelService) + await cambiarEstadoLead(mondayService, itemId, body.estado, body.fecha) res.json({ itemId, estado: body.estado, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) }) } @@ -298,26 +332,18 @@ 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 motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario, funnelService) - res.json({ itemId, estado: 'DESCARTADO', motivo: body.motivo, motivoItemId }) + const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario) + res.json({ itemId, estado: 'Perdido', motivo: body.motivo, motivoItemId }) } /** * GET /api/leads/buscar?q=texto — autocompletado para prellenar el alta de - * clientes desde un lead. q mínimo 2 caracteres; devuelve máximo 15. + * clientes. Busca en AMBOS tableros (Leads histórico + Oportunidades); q + * mínimo 2 caracteres; devuelve máximo 15, cada uno con su `fuente`. */ export async function buscarLeadsEndpoint(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') - const encontrados = buscarLeads(await mondayService.getLeads(), q) - res.json( - encontrados.map((l) => ({ - id: l.id, - nombre: l.nombre, - telefono: l.telefono, - email: l.email, - estado: l.estado, - origen: l.origen, - })), - ) + const [leads, oportunidades] = await Promise.all([mondayService.getLeads(), mondayService.getOportunidades()]) + res.json(buscarLeads(leads, oportunidades, q)) } diff --git a/api-gateway/src/controllers/webhooks.controller.test.ts b/api-gateway/src/controllers/webhooks.controller.test.ts index 9747ab2..65d88e3 100644 --- a/api-gateway/src/controllers/webhooks.controller.test.ts +++ b/api-gateway/src/controllers/webhooks.controller.test.ts @@ -4,7 +4,6 @@ import type { Request, Response } from 'express' const mocks = vi.hoisted(() => ({ runRoundRobin: vi.fn(), escaneoOportunidades: vi.fn(), - asegurarAfluencia: vi.fn(), moverAGrupo: vi.fn(), })) @@ -15,12 +14,6 @@ vi.mock('../services/automatizaciones.service.js', () => ({ }, })) -vi.mock('../services/funnel.service.js', () => ({ - funnelService: { - asegurarOportunidadAfluenciaPorLeadId: mocks.asegurarAfluencia, - }, -})) - vi.mock('../services/grupos-estado.service.js', () => ({ // Leads (111) y Oportunidades (222) con mapeo; el resto de boards, sin mapeo GRUPOS_POR_ESTADO: { @@ -52,7 +45,6 @@ beforeEach(() => { vi.clearAllMocks() mocks.runRoundRobin.mockResolvedValue({ asignados: 1 }) mocks.escaneoOportunidades.mockResolvedValue({ apartadosCreados: ['x'], ventasCreadas: [], errores: [] }) - mocks.asegurarAfluencia.mockResolvedValue('op-1') mocks.moverAGrupo.mockResolvedValue({ movido: true, grupo: 'topics' }) }) @@ -70,31 +62,34 @@ describe('webhookMonday', () => { expect(sendStatus).toHaveBeenCalledWith(200) expect(mocks.runRoundRobin).not.toHaveBeenCalled() expect(mocks.escaneoOportunidades).not.toHaveBeenCalled() - expect(mocks.asegurarAfluencia).not.toHaveBeenCalled() }) - it('create_item en Leads dispara el round-robin del item', () => { + it('create_item en Oportunidades dispara el round-robin del item', () => { const { res, sendStatus } = resFake() - webhookMonday(reqCon({ event: { type: 'create_item', boardId: 111, itemId: 999 } }), res) + webhookMonday(reqCon({ event: { type: 'create_item', boardId: 222, itemId: 999 } }), res) expect(sendStatus).toHaveBeenCalledWith(200) expect(mocks.runRoundRobin).toHaveBeenCalledWith('999') - expect(mocks.asegurarAfluencia).toHaveBeenCalledWith('999') }) 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: 111, pulseId: 777 } }), res) + webhookMonday(reqCon({ event: { type: 'create_pulse', boardId: 222, pulseId: 777 } }), res) expect(sendStatus).toHaveBeenCalledWith(200) expect(mocks.runRoundRobin).toHaveBeenCalledWith('777') - expect(mocks.asegurarAfluencia).toHaveBeenCalledWith('777') }) - it('create_item en otro board no dispara round-robin ni afluencia', () => { + it('create_item en Leads ya NO dispara nada (tablero archivo histórico)', () => { + const { res, sendStatus } = resFake() + webhookMonday(reqCon({ event: { type: 'create_item', boardId: 111, itemId: 999 } }), res) + expect(sendStatus).toHaveBeenCalledWith(200) + expect(mocks.runRoundRobin).not.toHaveBeenCalled() + }) + + 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() - expect(mocks.asegurarAfluencia).not.toHaveBeenCalled() }) it('change_status_column_value en Oportunidades dispara el escaneo', () => { @@ -148,7 +143,7 @@ describe('webhookMonday', () => { 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: 111, itemId: 1 } }), res) + 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 index 3c5d3cc..d537df9 100644 --- a/api-gateway/src/controllers/webhooks.controller.ts +++ b/api-gateway/src/controllers/webhooks.controller.ts @@ -2,7 +2,6 @@ import type { Request, Response } from 'express' import { z } from 'zod' import { config } from '../config/index.js' import { automatizacionesService } from '../services/automatizaciones.service.js' -import { funnelService } from '../services/funnel.service.js' import { GRUPOS_POR_ESTADO, moverAGrupoSegunEstado } from '../services/grupos-estado.service.js' import { mondayService } from '../services/monday.service.js' @@ -46,7 +45,6 @@ export function webhookMonday(req: Request, res: Response): void { } 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 @@ -55,7 +53,15 @@ export function webhookMonday(req: Request, res: Response): void { // Monday registra el webhook como 'create_item' pero el payload real llega // como 'create_pulse'; se aceptan ambos. - if ((type === 'create_item' || type === 'create_pulse') && esBoardLeads && idEvento !== undefined) { + // Item nuevo en Oportunidades → solo round-robin (la afluencia entra en + // etapa 'Afluencia' sin asesor; las del cotizador nacen en 'Cotizador' y el + // servicio las salta). 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. + // (El tablero Leads quedó como archivo histórico: ya no tiene automatización + // de create; su webhook se desregistra en Monday.) + if ((type === 'create_item' || type === 'create_pulse') && esBoardOportunidades && idEvento !== undefined) { const id = String(idEvento) // Fire-and-forget: la respuesta 200 no espera a que termine la asignación void automatizacionesService @@ -68,18 +74,6 @@ export function webhookMonday(req: Request, res: Response): void { // eslint-disable-next-line no-console console.error(`[webhook monday] error en round-robin item=${id}:`, err) }) - // El lead entra también al funnel de Oportunidades (etapa 'Afluencia'). - // El payload no trae nombre/teléfono: el servicio los consulta en Monday. - void funnelService - .asegurarOportunidadAfluenciaPorLeadId(id) - .then((opId) => { - // eslint-disable-next-line no-console - console.log(`[webhook monday] afluencia item=${id}: oportunidad ${opId ?? 'no creada'}`) - }) - .catch((err: unknown) => { - // eslint-disable-next-line no-console - console.error(`[webhook monday] error en afluencia item=${id}:`, err) - }) } // Cambio de etapa en Oportunidades → crear Apartado/Venta si aplica. diff --git a/api-gateway/src/services/automatizaciones.service.test.ts b/api-gateway/src/services/automatizaciones.service.test.ts index 558c224..17e1250 100644 --- a/api-gateway/src/services/automatizaciones.service.test.ts +++ b/api-gateway/src/services/automatizaciones.service.test.ts @@ -8,13 +8,13 @@ import { claveVentaCreada, esEstadoTerminal, esEtapa, - leadsInactivos, - leadsSinContactar, + oportunidadesInactivas, + oportunidadesSinContactar, parseRrUserIds, vendedorEnTurno, type StateStore, } from './automatizaciones.service.js' -import type { LeadMonday, OportunidadMonday, UsuarioMonday } from './monday.service.js' +import type { OportunidadMonday, UsuarioMonday } from './monday.service.js' // ---------- Fixtures ---------- @@ -24,27 +24,19 @@ const VENDEDORES: UsuarioMonday[] = [ { 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: [], - ...overrides, - } -} - function oportunidad(id: string, overrides: Partial = {}): OportunidadMonday { return { id, nombre: `Cliente ${id}`, createdAt: '2026-08-01T09:00:00Z', - etapa: 'Cotizador', + updatedAt: '2026-08-01T10:00:00Z', + etapa: 'Afluencia', unidadCodigo: 'U-101', precioFinal: 2500000, + asesorIds: [], + telefono: '', + email: '', + origen: '', ...overrides, } } @@ -63,8 +55,8 @@ function stateEnMemoria(): StateStore & { mapa: Map } { } } -/** Monday mockeado: leads fijos, registra asignaciones, updates y actividades */ -function mondayMock(leads: LeadMonday[], oportunidades: OportunidadMonday[] = []) { +/** Monday mockeado: oportunidades fijas, registra asignaciones, updates y actividades */ +function mondayMock(oportunidades: OportunidadMonday[] = []) { return { asignaciones: [] as { itemId: string; userId: number }[], updates: [] as { itemId: string; body: string }[], @@ -78,13 +70,10 @@ function mondayMock(leads: LeadMonday[], oportunidades: OportunidadMonday[] = [] }[], apartados: [] as Record[], ventas: [] as Record[], - async getLeads() { - return leads - }, async getOportunidades() { return oportunidades }, - async asignarVendedorALead(itemId: string, userId: number) { + async asignarAsesorAOportunidad(itemId: string, userId: number) { this.asignaciones.push({ itemId, userId }) }, async crearUpdateEnItem(itemId: string, body: string) { @@ -136,12 +125,14 @@ describe('parseRrUserIds', () => { }) }) -describe('esEstadoTerminal', () => { - it('reconoce DESCARTADO sin importar mayúsculas ni espacios', () => { - expect(esEstadoTerminal('DESCARTADO')).toBe(true) - expect(esEstadoTerminal(' descartado ')).toBe(true) - expect(esEstadoTerminal('NUEVO')).toBe(false) - expect(esEstadoTerminal('CONTACTADO')).toBe(false) +describe('esEstadoTerminal (etapas terminales de Oportunidades)', () => { + it('Ganado / Perdido / Abandono son terminales, sin importar mayúsculas ni espacios', () => { + 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) }) }) @@ -160,31 +151,34 @@ describe('vendedorEnTurno (rotación round-robin)', () => { }) }) -describe('leadsInactivos', () => { +describe('oportunidadesInactivas', () => { const ahora = new Date('2026-08-22T00:00:00Z') - it('incluye leads activos que superan el umbral de días', () => { - const leads = [ - lead('1', { updatedAt: '2026-08-10T00:00:00Z' }), // 12 días → inactivo (umbral 10) - lead('2', { updatedAt: '2026-08-20T00:00:00Z' }), // 2 días → activo - lead('3', { updatedAt: '2026-07-01T00:00:00Z', estado: 'DESCARTADO' }), // terminal → excluido + 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 inactivos = leadsInactivos(leads, ahora, 10) - expect(inactivos.map((l) => l.id)).toEqual(['1']) + const inactivas = oportunidadesInactivas(ops, ahora, 10) + expect(inactivas.map((o) => o.id)).toEqual(['1', '6']) }) - it('un lead actualizado hace exactamente N días aún no supera el umbral', () => { - const leads = [lead('1', { updatedAt: '2026-08-12T00:00:00Z' })] // justo 10 días - expect(leadsInactivos(leads, ahora, 10)).toEqual([]) + 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(leadsInactivos([lead('1', { updatedAt: 'no-es-fecha' })], ahora, 10)).toEqual([]) + expect(oportunidadesInactivas([oportunidad('1', { updatedAt: 'no-es-fecha', asesorIds: [101] })], ahora, 10)).toEqual([]) }) }) describe('claveRecordatorio (dedup por día)', () => { - it('una clave por lead 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') @@ -194,8 +188,8 @@ describe('claveRecordatorio (dedup por día)', () => { // ---------- Servicio (monday mockeado, sin red) ---------- describe('AutomatizacionesService.runRoundRobin', () => { - it('asigna vendedores en rotación a leads sin persona y persiste el índice', async () => { - const monday = mondayMock([lead('1'), lead('2'), lead('3'), lead('4')]) + it('asigna asesores en rotación a afluencias sin persona y persiste el índice', async () => { + const monday = mondayMock([oportunidad('1'), oportunidad('2'), oportunidad('3'), oportunidad('4')]) const state = stateEnMemoria() const svc = crearServicio(monday, state) @@ -204,8 +198,8 @@ describe('AutomatizacionesService.runRoundRobin', () => { expect(monday.asignaciones.map((a) => a.userId)).toEqual([101, 102, 103, 101]) expect(state.mapa.get('rr:indice')).toBe('1') // 4 asignaciones % 3 vendedores - // Segunda pasada con un lead nuevo: continúa donde quedó (no reinicia en 0) - const monday2 = mondayMock([lead('5')]) + // Segunda pasada con una afluencia nueva: continúa donde quedó (no reinicia en 0) + const monday2 = mondayMock([oportunidad('5')]) const svc2 = new AutomatizacionesService(monday2, state) const r2 = await svc2.runRoundRobin() expect(r2.asignados).toBe(1) @@ -213,22 +207,28 @@ describe('AutomatizacionesService.runRoundRobin', () => { expect(state.mapa.get('rr:indice')).toBe('2') }) - it('omite leads con vendedor ya asignado o en estado terminal', async () => { + it('solo reparte etapa Afluencia: Cotizador/Contactado NO entran al reparto', async () => { const monday = mondayMock([ - lead('1'), - lead('2', { vendedorIds: [101] }), // ya asignado - lead('3', { estado: 'DESCARTADO' }), // terminal + 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() - expect(r.asignados).toBe(1) - expect(monday.asignaciones).toEqual([{ itemId: '1', userId: 101 }]) + expect(r.asignados).toBe(2) + expect(monday.asignaciones).toEqual([ + { itemId: '1', userId: 101 }, + { itemId: '6', userId: 102 }, + ]) }) - it('omite leads con bandera rr:skip=capturista (los asigna su capturista)', async () => { - const monday = mondayMock([lead('1'), lead('2')]) + it('omite afluencias con bandera rr:skip=capturista (las asigna su capturista)', async () => { + const monday = mondayMock([oportunidad('1'), oportunidad('2')]) const state = stateEnMemoria() - state.mapa.set('rr:skip:1', 'capturista') // el capturista lo asigna + state.mapa.set('rr:skip:1', 'capturista') // el capturista la 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() @@ -237,7 +237,7 @@ describe('AutomatizacionesService.runRoundRobin', () => { }) it('con soloItemId solo evalúa ese item (ruta del webhook create_item)', async () => { - const monday = mondayMock([lead('1'), lead('2')]) + const monday = mondayMock([oportunidad('1'), oportunidad('2')]) const svc = crearServicio(monday, stateEnMemoria()) const r = await svc.runRoundRobin('2') expect(r.asignados).toBe(1) @@ -271,13 +271,13 @@ describe('AutomatizacionesService.runRoundRobin', () => { describe('AutomatizacionesService.escaneoInactividad', () => { const ahora = new Date('2026-08-22T09:00:00Z') - const leadsViejos = () => [ - lead('1', { updatedAt: '2026-08-01T00:00:00Z' }), // 21 días inactivo - lead('2', { updatedAt: '2026-08-20T00:00:00Z' }), // activo + 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(leadsViejos()) + const monday = mondayMock(oportunidadesViejas()) const state = stateEnMemoria() const svc = crearServicio(monday, state) @@ -291,7 +291,7 @@ describe('AutomatizacionesService.escaneoInactividad', () => { }) it('ejecución real: crea update, marca dedup y no repite el mismo día', async () => { - const monday = mondayMock(leadsViejos()) + const monday = mondayMock(oportunidadesViejas()) const state = stateEnMemoria() const svc = crearServicio(monday, state) @@ -301,7 +301,7 @@ describe('AutomatizacionesService.escaneoInactividad', () => { 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 el lead siga inactivo + // 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) @@ -319,15 +319,15 @@ describe('AutomatizacionesService.escaneoInactividad', () => { describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => { const ahora = new Date('2026-08-22T09:00:00Z') - it('el round-robin crea "Contactar a {lead}" tipo Llamada, Abierto, ligada al lead', async () => { - const monday = mondayMock([lead('1'), lead('2')]) + 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 Lead 1', + nombre: 'Contactar a Cliente 1', tipoLabel: 'Llamada', responsableId: 101, estadoLabel: 'Abierto', @@ -343,24 +343,24 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => { }) it('con actividadesEnabled=false el round-robin no crea actividades', async () => { - const monday = mondayMock([lead('1')]) + 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 y le crea su actividad de contacto', async () => { + 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('lead-77', 'Lead Propio', 555) + await svc.asignarACapturista('op-77', 'Lead Propio', 555) - expect(monday.asignaciones).toEqual([{ itemId: 'lead-77', userId: 555 }]) + expect(monday.asignaciones).toEqual([{ itemId: 'op-77', userId: 555 }]) expect(monday.actividades[0]).toMatchObject({ nombre: 'Contactar a Lead Propio', responsableId: 555, estadoLabel: 'Abierto', - leadItemId: 'lead-77', + leadItemId: 'op-77', }) }) @@ -368,24 +368,21 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => { const monday = mondayMock([]) const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: false }) - await svc.asignarACapturista('lead-78', 'Lead Propio', 555) + await svc.asignarACapturista('op-78', 'Lead Propio', 555) - expect(monday.asignaciones).toEqual([{ itemId: 'lead-78', userId: 555 }]) + expect(monday.asignaciones).toEqual([{ itemId: 'op-78', userId: 555 }]) expect(monday.actividades).toEqual([]) }) - it('inactividad: crea "Retomar contacto" al vendedor asignado; sin vendedor no crea nada', async () => { - const monday = mondayMock([ - lead('1', { updatedAt: '2026-08-01T00:00:00Z', vendedorIds: [103] }), // inactivo con vendedor - lead('2', { updatedAt: '2026-08-01T00:00:00Z' }), // inactivo sin vendedor - ]) + 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(2) + expect(r.recordatoriosEnviados).toBe(1) expect(monday.actividades).toHaveLength(1) expect(monday.actividades[0]).toMatchObject({ - nombre: 'Retomar contacto con Lead 1', + nombre: 'Retomar contacto con Cliente 1', tipoLabel: 'Llamada', responsableId: 103, estadoLabel: 'Abierto', @@ -394,14 +391,14 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => { }) it('preview de inactividad no crea actividades', async () => { - const monday = mondayMock([lead('1', { updatedAt: '2026-08-01T00:00:00Z', vendedorIds: [101] })]) + 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([lead('1')]) + 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 }) @@ -413,43 +410,35 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => { }) }) -describe('ESTADOS_TERMINALES_LEAD con vocabulario CRM', () => { - it('ABANDONO también es terminal', () => { - expect(esEstadoTerminal('ABANDONO')).toBe(true) - expect(esEstadoTerminal('Abandono')).toBe(true) - expect(esEstadoTerminal('Intento de contacto')).toBe(false) - }) -}) +// ---------- Escalado de afluencias sin contactar ---------- -// ---------- Ronda 2: escalado de leads sin contactar ---------- - -describe('leadsSinContactar (regla de escalado por horas)', () => { +describe('oportunidadesSinContactar (regla de escalado por horas)', () => { const ahora = new Date('2026-08-22T12:00:00Z') - it('incluye leads asignados en NUEVO / Intento de contacto que superan el umbral', () => { - const leads = [ - lead('1', { vendedorIds: [101], updatedAt: '2026-08-19T12:00:00Z' }), // 72 h → escala - lead('2', { vendedorIds: [102], estado: 'Intento de contacto', updatedAt: '2026-08-20T12:00:00Z' }), // 48 h justo → no - lead('3', { vendedorIds: [103], estado: 'CONTACTADO', updatedAt: '2026-08-01T12:00:00Z' }), // ya contactado - lead('4', { estado: 'NUEVO', updatedAt: '2026-08-01T12:00:00Z' }), // sin vendedor → no aplica - lead('5', { vendedorIds: [101], estado: 'DESCARTADO', updatedAt: '2026-08-01T12:00:00Z' }), // terminal + 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(leadsSinContactar(leads, ahora, 48).map((l) => l.id)).toEqual(['1']) + expect(oportunidadesSinContactar(ops, ahora, 48).map((o) => o.id)).toEqual(['1']) }) - it('un lead con exactamente N horas aún no supera el umbral', () => { - const leads = [lead('1', { vendedorIds: [101], updatedAt: '2026-08-20T12:00:00Z' })] - expect(leadsSinContactar(leads, ahora, 48)).toEqual([]) + 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 leads = [lead('1', { vendedorIds: [101], updatedAt: 'no-es-fecha' })] - expect(leadsSinContactar(leads, ahora, 48)).toEqual([]) + const ops = [oportunidad('1', { asesorIds: [101], updatedAt: 'no-es-fecha' })] + expect(oportunidadesSinContactar(ops, ahora, 48)).toEqual([]) }) }) -describe('claves de dedup de ronda 2', () => { - it('una clave por lead/oportunidad', () => { +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') @@ -469,11 +458,11 @@ describe('AutomatizacionesService.escaneoEscalado', () => { const ahora = new Date('2026-08-22T12:00:00Z') const configAuto = config.automatizaciones as Record - it('escala al vendedor asignado cuando no hay coordinador configurado', async () => { + it('escala al asesor asignado cuando no hay coordinador configurado', async () => { const anterior = configAuto['coordinadorUserId'] configAuto['coordinadorUserId'] = 0 try { - const monday = mondayMock([lead('1', { vendedorIds: [102], updatedAt: '2026-08-19T12:00:00Z' })]) + const monday = mondayMock([oportunidad('1', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' })]) const state = stateEnMemoria() const svc = new AutomatizacionesService(monday, state, { actividadesEnabled: true }) @@ -481,7 +470,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => { 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: lead sin contactar — Lead 1', + nombre: 'URGENTE: oportunidad sin contactar — Cliente 1', responsableId: 102, estadoLabel: 'Abierto', leadItemId: '1', @@ -498,7 +487,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => { const anterior = configAuto['coordinadorUserId'] configAuto['coordinadorUserId'] = 999 try { - const monday = mondayMock([lead('1', { vendedorIds: [102], updatedAt: '2026-08-19T12:00:00Z' })]) + 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) @@ -509,11 +498,11 @@ describe('AutomatizacionesService.escaneoEscalado', () => { } }) - it('dedup: no vuelve a escalar un lead ya escalado', async () => { + it('dedup: no vuelve a escalar una oportunidad ya escalada', async () => { const anterior = configAuto['coordinadorUserId'] configAuto['coordinadorUserId'] = 0 try { - const monday = mondayMock([lead('1', { vendedorIds: [101], updatedAt: '2026-08-19T12:00:00Z' })]) + const monday = mondayMock([oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-19T12:00:00Z' })]) const state = stateEnMemoria() const svc = new AutomatizacionesService(monday, state, { actividadesEnabled: true }) @@ -529,14 +518,14 @@ describe('AutomatizacionesService.escaneoEscalado', () => { } }) - it('un fallo de Monday en un lead no detiene el escalado de los demás', async () => { + 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([ - lead('1', { vendedorIds: [101], updatedAt: '2026-08-19T12:00:00Z' }), - lead('2', { vendedorIds: [102], updatedAt: '2026-08-19T12:00:00Z' }), + 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')) @@ -544,7 +533,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => { const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true }) const r = await svc.escaneoEscalado({ ahora }) - expect(r.escalados).toBe(1) // el segundo lead sí se escaló + expect(r.escalados).toBe(1) // la segunda oportunidad sí se escaló expect(r.candidatos).toHaveLength(2) } finally { configAuto['coordinadorUserId'] = anterior @@ -553,11 +542,11 @@ describe('AutomatizacionesService.escaneoEscalado', () => { }) }) -// ---------- Ronda 2: Oportunidad → Aparto / Ganado ---------- +// ---------- 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 monday = mondayMock([oportunidad('10', { etapa: 'Aparto' })]) const state = stateEnMemoria() const svc = crearServicio(monday, state) @@ -573,7 +562,7 @@ describe('AutomatizacionesService.escaneoOportunidades', () => { }) it('una oportunidad en Ganado crea su item en Ventas con unidad y valor', async () => { - const monday = mondayMock([], [oportunidad('11', { etapa: 'GANADO' })]) + const monday = mondayMock([oportunidad('11', { etapa: 'GANADO' })]) const state = stateEnMemoria() const svc = crearServicio(monday, state) @@ -584,7 +573,7 @@ describe('AutomatizacionesService.escaneoOportunidades', () => { }) it('dedup: la segunda pasada no duplica apartados ni ventas', async () => { - const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' }), oportunidad('11', { etapa: 'Ganado' })]) + const monday = mondayMock([oportunidad('10', { etapa: 'Aparto' }), oportunidad('11', { etapa: 'Ganado' })]) const state = stateEnMemoria() const svc = crearServicio(monday, state) @@ -599,7 +588,7 @@ describe('AutomatizacionesService.escaneoOportunidades', () => { }) it('ignora oportunidades en otras etapas', async () => { - const monday = mondayMock([], [ + const monday = mondayMock([ oportunidad('10', { etapa: 'Cotizador' }), oportunidad('11', { etapa: 'Perdido' }), oportunidad('12', { etapa: 'Afluencia' }), @@ -613,7 +602,7 @@ describe('AutomatizacionesService.escaneoOportunidades', () => { }) 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' })]) + 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) diff --git a/api-gateway/src/services/automatizaciones.service.ts b/api-gateway/src/services/automatizaciones.service.ts index 8eb5158..c795e9c 100644 --- a/api-gateway/src/services/automatizaciones.service.ts +++ b/api-gateway/src/services/automatizaciones.service.ts @@ -1,26 +1,32 @@ import { config } from '../config/index.js' import { AppError } from '../utils/errors.js' -import { mondayService, type LeadMonday, type MondayService, type UsuarioMonday } from './monday.service.js' +import { mondayService, type MondayService, type OportunidadMonday, type UsuarioMonday } from './monday.service.js' import { prisma } from './prisma.js' /** - * Automatizaciones sobre el tablero Leads de Monday: - * (a) asignación round-robin de vendedores a leads sin persona asignada - * (b) recordatorio de leads inactivos (> INACTIVIDAD_DIAS sin actualizarse) + * Automatizaciones sobre el tablero Oportunidades de Monday (la afluencia ES + * la oportunidad en etapa 'Afluencia'): + * (a) asignación round-robin de asesores a afluencias sin persona asignada + * (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' * * 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. */ -// Labels del tablero Leads: NUEVO, CONTACTADO, AGENDA CITA, CALIFICADO, DESCARTADO -// más los del vocabulario CRM FRASA (Lead Nuevo, Intento de contacto, Interesado, -// Abandono...). Terminales: DESCARTADO y ABANDONO (fuera de seguimiento). -export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'ABANDONO'] as const +// Etapas del tablero Oportunidades 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 -// Estados en los que un lead asignado aún no ha sido contactado de verdad; -// si permanece así más de LEAD_ESCALADO_HORAS horas se escala al coordinador. -export const ESTADOS_SIN_CONTACTAR = ['NUEVO', 'INTENTO DE CONTACTO'] 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' @@ -66,7 +72,7 @@ export function parseRrUserIds(raw: string): UsuarioMonday[] { }) } -export function esEstadoTerminal(estado: string, terminales: readonly string[] = ESTADOS_TERMINALES_LEAD): boolean { +export function esEstadoTerminal(estado: string, terminales: readonly string[] = ETAPAS_TERMINALES_OPORTUNIDAD): boolean { const normalizado = estado.trim().toUpperCase() return terminales.some((t) => t.toUpperCase() === normalizado) } @@ -78,40 +84,50 @@ export function vendedorEnTurno(vendedores: readonly UsuarioMonday[], indice: nu } /** - * Leads activos (estado no terminal) cuya última actualización supera + * 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 leadsInactivos(leads: readonly LeadMonday[], ahora: Date, diasInactividad: number): LeadMonday[] { +export function oportunidadesInactivas( + oportunidades: readonly OportunidadMonday[], + ahora: Date, + diasInactividad: number, +): OportunidadMonday[] { const limiteMs = diasInactividad * DIA_MS - return leads.filter((lead) => { - if (esEstadoTerminal(lead.estado)) return false - const t = Date.parse(lead.updatedAt) + 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 lead por día */ +/** 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(lead: LeadMonday, ahora: Date): number { - return Math.floor((ahora.getTime() - Date.parse(lead.updatedAt)) / DIA_MS) +export function diasSinActividad(op: Pick, ahora: Date): number { + return Math.floor((ahora.getTime() - Date.parse(op.updatedAt)) / DIA_MS) } /** - * Leads ASIGNADOS (con vendedor) que siguen en estado NUEVO / Intento de contacto - * y llevan más de `horasUmbral` horas sin actualizarse: candidatos a escalar. - * (El round-robin actualiza el item al asignar, así que el reloj arranca ahí.) + * 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 leadsSinContactar(leads: readonly LeadMonday[], ahora: Date, horasUmbral: number): LeadMonday[] { +export function oportunidadesSinContactar( + oportunidades: readonly OportunidadMonday[], + ahora: Date, + horasUmbral: number, +): OportunidadMonday[] { const limiteMs = horasUmbral * HORA_MS - return leads.filter((lead) => { - if (lead.vendedorIds.length === 0) return false - if (esEstadoTerminal(lead.estado)) return false - if (!ESTADOS_SIN_CONTACTAR.some((e) => e === lead.estado.trim().toUpperCase())) return false - const t = Date.parse(lead.updatedAt) + 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 }) @@ -141,9 +157,8 @@ export function esEtapa(etapa: string, esperada: string): boolean { type MondayLeads = Pick< MondayService, - | 'getLeads' | 'getOportunidades' - | 'asignarVendedorALead' + | 'asignarAsesorAOportunidad' | 'crearUpdateEnItem' | 'getUsuariosEquipo' | 'crearActividad' @@ -279,24 +294,25 @@ export class AutomatizacionesService { } /** - * Una pasada de round-robin: asigna vendedor a todo lead sin persona asignada - * y con estado no terminal. Si se pasa `soloItemId`, solo se evalúa ese item - * (ruta del webhook create_item). + * Una pasada de round-robin: asigna asesor a toda oportunidad en etapa + * 'Afluencia' sin persona asignada (las creadas por el cotizador nacen en + * 'Cotizador' y NO entran al reparto). Si se pasa `soloItemId`, solo se + * evalúa ese item (ruta del webhook create_item). */ async runRoundRobin(soloItemId?: string): Promise { - const leads = await this.monday.getLeads() - const sinAsignar = leads.filter( - (l) => - l.vendedorIds.length === 0 && - !esEstadoTerminal(l.estado) && - (soloItemId === undefined || l.id === soloItemId), + const oportunidades = await this.monday.getOportunidades() + const sinAsignar = oportunidades.filter( + (o) => + o.asesorIds.length === 0 && + o.etapa.trim().toUpperCase() === 'AFLUENCIA' && + (soloItemId === undefined || o.id === soloItemId), ) - // Los leads 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 vendedor y pisara la asignación. + // Las afluencias capturadas 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 las viera sin asesor y pisara la asignación. const pendientes: typeof sinAsignar = [] - for (const l of sinAsignar) { - if ((await this.state.get(`${CLAVE_RR_SKIP}${l.id}`)) !== 'capturista') pendientes.push(l) + for (const o of sinAsignar) { + if ((await this.state.get(`${CLAVE_RR_SKIP}${o.id}`)) !== 'capturista') pendientes.push(o) } if (pendientes.length === 0) { await this.state.set(CLAVE_RR_ULTIMA_PASADA, new Date().toISOString()) @@ -308,18 +324,18 @@ export class AutomatizacionesService { if (!Number.isInteger(indice) || indice < 0) indice = 0 const detalle: AsignacionRR[] = [] - for (const lead of pendientes) { + for (const op of pendientes) { const vendedor = vendedorEnTurno(vendedores, indice) if (!vendedor) throw new AppError(500, 'La lista de vendedores está vacía') - await this.monday.asignarVendedorALead(lead.id, vendedor.id) + await this.monday.asignarAsesorAOportunidad(op.id, vendedor.id) if (this.actividadesEnabled) { await this.crearActividadSegura({ - nombre: `Contactar a ${lead.nombre}`, + nombre: `Contactar a ${op.nombre}`, responsableId: vendedor.id, - leadItemId: lead.id, + leadItemId: op.id, }) } - detalle.push({ itemId: lead.id, lead: lead.nombre, vendedorId: vendedor.id, vendedor: vendedor.nombre }) + detalle.push({ itemId: op.id, lead: op.nombre, vendedorId: vendedor.id, vendedor: vendedor.nombre }) indice++ } @@ -329,18 +345,19 @@ export class AutomatizacionesService { } /** - * Asigna el lead 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. No hay carrera con el - * webhook create_item: runRoundRobin solo toma leads sin persona asignada. + * Asigna la afluencia a quien la capturó desde una app del ecosistema (su + * usuario Monday vinculado, columna Asesor de Oportunidades) y le crea la + * actividad de contacto — el mismo paquete que haría el round-robin, pero + * para el capturista. No hay carrera con el webhook create_item: + * runRoundRobin solo toma oportunidades sin persona asignada. */ - async asignarACapturista(leadId: string, leadNombre: string, vendedorId: number): Promise { - await this.monday.asignarVendedorALead(leadId, vendedorId) + async asignarACapturista(oportunidadId: string, nombre: string, vendedorId: number): Promise { + await this.monday.asignarAsesorAOportunidad(oportunidadId, vendedorId) if (this.actividadesEnabled) { await this.crearActividadSegura({ - nombre: `Contactar a ${leadNombre}`, + nombre: `Contactar a ${nombre}`, responsableId: vendedorId, - leadItemId: leadId, + leadItemId: oportunidadId, }) } } @@ -352,38 +369,38 @@ export class AutomatizacionesService { async escaneoInactividad(opciones: { preview: boolean; ahora?: Date }): Promise { const ahora = opciones.ahora ?? new Date() const diasUmbral = config.automatizaciones.inactividadDias - const leads = await this.monday.getLeads() - const inactivos = leadsInactivos(leads, ahora, diasUmbral) + const oportunidades = await this.monday.getOportunidades() + const inactivas = oportunidadesInactivas(oportunidades, ahora, diasUmbral) const candidatos: CandidatoInactividad[] = [] let recordatoriosEnviados = 0 - for (const lead of inactivos) { - const clave = claveRecordatorio(lead.id, ahora) + for (const op of inactivas) { + const clave = claveRecordatorio(op.id, ahora) const yaEnviado = (await this.state.get(clave)) !== null - const dias = diasSinActividad(lead, ahora) + const dias = diasSinActividad(op, ahora) candidatos.push({ - itemId: lead.id, - lead: lead.nombre, - estado: lead.estado, - updatedAt: lead.updatedAt, + 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: este lead lleva ${dias} días sin actualizarse ` + - `(última actividad: ${lead.updatedAt.slice(0, 10)}). ` + - `Favor de darle seguimiento o marcarlo como DESCARTADO.` - await this.monday.crearUpdateEnItem(lead.id, 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 vendedor asignado (si tiene) - const vendedorId = lead.vendedorIds[0] - if (this.actividadesEnabled && vendedorId !== undefined) { + // 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 ${lead.nombre}`, - responsableId: vendedorId, - leadItemId: lead.id, + nombre: `Retomar contacto con ${op.nombre}`, + responsableId: asesorId, + leadItemId: op.id, }) } } @@ -399,36 +416,36 @@ 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 vendedor asignado */ - private responsableEscalado(lead: LeadMonday): number | null { + /** 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 lead.vendedorIds[0] ?? null + return op.asesorIds[0] ?? null } /** - * Escalado de leads sin contactar: un lead asignado que sigue en - * NUEVO / Intento de contacto más de LEAD_ESCALADO_HORAS horas genera una - * actividad URGENTE al coordinador (o al vendedor) + update en el item. - * Un escalado por lead (dedup `escalado:`); un fallo de Monday en - * un lead no detiene a los demás. + * 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 { const ahora = opciones.ahora ?? new Date() const horasUmbral = config.automatizaciones.escaladoHoras - const leads = await this.monday.getLeads() - const pendientes = leadsSinContactar(leads, ahora, horasUmbral) + const oportunidades = await this.monday.getOportunidades() + const pendientes = oportunidadesSinContactar(oportunidades, ahora, horasUmbral) const candidatos: CandidatoEscalado[] = [] let escalados = 0 - for (const lead of pendientes) { - const responsableId = this.responsableEscalado(lead) - const horas = Math.floor((ahora.getTime() - Date.parse(lead.updatedAt)) / HORA_MS) - const yaEscalado = (await this.state.get(claveEscalado(lead.id))) !== null + 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: lead.id, - lead: lead.nombre, - estado: lead.estado, + itemId: op.id, + lead: op.nombre, + estado: op.etapa, horasSinContactar: horas, responsableId, yaEscalado, @@ -437,24 +454,24 @@ export class AutomatizacionesService { try { if (this.actividadesEnabled && responsableId !== null) { await this.monday.crearActividad({ - nombre: `URGENTE: lead sin contactar — ${lead.nombre}`, + nombre: `URGENTE: oportunidad sin contactar — ${op.nombre}`, tipoLabel: 'Llamada', responsableId, fecha: AutomatizacionesService.fechaHoyMas(0), estadoLabel: 'Abierto', - leadItemId: lead.id, + leadItemId: op.id, }) } await this.monday.crearUpdateEnItem( - lead.id, - `🚨 Escalado automático: el lead lleva ${horas} h asignado en estado "${lead.estado}" sin contacto. ` + - `Se notificó a ${responsableId !== null && responsableId !== lead.vendedorIds[0] ? 'la coordinación' : 'el vendedor asignado'}.`, + 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(lead.id), ahora.toISOString()) + await this.state.set(claveEscalado(op.id), ahora.toISOString()) escalados++ } catch (err) { // eslint-disable-next-line no-console - console.error(`[escalado] falló el escalado del lead ${lead.id} (${lead.nombre}):`, err) + console.error(`[escalado] falló el escalado de la oportunidad ${op.id} (${op.nombre}):`, err) } } diff --git a/api-gateway/src/services/funnel.service.test.ts b/api-gateway/src/services/funnel.service.test.ts index eb3a418..7f02ee2 100644 --- a/api-gateway/src/services/funnel.service.test.ts +++ b/api-gateway/src/services/funnel.service.test.ts @@ -1,40 +1,21 @@ import { describe, expect, it, vi } from 'vitest' import { FunnelService, type LeadOportunidadStore } from './funnel.service.js' -import type { LeadMonday } from './monday.service.js' +import type { LeadMonday, OportunidadMonday } from './monday.service.js' -/** Monday mockeado: registra oportunidades creadas, cambios de columna y movimientos de grupo */ -function mondayMock(opts: { leadPorTelefono?: LeadMonday | null; leadPorId?: LeadMonday | null } = {}) { +/** Monday mockeado: registra cambios de columna y devuelve búsquedas controladas */ +function mondayMock( + opts: { leadPorTelefono?: LeadMonday | null; oportunidadPorTelefono?: OportunidadMonday | null } = {}, +) { return { - oportunidadesCreadas: [] as Record[], cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[], - movimientosGrupo: [] as { itemId: string; groupId: string }[], - async crearOportunidadAfluencia(datos: Record) { - this.oportunidadesCreadas.push(datos) - return `op-${this.oportunidadesCreadas.length}` - }, 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 getLeadPorId(_itemId: string) { - return opts.leadPorId ?? null - }, - // La oportunidad recién creada nace en el grupo top del tablero (como en Monday real) - async columnaIdPorTitulo(_boardId: string, _titulo: string) { - return 'status_col' - }, - async getItemConGrupo(itemId: string) { - return { - id: itemId, - name: 'Op Prueba', - groupId: 'group_top', - column_values: [{ id: 'status_col', text: 'Afluencia', value: null }], - } - }, - async moverItemAGrupo(itemId: string, groupId: string) { - this.movimientosGrupo.push({ itemId, groupId }) + async buscarOportunidadPorTelefono(_telefono: string) { + return opts.oportunidadPorTelefono ?? null }, } } @@ -47,14 +28,18 @@ function storeMock(): LeadOportunidadStore & { mapping: Map } { 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 leadBase = { id: 'lead-1', nombre: 'Lead Prueba', telefono: '4771234567', desarrolloLabel: 'SENDA' } - const leadMonday = (overrides: Partial): LeadMonday => ({ id: 'lead-1', nombre: 'Lead Prueba', @@ -68,47 +53,19 @@ const leadMonday = (overrides: Partial): LeadMonday => ({ ...overrides, }) -describe('FunnelService.asegurarOportunidadAfluencia', () => { - it('crea la oportunidad en Monday y guarda el mapping lead ↔ oportunidad', async () => { - const monday = mondayMock() - const store = storeMock() - const funnel = new FunnelService(monday, store) - const opId = await funnel.asegurarOportunidadAfluencia(leadBase) - expect(opId).toBe('op-1') - expect(monday.oportunidadesCreadas).toEqual([ - { nombre: 'Lead Prueba', telefono: '4771234567', email: undefined, desarrolloLabel: 'SENDA' }, - ]) - expect(store.mapping.get('lead-1')).toBe('op-1') - // Y la mueve al grupo que corresponde a 'Afluencia' (nace en el grupo top del tablero) - expect(monday.movimientosGrupo).toEqual([{ itemId: 'op-1', groupId: 'topics' }]) - }) - - it('es idempotente: con mapping existente no crea una segunda oportunidad', async () => { - const monday = mondayMock() - const store = storeMock() - store.mapping.set('lead-1', 'op-vieja') - const funnel = new FunnelService(monday, store) - const opId = await funnel.asegurarOportunidadAfluencia(leadBase) - expect(opId).toBe('op-vieja') - expect(monday.oportunidadesCreadas).toEqual([]) - }) - - it('por leadId (webhook): consulta el lead en Monday y crea la oportunidad', async () => { - const monday = mondayMock({ leadPorId: leadMonday({ telefono: '4771234567', email: 'a@b.com' }) }) - const store = storeMock() - const funnel = new FunnelService(monday, store) - const opId = await funnel.asegurarOportunidadAfluenciaPorLeadId('lead-1') - expect(opId).toBe('op-1') - expect(monday.oportunidadesCreadas[0]).toMatchObject({ nombre: 'Lead Prueba', telefono: '4771234567' }) - expect(store.mapping.get('lead-1')).toBe('op-1') - }) - - it('por leadId inexistente en Monday no crea nada (null)', async () => { - const monday = mondayMock({ leadPorId: null }) - const funnel = new FunnelService(monday, storeMock()) - await expect(funnel.asegurarOportunidadAfluenciaPorLeadId('fantasma')).resolves.toBeNull() - expect(monday.oportunidadesCreadas).toEqual([]) - }) +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', () => { @@ -130,7 +87,7 @@ describe('FunnelService.moverOportunidadDeLead', () => { }) describe('FunnelService.oportunidadPorTelefono', () => { - it('resuelve lead por teléfono y devuelve la oportunidad mapeada', async () => { + 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') @@ -138,11 +95,24 @@ describe('FunnelService.oportunidadPorTelefono', () => { await expect(funnel.oportunidadPorTelefono('4771234567')).resolves.toBe('op-7') }) - it('sin lead con ese teléfono (o sin mapping) devuelve null', async () => { + 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() - const monday = mondayMock({ leadPorTelefono: leadMonday({ id: 'sin-mapping' }) }) - await expect(new FunnelService(monday, storeMock()).oportunidadPorTelefono('4771234567')).resolves.toBeNull() }) }) @@ -171,11 +141,39 @@ describe('FunnelService.moverAApartoConDedup', () => { 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('lead-sin-op', state)).resolves.toBeNull() + 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 index 68f3c36..4a596f6 100644 --- a/api-gateway/src/services/funnel.service.ts +++ b/api-gateway/src/services/funnel.service.ts @@ -1,44 +1,33 @@ import { config } from '../config/index.js' import { claveApartadoCreado, type StateStore } from './automatizaciones.service.js' -import { moverAGrupoSegunEstado } from './grupos-estado.service.js' import { mondayService, type MondayService } from './monday.service.js' import { prisma } from './prisma.js' /** - * Funnel comercial Leads → Oportunidades: - * todo lead capturado entra al tablero Oportunidades en etapa 'Afluencia' y la - * oportunidad avanza de etapa sola conforme avanza el proceso (Agendo Cita, - * Cotizador, Aparto, Ganado / Perdido). + * 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 vinculación lead ↔ oportunidad vive en la tabla LeadOportunidad (dedup - * persistente: una oportunidad por lead), inyectada como store para testear. + * 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 — crear el lead, la cotización o el alta — no se rompe). + * (la acción principal — la cotización o el alta — no se rompe). */ -export interface LeadFunnel { - id: string // item ID del lead en Monday - nombre: string - telefono?: string | undefined - email?: string | undefined - desarrolloLabel?: string | undefined -} - /** 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, - | 'crearOportunidadAfluencia' - | 'cambiarValorColumna' - | 'buscarLeadPorTelefono' - | 'getLeadPorId' - | 'columnaIdPorTitulo' - | 'getItemConGrupo' - | 'moverItemAGrupo' + 'cambiarValorColumna' | 'buscarLeadPorTelefono' | 'buscarOportunidadPorTelefono' > export class FunnelService { @@ -47,49 +36,6 @@ export class FunnelService { private readonly store: LeadOportunidadStore, ) {} - /** - * Garantiza que el lead tenga su oportunidad en 'Afluencia': si ya existe el - * mapping devuelve la oportunidad existente; si no, la crea en Monday y - * guarda el mapping. Idempotente. - */ - async asegurarOportunidadAfluencia(lead: LeadFunnel): Promise { - const existente = await this.store.buscarPorLeadId(lead.id) - if (existente !== null) return existente - const oportunidadId = await this.monday.crearOportunidadAfluencia({ - nombre: lead.nombre, - telefono: lead.telefono, - email: lead.email, - desarrolloLabel: lead.desarrolloLabel, - }) - await this.store.guardar(lead.id, oportunidadId) - // Monday crea los items en el grupo TOP del tablero (hoy "Perdidos" en - // Oportunidades) y el webhook de change_column_value no dispara en la - // creación: la oportunidad recién creada se mueve al grupo que corresponde - // a su etapa. Best-effort: un fallo no rompe la captura del lead. - try { - await moverAGrupoSegunEstado(this.monday, config.monday.boards.oportunidades, oportunidadId) - } catch (err) { - // eslint-disable-next-line no-console - console.error(`No se pudo mover al grupo correcto la oportunidad ${oportunidadId}:`, err) - } - return oportunidadId - } - - /** - * Variante para el webhook create_pulse: el payload no trae nombre/teléfono, - * así que el lead se consulta en Monday antes de asegurar la oportunidad. - */ - async asegurarOportunidadAfluenciaPorLeadId(leadId: string): Promise { - const lead = await this.monday.getLeadPorId(leadId) - if (!lead) return null - return this.asegurarOportunidadAfluencia({ - id: lead.id, - nombre: lead.nombre, - telefono: lead.telefono !== '' ? lead.telefono : undefined, - email: lead.email !== '' ? lead.email : undefined, - }) - } - /** 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) @@ -104,24 +50,36 @@ export class FunnelService { } /** - * Oportunidad del lead que tenga ese teléfono (match de cotizaciones: - * busca el lead por teléfono en Monday y de ahí el mapping). + * 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) return null - return this.store.buscarPorLeadId(lead.id) + 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 desde un lead: 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 el lead no tiene mapping. + * 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(leadId: string, state: StateStore): Promise { - const oportunidadId = await this.store.buscarPorLeadId(leadId) + 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') @@ -135,6 +93,10 @@ export const prismaLeadOportunidadStore: LeadOportunidadStore = { 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 } }) }, diff --git a/api-gateway/src/services/grupos-estado.service.ts b/api-gateway/src/services/grupos-estado.service.ts index 337ce6f..65732ca 100644 --- a/api-gateway/src/services/grupos-estado.service.ts +++ b/api-gateway/src/services/grupos-estado.service.ts @@ -42,6 +42,9 @@ export const GRUPOS_POR_ESTADO: Record & { id: string }): LeadMonday { +function oportunidad(parcial: Partial & { id: string }): OportunidadMonday { return { nombre: `Lead ${parcial.id}`, createdAt: '2026-08-25T10:00:00Z', updatedAt: '2026-08-27T10:00:00Z', - estado: 'NUEVO', + etapa: 'Afluencia', origen: 'Facebook', + unidadCodigo: '', + precioFinal: 0, + asesorIds: [MI_ID], telefono: '', email: '', - vendedorIds: [MI_ID], ...parcial, } } @@ -50,13 +52,13 @@ function apartado(parcial: Partial & { id: string }): ApartadoMo } function mondayMock(datos: { - leads?: LeadMonday[] + oportunidades?: OportunidadMonday[] actividades?: ActividadMonday[] apartados?: ApartadoMonday[] }) { return { - async getLeads() { - return datos.leads ?? [] + async getOportunidades() { + return datos.oportunidades ?? [] }, async getActividades() { return datos.actividades ?? [] @@ -72,23 +74,37 @@ const sinMapeo: UsuarioMiDia = { nombre: 'Erick Peña', rol: 'vendedor', mondayU const coordinador: UsuarioMiDia = { nombre: 'Daniel Alvarado', rol: 'coordinador', mondayUserId: null } describe('resumenMiDia (GET /api/mi-dia/resumen)', () => { - it('filtra mis leads por mondayUserId y excluye estados terminales', async () => { + it('filtra mis leads por mondayUserId (columna Asesor) y excluye etapas terminales', async () => { const monday = mondayMock({ - leads: [ - lead({ id: '1', estado: 'NUEVO' }), - lead({ id: '2', estado: 'CONTACTADO', telefono: '4771234567' }), - lead({ id: '3', estado: 'DESCARTADO' }), - lead({ id: '4', estado: 'CALIFICADO' }), - lead({ id: '5', estado: 'Abandono' }), - lead({ id: '6', estado: 'NUEVO', vendedorIds: [OTRO_ID] }), + oportunidades: [ + oportunidad({ id: '1', etapa: 'Afluencia' }), + oportunidad({ id: '2', etapa: 'Contactado', telefono: '4771234567' }), + oportunidad({ id: '3', etapa: 'Perdido' }), + oportunidad({ id: '4', etapa: 'Calificado' }), // NO es terminal en el nuevo modelo + oportunidad({ id: '5', etapa: 'Abandono' }), + oportunidad({ id: '6', etapa: 'Ganado' }), + oportunidad({ id: '7', 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(['1', '2']) + expect(r.misLeads.map((l) => l.id)).toEqual(['1', '2', '4']) expect(r.misLeads[1]?.telefono).toBe('4771234567') }) + 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).toEqual({ porEtapa: { Afluencia: 2, Ganado: 1 } }) + }) + it('actividades de hoy: solo abiertas con fecha <= mañana y mías', async () => { const monday = mondayMock({ actividades: [ @@ -126,13 +142,14 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => { it('sin mapeo Monday: listas personales vacías y sinMapeoMonday: true', async () => { const monday = mondayMock({ - leads: [lead({ id: '1' })], + 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: {} }) expect(r.misActividadesHoy).toEqual([]) expect(r.conteos).toEqual({ leadsNuevosSemana: 0, actividadesAbiertas: 0 }) // Los apartados por vencer son del pipeline completo: sí se muestran @@ -141,32 +158,26 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => { it('conteos: leads nuevos de los últimos 7 días (incluye terminales recientes)', async () => { const monday = mondayMock({ - leads: [ - lead({ id: '1', createdAt: '2026-08-28T08:00:00' }), // hoy - lead({ id: '2', createdAt: '2026-08-22T08:00:00' }), // hace 6 días - lead({ id: '3', createdAt: '2026-08-10T08:00:00' }), // viejo - lead({ id: '4', createdAt: '2026-08-27T08:00:00', vendedorIds: [OTRO_ID] }), // de otro + 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(2) }) - it('oportunidades se omiten con nota (el board no tiene columna de asesor)', async () => { - const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], [], AHORA) - expect(r.misOportunidades).toBeNull() - expect(r.notaOportunidades).toContain('asesor') - }) - - it('vendedor no ve sección equipo; coordinador sí (conteos por vendedor y sin asignar)', async () => { + it('vendedor no ve sección equipo; coordinador sí (conteos por asesor y sin asignar)', async () => { const monday = mondayMock({ - leads: [ - lead({ id: '1', vendedorIds: [] }), // sin asignar, NUEVO - lead({ id: '2', vendedorIds: [], estado: 'CONTACTADO' }), // sin asignar, no NUEVO - lead({ id: '3', vendedorIds: [MI_ID] }), - lead({ id: '4', vendedorIds: [MI_ID], estado: 'DESCARTADO' }), // terminal: no cuenta - lead({ id: '5', vendedorIds: [OTRO_ID] }), - lead({ id: '6', vendedorIds: [OTRO_ID], createdAt: '2026-08-10T08:00:00' }), // viejo: no es nuevo de la semana + 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']]) @@ -183,42 +194,42 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => { }) it('corporativo y admin también ven equipo', async () => { - const monday = mondayMock({ leads: [lead({ id: '1' })] }) + 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: enriquecidas con el estado/origen actual del lead en Monday', async () => { + it('mis capturas: enriquecidas con la etapa/origen actual de la oportunidad en Monday', async () => { const monday = mondayMock({ - leads: [lead({ id: '100', nombre: 'Ana López', estado: 'CONTACTADO', origen: 'WhatsApp', vendedorIds: [OTRO_ID] })], + 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) - // Aunque el lead quedó asignado a OTRO por round-robin, la captura es mía + // Aunque la afluencia quedó asignada a OTRO por round-robin, la captura es mía expect(r.misCapturas).toEqual([ { leadId: '100', nombre: 'Ana López', telefono: '6641112233', - estado: 'CONTACTADO', + estado: 'Contactado', origen: 'WhatsApp', capturadoEn: '2026-08-27T09:00:00.000Z', }, ]) }) - it('mis capturas: si el lead ya no existe en Monday, estado y origen van vacíos', async () => { + it('mis capturas: capturas viejas (del tablero Leads) ya no cruzan; 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') }, + { leadId: 'lead-viejo-1', nombreLead: 'Lead Histórico', 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' }, + { leadId: 'lead-viejo-1', nombre: 'Lead Histórico', telefono: '', estado: '', origen: '', capturadoEn: '2026-08-26T09:00:00.000Z' }, ]) }) @@ -290,12 +301,13 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => { }) describe('clasificadores de estado', () => { - it('estados terminales de lead (case-insensitive)', () => { - expect(esEstadoTerminalLead('DESCARTADO')).toBe(true) - expect(esEstadoTerminalLead('calificado')).toBe(true) - expect(esEstadoTerminalLead('Abandono')).toBe(true) - expect(esEstadoTerminalLead('NUEVO')).toBe(false) - expect(esEstadoTerminalLead('Intento de contacto')).toBe(false) + it('etapas terminales de oportunidad (case-insensitive)', () => { + expect(esEtapaTerminal('GANADO')).toBe(true) + expect(esEtapaTerminal('perdido')).toBe(true) + expect(esEtapaTerminal('Abandono')).toBe(true) + expect(esEtapaTerminal('Afluencia')).toBe(false) + expect(esEtapaTerminal('Calificado')).toBe(false) + expect(esEtapaTerminal('Intento de contacto')).toBe(false) }) it('estados finales de apartado', () => { diff --git a/api-gateway/src/services/mi-dia.service.ts b/api-gateway/src/services/mi-dia.service.ts index 22ddfdd..0a8b05c 100644 --- a/api-gateway/src/services/mi-dia.service.ts +++ b/api-gateway/src/services/mi-dia.service.ts @@ -5,12 +5,15 @@ import type { MondayService } from './monday.service.js' * 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 capturas" viene del registro local (tabla LeadCaptura), no de Monday: - * es la lista de leads que el usuario dio de alta, con su estado actual. + * La fuente de leads/oportunidades es el tablero Oportunidades (la afluencia + * ES la oportunidad en etapa 'Afluencia'; el tablero Leads es archivo + * histórico). "Mis capturas" viene del registro local (tabla LeadCaptura), no + * de Monday: es la lista de afluencias que el usuario dio de alta, con su + * estado actual. */ -// Estados del board Leads en los que el lead ya no es trabajo activo del vendedor -export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'CALIFICADO', 'ABANDONO'] +// Etapas del board Oportunidades en las que la oportunidad ya no es trabajo activo del asesor +export const ETAPAS_TERMINALES_OPORTUNIDAD = ['GANADO', 'PERDIDO', 'ABANDONO'] // Estados del board Apartado que cierran el pipeline Enkontrol export const ESTADOS_FINALES_APARTADO = ['ESCRITURADO', 'CANCELACION', 'CANCELADO', 'LIBERADO', 'FORMALIZADO'] @@ -76,9 +79,8 @@ export interface ResumenMiDia { misClientes: ClienteMiDia[] misActividadesHoy: ActividadMiDia[] apartadosPorVencer: ApartadoPorVencer[] - // null = el tablero Oportunidades no tiene columna people de asesor (hoy no existe) - misOportunidades: { porEtapa: Record } | null - notaOportunidades: string | null + /** Conteo de MIS oportunidades por etapa (columna Estado del board) */ + misOportunidades: { porEtapa: Record } conteos: { leadsNuevosSemana: number // míos, últimos 7 días naturales actividadesAbiertas: number // mías, Estado Abierto @@ -96,7 +98,7 @@ export interface ResumenMiDia { } } -type MondayMiDia = Pick +type MondayMiDia = Pick const MS_DIA = 24 * 60 * 60 * 1000 @@ -109,8 +111,8 @@ function fechaLocal(base: Date, dias: number): string { 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 { @@ -131,8 +133,8 @@ export async function resumenMiDia( const miId = usuario.mondayUserId const sinMapeo = miId === null - const [leads, actividades, apartados] = await Promise.all([ - monday.getLeads(), + const [oportunidades, actividades, apartados] = await Promise.all([ + monday.getOportunidades(), monday.getActividades(), monday.getApartados(), ]) @@ -143,31 +145,40 @@ export async function resumenMiDia( const hace7DiasMs = ahora.getTime() - 7 * MS_DIA // --- Listas personales (vacías si no hay mapeo Monday) --- - const mios = sinMapeo ? [] : leads.filter((l) => l.vendedorIds.includes(miId)) + const mios = sinMapeo ? [] : oportunidades.filter((o) => o.asesorIds.includes(miId)) const misLeads: LeadMiDia[] = mios - .filter((l) => !esEstadoTerminalLead(l.estado)) - .map((l) => ({ - id: l.id, - nombre: l.nombre, - telefono: l.telefono, - email: l.email, - estado: l.estado, - origen: l.origen, - updatedAt: l.updatedAt, + .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, + updatedAt: o.updatedAt, })) - // "Mis capturas" se enriquece con el estado/origen ACTUAL del lead en Monday - // (si ya no existe en el board, estado y origen van ''), más reciente primero - const leadsPorId = new Map(leads.map((l) => [l.id, l])) + // 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 (las + // capturas nuevas apuntan a Oportunidades; las viejas — del tablero Leads — + // ya no cruzan y quedan con estado/origen ''), más reciente primero + 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 ?? '', - origen: lead?.origen ?? '', + estado: op?.etapa ?? '', + origen: op?.origen ?? '', capturadoEn: c.createdAt.toISOString(), } }) @@ -224,27 +235,25 @@ export async function resumenMiDia( misClientes, misActividadesHoy, apartadosPorVencer, - // El tablero Oportunidades no tiene columna people de asesor: no se puede - // filtrar "mío"; se omite con nota explícita - misOportunidades: null, - notaOportunidades: 'El tablero Oportunidades no tiene columna de asesor (people); no se puede filtrar por vendedor', + misOportunidades: { porEtapa }, conteos, } if (ROLES_CON_EQUIPO.includes(usuario.rol)) { - const sinAsignar = leads.filter((l) => l.vendedorIds.length === 0 && !esEstadoTerminalLead(l.estado)) + const sinAsignar = oportunidades.filter((o) => o.asesorIds.length === 0 && !esEtapaTerminal(o.etapa)) const conteoPorId = new Map() const nuevosPorId = new Map() - for (const l of leads) { - if (esEstadoTerminalLead(l.estado)) continue - const esNuevoSemana = Date.parse(l.createdAt) >= hace7DiasMs - for (const id of l.vendedorIds) { + 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.filter((l) => l.estado.trim().toUpperCase() === 'NUEVO').length, + // Afluencias recién llegadas que el round-robin aún no reparte + leadsNuevosSinAsignar: sinAsignar.filter((o) => o.etapa.trim().toUpperCase() === 'AFLUENCIA').length, porVendedor: [...conteoPorId.entries()] .map(([mondayUserId, leadsAsignados]) => ({ mondayUserId, diff --git a/api-gateway/src/services/monday.service.test.ts b/api-gateway/src/services/monday.service.test.ts index 6cc4d8e..021b3ce 100644 --- a/api-gateway/src/services/monday.service.test.ts +++ b/api-gateway/src/services/monday.service.test.ts @@ -415,5 +415,241 @@ describe('MondayService', () => { 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('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']) + }) }) }) diff --git a/api-gateway/src/services/monday.service.ts b/api-gateway/src/services/monday.service.ts index 583c449..e0db921 100644 --- a/api-gateway/src/services/monday.service.ts +++ b/api-gateway/src/services/monday.service.ts @@ -86,9 +86,27 @@ 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' } /** Usuario del workspace (para la lista rotativa de vendedores) */ @@ -371,17 +389,17 @@ export class MondayService { } /** - * Resuelve el label real de una columna status tolerando mayúsculas y - * minúsculas (el código escribe 'NUEVO' aunque el board diga 'Nuevo' tras - * un rename en la UI). Sin match devuelve el valor tal cual (Monday dirá - * que el label no existe, como antes). + * 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 resolverLabelStatus( + private async resolverLabel( boardId: string, col: ColumnaMeta, valor: string | number | boolean, ): Promise { - if (col.type !== 'status') return valor + 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 @@ -395,7 +413,7 @@ export class MondayService { 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.resolverLabelStatus(boardId, col, crudo)) + out[col.id] = this.valorPorTipo(col, await this.resolverLabel(boardId, col, crudo)) } return JSON.stringify(out) } @@ -429,16 +447,33 @@ export class MondayService { } /** - * Oportunidad en etapa 'Afluencia' a partir de un lead recién capturado - * (entrada al funnel; sin unidad ni precios todavía). + * 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). */ 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: 'Afluencia', + Fecha: fechaHoy, 'Cliente teléfono': datos.telefono, 'Cliente email': datos.email, Desarrollo: datos.desarrolloLabel, - Estado: 'Afluencia', + '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) } @@ -561,7 +596,7 @@ export class MondayService { valor: string | number | boolean, ): Promise { const col = await this.columnaId(boardId, tituloColumna) - const resuelto = await this.resolverLabelStatus(boardId, col, valor) + 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 } @@ -689,21 +724,6 @@ export class MondayService { return items.map((item) => this.mapearLead(item, cols)) } - /** Un lead del tablero Leads por item ID (el payload del webhook no trae datos) */ - async getLeadPorId(itemId: string): Promise { - const boardId = config.monday.boards.leads - const cols = await this.columnasDe(boardId) - const data = await this.graphql<{ items: (ItemConFechas | null)[] }>( - `query ($ids: [ID!]) { - items(ids: $ids) { id name created_at updated_at column_values { id text value } } - }`, - { ids: [itemId] }, - ) - const item = data.items[0] - if (!item) return null - return this.mapearLead(item, cols) - } - private mapearLead(item: ItemConFechas, cols: Map): LeadMonday { const colVendedor = cols.get('Vendedor') if (!colVendedor || colVendedor.type !== 'people') { @@ -761,16 +781,16 @@ export class MondayService { } /** - * Items del tablero Oportunidades con etapa, unidad y precio final - * (los consumen las automatizaciones Aparto/Ganado y los reportes). + * 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) - const colEstado = cols.get('Estado') - if (!colEstado) throw new AppError(500, `Columna "Estado" no existe en el tablero Oportunidades (${boardId})`) - const colUnidad = cols.get('Unidad') - const colPrecio = cols.get('Precio final') + 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 @@ -805,18 +825,68 @@ export class MondayService { cursor = page.cursor } while (cursor !== null) - return items.map((item) => { - const porId = new Map(item.column_values.map((cv) => [cv.id, cv])) - const precio = colPrecio ? Number(porId.get(colPrecio.id)?.text ?? '') : NaN - return { - id: item.id, - nombre: item.name, - createdAt: item.created_at, - etapa: porId.get(colEstado.id)?.text ?? '', - unidadCodigo: colUnidad ? (porId.get(colUnidad.id)?.text ?? '') : '', - precioFinal: Number.isFinite(precio) ? precio : 0, - } - }) + 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'), + } + } + + /** + * 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) ---------- @@ -963,31 +1033,6 @@ export class MondayService { } } - /** Item en el tablero Leads (Estado NUEVO; captura rápida del vendedor) */ - async crearLead(datos: DatosLead): Promise { - const boardId = config.monday.boards.leads - const columnValues = await this.construirColumnValues(boardId, { - Estado: 'NUEVO', - Teléfono: datos.telefono, - Email: datos.email, - Origen: datos.origenLabel, - Desarrollo: datos.desarrolloLabel, - Notas: datos.notas, - 'Modelo de Interés': datos.modeloLabel, - '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 Economía': datos.tipoEconomiaLabel, - Califica: datos.calificaLabel, - 'Lo Quiere Para': datos.loQuierePara, - }) - 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. @@ -1066,10 +1111,10 @@ export class MondayService { return abiertas } - /** Asigna un vendedor (user ID de Monday) a la columna people "Vendedor" de un lead */ - async asignarVendedorALead(itemId: string, userId: number): Promise { - const boardId = config.monday.boards.leads - const col = await this.columnaId(boardId, 'Vendedor') + /** Asigna un asesor (user ID de Monday) a la columna people "Asesor" de una oportunidad */ + async asignarAsesorAOportunidad(itemId: string, userId: number): Promise { + const boardId = config.monday.boards.oportunidades + const col = await this.columnaId(boardId, 'Asesor') 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 } @@ -1141,10 +1186,25 @@ export interface DatosOportunidad { } export interface DatosOportunidadAfluencia { - nombre: string // name del item = nombre del lead + nombre: string // name del item = nombre del interesado 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' + notas?: string | undefined // long_text 'COMENTARIOS' } export interface DatosContacto { @@ -1195,27 +1255,6 @@ export interface DatosActividad { leadItemId?: string // item del tablero Leads relacionado (se omite si no aplica) } -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 - // Campos de calificación del interesado (labels reales de las columnas status del board Leads) - modeloLabel?: string | undefined // label de la columna status 'Modelo de Interés' (modelo según desarrollo) - medioContacto?: string | undefined - edad?: number | undefined - estadoCivilLabel?: string | undefined - tipoEmpleoLabel?: string | undefined - ocupacion?: string | undefined - rangoSalarial?: string | undefined - creditoLabel?: string | undefined - tipoEconomiaLabel?: string | undefined - calificaLabel?: string | undefined - loQuierePara?: string | undefined -} - /** Singleton usado por los controllers (los tests instancian el suyo) */ export const mondayService = new MondayService() diff --git a/api-gateway/src/services/reportes.service.test.ts b/api-gateway/src/services/reportes.service.test.ts index a006cda..20b02a1 100644 --- a/api-gateway/src/services/reportes.service.test.ts +++ b/api-gateway/src/services/reportes.service.test.ts @@ -1,25 +1,30 @@ 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 { OportunidadMonday, Unidad } from './monday.service.js' import type { PlanPago } from './cobranza.service.js' // ---------- Fixtures ---------- -function lead(id: string, overrides: Partial = {}): LeadMonday { +/** Afluencia/oportunidad del board Oportunidades (fuente única de la sección leads) */ +function afluencia(id: string, overrides: Partial = {}): OportunidadMonday { return { id, nombre: `Lead ${id}`, createdAt: '2026-08-20T10:00:00Z', updatedAt: '2026-08-20T10:00:00Z', - estado: 'NUEVO', + etapa: 'Afluencia', origen: 'Facebook', - vendedorIds: [], + unidadCodigo: '', + precioFinal: 0, + asesorIds: [], + telefono: '', + email: '', ...overrides, } } function op(id: string, etapa: string): OportunidadMonday { - return { id, nombre: `Cliente ${id}`, createdAt: '2026-08-01T09:00:00Z', etapa, unidadCodigo: 'U-101', precioFinal: 0 } + return afluencia(id, { nombre: `Cliente ${id}`, createdAt: '2026-08-01T09:00:00Z', etapa, unidadCodigo: 'U-101' }) } function unidad(codigo: string, desarrollo: string, status: string, etapa: string | null = null): Unidad { @@ -63,37 +68,37 @@ function pago(id: string, fechaVencimiento: string, monto: number, montoPagado = const AHORA = new Date('2026-08-22T12:00:00') // mediodía local -const DATOS_BASE: DatosResumen = { leads: [], oportunidades: [], unidades: [], planes: [], actividadesAbiertas: 0 } +const DATOS_BASE: DatosResumen = { oportunidades: [], unidades: [], planes: [], actividadesAbiertas: 0 } // ---------- construirResumen (puro) ---------- describe('construirResumen', () => { - it('cuenta leads por estado y por origen', () => { + it('cuenta la afluencia por estado (Etapa) y por origen (Origen del Lead)', () => { const r = construirResumen( { ...DATOS_BASE, - leads: [ - lead('1', { estado: 'NUEVO', origen: 'Facebook' }), - lead('2', { estado: 'NUEVO', origen: 'Facebook' }), - lead('3', { estado: 'CONTACTADO', origen: 'Referido' }), - lead('4', { estado: '', origen: '' }), + oportunidades: [ + afluencia('1', { etapa: 'Afluencia', origen: 'Facebook' }), + afluencia('2', { etapa: 'Afluencia', origen: 'Facebook' }), + afluencia('3', { etapa: 'Contactado', origen: 'Referido' }), + afluencia('4', { etapa: '', origen: '' }), ], }, AHORA, ) expect(r.leads.total).toBe(4) - expect(r.leads.porEstado).toEqual({ NUEVO: 2, CONTACTADO: 1, '(sin dato)': 1 }) + expect(r.leads.porEstado).toEqual({ Afluencia: 2, Contactado: 1, '(sin dato)': 1 }) expect(r.leads.porOrigen).toEqual({ Facebook: 2, Referido: 1, '(sin dato)': 1 }) }) - it('cuenta leads nuevos de hoy y de los últimos 7 días (fecha local)', () => { + it('cuenta afluencias nuevas de hoy y de los últimos 7 días (fecha local)', () => { const r = construirResumen( { ...DATOS_BASE, - leads: [ - lead('1', { createdAt: '2026-08-22T08:00:00' }), // hoy - lead('2', { createdAt: '2026-08-18T23:00:00' }), // esta semana - lead('3', { createdAt: '2026-08-10T12:00:00' }), // hace 12 días + oportunidades: [ + afluencia('1', { createdAt: '2026-08-22T08:00:00' }), // hoy + afluencia('2', { createdAt: '2026-08-18T23:00:00' }), // esta semana + afluencia('3', { createdAt: '2026-08-10T12:00:00' }), // hace 12 días ], }, AHORA, @@ -218,10 +223,10 @@ describe('construirResumen', () => { const r = construirResumen( { ...DATOS_BASE, - leads: [ - lead('1', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', credito: 'Bancario', califica: 'Si' }), - lead('2', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', rangoSalarial: '20-30 mil' }), - lead('3', { estadoCivil: '', desarrollo: ' ' }), // sin dato: no cuenta en ninguna dimensión + oportunidades: [ + afluencia('1', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', credito: 'Bancario', califica: 'Sí' }), + afluencia('2', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', rangoSalarial: '20-30 mil' }), + afluencia('3', { estadoCivil: '', desarrollo: ' ' }), // sin dato: no cuenta en ninguna dimensión ], }, AHORA, @@ -229,7 +234,7 @@ describe('construirResumen', () => { expect(r.leads.perfil.porEstadoCivil).toEqual({ Soltero: 2 }) expect(r.leads.perfil.porDesarrollo).toEqual({ ATELIER: 2 }) expect(r.leads.perfil.porCredito).toEqual({ Bancario: 1 }) - expect(r.leads.perfil.porCalifica).toEqual({ Si: 1 }) + expect(r.leads.perfil.porCalifica).toEqual({ Sí: 1 }) expect(r.leads.perfil.porRangoSalarial).toEqual({ 'Hasta $30k MXN': 1 }) // '20-30 mil' → 25k // Dimensiones sin ningún dato van como objeto vacío expect(r.leads.perfil.porModelo).toEqual({}) @@ -243,16 +248,16 @@ describe('construirResumen', () => { const r = construirResumen( { ...DATOS_BASE, - leads: [ - lead('1', { rangoSalarial: '16000' }), // Hasta $30k MXN - lead('2', { rangoSalarial: '$45K' }), // $30k–$60k MXN - lead('3', { rangoSalarial: '85,000.00 PESOS' }), // $60k–$100k MXN - lead('4', { rangoSalarial: '100,000.00-120,000.00' }), // Más de $100k MXN (promedio 110k) - lead('5', { rangoSalarial: '$2,5K dlls' }), // Hasta $3k USD (2,500) - lead('6', { rangoSalarial: '4,500 DLLS' }), // $3k–$5k USD - lead('7', { rangoSalarial: '8,000.000 DLLS' }), // Más de $5k USD (8,000) - lead('8', { rangoSalarial: 'NO QUISO DECIR' }), // sin monto: se salta - lead('9', { rangoSalarial: '' }), // vacío: se salta + oportunidades: [ + afluencia('1', { rangoSalarial: '16000' }), // Hasta $30k MXN + afluencia('2', { rangoSalarial: '$45K' }), // $30k–$60k MXN + afluencia('3', { rangoSalarial: '85,000.00 PESOS' }), // $60k–$100k MXN + afluencia('4', { rangoSalarial: '100,000.00-120,000.00' }), // Más de $100k MXN (promedio 110k) + afluencia('5', { rangoSalarial: '$2,5K dlls' }), // Hasta $3k USD (2,500) + afluencia('6', { rangoSalarial: '4,500 DLLS' }), // $3k–$5k USD + afluencia('7', { rangoSalarial: '8,000.000 DLLS' }), // Más de $5k USD (8,000) + afluencia('8', { rangoSalarial: 'NO QUISO DECIR' }), // sin monto: se salta + afluencia('9', { rangoSalarial: '' }), // vacío: se salta ], }, AHORA, @@ -272,16 +277,16 @@ describe('construirResumen', () => { const r = construirResumen( { ...DATOS_BASE, - leads: [ - lead('1', { edad: 18 }), // 15–24 - lead('2', { edad: 24 }), // 15–24 - lead('3', { edad: 25 }), // 25–34 - lead('4', { edad: 39 }), // 35–44 - lead('5', { edad: 50 }), // 45–54 - lead('6', { edad: 55 }), // 55+ - lead('7', { edad: 70 }), // 55+ - lead('8', { edad: null }), // sin dato: se salta - lead('9'), // edad undefined: se salta + oportunidades: [ + afluencia('1', { edad: 18 }), // 15–24 + afluencia('2', { edad: 24 }), // 15–24 + afluencia('3', { edad: 25 }), // 25–34 + afluencia('4', { edad: 39 }), // 35–44 + afluencia('5', { edad: 50 }), // 45–54 + afluencia('6', { edad: 55 }), // 55+ + afluencia('7', { edad: 70 }), // 55+ + afluencia('8', { edad: null }), // sin dato: se salta + afluencia('9'), // edad undefined: se salta ], }, AHORA, @@ -295,14 +300,10 @@ describe('construirResumen', () => { describe('ReportesService', () => { function mondayMock() { return { - llamadas: { leads: 0, oportunidades: 0, unidades: 0, actividades: 0 }, - async getLeads(): Promise { - this.llamadas.leads++ - return [lead('1')] - }, + llamadas: { oportunidades: 0, unidades: 0, actividades: 0 }, async getOportunidades(): Promise { this.llamadas.oportunidades++ - return [op('1', 'Aparto')] + return [afluencia('1')] }, async getUnidades(): Promise { this.llamadas.unidades++ @@ -315,15 +316,16 @@ describe('ReportesService', () => { } } - it('agrega todas las fuentes en una sola respuesta', async () => { + it('agrega todas las fuentes en una sola respuesta (una sola llamada a Oportunidades)', async () => { const monday = mondayMock() const svc = new ReportesService(monday, async () => [plan('a', { pagos: [pago('p1', '2026-09-01', 50000)] })]) const r = await svc.resumen() - expect(r.leads.total).toBe(1) + expect(r.leads.total).toBe(1) // la sección leads se alimenta de Oportunidades 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.oportunidades).toBe(1) // sin llamada duplicada }) it('cachea 60 s: la segunda llamada no vuelve a Monday ni a Prisma', async () => { @@ -336,14 +338,13 @@ describe('ReportesService', () => { await svc.resumen() await svc.resumen() - expect(monday.llamadas.leads).toBe(1) expect(monday.llamadas.oportunidades).toBe(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.leads).toBe(2) + expect(monday.llamadas.oportunidades).toBe(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 830a21c..b99c5bb 100644 --- a/api-gateway/src/services/reportes.service.ts +++ b/api-gateway/src/services/reportes.service.ts @@ -1,11 +1,12 @@ import { statusPago, statusPlan, type PlanPago } from './cobranza.service.js' -import type { LeadMonday, MondayService, OportunidadMonday, Unidad } from './monday.service.js' +import type { MondayService, OportunidadMonday, Unidad } from './monday.service.js' import { ETAPAS_PIPELINE } from './etapas.js' /** * Agregador de reportes para la app de tableros (/dash/). - * En UNA respuesta junta: leads (Monday), oportunidades (Monday), unidades - * (Monday), cobranza (Prisma local) y actividades abiertas (Monday). + * En UNA respuesta junta: afluencia/perfil (Monday, tablero Oportunidades — la + * afluencia ES la oportunidad en etapa 'Afluencia'), oportunidades (Monday), + * unidades (Monday), cobranza (Prisma local) y actividades abiertas (Monday). * Cache en memoria de 60 s para no martillar la API de Monday. */ @@ -75,7 +76,8 @@ export interface ResumenReportes { /** Datos crudos que necesita el agregador (inyectables en tests) */ export interface DatosResumen { - leads: LeadMonday[] + // La sección `leads` (afluencia + perfilamiento) y la sección `oportunidades` + // se alimentan de la MISMA fuente: el tablero Oportunidades oportunidades: OportunidadMonday[] unidades: Unidad[] planes: PlanPago[] // dominio de cobranza (con pagos) @@ -162,8 +164,8 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor const hoy = inicioDelDia(ahora) const hace7dias = new Date(hoy.getTime() - 7 * 24 * 60 * 60 * 1000) const creadosEn = (desde: Date): number => - datos.leads.filter((l) => { - const t = Date.parse(l.createdAt) + datos.oportunidades.filter((o) => { + const t = Date.parse(o.createdAt) return !Number.isNaN(t) && t >= desde.getTime() }).length @@ -219,29 +221,31 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor return { generadoEn: ahora.toISOString(), + // Sección "leads" = afluencia sobre el tablero Oportunidades (modelo + // nuevo: la afluencia ES la oportunidad en etapa 'Afluencia') leads: { - total: datos.leads.length, + total: datos.oportunidades.length, nuevosHoy: creadosEn(hoy), nuevosSemana: creadosEn(hace7dias), - porEstado: contarPor(datos.leads.map((l) => l.estado)), - porOrigen: contarPor(datos.leads.map((l) => l.origen)), + porEstado: contarPor(datos.oportunidades.map((o) => o.etapa)), + porOrigen: contarPor(datos.oportunidades.map((o) => o.origen)), perfil: { - porDesarrollo: contarPorNoVacios(datos.leads.map((l) => l.desarrollo ?? '')), - porModelo: contarPorNoVacios(datos.leads.map((l) => l.modeloInteres ?? '')), - porEstadoCivil: contarPorNoVacios(datos.leads.map((l) => l.estadoCivil ?? '')), - porTipoEmpleo: contarPorNoVacios(datos.leads.map((l) => l.tipoEmpleo ?? '')), - porTipoEconomia: contarPorNoVacios(datos.leads.map((l) => l.tipoEconomia ?? '')), - porCalifica: contarPorNoVacios(datos.leads.map((l) => l.califica ?? '')), - porCredito: contarPorNoVacios(datos.leads.map((l) => l.credito ?? '')), + porDesarrollo: contarPorNoVacios(datos.oportunidades.map((o) => o.desarrollo ?? '')), + porModelo: contarPorNoVacios(datos.oportunidades.map((o) => o.modeloInteres ?? '')), + porEstadoCivil: contarPorNoVacios(datos.oportunidades.map((o) => o.estadoCivil ?? '')), + porTipoEmpleo: contarPorNoVacios(datos.oportunidades.map((o) => o.tipoEmpleo ?? '')), + porTipoEconomia: contarPorNoVacios(datos.oportunidades.map((o) => o.tipoEconomia ?? '')), + porCalifica: contarPorNoVacios(datos.oportunidades.map((o) => o.califica ?? '')), + porCredito: contarPorNoVacios(datos.oportunidades.map((o) => o.credito ?? '')), porRangoSalarial: contarPorNoVacios( - datos.leads - .map((l) => bucketSalario(l.rangoSalarial ?? '')) + datos.oportunidades + .map((o) => bucketSalario(o.rangoSalarial ?? '')) .filter((b): b is string => b !== null), ), - porMedioContacto: contarPorNoVacios(datos.leads.map((l) => l.medioContacto ?? '')), - porLoQuierePara: contarPorNoVacios(datos.leads.map((l) => l.loQuierePara ?? '')), + porMedioContacto: contarPorNoVacios(datos.oportunidades.map((o) => o.medioContacto ?? '')), + porLoQuierePara: contarPorNoVacios(datos.oportunidades.map((o) => o.loQuierePara ?? '')), porEdad: contarPorNoVacios( - datos.leads.filter((l) => l.edad != null).map((l) => bucketEdad(l.edad!)), + datos.oportunidades.filter((o) => o.edad != null).map((o) => bucketEdad(o.edad!)), ), }, }, @@ -273,7 +277,7 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor // ---------- Servicio con cache ---------- -type MondayReportes = Pick +type MondayReportes = Pick /** Carga los planes de cobranza en forma de dominio (Prisma en prod, fixture en tests) */ export type CargadorPlanes = () => Promise @@ -309,14 +313,13 @@ export class ReportesService { if (this.cache && this.cache.expira > ahoraMs) return this.cache.resumen // 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(), + const [oportunidades, unidades, actividadesAbiertas, planes] = await Promise.all([ this.monday.getOportunidades(), this.monday.getUnidades(), this.monday.getActividadesAbiertas(), this.cargarPlanes(), ]) - const resumen = construirResumen({ leads, oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs)) + const resumen = construirResumen({ oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs)) this.cache = { resumen, expira: ahoraMs + this.cacheMs } return resumen } diff --git a/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx b/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx index 1bc81b0..67f145f 100644 --- a/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx +++ b/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx @@ -206,7 +206,7 @@ describe('AltaAfluenciaForm', () => { }) it('tras el éxito, "Pasar a Alta de Cliente" entrega el lead recién creado', async () => { - fetchMock.mockResolvedValueOnce(respuesta({ id: 'l-1', estado: 'NUEVO', oportunidadId: 'op-1' })) + fetchMock.mockResolvedValueOnce(respuesta({ id: 'op-1', estado: 'Afluencia', oportunidadId: 'op-1' })) const onPasar = vi.fn() render( undefined} onPasarAAltaCliente={onPasar} />) llenarRequeridos() @@ -216,12 +216,13 @@ describe('AltaAfluenciaForm', () => { fireEvent.click(await screen.findByRole('button', { name: /Pasar a Alta de Cliente/ })) expect(onPasar).toHaveBeenCalledWith({ - id: 'l-1', + id: 'op-1', nombre: 'Ana Demo', telefono: '6641234567', email: 'ana@correo.com', - estado: 'NUEVO', + estado: 'Afluencia', origen: '', + fuente: 'oportunidad', // la captura nueva vive en Oportunidades }) }) }) diff --git a/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx b/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx index ee8f3e8..f355ab0 100644 --- a/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx +++ b/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx @@ -122,6 +122,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro email: form.email.trim(), estado: res.lead.estado, origen: form.origen, + fuente: 'oportunidad', // la captura nueva vive en el tablero Oportunidades }) setAsignacion(res.lead.asignacion ?? 'round-robin') } else if (res.motivo === 'duplicado') { diff --git a/apps/alta-clientes/src/components/BusquedaLead.test.tsx b/apps/alta-clientes/src/components/BusquedaLead.test.tsx index 4512c62..fd9512b 100644 --- a/apps/alta-clientes/src/components/BusquedaLead.test.tsx +++ b/apps/alta-clientes/src/components/BusquedaLead.test.tsx @@ -6,8 +6,8 @@ import type { LeadResumen } from '../services/api' type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise const LEADS: LeadResumen[] = [ - { id: 'lead-1', nombre: 'MARÍA FERNANDA LÓPEZ', telefono: '4771234567', email: 'maria@correo.com', estado: 'NUEVO', origen: 'Web' }, - { id: 'lead-2', nombre: 'Juan Pérez', telefono: '', email: 'juan@correo.com', estado: 'CONTACTADO', origen: '' }, + { id: 'lead-1', nombre: 'MARÍA FERNANDA LÓPEZ', telefono: '4771234567', email: 'maria@correo.com', estado: 'NUEVO', origen: 'Web', fuente: 'lead' }, + { id: 'op-2', nombre: 'Juan Pérez', telefono: '', email: 'juan@correo.com', estado: 'Afluencia', origen: '', fuente: 'oportunidad' }, ] const respuestaOk = (data: unknown) => @@ -35,13 +35,16 @@ describe('BusquedaLead', () => { expect(llamadasBusqueda()).toHaveLength(0) }) - it('renderiza los resultados de la búsqueda (nombre, teléfono, estado)', async () => { + it('renderiza los resultados de la búsqueda (nombre, teléfono, estado) con insignia de fuente', async () => { render( undefined} />) fireEvent.change(screen.getByLabelText(/Buscar lead/), { target: { value: 'maria' } }) expect(await screen.findByText('MARÍA FERNANDA LÓPEZ')).toBeTruthy() expect(screen.getByText(/4771234567 · NUEVO/)).toBeTruthy() expect(screen.getByText('Juan Pérez')).toBeTruthy() + // Insignias: gris "Lead" (histórico) y azul "Oportunidad" (modelo nuevo) + expect(screen.getByText('Lead')).toBeTruthy() + expect(screen.getByText('Oportunidad')).toBeTruthy() const [url] = llamadasBusqueda()[0] as [string] expect(url).toContain('/leads/buscar?q=maria') }) diff --git a/apps/alta-clientes/src/components/BusquedaLead.tsx b/apps/alta-clientes/src/components/BusquedaLead.tsx index 54365d0..582a6ae 100644 --- a/apps/alta-clientes/src/components/BusquedaLead.tsx +++ b/apps/alta-clientes/src/components/BusquedaLead.tsx @@ -8,8 +8,9 @@ interface Props { } /** - * Buscador con debounce de leads de Monday (GET /leads/buscar). No llama al - * API con menos de 2 caracteres; al elegir un resultado avisa con onSeleccion. + * Buscador con debounce de leads/afluencias de Monday (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. */ export default function BusquedaLead({ onSeleccion }: Props) { const [q, setQ] = useState('') @@ -71,13 +72,25 @@ export default function BusquedaLead({ onSeleccion }: Props) { {resultados !== null && resultados.length > 0 && (
    {resultados.map((lead) => ( -
  • +