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:
164
api-gateway/src/services/funnel.service.test.ts
Normal file
164
api-gateway/src/services/funnel.service.test.ts
Normal file
@@ -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<string, unknown>[],
|
||||
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
|
||||
async crearOportunidadAfluencia(datos: Record<string, unknown>) {
|
||||
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<string, string> } {
|
||||
const mapping = new Map<string, string>()
|
||||
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>): 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([])
|
||||
})
|
||||
})
|
||||
127
api-gateway/src/services/funnel.service.ts
Normal file
127
api-gateway/src/services/funnel.service.ts
Normal file
@@ -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<string | null> // oportunidadId
|
||||
guardar(leadId: string, oportunidadId: string): Promise<void>
|
||||
}
|
||||
|
||||
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<string> {
|
||||
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<string | null> {
|
||||
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<boolean> {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
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:<opId>`
|
||||
* 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<string | null> {
|
||||
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)
|
||||
@@ -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<string> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
const boardId = config.monday.boards.contactos
|
||||
const columnValues = await this.construirColumnValues(boardId, {
|
||||
@@ -513,11 +551,6 @@ export class MondayService {
|
||||
async getLeads(): Promise<LeadMonday[]> {
|
||||
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<LeadMonday | null> {
|
||||
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<string, ColumnaMeta>): 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
|
||||
|
||||
Reference in New Issue
Block a user