Files
FRASA-CAS/api-gateway/src/services/reportes.service.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

375 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 { LeadMonday, OportunidadMonday, Unidad } from './monday.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<LeadMonday> = {}): LeadMonday {
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<OportunidadMonday> = {}): OportunidadMonday {
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): OportunidadMonday {
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 (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,
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 (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 [afluencia('1')]
},
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) // la sección leads se alimenta del tablero 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(monday.llamadas.leads).toBe(1)
expect(monday.llamadas.oportunidades).toBe(1)
})
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.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.oportunidades).toBe(2)
expect(cargarPlanes).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
})