- Columna Etapa en Monday Unidades (10 labels oficiales), 155 unidades pobladas - sync-etapas.ts idempotente (--archivo/--dry-run/--semilla) con traspasos Vendido→Escrituración y Escriturado→SAC como actividades (sin retroactivas) - API: etapa en unidades + porEtapa en /api/reportes/resumen - Dashboard: gráfica pipeline por etapa + KPI en notaría; cotizador muestra etapa - 28 cambios aplicados desde Reporte_EstatusVenta (8 avances, 20 nuevas); contradicciones retenidas para revisión humana (documentadas) - Tests: api 168, dashboards 9, cotizador 71
134 lines
4.3 KiB
TypeScript
134 lines
4.3 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
||
import {
|
||
datosCobranza,
|
||
datosFunnel,
|
||
datosPipeline,
|
||
formatoMoneda,
|
||
formatoPorcentaje,
|
||
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: {} },
|
||
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 → Oportunidades → Aparto o más → Ganado sin subir', () => {
|
||
const funnel = datosFunnel(resumen())
|
||
expect(funnel.map((e) => e.etapa)).toEqual(['Leads', 'Oportunidades', 'Aparto o más', 'Ganado'])
|
||
expect(funnel.map((e) => e.valor)).toEqual([40, 20, 6, 2]) // Aparto(4) + Ganado(2) acumulados
|
||
const valores = funnel.map((e) => e.valor)
|
||
for (let i = 1; i < valores.length; i++) expect(valores[i]!).toBeLessThanOrEqual(valores[i - 1]!)
|
||
})
|
||
|
||
it('tolera etiquetas con otra capitalización', () => {
|
||
const r = resumen()
|
||
r.oportunidades.porEtapa = { aparto: 1, ' GANADO ': 3 }
|
||
const funnel = datosFunnel(r)
|
||
expect(funnel[2]?.valor).toBe(4)
|
||
expect(funnel[3]?.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('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%')
|
||
})
|
||
})
|