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:
2026-09-05 00:33:53 +00:00
parent a4575bb948
commit 5b348d21e5
14 changed files with 654 additions and 17 deletions

View File

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