import type { Request, Response } from 'express' 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' import { crmLocal } from '../services/crm-local.service.js' import { NotFoundError, BadRequestError } from '../utils/errors.js' import * as auditoria from '../services/auditoria.service.js' // ---------- Alta de clientes ---------- export const crearClienteSchema = z .object({ datosGenerales: z .object({ nombreCompleto: z.string().min(1), curp: z.string().min(1), rfc: z.string().min(1), email: 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(), estadoCivil: z.string().optional(), tipoVivienda: z.string().optional(), tipoCompra: z.string().optional(), usuarioEnkontrol: z.string().optional(), }) .passthrough(), empleo: z.object({ tipoEmpleo: z.string().optional() }).passthrough().optional(), viviendaCredito: z .object({ proyecto: z.string().min(1), ubicacion: z.string().optional(), valorVenta: z.union([z.string(), z.number()]).optional(), enganche: z.union([z.string(), z.number()]).optional(), }) .passthrough(), firma: z.object({ firmaDataUrl: z.string().optional() }).passthrough().optional(), }) .passthrough() // el resto del payload pasa tal cual a payloadJson /** * Checkbox que corresponde a un tipo de documento en el expediente del * apartado. INE y el resto no tienen checkbox (devuelve null). */ export function checkboxParaTipoDocumento(tipo: string): string | null { switch (tipo.trim().toUpperCase()) { case 'CURP': return 'CURP' case 'RFC': case 'CONSTANCIA_RFC': return 'RFC' case 'COMPROBANTE': case 'COMPROBANTE_DOMICILIO': return 'Comprobante' default: return null } } const aNumero = (v: string | number | undefined): number => { if (v === undefined) return 0 const n = typeof v === 'number' ? v : Number(String(v).replace(/[^0-9.-]/g, '')) return Number.isFinite(n) ? n : 0 } const hoyMas = (dias: number): string => { const d = new Date() d.setDate(d.getDate() + dias) const m = String(d.getMonth() + 1).padStart(2, '0') const dia = String(d.getDate()).padStart(2, '0') return `${d.getFullYear()}-${m}-${dia}` } /** Label de "Forma de Pago" a partir del enum del formato de alta */ const FORMA_PAGO_LABEL: Record = { INFONAVIT: 'Infonavit', APOYO_INFONAVIT: 'Apoyo Infonavit', COFINAVIT: 'Cofinavit', SEGUNDO_CREDITO: 'Segundo crédito', CONTADO: 'Contado', FOVISSSTE: 'Fovissste', COFI_FOVISSSTE: 'Cofi-Fovissste', BANCARIO: 'Bancario', } /** * "Forma de Pago" a partir de viviendaCredito.tiposCredito (arreglo del * wizard; se toma el PRIMERO reconocido). undefined si no hay ninguno. */ export function formaPagoLabel(tiposCredito: unknown): string | undefined { if (!Array.isArray(tiposCredito)) return undefined for (const t of tiposCredito) { const label = FORMA_PAGO_LABEL[String(t).trim().toUpperCase()] if (label !== undefined) return label } return undefined } // ---------- Helpers locales compartidos (alta directa y alta progresiva) ---------- const apartadoLigadoDeBody = (body: Record): string | null => { const id = body['apartadoMondayId'] return typeof id === 'string' && id !== '' ? id : null } /** * Si el alta viene de una afluencia del funnel (la app alta-clientes manda * leadMondayId en la raíz del payload), su oportunidad pasa a 'Aparto' y se * crea el apartado local (crmLocal.apartar; idempotente por dedup). El id * puede ser una oportunidad directa (modelo nuevo: la afluencia ES la * oportunidad, fuente 'oportunidad' del picker) o un lead histórico: se * resuelve ambos vía LeadOportunidad, como hacía moverAApartoConDedup. * Devuelve el id de la fila de apartado local creada (null si no aplica). * Un fallo aquí no rompe el alta. */ async function moverLeadAApartoSiAplica( body: Record, capturadoPorId: string | null, ): Promise { const leadId = body['leadMondayId'] if (typeof leadId !== 'string' || leadId === '') return null try { const comoOportunidad = await prisma.leadOportunidad.findUnique({ where: { oportunidadId: leadId }, }) const mapping = comoOportunidad ?? (await prisma.leadOportunidad.findUnique({ where: { leadId } })) const oportunidadId = comoOportunidad?.oportunidadId ?? mapping?.oportunidadId ?? null if (oportunidadId === null) return null const apartado = await crmLocal.apartar(oportunidadId, capturadoPorId ?? undefined) return apartado.leadId } catch (err) { // eslint-disable-next-line no-console console.error(`No se pudo mover a 'Aparto' la oportunidad del lead ${leadId}:`, err) return null } } /** * Enriquece la fila de apartado local con lo capturado en el alta (unidad, * precio, enganche, forma de pago, contacto): el "Alta Enkontrol" desde un * apartado encuentra así la ficha completa al prellenar. Best-effort. */ async function enriquecerApartadoLocal( apartadoId: string, dg: { nombreCompleto: string; celular?: string | undefined; email?: string | undefined; curp?: string | undefined }, vc: { ubicacion?: string | undefined valorVenta?: string | number | undefined enganche?: string | number | undefined tiposCredito?: unknown }, ): Promise { try { const captura = await prisma.leadCaptura.findUnique({ where: { leadId: apartadoId } }) if (!captura) return let datos: Record = {} if (captura.datosJson) { try { datos = JSON.parse(captura.datosJson) as Record } catch { /* ignorar */ } } const valorVenta = aNumero(vc.valorVenta) const enganche = aNumero(vc.enganche) datos['unidadCodigo'] = vc.ubicacion ?? datos['unidadCodigo'] ?? '' if (valorVenta > 0) datos['precioFinal'] = valorVenta if (enganche > 0) { datos['enganche'] = enganche datos['montoApartado'] = enganche } datos['estadoLabel'] = 'Pago Apartado' datos['fechaApartado'] = datos['fechaApartado'] ?? hoyMas(0) datos['fechaLimite'] = datos['fechaLimite'] ?? hoyMas(15) datos['fechaCompra'] = hoyMas(0) const formaPago = formaPagoLabel(vc.tiposCredito) if (formaPago !== undefined) datos['formaPagoLabel'] = formaPago if (dg.celular !== undefined && dg.celular !== '') datos['telefono'] = dg.celular if (dg.email !== undefined && dg.email !== '') datos['email'] = dg.email if (dg.curp !== undefined && dg.curp !== '') datos['curp'] = dg.curp await prisma.leadCaptura.update({ where: { leadId: apartadoId }, data: { datosJson: JSON.stringify(datos), ...(dg.celular !== undefined && dg.celular !== '' && captura.telefono === null ? { telefono: dg.celular } : {}), ...(dg.email !== undefined && dg.email !== '' && captura.email === null ? { email: dg.email } : {}), }, }) } catch (err) { // eslint-disable-next-line no-console console.error(`No se pudo enriquecer el apartado local ${apartadoId}:`, err) } } /** * Liga el apartado del alta a la ficha del Cliente (mondayApartadoId conserva * el nombre histórico: es el id de la fila de apartado, local o heredado). * Best-effort: un fallo no rompe el alta. */ async function ligarApartadoACliente(clienteId: string, apartadoId: string): Promise { try { await prisma.cliente.update({ where: { id: clienteId }, data: { mondayApartadoId: apartadoId } }) } catch (err) { // eslint-disable-next-line no-console console.error(`No se pudo ligar el apartado ${apartadoId} al cliente ${clienteId}:`, err) } } /** POST /api/clientes — persiste el alta en SQLite y liga el apartado del funnel */ export async function crearCliente(req: Request, res: Response): Promise { const body = crearClienteSchema.parse(req.body) const dg = body.datosGenerales const vc = body.viviendaCredito const cliente = await prisma.cliente.create({ data: { nombreCompleto: dg.nombreCompleto, curp: dg.curp, rfc: dg.rfc, email: dg.email ?? null, celular: dg.celular ?? null, proyecto: vc.proyecto, unidadCodigo: vc.ubicacion ?? '', // la "ubicación" del formato es el código de unidad (U-101, etc.) capturadoPorId: req.usuarioToken?.sub ?? null, payloadJson: JSON.stringify(body), firmaPng: body.firma?.firmaDataUrl ?? null, }, }) // Apartado del alta: el existente (botón "Alta Enkontrol" desde Mis // apartados) se LIGA tal cual; si el alta viene del funnel se mueve la // oportunidad a 'Aparto' y se crea/enriquece el apartado local. let mondayApartadoId = apartadoLigadoDeBody(body as Record) if (mondayApartadoId === null) { const apartadoFunnelId = await moverLeadAApartoSiAplica(body as Record, req.usuarioToken?.sub ?? null) if (apartadoFunnelId !== null) { mondayApartadoId = apartadoFunnelId await enriquecerApartadoLocal(apartadoFunnelId, dg, vc) } } if (mondayApartadoId !== null) { await ligarApartadoACliente(cliente.id, mondayApartadoId) } res.status(201).json({ ...cliente, mondayContactoId: null, mondayApartadoId }) auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CREAR_CLIENTE', 'cliente', cliente.id) } // ---------- Alta progresiva (guardado etapa por etapa del wizard) ---------- /** * POST /progreso/iniciar — solo exige el nombre; curp/rfc/email/celular son * opcionales porque se completan en los pasos siguientes. */ export const iniciarProgresoSchema = z .object({ datosGenerales: z .object({ nombreCompleto: z.string().min(1), curp: z.string().optional(), rfc: z.string().optional(), email: z.string().optional(), celular: z.string().optional(), direccion: z.string().optional(), fechaNacimiento: z.string().optional(), estadoCivil: z.string().optional(), tipoVivienda: z.string().optional(), tipoCompra: z.string().optional(), usuarioEnkontrol: z.string().optional(), }) .passthrough(), leadMondayId: z.string().optional(), }) .passthrough() /** PATCH /progreso/:id — passthrough libre; solo se valida que sea un objeto */ const actualizarProgresoSchema = z.object({}).passthrough() /** POST /api/clientes/progreso/iniciar — crea el alta en progreso */ export async function iniciarProgreso(req: Request, res: Response): Promise { const body = iniciarProgresoSchema.parse(req.body) const dg = body.datosGenerales const cliente = await prisma.cliente.create({ data: { nombreCompleto: dg.nombreCompleto, curp: dg.curp ?? '', rfc: dg.rfc ?? '', email: dg.email ?? null, celular: dg.celular ?? null, proyecto: '', // se define al completar el alta capturadoPorId: req.usuarioToken?.sub ?? null, payloadJson: JSON.stringify(body), }, }) res.status(201).json({ id: cliente.id, mondayContactoId: null }) } /** PATCH /api/clientes/progreso/:id — guarda el avance del wizard (local) */ export async function actualizarProgreso(req: Request, res: Response): Promise { const id = String(req.params['id']) const body = actualizarProgresoSchema.parse(req.body) const cliente = await prisma.cliente.findUnique({ where: { id } }) if (!cliente) throw new NotFoundError('Cliente no encontrado') await prisma.cliente.update({ where: { id }, data: { payloadJson: JSON.stringify(body) } }) res.json({ id, mondaySync: false }) } /** * POST /api/clientes/progreso/:id/completar — cierra el alta iniciada con el * guardado progresivo. Misma validación y shape de respuesta que POST /clientes. */ export async function completarProgreso(req: Request, res: Response): Promise { const id = String(req.params['id']) const body = crearClienteSchema.parse(req.body) const dg = body.datosGenerales const vc = body.viviendaCredito const cliente = await prisma.cliente.findUnique({ where: { id } }) if (!cliente) throw new NotFoundError('Cliente no encontrado') const actualizado = await prisma.cliente.update({ where: { id }, data: { nombreCompleto: dg.nombreCompleto, curp: dg.curp, rfc: dg.rfc, email: dg.email ?? null, celular: dg.celular ?? null, proyecto: vc.proyecto, unidadCodigo: vc.ubicacion ?? '', payloadJson: JSON.stringify(body), firmaPng: body.firma?.firmaDataUrl ?? null, }, }) // Apartado del alta (misma lógica que el alta directa) let mondayApartadoId = apartadoLigadoDeBody(body as Record) if (mondayApartadoId === null) { const apartadoFunnelId = await moverLeadAApartoSiAplica(body as Record, cliente.capturadoPorId) if (apartadoFunnelId !== null) { mondayApartadoId = apartadoFunnelId await enriquecerApartadoLocal(apartadoFunnelId, dg, vc) } } if (mondayApartadoId !== null) { await ligarApartadoACliente(id, mondayApartadoId) } res.status(201).json({ ...actualizado, mondayContactoId: null, mondayApartadoId }) } /** GET /api/clientes/:id */ export async function obtenerCliente(req: Request, res: Response): Promise { const cliente = await prisma.cliente.findUnique({ where: { id: String(req.params['id']) }, include: { documentos: true }, }) if (!cliente) throw new NotFoundError('Cliente no encontrado') res.json(cliente) } // ---------- Documentos ---------- const MIME_PERMITIDOS = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png']) const EXT_POR_MIME: Record = { 'application/pdf': 'pdf', 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/png': 'png', } const storage = multer.diskStorage({ destination: (req, _file, cb) => { const dir = path.join(config.uploadsDir, String(req.params['id'])) fs.mkdirSync(dir, { recursive: true }) cb(null, dir) }, filename: (req, file, cb) => { const tipo = String(req.body['tipo'] ?? 'OTRO').toUpperCase() const ext = EXT_POR_MIME[file.mimetype] ?? 'bin' cb(null, `${tipo}_${randomUUID()}.${ext}`) }, }) export const uploadDocumentos = multer({ storage, limits: { fileSize: 10 * 1024 * 1024, files: 10 }, // 10 MB por archivo, máx 10 fileFilter: (_req, file, cb) => { if (!MIME_PERMITIDOS.has(file.mimetype)) { cb(new BadRequestError(`Tipo de archivo no permitido: ${file.mimetype}`)) return } cb(null, true) }, }) /** POST /api/clientes/:id/documentos — multipart, campo `archivos` (hasta 10) */ export async function subirDocumentos(req: Request, res: Response): Promise { const clienteId = String(req.params['id']) const cliente = await prisma.cliente.findUnique({ where: { id: clienteId } }) if (!cliente) throw new NotFoundError('Cliente no encontrado') const archivos = (req.files ?? []) as Express.Multer.File[] const tipo = String(req.body['tipo'] ?? 'OTRO').toUpperCase() const documentos = await Promise.all( archivos.map((f) => prisma.documento.create({ data: { clienteId, tipo, nombreArchivo: f.originalname, ruta: path.relative(config.uploadsDir, f.path), mime: f.mimetype, tamano: f.size, }, }), ), ) res.status(201).json(documentos) } /** GET /api/clientes/:id/documentos */ export async function listarDocumentos(req: Request, res: Response): Promise { const clienteId = String(req.params['id']) const cliente = await prisma.cliente.findUnique({ where: { id: clienteId } }) if (!cliente) throw new NotFoundError('Cliente no encontrado') const documentos = await prisma.documento.findMany({ where: { clienteId }, orderBy: { createdAt: 'desc' }, }) res.json(documentos) } /** * GET /api/altas/prefill-apartado/:itemId — todo lo ya recolectado de un * apartado para prellenar el alta de cliente (botón "Alta Enkontrol" de Mis * apartados): ficha del apartado (fila LeadCaptura board=APARTADO), contacto * relacionado (ficha del Cliente), oportunidad relacionada (perfil de la * afluencia) y la cotización más reciente vinculada. Las relaciones se arman * por teléfono (últimos 10 dígitos) o email. 404 si no existe el apartado. */ export async function prefillApartadoEndpoint(req: Request, res: Response): Promise { const itemId = String(req.params['itemId']) res.json(await prefillApartadoLocal(itemId)) } /** Shape de la cotización en la respuesta del prefill */ function cotizacionPrefill(cotizacion: { tipoPropuesta: string acabados: boolean apartado: number precioLista: number precioFinal: number enganche: number numMensualidades: number mensualidad: number | null pagoFinal: number }) { return { tipoPropuesta: cotizacion.tipoPropuesta, acabados: cotizacion.acabados, apartado: cotizacion.apartado, precioLista: cotizacion.precioLista, precioFinal: cotizacion.precioFinal, enganche: cotizacion.enganche, numMensualidades: cotizacion.numMensualidades, mensualidad: cotizacion.mensualidad, pagoFinal: cotizacion.pagoFinal, } } /** * Prefill local: el apartado es la fila LeadCaptura board=APARTADO (id loc_* o * sombra); el contacto sale del Cliente ligado, la oportunidad de la captura * board=OPORTUNIDADES con el mismo teléfono (su perfil va en datosJson) y la * cotización de la tabla local. */ async function prefillApartadoLocal(itemId: string) { const captura = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } }) if (!captura || captura.board !== 'APARTADO') throw new NotFoundError('Apartado no encontrado') const tel = (captura.telefono ?? '').replace(/\D/g, '').slice(-10) const cliente = await prisma.cliente.findFirst({ where: { OR: [{ mondayApartadoId: itemId }, ...(tel !== '' ? [{ celular: { contains: tel } }] : [])], }, orderBy: { createdAt: 'desc' }, }) const oportunidadLocal = await prisma.leadCaptura.findFirst({ where: { board: 'OPORTUNIDADES', ...(tel !== '' ? { telefono: { contains: tel } } : { nombreLead: captura.nombreLead }), }, orderBy: { updatedAt: 'desc' }, }) // Perfil de la afluencia (datosJson): solo los campos que el front consume let credito = '' if (oportunidadLocal?.datosJson) { try { const datos = JSON.parse(oportunidadLocal.datosJson) as Record if (typeof datos['credito'] === 'string') credito = datos['credito'] } catch { // datosJson inválido: se ignora, el prefill sale igual } } // Cotización local más reciente: vinculada a la oportunidad local o por teléfono let cotizacion = oportunidadLocal ? await prisma.cotizacion.findFirst({ where: { mondayItemId: oportunidadLocal.leadId }, orderBy: { createdAt: 'desc' }, }) : null if (!cotizacion && tel !== '') { cotizacion = await prisma.cotizacion.findFirst({ where: { clienteTelefono: { contains: tel } }, orderBy: { createdAt: 'desc' }, }) } // Datos del alta (payloadJson) que el prefill del front consume con claves // legacy de Monday: dirección, estado civil y fecha de nacimiento const contactoExtra: Record = {} if (cliente?.payloadJson) { try { const payload = JSON.parse(cliente.payloadJson) as { datosGenerales?: Record } const g = payload.datosGenerales ?? {} const str = (v: unknown): string => (typeof v === 'string' && v.trim() !== '' ? v : '') if (str(g['direccion']) !== '') contactoExtra['Dirección'] = str(g['direccion']) if (str(g['estadoCivil']) !== '') contactoExtra['Estado Civil'] = str(g['estadoCivil']) if (str(g['fechaNacimiento']) !== '') contactoExtra['Fecha Nacimiento'] = str(g['fechaNacimiento']) } catch { // payload inválido: el prefill sale parcial, como antes } } return { apartado: { id: captura.leadId, nombre: captura.nombreLead, unidadCodigo: cliente?.unidadCodigo ?? cotizacion?.unidadCodigo ?? '', fechaLimite: '', estado: captura.etapa, vendedorIds: [] as number[], telefono: captura.telefono ?? '', email: captura.email ?? '', precioFinal: cotizacion?.precioFinal ?? 0, enganche: cotizacion?.enganche ?? 0, }, contacto: cliente ? { nombre: cliente.nombreCompleto, Teléfono: cliente.celular ?? '', Email: cliente.email ?? '', CURP: cliente.curp, RFC: cliente.rfc, ...contactoExtra, } : null, oportunidad: oportunidadLocal ? { id: oportunidadLocal.leadId, desarrollo: oportunidadLocal.desarrollo ?? '', modeloInteres: oportunidadLocal.modelo ?? '', credito, } : null, 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`, ) }