feat(funnel): leads entran como Afluencia al funnel de Oportunidades
- Captura rápida crea lead + oportunidad en etapa Afluencia (vinculadas por nueva tabla LeadOportunidad); también aplica a leads creados directo en Monday vía webhook - Transiciones automáticas: AGENDA CITA → Agendo Cita, descarte → Perdido (+ dropdown Motivo No Compra), cotización con mismo teléfono ACTUALIZA la oportunidad (no duplica) → Cotizador, alta de cliente con leadMondayId → Aparto (dedup previo para no duplicar Apartado) - Columna Desarrollo agregada al board Oportunidades (color_mm6sa20t) - Migración lead_oportunidad aplicada con migrate diff + resolve (la DB tiene drift histórico por db push; migrate dev pedía reset) - Tests: 322 verdes; smoke en vivo verificado y limpiado
This commit is contained in:
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<void> {
|
||||
},
|
||||
})
|
||||
|
||||
// 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:<opId>` 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<string, unknown>)['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
|
||||
|
||||
114
api-gateway/src/controllers/cotizaciones.controller.test.ts
Normal file
114
api-gateway/src/controllers/cotizaciones.controller.test.ts
Normal file
@@ -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<string, unknown>]
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<void
|
||||
// Registra la oportunidad en Monday; si Monday falla, la cotización ya quedó persistida
|
||||
let mondayItemId: string | null = null
|
||||
try {
|
||||
mondayItemId = await mondayService.crearOportunidad({
|
||||
const datosOportunidad: DatosOportunidad = {
|
||||
clienteNombre: body.clienteNombre,
|
||||
unidadCodigo: unidad.codigo,
|
||||
tipoPropuestaLabel: LABEL_PROPUESTA_MONDAY[body.tipoPropuesta] ?? body.tipoPropuesta,
|
||||
@@ -83,7 +84,17 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
||||
estadoLabel: 'Cotizador', // etapa del funnel CRM al nacer la oportunidad
|
||||
...(body.clienteEmail !== undefined ? { clienteEmail: body.clienteEmail } : {}),
|
||||
...(body.clienteTelefono !== undefined ? { clienteTelefono: body.clienteTelefono } : {}),
|
||||
})
|
||||
}
|
||||
// Si el teléfono corresponde a un lead del funnel, se ACTUALIZA su
|
||||
// oportunidad (etapa 'Cotizador') en vez de crear una segunda.
|
||||
const opExistente =
|
||||
body.clienteTelefono !== undefined ? await funnelService.oportunidadPorTelefono(body.clienteTelefono) : null
|
||||
if (opExistente !== null) {
|
||||
await mondayService.actualizarOportunidadCotizacion(opExistente, datosOportunidad)
|
||||
mondayItemId = opExistente
|
||||
} else {
|
||||
mondayItemId = await mondayService.crearOportunidad(datosOportunidad)
|
||||
}
|
||||
await prisma.cotizacion.update({ where: { id: cotizacion.id }, data: { mondayItemId } })
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
@@ -10,7 +10,14 @@ import {
|
||||
import type { LeadMonday } from '../services/monday.service.js'
|
||||
|
||||
/** Monday mockeado: registra escrituras y devuelve catálogos controlados */
|
||||
function mondayMock(opts: { labelsOrigen?: string[]; labelsDesarrollo?: string[]; duplicado?: LeadMonday | null } = {}) {
|
||||
function mondayMock(
|
||||
opts: {
|
||||
labelsOrigen?: string[]
|
||||
labelsDesarrollo?: string[]
|
||||
labelsMotivo?: string[]
|
||||
duplicado?: LeadMonday | null
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
|
||||
motivos: [] as { nombre: string; categoria: string; descripcion?: string }[],
|
||||
@@ -29,6 +36,9 @@ function mondayMock(opts: { labelsOrigen?: string[]; labelsDesarrollo?: string[]
|
||||
if (titulo === 'Desarrollo') {
|
||||
return opts.labelsDesarrollo ?? ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA']
|
||||
}
|
||||
if (titulo === 'Motivo No Compra') {
|
||||
return opts.labelsMotivo ?? ['Precio', 'Producto', 'Ubicacion', 'Financiamiento', 'Competencia', 'Cliente', 'Timing']
|
||||
}
|
||||
return opts.labelsOrigen ?? ['WhatsApp', 'Web', 'Recomendado', 'Facebook', 'Otro']
|
||||
},
|
||||
async obtenerNombreItem(_itemId: string) {
|
||||
@@ -41,13 +51,33 @@ function mondayMock(opts: { labelsOrigen?: string[]; labelsDesarrollo?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Funnel mockeado: registra asegurados/movimientos; configurable para fallar */
|
||||
function funnelMock(opts: { oportunidadId?: string | null; fallaAsegurar?: boolean } = {}) {
|
||||
return {
|
||||
asegurados: [] as Record<string, unknown>[],
|
||||
movimientos: [] as { leadId: string; etapa: string }[],
|
||||
async asegurarOportunidadAfluencia(lead: Record<string, unknown>) {
|
||||
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)', () => {
|
||||
|
||||
@@ -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<typeof crearLeadSchema>,
|
||||
funnel?: Pick<FunnelService, 'asegurarOportunidadAfluencia'>,
|
||||
): Promise<ResultadoCrearLead> {
|
||||
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<FunnelService, 'moverOportunidadDeLead'>,
|
||||
): Promise<void> {
|
||||
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<FunnelService, 'oportunidadDeLead'>,
|
||||
): Promise<string> {
|
||||
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<void> {
|
||||
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<vo
|
||||
})
|
||||
return
|
||||
}
|
||||
res.status(201).json({ id: resultado.id, estado: 'NUEVO' })
|
||||
res.status(201).json({ id: resultado.id, estado: 'NUEVO', oportunidadId: resultado.oportunidadId })
|
||||
}
|
||||
|
||||
/** POST /api/leads/:itemId/estado { estado, fecha? } */
|
||||
export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = cambiarEstadoSchema.parse(req.body)
|
||||
const itemId = String(req.params['itemId'])
|
||||
await cambiarEstadoLead(mondayService, itemId, body.estado, body.fecha)
|
||||
await cambiarEstadoLead(mondayService, itemId, body.estado, body.fecha, 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<void> {
|
||||
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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user