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:
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user