From f382987fbfc0b1d987624d7daee26f0103afd6c0 Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Thu, 3 Sep 2026 21:26:20 +0000 Subject: [PATCH] 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. --- .../src/controllers/leads.controller.test.ts | 45 ++++++++++++++++++- .../src/controllers/leads.controller.ts | 29 +++++++++++- .../services/automatizaciones.service.test.ts | 36 +++++++++++++++ .../src/services/automatizaciones.service.ts | 28 +++++++++++- .../src/components/AltaAfluenciaForm.test.tsx | 13 ++++++ .../src/components/AltaAfluenciaForm.tsx | 7 ++- apps/alta-clientes/src/services/api.ts | 2 + apps/mi-dia/src/pages/MiDiaPage.tsx | 10 ++++- apps/mi-dia/src/services/mi-dia.ts | 2 + 9 files changed, 166 insertions(+), 6 deletions(-) diff --git a/api-gateway/src/controllers/leads.controller.test.ts b/api-gateway/src/controllers/leads.controller.test.ts index b7c5b42..3c80ecf 100644 --- a/api-gateway/src/controllers/leads.controller.test.ts +++ b/api-gateway/src/controllers/leads.controller.test.ts @@ -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 { @@ -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 () => { diff --git a/api-gateway/src/controllers/leads.controller.ts b/api-gateway/src/controllers/leads.controller.ts index 4af197b..b0848d1 100644 --- a/api-gateway/src/controllers/leads.controller.ts +++ b/api-gateway/src/controllers/leads.controller.ts @@ -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 { 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 diff --git a/api-gateway/src/services/automatizaciones.service.ts b/api-gateway/src/services/automatizaciones.service.ts index d9d3b07..1eba479 100644 --- a/api-gateway/src/services/automatizaciones.service.ts +++ b/api-gateway/src/services/automatizaciones.service.ts @@ -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 { 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 { + 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). diff --git a/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx b/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx index 8af133d..1bc81b0 100644 --- a/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx +++ b/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx @@ -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( 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( undefined} onPasarAAltaCliente={() => undefined} />) diff --git a/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx b/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx index f681c55..ee8f3e8 100644 --- a/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx +++ b/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx @@ -62,6 +62,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro const [errores, setErrores] = useState({}) const [enviando, setEnviando] = useState(false) const [leadCreado, setLeadCreado] = useState(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'}