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,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', () => {
|
||||
|
||||
Reference in New Issue
Block a user