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

@@ -248,6 +248,40 @@ async function crearApartadoSiAplica(
})
}
/**
* Apartado Monday del alta: con apartadoMondayId (alta arrancada desde un
* apartado existente, botón "Firma Cliente (Venta)" de Mis apartados) se
* LIGA y enriquece ese item; sin él, se crea uno nuevo como siempre.
*/
async function apartadoDeAlta(
dg: { nombreCompleto: string; celular?: string | undefined; email?: string | undefined; curp?: string | undefined },
vc: {
ubicacion?: string | undefined
valorVenta?: string | number | undefined
enganche?: string | number | undefined
tiposCredito?: unknown
},
capturadoPorId: string | null,
apartadoMondayId: unknown,
): Promise<string | null> {
const existente = typeof apartadoMondayId === 'string' && apartadoMondayId !== '' ? apartadoMondayId : null
if (existente === null) return crearApartadoSiAplica(dg, vc, capturadoPorId)
const valorVenta = aNumero(vc.valorVenta)
const enganche = aNumero(vc.enganche)
// Solo se mandan los campos con valor (exactOptionalPropertyTypes)
await mondayService.actualizarApartado(existente, {
clienteNombre: dg.nombreCompleto,
...(vc.ubicacion !== undefined && vc.ubicacion !== '' ? { unidadCodigo: vc.ubicacion } : {}),
...(dg.celular !== undefined && dg.celular !== '' ? { telefono: dg.celular } : {}),
...(dg.email !== undefined && dg.email !== '' ? { email: dg.email } : {}),
...(dg.curp !== undefined && dg.curp !== '' ? { curp: dg.curp } : {}),
...(valorVenta > 0 ? { precioFinal: valorVenta } : {}),
...(enganche > 0 ? { enganche } : {}),
...(formaPagoLabel(vc.tiposCredito) !== undefined ? { formaPagoLabel: formaPagoLabel(vc.tiposCredito) } : {}),
})
return existente
}
/** POST /api/clientes — persiste el alta y la replica en Monday (Contactos y Apartado) */
export async function crearCliente(req: Request, res: Response): Promise<void> {
const body = crearClienteSchema.parse(req.body)
@@ -290,7 +324,7 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
...{ actividadEconomicaLabel: actividadEconomicaLabel(body.empleo?.tipoEmpleo) },
})
mondayApartadoId = await crearApartadoSiAplica(dg, vc, req.usuarioToken?.sub ?? null)
mondayApartadoId = await apartadoDeAlta(dg, vc, req.usuarioToken?.sub ?? null, body.apartadoMondayId)
await prisma.cliente.update({
where: { id: cliente.id },
@@ -493,8 +527,10 @@ export async function completarProgreso(req: Request, res: Response): Promise<vo
}
if (mondayApartadoId === null) {
// El capturista original del alta progresiva es quien queda como Vendedor
mondayApartadoId = await crearApartadoSiAplica(dg, vc, cliente.capturadoPorId)
// Con apartadoMondayId (alta desde un apartado existente) se liga y
// enriquece ese item; si no, se crea. El capturista original del alta
// progresiva es quien queda como Vendedor.
mondayApartadoId = await apartadoDeAlta(dg, vc, cliente.capturadoPorId, body.apartadoMondayId)
}
await prisma.cliente.update({
@@ -607,3 +643,65 @@ export async function listarDocumentos(req: Request, res: Response): Promise<voi
})
res.json(documentos)
}
/**
* GET /api/altas/prefill-apartado/:itemId — todo lo ya recolectado de un
* apartado de Monday para prellenar el alta de cliente (botón "Firma Cliente
* (Venta)" de Mis apartados): ficha del apartado, contacto relacionado
* (perfil completo), oportunidad relacionada (perfil de la afluencia) y la
* cotización más reciente vinculada. Las relaciones se arman por teléfono
* (últimos 10 dígitos) o email.
*/
export async function prefillApartadoEndpoint(req: Request, res: Response): Promise<void> {
const itemId = String(req.params['itemId'])
const apartado = (await mondayService.getApartados()).find((a) => a.id === itemId)
if (!apartado) throw new NotFoundError('Apartado no encontrado')
const tel = (apartado.telefono ?? '').replace(/\D/g, '').slice(-10)
const email = (apartado.email ?? '').trim().toLowerCase()
const coincide = (t: string, e: string): boolean =>
(tel !== '' && t.replace(/\D/g, '').length >= 10 && t.replace(/\D/g, '').slice(-10) === tel) ||
(email !== '' && e.trim().toLowerCase() === email)
const [contactos, oportunidades] = await Promise.all([
mondayService.getContactos(),
mondayService.getOportunidades(),
])
const contactoBase = contactos.find((c) => coincide(c.telefono, c.email))
const contacto = contactoBase ? await mondayService.getContactoDetalle(contactoBase.id) : null
const oportunidad = oportunidades.find((o) => coincide(o.telefono, o.email)) ?? null
// Cotización local más reciente: vinculada a la oportunidad o por teléfono
let cotizacion = null
if (oportunidad) {
cotizacion = await prisma.cotizacion.findFirst({
where: { mondayItemId: oportunidad.id },
orderBy: { createdAt: 'desc' },
})
}
if (!cotizacion && tel !== '') {
cotizacion = await prisma.cotizacion.findFirst({
where: { clienteTelefono: { contains: tel } },
orderBy: { createdAt: 'desc' },
})
}
res.json({
apartado,
contacto,
oportunidad,
cotizacion: cotizacion
? {
tipoPropuesta: cotizacion.tipoPropuesta,
acabados: cotizacion.acabados,
apartado: cotizacion.apartado,
precioLista: cotizacion.precioLista,
precioFinal: cotizacion.precioFinal,
enganche: cotizacion.enganche,
numMensualidades: cotizacion.numMensualidades,
mensualidad: cotizacion.mensualidad,
pagoFinal: cotizacion.pagoFinal,
}
: null,
})
}

