diff --git a/api-gateway/prisma/migrations/20260901194803_lead_oportunidad/migration.sql b/api-gateway/prisma/migrations/20260901194803_lead_oportunidad/migration.sql new file mode 100644 index 0000000..d973dd1 --- /dev/null +++ b/api-gateway/prisma/migrations/20260901194803_lead_oportunidad/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "LeadOportunidad" ( + "id" TEXT NOT NULL PRIMARY KEY, + "leadId" TEXT NOT NULL, + "oportunidadId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateIndex +CREATE UNIQUE INDEX "LeadOportunidad_leadId_key" ON "LeadOportunidad"("leadId"); + +-- CreateIndex +CREATE UNIQUE INDEX "LeadOportunidad_oportunidadId_key" ON "LeadOportunidad"("oportunidadId"); + diff --git a/api-gateway/prisma/schema.prisma b/api-gateway/prisma/schema.prisma index b31b3e8..37460d2 100644 --- a/api-gateway/prisma/schema.prisma +++ b/api-gateway/prisma/schema.prisma @@ -140,6 +140,16 @@ model AplicacionPago { @@index([pagoId]) } +// Vinculación lead ↔ oportunidad del funnel: cada lead capturado entra al +// tablero Oportunidades en etapa 'Afluencia'; este mapping evita duplicar la +// oportunidad y permite moverla de etapa cuando el lead avanza. +model LeadOportunidad { + id String @id @default(uuid()) + leadId String @unique // item del tablero Leads (Monday) + oportunidadId String @unique // item del tablero Oportunidades (Monday) + createdAt DateTime @default(now()) +} + // Estado persistente de las automatizaciones de Monday (round-robin, recordatorios). // Clave-valor genérico: 'rr:indice', 'rr:vendedores', 'recordatorio::', etc. model AutomatizacionState { diff --git a/api-gateway/src/controllers/clientes.controller.test.ts b/api-gateway/src/controllers/clientes.controller.test.ts index 421f419..429d0da 100644 --- a/api-gateway/src/controllers/clientes.controller.test.ts +++ b/api-gateway/src/controllers/clientes.controller.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Request, Response } from 'express' import { actividadEconomicaLabel, checkboxParaTipoDocumento, @@ -6,6 +7,28 @@ import { rangoEdadLabel, } from './clientes.controller.js' +// ---------- Mocks de módulo (los tests no tocan BD ni red) ---------- + +const prismaMock = vi.hoisted(() => ({ + cliente: { + create: vi.fn(), + update: vi.fn(), + }, +})) + +const mondayMock = vi.hoisted(() => ({ + crearContacto: vi.fn(), + crearApartado: vi.fn(), +})) + +const funnelMock = vi.hoisted(() => ({ + moverAApartoConDedup: vi.fn(), +})) + +vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) +vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock })) +vi.mock('../services/funnel.service.js', () => ({ funnelService: funnelMock })) + describe('checkboxParaTipoDocumento (Apartado → checkboxes de documentos)', () => { it('mapea los tipos con checkbox al título de la columna', () => { expect(checkboxParaTipoDocumento('CURP')).toBe('CURP') @@ -92,3 +115,52 @@ describe('crearClienteSchema', () => { ).toThrow() }) }) + +describe('crearCliente (POST /api/clientes) — leadMondayId del funnel', () => { + // Import diferido para que los vi.mock de arriba apliquen al controller + const altaBase = { + datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123' }, + viviendaCredito: { proyecto: 'SENDA' }, + } + + const reqFake = (body: unknown): Request => ({ body } as Request) + const resFake = () => { + const json = vi.fn() + const status = vi.fn().mockReturnValue({ json }) + return { res: { json, status } as unknown as Response, status, json } + } + + beforeEach(() => { + vi.clearAllMocks() + prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data })) + prismaMock.cliente.update.mockResolvedValue({}) + mondayMock.crearContacto.mockResolvedValue('cont-1') + funnelMock.moverAApartoConDedup.mockResolvedValue('op-1') + }) + + it('con leadMondayId mueve la oportunidad a Aparto (con dedup) antes de replicar en Monday', async () => { + const { crearCliente } = await import('./clientes.controller.js') + const { prismaStateStore } = await import('../services/automatizaciones.service.js') + const { res, status } = resFake() + await crearCliente(reqFake({ ...altaBase, leadMondayId: 'lead-9' }), res) + expect(status).toHaveBeenCalledWith(201) + expect(funnelMock.moverAApartoConDedup).toHaveBeenCalledWith('lead-9', prismaStateStore) + }) + + it('sin leadMondayId no toca el funnel', async () => { + const { crearCliente } = await import('./clientes.controller.js') + const { res, status } = resFake() + await crearCliente(reqFake(altaBase), res) + expect(status).toHaveBeenCalledWith(201) + expect(funnelMock.moverAApartoConDedup).not.toHaveBeenCalled() + }) + + it('un fallo del funnel no rompe el alta (201 igual)', async () => { + const { crearCliente } = await import('./clientes.controller.js') + funnelMock.moverAApartoConDedup.mockRejectedValue(new Error('Monday caído')) + const { res, status, json } = resFake() + await crearCliente(reqFake({ ...altaBase, leadMondayId: 'lead-9' }), res) + expect(status).toHaveBeenCalledWith(201) + expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayContactoId: 'cont-1' }) + }) +}) diff --git a/api-gateway/src/controllers/clientes.controller.ts b/api-gateway/src/controllers/clientes.controller.ts index e08d20f..c78bb49 100644 --- a/api-gateway/src/controllers/clientes.controller.ts +++ b/api-gateway/src/controllers/clientes.controller.ts @@ -6,6 +6,8 @@ import multer from 'multer' import { z } from 'zod' import { config } from '../config/index.js' import { mondayService } from '../services/monday.service.js' +import { funnelService } from '../services/funnel.service.js' +import { prismaStateStore } from '../services/automatizaciones.service.js' import { prisma } from '../services/prisma.js' import { NotFoundError, BadRequestError } from '../utils/errors.js' @@ -175,6 +177,21 @@ export async function crearCliente(req: Request, res: Response): Promise { }, }) + // Si el alta viene de un lead del funnel (la app alta-clientes manda + // leadMondayId en la raíz del payload), su oportunidad pasa a 'Aparto'. + // moverAApartoConDedup escribe el dedup `apartado-creado:` ANTES del + // cambio de etapa, para que el webhook/cron no cree un Apartado duplicado. + // Un fallo aquí no rompe el alta. + const leadMondayId = (body as Record)['leadMondayId'] + if (typeof leadMondayId === 'string' && leadMondayId !== '') { + try { + await funnelService.moverAApartoConDedup(leadMondayId, prismaStateStore) + } catch (err) { + // eslint-disable-next-line no-console + console.error(`No se pudo mover a 'Aparto' la oportunidad del lead ${leadMondayId}:`, err) + } + } + // Réplica en Monday; si falla, el alta ya quedó persistida y se reportan ids null let mondayContactoId: string | null = null let mondayApartadoId: string | null = null diff --git a/api-gateway/src/controllers/cotizaciones.controller.test.ts b/api-gateway/src/controllers/cotizaciones.controller.test.ts new file mode 100644 index 0000000..c03294b --- /dev/null +++ b/api-gateway/src/controllers/cotizaciones.controller.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Request, Response } from 'express' + +// ---------- Prisma, Monday y Funnel mockeados (los tests no tocan BD ni red) ---------- + +const prismaMock = vi.hoisted(() => ({ + cotizacion: { + create: vi.fn(), + update: vi.fn(), + }, +})) + +const mondayMock = vi.hoisted(() => ({ + getUnidad: vi.fn(), + crearOportunidad: vi.fn(), + actualizarOportunidadCotizacion: vi.fn(), +})) + +const funnelMock = vi.hoisted(() => ({ + oportunidadPorTelefono: vi.fn(), +})) + +vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) +vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock })) +vi.mock('../services/funnel.service.js', () => ({ funnelService: funnelMock })) + +import { crearCotizacion } from './cotizaciones.controller.js' + +const unidadFake = { + codigo: 'U-101', + modelo: 'M1', + fechaEntrega: null, + supEstacionamiento: 0, + supConstruccion: 60, + supExcedente: 0, + precioSinAcabados: 1_000_000, + precioConAcabados: 1_100_000, + status: 'DISPONIBLE', + desarrollo: 'DIAGONAL', +} + +const bodyBase = { + unidadCodigo: 'U-101', + acabados: false, + clienteNombre: 'Cliente Demo', + clienteTelefono: '4771234567', + tipoPropuesta: 'BANCARIO' as const, +} + +function reqFake(body: unknown): Request { + return { body } as Request +} + +function resFake() { + const json = vi.fn() + const status = vi.fn().mockReturnValue({ json }) + return { res: { json, status } as unknown as Response, status, json } +} + +beforeEach(() => { + vi.clearAllMocks() + mondayMock.getUnidad.mockResolvedValue(unidadFake) + mondayMock.crearOportunidad.mockResolvedValue('op-nueva') + mondayMock.actualizarOportunidadCotizacion.mockResolvedValue(undefined) + funnelMock.oportunidadPorTelefono.mockResolvedValue(null) + prismaMock.cotizacion.create.mockImplementation(async ({ data }) => ({ id: 'cot-1', ...data })) + prismaMock.cotizacion.update.mockResolvedValue({}) +}) + +describe('crearCotizacion (POST /api/cotizaciones) — match con el funnel', () => { + it('sin oportunidad mapeada para el teléfono: crea una nueva (comportamiento actual)', async () => { + const { res, status, json } = resFake() + await crearCotizacion(reqFake(bodyBase), res) + expect(status).toHaveBeenCalledWith(201) + expect(mondayMock.crearOportunidad).toHaveBeenCalledOnce() + expect(mondayMock.actualizarOportunidadCotizacion).not.toHaveBeenCalled() + expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'op-nueva' }) + }) + + it('con teléfono de lead mapeado: ACTUALIZA esa oportunidad (no duplica) y la pasa a Cotizador', async () => { + funnelMock.oportunidadPorTelefono.mockResolvedValue('op-del-lead') + const { res, status, json } = resFake() + await crearCotizacion(reqFake(bodyBase), res) + expect(status).toHaveBeenCalledWith(201) + expect(funnelMock.oportunidadPorTelefono).toHaveBeenCalledWith('4771234567') + expect(mondayMock.crearOportunidad).not.toHaveBeenCalled() + expect(mondayMock.actualizarOportunidadCotizacion).toHaveBeenCalledOnce() + const [opId, datos] = mondayMock.actualizarOportunidadCotizacion.mock.calls[0] as [string, Record] + expect(opId).toBe('op-del-lead') + expect(datos).toMatchObject({ unidadCodigo: 'U-101', tipoPropuestaLabel: 'CREDITO BANCARIO', estadoLabel: 'Cotizador' }) + expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'op-del-lead' }) + expect(prismaMock.cotizacion.update).toHaveBeenCalledWith({ + where: { id: 'cot-1' }, + data: { mondayItemId: 'op-del-lead' }, + }) + }) + + it('sin teléfono en el body no consulta el funnel', async () => { + const { res } = resFake() + const { clienteTelefono: _omitido, ...sinTelefono } = bodyBase + await crearCotizacion(reqFake(sinTelefono), res) + expect(funnelMock.oportunidadPorTelefono).not.toHaveBeenCalled() + expect(mondayMock.crearOportunidad).toHaveBeenCalledOnce() + }) + + it('si Monday falla, la cotización igual se crea (201 con mondayItemId null)', async () => { + mondayMock.crearOportunidad.mockRejectedValue(new Error('Monday caído')) + const { res, status, json } = resFake() + await crearCotizacion(reqFake(bodyBase), res) + expect(status).toHaveBeenCalledWith(201) + expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: null }) + expect(prismaMock.cotizacion.create).toHaveBeenCalledOnce() + }) +}) diff --git a/api-gateway/src/controllers/cotizaciones.controller.ts b/api-gateway/src/controllers/cotizaciones.controller.ts index b2cb7ca..09bcea4 100644 --- a/api-gateway/src/controllers/cotizaciones.controller.ts +++ b/api-gateway/src/controllers/cotizaciones.controller.ts @@ -1,6 +1,7 @@ import type { Request, Response } from 'express' import { z } from 'zod' -import { mondayService } from '../services/monday.service.js' +import { mondayService, type DatosOportunidad } from '../services/monday.service.js' +import { funnelService } from '../services/funnel.service.js' import { calcularPropuesta, obtenerPrecioLista } from '../services/cotizacion.service.js' import { procesarAlertasInventario, @@ -72,7 +73,7 @@ export async function crearCotizacion(req: Request, res: Response): Promise[], + movimientos: [] as { leadId: string; etapa: string }[], + async asegurarOportunidadAfluencia(lead: Record) { + if (opts.fallaAsegurar === true) throw new Error('Monday caído') + this.asegurados.push(lead) + return 'op-1' + }, + async moverOportunidadDeLead(leadId: string, etapa: string) { + this.movimientos.push({ leadId, etapa }) + return true + }, + async oportunidadDeLead(_leadId: string) { + return opts.oportunidadId === undefined ? 'op-1' : opts.oportunidadId + }, + } +} + const datosBase = { nombre: 'Lead Prueba', telefono: '4771234567' } describe('crearLead (POST /api/leads)', () => { it('crea el item en Monday con Estado NUEVO y devuelve su id', async () => { const monday = mondayMock() const r = await crearLead(monday, { ...datosBase, origen: 'Facebook', notas: 'Vio el volante' }) - expect(r).toEqual({ id: 'lead-nuevo-1' }) + expect(r).toEqual({ id: 'lead-nuevo-1', oportunidadId: null }) expect(monday.leadsCreados).toEqual([ { nombre: 'Lead Prueba', @@ -67,19 +97,29 @@ describe('crearLead (POST /api/leads)', () => { email: 'lead@correo.com', desarrollo: 'SENDA', }) - expect(r).toEqual({ id: 'lead-nuevo-1' }) + expect(r).toEqual({ id: 'lead-nuevo-1', oportunidadId: null }) expect(monday.leadsCreados[0]).toMatchObject({ email: 'lead@correo.com', desarrolloLabel: 'SENDA' }) }) - it('rechaza un desarrollo que no sea label real de la columna Desarrollo', async () => { + it('con funnel: asegura la oportunidad de Afluencia con los datos del lead', async () => { const monday = mondayMock() - await expect(crearLead(monday, { ...datosBase, desarrollo: 'MONTEBELLO' })).rejects.toThrow( - /Desarrollo inválido/, - ) - expect(monday.leadsCreados).toEqual([]) + const funnel = funnelMock() + const r = await crearLead(monday, { ...datosBase, email: 'lead@correo.com', desarrollo: 'SENDA' }, funnel) + expect(r).toEqual({ id: 'lead-nuevo-1', oportunidadId: 'op-1' }) + expect(funnel.asegurados).toEqual([ + { id: 'lead-nuevo-1', nombre: 'Lead Prueba', telefono: '4771234567', email: 'lead@correo.com', desarrolloLabel: 'SENDA' }, + ]) }) - it('duplicado por teléfono: no crea nada y devuelve el lead existente (endpoint → 409)', async () => { + it('si falla la oportunidad de Afluencia, el lead igual se crea (oportunidadId null)', async () => { + const monday = mondayMock() + const funnel = funnelMock({ fallaAsegurar: true }) + const r = await crearLead(monday, datosBase, funnel) + expect(r).toEqual({ id: 'lead-nuevo-1', oportunidadId: null }) + expect(monday.leadsCreados).toHaveLength(1) + }) + + it('duplicado por teléfono: no crea lead ni oportunidad de Afluencia', async () => { const existente: LeadMonday = { id: 'lead-viejo-9', nombre: 'Lead Prueba', @@ -92,9 +132,19 @@ describe('crearLead (POST /api/leads)', () => { vendedorIds: [], } const monday = mondayMock({ duplicado: existente }) - const r = await crearLead(monday, datosBase) + const funnel = funnelMock() + const r = await crearLead(monday, datosBase, funnel) expect(r).toEqual({ duplicado: existente }) expect(monday.leadsCreados).toEqual([]) + expect(funnel.asegurados).toEqual([]) + }) + + it('rechaza un desarrollo que no sea label real de la columna Desarrollo', 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', async () => { @@ -132,6 +182,29 @@ describe('cambiarEstadoLead (POST /api/leads/:itemId/estado)', () => { await cambiarEstadoLead(monday, 'lead-1', 'AGENDA CITA') expect(monday.cambios).toHaveLength(1) }) + + it('AGENDA CITA mueve la oportunidad del lead a "Agendo Cita"', async () => { + const monday = mondayMock() + const funnel = funnelMock() + await cambiarEstadoLead(monday, 'lead-1', 'AGENDA CITA', '2026-09-01', funnel) + expect(funnel.movimientos).toEqual([{ leadId: 'lead-1', etapa: 'Agendo Cita' }]) + }) + + it('otros estados NO mueven la oportunidad del lead', async () => { + const monday = mondayMock() + const funnel = funnelMock() + await cambiarEstadoLead(monday, 'lead-1', 'CONTACTADO', undefined, funnel) + await cambiarEstadoLead(monday, 'lead-1', 'CALIFICADO', undefined, funnel) + expect(funnel.movimientos).toEqual([]) + }) + + it('un fallo del funnel en AGENDA CITA no rompe el cambio de estado', async () => { + const monday = mondayMock() + const funnel = funnelMock() + vi.spyOn(funnel, 'moverOportunidadDeLead').mockRejectedValueOnce(new Error('Monday caído')) + await expect(cambiarEstadoLead(monday, 'lead-1', 'AGENDA CITA', undefined, funnel)).resolves.toBeUndefined() + expect(monday.cambios).toEqual([{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'AGENDA CITA' }]) + }) }) describe('descartarLead (POST /api/leads/:itemId/descartar)', () => { @@ -161,6 +234,44 @@ describe('descartarLead (POST /api/leads/:itemId/descartar)', () => { await expect(descartarLead(monday, 'lead-1', 'Cliente')).rejects.toThrow('Monday caído') expect(monday.motivos).toEqual([]) }) + + it('con funnel: la oportunidad pasa a "Perdido" y hereda el motivo en el dropdown', async () => { + const monday = mondayMock() + const funnel = funnelMock({ oportunidadId: 'op-9' }) + await descartarLead(monday, 'lead-1', 'Precio', undefined, funnel) + expect(monday.cambios).toContainEqual({ boardId: '555', itemId: 'op-9', columna: 'Estado', valor: 'Perdido' }) + expect(monday.cambios).toContainEqual({ + boardId: '555', + itemId: 'op-9', + columna: 'Motivo No Compra', + valor: 'Precio', + }) + }) + + it('si el motivo no es label del dropdown, solo mueve a Perdido (sin dropdown)', async () => { + const monday = mondayMock({ labelsMotivo: ['Otro'] }) + const funnel = funnelMock({ oportunidadId: 'op-9' }) + await descartarLead(monday, 'lead-1', 'Precio', undefined, funnel) + expect(monday.cambios).toContainEqual({ boardId: '555', itemId: 'op-9', columna: 'Estado', valor: 'Perdido' }) + expect(monday.cambios.find((c) => c.columna === 'Motivo No Compra')).toBeUndefined() + }) + + it('sin mapping lead ↔ oportunidad no toca el tablero Oportunidades', async () => { + const monday = mondayMock() + const funnel = funnelMock({ oportunidadId: null }) + const motivoItemId = await descartarLead(monday, 'lead-1', 'Timing', undefined, funnel) + expect(motivoItemId).toBe('motivo-item-1') + expect(monday.cambios.filter((c) => c.boardId === '555')).toEqual([]) + }) + + it('un fallo del funnel no rompe el descarte (DESCARTADO + motivo ya quedaron)', async () => { + const monday = mondayMock() + const funnel = funnelMock({ oportunidadId: 'op-9' }) + vi.spyOn(funnel, 'oportunidadDeLead').mockRejectedValueOnce(new Error('Monday caído')) + const motivoItemId = await descartarLead(monday, 'lead-1', 'Precio', undefined, funnel) + expect(motivoItemId).toBe('motivo-item-1') + expect(monday.cambios).toContainEqual({ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'DESCARTADO' }) + }) }) describe('buscarLeads (GET /api/leads/buscar)', () => { diff --git a/api-gateway/src/controllers/leads.controller.ts b/api-gateway/src/controllers/leads.controller.ts index 4101a0e..1793cd6 100644 --- a/api-gateway/src/controllers/leads.controller.ts +++ b/api-gateway/src/controllers/leads.controller.ts @@ -2,6 +2,7 @@ import type { Request, Response } from 'express' import { z } from 'zod' import { config } from '../config/index.js' import { mondayService, type LeadMonday, type MondayService } from '../services/monday.service.js' +import { funnelService, type FunnelService } from '../services/funnel.service.js' import { MOTIVOS_NO_COMPRA } from './oportunidades.controller.js' import { AppError } from '../utils/errors.js' @@ -69,16 +70,19 @@ type MondayLeads = Pick< | 'obtenerNombreItem' > -export type ResultadoCrearLead = { id: string } | { duplicado: LeadMonday } +export type ResultadoCrearLead = { id: string; oportunidadId: string | null } | { duplicado: LeadMonday } /** * Crea un lead en Monday (Estado NUEVO). Si viene teléfono y ya existe un lead * con ese número, no crea nada y devuelve { duplicado } (el endpoint → 409). * El origen se valida contra los labels reales de la columna del board. + * Con `funnel`, el lead entra además al tablero Oportunidades en 'Afluencia'; + * un fallo de Monday en la oportunidad solo se loguea (el lead ya quedó). */ export async function crearLead( monday: MondayLeads, datos: z.infer, + funnel?: Pick, ): Promise { const boardLeads = config.monday.boards.leads @@ -109,7 +113,23 @@ export async function crearLead( desarrolloLabel: datos.desarrollo, notas: datos.notas, }) - return { id } + + let oportunidadId: string | null = null + if (funnel !== undefined) { + try { + oportunidadId = await funnel.asegurarOportunidadAfluencia({ + id, + nombre: datos.nombre, + telefono: datos.telefono, + email: datos.email, + desarrolloLabel: datos.desarrollo, + }) + } catch (err) { + // eslint-disable-next-line no-console + console.error(`No se pudo crear la oportunidad de Afluencia del lead ${id}:`, err) + } + } + return { id, oportunidadId } } /** Cambia el estado del lead (whitelist) y, en AGENDA CITA, puede setear la Fecha */ @@ -118,12 +138,22 @@ export async function cambiarEstadoLead( itemId: string, estado: (typeof ESTADOS_LEAD_PERMITIDOS)[number], fecha?: string, + funnel?: Pick, ): Promise { const boardLeads = config.monday.boards.leads await monday.cambiarValorColumna(boardLeads, itemId, 'Estado', estado) if (estado === 'AGENDA CITA' && fecha !== undefined) { await monday.cambiarValorColumna(boardLeads, itemId, 'Fecha', fecha) } + // La oportunidad del lead avanza a 'Agendo Cita'; un fallo no rompe el cambio + if (estado === 'AGENDA CITA' && funnel !== undefined) { + try { + await funnel.moverOportunidadDeLead(itemId, 'Agendo Cita') + } catch (err) { + // eslint-disable-next-line no-console + console.error(`No se pudo mover a 'Agendo Cita' la oportunidad del lead ${itemId}:`, err) + } + } } /** Estado DESCARTADO + registro en el catálogo Motivos No Compra (+ Notas opcional) */ @@ -132,6 +162,7 @@ export async function descartarLead( itemId: string, motivo: string, comentario?: string, + funnel?: Pick, ): Promise { const boardLeads = config.monday.boards.leads const nombreItem = await monday.obtenerNombreItem(itemId) @@ -139,17 +170,40 @@ export async function descartarLead( if (comentario !== undefined) { await monday.cambiarValorColumna(boardLeads, itemId, 'Notas', comentario) } - return monday.crearMotivoNoCompra({ + const motivoItemId = await monday.crearMotivoNoCompra({ nombre: `${motivo} — ${nombreItem}`, categoria: motivo, ...(comentario !== undefined ? { descripcion: comentario } : {}), }) + + // La oportunidad del lead pasa a 'Perdido' y hereda el motivo en su dropdown + // (solo si el label existe en la columna). Fallos → solo log. + if (funnel !== undefined) { + try { + const oportunidadId = await funnel.oportunidadDeLead(itemId) + if (oportunidadId !== null) { + const boardOportunidades = config.monday.boards.oportunidades + await monday.cambiarValorColumna(boardOportunidades, oportunidadId, 'Estado', 'Perdido') + const labels = await monday.labelsDeColumna(boardOportunidades, 'Motivo No Compra') + if (labels.includes(motivo)) { + await monday.cambiarValorColumna(boardOportunidades, oportunidadId, 'Motivo No Compra', motivo) + } else { + // eslint-disable-next-line no-console + console.log(`El motivo "${motivo}" no es label del dropdown 'Motivo No Compra'; se omite`) + } + } + } catch (err) { + // eslint-disable-next-line no-console + console.error(`No se pudo mover a 'Perdido' la oportunidad del lead ${itemId}:`, err) + } + } + return motivoItemId } /** POST /api/leads { nombre, telefono?, email?, origen?, notas? } */ export async function crearLeadEndpoint(req: Request, res: Response): Promise { const body = crearLeadSchema.parse(req.body) - const resultado = await crearLead(mondayService, body) + const resultado = await crearLead(mondayService, body, funnelService) if ('duplicado' in resultado) { res.status(409).json({ error: 'Ya existe un lead con ese teléfono', @@ -158,14 +212,14 @@ 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, funnelService) res.json({ itemId, estado: body.estado, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) }) } @@ -173,7 +227,7 @@ export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Pr 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) + const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario, funnelService) res.json({ itemId, estado: 'DESCARTADO', motivo: body.motivo, motivoItemId }) } diff --git a/api-gateway/src/controllers/webhooks.controller.test.ts b/api-gateway/src/controllers/webhooks.controller.test.ts index b9812ac..86203ec 100644 --- a/api-gateway/src/controllers/webhooks.controller.test.ts +++ b/api-gateway/src/controllers/webhooks.controller.test.ts @@ -4,6 +4,7 @@ import type { Request, Response } from 'express' const mocks = vi.hoisted(() => ({ runRoundRobin: vi.fn(), escaneoOportunidades: vi.fn(), + asegurarAfluencia: vi.fn(), })) vi.mock('../services/automatizaciones.service.js', () => ({ @@ -13,6 +14,12 @@ vi.mock('../services/automatizaciones.service.js', () => ({ }, })) +vi.mock('../services/funnel.service.js', () => ({ + funnelService: { + asegurarOportunidadAfluenciaPorLeadId: mocks.asegurarAfluencia, + }, +})) + vi.mock('../config/index.js', () => ({ config: { monday: { @@ -35,6 +42,7 @@ beforeEach(() => { vi.clearAllMocks() mocks.runRoundRobin.mockResolvedValue({ asignados: 1 }) mocks.escaneoOportunidades.mockResolvedValue({ apartadosCreados: ['x'], ventasCreadas: [], errores: [] }) + mocks.asegurarAfluencia.mockResolvedValue('op-1') }) describe('webhookMonday', () => { @@ -51,6 +59,7 @@ describe('webhookMonday', () => { expect(sendStatus).toHaveBeenCalledWith(200) expect(mocks.runRoundRobin).not.toHaveBeenCalled() expect(mocks.escaneoOportunidades).not.toHaveBeenCalled() + expect(mocks.asegurarAfluencia).not.toHaveBeenCalled() }) it('create_item en Leads dispara el round-robin del item', () => { @@ -58,6 +67,7 @@ describe('webhookMonday', () => { webhookMonday(reqCon({ event: { type: 'create_item', boardId: 111, itemId: 999 } }), res) expect(sendStatus).toHaveBeenCalledWith(200) expect(mocks.runRoundRobin).toHaveBeenCalledWith('999') + expect(mocks.asegurarAfluencia).toHaveBeenCalledWith('999') }) it('create_pulse (nombre real del evento) con pulseId también dispara el round-robin', () => { @@ -65,13 +75,15 @@ describe('webhookMonday', () => { webhookMonday(reqCon({ event: { type: 'create_pulse', boardId: 111, pulseId: 777 } }), res) expect(sendStatus).toHaveBeenCalledWith(200) expect(mocks.runRoundRobin).toHaveBeenCalledWith('777') + expect(mocks.asegurarAfluencia).toHaveBeenCalledWith('777') }) - it('create_item en otro board no dispara round-robin', () => { + it('create_item en otro board no dispara round-robin ni afluencia', () => { const { res, sendStatus } = resFake() webhookMonday(reqCon({ event: { type: 'create_item', boardId: 555, itemId: 999 } }), res) expect(sendStatus).toHaveBeenCalledWith(200) expect(mocks.runRoundRobin).not.toHaveBeenCalled() + expect(mocks.asegurarAfluencia).not.toHaveBeenCalled() }) it('change_status_column_value en Oportunidades dispara el escaneo', () => { diff --git a/api-gateway/src/controllers/webhooks.controller.ts b/api-gateway/src/controllers/webhooks.controller.ts index 752ca82..8e537ca 100644 --- a/api-gateway/src/controllers/webhooks.controller.ts +++ b/api-gateway/src/controllers/webhooks.controller.ts @@ -2,6 +2,7 @@ import type { Request, Response } from 'express' import { z } from 'zod' import { config } from '../config/index.js' import { automatizacionesService } from '../services/automatizaciones.service.js' +import { funnelService } from '../services/funnel.service.js' const eventoSchema = z .object({ @@ -65,6 +66,18 @@ export function webhookMonday(req: Request, res: Response): void { // eslint-disable-next-line no-console console.error(`[webhook monday] error en round-robin item=${id}:`, err) }) + // El lead entra también al funnel de Oportunidades (etapa 'Afluencia'). + // El payload no trae nombre/teléfono: el servicio los consulta en Monday. + void funnelService + .asegurarOportunidadAfluenciaPorLeadId(id) + .then((opId) => { + // eslint-disable-next-line no-console + console.log(`[webhook monday] afluencia item=${id}: oportunidad ${opId ?? 'no creada'}`) + }) + .catch((err: unknown) => { + // eslint-disable-next-line no-console + console.error(`[webhook monday] error en afluencia item=${id}:`, err) + }) } // Cambio de etapa en Oportunidades → crear Apartado/Venta si aplica. diff --git a/api-gateway/src/services/funnel.service.test.ts b/api-gateway/src/services/funnel.service.test.ts new file mode 100644 index 0000000..2ea531e --- /dev/null +++ b/api-gateway/src/services/funnel.service.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi } from 'vitest' +import { FunnelService, type LeadOportunidadStore } from './funnel.service.js' +import type { LeadMonday } from './monday.service.js' + +/** Monday mockeado: registra oportunidades creadas y cambios de columna */ +function mondayMock(opts: { leadPorTelefono?: LeadMonday | null; leadPorId?: LeadMonday | null } = {}) { + return { + oportunidadesCreadas: [] as Record[], + cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[], + async crearOportunidadAfluencia(datos: Record) { + this.oportunidadesCreadas.push(datos) + return `op-${this.oportunidadesCreadas.length}` + }, + async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) { + this.cambios.push({ boardId, itemId, columna, valor }) + }, + async buscarLeadPorTelefono(_telefono: string) { + return opts.leadPorTelefono ?? null + }, + async getLeadPorId(_itemId: string) { + return opts.leadPorId ?? null + }, + } +} + +/** Store en memoria (equivalente al LeadOportunidadStore de Prisma) */ +function storeMock(): LeadOportunidadStore & { mapping: Map } { + const mapping = new Map() + return { + mapping, + async buscarPorLeadId(leadId: string) { + return mapping.get(leadId) ?? null + }, + async guardar(leadId: string, oportunidadId: string) { + mapping.set(leadId, oportunidadId) + }, + } +} + +const leadBase = { id: 'lead-1', nombre: 'Lead Prueba', telefono: '4771234567', desarrolloLabel: 'SENDA' } + +const leadMonday = (overrides: Partial): LeadMonday => ({ + id: 'lead-1', + nombre: 'Lead Prueba', + createdAt: '', + updatedAt: '', + estado: 'NUEVO', + origen: '', + telefono: '', + email: '', + vendedorIds: [], + ...overrides, +}) + +describe('FunnelService.asegurarOportunidadAfluencia', () => { + it('crea la oportunidad en Monday y guarda el mapping lead ↔ oportunidad', async () => { + const monday = mondayMock() + const store = storeMock() + const funnel = new FunnelService(monday, store) + const opId = await funnel.asegurarOportunidadAfluencia(leadBase) + expect(opId).toBe('op-1') + expect(monday.oportunidadesCreadas).toEqual([ + { nombre: 'Lead Prueba', telefono: '4771234567', email: undefined, desarrolloLabel: 'SENDA' }, + ]) + expect(store.mapping.get('lead-1')).toBe('op-1') + }) + + it('es idempotente: con mapping existente no crea una segunda oportunidad', async () => { + const monday = mondayMock() + const store = storeMock() + store.mapping.set('lead-1', 'op-vieja') + const funnel = new FunnelService(monday, store) + const opId = await funnel.asegurarOportunidadAfluencia(leadBase) + expect(opId).toBe('op-vieja') + expect(monday.oportunidadesCreadas).toEqual([]) + }) + + it('por leadId (webhook): consulta el lead en Monday y crea la oportunidad', async () => { + const monday = mondayMock({ leadPorId: leadMonday({ telefono: '4771234567', email: 'a@b.com' }) }) + const store = storeMock() + const funnel = new FunnelService(monday, store) + const opId = await funnel.asegurarOportunidadAfluenciaPorLeadId('lead-1') + expect(opId).toBe('op-1') + expect(monday.oportunidadesCreadas[0]).toMatchObject({ nombre: 'Lead Prueba', telefono: '4771234567' }) + expect(store.mapping.get('lead-1')).toBe('op-1') + }) + + it('por leadId inexistente en Monday no crea nada (null)', async () => { + const monday = mondayMock({ leadPorId: null }) + const funnel = new FunnelService(monday, storeMock()) + await expect(funnel.asegurarOportunidadAfluenciaPorLeadId('fantasma')).resolves.toBeNull() + expect(monday.oportunidadesCreadas).toEqual([]) + }) +}) + +describe('FunnelService.moverOportunidadDeLead', () => { + it('mueve la oportunidad mapeada a la etapa indicada (board 555 en tests)', async () => { + const monday = mondayMock() + const store = storeMock() + store.mapping.set('lead-1', 'op-9') + const funnel = new FunnelService(monday, store) + await expect(funnel.moverOportunidadDeLead('lead-1', 'Agendo Cita')).resolves.toBe(true) + expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-9', columna: 'Estado', valor: 'Agendo Cita' }]) + }) + + it('sin mapping es no-op (false, sin llamadas a Monday)', async () => { + const monday = mondayMock() + const funnel = new FunnelService(monday, storeMock()) + await expect(funnel.moverOportunidadDeLead('lead-sin-op', 'Perdido')).resolves.toBe(false) + expect(monday.cambios).toEqual([]) + }) +}) + +describe('FunnelService.oportunidadPorTelefono', () => { + it('resuelve lead por teléfono y devuelve la oportunidad mapeada', async () => { + const monday = mondayMock({ leadPorTelefono: leadMonday({ id: 'lead-7' }) }) + const store = storeMock() + store.mapping.set('lead-7', 'op-7') + const funnel = new FunnelService(monday, store) + await expect(funnel.oportunidadPorTelefono('4771234567')).resolves.toBe('op-7') + }) + + it('sin lead con ese teléfono (o sin mapping) devuelve null', async () => { + const funnel = new FunnelService(mondayMock(), storeMock()) + await expect(funnel.oportunidadPorTelefono('4770000000')).resolves.toBeNull() + const monday = mondayMock({ leadPorTelefono: leadMonday({ id: 'sin-mapping' }) }) + await expect(new FunnelService(monday, storeMock()).oportunidadPorTelefono('4771234567')).resolves.toBeNull() + }) +}) + +describe('FunnelService.moverAApartoConDedup', () => { + it('escribe el dedup apartado-creado ANTES de mover la oportunidad a Aparto', async () => { + const monday = mondayMock() + const store = storeMock() + store.mapping.set('lead-1', 'op-1') + const funnel = new FunnelService(monday, store) + + const orden: string[] = [] + const state = { + get: vi.fn(async () => null), + set: vi.fn(async (clave: string) => { + orden.push(`dedup:${clave}`) + }), + } + const cambiarSpy = vi.spyOn(monday, 'cambiarValorColumna').mockImplementation(async () => { + orden.push('monday:Aparto') + }) + + const opId = await funnel.moverAApartoConDedup('lead-1', state) + expect(opId).toBe('op-1') + expect(state.set).toHaveBeenCalledWith('apartado-creado:op-1', expect.any(String)) + expect(cambiarSpy).toHaveBeenCalledWith('555', 'op-1', 'Estado', 'Aparto') + expect(orden).toEqual(['dedup:apartado-creado:op-1', 'monday:Aparto']) + }) + + it('sin mapping no escribe dedup ni mueve nada (null)', async () => { + const monday = mondayMock() + const funnel = new FunnelService(monday, storeMock()) + const state = { get: vi.fn(async () => null), set: vi.fn(async () => {}) } + await expect(funnel.moverAApartoConDedup('lead-sin-op', state)).resolves.toBeNull() + expect(state.set).not.toHaveBeenCalled() + expect(monday.cambios).toEqual([]) + }) +}) diff --git a/api-gateway/src/services/funnel.service.ts b/api-gateway/src/services/funnel.service.ts new file mode 100644 index 0000000..9e66d6a --- /dev/null +++ b/api-gateway/src/services/funnel.service.ts @@ -0,0 +1,127 @@ +import { config } from '../config/index.js' +import { claveApartadoCreado, type StateStore } from './automatizaciones.service.js' +import { mondayService, type MondayService } from './monday.service.js' +import { prisma } from './prisma.js' + +/** + * Funnel comercial Leads → Oportunidades: + * todo lead capturado entra al tablero Oportunidades en etapa 'Afluencia' y la + * oportunidad avanza de etapa sola conforme avanza el proceso (Agendo Cita, + * Cotizador, Aparto, Ganado / Perdido). + * + * La vinculación lead ↔ oportunidad vive en la tabla LeadOportunidad (dedup + * persistente: una oportunidad por lead), inyectada como store para testear. + * Los errores de Monday se propagan al caller, que decide loguear y continuar + * (la acción principal — crear el lead, la cotización o el alta — no se rompe). + */ + +export interface LeadFunnel { + id: string // item ID del lead en Monday + nombre: string + telefono?: string | undefined + email?: string | undefined + desarrolloLabel?: string | undefined +} + +/** Store del mapping lead ↔ oportunidad (Prisma en prod, memoria en tests) */ +export interface LeadOportunidadStore { + buscarPorLeadId(leadId: string): Promise // oportunidadId + guardar(leadId: string, oportunidadId: string): Promise +} + +type MondayFunnel = Pick< + MondayService, + 'crearOportunidadAfluencia' | 'cambiarValorColumna' | 'buscarLeadPorTelefono' | 'getLeadPorId' +> + +export class FunnelService { + constructor( + private readonly monday: MondayFunnel, + private readonly store: LeadOportunidadStore, + ) {} + + /** + * Garantiza que el lead tenga su oportunidad en 'Afluencia': si ya existe el + * mapping devuelve la oportunidad existente; si no, la crea en Monday y + * guarda el mapping. Idempotente. + */ + async asegurarOportunidadAfluencia(lead: LeadFunnel): Promise { + const existente = await this.store.buscarPorLeadId(lead.id) + if (existente !== null) return existente + const oportunidadId = await this.monday.crearOportunidadAfluencia({ + nombre: lead.nombre, + telefono: lead.telefono, + email: lead.email, + desarrolloLabel: lead.desarrolloLabel, + }) + await this.store.guardar(lead.id, oportunidadId) + return oportunidadId + } + + /** + * Variante para el webhook create_pulse: el payload no trae nombre/teléfono, + * así que el lead se consulta en Monday antes de asegurar la oportunidad. + */ + async asegurarOportunidadAfluenciaPorLeadId(leadId: string): Promise { + const lead = await this.monday.getLeadPorId(leadId) + if (!lead) return null + return this.asegurarOportunidadAfluencia({ + id: lead.id, + nombre: lead.nombre, + telefono: lead.telefono !== '' ? lead.telefono : undefined, + email: lead.email !== '' ? lead.email : undefined, + }) + } + + /** Mueve la oportunidad del lead a otra etapa. Sin mapping → no-op (false). */ + async moverOportunidadDeLead(leadId: string, etapa: string): Promise { + const oportunidadId = await this.store.buscarPorLeadId(leadId) + if (oportunidadId === null) return false + await this.monday.cambiarValorColumna(config.monday.boards.oportunidades, oportunidadId, 'Estado', etapa) + return true + } + + /** Oportunidad ligada al lead (para el match de cotizaciones); null si no hay */ + async oportunidadDeLead(leadId: string): Promise { + return this.store.buscarPorLeadId(leadId) + } + + /** + * Oportunidad del lead que tenga ese teléfono (match de cotizaciones: + * busca el lead por teléfono en Monday y de ahí el mapping). + */ + async oportunidadPorTelefono(telefono: string): Promise { + const lead = await this.monday.buscarLeadPorTelefono(telefono) + if (!lead) return null + return this.store.buscarPorLeadId(lead.id) + } + + /** + * Alta de cliente desde un lead: escribe el dedup `apartado-creado:` + * ANTES de mover la oportunidad a 'Aparto'. Este orden evita la carrera con + * el webhook/cron (escaneoOportunidades vería 'Aparto' y crearía un Apartado + * duplicado; con el dedup previo lo salta). Devuelve la oportunidadId movida + * o null si el lead no tiene mapping. + */ + async moverAApartoConDedup(leadId: string, state: StateStore): Promise { + const oportunidadId = await this.store.buscarPorLeadId(leadId) + if (oportunidadId === null) return null + await state.set(claveApartadoCreado(oportunidadId), new Date().toISOString()) + await this.monday.cambiarValorColumna(config.monday.boards.oportunidades, oportunidadId, 'Estado', 'Aparto') + return oportunidadId + } +} + +/** Store respaldado por la tabla LeadOportunidad de Prisma */ +export const prismaLeadOportunidadStore: LeadOportunidadStore = { + async buscarPorLeadId(leadId) { + const row = await prisma.leadOportunidad.findUnique({ where: { leadId } }) + return row?.oportunidadId ?? null + }, + async guardar(leadId, oportunidadId) { + await prisma.leadOportunidad.create({ data: { leadId, oportunidadId } }) + }, +} + +/** Singleton usado por los controllers (los tests instancian el suyo) */ +export const funnelService = new FunnelService(mondayService, prismaLeadOportunidadStore) diff --git a/api-gateway/src/services/monday.service.ts b/api-gateway/src/services/monday.service.ts index 117a0aa..043f514 100644 --- a/api-gateway/src/services/monday.service.ts +++ b/api-gateway/src/services/monday.service.ts @@ -390,6 +390,44 @@ export class MondayService { return this.crearItem(boardId, datos.clienteNombre, columnValues) } + /** + * Oportunidad en etapa 'Afluencia' a partir de un lead recién capturado + * (entrada al funnel; sin unidad ni precios todavía). + */ + async crearOportunidadAfluencia(datos: DatosOportunidadAfluencia): Promise { + const boardId = config.monday.boards.oportunidades + const columnValues = await this.construirColumnValues(boardId, { + 'Cliente teléfono': datos.telefono, + 'Cliente email': datos.email, + Desarrollo: datos.desarrolloLabel, + Estado: 'Afluencia', + }) + return this.crearItem(boardId, datos.nombre, columnValues) + } + + /** + * Actualiza una oportunidad existente con los datos de una cotización + * (mismo builder de valores que crearOportunidad) y la mueve a 'Cotizador'. + */ + async actualizarOportunidadCotizacion(oportunidadId: string, datos: DatosOportunidad): Promise { + const boardId = config.monday.boards.oportunidades + const columnValues = await this.construirColumnValues(boardId, { + Unidad: datos.unidadCodigo, + 'Tipo propuesta': datos.tipoPropuestaLabel, + 'Precio de lista': datos.precioLista, + 'Descuento %': datos.descuentoPct, + 'Precio final': datos.precioFinal, + 'Fecha cotización': datos.fechaCotizacion, + Estado: datos.estadoLabel, + }) + await this.graphql( + `mutation ($boardId: ID!, $itemId: ID!, $columnValues: JSON!) { + change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $columnValues) { id } + }`, + { boardId, itemId: oportunidadId, columnValues }, + ) + } + async crearContacto(datos: DatosContacto): Promise { const boardId = config.monday.boards.contactos const columnValues = await this.construirColumnValues(boardId, { @@ -513,11 +551,6 @@ export class MondayService { async getLeads(): Promise { const boardId = config.monday.boards.leads const cols = await this.columnasDe(boardId) - const colVendedor = cols.get('Vendedor') - if (!colVendedor || colVendedor.type !== 'people') { - throw new AppError(500, `Columna people "Vendedor" no existe en el tablero Leads (${boardId})`) - } - const colEstado = cols.get('Estado') const items: ItemConFechas[] = [] let cursor: string | null = null @@ -552,33 +585,55 @@ export class MondayService { cursor = page.cursor } while (cursor !== null) - return items.map((item) => { - const porId = new Map(item.column_values.map((cv) => [cv.id, cv])) - const rawVendedor = porId.get(colVendedor.id)?.value - let vendedorIds: number[] = [] - if (rawVendedor) { - try { - const parsed = JSON.parse(rawVendedor) as { personsAndTeams?: { id: number; kind: string }[] } - vendedorIds = (parsed.personsAndTeams ?? []).filter((p) => p.kind === 'person').map((p) => p.id) - } catch { - vendedorIds = [] - } + return items.map((item) => this.mapearLead(item, cols)) + } + + /** Un lead del tablero Leads por item ID (el payload del webhook no trae datos) */ + async getLeadPorId(itemId: string): Promise { + const boardId = config.monday.boards.leads + const cols = await this.columnasDe(boardId) + const data = await this.graphql<{ items: (ItemConFechas | null)[] }>( + `query ($ids: [ID!]) { + items(ids: $ids) { id name created_at updated_at column_values { id text value } } + }`, + { ids: [itemId] }, + ) + const item = data.items[0] + if (!item) return null + return this.mapearLead(item, cols) + } + + private mapearLead(item: ItemConFechas, cols: Map): LeadMonday { + const colVendedor = cols.get('Vendedor') + if (!colVendedor || colVendedor.type !== 'people') { + throw new AppError(500, `Columna people "Vendedor" no existe en el tablero Leads`) + } + const colEstado = cols.get('Estado') + const porId = new Map(item.column_values.map((cv) => [cv.id, cv])) + const rawVendedor = porId.get(colVendedor.id)?.value + let vendedorIds: number[] = [] + if (rawVendedor) { + try { + const parsed = JSON.parse(rawVendedor) as { personsAndTeams?: { id: number; kind: string }[] } + vendedorIds = (parsed.personsAndTeams ?? []).filter((p) => p.kind === 'person').map((p) => p.id) + } catch { + vendedorIds = [] } - const colOrigen = cols.get('Origen') - const colTelefono = cols.get('Teléfono') - const colEmail = cols.get('Email') - return { - id: item.id, - nombre: item.name, - createdAt: item.created_at, - updatedAt: item.updated_at, - estado: colEstado ? (porId.get(colEstado.id)?.text ?? '') : '', - origen: colOrigen ? (porId.get(colOrigen.id)?.text ?? '') : '', - telefono: colTelefono ? (porId.get(colTelefono.id)?.text ?? '') : '', - email: colEmail ? (porId.get(colEmail.id)?.text ?? '') : '', - vendedorIds, - } - }) + } + const colOrigen = cols.get('Origen') + const colTelefono = cols.get('Teléfono') + const colEmail = cols.get('Email') + return { + id: item.id, + nombre: item.name, + createdAt: item.created_at, + updatedAt: item.updated_at, + estado: colEstado ? (porId.get(colEstado.id)?.text ?? '') : '', + origen: colOrigen ? (porId.get(colOrigen.id)?.text ?? '') : '', + telefono: colTelefono ? (porId.get(colTelefono.id)?.text ?? '') : '', + email: colEmail ? (porId.get(colEmail.id)?.text ?? '') : '', + vendedorIds, + } } /** @@ -933,6 +988,13 @@ export interface DatosOportunidad { clienteTelefono?: string } +export interface DatosOportunidadAfluencia { + nombre: string // name del item = nombre del lead + telefono?: string | undefined + email?: string | undefined + desarrolloLabel?: string | undefined // label tal cual de la columna status 'Desarrollo' +} + export interface DatosContacto { nombre: string telefono?: string diff --git a/docs/AUTOMATIZACIONES-MONDAY.md b/docs/AUTOMATIZACIONES-MONDAY.md index a86b84a..a47c44c 100644 --- a/docs/AUTOMATIZACIONES-MONDAY.md +++ b/docs/AUTOMATIZACIONES-MONDAY.md @@ -36,6 +36,26 @@ Notas técnicas (quirks de Monday, ya contemplados en el código): - Monday no firma sus webhooks: el servidor usa el evento solo como disparador y re-consulta el tablero por API antes de escribir nada. +### 0.1 La cadena del funnel: Leads → Oportunidades + +Los leads **no** se cargan como clientes directo: cada lead capturado entra al +tablero **Oportunidades** en etapa **Afluencia** y la oportunidad avanza sola +conforme avanza el proceso. La vinculación lead ↔ oportunidad vive en la tabla +`LeadOportunidad` de la base local (una oportunidad por lead; idempotente). + +| Transición | Evento que la dispara | +|---|---| +| → **Afluencia** | `POST /api/leads` (captura) o webhook `create_pulse` en Leads (alta directa en la UI de Monday): crea la oportunidad con nombre, teléfono, email y Desarrollo del lead | +| → **Agendo Cita** | `POST /api/leads/:id/estado` con estado `AGENDA CITA` | +| → **Cotizador** | `POST /api/cotizaciones` con un teléfono que ya tiene lead mapeado: **actualiza** la misma oportunidad (Unidad, Tipo propuesta, precios, Fecha cotización) en vez de crear una segunda | +| → **Aparto** | `POST /api/clientes` con `leadMondayId` en el payload (app alta-clientes). Se escribe el dedup `apartado-creado:` **antes** del cambio de etapa para que el webhook/cron no duplique el Apartado | +| → **Ganado** | Manual en Monday (o flujo Enkontrol): el escaneo crea el item en Ventas | +| → **Perdido** | `POST /api/leads/:id/descartar`: mueve la oportunidad y, si el motivo es label del dropdown **Motivo No Compra**, lo setea también | + +Reglas de resiliencia: un fallo de Monday en cualquiera de estas transiciones +solo se loguea — la acción principal (crear el lead, la cotización o el alta) +nunca se rompe por el funnel. + Para re-registrar un webhook (p. ej. si cambia el dominio): ```graphql