{ "name": "Cruce de Seguridad Social - Guatemala", "nodes": [ { "parameters": { "formTitle": "Cruce Seguridad Social Guatemala", "formDescription": "Sube las dos nóminas de Guatemala del mes, el archivo Sistema Propio IGSS y la Planilla Consolidada mensual. El flujo generará un Google Sheets con hallazgos de afiliación y montos.", "formFields": { "values": [ { "fieldLabel": "Mes a validar", "fieldType": "dropdown", "fieldOptions": { "values": [ { "option": "01" }, { "option": "02" }, { "option": "03" }, { "option": "04" }, { "option": "05" }, { "option": "06" }, { "option": "07" }, { "option": "08" }, { "option": "09" }, { "option": "10" }, { "option": "11" }, { "option": "12" } ] }, "requiredField": true }, { "fieldLabel": "Año a validar", "fieldType": "number", "requiredField": true }, { "fieldLabel": "Nómina Q1", "fieldType": "file", "multipleFiles": false, "acceptFileTypes": ".xlsx, .xls, .xlsm, .csv", "requiredField": true }, { "fieldLabel": "Nómina Q2", "fieldType": "file", "multipleFiles": false, "acceptFileTypes": ".xlsx, .xls, .xlsm, .csv", "requiredField": true }, { "fieldLabel": "Sistema Propio IGSS", "fieldType": "file", "multipleFiles": false, "acceptFileTypes": ".xlsx, .xls, .xlsm, .csv", "requiredField": true }, { "fieldLabel": "Planilla Consolidada", "fieldType": "file", "multipleFiles": false, "acceptFileTypes": ".xlsx, .xls, .xlsm, .csv", "requiredField": true } ] }, "options": { "ignoreBots": true } }, "type": "n8n-nodes-base.formTrigger", "typeVersion": 2.3, "position": [ 8128, 2368 ], "id": "92ada1f0-69df-4a58-ab94-e9ebacd28ec3", "name": "On form submission", "webhookId": "109715d7-2a28-4e4c-b0c9-2e039a1f0a3a" }, { "parameters": { "jsCode": "const item = $input.all()[0];\nconst now = new Date();\nconst pad = n => String(n).padStart(2, '0');\nconst ts = `${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;\nconst runId = `GT_SEG_${ts}_${Math.random().toString(36).slice(2, 8)}`;\n\nconst mes = String(item.json['Mes a validar'] || '').padStart(2, '0');\nconst anio = String(item.json['Año a validar'] || item.json['Ano a validar'] || now.getFullYear()).trim();\nconst meses = {\n '01':'Enero','02':'Febrero','03':'Marzo','04':'Abril',\n '05':'Mayo','06':'Junio','07':'Julio','08':'Agosto',\n '09':'Septiembre','10':'Octubre','11':'Noviembre','12':'Diciembre'\n};\nconst fechaTitulo = `${pad(now.getDate())}-${pad(now.getMonth()+1)}-${now.getFullYear()}`;\nconst periodoLabel = `${meses[mes] || mes} ${anio}`;\nconst reportTitle = `Cruce Seguridad Social Guatemala - ${periodoLabel} - ${fechaTitulo}`;\n\nconst data = $getWorkflowStaticData('global');\ndata.gtSeguridadSocial = data.gtSeguridadSocial || {};\ndata.gtSeguridadSocial[runId] = {\n startedAt: now.toISOString(),\n mes,\n anio,\n periodoLabel,\n files: { q1:'', q2:'', sistema:'', consolidado:'' },\n nomina: [],\n ajustes: [],\n empleados: [],\n planillaMensual: [],\n bamboo: []\n};\n\nreturn [{\n json: { ...item.json, runId, mes, anio, periodoLabel, reportTitle, emailTo: 'msoto@gomezleemarketing.com, iaracena@gomezleemarketing.com, iherrera@gomezleemarketing.com, vparamo@gomezleemarketing.com, asrodriguez@gomezleemarketing.com, mgomez@gomezleemarketing.com, ymadera@gomezleemarketing.com' },\n binary: item.binary\n}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 8368, 2368 ], "id": "25a92da5-24bf-4b26-b084-afc571cf5f4a", "name": "Inicializar memoria" }, { "parameters": { "jsCode": "const init = $items('Inicializar memoria')[0];\nconst bin = init.binary || {};\nconst type = 'q1';\nfunction norm(s) { return String(s || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase(); }\nfunction looksExcel(fileName, mime) { const lower = norm(fileName); const m = norm(mime); return lower.endsWith('.xlsx') || lower.endsWith('.xls') || lower.endsWith('.xlsm') || lower.endsWith('.csv') || m.includes('spreadsheet') || m.includes('excel') || m.includes('csv'); }\nfunction scoreCandidate(key, fileName) {\n const text = norm(`${key} ${fileName}`);\n let score = 0;\n if (type === 'q1') { if (text.includes('nomina')) score += 20; if (/\\b1q\\b|q1|primera|mayo 15|15 2026/.test(text)) score += 120; if (/\\b2q\\b|q2|segunda|mayo 30|30 2026/.test(text)) score -= 150; if (/sistema|consolidada|consolidado/.test(text)) score -= 200; }\n if (type === 'q2') { if (text.includes('nomina')) score += 20; if (/\\b2q\\b|q2|segunda|mayo 30|30 2026/.test(text)) score += 120; if (/\\b1q\\b|q1|primera|mayo 15|15 2026/.test(text)) score -= 150; if (/sistema|consolidada|consolidado/.test(text)) score -= 200; }\n if (type === 'sistema') { if (/sistema|propio|2\\.2|igss/.test(text)) score += 120; if (/consolidada|consolidado|planilla consolidada|nomina/.test(text)) score -= 80; }\n if (type === 'consolidado') { if (/consolidada|consolidado|planilla consolidada/.test(text)) score += 120; if (/sistema|propio|2\\.2|nomina/.test(text)) score -= 80; }\n return score;\n}\nconst candidates = [];\nfor (const [key, file] of Object.entries(bin)) {\n const fileName = String(file.fileName || key);\n const mime = String(file.mimeType || '');\n if (!looksExcel(fileName, mime)) continue;\n candidates.push({ key, file, fileName, score: scoreCandidate(key, fileName) });\n}\ncandidates.sort((a,b) => b.score - a.score);\nconst chosen = candidates[0];\nif (!chosen || chosen.score <= 0) throw new Error(`No encontré el archivo requerido para ${type}. Revisa los campos del formulario y los nombres de archivo.`);\nconst data = $getWorkflowStaticData('global');\nconst slot = data.gtSeguridadSocial?.[init.json.runId];\nif (slot) slot.files[type] = chosen.fileName;\nreturn [{ json: { runId: init.json.runId, fileType: type, fileName: chosen.fileName }, binary: { data: chosen.file } }];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 8608, 2368 ], "id": "93f0bc45-0145-4a93-96c5-e0a524afb2b9", "name": "Preparar Nómina 1Q" }, { "parameters": { "operation": "update", "fileId": { "__rl": true, "value": "1za7ZdPAGsEbfktl5tuC6LbEZ8f7tiTCi_BszQeA-EO4", "mode": "list", "cachedResultName": "Nomina Guatemala", "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1za7ZdPAGsEbfktl5tuC6LbEZ8f7tiTCi_BszQeA-EO4/edit" }, "changeFileContent": true, "options": {} }, "type": "n8n-nodes-base.googleDrive", "typeVersion": 3, "position": [ 8976, 2368 ], "id": "be6acc75-0da2-40cd-b194-5d2ecfa4c9fd", "name": "Actualizar staging - Nómina 1Q", "credentials": { "googleDriveOAuth2Api": { "id": "g23xdGLZRzBGqKgH", "name": "Isaac - Google Drive" } } }, { "parameters": { "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.id || '1za7ZdPAGsEbfktl5tuC6LbEZ8f7tiTCi_BszQeA-EO4' }}?fields=spreadsheetId,sheets(properties(title,sheetId,hidden))", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleSheetsOAuth2Api", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 9584, 2368 ], "id": "b56ae934-213a-43bd-b0b8-73189146f745", "name": "Listar hojas - Nómina 1Q", "credentials": { "googleSheetsOAuth2Api": { "id": "AM9qcemWjo0CZB83", "name": "Google Sheets - Isaac" } } }, { "parameters": { "jsCode": "const init = $items('Inicializar memoria')[0].json;\nconst spreadsheetId = $json.spreadsheetId || $json.id || '1za7ZdPAGsEbfktl5tuC6LbEZ8f7tiTCi_BszQeA-EO4';\n\nfunction norm(s) {\n return String(s || '')\n .normalize('NFD').replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase()\n .replace(/[^a-z0-9]/g, '');\n}\nfunction shouldReadPayrollSheet(title) {\n const n = norm(title);\n\n // Primero se excluyen copias y hojas auxiliares. Abril 2026 trae\n // \"Copia de 1) Nomina General 2\"; si se permite por includes('nominageneral'),\n // duplica Q1 y genera diferencias falsas.\n const excluded = [\n 'calculos', 'resumen', 'temporales', 'auditorias', 'auditoria',\n 'bono', 'movilidad', 'viaticos', 'viatico', 'combustible',\n 'combustibles', 'tabla', 'dinamica', 'copia', 'copy', 'boletas',\n 'nvscripts', 'autocrat', 'donotdelete', 'bajas'\n ];\n if (excluded.some(x => n.includes(x))) return false;\n\n // Solo la hoja principal real. No usar includes para evitar hojas copiadas.\n if (n === '1nominageneral' || n === 'nominageneral') return true;\n\n // Cualquier otra hoja no excluida se leerá como posible ajuste/complemento,\n // pero luego el nodo de parseo valida que tenga encabezado real de nómina.\n return true;\n}\n\nconst sheets = ($json.sheets || []).map(s => s.properties || {});\nconst selected = sheets\n .filter(p => p.title && shouldReadPayrollSheet(p.title))\n .map(p => p.title);\n\nif (!selected.some(t => norm(t).includes('nominageneral'))) {\n throw new Error(`No encontré la hoja 1) Nomina General. Hojas disponibles: ${sheets.map(s => s.title).join(', ')}`);\n}\nif (!selected.length) {\n throw new Error(`No encontré hojas válidas para leer en la nómina. Hojas disponibles: ${sheets.map(s => s.title).join(', ')}`);\n}\n\nreturn [{\n json: {\n runId: init.runId,\n spreadsheetId,\n sheetTitles: selected,\n ranges: selected.map(t => `'${String(t).replace(/'/g, \"''\")}'!A:AZ`)\n }\n}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 9824, 2368 ], "id": "7640a1ed-965b-4a52-b651-ce8faa210a43", "name": "Resolver hoja Nómina 1Q" }, { "parameters": { "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $json.spreadsheetId + '/values:batchGet?' + $json.ranges.map(r => 'ranges=' + encodeURIComponent(r)).join('&') + '&valueRenderOption=UNFORMATTED_VALUE&dateTimeRenderOption=SERIAL_NUMBER' }}", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleSheetsOAuth2Api", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 10080, 2368 ], "id": "b36c03d2-4dc8-44e6-9131-ffd9d824d8cb", "name": "Leer Nómina 1Q", "credentials": { "googleSheetsOAuth2Api": { "id": "K0hDZh3a85MpOHCs", "name": "Google Sheets account 2" } } }, { "parameters": { "jsCode": "const qLabel = '1Q';\nconst init = $items('Inicializar memoria')[0].json;\nconst valueRanges = $json.valueRanges || [];\n\nfunction clean(s) { return String(s ?? '').replace(/\\s+/g, ' ').trim(); }\nfunction removeAccents(s) { return String(s || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, ''); }\nfunction normSheet(s) { return removeAccents(s).toLowerCase().replace(/[^a-z0-9]/g, ''); }\nfunction normalizeName(s) {\n return clean(s)\n .normalize('NFD').replace(/[\\u0300-\\u036f]/g, '')\n .toUpperCase()\n .replace(/[^A-Z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\nfunction toNumber(v) {\n if (v === null || v === undefined || v === '') return 0;\n if (typeof v === 'number') return Number.isFinite(v) ? v : 0;\n let s = String(v).trim().replace(/Q|\\$|USD|GTQ|,/gi, '').replace(/\\s/g, '');\n if (/^\\((.*)\\)$/.test(s)) s = '-' + s.replace(/^\\(|\\)$/g, '');\n const n = Number(s);\n return Number.isFinite(n) ? n : 0;\n}\nfunction normalizeAf(v) {\n const raw = clean(v);\n if (!raw || /pendiente|n\\/a|na|sin/i.test(raw)) {\n return { value:'', status: raw ? 'Pendiente/Vacío' : 'Vacío', raw };\n }\n let digits = '';\n if (typeof v === 'number') {\n digits = Math.trunc(v).toString();\n } else {\n const s = raw.replace(/,/g, '').trim();\n if (/e\\+?/i.test(s)) {\n const n = Number(s);\n if (Number.isFinite(n)) digits = Math.trunc(n).toString();\n } else {\n digits = s.replace(/\\D/g, '');\n }\n }\n if (!digits || /^0+$/.test(digits)) return { value:'', status:'Inválido', raw };\n if (digits.length < 8 || digits.length > 13) return { value:'', status:'Longitud inválida', raw };\n return { value:digits, status: /e\\+?/i.test(String(raw)) ? 'Válido científico' : 'Válido', raw };\n}\nfunction sheetNameFromRange(range) {\n let left = String(range || '').split('!')[0] || '';\n left = left.replace(/^'/, '').replace(/'$/, '').replace(/''/g, \"'\");\n return left;\n}\nfunction isMainSheet(title) {\n const n = normSheet(title);\n // Solo la hoja principal real; NO contar copias como \"Copia de 1) Nomina General 2\".\n return n === '1nominageneral' || n === 'nominageneral';\n}\nfunction looksLikePayrollSheet(values) {\n const header = (values[0] || []).map(v => removeAccents(clean(v)).toLowerCase().replace(/[^a-z0-9]/g, ''));\n return header[0] === 'proyecto'\n && header[5] === 'igss'\n && (header[7] === 'nombrecompleto' || header[7].includes('nombre'))\n && header[14].includes('salario')\n && header[19].includes('factor');\n}\nfunction parsePayrollRows(values, sheetTitle, sourceType) {\n const records = [];\n if (!looksLikePayrollSheet(values)) return records;\n let seen = false;\n for (let i = 1; i < values.length; i++) {\n const row = values[i] || [];\n const proyecto = clean(row[0]);\n const moneda = clean(row[1]);\n const igss = normalizeAf(row[5]);\n const nombre = clean(row[7]);\n\n if (seen && !proyecto && !nombre && !clean(row[5])) break;\n if (!proyecto || !nombre || /^total/i.test(proyecto) || /^total/i.test(nombre)) continue;\n seen = true;\n\n const factor = clean(row[19]);\n const factorNorm = removeAccents(factor).toLowerCase();\n const factorSi = /^(si|sí|yes|y|true|1)$/i.test(factorNorm);\n\n const salario = toNumber(row[14]);\n const vacaciones = toNumber(row[16]);\n const horasExtras = toNumber(row[18]);\n const variable = toNumber(row[21]);\n const montoAfectoOriginal = salario + vacaciones + horasExtras + (factorSi ? variable : 0);\n if (Math.abs(montoAfectoOriginal) <= 0.004 && sourceType === 'ajuste_candidato') continue;\n\n records.push({\n q: qLabel,\n sheetTitle,\n rowNumber: i + 1,\n sourceType,\n proyecto_nomina: proyecto,\n moneda,\n nombre_nomina: nombre,\n nombre_norm: normalizeName(nombre),\n igss_original: igss.raw,\n igss_norm: igss.value,\n igss_estado: igss.status,\n factor_prestacional: factor,\n salario_periodo: Number(salario.toFixed(2)),\n vacaciones: Number(vacaciones.toFixed(2)),\n horas_extras: Number(horasExtras.toFixed(2)),\n variable: Number(variable.toFixed(2)),\n variable_afecta: factorSi ? 'Sí' : 'No',\n monto_afecto_original: Number(montoAfectoOriginal.toFixed(2)),\n monto_afecto_gtq: Number(montoAfectoOriginal.toFixed(2)),\n regla_aplicada: factorSi ? 'Sueldo + vacaciones + horas extras + variable' : 'Sueldo + vacaciones + horas extras'\n });\n }\n return records;\n}\nfunction samePerson(a, b) {\n if (a.igss_norm && b.igss_norm && a.igss_norm === b.igss_norm) return true;\n return normalizeName(a.nombre_nomina) === normalizeName(b.nombre_nomina);\n}\nfunction amountEquals(a, b) {\n return Math.abs(Number(a || 0) - Number(b || 0)) <= 0.05;\n}\nfunction deriveAdjustments(baseRecords, candidateRecords) {\n const ajustes = [];\n\n function pushAdjustment(c, amount, tipo, referencia, observacion) {\n if (Math.abs(amount) <= 0.05) return;\n ajustes.push({\n ...c,\n sourceType: 'ajuste',\n monto_ajuste_original: Number(amount.toFixed(2)),\n monto_ajuste_gtq: Number(amount.toFixed(2)),\n tipo_ajuste: tipo,\n referencia: referencia || '',\n observacion_ajuste: observacion || (tipo === 'Corrección de monto'\n ? 'Se incluyó solo la diferencia entre la fila corregida y la fila principal.'\n : 'Se incluyó como complemento de nómina.')\n });\n }\n\n const used = new Set();\n const groups = new Map();\n candidateRecords.forEach((c, idx) => {\n c.__idx = idx;\n const key = `${c.sheetTitle || ''}||${c.igss_norm || c.nombre_norm || normalizeName(c.nombre_nomina)}`;\n if (!groups.has(key)) groups.set(key, []);\n groups.get(key).push(c);\n });\n\n // Caso abril 2026: la hoja Complemento trae valor anterior y valor corregido\n // de la variable de Oliver. No se suman ambas filas completas; se suma solo\n // la diferencia entre variable corregida y variable ya incluida en la nómina.\n for (const group of groups.values()) {\n if (!group.length) continue;\n const c0 = group[0];\n const isComplementSheet = /complement|complemento/i.test(removeAccents(c0.sheetTitle || '').toLowerCase());\n if (!isComplementSheet) continue;\n\n const possibleBase = baseRecords.filter(b => samePerson(b, c0));\n const baseAny = possibleBase[0];\n if (!baseAny) continue;\n\n const baseVariable = Number(baseAny.variable || 0);\n if (Math.abs(baseVariable) <= 0.05) continue;\n\n const variableOnly = group.filter(c =>\n Math.abs(Number(c.salario_periodo || 0)) <= 0.05 &&\n Math.abs(Number(c.vacaciones || 0)) <= 0.05 &&\n Math.abs(Number(c.horas_extras || 0)) <= 0.05 &&\n Math.abs(Number(c.variable || 0)) > 0.05 &&\n amountEquals(c.monto_afecto_original, c.variable)\n );\n\n const oldValue = variableOnly.find(c => amountEquals(c.monto_afecto_original, baseVariable));\n const corrected = variableOnly\n .filter(c => c.monto_afecto_original > baseVariable + 0.05)\n .sort((a,b) => Number(b.monto_afecto_original || 0) - Number(a.monto_afecto_original || 0))[0];\n\n if (oldValue && corrected) {\n for (const c of variableOnly) used.add(c.__idx);\n const diff = Number((corrected.monto_afecto_original - baseVariable).toFixed(2));\n pushAdjustment(\n corrected,\n diff,\n 'Corrección de variable',\n `Variable principal ${baseVariable}`,\n 'Se incluyó solo la diferencia entre la variable corregida y la variable ya incluida en la nómina principal.'\n );\n }\n }\n\n for (const c of candidateRecords) {\n if (used.has(c.__idx)) continue;\n const possibleBase = baseRecords.filter(b => samePerson(b, c));\n const baseMatch = possibleBase.find(b => amountEquals(b.monto_afecto_original, c.monto_afecto_original));\n const baseAny = possibleBase[0];\n\n if (baseMatch) {\n // Esta fila es una repetición de la fila principal, no se suma como ajuste.\n continue;\n }\n\n let adjustmentAmount = c.monto_afecto_original;\n let tipo = 'Complemento';\n let baseReference = '';\n\n const isComplementSheet = /complement|complemento/i.test(removeAccents(c.sheetTitle || '').toLowerCase());\n if (!isComplementSheet && baseAny && c.monto_afecto_original > baseAny.monto_afecto_original && possibleBase.some(b => amountEquals(b.monto_afecto_original, baseAny.monto_afecto_original))) {\n // Ejemplo: hoja Oliver trae una fila corregida completa. Se suma solo la diferencia contra la fila principal.\n adjustmentAmount = Number((c.monto_afecto_original - baseAny.monto_afecto_original).toFixed(2));\n tipo = 'Corrección de monto';\n baseReference = `Monto principal ${baseAny.monto_afecto_original}`;\n }\n\n pushAdjustment(c, Number(adjustmentAmount || 0), tipo, baseReference, tipo === 'Corrección de monto'\n ? 'Se incluyó solo la diferencia entre la fila corregida y la fila principal.'\n : 'Se incluyó como complemento de nómina.');\n }\n\n return ajustes;\n}\n\nconst mainRecords = [];\nconst candidates = [];\n\nfor (const vr of valueRanges) {\n const sheetTitle = sheetNameFromRange(vr.range);\n const values = vr.values || [];\n if (!values.length) continue;\n\n if (isMainSheet(sheetTitle)) {\n mainRecords.push(...parsePayrollRows(values, sheetTitle, 'principal'));\n } else {\n candidates.push(...parsePayrollRows(values, sheetTitle, 'ajuste_candidato'));\n }\n}\n\nif (!mainRecords.length) {\n throw new Error(`No pude leer registros válidos desde 1) Nomina General para ${qLabel}.`);\n}\n\nconst ajustes = deriveAdjustments(mainRecords, candidates);\n\nconst data = $getWorkflowStaticData('global');\ndata.gtSeguridadSocial = data.gtSeguridadSocial || {};\ndata.gtSeguridadSocial[init.runId] = data.gtSeguridadSocial[init.runId] || {};\ndata.gtSeguridadSocial[init.runId].nomina.push(...mainRecords);\ndata.gtSeguridadSocial[init.runId].ajustes = data.gtSeguridadSocial[init.runId].ajustes || [];\ndata.gtSeguridadSocial[init.runId].ajustes.push(...ajustes);\n\nreturn [{\n json: {\n runId: init.runId,\n quincena: qLabel,\n payrollRows: mainRecords.length,\n invalidIgssRows: mainRecords.filter(r => !r.igss_norm).length,\n adjustmentRows: ajustes.length,\n adjustmentSheets: [...new Set(ajustes.map(a => a.sheetTitle))]\n }\n}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 10320, 2368 ], "id": "89e8a0c5-03cb-4f40-84f6-6a53b14a339f", "name": "Guardar Nómina 1Q en memoria" }, { "parameters": { "jsCode": "const init = $items('Inicializar memoria')[0];\nconst bin = init.binary || {};\nconst type = 'q2';\nfunction norm(s) { return String(s || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase(); }\nfunction looksExcel(fileName, mime) { const lower = norm(fileName); const m = norm(mime); return lower.endsWith('.xlsx') || lower.endsWith('.xls') || lower.endsWith('.xlsm') || lower.endsWith('.csv') || m.includes('spreadsheet') || m.includes('excel') || m.includes('csv'); }\nfunction scoreCandidate(key, fileName) {\n const text = norm(`${key} ${fileName}`);\n let score = 0;\n if (type === 'q1') { if (text.includes('nomina')) score += 20; if (/\\b1q\\b|q1|primera|mayo 15|15 2026/.test(text)) score += 120; if (/\\b2q\\b|q2|segunda|mayo 30|30 2026/.test(text)) score -= 150; if (/sistema|consolidada|consolidado/.test(text)) score -= 200; }\n if (type === 'q2') { if (text.includes('nomina')) score += 20; if (/\\b2q\\b|q2|segunda|mayo 30|30 2026/.test(text)) score += 120; if (/\\b1q\\b|q1|primera|mayo 15|15 2026/.test(text)) score -= 150; if (/sistema|consolidada|consolidado/.test(text)) score -= 200; }\n if (type === 'sistema') { if (/sistema|propio|2\\.2|igss/.test(text)) score += 120; if (/consolidada|consolidado|planilla consolidada|nomina/.test(text)) score -= 80; }\n if (type === 'consolidado') { if (/consolidada|consolidado|planilla consolidada/.test(text)) score += 120; if (/sistema|propio|2\\.2|nomina/.test(text)) score -= 80; }\n return score;\n}\nconst candidates = [];\nfor (const [key, file] of Object.entries(bin)) {\n const fileName = String(file.fileName || key);\n const mime = String(file.mimeType || '');\n if (!looksExcel(fileName, mime)) continue;\n candidates.push({ key, file, fileName, score: scoreCandidate(key, fileName) });\n}\ncandidates.sort((a,b) => b.score - a.score);\nconst chosen = candidates[0];\nif (!chosen || chosen.score <= 0) throw new Error(`No encontré el archivo requerido para ${type}. Revisa los campos del formulario y los nombres de archivo.`);\nconst data = $getWorkflowStaticData('global');\nconst slot = data.gtSeguridadSocial?.[init.json.runId];\nif (slot) slot.files[type] = chosen.fileName;\nreturn [{ json: { runId: init.json.runId, fileType: type, fileName: chosen.fileName }, binary: { data: chosen.file } }];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 10560, 2368 ], "id": "a8a53d13-4855-49d0-9d96-42bc29af33a1", "name": "Preparar Nómina 2Q" }, { "parameters": { "operation": "update", "fileId": { "__rl": true, "value": "1za7ZdPAGsEbfktl5tuC6LbEZ8f7tiTCi_BszQeA-EO4", "mode": "list", "cachedResultName": "Nomina Guatemala", "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1za7ZdPAGsEbfktl5tuC6LbEZ8f7tiTCi_BszQeA-EO4/edit" }, "changeFileContent": true, "options": {} }, "type": "n8n-nodes-base.googleDrive", "typeVersion": 3, "position": [ 10816, 2368 ], "id": "47e13a5e-1b18-4c3c-bbdc-d534b2321a85", "name": "Actualizar staging - Nómina 2Q", "credentials": { "googleDriveOAuth2Api": { "id": "g23xdGLZRzBGqKgH", "name": "Isaac - Google Drive" } } }, { "parameters": { "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.id || '1za7ZdPAGsEbfktl5tuC6LbEZ8f7tiTCi_BszQeA-EO4' }}?fields=spreadsheetId,sheets(properties(title,sheetId,hidden))", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleSheetsOAuth2Api", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 11344, 2368 ], "id": "be1c4951-cd20-4e4b-8398-91067347e86d", "name": "Listar hojas - Nómina 2Q", "credentials": { "googleSheetsOAuth2Api": { "id": "K0hDZh3a85MpOHCs", "name": "Google Sheets account 2" } } }, { "parameters": { "jsCode": "const init = $items('Inicializar memoria')[0].json;\nconst spreadsheetId = $json.spreadsheetId || $json.id || '1za7ZdPAGsEbfktl5tuC6LbEZ8f7tiTCi_BszQeA-EO4';\n\nfunction norm(s) {\n return String(s || '')\n .normalize('NFD').replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase()\n .replace(/[^a-z0-9]/g, '');\n}\nfunction shouldReadPayrollSheet(title) {\n const n = norm(title);\n\n // Primero se excluyen copias y hojas auxiliares. Abril 2026 trae\n // \"Copia de 1) Nomina General 2\"; si se permite por includes('nominageneral'),\n // duplica Q1 y genera diferencias falsas.\n const excluded = [\n 'calculos', 'resumen', 'temporales', 'auditorias', 'auditoria',\n 'bono', 'movilidad', 'viaticos', 'viatico', 'combustible',\n 'combustibles', 'tabla', 'dinamica', 'copia', 'copy', 'boletas',\n 'nvscripts', 'autocrat', 'donotdelete', 'bajas'\n ];\n if (excluded.some(x => n.includes(x))) return false;\n\n // Solo la hoja principal real. No usar includes para evitar hojas copiadas.\n if (n === '1nominageneral' || n === 'nominageneral') return true;\n\n // Cualquier otra hoja no excluida se leerá como posible ajuste/complemento,\n // pero luego el nodo de parseo valida que tenga encabezado real de nómina.\n return true;\n}\n\nconst sheets = ($json.sheets || []).map(s => s.properties || {});\nconst selected = sheets\n .filter(p => p.title && shouldReadPayrollSheet(p.title))\n .map(p => p.title);\n\nif (!selected.some(t => norm(t).includes('nominageneral'))) {\n throw new Error(`No encontré la hoja 1) Nomina General. Hojas disponibles: ${sheets.map(s => s.title).join(', ')}`);\n}\nif (!selected.length) {\n throw new Error(`No encontré hojas válidas para leer en la nómina. Hojas disponibles: ${sheets.map(s => s.title).join(', ')}`);\n}\n\nreturn [{\n json: {\n runId: init.runId,\n spreadsheetId,\n sheetTitles: selected,\n ranges: selected.map(t => `'${String(t).replace(/'/g, \"''\")}'!A:AZ`)\n }\n}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 11664, 2368 ], "id": "bc2e3bfd-ab5f-4e9f-9348-6c0e0d79cfa1", "name": "Resolver hoja Nómina 2Q" }, { "parameters": { "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $json.spreadsheetId + '/values:batchGet?' + $json.ranges.map(r => 'ranges=' + encodeURIComponent(r)).join('&') + '&valueRenderOption=UNFORMATTED_VALUE&dateTimeRenderOption=SERIAL_NUMBER' }}", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleSheetsOAuth2Api", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 11984, 2368 ], "id": "47505774-a5a7-443a-bcc8-65a2b94cc773", "name": "Leer Nómina 2Q", "credentials": { "googleSheetsOAuth2Api": { "id": "K0hDZh3a85MpOHCs", "name": "Google Sheets account 2" } } }, { "parameters": { "jsCode": "const qLabel = '2Q';\nconst init = $items('Inicializar memoria')[0].json;\nconst valueRanges = $json.valueRanges || [];\n\nfunction clean(s) { return String(s ?? '').replace(/\\s+/g, ' ').trim(); }\nfunction removeAccents(s) { return String(s || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, ''); }\nfunction normSheet(s) { return removeAccents(s).toLowerCase().replace(/[^a-z0-9]/g, ''); }\nfunction normalizeName(s) {\n return clean(s)\n .normalize('NFD').replace(/[\\u0300-\\u036f]/g, '')\n .toUpperCase()\n .replace(/[^A-Z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\nfunction toNumber(v) {\n if (v === null || v === undefined || v === '') return 0;\n if (typeof v === 'number') return Number.isFinite(v) ? v : 0;\n let s = String(v).trim().replace(/Q|\\$|USD|GTQ|,/gi, '').replace(/\\s/g, '');\n if (/^\\((.*)\\)$/.test(s)) s = '-' + s.replace(/^\\(|\\)$/g, '');\n const n = Number(s);\n return Number.isFinite(n) ? n : 0;\n}\nfunction normalizeAf(v) {\n const raw = clean(v);\n if (!raw || /pendiente|n\\/a|na|sin/i.test(raw)) {\n return { value:'', status: raw ? 'Pendiente/Vacío' : 'Vacío', raw };\n }\n let digits = '';\n if (typeof v === 'number') {\n digits = Math.trunc(v).toString();\n } else {\n const s = raw.replace(/,/g, '').trim();\n if (/e\\+?/i.test(s)) {\n const n = Number(s);\n if (Number.isFinite(n)) digits = Math.trunc(n).toString();\n } else {\n digits = s.replace(/\\D/g, '');\n }\n }\n if (!digits || /^0+$/.test(digits)) return { value:'', status:'Inválido', raw };\n if (digits.length < 8 || digits.length > 13) return { value:'', status:'Longitud inválida', raw };\n return { value:digits, status: /e\\+?/i.test(String(raw)) ? 'Válido científico' : 'Válido', raw };\n}\nfunction sheetNameFromRange(range) {\n let left = String(range || '').split('!')[0] || '';\n left = left.replace(/^'/, '').replace(/'$/, '').replace(/''/g, \"'\");\n return left;\n}\nfunction isMainSheet(title) {\n const n = normSheet(title);\n // Solo la hoja principal real; NO contar copias como \"Copia de 1) Nomina General 2\".\n return n === '1nominageneral' || n === 'nominageneral';\n}\nfunction looksLikePayrollSheet(values) {\n const header = (values[0] || []).map(v => removeAccents(clean(v)).toLowerCase().replace(/[^a-z0-9]/g, ''));\n return header[0] === 'proyecto'\n && header[5] === 'igss'\n && (header[7] === 'nombrecompleto' || header[7].includes('nombre'))\n && header[14].includes('salario')\n && header[19].includes('factor');\n}\nfunction parsePayrollRows(values, sheetTitle, sourceType) {\n const records = [];\n if (!looksLikePayrollSheet(values)) return records;\n let seen = false;\n for (let i = 1; i < values.length; i++) {\n const row = values[i] || [];\n const proyecto = clean(row[0]);\n const moneda = clean(row[1]);\n const igss = normalizeAf(row[5]);\n const nombre = clean(row[7]);\n\n if (seen && !proyecto && !nombre && !clean(row[5])) break;\n if (!proyecto || !nombre || /^total/i.test(proyecto) || /^total/i.test(nombre)) continue;\n seen = true;\n\n const factor = clean(row[19]);\n const factorNorm = removeAccents(factor).toLowerCase();\n const factorSi = /^(si|sí|yes|y|true|1)$/i.test(factorNorm);\n\n const salario = toNumber(row[14]);\n const vacaciones = toNumber(row[16]);\n const horasExtras = toNumber(row[18]);\n const variable = toNumber(row[21]);\n const montoAfectoOriginal = salario + vacaciones + horasExtras + (factorSi ? variable : 0);\n if (Math.abs(montoAfectoOriginal) <= 0.004 && sourceType === 'ajuste_candidato') continue;\n\n records.push({\n q: qLabel,\n sheetTitle,\n rowNumber: i + 1,\n sourceType,\n proyecto_nomina: proyecto,\n moneda,\n nombre_nomina: nombre,\n nombre_norm: normalizeName(nombre),\n igss_original: igss.raw,\n igss_norm: igss.value,\n igss_estado: igss.status,\n factor_prestacional: factor,\n salario_periodo: Number(salario.toFixed(2)),\n vacaciones: Number(vacaciones.toFixed(2)),\n horas_extras: Number(horasExtras.toFixed(2)),\n variable: Number(variable.toFixed(2)),\n variable_afecta: factorSi ? 'Sí' : 'No',\n monto_afecto_original: Number(montoAfectoOriginal.toFixed(2)),\n monto_afecto_gtq: Number(montoAfectoOriginal.toFixed(2)),\n regla_aplicada: factorSi ? 'Sueldo + vacaciones + horas extras + variable' : 'Sueldo + vacaciones + horas extras'\n });\n }\n return records;\n}\nfunction samePerson(a, b) {\n if (a.igss_norm && b.igss_norm && a.igss_norm === b.igss_norm) return true;\n return normalizeName(a.nombre_nomina) === normalizeName(b.nombre_nomina);\n}\nfunction amountEquals(a, b) {\n return Math.abs(Number(a || 0) - Number(b || 0)) <= 0.05;\n}\nfunction deriveAdjustments(baseRecords, candidateRecords) {\n const ajustes = [];\n\n function pushAdjustment(c, amount, tipo, referencia, observacion) {\n if (Math.abs(amount) <= 0.05) return;\n ajustes.push({\n ...c,\n sourceType: 'ajuste',\n monto_ajuste_original: Number(amount.toFixed(2)),\n monto_ajuste_gtq: Number(amount.toFixed(2)),\n tipo_ajuste: tipo,\n referencia: referencia || '',\n observacion_ajuste: observacion || (tipo === 'Corrección de monto'\n ? 'Se incluyó solo la diferencia entre la fila corregida y la fila principal.'\n : 'Se incluyó como complemento de nómina.')\n });\n }\n\n const used = new Set();\n const groups = new Map();\n candidateRecords.forEach((c, idx) => {\n c.__idx = idx;\n const key = `${c.sheetTitle || ''}||${c.igss_norm || c.nombre_norm || normalizeName(c.nombre_nomina)}`;\n if (!groups.has(key)) groups.set(key, []);\n groups.get(key).push(c);\n });\n\n // Caso abril 2026: la hoja Complemento trae valor anterior y valor corregido\n // de la variable de Oliver. No se suman ambas filas completas; se suma solo\n // la diferencia entre variable corregida y variable ya incluida en la nómina.\n for (const group of groups.values()) {\n if (!group.length) continue;\n const c0 = group[0];\n const isComplementSheet = /complement|complemento/i.test(removeAccents(c0.sheetTitle || '').toLowerCase());\n if (!isComplementSheet) continue;\n\n const possibleBase = baseRecords.filter(b => samePerson(b, c0));\n const baseAny = possibleBase[0];\n if (!baseAny) continue;\n\n const baseVariable = Number(baseAny.variable || 0);\n if (Math.abs(baseVariable) <= 0.05) continue;\n\n const variableOnly = group.filter(c =>\n Math.abs(Number(c.salario_periodo || 0)) <= 0.05 &&\n Math.abs(Number(c.vacaciones || 0)) <= 0.05 &&\n Math.abs(Number(c.horas_extras || 0)) <= 0.05 &&\n Math.abs(Number(c.variable || 0)) > 0.05 &&\n amountEquals(c.monto_afecto_original, c.variable)\n );\n\n const oldValue = variableOnly.find(c => amountEquals(c.monto_afecto_original, baseVariable));\n const corrected = variableOnly\n .filter(c => c.monto_afecto_original > baseVariable + 0.05)\n .sort((a,b) => Number(b.monto_afecto_original || 0) - Number(a.monto_afecto_original || 0))[0];\n\n if (oldValue && corrected) {\n for (const c of variableOnly) used.add(c.__idx);\n const diff = Number((corrected.monto_afecto_original - baseVariable).toFixed(2));\n pushAdjustment(\n corrected,\n diff,\n 'Corrección de variable',\n `Variable principal ${baseVariable}`,\n 'Se incluyó solo la diferencia entre la variable corregida y la variable ya incluida en la nómina principal.'\n );\n }\n }\n\n for (const c of candidateRecords) {\n if (used.has(c.__idx)) continue;\n const possibleBase = baseRecords.filter(b => samePerson(b, c));\n const baseMatch = possibleBase.find(b => amountEquals(b.monto_afecto_original, c.monto_afecto_original));\n const baseAny = possibleBase[0];\n\n if (baseMatch) {\n // Esta fila es una repetición de la fila principal, no se suma como ajuste.\n continue;\n }\n\n let adjustmentAmount = c.monto_afecto_original;\n let tipo = 'Complemento';\n let baseReference = '';\n\n const isComplementSheet = /complement|complemento/i.test(removeAccents(c.sheetTitle || '').toLowerCase());\n if (!isComplementSheet && baseAny && c.monto_afecto_original > baseAny.monto_afecto_original && possibleBase.some(b => amountEquals(b.monto_afecto_original, baseAny.monto_afecto_original))) {\n // Ejemplo: hoja Oliver trae una fila corregida completa. Se suma solo la diferencia contra la fila principal.\n adjustmentAmount = Number((c.monto_afecto_original - baseAny.monto_afecto_original).toFixed(2));\n tipo = 'Corrección de monto';\n baseReference = `Monto principal ${baseAny.monto_afecto_original}`;\n }\n\n pushAdjustment(c, Number(adjustmentAmount || 0), tipo, baseReference, tipo === 'Corrección de monto'\n ? 'Se incluyó solo la diferencia entre la fila corregida y la fila principal.'\n : 'Se incluyó como complemento de nómina.');\n }\n\n return ajustes;\n}\n\nconst mainRecords = [];\nconst candidates = [];\n\nfor (const vr of valueRanges) {\n const sheetTitle = sheetNameFromRange(vr.range);\n const values = vr.values || [];\n if (!values.length) continue;\n\n if (isMainSheet(sheetTitle)) {\n mainRecords.push(...parsePayrollRows(values, sheetTitle, 'principal'));\n } else {\n candidates.push(...parsePayrollRows(values, sheetTitle, 'ajuste_candidato'));\n }\n}\n\nif (!mainRecords.length) {\n throw new Error(`No pude leer registros válidos desde 1) Nomina General para ${qLabel}.`);\n}\n\nconst ajustes = deriveAdjustments(mainRecords, candidates);\n\nconst data = $getWorkflowStaticData('global');\ndata.gtSeguridadSocial = data.gtSeguridadSocial || {};\ndata.gtSeguridadSocial[init.runId] = data.gtSeguridadSocial[init.runId] || {};\ndata.gtSeguridadSocial[init.runId].nomina.push(...mainRecords);\ndata.gtSeguridadSocial[init.runId].ajustes = data.gtSeguridadSocial[init.runId].ajustes || [];\ndata.gtSeguridadSocial[init.runId].ajustes.push(...ajustes);\n\nreturn [{\n json: {\n runId: init.runId,\n quincena: qLabel,\n payrollRows: mainRecords.length,\n invalidIgssRows: mainRecords.filter(r => !r.igss_norm).length,\n adjustmentRows: ajustes.length,\n adjustmentSheets: [...new Set(ajustes.map(a => a.sheetTitle))]\n }\n}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 12288, 2368 ], "id": "76fa52d2-2baf-46da-ada0-c096ef313973", "name": "Guardar Nómina 2Q en memoria" }, { "parameters": { "jsCode": "const init = $items('Inicializar memoria')[0];\nconst bin = init.binary || {};\nconst type = 'sistema';\nfunction norm(s) { return String(s || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase(); }\nfunction looksExcel(fileName, mime) { const lower = norm(fileName); const m = norm(mime); return lower.endsWith('.xlsx') || lower.endsWith('.xls') || lower.endsWith('.xlsm') || lower.endsWith('.csv') || m.includes('spreadsheet') || m.includes('excel') || m.includes('csv'); }\nfunction scoreCandidate(key, fileName) {\n const text = norm(`${key} ${fileName}`);\n let score = 0;\n if (type === 'q1') { if (text.includes('nomina')) score += 20; if (/\\b1q\\b|q1|primera|mayo 15|15 2026/.test(text)) score += 120; if (/\\b2q\\b|q2|segunda|mayo 30|30 2026/.test(text)) score -= 150; if (/sistema|consolidada|consolidado/.test(text)) score -= 200; }\n if (type === 'q2') { if (text.includes('nomina')) score += 20; if (/\\b2q\\b|q2|segunda|mayo 30|30 2026/.test(text)) score += 120; if (/\\b1q\\b|q1|primera|mayo 15|15 2026/.test(text)) score -= 150; if (/sistema|consolidada|consolidado/.test(text)) score -= 200; }\n if (type === 'sistema') { if (/sistema|propio|2\\.2|igss/.test(text)) score += 120; if (/consolidada|consolidado|planilla consolidada|nomina/.test(text)) score -= 80; }\n if (type === 'consolidado') { if (/consolidada|consolidado|planilla consolidada/.test(text)) score += 120; if (/sistema|propio|2\\.2|nomina/.test(text)) score -= 80; }\n return score;\n}\nconst candidates = [];\nfor (const [key, file] of Object.entries(bin)) {\n const fileName = String(file.fileName || key);\n const mime = String(file.mimeType || '');\n if (!looksExcel(fileName, mime)) continue;\n candidates.push({ key, file, fileName, score: scoreCandidate(key, fileName) });\n}\ncandidates.sort((a,b) => b.score - a.score);\nconst chosen = candidates[0];\nif (!chosen || chosen.score <= 0) throw new Error(`No encontré el archivo requerido para ${type}. Revisa los campos del formulario y los nombres de archivo.`);\nconst data = $getWorkflowStaticData('global');\nconst slot = data.gtSeguridadSocial?.[init.json.runId];\nif (slot) slot.files[type] = chosen.fileName;\nreturn [{ json: { runId: init.json.runId, fileType: type, fileName: chosen.fileName }, binary: { data: chosen.file } }];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 12864, 2384 ], "id": "74d9bb06-3447-4e27-9a64-28b31455e6fa", "name": "Preparar Sistema Propio" }, { "parameters": { "operation": "update", "fileId": { "__rl": true, "value": "1za7ZdPAGsEbfktl5tuC6LbEZ8f7tiTCi_BszQeA-EO4", "mode": "list", "cachedResultName": "Nomina Guatemala", "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1za7ZdPAGsEbfktl5tuC6LbEZ8f7tiTCi_BszQeA-EO4/edit" }, "changeFileContent": true, "options": {} }, "type": "n8n-nodes-base.googleDrive", "typeVersion": 3, "position": [ 13072, 2384 ], "id": "275ca7f4-9ac9-4a61-9b86-e7e2618c7591", "name": "Actualizar staging - Sistema Propio", "credentials": { "googleDriveOAuth2Api": { "id": "g23xdGLZRzBGqKgH", "name": "Isaac - Google Drive" } } }, { "parameters": { "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.id || '1za7ZdPAGsEbfktl5tuC6LbEZ8f7tiTCi_BszQeA-EO4' }}?fields=spreadsheetId,sheets(properties(title,sheetId,hidden))", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleSheetsOAuth2Api", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 13504, 2384 ], "id": "3d7072e9-bcb0-402e-9738-b98e8192797e", "name": "Listar hojas - Sistema Propio", "credentials": { "googleSheetsOAuth2Api": { "id": "K0hDZh3a85MpOHCs", "name": "Google Sheets account 2" } } }, { "parameters": { "jsCode": "const init = $items('Inicializar memoria')[0].json;\nconst target = 'empleados';\nconst spreadsheetId = $json.spreadsheetId || $json.id || '1za7ZdPAGsEbfktl5tuC6LbEZ8f7tiTCi_BszQeA-EO4';\nfunction norm(s) { return String(s || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]/g, ''); }\nconst wanted = { nomina: ['1nominageneral','nominageneral'], empleados: ['empleados'], planilla: ['planillamensual'] }[target];\nconst sheets = ($json.sheets || []).map(s => s.properties || {});\nlet match = null;\nfor (const w of wanted) { match = sheets.find(p => norm(p.title) === w || norm(p.title).includes(w)); if (match) break; }\nif (!match) throw new Error(`No encontré la hoja esperada para ${target}. Hojas disponibles: ${sheets.map(s => s.title).join(', ')}`);\nreturn [{ json: { runId: init.runId, spreadsheetId, sheetTitle: match.title, sheetId: match.sheetId, target } }];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 13728, 2384 ], "id": "27b72e62-9b86-49b6-9cc5-09bf47fa418b", "name": "Resolver hoja EMPLEADOS" }, { "parameters": { "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $json.spreadsheetId + '/values/' + encodeURIComponent(\"'\" + $json.sheetTitle.replace(/'/g, \"''\") + \"'!A:AZ\") + '?valueRenderOption=UNFORMATTED_VALUE&dateTimeRenderOption=SERIAL_NUMBER' }}", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleSheetsOAuth2Api", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 13952, 2384 ], "id": "c0650aa2-fab6-4dee-a15a-6f633fa225ee", "name": "Leer EMPLEADOS", "credentials": { "googleSheetsOAuth2Api": { "id": "K0hDZh3a85MpOHCs", "name": "Google Sheets account 2" } } }, { "parameters": { "jsCode": "const init = $items('Inicializar memoria')[0].json;\nconst values = $json.values || [];\nfunction clean(s) { return String(s ?? '').replace(/\\s+/g, ' ').trim(); }\nfunction normalizeName(s) { return clean(s).normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toUpperCase().replace(/[^A-Z0-9 ]/g, ' ').replace(/\\s+/g, ' ').trim(); }\nfunction normalizeAf(v) { const raw = clean(v); if (!raw) return ''; let digits = ''; if (typeof v === 'number') digits = Math.trunc(v).toString(); else { const s = raw.replace(/,/g, '').trim(); if (/e\\+?/i.test(s)) { const n = Number(s); if (Number.isFinite(n)) digits = Math.trunc(n).toString(); } else digits = s.replace(/\\D/g, ''); } return digits && digits.length >= 8 && digits.length <= 13 && !/^0+$/.test(digits) ? digits : ''; }\nfunction toNumber(v) { const n = Number(String(v ?? '').replace(/Q|\\$|USD|GTQ|,/gi, '').trim()); return Number.isFinite(n) ? n : 0; }\nconst records = [];\nfor (let i = 1; i < values.length; i++) { const row = values[i] || []; const afiliacion = normalizeAf(row[2]); if (!afiliacion) continue; const nombre = [row[4], row[6], row[8], row[10], row[12]].map(clean).filter(Boolean).join(' '); records.push({ afiliacion, nombre_contabilidad: nombre, nombre_norm: normalizeName(nombre), sueldo_devengado: Number(toNumber(row[14]).toFixed(2)) }); }\nconst by = new Map(); for (const r of records) if (!by.has(r.afiliacion)) by.set(r.afiliacion, r);\nconst finalRecords = [...by.values()];\nif (!finalRecords.length) throw new Error('No pude leer afiliaciones desde EMPLEADOS columna C.');\nconst data = $getWorkflowStaticData('global'); data.gtSeguridadSocial = data.gtSeguridadSocial || {}; data.gtSeguridadSocial[init.runId] = data.gtSeguridadSocial[init.runId] || {}; data.gtSeguridadSocial[init.runId].empleados = finalRecords;\nreturn [{ json: { runId: init.runId, empleadosRows: finalRecords.length } }];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 14192, 2384 ], "id": "367a8301-888f-4779-a749-f30c61f271b2", "name": "Guardar EMPLEADOS en memoria" }, { "parameters": { "jsCode": "const init = $items('Inicializar memoria')[0];\nconst bin = init.binary || {};\nconst type = 'consolidado';\nfunction norm(s) { return String(s || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase(); }\nfunction looksExcel(fileName, mime) { const lower = norm(fileName); const m = norm(mime); return lower.endsWith('.xlsx') || lower.endsWith('.xls') || lower.endsWith('.xlsm') || lower.endsWith('.csv') || m.includes('spreadsheet') || m.includes('excel') || m.includes('csv'); }\nfunction scoreCandidate(key, fileName) {\n const text = norm(`${key} ${fileName}`);\n let score = 0;\n if (type === 'q1') { if (text.includes('nomina')) score += 20; if (/\\b1q\\b|q1|primera|mayo 15|15 2026/.test(text)) score += 120; if (/\\b2q\\b|q2|segunda|mayo 30|30 2026/.test(text)) score -= 150; if (/sistema|consolidada|consolidado/.test(text)) score -= 200; }\n if (type === 'q2') { if (text.includes('nomina')) score += 20; if (/\\b2q\\b|q2|segunda|mayo 30|30 2026/.test(text)) score += 120; if (/\\b1q\\b|q1|primera|mayo 15|15 2026/.test(text)) score -= 150; if (/sistema|consolidada|consolidado/.test(text)) score -= 200; }\n if (type === 'sistema') { if (/sistema|propio|2\\.2|igss/.test(text)) score += 120; if (/consolidada|consolidado|planilla consolidada|nomina/.test(text)) score -= 80; }\n if (type === 'consolidado') { if (/consolidada|consolidado|planilla consolidada/.test(text)) score += 120; if (/sistema|propio|2\\.2|nomina/.test(text)) score -= 80; }\n return score;\n}\nconst candidates = [];\nfor (const [key, file] of Object.entries(bin)) {\n const fileName = String(file.fileName || key);\n const mime = String(file.mimeType || '');\n if (!looksExcel(fileName, mime)) continue;\n candidates.push({ key, file, fileName, score: scoreCandidate(key, fileName) });\n}\ncandidates.sort((a,b) => b.score - a.score);\nconst chosen = candidates[0];\nif (!chosen || chosen.score <= 0) throw new Error(`No encontré el archivo requerido para ${type}. Revisa los campos del formulario y los nombres de archivo.`);\nconst data = $getWorkflowStaticData('global');\nconst slot = data.gtSeguridadSocial?.[init.json.runId];\nif (slot) slot.files[type] = chosen.fileName;\nreturn [{ json: { runId: init.json.runId, fileType: type, fileName: chosen.fileName }, binary: { data: chosen.file } }];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 14848, 2384 ], "id": "c6a4305d-eec6-4534-8ef3-feeac981f898", "name": "Preparar Planilla Consolidada" }, { "parameters": { "operation": "update", "fileId": { "__rl": true, "value": "1za7ZdPAGsEbfktl5tuC6LbEZ8f7tiTCi_BszQeA-EO4", "mode": "list", "cachedResultName": "Nomina Guatemala", "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1za7ZdPAGsEbfktl5tuC6LbEZ8f7tiTCi_BszQeA-EO4/edit" }, "changeFileContent": true, "options": {} }, "type": "n8n-nodes-base.googleDrive", "typeVersion": 3, "position": [ 15056, 2384 ], "id": "9dbd810f-c51a-4de2-922e-625cb3c495d6", "name": "Actualizar staging - Consolidada", "credentials": { "googleDriveOAuth2Api": { "id": "g23xdGLZRzBGqKgH", "name": "Isaac - Google Drive" } } }, { "parameters": { "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.id || '1za7ZdPAGsEbfktl5tuC6LbEZ8f7tiTCi_BszQeA-EO4' }}?fields=spreadsheetId,sheets(properties(title,sheetId,hidden))", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleSheetsOAuth2Api", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 15408, 2384 ], "id": "c97bd6c8-4e62-403b-85f5-045fce057169", "name": "Listar hojas - Consolidada", "credentials": { "googleSheetsOAuth2Api": { "id": "K0hDZh3a85MpOHCs", "name": "Google Sheets account 2" } } }, { "parameters": { "jsCode": "const init = $items('Inicializar memoria')[0].json;\nconst target = 'planilla';\nconst spreadsheetId = $json.spreadsheetId || $json.id || '1za7ZdPAGsEbfktl5tuC6LbEZ8f7tiTCi_BszQeA-EO4';\nfunction norm(s) { return String(s || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]/g, ''); }\nconst wanted = { nomina: ['1nominageneral','nominageneral'], empleados: ['empleados'], planilla: ['planillamensual'] }[target];\nconst sheets = ($json.sheets || []).map(s => s.properties || {});\nlet match = null;\nfor (const w of wanted) { match = sheets.find(p => norm(p.title) === w || norm(p.title).includes(w)); if (match) break; }\nif (!match) throw new Error(`No encontré la hoja esperada para ${target}. Hojas disponibles: ${sheets.map(s => s.title).join(', ')}`);\nreturn [{ json: { runId: init.runId, spreadsheetId, sheetTitle: match.title, sheetId: match.sheetId, target } }];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 15600, 2384 ], "id": "8494033b-b9f7-4997-a5f1-f2cd787ebbbe", "name": "Resolver hoja PLANILLA MENSUAL" }, { "parameters": { "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $json.spreadsheetId + '/values/' + encodeURIComponent(\"'\" + $json.sheetTitle.replace(/'/g, \"''\") + \"'!A:AZ\") + '?valueRenderOption=UNFORMATTED_VALUE&dateTimeRenderOption=SERIAL_NUMBER' }}", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleSheetsOAuth2Api", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 15776, 2384 ], "id": "40cfbe01-46ed-4159-973f-100b1fbbc9a5", "name": "Leer PLANILLA MENSUAL", "credentials": { "googleSheetsOAuth2Api": { "id": "K0hDZh3a85MpOHCs", "name": "Google Sheets account 2" } } }, { "parameters": { "jsCode": "const init = $items('Inicializar memoria')[0].json;\nconst values = $json.values || [];\nfunction clean(s) { return String(s ?? '').replace(/\\s+/g, ' ').trim(); }\nfunction normalizeName(s) { return clean(s).normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toUpperCase().replace(/[^A-Z0-9 ]/g, ' ').replace(/\\s+/g, ' ').trim(); }\nfunction toNumber(v) { if (v === null || v === undefined || v === '') return 0; if (typeof v === 'number') return Number.isFinite(v) ? v : 0; const n = Number(String(v).replace(/Q|\\$|USD|GTQ|,/gi, '').trim()); return Number.isFinite(n) ? n : 0; }\nfunction normalizeAf(v) { const raw = clean(v); if (!raw) return ''; let digits = ''; if (typeof v === 'number') digits = Math.trunc(v).toString(); else { const s = raw.replace(/,/g, '').trim(); if (/e\\+?/i.test(s)) { const n = Number(s); if (Number.isFinite(n)) digits = Math.trunc(n).toString(); } else digits = s.replace(/\\D/g, ''); } return digits && digits.length >= 8 && digits.length <= 13 && !/^0+$/.test(digits) ? digits : ''; }\nconst records = [];\nfor (let i = 0; i < values.length; i++) { const row = values[i] || []; const afiliacion = normalizeAf(row[1]); if (!afiliacion) continue; const nombre = clean(row[2]); if (!nombre || /^total/i.test(nombre)) continue; records.push({ afiliacion, nombre_planilla: nombre, nombre_norm: normalizeName(nombre), proyecto_planilla: clean(row[4]), monto_planilla_mensual: Number(toNumber(row[25]).toFixed(2)), liquido_planilla: Number(toNumber(row[23]).toFixed(2)) }); }\nconst by = new Map();\nfor (const r of records) { if (!by.has(r.afiliacion)) by.set(r.afiliacion, { ...r }); else { const ex = by.get(r.afiliacion); ex.monto_planilla_mensual = Number((ex.monto_planilla_mensual + r.monto_planilla_mensual).toFixed(2)); ex.liquido_planilla = Number((ex.liquido_planilla + r.liquido_planilla).toFixed(2)); } }\nconst finalRecords = [...by.values()];\nif (!finalRecords.length) throw new Error('No pude leer afiliaciones y monto desde PLANILLA MENSUAL.');\nconst data = $getWorkflowStaticData('global'); data.gtSeguridadSocial = data.gtSeguridadSocial || {}; data.gtSeguridadSocial[init.runId] = data.gtSeguridadSocial[init.runId] || {}; data.gtSeguridadSocial[init.runId].planillaMensual = finalRecords;\nreturn [{ json: { runId: init.runId, planillaRows: finalRecords.length } }];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 15952, 2384 ], "id": "f63da741-6f40-4b65-9408-ee158b333983", "name": "Guardar PLANILLA MENSUAL en memoria" }, { "parameters": { "jsCode": "function clean(s) { return String(s ?? '').replace(/\\s+/g, ' ').trim(); }\nfunction money(n) { return Number((Number(n) || 0).toFixed(2)); }\nfunction blankCell(v) { return v === '' ? null : v; }\nfunction sanitizeRows(rows) { return (rows || []).map(row => (row || []).map(blankCell)); }\nfunction removeAccents(s) { return String(s || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, ''); }\nfunction normalizeName(s) {\n return clean(s)\n .normalize('NFD').replace(/[\\u0300-\\u036f]/g, '')\n .toUpperCase()\n .replace(/[^A-Z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\nconst MATCH_TOKEN_EQUIVALENCES = new Map([\n ['VASQUEZ', 'VAZQUEZ']\n]);\nfunction canonicalMatchName(s) {\n return normalizeName(s)\n .split(' ')\n .map(token => MATCH_TOKEN_EQUIVALENCES.get(token) || token)\n .join(' ');\n}\nfunction tokens(name) {\n const stop = new Set(['DE','DEL','LA','LAS','LOS','Y','DA','DAS','DO','VDA']);\n return canonicalMatchName(name).split(' ').filter(t => t.length > 1 && !stop.has(t));\n}\nfunction nameScore(a, b) {\n const A = tokens(a), B = tokens(b);\n if (!A.length || !B.length) return 0;\n const setB = new Set(B);\n const inter = A.filter(x => setB.has(x)).length;\n if (inter < 2) return 0;\n return inter / Math.min(A.length, B.length);\n}\nfunction bestByName(name, list, field) {\n const exact = list.find(r => canonicalMatchName(r[field]) === canonicalMatchName(name));\n if (exact) return { record: exact, score: 1, method: 'nombre exacto' };\n let best = { record: null, score: 0, method: '' };\n for (const r of list) {\n const sc = nameScore(name, r[field] || '');\n if (sc > best.score) best = { record: r, score: sc, method: 'nombre parecido' };\n }\n return best.score >= 0.70 ? best : { record: null, score: 0, method: '' };\n}\nfunction makeMap(records, key) {\n const m = new Map();\n for (const r of records || []) if (r[key]) m.set(String(r[key]), r);\n return m;\n}\nfunction uniqueJoin(arr) {\n return [...new Set((arr || []).filter(Boolean).map(clean))].join(' | ');\n}\nfunction fmt(n) {\n return Number(n || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });\n}\nfunction bambooStatusLabel(value) {\n const status = normalizeName(value);\n if (status === 'ACTIVE' || status === 'ACTIVO') return 'Activo';\n if (status === 'INACTIVE' || status === 'INACTIVO') return 'Inactivo';\n return clean(value);\n}\nfunction isBambooActive(value) {\n return bambooStatusLabel(value) === 'Activo';\n}\n\n// Personas que intencionalmente no se administran en BambooHR.\n// Se valida primero por IGSS y se conserva el nombre como respaldo.\nconst BAMBOO_EXCLUSIONS = [\n {\n afiliacion: '273223693',\n nombre_norm: normalizeName('SONIA LORENA GUAMUCH'),\n motivo: 'Personal de limpieza fuera de BambooHR por regla autorizada.'\n }\n];\nfunction getBambooExclusion(afiliacion, nombre) {\n const af = String(afiliacion || '').replace(/\\D/g, '');\n const nombreNorm = normalizeName(nombre);\n return BAMBOO_EXCLUSIONS.find(x =>\n (x.afiliacion && af && x.afiliacion === af) ||\n (x.nombre_norm && nombreNorm && x.nombre_norm === nombreNorm)\n ) || null;\n}\n\nfunction chooseBestName(records) {\n const names = (records || []).map(r => clean(r.nombre_nomina)).filter(Boolean);\n if (!names.length) return '';\n names.sort((a,b) => tokens(b).length - tokens(a).length || b.length - a.length);\n return names[0];\n}\nfunction buildNameIndex(rows, field) {\n const exact = new Map();\n const byToken = new Map();\n\n for (const row of rows || []) {\n const canonical = canonicalMatchName(row[field] || '');\n if (!canonical) continue;\n\n if (!exact.has(canonical)) exact.set(canonical, []);\n exact.get(canonical).push(row);\n\n for (const token of new Set(tokens(canonical))) {\n if (!byToken.has(token)) byToken.set(token, []);\n byToken.get(token).push(row);\n }\n }\n\n return { exact, byToken };\n}\nfunction indexedCandidates(name, index) {\n const counts = new Map();\n\n for (const token of new Set(tokens(name))) {\n for (const row of index.byToken.get(token) || []) {\n counts.set(row, (counts.get(row) || 0) + 1);\n }\n }\n\n return [...counts.entries()]\n .filter(([, shared]) => shared >= 2)\n .map(([row]) => row);\n}\nfunction matchBambooByName(name, nameIndex) {\n const target = canonicalMatchName(name);\n if (!target) return { record:null, score:0, method:'sin nombre' };\n\n const exact = [...(nameIndex.exact.get(target) || [])];\n if (exact.length) {\n exact.sort((a,b) => {\n const activeA = isBambooActive(a.estado_bamboo) ? 1 : 0;\n const activeB = isBambooActive(b.estado_bamboo) ? 1 : 0;\n return activeB - activeA;\n });\n return { record:exact[0], score:1, method:'nombre exacto' };\n }\n\n // Solo se evalúan empleados que comparten al menos dos partes del nombre.\n // Esto evita recorrer toda la compañía y mantiene el nodo por debajo\n // del límite del task runner incluso con miles de empleados en BambooHR.\n const candidates = indexedCandidates(name, nameIndex)\n .map(r => ({ record:r, score:nameScore(name, r.nombre_bamboo || '') }))\n .filter(x => x.score >= 0.70)\n .sort((a,b) => b.score - a.score);\n\n if (!candidates.length) return { record:null, score:0, method:'sin coincidencia' };\n\n const best = candidates[0];\n const second = candidates[1];\n const margin = second ? best.score - second.score : best.score;\n const sharedTokens = tokens(name).filter(t => new Set(tokens(best.record.nombre_bamboo || '')).has(t)).length;\n\n if (sharedTokens >= 2 && (best.score >= 0.85 || margin >= 0.15)) {\n return { record:best.record, score:best.score, method:'nombre parecido' };\n }\n return { record:null, score:0, method:'coincidencia ambigua' };\n}\n\nconst init = $items('Inicializar memoria')[0].json;\nconst data = $getWorkflowStaticData('global');\nconst slot = data.gtSeguridadSocial?.[init.runId];\nif (!slot) throw new Error('No encontré la memoria temporal de esta ejecución.');\n\nconst payroll = slot.nomina || [];\nconst ajustesRaw = slot.ajustes || [];\nconst empleados = slot.empleados || [];\nconst planilla = slot.planillaMensual || [];\nconst bamboo = slot.bamboo || [];\n\nif (!payroll.length || !empleados.length || !planilla.length || !bamboo.length) {\n throw new Error(`Faltan datos: nómina=${payroll.length}, empleados=${empleados.length}, planilla=${planilla.length}, bamboo=${bamboo.length}`);\n}\n\nconst bambooNameIndex = buildNameIndex(bamboo, 'nombre_bamboo');\n\nconst empByAf = makeMap(empleados, 'afiliacion');\nconst planByAf = makeMap(planilla, 'afiliacion');\n\nconst resolvedRows = [];\nconst sinIgssRows = [];\n\nfor (const r of payroll) {\n let resolvedAf = r.igss_norm || '';\n let resolveMethod = resolvedAf ? 'Número de IGSS en nómina' : '';\n let corrected = false;\n\n if (!resolvedAf || (!empByAf.has(resolvedAf) && !planByAf.has(resolvedAf))) {\n const bestEmp = bestByName(r.nombre_nomina, empleados, 'nombre_contabilidad');\n if (bestEmp.record) {\n corrected = !!resolvedAf && resolvedAf !== bestEmp.record.afiliacion;\n resolvedAf = bestEmp.record.afiliacion;\n resolveMethod = `${bestEmp.method} en EMPLEADOS`;\n } else {\n const bestPlan = bestByName(r.nombre_nomina, planilla, 'nombre_planilla');\n if (bestPlan.record) {\n corrected = !!resolvedAf && resolvedAf !== bestPlan.record.afiliacion;\n resolvedAf = bestPlan.record.afiliacion;\n resolveMethod = `${bestPlan.method} en PLANILLA MENSUAL`;\n }\n }\n }\n\n if (!r.igss_norm || corrected) {\n sinIgssRows.push({\n ...r,\n resolvedAf,\n resolveMethod,\n corrected,\n accion: corrected ? 'Corregir IGSS en nómina' : 'Completar IGSS en nómina'\n });\n }\n\n resolvedRows.push({ ...r, resolvedAf, resolveMethod, corrected });\n}\n\n// Tipo de cambio para nóminas en dólar, inferido con casos donde el monto coincide contra PLANILLA MENSUAL.\nconst ratios = [];\nfor (const r of resolvedRows) {\n if (!/dolar|dólar|usd/i.test(r.moneda || '') || !r.resolvedAf) continue;\n const pl = planByAf.get(r.resolvedAf);\n if (!pl || !r.monto_afecto_original) continue;\n const ratio = pl.monto_planilla_mensual / r.monto_afecto_original;\n if (ratio >= 5 && ratio <= 10) ratios.push(ratio);\n}\nratios.sort((a,b)=>a-b);\nconst usdRate = ratios.length ? money(ratios[Math.floor(ratios.length/2)]) : 1;\n\nfor (const r of resolvedRows) {\n r.tipo_cambio_usd = /dolar|dólar|usd/i.test(r.moneda || '') ? usdRate : 1;\n r.monto_afecto_gtq = money(r.monto_afecto_original * r.tipo_cambio_usd);\n}\n\nconst groups = new Map();\nconst unresolvedRows = [];\n\nfor (const r of resolvedRows) {\n if (!r.resolvedAf) {\n unresolvedRows.push(r);\n continue;\n }\n\n if (!groups.has(r.resolvedAf)) {\n groups.set(r.resolvedAf, { afiliacion:r.resolvedAf, records:[], ajustes:[], monto:0, montoPrincipal:0, montoAjustes:0 });\n }\n const g = groups.get(r.resolvedAf);\n g.records.push(r);\n g.montoPrincipal = money(g.montoPrincipal + r.monto_afecto_gtq);\n g.monto = money(g.monto + r.monto_afecto_gtq);\n}\n\n// Resolver e incluir ajustes/complementos sin contaminar el cruce de afiliaciones.\nconst ajustesIncluidos = [];\nconst ajustesNoResueltos = [];\n\nfor (const a0 of ajustesRaw) {\n let resolvedAf = a0.igss_norm || '';\n let resolveMethod = resolvedAf ? 'Número de IGSS en ajuste' : '';\n\n if (!resolvedAf || (!empByAf.has(resolvedAf) && !planByAf.has(resolvedAf))) {\n const bestEmp = bestByName(a0.nombre_nomina, empleados, 'nombre_contabilidad');\n if (bestEmp.record) {\n resolvedAf = bestEmp.record.afiliacion;\n resolveMethod = `${bestEmp.method} en EMPLEADOS`;\n } else {\n const bestPlan = bestByName(a0.nombre_nomina, planilla, 'nombre_planilla');\n if (bestPlan.record) {\n resolvedAf = bestPlan.record.afiliacion;\n resolveMethod = `${bestPlan.method} en PLANILLA MENSUAL`;\n }\n }\n }\n\n const rate = /dolar|dólar|usd/i.test(a0.moneda || '') ? usdRate : 1;\n const montoGtq = money((a0.monto_ajuste_original || 0) * rate);\n const ajuste = { ...a0, resolvedAf, resolveMethod, monto_ajuste_gtq: montoGtq, tipo_cambio_usd: rate };\n\n if (!resolvedAf) {\n ajustesNoResueltos.push(ajuste);\n continue;\n }\n\n if (!groups.has(resolvedAf)) {\n groups.set(resolvedAf, { afiliacion:resolvedAf, records:[], ajustes:[], monto:0, montoPrincipal:0, montoAjustes:0 });\n }\n const g = groups.get(resolvedAf);\n g.ajustes.push(ajuste);\n g.montoAjustes = money(g.montoAjustes + montoGtq);\n g.monto = money(g.monto + montoGtq);\n ajustesIncluidos.push(ajuste);\n}\n\n// Cruce adicional contra BambooHR. Se usa el nombre normalizado porque las nóminas\n// no contienen el ID interno de BambooHR. Solo se acepta coincidencia exacta o\n// coincidencia de nombre suficientemente clara y no ambigua.\nconst bambooMatchCache = new Map();\nfunction getBambooMatch(name) {\n const key = canonicalMatchName(name);\n if (!bambooMatchCache.has(key)) bambooMatchCache.set(key, matchBambooByName(name, bambooNameIndex));\n return bambooMatchCache.get(key);\n}\n\nconst payrollBambooMap = new Map();\nfor (const r of resolvedRows) {\n const key = r.resolvedAf ? `AF:${r.resolvedAf}` : `NM:${normalizeName(r.nombre_nomina)}`;\n if (!payrollBambooMap.has(key)) {\n payrollBambooMap.set(key, {\n afiliacion: r.resolvedAf || r.igss_norm || '',\n records: []\n });\n }\n payrollBambooMap.get(key).records.push(r);\n}\n\nconst payrollBambooGroups = [...payrollBambooMap.values()];\nfor (const g of payrollBambooGroups) {\n g.nombre = chooseBestName(g.records);\n g.quincenas = uniqueJoin(g.records.map(r => r.q));\n g.proyectos = uniqueJoin(g.records.map(r => r.proyecto_nomina));\n g.exclusion_bamboo = getBambooExclusion(g.afiliacion, g.nombre);\n\n if (g.exclusion_bamboo) {\n g.match = { record:null, score:0, method:'exclusión autorizada' };\n g.esta_bamboo = 'No aplica';\n g.fecha_ingreso = '';\n g.estado_bamboo = '';\n g.nombre_bamboo = '';\n g.metodo_bamboo = 'exclusión autorizada';\n } else {\n g.match = getBambooMatch(g.nombre);\n g.esta_bamboo = g.match.record ? 'Sí' : 'No';\n g.fecha_ingreso = g.match.record?.fecha_ingreso || '';\n g.estado_bamboo = bambooStatusLabel(g.match.record?.estado_bamboo || '');\n g.nombre_bamboo = g.match.record?.nombre_bamboo || '';\n g.metodo_bamboo = g.match.method || '';\n }\n}\n\nconst payrollBambooByAf = new Map(\n payrollBambooGroups\n .filter(g => g.afiliacion)\n .map(g => [String(g.afiliacion), g])\n);\nconst bambooExclusionsApplied = payrollBambooGroups.filter(g => g.exclusion_bamboo);\nconst payrollBambooEvaluados = payrollBambooGroups.filter(g => !g.exclusion_bamboo);\nconst nominaSinBamboo = payrollBambooEvaluados.filter(g => !g.match.record);\n\nfor (const [af, g] of groups) {\n const nombre = chooseBestName(g.records);\n const info = payrollBambooByAf.get(String(af)) || {\n nombre,\n exclusion_bamboo: getBambooExclusion(af, nombre),\n match: getBambooMatch(nombre)\n };\n g.exclusion_bamboo = info.exclusion_bamboo || null;\n g.bambooMatch = g.exclusion_bamboo ? null : (info.match?.record || null);\n g.esta_bamboo = g.exclusion_bamboo ? 'No aplica' : (g.bambooMatch ? 'Sí' : 'No');\n g.fecha_ingreso = g.bambooMatch?.fecha_ingreso || '';\n g.estado_bamboo = bambooStatusLabel(g.bambooMatch?.estado_bamboo || '');\n g.nombre_bamboo = g.bambooMatch?.nombre_bamboo || '';\n g.metodo_bamboo = g.exclusion_bamboo ? 'exclusión autorizada' : (info.match?.method || '');\n}\n\nconst matchedEmpAf = new Set([...groups.keys()].filter(af => empByAf.has(af)));\nconst matchedPlanAf = new Set([...groups.keys()].filter(af => planByAf.has(af)));\n\nconst soloContabilidad = empleados.filter(e => !matchedEmpAf.has(e.afiliacion));\nconst soloNomina = [...groups.values()].filter(g => !empByAf.has(g.afiliacion));\nconst afiliacionDiferencias = soloContabilidad.length + soloNomina.length;\n\n// Pestaña 1: Cruce Afiliaciones\nconst afiliacionRows = [];\nafiliacionRows.push(['Cruce de afiliaciones - Guatemala']);\nafiliacionRows.push([`Período: ${init.periodoLabel}`]);\nafiliacionRows.push(['Resultado', afiliacionDiferencias ? 'REVISAR DIFERENCIAS' : 'SIN DIFERENCIAS']);\nafiliacionRows.push(['']);\nafiliacionRows.push(['Resumen', 'Cantidad']);\nafiliacionRows.push(['Registros de nómina revisados (Q1 + Q2)', payroll.length]);\nafiliacionRows.push(['Empleados únicos en nómina', groups.size]);\nafiliacionRows.push(['Empleados en contabilidad', empleados.length]);\nafiliacionRows.push(['Empleados de nómina evaluados contra BambooHR', payrollBambooEvaluados.length]);\nafiliacionRows.push(['Empleados fuera de BambooHR por regla autorizada', bambooExclusionsApplied.length]);\nafiliacionRows.push(['Empleados de nómina no encontrados en BambooHR', nominaSinBamboo.length]);\nafiliacionRows.push(['Diferencias encontradas', afiliacionDiferencias]);\nafiliacionRows.push(['']);\nafiliacionRows.push(['Lectura rápida']);\nafiliacionRows.push([afiliacionDiferencias ? 'Hay empleados que aparecen en una fuente y no en la otra. Revisar las secciones de abajo.' : 'No hay empleados faltantes ni sobrantes entre nómina y contabilidad.']);\nafiliacionRows.push(['']);\nafiliacionRows.push(['No se muestran los empleados que coinciden correctamente.']);\nafiliacionRows.push(['']);\nafiliacionRows.push(['Empleados en contabilidad que NO aparecen en nómina']);\nafiliacionRows.push(['Afiliación', 'Nombre completo', 'Qué significa', 'Está en Bamboo?', 'Fecha de Ingreso', 'Activo/Inactivo', 'Nombre en BambooHR']);\nif (soloContabilidad.length) {\n for (const e of soloContabilidad) {\n const bm = getBambooMatch(e.nombre_contabilidad);\n afiliacionRows.push([\n e.afiliacion,\n e.nombre_contabilidad,\n 'Está en contabilidad, pero no aparece en las nóminas Q1/Q2.',\n bm.record ? 'Sí' : 'No',\n bm.record?.fecha_ingreso || '',\n bambooStatusLabel(bm.record?.estado_bamboo || ''),\n bm.record?.nombre_bamboo || ''\n ]);\n }\n} else {\n afiliacionRows.push(['Sin diferencias', '', '', '', '', '', '']);\n}\nafiliacionRows.push(['']);\nafiliacionRows.push(['Empleados en nómina que NO aparecen en contabilidad']);\nafiliacionRows.push(['Afiliación / IGSS', 'Nombre completo', 'Quincena(s)', 'Proyecto(s)', 'Qué significa', 'Está en Bamboo?', 'Fecha de Ingreso', 'Activo/Inactivo', 'Nombre en BambooHR']);\nif (soloNomina.length) {\n for (const g of soloNomina) {\n afiliacionRows.push([\n g.afiliacion,\n uniqueJoin(g.records.map(r=>r.nombre_nomina)),\n uniqueJoin(g.records.map(r=>r.q)),\n uniqueJoin(g.records.map(r=>r.proyecto_nomina)),\n 'Está en nómina, pero no existe en EMPLEADOS de contabilidad.',\n g.esta_bamboo || 'No',\n g.fecha_ingreso || '',\n g.estado_bamboo || '',\n g.nombre_bamboo || ''\n ]);\n }\n} else {\n afiliacionRows.push(['Sin diferencias', '', '', '', '', '', '', '', '']);\n}\n\n// Pestaña 2: Sin IGSS en nómina\nconst sinIgssSheetRows = [];\nsinIgssSheetRows.push(['Sin IGSS en nómina']);\nsinIgssSheetRows.push([`Período: ${init.periodoLabel}`]);\nsinIgssSheetRows.push(['Resultado', sinIgssRows.length ? 'REGISTROS PARA COMPLETAR / CORREGIR' : 'SIN REGISTROS']);\nsinIgssSheetRows.push(['']);\nsinIgssSheetRows.push(['Resumen', 'Cantidad']);\nsinIgssSheetRows.push(['Registros con IGSS pendiente/vacío o a corregir', sinIgssRows.length]);\nsinIgssSheetRows.push(['']);\nsinIgssSheetRows.push(['Lectura rápida']);\nsinIgssSheetRows.push([sinIgssRows.length ? 'Estos empleados existen en contabilidad, pero la columna IGSS de la nómina debe completarse o corregirse.' : 'No hay registros de nómina con IGSS pendiente/vacío o a corregir.']);\nsinIgssSheetRows.push(['']);\nsinIgssSheetRows.push(['Quincena', 'Fila nómina', 'Nombre completo', 'Proyecto', 'IGSS en nómina', 'Afiliación correcta', 'Acción sugerida']);\nif (sinIgssRows.length) {\n for (const r of sinIgssRows.sort((a,b) => String(a.q).localeCompare(String(b.q)) || Number(a.rowNumber||0)-Number(b.rowNumber||0))) {\n sinIgssSheetRows.push([\n r.q,\n r.rowNumber,\n r.nombre_nomina,\n r.proyecto_nomina,\n r.igss_original || '',\n r.resolvedAf || '',\n r.accion\n ]);\n }\n} else {\n sinIgssSheetRows.push(['Sin registros', '', '', '', '', '', '']);\n}\n\n// Pestaña 3: Nómina sin BambooHR\nconst nominaSinBambooRows = [];\nnominaSinBambooRows.push(['Nómina sin BambooHR']);\nnominaSinBambooRows.push([`Período: ${init.periodoLabel}`]);\nnominaSinBambooRows.push(['Resultado', nominaSinBamboo.length ? 'REVISAR EMPLEADOS NO ENCONTRADOS' : 'SIN DIFERENCIAS']);\nnominaSinBambooRows.push(['']);\nnominaSinBambooRows.push(['Resumen', 'Cantidad']);\nnominaSinBambooRows.push(['Empleados únicos de nómina evaluados', payrollBambooEvaluados.length]);\nnominaSinBambooRows.push(['Empleados fuera de BambooHR por regla autorizada', bambooExclusionsApplied.length]);\nnominaSinBambooRows.push(['Empleados de nómina no encontrados en BambooHR', nominaSinBamboo.length]);\nnominaSinBambooRows.push(['']);\nnominaSinBambooRows.push(['Lectura rápida']);\nnominaSinBambooRows.push([nominaSinBamboo.length\n ? 'Estos empleados aparecen en Nómina Q1/Q2, pero no se encontró una coincidencia suficientemente clara en BambooHR.'\n : 'Todos los empleados únicos de nómina fueron encontrados en BambooHR.']);\nnominaSinBambooRows.push(['']);\nnominaSinBambooRows.push(['Empleados de nómina que NO aparecen en BambooHR']);\nnominaSinBambooRows.push(['Afiliación / IGSS', 'Nombre completo', 'Quincena(s)', 'Proyecto(s)', 'Está en Bamboo?', 'Fecha de Ingreso', 'Qué significa']);\nif (nominaSinBamboo.length) {\n for (const g of nominaSinBamboo.sort((a,b) => String(a.nombre).localeCompare(String(b.nombre)))) {\n nominaSinBambooRows.push([\n g.afiliacion || '',\n g.nombre,\n g.quincenas,\n g.proyectos,\n 'No',\n '',\n 'Está en nómina, pero no se encontró en BambooHR por nombre exacto o coincidencia clara.'\n ]);\n }\n} else {\n nominaSinBambooRows.push(['Sin diferencias', '', '', '', '', '', '']);\n}\n\n// Cruce de montos después de ajustes\nconst montoFindings = [];\nconst noComparados = [];\nlet montoOk = 0;\n\nfor (const [af,g] of groups) {\n const pl = planByAf.get(af);\n if (!pl) {\n noComparados.push({estado:'Solo en nóminas',af,g,pl:null,obs:'No existe en PLANILLA MENSUAL.'});\n continue;\n }\n const diff = money(g.monto - pl.monto_planilla_mensual);\n if (Math.abs(diff) <= 0.05) {\n montoOk++;\n } else {\n montoFindings.push({af,g,pl,diff});\n }\n}\nfor (const pl of planilla) {\n if (!matchedPlanAf.has(pl.afiliacion)) {\n noComparados.push({estado:'Solo en PLANILLA MENSUAL',af:pl.afiliacion,g:null,pl,obs:'Existe en PLANILLA MENSUAL, pero no se relacionó con las nóminas Q1/Q2.'});\n }\n}\nfor (const r of unresolvedRows) {\n noComparados.push({estado:'IGSS sin resolver',af:'',g:{records:[r],monto:r.monto_afecto_gtq},pl:null,obs:'No se pudo resolver afiliación por número ni por nombre.'});\n}\nfor (const a of ajustesNoResueltos) {\n noComparados.push({estado:'Ajuste sin resolver',af:'',g:{records:[a],monto:a.monto_ajuste_gtq},pl:null,obs:'No se pudo relacionar el ajuste/complemento por IGSS ni por nombre.'});\n}\nmontoFindings.sort((a,b)=>Math.abs(b.diff)-Math.abs(a.diff));\nconst montoResultado = (montoFindings.length || noComparados.length) ? 'REVISAR DIFERENCIAS' : 'SIN DIFERENCIAS';\n\n// Pestaña 4: Cruce Montos\nconst montosRows = [];\nmontosRows.push(['Cruce de montos afectos - Guatemala']);\nmontosRows.push([`Período: ${init.periodoLabel}`]);\nmontosRows.push(['Resultado', montoResultado]);\nmontosRows.push([`Tipo de cambio USD usado: ${usdRate === 1 ? 'No aplicado' : usdRate}`]);\nmontosRows.push(['']);\nmontosRows.push(['Resumen', 'Cantidad']);\nmontosRows.push(['Afiliaciones comparadas con monto correcto', montoOk]);\nmontosRows.push(['Diferencias reales de monto', montoFindings.length]);\nmontosRows.push(['Casos no comparados', noComparados.length]);\nmontosRows.push(['Ajustes/complementos incluidos', ajustesIncluidos.length]);\nmontosRows.push(['']);\nmontosRows.push(['Lectura rápida']);\nmontosRows.push([montoResultado === 'SIN DIFERENCIAS' ? 'No hay diferencias de monto después de sumar nómina Q1, nómina Q2 y ajustes/complementos.' : 'Hay diferencias o casos no comparados. Revisar las secciones de abajo.']);\nmontosRows.push(['']);\nmontosRows.push(['No se muestran los empleados que cuadran correctamente.']);\nmontosRows.push(['']);\nmontosRows.push(['Diferencias reales de monto']);\nmontosRows.push(['Afiliación', 'Nombre completo', 'Proyecto', 'Monto nómina Q1+Q2 + ajustes', 'Monto planilla mensual', 'Diferencia', 'Detalle', 'Qué revisar']);\nif (montoFindings.length) {\n for (const f of montoFindings) {\n const details = [\n ...f.g.records.map(r => `${r.q}: ${fmt(r.monto_afecto_gtq)}${r.tipo_cambio_usd !== 1 ? ' GTQ (USD convertido)' : ''}`),\n ...f.g.ajustes.map(a => `Ajuste ${a.q}/${a.sheetTitle}: ${fmt(a.monto_ajuste_gtq)}`)\n ].join(' | ');\n montosRows.push([\n f.af,\n f.pl?.nombre_planilla || uniqueJoin(f.g.records.map(r=>r.nombre_nomina)),\n f.pl?.proyecto_planilla || uniqueJoin(f.g.records.map(r=>r.proyecto_nomina)),\n f.g.monto,\n f.pl.monto_planilla_mensual,\n f.diff,\n details,\n 'Revisar diferencia entre nómina combinada y PLANILLA MENSUAL.'\n ]);\n }\n} else {\n montosRows.push(['Sin diferencias', '', '', '', '', '', '', '']);\n}\nmontosRows.push(['']);\nmontosRows.push(['Casos no comparados']);\nmontosRows.push(['Estado','Afiliación','Nombre completo','Monto nómina / ajuste','Monto planilla mensual','Qué significa']);\nif (noComparados.length) {\n for (const f of noComparados) {\n montosRows.push([\n f.estado,\n f.af,\n f.pl?.nombre_planilla || uniqueJoin((f.g?.records || []).map(r=>r.nombre_nomina)),\n f.g?.monto || '',\n f.pl?.monto_planilla_mensual || '',\n f.obs\n ]);\n }\n} else {\n montosRows.push(['Sin casos', '', '', '', '', '']);\n}\n\n// Pestaña 5: Ajustes incluidos\nconst ajustesRows = [];\najustesRows.push(['Ajustes incluidos']);\najustesRows.push([`Período: ${init.periodoLabel}`]);\najustesRows.push(['Resultado', ajustesIncluidos.length ? `${ajustesIncluidos.length} ajuste(s) incluidos en el cruce de montos` : 'Sin ajustes incluidos']);\najustesRows.push(['']);\najustesRows.push(['Lectura rápida']);\najustesRows.push([ajustesIncluidos.length ? 'Estos ajustes/complementos se sumaron para comparar correctamente contra PLANILLA MENSUAL.' : 'No se encontraron ajustes/complementos para sumar al cruce de montos.']);\najustesRows.push(['']);\najustesRows.push(['Quincena', 'Hoja origen', 'Fila', 'Afiliación', 'Nombre completo', 'Proyecto', 'Monto ajuste', 'Tipo', 'Observación']);\nif (ajustesIncluidos.length) {\n for (const a of ajustesIncluidos.sort((a,b) => String(a.q).localeCompare(String(b.q)) || String(a.sheetTitle).localeCompare(String(b.sheetTitle)) || Number(a.rowNumber||0)-Number(b.rowNumber||0))) {\n ajustesRows.push([\n a.q,\n a.sheetTitle,\n a.rowNumber,\n a.resolvedAf,\n a.nombre_nomina,\n a.proyecto_nomina,\n a.monto_ajuste_gtq,\n a.tipo_ajuste,\n a.observacion_ajuste\n ]);\n }\n} else {\n ajustesRows.push(['Sin ajustes', '', '', '', '', '', '', '', '']);\n}\n\nconst q1Count = payroll.filter(r=>r.q==='1Q').length;\nconst q2Count = payroll.filter(r=>r.q==='2Q').length;\n\nconst summary = {\n periodo:init.periodoLabel,\n q1Rows:q1Count,\n q2Rows:q2Count,\n empleados:empleados.length,\n planilla:planilla.length,\n empleadosUnicosNomina:groups.size,\n afiliacionSoloContabilidad:soloContabilidad.length,\n afiliacionSoloNomina:soloNomina.length,\n afiliacionDiferencias,\n sinIgss:sinIgssRows.length,\n montoOk,\n montoFindings:montoFindings.length,\n noComparados:noComparados.length,\n ajustesIncluidos:ajustesIncluidos.length,\n bambooTotal:bamboo.length,\n empleadosNominaEvaluadosBamboo:payrollBambooEvaluados.length,\n bambooExcluidos:bambooExclusionsApplied.length,\n nominaSinBamboo:nominaSinBamboo.length,\n usdRate,\n files:slot.files || {}\n};\n\ndelete data.gtSeguridadSocial[init.runId];\n\nreturn [{\n json: {\n runId:init.runId,\n reportTitle:init.reportTitle,\n emailTo:init.emailTo || 'iaracena@gomezleemarketing.com',\n afiliacionRows: sanitizeRows(afiliacionRows),\n sinIgssRows: sanitizeRows(sinIgssSheetRows),\n nominaSinBambooRows: sanitizeRows(nominaSinBambooRows),\n montosRows: sanitizeRows(montosRows),\n ajustesRows: sanitizeRows(ajustesRows),\n summary,\n rowCounts:{\n afiliacion:afiliacionRows.length,\n sinIgss:sinIgssSheetRows.length,\n nominaSinBamboo:nominaSinBambooRows.length,\n montos:montosRows.length,\n ajustes:ajustesRows.length\n }\n }\n}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 16416, 2384 ], "id": "ffee68ac-df72-4561-939d-1b4dae369e15", "name": "Construir reporte producción" }, { "parameters": { "method": "POST", "url": "https://sheets.googleapis.com/v4/spreadsheets", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleSheetsOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ properties: { title: $json.reportTitle }, sheets: [ { properties: { title: 'Cruce Afiliaciones' } }, { properties: { title: 'Sin IGSS en nómina' } }, { properties: { title: 'Nómina sin BambooHR' } }, { properties: { title: 'Cruce Montos' } }, { properties: { title: 'Ajustes incluidos' } } ] }) }}", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 16656, 2384 ], "id": "2597a490-b065-45d1-902b-5abd11019177", "name": "Crear Google Sheet Reporte", "credentials": { "googleSheetsOAuth2Api": { "id": "K0hDZh3a85MpOHCs", "name": "Google Sheets account 2" } } }, { "parameters": { "method": "POST", "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $('Crear Google Sheet Reporte').item.json.spreadsheetId }}/values:batchUpdate", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleSheetsOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ data: [ { range: 'Cruce Afiliaciones!A1', majorDimension: 'ROWS', values: $('Construir reporte producción').item.json.afiliacionRows }, { range: 'Sin IGSS en nómina!A1', majorDimension: 'ROWS', values: $('Construir reporte producción').item.json.sinIgssRows }, { range: 'Nómina sin BambooHR!A1', majorDimension: 'ROWS', values: $('Construir reporte producción').item.json.nominaSinBambooRows }, { range: 'Cruce Montos!A1', majorDimension: 'ROWS', values: $('Construir reporte producción').item.json.montosRows }, { range: 'Ajustes incluidos!A1', majorDimension: 'ROWS', values: $('Construir reporte producción').item.json.ajustesRows } ], valueInputOption: 'RAW' }) }}", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 16896, 2384 ], "id": "7971ea0b-9fca-47d9-9595-2f1c5cb59046", "name": "Escribir reporte", "credentials": { "googleSheetsOAuth2Api": { "id": "K0hDZh3a85MpOHCs", "name": "Google Sheets account 2" } } }, { "parameters": { "method": "POST", "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $('Crear Google Sheet Reporte').item.json.spreadsheetId }}:batchUpdate", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleSheetsOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ (() => {\n const sheets = $('Crear Google Sheet Reporte').item.json.sheets || [];\n const colors = [\n { red: 0.91, green: 0.96, blue: 0.90 },\n { red: 1.00, green: 0.96, blue: 0.80 },\n { red: 1.00, green: 0.90, blue: 0.84 },\n { red: 0.91, green: 0.96, blue: 0.90 },\n { red: 0.93, green: 0.94, blue: 0.96 }\n ];\n const requests = [];\n sheets.forEach((sheet, index) => {\n const sheetId = sheet.properties.sheetId;\n requests.push(\n { autoResizeDimensions: { dimensions: { sheetId, dimension: 'COLUMNS', startIndex: 0, endIndex: 14 } } },\n { updateSheetProperties: { properties: { sheetId, gridProperties: { frozenRowCount: 1 } }, fields: 'gridProperties.frozenRowCount' } },\n { repeatCell: { range: { sheetId, startRowIndex: 0, endRowIndex: 1 }, cell: { userEnteredFormat: { textFormat: { bold: true, fontSize: 14 }, backgroundColor: colors[index] || colors[0] } }, fields: 'userEnteredFormat.textFormat,userEnteredFormat.backgroundColor' } },\n { repeatCell: { range: { sheetId }, cell: { userEnteredFormat: { wrapStrategy: 'WRAP' } }, fields: 'userEnteredFormat.wrapStrategy' } }\n );\n });\n return JSON.stringify({ requests });\n})() }}", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 17136, 2384 ], "id": "0d71593a-4ce2-4f35-8f9e-e0e3c75b5d78", "name": "Formatear reporte", "credentials": { "googleSheetsOAuth2Api": { "id": "K0hDZh3a85MpOHCs", "name": "Google Sheets account 2" } } }, { "parameters": { "method": "POST", "url": "=https://www.googleapis.com/drive/v3/files/{{ $('Crear Google Sheet Reporte').item.json.spreadsheetId }}/permissions?sendNotificationEmail=false&supportsAllDrives=true", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleDriveOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ role: 'writer', type: 'user', emailAddress: 'iaracena@gomezleemarketing.com' }) }}", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 17376, 2384 ], "id": "f941fae6-def7-4417-a4c8-62a6efe8e11a", "name": "Compartir con Isaac", "retryOnFail": true, "maxTries": 3, "waitBetweenTries": 1000, "credentials": { "googleDriveOAuth2Api": { "id": "g23xdGLZRzBGqKgH", "name": "Isaac - Google Drive" } }, "onError": "continueRegularOutput" }, { "parameters": { "method": "POST", "url": "=https://www.googleapis.com/drive/v3/files/{{ $('Crear Google Sheet Reporte').item.json.spreadsheetId }}/permissions?sendNotificationEmail=false&supportsAllDrives=true", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleDriveOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ role: 'writer', type: 'user', emailAddress: 'ymadera@gomezleemarketing.com' }) }}", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 17616, 2384 ], "id": "97660ee4-c52e-4996-ad4d-7a01aa954010", "name": "Compartir con Yanelly editor", "retryOnFail": true, "maxTries": 3, "waitBetweenTries": 1000, "credentials": { "googleDriveOAuth2Api": { "id": "g23xdGLZRzBGqKgH", "name": "Isaac - Google Drive" } }, "onError": "continueRegularOutput" }, { "parameters": { "sendTo": "msoto@gomezleemarketing.com, iaracena@gomezleemarketing.com, iherrera@gomezleemarketing.com, vparamo@gomezleemarketing.com, asrodriguez@gomezleemarketing.com, mgomez@gomezleemarketing.com, ymadera@gomezleemarketing.com", "subject": "={{ 'Cruce Seguridad Social Guatemala - ' + ($('Construir reporte producción').item.json.summary.periodo || '') }}", "message": "={{ (() => {\nconst s = $('Construir reporte producción').item.json.summary || {};\nconst url = $('Crear Google Sheet Reporte').item.json.spreadsheetUrl || '#';\nconst afiliacionDiff = Number(s.afiliacionDiferencias || 0);\nconst montoDiff = Number(s.montoFindings || 0);\nconst noComparados = Number(s.noComparados || 0);\nconst sinIgss = Number(s.sinIgss || 0);\nconst ajustes = Number(s.ajustesIncluidos || 0);\nconst sinBamboo = Number(s.nominaSinBamboo || 0);\nconst hardDiff = afiliacionDiff + montoDiff + noComparados + sinBamboo;\nconst status = hardDiff ? 'REQUIERE REVISIÓN' : (sinIgss ? 'COMPLETADO · DATOS A CORREGIR' : 'COMPLETADO · SIN DIFERENCIAS');\nconst statusBg = hardDiff ? '#fff7ed' : (sinIgss ? '#fffbeb' : '#f0fdf4');\nconst statusBorder = hardDiff ? '#fed7aa' : (sinIgss ? '#fde68a' : '#bbf7d0');\nconst statusColor = hardDiff ? '#9a3412' : (sinIgss ? '#92400e' : '#166534');\nreturn `
\n
\n
\n \"GomezLee\n
SEGURIDAD SOCIAL · GUATEMALA
\n

Cruce mensual completado

\n

${s.periodo || ''} · Nóminas Q1 + Q2

\n
\n
\n
${status}
\n
\n
\n
NÓMINA Q1
\n
${s.q1Rows||0}
\n
\n
\n
NÓMINA Q2
\n
${s.q2Rows||0}
\n
\n
\n
HALLAZGOS A REVISAR
\n
${hardDiff}
\n
\n
\n
\n
\n

Afiliaciones

\n

Resultado ${afiliacionDiff ? 'Revisar' : 'Sin diferencias'}

\n

Empleados únicos nómina ${s.empleadosUnicosNomina||0}

\n

Sin IGSS en nómina ${sinIgss}

\n

Nómina sin BambooHR ${sinBamboo}

\n
\n
\n

Montos afectos

\n

Diferencias reales ${montoDiff}

\n

Casos no comparados ${noComparados}

\n

Ajustes incluidos ${ajustes}

\n
\n
\n
\n Abrir reporte en Google Sheets\n
\n
\n Archivos procesados
\n Nómina Q1: ${s.files?.q1||''}
\n Nómina Q2: ${s.files?.q2||''}
\n Sistema Propio IGSS: ${s.files?.sistema||''}
\n Planilla Consolidada: ${s.files?.consolidado||''}\n
\n
\n
\n
`;\n})() }}", "options": { "appendAttribution": false } }, "type": "n8n-nodes-base.gmail", "typeVersion": 2.1, "position": [ 19072, 2576 ], "id": "ef4cb153-8fe4-43d4-acaf-49d0d927951b", "name": "Enviar correo", "webhookId": "a3513342-026e-470b-a4f1-319761ae1bca", "credentials": { "gmailOAuth2": { "id": "UDcO1FLJqA453V2D", "name": "Gmail account 3" } } }, { "parameters": { "content": "## Inicio de Flujo\nRecibir adjuntos a través de formulario y preparación de nóminas", "height": 624, "width": 4512, "color": 5 }, "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ 8048, 1936 ], "id": "a6eefbde-4ba8-4137-a6ff-c483675a0145", "name": "Sticky Note" }, { "parameters": { "content": "## Preparar archivos de contadores\n\nPreparar archivos Sistema Propio y Planilla Consolidada", "height": 320, "width": 3344 }, "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ 12784, 2256 ], "id": "19e73e16-4886-4683-8323-9beef2ff14e1", "name": "Sticky Note1" }, { "parameters": { "content": "## Preparar reporte y enviar correo\n\nConstruir reporte y prepararlo en el formato adecuado. Preparar correo para envío.", "height": 944, "width": 3296, "color": 4 }, "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ 16272, 1840 ], "id": "794fb56f-e352-4440-aa18-20a992dfbadf", "name": "Sticky Note2" }, { "parameters": { "amount": 8 }, "type": "n8n-nodes-base.wait", "typeVersion": 1.1, "position": [ 9328, 2368 ], "id": "ab82d494-0163-4ed9-9256-8839ba8a8684", "name": "Esperar conversión - Nómina 1Q", "webhookId": "12e87d62-e245-459a-ad10-dcccee2beca9" }, { "parameters": { "amount": 8 }, "type": "n8n-nodes-base.wait", "typeVersion": 1.1, "position": [ 11072, 2368 ], "id": "453f854e-31ce-4056-9246-55092399eff2", "name": "Esperar conversión - Nómina 2Q", "webhookId": "6be5dfdc-e90d-40f2-9349-35688e3532f1" }, { "parameters": { "amount": 8 }, "type": "n8n-nodes-base.wait", "typeVersion": 1.1, "position": [ 13280, 2384 ], "id": "657d681f-d26c-4294-957a-811ec0e20039", "name": "Esperar conversión - Sistema Propio", "webhookId": "8b362815-600a-4846-ad4f-f09897acfb9f" }, { "parameters": { "amount": 8 }, "type": "n8n-nodes-base.wait", "typeVersion": 1.1, "position": [ 15248, 2384 ], "id": "95a9a0ac-7ac3-4df9-9b93-ff66640bbb64", "name": "Esperar conversión - Consolidada", "webhookId": "a36aedc6-3818-4bad-8b81-f4b2f7f2f02d" }, { "parameters": { "jsCode": "const init = $items('Inicializar memoria')[0].json;\nconst source = $items('Inicializar memoria')[0];\nconst bin = source.binary || {};\nconst archivos = {};\nfor (const [key, file] of Object.entries(bin)) {\n archivos[key] = {\n nombre_archivo: file.fileName || key,\n mime_type: file.mimeType || '',\n tamano_bytes: file.fileSize || file.size || null\n };\n}\n\nreturn [{\n json: {\n id_ejecucion: init.runId,\n nombre_flujo: 'Cruce de Seguridad Social Guatemala',\n id_flujo_n8n: $workflow.id || null,\n id_ejecucion_n8n: $execution.id || null,\n pais_codigo: 'GT',\n pais_nombre: 'Guatemala',\n entidad_seguridad_social: 'IGSS',\n mes: Number(init.mes || 0),\n anio: Number(init.anio || 0),\n periodo: init.periodoLabel || `${init.mes}/${init.anio}`,\n estado: 'procesando',\n resultado: 'Cruce iniciado',\n usuario_ejecutor_email: null,\n destinatarios_correo: ['iaracena@gomezleemarketing.com', 'ymadera@gomezleemarketing.com'],\n moneda: 'GTQ',\n archivos_procesados: archivos,\n conteos_fuente: {},\n metricas: {\n etapa: 'inicio',\n formulario: 'Cruce Seguridad Social Guatemala'\n },\n resumen_completo: {\n runId: init.runId,\n periodo: init.periodoLabel,\n archivos_recibidos: archivos\n }\n }\n}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 8368, 2144 ], "id": "86ae8a39-aa36-4f3d-9f94-603629c34598", "name": "Preparar auditoría inicial Supabase", "onError": "continueRegularOutput" }, { "parameters": { "method": "POST", "url": "=https://dbit.digitalcompass.agency/rest/v1/auditoria_cruces_seguridad_social?on_conflict=id_ejecucion", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "apikey", "value": "=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" }, { "name": "Authorization", "value": "=Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" }, { "name": "Content-Type", "value": "application/json" }, { "name": "Prefer", "value": "resolution=merge-duplicates,return=minimal" } ] }, "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify($json) }}", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 8608, 2144 ], "id": "b8ce027b-f3db-485b-ace8-b69a6b044e71", "name": "Supabase - Crear auditoría inicial", "onError": "continueRegularOutput" }, { "parameters": { "jsCode": "function n(v){ const x = Number(v || 0); return Number.isFinite(x) ? x : 0; }\nfunction money(v){ return Number((Number(v || 0)).toFixed(2)); }\nfunction clean(v){ return String(v ?? '').replace(/\\s+/g,' ').trim(); }\nfunction parseRunStart(runId){\n const m = String(runId || '').match(/GT_SEG_(\\d{4})(\\d{2})(\\d{2})_(\\d{2})(\\d{2})(\\d{2})/);\n if (!m) return null;\n const [,y,mo,d,h,mi,s] = m;\n const dt = new Date(Number(y), Number(mo)-1, Number(d), Number(h), Number(mi), Number(s));\n return Number.isFinite(dt.getTime()) ? dt : null;\n}\nfunction sumMontoDiff(rows){\n const data = Array.isArray(rows) ? rows : [];\n const start = data.findIndex(r => r && r[0] === 'Diferencias reales de monto');\n if (start < 0) return 0;\n let total = 0;\n for (let i = start + 2; i < data.length; i++) {\n const row = data[i] || [];\n if (!row.length || row.every(x => clean(x) === '')) break;\n if (clean(row[0]) === 'Sin diferencias') continue;\n total += n(row[5]);\n }\n return money(total);\n}\n\nconst init = $items('Inicializar memoria')[0].json || {};\nconst reporte = $items('Construir reporte producción')[0].json || {};\nconst sheet = $items('Crear Google Sheet Reporte')[0].json || {};\nconst s = reporte.summary || {};\nconst now = new Date();\nconst start = parseRunStart(init.runId);\nconst executionSeconds = start ? Math.max(0, (now.getTime() - start.getTime()) / 1000) : null;\n\nconst diferenciasIdentidad = n(s.afiliacionDiferencias);\nconst diferenciasMonetarias = n(s.montoFindings);\nconst noComparados = n(s.noComparados);\nconst alertasDocumentales = n(s.sinIgss);\nconst sinBamboo = n(s.nominaSinBamboo);\nconst diferenciasTotal = diferenciasIdentidad + diferenciasMonetarias + noComparados + alertasDocumentales + sinBamboo;\nconst hardDiff = diferenciasIdentidad + diferenciasMonetarias + noComparados + sinBamboo;\nconst resultado = hardDiff\n ? 'REQUIERE REVISIÓN'\n : (alertasDocumentales ? 'COMPLETADO · DATOS A CORREGIR' : 'COMPLETADO · SIN DIFERENCIAS');\n\nconst minutosManual = 90;\nconst minutosAutomatizacion = executionSeconds === null ? null : money(executionSeconds / 60);\nconst minutosAhorrados = minutosAutomatizacion === null ? null : money(Math.max(0, minutosManual - minutosAutomatizacion));\nconst diferenciaMonto = sumMontoDiff(reporte.montosRows);\n\nconst finalPayload = {\n estado: 'exitoso',\n resultado,\n fecha_fin: now.toISOString(),\n duracion_segundos: executionSeconds === null ? null : money(executionSeconds),\n url_reporte: sheet.spreadsheetUrl || null,\n id_google_sheet: sheet.spreadsheetId || null,\n destinatarios_correo: [\"msoto@gomezleemarketing.com\", \"iaracena@gomezleemarketing.com\", \"iherrera@gomezleemarketing.com\", \"vparamo@gomezleemarketing.com\", \"asrodriguez@gomezleemarketing.com\", \"mgomez@gomezleemarketing.com\", \"ymadera@gomezleemarketing.com\"],\n moneda: 'GTQ',\n\n empleados_nomina: n(s.empleadosUnicosNomina),\n empleados_entidad: n(s.planilla),\n empleados_coincidentes: Math.max(0, n(s.empleadosUnicosNomina) - n(s.afiliacionSoloNomina)),\n solo_nomina: n(s.afiliacionSoloNomina),\n solo_entidad: n(s.afiliacionSoloContabilidad),\n\n diferencias_total: diferenciasTotal,\n diferencias_monetarias: diferenciasMonetarias,\n diferencias_identidad: diferenciasIdentidad,\n alertas_documentales: alertasDocumentales + noComparados + sinBamboo,\n registros_duplicados: 0,\n alertas_periodo: 0,\n\n total_nomina: null,\n total_entidad: null,\n diferencia_monto: diferenciaMonto,\n\n minutos_estimados_manual: minutosManual,\n minutos_automatizacion: minutosAutomatizacion,\n minutos_ahorrados: minutosAhorrados,\n\n archivos_procesados: s.files || {},\n conteos_fuente: {\n nomina_q1: n(s.q1Rows),\n nomina_q2: n(s.q2Rows),\n sistema_propio_empleados: n(s.empleados),\n planilla_consolidada: n(s.planilla),\n empleados_unicos_nomina: n(s.empleadosUnicosNomina),\n empleados_bamboo: n(s.bambooTotal),\n empleados_nomina_evaluados_bamboo: n(s.empleadosNominaEvaluadosBamboo),\n empleados_nomina_excluidos_bamboo: n(s.bambooExcluidos),\n nomina_sin_bamboo: sinBamboo\n },\n metricas: {\n monto_correcto: n(s.montoOk),\n monto_diferencias: diferenciasMonetarias,\n casos_no_comparados: noComparados,\n sin_igss: alertasDocumentales,\n nomina_sin_bamboo: sinBamboo,\n empleados_bamboo_consultados: n(s.bambooTotal),\n empleados_excluidos_bamboo: n(s.bambooExcluidos),\n ajustes_incluidos: n(s.ajustesIncluidos),\n tipo_cambio_usd: s.usdRate || null,\n filas_reporte: reporte.rowCounts || {},\n impacto: {\n minutos_manual_estimado: minutosManual,\n minutos_automatizacion: minutosAutomatizacion,\n minutos_ahorrados: minutosAhorrados\n }\n },\n resumen_completo: {\n summary: s,\n rowCounts: reporte.rowCounts || {},\n reportTitle: reporte.reportTitle,\n spreadsheetId: sheet.spreadsheetId || null,\n spreadsheetUrl: sheet.spreadsheetUrl || null\n },\n mensaje_error: null,\n nodo_error: null\n};\n\nreturn [{\n json: {\n id_ejecucion: init.runId,\n pais_codigo: 'GT',\n pais_nombre: 'Guatemala',\n moneda: 'GTQ',\n finalPayload,\n source: {\n summary: s,\n afiliacionRows: reporte.afiliacionRows || [],\n sinIgssRows: reporte.sinIgssRows || [],\n nominaSinBambooRows: reporte.nominaSinBambooRows || [],\n montosRows: reporte.montosRows || [],\n ajustesRows: reporte.ajustesRows || [],\n files: s.files || {},\n rowCounts: reporte.rowCounts || {},\n spreadsheetId: sheet.spreadsheetId || null,\n spreadsheetUrl: sheet.spreadsheetUrl || null\n }\n }\n}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 18816, 2144 ], "id": "322f0550-6f12-472d-9233-a17db84c9d7d", "name": "Preparar auditoría final Supabase", "onError": "continueRegularOutput" }, { "parameters": { "method": "PATCH", "url": "=https://dbit.digitalcompass.agency/rest/v1/auditoria_cruces_seguridad_social?id_ejecucion=eq.{{$json.id_ejecucion}}", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "apikey", "value": "=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" }, { "name": "Authorization", "value": "=Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" }, { "name": "Content-Type", "value": "application/json" }, { "name": "Prefer", "value": "return=minimal" } ] }, "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify($json.finalPayload) }}", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 19056, 2144 ], "id": "65df2fd6-97eb-4bdc-ba5d-9590442b4181", "name": "Supabase - Actualizar auditoría final", "onError": "continueRegularOutput" }, { "parameters": { "jsCode": "function clean(v){ return String(v ?? '').replace(/\\s+/g,' ').trim(); }\nfunction n(v){ const x = Number(v || 0); return Number.isFinite(x) ? x : null; }\nfunction push(rows, obj){ rows.push({ json: obj }); }\nfunction parseSection(rows, title){\n const data = Array.isArray(rows) ? rows : [];\n const start = data.findIndex(r => r && r[0] === title);\n if (start < 0) return [];\n const out = [];\n for (let i = start + 2; i < data.length; i++) {\n const row = data[i] || [];\n if (!row.length || row.every(x => clean(x) === '')) break;\n if (String(row[0] || '').toLowerCase().startsWith('sin ')) continue;\n out.push(row);\n }\n return out;\n}\n\nconst base = $input.first().json;\nconst src = base.source || {};\nconst common = {\n id_ejecucion: base.id_ejecucion,\n pais_codigo: 'GT',\n pais_nombre: 'Guatemala',\n moneda: 'GTQ'\n};\nconst out = [];\n\nfor (const r of parseSection(src.afiliacionRows, 'Empleados en contabilidad que NO aparecen en nómina')) {\n push(out, {\n ...common,\n categoria: 'Solo en contabilidad / Sistema Propio IGSS',\n severidad: 'revision',\n identificador_empleado: clean(r[0]),\n nombre_empleado: clean(r[1]),\n fuente_a: 'Sistema Propio IGSS / Contabilidad',\n fuente_b: 'Nómina Q1/Q2',\n diferencia_monto: null,\n descripcion: clean(r[2]) || 'Está en contabilidad, pero no aparece en las nóminas Q1/Q2.',\n accion_sugerida: 'Validar si el empleado debe estar en nómina del período o si corresponde depurar contabilidad.',\n datos_originales: { fila_reporte: r }\n });\n}\n\nfor (const r of parseSection(src.afiliacionRows, 'Empleados en nómina que NO aparecen en contabilidad')) {\n push(out, {\n ...common,\n categoria: 'Solo en nómina',\n severidad: 'revision',\n identificador_empleado: clean(r[0]),\n nombre_empleado: clean(r[1]),\n fuente_a: 'Nómina Q1/Q2',\n fuente_b: 'Sistema Propio IGSS / Contabilidad',\n diferencia_monto: null,\n descripcion: clean(r[4]) || 'Está en nómina, pero no existe en EMPLEADOS de contabilidad.',\n accion_sugerida: 'Completar o corregir afiliación en Sistema Propio IGSS / Contabilidad.',\n datos_originales: { fila_reporte: r, quincenas: r[2], proyectos: r[3] }\n });\n}\n\nfor (const r of parseSection(src.sinIgssRows, 'Quincena')) {\n push(out, {\n ...common,\n categoria: 'IGSS pendiente o inválido en nómina',\n severidad: 'advertencia',\n identificador_empleado: clean(r[5]) || clean(r[4]) || null,\n nombre_empleado: clean(r[2]),\n fuente_a: 'Nómina Q1/Q2',\n fuente_b: 'Sistema Propio IGSS / Contabilidad',\n diferencia_monto: null,\n descripcion: 'La nómina tiene IGSS pendiente, vacío o a corregir.',\n accion_sugerida: clean(r[6]) || 'Completar/corregir IGSS en nómina.',\n datos_originales: { fila_reporte: r, quincena: r[0], fila_nomina: r[1], proyecto: r[3], igss_nomina: r[4], afiliacion_correcta: r[5] }\n });\n}\n\nfor (const r of parseSection(src.nominaSinBambooRows, 'Empleados de nómina que NO aparecen en BambooHR')) {\n push(out, {\n ...common,\n categoria: 'Nómina sin BambooHR',\n severidad: 'revision',\n identificador_empleado: clean(r[0]) || null,\n nombre_empleado: clean(r[1]),\n fuente_a: 'Nómina Q1/Q2',\n fuente_b: 'BambooHR',\n diferencia_monto: null,\n descripcion: clean(r[6]) || 'Está en nómina, pero no se encontró en BambooHR.',\n accion_sugerida: 'Validar si el empleado debe crearse, reactivarse o corregirse en BambooHR.',\n datos_originales: { fila_reporte: r, quincenas: r[2], proyectos: r[3], esta_en_bamboo: r[4], fecha_ingreso: r[5] }\n });\n}\n\nfor (const r of parseSection(src.montosRows, 'Diferencias reales de monto')) {\n push(out, {\n ...common,\n categoria: 'Diferencia de monto afecto',\n severidad: 'revision',\n identificador_empleado: clean(r[0]),\n nombre_empleado: clean(r[1]),\n fuente_a: 'Nómina Q1 + Q2 + ajustes',\n fuente_b: 'Planilla Consolidada mensual',\n diferencia_monto: n(r[5]),\n descripcion: `Diferencia entre monto afecto de nómina y Planilla Consolidada: ${clean(r[5])}.`,\n accion_sugerida: clean(r[7]) || 'Revisar diferencia entre nómina combinada y PLANILLA MENSUAL.',\n datos_originales: { fila_reporte: r, proyecto: r[2], monto_nomina: r[3], monto_planilla: r[4], detalle: r[6] }\n });\n}\n\nfor (const r of parseSection(src.montosRows, 'Casos no comparados')) {\n push(out, {\n ...common,\n categoria: 'Caso no comparado',\n severidad: 'revision',\n identificador_empleado: clean(r[1]) || null,\n nombre_empleado: clean(r[2]),\n fuente_a: 'Nómina / Ajustes',\n fuente_b: 'Planilla Consolidada mensual',\n diferencia_monto: null,\n descripcion: clean(r[5]) || clean(r[0]) || 'Caso no comparado automáticamente.',\n accion_sugerida: 'Revisar afiliación y relación entre nómina, ajustes y Planilla Consolidada.',\n datos_originales: { fila_reporte: r, estado: r[0], monto_nomina_ajuste: r[3], monto_planilla: r[4] }\n });\n}\n\nreturn out;" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 19056, 1920 ], "id": "d411fcba-0919-4b90-aa65-38a43d7284be", "name": "Preparar hallazgos Supabase", "onError": "continueRegularOutput" }, { "parameters": { "method": "POST", "url": "=https://dbit.digitalcompass.agency/rest/v1/auditoria_cruces_hallazgos", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "apikey", "value": "=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" }, { "name": "Authorization", "value": "=Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" }, { "name": "Content-Type", "value": "application/json" }, { "name": "Prefer", "value": "return=minimal" } ] }, "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify($json) }}", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 19296, 1920 ], "id": "f1af589a-31f1-40d4-8133-c2e0f3e0bd07", "name": "Supabase - Insertar hallazgos", "onError": "continueRegularOutput" }, { "parameters": { "jsCode": "function clean(v){ return String(v ?? '').replace(/\\s+/g,' ').trim(); }\nfunction n(v){ const x = Number(v || 0); return Number.isFinite(x) ? x : null; }\nconst base = $input.first().json;\nconst s = (base.source || {}).summary || {};\nconst files = (base.source || {}).files || {};\nconst common = { id_ejecucion: base.id_ejecucion, pais_codigo: 'GT' };\nconst mapping = [\n { key:'q1', rol:'Nómina Q1', registros:s.q1Rows },\n { key:'q2', rol:'Nómina Q2', registros:s.q2Rows },\n { key:'sistema', rol:'Sistema Propio IGSS', registros:s.empleados },\n { key:'consolidado', rol:'Planilla Consolidada', registros:s.planilla },\n];\nconst out = [];\nfor (const m of mapping) {\n const name = clean(files[m.key]);\n if (!name) continue;\n out.push({ json: {\n ...common,\n rol_archivo: m.rol,\n nombre_archivo: name,\n tipo_archivo: name.toLowerCase().endsWith('.csv') ? 'csv' : 'excel',\n registros_detectados: n(m.registros),\n monto_total: null,\n datos_originales: { key: m.key, summary: s }\n }});\n}\nreturn out;" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 19072, 2304 ], "id": "b1900419-b8f6-4908-b1fe-433775196b28", "name": "Preparar archivos Supabase", "onError": "continueRegularOutput" }, { "parameters": { "method": "POST", "url": "=https://dbit.digitalcompass.agency/rest/v1/auditoria_cruces_archivos", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "apikey", "value": "=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" }, { "name": "Authorization", "value": "=Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" }, { "name": "Content-Type", "value": "application/json" }, { "name": "Prefer", "value": "return=minimal" } ] }, "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify($json) }}", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 19296, 2368 ], "id": "bec47dac-56d9-4607-af87-da34ad9da828", "name": "Supabase - Insertar archivos procesados", "onError": "continueRegularOutput" }, { "parameters": { "url": "https://glm.bamboohr.com/api/v1/employees?fields=fullName5,displayName,firstName,middleName,lastName,hireDate,originalHireDate,status,employeeNumber,location,country,workEmail&page%5Blimit%5D=2500", "authentication": "genericCredentialType", "genericAuthType": "httpBasicAuth", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "Accept", "value": "application/json" } ] }, "options": { "response": { "response": { "responseFormat": "json" } }, "pagination": { "pagination": { "parameters": { "parameters": [ { "name": "page[after]", "value": "={{ $response.body.meta.page.nextCursor }}" } ] }, "paginationCompleteWhen": "other", "completeExpression": "={{ !$response.body?.meta?.page?.nextCursor }}", "limitPagesFetched": true, "requestInterval": 500 } }, "timeout": 300000 } }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ 14432, 2384 ], "id": "c1fad1e0-9d86-42c5-b718-443e8e47d8d2", "name": "Consultar empleados BambooHR", "retryOnFail": true, "maxTries": 3, "waitBetweenTries": 2000, "credentials": { "httpBasicAuth": { "id": "7VrpNZ2jBLmiJ35q", "name": "BambooHR GLM Full Access" } } }, { "parameters": { "jsCode": "const init = $items('Inicializar memoria')[0].json;\nconst inputItems = $input.all();\n\nfunction clean(value) {\n return String(value ?? '').replace(/\\s+/g, ' ').trim();\n}\nfunction normalizeName(value) {\n return clean(value)\n .normalize('NFD').replace(/[\\u0300-\\u036f]/g, '')\n .toUpperCase()\n .replace(/[^A-Z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\nfunction normalizeDate(value) {\n const raw = clean(value);\n if (!raw) return '';\n const match = raw.match(/^(\\d{4}-\\d{2}-\\d{2})/);\n return match ? match[1] : raw;\n}\nfunction unwrapResponse(item) {\n const payload = item?.json || {};\n // Permite que el nodo siga funcionando aunque en el futuro se active\n // \"Include Response Headers and Status\".\n if (payload.body && typeof payload.body === 'object') return payload.body;\n return payload;\n}\n\nconst responses = inputItems.map(unwrapResponse);\nconst rows = [];\n\nfor (const response of responses) {\n if (Array.isArray(response.data)) {\n rows.push(...response.data);\n } else if (Array.isArray(response.employees)) {\n rows.push(...response.employees);\n } else if (Array.isArray(response)) {\n rows.push(...response);\n }\n}\n\nconst totals = responses\n .map(response => Number(response?.meta?.total || 0))\n .filter(value => Number.isFinite(value) && value > 0);\nconst totalReported = totals.length ? Math.max(...totals) : rows.length;\n\n// Comprobación independiente de integridad antes de normalizar nombres.\nconst rawByEmployee = new Map();\nrows.forEach((row, index) => {\n const key =\n clean(row.employeeId || row.id) ||\n clean(row.employeeNumber) ||\n `${normalizeName(row.fullName5 || row.displayName || [row.firstName, row.middleName, row.lastName].filter(Boolean).join(' '))}|${normalizeDate(row.hireDate || row.originalHireDate)}` ||\n `fila-${index}`;\n\n if (!rawByEmployee.has(key)) rawByEmployee.set(key, row);\n});\n\nconst uniqueRawRows = [...rawByEmployee.values()];\n\nif (totalReported > uniqueRawRows.length) {\n throw new Error(\n `BambooHR devolvió ${uniqueRawRows.length} empleados únicos de ${totalReported}. ` +\n `La paginación no terminó correctamente y el cruce no sería completo.`\n );\n}\n\nconst normalized = [];\nfor (const row of uniqueRawRows) {\n const nombre = clean(\n row.fullName5 ||\n row.displayName ||\n row.fullName1 ||\n [row.firstName, row.middleName, row.lastName].map(clean).filter(Boolean).join(' ')\n );\n if (!nombre) continue;\n\n normalized.push({\n employee_id: clean(row.employeeId || row.id),\n employee_number: clean(row.employeeNumber),\n nombre_bamboo: nombre,\n nombre_norm: normalizeName(nombre),\n fecha_ingreso: normalizeDate(row.hireDate || row.originalHireDate),\n estado_bamboo: clean(row.status),\n ubicacion: clean(row.location),\n pais: clean(row.country),\n correo: clean(row.workEmail)\n });\n}\n\nconst byEmployee = new Map();\nfor (const row of normalized) {\n const key =\n row.employee_id ||\n row.employee_number ||\n `${row.nombre_norm}|${row.fecha_ingreso}`;\n\n if (!byEmployee.has(key)) byEmployee.set(key, row);\n}\n\nconst bamboo = [...byEmployee.values()];\n\nif (!bamboo.length) {\n throw new Error(\n 'BambooHR no devolvió empleados legibles. Revisa la credencial y los permisos para nombres y fecha de ingreso.'\n );\n}\n\nconst data = $getWorkflowStaticData('global');\ndata.gtSeguridadSocial = data.gtSeguridadSocial || {};\ndata.gtSeguridadSocial[init.runId] = data.gtSeguridadSocial[init.runId] || {};\ndata.gtSeguridadSocial[init.runId].bamboo = bamboo;\n\nreturn [{\n json: {\n runId: init.runId,\n empleadosBamboo: bamboo.length,\n totalReportadoBamboo: totalReported,\n paginasBamboo: responses.length,\n empleadosRecibidosBamboo: rows.length,\n empleadosUnicosBamboo: uniqueRawRows.length\n }\n}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 14672, 2384 ], "id": "b1be220f-2711-4ce2-bf7a-95fbaf1ecaa2", "name": "Guardar BambooHR en memoria" }, { "parameters": { "method": "POST", "url": "=https://www.googleapis.com/drive/v3/files/{{ $('Crear Google Sheet Reporte').item.json.spreadsheetId }}/permissions?sendNotificationEmail=false&supportsAllDrives=true", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleDriveOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ role: 'writer', type: 'user', emailAddress: 'msoto@gomezleemarketing.com' }) }}", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 17856, 2384 ], "id": "a22df7c7-e68a-44ec-abe4-5b8b6506b943", "name": "Compartir con Mati Soto - Editor", "retryOnFail": true, "maxTries": 3, "waitBetweenTries": 1000, "credentials": { "googleDriveOAuth2Api": { "id": "g23xdGLZRzBGqKgH", "name": "Isaac - Google Drive" } }, "onError": "continueRegularOutput" }, { "parameters": { "method": "POST", "url": "=https://www.googleapis.com/drive/v3/files/{{ $('Crear Google Sheet Reporte').item.json.spreadsheetId }}/permissions?sendNotificationEmail=false&supportsAllDrives=true", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleDriveOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ role: 'writer', type: 'user', emailAddress: 'iherrera@gomezleemarketing.com' }) }}", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 18096, 2384 ], "id": "cad3778c-b256-4765-a6d0-de70b6afb584", "name": "Compartir con Iveth Herrera - Editor", "retryOnFail": true, "maxTries": 3, "waitBetweenTries": 1000, "credentials": { "googleDriveOAuth2Api": { "id": "g23xdGLZRzBGqKgH", "name": "Isaac - Google Drive" } }, "onError": "continueRegularOutput" }, { "parameters": { "method": "POST", "url": "=https://www.googleapis.com/drive/v3/files/{{ $('Crear Google Sheet Reporte').item.json.spreadsheetId }}/permissions?sendNotificationEmail=false&supportsAllDrives=true", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleDriveOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ role: 'writer', type: 'user', emailAddress: 'vparamo@gomezleemarketing.com' }) }}", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 18336, 2384 ], "id": "d2f69a4b-1848-4a34-9d40-881a64e368e2", "name": "Compartir con Viviana Paramo - Editor", "retryOnFail": true, "maxTries": 3, "waitBetweenTries": 1000, "credentials": { "googleDriveOAuth2Api": { "id": "g23xdGLZRzBGqKgH", "name": "Isaac - Google Drive" } }, "onError": "continueRegularOutput" }, { "parameters": { "method": "POST", "url": "=https://www.googleapis.com/drive/v3/files/{{ $('Crear Google Sheet Reporte').item.json.spreadsheetId }}/permissions?sendNotificationEmail=false&supportsAllDrives=true", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleDriveOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ role: 'writer', type: 'user', emailAddress: 'asrodriguez@gomezleemarketing.com' }) }}", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 18576, 2384 ], "id": "7c040257-30fe-487c-9405-02d78c812de9", "name": "Compartir con Ada Rodríguez - Editor", "retryOnFail": true, "maxTries": 3, "waitBetweenTries": 1000, "credentials": { "googleDriveOAuth2Api": { "id": "g23xdGLZRzBGqKgH", "name": "Isaac - Google Drive" } }, "onError": "continueRegularOutput" }, { "parameters": { "method": "POST", "url": "=https://www.googleapis.com/drive/v3/files/{{ $('Crear Google Sheet Reporte').item.json.spreadsheetId }}/permissions?sendNotificationEmail=false&supportsAllDrives=true", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleDriveOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ role: 'writer', type: 'user', emailAddress: 'mgomez@gomezleemarketing.com' }) }}", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 18816, 2384 ], "id": "a4d89434-807d-4f08-9172-8b07fc65737a", "name": "Compartir con Máximo Gomez - Editor", "retryOnFail": true, "maxTries": 3, "waitBetweenTries": 1000, "credentials": { "googleDriveOAuth2Api": { "id": "g23xdGLZRzBGqKgH", "name": "Isaac - Google Drive" } }, "onError": "continueRegularOutput" } ], "pinData": {}, "connections": { "On form submission": { "main": [ [ { "node": "Inicializar memoria", "type": "main", "index": 0 } ] ] }, "Inicializar memoria": { "main": [ [ { "node": "Preparar Nómina 1Q", "type": "main", "index": 0 }, { "node": "Preparar auditoría inicial Supabase", "type": "main", "index": 0 } ] ] }, "Preparar Nómina 1Q": { "main": [ [ { "node": "Actualizar staging - Nómina 1Q", "type": "main", "index": 0 } ] ] }, "Actualizar staging - Nómina 1Q": { "main": [ [ { "node": "Esperar conversión - Nómina 1Q", "type": "main", "index": 0 } ] ] }, "Listar hojas - Nómina 1Q": { "main": [ [ { "node": "Resolver hoja Nómina 1Q", "type": "main", "index": 0 } ] ] }, "Resolver hoja Nómina 1Q": { "main": [ [ { "node": "Leer Nómina 1Q", "type": "main", "index": 0 } ] ] }, "Leer Nómina 1Q": { "main": [ [ { "node": "Guardar Nómina 1Q en memoria", "type": "main", "index": 0 } ] ] }, "Guardar Nómina 1Q en memoria": { "main": [ [ { "node": "Preparar Nómina 2Q", "type": "main", "index": 0 } ] ] }, "Preparar Nómina 2Q": { "main": [ [ { "node": "Actualizar staging - Nómina 2Q", "type": "main", "index": 0 } ] ] }, "Actualizar staging - Nómina 2Q": { "main": [ [ { "node": "Esperar conversión - Nómina 2Q", "type": "main", "index": 0 } ] ] }, "Listar hojas - Nómina 2Q": { "main": [ [ { "node": "Resolver hoja Nómina 2Q", "type": "main", "index": 0 } ] ] }, "Resolver hoja Nómina 2Q": { "main": [ [ { "node": "Leer Nómina 2Q", "type": "main", "index": 0 } ] ] }, "Leer Nómina 2Q": { "main": [ [ { "node": "Guardar Nómina 2Q en memoria", "type": "main", "index": 0 } ] ] }, "Guardar Nómina 2Q en memoria": { "main": [ [ { "node": "Preparar Sistema Propio", "type": "main", "index": 0 } ] ] }, "Preparar Sistema Propio": { "main": [ [ { "node": "Actualizar staging - Sistema Propio", "type": "main", "index": 0 } ] ] }, "Actualizar staging - Sistema Propio": { "main": [ [ { "node": "Esperar conversión - Sistema Propio", "type": "main", "index": 0 } ] ] }, "Listar hojas - Sistema Propio": { "main": [ [ { "node": "Resolver hoja EMPLEADOS", "type": "main", "index": 0 } ] ] }, "Resolver hoja EMPLEADOS": { "main": [ [ { "node": "Leer EMPLEADOS", "type": "main", "index": 0 } ] ] }, "Leer EMPLEADOS": { "main": [ [ { "node": "Guardar EMPLEADOS en memoria", "type": "main", "index": 0 } ] ] }, "Guardar EMPLEADOS en memoria": { "main": [ [ { "node": "Consultar empleados BambooHR", "type": "main", "index": 0 } ] ] }, "Preparar Planilla Consolidada": { "main": [ [ { "node": "Actualizar staging - Consolidada", "type": "main", "index": 0 } ] ] }, "Actualizar staging - Consolidada": { "main": [ [ { "node": "Esperar conversión - Consolidada", "type": "main", "index": 0 } ] ] }, "Listar hojas - Consolidada": { "main": [ [ { "node": "Resolver hoja PLANILLA MENSUAL", "type": "main", "index": 0 } ] ] }, "Resolver hoja PLANILLA MENSUAL": { "main": [ [ { "node": "Leer PLANILLA MENSUAL", "type": "main", "index": 0 } ] ] }, "Leer PLANILLA MENSUAL": { "main": [ [ { "node": "Guardar PLANILLA MENSUAL en memoria", "type": "main", "index": 0 } ] ] }, "Guardar PLANILLA MENSUAL en memoria": { "main": [ [ { "node": "Construir reporte producción", "type": "main", "index": 0 } ] ] }, "Construir reporte producción": { "main": [ [ { "node": "Crear Google Sheet Reporte", "type": "main", "index": 0 } ] ] }, "Crear Google Sheet Reporte": { "main": [ [ { "node": "Escribir reporte", "type": "main", "index": 0 } ] ] }, "Escribir reporte": { "main": [ [ { "node": "Formatear reporte", "type": "main", "index": 0 } ] ] }, "Formatear reporte": { "main": [ [ { "node": "Compartir con Isaac", "type": "main", "index": 0 } ] ] }, "Compartir con Isaac": { "main": [ [ { "node": "Compartir con Yanelly editor", "type": "main", "index": 0 } ] ] }, "Compartir con Yanelly editor": { "main": [ [ { "node": "Compartir con Mati Soto - Editor", "type": "main", "index": 0 } ] ] }, "Esperar conversión - Nómina 1Q": { "main": [ [ { "node": "Listar hojas - Nómina 1Q", "type": "main", "index": 0 } ] ] }, "Esperar conversión - Nómina 2Q": { "main": [ [ { "node": "Listar hojas - Nómina 2Q", "type": "main", "index": 0 } ] ] }, "Esperar conversión - Sistema Propio": { "main": [ [ { "node": "Listar hojas - Sistema Propio", "type": "main", "index": 0 } ] ] }, "Esperar conversión - Consolidada": { "main": [ [ { "node": "Listar hojas - Consolidada", "type": "main", "index": 0 } ] ] }, "Preparar auditoría inicial Supabase": { "main": [ [ { "node": "Supabase - Crear auditoría inicial", "type": "main", "index": 0 } ] ] }, "Preparar auditoría final Supabase": { "main": [ [ { "node": "Supabase - Actualizar auditoría final", "type": "main", "index": 0 }, { "node": "Preparar hallazgos Supabase", "type": "main", "index": 0 }, { "node": "Preparar archivos Supabase", "type": "main", "index": 0 } ] ] }, "Preparar hallazgos Supabase": { "main": [ [ { "node": "Supabase - Insertar hallazgos", "type": "main", "index": 0 } ] ] }, "Preparar archivos Supabase": { "main": [ [ { "node": "Supabase - Insertar archivos procesados", "type": "main", "index": 0 } ] ] }, "Consultar empleados BambooHR": { "main": [ [ { "node": "Guardar BambooHR en memoria", "type": "main", "index": 0 } ] ] }, "Guardar BambooHR en memoria": { "main": [ [ { "node": "Preparar Planilla Consolidada", "type": "main", "index": 0 } ] ] }, "Compartir con Mati Soto - Editor": { "main": [ [ { "node": "Compartir con Iveth Herrera - Editor", "type": "main", "index": 0 } ] ] }, "Compartir con Iveth Herrera - Editor": { "main": [ [ { "node": "Compartir con Viviana Paramo - Editor", "type": "main", "index": 0 } ] ] }, "Compartir con Viviana Paramo - Editor": { "main": [ [ { "node": "Compartir con Ada Rodríguez - Editor", "type": "main", "index": 0 } ] ] }, "Compartir con Ada Rodríguez - Editor": { "main": [ [ { "node": "Compartir con Máximo Gomez - Editor", "type": "main", "index": 0 } ] ] }, "Compartir con Máximo Gomez - Editor": { "main": [ [ { "node": "Enviar correo", "type": "main", "index": 0 }, { "node": "Preparar auditoría final Supabase", "type": "main", "index": 0 } ] ] } }, "active": true, "settings": { "executionOrder": "v1", "binaryMode": "separate", "availableInMCP": true, "timeSavedMode": "fixed", "errorWorkflow": "puF4LUczoSz3hcek", "timezone": "America/Santo_Domingo", "callerPolicy": "workflowsFromSameOwner" }, "versionId": "da7519ba-a547-40b8-b673-c25408feba16", "meta": { "templateCredsSetupCompleted": true, "instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70" }, "id": "V73ZjH7QBjLndXQV", "tags": [] }