Files
FRASA-CAS/api-gateway/src/services/reportes.service.test.ts

366 lines
13 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, vi } from 'vitest'
import { construirResumen, ReportesService, type DatosResumen } from './reportes.service.js'
import type { LeadFila, OportunidadFila, Unidad } from './reportes.service.js'
import type { PlanPago } from './cobranza.service.js'
// ---------- Fixtures ----------
/** Lead del tablero Leads (captura ligera; fuente de la sección leads) */
function lead(id: string, overrides: Partial<LeadFila> = {}): LeadFila {
return {
id,
nombre: `Lead ${id}`,
createdAt: '2026-08-20T10:00:00Z',
updatedAt: '2026-08-20T10:00:00Z',
estado: 'Nuevo',
origen: 'Facebook',
vendedorIds: [],
telefono: '',
email: '',
...overrides,
}
}
/** Afluencia del board Oportunidades (fuente del perfilamiento de afluencia) */
function afluencia(id: string, overrides: Partial<OportunidadFila> = {}): OportunidadFila {
return {
id,
nombre: `Lead ${id}`,
createdAt: '2026-08-20T10:00:00Z',
updatedAt: '2026-08-20T10:00:00Z',
etapa: 'Afluencia',
origen: 'Facebook',
unidadCodigo: '',
precioFinal: 0,
asesorIds: [],
telefono: '',
email: '',
...overrides,
}
}
function op(id: string, etapa: string): OportunidadFila {
return afluencia(id, { nombre: `Cliente ${id}`, createdAt: '2026-08-01T09:00:00Z', etapa, unidadCodigo: 'U-101' })
}
function unidad(codigo: string, desarrollo: string, status: string, etapa: string | null = null): Unidad {
return {
codigo,
modelo: 'M1',
fechaEntrega: null,
supEstacionamiento: 0,
supConstruccion: 0,
supExcedente: 0,
precioSinAcabados: 0,
precioConAcabados: 0,
status,
desarrollo,
mesesParaFirma: null,
etapa,
}
}
function plan(id: string, overrides: Partial<PlanPago> = {}): PlanPago {
return {
id,
clienteNombre: `Cliente ${id}`,
unidadCodigo: 'U-101',
proyecto: 'ATELIER',
fechaInicio: '2026-07-01',
engancheTotal: 100000,
numPagos: 2,
mensualidad: 50000,
status: 'ACTIVO',
pagos: [],
notas: [],
createdAt: '2026-07-01T00:00:00.000Z',
...overrides,
}
}
function pago(id: string, fechaVencimiento: string, monto: number, montoPagado = 0): PlanPago['pagos'][number] {
return { id, numPago: 1, fechaVencimiento, monto, montoPagado, aplicaciones: [] }
}
const AHORA = new Date('2026-08-22T12:00:00') // mediodía local
const DATOS_BASE: DatosResumen = { leads: [], oportunidades: [], unidades: [], planes: [], actividadesAbiertas: 0 }
// ---------- construirResumen (puro) ----------
describe('construirResumen', () => {
it('cuenta leads por estado y por origen (tablero Leads, captura ligera)', () => {
const r = construirResumen(
{
...DATOS_BASE,
leads: [
lead('1', { estado: 'Nuevo', origen: 'Facebook' }),
lead('2', { estado: 'Nuevo', origen: 'Facebook' }),
lead('3', { estado: 'Contactado', origen: 'Referido' }),
lead('4', { estado: '', origen: '' }),
],
},
AHORA,
)
expect(r.leads.total).toBe(4)
expect(r.leads.porEstado).toEqual({ Nuevo: 2, Contactado: 1, '(sin dato)': 1 })
expect(r.leads.porOrigen).toEqual({ Facebook: 2, Referido: 1, '(sin dato)': 1 })
})
it('cuenta leads nuevos de hoy y de los últimos 7 días (fecha local)', () => {
const r = construirResumen(
{
...DATOS_BASE,
leads: [
lead('1', { createdAt: '2026-08-22T08:00:00' }), // hoy
lead('2', { createdAt: '2026-08-18T23:00:00' }), // esta semana
lead('3', { createdAt: '2026-08-10T12:00:00' }), // hace 12 días
],
},
AHORA,
)
expect(r.leads.nuevosHoy).toBe(1)
expect(r.leads.nuevosSemana).toBe(2)
})
it('agrega oportunidades por etapa y calcula tasas de Aparto y Ganado', () => {
const r = construirResumen(
{
...DATOS_BASE,
oportunidades: [
op('1', 'Afluencia'),
op('2', 'Cotizador'),
op('3', 'Aparto'),
op('4', 'Ganado'),
op('5', 'Perdido'),
],
},
AHORA,
)
expect(r.oportunidades.total).toBe(5)
expect(r.oportunidades.porEtapa).toEqual({ Afluencia: 1, Cotizador: 1, Aparto: 1, Ganado: 1, Perdido: 1 })
expect(r.oportunidades.activas).toBe(3) // Afluencia, Cotizador, Aparto (ni Ganado ni cerradas)
expect(r.oportunidades.tasaAparto).toBeCloseTo(2 / 5) // Aparto + Ganado
expect(r.oportunidades.tasaGanado).toBeCloseTo(1 / 5)
})
it('sin oportunidades las tasas son 0 (no divide entre cero)', () => {
const r = construirResumen(DATOS_BASE, AHORA)
expect(r.oportunidades.tasaAparto).toBe(0)
expect(r.oportunidades.tasaGanado).toBe(0)
})
it('agrega unidades por desarrollo × status y cuenta disponibles', () => {
const r = construirResumen(
{
...DATOS_BASE,
unidades: [
unidad('U-101', 'ATELIER', 'DISPONIBLE'),
unidad('U-102', 'ATELIER', 'VENDIDO'),
unidad('U-201', 'SOLANA', 'DISPONIBLE'),
unidad('U-202', 'SOLANA', 'apartado'), // status se normaliza a mayúsculas
],
},
AHORA,
)
expect(r.unidades.total).toBe(4)
expect(r.unidades.disponibles).toBe(2)
expect(r.unidades.porDesarrollo).toEqual([
{ desarrollo: 'ATELIER', total: 2, porStatus: { DISPONIBLE: 1, VENDIDO: 1 } },
{ desarrollo: 'SOLANA', total: 2, porStatus: { APARTADO: 1, DISPONIBLE: 1 } },
])
})
it('cuenta unidades por etapa del pipeline, en el orden oficial de las 10 etapas', () => {
const r = construirResumen(
{
...DATOS_BASE,
unidades: [
unidad('U-101', 'ATELIER', 'VENDIDO', 'Notaría'),
unidad('U-102', 'ATELIER', 'VENDIDO', 'Notaría'),
unidad('U-103', 'ATELIER', 'ESCRITURADO', 'Escriturado'),
unidad('U-104', 'ATELIER', 'APARTADO', 'Apartado'),
unidad('U-105', 'ATELIER', 'DISPONIBLE'), // sin etapa: no cuenta
],
},
AHORA,
)
expect(r.unidades.porEtapa).toEqual([
{ 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 },
])
})
it('manda al final de porEtapa los labels que no son del pipeline oficial', () => {
const r = construirResumen(
{ ...DATOS_BASE, unidades: [unidad('U-101', 'ATELIER', 'VENDIDO', 'Etapa rara')] },
AHORA,
)
const porEtapa = r.unidades.porEtapa
expect(porEtapa).toHaveLength(11)
expect(porEtapa[10]).toEqual({ etapa: 'Etapa rara', total: 1 })
})
it('cobra: planes por status, pagos vencidos y monto en mora de planes activos', () => {
const r = construirResumen(
{
...DATOS_BASE,
planes: [
plan('a', {
pagos: [
pago('p1', '2026-08-01', 50000), // vencido sin abono
pago('p2', '2026-08-10', 50000, 20000), // parcial VENCIDO: sí cuenta como atraso
pago('p3', '2026-09-01', 50000), // pendiente futuro
],
}),
plan('b', { pagos: [pago('p4', '2026-07-15', 30000)] }), // otro vencido
plan('c', { status: 'CANCELADO', pagos: [pago('p5', '2026-07-01', 99999)] }), // cancelado: no cuenta
plan('d', { pagos: [pago('p6', '2026-08-01', 1000, 1000)] }), // liquidado (saldo 0)
],
},
AHORA,
)
expect(r.cobranza.planes).toEqual({ activos: 2, liquidados: 1, cancelados: 1, sinPlan: 0 })
// p1 (vencido sin abono) + p2 (PARCIAL vencido, antes se ignoraba) + p4
expect(r.cobranza.pagosVencidos).toBe(3)
expect(r.cobranza.montoVencido).toBe(110000) // 50,000 + 30,000 restantes + 30,000
expect(r.cobranza.planesAlCorriente).toBe(0)
expect(r.cobranza.planesConVencidos).toBe(2)
})
it('perfil: cuenta solo valores capturados (los vacíos no van a ningún bucket)', () => {
const r = construirResumen(
{
...DATOS_BASE,
oportunidades: [
afluencia('1', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', credito: 'Bancario', califica: 'Sí' }),
afluencia('2', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', rangoSalarial: '20-30 mil' }),
afluencia('3', { estadoCivil: '', desarrollo: ' ' }), // sin dato: no cuenta en ninguna dimensión
],
},
AHORA,
)
expect(r.leads.perfil.porEstadoCivil).toEqual({ Soltero: 2 })
expect(r.leads.perfil.porDesarrollo).toEqual({ ATELIER: 2 })
expect(r.leads.perfil.porCredito).toEqual({ Bancario: 1 })
expect(r.leads.perfil.porCalifica).toEqual({ Sí: 1 })
expect(r.leads.perfil.porRangoSalarial).toEqual({ 'Hasta $30k MXN': 1 }) // '20-30 mil' → 25k
// Dimensiones sin ningún dato van como objeto vacío
expect(r.leads.perfil.porModelo).toEqual({})
expect(r.leads.perfil.porTipoEmpleo).toEqual({})
expect(r.leads.perfil.porTipoEconomia).toEqual({})
expect(r.leads.perfil.porMedioContacto).toEqual({})
expect(r.leads.perfil.porLoQuierePara).toEqual({})
})
it('perfil: rango salarial de texto libre se agrupa en buckets por moneda', () => {
const r = construirResumen(
{
...DATOS_BASE,
oportunidades: [
afluencia('1', { rangoSalarial: '16000' }), // Hasta $30k MXN
afluencia('2', { rangoSalarial: '$45K' }), // $30k–$60k MXN
afluencia('3', { rangoSalarial: '85,000.00 PESOS' }), // $60k–$100k MXN
afluencia('4', { rangoSalarial: '100,000.00-120,000.00' }), // Más de $100k MXN (promedio 110k)
afluencia('5', { rangoSalarial: '$2,5K dlls' }), // Hasta $3k USD (2,500)
afluencia('6', { rangoSalarial: '4,500 DLLS' }), // $3k–$5k USD
afluencia('7', { rangoSalarial: '8,000.000 DLLS' }), // Más de $5k USD (8,000)
afluencia('8', { rangoSalarial: 'NO QUISO DECIR' }), // sin monto: se salta
afluencia('9', { rangoSalarial: '' }), // vacío: se salta
],
},
AHORA,
)
expect(r.leads.perfil.porRangoSalarial).toEqual({
'Hasta $30k MXN': 1,
'$30k–$60k MXN': 1,
'$60k–$100k MXN': 1,
'Más de $100k MXN': 1,
'Hasta $3k USD': 1,
'$3k–$5k USD': 1,
'Más de $5k USD': 1,
})
})
it('perfil: buckets de edad y salto de edad null', () => {
const r = construirResumen(
{
...DATOS_BASE,
oportunidades: [
afluencia('1', { edad: 18 }), // 15–24
afluencia('2', { edad: 24 }), // 15–24
afluencia('3', { edad: 25 }), // 25–34
afluencia('4', { edad: 39 }), // 35–44
afluencia('5', { edad: 50 }), // 45–54
afluencia('6', { edad: 55 }), // 55+
afluencia('7', { edad: 70 }), // 55+
afluencia('8', { edad: null }), // sin dato: se salta
afluencia('9'), // edad undefined: se salta
],
},
AHORA,
)
expect(r.leads.perfil.porEdad).toEqual({ '15–24': 2, '25–34': 1, '35–44': 1, '45–54': 1, '55+': 2 })
})
})
// ---------- ReportesService (resumen desde tablas locales, cache en memoria) ----------
describe('ReportesService', () => {
function datosLocales() {
return {
leads: [lead('1')],
oportunidades: [afluencia('1')],
unidades: [unidad('U-101', 'ATELIER', 'DISPONIBLE')],
actividadesAbiertas: 7,
}
}
it('agrega todas las fuentes (tablas locales) en una sola respuesta', async () => {
const cargarDatos = vi.fn(async () => datosLocales())
const svc = new ReportesService(
async () => [plan('a', { pagos: [pago('p1', '2026-09-01', 50000)] })],
{ cargarDatosLocales: cargarDatos },
)
const r = await svc.resumen()
expect(r.leads.total).toBe(1) // la sección leads se alimenta de LeadCaptura board LEADS
expect(r.oportunidades.total).toBe(1)
expect(r.unidades.total).toBe(1)
expect(r.cobranza.planes.activos).toBe(1)
expect(r.actividadesAbiertas).toBe(7)
expect(r.parcial).toBe(false) // los datos locales son completos
expect(cargarDatos).toHaveBeenCalledTimes(1)
})
it('cachea 60 s: la segunda llamada no vuelve a leer las tablas locales', async () => {
vi.useFakeTimers()
try {
vi.setSystemTime(new Date('2026-08-22T12:00:00'))
const cargarDatos = vi.fn(async () => datosLocales())
const cargarPlanes = vi.fn(async () => [plan('a', { pagos: [pago('p1', '2026-09-01', 50000)] })])
const svc = new ReportesService(cargarPlanes, { cargarDatosLocales: cargarDatos })
await svc.resumen()
await svc.resumen()
expect(cargarDatos).toHaveBeenCalledTimes(1)
expect(cargarPlanes).toHaveBeenCalledTimes(1)
// 61 s después el cache expira y se vuelve a consultar
vi.setSystemTime(new Date('2026-08-22T12:01:01'))
await svc.resumen()
expect(cargarDatos).toHaveBeenCalledTimes(2)
expect(cargarPlanes).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
})