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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user