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.