- POST /api/leads: captura ligera al tablero Leads (sin oportunidad), self-assign a Vendedor o round-robin; tambien cubre la captura manual desde la UI de Monday (webhook create_item re-registrado). - POST /api/afluencias (nuevo): la forma completa sigue cayendo en Oportunidades con todo el perfil (etapa Afluencia, columna Asesor). - Round-robin dual (ambos tableros), Mi dia mezcla leads+oportunidades con insignia de origen, acciones con parametro fuente, reportes: leads desde Leads, perfilamiento desde Oportunidades, funnel Leads->Afluencia->...->Ganado.
167 lines
5.1 KiB
TypeScript
167 lines
5.1 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
||
import {
|
||
datosCobranza,
|
||
datosFunnel,
|
||
datosPipeline,
|
||
formatoMoneda,
|
||
formatoPorcentaje,
|
||
rebanadasDe,
|
||
unidadesApiladas,
|
||
unidadesEnEtapa,
|
||
type ResumenReportes,
|
||
} from './reportes'
|
||
|
||
function resumen(overrides: Partial<ResumenReportes> = {}): ResumenReportes {
|
||
return {
|
||
generadoEn: '2026-08-22T12:00:00.000Z',
|
||
leads: {
|
||
total: 40,
|
||
nuevosHoy: 3,
|
||
nuevosSemana: 12,
|
||
porEstado: {},
|
||
porOrigen: {},
|
||
perfil: {
|
||
porDesarrollo: { ATELIER: 10, DIAGONAL: 8 },
|
||
porModelo: {},
|
||
porEstadoCivil: { Soltero: 15, Casado: 9 },
|
||
porTipoEmpleo: {},
|
||
porTipoEconomia: {},
|
||
porCalifica: { Si: 20 },
|
||
porCredito: {},
|
||
porRangoSalarial: {},
|
||
porMedioContacto: {},
|
||
porLoQuierePara: {},
|
||
porEdad: { '25–34': 12, '15–24': 5 },
|
||
},
|
||
},
|
||
oportunidades: {
|
||
total: 20,
|
||
activas: 15,
|
||
porEtapa: { Afluencia: 8, Cotizador: 5, Aparto: 4, Ganado: 2, Perdido: 1 },
|
||
tasaAparto: 0.3,
|
||
tasaGanado: 0.1,
|
||
},
|
||
unidades: {
|
||
total: 5,
|
||
disponibles: 2,
|
||
porDesarrollo: [
|
||
{ desarrollo: 'ATELIER', total: 3, porStatus: { DISPONIBLE: 1, VENDIDO: 2 } },
|
||
{ desarrollo: 'SOLANA', total: 2, porStatus: { DISPONIBLE: 1, ESCRITURADO: 1 } },
|
||
],
|
||
porEtapa: [
|
||
{ etapa: 'Apartado', total: 1 },
|
||
{ etapa: 'Reserva', total: 0 },
|
||
{ etapa: 'Vendido', total: 0 },
|
||
{ etapa: 'Integración', total: 0 },
|
||
{ etapa: 'En Análisis', total: 0 },
|
||
{ etapa: 'Autorizado', total: 0 },
|
||
{ etapa: 'Notaría', total: 2 },
|
||
{ etapa: 'Escriturado', total: 1 },
|
||
{ etapa: 'Entrega', total: 0 },
|
||
{ etapa: 'PostVenta', total: 0 },
|
||
],
|
||
},
|
||
cobranza: {
|
||
planes: { activos: 6, liquidados: 2, cancelados: 1 },
|
||
planesAlCorriente: 4,
|
||
planesConVencidos: 2,
|
||
pagosVencidos: 3,
|
||
montoVencido: 125000,
|
||
},
|
||
actividadesAbiertas: 7,
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
describe('unidadesApiladas', () => {
|
||
it('mapea desarrollo × status con columnas fijas y agrupa lo desconocido en OTROS', () => {
|
||
expect(unidadesApiladas(resumen())).toEqual([
|
||
{ desarrollo: 'ATELIER', DISPONIBLE: 1, APARTADO: 0, VENDIDO: 2, BLOQUEADO: 0, OTROS: 0 },
|
||
{ desarrollo: 'SOLANA', DISPONIBLE: 1, APARTADO: 0, VENDIDO: 0, BLOQUEADO: 0, OTROS: 1 },
|
||
])
|
||
})
|
||
|
||
it('con inventario vacío devuelve arreglo vacío', () => {
|
||
expect(unidadesApiladas(resumen({ unidades: { total: 0, disponibles: 0, porDesarrollo: [], porEtapa: [] } }))).toEqual([])
|
||
})
|
||
})
|
||
|
||
describe('datosPipeline', () => {
|
||
it('devuelve las 10 etapas en el orden oficial con sus totales', () => {
|
||
const pipeline = datosPipeline(resumen())
|
||
expect(pipeline.map((e) => e.etapa)).toEqual([
|
||
'Apartado',
|
||
'Reserva',
|
||
'Vendido',
|
||
'Integración',
|
||
'En Análisis',
|
||
'Autorizado',
|
||
'Notaría',
|
||
'Escriturado',
|
||
'Entrega',
|
||
'PostVenta',
|
||
])
|
||
expect(pipeline.find((e) => e.etapa === 'Notaría')?.valor).toBe(2)
|
||
expect(pipeline.find((e) => e.etapa === 'Escriturado')?.valor).toBe(1)
|
||
})
|
||
})
|
||
|
||
describe('unidadesEnEtapa', () => {
|
||
it('devuelve el total de la etapa pedida y 0 si no aparece', () => {
|
||
expect(unidadesEnEtapa(resumen(), 'Notaría')).toBe(2)
|
||
expect(unidadesEnEtapa(resumen(), 'Reserva')).toBe(0)
|
||
expect(unidadesEnEtapa(resumen({ unidades: { total: 0, disponibles: 0, porDesarrollo: [], porEtapa: [] } }), 'Notaría')).toBe(0)
|
||
})
|
||
})
|
||
|
||
describe('datosFunnel', () => {
|
||
it('ordena Leads → Afluencia → Agendo Cita → Cotizador → Aparto o más → Ganado', () => {
|
||
const funnel = datosFunnel(resumen())
|
||
expect(funnel.map((e) => e.etapa)).toEqual(['Leads', 'Afluencia', 'Agendo Cita', 'Cotizador', 'Aparto o más', 'Ganado'])
|
||
expect(funnel.map((e) => e.valor)).toEqual([40, 8, 0, 5, 6, 2]) // Aparto(4) + Ganado(2) acumulados
|
||
})
|
||
|
||
it('tolera etiquetas con otra capitalización', () => {
|
||
const r = resumen()
|
||
r.oportunidades.porEtapa = { afluencia: 3, ' GANADO ': 3, aparto: 1 }
|
||
const funnel = datosFunnel(r)
|
||
expect(funnel[1]?.valor).toBe(3)
|
||
expect(funnel[4]?.valor).toBe(4)
|
||
expect(funnel[5]?.valor).toBe(3)
|
||
})
|
||
})
|
||
|
||
describe('datosCobranza', () => {
|
||
it('devuelve al corriente vs con vencidos', () => {
|
||
expect(datosCobranza(resumen())).toEqual([
|
||
{ nombre: 'Al corriente', valor: 4 },
|
||
{ nombre: 'Con vencidos', valor: 2 },
|
||
])
|
||
})
|
||
})
|
||
|
||
describe('rebanadasDe (perfilamiento)', () => {
|
||
it('ordena desc y filtra los valores en 0', () => {
|
||
expect(rebanadasDe({ Soltero: 15, Casado: 9, Viudo: 0, 'Union Libre': 22 })).toEqual([
|
||
{ nombre: 'Union Libre', valor: 22 },
|
||
{ nombre: 'Soltero', valor: 15 },
|
||
{ nombre: 'Casado', valor: 9 },
|
||
])
|
||
})
|
||
|
||
it('registro vacío devuelve lista vacía (la tarjeta muestra "Sin datos")', () => {
|
||
expect(rebanadasDe({})).toEqual([])
|
||
})
|
||
})
|
||
|
||
describe('formatos', () => {
|
||
it('moneda MXN sin centavos', () => {
|
||
expect(formatoMoneda(125000)).toBe('$125,000')
|
||
})
|
||
|
||
it('porcentaje desde fracción', () => {
|
||
expect(formatoPorcentaje(0.4)).toBe('40%')
|
||
expect(formatoPorcentaje(0.333)).toBe('33.3%')
|
||
})
|
||
})
|