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