fix(busqueda): telefono por digitos normalizados (espacios/ladas/invisibles) y nombres sin acento
This commit is contained in:
@@ -328,12 +328,104 @@ describe('CrmLocalService.buscarLocales', () => {
|
||||
modeloInteres: 'SOHO',
|
||||
})
|
||||
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.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.cliente.findMany.mockResolvedValue([])
|
||||
await svc.buscarLocales('ana', { usuarioId: 'u-9', soloPropio: true })
|
||||
expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { AND: [{ usuarioId: 'u-9' }, { OR: [{ nombreLead: { contains: 'ana' } }, { telefono: { contains: 'ana' } }] }] },
|
||||
}),
|
||||
const wherePrivado = prismaMock.leadCaptura.findMany.mock.calls[0][0]['where']
|
||||
expect(wherePrivado['AND'][0]).toEqual({ usuarioId: 'u-9' })
|
||||
expect(wherePrivado['AND'][1]['OR']).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ nombreLead: { contains: 'ana' } },
|
||||
{ telefono: { contains: 'ana' } },
|
||||
]),
|
||||
)
|
||||
expect(prismaMock.cliente.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { AND: [{ capturadoPorId: 'u-9' }, { OR: [{ nombreCompleto: { contains: 'ana' } }, { celular: { contains: 'ana' } }] }] },
|
||||
}),
|
||||
const whereCliente = prismaMock.cliente.findMany.mock.calls[0][0]['where']
|
||||
expect(whereCliente['AND'][0]).toEqual({ capturadoPorId: 'u-9' })
|
||||
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.cliente.findMany.mockResolvedValue([])
|
||||
await svc.buscarLocales('ana')
|
||||
expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { AND: [{}, { OR: [{ nombreLead: { contains: 'ana' } }, { telefono: { contains: 'ana' } }] }] },
|
||||
}),
|
||||
const whereGlobal = prismaMock.leadCaptura.findMany.mock.calls[0][0]['where']
|
||||
expect(whereGlobal['AND'][0]).toEqual({})
|
||||
expect(whereGlobal['AND'][1]['OR']).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ nombreLead: { contains: 'ana' } },
|
||||
{ telefono: { contains: 'ana' } },
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -435,18 +435,72 @@ export class CrmLocalService {
|
||||
): Promise<ResultadoBusquedaLocal[]> {
|
||||
const propioLead = alcance?.soloPropio ? { usuarioId: 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({
|
||||
where: { AND: [propioLead, { OR: [{ nombreLead: { contains: termino } }, { telefono: { contains: termino } }] }] },
|
||||
where: { AND: [propioLead, { OR: [...condNombreLead, ...condTelefonoLead] }] },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 15,
|
||||
take: 50,
|
||||
})
|
||||
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' },
|
||||
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 = [
|
||||
...capturas.map((c) => ({
|
||||
...capturas
|
||||
.filter((c) => !pareceTelefono || coincideTelefono(c.telefono) || c.nombreLead.includes(termino))
|
||||
.map((c) => ({
|
||||
id: c.leadId,
|
||||
nombre: c.nombreLead,
|
||||
telefono: c.telefono ?? '',
|
||||
@@ -458,7 +512,9 @@ export class CrmLocalService {
|
||||
modeloInteres: c.modelo ?? '',
|
||||
orden: c.updatedAt,
|
||||
})),
|
||||
...clientes.map((c) => ({
|
||||
...clientes
|
||||
.filter((c) => !pareceTelefono || coincideTelefono(c.celular) || c.nombreCompleto.includes(termino))
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
nombre: c.nombreCompleto,
|
||||
telefono: c.celular ?? '',
|
||||
|
||||
Reference in New Issue
Block a user