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',
|
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' } },
|
||||||
|
]),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 ?? '',
|
||||||
|
|||||||
Reference in New Issue
Block a user