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:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
iniciarProgreso,
|
||||
listarDocumentos,
|
||||
obtenerCliente,
|
||||
prefillApartadoEndpoint,
|
||||
subirDocumentos,
|
||||
uploadDocumentos,
|
||||
} from '../controllers/clientes.controller.js'
|
||||
@@ -54,6 +55,7 @@ import {
|
||||
crearAfluenciaEndpoint,
|
||||
crearLeadEndpoint,
|
||||
buscarLeadsEndpoint,
|
||||
convertirLeadEndpoint,
|
||||
descartarLeadEndpoint,
|
||||
} from '../controllers/leads.controller.js'
|
||||
import {
|
||||
@@ -100,6 +102,9 @@ apiRouter.get('/cotizaciones/:id/publico', obtenerCotizacionPublica)
|
||||
apiRouter.get('/cotizaciones/:id', obtenerCotizacion)
|
||||
|
||||
apiRouter.post('/clientes', crearCliente)
|
||||
// Prefill del alta desde un apartado de Monday (botón "Firma Cliente (Venta)"
|
||||
// de Mis apartados). Va ANTES de /clientes/:id para no colisionar.
|
||||
apiRouter.get('/altas/prefill-apartado/:itemId', prefillApartadoEndpoint)
|
||||
// Alta progresiva: guardado etapa por etapa del wizard (Contacto PROSPECTO → CLIENTE).
|
||||
// Van ANTES de /clientes/:id para no colisionar con el parámetro.
|
||||
apiRouter.post('/clientes/progreso/iniciar', iniciarProgreso)
|
||||
@@ -157,6 +162,8 @@ apiRouter.get('/leads/buscar', buscarLeadsEndpoint)
|
||||
apiRouter.post('/leads', crearLeadEndpoint)
|
||||
apiRouter.post('/leads/:itemId/estado', cambiarEstadoLeadEndpoint)
|
||||
apiRouter.post('/leads/:itemId/descartar', descartarLeadEndpoint)
|
||||
// Botón "Agendo" de Mis leads: convierte el lead en oportunidad (Agendo Cita)
|
||||
apiRouter.post('/leads/:itemId/convertir', convertirLeadEndpoint)
|
||||
|
||||
// Alta Afluencia (forma completa de /client): vive en el tablero Oportunidades
|
||||
apiRouter.post('/afluencias', crearAfluenciaEndpoint)
|
||||
|
||||
@@ -84,6 +84,10 @@ export interface ApartadoMonday {
|
||||
email?: string // columna email 'Correo electrónico' ('' si no tiene)
|
||||
precioFinal?: number // columna numbers 'Precio Final' (0 si no tiene)
|
||||
fechaCompra?: string // 'YYYY-MM-DD' de 'Fecha Compra' ('' si no tiene)
|
||||
curp?: string // columna text 'curp' ('' si no tiene)
|
||||
formaPago?: string // dropdown 'Forma de Pago' ('' si no tiene)
|
||||
enganche?: number // columna numbers 'Enganche' (0 si no tiene)
|
||||
montoApartado?: number // columna numbers 'Monto apartado' (0 si no tiene)
|
||||
}
|
||||
|
||||
/** Contacto del tablero Contactos (módulo Contactos de la landing) */
|
||||
@@ -510,13 +514,15 @@ export class MondayService {
|
||||
* Oportunidad en etapa 'Afluencia' con todo el perfil de captura (la
|
||||
* afluencia ES la oportunidad: no hay item aparte en el tablero Leads).
|
||||
* La columna date 'Fecha' lleva la fecha de captura (hoy, hora local).
|
||||
* estadoLabel permite crearla en otra etapa (p. ej. 'Agendo Cita' al
|
||||
* convertir un lead del tablero Leads).
|
||||
*/
|
||||
async crearOportunidadAfluencia(datos: DatosOportunidadAfluencia): Promise<string> {
|
||||
const boardId = config.monday.boards.oportunidades
|
||||
const hoy = new Date()
|
||||
const fechaHoy = `${hoy.getFullYear()}-${String(hoy.getMonth() + 1).padStart(2, '0')}-${String(hoy.getDate()).padStart(2, '0')}`
|
||||
const columnValues = await this.construirColumnValues(boardId, {
|
||||
Estado: 'Afluencia',
|
||||
Estado: datos.estadoLabel ?? 'Afluencia',
|
||||
Fecha: fechaHoy,
|
||||
'Cliente teléfono': datos.telefono,
|
||||
'Cliente email': datos.email,
|
||||
@@ -682,6 +688,41 @@ export class MondayService {
|
||||
return this.crearItem(boardId, datos.clienteNombre, columnValues)
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza un apartado existente con los campos presentes (mismo builder de
|
||||
* valores que crearApartado) — usado cuando el alta de cliente arranca desde
|
||||
* un apartado ya creado (botón "Firma Cliente (Venta)" de Mis apartados):
|
||||
* se enriquece el item en vez de crear uno duplicado.
|
||||
*/
|
||||
async actualizarApartado(itemId: string, datos: Partial<DatosApartado>): Promise<void> {
|
||||
const boardId = config.monday.boards.apartado
|
||||
const valores = JSON.parse(
|
||||
await this.construirColumnValues(boardId, {
|
||||
Unidad: datos.unidadCodigo,
|
||||
'Monto apartado': datos.montoApartado,
|
||||
'Fecha apartado': datos.fechaApartado,
|
||||
'Fecha límite': datos.fechaLimite,
|
||||
Estado: datos.estadoLabel,
|
||||
Teléfono: datos.telefono,
|
||||
'Correo electrónico': datos.email,
|
||||
curp: datos.curp,
|
||||
'Precio Final': datos.precioFinal,
|
||||
Enganche: datos.enganche,
|
||||
'Fecha Compra': datos.fechaCompra,
|
||||
Vendedor: datos.vendedorId,
|
||||
'Forma de Pago': datos.formaPagoLabel,
|
||||
Observaciones: datos.observaciones,
|
||||
}),
|
||||
) as Record<string, unknown>
|
||||
if (datos.clienteNombre !== undefined && datos.clienteNombre !== '') valores['name'] = datos.clienteNombre
|
||||
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, columnValues: JSON.stringify(valores) },
|
||||
)
|
||||
}
|
||||
|
||||
async actualizarDisponibilidadUnidad(codigo: string, status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA'): Promise<void> {
|
||||
const boardId = config.monday.boards.unidades
|
||||
// Asegura cache fresco para resolver el itemId
|
||||
@@ -1190,6 +1231,10 @@ export class MondayService {
|
||||
const colEmail = cols.get('Correo electrónico')
|
||||
const colPrecioFinal = cols.get('Precio Final')
|
||||
const colFechaCompra = cols.get('Fecha Compra')
|
||||
const colCurp = cols.get('curp')
|
||||
const colFormaPago = cols.get('Forma de Pago')
|
||||
const colEnganche = cols.get('Enganche')
|
||||
const colMontoApartado = cols.get('Monto apartado')
|
||||
|
||||
const items: ItemMonday[] = []
|
||||
let cursor: string | null = null
|
||||
@@ -1241,6 +1286,10 @@ export class MondayService {
|
||||
email: colEmail ? (porId.get(colEmail.id)?.text ?? '') : '',
|
||||
precioFinal: colPrecioFinal ? Number(porId.get(colPrecioFinal.id)?.text ?? 0) || 0 : 0,
|
||||
fechaCompra: colFechaCompra ? (porId.get(colFechaCompra.id)?.text ?? '') : '',
|
||||
curp: colCurp ? (porId.get(colCurp.id)?.text ?? '') : '',
|
||||
formaPago: colFormaPago ? (porId.get(colFormaPago.id)?.text ?? '') : '',
|
||||
enganche: colEnganche ? Number(porId.get(colEnganche.id)?.text ?? 0) || 0 : 0,
|
||||
montoApartado: colMontoApartado ? Number(porId.get(colMontoApartado.id)?.text ?? 0) || 0 : 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1515,6 +1564,8 @@ export interface DatosOportunidad {
|
||||
|
||||
export interface DatosOportunidadAfluencia {
|
||||
nombre: string // name del item = nombre del interesado
|
||||
/** Etapa inicial (default 'Afluencia'; 'Agendo Cita' al convertir un lead) */
|
||||
estadoLabel?: string | undefined
|
||||
telefono?: string | undefined
|
||||
email?: string | undefined
|
||||
desarrolloLabel?: string | undefined // label tal cual de la columna status 'Desarrollo'
|
||||
|
||||
Reference in New Issue
Block a user