From 656116c4e57f561420b750e30651b33727b4caf9 Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Fri, 18 Sep 2026 16:05:18 +0000 Subject: [PATCH] fix(auditoria): afluencia (validacion, update sin perder datos ni retroceder etapa, dedup cruzado, notas), alta cliente (registro tras firma, documentos por cliente, aviso de fallos) y reporte de clientes aplanado --- .../src/controllers/leads.controller.ts | 15 ++-- .../controllers/reportes-datos.controller.ts | 49 ++++++++++-- .../src/services/automatizaciones.service.ts | 4 +- api-gateway/src/services/crm-local.service.ts | 78 +++++++++++++++---- .../src/components/AltaAfluenciaForm.test.tsx | 14 ++-- .../src/components/AltaAfluenciaForm.tsx | 49 ++++++++++-- apps/alta-clientes/src/forms/FirmasForm.tsx | 27 ++++++- .../alta-clientes/src/hooks/useAltaCliente.ts | 5 +- apps/alta-clientes/src/hooks/useDocumentos.ts | 5 +- .../pages/AltaClientePage.progreso.test.tsx | 18 ++++- .../src/pages/AltaClientePage.tsx | 48 +++++++++++- apps/alta-clientes/src/services/api.test.ts | 12 +-- apps/alta-clientes/src/services/api.ts | 30 ++++++- 13 files changed, 295 insertions(+), 59 deletions(-) diff --git a/api-gateway/src/controllers/leads.controller.ts b/api-gateway/src/controllers/leads.controller.ts index 7eff955..c81f41b 100644 --- a/api-gateway/src/controllers/leads.controller.ts +++ b/api-gateway/src/controllers/leads.controller.ts @@ -202,14 +202,8 @@ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promi }) return } - // Actualización de oportunidad existente (botón "Afluencia" de Mi día): no - // hay captura nueva que registrar - if (resultado.actualizada === true) { - res.json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, actualizada: true }) - return - } - // La nota del alta de afluencia también vive en la bitácora (con clave de - // cliente para que siga al contacto en las fases siguientes). + // La nota del alta/actualización de afluencia vive en la bitácora (con clave + // de cliente para que siga al contacto en las fases siguientes). if (body.notas !== undefined && body.notas.trim() !== '') { await recordatorioVendedorService.crear(usuarioSub ?? 'sistema', { titulo: body.notas, @@ -218,6 +212,11 @@ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promi fechaRecordar: new Date().toISOString(), }) } + // Actualización de oportunidad existente (botón "Afluencia" de Mi día) + if (resultado.actualizada === true) { + res.json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, actualizada: true }) + return + } res.status(201).json({ id: resultado.id, estado: 'Afluencia', diff --git a/api-gateway/src/controllers/reportes-datos.controller.ts b/api-gateway/src/controllers/reportes-datos.controller.ts index 4e3a857..d4005be 100644 --- a/api-gateway/src/controllers/reportes-datos.controller.ts +++ b/api-gateway/src/controllers/reportes-datos.controller.ts @@ -51,6 +51,42 @@ const campoDesarrollo = (query: Record): string | undefined => const ETAPAS_CANCELACION = ['Descartado', 'Perdido', 'Cancelacion Apartado', 'Cancelo'] const FASES_ESCRITURACION: readonly string[] = VOCABULARIO.FASES_ESCRITURACION +/** + * Aplana el payload del alta de cliente, que viene anidado por secciones + * ({ datosGenerales, domicilio, empleo, economicos, coacreditado, … }) a un + * mapa plano de campos (usuarioEnkontrol, actividadOcupacion, compania, …). + */ +function perfilDeCliente(payloadJson: string | null): Record { + if (!payloadJson) return {} + try { + const datos = JSON.parse(payloadJson) as Record + const plano: Record = {} + // Secciones que NO se aplanan: el co-acreditado repite los mismos campos + // (compania, ingresosMensuales, estadoCivil…) y, al venir después, vacíos, + // sobrescribía los datos reales del cliente. Documentos/firma/foto tampoco + // aportan datos de perfil. + const SECCIONES_IGNORADAS = new Set(['coacreditado', 'documentos', 'firma', 'foto', 'referencias']) + const recorrer = (obj: Record, raiz: boolean): void => { + for (const [k, v] of Object.entries(obj)) { + if (v == null) continue + if (typeof v === 'object' && !Array.isArray(v)) { + if (raiz && SECCIONES_IGNORADAS.has(k)) continue + recorrer(v as Record, false) + continue + } + if (typeof v === 'object') continue + const texto = String(v) + if (texto.trim() === '') continue + if (plano[k] === undefined) plano[k] = texto // gana el primer valor no vacío + } + } + recorrer(datos, true) + return plano + } catch { + return {} + } +} + function perfilDeDatos(datosJson: string | null): Record { if (!datosJson) return {} try { @@ -286,7 +322,9 @@ export async function reporteClientes(_req: Request, res: Response): Promise [u.id, u.nombre])) res.json({ filas: clientes.map((c) => { - const perfil = perfilDeDatos(c.payloadJson) + // El alta guarda el payload ANIDADO por secciones (datosGenerales, + // empleo, economicos…) — hay que aplanarlo para leer sus campos. + const perfil = perfilDeCliente(c.payloadJson) return { nombre: c.nombreCompleto, curp: c.curp, @@ -295,13 +333,14 @@ export async function reporteClientes(_req: Request, res: Response): Promise { - // Dedup por teléfono (normalizando: últimos 10 dígitos) + // Dedup por teléfono (normalizado a los últimos 10 dígitos) en CUALQUIER + // tablero: evita que la misma persona quede como lead y como afluencia. if (datos.telefono !== undefined && datos.telefono.trim() !== '') { - const telLimpio = datos.telefono.replace(/\s+/g, '').slice(-10) + const telLimpio = soloDigitos(datos.telefono).slice(-10) if (telLimpio.length >= 7) { - const existente = await prisma.leadCaptura.findFirst({ - where: { board: 'LEADS', telefono: { contains: telLimpio } }, - }) + const existente = await buscarPorTelefono(telLimpio) if (existente) return { duplicado: { id: existente.leadId, nombre: existente.nombreLead } } } } @@ -124,30 +123,65 @@ export class CrmLocalService { if (oportunidadId !== undefined) { const existente = await prisma.leadCaptura.findUnique({ where: { leadId: oportunidadId } }) - if (existente) { - await prisma.leadCaptura.update({ - where: { leadId: oportunidadId }, + // Sin fila local (id heredado del CRM anterior): se crea la sombra con + // ese id para que la captura no se pierda en silencio. + if (!existente) { + await prisma.leadCaptura.create({ data: { + leadId: oportunidadId, + oportunidadId, + usuarioId: usuarioId ?? 'desconocido', nombreLead: datos.nombre, telefono: datos.telefono ?? null, email: datos.email ?? null, origen: datos.origen ?? null, desarrollo: datos.desarrollo ?? null, modelo: modelo ?? null, + board: 'OPORTUNIDADES', etapa: 'Afluencia', datosJson: JSON.stringify(datos), pendienteSync: true, }, }) + return { id: oportunidadId, oportunidadId, actualizada: true } } + + // Sólo se pisan los campos capturados: lo que no viene en el payload se + // conserva (antes se borraba con null y se perdía la cita agendada). + const cambio: Record = { pendienteSync: true } + if (datos.nombre.trim() !== '') cambio['nombreLead'] = datos.nombre + if (datos.telefono !== undefined) cambio['telefono'] = datos.telefono + if (datos.email !== undefined) cambio['email'] = datos.email + if (datos.origen !== undefined) cambio['origen'] = datos.origen + if (datos.desarrollo !== undefined) cambio['desarrollo'] = datos.desarrollo + if (modelo !== undefined) cambio['modelo'] = modelo + + // La etapa NO retrocede: sólo se marca Afluencia si aún no había avanzado + const etapaActual = existente.etapa.trim() + const etapasIniciales = ['', 'NUEVO', 'Afluencia'] + const etapaFinal = etapasIniciales.includes(etapaActual) ? 'Afluencia' : existente.etapa + if (etapaFinal !== existente.etapa) cambio['etapa'] = etapaFinal + + // datosJson se MEZCLA: preserva fechaCita y los campos no reenviados + let previos: Record = {} + try { + previos = existente.datosJson ? (JSON.parse(existente.datosJson) as Record) : {} + } catch { + previos = {} + } + const nuevos = Object.fromEntries(Object.entries(datos).filter(([, v]) => v !== undefined && v !== null)) + cambio['datosJson'] = JSON.stringify({ ...previos, ...nuevos }) + + await prisma.leadCaptura.update({ where: { leadId: oportunidadId }, data: cambio }) return { id: oportunidadId, oportunidadId, actualizada: true } } - if (datos.telefono !== undefined) { - const duplicado = await prisma.leadCaptura.findFirst({ - where: { telefono: datos.telefono, board: 'OPORTUNIDADES' }, - }) - if (duplicado) return { duplicado: { id: duplicado.leadId, nombre: duplicado.nombreLead } } + if (datos.telefono !== undefined && datos.telefono.trim() !== '') { + const telLimpio = soloDigitos(datos.telefono).slice(-10) + if (telLimpio.length >= 7) { + const duplicado = await buscarPorTelefono(telLimpio) + if (duplicado) return { duplicado: { id: duplicado.leadId, nombre: duplicado.nombreLead } } + } } const id = idLocal() @@ -411,4 +445,20 @@ export class CrmLocalService { } /** Singleton usado por los controllers (los tests instancian el suyo o mockean prisma) */ -export const crmLocal = new CrmLocalService() +export const crmLocal = new CrmLocalService()/** Sólo dígitos de un teléfono (quita +52, espacios, guiones, paréntesis) */ +function soloDigitos(telefono: string): string { + return telefono.replace(/\D/g, '') +} + +/** + * Busca un contacto por teléfono (últimos 10 dígitos) en CUALQUIER tablero. + * Así el mismo número no se duplica entre Leads, Oportunidades y Apartados. + */ +async function buscarPorTelefono(diezDigitos: string) { + return prisma.leadCaptura.findFirst({ + where: { telefono: { contains: diezDigitos } }, + orderBy: { createdAt: 'desc' }, + }) +} + + diff --git a/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx b/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx index 7047593..2e854de 100644 --- a/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx +++ b/apps/alta-clientes/src/components/AltaAfluenciaForm.test.tsx @@ -120,32 +120,32 @@ describe('AltaAfluenciaForm', () => { llenarRequeridos() fireEvent.change(screen.getByLabelText(/Desarrollo de interés/), { target: { value: 'SENDA' } }) fireEvent.change(screen.getByLabelText(/Departamento o casa de interés/), { target: { value: 'HIMALAYA' } }) - fireEvent.change(screen.getByLabelText(/Medio de contacto/), { target: { value: 'Llamada' } }) + fireEvent.change(screen.getByLabelText(/Número de dependientes/), { target: { value: '3' } }) fireEvent.change(screen.getByLabelText(/^Edad/), { target: { value: '35' } }) fireEvent.change(screen.getByLabelText(/^Estado civil/), { target: { value: 'Casado(a)' } }) fireEvent.change(screen.getByLabelText(/Tipo de empleo/), { target: { value: 'Asalariado' } }) fireEvent.change(screen.getByLabelText(/^Ocupación/), { target: { value: 'Ingeniero' } }) - fireEvent.change(screen.getByLabelText(/Rango salarial/), { target: { value: '$20,000 - $30,000' } }) + fireEvent.change(screen.getByLabelText(/Rango salarial/), { target: { value: 'F-25001.00-30000.00' } }) fireEvent.change(screen.getByLabelText(/^Crédito/), { target: { value: 'Infonavit' } }) fireEvent.change(screen.getByLabelText(/Tipo de economía/), { target: { value: 'Economía mexicana' } }) fireEvent.change(screen.getByLabelText(/^Califica/), { target: { value: 'Sí' } }) - fireEvent.change(screen.getByLabelText(/Lo quiere para/), { target: { value: 'Vivir ahí' } }) + fireEvent.change(screen.getByLabelText(/Lo quiere para/), { target: { value: 'Para vivir' } }) fireEvent.click(screen.getByRole('button', { name: /Registrar en afluencia/ })) await screen.findByRole('status') const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] expect(JSON.parse(String(init.body))).toMatchObject({ modelo: 'HIMALAYA', - medioContacto: 'Llamada', + numDependientes: '3', edad: '35', estadoCivil: 'Casado(a)', tipoEmpleo: 'Asalariado', ocupacion: 'Ingeniero', - rangoSalarial: '$20,000 - $30,000', + rangoSalarial: 'F-25001.00-30000.00', credito: 'Infonavit', tipoEconomia: 'Economía mexicana', califica: 'Sí', - loQuierePara: 'Vivir ahí', + loQuierePara: 'Para vivir', }) }) @@ -170,7 +170,7 @@ describe('AltaAfluenciaForm', () => { fireEvent.click(screen.getByRole('button', { name: /Registrar en afluencia/ })) expect((await screen.findByRole('alert')).textContent).toContain( - 'Ya existe un lead con ese teléfono: ANA DEMO.', + 'Ya existe un registro con ese teléfono: ANA DEMO.', ) }) diff --git a/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx b/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx index 8509937..6de5116 100644 --- a/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx +++ b/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx @@ -33,6 +33,7 @@ const labelClase = 'mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CB interface Errores { nombre?: string + nombrePila?: string telefono?: string email?: string } @@ -65,8 +66,33 @@ const FORM_INICIAL = { * lead y su oportunidad en etapa Afluencia. El alta completa de * cliente sigue siendo el wizard del picker. */ +/** + * Reparte un nombre completo en nombre(s) + apellido paterno + materno para + * prellenar los tres campos del formulario (el nombre viene de un solo campo + * en el lead/oportunidad de origen). + */ +function repartirNombre(nombre: string): { nombrePila: string; apellidoPaterno: string; apellidoMaterno: string } { + const partes = nombre.trim().split(/\s+/).filter(Boolean) + if (partes.length === 0) return { nombrePila: '', apellidoPaterno: '', apellidoMaterno: '' } + if (partes.length === 1) return { nombrePila: partes[0]!, apellidoPaterno: '', apellidoMaterno: '' } + if (partes.length === 2) return { nombrePila: partes[0]!, apellidoPaterno: partes[1]!, apellidoMaterno: '' } + return { + nombrePila: partes[0]!, + apellidoPaterno: partes[1]!, + apellidoMaterno: partes.slice(2).join(' '), + } +} + export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valoresIniciales, oportunidadId }: Props) { - const [form, setForm] = useState({ ...FORM_INICIAL, ...valoresIniciales }) + const [form, setForm] = useState(() => { + const base = { ...FORM_INICIAL, ...valoresIniciales } + // Si el origen trae el nombre completo en un solo campo, se reparte en + // nombre(s) + apellidos para que los tres inputs se vean llenos. + if (base.nombrePila.trim() === '' && base.nombre.trim() !== '') { + return { ...base, ...repartirNombre(base.nombre) } + } + return base + }) const [lada, setLada] = useState<'MX' | 'US'>( (valoresIniciales?.telefono ?? '').trim().startsWith('+1') ? 'US' : 'MX', ) @@ -142,7 +168,10 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor const validar = (): boolean => { const nuevos: Errores = {} - if (!form.nombre.trim()) nuevos.nombre = 'El nombre es obligatorio' + // El nombre capturado se compone de nombre(s) + apellidos (el campo `nombre` + // sólo se usa cuando viene prellenado de un lead existente) + const nombreCompuesto = nombreCompleto() + if (nombreCompuesto === '') nuevos.nombrePila = 'El nombre es obligatorio' if (!form.telefono.trim()) nuevos.telefono = 'El teléfono es obligatorio' if (form.email.trim() && !emailSchema.safeParse(form.email).success) { nuevos.email = 'Correo electrónico inválido' @@ -184,6 +213,11 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor setAviso('La afluencia se actualizó, pero hubo un problema de conexión. Verifica en Mi día.') } + /** Nombre completo capturado (nombre(s) + apellidos), con respaldo en `nombre` */ + const nombreCompleto = () => + [form.nombrePila, form.apellidoPaterno, form.apellidoMaterno].filter(Boolean).join(' ').trim() || + form.nombre.trim() + /** Teléfono con lada: " " (si ya trae +, se respeta tal cual) */ const telefonoConLada = () => { const numeroTel = form.telefono.trim() @@ -223,7 +257,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor if (res.ok) { setLeadCreado({ id: res.lead.id, - nombre: [form.nombrePila, form.apellidoPaterno, form.apellidoMaterno].filter(Boolean).join(' ').trim() || form.nombre.trim(), + nombre: nombreCompleto(), telefono: telefonoConLada(), email: form.email.trim(), estado: res.lead.estado, @@ -239,6 +273,11 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor setAviso(`Ya existe un registro con ese teléfono: ${res.nombre}.`) return } + if (res.motivo === 'validacion') { + setAvisoTipo('error') + setAviso(`El servidor rechazó la captura: ${res.detalle ?? 'revisa los datos.'}`) + return + } if (oportunidadIdElegido !== undefined) { // El backend es local: la actualización YA se guardó aunque el fetch se cortara setAvisoTipo('advertencia') @@ -340,9 +379,9 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor type="text" value={form.nombrePila} onChange={cambiar('nombrePila')} - className={`${inputClase} ${errores.nombre ? 'border-red-400' : ''}`} + className={`${inputClase} ${errores.nombrePila ? 'border-red-400' : ''}`} /> - {errores.nombre &&

{errores.nombre}

} + {errores.nombrePila &&

{errores.nombrePila}

}