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

@@ -160,8 +160,11 @@ const hoyMas = (dias: number): string => {
// ---------- Helpers compartidos (alta directa y alta progresiva) ----------
/**
* Si el alta viene de un lead del funnel (la app alta-clientes manda
* Si el alta viene de una afluencia del funnel (la app alta-clientes manda
* leadMondayId en la raíz del payload), su oportunidad pasa a 'Aparto'.
* El id puede ser una oportunidad directa (modelo nuevo: la afluencia ES la
* oportunidad, fuente 'oportunidad' del picker) o un lead histórico del
* tablero Leads; moverAApartoConDedup resuelve ambos vía LeadOportunidad.
* moverAApartoConDedup escribe el dedup `apartado-creado:<opId>` ANTES del
* cambio de etapa, para que el webhook/cron no cree un Apartado duplicado.
* Un fallo aquí no rompe el alta.

View File

@@ -9,8 +9,7 @@ import {
descartarLead,
ESTADOS_LEAD_PERMITIDOS,
} from './leads.controller.js'
import { mondayService, type LeadMonday } from '../services/monday.service.js'
import { funnelService } from '../services/funnel.service.js'
import { mondayService, type LeadMonday, type OportunidadMonday } from '../services/monday.service.js'
import { automatizacionesService } from '../services/automatizaciones.service.js'
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
@@ -19,6 +18,9 @@ const prismaMock = vi.hoisted(() => ({
leadCaptura: {
create: vi.fn(),
},
leadOportunidad: {
create: vi.fn(),
},
usuario: {
findUnique: vi.fn(),
},
@@ -30,37 +32,38 @@ const prismaMock = vi.hoisted(() => ({
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
const LABELS_DESARROLLO = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA']
const LABELS_ORIGEN = ['WhatsApp', 'Web', 'Recomendado', 'Facebook', 'Otro', 'Instagram', 'Broker', 'Base de datos', 'Prospección']
const LABELS_MOTIVO = ['Precio', 'Producto', 'Ubicacion', 'Financiamiento', 'Competencia', 'Cliente', 'Timing']
/** Monday mockeado: registra escrituras y devuelve catálogos controlados */
function mondayMock(
opts: {
labelsOrigen?: string[]
labelsDesarrollo?: string[]
labelsMotivo?: string[]
duplicado?: LeadMonday | null
duplicado?: OportunidadMonday | null
} = {},
) {
return {
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
motivos: [] as { nombre: string; categoria: string; descripcion?: string }[],
leadsCreados: [] as Record<string, unknown>[],
oportunidadesCreadas: [] as Record<string, unknown>[],
movimientosGrupo: [] as { itemId: string; groupId: string }[],
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
this.cambios.push({ boardId, itemId, columna, valor })
},
async crearLead(datos: Record<string, unknown>) {
this.leadsCreados.push(datos)
return 'lead-nuevo-1'
async crearOportunidadAfluencia(datos: Record<string, unknown>) {
this.oportunidadesCreadas.push(datos)
return 'op-nueva-1'
},
async buscarLeadPorTelefono(_telefono: string) {
async buscarOportunidadPorTelefono(_telefono: string) {
return opts.duplicado ?? null
},
async labelsDeColumna(_boardId: string, titulo: string) {
if (titulo === 'Desarrollo') {
return opts.labelsDesarrollo ?? ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA']
}
if (titulo === 'Motivo No Compra') {
return opts.labelsMotivo ?? ['Precio', 'Producto', 'Ubicacion', 'Financiamiento', 'Competencia', 'Cliente', 'Timing']
}
return opts.labelsOrigen ?? ['WhatsApp', 'Web', 'Recomendado', 'Facebook', 'Otro']
if (titulo === 'Desarrollo') return opts.labelsDesarrollo ?? LABELS_DESARROLLO
if (titulo === 'Motivo No Compra') return opts.labelsMotivo ?? LABELS_MOTIVO
return opts.labelsOrigen ?? LABELS_ORIGEN
},
async obtenerNombreItem(_itemId: string) {
return 'Lead Prueba'
@@ -69,46 +72,70 @@ function mondayMock(
this.motivos.push(datos)
return 'motivo-item-1'
},
}
}
/** Funnel mockeado: registra asegurados/movimientos; configurable para fallar */
function funnelMock(opts: { oportunidadId?: string | null; fallaAsegurar?: boolean } = {}) {
return {
asegurados: [] as Record<string, unknown>[],
movimientos: [] as { leadId: string; etapa: string }[],
async asegurarOportunidadAfluencia(lead: Record<string, unknown>) {
if (opts.fallaAsegurar === true) throw new Error('Monday caído')
this.asegurados.push(lead)
return 'op-1'
// Para moverAGrupoSegunEstado tras la creación: el item nace en el grupo
// top del tablero (como en Monday real) con Estado 'Afluencia'
async columnaIdPorTitulo(_boardId: string, _titulo: string) {
return 'col_estado'
},
async moverOportunidadDeLead(leadId: string, etapa: string) {
this.movimientos.push({ leadId, etapa })
return true
async getItemConGrupo(itemId: string) {
return {
id: itemId,
name: 'Lead Prueba',
groupId: 'group_top',
column_values: [{ id: 'col_estado', text: 'Afluencia', value: '{"index":1}' }],
}
},
async oportunidadDeLead(_leadId: string) {
return opts.oportunidadId === undefined ? 'op-1' : opts.oportunidadId
async moverItemAGrupo(itemId: string, groupId: string) {
this.movimientosGrupo.push({ itemId, groupId })
},
}
}
const datosBase = { nombre: 'Lead Prueba', telefono: '4771234567' }
describe('crearLead (POST /api/leads)', () => {
it('crea el item en Monday con Estado NUEVO y devuelve su id', async () => {
const oportunidadMonday = (overrides: Partial<OportunidadMonday>): OportunidadMonday => ({
id: 'op-1',
nombre: 'Lead Prueba',
createdAt: '2026-08-01T00:00:00Z',
updatedAt: '2026-08-01T00:00:00Z',
etapa: 'Afluencia',
unidadCodigo: '',
precioFinal: 0,
asesorIds: [],
telefono: '',
email: '',
origen: '',
...overrides,
})
describe('crearLead (POST /api/leads) — la afluencia ES la oportunidad', () => {
it('crea UN item en Oportunidades (Estado Afluencia) y devuelve su id como id y oportunidadId', async () => {
const monday = mondayMock()
const r = await crearLead(monday, { ...datosBase, origen: 'Facebook', notas: 'Vio el volante' })
expect(r).toEqual({ id: 'lead-nuevo-1', oportunidadId: null })
expect(monday.leadsCreados).toEqual([
expect(r).toEqual({ id: 'op-nueva-1', oportunidadId: 'op-nueva-1' })
expect(monday.oportunidadesCreadas).toEqual([
{
nombre: 'Lead Prueba',
telefono: '4771234567',
email: undefined,
origenLabel: 'Facebook',
desarrolloLabel: undefined,
origenLabel: 'Facebook',
modeloLabel: undefined,
medioContacto: undefined,
edad: undefined,
estadoCivilLabel: undefined,
tipoEmpleoLabel: undefined,
ocupacion: undefined,
rangoSalarial: undefined,
creditoLabel: undefined,
tipoEconomiaLabel: undefined,
calificaLabel: undefined,
loQuierePara: undefined,
notas: 'Vio el volante',
},
])
// Y la mueve al grupo de su etapa (Monday la crea en el grupo top, hoy "Perdidos")
expect(monday.movimientosGrupo).toEqual([{ itemId: 'op-nueva-1', groupId: 'topics' }])
})
it('pasa email y desarrollo al item de Monday cuando vienen en la captura', async () => {
@@ -118,8 +145,8 @@ describe('crearLead (POST /api/leads)', () => {
email: 'lead@correo.com',
desarrollo: 'SENDA',
})
expect(r).toEqual({ id: 'lead-nuevo-1', oportunidadId: null })
expect(monday.leadsCreados[0]).toMatchObject({ email: 'lead@correo.com', desarrolloLabel: 'SENDA' })
expect(r).toEqual({ id: 'op-nueva-1', oportunidadId: 'op-nueva-1' })
expect(monday.oportunidadesCreadas[0]).toMatchObject({ email: 'lead@correo.com', desarrolloLabel: 'SENDA' })
})
it('pasa los campos de calificación al item de Monday (labels listos para el board)', async () => {
@@ -138,8 +165,8 @@ describe('crearLead (POST /api/leads)', () => {
califica: 'Sí',
loQuierePara: 'Vivir ahí',
})
expect(r).toEqual({ id: 'lead-nuevo-1', oportunidadId: null })
expect(monday.leadsCreados[0]).toMatchObject({
expect(r).toEqual({ id: 'op-nueva-1', oportunidadId: 'op-nueva-1' })
expect(monday.oportunidadesCreadas[0]).toMatchObject({
modeloLabel: 'HIMALAYA',
medioContacto: 'WhatsApp',
edad: 35,
@@ -154,189 +181,136 @@ describe('crearLead (POST /api/leads)', () => {
})
})
it('con funnel: asegura la oportunidad de Afluencia con los datos del lead', async () => {
const monday = mondayMock()
const funnel = funnelMock()
const r = await crearLead(monday, { ...datosBase, email: 'lead@correo.com', desarrollo: 'SENDA' }, funnel)
expect(r).toEqual({ id: 'lead-nuevo-1', oportunidadId: 'op-1' })
expect(funnel.asegurados).toEqual([
{ id: 'lead-nuevo-1', nombre: 'Lead Prueba', telefono: '4771234567', email: 'lead@correo.com', desarrolloLabel: 'SENDA' },
])
})
it('si falla la oportunidad de Afluencia, el lead igual se crea (oportunidadId null)', async () => {
const monday = mondayMock()
const funnel = funnelMock({ fallaAsegurar: true })
const r = await crearLead(monday, datosBase, funnel)
expect(r).toEqual({ id: 'lead-nuevo-1', oportunidadId: null })
expect(monday.leadsCreados).toHaveLength(1)
})
it('duplicado por teléfono: no crea lead ni oportunidad de Afluencia', async () => {
const existente: LeadMonday = {
id: 'lead-viejo-9',
nombre: 'Lead Prueba',
createdAt: '',
updatedAt: '',
estado: 'NUEVO',
origen: '',
telefono: '4771234567',
email: '',
vendedorIds: [],
}
it('duplicado por teléfono contra Oportunidades: no crea nada y devuelve el item existente', async () => {
const existente = oportunidadMonday({ id: 'op-vieja-9', telefono: '4771234567' })
const monday = mondayMock({ duplicado: existente })
const funnel = funnelMock()
const r = await crearLead(monday, datosBase, funnel)
const r = await crearLead(monday, datosBase)
expect(r).toEqual({ duplicado: existente })
expect(monday.leadsCreados).toEqual([])
expect(funnel.asegurados).toEqual([])
expect(monday.oportunidadesCreadas).toEqual([])
expect(monday.movimientosGrupo).toEqual([])
})
it('rechaza un desarrollo que no sea label real de la columna Desarrollo', async () => {
it('un fallo al mover de grupo no rompe la captura (la oportunidad ya quedó)', async () => {
const monday = mondayMock()
vi.spyOn(monday, 'moverItemAGrupo').mockRejectedValueOnce(new Error('Monday caído'))
const espia = vi.spyOn(console, 'error').mockImplementation(() => {})
const r = await crearLead(monday, datosBase)
expect(r).toEqual({ id: 'op-nueva-1', oportunidadId: 'op-nueva-1' })
espia.mockRestore()
})
it('rechaza un desarrollo que no sea label real de la columna Desarrollo (board Oportunidades)', async () => {
const monday = mondayMock()
await expect(crearLead(monday, { ...datosBase, desarrollo: 'MONTEBELLO' })).rejects.toThrow(
/Desarrollo inválido/,
)
expect(monday.leadsCreados).toEqual([])
expect(monday.oportunidadesCreadas).toEqual([])
})
it('rechaza un origen que no sea label real de la columna Origen', async () => {
it('rechaza un origen que no sea label real del dropdown Origen del Lead', async () => {
const monday = mondayMock()
await expect(crearLead(monday, { ...datosBase, origen: 'Volanteo' })).rejects.toThrow(/Origen inválido/)
expect(monday.leadsCreados).toEqual([])
expect(monday.oportunidadesCreadas).toEqual([])
})
})
describe('cambiarEstadoLead (POST /api/leads/:itemId/estado)', () => {
it('la whitelist solo permite las transiciones del vendedor', () => {
expect(ESTADOS_LEAD_PERMITIDOS).toEqual(['CONTACTADO', 'Intento de contacto', 'AGENDA CITA', 'CALIFICADO'])
// DESCARTADO tiene su propio endpoint (lleva motivo); NUEVO no es acción
// Perdido tiene su propio endpoint (lleva motivo)
expect(ESTADOS_LEAD_PERMITIDOS).not.toContain('DESCARTADO')
expect(ESTADOS_LEAD_PERMITIDOS).not.toContain('NUEVO')
})
it('cambia solo la columna Estado en el board Leads (444 en tests)', async () => {
it('escribe la columna Estado en Oportunidades (555 en tests) con el label real del board', async () => {
const monday = mondayMock()
await cambiarEstadoLead(monday, 'lead-1', 'CONTACTADO')
expect(monday.cambios).toEqual([{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'CONTACTADO' }])
await cambiarEstadoLead(monday, 'op-1', 'CONTACTADO')
expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Contactado' }])
})
it('AGENDA CITA con fecha también setea la columna Fecha', async () => {
it('AGENDA CITA se traduce al label "Agendo Cita" y con fecha también setea la columna Fecha', async () => {
const monday = mondayMock()
await cambiarEstadoLead(monday, 'lead-1', 'AGENDA CITA', '2026-09-01')
await cambiarEstadoLead(monday, 'op-1', 'AGENDA CITA', '2026-09-01')
expect(monday.cambios).toEqual([
{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'AGENDA CITA' },
{ boardId: '444', itemId: 'lead-1', columna: 'Fecha', valor: '2026-09-01' },
{ boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Agendo Cita' },
{ boardId: '555', itemId: 'op-1', columna: 'Fecha', valor: '2026-09-01' },
])
})
it('AGENDA CITA sin fecha no toca la columna Fecha', async () => {
const monday = mondayMock()
await cambiarEstadoLead(monday, 'lead-1', 'AGENDA CITA')
await cambiarEstadoLead(monday, 'op-1', 'AGENDA CITA')
expect(monday.cambios).toHaveLength(1)
})
it('AGENDA CITA mueve la oportunidad del lead a "Agendo Cita"', async () => {
it('CALIFICADO se escribe como "Calificado" (label real del board)', async () => {
const monday = mondayMock()
const funnel = funnelMock()
await cambiarEstadoLead(monday, 'lead-1', 'AGENDA CITA', '2026-09-01', funnel)
expect(funnel.movimientos).toEqual([{ leadId: 'lead-1', etapa: 'Agendo Cita' }])
})
it('otros estados NO mueven la oportunidad del lead', async () => {
const monday = mondayMock()
const funnel = funnelMock()
await cambiarEstadoLead(monday, 'lead-1', 'CONTACTADO', undefined, funnel)
await cambiarEstadoLead(monday, 'lead-1', 'CALIFICADO', undefined, funnel)
expect(funnel.movimientos).toEqual([])
})
it('un fallo del funnel en AGENDA CITA no rompe el cambio de estado', async () => {
const monday = mondayMock()
const funnel = funnelMock()
vi.spyOn(funnel, 'moverOportunidadDeLead').mockRejectedValueOnce(new Error('Monday caído'))
await expect(cambiarEstadoLead(monday, 'lead-1', 'AGENDA CITA', undefined, funnel)).resolves.toBeUndefined()
expect(monday.cambios).toEqual([{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'AGENDA CITA' }])
await cambiarEstadoLead(monday, 'op-1', 'CALIFICADO')
expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Calificado' }])
})
})
describe('descartarLead (POST /api/leads/:itemId/descartar)', () => {
it('Estado DESCARTADO + Notas + registro en Motivos No Compra', async () => {
it('Estado Perdido + Motivo No Compra + COMENTARIOS + registro en Motivos No Compra', async () => {
const monday = mondayMock()
const motivoItemId = await descartarLead(monday, 'lead-1', 'Precio', 'Se le hizo caro')
const motivoItemId = await descartarLead(monday, 'op-1', 'Precio', 'Se le hizo caro')
expect(motivoItemId).toBe('motivo-item-1')
expect(monday.cambios).toEqual([
{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'DESCARTADO' },
{ boardId: '444', itemId: 'lead-1', columna: 'Notas', valor: 'Se le hizo caro' },
{ boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Perdido' },
{ boardId: '555', itemId: 'op-1', columna: 'COMENTARIOS', valor: 'Se le hizo caro' },
{ boardId: '555', itemId: 'op-1', columna: 'Motivo No Compra', valor: 'Precio' },
])
expect(monday.motivos).toEqual([
{ nombre: 'Precio — Lead Prueba', categoria: 'Precio', descripcion: 'Se le hizo caro' },
])
})
it('sin comentario no escribe Notas', async () => {
it('sin comentario no escribe COMENTARIOS', async () => {
const monday = mondayMock()
await descartarLead(monday, 'lead-1', 'Timing')
expect(monday.cambios).toEqual([{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'DESCARTADO' }])
await descartarLead(monday, 'op-1', 'Timing')
expect(monday.cambios).toEqual([
{ boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Perdido' },
{ boardId: '555', itemId: 'op-1', columna: 'Motivo No Compra', valor: 'Timing' },
])
expect(monday.motivos[0]).toEqual({ nombre: 'Timing — Lead Prueba', categoria: 'Timing' })
})
it('propaga el error si Monday falla (el error handler responde 5xx, no truena el server)', async () => {
const monday = mondayMock()
vi.spyOn(monday, 'cambiarValorColumna').mockRejectedValueOnce(new Error('Monday caído'))
await expect(descartarLead(monday, 'lead-1', 'Cliente')).rejects.toThrow('Monday caído')
await expect(descartarLead(monday, 'op-1', 'Cliente')).rejects.toThrow('Monday caído')
expect(monday.motivos).toEqual([])
})
it('con funnel: la oportunidad pasa a "Perdido" y hereda el motivo en el dropdown', async () => {
const monday = mondayMock()
const funnel = funnelMock({ oportunidadId: 'op-9' })
await descartarLead(monday, 'lead-1', 'Precio', undefined, funnel)
expect(monday.cambios).toContainEqual({ boardId: '555', itemId: 'op-9', columna: 'Estado', valor: 'Perdido' })
expect(monday.cambios).toContainEqual({
boardId: '555',
itemId: 'op-9',
columna: 'Motivo No Compra',
valor: 'Precio',
})
})
it('si el motivo no es label del dropdown, solo mueve a Perdido (sin dropdown)', async () => {
const monday = mondayMock({ labelsMotivo: ['Otro'] })
const funnel = funnelMock({ oportunidadId: 'op-9' })
await descartarLead(monday, 'lead-1', 'Precio', undefined, funnel)
expect(monday.cambios).toContainEqual({ boardId: '555', itemId: 'op-9', columna: 'Estado', valor: 'Perdido' })
await descartarLead(monday, 'op-1', 'Precio')
expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Perdido' }])
expect(monday.cambios.find((c) => c.columna === 'Motivo No Compra')).toBeUndefined()
})
it('sin mapping lead ↔ oportunidad no toca el tablero Oportunidades', async () => {
const monday = mondayMock()
const funnel = funnelMock({ oportunidadId: null })
const motivoItemId = await descartarLead(monday, 'lead-1', 'Timing', undefined, funnel)
expect(motivoItemId).toBe('motivo-item-1')
expect(monday.cambios.filter((c) => c.boardId === '555')).toEqual([])
})
it('un fallo del funnel no rompe el descarte (DESCARTADO + motivo ya quedaron)', async () => {
const monday = mondayMock()
const funnel = funnelMock({ oportunidadId: 'op-9' })
vi.spyOn(funnel, 'oportunidadDeLead').mockRejectedValueOnce(new Error('Monday caído'))
const motivoItemId = await descartarLead(monday, 'lead-1', 'Precio', undefined, funnel)
expect(motivoItemId).toBe('motivo-item-1')
expect(monday.cambios).toContainEqual({ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'DESCARTADO' })
expect(monday.motivos).toHaveLength(1) // el registro en el catálogo se crea igual
})
})
describe('crearLeadEndpoint (POST /api/leads)', () => {
beforeEach(() => {
vi.clearAllMocks()
// El singleton Monday responde como si creara el lead; el funnel liga la oportunidad
vi.spyOn(mondayService, 'buscarLeadPorTelefono').mockResolvedValue(null)
vi.spyOn(mondayService, 'crearLead').mockResolvedValue('lead-nuevo-9')
vi.spyOn(funnelService, 'asegurarOportunidadAfluencia').mockResolvedValue('op-9')
// El singleton Monday responde como si creara la afluencia en Oportunidades
vi.spyOn(mondayService, 'buscarOportunidadPorTelefono').mockResolvedValue(null)
vi.spyOn(mondayService, 'crearOportunidadAfluencia').mockResolvedValue('op-nueva-9')
// moverAGrupoSegunEstado tras la creación (mocks de sus dependencias)
vi.spyOn(mondayService, 'columnaIdPorTitulo').mockResolvedValue('col_estado')
vi.spyOn(mondayService, 'getItemConGrupo').mockResolvedValue({
id: 'op-nueva-9',
name: 'Lead Prueba',
groupId: 'group_top',
column_values: [{ id: 'col_estado', text: 'Afluencia', value: '{"index":1}' }],
})
vi.spyOn(mondayService, 'moverItemAGrupo').mockResolvedValue(undefined)
vi.spyOn(automatizacionesService, 'asignarACapturista').mockResolvedValue(undefined)
// Default: el capturista NO tiene Monday vinculado → asignación round-robin
prismaMock.usuario.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.create.mockResolvedValue({})
prismaMock.leadOportunidad.create.mockResolvedValue({})
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({})
})
@@ -355,27 +329,30 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
return { res: { json, status } as unknown as Response, status, json }
}
it('201: registra la captura local con el usuario del token', async () => {
prismaMock.leadCaptura.create.mockResolvedValue({})
it('201: registra la captura local y el auto-mapping con el usuario del token', async () => {
const { res, status, json } = resFake()
await crearLeadEndpoint(reqFake(), res)
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: {
leadId: 'lead-nuevo-9',
oportunidadId: 'op-9',
leadId: 'op-nueva-9',
oportunidadId: 'op-nueva-9',
usuarioId: 'u-1',
nombreLead: 'Lead Prueba',
telefono: '4771234567',
},
})
// Auto-mapping LeadOportunidad (leadId = oportunidadId): el alta de
// clientes encuentra así la oportunidad a mover a 'Aparto'
expect(prismaMock.leadOportunidad.create).toHaveBeenCalledWith({
data: { leadId: 'op-nueva-9', oportunidadId: 'op-nueva-9' },
})
expect(status).toHaveBeenCalledWith(201)
expect(json).toHaveBeenCalledWith({ id: 'lead-nuevo-9', estado: 'NUEVO', oportunidadId: 'op-9', asignacion: 'round-robin' })
expect(json).toHaveBeenCalledWith({ id: 'op-nueva-9', estado: 'Afluencia', oportunidadId: 'op-nueva-9', asignacion: 'round-robin' })
})
it('con Monday vinculado, el lead queda asignado al capturista (no al round-robin)', async () => {
prismaMock.leadCaptura.create.mockResolvedValue({})
it('con Monday vinculado, la afluencia queda asignada al capturista (columna Asesor)', async () => {
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 })
const { res, status, json } = resFake()
@@ -383,15 +360,14 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
// Bandera anti-carrera ANTES de asignar + asignación al capturista
expect(prismaMock.automatizacionState.upsert).toHaveBeenCalledWith(
expect.objectContaining({ where: { clave: 'rr:skip:lead-nuevo-9' }, create: { clave: 'rr:skip:lead-nuevo-9', valor: 'capturista' } }),
expect.objectContaining({ where: { clave: 'rr:skip:op-nueva-9' }, create: { clave: 'rr:skip:op-nueva-9', valor: 'capturista' } }),
)
expect(automatizacionesService.asignarACapturista).toHaveBeenCalledWith('lead-nuevo-9', 'Lead Prueba', 12345)
expect(automatizacionesService.asignarACapturista).toHaveBeenCalledWith('op-nueva-9', 'Lead Prueba', 12345)
expect(status).toHaveBeenCalledWith(201)
expect(json).toHaveBeenCalledWith({ id: 'lead-nuevo-9', estado: 'NUEVO', oportunidadId: 'op-9', asignacion: 'capturista' })
expect(json).toHaveBeenCalledWith({ id: 'op-nueva-9', estado: 'Afluencia', oportunidadId: 'op-nueva-9', asignacion: 'capturista' })
})
it('un fallo al asignar al capturista no rompe la captura (cae al round-robin)', async () => {
prismaMock.leadCaptura.create.mockResolvedValue({})
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 })
vi.spyOn(automatizacionesService, 'asignarACapturista').mockRejectedValue(new Error('Monday caído'))
const espia = vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -400,7 +376,7 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
await crearLeadEndpoint(reqFake(), res)
expect(status).toHaveBeenCalledWith(201)
expect(json).toHaveBeenCalledWith({ id: 'lead-nuevo-9', estado: 'NUEVO', oportunidadId: 'op-9', asignacion: 'round-robin' })
expect(json).toHaveBeenCalledWith({ id: 'op-nueva-9', estado: 'Afluencia', oportunidadId: 'op-nueva-9', asignacion: 'round-robin' })
espia.mockRestore()
})
@@ -410,10 +386,12 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
await crearLeadEndpoint(reqFake({ usuarioToken: undefined }), res)
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
// El auto-mapping sí se escribe (no depende del usuario)
expect(prismaMock.leadOportunidad.create).toHaveBeenCalled()
expect(status).toHaveBeenCalledWith(201)
})
it('un fallo del registro local no rompe la respuesta (el lead ya quedó en Monday)', async () => {
it('un fallo del registro local no rompe la respuesta (la afluencia ya quedó en Monday)', async () => {
prismaMock.leadCaptura.create.mockRejectedValue(new Error('SQLite bloqueado'))
const espia = vi.spyOn(console, 'error').mockImplementation(() => {})
const { res, status } = resFake()
@@ -425,7 +403,7 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
})
})
describe('buscarLeads (GET /api/leads/buscar)', () => {
describe('buscarLeads (GET /api/leads/buscar) — búsqueda dual Leads + Oportunidades', () => {
const lead = (overrides: Partial<LeadMonday>): LeadMonday => ({
id: 'lead-1',
nombre: 'Lead Prueba',
@@ -439,31 +417,47 @@ describe('buscarLeads (GET /api/leads/buscar)', () => {
...overrides,
})
it('matchea por nombre, teléfono y email (case-insensitive)', () => {
it('matchea por nombre, teléfono y email (case-insensitive) en ambos tableros, con su fuente', () => {
const leads = [
lead({ id: 'a', nombre: 'MARÍA FERNANDA LÓPEZ' }),
lead({ id: 'b', telefono: '477 123 4567' }),
lead({ id: 'c', email: 'Cliente@Correo.com' }),
lead({ id: 'd', nombre: 'Otro', telefono: '333', email: '' }),
]
expect(buscarLeads(leads, 'fernanda').map((l) => l.id)).toEqual(['a'])
expect(buscarLeads(leads, '123').map((l) => l.id)).toEqual(['b'])
expect(buscarLeads(leads, 'cliente@').map((l) => l.id)).toEqual(['c'])
const ops = [
oportunidadMonday({ id: 'op-c', email: 'Cliente@Correo.com' }),
oportunidadMonday({ id: 'op-x', nombre: 'Nadie' }),
]
expect(buscarLeads(leads, ops, 'fernanda').map((r) => r.id)).toEqual(['a'])
expect(buscarLeads(leads, ops, '123').map((r) => r.id)).toEqual(['b'])
const porEmail = buscarLeads(leads, ops, 'cliente@')
expect(porEmail.map((r) => r.id)).toEqual(['op-c'])
expect(porEmail[0]?.fuente).toBe('oportunidad')
expect(buscarLeads(leads, ops, 'fernanda')[0]?.fuente).toBe('lead')
})
it('ignora acentos tanto en el lead como en la búsqueda', () => {
it('ignora acentos tanto en el item como en la búsqueda', () => {
const leads = [lead({ id: 'a', nombre: 'MARÍA FERNANDA LÓPEZ', email: 'andres@correo.com' })]
expect(buscarLeads(leads, 'maria')).toHaveLength(1)
expect(buscarLeads(leads, 'andrés')).toHaveLength(1)
expect(buscarLeads(leads, [], 'maria')).toHaveLength(1)
expect(buscarLeads(leads, [], 'andrés')).toHaveLength(1)
})
it('ordena por updatedAt descendente y limita a 15', () => {
const leads = Array.from({ length: 20 }, (_, i) =>
it('ordena por updatedAt descendente (mezclando tableros) y limita a 15', () => {
const leads = Array.from({ length: 10 }, (_, i) =>
lead({ id: `l-${i}`, nombre: `Cliente Común ${i}`, updatedAt: `2026-08-${String(i + 1).padStart(2, '0')}T00:00:00Z` }),
)
const r = buscarLeads(leads, 'común')
const ops = Array.from({ length: 10 }, (_, i) =>
oportunidadMonday({ id: `op-${i}`, nombre: `Cliente Común ${i}`, updatedAt: `2026-08-${String(i + 11).padStart(2, '0')}T00:00:00Z` }),
)
const r = buscarLeads(leads, ops, 'común')
expect(r).toHaveLength(15)
expect(r[0]?.id).toBe('l-19') // el más reciente primero
expect(r[0]?.id).toBe('op-9') // el más reciente primero (de Oportunidades)
expect(r[14]?.id).toBe('l-5') // se corta en 15 entre ambos tableros (10 ops + 5 leads)
})
it('la oportunidad expone su etapa como estado y su origen del dropdown', () => {
const ops = [oportunidadMonday({ id: 'op-1', nombre: 'Cliente Común', etapa: 'Agendo Cita', origen: 'WhatsApp' })]
const r = buscarLeads([], ops, 'común')
expect(r[0]).toMatchObject({ id: 'op-1', estado: 'Agendo Cita', origen: 'WhatsApp', fuente: 'oportunidad' })
})
it('el endpoint responde 400 con q de menos de 2 caracteres', async () => {

View File

@@ -1,22 +1,35 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { config } from '../config/index.js'
import { mondayService, type LeadMonday, type MondayService } from '../services/monday.service.js'
import { funnelService, type FunnelService } from '../services/funnel.service.js'
import { mondayService, type LeadMonday, type MondayService, type OportunidadMonday } from '../services/monday.service.js'
import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
import { automatizacionesService, CLAVE_RR_SKIP, prismaStateStore } from '../services/automatizaciones.service.js'
import { prisma } from '../services/prisma.js'
import { MOTIVOS_NO_COMPRA } from './oportunidades.controller.js'
import { AppError } from '../utils/errors.js'
/**
* CRM ligero sobre el tablero Leads: captura rápida y acciones del vendedor.
* Captura y acciones del CRM ligero sobre el tablero Oportunidades: la
* afluencia ES la oportunidad en etapa 'Afluencia' (UN solo registro con todo
* el perfil). El tablero Leads queda como archivo histórico: solo se consulta
* en la búsqueda dual del picker del alta de clientes.
* Los labels de estado/origen son los reales del board (verificados contra
* Monday); la whitelist evita escribir labels que el status no tenga dados de alta.
* Monday); la whitelist evita escribir labels que el status no tenga dados de
* alta, y cada valor de la whitelist se traduce al label real del board.
*/
// Transiciones que el vendedor puede hacer desde la app Mi día
export const ESTADOS_LEAD_PERMITIDOS = ['CONTACTADO', 'Intento de contacto', 'AGENDA CITA', 'CALIFICADO'] as const
// Traducción de la whitelist (valores que mandan los frontends) a los labels
// reales de la columna status 'Estado' del board Oportunidades
const LABEL_ESTADO_OPORTUNIDAD: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], string> = {
CONTACTADO: 'Contactado',
'Intento de contacto': 'Intento de contacto',
'AGENDA CITA': 'Agendo Cita',
CALIFICADO: 'Calificado',
}
const crearLeadSchema = z.object({
nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
telefono: z.string().trim().min(1).optional(),
@@ -52,7 +65,7 @@ const descartarSchema = z.object({
comentario: z.string().trim().min(1).optional(),
})
// ---------- Búsqueda de leads (prellenado del alta de clientes) ----------
// ---------- Búsqueda dual (prellenado del alta de clientes) ----------
const LIMITE_BUSQUEDA_LEADS = 15
@@ -63,70 +76,109 @@ const sinAcentos = (s: string): string =>
.replace(/[̀-ͯ]/g, '') // diacríticos U+0300–U+036F
.toLowerCase()
/** Resultado del picker del alta: conviven el archivo histórico (Leads) y el modelo nuevo (Oportunidades) */
export interface ResultadoBusquedaLead {
id: string
nombre: string
telefono: string
email: string
estado: string
origen: string
fuente: 'lead' | 'oportunidad'
}
/**
* Filtra leads por q en nombre/teléfono/email (case-insensitive, sin acentos),
* ordena por actualización reciente y limita a 15 resultados.
* Filtra por q en nombre/teléfono/email (case-insensitive, sin acentos) en
* AMBOS tableros — Leads (histórico) y Oportunidades (modelo nuevo) —, ordena
* por actualización reciente y limita a 15 resultados combinados.
*/
export function buscarLeads(leads: LeadMonday[], q: string): LeadMonday[] {
export function buscarLeads(leads: LeadMonday[], oportunidades: OportunidadMonday[], q: string): ResultadoBusquedaLead[] {
const needle = sinAcentos(q)
return leads
.filter((l) => [l.nombre, l.telefono, l.email].some((campo) => sinAcentos(campo).includes(needle)))
const candidatos = [
...leads.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' as const,
})),
...oportunidades.map((o) => ({
id: o.id,
nombre: o.nombre,
telefono: o.telefono,
email: o.email,
estado: o.etapa,
origen: o.origen,
updatedAt: o.updatedAt,
fuente: 'oportunidad' as const,
})),
]
return candidatos
.filter((c) => [c.nombre, c.telefono, c.email].some((campo) => sinAcentos(campo).includes(needle)))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.slice(0, LIMITE_BUSQUEDA_LEADS)
.map(({ updatedAt: _updatedAt, ...resto }) => resto)
}
type MondayLeads = Pick<
MondayService,
| 'cambiarValorColumna'
| 'crearLead'
| 'crearOportunidadAfluencia'
| 'crearMotivoNoCompra'
| 'buscarLeadPorTelefono'
| 'buscarOportunidadPorTelefono'
| 'labelsDeColumna'
| 'obtenerNombreItem'
| 'columnaIdPorTitulo'
| 'getItemConGrupo'
| 'moverItemAGrupo'
>
export type ResultadoCrearLead = { id: string; oportunidadId: string | null } | { duplicado: LeadMonday }
export type ResultadoCrearLead = { id: string; oportunidadId: string } | { duplicado: OportunidadMonday }
/**
* Crea un lead en Monday (Estado NUEVO). Si viene teléfono y ya existe un lead
* con ese número, no crea nada y devuelve { duplicado } (el endpoint → 409).
* El origen se valida contra los labels reales de la columna del board.
* Con `funnel`, el lead entra además al tablero Oportunidades en 'Afluencia';
* un fallo de Monday en la oportunidad solo se loguea (el lead ya quedó).
* Crea la afluencia en Monday: UN item en el tablero Oportunidades con Estado
* 'Afluencia' y todo el perfil capturado. Si viene teléfono y ya existe una
* oportunidad con ese número, no crea nada y devuelve { duplicado } (el
* endpoint → 409). Origen (dropdown 'Origen del Lead') y desarrollo (status
* 'Desarrollo') se validan contra los labels reales del board.
* Tras crearla se mueve al grupo que corresponde a su etapa (Monday crea el
* item en el grupo top del tablero, hoy "Perdidos", y el webhook de change no
* dispara en la creación); un fallo ahí solo se loguea.
*/
export async function crearLead(
monday: MondayLeads,
datos: z.infer<typeof crearLeadSchema>,
funnel?: Pick<FunnelService, 'asegurarOportunidadAfluencia'>,
): Promise<ResultadoCrearLead> {
const boardLeads = config.monday.boards.leads
const boardOportunidades = config.monday.boards.oportunidades
if (datos.telefono !== undefined) {
const existente = await monday.buscarLeadPorTelefono(datos.telefono)
const existente = await monday.buscarOportunidadPorTelefono(datos.telefono)
if (existente) return { duplicado: existente }
}
if (datos.origen !== undefined) {
const labels = await monday.labelsDeColumna(boardLeads, 'Origen')
const labels = await monday.labelsDeColumna(boardOportunidades, 'Origen del Lead')
if (!labels.includes(datos.origen)) {
throw new AppError(400, `Origen inválido: "${datos.origen}". Labels válidos: ${labels.join(', ')}`)
}
}
if (datos.desarrollo !== undefined) {
const labels = await monday.labelsDeColumna(boardLeads, 'Desarrollo')
const labels = await monday.labelsDeColumna(boardOportunidades, 'Desarrollo')
if (!labels.includes(datos.desarrollo)) {
throw new AppError(400, `Desarrollo inválido: "${datos.desarrollo}". Labels válidos: ${labels.join(', ')}`)
}
}
const id = await monday.crearLead({
const id = await monday.crearOportunidadAfluencia({
nombre: datos.nombre,
telefono: datos.telefono,
email: datos.email,
origenLabel: datos.origen,
desarrolloLabel: datos.desarrollo,
notas: datos.notas,
origenLabel: datos.origen,
modeloLabel: datos.modelo,
medioContacto: datos.medioContacto,
edad: datos.edad,
@@ -138,98 +190,67 @@ export async function crearLead(
tipoEconomiaLabel: datos.tipoEconomia,
calificaLabel: datos.califica,
loQuierePara: datos.loQuierePara,
notas: datos.notas,
})
let oportunidadId: string | null = null
if (funnel !== undefined) {
try {
oportunidadId = await funnel.asegurarOportunidadAfluencia({
id,
nombre: datos.nombre,
telefono: datos.telefono,
email: datos.email,
desarrolloLabel: datos.desarrollo,
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo crear la oportunidad de Afluencia del lead ${id}:`, err)
}
try {
await moverAGrupoSegunEstado(monday, boardOportunidades, id)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover al grupo correcto la oportunidad ${id}:`, err)
}
return { id, oportunidadId }
return { id, oportunidadId: id }
}
/** Cambia el estado del lead (whitelist) y, en AGENDA CITA, puede setear la Fecha */
/** Cambia el estado de la afluencia (whitelist → label real del board); en AGENDA CITA puede setear la Fecha */
export async function cambiarEstadoLead(
monday: MondayLeads,
itemId: string,
estado: (typeof ESTADOS_LEAD_PERMITIDOS)[number],
fecha?: string,
funnel?: Pick<FunnelService, 'moverOportunidadDeLead'>,
): Promise<void> {
const boardLeads = config.monday.boards.leads
await monday.cambiarValorColumna(boardLeads, itemId, 'Estado', estado)
const boardOportunidades = config.monday.boards.oportunidades
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', LABEL_ESTADO_OPORTUNIDAD[estado])
if (estado === 'AGENDA CITA' && fecha !== undefined) {
await monday.cambiarValorColumna(boardLeads, itemId, 'Fecha', fecha)
}
// La oportunidad del lead avanza a 'Agendo Cita'; un fallo no rompe el cambio
if (estado === 'AGENDA CITA' && funnel !== undefined) {
try {
await funnel.moverOportunidadDeLead(itemId, 'Agendo Cita')
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover a 'Agendo Cita' la oportunidad del lead ${itemId}:`, err)
}
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Fecha', fecha)
}
}
/** Estado DESCARTADO + registro en el catálogo Motivos No Compra (+ Notas opcional) */
/**
* Estado 'Perdido' + dropdown Motivo No Compra (label ya sembrado; si no
* existiera se omite) + registro en el catálogo Motivos No Compra. El
* comentario opcional va a la columna long_text COMENTARIOS.
*/
export async function descartarLead(
monday: MondayLeads,
itemId: string,
motivo: string,
comentario?: string,
funnel?: Pick<FunnelService, 'oportunidadDeLead'>,
): Promise<string> {
const boardLeads = config.monday.boards.leads
const boardOportunidades = config.monday.boards.oportunidades
const nombreItem = await monday.obtenerNombreItem(itemId)
await monday.cambiarValorColumna(boardLeads, itemId, 'Estado', 'DESCARTADO')
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', 'Perdido')
if (comentario !== undefined) {
await monday.cambiarValorColumna(boardLeads, itemId, 'Notas', comentario)
await monday.cambiarValorColumna(boardOportunidades, itemId, 'COMENTARIOS', comentario)
}
const motivoItemId = await monday.crearMotivoNoCompra({
const labels = await monday.labelsDeColumna(boardOportunidades, 'Motivo No Compra')
if (labels.includes(motivo)) {
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Motivo No Compra', motivo)
} else {
// eslint-disable-next-line no-console
console.log(`El motivo "${motivo}" no es label del dropdown 'Motivo No Compra'; se omite`)
}
return monday.crearMotivoNoCompra({
nombre: `${motivo} — ${nombreItem}`,
categoria: motivo,
...(comentario !== undefined ? { descripcion: comentario } : {}),
})
// La oportunidad del lead pasa a 'Perdido' y hereda el motivo en su dropdown
// (solo si el label existe en la columna). Fallos → solo log.
if (funnel !== undefined) {
try {
const oportunidadId = await funnel.oportunidadDeLead(itemId)
if (oportunidadId !== null) {
const boardOportunidades = config.monday.boards.oportunidades
await monday.cambiarValorColumna(boardOportunidades, oportunidadId, 'Estado', 'Perdido')
const labels = await monday.labelsDeColumna(boardOportunidades, 'Motivo No Compra')
if (labels.includes(motivo)) {
await monday.cambiarValorColumna(boardOportunidades, oportunidadId, 'Motivo No Compra', motivo)
} else {
// eslint-disable-next-line no-console
console.log(`El motivo "${motivo}" no es label del dropdown 'Motivo No Compra'; se omite`)
}
}
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover a 'Perdido' la oportunidad del lead ${itemId}:`, err)
}
}
return motivoItemId
}
/** POST /api/leads { nombre, telefono?, email?, origen?, notas? } */
export async function crearLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = crearLeadSchema.parse(req.body)
const resultado = await crearLead(mondayService, body, funnelService)
const resultado = await crearLead(mondayService, body)
if ('duplicado' in resultado) {
res.status(409).json({
error: 'Ya existe un lead con ese teléfono',
@@ -239,8 +260,9 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
return
}
// Registro local del capturista para "Mis capturas" del panel Mi día (Monday
// no guarda quién dio de alta el lead). Un fallo aquí no rompe la respuesta:
// el lead ya quedó creado en Monday.
// no guarda quién dio de alta el lead). leadId = el id de la oportunidad
// (modelo nuevo: la afluencia ES la oportunidad). Un fallo aquí no rompe la
// respuesta: la afluencia ya quedó creada en Monday.
if (req.usuarioToken?.sub) {
try {
await prisma.leadCaptura.create({
@@ -257,10 +279,22 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
console.error(`No se pudo registrar la captura del lead ${resultado.id}:`, err)
}
}
// Asignación: si el capturista tiene su usuario Monday vinculado, el lead
// queda asignado a él. La bandera rr:skip se prende ANTES de asignar para
// que el webhook create_item no lo reparta por round-robin mientras Monday
// propaga la escritura (si la asignación falla, se apaga y cae al reparto).
// Auto-mapping leadId = oportunidadId en LeadOportunidad: el alta de
// clientes (moverAApartoConDedup) encuentra así la oportunidad a mover a
// 'Aparto' tanto en ligas históricas como en el modelo nuevo.
try {
await prisma.leadOportunidad.create({
data: { leadId: resultado.id, oportunidadId: resultado.oportunidadId },
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo guardar el mapping de la oportunidad ${resultado.id}:`, err)
}
// Asignación: si el capturista tiene su usuario Monday vinculado, la
// oportunidad queda asignada a él (columna Asesor). La bandera rr:skip se
// prende ANTES de asignar para que el webhook create_item no la reparta por
// round-robin mientras Monday propaga la escritura (si la asignación falla,
// se apaga y cae al reparto).
let asignacion: 'capturista' | 'round-robin' = 'round-robin'
if (req.usuarioToken?.sub) {
try {
@@ -280,17 +314,17 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
}
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo asignar el lead ${resultado.id} al capturista:`, err)
console.error(`No se pudo asignar la afluencia ${resultado.id} al capturista:`, err)
}
}
res.status(201).json({ id: resultado.id, estado: 'NUEVO', oportunidadId: resultado.oportunidadId, asignacion })
res.status(201).json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, asignacion })
}
/** POST /api/leads/:itemId/estado { estado, fecha? } */
export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = cambiarEstadoSchema.parse(req.body)
const itemId = String(req.params['itemId'])
await cambiarEstadoLead(mondayService, itemId, body.estado, body.fecha, funnelService)
await cambiarEstadoLead(mondayService, itemId, body.estado, body.fecha)
res.json({ itemId, estado: body.estado, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
}
@@ -298,26 +332,18 @@ export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Pr
export async function descartarLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = descartarSchema.parse(req.body)
const itemId = String(req.params['itemId'])
const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario, funnelService)
res.json({ itemId, estado: 'DESCARTADO', motivo: body.motivo, motivoItemId })
const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario)
res.json({ itemId, estado: 'Perdido', motivo: body.motivo, motivoItemId })
}
/**
* GET /api/leads/buscar?q=texto — autocompletado para prellenar el alta de
* clientes desde un lead. q mínimo 2 caracteres; devuelve máximo 15.
* clientes. Busca en AMBOS tableros (Leads histórico + Oportunidades); q
* mínimo 2 caracteres; devuelve máximo 15, cada uno con su `fuente`.
*/
export async function buscarLeadsEndpoint(req: Request, res: Response): Promise<void> {
const q = String(req.query['q'] ?? '').trim()
if (q.length < 2) throw new AppError(400, 'La búsqueda requiere al menos 2 caracteres')
const encontrados = buscarLeads(await mondayService.getLeads(), q)
res.json(
encontrados.map((l) => ({
id: l.id,
nombre: l.nombre,
telefono: l.telefono,
email: l.email,
estado: l.estado,
origen: l.origen,
})),
)
const [leads, oportunidades] = await Promise.all([mondayService.getLeads(), mondayService.getOportunidades()])
res.json(buscarLeads(leads, oportunidades, q))
}

View File

@@ -4,7 +4,6 @@ import type { Request, Response } from 'express'
const mocks = vi.hoisted(() => ({
runRoundRobin: vi.fn(),
escaneoOportunidades: vi.fn(),
asegurarAfluencia: vi.fn(),
moverAGrupo: vi.fn(),
}))
@@ -15,12 +14,6 @@ vi.mock('../services/automatizaciones.service.js', () => ({
},
}))
vi.mock('../services/funnel.service.js', () => ({
funnelService: {
asegurarOportunidadAfluenciaPorLeadId: mocks.asegurarAfluencia,
},
}))
vi.mock('../services/grupos-estado.service.js', () => ({
// Leads (111) y Oportunidades (222) con mapeo; el resto de boards, sin mapeo
GRUPOS_POR_ESTADO: {
@@ -52,7 +45,6 @@ beforeEach(() => {
vi.clearAllMocks()
mocks.runRoundRobin.mockResolvedValue({ asignados: 1 })
mocks.escaneoOportunidades.mockResolvedValue({ apartadosCreados: ['x'], ventasCreadas: [], errores: [] })
mocks.asegurarAfluencia.mockResolvedValue('op-1')
mocks.moverAGrupo.mockResolvedValue({ movido: true, grupo: 'topics' })
})
@@ -70,31 +62,34 @@ describe('webhookMonday', () => {
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.runRoundRobin).not.toHaveBeenCalled()
expect(mocks.escaneoOportunidades).not.toHaveBeenCalled()
expect(mocks.asegurarAfluencia).not.toHaveBeenCalled()
})
it('create_item en Leads dispara el round-robin del item', () => {
it('create_item en Oportunidades dispara el round-robin del item', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 111, itemId: 999 } }), res)
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 222, itemId: 999 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.runRoundRobin).toHaveBeenCalledWith('999')
expect(mocks.asegurarAfluencia).toHaveBeenCalledWith('999')
})
it('create_pulse (nombre real del evento) con pulseId también dispara el round-robin', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'create_pulse', boardId: 111, pulseId: 777 } }), res)
webhookMonday(reqCon({ event: { type: 'create_pulse', boardId: 222, pulseId: 777 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.runRoundRobin).toHaveBeenCalledWith('777')
expect(mocks.asegurarAfluencia).toHaveBeenCalledWith('777')
})
it('create_item en otro board no dispara round-robin ni afluencia', () => {
it('create_item en Leads ya NO dispara nada (tablero archivo histórico)', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 111, itemId: 999 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.runRoundRobin).not.toHaveBeenCalled()
})
it('create_item en otro board no dispara round-robin', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 555, itemId: 999 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.runRoundRobin).not.toHaveBeenCalled()
expect(mocks.asegurarAfluencia).not.toHaveBeenCalled()
})
it('change_status_column_value en Oportunidades dispara el escaneo', () => {
@@ -148,7 +143,7 @@ describe('webhookMonday', () => {
it('un error del servicio no rompe la respuesta 200 (fire-and-forget)', async () => {
mocks.runRoundRobin.mockRejectedValue(new Error('monday caído'))
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 111, itemId: 1 } }), res)
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 222, itemId: 1 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
// Deja que el rechazo se procese para no ensuciar otros tests
await new Promise((r) => setImmediate(r))

View File

@@ -2,7 +2,6 @@ import type { Request, Response } from 'express'
import { z } from 'zod'
import { config } from '../config/index.js'
import { automatizacionesService } from '../services/automatizaciones.service.js'
import { funnelService } from '../services/funnel.service.js'
import { GRUPOS_POR_ESTADO, moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
import { mondayService } from '../services/monday.service.js'
@@ -46,7 +45,6 @@ export function webhookMonday(req: Request, res: Response): void {
}
const { type, boardId, itemId, pulseId } = parsed.data.event
const esBoardLeads = String(boardId) === config.monday.boards.leads
const esBoardOportunidades = String(boardId) === config.monday.boards.oportunidades
const idEvento = itemId ?? pulseId
@@ -55,7 +53,15 @@ export function webhookMonday(req: Request, res: Response): void {
// Monday registra el webhook como 'create_item' pero el payload real llega
// como 'create_pulse'; se aceptan ambos.
if ((type === 'create_item' || type === 'create_pulse') && esBoardLeads && idEvento !== undefined) {
// Item nuevo en Oportunidades → solo round-robin (la afluencia entra en
// etapa 'Afluencia' sin asesor; las del cotizador nacen en 'Cotizador' y el
// servicio las salta). El grupo NO se arregla aquí: recién creado el item aún
// no tiene Estado (moverAGrupoSegunEstado no haría nada) y el create con
// column_values no dispara el webhook de change — para items creados a mano
// en la UI, el grupo lo corrige la próxima vez que cambie una columna.
// (El tablero Leads quedó como archivo histórico: ya no tiene automatización
// de create; su webhook se desregistra en Monday.)
if ((type === 'create_item' || type === 'create_pulse') && esBoardOportunidades && idEvento !== undefined) {
const id = String(idEvento)
// Fire-and-forget: la respuesta 200 no espera a que termine la asignación
void automatizacionesService
@@ -68,18 +74,6 @@ export function webhookMonday(req: Request, res: Response): void {
// eslint-disable-next-line no-console
console.error(`[webhook monday] error en round-robin item=${id}:`, err)
})
// El lead entra también al funnel de Oportunidades (etapa 'Afluencia').
// El payload no trae nombre/teléfono: el servicio los consulta en Monday.
void funnelService
.asegurarOportunidadAfluenciaPorLeadId(id)
.then((opId) => {
// eslint-disable-next-line no-console
console.log(`[webhook monday] afluencia item=${id}: oportunidad ${opId ?? 'no creada'}`)
})
.catch((err: unknown) => {
// eslint-disable-next-line no-console
console.error(`[webhook monday] error en afluencia item=${id}:`, err)
})
}
// Cambio de etapa en Oportunidades → crear Apartado/Venta si aplica.

View File

@@ -8,13 +8,13 @@ import {
claveVentaCreada,
esEstadoTerminal,
esEtapa,
leadsInactivos,
leadsSinContactar,
oportunidadesInactivas,
oportunidadesSinContactar,
parseRrUserIds,
vendedorEnTurno,
type StateStore,
} from './automatizaciones.service.js'
import type { LeadMonday, OportunidadMonday, UsuarioMonday } from './monday.service.js'
import type { OportunidadMonday, UsuarioMonday } from './monday.service.js'
// ---------- Fixtures ----------
@@ -24,27 +24,19 @@ 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: [],
...overrides,
}
}
function oportunidad(id: string, overrides: Partial<OportunidadMonday> = {}): OportunidadMonday {
return {
id,
nombre: `Cliente ${id}`,
createdAt: '2026-08-01T09:00:00Z',
etapa: 'Cotizador',
updatedAt: '2026-08-01T10:00:00Z',
etapa: 'Afluencia',
unidadCodigo: 'U-101',
precioFinal: 2500000,
asesorIds: [],
telefono: '',
email: '',
origen: '',
...overrides,
}
}
@@ -63,8 +55,8 @@ function stateEnMemoria(): StateStore & { mapa: Map<string, string> } {
}
}
/** Monday mockeado: leads fijos, registra asignaciones, updates y actividades */
function mondayMock(leads: LeadMonday[], oportunidades: OportunidadMonday[] = []) {
/** Monday mockeado: oportunidades fijas, registra asignaciones, updates y actividades */
function mondayMock(oportunidades: OportunidadMonday[] = []) {
return {
asignaciones: [] as { itemId: string; userId: number }[],
updates: [] as { itemId: string; body: string }[],
@@ -78,13 +70,10 @@ function mondayMock(leads: LeadMonday[], 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) {
async asignarAsesorAOportunidad(itemId: string, userId: number) {
this.asignaciones.push({ itemId, userId })
},
async crearUpdateEnItem(itemId: string, body: string) {
@@ -136,12 +125,14 @@ describe('parseRrUserIds', () => {
})
})
describe('esEstadoTerminal', () => {
it('reconoce DESCARTADO sin importar mayúsculas ni espacios', () => {
expect(esEstadoTerminal('DESCARTADO')).toBe(true)
expect(esEstadoTerminal(' descartado ')).toBe(true)
expect(esEstadoTerminal('NUEVO')).toBe(false)
expect(esEstadoTerminal('CONTACTADO')).toBe(false)
describe('esEstadoTerminal (etapas terminales de Oportunidades)', () => {
it('Ganado / Perdido / Abandono son terminales, sin importar mayúsculas ni espacios', () => {
expect(esEstadoTerminal('GANADO')).toBe(true)
expect(esEstadoTerminal(' perdido ')).toBe(true)
expect(esEstadoTerminal('Abandono')).toBe(true)
expect(esEstadoTerminal('Afluencia')).toBe(false)
expect(esEstadoTerminal('Contactado')).toBe(false)
expect(esEstadoTerminal('Intento de contacto')).toBe(false)
expect(esEstadoTerminal('')).toBe(false)
})
})
@@ -160,31 +151,34 @@ describe('vendedorEnTurno (rotación round-robin)', () => {
})
})
describe('leadsInactivos', () => {
describe('oportunidadesInactivas', () => {
const ahora = new Date('2026-08-22T00:00:00Z')
it('incluye leads activos que superan el umbral de días', () => {
const leads = [
lead('1', { updatedAt: '2026-08-10T00:00:00Z' }), // 12 días → inactivo (umbral 10)
lead('2', { updatedAt: '2026-08-20T00:00:00Z' }), // 2 días → activo
lead('3', { updatedAt: '2026-07-01T00:00:00Z', estado: 'DESCARTADO' }), // terminal → excluido
it('incluye oportunidades en etapa temprana CON asesor que superan el umbral de días', () => {
const ops = [
oportunidad('1', { updatedAt: '2026-08-10T00:00:00Z', asesorIds: [101] }), // 12 días → inactiva (umbral 10)
oportunidad('2', { updatedAt: '2026-08-20T00:00:00Z', asesorIds: [101] }), // 2 días → activa
oportunidad('3', { updatedAt: '2026-07-01T00:00:00Z', asesorIds: [101], etapa: 'Perdido' }), // terminal → excluida
oportunidad('4', { updatedAt: '2026-07-01T00:00:00Z' }), // sin asesor → excluida
oportunidad('5', { updatedAt: '2026-07-01T00:00:00Z', asesorIds: [101], etapa: 'Cotizador' }), // etapa avanzada → excluida
oportunidad('6', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [101], etapa: 'Contactado' }), // 21 días → inactiva
]
const inactivos = leadsInactivos(leads, ahora, 10)
expect(inactivos.map((l) => l.id)).toEqual(['1'])
const inactivas = oportunidadesInactivas(ops, ahora, 10)
expect(inactivas.map((o) => o.id)).toEqual(['1', '6'])
})
it('un lead actualizado hace exactamente N días aún no supera el umbral', () => {
const leads = [lead('1', { updatedAt: '2026-08-12T00:00:00Z' })] // justo 10 días
expect(leadsInactivos(leads, ahora, 10)).toEqual([])
it('una oportunidad actualizada hace exactamente N días aún no supera el umbral', () => {
const ops = [oportunidad('1', { updatedAt: '2026-08-12T00:00:00Z', asesorIds: [101] })] // justo 10 días
expect(oportunidadesInactivas(ops, ahora, 10)).toEqual([])
})
it('ignora fechas inválidas', () => {
expect(leadsInactivos([lead('1', { updatedAt: 'no-es-fecha' })], ahora, 10)).toEqual([])
expect(oportunidadesInactivas([oportunidad('1', { updatedAt: 'no-es-fecha', asesorIds: [101] })], ahora, 10)).toEqual([])
})
})
describe('claveRecordatorio (dedup por día)', () => {
it('una clave por lead por día', () => {
it('una clave por oportunidad por día', () => {
expect(claveRecordatorio('123', new Date('2026-08-22T09:00:00Z'))).toBe('recordatorio:123:2026-08-22')
expect(claveRecordatorio('123', new Date('2026-08-22T23:59:00Z'))).toBe('recordatorio:123:2026-08-22')
expect(claveRecordatorio('123', new Date('2026-08-23T00:00:00Z'))).toBe('recordatorio:123:2026-08-23')
@@ -194,8 +188,8 @@ describe('claveRecordatorio (dedup por día)', () => {
// ---------- Servicio (monday mockeado, sin red) ----------
describe('AutomatizacionesService.runRoundRobin', () => {
it('asigna vendedores en rotación a leads sin persona y persiste el índice', async () => {
const monday = mondayMock([lead('1'), lead('2'), lead('3'), lead('4')])
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')])
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
@@ -204,8 +198,8 @@ describe('AutomatizacionesService.runRoundRobin', () => {
expect(monday.asignaciones.map((a) => a.userId)).toEqual([101, 102, 103, 101])
expect(state.mapa.get('rr:indice')).toBe('1') // 4 asignaciones % 3 vendedores
// Segunda pasada con un lead nuevo: continúa donde quedó (no reinicia en 0)
const monday2 = mondayMock([lead('5')])
// Segunda pasada con una afluencia nueva: continúa donde quedó (no reinicia en 0)
const monday2 = mondayMock([oportunidad('5')])
const svc2 = new AutomatizacionesService(monday2, state)
const r2 = await svc2.runRoundRobin()
expect(r2.asignados).toBe(1)
@@ -213,22 +207,28 @@ describe('AutomatizacionesService.runRoundRobin', () => {
expect(state.mapa.get('rr:indice')).toBe('2')
})
it('omite leads con vendedor ya asignado o en estado terminal', async () => {
it('solo reparte etapa Afluencia: Cotizador/Contactado NO entran al reparto', async () => {
const monday = mondayMock([
lead('1'),
lead('2', { vendedorIds: [101] }), // ya asignado
lead('3', { estado: 'DESCARTADO' }), // terminal
oportunidad('1'),
oportunidad('2', { asesorIds: [101] }), // ya asignada
oportunidad('3', { etapa: 'Cotizador' }), // creada por el cotizador: no entra
oportunidad('4', { etapa: 'Contactado' }), // ya en seguimiento: no entra
oportunidad('5', { etapa: 'Perdido' }), // terminal: no entra
oportunidad('6', { etapa: 'afluencia' }), // case-insensitive: sí entra
])
const svc = crearServicio(monday, stateEnMemoria())
const r = await svc.runRoundRobin()
expect(r.asignados).toBe(1)
expect(monday.asignaciones).toEqual([{ itemId: '1', userId: 101 }])
expect(r.asignados).toBe(2)
expect(monday.asignaciones).toEqual([
{ itemId: '1', userId: 101 },
{ itemId: '6', userId: 102 },
])
})
it('omite leads con bandera rr:skip=capturista (los asigna su capturista)', async () => {
const monday = mondayMock([lead('1'), lead('2')])
it('omite afluencias con bandera rr:skip=capturista (las asigna su capturista)', async () => {
const monday = mondayMock([oportunidad('1'), oportunidad('2')])
const state = stateEnMemoria()
state.mapa.set('rr:skip:1', 'capturista') // el capturista lo asigna
state.mapa.set('rr:skip:1', 'capturista') // el capturista la 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()
@@ -237,7 +237,7 @@ describe('AutomatizacionesService.runRoundRobin', () => {
})
it('con soloItemId solo evalúa ese item (ruta del webhook create_item)', async () => {
const monday = mondayMock([lead('1'), lead('2')])
const monday = mondayMock([oportunidad('1'), oportunidad('2')])
const svc = crearServicio(monday, stateEnMemoria())
const r = await svc.runRoundRobin('2')
expect(r.asignados).toBe(1)
@@ -271,13 +271,13 @@ describe('AutomatizacionesService.runRoundRobin', () => {
describe('AutomatizacionesService.escaneoInactividad', () => {
const ahora = new Date('2026-08-22T09:00:00Z')
const leadsViejos = () => [
lead('1', { updatedAt: '2026-08-01T00:00:00Z' }), // 21 días inactivo
lead('2', { updatedAt: '2026-08-20T00:00:00Z' }), // activo
const oportunidadesViejas = () => [
oportunidad('1', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [101] }), // 21 días inactiva
oportunidad('2', { updatedAt: '2026-08-20T00:00:00Z', asesorIds: [101] }), // activa
]
it('preview: lista candidatos sin escribir en Monday ni en el estado', async () => {
const monday = mondayMock(leadsViejos())
const monday = mondayMock(oportunidadesViejas())
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
@@ -291,7 +291,7 @@ describe('AutomatizacionesService.escaneoInactividad', () => {
})
it('ejecución real: crea update, marca dedup y no repite el mismo día', async () => {
const monday = mondayMock(leadsViejos())
const monday = mondayMock(oportunidadesViejas())
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
@@ -301,7 +301,7 @@ describe('AutomatizacionesService.escaneoInactividad', () => {
expect(monday.updates[0]?.body).toContain('21 días')
expect(state.mapa.get('recordatorio:1:2026-08-22')).toBeDefined()
// Mismo día: no repite aunque el lead siga inactivo
// Mismo día: no repite aunque la oportunidad siga inactiva
const r2 = await svc.escaneoInactividad({ preview: false, ahora: new Date('2026-08-22T18:00:00Z') })
expect(r2.recordatoriosEnviados).toBe(0)
expect(r2.candidatos[0]?.yaRecordadoHoy).toBe(true)
@@ -319,15 +319,15 @@ describe('AutomatizacionesService.escaneoInactividad', () => {
describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
const ahora = new Date('2026-08-22T09:00:00Z')
it('el round-robin crea "Contactar a {lead}" tipo Llamada, Abierto, ligada al lead', async () => {
const monday = mondayMock([lead('1'), lead('2')])
it('el round-robin crea "Contactar a {nombre}" tipo Llamada, Abierto, ligada a la oportunidad', async () => {
const monday = mondayMock([oportunidad('1'), oportunidad('2')])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
const r = await svc.runRoundRobin()
expect(r.asignados).toBe(2)
expect(monday.actividades).toHaveLength(2)
expect(monday.actividades[0]).toMatchObject({
nombre: 'Contactar a Lead 1',
nombre: 'Contactar a Cliente 1',
tipoLabel: 'Llamada',
responsableId: 101,
estadoLabel: 'Abierto',
@@ -343,24 +343,24 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
})
it('con actividadesEnabled=false el round-robin no crea actividades', async () => {
const monday = mondayMock([lead('1')])
const monday = mondayMock([oportunidad('1')])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: false })
await svc.runRoundRobin()
expect(monday.actividades).toEqual([])
})
it('asignarACapturista: asigna al capturista y le crea su actividad de contacto', async () => {
it('asignarACapturista: asigna al capturista (columna Asesor) y le crea su actividad de contacto', async () => {
const monday = mondayMock([])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
await svc.asignarACapturista('lead-77', 'Lead Propio', 555)
await svc.asignarACapturista('op-77', 'Lead Propio', 555)
expect(monday.asignaciones).toEqual([{ itemId: 'lead-77', userId: 555 }])
expect(monday.asignaciones).toEqual([{ itemId: 'op-77', userId: 555 }])
expect(monday.actividades[0]).toMatchObject({
nombre: 'Contactar a Lead Propio',
responsableId: 555,
estadoLabel: 'Abierto',
leadItemId: 'lead-77',
leadItemId: 'op-77',
})
})
@@ -368,24 +368,21 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
const monday = mondayMock([])
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: false })
await svc.asignarACapturista('lead-78', 'Lead Propio', 555)
await svc.asignarACapturista('op-78', 'Lead Propio', 555)
expect(monday.asignaciones).toEqual([{ itemId: 'lead-78', userId: 555 }])
expect(monday.asignaciones).toEqual([{ itemId: 'op-78', userId: 555 }])
expect(monday.actividades).toEqual([])
})
it('inactividad: crea "Retomar contacto" al vendedor asignado; sin vendedor no crea nada', async () => {
const monday = mondayMock([
lead('1', { updatedAt: '2026-08-01T00:00:00Z', vendedorIds: [103] }), // inactivo con vendedor
lead('2', { updatedAt: '2026-08-01T00:00:00Z' }), // inactivo sin vendedor
])
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 svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
const r = await svc.escaneoInactividad({ preview: false, ahora })
expect(r.recordatoriosEnviados).toBe(2)
expect(r.recordatoriosEnviados).toBe(1)
expect(monday.actividades).toHaveLength(1)
expect(monday.actividades[0]).toMatchObject({
nombre: 'Retomar contacto con Lead 1',
nombre: 'Retomar contacto con Cliente 1',
tipoLabel: 'Llamada',
responsableId: 103,
estadoLabel: 'Abierto',
@@ -394,14 +391,14 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
})
it('preview de inactividad no crea actividades', async () => {
const monday = mondayMock([lead('1', { updatedAt: '2026-08-01T00:00:00Z', vendedorIds: [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([lead('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 })
@@ -413,43 +410,35 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
})
})
describe('ESTADOS_TERMINALES_LEAD con vocabulario CRM', () => {
it('ABANDONO también es terminal', () => {
expect(esEstadoTerminal('ABANDONO')).toBe(true)
expect(esEstadoTerminal('Abandono')).toBe(true)
expect(esEstadoTerminal('Intento de contacto')).toBe(false)
})
})
// ---------- Escalado de afluencias sin contactar ----------
// ---------- Ronda 2: escalado de leads sin contactar ----------
describe('leadsSinContactar (regla de escalado por horas)', () => {
describe('oportunidadesSinContactar (regla de escalado por horas)', () => {
const ahora = new Date('2026-08-22T12:00:00Z')
it('incluye leads asignados en NUEVO / Intento de contacto que superan el umbral', () => {
const leads = [
lead('1', { vendedorIds: [101], updatedAt: '2026-08-19T12:00:00Z' }), // 72 h → escala
lead('2', { vendedorIds: [102], estado: 'Intento de contacto', updatedAt: '2026-08-20T12:00:00Z' }), // 48 h justo → no
lead('3', { vendedorIds: [103], estado: 'CONTACTADO', updatedAt: '2026-08-01T12:00:00Z' }), // ya contactado
lead('4', { estado: 'NUEVO', updatedAt: '2026-08-01T12:00:00Z' }), // sin vendedor → no aplica
lead('5', { vendedorIds: [101], estado: 'DESCARTADO', updatedAt: '2026-08-01T12:00:00Z' }), // terminal
it('incluye oportunidades asignadas en Afluencia / Intento de contacto que superan el umbral', () => {
const ops = [
oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-19T12:00:00Z' }), // 72 h → escala
oportunidad('2', { asesorIds: [102], etapa: 'Intento de contacto', updatedAt: '2026-08-20T12:00:00Z' }), // 48 h justo → no
oportunidad('3', { asesorIds: [103], etapa: 'Contactado', updatedAt: '2026-08-01T12:00:00Z' }), // ya contactada
oportunidad('4', { etapa: 'Afluencia', updatedAt: '2026-08-01T12:00:00Z' }), // sin asesor → no aplica
oportunidad('5', { asesorIds: [101], etapa: 'Perdido', updatedAt: '2026-08-01T12:00:00Z' }), // terminal
]
expect(leadsSinContactar(leads, ahora, 48).map((l) => l.id)).toEqual(['1'])
expect(oportunidadesSinContactar(ops, ahora, 48).map((o) => o.id)).toEqual(['1'])
})
it('un lead con exactamente N horas aún no supera el umbral', () => {
const leads = [lead('1', { vendedorIds: [101], updatedAt: '2026-08-20T12:00:00Z' })]
expect(leadsSinContactar(leads, ahora, 48)).toEqual([])
it('una oportunidad con exactamente N horas aún no supera el umbral', () => {
const ops = [oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-20T12:00:00Z' })]
expect(oportunidadesSinContactar(ops, ahora, 48)).toEqual([])
})
it('ignora fechas inválidas', () => {
const leads = [lead('1', { vendedorIds: [101], updatedAt: 'no-es-fecha' })]
expect(leadsSinContactar(leads, ahora, 48)).toEqual([])
const ops = [oportunidad('1', { asesorIds: [101], updatedAt: 'no-es-fecha' })]
expect(oportunidadesSinContactar(ops, ahora, 48)).toEqual([])
})
})
describe('claves de dedup de ronda 2', () => {
it('una clave por lead/oportunidad', () => {
describe('claves de dedup', () => {
it('una clave por oportunidad', () => {
expect(claveEscalado('123')).toBe('escalado:123')
expect(claveApartadoCreado('456')).toBe('apartado-creado:456')
expect(claveVentaCreada('456')).toBe('venta-creada:456')
@@ -469,11 +458,11 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
const ahora = new Date('2026-08-22T12:00:00Z')
const configAuto = config.automatizaciones as Record<string, unknown>
it('escala al vendedor asignado cuando no hay coordinador configurado', async () => {
it('escala al asesor asignado cuando no hay coordinador configurado', async () => {
const anterior = configAuto['coordinadorUserId']
configAuto['coordinadorUserId'] = 0
try {
const monday = mondayMock([lead('1', { vendedorIds: [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 })
@@ -481,7 +470,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
expect(r.escalados).toBe(1)
expect(r.candidatos[0]).toMatchObject({ itemId: '1', horasSinContactar: 72, responsableId: 102, yaEscalado: false })
expect(monday.actividades[0]).toMatchObject({
nombre: 'URGENTE: lead sin contactar — Lead 1',
nombre: 'URGENTE: oportunidad sin contactar — Cliente 1',
responsableId: 102,
estadoLabel: 'Abierto',
leadItemId: '1',
@@ -498,7 +487,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
const anterior = configAuto['coordinadorUserId']
configAuto['coordinadorUserId'] = 999
try {
const monday = mondayMock([lead('1', { vendedorIds: [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)
@@ -509,11 +498,11 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
}
})
it('dedup: no vuelve a escalar un lead ya escalado', async () => {
it('dedup: no vuelve a escalar una oportunidad ya escalada', async () => {
const anterior = configAuto['coordinadorUserId']
configAuto['coordinadorUserId'] = 0
try {
const monday = mondayMock([lead('1', { vendedorIds: [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 })
@@ -529,14 +518,14 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
}
})
it('un fallo de Monday en un lead no detiene el escalado de los demás', async () => {
it('un fallo de Monday en una oportunidad no detiene el escalado de las demás', async () => {
const anterior = configAuto['coordinadorUserId']
configAuto['coordinadorUserId'] = 0
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const monday = mondayMock([
lead('1', { vendedorIds: [101], updatedAt: '2026-08-19T12:00:00Z' }),
lead('2', { vendedorIds: [102], updatedAt: '2026-08-19T12:00:00Z' }),
oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-19T12:00:00Z' }),
oportunidad('2', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' }),
])
vi.spyOn(monday, 'crearUpdateEnItem')
.mockRejectedValueOnce(new Error('Monday caído'))
@@ -544,7 +533,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
const r = await svc.escaneoEscalado({ ahora })
expect(r.escalados).toBe(1) // el segundo lead sí se escaló
expect(r.escalados).toBe(1) // la segunda oportunidad sí se escaló
expect(r.candidatos).toHaveLength(2)
} finally {
configAuto['coordinadorUserId'] = anterior
@@ -553,11 +542,11 @@ describe('AutomatizacionesService.escaneoEscalado', () => {
})
})
// ---------- Ronda 2: Oportunidad → Aparto / Ganado ----------
// ---------- Oportunidad → Aparto / Ganado ----------
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)
@@ -573,7 +562,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)
@@ -584,7 +573,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)
@@ -599,7 +588,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' }),
@@ -613,7 +602,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,26 +1,32 @@
import { config } from '../config/index.js'
import { AppError } from '../utils/errors.js'
import { mondayService, type LeadMonday, type MondayService, type UsuarioMonday } from './monday.service.js'
import { mondayService, type MondayService, type OportunidadMonday, type UsuarioMonday } from './monday.service.js'
import { prisma } from './prisma.js'
/**
* Automatizaciones sobre el tablero Leads de Monday:
* (a) asignación round-robin de vendedores a leads sin persona asignada
* (b) recordatorio de leads inactivos (> INACTIVIDAD_DIAS sin actualizarse)
* 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
* (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'
*
* El estado (índice RR, cache de vendedores, recordatorios enviados) vive en la
* tabla clave-valor AutomatizacionState para sobrevivir reinicios y no repetir
* recordatorios el mismo día.
*/
// Labels del tablero Leads: NUEVO, CONTACTADO, AGENDA CITA, CALIFICADO, DESCARTADO
// más los del vocabulario CRM FRASA (Lead Nuevo, Intento de contacto, Interesado,
// Abandono...). Terminales: DESCARTADO y ABANDONO (fuera de seguimiento).
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', '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
// Estados en los que un lead asignado aún no ha sido contactado de verdad;
// si permanece así más de LEAD_ESCALADO_HORAS horas se escala al coordinador.
export const ESTADOS_SIN_CONTACTAR = ['NUEVO', 'INTENTO DE CONTACTO'] as const
// Etapas tempranas que vigilan los escaneos de inactividad/escalado
export const ETAPAS_SEGUIMIENTO = ['AFLUENCIA', 'CONTACTADO', 'INTENTO DE CONTACTO'] as const
// Etapas en las que una oportunidad asignada aún no ha sido contactada de
// verdad; si permanece así más de LEAD_ESCALADO_HORAS horas se escala al
// coordinador.
export const ETAPAS_SIN_CONTACTAR = ['AFLUENCIA', 'INTENTO DE CONTACTO'] as const
// Etapas del tablero Oportunidades que disparan las reglas de ronda 2
export const ETAPA_APARTO = 'APARTO'
@@ -66,7 +72,7 @@ export function parseRrUserIds(raw: string): UsuarioMonday[] {
})
}
export function esEstadoTerminal(estado: string, terminales: readonly string[] = ESTADOS_TERMINALES_LEAD): boolean {
export function esEstadoTerminal(estado: string, terminales: readonly string[] = ETAPAS_TERMINALES_OPORTUNIDAD): boolean {
const normalizado = estado.trim().toUpperCase()
return terminales.some((t) => t.toUpperCase() === normalizado)
}
@@ -78,40 +84,50 @@ export function vendedorEnTurno(vendedores: readonly UsuarioMonday[], indice: nu
}
/**
* Leads activos (estado no terminal) cuya última actualización supera
* Oportunidades en etapa temprana (Afluencia / Contactado / Intento de
* contacto) CON asesor asignado cuya última actualización supera
* `diasInactividad` días respecto a `ahora`.
*/
export function leadsInactivos(leads: readonly LeadMonday[], ahora: Date, diasInactividad: number): LeadMonday[] {
export function oportunidadesInactivas(
oportunidades: readonly OportunidadMonday[],
ahora: Date,
diasInactividad: number,
): OportunidadMonday[] {
const limiteMs = diasInactividad * DIA_MS
return leads.filter((lead) => {
if (esEstadoTerminal(lead.estado)) return false
const t = Date.parse(lead.updatedAt)
return oportunidades.filter((op) => {
if (op.asesorIds.length === 0) return false
if (!ETAPAS_SEGUIMIENTO.some((e) => e === op.etapa.trim().toUpperCase())) return false
const t = Date.parse(op.updatedAt)
if (Number.isNaN(t)) return false
return ahora.getTime() - t > limiteMs
})
}
/** Clave de dedup: un recordatorio por lead por día */
/** Clave de dedup: un recordatorio por oportunidad por día */
export function claveRecordatorio(itemId: string, fecha: Date): string {
return `recordatorio:${itemId}:${fecha.toISOString().slice(0, 10)}`
}
export function diasSinActividad(lead: LeadMonday, ahora: Date): number {
return Math.floor((ahora.getTime() - Date.parse(lead.updatedAt)) / DIA_MS)
export function diasSinActividad(op: Pick<OportunidadMonday, 'updatedAt'>, ahora: Date): number {
return Math.floor((ahora.getTime() - Date.parse(op.updatedAt)) / DIA_MS)
}
/**
* Leads ASIGNADOS (con vendedor) que siguen en estado NUEVO / Intento de contacto
* y llevan más de `horasUmbral` horas sin actualizarse: candidatos a escalar.
* (El round-robin actualiza el item al asignar, así que el reloj arranca ahí.)
* Oportunidades ASIGNADAS (con asesor) que siguen en Afluencia /
* Intento de contacto y llevan más de `horasUmbral` horas sin actualizarse:
* candidatas a escalar. (El round-robin actualiza el item al asignar, así que
* el reloj arranca ahí.)
*/
export function leadsSinContactar(leads: readonly LeadMonday[], ahora: Date, horasUmbral: number): LeadMonday[] {
export function oportunidadesSinContactar(
oportunidades: readonly OportunidadMonday[],
ahora: Date,
horasUmbral: number,
): OportunidadMonday[] {
const limiteMs = horasUmbral * HORA_MS
return leads.filter((lead) => {
if (lead.vendedorIds.length === 0) return false
if (esEstadoTerminal(lead.estado)) return false
if (!ESTADOS_SIN_CONTACTAR.some((e) => e === lead.estado.trim().toUpperCase())) return false
const t = Date.parse(lead.updatedAt)
return oportunidades.filter((op) => {
if (op.asesorIds.length === 0) return false
if (!ETAPAS_SIN_CONTACTAR.some((e) => e === op.etapa.trim().toUpperCase())) return false
const t = Date.parse(op.updatedAt)
if (Number.isNaN(t)) return false
return ahora.getTime() - t > limiteMs
})
@@ -141,9 +157,8 @@ export function esEtapa(etapa: string, esperada: string): boolean {
type MondayLeads = Pick<
MondayService,
| 'getLeads'
| 'getOportunidades'
| 'asignarVendedorALead'
| 'asignarAsesorAOportunidad'
| 'crearUpdateEnItem'
| 'getUsuariosEquipo'
| 'crearActividad'
@@ -279,24 +294,25 @@ export class AutomatizacionesService {
}
/**
* Una pasada de round-robin: asigna vendedor a todo lead sin persona asignada
* y con estado no terminal. Si se pasa `soloItemId`, solo se evalúa ese item
* (ruta del webhook create_item).
* 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).
*/
async runRoundRobin(soloItemId?: string): Promise<ResultadoRoundRobin> {
const leads = await this.monday.getLeads()
const sinAsignar = leads.filter(
(l) =>
l.vendedorIds.length === 0 &&
!esEstadoTerminal(l.estado) &&
(soloItemId === undefined || l.id === soloItemId),
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),
)
// Los leads 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 vendedor y pisara la asignación.
// 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 l of sinAsignar) {
if ((await this.state.get(`${CLAVE_RR_SKIP}${l.id}`)) !== 'capturista') pendientes.push(l)
for (const o of sinAsignar) {
if ((await this.state.get(`${CLAVE_RR_SKIP}${o.id}`)) !== 'capturista') pendientes.push(o)
}
if (pendientes.length === 0) {
await this.state.set(CLAVE_RR_ULTIMA_PASADA, new Date().toISOString())
@@ -308,18 +324,18 @@ export class AutomatizacionesService {
if (!Number.isInteger(indice) || indice < 0) indice = 0
const detalle: AsignacionRR[] = []
for (const lead of pendientes) {
for (const op of pendientes) {
const vendedor = vendedorEnTurno(vendedores, indice)
if (!vendedor) throw new AppError(500, 'La lista de vendedores está vacía')
await this.monday.asignarVendedorALead(lead.id, vendedor.id)
await this.monday.asignarAsesorAOportunidad(op.id, vendedor.id)
if (this.actividadesEnabled) {
await this.crearActividadSegura({
nombre: `Contactar a ${lead.nombre}`,
nombre: `Contactar a ${op.nombre}`,
responsableId: vendedor.id,
leadItemId: lead.id,
leadItemId: op.id,
})
}
detalle.push({ itemId: lead.id, lead: lead.nombre, vendedorId: vendedor.id, vendedor: vendedor.nombre })
detalle.push({ itemId: op.id, lead: op.nombre, vendedorId: vendedor.id, vendedor: vendedor.nombre })
indice++
}
@@ -329,18 +345,19 @@ export class AutomatizacionesService {
}
/**
* Asigna el lead 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. No hay carrera con el
* webhook create_item: runRoundRobin solo toma leads sin persona asignada.
* 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.
*/
async asignarACapturista(leadId: string, leadNombre: string, vendedorId: number): Promise<void> {
await this.monday.asignarVendedorALead(leadId, vendedorId)
async asignarACapturista(oportunidadId: string, nombre: string, vendedorId: number): Promise<void> {
await this.monday.asignarAsesorAOportunidad(oportunidadId, vendedorId)
if (this.actividadesEnabled) {
await this.crearActividadSegura({
nombre: `Contactar a ${leadNombre}`,
nombre: `Contactar a ${nombre}`,
responsableId: vendedorId,
leadItemId: leadId,
leadItemId: oportunidadId,
})
}
}
@@ -352,38 +369,38 @@ export class AutomatizacionesService {
async escaneoInactividad(opciones: { preview: boolean; ahora?: Date }): Promise<ResultadoInactividad> {
const ahora = opciones.ahora ?? new Date()
const diasUmbral = config.automatizaciones.inactividadDias
const leads = await this.monday.getLeads()
const inactivos = leadsInactivos(leads, ahora, diasUmbral)
const oportunidades = await this.monday.getOportunidades()
const inactivas = oportunidadesInactivas(oportunidades, ahora, diasUmbral)
const candidatos: CandidatoInactividad[] = []
let recordatoriosEnviados = 0
for (const lead of inactivos) {
const clave = claveRecordatorio(lead.id, ahora)
for (const op of inactivas) {
const clave = claveRecordatorio(op.id, ahora)
const yaEnviado = (await this.state.get(clave)) !== null
const dias = diasSinActividad(lead, ahora)
const dias = diasSinActividad(op, ahora)
candidatos.push({
itemId: lead.id,
lead: lead.nombre,
estado: lead.estado,
updatedAt: lead.updatedAt,
itemId: op.id,
lead: op.nombre,
estado: op.etapa,
updatedAt: op.updatedAt,
diasInactivo: dias,
yaRecordadoHoy: yaEnviado,
})
if (opciones.preview || yaEnviado) continue
const body =
`⚠️ Recordatorio automático: este lead lleva ${dias} días sin actualizarse ` +
`(última actividad: ${lead.updatedAt.slice(0, 10)}). ` +
`Favor de darle seguimiento o marcarlo como DESCARTADO.`
await this.monday.crearUpdateEnItem(lead.id, body)
`⚠️ Recordatorio automático: esta oportunidad lleva ${dias} días sin actualizarse ` +
`(última actividad: ${op.updatedAt.slice(0, 10)}). ` +
`Favor de darle seguimiento o marcarla como Perdido.`
await this.monday.crearUpdateEnItem(op.id, body)
await this.state.set(clave, ahora.toISOString())
recordatoriosEnviados++
// Además del update, deja una actividad al vendedor asignado (si tiene)
const vendedorId = lead.vendedorIds[0]
if (this.actividadesEnabled && vendedorId !== undefined) {
// Además del update, deja una actividad al asesor asignado (si tiene)
const asesorId = op.asesorIds[0]
if (this.actividadesEnabled && asesorId !== undefined) {
await this.crearActividadSegura({
nombre: `Retomar contacto con ${lead.nombre}`,
responsableId: vendedorId,
leadItemId: lead.id,
nombre: `Retomar contacto con ${op.nombre}`,
responsableId: asesorId,
leadItemId: op.id,
})
}
}
@@ -399,36 +416,36 @@ export class AutomatizacionesService {
await this.state.set(clave, String((Number.isFinite(actual) ? actual : 0) + n))
}
/** Responsable del escalado: coordinador (env) o, si no hay, el vendedor asignado */
private responsableEscalado(lead: LeadMonday): number | null {
/** Responsable del escalado: coordinador (env) o, si no hay, el asesor asignado */
private responsableEscalado(op: OportunidadMonday): number | null {
const coordinador = config.automatizaciones.coordinadorUserId
if (Number.isInteger(coordinador) && coordinador > 0) return coordinador
return lead.vendedorIds[0] ?? null
return op.asesorIds[0] ?? null
}
/**
* Escalado de leads sin contactar: un lead asignado que sigue en
* NUEVO / Intento de contacto más de LEAD_ESCALADO_HORAS horas genera una
* actividad URGENTE al coordinador (o al vendedor) + update en el item.
* Un escalado por lead (dedup `escalado:<itemId>`); un fallo de Monday en
* un lead no detiene a los demás.
* Escalado de afluencias sin contactar: una oportunidad asignada que sigue en
* Afluencia / Intento de contacto más de LEAD_ESCALADO_HORAS horas genera una
* actividad URGENTE al coordinador (o al asesor) + update en el item.
* Un escalado por oportunidad (dedup `escalado:<itemId>`); un fallo de Monday
* en una oportunidad no detiene a las demás.
*/
async escaneoEscalado(opciones: { ahora?: Date } = {}): Promise<ResultadoEscalado> {
const ahora = opciones.ahora ?? new Date()
const horasUmbral = config.automatizaciones.escaladoHoras
const leads = await this.monday.getLeads()
const pendientes = leadsSinContactar(leads, ahora, horasUmbral)
const oportunidades = await this.monday.getOportunidades()
const pendientes = oportunidadesSinContactar(oportunidades, ahora, horasUmbral)
const candidatos: CandidatoEscalado[] = []
let escalados = 0
for (const lead of pendientes) {
const responsableId = this.responsableEscalado(lead)
const horas = Math.floor((ahora.getTime() - Date.parse(lead.updatedAt)) / HORA_MS)
const yaEscalado = (await this.state.get(claveEscalado(lead.id))) !== null
for (const op of pendientes) {
const responsableId = this.responsableEscalado(op)
const horas = Math.floor((ahora.getTime() - Date.parse(op.updatedAt)) / HORA_MS)
const yaEscalado = (await this.state.get(claveEscalado(op.id))) !== null
candidatos.push({
itemId: lead.id,
lead: lead.nombre,
estado: lead.estado,
itemId: op.id,
lead: op.nombre,
estado: op.etapa,
horasSinContactar: horas,
responsableId,
yaEscalado,
@@ -437,24 +454,24 @@ export class AutomatizacionesService {
try {
if (this.actividadesEnabled && responsableId !== null) {
await this.monday.crearActividad({
nombre: `URGENTE: lead sin contactar — ${lead.nombre}`,
nombre: `URGENTE: oportunidad sin contactar — ${op.nombre}`,
tipoLabel: 'Llamada',
responsableId,
fecha: AutomatizacionesService.fechaHoyMas(0),
estadoLabel: 'Abierto',
leadItemId: lead.id,
leadItemId: op.id,
})
}
await this.monday.crearUpdateEnItem(
lead.id,
`🚨 Escalado automático: el lead lleva ${horas} h asignado en estado "${lead.estado}" sin contacto. ` +
`Se notificó a ${responsableId !== null && responsableId !== lead.vendedorIds[0] ? 'la coordinación' : 'el vendedor asignado'}.`,
op.id,
`🚨 Escalado automático: la oportunidad lleva ${horas} h asignada en etapa "${op.etapa}" sin contacto. ` +
`Se notificó a ${responsableId !== null && responsableId !== op.asesorIds[0] ? 'la coordinación' : 'el asesor asignado'}.`,
)
await this.state.set(claveEscalado(lead.id), ahora.toISOString())
await this.state.set(claveEscalado(op.id), ahora.toISOString())
escalados++
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[escalado] falló el escalado del lead ${lead.id} (${lead.nombre}):`, err)
console.error(`[escalado] falló el escalado de la oportunidad ${op.id} (${op.nombre}):`, err)
}
}

View File

@@ -1,40 +1,21 @@
import { describe, expect, it, vi } from 'vitest'
import { FunnelService, type LeadOportunidadStore } from './funnel.service.js'
import type { LeadMonday } from './monday.service.js'
import type { LeadMonday, OportunidadMonday } from './monday.service.js'
/** Monday mockeado: registra oportunidades creadas, cambios de columna y movimientos de grupo */
function mondayMock(opts: { leadPorTelefono?: LeadMonday | null; leadPorId?: LeadMonday | null } = {}) {
/** Monday mockeado: registra cambios de columna y devuelve búsquedas controladas */
function mondayMock(
opts: { leadPorTelefono?: LeadMonday | null; oportunidadPorTelefono?: OportunidadMonday | null } = {},
) {
return {
oportunidadesCreadas: [] as Record<string, unknown>[],
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
movimientosGrupo: [] as { itemId: string; groupId: string }[],
async crearOportunidadAfluencia(datos: Record<string, unknown>) {
this.oportunidadesCreadas.push(datos)
return `op-${this.oportunidadesCreadas.length}`
},
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
this.cambios.push({ boardId, itemId, columna, valor })
},
async buscarLeadPorTelefono(_telefono: string) {
return opts.leadPorTelefono ?? null
},
async getLeadPorId(_itemId: string) {
return opts.leadPorId ?? null
},
// La oportunidad recién creada nace en el grupo top del tablero (como en Monday real)
async columnaIdPorTitulo(_boardId: string, _titulo: string) {
return 'status_col'
},
async getItemConGrupo(itemId: string) {
return {
id: itemId,
name: 'Op Prueba',
groupId: 'group_top',
column_values: [{ id: 'status_col', text: 'Afluencia', value: null }],
}
},
async moverItemAGrupo(itemId: string, groupId: string) {
this.movimientosGrupo.push({ itemId, groupId })
async buscarOportunidadPorTelefono(_telefono: string) {
return opts.oportunidadPorTelefono ?? null
},
}
}
@@ -47,14 +28,18 @@ function storeMock(): LeadOportunidadStore & { mapping: Map<string, string> } {
async buscarPorLeadId(leadId: string) {
return mapping.get(leadId) ?? null
},
async buscarPorOportunidadId(oportunidadId: string) {
for (const opId of mapping.values()) {
if (opId === oportunidadId) return opId
}
return null
},
async guardar(leadId: string, oportunidadId: string) {
mapping.set(leadId, oportunidadId)
},
}
}
const leadBase = { id: 'lead-1', nombre: 'Lead Prueba', telefono: '4771234567', desarrolloLabel: 'SENDA' }
const leadMonday = (overrides: Partial<LeadMonday>): LeadMonday => ({
id: 'lead-1',
nombre: 'Lead Prueba',
@@ -68,47 +53,19 @@ const leadMonday = (overrides: Partial<LeadMonday>): LeadMonday => ({
...overrides,
})
describe('FunnelService.asegurarOportunidadAfluencia', () => {
it('crea la oportunidad en Monday y guarda el mapping lead ↔ oportunidad', async () => {
const monday = mondayMock()
const store = storeMock()
const funnel = new FunnelService(monday, store)
const opId = await funnel.asegurarOportunidadAfluencia(leadBase)
expect(opId).toBe('op-1')
expect(monday.oportunidadesCreadas).toEqual([
{ nombre: 'Lead Prueba', telefono: '4771234567', email: undefined, desarrolloLabel: 'SENDA' },
])
expect(store.mapping.get('lead-1')).toBe('op-1')
// Y la mueve al grupo que corresponde a 'Afluencia' (nace en el grupo top del tablero)
expect(monday.movimientosGrupo).toEqual([{ itemId: 'op-1', groupId: 'topics' }])
})
it('es idempotente: con mapping existente no crea una segunda oportunidad', async () => {
const monday = mondayMock()
const store = storeMock()
store.mapping.set('lead-1', 'op-vieja')
const funnel = new FunnelService(monday, store)
const opId = await funnel.asegurarOportunidadAfluencia(leadBase)
expect(opId).toBe('op-vieja')
expect(monday.oportunidadesCreadas).toEqual([])
})
it('por leadId (webhook): consulta el lead en Monday y crea la oportunidad', async () => {
const monday = mondayMock({ leadPorId: leadMonday({ telefono: '4771234567', email: 'a@b.com' }) })
const store = storeMock()
const funnel = new FunnelService(monday, store)
const opId = await funnel.asegurarOportunidadAfluenciaPorLeadId('lead-1')
expect(opId).toBe('op-1')
expect(monday.oportunidadesCreadas[0]).toMatchObject({ nombre: 'Lead Prueba', telefono: '4771234567' })
expect(store.mapping.get('lead-1')).toBe('op-1')
})
it('por leadId inexistente en Monday no crea nada (null)', async () => {
const monday = mondayMock({ leadPorId: null })
const funnel = new FunnelService(monday, storeMock())
await expect(funnel.asegurarOportunidadAfluenciaPorLeadId('fantasma')).resolves.toBeNull()
expect(monday.oportunidadesCreadas).toEqual([])
})
const oportunidadMonday = (overrides: Partial<OportunidadMonday>): OportunidadMonday => ({
id: 'op-1',
nombre: 'Cliente Prueba',
createdAt: '',
updatedAt: '',
etapa: 'Afluencia',
unidadCodigo: '',
precioFinal: 0,
asesorIds: [],
telefono: '4771234567',
email: '',
origen: '',
...overrides,
})
describe('FunnelService.moverOportunidadDeLead', () => {
@@ -130,7 +87,7 @@ describe('FunnelService.moverOportunidadDeLead', () => {
})
describe('FunnelService.oportunidadPorTelefono', () => {
it('resuelve lead por teléfono y devuelve la oportunidad mapeada', async () => {
it('resuelve lead histórico por teléfono y devuelve la oportunidad mapeada', async () => {
const monday = mondayMock({ leadPorTelefono: leadMonday({ id: 'lead-7' }) })
const store = storeMock()
store.mapping.set('lead-7', 'op-7')
@@ -138,11 +95,24 @@ describe('FunnelService.oportunidadPorTelefono', () => {
await expect(funnel.oportunidadPorTelefono('4771234567')).resolves.toBe('op-7')
})
it('sin lead con ese teléfono (o sin mapping) devuelve null', async () => {
it('sin lead histórico, busca el teléfono directo en Oportunidades (modelo nuevo)', async () => {
const monday = mondayMock({ oportunidadPorTelefono: oportunidadMonday({ id: 'op-directa' }) })
const funnel = new FunnelService(monday, storeMock())
await expect(funnel.oportunidadPorTelefono('4771234567')).resolves.toBe('op-directa')
})
it('lead histórico sin mapping cae a la búsqueda directa en Oportunidades', async () => {
const monday = mondayMock({
leadPorTelefono: leadMonday({ id: 'sin-mapping' }),
oportunidadPorTelefono: oportunidadMonday({ id: 'op-directa' }),
})
const funnel = new FunnelService(monday, storeMock())
await expect(funnel.oportunidadPorTelefono('4771234567')).resolves.toBe('op-directa')
})
it('sin lead ni oportunidad con ese teléfono devuelve null', async () => {
const funnel = new FunnelService(mondayMock(), storeMock())
await expect(funnel.oportunidadPorTelefono('4770000000')).resolves.toBeNull()
const monday = mondayMock({ leadPorTelefono: leadMonday({ id: 'sin-mapping' }) })
await expect(new FunnelService(monday, storeMock()).oportunidadPorTelefono('4771234567')).resolves.toBeNull()
})
})
@@ -171,11 +141,39 @@ describe('FunnelService.moverAApartoConDedup', () => {
expect(orden).toEqual(['dedup:apartado-creado:op-1', 'monday:Aparto'])
})
it('con id de oportunidad directa (modelo nuevo) busca por oportunidadId y mueve ESA', async () => {
const monday = mondayMock()
const store = storeMock()
// Auto-mapping de la captura nueva: leadId = oportunidadId
store.mapping.set('op-77', 'op-77')
const funnel = new FunnelService(monday, store)
const state = { get: vi.fn(async () => null), set: vi.fn(async () => {}) }
const opId = await funnel.moverAApartoConDedup('op-77', state)
expect(opId).toBe('op-77')
expect(state.set).toHaveBeenCalledWith('apartado-creado:op-77', expect.any(String))
expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-77', columna: 'Estado', valor: 'Aparto' }])
})
it('un id que es a la vez lead de otro mapping y oportunidad: gana la oportunidad directa', async () => {
const monday = mondayMock()
const store = storeMock()
store.mapping.set('lead-1', 'op-1') // liga histórica cuya oportunidad es op-1
store.mapping.set('op-1', 'op-1') // auto-mapping de op-1 (modelo nuevo)
const funnel = new FunnelService(monday, store)
const state = { get: vi.fn(async () => null), set: vi.fn(async () => {}) }
// 'op-1' como entrada: se resuelve como oportunidad directa
await expect(funnel.moverAApartoConDedup('op-1', state)).resolves.toBe('op-1')
// 'lead-1' como entrada: se resuelve vía la liga histórica
await expect(funnel.moverAApartoConDedup('lead-1', state)).resolves.toBe('op-1')
})
it('sin mapping no escribe dedup ni mueve nada (null)', async () => {
const monday = mondayMock()
const funnel = new FunnelService(monday, storeMock())
const state = { get: vi.fn(async () => null), set: vi.fn(async () => {}) }
await expect(funnel.moverAApartoConDedup('lead-sin-op', state)).resolves.toBeNull()
await expect(funnel.moverAApartoConDedup('sin-mapping', state)).resolves.toBeNull()
expect(state.set).not.toHaveBeenCalled()
expect(monday.cambios).toEqual([])
})

View File

@@ -1,44 +1,33 @@
import { config } from '../config/index.js'
import { claveApartadoCreado, type StateStore } from './automatizaciones.service.js'
import { moverAGrupoSegunEstado } from './grupos-estado.service.js'
import { mondayService, type MondayService } from './monday.service.js'
import { prisma } from './prisma.js'
/**
* Funnel comercial Leads → Oportunidades:
* todo lead capturado entra al tablero Oportunidades en etapa 'Afluencia' y la
* oportunidad avanza de etapa sola conforme avanza el proceso (Agendo Cita,
* Cotizador, Aparto, Ganado / Perdido).
* Funnel comercial sobre el tablero Oportunidades:
* la captura de afluencia crea la oportunidad directamente en etapa
* 'Afluencia' (ver leads.controller), y la oportunidad avanza de etapa sola
* conforme avanza el proceso (Agendo Cita, Cotizador, Aparto, Ganado /
* Perdido).
*
* La vinculación lead ↔ oportunidad vive en la tabla LeadOportunidad (dedup
* persistente: una oportunidad por lead), inyectada como store para testear.
* La tabla LeadOportunidad (mapping lead ↔ oportunidad) queda para las ligas
* históricas (leads del tablero Leads de agosto) y como auto-mapping
* (leadId = oportunidadId) de las afluencias nuevas, para que el alta de
* clientes encuentre la oportunidad a mover a 'Aparto' en ambos esquemas.
* Los errores de Monday se propagan al caller, que decide loguear y continuar
* (la acción principal — crear el lead, la cotización o el alta — no se rompe).
* (la acción principal — la cotización o el alta — no se rompe).
*/
export interface LeadFunnel {
id: string // item ID del lead en Monday
nombre: string
telefono?: string | undefined
email?: string | undefined
desarrolloLabel?: string | undefined
}
/** Store del mapping lead ↔ oportunidad (Prisma en prod, memoria en tests) */
export interface LeadOportunidadStore {
buscarPorLeadId(leadId: string): Promise<string | null> // oportunidadId
buscarPorOportunidadId(oportunidadId: string): Promise<string | null> // oportunidadId si hay mapping
guardar(leadId: string, oportunidadId: string): Promise<void>
}
type MondayFunnel = Pick<
MondayService,
| 'crearOportunidadAfluencia'
| 'cambiarValorColumna'
| 'buscarLeadPorTelefono'
| 'getLeadPorId'
| 'columnaIdPorTitulo'
| 'getItemConGrupo'
| 'moverItemAGrupo'
'cambiarValorColumna' | 'buscarLeadPorTelefono' | 'buscarOportunidadPorTelefono'
>
export class FunnelService {
@@ -47,49 +36,6 @@ export class FunnelService {
private readonly store: LeadOportunidadStore,
) {}
/**
* Garantiza que el lead tenga su oportunidad en 'Afluencia': si ya existe el
* mapping devuelve la oportunidad existente; si no, la crea en Monday y
* guarda el mapping. Idempotente.
*/
async asegurarOportunidadAfluencia(lead: LeadFunnel): Promise<string> {
const existente = await this.store.buscarPorLeadId(lead.id)
if (existente !== null) return existente
const oportunidadId = await this.monday.crearOportunidadAfluencia({
nombre: lead.nombre,
telefono: lead.telefono,
email: lead.email,
desarrolloLabel: lead.desarrolloLabel,
})
await this.store.guardar(lead.id, oportunidadId)
// Monday crea los items en el grupo TOP del tablero (hoy "Perdidos" en
// Oportunidades) y el webhook de change_column_value no dispara en la
// creación: la oportunidad recién creada se mueve al grupo que corresponde
// a su etapa. Best-effort: un fallo no rompe la captura del lead.
try {
await moverAGrupoSegunEstado(this.monday, config.monday.boards.oportunidades, oportunidadId)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover al grupo correcto la oportunidad ${oportunidadId}:`, err)
}
return oportunidadId
}
/**
* Variante para el webhook create_pulse: el payload no trae nombre/teléfono,
* así que el lead se consulta en Monday antes de asegurar la oportunidad.
*/
async asegurarOportunidadAfluenciaPorLeadId(leadId: string): Promise<string | null> {
const lead = await this.monday.getLeadPorId(leadId)
if (!lead) return null
return this.asegurarOportunidadAfluencia({
id: lead.id,
nombre: lead.nombre,
telefono: lead.telefono !== '' ? lead.telefono : undefined,
email: lead.email !== '' ? lead.email : undefined,
})
}
/** Mueve la oportunidad del lead a otra etapa. Sin mapping → no-op (false). */
async moverOportunidadDeLead(leadId: string, etapa: string): Promise<boolean> {
const oportunidadId = await this.store.buscarPorLeadId(leadId)
@@ -104,24 +50,36 @@ export class FunnelService {
}
/**
* Oportunidad del lead que tenga ese teléfono (match de cotizaciones:
* busca el lead por teléfono en Monday y de ahí el mapping).
* Oportunidad que tenga ese teléfono (match de cotizaciones). Convierte con
* ambos esquemas: primero el mapping histórico (lead del tablero Leads → su
* oportunidad); si no hay lead con ese número, busca el teléfono directo en
* Oportunidades (modelo nuevo: la afluencia ES la oportunidad).
*/
async oportunidadPorTelefono(telefono: string): Promise<string | null> {
const lead = await this.monday.buscarLeadPorTelefono(telefono)
if (!lead) return null
return this.store.buscarPorLeadId(lead.id)
if (lead) {
const mapeada = await this.store.buscarPorLeadId(lead.id)
if (mapeada !== null) return mapeada
}
const directa = await this.monday.buscarOportunidadPorTelefono(telefono)
return directa?.id ?? null
}
/**
* Alta de cliente desde un lead: escribe el dedup `apartado-creado:<opId>`
* ANTES de mover la oportunidad a 'Aparto'. Este orden evita la carrera con
* el webhook/cron (escaneoOportunidades vería 'Aparto' y crearía un Apartado
* duplicado; con el dedup previo lo salta). Devuelve la oportunidadId movida
* o null si el lead no tiene mapping.
* Alta de cliente: escribe el dedup `apartado-creado:<opId>` ANTES de mover
* la oportunidad a 'Aparto'. Este orden evita la carrera con el webhook/cron
* (escaneoOportunidades vería 'Aparto' y crearía un Apartado duplicado; con
* el dedup previo lo salta). Devuelve la oportunidadId movida o null si no
* hay mapping.
*
* El id puede ser una oportunidad directa (modelo nuevo: la afluencia ES la
* oportunidad; el picker del alta la manda con fuente 'oportunidad') o un
* lead histórico del tablero Leads: se busca primero como oportunidad y
* luego como lead.
*/
async moverAApartoConDedup(leadId: string, state: StateStore): Promise<string | null> {
const oportunidadId = await this.store.buscarPorLeadId(leadId)
async moverAApartoConDedup(id: string, state: StateStore): Promise<string | null> {
const comoOportunidad = await this.store.buscarPorOportunidadId(id)
const oportunidadId = comoOportunidad ?? (await this.store.buscarPorLeadId(id))
if (oportunidadId === null) return null
await state.set(claveApartadoCreado(oportunidadId), new Date().toISOString())
await this.monday.cambiarValorColumna(config.monday.boards.oportunidades, oportunidadId, 'Estado', 'Aparto')
@@ -135,6 +93,10 @@ export const prismaLeadOportunidadStore: LeadOportunidadStore = {
const row = await prisma.leadOportunidad.findUnique({ where: { leadId } })
return row?.oportunidadId ?? null
},
async buscarPorOportunidadId(oportunidadId) {
const row = await prisma.leadOportunidad.findUnique({ where: { oportunidadId } })
return row?.oportunidadId ?? null
},
async guardar(leadId, oportunidadId) {
await prisma.leadOportunidad.create({ data: { leadId, oportunidadId } })
},

View File

@@ -42,6 +42,9 @@ export const GRUPOS_POR_ESTADO: Record<string, { columna: string; destinos: Reco
Abandono: 'group_mm6s4cgq',
'No Asistio': 'group_mm6s4cgq',
Afluencia: 'topics',
Contactado: 'topics',
'Intento de contacto': 'topics',
Calificado: 'topics',
Cotizador: 'topics',
'Agendo Cita': 'topics',
Aparto: 'topics',

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', () => {

View File

@@ -5,12 +5,15 @@ 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).
* "Mis capturas" viene del registro local (tabla LeadCaptura), no de Monday:
* es la lista de leads que el usuario dio de alta, con su estado actual.
* 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.
*/
// 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']
// Estados del board Apartado que cierran el pipeline Enkontrol
export const ESTADOS_FINALES_APARTADO = ['ESCRITURADO', 'CANCELACION', 'CANCELADO', 'LIBERADO', 'FORMALIZADO']
@@ -76,9 +79,8 @@ export interface ResumenMiDia {
misClientes: ClienteMiDia[]
misActividadesHoy: ActividadMiDia[]
apartadosPorVencer: ApartadoPorVencer[]
// null = el tablero Oportunidades no tiene columna people de asesor (hoy no existe)
misOportunidades: { porEtapa: Record<string, number> } | null
notaOportunidades: string | null
/** Conteo de MIS oportunidades por etapa (columna Estado del board) */
misOportunidades: { porEtapa: Record<string, number> }
conteos: {
leadsNuevosSemana: number // míos, últimos 7 días naturales
actividadesAbiertas: number // mías, Estado Abierto
@@ -96,7 +98,7 @@ export interface ResumenMiDia {
}
}
type MondayMiDia = Pick<MondayService, 'getLeads' | 'getActividades' | 'getApartados'>
type MondayMiDia = Pick<MondayService, 'getOportunidades' | 'getActividades' | 'getApartados'>
const MS_DIA = 24 * 60 * 60 * 1000
@@ -109,8 +111,8 @@ 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())
}
export function esEstadoFinalApartado(estado: string): boolean {
@@ -131,8 +133,8 @@ export async function resumenMiDia(
const miId = usuario.mondayUserId
const sinMapeo = miId === null
const [leads, actividades, apartados] = await Promise.all([
monday.getLeads(),
const [oportunidades, actividades, apartados] = await Promise.all([
monday.getOportunidades(),
monday.getActividades(),
monday.getApartados(),
])
@@ -143,31 +145,40 @@ export async function resumenMiDia(
const hace7DiasMs = ahora.getTime() - 7 * MS_DIA
// --- Listas personales (vacías si no hay mapeo Monday) ---
const mios = sinMapeo ? [] : leads.filter((l) => l.vendedorIds.includes(miId))
const mios = sinMapeo ? [] : oportunidades.filter((o) => o.asesorIds.includes(miId))
const misLeads: LeadMiDia[] = mios
.filter((l) => !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,
.filter((o) => !esEtapaTerminal(o.etapa))
.map((o) => ({
id: o.id,
nombre: o.nombre,
telefono: o.telefono,
email: o.email,
estado: o.etapa,
origen: o.origen,
updatedAt: o.updatedAt,
}))
// "Mis capturas" se enriquece con el estado/origen ACTUAL del lead en Monday
// (si ya no existe en el board, estado y origen van ''), más reciente primero
const leadsPorId = new Map(leads.map((l) => [l.id, l]))
// Conteo de MIS oportunidades por etapa (incluye terminales: es el resumen
// del pipeline personal, no la lista de trabajo activo)
const porEtapa: Record<string, number> = {}
for (const o of mios) {
const etapa = o.etapa.trim() === '' ? '(sin etapa)' : o.etapa.trim()
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
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: lead?.estado ?? '',
origen: lead?.origen ?? '',
estado: op?.etapa ?? '',
origen: op?.origen ?? '',
capturadoEn: c.createdAt.toISOString(),
}
})
@@ -224,27 +235,25 @@ export async function resumenMiDia(
misClientes,
misActividadesHoy,
apartadosPorVencer,
// El tablero Oportunidades no tiene columna people de asesor: no se puede
// filtrar "mío"; se omite con nota explícita
misOportunidades: null,
notaOportunidades: 'El tablero Oportunidades no tiene columna de asesor (people); no se puede filtrar por vendedor',
misOportunidades: { porEtapa },
conteos,
}
if (ROLES_CON_EQUIPO.includes(usuario.rol)) {
const sinAsignar = leads.filter((l) => l.vendedorIds.length === 0 && !esEstadoTerminalLead(l.estado))
const sinAsignar = oportunidades.filter((o) => o.asesorIds.length === 0 && !esEtapaTerminal(o.etapa))
const conteoPorId = new Map<number, number>()
const nuevosPorId = new Map<number, number>()
for (const l of leads) {
if (esEstadoTerminalLead(l.estado)) continue
const esNuevoSemana = Date.parse(l.createdAt) >= hace7DiasMs
for (const id of l.vendedorIds) {
for (const o of oportunidades) {
if (esEtapaTerminal(o.etapa)) continue
const esNuevoSemana = Date.parse(o.createdAt) >= hace7DiasMs
for (const id of o.asesorIds) {
conteoPorId.set(id, (conteoPorId.get(id) ?? 0) + 1)
if (esNuevoSemana) nuevosPorId.set(id, (nuevosPorId.get(id) ?? 0) + 1)
}
}
resumen.equipo = {
leadsNuevosSinAsignar: sinAsignar.filter((l) => l.estado.trim().toUpperCase() === 'NUEVO').length,
// Afluencias recién llegadas que el round-robin aún no reparte
leadsNuevosSinAsignar: sinAsignar.filter((o) => o.etapa.trim().toUpperCase() === 'AFLUENCIA').length,
porVendedor: [...conteoPorId.entries()]
.map(([mondayUserId, leadsAsignados]) => ({
mondayUserId,

View File

@@ -415,5 +415,241 @@ describe('MondayService', () => {
const mutacion = llamadas.find((l) => l.body.query.includes('change_column_value'))
expect(JSON.parse(String(mutacion?.body.variables['value']))).toEqual({ label: 'Nuevo' })
})
it('cambiarValorColumna resuelve el label de un DROPDOWN case-insensitive (los dropdown no auto-crean labels)', async () => {
const llamadas = mockearFetch((body) => {
if (body.query.includes('settings_str')) {
return respuestaOk({
boards: [{ columns: [{ id: 'dropdown_m', title: 'Motivo No Compra', settings_str: '{"labels":[{"name":"Precio"},{"name":"Timing"}]}' }] }],
})
}
if (body.query.includes('columns')) {
return respuestaOk({ boards: [{ columns: [{ id: 'dropdown_m', title: 'Motivo No Compra', type: 'dropdown' }] }] })
}
return respuestaOk({ change_column_value: { id: 'it-1' } })
})
await crearServicio().cambiarValorColumna('b1', 'it-1', 'Motivo No Compra', 'PRECIO')
const mutacion = llamadas.find((l) => l.body.query.includes('change_column_value'))
expect(JSON.parse(String(mutacion?.body.variables['value']))).toEqual({ labels: ['Precio'] })
})
})
// Columnas del board Oportunidades con los tipos reales (verificadas en Monday)
const COLUMNAS_OPORTUNIDADES = [
{ id: 'color_estado', title: 'Estado', type: 'status' },
{ id: 'date_fecha', title: 'Fecha', type: 'date' },
{ id: 'phone_ct', title: 'Cliente teléfono', type: 'phone' },
{ id: 'email_ce', title: 'Cliente email', type: 'email' },
{ id: 'color_des', title: 'Desarrollo', type: 'status' },
{ id: 'color_mod', title: 'Modelo de Interés', type: 'status' },
{ id: 'dropdown_or', title: 'Origen del Lead', type: 'dropdown' },
{ id: 'text_mc', title: 'Medio de Contacto', type: 'text' },
{ id: 'numeric_edad', title: 'Edad', type: 'numbers' },
{ id: 'dropdown_ec', title: 'Estado Civil', type: 'dropdown' },
{ id: 'color_te', title: 'Tipo de Empleo', type: 'status' },
{ id: 'text_ocu', title: 'Ocupación', type: 'text' },
{ id: 'text_rs', title: 'Rango Salarial', type: 'text' },
{ id: 'color_cr', title: 'Crédito', type: 'status' },
{ id: 'color_teco', title: 'Tipo de Econimia', type: 'status' }, // SIC: typo real
{ id: 'dropdown_cal', title: 'Lead Califica', type: 'dropdown' },
{ id: 'dropdown_lqp', title: 'Proposito de Compra', type: 'dropdown' },
{ id: 'long_com', title: 'COMENTARIOS', type: 'long_text' },
{ id: 'people_ase', title: 'Asesor', type: 'people' },
{ id: 'text_u1', title: 'Unidad', type: 'text' },
{ id: 'numeric_pf', title: 'Precio final', type: 'numbers' },
]
const itemOportunidad = (id: string, extras: { id: string; text: string; value?: string | null }[] = []) => ({
id,
name: `Cliente ${id}`,
created_at: '2026-08-20T10:00:00Z',
updated_at: '2026-08-21T10:00:00Z',
column_values: [
{ id: 'color_estado', text: 'Afluencia', value: '{"index":1}' },
{ id: 'phone_ct', text: '4771234567', value: null },
{ id: 'people_ase', text: '', value: '{"personsAndTeams":[{"id":106736727,"kind":"person"}]}' },
...extras.map((e) => ({ id: e.id, text: e.text, value: e.value ?? null })),
],
})
const mockTableroOportunidades = (items: unknown[]) =>
mockearFetch((body) => {
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_OPORTUNIDADES }] })
return respuestaOk({ boards: [{ items_page: { cursor: null, items } }] })
})
describe('getOportunidades / mapearOportunidad (afluencia en Oportunidades)', () => {
it('mapea asesor (people), contacto, origen y perfilamiento cuando existen en el board', async () => {
mockTableroOportunidades([
itemOportunidad('1', [
{ id: 'email_ce', text: 'a@b.com' },
{ id: 'dropdown_or', text: 'WhatsApp' },
{ id: 'color_des', text: 'ATELIER' },
{ id: 'color_mod', text: 'HIMALAYA' },
{ id: 'text_mc', text: 'Cabina' },
{ id: 'numeric_edad', text: '32' },
{ id: 'dropdown_ec', text: 'Soltero(a)' },
{ id: 'color_te', text: 'Asalariado' },
{ id: 'color_cr', text: 'Infonavit' },
{ id: 'color_teco', text: 'Economía mexicana' },
{ id: 'dropdown_cal', text: 'Sí' },
{ id: 'text_rs', text: '20-30 mil' },
{ id: 'dropdown_lqp', text: 'Vivir' },
{ id: 'text_u1', text: 'U-101' },
{ id: 'numeric_pf', text: '2500000' },
]),
])
const ops = await crearServicio().getOportunidades()
expect(ops[0]).toMatchObject({
id: '1',
etapa: 'Afluencia',
asesorIds: [106736727],
telefono: '4771234567',
email: 'a@b.com',
origen: 'WhatsApp',
desarrollo: 'ATELIER',
modeloInteres: 'HIMALAYA',
medioContacto: 'Cabina',
edad: 32,
estadoCivil: 'Soltero(a)',
tipoEmpleo: 'Asalariado',
credito: 'Infonavit',
tipoEconomia: 'Economía mexicana',
califica: 'Sí',
rangoSalarial: '20-30 mil',
loQuierePara: 'Vivir',
unidadCodigo: 'U-101',
precioFinal: 2500000,
updatedAt: '2026-08-21T10:00:00Z',
})
})
it('etapa con value null (default del tablero, nunca escrito) se lee como vacía', async () => {
mockTableroOportunidades([
// text trae el label default del board, pero value es null: no hay etapa real
itemOportunidad('1', [{ id: 'color_estado', text: 'Perdido', value: null }]),
])
// itemOportunidad ya pone color_estado con value; aquí el extra lo pisa en el mapa por id
const ops = await crearServicio().getOportunidades()
expect(ops[0]?.etapa).toBe('')
})
it('sin la columna Asesor devuelve asesorIds vacío (lectura tolerante)', async () => {
mockearFetch((body) => {
if (body.query.includes('columns')) {
return respuestaOk({ boards: [{ columns: COLUMNAS_OPORTUNIDADES.filter((c) => c.title !== 'Asesor') }] })
}
return respuestaOk({ boards: [{ items_page: { cursor: null, items: [itemOportunidad('1')] } }] })
})
const ops = await crearServicio().getOportunidades()
expect(ops[0]?.asesorIds).toEqual([])
})
it('buscarOportunidadPorTelefono: dedup por últimos 10 dígitos', async () => {
mockTableroOportunidades([itemOportunidad('1'), itemOportunidad('2', [{ id: 'phone_ct', text: '' }])])
const svc = crearServicio()
await expect(svc.buscarOportunidadPorTelefono('+52 477 123 4567')).resolves.toMatchObject({ id: '1' })
await expect(svc.buscarOportunidadPorTelefono('4770000000')).resolves.toBeNull()
await expect(svc.buscarOportunidadPorTelefono('123')).resolves.toBeNull() // menos de 10 dígitos
})
})
describe('asignarAsesorAOportunidad (columna people Asesor)', () => {
it('escribe personsAndTeams con el user id en el board Oportunidades (555 en tests)', async () => {
const llamadas = mockearFetch((body) => {
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_OPORTUNIDADES }] })
return respuestaOk({ change_column_value: { id: 'op-1' } })
})
await crearServicio().asignarAsesorAOportunidad('op-1', 12345)
const mutacion = llamadas.find((l) => l.body.query.includes('change_column_value'))
expect(mutacion?.body.variables['boardId']).toBe('555')
expect(mutacion?.body.variables['columnId']).toBe('people_ase')
expect(JSON.parse(String(mutacion?.body.variables['value']))).toEqual({
personsAndTeams: [{ id: 12345, kind: 'person' }],
})
})
})
describe('crearOportunidadAfluencia (captura completa en Oportunidades)', () => {
it('serializa cada campo según su tipo real (dropdown, status, phone, numbers, long_text, date)', async () => {
const llamadas = mockearFetch((body) => {
if (body.query.includes('settings_str')) {
// El resolver de labels consulta los settings de cada status/dropdown escrito
return respuestaOk({
boards: [
{
columns: COLUMNAS_OPORTUNIDADES
.filter((c) => c.type === 'status' || c.type === 'dropdown')
.map((c) => ({ id: c.id, title: c.title, settings_str: '{"labels":{"1":"Afluencia"}}' })),
},
],
})
}
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_OPORTUNIDADES }] })
return respuestaOk({ create_item: { id: 'op-nueva-1' } })
})
const id = await crearServicio().crearOportunidadAfluencia({
nombre: 'Lead Prueba',
telefono: '4771234567',
email: 'lead@correo.com',
desarrolloLabel: 'SENDA',
origenLabel: 'WhatsApp',
modeloLabel: 'HIMALAYA',
medioContacto: 'Cabina',
edad: 35,
estadoCivilLabel: 'Casado(a)',
tipoEmpleoLabel: 'Asalariado',
ocupacion: 'Ingeniero',
rangoSalarial: '$20,000 - $30,000',
creditoLabel: 'Infonavit',
tipoEconomiaLabel: 'Economía mexicana',
calificaLabel: 'Sí',
loQuierePara: 'Vivir',
notas: 'Vio el volante',
})
expect(id).toBe('op-nueva-1')
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(parsed['color_estado']).toEqual({ label: 'Afluencia' }) // nace en etapa Afluencia
expect(parsed['date_fecha']).toMatchObject({ date: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) }) // fecha de captura = hoy
expect(parsed['phone_ct']).toEqual({ phone: '4771234567', countryShortName: 'MX' })
expect(parsed['email_ce']).toEqual({ email: 'lead@correo.com', text: 'lead@correo.com' })
expect(parsed['dropdown_or']).toEqual({ labels: ['WhatsApp'] })
expect(parsed['dropdown_ec']).toEqual({ labels: ['Casado(a)'] })
expect(parsed['dropdown_cal']).toEqual({ labels: ['Sí'] })
expect(parsed['dropdown_lqp']).toEqual({ labels: ['Vivir'] })
expect(parsed['color_mod']).toEqual({ label: 'HIMALAYA' })
expect(parsed['color_teco']).toEqual({ label: 'Economía mexicana' }) // columna con typo 'Tipo de Econimia'
expect(parsed['numeric_edad']).toBe(35)
expect(parsed['text_mc']).toBe('Cabina')
expect(parsed['text_ocu']).toBe('Ingeniero')
expect(parsed['text_rs']).toBe('$20,000 - $30,000')
expect(parsed['long_com']).toEqual({ text: '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":"Afluencia"}}' }] }],
})
}
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_OPORTUNIDADES }] })
return respuestaOk({ create_item: { id: 'op-nueva-2' } })
})
await crearServicio().crearOportunidadAfluencia({ 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'])
})
})
})

View File

@@ -86,9 +86,27 @@ export interface OportunidadMonday {
id: string
nombre: string // nombre del cliente
createdAt: string // ISO
updatedAt: string // ISO (updated_at del item en Monday)
etapa: string // label de la columna status 'Estado'
unidadCodigo: string // columna text 'Unidad' ('' si no tiene)
precioFinal: number // columna numbers 'Precio final' (0 si no tiene)
asesorIds: number[] // user IDs asignados en la columna people 'Asesor'
telefono: string // columna phone 'Cliente teléfono' ('' si no tiene)
email: string // columna email 'Cliente email' ('' si no tiene)
origen: string // dropdown 'Origen del Lead' ('' si no tiene)
// Perfilamiento de la afluencia (alimenta las gráficas de /dash/); todos
// tolerantes: si la columna no existe en el board o viene vacía, quedan '' / null
desarrollo?: string // status 'Desarrollo'
modeloInteres?: string // status 'Modelo de Interés'
medioContacto?: string // text 'Medio de Contacto'
edad?: number | null // numbers 'Edad' (null si vacía o no numérica)
estadoCivil?: string // dropdown 'Estado Civil'
tipoEmpleo?: string // status 'Tipo de Empleo'
credito?: string // status 'Crédito'
tipoEconomia?: string // status 'Tipo de Econimia' (SIC: el título tiene typo en Monday)
califica?: string // dropdown 'Lead Califica'
rangoSalarial?: string // text 'Rango Salarial'
loQuierePara?: string // dropdown 'Proposito de Compra'
}
/** Usuario del workspace (para la lista rotativa de vendedores) */
@@ -371,17 +389,17 @@ export class MondayService {
}
/**
* Resuelve el label real de una columna status tolerando mayúsculas y
* minúsculas (el código escribe 'NUEVO' aunque el board diga 'Nuevo' tras
* un rename en la UI). Sin match devuelve el valor tal cual (Monday dirá
* que el label no existe, como antes).
* Resuelve el label real de una columna status/dropdown tolerando mayúsculas
* y minúsculas (el código escribe 'NUEVO' aunque el board diga 'Nuevo' tras
* un rename en la UI; los dropdown NO auto-crean labels al escribir). Sin
* match devuelve el valor tal cual (Monday dirá que el label no existe).
*/
private async resolverLabelStatus(
private async resolverLabel(
boardId: string,
col: ColumnaMeta,
valor: string | number | boolean,
): Promise<string | number | boolean> {
if (col.type !== 'status') return valor
if (col.type !== 'status' && col.type !== 'dropdown') return valor
const buscado = String(valor).trim().toUpperCase()
const labels = await this.labelsDeColumna(boardId, col.title)
return labels.find((l) => l.trim().toUpperCase() === buscado) ?? valor
@@ -395,7 +413,7 @@ export class MondayService {
for (const [titulo, crudo] of Object.entries(valores)) {
if (crudo === undefined || crudo === '') continue
const col = await this.columnaId(boardId, titulo)
out[col.id] = this.valorPorTipo(col, await this.resolverLabelStatus(boardId, col, crudo))
out[col.id] = this.valorPorTipo(col, await this.resolverLabel(boardId, col, crudo))
}
return JSON.stringify(out)
}
@@ -429,16 +447,33 @@ export class MondayService {
}
/**
* Oportunidad en etapa 'Afluencia' a partir de un lead recién capturado
* (entrada al funnel; sin unidad ni precios todavía).
* Oportunidad en etapa 'Afluencia' con todo el perfil de captura (la
* afluencia ES la oportunidad: no hay item aparte en el tablero Leads).
* La columna date 'Fecha' lleva la fecha de captura (hoy, hora local).
*/
async crearOportunidadAfluencia(datos: DatosOportunidadAfluencia): Promise<string> {
const boardId = config.monday.boards.oportunidades
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: 'Afluencia',
Fecha: fechaHoy,
'Cliente teléfono': datos.telefono,
'Cliente email': datos.email,
Desarrollo: datos.desarrolloLabel,
Estado: 'Afluencia',
'Modelo de Interés': datos.modeloLabel,
'Origen del Lead': datos.origenLabel,
'Medio de Contacto': datos.medioContacto,
Edad: datos.edad,
'Estado Civil': datos.estadoCivilLabel,
'Tipo de Empleo': datos.tipoEmpleoLabel,
Ocupación: datos.ocupacion,
'Rango Salarial': datos.rangoSalarial,
Crédito: datos.creditoLabel,
'Tipo de Econimia': datos.tipoEconomiaLabel, // SIC: typo en el título real de la columna
'Lead Califica': datos.calificaLabel,
'Proposito de Compra': datos.loQuierePara,
COMENTARIOS: datos.notas,
})
return this.crearItem(boardId, datos.nombre, columnValues)
}
@@ -561,7 +596,7 @@ export class MondayService {
valor: string | number | boolean,
): Promise<void> {
const col = await this.columnaId(boardId, tituloColumna)
const resuelto = await this.resolverLabelStatus(boardId, col, valor)
const resuelto = await this.resolverLabel(boardId, col, valor)
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 }
@@ -689,21 +724,6 @@ export class MondayService {
return items.map((item) => this.mapearLead(item, cols))
}
/** Un lead del tablero Leads por item ID (el payload del webhook no trae datos) */
async getLeadPorId(itemId: string): Promise<LeadMonday | null> {
const boardId = config.monday.boards.leads
const cols = await this.columnasDe(boardId)
const data = await this.graphql<{ items: (ItemConFechas | null)[] }>(
`query ($ids: [ID!]) {
items(ids: $ids) { id name created_at updated_at column_values { id text value } }
}`,
{ ids: [itemId] },
)
const item = data.items[0]
if (!item) return null
return this.mapearLead(item, cols)
}
private mapearLead(item: ItemConFechas, cols: Map<string, ColumnaMeta>): LeadMonday {
const colVendedor = cols.get('Vendedor')
if (!colVendedor || colVendedor.type !== 'people') {
@@ -761,16 +781,16 @@ export class MondayService {
}
/**
* Items del tablero Oportunidades con etapa, unidad y precio final
* (los consumen las automatizaciones Aparto/Ganado y los reportes).
* Items del tablero Oportunidades con etapa, unidad, precio final, asesor
* asignado y perfil de afluencia (los consumen las automatizaciones
* Aparto/Ganado, el round-robin, Mi día y los reportes).
*/
async getOportunidades(): Promise<OportunidadMonday[]> {
const boardId = config.monday.boards.oportunidades
const cols = await this.columnasDe(boardId)
const colEstado = cols.get('Estado')
if (!colEstado) throw new AppError(500, `Columna "Estado" no existe en el tablero Oportunidades (${boardId})`)
const colUnidad = cols.get('Unidad')
const colPrecio = cols.get('Precio final')
if (!cols.has('Estado')) {
throw new AppError(500, `Columna "Estado" no existe en el tablero Oportunidades (${boardId})`)
}
const items: ItemConFechas[] = []
let cursor: string | null = null
@@ -805,18 +825,68 @@ export class MondayService {
cursor = page.cursor
} while (cursor !== null)
return items.map((item) => {
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
const precio = colPrecio ? Number(porId.get(colPrecio.id)?.text ?? '') : NaN
return {
id: item.id,
nombre: item.name,
createdAt: item.created_at,
etapa: porId.get(colEstado.id)?.text ?? '',
unidadCodigo: colUnidad ? (porId.get(colUnidad.id)?.text ?? '') : '',
precioFinal: Number.isFinite(precio) ? precio : 0,
}
})
return items.map((item) => this.mapearOportunidad(item, cols))
}
private mapearOportunidad(item: ItemConFechas, cols: Map<string, ColumnaMeta>): OportunidadMonday {
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
// Lectura tolerante por título ('' si falta la columna o el valor)
const textoDe = (titulo: string): string => {
const col = cols.get(titulo)
return col ? (porId.get(col.id)?.text ?? '') : ''
}
// Misma corrección que mapearLead: cuando el status nunca se ha escrito,
// Monday devuelve value=null pero `text` muestra el default del tablero;
// la etapa real es vacía. Se lee `text` SOLO si hay value.
const colEstado = cols.get('Estado')
const estadoCv = colEstado ? porId.get(colEstado.id) : undefined
const etapa = estadoCv?.value ? (estadoCv.text ?? '') : ''
const colAsesor = cols.get('Asesor')
const precio = Number(textoDe('Precio final'))
const edadRaw = textoDe('Edad')
const edadNum = Number(edadRaw)
return {
id: item.id,
nombre: item.name,
createdAt: item.created_at,
updatedAt: item.updated_at,
etapa,
unidadCodigo: textoDe('Unidad'),
precioFinal: Number.isFinite(precio) ? precio : 0,
asesorIds: colAsesor ? this.peopleIds(porId.get(colAsesor.id)?.value) : [],
telefono: textoDe('Cliente teléfono'),
email: textoDe('Cliente email'),
origen: textoDe('Origen del Lead'),
desarrollo: textoDe('Desarrollo'),
modeloInteres: textoDe('Modelo de Interés'),
medioContacto: textoDe('Medio de Contacto'),
edad: edadRaw.trim() === '' || Number.isNaN(edadNum) ? null : edadNum,
estadoCivil: textoDe('Estado Civil'),
tipoEmpleo: textoDe('Tipo de Empleo'),
credito: textoDe('Crédito'),
tipoEconomia: textoDe('Tipo de Econimia'), // SIC: typo en el título real
califica: textoDe('Lead Califica'),
rangoSalarial: textoDe('Rango Salarial'),
loQuierePara: textoDe('Proposito de Compra'),
}
}
/**
* Oportunidad con el mismo teléfono (dedup de la captura de afluencia, que
* ahora vive en Oportunidades). Compara solo dígitos y los últimos 10
* (tolerante a lada/prefijo 52). null si no hay duplicado.
*/
async buscarOportunidadPorTelefono(telefono: string): Promise<OportunidadMonday | null> {
const digitos = telefono.replace(/\D/g, '')
if (digitos.length < 10) return null
const clave = digitos.slice(-10)
const oportunidades = await this.getOportunidades()
return (
oportunidades.find((o) => {
const d = o.telefono.replace(/\D/g, '')
return d.length >= 10 && d.slice(-10) === clave
}) ?? null
)
}
// ---------- Mi día (CRM ligero para vendedores) ----------
@@ -963,31 +1033,6 @@ export class MondayService {
}
}
/** Item en el tablero Leads (Estado NUEVO; captura rápida del vendedor) */
async crearLead(datos: DatosLead): Promise<string> {
const boardId = config.monday.boards.leads
const columnValues = await this.construirColumnValues(boardId, {
Estado: 'NUEVO',
Teléfono: datos.telefono,
Email: datos.email,
Origen: datos.origenLabel,
Desarrollo: datos.desarrolloLabel,
Notas: datos.notas,
'Modelo de Interés': datos.modeloLabel,
'Medio de Contacto': datos.medioContacto,
Edad: datos.edad,
'Estado Civil': datos.estadoCivilLabel,
'Tipo de Empleo': datos.tipoEmpleoLabel,
Ocupación: datos.ocupacion,
'Rango Salarial': datos.rangoSalarial,
Crédito: datos.creditoLabel,
'Tipo de Economía': datos.tipoEconomiaLabel,
Califica: datos.calificaLabel,
'Lo Quiere Para': datos.loQuierePara,
})
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.
@@ -1066,10 +1111,10 @@ export class MondayService {
return abiertas
}
/** Asigna un vendedor (user ID de Monday) a la columna people "Vendedor" de un lead */
async asignarVendedorALead(itemId: string, userId: number): Promise<void> {
const boardId = config.monday.boards.leads
const col = await this.columnaId(boardId, 'Vendedor')
/** 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')
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 }
@@ -1141,10 +1186,25 @@ export interface DatosOportunidad {
}
export interface DatosOportunidadAfluencia {
nombre: string // name del item = nombre del lead
nombre: string // name del item = nombre del interesado
telefono?: string | undefined
email?: string | undefined
desarrolloLabel?: string | undefined // label tal cual de la columna status 'Desarrollo'
// Campos de calificación de la afluencia (labels reales de las columnas del
// board Oportunidades; los dropdown no auto-crean labels, deben venir sembrados)
origenLabel?: string | undefined // dropdown 'Origen del Lead'
modeloLabel?: string | undefined // status 'Modelo de Interés'
medioContacto?: string | undefined // text 'Medio de Contacto'
edad?: number | undefined
estadoCivilLabel?: string | undefined // dropdown 'Estado Civil'
tipoEmpleoLabel?: string | undefined // status 'Tipo de Empleo'
ocupacion?: string | undefined // text 'Ocupación'
rangoSalarial?: string | undefined // text 'Rango Salarial'
creditoLabel?: string | undefined // status 'Crédito'
tipoEconomiaLabel?: string | undefined // status 'Tipo de Econimia' (SIC: typo real)
calificaLabel?: string | undefined // dropdown 'Lead Califica'
loQuierePara?: string | undefined // dropdown 'Proposito de Compra'
notas?: string | undefined // long_text 'COMENTARIOS'
}
export interface DatosContacto {
@@ -1195,27 +1255,6 @@ export interface DatosActividad {
leadItemId?: string // item del tablero Leads relacionado (se omite si no aplica)
}
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
// Campos de calificación del interesado (labels reales de las columnas status del board Leads)
modeloLabel?: string | undefined // label de la columna status 'Modelo de Interés' (modelo según desarrollo)
medioContacto?: string | undefined
edad?: number | undefined
estadoCivilLabel?: string | undefined
tipoEmpleoLabel?: string | undefined
ocupacion?: string | undefined
rangoSalarial?: string | undefined
creditoLabel?: string | undefined
tipoEconomiaLabel?: string | undefined
calificaLabel?: string | undefined
loQuierePara?: string | undefined
}
/** Singleton usado por los controllers (los tests instancian el suyo) */
export const mondayService = new MondayService()

View File

@@ -1,25 +1,30 @@
import { describe, expect, it, vi } from 'vitest'
import { construirResumen, ReportesService, type DatosResumen } from './reportes.service.js'
import type { LeadMonday, OportunidadMonday, Unidad } from './monday.service.js'
import type { OportunidadMonday, Unidad } from './monday.service.js'
import type { PlanPago } from './cobranza.service.js'
// ---------- Fixtures ----------
function lead(id: string, overrides: Partial<LeadMonday> = {}): LeadMonday {
/** Afluencia/oportunidad del board Oportunidades (fuente única de la sección leads) */
function afluencia(id: string, overrides: Partial<OportunidadMonday> = {}): OportunidadMonday {
return {
id,
nombre: `Lead ${id}`,
createdAt: '2026-08-20T10:00:00Z',
updatedAt: '2026-08-20T10:00:00Z',
estado: 'NUEVO',
etapa: 'Afluencia',
origen: 'Facebook',
vendedorIds: [],
unidadCodigo: '',
precioFinal: 0,
asesorIds: [],
telefono: '',
email: '',
...overrides,
}
}
function op(id: string, etapa: string): OportunidadMonday {
return { id, nombre: `Cliente ${id}`, createdAt: '2026-08-01T09:00:00Z', etapa, unidadCodigo: 'U-101', precioFinal: 0 }
return afluencia(id, { nombre: `Cliente ${id}`, createdAt: '2026-08-01T09:00:00Z', etapa, unidadCodigo: 'U-101' })
}
function unidad(codigo: string, desarrollo: string, status: string, etapa: string | null = null): Unidad {
@@ -63,37 +68,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 = { leads: [], oportunidades: [], unidades: [], planes: [], actividadesAbiertas: 0 }
const DATOS_BASE: DatosResumen = { oportunidades: [], unidades: [], planes: [], actividadesAbiertas: 0 }
// ---------- construirResumen (puro) ----------
describe('construirResumen', () => {
it('cuenta leads por estado y por origen', () => {
it('cuenta la afluencia por estado (Etapa) y por origen (Origen del Lead)', () => {
const r = construirResumen(
{
...DATOS_BASE,
leads: [
lead('1', { estado: 'NUEVO', origen: 'Facebook' }),
lead('2', { estado: 'NUEVO', origen: 'Facebook' }),
lead('3', { estado: 'CONTACTADO', origen: 'Referido' }),
lead('4', { estado: '', origen: '' }),
oportunidades: [
afluencia('1', { etapa: 'Afluencia', origen: 'Facebook' }),
afluencia('2', { etapa: 'Afluencia', origen: 'Facebook' }),
afluencia('3', { etapa: 'Contactado', origen: 'Referido' }),
afluencia('4', { etapa: '', origen: '' }),
],
},
AHORA,
)
expect(r.leads.total).toBe(4)
expect(r.leads.porEstado).toEqual({ NUEVO: 2, CONTACTADO: 1, '(sin dato)': 1 })
expect(r.leads.porEstado).toEqual({ Afluencia: 2, Contactado: 1, '(sin dato)': 1 })
expect(r.leads.porOrigen).toEqual({ Facebook: 2, Referido: 1, '(sin dato)': 1 })
})
it('cuenta leads nuevos de hoy y de los últimos 7 días (fecha local)', () => {
it('cuenta afluencias nuevas de hoy y de los últimos 7 días (fecha local)', () => {
const r = construirResumen(
{
...DATOS_BASE,
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
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
],
},
AHORA,
@@ -218,10 +223,10 @@ describe('construirResumen', () => {
const r = construirResumen(
{
...DATOS_BASE,
leads: [
lead('1', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', credito: 'Bancario', califica: 'Si' }),
lead('2', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', rangoSalarial: '20-30 mil' }),
lead('3', { estadoCivil: '', desarrollo: ' ' }), // sin dato: no cuenta en ninguna dimensión
oportunidades: [
afluencia('1', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', credito: 'Bancario', califica: 'Sí' }),
afluencia('2', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', rangoSalarial: '20-30 mil' }),
afluencia('3', { estadoCivil: '', desarrollo: ' ' }), // sin dato: no cuenta en ninguna dimensión
],
},
AHORA,
@@ -229,7 +234,7 @@ describe('construirResumen', () => {
expect(r.leads.perfil.porEstadoCivil).toEqual({ Soltero: 2 })
expect(r.leads.perfil.porDesarrollo).toEqual({ ATELIER: 2 })
expect(r.leads.perfil.porCredito).toEqual({ Bancario: 1 })
expect(r.leads.perfil.porCalifica).toEqual({ Si: 1 })
expect(r.leads.perfil.porCalifica).toEqual({ Sí: 1 })
expect(r.leads.perfil.porRangoSalarial).toEqual({ 'Hasta $30k MXN': 1 }) // '20-30 mil' → 25k
// Dimensiones sin ningún dato van como objeto vacío
expect(r.leads.perfil.porModelo).toEqual({})
@@ -243,16 +248,16 @@ describe('construirResumen', () => {
const r = construirResumen(
{
...DATOS_BASE,
leads: [
lead('1', { rangoSalarial: '16000' }), // Hasta $30k MXN
lead('2', { rangoSalarial: '$45K' }), // $30k–$60k MXN
lead('3', { rangoSalarial: '85,000.00 PESOS' }), // $60k–$100k MXN
lead('4', { rangoSalarial: '100,000.00-120,000.00' }), // Más de $100k MXN (promedio 110k)
lead('5', { rangoSalarial: '$2,5K dlls' }), // Hasta $3k USD (2,500)
lead('6', { rangoSalarial: '4,500 DLLS' }), // $3k–$5k USD
lead('7', { rangoSalarial: '8,000.000 DLLS' }), // Más de $5k USD (8,000)
lead('8', { rangoSalarial: 'NO QUISO DECIR' }), // sin monto: se salta
lead('9', { rangoSalarial: '' }), // vacío: se salta
oportunidades: [
afluencia('1', { rangoSalarial: '16000' }), // Hasta $30k MXN
afluencia('2', { rangoSalarial: '$45K' }), // $30k–$60k MXN
afluencia('3', { rangoSalarial: '85,000.00 PESOS' }), // $60k–$100k MXN
afluencia('4', { rangoSalarial: '100,000.00-120,000.00' }), // Más de $100k MXN (promedio 110k)
afluencia('5', { rangoSalarial: '$2,5K dlls' }), // Hasta $3k USD (2,500)
afluencia('6', { rangoSalarial: '4,500 DLLS' }), // $3k–$5k USD
afluencia('7', { rangoSalarial: '8,000.000 DLLS' }), // Más de $5k USD (8,000)
afluencia('8', { rangoSalarial: 'NO QUISO DECIR' }), // sin monto: se salta
afluencia('9', { rangoSalarial: '' }), // vacío: se salta
],
},
AHORA,
@@ -272,16 +277,16 @@ describe('construirResumen', () => {
const r = construirResumen(
{
...DATOS_BASE,
leads: [
lead('1', { edad: 18 }), // 15–24
lead('2', { edad: 24 }), // 15–24
lead('3', { edad: 25 }), // 25–34
lead('4', { edad: 39 }), // 35–44
lead('5', { edad: 50 }), // 45–54
lead('6', { edad: 55 }), // 55+
lead('7', { edad: 70 }), // 55+
lead('8', { edad: null }), // sin dato: se salta
lead('9'), // edad undefined: se salta
oportunidades: [
afluencia('1', { edad: 18 }), // 15–24
afluencia('2', { edad: 24 }), // 15–24
afluencia('3', { edad: 25 }), // 25–34
afluencia('4', { edad: 39 }), // 35–44
afluencia('5', { edad: 50 }), // 45–54
afluencia('6', { edad: 55 }), // 55+
afluencia('7', { edad: 70 }), // 55+
afluencia('8', { edad: null }), // sin dato: se salta
afluencia('9'), // edad undefined: se salta
],
},
AHORA,
@@ -295,14 +300,10 @@ describe('construirResumen', () => {
describe('ReportesService', () => {
function mondayMock() {
return {
llamadas: { leads: 0, oportunidades: 0, unidades: 0, actividades: 0 },
async getLeads(): Promise<LeadMonday[]> {
this.llamadas.leads++
return [lead('1')]
},
llamadas: { oportunidades: 0, unidades: 0, actividades: 0 },
async getOportunidades(): Promise<OportunidadMonday[]> {
this.llamadas.oportunidades++
return [op('1', 'Aparto')]
return [afluencia('1')]
},
async getUnidades(): Promise<Unidad[]> {
this.llamadas.unidades++
@@ -315,15 +316,16 @@ describe('ReportesService', () => {
}
}
it('agrega todas las fuentes en una sola respuesta', async () => {
it('agrega todas las fuentes en una sola respuesta (una sola llamada a Oportunidades)', 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)
expect(r.leads.total).toBe(1) // la sección leads se alimenta de Oportunidades
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
})
it('cachea 60 s: la segunda llamada no vuelve a Monday ni a Prisma', async () => {
@@ -336,14 +338,13 @@ describe('ReportesService', () => {
await svc.resumen()
await svc.resumen()
expect(monday.llamadas.leads).toBe(1)
expect(monday.llamadas.oportunidades).toBe(1)
expect(cargarPlanes).toHaveBeenCalledTimes(1)
// 61 s después el cache expira y se vuelve a consultar
vi.setSystemTime(new Date('2026-08-22T12:01:01'))
await svc.resumen()
expect(monday.llamadas.leads).toBe(2)
expect(monday.llamadas.oportunidades).toBe(2)
expect(cargarPlanes).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()

View File

@@ -1,11 +1,12 @@
import { statusPago, statusPlan, type PlanPago } from './cobranza.service.js'
import type { LeadMonday, MondayService, OportunidadMonday, Unidad } from './monday.service.js'
import type { 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: leads (Monday), oportunidades (Monday), unidades
* (Monday), cobranza (Prisma local) y actividades abiertas (Monday).
* 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).
* Cache en memoria de 60 s para no martillar la API de Monday.
*/
@@ -75,7 +76,8 @@ export interface ResumenReportes {
/** Datos crudos que necesita el agregador (inyectables en tests) */
export interface DatosResumen {
leads: LeadMonday[]
// La sección `leads` (afluencia + perfilamiento) y la sección `oportunidades`
// se alimentan de la MISMA fuente: el tablero Oportunidades
oportunidades: OportunidadMonday[]
unidades: Unidad[]
planes: PlanPago[] // dominio de cobranza (con pagos)
@@ -162,8 +164,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.leads.filter((l) => {
const t = Date.parse(l.createdAt)
datos.oportunidades.filter((o) => {
const t = Date.parse(o.createdAt)
return !Number.isNaN(t) && t >= desde.getTime()
}).length
@@ -219,29 +221,31 @@ 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')
leads: {
total: datos.leads.length,
total: datos.oportunidades.length,
nuevosHoy: creadosEn(hoy),
nuevosSemana: creadosEn(hace7dias),
porEstado: contarPor(datos.leads.map((l) => l.estado)),
porOrigen: contarPor(datos.leads.map((l) => l.origen)),
porEstado: contarPor(datos.oportunidades.map((o) => o.etapa)),
porOrigen: contarPor(datos.oportunidades.map((o) => o.origen)),
perfil: {
porDesarrollo: contarPorNoVacios(datos.leads.map((l) => l.desarrollo ?? '')),
porModelo: contarPorNoVacios(datos.leads.map((l) => l.modeloInteres ?? '')),
porEstadoCivil: contarPorNoVacios(datos.leads.map((l) => l.estadoCivil ?? '')),
porTipoEmpleo: contarPorNoVacios(datos.leads.map((l) => l.tipoEmpleo ?? '')),
porTipoEconomia: contarPorNoVacios(datos.leads.map((l) => l.tipoEconomia ?? '')),
porCalifica: contarPorNoVacios(datos.leads.map((l) => l.califica ?? '')),
porCredito: contarPorNoVacios(datos.leads.map((l) => l.credito ?? '')),
porDesarrollo: contarPorNoVacios(datos.oportunidades.map((o) => o.desarrollo ?? '')),
porModelo: contarPorNoVacios(datos.oportunidades.map((o) => o.modeloInteres ?? '')),
porEstadoCivil: contarPorNoVacios(datos.oportunidades.map((o) => o.estadoCivil ?? '')),
porTipoEmpleo: contarPorNoVacios(datos.oportunidades.map((o) => o.tipoEmpleo ?? '')),
porTipoEconomia: contarPorNoVacios(datos.oportunidades.map((o) => o.tipoEconomia ?? '')),
porCalifica: contarPorNoVacios(datos.oportunidades.map((o) => o.califica ?? '')),
porCredito: contarPorNoVacios(datos.oportunidades.map((o) => o.credito ?? '')),
porRangoSalarial: contarPorNoVacios(
datos.leads
.map((l) => bucketSalario(l.rangoSalarial ?? ''))
datos.oportunidades
.map((o) => bucketSalario(o.rangoSalarial ?? ''))
.filter((b): b is string => b !== null),
),
porMedioContacto: contarPorNoVacios(datos.leads.map((l) => l.medioContacto ?? '')),
porLoQuierePara: contarPorNoVacios(datos.leads.map((l) => l.loQuierePara ?? '')),
porMedioContacto: contarPorNoVacios(datos.oportunidades.map((o) => o.medioContacto ?? '')),
porLoQuierePara: contarPorNoVacios(datos.oportunidades.map((o) => o.loQuierePara ?? '')),
porEdad: contarPorNoVacios(
datos.leads.filter((l) => l.edad != null).map((l) => bucketEdad(l.edad!)),
datos.oportunidades.filter((o) => o.edad != null).map((o) => bucketEdad(o.edad!)),
),
},
},
@@ -273,7 +277,7 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor
// ---------- Servicio con cache ----------
type MondayReportes = Pick<MondayService, 'getLeads' | 'getOportunidades' | 'getUnidades' | 'getActividadesAbiertas'>
type MondayReportes = Pick<MondayService, 'getOportunidades' | 'getUnidades' | 'getActividadesAbiertas'>
/** Carga los planes de cobranza en forma de dominio (Prisma en prod, fixture en tests) */
export type CargadorPlanes = () => Promise<PlanPago[]>
@@ -309,14 +313,13 @@ 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 [leads, oportunidades, unidades, actividadesAbiertas, planes] = await Promise.all([
this.monday.getLeads(),
const [oportunidades, unidades, actividadesAbiertas, planes] = await Promise.all([
this.monday.getOportunidades(),
this.monday.getUnidades(),
this.monday.getActividadesAbiertas(),
this.cargarPlanes(),
])
const resumen = construirResumen({ leads, oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs))
const resumen = construirResumen({ oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs))
this.cache = { resumen, expira: ahoraMs + this.cacheMs }
return resumen
}