diff --git a/api-gateway/src/controllers/leads.controller.test.ts b/api-gateway/src/controllers/leads.controller.test.ts index 736417d..e50fa13 100644 --- a/api-gateway/src/controllers/leads.controller.test.ts +++ b/api-gateway/src/controllers/leads.controller.test.ts @@ -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[], oportunidadesCreadas: [] as Record[], 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) { + this.leadsCreados.push(datos) + return 'lead-nuevo-1' + }, async crearOportunidadAfluencia(datos: Record) { 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 => ({ + 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 => ({ id: 'op-1', nombre: 'Lead Prueba', @@ -108,10 +132,57 @@ const oportunidadMonday = (overrides: Partial): 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 { + 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 => ({ - 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` }), diff --git a/api-gateway/src/controllers/leads.controller.ts b/api-gateway/src/controllers/leads.controller.ts index cc6bdec..ea61905 100644 --- a/api-gateway/src/controllers/leads.controller.ts +++ b/api-gateway/src/controllers/leads.controller.ts @@ -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, +): Promise { + 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, + datos: z.infer, ): Promise { 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 { - 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 { - 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 { 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 { + 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 { 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 { 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 { const q = String(req.query['q'] ?? '').trim() diff --git a/api-gateway/src/controllers/webhooks.controller.test.ts b/api-gateway/src/controllers/webhooks.controller.test.ts index 65d88e3..0a062ec 100644 --- a/api-gateway/src/controllers/webhooks.controller.test.ts +++ b/api-gateway/src/controllers/webhooks.controller.test.ts @@ -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', () => { diff --git a/api-gateway/src/controllers/webhooks.controller.ts b/api-gateway/src/controllers/webhooks.controller.ts index d537df9..4189945 100644 --- a/api-gateway/src/controllers/webhooks.controller.ts +++ b/api-gateway/src/controllers/webhooks.controller.ts @@ -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) }) } diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts index 8d1321c..1daf3ea 100644 --- a/api-gateway/src/routes/index.ts +++ b/api-gateway/src/routes/index.ts @@ -47,6 +47,7 @@ import { perderOportunidadEndpoint } from '../controllers/oportunidades.controll import { correrRecordatorios, listarRecordatorios } from '../controllers/recordatorios.controller.js' import { cambiarEstadoLeadEndpoint, + crearAfluenciaEndpoint, crearLeadEndpoint, buscarLeadsEndpoint, descartarLeadEndpoint, @@ -140,6 +141,9 @@ apiRouter.post('/leads', crearLeadEndpoint) apiRouter.post('/leads/:itemId/estado', cambiarEstadoLeadEndpoint) apiRouter.post('/leads/:itemId/descartar', descartarLeadEndpoint) +// Alta Afluencia (forma completa de /client): vive en el tablero Oportunidades +apiRouter.post('/afluencias', crearAfluenciaEndpoint) + apiRouter.get('/monday/usuarios', listarUsuariosMondayEndpoint) apiRouter.patch('/usuarios/me/monday', actualizarVinculoMondayEndpoint) diff --git a/api-gateway/src/services/automatizaciones.service.test.ts b/api-gateway/src/services/automatizaciones.service.test.ts index 17e1250..dd84e03 100644 --- a/api-gateway/src/services/automatizaciones.service.test.ts +++ b/api-gateway/src/services/automatizaciones.service.test.ts @@ -8,13 +8,14 @@ import { claveVentaCreada, esEstadoTerminal, esEtapa, + ESTADOS_TERMINALES_LEAD, oportunidadesInactivas, oportunidadesSinContactar, parseRrUserIds, vendedorEnTurno, type StateStore, } from './automatizaciones.service.js' -import type { OportunidadMonday, UsuarioMonday } from './monday.service.js' +import type { LeadMonday, OportunidadMonday, UsuarioMonday } from './monday.service.js' // ---------- Fixtures ---------- @@ -24,6 +25,21 @@ const VENDEDORES: UsuarioMonday[] = [ { id: 103, nombre: 'Carla', email: 'carla@x.com' }, ] +function lead(id: string, overrides: Partial = {}): LeadMonday { + return { + id, + nombre: `Lead ${id}`, + createdAt: '2026-08-01T09:00:00Z', + updatedAt: '2026-08-01T10:00:00Z', + estado: 'Nuevo', + origen: '', + vendedorIds: [], + telefono: '', + email: '', + ...overrides, + } +} + function oportunidad(id: string, overrides: Partial = {}): OportunidadMonday { return { id, @@ -55,10 +71,10 @@ function stateEnMemoria(): StateStore & { mapa: Map } { } } -/** Monday mockeado: oportunidades fijas, registra asignaciones, updates y actividades */ -function mondayMock(oportunidades: OportunidadMonday[] = []) { +/** Monday mockeado: leads y oportunidades fijos, registra asignaciones, updates y actividades */ +function mondayMock(leads: LeadMonday[] = [], oportunidades: OportunidadMonday[] = []) { return { - asignaciones: [] as { itemId: string; userId: number }[], + asignaciones: [] as { itemId: string; userId: number; tablero: string }[], updates: [] as { itemId: string; body: string }[], actividades: [] as { nombre: string @@ -70,11 +86,17 @@ function mondayMock(oportunidades: OportunidadMonday[] = []) { }[], apartados: [] as Record[], ventas: [] as Record[], + async getLeads() { + return leads + }, async getOportunidades() { return oportunidades }, + async asignarVendedorALead(itemId: string, userId: number) { + this.asignaciones.push({ itemId, userId, tablero: 'leads' }) + }, async asignarAsesorAOportunidad(itemId: string, userId: number) { - this.asignaciones.push({ itemId, userId }) + this.asignaciones.push({ itemId, userId, tablero: 'oportunidades' }) }, async crearUpdateEnItem(itemId: string, body: string) { this.updates.push({ itemId, body }) @@ -125,8 +147,8 @@ describe('parseRrUserIds', () => { }) }) -describe('esEstadoTerminal (etapas terminales de Oportunidades)', () => { - it('Ganado / Perdido / Abandono son terminales, sin importar mayúsculas ni espacios', () => { +describe('esEstadoTerminal (terminales por tablero)', () => { + it('Ganado / Perdido / Abandono son terminales en Oportunidades (default)', () => { expect(esEstadoTerminal('GANADO')).toBe(true) expect(esEstadoTerminal(' perdido ')).toBe(true) expect(esEstadoTerminal('Abandono')).toBe(true) @@ -135,6 +157,14 @@ describe('esEstadoTerminal (etapas terminales de Oportunidades)', () => { expect(esEstadoTerminal('Intento de contacto')).toBe(false) expect(esEstadoTerminal('')).toBe(false) }) + + it('Descartado / Calificado / Abandono son terminales en Leads (title case, case-insensitive)', () => { + expect(esEstadoTerminal('Descartado', ESTADOS_TERMINALES_LEAD)).toBe(true) + expect(esEstadoTerminal('calificado', ESTADOS_TERMINALES_LEAD)).toBe(true) + expect(esEstadoTerminal('ABANDONO', ESTADOS_TERMINALES_LEAD)).toBe(true) + expect(esEstadoTerminal('Nuevo', ESTADOS_TERMINALES_LEAD)).toBe(false) + expect(esEstadoTerminal('Contactado', ESTADOS_TERMINALES_LEAD)).toBe(false) + }) }) describe('vendedorEnTurno (rotación round-robin)', () => { @@ -188,18 +218,24 @@ describe('claveRecordatorio (dedup por día)', () => { // ---------- Servicio (monday mockeado, sin red) ---------- describe('AutomatizacionesService.runRoundRobin', () => { - it('asigna asesores en rotación a afluencias sin persona y persiste el índice', async () => { - const monday = mondayMock([oportunidad('1'), oportunidad('2'), oportunidad('3'), oportunidad('4')]) + it('asigna en rotación sobre AMBOS tableros y persiste el índice (Leads → Vendedor, Oportunidades → Asesor)', async () => { + const monday = mondayMock([lead('l1'), lead('l2')], [oportunidad('o1'), oportunidad('o2')]) const state = stateEnMemoria() const svc = crearServicio(monday, state) const r1 = await svc.runRoundRobin() expect(r1.asignados).toBe(4) - expect(monday.asignaciones.map((a) => a.userId)).toEqual([101, 102, 103, 101]) + expect(monday.asignaciones).toEqual([ + { itemId: 'l1', userId: 101, tablero: 'leads' }, + { itemId: 'l2', userId: 102, tablero: 'leads' }, + { itemId: 'o1', userId: 103, tablero: 'oportunidades' }, + { itemId: 'o2', userId: 101, tablero: 'oportunidades' }, + ]) + expect(r1.detalle.map((d) => d.tablero)).toEqual(['leads', 'leads', 'oportunidades', 'oportunidades']) expect(state.mapa.get('rr:indice')).toBe('1') // 4 asignaciones % 3 vendedores // Segunda pasada con una afluencia nueva: continúa donde quedó (no reinicia en 0) - const monday2 = mondayMock([oportunidad('5')]) + const monday2 = mondayMock([], [oportunidad('o5')]) const svc2 = new AutomatizacionesService(monday2, state) const r2 = await svc2.runRoundRobin() expect(r2.asignados).toBe(1) @@ -207,8 +243,25 @@ describe('AutomatizacionesService.runRoundRobin', () => { expect(state.mapa.get('rr:indice')).toBe('2') }) - it('solo reparte etapa Afluencia: Cotizador/Contactado NO entran al reparto', async () => { + it('Leads: reparte los sin Vendedor y no terminales (labels title case, case-insensitive)', async () => { const monday = mondayMock([ + lead('1'), + lead('2', { vendedorIds: [101] }), // ya asignado + lead('3', { estado: 'Descartado' }), // terminal + lead('4', { estado: 'Calificado' }), // terminal + lead('5', { estado: 'Contactado' }), // en seguimiento: sí entra + ]) + const svc = crearServicio(monday, stateEnMemoria()) + const r = await svc.runRoundRobin(undefined, 'leads') + expect(r.asignados).toBe(2) + expect(monday.asignaciones).toEqual([ + { itemId: '1', userId: 101, tablero: 'leads' }, + { itemId: '5', userId: 102, tablero: 'leads' }, + ]) + }) + + it('Oportunidades: solo reparte etapa Afluencia (Cotizador/Contactado NO entran)', async () => { + const monday = mondayMock([], [ oportunidad('1'), oportunidad('2', { asesorIds: [101] }), // ya asignada oportunidad('3', { etapa: 'Cotizador' }), // creada por el cotizador: no entra @@ -217,35 +270,43 @@ describe('AutomatizacionesService.runRoundRobin', () => { oportunidad('6', { etapa: 'afluencia' }), // case-insensitive: sí entra ]) const svc = crearServicio(monday, stateEnMemoria()) - const r = await svc.runRoundRobin() + const r = await svc.runRoundRobin(undefined, 'oportunidades') expect(r.asignados).toBe(2) expect(monday.asignaciones).toEqual([ - { itemId: '1', userId: 101 }, - { itemId: '6', userId: 102 }, + { itemId: '1', userId: 101, tablero: 'oportunidades' }, + { itemId: '6', userId: 102, tablero: 'oportunidades' }, ]) }) - it('omite afluencias con bandera rr:skip=capturista (las asigna su capturista)', async () => { - const monday = mondayMock([oportunidad('1'), oportunidad('2')]) + it('omite items con bandera rr:skip=capturista (los asigna su capturista)', async () => { + const monday = mondayMock([lead('1')], [oportunidad('2')]) const state = stateEnMemoria() - state.mapa.set('rr:skip:1', 'capturista') // el capturista la asigna + 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 }]) + expect(monday.asignaciones).toEqual([{ itemId: '2', userId: 101, tablero: 'oportunidades' }]) }) - it('con soloItemId solo evalúa ese item (ruta del webhook create_item)', async () => { - const monday = mondayMock([oportunidad('1'), oportunidad('2')]) + it('con soloItemId + board solo evalúa ese item (ruta de los webhooks create_item)', async () => { + const monday = mondayMock([lead('1'), lead('2')], [oportunidad('9')]) const svc = crearServicio(monday, stateEnMemoria()) - const r = await svc.runRoundRobin('2') + const r = await svc.runRoundRobin('2', 'leads') expect(r.asignados).toBe(1) - expect(monday.asignaciones).toEqual([{ itemId: '2', userId: 101 }]) + expect(monday.asignaciones).toEqual([{ itemId: '2', userId: 101, tablero: 'leads' }]) + }) + + it('con soloItemId de otro tablero no asigna nada', async () => { + const monday = mondayMock([lead('1')], [oportunidad('9')]) + const svc = crearServicio(monday, stateEnMemoria()) + const r = await svc.runRoundRobin('9', 'leads') // '9' no está en Leads + expect(r.asignados).toBe(0) + expect(monday.asignaciones).toEqual([]) }) it('cachea los vendedores del equipo en el estado (una sola llamada dentro del TTL)', async () => { - const monday = mondayMock([]) + const monday = mondayMock() const spy = vi.spyOn(monday, 'getUsuariosEquipo') const state = stateEnMemoria() const svc = crearServicio(monday, state) @@ -258,7 +319,7 @@ describe('AutomatizacionesService.runRoundRobin', () => { }) it('cache con formato viejo (arreglo sin ts) se descarta y re-consulta', async () => { - const monday = mondayMock([]) + const monday = mondayMock() const spy = vi.spyOn(monday, 'getUsuariosEquipo') const state = stateEnMemoria() state.mapa.set('rr:vendedores', JSON.stringify([{ id: 999, nombre: 'Viejo', email: '' }])) @@ -277,7 +338,7 @@ describe('AutomatizacionesService.escaneoInactividad', () => { ] it('preview: lista candidatos sin escribir en Monday ni en el estado', async () => { - const monday = mondayMock(oportunidadesViejas()) + const monday = mondayMock([], oportunidadesViejas()) const state = stateEnMemoria() const svc = crearServicio(monday, state) @@ -291,7 +352,7 @@ describe('AutomatizacionesService.escaneoInactividad', () => { }) it('ejecución real: crea update, marca dedup y no repite el mismo día', async () => { - const monday = mondayMock(oportunidadesViejas()) + const monday = mondayMock([], oportunidadesViejas()) const state = stateEnMemoria() const svc = crearServicio(monday, state) @@ -320,7 +381,7 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => { const ahora = new Date('2026-08-22T09:00:00Z') it('el round-robin crea "Contactar a {nombre}" tipo Llamada, Abierto, ligada a la oportunidad', async () => { - const monday = mondayMock([oportunidad('1'), oportunidad('2')]) + const monday = mondayMock([], [oportunidad('1'), oportunidad('2')]) const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true }) const r = await svc.runRoundRobin() @@ -343,7 +404,7 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => { }) it('con actividadesEnabled=false el round-robin no crea actividades', async () => { - const monday = mondayMock([oportunidad('1')]) + const monday = mondayMock([], [oportunidad('1')]) const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: false }) await svc.runRoundRobin() expect(monday.actividades).toEqual([]) @@ -355,7 +416,7 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => { await svc.asignarACapturista('op-77', 'Lead Propio', 555) - expect(monday.asignaciones).toEqual([{ itemId: 'op-77', userId: 555 }]) + expect(monday.asignaciones).toEqual([{ itemId: 'op-77', userId: 555, tablero: 'oportunidades' }]) expect(monday.actividades[0]).toMatchObject({ nombre: 'Contactar a Lead Propio', responsableId: 555, @@ -364,18 +425,28 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => { }) }) + it('asignarACapturista con board "leads" escribe la columna Vendedor del tablero Leads', async () => { + const monday = mondayMock([]) + const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true }) + + await svc.asignarACapturista('lead-77', 'Lead Propio', 555, 'leads') + + expect(monday.asignaciones).toEqual([{ itemId: 'lead-77', userId: 555, tablero: 'leads' }]) + expect(monday.actividades[0]).toMatchObject({ nombre: 'Contactar a Lead Propio', 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('op-78', 'Lead Propio', 555) - expect(monday.asignaciones).toEqual([{ itemId: 'op-78', userId: 555 }]) + expect(monday.asignaciones).toEqual([{ itemId: 'op-78', userId: 555, tablero: 'oportunidades' }]) expect(monday.actividades).toEqual([]) }) it('inactividad: crea "Retomar contacto" al asesor asignado; las candidatas siempre tienen asesor', async () => { - const monday = mondayMock([oportunidad('1', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [103] })]) + const monday = mondayMock([], [oportunidad('1', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [103] })]) const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true }) const r = await svc.escaneoInactividad({ preview: false, ahora }) @@ -391,21 +462,21 @@ describe('Actividades automáticas (ACTIVIDADES_ENABLED)', () => { }) it('preview de inactividad no crea actividades', async () => { - const monday = mondayMock([oportunidad('1', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [101] })]) + const monday = mondayMock([], [oportunidad('1', { updatedAt: '2026-08-01T00:00:00Z', asesorIds: [101] })]) const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true }) await svc.escaneoInactividad({ preview: true, ahora }) expect(monday.actividades).toEqual([]) }) it('un fallo al crear la actividad no rompe la asignación', async () => { - const monday = mondayMock([oportunidad('1')]) + const monday = mondayMock([], [oportunidad('1')]) vi.spyOn(monday, 'crearActividad').mockRejectedValueOnce(new Error('Monday caído')) const spyError = vi.spyOn(console, 'error').mockImplementation(() => {}) const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true }) const r = await svc.runRoundRobin() expect(r.asignados).toBe(1) // la asignación se hizo igual - expect(monday.asignaciones).toEqual([{ itemId: '1', userId: 101 }]) + expect(monday.asignaciones).toEqual([{ itemId: '1', userId: 101, tablero: 'oportunidades' }]) spyError.mockRestore() }) }) @@ -462,7 +533,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => { const anterior = configAuto['coordinadorUserId'] configAuto['coordinadorUserId'] = 0 try { - const monday = mondayMock([oportunidad('1', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' })]) + const monday = mondayMock([], [oportunidad('1', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' })]) const state = stateEnMemoria() const svc = new AutomatizacionesService(monday, state, { actividadesEnabled: true }) @@ -487,7 +558,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => { const anterior = configAuto['coordinadorUserId'] configAuto['coordinadorUserId'] = 999 try { - const monday = mondayMock([oportunidad('1', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' })]) + const monday = mondayMock([], [oportunidad('1', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' })]) const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true }) const r = await svc.escaneoEscalado({ ahora }) expect(r.escalados).toBe(1) @@ -502,7 +573,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => { const anterior = configAuto['coordinadorUserId'] configAuto['coordinadorUserId'] = 0 try { - const monday = mondayMock([oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-19T12:00:00Z' })]) + const monday = mondayMock([], [oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-19T12:00:00Z' })]) const state = stateEnMemoria() const svc = new AutomatizacionesService(monday, state, { actividadesEnabled: true }) @@ -523,7 +594,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => { configAuto['coordinadorUserId'] = 0 const spyError = vi.spyOn(console, 'error').mockImplementation(() => {}) try { - const monday = mondayMock([ + const monday = mondayMock([], [ oportunidad('1', { asesorIds: [101], updatedAt: '2026-08-19T12:00:00Z' }), oportunidad('2', { asesorIds: [102], updatedAt: '2026-08-19T12:00:00Z' }), ]) @@ -546,7 +617,7 @@ describe('AutomatizacionesService.escaneoEscalado', () => { describe('AutomatizacionesService.escaneoOportunidades', () => { it('una oportunidad en Aparto crea su item en Apartado (estado Pago Apartado)', async () => { - const monday = mondayMock([oportunidad('10', { etapa: 'Aparto' })]) + const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' })]) const state = stateEnMemoria() const svc = crearServicio(monday, state) @@ -562,7 +633,7 @@ describe('AutomatizacionesService.escaneoOportunidades', () => { }) it('una oportunidad en Ganado crea su item en Ventas con unidad y valor', async () => { - const monday = mondayMock([oportunidad('11', { etapa: 'GANADO' })]) + const monday = mondayMock([], [oportunidad('11', { etapa: 'GANADO' })]) const state = stateEnMemoria() const svc = crearServicio(monday, state) @@ -573,7 +644,7 @@ describe('AutomatizacionesService.escaneoOportunidades', () => { }) it('dedup: la segunda pasada no duplica apartados ni ventas', async () => { - const monday = mondayMock([oportunidad('10', { etapa: 'Aparto' }), oportunidad('11', { etapa: 'Ganado' })]) + const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' }), oportunidad('11', { etapa: 'Ganado' })]) const state = stateEnMemoria() const svc = crearServicio(monday, state) @@ -588,7 +659,7 @@ describe('AutomatizacionesService.escaneoOportunidades', () => { }) it('ignora oportunidades en otras etapas', async () => { - const monday = mondayMock([ + const monday = mondayMock([], [ oportunidad('10', { etapa: 'Cotizador' }), oportunidad('11', { etapa: 'Perdido' }), oportunidad('12', { etapa: 'Afluencia' }), @@ -602,7 +673,7 @@ describe('AutomatizacionesService.escaneoOportunidades', () => { }) it('un fallo de Monday en una oportunidad no detiene a las demás', async () => { - const monday = mondayMock([oportunidad('10', { etapa: 'Aparto' }), oportunidad('11', { etapa: 'Ganado' })]) + const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' }), oportunidad('11', { etapa: 'Ganado' })]) vi.spyOn(monday, 'crearApartado').mockRejectedValueOnce(new Error('Monday caído')) const state = stateEnMemoria() const svc = crearServicio(monday, state) diff --git a/api-gateway/src/services/automatizaciones.service.ts b/api-gateway/src/services/automatizaciones.service.ts index c795e9c..c797e71 100644 --- a/api-gateway/src/services/automatizaciones.service.ts +++ b/api-gateway/src/services/automatizaciones.service.ts @@ -1,12 +1,20 @@ import { config } from '../config/index.js' import { AppError } from '../utils/errors.js' -import { mondayService, type MondayService, type OportunidadMonday, type UsuarioMonday } from './monday.service.js' +import { + mondayService, + type LeadMonday, + type MondayService, + type OportunidadMonday, + type UsuarioMonday, +} from './monday.service.js' import { prisma } from './prisma.js' /** - * Automatizaciones sobre el tablero Oportunidades de Monday (la afluencia ES - * la oportunidad en etapa 'Afluencia'): - * (a) asignación round-robin de asesores a afluencias sin persona asignada + * Automatizaciones sobre Monday: + * (a) asignación round-robin en AMBOS tableros del embudo: Leads (captura + * ligera, columna Vendedor, cualquier estado no terminal sin asignar) y + * Oportunidades (Alta Afluencia, columna Asesor, solo etapa 'Afluencia' + * sin asignar — las del cotizador nacen en 'Cotizador' y no entran) * (b) recordatorio de oportunidades inactivas (> INACTIVIDAD_DIAS sin actualizarse) * (c) escalado de afluencias asignadas sin contactar * (d) Oportunidad → Apartado / Ventas al entrar a 'Aparto' / 'Ganado' @@ -16,6 +24,10 @@ import { prisma } from './prisma.js' * recordatorios el mismo día. */ +// Estados del tablero Leads en los que el lead ya no entra al reparto ni es +// trabajo activo (labels title case en el board; se comparan case-insensitive) +export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'CALIFICADO', 'ABANDONO'] as const + // Etapas del tablero Oportunidades en las que la oportunidad ya no es trabajo // activo del asesor (fuera de seguimiento). export const ETAPAS_TERMINALES_OPORTUNIDAD = ['GANADO', 'PERDIDO', 'ABANDONO'] as const @@ -157,7 +169,9 @@ export function esEtapa(etapa: string, esperada: string): boolean { type MondayLeads = Pick< MondayService, + | 'getLeads' | 'getOportunidades' + | 'asignarVendedorALead' | 'asignarAsesorAOportunidad' | 'crearUpdateEnItem' | 'getUsuariosEquipo' @@ -176,8 +190,13 @@ export interface AsignacionRR { lead: string vendedorId: number vendedor: string + /** Tablero del item asignado: 'leads' (Vendedor) u 'oportunidades' (Asesor) */ + tablero: TableroRoundRobin } +/** Tablero del embudo que cubre el round-robin / la asignación al capturista */ +export type TableroRoundRobin = 'leads' | 'oportunidades' + export interface ResultadoRoundRobin { asignados: number detalle: AsignacionRR[] @@ -294,25 +313,45 @@ export class AutomatizacionesService { } /** - * Una pasada de round-robin: asigna asesor a toda oportunidad en etapa - * 'Afluencia' sin persona asignada (las creadas por el cotizador nacen en - * 'Cotizador' y NO entran al reparto). Si se pasa `soloItemId`, solo se - * evalúa ese item (ruta del webhook create_item). + * Una pasada de round-robin sobre AMBOS tableros del embudo: + * - Leads: todo lead sin Vendedor asignado y con estado no terminal + * (Descartado/Calificado/Abandono quedan fuera). + * - Oportunidades: solo etapa 'Afluencia' sin Asesor (las creadas por el + * cotizador nacen en 'Cotizador' y NO entran al reparto). + * Si se pasa `soloItemId`, solo se evalúa ese item, y `board` limita la + * pasada a un tablero (rutas de los webhooks create_item). */ - async runRoundRobin(soloItemId?: string): Promise { - const oportunidades = await this.monday.getOportunidades() - const sinAsignar = oportunidades.filter( - (o) => - o.asesorIds.length === 0 && - o.etapa.trim().toUpperCase() === 'AFLUENCIA' && - (soloItemId === undefined || o.id === soloItemId), - ) - // Las afluencias capturadas desde una app con sesión se asignan a su - // capturista (bandera rr:skip='capturista'): sin este filtro, la latencia - // de Monday hacía que el webhook las viera sin asesor y pisara la asignación. - const pendientes: typeof sinAsignar = [] - for (const o of sinAsignar) { - if ((await this.state.get(`${CLAVE_RR_SKIP}${o.id}`)) !== 'capturista') pendientes.push(o) + async runRoundRobin(soloItemId?: string, board?: TableroRoundRobin): Promise { + interface Candidato { + id: string + nombre: string + tablero: TableroRoundRobin + } + const candidatos: Candidato[] = [] + if (board === undefined || board === 'leads') { + const leads: LeadMonday[] = await this.monday.getLeads() + for (const l of leads) { + if (l.vendedorIds.length > 0) continue + if (esEstadoTerminal(l.estado, ESTADOS_TERMINALES_LEAD)) continue + if (soloItemId !== undefined && l.id !== soloItemId) continue + candidatos.push({ id: l.id, nombre: l.nombre, tablero: 'leads' }) + } + } + if (board === undefined || board === 'oportunidades') { + const oportunidades = await this.monday.getOportunidades() + for (const o of oportunidades) { + if (o.asesorIds.length > 0) continue + if (o.etapa.trim().toUpperCase() !== 'AFLUENCIA') continue + if (soloItemId !== undefined && o.id !== soloItemId) continue + candidatos.push({ id: o.id, nombre: o.nombre, tablero: 'oportunidades' }) + } + } + // Los items 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 persona asignada y pisara la asignación. + const pendientes: Candidato[] = [] + for (const c of candidatos) { + if ((await this.state.get(`${CLAVE_RR_SKIP}${c.id}`)) !== 'capturista') pendientes.push(c) } if (pendientes.length === 0) { await this.state.set(CLAVE_RR_ULTIMA_PASADA, new Date().toISOString()) @@ -324,18 +363,19 @@ export class AutomatizacionesService { if (!Number.isInteger(indice) || indice < 0) indice = 0 const detalle: AsignacionRR[] = [] - for (const op of pendientes) { + for (const c of pendientes) { const vendedor = vendedorEnTurno(vendedores, indice) if (!vendedor) throw new AppError(500, 'La lista de vendedores está vacía') - await this.monday.asignarAsesorAOportunidad(op.id, vendedor.id) + if (c.tablero === 'leads') await this.monday.asignarVendedorALead(c.id, vendedor.id) + else await this.monday.asignarAsesorAOportunidad(c.id, vendedor.id) if (this.actividadesEnabled) { await this.crearActividadSegura({ - nombre: `Contactar a ${op.nombre}`, + nombre: `Contactar a ${c.nombre}`, responsableId: vendedor.id, - leadItemId: op.id, + leadItemId: c.id, }) } - detalle.push({ itemId: op.id, lead: op.nombre, vendedorId: vendedor.id, vendedor: vendedor.nombre }) + detalle.push({ itemId: c.id, lead: c.nombre, vendedorId: vendedor.id, vendedor: vendedor.nombre, tablero: c.tablero }) indice++ } @@ -345,19 +385,25 @@ export class AutomatizacionesService { } /** - * Asigna la afluencia a quien la capturó desde una app del ecosistema (su - * usuario Monday vinculado, columna Asesor de Oportunidades) y le crea la - * actividad de contacto — el mismo paquete que haría el round-robin, pero - * para el capturista. No hay carrera con el webhook create_item: - * runRoundRobin solo toma oportunidades sin persona asignada. + * Asigna el item 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. `board` decide la + * columna: Vendedor en Leads, Asesor en Oportunidades. No hay carrera con el + * webhook create_item: runRoundRobin solo toma items sin persona asignada. */ - async asignarACapturista(oportunidadId: string, nombre: string, vendedorId: number): Promise { - await this.monday.asignarAsesorAOportunidad(oportunidadId, vendedorId) + async asignarACapturista( + itemId: string, + nombre: string, + vendedorId: number, + board: TableroRoundRobin = 'oportunidades', + ): Promise { + if (board === 'leads') await this.monday.asignarVendedorALead(itemId, vendedorId) + else await this.monday.asignarAsesorAOportunidad(itemId, vendedorId) if (this.actividadesEnabled) { await this.crearActividadSegura({ nombre: `Contactar a ${nombre}`, responsableId: vendedorId, - leadItemId: oportunidadId, + leadItemId: itemId, }) } } diff --git a/api-gateway/src/services/mi-dia.service.test.ts b/api-gateway/src/services/mi-dia.service.test.ts index b376968..f429fed 100644 --- a/api-gateway/src/services/mi-dia.service.test.ts +++ b/api-gateway/src/services/mi-dia.service.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from 'vitest' import { esEstadoFinalApartado, + esEstadoTerminalLead, esEtapaTerminal, resumenMiDia, type UsuarioMiDia, } from './mi-dia.service.js' -import type { ActividadMonday, ApartadoMonday, OportunidadMonday } from './monday.service.js' +import type { ActividadMonday, ApartadoMonday, LeadMonday, OportunidadMonday } from './monday.service.js' const MI_ID = 106736727 const OTRO_ID = 111111111 @@ -14,6 +15,20 @@ const AHORA = new Date('2026-08-28T12:00:00') // viernes const HOY = '2026-08-28' const MANANA = '2026-08-29' +function lead(parcial: Partial & { id: string }): LeadMonday { + return { + nombre: `Lead ${parcial.id}`, + createdAt: '2026-08-25T10:00:00Z', + updatedAt: '2026-08-27T10:00:00Z', + estado: 'Nuevo', + origen: 'Facebook', + telefono: '', + email: '', + vendedorIds: [MI_ID], + ...parcial, + } +} + function oportunidad(parcial: Partial & { id: string }): OportunidadMonday { return { nombre: `Lead ${parcial.id}`, @@ -52,11 +67,15 @@ function apartado(parcial: Partial & { id: string }): ApartadoMo } function mondayMock(datos: { + leads?: LeadMonday[] oportunidades?: OportunidadMonday[] actividades?: ActividadMonday[] apartados?: ApartadoMonday[] }) { return { + async getLeads() { + return datos.leads ?? [] + }, async getOportunidades() { return datos.oportunidades ?? [] }, @@ -74,22 +93,29 @@ const sinMapeo: UsuarioMiDia = { nombre: 'Erick Peña', rol: 'vendedor', mondayU const coordinador: UsuarioMiDia = { nombre: 'Daniel Alvarado', rol: 'coordinador', mondayUserId: null } describe('resumenMiDia (GET /api/mi-dia/resumen)', () => { - it('filtra mis leads por mondayUserId (columna Asesor) y excluye etapas terminales', async () => { + it('misLeads: merge de Leads (Vendedor=mío) y Oportunidades (Asesor=mío), sin terminales, con fuente, por updatedAt desc', async () => { const monday = mondayMock({ + leads: [ + lead({ id: 'l1', estado: 'Nuevo', updatedAt: '2026-08-26T10:00:00Z' }), + lead({ id: 'l2', estado: 'Contactado', telefono: '4771234567', updatedAt: '2026-08-28T08:00:00Z' }), + lead({ id: 'l3', estado: 'Descartado' }), // terminal Leads: fuera + lead({ id: 'l4', estado: 'Calificado' }), // terminal Leads: fuera + lead({ id: 'l5', estado: 'Nuevo', vendedorIds: [OTRO_ID] }), // de otro: fuera + ], oportunidades: [ - oportunidad({ id: '1', etapa: 'Afluencia' }), - oportunidad({ id: '2', etapa: 'Contactado', telefono: '4771234567' }), - oportunidad({ id: '3', etapa: 'Perdido' }), - oportunidad({ id: '4', etapa: 'Calificado' }), // NO es terminal en el nuevo modelo - oportunidad({ id: '5', etapa: 'Abandono' }), - oportunidad({ id: '6', etapa: 'Ganado' }), - oportunidad({ id: '7', etapa: 'Afluencia', asesorIds: [OTRO_ID] }), + oportunidad({ id: 'o1', etapa: 'Afluencia', updatedAt: '2026-08-27T10:00:00Z' }), + oportunidad({ id: 'o2', etapa: 'Calificado' }), // NO es terminal en Oportunidades + oportunidad({ id: 'o3', etapa: 'Perdido' }), + oportunidad({ id: 'o4', etapa: 'Abandono' }), + oportunidad({ id: 'o5', etapa: 'Ganado' }), + oportunidad({ id: 'o6', etapa: 'Afluencia', asesorIds: [OTRO_ID] }), ], }) const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA) expect(r.sinMapeoMonday).toBe(false) - expect(r.misLeads.map((l) => l.id)).toEqual(['1', '2', '4']) - expect(r.misLeads[1]?.telefono).toBe('4771234567') + expect(r.misLeads.map((l) => l.id)).toEqual(['l2', 'o1', 'o2', 'l1']) // updatedAt desc mezclando tableros + expect(r.misLeads.map((l) => l.fuente)).toEqual(['lead', 'oportunidad', 'oportunidad', 'lead']) + expect(r.misLeads[0]?.telefono).toBe('4771234567') }) it('misOportunidades: conteo de MIS oportunidades por etapa (incluye terminales)', async () => { @@ -156,8 +182,12 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => { expect(r.apartadosPorVencer).toHaveLength(1) }) - it('conteos: leads nuevos de los últimos 7 días (incluye terminales recientes)', async () => { + it('conteos: leads nuevos de los últimos 7 días suman AMBOS tableros (incluye terminales recientes)', async () => { const monday = mondayMock({ + leads: [ + lead({ id: 'l1', createdAt: '2026-08-28T08:00:00' }), // hoy + lead({ id: 'l2', createdAt: '2026-08-10T08:00:00' }), // viejo + ], oportunidades: [ oportunidad({ id: '1', createdAt: '2026-08-28T08:00:00' }), // hoy oportunidad({ id: '2', createdAt: '2026-08-22T08:00:00' }), // hace 6 días @@ -166,11 +196,16 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => { ], }) const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA) - expect(r.conteos.leadsNuevosSemana).toBe(2) + expect(r.conteos.leadsNuevosSemana).toBe(3) // 1 lead + 2 oportunidades }) - it('vendedor no ve sección equipo; coordinador sí (conteos por asesor y sin asignar)', async () => { + it('vendedor no ve sección equipo; coordinador sí (conteos por persona de AMBOS tableros y sin asignar)', async () => { const monday = mondayMock({ + leads: [ + lead({ id: 'l1', vendedorIds: [] }), // sin asignar, Nuevo + lead({ id: 'l2', vendedorIds: [], estado: 'Contactado' }), // sin asignar, no Nuevo + lead({ id: 'l3', vendedorIds: [OTRO_ID] }), + ], oportunidades: [ oportunidad({ id: '1', asesorIds: [] }), // sin asignar, Afluencia oportunidad({ id: '2', asesorIds: [], etapa: 'Contactado' }), // sin asignar, no Afluencia @@ -186,9 +221,9 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => { expect(rVendedor.equipo).toBeUndefined() const rCoord = await resumenMiDia(monday, coordinador, nombres, [], [], AHORA) - expect(rCoord.equipo?.leadsNuevosSinAsignar).toBe(1) + expect(rCoord.equipo?.leadsNuevosSinAsignar).toBe(2) // 1 lead Nuevo + 1 afluencia sin asignar expect(rCoord.equipo?.porVendedor).toEqual([ - { mondayUserId: OTRO_ID, nombre: null, leadsAsignados: 2, leadsNuevosSemana: 1 }, + { mondayUserId: OTRO_ID, nombre: null, leadsAsignados: 3, leadsNuevosSemana: 2 }, // 1 lead + 2 ops (1 vieja) { mondayUserId: MI_ID, nombre: 'Martin Zavala', leadsAsignados: 1, leadsNuevosSemana: 1 }, ]) }) @@ -222,14 +257,34 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => { ]) }) - it('mis capturas: capturas viejas (del tablero Leads) ya no cruzan; estado y origen van vacíos', async () => { - const monday = mondayMock({}) + it('mis capturas: capturas rápidas cruzan contra el tablero Leads por id', async () => { + const monday = mondayMock({ + leads: [lead({ id: 'lead-9', nombre: 'Pepe Rápido', estado: 'Contactado', origen: 'Web', vendedorIds: [OTRO_ID] })], + }) const capturas = [ - { leadId: 'lead-viejo-1', nombreLead: 'Lead Histórico', telefono: null, createdAt: new Date('2026-08-26T09:00:00Z') }, + { leadId: 'lead-9', nombreLead: 'Pepe Rápido', telefono: '4771112233', createdAt: new Date('2026-08-27T09:00:00Z') }, ] const r = await resumenMiDia(monday, vendedor, new Map(), capturas, [], AHORA) expect(r.misCapturas).toEqual([ - { leadId: 'lead-viejo-1', nombre: 'Lead Histórico', telefono: '', estado: '', origen: '', capturadoEn: '2026-08-26T09:00:00.000Z' }, + { + leadId: 'lead-9', + nombre: 'Pepe Rápido', + telefono: '4771112233', + estado: 'Contactado', + origen: 'Web', + capturadoEn: '2026-08-27T09:00:00.000Z', + }, + ]) + }) + + it('mis capturas: si el item ya no existe en ningún tablero, estado y origen van vacíos', async () => { + const monday = mondayMock({}) + const capturas = [ + { leadId: 'borrado-1', nombreLead: 'Lead Borrado', telefono: null, createdAt: new Date('2026-08-26T09:00:00Z') }, + ] + const r = await resumenMiDia(monday, vendedor, new Map(), capturas, [], AHORA) + expect(r.misCapturas).toEqual([ + { leadId: 'borrado-1', nombre: 'Lead Borrado', telefono: '', estado: '', origen: '', capturadoEn: '2026-08-26T09:00:00.000Z' }, ]) }) @@ -310,6 +365,14 @@ describe('clasificadores de estado', () => { expect(esEtapaTerminal('Intento de contacto')).toBe(false) }) + it('estados terminales de lead (tablero Leads, case-insensitive)', () => { + expect(esEstadoTerminalLead('DESCARTADO')).toBe(true) + expect(esEstadoTerminalLead('calificado')).toBe(true) + expect(esEstadoTerminalLead('Abandono')).toBe(true) + expect(esEstadoTerminalLead('Nuevo')).toBe(false) + expect(esEstadoTerminalLead('Intento de contacto')).toBe(false) + }) + it('estados finales de apartado', () => { expect(esEstadoFinalApartado('Escriturado')).toBe(true) expect(esEstadoFinalApartado('CANCELADO')).toBe(true) diff --git a/api-gateway/src/services/mi-dia.service.ts b/api-gateway/src/services/mi-dia.service.ts index 0a8b05c..b80d0b5 100644 --- a/api-gateway/src/services/mi-dia.service.ts +++ b/api-gateway/src/services/mi-dia.service.ts @@ -5,13 +5,16 @@ import type { MondayService } from './monday.service.js' * Todo se filtra por su mondayUserId (columna people de cada tablero); si el * usuario aún no tiene cuenta Monday (mondayUserId null), las listas personales * van vacías con sinMapeoMonday: true (las acciones del CRM siguen funcionando). - * La fuente de leads/oportunidades es el tablero Oportunidades (la afluencia - * ES la oportunidad en etapa 'Afluencia'; el tablero Leads es archivo - * histórico). "Mis capturas" viene del registro local (tabla LeadCaptura), no - * de Monday: es la lista de afluencias que el usuario dio de alta, con su - * estado actual. + * "Mis leads" mezcla AMBOS tableros del embudo: Leads (captura ligera, columna + * Vendedor) y Oportunidades (Alta Afluencia, columna Asesor — la afluencia ES + * la oportunidad en etapa 'Afluencia'). "Mis capturas" viene del registro + * local (tabla LeadCaptura), no de Monday: es la lista de items que el usuario + * dio de alta, con su estado actual (cruza contra ambos tableros por id). */ +// Estados del board Leads en los que el lead ya no es trabajo activo del vendedor +export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'CALIFICADO', 'ABANDONO'] + // Etapas del board Oportunidades en las que la oportunidad ya no es trabajo activo del asesor export const ETAPAS_TERMINALES_OPORTUNIDAD = ['GANADO', 'PERDIDO', 'ABANDONO'] @@ -34,6 +37,8 @@ export interface LeadMiDia { estado: string origen: string updatedAt: string + /** Tablero del item: 'lead' (tablero Leads) u 'oportunidad' (Oportunidades) */ + fuente: 'lead' | 'oportunidad' } export interface ActividadMiDia { @@ -98,7 +103,7 @@ export interface ResumenMiDia { } } -type MondayMiDia = Pick +type MondayMiDia = Pick const MS_DIA = 24 * 60 * 60 * 1000 @@ -111,6 +116,10 @@ function fechaLocal(base: Date, dias: number): string { return `${y}-${m}-${dd}` } +export function esEstadoTerminalLead(estado: string): boolean { + return ESTADOS_TERMINALES_LEAD.includes(estado.trim().toUpperCase()) +} + export function esEtapaTerminal(etapa: string): boolean { return ETAPAS_TERMINALES_OPORTUNIDAD.includes(etapa.trim().toUpperCase()) } @@ -133,7 +142,8 @@ export async function resumenMiDia( const miId = usuario.mondayUserId const sinMapeo = miId === null - const [oportunidades, actividades, apartados] = await Promise.all([ + const [leads, oportunidades, actividades, apartados] = await Promise.all([ + monday.getLeads(), monday.getOportunidades(), monday.getActividades(), monday.getApartados(), @@ -145,8 +155,25 @@ export async function resumenMiDia( const hace7DiasMs = ahora.getTime() - 7 * MS_DIA // --- Listas personales (vacías si no hay mapeo Monday) --- + // "Mis leads" mezcla ambos tableros: Leads (Vendedor = yo, estado no + // terminal) + Oportunidades (Asesor = yo, etapa no terminal), más reciente + // primero y con su fuente para las acciones del CRM. + const misLeadsLeads: LeadMiDia[] = sinMapeo + ? [] + : leads + .filter((l) => l.vendedorIds.includes(miId) && !esEstadoTerminalLead(l.estado)) + .map((l) => ({ + id: l.id, + nombre: l.nombre, + telefono: l.telefono, + email: l.email, + estado: l.estado, + origen: l.origen, + updatedAt: l.updatedAt, + fuente: 'lead', + })) const mios = sinMapeo ? [] : oportunidades.filter((o) => o.asesorIds.includes(miId)) - const misLeads: LeadMiDia[] = mios + const misLeadsOportunidades: LeadMiDia[] = mios .filter((o) => !esEtapaTerminal(o.etapa)) .map((o) => ({ id: o.id, @@ -156,7 +183,11 @@ export async function resumenMiDia( estado: o.etapa, origen: o.origen, updatedAt: o.updatedAt, + fuente: 'oportunidad', })) + const misLeads: LeadMiDia[] = [...misLeadsLeads, ...misLeadsOportunidades].sort((a, b) => + b.updatedAt.localeCompare(a.updatedAt), + ) // Conteo de MIS oportunidades por etapa (incluye terminales: es el resumen // del pipeline personal, no la lista de trabajo activo) @@ -166,19 +197,21 @@ export async function resumenMiDia( porEtapa[etapa] = (porEtapa[etapa] ?? 0) + 1 } - // "Mis capturas" se enriquece con el estado/origen ACTUAL en Monday (las - // capturas nuevas apuntan a Oportunidades; las viejas — del tablero Leads — - // ya no cruzan y quedan con estado/origen ''), más reciente primero + // "Mis capturas" se enriquece con el estado/origen ACTUAL en Monday, + // cruzando contra AMBOS tableros por id (las capturas rápidas apuntan a + // Leads; las de Alta Afluencia, a Oportunidades), más reciente primero + const leadsPorId = new Map(leads.map((l) => [l.id, l])) const oportunidadesPorId = new Map(oportunidades.map((o) => [o.id, o])) const misCapturas: CapturaMiDia[] = capturas .map((c) => { + const lead = leadsPorId.get(c.leadId) const op = oportunidadesPorId.get(c.leadId) return { leadId: c.leadId, nombre: c.nombreLead, telefono: c.telefono ?? '', - estado: op?.etapa ?? '', - origen: op?.origen ?? '', + estado: lead?.estado ?? op?.etapa ?? '', + origen: lead?.origen ?? op?.origen ?? '', capturadoEn: c.createdAt.toISOString(), } }) @@ -222,8 +255,12 @@ export async function resumenMiDia( diasRestantes: Math.round((Date.parse(`${a.fechaLimite}T00:00:00`) - Date.parse(`${hoy}T00:00:00`)) / MS_DIA), })) + // Conteo de la semana: míos en AMBOS tableros (leads + oportunidades) + const miosLeadRows = sinMapeo ? [] : leads.filter((l) => l.vendedorIds.includes(miId)) const conteos = { - leadsNuevosSemana: mios.filter((l) => Date.parse(l.createdAt) >= hace7DiasMs).length, + leadsNuevosSemana: + miosLeadRows.filter((l) => Date.parse(l.createdAt) >= hace7DiasMs).length + + mios.filter((o) => Date.parse(o.createdAt) >= hace7DiasMs).length, actividadesAbiertas: misActividades.filter((a) => a.estado.trim().toUpperCase() === 'ABIERTO').length, } @@ -240,9 +277,23 @@ export async function resumenMiDia( } if (ROLES_CON_EQUIPO.includes(usuario.rol)) { - const sinAsignar = oportunidades.filter((o) => o.asesorIds.length === 0 && !esEtapaTerminal(o.etapa)) + // Sin asignar: leads sin Vendedor (estado NUEVO) + afluencias sin Asesor + // (etapa Afluencia) — lo que el round-robin aún no reparte + const sinAsignar = + leads.filter((l) => l.vendedorIds.length === 0 && !esEstadoTerminalLead(l.estado) && l.estado.trim().toUpperCase() === 'NUEVO') + .length + + oportunidades.filter((o) => o.asesorIds.length === 0 && o.etapa.trim().toUpperCase() === 'AFLUENCIA').length const conteoPorId = new Map() const nuevosPorId = new Map() + // Leads y oportunidades comparten el conteo por vendedor (misma persona Monday) + for (const l of leads) { + if (esEstadoTerminalLead(l.estado)) continue + const esNuevoSemana = Date.parse(l.createdAt) >= hace7DiasMs + for (const id of l.vendedorIds) { + conteoPorId.set(id, (conteoPorId.get(id) ?? 0) + 1) + if (esNuevoSemana) nuevosPorId.set(id, (nuevosPorId.get(id) ?? 0) + 1) + } + } for (const o of oportunidades) { if (esEtapaTerminal(o.etapa)) continue const esNuevoSemana = Date.parse(o.createdAt) >= hace7DiasMs @@ -252,8 +303,7 @@ export async function resumenMiDia( } } resumen.equipo = { - // Afluencias recién llegadas que el round-robin aún no reparte - leadsNuevosSinAsignar: sinAsignar.filter((o) => o.etapa.trim().toUpperCase() === 'AFLUENCIA').length, + leadsNuevosSinAsignar: sinAsignar, porVendedor: [...conteoPorId.entries()] .map(([mondayUserId, leadsAsignados]) => ({ mondayUserId, diff --git a/api-gateway/src/services/monday.service.test.ts b/api-gateway/src/services/monday.service.test.ts index 021b3ce..2fb99a9 100644 --- a/api-gateway/src/services/monday.service.test.ts +++ b/api-gateway/src/services/monday.service.test.ts @@ -557,6 +557,97 @@ describe('MondayService', () => { }) }) + describe('asignarVendedorALead (columna people Vendedor del tablero Leads)', () => { + it('escribe personsAndTeams con el user id en el board Leads (444 en tests)', async () => { + const llamadas = mockearFetch((body) => { + if (body.query.includes('columns')) { + return respuestaOk({ boards: [{ columns: [{ id: 'people_v', title: 'Vendedor', type: 'people' }] }] }) + } + return respuestaOk({ change_column_value: { id: 'lead-1' } }) + }) + + await crearServicio().asignarVendedorALead('lead-1', 12345) + + const mutacion = llamadas.find((l) => l.body.query.includes('change_column_value')) + expect(mutacion?.body.variables['boardId']).toBe('444') + expect(mutacion?.body.variables['columnId']).toBe('people_v') + expect(JSON.parse(String(mutacion?.body.variables['value']))).toEqual({ + personsAndTeams: [{ id: 12345, kind: 'person' }], + }) + }) + }) + + describe('crearLead (captura ligera en el tablero Leads)', () => { + const COLUMNAS_LEADS_LIGERO = [ + { id: 'color_estado', title: 'Estado', type: 'status' }, + { id: 'date_fecha', title: 'Fecha', type: 'date' }, + { id: 'phone_tel', title: 'Teléfono', type: 'phone' }, + { id: 'email_em', title: 'Email', type: 'email' }, + { id: 'color_or', title: 'Origen', type: 'status' }, + { id: 'color_des', title: 'Desarrollo', type: 'status' }, + { id: 'text_notas', title: 'Notas', type: 'text' }, + ] + + it('crea el item ligero con Estado NUEVO y Fecha de hoy (sin perfil ni oportunidad)', async () => { + const llamadas = mockearFetch((body) => { + if (body.query.includes('settings_str')) { + return respuestaOk({ + boards: [ + { + columns: COLUMNAS_LEADS_LIGERO.filter((c) => c.type === 'status').map((c) => ({ + id: c.id, + title: c.title, + settings_str: '{"labels":{"1":"Nuevo"}}', + })), + }, + ], + }) + } + if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_LEADS_LIGERO }] }) + return respuestaOk({ create_item: { id: 'lead-nuevo-1' } }) + }) + + const id = await crearServicio().crearLead({ + nombre: 'Lead Prueba', + telefono: '4771234567', + email: 'lead@correo.com', + origenLabel: 'Facebook', + desarrolloLabel: 'SENDA', + notas: 'Vio el volante', + }) + expect(id).toBe('lead-nuevo-1') + + const mutacion = llamadas.find((l) => l.body.query.includes('create_item')) + expect(mutacion?.body.variables['boardId']).toBe('444') // tablero Leads + const parsed = JSON.parse(String(mutacion?.body.variables['columnValues'])) as Record + expect(parsed['color_estado']).toEqual({ label: 'Nuevo' }) // 'NUEVO' resuelto al label real + expect(parsed['date_fecha']).toMatchObject({ date: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) }) + expect(parsed['phone_tel']).toEqual({ phone: '4771234567', countryShortName: 'MX' }) + expect(parsed['email_em']).toEqual({ email: 'lead@correo.com', text: 'lead@correo.com' }) + expect(parsed['color_or']).toEqual({ label: 'Facebook' }) + expect(parsed['color_des']).toEqual({ label: 'SENDA' }) + expect(parsed['text_notas']).toBe('Vio el volante') + }) + + it('solo envía las columnas capturadas (las vacías se omiten)', async () => { + const llamadas = mockearFetch((body) => { + if (body.query.includes('settings_str')) { + return respuestaOk({ + boards: [{ columns: [{ id: 'color_estado', title: 'Estado', settings_str: '{"labels":{"1":"Nuevo"}}' }] }], + }) + } + if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_LEADS_LIGERO }] }) + return respuestaOk({ create_item: { id: 'lead-nuevo-2' } }) + }) + + await crearServicio().crearLead({ nombre: 'Solo Nombre' }) + + const mutacion = llamadas.find((l) => l.body.query.includes('create_item')) + const parsed = JSON.parse(String(mutacion?.body.variables['columnValues'])) as Record + expect(Object.keys(parsed).sort()).toEqual(['color_estado', 'date_fecha']) + }) + }) + describe('asignarAsesorAOportunidad (columna people Asesor)', () => { it('escribe personsAndTeams con el user id en el board Oportunidades (555 en tests)', async () => { const llamadas = mockearFetch((body) => { diff --git a/api-gateway/src/services/monday.service.ts b/api-gateway/src/services/monday.service.ts index e0db921..689f90b 100644 --- a/api-gateway/src/services/monday.service.ts +++ b/api-gateway/src/services/monday.service.ts @@ -1033,6 +1033,27 @@ export class MondayService { } } + /** + * Item en el tablero Leads (Estado NUEVO; captura rápida del vendedor desde + * /hoy o captura manual). La columna date 'Fecha' lleva la fecha de captura + * (hoy, hora local). Los leads NO crean oportunidad (eso es /api/afluencias). + */ + async crearLead(datos: DatosLead): Promise { + const boardId = config.monday.boards.leads + const hoy = new Date() + const fechaHoy = `${hoy.getFullYear()}-${String(hoy.getMonth() + 1).padStart(2, '0')}-${String(hoy.getDate()).padStart(2, '0')}` + const columnValues = await this.construirColumnValues(boardId, { + Estado: 'NUEVO', + Fecha: fechaHoy, + Teléfono: datos.telefono, + Email: datos.email, + Origen: datos.origenLabel, + Desarrollo: datos.desarrolloLabel, + Notas: datos.notas, + }) + return this.crearItem(boardId, datos.nombre, columnValues) + } + /** * Lead con el mismo teléfono (dedup de captura rápida). Compara solo dígitos * y los últimos 10 (tolerante a lada/prefijo 52). null si no hay duplicado. @@ -1111,10 +1132,9 @@ export class MondayService { return abiertas } - /** Asigna un asesor (user ID de Monday) a la columna people "Asesor" de una oportunidad */ - async asignarAsesorAOportunidad(itemId: string, userId: number): Promise { - const boardId = config.monday.boards.oportunidades - const col = await this.columnaId(boardId, 'Asesor') + /** Asigna una persona (user ID de Monday) a una columna people de un item */ + private async asignarPersona(boardId: string, itemId: string, tituloColumna: string, userId: number): Promise { + const col = await this.columnaId(boardId, tituloColumna) await this.graphql( `mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) { change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id } @@ -1128,6 +1148,16 @@ export class MondayService { ) } + /** Asigna un asesor (user ID de Monday) a la columna people "Asesor" de una oportunidad */ + async asignarAsesorAOportunidad(itemId: string, userId: number): Promise { + return this.asignarPersona(config.monday.boards.oportunidades, itemId, 'Asesor', userId) + } + + /** Asigna un vendedor (user ID de Monday) a la columna people "Vendedor" de un lead */ + async asignarVendedorALead(itemId: string, userId: number): Promise { + return this.asignarPersona(config.monday.boards.leads, itemId, 'Vendedor', userId) + } + /** Crea un update (comentario) en un item — usado para los recordatorios de inactividad */ async crearUpdateEnItem(itemId: string, body: string): Promise { const data = await this.graphql<{ create_update: { id: string } }>( @@ -1255,6 +1285,16 @@ export interface DatosActividad { leadItemId?: string // item del tablero Leads relacionado (se omite si no aplica) } +/** Captura ligera de lead (tablero Leads): la forma corta de /hoy y Monday UI */ +export interface DatosLead { + nombre: string + telefono?: string | undefined + email?: string | undefined + origenLabel?: string | undefined // label real de la columna status 'Origen' del board Leads + desarrolloLabel?: string | undefined // label real de la columna status 'Desarrollo' del board Leads + notas?: string | undefined +} + /** Singleton usado por los controllers (los tests instancian el suyo) */ export const mondayService = new MondayService() diff --git a/api-gateway/src/services/reportes.service.test.ts b/api-gateway/src/services/reportes.service.test.ts index 20b02a1..823b46d 100644 --- a/api-gateway/src/services/reportes.service.test.ts +++ b/api-gateway/src/services/reportes.service.test.ts @@ -1,11 +1,27 @@ import { describe, expect, it, vi } from 'vitest' import { construirResumen, ReportesService, type DatosResumen } from './reportes.service.js' -import type { OportunidadMonday, Unidad } from './monday.service.js' +import type { LeadMonday, OportunidadMonday, Unidad } from './monday.service.js' import type { PlanPago } from './cobranza.service.js' // ---------- Fixtures ---------- -/** Afluencia/oportunidad del board Oportunidades (fuente única de la sección leads) */ +/** Lead del tablero Leads (captura ligera; fuente de la sección leads) */ +function lead(id: string, overrides: Partial = {}): LeadMonday { + return { + id, + nombre: `Lead ${id}`, + createdAt: '2026-08-20T10:00:00Z', + updatedAt: '2026-08-20T10:00:00Z', + estado: 'Nuevo', + origen: 'Facebook', + vendedorIds: [], + telefono: '', + email: '', + ...overrides, + } +} + +/** Afluencia del board Oportunidades (fuente del perfilamiento de afluencia) */ function afluencia(id: string, overrides: Partial = {}): OportunidadMonday { return { id, @@ -68,37 +84,37 @@ function pago(id: string, fechaVencimiento: string, monto: number, montoPagado = const AHORA = new Date('2026-08-22T12:00:00') // mediodía local -const DATOS_BASE: DatosResumen = { oportunidades: [], unidades: [], planes: [], actividadesAbiertas: 0 } +const DATOS_BASE: DatosResumen = { leads: [], oportunidades: [], unidades: [], planes: [], actividadesAbiertas: 0 } // ---------- construirResumen (puro) ---------- describe('construirResumen', () => { - it('cuenta la afluencia por estado (Etapa) y por origen (Origen del Lead)', () => { + it('cuenta leads por estado y por origen (tablero Leads, captura ligera)', () => { const r = construirResumen( { ...DATOS_BASE, - oportunidades: [ - afluencia('1', { etapa: 'Afluencia', origen: 'Facebook' }), - afluencia('2', { etapa: 'Afluencia', origen: 'Facebook' }), - afluencia('3', { etapa: 'Contactado', origen: 'Referido' }), - afluencia('4', { etapa: '', origen: '' }), + leads: [ + lead('1', { estado: 'Nuevo', origen: 'Facebook' }), + lead('2', { estado: 'Nuevo', origen: 'Facebook' }), + lead('3', { estado: 'Contactado', origen: 'Referido' }), + lead('4', { estado: '', origen: '' }), ], }, AHORA, ) expect(r.leads.total).toBe(4) - expect(r.leads.porEstado).toEqual({ Afluencia: 2, Contactado: 1, '(sin dato)': 1 }) + expect(r.leads.porEstado).toEqual({ Nuevo: 2, Contactado: 1, '(sin dato)': 1 }) expect(r.leads.porOrigen).toEqual({ Facebook: 2, Referido: 1, '(sin dato)': 1 }) }) - it('cuenta afluencias nuevas de hoy y de los últimos 7 días (fecha local)', () => { + it('cuenta leads nuevos de hoy y de los últimos 7 días (fecha local)', () => { const r = construirResumen( { ...DATOS_BASE, - oportunidades: [ - afluencia('1', { createdAt: '2026-08-22T08:00:00' }), // hoy - afluencia('2', { createdAt: '2026-08-18T23:00:00' }), // esta semana - afluencia('3', { createdAt: '2026-08-10T12:00:00' }), // hace 12 días + leads: [ + lead('1', { createdAt: '2026-08-22T08:00:00' }), // hoy + lead('2', { createdAt: '2026-08-18T23:00:00' }), // esta semana + lead('3', { createdAt: '2026-08-10T12:00:00' }), // hace 12 días ], }, AHORA, @@ -300,7 +316,11 @@ describe('construirResumen', () => { describe('ReportesService', () => { function mondayMock() { return { - llamadas: { oportunidades: 0, unidades: 0, actividades: 0 }, + llamadas: { leads: 0, oportunidades: 0, unidades: 0, actividades: 0 }, + async getLeads(): Promise { + this.llamadas.leads++ + return [lead('1')] + }, async getOportunidades(): Promise { this.llamadas.oportunidades++ return [afluencia('1')] @@ -316,16 +336,17 @@ describe('ReportesService', () => { } } - it('agrega todas las fuentes en una sola respuesta (una sola llamada a Oportunidades)', async () => { + it('agrega todas las fuentes en una sola respuesta', async () => { const monday = mondayMock() const svc = new ReportesService(monday, async () => [plan('a', { pagos: [pago('p1', '2026-09-01', 50000)] })]) const r = await svc.resumen() - expect(r.leads.total).toBe(1) // la sección leads se alimenta de Oportunidades + expect(r.leads.total).toBe(1) // la sección leads se alimenta del tablero Leads expect(r.oportunidades.total).toBe(1) expect(r.unidades.total).toBe(1) expect(r.cobranza.planes.activos).toBe(1) expect(r.actividadesAbiertas).toBe(7) - expect(monday.llamadas.oportunidades).toBe(1) // sin llamada duplicada + expect(monday.llamadas.leads).toBe(1) + expect(monday.llamadas.oportunidades).toBe(1) }) it('cachea 60 s: la segunda llamada no vuelve a Monday ni a Prisma', async () => { diff --git a/api-gateway/src/services/reportes.service.ts b/api-gateway/src/services/reportes.service.ts index b99c5bb..86da08e 100644 --- a/api-gateway/src/services/reportes.service.ts +++ b/api-gateway/src/services/reportes.service.ts @@ -1,12 +1,13 @@ import { statusPago, statusPlan, type PlanPago } from './cobranza.service.js' -import type { MondayService, OportunidadMonday, Unidad } from './monday.service.js' +import type { LeadMonday, MondayService, OportunidadMonday, Unidad } from './monday.service.js' import { ETAPAS_PIPELINE } from './etapas.js' /** * Agregador de reportes para la app de tableros (/dash/). - * En UNA respuesta junta: afluencia/perfil (Monday, tablero Oportunidades — la - * afluencia ES la oportunidad en etapa 'Afluencia'), oportunidades (Monday), - * unidades (Monday), cobranza (Prisma local) y actividades abiertas (Monday). + * En UNA respuesta junta: leads (Monday, tablero Leads — captura ligera), + * afluencia/perfilamiento (Monday, tablero Oportunidades — el Alta Afluencia + * vive ahí), oportunidades (Monday), unidades (Monday), cobranza (Prisma + * local) y actividades abiertas (Monday). * Cache en memoria de 60 s para no martillar la API de Monday. */ @@ -76,8 +77,10 @@ export interface ResumenReportes { /** Datos crudos que necesita el agregador (inyectables en tests) */ export interface DatosResumen { - // La sección `leads` (afluencia + perfilamiento) y la sección `oportunidades` - // se alimentan de la MISMA fuente: el tablero Oportunidades + // La sección `leads` (total/nuevos/porEstado/porOrigen) lee el tablero Leads + // (captura ligera); el `perfil` de la afluencia lee Oportunidades (Alta + // Afluencia), que es también la fuente de la sección `oportunidades` + leads: LeadMonday[] oportunidades: OportunidadMonday[] unidades: Unidad[] planes: PlanPago[] // dominio de cobranza (con pagos) @@ -164,8 +167,8 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor const hoy = inicioDelDia(ahora) const hace7dias = new Date(hoy.getTime() - 7 * 24 * 60 * 60 * 1000) const creadosEn = (desde: Date): number => - datos.oportunidades.filter((o) => { - const t = Date.parse(o.createdAt) + datos.leads.filter((l) => { + const t = Date.parse(l.createdAt) return !Number.isNaN(t) && t >= desde.getTime() }).length @@ -221,14 +224,14 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor return { generadoEn: ahora.toISOString(), - // Sección "leads" = afluencia sobre el tablero Oportunidades (modelo - // nuevo: la afluencia ES la oportunidad en etapa 'Afluencia') + // Sección "leads" = tablero Leads (captura ligera); el perfilamiento de la + // afluencia se lee de Oportunidades (el Alta Afluencia vive ahí) leads: { - total: datos.oportunidades.length, + total: datos.leads.length, nuevosHoy: creadosEn(hoy), nuevosSemana: creadosEn(hace7dias), - porEstado: contarPor(datos.oportunidades.map((o) => o.etapa)), - porOrigen: contarPor(datos.oportunidades.map((o) => o.origen)), + porEstado: contarPor(datos.leads.map((l) => l.estado)), + porOrigen: contarPor(datos.leads.map((l) => l.origen)), perfil: { porDesarrollo: contarPorNoVacios(datos.oportunidades.map((o) => o.desarrollo ?? '')), porModelo: contarPorNoVacios(datos.oportunidades.map((o) => o.modeloInteres ?? '')), @@ -277,7 +280,7 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor // ---------- Servicio con cache ---------- -type MondayReportes = Pick +type MondayReportes = Pick /** Carga los planes de cobranza en forma de dominio (Prisma en prod, fixture en tests) */ export type CargadorPlanes = () => Promise @@ -313,13 +316,14 @@ export class ReportesService { if (this.cache && this.cache.expira > ahoraMs) return this.cache.resumen // Las fuentes de Monday se piden en paralelo; Prisma va por su lado - const [oportunidades, unidades, actividadesAbiertas, planes] = await Promise.all([ + const [leads, oportunidades, unidades, actividadesAbiertas, planes] = await Promise.all([ + this.monday.getLeads(), this.monday.getOportunidades(), this.monday.getUnidades(), this.monday.getActividadesAbiertas(), this.cargarPlanes(), ]) - const resumen = construirResumen({ oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs)) + const resumen = construirResumen({ leads, oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs)) this.cache = { resumen, expira: ahoraMs + this.cacheMs } return resumen } diff --git a/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx b/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx index 67f145f..17cec08 100644 --- a/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx +++ b/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx @@ -56,7 +56,7 @@ describe('AltaAfluenciaForm', () => { 'Registrado en afluencia — se asignará por round-robin', ) const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] - expect(url).toContain('/leads') + expect(url).toContain('/afluencias') expect(JSON.parse(String(init.body))).toMatchObject({ nombre: 'Ana Demo', telefono: '6641234567', diff --git a/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx b/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx index f355ab0..d3ca5fe 100644 --- a/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx +++ b/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx @@ -1,6 +1,6 @@ import { useState, type FormEvent } from 'react' import { z } from 'zod' -import { crearLead, type LeadResumen } from '../services/api' +import { crearAfluencia, type LeadResumen } from '../services/api' import { DESARROLLOS_LEAD, ESTADOS_CIVILES, @@ -94,7 +94,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro setAviso('') if (!validar()) return setEnviando(true) - const res = await crearLead({ + const res = await crearAfluencia({ nombre: form.nombre.trim(), telefono: form.telefono.trim(), ...(form.email.trim() ? { email: form.email.trim() } : {}), diff --git a/apps/alta-clientes/src/services/api.ts b/apps/alta-clientes/src/services/api.ts index d0e5f0b..db05bcc 100644 --- a/apps/alta-clientes/src/services/api.ts +++ b/apps/alta-clientes/src/services/api.ts @@ -253,12 +253,12 @@ export type ResultadoCrearLead = | { ok: false; motivo: 'conexion' } /** - * POST /leads — captura ligera de afluencia: crea UN item en el tablero - * Oportunidades (etapa Afluencia, con todo el perfil; la asigna el capturista - * o el round-robin). Si el teléfono ya existe el API responde 409 con la - * oportunidad existente (motivo 'duplicado'). + * POST /afluencias — Alta Afluencia (forma completa): crea UN item en el + * tablero Oportunidades (etapa Afluencia, con todo el perfil; la asigna el + * capturista o el round-robin). Si el teléfono ya existe el API responde 409 + * con la oportunidad existente (motivo 'duplicado'). */ -export async function crearLead(payload: { +export async function crearAfluencia(payload: { nombre: string telefono: string email?: string @@ -280,7 +280,7 @@ export async function crearLead(payload: { let res: Response try { res = await fetch( - `${API_URL}/leads`, + `${API_URL}/afluencias`, conAuth({ method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -288,7 +288,7 @@ export async function crearLead(payload: { }), ) } catch (err) { - console.warn('[api] crearLead: sin conexión al servidor', err) + console.warn('[api] crearAfluencia: sin conexión al servidor', err) return { ok: false, motivo: 'conexion' } } manejar401(res) @@ -299,7 +299,7 @@ export async function crearLead(payload: { const cuerpo = (await res.json()) as { leadId: string; nombre: string } return { ok: false, motivo: 'duplicado', leadId: cuerpo.leadId, nombre: cuerpo.nombre } } - console.warn(`[api] crearLead: HTTP ${res.status}`) + console.warn(`[api] crearAfluencia: HTTP ${res.status}`) return { ok: false, motivo: 'conexion' } } diff --git a/apps/dashboards/src/lib/reportes.test.ts b/apps/dashboards/src/lib/reportes.test.ts index 257d685..bdc5322 100644 --- a/apps/dashboards/src/lib/reportes.test.ts +++ b/apps/dashboards/src/lib/reportes.test.ts @@ -115,19 +115,19 @@ describe('unidadesEnEtapa', () => { }) describe('datosFunnel', () => { - it('ordena Afluencia → Agendo Cita → Cotizador → Aparto o más → Ganado (todo desde porEtapa)', () => { + it('ordena Leads → Afluencia → Agendo Cita → Cotizador → Aparto o más → Ganado', () => { const funnel = datosFunnel(resumen()) - expect(funnel.map((e) => e.etapa)).toEqual(['Afluencia', 'Agendo Cita', 'Cotizador', 'Aparto o más', 'Ganado']) - expect(funnel.map((e) => e.valor)).toEqual([8, 0, 5, 6, 2]) // Aparto(4) + Ganado(2) acumulados + expect(funnel.map((e) => e.etapa)).toEqual(['Leads', 'Afluencia', 'Agendo Cita', 'Cotizador', 'Aparto o más', 'Ganado']) + expect(funnel.map((e) => e.valor)).toEqual([40, 8, 0, 5, 6, 2]) // Aparto(4) + Ganado(2) acumulados }) it('tolera etiquetas con otra capitalización', () => { const r = resumen() r.oportunidades.porEtapa = { afluencia: 3, ' GANADO ': 3, aparto: 1 } const funnel = datosFunnel(r) - expect(funnel[0]?.valor).toBe(3) - expect(funnel[3]?.valor).toBe(4) - expect(funnel[4]?.valor).toBe(3) + expect(funnel[1]?.valor).toBe(3) + expect(funnel[4]?.valor).toBe(4) + expect(funnel[5]?.valor).toBe(3) }) }) diff --git a/apps/dashboards/src/lib/reportes.ts b/apps/dashboards/src/lib/reportes.ts index 02b4515..59b1ad3 100644 --- a/apps/dashboards/src/lib/reportes.ts +++ b/apps/dashboards/src/lib/reportes.ts @@ -104,9 +104,9 @@ export function unidadesEnEtapa(resumen: ResumenReportes, etapa: string): number } /** - * Datos del funnel comercial, todo desde el tablero Oportunidades (la - * afluencia ES la oportunidad en etapa 'Afluencia'): Afluencia → Agendo Cita - * → Cotizador → Aparto o más → Ganado. "Aparto o más" es acumulado + * Datos del funnel comercial: Leads (total del tablero Leads, captura ligera) + * → Afluencia → Agendo Cita → Cotizador → Aparto o más → Ganado (las etapas, + * del tablero Oportunidades vía porEtapa). "Aparto o más" es acumulado * (Aparto + Ganado) porque Aparto es subconjunto del camino a Ganado. */ export function datosFunnel(resumen: ResumenReportes): EtapaFunnel[] { @@ -117,6 +117,7 @@ export function datosFunnel(resumen: ResumenReportes): EtapaFunnel[] { .reduce((acc, [, v]) => acc + v, 0) const apartoOMas = conteo('APARTO') + conteo('GANADO') return [ + { etapa: 'Leads', valor: resumen.leads.total }, { etapa: 'Afluencia', valor: conteo('AFLUENCIA') }, { etapa: 'Agendo Cita', valor: conteo('AGENDO CITA') }, { etapa: 'Cotizador', valor: conteo('COTIZADOR') }, diff --git a/apps/mi-dia/src/lib/mi-dia.ts b/apps/mi-dia/src/lib/mi-dia.ts index 22f225d..df0ea63 100644 --- a/apps/mi-dia/src/lib/mi-dia.ts +++ b/apps/mi-dia/src/lib/mi-dia.ts @@ -8,6 +8,8 @@ export interface LeadMiDia { estado: string origen: string updatedAt: string + /** Tablero del item: 'lead' (tablero Leads) u 'oportunidad' (Oportunidades) */ + fuente: 'lead' | 'oportunidad' } export interface ActividadMiDia { diff --git a/apps/mi-dia/src/pages/MiDiaPage.tsx b/apps/mi-dia/src/pages/MiDiaPage.tsx index 4312ca1..c445c27 100644 --- a/apps/mi-dia/src/pages/MiDiaPage.tsx +++ b/apps/mi-dia/src/pages/MiDiaPage.tsx @@ -133,7 +133,19 @@ function FilaLead({
  • -

    {lead.nombre}

    +

    + {lead.nombre} + {/* Insignia de procedencia: gris = tablero Leads, azul = Oportunidades */} + + {lead.fuente === 'oportunidad' ? 'Oportunidad' : 'Lead'} + +

    {lead.telefono || 'Sin teléfono'} {lead.origen ? ` · ${lead.origen}` : ''} @@ -366,10 +378,10 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones

      {resumen.misLeads.map((lead) => ( void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha), 'No se pudo actualizar el lead')} + onAccion={(estado, fecha) => void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha, lead.fuente), 'No se pudo actualizar el lead')} onDescartar={() => setDescartando(lead)} /> ))} @@ -656,7 +668,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones const lead = descartando setDescartando(null) void ejecutar( - () => descartarLead(lead.id, motivo, comentario === '' ? undefined : comentario), + () => descartarLead(lead.id, motivo, comentario === '' ? undefined : comentario, lead.fuente), 'No se pudo descartar el lead', ) }} diff --git a/apps/mi-dia/src/services/mi-dia.ts b/apps/mi-dia/src/services/mi-dia.ts index 0e81157..063ab20 100644 --- a/apps/mi-dia/src/services/mi-dia.ts +++ b/apps/mi-dia/src/services/mi-dia.ts @@ -41,18 +41,26 @@ export async function crearLead(datos: { }) } -export function cambiarEstadoLead(itemId: string, estado: string, fecha?: string): Promise { +export function cambiarEstadoLead(itemId: string, estado: string, fecha?: string, fuente?: 'lead' | 'oportunidad'): Promise { return fetchJson(`/leads/${itemId}/estado`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(fecha !== undefined && fecha !== '' ? { estado, fecha } : { estado }), + body: JSON.stringify({ + estado, + ...(fecha !== undefined && fecha !== '' ? { fecha } : {}), + ...(fuente !== undefined ? { fuente } : {}), + }), }) } -export function descartarLead(itemId: string, motivo: string, comentario?: string): Promise { +export function descartarLead(itemId: string, motivo: string, comentario?: string, fuente?: 'lead' | 'oportunidad'): Promise { return fetchJson(`/leads/${itemId}/descartar`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(comentario !== undefined && comentario !== '' ? { motivo, comentario } : { motivo }), + body: JSON.stringify({ + motivo, + ...(comentario !== undefined && comentario !== '' ? { comentario } : {}), + ...(fuente !== undefined ? { fuente } : {}), + }), }) }