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

@@ -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)
})
})