import { beforeEach, describe, expect, it, vi } from 'vitest' import type { PlanPago } from './cobranza.service.js' import type { LeadFila, OportunidadFila, Unidad } from './reportes.service.js' // ---------- Prisma mockeado (los tests no tocan la BD) ---------- const prismaMock = vi.hoisted(() => ({ leadCaptura: { findMany: vi.fn(), }, unidad: { findMany: vi.fn(), }, actividadLocal: { count: vi.fn(), }, })) vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) import { cargarDatosLocalesPrisma, ReportesService, type DatosLocalesReportes } from './reportes.service.js' /** * Reportes 100% locales: el resumen se calcula desde tablas locales * (LeadCaptura por board, Unidad por status, PlanPago como siempre). * `parcial: false` porque los datos locales son completos. */ function pago(id: string, fechaVencimiento: string, monto: number, montoPagado = 0): PlanPago['pagos'][number] { return { id, numPago: 1, fechaVencimiento, monto, montoPagado, aplicaciones: [] } } 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: [pago(`p-${id}`, '2027-01-01', 50000)], // pendiente y al corriente: ACTIVO notas: [], createdAt: '2026-07-01T00:00:00.000Z', ...overrides, } } function leadFila(id: string, estado = 'Nuevo'): LeadFila { return { id, nombre: `Lead ${id}`, createdAt: '2026-09-04T10:00:00.000Z', updatedAt: '2026-09-04T10:00:00.000Z', estado, origen: 'Web', telefono: '', email: '', vendedorIds: [], } } function unidadFila(codigo: string, status = 'DISPONIBLE'): Unidad { return { codigo, modelo: '1R', fechaEntrega: null, supEstacionamiento: 0, supConstruccion: 60, supExcedente: 0, precioSinAcabados: 1_000_000, precioConAcabados: 1_100_000, status, desarrollo: 'SENDA', mesesParaFirma: null, etapa: null, } } const datosLocales: DatosLocalesReportes = { leads: [leadFila('loc_l1'), leadFila('loc_l2', 'Descartado')], oportunidades: [ { id: 'loc_o1', nombre: 'Op Local', createdAt: '2026-09-04T10:00:00.000Z', updatedAt: '2026-09-04T10:00:00.000Z', etapa: 'Afluencia', unidadCodigo: '', precioFinal: 0, asesorIds: [], telefono: '', email: '', origen: 'WhatsApp', desarrollo: 'SENDA', modeloInteres: '1R', credito: 'Infonavit', }, ], unidades: [unidadFila('U-1'), unidadFila('U-2', 'APARTADO')], } beforeEach(() => { vi.clearAllMocks() prismaMock.leadCaptura.findMany.mockResolvedValue([]) prismaMock.unidad.findMany.mockResolvedValue([]) prismaMock.actividadLocal.count.mockResolvedValue(0) }) describe('ReportesService (resumen desde tablas locales)', () => { it('resumen desde datos locales: leads, oportunidades, unidades y planes en una respuesta', async () => { const svc = new ReportesService(async () => [plan('a')], { cargarDatosLocales: async () => datosLocales, }) const r = await svc.resumen() expect(r.parcial).toBe(false) expect(r.leads.total).toBe(2) expect(r.leads.porEstado).toEqual({ Nuevo: 1, Descartado: 1 }) expect(r.oportunidades.total).toBe(1) expect(r.oportunidades.porEtapa).toEqual({ Afluencia: 1 }) expect(r.unidades.total).toBe(2) expect(r.unidades.disponibles).toBe(1) expect(r.cobranza.planes.activos).toBe(1) expect(r.actividadesAbiertas).toBe(0) // no hay actividades en el fixture }) it('cuenta actividades abiertas locales cuando el fixture las trae', async () => { const svc = new ReportesService(async () => [], { cargarDatosLocales: async () => ({ ...datosLocales, actividadesAbiertas: 3 }), }) const r = await svc.resumen() expect(r.actividadesAbiertas).toBe(3) }) it('usa el cargador local por defecto (cargarDatosLocalesPrisma) cuando no se inyecta', async () => { const svc = new ReportesService(async () => []) const r = await svc.resumen() // Con las tablas vacías el resumen sale en ceros pero completo expect(r.parcial).toBe(false) expect(r.leads.total).toBe(0) expect(r.oportunidades.total).toBe(0) expect(r.unidades.total).toBe(0) expect(prismaMock.leadCaptura.findMany).toHaveBeenCalled() expect(prismaMock.unidad.findMany).toHaveBeenCalled() expect(prismaMock.actividadLocal.count).toHaveBeenCalled() }) it('un error del cargador local se propaga (no se traga nada)', async () => { const svc = new ReportesService(async () => [], { cargarDatosLocales: async () => { throw new Error('bug') }, }) await expect(svc.resumen()).rejects.toThrow('bug') }) }) describe('cargarDatosLocalesPrisma (mapeo LeadCaptura/Unidad → DTOs del embudo)', () => { it('mapea boards a leads/oportunidades con el perfil de datosJson y unidades al DTO', async () => { prismaMock.leadCaptura.findMany.mockResolvedValue([ { leadId: 'loc_l1', nombreLead: 'Lead Local', telefono: '4771234567', email: null, origen: 'Web', desarrollo: null, modelo: null, board: 'LEADS', etapa: 'NUEVO', datosJson: null, createdAt: new Date('2026-09-04T10:00:00'), updatedAt: new Date('2026-09-04T11:00:00'), }, { leadId: 'loc_o1', nombreLead: 'Op Local', telefono: null, email: 'op@correo.mx', origen: 'WhatsApp', desarrollo: 'SENDA', modelo: '1R', board: 'OPORTUNIDADES', etapa: 'Afluencia', datosJson: JSON.stringify({ estadoCivil: 'Soltero(a)', tipoEmpleo: 'Empleado', credito: 'Infonavit', edad: 35, califica: 'Si' }), createdAt: new Date('2026-09-03T10:00:00'), updatedAt: new Date('2026-09-03T11:00:00'), }, { leadId: 'loc_a1', nombreLead: 'Apt Local', board: 'APARTADO', // el board APARTADO no alimenta leads ni oportunidades etapa: 'Pago Apartado', telefono: null, email: null, origen: null, desarrollo: null, modelo: null, datosJson: null, createdAt: new Date('2026-09-02T10:00:00'), updatedAt: new Date('2026-09-02T11:00:00'), }, ]) prismaMock.unidad.findMany.mockResolvedValue([ { codigo: 'SENDA-101', desarrollo: 'SENDA', modelo: '1R', supConstruccion: 69.5, supExcedente: 0, supEstacionamiento: 12.5, precioLista: 4_415_000, precioAcabados: 4_579_000, status: 'DISPONIBLE', etapa: '', }, ]) const datos = await cargarDatosLocalesPrisma() expect(datos.leads).toHaveLength(1) expect(datos.leads[0]).toMatchObject({ id: 'loc_l1', estado: 'NUEVO', origen: 'Web', telefono: '4771234567' }) expect(datos.oportunidades).toHaveLength(1) expect(datos.oportunidades[0]).toMatchObject({ id: 'loc_o1', etapa: 'Afluencia', desarrollo: 'SENDA', modeloInteres: '1R', estadoCivil: 'Soltero(a)', tipoEmpleo: 'Empleado', credito: 'Infonavit', edad: 35, califica: 'Si', }) expect(datos.unidades).toHaveLength(1) expect(datos.unidades[0]).toMatchObject({ codigo: 'SENDA-101', precioSinAcabados: 4_415_000, precioConAcabados: 4_579_000, status: 'DISPONIBLE', etapa: null, }) }) })