feat: automatizaciones ronda 2 + app de dashboards /dash
- Escalado: lead asignado sin contactar >48h genera actividad URGENTE al coordinador (LEAD_ESCALADO_HORAS, MONDAY_COORDINADOR_USER_ID) - Oportunidad en Aparto crea item en Apartado (fecha límite +7d); en Ganado crea la Venta; ambos con dedup - GET /api/reportes/resumen: leads, funnel, unidades por desarrollo, cobranza y actividades en una respuesta (cache 60s) - App dashboards (/dash): 8 KPIs + 3 gráficas Recharts, con auth - Landing: tarjeta Tableros; docs/AUTOMATIZACIONES-MONDAY.md con las recetas nativas para activar en la UI - Tests: api 158, dashboards 7, landing 5 — todo verde
This commit is contained in:
@@ -1,14 +1,20 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { config } from '../config/index.js'
|
||||
import {
|
||||
AutomatizacionesService,
|
||||
claveApartadoCreado,
|
||||
claveEscalado,
|
||||
claveRecordatorio,
|
||||
claveVentaCreada,
|
||||
esEstadoTerminal,
|
||||
esEtapa,
|
||||
leadsInactivos,
|
||||
leadsSinContactar,
|
||||
parseRrUserIds,
|
||||
vendedorEnTurno,
|
||||
type StateStore,
|
||||
} from './automatizaciones.service.js'
|
||||
import type { LeadMonday, UsuarioMonday } from './monday.service.js'
|
||||
import type { LeadMonday, OportunidadMonday, UsuarioMonday } from './monday.service.js'
|
||||
|
||||
// ---------- Fixtures ----------
|
||||
|
||||
@@ -22,13 +28,27 @@ 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',
|
||||
unidadCodigo: 'U-101',
|
||||
precioFinal: 2500000,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** StateStore en memoria para tests (sin Prisma) */
|
||||
function stateEnMemoria(): StateStore & { mapa: Map<string, string> } {
|
||||
const mapa = new Map<string, string>()
|
||||
@@ -44,7 +64,7 @@ function stateEnMemoria(): StateStore & { mapa: Map<string, string> } {
|
||||
}
|
||||
|
||||
/** Monday mockeado: leads fijos, registra asignaciones, updates y actividades */
|
||||
function mondayMock(leads: LeadMonday[]) {
|
||||
function mondayMock(leads: LeadMonday[], oportunidades: OportunidadMonday[] = []) {
|
||||
return {
|
||||
asignaciones: [] as { itemId: string; userId: number }[],
|
||||
updates: [] as { itemId: string; body: string }[],
|
||||
@@ -56,9 +76,14 @@ function mondayMock(leads: LeadMonday[]) {
|
||||
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 })
|
||||
},
|
||||
@@ -77,6 +102,14 @@ function mondayMock(leads: LeadMonday[]) {
|
||||
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 getUsuariosWorkspace() {
|
||||
return VENDEDORES
|
||||
},
|
||||
@@ -339,3 +372,208 @@ describe('ESTADOS_TERMINALES_LEAD con vocabulario CRM', () => {
|
||||
expect(esEstadoTerminal('Intento de contacto')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- Ronda 2: escalado de leads sin contactar ----------
|
||||
|
||||
describe('leadsSinContactar (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
|
||||
]
|
||||
expect(leadsSinContactar(leads, ahora, 48).map((l) => l.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('ignora fechas inválidas', () => {
|
||||
const leads = [lead('1', { vendedorIds: [101], updatedAt: 'no-es-fecha' })]
|
||||
expect(leadsSinContactar(leads, ahora, 48)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('claves de dedup de ronda 2', () => {
|
||||
it('una clave por lead/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 vendedor 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 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: lead sin contactar — Lead 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([lead('1', { vendedorIds: [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 un lead ya escalado', async () => {
|
||||
const anterior = configAuto['coordinadorUserId']
|
||||
configAuto['coordinadorUserId'] = 0
|
||||
try {
|
||||
const monday = mondayMock([lead('1', { vendedorIds: [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 un lead no detiene el escalado de los 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' }),
|
||||
])
|
||||
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) // el segundo lead sí se escaló
|
||||
expect(r.candidatos).toHaveLength(2)
|
||||
} finally {
|
||||
configAuto['coordinadorUserId'] = anterior
|
||||
spyError.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- Ronda 2: 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('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
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user