From 643508618137a761081e3b2fe0ca35072b26f257 Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Fri, 25 Sep 2026 21:41:59 +0000 Subject: [PATCH] feat(alta-clientes): telefono obligatorio en alta y afluencia, importacion de informacion Enkontrol por ID en pantalla final y campos de obra movidos a la seccion Obra en captura --- api-gateway/src/config/campos-departamento.ts | 16 ++- .../controllers/clientes.controller.test.ts | 6 +- .../src/controllers/clientes.controller.ts | 133 +++++++++++++++++- .../src/controllers/leads.controller.test.ts | 1 + .../src/controllers/leads.controller.ts | 3 +- api-gateway/src/routes/index.ts | 10 ++ .../captura-departamento.service.test.ts | 6 +- .../src/components/AltaAfluenciaForm.tsx | 1 + .../src/pages/AltaClientePage.tsx | 71 ++++++++++ apps/alta-clientes/src/services/api.ts | 30 +++- apps/landing/src/services/captura.ts | 16 ++- 11 files changed, 269 insertions(+), 24 deletions(-) diff --git a/api-gateway/src/config/campos-departamento.ts b/api-gateway/src/config/campos-departamento.ts index 15351a4..62cb520 100644 --- a/api-gateway/src/config/campos-departamento.ts +++ b/api-gateway/src/config/campos-departamento.ts @@ -16,6 +16,15 @@ export const CAMPOS_POR_DEPARTAMENTO: Record { describe('crearClienteSchema', () => { const base = { - datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123' }, + datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123', celular: '6649990003' }, viviendaCredito: { proyecto: 'SENDA' }, } @@ -102,7 +102,7 @@ describe('crearClienteSchema', () => { // ---------- Alta local (POST /api/clientes) ---------- const altaBase = { - datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123' }, + datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123', celular: '6649990003' }, viviendaCredito: { proyecto: 'SENDA' }, } @@ -360,7 +360,7 @@ describe('alta progresiva — local', () => { mondayApartadoId: null, }) const altaCompleta = { - datosGenerales: { nombreCompleto: 'Ana Demo Progreso', curp: 'AEDA900101MBCXXX00', rfc: 'AEDA900101XX0' }, + datosGenerales: { nombreCompleto: 'Ana Demo Progreso', curp: 'AEDA900101MBCXXX00', rfc: 'AEDA900101XX0', celular: '6649990003' }, viviendaCredito: { proyecto: 'SENDA', ubicacion: 'U-999', valorVenta: 1500000, enganche: 20000 }, leadMondayId: 'loc_op_9', } diff --git a/api-gateway/src/controllers/clientes.controller.ts b/api-gateway/src/controllers/clientes.controller.ts index 8f3f157..15e1c54 100644 --- a/api-gateway/src/controllers/clientes.controller.ts +++ b/api-gateway/src/controllers/clientes.controller.ts @@ -3,6 +3,7 @@ import path from 'node:path' import fs from 'node:fs' import { randomUUID } from 'node:crypto' import multer from 'multer' +import * as XLSX from 'xlsx' import { z } from 'zod' import { config } from '../config/index.js' import { prisma } from '../services/prisma.js' @@ -20,7 +21,8 @@ export const crearClienteSchema = z curp: z.string().min(1), rfc: z.string().min(1), email: z.string().optional(), - celular: z.string().optional(), + // Teléfono obligatorio (regla de negocio: sin celular no hay alta) + celular: z.string().trim().min(8, 'El celular es obligatorio'), direccion: z.string().optional(), // Perfil demográfico (opcionales; viajan en el payload del alta) fechaNacimiento: z.string().optional(), @@ -575,3 +577,132 @@ async function prefillApartadoLocal(itemId: string) { cotizacion: cotizacion ? cotizacionPrefill(cotizacion) : null, } } + + +// ---------- Importación de información desde Enkontrol ---------- + +/** Upload en memoria del documento de Enkontrol (Excel o CSV, máx 10 MB) */ +export const uploadImportacionEnkontrol = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 10 * 1024 * 1024 }, +}) + +/** Normaliza un encabezado para compararlo: minúsculas, sin acentos, espacios→' ' */ +function normalizarEncabezado(h: string): string { + return h + .trim() + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' ') +} + +/** Encabezados aceptados como ID de Enkontrol (el campo determinante del documento) */ +const COLUMNAS_ID_ENKONTROL = new Set([ + 'id enkontrol', + 'cliente enkontrol', + 'usuario enkontrol', + 'enkontrol id', + 'id de enkontrol', + 'cliente enkontrol id', +]) + +/** Encabezado normalizado → columna del Cliente que actualiza */ +const COLUMNAS_CLIENTE: Record = { + 'nombre': 'nombreCompleto', + 'nombre completo': 'nombreCompleto', + 'cliente': 'nombreCompleto', + 'curp': 'curp', + 'rfc': 'rfc', + 'email': 'email', + 'correo': 'email', + 'correo electronico': 'email', + 'celular': 'celular', + 'telefono': 'celular', + 'telefono celular': 'celular', + 'proyecto': 'proyecto', + 'desarrollo': 'proyecto', + 'unidad': 'unidadCodigo', + 'ubicacion': 'unidadCodigo', + 'unidad codigo': 'unidadCodigo', +} + +/** + * POST /api/clientes/importar-enkontrol — importa un documento (Excel/CSV) de + * Enkontrol usando el ID de Enkontrol como campo determinante de cada cliente: + * cada fila se empareja con la ficha local cuyo alta tenga ese usuarioEnkontrol + * y su información completa se guarda en el payload de la ficha. Las columnas + * conocidas (nombre, CURP, RFC, email, celular, proyecto, unidad) actualizan + * también los campos directos del Cliente. + */ +export async function importarEnkontrolEndpoint(req: Request, res: Response): Promise { + const archivo = (req as Request & { file?: Express.Multer.File }).file + if (!archivo) throw new BadRequestError('Adjunta el archivo Excel o CSV de Enkontrol') + + let filas: Record[] + try { + const libro = XLSX.read(archivo.buffer, { type: 'buffer' }) + const hoja = libro.Sheets[libro.SheetNames[0] ?? ''] + if (!hoja) throw new Error('sin hojas') + filas = XLSX.utils.sheet_to_json>(hoja, { defval: '' }) + } catch { + throw new BadRequestError('No se pudo leer el documento: usa un Excel (.xlsx) o CSV válido') + } + if (filas.length === 0) throw new BadRequestError('El documento no contiene filas de datos') + + const encabezados = Object.keys(filas[0]!) + const columnaId = encabezados.find((h) => COLUMNAS_ID_ENKONTROL.has(normalizarEncabezado(h))) + if (!columnaId) { + throw new BadRequestError( + `No encontré la columna del ID de Enkontrol (encabezados: ${encabezados.slice(0, 8).join(', ')})`, + ) + } + + // Índice de clientes locales por ID de Enkontrol (payloadJson.datosGenerales.usuarioEnkontrol) + const clientes = await prisma.cliente.findMany({ select: { id: true, nombreCompleto: true, payloadJson: true } }) + const porEnkontrol = new Map() + for (const c of clientes) { + try { + const payload = JSON.parse(c.payloadJson) as { datosGenerales?: { usuarioEnkontrol?: unknown } } + const idE = payload.datosGenerales?.usuarioEnkontrol + if (typeof idE === 'string' && idE.trim() !== '') { + porEnkontrol.set(idE.trim().toUpperCase(), c) + } + } catch { /* payload ilegible: se omite del índice */ } + } + + let actualizados = 0 + const noEncontrados: string[] = [] + const ahora = new Date().toISOString() + for (const fila of filas) { + const idE = String(fila[columnaId] ?? '').trim() + if (idE === '') continue + const cliente = porEnkontrol.get(idE.toUpperCase()) + if (!cliente) { + noEncontrados.push(idE) + continue + } + let payload: Record = {} + try { payload = JSON.parse(cliente.payloadJson) as Record } catch { payload = {} } + const data: Record = { + payloadJson: JSON.stringify({ ...payload, importacionEnkontrol: { fecha: ahora, datos: fila } }), + } + for (const [encabezado, valor] of Object.entries(fila)) { + const columna = COLUMNAS_CLIENTE[normalizarEncabezado(encabezado)] + const texto = String(valor ?? '').trim() + if (columna !== undefined && texto !== '') data[columna] = texto + } + await prisma.cliente.update({ where: { id: cliente.id }, data }) + actualizados++ + } + + res.json({ totalFilas: filas.length, actualizados, noEncontrados }) + auditoria.registrar( + req.usuarioToken?.sub ?? 'sistema', + 'IMPORTAR_ENKONTROL', + 'cliente', + archivo.originalname, + `${actualizados} actualizados, ${noEncontrados.length} sin ficha local`, + ) +} diff --git a/api-gateway/src/controllers/leads.controller.test.ts b/api-gateway/src/controllers/leads.controller.test.ts index 0fbce2b..f89e1e1 100644 --- a/api-gateway/src/controllers/leads.controller.test.ts +++ b/api-gateway/src/controllers/leads.controller.test.ts @@ -212,6 +212,7 @@ describe('crearAfluenciaEndpoint (POST /api/afluencias) — la afluencia ES la o reqCon({ body: { nombre: 'Lead Prueba', + telefono: '4771234567', oportunidadId: 'loc_op_3', desarrollo: 'SENDA', modelo: 'SOHO', diff --git a/api-gateway/src/controllers/leads.controller.ts b/api-gateway/src/controllers/leads.controller.ts index 8fef35a..14cb848 100644 --- a/api-gateway/src/controllers/leads.controller.ts +++ b/api-gateway/src/controllers/leads.controller.ts @@ -60,7 +60,8 @@ const crearLeadSchema = z.object({ /** Alta Afluencia (tablero Oportunidades): captura con perfil completo */ const crearAfluenciaSchema = z.object({ nombre: z.string().trim().min(1, 'El nombre es obligatorio'), - telefono: z.string().trim().min(1).optional(), + // Teléfono obligatorio (regla de negocio: sin teléfono no hay afluencia) + telefono: z.string().trim().min(7, 'El teléfono es obligatorio'), email: z.string().trim().email('Email inválido').optional(), origen: z.string().trim().min(1).optional(), desarrollo: z.string().trim().min(1).optional(), diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts index 41aeec5..88d6936 100644 --- a/api-gateway/src/routes/index.ts +++ b/api-gateway/src/routes/index.ts @@ -15,12 +15,14 @@ import { actualizarProgreso, completarProgreso, crearCliente, + importarEnkontrolEndpoint, iniciarProgreso, listarDocumentos, obtenerCliente, prefillApartadoEndpoint, subirDocumentos, uploadDocumentos, + uploadImportacionEnkontrol, } from '../controllers/clientes.controller.js' import { eliminarBorrador, @@ -208,6 +210,14 @@ apiRouter.get('/cotizaciones/:id/publico', obtenerCotizacionPublica) apiRouter.get('/cotizaciones/:id', obtenerCotizacion) apiRouter.post('/clientes', crearCliente) +// Importación de información desde un documento de Enkontrol (ID Enkontrol = +// campo determinante). Solo administración. +apiRouter.post( + '/clientes/importar-enkontrol', + requireAdminOManager, + uploadImportacionEnkontrol.single('archivo'), + importarEnkontrolEndpoint, +) // Prefill del alta desde un apartado existente (botón "Alta Enkontrol" // de Mis apartados). Va ANTES de /clientes/:id para no colisionar. apiRouter.get('/altas/prefill-apartado/:itemId', prefillApartadoEndpoint) diff --git a/api-gateway/src/services/captura-departamento.service.test.ts b/api-gateway/src/services/captura-departamento.service.test.ts index 719e58b..850bc12 100644 --- a/api-gateway/src/services/captura-departamento.service.test.ts +++ b/api-gateway/src/services/captura-departamento.service.test.ts @@ -193,7 +193,7 @@ describe('fechas de Obra bloqueadas', () => { expect(prismaMock.$transaction).not.toHaveBeenCalled() }) - it('ESCRITURACION: no-admin no puede modificar una fecha ya establecida', async () => { + it('OBRA: no-admin no puede modificar la fecha de entrega programada ya establecida', async () => { prismaMock.capturaDepartamento.findMany.mockResolvedValue([ { unidadCodigo: 'U-101', campo: 'entrega_programada_fecha', valor: '2026-10-01' }, ]) @@ -201,11 +201,11 @@ describe('fechas de Obra bloqueadas', () => { guardarCampo( 'SENDA', 'U-101', - 'ESCRITURACION', + 'OBRA', 'entrega_programada_fecha', '2026-10-15', 'user-1', - 'escrituracion', + 'obra', ), ).rejects.toThrow('solo puede modificarlas un administrador') expect(prismaMock.capturaDepartamento.upsert).not.toHaveBeenCalled() diff --git a/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx b/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx index af1dd60..476bdb0 100644 --- a/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx +++ b/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx @@ -426,6 +426,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor placeholder="664 123 4567" value={form.telefono} onChange={cambiar('telefono')} + required className={`${inputClase} ${errores.telefono ? 'border-red-400' : ''}`} /> diff --git a/apps/alta-clientes/src/pages/AltaClientePage.tsx b/apps/alta-clientes/src/pages/AltaClientePage.tsx index 8bfac87..27cd0d3 100644 --- a/apps/alta-clientes/src/pages/AltaClientePage.tsx +++ b/apps/alta-clientes/src/pages/AltaClientePage.tsx @@ -19,6 +19,7 @@ import { eliminarBorrador, generarLigaRemota, guardarBorrador, + importarInformacionEnkontrol, iniciarProgreso, listarBorradores, obtenerBorrador, @@ -27,7 +28,9 @@ import { subirDocumentos, type BorradorResumen, type LeadResumen, + type ResultadoImportacionEnkontrol, } from '../services/api' +import { getSesion } from '../services/auth' import { cotizacionAPrellenado } from '../lib/cotizacionAlta' import { apartadoAPrellenado } from '../lib/apartadoAlta' import { leadAPrellenado } from '../lib/leadAlta' @@ -137,6 +140,29 @@ export default function AltaClientePage() { const [ligaCopiada, setLigaCopiada] = useState(false) const [errorLiga, setErrorLiga] = useState('') + // ---------- Importación del documento de Enkontrol (solo administración) ---------- + const rolSesion = getSesion()?.usuario.rol ?? '' + const puedeImportarEnkontrol = rolSesion === 'admin' || rolSesion === 'manager' + const [importandoEnkontrol, setImportandoEnkontrol] = useState(false) + const [resultadoImportacion, setResultadoImportacion] = useState(null) + const [errorImportacion, setErrorImportacion] = useState('') + const inputImportacionRef = useRef(null) + + const importarEnkontrol = async (archivo: File | undefined) => { + if (!archivo) return + setImportandoEnkontrol(true) + setErrorImportacion('') + setResultadoImportacion(null) + try { + setResultadoImportacion(await importarInformacionEnkontrol(archivo)) + } catch (err) { + setErrorImportacion(err instanceof Error ? err.message : 'No se pudo importar el documento.') + } finally { + setImportandoEnkontrol(false) + if (inputImportacionRef.current) inputImportacionRef.current.value = '' + } + } + // Afluencia prellenada desde Mis oportunidades (Mi día): actualiza la // oportunidad existente en vez de crear una nueva const [afluenciaPrevia, setAfluenciaPrevia] = useState<{ @@ -610,6 +636,51 @@ export default function AltaClientePage() { + {puedeImportarEnkontrol && ( +
+

+ Importar información de Enkontrol +

+

+ Sube el documento de Enkontrol (Excel o CSV). Cada fila se empareja con la ficha + del cliente usando el ID de Enkontrol como campo determinante y su información se + guarda en la ficha. +

+
+ + void importarEnkontrol(e.target.files?.[0])} + /> +
+ {resultadoImportacion && ( +

+ Importación lista: {resultadoImportacion.actualizados} de {resultadoImportacion.totalFilas}{' '} + clientes actualizados. + {resultadoImportacion.noEncontrados.length > 0 && ( + <> Sin ficha local: {resultadoImportacion.noEncontrados.slice(0, 10).join(', ')} + {resultadoImportacion.noEncontrados.length > 10 ? '…' : ''} + )} +

+ )} + {errorImportacion && ( +

+ {errorImportacion} +

+ )} +
+ )} +

Con la liga de firma, el cliente firma su expediente y sube documentos desde su celular (vigencia 72 h, sin usuario ni contraseña). diff --git a/apps/alta-clientes/src/services/api.ts b/apps/alta-clientes/src/services/api.ts index 3bfb76a..b3a4df8 100644 --- a/apps/alta-clientes/src/services/api.ts +++ b/apps/alta-clientes/src/services/api.ts @@ -73,8 +73,7 @@ async function enviarAlta(ruta: string, payload: AltaCliente): Promise { + */export async function crearAltaCliente(payload: AltaCliente): Promise { return enviarAlta('/clientes', payload) } @@ -414,3 +413,30 @@ export async function subirDocumentoRemoto(token: string, tipo: string, archivo: }) manejarErrorLiga(res) } + + +/** Resultado de la importación del documento de Enkontrol */ +export interface ResultadoImportacionEnkontrol { + totalFilas: number + actualizados: number + noEncontrados: string[] +} + +/** + * POST /clientes/importar-enkontrol — sube el documento de Enkontrol (Excel o + * CSV). El ID de Enkontrol es el campo determinante: cada fila actualiza la + * ficha del cliente con ese usuarioEnkontrol. + */ +export async function importarInformacionEnkontrol( + archivo: File, +): Promise { + const form = new FormData() + form.append('archivo', archivo, archivo.name) + const res = await fetch(`${API_URL}/clientes/importar-enkontrol`, conAuth({ method: 'POST', body: form })) + manejar401(res) + if (!res.ok) { + const cuerpo = (await res.json().catch(() => null)) as { error?: string } | null + throw new Error(cuerpo?.error ?? `No se pudo importar (HTTP ${res.status})`) + } + return (await res.json()) as ResultadoImportacionEnkontrol +} diff --git a/apps/landing/src/services/captura.ts b/apps/landing/src/services/captura.ts index 8964322..2868811 100644 --- a/apps/landing/src/services/captura.ts +++ b/apps/landing/src/services/captura.ts @@ -40,6 +40,15 @@ export const CAMPOS_POR_DEPARTAMENTO: Record = { { campo: 'listas_avaluo', tipo: 'boolean', label: 'Listas para Avaluo', conFecha: 'listas_avaluo_fecha' }, { campo: 'listas_avaluo_fecha', tipo: 'date', label: 'Fecha Listas para Avaluo' }, { campo: 'habitabilidad', tipo: 'date', label: 'Habitabilidad a partir de' }, + // Fechas de obra (las llena el área de obra; rigen el arranque y la entrega) + { campo: 'inicio_obra', tipo: 'boolean', label: 'Inicio Obra', conFecha: 'inicio_obra_fecha' }, + { campo: 'inicio_obra_fecha', tipo: 'date', label: 'Fecha Inicio Obra' }, + { campo: 'entrega_programada', tipo: 'boolean', label: 'Entrega Programada', conFecha: 'entrega_programada_fecha' }, + { campo: 'entrega_programada_fecha', tipo: 'date', label: 'Fecha Programada Entrega Vivienda' }, + { campo: 'entrega_reprogramada', tipo: 'boolean', label: 'Entrega Reprogramada', conFecha: 'entrega_reprogramada_fecha' }, + { campo: 'entrega_reprogramada_fecha', tipo: 'date', label: 'Fecha Reprogramada Entrega Vivienda' }, + { campo: 'desviacion', tipo: 'number', label: 'Desviación (días)' }, + { campo: 'fecha_autorizacion', tipo: 'date', label: 'Fecha Autorización' }, { campo: 'comentarios_obra', tipo: 'text', label: 'Comentarios' }, ], VENTAS: [ @@ -55,13 +64,6 @@ export const CAMPOS_POR_DEPARTAMENTO: Record = { { campo: 'precio_venta', tipo: 'number', label: 'Precio Venta' }, ], ESCRITURACION: [ - { campo: 'inicio_obra', tipo: 'boolean', label: 'Inicio Obra', conFecha: 'inicio_obra_fecha' }, - { campo: 'inicio_obra_fecha', tipo: 'date', label: 'Fecha Inicio Obra' }, - { campo: 'entrega_programada', tipo: 'boolean', label: 'Entrega Programada', conFecha: 'entrega_programada_fecha' }, - { campo: 'entrega_programada_fecha', tipo: 'date', label: 'Fecha Programada Entrega Vivienda' }, - { campo: 'entrega_reprogramada', tipo: 'boolean', label: 'Entrega Reprogramada', conFecha: 'entrega_reprogramada_fecha' }, - { campo: 'entrega_reprogramada_fecha', tipo: 'date', label: 'Fecha Reprogramada Entrega Vivienda' }, - { campo: 'fecha_autorizacion', tipo: 'date', label: 'Fecha Autorización' }, { campo: 'fecha_firma', tipo: 'date', label: 'Fecha Firma' }, { campo: 'notaria', tipo: 'text', label: 'Notaría' }, { campo: 'fecha_pago_banco', tipo: 'date', label: 'Fecha Pago Banco' },