fix(busqueda): telefono por digitos normalizados (espacios/ladas/invisibles) y nombres sin acento

This commit is contained in:
2026-09-29 19:08:51 +00:00
parent 2533a05aab
commit 9bdd666626
2 changed files with 176 additions and 19 deletions

View File

@@ -328,12 +328,104 @@ describe('CrmLocalService.buscarLocales', () => {
modeloInteres: 'SOHO', modeloInteres: 'SOHO',
}) })
expect(r[1]).toMatchObject({ id: 'cli-9', nombre: 'Ana Cliente', fuente: 'oportunidad', desarrollo: 'ATELIER' }) expect(r[1]).toMatchObject({ id: 'cli-9', nombre: 'Ana Cliente', fuente: 'oportunidad', desarrollo: 'ATELIER' })
// La búsqueda por nombre incluye variantes con/sin acento: 'ana' también
// consulta 'ána'/'aná' además del término literal
const whereLead = prismaMock.leadCaptura.findMany.mock.calls[0][0]['where']
expect(whereLead['AND'][1]['OR']).toEqual(
expect.arrayContaining([
{ nombreLead: { contains: 'ana' } },
{ telefono: { contains: 'ana' } },
]),
)
})
it('buscar nombre sin acento encuentra el registro con tilde (jazmin -> Jazmin)', async () => {
prismaMock.leadCaptura.findMany.mockResolvedValue([
{
leadId: 'loc_ac',
nombreLead: 'Jazmín Estiven',
telefono: '6641112233',
telefonoNorm: '6641112233',
email: null,
etapa: 'NUEVO',
origen: null,
board: 'LEADS',
desarrollo: null,
modelo: null,
updatedAt: new Date('2026-09-29T10:00:00Z'),
},
])
prismaMock.cliente.findMany.mockResolvedValue([])
const r = await svc.buscarLocales('jazmin')
expect(r.map((x) => x.id)).toContain('loc_ac')
const whereLead = prismaMock.leadCaptura.findMany.mock.calls[0][0]['where']
expect(whereLead['AND'][1]['OR']).toEqual(
expect.arrayContaining([
{ nombreLead: { contains: 'jazmin' } },
{ nombreLead: { contains: 'jazmín' } },
]),
)
})
it('encuentra por telefono aunque el registro guarde espacios/ladas duplicadas', async () => {
prismaMock.leadCaptura.findMany.mockResolvedValue([
{
leadId: 'loc_tel',
nombreLead: 'Jazmin Estiven',
telefono: '+52 +52 664 782 5023',
telefonoNorm: '6647825023',
email: null,
etapa: 'Intento de contacto',
origen: 'PROSPECCION ASESOR',
board: 'LEADS',
desarrollo: 'DIAGONAL',
modelo: null,
updatedAt: new Date('2026-09-29T10:00:00Z'),
},
])
prismaMock.cliente.findMany.mockResolvedValue([])
const r = await svc.buscarLocales('6647825023')
expect(r).toHaveLength(1)
expect(r[0]).toMatchObject({ id: 'loc_tel', nombre: 'Jazmin Estiven' })
// El match telefonico va contra telefonoNorm (digitos), no el telefono crudo
expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith( expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
where: { AND: [{}, { OR: [{ nombreLead: { contains: 'ana' } }, { telefono: { contains: 'ana' } }] }] }, where: expect.objectContaining({
AND: [
{},
{
OR: [
{ nombreLead: { contains: '6647825023' } },
{ telefono: { contains: '6647825023' } },
{ telefonoNorm: { contains: '6647825023' } },
],
},
],
}),
}), }),
) )
}) })
it('buscar con lada completa (+52 ...) encuentra el registro sin lada', async () => {
prismaMock.leadCaptura.findMany.mockResolvedValue([
{
leadId: 'loc_tel2',
nombreLead: 'Jazmin Estiven',
telefono: '664 782 5023',
telefonoNorm: '6647825023',
email: null,
etapa: 'Intento de contacto',
origen: null,
board: 'LEADS',
desarrollo: null,
modelo: null,
updatedAt: new Date('2026-09-29T10:00:00Z'),
},
])
prismaMock.cliente.findMany.mockResolvedValue([])
const r = await svc.buscarLocales('+52 664 782 5023')
expect(r.map((x) => x.id)).toContain('loc_tel2')
})
}) })
@@ -372,15 +464,21 @@ describe('CrmLocalService.buscarLocales — privacidad por vendedor', () => {
prismaMock.leadCaptura.findMany.mockResolvedValue([]) prismaMock.leadCaptura.findMany.mockResolvedValue([])
prismaMock.cliente.findMany.mockResolvedValue([]) prismaMock.cliente.findMany.mockResolvedValue([])
await svc.buscarLocales('ana', { usuarioId: 'u-9', soloPropio: true }) await svc.buscarLocales('ana', { usuarioId: 'u-9', soloPropio: true })
expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith( const wherePrivado = prismaMock.leadCaptura.findMany.mock.calls[0][0]['where']
expect.objectContaining({ expect(wherePrivado['AND'][0]).toEqual({ usuarioId: 'u-9' })
where: { AND: [{ usuarioId: 'u-9' }, { OR: [{ nombreLead: { contains: 'ana' } }, { telefono: { contains: 'ana' } }] }] }, expect(wherePrivado['AND'][1]['OR']).toEqual(
}), expect.arrayContaining([
{ nombreLead: { contains: 'ana' } },
{ telefono: { contains: 'ana' } },
]),
) )
expect(prismaMock.cliente.findMany).toHaveBeenCalledWith( const whereCliente = prismaMock.cliente.findMany.mock.calls[0][0]['where']
expect.objectContaining({ expect(whereCliente['AND'][0]).toEqual({ capturadoPorId: 'u-9' })
where: { AND: [{ capturadoPorId: 'u-9' }, { OR: [{ nombreCompleto: { contains: 'ana' } }, { celular: { contains: 'ana' } }] }] }, expect(whereCliente['AND'][1]['OR']).toEqual(
}), expect.arrayContaining([
{ nombreCompleto: { contains: 'ana' } },
{ celular: { contains: 'ana' } },
]),
) )
}) })
@@ -388,10 +486,13 @@ describe('CrmLocalService.buscarLocales — privacidad por vendedor', () => {
prismaMock.leadCaptura.findMany.mockResolvedValue([]) prismaMock.leadCaptura.findMany.mockResolvedValue([])
prismaMock.cliente.findMany.mockResolvedValue([]) prismaMock.cliente.findMany.mockResolvedValue([])
await svc.buscarLocales('ana') await svc.buscarLocales('ana')
expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith( const whereGlobal = prismaMock.leadCaptura.findMany.mock.calls[0][0]['where']
expect.objectContaining({ expect(whereGlobal['AND'][0]).toEqual({})
where: { AND: [{}, { OR: [{ nombreLead: { contains: 'ana' } }, { telefono: { contains: 'ana' } }] }] }, expect(whereGlobal['AND'][1]['OR']).toEqual(
}), expect.arrayContaining([
{ nombreLead: { contains: 'ana' } },
{ telefono: { contains: 'ana' } },
]),
) )
}) })
}) })

