refactor: las afluencias viven en Oportunidades (no en Leads)
- POST /api/leads crea UN solo item en Oportunidades (Estado Afluencia) con todo el perfil (desarrollo, modelo, estado civil, credito, etc.) en las columnas del board; dedup por telefono contra Oportunidades; asignacion al capturista via columna Asesor o round-robin del equipo. - Round-robin, escalado e inactividad operan sobre Oportunidades (solo etapa Afluencia sin asesor entra al reparto). - Acciones del vendedor (contactado/cita/descartar) escriben directo en la oportunidad; descarte = Perdido + Motivo No Compra. - Mi dia y reportes leen Oportunidades; el funnel de /dash arranca en Afluencia; misOportunidades.porEtapa ya es real (columna Asesor). - El picker del alta busca en Leads (historico) Y Oportunidades, con insignia de origen; moverAAparto tolera ambos ids. - Lectura de status con value null = vacio (quirk del default Monday). - Script de migracion del historial de agosto Leads -> Oportunidades.
This commit is contained in:
@@ -1,25 +1,30 @@
|
||||
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 { OportunidadMonday, Unidad } from './monday.service.js'
|
||||
import type { PlanPago } from './cobranza.service.js'
|
||||
|
||||
// ---------- Fixtures ----------
|
||||
|
||||
function lead(id: string, overrides: Partial<LeadMonday> = {}): LeadMonday {
|
||||
/** Afluencia/oportunidad del board Oportunidades (fuente única de la sección leads) */
|
||||
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',
|
||||
estado: 'NUEVO',
|
||||
etapa: 'Afluencia',
|
||||
origen: 'Facebook',
|
||||
vendedorIds: [],
|
||||
unidadCodigo: '',
|
||||
precioFinal: 0,
|
||||
asesorIds: [],
|
||||
telefono: '',
|
||||
email: '',
|
||||
...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 }
|
||||
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 {
|
||||
@@ -63,37 +68,37 @@ function pago(id: string, fechaVencimiento: string, monto: number, montoPagado =
|
||||
|
||||
const AHORA = new Date('2026-08-22T12:00:00') // mediodía local
|
||||
|
||||
const DATOS_BASE: DatosResumen = { leads: [], oportunidades: [], unidades: [], planes: [], actividadesAbiertas: 0 }
|
||||
const DATOS_BASE: DatosResumen = { oportunidades: [], unidades: [], planes: [], actividadesAbiertas: 0 }
|
||||
|
||||
// ---------- construirResumen (puro) ----------
|
||||
|
||||
describe('construirResumen', () => {
|
||||
it('cuenta leads por estado y por origen', () => {
|
||||
it('cuenta la afluencia por estado (Etapa) y por origen (Origen del Lead)', () => {
|
||||
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: '' }),
|
||||
oportunidades: [
|
||||
afluencia('1', { etapa: 'Afluencia', origen: 'Facebook' }),
|
||||
afluencia('2', { etapa: 'Afluencia', origen: 'Facebook' }),
|
||||
afluencia('3', { etapa: 'Contactado', origen: 'Referido' }),
|
||||
afluencia('4', { etapa: '', origen: '' }),
|
||||
],
|
||||
},
|
||||
AHORA,
|
||||
)
|
||||
expect(r.leads.total).toBe(4)
|
||||
expect(r.leads.porEstado).toEqual({ NUEVO: 2, CONTACTADO: 1, '(sin dato)': 1 })
|
||||
expect(r.leads.porEstado).toEqual({ Afluencia: 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)', () => {
|
||||
it('cuenta afluencias nuevas 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
|
||||
oportunidades: [
|
||||
afluencia('1', { createdAt: '2026-08-22T08:00:00' }), // hoy
|
||||
afluencia('2', { createdAt: '2026-08-18T23:00:00' }), // esta semana
|
||||
afluencia('3', { createdAt: '2026-08-10T12:00:00' }), // hace 12 días
|
||||
],
|
||||
},
|
||||
AHORA,
|
||||
@@ -218,10 +223,10 @@ describe('construirResumen', () => {
|
||||
const r = construirResumen(
|
||||
{
|
||||
...DATOS_BASE,
|
||||
leads: [
|
||||
lead('1', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', credito: 'Bancario', califica: 'Si' }),
|
||||
lead('2', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', rangoSalarial: '20-30 mil' }),
|
||||
lead('3', { estadoCivil: '', desarrollo: ' ' }), // sin dato: no cuenta en ninguna dimensión
|
||||
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,
|
||||
@@ -229,7 +234,7 @@ describe('construirResumen', () => {
|
||||
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({ Si: 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({})
|
||||
@@ -243,16 +248,16 @@ describe('construirResumen', () => {
|
||||
const r = construirResumen(
|
||||
{
|
||||
...DATOS_BASE,
|
||||
leads: [
|
||||
lead('1', { rangoSalarial: '16000' }), // Hasta $30k MXN
|
||||
lead('2', { rangoSalarial: '$45K' }), // $30k–$60k MXN
|
||||
lead('3', { rangoSalarial: '85,000.00 PESOS' }), // $60k–$100k MXN
|
||||
lead('4', { rangoSalarial: '100,000.00-120,000.00' }), // Más de $100k MXN (promedio 110k)
|
||||
lead('5', { rangoSalarial: '$2,5K dlls' }), // Hasta $3k USD (2,500)
|
||||
lead('6', { rangoSalarial: '4,500 DLLS' }), // $3k–$5k USD
|
||||
lead('7', { rangoSalarial: '8,000.000 DLLS' }), // Más de $5k USD (8,000)
|
||||
lead('8', { rangoSalarial: 'NO QUISO DECIR' }), // sin monto: se salta
|
||||
lead('9', { rangoSalarial: '' }), // vacío: se salta
|
||||
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,
|
||||
@@ -272,16 +277,16 @@ describe('construirResumen', () => {
|
||||
const r = construirResumen(
|
||||
{
|
||||
...DATOS_BASE,
|
||||
leads: [
|
||||
lead('1', { edad: 18 }), // 15–24
|
||||
lead('2', { edad: 24 }), // 15–24
|
||||
lead('3', { edad: 25 }), // 25–34
|
||||
lead('4', { edad: 39 }), // 35–44
|
||||
lead('5', { edad: 50 }), // 45–54
|
||||
lead('6', { edad: 55 }), // 55+
|
||||
lead('7', { edad: 70 }), // 55+
|
||||
lead('8', { edad: null }), // sin dato: se salta
|
||||
lead('9'), // edad undefined: se salta
|
||||
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,
|
||||
@@ -295,14 +300,10 @@ describe('construirResumen', () => {
|
||||
describe('ReportesService', () => {
|
||||
function mondayMock() {
|
||||
return {
|
||||
llamadas: { leads: 0, oportunidades: 0, unidades: 0, actividades: 0 },
|
||||
async getLeads(): Promise<LeadMonday[]> {
|
||||
this.llamadas.leads++
|
||||
return [lead('1')]
|
||||
},
|
||||
llamadas: { oportunidades: 0, unidades: 0, actividades: 0 },
|
||||
async getOportunidades(): Promise<OportunidadMonday[]> {
|
||||
this.llamadas.oportunidades++
|
||||
return [op('1', 'Aparto')]
|
||||
return [afluencia('1')]
|
||||
},
|
||||
async getUnidades(): Promise<Unidad[]> {
|
||||
this.llamadas.unidades++
|
||||
@@ -315,15 +316,16 @@ describe('ReportesService', () => {
|
||||
}
|
||||
}
|
||||
|
||||
it('agrega todas las fuentes en una sola respuesta', async () => {
|
||||
it('agrega todas las fuentes en una sola respuesta (una sola llamada a Oportunidades)', 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.leads.total).toBe(1) // la sección leads se alimenta de Oportunidades
|
||||
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.oportunidades).toBe(1) // sin llamada duplicada
|
||||
})
|
||||
|
||||
it('cachea 60 s: la segunda llamada no vuelve a Monday ni a Prisma', async () => {
|
||||
@@ -336,14 +338,13 @@ describe('ReportesService', () => {
|
||||
|
||||
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(monday.llamadas.oportunidades).toBe(2)
|
||||
expect(cargarPlanes).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
|
||||
Reference in New Issue
Block a user