Files
FRASA-CAS/api-gateway/src/services/crm-local.service.ts

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' },
})
}