Files
FRASA-CAS/api-gateway/src/services/reportes.service.test.ts
Consult AS Technology 9e04b76616 feat(unidades): pipeline de 10 etapas por unidad + sync desde Reporte_EstatusVenta
- Columna Etapa en Monday Unidades (10 labels oficiales), 155 unidades pobladas
- sync-etapas.ts idempotente (--archivo/--dry-run/--semilla) con traspasos
  Vendido→Escrituración y Escriturado→SAC como actividades (sin retroactivas)
- API: etapa en unidades + porEtapa en /api/reportes/resumen
- Dashboard: gráfica pipeline por etapa + KPI en notaría; cotizador muestra etapa
- 28 cambios aplicados desde Reporte_EstatusVenta (8 avances, 20 nuevas);
  contradicciones retenidas para revisión humana (documentadas)
- Tests: api 168, dashboards 9, cotizador 71
2026-08-28 04:43:02 +00:00

278 lines
9.2 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)
})
})
// ---------- 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()
}
})
})