Files
FRASA-CAS/apps/dashboards/src/lib/reportes.test.ts
Consult AS Technology 135e3e6de5 feat(leads): doble via de captura — lead rapido a Leads, afluencia a Oportunidades
- 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.
2026-09-04 06:09:38 +00:00

167 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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%')
})
})