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

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