1535 lines
30 KiB
JavaScript
1535 lines
30 KiB
JavaScript
/**
|
|
* ============================================================
|
|
* 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);
|
|
}
|