Code

Pasted Nov 10, 2026, 5:50 PM1 file2,358 lines

Scan to open on your phone

Point a camera at the QR code - it opens this exact paste, no app needed.

https://codepastes.com/zzgs86psy3rrqskn
Code

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