692 lines
29 KiB
TypeScript
692 lines
29 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
|
import { AppError, NotFoundError } from '../utils/errors.js'
|
|
import { afluenciaCompleta } from '../config/vocabulario.js'
|
|
import { claveApartadoCreado } from './automatizaciones.service.js'
|
|
import { calcularClienteClave } from './recordatorio-vendedor.service.js'
|
|
import { prisma } from './prisma.js'
|
|
|
|
/**
|
|
* CRM local: implementa el embudo completo contra SQLite (tabla LeadCaptura).
|
|
*
|
|
* - Entidad local del embudo: LeadCaptura (board LEADS | OPORTUNIDADES |
|
|
* APARTADO + etapa). Ids locales: `loc_<uuid>` (ver `esIdLocal`).
|
|
* - Las operaciones que tocan varias tablas van en $transaction.
|
|
* - SECUENCIAL a propósito (SQLite P1008): nada de Promise.all con escrituras.
|
|
*/
|
|
|
|
/** Board local del embudo (columna LeadCaptura.board) */
|
|
export type BoardLocal = 'LEADS' | 'OPORTUNIDADES' | 'APARTADO'
|
|
|
|
/** true si el id es local (`loc_<uuid>`) en lugar de un id heredado del CRM anterior */
|
|
export function esIdLocal(id: string): boolean {
|
|
return id.startsWith('loc_')
|
|
}
|
|
|
|
function idLocal(): string {
|
|
return `loc_${randomUUID()}`
|
|
}
|
|
|
|
/** Captura rápida de lead (forma corta de /hoy) */
|
|
export interface DatosLeadLocal {
|
|
nombre: string
|
|
telefono?: string | undefined
|
|
email?: string | undefined
|
|
origen?: string | undefined
|
|
desarrollo?: string | undefined
|
|
notas?: string | undefined
|
|
}
|
|
|
|
/** Alta Afluencia: captura ligera + perfilamiento completo */
|
|
export interface DatosAfluenciaLocal extends DatosLeadLocal {
|
|
/** Con valor: se ACTUALIZA esa oportunidad en vez de crear una nueva */
|
|
oportunidadId?: string | undefined
|
|
modelo?: string | undefined
|
|
medioContacto?: string | undefined
|
|
edad?: number | undefined
|
|
estadoCivil?: string | undefined
|
|
tipoEmpleo?: string | undefined
|
|
ocupacion?: string | undefined
|
|
rangoSalarial?: string | undefined
|
|
credito?: string | undefined
|
|
tipoEconomia?: string | undefined
|
|
califica?: string | undefined
|
|
loQuierePara?: string | undefined
|
|
}
|
|
|
|
/** Misma forma que ResultadoCrearLead del controller (sin importarlo: ciclo) */
|
|
export type ResultadoCapturaLocal =
|
|
| { id: string; oportunidadId: string | null; actualizada?: boolean }
|
|
| { duplicado: { id: string; nombre: string } }
|
|
|
|
/** Resultado del buscador local (misma forma que ResultadoBusquedaLead) */
|
|
export interface ResultadoBusquedaLocal {
|
|
id: string
|
|
nombre: string
|
|
telefono: string
|
|
email: string
|
|
estado: string
|
|
origen: string
|
|
fuente: 'lead' | 'oportunidad'
|
|
desarrollo: string
|
|
modeloInteres: string
|
|
}
|
|
|
|
export class CrmLocalService {
|
|
/**
|
|
* Captura rápida local: LeadCaptura board=LEADS etapa 'NUEVO'. Dedup por
|
|
* teléfono y email entre los leads locales (mismo contrato 409 de siempre).
|
|
*/
|
|
async crearLead(datos: DatosLeadLocal, usuarioId?: string): Promise<ResultadoCapturaLocal> {
|
|
const telNorm =
|
|
datos.telefono !== undefined && datos.telefono.trim() !== '' ? normalizaTelefono(datos.telefono) : null
|
|
const emailNorm =
|
|
datos.email !== undefined && datos.email.trim() !== '' ? datos.email.trim().toLowerCase() : null
|
|
// Serializa la captura por teléfono/email: dos envíos simultáneos no deben
|
|
// pasar juntos la validación de duplicado.
|
|
return conLock(telNorm ? `tel:${telNorm}` : emailNorm ? `mail:${emailNorm}` : `cap:${usuarioId ?? 'anon'}`, async () => {
|
|
// Dedup por teléfono normalizado en CUALQUIER tablero: evita que la
|
|
// misma persona quede como lead y como afluencia.
|
|
if (telNorm) {
|
|
const existente = await buscarPorTelefono(telNorm)
|
|
if (existente) return { duplicado: { id: existente.leadId, nombre: existente.nombreLead } }
|
|
}
|
|
// Dedup por email (minúsculas): mismo criterio que el teléfono (cualquier
|
|
// tablero, sin etapas cerradas)
|
|
if (emailNorm) {
|
|
// Busca el email normalizado y, si venía distinto (mayúsculas), también
|
|
// tal cual: los registros viejos conservan el formato original.
|
|
const variantes = [{ email: emailNorm }]
|
|
if (datos.email && datos.email.trim() !== emailNorm) variantes.push({ email: datos.email.trim() })
|
|
const existenteEmail = await prisma.leadCaptura.findFirst({
|
|
where: { OR: variantes, etapa: { notIn: ETAPAS_CERRADAS } },
|
|
})
|
|
if (existenteEmail) return { duplicado: { id: existenteEmail.leadId, nombre: existenteEmail.nombreLead } }
|
|
}
|
|
const id = idLocal()
|
|
const fila = await prisma.leadCaptura.create({
|
|
data: {
|
|
leadId: id,
|
|
oportunidadId: null, // los leads no generan oportunidad
|
|
usuarioId: usuarioId ?? 'desconocido',
|
|
nombreLead: datos.nombre,
|
|
telefono: datos.telefono ?? null,
|
|
telefonoNorm: telNorm,
|
|
email: emailNorm,
|
|
origen: datos.origen ?? null,
|
|
desarrollo: datos.desarrollo ?? null,
|
|
board: 'LEADS',
|
|
etapa: 'NUEVO',
|
|
datosJson: JSON.stringify({ notas: datos.notas ?? null }),
|
|
pendienteSync: true,
|
|
},
|
|
})
|
|
return { id: fila.leadId, oportunidadId: null }
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Alta Afluencia local. Sin oportunidadId: LeadCaptura board=OPORTUNIDADES
|
|
* etapa 'Afluencia' con el perfilamiento en datosJson (además de los campos
|
|
* planos) + auto-mapping LeadOportunidad. Con oportunidadId (botón
|
|
* "Afluencia" de Mis oportunidades): actualiza la fila local si existe.
|
|
*/
|
|
async crearAfluencia(datos: DatosAfluenciaLocal, usuarioId?: string): Promise<ResultadoCapturaLocal> {
|
|
const { oportunidadId, modelo } = datos
|
|
|
|
if (oportunidadId !== undefined) {
|
|
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: oportunidadId } })
|
|
// Sin fila local: id inválido o liga guardada de una limpieza anterior.
|
|
// Antes se creaba una oportunidad "sombra" fantasma con ese id; ahora se
|
|
// rechaza para no sembrar registros huérfanos en Oportunidades.
|
|
if (!existente) {
|
|
throw new NotFoundError(`No existe la oportunidad ${oportunidadId}`)
|
|
}
|
|
|
|
// Sólo se pisan los campos capturados: lo que no viene en el payload se
|
|
// conserva (antes se borraba con null y se perdía la cita agendada).
|
|
const cambio: Record<string, unknown> = { pendienteSync: true }
|
|
if (datos.nombre.trim() !== '') cambio['nombreLead'] = datos.nombre
|
|
if (datos.telefono !== undefined) {
|
|
cambio['telefono'] = datos.telefono
|
|
cambio['telefonoNorm'] = datos.telefono.trim() === '' ? null : normalizaTelefono(datos.telefono)
|
|
}
|
|
if (datos.email !== undefined) cambio['email'] = datos.email.trim() === '' ? null : datos.email.trim().toLowerCase()
|
|
if (datos.origen !== undefined) cambio['origen'] = datos.origen
|
|
if (datos.desarrollo !== undefined) cambio['desarrollo'] = datos.desarrollo
|
|
if (modelo !== undefined) cambio['modelo'] = modelo
|
|
|
|
// La etapa NO retrocede: sólo se marca Afluencia si aún no había avanzado
|
|
const etapaActual = (existente.etapa ?? '').trim()
|
|
const etapasIniciales = ['', 'NUEVO', 'Afluencia']
|
|
const etapaFinal = etapasIniciales.includes(etapaActual) ? 'Afluencia' : existente.etapa
|
|
if (etapaFinal !== existente.etapa) cambio['etapa'] = etapaFinal
|
|
|
|
// datosJson se MEZCLA: preserva fechaCita y los campos no reenviados
|
|
let previos: Record<string, unknown> = {}
|
|
try {
|
|
previos = existente.datosJson ? (JSON.parse(existente.datosJson) as Record<string, unknown>) : {}
|
|
} catch {
|
|
previos = {}
|
|
}
|
|
const nuevos = Object.fromEntries(Object.entries(datos).filter(([, v]) => v !== undefined && v !== null))
|
|
cambio['datosJson'] = JSON.stringify({ ...previos, ...nuevos })
|
|
|
|
await prisma.leadCaptura.update({ where: { leadId: oportunidadId }, data: cambio })
|
|
return { id: oportunidadId, oportunidadId, actualizada: true }
|
|
}
|
|
|
|
const telNorm =
|
|
datos.telefono !== undefined && datos.telefono.trim() !== '' ? normalizaTelefono(datos.telefono) : null
|
|
const emailNorm =
|
|
datos.email !== undefined && datos.email.trim() !== '' ? datos.email.trim().toLowerCase() : null
|
|
// Dedup + creación serializados por teléfono/email (mismo criterio que
|
|
// crearLead: dos envíos simultáneos no deben duplicar).
|
|
return conLock(telNorm ? `tel:${telNorm}` : emailNorm ? `mail:${emailNorm}` : `afl:${usuarioId ?? 'anon'}`, async () => {
|
|
if (telNorm) {
|
|
const duplicado = await buscarPorTelefono(telNorm)
|
|
if (duplicado) return { duplicado: { id: duplicado.leadId, nombre: duplicado.nombreLead } }
|
|
}
|
|
|
|
const id = idLocal()
|
|
await prisma.$transaction([
|
|
prisma.leadCaptura.create({
|
|
data: {
|
|
leadId: id,
|
|
oportunidadId: id, // la afluencia ES la oportunidad
|
|
usuarioId: usuarioId ?? 'desconocido',
|
|
nombreLead: datos.nombre,
|
|
telefono: datos.telefono ?? null,
|
|
telefonoNorm: telNorm,
|
|
email: emailNorm,
|
|
origen: datos.origen ?? null,
|
|
desarrollo: datos.desarrollo ?? null,
|
|
modelo: modelo ?? null,
|
|
board: 'OPORTUNIDADES',
|
|
etapa: 'Afluencia',
|
|
datosJson: JSON.stringify(datos),
|
|
pendienteSync: true,
|
|
},
|
|
}),
|
|
// Auto-mapping leadId = oportunidadId: el alta de clientes encuentra así
|
|
// la oportunidad a mover a 'Aparto'
|
|
prisma.leadOportunidad.create({ data: { leadId: id, oportunidadId: id } }),
|
|
])
|
|
return { id, oportunidadId: id }
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Fila local del item, creándola "sombra" si no existe y `sombra` es true.
|
|
* Las sombras cubren ids heredados del CRM anterior sin fila local: la
|
|
* acción queda reflejada en Mi día. Sin `sombra` → 404.
|
|
*/
|
|
private async filaLocal(itemId: string, board: BoardLocal, usuarioId: string | undefined, sombra: boolean) {
|
|
const row = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
|
|
if (row) return row
|
|
if (!sombra) throw new NotFoundError(`No existe registro local para el item ${itemId}`)
|
|
return prisma.leadCaptura.create({
|
|
data: {
|
|
leadId: itemId,
|
|
usuarioId: usuarioId ?? 'desconocido',
|
|
nombreLead: 'Registro previo (CRM anterior)',
|
|
board,
|
|
etapa: 'NUEVO',
|
|
mondayItemId: esIdLocal(itemId) ? null : itemId,
|
|
pendienteSync: true,
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Cambio de estado/etapa local (boards LEADS, OPORTUNIDADES y APARTADO):
|
|
* actualiza `etapa` (+updatedAt vía Prisma) y guarda la fecha de la cita en
|
|
* datosJson si aplica.
|
|
*/
|
|
async cambiarEstado(
|
|
itemId: string,
|
|
label: string,
|
|
board: BoardLocal,
|
|
opciones: { fecha?: string | undefined; usuarioId?: string | undefined; sombra?: boolean | undefined } = {},
|
|
): Promise<void> {
|
|
await this.filaLocal(itemId, board, opciones.usuarioId, opciones.sombra ?? false)
|
|
// Guardar fechaCita en datosJson (oportunidad y apartado) para que la
|
|
// muestre Mi día y el calendario
|
|
const updateData: Record<string, unknown> = { etapa: label, pendienteSync: true }
|
|
if (opciones.fecha !== undefined && (board === 'OPORTUNIDADES' || board === 'APARTADO')) {
|
|
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: itemId }, select: { datosJson: true } })
|
|
let datos: Record<string, unknown> = {}
|
|
if (existente?.datosJson) {
|
|
try { datos = JSON.parse(existente.datosJson) as Record<string, unknown> } catch { /* ignorar */ }
|
|
}
|
|
datos['fechaCita'] = opciones.fecha
|
|
updateData['datosJson'] = JSON.stringify(datos)
|
|
}
|
|
await prisma.leadCaptura.update({
|
|
where: { leadId: itemId },
|
|
data: updateData,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Descarte local: etapa terminal ('Descartado' en Leads, 'Perdido' en
|
|
* Oportunidades); guarda motivo/submotivo.
|
|
*/
|
|
async descartar(
|
|
itemId: string,
|
|
motivo: string,
|
|
opciones: {
|
|
fuente?: 'lead' | 'oportunidad' | undefined
|
|
subcategoria?: string | undefined
|
|
comentario?: string | undefined
|
|
usuarioId?: string | undefined
|
|
sombra?: boolean | undefined
|
|
} = {},
|
|
): Promise<void> {
|
|
const fuente = opciones.fuente ?? 'oportunidad'
|
|
const board: BoardLocal = fuente === 'lead' ? 'LEADS' : 'OPORTUNIDADES'
|
|
const row = await this.filaLocal(itemId, board, opciones.usuarioId, opciones.sombra ?? false)
|
|
const etapaTerminal = fuente === 'lead' ? 'Descartado' : 'Perdido'
|
|
await prisma.leadCaptura.update({
|
|
where: { leadId: itemId },
|
|
data: {
|
|
etapa: etapaTerminal,
|
|
motivo,
|
|
submotivo: opciones.subcategoria ?? null,
|
|
pendienteSync: true,
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Conversión local lead → oportunidad (botón "Agendo" de Mis leads): el lead
|
|
* queda 'Calificado', se crea la oportunidad local en 'Agendo Cita' copiando
|
|
* los datos y se liga LeadOportunidad + `oportunidadId` en la captura. Las
|
|
* notas (recordatorios) del lead pasan a apuntar a la oportunidad para que
|
|
* sobrevivan al cambio de etapa. Si el lead ya fue convertido → 409. Devuelve
|
|
* la fila local de la oportunidad.
|
|
*/
|
|
async convertirLead(leadIdLocal: string, _usuarioId?: string) {
|
|
const lead = await prisma.leadCaptura.findUnique({ where: { leadId: leadIdLocal } })
|
|
if (!lead) throw new NotFoundError('Lead no encontrado')
|
|
if (lead.oportunidadId !== null) throw new AppError(409, 'El lead ya fue convertido en oportunidad')
|
|
|
|
const oportunidadId = idLocal()
|
|
const resultados = await prisma.$transaction([
|
|
prisma.leadCaptura.update({
|
|
where: { leadId: leadIdLocal },
|
|
data: { etapa: 'Calificado', oportunidadId, pendienteSync: true },
|
|
}),
|
|
prisma.leadCaptura.create({
|
|
data: {
|
|
leadId: oportunidadId,
|
|
oportunidadId,
|
|
usuarioId: lead.usuarioId,
|
|
nombreLead: lead.nombreLead,
|
|
telefono: lead.telefono,
|
|
telefonoNorm: lead.telefonoNorm,
|
|
email: lead.email,
|
|
origen: lead.origen,
|
|
desarrollo: lead.desarrollo,
|
|
modelo: lead.modelo,
|
|
board: 'OPORTUNIDADES',
|
|
etapa: 'Agendo Cita',
|
|
datosJson: lead.datosJson,
|
|
pendienteSync: true,
|
|
},
|
|
}),
|
|
prisma.leadOportunidad.create({ data: { leadId: leadIdLocal, oportunidadId } }),
|
|
// Las notas del lead viejo siguen a la nueva oportunidad (bitácora persiste)
|
|
prisma.recordatorioVendedor.updateMany({
|
|
where: { leadId: leadIdLocal },
|
|
data: { leadId: oportunidadId, clienteClave: calcularClienteClave(lead.nombreLead, lead.telefono) },
|
|
}),
|
|
])
|
|
return resultados[1]
|
|
}
|
|
|
|
/**
|
|
* "Aparto" local: la oportunidad pasa a etapa 'Aparto' y se crea el apartado
|
|
* local (board=APARTADO, etapa 'Pago Apartado', primer paso del pipeline
|
|
* Enkontrol) con los datos conocidos. Las notas (recordatorios) de la
|
|
* oportunidad pasan a apuntar al apartado para que sobrevivan al cambio de
|
|
* board. Idempotente con el mismo dedup `apartado-creado:<opId>` de las
|
|
* automatizaciones.
|
|
*
|
|
* `opciones.unidadCodigo` deja la unidad apartada en el `datosJson` del
|
|
* apartado (la cotización del vendedor o la unidad de interés): es lo que
|
|
* después leen el bloqueo de inventario y Mi día.
|
|
*/
|
|
async apartar(
|
|
oportunidadIdLocal: string,
|
|
usuarioId?: string,
|
|
opciones: { unidadCodigo?: string | undefined } = {},
|
|
) {
|
|
// Sombra para ids heredados sin fila local
|
|
const oportunidad = await this.filaLocal(
|
|
oportunidadIdLocal,
|
|
'OPORTUNIDADES',
|
|
usuarioId,
|
|
!esIdLocal(oportunidadIdLocal),
|
|
)
|
|
|
|
// Regla de negocio: no se aparta sin la afluencia completa del cliente
|
|
if (!afluenciaCompleta(oportunidad.datosJson)) {
|
|
throw new AppError(400, 'Primero debes capturar la afluencia del cliente (botón Afluencia) antes de apartar')
|
|
}
|
|
|
|
const clave = claveApartadoCreado(oportunidadIdLocal)
|
|
const previo = await prisma.automatizacionState.findUnique({ where: { clave } })
|
|
if (previo !== null) {
|
|
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: previo.valor } })
|
|
if (existente) return existente
|
|
}
|
|
|
|
const apartadoId = idLocal()
|
|
const datosJson = conUnidadCodigo(oportunidad.datosJson, opciones.unidadCodigo)
|
|
const resultados = await prisma.$transaction([
|
|
prisma.leadCaptura.update({
|
|
where: { leadId: oportunidadIdLocal },
|
|
data: { etapa: 'Aparto', pendienteSync: true },
|
|
}),
|
|
prisma.leadCaptura.create({
|
|
data: {
|
|
leadId: apartadoId,
|
|
usuarioId: oportunidad.usuarioId,
|
|
nombreLead: oportunidad.nombreLead,
|
|
telefono: oportunidad.telefono,
|
|
telefonoNorm: oportunidad.telefonoNorm,
|
|
email: oportunidad.email,
|
|
origen: oportunidad.origen,
|
|
desarrollo: oportunidad.desarrollo,
|
|
modelo: oportunidad.modelo,
|
|
board: 'APARTADO',
|
|
etapa: 'Pago Apartado',
|
|
datosJson,
|
|
// Fecha límite del apartado en la COLUMNA: la leen el KPI "Apartados
|
|
// por vencer", el cron de avisos y la lista de Mi día (antes sólo se
|
|
// calculaba en el JSON del alta y la columna quedaba vacía).
|
|
fechaLimite: fechaLimiteApartado(datosJson),
|
|
pendienteSync: true,
|
|
},
|
|
}),
|
|
prisma.automatizacionState.upsert({
|
|
where: { clave },
|
|
update: { valor: apartadoId },
|
|
create: { clave, valor: apartadoId },
|
|
}),
|
|
// Las notas de la oportunidad siguen al apartado (bitácora persiste)
|
|
prisma.recordatorioVendedor.updateMany({
|
|
where: { leadId: oportunidadIdLocal },
|
|
data: { leadId: apartadoId, clienteClave: calcularClienteClave(oportunidad.nombreLead, oportunidad.telefono) },
|
|
}),
|
|
])
|
|
return resultados[1]
|
|
}
|
|
|
|
/**
|
|
* Búsqueda local para el picker del alta de clientes (y el buscador del
|
|
* cotizador): LeadCaptura por nombre/teléfono + Cliente por nombre/celular.
|
|
* Con `alcance.soloPropio` (rol vendedor) filtra a los registros propios:
|
|
* cada asesor sólo encuentra sus leads y contactos, no los de sus compañeros.
|
|
*/
|
|
async buscarLocales(
|
|
termino: string,
|
|
alcance?: { usuarioId: string; soloPropio: boolean },
|
|
): Promise<ResultadoBusquedaLocal[]> {
|
|
const propioLead = alcance?.soloPropio ? { usuarioId: alcance.usuarioId } : {}
|
|
const propioCliente = alcance?.soloPropio ? { capturadoPorId: alcance.usuarioId } : {}
|
|
// Búsqueda telefónica: el teléfono crudo puede traer espacios, ladas o
|
|
// caracteres invisibles (p. ej. '+52 +52 664 782 5023'), así que el match
|
|
// va contra los dígitos normalizados (telefonoNorm), en ambos sentidos:
|
|
// quien busca '6647825023' o '+52 664 782 5023' debe encontrar el registro.
|
|
const digitos = termino.replace(/\D/g, '')
|
|
const pareceTelefono = /^[\d\s+().-]+$/.test(termino) && digitos.length >= 2
|
|
// Con lada (+52 ...) el término supera los 10 dígitos del número nacional:
|
|
// se agrega también el sufijo de 10 dígitos para el match en telefonoNorm
|
|
const sufijoNacional = digitos.length > 10 ? digitos.slice(-10) : null
|
|
const condTelefonoLead =
|
|
digitos.length >= 2
|
|
? [
|
|
{ telefono: { contains: termino } },
|
|
{ telefonoNorm: { contains: digitos } },
|
|
...(sufijoNacional !== null ? [{ telefonoNorm: { contains: sufijoNacional } }] : []),
|
|
]
|
|
: [{ telefono: { contains: termino } }]
|
|
const condTelefonoCliente = digitos.length >= 2 ? [{ celular: { contains: digitos } }] : []
|
|
// Nombres: variantes con/sin acento (y ñ/n) porque el asesor no siempre
|
|
// teclea las tildes ('jazmin' debe encontrar a 'Jazmín'). Se genera la
|
|
// forma sin acentos más una variante por vocal acentuada (una a la vez),
|
|
// suficiente en la práctica y acotado en tamaño.
|
|
const A_QUITA: Record<string, string> = {
|
|
á: 'a', à: 'a', ä: 'a', â: 'a', é: 'e', è: 'e', ë: 'e', ê: 'e',
|
|
í: 'i', ï: 'i', î: 'i', ó: 'o', ö: 'o', ô: 'o', ú: 'u', ü: 'u', û: 'u',
|
|
Á: 'A', À: 'A', Ä: 'A', Â: 'A', É: 'E', È: 'E', Ë: 'E', Ê: 'E',
|
|
Í: 'I', Ï: 'I', Î: 'I', Ó: 'O', Ö: 'O', Ô: 'O', Ú: 'U', Ü: 'U', Û: 'U',
|
|
}
|
|
const A_PONE: Record<string, string> = {
|
|
a: 'á', e: 'é', i: 'í', o: 'ó', u: 'ú', A: 'Á', E: 'É', I: 'Í', O: 'Ó', U: 'Ú', n: 'ñ', N: 'Ñ',
|
|
}
|
|
const variantesNombre = (t: string): string[] => {
|
|
const base = [...t].map((c) => A_QUITA[c] ?? c).join('')
|
|
const variants = new Set<string>([base])
|
|
const chars = [...base]
|
|
for (let i = 0; i < chars.length; i++) {
|
|
const ch = chars[i] as string
|
|
const acentuada = A_PONE[ch]
|
|
if (acentuada !== undefined) variants.add(chars.slice(0, i).join('') + acentuada + chars.slice(i + 1).join(''))
|
|
}
|
|
if (base !== t) variants.add(t)
|
|
return [...variants]
|
|
}
|
|
const condNombreLead = variantesNombre(termino).map((v) => ({ nombreLead: { contains: v } }))
|
|
const condNombreCliente = variantesNombre(termino).map((v) => ({ nombreCompleto: { contains: v } }))
|
|
const capturas = await prisma.leadCaptura.findMany({
|
|
where: { AND: [propioLead, { OR: [...condNombreLead, ...condTelefonoLead] }] },
|
|
orderBy: { updatedAt: 'desc' },
|
|
take: 50,
|
|
})
|
|
const clientes = await prisma.cliente.findMany({
|
|
where: { AND: [propioCliente, { OR: [...condNombreCliente, { celular: { contains: termino } }, ...condTelefonoCliente] }] },
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 50,
|
|
})
|
|
/** Teléfono buscado: match si los dígitos del registro contienen los del
|
|
* término, o viceversa (búsqueda con lada completa vs registro sin lada). */
|
|
const coincideTelefono = (crudo: string | null | undefined): boolean => {
|
|
if (digitos.length < 2) return true
|
|
const d = (crudo ?? '').replace(/\D/g, '')
|
|
return d.includes(digitos) || (d.length >= 6 && digitos.includes(d))
|
|
}
|
|
const candidatos = [
|
|
...capturas
|
|
.filter((c) => !pareceTelefono || coincideTelefono(c.telefono) || c.nombreLead.includes(termino))
|
|
.map((c) => ({
|
|
id: c.leadId,
|
|
nombre: c.nombreLead,
|
|
telefono: c.telefono ?? '',
|
|
email: c.email ?? '',
|
|
estado: c.etapa,
|
|
origen: c.origen ?? '',
|
|
fuente: (c.board === 'LEADS' ? 'lead' : 'oportunidad') as 'lead' | 'oportunidad',
|
|
desarrollo: c.desarrollo ?? '',
|
|
modeloInteres: c.modelo ?? '',
|
|
orden: c.updatedAt,
|
|
})),
|
|
...clientes
|
|
.filter((c) => !pareceTelefono || coincideTelefono(c.celular) || c.nombreCompleto.includes(termino))
|
|
.map((c) => ({
|
|
id: c.id,
|
|
nombre: c.nombreCompleto,
|
|
telefono: c.celular ?? '',
|
|
email: c.email ?? '',
|
|
estado: '',
|
|
origen: '',
|
|
fuente: 'oportunidad' as const,
|
|
desarrollo: c.proyecto,
|
|
modeloInteres: '',
|
|
orden: c.createdAt,
|
|
})),
|
|
]
|
|
return candidatos
|
|
.sort((a, b) => b.orden.getTime() - a.orden.getTime())
|
|
.slice(0, 15)
|
|
.map(({ orden: _orden, ...resto }) => resto)
|
|
}
|
|
|
|
/**
|
|
* Reasignación en cadena (solo admin): mueve el registro y TODA su cadena
|
|
* al nuevo vendedor. Un cliente puede tener hasta 3 filas (lead →
|
|
* oportunidad → apartado) y cada una tiene su propio leadId; reasignar sólo
|
|
* la fila tocada dejaba al cliente "partido" entre dos vendedores (el lead
|
|
* con uno y la oportunidad/apartado con el otro). También pasan las
|
|
* actividades PENDIENTES (calendario); las completadas quedan como
|
|
* historial del vendedor original. Devuelve cuántas filas se movieron.
|
|
*/
|
|
async reasignarCadena(itemId: string, usuarioId: string): Promise<number> {
|
|
const origen = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
|
|
if (!origen) throw new NotFoundError('Lead no encontrado')
|
|
|
|
const ids = new Set<string>([itemId])
|
|
let oportunidadId: string | null = null
|
|
if (origen.board === 'LEADS') {
|
|
oportunidadId = origen.oportunidadId
|
|
} else if (origen.board === 'OPORTUNIDADES') {
|
|
oportunidadId = itemId
|
|
} else if (origen.board === 'APARTADO') {
|
|
// Cadena inversa: el estado 'apartado-creado:{O}' apunta a este apartado
|
|
const estado = await prisma.automatizacionState.findFirst({
|
|
where: { clave: { startsWith: 'apartado-creado:' }, valor: itemId },
|
|
})
|
|
oportunidadId = estado?.clave.slice('apartado-creado:'.length) ?? null
|
|
}
|
|
|
|
if (oportunidadId !== null) {
|
|
ids.add(oportunidadId)
|
|
// El lead origen (tabla LEADS) apunta a la oportunidad con su columna
|
|
const lead = await prisma.leadCaptura.findFirst({
|
|
where: { board: 'LEADS', oportunidadId },
|
|
select: { leadId: true },
|
|
})
|
|
if (lead) ids.add(lead.leadId)
|
|
// El apartado cuelga de la oportunidad por la clave de automatización
|
|
const apartado = await prisma.automatizacionState.findUnique({
|
|
where: { clave: claveApartadoCreado(oportunidadId) },
|
|
})
|
|
if (apartado) ids.add(apartado.valor)
|
|
}
|
|
|
|
const listaIds = [...ids]
|
|
const resultados = await prisma.$transaction([
|
|
prisma.leadCaptura.updateMany({
|
|
where: { leadId: { in: listaIds } },
|
|
data: { usuarioId },
|
|
}),
|
|
prisma.recordatorioVendedor.updateMany({
|
|
where: { leadId: { in: listaIds }, completado: false },
|
|
data: { usuarioId },
|
|
}),
|
|
])
|
|
return resultados[0].count
|
|
}
|
|
}
|
|
|
|
/** Singleton usado por los controllers (los tests instancian el suyo o mockean prisma) */
|
|
export const crmLocal = new CrmLocalService()/** Fecha límite de un apartado: la del JSON si existe, si no hoy + 15 días */
|
|
function fechaLimiteApartado(datosJson: string | null): Date {
|
|
try {
|
|
const datos = datosJson ? (JSON.parse(datosJson) as Record<string, unknown>) : {}
|
|
const fecha = datos['fechaLimite']
|
|
if (typeof fecha === 'string' && fecha.trim() !== '') {
|
|
const d = new Date(fecha.length === 10 ? `${fecha}T00:00:00.000Z` : fecha)
|
|
if (!Number.isNaN(d.getTime())) return d
|
|
}
|
|
} catch {
|
|
/* JSON corrupto: se calcula por defecto */
|
|
}
|
|
const limite = new Date()
|
|
limite.setDate(limite.getDate() + 15)
|
|
return limite
|
|
}
|
|
|
|
/** Sólo dígitos de un teléfono (quita +52, espacios, guiones, paréntesis) */
|
|
function soloDigitos(telefono: string): string {
|
|
return telefono.replace(/\D/g, '')
|
|
}
|
|
|
|
/**
|
|
* Teléfono normalizado para dedup: sólo dígitos; con 10 o más toma los
|
|
* últimos 10 (cubre lada duplicada tipo "+52 52 ..."); con 7-9 conserva
|
|
* todos. Menos de 7 dígitos no es buscable (null). Coincide con el backfill
|
|
* de la migración 20260926120000_lead_telefono_norm.
|
|
*/
|
|
export function normalizaTelefono(telefono: string | null | undefined): string | null {
|
|
if (!telefono) return null
|
|
const digitos = soloDigitos(telefono)
|
|
if (digitos.length >= 10) return digitos.slice(-10)
|
|
if (digitos.length >= 7) return digitos
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Mutex en memoria por clave (un sólo proceso Node): serializa la pareja
|
|
* validar-duplicado + crear para el mismo teléfono/email. Sin esto, dos
|
|
* envíos simultáneos (doble clic en dos pestañas, red lenta) pasaban ambos
|
|
* la validación y duplicaban el registro.
|
|
*/
|
|
const colasLock = new Map<string, Promise<unknown>>()
|
|
|
|
function conLock<T>(clave: string, fn: () => Promise<T>): Promise<T> {
|
|
const encolada = (colasLock.get(clave) ?? Promise.resolve()).then(fn, fn)
|
|
colasLock.set(clave, encolada)
|
|
const libera = () => {
|
|
if (colasLock.get(clave) === encolada) colasLock.delete(clave)
|
|
}
|
|
void encolada.then(libera, libera)
|
|
return encolada
|
|
}
|
|
|
|
/**
|
|
* `datosJson` con la unidad apartada dentro (mezcla, no reemplaza: conserva
|
|
* fechaCita, fechaLimite y el perfilamiento de la captura). Sin unidadCodigo
|
|
* devuelve el JSON original tal cual.
|
|
*/
|
|
function conUnidadCodigo(datosJson: string | null, unidadCodigo?: string): string | null {
|
|
const unidad = unidadCodigo?.trim()
|
|
if (!unidad) return datosJson
|
|
let datos: Record<string, unknown> = {}
|
|
if (datosJson) {
|
|
try {
|
|
datos = JSON.parse(datosJson) as Record<string, unknown>
|
|
} catch {
|
|
datos = {}
|
|
}
|
|
}
|
|
return JSON.stringify({ ...datos, unidadCodigo: unidad })
|
|
}
|
|
|
|
/**
|
|
* Etapas que cierran el ciclo de un registro: descartados, perdidos y
|
|
* cancelados. Ya NO bloquean una captura nueva con el mismo teléfono/email:
|
|
* tras una limpieza (o un descarte) el vendedor debe poder volver a capturar
|
|
* a esa persona. Verificado en vivo: restos de APARTADO cancelados bloqueaban
|
|
* re-capturas tras la última limpieza de leads.
|
|
*/
|
|
const ETAPAS_CERRADAS = ['Descartado', 'Perdido', 'Cancelo', 'Cancelacion Apartado']
|
|
|
|
/**
|
|
* Busca un contacto por teléfono normalizado (columna telefonoNorm) en
|
|
* CUALQUIER tablero, excluyendo etapas cerradas. Así el mismo número no se
|
|
* duplica entre tableros activos, pero los registros limpiados o descartados
|
|
* no estorban. La búsqueda anterior por `telefono contains` fallaba contra
|
|
* teléfonos guardados con espacios ("+52 664 225 7841" no contiene
|
|
* "6642257841") y dejaba pasar duplicados.
|
|
*/
|
|
async function buscarPorTelefono(telefonoNormalizado: string) {
|
|
return prisma.leadCaptura.findFirst({
|
|
where: { telefonoNorm: telefonoNormalizado, etapa: { notIn: ETAPAS_CERRADAS } },
|
|
orderBy: { createdAt: 'desc' },
|
|
})
|
|
}
|
|
|
|
|