Scan to open on your phone
Point a camera at the QR code - it opens this exact paste, no app needed.
https://codepastes.com/zzgs86psy3rrqsknCodePlain Text
const ID_HOJA = '1a1e7ah08zijh5FSBGKcYvTWVCj-mTjSw8cA4HNBwrYc';
function configurarInventario() {
const libro = SpreadsheetApp.openById(ID_HOJA);
const estructura = {
PRODUCTOS: [
'ID', 'Código', 'Producto', 'Categoría', 'Unidad',
'Stock actual', 'Stock mínimo', 'Costo unitario',
'Activo', 'Fecha de creación'
],
MOVIMIENTOS: [
'ID movimiento', 'Fecha y hora', 'Usuario', 'Tipo',
'Código producto', 'Producto', 'Cantidad',
'Stock anterior', 'Stock resultante', 'Motivo'
],
CATEGORIAS: ['ID', 'Categoría', 'Descripción', 'Activa'],
CONFIGURACION: ['Parámetro', 'Valor']
};
for (const nombre in estructura) {
let hoja = libro.getSheetByName(nombre);
if (!hoja) hoja = libro.insertSheet(nombre);
if (hoja.getLastRow() === 0) {
hoja.getRange(1, 1, 1, estructura[nombre].length)
.setValues([estructura[nombre]]);
}
hoja.getRange(1, 1, 1, estructura[nombre].length)
.setFontWeight('bold');
hoja.setFrozenRows(1);
hoja.autoResizeColumns(1, estructura[nombre].length);
}
Logger.log('Estructura de inventario verificada.');
}
function doGet() {
return HtmlService.createHtmlOutputFromFile('Index')
.setTitle('Chechare Wash | Sistema de gestión');
}
function obtenerProductos() {
const hoja = SpreadsheetApp.openById(ID_HOJA)
.getSheetByName('PRODUCTOS');
if (!hoja) throw new Error('No se encontró la hoja PRODUCTOS.');
const datos = hoja.getDataRange().getDisplayValues();
if (datos.length <= 1) return [];
return datos.slice(1)
.filter(fila => fila[0] !== '')
.map(fila => ({
id: fila[0],
codigo: fila[1],
producto: fila[2],
categoria: fila[3],
unidad: fila[4],
stockActual: fila[5],
stockMinimo: fila[6],
activo: fila[8]
}));
}
function registrarProducto(producto) {
if (!producto || typeof producto !== 'object') {
throw new Error('Los datos del producto no son válidos.');
}
const codigo = String(producto.codigo || '').trim();
const nombre = String(producto.nombre || '').trim();
const categoria = String(producto.categoria || '').trim();
const unidad = String(producto.unidad || '').trim();
const stockInicial = Number(producto.stockInicial ?? 0);
const stockMinimo = Number(producto.stockMinimo ?? 0);
if (!codigo || !nombre || !categoria || !unidad) {
throw new Error('Completa código, nombre, categoría y unidad.');
}
if (!Number.isFinite(stockInicial) || stockInicial < 0) {
throw new Error('El stock inicial no puede ser negativo.');
}
if (!Number.isFinite(stockMinimo) || stockMinimo < 0) {
throw new Error('El stock mínimo no puede ser negativo.');
}
const libro = SpreadsheetApp.openById(ID_HOJA);
const hoja = libro.getSheetByName('PRODUCTOS');
const movimientos = libro.getSheetByName('MOVIMIENTOS');
if (!hoja || !movimientos) {
throw new Error('Faltan las hojas PRODUCTOS o MOVIMIENTOS.');
}
const bloqueo = LockService.getScriptLock();
bloqueo.waitLock(30000);
try {
const ultimaFila = hoja.getLastRow();
if (ultimaFila > 1) {
const codigos = hoja.getRange(2, 2, ultimaFila - 1, 1)
.getDisplayValues()
.flat()
.map(valor => valor.trim().toLowerCase());
if (codigos.includes(codigo.toLowerCase())) {
throw new Error('Ya existe un producto con ese código.');
}
}
const id = Utilities.getUuid();
const fecha = new Date();
hoja.appendRow([
id, codigo, nombre, categoria, unidad,
stockInicial, stockMinimo, 0, true, fecha
]);
movimientos.appendRow([
Utilities.getUuid(), fecha,
Session.getActiveUser().getEmail() || 'Administrador',
'Stock inicial', codigo, nombre, stockInicial,
0, stockInicial, 'Stock inicial al registrar el producto'
]);
return {
correcto: true,
mensaje: 'Producto registrado correctamente.',
id: id
};
} finally {
bloqueo.releaseLock();
}
}
/**
* Registra una entrada directa de inventario.
*/
function registrarEntrada(codigoProducto, cantidad, motivo) {
const codigo = String(codigoProducto || '').trim();
const valor = Number(cantidad);
const detalle = String(motivo || '').trim();
if (!codigo) throw new Error('Selecciona un producto.');
if (!Number.isFinite(valor) || valor <= 0) {
throw new Error('La cantidad de entrada debe ser mayor que cero.');
}
return actualizarStock_(
codigo,
valor,
'Entrada',
detalle || 'Entrada de inventario',
true
);
}
/**
* Registra un consumo de inventario.
*/
function registrarConsumo(codigoProducto, cantidad, motivo) {
const codigo = String(codigoProducto || '').trim();
const valor = Number(cantidad);
const detalle = String(motivo || '').trim();
if (!codigo) throw new Error('Selecciona un producto.');
if (!Number.isFinite(valor) || valor <= 0) {
throw new Error('La cantidad a consumir debe ser mayor que cero.');
}
if (!detalle) throw new Error('Indica el motivo del consumo.');
return actualizarStock_(
codigo,
valor,
'Consumo',
detalle,
false
);
}
function actualizarStock_(codigo, cantidad, tipo, motivo, esEntrada) {
const bloqueo = LockService.getScriptLock();
bloqueo.waitLock(30000);
try {
const libro = SpreadsheetApp.openById(ID_HOJA);
const hoja = libro.getSheetByName('PRODUCTOS');
const movimientos = libro.getSheetByName('MOVIMIENTOS');
if (!hoja || !movimientos) {
throw new Error('Faltan las hojas PRODUCTOS o MOVIMIENTOS.');
}
const ultimaFila = hoja.getLastRow();
if (ultimaFila < 2) {
throw new Error('Todavía no hay productos registrados.');
}
const datos = hoja.getRange(2, 1, ultimaFila - 1, 10).getValues();
const buscado = codigo.toLowerCase();
let filaProducto = -1;
let producto = null;
for (let i = 0; i < datos.length; i++) {
if (String(datos[i][1]).trim().toLowerCase() === buscado) {
filaProducto = i + 2;
producto = datos[i];
break;
}
}
if (filaProducto === -1) {
throw new Error('No se encontró el producto: ' + codigo);
}
const nombre = String(producto[2] || '');
const unidad = String(producto[4] || '');
const stockAnterior = Number(producto[5]);
const activo = producto[8];
if (activo === false || String(activo).toLowerCase() === 'false') {
throw new Error('Este producto está inactivo.');
}
if (!Number.isFinite(stockAnterior) || stockAnterior < 0) {
throw new Error('El stock actual no es válido.');
}
if (!esEntrada && cantidad > stockAnterior) {
throw new Error(
'Stock insuficiente. Disponible: ' +
stockAnterior + ' ' + unidad + '.'
);
}
const stockResultante = esEntrada
? stockAnterior + cantidad
: stockAnterior - cantidad;
const fecha = new Date();
const usuario = Session.getActiveUser().getEmail() || 'Administrador';
hoja.getRange(filaProducto, 6).setValue(stockResultante);
movimientos.appendRow([
Utilities.getUuid(),
fecha,
usuario,
tipo,
String(producto[1]),
nombre,
cantidad,
stockAnterior,
stockResultante,
motivo
]);
return {
correcto: true,
mensaje: tipo + ' registrada correctamente.',
codigo: String(producto[1]),
producto: nombre,
cantidad: cantidad,
unidad: unidad,
stockAnterior: stockAnterior,
stockResultante: stockResultante
};
} finally {
bloqueo.releaseLock();
}
}
/**
* Devuelve el historial de movimientos de inventario, del más reciente al más antiguo.
* Usa la hoja MOVIMIENTOS existente y no modifica su estructura.
*/
function obtenerHistorialMovimientos() {
const libro = SpreadsheetApp.openById(ID_HOJA);
const hoja = libro.getSheetByName('MOVIMIENTOS');
if (!hoja) throw new Error('No se encontró la hoja MOVIMIENTOS.');
const ultimaFila = hoja.getLastRow();
if (ultimaFila < 2) return [];
const datos = hoja.getRange(2, 1, ultimaFila - 1, 10).getValues();
const zonaHoraria = Session.getScriptTimeZone();
return datos.map(function(fila) {
const fecha = fila[1];
return {
fechaHora: fecha instanceof Date
? Utilities.formatDate(fecha, zonaHoraria, 'dd/MM/yyyy HH:mm:ss')
: String(fecha || ''),
tipo: String(fila[3] || ''),
codigo: String(fila[4] || ''),
producto: String(fila[5] || ''),
cantidad: fila[6],
stockAnterior: fila[7],
stockResultante: fila[8],
motivo: String(fila[9] || '')
};
}).reverse();
}
function enviarNotificacion(asunto, mensaje) {
MailApp.sendEmail({
to: 'turnoschechare@gmail.com',
subject: '[Chechare Wash] ' + asunto,
body: mensaje
});
return {
correcto: true,
mensaje: 'Notificación enviada correctamente.'
};
}
function convertirAUnidadBase(cantidad, contenidoPorUnidad) {
const cantidadNum = Number(cantidad);
const contenidoNum = Number(contenidoPorUnidad);
if (!Number.isFinite(cantidadNum) || cantidadNum <= 0) {
throw new Error('La cantidad debe ser mayor que cero.');
}
if (!Number.isFinite(contenidoNum) || contenidoNum <= 0) {
throw new Error('El contenido debe ser mayor que cero.');
}
return cantidadNum * contenidoNum;
}
/**
* Devuelve la columna de categoría de SOLICITUDES.
* Si no existe, agrega el encabezado al final sin sobrescribir columnas.
*/
function obtenerColumnaCategoriaSolicitud_(hoja) {
const ultimaColumna = Math.max(hoja.getLastColumn(), 1);
const encabezados = hoja.getRange(1, 1, 1, ultimaColumna).getDisplayValues()[0];
const existente = encabezados.findIndex(valor =>
String(valor || '').trim().toLowerCase() === 'categoría' ||
String(valor || '').trim().toLowerCase() === 'categoria'
);
if (existente !== -1) return existente + 1;
const nuevaColumna = ultimaColumna + 1;
hoja.getRange(1, nuevaColumna).setValue('Categoría').setFontWeight('bold');
return nuevaColumna;
}
/**
* Registra una nota enviada desde la aplicación.
*/
function registrarSolicitud(mensaje, nombrePersona, categoria) {
mensaje = String(mensaje || '').trim();
nombrePersona = String(nombrePersona || '').trim();
categoria = String(categoria || '').trim();
const categoriasPermitidas = [
'Stock', 'Información', 'Máquina', 'Personal', 'Incentivos / bonos', 'Otro'
];
if (!mensaje) throw new Error('Escribe una nota antes de enviarla.');
if (mensaje.length > 2000) throw new Error('La nota no puede superar los 2000 caracteres.');
if (!categoriasPermitidas.includes(categoria)) throw new Error('Selecciona una categoría válida.');
const bloqueo = LockService.getScriptLock();
bloqueo.waitLock(30000);
try {
const libro = SpreadsheetApp.openById(ID_HOJA);
let hoja = libro.getSheetByName('SOLICITUDES');
if (!hoja) {
hoja = libro.insertSheet('SOLICITUDES');
hoja.appendRow(['Fecha y hora', 'Persona', 'Nota', 'Estado']);
hoja.setFrozenRows(1);
hoja.getRange(1, 1, 1, 4).setFontWeight('bold');
}
const columnaCategoria = obtenerColumnaCategoriaSolicitud_(hoja);
hoja.appendRow([
new Date(),
nombrePersona || 'Sin identificar',
mensaje,
'Pendiente'
]);
hoja.getRange(hoja.getLastRow(), columnaCategoria).setValue(categoria);
} finally {
bloqueo.releaseLock();
}
MailApp.sendEmail({
to: 'turnoschechare@gmail.com',
subject: '[Chechare Wash] Nueva solicitud - ' + categoria,
body:
'Nueva nota recibida.\n\n' +
'Categoría: ' + categoria + '\n' +
'Persona: ' + (nombrePersona || 'Sin identificar') + '\n' +
'Nota: ' + mensaje
});
return {
correcto: true,
mensaje: 'Tu nota fue registrada y enviada al administrador.'
};
}
/**
* Obtiene las notas para mostrarlas en la bandeja.
* Las más recientes aparecen primero.
*/
function obtenerSolicitudes() {
const libro = SpreadsheetApp.openById(ID_HOJA);
let hoja = libro.getSheetByName('SOLICITUDES');
if (!hoja) {
hoja = libro.insertSheet('SOLICITUDES');
hoja.appendRow(['Fecha y hora', 'Persona', 'Nota', 'Estado']);
hoja.setFrozenRows(1);
hoja.getRange(1, 1, 1, 4).setFontWeight('bold');
return [];
}
const ultimaFila = hoja.getLastRow();
if (ultimaFila < 2) return [];
const ultimaColumna = Math.max(hoja.getLastColumn(), 4);
const encabezados = hoja.getRange(1, 1, 1, ultimaColumna).getDisplayValues()[0];
const indiceCategoria = encabezados.findIndex(valor =>
String(valor || '').trim().toLowerCase() === 'categoría' ||
String(valor || '').trim().toLowerCase() === 'categoria'
);
const datos = hoja.getRange(2, 1, ultimaFila - 1, ultimaColumna).getValues();
return datos.map((fila, indice) => ({
fila: indice + 2,
fecha: fila[0] instanceof Date
? Utilities.formatDate(fila[0], Session.getScriptTimeZone(), 'dd/MM/yyyy HH:mm')
: String(fila[0] || ''),
persona: String(fila[1] || 'Sin identificar'),
nota: String(fila[2] || ''),
estado: String(fila[3] || 'Pendiente'),
categoria: indiceCategoria >= 0 ? String(fila[indiceCategoria] || '') : ''
})).reverse();
}
/**
* Cambia el estado de una nota a Pendiente o Atendida.
*/
function actualizarEstadoSolicitud(numeroFila, nuevoEstado) {
const fila = Number(numeroFila);
const estado = String(nuevoEstado || '').trim();
if (!Number.isInteger(fila) || fila < 2) {
throw new Error('La fila de la solicitud no es válida.');
}
if (!['Pendiente', 'Atendida'].includes(estado)) {
throw new Error('El estado seleccionado no es válido.');
}
const bloqueo = LockService.getScriptLock();
bloqueo.waitLock(30000);
try {
const libro = SpreadsheetApp.openById(ID_HOJA);
const hoja = libro.getSheetByName('SOLICITUDES');
if (!hoja || fila > hoja.getLastRow()) {
throw new Error('No se encontró la solicitud seleccionada.');
}
const nota = String(hoja.getRange(fila, 3).getValue() || '').trim();
if (!nota) {
throw new Error('La fila seleccionada no contiene una nota.');
}
hoja.getRange(fila, 4).setValue(estado);
SpreadsheetApp.flush();
return {
correcto: true,
mensaje: 'La solicitud ahora está marcada como ' + estado + '.',
fila: fila,
estado: estado
};
} finally {
bloqueo.releaseLock();
}
}
// ==================== MÓDULO DE PERSONAL / LAVADORES ====================
const DOCUMENTOS_OBLIGATORIOS = [
'Licencia de conducir',
'Seguro',
'Certificado de antecedentes',
'Carnet de lavador'
];
const CORREO_ALERTAS_PERSONAL = 'turnoschechare@gmail.com';
const NOMBRE_CARPETA_PERSONAL = 'Chechare Wash - Documentación de lavadores';
function configurarPersonal() {
const libro = SpreadsheetApp.openById(ID_HOJA);
const estructuras = {
LAVADORES: ['ID lavador', 'Nombre completo', 'Teléfono', 'Fecha de alta', 'Activo', 'Fecha de ingreso laboral', 'Aplica incentivo semanal', 'Motivo no aplica'],
DOCUMENTOS_LAVADORES: [
'ID documento', 'ID lavador', 'Nombre lavador', 'Tipo documento',
'Fecha de carga', 'Fecha de emisión', 'Fecha de vencimiento', 'No vence',
'ID archivo Drive', 'URL archivo', 'Nombre archivo', 'Avisos enviados'
],
APLICABILIDAD_DOCUMENTOS: ['ID lavador', 'Tipo documento', 'Requerido']
};
Object.keys(estructuras).forEach(nombre => {
let hoja = libro.getSheetByName(nombre);
if (!hoja) hoja = libro.insertSheet(nombre);
const encabezados = estructuras[nombre];
if (hoja.getLastRow() === 0) {
hoja.getRange(1, 1, 1, encabezados.length).setValues([encabezados]);
} else {
const actuales = hoja.getRange(
1, 1, 1, Math.max(hoja.getLastColumn(), encabezados.length)
).getDisplayValues()[0];
encabezados.forEach((valor, i) => {
if (!actuales[i]) hoja.getRange(1, i + 1).setValue(valor);
});
}
hoja.getRange(1, 1, 1, encabezados.length).setFontWeight('bold');
hoja.setFrozenRows(1);
hoja.autoResizeColumns(1, encabezados.length);
});
// La carpeta se crea una sola vez y su ID queda guardado en las propiedades del proyecto.
obtenerOCrearCarpetaPersonal_();
// Evita instalar varios activadores diarios iguales.
ScriptApp.getProjectTriggers()
.filter(t => t.getHandlerFunction() === 'revisarVencimientosLavadores')
.forEach(t => ScriptApp.deleteTrigger(t));
ScriptApp.newTrigger('revisarVencimientosLavadores')
.timeBased().everyDays(1).atHour(8).create();
return {
correcto: true,
mensaje: 'Módulo de Personal configurado. Se crearon/verificaron las hojas y el aviso diario.'
};
}
function obtenerOCrearCarpetaPersonal_() {
const propiedades = PropertiesService.getScriptProperties();
const idGuardado = propiedades.getProperty('CARPETA_PERSONAL_ID');
if (idGuardado) {
try {
return DriveApp.getFolderById(idGuardado);
} catch (e) {}
}
const carpetas = DriveApp.getFoldersByName(NOMBRE_CARPETA_PERSONAL);
const carpeta = carpetas.hasNext()
? carpetas.next()
: DriveApp.createFolder(NOMBRE_CARPETA_PERSONAL);
propiedades.setProperty('CARPETA_PERSONAL_ID', carpeta.getId());
return carpeta;
}
function obtenerLavadores() {
const libro = SpreadsheetApp.openById(ID_HOJA);
const hoja = libro.getSheetByName('LAVADORES');
if (!hoja || hoja.getLastRow() < 2) return [];
const ultimaColumna = Math.max(hoja.getLastColumn(), 8);
return hoja.getRange(2, 1, hoja.getLastRow() - 1, ultimaColumna).getValues()
.filter(f => f[0])
.map(f => ({
id: String(f[0]),
nombre: String(f[1] || ''),
telefono: String(f[2] || ''),
fechaAlta: f[3] instanceof Date
? Utilities.formatDate(f[3], Session.getScriptTimeZone(), 'yyyy-MM-dd')
: String(f[3] || ''),
activo: String(f[4]).toLowerCase() !== 'false',
fechaIngreso: f[5] instanceof Date
? Utilities.formatDate(f[5], Session.getScriptTimeZone(), 'yyyy-MM-dd')
: String(f[5] || ''),
antiguedad: calcularAntiguedad_(f[5]),
aplicaIncentivo: String(f[6]).toLowerCase() !== 'false',
motivoNoAplica: String(f[7] || '')
}));
}
function registrarLavador(datos) {
datos = datos || {};
const nombre = String(datos.nombre || '').trim();
const telefono = String(datos.telefono || '').trim();
const fechaIngreso = datos.fechaIngreso ? parsearFechaLocal_(datos.fechaIngreso) : '';
if (!nombre) throw new Error('Escribe el nombre completo del lavador.');
const libro = SpreadsheetApp.openById(ID_HOJA);
let hoja = libro.getSheetByName('LAVADORES');
if (!hoja) {
configurarPersonal();
hoja = libro.getSheetByName('LAVADORES');
}
const id = Utilities.getUuid();
hoja.appendRow([id, nombre, telefono, new Date(), true, fechaIngreso || '', true, '']);
return {
correcto: true,
mensaje: 'Lavador registrado. Ahora puedes cargar sus documentos.',
id: id
};
}
function obtenerMapaAplicabilidadDocumentos_(idLavador) {
const hoja = SpreadsheetApp.openById(ID_HOJA).getSheetByName('APLICABILIDAD_DOCUMENTOS');
const mapa = {};
if (!hoja || hoja.getLastRow() < 2) return mapa;
hoja.getRange(2, 1, hoja.getLastRow() - 1, 3).getValues().forEach(f => {
if (String(f[0]) === String(idLavador)) {
mapa[String(f[1])] = String(f[2]).toLowerCase() !== 'false';
}
});
return mapa;
}
function guardarAplicabilidadDocumento(idLavador, tipo, requerido) {
idLavador = String(idLavador || '').trim();
tipo = String(tipo || '').trim();
requerido = requerido === true || String(requerido).toLowerCase() === 'true';
if (!idLavador) throw new Error('Selecciona un lavador.');
if (!DOCUMENTOS_OBLIGATORIOS.includes(tipo)) throw new Error('El tipo de documento no es válido.');
if (!obtenerLavadores().some(l => l.id === idLavador)) throw new Error('No se encontró el lavador.');
const libro = SpreadsheetApp.openById(ID_HOJA);
let hoja = libro.getSheetByName('APLICABILIDAD_DOCUMENTOS');
if (!hoja) {
hoja = libro.insertSheet('APLICABILIDAD_DOCUMENTOS');
hoja.appendRow(['ID lavador', 'Tipo documento', 'Requerido']);
}
const filas = hoja.getLastRow() > 1 ? hoja.getRange(2, 1, hoja.getLastRow() - 1, 3).getValues() : [];
const indice = filas.findIndex(f => String(f[0]) === idLavador && String(f[1]) === tipo);
const registro = [idLavador, tipo, requerido];
if (indice >= 0) hoja.getRange(indice + 2, 1, 1, 3).setValues([registro]);
else hoja.appendRow(registro);
return { correcto: true, mensaje: requerido ? 'Documento marcado como obligatorio.' : 'Documento marcado como No aplica.' };
}
function obtenerDocumentosLavador(idLavador) {
idLavador = String(idLavador || '').trim();
if (!idLavador) throw new Error('Selecciona un lavador.');
const libro = SpreadsheetApp.openById(ID_HOJA);
const hoja = libro.getSheetByName('DOCUMENTOS_LAVADORES');
const lavador = obtenerLavadores().find(l => l.id === idLavador);
if (!lavador) throw new Error('No se encontró el lavador seleccionado.');
const filas = hoja && hoja.getLastRow() > 1
? hoja.getRange(2, 1, hoja.getLastRow() - 1, 12).getValues()
: [];
const porTipo = {};
filas.filter(f => String(f[1]) === idLavador).forEach(f => {
porTipo[String(f[3])] = f;
});
const aplicabilidad = obtenerMapaAplicabilidadDocumentos_(idLavador);
return DOCUMENTOS_OBLIGATORIOS.map(tipo => {
const f = porTipo[tipo];
const fechaVenc = f && f[6] instanceof Date
? Utilities.formatDate(f[6], Session.getScriptTimeZone(), 'yyyy-MM-dd')
: (f ? String(f[6] || '') : '');
const fechaEmision = f && f[5] instanceof Date
? Utilities.formatDate(f[5], Session.getScriptTimeZone(), 'yyyy-MM-dd')
: (f ? String(f[5] || '') : '');
return {
tipo: tipo,
requerido: aplicabilidad[tipo] !== false,
cargado: !!(f && f[8] && f[9]),
fechaEmision: fechaEmision,
fechaVencimiento: fechaVenc,
noVence: !!(f && (f[7] === true || String(f[7]).toLowerCase() === 'true')),
url: f ? String(f[9] || '') : '',
nombreArchivo: f ? String(f[10] || '') : '',
estado: !f || !f[8] ? 'Falta cargar' :
((f[7] === true || String(f[7]).toLowerCase() === 'true')
? 'Completo · No vence'
: (!fechaVenc
? 'Falta fecha de vencimiento'
: (new Date(fechaVenc + 'T23:59:59') < new Date() ? 'Vencido' : 'Cargado')))
};
});
}
function guardarDocumentoLavador(datos) {
datos = datos || {};
const idLavador = String(datos.idLavador || '').trim();
const tipo = String(datos.tipo || '').trim();
const fechaEmision = String(datos.fechaEmision || '').trim();
const noVence = datos.noVence === true || String(datos.noVence).toLowerCase() === 'true';
const fechaVencimiento = noVence ? '' : String(datos.fechaVencimiento || '').trim();
const archivo = datos.archivo || {};
if (!idLavador) throw new Error('Selecciona un lavador.');
if (!DOCUMENTOS_OBLIGATORIOS.includes(tipo)) throw new Error('El tipo de documento no es válido.');
if (!archivo.nombre || !archivo.base64 || !archivo.mimeType) throw new Error('Selecciona una foto o archivo del documento.');
if (!noVence && !fechaVencimiento) throw new Error('Indica la fecha de vencimiento o marca «No vence».');
if (archivo.base64.length > 8 * 1024 * 1024) throw new Error('El archivo es demasiado grande. Usa un archivo de menos de aproximadamente 6 MB.');
const lavador = obtenerLavadores().find(l => l.id === idLavador);
if (!lavador) throw new Error('No se encontró el lavador.');
const libro = SpreadsheetApp.openById(ID_HOJA);
let hoja = libro.getSheetByName('DOCUMENTOS_LAVADORES');
if (!hoja) {
configurarPersonal();
hoja = libro.getSheetByName('DOCUMENTOS_LAVADORES');
}
const carpetaRaiz = obtenerOCrearCarpetaPersonal_();
const carpetas = carpetaRaiz.getFoldersByName(lavador.nombre);
const carpetaLavador = carpetas.hasNext()
? carpetas.next()
: carpetaRaiz.createFolder(lavador.nombre);
const bytes = Utilities.base64Decode(archivo.base64);
const blob = Utilities.newBlob(bytes, archivo.mimeType, archivo.nombre);
const archivoDrive = carpetaLavador.createFile(blob);
const filas = hoja.getLastRow() > 1
? hoja.getRange(2, 1, hoja.getLastRow() - 1, 12).getValues()
: [];
let filaExistente = -1;
let avisosPrevios = '';
filas.forEach((f, i) => {
if (String(f[1]) === idLavador && String(f[3]) === tipo) {
filaExistente = i + 2;
avisosPrevios = String(f[11] || '');
}
});
const nuevaFechaVenc = noVence ? '' : fechaVencimiento;
const registro = [
filaExistente > 0 ? hoja.getRange(filaExistente, 1).getValue() : Utilities.getUuid(),
idLavador,
lavador.nombre,
tipo,
new Date(),
fechaEmision ? new Date(fechaEmision + 'T12:00:00') : '',
nuevaFechaVenc ? new Date(nuevaFechaVenc + 'T12:00:00') : '',
noVence,
archivoDrive.getId(),
archivoDrive.getUrl(),
archivo.nombre,
// Si se renueva el documento, los avisos anteriores ya no corresponden a la nueva fecha.
(filaExistente > 0 && String(filas[filaExistente - 2][6] || '') === nuevaFechaVenc)
? avisosPrevios
: ''
];
if (filaExistente > 0) {
hoja.getRange(filaExistente, 1, 1, registro.length).setValues([registro]);
} else {
hoja.appendRow(registro);
}
return {
correcto: true,
mensaje: 'Documento guardado correctamente.',
url: archivoDrive.getUrl()
};
}
function revisarVencimientosLavadores() {
const libro = SpreadsheetApp.openById(ID_HOJA);
const hojaLavadores = libro.getSheetByName('LAVADORES');
const hojaDocs = libro.getSheetByName('DOCUMENTOS_LAVADORES');
if (!hojaLavadores || !hojaDocs) {
return {
correcto: false,
mensaje: 'Primero ejecuta configurarPersonal().'
};
}
const lavadores = obtenerLavadores();
const filasDocs = hojaDocs.getLastRow() > 1
? hojaDocs.getRange(2, 1, hojaDocs.getLastRow() - 1, 12).getValues()
: [];
const mapa = {};
filasDocs.forEach((f, i) => {
if (!mapa[String(f[1])]) mapa[String(f[1])] = {};
mapa[String(f[1])][String(f[3])] = { fila: i + 2, datos: f };
});
const hoy = new Date();
hoy.setHours(0, 0, 0, 0);
const faltantes = [];
const proximos = [];
const vencidos = [];
const umbrales = [30, 15, 7];
lavadores.forEach(lavador => {
const aplicabilidad = obtenerMapaAplicabilidadDocumentos_(lavador.id);
DOCUMENTOS_OBLIGATORIOS.forEach(tipo => {
if (aplicabilidad[tipo] === false) return;
const item = mapa[lavador.id] && mapa[lavador.id][tipo];
if (!item || !item.datos[8] || !item.datos[9]) {
faltantes.push('• ' + lavador.nombre + ' — ' + tipo);
return;
}
const f = item.datos;
if (
f[7] === true ||
String(f[7]).toLowerCase() === 'true' ||
!f[6]
) return;
const venc = f[6] instanceof Date
? new Date(f[6])
: new Date(String(f[6]) + 'T12:00:00');
if (isNaN(venc.getTime())) return;
venc.setHours(0, 0, 0, 0);
const dias = Math.round((venc.getTime() - hoy.getTime()) / 86400000);
if (dias < 0) {
vencidos.push(
'• ' + lavador.nombre + ' — ' + tipo + ' (venció el ' +
Utilities.formatDate(venc, Session.getScriptTimeZone(), 'dd/MM/yyyy') + ')'
);
} else {
const avisos = String(f[11] || '').split(',').filter(Boolean);
// En cada revisión se envía solo el umbral más cercano que corresponda.
const umbralAplicable = umbrales
.slice()
.sort((a, b) => a - b)
.find(umbral => dias <= umbral && !avisos.includes(String(umbral)));
if (umbralAplicable !== undefined) {
proximos.push(
'• ' + lavador.nombre + ' — ' + tipo + ': ' +
(dias === 0 ? 'vence hoy' : 'vence en ' + dias + ' días') +
' (fecha: ' +
Utilities.formatDate(venc, Session.getScriptTimeZone(), 'dd/MM/yyyy') +
'; aviso correspondiente a ' + umbralAplicable + ' días)'
);
avisos.push(String(umbralAplicable));
hojaDocs.getRange(item.fila, 12).setValue(avisos.join(','));
}
}
});
});
if (!faltantes.length && !proximos.length && !vencidos.length) {
return {
correcto: true,
mensaje: 'Sin pendientes de documentación ni vencimientos que requieran aviso.'
};
}
const secciones = [];
if (faltantes.length) {
secciones.push('DOCUMENTOS FALTANTES\n' + faltantes.join('\n'));
}
if (proximos.length) {
secciones.push('PRÓXIMOS A VENCER (avisos de 30, 15 y 7 días)\n' + proximos.join('\n'));
}
if (vencidos.length) {
secciones.push('DOCUMENTOS VENCIDOS\n' + vencidos.join('\n'));
}
MailApp.sendEmail({
to: CORREO_ALERTAS_PERSONAL,
subject: '[Chechare Wash] Control diario de documentación de lavadores',
body:
'Resumen diario de documentación del personal.\n\n' +
secciones.join('\n\n') +
'\n\nEste mensaje se genera automáticamente desde Chechare Wash.'
});
return {
correcto: true,
mensaje: 'Resumen enviado. Faltantes: ' + faltantes.length +
'; próximos avisos: ' + proximos.length +
'; vencidos: ' + vencidos.length + '.'
};
}
function probarAvisoPersonal() {
return revisarVencimientosLavadores();
}
// ==================== RESUMEN Y DETALLE DE DOCUMENTACIÓN ====================
function fechaDocumentoISO_(valor) {
if (!valor) return '';
const zona = Session.getScriptTimeZone();
if (valor instanceof Date && !isNaN(valor.getTime())) {
return Utilities.formatDate(valor, zona, 'yyyy-MM-dd');
}
const texto = String(valor).trim();
let m = texto.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if (m) {
return m[1] + '-' + ('0' + m[2]).slice(-2) + '-' + ('0' + m[3]).slice(-2);
}
// Formatos dd/MM/yyyy, dd-MM-yyyy o dd.MM.yyyy.
m = texto.match(/^(\d{1,2})[\/.-](\d{1,2})[\/.-](\d{4})$/);
if (m) {
return m[3] + '-' + ('0' + m[2]).slice(-2) + '-' + ('0' + m[1]).slice(-2);
}
const fecha = new Date(texto);
return isNaN(fecha.getTime())
? ''
: Utilities.formatDate(fecha, zona, 'yyyy-MM-dd');
}
function formatoFechaDocumento_(iso) {
if (!iso) return '';
const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})$/);
return m ? m[3] + '/' + m[2] + '/' + m[1] : String(iso);
}
function obtenerEstadoDetalleDocumento_(fila, hoyISO, limiteISO) {
const cargado = !!(fila && fila[8] && fila[9]);
if (!cargado) {
return { clave: 'faltantes', estado: 'Falta cargar', fechaISO: '' };
}
const noVence = fila[7] === true || String(fila[7]).toLowerCase() === 'true';
if (noVence) {
return { clave: 'completo', estado: 'Completo · No vence', fechaISO: '' };
}
const fechaISO = fechaDocumentoISO_(fila[6]);
if (!fechaISO) {
return { clave: 'sinFecha', estado: 'Falta fecha de vencimiento', fechaISO: '' };
}
if (fechaISO < hoyISO) {
return { clave: 'vencidos', estado: 'Vencido', fechaISO: fechaISO };
}
if (fechaISO <= limiteISO) {
return { clave: 'porVencer', estado: 'Por vencer', fechaISO: fechaISO };
}
return { clave: 'completo', estado: 'Vigente', fechaISO: fechaISO };
}
function obtenerDatosControlDocumentos_() {
const libro = SpreadsheetApp.openById(ID_HOJA);
const hojaDocs = libro.getSheetByName('DOCUMENTOS_LAVADORES');
const filasDocs = hojaDocs && hojaDocs.getLastRow() > 1
? hojaDocs.getRange(2, 1, hojaDocs.getLastRow() - 1, 12).getValues()
: [];
const hoyISO = Utilities.formatDate(
new Date(),
Session.getScriptTimeZone(),
'yyyy-MM-dd'
);
const partes = hoyISO.split('-').map(Number);
const limite = new Date(partes[0], partes[1] - 1, partes[2], 12, 0, 0);
limite.setDate(limite.getDate() + 30);
const limiteISO = Utilities.formatDate(
limite,
Session.getScriptTimeZone(),
'yyyy-MM-dd'
);
return {
lavadores: obtenerLavadores().filter(l => l.activo),
filasDocs: filasDocs,
hoyISO: hoyISO,
limiteISO: limiteISO
};
}
function obtenerResumenPersonal() {
const datos = obtenerDatosControlDocumentos_();
const porTipo = DOCUMENTOS_OBLIGATORIOS.map(tipo => ({
tipo: tipo,
faltantes: 0
}));
let totalFaltantes = 0;
let totalPorVencer = 0;
let totalVencidos = 0;
datos.lavadores.forEach(function(lavador) {
const aplicabilidad = obtenerMapaAplicabilidadDocumentos_(lavador.id);
DOCUMENTOS_OBLIGATORIOS.forEach(function(tipo) {
if (aplicabilidad[tipo] === false) return;
const fila = datos.filasDocs
.filter(f => String(f[1]) === lavador.id && String(f[3]) === tipo)
.pop();
const estado = obtenerEstadoDetalleDocumento_(
fila,
datos.hoyISO,
datos.limiteISO
);
if (estado.clave === 'faltantes') {
totalFaltantes++;
const resumen = porTipo.find(p => p.tipo === tipo);
if (resumen) resumen.faltantes++;
} else if (estado.clave === 'porVencer') {
totalPorVencer++;
} else if (estado.clave === 'vencidos') {
totalVencidos++;
}
});
});
return {
totalLavadores: datos.lavadores.length,
totalFaltantes: totalFaltantes,
totalPorVencer: totalPorVencer,
totalVencidos: totalVencidos,
porTipo: porTipo
};
}
/**
* Devuelve el estado de los cuatro documentos por lavador activo.
* No crea hojas ni modifica registros; solo consulta los datos existentes.
*/
function obtenerEstadoDocumentacionLavadores(busqueda) {
const libro = SpreadsheetApp.openById(ID_HOJA);
const hojaDocs = libro.getSheetByName('DOCUMENTOS_LAVADORES');
const hojaAplicabilidad = libro.getSheetByName('APLICABILIDAD_DOCUMENTOS');
const filasDocs = hojaDocs && hojaDocs.getLastRow() > 1
? hojaDocs.getRange(2, 1, hojaDocs.getLastRow() - 1, 12).getValues()
: [];
const filasAplicabilidad = hojaAplicabilidad && hojaAplicabilidad.getLastRow() > 1
? hojaAplicabilidad.getRange(2, 1, hojaAplicabilidad.getLastRow() - 1, 3).getValues()
: [];
const aplicabilidad = {};
filasAplicabilidad.forEach(function(fila) {
if (!aplicabilidad[String(fila[0])]) aplicabilidad[String(fila[0])] = {};
aplicabilidad[String(fila[0])][String(fila[1])] =
String(fila[2]).toLowerCase() !== 'false';
});
const documentos = {};
filasDocs.forEach(function(fila) {
const id = String(fila[1] || '');
const tipo = String(fila[3] || '');
if (!documentos[id]) documentos[id] = {};
documentos[id][tipo] = fila;
});
const hoyISO = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd');
const normalizar = function(valor) {
return String(valor || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim();
};
const termino = normalizar(busqueda);
const salida = obtenerLavadores()
.filter(function(lavador) { return lavador.activo && normalizar(lavador.nombre).includes(termino); })
.map(function(lavador) {
const docs = DOCUMENTOS_OBLIGATORIOS.map(function(tipo) {
const requerido = !aplicabilidad[lavador.id] || aplicabilidad[lavador.id][tipo] !== false;
if (!requerido) return { tipo: tipo, estado: 'No aplica', fechaVencimiento: '' };
const fila = documentos[lavador.id] && documentos[lavador.id][tipo];
const cargado = !!(fila && fila[8] && fila[9]);
if (!cargado) return { tipo: tipo, estado: 'Faltante', fechaVencimiento: '' };
const noVence = fila[7] === true || String(fila[7]).toLowerCase() === 'true';
if (noVence) return { tipo: tipo, estado: 'Completo', fechaVencimiento: 'No vence' };
const fechaISO = fechaDocumentoISO_(fila[6]);
if (!fechaISO) return { tipo: tipo, estado: 'Faltante', fechaVencimiento: 'Sin fecha de vencimiento' };
return {
tipo: tipo,
estado: fechaISO < hoyISO ? 'Vencido' : 'Completo',
fechaVencimiento: formatoFechaDocumento_(fechaISO)
};
});
const aplicables = docs.filter(function(doc) { return doc.estado !== 'No aplica'; });
const estadoGeneral = aplicables.some(function(doc) { return doc.estado === 'Vencido'; })
? 'Vencido'
: (aplicables.some(function(doc) { return doc.estado === 'Faltante'; }) ? 'Faltante' : 'Completo');
return { id: lavador.id, nombre: lavador.nombre, documentos: docs, estadoGeneral: estadoGeneral };
});
salida.sort(function(a, b) { return a.nombre.localeCompare(b.nombre, 'es'); });
return salida;
}
function obtenerDetalleDocumentos(tipo) {
tipo = String(tipo || '').trim();
if (!['faltantes', 'porVencer', 'vencidos'].includes(tipo)) {
throw new Error('La categoría de documentos no es válida.');
}
const datos = obtenerDatosControlDocumentos_();
const salida = [];
datos.lavadores.forEach(function(lavador) {
const aplicabilidad = obtenerMapaAplicabilidadDocumentos_(lavador.id);
DOCUMENTOS_OBLIGATORIOS.forEach(function(tipoDoc) {
if (aplicabilidad[tipoDoc] === false) return;
const fila = datos.filasDocs
.filter(f => String(f[1]) === lavador.id && String(f[3]) === tipoDoc)
.pop();
const estado = obtenerEstadoDetalleDocumento_(
fila,
datos.hoyISO,
datos.limiteISO
);
if (estado.clave !== tipo) return;
salida.push({
idLavador: lavador.id,
nombreLavador: lavador.nombre,
telefono: lavador.telefono,
tipo: tipoDoc,
estado: estado.estado,
fechaVencimiento: formatoFechaDocumento_(estado.fechaISO),
url: fila ? String(fila[9] || '') : '',
nombreArchivo: fila ? String(fila[10] || '') : ''
});
});
});
salida.sort(function(a, b) {
return a.nombreLavador.localeCompare(b.nombreLavador, 'es') ||
a.tipo.localeCompare(b.tipo, 'es');
});
return salida;
}
/**
* Desactiva un lavador para retirarlo de las listas activas.
* Conserva su ficha y documentación histórica en la hoja de cálculo.
*/
function eliminarLavador(idLavador) {
idLavador = String(idLavador || '').trim();
if (!idLavador) throw new Error('Selecciona un lavador.');
const libro = SpreadsheetApp.openById(ID_HOJA);
const hoja = libro.getSheetByName('LAVADORES');
if (!hoja || hoja.getLastRow() < 2) {
throw new Error('No hay lavadores registrados.');
}
const filas = hoja.getRange(2, 1, hoja.getLastRow() - 1, 5).getValues();
for (let i = 0; i < filas.length; i++) {
if (String(filas[i][0]) === idLavador) {
if (String(filas[i][4]).toLowerCase() === 'false') {
return {
correcto: true,
mensaje: 'Este lavador ya estaba desactivado.'
};
}
hoja.getRange(i + 2, 5).setValue(false);
SpreadsheetApp.flush();
return {
correcto: true,
mensaje: 'Lavador desactivado correctamente. Su ficha y documentos históricos se conservaron.'
};
}
}
throw new Error('No se encontró el lavador seleccionado.');
}
/**
* Elimina definitivamente el registro del lavador y sus registros documentales.
* Los archivos asociados se envían primero a la papelera de Drive.
* Si no se pueden procesar los archivos, la función se detiene antes de borrar las filas.
*/
function eliminarLavadorDefinitivo(idLavador) {
idLavador = String(idLavador || '').trim();
if (!idLavador) throw new Error('Selecciona un lavador.');
const bloqueo = LockService.getScriptLock();
bloqueo.waitLock(30000);
try {
const libro = SpreadsheetApp.openById(ID_HOJA);
const hojaLavadores = libro.getSheetByName('LAVADORES');
const hojaDocs = libro.getSheetByName('DOCUMENTOS_LAVADORES');
if (!hojaLavadores || hojaLavadores.getLastRow() < 2) {
throw new Error('No hay lavadores registrados.');
}
const datosLavadores = hojaLavadores
.getRange(2, 1, hojaLavadores.getLastRow() - 1, 5)
.getValues();
const indiceLavador = datosLavadores.findIndex(
f => String(f[0]) === idLavador
);
if (indiceLavador < 0) {
throw new Error('No se encontró el lavador seleccionado.');
}
const nombre = String(datosLavadores[indiceLavador][1] || '');
const datosDocs = hojaDocs && hojaDocs.getLastRow() > 1
? hojaDocs.getRange(2, 1, hojaDocs.getLastRow() - 1, 12).getValues()
: [];
const documentos = datosDocs.filter(f => String(f[1]) === idLavador);
// Enviar archivos registrados de este lavador a la papelera antes de borrar datos.
const idsArchivo = [
...new Set(
documentos.map(f => String(f[8] || '').trim()).filter(Boolean)
)
];
let archivosEnPapelera = 0;
const erroresArchivo = [];
idsArchivo.forEach(idArchivo => {
try {
const archivo = DriveApp.getFileById(idArchivo);
if (!archivo.isTrashed()) {
archivo.setTrashed(true);
archivosEnPapelera++;
}
} catch (e) {
erroresArchivo.push(
idArchivo + ': ' + (e && e.message ? e.message : e)
);
}
});
if (erroresArchivo.length) {
throw new Error(
'No se pudo enviar a la papelera uno o más archivos. ' +
'No se borraron los registros de la hoja. Revisa permisos o archivos compartidos. ' +
'Detalle: ' + erroresArchivo.join(' | ')
);
}
// Eliminar las filas documentales de abajo hacia arriba para no desplazar índices.
if (hojaDocs && hojaDocs.getLastRow() > 1) {
const filasDocs = [];
datosDocs.forEach((f, i) => {
if (String(f[1]) === idLavador) filasDocs.push(i + 2);
});
filasDocs.sort((a, b) => b - a).forEach(fila => hojaDocs.deleteRow(fila));
}
// Eliminar el registro del lavador.
hojaLavadores.deleteRow(indiceLavador + 2);
SpreadsheetApp.flush();
return {
correcto: true,
mensaje: 'Se eliminó definitivamente a ' + nombre +
'. Se borraron sus registros documentales y se enviaron ' +
archivosEnPapelera + ' archivo(s) a la papelera de Google Drive.',
archivosEnPapelera: archivosEnPapelera
};
} finally {
bloqueo.releaseLock();
}
}
// ==================== MÓDULO DE MÁQUINAS ====================
function configurarModuloMaquinas() {
const ss = SpreadsheetApp.openById(ID_HOJA);
const estructuras = {
MAQUINAS: [
'ID',
'Código',
'Tipo de máquina',
'Marca',
'Modelo',
'Número de serie',
'Estado',
'Lavador asignado',
'Fecha de registro',
'Observaciones'
],
ASIGNACIONES: [
'ID asignación',
'Código de máquina',
'ID lavador',
'Nombre del lavador',
'Fecha de entrega',
'Fecha de devolución',
'Estado de asignación',
'Observaciones'
],
HISTORIAL_MAQUINAS: [
'ID movimiento',
'Fecha y hora',
'Código de máquina',
'Tipo de movimiento',
'Lavador',
'Estado anterior',
'Estado nuevo',
'Motivo',
'Observaciones'
]
};
Object.entries(estructuras).forEach(([nombre, encabezados]) => {
let hoja = ss.getSheetByName(nombre);
if (!hoja) {
hoja = ss.insertSheet(nombre);
}
if (hoja.getLastRow() === 0) {
hoja.getRange(1, 1, 1, encabezados.length)
.setValues([encabezados]);
hoja.getRange(1, 1, 1, encabezados.length)
.setFontWeight('bold')
.setBackground('#1f2937')
.setFontColor('#ffffff');
hoja.setFrozenRows(1);
hoja.autoResizeColumns(1, encabezados.length);
}
});
return 'Módulo de máquinas configurado correctamente.';
}
/**
* Obtiene todas las máquinas registradas.
*/
function obtenerMaquinas() {
const ss = SpreadsheetApp.openById(ID_HOJA);
const hoja = ss.getSheetByName('MAQUINAS');
if (!hoja || hoja.getLastRow() < 2) return [];
const datos = hoja.getDataRange().getValues();
return datos.slice(1)
.filter(fila => fila[0])
.map(fila => ({
id: fila[0],
codigo: fila[1],
tipo: fila[2],
marca: fila[3],
modelo: fila[4],
numeroSerie: fila[5],
estado: fila[6],
lavadorAsignado: fila[7],
fechaRegistro: fila[8],
observaciones: fila[9]
}));
}
/**
* Registra una máquina nueva.
* Cada máquina recibe un UUID independiente de su código visible.
*/
function registrarMaquina(datos) {
if (!datos || !datos.codigo || !datos.tipo) {
throw new Error('Debes ingresar el código y el tipo de máquina.');
}
const bloqueo = LockService.getScriptLock();
bloqueo.waitLock(10000);
try {
const ss = SpreadsheetApp.openById(ID_HOJA);
const hoja = ss.getSheetByName('MAQUINAS');
if (!hoja) {
throw new Error('No existe la hoja MAQUINAS.');
}
const codigo = String(datos.codigo).trim().toUpperCase();
const existentes = obtenerMaquinas();
if (existentes.some(m => String(m.codigo).trim().toUpperCase() === codigo)) {
throw new Error('Ya existe una máquina con ese código.');
}
const id = Utilities.getUuid();
const fecha = new Date();
hoja.appendRow([
id,
codigo,
String(datos.tipo).trim(),
datos.marca || '',
datos.modelo || '',
datos.numeroSerie || '',
'De respaldo',
'',
fecha,
datos.observaciones || ''
]);
registrarMovimientoMaquina_({
codigo: codigo,
tipoMovimiento: 'Registro',
estadoAnterior: '',
estadoNuevo: 'De respaldo',
motivo: 'Alta de máquina',
observaciones: datos.observaciones || ''
});
return {
correcto: true,
mensaje: 'Máquina registrada correctamente.',
id: id,
codigo: codigo
};
} finally {
bloqueo.releaseLock();
}
}
/**
* Registra un movimiento en el historial de máquinas.
*/
function registrarMovimientoMaquina_(datos) {
const ss = SpreadsheetApp.openById(ID_HOJA);
const hoja = ss.getSheetByName('HISTORIAL_MAQUINAS');
if (!hoja) {
throw new Error('No existe la hoja HISTORIAL_MAQUINAS.');
}
hoja.appendRow([
Utilities.getUuid(),
new Date(),
datos.codigo || '',
datos.tipoMovimiento || '',
datos.lavador || '',
datos.estadoAnterior || '',
datos.estadoNuevo || '',
datos.motivo || '',
datos.observaciones || ''
]);
}
/**
* Asigna una máquina a un lavador activo.
* Una máquina no puede tener más de una asignación activa.
*/
function asignarMaquina(codigo, idLavador, observaciones) {
const lock = LockService.getScriptLock();
lock.waitLock(10000);
try {
if (!codigo || !idLavador) {
throw new Error('Debes indicar la máquina y el lavador.');
}
const ss = SpreadsheetApp.openById(ID_HOJA);
const hojaMaquinas = ss.getSheetByName('MAQUINAS');
const hojaAsignaciones = ss.getSheetByName('ASIGNACIONES');
const hojaLavadores = ss.getSheetByName('LAVADORES');
if (!hojaMaquinas || !hojaAsignaciones || !hojaLavadores) {
throw new Error('Faltan hojas necesarias para realizar la asignación.');
}
const codigoNormalizado = String(codigo).trim().toUpperCase();
const maquinas = hojaMaquinas.getDataRange().getValues();
const filaMaquina = maquinas.findIndex((fila, i) =>
i > 0 &&
String(fila[1]).trim().toUpperCase() === codigoNormalizado
);
if (filaMaquina === -1) {
throw new Error('No se encontró la máquina.');
}
const maquina = maquinas[filaMaquina];
if (maquina[7]) {
throw new Error('Esta máquina ya está asignada a un lavador.');
}
if (['Dañada', 'En reparación'].includes(String(maquina[6]))) {
throw new Error('No se puede asignar una máquina dañada o en mantenimiento.');
}
const lavadores = hojaLavadores.getDataRange().getValues();
const lavador = lavadores.find((fila, i) =>
i > 0 && String(fila[0]) === String(idLavador)
);
const lavadorActivo = lavador &&
(lavador[4] === true || String(lavador[4]).toLowerCase() === 'true');
if (!lavador || !lavadorActivo) {
throw new Error('El lavador no existe o no está activo.');
}
const fecha = new Date();
const nombreLavador = lavador[1];
// Verificar que no exista ya una asignación activa de esta máquina.
const ultimaFilaAsignaciones = hojaAsignaciones.getLastRow();
if (ultimaFilaAsignaciones >= 2) {
const asignaciones = hojaAsignaciones
.getRange(2, 1, ultimaFilaAsignaciones - 1, 8)
.getValues();
const yaAsignada = asignaciones.some(fila =>
String(fila[1]).trim().toUpperCase() === codigoNormalizado &&
String(fila[6]) === 'Activa'
);
if (yaAsignada) {
throw new Error('Ya existe una asignación activa para esta máquina.');
}
}
hojaAsignaciones.appendRow([
Utilities.getUuid(),
codigoNormalizado,
idLavador,
nombreLavador,
fecha,
'',
'Activa',
observaciones || ''
]);
hojaMaquinas.getRange(filaMaquina + 1, 7, 1, 2)
.setValues([['Operativa', nombreLavador]]);
registrarMovimientoMaquina_({
codigo: codigoNormalizado,
tipoMovimiento: 'Asignación',
lavador: nombreLavador,
estadoAnterior: maquina[6],
estadoNuevo: 'Operativa',
motivo: 'Entrega de máquina al lavador',
observaciones: observaciones || ''
});
return {
correcto: true,
mensaje: 'Máquina asignada correctamente.'
};
} finally {
lock.releaseLock();
}
}
/**
* Registra la devolución de una máquina.
* Si estaba operativa pasa a De respaldo.
* Si estaba dañada o en mantenimiento conserva ese estado.
*/
function devolverMaquina(codigo, motivo, observaciones) {
const lock = LockService.getScriptLock();
lock.waitLock(10000);
try {
codigo = String(codigo || '').trim().toUpperCase();
motivo = String(motivo || '').trim();
if (!codigo) {
throw new Error('Debes indicar el código de la máquina.');
}
if (!motivo) {
throw new Error('Indica el motivo de la devolución.');
}
const ss = SpreadsheetApp.openById(ID_HOJA);
const hojaMaquinas = ss.getSheetByName('MAQUINAS');
const hojaAsignaciones = ss.getSheetByName('ASIGNACIONES');
if (!hojaMaquinas || !hojaAsignaciones) {
throw new Error('Faltan hojas necesarias para registrar la devolución.');
}
const maquinas = hojaMaquinas.getDataRange().getValues();
const indiceMaquina = maquinas.findIndex((fila, i) =>
i > 0 && String(fila[1]).trim().toUpperCase() === codigo
);
if (indiceMaquina === -1) {
throw new Error('No se encontró la máquina.');
}
const maquina = maquinas[indiceMaquina];
if (!maquina[7]) {
throw new Error('Esta máquina no tiene un lavador asignado.');
}
const ultimaFila = hojaAsignaciones.getLastRow();
if (ultimaFila < 2) {
throw new Error('No se encontró una asignación activa.');
}
const asignaciones = hojaAsignaciones
.getRange(2, 1, ultimaFila - 1, 8)
.getValues();
let indiceAsignacion = -1;
let asignacion = null;
for (let i = asignaciones.length - 1; i >= 0; i--) {
if (
String(asignaciones[i][1]).trim().toUpperCase() === codigo &&
String(asignaciones[i][6]) === 'Activa'
) {
indiceAsignacion = i + 2;
asignacion = asignaciones[i];
break;
}
}
if (indiceAsignacion === -1) {
throw new Error('No se encontró la asignación activa de esta máquina.');
}
const fecha = new Date();
const estadoAnterior = String(maquina[6] || '');
// Una máquina operativa que se devuelve queda disponible como respaldo.
// Si está dañada o en mantenimiento, conserva ese estado.
const estadoNuevo = estadoAnterior === 'Operativa'
? 'De respaldo'
: estadoAnterior;
hojaAsignaciones.getRange(indiceAsignacion, 6).setValue(fecha);
hojaAsignaciones.getRange(indiceAsignacion, 7).setValue('Devuelta');
hojaMaquinas.getRange(indiceMaquina + 1, 7, 1, 2)
.setValues([[estadoNuevo, '']]);
registrarMovimientoMaquina_({
codigo: codigo,
tipoMovimiento: 'Devolución',
lavador: asignacion[3],
estadoAnterior: estadoAnterior,
estadoNuevo: estadoNuevo,
motivo: motivo,
observaciones: observaciones || ''
});
return {
correcto: true,
mensaje: 'Máquina devuelta correctamente.',
estadoNuevo: estadoNuevo
};
} finally {
lock.releaseLock();
}
}
/**
* Cambia el estado operativo de una máquina.
*
* Transiciones controladas:
* - Operativa -> Dañada o En reparación.
* - De respaldo -> Dañada o En reparación.
* - Dañada -> En reparación.
* - En reparación -> Operativa o De respaldo, confirmando la reparación.
*
* Una máquina asignada no puede pasar a De respaldo.
*/
function cambiarEstadoMaquina(codigo, nuevoEstado, motivo, observaciones) {
const lock = LockService.getScriptLock();
lock.waitLock(10000);
try {
codigo = String(codigo || '').trim().toUpperCase();
nuevoEstado = String(nuevoEstado || '').trim();
motivo = String(motivo || '').trim();
const estadosPermitidos = [
'Operativa',
'Dañada',
'En reparación',
'De respaldo'
];
if (!codigo) {
throw new Error('Debes indicar el código de la máquina.');
}
if (!estadosPermitidos.includes(nuevoEstado)) {
throw new Error('El estado seleccionado no es válido.');
}
if (!motivo) {
throw new Error('Debes indicar el motivo del cambio de estado.');
}
const ss = SpreadsheetApp.openById(ID_HOJA);
const hojaMaquinas = ss.getSheetByName('MAQUINAS');
if (!hojaMaquinas) {
throw new Error('No existe la hoja MAQUINAS.');
}
const maquinas = hojaMaquinas.getDataRange().getValues();
const indice = maquinas.findIndex((fila, i) =>
i > 0 && String(fila[1]).trim().toUpperCase() === codigo
);
if (indice === -1) {
throw new Error('No se encontró la máquina.');
}
const maquina = maquinas[indice];
const estadoAnterior = String(maquina[6] || '');
const lavadorAsignado = String(maquina[7] || '').trim();
if (estadoAnterior === nuevoEstado) {
throw new Error('La máquina ya tiene ese estado.');
}
if (lavadorAsignado && nuevoEstado === 'De respaldo') {
throw new Error(
'Primero debes registrar la devolución de la máquina al lavador.'
);
}
// Una máquina dañada debe pasar obligatoriamente por mantenimiento.
if (
estadoAnterior === 'Dañada' &&
nuevoEstado !== 'En reparación'
) {
throw new Error(
'Una máquina dañada debe pasar primero a En reparación.'
);
}
// La reparación debe confirmarse antes de volver a usar la máquina.
if (
estadoAnterior === 'En reparación' &&
['Operativa', 'De respaldo'].includes(nuevoEstado) &&
!motivo.toLowerCase().includes('repar')
) {
throw new Error(
'Para finalizar el mantenimiento, indica en el motivo que la máquina fue reparada.'
);
}
// No se permite pasar directamente de respaldo a operativa:
// la asignación es la que realiza ese cambio.
if (
estadoAnterior === 'De respaldo' &&
nuevoEstado === 'Operativa'
) {
throw new Error(
'Para poner en servicio una máquina de respaldo, registra su asignación a un lavador.'
);
}
// Una máquina operativa o de respaldo puede quedar dañada o ingresar a mantenimiento.
const transicionesPermitidas = {
'Operativa': ['Dañada', 'En reparación', 'De respaldo'],
'De respaldo': ['Dañada', 'En reparación'],
'Dañada': ['En reparación'],
'En reparación': ['Operativa', 'De respaldo']
};
if (
!transicionesPermitidas[estadoAnterior] ||
!transicionesPermitidas[estadoAnterior].includes(nuevoEstado)
) {
throw new Error(
'No está permitido cambiar el estado de ' +
estadoAnterior + ' a ' + nuevoEstado + '.'
);
}
hojaMaquinas.getRange(indice + 1, 7).setValue(nuevoEstado);
registrarMovimientoMaquina_({
codigo: codigo,
tipoMovimiento: 'Cambio de estado',
lavador: lavadorAsignado,
estadoAnterior: estadoAnterior,
estadoNuevo: nuevoEstado,
motivo: motivo,
observaciones: observaciones || ''
});
return {
correcto: true,
mensaje: 'Estado de la máquina actualizado correctamente.',
estadoAnterior: estadoAnterior,
estadoNuevo: nuevoEstado
};
} finally {
lock.releaseLock();
}
}
function obtenerHistorialMaquinas(codigo, lavador) {
const ss = SpreadsheetApp.openById(ID_HOJA);
const hoja = ss.getSheetByName('HISTORIAL_MAQUINAS');
if (!hoja || hoja.getLastRow() < 2) {
return [];
}
const datos = hoja.getDataRange().getValues();
const zonaHoraria = Session.getScriptTimeZone();
codigo = String(codigo || '').trim().toUpperCase();
lavador = String(lavador || '').trim().toLowerCase();
return datos.slice(1)
.filter(fila => {
const coincideCodigo = !codigo ||
String(fila[2]).trim().toUpperCase() === codigo;
const coincideLavador = !lavador ||
String(fila[4]).trim().toLowerCase().includes(lavador);
return coincideCodigo && coincideLavador;
})
.map(fila => ({
id: fila[0],
fecha: fila[1] instanceof Date
? Utilities.formatDate(
fila[1],
zonaHoraria,
'dd/MM/yyyy HH:mm:ss'
)
: String(fila[1] || ''),
codigo: fila[2],
tipoMovimiento: fila[3],
lavador: fila[4],
estadoAnterior: fila[5],
estadoNuevo: fila[6],
motivo: fila[7],
observaciones: fila[8]
}))
.reverse();
}
function obtenerResumenMaquinas() {
const ss = SpreadsheetApp.openById(ID_HOJA);
const hojaMaquinas = ss.getSheetByName('MAQUINAS');
const hojaAsignaciones = ss.getSheetByName('ASIGNACIONES');
if (!hojaMaquinas || !hojaAsignaciones) {
throw new Error('Faltan hojas necesarias para consultar las máquinas.');
}
const maquinas = hojaMaquinas.getDataRange().getValues();
if (maquinas.length < 2) {
return [];
}
const ultimaFila = hojaAsignaciones.getLastRow();
const asignaciones = ultimaFila >= 2
? hojaAsignaciones.getRange(2, 1, ultimaFila - 1, 8).getValues()
: [];
return maquinas.slice(1).map(fila => {
const codigo = String(fila[1] || '').trim().toUpperCase();
const asignacionActiva = asignaciones.find(asignacion =>
String(asignacion[1] || '').trim().toUpperCase() === codigo &&
String(asignacion[6] || '') === 'Activa'
);
return {
id: fila[0],
codigo: fila[1],
tipo: fila[2],
marca: fila[3],
modelo: fila[4],
numeroSerie: fila[5],
estado: fila[6],
lavadorAsignado: fila[7],
fechaRegistro: fila[8] instanceof Date
? Utilities.formatDate(
fila[8],
Session.getScriptTimeZone(),
'dd/MM/yyyy'
)
: String(fila[8] || ''),
observaciones: fila[9],
asignacionActiva: asignacionActiva ? {
id: asignacionActiva[0],
idLavador: asignacionActiva[2],
nombreLavador: asignacionActiva[3],
fechaEntrega: asignacionActiva[4] instanceof Date
? Utilities.formatDate(
asignacionActiva[4],
Session.getScriptTimeZone(),
'dd/MM/yyyy'
)
: String(asignacionActiva[4] || ''),
observaciones: asignacionActiva[7]
} : null
};
});
}
function probarResumenMaquinas() {
const resultado = obtenerResumenMaquinas();
console.log(JSON.stringify(resultado, null, 2));
return resultado;
}
function probarRegistroMaquina() {
return registrarMaquina({
codigo: 'H1',
tipo: 'Hidrolavadora',
marca: 'Prueba',
modelo: 'Modelo de prueba',
numeroSerie: 'TEST-001',
observaciones: 'Registro de prueba del sistema'
});
}
function probarConsultaLavadoresActivos() {
const ss = SpreadsheetApp.openById(ID_HOJA);
const hoja = ss.getSheetByName('LAVADORES');
const datos = hoja.getDataRange().getValues();
const activos = datos.slice(1)
.filter(fila =>
String(fila[4]).toLowerCase() === 'true'
)
.map(fila => ({
id: fila[0],
nombre: fila[1]
}));
console.log(JSON.stringify(activos, null, 2));
return activos;
}
function probarAsignacionMaquina() {
const resultado = asignarMaquina(
'H1',
'2644475a-2839-4e8d-a330-2807d9bd3b47',
'Prueba de asignación del sistema'
);
console.log(JSON.stringify(resultado, null, 2));
return resultado;
}
function probarDevolucionMaquina() {
const resultado = devolverMaquina(
'H1',
'Prueba de devolución',
'Verificación del circuito de devolución'
);
console.log(JSON.stringify(resultado, null, 2));
return resultado;
}
function probarMaquinaDanada() {
const resultado = cambiarEstadoMaquina(
'H1',
'Dañada',
'Prueba de cambio de estado',
'Verificación del módulo de estados'
);
console.log(JSON.stringify(resultado, null, 2));
return resultado;
}
function probarEnReparacion() {
const resultado = cambiarEstadoMaquina(
'H1',
'En reparación',
'Envío a reparación',
'Prueba de transición de estado'
);
console.log(JSON.stringify(resultado, null, 2));
return resultado;
}
// ==================== INCENTIVOS Y BONO SEMESTRAL ====================
const HOJA_INCENTIVOS_SEMANALES = 'INCENTIVOS_SEMANALES';
const HOJA_BONOS_SEMESTRALES = 'BONOS_SEMESTRALES';
const HOJA_HISTORIAL_BONOS = 'HISTORIAL_BONOS';
const LIMITE_FALTAS_BONO = 9;
const FECHA_INICIO_CALCULO_BONOS = new Date(2026, 5, 1); // 1 de junio de 2026
function configurarModuloIncentivos() {
const libro = SpreadsheetApp.openById(ID_HOJA);
const estructuras = {
LAVADORES: ['ID lavador', 'Nombre completo', 'Teléfono', 'Fecha de alta', 'Activo', 'Fecha de ingreso laboral', 'Aplica incentivo semanal', 'Motivo no aplica'],
INCENTIVOS_SEMANALES: ['ID registro', 'ID lavador', 'Nombre lavador', 'Semana desde', 'Semana hasta', 'Incentivo', 'Fecha de registro', 'Última modificación'],
BONOS_SEMESTRALES: ['ID lavador', 'Nombre lavador', 'Fecha último bono pagado', 'Estado bloqueado', 'Fecha de bloqueo', 'Observaciones'],
HISTORIAL_BONOS: ['ID movimiento', 'ID lavador', 'Nombre lavador', 'Movimiento', 'Fecha del movimiento', 'Fecha de pago', 'Fecha inicio del ciclo', 'Fecha fin del ciclo', 'Faltas del ciclo', 'Estado anterior', 'Estado nuevo', 'Observaciones']
};
Object.keys(estructuras).forEach(nombre => {
let hoja = libro.getSheetByName(nombre);
if (!hoja) hoja = libro.insertSheet(nombre);
const encabezados = estructuras[nombre];
if (hoja.getLastRow() === 0) {
hoja.getRange(1, 1, 1, encabezados.length).setValues([encabezados]);
} else {
const actuales = hoja.getRange(1, 1, 1, Math.max(hoja.getLastColumn(), encabezados.length)).getDisplayValues()[0];
encabezados.forEach((valor, i) => { if (!actuales[i]) hoja.getRange(1, i + 1).setValue(valor); });
}
hoja.getRange(1, 1, 1, encabezados.length).setFontWeight('bold');
hoja.setFrozenRows(1);
hoja.autoResizeColumns(1, encabezados.length);
});
return { correcto: true, mensaje: 'Módulo de incentivos configurado. Se verificaron las hojas y se agregó la columna de fecha de ingreso laboral.' };
}
function parsearFechaLocal_(valor) {
if (!valor) return null;
if (valor instanceof Date && !isNaN(valor)) return new Date(valor.getFullYear(), valor.getMonth(), valor.getDate());
const partes = String(valor).split('-').map(Number);
if (partes.length !== 3 || partes.some(n => !n)) throw new Error('La fecha indicada no es válida.');
return new Date(partes[0], partes[1] - 1, partes[2]);
}
function fechaISOIncentivos_(fecha) {
if (!(fecha instanceof Date) || isNaN(fecha)) return '';
return Utilities.formatDate(fecha, Session.getScriptTimeZone(), 'yyyy-MM-dd');
}
function sumarMesesFecha_(fecha, meses) {
const f = new Date(fecha.getFullYear(), fecha.getMonth(), fecha.getDate());
const dia = f.getDate();
f.setDate(1);
f.setMonth(f.getMonth() + meses);
const ultimoDia = new Date(f.getFullYear(), f.getMonth() + 1, 0).getDate();
f.setDate(Math.min(dia, ultimoDia));
return f;
}
function calcularAntiguedad_(fechaIngreso) {
if (!(fechaIngreso instanceof Date) || isNaN(fechaIngreso)) return 'Sin fecha de ingreso';
const hoy = new Date();
let meses = (hoy.getFullYear() - fechaIngreso.getFullYear()) * 12 + hoy.getMonth() - fechaIngreso.getMonth();
if (hoy.getDate() < fechaIngreso.getDate()) meses--;
if (meses < 0) return 'Fecha futura';
const anios = Math.floor(meses / 12);
const resto = meses % 12;
if (anios && resto) return anios + (anios === 1 ? ' año y ' : ' años y ') + resto + (resto === 1 ? ' mes' : ' meses');
if (anios) return anios + (anios === 1 ? ' año' : ' años');
return meses + (meses === 1 ? ' mes' : ' meses');
}
function actualizarFechaIngresoLavador(idLavador, fechaIngreso) {
if (!idLavador) throw new Error('Selecciona un lavador.');
const fecha = parsearFechaLocal_(fechaIngreso);
if (!fecha) throw new Error('Indica la fecha en que comenzó a trabajar.');
if (fecha > new Date(new Date().getFullYear(), new Date().getMonth(), new Date().getDate())) throw new Error('La fecha de ingreso no puede ser futura.');
const hoja = SpreadsheetApp.openById(ID_HOJA).getSheetByName('LAVADORES');
if (!hoja || hoja.getLastRow() < 2) throw new Error('No hay lavadores registrados.');
const datos = hoja.getRange(2, 1, hoja.getLastRow() - 1, Math.max(hoja.getLastColumn(), 6)).getValues();
const indice = datos.findIndex(f => String(f[0]) === String(idLavador));
if (indice < 0) throw new Error('No se encontró el lavador.');
hoja.getRange(indice + 2, 6).setValue(fecha);
return { correcto: true, mensaje: 'Fecha de ingreso laboral actualizada.', antiguedad: calcularAntiguedad_(fecha) };
}
function actualizarAplicabilidadIncentivoLavador(idLavador, aplica, motivo) {
if (!idLavador) throw new Error('Selecciona un lavador.');
const participa = aplica === true || String(aplica).toLowerCase() === 'true';
const motivoLimpio = participa ? '' : String(motivo || '').trim();
const motivosPermitidos = ['Jefe', 'Encargado', 'Otro'];
if (!participa && !motivosPermitidos.includes(motivoLimpio)) {
throw new Error('Selecciona el motivo por el que no aplica la evaluación.');
}
const hoja = SpreadsheetApp.openById(ID_HOJA).getSheetByName('LAVADORES');
if (!hoja || hoja.getLastRow() < 2) throw new Error('No hay lavadores registrados.');
const datos = hoja.getRange(2, 1, hoja.getLastRow() - 1, Math.max(hoja.getLastColumn(), 8)).getValues();
const indice = datos.findIndex(f => String(f[0]) === String(idLavador));
if (indice < 0) throw new Error('No se encontró el lavador.');
hoja.getRange(indice + 2, 7, 1, 2).setValues([[participa, motivoLimpio]]);
return { correcto: true, mensaje: participa ? 'Lavador habilitado para la evaluación semanal.' : 'Lavador marcado como No aplica (' + motivoLimpio + ').' };
}
function obtenerHojaIncentivos_(nombre) {
const hoja = SpreadsheetApp.openById(ID_HOJA).getSheetByName(nombre);
if (!hoja) throw new Error('Primero ejecuta configurarModuloIncentivos().');
return hoja;
}
function lunesDeSemana_(fecha) {
const f = new Date(fecha.getFullYear(), fecha.getMonth(), fecha.getDate());
const dia = f.getDay();
f.setDate(f.getDate() - ((dia + 6) % 7));
return f;
}
function motivoNoAplicaSemana_(lavador, lunes) {
if (!lavador) return '';
if (!lavador.aplicaIncentivo) return lavador.motivoNoAplica || 'No aplica por función';
if (lavador.fechaIngreso) {
const ingreso = parsearFechaLocal_(lavador.fechaIngreso);
if (ingreso && ingreso > lunes) return 'Aún no había ingresado';
}
return '';
}
function obtenerIncentivosSemana(fechaReferencia) {
const referencia = parsearFechaLocal_(fechaReferencia) || new Date();
const lunes = lunesDeSemana_(referencia);
const domingo = new Date(lunes); domingo.setDate(domingo.getDate() + 6);
const hoja = obtenerHojaIncentivos_(HOJA_INCENTIVOS_SEMANALES);
const registros = hoja.getLastRow() > 1 ? hoja.getRange(2, 1, hoja.getLastRow() - 1, 8).getValues() : [];
const porLavador = {};
registros.forEach(f => {
if (f[1] && f[3] instanceof Date && fechaISOIncentivos_(f[3]) === fechaISOIncentivos_(lunes)) {
porLavador[String(f[1])] = String(f[5] || '');
}
});
const lunesActual = lunesDeSemana_(new Date());
const esHistorica = lunes < lunesActual;
const lavadores = obtenerLavadores().filter(l => esHistorica || l.activo);
return {
semanaDesde: fechaISOIncentivos_(lunes),
semanaHasta: fechaISOIncentivos_(domingo),
registros: lavadores.map(l => {
const motivoNoAplica = motivoNoAplicaSemana_(l, lunes);
return {
idLavador: l.id,
nombre: l.nombre,
incentivo: motivoNoAplica ? 'No aplica' : (porLavador[l.id] || ''),
motivoNoAplica: motivoNoAplica,
aplicaIncentivo: !motivoNoAplica
};
})
};
}
function guardarIncentivosSemana(fechaReferencia, registros) {
const fecha = parsearFechaLocal_(fechaReferencia);
if (!fecha) throw new Error('Selecciona la semana que deseas registrar.');
const lunes = lunesDeSemana_(fecha);
if (fechaISOIncentivos_(fecha) !== fechaISOIncentivos_(lunes)) throw new Error('La fecha de la semana debe ser un lunes.');
if (!Array.isArray(registros) || !registros.length) throw new Error('No hay resultados para guardar.');
const lunesActual = lunesDeSemana_(new Date());
const esHistorica = lunes < lunesActual;
const lavadores = obtenerLavadores().filter(l => esHistorica || l.activo);
const activos = new Map(lavadores.map(l => [l.id, l]));
const hoja = obtenerHojaIncentivos_(HOJA_INCENTIVOS_SEMANALES);
const lock = LockService.getScriptLock();
lock.waitLock(30000);
try {
const existentes = hoja.getLastRow() > 1 ? hoja.getRange(2, 1, hoja.getLastRow() - 1, 8).getValues() : [];
const indices = new Map();
existentes.forEach((f, i) => {
if (f[1] && f[3] instanceof Date) indices.set(String(f[1]) + '|' + fechaISOIncentivos_(f[3]), i + 2);
});
const domingo = new Date(lunes); domingo.setDate(domingo.getDate() + 6);
const ahora = new Date();
let actualizados = 0, nuevos = 0;
registros.forEach(reg => {
const id = String(reg.idLavador || '');
let incentivo = String(reg.incentivo || '').trim();
const lavador = activos.get(id);
if (!lavador) throw new Error('Uno de los lavadores seleccionados no existe.');
const motivoNoAplica = motivoNoAplicaSemana_(lavador, lunes);
if (motivoNoAplica) incentivo = 'No aplica';
else if (!['Sí', 'No'].includes(incentivo)) throw new Error('Debes seleccionar Sí o No para ' + lavador.nombre + '.');
const clave = id + '|' + fechaISOIncentivos_(lunes);
const fila = [indices.has(clave) ? existentes[indices.get(clave) - 2][0] : Utilities.getUuid(), id, lavador.nombre, lunes, domingo, incentivo, indices.has(clave) ? existentes[indices.get(clave) - 2][6] : ahora, ahora];
if (indices.has(clave)) { hoja.getRange(indices.get(clave), 1, 1, 8).setValues([fila]); actualizados++; }
else { hoja.appendRow(fila); nuevos++; }
});
return { correcto: true, mensaje: 'Semana guardada. ' + nuevos + ' registro(s) nuevo(s) y ' + actualizados + ' actualizado(s).' };
} finally { lock.releaseLock(); }
}
function obtenerResumenBonosSemestrales() {
const libro = SpreadsheetApp.openById(ID_HOJA);
const hojaSem = libro.getSheetByName(HOJA_INCENTIVOS_SEMANALES);
const hojaBonos = libro.getSheetByName(HOJA_BONOS_SEMESTRALES);
if (!hojaSem || !hojaBonos) throw new Error('Primero ejecuta configurarModuloIncentivos().');
const incentivos = hojaSem.getLastRow() > 1 ? hojaSem.getRange(2, 1, hojaSem.getLastRow() - 1, 8).getValues() : [];
const bonos = hojaBonos.getLastRow() > 1 ? hojaBonos.getRange(2, 1, hojaBonos.getLastRow() - 1, 6).getValues() : [];
const config = new Map(bonos.map((f, i) => [String(f[0]), { valores:f, fila:i + 2 }]).filter(x => x[0]));
const hoy = new Date();
return obtenerLavadores().map((l, indiceLavador) => {
if (!l.aplicaIncentivo) {
return { idLavador:l.id, nombre:l.nombre, antiguedad:l.antiguedad, estado:'No aplica', faltas:0, fechaInicio:'', fechaEvaluacion:'', ultimoPago:'' };
}
const configLavador = config.get(l.id);
const c = configLavador ? configLavador.valores : [];
const ultimoPago = c[2] instanceof Date ? c[2] : null;
const fechaIngreso = l.fechaIngreso ? parsearFechaLocal_(l.fechaIngreso) : null;
// Primer ciclo: nunca antes del 01/06/2026, pero respeta la fecha real de ingreso.
// Ciclos siguientes: comienzan exactamente en la fecha del último bono pagado.
const inicioBase = ultimoPago || FECHA_INICIO_CALCULO_BONOS;
const inicio = fechaIngreso && fechaIngreso > inicioBase ? fechaIngreso : inicioBase;
if (!fechaIngreso) return { idLavador:l.id, nombre:l.nombre, antiguedad:l.antiguedad, estado:'Falta fecha de ingreso', faltas:0, fechaInicio:'', fechaEvaluacion:'', ultimoPago:fechaISOIncentivos_(ultimoPago) };
const evaluacion = sumarMesesFecha_(inicio, 6);
const cumpleAntiguedadMinima = hoy >= sumarMesesFecha_(fechaIngreso, 5);
const lunesInicio = new Date(lunesDeSemana_(inicio));
if (inicio.getDay() !== 1) lunesInicio.setDate(lunesInicio.getDate() + 7);
const faltas = incentivos.filter(f => String(f[1]) === l.id && f[5] === 'No' && f[3] instanceof Date && f[3] >= lunesInicio && f[4] instanceof Date && f[4] <= evaluacion).length;
const estadoGuardado = String(c[3] || '');
let estado = estadoGuardado || (!cumpleAntiguedadMinima || hoy < evaluacion ? 'En evaluación' : (faltas > LIMITE_FALTAS_BONO ? 'No corresponde' : 'Bono pendiente'));
if (estado === 'No corresponde' && !estadoGuardado) {
const fila = [l.id, l.nombre, ultimoPago || '', 'No corresponde', new Date(), 'Bloqueo automático: más de 9 semanas sin incentivo.'];
if (configLavador) hojaBonos.getRange(configLavador.fila, 1, 1, 6).setValues([fila]);
else hojaBonos.appendRow(fila);
}
return { idLavador:l.id, nombre:l.nombre, antiguedad:l.antiguedad, estado:estado, faltas:faltas, fechaInicio:fechaISOIncentivos_(inicio), fechaEvaluacion:fechaISOIncentivos_(evaluacion), ultimoPago:fechaISOIncentivos_(ultimoPago), fechaBloqueo:c[4] instanceof Date ? fechaISOIncentivos_(c[4]) : (estado === 'No corresponde' ? fechaISOIncentivos_(hoy) : '') };
});
}
function registrarPagoBonoSemestral(idLavador, fechaPago, observaciones) {
const fecha = parsearFechaLocal_(fechaPago) || new Date();
if (fecha > new Date(new Date().getFullYear(), new Date().getMonth(), new Date().getDate())) throw new Error('La fecha de pago no puede ser futura.');
const resumen = obtenerResumenBonosSemestrales().find(x => x.idLavador === String(idLavador));
if (!resumen) throw new Error('No se encontró el lavador.');
if (resumen.estado !== 'Bono pendiente') throw new Error('Solo se puede registrar el pago cuando el bono está pendiente. Estado actual: ' + resumen.estado + '.');
if (fecha < parsearFechaLocal_(resumen.fechaEvaluacion)) throw new Error('La fecha de pago no puede ser anterior a la fecha de evaluación del bono.');
const hoja = obtenerHojaIncentivos_(HOJA_BONOS_SEMESTRALES);
const historial = obtenerHojaIncentivos_(HOJA_HISTORIAL_BONOS);
const datos = hoja.getLastRow() > 1 ? hoja.getRange(2, 1, hoja.getLastRow() - 1, 6).getValues() : [];
const indice = datos.findIndex(f => String(f[0]) === String(idLavador));
const fila = [idLavador, resumen.nombre, fecha, '', '', String(observaciones || '')];
if (indice >= 0) hoja.getRange(indice + 2, 1, 1, 6).setValues([fila]); else hoja.appendRow(fila);
historial.appendRow([Utilities.getUuid(), idLavador, resumen.nombre, 'Bono pagado', new Date(), fecha, resumen.fechaInicio, resumen.fechaEvaluacion, resumen.faltas, resumen.estado, 'Bono pagado', String(observaciones || '')]);
return { correcto:true, mensaje:'Pago registrado. Comienza un nuevo período individual de seis meses desde ' + fechaISOIncentivos_(fecha) + '.' };
}
function desbloquearEstadoBono(idLavador, nuevoEstado, codigo, observaciones) {
const propiedades = PropertiesService.getScriptProperties();
const codigoConfigurado = propiedades.getProperty('CODIGO_AUTORIZACION_BONOS');
if (!codigoConfigurado) throw new Error('Primero configura el código de autorización de bonos desde el administrador del proyecto.');
if (String(codigo || '') !== codigoConfigurado) throw new Error('Código de autorización incorrecto.');
if (!['Bono pendiente', 'En evaluación'].includes(String(nuevoEstado))) throw new Error('El estado solicitado no está permitido.');
const hoja = obtenerHojaIncentivos_(HOJA_BONOS_SEMESTRALES);
const datos = hoja.getLastRow() > 1 ? hoja.getRange(2, 1, hoja.getLastRow() - 1, 6).getValues() : [];
const indice = datos.findIndex(f => String(f[0]) === String(idLavador));
if (indice < 0) throw new Error('No existe un registro de bono para ese lavador.');
const fila = indice + 2;
const anterior = String(datos[indice][3] || 'No corresponde');
hoja.getRange(fila, 4, 1, 3).setValues([[nuevoEstado, '', String(observaciones || '')]]);
obtenerHojaIncentivos_(HOJA_HISTORIAL_BONOS).appendRow([Utilities.getUuid(), idLavador, String(datos[indice][1] || ''), 'Desbloqueo autorizado', new Date(), '', '', '', '', anterior, nuevoEstado, String(observaciones || '')]);
return { correcto:true, mensaje:'Estado desbloqueado mediante autorización. El estado calculado se actualizará según las fechas y faltas registradas.' };
}
function obtenerHistorialIndividualLavador(idLavador) {
if (!idLavador) throw new Error('Selecciona un lavador.');
const libro = SpreadsheetApp.openById(ID_HOJA);
const hojaSem = libro.getSheetByName(HOJA_INCENTIVOS_SEMANALES);
const hojaBonos = libro.getSheetByName(HOJA_HISTORIAL_BONOS);
if (!hojaSem || !hojaBonos) throw new Error('Primero ejecuta configurarModuloIncentivos().');
const formato = valor => valor instanceof Date ? fechaISOIncentivos_(valor) : (valor || '');
const incentivos = hojaSem.getLastRow() > 1 ? hojaSem.getRange(2,1,hojaSem.getLastRow()-1,8).getValues() : [];
const bonos = hojaBonos.getLastRow() > 1 ? hojaBonos.getRange(2,1,hojaBonos.getLastRow()-1,12).getValues() : [];
return {
incentivos: incentivos.filter(f => String(f[1]) === String(idLavador)).map(f => ({
semanaDesde:formato(f[3]), semanaHasta:formato(f[4]), incentivo:String(f[5] || ''), fechaRegistro:formato(f[6])
})).sort((a,b) => b.semanaDesde.localeCompare(a.semanaDesde)),
bonos: bonos.filter(f => String(f[1]) === String(idLavador)).map(f => ({
movimiento:String(f[3] || ''), fecha:formato(f[4]), periodo:(formato(f[6]) && formato(f[7])) ? formato(f[6]) + ' a ' + formato(f[7]) : '',
faltas:f[8] === '' ? '' : String(f[8]), estado:String(f[10] || f[9] || ''), observaciones:String(f[11] || '')
})).sort((a,b) => b.fecha.localeCompare(a.fecha))
};
}
79,425 chars · codepastes.com