fix: dedup de leads por telefonoNorm + lock anti-carrera (duplicados)

- Migracion: columna LeadCaptura.telefonoNorm (ultimos 10 digitos) con
  backfill de los 873 registros e indice
- buscarPorTelefono usa telefonoNorm exacto: la busqueda anterior
  'telefono contains' fallaba con telefonos guardados con espacios
  ('+52 664 225 7841' no contiene '6642257841') y dejaba pasar duplicados
- Mutex en memoria por telefono/email: dos envios simultaneos ya no pasan
  juntos la validacion
- convertirLead/apartar propagan telefonoNorm; email se guarda en minusculas
- 8 tests nuevos (normalizacion, dedup con espacios, lock)
This commit is contained in:
2026-09-26 21:19:31 +00:00
parent 19a8a5b037
commit db28af1c58
4 changed files with 209 additions and 28 deletions

View File

@@ -77,20 +77,28 @@ export class CrmLocalService {
* teléfono y email entre los leads locales (mismo contrato 409 de siempre).
*/
async crearLead(datos: DatosLeadLocal, usuarioId?: string): Promise<ResultadoCapturaLocal> {
// Dedup por teléfono (normalizado a los últimos 10 dígitos) en CUALQUIER
// tablero: evita que la misma persona quede como lead y como afluencia.
if (datos.telefono !== undefined && datos.telefono.trim() !== '') {
const telLimpio = soloDigitos(datos.telefono).slice(-10)
if (telLimpio.length >= 7) {
const existente = await buscarPorTelefono(telLimpio)
if (existente) return { duplicado: { id: existente.leadId, nombre: existente.nombreLead } }
}
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: mismo criterio que el teléfono (cualquier tablero,
// sin etapas cerradas)
if (datos.email !== undefined && datos.email.trim() !== '') {
// 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: { email: datos.email.trim(), etapa: { notIn: ETAPAS_CERRADAS } },
where: { OR: variantes, etapa: { notIn: ETAPAS_CERRADAS } },
})
if (existenteEmail) return { duplicado: { id: existenteEmail.leadId, nombre: existenteEmail.nombreLead } }
}
@@ -102,7 +110,8 @@ export class CrmLocalService {
usuarioId: usuarioId ?? 'desconocido',
nombreLead: datos.nombre,
telefono: datos.telefono ?? null,
email: datos.email ?? null,
telefonoNorm: telNorm,
email: emailNorm,
origen: datos.origen ?? null,
desarrollo: datos.desarrollo ?? null,
board: 'LEADS',
@@ -112,6 +121,7 @@ export class CrmLocalService {
},
})
return { id: fila.leadId, oportunidadId: null }
})
}
/**
@@ -136,8 +146,11 @@ export class CrmLocalService {
// 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
if (datos.email !== undefined) cambio['email'] = datos.email
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
@@ -162,12 +175,16 @@ export class CrmLocalService {
return { id: oportunidadId, oportunidadId, actualizada: true }
}
if (datos.telefono !== undefined && datos.telefono.trim() !== '') {
const telLimpio = soloDigitos(datos.telefono).slice(-10)
if (telLimpio.length >= 7) {
const duplicado = await buscarPorTelefono(telLimpio)
if (duplicado) return { duplicado: { id: duplicado.leadId, nombre: duplicado.nombreLead } }
}
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()
@@ -179,7 +196,8 @@ export class CrmLocalService {
usuarioId: usuarioId ?? 'desconocido',
nombreLead: datos.nombre,
telefono: datos.telefono ?? null,
email: datos.email ?? null,
telefonoNorm: telNorm,
email: emailNorm,
origen: datos.origen ?? null,
desarrollo: datos.desarrollo ?? null,
modelo: modelo ?? null,
@@ -194,6 +212,7 @@ export class CrmLocalService {
prisma.leadOportunidad.create({ data: { leadId: id, oportunidadId: id } }),
])
return { id, oportunidadId: id }
})
}
/**
@@ -304,6 +323,7 @@ export class CrmLocalService {
usuarioId: lead.usuarioId,
nombreLead: lead.nombreLead,
telefono: lead.telefono,
telefonoNorm: lead.telefonoNorm,
email: lead.email,
origen: lead.origen,
desarrollo: lead.desarrollo,
@@ -374,6 +394,7 @@ export class CrmLocalService {
usuarioId: oportunidad.usuarioId,
nombreLead: oportunidad.nombreLead,
telefono: oportunidad.telefono,
telefonoNorm: oportunidad.telefonoNorm,
email: oportunidad.email,
origen: oportunidad.origen,
desarrollo: oportunidad.desarrollo,
@@ -480,6 +501,38 @@ 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
@@ -509,13 +562,16 @@ function conUnidadCodigo(datosJson: string | null, unidadCodigo?: string): strin
const ETAPAS_CERRADAS = ['Descartado', 'Perdido', 'Cancelo', 'Cancelacion Apartado']
/**
* Busca un contacto por teléfono (últimos 10 dígitos) 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.
* 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(diezDigitos: string) {
async function buscarPorTelefono(telefonoNormalizado: string) {
return prisma.leadCaptura.findFirst({
where: { telefono: { contains: diezDigitos }, etapa: { notIn: ETAPAS_CERRADAS } },
where: { telefonoNorm: telefonoNormalizado, etapa: { notIn: ETAPAS_CERRADAS } },
orderBy: { createdAt: 'desc' },
})
}