- El alta (directa y progresiva) llena el Apartado con telefono, correo, curp, Precio Final, Enganche, Fecha Compra, Vendedor (el capturista con Monday vinculado) y Forma de Pago; el path webhook/cron hereda contacto, precio y asesor de la oportunidad. - Bidireccional: Mis clientes en /hoy muestra el estado actual del Apartado leyendolo de Monday (si lo mueven en Monday, la app lo refleja en el siguiente refresh). - getApartados con la correccion value-null al leer Estado. - Test del mapa de Tipo propuesta del cotizador (los 502 del log eran de un proceso previo al fix 4f31d1c; queda cubierto).
707 lines
29 KiB
TypeScript
707 lines
29 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import { config } from '../config/index.js'
|
|
import {
|
|
AutomatizacionesService,
|
|
claveApartadoCreado,
|
|
claveEscalado,
|
|
claveRecordatorio,
|
|
claveVentaCreada,
|
|
esEstadoTerminal,
|
|
esEtapa,
|
|
ESTADOS_TERMINALES_LEAD,
|
|
oportunidadesInactivas,
|
|
oportunidadesSinContactar,
|
|
parseRrUserIds,
|
|
vendedorEnTurno,
|
|
type StateStore,
|
|
} from './automatizaciones.service.js'
|
|
import type { LeadMonday, OportunidadMonday, UsuarioMonday } from './monday.service.js'
|
|
|
|
// ---------- Fixtures ----------
|
|
|
|
const VENDEDORES: UsuarioMonday[] = [
|
|
{ id: 101, nombre: 'Ana', email: 'ana@x.com' },
|
|
{ id: 102, nombre: 'Beto', email: 'beto@x.com' },
|
|
{ id: 103, nombre: 'Carla', email: 'carla@x.com' },
|
|
]
|
|
|
|
function lead(id: string, overrides: Partial<LeadMonday> = {}): LeadMonday {
|
|
return {
|
|
id,
|
|
nombre: `Lead ${id}`,
|
|
createdAt: '2026-08-01T09:00:00Z',
|
|
updatedAt: '2026-08-01T10:00:00Z',
|
|
estado: 'Nuevo',
|
|
origen: '',
|
|
vendedorIds: [],
|
|
telefono: '',
|
|
email: '',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function oportunidad(id: string, overrides: Partial<OportunidadMonday> = {}): OportunidadMonday {
|
|
return {
|
|
id,
|
|
nombre: `Cliente ${id}`,
|
|
createdAt: '2026-08-01T09:00:00Z',
|
|
updatedAt: '2026-08-01T10:00:00Z',
|
|
etapa: 'Afluencia',
|
|
unidadCodigo: 'U-101',
|
|
precioFinal: 2500000,
|
|
asesorIds: [],
|
|
telefono: '',
|
|
email: '',
|
|
origen: '',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
/** StateStore en memoria para tests (sin Prisma) */
|
|
function stateEnMemoria(): StateStore & { mapa: Map<string, string> } {
|
|
const mapa = new Map<string, string>()
|
|
return {
|
|
mapa,
|
|
async get(clave) {
|
|
return mapa.get(clave) ?? null
|
|
},
|
|
async set(clave, valor) {
|
|
mapa.set(clave, valor)
|
|
},
|
|
}
|
|
}
|
|
|
|
/** Monday mockeado: leads y oportunidades fijos, registra asignaciones, updates y actividades */
|
|
function mondayMock(leads: LeadMonday[] = [], oportunidades: OportunidadMonday[] = []) {
|
|
return {
|
|
asignaciones: [] as { itemId: string; userId: number; tablero: string }[],
|
|
updates: [] as { itemId: string; body: string }[],
|
|
actividades: [] as {
|
|
nombre: string
|
|
tipoLabel: string
|
|
responsableId: number
|
|
fecha: string
|
|
estadoLabel: string
|
|
leadItemId: string
|
|
}[],
|
|
apartados: [] as Record<string, unknown>[],
|
|
ventas: [] as Record<string, unknown>[],
|
|
async getLeads() {
|
|
return leads
|
|
},
|
|
async getOportunidades() {
|
|
return oportunidades
|
|
},
|
|
async asignarVendedorALead(itemId: string, userId: number) {
|
|
this.asignaciones.push({ itemId, userId, tablero: 'leads' })
|
|
},
|
|
async asignarAsesorAOportunidad(itemId: string, userId: number) {
|
|
this.asignaciones.push({ itemId, userId, tablero: 'oportunidades' })
|
|
},
|
|
async crearUpdateEnItem(itemId: string, body: string) {
|
|
this.updates.push({ itemId, body })
|
|
return 'upd-1'
|
|
},
|
|
async crearActividad(datos: {
|
|
nombre: string
|
|
tipoLabel: string
|
|
responsableId: number
|
|
fecha: string
|
|
estadoLabel: string
|
|
leadItemId: string
|
|
}) {
|
|
this.actividades.push(datos)
|
|
return 'act-1'
|
|
},
|
|
async crearApartado(datos: Record<string, unknown>) {
|
|
this.apartados.push(datos)
|
|
return `apt-${this.apartados.length}`
|
|
},
|
|
async crearVenta(datos: Record<string, unknown>) {
|
|
this.ventas.push(datos)
|
|
return `vta-${this.ventas.length}`
|
|
},
|
|
async getUsuariosEquipo(_nombre: string) {
|
|
return VENDEDORES
|
|
},
|
|
}
|
|
}
|
|
|
|
const crearServicio = (monday: ReturnType<typeof mondayMock>, state: StateStore) =>
|
|
new AutomatizacionesService(monday, state)
|
|
|
|
// ---------- Lógica pura ----------
|
|
|
|
describe('parseRrUserIds', () => {
|
|
it('parsea IDs separados por coma ignorando espacios y vacíos', () => {
|
|
expect(parseRrUserIds(' 106735975, 106735976 ,, ')).toEqual([
|
|
{ id: 106735975, nombre: 'Usuario 106735975', email: '' },
|
|
{ id: 106735976, nombre: 'Usuario 106735976', email: '' },
|
|
])
|
|
expect(parseRrUserIds('')).toEqual([])
|
|
})
|
|
|
|
it('lanza error con IDs inválidos', () => {
|
|
expect(() => parseRrUserIds('123,abc')).toThrow(/inválido/)
|
|
expect(() => parseRrUserIds('-5')).toThrow(/inválido/)
|
|
})
|
|
})
|
|
|
|
describe('esEstadoTerminal (terminales por tablero)', () => {
|
|
it('Ganado / Perdido / Abandono son terminales en Oportunidades (default)', () => {
|
|
expect(esEstadoTerminal('GANADO')).toBe(true)
|
|
expect(esEstadoTerminal(' perdido ')).toBe(true)
|
|
expect(esEstadoTerminal('Abandono')).toBe(true)
|
|
expect(esEstadoTerminal('Afluencia')).toBe(false)
|
|
expect(esEstadoTerminal('Contactado')).toBe(false)
|
|
expect(esEstadoTerminal('Intento de contacto')).toBe(false)
|
|
expect(esEstadoTerminal('')).toBe(false)
|
|
})
|
|
|
|
it('Descartado / Calificado / Abandono son terminales en Leads (title case, case-insensitive)', () => {
|
|
expect(esEstadoTerminal('Descartado', ESTADOS_TERMINALES_LEAD)).toBe(true)
|
|
expect(esEstadoTerminal('calificado', ESTADOS_TERMINALES_LEAD)).toBe(true)
|
|
expect(esEstadoTerminal('ABANDONO', ESTADOS_TERMINALES_LEAD)).toBe(true)
|
|
expect(esEstadoTerminal('Nuevo', ESTADOS_TERMINALES_LEAD)).toBe(false)
|
|
expect(esEstadoTerminal('Contactado', ESTADOS_TERMINALES_LEAD)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('vendedorEnTurno (rotación round-robin)', () => {
|
|
it('rota circularmente por la lista', () => {
|
|
expect(vendedorEnTurno(VENDEDORES, 0)?.id).toBe(101)
|
|
expect(vendedorEnTurno(VENDEDORES, 1)?.id).toBe(102)
|
|
expect(vendedorEnTurno(VENDEDORES, 2)?.id).toBe(103)
|
|
expect(vendedorEnTurno(VENDEDORES, 3)?.id).toBe(101) // wraparound
|
|
expect(vendedorEnTurno(VENDEDORES, 7)?.id).toBe(102)
|
|
})
|
|
|
|
it('devuelve undefined con lista vacía', () => {
|
|
expect(vendedorEnTurno([], 0)).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('oportunidadesInactivas', () => {
|
|
const ahora = new Date('2026-08-22T00:00:00Z')
|
|
|
|
it('incluye oportunidades en etapa temprana CON asesor que superan el umbral de días', () => {
|
|
const ops = [
|
|
oportunidad('1', { updatedAt: '2026-08-10T00:00:00Z', asesorIds: [101] }), // 12 días → inactiva (umbral 10)
|
|
oportunidad('2', { updatedAt: '2026-08-20T00:00:00Z', asesorIds: [101] }), // 2 días → activa
|
|
oportunidad('3', { updatedAt: '2026-07-01T00:00:00Z', asesorIds: [101], etapa: 'Perdido' }), // terminal → excluida
|
|
oportunidad('4', { updatedAt: '2026-07-01T00:00:00Z' }), // sin asesor → excluida
|
|
oportunidad('5', { updatedAt: '2026-07-01T00:00:00Z', asesorIds: [101], etapa: 'Cotizador' }), // etapa avanzada → excluida
|
|
oportunidad('6', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [101], etapa: 'Contactado' }), // 21 días → inactiva
|
|
]
|
|
const inactivas = oportunidadesInactivas(ops, ahora, 10)
|
|
expect(inactivas.map((o) => o.id)).toEqual(['1', '6'])
|
|
})
|
|
|
|
it('una oportunidad actualizada hace exactamente N días aún no supera el umbral', () => {
|
|
const ops = [oportunidad('1', { updatedAt: '2026-08-12T00:00:00Z', asesorIds: [101] })] // justo 10 días
|
|
expect(oportunidadesInactivas(ops, ahora, 10)).toEqual([])
|
|
})
|
|
|
|
it('ignora fechas inválidas', () => {
|
|
expect(oportunidadesInactivas([oportunidad('1', { updatedAt: 'no-es-fecha', asesorIds: [101] })], ahora, 10)).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('claveRecordatorio (dedup por día)', () => {
|
|
it('una clave por oportunidad por día', () => {
|
|
expect(claveRecordatorio('123', new Date('2026-08-22T09:00:00Z'))).toBe('recordatorio:123:2026-08-22')
|
|
expect(claveRecordatorio('123', new Date('2026-08-22T23:59:00Z'))).toBe('recordatorio:123:2026-08-22')
|
|
expect(claveRecordatorio('123', new Date('2026-08-23T00:00:00Z'))).toBe('recordatorio:123:2026-08-23')
|
|
})
|
|
})
|
|
|
|
// ---------- Servicio (monday mockeado, sin red) ----------
|
|
|
|
describe('AutomatizacionesService.runRoundRobin', () => {
|
|
it('asigna en rotación sobre AMBOS tableros y persiste el índice (Leads → Vendedor, Oportunidades → Asesor)', async () => {
|
|
const monday = mondayMock([lead('l1'), lead('l2')], [oportunidad('o1'), oportunidad('o2')])
|
|
const state = stateEnMemoria()
|
|
const svc = crearServicio(monday, state)
|
|
|
|
const r1 = await svc.runRoundRobin()
|
|
expect(r1.asignados).toBe(4)
|
|
expect(monday.asignaciones).toEqual([
|
|
{ itemId: 'l1', userId: 101, tablero: 'leads' },
|
|
{ itemId: 'l2', userId: 102, tablero: 'leads' },
|
|
{ itemId: 'o1', userId: 103, tablero: 'oportunidades' },
|
|
{ itemId: 'o2', userId: 101, tablero: 'oportunidades' },
|
|
])
|
|
expect(r1.detalle.map((d) => d.tablero)).toEqual(['leads', 'leads', 'oportunidades', 'oportunidades'])
|
|
expect(state.mapa.get('rr:indice')).toBe('1') // 4 asignaciones % 3 vendedores
|
|
|
|
// Segunda pasada con una afluencia nueva: continúa donde quedó (no reinicia en 0)
|
|
const monday2 = mondayMock([], [oportunidad('o5')])
|
|
const svc2 = new AutomatizacionesService(monday2, state)
|
|
const r2 = await svc2.runRoundRobin()
|
|
expect(r2.asignados).toBe(1)
|
|
expect(monday2.asignaciones[0]?.userId).toBe(102)
|
|
expect(state.mapa.get('rr:indice')).toBe('2')
|
|
})
|
|
|
|
it('Leads: reparte los sin Vendedor y no terminales (labels title case, case-insensitive)', async () => {
|
|
const monday = mondayMock([
|
|
lead('1'),
|
|
lead('2', { vendedorIds: [101] }), // ya asignado
|
|
lead('3', { estado: 'Descartado' }), // terminal
|
|
lead('4', { estado: 'Calificado' }), // terminal
|
|
lead('5', { estado: 'Contactado' }), // en seguimiento: sí entra
|
|
])
|
|
const svc = crearServicio(monday, stateEnMemoria())
|
|
const r = await svc.runRoundRobin(undefined, 'leads')
|
|
expect(r.asignados).toBe(2)
|
|
expect(monday.asignaciones).toEqual([
|
|
{ itemId: '1', userId: 101, tablero: 'leads' },
|
|
{ itemId: '5', userId: 102, tablero: 'leads' },
|
|
])
|
|
})
|
|
|
|
it('Oportunidades: solo reparte etapa Afluencia (Cotizador/Contactado NO entran)', async () => {
|
|
const monday = mondayMock([], [
|
|
oportunidad('1'),
|
|
oportunidad('2', { asesorIds: [101] }), // ya asignada
|
|
oportunidad('3', { etapa: 'Cotizador' }), // creada por el cotizador: no entra
|
|
oportunidad('4', { etapa: 'Contactado' }), // ya en seguimiento: no entra
|
|
oportunidad('5', { etapa: 'Perdido' }), // terminal: no entra
|
|
oportunidad('6', { etapa: 'afluencia' }), // case-insensitive: sí entra
|
|
])
|
|
const svc = crearServicio(monday, stateEnMemoria())
|
|
const r = await svc.runRoundRobin(undefined, 'oportunidades')
|
|
expect(r.asignados).toBe(2)
|
|
expect(monday.asignaciones).toEqual([
|
|
{ itemId: '1', userId: 101, tablero: 'oportunidades' },
|
|
{ itemId: '6', userId: 102, tablero: 'oportunidades' },
|
|
])
|
|
})
|
|
|
|
it('omite items con bandera rr:skip=capturista (los asigna su capturista)', async () => {
|
|
const monday = mondayMock([lead('1')], [oportunidad('2')])
|
|
const state = stateEnMemoria()
|
|
state.mapa.set('rr:skip:1', 'capturista') // el capturista lo asigna
|
|
state.mapa.set('rr:skip:2', 'round-robin') // bandera liberada: sí entra al reparto
|
|
const svc = crearServicio(monday, state)
|
|
const r = await svc.runRoundRobin()
|
|
expect(r.asignados).toBe(1)
|
|
expect(monday.asignaciones).toEqual([{ itemId: '2', userId: 101, tablero: 'oportunidades' }])
|
|
})
|
|
|
|
it('con soloItemId + board solo evalúa ese item (ruta de los webhooks create_item)', async () => {
|
|
const monday = mondayMock([lead('1'), lead('2')], [oportunidad('9')])
|
|
const svc = crearServicio(monday, stateEnMemoria())
|
|
const r = await svc.runRoundRobin('2', 'leads')
|
|
expect(r.asignados).toBe(1)
|
|
expect(monday.asignaciones).toEqual([{ itemId: '2', userId: 101, tablero: 'leads' }])
|
|
})
|
|
|
|
it('con soloItemId de otro tablero no asigna nada', async () => {
|
|
const monday = mondayMock([lead('1')], [oportunidad('9')])
|
|
const svc = crearServicio(monday, stateEnMemoria())
|
|
const r = await svc.runRoundRobin('9', 'leads') // '9' no está en Leads
|
|
expect(r.asignados).toBe(0)
|
|
expect(monday.asignaciones).toEqual([])
|
|
})
|
|
|
|
it('cachea los vendedores del equipo en el estado (una sola llamada dentro del TTL)', async () => {
|
|
const monday = mondayMock()
|
|
const spy = vi.spyOn(monday, 'getUsuariosEquipo')
|
|
const state = stateEnMemoria()
|
|
const svc = crearServicio(monday, state)
|
|
const v1 = await svc.obtenerVendedores()
|
|
const v2 = await svc.obtenerVendedores()
|
|
expect(v1.map((v) => v.id)).toEqual([101, 102, 103])
|
|
expect(v2).toEqual(v1)
|
|
expect(spy).toHaveBeenCalledTimes(1)
|
|
expect(spy.mock.calls[0]?.[0]).toBe('vendedores') // equipo por default
|
|
})
|
|
|
|
it('cache con formato viejo (arreglo sin ts) se descarta y re-consulta', async () => {
|
|
const monday = mondayMock()
|
|
const spy = vi.spyOn(monday, 'getUsuariosEquipo')
|
|
const state = stateEnMemoria()
|
|
state.mapa.set('rr:vendedores', JSON.stringify([{ id: 999, nombre: 'Viejo', email: '' }]))
|
|
const svc = crearServicio(monday, state)
|
|
const v = await svc.obtenerVendedores()
|
|
expect(v.map((x) => x.id)).toEqual([101, 102, 103])
|
|
expect(spy).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|
|
|
|
describe('AutomatizacionesService.escaneoInactividad', () => {
|
|
const ahora = new Date('2026-08-22T09:00:00Z')
|
|
const oportunidadesViejas = () => [
|
|
oportunidad('1', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [101] }), // 21 días inactiva
|
|
oportunidad('2', { updatedAt: '2026-08-20T00:00:00Z', asesorIds: [101] }), // activa
|
|
]
|
|
|
|
it('preview: lista candidatos sin escribir en Monday ni en el estado', async () => {
|
|
const monday = mondayMock([], oportunidadesViejas())
|
|
const state = stateEnMemoria()
|
|
const svc = crearServicio(monday, state)
|
|
|
|
const r = await svc.escaneoInactividad({ preview: true, ahora })
|
|
expect(r.preview).toBe(true)
|
|
expect(r.candidatos.map((c) => c.itemId)).toEqual(['1'])
|
|
expect(r.candidatos[0]?.diasInactivo).toBe(21)
|
|
expect(r.recordatoriosEnviados).toBe(0)
|
|
expect(monday.updates).toEqual([])
|
|
expect(state.mapa.size).toBe(0) // preview no persiste nada
|
|
})
|
|
|
|
it('ejecución real: crea update, marca dedup y no repite el mismo día', async () => {
|
|
const monday = mondayMock([], oportunidadesViejas())
|
|
const state = stateEnMemoria()
|
|
const svc = crearServicio(monday, state)
|
|
|
|
const r1 = await svc.escaneoInactividad({ preview: false, ahora })
|
|
expect(r1.recordatoriosEnviados).toBe(1)
|
|
expect(monday.updates).toHaveLength(1)
|
|
expect(monday.updates[0]?.body).toContain('21 días')
|
|
expect(state.mapa.get('recordatorio:1:2026-08-22')).toBeDefined()
|
|
|
|
// Mismo día: no repite aunque la oportunidad siga inactiva
|
|
const r2 = await svc.escaneoInactividad({ preview: false, ahora: new Date('2026-08-22T18:00:00Z') })
|
|
expect(r2.recordatoriosEnviados).toBe(0)
|
|
expect(r2.candidatos[0]?.yaRecordadoHoy).toBe(true)
|
|
expect(monday.updates).toHaveLength(1)
|
|
|
|
// Al día siguiente vuelve a recordar
|
|
const r3 = await svc.escaneoInactividad({ preview: false, ahora: new Date('2026-08-23T09:00:00Z') })
|
|
expect(r3.recordatoriosEnviados).toBe(1)
|
|
expect(monday.updates).toHaveLength(2)
|
|
})
|
|
})
|
|
|
|
// ---------- Actividades automáticas (tablero Actividades del CRM) ----------
|
|
|
|
describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
|
|
const ahora = new Date('2026-08-22T09:00:00Z')
|
|
|
|
it('el round-robin crea "Contactar a {nombre}" tipo Llamada, Abierto, ligada a la oportunidad', async () => {
|
|
const monday = mondayMock([], [oportunidad('1'), oportunidad('2')])
|
|
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
|
|
|
|
const r = await svc.runRoundRobin()
|
|
expect(r.asignados).toBe(2)
|
|
expect(monday.actividades).toHaveLength(2)
|
|
expect(monday.actividades[0]).toMatchObject({
|
|
nombre: 'Contactar a Cliente 1',
|
|
tipoLabel: 'Llamada',
|
|
responsableId: 101,
|
|
estadoLabel: 'Abierto',
|
|
leadItemId: '1',
|
|
})
|
|
// Fecha compromiso: hoy + 1 día (fecha local, como la construye el servicio)
|
|
const esperada = new Date()
|
|
esperada.setDate(esperada.getDate() + 1)
|
|
const m = String(esperada.getMonth() + 1).padStart(2, '0')
|
|
const d = String(esperada.getDate()).padStart(2, '0')
|
|
expect(monday.actividades[0]?.fecha).toBe(`${esperada.getFullYear()}-${m}-${d}`)
|
|
expect(monday.actividades[1]?.responsableId).toBe(102)
|
|
})
|
|
|
|
it('con actividadesEnabled=false el round-robin no crea actividades', async () => {
|
|
const monday = mondayMock([], [oportunidad('1')])
|
|
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: false })
|
|
await svc.runRoundRobin()
|
|
expect(monday.actividades).toEqual([])
|
|
})
|
|
|
|
it('asignarACapturista: asigna al capturista (columna Asesor) y le crea su actividad de contacto', async () => {
|
|
const monday = mondayMock([])
|
|
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
|
|
|
|
await svc.asignarACapturista('op-77', 'Lead Propio', 555)
|
|
|
|
expect(monday.asignaciones).toEqual([{ itemId: 'op-77', userId: 555, tablero: 'oportunidades' }])
|
|
expect(monday.actividades[0]).toMatchObject({
|
|
nombre: 'Contactar a Lead Propio',
|
|
responsableId: 555,
|
|
estadoLabel: 'Abierto',
|
|
leadItemId: 'op-77',
|
|
})
|
|
})
|
|
|
|
it('asignarACapturista con board "leads" escribe la columna Vendedor del tablero Leads', async () => {
|
|
const monday = mondayMock([])
|
|
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
|
|
|
|
await svc.asignarACapturista('lead-77', 'Lead Propio', 555, 'leads')
|
|
|
|
expect(monday.asignaciones).toEqual([{ itemId: 'lead-77', userId: 555, tablero: 'leads' }])
|
|
expect(monday.actividades[0]).toMatchObject({ nombre: 'Contactar a Lead Propio', leadItemId: 'lead-77' })
|
|
})
|
|
|
|
it('asignarACapturista con actividadesEnabled=false solo asigna', async () => {
|
|
const monday = mondayMock([])
|
|
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: false })
|
|
|
|
await svc.asignarACapturista('op-78', 'Lead Propio', 555)
|
|
|
|
expect(monday.asignaciones).toEqual([{ itemId: 'op-78', userId: 555, tablero: 'oportunidades' }])
|
|
expect(monday.actividades).toEqual([])
|
|
})
|
|
|
|
it('inactividad: crea "Retomar contacto" al asesor asignado; las candidatas siempre tienen asesor', async () => {
|
|
const monday = mondayMock([], [oportunidad('1', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [103] })])
|
|
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
|
|
|
|
const r = await svc.escaneoInactividad({ preview: false, ahora })
|
|
expect(r.recordatoriosEnviados).toBe(1)
|
|
expect(monday.actividades).toHaveLength(1)
|
|
expect(monday.actividades[0]).toMatchObject({
|
|
nombre: 'Retomar contacto con Cliente 1',
|
|
tipoLabel: 'Llamada',
|
|
responsableId: 103,
|
|
estadoLabel: 'Abierto',
|
|
leadItemId: '1',
|
|
})
|
|
})
|
|
|
|
it('preview de inactividad no crea actividades', async () => {
|
|
const monday = mondayMock([], [oportunidad('1', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [101] })])
|
|
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
|
|
await svc.escaneoInactividad({ preview: true, ahora })
|
|
expect(monday.actividades).toEqual([])
|
|
})
|
|
|
|
it('un fallo al crear la actividad no rompe la asignación', async () => {
|
|
const monday = mondayMock([], [oportunidad('1')])
|
|
vi.spyOn(monday, 'crearActividad').mockRejectedValueOnce(new Error('Monday caído'))
|
|
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
|
|
|
|
const r = await svc.runRoundRobin()
|
|
expect(r.asignados).toBe(1) // la asignación se hizo igual
|
|
expect(monday.asignaciones).toEqual([{ itemId: '1', userId: 101, tablero: 'oportunidades' }])
|
|
spyError.mockRestore()
|
|
})
|
|
})
|
|
|
|
// ---------- Escalado de afluencias sin contactar ----------
|
|
|
|
describe('oportunidadesSinContactar (regla de escalado por horas)', () => {
|
|
const ahora = new Date('2026-08-22T12:00:00Z')
|
|
|
|
it('incluye oportunidades asignadas en Afluencia / Intento de contacto que superan el umbral', () => {
|
|
const ops = [
|
|
oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-19T12:00:00Z' }), // 72 h → escala
|
|
oportunidad('2', { asesorIds: [102], etapa: 'Intento de contacto', updatedAt: '2026-08-20T12:00:00Z' }), // 48 h justo → no
|
|
oportunidad('3', { asesorIds: [103], etapa: 'Contactado', updatedAt: '2026-08-01T12:00:00Z' }), // ya contactada
|
|
oportunidad('4', { etapa: 'Afluencia', updatedAt: '2026-08-01T12:00:00Z' }), // sin asesor → no aplica
|
|
oportunidad('5', { asesorIds: [101], etapa: 'Perdido', updatedAt: '2026-08-01T12:00:00Z' }), // terminal
|
|
]
|
|
expect(oportunidadesSinContactar(ops, ahora, 48).map((o) => o.id)).toEqual(['1'])
|
|
})
|
|
|
|
it('una oportunidad con exactamente N horas aún no supera el umbral', () => {
|
|
const ops = [oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-20T12:00:00Z' })]
|
|
expect(oportunidadesSinContactar(ops, ahora, 48)).toEqual([])
|
|
})
|
|
|
|
it('ignora fechas inválidas', () => {
|
|
const ops = [oportunidad('1', { asesorIds: [101], updatedAt: 'no-es-fecha' })]
|
|
expect(oportunidadesSinContactar(ops, ahora, 48)).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('claves de dedup', () => {
|
|
it('una clave por oportunidad', () => {
|
|
expect(claveEscalado('123')).toBe('escalado:123')
|
|
expect(claveApartadoCreado('456')).toBe('apartado-creado:456')
|
|
expect(claveVentaCreada('456')).toBe('venta-creada:456')
|
|
})
|
|
})
|
|
|
|
describe('esEtapa (detección de transición de etapa)', () => {
|
|
it('compara tolerando mayúsculas y espacios', () => {
|
|
expect(esEtapa('Aparto', 'APARTO')).toBe(true)
|
|
expect(esEtapa(' ganado ', 'Ganado')).toBe(true)
|
|
expect(esEtapa('Cotizador', 'Aparto')).toBe(false)
|
|
expect(esEtapa('', 'Ganado')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('AutomatizacionesService.escaneoEscalado', () => {
|
|
const ahora = new Date('2026-08-22T12:00:00Z')
|
|
const configAuto = config.automatizaciones as Record<string, unknown>
|
|
|
|
it('escala al asesor asignado cuando no hay coordinador configurado', async () => {
|
|
const anterior = configAuto['coordinadorUserId']
|
|
configAuto['coordinadorUserId'] = 0
|
|
try {
|
|
const monday = mondayMock([], [oportunidad('1', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' })])
|
|
const state = stateEnMemoria()
|
|
const svc = new AutomatizacionesService(monday, state, { actividadesEnabled: true })
|
|
|
|
const r = await svc.escaneoEscalado({ ahora })
|
|
expect(r.escalados).toBe(1)
|
|
expect(r.candidatos[0]).toMatchObject({ itemId: '1', horasSinContactar: 72, responsableId: 102, yaEscalado: false })
|
|
expect(monday.actividades[0]).toMatchObject({
|
|
nombre: 'URGENTE: oportunidad sin contactar — Cliente 1',
|
|
responsableId: 102,
|
|
estadoLabel: 'Abierto',
|
|
leadItemId: '1',
|
|
})
|
|
expect(monday.updates[0]?.body).toContain('72 h')
|
|
expect(state.mapa.get('escalado:1')).toBeDefined()
|
|
expect(state.mapa.get('contador:escalados')).toBe('1')
|
|
} finally {
|
|
configAuto['coordinadorUserId'] = anterior
|
|
}
|
|
})
|
|
|
|
it('escala al coordinador cuando MONDAY_COORDINADOR_USER_ID está configurado', async () => {
|
|
const anterior = configAuto['coordinadorUserId']
|
|
configAuto['coordinadorUserId'] = 999
|
|
try {
|
|
const monday = mondayMock([], [oportunidad('1', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' })])
|
|
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
|
|
const r = await svc.escaneoEscalado({ ahora })
|
|
expect(r.escalados).toBe(1)
|
|
expect(monday.actividades[0]?.responsableId).toBe(999)
|
|
expect(r.candidatos[0]?.responsableId).toBe(999)
|
|
} finally {
|
|
configAuto['coordinadorUserId'] = anterior
|
|
}
|
|
})
|
|
|
|
it('dedup: no vuelve a escalar una oportunidad ya escalada', async () => {
|
|
const anterior = configAuto['coordinadorUserId']
|
|
configAuto['coordinadorUserId'] = 0
|
|
try {
|
|
const monday = mondayMock([], [oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-19T12:00:00Z' })])
|
|
const state = stateEnMemoria()
|
|
const svc = new AutomatizacionesService(monday, state, { actividadesEnabled: true })
|
|
|
|
const r1 = await svc.escaneoEscalado({ ahora })
|
|
expect(r1.escalados).toBe(1)
|
|
const r2 = await svc.escaneoEscalado({ ahora: new Date('2026-08-23T12:00:00Z') })
|
|
expect(r2.escalados).toBe(0)
|
|
expect(r2.candidatos[0]?.yaEscalado).toBe(true)
|
|
expect(monday.actividades).toHaveLength(1)
|
|
expect(state.mapa.get('contador:escalados')).toBe('1')
|
|
} finally {
|
|
configAuto['coordinadorUserId'] = anterior
|
|
}
|
|
})
|
|
|
|
it('un fallo de Monday en una oportunidad no detiene el escalado de las demás', async () => {
|
|
const anterior = configAuto['coordinadorUserId']
|
|
configAuto['coordinadorUserId'] = 0
|
|
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
try {
|
|
const monday = mondayMock([], [
|
|
oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-19T12:00:00Z' }),
|
|
oportunidad('2', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' }),
|
|
])
|
|
vi.spyOn(monday, 'crearUpdateEnItem')
|
|
.mockRejectedValueOnce(new Error('Monday caído'))
|
|
.mockResolvedValue('upd-ok')
|
|
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
|
|
|
|
const r = await svc.escaneoEscalado({ ahora })
|
|
expect(r.escalados).toBe(1) // la segunda oportunidad sí se escaló
|
|
expect(r.candidatos).toHaveLength(2)
|
|
} finally {
|
|
configAuto['coordinadorUserId'] = anterior
|
|
spyError.mockRestore()
|
|
}
|
|
})
|
|
})
|
|
|
|
// ---------- Oportunidad → Aparto / Ganado ----------
|
|
|
|
describe('AutomatizacionesService.escaneoOportunidades', () => {
|
|
it('una oportunidad en Aparto crea su item en Apartado (estado Pago Apartado)', async () => {
|
|
const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' })])
|
|
const state = stateEnMemoria()
|
|
const svc = crearServicio(monday, state)
|
|
|
|
const r = await svc.escaneoOportunidades()
|
|
expect(r.apartadosCreados).toEqual([{ oportunidadId: '10', nombre: 'Cliente 10', apartadoItemId: 'apt-1' }])
|
|
expect(monday.apartados[0]).toMatchObject({
|
|
clienteNombre: 'Cliente 10',
|
|
unidadCodigo: 'U-101',
|
|
estadoLabel: 'Pago Apartado',
|
|
})
|
|
expect(state.mapa.get('apartado-creado:10')).toBe('apt-1')
|
|
expect(state.mapa.get('contador:apartadosCreados')).toBe('1')
|
|
})
|
|
|
|
it('el Apartado hereda de la oportunidad: contacto, Precio Final y Vendedor (primer asesor)', async () => {
|
|
const monday = mondayMock([], [
|
|
oportunidad('10', { etapa: 'Aparto', telefono: '6641112233', email: 'c@c.com', asesorIds: [101, 102] }),
|
|
])
|
|
const state = stateEnMemoria()
|
|
const svc = crearServicio(monday, state)
|
|
|
|
const r = await svc.escaneoOportunidades()
|
|
expect(r.apartadosCreados).toHaveLength(1)
|
|
expect(monday.apartados[0]).toMatchObject({
|
|
clienteNombre: 'Cliente 10',
|
|
unidadCodigo: 'U-101',
|
|
telefono: '6641112233',
|
|
email: 'c@c.com',
|
|
precioFinal: 2500000,
|
|
vendedorId: 101,
|
|
})
|
|
})
|
|
|
|
it('una oportunidad en Ganado crea su item en Ventas con unidad y valor', async () => {
|
|
const monday = mondayMock([], [oportunidad('11', { etapa: 'GANADO' })])
|
|
const state = stateEnMemoria()
|
|
const svc = crearServicio(monday, state)
|
|
|
|
const r = await svc.escaneoOportunidades()
|
|
expect(r.ventasCreadas).toEqual([{ oportunidadId: '11', nombre: 'Cliente 11', ventaItemId: 'vta-1' }])
|
|
expect(monday.ventas[0]).toMatchObject({ clienteNombre: 'Cliente 11', unidadCodigo: 'U-101', valorVenta: 2500000 })
|
|
expect(state.mapa.get('venta-creada:11')).toBe('vta-1')
|
|
})
|
|
|
|
it('dedup: la segunda pasada no duplica apartados ni ventas', async () => {
|
|
const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' }), oportunidad('11', { etapa: 'Ganado' })])
|
|
const state = stateEnMemoria()
|
|
const svc = crearServicio(monday, state)
|
|
|
|
await svc.escaneoOportunidades()
|
|
const r2 = await svc.escaneoOportunidades()
|
|
expect(r2.apartadosCreados).toEqual([])
|
|
expect(r2.ventasCreadas).toEqual([])
|
|
expect(monday.apartados).toHaveLength(1)
|
|
expect(monday.ventas).toHaveLength(1)
|
|
expect(state.mapa.get('contador:apartadosCreados')).toBe('1')
|
|
expect(state.mapa.get('contador:ventasCreadas')).toBe('1')
|
|
})
|
|
|
|
it('ignora oportunidades en otras etapas', async () => {
|
|
const monday = mondayMock([], [
|
|
oportunidad('10', { etapa: 'Cotizador' }),
|
|
oportunidad('11', { etapa: 'Perdido' }),
|
|
oportunidad('12', { etapa: 'Afluencia' }),
|
|
])
|
|
const svc = crearServicio(monday, stateEnMemoria())
|
|
const r = await svc.escaneoOportunidades()
|
|
expect(r.apartadosCreados).toEqual([])
|
|
expect(r.ventasCreadas).toEqual([])
|
|
expect(monday.apartados).toEqual([])
|
|
expect(monday.ventas).toEqual([])
|
|
})
|
|
|
|
it('un fallo de Monday en una oportunidad no detiene a las demás', async () => {
|
|
const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' }), oportunidad('11', { etapa: 'Ganado' })])
|
|
vi.spyOn(monday, 'crearApartado').mockRejectedValueOnce(new Error('Monday caído'))
|
|
const state = stateEnMemoria()
|
|
const svc = crearServicio(monday, state)
|
|
|
|
const r = await svc.escaneoOportunidades()
|
|
expect(r.errores).toHaveLength(1)
|
|
expect(r.errores[0]?.oportunidadId).toBe('10')
|
|
expect(r.ventasCreadas).toHaveLength(1) // la segunda oportunidad sí se procesó
|
|
expect(state.mapa.get('apartado-creado:10')).toBeUndefined() // sin dedup: se reintenta en la próxima pasada
|
|
})
|
|
})
|