View File

@@ -435,18 +435,72 @@ export class CrmLocalService {
): Promise<ResultadoBusquedaLocal[]> { ): Promise<ResultadoBusquedaLocal[]> {
const propioLead = alcance?.soloPropio ? { usuarioId: alcance.usuarioId } : {} const propioLead = alcance?.soloPropio ? { usuarioId: alcance.usuarioId } : {}
const propioCliente = alcance?.soloPropio ? { capturadoPorId: 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({ const capturas = await prisma.leadCaptura.findMany({
where: { AND: [propioLead, { OR: [{ nombreLead: { contains: termino } }, { telefono: { contains: termino } }] }] }, where: { AND: [propioLead, { OR: [...condNombreLead, ...condTelefonoLead] }] },
orderBy: { updatedAt: 'desc' }, orderBy: { updatedAt: 'desc' },
take: 15, take: 50,
}) })
const clientes = await prisma.cliente.findMany({ const clientes = await prisma.cliente.findMany({
where: { AND: [propioCliente, { OR: [{ nombreCompleto: { contains: termino } }, { celular: { contains: termino } }] }] }, where: { AND: [propioCliente, { OR: [...condNombreCliente, { celular: { contains: termino } }, ...condTelefonoCliente] }] },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 15, 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 = [ const candidatos = [
...capturas.map((c) => ({ ...capturas
.filter((c) => !pareceTelefono || coincideTelefono(c.telefono) || c.nombreLead.includes(termino))
.map((c) => ({
id: c.leadId, id: c.leadId,
nombre: c.nombreLead, nombre: c.nombreLead,
telefono: c.telefono ?? '', telefono: c.telefono ?? '',
@@ -458,7 +512,9 @@ export class CrmLocalService {
modeloInteres: c.modelo ?? '', modeloInteres: c.modelo ?? '',
orden: c.updatedAt, orden: c.updatedAt,
})), })),
...clientes.map((c) => ({ ...clientes
.filter((c) => !pareceTelefono || coincideTelefono(c.celular) || c.nombreCompleto.includes(termino))
.map((c) => ({
id: c.id, id: c.id,
nombre: c.nombreCompleto, nombre: c.nombreCompleto,
telefono: c.celular ?? '', telefono: c.celular ?? '',