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:
2026-08-27 09:05:09 +00:00
parent de11af985e
commit 04568a5603
34 changed files with 6279 additions and 8 deletions

View File

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