import type { Request, Response } from 'express' import { z } from 'zod' import { config } from '../config/index.js' import { mondayService, type LeadMonday, type MondayService, type OportunidadMonday } from '../services/monday.service.js' import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js' import { automatizacionesService, CLAVE_RR_SKIP, prismaStateStore } from '../services/automatizaciones.service.js' import { prisma } from '../services/prisma.js' import { saludMonday } from '../services/salud-monday.service.js' import { crmLocal, encolarSyncSeguro, esIdLocal } from '../services/crm-local.service.js' import * as auditoria from '../services/auditoria.service.js' import { esFalloMonday } from '../services/sync-monday.service.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: * - Tablero Leads (POST /api/leads): captura rápida de /hoy y captura manual * desde la UI de Monday. Item ligero (nombre, teléfono, email, origen, * desarrollo, notas). Los leads NO crean oportunidad. * - Tablero Oportunidades (POST /api/afluencias): el Alta Afluencia (forma * completa de /client) — la afluencia ES la oportunidad en etapa * 'Afluencia', con todo el perfil. * Las acciones del vendedor (/estado, /descartar) y la búsqueda del picker del * alta conviven con ambos tableros vía el parámetro `fuente`. * Los labels se validan/resuelven contra los reales de cada board (los * dropdown y status de Monday no auto-crean labels al escribir). */ // Transiciones que el vendedor puede hacer desde la app Mi día export const ESTADOS_LEAD_PERMITIDOS = ['CONTACTADO', 'Intento de contacto', 'INTERESADO', 'AGENDA CITA', 'CALIFICADO'] as const // Traducción de la whitelist (valores que mandan los frontends) a los labels // reales de la columna status 'Estado' de cada board const LABEL_ESTADO_LEADS: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], string> = { CONTACTADO: 'Contactado', 'Intento de contacto': 'Intento de contacto', INTERESADO: 'Interesado', 'AGENDA CITA': 'Agenda Cita', CALIFICADO: 'Calificado', } const LABEL_ESTADO_OPORTUNIDAD: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], string> = { CONTACTADO: 'Contactado', 'Intento de contacto': 'Intento de contacto', INTERESADO: 'Interesado', // no existe en el board Oportunidades: se rechaza abajo 'AGENDA CITA': 'Agendo Cita', CALIFICADO: 'Calificado', } export type FuenteLead = 'lead' | 'oportunidad' const fuenteSchema = z.enum(['lead', 'oportunidad']).optional() /** Captura rápida de lead (tablero Leads): solo los campos ligeros */ const crearLeadSchema = z.object({ nombre: z.string().trim().min(1, 'El nombre es obligatorio'), telefono: z.string().trim().min(1).optional(), email: z.string().trim().email('Email inválido').optional(), origen: z.string().trim().min(1).optional(), desarrollo: z.string().trim().min(1).optional(), notas: z.string().trim().min(1).optional(), }) /** Alta Afluencia (tablero Oportunidades): captura con perfil completo */ const crearAfluenciaSchema = z.object({ nombre: z.string().trim().min(1, 'El nombre es obligatorio'), telefono: z.string().trim().min(1).optional(), email: z.string().trim().email('Email inválido').optional(), origen: z.string().trim().min(1).optional(), desarrollo: z.string().trim().min(1).optional(), notas: z.string().trim().min(1).optional(), // Con oportunidadId (botón "Afluencia" de Mis oportunidades en Mi día): se // ACTUALIZA esa oportunidad con el perfil capturado en vez de crear una nueva oportunidadId: z.string().trim().min(1).optional(), // Campos de calificación de la afluencia (opcionales; los labels fijos del // front coinciden con los sembrados en Monday, no se validan contra el board) modelo: z.string().trim().min(1).optional(), medioContacto: z.string().trim().min(1).optional(), edad: z.coerce.number().int().min(15).max(120).optional(), estadoCivil: z.string().trim().min(1).optional(), tipoEmpleo: z.string().trim().min(1).optional(), ocupacion: z.string().trim().min(1).optional(), rangoSalarial: z.string().trim().min(1).optional(), credito: z.string().trim().min(1).optional(), tipoEconomia: z.string().trim().min(1).optional(), califica: z.string().trim().min(1).optional(), loQuierePara: z.string().trim().min(1).optional(), }) const cambiarEstadoSchema = z.object({ estado: z.enum(ESTADOS_LEAD_PERMITIDOS), fecha: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe ser YYYY-MM-DD') .optional(), // Tablero del item: 'lead' = tablero Leads; 'oportunidad' = Oportunidades // (default por compat: las afluencias nuevas viven ahí) fuente: fuenteSchema, }) const descartarSchema = z.object({ motivo: z.enum(MOTIVOS_NO_COMPRA), // Subcategoría del catálogo de Monday (debe pertenecer a la categoría elegida) subcategoria: z.string().trim().min(1).optional(), comentario: z.string().trim().min(1).optional(), fuente: fuenteSchema, }).refine( (b) => b.subcategoria === undefined || SUBCATEGORIAS_NO_COMPRA[b.motivo].includes(b.subcategoria), { message: 'La subcategoría no pertenece a la categoría elegida', path: ['subcategoria'] }, ) // ---------- Búsqueda dual (prellenado del alta de clientes) ---------- const LIMITE_BUSQUEDA_LEADS = 15 /** Minúsculas y sin diacríticos, para comparar nombres/teléfonos con acentos */ const sinAcentos = (s: string): string => s .normalize('NFD') .replace(/[̀-ͯ]/g, '') // diacríticos U+0300–U+036F .toLowerCase() /** Resultado del picker del alta: conviven Leads (captura ligera) y Oportunidades (alta afluencia) */ export interface ResultadoBusquedaLead { id: string nombre: string telefono: string email: string estado: string origen: string fuente: FuenteLead /** Desarrollo de interés ('' si no tiene); prellena afluencia/cotizador */ desarrollo: string /** Modelo de interés ('' si no tiene) */ modeloInteres: string } /** * Filtra por q en nombre/teléfono/email (case-insensitive, sin acentos) en * AMBOS tableros — Leads y Oportunidades —, ordena por actualización reciente * y limita a 15 resultados combinados. */ export function buscarLeads(leads: LeadMonday[], oportunidades: OportunidadMonday[], q: string): ResultadoBusquedaLead[] { const needle = sinAcentos(q) const candidatos = [ ...leads.map((l) => ({ id: l.id, nombre: l.nombre, telefono: l.telefono, email: l.email, estado: l.estado, origen: l.origen, desarrollo: l.desarrollo ?? '', modeloInteres: l.modeloInteres ?? '', updatedAt: l.updatedAt, fuente: 'lead' as const, })), ...oportunidades.map((o) => ({ id: o.id, nombre: o.nombre, telefono: o.telefono, email: o.email, estado: o.etapa, origen: o.origen, desarrollo: o.desarrollo ?? '', modeloInteres: o.modeloInteres ?? '', updatedAt: o.updatedAt, fuente: 'oportunidad' as const, })), ] return candidatos .filter((c) => [c.nombre, c.telefono, c.email].some((campo) => sinAcentos(campo).includes(needle))) .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) .slice(0, LIMITE_BUSQUEDA_LEADS) .map(({ updatedAt: _updatedAt, ...resto }) => resto) } type MondayLeads = Pick< MondayService, | 'cambiarValorColumna' | 'crearLead' | 'crearOportunidadAfluencia' | 'actualizarOportunidadAfluencia' | 'crearMotivoNoCompra' | 'buscarLeadPorTelefono' | 'buscarOportunidadPorTelefono' | 'getLeads' | 'labelsDeColumna' | 'obtenerNombreItem' | 'columnaIdPorTitulo' | 'getItemConGrupo' | 'moverItemAGrupo' > export type ResultadoCrearLead = | { id: string; oportunidadId: string | null; actualizada?: boolean } | { duplicado: { id: string; nombre: string } } /** * Crea un lead en el tablero Leads (Estado NUEVO + Fecha de hoy; captura * rápida ligera, SIN oportunidad). Si viene teléfono y ya existe un lead con * ese número, no crea nada y devuelve { duplicado } (el endpoint → 409). * Origen y desarrollo se validan contra los labels reales del board Leads. */ export async function crearLead( monday: MondayLeads, datos: z.infer, ): Promise { const boardLeads = config.monday.boards.leads if (datos.telefono !== undefined) { const existente = await monday.buscarLeadPorTelefono(datos.telefono) if (existente) return { duplicado: { id: existente.id, nombre: existente.nombre } } } if (datos.origen !== undefined) { const labels = await monday.labelsDeColumna(boardLeads, 'Origen') if (!labels.includes(datos.origen)) { throw new AppError(400, `Origen inválido: "${datos.origen}". Labels válidos: ${labels.join(', ')}`) } } if (datos.desarrollo !== undefined) { const labels = await monday.labelsDeColumna(boardLeads, 'Desarrollo') if (!labels.includes(datos.desarrollo)) { throw new AppError(400, `Desarrollo inválido: "${datos.desarrollo}". Labels válidos: ${labels.join(', ')}`) } } const id = await monday.crearLead({ nombre: datos.nombre, telefono: datos.telefono, email: datos.email, origenLabel: datos.origen, desarrolloLabel: datos.desarrollo, notas: datos.notas, }) return { id, oportunidadId: null } } /** * Crea la afluencia en Monday: UN item en el tablero Oportunidades con Estado * 'Afluencia' y todo el perfil capturado. Si viene teléfono y ya existe una * oportunidad con ese número, no crea nada y devuelve { duplicado } (el * endpoint → 409). Origen (dropdown 'Origen del Lead') y desarrollo (status * 'Desarrollo') se validan contra los labels reales del board. * Tras crearla se mueve al grupo que corresponde a su etapa (Monday crea el * item en el grupo top del tablero, hoy "Perdidos", y el webhook de change no * dispara en la creación); un fallo ahí solo se loguea. */ export async function crearAfluencia( monday: MondayLeads, datos: z.infer, ): Promise { const boardOportunidades = config.monday.boards.oportunidades // Con oportunidadId: el formulario venía prellenado desde Mis oportunidades // (Mi día) → se ACTUALIZA esa oportunidad con el perfil completo y queda en // etapa 'Afluencia'; no se crea duplicado ni se valida teléfono. if (datos.oportunidadId !== undefined) { await monday.actualizarOportunidadAfluencia(datos.oportunidadId, { nombre: datos.nombre, telefono: datos.telefono, email: datos.email, desarrolloLabel: datos.desarrollo, origenLabel: datos.origen, modeloLabel: datos.modelo, medioContacto: datos.medioContacto, edad: datos.edad, estadoCivilLabel: datos.estadoCivil, tipoEmpleoLabel: datos.tipoEmpleo, ocupacion: datos.ocupacion, rangoSalarial: datos.rangoSalarial, creditoLabel: datos.credito, tipoEconomiaLabel: datos.tipoEconomia, calificaLabel: datos.califica, loQuierePara: datos.loQuierePara, notas: datos.notas, }) try { await moverAGrupoSegunEstado(monday, boardOportunidades, datos.oportunidadId) } catch (err) { // eslint-disable-next-line no-console console.error(`No se pudo mover al grupo correcto la oportunidad ${datos.oportunidadId}:`, err) } return { id: datos.oportunidadId, oportunidadId: datos.oportunidadId, actualizada: true } } if (datos.telefono !== undefined) { const existente = await monday.buscarOportunidadPorTelefono(datos.telefono) if (existente) return { duplicado: { id: existente.id, nombre: existente.nombre } } } if (datos.origen !== undefined) { const labels = await monday.labelsDeColumna(boardOportunidades, 'Origen del Lead') if (!labels.includes(datos.origen)) { throw new AppError(400, `Origen inválido: "${datos.origen}". Labels válidos: ${labels.join(', ')}`) } } if (datos.desarrollo !== undefined) { const labels = await monday.labelsDeColumna(boardOportunidades, 'Desarrollo') if (!labels.includes(datos.desarrollo)) { throw new AppError(400, `Desarrollo inválido: "${datos.desarrollo}". Labels válidos: ${labels.join(', ')}`) } } const id = await monday.crearOportunidadAfluencia({ nombre: datos.nombre, telefono: datos.telefono, email: datos.email, desarrolloLabel: datos.desarrollo, origenLabel: datos.origen, modeloLabel: datos.modelo, medioContacto: datos.medioContacto, edad: datos.edad, estadoCivilLabel: datos.estadoCivil, tipoEmpleoLabel: datos.tipoEmpleo, ocupacion: datos.ocupacion, rangoSalarial: datos.rangoSalarial, creditoLabel: datos.credito, tipoEconomiaLabel: datos.tipoEconomia, calificaLabel: datos.califica, loQuierePara: datos.loQuierePara, notas: datos.notas, }) try { await moverAGrupoSegunEstado(monday, boardOportunidades, id) } catch (err) { // eslint-disable-next-line no-console console.error(`No se pudo mover al grupo correcto la oportunidad ${id}:`, err) } return { id, oportunidadId: id } } /** * Cambia el estado del item (whitelist → label real del board correspondiente * según `fuente`); en AGENDA CITA puede setear la columna Fecha. */ export async function cambiarEstadoLead( monday: MondayLeads, itemId: string, estado: (typeof ESTADOS_LEAD_PERMITIDOS)[number], fecha?: string, fuente: FuenteLead = 'oportunidad', ): Promise { const esLead = fuente === 'lead' // 'Interesado' solo existe en el tablero Leads (no es etapa del funnel) if (estado === 'INTERESADO' && !esLead) { throw new AppError(400, 'La etapa Interesado solo aplica a leads del tablero Leads') } const boardId = esLead ? config.monday.boards.leads : config.monday.boards.oportunidades const label = esLead ? LABEL_ESTADO_LEADS[estado] : LABEL_ESTADO_OPORTUNIDAD[estado] await monday.cambiarValorColumna(boardId, itemId, 'Estado', label) if (estado === 'AGENDA CITA' && fecha !== undefined) { await monday.cambiarValorColumna(boardId, itemId, 'Fecha', fecha) } } /** * Descarte del item según `fuente`: * - 'oportunidad': Estado 'Perdido' + dropdown Motivo No Compra (label ya * sembrado; si no existiera se omite) + comentario en COMENTARIOS. * - 'lead': Estado 'Descartado' + comentario en Notas. * Ambos registran el motivo (y la subcategoría si viene) en el tablero * catálogo Motivos No Compra. */ export async function descartarLead( monday: MondayLeads, itemId: string, motivo: string, comentario?: string, fuente: FuenteLead = 'oportunidad', subcategoria?: string, ): Promise { const nombreItem = await monday.obtenerNombreItem(itemId) if (fuente === 'lead') { const boardLeads = config.monday.boards.leads await monday.cambiarValorColumna(boardLeads, itemId, 'Estado', 'Descartado') if (comentario !== undefined) { await monday.cambiarValorColumna(boardLeads, itemId, 'Notas', comentario) } } else { const boardOportunidades = config.monday.boards.oportunidades await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', 'Perdido') if (comentario !== undefined) { await monday.cambiarValorColumna(boardOportunidades, itemId, 'COMENTARIOS', comentario) } const labels = await monday.labelsDeColumna(boardOportunidades, 'Motivo No Compra') if (labels.includes(motivo)) { await monday.cambiarValorColumna(boardOportunidades, itemId, '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`) } } return monday.crearMotivoNoCompra({ nombre: `${motivo}${subcategoria !== undefined ? ` / ${subcategoria}` : ''} — ${nombreItem}`, categoria: motivo, ...(subcategoria !== undefined ? { subcategoria } : {}), ...(comentario !== undefined ? { descripcion: comentario } : {}), }) } /** * Self-assign del capturista con bandera anti-carrera rr:skip (para que el * webhook create_item no lo reparta por round-robin mientras Monday propaga la * escritura; si la asignación falla, se apaga y cae al reparto — y queda * encolada como ASIGNAR_PERSONA para reintentarla cuando Monday vuelva). * Devuelve cómo quedó la asignación. */ async function asignarCapturistaSiAplica( usuarioId: string | undefined, itemId: string, nombre: string, board: 'leads' | 'oportunidades', ): Promise<'capturista' | 'round-robin'> { if (usuarioId === undefined) return 'round-robin' try { const capturista = await prisma.usuario.findUnique({ where: { id: usuarioId }, select: { mondayUserId: true }, }) if (capturista?.mondayUserId != null) { await prismaStateStore.set(`${CLAVE_RR_SKIP}${itemId}`, 'capturista') try { await automatizacionesService.asignarACapturista(itemId, nombre, capturista.mondayUserId, board) return 'capturista' } catch (err) { await prismaStateStore.set(`${CLAVE_RR_SKIP}${itemId}`, 'round-robin') // Best-effort: el worker la reintenta al drenar la cola await encolarSyncSeguro('ASIGNAR_PERSONA', { board, mondayUserId: capturista.mondayUserId }, itemId) throw err } } } catch (err) { // eslint-disable-next-line no-console console.error(`No se pudo asignar el item ${itemId} al capturista:`, err) } return 'round-robin' } /** POST /api/leads { nombre, telefono?, email?, origen?, desarrollo?, notas? } — captura rápida (tablero Leads) */ export async function crearLeadEndpoint(req: Request, res: Response): Promise { const body = crearLeadSchema.parse(req.body) const usuarioSub = req.usuarioToken?.sub // Modo local: Monday caído (flag) → directo a crmLocal; Monday vivo → flujo // actual con fallback a local si la llamada lanza 502. La respuesta mantiene // la misma forma en ambos caminos (el front no se entera). let modoLocal = !saludMonday.estaDisponible() let resultado: ResultadoCrearLead if (modoLocal) { resultado = await crmLocal.crearLead(body, usuarioSub) } else { try { resultado = await crearLead(mondayService, body) } catch (err) { if (!esFalloMonday(err)) throw err modoLocal = true resultado = await crmLocal.crearLead(body, usuarioSub) } } if ('duplicado' in resultado) { res.status(409).json({ error: 'Ya existe un lead con ese teléfono', leadId: resultado.duplicado.id, nombre: resultado.duplicado.nombre, }) return } if (modoLocal) { // Sin round-robin en modo local: el capturista queda asignado (su // ASIGNAR_PERSONA viaja encolada tras el CREATE) res.status(201).json({ id: resultado.id, estado: 'NUEVO', oportunidadId: null, asignacion: usuarioSub !== undefined ? 'capturista' : 'round-robin', }) return } // Registro local del capturista para "Mis capturas" del panel Mi día (Monday // no guarda quién dio de alta el lead). Un fallo aquí no rompe la respuesta: // el lead ya quedó creado en Monday. if (usuarioSub) { try { await prisma.leadCaptura.create({ data: { leadId: resultado.id, oportunidadId: null, // los leads ya no generan oportunidad usuarioId: usuarioSub, nombreLead: body.nombre, telefono: body.telefono ?? null, }, }) } catch (err) { // eslint-disable-next-line no-console console.error(`No se pudo registrar la captura del lead ${resultado.id}:`, err) } } const asignacion = await asignarCapturistaSiAplica(usuarioSub, resultado.id, body.nombre, 'leads') res.status(201).json({ id: resultado.id, estado: 'NUEVO', oportunidadId: null, asignacion }) auditoria.registrar(usuarioSub ?? 'sistema', 'CREAR_LEAD', 'lead', resultado.id) } /** POST /api/afluencias { nombre, telefono?, ..., perfil completo } — Alta Afluencia (tablero Oportunidades) */ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promise { const body = crearAfluenciaSchema.parse(req.body) const usuarioSub = req.usuarioToken?.sub // Modo local: Monday caído (flag) → crmLocal (crear o actualizar); fallback // a local tras 502. Misma forma de respuesta en ambos caminos. let modoLocal = !saludMonday.estaDisponible() let resultado: ResultadoCrearLead if (modoLocal) { resultado = await crmLocal.crearAfluencia(body, usuarioSub) } else { try { resultado = await crearAfluencia(mondayService, body) } catch (err) { if (!esFalloMonday(err)) throw err modoLocal = true resultado = await crmLocal.crearAfluencia(body, usuarioSub) } } if ('duplicado' in resultado) { res.status(409).json({ error: 'Ya existe un lead con ese teléfono', leadId: resultado.duplicado.id, nombre: resultado.duplicado.nombre, }) return } // Actualización de oportunidad existente (botón "Afluencia" de Mi día): no // hay captura nueva que registrar ni round-robin que correr if (resultado.actualizada === true) { res.json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, actualizada: true }) return } if (modoLocal) { res.status(201).json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, asignacion: usuarioSub !== undefined ? 'capturista' : 'round-robin', }) return } // Registro local del capturista para "Mis capturas" del panel Mi día. // leadId = el id de la oportunidad (la afluencia ES la oportunidad). Un // fallo aquí no rompe la respuesta: la afluencia ya quedó creada en Monday. if (usuarioSub) { try { await prisma.leadCaptura.create({ data: { leadId: resultado.id, oportunidadId: resultado.oportunidadId, usuarioId: usuarioSub, nombreLead: body.nombre, telefono: body.telefono ?? null, }, }) } catch (err) { // eslint-disable-next-line no-console console.error(`No se pudo registrar la captura de la afluencia ${resultado.id}:`, err) } } // Auto-mapping leadId = oportunidadId en LeadOportunidad: el alta de // clientes (moverAApartoConDedup) encuentra así la oportunidad a mover a // 'Aparto' tanto en ligas históricas como en el modelo nuevo. try { await prisma.leadOportunidad.create({ data: { leadId: resultado.id, oportunidadId: resultado.oportunidadId ?? resultado.id }, }) } catch (err) { // eslint-disable-next-line no-console console.error(`No se pudo guardar el mapping de la oportunidad ${resultado.id}:`, err) } const asignacion = await asignarCapturistaSiAplica(usuarioSub, resultado.id, body.nombre, 'oportunidades') res.status(201).json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, asignacion }) } /** * Cambio de estado en modo local: guarda la etapa (label real del board) en la * fila local y encola CAMBIO_ETAPA. Con id Monday sin fila local (fallback 502 * sobre items viejos) crea la fila "sombra" para que Mi día lo refleje. */ async function cambiarEstadoLocal( itemId: string, estado: (typeof ESTADOS_LEAD_PERMITIDOS)[number], fecha: string | undefined, fuente: FuenteLead, usuarioId: string | undefined, ): Promise { const esLead = fuente === 'lead' if (estado === 'INTERESADO' && !esLead) { throw new AppError(400, 'La etapa Interesado solo aplica a leads del tablero Leads') } const label = esLead ? LABEL_ESTADO_LEADS[estado] : LABEL_ESTADO_OPORTUNIDAD[estado] await crmLocal.cambiarEstado(itemId, label, esLead ? 'LEADS' : 'OPORTUNIDADES', { fecha, usuarioId, sombra: !esIdLocal(itemId), }) } /** POST /api/leads/:itemId/estado { estado, fecha?, fuente? } */ export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Promise { const body = cambiarEstadoSchema.parse(req.body) const itemId = String(req.params['itemId']) const fuente = body.fuente ?? 'oportunidad' // Los ids loc_* siempre van al path local (aún no existen en Monday); con el // flag caído todo va a local; con Monday vivo, fallback a local tras 502. if (esIdLocal(itemId) || !saludMonday.estaDisponible()) { await cambiarEstadoLocal(itemId, body.estado, body.fecha, fuente, req.usuarioToken?.sub) } else { try { await cambiarEstadoLead(mondayService, itemId, body.estado, body.fecha, body.fuente) } catch (err) { if (!esFalloMonday(err)) throw err await cambiarEstadoLocal(itemId, body.estado, body.fecha, fuente, req.usuarioToken?.sub) } } res.json({ itemId, estado: body.estado, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) }) auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CAMBIAR_ESTADO', 'lead', itemId, body.estado) } /** POST /api/leads/:itemId/descartar { motivo, subcategoria?, comentario?, fuente? } */ export async function descartarLeadEndpoint(req: Request, res: Response): Promise { const body = descartarSchema.parse(req.body) const itemId = String(req.params['itemId']) const fuente = body.fuente ?? 'oportunidad' let motivoItemId: string | null = null if (esIdLocal(itemId) || !saludMonday.estaDisponible()) { await crmLocal.descartar(itemId, body.motivo, { fuente, subcategoria: body.subcategoria, comentario: body.comentario, usuarioId: req.usuarioToken?.sub, sombra: !esIdLocal(itemId), }) } else { try { motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario, body.fuente, body.subcategoria) } catch (err) { if (!esFalloMonday(err)) throw err await crmLocal.descartar(itemId, body.motivo, { fuente, subcategoria: body.subcategoria, comentario: body.comentario, usuarioId: req.usuarioToken?.sub, sombra: !esIdLocal(itemId), }) } } res.json({ itemId, estado: body.fuente === 'lead' ? 'Descartado' : 'Perdido', motivo: body.motivo, motivoItemId }) auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'DESCARTAR_LEAD', 'lead', itemId, body.motivo) } // ---------- 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 { const body = convertirSchema.parse(req.body ?? {}) const itemId = String(req.params['itemId']) const usuarioSub = req.usuarioToken?.sub // Modo local (id loc_* o Monday caído / fallback 502): la conversión crea la // oportunidad local en 'Agendo Cita' y encola CONVERSION. Nota: en el // fallback la fecha de la cita viaja en el CAMBIO_ETAPA posterior del front, // como en el flujo normal. if (esIdLocal(itemId) || !saludMonday.estaDisponible()) { const oportunidad = await crmLocal.convertirLead(itemId, usuarioSub) res.status(201).json({ leadId: itemId, oportunidadId: oportunidad.leadId, etapa: 'Agendo Cita' }) auditoria.registrar(usuarioSub ?? 'sistema', 'CONVERTIR_LEAD', 'lead', itemId) return } try { const { oportunidadId } = await convertirLead(mondayService, itemId, usuarioSub, body.fecha) // Crear LeadCaptura local para la oportunidad (Mi día las necesita) try { const leadLocal = await prisma.leadCaptura.findFirst({ where: { leadId: itemId } }) await prisma.leadCaptura.upsert({ where: { leadId: oportunidadId }, update: { board: 'OPORTUNIDADES', etapa: 'Agendo Cita' }, create: { leadId: oportunidadId, board: 'OPORTUNIDADES', etapa: 'Agendo Cita', nombreLead: leadLocal?.nombreLead ?? '', telefono: leadLocal?.telefono ?? null, email: leadLocal?.email ?? null, usuarioId: usuarioSub ?? 'sistema', mondayItemId: oportunidadId, pendienteSync: false, }, }) } catch { /* best effort */ } res.status(201).json({ leadId: itemId, oportunidadId, etapa: 'Agendo Cita' }) auditoria.registrar(usuarioSub ?? 'sistema', 'CONVERTIR_LEAD', 'lead', itemId) } catch (err) { if (!esFalloMonday(err)) throw err const oportunidad = await crmLocal.convertirLead(itemId, usuarioSub) res.status(201).json({ leadId: itemId, oportunidadId: oportunidad.leadId, etapa: 'Agendo Cita' }) auditoria.registrar(usuarioSub ?? 'sistema', 'CONVERTIR_LEAD', 'lead', itemId) } } /** * GET /api/leads/buscar?q=texto — autocompletado para prellenar el alta de * clientes. Busca en AMBOS tableros (Leads + Oportunidades); q mínimo 2 * caracteres; devuelve máximo 15, cada uno con su `fuente`. * Con Monday caído busca en lo local (LeadCaptura + Cliente), misma forma. */ export async function buscarLeadsEndpoint(req: Request, res: Response): Promise { const q = String(req.query['q'] ?? '').trim() if (q.length < 2) throw new AppError(400, 'La búsqueda requiere al menos 2 caracteres') if (!saludMonday.estaDisponible()) { res.json(await crmLocal.buscarLocales(q)) return } const [leads, oportunidades] = await Promise.all([mondayService.getLeads(), mondayService.getOportunidades()]) res.json(buscarLeads(leads, oportunidades, q)) }