From 4d607621f8f3dcb8127cf00e8aaf1aff29f3309a Mon Sep 17 00:00:00 2001 From: Isaac_Aracena Date: Mon, 27 Jul 2026 16:48:02 +0000 Subject: [PATCH] =?UTF-8?q?A=C3=B1adir=20Generar=20PDFs.js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Generar PDFs.js | 1534 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1534 insertions(+) create mode 100644 Generar PDFs.js diff --git a/Generar PDFs.js b/Generar PDFs.js new file mode 100644 index 0000000..37bd337 --- /dev/null +++ b/Generar PDFs.js @@ -0,0 +1,1534 @@ +/** + * ============================================================ + * EXPEDIENTES DE FINIQUITOS - GUATEMALA + * Apps Script completo para: + * - Leer el Consolidado. + * - Validar el estatus original. + * - Generar PDFs individuales. + * - Crear y mantener CONTROL_ENVIOS. + * - Procesar todos, por lotes, por nombre o por fila. + * - Regenerar el caso especial Lilian Jeannette / L43. + * ============================================================ + */ + +const CONFIG_PRESTACIONES = Object.freeze({ + HOJA_CONSOLIDADO: 'Consolidado', + HOJA_CONTROL: 'CONTROL_ENVIOS', + PREFIJO_HOJAS_EMPLEADO: 'L', + CARPETA_SALIDA: 'PDF Prestaciones P&G - Generados', + + TAMANO_LOTE: 10, + PROPIEDAD_INDICE_LOTE: 'PRESTACIONES_INDICE_LOTE', + + ESTADO_LISTO: 'LISTO_PARA_ENVIAR', + ESTADO_PENDIENTE: 'PENDIENTE_VALIDACION', + ESTADO_ERROR: 'ERROR_GENERACION', + ESTADO_ENVIADO: 'ENVIADO', + + ENCABEZADOS_CONTROL: [ + 'NO.', + 'Hoja', + 'Nombre', + 'Email personal', + 'Estatus original', + 'PDF File ID', + 'PDF URL', + 'Estado envío', + 'Fecha generación', + 'Observación', + 'Fecha envío', + 'Gmail ID', + 'Última actualización' + ] +}); + + +/** + * Crea el menú del proyecto al abrir el Google Sheet. + */ +function onOpen() { + SpreadsheetApp.getUi() + .createMenu('Finiquitos Guatemala') + .addItem( + 'Generar todos los PDFs', + 'generarPDFsPrestaciones' + ) + .addItem( + 'Generar siguiente lote', + 'generarPDFsPrestacionesPorLotes' + ) + .addItem( + 'Reiniciar generación por lotes', + 'reiniciarGeneracionPorLotes' + ) + .addSeparator() + .addItem( + 'Regenerar PDF por nombre', + 'regenerarPDFPrestacionesPorNombre' + ) + .addItem( + 'Regenerar PDF de la fila activa', + 'regenerarPDFPrestacionesFilaActiva' + ) + .addSeparator() + .addItem( + 'Regenerar Lilian L43', + 'regenerarPDFLilianL43' + ) + .addToUi(); +} + + +/** + * Genera o regenera todos los registros válidos del Consolidado. + * + * Reglas: + * - Aprobada: genera PDF. + * - No aprobada: no genera PDF y queda PENDIENTE_VALIDACION. + * - Sin email válido: genera PDF, pero queda PENDIENTE_VALIDACION. + * - Irma / L29: genera PDF, pero nunca queda listo automáticamente. + */ +function generarPDFsPrestaciones() { + const ss = SpreadsheetApp.getActiveSpreadsheet(); + const contexto = prepararContextoPrestaciones_(ss); + + const resultado = procesarFilasPrestaciones_( + contexto, + 0, + contexto.filas.length + ); + + SpreadsheetApp.getUi().alert( + 'Generación terminada', + construirResumen_(resultado), + SpreadsheetApp.getUi().ButtonSet.OK + ); +} + + +/** + * Procesa los registros por lotes para evitar el límite + * de tiempo máximo de ejecución de Apps Script. + */ +function generarPDFsPrestacionesPorLotes() { + const ss = SpreadsheetApp.getActiveSpreadsheet(); + const contexto = prepararContextoPrestaciones_(ss); + const propiedades = PropertiesService.getScriptProperties(); + + let indiceInicial = Number( + propiedades.getProperty( + CONFIG_PRESTACIONES.PROPIEDAD_INDICE_LOTE + ) || 0 + ); + + if ( + !Number.isFinite(indiceInicial) || + indiceInicial < 0 || + indiceInicial >= contexto.filas.length + ) { + indiceInicial = 0; + } + + const indiceFinal = Math.min( + indiceInicial + CONFIG_PRESTACIONES.TAMANO_LOTE, + contexto.filas.length + ); + + const resultado = procesarFilasPrestaciones_( + contexto, + indiceInicial, + indiceFinal + ); + + if (indiceFinal >= contexto.filas.length) { + propiedades.deleteProperty( + CONFIG_PRESTACIONES.PROPIEDAD_INDICE_LOTE + ); + } else { + propiedades.setProperty( + CONFIG_PRESTACIONES.PROPIEDAD_INDICE_LOTE, + String(indiceFinal) + ); + } + + const mensajeContinuacion = + indiceFinal >= contexto.filas.length + ? '\n\nSe completaron todas las filas.' + : ( + '\n\nPróximo lote: registros internos ' + + `${indiceFinal + 1} a ` + + `${Math.min( + indiceFinal + CONFIG_PRESTACIONES.TAMANO_LOTE, + contexto.filas.length + )}.` + ); + + SpreadsheetApp.getUi().alert( + 'Lote procesado', + construirResumen_(resultado) + mensajeContinuacion, + SpreadsheetApp.getUi().ButtonSet.OK + ); +} + + +/** + * Reinicia el avance guardado de la generación por lotes. + */ +function reiniciarGeneracionPorLotes() { + PropertiesService + .getScriptProperties() + .deleteProperty( + CONFIG_PRESTACIONES.PROPIEDAD_INDICE_LOTE + ); + + SpreadsheetApp.getUi().alert( + 'El contador de generación por lotes fue reiniciado.' + ); +} + + +/** + * Solicita el nombre del colaborador y regenera solo su caso. + */ +function regenerarPDFPrestacionesPorNombre() { + const ui = SpreadsheetApp.getUi(); + + const respuesta = ui.prompt( + 'Regenerar PDF', + 'Escribe el nombre del colaborador tal como aparece en Consolidado:', + ui.ButtonSet.OK_CANCEL + ); + + if (respuesta.getSelectedButton() !== ui.Button.OK) { + return; + } + + const nombreBuscado = normalizarTexto_( + respuesta.getResponseText() + ); + + if (!nombreBuscado) { + ui.alert('Debes escribir un nombre.'); + return; + } + + const ss = SpreadsheetApp.getActiveSpreadsheet(); + const contexto = prepararContextoPrestaciones_(ss); + + const indice = contexto.filas.findIndex( + fila => + normalizarTexto_(fila.nombre) === nombreBuscado + ); + + if (indice === -1) { + ui.alert( + 'No encontré ese nombre en la hoja Consolidado.' + ); + return; + } + + const resultado = procesarFilasPrestaciones_( + contexto, + indice, + indice + 1 + ); + + ui.alert( + 'Regeneración terminada', + construirResumen_(resultado), + ui.ButtonSet.OK + ); +} + + +/** + * Regenera el caso asociado a la fila seleccionada + * en la hoja Consolidado. + */ +function regenerarPDFPrestacionesFilaActiva() { + const ss = SpreadsheetApp.getActiveSpreadsheet(); + const hojaActiva = ss.getActiveSheet(); + + if ( + hojaActiva.getName() !== + CONFIG_PRESTACIONES.HOJA_CONSOLIDADO + ) { + SpreadsheetApp.getUi().alert( + `Selecciona primero una fila en la hoja ${CONFIG_PRESTACIONES.HOJA_CONSOLIDADO}.` + ); + return; + } + + const rangoActivo = hojaActiva.getActiveRange(); + + if (!rangoActivo) { + SpreadsheetApp.getUi().alert( + 'Selecciona una fila de empleado.' + ); + return; + } + + const filaActiva = rangoActivo.getRow(); + + if (filaActiva <= 1) { + SpreadsheetApp.getUi().alert( + 'Selecciona una fila de empleado, no el encabezado.' + ); + return; + } + + const contexto = prepararContextoPrestaciones_(ss); + + const indice = contexto.filas.findIndex( + fila => fila.numeroFila === filaActiva + ); + + if (indice === -1) { + SpreadsheetApp.getUi().alert( + 'La fila seleccionada no contiene un empleado válido.' + ); + return; + } + + const resultado = procesarFilasPrestaciones_( + contexto, + indice, + indice + 1 + ); + + SpreadsheetApp.getUi().alert( + 'Regeneración terminada', + construirResumen_(resultado), + SpreadsheetApp.getUi().ButtonSet.OK + ); +} + + +/** + * Prepara el contexto completo del proyecto: + * - Consolidado. + * - Empleados. + * - CONTROL_ENVIOS. + * - Mapa de columnas. + * - Mapa de filas existentes. + * - Carpeta de salida. + */ +function prepararContextoPrestaciones_(ss) { + const hojaConsolidado = ss.getSheetByName( + CONFIG_PRESTACIONES.HOJA_CONSOLIDADO + ); + + if (!hojaConsolidado) { + throw new Error( + `No existe la hoja ${CONFIG_PRESTACIONES.HOJA_CONSOLIDADO}.` + ); + } + + const valores = hojaConsolidado + .getDataRange() + .getDisplayValues(); + + if (valores.length < 2) { + throw new Error( + 'La hoja Consolidado no contiene empleados.' + ); + } + + const encabezados = valores[0]; + + const colNo = buscarColumna_(encabezados, [ + 'NO.', + 'NO', + 'No.', + 'No' + ]); + + const colNombre = buscarColumna_(encabezados, [ + 'Nombre', + 'NOMBRE', + 'Colaborador', + 'Empleado' + ]); + + const colEmail = buscarColumna_(encabezados, [ + 'Email personal', + 'Email Personal', + 'Correo personal', + 'Correo Personal', + 'Email', + 'Correo' + ], false); + + const colEstatus = buscarColumna_(encabezados, [ + 'Estatus original', + 'Estatus Original', + 'Estatus', + 'Estado', + 'Status' + ]); + + if (colNo === -1) { + throw new Error( + 'No encontré la columna NO. en Consolidado.' + ); + } + + if (colNombre === -1) { + throw new Error( + 'No encontré la columna Nombre en Consolidado.' + ); + } + + if (colEstatus === -1) { + throw new Error( + 'No encontré la columna Estatus en Consolidado.' + ); + } + + const filas = []; + + for (let indice = 1; indice < valores.length; indice++) { + const fila = valores[indice]; + + const no = normalizarNumeroCaso_(fila[colNo]); + + const nombre = String(fila[colNombre] || '') + .replace(/\s+/g, ' ') + .trim(); + + const email = + colEmail >= 0 + ? String(fila[colEmail] || '') + .trim() + .toLowerCase() + : ''; + + const estatusOriginal = String( + fila[colEstatus] || '' + ) + .replace(/\s+/g, ' ') + .trim(); + + if (!no || !nombre) { + continue; + } + + if (normalizarTexto_(nombre) === 'total') { + continue; + } + + filas.push({ + numeroFila: indice + 1, + no, + hoja: + `${CONFIG_PRESTACIONES.PREFIJO_HOJAS_EMPLEADO}${no}`, + nombre, + email, + estatusOriginal + }); + } + + if (!filas.length) { + throw new Error( + 'No encontré filas válidas para procesar.' + ); + } + + const hojaControl = asegurarHojaControl_(ss); + const columnasControl = + obtenerColumnasControl_(hojaControl); + + const mapaControl = construirMapaControl_( + hojaControl, + columnasControl + ); + + const carpeta = obtenerOCrearCarpeta_( + ss, + CONFIG_PRESTACIONES.CARPETA_SALIDA + ); + + return { + ss, + hojaConsolidado, + hojaControl, + columnasControl, + mapaControl, + carpeta, + filas + }; +} + + +/** + * Procesa un tramo del arreglo de empleados. + */ +function procesarFilasPrestaciones_( + contexto, + indiceInicial, + indiceFinal +) { + const resultado = { + procesados: 0, + listos: 0, + pendientes: 0, + noAprobados: 0, + errores: 0, + detalleErrores: [] + }; + + for ( + let indice = indiceInicial; + indice < indiceFinal; + indice++ + ) { + const empleado = contexto.filas[indice]; + + try { + if (!esEstatusAprobado_(empleado.estatusOriginal)) { + actualizarControl_(contexto, { + ...empleado, + pdfFileId: '', + pdfUrl: '', + estado: CONFIG_PRESTACIONES.ESTADO_PENDIENTE, + observacion: + `No se generó PDF porque el Estatus original es "${empleado.estatusOriginal || 'vacío'}".`, + fechaGeneracion: '' + }); + + resultado.procesados++; + resultado.pendientes++; + resultado.noAprobados++; + continue; + } + + const hojaEmpleado = contexto.ss.getSheetByName( + empleado.hoja + ); + + if (!hojaEmpleado) { + throw new Error( + `No existe la hoja ${empleado.hoja}.` + ); + } + + SpreadsheetApp.flush(); + Utilities.sleep(500); + + const nombreArchivo = limpiarNombreArchivo_( + `Prestaciones Laborales - ${empleado.nombre}.pdf` + ); + + const blob = exportarHojaComoPDF_( + contexto.ss, + hojaEmpleado, + nombreArchivo + ); + + const archivo = guardarOReemplazarArchivo_( + contexto.carpeta, + nombreArchivo, + blob + ); + + const esCasoEspecial = + esCasoEspecial_(empleado); + + const tieneEmail = + validarEmail_(empleado.email); + + let estado = + CONFIG_PRESTACIONES.ESTADO_LISTO; + + let observacion = + `PDF generado correctamente desde ${empleado.hoja}.`; + + if (esCasoEspecial) { + estado = + CONFIG_PRESTACIONES.ESTADO_PENDIENTE; + + observacion = + `PDF generado desde ${empleado.hoja}. Caso excluido del envío automático; requiere validación manual.`; + } else if (!tieneEmail) { + estado = + CONFIG_PRESTACIONES.ESTADO_PENDIENTE; + + observacion = + `PDF generado desde ${empleado.hoja}, pero falta o es inválido el Email personal.`; + } + + actualizarControl_(contexto, { + ...empleado, + pdfFileId: archivo.getId(), + pdfUrl: archivo.getUrl(), + estado, + observacion, + fechaGeneracion: new Date() + }); + + resultado.procesados++; + + if ( + estado === + CONFIG_PRESTACIONES.ESTADO_LISTO + ) { + resultado.listos++; + } else { + resultado.pendientes++; + } + } catch (error) { + resultado.errores++; + + resultado.detalleErrores.push( + `${empleado.hoja} - ${empleado.nombre}: ${error.message}` + ); + + actualizarControl_(contexto, { + ...empleado, + pdfFileId: '', + pdfUrl: '', + estado: CONFIG_PRESTACIONES.ESTADO_ERROR, + observacion: + `Error generando PDF: ${error.message}`, + fechaGeneracion: new Date() + }); + } + } + + return resultado; +} + + +/** + * Crea CONTROL_ENVIOS si no existe. + * + * Si la hoja ya existe: + * - No cambia el orden actual. + * - No sobrescribe encabezados existentes. + * - Solo agrega al final cualquier encabezado faltante. + */ +function asegurarHojaControl_(ss) { + let hoja = ss.getSheetByName( + CONFIG_PRESTACIONES.HOJA_CONTROL + ); + + if (!hoja) { + hoja = ss.insertSheet( + CONFIG_PRESTACIONES.HOJA_CONTROL + ); + } + + const ultimaColumna = hoja.getLastColumn(); + + if (ultimaColumna === 0) { + hoja + .getRange( + 1, + 1, + 1, + CONFIG_PRESTACIONES + .ENCABEZADOS_CONTROL + .length + ) + .setValues([ + CONFIG_PRESTACIONES + .ENCABEZADOS_CONTROL + ]); + } else { + const encabezadosActuales = hoja + .getRange(1, 1, 1, ultimaColumna) + .getDisplayValues()[0] + .map(valor => String(valor || '').trim()); + + const normalizados = new Set( + encabezadosActuales.map( + encabezado => + normalizarEncabezado_(encabezado) + ) + ); + + const faltantes = + CONFIG_PRESTACIONES + .ENCABEZADOS_CONTROL + .filter( + encabezado => + !normalizados.has( + normalizarEncabezado_(encabezado) + ) + ); + + if (faltantes.length) { + hoja + .getRange( + 1, + ultimaColumna + 1, + 1, + faltantes.length + ) + .setValues([faltantes]); + } + } + + const totalColumnas = hoja.getLastColumn(); + + hoja + .getRange(1, 1, 1, totalColumnas) + .setFontWeight('bold') + .setBackground('#4F758B') + .setFontColor('#FFFFFF') + .setHorizontalAlignment('center'); + + hoja.setFrozenRows(1); + hoja.autoResizeColumns(1, totalColumnas); + + return hoja; +} + + +/** + * Obtiene los índices reales de las columnas de CONTROL_ENVIOS. + * Los índices devueltos son 1-based. + */ +function obtenerColumnasControl_(hojaControl) { + const ultimaColumna = hojaControl.getLastColumn(); + + if (ultimaColumna < 1) { + throw new Error( + 'CONTROL_ENVIOS no contiene encabezados.' + ); + } + + const encabezados = hojaControl + .getRange(1, 1, 1, ultimaColumna) + .getDisplayValues()[0] + .map(valor => String(valor || '').trim()); + + const obtener = ( + nombres, + etiqueta, + obligatoria = true + ) => { + const indice = buscarColumna_( + encabezados, + nombres, + obligatoria + ); + + if (indice === -1 && obligatoria) { + throw new Error( + `No encontré la columna ${etiqueta} en CONTROL_ENVIOS.` + ); + } + + return indice >= 0 ? indice + 1 : 0; + }; + + return { + no: obtener( + ['NO.', 'NO', 'No'], + 'NO.' + ), + + hoja: obtener( + ['Hoja'], + 'Hoja' + ), + + nombre: obtener( + ['Nombre'], + 'Nombre' + ), + + email: obtener( + [ + 'Email personal', + 'Email Personal', + 'Correo personal' + ], + 'Email personal' + ), + + estatusOriginal: obtener( + [ + 'Estatus original', + 'Estatus Original' + ], + 'Estatus original' + ), + + pdfFileId: obtener( + ['PDF File ID'], + 'PDF File ID' + ), + + pdfUrl: obtener( + ['PDF URL'], + 'PDF URL' + ), + + estadoEnvio: obtener( + [ + 'Estado envío', + 'Estado Envio' + ], + 'Estado envío' + ), + + fechaGeneracion: obtener( + [ + 'Fecha generación', + 'Fecha generacion' + ], + 'Fecha generación' + ), + + observacion: obtener( + [ + 'Observación', + 'Observacion' + ], + 'Observación' + ), + + fechaEnvio: obtener( + [ + 'Fecha envío', + 'Fecha envio' + ], + 'Fecha envío', + false + ), + + gmailId: obtener( + ['Gmail ID'], + 'Gmail ID', + false + ), + + ultimaActualizacion: obtener( + [ + 'Última actualización', + 'Ultima actualizacion' + ], + 'Última actualización', + false + ) + }; +} + + +/** + * Construye un mapa para encontrar rápidamente + * una fila de CONTROL_ENVIOS por NO. o por Hoja. + */ +function construirMapaControl_( + hojaControl, + columnas +) { + const ultimaFila = hojaControl.getLastRow(); + const mapa = new Map(); + + if (ultimaFila < 2) { + return mapa; + } + + const ultimaColumna = + hojaControl.getLastColumn(); + + const valores = hojaControl + .getRange( + 2, + 1, + ultimaFila - 1, + ultimaColumna + ) + .getDisplayValues(); + + valores.forEach((fila, indice) => { + const no = normalizarNumeroCaso_( + fila[columnas.no - 1] + ); + + const hoja = String( + fila[columnas.hoja - 1] || '' + ).trim(); + + if (no) { + mapa.set( + `NO:${no}`, + indice + 2 + ); + } + + if (hoja) { + mapa.set( + `HOJA:${normalizarTexto_(hoja)}`, + indice + 2 + ); + } + }); + + return mapa; +} + + +/** + * Inserta o actualiza la fila correspondiente + * en CONTROL_ENVIOS sin alterar otras columnas. + */ +function actualizarControl_(contexto, datos) { + const claveNo = `NO:${datos.no}`; + + const claveHoja = + `HOJA:${normalizarTexto_(datos.hoja)}`; + + let filaControl = + contexto.mapaControl.get(claveNo) || + contexto.mapaControl.get(claveHoja) || + 0; + + if (!filaControl) { + filaControl = Math.max( + contexto.hojaControl.getLastRow() + 1, + 2 + ); + } + + const ultimaColumna = + contexto.hojaControl.getLastColumn(); + + const rango = contexto.hojaControl.getRange( + filaControl, + 1, + 1, + ultimaColumna + ); + + const existente = rango.getValues()[0]; + + const columnas = contexto.columnasControl; + + const valorExistente = columna => + columna > 0 + ? existente[columna - 1] + : ''; + + const estadoExistente = String( + valorExistente(columnas.estadoEnvio) || '' + ) + .trim() + .toUpperCase(); + + let estadoFinal = datos.estado; + let observacionFinal = datos.observacion; + + if ( + estadoExistente === + CONFIG_PRESTACIONES.ESTADO_ENVIADO && + !datos.forzarEstado + ) { + estadoFinal = + CONFIG_PRESTACIONES.ESTADO_ENVIADO; + + observacionFinal = + `${datos.observacion} El registro ya estaba marcado como ENVIADO y se conservó ese estado.`; + } + + const valores = existente.slice(); + + const asignar = (columna, valor) => { + if (columna > 0) { + valores[columna - 1] = valor; + } + }; + + asignar( + columnas.no, + datos.no + ); + + asignar( + columnas.hoja, + datos.hoja + ); + + asignar( + columnas.nombre, + datos.nombre + ); + + asignar( + columnas.email, + datos.email || '' + ); + + asignar( + columnas.estatusOriginal, + datos.estatusOriginal || '' + ); + + asignar( + columnas.pdfFileId, + datos.pdfFileId || '' + ); + + asignar( + columnas.pdfUrl, + datos.pdfUrl || '' + ); + + asignar( + columnas.estadoEnvio, + estadoFinal + ); + + asignar( + columnas.fechaGeneracion, + datos.fechaGeneracion || '' + ); + + asignar( + columnas.observacion, + observacionFinal || '' + ); + + if (columnas.ultimaActualizacion > 0) { + asignar( + columnas.ultimaActualizacion, + new Date() + ); + } + + rango.setValues([valores]); + + aplicarFormatoFechasControl_( + contexto.hojaControl, + filaControl, + columnas + ); + + contexto.mapaControl.set( + claveNo, + filaControl + ); + + contexto.mapaControl.set( + claveHoja, + filaControl + ); +} + + +/** + * Formatea las columnas de fecha disponibles + * en CONTROL_ENVIOS. + */ +function aplicarFormatoFechasControl_( + hojaControl, + fila, + columnas +) { + const columnasFecha = [ + columnas.fechaGeneracion, + columnas.fechaEnvio, + columnas.ultimaActualizacion + ].filter(columna => columna > 0); + + columnasFecha.forEach(columna => { + hojaControl + .getRange(fila, columna) + .setNumberFormat( + 'yyyy-mm-dd hh:mm:ss' + ); + }); +} + + +/** + * Exporta una sola hoja a PDF. + */ +function exportarHojaComoPDF_( + spreadsheet, + hoja, + nombreArchivo +) { + const parametros = [ + 'format=pdf', + 'size=letter', + 'portrait=true', + 'fitw=true', + 'sheetnames=false', + 'printtitle=false', + 'pagenumbers=false', + 'gridlines=false', + 'fzr=false', + 'attachment=true', + 'top_margin=0.25', + 'bottom_margin=0.25', + 'left_margin=0.25', + 'right_margin=0.25', + `gid=${hoja.getSheetId()}` + ].join('&'); + + const url = + `https://docs.google.com/spreadsheets/d/${spreadsheet.getId()}/export?${parametros}`; + + const respuesta = UrlFetchApp.fetch( + url, + { + method: 'get', + headers: { + Authorization: + `Bearer ${ScriptApp.getOAuthToken()}` + }, + muteHttpExceptions: true + } + ); + + const codigo = + respuesta.getResponseCode(); + + if (codigo !== 200) { + throw new Error( + `Google devolvió HTTP ${codigo} al exportar ${hoja.getName()} como PDF.` + ); + } + + return respuesta + .getBlob() + .setContentType('application/pdf') + .setName(nombreArchivo); +} + + +/** + * Busca o crea la carpeta de salida + * dentro de la misma carpeta del Google Sheet. + */ +function obtenerOCrearCarpeta_( + spreadsheet, + nombreCarpeta +) { + const archivoSpreadsheet = + DriveApp.getFileById( + spreadsheet.getId() + ); + + const padres = + archivoSpreadsheet.getParents(); + + const carpetaPadre = + padres.hasNext() + ? padres.next() + : DriveApp.getRootFolder(); + + const carpetas = + carpetaPadre.getFoldersByName( + nombreCarpeta + ); + + if (carpetas.hasNext()) { + return carpetas.next(); + } + + return carpetaPadre.createFolder( + nombreCarpeta + ); +} + + +/** + * Elimina de Drive las versiones anteriores + * con el mismo nombre y crea el archivo nuevo. + */ +function guardarOReemplazarArchivo_( + carpeta, + nombreArchivo, + blob +) { + const anteriores = + carpeta.getFilesByName( + nombreArchivo + ); + + while (anteriores.hasNext()) { + anteriores + .next() + .setTrashed(true); + } + + return carpeta + .createFile(blob) + .setName(nombreArchivo); +} + + +/** + * Busca una columna ignorando: + * - Mayúsculas. + * - Minúsculas. + * - Tildes. + * - Espacios. + * - Signos. + * + * Devuelve un índice 0-based. + */ +function buscarColumna_( + encabezados, + nombresPosibles, + obligatoria = true +) { + const normalizados = + encabezados.map( + encabezado => + normalizarEncabezado_(encabezado) + ); + + for (const nombre of nombresPosibles) { + const indice = + normalizados.indexOf( + normalizarEncabezado_(nombre) + ); + + if (indice !== -1) { + return indice; + } + } + + return obligatoria ? -1 : -1; +} + + +/** + * Normaliza encabezados. + */ +function normalizarEncabezado_(valor) { + return String(valor ?? '') + .trim() + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]/g, ''); +} + + +/** + * Limpia un nombre para usarlo como archivo. + */ +function limpiarNombreArchivo_(nombre) { + return String(nombre || '') + .replace(/[\\/:*?"<>|]/g, '-') + .replace(/[\u0000-\u001F\u007F]/g, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 180); +} + + +/** + * Normaliza valores de NO. como: + * - 1 + * - 1.0 + * - "1" + */ +function normalizarNumeroCaso_(valor) { + const texto = + String(valor ?? '').trim(); + + if (!texto) { + return ''; + } + + const numero = Number( + texto.replace(',', '.') + ); + + if (Number.isFinite(numero)) { + return String( + Math.trunc(numero) + ); + } + + return texto.replace(/\.0+$/, ''); +} + + +/** + * Normalización general de texto. + */ +function normalizarTexto_(valor) { + return String(valor ?? '') + .trim() + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]/g, ''); +} + + +/** + * Indica si el estatus permite generar PDF. + */ +function esEstatusAprobado_(estatus) { + const normalizado = + normalizarTexto_(estatus); + + return ( + normalizado === 'aprobada' || + normalizado === 'aprobado' || + normalizado === 'approved' + ); +} + + +/** + * Validación básica de correo. + */ +function validarEmail_(email) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/i.test( + String(email || '').trim() + ); +} + + +/** + * Casos que nunca deben quedar listos + * para envío automático. + */ +function esCasoEspecial_(empleado) { + const nombre = + normalizarTexto_(empleado.nombre); + + return ( + nombre.includes( + 'irmaalejandraventura' + ) || + String(empleado.hoja || '') + .toUpperCase() === 'L29' + ); +} + + +/** + * Construye el resumen mostrado al finalizar. + */ +function construirResumen_(resultado) { + let mensaje = + `Procesados: ${resultado.procesados}\n` + + `Listos para enviar: ${resultado.listos}\n` + + `Pendientes de validación: ${resultado.pendientes}\n` + + `No aprobados: ${resultado.noAprobados}\n` + + `Errores: ${resultado.errores}`; + + if (resultado.detalleErrores.length) { + mensaje += + '\n\nDetalle de errores:\n' + + resultado.detalleErrores + .slice(0, 10) + .join('\n'); + + if ( + resultado.detalleErrores.length > 10 + ) { + mensaje += + `\n... y ${resultado.detalleErrores.length - 10} error(es) adicional(es).`; + } + } + + return mensaje; +} + + +/** + * ============================================================ + * REGENERACIÓN ESPECIAL DE LILIAN JEANNETTE / L43 + * ============================================================ + * + * Esta función: + * - Exporta la pestaña L43 nueva/modificada. + * - Reemplaza el PDF anterior con el mismo nombre. + * - Marca Estatus original = Aprobada. + * - Marca Estado envío = LISTO_PARA_ENVIAR. + */ +function regenerarPDFLilianL43() { + const ss = + SpreadsheetApp.getActiveSpreadsheet(); + + const nombreHojaCaso = 'L43'; + + const nombreEmpleado = + 'Lilian Jeannette Revolorio Perez'; + + const hojaCaso = + ss.getSheetByName(nombreHojaCaso); + + if (!hojaCaso) { + throw new Error( + 'No existe la hoja L43. Revisa que la pestaña nueva se llame exactamente L43.' + ); + } + + const contexto = + prepararContextoPrestaciones_(ss); + + const empleadoConsolidado = + contexto.filas.find( + empleado => + normalizarNumeroCaso_( + empleado.no + ) === '43' || + normalizarTexto_( + empleado.hoja + ) === 'l43' || + normalizarTexto_( + empleado.nombre + ) === normalizarTexto_( + nombreEmpleado + ) + ); + + const filaControlExistente = + contexto.mapaControl.get('NO:43') || + contexto.mapaControl.get('HOJA:l43') || + 0; + + let email = empleadoConsolidado + ? empleadoConsolidado.email + : ''; + + if ( + !email && + filaControlExistente + ) { + email = String( + contexto.hojaControl + .getRange( + filaControlExistente, + contexto.columnasControl.email + ) + .getDisplayValue() || '' + ) + .trim() + .toLowerCase(); + } + + if (!email) { + throw new Error( + 'La fila de Lilian no tiene Email personal.' + ); + } + + SpreadsheetApp.flush(); + Utilities.sleep(500); + + const nombrePDF = + `${nombreHojaCaso} - ${limpiarNombreArchivo_(nombreEmpleado)}.pdf`; + + const pdfBlob = + exportarHojaComoPDFL43_( + ss.getId(), + hojaCaso.getSheetId(), + nombrePDF + ); + + const archivoPDF = + guardarOReemplazarArchivo_( + contexto.carpeta, + nombrePDF, + pdfBlob + ); + + actualizarControl_(contexto, { + no: '43', + hoja: nombreHojaCaso, + nombre: nombreEmpleado, + email, + estatusOriginal: 'Aprobada', + pdfFileId: archivoPDF.getId(), + pdfUrl: archivoPDF.getUrl(), + estado: + CONFIG_PRESTACIONES.ESTADO_LISTO, + observacion: + 'PDF regenerado desde la pestaña L43 nueva/modificada. Listo para envío.', + fechaGeneracion: new Date(), + forzarEstado: true + }); + + contexto.hojaControl.autoResizeColumns( + 1, + contexto.hojaControl.getLastColumn() + ); + + SpreadsheetApp.getUi().alert( + 'Listo. Se regeneró el PDF de Lilian L43 y se actualizó CONTROL_ENVIOS.' + ); +} + + +/** + * Exportación especial usada por L43. + */ +function exportarHojaComoPDFL43_( + spreadsheetId, + sheetId, + nombrePDF +) { + const urlBase = + `https://docs.google.com/spreadsheets/d/${spreadsheetId}/export`; + + const parametros = { + format: 'pdf', + gid: sheetId, + size: 'letter', + portrait: 'true', + scale: '4', + sheetnames: 'false', + printtitle: 'false', + pagenumbers: 'false', + gridlines: 'false', + fzr: 'false', + top_margin: '0.25', + bottom_margin: '0.25', + left_margin: '0.25', + right_margin: '0.25', + horizontal_alignment: 'CENTER', + vertical_alignment: 'TOP' + }; + + const queryString = + Object.keys(parametros) + .map( + key => + `${key}=${encodeURIComponent(parametros[key])}` + ) + .join('&'); + + const url = + `${urlBase}?${queryString}`; + + const respuesta = + UrlFetchApp.fetch( + url, + { + headers: { + Authorization: + `Bearer ${ScriptApp.getOAuthToken()}` + }, + muteHttpExceptions: true + } + ); + + const codigo = + respuesta.getResponseCode(); + + if (codigo !== 200) { + throw new Error( + `Error exportando PDF de L43. Código HTTP: ${codigo}` + ); + } + + return respuesta + .getBlob() + .setContentType('application/pdf') + .setName(nombrePDF); +}