From db28af1c589c43f364c3bbf04d3175f866ddd572 Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Sat, 26 Sep 2026 21:19:31 +0000 Subject: [PATCH] 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) --- .../migration.sql | 20 ++++ api-gateway/prisma/schema.prisma | 5 + .../src/services/crm-local.service.test.ts | 102 +++++++++++++++- api-gateway/src/services/crm-local.service.ts | 110 +++++++++++++----- 4 files changed, 209 insertions(+), 28 deletions(-) create mode 100644 api-gateway/prisma/migrations/20260926120000_lead_telefono_norm/migration.sql diff --git a/api-gateway/prisma/migrations/20260926120000_lead_telefono_norm/migration.sql b/api-gateway/prisma/migrations/20260926120000_lead_telefono_norm/migration.sql new file mode 100644 index 0000000..4209246 --- /dev/null +++ b/api-gateway/prisma/migrations/20260926120000_lead_telefono_norm/migration.sql @@ -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"); diff --git a/api-gateway/prisma/schema.prisma b/api-gateway/prisma/schema.prisma index 06f7d0e..8392dcf 100644 --- a/api-gateway/prisma/schema.prisma +++ b/api-gateway/prisma/schema.prisma @@ -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 diff --git a/api-gateway/src/services/crm-local.service.test.ts b/api-gateway/src/services/crm-local.service.test.ts index 22377a5..380e670 100644 --- a/api-gateway/src/services/crm-local.service.test.ts +++ b/api-gateway/src/services/crm-local.service.test.ts @@ -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() + 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) + }) +}) diff --git a/api-gateway/src/services/crm-local.service.ts b/api-gateway/src/services/crm-local.service.ts index 423e6bb..595cfe4 100644 --- a/api-gateway/src/services/crm-local.service.ts +++ b/api-gateway/src/services/crm-local.service.ts @@ -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 { - // 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 = { 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>() + +function conLock(clave: string, fn: () => Promise): Promise { + 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' }, }) }