Files
FRASA-CAS/api-gateway/src/services/reportes.service.test.ts
Consult AS Technology 9ef86efb90 feat(reportes): rango salarial en buckets + script de importacion de afluencia
- bucketSalario: el texto libre del rango salarial ('$80k', '4,500 DLLS',
  '50-80 MIL') se agrupa en rangos por moneda para que la grafica sea
  legible; valores sin monto se omiten.
- scripts/importar-afluencia-agosto.py: importa el historial de
  afluencia al tablero Leads con normalizacion de catalogos, dedup por
  telefono y desregistro temporal del webhook create_item (para no
  disparar round-robin ni oportunidades con datos historicos).
2026-09-02 04:46:18 +00:00

353 lines
12 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 { LeadMonday, OportunidadMonday, Unidad } from './monday.service.js'
import type { PlanPago } from './cobranza.service.js'
// ---------- Fixtures ----------
function lead(id: string, overrides: Partial<LeadMonday> = {}): LeadMonday {
return {
id,
nombre: `Lead ${id}`,
createdAt: '2026-08-20T10:00:00Z',
updatedAt: '2026-08-20T10:00:00Z',
estado: 'NUEVO',
origen: 'Facebook',
vendedorIds: [],
...overrides,
}
}
function op(id: string, etapa: string): OportunidadMonday {
return { id, nombre: `Cliente ${id}`, createdAt: '2026-08-01T09:00:00Z', etapa, unidadCodigo: 'U-101', precioFinal: 0 }
}
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', () => {
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 (no cuenta como VENCIDO)
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 })
expect(r.cobranza.pagosVencidos).toBe(2)
expect(r.cobranza.montoVencido).toBe(80000)
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,
leads: [
lead('1', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', credito: 'Bancario', califica: 'Si' }),
lead('2', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', rangoSalarial: '20-30 mil' }),
lead('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({ Si: 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,
leads: [
lead('1', { rangoSalarial: '16000' }), // Hasta $30k MXN
lead('2', { rangoSalarial: '$45K' }), // $30k–$60k MXN
lead('3', { rangoSalarial: '85,000.00 PESOS' }), // $60k–$100k MXN
lead('4', { rangoSalarial: '100,000.00-120,000.00' }), // Más de $100k MXN (promedio 110k)
lead('5', { rangoSalarial: '$2,5K dlls' }), // Hasta $3k USD (2,500)
lead('6', { rangoSalarial: '4,500 DLLS' }), // $3k–$5k USD
lead('7', { rangoSalarial: '8,000.000 DLLS' }), // Más de $5k USD (8,000)
lead('8', { rangoSalarial: 'NO QUISO DECIR' }), // sin monto: se salta
lead('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,
leads: [
lead('1', { edad: 18 }), // 15–24
lead('2', { edad: 24 }), // 15–24
lead('3', { edad: 25 }), // 25–34
lead('4', { edad: 39 }), // 35–44
lead('5', { edad: 50 }), // 45–54
lead('6', { edad: 55 }), // 55+
lead('7', { edad: 70 }), // 55+
lead('8', { edad: null }), // sin dato: se salta
lead('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 (cache en memoria) ----------
describe('ReportesService', () => {
function mondayMock() {
return {
llamadas: { leads: 0, oportunidades: 0, unidades: 0, actividades: 0 },
async getLeads(): Promise<LeadMonday[]> {
this.llamadas.leads++
return [lead('1')]
},
async getOportunidades(): Promise<OportunidadMonday[]> {
this.llamadas.oportunidades++
return [op('1', 'Aparto')]
},
async getUnidades(): Promise<Unidad[]> {
this.llamadas.unidades++
return [unidad('U-101', 'ATELIER', 'DISPONIBLE')]
},
async getActividadesAbiertas(): Promise<number> {
this.llamadas.actividades++
return 7
},
}
}
it('agrega todas las fuentes en una sola respuesta', async () => {
const monday = mondayMock()
const svc = new ReportesService(monday, async () => [plan('a', { pagos: [pago('p1', '2026-09-01', 50000)] })])
const r = await svc.resumen()
expect(r.leads.total).toBe(1)
expect(r.oportunidades.total).toBe(1)
expect(r.unidades.total).toBe(1)
expect(r.cobranza.planes.activos).toBe(1)
expect(r.actividadesAbiertas).toBe(7)
})
it('cachea 60 s: la segunda llamada no vuelve a Monday ni a Prisma', async () => {
vi.useFakeTimers()
try {
vi.setSystemTime(new Date('2026-08-22T12:00:00'))
const monday = mondayMock()
const cargarPlanes = vi.fn(async () => [plan('a', { pagos: [pago('p1', '2026-09-01', 50000)] })])
const svc = new ReportesService(monday, cargarPlanes)
await svc.resumen()
await svc.resumen()
expect(monday.llamadas.leads).toBe(1)
expect(monday.llamadas.oportunidades).toBe(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(monday.llamadas.leads).toBe(2)
expect(cargarPlanes).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
})