refactor: las afluencias viven en Oportunidades (no en Leads)

- POST /api/leads crea UN solo item en Oportunidades (Estado Afluencia)
  con todo el perfil (desarrollo, modelo, estado civil, credito, etc.)
  en las columnas del board; dedup por telefono contra Oportunidades;
  asignacion al capturista via columna Asesor o round-robin del equipo.
- Round-robin, escalado e inactividad operan sobre Oportunidades (solo
  etapa Afluencia sin asesor entra al reparto).
- Acciones del vendedor (contactado/cita/descartar) escriben directo en
  la oportunidad; descarte = Perdido + Motivo No Compra.
- Mi dia y reportes leen Oportunidades; el funnel de /dash arranca en
  Afluencia; misOportunidades.porEtapa ya es real (columna Asesor).
- El picker del alta busca en Leads (historico) Y Oportunidades, con
  insignia de origen; moverAAparto tolera ambos ids.
- Lectura de status con value null = vacio (quirk del default Monday).
- Script de migracion del historial de agosto Leads -> Oportunidades.
This commit is contained in:
2026-09-04 04:46:34 +00:00
parent d4fc9ee892
commit 845f314560
26 changed files with 1496 additions and 985 deletions

View File

@@ -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()

View File

@@ -160,8 +160,11 @@ const hoyMas = (dias: number): string => {
// ---------- Helpers compartidos (alta directa y alta progresiva) ---------- // ---------- 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'. * 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:<opId>` ANTES del * moverAApartoConDedup escribe el dedup `apartado-creado:<opId>` ANTES del
* cambio de etapa, para que el webhook/cron no cree un Apartado duplicado. * cambio de etapa, para que el webhook/cron no cree un Apartado duplicado.
* Un fallo aquí no rompe el alta. * Un fallo aquí no rompe el alta.

View File

@@ -9,8 +9,7 @@ import {
descartarLead, descartarLead,
ESTADOS_LEAD_PERMITIDOS, ESTADOS_LEAD_PERMITIDOS,
} from './leads.controller.js' } from './leads.controller.js'
import { mondayService, type LeadMonday } from '../services/monday.service.js' import { mondayService, type LeadMonday, type OportunidadMonday } from '../services/monday.service.js'
import { funnelService } from '../services/funnel.service.js'
import { automatizacionesService } from '../services/automatizaciones.service.js' import { automatizacionesService } from '../services/automatizaciones.service.js'
// ---------- Prisma mockeado (los tests no tocan la BD) ---------- // ---------- Prisma mockeado (los tests no tocan la BD) ----------
@@ -19,6 +18,9 @@ const prismaMock = vi.hoisted(() => ({
leadCaptura: { leadCaptura: {
create: vi.fn(), create: vi.fn(),
}, },
leadOportunidad: {
create: vi.fn(),
},
usuario: { usuario: {
findUnique: vi.fn(), findUnique: vi.fn(),
}, },
@@ -30,37 +32,38 @@ const prismaMock = vi.hoisted(() => ({
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) 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 */ /** Monday mockeado: registra escrituras y devuelve catálogos controlados */
function mondayMock( function mondayMock(
opts: { opts: {
labelsOrigen?: string[] labelsOrigen?: string[]
labelsDesarrollo?: string[] labelsDesarrollo?: string[]
labelsMotivo?: string[] labelsMotivo?: string[]
duplicado?: LeadMonday | null duplicado?: OportunidadMonday | null
} = {}, } = {},
) { ) {
return { return {
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[], cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
motivos: [] as { nombre: string; categoria: string; descripcion?: string }[], motivos: [] as { nombre: string; categoria: string; descripcion?: string }[],
leadsCreados: [] as Record<string, unknown>[], oportunidadesCreadas: [] as Record<string, unknown>[],
movimientosGrupo: [] as { itemId: string; groupId: string }[],
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) { async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
this.cambios.push({ boardId, itemId, columna, valor }) this.cambios.push({ boardId, itemId, columna, valor })
}, },
async crearLead(datos: Record<string, unknown>) { async crearOportunidadAfluencia(datos: Record<string, unknown>) {
this.leadsCreados.push(datos) this.oportunidadesCreadas.push(datos)
return 'lead-nuevo-1' return 'op-nueva-1'
}, },
async buscarLeadPorTelefono(_telefono: string) { async buscarOportunidadPorTelefono(_telefono: string) {
return opts.duplicado ?? null return opts.duplicado ?? null
}, },
async labelsDeColumna(_boardId: string, titulo: string) { async labelsDeColumna(_boardId: string, titulo: string) {
if (titulo === 'Desarrollo') { if (titulo === 'Desarrollo') return opts.labelsDesarrollo ?? LABELS_DESARROLLO
return opts.labelsDesarrollo ?? ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'] if (titulo === 'Motivo No Compra') return opts.labelsMotivo ?? LABELS_MOTIVO
} return opts.labelsOrigen ?? LABELS_ORIGEN
if (titulo === 'Motivo No Compra') {
return opts.labelsMotivo ?? ['Precio', 'Producto', 'Ubicacion', 'Financiamiento', 'Competencia', 'Cliente', 'Timing']
}
return opts.labelsOrigen ?? ['WhatsApp', 'Web', 'Recomendado', 'Facebook', 'Otro']
}, },
async obtenerNombreItem(_itemId: string) { async obtenerNombreItem(_itemId: string) {
return 'Lead Prueba' return 'Lead Prueba'
@@ -69,46 +72,70 @@ function mondayMock(
this.motivos.push(datos) this.motivos.push(datos)
return 'motivo-item-1' return 'motivo-item-1'
}, },
} // Para moverAGrupoSegunEstado tras la creación: el item nace en el grupo
} // top del tablero (como en Monday real) con Estado 'Afluencia'
async columnaIdPorTitulo(_boardId: string, _titulo: string) {
/** Funnel mockeado: registra asegurados/movimientos; configurable para fallar */ return 'col_estado'
function funnelMock(opts: { oportunidadId?: string | null; fallaAsegurar?: boolean } = {}) { },
async getItemConGrupo(itemId: string) {
return { return {
asegurados: [] as Record<string, unknown>[], id: itemId,
movimientos: [] as { leadId: string; etapa: string }[], name: 'Lead Prueba',
async asegurarOportunidadAfluencia(lead: Record<string, unknown>) { groupId: 'group_top',
if (opts.fallaAsegurar === true) throw new Error('Monday caído') column_values: [{ id: 'col_estado', text: 'Afluencia', value: '{"index":1}' }],
this.asegurados.push(lead) }
return 'op-1'
}, },
async moverOportunidadDeLead(leadId: string, etapa: string) { async moverItemAGrupo(itemId: string, groupId: string) {
this.movimientos.push({ leadId, etapa }) this.movimientosGrupo.push({ itemId, groupId })
return true
},
async oportunidadDeLead(_leadId: string) {
return opts.oportunidadId === undefined ? 'op-1' : opts.oportunidadId
}, },
} }
} }
const datosBase = { nombre: 'Lead Prueba', telefono: '4771234567' } const datosBase = { nombre: 'Lead Prueba', telefono: '4771234567' }
describe('crearLead (POST /api/leads)', () => { const oportunidadMonday = (overrides: Partial<OportunidadMonday>): OportunidadMonday => ({
it('crea el item en Monday con Estado NUEVO y devuelve su id', async () => { 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 monday = mondayMock()
const r = await crearLead(monday, { ...datosBase, origen: 'Facebook', notas: 'Vio el volante' }) const r = await crearLead(monday, { ...datosBase, origen: 'Facebook', notas: 'Vio el volante' })
expect(r).toEqual({ id: 'lead-nuevo-1', oportunidadId: null }) expect(r).toEqual({ id: 'op-nueva-1', oportunidadId: 'op-nueva-1' })
expect(monday.leadsCreados).toEqual([ expect(monday.oportunidadesCreadas).toEqual([
{ {
nombre: 'Lead Prueba', nombre: 'Lead Prueba',
telefono: '4771234567', telefono: '4771234567',
email: undefined, email: undefined,
origenLabel: 'Facebook',
desarrolloLabel: undefined, 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', 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 () => { 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', email: 'lead@correo.com',
desarrollo: 'SENDA', desarrollo: 'SENDA',
}) })
expect(r).toEqual({ id: 'lead-nuevo-1', oportunidadId: null }) expect(r).toEqual({ id: 'op-nueva-1', oportunidadId: 'op-nueva-1' })
expect(monday.leadsCreados[0]).toMatchObject({ email: 'lead@correo.com', desarrolloLabel: 'SENDA' }) 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 () => { 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í', califica: 'Sí',
loQuierePara: 'Vivir ahí', loQuierePara: 'Vivir ahí',
}) })
expect(r).toEqual({ id: 'lead-nuevo-1', oportunidadId: null }) expect(r).toEqual({ id: 'op-nueva-1', oportunidadId: 'op-nueva-1' })
expect(monday.leadsCreados[0]).toMatchObject({ expect(monday.oportunidadesCreadas[0]).toMatchObject({
modeloLabel: 'HIMALAYA', modeloLabel: 'HIMALAYA',
medioContacto: 'WhatsApp', medioContacto: 'WhatsApp',
edad: 35, 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 () => { it('duplicado por teléfono contra Oportunidades: no crea nada y devuelve el item existente', async () => {
const monday = mondayMock() const existente = oportunidadMonday({ id: 'op-vieja-9', telefono: '4771234567' })
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: [],
}
const monday = mondayMock({ duplicado: existente }) const monday = mondayMock({ duplicado: existente })
const funnel = funnelMock() const r = await crearLead(monday, datosBase)
const r = await crearLead(monday, datosBase, funnel)
expect(r).toEqual({ duplicado: existente }) expect(r).toEqual({ duplicado: existente })
expect(monday.leadsCreados).toEqual([]) expect(monday.oportunidadesCreadas).toEqual([])
expect(funnel.asegurados).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() const monday = mondayMock()
await expect(crearLead(monday, { ...datosBase, desarrollo: 'MONTEBELLO' })).rejects.toThrow( await expect(crearLead(monday, { ...datosBase, desarrollo: 'MONTEBELLO' })).rejects.toThrow(
/Desarrollo inválido/, /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() const monday = mondayMock()
await expect(crearLead(monday, { ...datosBase, origen: 'Volanteo' })).rejects.toThrow(/Origen inválido/) 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)', () => { describe('cambiarEstadoLead (POST /api/leads/:itemId/estado)', () => {
it('la whitelist solo permite las transiciones del vendedor', () => { it('la whitelist solo permite las transiciones del vendedor', () => {
expect(ESTADOS_LEAD_PERMITIDOS).toEqual(['CONTACTADO', 'Intento de contacto', 'AGENDA CITA', 'CALIFICADO']) 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('DESCARTADO')
expect(ESTADOS_LEAD_PERMITIDOS).not.toContain('NUEVO') 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() const monday = mondayMock()
await cambiarEstadoLead(monday, 'lead-1', 'CONTACTADO') await cambiarEstadoLead(monday, 'op-1', 'CONTACTADO')
expect(monday.cambios).toEqual([{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: '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() 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([ expect(monday.cambios).toEqual([
{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'AGENDA CITA' }, { boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Agendo Cita' },
{ boardId: '444', itemId: 'lead-1', columna: 'Fecha', valor: '2026-09-01' }, { boardId: '555', itemId: 'op-1', columna: 'Fecha', valor: '2026-09-01' },
]) ])
}) })
it('AGENDA CITA sin fecha no toca la columna Fecha', async () => { it('AGENDA CITA sin fecha no toca la columna Fecha', async () => {
const monday = mondayMock() const monday = mondayMock()
await cambiarEstadoLead(monday, 'lead-1', 'AGENDA CITA') await cambiarEstadoLead(monday, 'op-1', 'AGENDA CITA')
expect(monday.cambios).toHaveLength(1) 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 monday = mondayMock()
const funnel = funnelMock() await cambiarEstadoLead(monday, 'op-1', 'CALIFICADO')
await cambiarEstadoLead(monday, 'lead-1', 'AGENDA CITA', '2026-09-01', funnel) expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Calificado' }])
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' }])
}) })
}) })
describe('descartarLead (POST /api/leads/:itemId/descartar)', () => { 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 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(motivoItemId).toBe('motivo-item-1')
expect(monday.cambios).toEqual([ expect(monday.cambios).toEqual([
{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'DESCARTADO' }, { boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Perdido' },
{ boardId: '444', itemId: 'lead-1', columna: 'Notas', valor: 'Se le hizo caro' }, { 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([ expect(monday.motivos).toEqual([
{ nombre: 'Precio — Lead Prueba', categoria: 'Precio', descripcion: 'Se le hizo caro' }, { 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() const monday = mondayMock()
await descartarLead(monday, 'lead-1', 'Timing') await descartarLead(monday, 'op-1', 'Timing')
expect(monday.cambios).toEqual([{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'DESCARTADO' }]) 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' }) 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 () => { it('propaga el error si Monday falla (el error handler responde 5xx, no truena el server)', async () => {
const monday = mondayMock() const monday = mondayMock()
vi.spyOn(monday, 'cambiarValorColumna').mockRejectedValueOnce(new Error('Monday caído')) 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([]) 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 () => { it('si el motivo no es label del dropdown, solo mueve a Perdido (sin dropdown)', async () => {
const monday = mondayMock({ labelsMotivo: ['Otro'] }) const monday = mondayMock({ labelsMotivo: ['Otro'] })
const funnel = funnelMock({ oportunidadId: 'op-9' }) await descartarLead(monday, 'op-1', 'Precio')
await descartarLead(monday, 'lead-1', 'Precio', undefined, funnel) expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Perdido' }])
expect(monday.cambios).toContainEqual({ boardId: '555', itemId: 'op-9', columna: 'Estado', valor: 'Perdido' })
expect(monday.cambios.find((c) => c.columna === 'Motivo No Compra')).toBeUndefined() expect(monday.cambios.find((c) => c.columna === 'Motivo No Compra')).toBeUndefined()
}) expect(monday.motivos).toHaveLength(1) // el registro en el catálogo se crea igual
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' })
}) })
}) })
describe('crearLeadEndpoint (POST /api/leads)', () => { describe('crearLeadEndpoint (POST /api/leads)', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
// El singleton Monday responde como si creara el lead; el funnel liga la oportunidad // El singleton Monday responde como si creara la afluencia en Oportunidades
vi.spyOn(mondayService, 'buscarLeadPorTelefono').mockResolvedValue(null) vi.spyOn(mondayService, 'buscarOportunidadPorTelefono').mockResolvedValue(null)
vi.spyOn(mondayService, 'crearLead').mockResolvedValue('lead-nuevo-9') vi.spyOn(mondayService, 'crearOportunidadAfluencia').mockResolvedValue('op-nueva-9')
vi.spyOn(funnelService, 'asegurarOportunidadAfluencia').mockResolvedValue('op-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) vi.spyOn(automatizacionesService, 'asignarACapturista').mockResolvedValue(undefined)
// Default: el capturista NO tiene Monday vinculado → asignación round-robin // Default: el capturista NO tiene Monday vinculado → asignación round-robin
prismaMock.usuario.findUnique.mockResolvedValue(null) prismaMock.usuario.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.create.mockResolvedValue({})
prismaMock.leadOportunidad.create.mockResolvedValue({})
prismaMock.automatizacionState.findUnique.mockResolvedValue(null) prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({}) prismaMock.automatizacionState.upsert.mockResolvedValue({})
}) })
@@ -355,27 +329,30 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
return { res: { json, status } as unknown as Response, status, json } return { res: { json, status } as unknown as Response, status, json }
} }
it('201: registra la captura local con el usuario del token', async () => { it('201: registra la captura local y el auto-mapping con el usuario del token', async () => {
prismaMock.leadCaptura.create.mockResolvedValue({})
const { res, status, json } = resFake() const { res, status, json } = resFake()
await crearLeadEndpoint(reqFake(), res) await crearLeadEndpoint(reqFake(), res)
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: { data: {
leadId: 'lead-nuevo-9', leadId: 'op-nueva-9',
oportunidadId: 'op-9', oportunidadId: 'op-nueva-9',
usuarioId: 'u-1', usuarioId: 'u-1',
nombreLead: 'Lead Prueba', nombreLead: 'Lead Prueba',
telefono: '4771234567', 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(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 () => { it('con Monday vinculado, la afluencia queda asignada al capturista (columna Asesor)', async () => {
prismaMock.leadCaptura.create.mockResolvedValue({})
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 }) prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 })
const { res, status, json } = resFake() const { res, status, json } = resFake()
@@ -383,15 +360,14 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
// Bandera anti-carrera ANTES de asignar + asignación al capturista // Bandera anti-carrera ANTES de asignar + asignación al capturista
expect(prismaMock.automatizacionState.upsert).toHaveBeenCalledWith( 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(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 () => { 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 }) prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 })
vi.spyOn(automatizacionesService, 'asignarACapturista').mockRejectedValue(new Error('Monday caído')) vi.spyOn(automatizacionesService, 'asignarACapturista').mockRejectedValue(new Error('Monday caído'))
const espia = vi.spyOn(console, 'error').mockImplementation(() => {}) const espia = vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -400,7 +376,7 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
await crearLeadEndpoint(reqFake(), res) await crearLeadEndpoint(reqFake(), res)
expect(status).toHaveBeenCalledWith(201) 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() espia.mockRestore()
}) })
@@ -410,10 +386,12 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
await crearLeadEndpoint(reqFake({ usuarioToken: undefined }), res) await crearLeadEndpoint(reqFake({ usuarioToken: undefined }), res)
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled() 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) 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')) prismaMock.leadCaptura.create.mockRejectedValue(new Error('SQLite bloqueado'))
const espia = vi.spyOn(console, 'error').mockImplementation(() => {}) const espia = vi.spyOn(console, 'error').mockImplementation(() => {})
const { res, status } = resFake() 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>): LeadMonday => ({ const lead = (overrides: Partial<LeadMonday>): LeadMonday => ({
id: 'lead-1', id: 'lead-1',
nombre: 'Lead Prueba', nombre: 'Lead Prueba',
@@ -439,31 +417,47 @@ describe('buscarLeads (GET /api/leads/buscar)', () => {
...overrides, ...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 = [ const leads = [
lead({ id: 'a', nombre: 'MARÍA FERNANDA LÓPEZ' }), lead({ id: 'a', nombre: 'MARÍA FERNANDA LÓPEZ' }),
lead({ id: 'b', telefono: '477 123 4567' }), lead({ id: 'b', telefono: '477 123 4567' }),
lead({ id: 'c', email: 'Cliente@Correo.com' }),
lead({ id: 'd', nombre: 'Otro', telefono: '333', email: '' }), lead({ id: 'd', nombre: 'Otro', telefono: '333', email: '' }),
] ]
expect(buscarLeads(leads, 'fernanda').map((l) => l.id)).toEqual(['a']) const ops = [
expect(buscarLeads(leads, '123').map((l) => l.id)).toEqual(['b']) oportunidadMonday({ id: 'op-c', email: 'Cliente@Correo.com' }),
expect(buscarLeads(leads, 'cliente@').map((l) => l.id)).toEqual(['c']) 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' })] const leads = [lead({ id: 'a', nombre: 'MARÍA FERNANDA LÓPEZ', email: 'andres@correo.com' })]
expect(buscarLeads(leads, 'maria')).toHaveLength(1) expect(buscarLeads(leads, [], 'maria')).toHaveLength(1)
expect(buscarLeads(leads, 'andrés')).toHaveLength(1) expect(buscarLeads(leads, [], 'andrés')).toHaveLength(1)
}) })
it('ordena por updatedAt descendente y limita a 15', () => { it('ordena por updatedAt descendente (mezclando tableros) y limita a 15', () => {
const leads = Array.from({ length: 20 }, (_, i) => 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` }), 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).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 () => { it('el endpoint responde 400 con q de menos de 2 caracteres', async () => {

View File

@@ -1,22 +1,35 @@
import type { Request, Response } from 'express' import type { Request, Response } from 'express'
import { z } from 'zod' import { z } from 'zod'
import { config } from '../config/index.js' import { config } from '../config/index.js'
import { mondayService, type LeadMonday, type MondayService } from '../services/monday.service.js' import { mondayService, type LeadMonday, type MondayService, type OportunidadMonday } from '../services/monday.service.js'
import { funnelService, type FunnelService } from '../services/funnel.service.js' import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
import { automatizacionesService, CLAVE_RR_SKIP, prismaStateStore } from '../services/automatizaciones.service.js' import { automatizacionesService, CLAVE_RR_SKIP, prismaStateStore } from '../services/automatizaciones.service.js'
import { prisma } from '../services/prisma.js' import { prisma } from '../services/prisma.js'
import { MOTIVOS_NO_COMPRA } from './oportunidades.controller.js' import { MOTIVOS_NO_COMPRA } from './oportunidades.controller.js'
import { AppError } from '../utils/errors.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 * 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 // 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 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({ const crearLeadSchema = z.object({
nombre: z.string().trim().min(1, 'El nombre es obligatorio'), nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
telefono: z.string().trim().min(1).optional(), telefono: z.string().trim().min(1).optional(),
@@ -52,7 +65,7 @@ const descartarSchema = z.object({
comentario: z.string().trim().min(1).optional(), 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 const LIMITE_BUSQUEDA_LEADS = 15
@@ -63,70 +76,109 @@ const sinAcentos = (s: string): string =>
.replace(/[̀-ͯ]/g, '') // diacríticos U+0300–U+036F .replace(/[̀-ͯ]/g, '') // diacríticos U+0300–U+036F
.toLowerCase() .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), * Filtra por q en nombre/teléfono/email (case-insensitive, sin acentos) en
* ordena por actualización reciente y limita a 15 resultados. * 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) const needle = sinAcentos(q)
return leads const candidatos = [
.filter((l) => [l.nombre, l.telefono, l.email].some((campo) => sinAcentos(campo).includes(needle))) ...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)) .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.slice(0, LIMITE_BUSQUEDA_LEADS) .slice(0, LIMITE_BUSQUEDA_LEADS)
.map(({ updatedAt: _updatedAt, ...resto }) => resto)
} }
type MondayLeads = Pick< type MondayLeads = Pick<
MondayService, MondayService,
| 'cambiarValorColumna' | 'cambiarValorColumna'
| 'crearLead' | 'crearOportunidadAfluencia'
| 'crearMotivoNoCompra' | 'crearMotivoNoCompra'
| 'buscarLeadPorTelefono' | 'buscarOportunidadPorTelefono'
| 'labelsDeColumna' | 'labelsDeColumna'
| 'obtenerNombreItem' | '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 * Crea la afluencia en Monday: UN item en el tablero Oportunidades con Estado
* con ese número, no crea nada y devuelve { duplicado } (el endpoint → 409). * 'Afluencia' y todo el perfil capturado. Si viene teléfono y ya existe una
* El origen se valida contra los labels reales de la columna del board. * oportunidad con ese número, no crea nada y devuelve { duplicado } (el
* Con `funnel`, el lead entra además al tablero Oportunidades en 'Afluencia'; * endpoint → 409). Origen (dropdown 'Origen del Lead') y desarrollo (status
* un fallo de Monday en la oportunidad solo se loguea (el lead ya quedó). * '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( export async function crearLead(
monday: MondayLeads, monday: MondayLeads,
datos: z.infer<typeof crearLeadSchema>, datos: z.infer<typeof crearLeadSchema>,
funnel?: Pick<FunnelService, 'asegurarOportunidadAfluencia'>,
): Promise<ResultadoCrearLead> { ): Promise<ResultadoCrearLead> {
const boardLeads = config.monday.boards.leads const boardOportunidades = config.monday.boards.oportunidades
if (datos.telefono !== undefined) { if (datos.telefono !== undefined) {
const existente = await monday.buscarLeadPorTelefono(datos.telefono) const existente = await monday.buscarOportunidadPorTelefono(datos.telefono)
if (existente) return { duplicado: existente } if (existente) return { duplicado: existente }
} }
if (datos.origen !== undefined) { 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)) { if (!labels.includes(datos.origen)) {
throw new AppError(400, `Origen inválido: "${datos.origen}". Labels válidos: ${labels.join(', ')}`) throw new AppError(400, `Origen inválido: "${datos.origen}". Labels válidos: ${labels.join(', ')}`)
} }
} }
if (datos.desarrollo !== undefined) { if (datos.desarrollo !== undefined) {
const labels = await monday.labelsDeColumna(boardLeads, 'Desarrollo') const labels = await monday.labelsDeColumna(boardOportunidades, 'Desarrollo')
if (!labels.includes(datos.desarrollo)) { if (!labels.includes(datos.desarrollo)) {
throw new AppError(400, `Desarrollo inválido: "${datos.desarrollo}". Labels válidos: ${labels.join(', ')}`) 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, nombre: datos.nombre,
telefono: datos.telefono, telefono: datos.telefono,
email: datos.email, email: datos.email,
origenLabel: datos.origen,
desarrolloLabel: datos.desarrollo, desarrolloLabel: datos.desarrollo,
notas: datos.notas, origenLabel: datos.origen,
modeloLabel: datos.modelo, modeloLabel: datos.modelo,
medioContacto: datos.medioContacto, medioContacto: datos.medioContacto,
edad: datos.edad, edad: datos.edad,
@@ -138,98 +190,67 @@ export async function crearLead(
tipoEconomiaLabel: datos.tipoEconomia, tipoEconomiaLabel: datos.tipoEconomia,
calificaLabel: datos.califica, calificaLabel: datos.califica,
loQuierePara: datos.loQuierePara, loQuierePara: datos.loQuierePara,
notas: datos.notas,
}) })
let oportunidadId: string | null = null
if (funnel !== undefined) {
try { try {
oportunidadId = await funnel.asegurarOportunidadAfluencia({ await moverAGrupoSegunEstado(monday, boardOportunidades, id)
id,
nombre: datos.nombre,
telefono: datos.telefono,
email: datos.email,
desarrolloLabel: datos.desarrollo,
})
} catch (err) { } catch (err) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(`No se pudo crear la oportunidad de Afluencia del lead ${id}:`, err) console.error(`No se pudo mover al grupo correcto la oportunidad ${id}:`, err)
} }
} return { id, oportunidadId: id }
return { id, oportunidadId }
} }
/** 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( export async function cambiarEstadoLead(
monday: MondayLeads, monday: MondayLeads,
itemId: string, itemId: string,
estado: (typeof ESTADOS_LEAD_PERMITIDOS)[number], estado: (typeof ESTADOS_LEAD_PERMITIDOS)[number],
fecha?: string, fecha?: string,
funnel?: Pick<FunnelService, 'moverOportunidadDeLead'>,
): Promise<void> { ): Promise<void> {
const boardLeads = config.monday.boards.leads const boardOportunidades = config.monday.boards.oportunidades
await monday.cambiarValorColumna(boardLeads, itemId, 'Estado', estado) await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', LABEL_ESTADO_OPORTUNIDAD[estado])
if (estado === 'AGENDA CITA' && fecha !== undefined) { if (estado === 'AGENDA CITA' && fecha !== undefined) {
await monday.cambiarValorColumna(boardLeads, itemId, 'Fecha', fecha) await monday.cambiarValorColumna(boardOportunidades, 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)
}
} }
} }
/** 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( export async function descartarLead(
monday: MondayLeads, monday: MondayLeads,
itemId: string, itemId: string,
motivo: string, motivo: string,
comentario?: string, comentario?: string,
funnel?: Pick<FunnelService, 'oportunidadDeLead'>,
): Promise<string> { ): Promise<string> {
const boardLeads = config.monday.boards.leads
const nombreItem = await monday.obtenerNombreItem(itemId)
await monday.cambiarValorColumna(boardLeads, itemId, 'Estado', 'DESCARTADO')
if (comentario !== undefined) {
await monday.cambiarValorColumna(boardLeads, itemId, 'Notas', comentario)
}
const motivoItemId = await 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 const boardOportunidades = config.monday.boards.oportunidades
await monday.cambiarValorColumna(boardOportunidades, oportunidadId, 'Estado', 'Perdido') const nombreItem = await monday.obtenerNombreItem(itemId)
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', 'Perdido')
if (comentario !== undefined) {
await monday.cambiarValorColumna(boardOportunidades, itemId, 'COMENTARIOS', comentario)
}
const labels = await monday.labelsDeColumna(boardOportunidades, 'Motivo No Compra') const labels = await monday.labelsDeColumna(boardOportunidades, 'Motivo No Compra')
if (labels.includes(motivo)) { if (labels.includes(motivo)) {
await monday.cambiarValorColumna(boardOportunidades, oportunidadId, 'Motivo No Compra', motivo) await monday.cambiarValorColumna(boardOportunidades, itemId, 'Motivo No Compra', motivo)
} else { } else {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`El motivo "${motivo}" no es label del dropdown 'Motivo No Compra'; se omite`) console.log(`El motivo "${motivo}" no es label del dropdown 'Motivo No Compra'; se omite`)
} }
} return monday.crearMotivoNoCompra({
} catch (err) { nombre: `${motivo} — ${nombreItem}`,
// eslint-disable-next-line no-console categoria: motivo,
console.error(`No se pudo mover a 'Perdido' la oportunidad del lead ${itemId}:`, err) ...(comentario !== undefined ? { descripcion: comentario } : {}),
} })
}
return motivoItemId
} }
/** POST /api/leads { nombre, telefono?, email?, origen?, notas? } */ /** POST /api/leads { nombre, telefono?, email?, origen?, notas? } */
export async function crearLeadEndpoint(req: Request, res: Response): Promise<void> { export async function crearLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = crearLeadSchema.parse(req.body) const body = crearLeadSchema.parse(req.body)
const resultado = await crearLead(mondayService, body, funnelService) const resultado = await crearLead(mondayService, body)
if ('duplicado' in resultado) { if ('duplicado' in resultado) {
res.status(409).json({ res.status(409).json({
error: 'Ya existe un lead con ese teléfono', error: 'Ya existe un lead con ese teléfono',
@@ -239,8 +260,9 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
return return
} }
// Registro local del capturista para "Mis capturas" del panel Mi día (Monday // Registro local del capturista para "Mis capturas" del panel Mi día (Monday
// no guarda quién dio de alta el lead). Un fallo aquí no rompe la respuesta: // no guarda quién dio de alta el lead). leadId = el id de la oportunidad
// el lead ya quedó creado en Monday. // (modelo nuevo: la afluencia ES la oportunidad). Un fallo aquí no rompe la
// respuesta: la afluencia ya quedó creada en Monday.
if (req.usuarioToken?.sub) { if (req.usuarioToken?.sub) {
try { try {
await prisma.leadCaptura.create({ await prisma.leadCaptura.create({
@@ -257,10 +279,22 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
console.error(`No se pudo registrar la captura del lead ${resultado.id}:`, err) console.error(`No se pudo registrar la captura del lead ${resultado.id}:`, err)
} }
} }
// Asignación: si el capturista tiene su usuario Monday vinculado, el lead // Auto-mapping leadId = oportunidadId en LeadOportunidad: el alta de
// queda asignado a él. La bandera rr:skip se prende ANTES de asignar para // clientes (moverAApartoConDedup) encuentra así la oportunidad a mover a
// que el webhook create_item no lo reparta por round-robin mientras Monday // 'Aparto' tanto en ligas históricas como en el modelo nuevo.
// propaga la escritura (si la asignación falla, se apaga y cae al reparto). try {
await prisma.leadOportunidad.create({
data: { leadId: resultado.id, oportunidadId: resultado.oportunidadId },
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo guardar el mapping de la oportunidad ${resultado.id}:`, err)
}
// Asignación: si el capturista tiene su usuario Monday vinculado, la
// oportunidad queda asignada a él (columna Asesor). La bandera rr:skip se
// prende ANTES de asignar para que el webhook create_item no la reparta por
// round-robin mientras Monday propaga la escritura (si la asignación falla,
// se apaga y cae al reparto).
let asignacion: 'capturista' | 'round-robin' = 'round-robin' let asignacion: 'capturista' | 'round-robin' = 'round-robin'
if (req.usuarioToken?.sub) { if (req.usuarioToken?.sub) {
try { try {
@@ -280,17 +314,17 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
} }
} catch (err) { } catch (err) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(`No se pudo asignar el lead ${resultado.id} al capturista:`, err) console.error(`No se pudo asignar la afluencia ${resultado.id} al capturista:`, err)
} }
} }
res.status(201).json({ id: resultado.id, estado: 'NUEVO', oportunidadId: resultado.oportunidadId, asignacion }) res.status(201).json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, asignacion })
} }
/** POST /api/leads/:itemId/estado { estado, fecha? } */ /** POST /api/leads/:itemId/estado { estado, fecha? } */
export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Promise<void> { export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = cambiarEstadoSchema.parse(req.body) const body = cambiarEstadoSchema.parse(req.body)
const itemId = String(req.params['itemId']) 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 } : {}) }) 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<void> { export async function descartarLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = descartarSchema.parse(req.body) const body = descartarSchema.parse(req.body)
const itemId = String(req.params['itemId']) const itemId = String(req.params['itemId'])
const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario, funnelService) const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario)
res.json({ itemId, estado: 'DESCARTADO', motivo: body.motivo, motivoItemId }) res.json({ itemId, estado: 'Perdido', motivo: body.motivo, motivoItemId })
} }
/** /**
* GET /api/leads/buscar?q=texto — autocompletado para prellenar el alta de * 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<void> { export async function buscarLeadsEndpoint(req: Request, res: Response): Promise<void> {
const q = String(req.query['q'] ?? '').trim() const q = String(req.query['q'] ?? '').trim()
if (q.length < 2) throw new AppError(400, 'La búsqueda requiere al menos 2 caracteres') if (q.length < 2) throw new AppError(400, 'La búsqueda requiere al menos 2 caracteres')
const encontrados = buscarLeads(await mondayService.getLeads(), q) const [leads, oportunidades] = await Promise.all([mondayService.getLeads(), mondayService.getOportunidades()])
res.json( res.json(buscarLeads(leads, oportunidades, q))
encontrados.map((l) => ({
id: l.id,
nombre: l.nombre,
telefono: l.telefono,
email: l.email,
estado: l.estado,
origen: l.origen,
})),
)
} }

View File

@@ -4,7 +4,6 @@ import type { Request, Response } from 'express'
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
runRoundRobin: vi.fn(), runRoundRobin: vi.fn(),
escaneoOportunidades: vi.fn(), escaneoOportunidades: vi.fn(),
asegurarAfluencia: vi.fn(),
moverAGrupo: 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', () => ({ vi.mock('../services/grupos-estado.service.js', () => ({
// Leads (111) y Oportunidades (222) con mapeo; el resto de boards, sin mapeo // Leads (111) y Oportunidades (222) con mapeo; el resto de boards, sin mapeo
GRUPOS_POR_ESTADO: { GRUPOS_POR_ESTADO: {
@@ -52,7 +45,6 @@ beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mocks.runRoundRobin.mockResolvedValue({ asignados: 1 }) mocks.runRoundRobin.mockResolvedValue({ asignados: 1 })
mocks.escaneoOportunidades.mockResolvedValue({ apartadosCreados: ['x'], ventasCreadas: [], errores: [] }) mocks.escaneoOportunidades.mockResolvedValue({ apartadosCreados: ['x'], ventasCreadas: [], errores: [] })
mocks.asegurarAfluencia.mockResolvedValue('op-1')
mocks.moverAGrupo.mockResolvedValue({ movido: true, grupo: 'topics' }) mocks.moverAGrupo.mockResolvedValue({ movido: true, grupo: 'topics' })
}) })
@@ -70,31 +62,34 @@ describe('webhookMonday', () => {
expect(sendStatus).toHaveBeenCalledWith(200) expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.runRoundRobin).not.toHaveBeenCalled() expect(mocks.runRoundRobin).not.toHaveBeenCalled()
expect(mocks.escaneoOportunidades).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() 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(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.runRoundRobin).toHaveBeenCalledWith('999') 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', () => { it('create_pulse (nombre real del evento) con pulseId también dispara el round-robin', () => {
const { res, sendStatus } = resFake() 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(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.runRoundRobin).toHaveBeenCalledWith('777') 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() const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 555, itemId: 999 } }), res) webhookMonday(reqCon({ event: { type: 'create_item', boardId: 555, itemId: 999 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200) expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.runRoundRobin).not.toHaveBeenCalled() expect(mocks.runRoundRobin).not.toHaveBeenCalled()
expect(mocks.asegurarAfluencia).not.toHaveBeenCalled()
}) })
it('change_status_column_value en Oportunidades dispara el escaneo', () => { 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 () => { it('un error del servicio no rompe la respuesta 200 (fire-and-forget)', async () => {
mocks.runRoundRobin.mockRejectedValue(new Error('monday caído')) mocks.runRoundRobin.mockRejectedValue(new Error('monday caído'))
const { res, sendStatus } = resFake() 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) expect(sendStatus).toHaveBeenCalledWith(200)
// Deja que el rechazo se procese para no ensuciar otros tests // Deja que el rechazo se procese para no ensuciar otros tests
await new Promise((r) => setImmediate(r)) await new Promise((r) => setImmediate(r))

View File

@@ -2,7 +2,6 @@ import type { Request, Response } from 'express'
import { z } from 'zod' import { z } from 'zod'
import { config } from '../config/index.js' import { config } from '../config/index.js'
import { automatizacionesService } from '../services/automatizaciones.service.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 { GRUPOS_POR_ESTADO, moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
import { mondayService } from '../services/monday.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 { type, boardId, itemId, pulseId } = parsed.data.event
const esBoardLeads = String(boardId) === config.monday.boards.leads
const esBoardOportunidades = String(boardId) === config.monday.boards.oportunidades const esBoardOportunidades = String(boardId) === config.monday.boards.oportunidades
const idEvento = itemId ?? pulseId 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 // Monday registra el webhook como 'create_item' pero el payload real llega
// como 'create_pulse'; se aceptan ambos. // 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) const id = String(idEvento)
// Fire-and-forget: la respuesta 200 no espera a que termine la asignación // Fire-and-forget: la respuesta 200 no espera a que termine la asignación
void automatizacionesService void automatizacionesService
@@ -68,18 +74,6 @@ export function webhookMonday(req: Request, res: Response): void {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(`[webhook monday] error en round-robin item=${id}:`, err) 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. // Cambio de etapa en Oportunidades → crear Apartado/Venta si aplica.

View File

@@ -8,13 +8,13 @@ import {
claveVentaCreada, claveVentaCreada,
esEstadoTerminal, esEstadoTerminal,
esEtapa, esEtapa,
leadsInactivos, oportunidadesInactivas,
leadsSinContactar, oportunidadesSinContactar,
parseRrUserIds, parseRrUserIds,
vendedorEnTurno, vendedorEnTurno,
type StateStore, type StateStore,
} from './automatizaciones.service.js' } from './automatizaciones.service.js'
import type { LeadMonday, OportunidadMonday, UsuarioMonday } from './monday.service.js' import type { OportunidadMonday, UsuarioMonday } from './monday.service.js'
// ---------- Fixtures ---------- // ---------- Fixtures ----------
@@ -24,27 +24,19 @@ const VENDEDORES: UsuarioMonday[] = [
{ id: 103, nombre: 'Carla', email: 'carla@x.com' }, { id: 103, nombre: 'Carla', email: 'carla@x.com' },
] ]
function lead(id: string, overrides: Partial<LeadMonday> = {}): 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> = {}): OportunidadMonday { function oportunidad(id: string, overrides: Partial<OportunidadMonday> = {}): OportunidadMonday {
return { return {
id, id,
nombre: `Cliente ${id}`, nombre: `Cliente ${id}`,
createdAt: '2026-08-01T09:00:00Z', createdAt: '2026-08-01T09:00:00Z',
etapa: 'Cotizador', updatedAt: '2026-08-01T10:00:00Z',
etapa: 'Afluencia',
unidadCodigo: 'U-101', unidadCodigo: 'U-101',
precioFinal: 2500000, precioFinal: 2500000,
asesorIds: [],
telefono: '',
email: '',
origen: '',
...overrides, ...overrides,
} }
} }
@@ -63,8 +55,8 @@ function stateEnMemoria(): StateStore & { mapa: Map<string, string> } {
} }
} }
/** Monday mockeado: leads fijos, registra asignaciones, updates y actividades */ /** Monday mockeado: oportunidades fijas, registra asignaciones, updates y actividades */
function mondayMock(leads: LeadMonday[], oportunidades: OportunidadMonday[] = []) { function mondayMock(oportunidades: OportunidadMonday[] = []) {
return { return {
asignaciones: [] as { itemId: string; userId: number }[], asignaciones: [] as { itemId: string; userId: number }[],
updates: [] as { itemId: string; body: string }[], updates: [] as { itemId: string; body: string }[],
@@ -78,13 +70,10 @@ function mondayMock(leads: LeadMonday[], oportunidades: OportunidadMonday[] = []
}[], }[],
apartados: [] as Record<string, unknown>[], apartados: [] as Record<string, unknown>[],
ventas: [] as Record<string, unknown>[], ventas: [] as Record<string, unknown>[],
async getLeads() {
return leads
},
async getOportunidades() { async getOportunidades() {
return oportunidades return oportunidades
}, },
async asignarVendedorALead(itemId: string, userId: number) { async asignarAsesorAOportunidad(itemId: string, userId: number) {
this.asignaciones.push({ itemId, userId }) this.asignaciones.push({ itemId, userId })
}, },
async crearUpdateEnItem(itemId: string, body: string) { async crearUpdateEnItem(itemId: string, body: string) {
@@ -136,12 +125,14 @@ describe('parseRrUserIds', () => {
}) })
}) })
describe('esEstadoTerminal', () => { describe('esEstadoTerminal (etapas terminales de Oportunidades)', () => {
it('reconoce DESCARTADO sin importar mayúsculas ni espacios', () => { it('Ganado / Perdido / Abandono son terminales, sin importar mayúsculas ni espacios', () => {
expect(esEstadoTerminal('DESCARTADO')).toBe(true) expect(esEstadoTerminal('GANADO')).toBe(true)
expect(esEstadoTerminal(' descartado ')).toBe(true) expect(esEstadoTerminal(' perdido ')).toBe(true)
expect(esEstadoTerminal('NUEVO')).toBe(false) expect(esEstadoTerminal('Abandono')).toBe(true)
expect(esEstadoTerminal('CONTACTADO')).toBe(false) expect(esEstadoTerminal('Afluencia')).toBe(false)
expect(esEstadoTerminal('Contactado')).toBe(false)
expect(esEstadoTerminal('Intento de contacto')).toBe(false)
expect(esEstadoTerminal('')).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') const ahora = new Date('2026-08-22T00:00:00Z')
it('incluye leads activos que superan el umbral de días', () => { it('incluye oportunidades en etapa temprana CON asesor que superan el umbral de días', () => {
const leads = [ const ops = [
lead('1', { updatedAt: '2026-08-10T00:00:00Z' }), // 12 días → inactivo (umbral 10) oportunidad('1', { updatedAt: '2026-08-10T00:00:00Z', asesorIds: [101] }), // 12 días → inactiva (umbral 10)
lead('2', { updatedAt: '2026-08-20T00:00:00Z' }), // 2 días → activo oportunidad('2', { updatedAt: '2026-08-20T00:00:00Z', asesorIds: [101] }), // 2 días → activa
lead('3', { updatedAt: '2026-07-01T00:00:00Z', estado: 'DESCARTADO' }), // terminal → excluido 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) const inactivas = oportunidadesInactivas(ops, ahora, 10)
expect(inactivos.map((l) => l.id)).toEqual(['1']) expect(inactivas.map((o) => o.id)).toEqual(['1', '6'])
}) })
it('un lead actualizado hace exactamente N días aún no supera el umbral', () => { it('una oportunidad actualizada 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 const ops = [oportunidad('1', { updatedAt: '2026-08-12T00:00:00Z', asesorIds: [101] })] // justo 10 días
expect(leadsInactivos(leads, ahora, 10)).toEqual([]) expect(oportunidadesInactivas(ops, ahora, 10)).toEqual([])
}) })
it('ignora fechas inválidas', () => { 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)', () => { 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-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-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') 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) ---------- // ---------- Servicio (monday mockeado, sin red) ----------
describe('AutomatizacionesService.runRoundRobin', () => { describe('AutomatizacionesService.runRoundRobin', () => {
it('asigna vendedores en rotación a leads sin persona y persiste el índice', async () => { it('asigna asesores en rotación a afluencias sin persona y persiste el índice', async () => {
const monday = mondayMock([lead('1'), lead('2'), lead('3'), lead('4')]) const monday = mondayMock([oportunidad('1'), oportunidad('2'), oportunidad('3'), oportunidad('4')])
const state = stateEnMemoria() const state = stateEnMemoria()
const svc = crearServicio(monday, state) 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(monday.asignaciones.map((a) => a.userId)).toEqual([101, 102, 103, 101])
expect(state.mapa.get('rr:indice')).toBe('1') // 4 asignaciones % 3 vendedores 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) // Segunda pasada con una afluencia nueva: continúa donde quedó (no reinicia en 0)
const monday2 = mondayMock([lead('5')]) const monday2 = mondayMock([oportunidad('5')])
const svc2 = new AutomatizacionesService(monday2, state) const svc2 = new AutomatizacionesService(monday2, state)
const r2 = await svc2.runRoundRobin() const r2 = await svc2.runRoundRobin()
expect(r2.asignados).toBe(1) expect(r2.asignados).toBe(1)
@@ -213,22 +207,28 @@ describe('AutomatizacionesService.runRoundRobin', () => {
expect(state.mapa.get('rr:indice')).toBe('2') 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([ const monday = mondayMock([
lead('1'), oportunidad('1'),
lead('2', { vendedorIds: [101] }), // ya asignado oportunidad('2', { asesorIds: [101] }), // ya asignada
lead('3', { estado: 'DESCARTADO' }), // terminal 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 svc = crearServicio(monday, stateEnMemoria())
const r = await svc.runRoundRobin() const r = await svc.runRoundRobin()
expect(r.asignados).toBe(1) expect(r.asignados).toBe(2)
expect(monday.asignaciones).toEqual([{ itemId: '1', userId: 101 }]) 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 () => { it('omite afluencias con bandera rr:skip=capturista (las asigna su capturista)', async () => {
const monday = mondayMock([lead('1'), lead('2')]) const monday = mondayMock([oportunidad('1'), oportunidad('2')])
const state = stateEnMemoria() 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 state.mapa.set('rr:skip:2', 'round-robin') // bandera liberada: sí entra al reparto
const svc = crearServicio(monday, state) const svc = crearServicio(monday, state)
const r = await svc.runRoundRobin() 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 () => { 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 svc = crearServicio(monday, stateEnMemoria())
const r = await svc.runRoundRobin('2') const r = await svc.runRoundRobin('2')
expect(r.asignados).toBe(1) expect(r.asignados).toBe(1)
@@ -271,13 +271,13 @@ describe('AutomatizacionesService.runRoundRobin', () => {
describe('AutomatizacionesService.escaneoInactividad', () => { describe('AutomatizacionesService.escaneoInactividad', () => {
const ahora = new Date('2026-08-22T09:00:00Z') const ahora = new Date('2026-08-22T09:00:00Z')
const leadsViejos = () => [ const oportunidadesViejas = () => [
lead('1', { updatedAt: '2026-08-01T00:00:00Z' }), // 21 días inactivo oportunidad('1', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [101] }), // 21 días inactiva
lead('2', { updatedAt: '2026-08-20T00:00:00Z' }), // activo oportunidad('2', { updatedAt: '2026-08-20T00:00:00Z', asesorIds: [101] }), // activa
] ]
it('preview: lista candidatos sin escribir en Monday ni en el estado', async () => { 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 state = stateEnMemoria()
const svc = crearServicio(monday, state) 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 () => { 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 state = stateEnMemoria()
const svc = crearServicio(monday, state) const svc = crearServicio(monday, state)
@@ -301,7 +301,7 @@ describe('AutomatizacionesService.escaneoInactividad', () => {
expect(monday.updates[0]?.body).toContain('21 días') expect(monday.updates[0]?.body).toContain('21 días')
expect(state.mapa.get('recordatorio:1:2026-08-22')).toBeDefined() 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') }) const r2 = await svc.escaneoInactividad({ preview: false, ahora: new Date('2026-08-22T18:00:00Z') })
expect(r2.recordatoriosEnviados).toBe(0) expect(r2.recordatoriosEnviados).toBe(0)
expect(r2.candidatos[0]?.yaRecordadoHoy).toBe(true) expect(r2.candidatos[0]?.yaRecordadoHoy).toBe(true)
@@ -319,15 +319,15 @@ describe('AutomatizacionesService.escaneoInactividad', () => {
describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => { describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
const ahora = new Date('2026-08-22T09:00:00Z') const ahora = new Date('2026-08-22T09:00:00Z')
it('el round-robin crea "Contactar a {lead}" tipo Llamada, Abierto, ligada al lead', async () => { it('el round-robin crea "Contactar a {nombre}" tipo Llamada, Abierto, ligada a la oportunidad', async () => {
const monday = mondayMock([lead('1'), lead('2')]) const monday = mondayMock([oportunidad('1'), oportunidad('2')])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true }) const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
const r = await svc.runRoundRobin() const r = await svc.runRoundRobin()
expect(r.asignados).toBe(2) expect(r.asignados).toBe(2)
expect(monday.actividades).toHaveLength(2) expect(monday.actividades).toHaveLength(2)
expect(monday.actividades[0]).toMatchObject({ expect(monday.actividades[0]).toMatchObject({
nombre: 'Contactar a Lead 1', nombre: 'Contactar a Cliente 1',
tipoLabel: 'Llamada', tipoLabel: 'Llamada',
responsableId: 101, responsableId: 101,
estadoLabel: 'Abierto', estadoLabel: 'Abierto',
@@ -343,24 +343,24 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
}) })
it('con actividadesEnabled=false el round-robin no crea actividades', async () => { 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 }) const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: false })
await svc.runRoundRobin() await svc.runRoundRobin()
expect(monday.actividades).toEqual([]) 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 monday = mondayMock([])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true }) 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({ expect(monday.actividades[0]).toMatchObject({
nombre: 'Contactar a Lead Propio', nombre: 'Contactar a Lead Propio',
responsableId: 555, responsableId: 555,
estadoLabel: 'Abierto', estadoLabel: 'Abierto',
leadItemId: 'lead-77', leadItemId: 'op-77',
}) })
}) })
@@ -368,24 +368,21 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
const monday = mondayMock([]) const monday = mondayMock([])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: false }) 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([]) expect(monday.actividades).toEqual([])
}) })
it('inactividad: crea "Retomar contacto" al vendedor asignado; sin vendedor no crea nada', async () => { it('inactividad: crea "Retomar contacto" al asesor asignado; las candidatas siempre tienen asesor', async () => {
const monday = mondayMock([ const monday = mondayMock([oportunidad('1', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [103] })])
lead('1', { updatedAt: '2026-08-01T00:00:00Z', vendedorIds: [103] }), // inactivo con vendedor
lead('2', { updatedAt: '2026-08-01T00:00:00Z' }), // inactivo sin vendedor
])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true }) const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
const r = await svc.escaneoInactividad({ preview: false, ahora }) 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).toHaveLength(1)
expect(monday.actividades[0]).toMatchObject({ expect(monday.actividades[0]).toMatchObject({
nombre: 'Retomar contacto con Lead 1', nombre: 'Retomar contacto con Cliente 1',
tipoLabel: 'Llamada', tipoLabel: 'Llamada',
responsableId: 103, responsableId: 103,
estadoLabel: 'Abierto', estadoLabel: 'Abierto',
@@ -394,14 +391,14 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
}) })
it('preview de inactividad no crea actividades', async () => { 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 }) const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
await svc.escaneoInactividad({ preview: true, ahora }) await svc.escaneoInactividad({ preview: true, ahora })
expect(monday.actividades).toEqual([]) expect(monday.actividades).toEqual([])
}) })
it('un fallo al crear la actividad no rompe la asignación', async () => { 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')) vi.spyOn(monday, 'crearActividad').mockRejectedValueOnce(new Error('Monday caído'))
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {}) const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true }) 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', () => { // ---------- Escalado de afluencias sin contactar ----------
it('ABANDONO también es terminal', () => {
expect(esEstadoTerminal('ABANDONO')).toBe(true)
expect(esEstadoTerminal('Abandono')).toBe(true)
expect(esEstadoTerminal('Intento de contacto')).toBe(false)
})
})
// ---------- Ronda 2: escalado de leads sin contactar ---------- describe('oportunidadesSinContactar (regla de escalado por horas)', () => {
describe('leadsSinContactar (regla de escalado por horas)', () => {
const ahora = new Date('2026-08-22T12:00:00Z') const ahora = new Date('2026-08-22T12:00:00Z')
it('incluye leads asignados en NUEVO / Intento de contacto que superan el umbral', () => { it('incluye oportunidades asignadas en Afluencia / Intento de contacto que superan el umbral', () => {
const leads = [ const ops = [
lead('1', { vendedorIds: [101], updatedAt: '2026-08-19T12:00:00Z' }), // 72 h → escala oportunidad('1', { asesorIds: [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 oportunidad('2', { asesorIds: [102], etapa: '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 oportunidad('3', { asesorIds: [103], etapa: 'Contactado', updatedAt: '2026-08-01T12:00:00Z' }), // ya contactada
lead('4', { estado: 'NUEVO', updatedAt: '2026-08-01T12:00:00Z' }), // sin vendedor → no aplica oportunidad('4', { etapa: 'Afluencia', updatedAt: '2026-08-01T12:00:00Z' }), // sin asesor → no aplica
lead('5', { vendedorIds: [101], estado: 'DESCARTADO', updatedAt: '2026-08-01T12:00:00Z' }), // terminal 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', () => { it('una oportunidad con exactamente N horas aún no supera el umbral', () => {
const leads = [lead('1', { vendedorIds: [101], updatedAt: '2026-08-20T12:00:00Z' })] const ops = [oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-20T12:00:00Z' })]
expect(leadsSinContactar(leads, ahora, 48)).toEqual([]) expect(oportunidadesSinContactar(ops, ahora, 48)).toEqual([])
}) })
it('ignora fechas inválidas', () => { it('ignora fechas inválidas', () => {
const leads = [lead('1', { vendedorIds: [101], updatedAt: 'no-es-fecha' })] const ops = [oportunidad('1', { asesorIds: [101], updatedAt: 'no-es-fecha' })]
expect(leadsSinContactar(leads, ahora, 48)).toEqual([]) expect(oportunidadesSinContactar(ops, ahora, 48)).toEqual([])
}) })
}) })
describe('claves de dedup de ronda 2', () => { describe('claves de dedup', () => {
it('una clave por lead/oportunidad', () => { it('una clave por oportunidad', () => {
expect(claveEscalado('123')).toBe('escalado:123') expect(claveEscalado('123')).toBe('escalado:123')
expect(claveApartadoCreado('456')).toBe('apartado-creado:456') expect(claveApartadoCreado('456')).toBe('apartado-creado:456')
expect(claveVentaCreada('456')).toBe('venta-creada: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 ahora = new Date('2026-08-22T12:00:00Z')
const configAuto = config.automatizaciones as Record<string, unknown> const configAuto = config.automatizaciones as Record<string, unknown>
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'] const anterior = configAuto['coordinadorUserId']
configAuto['coordinadorUserId'] = 0 configAuto['coordinadorUserId'] = 0
try { 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 state = stateEnMemoria()
const svc = new AutomatizacionesService(monday, state, { actividadesEnabled: true }) const svc = new AutomatizacionesService(monday, state, { actividadesEnabled: true })
@@ -481,7 +470,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
expect(r.escalados).toBe(1) expect(r.escalados).toBe(1)
expect(r.candidatos[0]).toMatchObject({ itemId: '1', horasSinContactar: 72, responsableId: 102, yaEscalado: false }) expect(r.candidatos[0]).toMatchObject({ itemId: '1', horasSinContactar: 72, responsableId: 102, yaEscalado: false })
expect(monday.actividades[0]).toMatchObject({ expect(monday.actividades[0]).toMatchObject({
nombre: 'URGENTE: lead sin contactar — Lead 1', nombre: 'URGENTE: oportunidad sin contactar — Cliente 1',
responsableId: 102, responsableId: 102,
estadoLabel: 'Abierto', estadoLabel: 'Abierto',
leadItemId: '1', leadItemId: '1',
@@ -498,7 +487,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
const anterior = configAuto['coordinadorUserId'] const anterior = configAuto['coordinadorUserId']
configAuto['coordinadorUserId'] = 999 configAuto['coordinadorUserId'] = 999
try { 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 svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
const r = await svc.escaneoEscalado({ ahora }) const r = await svc.escaneoEscalado({ ahora })
expect(r.escalados).toBe(1) 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'] const anterior = configAuto['coordinadorUserId']
configAuto['coordinadorUserId'] = 0 configAuto['coordinadorUserId'] = 0
try { 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 state = stateEnMemoria()
const svc = new AutomatizacionesService(monday, state, { actividadesEnabled: true }) 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'] const anterior = configAuto['coordinadorUserId']
configAuto['coordinadorUserId'] = 0 configAuto['coordinadorUserId'] = 0
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {}) const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
try { try {
const monday = mondayMock([ const monday = mondayMock([
lead('1', { vendedorIds: [101], updatedAt: '2026-08-19T12:00:00Z' }), oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-19T12:00:00Z' }),
lead('2', { vendedorIds: [102], updatedAt: '2026-08-19T12:00:00Z' }), oportunidad('2', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' }),
]) ])
vi.spyOn(monday, 'crearUpdateEnItem') vi.spyOn(monday, 'crearUpdateEnItem')
.mockRejectedValueOnce(new Error('Monday caído')) .mockRejectedValueOnce(new Error('Monday caído'))
@@ -544,7 +533,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true }) const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
const r = await svc.escaneoEscalado({ ahora }) 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) expect(r.candidatos).toHaveLength(2)
} finally { } finally {
configAuto['coordinadorUserId'] = anterior configAuto['coordinadorUserId'] = anterior
@@ -553,11 +542,11 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
}) })
}) })
// ---------- Ronda 2: Oportunidad → Aparto / Ganado ---------- // ---------- Oportunidad → Aparto / Ganado ----------
describe('AutomatizacionesService.escaneoOportunidades', () => { describe('AutomatizacionesService.escaneoOportunidades', () => {
it('una oportunidad en Aparto crea su item en Apartado (estado Pago Apartado)', async () => { 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 state = stateEnMemoria()
const svc = crearServicio(monday, state) 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 () => { 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 state = stateEnMemoria()
const svc = crearServicio(monday, state) const svc = crearServicio(monday, state)
@@ -584,7 +573,7 @@ describe('AutomatizacionesService.escaneoOportunidades', () => {
}) })
it('dedup: la segunda pasada no duplica apartados ni ventas', async () => { 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 state = stateEnMemoria()
const svc = crearServicio(monday, state) const svc = crearServicio(monday, state)
@@ -599,7 +588,7 @@ describe('AutomatizacionesService.escaneoOportunidades', () => {
}) })
it('ignora oportunidades en otras etapas', async () => { it('ignora oportunidades en otras etapas', async () => {
const monday = mondayMock([], [ const monday = mondayMock([
oportunidad('10', { etapa: 'Cotizador' }), oportunidad('10', { etapa: 'Cotizador' }),
oportunidad('11', { etapa: 'Perdido' }), oportunidad('11', { etapa: 'Perdido' }),
oportunidad('12', { etapa: 'Afluencia' }), 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 () => { 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')) vi.spyOn(monday, 'crearApartado').mockRejectedValueOnce(new Error('Monday caído'))
const state = stateEnMemoria() const state = stateEnMemoria()
const svc = crearServicio(monday, state) const svc = crearServicio(monday, state)

View File

@@ -1,26 +1,32 @@
import { config } from '../config/index.js' import { config } from '../config/index.js'
import { AppError } from '../utils/errors.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' import { prisma } from './prisma.js'
/** /**
* Automatizaciones sobre el tablero Leads de Monday: * Automatizaciones sobre el tablero Oportunidades de Monday (la afluencia ES
* (a) asignación round-robin de vendedores a leads sin persona asignada * la oportunidad en etapa 'Afluencia'):
* (b) recordatorio de leads inactivos (> INACTIVIDAD_DIAS sin actualizarse) * (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 * El estado (índice RR, cache de vendedores, recordatorios enviados) vive en la
* tabla clave-valor AutomatizacionState para sobrevivir reinicios y no repetir * tabla clave-valor AutomatizacionState para sobrevivir reinicios y no repetir
* recordatorios el mismo día. * recordatorios el mismo día.
*/ */
// Labels del tablero Leads: NUEVO, CONTACTADO, AGENDA CITA, CALIFICADO, DESCARTADO // Etapas del tablero Oportunidades en las que la oportunidad ya no es trabajo
// más los del vocabulario CRM FRASA (Lead Nuevo, Intento de contacto, Interesado, // activo del asesor (fuera de seguimiento).
// Abandono...). Terminales: DESCARTADO y ABANDONO (fuera de seguimiento). export const ETAPAS_TERMINALES_OPORTUNIDAD = ['GANADO', 'PERDIDO', 'ABANDONO'] as const
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'ABANDONO'] as const
// Estados en los que un lead asignado aún no ha sido contactado de verdad; // Etapas tempranas que vigilan los escaneos de inactividad/escalado
// si permanece así más de LEAD_ESCALADO_HORAS horas se escala al coordinador. export const ETAPAS_SEGUIMIENTO = ['AFLUENCIA', 'CONTACTADO', 'INTENTO DE CONTACTO'] as const
export const ESTADOS_SIN_CONTACTAR = ['NUEVO', '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 // Etapas del tablero Oportunidades que disparan las reglas de ronda 2
export const ETAPA_APARTO = 'APARTO' 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() const normalizado = estado.trim().toUpperCase()
return terminales.some((t) => t.toUpperCase() === normalizado) 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`. * `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 const limiteMs = diasInactividad * DIA_MS
return leads.filter((lead) => { return oportunidades.filter((op) => {
if (esEstadoTerminal(lead.estado)) return false if (op.asesorIds.length === 0) return false
const t = Date.parse(lead.updatedAt) if (!ETAPAS_SEGUIMIENTO.some((e) => e === op.etapa.trim().toUpperCase())) return false
const t = Date.parse(op.updatedAt)
if (Number.isNaN(t)) return false if (Number.isNaN(t)) return false
return ahora.getTime() - t > limiteMs 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 { export function claveRecordatorio(itemId: string, fecha: Date): string {
return `recordatorio:${itemId}:${fecha.toISOString().slice(0, 10)}` return `recordatorio:${itemId}:${fecha.toISOString().slice(0, 10)}`
} }
export function diasSinActividad(lead: LeadMonday, ahora: Date): number { export function diasSinActividad(op: Pick<OportunidadMonday, 'updatedAt'>, ahora: Date): number {
return Math.floor((ahora.getTime() - Date.parse(lead.updatedAt)) / DIA_MS) return Math.floor((ahora.getTime() - Date.parse(op.updatedAt)) / DIA_MS)
} }
/** /**
* Leads ASIGNADOS (con vendedor) que siguen en estado NUEVO / Intento de contacto * Oportunidades ASIGNADAS (con asesor) que siguen en Afluencia /
* y llevan más de `horasUmbral` horas sin actualizarse: candidatos a escalar. * Intento de contacto y llevan más de `horasUmbral` horas sin actualizarse:
* (El round-robin actualiza el item al asignar, así que el reloj arranca ahí.) * 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 const limiteMs = horasUmbral * HORA_MS
return leads.filter((lead) => { return oportunidades.filter((op) => {
if (lead.vendedorIds.length === 0) return false if (op.asesorIds.length === 0) return false
if (esEstadoTerminal(lead.estado)) return false if (!ETAPAS_SIN_CONTACTAR.some((e) => e === op.etapa.trim().toUpperCase())) return false
if (!ESTADOS_SIN_CONTACTAR.some((e) => e === lead.estado.trim().toUpperCase())) return false const t = Date.parse(op.updatedAt)
const t = Date.parse(lead.updatedAt)
if (Number.isNaN(t)) return false if (Number.isNaN(t)) return false
return ahora.getTime() - t > limiteMs return ahora.getTime() - t > limiteMs
}) })
@@ -141,9 +157,8 @@ export function esEtapa(etapa: string, esperada: string): boolean {
type MondayLeads = Pick< type MondayLeads = Pick<
MondayService, MondayService,
| 'getLeads'
| 'getOportunidades' | 'getOportunidades'
| 'asignarVendedorALead' | 'asignarAsesorAOportunidad'
| 'crearUpdateEnItem' | 'crearUpdateEnItem'
| 'getUsuariosEquipo' | 'getUsuariosEquipo'
| 'crearActividad' | 'crearActividad'
@@ -279,24 +294,25 @@ export class AutomatizacionesService {
} }
/** /**
* Una pasada de round-robin: asigna vendedor a todo lead sin persona asignada * Una pasada de round-robin: asigna asesor a toda oportunidad en etapa
* y con estado no terminal. Si se pasa `soloItemId`, solo se evalúa ese item * 'Afluencia' sin persona asignada (las creadas por el cotizador nacen en
* (ruta del webhook create_item). * '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<ResultadoRoundRobin> { async runRoundRobin(soloItemId?: string): Promise<ResultadoRoundRobin> {
const leads = await this.monday.getLeads() const oportunidades = await this.monday.getOportunidades()
const sinAsignar = leads.filter( const sinAsignar = oportunidades.filter(
(l) => (o) =>
l.vendedorIds.length === 0 && o.asesorIds.length === 0 &&
!esEstadoTerminal(l.estado) && o.etapa.trim().toUpperCase() === 'AFLUENCIA' &&
(soloItemId === undefined || l.id === soloItemId), (soloItemId === undefined || o.id === soloItemId),
) )
// Los leads capturados desde una app con sesión se asignan a su capturista // Las afluencias capturadas desde una app con sesión se asignan a su
// (bandera rr:skip='capturista'): sin este filtro, la latencia de Monday // capturista (bandera rr:skip='capturista'): sin este filtro, la latencia
// hacía que el webhook los viera sin vendedor y pisara la asignación. // de Monday hacía que el webhook las viera sin asesor y pisara la asignación.
const pendientes: typeof sinAsignar = [] const pendientes: typeof sinAsignar = []
for (const l of sinAsignar) { for (const o of sinAsignar) {
if ((await this.state.get(`${CLAVE_RR_SKIP}${l.id}`)) !== 'capturista') pendientes.push(l) if ((await this.state.get(`${CLAVE_RR_SKIP}${o.id}`)) !== 'capturista') pendientes.push(o)
} }
if (pendientes.length === 0) { if (pendientes.length === 0) {
await this.state.set(CLAVE_RR_ULTIMA_PASADA, new Date().toISOString()) 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 if (!Number.isInteger(indice) || indice < 0) indice = 0
const detalle: AsignacionRR[] = [] const detalle: AsignacionRR[] = []
for (const lead of pendientes) { for (const op of pendientes) {
const vendedor = vendedorEnTurno(vendedores, indice) const vendedor = vendedorEnTurno(vendedores, indice)
if (!vendedor) throw new AppError(500, 'La lista de vendedores está vacía') 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) { if (this.actividadesEnabled) {
await this.crearActividadSegura({ await this.crearActividadSegura({
nombre: `Contactar a ${lead.nombre}`, nombre: `Contactar a ${op.nombre}`,
responsableId: vendedor.id, 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++ indice++
} }
@@ -329,18 +345,19 @@ export class AutomatizacionesService {
} }
/** /**
* Asigna el lead a quien lo capturó desde una app del ecosistema (su usuario * Asigna la afluencia a quien la capturó desde una app del ecosistema (su
* Monday vinculado) y le crea la actividad de contacto — el mismo paquete * usuario Monday vinculado, columna Asesor de Oportunidades) y le crea la
* que haría el round-robin, pero para el capturista. No hay carrera con el * actividad de contacto — el mismo paquete que haría el round-robin, pero
* webhook create_item: runRoundRobin solo toma leads sin persona asignada. * 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<void> { async asignarACapturista(oportunidadId: string, nombre: string, vendedorId: number): Promise<void> {
await this.monday.asignarVendedorALead(leadId, vendedorId) await this.monday.asignarAsesorAOportunidad(oportunidadId, vendedorId)
if (this.actividadesEnabled) { if (this.actividadesEnabled) {
await this.crearActividadSegura({ await this.crearActividadSegura({
nombre: `Contactar a ${leadNombre}`, nombre: `Contactar a ${nombre}`,
responsableId: vendedorId, responsableId: vendedorId,
leadItemId: leadId, leadItemId: oportunidadId,
}) })
} }
} }
@@ -352,38 +369,38 @@ export class AutomatizacionesService {
async escaneoInactividad(opciones: { preview: boolean; ahora?: Date }): Promise<ResultadoInactividad> { async escaneoInactividad(opciones: { preview: boolean; ahora?: Date }): Promise<ResultadoInactividad> {
const ahora = opciones.ahora ?? new Date() const ahora = opciones.ahora ?? new Date()
const diasUmbral = config.automatizaciones.inactividadDias const diasUmbral = config.automatizaciones.inactividadDias
const leads = await this.monday.getLeads() const oportunidades = await this.monday.getOportunidades()
const inactivos = leadsInactivos(leads, ahora, diasUmbral) const inactivas = oportunidadesInactivas(oportunidades, ahora, diasUmbral)
const candidatos: CandidatoInactividad[] = [] const candidatos: CandidatoInactividad[] = []
let recordatoriosEnviados = 0 let recordatoriosEnviados = 0
for (const lead of inactivos) { for (const op of inactivas) {
const clave = claveRecordatorio(lead.id, ahora) const clave = claveRecordatorio(op.id, ahora)
const yaEnviado = (await this.state.get(clave)) !== null const yaEnviado = (await this.state.get(clave)) !== null
const dias = diasSinActividad(lead, ahora) const dias = diasSinActividad(op, ahora)
candidatos.push({ candidatos.push({
itemId: lead.id, itemId: op.id,
lead: lead.nombre, lead: op.nombre,
estado: lead.estado, estado: op.etapa,
updatedAt: lead.updatedAt, updatedAt: op.updatedAt,
diasInactivo: dias, diasInactivo: dias,
yaRecordadoHoy: yaEnviado, yaRecordadoHoy: yaEnviado,
}) })
if (opciones.preview || yaEnviado) continue if (opciones.preview || yaEnviado) continue
const body = const body =
`⚠️ Recordatorio automático: este lead lleva ${dias} días sin actualizarse ` + `⚠️ Recordatorio automático: esta oportunidad lleva ${dias} días sin actualizarse ` +
`(última actividad: ${lead.updatedAt.slice(0, 10)}). ` + `(última actividad: ${op.updatedAt.slice(0, 10)}). ` +
`Favor de darle seguimiento o marcarlo como DESCARTADO.` `Favor de darle seguimiento o marcarla como Perdido.`
await this.monday.crearUpdateEnItem(lead.id, body) await this.monday.crearUpdateEnItem(op.id, body)
await this.state.set(clave, ahora.toISOString()) await this.state.set(clave, ahora.toISOString())
recordatoriosEnviados++ recordatoriosEnviados++
// Además del update, deja una actividad al vendedor asignado (si tiene) // Además del update, deja una actividad al asesor asignado (si tiene)
const vendedorId = lead.vendedorIds[0] const asesorId = op.asesorIds[0]
if (this.actividadesEnabled && vendedorId !== undefined) { if (this.actividadesEnabled && asesorId !== undefined) {
await this.crearActividadSegura({ await this.crearActividadSegura({
nombre: `Retomar contacto con ${lead.nombre}`, nombre: `Retomar contacto con ${op.nombre}`,
responsableId: vendedorId, responsableId: asesorId,
leadItemId: lead.id, leadItemId: op.id,
}) })
} }
} }
@@ -399,36 +416,36 @@ export class AutomatizacionesService {
await this.state.set(clave, String((Number.isFinite(actual) ? actual : 0) + n)) await this.state.set(clave, String((Number.isFinite(actual) ? actual : 0) + n))
} }
/** Responsable del escalado: coordinador (env) o, si no hay, el vendedor asignado */ /** Responsable del escalado: coordinador (env) o, si no hay, el asesor asignado */
private responsableEscalado(lead: LeadMonday): number | null { private responsableEscalado(op: OportunidadMonday): number | null {
const coordinador = config.automatizaciones.coordinadorUserId const coordinador = config.automatizaciones.coordinadorUserId
if (Number.isInteger(coordinador) && coordinador > 0) return coordinador 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 * Escalado de afluencias sin contactar: una oportunidad asignada que sigue en
* NUEVO / Intento de contacto más de LEAD_ESCALADO_HORAS horas genera una * Afluencia / Intento de contacto más de LEAD_ESCALADO_HORAS horas genera una
* actividad URGENTE al coordinador (o al vendedor) + update en el item. * actividad URGENTE al coordinador (o al asesor) + update en el item.
* Un escalado por lead (dedup `escalado:<itemId>`); un fallo de Monday en * Un escalado por oportunidad (dedup `escalado:<itemId>`); un fallo de Monday
* un lead no detiene a los demás. * en una oportunidad no detiene a las demás.
*/ */
async escaneoEscalado(opciones: { ahora?: Date } = {}): Promise<ResultadoEscalado> { async escaneoEscalado(opciones: { ahora?: Date } = {}): Promise<ResultadoEscalado> {
const ahora = opciones.ahora ?? new Date() const ahora = opciones.ahora ?? new Date()
const horasUmbral = config.automatizaciones.escaladoHoras const horasUmbral = config.automatizaciones.escaladoHoras
const leads = await this.monday.getLeads() const oportunidades = await this.monday.getOportunidades()
const pendientes = leadsSinContactar(leads, ahora, horasUmbral) const pendientes = oportunidadesSinContactar(oportunidades, ahora, horasUmbral)
const candidatos: CandidatoEscalado[] = [] const candidatos: CandidatoEscalado[] = []
let escalados = 0 let escalados = 0
for (const lead of pendientes) { for (const op of pendientes) {
const responsableId = this.responsableEscalado(lead) const responsableId = this.responsableEscalado(op)
const horas = Math.floor((ahora.getTime() - Date.parse(lead.updatedAt)) / HORA_MS) const horas = Math.floor((ahora.getTime() - Date.parse(op.updatedAt)) / HORA_MS)
const yaEscalado = (await this.state.get(claveEscalado(lead.id))) !== null const yaEscalado = (await this.state.get(claveEscalado(op.id))) !== null
candidatos.push({ candidatos.push({
itemId: lead.id, itemId: op.id,
lead: lead.nombre, lead: op.nombre,
estado: lead.estado, estado: op.etapa,
horasSinContactar: horas, horasSinContactar: horas,
responsableId, responsableId,
yaEscalado, yaEscalado,
@@ -437,24 +454,24 @@ export class AutomatizacionesService {
try { try {
if (this.actividadesEnabled && responsableId !== null) { if (this.actividadesEnabled && responsableId !== null) {
await this.monday.crearActividad({ await this.monday.crearActividad({
nombre: `URGENTE: lead sin contactar — ${lead.nombre}`, nombre: `URGENTE: oportunidad sin contactar — ${op.nombre}`,
tipoLabel: 'Llamada', tipoLabel: 'Llamada',
responsableId, responsableId,
fecha: AutomatizacionesService.fechaHoyMas(0), fecha: AutomatizacionesService.fechaHoyMas(0),
estadoLabel: 'Abierto', estadoLabel: 'Abierto',
leadItemId: lead.id, leadItemId: op.id,
}) })
} }
await this.monday.crearUpdateEnItem( await this.monday.crearUpdateEnItem(
lead.id, op.id,
`🚨 Escalado automático: el lead lleva ${horas} h asignado en estado "${lead.estado}" sin contacto. ` + `🚨 Escalado automático: la oportunidad lleva ${horas} h asignada en etapa "${op.etapa}" sin contacto. ` +
`Se notificó a ${responsableId !== null && responsableId !== lead.vendedorIds[0] ? 'la coordinación' : 'el vendedor asignado'}.`, `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++ escalados++
} catch (err) { } catch (err) {
// eslint-disable-next-line no-console // 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)
} }
} }

View File

@@ -1,40 +1,21 @@
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { FunnelService, type LeadOportunidadStore } from './funnel.service.js' 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 */ /** Monday mockeado: registra cambios de columna y devuelve búsquedas controladas */
function mondayMock(opts: { leadPorTelefono?: LeadMonday | null; leadPorId?: LeadMonday | null } = {}) { function mondayMock(
opts: { leadPorTelefono?: LeadMonday | null; oportunidadPorTelefono?: OportunidadMonday | null } = {},
) {
return { return {
oportunidadesCreadas: [] as Record<string, unknown>[],
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[], cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
movimientosGrupo: [] as { itemId: string; groupId: string }[],
async crearOportunidadAfluencia(datos: Record<string, unknown>) {
this.oportunidadesCreadas.push(datos)
return `op-${this.oportunidadesCreadas.length}`
},
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) { async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
this.cambios.push({ boardId, itemId, columna, valor }) this.cambios.push({ boardId, itemId, columna, valor })
}, },
async buscarLeadPorTelefono(_telefono: string) { async buscarLeadPorTelefono(_telefono: string) {
return opts.leadPorTelefono ?? null return opts.leadPorTelefono ?? null
}, },
async getLeadPorId(_itemId: string) { async buscarOportunidadPorTelefono(_telefono: string) {
return opts.leadPorId ?? null return opts.oportunidadPorTelefono ?? 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 })
}, },
} }
} }
@@ -47,14 +28,18 @@ function storeMock(): LeadOportunidadStore & { mapping: Map<string, string> } {
async buscarPorLeadId(leadId: string) { async buscarPorLeadId(leadId: string) {
return mapping.get(leadId) ?? null 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) { async guardar(leadId: string, oportunidadId: string) {
mapping.set(leadId, oportunidadId) mapping.set(leadId, oportunidadId)
}, },
} }
} }
const leadBase = { id: 'lead-1', nombre: 'Lead Prueba', telefono: '4771234567', desarrolloLabel: 'SENDA' }
const leadMonday = (overrides: Partial<LeadMonday>): LeadMonday => ({ const leadMonday = (overrides: Partial<LeadMonday>): LeadMonday => ({
id: 'lead-1', id: 'lead-1',
nombre: 'Lead Prueba', nombre: 'Lead Prueba',
@@ -68,47 +53,19 @@ const leadMonday = (overrides: Partial<LeadMonday>): LeadMonday => ({
...overrides, ...overrides,
}) })
describe('FunnelService.asegurarOportunidadAfluencia', () => { const oportunidadMonday = (overrides: Partial<OportunidadMonday>): OportunidadMonday => ({
it('crea la oportunidad en Monday y guarda el mapping lead ↔ oportunidad', async () => { id: 'op-1',
const monday = mondayMock() nombre: 'Cliente Prueba',
const store = storeMock() createdAt: '',
const funnel = new FunnelService(monday, store) updatedAt: '',
const opId = await funnel.asegurarOportunidadAfluencia(leadBase) etapa: 'Afluencia',
expect(opId).toBe('op-1') unidadCodigo: '',
expect(monday.oportunidadesCreadas).toEqual([ precioFinal: 0,
{ nombre: 'Lead Prueba', telefono: '4771234567', email: undefined, desarrolloLabel: 'SENDA' }, asesorIds: [],
]) telefono: '4771234567',
expect(store.mapping.get('lead-1')).toBe('op-1') email: '',
// Y la mueve al grupo que corresponde a 'Afluencia' (nace en el grupo top del tablero) origen: '',
expect(monday.movimientosGrupo).toEqual([{ itemId: 'op-1', groupId: 'topics' }]) ...overrides,
})
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([])
})
}) })
describe('FunnelService.moverOportunidadDeLead', () => { describe('FunnelService.moverOportunidadDeLead', () => {
@@ -130,7 +87,7 @@ describe('FunnelService.moverOportunidadDeLead', () => {
}) })
describe('FunnelService.oportunidadPorTelefono', () => { 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 monday = mondayMock({ leadPorTelefono: leadMonday({ id: 'lead-7' }) })
const store = storeMock() const store = storeMock()
store.mapping.set('lead-7', 'op-7') store.mapping.set('lead-7', 'op-7')
@@ -138,11 +95,24 @@ describe('FunnelService.oportunidadPorTelefono', () => {
await expect(funnel.oportunidadPorTelefono('4771234567')).resolves.toBe('op-7') 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()) const funnel = new FunnelService(mondayMock(), storeMock())
await expect(funnel.oportunidadPorTelefono('4770000000')).resolves.toBeNull() 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']) 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 () => { it('sin mapping no escribe dedup ni mueve nada (null)', async () => {
const monday = mondayMock() const monday = mondayMock()
const funnel = new FunnelService(monday, storeMock()) const funnel = new FunnelService(monday, storeMock())
const state = { get: vi.fn(async () => null), set: vi.fn(async () => {}) } 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(state.set).not.toHaveBeenCalled()
expect(monday.cambios).toEqual([]) expect(monday.cambios).toEqual([])
}) })

View File

@@ -1,44 +1,33 @@
import { config } from '../config/index.js' import { config } from '../config/index.js'
import { claveApartadoCreado, type StateStore } from './automatizaciones.service.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 { mondayService, type MondayService } from './monday.service.js'
import { prisma } from './prisma.js' import { prisma } from './prisma.js'
/** /**
* Funnel comercial Leads → Oportunidades: * Funnel comercial sobre el tablero Oportunidades:
* todo lead capturado entra al tablero Oportunidades en etapa 'Afluencia' y la * la captura de afluencia crea la oportunidad directamente en etapa
* oportunidad avanza de etapa sola conforme avanza el proceso (Agendo Cita, * 'Afluencia' (ver leads.controller), y la oportunidad avanza de etapa sola
* Cotizador, Aparto, Ganado / Perdido). * conforme avanza el proceso (Agendo Cita, Cotizador, Aparto, Ganado /
* Perdido).
* *
* La vinculación lead ↔ oportunidad vive en la tabla LeadOportunidad (dedup * La tabla LeadOportunidad (mapping lead ↔ oportunidad) queda para las ligas
* persistente: una oportunidad por lead), inyectada como store para testear. * 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 * 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) */ /** Store del mapping lead ↔ oportunidad (Prisma en prod, memoria en tests) */
export interface LeadOportunidadStore { export interface LeadOportunidadStore {
buscarPorLeadId(leadId: string): Promise<string | null> // oportunidadId buscarPorLeadId(leadId: string): Promise<string | null> // oportunidadId
buscarPorOportunidadId(oportunidadId: string): Promise<string | null> // oportunidadId si hay mapping
guardar(leadId: string, oportunidadId: string): Promise<void> guardar(leadId: string, oportunidadId: string): Promise<void>
} }
type MondayFunnel = Pick< type MondayFunnel = Pick<
MondayService, MondayService,
| 'crearOportunidadAfluencia' 'cambiarValorColumna' | 'buscarLeadPorTelefono' | 'buscarOportunidadPorTelefono'
| 'cambiarValorColumna'
| 'buscarLeadPorTelefono'
| 'getLeadPorId'
| 'columnaIdPorTitulo'
| 'getItemConGrupo'
| 'moverItemAGrupo'
> >
export class FunnelService { export class FunnelService {
@@ -47,49 +36,6 @@ export class FunnelService {
private readonly store: LeadOportunidadStore, 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<string> {
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<string | null> {
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). */ /** Mueve la oportunidad del lead a otra etapa. Sin mapping → no-op (false). */
async moverOportunidadDeLead(leadId: string, etapa: string): Promise<boolean> { async moverOportunidadDeLead(leadId: string, etapa: string): Promise<boolean> {
const oportunidadId = await this.store.buscarPorLeadId(leadId) 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: * Oportunidad que tenga ese teléfono (match de cotizaciones). Convierte con
* busca el lead por teléfono en Monday y de ahí el mapping). * 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<string | null> { async oportunidadPorTelefono(telefono: string): Promise<string | null> {
const lead = await this.monday.buscarLeadPorTelefono(telefono) const lead = await this.monday.buscarLeadPorTelefono(telefono)
if (!lead) return null if (lead) {
return this.store.buscarPorLeadId(lead.id) 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:<opId>` * Alta de cliente: escribe el dedup `apartado-creado:<opId>` ANTES de mover
* ANTES de mover la oportunidad a 'Aparto'. Este orden evita la carrera con * la oportunidad a 'Aparto'. Este orden evita la carrera con el webhook/cron
* el webhook/cron (escaneoOportunidades vería 'Aparto' y crearía un Apartado * (escaneoOportunidades vería 'Aparto' y crearía un Apartado duplicado; con
* duplicado; con el dedup previo lo salta). Devuelve la oportunidadId movida * el dedup previo lo salta). Devuelve la oportunidadId movida o null si no
* o null si el lead no tiene mapping. * 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<string | null> { async moverAApartoConDedup(id: string, state: StateStore): Promise<string | null> {
const oportunidadId = await this.store.buscarPorLeadId(leadId) const comoOportunidad = await this.store.buscarPorOportunidadId(id)
const oportunidadId = comoOportunidad ?? (await this.store.buscarPorLeadId(id))
if (oportunidadId === null) return null if (oportunidadId === null) return null
await state.set(claveApartadoCreado(oportunidadId), new Date().toISOString()) await state.set(claveApartadoCreado(oportunidadId), new Date().toISOString())
await this.monday.cambiarValorColumna(config.monday.boards.oportunidades, oportunidadId, 'Estado', 'Aparto') 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 } }) const row = await prisma.leadOportunidad.findUnique({ where: { leadId } })
return row?.oportunidadId ?? null return row?.oportunidadId ?? null
}, },
async buscarPorOportunidadId(oportunidadId) {
const row = await prisma.leadOportunidad.findUnique({ where: { oportunidadId } })
return row?.oportunidadId ?? null
},
async guardar(leadId, oportunidadId) { async guardar(leadId, oportunidadId) {
await prisma.leadOportunidad.create({ data: { leadId, oportunidadId } }) await prisma.leadOportunidad.create({ data: { leadId, oportunidadId } })
}, },

View File

@@ -42,6 +42,9 @@ export const GRUPOS_POR_ESTADO: Record<string, { columna: string; destinos: Reco
Abandono: 'group_mm6s4cgq', Abandono: 'group_mm6s4cgq',
'No Asistio': 'group_mm6s4cgq', 'No Asistio': 'group_mm6s4cgq',
Afluencia: 'topics', Afluencia: 'topics',
Contactado: 'topics',
'Intento de contacto': 'topics',
Calificado: 'topics',
Cotizador: 'topics', Cotizador: 'topics',
'Agendo Cita': 'topics', 'Agendo Cita': 'topics',
Aparto: 'topics', Aparto: 'topics',

View File

@@ -1,11 +1,11 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
esEstadoFinalApartado, esEstadoFinalApartado,
esEstadoTerminalLead, esEtapaTerminal,
resumenMiDia, resumenMiDia,
type UsuarioMiDia, type UsuarioMiDia,
} from './mi-dia.service.js' } from './mi-dia.service.js'
import type { ActividadMonday, ApartadoMonday, LeadMonday } from './monday.service.js' import type { ActividadMonday, ApartadoMonday, OportunidadMonday } from './monday.service.js'
const MI_ID = 106736727 const MI_ID = 106736727
const OTRO_ID = 111111111 const OTRO_ID = 111111111
@@ -14,16 +14,18 @@ const AHORA = new Date('2026-08-28T12:00:00') // viernes
const HOY = '2026-08-28' const HOY = '2026-08-28'
const MANANA = '2026-08-29' const MANANA = '2026-08-29'
function lead(parcial: Partial<LeadMonday> & { id: string }): LeadMonday { function oportunidad(parcial: Partial<OportunidadMonday> & { id: string }): OportunidadMonday {
return { return {
nombre: `Lead ${parcial.id}`, nombre: `Lead ${parcial.id}`,
createdAt: '2026-08-25T10:00:00Z', createdAt: '2026-08-25T10:00:00Z',
updatedAt: '2026-08-27T10:00:00Z', updatedAt: '2026-08-27T10:00:00Z',
estado: 'NUEVO', etapa: 'Afluencia',
origen: 'Facebook', origen: 'Facebook',
unidadCodigo: '',
precioFinal: 0,
asesorIds: [MI_ID],
telefono: '', telefono: '',
email: '', email: '',
vendedorIds: [MI_ID],
...parcial, ...parcial,
} }
} }
@@ -50,13 +52,13 @@ function apartado(parcial: Partial<ApartadoMonday> & { id: string }): ApartadoMo
} }
function mondayMock(datos: { function mondayMock(datos: {
leads?: LeadMonday[] oportunidades?: OportunidadMonday[]
actividades?: ActividadMonday[] actividades?: ActividadMonday[]
apartados?: ApartadoMonday[] apartados?: ApartadoMonday[]
}) { }) {
return { return {
async getLeads() { async getOportunidades() {
return datos.leads ?? [] return datos.oportunidades ?? []
}, },
async getActividades() { async getActividades() {
return datos.actividades ?? [] 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 } const coordinador: UsuarioMiDia = { nombre: 'Daniel Alvarado', rol: 'coordinador', mondayUserId: null }
describe('resumenMiDia (GET /api/mi-dia/resumen)', () => { 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({ const monday = mondayMock({
leads: [ oportunidades: [
lead({ id: '1', estado: 'NUEVO' }), oportunidad({ id: '1', etapa: 'Afluencia' }),
lead({ id: '2', estado: 'CONTACTADO', telefono: '4771234567' }), oportunidad({ id: '2', etapa: 'Contactado', telefono: '4771234567' }),
lead({ id: '3', estado: 'DESCARTADO' }), oportunidad({ id: '3', etapa: 'Perdido' }),
lead({ id: '4', estado: 'CALIFICADO' }), oportunidad({ id: '4', etapa: 'Calificado' }), // NO es terminal en el nuevo modelo
lead({ id: '5', estado: 'Abandono' }), oportunidad({ id: '5', etapa: 'Abandono' }),
lead({ id: '6', estado: 'NUEVO', vendedorIds: [OTRO_ID] }), oportunidad({ id: '6', etapa: 'Ganado' }),
oportunidad({ id: '7', etapa: 'Afluencia', asesorIds: [OTRO_ID] }),
], ],
}) })
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA) const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
expect(r.sinMapeoMonday).toBe(false) 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') 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 () => { it('actividades de hoy: solo abiertas con fecha <= mañana y mías', async () => {
const monday = mondayMock({ const monday = mondayMock({
actividades: [ actividades: [
@@ -126,13 +142,14 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
it('sin mapeo Monday: listas personales vacías y sinMapeoMonday: true', async () => { it('sin mapeo Monday: listas personales vacías y sinMapeoMonday: true', async () => {
const monday = mondayMock({ const monday = mondayMock({
leads: [lead({ id: '1' })], oportunidades: [oportunidad({ id: '1' })],
actividades: [actividad({ id: 'a1' })], actividades: [actividad({ id: 'a1' })],
apartados: [apartado({ id: 'p1' })], apartados: [apartado({ id: 'p1' })],
}) })
const r = await resumenMiDia(monday, sinMapeo, new Map(), [], [], AHORA) const r = await resumenMiDia(monday, sinMapeo, new Map(), [], [], AHORA)
expect(r.sinMapeoMonday).toBe(true) expect(r.sinMapeoMonday).toBe(true)
expect(r.misLeads).toEqual([]) expect(r.misLeads).toEqual([])
expect(r.misOportunidades).toEqual({ porEtapa: {} })
expect(r.misActividadesHoy).toEqual([]) expect(r.misActividadesHoy).toEqual([])
expect(r.conteos).toEqual({ leadsNuevosSemana: 0, actividadesAbiertas: 0 }) expect(r.conteos).toEqual({ leadsNuevosSemana: 0, actividadesAbiertas: 0 })
// Los apartados por vencer son del pipeline completo: sí se muestran // 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 () => { it('conteos: leads nuevos de los últimos 7 días (incluye terminales recientes)', async () => {
const monday = mondayMock({ const monday = mondayMock({
leads: [ oportunidades: [
lead({ id: '1', createdAt: '2026-08-28T08:00:00' }), // hoy oportunidad({ id: '1', createdAt: '2026-08-28T08:00:00' }), // hoy
lead({ id: '2', createdAt: '2026-08-22T08:00:00' }), // hace 6 días oportunidad({ id: '2', createdAt: '2026-08-22T08:00:00' }), // hace 6 días
lead({ id: '3', createdAt: '2026-08-10T08:00:00' }), // viejo oportunidad({ id: '3', createdAt: '2026-08-10T08:00:00' }), // viejo
lead({ id: '4', createdAt: '2026-08-27T08:00:00', vendedorIds: [OTRO_ID] }), // de otro oportunidad({ id: '4', createdAt: '2026-08-27T08:00:00', asesorIds: [OTRO_ID] }), // de otro
], ],
}) })
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA) const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
expect(r.conteos.leadsNuevosSemana).toBe(2) expect(r.conteos.leadsNuevosSemana).toBe(2)
}) })
it('oportunidades se omiten con nota (el board no tiene columna de asesor)', async () => { it('vendedor no ve sección equipo; coordinador sí (conteos por asesor y sin asignar)', 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 () => {
const monday = mondayMock({ const monday = mondayMock({
leads: [ oportunidades: [
lead({ id: '1', vendedorIds: [] }), // sin asignar, NUEVO oportunidad({ id: '1', asesorIds: [] }), // sin asignar, Afluencia
lead({ id: '2', vendedorIds: [], estado: 'CONTACTADO' }), // sin asignar, no NUEVO oportunidad({ id: '2', asesorIds: [], etapa: 'Contactado' }), // sin asignar, no Afluencia
lead({ id: '3', vendedorIds: [MI_ID] }), oportunidad({ id: '3', asesorIds: [MI_ID] }),
lead({ id: '4', vendedorIds: [MI_ID], estado: 'DESCARTADO' }), // terminal: no cuenta oportunidad({ id: '4', asesorIds: [MI_ID], etapa: 'Perdido' }), // terminal: no cuenta
lead({ id: '5', vendedorIds: [OTRO_ID] }), oportunidad({ id: '5', asesorIds: [OTRO_ID] }),
lead({ id: '6', vendedorIds: [OTRO_ID], createdAt: '2026-08-10T08:00:00' }), // viejo: no es nuevo de la semana oportunidad({ id: '6', asesorIds: [OTRO_ID], createdAt: '2026-08-10T08:00:00' }), // viejo: no es nuevo de la semana
], ],
}) })
const nombres = new Map([[MI_ID, 'Martin Zavala']]) const 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 () => { 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']) { for (const rol of ['corporativo', 'admin']) {
const r = await resumenMiDia(monday, { nombre: 'X', rol, mondayUserId: null }, new Map(), [], [], AHORA) const r = await resumenMiDia(monday, { nombre: 'X', rol, mondayUserId: null }, new Map(), [], [], AHORA)
expect(r.equipo).toBeDefined() 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({ 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 = [ const capturas = [
{ leadId: '100', nombreLead: 'Ana López', telefono: '6641112233', createdAt: new Date('2026-08-27T09:00:00Z') }, { 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) 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([ expect(r.misCapturas).toEqual([
{ {
leadId: '100', leadId: '100',
nombre: 'Ana López', nombre: 'Ana López',
telefono: '6641112233', telefono: '6641112233',
estado: 'CONTACTADO', estado: 'Contactado',
origen: 'WhatsApp', origen: 'WhatsApp',
capturadoEn: '2026-08-27T09:00:00.000Z', 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 monday = mondayMock({})
const capturas = [ 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) const r = await resumenMiDia(monday, vendedor, new Map(), capturas, [], AHORA)
expect(r.misCapturas).toEqual([ 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', () => { describe('clasificadores de estado', () => {
it('estados terminales de lead (case-insensitive)', () => { it('etapas terminales de oportunidad (case-insensitive)', () => {
expect(esEstadoTerminalLead('DESCARTADO')).toBe(true) expect(esEtapaTerminal('GANADO')).toBe(true)
expect(esEstadoTerminalLead('calificado')).toBe(true) expect(esEtapaTerminal('perdido')).toBe(true)
expect(esEstadoTerminalLead('Abandono')).toBe(true) expect(esEtapaTerminal('Abandono')).toBe(true)
expect(esEstadoTerminalLead('NUEVO')).toBe(false) expect(esEtapaTerminal('Afluencia')).toBe(false)
expect(esEstadoTerminalLead('Intento de contacto')).toBe(false) expect(esEtapaTerminal('Calificado')).toBe(false)
expect(esEtapaTerminal('Intento de contacto')).toBe(false)
}) })
it('estados finales de apartado', () => { it('estados finales de apartado', () => {

View File

@@ -5,12 +5,15 @@ import type { MondayService } from './monday.service.js'
* Todo se filtra por su mondayUserId (columna people de cada tablero); si el * 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 * usuario aún no tiene cuenta Monday (mondayUserId null), las listas personales
* van vacías con sinMapeoMonday: true (las acciones del CRM siguen funcionando). * van vacías con sinMapeoMonday: true (las acciones del CRM siguen funcionando).
* "Mis capturas" viene del registro local (tabla LeadCaptura), no de Monday: * La fuente de leads/oportunidades es el tablero Oportunidades (la afluencia
* es la lista de leads que el usuario dio de alta, con su estado actual. * 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 // Etapas del board Oportunidades en las que la oportunidad ya no es trabajo activo del asesor
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'CALIFICADO', 'ABANDONO'] export const ETAPAS_TERMINALES_OPORTUNIDAD = ['GANADO', 'PERDIDO', 'ABANDONO']
// Estados del board Apartado que cierran el pipeline Enkontrol // Estados del board Apartado que cierran el pipeline Enkontrol
export const ESTADOS_FINALES_APARTADO = ['ESCRITURADO', 'CANCELACION', 'CANCELADO', 'LIBERADO', 'FORMALIZADO'] export const ESTADOS_FINALES_APARTADO = ['ESCRITURADO', 'CANCELACION', 'CANCELADO', 'LIBERADO', 'FORMALIZADO']
@@ -76,9 +79,8 @@ export interface ResumenMiDia {
misClientes: ClienteMiDia[] misClientes: ClienteMiDia[]
misActividadesHoy: ActividadMiDia[] misActividadesHoy: ActividadMiDia[]
apartadosPorVencer: ApartadoPorVencer[] apartadosPorVencer: ApartadoPorVencer[]
// null = el tablero Oportunidades no tiene columna people de asesor (hoy no existe) /** Conteo de MIS oportunidades por etapa (columna Estado del board) */
misOportunidades: { porEtapa: Record<string, number> } | null misOportunidades: { porEtapa: Record<string, number> }
notaOportunidades: string | null
conteos: { conteos: {
leadsNuevosSemana: number // míos, últimos 7 días naturales leadsNuevosSemana: number // míos, últimos 7 días naturales
actividadesAbiertas: number // mías, Estado Abierto actividadesAbiertas: number // mías, Estado Abierto
@@ -96,7 +98,7 @@ export interface ResumenMiDia {
} }
} }
type MondayMiDia = Pick<MondayService, 'getLeads' | 'getActividades' | 'getApartados'> type MondayMiDia = Pick<MondayService, 'getOportunidades' | 'getActividades' | 'getApartados'>
const MS_DIA = 24 * 60 * 60 * 1000 const MS_DIA = 24 * 60 * 60 * 1000
@@ -109,8 +111,8 @@ function fechaLocal(base: Date, dias: number): string {
return `${y}-${m}-${dd}` return `${y}-${m}-${dd}`
} }
export function esEstadoTerminalLead(estado: string): boolean { export function esEtapaTerminal(etapa: string): boolean {
return ESTADOS_TERMINALES_LEAD.includes(estado.trim().toUpperCase()) return ETAPAS_TERMINALES_OPORTUNIDAD.includes(etapa.trim().toUpperCase())
} }
export function esEstadoFinalApartado(estado: string): boolean { export function esEstadoFinalApartado(estado: string): boolean {
@@ -131,8 +133,8 @@ export async function resumenMiDia(
const miId = usuario.mondayUserId const miId = usuario.mondayUserId
const sinMapeo = miId === null const sinMapeo = miId === null
const [leads, actividades, apartados] = await Promise.all([ const [oportunidades, actividades, apartados] = await Promise.all([
monday.getLeads(), monday.getOportunidades(),
monday.getActividades(), monday.getActividades(),
monday.getApartados(), monday.getApartados(),
]) ])
@@ -143,31 +145,40 @@ export async function resumenMiDia(
const hace7DiasMs = ahora.getTime() - 7 * MS_DIA const hace7DiasMs = ahora.getTime() - 7 * MS_DIA
// --- Listas personales (vacías si no hay mapeo Monday) --- // --- 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 const misLeads: LeadMiDia[] = mios
.filter((l) => !esEstadoTerminalLead(l.estado)) .filter((o) => !esEtapaTerminal(o.etapa))
.map((l) => ({ .map((o) => ({
id: l.id, id: o.id,
nombre: l.nombre, nombre: o.nombre,
telefono: l.telefono, telefono: o.telefono,
email: l.email, email: o.email,
estado: l.estado, estado: o.etapa,
origen: l.origen, origen: o.origen,
updatedAt: l.updatedAt, updatedAt: o.updatedAt,
})) }))
// "Mis capturas" se enriquece con el estado/origen ACTUAL del lead en Monday // Conteo de MIS oportunidades por etapa (incluye terminales: es el resumen
// (si ya no existe en el board, estado y origen van ''), más reciente primero // del pipeline personal, no la lista de trabajo activo)
const leadsPorId = new Map(leads.map((l) => [l.id, l])) const porEtapa: Record<string, number> = {}
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 const misCapturas: CapturaMiDia[] = capturas
.map((c) => { .map((c) => {
const lead = leadsPorId.get(c.leadId) const op = oportunidadesPorId.get(c.leadId)
return { return {
leadId: c.leadId, leadId: c.leadId,
nombre: c.nombreLead, nombre: c.nombreLead,
telefono: c.telefono ?? '', telefono: c.telefono ?? '',
estado: lead?.estado ?? '', estado: op?.etapa ?? '',
origen: lead?.origen ?? '', origen: op?.origen ?? '',
capturadoEn: c.createdAt.toISOString(), capturadoEn: c.createdAt.toISOString(),
} }
}) })
@@ -224,27 +235,25 @@ export async function resumenMiDia(
misClientes, misClientes,
misActividadesHoy, misActividadesHoy,
apartadosPorVencer, apartadosPorVencer,
// El tablero Oportunidades no tiene columna people de asesor: no se puede misOportunidades: { porEtapa },
// 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',
conteos, conteos,
} }
if (ROLES_CON_EQUIPO.includes(usuario.rol)) { 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<number, number>() const conteoPorId = new Map<number, number>()
const nuevosPorId = new Map<number, number>() const nuevosPorId = new Map<number, number>()
for (const l of leads) { for (const o of oportunidades) {
if (esEstadoTerminalLead(l.estado)) continue if (esEtapaTerminal(o.etapa)) continue
const esNuevoSemana = Date.parse(l.createdAt) >= hace7DiasMs const esNuevoSemana = Date.parse(o.createdAt) >= hace7DiasMs
for (const id of l.vendedorIds) { for (const id of o.asesorIds) {
conteoPorId.set(id, (conteoPorId.get(id) ?? 0) + 1) conteoPorId.set(id, (conteoPorId.get(id) ?? 0) + 1)
if (esNuevoSemana) nuevosPorId.set(id, (nuevosPorId.get(id) ?? 0) + 1) if (esNuevoSemana) nuevosPorId.set(id, (nuevosPorId.get(id) ?? 0) + 1)
} }
} }
resumen.equipo = { 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()] porVendedor: [...conteoPorId.entries()]
.map(([mondayUserId, leadsAsignados]) => ({ .map(([mondayUserId, leadsAsignados]) => ({
mondayUserId, mondayUserId,

View File

@@ -415,5 +415,241 @@ describe('MondayService', () => {
const mutacion = llamadas.find((l) => l.body.query.includes('change_column_value')) const mutacion = llamadas.find((l) => l.body.query.includes('change_column_value'))
expect(JSON.parse(String(mutacion?.body.variables['value']))).toEqual({ label: 'Nuevo' }) 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<string, unknown>
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<string, unknown>
expect(Object.keys(parsed).sort()).toEqual(['color_estado', 'date_fecha'])
})
}) })
}) })

View File

@@ -86,9 +86,27 @@ export interface OportunidadMonday {
id: string id: string
nombre: string // nombre del cliente nombre: string // nombre del cliente
createdAt: string // ISO createdAt: string // ISO
updatedAt: string // ISO (updated_at del item en Monday)
etapa: string // label de la columna status 'Estado' etapa: string // label de la columna status 'Estado'
unidadCodigo: string // columna text 'Unidad' ('' si no tiene) unidadCodigo: string // columna text 'Unidad' ('' si no tiene)
precioFinal: number // columna numbers 'Precio final' (0 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) */ /** 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 * Resuelve el label real de una columna status/dropdown tolerando mayúsculas
* minúsculas (el código escribe 'NUEVO' aunque el board diga 'Nuevo' tras * 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á * un rename en la UI; los dropdown NO auto-crean labels al escribir). Sin
* que el label no existe, como antes). * match devuelve el valor tal cual (Monday dirá que el label no existe).
*/ */
private async resolverLabelStatus( private async resolverLabel(
boardId: string, boardId: string,
col: ColumnaMeta, col: ColumnaMeta,
valor: string | number | boolean, valor: string | number | boolean,
): Promise<string | number | boolean> { ): Promise<string | number | boolean> {
if (col.type !== 'status') return valor if (col.type !== 'status' && col.type !== 'dropdown') return valor
const buscado = String(valor).trim().toUpperCase() const buscado = String(valor).trim().toUpperCase()
const labels = await this.labelsDeColumna(boardId, col.title) const labels = await this.labelsDeColumna(boardId, col.title)
return labels.find((l) => l.trim().toUpperCase() === buscado) ?? valor return labels.find((l) => l.trim().toUpperCase() === buscado) ?? valor
@@ -395,7 +413,7 @@ export class MondayService {
for (const [titulo, crudo] of Object.entries(valores)) { for (const [titulo, crudo] of Object.entries(valores)) {
if (crudo === undefined || crudo === '') continue if (crudo === undefined || crudo === '') continue
const col = await this.columnaId(boardId, titulo) 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) return JSON.stringify(out)
} }
@@ -429,16 +447,33 @@ export class MondayService {
} }
/** /**
* Oportunidad en etapa 'Afluencia' a partir de un lead recién capturado * Oportunidad en etapa 'Afluencia' con todo el perfil de captura (la
* (entrada al funnel; sin unidad ni precios todavía). * 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<string> { async crearOportunidadAfluencia(datos: DatosOportunidadAfluencia): Promise<string> {
const boardId = config.monday.boards.oportunidades 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, { const columnValues = await this.construirColumnValues(boardId, {
Estado: 'Afluencia',
Fecha: fechaHoy,
'Cliente teléfono': datos.telefono, 'Cliente teléfono': datos.telefono,
'Cliente email': datos.email, 'Cliente email': datos.email,
Desarrollo: datos.desarrolloLabel, 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) return this.crearItem(boardId, datos.nombre, columnValues)
} }
@@ -561,7 +596,7 @@ export class MondayService {
valor: string | number | boolean, valor: string | number | boolean,
): Promise<void> { ): Promise<void> {
const col = await this.columnaId(boardId, tituloColumna) 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( await this.graphql(
`mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) { `mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) {
change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id } 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)) 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<LeadMonday | null> {
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<string, ColumnaMeta>): LeadMonday { private mapearLead(item: ItemConFechas, cols: Map<string, ColumnaMeta>): LeadMonday {
const colVendedor = cols.get('Vendedor') const colVendedor = cols.get('Vendedor')
if (!colVendedor || colVendedor.type !== 'people') { if (!colVendedor || colVendedor.type !== 'people') {
@@ -761,16 +781,16 @@ export class MondayService {
} }
/** /**
* Items del tablero Oportunidades con etapa, unidad y precio final * Items del tablero Oportunidades con etapa, unidad, precio final, asesor
* (los consumen las automatizaciones Aparto/Ganado y los reportes). * asignado y perfil de afluencia (los consumen las automatizaciones
* Aparto/Ganado, el round-robin, Mi día y los reportes).
*/ */
async getOportunidades(): Promise<OportunidadMonday[]> { async getOportunidades(): Promise<OportunidadMonday[]> {
const boardId = config.monday.boards.oportunidades const boardId = config.monday.boards.oportunidades
const cols = await this.columnasDe(boardId) const cols = await this.columnasDe(boardId)
const colEstado = cols.get('Estado') if (!cols.has('Estado')) {
if (!colEstado) throw new AppError(500, `Columna "Estado" no existe en el tablero Oportunidades (${boardId})`) throw new AppError(500, `Columna "Estado" no existe en el tablero Oportunidades (${boardId})`)
const colUnidad = cols.get('Unidad') }
const colPrecio = cols.get('Precio final')
const items: ItemConFechas[] = [] const items: ItemConFechas[] = []
let cursor: string | null = null let cursor: string | null = null
@@ -805,18 +825,68 @@ export class MondayService {
cursor = page.cursor cursor = page.cursor
} while (cursor !== null) } while (cursor !== null)
return items.map((item) => { return items.map((item) => this.mapearOportunidad(item, cols))
}
private mapearOportunidad(item: ItemConFechas, cols: Map<string, ColumnaMeta>): OportunidadMonday {
const porId = new Map(item.column_values.map((cv) => [cv.id, cv])) const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
const precio = colPrecio ? Number(porId.get(colPrecio.id)?.text ?? '') : NaN // 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 { return {
id: item.id, id: item.id,
nombre: item.name, nombre: item.name,
createdAt: item.created_at, createdAt: item.created_at,
etapa: porId.get(colEstado.id)?.text ?? '', updatedAt: item.updated_at,
unidadCodigo: colUnidad ? (porId.get(colUnidad.id)?.text ?? '') : '', etapa,
unidadCodigo: textoDe('Unidad'),
precioFinal: Number.isFinite(precio) ? precio : 0, 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<OportunidadMonday | null> {
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) ---------- // ---------- 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<string> {
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 * 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. * y los últimos 10 (tolerante a lada/prefijo 52). null si no hay duplicado.
@@ -1066,10 +1111,10 @@ export class MondayService {
return abiertas return abiertas
} }
/** Asigna un vendedor (user ID de Monday) a la columna people "Vendedor" de un lead */ /** Asigna un asesor (user ID de Monday) a la columna people "Asesor" de una oportunidad */
async asignarVendedorALead(itemId: string, userId: number): Promise<void> { async asignarAsesorAOportunidad(itemId: string, userId: number): Promise<void> {
const boardId = config.monday.boards.leads const boardId = config.monday.boards.oportunidades
const col = await this.columnaId(boardId, 'Vendedor') const col = await this.columnaId(boardId, 'Asesor')
await this.graphql( await this.graphql(
`mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) { `mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) {
change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id } 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 { export interface DatosOportunidadAfluencia {
nombre: string // name del item = nombre del lead nombre: string // name del item = nombre del interesado
telefono?: string | undefined telefono?: string | undefined
email?: string | undefined email?: string | undefined
desarrolloLabel?: string | undefined // label tal cual de la columna status 'Desarrollo' 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 { export interface DatosContacto {
@@ -1195,27 +1255,6 @@ export interface DatosActividad {
leadItemId?: string // item del tablero Leads relacionado (se omite si no aplica) 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) */ /** Singleton usado por los controllers (los tests instancian el suyo) */
export const mondayService = new MondayService() export const mondayService = new MondayService()

View File

@@ -1,25 +1,30 @@
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { construirResumen, ReportesService, type DatosResumen } from './reportes.service.js' 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' import type { PlanPago } from './cobranza.service.js'
// ---------- Fixtures ---------- // ---------- Fixtures ----------
function lead(id: string, overrides: Partial<LeadMonday> = {}): LeadMonday { /** Afluencia/oportunidad del board Oportunidades (fuente única de la sección leads) */
function afluencia(id: string, overrides: Partial<OportunidadMonday> = {}): OportunidadMonday {
return { return {
id, id,
nombre: `Lead ${id}`, nombre: `Lead ${id}`,
createdAt: '2026-08-20T10:00:00Z', createdAt: '2026-08-20T10:00:00Z',
updatedAt: '2026-08-20T10:00:00Z', updatedAt: '2026-08-20T10:00:00Z',
estado: 'NUEVO', etapa: 'Afluencia',
origen: 'Facebook', origen: 'Facebook',
vendedorIds: [], unidadCodigo: '',
precioFinal: 0,
asesorIds: [],
telefono: '',
email: '',
...overrides, ...overrides,
} }
} }
function op(id: string, etapa: string): OportunidadMonday { 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 { 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 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) ---------- // ---------- construirResumen (puro) ----------
describe('construirResumen', () => { 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( const r = construirResumen(
{ {
...DATOS_BASE, ...DATOS_BASE,
leads: [ oportunidades: [
lead('1', { estado: 'NUEVO', origen: 'Facebook' }), afluencia('1', { etapa: 'Afluencia', origen: 'Facebook' }),
lead('2', { estado: 'NUEVO', origen: 'Facebook' }), afluencia('2', { etapa: 'Afluencia', origen: 'Facebook' }),
lead('3', { estado: 'CONTACTADO', origen: 'Referido' }), afluencia('3', { etapa: 'Contactado', origen: 'Referido' }),
lead('4', { estado: '', origen: '' }), afluencia('4', { etapa: '', origen: '' }),
], ],
}, },
AHORA, AHORA,
) )
expect(r.leads.total).toBe(4) 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 }) 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( const r = construirResumen(
{ {
...DATOS_BASE, ...DATOS_BASE,
leads: [ oportunidades: [
lead('1', { createdAt: '2026-08-22T08:00:00' }), // hoy afluencia('1', { createdAt: '2026-08-22T08:00:00' }), // hoy
lead('2', { createdAt: '2026-08-18T23:00:00' }), // esta semana afluencia('2', { createdAt: '2026-08-18T23:00:00' }), // esta semana
lead('3', { createdAt: '2026-08-10T12:00:00' }), // hace 12 días afluencia('3', { createdAt: '2026-08-10T12:00:00' }), // hace 12 días
], ],
}, },
AHORA, AHORA,
@@ -218,10 +223,10 @@ describe('construirResumen', () => {
const r = construirResumen( const r = construirResumen(
{ {
...DATOS_BASE, ...DATOS_BASE,
leads: [ oportunidades: [
lead('1', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', credito: 'Bancario', califica: 'Si' }), afluencia('1', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', credito: 'Bancario', califica: 'Sí' }),
lead('2', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', rangoSalarial: '20-30 mil' }), afluencia('2', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', rangoSalarial: '20-30 mil' }),
lead('3', { estadoCivil: '', desarrollo: ' ' }), // sin dato: no cuenta en ninguna dimensión afluencia('3', { estadoCivil: '', desarrollo: ' ' }), // sin dato: no cuenta en ninguna dimensión
], ],
}, },
AHORA, AHORA,
@@ -229,7 +234,7 @@ describe('construirResumen', () => {
expect(r.leads.perfil.porEstadoCivil).toEqual({ Soltero: 2 }) expect(r.leads.perfil.porEstadoCivil).toEqual({ Soltero: 2 })
expect(r.leads.perfil.porDesarrollo).toEqual({ ATELIER: 2 }) expect(r.leads.perfil.porDesarrollo).toEqual({ ATELIER: 2 })
expect(r.leads.perfil.porCredito).toEqual({ Bancario: 1 }) 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 expect(r.leads.perfil.porRangoSalarial).toEqual({ 'Hasta $30k MXN': 1 }) // '20-30 mil' → 25k
// Dimensiones sin ningún dato van como objeto vacío // Dimensiones sin ningún dato van como objeto vacío
expect(r.leads.perfil.porModelo).toEqual({}) expect(r.leads.perfil.porModelo).toEqual({})
@@ -243,16 +248,16 @@ describe('construirResumen', () => {
const r = construirResumen( const r = construirResumen(
{ {
...DATOS_BASE, ...DATOS_BASE,
leads: [ oportunidades: [
lead('1', { rangoSalarial: '16000' }), // Hasta $30k MXN afluencia('1', { rangoSalarial: '16000' }), // Hasta $30k MXN
lead('2', { rangoSalarial: '$45K' }), // $30k–$60k MXN afluencia('2', { rangoSalarial: '$45K' }), // $30k–$60k MXN
lead('3', { rangoSalarial: '85,000.00 PESOS' }), // $60k–$100k MXN afluencia('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) afluencia('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) afluencia('5', { rangoSalarial: '$2,5K dlls' }), // Hasta $3k USD (2,500)
lead('6', { rangoSalarial: '4,500 DLLS' }), // $3k–$5k USD afluencia('6', { rangoSalarial: '4,500 DLLS' }), // $3k–$5k USD
lead('7', { rangoSalarial: '8,000.000 DLLS' }), // Más de $5k USD (8,000) afluencia('7', { rangoSalarial: '8,000.000 DLLS' }), // Más de $5k USD (8,000)
lead('8', { rangoSalarial: 'NO QUISO DECIR' }), // sin monto: se salta afluencia('8', { rangoSalarial: 'NO QUISO DECIR' }), // sin monto: se salta
lead('9', { rangoSalarial: '' }), // vacío: se salta afluencia('9', { rangoSalarial: '' }), // vacío: se salta
], ],
}, },
AHORA, AHORA,
@@ -272,16 +277,16 @@ describe('construirResumen', () => {
const r = construirResumen( const r = construirResumen(
{ {
...DATOS_BASE, ...DATOS_BASE,
leads: [ oportunidades: [
lead('1', { edad: 18 }), // 15–24 afluencia('1', { edad: 18 }), // 15–24
lead('2', { edad: 24 }), // 15–24 afluencia('2', { edad: 24 }), // 15–24
lead('3', { edad: 25 }), // 25–34 afluencia('3', { edad: 25 }), // 25–34
lead('4', { edad: 39 }), // 35–44 afluencia('4', { edad: 39 }), // 35–44
lead('5', { edad: 50 }), // 45–54 afluencia('5', { edad: 50 }), // 45–54
lead('6', { edad: 55 }), // 55+ afluencia('6', { edad: 55 }), // 55+
lead('7', { edad: 70 }), // 55+ afluencia('7', { edad: 70 }), // 55+
lead('8', { edad: null }), // sin dato: se salta afluencia('8', { edad: null }), // sin dato: se salta
lead('9'), // edad undefined: se salta afluencia('9'), // edad undefined: se salta
], ],
}, },
AHORA, AHORA,
@@ -295,14 +300,10 @@ describe('construirResumen', () => {
describe('ReportesService', () => { describe('ReportesService', () => {
function mondayMock() { function mondayMock() {
return { return {
llamadas: { leads: 0, oportunidades: 0, unidades: 0, actividades: 0 }, llamadas: { oportunidades: 0, unidades: 0, actividades: 0 },
async getLeads(): Promise<LeadMonday[]> {
this.llamadas.leads++
return [lead('1')]
},
async getOportunidades(): Promise<OportunidadMonday[]> { async getOportunidades(): Promise<OportunidadMonday[]> {
this.llamadas.oportunidades++ this.llamadas.oportunidades++
return [op('1', 'Aparto')] return [afluencia('1')]
}, },
async getUnidades(): Promise<Unidad[]> { async getUnidades(): Promise<Unidad[]> {
this.llamadas.unidades++ 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 monday = mondayMock()
const svc = new ReportesService(monday, async () => [plan('a', { pagos: [pago('p1', '2026-09-01', 50000)] })]) const svc = new ReportesService(monday, async () => [plan('a', { pagos: [pago('p1', '2026-09-01', 50000)] })])
const r = await svc.resumen() 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.oportunidades.total).toBe(1)
expect(r.unidades.total).toBe(1) expect(r.unidades.total).toBe(1)
expect(r.cobranza.planes.activos).toBe(1) expect(r.cobranza.planes.activos).toBe(1)
expect(r.actividadesAbiertas).toBe(7) 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 () => { 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()
await svc.resumen() await svc.resumen()
expect(monday.llamadas.leads).toBe(1)
expect(monday.llamadas.oportunidades).toBe(1) expect(monday.llamadas.oportunidades).toBe(1)
expect(cargarPlanes).toHaveBeenCalledTimes(1) expect(cargarPlanes).toHaveBeenCalledTimes(1)
// 61 s después el cache expira y se vuelve a consultar // 61 s después el cache expira y se vuelve a consultar
vi.setSystemTime(new Date('2026-08-22T12:01:01')) vi.setSystemTime(new Date('2026-08-22T12:01:01'))
await svc.resumen() await svc.resumen()
expect(monday.llamadas.leads).toBe(2) expect(monday.llamadas.oportunidades).toBe(2)
expect(cargarPlanes).toHaveBeenCalledTimes(2) expect(cargarPlanes).toHaveBeenCalledTimes(2)
} finally { } finally {
vi.useRealTimers() vi.useRealTimers()

View File

@@ -1,11 +1,12 @@
import { statusPago, statusPlan, type PlanPago } from './cobranza.service.js' 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' import { ETAPAS_PIPELINE } from './etapas.js'
/** /**
* Agregador de reportes para la app de tableros (/dash/). * Agregador de reportes para la app de tableros (/dash/).
* En UNA respuesta junta: leads (Monday), oportunidades (Monday), unidades * En UNA respuesta junta: afluencia/perfil (Monday, tablero Oportunidades — la
* (Monday), cobranza (Prisma local) y actividades abiertas (Monday). * 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. * 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) */ /** Datos crudos que necesita el agregador (inyectables en tests) */
export interface DatosResumen { 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[] oportunidades: OportunidadMonday[]
unidades: Unidad[] unidades: Unidad[]
planes: PlanPago[] // dominio de cobranza (con pagos) planes: PlanPago[] // dominio de cobranza (con pagos)
@@ -162,8 +164,8 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor
const hoy = inicioDelDia(ahora) const hoy = inicioDelDia(ahora)
const hace7dias = new Date(hoy.getTime() - 7 * 24 * 60 * 60 * 1000) const hace7dias = new Date(hoy.getTime() - 7 * 24 * 60 * 60 * 1000)
const creadosEn = (desde: Date): number => const creadosEn = (desde: Date): number =>
datos.leads.filter((l) => { datos.oportunidades.filter((o) => {
const t = Date.parse(l.createdAt) const t = Date.parse(o.createdAt)
return !Number.isNaN(t) && t >= desde.getTime() return !Number.isNaN(t) && t >= desde.getTime()
}).length }).length
@@ -219,29 +221,31 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor
return { return {
generadoEn: ahora.toISOString(), generadoEn: ahora.toISOString(),
// Sección "leads" = afluencia sobre el tablero Oportunidades (modelo
// nuevo: la afluencia ES la oportunidad en etapa 'Afluencia')
leads: { leads: {
total: datos.leads.length, total: datos.oportunidades.length,
nuevosHoy: creadosEn(hoy), nuevosHoy: creadosEn(hoy),
nuevosSemana: creadosEn(hace7dias), nuevosSemana: creadosEn(hace7dias),
porEstado: contarPor(datos.leads.map((l) => l.estado)), porEstado: contarPor(datos.oportunidades.map((o) => o.etapa)),
porOrigen: contarPor(datos.leads.map((l) => l.origen)), porOrigen: contarPor(datos.oportunidades.map((o) => o.origen)),
perfil: { perfil: {
porDesarrollo: contarPorNoVacios(datos.leads.map((l) => l.desarrollo ?? '')), porDesarrollo: contarPorNoVacios(datos.oportunidades.map((o) => o.desarrollo ?? '')),
porModelo: contarPorNoVacios(datos.leads.map((l) => l.modeloInteres ?? '')), porModelo: contarPorNoVacios(datos.oportunidades.map((o) => o.modeloInteres ?? '')),
porEstadoCivil: contarPorNoVacios(datos.leads.map((l) => l.estadoCivil ?? '')), porEstadoCivil: contarPorNoVacios(datos.oportunidades.map((o) => o.estadoCivil ?? '')),
porTipoEmpleo: contarPorNoVacios(datos.leads.map((l) => l.tipoEmpleo ?? '')), porTipoEmpleo: contarPorNoVacios(datos.oportunidades.map((o) => o.tipoEmpleo ?? '')),
porTipoEconomia: contarPorNoVacios(datos.leads.map((l) => l.tipoEconomia ?? '')), porTipoEconomia: contarPorNoVacios(datos.oportunidades.map((o) => o.tipoEconomia ?? '')),
porCalifica: contarPorNoVacios(datos.leads.map((l) => l.califica ?? '')), porCalifica: contarPorNoVacios(datos.oportunidades.map((o) => o.califica ?? '')),
porCredito: contarPorNoVacios(datos.leads.map((l) => l.credito ?? '')), porCredito: contarPorNoVacios(datos.oportunidades.map((o) => o.credito ?? '')),
porRangoSalarial: contarPorNoVacios( porRangoSalarial: contarPorNoVacios(
datos.leads datos.oportunidades
.map((l) => bucketSalario(l.rangoSalarial ?? '')) .map((o) => bucketSalario(o.rangoSalarial ?? ''))
.filter((b): b is string => b !== null), .filter((b): b is string => b !== null),
), ),
porMedioContacto: contarPorNoVacios(datos.leads.map((l) => l.medioContacto ?? '')), porMedioContacto: contarPorNoVacios(datos.oportunidades.map((o) => o.medioContacto ?? '')),
porLoQuierePara: contarPorNoVacios(datos.leads.map((l) => l.loQuierePara ?? '')), porLoQuierePara: contarPorNoVacios(datos.oportunidades.map((o) => o.loQuierePara ?? '')),
porEdad: contarPorNoVacios( 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 ---------- // ---------- Servicio con cache ----------
type MondayReportes = Pick<MondayService, 'getLeads' | 'getOportunidades' | 'getUnidades' | 'getActividadesAbiertas'> type MondayReportes = Pick<MondayService, 'getOportunidades' | 'getUnidades' | 'getActividadesAbiertas'>
/** Carga los planes de cobranza en forma de dominio (Prisma en prod, fixture en tests) */ /** Carga los planes de cobranza en forma de dominio (Prisma en prod, fixture en tests) */
export type CargadorPlanes = () => Promise<PlanPago[]> export type CargadorPlanes = () => Promise<PlanPago[]>
@@ -309,14 +313,13 @@ export class ReportesService {
if (this.cache && this.cache.expira > ahoraMs) return this.cache.resumen if (this.cache && this.cache.expira > ahoraMs) return this.cache.resumen
// Las fuentes de Monday se piden en paralelo; Prisma va por su lado // Las fuentes de Monday se piden en paralelo; Prisma va por su lado
const [leads, oportunidades, unidades, actividadesAbiertas, planes] = await Promise.all([ const [oportunidades, unidades, actividadesAbiertas, planes] = await Promise.all([
this.monday.getLeads(),
this.monday.getOportunidades(), this.monday.getOportunidades(),
this.monday.getUnidades(), this.monday.getUnidades(),
this.monday.getActividadesAbiertas(), this.monday.getActividadesAbiertas(),
this.cargarPlanes(), 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 } this.cache = { resumen, expira: ahoraMs + this.cacheMs }
return resumen return resumen
} }

View File

@@ -206,7 +206,7 @@ describe('AltaAfluenciaForm', () => {
}) })
it('tras el éxito, "Pasar a Alta de Cliente" entrega el lead recién creado', async () => { 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() const onPasar = vi.fn()
render(<AltaAfluenciaForm onVolver={() => undefined} onPasarAAltaCliente={onPasar} />) render(<AltaAfluenciaForm onVolver={() => undefined} onPasarAAltaCliente={onPasar} />)
llenarRequeridos() llenarRequeridos()
@@ -216,12 +216,13 @@ describe('AltaAfluenciaForm', () => {
fireEvent.click(await screen.findByRole('button', { name: /Pasar a Alta de Cliente/ })) fireEvent.click(await screen.findByRole('button', { name: /Pasar a Alta de Cliente/ }))
expect(onPasar).toHaveBeenCalledWith({ expect(onPasar).toHaveBeenCalledWith({
id: 'l-1', id: 'op-1',
nombre: 'Ana Demo', nombre: 'Ana Demo',
telefono: '6641234567', telefono: '6641234567',
email: 'ana@correo.com', email: 'ana@correo.com',
estado: 'NUEVO', estado: 'Afluencia',
origen: '', origen: '',
fuente: 'oportunidad', // la captura nueva vive en Oportunidades
}) })
}) })
}) })

View File

@@ -122,6 +122,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro
email: form.email.trim(), email: form.email.trim(),
estado: res.lead.estado, estado: res.lead.estado,
origen: form.origen, origen: form.origen,
fuente: 'oportunidad', // la captura nueva vive en el tablero Oportunidades
}) })
setAsignacion(res.lead.asignacion ?? 'round-robin') setAsignacion(res.lead.asignacion ?? 'round-robin')
} else if (res.motivo === 'duplicado') { } else if (res.motivo === 'duplicado') {

View File

@@ -6,8 +6,8 @@ import type { LeadResumen } from '../services/api'
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
const LEADS: LeadResumen[] = [ const LEADS: LeadResumen[] = [
{ id: 'lead-1', nombre: 'MARÍA FERNANDA LÓPEZ', telefono: '4771234567', email: 'maria@correo.com', estado: 'NUEVO', origen: 'Web' }, { id: 'lead-1', nombre: 'MARÍA FERNANDA LÓPEZ', telefono: '4771234567', email: 'maria@correo.com', estado: 'NUEVO', origen: 'Web', fuente: 'lead' },
{ id: 'lead-2', nombre: 'Juan Pérez', telefono: '', email: 'juan@correo.com', estado: 'CONTACTADO', origen: '' }, { id: 'op-2', nombre: 'Juan Pérez', telefono: '', email: 'juan@correo.com', estado: 'Afluencia', origen: '', fuente: 'oportunidad' },
] ]
const respuestaOk = (data: unknown) => const respuestaOk = (data: unknown) =>
@@ -35,13 +35,16 @@ describe('BusquedaLead', () => {
expect(llamadasBusqueda()).toHaveLength(0) 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(<BusquedaLead onSeleccion={() => undefined} />) render(<BusquedaLead onSeleccion={() => undefined} />)
fireEvent.change(screen.getByLabelText(/Buscar lead/), { target: { value: 'maria' } }) fireEvent.change(screen.getByLabelText(/Buscar lead/), { target: { value: 'maria' } })
expect(await screen.findByText('MARÍA FERNANDA LÓPEZ')).toBeTruthy() expect(await screen.findByText('MARÍA FERNANDA LÓPEZ')).toBeTruthy()
expect(screen.getByText(/4771234567 · NUEVO/)).toBeTruthy() expect(screen.getByText(/4771234567 · NUEVO/)).toBeTruthy()
expect(screen.getByText('Juan Pérez')).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] const [url] = llamadasBusqueda()[0] as [string]
expect(url).toContain('/leads/buscar?q=maria') expect(url).toContain('/leads/buscar?q=maria')
}) })

View File

@@ -8,8 +8,9 @@ interface Props {
} }
/** /**
* Buscador con debounce de leads de Monday (GET /leads/buscar). No llama al * Buscador con debounce de leads/afluencias de Monday (GET /leads/buscar,
* API con menos de 2 caracteres; al elegir un resultado avisa con onSeleccion. * 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) { export default function BusquedaLead({ onSeleccion }: Props) {
const [q, setQ] = useState('') const [q, setQ] = useState('')
@@ -71,13 +72,25 @@ export default function BusquedaLead({ onSeleccion }: Props) {
{resultados !== null && resultados.length > 0 && ( {resultados !== null && resultados.length > 0 && (
<ul className="mt-3 max-w-md divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm"> <ul className="mt-3 max-w-md divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
{resultados.map((lead) => ( {resultados.map((lead) => (
<li key={lead.id}> <li key={`${lead.fuente}-${lead.id}`}>
<button <button
type="button" type="button"
onClick={() => onSeleccion(lead)} onClick={() => onSeleccion(lead)}
className="flex w-full flex-wrap items-baseline justify-between gap-2 px-4 py-3 text-left transition-colors duration-100 hover:bg-[#F8FAFC]" className="flex w-full flex-wrap items-baseline justify-between gap-2 px-4 py-3 text-left transition-colors duration-100 hover:bg-[#F8FAFC]"
> >
<span className="text-sm text-[#111827]">{lead.nombre}</span> <span className="flex items-baseline gap-2 text-sm text-[#111827]">
{lead.nombre}
{/* Insignia de procedencia: gris = archivo histórico, azul = modelo nuevo */}
<span
className={
lead.fuente === 'oportunidad'
? 'rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-medium text-blue-800'
: 'rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-600'
}
>
{lead.fuente === 'oportunidad' ? 'Oportunidad' : 'Lead'}
</span>
</span>
<span className="text-xs text-[#64748B]"> <span className="text-xs text-[#64748B]">
{lead.telefono || 'sin teléfono'} {lead.telefono || 'sin teléfono'}
{lead.estado ? ` · ${lead.estado}` : ''} {lead.estado ? ` · ${lead.estado}` : ''}

View File

@@ -12,6 +12,7 @@ const LEAD: LeadResumen = {
email: 'maria@correo.com', email: 'maria@correo.com',
estado: 'NUEVO', estado: 'NUEVO',
origen: 'Web', origen: 'Web',
fuente: 'lead',
} }
const respuestaOk = (data: unknown) => const respuestaOk = (data: unknown) =>

View File

@@ -225,9 +225,11 @@ export interface LeadResumen {
email: string email: string
estado: string estado: string
origen: string origen: string
/** 'lead' = archivo histórico (tablero Leads); 'oportunidad' = modelo nuevo (tablero Oportunidades) */
fuente: 'lead' | 'oportunidad'
} }
/** GET /leads/buscar?q= — autocompletado para prellenar el alta desde un lead */ /** GET /leads/buscar?q= — autocompletado para prellenar el alta (busca en AMBOS tableros) */
export async function buscarLeads(q: string): Promise<LeadResumen[]> { export async function buscarLeads(q: string): Promise<LeadResumen[]> {
const res = await fetch(`${API_URL}/leads/buscar?q=${encodeURIComponent(q)}`, conAuth()) const res = await fetch(`${API_URL}/leads/buscar?q=${encodeURIComponent(q)}`, conAuth())
manejar401(res) manejar401(res)
@@ -236,8 +238,9 @@ export async function buscarLeads(q: string): Promise<LeadResumen[]> {
} }
export interface LeadCreado { export interface LeadCreado {
/** id de la oportunidad creada (la afluencia ES la oportunidad: id === oportunidadId) */
id: string id: string
estado: string estado: string // 'Afluencia'
oportunidadId: string | null oportunidadId: string | null
/** 'capturista' = asignado a quien lo registró; 'round-robin' = entra al reparto */ /** 'capturista' = asignado a quien lo registró; 'round-robin' = entra al reparto */
asignacion?: 'capturista' | 'round-robin' asignacion?: 'capturista' | 'round-robin'
@@ -250,9 +253,10 @@ export type ResultadoCrearLead =
| { ok: false; motivo: 'conexion' } | { ok: false; motivo: 'conexion' }
/** /**
* POST /leads — captura ligera de afluencia: crea el lead en Monday y su * POST /leads — captura ligera de afluencia: crea UN item en el tablero
* oportunidad en etapa Afluencia (round-robin). Si el teléfono ya existe el * Oportunidades (etapa Afluencia, con todo el perfil; la asigna el capturista
* API responde 409 con el lead existente (motivo 'duplicado'). * o el round-robin). Si el teléfono ya existe el API responde 409 con la
* oportunidad existente (motivo 'duplicado').
*/ */
export async function crearLead(payload: { export async function crearLead(payload: {
nombre: string nombre: string

View File

@@ -115,20 +115,19 @@ describe('unidadesEnEtapa', () => {
}) })
describe('datosFunnel', () => { describe('datosFunnel', () => {
it('ordena Leads → Oportunidades → Aparto o más → Ganado sin subir', () => { it('ordena Afluencia → Agendo Cita → Cotizador → Aparto o más → Ganado (todo desde porEtapa)', () => {
const funnel = datosFunnel(resumen()) const funnel = datosFunnel(resumen())
expect(funnel.map((e) => e.etapa)).toEqual(['Leads', 'Oportunidades', 'Aparto o más', 'Ganado']) expect(funnel.map((e) => e.etapa)).toEqual(['Afluencia', 'Agendo Cita', 'Cotizador', 'Aparto o más', 'Ganado'])
expect(funnel.map((e) => e.valor)).toEqual([40, 20, 6, 2]) // Aparto(4) + Ganado(2) acumulados expect(funnel.map((e) => e.valor)).toEqual([8, 0, 5, 6, 2]) // Aparto(4) + Ganado(2) acumulados
const valores = funnel.map((e) => e.valor)
for (let i = 1; i < valores.length; i++) expect(valores[i]!).toBeLessThanOrEqual(valores[i - 1]!)
}) })
it('tolera etiquetas con otra capitalización', () => { it('tolera etiquetas con otra capitalización', () => {
const r = resumen() const r = resumen()
r.oportunidades.porEtapa = { aparto: 1, ' GANADO ': 3 } r.oportunidades.porEtapa = { afluencia: 3, ' GANADO ': 3, aparto: 1 }
const funnel = datosFunnel(r) const funnel = datosFunnel(r)
expect(funnel[2]?.valor).toBe(4) expect(funnel[0]?.valor).toBe(3)
expect(funnel[3]?.valor).toBe(3) expect(funnel[3]?.valor).toBe(4)
expect(funnel[4]?.valor).toBe(3)
}) })
}) })

View File

@@ -104,8 +104,10 @@ export function unidadesEnEtapa(resumen: ResumenReportes, etapa: string): number
} }
/** /**
* Datos del funnel comercial: Leads → Oportunidades → Aparto o más → Ganado. * Datos del funnel comercial, todo desde el tablero Oportunidades (la
* "Aparto o más" es acumulado (Aparto + Ganado) para que el funnel no suba. * afluencia ES la oportunidad en etapa 'Afluencia'): Afluencia → Agendo Cita
* → Cotizador → Aparto o más → Ganado. "Aparto o más" es acumulado
* (Aparto + Ganado) porque Aparto es subconjunto del camino a Ganado.
*/ */
export function datosFunnel(resumen: ResumenReportes): EtapaFunnel[] { export function datosFunnel(resumen: ResumenReportes): EtapaFunnel[] {
const porEtapa = resumen.oportunidades.porEtapa const porEtapa = resumen.oportunidades.porEtapa
@@ -115,8 +117,9 @@ export function datosFunnel(resumen: ResumenReportes): EtapaFunnel[] {
.reduce((acc, [, v]) => acc + v, 0) .reduce((acc, [, v]) => acc + v, 0)
const apartoOMas = conteo('APARTO') + conteo('GANADO') const apartoOMas = conteo('APARTO') + conteo('GANADO')
return [ return [
{ etapa: 'Leads', valor: resumen.leads.total }, { etapa: 'Afluencia', valor: conteo('AFLUENCIA') },
{ etapa: 'Oportunidades', valor: resumen.oportunidades.total }, { etapa: 'Agendo Cita', valor: conteo('AGENDO CITA') },
{ etapa: 'Cotizador', valor: conteo('COTIZADOR') },
{ etapa: 'Aparto o más', valor: apartoOMas }, { etapa: 'Aparto o más', valor: apartoOMas },
{ etapa: 'Ganado', valor: conteo('GANADO') }, { etapa: 'Ganado', valor: conteo('GANADO') },
] ]

View File

@@ -53,8 +53,7 @@ export interface ResumenMiDia {
misClientes: ClienteMiDia[] misClientes: ClienteMiDia[]
misActividadesHoy: ActividadMiDia[] misActividadesHoy: ActividadMiDia[]
apartadosPorVencer: ApartadoPorVencer[] apartadosPorVencer: ApartadoPorVencer[]
misOportunidades: { porEtapa: Record<string, number> } | null misOportunidades: { porEtapa: Record<string, number> } // conteo de MIS oportunidades por etapa
notaOportunidades: string | null
conteos: { conteos: {
leadsNuevosSemana: number leadsNuevosSemana: number
actividadesAbiertas: number actividadesAbiertas: number