View File

@@ -4,6 +4,7 @@ import {
buscarLeads,
buscarLeadsEndpoint,
cambiarEstadoLead,
convertirLead,
crearAfluencia,
crearAfluenciaEndpoint,
crearLead,
@@ -22,6 +23,7 @@ const prismaMock = vi.hoisted(() => ({
},
leadOportunidad: {
create: vi.fn(),
findUnique: vi.fn(),
},
usuario: {
findUnique: vi.fn(),
@@ -37,6 +39,7 @@ vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
const LABELS_DESARROLLO = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA']
const LABELS_ORIGEN = ['WhatsApp', 'Web', 'Recomendado', 'Facebook', 'Otro', 'Instagram', 'Broker', 'Base de datos', 'Prospección']
const LABELS_MOTIVO = ['Precio', 'Producto', 'Ubicacion', 'Financiamiento', 'Competencia', 'Cliente', 'Timing']
const LABELS_MODELO = ['1R', '2RA', '2RB', '2RC', 'HIMALAYA', 'SANTIAGO', 'SOHO', 'MIRADOR', 'PANORAMA']
/** Monday mockeado: registra escrituras y devuelve catálogos controlados */
function mondayMock(
@@ -46,6 +49,7 @@ function mondayMock(
labelsMotivo?: string[]
duplicadoLead?: LeadMonday | null
duplicadoOportunidad?: OportunidadMonday | null
leads?: LeadMonday[]
} = {},
) {
return {
@@ -65,6 +69,9 @@ function mondayMock(
this.oportunidadesCreadas.push(datos)
return 'op-nueva-1'
},
async getLeads() {
return opts.leads ?? []
},
oportunidadesActualizadas: [] as { id: string; datos: Record<string, unknown> }[],
async actualizarOportunidadAfluencia(id: string, datos: Record<string, unknown>) {
this.oportunidadesActualizadas.push({ id, datos })
@@ -78,6 +85,8 @@ function mondayMock(
async labelsDeColumna(_boardId: string, titulo: string) {
if (titulo === 'Desarrollo') return opts.labelsDesarrollo ?? LABELS_DESARROLLO
if (titulo === 'Motivo No Compra') return opts.labelsMotivo ?? LABELS_MOTIVO
if (titulo === 'Modelo de Interés') return LABELS_MODELO
if (titulo === 'Origen del Lead') return opts.labelsOrigen ?? ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'OTRO']
return opts.labelsOrigen ?? LABELS_ORIGEN
},
async obtenerNombreItem(_itemId: string) {
@@ -186,7 +195,7 @@ describe('crearLead (POST /api/leads) — captura rápida en el tablero Leads',
describe('crearAfluencia (POST /api/afluencias) — la afluencia ES la oportunidad', () => {
it('crea UN item en Oportunidades (Estado Afluencia) y devuelve su id como id y oportunidadId', async () => {
const monday = mondayMock()
const r = await crearAfluencia(monday, { ...datosBase, origen: 'Facebook', notas: 'Vio el volante' })
const r = await crearAfluencia(monday, { ...datosBase, origen: 'PASE', notas: 'Vio el volante' })
expect(r).toEqual({ id: 'op-nueva-1', oportunidadId: 'op-nueva-1' })
expect(monday.oportunidadesCreadas).toEqual([
{
@@ -194,7 +203,7 @@ describe('crearAfluencia (POST /api/afluencias) — la afluencia ES la oportunid
telefono: '4771234567',
email: undefined,
desarrolloLabel: undefined,
origenLabel: 'Facebook',
origenLabel: 'PASE',
modeloLabel: undefined,
medioContacto: undefined,
edad: undefined,
@@ -707,3 +716,74 @@ describe('buscarLeads (GET /api/leads/buscar) — búsqueda dual Leads + Oportun
).rejects.toMatchObject({ status: 400 })
})
})
describe('convertirLead (POST /api/leads/:itemId/convertir) — botón "Agendo" de Mis leads', () => {
const leadConvertible = leadMonday({
id: 'lead-conv-1',
nombre: 'Ana Demo',
telefono: '+52 6641234567',
email: 'ana@correo.com',
origen: 'PASE',
desarrollo: 'SENDA',
modeloInteres: 'SOHO',
vendedorIds: [106358096],
})
beforeEach(() => {
vi.clearAllMocks()
prismaMock.leadOportunidad.findUnique.mockResolvedValue(null)
prismaMock.leadOportunidad.create.mockResolvedValue({})
prismaMock.automatizacionState.upsert.mockResolvedValue({})
})
it('crea la oportunidad en Agendo Cita con los datos del lead, asigna al vendedor, califica el lead y guarda el mapping', async () => {
const monday = mondayMock({ leads: [leadConvertible] })
const r = await convertirLead(monday, 'lead-conv-1')
expect(r).toEqual({ oportunidadId: 'op-nueva-1' })
expect(monday.oportunidadesCreadas).toEqual([
expect.objectContaining({
nombre: 'Ana Demo',
estadoLabel: 'Agendo Cita',
telefono: '+52 6641234567',
email: 'ana@correo.com',
origenLabel: 'PASE',
desarrolloLabel: 'SENDA',
modeloLabel: 'SOHO',
}),
])
// Asesor = vendedor del lead; lead calificado
expect(monday.cambios).toEqual([
{ boardId: '555', itemId: 'op-nueva-1', columna: 'Asesor', valor: 106358096 },
{ boardId: '444', itemId: 'lead-conv-1', columna: 'Estado', valor: 'Calificado' },
])
expect(prismaMock.leadOportunidad.create).toHaveBeenCalledWith({
data: { leadId: 'lead-conv-1', oportunidadId: 'op-nueva-1' },
})
})
it('sin vendedor en el lead, asigna al usuario que convierte (vía Monday vinculado)', async () => {
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 111 })
const monday = mondayMock({ leads: [{ ...leadConvertible, vendedorIds: [] }] })
await convertirLead(monday, 'lead-conv-1', 'usr-9')
expect(monday.cambios[0]).toEqual({ boardId: '555', itemId: 'op-nueva-1', columna: 'Asesor', valor: 111 })
})
it('lead ya convertido → 409 sin crear nada', async () => {
prismaMock.leadOportunidad.findUnique.mockResolvedValue({ oportunidadId: 'op-vieja' })
const monday = mondayMock({ leads: [leadConvertible] })
await expect(convertirLead(monday, 'lead-conv-1')).rejects.toMatchObject({ status: 409 })
expect(monday.oportunidadesCreadas).toEqual([])
})
it('lead inexistente en el tablero → 404', async () => {
const monday = mondayMock({ leads: [] })
await expect(convertirLead(monday, 'no-existe')).rejects.toMatchObject({ status: 404 })
})
it('omite labels que no existen en el board Oportunidades (dropdown no auto-crea)', async () => {
const monday = mondayMock({ leads: [{ ...leadConvertible, origen: 'WhatsApp' }] }) // WhatsApp no está en Origen del Lead
await convertirLead(monday, 'lead-conv-1')
expect(monday.oportunidadesCreadas[0]?.['origenLabel']).toBeUndefined()
})
})

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