{ "updatedAt": "2026-07-04T14:18:46.100Z", "createdAt": "2026-07-03T18:08:04.259Z", "id": "loFncgFuvBlXUf8u", "name": "Cruce de Seguridad Social Guatemala - Q1 Q2 PRODUCCIÓN FINAL", "description": null, "active": true, "isArchived": false, "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": [ 80, 128 ], "id": "81aef7de-7414-49ce-adb3-cf9b7dd3b696", "name": "On form submission", "webhookId": "282cf895-0642-42cc-afaa-372704ba2c5f" }, { "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};\n\nreturn [{\n json: { ...item.json, runId, mes, anio, periodoLabel, reportTitle, emailTo: 'iaracena@gomezleemarketing.com' },\n binary: item.binary\n}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 320, 128 ], "id": "cfb66319-c454-46d6-923e-5d0c1ea222d3", "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": [ 560, 128 ], "id": "1e8d8999-f196-4241-bf57-2182f5ba5f6f", "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": [ 928, 128 ], "id": "75adafbf-6eb4-466c-b4da-2af5402c7b76", "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": [ 1536, 128 ], "id": "809fb908-59f6-45f9-8a11-5a38efda1d19", "name": "Listar hojas - Nómina 1Q", "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": [ 1776, 128 ], "id": "4c813693-118f-4f90-87d9-336d57082397", "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": [ 2032, 128 ], "id": "a8d4b5d4-ff8b-43e2-91d8-16d5897e59ed", "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": [ 2272, 128 ], "id": "20bbc195-d609-4009-869d-7f7b05b940ee", "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": [ 2512, 128 ], "id": "651da05c-6759-48de-99e5-2eb8cb4615e4", "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": [ 2768, 128 ], "id": "7d919da1-d6bb-424a-a6da-5c0e8d346f75", "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": [ 3296, 128 ], "id": "4fb42911-ad0a-4b08-9438-5c65ef1726ae", "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": [ 3616, 128 ], "id": "a7dbed9c-8d11-4190-b934-53425ef4a7ea", "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": [ 3936, 128 ], "id": "4a9bc146-8d26-4e33-b49d-aa9e1ef4ce35", "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": [ 4240, 128 ], "id": "05be903c-d13c-4388-a59b-4bb4e61d7f31", "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": [ 4816, 144 ], "id": "68a4c8c9-bbb2-4c6f-babe-e5eb4d4000eb", "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": [ 5024, 144 ], "id": "80de44b1-e5b1-4c5a-a7cf-91a61f398b1a", "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": [ 5456, 144 ], "id": "4b11aaac-9dcf-48cc-8226-43f4832f2127", "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": [ 5680, 144 ], "id": "11fc59a9-9bf5-46ea-a82d-563f7ae6893a", "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": [ 5904, 144 ], "id": "40ce68e1-04ce-43b6-975b-9b48c2734073", "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": [ 6144, 144 ], "id": "c20c8ed1-102f-42ac-b368-7b8277c8e5a9", "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": [ 6320, 144 ], "id": "b791dc1c-9487-42ab-a3ed-ebac85b4df46", "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": [ 6528, 144 ], "id": "b9e4bd53-f993-42db-8fbd-e130872113b7", "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": [ 6880, 144 ], "id": "612e0c20-72a0-4b9d-9ee3-15f76a82c191", "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": [ 7072, 144 ], "id": "a7459273-64d3-4ca3-8966-e505f1f74bf2", "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": [ 7248, 144 ], "id": "ce9dda4f-a310-4d15-aa02-832da8b04e65", "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": [ 7424, 144 ], "id": "eb1a7fda-e46a-47c1-aaf6-c8a726353511", "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}\nfunction tokens(name) {\n const stop = new Set(['DE','DEL','LA','LAS','LOS','Y','DA','DAS','DO','VDA']);\n return normalizeName(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 => normalizeName(r[field]) === normalizeName(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}\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 || [];\n\nif (!payroll.length || !empleados.length || !planilla.length) {\n throw new Error(`Faltan datos: nómina=${payroll.length}, empleados=${empleados.length}, planilla=${planilla.length}`);\n}\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\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(['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']);\nif (soloContabilidad.length) {\n for (const e of soloContabilidad) afiliacionRows.push([e.afiliacion, e.nombre_contabilidad, 'Está en contabilidad, pero no aparece en las nóminas Q1/Q2.']);\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']);\nif (soloNomina.length) {\n for (const g of soloNomina) afiliacionRows.push([g.afiliacion, uniqueJoin(g.records.map(r=>r.nombre_nomina)), uniqueJoin(g.records.map(r=>r.q)), uniqueJoin(g.records.map(r=>r.proyecto_nomina)), 'Está en nómina, pero no existe en EMPLEADOS de contabilidad.']);\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// 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 3: 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 4: 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 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 montosRows: sanitizeRows(montosRows),\n ajustesRows: sanitizeRows(ajustesRows),\n summary,\n rowCounts:{\n afiliacion:afiliacionRows.length,\n sinIgss:sinIgssSheetRows.length,\n montos:montosRows.length,\n ajustes:ajustesRows.length\n }\n }\n}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 7888, 144 ], "id": "d1e21e26-ece2-4322-a1ad-fcedb6a934ab", "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: 'Cruce Montos' } }, { properties: { title: 'Ajustes incluidos' } } ] }) }}", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 8128, 144 ], "id": "a5f21136-97b7-4aff-ae35-3c71c7efb03e", "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: '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": [ 8368, 144 ], "id": "5c5afcdb-1228-4817-9cb9-b203c2ed6835", "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": "={{ JSON.stringify({ requests: [\n { autoResizeDimensions: { dimensions: { sheetId: $('Crear Google Sheet Reporte').item.json.sheets[0].properties.sheetId, dimension: 'COLUMNS', startIndex: 0, endIndex: 12 } } },\n { autoResizeDimensions: { dimensions: { sheetId: $('Crear Google Sheet Reporte').item.json.sheets[1].properties.sheetId, dimension: 'COLUMNS', startIndex: 0, endIndex: 12 } } },\n { autoResizeDimensions: { dimensions: { sheetId: $('Crear Google Sheet Reporte').item.json.sheets[2].properties.sheetId, dimension: 'COLUMNS', startIndex: 0, endIndex: 12 } } },\n { autoResizeDimensions: { dimensions: { sheetId: $('Crear Google Sheet Reporte').item.json.sheets[3].properties.sheetId, dimension: 'COLUMNS', startIndex: 0, endIndex: 12 } } },\n { updateSheetProperties: { properties: { sheetId: $('Crear Google Sheet Reporte').item.json.sheets[0].properties.sheetId, gridProperties: { frozenRowCount: 1 } }, fields: 'gridProperties.frozenRowCount' } },\n { updateSheetProperties: { properties: { sheetId: $('Crear Google Sheet Reporte').item.json.sheets[1].properties.sheetId, gridProperties: { frozenRowCount: 1 } }, fields: 'gridProperties.frozenRowCount' } },\n { updateSheetProperties: { properties: { sheetId: $('Crear Google Sheet Reporte').item.json.sheets[2].properties.sheetId, gridProperties: { frozenRowCount: 1 } }, fields: 'gridProperties.frozenRowCount' } },\n { updateSheetProperties: { properties: { sheetId: $('Crear Google Sheet Reporte').item.json.sheets[3].properties.sheetId, gridProperties: { frozenRowCount: 1 } }, fields: 'gridProperties.frozenRowCount' } },\n { repeatCell: { range: { sheetId: $('Crear Google Sheet Reporte').item.json.sheets[0].properties.sheetId, startRowIndex: 0, endRowIndex: 1 }, cell: { userEnteredFormat: { textFormat: { bold: true, fontSize: 14 }, backgroundColor: { red: 0.91, green: 0.96, blue: 0.90 } } }, fields: 'userEnteredFormat.textFormat,userEnteredFormat.backgroundColor' } },\n { repeatCell: { range: { sheetId: $('Crear Google Sheet Reporte').item.json.sheets[1].properties.sheetId, startRowIndex: 0, endRowIndex: 1 }, cell: { userEnteredFormat: { textFormat: { bold: true, fontSize: 14 }, backgroundColor: { red: 1.00, green: 0.96, blue: 0.80 } } }, fields: 'userEnteredFormat.textFormat,userEnteredFormat.backgroundColor' } },\n { repeatCell: { range: { sheetId: $('Crear Google Sheet Reporte').item.json.sheets[2].properties.sheetId, startRowIndex: 0, endRowIndex: 1 }, cell: { userEnteredFormat: { textFormat: { bold: true, fontSize: 14 }, backgroundColor: { red: 0.91, green: 0.96, blue: 0.90 } } }, fields: 'userEnteredFormat.textFormat,userEnteredFormat.backgroundColor' } },\n { repeatCell: { range: { sheetId: $('Crear Google Sheet Reporte').item.json.sheets[3].properties.sheetId, startRowIndex: 0, endRowIndex: 1 }, cell: { userEnteredFormat: { textFormat: { bold: true, fontSize: 14 }, backgroundColor: { red: 0.93, green: 0.94, blue: 0.96 } } }, fields: 'userEnteredFormat.textFormat,userEnteredFormat.backgroundColor' } },\n { repeatCell: { range: { sheetId: $('Crear Google Sheet Reporte').item.json.sheets[0].properties.sheetId }, cell: { userEnteredFormat: { wrapStrategy: 'WRAP' } }, fields: 'userEnteredFormat.wrapStrategy' } },\n { repeatCell: { range: { sheetId: $('Crear Google Sheet Reporte').item.json.sheets[1].properties.sheetId }, cell: { userEnteredFormat: { wrapStrategy: 'WRAP' } }, fields: 'userEnteredFormat.wrapStrategy' } },\n { repeatCell: { range: { sheetId: $('Crear Google Sheet Reporte').item.json.sheets[2].properties.sheetId }, cell: { userEnteredFormat: { wrapStrategy: 'WRAP' } }, fields: 'userEnteredFormat.wrapStrategy' } },\n { repeatCell: { range: { sheetId: $('Crear Google Sheet Reporte').item.json.sheets[3].properties.sheetId }, cell: { userEnteredFormat: { wrapStrategy: 'WRAP' } }, fields: 'userEnteredFormat.wrapStrategy' } }\n] }) }}", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 8608, 144 ], "id": "c5cc0f8d-e1dd-4f79-9c6d-4724e46e1474", "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": [ 8848, 144 ], "id": "187f0813-12b8-4a0c-95af-05357f682885", "name": "Compartir con Isaac", "credentials": { "googleDriveOAuth2Api": { "id": "g23xdGLZRzBGqKgH", "name": "Isaac - Google Drive" } }, "onError": "continueRegularOutput" }, { "parameters": { "sendTo": "iaracena@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 hardDiff = afiliacionDiff + montoDiff + noComparados;\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 `
${s.periodo || ''} · Nóminas Q1 + Q2
\nResultado ${afiliacionDiff ? 'Revisar' : 'Sin diferencias'}
\nEmpleados únicos nómina ${s.empleadosUnicosNomina||0}
\nSin IGSS en nómina ${sinIgss}
\nDiferencias reales ${montoDiff}
\nCasos no comparados ${noComparados}
\nAjustes incluidos ${ajustes}
\n${s.periodo || ''} · Nóminas Q1 + Q2
\nResultado ${afiliacionDiff ? 'Revisar' : 'Sin diferencias'}
\nEmpleados únicos nómina ${s.empleadosUnicosNomina||0}
\nSin IGSS en nómina ${sinIgss}
\nDiferencias reales ${montoDiff}
\nCasos no comparados ${noComparados}
\nAjustes incluidos ${ajustes}
\n