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:
2026-09-01 20:10:51 +00:00
parent ead7a71025
commit 98fedbd8d3
14 changed files with 854 additions and 53 deletions

View File

@@ -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");

View File

@@ -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:<itemId>:<fecha>', etc.
model AutomatizacionState {

View File

@@ -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' })
})
})

View File

@@ -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

View 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()
})
})

View File

@@ -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

View File

@@ -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)', () => {

View File

@@ -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 })
}

View File

@@ -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', () => {

View File

@@ -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.

View 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([])
})
})

View 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)

View File

@@ -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,7 +585,30 @@ export class MondayService {
cursor = page.cursor
} while (cursor !== null)
return items.map((item) => {
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[] = []
@@ -578,7 +634,6 @@ export class MondayService {
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

View File

@@ -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:<opId>` **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