feat(leads): la captura desde una app se asigna al capturista
POST /api/leads con sesion y Monday vinculado asigna el lead a quien lo registro (mas su actividad de contacto) en vez del round-robin; sin vinculo o sin token sigue el reparto. Bandera rr:skip para que el webhook create_item no pise la asignacion durante la propagacion de Monday (la carrera se observo en el smoke). Mensajes de exito en /hoy y /client reflejan a quien quedo asignado.
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
} from './leads.controller.js'
|
||||
import { mondayService, type LeadMonday } from '../services/monday.service.js'
|
||||
import { funnelService } from '../services/funnel.service.js'
|
||||
import { automatizacionesService } from '../services/automatizaciones.service.js'
|
||||
|
||||
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
|
||||
|
||||
@@ -18,6 +19,13 @@ const prismaMock = vi.hoisted(() => ({
|
||||
leadCaptura: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
usuario: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
automatizacionState: {
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
@@ -326,6 +334,11 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
|
||||
vi.spyOn(mondayService, 'buscarLeadPorTelefono').mockResolvedValue(null)
|
||||
vi.spyOn(mondayService, 'crearLead').mockResolvedValue('lead-nuevo-9')
|
||||
vi.spyOn(funnelService, 'asegurarOportunidadAfluencia').mockResolvedValue('op-9')
|
||||
vi.spyOn(automatizacionesService, 'asignarACapturista').mockResolvedValue(undefined)
|
||||
// Default: el capturista NO tiene Monday vinculado → asignación round-robin
|
||||
prismaMock.usuario.findUnique.mockResolvedValue(null)
|
||||
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
|
||||
prismaMock.automatizacionState.upsert.mockResolvedValue({})
|
||||
})
|
||||
|
||||
function reqFake(overrides: Partial<Request> = {}): Request {
|
||||
@@ -358,7 +371,37 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
|
||||
},
|
||||
})
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(json).toHaveBeenCalledWith({ id: 'lead-nuevo-9', estado: 'NUEVO', oportunidadId: 'op-9' })
|
||||
expect(json).toHaveBeenCalledWith({ id: 'lead-nuevo-9', estado: 'NUEVO', oportunidadId: 'op-9', asignacion: 'round-robin' })
|
||||
})
|
||||
|
||||
it('con Monday vinculado, el lead queda asignado al capturista (no al round-robin)', async () => {
|
||||
prismaMock.leadCaptura.create.mockResolvedValue({})
|
||||
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)
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(json).toHaveBeenCalledWith({ id: 'lead-nuevo-9', estado: 'NUEVO', oportunidadId: 'op-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(() => {})
|
||||
const { res, status, json } = resFake()
|
||||
|
||||
await crearLeadEndpoint(reqFake(), res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(json).toHaveBeenCalledWith({ id: 'lead-nuevo-9', estado: 'NUEVO', oportunidadId: 'op-9', asignacion: 'round-robin' })
|
||||
espia.mockRestore()
|
||||
})
|
||||
|
||||
it('sin token (AUTH_ENABLED=false) no registra captura y responde 201 igual', async () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { 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'
|
||||
@@ -256,7 +257,33 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
|
||||
console.error(`No se pudo registrar la captura del lead ${resultado.id}:`, err)
|
||||
}
|
||||
}
|
||||
res.status(201).json({ id: resultado.id, estado: 'NUEVO', oportunidadId: resultado.oportunidadId })
|
||||
// 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).
|
||||
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 el lead ${resultado.id} al capturista:`, err)
|
||||
}
|
||||
}
|
||||
res.status(201).json({ id: resultado.id, estado: 'NUEVO', oportunidadId: resultado.oportunidadId, asignacion })
|
||||
}
|
||||
|
||||
/** POST /api/leads/:itemId/estado { estado, fecha? } */
|
||||
|
||||
@@ -225,6 +225,17 @@ describe('AutomatizacionesService.runRoundRobin', () => {
|
||||
expect(monday.asignaciones).toEqual([{ itemId: '1', userId: 101 }])
|
||||
})
|
||||
|
||||
it('omite leads con bandera rr:skip=capturista (los asigna su capturista)', async () => {
|
||||
const monday = mondayMock([lead('1'), lead('2')])
|
||||
const state = stateEnMemoria()
|
||||
state.mapa.set('rr:skip:1', 'capturista') // el capturista lo asigna
|
||||
state.mapa.set('rr:skip:2', 'round-robin') // bandera liberada: sí entra al reparto
|
||||
const svc = crearServicio(monday, state)
|
||||
const r = await svc.runRoundRobin()
|
||||
expect(r.asignados).toBe(1)
|
||||
expect(monday.asignaciones).toEqual([{ itemId: '2', userId: 101 }])
|
||||
})
|
||||
|
||||
it('con soloItemId solo evalúa ese item (ruta del webhook create_item)', async () => {
|
||||
const monday = mondayMock([lead('1'), lead('2')])
|
||||
const svc = crearServicio(monday, stateEnMemoria())
|
||||
@@ -326,6 +337,31 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => {
|
||||
expect(monday.actividades).toEqual([])
|
||||
})
|
||||
|
||||
it('asignarACapturista: asigna al capturista y le crea su actividad de contacto', async () => {
|
||||
const monday = mondayMock([])
|
||||
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
|
||||
|
||||
await svc.asignarACapturista('lead-77', 'Lead Propio', 555)
|
||||
|
||||
expect(monday.asignaciones).toEqual([{ itemId: 'lead-77', userId: 555 }])
|
||||
expect(monday.actividades[0]).toMatchObject({
|
||||
nombre: 'Contactar a Lead Propio',
|
||||
responsableId: 555,
|
||||
estadoLabel: 'Abierto',
|
||||
leadItemId: 'lead-77',
|
||||
})
|
||||
})
|
||||
|
||||
it('asignarACapturista con actividadesEnabled=false solo asigna', async () => {
|
||||
const monday = mondayMock([])
|
||||
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: false })
|
||||
|
||||
await svc.asignarACapturista('lead-78', 'Lead Propio', 555)
|
||||
|
||||
expect(monday.asignaciones).toEqual([{ itemId: 'lead-78', userId: 555 }])
|
||||
expect(monday.actividades).toEqual([])
|
||||
})
|
||||
|
||||
it('inactividad: crea "Retomar contacto" al vendedor asignado; sin vendedor no crea nada', async () => {
|
||||
const monday = mondayMock([
|
||||
lead('1', { updatedAt: '2026-08-01T00:00:00Z', vendedorIds: [103] }), // inactivo con vendedor
|
||||
|
||||
@@ -29,6 +29,8 @@ export const ETAPA_GANADO = 'GANADO'
|
||||
const CLAVE_RR_INDICE = 'rr:indice'
|
||||
const CLAVE_RR_VENDEDORES = 'rr:vendedores'
|
||||
const CLAVE_RR_ULTIMA_PASADA = 'rr:ultimaPasada'
|
||||
/** Prefijo de la bandera "este lead lo asigna su capturista, no el round-robin" */
|
||||
export const CLAVE_RR_SKIP = 'rr:skip:'
|
||||
const CLAVE_INACTIVIDAD_ULTIMO_ESCANEO = 'inactividad:ultimoEscaneo'
|
||||
const CLAVE_ESCALADO_ULTIMO_ESCANEO = 'escalado:ultimoEscaneo'
|
||||
const CLAVE_OPORTUNIDADES_ULTIMO_ESCANEO = 'oportunidades:ultimoEscaneo'
|
||||
@@ -270,12 +272,19 @@ export class AutomatizacionesService {
|
||||
*/
|
||||
async runRoundRobin(soloItemId?: string): Promise<ResultadoRoundRobin> {
|
||||
const leads = await this.monday.getLeads()
|
||||
const pendientes = leads.filter(
|
||||
const sinAsignar = leads.filter(
|
||||
(l) =>
|
||||
l.vendedorIds.length === 0 &&
|
||||
!esEstadoTerminal(l.estado) &&
|
||||
(soloItemId === undefined || l.id === soloItemId),
|
||||
)
|
||||
// Los leads capturados desde una app con sesión se asignan a su capturista
|
||||
// (bandera rr:skip='capturista'): sin este filtro, la latencia de Monday
|
||||
// hacía que el webhook los viera sin vendedor y pisara la asignación.
|
||||
const pendientes: typeof sinAsignar = []
|
||||
for (const l of sinAsignar) {
|
||||
if ((await this.state.get(`${CLAVE_RR_SKIP}${l.id}`)) !== 'capturista') pendientes.push(l)
|
||||
}
|
||||
if (pendientes.length === 0) {
|
||||
await this.state.set(CLAVE_RR_ULTIMA_PASADA, new Date().toISOString())
|
||||
return { asignados: 0, detalle: [] }
|
||||
@@ -306,6 +315,23 @@ export class AutomatizacionesService {
|
||||
return { asignados: detalle.length, detalle }
|
||||
}
|
||||
|
||||
/**
|
||||
* Asigna el lead a quien lo capturó desde una app del ecosistema (su usuario
|
||||
* Monday vinculado) y le crea la actividad de contacto — el mismo paquete
|
||||
* que haría el round-robin, pero para el capturista. No hay carrera con el
|
||||
* webhook create_item: runRoundRobin solo toma leads sin persona asignada.
|
||||
*/
|
||||
async asignarACapturista(leadId: string, leadNombre: string, vendedorId: number): Promise<void> {
|
||||
await this.monday.asignarVendedorALead(leadId, vendedorId)
|
||||
if (this.actividadesEnabled) {
|
||||
await this.crearActividadSegura({
|
||||
nombre: `Contactar a ${leadNombre}`,
|
||||
responsableId: vendedorId,
|
||||
leadItemId: leadId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escaneo de inactividad. Con `preview: true` no escribe nada en Monday ni en
|
||||
* el estado (sirve para el endpoint de preview).
|
||||
|
||||
@@ -65,6 +65,19 @@ describe('AltaAfluenciaForm', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('con asignacion "capturista" el mensaje dice que quedó asignado a ti', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
respuesta({ id: 'l-1', estado: 'NUEVO', oportunidadId: 'op-1', asignacion: 'capturista' }),
|
||||
)
|
||||
render(<AltaAfluenciaForm onVolver={() => undefined} onPasarAAltaCliente={() => undefined} />)
|
||||
llenarRequeridos()
|
||||
fireEvent.click(screen.getByRole('button', { name: /Registrar en afluencia/ }))
|
||||
|
||||
expect((await screen.findByRole('status')).textContent).toContain(
|
||||
'Registrado en afluencia — asignado a ti',
|
||||
)
|
||||
})
|
||||
|
||||
it('renderiza los selects de calificación con las opciones de los catálogos', () => {
|
||||
render(<AltaAfluenciaForm onVolver={() => undefined} onPasarAAltaCliente={() => undefined} />)
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro
|
||||
const [errores, setErrores] = useState<Errores>({})
|
||||
const [enviando, setEnviando] = useState(false)
|
||||
const [leadCreado, setLeadCreado] = useState<LeadResumen | null>(null)
|
||||
const [asignacion, setAsignacion] = useState<'capturista' | 'round-robin' | null>(null)
|
||||
const [aviso, setAviso] = useState('')
|
||||
|
||||
const cambiar = (campo: keyof typeof FORM_INICIAL) => (e: { target: { value: string } }) => {
|
||||
@@ -122,6 +123,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro
|
||||
estado: res.lead.estado,
|
||||
origen: form.origen,
|
||||
})
|
||||
setAsignacion(res.lead.asignacion ?? 'round-robin')
|
||||
} else if (res.motivo === 'duplicado') {
|
||||
setAviso(`Ya existe un lead con ese teléfono: ${res.nombre}.`)
|
||||
} else {
|
||||
@@ -134,6 +136,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro
|
||||
setErrores({})
|
||||
setAviso('')
|
||||
setLeadCreado(null)
|
||||
setAsignacion(null)
|
||||
}
|
||||
|
||||
if (leadCreado !== null) {
|
||||
@@ -143,7 +146,9 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro
|
||||
role="status"
|
||||
className="rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700"
|
||||
>
|
||||
Registrado en afluencia — se asignará por round-robin
|
||||
{asignacion === 'capturista'
|
||||
? 'Registrado en afluencia — asignado a ti'
|
||||
: 'Registrado en afluencia — se asignará por round-robin'}
|
||||
</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<button
|
||||
|
||||
@@ -239,6 +239,8 @@ export interface LeadCreado {
|
||||
id: string
|
||||
estado: string
|
||||
oportunidadId: string | null
|
||||
/** 'capturista' = asignado a quien lo registró; 'round-robin' = entra al reparto */
|
||||
asignacion?: 'capturista' | 'round-robin'
|
||||
}
|
||||
|
||||
/** Resultado del alta de afluencia: distingue duplicado (409) de fallo de red */
|
||||
|
||||
@@ -235,7 +235,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
setCapEnviando(true)
|
||||
setCapMensaje(null)
|
||||
try {
|
||||
await crearLead({
|
||||
const creado = await crearLead({
|
||||
nombre: capNombre.trim(),
|
||||
...(capTelefono.trim() !== '' ? { telefono: capTelefono.trim() } : {}),
|
||||
...(capEmail.trim() !== '' ? { email: capEmail.trim() } : {}),
|
||||
@@ -243,7 +243,13 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
...(capDesarrollo !== '' ? { desarrollo: capDesarrollo } : {}),
|
||||
...(capNotas.trim() !== '' ? { notas: capNotas.trim() } : {}),
|
||||
})
|
||||
setCapMensaje({ tipo: 'ok', texto: 'Lead registrado, se asignará por round-robin' })
|
||||
setCapMensaje({
|
||||
tipo: 'ok',
|
||||
texto:
|
||||
creado.asignacion === 'capturista'
|
||||
? 'Lead registrado y asignado a ti'
|
||||
: 'Lead registrado, se asignará por round-robin',
|
||||
})
|
||||
setCapNombre('')
|
||||
setCapTelefono('')
|
||||
setCapEmail('')
|
||||
|
||||
@@ -21,6 +21,8 @@ export function fetchResumenMiDia(): Promise<ResumenMiDia> {
|
||||
export interface LeadCreado {
|
||||
id: string
|
||||
estado: string
|
||||
/** 'capturista' = asignado a quien lo registró; 'round-robin' = entra al reparto */
|
||||
asignacion?: 'capturista' | 'round-robin'
|
||||
}
|
||||
|
||||
/** Crea un lead; en duplicado (409) lanza Error con el mensaje del API */
|
||||
|
||||
Reference in New Issue
Block a user