feat(leads): doble via de captura — lead rapido a Leads, afluencia a Oportunidades

- POST /api/leads: captura ligera al tablero Leads (sin oportunidad),
  self-assign a Vendedor o round-robin; tambien cubre la captura manual
  desde la UI de Monday (webhook create_item re-registrado).
- POST /api/afluencias (nuevo): la forma completa sigue cayendo en
  Oportunidades con todo el perfil (etapa Afluencia, columna Asesor).
- Round-robin dual (ambos tableros), Mi dia mezcla leads+oportunidades
  con insignia de origen, acciones con parametro fuente, reportes:
  leads desde Leads, perfilamiento desde Oportunidades, funnel
  Leads->Afluencia->...->Ganado.
This commit is contained in:
2026-09-04 06:09:38 +00:00
parent 772fdef09f
commit 135e3e6de5
21 changed files with 1053 additions and 326 deletions

View File

@@ -4,6 +4,8 @@ import {
buscarLeads,
buscarLeadsEndpoint,
cambiarEstadoLead,
crearAfluencia,
crearAfluenciaEndpoint,
crearLead,
crearLeadEndpoint,
descartarLead,
@@ -42,23 +44,32 @@ function mondayMock(
labelsOrigen?: string[]
labelsDesarrollo?: string[]
labelsMotivo?: string[]
duplicado?: OportunidadMonday | null
duplicadoLead?: LeadMonday | null
duplicadoOportunidad?: 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) {
return opts.duplicadoLead ?? null
},
async buscarOportunidadPorTelefono(_telefono: string) {
return opts.duplicado ?? null
return opts.duplicadoOportunidad ?? null
},
async labelsDeColumna(_boardId: string, titulo: string) {
if (titulo === 'Desarrollo') return opts.labelsDesarrollo ?? LABELS_DESARROLLO
@@ -93,6 +104,19 @@ function mondayMock(
const datosBase = { nombre: 'Lead Prueba', telefono: '4771234567' }
const leadMonday = (overrides: Partial<LeadMonday>): LeadMonday => ({
id: 'lead-1',
nombre: 'Lead Prueba',
createdAt: '2026-08-01T00:00:00Z',
updatedAt: '2026-08-01T00:00:00Z',
estado: 'Nuevo',
origen: '',
telefono: '',
email: '',
vendedorIds: [],
...overrides,
})
const oportunidadMonday = (overrides: Partial<OportunidadMonday>): OportunidadMonday => ({
id: 'op-1',
nombre: 'Lead Prueba',
@@ -108,10 +132,57 @@ const oportunidadMonday = (overrides: Partial<OportunidadMonday>): OportunidadMo
...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 () => {
describe('crearLead (POST /api/leads) — captura rápida en el tablero Leads', () => {
it('crea el item en Leads (ligero, SIN oportunidad) y devuelve su id', 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([
{
nombre: 'Lead Prueba',
telefono: '4771234567',
email: undefined,
origenLabel: 'Facebook',
desarrolloLabel: undefined,
notas: 'Vio el volante',
},
])
expect(monday.oportunidadesCreadas).toEqual([])
expect(monday.movimientosGrupo).toEqual([]) // el grupo lo arregla el webhook de change
})
it('pasa email y desarrollo al item de Monday cuando vienen en la captura', async () => {
const monday = mondayMock()
const r = await crearLead(monday, { ...datosBase, 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' })
})
it('duplicado por teléfono contra Leads: no crea nada y devuelve el item existente', async () => {
const existente = leadMonday({ id: 'lead-viejo-9', telefono: '4771234567' })
const monday = mondayMock({ duplicadoLead: existente })
const r = await crearLead(monday, datosBase)
expect(r).toEqual({ duplicado: { id: 'lead-viejo-9', nombre: 'Lead Prueba' } })
expect(monday.leadsCreados).toEqual([])
})
it('rechaza un desarrollo que no sea label real de la columna Desarrollo (board Leads)', async () => {
const monday = mondayMock()
await expect(crearLead(monday, { ...datosBase, desarrollo: 'MONTEBELLO' })).rejects.toThrow(/Desarrollo inválido/)
expect(monday.leadsCreados).toEqual([])
})
it('rechaza un origen que no sea label real de la columna Origen (board Leads)', async () => {
const monday = mondayMock()
await expect(crearLead(monday, { ...datosBase, origen: 'Volanteo' })).rejects.toThrow(/Origen inválido/)
expect(monday.leadsCreados).toEqual([])
})
})
describe('crearAfluencia (POST /api/afluencias) — 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 crearAfluencia(monday, { ...datosBase, origen: 'Facebook', notas: 'Vio el volante' })
expect(r).toEqual({ id: 'op-nueva-1', oportunidadId: 'op-nueva-1' })
expect(monday.oportunidadesCreadas).toEqual([
{
@@ -134,24 +205,14 @@ describe('crearLead (POST /api/leads) — la afluencia ES la oportunidad', () =>
notas: 'Vio el volante',
},
])
expect(monday.leadsCreados).toEqual([]) // NO se crea item en Leads
// 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 () => {
const monday = mondayMock()
const r = await crearLead(monday, {
...datosBase,
email: 'lead@correo.com',
desarrollo: '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 () => {
const monday = mondayMock()
const r = await crearLead(monday, {
const r = await crearAfluencia(monday, {
...datosBase,
modelo: 'HIMALAYA',
medioContacto: 'WhatsApp',
@@ -183,9 +244,9 @@ describe('crearLead (POST /api/leads) — la afluencia ES la oportunidad', () =>
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 r = await crearLead(monday, datosBase)
expect(r).toEqual({ duplicado: existente })
const monday = mondayMock({ duplicadoOportunidad: existente })
const r = await crearAfluencia(monday, datosBase)
expect(r).toEqual({ duplicado: { id: 'op-vieja-9', nombre: 'Lead Prueba' } })
expect(monday.oportunidadesCreadas).toEqual([])
expect(monday.movimientosGrupo).toEqual([])
})
@@ -194,14 +255,14 @@ describe('crearLead (POST /api/leads) — la afluencia ES la oportunidad', () =>
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)
const r = await crearAfluencia(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(
await expect(crearAfluencia(monday, { ...datosBase, desarrollo: 'MONTEBELLO' })).rejects.toThrow(
/Desarrollo inválido/,
)
expect(monday.oportunidadesCreadas).toEqual([])
@@ -209,7 +270,7 @@ describe('crearLead (POST /api/leads) — la afluencia ES la oportunidad', () =>
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/)
await expect(crearAfluencia(monday, { ...datosBase, origen: 'Volanteo' })).rejects.toThrow(/Origen inválido/)
expect(monday.oportunidadesCreadas).toEqual([])
})
})
@@ -217,18 +278,18 @@ describe('crearLead (POST /api/leads) — la afluencia ES la oportunidad', () =>
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'])
// Perdido tiene su propio endpoint (lleva motivo)
// El descarte tiene su propio endpoint (lleva motivo)
expect(ESTADOS_LEAD_PERMITIDOS).not.toContain('DESCARTADO')
expect(ESTADOS_LEAD_PERMITIDOS).not.toContain('NUEVO')
})
it('escribe la columna Estado en Oportunidades (555 en tests) con el label real del board', async () => {
it('default (fuente oportunidad): escribe Estado en Oportunidades (555) con el label real del board', async () => {
const monday = mondayMock()
await cambiarEstadoLead(monday, 'op-1', 'CONTACTADO')
expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Contactado' }])
})
it('AGENDA CITA se traduce al label "Agendo Cita" y con fecha también setea la columna Fecha', async () => {
it('AGENDA CITA se traduce a "Agendo Cita" (Oportunidades) y con fecha también setea la columna Fecha', async () => {
const monday = mondayMock()
await cambiarEstadoLead(monday, 'op-1', 'AGENDA CITA', '2026-09-01')
expect(monday.cambios).toEqual([
@@ -243,15 +304,28 @@ describe('cambiarEstadoLead (POST /api/leads/:itemId/estado)', () => {
expect(monday.cambios).toHaveLength(1)
})
it('CALIFICADO se escribe como "Calificado" (label real del board)', async () => {
it('fuente lead: escribe en el tablero Leads (444) con labels title case ("Agenda Cita")', async () => {
const monday = mondayMock()
await cambiarEstadoLead(monday, 'op-1', 'CALIFICADO')
expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-1', columna: 'Estado', valor: 'Calificado' }])
await cambiarEstadoLead(monday, 'lead-1', 'AGENDA CITA', '2026-09-01', 'lead')
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' },
])
})
it('fuente lead: CONTACTADO / CALIFICADO van title case al board Leads', async () => {
const monday = mondayMock()
await cambiarEstadoLead(monday, 'lead-1', 'CONTACTADO', undefined, 'lead')
await cambiarEstadoLead(monday, 'lead-1', 'CALIFICADO', undefined, 'lead')
expect(monday.cambios).toEqual([
{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'Contactado' },
{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'Calificado' },
])
})
})
describe('descartarLead (POST /api/leads/:itemId/descartar)', () => {
it('Estado Perdido + Motivo No Compra + COMENTARIOS + registro en Motivos No Compra', async () => {
it('default (oportunidad): Estado Perdido + Motivo No Compra + COMENTARIOS + registro en catálogo', async () => {
const monday = mondayMock()
const motivoItemId = await descartarLead(monday, 'op-1', 'Precio', 'Se le hizo caro')
expect(motivoItemId).toBe('motivo-item-1')
@@ -275,6 +349,27 @@ describe('descartarLead (POST /api/leads/:itemId/descartar)', () => {
expect(monday.motivos[0]).toEqual({ nombre: 'Timing — Lead Prueba', categoria: 'Timing' })
})
it('fuente lead: Estado Descartado + Notas (sin dropdown Motivo) + registro en catálogo', async () => {
const monday = mondayMock()
const motivoItemId = await descartarLead(monday, 'lead-1', 'Precio', 'Se le hizo caro', 'lead')
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' },
])
expect(monday.cambios.find((c) => c.columna === 'Motivo No Compra')).toBeUndefined()
expect(monday.motivos).toEqual([
{ nombre: 'Precio — Lead Prueba', categoria: 'Precio', descripcion: 'Se le hizo caro' },
])
})
it('fuente lead sin comentario no escribe Notas', async () => {
const monday = mondayMock()
await descartarLead(monday, 'lead-1', 'Timing', undefined, 'lead')
expect(monday.cambios).toEqual([{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'Descartado' }])
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'))
@@ -292,6 +387,102 @@ describe('descartarLead (POST /api/leads/:itemId/descartar)', () => {
})
describe('crearLeadEndpoint (POST /api/leads)', () => {
beforeEach(() => {
vi.clearAllMocks()
// El singleton Monday responde como si creara el lead en el tablero Leads
vi.spyOn(mondayService, 'buscarLeadPorTelefono').mockResolvedValue(null)
vi.spyOn(mondayService, 'crearLead').mockResolvedValue('lead-nuevo-9')
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.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({})
})
function reqFake(overrides: Partial<Request> = {}): Request {
return {
body: { nombre: 'Lead Prueba', telefono: '4771234567' },
usuarioToken: { sub: 'u-1', username: 'asesor uno', rol: 'vendedor' },
...overrides,
} as Request
}
function resFake() {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
it('201: registra la captura local (sin oportunidad) 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: null,
usuarioId: 'u-1',
nombreLead: 'Lead Prueba',
telefono: '4771234567',
},
})
expect(prismaMock.leadOportunidad.create).not.toHaveBeenCalled() // los leads ya no generan oportunidad
expect(status).toHaveBeenCalledWith(201)
expect(json).toHaveBeenCalledWith({ id: 'lead-nuevo-9', estado: 'NUEVO', oportunidadId: null, asignacion: 'round-robin' })
})
it('con Monday vinculado, el lead queda asignado al capturista (columna Vendedor del tablero Leads)', async () => {
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 })
const { res, status, json } = resFake()
await crearLeadEndpoint(reqFake(), res)
// 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(automatizacionesService.asignarACapturista).toHaveBeenCalledWith('lead-nuevo-9', 'Lead Prueba', 12345, 'leads')
expect(status).toHaveBeenCalledWith(201)
expect(json).toHaveBeenCalledWith({ id: 'lead-nuevo-9', estado: 'NUEVO', oportunidadId: null, asignacion: 'capturista' })
})
it('un fallo al asignar al capturista no rompe la captura (cae al round-robin)', async () => {
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 })
vi.spyOn(automatizacionesService, 'asignarACapturista').mockRejectedValue(new Error('Monday caído'))
const espia = vi.spyOn(console, 'error').mockImplementation(() => {})
const { res, status, json } = resFake()
await crearLeadEndpoint(reqFake(), res)
expect(status).toHaveBeenCalledWith(201)
expect(json).toHaveBeenCalledWith({ id: 'lead-nuevo-9', estado: 'NUEVO', oportunidadId: null, asignacion: 'round-robin' })
espia.mockRestore()
})
it('sin token (AUTH_ENABLED=false) no registra captura y responde 201 igual', async () => {
const { res, status } = resFake()
await crearLeadEndpoint(reqFake({ usuarioToken: undefined }), res)
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
expect(status).toHaveBeenCalledWith(201)
})
it('un fallo del registro local no rompe la respuesta (el lead ya quedó en Monday)', async () => {
prismaMock.leadCaptura.create.mockRejectedValue(new Error('SQLite bloqueado'))
const espia = vi.spyOn(console, 'error').mockImplementation(() => {})
const { res, status } = resFake()
await crearLeadEndpoint(reqFake(), res)
expect(status).toHaveBeenCalledWith(201)
espia.mockRestore()
})
})
describe('crearAfluenciaEndpoint (POST /api/afluencias)', () => {
beforeEach(() => {
vi.clearAllMocks()
// El singleton Monday responde como si creara la afluencia en Oportunidades
@@ -332,7 +523,7 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
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)
await crearAfluenciaEndpoint(reqFake(), res)
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: {
@@ -356,13 +547,13 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 })
const { res, status, json } = resFake()
await crearLeadEndpoint(reqFake(), res)
await crearAfluenciaEndpoint(reqFake(), res)
// Bandera anti-carrera ANTES de asignar + asignación al capturista
expect(prismaMock.automatizacionState.upsert).toHaveBeenCalledWith(
expect.objectContaining({ where: { clave: 'rr:skip:op-nueva-9' }, create: { clave: 'rr:skip:op-nueva-9', valor: 'capturista' } }),
)
expect(automatizacionesService.asignarACapturista).toHaveBeenCalledWith('op-nueva-9', 'Lead Prueba', 12345)
expect(automatizacionesService.asignarACapturista).toHaveBeenCalledWith('op-nueva-9', 'Lead Prueba', 12345, 'oportunidades')
expect(status).toHaveBeenCalledWith(201)
expect(json).toHaveBeenCalledWith({ id: 'op-nueva-9', estado: 'Afluencia', oportunidadId: 'op-nueva-9', asignacion: 'capturista' })
})
@@ -373,7 +564,7 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
const espia = vi.spyOn(console, 'error').mockImplementation(() => {})
const { res, status, json } = resFake()
await crearLeadEndpoint(reqFake(), res)
await crearAfluenciaEndpoint(reqFake(), res)
expect(status).toHaveBeenCalledWith(201)
expect(json).toHaveBeenCalledWith({ id: 'op-nueva-9', estado: 'Afluencia', oportunidadId: 'op-nueva-9', asignacion: 'round-robin' })
@@ -383,7 +574,7 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
it('sin token (AUTH_ENABLED=false) no registra captura y responde 201 igual', async () => {
const { res, status } = resFake()
await crearLeadEndpoint(reqFake({ usuarioToken: undefined }), res)
await crearAfluenciaEndpoint(reqFake({ usuarioToken: undefined }), res)
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
// El auto-mapping sí se escribe (no depende del usuario)
@@ -396,7 +587,7 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
const espia = vi.spyOn(console, 'error').mockImplementation(() => {})
const { res, status } = resFake()
await crearLeadEndpoint(reqFake(), res)
await crearAfluenciaEndpoint(reqFake(), res)
expect(status).toHaveBeenCalledWith(201)
espia.mockRestore()
@@ -404,24 +595,11 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
})
describe('buscarLeads (GET /api/leads/buscar) — búsqueda dual Leads + Oportunidades', () => {
const lead = (overrides: Partial<LeadMonday>): LeadMonday => ({
id: 'lead-1',
nombre: 'Lead Prueba',
createdAt: '2026-08-01T00:00:00Z',
updatedAt: '2026-08-01T00:00:00Z',
estado: 'NUEVO',
origen: '',
telefono: '',
email: '',
vendedorIds: [],
...overrides,
})
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: 'd', nombre: 'Otro', telefono: '333', email: '' }),
leadMonday({ id: 'a', nombre: 'MARÍA FERNANDA LÓPEZ' }),
leadMonday({ id: 'b', telefono: '477 123 4567' }),
leadMonday({ id: 'd', nombre: 'Otro', telefono: '333', email: '' }),
]
const ops = [
oportunidadMonday({ id: 'op-c', email: 'Cliente@Correo.com' }),
@@ -436,14 +614,14 @@ describe('buscarLeads (GET /api/leads/buscar) — búsqueda dual Leads + Oportun
})
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' })]
const leads = [leadMonday({ 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)
})
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` }),
leadMonday({ id: `l-${i}`, nombre: `Cliente Común ${i}`, updatedAt: `2026-08-${String(i + 1).padStart(2, '0')}T00:00:00Z` }),
)
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` }),

View File

@@ -9,20 +9,30 @@ import { MOTIVOS_NO_COMPRA } from './oportunidades.controller.js'
import { AppError } from '../utils/errors.js'
/**
* 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, y cada valor de la whitelist se traduce al label real del board.
* Captura y acciones del CRM ligero, sobre DOS tableros:
* - Tablero Leads (POST /api/leads): captura rápida de /hoy y captura manual
* desde la UI de Monday. Item ligero (nombre, teléfono, email, origen,
* desarrollo, notas). Los leads NO crean oportunidad.
* - Tablero Oportunidades (POST /api/afluencias): el Alta Afluencia (forma
* completa de /client) — la afluencia ES la oportunidad en etapa
* 'Afluencia', con todo el perfil.
* Las acciones del vendedor (/estado, /descartar) y la búsqueda del picker del
* alta conviven con ambos tableros vía el parámetro `fuente`.
* Los labels se validan/resuelven contra los reales de cada board (los
* dropdown y status de Monday no auto-crean labels al escribir).
*/
// 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
// reales de la columna status 'Estado' de cada board
const LABEL_ESTADO_LEADS: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], string> = {
CONTACTADO: 'Contactado',
'Intento de contacto': 'Intento de contacto',
'AGENDA CITA': 'Agenda Cita',
CALIFICADO: 'Calificado',
}
const LABEL_ESTADO_OPORTUNIDAD: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], string> = {
CONTACTADO: 'Contactado',
'Intento de contacto': 'Intento de contacto',
@@ -30,6 +40,11 @@ const LABEL_ESTADO_OPORTUNIDAD: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number],
CALIFICADO: 'Calificado',
}
export type FuenteLead = 'lead' | 'oportunidad'
const fuenteSchema = z.enum(['lead', 'oportunidad']).optional()
/** Captura rápida de lead (tablero Leads): solo los campos ligeros */
const crearLeadSchema = z.object({
nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
telefono: z.string().trim().min(1).optional(),
@@ -37,6 +52,16 @@ const crearLeadSchema = z.object({
origen: z.string().trim().min(1).optional(),
desarrollo: z.string().trim().min(1).optional(),
notas: z.string().trim().min(1).optional(),
})
/** Alta Afluencia (tablero Oportunidades): captura con perfil completo */
const crearAfluenciaSchema = z.object({
nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
telefono: z.string().trim().min(1).optional(),
email: z.string().trim().email('Email inválido').optional(),
origen: z.string().trim().min(1).optional(),
desarrollo: z.string().trim().min(1).optional(),
notas: z.string().trim().min(1).optional(),
// Campos de calificación de la afluencia (opcionales; los labels fijos del
// front coinciden con los sembrados en Monday, no se validan contra el board)
modelo: z.string().trim().min(1).optional(),
@@ -58,11 +83,15 @@ const cambiarEstadoSchema = z.object({
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe ser YYYY-MM-DD')
.optional(),
// Tablero del item: 'lead' = tablero Leads; 'oportunidad' = Oportunidades
// (default por compat: las afluencias nuevas viven ahí)
fuente: fuenteSchema,
})
const descartarSchema = z.object({
motivo: z.enum(MOTIVOS_NO_COMPRA),
comentario: z.string().trim().min(1).optional(),
fuente: fuenteSchema,
})
// ---------- Búsqueda dual (prellenado del alta de clientes) ----------
@@ -76,7 +105,7 @@ 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) */
/** Resultado del picker del alta: conviven Leads (captura ligera) y Oportunidades (alta afluencia) */
export interface ResultadoBusquedaLead {
id: string
nombre: string
@@ -84,13 +113,13 @@ export interface ResultadoBusquedaLead {
email: string
estado: string
origen: string
fuente: 'lead' | 'oportunidad'
fuente: FuenteLead
}
/**
* 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.
* AMBOS tableros — Leads y Oportunidades —, ordena por actualización reciente
* y limita a 15 resultados combinados.
*/
export function buscarLeads(leads: LeadMonday[], oportunidades: OportunidadMonday[], q: string): ResultadoBusquedaLead[] {
const needle = sinAcentos(q)
@@ -126,8 +155,10 @@ export function buscarLeads(leads: LeadMonday[], oportunidades: OportunidadMonda
type MondayLeads = Pick<
MondayService,
| 'cambiarValorColumna'
| 'crearLead'
| 'crearOportunidadAfluencia'
| 'crearMotivoNoCompra'
| 'buscarLeadPorTelefono'
| 'buscarOportunidadPorTelefono'
| 'labelsDeColumna'
| 'obtenerNombreItem'
@@ -136,7 +167,51 @@ type MondayLeads = Pick<
| 'moverItemAGrupo'
>
export type ResultadoCrearLead = { id: string; oportunidadId: string } | { duplicado: OportunidadMonday }
export type ResultadoCrearLead =
| { id: string; oportunidadId: string | null }
| { duplicado: { id: string; nombre: string } }
/**
* Crea un lead en el tablero Leads (Estado NUEVO + Fecha de hoy; captura
* rápida ligera, SIN oportunidad). Si viene teléfono y ya existe un lead con
* ese número, no crea nada y devuelve { duplicado } (el endpoint → 409).
* Origen y desarrollo se validan contra los labels reales del board Leads.
*/
export async function crearLead(
monday: MondayLeads,
datos: z.infer<typeof crearLeadSchema>,
): Promise<ResultadoCrearLead> {
const boardLeads = config.monday.boards.leads
if (datos.telefono !== undefined) {
const existente = await monday.buscarLeadPorTelefono(datos.telefono)
if (existente) return { duplicado: { id: existente.id, nombre: existente.nombre } }
}
if (datos.origen !== undefined) {
const labels = await monday.labelsDeColumna(boardLeads, 'Origen')
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')
if (!labels.includes(datos.desarrollo)) {
throw new AppError(400, `Desarrollo inválido: "${datos.desarrollo}". Labels válidos: ${labels.join(', ')}`)
}
}
const id = await monday.crearLead({
nombre: datos.nombre,
telefono: datos.telefono,
email: datos.email,
origenLabel: datos.origen,
desarrolloLabel: datos.desarrollo,
notas: datos.notas,
})
return { id, oportunidadId: null }
}
/**
* Crea la afluencia en Monday: UN item en el tablero Oportunidades con Estado
@@ -148,15 +223,15 @@ export type ResultadoCrearLead = { id: string; oportunidadId: string } | { dupli
* 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(
export async function crearAfluencia(
monday: MondayLeads,
datos: z.infer<typeof crearLeadSchema>,
datos: z.infer<typeof crearAfluenciaSchema>,
): Promise<ResultadoCrearLead> {
const boardOportunidades = config.monday.boards.oportunidades
if (datos.telefono !== undefined) {
const existente = await monday.buscarOportunidadPorTelefono(datos.telefono)
if (existente) return { duplicado: existente }
if (existente) return { duplicado: { id: existente.id, nombre: existente.nombre } }
}
if (datos.origen !== undefined) {
@@ -202,43 +277,60 @@ export async function crearLead(
return { id, oportunidadId: id }
}
/** Cambia el estado de la afluencia (whitelist → label real del board); en AGENDA CITA puede setear la Fecha */
/**
* Cambia el estado del item (whitelist → label real del board correspondiente
* según `fuente`); en AGENDA CITA puede setear la columna Fecha.
*/
export async function cambiarEstadoLead(
monday: MondayLeads,
itemId: string,
estado: (typeof ESTADOS_LEAD_PERMITIDOS)[number],
fecha?: string,
fuente: FuenteLead = 'oportunidad',
): Promise<void> {
const boardOportunidades = config.monday.boards.oportunidades
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', LABEL_ESTADO_OPORTUNIDAD[estado])
const esLead = fuente === 'lead'
const boardId = esLead ? config.monday.boards.leads : config.monday.boards.oportunidades
const label = esLead ? LABEL_ESTADO_LEADS[estado] : LABEL_ESTADO_OPORTUNIDAD[estado]
await monday.cambiarValorColumna(boardId, itemId, 'Estado', label)
if (estado === 'AGENDA CITA' && fecha !== undefined) {
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Fecha', fecha)
await monday.cambiarValorColumna(boardId, itemId, 'Fecha', fecha)
}
}
/**
* 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.
* Descarte del item según `fuente`:
* - 'oportunidad': Estado 'Perdido' + dropdown Motivo No Compra (label ya
* sembrado; si no existiera se omite) + comentario en COMENTARIOS.
* - 'lead': Estado 'Descartado' + comentario en Notas.
* Ambos registran el motivo en el tablero catálogo Motivos No Compra.
*/
export async function descartarLead(
monday: MondayLeads,
itemId: string,
motivo: string,
comentario?: string,
fuente: FuenteLead = 'oportunidad',
): Promise<string> {
const boardOportunidades = config.monday.boards.oportunidades
const nombreItem = await monday.obtenerNombreItem(itemId)
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', 'Perdido')
if (comentario !== undefined) {
await monday.cambiarValorColumna(boardOportunidades, itemId, 'COMENTARIOS', comentario)
}
const labels = await monday.labelsDeColumna(boardOportunidades, 'Motivo No Compra')
if (labels.includes(motivo)) {
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Motivo No Compra', motivo)
if (fuente === 'lead') {
const boardLeads = config.monday.boards.leads
await monday.cambiarValorColumna(boardLeads, itemId, 'Estado', 'Descartado')
if (comentario !== undefined) {
await monday.cambiarValorColumna(boardLeads, itemId, 'Notas', comentario)
}
} else {
// eslint-disable-next-line no-console
console.log(`El motivo "${motivo}" no es label del dropdown 'Motivo No Compra'; se omite`)
const boardOportunidades = config.monday.boards.oportunidades
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', 'Perdido')
if (comentario !== undefined) {
await monday.cambiarValorColumna(boardOportunidades, itemId, 'COMENTARIOS', comentario)
}
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}`,
@@ -247,7 +339,42 @@ export async function descartarLead(
})
}
/** POST /api/leads { nombre, telefono?, email?, origen?, notas? } */
/**
* Self-assign del capturista con bandera anti-carrera rr:skip (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).
* Devuelve cómo quedó la asignación.
*/
async function asignarCapturistaSiAplica(
usuarioId: string | undefined,
itemId: string,
nombre: string,
board: 'leads' | 'oportunidades',
): Promise<'capturista' | 'round-robin'> {
if (usuarioId === undefined) return 'round-robin'
try {
const capturista = await prisma.usuario.findUnique({
where: { id: usuarioId },
select: { mondayUserId: true },
})
if (capturista?.mondayUserId != null) {
await prismaStateStore.set(`${CLAVE_RR_SKIP}${itemId}`, 'capturista')
try {
await automatizacionesService.asignarACapturista(itemId, nombre, capturista.mondayUserId, board)
return 'capturista'
} catch (err) {
await prismaStateStore.set(`${CLAVE_RR_SKIP}${itemId}`, 'round-robin')
throw err
}
}
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo asignar el item ${itemId} al capturista:`, err)
}
return 'round-robin'
}
/** POST /api/leads { nombre, telefono?, email?, origen?, desarrollo?, notas? } — captura rápida (tablero Leads) */
export async function crearLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = crearLeadSchema.parse(req.body)
const resultado = await crearLead(mondayService, body)
@@ -260,9 +387,43 @@ 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). 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.
// no guarda quién dio de alta el lead). Un fallo aquí no rompe la respuesta:
// el lead ya quedó creado en Monday.
if (req.usuarioToken?.sub) {
try {
await prisma.leadCaptura.create({
data: {
leadId: resultado.id,
oportunidadId: null, // los leads ya no generan oportunidad
usuarioId: req.usuarioToken.sub,
nombreLead: body.nombre,
telefono: body.telefono ?? null,
},
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo registrar la captura del lead ${resultado.id}:`, err)
}
}
const asignacion = await asignarCapturistaSiAplica(req.usuarioToken?.sub, resultado.id, body.nombre, 'leads')
res.status(201).json({ id: resultado.id, estado: 'NUEVO', oportunidadId: null, asignacion })
}
/** POST /api/afluencias { nombre, telefono?, ..., perfil completo } — Alta Afluencia (tablero Oportunidades) */
export async function crearAfluenciaEndpoint(req: Request, res: Response): Promise<void> {
const body = crearAfluenciaSchema.parse(req.body)
const resultado = await crearAfluencia(mondayService, body)
if ('duplicado' in resultado) {
res.status(409).json({
error: 'Ya existe un lead con ese teléfono',
leadId: resultado.duplicado.id,
nombre: resultado.duplicado.nombre,
})
return
}
// Registro local del capturista para "Mis capturas" del panel Mi día.
// leadId = el id de la oportunidad (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({
@@ -276,7 +437,7 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo registrar la captura del lead ${resultado.id}:`, err)
console.error(`No se pudo registrar la captura de la afluencia ${resultado.id}:`, err)
}
}
// Auto-mapping leadId = oportunidadId en LeadOportunidad: el alta de
@@ -284,62 +445,36 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
// 'Aparto' tanto en ligas históricas como en el modelo nuevo.
try {
await prisma.leadOportunidad.create({
data: { leadId: resultado.id, oportunidadId: resultado.oportunidadId },
data: { leadId: resultado.id, oportunidadId: resultado.oportunidadId ?? resultado.id },
})
} 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 {
const capturista = await prisma.usuario.findUnique({
where: { id: req.usuarioToken.sub },
select: { mondayUserId: true },
})
if (capturista?.mondayUserId != null) {
await prismaStateStore.set(`${CLAVE_RR_SKIP}${resultado.id}`, 'capturista')
try {
await automatizacionesService.asignarACapturista(resultado.id, body.nombre, capturista.mondayUserId)
asignacion = 'capturista'
} catch (err) {
await prismaStateStore.set(`${CLAVE_RR_SKIP}${resultado.id}`, 'round-robin')
throw err
}
}
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo asignar la afluencia ${resultado.id} al capturista:`, err)
}
}
const asignacion = await asignarCapturistaSiAplica(req.usuarioToken?.sub, resultado.id, body.nombre, 'oportunidades')
res.status(201).json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, asignacion })
}
/** POST /api/leads/:itemId/estado { estado, fecha? } */
/** POST /api/leads/:itemId/estado { estado, fecha?, fuente? } */
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)
await cambiarEstadoLead(mondayService, itemId, body.estado, body.fecha, body.fuente)
res.json({ itemId, estado: body.estado, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
}
/** POST /api/leads/:itemId/descartar { motivo, comentario? } */
/** POST /api/leads/:itemId/descartar { motivo, comentario?, fuente? } */
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)
res.json({ itemId, estado: 'Perdido', motivo: body.motivo, motivoItemId })
const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario, body.fuente)
res.json({ itemId, estado: body.fuente === 'lead' ? 'Descartado' : 'Perdido', motivo: body.motivo, motivoItemId })
}
/**
* GET /api/leads/buscar?q=texto — autocompletado para prellenar el alta de
* clientes. Busca en AMBOS tableros (Leads histórico + Oportunidades); q
* mínimo 2 caracteres; devuelve máximo 15, cada uno con su `fuente`.
* clientes. Busca en AMBOS tableros (Leads + 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()

View File

@@ -64,25 +64,25 @@ describe('webhookMonday', () => {
expect(mocks.escaneoOportunidades).not.toHaveBeenCalled()
})
it('create_item en Oportunidades dispara el round-robin del item', () => {
it('create_item en Oportunidades dispara el round-robin del item (tablero oportunidades)', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 222, itemId: 999 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.runRoundRobin).toHaveBeenCalledWith('999')
expect(mocks.runRoundRobin).toHaveBeenCalledWith('999', 'oportunidades')
})
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: 222, pulseId: 777 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.runRoundRobin).toHaveBeenCalledWith('777')
expect(mocks.runRoundRobin).toHaveBeenCalledWith('777', 'oportunidades')
})
it('create_item en Leads ya NO dispara nada (tablero archivo histórico)', () => {
it('create_item en Leads dispara el round-robin del tablero Leads (SIN crear oportunidad)', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 111, itemId: 999 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.runRoundRobin).not.toHaveBeenCalled()
expect(mocks.runRoundRobin).toHaveBeenCalledWith('999', 'leads')
})
it('create_item en otro board no dispara round-robin', () => {

View File

@@ -45,6 +45,7 @@ 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
@@ -53,26 +54,26 @@ 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.
// 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) {
// Item nuevo en Leads u Oportunidades → solo round-robin del tablero
// correspondiente (los leads NO crean oportunidad; el Alta Afluencia entra
// directo a Oportunidades vía POST /api/afluencias). 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.
if ((type === 'create_item' || type === 'create_pulse') && (esBoardLeads || esBoardOportunidades) && idEvento !== undefined) {
const id = String(idEvento)
const board = esBoardLeads ? 'leads' : 'oportunidades'
// Fire-and-forget: la respuesta 200 no espera a que termine la asignación
void automatizacionesService
.runRoundRobin(id)
.runRoundRobin(id, board)
.then((r) => {
// eslint-disable-next-line no-console
console.log(`[webhook monday] round-robin item=${id}: ${r.asignados} asignado(s)`)
console.log(`[webhook monday] round-robin ${board} item=${id}: ${r.asignados} asignado(s)`)
})
.catch((err: unknown) => {
// eslint-disable-next-line no-console
console.error(`[webhook monday] error en round-robin item=${id}:`, err)
console.error(`[webhook monday] error en round-robin ${board} item=${id}:`, err)
})
}