feat: conversión lead→oportunidad (Agendo) y alta ligada a apartado
- Mis leads: botón 'Agendo →' convierte el lead en oportunidad (etapa Agendo Cita, con sus datos, asesor = vendedor del lead o quien convierte), marca el lead Calificado y guarda el mapping LeadOportunidad; 409 si ya fue convertido - Mis oportunidades: se quitan No Asistio y Abandono (redundantes con Descartar); quedan Cotizar, Afluencia, Agendo Cita y Aparto - Mis apartados: 'Firma cliente (venta) →' abre el alta de cliente con TODO lo recolectado prellenado (ficha del apartado + contacto + oportunidad + cotización vinculada vía GET /altas/prefill-apartado/:id) - El alta con apartadoMondayId se LIGA al apartado existente y lo enriquece (curp, teléfono, email, precio final, enganche, forma de pago, unidad) en vez de crear un duplicado - monday.service: actualizarApartado + ApartadoMonday con curp, forma de pago, enganche y monto apartado - Tests: 470 API + 120 alta-clientes + 16 mi-dia, verde
This commit is contained in:
@@ -7,6 +7,7 @@ import { automatizacionesService, CLAVE_RR_SKIP, prismaStateStore } from '../ser
|
||||
import { prisma } from '../services/prisma.js'
|
||||
import { MOTIVOS_NO_COMPRA, SUBCATEGORIAS_NO_COMPRA } from './oportunidades.controller.js'
|
||||
import { AppError } from '../utils/errors.js'
|
||||
import { NotFoundError } from '../utils/errors.js'
|
||||
|
||||
/**
|
||||
* Captura y acciones del CRM ligero, sobre DOS tableros:
|
||||
@@ -179,6 +180,7 @@ type MondayLeads = Pick<
|
||||
| 'crearMotivoNoCompra'
|
||||
| 'buscarLeadPorTelefono'
|
||||
| 'buscarOportunidadPorTelefono'
|
||||
| 'getLeads'
|
||||
| 'labelsDeColumna'
|
||||
| 'obtenerNombreItem'
|
||||
| 'columnaIdPorTitulo'
|
||||
@@ -535,6 +537,91 @@ export async function descartarLeadEndpoint(req: Request, res: Response): Promis
|
||||
res.json({ itemId, estado: body.fuente === 'lead' ? 'Descartado' : 'Perdido', motivo: body.motivo, motivoItemId })
|
||||
}
|
||||
|
||||
// ---------- Conversión lead → oportunidad (botón "Agendo" de Mis leads) ----------
|
||||
|
||||
const convertirSchema = z.object({
|
||||
fecha: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe ser YYYY-MM-DD')
|
||||
.optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Convierte un lead del tablero Leads en oportunidad (botón "Agendo" de Mis
|
||||
* leads): crea el item en Oportunidades con etapa 'Agendo Cita' y los datos
|
||||
* del lead (los labels se omiten si no existen en el board destino), asigna
|
||||
* como Asesor al vendedor del lead (o a quien convierte), marca el lead como
|
||||
* 'Calificado' (sale de Mis leads) y guarda el mapping LeadOportunidad para
|
||||
* que el alta de clientes la encuentre. Si el lead ya fue convertido → 409.
|
||||
*/
|
||||
export async function convertirLead(
|
||||
monday: MondayLeads,
|
||||
itemId: string,
|
||||
usuarioId?: string,
|
||||
fecha?: string,
|
||||
): Promise<{ oportunidadId: string }> {
|
||||
const existente = await prisma.leadOportunidad.findUnique({ where: { leadId: itemId } })
|
||||
if (existente) throw new AppError(409, 'El lead ya fue convertido en oportunidad')
|
||||
|
||||
const lead = (await monday.getLeads()).find((l) => l.id === itemId)
|
||||
if (!lead) throw new NotFoundError('Lead no encontrado en el tablero Leads')
|
||||
|
||||
const boardOportunidades = config.monday.boards.oportunidades
|
||||
// Los labels solo se copian si existen en el board destino (dropdown/status
|
||||
// no auto-crean labels al escribir)
|
||||
const [labelsOrigen, labelsDesarrollo, labelsModelo] = await Promise.all([
|
||||
monday.labelsDeColumna(boardOportunidades, 'Origen del Lead'),
|
||||
monday.labelsDeColumna(boardOportunidades, 'Desarrollo'),
|
||||
monday.labelsDeColumna(boardOportunidades, 'Modelo de Interés'),
|
||||
])
|
||||
const oportunidadId = await monday.crearOportunidadAfluencia({
|
||||
nombre: lead.nombre,
|
||||
estadoLabel: 'Agendo Cita',
|
||||
telefono: lead.telefono || undefined,
|
||||
email: lead.email || undefined,
|
||||
origenLabel: lead.origen && labelsOrigen.includes(lead.origen) ? lead.origen : undefined,
|
||||
desarrolloLabel: lead.desarrollo && labelsDesarrollo.includes(lead.desarrollo) ? lead.desarrollo : undefined,
|
||||
modeloLabel: lead.modeloInteres && labelsModelo.includes(lead.modeloInteres) ? lead.modeloInteres : undefined,
|
||||
})
|
||||
|
||||
if (fecha !== undefined) {
|
||||
await monday.cambiarValorColumna(boardOportunidades, oportunidadId, 'Fecha', fecha)
|
||||
}
|
||||
|
||||
// Asesor de la oportunidad: el vendedor del lead, o quien la convierte
|
||||
let asesorId = lead.vendedorIds[0]
|
||||
if (asesorId === undefined && usuarioId !== undefined) {
|
||||
const usuario = await prisma.usuario.findUnique({ where: { id: usuarioId }, select: { mondayUserId: true } })
|
||||
asesorId = usuario?.mondayUserId ?? undefined
|
||||
}
|
||||
if (asesorId !== undefined) {
|
||||
// Bandera anti-carrera: el webhook create_item no debe round-robinnearla
|
||||
await prismaStateStore.set(`${CLAVE_RR_SKIP}${oportunidadId}`, 'capturista')
|
||||
try {
|
||||
await monday.cambiarValorColumna(boardOportunidades, oportunidadId, 'Asesor', asesorId)
|
||||
} catch (err) {
|
||||
await prismaStateStore.set(`${CLAVE_RR_SKIP}${oportunidadId}`, 'round-robin')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// El lead queda Calificado (convertido: sale de Mis leads) y ambos items a su grupo
|
||||
await monday.cambiarValorColumna(config.monday.boards.leads, itemId, 'Estado', 'Calificado')
|
||||
await moverAGrupoSegunEstado(monday, config.monday.boards.leads, itemId)
|
||||
await moverAGrupoSegunEstado(monday, boardOportunidades, oportunidadId)
|
||||
|
||||
await prisma.leadOportunidad.create({ data: { leadId: itemId, oportunidadId } })
|
||||
return { oportunidadId }
|
||||
}
|
||||
|
||||
/** POST /api/leads/:itemId/convertir { fecha? } */
|
||||
export async function convertirLeadEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = convertirSchema.parse(req.body ?? {})
|
||||
const itemId = String(req.params['itemId'])
|
||||
const { oportunidadId } = await convertirLead(mondayService, itemId, req.usuarioToken?.sub, body.fecha)
|
||||
res.status(201).json({ leadId: itemId, oportunidadId, etapa: 'Agendo Cita' })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/leads/buscar?q=texto — autocompletado para prellenar el alta de
|
||||
* clientes. Busca en AMBOS tableros (Leads + Oportunidades); q mínimo 2
|
||||
|
||||
Reference in New Issue
Block a user