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:
@@ -0,0 +1,20 @@
|
||||
-- Dedup de leads/afluencias: teléfono normalizado (solo dígitos, últimos 10)
|
||||
-- en su propia columna indexada. La búsqueda anterior `telefono contains`
|
||||
-- fallaba contra teléfonos guardados con espacios ("+52 664 225 7841" no
|
||||
-- contiene "6642257841"), permitiendo duplicados.
|
||||
-- Patrón manual: sqlite3 + _prisma_migrations. NUNCA migrate dev.
|
||||
|
||||
ALTER TABLE "LeadCaptura" ADD COLUMN "telefonoNorm" TEXT;
|
||||
|
||||
-- Backfill: últimos 10 dígitos cuando hay al menos 10; si no, todos los
|
||||
-- dígitos (coincide con normalizaTelefono() del servicio).
|
||||
UPDATE "LeadCaptura"
|
||||
SET "telefonoNorm" = CASE
|
||||
WHEN length(replace(replace(replace(replace(replace(coalesce("telefono", ''), '+', ''), ' ', ''), '-', ''), '(', ''), ')', '')) >= 10
|
||||
THEN substr(replace(replace(replace(replace(replace("telefono", '+', ''), ' ', ''), '-', ''), '(', ''), ')', ''), -10)
|
||||
WHEN length(replace(replace(replace(replace(replace(coalesce("telefono", ''), '+', ''), ' ', ''), '-', ''), '(', ''), ')', '')) >= 7
|
||||
THEN replace(replace(replace(replace(replace("telefono", '+', ''), ' ', ''), '-', ''), '(', ''), ')', '')
|
||||
ELSE NULL
|
||||
END;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "LeadCaptura_telefonoNorm_idx" ON "LeadCaptura"("telefonoNorm");
|
||||
@@ -173,6 +173,10 @@ model LeadCaptura {
|
||||
usuarioId String // Usuario.id del capturista
|
||||
nombreLead String
|
||||
telefono String?
|
||||
// Teléfono normalizado (solo dígitos, últimos 10) para el dedup de
|
||||
// capturas: la búsqueda por `telefono contains` fallaba con formatos
|
||||
// con espacios. Se mantiene en cada escritura vía normalizaTelefono().
|
||||
telefonoNorm String?
|
||||
// Board del embudo donde vive el registro: LEADS | OPORTUNIDADES | APARTADO
|
||||
board String @default("LEADS")
|
||||
// Etapa/estado local actual (NUEVO al capturar)
|
||||
@@ -198,6 +202,7 @@ model LeadCaptura {
|
||||
@@index([usuarioId])
|
||||
@@index([mondayItemId])
|
||||
@@index([nombreLead])
|
||||
@@index([telefonoNorm])
|
||||
}
|
||||
|
||||
// Espejo local del inventario (tablero Unidades de Monday). Lo siembra
|
||||
|
||||
@@ -360,7 +360,7 @@ describe('CrmLocalService.crearLead — dedup con etapas cerradas', () => {
|
||||
await svc.crearLead({ nombre: 'Reintento Mail', telefono: '', email: 'reintento@correo.com' }, 'u-1')
|
||||
expect(prismaMock.leadCaptura.findFirst).toHaveBeenLastCalledWith({
|
||||
where: {
|
||||
email: 'reintento@correo.com',
|
||||
OR: [{ email: 'reintento@correo.com' }],
|
||||
etapa: { notIn: ['Descartado', 'Perdido', 'Cancelo', 'Cancelacion Apartado'] },
|
||||
},
|
||||
})
|
||||
@@ -395,3 +395,103 @@ describe('CrmLocalService.buscarLocales — privacidad por vendedor', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizaTelefono', () => {
|
||||
it('quita formato y toma los últimos 10 dígitos', async () => {
|
||||
const { normalizaTelefono } = await import('./crm-local.service.js')
|
||||
expect(normalizaTelefono('+52 664 225 7841')).toBe('6642257841')
|
||||
expect(normalizaTelefono('+526198790165')).toBe('6198790165')
|
||||
expect(normalizaTelefono('+52 52 664 225 7841')).toBe('6642257841') // lada duplicada
|
||||
expect(normalizaTelefono('4771234567')).toBe('4771234567')
|
||||
})
|
||||
|
||||
it('conserva 7-9 dígitos; rechaza menos de 7 y vacíos', async () => {
|
||||
const { normalizaTelefono } = await import('./crm-local.service.js')
|
||||
expect(normalizaTelefono('12345678')).toBe('12345678')
|
||||
expect(normalizaTelefono('123456')).toBeNull()
|
||||
expect(normalizaTelefono('')).toBeNull()
|
||||
expect(normalizaTelefono(null)).toBeNull()
|
||||
expect(normalizaTelefono(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CrmLocalService dedup normalizado (anti-duplicados)', () => {
|
||||
it('crearLead guarda telefonoNorm aunque el teléfono venga con espacios', async () => {
|
||||
const r = await svc.crearLead({ nombre: 'Guadalupe Avila', telefono: '+52 664 225 7841' }, 'u-1')
|
||||
expect('duplicado' in r).toBe(false)
|
||||
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ telefono: '+52 664 225 7841', telefonoNorm: '6642257841' }),
|
||||
})
|
||||
})
|
||||
|
||||
it('el dedup busca por telefonoNorm exacto (no por contains con espacios)', async () => {
|
||||
prismaMock.leadCaptura.findFirst.mockResolvedValue({ leadId: 'loc_viejo', nombreLead: 'Guadalupe' })
|
||||
const r = await svc.crearLead({ nombre: 'Guadalupe', telefono: '664 225 7841' })
|
||||
expect(r).toEqual({ duplicado: { id: 'loc_viejo', nombre: 'Guadalupe' } })
|
||||
expect(prismaMock.leadCaptura.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ telefonoNorm: '6642257841' }),
|
||||
}),
|
||||
)
|
||||
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('crearAfluencia guarda telefonoNorm y dedup por teléfono normalizado', async () => {
|
||||
const r = await svc.crearAfluencia({ nombre: 'Luis Demo', telefono: '+52 (664) 111-2233' }, 'u-2')
|
||||
expect('duplicado' in r).toBe(false)
|
||||
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ telefonoNorm: '6641112233' }),
|
||||
})
|
||||
})
|
||||
|
||||
it('email se guarda en minúsculas y el dedup lo busca también en minúsculas', async () => {
|
||||
prismaMock.leadCaptura.findFirst.mockResolvedValue(null)
|
||||
const r = await svc.crearLead({ nombre: 'Ana Mail', email: 'Ana@Correo.COM' }, 'u-1')
|
||||
expect('duplicado' in r).toBe(false)
|
||||
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ email: 'ana@correo.com' }),
|
||||
})
|
||||
})
|
||||
|
||||
it('convertirLead propaga telefonoNorm a la oportunidad nueva', async () => {
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue({
|
||||
leadId: 'loc_lead',
|
||||
oportunidadId: null,
|
||||
usuarioId: 'u-1',
|
||||
nombreLead: 'Ana Propaga',
|
||||
telefono: '+52 664 225 7841',
|
||||
telefonoNorm: '6642257841',
|
||||
email: null,
|
||||
origen: null,
|
||||
desarrollo: null,
|
||||
modelo: null,
|
||||
datosJson: null,
|
||||
})
|
||||
await svc.convertirLead('loc_lead')
|
||||
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ board: 'OPORTUNIDADES', telefonoNorm: '6642257841' }),
|
||||
})
|
||||
})
|
||||
|
||||
it('dos capturas simultáneas del mismo teléfono: sólo una crea (lock anti-carrera)', async () => {
|
||||
// findFirst devuelve null la primera vez y "encuentra" la fila ya creada
|
||||
// a partir de la segunda: sin lock, la carrera crearía dos filas.
|
||||
const tienda = new Map<string, { leadId: string; nombreLead: string }>()
|
||||
prismaMock.leadCaptura.findFirst.mockImplementation(async ({ where }) => {
|
||||
const tel = where?.telefonoNorm as string | undefined
|
||||
return tel ? (tienda.get(tel) ?? null) : null
|
||||
})
|
||||
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => {
|
||||
tienda.set(data.telefonoNorm, { leadId: data.leadId, nombreLead: data.nombreLead })
|
||||
return { id: 'row', ...data }
|
||||
})
|
||||
const [r1, r2] = await Promise.all([
|
||||
svc.crearLead({ nombre: 'Doble Click', telefono: '6641112233' }, 'u-1'),
|
||||
svc.crearLead({ nombre: 'Doble Click', telefono: '6641112233' }, 'u-1'),
|
||||
])
|
||||
const duplicados = [r1, r2].filter((r) => 'duplicado' in r).length
|
||||
const creados = [r1, r2].filter((r) => !('duplicado' in r)).length
|
||||
expect(creados).toBe(1)
|
||||
expect(duplicados).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user