diff --git a/.clasp.json b/.clasp.json index 5b37329..f69d6a1 100644 --- a/.clasp.json +++ b/.clasp.json @@ -1,5 +1,5 @@ { - "scriptId": "17Syj1-R3oXZrnYtuLlf_5NitB3N_bz7wq7dIgyog8jRYCQN5kSj8H69h", + "scriptId": "18vY9LSc7K8fL-HErdaCJ0ar9ITO4IpvJ_UDi24rVbFgeGJhzfSny7FGi", "rootDir": "", "scriptExtensions": [ ".js", diff --git a/.claspignore b/.claspignore new file mode 100644 index 0000000..e92db0b --- /dev/null +++ b/.claspignore @@ -0,0 +1,33 @@ +**/.git/** +**/.gitignore +**/.gitattributes +**/node_modules/** +**/package.json +**/package-lock.json +**/.husky/** +**/.github/** +**/scripts/lint-html-scripts.js +**/*.md +**/.claude/** +**/.gstack/** +**/consolidacion_colab/** +**/.graphifyignore +graphify-out/** +commitlint.config.js +.claspignore +README.md +CONTRIBUTING.md +DESIGN.md +ROADMAP.md +ACTA_CIERRE_SPRINT1.md +ARCHITECTURE_*.md +DOCUMENTACION_TECNICA_VIVA.md +Documento_Tecnico_Aplicacion_Predios.* +TODOS.md +INICIO_SESION.md +CURRENT_STATE.md +PR_SUMMARY.md +PROTOCOLO_CIERRE_SESION_*.md +QA_SPRINT1_UIUX.md +gitleaks-report.json +--help/** diff --git a/.claude/settings.json b/.claude/settings.json index f1b9716..cd5e483 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,4 +1,15 @@ { + "permissions": { + "allow": [ + "Bash(clasp pull *)", + "Bash(rm -rf _comparacion_produccion)", + "Bash(clasp clone *)", + "Bash(cd \"E:\\\\PROYECTOS\\\\CLAUDE CODE\\\\CREACIÓN APK\\\\Aplicación de Predios\" && rm -rf _comparacion_produccion && echo \"✓ Carpeta temporal _comparacion_produccion eliminada\" && echo \"\" && echo \"=== VERIFICACIÓN FINAL ===\" && ls _comparacion_produccion 2>&1)", + "Bash(xargs -I {} sh -c 'echo \"{}:\"; wc -l {}')", + "Bash(xargs grep -l \"LockService\\\\|worktree\\\\|concurrent\")", + "Bash(grep -v \"//\")" + ] + }, "hooks": { "PreToolUse": [ { @@ -21,4 +32,4 @@ } ] } -} \ No newline at end of file +} diff --git a/.cursor/rules/ciclo-plan-dos-ias.mdc b/.cursor/rules/ciclo-plan-dos-ias.mdc new file mode 100644 index 0000000..b841a76 --- /dev/null +++ b/.cursor/rules/ciclo-plan-dos-ias.mdc @@ -0,0 +1,10 @@ +--- +description: Ciclo fijo de plan, auditoría y ejecución entre IAs en este repo. +alwaysApply: true +--- + +# Ciclo de trabajo entre IAs + +Misma regla que `~/.cursor/rules/armonia-ias.mdc`: un modelo avanzado planifica, otro audita, se publica un plan conjunto y un modelo económico ejecuta. Cursor, Claude y Copilot alternan esos roles. No hay un segundo plan en paralelo. + +En este repo, al ejecutar: script único `18vY9LSc7K8fL-HErdaCJ0ar9ITO4IpvJ_UDi24rVbFgeGJhzfSny7FGi`. Graphify antes de explorar código (`graphify query` / `path` / `explain`). diff --git a/.cursor/rules/convivencia-agentes.mdc b/.cursor/rules/convivencia-agentes.mdc new file mode 100644 index 0000000..4d88651 --- /dev/null +++ b/.cursor/rules/convivencia-agentes.mdc @@ -0,0 +1,14 @@ +--- +description: Convivencia de Cursor con Claude Code y GitHub Copilot en este repo. +alwaysApply: true +--- + +# Alternar IAs en el mismo repo + +Cursor entra al mismo modelo que Claude Code (`CLAUDE.md`) y GitHub Copilot (`DOCUMENTACION_TECNICA_VIVA.md` sección 16, `CONTRIBUTING.md`, `INICIO_SESION.md`). + +- Una sesión activa por checkout. Si `git status` muestra cambios que no son de esta sesión, no tocarlos ni revertirlos. +- Leer `TODOS.md` antes de proponer trabajo nuevo. Bitácora única: `DOCUMENTACION_TECNICA_VIVA.md`. Solo complementar; no borrar entradas de otro agente. Firmar las entradas nuevas como Cursor. +- `clasp push` solo si el usuario lo pide, y solo al script `18vY9…`. No empujar en paralelo con otra IA. +- Commits solo cuando el usuario lo pida. Formato: `tipo(módulo): descripción [TICKET]`. +- Graphify: la regla `.cursor/rules/graphify.mdc` es el equivalente Cursor del skill que Claude y Copilot ya tienen. Consultar el grafo antes de explorar código. diff --git a/.cursor/rules/graphify.mdc b/.cursor/rules/graphify.mdc new file mode 100644 index 0000000..35471a9 --- /dev/null +++ b/.cursor/rules/graphify.mdc @@ -0,0 +1,21 @@ +--- +description: graphify knowledge graph context +alwaysApply: true +--- + +This project has a graphify knowledge graph at graphify-out/. + +**MANDATORY: Before using Read, Grep, Glob, or Bash to explore the codebase, you MUST run graphify first:** +- `graphify query ""` — scoped subgraph for any codebase or architecture question +- `graphify path "" ""` — dependency path between two symbols +- `graphify explain ""` — all nodes related to a concept + +This applies to YOU and to every subagent you spawn. Include this rule explicitly in every subagent prompt that involves code exploration. Do not skip graphify because files are "already known" or because you are executing a plan — the graph surfaces cross-file dependencies and INFERRED edges that grep and Read cannot find. + +Only use Read/Grep/Glob directly when: +1. graphify has already oriented you and you need to modify or debug specific lines +2. `graphify-out/graph.json` does not exist yet + +- If `graphify-out/wiki/index.md` exists, navigate it instead of reading raw files +- Read `graphify-out/GRAPH_REPORT.md` only for broad architecture review when query/path/explain do not surface enough context +- After modifying code files, run `graphify update .` to keep the graph current (AST-only, no API cost) diff --git a/.cursor/rules/script-unico.mdc b/.cursor/rules/script-unico.mdc new file mode 100644 index 0000000..4f6d48d --- /dev/null +++ b/.cursor/rules/script-unico.mdc @@ -0,0 +1,16 @@ +--- +description: Solo el Apps Script 18vY9 de trabajo. Nunca otro proyecto de Google. +alwaysApply: true +--- + +# Un solo script de Google + +El único proyecto Apps Script de esta sesión es: + +`18vY9LSc7K8fL-HErdaCJ0ar9ITO4IpvJ_UDi24rVbFgeGJhzfSny7FGi` + +Editor: https://script.google.com/home/projects/18vY9LSc7K8fL-HErdaCJ0ar9ITO4IpvJ_UDi24rVbFgeGJhzfSny7FGi/edit + +Antes de cualquier `clasp push`, `clasp pull`, `clasp deploy` o `clasp clone`, leer `.clasp.json` y confirmar que `scriptId` es exactamente ese valor. Si no coincide, detenerse y avisarlo. + +No clonar, empujar ni editar los otros proyectos documentados (`16gqzy…` producción congelada, `17Syj1…` dev/test). No cambiar `scriptId` en `.clasp.json`. diff --git a/ARCHITECTURE_V5.md b/ARCHITECTURE_V5.md index 07c537e..57ed69d 100644 --- a/ARCHITECTURE_V5.md +++ b/ARCHITECTURE_V5.md @@ -1,6 +1,6 @@ # ARCHITECTURE_V5.md — Sprint 6: Estabilización Post-Línea Cero (Performance, Limpieza y UX) -**Estado:** [PLANIFICACIÓN — investigación completada, código no tocado todavía] +**Estado:** [COMPLETADO Y DESPLEGADO — 2026-08-18. Ver DOCUMENTACION_TECNICA_VIVA.md, Sección 25, para el detalle de la implementación real. Este documento se conserva como registro histórico del plan original.] **Agente:** Claude Code (Claude Sonnet 5, orquestado con gstack v1.60.1.0). **Fecha:** 2026-08-18. **Contexto:** la Línea Cero (Sprint 5) redujo los RTs pendientes de ~9691 a 4690 — éxito confirmado por el usuario en producción. Este sprint no es funcionalidad nueva, es estabilización: 4 puntos críticos de UX/Performance levantados directamente por el usuario tras usar el sistema real. diff --git a/Codigo.js b/Codigo.js index 122473b..11b65c8 100644 --- a/Codigo.js +++ b/Codigo.js @@ -156,6 +156,26 @@ function doGet(e) { const userEmail = Session.getActiveUser().getEmail() || ''; console.log(`👤 Usuario: ${userEmail}`); + var tokenCrudo = (e && e.parameter && e.parameter.sa) ? String(e.parameter.sa) : ''; + if (tokenCrudo) { + if (!/^[A-Za-z0-9-]{20,64}$/.test(tokenCrudo)) { + return HtmlService.createHtmlOutput( + '

Enlace no válido

Este enlace de observaciones no se puede abrir.

' + ).setTitle('Enlace no válido'); + } + if (!userEmail) { + return HtmlService.createHtmlOutput( + '

Entra con tu cuenta del IDU

Para registrar la observación tienes que abrir el enlace con tu correo institucional.

' + ).setTitle('Entra con tu cuenta del IDU'); + } + var formulario = HtmlService.createTemplateFromFile('seguimiento_formulario'); + formulario.tokenJs = JSON.stringify(tokenCrudo); + return formulario.evaluate() + .setTitle('Responder observaciones') + .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL) + .addMetaTag('viewport', 'width=device-width, initial-scale=1'); + } + // 3️⃣ OBTENER ROL (CON MANEJO DE ERRORES) let role = null; try { @@ -197,6 +217,9 @@ function doGet(e) { // expresiones complejas dentro de . template.userEmail = userEmail; template.safeUserEmailJS = JSON.stringify(userEmail || ''); + var tokenSeguimiento = (e && e.parameter && e.parameter.sa) ? String(e.parameter.sa) : ''; + if (!/^[A-Za-z0-9-]{20,64}$/.test(tokenSeguimiento)) tokenSeguimiento = ''; + template.tokenJs = JSON.stringify(tokenSeguimiento); return template.evaluate() .setTitle('Tablero de Gestión Predial - Enterprise Modular') @@ -534,7 +557,7 @@ function getDashboardData() { }); console.timeEnd('dashboard:sheetRead'); - const alertasResumen = evaluarAlertasDataset(allRecords); + const alertasResumen = resumirAlertasActivasParaDashboard_(allRecords); console.time('dashboard:serialize'); const cacheableResponse = { @@ -578,7 +601,7 @@ function getDashboardData() { alertasResumen: JSON.stringify({ success: false, totalAlertas: 0, - severidades: { CRITICA: 0, ALERTA: 0, ADVERTENCIA: 0, INFO: 0 }, + severidades: { ROJO: 0, NARANJA: 0, AMARILLO: 0 }, proyectosImpactados: 0, rtImpactados: 0, alertas: [], @@ -1069,42 +1092,81 @@ function getSavedReports(usuario) { } function saveReport(nombre, config, filtros, usuario) { + const actualUserEmail = Session.getActiveUser().getEmail(); + + // ✅ TRY/CATCH 1: PERMISOS (whitelist de roles que pueden crear) try { - const gestor = new GestorReportes(); - return gestor.guardarReporte(nombre, config, filtros, usuario); - } catch (e) { - console.error(`Error en saveReport: ${e.message}`); - return { - success: false, - error: e.message - }; + const gestor = new GestorPermisos(); + gestor.validarPermiso('REPORTES'); + + const rol = gestor.obtenerRol(actualUserEmail); + const ROLES_PUEDEN_CREAR_REPORTE = ['Administrador', 'Editor', 'Articulador']; + if (ROLES_PUEDEN_CREAR_REPORTE.indexOf(rol) === -1) { + throw new Error( + `❌ ACCESO DENEGADO — Solo ${ROLES_PUEDEN_CREAR_REPORTE.join(', ')} pueden crear reportes. ` + + `Tu rol es: ${rol}.` + ); + } + } catch (ePermiso) { + logAction(actualUserEmail, 'CREAR_REPORTE_DENEGADO', { razon: ePermiso.message }); + console.error(`Error en saveReport (permisos): ${ePermiso.message}`); + return { success: false, error: ePermiso.message }; + } + + // ✅ TRY/CATCH 2: LÓGICA DE NEGOCIO + try { + const gestorReportes = new GestorReportes(); + return gestorReportes.guardarReporte(nombre, config, filtros, usuario); + } catch (eLogica) { + console.error(`Error en saveReport (lógica): ${eLogica.message}`); + return { success: false, error: eLogica.message }; } } function executeReport(reporteId, usuario) { + const actualUserEmail = Session.getActiveUser().getEmail(); + + // ✅ TRY/CATCH 1: PERMISOS try { - const gestor = new GestorReportes(); - const resultado = gestor.ejecutarReporte(reporteId, usuario); + const gestor = new GestorPermisos(); + gestor.validarPermiso('REPORTES'); + } catch (ePermiso) { + logAction(actualUserEmail, 'EJECUTAR_REPORTE_DENEGADO', { razon: ePermiso.message }); + console.error(`Error en executeReport (permisos): ${ePermiso.message}`); + return JSON.stringify({ success: false, error: ePermiso.message }); + } + + // ✅ TRY/CATCH 2: LÓGICA DE NEGOCIO + try { + const gestorReportes = new GestorReportes(); + const resultado = gestorReportes.ejecutarReporte(reporteId, usuario); return JSON.stringify(resultado); - } catch (e) { - console.error(`Error en executeReport: ${e.message}`); - return JSON.stringify({ - success: false, - error: e.message - }); + } catch (eLogica) { + console.error(`Error en executeReport (lógica): ${eLogica.message}`); + return JSON.stringify({ success: false, error: eLogica.message }); } } function deleteReport(reporteId, usuario) { + const actualUserEmail = Session.getActiveUser().getEmail(); + + // ✅ TRY/CATCH 1: PERMISOS (requiere ELIMINAR, solo Admin) try { - const gestor = new GestorReportes(); - return gestor.eliminarReporte(reporteId, usuario); - } catch (e) { - console.error(`Error en deleteReport: ${e.message}`); - return { - success: false, - error: e.message - }; + const gestor = new GestorPermisos(); + gestor.validarPermiso('ELIMINAR'); + } catch (ePermiso) { + logAction(actualUserEmail, 'ELIMINAR_REPORTE_DENEGADO', { razon: ePermiso.message }); + console.error(`Error en deleteReport (permisos): ${ePermiso.message}`); + return { success: false, error: ePermiso.message }; + } + + // ✅ TRY/CATCH 2: LÓGICA DE NEGOCIO + try { + const gestorReportes = new GestorReportes(); + return gestorReportes.eliminarReporte(reporteId, usuario); + } catch (eLogica) { + console.error(`Error en deleteReport (lógica): ${eLogica.message}`); + return { success: false, error: eLogica.message }; } } @@ -1114,21 +1176,78 @@ function deleteReport(reporteId, usuario) { * ═══════════════════════════════════════════════════════════ */ +/** + * ✅ SPRINT6-FASE-0: Registra acción en auditoría. + * CRÍTICO: Obtiene identidad del usuario SIEMPRE del servidor (Session.getActiveUser), + * NUNCA del parámetro 'user' enviado por cliente (que podría ser falsificado). + * El parámetro 'user' se mantiene por compatibilidad pero es ignorado. + */ function logAction(user, action, details) { try { + // ✅ Obtener identidad real del servidor, ignorar el parámetro 'user' del cliente + const actualUserEmail = Session.getActiveUser().getEmail(); const auditoria = new GestorAuditoria(); - return auditoria.registrarAccion(user, action, details); + return auditoria.registrarAccion(actualUserEmail, action, details); } catch (e) { console.error(`Error en logAction: ${e.message}`); } } +/** + * ✅ [2026-10-06] Tarea R: entrega la URL de la herramienta de Normalización solo a + * usuarios con rol Administrador. El valor real vive en la Script Property + * NORMALIZACION_URL — nunca en código versionado ni en el frontend (antes estaba + * hardcodeada en app_core_js.html, enviada al navegador de cualquiera que abriera la + * web app, sin importar su rol). Registra tanto el acceso concedido como el intento + * denegado, igual que pac_verificarRolAdmin(). + */ +function obtenerUrlNormalizacion() { + try { + const actualUserEmail = Session.getActiveUser().getEmail(); + const gestorPermisos = new GestorPermisos(); + const rol = gestorPermisos.obtenerRol(actualUserEmail); + + if (rol !== getConfig('ROLES.ADMIN')) { + logAction(actualUserEmail, 'INTENTO_ACCESO_NORMALIZACION_DENEGADO', 'Rol actual: ' + rol); + return { success: false, mensaje: 'No tiene permiso para acceder a esta herramienta. Requiere rol Administrador.' }; + } + + const url = getConfigProperty('NORMALIZACION_URL', ''); + if (!url) { + console.warn('⚠️ obtenerUrlNormalizacion: NORMALIZACION_URL no está configurada en Script Properties.'); + return { success: false, mensaje: 'La herramienta de Normalización no está configurada. Contacte al administrador del sistema.' }; + } + + logAction(actualUserEmail, 'Acceso a Normalización', 'Abrió CONSOLIDADO Script'); + return { success: true, url: url }; + } catch (e) { + console.error(`Error en obtenerUrlNormalizacion: ${e.message}`); + return { success: false, mensaje: 'Error interno: ' + e.message }; + } +} + function getUserLogs(usuario) { + const requesterEmail = Session.getActiveUser().getEmail(); try { + const gestor = new GestorPermisos(); + const esConsultaPropia = (usuario === requesterEmail); + + // Validar: si no es consulta propia (incluyendo 'ALL'), requerir 'PERMISOS' + if (!esConsultaPropia) { + gestor.validarPermiso('PERMISOS'); + } + + // Acceso permitido — proceder a obtener logs const auditoria = new GestorAuditoria(); const logs = auditoria.obtenerLogsUsuario(usuario); return JSON.stringify(logs); + } catch (e) { + // Log de intento denegado para auditoría + logAction(requesterEmail, 'ACCESO_LOGS_DENEGADO', { + usuarioSolicitado: usuario, + razonRechazo: e.message + }); console.error(`Error en getUserLogs: ${e.message}`); return JSON.stringify([]); } @@ -1280,6 +1399,10 @@ function getGeneralStats() { function initializeSystem() { try { + // ✅ SPRINT6-FASE-0: Validación RBAC server-side + const gestorPermisos = new GestorPermisos(); + gestorPermisos.validarPermiso('ADMIN_SISTEMA'); // Lanza error si no es ADMIN + validateConfig(); const gestor = new GestorDatos(); @@ -1813,13 +1936,14 @@ function _buildAlertasEmailAttachmentGrid(alertasRows, fileNameBase) { const rows = Array.isArray(alertasRows) ? alertasRows : []; const headers = [ 'TIMESTAMP', 'NIVEL', 'RT', 'PROYECTO', 'ARTICULADOR', 'ARTICULADOR_EMAIL', - 'GESTOR_EMAIL', 'FASE', 'REGLA', 'DIAS RESTANTES', 'MENSAJE', + 'GESTOR_EMAIL', 'FASE', 'REGLA', 'DIAS RESTANTES', 'FECHA_VENCIMIENTO', 'MENSAJE', 'ESTADO PREDIAL ACTUAL', 'OBSERVACIONES' ]; const tableRows = rows.map(function(r) { return '' + headers.map(function(h) { - return '' + _escapeHtmlAlertaEmail(r[h]) + ''; + var valor = h === 'FECHA_VENCIMIENTO' ? _textoFechaAlerta_(r[h]) : r[h]; + return '' + _escapeHtmlAlertaEmail(valor) + ''; }).join('') + ''; }).join(''); @@ -1880,97 +2004,165 @@ function _obtenerObservacionesPorRT(rtsObjetivo) { return mapa; } -function _buildResumenHtmlAgrupado(alertasRows, filtros, etiquetaDestino) { +function _etiquetaSemaforoAlerta_(nivel) { + var n = String(nivel || '').toUpperCase(); + if (n === 'ROJO') return 'Crítica'; + if (n === 'NARANJA') return 'Riesgo'; + if (n === 'AMARILLO') return 'Preventiva'; + return n || 'Sin nivel'; +} + +function _diasVigenciaTexto_(valor) { + var crudo = String(valor == null ? '' : valor).trim().replace(',', '.'); + if (!crudo || !/^-?\d+(\.\d+)?$/.test(crudo)) return { orden: 1e15, texto: 'sin dato' }; + var dias = Math.trunc(Number(crudo)); + if (dias < 0) return { orden: dias, texto: 'vencido hace ' + Math.abs(dias) + ' días' }; + return { orden: dias, texto: dias + ' días de vigencia' }; +} + +function _datoOSinDato_(valor) { + var texto = String(valor == null ? '' : valor).trim(); + return texto || 'sin dato'; +} + +function _textoArticuladorAlerta_(row) { + var nombre = String(row.ARTICULADOR || '').trim(); + var correo = String(row.ARTICULADOR_EMAIL || '').trim(); + if (nombre && correo && nombre.toLowerCase() !== correo.toLowerCase()) return nombre + ' (' + correo + ')'; + return nombre || correo || 'sin dato'; +} + +function _renglonRtAlerta_(row) { + return 'RT ' + _datoOSinDato_(row.RT) + + ' · ' + _datoOSinDato_(row.PROYECTO) + + ' · ' + _diasVigenciaTexto_(row['DIAS RESTANTES']).texto + + ' · Gestor: ' + _datoOSinDato_(row.GESTOR_EMAIL) + + ' · Articulador: ' + _textoArticuladorAlerta_(row) + + ' · Estado predial: ' + _datoOSinDato_(row['ESTADO PREDIAL ACTUAL']); +} + +function _ordenarRtsPorVigencia_(lista) { + return (lista || []).slice().sort(function(a, b) { + var da = _diasVigenciaTexto_(a['DIAS RESTANTES']).orden; + var db = _diasVigenciaTexto_(b['DIAS RESTANTES']).orden; + if (da !== db) return da - db; + var ra = String(a.RT || ''); + var rb = String(b.RT || ''); + if (ra < rb) return -1; + if (ra > rb) return 1; + return 0; + }); +} + +function _botonResponderObservaciones_(url) { + if (!url) return ''; + var href = _escapeHtmlAlertaEmail(url); + return '
' + + 'Responder observaciones' + + '
'; +} + +function _contextoBotonObservaciones_() { + return '

Para cargar las observaciones hay que pulsar el botón Responder observaciones.

' + + '

La respuesta es obligatoria en las alertas que la exigen. Este correo se envía a la persona que tiene la asignación.

' + + '

Se requiere la respuesta dentro de los próximos tres días hábiles. Si no se cuenta con ella, se remitirá un informe al articulador. Si al séptimo día hábil sigue sin respuesta, se informará a quien remitió la alerta y al jefe inmediato.

'; +} + +function _avisoAmarilloGestor_() { + return '
Si usted no es el gestor del RT, notifique a su articulador para la actualización de los grupos de trabajo.
'; +} + +var TEXTO_BASE_CORREO_ALERTAS_ = 'Equipo de seguimiento predial. Este informe relaciona los predios que las reglas de alerta temprana marcaron al contrastar la fecha de inicio y la vida útil configuradas en la base de datos del tablero. Junto a cada RT se muestra el estado predial tal como está registrado, para que el equipo verifique si corresponde a la gestión más reciente. Cuando la alerta lo exige, hay que cargar una observación por cada RT. Si la decisión es que el proceso no se va a usar, se adjunta un solo archivo: el informe técnico / jurídico. Si la gestión o el documento ya existen y lo que falta es actualizar la base, marque Revisar observación, indique la fecha del documento y deje la evidencia cargada en los repositorios institucionales para que pueda corroborarse.'; + +function _textoFechaAlerta_(valor) { + if (valor instanceof Date && !isNaN(valor.getTime())) { + return Utilities.formatDate(valor, Session.getScriptTimeZone() || 'America/Bogota', 'yyyy-MM-dd'); + } + var texto = String(valor == null ? '' : valor).trim(); + if (/^\d{4}-\d{2}-\d{2}/.test(texto)) return texto.substring(0, 10); + return texto || 'Sin fecha'; +} + +function _asuntoAlertas_(rows, hoy) { + var nombres = {}; + (rows || []).forEach(function(row) { + var nombre = String(row.REGLA || '').trim(); + if (nombre) nombres[nombre] = true; + }); + var lista = Object.keys(nombres); + var etiqueta = lista.length === 1 ? lista[0] : 'Varias reglas'; + return 'Alerta temprana - ' + etiqueta + ' [' + hoy + ']'; +} + +function _introCorreoAlertas_(filas, textos) { + var mapa = textos || {}; + var nombres = {}; + (filas || []).forEach(function(row) { + var nombre = String(row.REGLA || '').trim(); + if (nombre) nombres[nombre] = true; + }); + var lista = Object.keys(nombres); + if (lista.length === 1) { + var propio = String(mapa[lista[0].toUpperCase()] || '').trim(); + return { general: propio || TEXTO_BASE_CORREO_ALERTAS_, porRegla: false }; + } + return { general: TEXTO_BASE_CORREO_ALERTAS_, porRegla: true }; +} + +function _buildResumenHtmlAgrupado(alertasRows, filtros, etiquetaDestino, urlSeguimiento, mapaPolitica, textosIniciales) { const filas = Array.isArray(alertasRows) ? alertasRows : []; const f = filtros || {}; + const mapa = mapaPolitica || (typeof mapaPoliticasSeguimiento_ === 'function' ? mapaPoliticasSeguimiento_() : {}); const total = filas.length; - const count = { ROJO: 0, NARANJA: 0, AMARILLO: 0 }; - const porTipo = {}; + const grupos = {}; filas.forEach(function(row) { - const nivel = String(row.NIVEL || '').toUpperCase(); + const nivel = String(row.NIVEL || '').toUpperCase() || 'SIN NIVEL'; if (count[nivel] !== undefined) count[nivel]++; - const regla = String(row.REGLA || 'SIN REGLA').trim() || 'SIN REGLA'; - const tipoKey = nivel + '||' + regla; - if (!porTipo[tipoKey]) { - porTipo[tipoKey] = { - nivel: nivel || 'SIN NIVEL', - regla: regla, - total: 0, - articuladores: {} - }; - } - porTipo[tipoKey].total++; - - const articulador = String(row.ARTICULADOR_EMAIL || row.ARTICULADOR || 'ARTICULADOR SIN ASIGNAR').trim() || 'ARTICULADOR SIN ASIGNAR'; - const gestor = String(row.GESTOR_EMAIL || 'GESTOR SIN ASIGNAR').trim() || 'GESTOR SIN ASIGNAR'; - if (!porTipo[tipoKey].articuladores[articulador]) { - porTipo[tipoKey].articuladores[articulador] = { total: 0, gestores: {} }; - } - porTipo[tipoKey].articuladores[articulador].total++; - porTipo[tipoKey].articuladores[articulador].gestores[gestor] = (porTipo[tipoKey].articuladores[articulador].gestores[gestor] || 0) + 1; + if (!grupos[nivel]) grupos[nivel] = {}; + if (!grupos[nivel][regla]) grupos[nivel][regla] = []; + grupos[nivel][regla].push(row); }); function colorTipo(nivel) { - if (nivel === 'ROJO') return { bg: '#fdecec', border: '#c0392b', text: '#9b1c1c', badge: 'rgba(192,57,43,.12)' }; - if (nivel === 'NARANJA') return { bg: '#fff4ea', border: '#e67e22', text: '#b45309', badge: 'rgba(230,126,34,.14)' }; - if (nivel === 'AMARILLO') return { bg: '#fffbea', border: '#f1c40f', text: '#8a6d00', badge: 'rgba(241,196,15,.20)' }; - return { bg: '#eef2f7', border: '#64748b', text: '#334155', badge: 'rgba(100,116,139,.14)' }; + if (nivel === 'ROJO') return { bg: '#fdecec', border: '#c0392b', text: '#9b1c1c' }; + if (nivel === 'NARANJA') return { bg: '#fff4ea', border: '#e67e22', text: '#b45309' }; + if (nivel === 'AMARILLO') return { bg: '#fffbea', border: '#f1c40f', text: '#8a6d00' }; + return { bg: '#eef2f7', border: '#64748b', text: '#334155' }; } - const tiposOrdenados = Object.keys(porTipo).sort(function(a, b) { - return porTipo[b].total - porTipo[a].total; - }); - - const tiposHtml = tiposOrdenados.map(function(tipoKey) { - const tipo = porTipo[tipoKey]; - const c = colorTipo(tipo.nivel); - const articuladoresOrdenados = Object.keys(tipo.articuladores).sort(function(a, b) { - return tipo.articuladores[b].total - tipo.articuladores[a].total; - }); - - const articuladoresHtml = articuladoresOrdenados.map(function(articulador) { - const dataArt = tipo.articuladores[articulador]; - const gestoresRows = Object.keys(dataArt.gestores).sort(function(a, b) { - return dataArt.gestores[b] - dataArt.gestores[a]; - }).map(function(gestor) { - return '' + _escapeHtmlAlertaEmail(gestor) + '' + dataArt.gestores[gestor] + ''; + const orden = ['ROJO', 'NARANJA', 'AMARILLO'].concat(Object.keys(grupos).filter(function(n) { + return n !== 'ROJO' && n !== 'NARANJA' && n !== 'AMARILLO'; + })); + const intro = _introCorreoAlertas_(filas, textosIniciales); + const bloques = []; + let hayObligacion = false; + orden.forEach(function(nivel) { + if (!grupos[nivel]) return; + const c = colorTipo(nivel); + const reglasHtml = Object.keys(grupos[nivel]).map(function(regla) { + const lista = _ordenarRtsPorVigencia_(grupos[nivel][regla]); + const pol = typeof politicaSeguimientoDeRegla_ === 'function' ? politicaSeguimientoDeRegla_(regla, nivel, mapa) : null; + if (pol && pol.exigir_observacion) hayObligacion = true; + const items = lista.map(function(row) { + return '' + _escapeHtmlAlertaEmail(_renglonRtAlerta_(row)) + ''; }).join(''); - - return [ - '
', - '
', - '👤 Articulador: ' + _escapeHtmlAlertaEmail(articulador) + ' (' + dataArt.total + ')', - '
', - '', - '', - '', gestoresRows, '', - '
GestorCantidad
', - '
' - ].join(''); + const nota = (pol && pol.nota_correo) ? '

' + _escapeHtmlAlertaEmail(pol.nota_correo) + '

' : ''; + const propio = intro.porRegla ? String((textosIniciales || {})[String(regla).trim().toUpperCase()] || '').trim() : ''; + const textoRegla = propio ? '

' + _escapeHtmlAlertaEmail(propio) + '

' : ''; + return '
' + _escapeHtmlAlertaEmail(regla) + ' · ' + lista.length + '
' + textoRegla + '' + items + '
' + nota + '
'; }).join(''); - - return [ - '
', - '
', - '
', - '🔔 ' + _escapeHtmlAlertaEmail(tipo.regla), - '
', - '
', - _escapeHtmlAlertaEmail(tipo.nivel) + ' · ' + tipo.total, - '
', - '
', - '
', - articuladoresHtml, - '
' - ].join(''); - }).join(''); + bloques.push('
' + _escapeHtmlAlertaEmail(_etiquetaSemaforoAlerta_(nivel)) + '
' + reglasHtml + '
'); + }); const lblNivel = (!f.nivel || f.nivel === 'ALL') ? 'Todos' : f.nivel; const lblRegla = (!f.regla || f.regla === 'ALL') ? 'Todas' : f.regla; const lblProyecto = (!f.proyecto || f.proyecto === 'ALL') ? 'Todos' : f.proyecto; const lblArt = (!f.articulador || f.articulador === 'ALL') ? 'Todos' : f.articulador; + const hayEnlace = hayObligacion && urlSeguimiento; + const apertura = hayEnlace ? (_contextoBotonObservaciones_() + _botonResponderObservaciones_(urlSeguimiento) + _avisoAmarilloGestor_()) : ''; + const cierre = hayEnlace ? _botonResponderObservaciones_(urlSeguimiento) : ''; return [ '
', @@ -1978,24 +2170,606 @@ function _buildResumenHtmlAgrupado(alertasRows, filtros, etiquetaDestino) { '

Informe de Alertas Tempranas

', '
Destinatario: ' + _escapeHtmlAlertaEmail(etiquetaDestino) + '
', '
', - '', + apertura, + '

' + _escapeHtmlAlertaEmail(intro.general) + '

', + '
', '', '', '', '', '
Filtro Nivel' + _escapeHtmlAlertaEmail(lblNivel) + '
Filtro Regla' + _escapeHtmlAlertaEmail(lblRegla) + '
Filtro Proyecto' + _escapeHtmlAlertaEmail(lblProyecto) + '
Filtro Articulador' + _escapeHtmlAlertaEmail(lblArt) + '
', '
', - 'Total alertas: ' + total + '  |  ROJO: ' + count.ROJO + '  |  NARANJA: ' + count.NARANJA + '  |  AMARILLO: ' + count.AMARILLO, + 'Total alertas: ' + total + '  |  Crítica: ' + count.ROJO + '  |  Riesgo: ' + count.NARANJA + '  |  Preventiva: ' + count.AMARILLO, '
', - '

Agrupación por tipo de alerta → articulador → gestor

', - tiposHtml, + bloques.join(''), + cierre, '

Se adjunta archivo tabular con cuadrícula y columna OBSERVACIONES.

', '
' ].join(''); } +function _textoPlanoAlertas_(alertasRows, etiquetaDestino, urlSeguimiento, mapaPolitica, textosIniciales) { + const filas = Array.isArray(alertasRows) ? alertasRows : []; + const mapa = mapaPolitica || {}; + const grupos = {}; + filas.forEach(function(row) { + const nivel = String(row.NIVEL || '').toUpperCase() || 'SIN NIVEL'; + const regla = String(row.REGLA || 'SIN REGLA').trim() || 'SIN REGLA'; + if (!grupos[nivel]) grupos[nivel] = {}; + if (!grupos[nivel][regla]) grupos[nivel][regla] = []; + grupos[nivel][regla].push(row); + }); + const intro = _introCorreoAlertas_(filas, textosIniciales); + const lineas = ['Informe de Alertas Tempranas', 'Destinatario: ' + (etiquetaDestino || ''), '', intro.general]; + let hayObligacion = false; + Object.keys(grupos).forEach(function(nivel) { + lineas.push(''); + lineas.push(_etiquetaSemaforoAlerta_(nivel)); + Object.keys(grupos[nivel]).forEach(function(regla) { + const lista = _ordenarRtsPorVigencia_(grupos[nivel][regla]); + const pol = typeof politicaSeguimientoDeRegla_ === 'function' ? politicaSeguimientoDeRegla_(regla, nivel, mapa) : null; + if (pol && pol.exigir_observacion) hayObligacion = true; + lineas.push(regla + ' (' + lista.length + ')'); + if (intro.porRegla) { + var propio = String((textosIniciales || {})[String(regla).trim().toUpperCase()] || '').trim(); + if (propio) lineas.push(propio); + } + lista.forEach(function(row) { + lineas.push('- ' + _renglonRtAlerta_(row)); + }); + if (pol && pol.nota_correo) lineas.push(pol.nota_correo); + }); + }); + if (hayObligacion && urlSeguimiento) { + lineas.splice(2, 0, + '', + 'Para cargar las observaciones hay que pulsar el botón Responder observaciones.', + 'La respuesta es obligatoria en las alertas que la exigen. Este correo se envía a la persona que tiene la asignación.', + 'Se requiere la respuesta dentro de los próximos tres días hábiles. Si no se cuenta con ella, se remitirá un informe al articulador. Si al séptimo día hábil sigue sin respuesta, se informará a quien remitió la alerta y al jefe inmediato.', + 'Si usted no es el gestor del RT, notifique a su articulador para la actualización de los grupos de trabajo.', + 'Responder observaciones: ' + urlSeguimiento + ); + lineas.push('', 'Responder observaciones: ' + urlSeguimiento); + } + lineas.push('', 'Se adjunta el archivo con el detalle.'); + return lineas.join('\n'); +} + +function probarCuerpoAlertas_() { + var denegado = _exigirAdminMotorReglas_('PROBAR_CUERPO_ALERTAS'); + if (denegado) { + console.log(denegado.error || 'Requiere rol Administrador.'); + return denegado; + } + var base = { + NIVEL: 'ROJO', + REGLA: 'Regla prueba', + PROYECTO: 'Proyecto prueba', + GESTOR_EMAIL: 'gestor@idu.gov.co', + ARTICULADOR: 'Ana Ruiz', + ARTICULADOR_EMAIL: 'ana.ruiz@idu.gov.co', + 'ESTADO PREDIAL ACTUAL': 'En gestión' + }; + function fila(rt, dias) { + var copia = {}; + Object.keys(base).forEach(function(k) { copia[k] = base[k]; }); + copia.RT = rt; + copia['DIAS RESTANTES'] = dias; + return copia; + } + var filas = [ + fila('B', 5), + fila('A', ''), + fila('C', -3), + fila('D', 0), + fila('E', 5) + ]; + var i; + for (i = 1; i <= 150; i++) filas.push(fila('F' + i, 10 + i)); + var html = _buildResumenHtmlAgrupado(filas, {}, 'prueba', '', {}); + var texto = _textoPlanoAlertas_(filas, 'prueba', '', {}); + var faltan = []; + filas.forEach(function(f) { + if (html.indexOf('RT ' + f.RT + ' ·') < 0 || texto.indexOf('RT ' + f.RT + ' ·') < 0) faltan.push(f.RT); + }); + var marcas = ['RT C ·', 'RT D ·', 'RT B ·', 'RT E ·', 'RT F1 ·', 'RT A ·'].map(function(m) { + return texto.indexOf(m); + }); + var ordenOk = marcas.every(function(pos, idx) { + return pos >= 0 && (idx === 0 || pos > marcas[idx - 1]); + }); + var frase = html.indexOf('Los demás están') >= 0 || texto.indexOf('Los demás están') >= 0; + console.log('RT faltantes: ' + (faltan.length ? faltan.join(', ') : 'ninguno')); + console.log('Orden: ' + (ordenOk ? 'correcto' : 'falló')); + console.log('Caracteres HTML: ' + html.length); + console.log('Frase de adjunto: ' + (frase ? 'apareció' : 'no aparece')); + return { success: faltan.length === 0 && ordenOk && !frase, caracteres: html.length, faltantes: faltan.length }; +} + + +function _diasHastaVencimiento_(fechaTexto) { + var t = _textoFechaAlerta_(fechaTexto); + if (t === 'Sin fecha' || !/^\d{4}-\d{2}-\d{2}$/.test(t)) return null; + var hoy = Utilities.formatDate(new Date(), Session.getScriptTimeZone() || 'America/Bogota', 'yyyy-MM-dd'); + function aUtc(s) { + var p = s.split('-'); + return Date.UTC(Number(p[0]), Number(p[1]) - 1, Number(p[2])); + } + return Math.round((aUtc(t) - aUtc(hoy)) / 86400000); +} + +function _diasRestantesNumero_(valor) { + var crudo = String(valor == null ? '' : valor).trim().replace(',', '.'); + if (!/^-?\d+(\.\d+)?$/.test(crudo)) return null; + return Math.trunc(Number(crudo)); +} + +function _cuboDesdeDias_(dias) { + if (dias === null || dias === undefined) return 'sin'; + if (dias < 0) return null; + if (dias <= 30) return 'd30'; + if (dias <= 60) return 'd60'; + if (dias <= 90) return 'd90'; + return 'mas90'; +} + +function _cuboVencimiento_(fechaTexto) { + return _cuboDesdeDias_(_diasHastaVencimiento_(fechaTexto)); +} + +function _historialSeguimientoInforme_(filasEnvio) { + var claves = {}; + (filasEnvio || []).forEach(function(row) { + var clave = _claveRtRegla_(row.RT, row.REGLA); + if (String(row.RT || '').trim() || String(row.REGLA || '').trim()) claves[clave] = true; + }); + var lectura = (typeof _leerSeguimientoAlertas_ === 'function') ? _leerSeguimientoAlertas_() : { filas: [] }; + var filas = []; + var respondidas = {}; + var noUso = {}; + var revision = {}; + (lectura.filas || []).forEach(function(fila) { + var clave = _claveRtRegla_(fila.RT, fila.REGLA); + if (!claves[clave]) return; + var estado = String(fila.ESTADO || ''); + if (estado !== 'RESPONDIDA' && estado !== 'PENDIENTE') return; + var marcoNoUso = String(fila.NO_USO || '').toUpperCase() === 'SI'; + var marcoRevision = String(fila.REVISION_ESTADO || '').toUpperCase() === 'SI'; + if (estado === 'RESPONDIDA') { + respondidas[clave] = true; + if (marcoNoUso) noUso[clave] = true; + if (marcoRevision) revision[clave] = true; + } + var persona = String(fila.GESTOR_EMAIL || '').trim() || String(fila.ARTICULADOR_EMAIL || '').trim(); + filas.push({ + rt: String(fila.RT || ''), + regla: String(fila.REGLA || ''), + persona: persona, + estado: estado, + noUso: marcoNoUso ? 'SI' : 'NO', + revision: marcoRevision ? 'SI' : 'NO', + enlace: String(fila.ARCHIVO_JURIDICO_URL || '').trim() + }); + }); + var total = Object.keys(claves).length; + var nRespondidas = Object.keys(respondidas).length; + return { + conteo: { + total: total, + respondidas: nRespondidas, + pendientes: total - nRespondidas, + noUso: Object.keys(noUso).length, + revision: Object.keys(revision).length + }, + filas: filas + }; +} + +function _reglasDeTiempoInforme_() { + var mapa = {}; + var leido = (typeof obtenerReglasJSON === 'function') ? obtenerReglasJSON() : null; + if (!leido || !leido.success) return mapa; + var parsed; + try { parsed = JSON.parse(leido.data || '{}'); } catch (e) { return mapa; } + var reglas = parsed && Array.isArray(parsed.ReglasDeNegocio) ? parsed.ReglasDeNegocio : []; + reglas.forEach(function(regla) { + var nombre = String(regla && regla.nombre || '').trim().toUpperCase(); + if (!nombre) return; + var tiempo = regla.regla_tiempo || {}; + mapa[nombre] = { + columna: String(tiempo.columna_fecha_inicio || '').trim().toUpperCase(), + vida: Number(tiempo.dias_vida_util), + tipo: String(tiempo.tipo_dias || 'CALENDARIO').toUpperCase() + }; + }); + return mapa; +} + +function _parseFechaInforme_(valor) { + if (valor instanceof Date && !isNaN(valor.getTime())) { + var copia = new Date(valor.getTime()); + copia.setHours(12, 0, 0, 0); + return copia; + } + var s = String(valor || '').trim(); + if (!s) return null; + var iso = s.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (iso) return new Date(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3]), 12, 0, 0); + var partes = s.split(/[-/]/); + if (partes.length >= 3 && partes[0].length <= 2) { + var dia = parseInt(partes[0], 10); + var mes = parseInt(partes[1], 10) - 1; + var anioTxt = String(partes[2]).replace(/\D/g, ''); + var anio = anioTxt.length === 2 ? 2000 + parseInt(anioTxt, 10) : parseInt(anioTxt, 10); + var fecha = new Date(anio, mes, dia, 12, 0, 0); + if (fecha.getFullYear() === anio && fecha.getMonth() === mes && fecha.getDate() === dia) return fecha; + } + return null; +} + +function _sumarDiasCalendarioInforme_(fecha, dias) { + var d = new Date(fecha.getTime()); + d.setHours(12, 0, 0, 0); + d.setDate(d.getDate() + Math.round(Number(dias) || 0)); + return d; +} + +function _fechasInicioPorRt_(rts, columnas) { + var mapa = {}; + var ids = (typeof getDataFilesIds === 'function') ? getDataFilesIds() : []; + var hojaNombre = getConfig('SHEETS.DATOS'); + var colRt = String(getConfig('COLUMNS.RT', 'RT') || 'RT').toUpperCase().trim(); + ids.forEach(function(id) { + try { + var ss = SpreadsheetApp.openById(id); + var ws = ss.getSheetByName(hojaNombre); + if (!ws) return; + var values = ws.getDataRange().getValues(); + if (!values || values.length < 2) return; + var headers = values[0].map(function(h) { return String(h || '').toUpperCase().trim(); }); + var idxRt = headers.indexOf(colRt); + if (idxRt < 0) return; + var idxCols = {}; + columnas.forEach(function(c) { idxCols[c] = headers.indexOf(c); }); + for (var r = 1; r < values.length; r++) { + var rt = String(values[r][idxRt] || '').trim().toUpperCase(); + if (!rt || !rts[rt] || mapa[rt]) continue; + var dato = {}; + columnas.forEach(function(c) { + if (idxCols[c] >= 0) dato[c] = values[r][idxCols[c]]; + }); + mapa[rt] = dato; + } + } catch (e) { + console.warn('No se pudo leer la fecha de la matriz ' + id + ': ' + e.message); + } + }); + return mapa; +} + +function _completarFechasVencimiento_(filas) { + var lista = Array.isArray(filas) ? filas : []; + var reglas = _reglasDeTiempoInforme_(); + var rts = {}; + var columnas = { 'FECHA AVALUO': true }; + lista.forEach(function(row) { + var rt = String(row.RT || '').trim().toUpperCase(); + if (rt) rts[rt] = true; + var conf = reglas[String(row.REGLA || '').trim().toUpperCase()]; + if (conf && conf.columna) columnas[conf.columna] = true; + }); + var fechas = _fechasInicioPorRt_(rts, Object.keys(columnas)); + var tz = Session.getScriptTimeZone() || 'America/Bogota'; + var motorHabil = null; + lista.forEach(function(row) { + var ya = _textoFechaAlerta_(row.FECHA_VENCIMIENTO); + if (ya !== 'Sin fecha') { + row.FECHA_VENCIMIENTO = ya; + return; + } + var nombre = String(row.REGLA || '').trim().toUpperCase(); + var conf = reglas[nombre] || {}; + var esAvaluo = nombre.indexOf('AVALU') >= 0; + var columna = conf.columna || (esAvaluo ? 'FECHA AVALUO' : ''); + var vida = Number(conf.vida); + if (!(vida > 0) && esAvaluo) vida = 365; + var tipo = conf.tipo || 'CALENDARIO'; + if (!columna || !(vida > 0)) return; + var datoRt = fechas[String(row.RT || '').trim().toUpperCase()] || {}; + var crudo = datoRt[columna]; + if ((crudo === undefined || crudo === '' || crudo === null) && esAvaluo && columna !== 'FECHA AVALUO') { + crudo = datoRt['FECHA AVALUO']; + if (!(Number(conf.vida) > 0)) vida = 365; + tipo = Number(conf.vida) > 0 ? tipo : 'CALENDARIO'; + } + var inicio = _parseFechaInforme_(crudo); + if (!inicio) return; + var fin; + if (tipo === 'HABILES' && typeof MotorEvaluadorReglas === 'function') { + if (!motorHabil) motorHabil = new MotorEvaluadorReglas(); + fin = motorHabil._sumarDiasHabiles(inicio, vida); + } else { + fin = _sumarDiasCalendarioInforme_(inicio, vida); + } + row.FECHA_VENCIMIENTO = Utilities.formatDate(fin, tz, 'yyyy-MM-dd'); + }); +} + +function _cuboFilaInforme_(row) { + var porDias = _diasRestantesNumero_(row && row['DIAS RESTANTES']); + if (porDias !== null) return _cuboDesdeDias_(porDias); + return _cuboVencimiento_(row && row.FECHA_VENCIMIENTO); +} + +function _conteoVencimientos_(filas) { + var c = { d30: 0, d60: 0, d90: 0, mas90: 0, sin: 0 }; + (filas || []).forEach(function(row) { + var cubo = _cuboFilaInforme_(row); + if (!cubo || c[cubo] === undefined) return; + c[cubo]++; + }); + return c; +} + +function _tortaVencimientos_(filas) { + var c = _conteoVencimientos_(filas); + var etiquetas = [ + ['0 a 30', c.d30], + ['31 a 60', c.d60], + ['61 a 90', c.d90], + ['Mayor de 90', c.mas90] + ]; + if (c.sin) etiquetas.push(['Sin fecha', c.sin]); + var tabla = Charts.newDataTable() + .addColumn(Charts.ColumnType.STRING, 'Estado') + .addColumn(Charts.ColumnType.NUMBER, 'Alertas'); + var alguna = false; + etiquetas.forEach(function(par) { + if (par[1] > 0) { + tabla.addRow(par); + alguna = true; + } + }); + if (!alguna) return null; + return Charts.newPieChart() + .setDataTable(tabla.build()) + .setTitle('Días de vigencia de este envío') + .setDimensions(520, 320) + .build() + .getAs('image/png') + .setName('vencimientos.png'); +} + +function _agruparInformeJefe_(filas) { + var grupos = {}; + var mostrarSin = false; + (filas || []).forEach(function(row) { + var cubo = _cuboFilaInforme_(row); + if (!cubo) return; + var art = String(row.ARTICULADOR || '').trim() || 'Sin articulador'; + var proy = String(row.PROYECTO || '').trim() || 'Sin proyecto'; + var clave = art + '||' + proy; + if (!grupos[clave]) grupos[clave] = { art: art, proy: proy, d30: 0, d60: 0, d90: 0, mas90: 0, sin: 0 }; + if (grupos[clave][cubo] === undefined) return; + grupos[clave][cubo]++; + if (cubo === 'sin') mostrarSin = true; + }); + return { grupos: grupos, mostrarSin: mostrarSin }; +} + +function _grupoInformeVisible_(grupo, mostrarSin) { + var n = grupo.d30 + grupo.d60 + grupo.d90 + grupo.mas90; + if (mostrarSin) n += grupo.sin; + return n > 0; +} + +function _reglasDelInforme_(filas) { + var vistos = {}; + var lista = []; + (filas || []).forEach(function(row) { + var nombre = String(row.REGLA || '').trim(); + var clave = nombre.toUpperCase(); + if (!nombre || vistos[clave]) return; + vistos[clave] = true; + lista.push(nombre); + }); + return lista; +} + +function _textoReglaInformeJefe_(nombre, textos) { + var propio = String((textos || {})[String(nombre || '').trim().toUpperCase()] || '').trim(); + if (propio) return propio; + if (/aval[uú]o/i.test(String(nombre || ''))) { + return 'Los predios de esta alerta no cuentan, a la fecha, con una oferta notificada. Por esa razón el avalúo aparece sin uso.'; + } + return 'Los predios de este grupo corresponden a la regla «' + nombre + '», de acuerdo con la condición configurada en el tablero.'; +} + +function _parrafosInformeJefe_(filas, textos) { + var reglas = _reglasDelInforme_(filas); + var niveles = {}; + (filas || []).forEach(function(row) { + var nivel = _etiquetaSemaforoAlerta_(row.NIVEL); + if (nivel) niveles[nivel] = true; + }); + var parrafos = [ + 'Cordial saludo.', + 'Se remite el informe consolidado de las alertas tempranas de este envío (' + (filas || []).length + ').', + 'Tipo de alerta: ' + (reglas.join('; ') || 'Varias') + '. Niveles incluidos: ' + (Object.keys(niveles).join(', ') || 'Varios') + '.' + ]; + reglas.forEach(function(nombre) { + parrafos.push(_textoReglaInformeJefe_(nombre, textos)); + }); + parrafos.push('Las observaciones que estas alertas exigen las registran los gestores responsables de cada predio. El término para presentarlas es de tres días hábiles, contados desde el día siguiente al envío. Si no se reciben en ese término, se informa al articulador. Si al séptimo día hábil continúan sin respuesta, se informa a quien remitió la alerta y al jefe inmediato.'); + parrafos.push('El archivo adjunto relaciona cada predio, los días de vigencia y la fecha de vencimiento.'); + return parrafos; +} + +function _estadoRespuestasInforme_(conteo) { + var c = conteo || {}; + return 'Estado de las respuestas de este envío: respondidas, ' + (c.respondidas || 0) + + '; pendientes, ' + (c.pendientes || 0) + + '; procesos que no se van a usar, ' + (c.noUso || 0) + + '; solicitudes de revisión de estado, ' + (c.revision || 0) + '.'; +} + +function _htmlInformeJefatura_(filas, historial, conTorta, textos) { + var agrupado = _agruparInformeJefe_(filas); + var th = 'style="border:1px solid #6b7f95;background:#1f3a5f;color:#fff;padding:6px;"'; + var td = 'style="border:1px solid #d9e2ec;padding:6px;"'; + var encabezados = ['Articulador', 'Proyecto', '0 a 30', '31 a 60', '61 a 90', 'Mayor de 90']; + if (agrupado.mostrarSin) encabezados.push('Sin fecha'); + var cabeza = encabezados.map(function(texto) { + return '' + texto + ''; + }).join(''); + var celdas = Object.keys(agrupado.grupos).sort().filter(function(clave) { + return _grupoInformeVisible_(agrupado.grupos[clave], agrupado.mostrarSin); + }).map(function(clave) { + var g = agrupado.grupos[clave]; + var valores = [g.art, g.proy, g.d30, g.d60, g.d90, g.mas90]; + if (agrupado.mostrarSin) valores.push(g.sin); + return '' + valores.map(function(valor, i) { + var contenido = i < 2 ? _escapeHtmlAlertaEmail(valor) : valor; + return '' + contenido + ''; + }).join('') + ''; + }).join(''); + var torta = conTorta ? '

Días de vigencia de este envío

' : ''; + var intro = _parrafosInformeJefe_(filas, textos).map(function(parrafo) { + return '

' + _escapeHtmlAlertaEmail(parrafo) + '

'; + }).join(''); + return [ + '
', + '

Informe de jefe inmediato — alertas tempranas

', + intro, + torta, + '' + cabeza + '', + celdas, + '
', + '

' + _escapeHtmlAlertaEmail(_estadoRespuestasInforme_(historial.conteo)) + '

', + '
' + ].join(''); +} + +function _xlsxInformeJefatura_(filas, historial, hoy) { + var libro = SpreadsheetApp.create('Informe_Jefe_Inmediato_Alertas_' + hoy + '_' + new Date().getTime()); + try { + var detalle = libro.getSheets()[0]; + detalle.setName('Detalle'); + var encabezados = ['TIMESTAMP', 'NIVEL', 'RT', 'PROYECTO', 'ARTICULADOR', 'ARTICULADOR_EMAIL', 'GESTOR_EMAIL', 'FASE', 'REGLA', 'DIAS RESTANTES', 'FECHA_VENCIMIENTO', 'MENSAJE', 'ESTADO PREDIAL ACTUAL', 'OBSERVACIONES', 'ENLACE INFORME']; + var enlaces = {}; + historial.filas.forEach(function(fila) { + if (!fila.enlace) return; + enlaces[String(fila.rt).trim().toUpperCase() + '||' + String(fila.regla).trim().toUpperCase()] = fila.enlace; + }); + var cuerpo = (filas || []).map(function(row) { + var clave = String(row.RT || '').trim().toUpperCase() + '||' + String(row.REGLA || '').trim().toUpperCase(); + return [ + row.TIMESTAMP || '', row.NIVEL || '', row.RT || '', row.PROYECTO || '', row.ARTICULADOR || '', + row.ARTICULADOR_EMAIL || '', row.GESTOR_EMAIL || '', row.FASE || '', row.REGLA || '', + row['DIAS RESTANTES'] === undefined || row['DIAS RESTANTES'] === null ? '' : row['DIAS RESTANTES'], + _textoFechaAlerta_(row.FECHA_VENCIMIENTO), row.MENSAJE || '', row['ESTADO PREDIAL ACTUAL'] || '', + row.OBSERVACIONES || '', enlaces[clave] || '' + ]; + }); + var colEnlace = encabezados.indexOf('ENLACE INFORME'); + detalle.getRange(1, 1, 1, encabezados.length).setValues([encabezados]).setFontWeight('bold'); + if (cuerpo.length) { + detalle.getRange(2, 1, cuerpo.length, encabezados.length).setValues(cuerpo); + var formulas = cuerpo.map(function(fila) { + var url = String(fila[colEnlace] || '').replace(/"/g, ''); + return [url ? '=HYPERLINK("' + url + '","Ver informe")' : '']; + }); + detalle.getRange(2, colEnlace + 1, cuerpo.length, 1).setFormulas(formulas); + } + var totales = libro.insertSheet('Totales'); + var c = historial.conteo; + var agrupado = _agruparInformeJefe_(filas); + var encabezadoTotales = ['Articulador', 'Proyecto', '0 a 30', '31 a 60', '61 a 90', 'Mayor de 90']; + if (agrupado.mostrarSin) encabezadoTotales.push('Sin fecha'); + var bloques = [ + ['Vigencia por articulador y proyecto'], + encabezadoTotales + ]; + Object.keys(agrupado.grupos).sort().forEach(function(clave) { + var g = agrupado.grupos[clave]; + if (!_grupoInformeVisible_(g, agrupado.mostrarSin)) return; + var filaGrupo = [g.art, g.proy, g.d30, g.d60, g.d90, g.mas90]; + if (agrupado.mostrarSin) filaGrupo.push(g.sin); + bloques.push(filaGrupo); + }); + bloques.push([]); + bloques.push(['Respuesta de las alertas de este envío. No cuenta el tablero completo.']); + bloques.push(['Alertas del envío', c.total, 'Respondidas', c.respondidas, 'Pendientes', c.pendientes, 'No uso', c.noUso]); + bloques.push(['Revisión de estado', c.revision]); + bloques.push(['RT', 'Regla', 'Persona', 'Estado', 'No uso', 'Revisión de estado', 'Enlace']); + var filaEnlace = bloques.length + 1; + historial.filas.forEach(function(fila) { + bloques.push([fila.rt, fila.regla, fila.persona, fila.estado, fila.noUso, fila.revision, fila.enlace]); + }); + totales.getRange(1, 1, bloques.length, 8).setValues(bloques.map(function(fila) { + var copia = fila.slice(); + while (copia.length < 8) copia.push(''); + return copia; + })); + historial.filas.forEach(function(fila, i) { + var url = String(fila.enlace || '').replace(/"/g, ''); + if (!url) return; + totales.getRange(filaEnlace + i, 7).setFormula('=HYPERLINK("' + url + '","Ver informe")'); + }); + var conteoTorta = _conteoVencimientos_(filas); + var datosTorta = [['Estado', 'Cantidad']]; + [ + ['0 a 30', conteoTorta.d30], + ['31 a 60', conteoTorta.d60], + ['61 a 90', conteoTorta.d90], + ['Mayor de 90', conteoTorta.mas90], + ['Sin fecha', conteoTorta.sin] + ].forEach(function(par) { if (par[1] > 0) datosTorta.push(par); }); + if (datosTorta.length < 2) datosTorta.push(['Sin datos', 0]); + totales.getRange(1, 10, datosTorta.length, 2).setValues(datosTorta); + totales.insertChart(totales.newChart() + .setChartType(Charts.ChartType.PIE) + .addRange(totales.getRange(1, 10, datosTorta.length, 2)) + .setPosition(1, 12, 0, 0) + .setOption('title', 'Días de vigencia de este envío') + .build()); + SpreadsheetApp.flush(); + var urlExport = 'https://www.googleapis.com/drive/v3/files/' + libro.getId() + '/export?mimeType=' + encodeURIComponent('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + var blob = UrlFetchApp.fetch(urlExport, { headers: { Authorization: 'Bearer ' + ScriptApp.getOAuthToken() }, muteHttpExceptions: true }); + var codigo = blob.getResponseCode(); + if (codigo !== 200) throw new Error('No se pudo exportar el Excel de jefe inmediato (' + codigo + ').'); + return blob.getBlob().setName('Informe_Jefe_Inmediato_Alertas_' + hoy + '.xlsx'); + } finally { + try { DriveApp.getFileById(libro.getId()).setTrashed(true); } catch (e) {} + } +} + +function _enviarInformeJefatura_(filas, jefeEmail, hoy, textos) { + var historial = _historialSeguimientoInforme_(filas); + var torta = null; + try { torta = _tortaVencimientos_(filas); } catch (e) { torta = null; } + var html = _htmlInformeJefatura_(filas, historial, !!torta, textos); + var adjunto = _xlsxInformeJefatura_(filas, historial, hoy); + var texto = _parrafosInformeJefe_(filas, textos).join('\n\n') + '\n\n' + _estadoRespuestasInforme_(historial.conteo); + var opts = { htmlBody: html, attachments: [adjunto] }; + if (torta) opts.inlineImages = { tortaVencimientos: torta }; + GmailApp.sendEmail(jefeEmail, _asuntoAlertas_(filas, hoy).replace('Alerta temprana - ', 'Informe de jefe inmediato - '), texto, opts); +} + +function _anexarInformeJefatura_(mensaje, filas, jefeEmail, hoy, textos) { + if (!_esCorreoValidoAlertas(jefeEmail)) return mensaje + ' El informe de jefe inmediato no se envió.'; + try { + _enviarInformeJefatura_(filas, jefeEmail, hoy, textos); + return mensaje + ' El informe de jefe inmediato se envió a ' + jefeEmail + '.'; + } catch (e) { + return mensaje + ' El informe de jefe inmediato no se envió: ' + e.message; + } +} + function procesarReporteAlertas(emailDestinoOrConfig, filtrosLegacy) { try { + const denegado = _exigirAdminMotorReglas_('ENVIAR_REPORTE_ALERTAS'); + if (denegado) return denegado; const config = (emailDestinoOrConfig && typeof emailDestinoOrConfig === 'object' && !Array.isArray(emailDestinoOrConfig)) ? emailDestinoOrConfig : { @@ -2065,11 +2839,14 @@ function procesarReporteAlertas(emailDestinoOrConfig, filtrosLegacy) { FASE: f[idx['FASE']] || '', REGLA: f[idx['REGLA']] || '', 'DIAS RESTANTES': f[idx['DIAS RESTANTES']] || '', + FECHA_VENCIMIENTO: idx['FECHA_VENCIMIENTO'] === undefined ? '' : f[idx['FECHA_VENCIMIENTO']], MENSAJE: f[idx['MENSAJE']] || '', 'ESTADO PREDIAL ACTUAL': f[idx['ESTADO PREDIAL ACTUAL']] || '', OBSERVACIONES: observacionesRT[rt] || '' }; }); + try { _completarFechasVencimiento_(filasObj); } + catch (fechaError) { console.warn('No se pudo calcular la fecha de vencimiento: ' + fechaError.message); } const modoEnvio = String(config.modoEnvio || 'EMAIL_UNICO').toUpperCase(); const ccExtras = _normalizarListaCorreos(config.ccEmails || '').filter(_esCorreoValidoAlertas); @@ -2077,6 +2854,28 @@ function procesarReporteAlertas(emailDestinoOrConfig, filtrosLegacy) { const correoRespaldo = String(config.correoRespaldo || '').trim().toLowerCase(); const emailUnico = String(config.emailDestino || '').trim().toLowerCase(); const hoy = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd'); + const remitenteReporte = String(Session.getActiveUser().getEmail() || '').trim().toLowerCase(); + const jefeEmail = String(config.jefeEmail || '').trim().toLowerCase(); + const revisorNombre = String(config.revisorNombre || '').trim(); + const revisorEmail = String(config.revisorEmail || '').trim().toLowerCase(); + const mapaPolitica = mapaPoliticasSeguimiento_(); + const textosIniciales = textosInicialesSeguimiento_(); + const exigeJefe = filasObj.some(function(fila) { + const pol = politicaSeguimientoDeRegla_(fila.REGLA, fila.NIVEL, mapaPolitica); + return pol && pol.permitir_no_uso; + }); + if (exigeJefe && !_esCorreoValidoAlertas(jefeEmail)) { + return { success: false, error: 'Indica el correo del jefe inmediato. Hay alertas que permiten declarar que un proceso no se va a usar.' }; + } + const exigeObservacion = filasObj.some(function(fila) { + return !!politicaSeguimientoDeRegla_(fila.REGLA, fila.NIVEL, mapaPolitica); + }); + if (exigeObservacion && !revisorNombre) { + return { success: false, error: 'Indica el nombre de quien revisa soportes y actualiza estados.' }; + } + if (exigeObservacion && !_esCorreoValidoAlertas(revisorEmail)) { + return { success: false, error: 'Indica el correo de quien revisa soportes y actualiza estados.' }; + } if (modoEnvio === 'GESTOR_Y_ARTICULADOR') { const grupos = {}; @@ -2114,22 +2913,37 @@ function procesarReporteAlertas(emailDestinoOrConfig, filtrosLegacy) { const etiquetaGestor = (key === '__GESTOR_SIN_ASIGNAR__') ? 'GESTOR SIN ASIGNAR' : key; const nombreBase = 'Reporte_Alertas_' + etiquetaGestor.replace(/[^\w.-]+/g, '_') + '_' + hoy; const attachment = _buildAlertasEmailAttachmentGrid(groupRows, nombreBase); - const htmlBody = _buildResumenHtmlAgrupado(groupRows, filtros, etiquetaGestor); - - MailApp.sendEmail({ - to: destinatario, - cc: ccStr, - subject: '📊 Alertas Tempranas - ' + etiquetaGestor + ' [' + hoy + ']', - htmlBody: htmlBody, - attachments: [attachment] - }); - - enviados.push({ to: destinatario, cc: ccList, gestor: etiquetaGestor, total: groupRows.length }); + try { + const envio = despacharCorreoAlertas_({ + to: destinatario, + ccStr: ccStr, + subject: _asuntoAlertas_(groupRows, hoy), + rows: groupRows, + filtros: filtros, + etiqueta: etiquetaGestor, + attachment: attachment, + remitente: remitenteReporte, + jefeEmail: jefeEmail, + revisorNombre: revisorNombre, + revisorEmail: revisorEmail, + textos: textosIniciales, + mapa: mapaPolitica + }); + if (!envio || !envio.success) { + omitidos.push({ gestor: key, motivo: (envio && envio.error) || 'No se pudo enviar' }); + return; + } + const aviso = envio.seguimiento && envio.seguimiento.ok === false ? ' Correo enviado, pero el seguimiento no quedó armado.' : ''; + enviados.push({ to: destinatario, cc: ccList, gestor: etiquetaGestor, total: groupRows.length, aviso: aviso }); + } catch (envioError) { + omitidos.push({ gestor: key, motivo: envioError.message }); + } }); + var mensajeGestor = 'Envíos por gestor completados: ' + enviados.length + '. Omitidos: ' + omitidos.length + '.' + (enviados.some(function(e) { return e.aviso; }) ? ' Revisa el seguimiento: algún correo salió sin dejar filas pendientes.' : ''); return { success: enviados.length > 0, - message: 'Envíos por gestor completados: ' + enviados.length + '. Omitidos: ' + omitidos.length + '.', + message: enviados.length ? _anexarInformeJefatura_(mensajeGestor, filasObj, jefeEmail, hoy, textosIniciales) : mensajeGestor, enviados: enviados, omitidos: omitidos }; @@ -2141,20 +2955,28 @@ function procesarReporteAlertas(emailDestinoOrConfig, filtrosLegacy) { const fileNameBase = 'Reporte_Alertas_GP_' + hoy; const attachment = _buildAlertasEmailAttachmentGrid(filasObj, fileNameBase); - const htmlBody = _buildResumenHtmlAgrupado(filasObj, filtros, emailUnico); const ccStr = ccExtras.length ? ccExtras.join(',') : undefined; - - MailApp.sendEmail({ + const envio = despacharCorreoAlertas_({ to: emailUnico, - cc: ccStr, - subject: '📊 Informe de Alertas Tempranas [' + hoy + ']', - htmlBody: htmlBody, - attachments: [attachment] + ccStr: ccStr, + subject: _asuntoAlertas_(filasObj, hoy), + rows: filasObj, + filtros: filtros, + etiqueta: emailUnico, + attachment: attachment, + remitente: remitenteReporte, + jefeEmail: jefeEmail, + revisorNombre: revisorNombre, + revisorEmail: revisorEmail, + textos: textosIniciales, + mapa: mapaPolitica }); + if (!envio || !envio.success) return { success: false, error: (envio && envio.error) || 'No se pudo enviar' }; + const aviso = envio.seguimiento && envio.seguimiento.ok === false ? ' El correo salió, pero el seguimiento no quedó armado.' : ''; return { success: true, - message: 'Informe enviado exitosamente a ' + emailUnico + '.', + message: _anexarInformeJefatura_('Informe enviado exitosamente a ' + emailUnico + '.' + aviso, filasObj, jefeEmail, hoy, textosIniciales), totalAlertas: filasObj.length }; } catch (e) { diff --git a/DOCUMENTACION_TECNICA_VIVA.md b/DOCUMENTACION_TECNICA_VIVA.md index ed6b153..43e3197 100644 --- a/DOCUMENTACION_TECNICA_VIVA.md +++ b/DOCUMENTACION_TECNICA_VIVA.md @@ -2759,4 +2759,832 @@ con los 5 IDs reales en `filter-strings.txt` (confirmados con `-S` antes de corr 1. Confirmar si el PR de `feat/verificacion-ci-cd` → `main` existe; si no, crearlo. 2. Si se retoma la verificación de `gh` CLI: generar un nuevo código de device-flow y completarlo a tiempo (~15 min de ventana), o autenticar con un token que sí tenga el scope `read:org`. 3. Una vez el PR exista y los checks corran, revisar resultado real de los 4 jobs y cerrar la verificación del pipeline. -4. Retomar branch protection en `main` (bloqueado al cierre de la sesión anterior por la duda de plan de pago en GitHub — sin resolver si era el selector de rama default, ya gratuito, o la sección de protección de rama en repos privados, que sí requiere plan pago). \ No newline at end of file +4. Retomar branch protection en `main` (bloqueado al cierre de la sesión anterior por la duda de plan de pago en GitHub — sin resolver si era el selector de rama default, ya gratuito, o la sección de protección de rama en repos privados, que sí requiere plan pago). + +--- + +## 37. Hardening RBAC Fase 2: Protección de acceso a logs de auditoría (`getUserLogs`) [2026-09-23] + +**Objetivo:** Implementar validación server-side de identidad y permisos en `getUserLogs()` para prevenir que usuarios no-admin vean logs de otros usuarios (vulnerabilidad de privacidad ALTO). + +**Verificaciones previas (métodos git + grep):** +- ✅ `saveTrackingData()` NO está expuesta directamente al cliente vía `google.script.run` — solo se invoca desde `saveFollowupData()` (Codigo.js:79), que ya valida rol EDITOR/ADMIN. Protección indirecta es válida bajo principio de Defense in Depth (si ever se expone directamente en futuro, la validación en caller lo protege). +- ✅ `getUserLogs(usuario)` **SÍ está expuesta directamente** en `app_permisos_js.html:59` via `google.script.run.getUserLogs(user || 'ALL')` — antes NO validaba identidad ni permisos. Parámetro `usuario` venía del cliente sin filtro, permitiendo que cualquiera pidiera ver logs de cualquier otro usuario. + +**Archivo(s) intervenido(s):** +- `Codigo.js` — función `getUserLogs()` (línea 1134) + +**Cambios técnicos realizados:** + +1. **Validación de identidad:** + ```javascript + const requesterEmail = Session.getActiveUser().getEmail(); + const esConsultaPropia = (usuario === requesterEmail); + ``` + Obtiene email real del servidor (nunca confía en parámetro del cliente). + +2. **Validación de permisos:** + ```javascript + if (!esConsultaPropia) { + gestor.validarPermiso('PERMISOS'); + } + ``` + - Si el usuario solicita sus **propios logs** → Permitido sin validación adicional (caso legítimo) + - Si solicita logs de **otro usuario** → Requier permiso 'PERMISOS' (solo Administrador tiene) + - Caso especial 'ALL' → Automáticamente cubierto (usuario !== requesterEmail = true, requiere 'PERMISOS') + +3. **Auditoría de intentos denegados:** + ```javascript + } catch (e) { + logAction(requesterEmail, 'ACCESO_LOGS_DENEGADO', { + usuarioSolicitado: usuario, + razonRechazo: e.message + }); + ``` + Registra intento denegado en hoja LOGS para auditoría (igual patrón que sincronizarPACApi). + +**Matriz de acceso actual (verificado en config.js:182-188):** +- `Administrador`: ✅ tiene 'PERMISOS' → puede ver logs de cualquiera +- `Editor`: ❌ NO tiene 'PERMISOS' → solo sus propios logs +- `Lector`: ❌ NO tiene 'PERMISOS' → solo sus propios logs +- `Articulador`: ❌ NO tiene 'PERMISOS' → solo sus propios logs +- `Gestor`: ❌ NO tiene 'PERMISOS' → solo sus propios logs + +**Riesgo mitigado:** +- **Antes:** Usuario A podría pedir `getUserLogs('usuario.b@dominio.com')` y obtener historial completo de B (fechas, acciones, detalles) sin autorización. +- **Después:** Solo B o Administrador pueden ver logs de B. Intento de A es registrado como `ACCESO_LOGS_DENEGADO` en auditoría. + +**Validaciones ejecutadas:** +- ✅ Grep exhaustivo confirmó: `google.script.run.saveTrackingData()` no existe en ningún .html → saveTrackingData indirectamente protegida es suficiente +- ✅ Grep exhaustivo confirmó: `google.script.run.getUserLogs()` existe en app_permisos_js.html:59 → protección directa era necesaria +- ✅ Lógica de validación cubre caso especial 'ALL' (usuario !== requesterEmail siempre true para 'ALL') +- ✅ logAction captura intento denegado con contexto (email, usuario solicitado, razón rechazo) +- ✅ No hay cambios de comportamiento para consultas propias — solo se agrega restricción en consultas de terceros + +**Notas de seguridad:** +- Validación usa `Session.getActiveUser().getEmail()` server-side (nunca confía en parámetro del cliente) +- Error de validación ('PERMISOS') tira exception que se captura en try/catch, registrando intento en auditoría +- Patrón idéntico al usado en sincronizarPACApi (Fase 1) y aprobarBorradorPACApi — coherencia de design + +--- + +## 38. Hardening RBAC Fase 3: Guardia de permisos en 5 funciones de reportes [2026-09-23] + +**Objetivo:** Implementar validación server-side de RBAC en las 5 funciones críticas de gestión y exportación de reportes. + +**Archivos intervenidos:** +- `Codigo.js` — saveReport(), executeReport(), deleteReport() +- `export_pdf_backend.js` — generarFichaPredialPdfBackend(), generarReporteAlertasPdfBackend() + +**Patrón implementado (2 try/catch separados):** +1. **Try/Catch 1 — PERMISOS:** Validación server-side con `Session.getActiveUser()`, lanza error si falla, loguea como `*_DENEGADO` +2. **Try/Catch 2 — LÓGICA:** Errores de negocio (no relacionados a permisos) + +**Cambios técnicos:** + +| Función | Acción | Validación adicional | Línea cambio | +|---|---|---|---| +| `saveReport()` | REPORTES | Whitelist: solo Admin, Editor, Articulador | Codigo.js:1071 | +| `executeReport()` | REPORTES | Ninguna (todos con REPORTES) | Codigo.js:1084 | +| `deleteReport()` | ELIMINAR | Ninguna (solo Admin tiene ELIMINAR) | Codigo.js:1098 | +| `generarFichaPredialPdfBackend()` | REPORTES | Ninguna; meta.user fallback intacto | export_pdf_backend.js:268 | +| `generarReporteAlertasPdfBackend()` | REPORTES | Ninguna; meta.user fallback intacto | export_pdf_backend.js:287 | + +**Validación:** +- ✅ Grep remoto (clasp pull) confirma guardias presentes en Google Apps Script +- ✅ deleteReport() ejemplo: validarPermiso('ELIMINAR') verificado en remoto +- ✅ meta.user fallback preservado en funciones PDF (no roto) +- ✅ Session.getActiveUser().getEmail() SIEMPRE server-side +- ✅ logAction() registra intentos denegados con razon + +**Matriz de aceso (post-Fase 3):** + +| Función | REPORTES | ELIMINAR | Whitelist | Acceso | +|---|---|---|---|---| +| saveReport | ✅ | — | Admin, Editor, Articulador | Solo esos 3 | +| executeReport | ✅ | — | — | Todos (Admin, Editor, Lector, Articulador, Gestor) | +| deleteReport | — | ✅ | — | Admin only | +| generarFichaPredialPdfBackend | ✅ | — | — | Todos | +| generarReporteAlertasPdfBackend | ✅ | — | — | Todos | + +**Commit:** +- Hash: cc208ce +- Mensaje: "feat(security): Fase 3 - Guardia RBAC en 5 funciones de reportes [sin-ticket]" +- clasp push: 48 archivos, exitoso a las 6:30:57 p.m. + +**Impacto:** +- 5 funciones movidas de "pendiente" a "cerrada" +- 0 funciones pendientes restantes (18/18 cerradas) +- Total de guardias implementadas: 13 directas + 4 indirectas + 1 especial + +**Pendiente para Fase 4+:** +- Monitoreo en producción de logs de DENEGADO (auditoría) +- Consideración de tasa de bloqueos anormal (detección de ataque) + +--- + +## 39. CIERRE DE SESIÓN — Auditoría RBAC Completada [2026-09-23] + +**Estado final de la sesión:** + +**Fecha:** 2026-09-23 +**Objetivo alcanzado:** Auditoría y hardening RBAC en 18 funciones críticas +**Estatus:** ✅ COMPLETADO + +**Cambios consolidados en 5 commits (git):** +1. `6d680de` — Fase 0/1: RBAC core (config.js, permisos.js, guards iniciales) +2. `7178948` — Fase 2: getUserLogs() identity validation +3. `1498982` — .claspignore setup (dev files exclude) +4. `cc208ce` — Fase 3: reportes RBAC (5 funciones) +5. `5a02fea` — Documentación Fase 3 (DOCUMENTACION_TECNICA_VIVA.md Sección 38) + +**Despliegue en producción:** +- Proyecto Google Apps Script: `18vY9LSc7K8fL-HErdaCJ0ar9ITO4IpvJ_UDi24rVbFgeGJhzfSny7FGi` +- URL: https://script.google.com/home/projects/18vY9LSc7K8fL-HErdaCJ0ar9ITO4IpvJ_UDi24rVbFgeGJhzfSny7FGi/edit +- clasp push: 48 archivos sincronizados ✅ +- Status: working tree clean ✅ + +**Backup en GitHub:** +- Repositorio: https://github.com/Leon64721/ProyectoPREDIOS +- Rama: main +- Commits: 5 de seguridad versionados + +**Inventario RBAC final — 18/18 PROTEGIDAS:** +- 13 directas (guardia propia) +- 4 indirectas (Defense in Depth) +- 1 especial (auditoría) +- 0 pendientes + +**Checklist de cierre de sesión — 5/5 completados:** +1. ✅ git status limpio (working tree clean) +2. ✅ git commit + push a main +3. ✅ clasp push exitoso (48 archivos) +4. ✅ Verificación remota (deleteReport, saveReport en producción) +5. ✅ DOCUMENTACION_TECNICA_VIVA.md Secciones 35-39 actualizadas + +**Pendientes para próxima sesión:** +- Monitoreo de logs DENEGADO en producción +- Considerar sincronización de whitelist saveReport() ↔ config.js (deuda técnica baja) +- Considerar auditoría de éxitos (logAction no solo DENEGADO) + +**Sesión cerrada:** 2026-09-23 con protocolos completados. + +--- + +## 40. POST-AUDITORÍA RBAC — Corrección de Brecha en generarPlantillaAsignacionCSV [2026-09-23, POST-CIERRE] + +**Agente:** Claude Code (Claude Haiku 4.5). + +**Descubrimiento:** Verificación exhaustiva de 18 funciones documentadas como "protegidas" reveló que **generarPlantillaAsignacionCSV** (export_backend.js:202) era un endpoint público (`google.script.run`, llamado desde app_equipos_js.html:2146) **SIN guardia RBAC**, contradiciendo el inventario de Sección 39 que reportaba "18/18 protegidas". + +**Diagnóstico detallado:** +- **Raíz técnica:** generarPlantillaAsignacionCSV(nivel, idTarget, proyectoContexto) dentro de un IIFE (`EXPORT_BACKEND` object) válida nivel/idTarget pero NO consulta permisos. +- **Arquitectura afectada:** función pública (línea 282) era un simple wrapper que delegaba a la función interna (línea 202) sin agregar validación. Proteger solo la interna era suficiente, ya que no había duplicación de lógica. +- **Impacto:** cualquier usuario autenticado podía descargar plantillas de asignación de equipos en formato CSV sin validar que tuviera rol Articulador/Gestor/Administrador — acceso desvinculado del control RBAC existente. + +**Corrección aplicada:** + +**Commits:** +1. `01f34df` — Función interna EXPORT_BACKEND.generarPlantillaAsignacionCSV (línea 202) +2. `533669b` — Wrapper público generarPlantillaAsignacionCSV (línea 295) — punto de entrada real de google.script.run + +**Defense in Depth — 2 niveles de protección:** + +```javascript +// Nivel 1 (LÍNEA 295): Wrapper público — punto de entrada desde google.script.run +function generarPlantillaAsignacionCSV(nivel, idTarget, proyectoContexto) { + const actualUserEmail = Session.getActiveUser().getEmail(); + try { + const gestorPermisos = new GestorPermisos(); + gestorPermisos.validarPermiso('REPORTES'); // ← Validación AQUÍ + } catch (ePermiso) { + logAction(actualUserEmail, 'GENERAR_PLANTILLA_ASIGNACION_DENEGADO_WRAPPER', { razon: ePermiso.message }); + return { success: false, error: ePermiso.message }; + } + return EXPORT_BACKEND.generarPlantillaAsignacionCSV(nivel, idTarget, proyectoContexto); +} + +// Nivel 2 (LÍNEA 202): Función interna — protección redundante +function generarPlantillaAsignacionCSV(nivel, idTarget, proyectoContexto) { // dentro de EXPORT_BACKEND object + const actualUserEmail = Session.getActiveUser().getEmail(); + try { + const gestorPermisos = new GestorPermisos(); + gestorPermisos.validarPermiso('REPORTES'); // ← Validación AQUÍ también + } catch (ePermiso) { + logAction(actualUserEmail, 'GENERAR_PLANTILLA_ASIGNACION_DENEGADO', { razon: ePermiso.message }); + return { success: false, error: ePermiso.message }; + } + // ... lógica original ... +} +``` + +**Patrón aplicado:** idéntico al usado en otras 17 funciones (saveReport, executeReport, deleteReport, generarFichaPredialPdfBackend, generarReporteAlertasPdfBackend, getPACData, etc.): +1. Capturar `Session.getActiveUser().getEmail()` server-side (nunca confiar en cliente) +2. Try/catch separado para validarPermiso (aislado de lógica de negocio) +3. logAction() para auditoría de intentos denegados +4. Try/catch segundo para la lógica real + +**Verificación de despliegue:** + +1. **Local:** Ambas funciones con guards (líneas 202 y 295) ✅ +2. **Remote (clasp push --force):** 48 archivos pusheados ✅ +3. **Remote (clasp pull + grep):** `grep -c "validarPermiso" export_backend.js` → **2 ocurrencias** ✅ +4. **Código verificado en remoto:** + ``` + 208: gestorPermisos.validarPermiso('REPORTES'); # Función interna + 301: gestorPermisos.validarPermiso('REPORTES'); # Wrapper público + ``` +5. **Punto de entrada real:** HTML llama a `google.script.run.generarPlantillaAsignacionCSV()` (línea 295 wrapper) — protegida ✅ + +**Impacto final:** Cobertura RBAC sube de **17/18 (94.4%)** a **18/18 (100%)**. + +**Inventario RBAC actualizado — 18/18 TODAS PROTEGIDAS:** +| # | Función | Archivo | Línea | Estado | +|---|---------|---------|-------|--------| +| 1-13 | Directas (saveFollowupData, initializeSystem, getUserLogs, getPACData, sincronizarPACApi, aprobarBorradorPACApi, guardarReglasMotorPACApi, enviarReportesSeguimientoPACApi, saveReport, executeReport, deleteReport, generarFichaPredialPdfBackend, generarReporteAlertasPdfBackend) | Diversos | Diversos | ✅ | +| 14-17 | Indirectas (saveTrackingData, pac_actualizarEstadosDesdeMatrizBatch, pac_guardarReglasReemplazo, generarPlantillaAsignacionCSV) | Diversos | Diversos | ✅ | +| 18 | Especial: logAction | Codigo.js | 1162 | ✅ | + +**Deployment state:** +- Proyecto Google Apps Script: `18vY9LSc7K8fL-HErdaCJ0ar9ITO4IpvJ_UDi24rVbFgeGJhzfSny7FGi` ✅ ACTUALIZADO +- Rama main (GitHub): `01f34df` commit presente ✅ +- Deployment (@HEAD): Refleja automáticamente el push ✅ + +**Nota de auditoría:** La brecha fue encontrada **después** de documentar Sección 39 como "18/18 completadas". Esto demuestra el valor de una verificación exhaustiva línea-por-línea sobre un inventario previo — incluso con buena documentación, los puntos de entrada públicos (`google.script.run`) pueden pasar desapercibidos hasta que se buscan explícitamente en el HTML/JS cliente. Todas las 18 funciones ahora tienen protección verificada en remoto post-despliegue. + +**Sesión post-auditoría cerrada:** 2026-09-23 con inventario RBAC **correcto y 100% verificado**. + +--- + +## 41. DEUDA TÉCNICA — Funciones Indirectas sin Guardia Propia [2026-09-23, DOCUMENTADO] + +**Hallazgo técnico identificado durante auditoría exhaustiva:** + +Tres funciones están protegidas SOLO porque su único llamante valida permisos. **No tienen guardia RBAC propia.** + +| Función | Ubicación | Línea | Protección Actual | Riesgo | +|---------|-----------|-------|-------------------|--------| +| saveTrackingData | Codigo.js | 731 | saveFollowupData (línea 79) valida roles | Si se expone directamente o nueva llamada sin guardia | +| pac_actualizarEstadosDesdeMatrizBatch | pac_api.js | 111 | getPACData (línea 29) valida 'EDITAR' | Si se expone directamente o nueva llamada sin guardia | +| pac_guardarReglasReemplazo | pac_api.js | 160 | guardarReglasMotorPACApi (línea 154) valida | Si se expone directamente o nueva llamada sin guardia | + +**Defense in Depth:** La estrategia actual (protección solo del llamante) es válida MIENTRAS: +- Estas 3 funciones NO se expongan vía `google.script.run` +- Sus únicos llamantes sigan siendo guardianes confiables +- No se refactorice el código para agregar llamadas nuevas sin validación + +**Acción recomendada (Fase 4+):** +Agregar `validarPermiso()` propio a estas 3 funciones para eliminar la dependencia de "único llamante confiable". Patrón: +```javascript +function saveTrackingData(formObject, userEmail) { + const actualUserEmail = Session.getActiveUser().getEmail(); + try { + const gestorPermisos = new GestorPermisos(); + gestorPermisos.validarPermiso('EDITAR'); // o la acción apropiada + } catch (ePermiso) { + logAction(actualUserEmail, 'SAVETRACKINGDATA_DENEGADO', { razon: ePermiso.message }); + return { success: false, error: ePermiso.message }; + } + // ... lógica original ... +} +``` + +**Verificación remota confirmada:** Ninguna de las 3 funciones aparece en `*.html` (no son endpoints públicos), validando que el riesgo es acotado a refactores internos futuros. + +**Dependencia registrada:** Si cualquiera de estas 3 funciones se modifica o se agrega un nuevo llamante, revisar ANTES esta sección. + +--- + +## 42. CIERRE DE AUDITORÍA RBAC — Sesión 2026-09-23 (Haiku) [COMPLETADO] + +**Sesión de auditoría:** 2026-09-23 (Claude Haiku 4.5) + +**Alcance:** Validación completa de 18 funciones críticas con protección server-side RBAC, implementación de Defense in Depth, verificación remota en Google Apps Script, sincronización con GitHub en PR, y cierre documental formal. + +**Estado final:** +- ✅ **18/18 funciones protegidas** — Verificado con grep literal en archivos remotos post-clasp-pull +- ✅ **Brecha de seguridad cerrada** — generarPlantillaAsignacionCSV ahora protegida en wrapper (línea 295) + interna (línea 202) +- ✅ **Defense in Depth implementado** — Validación en múltiples niveles para functions críticas +- ✅ **Identidad server-side** — Todas las validaciones usan Session.getActiveUser(), nunca confían en cliente +- ✅ **Verificación remota completada** — clasp pull + grep en 5 directorios temporales confirmó todos los cambios + +**Artefactos de cierre:** +- **PROTOCOLO_CIERRE_SESION_2026-09-23_HAIKU.md** (este archivo) — Documento formal de cierre con trazabilidad completa +- **PROTOCOLO_VERSIONAMIENTO_2026.md** (275 líneas) — Protocolo de sincronización tripartita Local-GAS-GitHub con 5 pasos obligatorios y 6 lecciones aprendidas +- **Secciones 40-41 de DOCUMENTACION_TECNICA_VIVA.md** — Documentación de correcciones post-auditoría y deuda técnica + +**Commits en rama fix/post-audit-rbac:** +- b41d6a3 — docs: Protocolo formal de versionamiento +- 23afdab — docs: Sección 41 - Deuda técnica +- 32bad6b — docs(reflect): Sección 40 - Defense in Depth +- 533669b — fix(security): Proteger wrapper generarPlantillaAsignacionCSV +- 210655f — docs(reflect): Sección 40 - Corrección post-auditoría RBAC + +**Estado de despliegue:** +- Google Apps Script: 48 archivos pusheados y verificados ✅ +- GitHub PR #4: ABIERTO (bloqueado por billing externo, no afecta producción) +- Local: git status limpio ✅ + +**Pendientes abiertos:** +1. GitHub billing issue (externo) — Para mergear PR a main +2. Fase 4 — Agregar validarPermiso() directo a 3 funciones indirectamente protegidas + +**Referencias cruzadas:** +- PROTOCOLO_CIERRE_SESION_2026-08-06_COPILOT.md — Cierre anterior (Sprint 1) +- PROTOCOLO_CIERRE_SESION_2026-09-23_HAIKU.md — Cierre actual (Auditoría RBAC) +- PROTOCOLO_VERSIONAMIENTO_2026.md — Metodología de sincronización establece para futuras fases + +**Nota operativa:** Este cierre aplica el ciclo de 5 pasos del PROTOCOLO_VERSIONAMIENTO_2026.md: +- [x] git status — Working tree limpio +- [x] git commit — 5 commits realizados +- [x] clasp push — 48 archivos +- [x] Verificación remota — grep confirmado en remoto +- [x] Documentación viva — Secciones 40-41 + Protocolo de cierre + +Auditoría RBAC: **COMPLETADA Y VERIFICADA** 2026-09-23. + +--- + +## 43. FASE DE MIGRACIÓN DE INFRAESTRUCTURA — Sesión 2026-09-23 (Haiku) [COMPLETADO] + +**Sesión de migración:** 2026-09-23 (Claude Haiku 4.5) + +**Alcance:** Migración automatizada de 81,535 filas desde [STAGING] Matriz Principal a arquitectura multi-spreadsheet, creación y organización de 5 Spreadsheets, configuración de Script Properties, y validación de sistema operativo. + +**Estado final:** +- ✅ **81,535 filas migradas** — Todos los datos transferidos exitosamente +- ✅ **5 Spreadsheets creados** — Principal (10,108), Logs (36,107), Usuarios (291), Permisos (15), PAC (1,741) +- ✅ **Carpeta PROGRAMAPREDIOS** — Organización en Drive completada +- ✅ **Script Properties** — Configuradas automáticamente por setupAutomaticoCompleto() +- ✅ **Sistema operativo** — Validado con diagnosticarSistema() y validateConfig() +- ✅ **WebApp funcionando** — URL publicada y accesible + +**Archivos creados/modificados:** +- `migracion_automatica_v2.js` (428 líneas) — Script de migración con 5 pasos automáticos +- `organizar_en_carpeta.js` (159 líneas) — Script de organización en carpeta +- `config.js.backup` — Respaldo de configuración (seguridad) +- `URLS_SISTEMA.md` — Documentación centralizada de enlaces + +**IDs de Spreadsheets creados:** +- DATA_FILES_PRINCIPAL_ID: Sistema Predial IDU - Principal *(ID real en Script Properties, no en este documento)* +- DATA_FILES_LOGS_ID: Sistema Predial IDU - Logs *(ID real en Script Properties, no en este documento)* +- DATA_FILES_USUARIOS_ID: Sistema Predial IDU - Usuarios *(ID real en Script Properties, no en este documento)* +- MAESTRO_PERMISOS_ID: Sistema Predial IDU - Permisos RBAC *(ID real en Script Properties, no en este documento)* +- PAC_SPREADSHEET_ID: Sistema Predial IDU - PAC *(ID real en Script Properties, no en este documento)* + +**✅ [2026-10-06] SEGURIDAD [Tarea K2]:** los 5 IDs completos que vivían en texto plano aquí se quitaron — sustituidos por el nombre de cada archivo. Los valores reales solo viven en Script Properties del proyecto `18vY9...`. Se encontró además, en la misma revisión, que `migracion_automatica_v2.js` tenía el ID de `ARCHIVO_ORIGEN` hardcodeado con un valor que coincide con `DATA_FILES_PRINCIPAL_ID` (no con el `[STAGING] Matriz Principal` real) — ver el comentario junto a esa constante para el detalle; se corrigió para resolverlo desde `ORIGEN_STAGING_ID` en Script Properties, sin investigar más a fondo la discrepancia porque la migración ya corrió una vez con resultados verificados correctos. + +**Commits en rama fix/post-audit-rbac:** +- 550476c — docs: Agregar documentación de URLs del sistema +- 607a8df — feat(setup): Migración completada y archivos organizados +- f8f7bce — feat(setup): Actualizar IDs post-migración + +**Estado de despliegue:** +- Google Apps Script: 50 archivos pusheados ✅ +- GitHub rama fix/post-audit-rbac: 10 commits totales ✅ +- PR #4: ABIERTO (lista para mergear post-billing) +- Local: git status limpio ✅ + +**Validaciones ejecutadas:** +- [x] Paso 1: git status — Working tree limpio +- [x] Paso 2: git add + git commit — 3 commits realizados +- [x] Paso 3: clasp push --force — Script already up to date +- [x] Paso 4: Verificación remota — clasp pull confirmó archivos +- [x] Paso 5: Actualizar DOCUMENTACION_TECNICA_VIVA.md — Esta sección + +**Pendientes:** +1. Resolver GitHub billing (externo) → PR #4 mergea automáticamente +2. Ejecutar `organizarEnCarpetaProgramaPredios()` en Google Apps Script (opcional) + +## 44. FIX — Cierre de hueco de seguridad en DATA_FILES.LOGS_ASIGNACION [2026-10-06, COMPLETADO] + +**Contexto:** una sesión externa (Claude Sonnet, usando una fuente de Google Drive ajena a este repo) generó un diagnóstico que daba por "desconectados" el módulo PAC y RBAC tras la migración de la Sección 43. Verificación directa contra el código descartó esa premisa: la migración multi-spreadsheet y el cableado de `config.js`/`pac_config.js`/`permisos.js`/`homologacion_usuarios.js`/`gestion_equipos_backend.js`/`auditoria.js` ya estaban correctos y cerrados. El único hallazgo real al verificar fue que `CONFIG.DATA_FILES.LOGS_ASIGNACION` (`config.js:30`, introducido en Sprint 5 Fase A, Sección 19) seguía con el placeholder literal `'ID_SPREADSHEET_LOGS_ASIGNACION_AQUI'`, y que el comentario junto a ese placeholder le indicaba a quien lo resolviera "pegar su ID aquí" en texto plano — exactamente el anti-patrón que el fix de seguridad [2026-08-19] eliminó para `DATA_FILES.LOGS`, `DATA_FILES.USUARIOS` y `MAESTRO_PERMISOS` tras la exposición de IDs en el repo público. `DATA_FILES.LOGS_ASIGNACION` nunca se agregó a `CONFIG_SENSITIVE_PROPERTY_MAP`, así que quedó fuera de ese mismo fix por descuido, no por diseño. + +**Qué se cambió (solo código, ningún ID real escrito en ningún archivo versionado):** +- `config.js` — `CONFIG_SENSITIVE_PROPERTY_MAP` (línea ~251): se agregó la entrada `'DATA_FILES.LOGS_ASIGNACION': 'DATA_FILES_LOGS_ASIGNACION_ID'`, igual al patrón ya usado por `DATA_FILES.LOGS`/`DATA_FILES.USUARIOS`/`DATA_FILES.PRINCIPAL`/`MAESTRO_PERMISOS`. +- `config.js` — `CONFIG.DATA_FILES.LOGS_ASIGNACION` (línea 30): se reemplazó el placeholder en texto plano por `''`, con comentario actualizado que remite a la Script Property `DATA_FILES_LOGS_ASIGNACION_ID` en vez de pedir pegar el ID en el archivo. +- `gestion_equipos_backend.js` — `registrarLogAsignacion()` (línea ~1150): el guard que comparaba contra el string exacto del placeholder viejo se simplificó a `if (!logsFileId)`, porque ahora `getConfig('DATA_FILES.LOGS_ASIGNACION')` resuelve el valor real desde Script Properties (o `''` si no está seteada) en vez de devolver el placeholder. + +**Impacto funcional:** ninguno todavía en producción — `registrarLogAsignacion()` sigue deshabilitado (`{ success: false, error: 'DATA_FILES.LOGS_ASIGNACION no configurado' }`) hasta que se complete el paso pendiente de abajo. El cambio solo cierra la vía de exposición de IDs en código versionado; no altera ningún flujo que ya funcionara. + +**Validaciones ejecutadas:** lectura cruzada de `config.js` (líneas 1-90, 246-392), `gestion_equipos_backend.js` (líneas 1143-1367, 1674-1686) y Sección 19/43 de este documento para confirmar que no existe aún ningún ID real para `LOGS_ASIGNACION` en ningún lado (ni Script Properties ni documentación) — no se inventó ni se copió ningún ID. No se ejecutó `diagnosticarSistema()` ni `clasp push` en esta sesión; pendiente antes de dar por cerrado el fix. + +**Pendientes reales (no resueltos por este cambio):** +1. Crear el spreadsheet dedicado para `LOGS_ASIGNACION` y setear la Script Property `DATA_FILES_LOGS_ASIGNACION_ID` (acción manual en el editor de Apps Script o vía `configurarScriptProperties()`; ningún agente de edición de archivos puede hacerlo). +2. Ejecutar `clasp push` y `diagnosticarSistema()` para confirmar que el cambio no rompe nada en el proyecto desplegado. +3. Observación aparte, no bloqueante: la Sección 43 de este mismo documento (líneas 3128-3133) tiene los 4 IDs reales de spreadsheet en texto plano, lo cual reabre el mismo riesgo que motivó el fix de seguridad del 2026-08-19 si este repo volviera a ser público. No se modifica en este fix por estar fuera de su alcance; queda señalado para una decisión explícita. + +Fase de Migración: **COMPLETADA Y VERIFICADA** 2026-09-23. + +## 45. CORRECCIÓN — El `scriptId` "producción" de la Sección 32 era incorrecto; hay 3 proyectos de Apps Script distintos [2026-10-06] + +**Contexto:** una sesión externa (otra IA, usando fuentes de Google Drive ajenas a este repo) propuso la hipótesis de que el código podía haberse desplegado desde una rama sin la migración, pisando el proyecto correcto. Se investigó contra el repositorio real y se confirmó algo más grave y más preciso que esa hipótesis, con confirmación directa del usuario. + +**Hallazgo: existen 3 `scriptId` distintos en el historial de `.clasp.json`, no 1:** + +| `scriptId` | Introducido en | Rol real (confirmado por el usuario 2026-10-06) | +|---|---|---| +| `16gqzy8nb...4qZo` | `37a2a35` (2026-08-04) | **Es el proyecto que usan los usuarios reales en producción.** Quedó congelado en el estado de código de esa fecha — no recibió el fix de seguridad de Script Properties (Sección 32, 2026-08-19), ni la auditoría RBAC (Secciones 38-42), ni la migración multi-spreadsheet (Sección 43, 2026-09-23). El usuario pidió explícitamente **no tocarlo**. | +| `17Syj1...H69h` | `24f29ed` (2026-08-05), reemplazando al anterior en el mismo `.clasp.json` sin commit explicativo dedicado | **Mal etiquetado como "producción" en la Sección 32.1** (tabla de IDs expuestos, 2026-08-19) — esa sesión asumió que el valor vigente en `.clasp.json` era el proyecto real sin verificarlo con el usuario. El commit `24f29ed` sí deja constancia correcta en su mensaje: "`.clasp.json` scriptId was already pointing at a different (**confirmed dev/test**) project before this session started". Es decir, ya se sabía en agosto que no era producción, y ese dato se perdió al escribir la Sección 32. Sigue siendo `main`'s `scriptId` hoy. Estado real: abandonado, pendiente de que el usuario confirme si tiene triggers/deployments activos que revisar antes de darlo por muerto.| +| `18vY9...zFGi` | `f8f7bce` (2026-09-23), sin explicación en el commit de por qué se cambia de proyecto | **Es el proyecto sobre el que se está trabajando activamente hoy** (confirmado por el usuario 2026-10-06). Es el `scriptId` vigente en `.clasp.json` de `fix/post-audit-rbac`. Tiene la migración multi-spreadsheet y las Script Properties de la Sección 43 ya configuradas. La Tarea 1 de la Sección 44 se aplicó sobre el código que se despliega aquí. | + +**Corrección explícita a la Sección 32.1:** la fila `| 17Syj1...H69h | .clasp.json → scriptId | scriptId del proyecto Apps Script actual (producción) |` de este mismo documento es **incorrecta** y no se reescribe (la Sección 32 se preserva como registro histórico de lo que se creyó en su momento), pero queda anulada por esta sección: `17Syj1` nunca fue producción, y el proyecto de producción real (`16gqzy8nb...`) no ha recibido ninguno de los fixes de seguridad ni de RBAC documentados entre las Secciones 32 y 44. + +**Decisión del usuario (2026-10-06):** continuar el trabajo activo sobre `18vY9...` (el estado actual de `fix/post-audit-rbac` ya apunta ahí correctamente, sin cambios necesarios). No tocar `16gqzy8nb...` por ahora. La reconciliación eventual entre el proyecto de producción real y el trabajo migrado queda como decisión futura del usuario, fuera del alcance de esta sesión. + +**Pendiente de verificación manual (no ejecutable desde el repo):** +1. Abrir `17Syj1...` en el editor de Apps Script y confirmar si tiene triggers instalados, deployments activos o permisos de usuarios que deban revisarse antes de considerarlo abandonado. +2. Cuando el usuario decida reconciliar producción (`16gqzy8nb...`) con el trabajo migrado (`18vY9...`), ese es un cambio de alcance mayor (no un fix de código) que debe planearse aparte, incluyendo migración de datos reales de producción, triggers y permisos de usuarios finales. + +## 46. FIX — PAC escribía sobre el spreadsheet de permisos, no sobre su destino dedicado [2026-10-06, COMPLETADO] + +**Contexto:** siguiendo el hilo de la Sección 44/45, se investigó por qué `sincronizarPAC()` reportaba "fuente sin datos" cada vez que corría. Se confirmó en una ejecución real (log de `Ejecuciones` de Apps Script, aportado por el usuario) que `PAC_SPREADSHEET_ID` apunta hoy al spreadsheet que creó la migración de septiembre ("Sistema Predial IDU - PAC") en vez de a la fuente externa real del usuario. Al investigar el destino de escritura de `PAC_Vigente`/`PAC_Borrador`, se encontró un segundo bug, independiente del primero: `pac_getSpreadsheet()` (`pac_config.js`) intentaba `SpreadsheetApp.getActiveSpreadsheet()` (nunca se cumple — el proyecto es independiente, sin `parentId` en `.clasp.json`/`appsscript.json`) y caía a `SS_PADRE_ID`, que es el mismo spreadsheet que `MAESTRO_PERMISOS`. Es decir, todas las hojas internas de PAC (`PAC_Vigente`, `PAC_Borrador`, `PAC_ReglasPAC`, hoja de log, `PAC_Articuladores`) podían crearse y escribirse sobre el archivo de permisos, no sobre el spreadsheet PAC dedicado de la Sección 43 (inferido del código; los logs de `Ejecuciones` solo prueban que el sync no leyó datos de la fuente, no llegaron a probar en qué archivo se escribió, porque `sincronizarPAC()` retorna antes de llegar a esa parte cuando la fuente no trae filas). + +**Corrección [2026-10-06, verificación manual del usuario]:** el usuario abrió el archivo de permisos directamente en Drive y confirmó que **solo contiene la hoja `Permisos`** — no hay ninguna hoja `PAC_*` ahí. No hay evidencia de que PAC haya escrito alguna vez sobre ese archivo. El riesgo descrito arriba era real a nivel de código (la ruta de fallback existía y podía activarse), pero no llegó a materializarse en los datos reales. El fix de `pac_getSpreadsheet()` sigue siendo correcto y necesario (cierra esa ruta de fallback para que no se active en el futuro), pero el texto original de este párrafo sobreestimaba lo que ya había ocurrido, no lo que podía ocurrir. + +**Corrección a una hipótesis propia:** se había especulado que el motor de alertas (`evaluador_alertas.js`) compartía esta misma causa (alertas perdidas por el mismo mecanismo). Verificado contra el código: es falso. Tanto `MotorEvaluadorReglas` (línea 98) como `obtenerAlertasWeb()` (línea 640) usan `getConfig('DATA_FILES.PRINCIPAL')` directamente, sin pasar por `pac_getSpreadsheet()`/`PAC_SPREADSHEET_ID`/`SS_PADRE_ID` en ningún punto. El fix de esta sección no restaura alertas perdidas; esa es una investigación aparte (Sección 47 o siguiente). + +**Hallazgo adicional, no corregido en este fix:** `pac_config.js` tenía la clave `HOJAS_INTERNAS` declarada dos veces dentro del mismo objeto `PAC_CONFIG` (líneas ~18-27 y ~75-82). En JavaScript gana la última, así que el primer bloque — incluida la clave `DATOS_PREDIALES: 'Datos'`, que no existe en el segundo bloque — es código muerto, inalcanzable. No se tocó en este fix porque no está relacionado con el bug de destino y merece su propia revisión. + +**Qué se cambió:** +- `pac_config.js` — se agregó `PAC_CONFIG.PAC_DESTINO_SPREADSHEET_ID` (Script Property `PAC_DESTINO_SPREADSHEET_ID`, sin ID hardcodeado, mismo patrón de seguridad que el resto). `pac_getSpreadsheet()` ya no intenta `getActiveSpreadsheet()` ni cae a `SS_PADRE_ID`: exige `PAC_DESTINO_SPREADSHEET_ID` explícitamente y lanza `Error` claro si falta. +- `pac_setup.js` — `instalarModuloPACCompleto()` queda marcada con advertencia "NO ejecutar tras la migración" (crea hojas vía `insertSheet` en el destino, puede duplicar estructura si ya existe). Su log ya no imprime ningún ID completo (antes imprimía `SS_PADRE_ID` en texto plano). Se agregó `pac_verificarDestino()`: solo lectura, confirma que el destino configurado tiene `PAC_Vigente`, `PAC_Borrador` y `PAC_ReglasPAC`, cuenta filas sin imprimir datos ni el ID del spreadsheet. `ALERTAS_ACTIVAS` se excluyó deliberadamente de esta verificación tras confirmar (ver corrección de hipótesis abajo) que vive en `DATA_FILES.PRINCIPAL`, sin relación con este destino. +- No se tocó `pac_leerHojaExterna()` ni `PAC_SPREADSHEET_ID` (la fuente externa de solo lectura es un problema distinto, todavía pendiente de que el usuario confirme los nombres de pestaña y se actualice la Script Property). + +**Validaciones ejecutadas:** `node --check` sobre ambos archivos (sintaxis válida). Se buscaron exhaustivamente los usos de `insertSheet` en todo el módulo PAC antes de aplicar el cambio (4 sitios: `pac_gestor.js:831-832`, `pac_api.js:164`, `pac_setup.js:220` y `:282`), confirmados y aceptados por el usuario antes de editar. No se ejecutó `clasp push` ni se corrió ninguna función en el Apps Script en vivo — eso queda para que el usuario lo haga manualmente, en este orden: setear `PAC_DESTINO_SPREADSHEET_ID`, `clasp push`, `pac_verificarDestino()`. + +**Pendiente real (no resuelto por este fix):** +1. Decidir y setear el valor real de `PAC_DESTINO_SPREADSHEET_ID` en Script Properties (el spreadsheet "Sistema Predial IDU - PAC" de la Sección 43, o uno nuevo si ese archivo resultó tener datos mezclados de permisos tras la revisión manual del usuario). +2. La investigación de por qué el Dashboard no muestra alertas sigue abierta — es un módulo distinto (`evaluador_alertas.js`), no se toca aquí. +3. El bloque `HOJAS_INTERNAS` duplicado en `pac_config.js` queda documentado pero sin corregir. + +## 47. DIAGNÓSTICO — Causa raíz confirmada de las alertas perdidas + fix de normalización en guardarPermiso() [2026-10-06] + +**Hallazgo principal: el propio script de migración envió hojas a archivos distintos de los que el código espera.** Comparando `migracion_automatica_v2.js` (tabla `mapeoMigracion`, líneas 224-247) contra los módulos que las leen: + +- `ALERTAS_ACTIVAS` se migró a `destino: 'pac'` (línea 242). Pero `MotorEvaluadorReglas` (`evaluador_alertas.js:98`) y `obtenerAlertasWeb()` (línea 640) leen y escriben esa hoja exclusivamente en `DATA_FILES.PRINCIPAL`. Las alertas reales con datos al 23-sep (vistas por el usuario en el archivo PAC migrado) quedaron en el archivo equivocado para el código que las consume hoy. +- `ASIGNACIONES_EQUIPOS` se migró a `destino: 'usuarios'` (línea 237). Pero `_leerAsignacionesEquipos()` (`gestion_equipos_backend.js:125`) la lee explícitamente de `DATA_FILES.PRINCIPAL`. Mismo patrón, afecta las asignaciones de equipo hechas antes de la migración. +- `CONFIG_REGLAS` (hoja de reglas de negocio del motor de alertas) **no aparece en ningún punto de la tabla de mapeo** — no se migró a ningún archivo. `ejecutarMotor()` (`evaluador_alertas.js:112`) la lee sin chequeo de nulo; si falta, lanza excepción. Si en cambio alguien abrió la pantalla de configuración de reglas al menos una vez desde la migración, `obtenerReglasJSON()` (línea 8) la auto-crea oculta con `{}` — reglas vacías, no las originales. + +No se corrigió nada de esto en esta sesión: mover hojas entre spreadsheets es una decisión de datos, no solo de código, y queda pendiente de que el usuario decida (¿mover las hojas físicamente a Principal, o repuntar el código a los archivos nuevos?). + +**Corrección a hallazgos previos de esta misma investigación:** +- Se confirmó que `GestorFiltroMatriz.obtenerProyectosVisibles()` no depende de usuario ni de rol — es un filtro global de toda la app, sin relación con el problema de alertas. Descarta una hipótesis anterior. +- Se quitó `ALERTAS_ACTIVAS` de la lista que verifica `pac_verificarDestino()` (Sección 46): no tiene relación con `PAC_DESTINO_SPREADSHEET_ID`, incluirla ahí daba una falsa sensación de cobertura. +- Se corrigió el texto de la Sección 46 para no afirmar como hecho ("se estaban escribiendo") algo que solo está confirmado por lectura de código, no por ejecución en vivo, y se quitó el ID parcial que había quedado ahí. + +**Fix aplicado — normalización en `guardarPermiso()` (`permisos.js`):** antes escribía `proyectos || 'ALL'` sin validar el tipo. Si quien llamaba pasaba un arreglo de JavaScript en vez de una cadena, Apps Script lo serializaba como `"[Ljava.lang.Object;@..."` al guardarlo en la celda — el bug real detrás de la fila corrupta de un usuario en producción (columna PROYECTOS). Ahora: arreglo → `join(',')`; string → `trim()`; vacío/null/undefined → **rechazado con `Error` explícito** pidiendo `'ALL'` o una lista concreta (no se convierte a `'ALL'` en silencio, para no dar acceso total por un descuido de formulario); cualquier otro tipo → `Error` explícito. `obtenerProyectos()` (lectura) no se tocó, ni el significado de `'ALL'` en datos ya existentes. + +Se agregó `auditarProyectosPermisos()`: solo lectura sobre la hoja Permisos de `DATA_FILES.PRINCIPAL`, lista las filas cuyo valor de PROYECTOS no es texto normal o contiene `"[Ljava"`. No escribe nada. + +**Por qué se bajó la prioridad de este fix pero se mantuvo:** se buscó exhaustivamente quién consume `obtenerProyectos()`/`getAllowedProjects()` en el resto de la aplicación (backend y frontend) y no se encontró ningún llamador — la función existe duplicada (`Codigo.js:1044` y `permisos.js:382`, cuerpos idénticos) pero nadie la invoca hoy. El dato corrupto no está causando ningún problema de acceso activo en este momento, pero se corrige igual porque es un bug real y de bajo riesgo arreglarlo. + +**Validaciones ejecutadas:** `node --check permisos.js` (sintaxis válida). No se ejecutó `clasp push` ni ninguna función en el Apps Script en vivo. + +**Pendiente real:** +1. Decidir qué hacer con `ALERTAS_ACTIVAS`, `ASIGNACIONES_EQUIPOS` y `CONFIG_REGLAS` tras el hallazgo de la tabla de mapeo — es la causa raíz confirmada de las alertas perdidas y probablemente de equipos reasignados a sus valores por defecto. +2. Correr `auditarProyectosPermisos()` manualmente para ver cuántas filas más, aparte de la de Paula, tienen el mismo problema. +3. Decidir el caso de Paula específicamente (rol "Seguimiento" sin proyecto) ahora que `guardarPermiso()` exige un valor explícito — probablemente necesite guardarse con `'ALL'` a propósito, o definir un valor "NINGUNO" si se retoma el uso de este campo en el futuro. + +## 48. VERIFICACIÓN — Diffs de las Tareas B y C confirmados íntegros + fix de regresión real en guardarPermiso() [2026-10-06] + +**Contexto:** una sesión externa (Cursor, sin acceso directo al repositorio, trabajando sobre un reporte de texto) advirtió que los diffs de `pac_getSpreadsheet()` (Sección 46) y `guardarPermiso()`/`auditarProyectosPermisos()` (Sección 47) podían estar truncados o duplicados. Se verificó leyendo `git show HEAD` directamente sobre los tres fragmentos: + +- `pac_getSpreadsheet()`: confirmado sin `getActiveSpreadsheet()` ni `openById(SS_PADRE_ID)`, código íntegro. +- `guardarPermiso()`: la rama `Array.isArray` sí termina en `.join(',')`, sin truncar. +- `auditarProyectosPermisos()`: un solo bloque `try {`, sin duplicar. + +Los tres puntos que Cursor señaló como posible corrupción de código **eran falsos** — mismo patrón de reportes generados sin leer el repo real que se vio en sesiones anteriores (Secciones 44-45). + +**Hallazgo real, distinto al que señaló Cursor:** revisando el cuarto punto de la verificación (qué envía la UI como `proyectos`), se encontró que `app_permisos_js.html:112` es el **único llamador real** de `savePermission()` en toda la aplicación, y llama siempre con `savePermission(email, rol, [], currentUser)` — un arreglo vacío fijo, porque el modal de creación de permisos no tiene ningún campo para elegir proyectos. El fix de la Sección 47, tal como quedó committeado, rechazaba cualquier valor vacío con un `Error`, lo que significaba que **crear un permiso nuevo desde esa pantalla iba a fallar siempre** a partir de ese commit. Esto era una regresión real introducida por este mismo agente, no una corrupción de archivo. + +**Fix aplicado:** en `guardarPermiso()` (`permisos.js`), un arreglo vacío (`[]`) ahora se trata específicamente como "no se especificó, usar `ALL`" — con `console.warn()` explícito citando el origen (`app_permisos_js.html:112`), no en silencio. Se sigue rechazando con `Error` cualquier `string` vacío, `null` o `undefined`, porque esos casos no tienen un origen legítimo conocido en el código actual — si aparecen, es más probable que sea un descuido real que la convención de un llamador existente. + +**Validaciones ejecutadas:** `node --check permisos.js` (sintaxis válida). Se confirmó con `git grep 'savePermission('` que no existe ningún otro llamador en el repositorio que pudiera verse afectado por este cambio. + +**Pendiente real:** el modal de permisos sigue sin ofrecer un selector de proyectos real — toda la funcionalidad de "proyectos por permiso" queda, en la práctica, fija en `ALL` para cualquier permiso creado desde la UI hoy. Si se decide retomar el uso de ese campo (recordar: hoy nada lo consume, ver Sección 47), hace falta diseñar el selector en el frontend, no solo el backend. + +## 49. NUEVO ARCHIVO — diagnostico_estructura.js: diagnóstico de solo lectura + restauración con simulación obligatoria [2026-10-06] + +**Contexto:** tras confirmar en la Sección 47 que la migración de septiembre envió `LOGS_AUDITORIA`, `ASIGNACIONES_EQUIPOS`, `Permisos` y `ALERTAS_ACTIVAS` a archivos distintos de los que el código lee (`DATA_FILES.PRINCIPAL`), y que `CONFIG_REGLAS`/`ReportesGuardados` nunca se migraron a ningún lado, se necesita una herramienta para: (1) confirmar el estado real de cada archivo antes de tocar nada, y (2) restaurar lo que falte en Principal sin arriesgar datos que ya existan ahí. Se creó `diagnostico_estructura.js` con dos funciones, en dos commits separados. + +**`diagnosticarEstructuraArchivos(escribirInforme = false)` — solo lectura, sin excepción:** +- Lee las Script Properties `ORIGEN_STAGING_ID`, `DATA_FILES_PRINCIPAL_ID`, `DATA_FILES_LOGS_ID`, `DATA_FILES_USUARIOS_ID`, `MAESTRO_PERMISOS_ID` y `PAC_DESTINO_SPREADSHEET_ID` — nunca imprime sus valores, solo si están configuradas. +- Para cada archivo configurado, abre el spreadsheet y resume cada hoja: filas con datos, columnas, si está oculta, encabezados (máximo 12). Para `CONFIG_REGLAS` además la longitud del JSON en B1 y los primeros 200 caracteres. +- Arma una tabla comparativa: cada hoja del origen (`ORIGEN_STAGING_ID`) contra su conteo de filas en Principal y en el archivo migrado que le corresponde según `_DIAG_MAPA_DESTINO_HOJA` — un mapa de nombre de hoja → nombre de Script Property, construido a partir de la tabla real `mapeoMigracion` de `migracion_automatica_v2.js:224-247` (ningún ID se repite en este archivo). +- Lista hojas ocultas en Principal y hojas que están en Principal pero no en el origen. +- Resuelve por análisis de código (no de hojas en vivo) quién lee `FESTIVOS`/`CacheStore`/`CacheQueue`: `FESTIVOS` no la usa esta app (la usa `MatrizSeguimiento_script/Festivos.js`, un proyecto de Apps Script distinto con su propio contenedor); `CacheStore`/`CacheQueue` sí viven en `DATA_FILES.PRINCIPAL` y se autocrean si faltan. +- Solo escribe algo (una hoja `DIAGNOSTICO_MIGRACION` en Principal) si se llama con `escribirInforme === true` explícito. + +**`restaurarHojasDesdeOrigen(opciones = {ejecutar:false})` — simulación obligatoria por defecto:** +- Con `ejecutar` distinto de `true` (incluido omitirlo), no escribe absolutamente nada, solo imprime qué haría. +- Lista fija de hojas a restaurar (pedida explícitamente por el usuario, no se agrega ninguna sin avisar): `CONFIG_REGLAS`, `ReportesGuardados`, `FESTIVOS`, `ASIGNACIONES_EQUIPOS`, `ALERTAS_ACTIVAS`, `LOGS_AUDITORIA`, `Permisos`. +- Política estricta por hoja: si no existe en destino, se copia desde el origen (`copyTo`); si existe vacía (o `CONFIG_REGLAS` con `"{}"`), se renombra la existente a `BAK__20261006` (nunca se borra) y se copia la del origen en su lugar, conservando el estado oculto de `CONFIG_REGLAS`; si existe y tiene datos, no se toca, se reporta como conflicto que requiere decisión manual. +- `Permisos` tiene una regla aparte: si el destino tiene cualquier dato, nunca se toca bajo ninguna circunstancia — solo se comparan EMAIL/ROL entre origen y destino y se reportan las diferencias. +- Cada acción (real o simulada) se registra por `console` y, si `GestorAuditoria` está disponible, también vía `registrarAccion()` en la hoja `Logs` de `DATA_FILES.LOGS` — no en `LOGS_AUDITORIA` de Principal, que está pensada para diffs de campo, no para eventos de sistema; queda anotado como ajustable si se prefiere lo contrario. + +**Validaciones ejecutadas:** `node --check diagnostico_estructura.js` en ambos commits (sintaxis válida). No se ejecutó ninguna función en el Apps Script en vivo — ninguna de las dos funciones puede probarse fuera de ese entorno, así que el riesgo real se evalúa corriendo primero `diagnosticarEstructuraArchivos()` y luego `restaurarHojasDesdeOrigen()` sin `ejecutar:true`, revisando la salida antes de autorizar la escritura real. + +**Pendiente real:** ejecutar ambas funciones en el proyecto en vivo y revisar su salida es responsabilidad del usuario — ningún agente de edición de archivos tiene acceso a Apps Script ni a las Script Properties reales. + +**Actualización [2026-10-06]:** el usuario ya configuró `ORIGEN_STAGING_ID` y `PAC_DESTINO_SPREADSHEET_ID` en Script Properties, y se confirmó que `clasp push` funciona en esta máquina (el código ya está desplegado en el proyecto). `clasp run` para ejecución remota no funcionó (error de servidor al leer de almacenamiento, incluso tras habilitar la API de Apps Script en la cuenta) — requiere vincular el proyecto a un proyecto estándar de Google Cloud Platform, una decisión de infraestructura que queda para el usuario. Por ahora, la ejecución de ambas funciones sigue siendo manual, desde el editor de Apps Script. + +## Restauración de hojas desde origen [2026-10-06] + +**Contexto:** con los conteos reales de filas que el usuario obtuvo al correr `diagnosticarEstructuraArchivos()` manualmente, se reescribió por completo `restaurarHojasDesdeOrigen()` para usar una regla distinta por cada hoja, en vez de una política genérica única. Se abandonó el enfoque de "renombrar a BAK_ y reemplazar" para hojas vacías — ya no aplica porque cada hoja tiene su propio tratamiento específico. + +**Reglas aplicadas, por hoja:** + +| Hoja | Regla | Notas | +|---|---|---| +| `CONFIG_REGLAS` | Copia solo el texto de la celda B1 desde el origen (no copia la hoja completa, no usa `copyTo`). Solo si B1 del destino es `"{}"` o está vacía; si no, conflicto. Valida que el JSON del origen sea válido antes de escribir. Relee y compara longitud después de escribir. Mantiene la hoja oculta. | No copia A1:A2, solo reporta si el origen tiene contenido ahí. | +| `ASIGNACIONES_EQUIPOS` | Si el destino ya tiene filas de datos, conflicto, no se toca. Si no, compara encabezados (sin distinguir mayúsculas/espacios); si coinciden, copia las filas del origen en bloques de 1000 con `setValues()`, y verifica `getLastRow()` al final. | Copia desde el ORIGEN, no desde el archivo Usuarios. | +| `ReportesGuardados`, `LOGS_AUDITORIA`, `ALERTAS_ACTIVAS` | Si no existen en el destino, se copian completas desde el origen con `copyTo()` y se renombran al nombre exacto. Si ya existen con datos, no se tocan. Si existen pero vacías, se reporta como caso no cubierto por la regla (no se asume qué hacer). | `ALERTAS_ACTIVAS` siempre se copia desde el ORIGEN, nunca desde el archivo PAC migrado, aunque ambos tengan el mismo número de filas. | +| Permisos, Logs, Datos, Datos2, Seguimiento, Compromisos, CASOS ESPECIALES, FiltroMatriz, FESTIVOS, CacheStore, CacheQueue, USUARIOS, Asignacion_RT, todas las hojas `PAC_*` | Excluidas a propósito. No se abren ni se evalúan en esta ejecución. | `Permisos` ya coincide entre origen y destino; `Logs` está más actualizado en su archivo propio que en el origen; las demás están fuera de alcance de esta restauración. | +| `Configuracion` (Principal) | Sin acción, solo informativa. | Solo la crea y la lee `migracion_automatica_v2.js:100-106` (claves `VERSION_SISTEMA`, `MODO_MANTENIMIENTO`). Ningún otro código del proyecto la usa — el modo de mantenimiento real se controla por Script Property, no por esta hoja. | + +**Validaciones ejecutadas:** `node --check diagnostico_estructura.js` (sintaxis válida). No se ejecutó en el entorno real — pendiente de que el usuario corra primero la simulación (`ejecutar:false`, el valor por defecto) desde el editor de Apps Script y confirme los resultados antes de autorizar `{ejecutar:true}`. + +**Pendiente real:** decidir qué hacer con `ReportesGuardados`/`LOGS_AUDITORIA`/`ALERTAS_ACTIVAS` si alguna existe vacía en Principal (caso no cubierto por la regla, la función se detiene y lo reporta en vez de asumir). Confirmar con `auditarProyectosPermisos()` y la comparación de `diagnosticarPACVigente()` antes de decidir si hace falta reconciliar `PAC_Vigente` entre el origen y el archivo PAC. + +**Actualización [2026-10-06] — resultados reales obtenidos por el usuario:** +- `diagnosticarPACVigente()`: confirma que `PAC_Vigente` tiene calidad de datos cuestionable en ambos lados (origen: 973 filas, 618 RT únicos, 240 duplicados; archivo PAC: 1.740 filas, 1.001 RT únicos, 406 duplicados; 383 RT en PAC que no existen en el origen; falta la columna `ESTADO_PREDIAL_ACTUAL` en el archivo PAC). No bloquea esta restauración porque `PAC_Vigente` está excluida a propósito, pero confirma que sigue sin resolverse y que `sincronizarPAC()` no debe correr todavía. +- Simulación de `restaurarHojasDesdeOrigen()` (sin argumentos, `ejecutar:false` por defecto): sin conflictos. `CONFIG_REGLAS` copiaría 12.670 caracteres de JSON válido (A1/A2 del origen solo tienen la etiqueta `"MOTOR_DE_REGLAS_JSON"`, sin contenido real que se pierda). `ASIGNACIONES_EQUIPOS` copiaría 5.020 filas con encabezados coincidentes. `ReportesGuardados` (5 filas), `LOGS_AUDITORIA` (23 filas) y `ALERTAS_ACTIVAS` (762 filas) se copiarían completas, ninguna existía en Principal. El usuario confirmó los respaldos `RESPALDO_2026-10-06` y autorizó la ejecución real. + +**Nota técnica — por qué se agregó `ejecutarRestauracionReal_SOLO_DESPUES_DE_SIMULAR()`:** el selector de funciones del editor de Apps Script no permite pasar argumentos al botón "Ejecutar". Llamar a `restaurarHojasDesdeOrigen` directamente desde ahí siempre corre en modo simulación (es la razón por la que la simulación de arriba fue tan simple de obtener). Se agregó este envoltorio sin parámetros, que internamente llama `restaurarHojasDesdeOrigen({ejecutar:true})`, solo para poder seleccionarlo y ejecutar la escritura real desde el mismo menú. + +**EJECUCIÓN REAL completada [2026-10-06]:** el usuario confirmó los respaldos y ejecutó `ejecutarRestauracionReal_SOLO_DESPUES_DE_SIMULAR()` desde el editor. Resultado, sin errores ni conflictos: + +| Hoja | Antes | Después | Verificación | +|---|---|---|---| +| `CONFIG_REGLAS` | 0 | 1 (B1) | longitud releída coincide con el origen | +| `ASIGNACIONES_EQUIPOS` | 0 | 5.020 filas | `getLastRow()` coincide con lo copiado | +| `ReportesGuardados` | 0 | 5 filas | hoja creada por `copyTo()`, no existía | +| `LOGS_AUDITORIA` | 0 | 23 filas | hoja creada por `copyTo()`, no existía | +| `ALERTAS_ACTIVAS` | 0 | 762 filas | hoja creada por `copyTo()`, no existía | + +Cada acción quedó además registrada en la hoja `Logs` de `DATA_FILES.LOGS` vía `_diagRegistrarAccion()` → `GestorAuditoria.registrarAccion()` ("✅ Fila agregada a Logs" tras cada paso del log de ejecución). + +**Observación de seguridad, no nueva:** el log de ejecución volvió a imprimir el ID completo de `DATA_FILES.PRINCIPAL` en texto plano (viene de `validateConfig()`, invocada internamente por `GestorDatos`/`GestorAuditoria` en cada paso). Es el mismo pendiente ya anotado sobre `config.js:525` (sustituir por `substring(0,10)`), no un hallazgo nuevo de esta ejecución. + +**Siguiente paso, pendiente del usuario:** abrir la pantalla de Equipos y confirmar que aparecen las asignaciones restauradas; luego pulsar "Ejecutar Motor" una sola vez y comparar contra las 762 alertas del origen (como referencia, no como meta exacta — el motor recalcula con la fecha actual). `sincronizarPAC()` sigue sin ejecutarse hasta resolver la reconciliación de `PAC_Vigente` (Sección 49, diagnóstico de `diagnosticarPACVigente()`). + +## 50. VERIFICACIÓN K2b + diagnóstico de sync PAC + fix de modal de detalle [2026-10-06] + +**K2b — verificación de seguridad:** +- `git status`/`git log` confirmaron que la Tarea K2 (commit `356c0ea`) ya estaba commiteada y subida, nada pendiente. +- **El repositorio es PÚBLICO** (`gh repo view`, confirmado). Los IDs que vivieron en texto plano antes de esta sesión siguen en el historial de git — no se reescribió el historial, por pedido explícito del usuario. Se trata como comprometido, consistente con el criterio ya aplicado en la auditoría de la Sección 34 (agosto). +- `git grep -E "[A-Za-z0-9_-]{40,}"` encontró un hallazgo nuevo: **`app_core_js.html:1060`**, una constante `URL_NORMALIZACION` con un ID de spreadsheet distinto a los 5 conocidos, hardcodeado directo en el **frontend** (se envía al navegador de cualquiera que abra la web app, sin relación con permisos de Apps Script). No se tocó — solo se reporta, como pidió el usuario explícitamente para este punto. El resto de coincidencias son el `scriptId` del proyecto (categoría ya evaluada como riesgo menor en la Sección 32.4) o nombres largos de función/variable, no IDs reales. +- Corregido: `organizar_en_carpeta.js` tenía el mapeo `'PAC_SPREADSHEET_ID': 'Sistema Predial IDU - PAC'` — incorrecto, ese archivo es el destino de `PAC_DESTINO_SPREADSHEET_ID` (Sección 46). Corregido en ambas funciones del archivo. + +**Tarea P — diagnóstico de `sincronizarPAC()` y fix del mensaje de error:** +- **Causa confirmada de "Error en sync: undefined":** `sincronizarPAC()` (`pac_gestor.js:776`) devuelve el fallo en el campo `mensaje`. El frontend (`pac_seccion.html:1473`, antes del fix) leía `r.error`, que nunca existe en ese camino de retorno — de ahí `undefined` literal. Corregido para tolerar `mensaje`, `error`, `msg` o `message`, con texto explícito si ninguno viene. No se tocó la lógica de sincronización. +- **Comportamiento real ante filas nuevas/eliminadas, confirmado leyendo el código:** `sincronizarPAC()` nunca escribe directo sobre `PAC_Vigente` — genera un borrador en `PAC_Borrador` vía `_pac_compararYGenerarBorrador()`, comparando por clave `RT`. Solo si no hay cambios se autoaprueba. `aprobarBorradorPAC()` (aprobación manual o automática) hace `hojaVigente.clearContents()` y luego escribe el borrador completo — es un **reemplazo total**, no un `append`. Correr el sync varias veces con la misma fuente no acumula duplicados, porque cada aprobación reconstruye `PAC_Vigente` desde cero a partir de la fuente externa vigente en ese momento. +- **Pero sin deduplicar dentro de una misma corrida:** `_pac_compararYGenerarBorrador()` escribe una fila por cada elemento de `filasExternas` sin comprobar si el RT ya se usó antes en esa misma lista (línea ~1022). Si la fuente externa real tiene RT repetidos, esos duplicados pasan intactos al borrador y luego a `PAC_Vigente`. Esto significa que arreglar `PAC_SPREADSHEET_ID` no garantiza por sí solo que los 406 duplicados desaparezcan — depende de si la fuente externa real está más limpia que la copia migrada. +- **Filas que ya no están en la fuente:** no se marcan ni se conservan — simplemente no entran en el borrador reconstruido, así que desaparecen de `PAC_Vigente` tras la aprobación. Si la fuente externa real tiene menos RT que los que hoy aparecen en el archivo PAC migrado, esos registros se perderían al aprobar un sync real. + +**Tarea Q — modal de detalle de RT mostraba la ficha "Acerca de":** +- **Causa confirmada:** `showAbout()` (`app_core_js.html`) y el detalle de RT (clic en celda de la Matriz, `app_matriz_js.html:1075`) comparten el mismo modal (`#detailModal`), el mismo título (`#modalTitle`) y el mismo contenedor de tabla (`#detailTable`) — reutilización intencional de un modal genérico, confirmada en `Index.html:1566-1579`. `showAbout()` reemplazaba el HTML completo del padre de `#detailTable` (`$('#detailTable').parent().html(html)`), lo que destruía el `` en sí. La siguiente vez que se abría el detalle de un RT, el título se actualizaba bien (es un elemento aparte) pero el cuerpo no podía reconstruirse porque `#detailTable` ya no existía para que DataTables lo reinicializara — de ahí que el título mostrara el RT correcto mientras el cuerpo seguía mostrando "Acerca de". +- **No es una regresión de esta sesión:** `git log -S"function showAbout"` ubica su introducción en el commit `335e3e1`, un refactor antiguo sin relación con los cambios de hoy. +- **Fix aplicado:** `showAbout()` ahora restaura el HTML original del contenedor al cerrar su modal (evento `hidden.bs.modal`), y destruye la instancia de DataTable si estaba inicializada, antes de reemplazar el contenido. Cambio mínimo, contenido dentro de `showAbout()`, sin tocar `app_matriz_js.html` ni `Index.html`. + +**Validaciones ejecutadas:** `node --check` en los `.js` tocados; `node scripts/lint-html-scripts.js app_core_js.html pac_seccion.html` → "2/2 bloques de JS válidos". No se ejecutó ninguna sincronización de PAC, por instrucción explícita del usuario. + +**Pendiente real:** +1. Decidir qué hacer con el ID hardcodeado en `app_core_js.html:1060` (`URL_NORMALIZACION`), nuevo hallazgo de esta sesión, sin tocar todavía. +2. Confirmar manualmente si `pac_verificarAccesoExterno()` pasa contra la fuente real una vez compartida y configurada, antes de autorizar cualquier sync. +3. Probar en navegador que el fix del modal de detalle de RT funciona como se espera (abrir "Acerca de", cerrarlo, y confirmar que el detalle de un RT se ve correcto después). + +## 51. Tarea S — sync seguro de PAC: dedup, guarda de eliminación y respaldo [2026-10-06] + +**Contexto:** tras el diagnóstico de la Sección 50, se confirmó que `sincronizarPAC()` no acumula duplicados entre corridas (cada aprobación reemplaza `PAC_Vigente` por completo), pero no deduplicaba RT repetidos dentro de la fuente externa, y las filas que dejan de estar en la fuente desaparecen de `PAC_Vigente` al aprobar, sin ningún aviso ni respaldo previo. Esta sección cierra esos tres huecos, sin ejecutar ninguna sincronización real. + +**1. Deduplicación por RT (`_pac_compararYGenerarBorrador()`, `pac_gestor.js`):** antes de comparar o construir el borrador, se filtran las filas de la fuente externa por `RT`, conservando solo la primera aparición de cada clave. El conteo de duplicados descartados queda en `res.duplicadosDescartados` (nunca se imprime el contenido de las filas, solo el número). Las filas sin `RT` no se deduplican, porque no hay clave para comparar. + +**2. Guarda de seguridad del 20% (`sincronizarPAC()`, `pac_gestor.js`):** si el borrador resultante eliminaría más del 20% de las filas actuales de `PAC_Vigente`, la función **nunca** autoaprueba, sin importar qué tan "limpio" se vea el resto del cambio. Devuelve `{ok:true, requiereRevision:true, mensaje, eliminadas, nuevas, total}` además de los campos existentes (`success`, `cambios`, `nuevos`, `eliminados`, `modificados`, `duplicadosDescartados`), y registra la advertencia por `pac_log()`. Solo bloquea la escritura automática — la aprobación manual sigue siendo posible desde la hoja `PAC_Borrador` o desde `aprobarBorradorPAC()` directamente, a propósito (es una guarda, no un bloqueo absoluto). + +**3. Respaldo automático antes de aprobar (`_pac_respaldarVigenteAntesDeAprobar()`, nueva, `pac_gestor.js`):** se llama desde `aprobarBorradorPAC()` justo antes de `hojaVigente.clearContents()`. Copia `PAC_Vigente` completa a una hoja oculta `BAK_PAC_Vigente_` dentro del mismo spreadsheet destino, y conserva solo los 3 respaldos más recientes de ese prefijo (borra los demás). No es crítico: si el respaldo falla, se registra el error y la aprobación continúa igual, para no bloquear la operación principal por un problema de respaldo. + +**4. Resumen del borrador antes de aprobar (`pac_seccion.html`, frontend):** `pac_sincronizar()` ahora muestra nuevas/eliminadas/modificadas/duplicados descartados antes de ofrecer el botón de aprobar. Si el backend marcó `requiereRevision:true`, no se ofrece aprobar desde ese flujo en absoluto — se muestra una alerta explícita pidiendo revisar `PAC_Borrador` manualmente. + +**Bonus, mismo archivo:** se quitó un ID de spreadsheet que `sincronizarPAC()` imprimía en el mensaje de error cuando ninguna fuente trae datos (`'... (ID: ' + PAC_CONFIG.PAC_SPREADSHEET_ID + ')'`), consistente con la política de la Tarea K2/K2b. + +**Validaciones ejecutadas:** `node --check pac_gestor.js` (sintaxis válida), `node scripts/lint-html-scripts.js pac_seccion.html` → "1/1 bloques de JS válidos". No se ejecutó ninguna sincronización real, por instrucción explícita del usuario. + +**Pendiente real:** probar el flujo completo con datos reales una vez se comparta y configure la fuente externa de PAC — en particular, confirmar que la guarda del 20% se comporta como se espera con el caso real de `PAC_Vigente` (1.740 filas, 383 RT que no están en el origen, que representarían ~22% si todas se eliminaran de golpe). + +## 52. Tarea R — URL de Normalización movida de frontend hardcodeado a backend con RBAC [2026-10-06] + +**Contexto:** el hallazgo nuevo de la Sección 50 (`app_core_js.html:1060`) tenía un ID de spreadsheet distinto a los 5 conocidos, hardcodeado directo en el frontend. Antes de tocarlo, se confirmó que `URL_NORMALIZACION` se usaba en un único punto (`abrirNormalizacion()`), llamada desde un único ítem de menú (`Index.html:80`) — confirmado el alcance antes de editar, como se pidió. + +**Qué se cambió:** +- `app_core_js.html` — `abrirNormalizacion()` ya no tiene ningún ID hardcodeado. Ahora pide la URL al backend vía `google.script.run.obtenerUrlNormalizacion()`, y solo la abre si la respuesta viene con éxito. Si no hay permiso o la Script Property no está configurada, muestra una alerta con el motivo en vez de abrir nada. +- `Codigo.js` — nueva función `obtenerUrlNormalizacion()`: verifica que el usuario activo tenga rol Administrador (mismo mecanismo que `pac_verificarRolAdmin()` en el módulo PAC) antes de leer y devolver la Script Property `NORMALIZACION_URL`. Registra tanto el acceso concedido como el intento denegado vía `logAction()`. Si la property no está configurada, devuelve un mensaje claro en vez de fallar en silencio. + +**Validaciones ejecutadas:** `node --check Codigo.js` (sintaxis válida), `node scripts/lint-html-scripts.js app_core_js.html` → "1/1 bloques de JS válidos". Se confirmó con `git grep` que el ID ya no aparece en ningún archivo rastreado por git. + +**Pendiente real, acción del usuario:** crear la Script Property `NORMALIZACION_URL` en el proyecto `18vY9...` con el valor de la URL actual, **antes** de hacer `clasp push` de este cambio — si no, el botón de Normalización deja de funcionar hasta que se configure (el mensaje de error ya es claro sobre esto, pero el botón no funcionará igual). + +## 53. Cursor entra al modelo multi-agente y queda anclado al script 18vY9 [2026-10-18] + +**Agente:** Cursor. Sin cambios de código del tablero y sin `clasp push`. + +**Qué se hizo:** Claude Code ya tenía el skill de graphify en `~/.claude/skills/graphify` y Copilot en `~/.copilot/skills/graphify`. Cursor no tenía el equivalente. Se instaló con `graphify cursor install`, que escribió `.cursor/rules/graphify.mdc` (`alwaysApply: true`). El grafo ya existía (`graphify-out/graph.json`, 876 nodos). + +**Reglas nuevas, solo para Cursor:** +- `.cursor/rules/script-unico.mdc` — único Apps Script permitido: `18vY9LSc7K8fL-HErdaCJ0ar9ITO4IpvJ_UDi24rVbFgeGJhzfSny7FGi`. Prohibido tocar `16gqzy…` y `17Syj1…`. +- `.cursor/rules/convivencia-agentes.mdc` — misma convivencia que la Sección 16 y `CONTRIBUTING.md`: un checkout a la vez, no revertir trabajo ajeno, bitácora única, `clasp push` solo si el usuario lo pide y solo a `18vY9…`. + +**Consulta de grafo sobre alertas (misma fecha):** `graphify query` une `MotorEvaluadorReglas.ejecutarMotor` / `_guardarAlertasEnHoja` con `evaluarAlertasDataset` y, por otro lado, `MotorReglas.reglasBase`. `graphify path MotorReglas MotorEvaluadorReglas` no encuentra camino dirigido: son dos motores paralelos. `graphify explain obtenerReglasJSON` es ambiguo: el símbolo existe en `evaluador_alertas.js` y en `motor_reglas.js`. Los `.html` siguen clasificados como documentación en el grafo (ver `.graphifyignore`), así que `app_alertas_js.html` no aparece como nodo de código. + +## 54. Motor de reglas y alertas — antes del cambio [2026-10-18] + +**Agente:** Cursor. Línea base: `node scripts/lint-html-scripts.js motor_reglas.js evaluador_alertas.js Codigo.js app_alertas_js.html Index.html` → `6/6 bloques de JS válidos`. `.clasp.json` confirma el script `18vY9LSc7K8fL-HErdaCJ0ar9ITO4IpvJ_UDi24rVbFgeGJhzfSny7FGi`. + +**Archivos que se van a tocar:** `motor_reglas.js` (guardar JSON y, hasta ahora, reglas fijas del dashboard), `evaluador_alertas.js` (evaluación y una copia duplicada del guardado), `Codigo.js` (dashboard y correo), `app_alertas_js.html` (bandeja, CSV y el botón que llama a una función inexistente), `Index.html` (botón Nueva Regla sin acción y botones copiados en Permisos). Se añade `app_reglas_editor_js.html` y el permiso de Calendar en `appsscript.json`. + +**Cambio previsto:** una sola fuente de reglas, dashboard leyendo `ALERTAS_ACTIVAS`, constructor gráfico, solo Administrador para guardar/ejecutar/enviar, CSV con columnas reales, trigger diario propio, cruce marcado como no evaluado. Plan: `PLAN_MOTOR_REGLAS_ALERTAS.md`. + +## 55. Motor de reglas y alertas — después del cambio [2026-10-18] + +**Agente:** Cursor. Código local listo. El 2026-10-18 a las 10:41 el usuario confirmó el respaldo del JSON y se ejecutó `clasp push --force` al script `18vY9LSc7K8fL-HErdaCJ0ar9ITO4IpvJ_UDi24rVbFgeGJhzfSny7FGi` (52 archivos, incluido `app_reglas_editor_js.html`). Sin `clasp deploy`. No se instaló el trigger desde el agente. + +**Funciones:** +- `motor_reglas.js`: quedó solo el almacén. Nuevas `_exigirAdminMotorReglas_`, `_obtenerHojaConfigReglas_`, `_versionReglas_`, `_leerEncabezadosDatosReglas_`, `validarConfigReglas_`, `obtenerMetadatosEditorReglas`. `obtenerReglasJSON` devuelve `version`. `guardarReglasJSON` exige Administrador, valida, limita 49.000 caracteres y rechaza un guardado si otro administrador cambió la hoja. Se borró `class MotorReglas`. +- `evaluador_alertas.js`: se borraron la copia de obtener/guardar, `evaluarAlertasDataset` y `_normalizarSeveridadAlertas`. `ejecutarMotor` lee por `obtenerReglasJSON`, ignora una regla mala sin tumbar el resto, cuenta reglas con cruce no evaluado y cruce ignorado, escribe `ALERTAS_ACTIVAS` e invalida el caché. Nuevas `resumirAlertasActivasParaDashboard_`, `ejecutarMotorReglasInterno_`, `triggerDiarioMotorReglas`, `instalarTriggerMotorReglas`, `eliminarTriggerMotorReglas` y las constantes de operadores. +- `Codigo.js`: el dashboard llama a `resumirAlertasActivasParaDashboard_`. `procesarReporteAlertas` exige Administrador. +- `app_alertas_js.html`: badges ROJO/NARANJA/AMARILLO, aviso "Sin evaluar", CSV con `DIAS RESTANTES` y `ESTADO PREDIAL ACTUAL`, se quitó `programarReporte`. +- `Index.html`: botón Nueva Regla abre el constructor, Guardar JSON quedó en el modo desarrollador, Permisos ya no tiene los cuatro botones de alertas, nota de alcance para Articulador y Gestor. +- `app_reglas_editor_js.html`: constructor gráfico nuevo. +- `appsscript.json`: se añadió `calendar.readonly`. + +**Validación:** `node --check` en `motor_reglas.js`, `evaluador_alertas.js` y `Codigo.js` sin error. `node scripts/lint-html-scripts.js` en los HTML tocados: bloques válidos. Una sola definición de `obtenerReglasJSON` y `guardarReglasJSON`, ambas en `motor_reglas.js`. + +**Pendiente manual:** reautorizar el script por el permiso nuevo de Calendar; ejecutar una vez `instalarTriggerMotorReglas()` con la cuenta Administrador y comprobar en Activadores el handler `triggerDiarioMotorReglas` diario a las 6:00; recorrer el checklist de la Web App (constructor, bandeja, aviso "Sin evaluar", CSV y página Permisos). El checklist de tiempos de la Web App queda para cuando el usuario lo recorra. + +## 56. Plan de seguimiento del correo de alertas [2026-10-18] + +**Agente:** Cursor. Solo plan. Sin cambios de código del tablero y sin `clasp push`. + +**Qué se hizo:** se escribió `PLAN_SEGUIMIENTO_CORREO_ALERTAS.md`. El correo actual (`procesarReporteAlertas` en `Codigo.js`) adjunta el Excel y agrupa cantidades, sin listar RT ni pedir respuesta. El plan propone lista de RT por semáforo, política por regla y color en el constructor, cuadro en la Web App y respuesta en el mismo hilo, con historial en la hoja `SEGUIMIENTO_ALERTAS`. + +**Auditoría:** Claude, mismo día, veredicto APROBAR CON CAMBIOS. El plan conjunto quedó en la sección 12. Fabian cerró D1–D5. El correo de jefatura se pide en el cuadro de envío; no va a la hoja de usuarios. + +## 57. Seguimiento del correo de alertas — antes del cambio [2026-10-18] + +**Agente:** Cursor. `procesarReporteAlertas` en `Codigo.js` enviaba con `MailApp.sendEmail` un Excel y un cuerpo que contaba articuladores y gestores, sin listar RT. `_obtenerObservacionesPorRT` copia la observación de la hoja Datos al adjunto; no es historial. `doGet` niega el ingreso a quien no tiene rol en Permisos (cerca de la línea 171). No se pudo leer la hoja Permisos en vivo: hay que confirmar ahí que los gestores que deben responder tienen rol, porque si no, el enlace no abre. El spike de Gmail (`createDraft` → `send` → `replyAll`) no se ejecutó en el editor. Por eso `appsscript.json` todavía no lleva el scope nuevo. + +## 58. Seguimiento del correo de alertas — después del cambio local [2026-10-18] + +**Agente:** Cursor. Sin `clasp push`. + +**Funciones nuevas:** `seguimiento_alertas.js` crea la hoja `SEGUIMIENTO_ALERTAS` y la carpeta "Seguimiento alertas", lee la política `seguimiento_correo` de cada regla, arma las filas pendientes y guarda la respuesta. `despacharCorreoAlertas_` sustituye los dos `MailApp.sendEmail` de `procesarReporteAlertas`. El PDF, el trigger del motor y el PAC no se tocaron. + +**Pantalla:** el constructor pinta Seguimiento del correo dentro de `pintarConstructor`. El modal de envío pide la jefatura. El enlace `?sa=` abre `app_seguimiento_alertas_js.html` después de cargar el tablero. + +**Validación:** `node --check` en `seguimiento_alertas.js`, `motor_reglas.js` y `Codigo.js`. Lint de los HTML tocados: bloques válidos. + +**Spike 2026-10-18 13:22:** `spikeHiloGmailSeguimiento` falló porque el manifiesto solo tenía `gmail.send`. Se agregó `gmail.modify`. A las 13:26 se hizo `clasp push --force` al script `18vY9…` (54 archivos). La API del proyecto remoto confirma `seguimiento_alertas`, `app_seguimiento_alertas_js`, `despacharCorreoAlertas_` y el scope `gmail.modify`. Falta que el dueño acepte la autorización nueva y repita la prueba del hilo. Si `replyAll` falla después de autorizar, el scope pasa a `https://mail.google.com/`. La Web App publicada no cambia hasta una versión nueva. + +## 59. Retroalimentación visible en la bandeja [2026-10-18] + +**Agente:** Cursor. + +**Qué se hizo:** en `#page-alertas` el Administrador tiene el botón Retroalimentación. El cuadro vive en `app_reglas_editor_js.html` (`abrirRetroalimentacionAlertas`, `guardarRetroalimentacionAlertas`). Escribe `seguimiento_correo` con `persistirConfigReglas`, que ahora acepta callbacks para no usar el modal del constructor. `cargarJSONenPantalla` acepta un callback y, si se llama desde la bandeja, pregunta antes de pisar el Modo JSON. La validación de este guardado solo mira el bloque de retroalimentación. No se tocaron el correo, la hoja de seguimiento ni PAC. + +**Validación:** lint de `Index.html`, `app_alertas_js.html` y `app_reglas_editor_js.html`. A las 15:28 se hizo `clasp push --force` al script `18vY9…` (54 archivos). Un `clasp pull` posterior coincide, archivo por archivo, con el repositorio. El botón Retroalimentación está en el `Index.html` remoto. La Web App publicada no cambia hasta una versión nueva: hay que probarlo en la URL de desarrollo, como Administrador. + +**Prueba de envío 15:43:** el aviso rojo sale en `GmailApp.createDraft`. El manifiesto ya tiene `gmail.modify`, pero la cuenta no lo ha aceptado. Ejecutar `procesarReporteAlertas` desde el editor no pide el permiso: sin correo destino vuelve antes de Gmail. El aviso de `COLUMNS.OBS` es la columna de observaciones del Excel y no corta el envío. La función `autorizarGmailSeguimiento` crea un borrador al propio usuario y lo borra, para que el editor muestre la autorización. + +## 60. Cuerpo completo y formulario de respuesta [2026-10-18] + +**Agente:** Cursor. Sin despliegue de la Web App. + +**Qué se hizo:** `_buildResumenHtmlAgrupado` y `_textoPlanoAlertas_` listan todos los RT, ordenados por días de vigencia, con gestor, articulador y estado predial. Se quitó el corte de 40 y la frase del adjunto. El botón "Responder observaciones" va arriba y abajo. Si `doGet` recibe `sa` válido, después del rol y antes del registro de ingreso al tablero, abre `seguimiento_formulario.html`. Guardar sigue en `guardarSeguimientoAlertas`. `probarCuerpoAlertas_` arma 155 RT ficticios y no llama a Gmail. + +**Validación:** `node --check` en `Codigo.js` y `seguimiento_alertas.js`. Lint del formulario. Una simulación local con 155 RT no perdió ninguno, respetó el orden y no escribió la frase del adjunto. El HTML de esa prueba pesó 31768 caracteres. A las 19:20 se hizo `clasp push --force` al script `18vY9…` (55 archivos, incluye `seguimiento_formulario`). La Web App publicada no cambia hasta una versión nueva. + +**Concurrencia:** la hoja no se reescribe entera. Cada guardado agrega filas y el candado cubre solo esa escritura. A partir del siguiente envío, si el gestor y el articulador son correos distintos, cada uno recibe su propia fila pendiente. El enlace ya abierto sigue con las filas compartidas de ese envío. + +## 61. Informe de jefatura y revisión de estado [2026-10-18] + +**Agente:** Cursor. Plan: `PLAN_INFORME_JEFE_Y_REVISION.md`, sección 12 y 13. Sin despliegue de la Web App. + +**Qué se hizo:** el asunto del correo nombra la regla que va en ese envío. El texto inicial sale de `texto_inicial_correo` de cada regla, editable en Retroalimentación; si está vacío, sale el texto base. "No se va a usar" pide un solo archivo, el informe técnico / jurídico. El formulario tiene Revisar observación, con fecha del documento, y esos RT se envían solo a quien revisa soportes. El evaluador guarda `FECHA_VENCIMIENTO`. El Excel del gestor usa esa fecha. Si el envío trae correo de jefatura, sale además un informe con tablas y un XLSX de dos hojas. La fecha aparece después de la próxima corrida del motor. + +**Validación:** `node --check` en `Codigo.js`, `seguimiento_alertas.js`, `evaluador_alertas.js` y `motor_reglas.js`. A las 21:00 se hizo `clasp push` al script `18vY9…` (55 archivos). La Web App publicada no cambia hasta una versión nueva: hay que probarlo en la URL de desarrollo. La fecha de vencimiento queda en la hoja cuando se vuelva a correr el motor. + +**Corrección del informe de jefatura:** el primer correo dejó todas las filas en Sin fecha y contó 82 pendientes, el total de la hoja y no las alertas del envío. Al armar el reporte se calcula la fecha con la columna de inicio de la regla y su vida útil. En avalúo, si la regla no trae otra columna, es `FECHA AVALUO` más 365 días. El Excel vuelve a traer `DIAS RESTANTES` junto a `FECHA_VENCIMIENTO`. Las respondidas y pendientes son solo las alertas de ese envío. El correo y el Excel incluyen una torta de esos vencimientos. + +## 62. Plan — una observación, texto reutilizable e informe al jefe inmediato [2026-10-26] + +**Agente:** Cursor. Solo plan. Sin cambios de código del tablero y sin `clasp push`. + +**Qué se hizo:** se escribió `PLAN_UNA_OBSERVACION_Y_JEFE.md`. Tres ajustes pedidos por Fabian: una sola observación por alerta (la llena el gestor o el articulador), el texto anterior dentro del editor, y el informe pasa a decir jefe inmediato, sin columna de vencidos, con rangos 0 a 30, 31 a 60, 61 a 90 y mayor de 90, agrupado por articulador y proyecto. + +**Auditoría:** Claude Code, con la sesión de claude.ai y sin la clave de API. Revisó el plan y los archivos que se van a modificar. Primera vuelta APROBAR CON CAMBIOS: la ayuda del modal dice "informe gerencial", no "jefatura", y el plan no la nombraba. Cursor la agregó (líneas 813 y 964-965 de `app_alertas_js.html`). Segunda vuelta: **APROBAR**. No se ejecuta hasta que Fabian lo pida. + +## 63. Ejecución — una observación, texto en el editor e informe al jefe inmediato [2026-10-26] + +**Agente:** Cursor. Plan: `PLAN_UNA_OBSERVACION_Y_JEFE.md`, secciones 3 a 5 y 12. Sin `clasp push` y sin despliegue. + +**Funciones:** +- `seguimiento_alertas.js`: `_personasPendiente_` deja una fila con los dos correos. `registrarPendientesSeguimiento_` no repite el mismo RT y regla en el envío. `guardarSeguimientoAlertas` guarda el texto del cuadro como `OBSERVACION_VIGENTE`, sin concatenar la previa. Los dos errores dicen jefe inmediato. +- `seguimiento_formulario.html`: el cuadro nace con la observación anterior. La página dice que basta la respuesta del gestor o del articulador. +- `Codigo.js`: `_cuboFilaInforme_` usa `DIAS RESTANTES` y, si no es número, la fecha. Devuelve `d30`, `d60`, `d90`, `mas90`, `sin` o `null`. El día negativo no suma. El cuadro y la hoja Totales agrupan por articulador y proyecto. El título es "Vigencia por articulador y proyecto". El correo y el archivo dicen jefe inmediato. +- `app_alertas_js.html`: etiqueta, placeholder, toast y las tres ayudas (línea fija y las dos del callback) dicen jefe inmediato. +- `app_reglas_editor_js.html`: `TEXTO_AVALUO_AVISO` dice jefe inmediato. No se reescribieron reglas ya guardadas. + +**Validación:** `node --check` en `Codigo.js` y `seguimiento_alertas.js`. Lint de `seguimiento_formulario.html`, `app_alertas_js.html` y `app_reglas_editor_js.html`: 3/3. Prueba local de rangos: −1 no entra, 0 y 30 en 0 a 30, 31 y 60 en 31 a 60, 61 y 90 en 61 a 90, 91 y 120 en mayor de 90, texto vacío en sin fecha. Un `<` dentro de la observación queda escapado y el salto de línea se conserva. + +**Fuera de este cambio:** motor, `JEFE_EMAIL`, `_historialSeguimientoInforme_`, `app_seguimiento_alertas_js.html`, Para, CC y el Excel del gestor. La Web App publicada no cambia hasta una versión nueva. + +## 64. Plan — subir el ajuste y abrir el enlace de observaciones [2026-10-26] + +**Agente:** Cursor. Solo plan. Sin `clasp push`. + +**Qué se vio:** el correo del 2026-10-26 salió con el informe viejo (jefatura, gestor, vencidas, 15 y 30 días) y con dos tarjetas del mismo RT. Ese código ya no está en el repositorio (`fd0a626`). El `git push` no actualiza Apps Script. El botón abre la URL `/dev` de `URLS_SISTEMA.md`, y esa dirección solo la abre quien edita el proyecto. + +**Plan:** `PLAN_SUBIDA_Y_ACCESO_OBSERVACIONES.md`. Claude Code lo auditó leyendo los archivos y respondió **APROBAR**. + +## 65. Ejecución — enlace de observaciones para quien recibe el correo [2026-10-26] + +**Agente:** Cursor. Plan: `PLAN_SUBIDA_Y_ACCESO_OBSERVACIONES.md`. Fabian pidió ejecutarlo y subirlo al repositorio y al script `18vY9…`. + +**Funciones:** +- `Codigo.js` `doGet`: si `sa` es válido, abre el formulario antes de pedir rol en Permisos. Sin correo de sesión muestra que debe entrar con la cuenta del IDU y no abre el tablero. +- `seguimiento_alertas.js` `_urlSeguimientoAlertas_`: si `getUrl()` termina en `/dev`, el botón usa `/exec`. +- `_filasVisiblesDelToken_`: con correo de sesión muestra las pendientes de ese token. Sin correo no lista ni guarda. +- `RESPONDIDO_POR` queda al final de `SEGUIMIENTO_ALERTAS`. La fila respondida guarda el correo de quien diligenció. La pendiente lo deja vacío. + +**Validación:** `node --check` en `Codigo.js` y `seguimiento_alertas.js`. `clasp push --force` al script `18vY9LSc7K8fL-HErdaCJ0ar9ITO4IpvJ_UDi24rVbFgeGJhzfSny7FGi` subió 55 archivos. La lectura posterior del proyecto remoto devolvió cero diferencias y confirmó en el HEAD remoto: “Informe de jefe inmediato”, “Entra con tu cuenta del IDU”, `RESPONDIDO_POR`, el cambio de `/dev` a `/exec` y el texto de una sola observación. El manifiesto remoto coincide con `appsscript.json`. El enlace público queda descrito en la sección 66. + +**Auditoría de la ejecución:** Claude Code, sesión claude.ai, solo lectura sobre el diff. Veredicto: **APROBAR**. No pidió correcciones. + +## 66. Despliegue web del enlace de observaciones [2026-10-26] + +**Agente:** Cursor. Fabian pidió publicar la versión nueva del mismo despliegue, con acceso al dominio. + +**Resultado:** el despliegue `AKfycbyfD70GGwc4DlnFW48B0xvUX8LFaGKWPxnSGjW08wQ` es de solo lectura. La API respondió `Read-only deployments may not be modified`. No tiene número de versión: sirve el código más reciente del proyecto. Acceso `DOMAIN`, ejecución `USER_DEPLOYING`. URL: `https://script.google.com/a/macros/idu.gov.co/s/AKfycbyfD70GGwc4DlnFW48B0xvUX8LFaGKWPxnSGjW08wQ/exec`. Al abrirla, Google pide una cuenta `@idu.gov.co`. + +Quedó creada la versión inmutable 5, descripción `Observaciones: una respuesta y enlace para quien recibe el correo`, como foto del código subido. Ese id no acepta fijarla. + +El despliegue `AKfycbyGXZ0Dwnu2I_QoyXjVbW6DVST4BKlk0ZNVzjvDIH8iNJHQzd0PrbpfxzXgo24VU_tV` sigue en la versión 4, descripción `REVISION`. No se modificó. + +## 67. Plan — informe a cinco días hábiles y cuerpo de las respuestas [2026-10-26] + +**Agente:** Cursor. Plan aprobado: `PLAN_INFORME_SIN_RESPUESTA_Y_CUERPO.md`. Incluye el informe a cinco días hábiles, que se enciende al enviar las alertas, los cuerpos de respuesta y de base, el aviso amarillo, la obligación de responder y la lectura del botón. Claude Code lo auditó en dos pasadas contra el código y cerró en **APROBAR**. Cursor también. La ejecución está en la sección 68. + +## 68. Ejecución — informe a cinco días y cuerpos de correo [2026-10-26] + +**Agente:** Cursor. Plan: `PLAN_INFORME_SIN_RESPUESTA_Y_CUERPO.md`. + +**Funciones:** `Codigo.js` deja el botón Responder observaciones antes del detalle, con la frase de pulsarlo, la obligación y el aviso amarillo. `seguimiento_alertas.js` guarda `ASUNTO` e `INFORME_PENDIENTES_EN`, arma el cuerpo de la respuesta y el de quien actualiza la base, y crea el trigger de las 7:00 al enviar si no existe. `seguimiento_formulario.html` ordena la lectura y conserva los campos. + +**Validación:** `node --check` en `Codigo.js` y `seguimiento_alertas.js`. El script del formulario quedó válido. Lunes 2026-10-26 más cinco hábiles da 2026-11-02; viernes 2026-10-30 da 2026-11-06; con festivo el 2026-10-28 da 2026-11-03. + +**Auditoría de la ejecución:** Claude Code, sesión claude.ai, solo lectura sobre el diff. Veredicto: **APROBAR**. No pidió correcciones. Los archivos que el plan deja intactos no entraron al diff. + +## 69. Plan — respuesta a tres días y aviso al séptimo [2026-10-26] + +**Agente:** Cursor. Plan aprobado: `PLAN_ESCALA_TRES_Y_SIETE_DIAS.md`. Sustituye el aviso de cinco días por el articulador al tercer día hábil y, al séptimo, por quien remitió la alerta con copia al jefe inmediato. El correo de alertas dirá ese plazo. Claude Code lo leyó archivo por archivo y respondió **APROBAR**. Cursor también. La ejecución está en la sección 70. + +## 70. Ejecución — tres días al articulador y siete a quien remitió [2026-10-26] + +**Agente:** Cursor. Plan: `PLAN_ESCALA_TRES_Y_SIETE_DIAS.md`. + +**Funciones:** `Codigo.js` agrega en el correo de alertas la frase de los tres días hábiles y del séptimo. `seguimiento_alertas.js` agrega `INFORME_ARTICULADOR_EN`. A los tres días hábiles el articulador recibe los RT a su cargo y el gestor de cada uno. A los siete, quien remitió la alerta recibe lo que sigue pendiente, con copia al jefe inmediato. El aviso de cinco días deja de enviarse. + +**Validación:** `node --check` en `Codigo.js` y `seguimiento_alertas.js`. Lunes 2026-10-26 más 3 hábiles da 2026-10-29, y más 7 da 2026-11-04. + +**Auditoría de la ejecución:** Claude Code, sesión claude.ai, solo lectura sobre el diff. Veredicto: **APROBAR**. No pidió correcciones de código. Los archivos que el plan deja intactos no entraron al diff. + +## 71. Carga verificada — tres sitios con la escala de tres y siete días [2026-10-26] + +**Agente:** Cursor. Commit `f768978` en local y en `origin/fix/post-audit-rbac`. El mismo código quedó en el script `18vY9LSc7K8fL-HErdaCJ0ar9ITO4IpvJ_UDi24rVbFgeGJhzfSny7FGi` con `clasp push`. El manifiesto se interpretó igual; el empuje usó `--force` porque el archivo difería solo en formato y, sin eso, clasp omite toda la subida. + +**Validación:** 55 archivos `.js`, `.html` y `.json` bajados del script coinciden en hash con el proyecto local. El script ya tiene la frase de los tres días hábiles, `INFORME_ARTICULADOR_EN` y «siete días hábiles». + +**Auditoría:** Claude Code, sesión claude.ai, solo lectura, con `clasp pull` en una carpeta temporal fuera del repo. Veredicto: **APROBAR**. Local, GitHub y ese script coinciden. No abrió los otros scripts. + +## 72. Texto formal del informe al jefe inmediato [2026-10-26] + +**Agente:** Cursor. Plan: `PLAN_TEXTO_INFORME_JEFE.md`. + +**Qué se encontró:** la frase de los tres días hábiles sí está en el script, en el correo de alertas al gestor, y solo cuando ese envío exige observación y tiene enlace. El correo que se estaba leyendo es el informe al jefe inmediato. Ese texto no se había reescrito y seguía explicando la fecha de vencimiento con FECHA AVALUO más 365 días. + +**Funciones:** `Codigo.js`, `_parrafosInformeJefe_`, `_textoReglaInformeJefe_`, `_htmlInformeJefatura_` y `_enviarInformeJefatura_`. El informe saluda, remite el consolidado, nombra el tipo de alerta y pone un párrafo por regla. Si la regla tiene texto inicial, usa ese. Si el nombre habla de avalúo y no hay texto propio, dice que no hay oferta notificada y el avalúo aparece sin uso. El término de tres y siete días hábiles queda en ese correo. La tabla, la gráfica y el Excel siguen igual. + +**Validación:** `node --check` en `Codigo.js`. + +**Auditoría:** Claude Code leyó el plan y pidió no limitar el término a las alertas críticas, porque la observación se exige por nivel. La ejecución usa «las alertas que exigen observación». Sobre el diff respondió **APROBAR**. + +## 73. Cierre de sesión — alertas de tres y siete días e informe al jefe [2026-10-26] + +**Agente:** Cursor. Acta: `PROTOCOLO_CIERRE_SESION_2026-10-26_CURSOR.md`. Reglas aplicadas: sección 16.2 de esta bitácora, `PROTOCOLO_VERSIONAMIENTO_2026.md` y la regeneración de entregables de `CLAUDE.md`. + +**Estado:** COMPLETADO 2026-10-26. Rama `fix/post-audit-rbac`. Último commit de código `a680ee8`. Local y `origin/fix/post-audit-rbac` coinciden. El script `18vY9LSc7K8fL-HErdaCJ0ar9ITO4IpvJ_UDi24rVbFgeGJhzfSny7FGi` se volvió a bajar al cierre: 55 archivos. En el `Codigo.js` remoto la frase de tres días está en las líneas 2068 y 2228, y el informe consolidado en la 2597. En `seguimiento_alertas.js` el encabezado `INFORME_ARTICULADOR_EN` está en la línea 13, el asunto «Gestionar respuesta» en la 818 y «Pasaron siete días hábiles» en la 843. No aparecen «cinco días hábiles» ni «FECHA AVALUO más 365». + +**Commits de la jornada en esta rama:** `fd0a626`, `fab839a`, `5443d2c`, `471fa7a`, `0040f08`, `f768978`, `f791cf4`, `a680ee8`. + +**Pendiente al cierre:** esta rama no está fusionada en `main`. Los correos ya enviados conservan el texto con el que salieron. El párrafo de oferta notificada solo se usa si la regla de avalúo no tiene texto inicial en Retroalimentación. No se agregó un segundo correo automático cuando todas las observaciones estén listas. \ No newline at end of file diff --git a/Documento_Tecnico_Aplicacion_Predios.docx b/Documento_Tecnico_Aplicacion_Predios.docx index a016a51..6855bdc 100644 Binary files a/Documento_Tecnico_Aplicacion_Predios.docx and b/Documento_Tecnico_Aplicacion_Predios.docx differ diff --git a/Documento_Tecnico_Aplicacion_Predios.pdf b/Documento_Tecnico_Aplicacion_Predios.pdf index 6af30e3..8a782ef 100644 Binary files a/Documento_Tecnico_Aplicacion_Predios.pdf and b/Documento_Tecnico_Aplicacion_Predios.pdf differ diff --git a/Index.html b/Index.html index 5e68fdb..0269b2c 100644 --- a/Index.html +++ b/Index.html @@ -91,7 +91,7 @@
+
El tablero muestra las alertas de tus RT. Esta bandeja muestra las de los proyectos que puedes ver.
@@ -883,20 +887,7 @@

Gestión de Permisos

-
- - - - -
+
@@ -935,9 +926,6 @@

-

@@ -957,7 +945,7 @@
Panel de Control de Reglas de Negocio
en sí. La próxima vez que se + // abría el detalle de un RT, el título se actualizaba bien (es un elemento aparte), + // pero el cuerpo seguía mostrando "Acerca de" porque #detailTable ya no existía en el + // DOM para que DataTables lo reinicializara. Se restaura el HTML original del + // contenedor al cerrar este modal, antes de que nadie pueda volver a abrir el detalle. + const modalEl = document.getElementById('detailModal'); + const $contenedorTabla = $('#detailTable').parent(); + const htmlOriginalTabla = $contenedorTabla.html(); + + if (typeof $.fn.DataTable !== 'undefined' && $.fn.DataTable.isDataTable('#detailTable')) { + $('#detailTable').DataTable().destroy(); + } + + new bootstrap.Modal(modalEl).show(); $('#modalTitle').text('Acerca de'); - $('#detailTable').parent().html(html); + $contenedorTabla.html(html); + + $(modalEl).one('hidden.bs.modal', function () { + $contenedorTabla.html(htmlOriginalTabla); + }); } function logout() { @@ -1057,25 +1081,25 @@
Características Principales: } function abrirNormalizacion() { - const URL_NORMALIZACION = 'https://docs.google.com/spreadsheets/d/1pB8sXqsrddUIQY6sE1Sllpqye2V75lGjwgDwoUH3_90/edit?pli=1&gid=2100236556#gid=2100236556'; - - console.log('🔧 Abriendo herramienta de Normalización...'); - window.open(URL_NORMALIZACION, '_blank'); - - try { - google.script.run - .withSuccessHandler(function() { - console.log('✅ Acceso a Normalización registrado'); - }) - .withFailureHandler(function(error) { - console.warn('⚠️ No se pudo registrar el acceso:', error.message); - }) - .logAction(currentUser, 'Acceso a Normalización', 'Abrió CONSOLIDADO Script'); - } catch (e) { - console.warn('⚠️ Error registrando acceso:', e.message); - } - - showToast('📂 Abriendo Base de Datos para Normalización...', 'info'); + // ✅ [2026-10-06] FIX Tarea R: la URL ya no vive hardcodeada en el frontend (se + // enviaba al navegador de cualquiera que abriera la web app). Ahora se pide al + // backend, que solo la entrega a usuarios con rol Administrador y registra el + // acceso (o el intento denegado) por su cuenta — ver obtenerUrlNormalizacion(). + console.log('🔧 Solicitando acceso a herramienta de Normalización...'); + google.script.run + .withSuccessHandler(function(resp) { + if (resp && resp.success && resp.url) { + showToast('📂 Abriendo Base de Datos para Normalización...', 'info'); + window.open(resp.url, '_blank'); + } else { + alert('⚠️ ' + (resp && resp.mensaje ? resp.mensaje : 'No se pudo abrir Normalización.')); + } + }) + .withFailureHandler(function(error) { + console.warn('⚠️ No se pudo obtener la URL de Normalización:', error.message); + alert('⚠️ No se pudo abrir Normalización: ' + (error.message || error)); + }) + .obtenerUrlNormalizacion(); } console.log('✅ app_core_js.html cargado'); diff --git a/app_reglas_editor_js.html b/app_reglas_editor_js.html new file mode 100644 index 0000000..f8ebeae --- /dev/null +++ b/app_reglas_editor_js.html @@ -0,0 +1,566 @@ + diff --git a/app_seguimiento_alertas_js.html b/app_seguimiento_alertas_js.html new file mode 100644 index 0000000..101e5c4 --- /dev/null +++ b/app_seguimiento_alertas_js.html @@ -0,0 +1,134 @@ + diff --git a/appsscript.json b/appsscript.json index dd52a8c..f5d0e4a 100644 --- a/appsscript.json +++ b/appsscript.json @@ -14,7 +14,9 @@ "https://www.googleapis.com/auth/script.external_request", "https://www.googleapis.com/auth/script.scriptapp", "https://www.googleapis.com/auth/gmail.send", + "https://www.googleapis.com/auth/gmail.modify", "https://www.googleapis.com/auth/script.send_mail", - "https://www.googleapis.com/auth/userinfo.email" + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/calendar.readonly" ] } diff --git a/config.js b/config.js index d02e408..431118e 100644 --- a/config.js +++ b/config.js @@ -27,7 +27,7 @@ const CONFIG = { STAGING: '', // ID de Dato 2 / Staging para validaciones y promoción manual LOGS: '', // ✅ FASE 5b: spreadsheet separado (BD_OPERACIONAL_PREDIOS) para logs de auditoría (registrarAccion/getUserLogs) — ID real en Script Property DATA_FILES_LOGS_ID USUARIOS: '', // ✅ SPRINT5-FASE-A: directorio de identidad/rol (EMAIL/ROL/NOMBRE/ACTIVO/COMPONENTE), spreadsheet separado — ver ARCHITECTURE_V4.md Sección 6.1 — ID real en Script Property DATA_FILES_USUARIOS_ID - LOGS_ASIGNACION: 'ID_SPREADSHEET_LOGS_ASIGNACION_AQUI' // ⚠️ SPRINT5-FASE-A: placeholder sin reemplazar — crear spreadsheet dedicado y pegar su ID aquí antes de operar en producción (registrarLogAsignacion() se autodeshabilita mientras esto siga así, ver auditoria.js:31 para el mismo patrón) + LOGS_ASIGNACION: '' // ✅ [2026-10-06] SEGURIDAD: ya NO se pega el ID aquí en texto plano (ver nota de seguridad [2026-08-19] arriba) — se resuelve en runtime desde la Script Property DATA_FILES_LOGS_ASIGNACION_ID (CONFIG_SENSITIVE_PROPERTY_MAP). Falta crear el spreadsheet dedicado y setear esa Script Property; hasta entonces registrarLogAsignacion() sigue autodeshabilitado. }, // ✅ NUEVO: Compatibilidad con código que usa DATA_FILES_IDS @@ -178,8 +178,9 @@ const CONFIG = { }, // ✅ PERMISOS POR ROL — SPRINT5-FASE-A añade Articulador/Gestor + // ✅ SPRINT6-FASE-0: agregar ADMIN_SISTEMA, PAC_APROBAR para RBAC server-side PERMISOS_POR_ROL: { - 'Administrador': ['LEER', 'EDITAR', 'ELIMINAR', 'PERMISOS', 'REPORTES'], + 'Administrador': ['LEER', 'EDITAR', 'ELIMINAR', 'PERMISOS', 'REPORTES', 'ASIGNAR_EQUIPO', 'ADMIN_SISTEMA', 'PAC_APROBAR'], 'Editor': ['LEER', 'EDITAR', 'REPORTES'], 'Lector': ['LEER', 'REPORTES'], 'Articulador': ['LEER', 'EDITAR', 'REPORTES', 'ASIGNAR_EQUIPO'], // alcance recortado a sus proyectos — aplicado en backend, no solo UI @@ -251,6 +252,7 @@ const CONFIG_SENSITIVE_PROPERTY_MAP = { 'DATA_FILES.LOGS': 'DATA_FILES_LOGS_ID', 'DATA_FILES.USUARIOS': 'DATA_FILES_USUARIOS_ID', 'DATA_FILES.PRINCIPAL': 'DATA_FILES_PRINCIPAL_ID', + 'DATA_FILES.LOGS_ASIGNACION': 'DATA_FILES_LOGS_ASIGNACION_ID', // ✅ [2026-10-06]: cierra el mismo hueco de seguridad para LOGS_ASIGNACION (antes solo tenía placeholder en texto plano, ver nota en DATA_FILES) 'MAESTRO_PERMISOS': 'MAESTRO_PERMISOS_ID' }; @@ -396,7 +398,7 @@ function getConfig(path, defaultValue = null) { if (path === 'DATA_FILES.PRINCIPAL' && (value === null || value === undefined || value === '')) { try { const activeId = SpreadsheetApp.getActiveSpreadsheet().getId(); - console.log(`✅ DATA_FILES.PRINCIPAL no configurado, usando hoja activa: ${activeId}`); + console.log('✅ DATA_FILES.PRINCIPAL no configurado, usando hoja activa.'); // ✅ [2026-10-06] SEGURIDAD: ya no imprime el ID return activeId; } catch (err) { console.warn('⚠️ No se pudo obtener Spreadsheet activo para DATA_FILES.PRINCIPAL: ' + err.message); @@ -520,7 +522,7 @@ function validateConfig() { } console.log('✅ Configuración válida'); - console.log(` DATA_FILES.PRINCIPAL: ${getConfig('DATA_FILES.PRINCIPAL')}`); + console.log(` DATA_FILES.PRINCIPAL: configurado (${getConfig('DATA_FILES.PRINCIPAL') ? 'sí' : 'no'})`); // ✅ [2026-10-06] SEGURIDAD: ya no imprime el ID completo console.log(` DATA_FILES_IDS: ${JSON.stringify(getConfig('DATA_FILES_IDS'))}`); return true; @@ -568,7 +570,7 @@ function diagnosticarSistema() { } const ss = SpreadsheetApp.openById(fileId); console.log(` Nombre: ${ss.getName()}`); - console.log(` ID: ${ss.getId()}`); + console.log(' ID: configurado'); // ✅ [2026-10-06] SEGURIDAD: ya no imprime el ID completo // 5. Listar hojas console.log('\n📋 HOJAS DISPONIBLES:'); @@ -648,7 +650,7 @@ function getConfigInfo() { sheets: Object.keys(getConfig('SHEETS')), columns: Object.keys(getConfig('COLUMNS')), roles: Object.keys(getConfig('ROLES')), - dataFileId: getConfig('DATA_FILES.PRINCIPAL').substring(0, 15) + '...', + dataFileId: getConfig('DATA_FILES.PRINCIPAL') ? 'configurado' : 'no configurado', // ✅ [2026-10-06] SEGURIDAD: ya no devuelve ningún fragmento del ID isValid: validateConfig() }; } catch (e) { diff --git a/config.js.backup b/config.js.backup new file mode 100644 index 0000000..10e99a6 --- /dev/null +++ b/config.js.backup @@ -0,0 +1,1047 @@ +/** + * ═══════════════════════════════════════════════════════════ + * CONFIGURACIÓN CENTRAL DEL SISTEMA - V3.0 COMPATIBLE + * ═══════════════════════════════════════════════════════════ + */ + +const CONFIG = { + // ✅ NUEVO: CONTROL DE MANTENIMIENTO + MANTENIMIENTO: { + ACTIVO: false, // Cambia true a false cuando termines de actualizar la base + MENSAJE: "Estamos actualizando nuestra base de datos para brindarte un mejor servicio. En breve volveremos a estar en funcionamiento.", + CORREOS_CONTACTO: "fabian.montanez@idu.gov.co o sistemasdtdp@idu.gov.co" + }, + + // ✅ ARCHIVOS DE DATOS (ESTRUCTURA ORIGINAL + COMPATIBILIDAD) + // ⚠️ SEGURIDAD [2026-08-19]: los IDs de spreadsheet reales de LOGS, USUARIOS y + // MAESTRO_PERMISOS ya NO viven aquí en texto plano — este repo estuvo público en + // GitHub y esos IDs quedaron expuestos. Ahora se resuelven en runtime desde + // Script Properties (ver getConfigProperty() más abajo y getConfig(), que hace el + // lookup automáticamente para estas claves). Los valores '' de aquí son solo el + // fallback si la Script Property correspondiente no está seteada — ver + // DOCUMENTACION_TECNICA_VIVA.md sección "Migración de IDs sensibles a Script + // Properties" para el procedimiento de setup. + DATA_FILES: { + PRINCIPAL: '', // Usar el Spreadsheet activo si no se especifica otro ID (o Script Property DATA_FILES_PRINCIPAL_ID) + SECUNDARIOS: [], + STAGING: '', // ID de Dato 2 / Staging para validaciones y promoción manual + LOGS: '', // ✅ FASE 5b: spreadsheet separado (BD_OPERACIONAL_PREDIOS) para logs de auditoría (registrarAccion/getUserLogs) — ID real en Script Property DATA_FILES_LOGS_ID + USUARIOS: '', // ✅ SPRINT5-FASE-A: directorio de identidad/rol (EMAIL/ROL/NOMBRE/ACTIVO/COMPONENTE), spreadsheet separado — ver ARCHITECTURE_V4.md Sección 6.1 — ID real en Script Property DATA_FILES_USUARIOS_ID + LOGS_ASIGNACION: 'ID_SPREADSHEET_LOGS_ASIGNACION_AQUI' // ⚠️ SPRINT5-FASE-A: placeholder sin reemplazar — crear spreadsheet dedicado y pegar su ID aquí antes de operar en producción (registrarLogAsignacion() se autodeshabilita mientras esto siga así, ver auditoria.js:31 para el mismo patrón) + }, + + // ✅ NUEVO: Compatibilidad con código que usa DATA_FILES_IDS + DATA_FILES_IDS: [], + + // ✅ COMPATIBILIDAD: Alias para código antiguo — ID real en Script Property MAESTRO_PERMISOS_ID (ver nota de seguridad arriba) + MAESTRO_PERMISOS: '', + + // ✅ HOJAS DE CÁLCULO (V1 + V2 + NUEVAS) - SIN CAMBIOS + SHEETS: { + DATOS: 'Datos', + SEGUIMIENTO: 'Seguimiento', + PERMISOS: 'Permisos', + REPORTES: 'ReportesGuardados', + LOGS: 'Logs', + AUDITORIA: 'LOGS_AUDITORIA', + HISTORIAL_PERMISOS: 'Historial_Permisos', + USUARIOS: 'USUARIOS', // ✅ SPRINT5-FASE-A: pestaña dentro de DATA_FILES.USUARIOS + LOGS_ASIGNACION: 'LOGS_ASIGNACION', // ✅ SPRINT5-FASE-A: pestaña dentro de DATA_FILES.LOGS_ASIGNACION, creada automáticamente por registrarLogAsignacion() si no existe + // ✅ SPRINT6-DESACOPLE [CONC-BE-12]: capa de escritura de asignaciones, separada de Datos + // (que pasa a ser 100% lectura). Vive en DATA_FILES.PRINCIPAL, junto a Datos — se crea + // automáticamente con headers por _asegurarHojaAsignacionesEquipos() si no existe. + ASIGNACIONES_EQUIPOS: 'ASIGNACIONES_EQUIPOS' + }, + + // ✅ COLUMNAS DE DATOS (V1 + V2 completo) - SIN CAMBIOS + COLUMNS: { + // Identificadores + RT: 'RT', + CHIP: 'CHIP', + + // Ubicación + PROYECTO: 'PROYECTO', + TRAMO: 'TRAMO', + DIRECCION: 'DIRECCIÓN', + + // Estados (V1 + V2) + ESTADO: 'ESTADO PREDIAL AJUSTADO', + ESTADO_PREDIAL: 'ESTADO PREDIAL', + ESTADO_RT: 'ESTADO RT', + ESTADO_TITULOS: 'ESTADO ESTUDIO DE TITULOS', + ESTADO_TASACION: 'ESTADO TASACIÓN', + ESTADO_AVALUO: 'ESTADO AVALUO', + + // Disponibilidad + DISPONIBILIDAD: 'PREDIOS DISPONIBLES (INCLUYE CESIONES)', + + // Financiero + ESTIMADO: 'ESTIMADO $', + PAGADO: 'VALOR PAGADO', + SALDO: 'SALDO', + + // Fechas + FECHA_ENTREGA: 'FECHA ESTIMADA DE ENTREGA', + FECHA_AVALUO: 'FECHA AVALUO', + + // Especiales + SITUACIONES: 'SITUACIONES ESPECIALES', + OBSERVACIONES: 'OBSERVACIONES', + + // Virtual (creado en cliente) + FECHA_ISO: 'FECHA_ISO', + + // ✅ SPRINT5-FASE-A: responsables jurídicos — nombres EXACTOS de producción. + // ARTICULADOR_JURIDICO tiene un typo real en la hoja ("JUIRIDICO", no "JURIDICO") — NO corregir, + // debe matchear el header literal o findColumnIndex() no lo encuentra. + ARTICULADOR_JURIDICO: 'ARTICULADOR JUIRIDICO', + GESTOR_JURIDICO: 'GESTOR JURÍDICO' + }, + + // ✅ COLUMNAS DE SEGUIMIENTO (V2) - SIN CAMBIOS + COLUMNS_SEGUIMIENTO: [ + 'FECHA', + 'RT', + 'ESTADO_PREDIAL', + 'ESTADO_PREDIAL_AJUSTADO', + 'PREDIOS_DISPONIBLES', + 'ESTADO_RT', + 'ESTADO_TITULOS', + 'ESTADO_TASACION', + 'ESTADO_AVALUO', + 'FECHA_AVALUO', + 'SITUACIONES', + 'OBSERVACIONES' + ], + + // ✅ COLUMNAS DE AUDITORIA (NUEVA) - SIN CAMBIOS + COLUMNS_AUDITORIA: [ + 'TIMESTAMP', + 'USUARIO', + 'RT', + 'CAMPO', + 'VALOR_ANTERIOR', + 'VALOR_NUEVO', + 'ACCION' + ], + + // ✅ COLUMNAS DE PERMISOS - SIN CAMBIOS + COLUMNS_PERMISOS: [ + 'EMAIL', + 'ROL', + 'PROYECTOS', + 'ACTIVO', + 'FECHA_CREACION' + ], + + // ✅ COLUMNAS DE USUARIOS (SPRINT5-FASE-A) — esquema confirmado por el usuario, hoja en DATA_FILES.USUARIOS + COLUMNS_USUARIOS: [ + 'No', + 'EMAIL', + 'ROL', + 'NOMBRE', + 'ACTIVO', + 'COMPONENTE' + ], + + // ✅ COLUMNAS DE LOGS_ASIGNACION (SPRINT5-FASE-A) — ver ARCHITECTURE_V4.md Sección 3 + COLUMNS_LOG_ASIGNACION: [ + 'TIMESTAMP', + 'NIVEL', + 'ID_TARGET', + 'ROL', + 'USUARIO_ANTERIOR', + 'USUARIO_NUEVO', + 'EJECUTOR_EMAIL', + 'OBSERVACIONES' + ], + + // ✅ SPRINT6-DESACOPLE [CONC-BE-12]: esquema de ASIGNACIONES_EQUIPOS, clave primaria RT. + // Único destino de escritura de asignarEquipoGranular()/reasignarUsuarioMasivo()/ + // ejecutarCargaLineaCero()/asignarEquipoGranularLote() — Datos ya no se escribe nunca. + COLUMNS_ASIGNACIONES_EQUIPOS: [ + 'RT', + 'ARTICULADOR_EMAIL', + 'GESTOR_EMAIL', + 'FECHA_ACTUALIZACION', + 'EJECUTOR' + ], + + // ✅ ROLES Y PERMISOS — SPRINT5-FASE-A añade ARTICULADOR/GESTOR (jerarquía de negocio, + // ver ARCHITECTURE_V4.md Sección 6.1: se decidió extender CONFIG.ROLES, no una capa paralela) + ROLES: { + EDITOR: 'Editor', + LECTOR: 'Lector', + ADMIN: 'Administrador', + ARTICULADOR: 'Articulador', + GESTOR: 'Gestor' + }, + + // ✅ PERMISOS POR ROL — SPRINT5-FASE-A añade Articulador/Gestor + // ✅ SPRINT6-FASE-0: agregar ADMIN_SISTEMA, PAC_APROBAR para RBAC server-side + PERMISOS_POR_ROL: { + 'Administrador': ['LEER', 'EDITAR', 'ELIMINAR', 'PERMISOS', 'REPORTES', 'ASIGNAR_EQUIPO', 'ADMIN_SISTEMA', 'PAC_APROBAR'], + 'Editor': ['LEER', 'EDITAR', 'REPORTES'], + 'Lector': ['LEER', 'REPORTES'], + 'Articulador': ['LEER', 'EDITAR', 'REPORTES', 'ASIGNAR_EQUIPO'], // alcance recortado a sus proyectos — aplicado en backend, no solo UI + 'Gestor': ['LEER', 'REPORTES'] // alcance recortado a los RTs de su Articulador + }, + + // ✅ FORMATOS - SIN CAMBIOS + FORMATS: { + FECHA_ISO: 'YYYY-MM-DD', + MONEDA: 'COP', + LOCALE: 'es-CO' + }, + + // ✅ ESTADOS PERMITIDOS (V2) - SIN CAMBIOS + ESTADOS_PERMITIDOS: { + DISPONIBILIDAD: ['DISPONIBLE', 'PENDIENTE', 'CESIÓN'], + PREDIAL: ['ADQUIRIDO', 'EN PROCESO', 'PENDIENTE'], + RT: ['ACTIVO', 'INACTIVO', 'EN REVISIÓN'], + TITULOS: ['COMPLETO', 'INCOMPLETO', 'EN REVISIÓN'], + TASACION: ['COMPLETADA', 'PENDIENTE', 'EN REVISIÓN'], + AVALUO: ['COMPLETADO', 'PENDIENTE', 'EN REVISIÓN'] + }, + + // ✅ TRIMESTRES PARA PROYECCIÓN - SIN CAMBIOS + TRIMESTRES: { + Q1: [1, 2, 3], + Q2: [4, 5, 6], + Q3: [7, 8, 9], + Q4: [10, 11, 12] + }, + + // ✅ FINANCIACIÓN (NUEVA - para futura mejora #3) - SIN CAMBIOS + FINANCIACION_TIPOS: { + 'TRANSMILENIO': 'TM', + 'IDU': 'IDU', + 'OTROS': 'OTR' + }, + + // ✅ NUEVA SECCIÓN: CONFIGURACIÓN DE FILTRO MATRIZ + FILTRO_MATRIZ: { + SHEET_NAME: 'FiltroMatriz', + COLUMNS: { + ID: 'ID', + NOMBRE: 'NOMBRE_FILTRO', + DESCRIPCION: 'DESCRIPCION', + PROYECTOS_INCLUIDOS: 'PROYECTOS_INCLUIDOS', + PROYECTOS_EXCLUIDOS: 'PROYECTOS_EXCLUIDOS', + ACTIVO: 'ACTIVO', + USUARIO_CREADOR: 'USUARIO_CREADOR', + FECHA_CREACION: 'FECHA_CREACION', + ULTIMA_MODIFICACION: 'ULTIMA_MODIFICACION', + TIPO: 'TIPO' + } + } +}; + +/** + * ═══════════════════════════════════════════════════════════ + * FUNCIONES DE CONFIGURACIÓN - ROBUSTAS Y VALIDADAS + * ═══════════════════════════════════════════════════════════ + */ + +/** + * ✅ SEGURIDAD [2026-08-19]: mapa de rutas de CONFIG cuyo valor real vive en Script + * Properties (no en el código versionado). getConfig() consulta esta tabla antes de + * devolver el valor hardcodeado (que para estas claves es '' a propósito). + */ +const CONFIG_SENSITIVE_PROPERTY_MAP = { + 'DATA_FILES.LOGS': 'DATA_FILES_LOGS_ID', + 'DATA_FILES.USUARIOS': 'DATA_FILES_USUARIOS_ID', + 'DATA_FILES.PRINCIPAL': 'DATA_FILES_PRINCIPAL_ID', + 'MAESTRO_PERMISOS': 'MAESTRO_PERMISOS_ID' +}; + +/** + * ✅ SEGURIDAD [2026-08-19]: lee un valor sensible (típicamente un ID de Spreadsheet) + * desde Script Properties en vez de tenerlo hardcodeado en el código fuente + * versionado. Usar para cualquier ID que no deba quedar expuesto si el repositorio + * se vuelve público (aunque sea temporalmente). + * + * Cómo setear la Script Property la primera vez: ver DOCUMENTACION_TECNICA_VIVA.md, + * sección "Migración de IDs sensibles a Script Properties". + * + * @param {string} key - Nombre de la Script Property (ej: 'DATA_FILES_LOGS_ID') + * @param {*} [fallback=null] - Valor a devolver si la propiedad no está seteada + * @returns {*} El valor de la propiedad, o fallback si no existe/está vacío + */ +function getConfigProperty(key, fallback = null) { + try { + const value = PropertiesService.getScriptProperties().getProperty(key); + if (value === null || value === undefined || value === '') { + if (fallback === null || fallback === undefined || fallback === '') { + console.warn(`⚠️ getConfigProperty: Script Property '${key}' no está seteada. Configúrala en Extensiones ▸ Propiedades del proyecto ▸ Propiedades del script (o vía clasp), ver DOCUMENTACION_TECNICA_VIVA.md.`); + } + return fallback; + } + return value; + } catch (e) { + console.error(`❌ Error leyendo Script Property '${key}': ${e.message}`); + return fallback; + } +} + +/** + * ✅ SEGURIDAD [2026-08-19] [SEC-01]: valida explícitamente que las 4 Script + * Properties sensibles (IDs de spreadsheet que ya no viven en el código + * versionado tras la migración) estén seteadas. A diferencia de + * validateConfig()/diagnosticarSistema(), esta función SÍ cubre las 4 — + * incluyendo PAC_SPREADSHEET_ID, que vive en pac_config.js/PAC_CONFIG (fuera + * del objeto CONFIG) y por eso no aparece en CONFIG_SENSITIVE_PROPERTY_MAP. + * No imprime el valor completo en el log — solo presencia y longitud, para + * no exponer el ID en los logs de Stackdriver. + * @returns {{success: boolean, faltantes?: string[]}} + */ +function verificarScriptProperties() { + console.log('═══════════════════════════════════════════════════════'); + console.log('🔐 VERIFICANDO SCRIPT PROPERTIES SENSIBLES'); + console.log('═══════════════════════════════════════════════════════'); + + const propiedadesSensibles = [ + CONFIG_SENSITIVE_PROPERTY_MAP['DATA_FILES.LOGS'], // DATA_FILES_LOGS_ID + CONFIG_SENSITIVE_PROPERTY_MAP['DATA_FILES.USUARIOS'], // DATA_FILES_USUARIOS_ID + CONFIG_SENSITIVE_PROPERTY_MAP['MAESTRO_PERMISOS'], // MAESTRO_PERMISOS_ID + 'PAC_SPREADSHEET_ID' // pac_config.js/PAC_CONFIG — no vive en CONFIG_SENSITIVE_PROPERTY_MAP + ]; + + const props = PropertiesService.getScriptProperties(); + const faltantes = []; + + propiedadesSensibles.forEach(key => { + const value = props.getProperty(key); + if (value === null || value === undefined || value === '') { + console.error(` ❌ ${key}: FALTA`); + faltantes.push(key); + } else { + console.log(` ✅ ${key}: OK (${value.length} caracteres)`); + } + }); + + console.log('═══════════════════════════════════════════════════════'); + + if (faltantes.length > 0) { + console.error(`❌ Faltan ${faltantes.length} Script Properties sensibles: ${faltantes.join(', ')}`); + return { success: false, faltantes: faltantes }; + } + + console.log('✅ Las 4 Script Properties sensibles están configuradas'); + return { success: true }; +} + +/** + * ✅ MEJORADO: Obtiene valor de configuración con validación completa + * @param {string} path - Ruta de la configuración (ej: 'SHEETS.DATOS') + * @param {*} defaultValue - Valor por defecto si no encuentra + * @returns {*} Valor de configuración o null + */ +function getConfig(path, defaultValue = null) { + try { + // ✅ VALIDACIÓN 1: Verificar que path existe + if (!path) { + console.warn(`⚠️ getConfig: path no proporcionado, usando defaultValue`); + return defaultValue; + } + + // ✅ VALIDACIÓN 2: Verificar que path es string + if (typeof path !== 'string') { + console.error(`❌ getConfig: path no es string, es ${typeof path}`); + return defaultValue; + } + + // ✅ VALIDACIÓN 3: Verificar que path no está vacío + if (path.trim() === '') { + console.warn(`⚠️ getConfig: path está vacío`); + return defaultValue; + } + + // ✅ Dividir el path + const keys = path.split('.'); + + // ✅ Navegar por la configuración + let value = CONFIG; + + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + + // Validar que value es un objeto + if (!value || typeof value !== 'object') { + console.warn(`⚠️ Config: ${path} - No se puede acceder a '${key}' en nivel ${i}`); + return defaultValue; + } + + // Validar que la clave existe + if (!(key in value)) { + console.warn(`⚠️ Config: Clave '${key}' no encontrada en ${path}`); + return defaultValue; + } + + value = value[key]; + } + + // ✅ SEGURIDAD [2026-08-19]: para rutas sensibles, resolver primero desde Script + // Properties antes de aplicar cualquier otro fallback (el valor hardcodeado en + // CONFIG para estas claves es '' a propósito, ver CONFIG_SENSITIVE_PROPERTY_MAP). + if (CONFIG_SENSITIVE_PROPERTY_MAP[path] && (value === null || value === undefined || value === '')) { + const fromProperties = getConfigProperty(CONFIG_SENSITIVE_PROPERTY_MAP[path]); + if (fromProperties) { + value = fromProperties; + } + } + + // ✅ Si solicitamos el archivo principal y no hay valor configurado (ni en código + // ni en Script Properties), usar el spreadsheet activo como fallback para + // staging y pruebas locales. + if (path === 'DATA_FILES.PRINCIPAL' && (value === null || value === undefined || value === '')) { + try { + const activeId = SpreadsheetApp.getActiveSpreadsheet().getId(); + console.log(`✅ DATA_FILES.PRINCIPAL no configurado, usando hoja activa: ${activeId}`); + return activeId; + } catch (err) { + console.warn('⚠️ No se pudo obtener Spreadsheet activo para DATA_FILES.PRINCIPAL: ' + err.message); + } + } + + // ✅ Retornar el valor encontrado + return value; + + } catch (e) { + console.error(`❌ Error en getConfig('${path}'): ${e.message}`); + return defaultValue; + } +} + +/** + * Obtiene configuración con valor por defecto seguro + * @param {string} path - Ruta de la configuración + * @param {*} defaultValue - Valor por defecto + * @returns {*} Valor de configuración o defaultValue + */ +function getConfigOrDefault(path, defaultValue) { + const value = getConfig(path); + return value !== null && value !== undefined ? value : defaultValue; +} + +/** + * Obtiene lista de archivos de datos utilizando DATA_FILES_IDS o fallback de MAESTRO_PERMISOS. + * @returns {string[]} Array de IDs de hojas de cálculo. + */ +function getDataFilesIds() { + try { + const ids = getConfig('DATA_FILES_IDS', []); + if (Array.isArray(ids) && ids.length > 0) { + return ids; + } + const allIds = getAllFileIds(); + if (Array.isArray(allIds) && allIds.length > 0) { + return allIds; + } + const maestro = getConfig('MAESTRO_PERMISOS'); + return maestro ? [maestro] : []; + } catch (e) { + console.error(`Error en getDataFilesIds: ${e.message}`); + const maestro = getConfig('MAESTRO_PERMISOS'); + return maestro ? [maestro] : []; + } +} + +/** + * ✅ MEJORADO: Valida que la configuración sea completa y correcta + * @returns {boolean} true si es válida, false si no + * @throws {Error} Si hay configuración crítica faltante + */ +function validateConfig() { + try { + console.log('🔍 Validando configuración...'); + + const required = [ + 'DATA_FILES.PRINCIPAL', + // ✅ SEGURIDAD [2026-08-19] [SEC-01]: DATA_FILES.LOGS y DATA_FILES.USUARIOS + // se resuelven vía Script Properties (CONFIG_SENSITIVE_PROPERTY_MAP) desde la + // migración de IDs sensibles — agregadas aquí para que validateConfig() falle + // de forma explícita si alguna Script Property falta, en vez de dejarlo pasar + // en silencio como ocurría antes del fix. PAC_SPREADSHEET_ID NO se agrega + // aquí a propósito: vive en pac_config.js/PAC_CONFIG, un objeto y archivo + // distinto — validateConfig() se mantiene enfocado solo en CONFIG + // (config.js). Se verifica junto con las otras 3 en verificarScriptProperties(). + 'DATA_FILES.LOGS', + 'DATA_FILES.USUARIOS', + 'SHEETS.DATOS', + 'SHEETS.PERMISOS', + 'SHEETS.LOGS', + 'COLUMNS.RT', + 'COLUMNS.PROYECTO', + 'ROLES.ADMIN', + 'ROLES.EDITOR', + 'ROLES.LECTOR', + 'FILTRO_MATRIZ.SHEET_NAME', + 'FILTRO_MATRIZ.COLUMNS.ID' + ]; + + const missing = []; + const invalid = []; + + for (const path of required) { + const value = getConfig(path); + + if (value === null || value === undefined) { + missing.push(path); + } + + if (typeof value === 'string' && value.trim() === '') { + invalid.push(path); + } + + // Validación especial para arrays + if (path === 'DATA_FILES_IDS' && (!Array.isArray(value) || value.length === 0)) { + invalid.push(path + ' (debe ser array con elementos)'); + } + } + + // Reportar errores + if (missing.length > 0) { + const error = `Config incompleta: ${missing.join(', ')}`; + console.error(`❌ ${error}`); + throw new Error(error); + } + + if (invalid.length > 0) { + const error = `Config inválida: ${invalid.join(', ')}`; + console.error(`❌ ${error}`); + throw new Error(error); + } + + const resolvedIds = getDataFilesIds(); + if (!Array.isArray(resolvedIds) || resolvedIds.length === 0) { + const error = 'Config inválida: no se encontraron archivos de datos válidos en DATA_FILES.PRINCIPAL, DATA_FILES.SECUNDARIOS, DATA_FILES_IDS o MAESTRO_PERMISOS'; + console.error(`❌ ${error}`); + throw new Error(error); + } + + console.log('✅ Configuración válida'); + console.log(` DATA_FILES.PRINCIPAL: ${getConfig('DATA_FILES.PRINCIPAL')}`); + console.log(` DATA_FILES_IDS: ${JSON.stringify(getConfig('DATA_FILES_IDS'))}`); + + return true; + + } catch (e) { + console.error(`❌ Error validando config: ${e.message}`); + throw e; + } +} + +/** + * ✅ NUEVO: Función de diagnóstico completa + */ +function diagnosticarSistema() { + console.log('═══════════════════════════════════════════════════════'); + console.log('🔍 DIAGNÓSTICO DEL SISTEMA'); + console.log('═══════════════════════════════════════════════════════'); + + try { + // 1. Verificar CONFIG + console.log('\n📋 VERIFICANDO CONFIG:'); + console.log(' typeof CONFIG:', typeof CONFIG); + console.log(' CONFIG existe:', CONFIG !== undefined); + + // 2. Verificar ambas estructuras de archivos + console.log('\n📂 VERIFICANDO ARCHIVOS:'); + console.log(' DATA_FILES.PRINCIPAL:', CONFIG.DATA_FILES.PRINCIPAL); + console.log(' DATA_FILES.SECUNDARIOS:', CONFIG.DATA_FILES.SECUNDARIOS); + console.log(' DATA_FILES_IDS:', CONFIG.DATA_FILES_IDS); + // ✅ SEGURIDAD [2026-08-19] [SEC-01]: antes leía CONFIG.MAESTRO_PERMISOS crudo, + // que desde la migración a Script Properties siempre es '' — daba un falso + // negativo aunque la Script Property estuviera bien seteada. getConfig() + // resuelve el valor real (ver CONFIG_SENSITIVE_PROPERTY_MAP más arriba). + console.log(' MAESTRO_PERMISOS:', getConfig('MAESTRO_PERMISOS')); + + // 3. Validar configuración + console.log('\n🔍 VALIDANDO CONFIGURACIÓN:'); + validateConfig(); + + // 4. Verificar Spreadsheet + console.log('\n📊 VERIFICANDO ARCHIVO:'); + const fileId = getConfig('DATA_FILES.PRINCIPAL'); + if (!fileId) { + throw new Error('DATA_FILES.PRINCIPAL no configurado y no hay hoja activa disponible'); + } + const ss = SpreadsheetApp.openById(fileId); + console.log(` Nombre: ${ss.getName()}`); + console.log(` ID: ${ss.getId()}`); + + // 5. Listar hojas + console.log('\n📋 HOJAS DISPONIBLES:'); + ss.getSheets().forEach((sheet, index) => { + const lastRow = sheet.getLastRow(); + const lastCol = sheet.getLastColumn(); + console.log(` ${index + 1}. ${sheet.getName()} - ${lastRow} filas x ${lastCol} columnas`); + }); + + // 6. Verificar hoja Datos + console.log('\n📂 VERIFICANDO HOJA "Datos":'); + const wsDatos = ss.getSheetByName('Datos'); + + if (wsDatos) { + const data = wsDatos.getDataRange().getValues(); + console.log(` ✅ Hoja encontrada`); + console.log(` 📊 Total filas: ${data.length}`); + console.log(` 📋 Total columnas: ${data[0] ? data[0].length : 0}`); + + if (data.length > 0) { + console.log(` 📝 Headers (primeros 5): ${data[0].slice(0, 5).join(', ')}`); + } + + if (data.length > 1) { + console.log(` 📄 Primera fila (primeros 3): ${data[1].slice(0, 3).join(', ')}`); + } + } else { + console.error(' ❌ Hoja "Datos" NO encontrada'); + console.log(' 💡 Hojas disponibles:', ss.getSheets().map(s => s.getName()).join(', ')); + } + + // 7. Probar getConfig con ambas estructuras + console.log('\n🧪 PROBANDO getConfig():'); + const testPaths = [ + 'DATA_FILES.PRINCIPAL', + 'DATA_FILES_IDS', + 'MAESTRO_PERMISOS', + 'SHEETS.DATOS', + 'COLUMNS.RT', + 'ROLES.EDITOR' + ]; + + testPaths.forEach(path => { + const value = getConfig(path); + console.log(` ${path}: ${JSON.stringify(value)}`); + }); + + console.log('\n═══════════════════════════════════════════════════════'); + console.log('✅ DIAGNÓSTICO COMPLETADO'); + console.log('═══════════════════════════════════════════════════════'); + + return { + success: true, + spreadsheet: ss.getName(), + sheets: ss.getSheets().length, + dataRows: wsDatos ? wsDatos.getLastRow() : 0 + }; + + } catch (e) { + console.error('\n❌ ERROR EN DIAGNÓSTICO:', e.message); + console.error('📍 Stack:', e.stack); + return { + success: false, + error: e.message, + stack: e.stack + }; + } +} + +// ✅ MANTENER TODAS LAS FUNCIONES ORIGINALES (SIN CAMBIOS) + +function getConfigInfo() { + try { + return { + version: '3.0.0', + timestamp: new Date().toISOString(), + sheets: Object.keys(getConfig('SHEETS')), + columns: Object.keys(getConfig('COLUMNS')), + roles: Object.keys(getConfig('ROLES')), + dataFileId: getConfig('DATA_FILES.PRINCIPAL').substring(0, 15) + '...', + isValid: validateConfig() + }; + } catch (e) { + console.error(`Error obteniendo info: ${e.message}`); + return { error: e.message }; + } +} + +function getAllSheets() { + try { + const sheets = getConfig('SHEETS'); + if (!sheets || typeof sheets !== 'object') { + console.warn('⚠️ SHEETS no es un objeto válido'); + return []; + } + return Object.values(sheets); + } catch (e) { + console.error(`Error obteniendo hojas: ${e.message}`); + return []; + } +} + +function getAllColumns() { + try { + const columns = getConfig('COLUMNS'); + if (!columns || typeof columns !== 'object') { + console.warn('⚠️ COLUMNS no es un objeto válido'); + return []; + } + return Object.values(columns); + } catch (e) { + console.error(`Error obteniendo columnas: ${e.message}`); + return []; + } +} + +function getAllRoles() { + try { + const roles = getConfig('ROLES'); + if (!roles || typeof roles !== 'object') { + console.warn('⚠️ ROLES no es un objeto válido'); + return []; + } + return Object.values(roles); + } catch (e) { + console.error(`Error obteniendo roles: ${e.message}`); + return []; + } +} + +function roleExists(rol) { + try { + if (!rol) return false; + const roles = getAllRoles(); + return roles.includes(rol); + } catch (e) { + console.error(`Error verificando rol: ${e.message}`); + return false; + } +} + +function getPermisosForRole(rol) { + try { + if (!rol) { + console.warn('⚠️ Rol no especificado'); + return []; + } + + const permisos = getConfig(`PERMISOS_POR_ROL.${rol}`); + + if (!permisos) { + console.warn(`⚠️ Rol '${rol}' no tiene permisos configurados`); + return []; + } + + if (!Array.isArray(permisos)) { + console.warn(`⚠️ Permisos para '${rol}' no es un array`); + return []; + } + + return permisos; + } catch (e) { + console.error(`Error obteniendo permisos: ${e.message}`); + return []; + } +} + +function hasPermission(rol, permiso) { + try { + if (!rol || !permiso) { + console.warn('⚠️ Rol o permiso no especificado'); + return false; + } + + const permisos = getPermisosForRole(rol); + return permisos.includes(permiso); + } catch (e) { + console.error(`Error verificando permiso: ${e.message}`); + return false; + } +} + +function getEstadosPermitidos(categoria) { + try { + if (!categoria) { + console.warn('⚠️ Categoría no especificada'); + return []; + } + + const estados = getConfig(`ESTADOS_PERMITIDOS.${categoria}`); + + if (!estados) { + console.warn(`⚠️ Categoría '${categoria}' no tiene estados configurados`); + return []; + } + + if (!Array.isArray(estados)) { + console.warn(`⚠️ Estados para '${categoria}' no es un array`); + return []; + } + + return estados; + } catch (e) { + console.error(`Error obteniendo estados: ${e.message}`); + return []; + } +} + +function isEstadoValido(categoria, estado) { + try { + if (!categoria || !estado) { + console.warn('⚠️ Categoría o estado no especificado'); + return false; + } + + const estados = getEstadosPermitidos(categoria); + return estados.includes(estado); + } catch (e) { + console.error(`Error verificando estado: ${e.message}`); + return false; + } +} + +function getSheetName(key) { + try { + if (!key) { + console.warn('⚠️ Clave de hoja no especificada'); + return null; + } + + return getConfig(`SHEETS.${key}`); + } catch (e) { + console.error(`Error obteniendo nombre de hoja: ${e.message}`); + return null; + } +} + +function getColumnName(key) { + try { + if (!key) { + console.warn('⚠️ Clave de columna no especificada'); + return null; + } + + return getConfig(`COLUMNS.${key}`); + } catch (e) { + console.error(`Error obteniendo nombre de columna: ${e.message}`); + return null; + } +} + +function getRoleName(key) { + try { + if (!key) { + console.warn('⚠️ Clave de rol no especificada'); + return null; + } + + return getConfig(`ROLES.${key}`); + } catch (e) { + console.error(`Error obteniendo nombre de rol: ${e.message}`); + return null; + } +} + +function getPrincipalFileId() { + try { + const fileId = getConfig('DATA_FILES.PRINCIPAL'); + + if (!fileId) { + throw new Error('DATA_FILES.PRINCIPAL no configurado'); + } + + return fileId; + } catch (e) { + console.error(`Error obteniendo archivo principal: ${e.message}`); + return null; + } +} + +function getStagingFileId() { + try { + const fileId = getConfig('DATA_FILES.STAGING'); + return fileId || null; + } catch (e) { + console.error(`Error obteniendo archivo staging: ${e.message}`); + return null; + } +} + +function getSecondaryFileIds() { + try { + const fileIds = getConfig('DATA_FILES.SECUNDARIOS'); + + if (!Array.isArray(fileIds)) { + console.warn('⚠️ SECUNDARIOS no es un array'); + return []; + } + + return fileIds; + } catch (e) { + console.error(`Error obteniendo archivos secundarios: ${e.message}`); + return []; + } +} + +/** + * ✅ MEJORADO: Obtiene todos los IDs de archivos (compatible con ambas estructuras) + */ +function getAllFileIds() { + try { + // Método 1: Usar DATA_FILES_IDS si existe + const dataFilesIds = getConfig('DATA_FILES_IDS'); + if (dataFilesIds && Array.isArray(dataFilesIds) && dataFilesIds.length > 0) { + console.log('✅ Usando DATA_FILES_IDS'); + return dataFilesIds; + } + + // Método 2: Construir desde DATA_FILES + const principal = getPrincipalFileId(); + const secundarios = getSecondaryFileIds(); + + if (!principal) { + console.warn('⚠️ No hay archivo principal configurado'); + return secundarios; + } + + console.log('✅ Usando DATA_FILES.PRINCIPAL + SECUNDARIOS'); + return [principal, ...secundarios]; + + } catch (e) { + console.error(`Error obteniendo todos los archivos: ${e.message}`); + return []; + } +} + +function getFormatConfig() { + try { + return getConfig('FORMATS') || { + FECHA_ISO: 'YYYY-MM-DD', + MONEDA: 'COP', + LOCALE: 'es-CO' + }; + } catch (e) { + console.error(`Error obteniendo formato: ${e.message}`); + return { + FECHA_ISO: 'YYYY-MM-DD', + MONEDA: 'COP', + LOCALE: 'es-CO' + }; + } +} + +function printConfig() { + try { + console.log('═══════════════════════════════════════════════════════════'); + console.log('📋 CONFIGURACIÓN COMPLETA DEL SISTEMA'); + console.log('═══════════════════════════════════════════════════════════'); + console.log(''); + + console.log('📁 ARCHIVOS DE DATOS:'); + console.log(' Principal:', getPrincipalFileId()); + console.log(' Secundarios:', getSecondaryFileIds()); + console.log(' DATA_FILES_IDS:', getConfig('DATA_FILES_IDS')); + console.log(''); + + console.log('📊 HOJAS DE CÁLCULO:'); + Object.entries(getConfig('SHEETS')).forEach(([key, value]) => { + console.log(` ${key}: ${value}`); + }); + console.log(''); + + console.log('📋 COLUMNAS PRINCIPALES:'); + Object.entries(getConfig('COLUMNS')).forEach(([key, value]) => { + console.log(` ${key}: ${value}`); + }); + console.log(''); + + console.log('👥 ROLES:'); + Object.entries(getConfig('ROLES')).forEach(([key, value]) => { + console.log(` ${key}: ${value}`); + }); + console.log(''); + + console.log('🔐 PERMISOS POR ROL:'); + Object.entries(getConfig('PERMISOS_POR_ROL')).forEach(([rol, permisos]) => { + console.log(` ${rol}: ${permisos.join(', ')}`); + }); + console.log(''); + + console.log('✅ Estados permitidos:'); + Object.entries(getConfig('ESTADOS_PERMITIDOS')).forEach(([cat, estados]) => { + console.log(` ${cat}: ${estados.join(', ')}`); + }); + console.log(''); + + console.log('═══════════════════════════════════════════════════════════'); + + } catch (e) { + console.error(`Error imprimiendo config: ${e.message}`); + } +} + +function testConfig() { + console.log('🧪 Iniciando pruebas de configuración...\n'); + + try { + // Test 1: Obtener valor simple + console.log('✅ Test 1: Obtener SHEETS.DATOS'); + const sheetDatos = getConfig('SHEETS.DATOS'); + console.log(` Resultado: ${sheetDatos}\n`); + + // Test 2: Obtener con valor por defecto + console.log('✅ Test 2: Obtener valor inexistente con default'); + const inexistente = getConfig('INEXISTENTE.VALOR', 'default-value'); + console.log(` Resultado: ${inexistente}\n`); + + // Test 3: Validar configuración + console.log('✅ Test 3: Validar configuración'); + validateConfig(); + console.log(' Configuración válida!\n'); + + // Test 4: Verificar permisos + console.log('✅ Test 4: Verificar permisos de Editor'); + const tieneEditar = hasPermission('Editor', 'EDITAR'); + console.log(` ¿Editor puede EDITAR? ${tieneEditar}\n`); + + // Test 5: Estados permitidos + console.log('✅ Test 5: Obtener estados de DISPONIBILIDAD'); + const estados = getEstadosPermitidos('DISPONIBILIDAD'); + console.log(` Estados: ${estados.join(', ')}\n`); + + // Test 6: NUEVO - Verificar compatibilidad de archivos + console.log('✅ Test 6: Verificar compatibilidad de IDs de archivos'); + const allIds = getAllFileIds(); + console.log(` IDs encontrados: ${JSON.stringify(allIds)}\n`); + + console.log('🎉 TODOS LOS TESTS PASARON!\n'); + + } catch (e) { + console.error(`❌ Error en tests: ${e.message}`); + } +} + +/** + * Ejecuta el diagnóstico y lo guarda en la hoja `DIAGNOSTICO` (staging) + * Útil para ejecutar desde staging sin revisar logs manualmente + */ +function staging_diagnosticar() { + const lock = LockService.getScriptLock(); + try { + lock.waitLock(30000); + const resultado = diagnosticarSistema(); + + const fileId = getConfig('DATA_FILES.PRINCIPAL'); + const ss = SpreadsheetApp.openById(fileId); + let sheet = ss.getSheetByName('DIAGNOSTICO'); + if (!sheet) { + sheet = ss.insertSheet('DIAGNOSTICO'); + sheet.appendRow(['TIMESTAMP', 'SUCCESS', 'SUMMARY', 'DETAILS']); + } + + const summary = resultado.success ? 'OK' : 'ERROR'; + const details = JSON.stringify(resultado); + sheet.insertRowBefore(2); + sheet.getRange(2, 1, 1, 4).setValues([[new Date(), summary, resultado.spreadsheet || '', details]]); + + return { success: true, message: 'Diagnóstico ejecutado y guardado en DIAGNOSTICO' }; + } catch (e) { + console.error(`Error en staging_diagnosticar: ${e.message}`); + return { success: false, error: e.message }; + } finally { + try { lock.releaseLock(); } catch (er) {} + } +} diff --git a/consolidacion_colab/__pycache__/uniondatosfinales.cpython-314.pyc b/consolidacion_colab/__pycache__/uniondatosfinales.cpython-314.pyc new file mode 100644 index 0000000..276f12e Binary files /dev/null and b/consolidacion_colab/__pycache__/uniondatosfinales.cpython-314.pyc differ diff --git a/datos.js b/datos.js index 25ea594..2a37199 100644 --- a/datos.js +++ b/datos.js @@ -17,7 +17,7 @@ class GestorDatos { this.ss = SpreadsheetApp.openById(this.fileId); this.cache = {}; - console.log(`✅ GestorDatos inicializado: ${this.fileId.substring(0, 10)}...`); + console.log('✅ GestorDatos inicializado: configurado'); // ✅ [2026-10-06] SEGURIDAD: ya no imprime ningún fragmento del ID } catch (e) { console.error(`❌ Error en GestorDatos: ${e.message}`); throw e; diff --git a/diagnostico_estructura.js b/diagnostico_estructura.js new file mode 100644 index 0000000..db5fba4 --- /dev/null +++ b/diagnostico_estructura.js @@ -0,0 +1,720 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// DIAGNOSTICO_ESTRUCTURA.GS — Diagnóstico post-migración [2026-10-06] +// Solo lectura. NUNCA escribir IDs reales de spreadsheet en este archivo. +// Contexto completo en DOCUMENTACION_TECNICA_VIVA.md, Secciones 44-48. +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * ✅ [2026-10-06] Mapa de a qué Script Property corresponde el archivo destino de cada + * hoja del origen monolítico, según la tabla `mapeoMigracion` real de + * migracion_automatica_v2.js:224-247 (NO se repite aquí ningún ID, solo el nombre de la + * Script Property). Las hojas que no aparecen en este mapa se asumen destinadas a + * DATA_FILES_PRINCIPAL_ID por defecto (todas las de ese bloque en el script de migración + * original quedan ahí). + */ +const _DIAG_MAPA_DESTINO_HOJA = { + 'LOGS_AUDITORIA': 'DATA_FILES_LOGS_ID', + 'Logs': 'DATA_FILES_LOGS_ID', + '📈 Historial de Cambios': 'DATA_FILES_LOGS_ID', + '📋 Auditoría de Cambios': 'DATA_FILES_LOGS_ID', + '📊 Logs del Sistema': 'DATA_FILES_LOGS_ID', + 'USUARIOS': 'DATA_FILES_USUARIOS_ID', + 'ASIGNACIONES_EQUIPOS': 'DATA_FILES_USUARIOS_ID', + 'Asignacion_RT': 'DATA_FILES_USUARIOS_ID', + 'Permisos': 'MAESTRO_PERMISOS_ID', + 'PAC_ReglasPAC': 'PAC_DESTINO_SPREADSHEET_ID', + 'LOG_PAC_SISTEMA': 'PAC_DESTINO_SPREADSHEET_ID', + 'ALERTAS_ACTIVAS': 'PAC_DESTINO_SPREADSHEET_ID', + 'PAC_Vigente': 'PAC_DESTINO_SPREADSHEET_ID', + 'PAC_Borrador': 'PAC_DESTINO_SPREADSHEET_ID', + 'PAC_Historial': 'PAC_DESTINO_SPREADSHEET_ID', + 'PAC_Alertas': 'PAC_DESTINO_SPREADSHEET_ID', + 'PAC_Articuladores': 'PAC_DESTINO_SPREADSHEET_ID' +}; + +/** + * Lee una hoja y devuelve su resumen de estructura, sin exponer datos de usuarios. + * Solo lectura. Nunca escribe ni crea nada. + */ +function _diagResumenHoja(ss, nombreHoja) { + const hoja = ss.getSheetByName(nombreHoja); + if (!hoja) { + return { nombre: nombreHoja, existe: false }; + } + const lastRow = hoja.getLastRow(); + const lastCol = hoja.getLastColumn(); + const filasDatos = Math.max(0, lastRow - 1); // sin contar encabezado + let headers = []; + try { + if (lastRow >= 1 && lastCol >= 1) { + const anchoHeaders = Math.min(12, lastCol); + headers = hoja.getRange(1, 1, 1, anchoHeaders).getValues()[0].map(h => String(h || '').trim()); + } + } catch (e) { + headers = ['(error leyendo encabezados: ' + e.message + ')']; + } + + const resumen = { + nombre: nombreHoja, + existe: true, + filasDatos: filasDatos, + columnas: lastCol, + oculta: (function () { try { return hoja.isSheetHidden(); } catch (e) { return null; } })(), + headers: headers + }; + + if (nombreHoja === 'CONFIG_REGLAS') { + try { + const b1 = String(hoja.getRange('B1').getValue() || ''); + resumen.configReglasLongitudB1 = b1.length; + resumen.configReglasPreviewB1 = b1.substring(0, 200); + } catch (e) { + resumen.configReglasError = e.message; + } + } + + return resumen; +} + +/** + * ✅ [2026-10-06] Diagnóstico completo de solo lectura. NO crea, borra ni escribe nada en + * ningún spreadsheet. Lee Script Properties sin imprimir sus valores (solo si están + * configuradas o no), abre cada archivo configurado, resume estructura de hojas, y arma + * una tabla comparativa de filas entre el origen y los destinos migrados. + * + * @param {boolean} [escribirInforme=false] Si es true (y SOLO si es exactamente true), + * además escribe este mismo resultado en una hoja nueva "DIAGNOSTICO_MIGRACION" dentro + * de DATA_FILES.PRINCIPAL. Por defecto no escribe nada. + * @returns {Object} Resultado completo del diagnóstico. + */ +function diagnosticarEstructuraArchivos(escribirInforme) { + const debeEscribir = (escribirInforme === true); + console.log('=== DIAGNÓSTICO DE ESTRUCTURA — INICIO (solo lectura) ==='); + + // 1. Leer Script Properties sin imprimir valores + const CLAVES = [ + 'ORIGEN_STAGING_ID', + 'DATA_FILES_PRINCIPAL_ID', + 'DATA_FILES_LOGS_ID', + 'DATA_FILES_USUARIOS_ID', + 'MAESTRO_PERMISOS_ID', + 'PAC_DESTINO_SPREADSHEET_ID' + ]; + + const propiedades = {}; + const idsResueltos = {}; // vive solo en memoria de esta ejecución, nunca se imprime completo + CLAVES.forEach(function (clave) { + const valor = getConfigProperty(clave, ''); + propiedades[clave] = valor ? 'CONFIGURADA' : 'NO CONFIGURADA'; + idsResueltos[clave] = valor || null; + console.log(' ' + clave + ': ' + propiedades[clave]); + }); + + const resultado = { + scriptProperties: propiedades, + archivos: {}, + tablaComparativa: [], + hojasOcultasEnPrincipal: [], + hojasEnPrincipalQueNoEstanEnOrigen: [], + festivosCacheReporte: { + FESTIVOS: 'No la usa esta app. MotorEvaluadorReglas._cargarFestivosColombia() ' + + '(evaluador_alertas.js) consulta CalendarApp (calendario público de festivos de ' + + 'Colombia), no lee ninguna hoja. La hoja FESTIVOS solo la usa ' + + 'MatrizSeguimiento_script/Festivos.js, un proyecto de Apps Script distinto con su ' + + 'propio spreadsheet contenedor — no DATA_FILES.PRINCIPAL de este proyecto.', + CacheStore: 'cache_backend.js, vía getConfig(\'DATA_FILES.PRINCIPAL\'). Se autocrea ' + + 'con insertSheet() si falta — no tiene el mismo riesgo que las hojas migradas.', + CacheQueue: 'cache_backend.js, vía getConfig(\'DATA_FILES.PRINCIPAL\'). Misma ' + + 'autocreación que CacheStore.' + } + }; + + // 2. Abrir cada archivo configurado y resumir su estructura + const spreadsheetsAbiertos = {}; // clave -> objeto Spreadsheet, para reusar en el cruce + CLAVES.forEach(function (clave) { + const id = idsResueltos[clave]; + if (!id) { + resultado.archivos[clave] = { status: 'NO CONFIGURADA' }; + return; + } + try { + const ss = SpreadsheetApp.openById(id); + spreadsheetsAbiertos[clave] = ss; + const hojas = ss.getSheets().map(function (h) { + return _diagResumenHoja(ss, h.getSheetName()); + }); + resultado.archivos[clave] = { + status: 'OK', + nombreArchivo: ss.getName(), + totalHojas: hojas.length, + hojas: hojas + }; + console.log('✅ ' + clave + ' — "' + ss.getName() + '" — ' + hojas.length + ' hoja(s)'); + hojas.forEach(function (h) { + console.log(' · ' + h.nombre + ' — ' + h.filasDatos + ' fila(s) de datos, ' + + h.columnas + ' columna(s)' + (h.oculta ? ' [OCULTA]' : '')); + if (h.nombre === 'CONFIG_REGLAS' && h.configReglasPreviewB1 !== undefined) { + console.log(' B1 (' + h.configReglasLongitudB1 + ' caracteres): ' + h.configReglasPreviewB1); + } + }); + } catch (e) { + resultado.archivos[clave] = { status: 'ERROR', mensaje: e.message }; + console.error('❌ ' + clave + ': ' + e.message); + } + }); + + // 3. Tabla comparativa: todas las hojas del ORIGEN vs PRINCIPAL vs destino migrado + const origenInfo = resultado.archivos['ORIGEN_STAGING_ID']; + const principalInfo = resultado.archivos['DATA_FILES_PRINCIPAL_ID']; + + if (origenInfo && origenInfo.status === 'OK') { + origenInfo.hojas.forEach(function (hojaOrigen) { + const nombre = hojaOrigen.nombre; + const filasOrigen = hojaOrigen.filasDatos; + + let filasPrincipal = null; + if (principalInfo && principalInfo.status === 'OK') { + const enPrincipal = principalInfo.hojas.find(function (h) { return h.nombre === nombre; }); + filasPrincipal = enPrincipal ? enPrincipal.filasDatos : 'NO EXISTE'; + } else { + filasPrincipal = 'PRINCIPAL NO DISPONIBLE'; + } + + const claveDestino = _DIAG_MAPA_DESTINO_HOJA[nombre] || null; + let filasMigrado = 'N/A (no mapeada, se asume PRINCIPAL)'; + let archivoMigrado = 'DATA_FILES_PRINCIPAL_ID (por defecto)'; + if (claveDestino && claveDestino !== 'DATA_FILES_PRINCIPAL_ID') { + archivoMigrado = claveDestino; + const infoDestino = resultado.archivos[claveDestino]; + if (infoDestino && infoDestino.status === 'OK') { + const enDestino = infoDestino.hojas.find(function (h) { return h.nombre === nombre; }); + filasMigrado = enDestino ? enDestino.filasDatos : 'NO EXISTE EN EL DESTINO'; + } else if (infoDestino) { + filasMigrado = infoDestino.status; + } else { + filasMigrado = 'DESTINO NO CONFIGURADO'; + } + } else { + filasMigrado = filasPrincipal; // su destino migrado ES principal + } + + const diferencia = (typeof filasOrigen === 'number' && typeof filasPrincipal === 'number') + ? (filasOrigen - filasPrincipal) + : 'N/C'; + + resultado.tablaComparativa.push({ + hoja: nombre, + filasOrigen: filasOrigen, + filasPrincipal: filasPrincipal, + archivoMigradoCorrespondiente: archivoMigrado, + filasEnMigrado: filasMigrado, + diferenciaOrigenVsPrincipal: diferencia + }); + }); + + console.log('\n=== TABLA COMPARATIVA ==='); + resultado.tablaComparativa.forEach(function (fila) { + console.log(fila.hoja + ' | origen:' + fila.filasOrigen + ' | principal:' + fila.filasPrincipal + + ' | ' + fila.archivoMigradoCorrespondiente + ':' + fila.filasEnMigrado + + ' | diff:' + fila.diferenciaOrigenVsPrincipal); + }); + } else { + console.warn('⚠️ No se pudo leer ORIGEN_STAGING_ID — tabla comparativa omitida.'); + } + + // 4. En PRINCIPAL: hojas ocultas y hojas que NO existen en el origen + if (principalInfo && principalInfo.status === 'OK') { + resultado.hojasOcultasEnPrincipal = principalInfo.hojas + .filter(function (h) { return h.oculta; }) + .map(function (h) { return h.nombre; }); + + if (origenInfo && origenInfo.status === 'OK') { + const nombresOrigen = origenInfo.hojas.map(function (h) { return h.nombre; }); + resultado.hojasEnPrincipalQueNoEstanEnOrigen = principalInfo.hojas + .map(function (h) { return h.nombre; }) + .filter(function (nombre) { return nombresOrigen.indexOf(nombre) === -1; }); + } + + console.log('\nHojas ocultas en PRINCIPAL: ' + (resultado.hojasOcultasEnPrincipal.join(', ') || '(ninguna)')); + console.log('Hojas en PRINCIPAL que no están en el origen: ' + + (resultado.hojasEnPrincipalQueNoEstanEnOrigen.join(', ') || '(ninguna)')); + } + + // 5. FESTIVOS / CacheStore / CacheQueue — ya resuelto por análisis de código, ver arriba + console.log('\n=== FESTIVOS / CacheStore / CacheQueue (análisis de código, no de hojas) ==='); + console.log(JSON.stringify(resultado.festivosCacheReporte, null, 2)); + + // 6. Escribir informe en PRINCIPAL SOLO si se pidió explícitamente + if (debeEscribir) { + try { + if (!principalInfo || principalInfo.status !== 'OK') { + console.error('❌ No se pudo escribir el informe: DATA_FILES_PRINCIPAL_ID no está disponible.'); + } else { + const ssPrincipal = spreadsheetsAbiertos['DATA_FILES_PRINCIPAL_ID']; + let hojaInforme = ssPrincipal.getSheetByName('DIAGNOSTICO_MIGRACION'); + if (!hojaInforme) { + hojaInforme = ssPrincipal.insertSheet('DIAGNOSTICO_MIGRACION'); + } else { + hojaInforme.clear(); + } + const headers = ['HOJA', 'FILAS_ORIGEN', 'FILAS_PRINCIPAL', 'ARCHIVO_MIGRADO', 'FILAS_MIGRADO', 'DIFERENCIA']; + hojaInforme.getRange(1, 1, 1, headers.length).setValues([headers]) + .setFontWeight('bold').setBackground('#34495e').setFontColor('white'); + if (resultado.tablaComparativa.length > 0) { + const filas = resultado.tablaComparativa.map(function (f) { + return [f.hoja, f.filasOrigen, f.filasPrincipal, f.archivoMigradoCorrespondiente, + f.filasEnMigrado, f.diferenciaOrigenVsPrincipal]; + }); + hojaInforme.getRange(2, 1, filas.length, headers.length).setValues(filas); + } + console.log('✅ Informe escrito en la hoja DIAGNOSTICO_MIGRACION de PRINCIPAL.'); + } + } catch (e) { + console.error('❌ Error escribiendo el informe: ' + e.message); + } + } + + console.log('=== DIAGNÓSTICO DE ESTRUCTURA — FIN ==='); + return resultado; +} + + +/** + * ✅ [2026-10-06] Compara PAC_Vigente del origen (ORIGEN_STAGING_ID) contra PAC_Vigente + * del archivo PAC migrado (PAC_DESTINO_SPREADSHEET_ID). Solo lectura, no escribe nada. + * Clave de fila: columna 'RT' (la misma que usa pac_gestor.js:974 en + * _pac_compararYGenerarBorrador() para detectar nuevos/eliminados). No imprime datos + * personales, solo encabezados, conteos y la lista de RT (identificadores de predio, + * no PII) que sobran en el archivo PAC. + * @returns {Object} Resumen de la comparación. + */ +function diagnosticarPACVigente() { + console.log('=== DIAGNÓSTICO PAC_Vigente: origen vs archivo PAC — INICIO (solo lectura) ==='); + + const origenId = getConfigProperty('ORIGEN_STAGING_ID', ''); + const pacId = getConfigProperty('PAC_DESTINO_SPREADSHEET_ID', ''); + + if (!origenId || !pacId) { + const msg = 'Faltan Script Properties: ORIGEN_STAGING_ID y/o PAC_DESTINO_SPREADSHEET_ID.'; + console.error('❌ ' + msg); + return { error: msg }; + } + + function leerHoja(ssId, nombreHoja) { + const ss = SpreadsheetApp.openById(ssId); + const hoja = ss.getSheetByName(nombreHoja); + if (!hoja) return null; + const lastRow = hoja.getLastRow(); + const lastCol = hoja.getLastColumn(); + if (lastRow < 1 || lastCol < 1) return { headers: [], filas: [] }; + const datos = hoja.getRange(1, 1, lastRow, lastCol).getValues(); + const headers = datos[0].map(function (h) { return String(h || '').trim(); }); + return { headers: headers, filas: datos.slice(1) }; + } + + let origen, pac; + try { + origen = leerHoja(origenId, 'PAC_Vigente'); + pac = leerHoja(pacId, 'PAC_Vigente'); + } catch (e) { + console.error('❌ Error abriendo PAC_Vigente: ' + e.message); + return { error: e.message }; + } + + if (!origen || !pac) { + const msg = 'PAC_Vigente no existe en ' + (!origen ? 'el origen' : 'el archivo PAC') + '.'; + console.error('❌ ' + msg); + return { error: msg }; + } + + // 1. Diferencias de encabezados + const headersOrigen = origen.headers; + const headersPac = pac.headers; + const soloEnOrigen = headersOrigen.filter(function (h) { return headersPac.indexOf(h) === -1; }); + const soloEnPac = headersPac.filter(function (h) { return headersOrigen.indexOf(h) === -1; }); + + console.log('Encabezados — origen: ' + headersOrigen.length + ' columnas, PAC: ' + headersPac.length + ' columnas.'); + console.log('Columnas solo en origen (faltan en PAC): ' + (soloEnOrigen.join(', ') || '(ninguna)')); + console.log('Columnas solo en PAC (no están en origen): ' + (soloEnPac.join(', ') || '(ninguna)')); + + // 2. Clave RT: únicos, duplicados, y filas de PAC que no están en el origen + const iRtOrigen = headersOrigen.indexOf('RT'); + const iRtPac = headersPac.indexOf('RT'); + + function analizarClaves(filas, iRt) { + if (iRt < 0) return { error: 'No se encontró columna RT' }; + const conteo = {}; + filas.forEach(function (fila) { + const rt = String(fila[iRt] || '').trim(); + if (!rt) return; + conteo[rt] = (conteo[rt] || 0) + 1; + }); + const claves = Object.keys(conteo); + const duplicadas = claves.filter(function (rt) { return conteo[rt] > 1; }); + return { totalFilas: filas.length, clavesUnicas: claves.length, filasDuplicadas: duplicadas.length, mapa: conteo }; + } + + const analisisOrigen = analizarClaves(origen.filas, iRtOrigen); + const analisisPac = analizarClaves(pac.filas, iRtPac); + + console.log('Origen — ' + analisisOrigen.totalFilas + ' filas, ' + analisisOrigen.clavesUnicas + + ' RT únicos, ' + analisisOrigen.filasDuplicadas + ' RT duplicados.'); + console.log('PAC — ' + analisisPac.totalFilas + ' filas, ' + analisisPac.clavesUnicas + + ' RT únicos, ' + analisisPac.filasDuplicadas + ' RT duplicados.'); + + let rtSoloEnPac = []; + if (analisisOrigen.mapa && analisisPac.mapa) { + rtSoloEnPac = Object.keys(analisisPac.mapa).filter(function (rt) { return !(rt in analisisOrigen.mapa); }); + } + console.log('RT en PAC que no existen en el origen: ' + rtSoloEnPac.length); + if (rtSoloEnPac.length > 0 && rtSoloEnPac.length <= 50) { + console.log(' RT: ' + rtSoloEnPac.join(', ')); + } else if (rtSoloEnPac.length > 50) { + console.log(' (más de 50, se omite la lista completa; primeros 50): ' + rtSoloEnPac.slice(0, 50).join(', ')); + } + + console.log('=== DIAGNÓSTICO PAC_Vigente — FIN ==='); + + return { + headers: { origen: headersOrigen.length, pac: headersPac.length, soloEnOrigen: soloEnOrigen, soloEnPac: soloEnPac }, + origen: { totalFilas: analisisOrigen.totalFilas, clavesUnicas: analisisOrigen.clavesUnicas, filasDuplicadas: analisisOrigen.filasDuplicadas }, + pac: { totalFilas: analisisPac.totalFilas, clavesUnicas: analisisPac.clavesUnicas, filasDuplicadas: analisisPac.filasDuplicadas }, + rtEnPacQueNoEstanEnOrigen: rtSoloEnPac + }; +} + + +/** + * ✅ [2026-10-06, reescrita] Restaura hojas específicas en DATA_FILES.PRINCIPAL desde el + * origen monolítico (ORIGEN_STAGING_ID), con reglas distintas por hoja (ya no genéricas). + * + * REGLA DE SEGURIDAD: con opciones.ejecutar !== true (incluye omitirlo, undefined, 0, + * cualquier valor que no sea literalmente el booleano true), esta función NO escribe NADA. + * Solo imprime qué haría. + * + * Reglas por hoja (en este orden): + * 1. CONFIG_REGLAS — copia el TEXTO de B1 del origen al B1 del destino (NO usa copyTo). + * Solo si B1 del destino es "{}" o vacío; si no, CONFLICTO. Valida JSON.parse del + * origen antes de escribir; si no es válido, aborta esa hoja. Tras escribir, relee y + * compara longitud. Mantiene la hoja oculta. Reporta si el origen tiene contenido en + * A1:A2 (no lo copia, solo informa). + * 2. ASIGNACIONES_EQUIPOS — si el destino ya tiene filas de datos, CONFLICTO, no se toca. + * Si no, compara encabezados (sin distinguir mayúsculas/espacios); si coinciden, + * escribe las filas del ORIGEN en bloques de 1000 con setValues(), y verifica + * getLastRow() al final. + * 3. ReportesGuardados, LOGS_AUDITORIA, ALERTAS_ACTIVAS — si no existen en el destino, se + * copian completas desde el ORIGEN (nunca desde el archivo PAC) con copyTo() y se + * renombran al nombre exacto. Si ya existen con datos, no se tocan. Si existen pero + * vacías, se reporta como caso no cubierto por la regla (no se asume qué hacer). + * 4. Todo lo demás (Permisos, Logs, Datos, Datos2, Seguimiento, Compromisos, + * CASOS ESPECIALES, FiltroMatriz, FESTIVOS, CacheStore, CacheQueue, USUARIOS, + * Asignacion_RT, PAC_*) — NO se toca, ni se abre siquiera. 'Configuracion' de + * Principal se reporta solo informativamente (quién la usa), sin acción. + * + * @param {{ejecutar?: boolean}} [opciones] Por defecto { ejecutar: false }. + * @returns {{resumen: Array, conflictos: Array}} Resumen de acciones (reales o simuladas). + */ +function restaurarHojasDesdeOrigen(opciones) { + const opts = opciones || {}; + const ejecutar = (opts.ejecutar === true); + + console.log('=== RESTAURACIÓN DE HOJAS — modo: ' + (ejecutar ? 'EJECUCIÓN REAL' : 'SIMULACIÓN (nada se escribe)') + ' ==='); + + const origenId = getConfigProperty('ORIGEN_STAGING_ID', ''); + const principalId = getConfigProperty('DATA_FILES_PRINCIPAL_ID', ''); + // Se leen por completitud / consistencia con diagnosticarEstructuraArchivos(), aunque + // esta función no escribe en Usuarios ni en Logs (ver regla 4 arriba). + getConfigProperty('DATA_FILES_USUARIOS_ID', ''); + getConfigProperty('DATA_FILES_LOGS_ID', ''); + getConfigProperty('PAC_DESTINO_SPREADSHEET_ID', ''); + + if (!origenId || !principalId) { + const msg = 'Faltan Script Properties: ORIGEN_STAGING_ID y/o DATA_FILES_PRINCIPAL_ID. Abortando sin tocar nada.'; + console.error('❌ ' + msg); + return { resumen: [], conflictos: [], error: msg }; + } + + let ssOrigen, ssPrincipal; + try { + ssOrigen = SpreadsheetApp.openById(origenId); + ssPrincipal = SpreadsheetApp.openById(principalId); + } catch (e) { + console.error('❌ No se pudo abrir origen o destino: ' + e.message); + return { resumen: [], conflictos: [], error: e.message }; + } + + const resumen = []; + const conflictos = []; + + function agregar(hoja, accion, filasAntes, filasDespues) { + resumen.push({ hoja: hoja, accion: accion, filasAntes: filasAntes, filasDespues: filasDespues }); + console.log(hoja + ' | ' + accion + ' | antes:' + filasAntes + ' | después:' + filasDespues); + } + + // ── 1. CONFIG_REGLAS ────────────────────────────────────────────────────────────── + (function restaurarConfigReglas() { + const nombreHoja = 'CONFIG_REGLAS'; + const hojaOrigen = ssOrigen.getSheetByName(nombreHoja); + const hojaDestino = ssPrincipal.getSheetByName(nombreHoja); + + if (!hojaOrigen) { + agregar(nombreHoja, 'OMITIDA — no existe en el origen', '-', '-'); + return; + } + if (!hojaDestino) { + agregar(nombreHoja, 'CONFLICTO — no existe en Principal, requiere crear la hoja primero (fuera de esta regla)', '-', '-'); + conflictos.push({ hoja: nombreHoja, tipo: 'NO_EXISTE_EN_DESTINO', mensaje: 'CONFIG_REGLAS no existe en Principal.' }); + return; + } + + const b1Destino = String(hojaDestino.getRange('B1').getValue() || '').trim(); + const destinoVacio = (b1Destino === '{}' || b1Destino === ''); + if (!destinoVacio) { + conflictos.push({ hoja: nombreHoja, tipo: 'B1_DESTINO_NO_VACIO', mensaje: 'B1 de Principal no es "{}" ni está vacío — no se toca.' }); + agregar(nombreHoja, 'CONFLICTO — B1 de Principal ya tiene contenido, no se toca', 1, 1); + return; + } + + const b1Origen = String(hojaOrigen.getRange('B1').getValue() || ''); + try { + JSON.parse(b1Origen); + } catch (e) { + conflictos.push({ hoja: nombreHoja, tipo: 'JSON_INVALIDO_EN_ORIGEN', mensaje: 'B1 del origen no es JSON válido: ' + e.message }); + agregar(nombreHoja, 'ABORTADA — JSON inválido en el origen, no se toca nada', 1, 1); + return; + } + + const a1Origen = hojaOrigen.getRange('A1').getValue(); + const a2Origen = hojaOrigen.getRange('A2').getValue(); + const notaA1A2 = (a1Origen || a2Origen) + ? ('El origen tiene contenido en A1/A2 que esta regla NO copia — revisar a mano si hace falta: A1="' + a1Origen + '", A2="' + a2Origen + '".') + : 'A1:A2 del origen están vacías, no hay nada que copiar ahí.'; + console.log('CONFIG_REGLAS — ' + notaA1A2); + + if (ejecutar) { + try { + const estabaOculta = (function () { try { return hojaDestino.isSheetHidden(); } catch (e) { return false; } })(); + hojaDestino.getRange('B1').setValue(b1Origen); + if (estabaOculta) { try { hojaDestino.hideSheet(); } catch (e) { /* no crítico */ } } + + const b1Releido = String(hojaDestino.getRange('B1').getValue() || ''); + const longitudCoincide = (b1Releido.length === b1Origen.length); + if (!longitudCoincide) { + console.error('❌ CONFIG_REGLAS: longitud tras escribir (' + b1Releido.length + ') no coincide con el origen (' + b1Origen.length + ').'); + } + _diagRegistrarAccion(nombreHoja, 'B1_COPIADO_DESDE_ORIGEN', 1); + agregar(nombreHoja, 'B1 copiado desde el origen' + (longitudCoincide ? ' (longitud verificada OK)' : ' (ADVERTENCIA: longitud no coincide)') + '. ' + notaA1A2, 0, 1); + } catch (e) { + agregar(nombreHoja, 'ERROR al escribir B1: ' + e.message, '-', '-'); + } + } else { + agregar(nombreHoja, 'SE COPIARÍA el texto de B1 desde el origen (' + b1Origen.length + ' caracteres, JSON válido). ' + notaA1A2, 0, '-'); + } + })(); + + // ── 2. ASIGNACIONES_EQUIPOS ─────────────────────────────────────────────────────── + (function restaurarAsignacionesEquipos() { + const nombreHoja = 'ASIGNACIONES_EQUIPOS'; + const hojaOrigen = ssOrigen.getSheetByName(nombreHoja); + const hojaDestino = ssPrincipal.getSheetByName(nombreHoja); + + if (!hojaOrigen) { + agregar(nombreHoja, 'OMITIDA — no existe en el origen', '-', '-'); + return; + } + if (!hojaDestino) { + agregar(nombreHoja, 'CONFLICTO — no existe en Principal, requiere crear la hoja primero (fuera de esta regla)', '-', '-'); + conflictos.push({ hoja: nombreHoja, tipo: 'NO_EXISTE_EN_DESTINO', mensaje: 'ASIGNACIONES_EQUIPOS no existe en Principal.' }); + return; + } + + const filasDestinoAntes = Math.max(0, hojaDestino.getLastRow() - 1); + if (filasDestinoAntes > 0) { + conflictos.push({ hoja: nombreHoja, tipo: 'DESTINO_YA_TIENE_DATOS', mensaje: 'El destino ya tiene ' + filasDestinoAntes + ' fila(s) de datos — no se toca.' }); + agregar(nombreHoja, 'CONFLICTO — destino ya tiene ' + filasDestinoAntes + ' fila(s), no se toca', filasDestinoAntes, filasDestinoAntes); + return; + } + + const lastColOrigen = hojaOrigen.getLastColumn(); + const lastColDestino = hojaDestino.getLastColumn(); + const headersOrigen = hojaOrigen.getRange(1, 1, 1, lastColOrigen).getValues()[0] + .map(function (h) { return String(h || '').trim().toUpperCase(); }); + const headersDestino = hojaDestino.getRange(1, 1, 1, lastColDestino).getValues()[0] + .map(function (h) { return String(h || '').trim().toUpperCase(); }); + + const headersCoinciden = (headersOrigen.length === headersDestino.length) && + headersOrigen.every(function (h, i) { return h === headersDestino[i]; }); + + if (!headersCoinciden) { + conflictos.push({ + hoja: nombreHoja, tipo: 'ENCABEZADOS_NO_COINCIDEN', + mensaje: 'Encabezados de origen (' + headersOrigen.join(',') + ') no coinciden con destino (' + headersDestino.join(',') + ').' + }); + agregar(nombreHoja, 'CONFLICTO — encabezados no coinciden, no se toca', 0, 0); + return; + } + + const filasOrigen = Math.max(0, hojaOrigen.getLastRow() - 1); + if (filasOrigen === 0) { + agregar(nombreHoja, 'OMITIDA — el origen no tiene filas de datos', 0, 0); + return; + } + + if (ejecutar) { + try { + const BLOQUE = 1000; + const datos = hojaOrigen.getRange(2, 1, filasOrigen, lastColOrigen).getValues(); + for (let i = 0; i < datos.length; i += BLOQUE) { + const bloque = datos.slice(i, i + BLOQUE); + hojaDestino.getRange(2 + i, 1, bloque.length, lastColOrigen).setValues(bloque); + } + const filasDespues = Math.max(0, hojaDestino.getLastRow() - 1); + const coincide = (filasDespues === filasOrigen); + if (!coincide) { + console.error('❌ ASIGNACIONES_EQUIPOS: getLastRow tras escribir (' + filasDespues + ') no coincide con el origen (' + filasOrigen + ').'); + } + _diagRegistrarAccion(nombreHoja, 'FILAS_COPIADAS_DESDE_ORIGEN', filasOrigen); + agregar(nombreHoja, 'Copiadas ' + filasOrigen + ' fila(s) desde el origen en bloques de 1000' + (coincide ? ' (verificado OK)' : ' (ADVERTENCIA: no coincide)'), 0, filasDespues); + } catch (e) { + agregar(nombreHoja, 'ERROR al copiar filas: ' + e.message, 0, '-'); + } + } else { + agregar(nombreHoja, 'SE COPIARÍAN ' + filasOrigen + ' fila(s) desde el origen en bloques de 1000 (encabezados coinciden)', 0, '-'); + } + })(); + + // ── 3. ReportesGuardados, LOGS_AUDITORIA, ALERTAS_ACTIVAS — copia completa si falta ── + function restaurarHojaCompletaSiFalta(nombreHoja) { + const hojaOrigen = ssOrigen.getSheetByName(nombreHoja); + if (!hojaOrigen) { + agregar(nombreHoja, 'OMITIDA — no existe en el origen', '-', '-'); + return; + } + const filasOrigen = Math.max(0, hojaOrigen.getLastRow() - 1); + const hojaDestino = ssPrincipal.getSheetByName(nombreHoja); + + if (hojaDestino) { + const filasDestino = Math.max(0, hojaDestino.getLastRow() - 1); + if (filasDestino > 0) { + conflictos.push({ hoja: nombreHoja, tipo: 'YA_EXISTE_CON_DATOS', mensaje: 'Ya existe en Principal con ' + filasDestino + ' fila(s) — no se toca.' }); + agregar(nombreHoja, 'NO TOCADA — ya existe en Principal con ' + filasDestino + ' fila(s)', filasDestino, filasDestino); + return; + } + conflictos.push({ hoja: nombreHoja, tipo: 'EXISTE_VACIA_SIN_REGLA', mensaje: 'Existe en Principal pero vacía. La regla pedida solo cubre "no existe"; este caso no está definido, se omite por seguridad.' }); + agregar(nombreHoja, 'OMITIDA — existe vacía en Principal, caso no cubierto por la regla, requiere decisión', 0, 0); + return; + } + + if (ejecutar) { + try { + const copia = hojaOrigen.copyTo(ssPrincipal); // crea "Copia de " + copia.setName(nombreHoja); + _diagRegistrarAccion(nombreHoja, 'COPIADA_COMPLETA_DESDE_ORIGEN', filasOrigen); + agregar(nombreHoja, 'Copiada completa desde el origen (no existía en Principal)', 0, filasOrigen); + } catch (e) { + agregar(nombreHoja, 'ERROR al copiar: ' + e.message, 0, '-'); + } + } else { + agregar(nombreHoja, 'SE COPIARÍA completa desde el origen (no existe en Principal), ' + filasOrigen + ' fila(s)', 0, '-'); + } + } + + restaurarHojaCompletaSiFalta('ReportesGuardados'); + restaurarHojaCompletaSiFalta('LOGS_AUDITORIA'); + restaurarHojaCompletaSiFalta('ALERTAS_ACTIVAS'); // ⚠️ siempre desde ssOrigen, nunca desde el archivo PAC + + // ── 4. Todo lo demás: NO se toca, ni se abre. Solo una nota informativa. ─────────── + const EXCLUIDAS_A_PROPOSITO = [ + 'Permisos', 'Logs', 'Datos', 'Datos2', 'Seguimiento', 'Compromisos', 'CASOS ESPECIALES', + 'FiltroMatriz', 'FESTIVOS', 'CacheStore', 'CacheQueue', 'USUARIOS', 'Asignacion_RT', + 'PAC_Vigente', 'PAC_Borrador', 'PAC_Historial', 'PAC_Alertas', 'PAC_Articuladores', + 'PAC_ReglasPAC', 'LOG_PAC_SISTEMA' + ]; + console.log('\nExcluidas a propósito, no evaluadas ni abiertas en esta ejecución: ' + EXCLUIDAS_A_PROPOSITO.join(', ')); + console.log('"Configuracion" (Principal): solo la crea y la lee migracion_automatica_v2.js:100-106 ' + + '(VERSION_SISTEMA, MODO_MANTENIMIENTO) — ningún otro código del proyecto la usa hoy. ' + + 'El MODO_MANTENIMIENTO real se controla por Script Property, no por esta hoja.'); + + console.log('\n=== RESUMEN FINAL ==='); + resumen.forEach(function (r) { + console.log(r.hoja + ' | ' + r.accion + ' | antes:' + r.filasAntes + ' | después:' + r.filasDespues); + }); + if (conflictos.length > 0) { + console.log('\n=== CONFLICTOS QUE REQUIEREN DECISIÓN ==='); + conflictos.forEach(function (c) { + console.log(c.hoja + ' (' + c.tipo + '): ' + c.mensaje); + }); + } + console.log('=== RESTAURACIÓN DE HOJAS — FIN ==='); + + return { resumen: resumen, conflictos: conflictos }; +} + +/** + * Compara EMAIL/ROL entre la hoja Permisos del origen y la del destino. Solo lectura. + * No expone nada más que email y rol (ya son columnas administrativas conocidas, no PII + * sensible como contraseñas o tokens). + */ +function _diagCompararPermisos(hojaOrigen, hojaDestino) { + function leerEmailRol(hoja) { + const datos = hoja.getDataRange().getValues(); + if (datos.length < 2) return {}; + const headers = datos[0].map(function (h) { return String(h).trim().toUpperCase(); }); + const idxEmail = headers.indexOf('EMAIL'); + const idxRol = headers.indexOf('ROL'); + const mapa = {}; + for (let i = 1; i < datos.length; i++) { + const email = String(datos[i][idxEmail] || '').trim().toLowerCase(); + if (!email) continue; + mapa[email] = String(datos[i][idxRol] || '').trim(); + } + return mapa; + } + + const origenMap = leerEmailRol(hojaOrigen); + const destinoMap = leerEmailRol(hojaDestino); + const soloEnOrigen = Object.keys(origenMap).filter(function (e) { return !(e in destinoMap); }); + const soloEnDestino = Object.keys(destinoMap).filter(function (e) { return !(e in origenMap); }); + const rolDistinto = Object.keys(origenMap) + .filter(function (e) { return (e in destinoMap) && destinoMap[e] !== origenMap[e]; }) + .map(function (e) { return { email: e, rolOrigen: origenMap[e], rolDestino: destinoMap[e] }; }); + + return { soloEnOrigen: soloEnOrigen, soloEnDestino: soloEnDestino, rolDistinto: rolDistinto }; +} + +/** + * Registra una acción de restauración, sin IDs ni datos personales, por console y en el + * log de acciones del sistema si está disponible. + * ⚠️ Nota de interpretación: se usa GestorAuditoria.registrarAccion() (escribe en la hoja + * 'Logs' de DATA_FILES.LOGS), NO registrarCambio() (que escribe en la hoja LOGS_AUDITORIA + * dentro de PRINCIPAL, pensada para diffs de campo RT/CAMPO/VALOR_ANTERIOR/VALOR_NUEVO, no + * para eventos de sistema como este). Si se prefiere que quede específicamente en + * LOGS_AUDITORIA, avisar para ajustarlo — es un cambio de una línea. + */ +function _diagRegistrarAccion(hoja, accion, filas) { + const mensaje = 'restaurarHojasDesdeOrigen: ' + hoja + ' — ' + accion + ' — ' + filas + ' fila(s)'; + console.log(mensaje); + try { + if (typeof GestorAuditoria === 'function') { + const auditoria = new GestorAuditoria(); + auditoria.registrarAccion('SISTEMA_RESTAURACION', 'RESTAURAR_HOJA_MIGRACION', mensaje); + } + } catch (e) { + console.warn('No se pudo registrar en el log de acciones (no crítico): ' + e.message); + } +} + +/** + * ✅ [2026-10-06] Envoltorio SIN PARÁMETROS para ejecutar la restauración REAL. + * El selector de funciones del editor de Apps Script no permite pasar argumentos al + * botón "Ejecutar" — llamar directamente a restaurarHojasDesdeOrigen() desde ahí siempre + * corre en modo simulación (ejecutar:false por defecto). Esta función existe solo para + * poder seleccionarla en el menú y ejecutar con ejecutar:true de verdad. + * + * ⚠️ ESTA FUNCIÓN ESCRIBE DATOS REALES. Ejecutar solo después de revisar la simulación + * de restaurarHojasDesdeOrigen() (sin argumentos) y confirmar que los respaldos + * RESPALDO_2026-10-06 ya existen. + */ +function ejecutarRestauracionReal_SOLO_DESPUES_DE_SIMULAR() { + return restaurarHojasDesdeOrigen({ ejecutar: true }); +} diff --git a/evaluador_alertas.js b/evaluador_alertas.js index 82106e8..29961c6 100644 --- a/evaluador_alertas.js +++ b/evaluador_alertas.js @@ -1,89 +1,20 @@ /** - * ═══════════════════════════════════════════════════════════ - * MOTOR DE REGLAS - ALMACENAMIENTO Y LECTURA - * ═══════════════════════════════════════════════════════════ + * Debe coincidir con el switch de _cumpleCondiciones. */ - -// Función para obtener el JSON actual de las reglas -function obtenerReglasJSON() { - try { - const fileId = getConfig('DATA_FILES.PRINCIPAL'); - const ss = SpreadsheetApp.openById(fileId); - let hojaReglas = ss.getSheetByName('CONFIG_REGLAS'); - - // Si la hoja no existe, la crea (Instalación automática) - if (!hojaReglas) { - // ✅ SEC-P1.5: LockService para evitar creación duplicada de la hoja bajo concurrencia - const lock = LockService.getScriptLock(); - try { - lock.waitLock(20000); - // Re-chequear tras adquirir el lock: otro proceso pudo haberla creado mientras esperábamos - hojaReglas = ss.getSheetByName('CONFIG_REGLAS'); - if (!hojaReglas) { - hojaReglas = ss.insertSheet('CONFIG_REGLAS'); - hojaReglas.hideSheet(); // La oculta para que los usuarios no la dañen - hojaReglas.getRange("A1").setValue("MOTOR_DE_REGLAS_JSON"); - hojaReglas.getRange("A1").setFontWeight("bold").setBackground("#34495e").setFontColor("white"); - hojaReglas.getRange("B1").setValue("{}"); // JSON vacío por defecto - } - } catch (lockError) { - // ✅ CONC-P2.1: diferenciar timeout de lock de otros errores (antes se tragaba en silencio - // y caía al TypeError de hojaReglas null, con mensaje técnico confuso) - console.error("Error adquiriendo lock para crear CONFIG_REGLAS:", lockError); - return { success: false, message: 'El sistema se encuentra ocupado por otro administrador. Por favor intente de nuevo en unos segundos.' }; - } finally { - try { lock.releaseLock(); } catch (er) {} - } - } - - const jsonString = hojaReglas.getRange("B1").getValue(); - return { success: true, data: jsonString }; - - } catch (error) { - console.error("Error leyendo reglas:", error); - return { success: false, error: error.message }; - } -} - -// Función para que el Administrador guarde cambios en el JSON desde la UI -function guardarReglasJSON(jsonString, usuario) { - // ✅ CONC-P2.1: validar el JSON ANTES de tocar el lock, para que un JSON invalido - // nunca se reporte como timeout ni un timeout se reporte como JSON invalido - try { - JSON.parse(jsonString); - } catch (parseError) { - return { success: false, error: "El formato JSON es inválido. Revisa la sintaxis." }; - } - - const lock = LockService.getScriptLock(); - try { - lock.waitLock(20000); - } catch (lockError) { - return { success: false, message: 'El sistema se encuentra ocupado por otro administrador. Por favor intente de nuevo en unos segundos.' }; - } - - try { - const fileId = getConfig('DATA_FILES.PRINCIPAL'); - const ss = SpreadsheetApp.openById(fileId); - let hojaReglas = ss.getSheetByName('CONFIG_REGLAS'); - - // 2. Guardar en la celda B1 - hojaReglas.getRange("B1").setValue(jsonString); - - // 3. Registrar en auditoría (usando tu Gestor de Auditoría actual) - try { - const auditoria = new GestorAuditoria(fileId); - auditoria.registrarAccion(usuario, 'ACTUALIZAR_MOTOR_REGLAS', 'Se actualizó el JSON maestro de reglas de negocio'); - } catch(e) { console.warn("No se pudo auditar:", e); } - - return { success: true, message: "Reglas actualizadas correctamente en la base de datos." }; - - } catch (error) { - return { success: false, error: 'Error al guardar las reglas: ' + error.message }; - } finally { - try { lock.releaseLock(); } catch (er) {} - } -} +const OPERADORES_CONDICION_REGLAS = { + EQUALS: { etiqueta: 'Es igual a', parametros: ['valor'] }, + NOT_EQUAL: { etiqueta: 'Es distinto de', parametros: ['valor'] }, + IN: { etiqueta: 'Está en la lista', parametros: ['valores'] }, + NOT_IN: { etiqueta: 'No está en la lista', parametros: ['valores'] }, + IS_NULL: { etiqueta: 'Está vacío (vacío, N/A, -)', parametros: [] }, + IS_NOT_NULL: { etiqueta: 'Tiene dato (no vacío, 0 ni N/A)', parametros: [] }, + LESS_THAN: { etiqueta: 'Número menor que', parametros: ['valor'] }, + DATE_BEFORE_COLUMN: { etiqueta: 'Fecha anterior a otra columna', parametros: ['columna_destino'] }, + PERCENTAGE_BETWEEN_COLUMNS: { etiqueta: '(Columna ÷ otra columna) × 100 entre min y max', parametros: ['columna_destino', 'min_pct', 'max_pct'] }, + DAYS_PASSED_LESS_THAN: { etiqueta: 'Días transcurridos hasta hoy ≤', parametros: ['valor'] } +}; +const NIVELES_ALERTA_REGLAS = ['ROJO', 'NARANJA', 'AMARILLO']; +const TIPOS_DIAS_REGLAS = ['HABILES', 'CALENDARIO']; /** * ═══════════════════════════════════════════════════════════ @@ -98,6 +29,7 @@ class MotorEvaluadorReglas { this.fileId = getConfig('DATA_FILES.PRINCIPAL'); this.ss = SpreadsheetApp.openById(this.fileId); this.hoy = new Date(); + this.festivosCargados = false; this.festivosColombia = this._cargarFestivosColombia(this.hoy.getFullYear()); this.alertasGeneradas = []; } @@ -107,16 +39,28 @@ class MotorEvaluadorReglas { */ ejecutarMotor() { console.log("🚀 Iniciando Motor Evaluador de Reglas..."); - - // 1. Obtener el JSON de Reglas - const jsonString = this.ss.getSheetByName('CONFIG_REGLAS').getRange("B1").getValue(); - if (!jsonString || jsonString === "{}") { - console.warn("⚠️ No hay reglas JSON configuradas."); - return { success: false, error: "No hay reglas configuradas" }; + + const lectura = obtenerReglasJSON(); + if (!lectura.success) return { success: false, error: lectura.error || lectura.message }; + let configReglas; + try { + configReglas = JSON.parse(lectura.data || '{}'); + } catch (e) { + return { success: false, error: 'El JSON de reglas no se pudo leer.' }; } - - const configReglas = JSON.parse(jsonString); + if (!configReglas || !Array.isArray(configReglas.ReglasDeNegocio)) { + return { success: false, error: 'El JSON de reglas no tiene ReglasDeNegocio[]' }; + } + if (!configReglas.ReglasDeNegocio.length || lectura.data === '{}' || lectura.data === '') { + this.alertasGeneradas = []; + const vacio = this._guardarAlertasEnHoja(); + if (!vacio) return { success: false, error: 'No se pudo limpiar ALERTAS_ACTIVAS.' }; + return { success: false, error: 'No hay reglas configuradas', totalAlertas: 0, reglasNoEvaluables: 0, reglasConCruceIgnorado: 0 }; + } + const reglasActivas = configReglas.ReglasDeNegocio.filter(r => r.activa); + const reglasNoEvaluables = reglasActivas.filter(r => r.evaluacion_cruzada && r.alerta_bloqueo && !r.regla_tiempo).length; + const reglasConCruceIgnorado = reglasActivas.filter(r => r.evaluacion_cruzada && r.regla_tiempo).length; console.log(`📋 Se encontraron ${reglasActivas.length} reglas activas en el JSON.`); // 2. Leer la Matriz de Datos (Modo Solo Lectura) @@ -154,6 +98,7 @@ class MotorEvaluadorReglas { if (!rt) return; // Saltar filas vacías reglasActivas.forEach(regla => { + try { // A. Verificar si cumple las CONDICIONES previas if (this._cumpleCondiciones(filaMatriz, regla.condiciones)) { @@ -181,14 +126,27 @@ class MotorEvaluadorReglas { } } } + } catch (errorRegla) { + console.error('Regla omitida por error:', regla && regla.nombre, errorRegla); + } }); }); - // 4. Guardar resultados en hoja separada (Aplica Deduplicación antes de guardar) - this._guardarAlertasEnHoja(); + const guardado = this._guardarAlertasEnHoja(); + if (!guardado) return { success: false, error: 'No se pudo escribir ALERTAS_ACTIVAS.' }; + if (typeof invalidateDataCache === 'function') invalidateDataCache(); + try { + PropertiesService.getScriptProperties().setProperty('MOTOR_REGLAS_ULTIMA_EVALUACION_ISO', new Date().toISOString()); + } catch (e) {} console.log(`✅ Motor finalizado. ${this.alertasGeneradas.length} alertas generadas únicas.`); - return { success: true, totalAlertas: this.alertasGeneradas.length }; + return { + success: true, + totalAlertas: this.alertasGeneradas.length, + reglasNoEvaluables: reglasNoEvaluables, + reglasConCruceIgnorado: reglasConCruceIgnorado, + festivosCargados: this.festivosCargados + }; } /** @@ -339,14 +297,14 @@ class MotorEvaluadorReglas { mensajeDinamico = mensajeDinamico.replace("{DIAS}", Math.abs(diasRestantes)); } - this._registrarAlerta(rt, filaMatriz, regla, umbralAplicable.nivel, mensajeDinamico, diasRestantes); + this._registrarAlerta(rt, filaMatriz, regla, umbralAplicable.nivel, mensajeDinamico, diasRestantes, this._fechaVencimiento_(fechaInicio, conf)); } } /** * Acumula las alertas en la memoria antes de escribirlas */ - _registrarAlerta(rt, filaMatriz, regla, nivel, mensaje, diasRestantes) { + _registrarAlerta(rt, filaMatriz, regla, nivel, mensaje, diasRestantes, fechaVencimiento) { // Lógica de prioridad para el articulador let articuladorFinal = "SIN ASIGNAR"; @@ -372,6 +330,7 @@ class MotorEvaluadorReglas { NIVEL: nivel, MENSAJE: mensaje, DIAS_RESTANTES: diasRestantes !== null ? Math.round(diasRestantes) : "N/A", + FECHA_VENCIMIENTO: fechaVencimiento || "", ESTADO_PREDIAL: filaMatriz["ESTADO PREDIAL AJUSTADO"] || "" }); } @@ -409,13 +368,19 @@ class MotorEvaluadorReglas { hojaAlertas.clear(); } - const headers = ["TIMESTAMP", "NIVEL", "RT", "PROYECTO", "ARTICULADOR", "ARTICULADOR_EMAIL", "GESTOR_EMAIL", "FASE", "REGLA", "DIAS RESTANTES", "MENSAJE", "ESTADO PREDIAL ACTUAL"]; + const headers = ["TIMESTAMP", "NIVEL", "RT", "PROYECTO", "ARTICULADOR", "ARTICULADOR_EMAIL", "GESTOR_EMAIL", "FASE", "REGLA", "DIAS RESTANTES", "FECHA_VENCIMIENTO", "MENSAJE", "ESTADO PREDIAL ACTUAL"]; hojaAlertas.getRange(1, 1, 1, headers.length).setValues([headers]); hojaAlertas.getRange(1, 1, 1, headers.length).setBackground("#c0392b").setFontColor("white").setFontWeight("bold"); if (this.alertasGeneradas.length === 0) { - hojaAlertas.getRange(2, 1).setValue("No hay alertas activas en el sistema."); - return; + const tsVacio = this.hoy.toLocaleString('es-CO'); + const vacio = headers.map(function(h) { + if (h === "TIMESTAMP") return tsVacio; + if (h === "MENSAJE") return "No hay alertas activas en el sistema."; + return ""; + }); + hojaAlertas.getRange(2, 1, 1, headers.length).setValues([vacio]); + return true; } // Ordenar alertas (ROJO primero, luego NARANJA, luego AMARILLO) @@ -434,6 +399,7 @@ class MotorEvaluadorReglas { a.FASE, a.REGLA, a.DIAS_RESTANTES, + a.FECHA_VENCIMIENTO || "", a.MENSAJE, a.ESTADO_PREDIAL ]); @@ -448,8 +414,10 @@ class MotorEvaluadorReglas { hojaAlertas.setConditionalFormatRules([ruleRojo, ruleNaranja, ruleAmarillo]); hojaAlertas.autoResizeColumns(1, headers.length); + return true; } catch (error) { console.error("Error guardando ALERTAS_ACTIVAS:", error); + return false; } finally { try { lock.releaseLock(); } catch (er) {} } @@ -469,8 +437,10 @@ class MotorEvaluadorReglas { const endDate = new Date(year + 1, 11, 31); const events = calendar.getEvents(startDate, endDate); festivosStr = events.map(e => e.getStartTime().toISOString().split('T')[0]); + this.festivosCargados = festivosStr.length > 0; } } catch (e) { + this.festivosCargados = false; console.warn("⚠️ No se pudo cargar calendario de festivos. Se usarán solo fines de semana.", e); } return festivosStr; @@ -496,6 +466,36 @@ class MotorEvaluadorReglas { } return cuenta; } + + _sumarDiasHabiles(fechaInicio, cantidad) { + var n = Math.round(Number(cantidad) || 0); + var cur = new Date(fechaInicio); + cur.setHours(12, 0, 0, 0); + var cuenta = 0; + var guardia = 0; + while (cuenta < n && guardia < 10000) { + guardia++; + cur.setDate(cur.getDate() + 1); + var dia = cur.getDay(); + var iso = cur.toISOString().split('T')[0]; + if (dia !== 0 && dia !== 6 && this.festivosColombia.indexOf(iso) < 0) cuenta++; + } + return cur; + } + + _fechaVencimiento_(fechaInicio, conf) { + var n = Number(conf && conf.dias_vida_util); + if (!(n > 0) || !fechaInicio) return ""; + var fecha; + if (conf.tipo_dias === "HABILES") { + fecha = this._sumarDiasHabiles(fechaInicio, n); + } else { + fecha = new Date(fechaInicio.getTime()); + fecha.setHours(12, 0, 0, 0); + fecha.setDate(fecha.getDate() + Math.round(n)); + } + return Utilities.formatDate(fecha, Session.getScriptTimeZone() || "America/Bogota", "yyyy-MM-dd"); + } } const MAX_ALERTAS_PAYLOAD = 100; @@ -511,127 +511,155 @@ function _resolverEmailDesdeCeldaAlertas(valor) { return REGEX_EMAIL_SIMPLE_ALERTAS.test(texto) ? texto.toLowerCase() : ''; } -function _normalizarSeveridadAlertas(valor) { - const nivel = String(valor || '').toUpperCase(); - if (['CRITICA', 'ALERTA', 'ADVERTENCIA', 'INFO'].includes(nivel)) return nivel; - return 'INFO'; +function _resumenAlertasVacio_(estado, error) { + return { + success: estado !== 'ERROR', + estado: estado, + totalAlertas: 0, + severidades: { ROJO: 0, NARANJA: 0, AMARILLO: 0 }, + proyectosImpactados: 0, + rtImpactados: 0, + alertas: [], + ultimaEvaluacion: '', + obsoleta: false, + error: error || '' + }; } -function evaluarAlertasDataset(dataset) { +function resumirAlertasActivasParaDashboard_(registrosVisibles) { try { - const registros = Array.isArray(dataset) ? dataset : []; - const MotorReglasClass = (typeof globalThis !== 'undefined' && globalThis.MotorReglas) ? globalThis.MotorReglas : null; - - if (!MotorReglasClass) { - return { - success: true, - totalAlertas: 0, - severidades: { CRITICA: 0, ALERTA: 0, ADVERTENCIA: 0, INFO: 0 }, - proyectosImpactados: 0, - rtImpactados: 0, - alertas: [], - ultimaEvaluacion: new Date().toISOString() - }; - } - - const motor = new MotorReglasClass(); - const alertas = []; - const severidades = { CRITICA: 0, ALERTA: 0, ADVERTENCIA: 0, INFO: 0 }; + const rts = new Set((Array.isArray(registrosVisibles) ? registrosVisibles : []).map(function(r) { + return String((r && (r.RT || r['NUMERO RT'])) || '').trim(); + }).filter(Boolean)); + const ss = SpreadsheetApp.openById(getConfig('DATA_FILES.PRINCIPAL')); + const hoja = ss.getSheetByName('ALERTAS_ACTIVAS'); + if (!hoja || hoja.getLastRow() < 2) return _resumenAlertasVacio_('SIN_EVALUAR'); + const datos = hoja.getDataRange().getValues(); + const headers = datos[0].map(function(h) { return String(h || '').trim(); }); + const idx = {}; + headers.forEach(function(h, i) { idx[h] = i; }); + const filas = datos.slice(1).filter(function(fila) { + const rt = String(fila[idx.RT] || '').trim(); + return rt && rts.has(rt); + }); + const mensajeVacio = filas.length === 1 && String(filas[0][idx.MENSAJE] || '') === 'No hay alertas activas en el sistema.'; + const peso = { ROJO: 1, NARANJA: 2, AMARILLO: 3 }; + const severidades = { ROJO: 0, NARANJA: 0, AMARILLO: 0 }; const proyectos = new Set(); - const rts = new Set(); - - registros.forEach((registro) => { - if (!registro || typeof registro !== 'object') return; - - const evaluados = motor.evaluarRegistro(registro); - if (!Array.isArray(evaluados) || evaluados.length === 0) return; - - evaluados.forEach((alerta) => { - const nivel = _normalizarSeveridadAlertas(alerta.severidad); - severidades[nivel] = (severidades[nivel] || 0) + 1; - - const proyecto = String(alerta.proyecto || registro.PROYECTO || 'SIN PROYECTO').trim() || 'SIN PROYECTO'; - const rt = String(alerta.rt || registro.RT || registro['NUMERO RT'] || 'SIN_RT').trim() || 'SIN_RT'; - + const rtSet = new Set(); + const alertas = []; + if (!mensajeVacio) { + filas.forEach(function(fila) { + const nivel = String(fila[idx.NIVEL] || '').trim().toUpperCase(); + if (!severidades.hasOwnProperty(nivel)) return; + severidades[nivel] += 1; + const rt = String(fila[idx.RT] || '').trim(); + const proyecto = String(fila[idx.PROYECTO] || 'SIN PROYECTO').trim() || 'SIN PROYECTO'; proyectos.add(proyecto); - rts.add(rt); - - const articuladorEmail = _resolverEmailDesdeCeldaAlertas(registro['ARTICULADOR JUIRIDICO']); - const gestorEmail = _resolverEmailDesdeCeldaAlertas(registro['GESTOR JURÍDICO']); - + rtSet.add(rt); + const art = String(fila[idx.ARTICULADOR_EMAIL] || ''); + const ges = String(fila[idx.GESTOR_EMAIL] || ''); alertas.push({ RT: rt, PROYECTO: proyecto, - REGLA: alerta.nombre || alerta.codigo || 'DESCONOCIDA', + REGLA: String(fila[idx.REGLA] || ''), NIVEL: nivel, - MENSAJE: alerta.mensaje || '', - FECHA_EVALUACION: alerta.fechaEvaluacion || new Date().toISOString(), - // ✅ SPRINT5-FASE-C: enrutamiento — vacío si la celda todavía tiene el nombre libre - // histórico (no migrado); SIN_RESPONSABLE marca los RTs que hoy no se pueden notificar. - ARTICULADOR_EMAIL: articuladorEmail, - GESTOR_EMAIL: gestorEmail, - SIN_RESPONSABLE: !articuladorEmail && !gestorEmail + MENSAJE: String(fila[idx.MENSAJE] || ''), + FECHA_EVALUACION: String(fila[idx.TIMESTAMP] || ''), + ARTICULADOR_EMAIL: art, + GESTOR_EMAIL: ges, + SIN_RESPONSABLE: !art && !ges }); }); - }); - - const pesoNivel = { CRITICA: 1, ALERTA: 2, ADVERTENCIA: 3, INFO: 4 }; - const alertasOrdenadas = alertas - .sort((a, b) => (pesoNivel[a.NIVEL] || 99) - (pesoNivel[b.NIVEL] || 99)) - .slice(0, MAX_ALERTAS_PAYLOAD); - - const resumen = { + } + alertas.sort(function(a, b) { return (peso[a.NIVEL] || 99) - (peso[b.NIVEL] || 99); }); + let iso = ''; + try { iso = PropertiesService.getScriptProperties().getProperty('MOTOR_REGLAS_ULTIMA_EVALUACION_ISO') || ''; } catch (e) {} + const obsoleta = iso ? (Date.now() - new Date(iso).getTime()) > (36 * 60 * 60 * 1000) : false; + return { success: true, - totalAlertas: alertas.length, - severidades, + estado: 'OK', + totalAlertas: mensajeVacio ? 0 : alertas.length, + severidades: severidades, proyectosImpactados: proyectos.size, - rtImpactados: rts.size, - alertas: alertasOrdenadas, - ultimaEvaluacion: new Date().toISOString() + rtImpactados: rtSet.size, + alertas: alertas.slice(0, MAX_ALERTAS_PAYLOAD), + ultimaEvaluacion: mensajeVacio || alertas.length ? String((filas[0] && filas[0][idx.TIMESTAMP]) || '') : '', + obsoleta: obsoleta }; - - if (severidades.CRITICA > 0) { - try { - const emailUsuario = (typeof Session !== 'undefined' && Session.getActiveUser && Session.getActiveUser()) - ? Session.getActiveUser().getEmail() - : 'Sistema'; - - logAction(emailUsuario, 'ALERTA_CRITICA_DETECTADA', JSON.stringify({ - totalCriticas: severidades.CRITICA, - proyectosImpactados: Array.from(proyectos).slice(0, 10), - alertas: alertasOrdenadas.filter(a => a.NIVEL === 'CRITICA').slice(0, 10) - })); - } catch (e) { - console.warn('No se pudo registrar alerta crítica en LOGS:', e && e.message ? e.message : e); - } - } - - return resumen; } catch (e) { - console.error('Error evaluando alertas por dataset:', e && e.message ? e.message : e); - return { - success: false, - totalAlertas: 0, - severidades: { CRITICA: 0, ALERTA: 0, ADVERTENCIA: 0, INFO: 0 }, - proyectosImpactados: 0, - rtImpactados: 0, - alertas: [], - error: e && e.message ? e.message : String(e), - ultimaEvaluacion: new Date().toISOString() - }; + return _resumenAlertasVacio_('ERROR', e && e.message ? e.message : String(e)); } } /** - * ═══════════════════════════════════════════════════════════ - * FUNCIONES PARA LLAMAR DESDE LA INTERFAZ Y TRIGGERS - * ═══════════════════════════════════════════════════════════ + * Ejecutar UNA VEZ desde el editor con la cuenta Administrador. Independiente de instalarTriggersPAC(). */ +function ejecutarMotorReglasInterno_() { + const motor = new MotorEvaluadorReglas(); + const resultado = motor.ejecutarMotor(); + try { + const auditoria = new GestorAuditoria(getConfig('DATA_FILES.PRINCIPAL')); + auditoria.registrarAccion('SISTEMA', 'MOTOR_REGLAS_EJECUTADO', JSON.stringify({ + totalAlertas: resultado.totalAlertas, + reglasNoEvaluables: resultado.reglasNoEvaluables, + reglasConCruceIgnorado: resultado.reglasConCruceIgnorado, + festivosCargados: resultado.festivosCargados + })); + } catch (e) {} + return resultado; +} -// Ejecución manual desde el botón "Recargar Motor" o Trigger Diario function ejecutarEvaluadorAlertas() { - const motor = new MotorEvaluadorReglas(); - return motor.ejecutarMotor(); + const denegado = _exigirAdminMotorReglas_('EJECUTAR_MOTOR_REGLAS'); + if (denegado) return denegado; + return ejecutarMotorReglasInterno_(); +} + +function triggerDiarioMotorReglas(e) { + if (!e || !e.triggerUid) return { success: false, error: 'Solo invocable por el trigger instalado' }; + const conocido = ScriptApp.getProjectTriggers().some(function(t) { return t.getUniqueId() === e.triggerUid; }); + if (!conocido) return { success: false, error: 'Solo invocable por el trigger instalado' }; + try { + const r = ejecutarMotorReglasInterno_(); + if (!r.success && r.error !== 'No hay reglas configuradas') throw new Error(r.error || 'Fallo del motor'); + return r; + } catch (err) { + console.error(err); + try { + const destino = Session.getEffectiveUser().getEmail(); + if (destino) { + MailApp.sendEmail({ + to: destino, + subject: '[Motor de Reglas] Fallo en trigger diario', + htmlBody: '

El motor de reglas falló: ' + String(err.message || err).replace(/' + }); + } + } catch (mailErr) {} + return { success: false, error: String(err.message || err) }; + } +} + +function _eliminarTriggerMotorReglas_() { + const triggers = ScriptApp.getProjectTriggers().filter(function(t) { + return t.getHandlerFunction() === 'triggerDiarioMotorReglas'; + }); + triggers.forEach(function(t) { ScriptApp.deleteTrigger(t); }); + return triggers.length; +} + +function instalarTriggerMotorReglas() { + const denegado = _exigirAdminMotorReglas_('INSTALAR_TRIGGER_MOTOR'); + if (denegado) return denegado; + _eliminarTriggerMotorReglas_(); + ScriptApp.newTrigger('triggerDiarioMotorReglas').timeBased().everyDays(1).atHour(6).create(); + return { success: true, mensaje: 'Trigger diario 6:00 instalado' }; +} + +function eliminarTriggerMotorReglas() { + const denegado = _exigirAdminMotorReglas_('ELIMINAR_TRIGGER_MOTOR'); + if (denegado) return denegado; + return { success: true, eliminados: _eliminarTriggerMotorReglas_() }; } // Función para obtener las alertas para la interfaz web (Dashboard) diff --git a/export_backend.js b/export_backend.js index d8a12ca..7686a62 100644 --- a/export_backend.js +++ b/export_backend.js @@ -200,6 +200,19 @@ * CONC-FE-15; se mantiene, solo cambia la forma del objeto de éxito. */ function generarPlantillaAsignacionCSV(nivel, idTarget, proyectoContexto) { + const actualUserEmail = Session.getActiveUser().getEmail(); + + // ✅ TRY/CATCH 1: PERMISOS + try { + const gestorPermisos = new GestorPermisos(); + gestorPermisos.validarPermiso('REPORTES'); + } catch (ePermiso) { + logAction(actualUserEmail, 'GENERAR_PLANTILLA_ASIGNACION_DENEGADO', { razon: ePermiso.message }); + console.error(`❌ Acceso denegado en generarPlantillaAsignacionCSV: ${ePermiso.message}`); + return { success: false, error: ePermiso.message }; + } + + // ✅ TRY/CATCH 2: LÓGICA DE NEGOCIO try { if (typeof _leerFilasVisiblesRBACEquipos !== 'function') { throw new Error('_leerFilasVisiblesRBACEquipos no está disponible (gestion_equipos_backend.js no cargado)'); @@ -280,5 +293,18 @@ * plana, fuera del IIFE, es la que realmente queda expuesta a google.script.run. */ function generarPlantillaAsignacionCSV(nivel, idTarget, proyectoContexto) { + const actualUserEmail = Session.getActiveUser().getEmail(); + + // ✅ GUARDIA OUTER: Validar permisos ANTES de delegar a EXPORT_BACKEND + try { + const gestorPermisos = new GestorPermisos(); + gestorPermisos.validarPermiso('REPORTES'); + } catch (ePermiso) { + logAction(actualUserEmail, 'GENERAR_PLANTILLA_ASIGNACION_DENEGADO_WRAPPER', { razon: ePermiso.message }); + console.error(`❌ Acceso denegado en wrapper generarPlantillaAsignacionCSV: ${ePermiso.message}`); + return { success: false, error: ePermiso.message }; + } + + // ✅ DELEGACIÓN: Llamar a la función interna (que también tiene su propio try/catch de permisos + lógica) return EXPORT_BACKEND.generarPlantillaAsignacionCSV(nivel, idTarget, proyectoContexto); } diff --git a/export_pdf_backend.js b/export_pdf_backend.js index a257535..f0e823f 100644 --- a/export_pdf_backend.js +++ b/export_pdf_backend.js @@ -266,6 +266,19 @@ * `predioData` viene ya armado en cliente desde currentData/IndexedDB — cero lectura de Sheets aquí. */ function generarFichaPredialPdfBackend(predioData, metadata) { + const actualUserEmail = Session.getActiveUser().getEmail(); + + // ✅ TRY/CATCH 1: PERMISOS + try { + const gestor = new GestorPermisos(); + gestor.validarPermiso('REPORTES'); + } catch (ePermiso) { + logAction(actualUserEmail, 'GENERAR_FICHA_PDF_DENEGADO', { razon: ePermiso.message }); + console.error(`❌ Acceso denegado en generarFichaPredialPdfBackend: ${ePermiso.message}`); + return { success: false, error: ePermiso.message }; + } + + // ✅ TRY/CATCH 2: LÓGICA DE GENERACIÓN try { const meta = metadata || {}; if (!meta.user) { @@ -285,6 +298,19 @@ function generarFichaPredialPdfBackend(predioData, metadata) { * `alertasArray` viene ya resuelto en cliente (alertasResumenActivo, tope MAX_ALERTAS_PAYLOAD=100). */ function generarReporteAlertasPdfBackend(alertasArray, metadata) { + const actualUserEmail = Session.getActiveUser().getEmail(); + + // ✅ TRY/CATCH 1: PERMISOS + try { + const gestor = new GestorPermisos(); + gestor.validarPermiso('REPORTES'); + } catch (ePermiso) { + logAction(actualUserEmail, 'GENERAR_ALERTAS_PDF_DENEGADO', { razon: ePermiso.message }); + console.error(`❌ Acceso denegado en generarReporteAlertasPdfBackend: ${ePermiso.message}`); + return { success: false, error: ePermiso.message }; + } + + // ✅ TRY/CATCH 2: LÓGICA DE GENERACIÓN try { const meta = metadata || {}; if (!meta.user) { diff --git a/gestion_equipos_backend.js b/gestion_equipos_backend.js index 61ac719..c6fc6f2 100644 --- a/gestion_equipos_backend.js +++ b/gestion_equipos_backend.js @@ -1147,8 +1147,11 @@ function _asegurarHojaLogsAsignacion(gestor, sheetName) { function registrarLogAsignacion(eventoData) { try { const logsFileId = getConfig('DATA_FILES.LOGS_ASIGNACION'); - if (!logsFileId || logsFileId === 'ID_SPREADSHEET_LOGS_ASIGNACION_AQUI') { - console.error('❌ CONFIG.DATA_FILES.LOGS_ASIGNACION sigue en el valor de placeholder — registrarLogAsignacion() deshabilitado hasta configurar un ID de spreadsheet real en config.js.'); + // ✅ SEGURIDAD [2026-10-06]: ya no se compara contra el string de placeholder viejo — + // el ID real ya no vive en config.js, se lee desde la Script Property + // DATA_FILES_LOGS_ASIGNACION_ID (ver CONFIG_SENSITIVE_PROPERTY_MAP en config.js). + if (!logsFileId) { + console.error('❌ DATA_FILES.LOGS_ASIGNACION no está configurado — falta crear el spreadsheet dedicado y setear la Script Property DATA_FILES_LOGS_ASIGNACION_ID. registrarLogAsignacion() deshabilitado hasta entonces. Ver DOCUMENTACION_TECNICA_VIVA.md.'); return { success: false, error: 'DATA_FILES.LOGS_ASIGNACION no configurado' }; } diff --git a/migracion_automatica_v2.js b/migracion_automatica_v2.js new file mode 100644 index 0000000..ee802bd --- /dev/null +++ b/migracion_automatica_v2.js @@ -0,0 +1,437 @@ +/** + * SCRIPT DE MIGRACIÓN AUTOMÁTICA V2 + * Sistema Predial IDU - Migración desde archivo único a arquitectura distribuida + * + * BASADO EN LA ESTRUCTURA REAL DEL ARCHIVO: [STAGING] Matriz Principal + * + * ✅ [2026-10-06] SEGURIDAD: el ID ya NO vive aquí en texto plano — se resuelve desde la + * Script Property ORIGEN_STAGING_ID (mismo patrón que el resto del proyecto). + * ⚠️ Nota de auditoría: el valor que estaba hardcodeado aquí antes de este fix + * ('1FHC6Z6Be...') coincide con el que hoy vive en DATA_FILES_PRINCIPAL_ID, NO con el ID + * real del [STAGING] Matriz Principal que el usuario confirmó y configuró en + * ORIGEN_STAGING_ID. Es casi seguro un valor de prueba/desarrollo que quedó en el + * comentario y la constante sin actualizarse — esta migración ya corrió una vez (Sección + * 43) con resultados verificados correctos, así que no se investiga más a fondo aquí; solo + * se corrige para que, si esta función se vuelve a ejecutar alguna vez, use la Script + * Property correcta en vez del valor viejo hardcodeado. + */ +const ARCHIVO_ORIGEN = getConfigProperty('ORIGEN_STAGING_ID', ''); +const CARPETA_DESTINO = null; // null = raíz de Drive + +// ═══════════════════════════════════════════════════════════════════ +// FUNCIÓN PRINCIPAL +// ═══════════════════════════════════════════════════════════════════ + +function setupAutomaticoCompleto() { + console.log('═══════════════════════════════════════════════════════════'); + console.log('🚀 MIGRACIÓN AUTOMÁTICA V2 - Sistema Predial IDU'); + console.log(' Archivo origen: [STAGING] Matriz Principal'); + console.log('═══════════════════════════════════════════════════════════\n'); + + try { + // PASO 1: Analizar archivo origen + console.log('📊 PASO 1: Analizando archivo origen...'); + const archivoOrigen = analizarArchivoOrigen(); + console.log(`✅ Archivo: "${archivoOrigen.nombre}"`); + console.log(` Total de hojas: ${archivoOrigen.hojas.length}`); + console.log(' Hojas encontradas:'); + archivoOrigen.hojas.forEach(h => console.log(` - ${h}`)); + + // PASO 2: Crear nuevos archivos + console.log('\n📁 PASO 2: Creando estructura de 5 archivos...'); + const nuevosArchivos = crearEstructuraArchivos(); + + // PASO 3: Migrar datos + console.log('\n📦 PASO 3: Migrando datos del archivo origen...'); + migrarDatosCompletos(archivoOrigen, nuevosArchivos); + + // PASO 4: Configurar Script Properties + console.log('\n⚙️ PASO 4: Configurando Script Properties...'); + configurarScriptProperties(nuevosArchivos); + + // PASO 5: Validar instalación + console.log('\n✅ PASO 5: Validando instalación...'); + const validacion = validarInstalacion(); + + // RESUMEN FINAL + mostrarResumenFinal(archivoOrigen, nuevosArchivos, validacion); + + return nuevosArchivos; + + } catch (error) { + console.error('❌ ERROR EN LA MIGRACIÓN:', error.message); + console.error('Stack:', error.stack); + throw error; + } +} + +// ═══════════════════════════════════════════════════════════════════ +// PASO 1: ANALIZAR ARCHIVO ORIGEN +// ═══════════════════════════════════════════════════════════════════ + +function analizarArchivoOrigen() { + try { + const ss = SpreadsheetApp.openById(ARCHIVO_ORIGEN); + const hojas = ss.getSheets().map(h => h.getName()); + + return { + id: ARCHIVO_ORIGEN, + nombre: ss.getName(), + url: ss.getUrl(), + hojas: hojas, + spreadsheet: ss + }; + } catch (error) { + throw new Error(`No se pudo acceder al archivo origen: ${error.message}`); + } +} + +// ═══════════════════════════════════════════════════════════════════ +// PASO 2: CREAR ESTRUCTURA DE ARCHIVOS +// ═══════════════════════════════════════════════════════════════════ + +function crearEstructuraArchivos() { + const nuevosArchivos = {}; + + // ───────────────────────────────────────────────────────────────── + // ARCHIVO 1: Principal (Datos operativos) + // ───────────────────────────────────────────────────────────────── + console.log(' 📄 Creando: Sistema Predial IDU - Principal...'); + const ssPrincipal = SpreadsheetApp.create('Sistema Predial IDU - Principal'); + + // Crear hojas con estructura + crearHojaSiNoExiste(ssPrincipal, 'Datos', null); + crearHojaSiNoExiste(ssPrincipal, 'Datos2', null); + crearHojaSiNoExiste(ssPrincipal, 'Seguimiento', null); + crearHojaSiNoExiste(ssPrincipal, 'Compromisos', null); + crearHojaSiNoExiste(ssPrincipal, 'CASOS ESPECIALES', null); + crearHojaSiNoExiste(ssPrincipal, 'FiltroMatriz', null); + crearHojaSiNoExiste(ssPrincipal, 'Configuracion', ['Clave', 'Valor', 'Descripcion', 'FechaActualizacion']); + + // Datos iniciales de configuración + const hojaConfig = ssPrincipal.getSheetByName('Configuracion'); + hojaConfig.getRange(2, 1, 2, 4).setValues([ + ['VERSION_SISTEMA', '2.0', 'Versión actual del sistema', new Date()], + ['MODO_MANTENIMIENTO', 'false', 'Sistema en mantenimiento', new Date()] + ]); + + eliminarHojaPorDefecto(ssPrincipal); + + nuevosArchivos.principal = { + id: ssPrincipal.getId(), + nombre: 'Sistema Predial IDU - Principal', + url: ssPrincipal.getUrl(), + spreadsheet: ssPrincipal + }; + console.log(' ✅ Creado (ID guardado en nuevosArchivos.principal, no se imprime)'); // ✅ [2026-10-06] SEGURIDAD: el ID ya queda en nuevosArchivos.principal.id para configurarScriptProperties(), no hace falta imprimirlo + + // ───────────────────────────────────────────────────────────────── + // ARCHIVO 2: Logs (Auditoría y trazabilidad) + // ───────────────────────────────────────────────────────────────── + console.log(' 📄 Creando: Sistema Predial IDU - Logs...'); + const ssLogs = SpreadsheetApp.create('Sistema Predial IDU - Logs'); + + crearHojaSiNoExiste(ssLogs, 'LOGS_AUDITORIA', null); + crearHojaSiNoExiste(ssLogs, 'Logs', null); + crearHojaSiNoExiste(ssLogs, '📈 Historial de Cambios', null); + crearHojaSiNoExiste(ssLogs, '📋 Auditoría de Cambios', null); + crearHojaSiNoExiste(ssLogs, '📊 Logs del Sistema', null); + crearHojaSiNoExiste(ssLogs, 'LOGS_ASIGNACION', ['Timestamp', 'Usuario', 'TipoAsignacion', 'PredioID', 'AsignadoA', 'Observaciones']); + + eliminarHojaPorDefecto(ssLogs); + + nuevosArchivos.logs = { + id: ssLogs.getId(), + nombre: 'Sistema Predial IDU - Logs', + url: ssLogs.getUrl(), + spreadsheet: ssLogs + }; + console.log(' ✅ Creado (ID guardado en nuevosArchivos.logs, no se imprime)'); // ✅ [2026-10-06] SEGURIDAD + + // ───────────────────────────────────────────────────────────────── + // ARCHIVO 3: Usuarios (Directorio y asignaciones) + // ───────────────────────────────────────────────────────────────── + console.log(' 📄 Creando: Sistema Predial IDU - Usuarios...'); + const ssUsuarios = SpreadsheetApp.create('Sistema Predial IDU - Usuarios'); + + crearHojaSiNoExiste(ssUsuarios, 'USUARIOS', null); + crearHojaSiNoExiste(ssUsuarios, 'ASIGNACIONES_EQUIPOS', null); + crearHojaSiNoExiste(ssUsuarios, 'Asignacion_RT', null); + + eliminarHojaPorDefecto(ssUsuarios); + + nuevosArchivos.usuarios = { + id: ssUsuarios.getId(), + nombre: 'Sistema Predial IDU - Usuarios', + url: ssUsuarios.getUrl(), + spreadsheet: ssUsuarios + }; + console.log(' ✅ Creado (ID guardado en nuevosArchivos.usuarios, no se imprime)'); // ✅ [2026-10-06] SEGURIDAD + + // ───────────────────────────────────────────────────────────────── + // ARCHIVO 4: Permisos RBAC + // ───────────────────────────────────────────────────────────────── + console.log(' 📄 Creando: Sistema Predial IDU - Permisos RBAC...'); + const ssPermisos = SpreadsheetApp.create('Sistema Predial IDU - Permisos RBAC'); + + crearHojaSiNoExiste(ssPermisos, 'Permisos', null); + + eliminarHojaPorDefecto(ssPermisos); + + nuevosArchivos.permisos = { + id: ssPermisos.getId(), + nombre: 'Sistema Predial IDU - Permisos RBAC', + url: ssPermisos.getUrl(), + spreadsheet: ssPermisos + }; + console.log(' ✅ Creado (ID guardado en nuevosArchivos.permisos, no se imprime)'); // ✅ [2026-10-06] SEGURIDAD + + // ───────────────────────────────────────────────────────────────── + // ARCHIVO 5: PAC (Motor de reglas y alertas) + // ───────────────────────────────────────────────────────────────── + console.log(' 📄 Creando: Sistema Predial IDU - PAC...'); + const ssPAC = SpreadsheetApp.create('Sistema Predial IDU - PAC'); + + crearHojaSiNoExiste(ssPAC, 'PAC_ReglasPAC', null); + crearHojaSiNoExiste(ssPAC, 'LOG_PAC_SISTEMA', null); + crearHojaSiNoExiste(ssPAC, 'ALERTAS_ACTIVAS', null); + crearHojaSiNoExiste(ssPAC, 'PAC_Vigente', null); + crearHojaSiNoExiste(ssPAC, 'PAC_Borrador', null); + crearHojaSiNoExiste(ssPAC, 'PAC_Historial', null); + crearHojaSiNoExiste(ssPAC, 'PAC_Alertas', null); + crearHojaSiNoExiste(ssPAC, 'PAC_Articuladores', null); + crearHojaSiNoExiste(ssPAC, 'PAC_Motor', ['Clave', 'Valor', 'Descripcion']); + + // Configuración inicial del motor + const hojaMotor = ssPAC.getSheetByName('PAC_Motor'); + hojaMotor.getRange(2, 1, 2, 3).setValues([ + ['VERSION_MOTOR', '2.0', 'Versión del motor PAC'], + ['ULTIMA_EJECUCION', '', 'Timestamp última ejecución'] + ]); + + eliminarHojaPorDefecto(ssPAC); + + nuevosArchivos.pac = { + id: ssPAC.getId(), + nombre: 'Sistema Predial IDU - PAC', + url: ssPAC.getUrl(), + spreadsheet: ssPAC + }; + console.log(' ✅ Creado (ID guardado en nuevosArchivos.pac, no se imprime)'); // ✅ [2026-10-06] SEGURIDAD + + return nuevosArchivos; +} + +// ═══════════════════════════════════════════════════════════════════ +// PASO 3: MIGRAR DATOS COMPLETOS +// ═══════════════════════════════════════════════════════════════════ + +function migrarDatosCompletos(archivoOrigen, nuevosArchivos) { + const ssOrigen = archivoOrigen.spreadsheet; + + // Mapeo completo basado en la estructura real + const mapeoMigracion = [ + { origen: 'Datos', destino: 'principal', hoja: 'Datos' }, + { origen: 'Datos2', destino: 'principal', hoja: 'Datos2' }, + { origen: 'Seguimiento', destino: 'principal', hoja: 'Seguimiento' }, + { origen: 'Compromisos', destino: 'principal', hoja: 'Compromisos' }, + { origen: 'CASOS ESPECIALES', destino: 'principal', hoja: 'CASOS ESPECIALES' }, + { origen: 'FiltroMatriz', destino: 'principal', hoja: 'FiltroMatriz' }, + { origen: 'LOGS_AUDITORIA', destino: 'logs', hoja: 'LOGS_AUDITORIA' }, + { origen: 'Logs', destino: 'logs', hoja: 'Logs' }, + { origen: '📈 Historial de Cambios', destino: 'logs', hoja: '📈 Historial de Cambios' }, + { origen: '📋 Auditoría de Cambios', destino: 'logs', hoja: '📋 Auditoría de Cambios' }, + { origen: '📊 Logs del Sistema', destino: 'logs', hoja: '📊 Logs del Sistema' }, + { origen: 'USUARIOS', destino: 'usuarios', hoja: 'USUARIOS' }, + { origen: 'ASIGNACIONES_EQUIPOS', destino: 'usuarios', hoja: 'ASIGNACIONES_EQUIPOS' }, + { origen: 'Asignacion_RT', destino: 'usuarios', hoja: 'Asignacion_RT' }, + { origen: 'Permisos', destino: 'permisos', hoja: 'Permisos' }, + { origen: 'PAC_ReglasPAC', destino: 'pac', hoja: 'PAC_ReglasPAC' }, + { origen: 'LOG_PAC_SISTEMA', destino: 'pac', hoja: 'LOG_PAC_SISTEMA' }, + { origen: 'ALERTAS_ACTIVAS', destino: 'pac', hoja: 'ALERTAS_ACTIVAS' }, + { origen: 'PAC_Vigente', destino: 'pac', hoja: 'PAC_Vigente' }, + { origen: 'PAC_Borrador', destino: 'pac', hoja: 'PAC_Borrador' }, + { origen: 'PAC_Historial', destino: 'pac', hoja: 'PAC_Historial' }, + { origen: 'PAC_Alertas', destino: 'pac', hoja: 'PAC_Alertas' }, + { origen: 'PAC_Articuladores', destino: 'pac', hoja: 'PAC_Articuladores' } + ]; + + let datosCopiados = 0; + let hojasCopiadas = 0; + + console.log(' Iniciando migración de datos...\n'); + + mapeoMigracion.forEach(mapeo => { + const hojaOrigen = ssOrigen.getSheetByName(mapeo.origen); + + if (hojaOrigen) { + const hojaDestino = nuevosArchivos[mapeo.destino].spreadsheet.getSheetByName(mapeo.hoja); + + if (hojaDestino) { + try { + const ultimaFila = hojaOrigen.getLastRow(); + const ultimaColumna = hojaOrigen.getLastColumn(); + + if (ultimaFila > 0 && ultimaColumna > 0) { + const datos = hojaOrigen.getRange(1, 1, ultimaFila, ultimaColumna).getValues(); + const formatos = hojaOrigen.getRange(1, 1, ultimaFila, ultimaColumna).getBackgrounds(); + + hojaDestino.getRange(1, 1, datos.length, datos[0].length).setValues(datos); + hojaDestino.getRange(1, 1, formatos.length, formatos[0].length).setBackgrounds(formatos); + + for (let col = 1; col <= ultimaColumna; col++) { + const ancho = hojaOrigen.getColumnWidth(col); + hojaDestino.setColumnWidth(col, ancho); + } + + if (hojaOrigen.getFrozenRows() > 0) { + hojaDestino.setFrozenRows(1); + } + + console.log(` ✅ ${mapeo.origen} → ${mapeo.destino}/${mapeo.hoja} (${ultimaFila} filas, ${ultimaColumna} cols)`); + datosCopiados += ultimaFila; + hojasCopiadas++; + } else { + console.log(` ⚠️ ${mapeo.origen}: Hoja vacía (omitida)`); + } + } catch (error) { + console.log(` ❌ Error copiando ${mapeo.origen}: ${error.message}`); + } + } else { + console.log(` ⚠️ Hoja destino no encontrada: ${mapeo.hoja}`); + } + } else { + console.log(` ⚠️ Hoja origen no encontrada: ${mapeo.origen}`); + } + }); + + console.log(`\n 📊 Resumen de migración:`); + console.log(` - Hojas copiadas: ${hojasCopiadas}/${mapeoMigracion.length}`); + console.log(` - Total de filas migradas: ${datosCopiados}`); +} + +// ═══════════════════════════════════════════════════════════════════ +// PASO 4: CONFIGURAR SCRIPT PROPERTIES +// ═══════════════════════════════════════════════════════════════════ + +function configurarScriptProperties(nuevosArchivos) { + const props = PropertiesService.getScriptProperties(); + + const propiedades = { + 'DATA_FILES_PRINCIPAL_ID': nuevosArchivos.principal.id, + 'DATA_FILES_LOGS_ID': nuevosArchivos.logs.id, + 'DATA_FILES_USUARIOS_ID': nuevosArchivos.usuarios.id, + 'MAESTRO_PERMISOS_ID': nuevosArchivos.permisos.id, + 'PAC_SPREADSHEET_ID': nuevosArchivos.pac.id + }; + + props.setProperties(propiedades); + + Object.keys(propiedades).forEach(key => { + console.log(` ✅ ${key}: ${propiedades[key]}`); + }); +} + +// ═══════════════════════════════════════════════════════════════════ +// PASO 5: VALIDAR INSTALACIÓN +// ═══════════════════════════════════════════════════════════════════ + +function validarInstalacion() { + const props = PropertiesService.getScriptProperties(); + const propiedadesRequeridas = [ + 'DATA_FILES_PRINCIPAL_ID', + 'DATA_FILES_LOGS_ID', + 'DATA_FILES_USUARIOS_ID', + 'MAESTRO_PERMISOS_ID', + 'PAC_SPREADSHEET_ID' + ]; + + let todasOK = true; + + propiedadesRequeridas.forEach(prop => { + const valor = props.getProperty(prop); + if (valor && valor.length > 20) { + try { + const ss = SpreadsheetApp.openById(valor); + console.log(` ✅ ${prop}: ${ss.getName()}`); + } catch (e) { + console.log(` ❌ ${prop}: ERROR - ${e.message}`); + todasOK = false; + } + } else { + console.log(` ❌ ${prop}: NO CONFIGURADA`); + todasOK = false; + } + }); + + return todasOK; +} + +// ═══════════════════════════════════════════════════════════════════ +// RESUMEN FINAL +// ═══════════════════════════════════════════════════════════════════ + +function mostrarResumenFinal(archivoOrigen, nuevosArchivos, validacion) { + console.log('\n═══════════════════════════════════════════════════════════'); + console.log('📋 RESUMEN DE MIGRACIÓN'); + console.log('═══════════════════════════════════════════════════════════'); + console.log(`\n📂 Archivo origen:`); + console.log(` Nombre: ${archivoOrigen.nombre}`); + console.log(` ID: ${archivoOrigen.id}`); + console.log(` URL: ${archivoOrigen.url}`); + + console.log(`\n📁 Archivos creados:\n`); + Object.keys(nuevosArchivos).forEach(key => { + const archivo = nuevosArchivos[key]; + console.log(`✅ ${archivo.nombre}`); + console.log(` ID: ${archivo.id}`); + console.log(` URL: ${archivo.url}\n`); + }); + + if (validacion) { + console.log('🎉 ¡MIGRACIÓN COMPLETADA CON ÉXITO!'); + console.log('\n🔗 URL del sistema:'); + console.log('https://script.google.com/a/macros/idu.gov.co/s/AKfycbyfD70GGwc4DlnFW48B0xvUX8LFaGKWPxnSGjW08wQ/dev'); + console.log('\n✅ El sistema está listo para usarse.'); + } else { + console.log('⚠️ MIGRACIÓN COMPLETADA CON ADVERTENCIAS'); + console.log('Revisa los mensajes de validación arriba.'); + } + + console.log('═══════════════════════════════════════════════════════════'); +} + +// ═══════════════════════════════════════════════════════════════════ +// FUNCIONES AUXILIARES +// ═══════════════════════════════════════════════════════════════════ + +function crearHojaSiNoExiste(spreadsheet, nombreHoja, encabezados) { + let hoja = spreadsheet.getSheetByName(nombreHoja); + + if (!hoja) { + hoja = spreadsheet.insertSheet(nombreHoja); + } + + if (encabezados && encabezados.length > 0) { + hoja.getRange(1, 1, 1, encabezados.length).setValues([encabezados]); + hoja.getRange(1, 1, 1, encabezados.length).setFontWeight('bold'); + hoja.setFrozenRows(1); + } + + return hoja; +} + +function eliminarHojaPorDefecto(spreadsheet) { + const hojas = spreadsheet.getSheets(); + hojas.forEach(hoja => { + if (hoja.getName().includes('Hoja') || hoja.getName().includes('Sheet')) { + try { + if (spreadsheet.getSheets().length > 1) { + spreadsheet.deleteSheet(hoja); + } + } catch (e) { + // Ignorar si no se puede eliminar + } + } + }); +} diff --git a/motor_reglas.js b/motor_reglas.js index c5445fe..cdfb78e 100644 --- a/motor_reglas.js +++ b/motor_reglas.js @@ -1,332 +1,285 @@ /** - * ═══════════════════════════════════════════════════════════ - * MOTOR DE REGLAS - ALMACENAMIENTO Y LECTURA - * ═══════════════════════════════════════════════════════════ + * MOTOR DE REGLAS - ALMACENAMIENTO Y CONFIGURACIÓN + * Única definición de obtenerReglasJSON / guardarReglasJSON. + * La evaluación vive en evaluador_alertas.js. */ -// Función para obtener el JSON actual de las reglas -function obtenerReglasJSON() { +function _exigirAdminMotorReglas_(accion) { try { - const fileId = getConfig('DATA_FILES.PRINCIPAL'); // Toma el ID de tu config actual - const ss = SpreadsheetApp.openById(fileId); - let hojaReglas = ss.getSheetByName('CONFIG_REGLAS'); - - // Si la hoja no existe, la crea (Instalación automática) - if (!hojaReglas) { - // ✅ SEC-P1.5: LockService para evitar creación duplicada de la hoja bajo concurrencia - const lock = LockService.getScriptLock(); - try { - lock.waitLock(20000); - // Re-chequear tras adquirir el lock: otro proceso pudo haberla creado mientras esperábamos - hojaReglas = ss.getSheetByName('CONFIG_REGLAS'); - if (!hojaReglas) { - hojaReglas = ss.insertSheet('CONFIG_REGLAS'); - hojaReglas.hideSheet(); // La oculta para que los usuarios no la dañen - hojaReglas.getRange("A1").setValue("MOTOR_DE_REGLAS_JSON"); - hojaReglas.getRange("A1").setFontWeight("bold").setBackground("#34495e").setFontColor("white"); - hojaReglas.getRange("B1").setValue("{}"); // JSON vacío por defecto - } - } catch (lockError) { - // ✅ CONC-P2.1: diferenciar timeout de lock de otros errores (antes se tragaba en silencio - // y caía al TypeError de hojaReglas null, con mensaje técnico confuso) - console.error("Error adquiriendo lock para crear CONFIG_REGLAS:", lockError); - return { success: false, message: 'El sistema se encuentra ocupado por otro administrador. Por favor intente de nuevo en unos segundos.' }; - } finally { - try { lock.releaseLock(); } catch (er) {} - } - } - - const jsonString = hojaReglas.getRange("B1").getValue(); - return { success: true, data: jsonString }; - - } catch (error) { - console.error("Error leyendo reglas:", error); - return { success: false, error: error.message }; + new GestorPermisos().validarPermiso('ADMIN_SISTEMA'); + return null; + } catch (e) { + try { + logAction(Session.getActiveUser().getEmail() || 'desconocido', accion + '_DENEGADO', e.message); + } catch (_) {} + return { success: false, error: 'Requiere rol Administrador.' }; } } -// Función para que el Administrador guarde cambios en el JSON desde la UI -function guardarReglasJSON(jsonString, usuario) { - // ✅ CONC-P2.1: validar el JSON ANTES de tocar el lock, para que un JSON invalido - // nunca se reporte como timeout ni un timeout se reporte como JSON invalido - try { - JSON.parse(jsonString); - } catch (parseError) { - return { success: false, error: "El formato JSON es inválido. Revisa la sintaxis." }; - } +function _obtenerHojaConfigReglas_(ss, crearSiFalta, yaTieneLock) { + let hojaReglas = ss.getSheetByName('CONFIG_REGLAS'); + if (hojaReglas || !crearSiFalta) return hojaReglas; - const lock = LockService.getScriptLock(); + const lock = yaTieneLock ? null : LockService.getScriptLock(); try { - lock.waitLock(20000); + if (!yaTieneLock) lock.waitLock(20000); + hojaReglas = ss.getSheetByName('CONFIG_REGLAS'); + if (!hojaReglas) { + hojaReglas = ss.insertSheet('CONFIG_REGLAS'); + hojaReglas.hideSheet(); + hojaReglas.getRange('A1').setValue('MOTOR_DE_REGLAS_JSON'); + hojaReglas.getRange('A1').setFontWeight('bold').setBackground('#34495e').setFontColor('white'); + hojaReglas.getRange('B1').setValue('{}'); + } + return hojaReglas; } catch (lockError) { - return { success: false, message: 'El sistema se encuentra ocupado por otro administrador. Por favor intente de nuevo en unos segundos.' }; + console.error('Error adquiriendo lock para crear CONFIG_REGLAS:', lockError); + if (yaTieneLock) throw lockError; + return null; + } finally { + if (!yaTieneLock && lock) { + try { lock.releaseLock(); } catch (er) {} + } } +} + +function _versionReglas_(str) { + const bytes = Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, String(str), Utilities.Charset.UTF_8); + return bytes.map(function(b) { + const n = b < 0 ? b + 256 : b; + return n.toString(16).padStart(2, '0'); + }).join(''); +} +function _leerEncabezadosDatosReglas_() { + const fileId = getConfig('DATA_FILES.PRINCIPAL'); + const ss = SpreadsheetApp.openById(fileId); + const hoja = ss.getSheetByName(getConfig('SHEETS.DATOS')); + if (!hoja || hoja.getLastColumn() < 1) return []; + const fila = hoja.getRange(1, 1, 1, hoja.getLastColumn()).getValues()[0]; + const vistos = {}; + const columnas = []; + fila.forEach(function(h) { + const nombre = String(h || '').trim().toUpperCase(); + if (!nombre || vistos[nombre]) return; + vistos[nombre] = true; + columnas.push(nombre); + }); + return columnas; +} + +function obtenerReglasJSON() { try { const fileId = getConfig('DATA_FILES.PRINCIPAL'); const ss = SpreadsheetApp.openById(fileId); - let hojaReglas = ss.getSheetByName('CONFIG_REGLAS'); - - // 2. Guardar en la celda B1 - hojaReglas.getRange("B1").setValue(jsonString); - - // 3. Registrar en auditoría (usando tu Gestor de Auditoría actual) - try { - const auditoria = new GestorAuditoria(fileId); - auditoria.registrarAccion(usuario, 'ACTUALIZAR_MOTOR_REGLAS', 'Se actualizó el JSON maestro de reglas de negocio'); - } catch(e) { console.warn("No se pudo auditar:", e); } - - return { success: true, message: "Reglas actualizadas correctamente en la base de datos." }; - + const hojaReglas = _obtenerHojaConfigReglas_(ss, true, false); + if (!hojaReglas) { + return { success: false, message: 'El sistema se encuentra ocupado por otro administrador. Por favor intente de nuevo en unos segundos.' }; + } + const jsonString = String(hojaReglas.getRange('B1').getValue() || ''); + return { success: true, data: jsonString, version: _versionReglas_(jsonString) }; } catch (error) { - return { success: false, error: 'Error al guardar las reglas: ' + error.message }; - } finally { - try { lock.releaseLock(); } catch (er) {} + console.error('Error leyendo reglas:', error); + return { success: false, error: error.message }; } } -/** - * @typedef {Object} Regla - * @property {string} id - * @property {string} nombre - * @property {string} descripcion - * @property {string} codigo - * @property {'ACTIVA'|'INACTIVA'} estado - * @property {'FINANCIERO'|'PLAZO'|'ESTADO'|'GENERAL'} categoria - * @property {'INFO'|'ADVERTENCIA'|'ALERTA'|'CRITICA'} severidad - * @property {number} prioridad - * @property {Object} parametros - * @property {string[]} camposRequeridos - * @property {boolean} activa - */ - -/** - * @typedef {Object} AlertaGenerada - * @property {string} id - * @property {string} reglaId - * @property {string} nombre - * @property {string} codigo - * @property {'INFO'|'ADVERTENCIA'|'ALERTA'|'CRITICA'} severidad - * @property {number} prioridad - * @property {string} mensaje - * @property {string} rt - * @property {string} proyecto - * @property {string} tramo - * @property {string} estado - * @property {string} fechaEvaluacion - * @property {string} detalle - * @property {boolean} resuelta - */ +function _aliasColumnaRegla_(criterio) { + return String((criterio && (criterio.columna_estado || criterio.columna_A || criterio.columna)) || '').trim().toUpperCase(); +} -class MotorReglas { - constructor(reglas = MotorReglas.reglasBase()) { - this.reglas = Array.isArray(reglas) ? reglas.filter(Boolean) : []; - } +function _aliasOperadorRegla_(criterio) { + return String((criterio && (criterio.operador_estado || criterio.operador)) || '').trim(); +} - static reglasBase() { - return [ - { - id: 'REG-001', - nombre: 'Inconsistencia Financiera', - descripcion: 'Detecta cuando el valor ejecutado supera el valor programado.', - codigo: 'INCONSISTENCIA_FINANCIERA', - estado: 'ACTIVA', - categoria: 'FINANCIERO', - severidad: 'ALERTA', - prioridad: 100, - activa: true, - parametros: { umbral: 0 }, - camposRequeridos: ['VALOR PAGADO', 'ESTIMADO $'] - }, - { - id: 'REG-002', - nombre: 'Alerta de Vencimiento', - descripcion: 'Detecta fechas próximas de compromiso o vencimiento.', - codigo: 'ALERTA_VENCIMIENTO', - estado: 'ACTIVA', - categoria: 'PLAZO', - severidad: 'ADVERTENCIA', - prioridad: 80, - activa: true, - parametros: { dias: 30 }, - camposRequeridos: ['FECHA COMPROMISO', 'FECHA ESTIMADA DE ENTREGA'] - }, - { - id: 'REG-003', - nombre: 'Falta de Estado', - descripcion: 'Detecta RT activos sin estado definido o no normalizado.', - codigo: 'FALTA_DE_ESTADO', - estado: 'ACTIVA', - categoria: 'ESTADO', - severidad: 'INFO', - prioridad: 60, - activa: true, - parametros: { activo: 'ACTIVO' }, - camposRequeridos: ['ACTIVO/INACTIVO', 'ESTADO PREDIAL AJUSTADO'] - } - ]; +function _recorrerBloquesRegla_(regla, visitante) { + if (regla.condiciones) visitante(regla.condiciones, 'condiciones'); + if (Array.isArray(regla.excepciones)) { + regla.excepciones.forEach(function(bloque, i) { visitante(bloque, 'excepciones[' + i + ']'); }); + } else if (regla.excepciones) { + visitante(regla.excepciones, 'excepciones'); } +} - _normalizarTexto(value) { - if (value === null || value === undefined) return ''; - return String(value).trim(); +function validarConfigReglas_(config, columnasConocidas) { + const errores = []; + const advertencias = []; + if (!config || typeof config !== 'object' || Array.isArray(config)) { + errores.push('La configuración debe ser un objeto JSON.'); + return { errores: errores, advertencias: advertencias }; } - - _parseNumber(value) { - if (value === null || value === undefined || value === '') return 0; - if (typeof value === 'number') return Number.isFinite(value) ? value : 0; - const cleaned = String(value).replace(/[$.\s]/g, '').replace(',', '.'); - const numeric = Number(cleaned); - return Number.isFinite(numeric) ? numeric : 0; + if (!Array.isArray(config.ReglasDeNegocio)) { + errores.push('ReglasDeNegocio debe ser un array.'); + return { errores: errores, advertencias: advertencias }; } - - _parseDate(value) { - if (!value && value !== 0) return null; - const str = this._normalizarTexto(value); - if (!str) return null; - - const direct = new Date(str); - if (!isNaN(direct.getTime())) return direct; - - const match = str.match(/^(\d{1,2})[/-](\d{1,2})[/-](\d{4})$/); - if (match) { - const d = new Date(Number(match[3]), Number(match[2]) - 1, Number(match[1])); - if (!isNaN(d.getTime())) return d; + const nombres = {}; + const columnas = Array.isArray(columnasConocidas) ? columnasConocidas : null; + + config.ReglasDeNegocio.forEach(function(regla, idx) { + const etiqueta = 'Regla ' + (idx + 1); + if (!regla || typeof regla !== 'object') { + errores.push(etiqueta + ': no es un objeto.'); + return; } - - return null; - } - - _coalesceField(rtData, candidateKeys) { - if (!rtData || typeof rtData !== 'object') return ''; - const upperMap = {}; - Object.keys(rtData).forEach((key) => { - upperMap[String(key).toUpperCase().trim()] = rtData[key]; + const nombre = String(regla.nombre || '').trim(); + if (!nombre) errores.push(etiqueta + ': falta el nombre.'); + const claveNombre = nombre.toUpperCase(); + if (nombre && nombres[claveNombre]) errores.push(etiqueta + ': el nombre "' + nombre + '" está repetido.'); + if (nombre) nombres[claveNombre] = true; + + _recorrerBloquesRegla_(regla, function(bloque, donde) { + const criterios = bloque && Array.isArray(bloque.criterios) ? bloque.criterios : []; + if (!criterios.length) { + errores.push(etiqueta + ' / ' + donde + ': hace falta al menos un criterio. Un bloque vacío haría que la regla aplique a todas las filas o anule todas las alertas.'); + } + criterios.forEach(function(criterio, cidx) { + const op = _aliasOperadorRegla_(criterio); + if (!op || !OPERADORES_CONDICION_REGLAS[op]) { + errores.push(etiqueta + ' / ' + donde + ' criterio ' + (cidx + 1) + ': operador desconocido.'); + return; + } + const params = OPERADORES_CONDICION_REGLAS[op].parametros || []; + if (params.indexOf('valor') >= 0 && (criterio.valor === undefined || criterio.valor === null || String(criterio.valor) === '')) { + errores.push(etiqueta + ' / ' + donde + ' criterio ' + (cidx + 1) + ': falta el valor.'); + } + if (params.indexOf('valores') >= 0 && (!Array.isArray(criterio.valores) || !criterio.valores.length) && (!Array.isArray(criterio.valores_estado) || !criterio.valores_estado.length)) { + errores.push(etiqueta + ' / ' + donde + ' criterio ' + (cidx + 1) + ': la lista de valores está vacía.'); + } + if (columnas) { + const col = _aliasColumnaRegla_(criterio); + if (col && columnas.indexOf(col) < 0) advertencias.push(etiqueta + ': la columna "' + col + '" no está en Datos.'); + const destino = String(criterio.columna_destino || '').trim().toUpperCase(); + if (destino && columnas.indexOf(destino) < 0) advertencias.push(etiqueta + ': la columna destino "' + destino + '" no está en Datos.'); + } + }); }); - for (let i = 0; i < candidateKeys.length; i++) { - const key = candidateKeys[i]; - const normalized = String(key).toUpperCase().trim(); - if (upperMap[normalized] !== undefined) return upperMap[normalized]; + if (regla.alerta_bloqueo) { + const nivel = String(regla.alerta_bloqueo.nivel || '').toUpperCase(); + if (NIVELES_ALERTA_REGLAS.indexOf(nivel) < 0) errores.push(etiqueta + ': nivel de bloqueo inválido.'); + if (!String(regla.alerta_bloqueo.mensaje || '').trim()) errores.push(etiqueta + ': el bloqueo necesita un mensaje.'); } - return ''; - } + if (regla.regla_tiempo) { + if (!Array.isArray(regla.regla_tiempo.umbrales) || !regla.regla_tiempo.umbrales.length) { + errores.push(etiqueta + ': la regla de tiempo necesita al menos un umbral.'); + } else { + regla.regla_tiempo.umbrales.forEach(function(umbral, uidx) { + const nivel = String((umbral && umbral.nivel) || '').toUpperCase(); + if (NIVELES_ALERTA_REGLAS.indexOf(nivel) < 0) errores.push(etiqueta + ': umbral ' + (uidx + 1) + ' con nivel inválido.'); + if (!umbral || typeof umbral.mensaje !== 'string' || !umbral.mensaje.trim()) { + errores.push(etiqueta + ': el umbral ' + (uidx + 1) + ' necesita un mensaje.'); + } + }); + } + if (columnas) { + const fecha = String(regla.regla_tiempo.columna_fecha_inicio || '').trim().toUpperCase(); + if (fecha && columnas.indexOf(fecha) < 0) advertencias.push(etiqueta + ': la fecha "' + fecha + '" no está en Datos.'); + } + } + if (regla.texto_inicial_correo !== undefined && regla.texto_inicial_correo !== null) { + if (typeof regla.texto_inicial_correo !== 'string') errores.push(etiqueta + ': el texto inicial del correo debe ser texto.'); + else if (regla.texto_inicial_correo.length > 2000) errores.push(etiqueta + ': el texto inicial del correo supera 2000 caracteres.'); + } + if (regla.seguimiento_correo !== undefined && regla.seguimiento_correo !== null) { + const seg = regla.seguimiento_correo; + if (typeof seg !== 'object' || Array.isArray(seg)) { + errores.push(etiqueta + ': seguimiento_correo debe ser un objeto.'); + } else { + Object.keys(seg).forEach(function(nivel) { + if (NIVELES_ALERTA_REGLAS.indexOf(nivel) < 0) { + errores.push(etiqueta + ': seguimiento con nivel desconocido "' + nivel + '".'); + return; + } + const item = seg[nivel] || {}; + if (item.exigir_observacion !== undefined && typeof item.exigir_observacion !== 'boolean') { + errores.push(etiqueta + ': exigir_observacion de ' + nivel + ' debe ser verdadero o falso.'); + } + if (item.permitir_no_uso !== undefined && typeof item.permitir_no_uso !== 'boolean') { + errores.push(etiqueta + ': permitir_no_uso de ' + nivel + ' debe ser verdadero o falso.'); + } + const nota = String(item.nota_correo || ''); + const aviso = String(item.texto_no_uso || ''); + if (nota.length > 600) errores.push(etiqueta + ': la nota de ' + nivel + ' supera 600 caracteres.'); + if (aviso.length > 600) errores.push(etiqueta + ': el aviso de no uso de ' + nivel + ' supera 600 caracteres.'); + if (item.exigir_observacion === true && !nota.trim()) errores.push(etiqueta + ': ' + nivel + ' exige observación y la nota del correo está vacía.'); + if (item.permitir_no_uso === true && item.exigir_observacion !== true) errores.push(etiqueta + ': ' + nivel + ' no puede permitir no uso sin exigir observación.'); + if (item.permitir_no_uso === true && !aviso.trim()) errores.push(etiqueta + ': ' + nivel + ' permite no uso y el aviso está vacío.'); + }); + } + } + }); + return { errores: errores, advertencias: advertencias }; +} - _buildAlerta(regla, rtData, mensaje, detalle, override = {}) { - const rt = this._normalizarTexto(this._coalesceField(rtData, ['RT', 'NÚMERO RT', 'NUMERO RT'])); - const proyecto = this._normalizarTexto(this._coalesceField(rtData, ['PROYECTO', 'NOMBRE PROYECTO'])); - const tramo = this._normalizarTexto(this._coalesceField(rtData, ['TRAMO', 'CÓDIGO TRAMO', 'CODIGO TRAMO'])); - const estado = this._normalizarTexto(this._coalesceField(rtData, ['ESTADO PREDIAL AJUSTADO', 'ESTADO PREDIAL', 'ESTADO'])); +function guardarReglasJSON(jsonString, _usuarioIgnorado, versionEsperada) { + const denegado = _exigirAdminMotorReglas_('GUARDAR_REGLAS'); + if (denegado) return denegado; - return { - id: `${regla.codigo}-${rt || 'sin-rt'}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, - reglaId: regla.id, - nombre: regla.nombre, - codigo: regla.codigo, - severidad: override.severidad || regla.severidad, - prioridad: override.prioridad || regla.prioridad, - mensaje, - rt, - proyecto, - tramo, - estado, - fechaEvaluacion: new Date().toISOString(), - detalle, - resuelta: false - }; + const texto = String(jsonString || ''); + if (texto.length > 49000) { + return { success: false, error: 'El JSON supera 49.000 caracteres. Una celda de Sheets no lo puede guardar. Reduce sangría o reglas.' }; } - _evalInconsistenciaFinanciera(rtData, regla) { - const ejecutado = this._parseNumber(this._coalesceField(rtData, ['VALOR PAGADO', 'VALOR EJECUTADO', 'PAGADO', 'EJECUTADO'])); - const programado = this._parseNumber(this._coalesceField(rtData, ['ESTIMADO $', 'VALOR PROGRAMADO', 'PROGRAMADO', 'ESTIMADO'])); - const umbral = Number(regla.parametros && regla.parametros.umbral !== undefined ? regla.parametros.umbral : 0); - - if (programado > 0 && ejecutado > programado + umbral) { - return this._buildAlerta( - regla, - rtData, - 'El valor ejecutado supera el valor programado.', - `Ejecutado: ${ejecutado.toLocaleString('es-CO')} > Programado: ${programado.toLocaleString('es-CO')}`, - { severidad: 'ALERTA', prioridad: regla.prioridad } - ); - } - - return null; + let obj; + try { + obj = JSON.parse(texto); + } catch (parseError) { + return { success: false, error: 'El formato JSON es inválido. Revisa la sintaxis.' }; } - _evalAlertaVencimiento(rtData, regla) { - const diasAlerta = Number(regla.parametros && regla.parametros.dias !== undefined ? regla.parametros.dias : 30); - const fechaCompromiso = this._parseDate(this._coalesceField(rtData, ['FECHA COMPROMISO', 'FECHA DE COMPROMISO', 'FECHA VENCIMIENTO', 'FECHA ESTIMADA DE ENTREGA'])); - if (!fechaCompromiso) return null; - - const diffMs = fechaCompromiso.getTime() - Date.now(); - const diffDias = diffMs / (1000 * 60 * 60 * 24); - - if (diffDias <= diasAlerta && diffDias >= 0) { - return this._buildAlerta( - regla, - rtData, - `Falta poco tiempo para la fecha de compromiso o entrega.`, - `Quedan ${Math.ceil(diffDias)} días para la fecha de compromiso: ${fechaCompromiso.toISOString().slice(0, 10)}`, - { severidad: diffDias <= 7 ? 'CRITICA' : 'ADVERTENCIA', prioridad: regla.prioridad } - ); - } - - return null; + let columnas = []; + try { columnas = _leerEncabezadosDatosReglas_(); } catch (e) { columnas = []; } + const validacion = validarConfigReglas_(obj, columnas); + if (validacion.errores.length) { + return { success: false, error: validacion.errores.join(' | '), errores: validacion.errores, advertencias: validacion.advertencias }; } - _evalFaltaDeEstado(rtData, regla) { - const activoRaw = this._coalesceField(rtData, ['ACTIVO/INACTIVO', 'ACTIVO_INACTIVO', 'ESTADO ACTIVO', 'ESTADO']); - const estadoActual = this._normalizarTexto(this._coalesceField(rtData, ['ESTADO PREDIAL AJUSTADO', 'ESTADO PREDIAL', 'ESTADO'])); - const activo = this._normalizarTexto(activoRaw).toUpperCase(); - const activoFlag = activo === 'ACTIVO' || activo === 'SI' || activo === 'S' || activo === '1'; - - if (activoFlag && (!estadoActual || estadoActual === '')) { - return this._buildAlerta( - regla, - rtData, - 'El RT está activo pero no tiene estado definido.', - 'El registro requiere asignar un estado para activar la clasificación operativa.', - { severidad: 'INFO', prioridad: regla.prioridad } - ); - } - - return null; + const lock = LockService.getScriptLock(); + try { + lock.waitLock(20000); + } catch (lockError) { + return { success: false, message: 'El sistema se encuentra ocupado por otro administrador. Por favor intente de nuevo en unos segundos.' }; } - _evaluarRegla(regla, rtData) { - if (!regla || !regla.activa) return null; - const codigo = String(regla.codigo || '').toUpperCase(); - - switch (codigo) { - case 'INCONSISTENCIA_FINANCIERA': - return this._evalInconsistenciaFinanciera(rtData, regla); - case 'ALERTA_VENCIMIENTO': - return this._evalAlertaVencimiento(rtData, regla); - case 'FALTA_DE_ESTADO': - return this._evalFaltaDeEstado(rtData, regla); - default: - return null; + try { + const fileId = getConfig('DATA_FILES.PRINCIPAL'); + const ss = SpreadsheetApp.openById(fileId); + const hojaReglas = _obtenerHojaConfigReglas_(ss, true, true); + const actual = String(hojaReglas.getRange('B1').getValue() || ''); + if (versionEsperada && _versionReglas_(actual) !== String(versionEsperada)) { + return { success: false, conflicto: true, error: 'Otro administrador modificó las reglas. Recarga antes de guardar.' }; } + hojaReglas.getRange('B1').setValue(texto); + const email = Session.getActiveUser().getEmail(); + try { + const auditoria = new GestorAuditoria(fileId); + auditoria.registrarAccion(email, 'ACTUALIZAR_MOTOR_REGLAS', 'Se actualizó el JSON maestro de reglas de negocio'); + } catch (e) { console.warn('No se pudo auditar:', e); } + return { + success: true, + message: 'Reglas actualizadas correctamente en la base de datos.', + version: _versionReglas_(texto), + advertencias: validacion.advertencias + }; + } catch (error) { + return { success: false, error: 'Error al guardar las reglas: ' + error.message }; + } finally { + try { lock.releaseLock(); } catch (er) {} } +} - evaluarRegistro(rtData) { - if (!rtData || typeof rtData !== 'object') { - return []; - } - - const resultados = []; - for (let i = 0; i < this.reglas.length; i++) { - const regla = this.reglas[i]; - const alerta = this._evaluarRegla(regla, rtData); - if (alerta) { - resultados.push(alerta); - } - } - return resultados; +function obtenerMetadatosEditorReglas() { + const denegado = _exigirAdminMotorReglas_('LEER_METADATOS_REGLAS'); + if (denegado) return denegado; + try { + return { + success: true, + columnas: _leerEncabezadosDatosReglas_(), + operadores: OPERADORES_CONDICION_REGLAS, + niveles: NIVELES_ALERTA_REGLAS, + tiposDias: TIPOS_DIAS_REGLAS + }; + } catch (error) { + return { success: false, error: error.message }; } } - -if (typeof globalThis !== 'undefined') { - globalThis.MotorReglas = MotorReglas; -} \ No newline at end of file diff --git a/organizar_en_carpeta.js b/organizar_en_carpeta.js new file mode 100644 index 0000000..414b692 --- /dev/null +++ b/organizar_en_carpeta.js @@ -0,0 +1,155 @@ +/** + * Organiza los 5 Spreadsheets en una carpeta llamada "PROGRAMAPREDIOS" + * y actualiza las Script Properties con las nuevas ubicaciones + */ +function organizarEnCarpetaProgramaPredios() { + console.log('═══════════════════════════════════════════════════════'); + console.log('📁 ORGANIZANDO ARCHIVOS EN CARPETA "PROGRAMAPREDIOS"'); + console.log('═══════════════════════════════════════════════════════'); + + const FOLDER_NAME = 'PROGRAMAPREDIOS'; + const props = PropertiesService.getScriptProperties(); + + // ✅ [2026-10-06] SEGURIDAD: los IDs ya NO viven aquí en texto plano — este repo estuvo + // público en GitHub. Se resuelven en runtime desde Script Properties (mismo patrón que + // config.js/pac_config.js). Si alguna property no está seteada, esa entrada se omite + // (se reporta y se sigue con las demás, no se detiene todo el proceso). + const CLAVES_ARCHIVOS = { + 'DATA_FILES_PRINCIPAL_ID': 'Sistema Predial IDU - Principal', + 'DATA_FILES_LOGS_ID': 'Sistema Predial IDU - Logs', + 'DATA_FILES_USUARIOS_ID': 'Sistema Predial IDU - Usuarios', + 'MAESTRO_PERMISOS_ID': 'Sistema Predial IDU - Permisos RBAC', + // ✅ [2026-10-06] CORRECCIÓN: "Sistema Predial IDU - PAC" es el archivo que apunta + // PAC_DESTINO_SPREADSHEET_ID (destino de escritura de PAC_Vigente/Borrador/etc, ver + // Sección 46), NO PAC_SPREADSHEET_ID. Esa clave está destinada a cambiar a la fuente + // externa real (1i4Qt..., ver pendientes), un archivo que esta función no debe mover + // a la carpeta PROGRAMAPREDIOS porque no es propiedad de este sistema. + 'PAC_DESTINO_SPREADSHEET_ID': 'Sistema Predial IDU - PAC' + }; + const archivos = {}; + Object.keys(CLAVES_ARCHIVOS).forEach(function (clave) { + const id = getConfigProperty(clave, ''); + if (!id) { + console.warn('⚠️ ' + clave + ' no está configurada — se omite ' + CLAVES_ARCHIVOS[clave] + '.'); + return; + } + archivos[clave] = { id: id, nombre: CLAVES_ARCHIVOS[clave] }; + }); + + try { + // 1. Buscar o crear la carpeta "PROGRAMAPREDIOS" + console.log('\n📂 PASO 1: Buscando/creando carpeta "PROGRAMAPREDIOS"...'); + + let folder; + const folders = DriveApp.getFoldersByName(FOLDER_NAME); + + if (folders.hasNext()) { + folder = folders.next(); + console.log(` ✅ Carpeta encontrada: ${folder.getName()}`); // ✅ [2026-10-06] SEGURIDAD: ya no imprime ID/URL de la carpeta + } else { + folder = DriveApp.createFolder(FOLDER_NAME); + console.log(` ✅ Carpeta creada: ${folder.getName()}`); + } + + // 2. Mover cada archivo a la carpeta + console.log('\n📦 PASO 2: Moviendo archivos a la carpeta...'); + + let movidosCount = 0; + + for (const [key, info] of Object.entries(archivos)) { + try { + const file = DriveApp.getFileById(info.id); + const nombreActual = file.getName(); + + // Verificar si ya está en la carpeta + const parents = file.getParents(); + let yaEnCarpeta = false; + + while (parents.hasNext()) { + const parent = parents.next(); + if (parent.getId() === folder.getId()) { + yaEnCarpeta = true; + break; + } + } + + if (yaEnCarpeta) { + console.log(` ⚠️ ${nombreActual}: Ya está en la carpeta`); + } else { + // Mover a la carpeta + file.moveTo(folder); + console.log(` ✅ ${nombreActual}: Movido exitosamente`); + movidosCount++; + } + + } catch (error) { + console.error(` ❌ Error moviendo ${info.nombre}: ${error.message}`); + } + } + + // 3. Resumen + console.log('\n═══════════════════════════════════════════════════════'); + console.log('📊 RESUMEN'); + console.log('═══════════════════════════════════════════════════════'); + console.log(` 📁 Carpeta: ${folder.getName()}`); // ✅ [2026-10-06] SEGURIDAD: ya no imprime ID/URL de la carpeta + console.log(` 📦 Archivos movidos: ${movidosCount}/${Object.keys(archivos).length}`); + console.log('\n✅ ORGANIZACIÓN COMPLETADA'); + console.log('═══════════════════════════════════════════════════════'); + + // Retornar información de la carpeta + return { + folderId: folder.getId(), + folderUrl: folder.getUrl(), + folderName: folder.getName(), + archivosMoved: movidosCount + }; + + } catch (error) { + console.error(`❌ Error: ${error.message}`); + throw error; + } +} + +/** + * Obtiene las URLs de todos los archivos organizados. + * ⚠️ [2026-10-06] SEGURIDAD: esta función, a propósito, SÍ imprime URLs completas (que + * incluyen el ID del spreadsheet) — es su función: darle al usuario un enlace clicable. + * Ejecutarla deja esos IDs en el log de ejecución de Apps Script, visible para cualquier + * editor del proyecto. Ejecutar solo cuando de verdad se necesiten los enlaces, no como + * parte de un diagnóstico de rutina. Los IDs ya no están hardcodeados aquí — se resuelven + * desde Script Properties, igual que en el resto del proyecto. + */ +function obtenerURLsArchivos() { + console.log('═══════════════════════════════════════════════════════'); + console.log('🔗 URLS DE LOS ARCHIVOS (⚠️ este log incluirá los IDs reales)'); + console.log('═══════════════════════════════════════════════════════\n'); + + // ✅ [2026-10-06] CORRECCIÓN: PAC_DESTINO_SPREADSHEET_ID, no PAC_SPREADSHEET_ID — mismo motivo que en organizarEnCarpetaProgramaPredios() arriba. + const CLAVES = ['DATA_FILES_PRINCIPAL_ID', 'DATA_FILES_LOGS_ID', 'DATA_FILES_USUARIOS_ID', 'MAESTRO_PERMISOS_ID', 'PAC_DESTINO_SPREADSHEET_ID']; + const urls = {}; + + CLAVES.forEach(function (key) { + const id = getConfigProperty(key, ''); + if (!id) { + console.warn(`⚠️ ${key} no está configurada, se omite.`); + return; + } + try { + const file = DriveApp.getFileById(id); + const url = file.getUrl(); + const nombre = file.getName(); + + urls[key] = { nombre: nombre, id: id, url: url }; + + console.log(`✅ ${nombre}`); + console.log(` URL: ${url}\n`); + + } catch (error) { + console.error(`❌ Error obteniendo ${key}: ${error.message}\n`); + } + }); + + console.log('═══════════════════════════════════════════════════════'); + + return urls; +} diff --git a/pac_api.js b/pac_api.js index 7d93c63..f9218c2 100644 --- a/pac_api.js +++ b/pac_api.js @@ -4,6 +4,11 @@ function getPACData(filtros, modoEjecucion) { try { + // ✅ SPRINT6-FASE-0: Validación RBAC server-side + // getPACData hace ESCRITURA (sincroniza estados), así requiere EDITAR + const gestorPermisos = new GestorPermisos(); + gestorPermisos.validarPermiso('EDITAR'); // Lanza error si no tiene permiso + filtros = filtros || {}; modoEjecucion = modoEjecucion || PAC_CONFIG.MODOS_EJECUCION.RADICADO; @@ -310,27 +315,41 @@ function pac_construirReporteSeguimientoHTML(usuario, registros, data) { function sincronizarPACApi() { try { - pac_verificarRolAdmin(); + // ✅ SPRINT6-FASE-0: Validación RBAC server-side + pac_verificarRolAdmin(); // Valida PAC_APROBAR (requiere ADMIN) return JSON.stringify(sincronizarPAC()); } catch(e) { + // ✅ Registrar intento denegado + const userEmail = Session.getActiveUser().getEmail(); + logAction(userEmail, 'INTENTO_SINCRONIZAR_PAC_DENEGADO', e.message); return JSON.stringify({ success:false, error:e.message }); } } function aprobarBorradorPACApi(observacion) { try { - pac_verificarRolAdmin(); + // ✅ SPRINT6-FASE-0: Validación RBAC server-side + pac_verificarRolAdmin(); // Valida PAC_APROBAR (requiere ADMIN) return JSON.stringify(aprobarBorradorPAC(observacion)); } catch(e) { + // ✅ Registrar intento denegado + const userEmail = Session.getActiveUser().getEmail(); + logAction(userEmail, 'INTENTO_APROBAR_PAC_DENEGADO', e.message); return JSON.stringify({ success:false, error:e.message }); } } +/** + * ✅ SPRINT6-FASE-0: Verifica que usuario actual sea ADMIN para operaciones PAC. + * Utiliza el nuevo sistema de RBAC server-side. + */ function pac_verificarRolAdmin() { try { - if (typeof CONFIG === 'undefined') return; - if (typeof verificarRol === 'function') verificarRol(CONFIG.ROLES.ADMIN); + const gestorPermisos = new GestorPermisos(); + // Para operaciones PAC críticas, se requiere rol ADMIN específicamente + // (no basta con tener permiso 'EDITAR', se necesita ADMIN_SISTEMA o PAC_APROBAR) + gestorPermisos.validarPermiso('PAC_APROBAR'); // Lanza error si no es ADMIN } catch(e) { - throw new Error('Acceso denegado: ' + e.message); + throw new Error('❌ Acceso denegado a operación PAC crítica: ' + e.message); } } diff --git a/pac_config.js b/pac_config.js index b525292..6f92542 100644 --- a/pac_config.js +++ b/pac_config.js @@ -11,8 +11,14 @@ // spreadsheet, no un ID distinto) — no crear una property nueva para este valor. Ver // DOCUMENTACION_TECNICA_VIVA.md, sección "Migración de IDs sensibles a Script Properties". const PAC_CONFIG = { - SS_PADRE_ID: getConfigProperty('MAESTRO_PERMISOS_ID', ''), - PAC_SPREADSHEET_ID: getConfigProperty('PAC_SPREADSHEET_ID', ''), + SS_PADRE_ID: getConfigProperty('MAESTRO_PERMISOS_ID', ''), // ⚠️ [2026-10-06]: ya NO lo usa pac_getSpreadsheet() como respaldo (ver fix abajo) — queda solo por si algo externo a este archivo todavía lo referencia. + PAC_SPREADSHEET_ID: getConfigProperty('PAC_SPREADSHEET_ID', ''), // Fuente EXTERNA de solo lectura (hojas "PAC IDU"/"PAC TRANSMILENIO"), usada únicamente por pac_leerHojaExterna(). NO es el destino de escritura. + // ✅ [2026-10-06] FIX: PAC_Vigente/PAC_Borrador/PAC_ReglasPAC/etc. se escribían en lo que + // devolviera pac_getSpreadsheet() (spreadsheet activo, o si no había ninguno, SS_PADRE_ID — + // es decir, el spreadsheet de permisos). Desde la migración multi-spreadsheet [Sección 43], + // el destino real es este spreadsheet dedicado, resuelto en runtime vía Script Property + // (no hardcodear el ID aquí, mismo criterio de seguridad que SS_PADRE_ID/PAC_SPREADSHEET_ID). + PAC_DESTINO_SPREADSHEET_ID: getConfigProperty('PAC_DESTINO_SPREADSHEET_ID', ''), // --- ⚠️ AGREGAR ESTE BLOQUE FALTANTE ⚠️ --- HOJAS_INTERNAS: { @@ -142,16 +148,16 @@ var _PAC_RUNTIME_CACHE = { articuladores: null }; +// ✅ [2026-10-06] FIX: antes caía a getActiveSpreadsheet() (nunca se cumplía — este es un +// proyecto independiente, sin appsscript.json/.clasp.json con parentId) y de ahí a SS_PADRE_ID +// (el spreadsheet de permisos), mezclando las hojas internas de PAC con datos de permisos. +// Ahora exige explícitamente PAC_DESTINO_SPREADSHEET_ID — sin fallback silencioso. function pac_getSpreadsheet() { if (_PAC_SS_CACHE) return _PAC_SS_CACHE; - try { - var ss = SpreadsheetApp.getActiveSpreadsheet(); - if (ss) { - _PAC_SS_CACHE = ss; - return ss; - } - } catch(e) {} - _PAC_SS_CACHE = SpreadsheetApp.openById(PAC_CONFIG.SS_PADRE_ID); + if (!PAC_CONFIG.PAC_DESTINO_SPREADSHEET_ID) { + throw new Error('PAC_DESTINO_SPREADSHEET_ID no está configurado. PAC_Vigente/PAC_Borrador/PAC_ReglasPAC no tienen destino válido. Setee la Script Property PAC_DESTINO_SPREADSHEET_ID antes de sincronizar o leer datos de PAC.'); + } + _PAC_SS_CACHE = SpreadsheetApp.openById(PAC_CONFIG.PAC_DESTINO_SPREADSHEET_ID); return _PAC_SS_CACHE; } diff --git a/pac_gestor.js b/pac_gestor.js index 833811f..3017e33 100644 --- a/pac_gestor.js +++ b/pac_gestor.js @@ -812,10 +812,10 @@ function sincronizarPAC() { }); if (todasLasFilas.length === 0) { + // ✅ [2026-10-06] SEGURIDAD: ya no imprime el ID del archivo externo en el mensaje respuesta.mensaje = 'Ninguna fuente PAC devolvió datos. ' + 'Verifique que las hojas "PAC IDU" y "PAC TRANSMILENIO" ' + - 'existan en el archivo externo (ID: ' + - PAC_CONFIG.PAC_SPREADSHEET_ID + ').'; + 'existan en el archivo externo configurado en PAC_SPREADSHEET_ID.'; pac_log('sincronizarPAC: ' + respuesta.mensaje, 'ADVERTENCIA'); return respuesta; } @@ -831,21 +831,45 @@ function sincronizarPAC() { if (!hojaVigente) hojaVigente = ss.insertSheet(nombreVigente); if (!hojaBorrador) hojaBorrador = ss.insertSheet(nombreBorrador); + // ✅ [2026-10-06] Tarea S: filas de PAC_Vigente ANTES de comparar, para la guarda de + // seguridad de abajo (porcentaje de eliminación). _pac_compararYGenerarBorrador() no + // toca PAC_Vigente, así que este conteo es válido tanto antes como después de llamarla. + const filasVigentesAntes = Math.max(0, hojaVigente.getLastRow() - 1); + // ── FIX #3: Pasar el array de filas (objetos planos) al comparador ──── const diff = _pac_compararYGenerarBorrador(hojaVigente, hojaBorrador, todasLasFilas); - respuesta.success = true; - respuesta.cambios = diff.totalCambios || 0; - respuesta.nuevos = diff.nuevos || 0; - respuesta.modificados = diff.modificados || 0; - respuesta.eliminados = diff.eliminados || 0; + respuesta.success = true; + respuesta.cambios = diff.totalCambios || 0; + respuesta.nuevos = diff.nuevos || 0; + respuesta.modificados = diff.modificados || 0; + respuesta.eliminados = diff.eliminados || 0; + respuesta.duplicadosDescartados = diff.duplicadosDescartados || 0; respuesta.mensaje = diff.totalCambios === 0 ? 'Sincronización exitosa. ' + todasLasFilas.length + ' registros procesados sin cambios.' : diff.totalCambios + ' cambio(s) detectado(s) en ' + todasLasFilas.length + ' registros.'; + // ✅ [2026-10-06] Tarea S: guarda de seguridad — si el borrador eliminaría más del 20% + // de las filas actuales de PAC_Vigente, NUNCA se autoaprueba, sin importar el caso. + // Requiere revisión manual explícita del borrador antes de aprobar. + const UMBRAL_ELIMINACION_MAXIMA = 0.20; + const porcentajeEliminado = filasVigentesAntes > 0 ? (diff.eliminados / filasVigentesAntes) : 0; + if (diff.totalCambios === 0) { respuesta.aprobadoAutomaticamente = true; try { aprobarBorradorPAC('Auto-aprobado: sin cambios'); } catch(e) { /* no crítico */ } + } else if (porcentajeEliminado > UMBRAL_ELIMINACION_MAXIMA) { + respuesta.aprobadoAutomaticamente = false; + respuesta.ok = true; + respuesta.requiereRevision = true; + respuesta.eliminadas = diff.eliminados; + respuesta.nuevas = diff.nuevos; + respuesta.total = todasLasFilas.length; + respuesta.mensaje = 'GUARDA DE SEGURIDAD: el borrador eliminaría ' + diff.eliminados + + ' de ' + filasVigentesAntes + ' filas actuales de PAC_Vigente (' + + (porcentajeEliminado * 100).toFixed(1) + '%), por encima del umbral de seguridad (20%). ' + + 'NO se aprobó automáticamente. Revise la hoja PAC_Borrador manualmente antes de aprobar.'; + pac_log('sincronizarPAC: ' + respuesta.mensaje, 'ADVERTENCIA'); } else { respuesta.aprobadoAutomaticamente = false; } @@ -860,6 +884,46 @@ function sincronizarPAC() { } } +/** + * ✅ [2026-10-06] FIX Tarea S: respalda PAC_Vigente en una hoja oculta + * "BAK__" dentro del mismo spreadsheet destino, antes de + * que aprobarBorradorPAC() la sobrescriba. Conserva solo los 3 respaldos más recientes + * de este prefijo (borra los demás). No crítico: si falla, se registra y la aprobación + * continúa igual — no bloquea la operación principal por un respaldo fallido. + */ +function _pac_respaldarVigenteAntesDeAprobar(ss, hojaVigente, nombreVigente) { + try { + if (hojaVigente.getLastRow() < 1) return; // nada que respaldar + + const zona = Session.getScriptTimeZone() || 'America/Bogota'; + const timestamp = Utilities.formatDate(new Date(), zona, 'yyyyMMdd_HHmm'); + const prefijo = 'BAK_' + nombreVigente + '_'; + const nombreBak = prefijo + timestamp; + + const copia = hojaVigente.copyTo(ss); + copia.setName(nombreBak); + try { copia.hideSheet(); } catch (e) { /* no crítico */ } + pac_log('_pac_respaldarVigenteAntesDeAprobar: respaldo creado (' + nombreBak + ').'); + + // Conservar solo los 3 respaldos más recientes de este prefijo (orden alfabético del + // timestamp yyyyMMdd_HHmm coincide con orden cronológico). + const backups = ss.getSheets() + .filter(function (h) { return h.getName().indexOf(prefijo) === 0; }) + .sort(function (a, b) { return a.getName() < b.getName() ? 1 : -1; }); // más reciente primero + + backups.slice(3).forEach(function (h) { + try { + ss.deleteSheet(h); + pac_log('_pac_respaldarVigenteAntesDeAprobar: respaldo antiguo eliminado (' + h.getName() + ').'); + } catch (e) { + pac_log('_pac_respaldarVigenteAntesDeAprobar: no se pudo eliminar respaldo antiguo ' + h.getName() + ': ' + e.message, 'ADVERTENCIA'); + } + }); + } catch (e) { + pac_log('_pac_respaldarVigenteAntesDeAprobar ERROR (no crítico, la aprobación continúa): ' + e.message, 'ERROR'); + } +} + /** * aprobarBorradorPAC() — v2.2 corregida * FIX #2: Usa PAC_CONFIG.HOJAS_INTERNAS.VIGENTE y .BORRADOR (nombres reales) @@ -887,6 +951,10 @@ function aprobarBorradorPAC(motivo) { return { success: false, mensaje: 'No se encontró la hoja "' + nombreVigente + '".' }; } + // ✅ [2026-10-06] FIX Tarea S: respaldo de PAC_Vigente ANTES de sobrescribirla. + // Conserva solo los 3 respaldos más recientes (ver _pac_respaldarVigenteAntesDeAprobar). + _pac_respaldarVigenteAntesDeAprobar(ss, hojaVigente, nombreVigente); + const datosBorrador = hojaBorrador.getDataRange().getValues(); hojaVigente.clearContents(); hojaVigente.getRange(1, 1, datosBorrador.length, datosBorrador[0].length) @@ -954,7 +1022,7 @@ function rechazarBorradorPAC() { * El borrador se escribe aplanando los datos mensuales en columnas. */ function _pac_compararYGenerarBorrador(hojaVigente, hojaBorrador, filasExternas) { - var res = { totalCambios: 0, nuevos: 0, modificados: 0, eliminados: 0 }; + var res = { totalCambios: 0, nuevos: 0, modificados: 0, eliminados: 0, duplicadosDescartados: 0 }; // ✅ SEC-P1.5: LockService — genera/reescribe PAC_Borrador (escritura masiva compartida) var lock = LockService.getScriptLock(); @@ -962,6 +1030,27 @@ function _pac_compararYGenerarBorrador(hojaVigente, hojaBorrador, filasExternas) lock.waitLock(20000); hojaBorrador.clearContents(); + // ✅ [2026-10-06] FIX Tarea S: deduplicar filasExternas por RT ANTES de comparar o + // construir el borrador. Si la fuente externa trae RT repetidos, se conserva solo la + // primera aparición de cada uno; el resto se descarta y se cuenta en + // res.duplicadosDescartados (nunca se imprime el contenido de la fila, solo el conteo). + (function deduplicarPorRT() { + var vistos = {}; + var filasUnicas = []; + filasExternas.forEach(function(item) { + var rt = String(item.RT || '').trim(); + if (!rt) { filasUnicas.push(item); return; } // sin RT no se puede deduplicar por clave, se conserva tal cual + if (vistos[rt]) { res.duplicadosDescartados++; return; } + vistos[rt] = true; + filasUnicas.push(item); + }); + if (res.duplicadosDescartados > 0) { + pac_log('_pac_compararYGenerarBorrador: ' + res.duplicadosDescartados + + ' fila(s) duplicada(s) por RT descartada(s) de la fuente externa.', 'ADVERTENCIA'); + } + filasExternas = filasUnicas; // todo lo siguiente usa la lista ya deduplicada + })(); + // ── 1. Detectar nuevos y eliminados ─────────────────────────────────── var mapaVigente = {}; if (hojaVigente.getLastRow() > 1) { diff --git a/pac_seccion.html b/pac_seccion.html index f482be9..775fff7 100644 --- a/pac_seccion.html +++ b/pac_seccion.html @@ -1465,12 +1465,34 @@