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 { 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 { 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 { 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 { this.llamadas.leads++ return [lead('1')] }, async getOportunidades(): Promise { this.llamadas.oportunidades++ return [afluencia('1')] }, async getUnidades(): Promise { this.llamadas.unidades++ return [unidad('U-101', 'ATELIER', 'DISPONIBLE')] }, async getActividadesAbiertas(): Promise { 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() } }) })