From 9bdd666626f4eb101e1f434d2c85872b829108aa Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Tue, 29 Sep 2026 19:08:51 +0000 Subject: [PATCH] fix(busqueda): telefono por digitos normalizados (espacios/ladas/invisibles) y nombres sin acento --- .../src/services/crm-local.service.test.ts | 127 ++++++++++++++++-- api-gateway/src/services/crm-local.service.ts | 68 +++++++++- 2 files changed, 176 insertions(+), 19 deletions(-) diff --git a/api-gateway/src/services/crm-local.service.test.ts b/api-gateway/src/services/crm-local.service.test.ts index 380e670..b32b9ad 100644 --- a/api-gateway/src/services/crm-local.service.test.ts +++ b/api-gateway/src/services/crm-local.service.test.ts @@ -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' } }, + ]), ) }) }) diff --git a/api-gateway/src/services/crm-local.service.ts b/api-gateway/src/services/crm-local.service.ts index 595cfe4..97ef7d7 100644 --- a/api-gateway/src/services/crm-local.service.ts +++ b/api-gateway/src/services/crm-local.service.ts @@ -435,18 +435,72 @@ export class CrmLocalService { ): Promise { 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 = { + á: '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 = { + 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([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 ?? '',