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

@@ -8,13 +8,14 @@ import {
claveVentaCreada,
esEstadoTerminal,
esEtapa,
ESTADOS_TERMINALES_LEAD,
oportunidadesInactivas,
oportunidadesSinContactar,
parseRrUserIds,
vendedorEnTurno,
type StateStore,
} from './automatizaciones.service.js'
import type { OportunidadMonday, UsuarioMonday } from './monday.service.js'
import type { LeadMonday, OportunidadMonday, UsuarioMonday } from './monday.service.js'
// ---------- Fixtures ----------
@@ -24,6 +25,21 @@ const VENDEDORES: UsuarioMonday[] = [
{ id: 103, nombre: 'Carla', email: 'carla@x.com' },
]
function lead(id: string, overrides: Partial<LeadMonday> = {}): LeadMonday {
return {
id,
nombre: `Lead ${id}`,
createdAt: '2026-08-01T09:00:00Z',
updatedAt: '2026-08-01T10:00:00Z',
estado: 'Nuevo',
origen: '',
vendedorIds: [],
telefono: '',
email: '',
...overrides,
}
}
function oportunidad(id: string, overrides: Partial<OportunidadMonday> = {}): OportunidadMonday {
return {
id,
@@ -55,10 +71,10 @@ function stateEnMemoria(): StateStore & { mapa: Map<string, string> } {
}
}
/** Monday mockeado: oportunidades fijas, registra asignaciones, updates y actividades */
function mondayMock(oportunidades: OportunidadMonday[] = []) {
/** Monday mockeado: leads y oportunidades fijos, registra asignaciones, updates y actividades */
function mondayMock(leads: LeadMonday[] = [], oportunidades: OportunidadMonday[] = []) {
return {
asignaciones: [] as { itemId: string; userId: number }[],
asignaciones: [] as { itemId: string; userId: number; tablero: string }[],
updates: [] as { itemId: string; body: string }[],
actividades: [] as {
nombre: string
@@ -70,11 +86,17 @@ function mondayMock(oportunidades: OportunidadMonday[] = []) {
}[],
apartados: [] as Record<string, unknown>[],
ventas: [] as Record<string, unknown>[],
async getLeads() {
return leads
},
async getOportunidades() {
return oportunidades
},
async asignarVendedorALead(itemId: string, userId: number) {
this.asignaciones.push({ itemId, userId, tablero: 'leads' })
},
async asignarAsesorAOportunidad(itemId: string, userId: number) {
this.asignaciones.push({ itemId, userId })
this.asignaciones.push({ itemId, userId, tablero: 'oportunidades' })
},
async crearUpdateEnItem(itemId: string, body: string) {
this.updates.push({ itemId, body })
@@ -125,8 +147,8 @@ describe('parseRrUserIds', () => {
})
})
describe('esEstadoTerminal (etapas terminales de Oportunidades)', () => {
it('Ganado / Perdido / Abandono son terminales, sin importar mayúsculas ni espacios', () => {
describe('esEstadoTerminal (terminales por tablero)', () => {
it('Ganado / Perdido / Abandono son terminales en Oportunidades (default)', () => {
expect(esEstadoTerminal('GANADO')).toBe(true)
expect(esEstadoTerminal(' perdido ')).toBe(true)
expect(esEstadoTerminal('Abandono')).toBe(true)
@@ -135,6 +157,14 @@ describe('esEstadoTerminal (etapas terminales de Oportunidades)', () => {
expect(esEstadoTerminal('Intento de contacto')).toBe(false)
expect(esEstadoTerminal('')).toBe(false)
})
it('Descartado / Calificado / Abandono son terminales en Leads (title case, case-insensitive)', () => {
expect(esEstadoTerminal('Descartado', ESTADOS_TERMINALES_LEAD)).toBe(true)
expect(esEstadoTerminal('calificado', ESTADOS_TERMINALES_LEAD)).toBe(true)
expect(esEstadoTerminal('ABANDONO', ESTADOS_TERMINALES_LEAD)).toBe(true)
expect(esEstadoTerminal('Nuevo', ESTADOS_TERMINALES_LEAD)).toBe(false)
expect(esEstadoTerminal('Contactado', ESTADOS_TERMINALES_LEAD)).toBe(false)
})
})
describe('vendedorEnTurno (rotación round-robin)', () => {
@@ -188,18 +218,24 @@ describe('claveRecordatorio (dedup por día)', () => {
// ---------- Servicio (monday mockeado, sin red) ----------
describe('AutomatizacionesService.runRoundRobin', () => {
it('asigna asesores en rotación a afluencias sin persona y persiste el índice', async () => {
const monday = mondayMock([oportunidad('1'), oportunidad('2'), oportunidad('3'), oportunidad('4')])
it('asigna en rotación sobre AMBOS tableros y persiste el índice (Leads → Vendedor, Oportunidades → Asesor)', async () => {
const monday = mondayMock([lead('l1'), lead('l2')], [oportunidad('o1'), oportunidad('o2')])
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
const r1 = await svc.runRoundRobin()
expect(r1.asignados).toBe(4)
expect(monday.asignaciones.map((a) => a.userId)).toEqual([101, 102, 103, 101])
expect(monday.asignaciones).toEqual([
{ itemId: 'l1', userId: 101, tablero: 'leads' },
{ itemId: 'l2', userId: 102, tablero: 'leads' },
{ itemId: 'o1', userId: 103, tablero: 'oportunidades' },
{ itemId: 'o2', userId: 101, tablero: 'oportunidades' },
])
expect(r1.detalle.map((d) => d.tablero)).toEqual(['leads', 'leads', 'oportunidades', 'oportunidades'])
expect(state.mapa.get('rr:indice')).toBe('1') // 4 asignaciones % 3 vendedores
// Segunda pasada con una afluencia nueva: continúa donde quedó (no reinicia en 0)
const monday2 = mondayMock([oportunidad('5')])
const monday2 = mondayMock([], [oportunidad('o5')])
const svc2 = new AutomatizacionesService(monday2, state)
const r2 = await svc2.runRoundRobin()
expect(r2.asignados).toBe(1)
@@ -207,8 +243,25 @@ describe('AutomatizacionesService.runRoundRobin', () => {
expect(state.mapa.get('rr:indice')).toBe('2')
})
it('solo reparte etapa Afluencia: Cotizador/Contactado NO entran al reparto', async () => {
it('Leads: reparte los sin Vendedor y no terminales (labels title case, case-insensitive)', async () => {
const monday = mondayMock([
lead('1'),
lead('2', { vendedorIds: [101] }), // ya asignado
lead('3', { estado: 'Descartado' }), // terminal
lead('4', { estado: 'Calificado' }), // terminal
lead('5', { estado: 'Contactado' }), // en seguimiento: sí entra
])
const svc = crearServicio(monday, stateEnMemoria())
const r = await svc.runRoundRobin(undefined, 'leads')
expect(r.asignados).toBe(2)
expect(monday.asignaciones).toEqual([
{ itemId: '1', userId: 101, tablero: 'leads' },
{ itemId: '5', userId: 102, tablero: 'leads' },
])
})
it('Oportunidades: solo reparte etapa Afluencia (Cotizador/Contactado NO entran)', async () => {
const monday = mondayMock([], [
oportunidad('1'),
oportunidad('2', { asesorIds: [101] }), // ya asignada
oportunidad('3', { etapa: 'Cotizador' }), // creada por el cotizador: no entra
@@ -217,35 +270,43 @@ describe('AutomatizacionesService.runRoundRobin', () => {
oportunidad('6', { etapa: 'afluencia' }), // case-insensitive: sí entra
])
const svc = crearServicio(monday, stateEnMemoria())
const r = await svc.runRoundRobin()
const r = await svc.runRoundRobin(undefined, 'oportunidades')
expect(r.asignados).toBe(2)
expect(monday.asignaciones).toEqual([
{ itemId: '1', userId: 101 },
{ itemId: '6', userId: 102 },
{ itemId: '1', userId: 101, tablero: 'oportunidades' },
{ itemId: '6', userId: 102, tablero: 'oportunidades' },
])
})
it('omite afluencias con bandera rr:skip=capturista (las asigna su capturista)', async () => {
const monday = mondayMock([oportunidad('1'), oportunidad('2')])
it('omite items con bandera rr:skip=capturista (los asigna su capturista)', async () => {
const monday = mondayMock([lead('1')], [oportunidad('2')])
const state = stateEnMemoria()
state.mapa.set('rr:skip:1', 'capturista') // el capturista la asigna
state.mapa.set('rr:skip:1', 'capturista') // el capturista lo asigna
state.mapa.set('rr:skip:2', 'round-robin') // bandera liberada: sí entra al reparto
const svc = crearServicio(monday, state)
const r = await svc.runRoundRobin()
expect(r.asignados).toBe(1)
expect(monday.asignaciones).toEqual([{ itemId: '2', userId: 101 }])
expect(monday.asignaciones).toEqual([{ itemId: '2', userId: 101, tablero: 'oportunidades' }])
})
it('con soloItemId solo evalúa ese item (ruta del webhook create_item)', async () => {
const monday = mondayMock([oportunidad('1'), oportunidad('2')])
it('con soloItemId + board solo evalúa ese item (ruta de los webhooks create_item)', async () => {
const monday = mondayMock([lead('1'), lead('2')], [oportunidad('9')])
const svc = crearServicio(monday, stateEnMemoria())
const r = await svc.runRoundRobin('2')
const r = await svc.runRoundRobin('2', 'leads')
expect(r.asignados).toBe(1)
expect(monday.asignaciones).toEqual([{ itemId: '2', userId: 101 }])
expect(monday.asignaciones).toEqual([{ itemId: '2', userId: 101, tablero: 'leads' }])
})
it('con soloItemId de otro tablero no asigna nada', async () => {
const monday = mondayMock([lead('1')], [oportunidad('9')])
const svc = crearServicio(monday, stateEnMemoria())
const r = await svc.runRoundRobin('9', 'leads') // '9' no está en Leads
expect(r.asignados).toBe(0)
expect(monday.asignaciones).toEqual([])
})
it('cachea los vendedores del equipo en el estado (una sola llamada dentro del TTL)', async () => {
const monday = mondayMock([])
const monday = mondayMock()
const spy = vi.spyOn(monday, 'getUsuariosEquipo')
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
@@ -258,7 +319,7 @@ describe('AutomatizacionesService.runRoundRobin', () => {
})
it('cache con formato viejo (arreglo sin ts) se descarta y re-consulta', async () => {
const monday = mondayMock([])
const monday = mondayMock()
const spy = vi.spyOn(monday, 'getUsuariosEquipo')
const state = stateEnMemoria()
state.mapa.set('rr:vendedores', JSON.stringify([{ id: 999, nombre: 'Viejo', email: '' }]))
@@ -277,7 +338,7 @@ describe('AutomatizacionesService.escaneoInactividad', () => {
]
it('preview: lista candidatos sin escribir en Monday ni en el estado', async () => {
const monday = mondayMock(oportunidadesViejas())
const monday = mondayMock([], oportunidadesViejas())
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
@@ -291,7 +352,7 @@ describe('AutomatizacionesService.escaneoInactividad', () => {
})
it('ejecución real: crea update, marca dedup y no repite el mismo día', async () => {
const monday = mondayMock(oportunidadesViejas())
const monday = mondayMock([], oportunidadesViejas())
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
@@ -320,7 +381,7 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
const ahora = new Date('2026-08-22T09:00:00Z')
it('el round-robin crea "Contactar a {nombre}" tipo Llamada, Abierto, ligada a la oportunidad', async () => {
const monday = mondayMock([oportunidad('1'), oportunidad('2')])
const monday = mondayMock([], [oportunidad('1'), oportunidad('2')])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
const r = await svc.runRoundRobin()
@@ -343,7 +404,7 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
})
it('con actividadesEnabled=false el round-robin no crea actividades', async () => {
const monday = mondayMock([oportunidad('1')])
const monday = mondayMock([], [oportunidad('1')])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: false })
await svc.runRoundRobin()
expect(monday.actividades).toEqual([])
@@ -355,7 +416,7 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
await svc.asignarACapturista('op-77', 'Lead Propio', 555)
expect(monday.asignaciones).toEqual([{ itemId: 'op-77', userId: 555 }])
expect(monday.asignaciones).toEqual([{ itemId: 'op-77', userId: 555, tablero: 'oportunidades' }])
expect(monday.actividades[0]).toMatchObject({
nombre: 'Contactar a Lead Propio',
responsableId: 555,
@@ -364,18 +425,28 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
})
})
it('asignarACapturista con board "leads" escribe la columna Vendedor del tablero Leads', async () => {
const monday = mondayMock([])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
await svc.asignarACapturista('lead-77', 'Lead Propio', 555, 'leads')
expect(monday.asignaciones).toEqual([{ itemId: 'lead-77', userId: 555, tablero: 'leads' }])
expect(monday.actividades[0]).toMatchObject({ nombre: 'Contactar a Lead Propio', leadItemId: 'lead-77' })
})
it('asignarACapturista con actividadesEnabled=false solo asigna', async () => {
const monday = mondayMock([])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: false })
await svc.asignarACapturista('op-78', 'Lead Propio', 555)
expect(monday.asignaciones).toEqual([{ itemId: 'op-78', userId: 555 }])
expect(monday.asignaciones).toEqual([{ itemId: 'op-78', userId: 555, tablero: 'oportunidades' }])
expect(monday.actividades).toEqual([])
})
it('inactividad: crea "Retomar contacto" al asesor asignado; las candidatas siempre tienen asesor', async () => {
const monday = mondayMock([oportunidad('1', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [103] })])
const monday = mondayMock([], [oportunidad('1', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [103] })])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
const r = await svc.escaneoInactividad({ preview: false, ahora })
@@ -391,21 +462,21 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
})
it('preview de inactividad no crea actividades', async () => {
const monday = mondayMock([oportunidad('1', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [101] })])
const monday = mondayMock([], [oportunidad('1', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [101] })])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
await svc.escaneoInactividad({ preview: true, ahora })
expect(monday.actividades).toEqual([])
})
it('un fallo al crear la actividad no rompe la asignación', async () => {
const monday = mondayMock([oportunidad('1')])
const monday = mondayMock([], [oportunidad('1')])
vi.spyOn(monday, 'crearActividad').mockRejectedValueOnce(new Error('Monday caído'))
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
const r = await svc.runRoundRobin()
expect(r.asignados).toBe(1) // la asignación se hizo igual
expect(monday.asignaciones).toEqual([{ itemId: '1', userId: 101 }])
expect(monday.asignaciones).toEqual([{ itemId: '1', userId: 101, tablero: 'oportunidades' }])
spyError.mockRestore()
})
})
@@ -462,7 +533,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
const anterior = configAuto['coordinadorUserId']
configAuto['coordinadorUserId'] = 0
try {
const monday = mondayMock([oportunidad('1', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' })])
const monday = mondayMock([], [oportunidad('1', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' })])
const state = stateEnMemoria()
const svc = new AutomatizacionesService(monday, state, { actividadesEnabled: true })
@@ -487,7 +558,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
const anterior = configAuto['coordinadorUserId']
configAuto['coordinadorUserId'] = 999
try {
const monday = mondayMock([oportunidad('1', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' })])
const monday = mondayMock([], [oportunidad('1', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' })])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
const r = await svc.escaneoEscalado({ ahora })
expect(r.escalados).toBe(1)
@@ -502,7 +573,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
const anterior = configAuto['coordinadorUserId']
configAuto['coordinadorUserId'] = 0
try {
const monday = mondayMock([oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-19T12:00:00Z' })])
const monday = mondayMock([], [oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-19T12:00:00Z' })])
const state = stateEnMemoria()
const svc = new AutomatizacionesService(monday, state, { actividadesEnabled: true })
@@ -523,7 +594,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
configAuto['coordinadorUserId'] = 0
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const monday = mondayMock([
const monday = mondayMock([], [
oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-19T12:00:00Z' }),
oportunidad('2', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' }),
])
@@ -546,7 +617,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
describe('AutomatizacionesService.escaneoOportunidades', () => {
it('una oportunidad en Aparto crea su item en Apartado (estado Pago Apartado)', async () => {
const monday = mondayMock([oportunidad('10', { etapa: 'Aparto' })])
const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' })])
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
@@ -562,7 +633,7 @@ describe('AutomatizacionesService.escaneoOportunidades', () => {
})
it('una oportunidad en Ganado crea su item en Ventas con unidad y valor', async () => {
const monday = mondayMock([oportunidad('11', { etapa: 'GANADO' })])
const monday = mondayMock([], [oportunidad('11', { etapa: 'GANADO' })])
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
@@ -573,7 +644,7 @@ describe('AutomatizacionesService.escaneoOportunidades', () => {
})
it('dedup: la segunda pasada no duplica apartados ni ventas', async () => {
const monday = mondayMock([oportunidad('10', { etapa: 'Aparto' }), oportunidad('11', { etapa: 'Ganado' })])
const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' }), oportunidad('11', { etapa: 'Ganado' })])
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
@@ -588,7 +659,7 @@ describe('AutomatizacionesService.escaneoOportunidades', () => {
})
it('ignora oportunidades en otras etapas', async () => {
const monday = mondayMock([
const monday = mondayMock([], [
oportunidad('10', { etapa: 'Cotizador' }),
oportunidad('11', { etapa: 'Perdido' }),
oportunidad('12', { etapa: 'Afluencia' }),
@@ -602,7 +673,7 @@ describe('AutomatizacionesService.escaneoOportunidades', () => {
})
it('un fallo de Monday en una oportunidad no detiene a las demás', async () => {
const monday = mondayMock([oportunidad('10', { etapa: 'Aparto' }), oportunidad('11', { etapa: 'Ganado' })])
const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' }), oportunidad('11', { etapa: 'Ganado' })])
vi.spyOn(monday, 'crearApartado').mockRejectedValueOnce(new Error('Monday caído'))
const state = stateEnMemoria()
const svc = crearServicio(monday, state)

View File

@@ -1,12 +1,20 @@
import { config } from '../config/index.js'
import { AppError } from '../utils/errors.js'
import { mondayService, type MondayService, type OportunidadMonday, type UsuarioMonday } from './monday.service.js'
import {
mondayService,
type LeadMonday,
type MondayService,
type OportunidadMonday,
type UsuarioMonday,
} from './monday.service.js'
import { prisma } from './prisma.js'
/**
* Automatizaciones sobre el tablero Oportunidades de Monday (la afluencia ES
* la oportunidad en etapa 'Afluencia'):
* (a) asignación round-robin de asesores a afluencias sin persona asignada
* Automatizaciones sobre Monday:
* (a) asignación round-robin en AMBOS tableros del embudo: Leads (captura
* ligera, columna Vendedor, cualquier estado no terminal sin asignar) y
* Oportunidades (Alta Afluencia, columna Asesor, solo etapa 'Afluencia'
* sin asignar — las del cotizador nacen en 'Cotizador' y no entran)
* (b) recordatorio de oportunidades inactivas (> INACTIVIDAD_DIAS sin actualizarse)
* (c) escalado de afluencias asignadas sin contactar
* (d) Oportunidad → Apartado / Ventas al entrar a 'Aparto' / 'Ganado'
@@ -16,6 +24,10 @@ import { prisma } from './prisma.js'
* recordatorios el mismo día.
*/
// Estados del tablero Leads en los que el lead ya no entra al reparto ni es
// trabajo activo (labels title case en el board; se comparan case-insensitive)
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'CALIFICADO', 'ABANDONO'] as const
// Etapas del tablero Oportunidades en las que la oportunidad ya no es trabajo
// activo del asesor (fuera de seguimiento).
export const ETAPAS_TERMINALES_OPORTUNIDAD = ['GANADO', 'PERDIDO', 'ABANDONO'] as const
@@ -157,7 +169,9 @@ export function esEtapa(etapa: string, esperada: string): boolean {
type MondayLeads = Pick<
MondayService,
| 'getLeads'
| 'getOportunidades'
| 'asignarVendedorALead'
| 'asignarAsesorAOportunidad'
| 'crearUpdateEnItem'
| 'getUsuariosEquipo'
@@ -176,8 +190,13 @@ export interface AsignacionRR {
lead: string
vendedorId: number
vendedor: string
/** Tablero del item asignado: 'leads' (Vendedor) u 'oportunidades' (Asesor) */
tablero: TableroRoundRobin
}
/** Tablero del embudo que cubre el round-robin / la asignación al capturista */
export type TableroRoundRobin = 'leads' | 'oportunidades'
export interface ResultadoRoundRobin {
asignados: number
detalle: AsignacionRR[]
@@ -294,25 +313,45 @@ export class AutomatizacionesService {
}
/**
* Una pasada de round-robin: asigna asesor a toda oportunidad en etapa
* 'Afluencia' sin persona asignada (las creadas por el cotizador nacen en
* 'Cotizador' y NO entran al reparto). Si se pasa `soloItemId`, solo se
* evalúa ese item (ruta del webhook create_item).
* Una pasada de round-robin sobre AMBOS tableros del embudo:
* - Leads: todo lead sin Vendedor asignado y con estado no terminal
* (Descartado/Calificado/Abandono quedan fuera).
* - Oportunidades: solo etapa 'Afluencia' sin Asesor (las creadas por el
* cotizador nacen en 'Cotizador' y NO entran al reparto).
* Si se pasa `soloItemId`, solo se evalúa ese item, y `board` limita la
* pasada a un tablero (rutas de los webhooks create_item).
*/
async runRoundRobin(soloItemId?: string): Promise<ResultadoRoundRobin> {
const oportunidades = await this.monday.getOportunidades()
const sinAsignar = oportunidades.filter(
(o) =>
o.asesorIds.length === 0 &&
o.etapa.trim().toUpperCase() === 'AFLUENCIA' &&
(soloItemId === undefined || o.id === soloItemId),
)
// Las afluencias capturadas desde una app con sesión se asignan a su
// capturista (bandera rr:skip='capturista'): sin este filtro, la latencia
// de Monday hacía que el webhook las viera sin asesor y pisara la asignación.
const pendientes: typeof sinAsignar = []
for (const o of sinAsignar) {
if ((await this.state.get(`${CLAVE_RR_SKIP}${o.id}`)) !== 'capturista') pendientes.push(o)
async runRoundRobin(soloItemId?: string, board?: TableroRoundRobin): Promise<ResultadoRoundRobin> {
interface Candidato {
id: string
nombre: string
tablero: TableroRoundRobin
}
const candidatos: Candidato[] = []
if (board === undefined || board === 'leads') {
const leads: LeadMonday[] = await this.monday.getLeads()
for (const l of leads) {
if (l.vendedorIds.length > 0) continue
if (esEstadoTerminal(l.estado, ESTADOS_TERMINALES_LEAD)) continue
if (soloItemId !== undefined && l.id !== soloItemId) continue
candidatos.push({ id: l.id, nombre: l.nombre, tablero: 'leads' })
}
}
if (board === undefined || board === 'oportunidades') {
const oportunidades = await this.monday.getOportunidades()
for (const o of oportunidades) {
if (o.asesorIds.length > 0) continue
if (o.etapa.trim().toUpperCase() !== 'AFLUENCIA') continue
if (soloItemId !== undefined && o.id !== soloItemId) continue
candidatos.push({ id: o.id, nombre: o.nombre, tablero: 'oportunidades' })
}
}
// Los items capturados desde una app con sesión se asignan a su capturista
// (bandera rr:skip='capturista'): sin este filtro, la latencia de Monday
// hacía que el webhook los viera sin persona asignada y pisara la asignación.
const pendientes: Candidato[] = []
for (const c of candidatos) {
if ((await this.state.get(`${CLAVE_RR_SKIP}${c.id}`)) !== 'capturista') pendientes.push(c)
}
if (pendientes.length === 0) {
await this.state.set(CLAVE_RR_ULTIMA_PASADA, new Date().toISOString())
@@ -324,18 +363,19 @@ export class AutomatizacionesService {
if (!Number.isInteger(indice) || indice < 0) indice = 0
const detalle: AsignacionRR[] = []
for (const op of pendientes) {
for (const c of pendientes) {
const vendedor = vendedorEnTurno(vendedores, indice)
if (!vendedor) throw new AppError(500, 'La lista de vendedores está vacía')
await this.monday.asignarAsesorAOportunidad(op.id, vendedor.id)
if (c.tablero === 'leads') await this.monday.asignarVendedorALead(c.id, vendedor.id)
else await this.monday.asignarAsesorAOportunidad(c.id, vendedor.id)
if (this.actividadesEnabled) {
await this.crearActividadSegura({
nombre: `Contactar a ${op.nombre}`,
nombre: `Contactar a ${c.nombre}`,
responsableId: vendedor.id,
leadItemId: op.id,
leadItemId: c.id,
})
}
detalle.push({ itemId: op.id, lead: op.nombre, vendedorId: vendedor.id, vendedor: vendedor.nombre })
detalle.push({ itemId: c.id, lead: c.nombre, vendedorId: vendedor.id, vendedor: vendedor.nombre, tablero: c.tablero })
indice++
}
@@ -345,19 +385,25 @@ export class AutomatizacionesService {
}
/**
* Asigna la afluencia a quien la capturó desde una app del ecosistema (su
* usuario Monday vinculado, columna Asesor de Oportunidades) y le crea la
* actividad de contacto — el mismo paquete que haría el round-robin, pero
* para el capturista. No hay carrera con el webhook create_item:
* runRoundRobin solo toma oportunidades sin persona asignada.
* Asigna el item a quien lo capturó desde una app del ecosistema (su usuario
* Monday vinculado) y le crea la actividad de contacto — el mismo paquete
* que haría el round-robin, pero para el capturista. `board` decide la
* columna: Vendedor en Leads, Asesor en Oportunidades. No hay carrera con el
* webhook create_item: runRoundRobin solo toma items sin persona asignada.
*/
async asignarACapturista(oportunidadId: string, nombre: string, vendedorId: number): Promise<void> {
await this.monday.asignarAsesorAOportunidad(oportunidadId, vendedorId)
async asignarACapturista(
itemId: string,
nombre: string,
vendedorId: number,
board: TableroRoundRobin = 'oportunidades',
): Promise<void> {
if (board === 'leads') await this.monday.asignarVendedorALead(itemId, vendedorId)
else await this.monday.asignarAsesorAOportunidad(itemId, vendedorId)
if (this.actividadesEnabled) {
await this.crearActividadSegura({
nombre: `Contactar a ${nombre}`,
responsableId: vendedorId,
leadItemId: oportunidadId,
leadItemId: itemId,
})
}
}

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)

View File

@@ -5,13 +5,16 @@ import type { MondayService } from './monday.service.js'
* Todo se filtra por su mondayUserId (columna people de cada tablero); si el
* usuario aún no tiene cuenta Monday (mondayUserId null), las listas personales
* van vacías con sinMapeoMonday: true (las acciones del CRM siguen funcionando).
* La fuente de leads/oportunidades es el tablero Oportunidades (la afluencia
* ES la oportunidad en etapa 'Afluencia'; el tablero Leads es archivo
* histórico). "Mis capturas" viene del registro local (tabla LeadCaptura), no
* de Monday: es la lista de afluencias que el usuario dio de alta, con su
* estado actual.
* "Mis leads" mezcla AMBOS tableros del embudo: Leads (captura ligera, columna
* Vendedor) y Oportunidades (Alta Afluencia, columna Asesor — la afluencia ES
* la oportunidad en etapa 'Afluencia'). "Mis capturas" viene del registro
* local (tabla LeadCaptura), no de Monday: es la lista de items que el usuario
* dio de alta, con su estado actual (cruza contra ambos tableros por id).
*/
// Estados del board Leads en los que el lead ya no es trabajo activo del vendedor
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'CALIFICADO', 'ABANDONO']
// Etapas del board Oportunidades en las que la oportunidad ya no es trabajo activo del asesor
export const ETAPAS_TERMINALES_OPORTUNIDAD = ['GANADO', 'PERDIDO', 'ABANDONO']
@@ -34,6 +37,8 @@ export interface LeadMiDia {
estado: string
origen: string
updatedAt: string
/** Tablero del item: 'lead' (tablero Leads) u 'oportunidad' (Oportunidades) */
fuente: 'lead' | 'oportunidad'
}
export interface ActividadMiDia {
@@ -98,7 +103,7 @@ export interface ResumenMiDia {
}
}
type MondayMiDia = Pick<MondayService, 'getOportunidades' | 'getActividades' | 'getApartados'>
type MondayMiDia = Pick<MondayService, 'getLeads' | 'getOportunidades' | 'getActividades' | 'getApartados'>
const MS_DIA = 24 * 60 * 60 * 1000
@@ -111,6 +116,10 @@ function fechaLocal(base: Date, dias: number): string {
return `${y}-${m}-${dd}`
}
export function esEstadoTerminalLead(estado: string): boolean {
return ESTADOS_TERMINALES_LEAD.includes(estado.trim().toUpperCase())
}
export function esEtapaTerminal(etapa: string): boolean {
return ETAPAS_TERMINALES_OPORTUNIDAD.includes(etapa.trim().toUpperCase())
}
@@ -133,7 +142,8 @@ export async function resumenMiDia(
const miId = usuario.mondayUserId
const sinMapeo = miId === null
const [oportunidades, actividades, apartados] = await Promise.all([
const [leads, oportunidades, actividades, apartados] = await Promise.all([
monday.getLeads(),
monday.getOportunidades(),
monday.getActividades(),
monday.getApartados(),
@@ -145,8 +155,25 @@ export async function resumenMiDia(
const hace7DiasMs = ahora.getTime() - 7 * MS_DIA
// --- Listas personales (vacías si no hay mapeo Monday) ---
// "Mis leads" mezcla ambos tableros: Leads (Vendedor = yo, estado no
// terminal) + Oportunidades (Asesor = yo, etapa no terminal), más reciente
// primero y con su fuente para las acciones del CRM.
const misLeadsLeads: LeadMiDia[] = sinMapeo
? []
: leads
.filter((l) => l.vendedorIds.includes(miId) && !esEstadoTerminalLead(l.estado))
.map((l) => ({
id: l.id,
nombre: l.nombre,
telefono: l.telefono,
email: l.email,
estado: l.estado,
origen: l.origen,
updatedAt: l.updatedAt,
fuente: 'lead',
}))
const mios = sinMapeo ? [] : oportunidades.filter((o) => o.asesorIds.includes(miId))
const misLeads: LeadMiDia[] = mios
const misLeadsOportunidades: LeadMiDia[] = mios
.filter((o) => !esEtapaTerminal(o.etapa))
.map((o) => ({
id: o.id,
@@ -156,7 +183,11 @@ export async function resumenMiDia(
estado: o.etapa,
origen: o.origen,
updatedAt: o.updatedAt,
fuente: 'oportunidad',
}))
const misLeads: LeadMiDia[] = [...misLeadsLeads, ...misLeadsOportunidades].sort((a, b) =>
b.updatedAt.localeCompare(a.updatedAt),
)
// Conteo de MIS oportunidades por etapa (incluye terminales: es el resumen
// del pipeline personal, no la lista de trabajo activo)
@@ -166,19 +197,21 @@ export async function resumenMiDia(
porEtapa[etapa] = (porEtapa[etapa] ?? 0) + 1
}
// "Mis capturas" se enriquece con el estado/origen ACTUAL en Monday (las
// capturas nuevas apuntan a Oportunidades; las viejas — del tablero Leads —
// ya no cruzan y quedan con estado/origen ''), más reciente primero
// "Mis capturas" se enriquece con el estado/origen ACTUAL en Monday,
// cruzando contra AMBOS tableros por id (las capturas rápidas apuntan a
// Leads; las de Alta Afluencia, a Oportunidades), más reciente primero
const leadsPorId = new Map(leads.map((l) => [l.id, l]))
const oportunidadesPorId = new Map(oportunidades.map((o) => [o.id, o]))
const misCapturas: CapturaMiDia[] = capturas
.map((c) => {
const lead = leadsPorId.get(c.leadId)
const op = oportunidadesPorId.get(c.leadId)
return {
leadId: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
estado: op?.etapa ?? '',
origen: op?.origen ?? '',
estado: lead?.estado ?? op?.etapa ?? '',
origen: lead?.origen ?? op?.origen ?? '',
capturadoEn: c.createdAt.toISOString(),
}
})
@@ -222,8 +255,12 @@ export async function resumenMiDia(
diasRestantes: Math.round((Date.parse(`${a.fechaLimite}T00:00:00`) - Date.parse(`${hoy}T00:00:00`)) / MS_DIA),
}))
// Conteo de la semana: míos en AMBOS tableros (leads + oportunidades)
const miosLeadRows = sinMapeo ? [] : leads.filter((l) => l.vendedorIds.includes(miId))
const conteos = {
leadsNuevosSemana: mios.filter((l) => Date.parse(l.createdAt) >= hace7DiasMs).length,
leadsNuevosSemana:
miosLeadRows.filter((l) => Date.parse(l.createdAt) >= hace7DiasMs).length +
mios.filter((o) => Date.parse(o.createdAt) >= hace7DiasMs).length,
actividadesAbiertas: misActividades.filter((a) => a.estado.trim().toUpperCase() === 'ABIERTO').length,
}
@@ -240,9 +277,23 @@ export async function resumenMiDia(
}
if (ROLES_CON_EQUIPO.includes(usuario.rol)) {
const sinAsignar = oportunidades.filter((o) => o.asesorIds.length === 0 && !esEtapaTerminal(o.etapa))
// Sin asignar: leads sin Vendedor (estado NUEVO) + afluencias sin Asesor
// (etapa Afluencia) — lo que el round-robin aún no reparte
const sinAsignar =
leads.filter((l) => l.vendedorIds.length === 0 && !esEstadoTerminalLead(l.estado) && l.estado.trim().toUpperCase() === 'NUEVO')
.length +
oportunidades.filter((o) => o.asesorIds.length === 0 && o.etapa.trim().toUpperCase() === 'AFLUENCIA').length
const conteoPorId = new Map<number, number>()
const nuevosPorId = new Map<number, number>()
// Leads y oportunidades comparten el conteo por vendedor (misma persona Monday)
for (const l of leads) {
if (esEstadoTerminalLead(l.estado)) continue
const esNuevoSemana = Date.parse(l.createdAt) >= hace7DiasMs
for (const id of l.vendedorIds) {
conteoPorId.set(id, (conteoPorId.get(id) ?? 0) + 1)
if (esNuevoSemana) nuevosPorId.set(id, (nuevosPorId.get(id) ?? 0) + 1)
}
}
for (const o of oportunidades) {
if (esEtapaTerminal(o.etapa)) continue
const esNuevoSemana = Date.parse(o.createdAt) >= hace7DiasMs
@@ -252,8 +303,7 @@ export async function resumenMiDia(
}
}
resumen.equipo = {
// Afluencias recién llegadas que el round-robin aún no reparte
leadsNuevosSinAsignar: sinAsignar.filter((o) => o.etapa.trim().toUpperCase() === 'AFLUENCIA').length,
leadsNuevosSinAsignar: sinAsignar,
porVendedor: [...conteoPorId.entries()]
.map(([mondayUserId, leadsAsignados]) => ({
mondayUserId,

View File

@@ -557,6 +557,97 @@ describe('MondayService', () => {
})
})
describe('asignarVendedorALead (columna people Vendedor del tablero Leads)', () => {
it('escribe personsAndTeams con el user id en el board Leads (444 en tests)', async () => {
const llamadas = mockearFetch((body) => {
if (body.query.includes('columns')) {
return respuestaOk({ boards: [{ columns: [{ id: 'people_v', title: 'Vendedor', type: 'people' }] }] })
}
return respuestaOk({ change_column_value: { id: 'lead-1' } })
})
await crearServicio().asignarVendedorALead('lead-1', 12345)
const mutacion = llamadas.find((l) => l.body.query.includes('change_column_value'))
expect(mutacion?.body.variables['boardId']).toBe('444')
expect(mutacion?.body.variables['columnId']).toBe('people_v')
expect(JSON.parse(String(mutacion?.body.variables['value']))).toEqual({
personsAndTeams: [{ id: 12345, kind: 'person' }],
})
})
})
describe('crearLead (captura ligera en el tablero Leads)', () => {
const COLUMNAS_LEADS_LIGERO = [
{ id: 'color_estado', title: 'Estado', type: 'status' },
{ id: 'date_fecha', title: 'Fecha', type: 'date' },
{ id: 'phone_tel', title: 'Teléfono', type: 'phone' },
{ id: 'email_em', title: 'Email', type: 'email' },
{ id: 'color_or', title: 'Origen', type: 'status' },
{ id: 'color_des', title: 'Desarrollo', type: 'status' },
{ id: 'text_notas', title: 'Notas', type: 'text' },
]
it('crea el item ligero con Estado NUEVO y Fecha de hoy (sin perfil ni oportunidad)', async () => {
const llamadas = mockearFetch((body) => {
if (body.query.includes('settings_str')) {
return respuestaOk({
boards: [
{
columns: COLUMNAS_LEADS_LIGERO.filter((c) => c.type === 'status').map((c) => ({
id: c.id,
title: c.title,
settings_str: '{"labels":{"1":"Nuevo"}}',
})),
},
],
})
}
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_LEADS_LIGERO }] })
return respuestaOk({ create_item: { id: 'lead-nuevo-1' } })
})
const id = await crearServicio().crearLead({
nombre: 'Lead Prueba',
telefono: '4771234567',
email: 'lead@correo.com',
origenLabel: 'Facebook',
desarrolloLabel: 'SENDA',
notas: 'Vio el volante',
})
expect(id).toBe('lead-nuevo-1')
const mutacion = llamadas.find((l) => l.body.query.includes('create_item'))
expect(mutacion?.body.variables['boardId']).toBe('444') // tablero Leads
const parsed = JSON.parse(String(mutacion?.body.variables['columnValues'])) as Record<string, unknown>
expect(parsed['color_estado']).toEqual({ label: 'Nuevo' }) // 'NUEVO' resuelto al label real
expect(parsed['date_fecha']).toMatchObject({ date: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) })
expect(parsed['phone_tel']).toEqual({ phone: '4771234567', countryShortName: 'MX' })
expect(parsed['email_em']).toEqual({ email: 'lead@correo.com', text: 'lead@correo.com' })
expect(parsed['color_or']).toEqual({ label: 'Facebook' })
expect(parsed['color_des']).toEqual({ label: 'SENDA' })
expect(parsed['text_notas']).toBe('Vio el volante')
})
it('solo envía las columnas capturadas (las vacías se omiten)', async () => {
const llamadas = mockearFetch((body) => {
if (body.query.includes('settings_str')) {
return respuestaOk({
boards: [{ columns: [{ id: 'color_estado', title: 'Estado', settings_str: '{"labels":{"1":"Nuevo"}}' }] }],
})
}
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_LEADS_LIGERO }] })
return respuestaOk({ create_item: { id: 'lead-nuevo-2' } })
})
await crearServicio().crearLead({ nombre: 'Solo Nombre' })
const mutacion = llamadas.find((l) => l.body.query.includes('create_item'))
const parsed = JSON.parse(String(mutacion?.body.variables['columnValues'])) as Record<string, unknown>
expect(Object.keys(parsed).sort()).toEqual(['color_estado', 'date_fecha'])
})
})
describe('asignarAsesorAOportunidad (columna people Asesor)', () => {
it('escribe personsAndTeams con el user id en el board Oportunidades (555 en tests)', async () => {
const llamadas = mockearFetch((body) => {

View File

@@ -1033,6 +1033,27 @@ export class MondayService {
}
}
/**
* Item en el tablero Leads (Estado NUEVO; captura rápida del vendedor desde
* /hoy o captura manual). La columna date 'Fecha' lleva la fecha de captura
* (hoy, hora local). Los leads NO crean oportunidad (eso es /api/afluencias).
*/
async crearLead(datos: DatosLead): Promise<string> {
const boardId = config.monday.boards.leads
const hoy = new Date()
const fechaHoy = `${hoy.getFullYear()}-${String(hoy.getMonth() + 1).padStart(2, '0')}-${String(hoy.getDate()).padStart(2, '0')}`
const columnValues = await this.construirColumnValues(boardId, {
Estado: 'NUEVO',
Fecha: fechaHoy,
Teléfono: datos.telefono,
Email: datos.email,
Origen: datos.origenLabel,
Desarrollo: datos.desarrolloLabel,
Notas: datos.notas,
})
return this.crearItem(boardId, datos.nombre, columnValues)
}
/**
* Lead con el mismo teléfono (dedup de captura rápida). Compara solo dígitos
* y los últimos 10 (tolerante a lada/prefijo 52). null si no hay duplicado.
@@ -1111,10 +1132,9 @@ export class MondayService {
return abiertas
}
/** Asigna un asesor (user ID de Monday) a la columna people "Asesor" de una oportunidad */
async asignarAsesorAOportunidad(itemId: string, userId: number): Promise<void> {
const boardId = config.monday.boards.oportunidades
const col = await this.columnaId(boardId, 'Asesor')
/** Asigna una persona (user ID de Monday) a una columna people de un item */
private async asignarPersona(boardId: string, itemId: string, tituloColumna: string, userId: number): Promise<void> {
const col = await this.columnaId(boardId, tituloColumna)
await this.graphql(
`mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) {
change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id }
@@ -1128,6 +1148,16 @@ export class MondayService {
)
}
/** Asigna un asesor (user ID de Monday) a la columna people "Asesor" de una oportunidad */
async asignarAsesorAOportunidad(itemId: string, userId: number): Promise<void> {
return this.asignarPersona(config.monday.boards.oportunidades, itemId, 'Asesor', userId)
}
/** Asigna un vendedor (user ID de Monday) a la columna people "Vendedor" de un lead */
async asignarVendedorALead(itemId: string, userId: number): Promise<void> {
return this.asignarPersona(config.monday.boards.leads, itemId, 'Vendedor', userId)
}
/** Crea un update (comentario) en un item — usado para los recordatorios de inactividad */
async crearUpdateEnItem(itemId: string, body: string): Promise<string> {
const data = await this.graphql<{ create_update: { id: string } }>(
@@ -1255,6 +1285,16 @@ export interface DatosActividad {
leadItemId?: string // item del tablero Leads relacionado (se omite si no aplica)
}
/** Captura ligera de lead (tablero Leads): la forma corta de /hoy y Monday UI */
export interface DatosLead {
nombre: string
telefono?: string | undefined
email?: string | undefined
origenLabel?: string | undefined // label real de la columna status 'Origen' del board Leads
desarrolloLabel?: string | undefined // label real de la columna status 'Desarrollo' del board Leads
notas?: string | undefined
}
/** Singleton usado por los controllers (los tests instancian el suyo) */
export const mondayService = new MondayService()

View File

@@ -1,11 +1,27 @@
import { describe, expect, it, vi } from 'vitest'
import { construirResumen, ReportesService, type DatosResumen } from './reportes.service.js'
import type { OportunidadMonday, Unidad } from './monday.service.js'
import type { LeadMonday, OportunidadMonday, Unidad } from './monday.service.js'
import type { PlanPago } from './cobranza.service.js'
// ---------- Fixtures ----------
/** Afluencia/oportunidad del board Oportunidades (fuente única de la sección leads) */
/** Lead del tablero Leads (captura ligera; fuente de la sección leads) */
function lead(id: string, overrides: Partial<LeadMonday> = {}): LeadMonday {
return {
id,
nombre: `Lead ${id}`,
createdAt: '2026-08-20T10:00:00Z',
updatedAt: '2026-08-20T10:00:00Z',
estado: 'Nuevo',
origen: 'Facebook',
vendedorIds: [],
telefono: '',
email: '',
...overrides,
}
}
/** Afluencia del board Oportunidades (fuente del perfilamiento de afluencia) */
function afluencia(id: string, overrides: Partial<OportunidadMonday> = {}): OportunidadMonday {
return {
id,
@@ -68,37 +84,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 = { oportunidades: [], unidades: [], planes: [], actividadesAbiertas: 0 }
const DATOS_BASE: DatosResumen = { leads: [], oportunidades: [], unidades: [], planes: [], actividadesAbiertas: 0 }
// ---------- construirResumen (puro) ----------
describe('construirResumen', () => {
it('cuenta la afluencia por estado (Etapa) y por origen (Origen del Lead)', () => {
it('cuenta leads por estado y por origen (tablero Leads, captura ligera)', () => {
const r = construirResumen(
{
...DATOS_BASE,
oportunidades: [
afluencia('1', { etapa: 'Afluencia', origen: 'Facebook' }),
afluencia('2', { etapa: 'Afluencia', origen: 'Facebook' }),
afluencia('3', { etapa: 'Contactado', origen: 'Referido' }),
afluencia('4', { etapa: '', origen: '' }),
leads: [
lead('1', { estado: 'Nuevo', origen: 'Facebook' }),
lead('2', { estado: 'Nuevo', origen: 'Facebook' }),
lead('3', { estado: 'Contactado', origen: 'Referido' }),
lead('4', { estado: '', origen: '' }),
],
},
AHORA,
)
expect(r.leads.total).toBe(4)
expect(r.leads.porEstado).toEqual({ Afluencia: 2, Contactado: 1, '(sin dato)': 1 })
expect(r.leads.porEstado).toEqual({ Nuevo: 2, Contactado: 1, '(sin dato)': 1 })
expect(r.leads.porOrigen).toEqual({ Facebook: 2, Referido: 1, '(sin dato)': 1 })
})
it('cuenta afluencias nuevas de hoy y de los últimos 7 días (fecha local)', () => {
it('cuenta leads nuevos de hoy y de los últimos 7 días (fecha local)', () => {
const r = construirResumen(
{
...DATOS_BASE,
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
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
],
},
AHORA,
@@ -300,7 +316,11 @@ describe('construirResumen', () => {
describe('ReportesService', () => {
function mondayMock() {
return {
llamadas: { oportunidades: 0, unidades: 0, actividades: 0 },
llamadas: { leads: 0, oportunidades: 0, unidades: 0, actividades: 0 },
async getLeads(): Promise<LeadMonday[]> {
this.llamadas.leads++
return [lead('1')]
},
async getOportunidades(): Promise<OportunidadMonday[]> {
this.llamadas.oportunidades++
return [afluencia('1')]
@@ -316,16 +336,17 @@ describe('ReportesService', () => {
}
}
it('agrega todas las fuentes en una sola respuesta (una sola llamada a Oportunidades)', async () => {
it('agrega todas las fuentes en una sola respuesta', 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) // la sección leads se alimenta de Oportunidades
expect(r.leads.total).toBe(1) // la sección leads se alimenta del tablero Leads
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
expect(monday.llamadas.leads).toBe(1)
expect(monday.llamadas.oportunidades).toBe(1)
})
it('cachea 60 s: la segunda llamada no vuelve a Monday ni a Prisma', async () => {

View File

@@ -1,12 +1,13 @@
import { statusPago, statusPlan, type PlanPago } from './cobranza.service.js'
import type { MondayService, OportunidadMonday, Unidad } from './monday.service.js'
import type { LeadMonday, MondayService, OportunidadMonday, Unidad } from './monday.service.js'
import { ETAPAS_PIPELINE } from './etapas.js'
/**
* Agregador de reportes para la app de tableros (/dash/).
* En UNA respuesta junta: afluencia/perfil (Monday, tablero Oportunidades — la
* afluencia ES la oportunidad en etapa 'Afluencia'), oportunidades (Monday),
* unidades (Monday), cobranza (Prisma local) y actividades abiertas (Monday).
* En UNA respuesta junta: leads (Monday, tablero Leads — captura ligera),
* afluencia/perfilamiento (Monday, tablero Oportunidades — el Alta Afluencia
* vive ahí), oportunidades (Monday), unidades (Monday), cobranza (Prisma
* local) y actividades abiertas (Monday).
* Cache en memoria de 60 s para no martillar la API de Monday.
*/
@@ -76,8 +77,10 @@ export interface ResumenReportes {
/** Datos crudos que necesita el agregador (inyectables en tests) */
export interface DatosResumen {
// La sección `leads` (afluencia + perfilamiento) y la sección `oportunidades`
// se alimentan de la MISMA fuente: el tablero Oportunidades
// La sección `leads` (total/nuevos/porEstado/porOrigen) lee el tablero Leads
// (captura ligera); el `perfil` de la afluencia lee Oportunidades (Alta
// Afluencia), que es también la fuente de la sección `oportunidades`
leads: LeadMonday[]
oportunidades: OportunidadMonday[]
unidades: Unidad[]
planes: PlanPago[] // dominio de cobranza (con pagos)
@@ -164,8 +167,8 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor
const hoy = inicioDelDia(ahora)
const hace7dias = new Date(hoy.getTime() - 7 * 24 * 60 * 60 * 1000)
const creadosEn = (desde: Date): number =>
datos.oportunidades.filter((o) => {
const t = Date.parse(o.createdAt)
datos.leads.filter((l) => {
const t = Date.parse(l.createdAt)
return !Number.isNaN(t) && t >= desde.getTime()
}).length
@@ -221,14 +224,14 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor
return {
generadoEn: ahora.toISOString(),
// Sección "leads" = afluencia sobre el tablero Oportunidades (modelo
// nuevo: la afluencia ES la oportunidad en etapa 'Afluencia')
// Sección "leads" = tablero Leads (captura ligera); el perfilamiento de la
// afluencia se lee de Oportunidades (el Alta Afluencia vive ahí)
leads: {
total: datos.oportunidades.length,
total: datos.leads.length,
nuevosHoy: creadosEn(hoy),
nuevosSemana: creadosEn(hace7dias),
porEstado: contarPor(datos.oportunidades.map((o) => o.etapa)),
porOrigen: contarPor(datos.oportunidades.map((o) => o.origen)),
porEstado: contarPor(datos.leads.map((l) => l.estado)),
porOrigen: contarPor(datos.leads.map((l) => l.origen)),
perfil: {
porDesarrollo: contarPorNoVacios(datos.oportunidades.map((o) => o.desarrollo ?? '')),
porModelo: contarPorNoVacios(datos.oportunidades.map((o) => o.modeloInteres ?? '')),
@@ -277,7 +280,7 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor
// ---------- Servicio con cache ----------
type MondayReportes = Pick<MondayService, 'getOportunidades' | 'getUnidades' | 'getActividadesAbiertas'>
type MondayReportes = Pick<MondayService, 'getLeads' | 'getOportunidades' | 'getUnidades' | 'getActividadesAbiertas'>
/** Carga los planes de cobranza en forma de dominio (Prisma en prod, fixture en tests) */
export type CargadorPlanes = () => Promise<PlanPago[]>
@@ -313,13 +316,14 @@ export class ReportesService {
if (this.cache && this.cache.expira > ahoraMs) return this.cache.resumen
// Las fuentes de Monday se piden en paralelo; Prisma va por su lado
const [oportunidades, unidades, actividadesAbiertas, planes] = await Promise.all([
const [leads, oportunidades, unidades, actividadesAbiertas, planes] = await Promise.all([
this.monday.getLeads(),
this.monday.getOportunidades(),
this.monday.getUnidades(),
this.monday.getActividadesAbiertas(),
this.cargarPlanes(),
])
const resumen = construirResumen({ oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs))
const resumen = construirResumen({ leads, oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs))
this.cache = { resumen, expira: ahoraMs + this.cacheMs }
return resumen
}