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:
2026-09-04 04:46:34 +00:00
parent d4fc9ee892
commit 845f314560
26 changed files with 1496 additions and 985 deletions

View File

@@ -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()