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,11 +1,11 @@
import { describe, expect, it } from 'vitest'
import {
esEstadoFinalApartado,
esEstadoTerminalLead,
esEtapaTerminal,
resumenMiDia,
type UsuarioMiDia,
} from './mi-dia.service.js'
import type { ActividadMonday, ApartadoMonday, LeadMonday } from './monday.service.js'
import type { ActividadMonday, ApartadoMonday, OportunidadMonday } from './monday.service.js'
const MI_ID = 106736727
const OTRO_ID = 111111111
@@ -14,16 +14,18 @@ const AHORA = new Date('2026-08-28T12:00:00') // viernes
const HOY = '2026-08-28'
const MANANA = '2026-08-29'
function lead(parcial: Partial<LeadMonday> & { id: string }): LeadMonday {
function oportunidad(parcial: Partial<OportunidadMonday> & { id: string }): OportunidadMonday {
return {
nombre: `Lead ${parcial.id}`,
createdAt: '2026-08-25T10:00:00Z',
updatedAt: '2026-08-27T10:00:00Z',
estado: 'NUEVO',
etapa: 'Afluencia',
origen: 'Facebook',
unidadCodigo: '',
precioFinal: 0,
asesorIds: [MI_ID],
telefono: '',
email: '',
vendedorIds: [MI_ID],
...parcial,
}
}
@@ -50,13 +52,13 @@ function apartado(parcial: Partial<ApartadoMonday> & { id: string }): ApartadoMo
}
function mondayMock(datos: {
leads?: LeadMonday[]
oportunidades?: OportunidadMonday[]
actividades?: ActividadMonday[]
apartados?: ApartadoMonday[]
}) {
return {
async getLeads() {
return datos.leads ?? []
async getOportunidades() {
return datos.oportunidades ?? []
},
async getActividades() {
return datos.actividades ?? []
@@ -72,23 +74,37 @@ const sinMapeo: UsuarioMiDia = { nombre: 'Erick Peña', rol: 'vendedor', mondayU
const coordinador: UsuarioMiDia = { nombre: 'Daniel Alvarado', rol: 'coordinador', mondayUserId: null }
describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
it('filtra mis leads por mondayUserId y excluye estados terminales', async () => {
it('filtra mis leads por mondayUserId (columna Asesor) y excluye etapas terminales', async () => {
const monday = mondayMock({
leads: [
lead({ id: '1', estado: 'NUEVO' }),
lead({ id: '2', estado: 'CONTACTADO', telefono: '4771234567' }),
lead({ id: '3', estado: 'DESCARTADO' }),
lead({ id: '4', estado: 'CALIFICADO' }),
lead({ id: '5', estado: 'Abandono' }),
lead({ id: '6', estado: 'NUEVO', vendedorIds: [OTRO_ID] }),
oportunidades: [
oportunidad({ id: '1', etapa: 'Afluencia' }),
oportunidad({ id: '2', etapa: 'Contactado', telefono: '4771234567' }),
oportunidad({ id: '3', etapa: 'Perdido' }),
oportunidad({ id: '4', etapa: 'Calificado' }), // NO es terminal en el nuevo modelo
oportunidad({ id: '5', etapa: 'Abandono' }),
oportunidad({ id: '6', etapa: 'Ganado' }),
oportunidad({ id: '7', etapa: 'Afluencia', asesorIds: [OTRO_ID] }),
],
})
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
expect(r.sinMapeoMonday).toBe(false)
expect(r.misLeads.map((l) => l.id)).toEqual(['1', '2'])
expect(r.misLeads.map((l) => l.id)).toEqual(['1', '2', '4'])
expect(r.misLeads[1]?.telefono).toBe('4771234567')
})
it('misOportunidades: conteo de MIS oportunidades por etapa (incluye terminales)', async () => {
const monday = mondayMock({
oportunidades: [
oportunidad({ id: '1', etapa: 'Afluencia' }),
oportunidad({ id: '2', etapa: 'Afluencia' }),
oportunidad({ id: '3', etapa: 'Ganado' }),
oportunidad({ id: '4', etapa: 'Afluencia', asesorIds: [OTRO_ID] }), // de otro: no cuenta
],
})
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
expect(r.misOportunidades).toEqual({ porEtapa: { Afluencia: 2, Ganado: 1 } })
})
it('actividades de hoy: solo abiertas con fecha <= mañana y mías', async () => {
const monday = mondayMock({
actividades: [
@@ -126,13 +142,14 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
it('sin mapeo Monday: listas personales vacías y sinMapeoMonday: true', async () => {
const monday = mondayMock({
leads: [lead({ id: '1' })],
oportunidades: [oportunidad({ id: '1' })],
actividades: [actividad({ id: 'a1' })],
apartados: [apartado({ id: 'p1' })],
})
const r = await resumenMiDia(monday, sinMapeo, new Map(), [], [], AHORA)
expect(r.sinMapeoMonday).toBe(true)
expect(r.misLeads).toEqual([])
expect(r.misOportunidades).toEqual({ porEtapa: {} })
expect(r.misActividadesHoy).toEqual([])
expect(r.conteos).toEqual({ leadsNuevosSemana: 0, actividadesAbiertas: 0 })
// Los apartados por vencer son del pipeline completo: sí se muestran
@@ -141,32 +158,26 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
it('conteos: leads nuevos de los últimos 7 días (incluye terminales recientes)', async () => {
const monday = mondayMock({
leads: [
lead({ id: '1', createdAt: '2026-08-28T08:00:00' }), // hoy
lead({ id: '2', createdAt: '2026-08-22T08:00:00' }), // hace 6 días
lead({ id: '3', createdAt: '2026-08-10T08:00:00' }), // viejo
lead({ id: '4', createdAt: '2026-08-27T08:00:00', vendedorIds: [OTRO_ID] }), // de otro
oportunidades: [
oportunidad({ id: '1', createdAt: '2026-08-28T08:00:00' }), // hoy
oportunidad({ id: '2', createdAt: '2026-08-22T08:00:00' }), // hace 6 días
oportunidad({ id: '3', createdAt: '2026-08-10T08:00:00' }), // viejo
oportunidad({ id: '4', createdAt: '2026-08-27T08:00:00', asesorIds: [OTRO_ID] }), // de otro
],
})
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
expect(r.conteos.leadsNuevosSemana).toBe(2)
})
it('oportunidades se omiten con nota (el board no tiene columna de asesor)', async () => {
const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], [], AHORA)
expect(r.misOportunidades).toBeNull()
expect(r.notaOportunidades).toContain('asesor')
})
it('vendedor no ve sección equipo; coordinador sí (conteos por vendedor y sin asignar)', async () => {
it('vendedor no ve sección equipo; coordinador sí (conteos por asesor y sin asignar)', async () => {
const monday = mondayMock({
leads: [
lead({ id: '1', vendedorIds: [] }), // sin asignar, NUEVO
lead({ id: '2', vendedorIds: [], estado: 'CONTACTADO' }), // sin asignar, no NUEVO
lead({ id: '3', vendedorIds: [MI_ID] }),
lead({ id: '4', vendedorIds: [MI_ID], estado: 'DESCARTADO' }), // terminal: no cuenta
lead({ id: '5', vendedorIds: [OTRO_ID] }),
lead({ id: '6', vendedorIds: [OTRO_ID], createdAt: '2026-08-10T08:00:00' }), // viejo: no es nuevo de la semana
oportunidades: [
oportunidad({ id: '1', asesorIds: [] }), // sin asignar, Afluencia
oportunidad({ id: '2', asesorIds: [], etapa: 'Contactado' }), // sin asignar, no Afluencia
oportunidad({ id: '3', asesorIds: [MI_ID] }),
oportunidad({ id: '4', asesorIds: [MI_ID], etapa: 'Perdido' }), // terminal: no cuenta
oportunidad({ id: '5', asesorIds: [OTRO_ID] }),
oportunidad({ id: '6', asesorIds: [OTRO_ID], createdAt: '2026-08-10T08:00:00' }), // viejo: no es nuevo de la semana
],
})
const nombres = new Map([[MI_ID, 'Martin Zavala']])
@@ -183,42 +194,42 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
})
it('corporativo y admin también ven equipo', async () => {
const monday = mondayMock({ leads: [lead({ id: '1' })] })
const monday = mondayMock({ oportunidades: [oportunidad({ id: '1' })] })
for (const rol of ['corporativo', 'admin']) {
const r = await resumenMiDia(monday, { nombre: 'X', rol, mondayUserId: null }, new Map(), [], [], AHORA)
expect(r.equipo).toBeDefined()
}
})
it('mis capturas: enriquecidas con el estado/origen actual del lead en Monday', async () => {
it('mis capturas: enriquecidas con la etapa/origen actual de la oportunidad en Monday', async () => {
const monday = mondayMock({
leads: [lead({ id: '100', nombre: 'Ana López', estado: 'CONTACTADO', origen: 'WhatsApp', vendedorIds: [OTRO_ID] })],
oportunidades: [oportunidad({ id: '100', nombre: 'Ana López', etapa: 'Contactado', origen: 'WhatsApp', asesorIds: [OTRO_ID] })],
})
const capturas = [
{ leadId: '100', nombreLead: 'Ana López', telefono: '6641112233', createdAt: new Date('2026-08-27T09:00:00Z') },
]
const r = await resumenMiDia(monday, vendedor, new Map(), capturas, [], AHORA)
// Aunque el lead quedó asignado a OTRO por round-robin, la captura es mía
// Aunque la afluencia quedó asignada a OTRO por round-robin, la captura es mía
expect(r.misCapturas).toEqual([
{
leadId: '100',
nombre: 'Ana López',
telefono: '6641112233',
estado: 'CONTACTADO',
estado: 'Contactado',
origen: 'WhatsApp',
capturadoEn: '2026-08-27T09:00:00.000Z',
},
])
})
it('mis capturas: si el lead ya no existe en Monday, estado y origen van vacíos', async () => {
it('mis capturas: capturas viejas (del tablero Leads) ya no cruzan; estado y origen van vacíos', async () => {
const monday = mondayMock({})
const capturas = [
{ leadId: 'borrado-1', nombreLead: 'Lead Borrado', telefono: null, createdAt: new Date('2026-08-26T09:00:00Z') },
{ leadId: 'lead-viejo-1', nombreLead: 'Lead Histórico', telefono: null, createdAt: new Date('2026-08-26T09:00:00Z') },
]
const r = await resumenMiDia(monday, vendedor, new Map(), capturas, [], AHORA)
expect(r.misCapturas).toEqual([
{ leadId: 'borrado-1', nombre: 'Lead Borrado', telefono: '', estado: '', origen: '', capturadoEn: '2026-08-26T09:00:00.000Z' },
{ leadId: 'lead-viejo-1', nombre: 'Lead Histórico', telefono: '', estado: '', origen: '', capturadoEn: '2026-08-26T09:00:00.000Z' },
])
})
@@ -290,12 +301,13 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
})
describe('clasificadores de estado', () => {
it('estados terminales de lead (case-insensitive)', () => {
expect(esEstadoTerminalLead('DESCARTADO')).toBe(true)
expect(esEstadoTerminalLead('calificado')).toBe(true)
expect(esEstadoTerminalLead('Abandono')).toBe(true)
expect(esEstadoTerminalLead('NUEVO')).toBe(false)
expect(esEstadoTerminalLead('Intento de contacto')).toBe(false)
it('etapas terminales de oportunidad (case-insensitive)', () => {
expect(esEtapaTerminal('GANADO')).toBe(true)
expect(esEtapaTerminal('perdido')).toBe(true)
expect(esEtapaTerminal('Abandono')).toBe(true)
expect(esEtapaTerminal('Afluencia')).toBe(false)
expect(esEtapaTerminal('Calificado')).toBe(false)
expect(esEtapaTerminal('Intento de contacto')).toBe(false)
})
it('estados finales de apartado', () => {