feat(leads): doble via de captura — lead rapido a Leads, afluencia a Oportunidades

- POST /api/leads: captura ligera al tablero Leads (sin oportunidad),
  self-assign a Vendedor o round-robin; tambien cubre la captura manual
  desde la UI de Monday (webhook create_item re-registrado).
- POST /api/afluencias (nuevo): la forma completa sigue cayendo en
  Oportunidades con todo el perfil (etapa Afluencia, columna Asesor).
- Round-robin dual (ambos tableros), Mi dia mezcla leads+oportunidades
  con insignia de origen, acciones con parametro fuente, reportes:
  leads desde Leads, perfilamiento desde Oportunidades, funnel
  Leads->Afluencia->...->Ganado.
This commit is contained in:
2026-09-04 06:09:38 +00:00
parent 772fdef09f
commit 135e3e6de5
21 changed files with 1053 additions and 326 deletions

View File

@@ -1,11 +1,12 @@
import { describe, expect, it } from 'vitest'
import {
esEstadoFinalApartado,
esEstadoTerminalLead,
esEtapaTerminal,
resumenMiDia,
type UsuarioMiDia,
} from './mi-dia.service.js'
import type { ActividadMonday, ApartadoMonday, OportunidadMonday } from './monday.service.js'
import type { ActividadMonday, ApartadoMonday, LeadMonday, OportunidadMonday } from './monday.service.js'
const MI_ID = 106736727
const OTRO_ID = 111111111
@@ -14,6 +15,20 @@ 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 {
return {
nombre: `Lead ${parcial.id}`,
createdAt: '2026-08-25T10:00:00Z',
updatedAt: '2026-08-27T10:00:00Z',
estado: 'Nuevo',
origen: 'Facebook',
telefono: '',
email: '',
vendedorIds: [MI_ID],
...parcial,
}
}
function oportunidad(parcial: Partial<OportunidadMonday> & { id: string }): OportunidadMonday {
return {
nombre: `Lead ${parcial.id}`,
@@ -52,11 +67,15 @@ 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 ?? []
},
@@ -74,22 +93,29 @@ 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 (columna Asesor) y excluye etapas terminales', async () => {
it('misLeads: merge de Leads (Vendedor=mío) y Oportunidades (Asesor=mío), sin terminales, con fuente, por updatedAt desc', async () => {
const monday = mondayMock({
leads: [
lead({ id: 'l1', estado: 'Nuevo', updatedAt: '2026-08-26T10:00:00Z' }),
lead({ id: 'l2', estado: 'Contactado', telefono: '4771234567', updatedAt: '2026-08-28T08:00:00Z' }),
lead({ id: 'l3', estado: 'Descartado' }), // terminal Leads: fuera
lead({ id: 'l4', estado: 'Calificado' }), // terminal Leads: fuera
lead({ id: 'l5', estado: 'Nuevo', vendedorIds: [OTRO_ID] }), // de otro: fuera
],
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] }),
oportunidad({ id: 'o1', etapa: 'Afluencia', updatedAt: '2026-08-27T10:00:00Z' }),
oportunidad({ id: 'o2', etapa: 'Calificado' }), // NO es terminal en Oportunidades
oportunidad({ id: 'o3', etapa: 'Perdido' }),
oportunidad({ id: 'o4', etapa: 'Abandono' }),
oportunidad({ id: 'o5', etapa: 'Ganado' }),
oportunidad({ id: 'o6', 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', '4'])
expect(r.misLeads[1]?.telefono).toBe('4771234567')
expect(r.misLeads.map((l) => l.id)).toEqual(['l2', 'o1', 'o2', 'l1']) // updatedAt desc mezclando tableros
expect(r.misLeads.map((l) => l.fuente)).toEqual(['lead', 'oportunidad', 'oportunidad', 'lead'])
expect(r.misLeads[0]?.telefono).toBe('4771234567')
})
it('misOportunidades: conteo de MIS oportunidades por etapa (incluye terminales)', async () => {
@@ -156,8 +182,12 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
expect(r.apartadosPorVencer).toHaveLength(1)
})
it('conteos: leads nuevos de los últimos 7 días (incluye terminales recientes)', async () => {
it('conteos: leads nuevos de los últimos 7 días suman AMBOS tableros (incluye terminales recientes)', async () => {
const monday = mondayMock({
leads: [
lead({ id: 'l1', createdAt: '2026-08-28T08:00:00' }), // hoy
lead({ id: 'l2', createdAt: '2026-08-10T08:00:00' }), // viejo
],
oportunidades: [
oportunidad({ id: '1', createdAt: '2026-08-28T08:00:00' }), // hoy
oportunidad({ id: '2', createdAt: '2026-08-22T08:00:00' }), // hace 6 días
@@ -166,11 +196,16 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
],
})
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
expect(r.conteos.leadsNuevosSemana).toBe(2)
expect(r.conteos.leadsNuevosSemana).toBe(3) // 1 lead + 2 oportunidades
})
it('vendedor no ve sección equipo; coordinador sí (conteos por asesor y sin asignar)', async () => {
it('vendedor no ve sección equipo; coordinador sí (conteos por persona de AMBOS tableros y sin asignar)', async () => {
const monday = mondayMock({
leads: [
lead({ id: 'l1', vendedorIds: [] }), // sin asignar, Nuevo
lead({ id: 'l2', vendedorIds: [], estado: 'Contactado' }), // sin asignar, no Nuevo
lead({ id: 'l3', vendedorIds: [OTRO_ID] }),
],
oportunidades: [
oportunidad({ id: '1', asesorIds: [] }), // sin asignar, Afluencia
oportunidad({ id: '2', asesorIds: [], etapa: 'Contactado' }), // sin asignar, no Afluencia
@@ -186,9 +221,9 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
expect(rVendedor.equipo).toBeUndefined()
const rCoord = await resumenMiDia(monday, coordinador, nombres, [], [], AHORA)
expect(rCoord.equipo?.leadsNuevosSinAsignar).toBe(1)
expect(rCoord.equipo?.leadsNuevosSinAsignar).toBe(2) // 1 lead Nuevo + 1 afluencia sin asignar
expect(rCoord.equipo?.porVendedor).toEqual([
{ mondayUserId: OTRO_ID, nombre: null, leadsAsignados: 2, leadsNuevosSemana: 1 },
{ mondayUserId: OTRO_ID, nombre: null, leadsAsignados: 3, leadsNuevosSemana: 2 }, // 1 lead + 2 ops (1 vieja)
{ mondayUserId: MI_ID, nombre: 'Martin Zavala', leadsAsignados: 1, leadsNuevosSemana: 1 },
])
})
@@ -222,14 +257,34 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
])
})
it('mis capturas: capturas viejas (del tablero Leads) ya no cruzan; estado y origen van vacíos', async () => {
const monday = mondayMock({})
it('mis capturas: capturas rápidas cruzan contra el tablero Leads por id', async () => {
const monday = mondayMock({
leads: [lead({ id: 'lead-9', nombre: 'Pepe Rápido', estado: 'Contactado', origen: 'Web', vendedorIds: [OTRO_ID] })],
})
const capturas = [
{ leadId: 'lead-viejo-1', nombreLead: 'Lead Histórico', telefono: null, createdAt: new Date('2026-08-26T09:00:00Z') },
{ leadId: 'lead-9', nombreLead: 'Pepe Rápido', telefono: '4771112233', createdAt: new Date('2026-08-27T09:00:00Z') },
]
const r = await resumenMiDia(monday, vendedor, new Map(), capturas, [], AHORA)
expect(r.misCapturas).toEqual([
{ leadId: 'lead-viejo-1', nombre: 'Lead Histórico', telefono: '', estado: '', origen: '', capturadoEn: '2026-08-26T09:00:00.000Z' },
{
leadId: 'lead-9',
nombre: 'Pepe Rápido',
telefono: '4771112233',
estado: 'Contactado',
origen: 'Web',
capturadoEn: '2026-08-27T09:00:00.000Z',
},
])
})
it('mis capturas: si el item ya no existe en ningún tablero, 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') },
]
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' },
])
})
@@ -310,6 +365,14 @@ describe('clasificadores de estado', () => {
expect(esEtapaTerminal('Intento de contacto')).toBe(false)
})
it('estados terminales de lead (tablero Leads, 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('estados finales de apartado', () => {
expect(esEstadoFinalApartado('Escriturado')).toBe(true)
expect(esEstadoFinalApartado('CANCELADO')).toBe(true)