- convertirLeadEndpoint: crea LeadCaptura OPORTUNIDADES al convertir lead - escaneoOportunidades: crea LeadCaptura APARTADO al crear apartado - Mi día: esMio para apartados incluye oportunidadId del usuario - Flujo completo verificado: Lead → Contactado → Agendo → Aparto
799 lines
32 KiB
TypeScript
799 lines
32 KiB
TypeScript
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<typeof crearLeadSchema>,
|
||
): Promise<ResultadoCrearLead> {
|
||
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<typeof crearAfluenciaSchema>,
|
||
): Promise<ResultadoCrearLead> {
|
||
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<void> {
|
||
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<string> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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))
|
||
}
|