From c69190e75f9b05a3723c19bfd82c4473995ffa51 Mon Sep 17 00:00:00 2001 From: Isaac_Aracena Date: Sat, 8 Aug 2026 15:51:56 +0000 Subject: [PATCH] Subir archivos a "/" --- ... de Seguridad Social - Costa Rica (6).json | 1176 +++++++++++ ...de Seguridad Social - El Salvador (3).json | 1366 ++++++++++++ ...e de Seguridad Social - Guatemala (1).json | 1834 +++++++++++++++++ Cruce de Seguridad Social - Honduras (5).json | 1310 ++++++++++++ ...dad Social - República Dominicana (6).json | 1624 +++++++++++++++ 5 files changed, 7310 insertions(+) create mode 100644 Cruce de Seguridad Social - Costa Rica (6).json create mode 100644 Cruce de Seguridad Social - El Salvador (3).json create mode 100644 Cruce de Seguridad Social - Guatemala (1).json create mode 100644 Cruce de Seguridad Social - Honduras (5).json create mode 100644 Cruce de Seguridad Social - República Dominicana (6).json diff --git a/Cruce de Seguridad Social - Costa Rica (6).json b/Cruce de Seguridad Social - Costa Rica (6).json new file mode 100644 index 0000000..cb34055 --- /dev/null +++ b/Cruce de Seguridad Social - Costa Rica (6).json @@ -0,0 +1,1176 @@ +{ + "name": "Cruce de Seguridad Social - Costa Rica", + "nodes": [ + { + "parameters": { + "formTitle": "Cruce Seguridad Social Costa Rica", + "formDescription": "Sube la nómina mensual de Costa Rica y la planilla oficial de la CCSS en PDF. El flujo cruza identidad, cédulas y montos; genera un Google Sheet, lo comparte y envía el resumen por correo.", + "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", + "placeholder": "2026", + "requiredField": true + }, + { + "fieldLabel": "Nómina", + "fieldType": "file", + "multipleFiles": false, + "acceptFileTypes": ".xlsx,.xls", + "requiredField": true + }, + { + "fieldLabel": "Planilla CCSS PDF", + "fieldType": "file", + "multipleFiles": false, + "acceptFileTypes": ".pdf", + "requiredField": true + } + ] + }, + "options": { + "ignoreBots": true + } + }, + "type": "n8n-nodes-base.formTrigger", + "typeVersion": 2.3, + "position": [ + 912, + 1376 + ], + "id": "a16fe0ae-ecbb-4b18-bb4d-38997fc83f9e", + "name": "On form submission", + "webhookId": "6dc6c5cd-1994-431b-b85a-1353a3346576" + }, + { + "parameters": { + "jsCode": "const item = $input.first();\nconst now = new Date();\nconst pad = n => String(n).padStart(2, '0');\nconst mes = String(item.json['Mes a validar'] || '').replace(/\\D/g, '').padStart(2, '0');\nconst anioRaw = String(item.json['Año a validar'] || item.json['Ano a validar'] || '').trim();\nconst anio = Number(anioRaw.replace(/\\D/g, ''));\nif (!/^\\d{2}$/.test(mes) || Number(mes) < 1 || Number(mes) > 12) throw new Error('Selecciona un mes válido entre 01 y 12.');\nif (!/^\\d{4}$/.test(String(anio)) || anio < 2020 || anio > 2100) throw new Error('Escribe un año válido de cuatro dígitos, por ejemplo 2026.');\nconst meses = { '01':'Enero','02':'Febrero','03':'Marzo','04':'Abril','05':'Mayo','06':'Junio','07':'Julio','08':'Agosto','09':'Septiembre','10':'Octubre','11':'Noviembre','12':'Diciembre' };\nconst ts = `${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;\nconst runId = `CR_CCSS_${ts}_${Math.random().toString(36).slice(2,8)}`;\nconst periodoLabel = `${meses[mes]} ${anio}`;\nconst fechaTitulo = `${pad(now.getDate())}-${pad(now.getMonth()+1)}-${now.getFullYear()}`;\nconst reportTitle = `Cruce Seguridad Social Costa Rica - ${periodoLabel} - ${fechaTitulo}`;\nreturn [{ json: { runId, mes, anio, periodoLabel, reportTitle, startedAt: now.toISOString() }, binary: item.binary }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1152, + 1376 + ], + "id": "6b74cc38-f3c5-4d8e-8aa7-fd411c52b753", + "name": "Inicializar ejecución" + }, + { + "parameters": { + "jsCode": "const base = $items('Inicializar ejecución')[0];\nconst bin = base.binary || {};\nfunction ascii(s){return String(s||'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase();}\nconst candidates = Object.entries(bin).map(([key,file]) => {\n const name = String(file.fileName || key);\n const s = ascii(`${key} ${name} ${file.mimeType || ''}`);\n let score = 0;\n if (/\\.xlsx$|\\.xls$|spreadsheet|excel/.test(s)) score += 100;\n if (/nomina|n[oó]mina|field force|payroll/.test(s)) score += 100;\n if (/ccss.*pdf|planilla.*pdf|\\.pdf$/.test(s)) score -= 300;\n return {key,file,name,score};\n}).sort((a,b)=>b.score-a.score);\nif (!candidates.length || candidates[0].score < 100) throw new Error('No pude identificar el archivo de Nómina. Adjunta un archivo .xlsx o .xls en el campo Nómina.');\nconst chosen = candidates[0];\nreturn [{json:{runId:base.json.runId,mes:base.json.mes,anio:base.json.anio,periodoLabel:base.json.periodoLabel,reportTitle:base.json.reportTitle,fileName:chosen.name},binary:{data:chosen.file}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1392, + 1136 + ], + "id": "d380541d-185a-486e-91d5-cb993a55026a", + "name": "Preparar Nómina" + }, + { + "parameters": { + "method": "POST", + "url": "https://sheets.googleapis.com/v4/spreadsheets", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ properties: { title: `TEMP CR Nómina - ${$('Inicializar ejecución').item.json.runId}` } }) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 1632, + 1136 + ], + "id": "9bc731aa-8b7c-4aea-b84a-160885731e20", + "name": "Crear staging Nómina", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const staging = $input.first().json || {};\nconst prep = $items('Preparar Nómina')[0];\nconst spreadsheetId = staging.spreadsheetId || staging.id;\nif (!spreadsheetId) throw new Error('No se pudo crear el archivo temporal para convertir la nómina.');\nreturn [{json:{...prep.json,spreadsheetId},binary:prep.binary}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1872, + 1136 + ], + "id": "9307716f-e990-4139-9400-19ea81b16a20", + "name": "Reunir staging y Nómina" + }, + { + "parameters": { + "operation": "update", + "fileId": { + "__rl": true, + "value": "={{ $json.spreadsheetId }}", + "mode": "id" + }, + "changeFileContent": true, + "options": {} + }, + "type": "n8n-nodes-base.googleDrive", + "typeVersion": 3, + "position": [ + 2112, + 1136 + ], + "id": "c45519cc-4254-40c0-9b2b-141a01785d65", + "name": "Actualizar staging Nómina", + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + } + }, + { + "parameters": {}, + "type": "n8n-nodes-base.wait", + "typeVersion": 1.1, + "position": [ + 2352, + 1136 + ], + "id": "63aec9da-2e25-48ac-bb30-2e61c02ab884", + "name": "Esperar conversión Nómina", + "webhookId": "9d908850-0944-4b6d-8858-12ef55951f9d" + }, + { + "parameters": { + "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $('Crear staging Nómina').item.json.spreadsheetId }}?fields=spreadsheetId,sheets(properties(title,sheetId,hidden))", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 2592, + 1136 + ], + "id": "1d50a0b6-1bfa-49a7-aad8-88d578d48961", + "name": "Listar hojas Nómina", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const init = $items('Inicializar ejecución')[0].json;\nconst prep = $items('Preparar Nómina')[0].json;\nconst spreadsheetId = $json.spreadsheetId || $('Crear staging Nómina').item.json.spreadsheetId;\nconst sheets = Array.isArray($json.sheets) ? $json.sheets : [];\nfunction norm(s){return String(s||'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase().replace(/[^a-z0-9]/g,'');}\nlet chosen = sheets.find(s => norm(s?.properties?.title) === 'ccssins');\nif (!chosen) chosen = sheets.find(s => /ccss|ins/.test(norm(s?.properties?.title)) && !s?.properties?.hidden);\nif (!chosen) chosen = sheets.find(s => !s?.properties?.hidden);\nif (!chosen) throw new Error('No encontré una hoja legible dentro de la nómina. Se esperaba la hoja “CCSS, INS”.');\nconst title = String(chosen.properties.title);\nconst range = `'${title.replace(/'/g,\"''\")}'!A1:M5000`;\nreturn [{json:{runId:init.runId,mes:init.mes,anio:init.anio,periodoLabel:init.periodoLabel,reportTitle:init.reportTitle,spreadsheetId,sheetTitle:title,fileName:prep.fileName,rangeQuery:'ranges='+encodeURIComponent(range)}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2832, + 1136 + ], + "id": "61a7c4eb-c72c-4514-8b98-b76f40a572f9", + "name": "Preparar rango Nómina" + }, + { + "parameters": { + "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.spreadsheetId }}/values:batchGet?{{ $json.rangeQuery }}&majorDimension=ROWS&valueRenderOption=UNFORMATTED_VALUE&dateTimeRenderOption=SERIAL_NUMBER", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 3072, + 1136 + ], + "id": "cc7e5c03-f3bb-4f78-b755-1ba57e3f24d8", + "name": "Leer hoja Nómina", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const init=$items('Inicializar ejecución')[0].json;\nconst prep=$items('Preparar rango Nómina')[0].json;\nconst payload=$json.body&&typeof $json.body==='object'?$json.body:$json;\nconst values=payload?.valueRanges?.[0]?.values||payload?.values||[];\n\nfunction clean(value){\n return String(value??'').replace(/\\s+/g,' ').trim();\n}\nfunction ascii(value){\n return clean(value).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toUpperCase();\n}\nfunction money(value){\n if(value===null||value===undefined||value==='') return 0;\n if(typeof value==='number'){\n return Math.round((value+Math.sign(value||1)*1e-9)*100)/100;\n }\n let text=String(value).replace(/₡|CRC|COLONES|,/gi,'').replace(/\\s/g,'').trim();\n if(/^\\(.*\\)$/.test(text)) text='-'+text.slice(1,-1);\n const number=Number(text);\n return Number.isFinite(number)\n ?Math.round((number+Math.sign(number||1)*1e-9)*100)/100\n :0;\n}\nfunction normalizedNumberText(value){\n let text=clean(value);\n if(!text) return '';\n if(/^-?\\d+(\\.\\d+)?e[+-]?\\d+$/i.test(text)){\n const number=Number(text);\n if(Number.isFinite(number)){\n text=number.toLocaleString('fullwide',{useGrouping:false,maximumFractionDigits:0});\n }\n }\n if(/^\\d+\\.0+$/.test(text)) text=text.replace(/\\.0+$/,'');\n return text;\n}\nfunction normalizeId(value){\n const text=normalizedNumberText(value);\n if(!text) return '';\n const digits=text.replace(/\\D/g,'').replace(/^0+/,'');\n return digits||'0';\n}\nfunction displayId(value){\n return normalizedNumberText(value);\n}\nfunction findHeader(rows){\n for(let index=0;indexpattern.test(value));\n if(index>=0) return index;\n }\n return -1;\n}\n\nconst columns={\n novedad:columnIndex(/^NOVEDAD/),\n primary:columnIndex(/^ID$/,/CEDULA/),\n ccss:columnIndex(/ID CCSS/,/CEDULA CCSS/),\n nombre:columnIndex(/^NOMBRE$/),\n apellido1:columnIndex(/APELLIDO 1/,/^APELLIDO$/),\n apellido2:columnIndex(/APELLIDO 2/),\n monto:columnIndex(/^VALOR$/,/SALARIO CCSS/),\n estado:columnIndex(/^ESTADO$/),\n proyecto:columnIndex(/^CUENTA$/,/PROYECTO/)\n};\n\nconst hasCcssColumn=columns.ccss>=0;\nif(!hasCcssColumn){\n if(columns.apellido1<0) columns.apellido1=columns.nombre+1;\n if(columns.apellido2<0) columns.apellido2=columns.nombre+2;\n if(columns.estado<0) columns.estado=12;\n}\n\nif(\n columns.nombre<0||\n columns.monto<0||\n (columns.ccss<0&&columns.primary<0)\n){\n throw new Error(\n 'La hoja de nómina no tiene la estructura esperada. '+\n 'Debe contener ID, NOMBRE/APELLIDOS y VALOR.'\n );\n}\n\nconst recordsMap=new Map();\nconst identityByName=new Map();\nlet lastName='';\nlet lastOfficialId='';\nlet lastPrimaryId='';\nlet lastPrimaryDisplay='';\nlet lastCcssDisplay='';\n\nfor(let rowIndex=headerIndex+1;rowIndex=0?row[columns.apellido1]:'',\n columns.apellido2>=0?row[columns.apellido2]:''\n ].filter(Boolean).join(' '));\n\n let officialRaw=hasCcssColumn?row[columns.ccss]:row[columns.primary];\n let primaryRaw=columns.primary>=0?row[columns.primary]:officialRaw;\n\n let officialId=normalizeId(officialRaw)||normalizeId(primaryRaw);\n let primaryId=normalizeId(primaryRaw);\n let primaryDisplay=displayId(primaryRaw);\n let ccssDisplay=hasCcssColumn?displayId(officialRaw):'';\n const nameKey=ascii(name);\n\n if(!officialId&&nameKey&&identityByName.has(nameKey)){\n const saved=identityByName.get(nameKey);\n officialId=saved.officialId;\n primaryId=saved.primaryId;\n primaryDisplay=saved.primaryDisplay;\n ccssDisplay=saved.ccssDisplay;\n }\n\n if(!officialId&&nameKey&&nameKey===lastName){\n officialId=lastOfficialId;\n primaryId=lastPrimaryId;\n primaryDisplay=lastPrimaryDisplay;\n ccssDisplay=lastCcssDisplay;\n }\n\n if(!name||!officialId) continue;\n\n identityByName.set(nameKey,{\n officialId,\n primaryId:primaryId||officialId,\n primaryDisplay,\n ccssDisplay\n });\n lastName=nameKey;\n lastOfficialId=officialId;\n lastPrimaryId=primaryId||officialId;\n lastPrimaryDisplay=primaryDisplay;\n lastCcssDisplay=ccssDisplay;\n\n if(/TOTAL|SUBTOTAL|VERIFICACION/.test(ascii(name))) continue;\n\n const amount=money(row[columns.monto]);\n const status=columns.estado>=0?clean(row[columns.estado]):'';\n const project=columns.proyecto>=0?clean(row[columns.proyecto]):'';\n const key=`${officialId}|${nameKey}`;\n\n if(!recordsMap.has(key)){\n recordsMap.set(key,{\n official_id:officialId,\n has_ccss_id:hasCcssColumn,\n primary_ids:new Set(),\n primary_displays:new Set(),\n ccss_displays:new Set(),\n nombres:new Set(),\n monto:0,\n proyectos:new Set(),\n filas:[],\n estado:''\n });\n }\n\n const record=recordsMap.get(key);\n if(primaryId) record.primary_ids.add(primaryId);\n if(primaryDisplay) record.primary_displays.add(primaryDisplay);\n if(ccssDisplay) record.ccss_displays.add(ccssDisplay);\n record.nombres.add(name);\n record.monto=Math.round((record.monto+amount+1e-9)*100)/100;\n if(project) record.proyectos.add(project);\n record.filas.push(rowIndex+1);\n if(status) record.estado=status;\n}\n\nconst records=[...recordsMap.values()].map(record=>{\n const names=[...record.nombres].sort(\n (a,b)=>b.split(' ').length-a.split(' ').length||b.length-a.length\n );\n const primaryDisplays=[...record.primary_displays];\n const ccssDisplays=[...record.ccss_displays];\n\n return {\n official_id:record.official_id,\n has_ccss_id:record.has_ccss_id,\n primary_ids:[...record.primary_ids],\n primary_display:primaryDisplays[0]||[...record.primary_ids][0]||'',\n ccss_display:record.has_ccss_id?(ccssDisplays[0]||''):'',\n official_display:record.has_ccss_id?(ccssDisplays[0]||''):'',\n nombre:names[0]||'',\n nombres:names,\n monto:Number(record.monto.toFixed(2)),\n proyectos:[...record.proyectos],\n filas:record.filas,\n estado:record.estado\n };\n}).filter(record=>record.nombre);\n\nif(!records.length){\n throw new Error('No se detectaron empleados válidos en la hoja de nómina.');\n}\n\nreturn [{json:{\n runId:init.runId,\n mes:init.mes,\n anio:init.anio,\n periodoLabel:init.periodoLabel,\n reportTitle:init.reportTitle,\n fileName:prep.fileName,\n spreadsheetId:prep.spreadsheetId,\n payrollRecords:records,\n payrollCount:records.length,\n payrollTotal:Number(records.reduce((sum,record)=>sum+record.monto,0).toFixed(2)),\n payrollHasCcssColumn:hasCcssColumn\n}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3312, + 1136 + ], + "id": "38e763fe-6eda-4f26-abe1-08e5e8f801a1", + "name": "Extraer Nómina" + }, + { + "parameters": { + "jsCode": "const base=$items('Inicializar ejecución')[0];\nconst bin=base.binary||{};\nfunction ascii(s){return String(s||'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase();}\nconst candidates=Object.entries(bin).map(([key,file])=>{\n const name=String(file.fileName||key); const s=ascii(`${key} ${name} ${file.mimeType||''}`);\n let score=0; if(/\\.pdf$|application\\/pdf/.test(s))score+=100; if(/ccss|planilla|seguridad social/.test(s))score+=100; if(/nomina|xlsx|xls/.test(s))score-=200;\n return {key,file,name,score};\n}).sort((a,b)=>b.score-a.score);\nif(!candidates.length||candidates[0].score<100) throw new Error('No pude identificar la planilla CCSS en PDF.');\nconst chosen=candidates[0];\nreturn [{json:{runId:base.json.runId,mes:base.json.mes,anio:base.json.anio,periodoLabel:base.json.periodoLabel,reportTitle:base.json.reportTitle,fileName:chosen.name},binary:{data:chosen.file}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1392, + 1376 + ], + "id": "bbc7c7e1-abfd-4eca-bb58-944bb60a9b68", + "name": "Preparar Planilla CCSS" + }, + { + "parameters": { + "operation": "pdf", + "options": {} + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + 1632, + 1376 + ], + "id": "0e68067a-b7b5-4296-8ffc-c976303d6718", + "name": "Extraer texto Planilla CCSS" + }, + { + "parameters": { + "jsCode": "const init=$items('Inicializar ejecución')[0].json;\nconst prep=$items('Preparar Planilla CCSS')[0].json;\nconst text=String($json.text||$json.data||$json.content||$json.body||'');\nif(!text.trim()) throw new Error('No se pudo extraer texto de la planilla CCSS PDF.');\n\nfunction clean(value){\n return String(value??'').replace(/\\s+/g,' ').trim();\n}\nfunction ascii(value){\n return clean(value).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toUpperCase();\n}\nfunction asciiPreservingLength(value){\n return String(value??'')\n .normalize('NFD').replace(/[\\u0300-\\u036f]/g,'')\n .toUpperCase();\n}\nfunction money(value){\n let text=String(value??'').replace(/₡|CRC|COLONES|,/gi,'').replace(/\\s/g,'').trim();\n if(!text) return 0;\n if(/^\\(.*\\)$/.test(text)) text='-'+text.slice(1,-1);\n const number=Number(text);\n return Number.isFinite(number)?Number(number.toFixed(2)):0;\n}\nfunction normalizeId(value){\n const digits=String(value??'').replace(/\\D/g,'').replace(/^0+/,'');\n return digits||'0';\n}\n\nconst MONTH_NAMES={\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 MONTH_ALIASES={\n '01':['ENERO'],'02':['FEBRERO'],'03':['MARZO'],'04':['ABRIL'],\n '05':['MAYO'],'06':['JUNIO'],'07':['JULIO'],'08':['AGOSTO'],\n '09':['SEPTIEMBRE','SETIEMBRE'],'10':['OCTUBRE'],\n '11':['NOVIEMBRE'],'12':['DICIEMBRE']\n};\n\nfunction splitPdfPages(rawText){\n const raw=String(rawText??'').replace(/\\r/g,'');\n const formFeedPages=raw.split(/\\f+/).filter(page=>page.trim());\n if(formFeedPages.length>1) return formFeedPages;\n\n const title='PLANILLA MENSUAL PARA EL MOVIMIENTO DE LOS TRABAJADORES';\n const upper=asciiPreservingLength(raw);\n const indexes=[];\n let offset=0;\n\n while(true){\n const index=upper.indexOf(title,offset);\n if(index<0) break;\n indexes.push(index);\n offset=index+title.length;\n }\n\n if(indexes.length>1){\n return indexes.map((start,index)=>\n raw.slice(start,indexes[index+1]??raw.length)\n ).filter(page=>page.trim());\n }\n\n return [raw];\n}\n\nfunction collectPeriod(rawText,fileName){\n const firstPage=ascii(splitPdfPages(rawText)[0]||rawText);\n const evidence=[];\n\n function add(month,year,score,source){\n const normalizedMonth=String(month??'').padStart(2,'0');\n const normalizedYear=Number(year);\n if(\n !MONTH_NAMES[normalizedMonth]||\n !Number.isInteger(normalizedYear)||\n normalizedYear<2020||\n normalizedYear>2100\n ) return;\n\n evidence.push({\n month:normalizedMonth,\n year:normalizedYear,\n score,\n source\n });\n }\n\n let start=firstPage.indexOf('PERIODO (3)');\n if(start<0) start=0;\n let end=firstPage.indexOf('SALARIOS Y MOVIMIENTOS PARA',start);\n if(end<0||end<=start) end=Math.min(firstPage.length,start+2200);\n const scope=firstPage.slice(start,end);\n\n for(const [month,aliases] of Object.entries(MONTH_ALIASES)){\n for(const alias of aliases){\n const regex=new RegExp(`\\\\b${alias}\\\\b`,'g');\n for(const match of scope.matchAll(regex)){\n const years=[...scope.matchAll(/\\b(20\\d{2})\\b/g)]\n .map(year=>({\n year:Number(year[1]),\n distance:Math.abs(year.index-match.index)\n }))\n .filter(year=>year.distance<=450)\n .sort((a,b)=>a.distance-b.distance);\n\n if(years.length){\n add(\n month,\n years[0].year,\n 320-Math.min(years[0].distance,200),\n 'encabezado'\n );\n }\n }\n }\n }\n\n const invoiceScope=firstPage.slice(0,Math.min(firstPage.length,5000));\n for(const match of invoiceScope.matchAll(/\\b\\d{4}(20\\d{2})(0[1-9]|1[0-2])\\d{5,}\\b/g)){\n add(match[2],Number(match[1]),280,'factura');\n }\n\n const fileScope=ascii(fileName||'');\n const fileYears=[...fileScope.matchAll(/\\b(20\\d{2})\\b/g)]\n .map(match=>Number(match[1]));\n\n for(const [month,aliases] of Object.entries(MONTH_ALIASES)){\n if(!aliases.some(alias=>new RegExp(`\\\\b${alias}\\\\b`).test(fileScope))) continue;\n for(const year of fileYears) add(month,year,160,'archivo');\n }\n\n for(const match of fileScope.matchAll(/\\b(20\\d{2})(0[1-9]|1[0-2])\\b/g)){\n add(match[2],Number(match[1]),150,'archivo compacto');\n }\n\n const grouped=new Map();\n for(const item of evidence){\n const key=`${item.month}|${item.year}`;\n if(!grouped.has(key)){\n grouped.set(key,{\n month:item.month,\n year:item.year,\n score:0,\n sources:[]\n });\n }\n const group=grouped.get(key);\n group.score+=item.score;\n group.sources.push(item.source);\n }\n\n return [...grouped.values()].sort((a,b)=>b.score-a.score)[0]||\n {month:'',year:null,score:0,sources:[]};\n}\n\nconst idRegex=/^\\d-\\d{8,10}$/;\nconst salaryRegex=/^(?:\\d{1,3}(?:,\\d{3})*|\\d+)?\\.\\d{2}$/;\nconst occupationRegex=/^\\d{4}$/;\n\nconst nonNames=new Set([\n 'C','SA','IN','EX','IC','JP','*S','*I','*A','SALARIO','CS',\n ...Object.values(MONTH_NAMES),'SETIEMBRE'\n]);\n\nconst nameBlockers=[\n 'PLANILLA MENSUAL','CAJA COSTARRICENSE','COSTARRICENSE DE SEGURO SOCIAL',\n 'SEGURO SOCIAL','CONSECUTIVO','DOCUMENTO EMITIDO','PATRONAL',\n 'NOMBRE DEL PATRONO','SOCIEDAD ANONIMA','GOMEZLEE','CODIGO ACTIVIDAD',\n 'DIRECCION DE CORRESPONDENCIA','TELEF','PERIODO','FACTURA',\n 'DATOS DE TRABAJADORES','TIPO /','APELLIDOS Y NOMBRES','OCUP',\n 'SALARIO GANADO','Nº.ORDEN','CAMBIO','OBSERVACIONES','TOTAL SALARIOS',\n 'FIRMA AUTORIZADA','AJUSTE AL MINIMO','LUGAR DE PAGO','OFI. CENTRALES',\n 'RP_PLANILLA','RP PLANILLA','PRE/WEB','NOMBRE COMERCIAL','FECHA NACIMIENTO',\n 'SALARIOS Y MOVIMIENTOS PARA','VALIDACION DE DOCUMENTOS',\n 'URBANIZACION LA PRIMAVERA','EN CASO QUE NECESITE','SALARIOS CAMB'\n];\n\nfunction isEmployeeName(line){\n const raw=clean(line);\n const normalized=ascii(raw);\n\n if(normalized.length<5||nonNames.has(normalized)) return false;\n if(idRegex.test(raw)||salaryRegex.test(raw)||/^\\d+$/.test(raw)) return false;\n if(nameBlockers.some(blocker=>normalized.includes(blocker))) return false;\n if(!/^[A-ZÁÉÍÓÚÜÑ'’.\\- ]+$/i.test(raw)) return false;\n\n return normalized.split(' ').filter(Boolean).length>=2;\n}\n\nfunction findConsecutiveRun(lines,startIndex,count,predicate){\n for(let index=startIndex;index<=lines.length-count;index++){\n let valid=true;\n for(let offset=0;offsetlines[index]);\n const count=ids.length;\n const lastIdIndex=idIndexes[idIndexes.length-1];\n\n const occupationStart=findConsecutiveRun(\n lines,\n lastIdIndex+1,\n count,\n line=>\n occupationRegex.test(line)&&\n !(Number(line)>=2020&&Number(line)<=2100)&&\n line!=='7414'\n );\n\n if(occupationStart<0){\n throw new Error(\n `No se pudo identificar la columna de ocupaciones de la página ${pageNumber}.`\n );\n }\n\n const names=lines\n .slice(lastIdIndex+1,occupationStart)\n .filter(isEmployeeName);\n\n if(names.length!==count){\n throw new Error(\n `La página ${pageNumber} contiene ${count} cédulas, pero `+\n `${names.length} nombres válidos.`\n );\n }\n\n const salaryStart=findConsecutiveRun(\n lines,\n occupationStart+count,\n count,\n line=>salaryRegex.test(line)\n );\n\n if(salaryStart<0){\n throw new Error(\n `No se pudo identificar la columna de salarios de la página ${pageNumber}.`\n );\n }\n\n const occupations=lines.slice(occupationStart,occupationStart+count);\n const salaries=lines.slice(salaryStart,salaryStart+count);\n const records=ids.map((idDisplay,index)=>({\n ccss_id:normalizeId(idDisplay),\n ccss_display:idDisplay,\n nombre:names[index],\n occupation:occupations[index],\n monto:money(salaries[index])\n }));\n\n const officialTotals=lines\n .slice(salaryStart+count)\n .filter(line=>salaryRegex.test(line))\n .map(money)\n .filter(value=>value>0);\n\n return {records,officialTotals};\n}\n\nconst pages=splitPdfPages(text);\nconst expectedPages=Number($json.numpages||0);\nif(expectedPages&&pages.length!==expectedPages){\n throw new Error(\n `El PDF informa ${expectedPages} páginas, pero el lector pudo separar ${pages.length}.`\n );\n}\n\nconst parsedRecords=[];\nconst officialTotals=[];\n\nfor(let index=0;index0.01||\n ascii(existing.nombre)!==ascii(record.nombre)\n ){\n throw new Error(\n `La cédula ${record.ccss_display} aparece duplicada con datos diferentes.`\n );\n }\n}\n\nconst unique=[...byId.values()];\nif(!unique.length){\n throw new Error('No se detectaron trabajadores en la planilla CCSS.');\n}\n\nconst suspicious=unique.filter(record=>\n /SEGURO SOCIAL|GOMEZLEE|PLANILLA|TOTAL SALARIOS|PATRONAL/.test(ascii(record.nombre))\n);\nif(suspicious.length){\n throw new Error(\n 'La lectura de nombres de la CCSS quedó desalineada. '+\n 'El proceso fue detenido para evitar generar un reporte incorrecto.'\n );\n}\n\nconst detectedPeriod=collectPeriod(text,prep.fileName);\nconst expectedMonth=String(init.mes).padStart(2,'0');\nconst expectedYear=Number(init.anio);\n\nif(\n detectedPeriod.month!==expectedMonth||\n detectedPeriod.year!==expectedYear\n){\n const detectedLabel=detectedPeriod.month&&detectedPeriod.year\n ?`${MONTH_NAMES[detectedPeriod.month]} ${detectedPeriod.year}`\n :'un período no identificable';\n\n throw new Error(\n `La planilla CCSS corresponde a ${detectedLabel}, no a ${init.periodoLabel}. `+\n 'Revisa el mes, el año y el archivo seleccionados.'\n );\n}\n\nconst total=Number(\n unique.reduce((sum,record)=>sum+record.monto,0).toFixed(2)\n);\nconst officialTotal=officialTotals.length\n ?Math.max(...officialTotals.map(value=>Number(value)||0))\n :null;\n\nif(officialTotal!==null&&Math.abs(total-officialTotal)>0.01){\n throw new Error(\n `La lectura no concilia con el TOTAL SALARIOS oficial: `+\n `se extrajeron ₡${total.toFixed(2)} y el PDF muestra ₡${officialTotal.toFixed(2)}.`\n );\n}\n\nreturn [{json:{\n runId:init.runId,\n mes:init.mes,\n anio:init.anio,\n periodoLabel:init.periodoLabel,\n reportTitle:init.reportTitle,\n fileName:prep.fileName,\n ccssRecords:unique,\n ccssCount:unique.length,\n ccssTotal:total,\n ccssOfficialTotal:officialTotal,\n detectedPeriod:{\n month:detectedPeriod.month,\n year:detectedPeriod.year,\n sources:detectedPeriod.sources\n },\n parsedPages:pages.length\n}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1872, + 1376 + ], + "id": "cb3d5b10-1bdf-48f2-9097-549c5cc9f82b", + "name": "Extraer Planilla CCSS" + }, + { + "parameters": { + "method": "POST", + "url": "https://glm.bamboohr.com/api/v1/reports/custom?format=JSON&onlyCurrent=false", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": { + "title": "Información de BambooHR - Proyecto y Fecha de Ingreso", + "fields": [ + "firstName", + "middleName", + "lastName", + "displayName", + "department", + "division", + "location", + "customPosicion-Cliente", + "hireDate", + "originalHireDate", + "status", + "employeeNumber" + ] + }, + "options": { + "response": { + "response": { + "responseFormat": "json" + } + }, + "timeout": 300000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1392, + 1616 + ], + "id": "7a5f9f84-588d-41ea-906d-d4717d5a30eb", + "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 ejecución')[0].json;\n\nfunction clean(value){\n return String(value ?? '').replace(/\\s+/g,' ').trim();\n}\nfunction norm(value){\n return clean(value)\n .normalize('NFD').replace(/[\\u0300-\\u036f]/g,'')\n .toUpperCase().replace(/[^A-Z0-9 ]/g,' ')\n .replace(/\\s+/g,' ').trim();\n}\nfunction date(value){\n const text=clean(value);\n if(!text) return '';\n const iso=text.match(/^(\\d{4}-\\d{2}-\\d{2})/);\n if(iso) return iso[1];\n const us=text.match(/^(\\d{1,2})\\/(\\d{1,2})\\/(\\d{4})$/);\n if(us) return `${us[3]}-${String(us[1]).padStart(2,'0')}-${String(us[2]).padStart(2,'0')}`;\n return text;\n}\nfunction toArray(value){\n if(Array.isArray(value)) return value;\n if(value && typeof value==='object') return Object.values(value);\n return [];\n}\n\nconst inputs=$input.all().map(item=>{\n const json=item.json || {};\n return json.body && typeof json.body==='object' ? json.body : json;\n});\n\nconst rows=[];\nfor(const payload of inputs){\n const candidates=[\n payload?.employees,\n payload?.data,\n payload?.results,\n payload?.data?.employees,\n payload?.report?.employees,\n payload\n ];\n for(const candidate of candidates){\n const found=toArray(candidate);\n if(found.length && found.some(row=>row && typeof row==='object')){\n rows.push(...found);\n break;\n }\n }\n}\n\nconst map=new Map();\nfor(const row of rows){\n if(!row || typeof row!=='object') continue;\n\n const firstName=clean(row.firstName ?? row['First Name']);\n const middleName=clean(row.middleName ?? row['Middle Name']);\n const lastName=clean(row.lastName ?? row['Last Name']);\n const displayName=clean(row.displayName ?? row['Display Name']);\n const fullConstructed=clean([firstName,middleName,lastName].filter(Boolean).join(' '));\n const shortConstructed=clean([firstName,lastName].filter(Boolean).join(' '));\n\n const variants=[displayName,fullConstructed,shortConstructed]\n .filter(Boolean)\n .filter((value,index,array)=>array.findIndex(other=>norm(other)===norm(value))===index);\n\n if(!variants.length) continue;\n\n const preferred=variants.slice().sort((a,b)=>b.split(' ').length-a.split(' ').length || b.length-a.length)[0];\n const location=clean(row.location ?? row['Location']);\n const division=clean(row.division ?? row['Division']);\n const department=clean(row.department ?? row['Department']);\n const countrySignal=norm(`${location} ${division} ${department}`);\n const esCostaRica=/COSTA RICA|\\bCR\\b/.test(countrySignal);\n\n const rec={\n employee_id:clean(row.employeeId ?? row.id ?? row.employeeID),\n employee_number:clean(row.employeeNumber ?? row['Employee #'] ?? row['Employee Number']),\n nombre_bamboo:preferred,\n nombres_bamboo:variants,\n nombre_norm:norm(preferred),\n fecha_ingreso:date(row.hireDate ?? row.originalHireDate ?? row['Hire Date'] ?? row['Original Hire Date']),\n estado_bamboo:clean(row.status ?? row['Status']),\n ubicacion:location,\n division,\n departamento:department,\n posicion_cliente:clean(row['customPosicion-Cliente'] ?? row.customPosicionCliente),\n es_costa_rica:esCostaRica\n };\n\n const key=rec.employee_id || rec.employee_number || `${rec.nombre_norm}|${rec.fecha_ingreso}`;\n if(!map.has(key)){\n map.set(key,rec);\n }else{\n const current=map.get(key);\n current.nombres_bamboo=[...new Set([...current.nombres_bamboo,...rec.nombres_bamboo])];\n if(!current.nombre_bamboo || rec.nombre_bamboo.length>current.nombre_bamboo.length) current.nombre_bamboo=rec.nombre_bamboo;\n if(!current.fecha_ingreso) current.fecha_ingreso=rec.fecha_ingreso;\n if(!current.estado_bamboo) current.estado_bamboo=rec.estado_bamboo;\n current.es_costa_rica=current.es_costa_rica || rec.es_costa_rica;\n }\n}\n\nconst bambooRecords=[...map.values()];\nif(!bambooRecords.length){\n throw new Error('El reporte personalizado de BambooHR no devolvió empleados legibles.');\n}\n\nreturn [{json:{\n runId:init.runId,\n bambooRecords,\n empleadosBamboo:bambooRecords.length,\n empleadosCostaRica:bambooRecords.filter(row=>row.es_costa_rica).length,\n paginasBamboo:inputs.length\n}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1632, + 1616 + ], + "id": "e2648e20-95a2-446a-af12-8db5aba548fb", + "name": "Normalizar empleados BambooHR" + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 4640, + 1248 + ], + "id": "cdcc7bed-0ca9-4852-a86e-60557882ffa8", + "name": "Esperar Nómina y CCSS" + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 4880, + 1376 + ], + "id": "732bf02e-14c9-4293-b0f7-5a0ff371f26f", + "name": "Esperar BambooHR y cruces" + }, + { + "parameters": { + "jsCode": "const init=$items('Inicializar ejecución')[0].json;\nconst payrollOut=$items('Extraer Nómina')[0].json;\nconst ccssOut=$items('Extraer Planilla CCSS')[0].json;\nconst bambooOut=$items('Normalizar empleados BambooHR')[0].json;\n\nconst payroll=Array.isArray(payrollOut.payrollRecords)?payrollOut.payrollRecords:[];\nconst ccss=Array.isArray(ccssOut.ccssRecords)?ccssOut.ccssRecords:[];\nconst bamboo=Array.isArray(bambooOut.bambooRecords)?bambooOut.bambooRecords:[];\n\nconst BLANK=null;\nconst STOPWORDS=new Set(['DE','DEL','LA','LAS','LOS','Y','NOINDICAOTRO']);\nconst TOKEN_ALIAS={\n BREYNER:'BRAINER',\n BRAYNER:'BRAINER',\n SEGRUA:'SEGURA',\n SANDI:'SANDINO',\n JEREMIS:'JEREMY',\n JEREMYS:'JEREMY',\n JEREMI:'JEREMY',\n YEREMIS:'JEREMY',\n YEREMY:'JEREMY'\n};\n\nfunction clean(value){\n return String(value ?? '').replace(/\\s+/g,' ').trim();\n}\nfunction canon(value){\n return clean(value)\n .normalize('NFD').replace(/[\\u0300-\\u036f]/g,'')\n .toUpperCase().replace(/[^A-Z0-9 ]/g,' ')\n .replace(/\\s+/g,' ').trim();\n}\nfunction rawTokens(value){\n return canon(value).split(' ').filter(token=>token.length>1&&!STOPWORDS.has(token));\n}\nfunction tokens(value){\n return rawTokens(value).map(token=>TOKEN_ALIAS[token]||token);\n}\nfunction levenshtein(a,b){\n a=String(a); b=String(b);\n const n=b.length;\n let previous=Array.from({length:n+1},(_,index)=>index);\n for(let i=1;i<=a.length;i++){\n const current=[i];\n for(let j=1;j<=n;j++){\n current[j]=Math.min(\n current[j-1]+1,\n previous[j]+1,\n previous[j-1]+(a[i-1]===b[j-1]?0:1)\n );\n }\n previous=current;\n }\n return previous[n];\n}\nfunction adjacentTranspose(a,b){\n if(a.length!==b.length) return false;\n const diff=[];\n for(let i=0;ibestScore){\n bestScore=score;\n bestIndex=index;\n }\n }\n if(bestIndex>=0 && bestScore>=0.68){\n used.add(bestIndex);\n matched++;\n totalScore+=bestScore;\n if(source===bTokens[bestIndex]) exact++;\n }\n }\n\n return {\n matched,\n exact,\n average:matched?totalScore/matched:0,\n unmatchedA:aTokens.length-matched,\n unmatchedB:bTokens.length-matched\n };\n}\nfunction nameScoreDetail(a,b){\n const A=tokens(a);\n const B=tokens(b);\n if(!A.length||!B.length) return {score:0,matched:0,exact:0};\n\n const match=bestTokenMatch(A,B);\n const coverage=match.matched/Math.max(A.length,B.length);\n const shorter=match.matched/Math.min(A.length,B.length);\n let score=coverage*68+shorter*22+match.average*10;\n\n if(match.matched===Math.min(A.length,B.length)){\n score=Math.max(score,92-Math.abs(A.length-B.length)*1.5);\n }\n if(canon(a)===canon(b)) score=100;\n\n return {...match,score:Math.min(100,score)};\n}\nfunction nameScore(a,b){\n return nameScoreDetail(a,b).score;\n}\nfunction materialNameDifference(a,b){\n const A=tokens(a);\n const B=tokens(b);\n if(!A.length||!B.length) return false;\n\n const fuzzy=bestTokenMatch(A,B);\n\n // No se reportan como diferencia los acentos, errores pequeños,\n // letras transpuestas, nombres adicionales ni apellidos abreviados.\n if(fuzzy.matched===Math.min(A.length,B.length)) return false;\n\n // Solo se reporta cuando ambos documentos conservan tokens\n // significativos sin emparejar; por ejemplo, apellidos realmente distintos.\n return (\n fuzzy.matched>=2&&\n fuzzy.unmatchedA>0&&\n fuzzy.unmatchedB>0\n );\n}\nfunction money(value){\n const number=Number(value)||0;\n return Math.round((number+Math.sign(number||1)*1e-9)*100)/100;\n}\nfunction comparableId(value){\n const digits=String(value||'').replace(/\\D/g,'').replace(/^0+/,'');\n return digits||'0';\n}\nfunction statusEs(value){\n const status=canon(value);\n if(status==='ACTIVE'||status==='ACTIVO') return 'Activo';\n if(status==='INACTIVE'||status==='INACTIVO') return 'Inactivo';\n return clean(value)||'No disponible';\n}\nfunction payrollPrimaryDisplay(record){\n return clean(\n record.primary_display||\n record.primary_ids?.[0]||\n ''\n );\n}\nfunction payrollCcssDisplay(record){\n return record.has_ccss_id\n ?clean(record.ccss_display||record.official_display||'')\n :'';\n}\nfunction sortByName(list,selector){\n return list.slice().sort((a,b)=>\n canon(selector(a)).localeCompare(canon(selector(b)),'es')\n );\n}\n\nconst bambooExact=new Map();\nconst bambooTokenIndex=new Map();\n\nfor(let index=0;indexbest.score) best={...detail,variant};\n }\n return best;\n}\n\nfunction matchBamboo(name){\n const exactIndexes=bambooExact.get(canon(name))||[];\n if(exactIndexes.length===1) return bamboo[exactIndexes[0]];\n if(exactIndexes.length>1){\n const costa=exactIndexes.map(index=>bamboo[index]).filter(employee=>employee.es_costa_rica);\n if(costa.length===1) return costa[0];\n return null;\n }\n\n const hitCounts=new Map();\n for(const token of new Set(tokens(name))){\n for(const index of bambooTokenIndex.get(token)||[]){\n hitCounts.set(index,(hitCounts.get(index)||0)+1);\n }\n }\n\n let indexes=[...hitCounts.entries()]\n .filter(([,hits])=>hits>=2)\n .sort((a,b)=>b[1]-a[1])\n .map(([index])=>index);\n\n if(!indexes.length){\n indexes=[...hitCounts.entries()]\n .filter(([,hits])=>hits>=1)\n .sort((a,b)=>b[1]-a[1])\n .slice(0,160)\n .map(([index])=>index);\n }\n if(!indexes.length) return null;\n\n const ranked=indexes.map(index=>{\n const employee=bamboo[index];\n const detail=bambooCandidateScore(name,employee);\n const adjusted=detail.score+(employee.es_costa_rica?3:0);\n return {employee,detail,adjusted};\n }).sort((a,b)=>b.adjusted-a.adjusted||b.detail.score-a.detail.score);\n\n const best=ranked[0];\n const second=ranked[1];\n\n if(!best || best.detail.score<80 || best.detail.matched<2) return null;\n\n if(second){\n const sameStrength=Math.abs(best.adjusted-second.adjusted)<5;\n const bothCosta=Boolean(best.employee.es_costa_rica)===Boolean(second.employee.es_costa_rica);\n if(sameStrength && bothCosta && best.detail.score<96) return null;\n if(Math.abs(best.adjusted-second.adjusted)<1.5 && best.detail.score<99) return null;\n }\n\n return best.employee;\n}\n\nconst ccssById=new Map(ccss.map(record=>[record.ccss_id,record]));\nconst usedCcss=new Set();\nconst matched=[];\nconst soloNomina=[];\n\nfor(const payrollRecord of payroll){\n let ccssRecord=ccssById.get(payrollRecord.official_id)||null;\n let method=ccssRecord?'ID CCSS':'';\n\n if(ccssRecord && (usedCcss.has(ccssRecord.ccss_id) || nameScore(payrollRecord.nombre,ccssRecord.nombre)<50)){\n ccssRecord=null;\n method='';\n }\n\n if(!ccssRecord){\n const candidates=ccss\n .filter(record=>!usedCcss.has(record.ccss_id))\n .map(record=>({\n record,\n score:nameScore(payrollRecord.nombre,record.nombre),\n amountDifference:Math.abs(money(payrollRecord.monto)-money(record.monto))\n }))\n .sort((a,b)=>(b.score-a.score)||(a.amountDifference-b.amountDifference));\n\n const best=candidates[0];\n const second=candidates[1];\n if(\n best &&\n best.score>=82 &&\n best.amountDifference<=1 &&\n (!second || best.score-second.score>=6 || best.score>=96)\n ){\n ccssRecord=best.record;\n method='Nombre + monto';\n }\n }\n\n if(ccssRecord){\n usedCcss.add(ccssRecord.ccss_id);\n matched.push({p:payrollRecord,c:ccssRecord,method});\n }else{\n soloNomina.push(payrollRecord);\n }\n}\n\nconst soloCcss=ccss.filter(record=>!usedCcss.has(record.ccss_id));\nconst minimumSource=Math.min(payroll.length,ccss.length);\nconst matchRate=minimumSource?matched.length/minimumSource:0;\n\nif(minimumSource>=20&&matchRate<0.75){\n throw new Error(\n `El cruce solo logró emparejar ${matched.length} de ${minimumSource} personas `+\n `(${(matchRate*100).toFixed(1)}%). `+\n 'El proceso fue detenido porque la lectura de las fuentes parece desalineada.'\n );\n}\nconst cedulaDiffs=[];\nconst nameDiffs=[];\nconst amountDiffs=[];\n\nfor(const match of matched){\n const primary=match.p.primary_ids?.[0]||'';\n if(primary && comparableId(primary)!==comparableId(match.c.ccss_id)){\n cedulaDiffs.push({...match,primary});\n }\n if(materialNameDifference(match.p.nombre,match.c.nombre)){\n nameDiffs.push(match);\n }\n const difference=money(match.p.monto-match.c.monto);\n if(Math.abs(difference)>0.01){\n amountDiffs.push({...match,dif:difference});\n }\n}\n\nconst bambooMatches=new Map();\nfor(const payrollRecord of payroll){\n bambooMatches.set(payrollRecord,matchBamboo(payrollRecord.nombre));\n}\n\nconst soloNominaEnriched=soloNomina.map(p=>({p,b:bambooMatches.get(p)||null}));\nconst payrollWithoutBamboo=payroll\n .map(p=>({p,b:bambooMatches.get(p)||null}))\n .filter(item=>!item.b);\n\nconst totalPayroll=money(payroll.reduce((sum,record)=>sum+record.monto,0));\nconst totalCcss=money(ccss.reduce((sum,record)=>sum+record.monto,0));\nconst matchedPayroll=money(matched.reduce((sum,item)=>sum+item.p.monto,0));\nconst matchedCcss=money(matched.reduce((sum,item)=>sum+item.c.monto,0));\n\nconst soloNominaSorted=sortByName(soloNomina,record=>record.nombre);\nconst soloCcssSorted=sortByName(soloCcss,record=>record.nombre);\nconst cedulaDiffsSorted=sortByName(cedulaDiffs,item=>item.p.nombre);\nconst nameDiffsSorted=sortByName(nameDiffs,item=>item.p.nombre);\nconst amountDiffsSorted=sortByName(amountDiffs,item=>item.p.nombre);\n\nconst soloNominaEnrichedSorted=soloNominaSorted.map(p=>({\n p,\n b:bambooMatches.get(p)||null\n}));\n\nconst identityCount=\n soloNominaSorted.length+\n soloCcssSorted.length+\n cedulaDiffsSorted.length+\n nameDiffsSorted.length;\nconst amountCount=\n amountDiffsSorted.length+\n soloNominaSorted.length+\n soloCcssSorted.length;\n\nlet statusText='COMPLETADO · SIN DIFERENCIAS';\nlet statusDetail='No se detectaron diferencias de identidad, cédulas ni montos.';\nif(identityCount||amountCount){\n statusText='COMPLETADO · REVISAR HALLAZGOS';\n statusDetail='El reporte contiene diferencias que Administración debe revisar.';\n}\n\nconst identityRows=[];\nconst idMeta={\n titleRows:[1,2],\n sectionRows:[4],\n summaryRows:[5,6],\n headerRows:[],\n maxColumns:10\n};\n\nidentityRows.push([`GOMEZLEE MARKETING | Cruce de Seguridad Social Costa Rica - ${init.periodoLabel}`]);\nidentityRows.push([`Identidad, cédulas y presencia Nómina vs. CCSS · ${statusText}`]);\nidentityRows.push([]);\nidentityRows.push(['Resumen']);\nidentityRows.push([\n 'Personas en Nómina',payroll.length,\n 'Personas en CCSS',ccss.length,\n 'Coincidentes',matched.length,\n 'Solo Nómina',soloNomina.length,\n 'Solo CCSS',soloCcss.length\n]);\nidentityRows.push([\n 'Cédulas distintas',cedulaDiffs.length,\n 'Nombres/apellidos distintos',nameDiffs.length,\n 'Total diferencias',identityCount,\n BLANK,BLANK,BLANK,BLANK\n]);\n\nfunction addIdentitySection(title,headers,rows){\n if(!rows.length) return;\n identityRows.push([]);\n identityRows.push([title]);\n idMeta.sectionRows.push(identityRows.length);\n identityRows.push(headers);\n idMeta.headerRows.push(identityRows.length);\n identityRows.push(...rows);\n}\n\naddIdentitySection(\n 'SOLO EN NÓMINA',\n ['Tipo','Nombre en Nómina','Cédula principal','ID CCSS registrado en Nómina','Monto Nómina (CRC)','Activo/Inactivo BambooHR','Fecha de Ingreso','Nombre en BambooHR','Proyecto(s)','Observación'],\n soloNominaEnrichedSorted.map(({p,b})=>[\n 'Solo en Nómina',\n p.nombre,\n payrollPrimaryDisplay(p)||BLANK,\n payrollCcssDisplay(p)||BLANK,\n p.monto,\n b?statusEs(b.estado_bamboo):'No encontrado',\n b?.fecha_ingreso||BLANK,\n b?.nombre_bamboo||BLANK,\n (p.proyectos||[]).join(', ')||BLANK,\n 'No aparece en la planilla CCSS del período.'\n ])\n);\n\naddIdentitySection(\n 'SOLO EN PLANILLA CCSS',\n ['Tipo','Nombre en CCSS','Cédula CCSS','Monto CCSS (CRC)','Observación',BLANK,BLANK,BLANK,BLANK,BLANK],\n soloCcssSorted.map(c=>[\n 'Solo en CCSS',c.nombre,c.ccss_display,c.monto,\n 'No aparece en la nómina del período.',\n BLANK,BLANK,BLANK,BLANK,BLANK\n ])\n);\n\naddIdentitySection(\n 'CÉDULAS DISTINTAS',\n ['Tipo','Nombre en Nómina','Cédula principal Nómina','ID CCSS registrado en Nómina','Nombre en CCSS','Cédula CCSS','Monto Nómina (CRC)','Monto CCSS (CRC)','Proyecto(s)','Observación'],\n cedulaDiffsSorted.map(item=>[\n 'Cédula distinta',\n item.p.nombre,\n payrollPrimaryDisplay(item.p)||item.primary,\n payrollCcssDisplay(item.p)||BLANK,\n item.c.nombre,\n item.c.ccss_display,\n item.p.monto,\n item.c.monto,\n (item.p.proyectos||[]).join(', ')||BLANK,\n 'La cédula principal de nómina no coincide con la utilizada en la planilla CCSS.'\n ])\n);\n\naddIdentitySection(\n 'NOMBRE O APELLIDO DISTINTO',\n ['Tipo','Nombre en Nómina','Cédula/ID','Nombre en CCSS','Cédula CCSS','Monto Nómina (CRC)','Monto CCSS (CRC)','Método de cruce','Proyecto(s)','Observación'],\n nameDiffsSorted.map(item=>[\n 'Nombre/apellido distinto',\n item.p.nombre,\n payrollPrimaryDisplay(item.p)||item.p.official_id,\n item.c.nombre,\n item.c.ccss_display,\n item.p.monto,\n item.c.monto,\n item.method,\n (item.p.proyectos||[]).join(', ')||BLANK,\n 'La identidad coincide, pero existe una diferencia material de nombre o apellido.'\n ])\n);\n\nif(!identityCount){\n identityRows.push([]);\n identityRows.push(['RESULTADO']);\n idMeta.sectionRows.push(identityRows.length);\n identityRows.push(['Sin diferencias de identidad ni cédulas.']);\n}\n\nconst amountRows=[];\nconst amountMeta={\n titleRows:[1,2],\n sectionRows:[4,8],\n summaryRows:[5,6],\n headerRows:[9],\n maxColumns:10\n};\n\namountRows.push([`GOMEZLEE MARKETING | Cruce de Montos CCSS Costa Rica - ${init.periodoLabel}`]);\namountRows.push([`Comparación del salario sujeto a CCSS · ${statusText}`]);\namountRows.push([]);\namountRows.push(['Resumen']);\namountRows.push([\n 'Personas comparadas',matched.length,\n 'Diferencias por empleado',amountDiffs.length,\n 'Total Nómina (CRC)',totalPayroll,\n 'Total CCSS (CRC)',totalCcss,\n 'Diferencia total',money(totalPayroll-totalCcss)\n]);\namountRows.push([\n 'Total coincidente Nómina',matchedPayroll,\n 'Total coincidente CCSS',matchedCcss,\n 'Diferencia coincidente',money(matchedPayroll-matchedCcss),\n 'Casos solo en una fuente',soloNomina.length+soloCcss.length,\n BLANK,BLANK\n]);\namountRows.push([]);\namountRows.push(['DIFERENCIAS DE MONTOS']);\namountRows.push([\n 'Tipo','Nombre en Nómina','Cédula Nómina','Monto Nómina (CRC)',\n 'Nombre en CCSS','Cédula CCSS','Monto CCSS (CRC)',\n 'Diferencia (CRC)','Proyecto(s)','Observación'\n]);\n\nfor(const item of amountDiffsSorted){\n amountRows.push([\n 'Diferencia de monto',\n item.p.nombre,\n payrollPrimaryDisplay(item.p)||item.p.official_id,\n item.p.monto,\n item.c.nombre,\n item.c.ccss_display,\n item.c.monto,\n item.dif,\n (item.p.proyectos||[]).join(', ')||BLANK,\n 'Revisar el salario sujeto a CCSS.'\n ]);\n}\nfor(const p of soloNominaSorted){\n amountRows.push([\n 'Solo en Nómina',p.nombre,payrollPrimaryDisplay(p)||p.official_id,p.monto,\n BLANK,BLANK,0,p.monto,\n (p.proyectos||[]).join(', ')||BLANK,\n 'Diferencia explicada por ausencia en CCSS.'\n ]);\n}\nfor(const c of soloCcssSorted){\n amountRows.push([\n 'Solo en CCSS',BLANK,BLANK,0,c.nombre,c.ccss_display,c.monto,-c.monto,\n BLANK,'Diferencia explicada por ausencia en Nómina.'\n ]);\n}\nif(!amountCount){\n amountRows.push([\n 'Sin diferencias',BLANK,BLANK,0,BLANK,BLANK,0,0,BLANK,\n 'Los montos por empleado y los totales concilian.'\n ]);\n}\n\nconst noBambooRows=[];\nconst bambooMeta={\n titleRows:[1,2],\n sectionRows:[4],\n summaryRows:[],\n headerRows:[5],\n maxColumns:1\n};\n\nnoBambooRows.push([`GOMEZLEE MARKETING | Nómina sin BambooHR - ${init.periodoLabel}`]);\nnoBambooRows.push([\n `${payrollWithoutBamboo.length} empleado(s) de la Nómina sin una coincidencia suficientemente confiable en BambooHR.`\n]);\nnoBambooRows.push([]);\nnoBambooRows.push(['RESULTADO']);\nnoBambooRows.push(['Nombre en Nómina']);\nif(payrollWithoutBamboo.length){\n noBambooRows.push(...payrollWithoutBamboo.map(item=>[item.p.nombre]));\n}else{\n noBambooRows.push(['Sin diferencias']);\n}\n\nreturn [{json:{\n runId:init.runId,\n mes:init.mes,\n anio:init.anio,\n periodoLabel:init.periodoLabel,\n reportTitle:init.reportTitle,\n identityRows,\n amountRows,\n nominaSinBambooRows:noBambooRows,\n formatMeta:{identity:idMeta,amount:amountMeta,bamboo:bambooMeta},\n diferenciasIdentidad:{soloNomina:soloNominaEnriched,soloCcss,cedulaDiffs,nameDiffs},\n diferenciasMontos:{amountDiffs,soloNomina,soloCcss},\n summary:{\n statusText,\n statusDetail,\n payrollCount:payroll.length,\n ccssCount:ccss.length,\n matchedCount:matched.length,\n soloNomina:soloNomina.length,\n soloCcss:soloCcss.length,\n cedulaDifferences:cedulaDiffs.length,\n nameDifferences:nameDiffs.length,\n identityDifferences:identityCount,\n amountDifferences:amountCount,\n totalPayroll,\n totalCcss,\n totalDifference:money(totalPayroll-totalCcss),\n matchedPayroll,\n matchedCcss,\n nominaSinBamboo:payrollWithoutBamboo.length,\n bambooCount:bamboo.length,\n files:{nomina:payrollOut.fileName,ccss:ccssOut.fileName},\n stagingSpreadsheetId:payrollOut.spreadsheetId\n }\n}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 5120, + 1376 + ], + "id": "75f06abd-c6ae-4424-8cb1-1f31b4ed66c8", + "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: $('Construir reporte producción').item.json.reportTitle }, sheets: [{ properties: { title: 'Cruce de Identidad' } }, { properties: { title: 'Cruce de Montos' } }, { properties: { title: 'Nómina sin BambooHR' } }] }) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 5360, + 1376 + ], + "id": "9e46c1d4-b148-40d9-8bdf-21a460e3aa54", + "name": "Crear Google Sheet Reporte", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "method": "POST", + "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.spreadsheetId }}/values:batchUpdate", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ valueInputOption: 'RAW', data: [{ range: 'Cruce de Identidad!A1', majorDimension: 'ROWS', values: $('Construir reporte producción').item.json.identityRows }, { range: 'Cruce de Montos!A1', majorDimension: 'ROWS', values: $('Construir reporte producción').item.json.amountRows }, { range: 'Nómina sin BambooHR!A1', majorDimension: 'ROWS', values: $('Construir reporte producción').item.json.nominaSinBambooRows }] }) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 5600, + 1376 + ], + "id": "e65051cf-c498-4e17-843f-2670a33b3963", + "name": "Escribir reporte", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const created=$items('Crear Google Sheet Reporte')[0].json;\nconst report=$items('Construir reporte producción')[0].json;\n\nconst ids={};\nfor(const sheet of (created.sheets||[])){\n ids[sheet.properties.title]=sheet.properties.sheetId;\n}\n\nconst C={\n blue:{red:79/255,green:117/255,blue:139/255},\n blue2:{red:91/255,green:127/255,blue:149/255},\n label:{red:238/255,green:246/255,blue:232/255},\n section:{red:214/255,green:232/255,blue:244/255},\n gray:{red:245/255,green:245/255,blue:245/255},\n text:{red:74/255,green:74/255,blue:74/255},\n white:{red:1,green:1,blue:1},\n border:{red:190/255,green:198/255,blue:204/255}\n};\n\nconst requests=[];\nconst borders={\n top:{style:'SOLID',color:C.border},\n bottom:{style:'SOLID',color:C.border},\n left:{style:'SOLID',color:C.border},\n right:{style:'SOLID',color:C.border}\n};\n\nfunction repeat(sheetId,startRow,endRow,startColumn,endColumn,format,fields='userEnteredFormat'){\n if(endRow<=startRow||endColumn<=startColumn) return;\n requests.push({\n repeatCell:{\n range:{\n sheetId,\n startRowIndex:startRow,\n endRowIndex:endRow,\n startColumnIndex:startColumn,\n endColumnIndex:endColumn\n },\n cell:{userEnteredFormat:format},\n fields\n }\n });\n}\nfunction setRowHeight(sheetId,startRow,endRow,pixelSize){\n if(endRow<=startRow) return;\n requests.push({\n updateDimensionProperties:{\n range:{\n sheetId,\n dimension:'ROWS',\n startIndex:startRow,\n endIndex:endRow\n },\n properties:{pixelSize},\n fields:'pixelSize'\n }\n });\n}\nfunction setColumnWidth(sheetId,column,pixelSize){\n requests.push({\n updateDimensionProperties:{\n range:{\n sheetId,\n dimension:'COLUMNS',\n startIndex:column,\n endIndex:column+1\n },\n properties:{pixelSize},\n fields:'pixelSize'\n }\n });\n}\nfunction autoResizeRows(sheetId,rowCount){\n requests.push({\n autoResizeDimensions:{\n dimensions:{\n sheetId,\n dimension:'ROWS',\n startIndex:0,\n endIndex:rowCount\n }\n }\n });\n}\nfunction mergeRow(sheetId,rowIndex,columnCount){\n if(columnCount<=1) return;\n requests.push({\n mergeCells:{\n range:{\n sheetId,\n startRowIndex:rowIndex,\n endRowIndex:rowIndex+1,\n startColumnIndex:0,\n endColumnIndex:columnCount\n },\n mergeType:'MERGE_ALL'\n }\n });\n}\nfunction blankRow(row){\n return !Array.isArray(row)||\n row.every(value=>\n value===null||\n value===undefined||\n String(value)===''\n );\n}\nfunction normalized(value){\n return String(value??'')\n .normalize('NFD').replace(/[\\u0300-\\u036f]/g,'')\n .toUpperCase();\n}\nfunction tableEnd(rows,headerRowOneBased){\n let index=headerRowOneBased;\n while(indexsetColumnWidth(sheetId,index,width));\n }else if(title==='Cruce de Montos'){\n [190,300,190,180,300,190,180,180,285,430]\n .forEach((width,index)=>setColumnWidth(sheetId,index,width));\n }else if(title==='Nómina sin BambooHR'){\n setColumnWidth(sheetId,0,580);\n }\n\n // Cuerpo de cada tabla: bordes, alternancia y alineación.\n for(const headerRow of meta.headerRows||[]){\n const headerIndex=headerRow-1;\n const endIndex=tableEnd(rows,headerRow);\n const headers=rows[headerIndex]||[];\n\n for(let rowIndex=headerIndex+1;rowIndexx.toLowerCase()))];\nif(!emails.length)throw new Error('No hay destinatarios configurados.');\nconst sheet=$items('Crear Google Sheet Reporte')[0].json;\nreturn [{json:{toEmails:emails,allEmails:emails,sendTo:emails.join(', '),spreadsheetId:sheet.spreadsheetId,spreadsheetUrl:sheet.spreadsheetUrl||`https://docs.google.com/spreadsheets/d/${sheet.spreadsheetId}/edit`,permissionRole:'writer'}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 7280, + 1392 + ], + "id": "6529d3ea-f207-4284-93e7-e81d412640e4", + "name": "CONFIG - Destinatarios correo y permisos" + }, + { + "parameters": { + "jsCode": "const c=$input.first().json;return c.allEmails.map(email=>({json:{emailAddress:email,spreadsheetId:c.spreadsheetId,spreadsheetUrl:c.spreadsheetUrl,permissionRole:'writer'}}));" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 7520, + 1392 + ], + "id": "9ea32f6e-67c7-40bb-9702-60a81855286c", + "name": "Expandir permisos destinatarios" + }, + { + "parameters": { + "method": "POST", + "url": "=https://www.googleapis.com/drive/v3/files/{{ $json.spreadsheetId }}/permissions?sendNotificationEmail=false&supportsAllDrives=true", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleDriveOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ role: 'writer', type: 'user', emailAddress: $json.emailAddress }) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 7760, + 1392 + ], + "id": "b00b2624-a547-4437-9a11-49a79dfe82c2", + "name": "Compartir reporte como editor", + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + }, + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "const cfg=$items('CONFIG - Destinatarios correo y permisos')[0].json;\nconst r=$items('Construir reporte producción')[0].json;\nconst s=r.summary||{};\nconst nf=n=>Number(n||0).toLocaleString('es-CR');\nconst money=n=>'₡'+Number(n||0).toLocaleString('es-CR',{minimumFractionDigits:2,maximumFractionDigits:2});\nconst has=Number(s.identityDifferences||0)>0||Number(s.amountDifferences||0)>0;\nconst statusBg=has?'#FFF8E1':'#E8F5E9';\nconst statusColor=has?'#9A6700':'#166534';\nconst html=`
 
\"GomezLee
SEGURIDAD SOCIAL · COSTA RICA

Cruce Nómina vs. CCSS

${r.periodoLabel}

${s.statusText}
${s.statusDetail}
MétricaResultado
Personas en Nómina${nf(s.payrollCount)}
Personas en CCSS${nf(s.ccssCount)}
Diferencias de identidad/cédula${nf(s.identityDifferences)}
Casos con diferencia de monto o presencia${nf(s.amountDifferences)}
Total Nómina${money(s.totalPayroll)}
Total CCSS${money(s.totalCcss)}
Nómina sin BambooHR${nf(s.nominaSinBamboo)}
Abrir reporte en Google Sheets
Archivos procesados:
Nómina: ${s.files?.nomina||''}
Planilla CCSS: ${s.files?.ccss||''}
GOMEZLEE MARKETING · Uso interno
`;\nreturn [{json:{sendTo:cfg.sendTo,spreadsheetId:cfg.spreadsheetId,spreadsheetUrl:cfg.spreadsheetUrl,subject:`${has?'[REVISAR]':'[OK]'} Cruce Seguridad Social Costa Rica - ${r.periodoLabel}`,html}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 8000, + 1392 + ], + "id": "602d8caa-fa4c-4062-90b9-9ee5b9ea0559", + "name": "Preparar correo HTML GLM" + }, + { + "parameters": { + "sendTo": "={{ $json.sendTo }}", + "subject": "={{ $json.subject }}", + "message": "={{ $json.html }}", + "options": {} + }, + "type": "n8n-nodes-base.gmail", + "typeVersion": 2.1, + "position": [ + 8240, + 1392 + ], + "id": "4c1605b6-7ba8-4e41-aba3-774585862e3e", + "name": "Enviar correo resumen", + "webhookId": "1bdf4396-6002-4925-9a9d-74ae0e398f75", + "executeOnce": true, + "credentials": { + "gmailOAuth2": { + "id": "UDcO1FLJqA453V2D", + "name": "Gmail account 3" + } + } + }, + { + "parameters": { + "jsCode": "const init=$items('Inicializar ejecución')[0].json;\nconst workflowName=typeof $workflow!=='undefined'&&$workflow.name?$workflow.name:'Cruce de Seguridad Social - Costa Rica';\nconst workflowId=typeof $workflow!=='undefined'&&$workflow.id?String($workflow.id):'';\nconst executionId=typeof $execution!=='undefined'&&$execution.id?String($execution.id):'';\nreturn [{json:{id_ejecucion:init.runId,nombre_flujo:workflowName,id_flujo_n8n:workflowId||null,id_ejecucion_n8n:executionId||null,pais_codigo:'CR',pais_nombre:'Costa Rica',entidad_seguridad_social:'CCSS',mes:Number(init.mes),anio:Number(init.anio),periodo:init.periodoLabel,estado:'procesando',resultado:'Cruce iniciado',fecha_inicio:new Date().toISOString(),moneda:'CRC',archivos_procesados:{},conteos_fuente:{},metricas:{etapa:'inicio',origen:'n8n',formulario:'Cruce Seguridad Social Costa Rica'},resumen_completo:{reportTitle:init.reportTitle,runId:init.runId}}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1392, + 1856 + ], + "id": "11b571c2-32ae-4498-a5c6-cd5a10f7b81b", + "name": "Preparar auditoría inicial Supabase", + "continueOnFail": true + }, + { + "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.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOjE2NDE3NjkyMDAsCiAgICAiZXhwIjoxNzk5NTM1NjAwCn0.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" + }, + { + "name": "Authorization", + "value": "=Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOjE2NDE3NjkyMDAsCiAgICAiZXhwIjoxNzk5NTM1NjAwCn0.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": [ + 1632, + 1856 + ], + "id": "e9859a9a-7cdb-4aa9-bf54-a7155a26a74a", + "name": "Registrar auditoría inicial Supabase", + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "const r=$items('Construir reporte producción')[0].json;const s=r.summary||{};const cfg=$items('CONFIG - Destinatarios correo y permisos')[0].json;\nconst workflowName=typeof $workflow!=='undefined'&&$workflow.name?$workflow.name:'Cruce de Seguridad Social - Costa Rica';\nconst workflowId=typeof $workflow!=='undefined'&&$workflow.id?String($workflow.id):'';const executionId=typeof $execution!=='undefined'&&$execution.id?String($execution.id):'';\nconst num=v=>Number((Number(v)||0).toFixed(2));\nreturn [{json:{id_ejecucion:r.runId,nombre_flujo:workflowName,id_flujo_n8n:workflowId||null,id_ejecucion_n8n:executionId||null,pais_codigo:'CR',pais_nombre:'Costa Rica',entidad_seguridad_social:'CCSS',mes:Number(r.mes),anio:Number(r.anio),periodo:r.periodoLabel,estado:'exitoso',resultado:s.statusText||'Completado',fecha_fin:new Date().toISOString(),url_reporte:cfg.spreadsheetUrl,id_google_sheet:cfg.spreadsheetId,destinatarios_correo:cfg.allEmails,moneda:'CRC',empleados_nomina:Number(s.payrollCount||0),empleados_entidad:Number(s.ccssCount||0),empleados_coincidentes:Number(s.matchedCount||0),solo_nomina:Number(s.soloNomina||0),solo_entidad:Number(s.soloCcss||0),diferencias_total:Number(s.identityDifferences||0)+Number(s.amountDifferences||0),diferencias_monetarias:Number(s.amountDifferences||0),diferencias_identidad:Number(s.identityDifferences||0),alertas_documentales:Number(s.cedulaDifferences||0)+Number(s.nameDifferences||0),registros_duplicados:0,alertas_periodo:0,total_nomina:num(s.totalPayroll),total_entidad:num(s.totalCcss),diferencia_monto:num(s.totalDifference),minutos_estimados_manual:90,minutos_automatizacion:7,minutos_ahorrados:83,archivos_procesados:s.files||{},conteos_fuente:{empleados_nomina:s.payrollCount,empleados_ccss:s.ccssCount,coincidentes:s.matchedCount,solo_nomina:s.soloNomina,solo_ccss:s.soloCcss},metricas:{cedulas_distintas:s.cedulaDifferences,nombres_distintos:s.nameDifferences,nomina_sin_bamboohr:s.nominaSinBamboo,empleados_bamboo:s.bambooCount,total_nomina:num(s.totalPayroll),total_ccss:num(s.totalCcss)},resumen_completo:{summary:s,diferenciasIdentidad:r.diferenciasIdentidad,diferenciasMontos:r.diferenciasMontos,reporte:{spreadsheetId:cfg.spreadsheetId,spreadsheetUrl:cfg.spreadsheetUrl}},mensaje_error:null,nodo_error:null}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 8480, + 1392 + ], + "id": "b8c32c98-4108-4091-b425-13e0c160c59a", + "name": "Preparar auditoría final Supabase", + "continueOnFail": true + }, + { + "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.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOjE2NDE3NjkyMDAsCiAgICAiZXhwIjoxNzk5NTM1NjAwCn0.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" + }, + { + "name": "Authorization", + "value": "=Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOjE2NDE3NjkyMDAsCiAgICAiZXhwIjoxNzk5NTM1NjAwCn0.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": [ + 8720, + 1392 + ], + "id": "37cdf6da-a29d-4971-873d-2aa36f82c1a2", + "name": "Guardar auditoría final Supabase", + "continueOnFail": true + }, + { + "parameters": { + "method": "DELETE", + "url": "=https://www.googleapis.com/drive/v3/files/{{ $('Construir reporte producción').item.json.summary.stagingSpreadsheetId }}?supportsAllDrives=true", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleDriveOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 8960, + 1392 + ], + "id": "12e1dd61-f164-405f-ba53-c2af81189f8d", + "name": "Eliminar staging Nómina", + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + }, + "continueOnFail": true + }, + { + "parameters": { + "operation": "completion", + "respondWith": "showText", + "responseText": "
\n
\n
GOMEZLEE MARKETING
\n

Proceso completado

\n

El reporte de Seguridad Social de Costa Rica fue generado, compartido como editor y enviado por correo correctamente.

\n Regresar al formulario\n

Si el botón no responde, abre nuevamente el formulario desde este enlace: volver al inicio.

\n
" + }, + "type": "n8n-nodes-base.form", + "typeVersion": 2.5, + "position": [ + 9200, + 1392 + ], + "id": "e58f8d0a-c3a0-4212-b947-c538cadde546", + "name": "Confirmación y regreso al formulario", + "webhookId": "b940fa07-8c45-47f0-b9d5-8aa8ace8885b", + "notesInFlow": true, + "notes": "Página final con botón de regreso al formulario de Costa Rica en la misma pestaña." + }, + { + "parameters": { + "content": "# 📥 RECEPCIÓN Y PREPARACIÓN DE FUENTES — COSTA RICA\n\nRecibe desde el formulario la nómina, la planilla de la CCSS y los parámetros del período que serán utilizados en el cruce.\n\n## Nómina\n\nEl archivo de nómina se prepara y convierte temporalmente en un Google Sheet de staging para facilitar su lectura.\n\nEl flujo:\n\n1. Prepara el archivo recibido.\n2. Crea el documento temporal de staging.\n3. Relaciona el archivo original con el staging.\n4. Espera que Google Drive complete la conversión.\n5. Consulta las hojas disponibles.\n6. Determina el rango que debe leer.\n7. Recupera los datos.\n8. Extrae y normaliza los registros de nómina.\n\n## Planilla CCSS\n\nLa planilla oficial se procesa desde PDF:\n\n- Se prepara el archivo.\n- Se extrae su contenido textual.\n- Se identifican los empleados, identificaciones y montos relevantes.\n- Se transforma la información en registros aptos para el cruce.\n\n## BambooHR\n\nSe consulta el directorio de empleados de BambooHR y se normalizan datos como:\n\n- Nombre.\n- Identificación disponible.\n- Estado activo o inactivo.\n- Fecha de ingreso.\n- Nombre oficial en BambooHR.\n\n## Auditoría inicial\n\nAl comenzar se registra en Supabase:\n\n- País y período.\n- Usuario que ejecutó el flujo.\n- Fecha y hora de inicio.\n- Identificador de ejecución.\n- Estado inicial del proceso.\n\nReglas:\n\n- No iniciar el cruce sin nómina y planilla CCSS.\n- Mantener separadas las tres fuentes hasta la etapa de comparación.\n- No asumir que el archivo de nómina contiene una única hoja.\n- No perder el archivo original durante la conversión temporal.\n- No modificar información en BambooHR.", + "height": 1328, + "width": 3248, + "color": 7 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 288, + 752 + ], + "id": "68364d34-8402-499b-bbff-0125abf9217e", + "name": "Sticky Note" + }, + { + "parameters": { + "content": "# 🔍 CRUCES Y GENERACIÓN DEL REPORTE — COSTA RICA\n\nSincroniza los resultados de la nómina, la planilla CCSS y BambooHR antes de construir el reporte final.\n\n## Cruce de nómina y CCSS\n\nCompara los empleados de ambas fuentes para detectar:\n\n- Diferencias de identificación.\n- Personas presentes solamente en nómina.\n- Personas presentes solamente en la CCSS.\n- Diferencias en montos reportados.\n- Coincidencias que requieren revisión manual.\n\n## Validación con BambooHR\n\nLos casos relevantes se enriquecen con información del directorio para indicar:\n\n- Si el empleado existe en BambooHR.\n- Nombre oficial registrado.\n- Fecha de ingreso.\n- Estado activo o inactivo.\n- Posibles diferencias de identidad.\n\nBambooHR funciona como fuente complementaria; la comparación principal continúa siendo Nómina vs. CCSS.\n\n## Construcción del reporte\n\nCuando todas las ramas terminan:\n\n1. Se consolidan los cruces.\n2. Se organizan las hojas y columnas del entregable.\n3. Se crea un nuevo Google Sheet.\n4. Se escriben los resultados.\n5. Se preparan las instrucciones de formato.\n6. Se aplica el formato final.\n\nEl reporte puede organizar información como:\n\n- Cruce de identidad.\n- Cruce de montos.\n- Casos presentes en una sola fuente.\n- Nómina sin BambooHR.\n- Resumen de resultados.\n\nFormato esperado:\n\n- Encabezados visibles.\n- Filtros y filas congeladas.\n- Fechas estandarizadas.\n- Montos con formato numérico.\n- Columnas ajustadas.\n- Diferencias claramente identificables.\n\nReglas:\n\n- Esperar todas las fuentes antes de construir el reporte.\n- No ocultar empleados sin coincidencia.\n- No considerar una variación de nombre como ausencia automática.\n- No informar una diferencia de monto sin conservar ambos valores.\n- No declarar terminado el reporte hasta completar escritura y formato.", + "height": 1168, + "width": 2656, + "color": 6 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 3664, + 800 + ], + "id": "2e0deb3c-fcc4-4809-af06-4766a5ea5012", + "name": "Sticky Note1" + }, + { + "parameters": { + "content": "# ✅ DISTRIBUCIÓN Y CIERRE DEL PROCESO\n\nComparte el reporte terminado, notifica a los responsables, registra la auditoría final y elimina los archivos temporales.\n\n## Permisos del reporte\n\nLa configuración central define los destinatarios autorizados.\n\nEl flujo:\n\n1. Recupera la lista de correos.\n2. Genera un registro independiente por destinatario.\n3. Comparte el Google Sheet como editor.\n4. Conserva el enlace final del reporte.\n\nSolamente las personas incluidas en la configuración deben recibir acceso.\n\n## Correo de resultados\n\nSe prepara un correo HTML con la identidad visual de GLM que puede incluir:\n\n- País y período procesado.\n- Resumen de hallazgos.\n- Cantidad de empleados analizados.\n- Cantidad de diferencias.\n- Enlace directo al Google Sheet.\n- Información necesaria para la revisión.\n\nEl correo se envía únicamente después de crear, escribir, formatear y compartir el reporte.\n\n## Auditoría final\n\nSupabase registra el cierre de la ejecución con información como:\n\n- Fecha y hora de finalización.\n- Estado final.\n- Cantidad de registros procesados.\n- Hallazgos detectados.\n- Enlace del reporte.\n- Usuario ejecutor.\n- Resultado del envío y distribución.\n\n## Limpieza y confirmación\n\nAl finalizar:\n\n1. Se elimina el archivo temporal de staging.\n2. Se conserva intacta la nómina original.\n3. Se muestra una página de confirmación.\n4. Se ofrece al usuario regresar al formulario para iniciar otro período.\n\nReglas:\n\n- No enviar el correo antes de compartir correctamente el reporte.\n- No eliminar el staging antes de terminar todas las lecturas.\n- No eliminar el archivo original cargado por el usuario.\n- No registrar la auditoría como exitosa si falló la generación del reporte.\n- No finalizar sin mostrar una confirmación clara al usuario.", + "height": 1088, + "width": 3200, + "color": 5 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 6432, + 832 + ], + "id": "0db53b76-a971-4334-8321-8eb406d2c8e9", + "name": "Sticky Note2" + } + ], + "pinData": {}, + "connections": { + "On form submission": { + "main": [ + [ + { + "node": "Inicializar ejecución", + "type": "main", + "index": 0 + } + ] + ] + }, + "Inicializar ejecución": { + "main": [ + [ + { + "node": "Preparar Nómina", + "type": "main", + "index": 0 + }, + { + "node": "Preparar Planilla CCSS", + "type": "main", + "index": 0 + }, + { + "node": "Consultar empleados BambooHR", + "type": "main", + "index": 0 + }, + { + "node": "Preparar auditoría inicial Supabase", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar Nómina": { + "main": [ + [ + { + "node": "Crear staging Nómina", + "type": "main", + "index": 0 + } + ] + ] + }, + "Crear staging Nómina": { + "main": [ + [ + { + "node": "Reunir staging y Nómina", + "type": "main", + "index": 0 + } + ] + ] + }, + "Reunir staging y Nómina": { + "main": [ + [ + { + "node": "Actualizar staging Nómina", + "type": "main", + "index": 0 + } + ] + ] + }, + "Actualizar staging Nómina": { + "main": [ + [ + { + "node": "Esperar conversión Nómina", + "type": "main", + "index": 0 + } + ] + ] + }, + "Esperar conversión Nómina": { + "main": [ + [ + { + "node": "Listar hojas Nómina", + "type": "main", + "index": 0 + } + ] + ] + }, + "Listar hojas Nómina": { + "main": [ + [ + { + "node": "Preparar rango Nómina", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar rango Nómina": { + "main": [ + [ + { + "node": "Leer hoja Nómina", + "type": "main", + "index": 0 + } + ] + ] + }, + "Leer hoja Nómina": { + "main": [ + [ + { + "node": "Extraer Nómina", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar Planilla CCSS": { + "main": [ + [ + { + "node": "Extraer texto Planilla CCSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extraer texto Planilla CCSS": { + "main": [ + [ + { + "node": "Extraer Planilla CCSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extraer Nómina": { + "main": [ + [ + { + "node": "Esperar Nómina y CCSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extraer Planilla CCSS": { + "main": [ + [ + { + "node": "Esperar Nómina y CCSS", + "type": "main", + "index": 1 + } + ] + ] + }, + "Esperar Nómina y CCSS": { + "main": [ + [ + { + "node": "Esperar BambooHR y cruces", + "type": "main", + "index": 0 + } + ] + ] + }, + "Consultar empleados BambooHR": { + "main": [ + [ + { + "node": "Normalizar empleados BambooHR", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalizar empleados BambooHR": { + "main": [ + [ + { + "node": "Esperar BambooHR y cruces", + "type": "main", + "index": 1 + } + ] + ] + }, + "Esperar BambooHR y cruces": { + "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": "Preparar formato reporte", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar formato reporte": { + "main": [ + [ + { + "node": "Formatear reporte", + "type": "main", + "index": 0 + } + ] + ] + }, + "Formatear reporte": { + "main": [ + [ + { + "node": "CONFIG - Destinatarios correo y permisos", + "type": "main", + "index": 0 + } + ] + ] + }, + "CONFIG - Destinatarios correo y permisos": { + "main": [ + [ + { + "node": "Expandir permisos destinatarios", + "type": "main", + "index": 0 + } + ] + ] + }, + "Expandir permisos destinatarios": { + "main": [ + [ + { + "node": "Compartir reporte como editor", + "type": "main", + "index": 0 + } + ] + ] + }, + "Compartir reporte como editor": { + "main": [ + [ + { + "node": "Preparar correo HTML GLM", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar correo HTML GLM": { + "main": [ + [ + { + "node": "Enviar correo resumen", + "type": "main", + "index": 0 + } + ] + ] + }, + "Enviar correo resumen": { + "main": [ + [ + { + "node": "Preparar auditoría final Supabase", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar auditoría final Supabase": { + "main": [ + [ + { + "node": "Guardar auditoría final Supabase", + "type": "main", + "index": 0 + } + ] + ] + }, + "Guardar auditoría final Supabase": { + "main": [ + [ + { + "node": "Eliminar staging Nómina", + "type": "main", + "index": 0 + }, + { + "node": "Confirmación y regreso al formulario", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar auditoría inicial Supabase": { + "main": [ + [ + { + "node": "Registrar auditoría inicial 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": "66808436-7f89-414b-a47f-c2e804bb1dc7", + "meta": { + "instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70" + }, + "id": "cmtY0sJInCi21otP", + "tags": [] +} \ No newline at end of file diff --git a/Cruce de Seguridad Social - El Salvador (3).json b/Cruce de Seguridad Social - El Salvador (3).json new file mode 100644 index 0000000..efcb4e0 --- /dev/null +++ b/Cruce de Seguridad Social - El Salvador (3).json @@ -0,0 +1,1366 @@ +{ + "name": "Cruce de Seguridad Social - El Salvador", + "nodes": [ + { + "parameters": { + "formTitle": "Cruce de Seguridad Social · El Salvador", + "formDescription": "
GOMEZLEE MARKETING

Carga la nómina y la Planilla Única del mismo período. El proceso validará identidad, DUI, montos y presencia en BambooHR. El Excel con solo las diferencias se enviará a las siete personas autorizadas y quedará disponible en Google Drive con acceso de edición para cada una.

", + "formFields": { + "values": [ + { + "fieldLabel": "Mes a validar", + "fieldType": "dropdown", + "fieldName": "mes", + "defaultValue": "={{ $now.toFormat(\"MM\") }}", + "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", + "fieldName": "anio", + "defaultValue": "={{ $now.year }}", + "requiredField": true + }, + { + "fieldLabel": "Nómina", + "fieldType": "file", + "fieldName": "nomina", + "multipleFiles": false, + "acceptFileTypes": ".xlsx,.xlsm", + "requiredField": true + }, + { + "fieldLabel": "Planilla de Seguridad Social", + "fieldType": "file", + "fieldName": "planilla_seguridad_social", + "multipleFiles": false, + "acceptFileTypes": ".xlsx,.xlsm", + "requiredField": true + } + ] + }, + "responseMode": "lastNode", + "options": { + "appendAttribution": false, + "buttonLabel": "Procesar cruce", + "path": "ef422914-fb5b-4f33-8283-bc2d2ea9abfc", + "ignoreBots": true, + "useWorkflowTimezone": true, + "customCss": ":root{--font-family:Arial,Helvetica,sans-serif;--color-primary:#6CC24A;--color-primary-shade-1:#5AAF3E;--color-secondary:#4F758B;--color-card-background:#FFFFFF;--color-card-border:#D0D0D0;--border-radius-card:14px;--border-radius-input:8px;}\nbody{background:linear-gradient(180deg,#EEF6E8 0,#F4F7F9 240px);font-family:Arial,Helvetica,sans-serif;color:#4A4A4A;}\nform{box-shadow:0 18px 45px rgba(79,117,139,.13);}\nh1{color:#4F758B!important;letter-spacing:-.3px;}\nbutton[type=submit]{font-weight:700;letter-spacing:.2px;box-shadow:none;}\ninput:focus,select:focus{border-color:#6CC24A!important;box-shadow:0 0 0 3px rgba(108,194,74,.14)!important;}" + } + }, + "id": "562937d1-e0f5-4f04-a534-e6155728fe28", + "name": "Formulario de cruce", + "type": "n8n-nodes-base.formTrigger", + "typeVersion": 2.5, + "position": [ + 576, + 768 + ], + "webhookId": "ef422914-fb5b-4f33-8283-bc2d2ea9abfc" + }, + { + "parameters": { + "jsCode": "const formItem = $items('Formulario de cruce')[0] || {};\n const recipient = 'iaracena@gomezleemarketing.com, msoto@gomezleemarketing.com, iherrera@gomezleemarketing.com, mgomez@gomezleemarketing.com, vparamo@gomezleemarketing.com, asrodriguez@gomezleemarketing.com, jgomez@gomezleemarketing.com';\n const binaries = formItem.binary || {};\n const keys = Object.keys(binaries);\n function normalize(value) {\n return String(value || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase();\n }\n function score(key) {\n const file = binaries[key] || {};\n const text = normalize(key + ' ' + (file.fileName || ''));\n let value = 0;\n if (key === 'nomina') value += 100;\n if (text.includes('nomina')) value += 30;\n if (text.includes('payroll')) value += 15;\n if (text.includes('planilla')) value -= 50;\n return value;\n }\n const selectedKey = keys.slice().sort((a, b) => score(b) - score(a))[0];\n if (!selectedKey || score(selectedKey) <= 0) {\n throw new Error('No se encontró el archivo de nómina. Adjunta un .xlsx o .xlsm en el campo Nómina.');\n }\n const file = binaries[selectedKey];\n if (!/\\.(xlsx|xlsm)$/i.test(String(file.fileName || ''))) {\n throw new Error('La nómina debe estar en formato .xlsx o .xlsm.');\n }\n return [{\n json: {\n tipoArchivo: 'nomina',\n archivoNomina: file.fileName || 'nomina.xlsx',\n correoDestino: recipient,\n mes: formItem.json?.mes || '',\n anio: formItem.json?.anio || ''\n },\n binary: { data: file }\n }];" + }, + "id": "a165b03b-eb8e-45c1-8694-0450a28ff20e", + "name": "Preparar archivo Nómina", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 752, + 624 + ], + "onError": "continueErrorOutput" + }, + { + "parameters": { + "operation": "xlsx", + "options": { + "headerRow": true, + "includeEmptyCells": true, + "range": "A1:P100000", + "rawData": false, + "readAsString": false, + "sheetName": "Nomina" + } + }, + "id": "27f85e3c-bac3-4ad0-adf7-ed6468f13b1a", + "name": "Extraer Nómina · hoja Nomina", + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + 1088, + 512 + ], + "onError": "continueErrorOutput" + }, + { + "parameters": { + "jsCode": "const formItem = $items('Formulario de cruce')[0] || {};\n const binaries = formItem.binary || {};\n const keys = Object.keys(binaries);\n function normalize(value) {\n return String(value || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase();\n }\n function score(key) {\n const file = binaries[key] || {};\n const text = normalize(key + ' ' + (file.fileName || ''));\n let value = 0;\n if (key === 'planilla_seguridad_social') value += 100;\n if (text.includes('planilla')) value += 35;\n if (text.includes('seguridad')) value += 20;\n if (text.includes('nomina')) value -= 50;\n return value;\n }\n const selectedKey = keys.slice().sort((a, b) => score(b) - score(a))[0];\n if (!selectedKey || score(selectedKey) <= 0) {\n throw new Error('No se encontró la planilla de Seguridad Social. Adjunta un .xlsx o .xlsm en el campo correspondiente.');\n }\n const file = binaries[selectedKey];\n if (!/\\.(xlsx|xlsm)$/i.test(String(file.fileName || ''))) {\n throw new Error('La planilla de Seguridad Social debe estar en formato .xlsx o .xlsm.');\n }\n return [{\n json: {\n tipoArchivo: 'planilla',\n archivoPlanilla: file.fileName || 'planilla.xlsm'\n },\n binary: { data: file }\n }];" + }, + "id": "9e9f6a87-232a-4f0a-9352-aacd1e8b379a", + "name": "Preparar archivo Planilla", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1600, + 656 + ], + "onError": "continueErrorOutput" + }, + { + "parameters": { + "operation": "xlsx", + "options": { + "headerRow": true, + "includeEmptyCells": true, + "range": "A4:V100000", + "rawData": false, + "readAsString": false, + "sheetName": "PLANILLA_UNICA" + } + }, + "id": "c6b2e409-97da-4abf-b525-c9198bf48579", + "name": "Extraer Planilla", + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + 1856, + 656 + ], + "onError": "continueErrorOutput" + }, + { + "parameters": { + "jsCode": "const payrollRows = $items('Extraer Nómina').length;\n const planRows = $items('Extraer Planilla').length;\n if (!payrollRows) {\n throw new Error('No se pudo leer una hoja de nómina válida con registros legibles.');\n }\n if (!planRows) {\n throw new Error('La hoja \"PLANILLA_UNICA\" no contiene registros legibles desde la fila 4.');\n }\n return [{ json: { payrollRows, planRows, listoParaBamboo: true } }];" + }, + "id": "ceabea56-3cb4-4093-8ea4-0f847d44aafe", + "name": "Validar lecturas", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2208, + 800 + ], + "onError": "continueErrorOutput" + }, + { + "parameters": { + "method": "POST", + "url": "https://glm.bamboohr.com/api/v1/reports/custom?format=JSON&onlyCurrent=false", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": { + "title": "Cruce de Seguridad Social El Salvador - Directorio completo", + "fields": [ + "firstName", + "middleName", + "lastName", + "displayName", + "department", + "division", + "location", + "customPosicion-Cliente", + "hireDate", + "originalHireDate", + "status", + "employeeNumber" + ] + }, + "options": { + "response": { + "response": { + "responseFormat": "json" + } + }, + "timeout": 300000 + } + }, + "id": "8e202b5d-0f0b-4228-acc7-43f06c0d79e0", + "name": "Obtener reporte completo BambooHR", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 2464, + 800 + ], + "executeOnce": true, + "retryOnFail": true, + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + }, + "onError": "continueErrorOutput" + }, + { + "parameters": { + "jsCode": "const payrollSource = $items('Extraer Nómina').map(item => item.json || {});\n const planSource = $items('Extraer Planilla').map(item => item.json || {});\n const formItem = $items('Formulario de cruce')[0] || {};\n const form = formItem.json || {};\n const bambooInput = $input.all();\n\n function clean(value) {\n return String(value ?? '').replace(/\\s+/g, ' ').trim();\n }\n function stripAccents(value) {\n return String(value || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '');\n }\n function normalizeHeader(value) {\n return stripAccents(clean(value)).toUpperCase().replace(/[^A-Z0-9]+/g, ' ').trim();\n }\n function normalizeName(value) {\n return stripAccents(clean(value)).toUpperCase().replace(/[^A-Z0-9 ]+/g, ' ').replace(/\\s+/g, ' ').trim();\n }\n const STOP = new Set(['DE', 'DEL', 'LA', 'LAS', 'LOS', 'Y', 'DA', 'DAS', 'DO', 'VDA']);\n function nameTokens(value) {\n return normalizeName(value).split(' ').filter(token => token.length > 1 && !STOP.has(token));\n }\n function canonicalName(value) {\n return nameTokens(value).slice().sort().join(' ');\n }\n function nameScore(a, b) {\n const A = [...new Set(nameTokens(a))];\n const B = [...new Set(nameTokens(b))];\n if (!A.length || !B.length) return { score: 0, shared: 0 };\n if (A.slice().sort().join(' ') === B.slice().sort().join(' ')) return { score: 1, shared: A.length };\n const setB = new Set(B);\n const shared = A.filter(token => setB.has(token)).length;\n if (shared < 2) return { score: 0, shared };\n const coverage = shared / Math.min(A.length, B.length);\n const dice = (2 * shared) / (A.length + B.length);\n return { score: 0.7 * coverage + 0.3 * dice, shared };\n }\n function parseNumber(value) {\n if (typeof value === 'number') return Number.isFinite(value) ? value : 0;\n let text = clean(value).replace(/[^\\d,.\\-]/g, '');\n if (!text) return 0;\n const comma = text.lastIndexOf(',');\n const dot = text.lastIndexOf('.');\n if (comma > dot) text = text.replace(/\\./g, '').replace(',', '.');\n else text = text.replace(/,/g, '');\n const number = Number(text);\n return Number.isFinite(number) ? number : 0;\n }\n function valueAt(row, aliases, fallbackIndex) {\n const entries = Object.entries(row || {});\n const wanted = aliases.map(normalizeHeader);\n for (const [key, value] of entries) {\n const normalized = normalizeHeader(key);\n if (wanted.includes(normalized)) return value;\n }\n return entries[fallbackIndex]?.[1];\n }\n function normalizeDui(value) {\n let digits = clean(value).replace(/\\D/g, '');\n if (digits.length === 8) digits = digits.padStart(9, '0');\n if (digits.length !== 9) return '';\n return digits.slice(0, 8) + '-' + digits.slice(8);\n }\n function chooseName(values) {\n const names = [...new Set((values || []).map(clean).filter(Boolean))];\n names.sort((a, b) => nameTokens(b).length - nameTokens(a).length || b.length - a.length);\n return names[0] || '';\n }\n function round2(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n }\n function uniqueJoin(values) {\n return [...new Set((values || []).map(clean).filter(Boolean))].join(' | ');\n }\n function isTotalName(value) {\n return /^TOTAL(?:\\s|$)/i.test(clean(value));\n }\n\n const monthNumber = String(form.mes || '').padStart(2, '0');\n const yearNumber = Number(form.anio);\n if (!/^(0[1-9]|1[0-2])$/.test(monthNumber)) {\n throw new Error('Selecciona un mes válido entre 01 y 12.');\n }\n if (!Number.isInteger(yearNumber) || yearNumber < 2020 || yearNumber > 2100) {\n throw new Error('Ingresa un año válido.');\n }\n const monthNames = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];\n const periodLabel = monthNames[Number(monthNumber) - 1] + ' ' + yearNumber;\n const periodFile = yearNumber + '-' + monthNumber;\n\n const payrollRecords = [];\n for (let index = 0; index < payrollSource.length; index++) {\n const row = payrollSource[index];\n const rawDui = valueAt(row, ['DUI'], 3);\n const name = clean(valueAt(row, ['COLABORADOR', 'EMPLEADO', 'NOMBRE'], 4));\n const project = clean(valueAt(row, ['PROYECTO'], 2));\n const amount = parseNumber(valueAt(row, ['SUB TOTAL PERCIBIDO', 'TOTAL PERCIBIDO'], 15));\n const dui = normalizeDui(rawDui);\n if ((!name && !dui) || isTotalName(name)) continue;\n payrollRecords.push({\n sourceIndex: index + 2,\n dui,\n rawDui: clean(rawDui),\n name,\n project,\n amount\n });\n }\n\n const planRecords = [];\n for (let index = 0; index < planSource.length; index++) {\n const row = planSource[index];\n const rawDui = valueAt(row, ['NUMERO DE DOCUMENTO AFILIADO', 'DUI'], 4);\n const nameParts = [\n valueAt(row, ['PRIMER NOMBRE'], 8),\n valueAt(row, ['SEGUNDO NOMBRE'], 9),\n valueAt(row, ['PRIMER APELLIDO'], 10),\n valueAt(row, ['SEGUNDO APELLIDO'], 11),\n valueAt(row, ['APELLIDO DE CASADA'], 12)\n ].map(clean).filter(Boolean);\n const name = clean(nameParts.join(' '));\n const salary = parseNumber(valueAt(row, ['SALARIO'], 13));\n const additional = parseNumber(valueAt(row, ['PAGO ADICIONAL'], 14));\n const vacation = parseNumber(valueAt(row, ['MONTO DE VACACION', 'MONTO DE VACACIONES'], 15));\n const observations = uniqueJoin([\n valueAt(row, ['CODIGO DE OBSERVACION 1'], 19),\n valueAt(row, ['CODIGO DE OBSERVACION 2'], 20)\n ]);\n const dui = normalizeDui(rawDui);\n if ((!name && !dui) || isTotalName(name)) continue;\n planRecords.push({\n sourceIndex: index + 5,\n dui,\n rawDui: clean(rawDui),\n name,\n amount: salary + additional + vacation,\n salary,\n additional,\n vacation,\n observations\n });\n }\n\n if (!payrollRecords.length) {\n throw new Error('No se encontraron empleados en la hoja \"Nomina\". Se esperaba el encabezado en la fila 1 y datos desde la fila 2.');\n }\n if (!planRecords.length) {\n throw new Error('No se encontraron empleados en \"PLANILLA_UNICA\". Se esperaba el encabezado en la fila 4 y datos desde la fila 5.');\n }\n if (!payrollRecords.some(record => record.dui)) {\n throw new Error('No se pudo leer ningún DUI válido en la columna D de la nómina.');\n }\n if (!planRecords.some(record => record.dui)) {\n throw new Error('No se pudo leer ningún DUI válido en la columna E de la planilla.');\n }\n\n function aggregate(records, source) {\n const groups = new Map();\n for (const record of records) {\n if (!record.dui) continue;\n if (!groups.has(record.dui)) {\n groups.set(record.dui, {\n dui: record.dui,\n source,\n amount: 0,\n names: [],\n projects: [],\n observations: [],\n rows: []\n });\n }\n const group = groups.get(record.dui);\n group.amount += Number(record.amount) || 0;\n group.names.push(record.name);\n if (record.project) group.projects.push(record.project);\n if (record.observations) group.observations.push(record.observations);\n group.rows.push(record.sourceIndex);\n }\n for (const group of groups.values()) {\n group.name = chooseName(group.names);\n group.project = uniqueJoin(group.projects);\n group.observation = uniqueJoin(group.observations);\n group.nameVariants = [...new Set(group.names.map(normalizeName).filter(Boolean))];\n }\n return groups;\n }\n\n const payrollGroups = aggregate(payrollRecords, 'Nómina');\n const planGroups = aggregate(planRecords, 'Planilla');\n const payrollMissingDui = payrollRecords.filter(record => !record.dui);\n const planMissingDui = planRecords.filter(record => !record.dui);\n const findings = [];\n\n function addFinding(data) {\n findings.push({\n tipo: data.tipo || '',\n severidad: data.severidad || 'Media',\n duiNomina: data.duiNomina || '',\n duiPlanilla: data.duiPlanilla || '',\n nombreNomina: data.nombreNomina || '',\n nombrePlanilla: data.nombrePlanilla || '',\n montoNomina: data.montoNomina === '' || data.montoNomina == null ? '' : Number(data.montoNomina),\n montoPlanilla: data.montoPlanilla === '' || data.montoPlanilla == null ? '' : Number(data.montoPlanilla),\n diferencia: data.diferencia === '' || data.diferencia == null ? '' : Number(data.diferencia),\n accion: data.accion || ''\n });\n }\n\n for (const record of payrollMissingDui) {\n addFinding({\n tipo: 'DUI faltante o inválido en nómina',\n severidad: 'Alta',\n nombreNomina: record.name,\n montoNomina: record.amount,\n accion: 'Completar o corregir el DUI en la nómina antes de presentar la planilla.'\n });\n }\n for (const record of planMissingDui) {\n addFinding({\n tipo: 'DUI faltante o inválido en planilla',\n severidad: 'Alta',\n nombrePlanilla: record.name,\n montoPlanilla: record.amount,\n accion: 'Completar o corregir el DUI en la planilla de Seguridad Social.'\n });\n }\n\n for (const [dui, group] of payrollGroups) {\n if (group.nameVariants.length > 1) {\n addFinding({\n tipo: 'Un DUI tiene varios nombres en nómina',\n severidad: 'Alta',\n duiNomina: dui,\n nombreNomina: uniqueJoin(group.names),\n montoNomina: group.amount,\n accion: 'Validar que el DUI no esté asociado a más de una persona.'\n });\n }\n }\n for (const [dui, group] of planGroups) {\n if (group.nameVariants.length > 1) {\n addFinding({\n tipo: 'Un DUI tiene varios nombres en planilla',\n severidad: 'Alta',\n duiPlanilla: dui,\n nombrePlanilla: uniqueJoin(group.names),\n montoPlanilla: group.amount,\n accion: 'Validar las filas duplicadas y la identidad del afiliado.'\n });\n }\n }\n\n function flagSameNameMultipleDuis(groups, sourceLabel) {\n const byName = new Map();\n for (const group of groups.values()) {\n const key = canonicalName(group.name);\n if (!key) continue;\n if (!byName.has(key)) byName.set(key, []);\n byName.get(key).push(group);\n }\n for (const list of byName.values()) {\n const duis = [...new Set(list.map(group => group.dui))];\n if (duis.length <= 1) continue;\n addFinding({\n tipo: 'Misma persona con más de un DUI en ' + sourceLabel.toLowerCase(),\n severidad: 'Alta',\n duiNomina: sourceLabel === 'Nómina' ? duis.join(' | ') : '',\n duiPlanilla: sourceLabel === 'Planilla' ? duis.join(' | ') : '',\n nombreNomina: sourceLabel === 'Nómina' ? list[0].name : '',\n nombrePlanilla: sourceLabel === 'Planilla' ? list[0].name : '',\n montoNomina: sourceLabel === 'Nómina' ? list.reduce((sum, group) => sum + group.amount, 0) : '',\n montoPlanilla: sourceLabel === 'Planilla' ? list.reduce((sum, group) => sum + group.amount, 0) : '',\n accion: 'Confirmar el DUI correcto y unificar el registro.'\n });\n }\n }\n flagSameNameMultipleDuis(payrollGroups, 'Nómina');\n flagSameNameMultipleDuis(planGroups, 'Planilla');\n\n const commonDuis = [...payrollGroups.keys()].filter(dui => planGroups.has(dui));\n for (const dui of commonDuis) {\n const payroll = payrollGroups.get(dui);\n const plan = planGroups.get(dui);\n if (normalizeName(payroll.name) !== normalizeName(plan.name)) {\n addFinding({\n tipo: 'Nombre no idéntico con el mismo DUI',\n severidad: 'Media',\n duiNomina: dui,\n duiPlanilla: dui,\n nombreNomina: payroll.name,\n nombrePlanilla: plan.name,\n montoNomina: payroll.amount,\n montoPlanilla: plan.amount,\n accion: 'Verificar la escritura del nombre; el DUI coincide en ambas fuentes.'\n });\n }\n const delta = payroll.amount - plan.amount;\n if (Math.abs(delta) > 0.0000001) {\n const isSubcent = Math.abs(round2(payroll.amount) - round2(plan.amount)) <= 0.0000001;\n addFinding({\n tipo: isSubcent ? 'Diferencia subcentavo' : 'Diferencia de monto',\n severidad: isSubcent ? 'Informativa' : 'Alta',\n duiNomina: dui,\n duiPlanilla: dui,\n nombreNomina: payroll.name,\n nombrePlanilla: plan.name,\n montoNomina: payroll.amount,\n montoPlanilla: plan.amount,\n diferencia: delta,\n accion: isSubcent\n ? 'Los montos coinciden a dos decimales; conservar como control de precisión.'\n : 'Revisar salario, pago adicional, vacaciones, subsidios y acumulación de filas.'\n });\n }\n }\n\n const onlyPayroll = [...payrollGroups.values()].filter(group => !planGroups.has(group.dui));\n const onlyPlan = [...planGroups.values()].filter(group => !payrollGroups.has(group.dui));\n const usedPlan = new Set();\n\n function bestUnmatchedName(targetName, candidates, excludedIndexes) {\n const scored = candidates.map((candidate, index) => {\n const scoreData = nameScore(targetName, candidate.name);\n return { candidate, index, score: scoreData.score, shared: scoreData.shared };\n }).filter(result => !excludedIndexes.has(result.index) && result.score > 0)\n .sort((a, b) => b.score - a.score);\n const best = scored[0] || null;\n const second = scored[1] || null;\n if (!best) return { accepted: false, best: null };\n const margin = second ? best.score - second.score : best.score;\n const exact = canonicalName(targetName) === canonicalName(best.candidate.name);\n const accepted = exact || (best.shared >= 2 && best.score >= 0.88 && (margin >= 0.08 || best.score >= 0.98));\n return { accepted, best, second, margin };\n }\n\n for (const payroll of onlyPayroll) {\n const match = bestUnmatchedName(payroll.name, onlyPlan, usedPlan);\n if (match.accepted) {\n usedPlan.add(match.best.index);\n const plan = match.best.candidate;\n addFinding({\n tipo: 'Posible misma persona con DUI diferente',\n severidad: 'Alta',\n duiNomina: payroll.dui,\n duiPlanilla: plan.dui,\n nombreNomina: payroll.name,\n nombrePlanilla: plan.name,\n montoNomina: payroll.amount,\n montoPlanilla: plan.amount,\n accion: 'Confirmar la identidad y corregir el DUI en la fuente que corresponda.'\n });\n } else {\n addFinding({\n tipo: 'Persona en nómina que no está en planilla',\n severidad: 'Alta',\n duiNomina: payroll.dui,\n nombreNomina: payroll.name,\n montoNomina: payroll.amount,\n accion: 'Validar inclusión en la planilla de Seguridad Social.'\n });\n }\n }\n onlyPlan.forEach((plan, index) => {\n if (usedPlan.has(index)) return;\n addFinding({\n tipo: 'Persona en planilla que no está en nómina',\n severidad: 'Alta',\n duiPlanilla: plan.dui,\n nombrePlanilla: plan.name,\n montoPlanilla: plan.amount,\n accion: 'Validar si corresponde excluirla o incorporarla a la nómina.'\n });\n });\n\n function collectBambooPayload(payload, rows, totals) {\n if (payload == null) return;\n if (Array.isArray(payload)) {\n for (const value of payload) collectBambooPayload(value, rows, totals);\n return;\n }\n if (typeof payload !== 'object') return;\n if (Number(payload?.meta?.total) > 0) totals.push(Number(payload.meta.total));\n if (payload.body && typeof payload.body === 'object') {\n collectBambooPayload(payload.body, rows, totals);\n return;\n }\n if (Array.isArray(payload.data)) {\n rows.push(...payload.data);\n return;\n }\n if (Array.isArray(payload.employees)) {\n rows.push(...payload.employees);\n return;\n }\n if (payload.fullName5 || payload.displayName || payload.firstName || payload.lastName) rows.push(payload);\n }\n\n const bambooRaw = [];\n const bambooTotals = [];\n for (const item of bambooInput) collectBambooPayload(item.json, bambooRaw, bambooTotals);\n\n function uniqueNames(values) {\n return [...new Set(values.map(clean).filter(Boolean))];\n }\n\n function normalizeBambooDui(value) {\n const digits = clean(value).replace(/\\D/g, '');\n return digits.length === 9 ? digits.slice(0, 8) + '-' + digits.slice(8) : '';\n }\n\n function isElSalvadorEmployee(row) {\n return /EL\\s*SALVADOR|SALVADOR/i.test([\n row.location,\n row.country,\n row.division,\n row.department\n ].map(clean).join(' '));\n }\n\n const bambooUnique = new Map();\n for (let index = 0; index < bambooRaw.length; index++) {\n const row = bambooRaw[index] || {};\n const constructedName = [row.firstName, row.middleName, row.lastName].map(clean).filter(Boolean).join(' ');\n const aliases = uniqueNames([\n row.fullName5,\n row.fullName1,\n row.fullName2,\n row.fullName3,\n row.fullName4,\n constructedName,\n [row.firstName, row.lastName].map(clean).filter(Boolean).join(' '),\n [row.preferredName, row.lastName].map(clean).filter(Boolean).join(' '),\n row.displayName\n ]);\n const name = clean(row.displayName || row.fullName5 || row.fullName1 || constructedName);\n if (!name || !aliases.length) continue;\n const identity = clean(row.id || row.employeeId);\n const fallbackIdentity = clean(row.employeeNumber) + '|' + canonicalName(name) + '|' + clean(row.hireDate || row.originalHireDate);\n const key = identity || fallbackIdentity || 'fila-' + index;\n if (!bambooUnique.has(key)) {\n bambooUnique.set(key, {\n id: identity || key,\n employeeId: clean(row.employeeId || row.id),\n employeeNumber: clean(row.employeeNumber),\n dui: normalizeBambooDui(row.employeeNumber),\n name,\n aliases,\n canonicalAliases: [...new Set(aliases.map(canonicalName).filter(Boolean))],\n status: clean(row.status || row.employmentStatus || row.employmentHistoryStatus),\n hireDate: clean(row.hireDate || row.originalHireDate).slice(0, 10),\n terminationDate: clean(row.terminationDate).slice(0, 10),\n location: clean(row.location),\n country: clean(row.country),\n division: clean(row.division),\n department: clean(row.department),\n email: clean(row.workEmail),\n isElSalvador: isElSalvadorEmployee(row)\n });\n }\n }\n\n const bambooEmployees = [...bambooUnique.values()];\n const reportedTotal = bambooTotals.length ? Math.max(...bambooTotals) : bambooEmployees.length;\n if (!bambooEmployees.length) {\n throw new Error('BambooHR no devolvió empleados legibles. Revisa la credencial y los permisos del reporte completo.');\n }\n if (reportedTotal > bambooEmployees.length) {\n throw new Error('BambooHR devolvió ' + bambooEmployees.length + ' empleados únicos de ' + reportedTotal + '. La consulta quedó incompleta.');\n }\n\n function phoneticToken(value) {\n return normalizeName(value)\n .replace(/LL/g, 'Y')\n .replace(/QU/g, 'K')\n .replace(/PH/g, 'F')\n .replace(/C(?=[EI])/g, 'S')\n .replace(/G(?=[EI])/g, 'J')\n .replace(/Z/g, 'S')\n .replace(/V/g, 'B')\n .replace(/H/g, '')\n .replace(/Q/g, 'K')\n .replace(/Y/g, 'I')\n .replace(/(.)\\1+/g, '$1');\n }\n\n function damerauLevenshtein(left, right) {\n const a = String(left || '');\n const b = String(right || '');\n const rows = a.length + 1;\n const columns = b.length + 1;\n const matrix = Array.from({ length: rows }, () => Array(columns).fill(0));\n for (let row = 0; row < rows; row++) matrix[row][0] = row;\n for (let column = 0; column < columns; column++) matrix[0][column] = column;\n for (let row = 1; row < rows; row++) {\n for (let column = 1; column < columns; column++) {\n const cost = a[row - 1] === b[column - 1] ? 0 : 1;\n matrix[row][column] = Math.min(\n matrix[row - 1][column] + 1,\n matrix[row][column - 1] + 1,\n matrix[row - 1][column - 1] + cost\n );\n if (\n row > 1 &&\n column > 1 &&\n a[row - 1] === b[column - 2] &&\n a[row - 2] === b[column - 1]\n ) {\n matrix[row][column] = Math.min(\n matrix[row][column],\n matrix[row - 2][column - 2] + cost\n );\n }\n }\n }\n return matrix[a.length][b.length];\n }\n\n function tokenSimilarity(left, right) {\n const rawLeft = normalizeName(left);\n const rawRight = normalizeName(right);\n const phoneticLeft = phoneticToken(left);\n const phoneticRight = phoneticToken(right);\n const rawScore = 1 - damerauLevenshtein(rawLeft, rawRight) / Math.max(rawLeft.length, rawRight.length, 1);\n const phoneticScore = 1 - damerauLevenshtein(phoneticLeft, phoneticRight) / Math.max(phoneticLeft.length, phoneticRight.length, 1);\n return Math.max(rawScore, phoneticScore);\n }\n\n function compareBambooNames(personName, bambooName) {\n const personTokens = nameTokens(personName);\n const bambooTokens = nameTokens(bambooName);\n if (!personTokens.length || !bambooTokens.length) {\n return {\n score: 0,\n coverage: 0,\n strong: 0,\n anchors: 0,\n exact: 0,\n weak: personTokens.length,\n targetCount: personTokens.length,\n candidateCount: bambooTokens.length,\n extra: bambooTokens.length,\n pairs: []\n };\n }\n\n const choices = [];\n for (let personIndex = 0; personIndex < personTokens.length; personIndex++) {\n for (let bambooIndex = 0; bambooIndex < bambooTokens.length; bambooIndex++) {\n choices.push({\n personIndex,\n bambooIndex,\n similarity: tokenSimilarity(personTokens[personIndex], bambooTokens[bambooIndex])\n });\n }\n }\n choices.sort((left, right) => right.similarity - left.similarity);\n\n const usedPerson = new Set();\n const usedBamboo = new Set();\n const pairs = [];\n for (const choice of choices) {\n if (usedPerson.has(choice.personIndex) || usedBamboo.has(choice.bambooIndex)) continue;\n usedPerson.add(choice.personIndex);\n usedBamboo.add(choice.bambooIndex);\n pairs.push({\n person: personTokens[choice.personIndex],\n bamboo: bambooTokens[choice.bambooIndex],\n similarity: choice.similarity\n });\n }\n for (let personIndex = 0; personIndex < personTokens.length; personIndex++) {\n if (!usedPerson.has(personIndex)) {\n pairs.push({ person: personTokens[personIndex], bamboo: '', similarity: 0 });\n }\n }\n\n const totalWeight = personTokens.reduce((sum, token) => sum + Math.min(Math.max(token.length, 3), 10), 0) || 1;\n const coverage = pairs.reduce((sum, pair) => {\n const weight = Math.min(Math.max(pair.person.length, 3), 10);\n return sum + pair.similarity * weight;\n }, 0) / totalWeight;\n const extra = Math.max(0, bambooTokens.length - usedBamboo.size);\n const score = Math.max(0, coverage - Math.min(0.03, extra * 0.008));\n\n return {\n score,\n coverage,\n strong: pairs.filter(pair => pair.similarity >= 0.78).length,\n anchors: pairs.filter(pair => pair.similarity >= 0.92).length,\n exact: pairs.filter(pair => pair.similarity >= 0.999).length,\n weak: pairs.filter(pair => pair.similarity < 0.70).length,\n targetCount: personTokens.length,\n candidateCount: bambooTokens.length,\n extra,\n pairs\n };\n }\n\n const exactBambooByDui = new Map();\n const exactBambooByName = new Map();\n const bambooTokenIndex = new Map();\n const bambooPhoneticIndex = new Map();\n const bambooElSalvador = bambooEmployees.filter(employee => employee.isElSalvador);\n\n for (const employee of bambooEmployees) {\n if (employee.dui) {\n if (!exactBambooByDui.has(employee.dui)) exactBambooByDui.set(employee.dui, new Set());\n exactBambooByDui.get(employee.dui).add(employee);\n }\n for (const canonical of employee.canonicalAliases) {\n if (!exactBambooByName.has(canonical)) exactBambooByName.set(canonical, new Set());\n exactBambooByName.get(canonical).add(employee);\n }\n for (const alias of employee.aliases) {\n for (const token of new Set(nameTokens(alias))) {\n if (!bambooTokenIndex.has(token)) bambooTokenIndex.set(token, new Set());\n bambooTokenIndex.get(token).add(employee);\n const phonetic = phoneticToken(token);\n if (!bambooPhoneticIndex.has(phonetic)) bambooPhoneticIndex.set(phonetic, new Set());\n bambooPhoneticIndex.get(phonetic).add(employee);\n }\n }\n }\n\n function bambooRank(employee) {\n const status = clean(employee.status);\n const active = /^(ACTIVE|ACTIVO)$/i.test(status) ? 2 : 0;\n return (employee.isElSalvador ? 4 : 0) + active;\n }\n\n function bestEmployeeNameScore(employee, personAliases) {\n let best = null;\n for (const personAlias of personAliases) {\n for (const bambooAlias of employee.aliases) {\n const comparison = compareBambooNames(personAlias, bambooAlias);\n if (!best || comparison.score > best.comparison.score) {\n best = { comparison, personAlias, bambooAlias };\n }\n }\n }\n return best || {\n comparison: compareBambooNames('', ''),\n personAlias: '',\n bambooAlias: ''\n };\n }\n\n function scoreEmployees(employees, personAliases) {\n return [...employees].map(employee => {\n const result = bestEmployeeNameScore(employee, personAliases);\n return {\n employee,\n comparison: result.comparison,\n personAlias: result.personAlias,\n bambooAlias: result.bambooAlias,\n rank: bambooRank(employee)\n };\n }).sort((left, right) =>\n right.comparison.score - left.comparison.score ||\n right.rank - left.rank\n );\n }\n\n function exactDuiNameCompatible(result) {\n const comparison = result.comparison;\n return (\n canonicalName(result.personAlias) === canonicalName(result.bambooAlias) ||\n (comparison.strong >= Math.min(2, comparison.targetCount) && comparison.score >= 0.62) ||\n comparison.score >= 0.78\n );\n }\n\n function identityIssueFor(person, matchedEmployee, duiOwner) {\n const matchedDui = clean(matchedEmployee?.dui);\n if (duiOwner && matchedEmployee && duiOwner.id !== matchedEmployee.id) {\n return {\n type: 'DUI_BAMBOO_OTRA_PERSONA',\n duiOwner,\n matchedEmployee,\n sourceDui: person.dui\n };\n }\n if (matchedEmployee && matchedDui && matchedDui !== person.dui) {\n return {\n type: 'DUI_BAMBOO_DIFERENTE',\n duiOwner: null,\n matchedEmployee,\n sourceDui: person.dui\n };\n }\n return null;\n }\n\n function acceptedMatch(methodCode, method, employee, score, margin, result, identityIssue) {\n return {\n accepted: true,\n review: false,\n methodCode,\n method,\n employee,\n candidate: employee,\n score,\n margin,\n sourceAlias: result?.personAlias || '',\n matchedAlias: result?.bambooAlias || '',\n identityIssue: identityIssue || null\n };\n }\n\n function matchBambooPerson(person) {\n const personAliases = uniqueNames(person.aliases || []);\n const exactDuiCandidates = [...(exactBambooByDui.get(person.dui) || [])];\n let duiOwner = null;\n\n if (exactDuiCandidates.length) {\n const scoredDui = scoreEmployees(exactDuiCandidates, personAliases);\n const compatibleDui = scoredDui.filter(exactDuiNameCompatible);\n const compatibleElSalvador = compatibleDui.filter(result => result.employee.isElSalvador);\n const compatiblePool = compatibleElSalvador.length ? compatibleElSalvador : compatibleDui;\n if (compatiblePool.length === 1) {\n const best = compatiblePool[0];\n return acceptedMatch(\n 'DUI_EXACTO',\n 'DUI exacto',\n best.employee,\n 1,\n 1,\n best,\n null\n );\n }\n if (compatiblePool.length > 1) {\n const best = compatiblePool[0];\n const second = compatiblePool[1];\n const margin = best.comparison.score - second.comparison.score;\n if (best.comparison.score >= 0.90 && margin >= 0.10) {\n return acceptedMatch(\n 'DUI_EXACTO_DESAMBIGUADO',\n 'DUI exacto desambiguado por nombre',\n best.employee,\n 1,\n margin,\n best,\n null\n );\n }\n }\n duiOwner = scoredDui[0]?.employee || exactDuiCandidates[0];\n }\n\n const exactNameEmployees = new Set();\n for (const alias of personAliases) {\n for (const employee of exactBambooByName.get(canonicalName(alias)) || []) {\n exactNameEmployees.add(employee);\n }\n }\n if (exactNameEmployees.size) {\n const exactList = [...exactNameEmployees];\n const exactElSalvador = exactList.filter(employee => employee.isElSalvador);\n const exactPool = exactElSalvador.length ? exactElSalvador : exactList;\n if (exactPool.length === 1) {\n const employee = exactPool[0];\n const result = bestEmployeeNameScore(employee, personAliases);\n return acceptedMatch(\n 'NOMBRE_EXACTO_UNICO',\n duiOwner ? 'Nombre exacto; revisar DUI en BambooHR' : 'Nombre exacto único',\n employee,\n 1,\n 1,\n result,\n identityIssueFor(person, employee, duiOwner)\n );\n }\n }\n\n const candidates = new Set(bambooElSalvador);\n for (const alias of personAliases) {\n for (const token of new Set(nameTokens(alias))) {\n const exactTokenMatches = bambooTokenIndex.get(token);\n if (exactTokenMatches && exactTokenMatches.size <= 100) {\n for (const employee of exactTokenMatches) candidates.add(employee);\n }\n const phoneticMatches = bambooPhoneticIndex.get(phoneticToken(token));\n if (phoneticMatches && phoneticMatches.size <= 100) {\n for (const employee of phoneticMatches) candidates.add(employee);\n }\n }\n }\n\n const scored = scoreEmployees(candidates, personAliases);\n const best = scored[0] || null;\n const second = scored[1] || null;\n if (!best) {\n return {\n accepted: false,\n review: Boolean(duiOwner),\n methodCode: duiOwner ? 'DUI_NOMBRE_CONFLICTO' : 'SIN_COINCIDENCIA',\n method: duiOwner ? 'DUI asociado a otro nombre' : 'Sin coincidencia',\n employee: null,\n candidate: duiOwner,\n score: 0,\n margin: 0,\n identityIssue: identityIssueFor(person, null, duiOwner)\n };\n }\n\n const comparison = best.comparison;\n const margin = second ? comparison.score - second.comparison.score : 1;\n const longStrong = (\n comparison.targetCount >= 3 &&\n comparison.weak === 0 &&\n comparison.strong >= 3 &&\n comparison.anchors >= 2 &&\n comparison.score >= 0.92 &&\n margin >= 0.10 &&\n (best.employee.isElSalvador || (comparison.score >= 0.97 && comparison.exact >= 3))\n );\n const shortStrong = (\n comparison.targetCount === 2 &&\n comparison.weak === 0 &&\n comparison.strong === 2 &&\n comparison.anchors === 2 &&\n comparison.exact >= 1 &&\n comparison.score >= 0.96 &&\n margin >= 0.15 &&\n best.employee.isElSalvador\n );\n\n if (longStrong || shortStrong) {\n return acceptedMatch(\n 'NOMBRE_TIPOGRAFICO_UNICO',\n duiOwner ? 'Nombre tipográfico; revisar DUI en BambooHR' : 'Nombre tipográfico único',\n best.employee,\n comparison.score,\n margin,\n best,\n identityIssueFor(person, best.employee, duiOwner)\n );\n }\n\n const review = Boolean(duiOwner) || (\n comparison.score >= 0.82 &&\n comparison.strong >= Math.min(2, comparison.targetCount)\n );\n return {\n accepted: false,\n review,\n methodCode: duiOwner ? 'DUI_NOMBRE_CONFLICTO' : (review ? 'COINCIDENCIA_AMBIGUA' : 'SIN_COINCIDENCIA'),\n method: duiOwner ? 'DUI asociado a otro nombre' : (review ? 'Coincidencia ambigua' : 'Sin coincidencia'),\n employee: null,\n candidate: duiOwner || best.employee,\n score: comparison.score,\n margin,\n sourceAlias: best.personAlias,\n matchedAlias: best.bambooAlias,\n identityIssue: identityIssueFor(person, null, duiOwner)\n };\n }\n\n function sourcePeople(groups, missingRecords, source) {\n const people = [...groups.values()].map(group => ({\n source,\n dui: group.dui,\n name: group.name,\n project: group.project || '',\n amount: group.amount\n }));\n for (const record of missingRecords) {\n people.push({\n source,\n dui: '',\n name: record.name,\n project: record.project || '',\n amount: record.amount\n });\n }\n return people;\n }\n\n const payrollPeople = sourcePeople(payrollGroups, payrollMissingDui, 'Nómina');\n const planPeople = sourcePeople(planGroups, planMissingDui, 'Planilla');\n const unifiedPeople = new Map();\n\n function addUnifiedPerson(person, index) {\n const key = person.dui\n ? 'DUI:' + person.dui\n : person.source + ':SIN_DUI:' + index + ':' + canonicalName(person.name);\n person.matchKey = key;\n if (!unifiedPeople.has(key)) {\n unifiedPeople.set(key, {\n key,\n dui: person.dui,\n aliases: [],\n payrollNames: [],\n planNames: [],\n payrollAmounts: [],\n planAmounts: []\n });\n }\n const unified = unifiedPeople.get(key);\n unified.aliases = uniqueNames([...unified.aliases, person.name]);\n if (person.source === 'Nómina') {\n unified.payrollNames = uniqueNames([...unified.payrollNames, person.name]);\n unified.payrollAmounts.push(person.amount);\n } else {\n unified.planNames = uniqueNames([...unified.planNames, person.name]);\n unified.planAmounts.push(person.amount);\n }\n }\n\n payrollPeople.forEach(addUnifiedPerson);\n planPeople.forEach(addUnifiedPerson);\n\n const matchByKey = new Map();\n for (const person of unifiedPeople.values()) {\n matchByKey.set(person.key, matchBambooPerson(person));\n }\n\n const bambooUsage = new Map();\n for (const [key, match] of matchByKey) {\n if (!match.accepted || !match.employee?.id) continue;\n if (!bambooUsage.has(match.employee.id)) bambooUsage.set(match.employee.id, []);\n bambooUsage.get(match.employee.id).push({ key, match });\n }\n for (const uses of bambooUsage.values()) {\n if (uses.length <= 1) continue;\n const exactDuiUses = uses.filter(use => /^DUI_EXACTO/.test(use.match.methodCode));\n if (exactDuiUses.length === 1) {\n for (const use of uses) {\n if (use === exactDuiUses[0]) continue;\n use.match.accepted = false;\n use.match.review = true;\n use.match.methodCode = 'REGISTRO_BAMBOO_REUTILIZADO';\n use.match.method = 'Coincidencia ambigua: registro BambooHR ya asignado a otro DUI';\n }\n } else {\n for (const use of uses) {\n use.match.accepted = false;\n use.match.review = true;\n use.match.methodCode = 'REGISTRO_BAMBOO_REUTILIZADO';\n use.match.method = 'Coincidencia ambigua: registro BambooHR asociado a más de un DUI';\n }\n }\n }\n\n const bambooMatchStats = {\n duiExacto: 0,\n nombreExacto: 0,\n nombreTipografico: 0,\n inactivos: 0,\n conflictosDui: 0,\n ambiguos: 0,\n sinCoincidencia: 0\n };\n\n for (const person of unifiedPeople.values()) {\n const match = matchByKey.get(person.key);\n if (match.accepted) {\n if (/^DUI_EXACTO/.test(match.methodCode)) bambooMatchStats.duiExacto++;\n else if (match.methodCode === 'NOMBRE_EXACTO_UNICO') bambooMatchStats.nombreExacto++;\n else if (match.methodCode === 'NOMBRE_TIPOGRAFICO_UNICO') bambooMatchStats.nombreTipografico++;\n if (clean(match.employee?.status) && !/^(ACTIVE|ACTIVO)$/i.test(match.employee.status)) {\n bambooMatchStats.inactivos++;\n }\n } else if (match.review) {\n bambooMatchStats.ambiguos++;\n } else {\n bambooMatchStats.sinCoincidencia++;\n }\n\n if (match.identityIssue) {\n bambooMatchStats.conflictosDui++;\n const issue = match.identityIssue;\n const matchedEmployee = issue.matchedEmployee || match.employee;\n const duiOwner = issue.duiOwner;\n const detail = duiOwner\n ? 'En BambooHR el DUI/employeeNumber ' + person.dui + ' está asociado a ' + duiOwner.name +\n '. La persona coincide por nombre con ' + (matchedEmployee?.name || 'otro registro') +\n (matchedEmployee?.employeeNumber ? ' (employeeNumber ' + matchedEmployee.employeeNumber + ')' : '') + '.'\n : 'La persona coincide por nombre con ' + (matchedEmployee?.name || 'un registro de BambooHR') +\n ', pero BambooHR contiene el employeeNumber ' + (matchedEmployee?.employeeNumber || 'vacío o inválido') +\n ' y las fuentes del cruce contienen ' + person.dui + '.';\n addFinding({\n tipo: 'DUI distinto o asociado a otra persona en BambooHR',\n severidad: 'Alta',\n duiNomina: person.payrollNames.length ? person.dui : '',\n duiPlanilla: person.planNames.length ? person.dui : '',\n nombreNomina: person.payrollNames.join(' | '),\n nombrePlanilla: person.planNames.join(' | '),\n montoNomina: person.payrollAmounts.length ? person.payrollAmounts.reduce((sum, value) => sum + Number(value || 0), 0) : '',\n montoPlanilla: person.planAmounts.length ? person.planAmounts.reduce((sum, value) => sum + Number(value || 0), 0) : '',\n accion: detail + ' Corregir y validar el identificador en BambooHR.'\n });\n }\n }\n\n const payrollWithoutBamboo = [];\n const planWithoutBamboo = [];\n\n function appendBambooException(person, output) {\n const match = matchByKey.get(person.matchKey);\n if (match?.accepted) return;\n const candidate = match?.candidate;\n output.push({\n dui: person.dui,\n name: person.name,\n project: person.project || '',\n amount: person.amount,\n candidateName: candidate?.name || '',\n score: Number(match?.score || 0),\n method: match?.method || 'Sin coincidencia',\n candidateStatus: candidate?.status || '',\n candidateLocation: candidate?.location || '',\n candidateCountry: candidate?.country || '',\n action: match?.review\n ? 'Revisar manualmente la coincidencia; no se clasificó automáticamente como persona ausente.'\n : 'Crear o validar el registro de la persona en BambooHR.'\n });\n }\n\n for (const person of payrollPeople) appendBambooException(person, payrollWithoutBamboo);\n for (const person of planPeople) appendBambooException(person, planWithoutBamboo);\n payrollWithoutBamboo.sort((left, right) => left.name.localeCompare(right.name, 'es'));\n planWithoutBamboo.sort((left, right) => left.name.localeCompare(right.name, 'es'));\n\n const severityOrder = { Alta: 0, Media: 1, Informativa: 2 };\n findings.sort((a, b) => (severityOrder[a.severidad] ?? 9) - (severityOrder[b.severidad] ?? 9) || a.tipo.localeCompare(b.tipo, 'es') || (a.nombreNomina || a.nombrePlanilla).localeCompare(b.nombreNomina || b.nombrePlanilla, 'es'));\n\n const amountFindings = findings.filter(item => item.tipo === 'Diferencia de monto' || item.tipo === 'Diferencia subcentavo').length;\n const subcentFindings = findings.filter(item => item.tipo === 'Diferencia subcentavo').length;\n const identityFindings = findings.length - amountFindings;\n const nameFindings = findings.filter(item => item.tipo === 'Nombre no idéntico con el mismo DUI').length;\n const highFindings = findings.filter(item => item.severidad === 'Alta').length;\n const totalFindings = findings.length + payrollWithoutBamboo.length + planWithoutBamboo.length;\n\n function color(hex) {\n const text = hex.replace('#', '');\n return {\n red: parseInt(text.slice(0, 2), 16) / 255,\n green: parseInt(text.slice(2, 4), 16) / 255,\n blue: parseInt(text.slice(4, 6), 16) / 255\n };\n }\n const COLORS = {\n blue: color('#4F758B'),\n mediumBlue: color('#5B7F95'),\n steel: color('#6B8FA3'),\n green: color('#6CC24A'),\n greenBg: color('#EEF6E8'),\n blueBg: color('#D6E8F4'),\n gray: color('#4A4A4A'),\n border: color('#D0D0D0'),\n zebra: color('#F5F5F5'),\n white: color('#FFFFFF'),\n redBg: color('#FDECEC'),\n redText: color('#9C2F2F'),\n amberBg: color('#FFF6E3'),\n amberText: color('#8A5A00')\n };\n function gridBorder() {\n return { style: 'SOLID', color: COLORS.border };\n }\n function valueObject(value) {\n if (typeof value === 'number' && Number.isFinite(value)) return { numberValue: value };\n if (typeof value === 'boolean') return { boolValue: value };\n return { stringValue: value == null ? '' : String(value) };\n }\n function sheetDefinition(options) {\n const columnCount = options.headers.length;\n const empty = Array(columnCount).fill('');\n const dataRows = options.rows.length ? options.rows : [[options.emptyLabel || 'Sin diferencias', ...Array(columnCount - 1).fill('')]];\n const rawRows = [\n [options.title, ...Array(columnCount - 1).fill('')],\n [options.subtitle, ...Array(columnCount - 1).fill('')],\n [options.note, ...Array(columnCount - 1).fill('')],\n empty.slice(),\n options.headers,\n ...dataRows\n ];\n const rowData = rawRows.map((row, rowIndex) => {\n let background = COLORS.white;\n let foreground = COLORS.gray;\n let bold = false;\n let fontSize = 10;\n let horizontal = 'LEFT';\n let wrap = 'WRAP';\n if (rowIndex === 0) {\n background = COLORS.blue;\n foreground = COLORS.white;\n bold = true;\n fontSize = 15;\n horizontal = 'LEFT';\n } else if (rowIndex === 1) {\n background = COLORS.mediumBlue;\n foreground = COLORS.white;\n bold = true;\n } else if (rowIndex === 2) {\n background = COLORS.blueBg;\n foreground = COLORS.blue;\n bold = true;\n } else if (rowIndex === 4) {\n background = COLORS.blue;\n foreground = COLORS.white;\n bold = true;\n horizontal = 'CENTER';\n } else if (rowIndex >= 5) {\n background = (rowIndex - 5) % 2 ? COLORS.zebra : COLORS.white;\n if (row[0] === 'Sin diferencias' || row[0] === 'Sin personas fuera de BambooHR') {\n background = COLORS.greenBg;\n foreground = COLORS.green;\n bold = true;\n }\n if (options.kind === 'main' && row[1] === 'Alta') {\n background = COLORS.redBg;\n foreground = COLORS.redText;\n } else if (options.kind === 'main' && row[1] === 'Media') {\n background = COLORS.amberBg;\n foreground = COLORS.amberText;\n } else if (options.kind === 'main' && row[1] === 'Informativa') {\n background = COLORS.blueBg;\n foreground = COLORS.blue;\n }\n }\n return {\n values: row.map((value, columnIndex) => {\n const format = {\n backgroundColor: background,\n textFormat: {\n foregroundColor: foreground,\n bold,\n fontFamily: 'Arial',\n fontSize\n },\n verticalAlignment: 'MIDDLE',\n horizontalAlignment: rowIndex === 4 ? 'CENTER' : (options.moneyColumns.includes(columnIndex) && rowIndex >= 5 ? 'RIGHT' : horizontal),\n wrapStrategy: wrap,\n padding: { top: 5, bottom: 5, left: 7, right: 7 }\n };\n if (rowIndex >= 4) {\n format.borders = {\n top: gridBorder(),\n bottom: gridBorder(),\n left: gridBorder(),\n right: gridBorder()\n };\n }\n if (options.moneyColumns.includes(columnIndex) && rowIndex >= 5 && typeof value === 'number') {\n format.numberFormat = { type: 'CURRENCY', pattern: '$#,##0.0000;[Red]-$#,##0.0000' };\n }\n return { userEnteredValue: valueObject(value), userEnteredFormat: format };\n })\n };\n });\n const rowMetadata = rawRows.map((row, index) => ({ pixelSize: index === 0 ? 38 : index === 3 ? 10 : index === 4 ? 34 : index < 3 ? 27 : 31 }));\n return {\n properties: {\n sheetId: options.sheetId,\n title: options.sheetTitle,\n tabColor: options.tabColor,\n gridProperties: {\n rowCount: Math.max(rawRows.length + 20, 50),\n columnCount,\n frozenRowCount: 5,\n hideGridlines: true\n }\n },\n data: [{\n startRow: 0,\n startColumn: 0,\n rowData,\n rowMetadata,\n columnMetadata: options.widths.map(pixelSize => ({ pixelSize }))\n }],\n merges: [0, 1, 2].map(rowIndex => ({\n sheetId: options.sheetId,\n startRowIndex: rowIndex,\n endRowIndex: rowIndex + 1,\n startColumnIndex: 0,\n endColumnIndex: columnCount\n })),\n basicFilter: {\n range: {\n sheetId: options.sheetId,\n startRowIndex: 4,\n endRowIndex: rawRows.length,\n startColumnIndex: 0,\n endColumnIndex: columnCount\n }\n }\n };\n }\n\n const mainHeaders = ['Tipo de diferencia', 'Severidad', 'DUI Nómina', 'DUI Planilla', 'Nombre Nómina', 'Nombre Planilla', 'Monto Nómina (USD)', 'Monto Planilla (USD)', 'Diferencia (USD)', 'Observación / Acción'];\n const mainRows = findings.map(item => [\n item.tipo,\n item.severidad,\n item.duiNomina,\n item.duiPlanilla,\n item.nombreNomina,\n item.nombrePlanilla,\n item.montoNomina,\n item.montoPlanilla,\n item.diferencia,\n item.accion\n ]);\n const payrollBambooHeaders = ['DUI', 'Nombre en Nómina', 'Proyecto', 'Monto Nómina (USD)', 'Mejor candidato en BambooHR', 'Similitud', 'Resultado automático', 'Estado candidato', 'Ubicación / País', 'Acción'];\n const payrollBambooRows = payrollWithoutBamboo.map(item => [\n item.dui,\n item.name,\n item.project,\n item.amount,\n item.candidateName,\n item.score ? Math.round(item.score * 100) + '%' : '',\n item.method,\n item.candidateStatus,\n uniqueJoin([item.candidateLocation, item.candidateCountry]),\n item.action\n ]);\n const planBambooHeaders = ['DUI', 'Nombre en Planilla', 'Monto Planilla (USD)', 'Mejor candidato en BambooHR', 'Similitud', 'Resultado automático', 'Estado candidato', 'Ubicación / País', 'Acción'];\n const planBambooRows = planWithoutBamboo.map(item => [\n item.dui,\n item.name,\n item.amount,\n item.candidateName,\n item.score ? Math.round(item.score * 100) + '%' : '',\n item.method,\n item.candidateStatus,\n uniqueJoin([item.candidateLocation, item.candidateCountry]),\n item.action\n ]);\n\n const timestamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 16);\n const reportTitle = 'Cruce Seguridad Social El Salvador - ' + periodLabel + ' - ' + timestamp;\n const spreadsheetPayload = {\n properties: {\n title: reportTitle,\n locale: 'es_SV',\n timeZone: 'America/El_Salvador'\n },\n sheets: [\n sheetDefinition({\n sheetId: 0,\n sheetTitle: 'Diferencias',\n title: 'CRUCE DE SEGURIDAD SOCIAL · EL SALVADOR',\n subtitle: 'Período: ' + periodLabel + ' · Nómina vs. Planilla Única',\n note: 'Solo se muestran hallazgos · Identidad/nombres: ' + identityFindings + ' · Montos: ' + amountFindings + ' · Total: ' + findings.length,\n headers: mainHeaders,\n rows: mainRows,\n widths: [210, 95, 115, 115, 190, 190, 130, 130, 125, 330],\n moneyColumns: [6, 7, 8],\n tabColor: COLORS.blue,\n kind: 'main',\n emptyLabel: 'Sin diferencias'\n }),\n sheetDefinition({\n sheetId: 1,\n sheetTitle: 'Nómina sin Bamboo',\n title: 'NÓMINA SIN BAMBOO',\n subtitle: 'Período: ' + periodLabel + ' · Personas de nómina sin coincidencia concluyente en BambooHR',\n note: 'Solo se muestran excepciones · Total: ' + payrollWithoutBamboo.length + ' · Las coincidencias ambiguas requieren revisión manual',\n headers: payrollBambooHeaders,\n rows: payrollBambooRows,\n widths: [115, 210, 150, 130, 210, 85, 145, 110, 170, 310],\n moneyColumns: [3],\n tabColor: COLORS.green,\n kind: 'bamboo',\n emptyLabel: 'Sin personas fuera de BambooHR'\n }),\n sheetDefinition({\n sheetId: 2,\n sheetTitle: 'Planilla sin Bamboo',\n title: 'PLANILLA SIN BAMBOO',\n subtitle: 'Período: ' + periodLabel + ' · Personas de planilla sin coincidencia concluyente en BambooHR',\n note: 'Solo se muestran excepciones · Total: ' + planWithoutBamboo.length + ' · Las coincidencias ambiguas requieren revisión manual',\n headers: planBambooHeaders,\n rows: planBambooRows,\n widths: [115, 220, 135, 220, 85, 145, 110, 180, 330],\n moneyColumns: [2],\n tabColor: COLORS.steel,\n kind: 'bamboo',\n emptyLabel: 'Sin personas fuera de BambooHR'\n })\n ]\n };\n\n const binaryValues = formItem.binary || {};\n const payrollBinary = binaryValues.nomina || Object.values(binaryValues).find(file => /nomina/i.test((file.fileName || '') + '')) || {};\n const planBinary = binaryValues.planilla_seguridad_social || Object.values(binaryValues).find(file => /planilla/i.test((file.fileName || '') + '')) || {};\n\n return [{\n json: {\n spreadsheetPayload,\n summary: {\n periodo: periodLabel,\n periodoArchivo: periodFile,\n correoDestino: 'iaracena@gomezleemarketing.com, msoto@gomezleemarketing.com, iherrera@gomezleemarketing.com, mgomez@gomezleemarketing.com, vparamo@gomezleemarketing.com, asrodriguez@gomezleemarketing.com, jgomez@gomezleemarketing.com',\n archivoNomina: payrollBinary.fileName || 'Nómina',\n archivoPlanilla: planBinary.fileName || 'Planilla',\n registrosNomina: payrollRecords.length,\n registrosPlanilla: planRecords.length,\n empleadosNomina: payrollGroups.size + payrollMissingDui.length,\n empleadosPlanilla: planGroups.size + planMissingDui.length,\n empleadosBamboo: bambooEmployees.length,\n hallazgosIdentidad: identityFindings,\n hallazgosMontos: amountFindings,\n hallazgosSubcentavo: subcentFindings,\n hallazgosNombres: nameFindings,\n hallazgosAlta: highFindings,\n hallazgosPrincipales: findings.length,\n nominaSinBamboo: payrollWithoutBamboo.length,\n planillaSinBamboo: planWithoutBamboo.length,\n coincidenciasBambooInactivas: bambooMatchStats.inactivos,\n coincidenciasBambooDuiExacto: bambooMatchStats.duiExacto,\n coincidenciasBambooNombreExacto: bambooMatchStats.nombreExacto,\n coincidenciasBambooNombreTipografico: bambooMatchStats.nombreTipografico,\n conflictosDuiBamboo: bambooMatchStats.conflictosDui,\n coincidenciasBambooAmbiguas: bambooMatchStats.ambiguos,\n hallazgosTotales: totalFindings,\n nombreArchivo: 'Cruce_Seguridad_Social_El_Salvador_' + periodFile + '.xlsx'\n }\n }\n }];" + }, + "id": "1358629e-a085-42c6-8d94-a7a8fc9da36b", + "name": "Analizar y construir reporte", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2720, + 800 + ], + "onError": "continueErrorOutput" + }, + { + "parameters": { + "jsCode": "const input = $input.first();\n const payload = input.json?.spreadsheetPayload;\n const summary = input.json?.summary || {};\n if (!payload?.sheets?.length) throw new Error('No se encontró la estructura del reporte para generar el Excel.');\n\n function esc(value) {\n return String(value ?? '').replace(/[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F]/g, '').replace(/&/g, '&').replace(//g, '>').replace(/\"/g, '"').replace(/'/g, ''');\n }\n function col(index) {\n let value = index + 1;\n let result = '';\n while (value > 0) {\n const remainder = (value - 1) % 26;\n result = String.fromCharCode(65 + remainder) + result;\n value = Math.floor((value - 1) / 26);\n }\n return result;\n }\n function valueOf(cell) {\n const value = cell?.userEnteredValue || {};\n if ('numberValue' in value) return value.numberValue;\n if ('boolValue' in value) return value.boolValue;\n if ('stringValue' in value) return value.stringValue;\n return '';\n }\n function rgbHex(rgb, fallback) {\n if (!rgb) return fallback;\n const channel = value => Math.max(0, Math.min(255, Math.round(Number(value || 0) * 255))).toString(16).padStart(2, '0').toUpperCase();\n return 'FF' + channel(rgb.red) + channel(rgb.green) + channel(rgb.blue);\n }\n function baseStyle(sheetTitle, rowIndex, values) {\n if (rowIndex === 0) return 1;\n if (rowIndex === 1) return 2;\n if (rowIndex === 2) return 3;\n if (rowIndex === 4) return 4;\n if (rowIndex < 5) return 0;\n if (values[0] === 'Sin diferencias' || values[0] === 'Sin personas fuera de BambooHR') return 10;\n if (sheetTitle === 'Diferencias') {\n if (values[1] === 'Alta') return 7;\n if (values[1] === 'Media') return 8;\n if (values[1] === 'Informativa') return 9;\n }\n return (rowIndex - 5) % 2 ? 6 : 5;\n }\n function moneyStyle(style) {\n return ({ 5: 11, 6: 12, 7: 13, 8: 14, 9: 15, 10: 16 })[style] || style;\n }\n function makeSheetXml(sheet) {\n const grid = sheet.data?.[0] || {};\n const rows = grid.rowData || [];\n const columnCount = sheet.properties?.gridProperties?.columnCount || Math.max(1, ...rows.map(row => row.values?.length || 0));\n const lastColumn = col(columnCount - 1);\n const sheetTitle = sheet.properties?.title || 'Hoja';\n const tabColor = rgbHex(sheet.properties?.tabColor, sheetTitle === 'Diferencias' ? 'FF4F758B' : 'FF6CC24A');\n const widths = (grid.columnMetadata || []).map(meta => Math.max(8, Math.min(60, Number(meta.pixelSize || 100) / 7)));\n const columns = Array.from({ length: columnCount }, (_, index) => '').join('');\n const rowXml = rows.map((row, rowIndex) => {\n const values = Array.from({ length: columnCount }, (_, index) => valueOf(row.values?.[index]));\n const style = baseStyle(sheetTitle, rowIndex, values);\n const height = rowIndex === 0 ? 30 : rowIndex === 3 ? 9 : rowIndex === 4 ? 28 : rowIndex < 3 ? 22 : 24;\n const cells = Array.from({ length: columnCount }, (_, columnIndex) => {\n const cell = row.values?.[columnIndex] || {};\n const value = values[columnIndex];\n const cellStyle = cell.userEnteredFormat?.numberFormat ? moneyStyle(style) : style;\n const reference = col(columnIndex) + (rowIndex + 1);\n if (typeof value === 'number' && Number.isFinite(value)) return '' + value + '';\n if (typeof value === 'boolean') return '' + (value ? 1 : 0) + '';\n return '' + esc(value) + '';\n }).join('');\n return '' + cells + '';\n }).join('');\n const mergeCells = (sheet.merges || []).map(merge => '');\n const merges = mergeCells.length ? '' + mergeCells.join('') + '' : '';\n const lastRow = Math.max(rows.length, 5);\n return '' +\n '' +\n '' +\n '' + columns + '' + rowXml + '' + merges +\n '';\n }\n\n const fills = ['4F758B', '5B7F95', 'D6E8F4', 'FFFFFF', 'F5F5F5', 'FDECEC', 'FFF6E3', 'EEF6E8'].map(hex => '').join('');\n function xf(numFmt, font, fill, border, alignment) {\n return '';\n }\n const xfs = [\n '',\n xf(0, 1, 2, 0, 'horizontal=\"left\" vertical=\"center\"'),\n xf(0, 2, 3, 0, 'horizontal=\"left\" vertical=\"center\"'),\n xf(0, 3, 4, 0, 'horizontal=\"left\" vertical=\"center\" wrapText=\"1\"'),\n xf(0, 2, 2, 1, 'horizontal=\"center\" vertical=\"center\" wrapText=\"1\"'),\n xf(0, 0, 5, 1, 'vertical=\"center\" wrapText=\"1\"'),\n xf(0, 0, 6, 1, 'vertical=\"center\" wrapText=\"1\"'),\n xf(0, 4, 7, 1, 'vertical=\"center\" wrapText=\"1\"'),\n xf(0, 5, 8, 1, 'vertical=\"center\" wrapText=\"1\"'),\n xf(0, 3, 4, 1, 'vertical=\"center\" wrapText=\"1\"'),\n xf(0, 6, 9, 1, 'vertical=\"center\" wrapText=\"1\"'),\n xf(164, 0, 5, 1, 'horizontal=\"right\" vertical=\"center\"'),\n xf(164, 0, 6, 1, 'horizontal=\"right\" vertical=\"center\"'),\n xf(164, 4, 7, 1, 'horizontal=\"right\" vertical=\"center\"'),\n xf(164, 5, 8, 1, 'horizontal=\"right\" vertical=\"center\"'),\n xf(164, 3, 4, 1, 'horizontal=\"right\" vertical=\"center\"'),\n xf(164, 6, 9, 1, 'horizontal=\"right\" vertical=\"center\"')\n ].join('');\n const styles = '' +\n '' +\n '' +\n '' +\n '' +\n '' + fills + '' +\n '' +\n '' + xfs + '';\n\n const sheetNames = payload.sheets.map(sheet => sheet.properties?.title || 'Hoja');\n const overrides = payload.sheets.map((sheet, index) => '').join('');\n const workbookSheets = sheetNames.map((name, index) => '').join('');\n const relationships = payload.sheets.map((sheet, index) => '').join('') +\n '';\n const nowIso = new Date().toISOString();\n const titleParts = sheetNames.map(name => '' + esc(name) + '').join('');\n const files = [\n { name: '[Content_Types].xml', data: '' + overrides + '' },\n { name: '_rels/.rels', data: '' },\n { name: 'docProps/core.xml', data: 'GomezLee Marketingn8n' + esc(summary.nombreArchivo || 'Cruce Seguridad Social El Salvador') + '' + nowIso + '' + nowIso + '' },\n { name: 'docProps/app.xml', data: 'Microsoft ExcelHojas de cálculo' + sheetNames.length + '' + titleParts + 'GomezLee Marketing16.0300' },\n { name: 'xl/workbook.xml', data: '' + workbookSheets + '' },\n { name: 'xl/_rels/workbook.xml.rels', data: '' + relationships + '' },\n { name: 'xl/styles.xml', data: styles }\n ];\n payload.sheets.forEach((sheet, index) => files.push({ name: 'xl/worksheets/sheet' + (index + 1) + '.xml', data: makeSheetXml(sheet) }));\n\n function crc32(buffer) {\n if (!crc32.table) crc32.table = Array.from({ length: 256 }, (_, number) => {\n let value = number;\n for (let bit = 0; bit < 8; bit++) value = (value & 1) ? (0xEDB88320 ^ (value >>> 1)) : (value >>> 1);\n return value >>> 0;\n });\n let crc = 0xFFFFFFFF;\n for (const byte of buffer) crc = crc32.table[(crc ^ byte) & 0xFF] ^ (crc >>> 8);\n return (crc ^ 0xFFFFFFFF) >>> 0;\n }\n function zip(entries) {\n const now = new Date();\n const year = Math.max(1980, now.getFullYear());\n const dosTime = (now.getHours() << 11) | (now.getMinutes() << 5) | Math.floor(now.getSeconds() / 2);\n const dosDate = ((year - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate();\n const localParts = [];\n const centralParts = [];\n let offset = 0;\n for (const entry of entries) {\n const name = Buffer.from(entry.name, 'utf8');\n const data = Buffer.from(entry.data, 'utf8');\n const crc = crc32(data);\n const local = Buffer.alloc(30);\n local.writeUInt32LE(0x04034B50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0x0800, 6); local.writeUInt16LE(0, 8);\n local.writeUInt16LE(dosTime, 10); local.writeUInt16LE(dosDate, 12); local.writeUInt32LE(crc, 14); local.writeUInt32LE(data.length, 18); local.writeUInt32LE(data.length, 22); local.writeUInt16LE(name.length, 26);\n const localEntry = Buffer.concat([local, name, data]);\n localParts.push(localEntry);\n const central = Buffer.alloc(46);\n central.writeUInt32LE(0x02014B50, 0); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6); central.writeUInt16LE(0x0800, 8); central.writeUInt16LE(0, 10);\n central.writeUInt16LE(dosTime, 12); central.writeUInt16LE(dosDate, 14); central.writeUInt32LE(crc, 16); central.writeUInt32LE(data.length, 20); central.writeUInt32LE(data.length, 24); central.writeUInt16LE(name.length, 28); central.writeUInt32LE(offset, 42);\n centralParts.push(Buffer.concat([central, name]));\n offset += localEntry.length;\n }\n const directory = Buffer.concat(centralParts);\n const end = Buffer.alloc(22);\n end.writeUInt32LE(0x06054B50, 0); end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10); end.writeUInt32LE(directory.length, 12); end.writeUInt32LE(offset, 16);\n return Buffer.concat([...localParts, directory, end]);\n }\n\n const workbook = zip(files);\n const fileName = summary.nombreArchivo || 'Cruce_Seguridad_Social_El_Salvador.xlsx';\n return [{\n json: { ...summary },\n binary: { reporteExcel: { data: workbook.toString('base64'), mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', fileName, fileExtension: 'xlsx' } }\n }];" + }, + "id": "f017b8e9-f5b4-4dac-98c5-223cdfdb4dec", + "name": "Generar Excel", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3184, + 816 + ], + "onError": "continueErrorOutput" + }, + { + "parameters": { + "inputDataFieldName": "reporteExcel", + "name": "={{ $(\"Generar Excel\").item.json.nombreArchivo }}", + "driveId": { + "__rl": true, + "mode": "list", + "value": "My Drive" + }, + "folderId": { + "__rl": true, + "mode": "list", + "value": "root", + "cachedResultName": "/ (Root folder)" + }, + "options": { + "simplifyOutput": false + } + }, + "id": "57954526-8ffd-4856-94bb-3eaa1b357c77", + "name": "Subir reporte a Google Drive", + "type": "n8n-nodes-base.googleDrive", + "typeVersion": 3, + "position": [ + 3456, + 656 + ], + "executeOnce": true, + "retryOnFail": true, + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + }, + "onError": "continueErrorOutput" + }, + { + "parameters": { + "mode": "raw", + "jsonOutput": "={{ (() => {\nconst recipients=[{\"name\":\"Isaac Aracena\",\"email\":\"iaracena@gomezleemarketing.com\"},{\"name\":\"Mati Soto\",\"email\":\"msoto@gomezleemarketing.com\"},{\"name\":\"Iveth Herrera\",\"email\":\"iherrera@gomezleemarketing.com\"},{\"name\":\"Máximo Gomez\",\"email\":\"mgomez@gomezleemarketing.com\"},{\"name\":\"Viviana Paramo\",\"email\":\"vparamo@gomezleemarketing.com\"},{\"name\":\"Ada Rodriguez\",\"email\":\"asrodriguez@gomezleemarketing.com\"}];\nconst norm=value=>String(value??'').trim().toLowerCase();\nconst file=$('Subir reporte a Google Drive').first().json||{};\nconst owners=Array.isArray(file.owners)?file.owners:[];\nconst permissions=Array.isArray(file.permissions)?file.permissions:[];\nconst editable=new Set(['owner','writer','organizer','fileOrganizer']);\nconst hasAccess=recipient=>{\n const email=norm(recipient.email);\n return owners.some(o=>!o?.deleted&&norm(o?.emailAddress)===email)||\n permissions.some(p=>!p?.deleted&&norm(p?.emailAddress)===email&&editable.has(String(p?.role||'')));\n};\nconst recipientsToShare=recipients.filter(r=>!hasAccess(r));\nreturn {recipients,recipientsToShare,pendingAccessCount:recipientsToShare.length,existingAccessCount:recipients.length-recipientsToShare.length};\n})() }}", + "options": {} + }, + "id": "1729e8fc-105c-411a-bf80-1be1dfb14614", + "name": "Preparar lista de destinatarios", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 3712, + 560 + ], + "onError": "continueErrorOutput" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 2 + }, + "conditions": [ + { + "id": "pending-access-count", + "leftValue": "={{ $json.pendingAccessCount }}", + "rightValue": 0, + "operator": { + "type": "number", + "operation": "gt" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "9743bb45-f09b-454e-b787-73ee450b69b3", + "name": "¿Hay accesos pendientes?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 3952, + 560 + ], + "onError": "continueErrorOutput" + }, + { + "parameters": { + "fieldToSplitOut": "recipientsToShare", + "options": { + "destinationFieldName": "recipient" + } + }, + "id": "3d74bea6-b858-4ae2-b1ed-8d46ece27ea5", + "name": "Separar destinatarios", + "type": "n8n-nodes-base.splitOut", + "typeVersion": 1, + "position": [ + 4192, + 480 + ], + "onError": "continueErrorOutput" + }, + { + "parameters": { + "method": "POST", + "url": "=https://www.googleapis.com/drive/v3/files/{{ $('Subir reporte a Google Drive').first().json.id }}/permissions?sendNotificationEmail=false&supportsAllDrives=true", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleDriveOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ role: 'writer', type: 'user', emailAddress: $json.recipient.email }) }}", + "options": { + "timeout": 300000 + } + }, + "id": "94d1d384-81f3-412e-907c-c888d92cef81", + "name": "Crear permiso de editor por API", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 4432, + 480 + ], + "retryOnFail": true, + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + }, + "onError": "continueErrorOutput" + }, + { + "parameters": { + "aggregate": "aggregateAllItemData", + "destinationFieldName": "permisosCreados", + "options": {} + }, + "id": "b114e1f6-fc06-49fb-ac36-52c2e0893428", + "name": "Agrupar permisos creados", + "type": "n8n-nodes-base.aggregate", + "typeVersion": 1, + "position": [ + 4672, + 480 + ], + "onError": "continueErrorOutput" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 2 + }, + "conditions": [ + { + "id": "created-permission-count", + "leftValue": "={{ Array.isArray($json.permisosCreados) && $json.permisosCreados.length === Number($('Preparar lista de destinatarios').first().json.pendingAccessCount || 0) }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "d1679655-fc24-4f4c-b875-68c8bc002c24", + "name": "¿Se crearon todos los permisos?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 4928, + 480 + ], + "onError": "continueErrorOutput" + }, + { + "parameters": { + "url": "=https://www.googleapis.com/drive/v3/files/{{ $('Subir reporte a Google Drive').first().json.id }}/permissions", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleDriveOAuth2Api", + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "supportsAllDrives", + "value": "true" + }, + { + "name": "fields", + "value": "permissions(id,type,role,emailAddress,deleted,pendingOwner)" + } + ] + }, + "options": { + "response": { + "response": { + "responseFormat": "json" + } + }, + "timeout": 300000 + } + }, + "id": "480bcf25-fce8-4c7e-821b-608a879277d8", + "name": "Verificar accesos en Google Drive", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 5168, + 400 + ], + "executeOnce": true, + "retryOnFail": true, + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + }, + "onError": "continueErrorOutput" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 2 + }, + "conditions": [ + { + "id": "verified-editor-access", + "leftValue": "={{ (() => { const norm=value=>String(value??'').trim().toLowerCase(); const recipients=$('Preparar lista de destinatarios').first().json.recipients||[]; const permissions=Array.isArray($json.permissions)?$json.permissions:[]; const editable=new Set(['owner','writer','organizer','fileOrganizer']); return recipients.every(recipient=>permissions.some(permission=>!permission?.deleted&&norm(permission?.emailAddress)===norm(recipient.email)&&editable.has(String(permission?.role||'')))); })() }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "45086ef1-fb80-4c04-8860-681fbe2a1bd4", + "name": "¿Están confirmados los siete accesos?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 5424, + 400 + ], + "onError": "continueErrorOutput" + }, + { + "parameters": { + "mode": "raw", + "jsonOutput": "={{ ({ accesosConfirmados: $('Preparar lista de destinatarios').first().json.recipients.length, permisosNuevos: $('Preparar lista de destinatarios').first().json.pendingAccessCount, accesosPrevios: $('Preparar lista de destinatarios').first().json.existingAccessCount, verificacionDrive: true }) }}", + "options": {} + }, + "id": "e6dafe32-01f9-44d8-8130-25152709d028", + "name": "Confirmar acceso de siete personas", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 5424, + 752 + ], + "executeOnce": true, + "onError": "continueErrorOutput" + }, + { + "parameters": { + "mode": "combine", + "combineBy": "combineByPosition", + "options": { + "includeUnpaired": false + } + }, + "id": "5e783156-45dc-41e1-a78a-ce90fa8d87a9", + "name": "Combinar reporte y acceso", + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 5184, + 816 + ] + }, + { + "parameters": { + "sendTo": "={{ $('Preparar lista de destinatarios').first().json.recipients.map(recipient => recipient.email).join(', ') }}", + "subject": "={{ \"Cruce Seguridad Social El Salvador - \" + $(\"Combinar reporte y acceso\").item.json.periodo + \" - \" + $(\"Combinar reporte y acceso\").item.json.hallazgosTotales + \" hallazgo(s)\" }}", + "message": "={{ (function emailExpressionRuntime() {\n const s = $('Combinar reporte y acceso').item.json || {};\n const report = $('Subir reporte a Google Drive').item.json || {};\n const reportUrl = report.webViewLink || ('https://drive.google.com/open?id=' + (report.id || ''));\n const esc = value => String(value ?? '').replace(/[&<>\"']/g, character => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' })[character]);\n const number = value => Number(value || 0).toLocaleString('es-SV');\n const total = Number(s.hallazgosTotales || 0);\n const status = total ? 'REQUIERE REVISIÓN' : 'COMPLETADO · SIN DIFERENCIAS';\n const statusBackground = total ? '#FFF6E3' : '#EEF6E8';\n const statusBorder = total ? '#E7C56A' : '#B9DDAA';\n const statusColor = total ? '#7A5200' : '#347326';\n const logo = 'data:image/png;base64,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';\n return '
' +\n '' +\n '' +\n '' +\n '' +\n '
 
\"GomezLee
SEGURIDAD SOCIAL · EL SALVADOR

Cruce mensual completado

' + esc(s.periodo) + ' · Nómina, Planilla Única y BambooHR

' + status + '
NÓMINA
' + number(s.empleadosNomina) + '
personas
PLANILLA
' + number(s.empleadosPlanilla) + '
personas
HALLAZGOS
' + number(total) + '
a revisar

Resumen del cruce

Diferencias de identidad y nombres' + number(s.hallazgosIdentidad) + '
Diferencias de montos (subcentavos: ' + number(s.hallazgosSubcentavo) + ')' + number(s.hallazgosMontos) + '
Nómina sin BambooHR' + number(s.nominaSinBamboo) + '
Planilla sin BambooHR' + number(s.planillaSinBamboo) + '
Reporte Excel adjunto y disponible en Google Drive
Incluye las hojas Diferencias, Nómina sin Bamboo y Planilla sin Bamboo.
Archivos procesados
Nómina: ' + esc(s.archivoNomina) + '
Planilla: ' + esc(s.archivoPlanilla) + '
Reporte: ' + esc(s.nombreArchivo) + '
';\n})() }}", + "options": { + "appendAttribution": false, + "attachmentsUi": { + "attachmentsBinary": [ + { + "property": "reporteExcel" + } + ] + }, + "senderName": "GomezLee Marketing" + } + }, + "id": "66c9e023-50e9-4c8b-b003-2d8c4780e7c3", + "name": "Enviar reporte Excel GLM", + "type": "n8n-nodes-base.gmail", + "typeVersion": 2.2, + "position": [ + 5680, + 864 + ], + "executeOnce": true, + "retryOnFail": true, + "webhookId": "9b4293aa-1dca-4aa3-9c7d-e957e5cd6c85", + "credentials": { + "gmailOAuth2": { + "id": "cehQBe6X1BQLmcD9", + "name": "Gmail account - Isaac" + } + }, + "onError": "continueErrorOutput" + }, + { + "parameters": { + "operation": "completion", + "respondWith": "showText", + "responseText": "={{ (function successExpressionRuntime() {\n const s = $('Combinar reporte y acceso').item.json || {};\n const report = $('Subir reporte a Google Drive').item.json || {};\n const esc = value => String(value ?? '').replace(/[&<>\"']/g, character => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' })[character]);\n const total = Number(s.hallazgosTotales || 0);\n const status = total\n ? 'El cruce terminó y hay ' + total + ' hallazgo(s) para revisar.'\n : 'El cruce terminó sin diferencias.';\n const reportUrl = report.webViewLink || ('https://drive.google.com/open?id=' + (report.id || ''));\n return '
' +\n '
SEGURIDAD SOCIAL · EL SALVADOR
' +\n '

Proceso completado

' +\n '

' + esc(s.periodo) + '

' +\n '

' + esc(status) +\n ' El Excel fue enviado a las siete personas autorizadas y quedó disponible en Google Drive con acceso de edición para cada una.

' +\n 'Abrir reporte' +\n 'Realizar otro cruce' +\n '
';\n})() }}" + }, + "id": "6a51a26e-7c88-451c-b966-6f6dec925f7a", + "name": "Mostrar resultado y repetir", + "type": "n8n-nodes-base.form", + "typeVersion": 2.5, + "position": [ + 6016, + 768 + ], + "webhookId": "2bbb060d-09d8-40c1-bcfa-6fb7c8c3d3da" + }, + { + "parameters": { + "operation": "completion", + "respondWith": "showText", + "responseText": "={{ (() => {\nconst source=$json?.error?.message??$json?.error??$json?.message??'Error no identificado';\nlet detail; try{detail=typeof source==='string'?source:JSON.stringify(source);}catch{detail=String(source);}\ndetail=String(detail||'Error no identificado').replace(/\\s+/g,' ').trim().slice(0,300);\nconst esc=value=>String(value??'').replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''})[c]);\nconst isDrive=/forbidden|permission|permiso|share|compart/i.test(detail);\nconst guidance=isDrive?'Google Drive no pudo confirmar uno de los accesos de edición. Verifica que la cuenta conectada pueda compartir el archivo con el destinatario indicado y vuelve a intentarlo.':'Revisa el detalle mostrado abajo y vuelve a intentarlo. Si el problema persiste, consulta esta ejecución en n8n.';\nreturn '
SEGURIDAD SOCIAL · EL SALVADOR

No se pudo completar el cruce

'+esc(guidance)+'

'+esc(detail)+'
Volver al formulario
';\n})() }}" + }, + "id": "30d59ab7-e53a-4533-92dd-c9f4786cb37e", + "name": "Mostrar error y reintentar", + "type": "n8n-nodes-base.form", + "typeVersion": 2.5, + "position": [ + 5584, + 1248 + ], + "webhookId": "b8aa8b32-d69c-45a5-aa33-dfe9040bb2f1" + }, + { + "parameters": { + "mode": "raw", + "jsonOutput": "={{ (() => { const norm=value=>String(value??'').trim().toLowerCase(); const recipients=$('Preparar lista de destinatarios').first().json.recipients||[]; const permissions=Array.isArray($json.permissions)?$json.permissions:[]; const editable=new Set(['owner','writer','organizer','fileOrganizer']); const missing=recipients.filter(recipient=>!permissions.some(permission=>!permission?.deleted&&norm(permission?.emailAddress)===norm(recipient.email)&&editable.has(String(permission?.role||'')))); return { error: 'Google Drive respondió, pero no confirmó acceso de edición para: '+missing.map(person=>person.name+' ('+person.email+')').join(', ')+'. No se envió el correo.' }; })() }}", + "options": {} + }, + "id": "a8f07990-7948-4037-8ece-16fc38f78991", + "name": "Preparar accesos faltantes", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 5680, + 528 + ], + "executeOnce": true, + "onError": "continueErrorOutput" + }, + { + "parameters": { + "mode": "raw", + "jsonOutput": "={{ ({ error: 'Google Drive no confirmó la creación de todos los permisos de edición requeridos. No se envió el correo para evitar una entrega incompleta.' }) }}", + "options": {} + }, + "id": "a135fd1f-b61f-4e45-be6c-03021b7d3378", + "name": "Preparar error de permisos", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 5168, + 576 + ], + "executeOnce": true, + "onError": "continueErrorOutput" + }, + { + "parameters": { + "content": "## 1 · Formulario\nRecibe período, nómina y planilla. Conserva las hojas **Nomina** y **PLANILLA_UNICA**.", + "height": 744, + "width": 1564, + "color": 4 + }, + "id": "fc9bcda7-c796-4f9e-aa23-426e11737463", + "name": "Guía · Formulario", + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 496, + 432 + ] + }, + { + "parameters": { + "content": "## 2 · Cruces y BambooHR\nCompara identidad, DUI, montos y presencia en el directorio histórico completo de BambooHR.", + "height": 312, + "width": 812, + "color": 5 + }, + "id": "380a2c79-217e-4de4-b5dd-14f2c1a7a259", + "name": "Guía · Cruces y BambooHR", + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 2160, + 672 + ] + }, + { + "parameters": { + "content": "## 3 · Reporte y entrega\nGenera el Excel, crea los permisos por la API de Drive, relee los permisos del archivo y envía el correo solo después de confirmar los siete accesos de edición.", + "height": 760, + "width": 3132, + "color": 3 + }, + "id": "6d234122-5e43-4473-a11c-eedcb76e31eb", + "name": "Guía · Reporte y entrega", + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 3104, + 288 + ] + }, + { + "parameters": { + "content": "## Manejo de errores\nMuestra detalle seguro y permite volver al formulario. Si falla la creación o la verificación de cualquier acceso, el correo queda bloqueado.", + "height": 332, + "width": 420, + "color": 2 + }, + "id": "dedc732b-cda5-4d60-81fc-bfd660445a71", + "name": "Guía · Manejo de errores", + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 5424, + 1088 + ] + }, + { + "parameters": { + "operation": "xlsx", + "options": { + "headerRow": true, + "includeEmptyCells": true, + "range": "A1:P100000", + "rawData": false, + "readAsString": false, + "sheetName": "={{ ['ENERO','FEBRERO','MARZO','ABRIL','MAYO','JUNIO','JULIO','AGOSTO','SEPTIEMBRE','OCTUBRE','NOVIEMBRE','DICIEMBRE'][Number($items('Formulario de cruce')[0].json.mes)-1] }}" + } + }, + "id": "1f75b85f-a535-41fc-a09c-f4975d2b8311", + "name": "Extraer Nómina · hoja del mes", + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + 1136, + 720 + ], + "onError": "continueErrorOutput" + }, + { + "parameters": { + "operation": "xlsx", + "options": { + "headerRow": true, + "includeEmptyCells": true, + "range": "A1:P100000", + "rawData": false, + "readAsString": false, + "sheetName": "Nómina" + } + }, + "id": "a86eaafa-db0f-46f9-97df-c59f2a707478", + "name": "Extraer Nómina · hoja Nómina", + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + 1168, + 944 + ], + "onError": "continueErrorOutput" + }, + { + "parameters": { + "jsCode": "const rows = $input.all();\nif (!rows.length) {\n throw new Error(\n 'No se encontraron registros en la hoja de nómina seleccionada.'\n );\n}\n\nfunction normalize(value) {\n return String(value ?? '')\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toUpperCase()\n .replace(/[^A-Z0-9]+/g, ' ')\n .trim();\n}\n\nconst headers = new Set();\nfor (const item of rows.slice(0, 25)) {\n for (const key of Object.keys(item.json || {})) {\n headers.add(normalize(key));\n }\n}\n\nfunction hasAny(patterns) {\n return [...headers].some(header =>\n patterns.some(pattern => pattern.test(header))\n );\n}\n\nconst hasDui = hasAny([/^DUI$/, /DOCUMENTO.*AFILIADO/]);\nconst hasName = hasAny([\n /^COLABORADOR$/,\n /^EMPLEADO$/,\n /^NOMBRE$/,\n /NOMBRE.*COLABORADOR/\n]);\nconst hasAmount = hasAny([\n /SUB TOTAL PERCIBIDO/,\n /TOTAL PERCIBIDO/,\n /SALARIO PERCIBIDO/\n]);\n\nif (!hasDui || !hasName || !hasAmount) {\n throw new Error(\n 'La hoja encontrada no parece ser la nómina de El Salvador: ' +\n 'debe contener DUI, nombre del colaborador y total percibido.'\n );\n}\n\nreturn rows;" + }, + "id": "d018c32f-57a6-4f13-9a01-4a7be1c5ab5d", + "name": "Extraer Nómina", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1344, + 656 + ], + "onError": "continueErrorOutput" + }, + { + "parameters": { + "jsCode": "const source = $items('Preparar archivo Nómina')[0];\nif (!source?.binary?.data) {\n throw new Error('No fue posible recuperar el archivo binario original de la nómina para continuar con la detección automática de hojas.');\n}\nreturn [{\n json: {\n ...(source.json || {}),\n intentoHoja: 'hoja del mes seleccionada'\n },\n binary: {\n data: source.binary.data\n }\n}];" + }, + "id": "f1a0d4b2-2820-45a5-8238-fa0a34c4c784", + "name": "Restaurar binario · intento hoja del mes", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 944, + 736 + ], + "onError": "continueErrorOutput" + }, + { + "parameters": { + "jsCode": "const source = $items('Preparar archivo Nómina')[0];\nif (!source?.binary?.data) {\n throw new Error('No fue posible recuperar el archivo binario original de la nómina para continuar con la detección automática de hojas.');\n}\nreturn [{\n json: {\n ...(source.json || {}),\n intentoHoja: 'hoja Nómina con tilde'\n },\n binary: {\n data: source.binary.data\n }\n}];" + }, + "id": "84004e83-b649-4871-b83d-9b0f490541ed", + "name": "Restaurar binario · intento hoja Nómina", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 960, + 960 + ], + "onError": "continueErrorOutput" + } + ], + "pinData": {}, + "connections": { + "Formulario de cruce": { + "main": [ + [ + { + "node": "Preparar archivo Nómina", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar archivo Nómina": { + "main": [ + [ + { + "node": "Extraer Nómina · hoja Nomina", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mostrar error y reintentar", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar archivo Planilla": { + "main": [ + [ + { + "node": "Extraer Planilla", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mostrar error y reintentar", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extraer Planilla": { + "main": [ + [ + { + "node": "Validar lecturas", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mostrar error y reintentar", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validar lecturas": { + "main": [ + [ + { + "node": "Obtener reporte completo BambooHR", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mostrar error y reintentar", + "type": "main", + "index": 0 + } + ] + ] + }, + "Obtener reporte completo BambooHR": { + "main": [ + [ + { + "node": "Analizar y construir reporte", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mostrar error y reintentar", + "type": "main", + "index": 0 + } + ] + ] + }, + "Analizar y construir reporte": { + "main": [ + [ + { + "node": "Generar Excel", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mostrar error y reintentar", + "type": "main", + "index": 0 + } + ] + ] + }, + "Generar Excel": { + "main": [ + [ + { + "node": "Subir reporte a Google Drive", + "type": "main", + "index": 0 + }, + { + "node": "Combinar reporte y acceso", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mostrar error y reintentar", + "type": "main", + "index": 0 + } + ] + ] + }, + "Subir reporte a Google Drive": { + "main": [ + [ + { + "node": "Preparar lista de destinatarios", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mostrar error y reintentar", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar lista de destinatarios": { + "main": [ + [ + { + "node": "¿Hay accesos pendientes?", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mostrar error y reintentar", + "type": "main", + "index": 0 + } + ] + ] + }, + "¿Hay accesos pendientes?": { + "main": [ + [ + { + "node": "Separar destinatarios", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Verificar accesos en Google Drive", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mostrar error y reintentar", + "type": "main", + "index": 0 + } + ] + ] + }, + "Separar destinatarios": { + "main": [ + [ + { + "node": "Crear permiso de editor por API", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mostrar error y reintentar", + "type": "main", + "index": 0 + } + ] + ] + }, + "Crear permiso de editor por API": { + "main": [ + [ + { + "node": "Agrupar permisos creados", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mostrar error y reintentar", + "type": "main", + "index": 0 + } + ] + ] + }, + "Agrupar permisos creados": { + "main": [ + [ + { + "node": "¿Se crearon todos los permisos?", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mostrar error y reintentar", + "type": "main", + "index": 0 + } + ] + ] + }, + "¿Se crearon todos los permisos?": { + "main": [ + [ + { + "node": "Verificar accesos en Google Drive", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Preparar error de permisos", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mostrar error y reintentar", + "type": "main", + "index": 0 + } + ] + ] + }, + "Verificar accesos en Google Drive": { + "main": [ + [ + { + "node": "¿Están confirmados los siete accesos?", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mostrar error y reintentar", + "type": "main", + "index": 0 + } + ] + ] + }, + "¿Están confirmados los siete accesos?": { + "main": [ + [ + { + "node": "Confirmar acceso de siete personas", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Preparar accesos faltantes", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mostrar error y reintentar", + "type": "main", + "index": 0 + } + ] + ] + }, + "Confirmar acceso de siete personas": { + "main": [ + [ + { + "node": "Combinar reporte y acceso", + "type": "main", + "index": 1 + } + ], + [ + { + "node": "Mostrar error y reintentar", + "type": "main", + "index": 0 + } + ] + ] + }, + "Combinar reporte y acceso": { + "main": [ + [ + { + "node": "Enviar reporte Excel GLM", + "type": "main", + "index": 0 + } + ] + ] + }, + "Enviar reporte Excel GLM": { + "main": [ + [ + { + "node": "Mostrar resultado y repetir", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mostrar error y reintentar", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar accesos faltantes": { + "main": [ + [ + { + "node": "Mostrar error y reintentar", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mostrar error y reintentar", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar error de permisos": { + "main": [ + [ + { + "node": "Mostrar error y reintentar", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mostrar error y reintentar", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extraer Nómina · hoja Nomina": { + "main": [ + [ + { + "node": "Extraer Nómina", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Restaurar binario · intento hoja del mes", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extraer Nómina · hoja del mes": { + "main": [ + [ + { + "node": "Extraer Nómina", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Restaurar binario · intento hoja Nómina", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extraer Nómina · hoja Nómina": { + "main": [ + [ + { + "node": "Extraer Nómina", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mostrar error y reintentar", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extraer Nómina": { + "main": [ + [ + { + "node": "Preparar archivo Planilla", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mostrar error y reintentar", + "type": "main", + "index": 0 + } + ] + ] + }, + "Restaurar binario · intento hoja del mes": { + "main": [ + [ + { + "node": "Extraer Nómina · hoja del mes", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mostrar error y reintentar", + "type": "main", + "index": 0 + } + ] + ] + }, + "Restaurar binario · intento hoja Nómina": { + "main": [ + [ + { + "node": "Extraer Nómina · hoja Nómina", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mostrar error y reintentar", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": true, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate", + "availableInMCP": true, + "timeSavedMode": "fixed", + "errorWorkflow": "puF4LUczoSz3hcek", + "timezone": "America/Santo_Domingo", + "callerPolicy": "workflowsFromSameOwner" + }, + "versionId": "0ceb0ca3-f520-4202-ab01-c299878c6ce9", + "meta": { + "aiBuilderAssisted": true, + "builderVariant": "mcp", + "instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70" + }, + "id": "wCud4hOciMao0Ab3", + "tags": [] +} \ No newline at end of file diff --git a/Cruce de Seguridad Social - Guatemala (1).json b/Cruce de Seguridad Social - Guatemala (1).json new file mode 100644 index 0000000..7d6b956 --- /dev/null +++ b/Cruce de Seguridad Social - Guatemala (1).json @@ -0,0 +1,1834 @@ +{ + "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
\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": [] +} \ No newline at end of file diff --git a/Cruce de Seguridad Social - Honduras (5).json b/Cruce de Seguridad Social - Honduras (5).json new file mode 100644 index 0000000..e709fab --- /dev/null +++ b/Cruce de Seguridad Social - Honduras (5).json @@ -0,0 +1,1310 @@ +{ + "name": "Cruce de Seguridad Social - Honduras", + "nodes": [ + { + "parameters": { + "formTitle": "Cruce IHSS vs Nómina Honduras", + "formDescription": "Sube la nómina mensual de Honduras y una o varias planillas IHSS en PDF. El flujo generará un reporte simple con cruce de identidad y cruce de montos IHSS.", + "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": "Planillas IHSS PDF", + "fieldType": "file", + "acceptFileTypes": ".pdf", + "requiredField": true + }, + { + "fieldLabel": "Nómina", + "fieldType": "file", + "multipleFiles": false, + "acceptFileTypes": ".xlsx, .xls, .csv", + "requiredField": true + } + ] + }, + "options": { + "ignoreBots": true + } + }, + "type": "n8n-nodes-base.formTrigger", + "typeVersion": 2.3, + "position": [ + -2976, + 2864 + ], + "id": "6cf49d17-8bfb-4735-ac86-5b20d1de54f0", + "name": "On form submission", + "webhookId": "68fcb432-b695-4112-b83b-df3eab99f7b2" + }, + { + "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 = `HN_IHSS_${ts}_${Math.random().toString(36).slice(2, 8)}`;\nconst mes = String(item.json['Mes a validar'] || '').padStart(2, '0');\nconst anio = now.getFullYear();\nconst meses = { '01':'Enero','02':'Febrero','03':'Marzo','04':'Abril','05':'Mayo','06':'Junio','07':'Julio','08':'Agosto','09':'Septiembre','10':'Octubre','11':'Noviembre','12':'Diciembre' };\nconst fechaTitulo = `${pad(now.getDate())}-${pad(now.getMonth()+1)}-${now.getFullYear()}`;\nconst periodoLabel = `${meses[mes] || mes} ${anio}`;\nconst reportTitle = `Cruce Seguridad Social Honduras - ${periodoLabel} - ${fechaTitulo}`;\nconst data = $getWorkflowStaticData('global');\ndata.hnCruceIHSSNomina = data.hnCruceIHSSNomina || {};\ndata.hnCruceIHSSNomina[runId] = {\n startedAt: now.toISOString(),\n mes,\n anio,\n periodoLabel,\n ihss: [],\n ihssPlanillas: [],\n nomina: [],\n payrollIhssSummary: {},\n payrollIhssBlocks: [],\n pdfFiles: [],\n payrollFile: '',\n bamboo: [],\n};\nreturn [{ json: { ...item.json, runId, mes, anio, periodoLabel, reportTitle, emailTo: 'iaracena@gomezleemarketing.com, ymadera@gomezleemarketing.com, msoto@gomezleemarketing.com, asrodriguez@gomezleemarketing.com, iherrera@gomezleemarketing.com, mgomez@gomezleemarketing.com, vparamo@gomezleemarketing.com' }, binary: item.binary }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -2736, + 2864 + ], + "id": "484c9045-a3ef-4a05-beb9-c48a13dd7d66", + "name": "Inicializar memoria" + }, + { + "parameters": { + "jsCode": "const base = $input.all()[0];\nconst bin = base.binary || {};\nconst out = [];\nfor (const [key, file] of Object.entries(bin)) {\n const fileName = String(file.fileName || key);\n const mime = String(file.mimeType || '').toLowerCase();\n const lower = fileName.toLowerCase();\n const isPdf = mime.includes('pdf') || lower.endsWith('.pdf') || key.toLowerCase().includes('pdf') || key.toLowerCase().includes('planilla');\n if (!isPdf) continue;\n out.push({\n json: { runId: base.json.runId, mes: base.json.mes, anio: base.json.anio, fileName, originalBinaryKey: key },\n binary: { data: file },\n });\n}\nif (!out.length) {\n throw new Error('No encontré planillas IHSS en PDF. Revisa que el campo de planillas acepte .pdf y que hayas adjuntado al menos un PDF.');\n}\nconst data = $getWorkflowStaticData('global');\nif (data.hnCruceIHSSNomina?.[base.json.runId]) {\n data.hnCruceIHSSNomina[base.json.runId].pdfFiles = out.map(i => i.json.fileName);\n}\nreturn out;" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -1664, + 2624 + ], + "id": "df786528-9ebf-4063-8530-6d0989aec394", + "name": "Separar planillas PDF" + }, + { + "parameters": { + "operation": "pdf", + "options": {} + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + -1424, + 2624 + ], + "id": "f84a024b-46f4-4626-a927-e61480458cf5", + "name": "Extraer texto PDF" + }, + { + "parameters": { + "jsCode": "function normalizeText(s) {\n return String(s || '').replace(/\\r/g, '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').replace(/Ù/g, 'U').replace(/ù/g, 'u');\n}\nfunction clean(s) { return String(s || '').replace(/\\s+/g, ' ').trim(); }\nfunction cleanNameLine(s) { return clean(String(s || '').replace(/\\s+\\d{1,2}\\s+[\\d,.]+.*$/, '')); }\nfunction toNumber(v) {\n if (v === null || v === undefined || v === '') return 0;\n let s = String(v).replace(/LPS|L|Q|USD|\\$|,/gi, '').replace(/\\s/g, '').trim();\n if (/^\\((.*)\\)$/.test(s)) s = '-' + s.replace(/^\\(|\\)$/g, '');\n const n = Number(s);\n return Number.isFinite(n) ? n : 0;\n}\nfunction money(n) { return Number((Number(n) || 0).toFixed(2)); }\nfunction firstMatch(text, patterns) { for (const p of patterns) { const m = text.match(p); if (m) return m[1] || m[0]; } return ''; }\nfunction numMatch(text, patterns) { const v = firstMatch(text, patterns); return v ? toNumber(v) : 0; }\nfunction unique13Digits(raw) {\n const found = String(raw || '').match(/\\b\\d{13}\\b/g) || [];\n return [...new Set(found)].filter(id => id !== '0000000000000');\n}\nfunction cleanNamesFromBlock(block) {\n return String(block || '').split('\\n')\n .map(cleanNameLine)\n .filter(l => l && !/^\\d+$/.test(l))\n .filter(l => /[A-ZÑ]/i.test(l))\n .filter(l => !/SALARIO|DIAS|TRABAJADOR|PATRONO|ESTADO|PERIODO|RIESGO|PAGINA|CUOTA|RECIBO|MONTO|TOTAL|PERSONA JURIDICA|PRIVADO|PUBLICIDAD|SECTOR|ACTIVIDAD|DIRECCION|TELEFONO|ZONA|CUADRA|NO\\.?\\s*AFILIACION|IDENTIDAD|NOMBRE Y APELLIDO/i.test(l));\n}\nfunction expectedWorkers(text) {\n const nums = [...String(text || '').matchAll(/TRABAJADORES\\s+(\\d{1,4})/gi)].map(m => Number(m[1])).filter(n => Number.isFinite(n) && n > 0);\n return nums.length ? Math.max(...nums) : 0;\n}\nfunction addCandidate(store, ids, names, meta) {\n for (let i = 0; i < ids.length; i++) {\n const id = ids[i];\n if (!id || id === '0000000000000') continue;\n if (!store.has(id)) {\n store.set(id, { identidad: id, nombre_planilla: clean(names[i] || ''), archivo_planilla: meta.fileName, numero_patronal: meta.patronal, recibo: meta.recibo, periodo: meta.periodo, cuota_trabajador: 0 });\n } else {\n const ex = store.get(id);\n if (!ex.nombre_planilla && clean(names[i] || '')) ex.nombre_planilla = clean(names[i]);\n }\n }\n}\nfunction extractCandidatesFromText(text, meta) {\n const store = new Map();\n const reA = /NO\\.?\\s*AFILIACION([\\s\\S]*?)IDENTIDAD([\\s\\S]*?)NOMBRE\\s+Y\\s+APELLIDO/gi;\n let m;\n while ((m = reA.exec(text)) !== null) addCandidate(store, unique13Digits(m[1]), cleanNamesFromBlock(m[2]), meta);\n const reB = /IDENTIDAD([\\s\\S]*?)NO\\.?\\s*AFILIACION[\\s\\S]*?NOMBRE\\s+Y\\s+APELLIDO([\\s\\S]*?)(?:\\n\\s*SALARIO|\\n\\s*30\\s|\\n\\s*Hago\\s+Constar|\\f|$)/gi;\n while ((m = reB.exec(text)) !== null) addCandidate(store, unique13Digits(m[1]), cleanNamesFromBlock(m[2]), meta);\n const reC = /IDENTIDAD([\\s\\S]*?)NOMBRE\\s+Y\\s+APELLIDO/gi;\n while ((m = reC.exec(text)) !== null) {\n const ids = unique13Digits(m[1]);\n if (ids.length) addCandidate(store, ids, [], meta);\n }\n const expected = expectedWorkers(text);\n if ((expected && store.size < Math.ceil(expected * 0.8)) || store.size === 0) addCandidate(store, unique13Digits(text), [], meta);\n return [...store.values()];\n}\nfunction extractPlanillaSummary(text, meta) {\n const trabajadores = numMatch(text, [/TRABAJADORES\\s+(\\d{1,4})/i]);\n const salario = numMatch(text, [/MONTO\\s+DE\\s+SALARIOS\\s+([\\d,.]+)/i]);\n const cuotaTrabajador = numMatch(text, [/CUOTA\\s+TRABAJADOR\\s+([\\d,.]+)/i]);\n const cuotaPatrono = numMatch(text, [/CUOTA\\s+PATRONO\\s+([\\d,.]+)/i]);\n const totalSinRecargo = money(cuotaTrabajador + cuotaPatrono);\n return {\n archivo_planilla: meta.fileName,\n numero_patronal: meta.patronal,\n recibo: meta.recibo,\n periodo: meta.periodo,\n trabajadores,\n salario_reportado: money(salario),\n cuota_trabajador: money(cuotaTrabajador),\n cuota_patrono: money(cuotaPatrono),\n total_planilla: totalSinRecargo,\n };\n}\nfunction periodoKey(v) { return String(v || '').replace(/\\s/g, '').trim(); }\nfunction planillaKey(p) {\n return [p.numero_patronal || '', p.recibo || '', periodoKey(p.periodo), p.trabajadores || '', money(p.cuota_trabajador || 0)].join('|');\n}\nconst init = $items('Inicializar memoria')[0].json;\nconst runId = init.runId;\nconst expectedPeriodo = `${String(init.mes || '').padStart(2, '0')}/${init.anio}`;\nconst items = $input.all();\nconst sourcePdfItems = $items('Separar planillas PDF');\nconst all = [];\nconst planillas = [];\nconst duplicatePlanillas = [];\nconst periodoMismatches = [];\nconst seenPlanillas = new Map();\nfor (let idx = 0; idx < items.length; idx++) {\n const item = items[idx];\n const source = sourcePdfItems[idx]?.json || {};\n const fileName = item.json.fileName || source.fileName || source.originalBinaryKey || item.json.file || item.json.originalBinaryKey || `Planilla PDF ${idx + 1}`;\n const text = normalizeText(item.json.text || item.json.data || item.json.content || '');\n if (!text.trim()) continue;\n const meta = {\n fileName,\n patronal: firstMatch(text, [/([0-9]{3}-[0-9]{4}-[0-9]{4}-[0-9])\\s*NUMERO\\s+PATRONAL/i, /NUMERO\\s+PATRONAL\\s*\\n?\\s*([0-9]{3}-[0-9]{4}-[0-9]{4}-[0-9])/i, /\\b([0-9]{3}-[0-9]{4}-[0-9]{4}-[0-9])\\b/i]),\n periodo: firstMatch(text, [/(\\d{2}\\s*\\/\\s*\\d{4})\\s*PERIODO/i, /PERIODO\\s*\\n?\\s*(\\d{2}\\s*\\/\\s*\\d{4})/i]),\n recibo: firstMatch(text, [/RECIBO\\s+NUMERO\\s*\\n?\\s*(\\d{5,})/i, /RECIBO\\s+DE\\s+PAGO[\\s\\S]{0,80}?\\b(\\d{6,})\\b/i, /\\*(\\d{6,})\\*/]),\n };\n const planilla = extractPlanillaSummary(text, meta);\n const candidates = extractCandidatesFromText(text, meta);\n const uniqueWorkersInPdf = new Set(candidates.map(r => r.identidad).filter(Boolean)).size;\n\n // Producción: algunos PDFs no exponen bien TRABAJADORES / MONTO DE SALARIOS en texto plano.\n if (!planilla.trabajadores || planilla.trabajadores < 1) planilla.trabajadores = uniqueWorkersInPdf;\n if (!planilla.salario_reportado || planilla.salario_reportado < 1000) {\n planilla.salario_reportado = planilla.cuota_trabajador\n ? money(planilla.cuota_trabajador / 0.05)\n : money(uniqueWorkersInPdf * 11903.13);\n }\n\n // Producción: si cargan el mismo PDF/recibo dos veces, NO se suma dos veces.\n const key = planillaKey(planilla);\n if (seenPlanillas.has(key)) {\n duplicatePlanillas.push({ ...planilla, archivo_original: seenPlanillas.get(key).archivo_planilla, archivo_duplicado: fileName });\n continue;\n }\n seenPlanillas.set(key, planilla);\n\n // Control de período: no detiene el flujo, pero queda visible como hallazgo.\n if (planilla.periodo && periodoKey(planilla.periodo) !== expectedPeriodo) {\n periodoMismatches.push({ archivo_planilla: fileName, periodo_detectado: planilla.periodo, periodo_esperado: expectedPeriodo });\n }\n\n planillas.push(planilla);\n const cuotaPorPersona = planilla.trabajadores ? money(planilla.cuota_trabajador / planilla.trabajadores) : 0;\n all.push(...candidates.map(r => ({...r, cuota_trabajador: cuotaPorPersona})));\n}\nconst byId = new Map();\nfor (const r of all) {\n if (!r.identidad) continue;\n if (!byId.has(r.identidad)) {\n byId.set(r.identidad, { ...r, archivos_planilla: r.archivo_planilla ? [r.archivo_planilla] : [], patronales: r.numero_patronal ? [r.numero_patronal] : [], recibos: r.recibo ? [r.recibo] : [] });\n } else {\n const ex = byId.get(r.identidad);\n if (r.nombre_planilla && !ex.nombre_planilla) ex.nombre_planilla = r.nombre_planilla;\n if (r.archivo_planilla && !ex.archivos_planilla.includes(r.archivo_planilla)) ex.archivos_planilla.push(r.archivo_planilla);\n if (r.numero_patronal && !ex.patronales.includes(r.numero_patronal)) ex.patronales.push(r.numero_patronal);\n if (r.recibo && !ex.recibos.includes(r.recibo)) ex.recibos.push(r.recibo);\n if (!ex.cuota_trabajador && r.cuota_trabajador) ex.cuota_trabajador = r.cuota_trabajador;\n }\n}\nconst registros = [...byId.values()].map(r => ({ identidad: r.identidad, nombre_planilla: r.nombre_planilla || '', archivos_planilla: r.archivos_planilla.join(' | '), numero_patronal: r.patronales.join(' | '), recibo: r.recibos.join(' | '), periodo: r.periodo || '', cuota_trabajador: money(r.cuota_trabajador) }));\nif (!registros.length) throw new Error('No pude extraer identidades de las planillas IHSS. Revisa que los PDF sean legibles y que no sean escaneos sin texto.');\nconst data = $getWorkflowStaticData('global');\ndata.hnCruceIHSSNomina = data.hnCruceIHSSNomina || {};\ndata.hnCruceIHSSNomina[runId] = data.hnCruceIHSSNomina[runId] || { ihss: [], nomina: [] };\ndata.hnCruceIHSSNomina[runId].ihss = registros;\ndata.hnCruceIHSSNomina[runId].ihssPlanillas = planillas;\ndata.hnCruceIHSSNomina[runId].duplicatePlanillas = duplicatePlanillas;\ndata.hnCruceIHSSNomina[runId].periodoMismatches = periodoMismatches;\nreturn [{ json: { runId, ihssCount: registros.length, planillasCount: planillas.length, duplicatePlanillasCount: duplicatePlanillas.length, periodoMismatchesCount: periodoMismatches.length, pdfFiles: [...new Set(registros.flatMap(r => String(r.archivos_planilla).split(' | ').filter(Boolean)))] } }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -1184, + 2624 + ], + "id": "62bf8bce-2475-4396-a5d9-b88099531f04", + "name": "Guardar IHSS en memoria" + }, + { + "parameters": { + "jsCode": "const base = $input.all()[0];\nconst bin = base.binary || {};\nconst candidates = [];\nfor (const [key, file] of Object.entries(bin)) {\n const fileName = String(file.fileName || key);\n const mime = String(file.mimeType || '').toLowerCase();\n const lower = fileName.toLowerCase();\n const isExcel = lower.endsWith('.xlsx') || lower.endsWith('.xls') || lower.endsWith('.csv') || mime.includes('spreadsheet') || mime.includes('excel') || key.toLowerCase().includes('nomina');\n if (isExcel && !lower.endsWith('.pdf')) candidates.push([key, file]);\n}\nif (!candidates.length) {\n throw new Error('No encontré el archivo de nómina. Debe ser .xlsx, .xls o .csv en el campo Nomina.');\n}\nif (candidates.length > 1) {\n // Prefer the binary field whose key includes Nomina; otherwise use the first Excel-like file.\n const idx = candidates.findIndex(([key]) => key.toLowerCase().includes('nomina'));\n const chosen = candidates[idx >= 0 ? idx : 0];\n candidates.length = 0;\n candidates.push(chosen);\n}\nconst [key, file] = candidates[0];\nconst data = $getWorkflowStaticData('global');\nif (data.hnCruceIHSSNomina?.[base.json.runId]) data.hnCruceIHSSNomina[base.json.runId].payrollFile = file.fileName || key;\nreturn [{ json: { runId: base.json.runId, mes: base.json.mes, anio: base.json.anio, payrollFile: file.fileName || key }, binary: { data: file } }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -1664, + 3056 + ], + "id": "3732ae39-2a05-4588-b619-a4704e70b1bd", + "name": "Preparar nómina" + }, + { + "parameters": { + "operation": "update", + "fileId": { + "__rl": true, + "value": "1LNJtm2ITumAFGQwWmHXpoeSOWXBXjdTDpjSBBwqnHd4", + "mode": "list", + "cachedResultName": "Nomina Honduras", + "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1LNJtm2ITumAFGQwWmHXpoeSOWXBXjdTDpjSBBwqnHd4/edit" + }, + "changeFileContent": true, + "options": {} + }, + "type": "n8n-nodes-base.googleDrive", + "typeVersion": 3, + "position": [ + -1424, + 3056 + ], + "id": "3755eba7-480b-4045-af77-760a6f5bf221", + "name": "Actualizar staging Nómina", + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + } + }, + { + "parameters": {}, + "type": "n8n-nodes-base.wait", + "typeVersion": 1.1, + "position": [ + -1200, + 3056 + ], + "id": "246710b3-b5f1-449e-9aae-33e7b2fee840", + "name": "Esperar conversión", + "webhookId": "wait-conversion-nomina" + }, + { + "parameters": { + "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.id || '1LNJtm2ITumAFGQwWmHXpoeSOWXBXjdTDpjSBBwqnHd4' }}?fields=sheets(properties(title,sheetId,hidden))", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + -976, + 3056 + ], + "id": "a87dbc44-c2d2-4214-8b76-fcb2e533fed6", + "name": "Listar hojas Nómina", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const init = $items('Inicializar memoria')[0].json;\nconst spreadsheetId = $json.spreadsheetId || $node['Actualizar staging Nómina'].json.id || '1LNJtm2ITumAFGQwWmHXpoeSOWXBXjdTDpjSBBwqnHd4';\nfunction norm(s) { return String(s || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]/g, ''); }\nconst ignoreBase = /DO NOT DELETE|NVScriptsProperties|AutoCrat|Resumen|Presupuesto|Retroactivo|Combustible|Combustibles/i;\nconst sheets = ($json.sheets || []).map(s => s.properties || {}).filter(p => p.title && !p.hidden);\nconst out = [];\nfor (const p of sheets) {\n const title = String(p.title || '');\n const n = norm(title);\n if (n === 'ihssrap' || (n.includes('ihss') && n.includes('rap'))) {\n out.push({ json: { runId: init.runId, spreadsheetId, sheetTitle: title, sheetId: p.sheetId, sheetKind: 'ihss_rap' } });\n continue;\n }\n if (!ignoreBase.test(title) && !/IHSS|RAP/i.test(title)) {\n out.push({ json: { runId: init.runId, spreadsheetId, sheetTitle: title, sheetId: p.sheetId, sheetKind: 'nomina_base' } });\n }\n}\nif (!out.length) throw new Error('No encontré hojas válidas de nómina. Revisa que el archivo tenga hojas de pago/base y la hoja IHSS-RAP.');\nreturn out;" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -736, + 3056 + ], + "id": "51341e3b-f409-475f-9792-072e6839d698", + "name": "Preparar hojas Nómina" + }, + { + "parameters": { + "options": {} + }, + "type": "n8n-nodes-base.splitInBatches", + "typeVersion": 3, + "position": [ + -512, + 3056 + ], + "id": "874a37ab-e31c-4edd-87ae-342650237ebc", + "name": "Loop hojas Nómina" + }, + { + "parameters": { + "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.spreadsheetId }}/values/{{ encodeURIComponent(\"'\" + String($json.sheetTitle).replace(/'/g, \"''\") + \"'!A1:AZ1000\") }}?majorDimension=ROWS&valueRenderOption=FORMATTED_VALUE&dateTimeRenderOption=FORMATTED_STRING", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + -240, + 3248 + ], + "id": "ecb62bd7-c4fc-4785-a790-15c0881f069c", + "name": "Leer hoja Nómina", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "function normHeader(s) { return String(s || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]/g, ''); }\nfunction clean(v) { return String(v ?? '').replace(/\\s+/g, ' ').trim(); }\nfunction cleanName(v) { return clean(v); }\nfunction plain(v) { return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase(); }\nfunction toNumber(v) {\n if (v === null || v === undefined || v === '') return 0;\n let s = String(v).replace(/LPS|L|Q|USD|\\$|,/gi, '').replace(/\\s/g, '').trim();\n if (/^\\((.*)\\)$/.test(s)) s = '-' + s.replace(/^\\(|\\)$/g, '');\n const n = Number(s);\n return Number.isFinite(n) ? n : 0;\n}\nfunction money(n) { return Number((Number(n) || 0).toFixed(2)); }\nfunction normalizeId(v) {\n if (v === undefined || v === null) return '';\n let s = String(v).trim();\n if (/^\\d+(\\.0+)?$/.test(s)) s = s.replace(/\\.0+$/, '');\n const d = s.replace(/\\D/g, '');\n if (d.length !== 13 || d === '0000000000000') return '';\n return d;\n}\nfunction findHeader(values) {\n const maxScan = Math.min(values.length, 40);\n for (let r = 0; r < maxScan; r++) {\n const row = values[r] || [];\n const headers = row.map(normHeader);\n const idCol = headers.findIndex(h => h === 'identidad' || h === 'identida' || h === 'cedula' || h === 'idcedula' || h === 'documento' || h === 'nodocumento');\n const nameCol = headers.findIndex(h => h === 'nombre' || h === 'nombrecompleto' || h === 'empleado' || h === 'beneficiario' || h === 'colaborador' || h.startsWith('nombre'));\n if (idCol >= 0 && nameCol >= 0) return { headerRow: r, idCol, nameCol };\n }\n return null;\n}\nfunction isSpecialContextLine(line) {\n return /visado|walmart|personal\\s+especial|personal\\s+para\\s+visado|personal\\s+temporal/i.test(line);\n}\nfunction extractPersonCount(line) {\n const m = plain(line).match(/(\\d{1,3})\\s*(personas?|colaboradores?|empleados?)/i);\n return m ? Number(m[1]) : 0;\n}\nfunction isLikelySpecialName(s) {\n const raw = clean(s);\n const p = plain(raw);\n if (!raw || raw.length < 3) return false;\n if (!/[A-Za-zÁÉÍÓÚÑáéíóúñ]/.test(raw)) return false;\n if (/empleado|patrono|total|planilla|nomina|diferencia|moneda|monto|ihss|rap|observacion|cuota|lps|salario/i.test(p)) return false;\n return true;\n}\nfunction getSpecialPairsFromRow(cells) {\n const pairs = [];\n // Formato observado en GLM Honduras: columnas J/K = referencia corta + monto.\n const candidateColumns = [9, 7, 8, 10, 11];\n for (const idx of candidateColumns) {\n const name = cells[idx] || '';\n const amount = toNumber(cells[idx + 1] || '');\n if (isLikelySpecialName(name) && amount > 0) {\n pairs.push({ referencia: name, monto: money(amount) });\n }\n }\n // Fallback: buscar cualquier par nombre/monto contiguo al final de la fila.\n for (let i = 0; i < cells.length - 1; i++) {\n const name = cells[i] || '';\n const amount = toNumber(cells[i + 1] || '');\n if (isLikelySpecialName(name) && amount > 0) {\n if (!pairs.some(p => plain(p.referencia) === plain(name) && p.monto === money(amount))) {\n pairs.push({ referencia: name, monto: money(amount) });\n }\n }\n }\n return pairs;\n}\n\nconst runId = $items('Inicializar memoria')[0].json.runId;\nlet sheetTitle = '';\nlet sheetKind = 'nomina_base';\ntry { sheetTitle = $node['Loop hojas Nómina'].json.sheetTitle || ''; sheetKind = $node['Loop hojas Nómina'].json.sheetKind || 'nomina_base'; } catch (e) {}\nsheetTitle = sheetTitle || 'Hoja de nómina';\nconst values = $json.values || [];\nconst data = $getWorkflowStaticData('global');\ndata.hnCruceIHSSNomina = data.hnCruceIHSSNomina || {};\ndata.hnCruceIHSSNomina[runId] = data.hnCruceIHSSNomina[runId] || { ihss: [], nomina: [], payrollIhssSummary: {}, payrollIhssBlocks: [], payrollSpecialEmployees: [] };\n\nif (sheetKind === 'ihss_rap') {\n const blocks = [];\n const specialEmployees = [];\n const summary = {\n totalNominaIhss: 0,\n ajusteIhss: 0,\n totalNominaIhssAjustado: 0,\n totalPlanillasIhss: 0,\n diferenciaIhss: 0,\n observacionIhss: '',\n ajusteIhssMotivo: ''\n };\n\n let inIhss = false;\n let specialContextActive = false;\n let specialContextLabel = '';\n let expectedSpecialCount = 0;\n let collectedInContext = 0;\n let specialBlockAmount = 0;\n\n for (const row of values) {\n const cells = (row || []).map(clean);\n const lineOriginal = cells.join(' ');\n const line = plain(lineOriginal);\n\n if (line.includes('ihss') && !line.includes('rap')) inIhss = true;\n if (line.includes('rap aportacion') || line.includes('rap reserva')) {\n inIhss = false;\n specialContextActive = false;\n }\n\n const concept = cells[1] || cells[0] || '';\n const amount = toNumber(cells[3] || cells[2] || '');\n const conceptNorm = plain(concept);\n\n if (conceptNorm.includes('total nomina')) {\n summary.totalNominaIhss = money(amount);\n specialContextActive = false;\n } else if (conceptNorm.includes('total planilla')) {\n summary.totalPlanillasIhss = money(amount);\n specialContextActive = false;\n } else if (inIhss && conceptNorm.includes('diferencia')) {\n summary.diferenciaIhss = money(amount);\n summary.observacionIhss = cells.slice(4).filter(Boolean).join(' ');\n specialContextActive = false;\n } else if (inIhss && concept && amount && !/ihss|moneda|empleado|total|planilla|diferencia/i.test(concept)) {\n blocks.push({ concepto: concept, moneda: cells[2] || '', monto: money(amount) });\n\n // PRODUCCIÓN: un ajuste de personal solo se suma si el bloque lo declara explícitamente\n // como visado/Walmart/personal especial. Esto evita inflar mayo por observaciones normales.\n if (isSpecialContextLine(lineOriginal) || isSpecialContextLine(concept)) {\n specialBlockAmount = money(specialBlockAmount + amount);\n specialContextActive = true;\n specialContextLabel = concept;\n expectedSpecialCount = extractPersonCount(lineOriginal) || extractPersonCount(concept) || 0;\n collectedInContext = 0;\n }\n }\n\n if (inIhss && isSpecialContextLine(lineOriginal)) {\n specialContextActive = true;\n specialContextLabel = concept || lineOriginal;\n expectedSpecialCount = extractPersonCount(lineOriginal) || expectedSpecialCount || 0;\n collectedInContext = 0;\n }\n\n // Caso Honduras marzo: empleados fuera de hojas Base incluidos explícitamente en IHSS-RAP\n // como \"Personal para visado de Walmart\". Solo se leen nombres/montos si ese contexto existe.\n if (inIhss && specialContextActive) {\n const pairs = getSpecialPairsFromRow(cells);\n for (const pair of pairs) {\n if (!specialEmployees.some(e => plain(e.referencia) === plain(pair.referencia) && e.monto === pair.monto)) {\n specialEmployees.push({ referencia: pair.referencia, monto: pair.monto, fuente: sheetTitle, contexto: specialContextLabel || 'Personal especial / visado Walmart' });\n collectedInContext++;\n }\n }\n if (expectedSpecialCount && collectedInContext >= expectedSpecialCount) {\n specialContextActive = false;\n }\n }\n }\n\n const specialTotal = money(specialEmployees.reduce((a, e) => a + Number(e.monto || 0), 0));\n\n // Regla final de producción:\n // - Si hay empleados especiales explícitos, se suma su total.\n // - Si no se leyeron empleados pero hay bloque explícito de visado/Walmart, se suma el monto del bloque.\n // - Si no hay contexto explícito, ajuste = 0.00.\n summary.ajusteIhss = money(specialTotal || specialBlockAmount || 0);\n summary.ajusteIhssMotivo = summary.ajusteIhss\n ? (specialEmployees.length ? 'Personal especial/visado detectado explícitamente en IHSS-RAP.' : 'Bloque especial/visado detectado explícitamente en IHSS-RAP.')\n : 'Sin ajuste especial explícito.';\n summary.totalNominaIhssAjustado = money(summary.totalNominaIhss + summary.ajusteIhss);\n\n // La diferencia productiva de IHSS debe usar TOTAL Nómina ajustado - Total Planillas.\n // Evita capturar diferencias de RAP Aportación / RAP Reserva cuando la hoja tiene varias secciones.\n if (summary.totalNominaIhssAjustado || summary.totalPlanillasIhss) {\n summary.diferenciaIhss = money(summary.totalNominaIhssAjustado - summary.totalPlanillasIhss);\n }\n if (!summary.observacionIhss && Math.abs(summary.diferenciaIhss || 0) > 0.05) {\n summary.observacionIhss = summary.ajusteIhss\n ? 'La hoja IHSS-RAP incluye ajuste de personal especial/visado. Diferencia restante corresponde a redondeo o ajuste menor.'\n : 'La hoja IHSS-RAP muestra diferencia en IHSS. Revisar soporte/observación interna de nómina con Administración.';\n }\n\n data.hnCruceIHSSNomina[runId].payrollIhssSummary = summary;\n data.hnCruceIHSSNomina[runId].payrollIhssBlocks = blocks;\n data.hnCruceIHSSNomina[runId].payrollSpecialEmployees = specialEmployees;\n return [{ json: { runId, sheetTitle, sheetKind, identitiesFound: 0, ihssSummaryFound: Boolean(summary.totalNominaIhss || summary.totalPlanillasIhss), ajusteIhss: summary.ajusteIhss, specialEmployeesFound: specialEmployees.length } }];\n}\n\nconst header = findHeader(values);\nconst records = [];\nif (header) {\n for (let r = header.headerRow + 1; r < values.length; r++) {\n const row = values[r] || [];\n const identidad = normalizeId(row[header.idCol]);\n if (!identidad) continue;\n const nombre = cleanName(row[header.nameCol]);\n if (!nombre || /^total/i.test(nombre)) continue;\n records.push({ identidad, nombre_nomina: nombre, hoja_nomina: sheetTitle });\n }\n}\ndata.hnCruceIHSSNomina[runId].nomina.push(...records);\nreturn [{ json: { runId, sheetTitle, sheetKind, identitiesFound: records.length, headerDetected: Boolean(header), headerRow: header ? header.headerRow + 1 : '' } }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 0, + 3248 + ], + "id": "492be91a-d0b4-4d39-9304-94f784f3e45b", + "name": "Acumular identidades Nómina" + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 528, + 2896 + ], + "id": "30adc4e9-78af-4f92-9bd8-bb4d0280deb8", + "name": "Esperar ramas" + }, + { + "parameters": { + "jsCode": "function clean(s) { return String(s ?? '').replace(/\\s+/g, ' ').trim(); }\nfunction money(n) { return Number((Number(n) || 0).toFixed(2)); }\nfunction fmt(n) { return Number(n || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }\nfunction normalizeId(v) {\n const d = String(v ?? '').replace(/\\D/g, '');\n if (d.length !== 13 || d === '0000000000000') return '';\n return d;\n}\nfunction plain(s) { return clean(s).normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase(); }\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 BAMBOO_TOKEN_EQUIVALENCES = new Map([\n ['VASQUEZ', 'VAZQUEZ']\n]);\nfunction canonicalBambooName(s) {\n return normalizeName(s)\n .split(' ')\n .map(token => BAMBOO_TOKEN_EQUIVALENCES.get(token) || token)\n .join(' ');\n}\nfunction bambooTokens(name) {\n const stop = new Set(['DE','DEL','LA','LAS','LOS','Y','DA','DAS','DO','VDA']);\n return canonicalBambooName(name).split(' ').filter(t => t.length > 1 && !stop.has(t));\n}\nfunction bambooNameScore(a, b) {\n const A = bambooTokens(a);\n const B = bambooTokens(b);\n if (!A.length || !B.length) return 0;\n const setB = new Set(B);\n const shared = A.filter(token => setB.has(token)).length;\n if (shared < 2) return 0;\n return shared / Math.min(A.length, B.length);\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}\nfunction buildBambooNameIndex(rows) {\n const exact = new Map();\n const byToken = new Map();\n for (const row of rows || []) {\n const canonical = canonicalBambooName(row.nombre_bamboo || '');\n if (!canonical) continue;\n if (!exact.has(canonical)) exact.set(canonical, []);\n exact.get(canonical).push(row);\n for (const token of new Set(bambooTokens(canonical))) {\n if (!byToken.has(token)) byToken.set(token, []);\n byToken.get(token).push(row);\n }\n }\n return { exact, byToken };\n}\nfunction indexedBambooCandidates(name, index) {\n const counts = new Map();\n for (const token of new Set(bambooTokens(name))) {\n for (const row of index.byToken.get(token) || []) {\n counts.set(row, (counts.get(row) || 0) + 1);\n }\n }\n return [...counts.entries()]\n .filter(([, shared]) => shared >= 2)\n .map(([row]) => row);\n}\nfunction matchBambooByName(name, index) {\n const target = canonicalBambooName(name);\n if (!target) return { record:null, score:0, method:'sin nombre' };\n\n const exact = [...(index.exact.get(target) || [])];\n if (exact.length) {\n exact.sort((a,b) => Number(isBambooActive(b.estado_bamboo)) - Number(isBambooActive(a.estado_bamboo)));\n return { record:exact[0], score:1, method:'nombre exacto' };\n }\n\n const candidates = indexedBambooCandidates(name, index)\n .map(record => ({ record, score:bambooNameScore(name, record.nombre_bamboo || '') }))\n .filter(item => item.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 bestTokens = new Set(bambooTokens(best.record.nombre_bamboo || ''));\n const sharedTokens = bambooTokens(name).filter(token => bestTokens.has(token)).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}\nfunction bestGroupName(group) {\n const allNames = [...(group?.names || [])].map(clean).filter(Boolean);\n const preferred = allNames.filter(name => !/^IHSS-RAP:/i.test(name));\n const names = preferred.length ? preferred : allNames;\n names.sort((a,b) => bambooTokens(b).length - bambooTokens(a).length || b.length - a.length);\n return names[0] || '';\n}\nfunction dedupe(records, source) {\n const map = new Map();\n for (const r of records || []) {\n const id = normalizeId(r.identidad);\n if (!id) continue;\n if (!map.has(id)) map.set(id, { identidad: id, names: new Set(), sheets: new Set(), files: new Set(), patronales: new Set(), recibos: new Set(), cuota_trabajador: 0, raw: [] });\n const x = map.get(id);\n if (source === 'ihss') {\n if (clean(r.nombre_planilla)) x.names.add(clean(r.nombre_planilla));\n if (clean(r.archivos_planilla)) String(r.archivos_planilla).split(' | ').forEach(v => v && x.files.add(v));\n if (clean(r.numero_patronal)) String(r.numero_patronal).split(' | ').forEach(v => v && x.patronales.add(v));\n if (clean(r.recibo)) String(r.recibo).split(' | ').forEach(v => v && x.recibos.add(v));\n if (r.cuota_trabajador) x.cuota_trabajador = money(r.cuota_trabajador);\n } else {\n if (clean(r.nombre_nomina)) x.names.add(clean(r.nombre_nomina));\n if (clean(r.hoja_nomina)) x.sheets.add(clean(r.hoja_nomina));\n }\n x.raw.push(r);\n }\n return map;\n}\nfunction referenceMatchesName(reference, fullName) {\n const ref = plain(reference);\n const name = plain(fullName);\n if (!ref || !name) return false;\n const refTokens = ref.split(/\\s+/).filter(t => t.length >= 3 && !['para','personal','visado','walmart'].includes(t));\n if (!refTokens.length) return false;\n return refTokens.every(t => name.includes(t) || name.includes(t.replace(/ck/g, 'k')) || name.includes(t.replace(/k/g, 'ck')));\n}\nconst init = $items('Inicializar memoria')[0].json;\nconst runId = init.runId;\nconst data = $getWorkflowStaticData('global');\nconst slot = data.hnCruceIHSSNomina?.[runId];\nif (!slot) throw new Error(`No encontré memoria temporal para la ejecución ${runId}.`);\nconst ihss = dedupe(slot.ihss, 'ihss');\nconst nomina = dedupe(slot.nomina, 'nomina');\nif (ihss.size === 0) throw new Error('No se extrajo ninguna identidad desde las planillas IHSS.');\nif (nomina.size === 0) throw new Error('No se extrajo ninguna identidad desde la nómina.');\nconst bamboo = slot.bamboo || [];\nif (!bamboo.length) throw new Error('No se recibieron empleados de BambooHR para completar el cruce de identidad.');\nconst bambooNameIndex = buildBambooNameIndex(bamboo);\nconst bambooMatchCache = new Map();\nfunction getBambooMatch(name) {\n const key = canonicalBambooName(name);\n if (!bambooMatchCache.has(key)) bambooMatchCache.set(key, matchBambooByName(name, bambooNameIndex));\n return bambooMatchCache.get(key);\n}\nfunction bambooInfoForGroup(group) {\n const nombre = bestGroupName(group);\n const match = getBambooMatch(nombre);\n return {\n nombre,\n match,\n esta_bamboo: match.record ? 'Sí' : 'No',\n fecha_ingreso: match.record?.fecha_ingreso || '',\n estado_bamboo: bambooStatusLabel(match.record?.estado_bamboo || ''),\n nombre_bamboo: match.record?.nombre_bamboo || ''\n };\n}\n\n// Producción Honduras: resolver empleados especiales de IHSS-RAP (ej. visado Walmart) por nombre contra planilla IHSS.\n// Esto evita que aparezcan como \"Solo IHSS\" cuando la nómina los trae solo en el resumen IHSS-RAP sin identidad.\nconst specialEmployees = slot.payrollSpecialEmployees || [];\nconst resolvedSpecialEmployees = [];\nconst unresolvedSpecialEmployees = [];\nfor (const sp of specialEmployees) {\n const candidates = [...ihss.values()].filter(r => {\n if (nomina.has(r.identidad)) return false;\n const names = [...r.names];\n return names.some(n => referenceMatchesName(sp.referencia, n));\n });\n if (candidates.length === 1) {\n const r = candidates[0];\n nomina.set(r.identidad, {\n identidad: r.identidad,\n names: new Set([...r.names, `IHSS-RAP: ${sp.referencia}`]),\n sheets: new Set([sp.fuente || 'IHSS-RAP', 'Personal especial / visado Walmart']),\n files: new Set(), patronales: new Set(), recibos: new Set(), cuota_trabajador: 0,\n raw: [{ identidad: r.identidad, nombre_nomina: [...r.names][0] || sp.referencia, hoja_nomina: sp.fuente || 'IHSS-RAP', fuente_especial: 'Personal especial / visado Walmart', monto: sp.monto }]\n });\n resolvedSpecialEmployees.push({ referencia: sp.referencia, identidad: r.identidad, nombre: [...r.names][0] || '', monto: sp.monto, estado: 'Resuelto por nombre contra IHSS' });\n } else {\n unresolvedSpecialEmployees.push({ referencia: sp.referencia, monto: sp.monto, coincidencias: candidates.length });\n }\n}\n\n// Fallback de producción: si IHSS-RAP trae un bloque especial con monto, pero no se pudieron leer\n// los nombres de las columnas internas, se resuelve contra los empleados que están solo en IHSS.\n// Solo se aplica cuando la cantidad faltante coincide con el monto especial / cuota IHSS por empleado.\nconst payrollAdjustmentForSpecial = money((slot.payrollIhssSummary || {}).ajusteIhss || 0);\nif (!resolvedSpecialEmployees.length && payrollAdjustmentForSpecial > 0.05) {\n const pendingIHSS = [...ihss.values()].filter(r => !nomina.has(r.identidad));\n const avgCuota = pendingIHSS.length\n ? money(pendingIHSS.reduce((a, r) => a + Number(r.cuota_trabajador || 0), 0) / pendingIHSS.length)\n : 0;\n const expectedSpecialCount = avgCuota ? Math.round(payrollAdjustmentForSpecial / avgCuota) : 0;\n const amountMatches = avgCuota && Math.abs(payrollAdjustmentForSpecial - money(avgCuota * pendingIHSS.length)) <= 1.00;\n if (pendingIHSS.length > 0 && pendingIHSS.length === expectedSpecialCount && amountMatches) {\n for (const r of pendingIHSS) {\n const nm = [...r.names][0] || '';\n nomina.set(r.identidad, {\n identidad: r.identidad,\n names: new Set([...r.names, 'IHSS-RAP: Personal especial / visado Walmart']),\n sheets: new Set(['IHSS-RAP', 'Personal especial / visado Walmart']),\n files: new Set(), patronales: new Set(), recibos: new Set(), cuota_trabajador: 0,\n raw: [{ identidad: r.identidad, nombre_nomina: nm, hoja_nomina: 'IHSS-RAP', fuente_especial: 'Personal especial / visado Walmart', monto: r.cuota_trabajador }]\n });\n resolvedSpecialEmployees.push({ referencia: 'Personal especial / visado Walmart', identidad: r.identidad, nombre: nm, monto: r.cuota_trabajador, estado: 'Resuelto por monto contra empleados solo IHSS' });\n }\n }\n}\n\nconst nominaBamboo = [...nomina.values()].map(group => ({\n identidad: group.identidad,\n hojas: [...group.sheets].join(' | '),\n ...bambooInfoForGroup(group)\n}));\nconst nominaBambooById = new Map(nominaBamboo.map(row => [row.identidad, row]));\nconst nominaSinBamboo = nominaBamboo.filter(row => !row.match.record);\n\nconst soloIHSS = [...ihss.values()].filter(r => !nomina.has(r.identidad));\nconst soloNomina = [...nomina.values()].filter(r => !ihss.has(r.identidad));\nconst ambas = [...ihss.keys()].filter(id => nomina.has(id)).length;\nconst diferenciasIdentidad = soloIHSS.length + soloNomina.length;\nconst planillas = slot.ihssPlanillas || [];\nconst duplicatePlanillas = slot.duplicatePlanillas || [];\nconst periodoMismatches = slot.periodoMismatches || [];\nconst pdfTrabajadores = planillas.reduce((a,p) => a + Number(p.trabajadores || 0), 0);\nconst pdfSalario = money(planillas.reduce((a,p) => a + Number(p.salario_reportado || 0), 0));\nconst pdfCuotaTrabajador = money(planillas.reduce((a,p) => a + Number(p.cuota_trabajador || 0), 0));\nconst pdfCuotaPatrono = money(planillas.reduce((a,p) => a + Number(p.cuota_patrono || 0), 0));\nconst pdfTotalPlanilla = money(planillas.reduce((a,p) => a + Number(p.total_planilla || 0), 0));\nconst payroll = slot.payrollIhssSummary || {};\nconst payrollBaseIhss = money(payroll.totalNominaIhss || 0);\nconst payrollAdjustmentIhss = money(payroll.ajusteIhss || 0);\nconst payrollIhss = money(payroll.totalNominaIhssAjustado || (payrollBaseIhss + payrollAdjustmentIhss));\nconst diffMontos = money(payrollIhss - pdfCuotaTrabajador);\nlet resultadoMontos = 'SIN DIFERENCIAS';\nlet lecturaMontos = 'El total descontado en nómina por IHSS coincide con la cuota trabajador de las planillas IHSS.';\nif (Math.abs(diffMontos) > 0.05) {\n resultadoMontos = 'REVISAR DIFERENCIA';\n lecturaMontos = diffMontos > 0 ? 'La nómina descontó más IHSS que lo reportado como cuota trabajador en las planillas.' : 'La nómina descontó menos IHSS que lo reportado como cuota trabajador en las planillas.';\n}\nfunction titleCaseName(s) {\n return String(s || '')\n .toLowerCase()\n .replace(/\\b([a-záéíóúñü])/gi, c => c.toUpperCase())\n .replace(/\\bDe\\b/g, 'de')\n .replace(/\\bDel\\b/g, 'del')\n .replace(/\\bLa\\b/g, 'la');\n}\nfunction inferEmployeeFromObservation(obs) {\n const text = clean(obs);\n const patterns = [\n /(?:a|de)\\s+([A-ZÁÉÍÓÚÑ][A-Za-zÁÉÍÓÚÑáéíóúñü\\s]{6,80}?)(?:\\s+en\\s+|\\s+por\\s+|\\.|,|$)/i,\n /emplead[oa]\\s+([A-ZÁÉÍÓÚÑ][A-Za-zÁÉÍÓÚÑáéíóúñü\\s]{6,80}?)(?:\\s+en\\s+|\\s+por\\s+|\\.|,|$)/i\n ];\n for (const p of patterns) {\n const m = text.match(p);\n if (m && m[1]) {\n const name = clean(m[1]).replace(/\\s+(1Q|2Q|Colgate|Motorola|Nestle|Whirlpool|GLM).*$/i, '');\n if (name.length >= 6) return titleCaseName(name);\n }\n }\n return '';\n}\nconst observacionMonto = payroll.observacionIhss || '';\nconst empleadoObs = inferEmployeeFromObservation(observacionMonto);\nconst casosMonto = [];\nif (Math.abs(diffMontos) > 0.05) {\n casosMonto.push({\n tipo: Math.abs(diffMontos) <= 1 ? 'Diferencia menor / redondeo' : (diffMontos > 0 ? 'Cobro de más en nómina' : 'Cobro de menos en nómina'),\n empleado: Math.abs(diffMontos) <= 1 ? 'Redondeo total' : (empleadoObs || 'No identificado automáticamente'),\n monto: Math.abs(diffMontos),\n fuente: observacionMonto ? 'Hoja IHSS-RAP' : 'Diferencia total',\n observacion: observacionMonto || 'La nómina y las planillas tienen diferencia, pero la hoja IHSS-RAP no trae una observación con el empleado específico.',\n accion: Math.abs(diffMontos) <= 1 ? 'Aceptar como diferencia menor si Administración confirma redondeo.' : (empleadoObs ? 'Revisar este caso con Nómina/Administración.' : 'Revisar detalle interno de nómina para identificar el colaborador.')\n });\n}\nconst identidadRows = [];\nidentidadRows.push(['Cruce de identidad - Honduras']);\nidentidadRows.push([`Período: ${init.periodoLabel || `${init.mes}/${init.anio}`}`]);\nidentidadRows.push(['Resultado', diferenciasIdentidad ? 'REVISAR DIFERENCIAS' : 'SIN DIFERENCIAS']);\nidentidadRows.push(['']);\nidentidadRows.push(['Resumen', 'Cantidad']);\nidentidadRows.push(['Identidades únicas en planillas IHSS', ihss.size]);\nidentidadRows.push(['Identidades únicas en nómina', nomina.size]);\nidentidadRows.push(['Coinciden en ambas', ambas]);\nidentidadRows.push(['Diferencias encontradas', diferenciasIdentidad]);\nidentidadRows.push(['Personal especial IHSS-RAP resuelto', resolvedSpecialEmployees.length]);\nidentidadRows.push(['']);\nidentidadRows.push(['Lectura rápida']);\nidentidadRows.push([diferenciasIdentidad ? '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 planillas IHSS.']);\nidentidadRows.push(['']);\nidentidadRows.push(['Personal especial resuelto desde IHSS-RAP']);\nidentidadRows.push(['Referencia', 'Identidad resuelta', 'Nombre en IHSS', 'Monto', 'Estado']);\nif (resolvedSpecialEmployees.length) for (const r of resolvedSpecialEmployees) identidadRows.push([r.referencia, r.identidad, r.nombre, r.monto, r.estado]); else identidadRows.push(['No aplica', '', '', '', '']);\nif (unresolvedSpecialEmployees.length) {\n identidadRows.push(['']);\n identidadRows.push(['Personal especial NO resuelto automáticamente']);\n identidadRows.push(['Referencia', 'Monto', 'Coincidencias posibles']);\n for (const r of unresolvedSpecialEmployees) identidadRows.push([r.referencia, r.monto, r.coincidencias]);\n}\nidentidadRows.push(['']);\nidentidadRows.push(['Empleados en planilla IHSS que NO aparecen en nómina']);\nidentidadRows.push(['Identidad', 'Nombre en planilla IHSS', 'Número patronal', 'Archivo planilla', 'Qué significa', 'Está en Bamboo?', 'Fecha de Ingreso', 'Activo/Inactivo', 'Nombre en BambooHR']);\nif (soloIHSS.length) {\n for (const r of soloIHSS) {\n const info = bambooInfoForGroup(r);\n identidadRows.push([\n r.identidad,\n [...r.names].join(' | '),\n [...r.patronales].join(' | '),\n [...r.files].join(' | '),\n 'Está en IHSS, pero no aparece en la nómina cargada.',\n info.esta_bamboo,\n info.fecha_ingreso,\n info.estado_bamboo,\n info.nombre_bamboo\n ]);\n }\n} else {\n identidadRows.push(['Sin diferencias', '', '', '', '', '', '', '', '']);\n}\nidentidadRows.push(['']);\nidentidadRows.push(['Empleados en nómina que NO aparecen en planilla IHSS']);\nidentidadRows.push(['Identidad', 'Nombre en nómina', 'Hoja nómina', 'Qué significa', 'Está en Bamboo?', 'Fecha de Ingreso', 'Activo/Inactivo', 'Nombre en BambooHR']);\nif (soloNomina.length) {\n for (const r of soloNomina) {\n const info = nominaBambooById.get(r.identidad) || bambooInfoForGroup(r);\n identidadRows.push([\n r.identidad,\n [...r.names].join(' | '),\n [...r.sheets].join(' | '),\n 'Está en nómina, pero no aparece en las planillas IHSS cargadas.',\n info.esta_bamboo,\n info.fecha_ingreso,\n info.estado_bamboo,\n info.nombre_bamboo\n ]);\n }\n} else {\n identidadRows.push(['Sin diferencias', '', '', '', '', '', '', '']);\n}\nif (duplicatePlanillas.length) {\n identidadRows.push(['']);\n identidadRows.push(['Planillas duplicadas ignoradas']);\n identidadRows.push(['Archivo duplicado', 'Archivo original', 'Número patronal', 'Recibo', 'Período', 'Cuota trabajador']);\n for (const d of duplicatePlanillas) identidadRows.push([d.archivo_duplicado, d.archivo_original, d.numero_patronal, d.recibo, d.periodo, d.cuota_trabajador]);\n}\nif (periodoMismatches.length) {\n identidadRows.push(['']);\n identidadRows.push(['Alertas de período']);\n identidadRows.push(['Archivo', 'Período detectado', 'Período esperado']);\n for (const p of periodoMismatches) identidadRows.push([p.archivo_planilla, p.periodo_detectado, p.periodo_esperado]);\n}\n\nconst nominaSinBambooRows = [];\nnominaSinBambooRows.push(['Nómina sin BambooHR']);\nnominaSinBambooRows.push([`Período: ${init.periodoLabel || `${init.mes}/${init.anio}`}`]);\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', nominaBamboo.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 la nómina de Honduras, 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(['Identidad', 'Nombre en nómina', 'Hoja(s) nómina', 'Está en Bamboo?', 'Fecha de Ingreso', 'Activo/Inactivo', 'Nombre en BambooHR', 'Qué significa']);\nif (nominaSinBamboo.length) {\n for (const row of nominaSinBamboo.sort((a,b) => String(a.nombre).localeCompare(String(b.nombre)))) {\n nominaSinBambooRows.push([\n row.identidad,\n row.nombre,\n row.hojas,\n 'No',\n '',\n '',\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\nconst montosRows = [];\nmontosRows.push(['Cruce de montos IHSS - Honduras']);\nmontosRows.push([`Período: ${init.periodoLabel || `${init.mes}/${init.anio}`}`]);\nmontosRows.push(['Resultado', resultadoMontos]);\nmontosRows.push(['']);\nmontosRows.push(['Resumen', 'Monto']);\nmontosRows.push(['IHSS descontado en nómina base', payrollBaseIhss]);\nmontosRows.push(['Ajuste IHSS personal especial / visado', payrollAdjustmentIhss]);\nmontosRows.push(['IHSS descontado en nómina ajustado', payrollIhss]);\nmontosRows.push(['Cuota trabajador según planillas IHSS', pdfCuotaTrabajador]);\nmontosRows.push(['Diferencia', diffMontos]);\nmontosRows.push(['']);\nmontosRows.push(['Lectura rápida']);\nmontosRows.push([lecturaMontos]);\nmontosRows.push(['']);\nmontosRows.push(['Observación en nómina']);\nmontosRows.push([payroll.observacionIhss || (Math.abs(diffMontos) > 0.05 ? 'No se encontró observación en la hoja IHSS-RAP. Revisar con Administración.' : 'Sin observación necesaria.')]);\nmontosRows.push(['']);\nmontosRows.push(['Casos con diferencia']);\nmontosRows.push(['Tipo', 'Empleado / referencia', 'Monto diferencia', 'Fuente', 'Acción sugerida']);\nif (casosMonto.length) {\n for (const c of casosMonto) montosRows.push([c.tipo, c.empleado, c.monto, c.fuente, c.accion]);\n} else {\n montosRows.push(['Sin diferencias', '', '', '', '']);\n}\nmontosRows.push(['']);\nmontosRows.push(['Detalle / observación del caso']);\nif (casosMonto.length) {\n for (const c of casosMonto) montosRows.push([c.observacion]);\n} else {\n montosRows.push(['No aplica.']);\n}\nmontosRows.push(['']);\nmontosRows.push(['Qué se compara']);\nmontosRows.push(['Nómina', 'Columna / dato', 'Explicación']);\nmontosRows.push(['Nómina Honduras', 'Hoja IHSS-RAP > TOTAL Nomina + ajustes especiales', 'Total mensual descontado al colaborador por IHSS, incluyendo personal especial si está documentado en IHSS-RAP.']);\nmontosRows.push(['Planillas IHSS', 'CUOTA TRABAJADOR', 'Total que IHSS espera cobrar a los colaboradores. No incluye cuota patronal.']);\nconst resumenRows = [];\nresumenRows.push(['Resumen de planillas IHSS - Honduras']);\nresumenRows.push([`Período: ${init.periodoLabel || `${init.mes}/${init.anio}`}`]);\nresumenRows.push(['']);\nresumenRows.push(['Archivo PDF', 'Número patronal', 'Recibo', 'Trabajadores', 'Salario reportado', 'Cuota trabajador', 'Cuota patrono', 'Total planilla']);\nfor (const p of planillas) resumenRows.push([p.archivo_planilla, p.numero_patronal, p.recibo, p.trabajadores, p.salario_reportado, p.cuota_trabajador, p.cuota_patrono, p.total_planilla]);\nresumenRows.push(['TOTAL', '', '', pdfTrabajadores, pdfSalario, pdfCuotaTrabajador, pdfCuotaPatrono, pdfTotalPlanilla]);\nif (duplicatePlanillas.length) {\n resumenRows.push(['']);\n resumenRows.push(['Duplicados ignorados']);\n resumenRows.push(['Archivo duplicado', 'Archivo original', 'Número patronal', 'Recibo', 'Cuota trabajador']);\n for (const d of duplicatePlanillas) resumenRows.push([d.archivo_duplicado, d.archivo_original, d.numero_patronal, d.recibo, d.cuota_trabajador]);\n}\nconst detalleRows = [];\ndetalleRows.push(['Detalle IHSS según nómina - Honduras']);\ndetalleRows.push([`Período: ${init.periodoLabel || `${init.mes}/${init.anio}`}`]);\ndetalleRows.push(['']);\ndetalleRows.push(['Concepto / bloque', 'Moneda', 'Monto IHSS nómina']);\nfor (const b of (slot.payrollIhssBlocks || [])) detalleRows.push([b.concepto, b.moneda, b.monto]);\ndetalleRows.push(['TOTAL Nómina IHSS base', 'Lps', payrollBaseIhss]);\ndetalleRows.push(['Ajuste personal especial / visado', 'Lps', payrollAdjustmentIhss]);\ndetalleRows.push(['Motivo ajuste especial', '', payroll.ajusteIhssMotivo || (payrollAdjustmentIhss ? 'Ajuste especial detectado.' : 'Sin ajuste especial explícito.')]);\ndetalleRows.push(['TOTAL Nómina IHSS ajustado', 'Lps', payrollIhss]);\ndetalleRows.push(['Total Planillas según nómina', 'Lps', money(payroll.totalPlanillasIhss || 0)]);\ndetalleRows.push(['Diferencia según nómina ajustada', 'Lps', diffMontos]);\ndetalleRows.push(['Observación', '', payroll.observacionIhss || '']);\nif (specialEmployees.length) {\n detalleRows.push(['']);\n detalleRows.push(['Personal especial detectado en IHSS-RAP']);\n detalleRows.push(['Referencia', 'Monto', 'Identidad resuelta', 'Nombre resuelto']);\n for (const sp of specialEmployees) {\n const res = resolvedSpecialEmployees.find(r => plain(r.referencia) === plain(sp.referencia));\n detalleRows.push([sp.referencia, sp.monto, res?.identidad || '', res?.nombre || '']);\n }\n}\nconst summary = { ihss: ihss.size, nomina: nomina.size, ambas, soloIHSS: soloIHSS.length, soloNomina: soloNomina.length, diferenciasIdentidad, empleadosBamboo: bamboo.length, nominaBambooEvaluados: nominaBamboo.length, nominaSinBamboo: nominaSinBamboo.length, pdfFiles: slot.pdfFiles || [], payrollFile: slot.payrollFile || '', planillas: planillas.length, duplicatePlanillas: duplicatePlanillas.length, duplicatePlanillasDetalle: duplicatePlanillas, periodoMismatches: periodoMismatches.length, periodoMismatchesDetalle: periodoMismatches, pdfTrabajadores, pdfCuotaTrabajador, pdfCuotaPatrono, pdfTotalPlanilla, payrollBaseIhss, payrollAdjustmentIhss, payrollIhss, diffMontos, resultadoMontos, observacionIhss: payroll.observacionIhss || '', ajusteIhssMotivo: payroll.ajusteIhssMotivo || '', specialEmployees, resolvedSpecialEmployees, unresolvedSpecialEmployees, casosMonto, casosMontoCount: casosMonto.length, casoMontoPrincipal: casosMonto[0] || null };\ndelete data.hnCruceIHSSNomina[runId];\nreturn [{ json: { runId, reportTitle: init.reportTitle, emailTo: init.emailTo || 'iaracena@gomezleemarketing.com', identidadRows, nominaSinBambooRows, montosRows, resumenRows, detalleRows, summary, rowCounts: { identidad: identidadRows.length, nominaSinBamboo: nominaSinBambooRows.length, montos: montosRows.length, resumen: resumenRows.length, detalle: detalleRows.length } } }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 752, + 2896 + ], + "id": "a511235b-ed04-4a0a-80ec-7637d18b1fb9", + "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 Identidad' } }, { properties: { title: 'Nómina sin BambooHR' } }, { properties: { title: 'Cruce Montos IHSS' } }, { properties: { title: 'Resumen Planillas' } }, { properties: { title: 'Detalle Nómina IHSS' } } ] }) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 992, + 2896 + ], + "id": "8e4dfca4-24d3-4ee9-8c8d-65ad2fa9b6b0", + "name": "Crear Google Sheet Reporte", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "method": "POST", + "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.spreadsheetId }}/values:batchUpdate", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ valueInputOption: 'USER_ENTERED', data: [ { range: 'Cruce Identidad!A1', majorDimension: 'ROWS', values: $('Construir reporte producción').item.json.identidadRows }, { range: 'Nómina sin BambooHR!A1', majorDimension: 'ROWS', values: $('Construir reporte producción').item.json.nominaSinBambooRows }, { range: 'Cruce Montos IHSS!A1', majorDimension: 'ROWS', values: $('Construir reporte producción').item.json.montosRows }, { range: 'Resumen Planillas!A1', majorDimension: 'ROWS', values: $('Construir reporte producción').item.json.resumenRows }, { range: 'Detalle Nómina IHSS!A1', majorDimension: 'ROWS', values: $('Construir reporte producción').item.json.detalleRows } ] }) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 1216, + 2896 + ], + "id": "a0b3c6f4-538f-43b3-b7b0-7ff75becbad7", + "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 id = title => (sheets.find(s => s.properties.title === title) || {}).properties.sheetId;\n const requests = [];\n const titles = ['Cruce Identidad','Nómina sin BambooHR','Cruce Montos IHSS','Resumen Planillas','Detalle Nómina IHSS'];\n for (const title of titles) {\n const sid = id(title);\n if (sid === undefined) continue;\n requests.push(\n { repeatCell: { range: { sheetId: sid, startRowIndex: 0, endRowIndex: 1 }, cell: { userEnteredFormat: { textFormat: { bold: true, fontSize: 14 }, backgroundColor: { red: 0.91, green: 0.96, blue: 0.91 } } }, fields: 'userEnteredFormat.textFormat,userEnteredFormat.backgroundColor' } },\n { autoResizeDimensions: { dimensions: { sheetId: sid, dimension: 'COLUMNS', startIndex: 0, endIndex: 10 } } }\n );\n }\n const sidId = id('Cruce Identidad');\n if (sidId !== undefined) {\n requests.push({ repeatCell: { range: { sheetId: sidId, startRowIndex: 4, endRowIndex: 5 }, cell: { userEnteredFormat: { textFormat: { bold: true }, backgroundColor: { red: 0.89, green: 0.95, blue: 1 } } }, fields: 'userEnteredFormat.textFormat,userEnteredFormat.backgroundColor' } });\n requests.push({ repeatCell: { range: { sheetId: sidId, startRowIndex: 14, endRowIndex: 15 }, cell: { userEnteredFormat: { textFormat: { bold: true }, backgroundColor: { red: 0.89, green: 0.95, blue: 1 } } }, fields: 'userEnteredFormat.textFormat,userEnteredFormat.backgroundColor' } });\n requests.push({ repeatCell: { range: { sheetId: sidId, startRowIndex: 19, endRowIndex: 20 }, cell: { userEnteredFormat: { textFormat: { bold: true }, backgroundColor: { red: 0.89, green: 0.95, blue: 1 } } }, fields: 'userEnteredFormat.textFormat,userEnteredFormat.backgroundColor' } });\n }\n const sidMontos = id('Cruce Montos IHSS');\n if (sidMontos !== undefined) {\n requests.push({ repeatCell: { range: { sheetId: sidMontos, startRowIndex: 4, endRowIndex: 8, startColumnIndex: 1, endColumnIndex: 2 }, cell: { userEnteredFormat: { numberFormat: { type: 'NUMBER', pattern: '#,##0.00' } } }, fields: 'userEnteredFormat.numberFormat' } });\n requests.push({ repeatCell: { range: { sheetId: sidMontos, startRowIndex: 4, endRowIndex: 5 }, cell: { userEnteredFormat: { textFormat: { bold: true }, backgroundColor: { red: 0.89, green: 0.95, blue: 1 } } }, fields: 'userEnteredFormat.textFormat,userEnteredFormat.backgroundColor' } });\n requests.push({ repeatCell: { range: { sheetId: sidMontos, startRowIndex: 15, endRowIndex: 17 }, cell: { userEnteredFormat: { textFormat: { bold: true }, backgroundColor: { red: 1, green: 0.95, blue: 0.85 } } }, fields: 'userEnteredFormat.textFormat,userEnteredFormat.backgroundColor' } });\n requests.push({ repeatCell: { range: { sheetId: sidMontos, startRowIndex: 16, startColumnIndex: 2, endColumnIndex: 3 }, cell: { userEnteredFormat: { numberFormat: { type: 'NUMBER', pattern: '#,##0.00' } } }, fields: 'userEnteredFormat.numberFormat' } });\n }\n const sidResumen = id('Resumen Planillas');\n if (sidResumen !== undefined) {\n requests.push({ repeatCell: { range: { sheetId: sidResumen, startRowIndex: 3, endRowIndex: 4 }, cell: { userEnteredFormat: { textFormat: { bold: true }, backgroundColor: { red: 0.89, green: 0.95, blue: 1 } } }, fields: 'userEnteredFormat.textFormat,userEnteredFormat.backgroundColor' } });\n requests.push({ repeatCell: { range: { sheetId: sidResumen, startRowIndex: 4, startColumnIndex: 4, endColumnIndex: 8 }, cell: { userEnteredFormat: { numberFormat: { type: 'NUMBER', pattern: '#,##0.00' } } }, fields: 'userEnteredFormat.numberFormat' } });\n }\n const sidDetalle = id('Detalle Nómina IHSS');\n if (sidDetalle !== undefined) {\n requests.push({ repeatCell: { range: { sheetId: sidDetalle, startRowIndex: 3, endRowIndex: 4 }, cell: { userEnteredFormat: { textFormat: { bold: true }, backgroundColor: { red: 0.89, green: 0.95, blue: 1 } } }, fields: 'userEnteredFormat.textFormat,userEnteredFormat.backgroundColor' } });\n requests.push({ repeatCell: { range: { sheetId: sidDetalle, startRowIndex: 4, startColumnIndex: 2, endColumnIndex: 3 }, cell: { userEnteredFormat: { numberFormat: { type: 'NUMBER', pattern: '#,##0.00' } } }, fields: 'userEnteredFormat.numberFormat' } });\n }\n return JSON.stringify({ requests });\n})() }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 1456, + 2896 + ], + "id": "ae6f453b-8a53-473c-b51f-4a55790e6c53", + "name": "Formatear reporte", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "sendTo": "iaracena@gomezleemarketing.com, ymadera@gomezleemarketing.com, msoto@gomezleemarketing.com, asrodriguez@gomezleemarketing.com, iherrera@gomezleemarketing.com, mgomez@gomezleemarketing.com, vparamo@gomezleemarketing.com", + "subject": "={{ (() => { const init = $('Inicializar memoria').item.json || {}; return `Cruce Seguridad Social Honduras - ${init.periodoLabel || (init.mes + '/' + init.anio)}`; })() }}", + "message": "={{ (() => {\n const summary = $('Construir reporte producción').item.json.summary || {};\n const sheetUrl = $('Crear Google Sheet Reporte').item.json.spreadsheetUrl || '#';\n const init = $('Inicializar memoria').item.json || {};\n const periodo = init.periodoLabel || `${init.mes}/${init.anio}`;\n const fmt = n => Number(n || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });\n const diferenciasIdentidad = Number(summary.diferenciasIdentidad || 0);\n const diffMontos = Number(summary.diffMontos || 0);\n const diffReal = Math.abs(diffMontos) > 0.05;\n const statusText = diferenciasIdentidad || diffReal ? 'COMPLETADO · REVISAR HALLAZGOS' : 'COMPLETADO · SIN DIFERENCIAS';\n const statusBg = diferenciasIdentidad || diffReal ? '#fff7e6' : '#eefaf0';\n const statusBorder = diferenciasIdentidad || diffReal ? '#f59e0b' : '#5fbf47';\n const statusColor = diferenciasIdentidad || diffReal ? '#92400e' : '#2f7d32';\n const logo = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAV4AAABtCAYAAAGetTk7AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAQzFJREFUeNpi/P//P8NQAkwMQwwMfQd/+fqd79GzVyowDBJ7+ea9Ioj989dvdhC/edryPej6+uavXweiP3z6IgpS++7DZ3kQH9msZy/fKvz4/ZXzx5+vHCAM0wvj//zznQ3d3P8MaEkWlIZheMWWAx3IfBBOqur/j8x///GzKEisc9aqbTCx8ML2/yCx3ObpL9H1w3BsWc//X79+syCLte6LPIquLmO9Fop9qes0UfgsyI5//e4jOFQSK/vB3mJiYsSIks9fv0uA6Eu3H3rCxOQlxa68+fBJ59uPn/wgh8PE57UVgg1Irp7wf1FnMZgdv1bhPzeLAIqZWRsN4Hr+MzLjTRIoDnaxNpxU0DbzfpS3febHL98kNx04WcfEwMiwfvexGh5OjjeLN++fDrN4SVcJI8ghIM6c1gIw21BDeR0jE8MfVTmp/TAzQeIRnnaZu4+cywDxq+1WWzIxMoMd2HU46gSILrKeZwFJGt8F+o/F73j56b46iL//7vJURX7908huZBzSxdq/f/+GloNbpq/YC6L9sppQgr2wfdYDZH5wXhvBaAF6ngVdDJ++v//+obhl4qKNq4kq1r5++8GzaVodSm7TU1fcElXchRL8EUWdVE1LzExM/1JqJr6H8XNj/MJAdBiwBBo+aXjUwTQAAAE0miRGHYyvaoa1rmBsOSmxO8hiMD4+AFLLw8X5G8T++PUbGzMj439k85BbaRws3D/Q+egtNUYGRtytNfSWGQiDyltk+eev3ymgq4PxsenHZ3bmBn1Uc9Yq/8cnj9FaQ2+pzQU2athZWTBy5VxoYwdGz2rKg5uD3lr7/fsPa2r95F8LoC035JYZOp+dmYf0JAFsqYFaVYwrth+aDlbAzMQIbWmhWDS9PpstoaLv/4KOIsa/f//B24SRXvZJMHZl7/xzz1+/N4z2cQC31lxtjGbAWmYgO/qOJh1H4jMA+SdefX4IDGbMan14FWuD2RMoDvaFttKq+xdeQhYPyGnB8EFqzcRvMPbTl28UDp+5EkGJQ/78/cvcPWfNVpLS8GZgK+3h89fqyGIxpd3//Z3Mp6JrnN2SzwVj985fv/nu4xc6tiY6K8guX5mZ/zIyMo5WzaMOJhUABNCQSxKjKWIUjAbwgLbekQGo3YhPHtq2/ApkcuFT8+LNewVgXX//+89fDMt6yhgJ2QfSA2qTZjRM+YXP3Mjirv9cHGx43ZewRvY/F6swVvlPv18zLAl5yoje3kUGKoLGe4vs5rpgkwPp+/PvF8OswGuMJAfwlMWblsMc+eTFGwM84cwFbAuD2sRM2ALs8fPXuqysLJ9BfAlhwXsgsZlNuczAdgN8FGnhhj29h05fKeoqTRYv6577EiSW2TgVrKcxN0YJl8XLe1EjC3kkFSY2M+AWOza9e24vztp6a1o/iN3vfRyrmrSNmj9vvT/tTJMUjFz3yUiIXEBvvLGzsYIDH1ZZ4rEDblJfZaoyLCB+/PzNsLSnlDGlZuJ/UMpe2l2K0mmBmQm0+z4+Dxw8dTlq0ca9S3HlDnYWzl/YG4ZsfwipYWCgvAGAtxUB7Ep+BSYzLia08PvxC9Uz0SVdwABnQ4ugfwxSYsKnWwvjzUBFRFXfwvvz2hApq2Pmqh23Hz1zn92czwiblAAFcGrtpD8wdVFAczmAEYkt/kBqEip6/zMxMeMpIvIZE9fKYS0i/v7/zZBk1BlqLuezBlcR8fvvV4bZQbcZi7faPvv+57Mkuvy3328ZFoQ8Zhxtpg3GVkTL9BU70cVqJy46g0v9ldsP7L5//8mNSx55muDUpZs+2NSAOrEg+s37T9KUeiwot42slLP7yLl0GBtXZxiUs+C5F1hckhXAsLmU5VsPth49dzUUmoWxVtmgWV4dVYVDnJzsX/F6Oqf1f+uMFbvN9NS34FITVtjxX0SQ7yku+dXbD1ev2X64Elj2BuPNmozkRYyrjdHMzftOFuKST6zs/wsqHkGzzxFFHf9//fpDXiX389dv8HhcpLd9NUwMWJHwYkbEfyYebs5PMP77j5/FBPl5X2FEwvefDDtnN4K9PXXJ5oXZMb7x6GqkxUUerOqvYIQN2WyYUoMRTEu2HGiBse3NdHEGI8g+coGvk3k/pK75jeHf+e2FzJ+/fBPMaJx6HzR6wsbCMtpVHu3JjQbwKCAXAATQaBExmnpHA3cUjAbuaOCOBu4oYCCvx4YYB3ir/PfvX4yeEbDb+pKLkwM8Nvv9x0+u1+8+SsHkODnY/4gK8T9A6sencnNxvLUy0lqHz65py7YsiA9wyQKqhU+mw5bkcLCz/YWNFYOWywgL8D1EXsKDDmBLeJCX3cAAOwvXD9hym7//frP8/vcLIxzQl+hgA1iX7RATuKB+Py8XB06N6eGe0WZ66svwzWyICfFfaS9O1F2+7eAsUNfRwkCDkYkJe2ZZs+NIzdmrd+JBGDSWHFXS/Z+TnRWr2m8/fjIs7y1nbJy67DYuuznZ2d5MqcsSLdpq/R1nZPqfZzxwd2XJ2ms97ThaqmA12GTOPNkRMe9sxfJp/hdIn7EABSywrw0e6Mal5srN+04gGmlgHQ7iynv/v3jzQQdZLLV20n9sakFg++EzzaiBw8qgqSS7pSQ52BeX/ehm7T9xMX7J5v0Lvn7/CZIThQQgds+Dxngrt7s+bvfcI+usGtOBS83nH2+FeTmE31K9WFCSEb/0+PlrA2zLPYT4eR98+f5DBpfe33/+MLCwIwbWJEWFzjx//c4ENIyHPqsASv2/gOpBWYyVBTE4rigtfurJizeKGNkR2PGRlRRFmcUo65p76e3Hz7rcnBwPgAEL14NtvwQ8B/z9Ivrv/1+m339/4QyHDz9eS9AkcMGO7pl/HjJbgFQLMjGCioVQUiz6/fsPx4TKNIGC9lkf2mes3F2ZEe4KEgetHOTmZGdY3FnCCErtyHq2HT7TtPXQ6SZsxcKKvgp4BIUXd/7n4WBncLY0aI7ycahDVlu41RLvcNm5p7v9gdl7Hd0rtH///rOgTxbCUho5lvHycH2UEBE8d+fxcxdgymaes2rnbFDATq3L4sSmPtrHMdvJUn8aPjNBEQIK2Ob8OBUpMaG7mOUq7mIBVKiayHisB2K8arCWxkjrV8kK3Jdv3mtt3neyHJhyP8JyJLBymgIqJoy0VDYCWxEg/QuzG6e+CXC2rIY55N3HL7JcQA+Dymx00FoYbwwa1c+onwIeeVaUEd8LbAlgrZkXbto7FWjHDwa02cQ/f/4yeDqYzgdFMqgYifZ2SLx664ErCMPV/PvH4GlnMuPOm3Pm6ObefH3aAURrCltsef/tpdTbb09lMew+W7sARMsKaFyB7beBgV//frLPOl24gpmRjbzAnVafzZ5cM+Hn+j3HO1DLOwYGPh6ueywszL9BGBiID798+yG/fNuhGcjqkCc5IYt1ESkAVEmCAgaUvWsyI12Qy1LYOFJZSohpy4yVp4HmzsUsFn4wGGgpH4SpXbLlwHx0Nb/+/AYHLmiJKzb/ffn9miHDckLw3ttLKrC3Fv4xsDFygCcBGvcH3kCXZWZkYXBTTq4fHRUb7aGNBu4ooEbgVvUtQFmJA9oCi8+gmLIeosoXYOX3HJv4xAUbdsHYoJlWbGrKu+c9ooan0XcfwkBkMWIDH2ihCzY1B05civn4+asQiD1n9Y4p+PvHOHacfPv+gw+Z//3HTxZcap+9equESw4d//z1mxWb+IT563eB6ML2Wbdw6QV2FB4Saw8+7JvZiHWnTVBOy39QwIPYoJ062NRcunnfgVh7cKbcHz9/CXTNhiyWKO2ccwPYJOLEpba6f9FlEO2b2YQ39abXT/4O7Eyw47GT3c5YZy7ObMbE9Ae0jgGEaZWVN06tZUyrnYSzR6arpnAAMvbS/h9XDiOYct99+CQHokML2sEx+eXrN15s6oBtTqZ5a3dNBuIpIBpfTN59/FwXlxywWNi5eNO+Lohd33kGKuXC2LNX7ejHpiYgq/k/sfbglHj7/hNKtgAGLh82dR4ptSiWJVT0/cBnYd2kxUewibfNWHkCxr5y+4EVNjXAIuMZKNuCMK4AIgY7JVRi1eueXAMX//X7D85iMLd52hOYO/DZM9rOHW2KjQbuKBgN3NHAHQ3cUYAfAARg70qgmrjW8IRkCIEAAQyyI0hZHoJrBbVarc9WlGJdC4oVXBERCgiyHutxQaBsigtCUVCwotatSK2l6nmtolCrqGXzwZOwhD0UlCXbuzcYDBDIzNSnj575zrmHYZL57507//3vf+/8/xfSWyBBWgYSJEjlJUGCVF4SpPKSIEEqLwkSbxY0IhdBjl/v3Uca/nzxUhdFaQgVZ66zl+si9+n2VlnS/3fGpj+q5jbb04EseRTSg+vu4QsQWwvT6xFbXRdKzze2tRuExKbXwsg2kUiMBHp8NneCldltoh3jt/cYDJqV0FsLhSIE8gpKP6upb3ovJP5kOQzZQqk0uane8jZxXnT3INLsZxjvORIBzUiwNjPKC960chE8ds9hi1VRNrbnhggRgbAb0VTRb4r6+EdzVWWNTnj+p/LM8JynB/ZirV8o7kX4oh7EafyWg26TI/zwtr+Qc809pcj/FDwWCHuRkyuqKG9FeZ9V102NSsmRkDTAzocv+GZOtslgMlR4WGWMZWv/Af/y+QLlDZFJPSiVKpElAFrnMNHqJIup1izvuobWdtN7xWWrYFhlVQ33E7egGPGZ2L4IQJEMrwEcAAmZl2/18gXCUzE7cN1jU2u7YWB0Wo1sZDaV+nqCSvn22on7j8s9IH8N3GY0NdD9BSjTDQQD2UuvUNh/vHju+3HKMiRB8kFBONwm65JKTn/IPYxo3LhyYT8twGyTL87QqSoixQonRMua73/QyW814Au72Tvy5nTMMV2V5Dop7Es9DbPimUZLs7D0T1t3I7u4Mf9jJqqD/Kv6vO8Plam+Gcuf41I+CkVJoKzEeDX1U9+e5c27VRQoPWZraZZGBXraEK38t6cVrlBxpdYtQw5DlBx8DsORoAIzUBTh1DfZGuuznw60emJEwpkANBBSxfqs+XTBFFuLnxTOCLuSG/kCARsqLh8omrRtA6zGkwo36fEa53k+82dOOkzk3te6fLRjpM+PfZubdr+4fIM0laJXIBScig4ckpmzxTFuNZ56r5dnfHm5JEHCO3G35ooXVN4JerOvwoJHzroLpmI1mhbCoGkiHF6JrTHL5un/vdtAkcnJotGoEsap1wRMmN2GVcBtGMyQT/RtyRCFB4PqSXTQejtpGsXh7O9v9PD54tMxQXIHR21Ds2V4QmaZ1MIuWzDL12nO1CMeoQkCWQs8GNm5t5Kzvr+ZjLWhg9M85MEjJL4LtEOlr68pyHumBtdCNq9aPNz315wbK1EirKBSUCB34D0V1uQtTy30P4+n0wfWSUHeBWhvQkhyhLeJouwM/6jUapnp96281jsbt5OSf+fhBqBkaXQUpawPSxR7uTotBgOnP1h2Q3giZPNRh23r6u5Fsl+l3wyioJMLp9nT9jlOssLMSQy8IrlPuYbbbBkUm14GXSfYDphU5rPaeYnDROsrimRun566hKqE/TE+rL+9+Pf665tlz5lr2d/ZNC1+CY6uFacU+V1RVlIbXQu2MVoaNdJjTn2zLfyrzWJyFPp74IEwqH1uqQZTVRIhzVCh90dKA8uIefhSX03ncFHEVFVpHOm7YFr/BhY4OzDoykjK2bzcpFNXkCj/dTa7DmWVQP8YOoxbVn6yzHGSzUVFdQMZHcCCSuJkua1t5kZ67CdEO/94Tt7RgoelXtDCQsWFqaHAdaIDf7oXqwxHU5creOosaSjoV1IUQSXJ3TpqhvWg4JJz+N5WBCovBTwENWUWD8+1j+pu9y+0USqdT7T/CMU2wOBgdVUGoQpBfd3p+/37L/5iZxwfTM2EZgBNpmplfOhmCYlnH+fkSQk/2BiWhsRtGOKvPi53Pnom96psSu5wizpoeT3DXrsN0lzkru4exqbIQy/pyvibLE0aL/9PrUN06rkCog8NGoLMA4EUeB8e5w2HpcQdycPqFXUhiYsKtJl0rbb8itOhF/6I2U/ES6NRUN5Bl0It/9xZDV2CDl3FVyvJ6IIQCZ97wcaIZVn61pR30Orc6EV3j95wjg+oQKjBVKvR0mQ2KlzJ8jrH/vnihZEYLEeHkcVnaTA5mupqQ6LvgQ7S6xpa7MWS7Stql+FYnScjWLzDYKE3zXftkqVsbc264fqmqrZhmlTRxxnoFspxA8CgaR0PFlPaWLcYgamWyGnhdRh0vOwyJGgAYG66RM6dqu88lJRQTA9RTVmzwZhl/RuLodske76u/ZldNa9kCsbae3TVx5Wbads9kKWkAAMgkC8aSjw4uAsYNFWumY7dXRPWPx7/VbeBjCojMWpBvmEjMWqB23EDfuW9/QEeDoq+BxMJY3dutCbSKPfgr7tOx+wg5FS7eO8Ry25mwP3ggHWfuUyfaIV5HzP2+Llf3VzmrgWLsUrZ85mX8+Mqq7nvf7V9zRwscgKj0+oqntfpy5v2rx7dhWmB+s/1EQPexIEFKtL58jWdhaa6qoSVBYus+Z5h4vwT+zEvjHcnZ9/c5bN63uDz9U2t5mnnrqdHervNxSoLZrCGJ2SUtrR30lEaTULrs3LhB7Hun84LJqy9eHPq9hw58+OmyKTOwsdli+R9Xlldb+cREt8bkZjxgEjOnmvAAdGj0spZkYmZRUSuX7Z9n9xcPffgWMx5hDB1uYbbNCAvEuYADv55ZUUFJolCApY3kSiq6P7+SrLpcAXu0MBSXddoKXseKmJM6rlcLDL4fAFtqUwy65ssuN0GsOigRgd62rI01Os8Q+M7ZT8LiDr+rLGFNz450msMWK3jtuqbIg+2H9+9XcfeyuxXGPuQcSk/kciAhGzs0uMqDnfycvDAY4M3jiMi6+e7jzxD4048gmnaKEoT4PLJlJSENwuKVwMZ7tLy4OmzD0fLtNzd3fezK3yhUGWFH7HfQtgQmdT73aHw/8lbDKI+r9jCRP/hiagA5gq/KHFTa/tYaJniQzdbOEyyvoQQeAkRFP1NuavTbN/axhar0kqOo4Wp/l1gxR0KHpYsxX1TShSRQCCkLt22V2xmrPf7BdB5ejqs53hkAHeDB7kap06wuMjhNtuW/Lt6BpGBPs/RPvujGRNPS8sUW4vbyCiDuZFe8fmkMMr+o2dvHEjJyaNRqZgHsYOdZVbR4wrnkbZd39mC7XxSKIXDbbId/BvneLDv2Nl8N+cPt82fOTnD2ty4QFp2+7rPyMn7JaGhmWeC25mnUYUXD0dQLt24GxxwIBXXPiKFQhEGf50u+f0LTXU13unYIJqJPvvJkm178HY0JBUVIH8ThG39fEHIllVOPnuOcLDel/dq57UisUi0Ehg5bnOb8ZtsD7lVJgfwRQRDhd5F9sTwgFRHdGWU/y7bQCoviVELcp+XBKm8JEiQykuCBKm8JP7u+K8A7F0HWBTXFp7tjaoCUqRJULEQVBTbU4kGxfpiw1hAjS2xEKIi8iRGUYoNS4xdBDWiBnvUgBpLBAtiQ1RARBHpS12WLey7d5fBBXbZmcH63v35LjDlnrkz899zzz0zcw6asCEgzYuAgMiLgIDIi4DIi4CAyIuAgMiLgIDIi4DIi4CAyIuAgMiLgMiLgPApo1mB9mAEGGFZpWWlqIqPEfxuTaFQ0GwszdI0bSsuKW9dJqrSp2uRZSDglxoZ6hVo2iaRSFnbDp45hNFpMl/v0RObe2GeZmb3PHLu2upuHdqe9BzYY4umfaolUk5ZhcgcJmJWW03T4/M0xhrjcdmlPC6nBJynSYWoypD09Qay25i1Smcw6MrrI62RMOU1UkL3kIbR5RwmT+OXD2JpJQ/sQOj+cZh8Ma2ZUSEVaodqjixKL+acjE9c/PufV8LZMCo6nY6pQiIRk1NVLVHG68KXkx6lea7bd/wsk0HHmAxGk7JgU2HcXGUKXV/vjjaWpo/xbTMCI6rBH2WAA4lMLo8O+4lyxxSWVJguCNmex2YylccK9p3qZGHaMhXfHrIj5vSDZy+Gs5SR3OkNhi8apu2ajnJ3WzN6cK/AyOPxO67efjiLwu3CKqrEWMwGfzq4Too/HqyPuJCxh3BkchjRXF5TjQ1x+D7c68sAf3z9t0dMFHoko6sbsE2FwR4XHAw4LYrJnsW3R1oq+KxWSmnLBsR2tzfunPRBzIZLCfe9T11ODBfwOBgMQgejLEK+QdIRLThe5xU6bD54+iwMqsGuDenflCy4HYb+h7GAl0XsTwGaj1t3IjRaXdhxDovBCN919DRV8s5d+auSuGqjRV3Uj+Wbom6lv3wzHAYzgcGnGY3aqYrMrqmA7XgE8xoy10tdtgBcK1GVWBkTDEYYZ9LZGNHCYehhMCjf1ayYJdtuzI+tG37pXMIyYERzPssYkymkxvNOOxfVKOSkOcRi6CvlwALOqobqfSKtnSJPxEfikROlMjnmaGt55Qtr8zuqUU03JHJ5HXs37T95nklXnbsMmCB2FmY32ttZJYKbJG9YDyhTdvKTzGHC0nIHuAwJvPPwn1Hzp44ar0lDP8nMHn7/yXN35/b2l8ic37yV217DjqkNGa9yXWGoVJWGl2GDe7ts4jCZRURkO1i3vgb/OrW1juOxVeFFmwKbxao6eSkxSD2tABgBbgn4PBhTGLMydLzbw2IYoXD8xVW5Zg8LLg/iMQ2Uy4k5J/79PaayhvpYjSeUGkChqGE9KbrVq1xS0AbqPRjaP/LO8r3TXdf4fBY2LyQsTt4Fk0cM797Z8SzVg+cUFLfl1EZUHzWgZ9A3Hn1WNbX/pJGY7/KIqLugngtcBgQdrHWABT0gbPexiwfCF4GRnU7Ipjl85kowMGsstG0Htr0h1PxKe1cqxQ6EL6ZksPXo4hgLi679fJZuEOPEhaZIT+d2O2dP8JxdJ8d62CFYyBz7u+PtFFDjcYH2E0sr+FyWnohsaoBN12adf1p8ywP+/yjv6ojPxtuAJ+uBJHZxanuuWRM+NdsQyDpJpE4fF6dINftZ46RHWpu4BGrIqUs3EIovkFsgtD1/PSlQzVRotE9ZRVVL3OyBOTTeF4A5xIbZggBxOfixJnr2n6lOXKqQyEXKvwwaAxNJK0yoyOhpPSKmrkPLSo0+O28DnDECjVZTKCyzE2shkRZvA9bG3OSehvVyIvX5vHqRtGmaiLt2yXemi8J257PBCAE0JT1ke8z5gDkThjQl1zdkZyZuLhjo8dNBqczOLXTW2olBLwbbbTESCRmMDQQFYMivaGqflzn57Zeuj0zF2yKWSLGg7ye6trOzutO488vpUrmESU5b0bGG169aVkUqp5asRvpJuFibnZPiwKnLmx48fU546GjobXgfaGEgKPQa2s83Nu6GMtZZ2sscj7sp6R5dOzpc0LT/rKAtxThZYGj9Pat9v1i2ITK1qWNw2WwsaHN0Jpl2+YweNPVfPTpHa9t+OfHBlH3H46J4tVEhYVt2rpzfAnQkoab9k17/5b3r9o97ybQBTrYaYvpx22oySVlgQhaYmOVjo9k9iKZQ0OEI21TBbdCG3ob3BgXMc9Zjk7Ghfip+7HX7Ys/L5Y1nxruPXdgG1hur7FgZtnvVAoOPcSN2xpzbEX3qYhQ+nxCJJdjhDf40bcSt9ZHKVZ4C4kWjBgM2MBkZnwJx34nmnTHOw0cqkzUZSzfzdW7PXw+ePfqhT27dkhlOdTnbau3fg2vfprN6np3b6Z+klLn0Wo/Hd9987a0n4JUTkQ2H87CffDqTaY+xgV62pvX+a/feKywpc1br2GW/r1+i0xSzM+58eVrXNaSy+Gy/vfAUh1E/i8/c7ptJZRR6Vpjs/s/Lows/W/LSVM54GrjZhbr2zc4r+kL3UEQjNApUievZeDq9CHtX+wpmLt9cCb0aPDaLtmrb75eWfz/RHW4L3Lj/ISR2rQvq5gC3LlGEJ5s1NVhzMgHhmBawsZJBp/HxZdMWhvdCfprmQqRuS4HlK1jIHG/LzdlYQ/KSzSgEJtpGgLxa5x2frNlQU0sXBhjeXmTnOROpk3gv1Qv/n6GWVZKuZkKkvXjdj4is5NSM0W/tTt2+Uh6XI5rjNdSrppbpmdl5A++lZgyavmyjCCcumHBiqxZOcdMlS1/AFeJeCFxbU571S2VM6FEAEz++AlOZV906OkQSJS5VMGoTCNaAHxadVUpFRpYwpe6+c5kCEVXSwWspYOqXfTDNy6z1O8LfQVsPJvfv3nGXrYVZshYtyHr07MXgB2kvRuA326KV8bO3WsboZWlFpTJ86YEzf/+a8epNLwdri5twQtvYG4Fx7j5OH/Ys67U7Prw62FheJtLmvt06xcTfuD/vVW5BX1h1U9TJOPhYW1E7gYwKJfYoWU/AF1ZL5cAEoSsfkgCNXj7K3W0Fh8Uk9IjUztr8voO1+d18Yan1wuAdWeoPQ1q3Mk50tLFMiLt+d44uOQI+J693147HCyuy25SI8y0IjVjSCuND91Zu4zFVni2Yykqf21JpTz8ruO1GpzGI+MJpz4se9InL2OPHYqgsRVvDLgnQ65FXnuVIx5rOJJlbkWW/+86iI3gbpAoxZqLXJuODkdfLs//82Lh/lI9moMM+8f7TmaA03dPUnqItmDqyzjMxb9LwEat+O3wfup3gROVOSvpkWHSYF7jmwmZPGDqFaLtXzJ/Ub/LitQpoPtBriQt7/uLpYwbBWL5E5bQxa5kK7NMOtaaD3vH4G+uI1h050C0YktcvZFdWw4zveUVCt9//vOJGRI6FaYsESN6rmUfnxj/fF0BlkDXh2tQlcAy+MjpBj0QuN5y4UoUEm9p1xdTK6lJB0CXPVAaNTcZNB0YBtpSgtfhuzIZhA1y3/su101roxoHOc12eBmVeAplc6faZNdZjvKVZqzrNa29t/gC6j+A2/IUbXbIkQBaciQfMGt9HwOeWq2lmOr6PNkSG+jFgXRyd2tqccHFqe1GzP1qZ3LGRvLDF050geaDGhnav7vOv53WhqUwnuvIRB5m66qXmrZ2pUN1CIkVlLFTLKzEug/863POyRX0aEJcDXfJl0gLM23mVd0uBxRuVBIaSnE39vPUTS0ARS7ePesjGmoFmhXuCQZjzikvtoftQq2qn00vBkJihS7tBGzC/SNhWVqPQ6qpiMRhCcxPjdE325suc/G7gptLh+cCEf9pccrlFJTZbo08dtW/TOmH6mK+1zphz8os6gY7CgwMhIGsKi8VsZNuVlVe2EJZVWCtoxHxHRnr8HCMDvdfwfzBf6F7P6CcBNpNRAd9yyxI+cnlTltmFoKNBbGZg/9TOuFOjB0TXM2N9mMQTEeZbGbZLMuab1SWFrJZVcS6lHwxomJC7sUKQi8z07VKdTN0uclnkbeV3Sl4EhI8J9CUFAiIvAgIiLwLC+yAvfKJGYl/UMRDeK0j5eeF3U8fOX/957JC+vzS1H8waOdCtyz4qDbp578kYe2vzRJMWhq/J1t115Hzk2at3vKELC0cnB+tbRHIlqwPmW4MZLxuu95gZpDixJVCPx+VU6pIB2sActzBEir9brNapsS2Bc3rZWpkl6pJxPenRhPA9sYfxZQ6LpXQp4ucHXZVHNvobC/i8El2y/voneZFMJiv37O+6g8g12Blz7sCMsR5T8I891QEziv5BMqvlb4fO7j53/e4M+GwAfoHSupWRMNTPx9HYUL+QMnvJ5nsd57tGIaoS87Rthxd0zII1CpUPlHw+WZjr1nfNjiwqdffF/rXj+cscZ/V1d1PSB40hma8XvszTcN3cn7fmJqWkeRCVAXMO+6/dm/kuc+0eOHlxLTi/3lTqAoUy/69rSdOI7h99In6rl1+o5jzOi9eSup4TfgxRHDp9OVh9XUFxqcVYwJO0rByXD5Z7mMflyqcHRmj10U0L3CjS53MoJZfLeJXr7GhjmZhbKLQG5KfkAxVLpPXeOnFxahvv3N7uem5RiR3VDn4iLsHftJVRWlcnze8DfyjTCWhddsPze1+AD0KcHe2OzgraUtAcOUGbo2/NHDtk+sThA/6jvr6VsUHO0U0BNAdr8+QPNmFjMWiMkQN7hm6MPP5Hw22Hz/y9plt7+z8FPG45lcb8svXgrSXfjXFfMHnk2E1Rp2Lf1Y0QllVYcllMSm0qrxAZRp++HLpi3qR+2P8Z/v117zBbc5P74buOnqEqIyX9letXvb/c9z7aR5q88JkG6EUByanPPV/m5Heou8mVIsNjcQkBC7xHjwVak7Tc4pJyM2BMlsO0qb1cOvxx6eaD0c09IXG1hAtsrUiJRMoxMtCjZFv5LIsoiQ7z06NSV1RVLQDD9SQ843vc9eTJnxN55TI5c9lcr0HPsnL6Xria9APZ+oXCMsuuTvbXPokJmzr2h/rx4MQGT5jtExBREhX6I+WvEJZvOXDv5wWTv8SXxw3pE3H279u+wwa4RpAa7mg07NTFm4tizl1Za9bSKGtQb5ewud8O86HUpoioW1wOC+PzuJWUbj6YtBWXlNUl/Za9x4823yd2By80gvZp+7ZWl2wszVKJT/Dh3FJRj2P4fKLWAYCtmj9lgIOtxZUPSl544GUzx33jF7orw8qs1UOvof1WgFkvpaEZasaSsgozYUmZFSxwXY/O7Q79B5CHLHnh0DDyq57rHqdnfdXCSD8bzK5/o9Km5McZXxeXllsFzhrfb6r/+qqosJ94ZGXoC3ilY4f2C/lfMCEOrV/CHucbIondHEh4VG1pZPAm6XFGL/V16t8v7jp6PpJOp8motqlZEwo3lw7HmXSGOCU9y33c0H6/UJUTvP3wjb4uTpEJ955MfltSve0sTTOeZGb3oCJz6ezxQ28+eDr+5v0no8jWZbGY0uDtMRd+/fkHC6cvbK4P7uOyOXDj/tv/zz5VNrgmEUtndp7iv66Gw2ZJiNZz7eRw6WR8wiIt7kTGRzEbcIQvmd5RBmwjyq46lVHfdeWCKd0aeQ6AzTp7xdaS/SF+XCqy96z2NRw9L1ixb/VCEzL+xOpqKSsiYGY7fHnKSHf/wA2Rdw6Bidu3IwYuJTE6/U+99WTXpvWjiZ79fzx87upGonUC53h9BQgvzi0SOsye4FnvRXsGgy77oOQViasbC2EyZA32IRzHIXRHTLyfT+OQTRBcDlsMhwYY+M7YSC9fp/khlfEVwM6s5wFZv4Q3+ofgqtO/BXGBRq0m0qYenR2PtTE3eaa+brWfT/fJS9ZV21ma3erV1YmQJwRMdGzgvKCB+wzbvGy2m62V2U3S5pVUxmt4fiTcbBwwASOsMaXgWJo04wj3nhF3HqWNI3Ps6LBF3MNnrvwC/e0ytQdIbDYTG9C9SyRl0xW9EtnIZIZv2dagK6HzOsFpz0clDyIvwmcL9PIMAiIvAgIiLwICIi8CIi8CAiIvAgIiLwICIi8CIi8CAiIvAgIZ/FeA9q4ELsb0j79zX9V0X7ovonKkHJGyJJSb0pLbrvta/sSuKywtLRa5Ulkqd0mO0jpKKUoHyVWppLvpmJr7/zyRnUa5mpksz/fzmY6Zed/n9z7H9/n+fs+FhocREBAQkGpAQEBAQMSLgICAgICIFwEBAQERLwICAgICIl4EBAQERLwICAgICIh4ERAQEBDxIiAgICDiRUBAQEBAxIuAgICAiBcBAQEB4dNBlHeC8HTL1IfP3B+/KBhcXF5lWVvfoM3l8RV4fD5ZKBThRDjpp9nA4WIrpo3xtrfpHNbWdzhcHuVB9nOXrKf5rgXF5T1YdfV6DY1cpkAopPL5Avzn2AXP/SGRCAICgcBWoFKqlZmKeUa6Gne7W5pe6mZmmID7wL3giaH7T0QF/5OSNYVKJmI4PL7pZHd46OBUd+fl7oP7+HdkhXn4JG+AX+C5qxwejw6er+lQGA6Hh/XuZnp5xczxo+HRXx+6/tGzl30zHue65RWV9KmorjFmN3JUeAIBncsXED6Sn0I8Hv/JG4vAw348XAb+PnKw/btj1Y6di/nrcnzqXDqVLJe8gnaDshboaqimzfMa6d1JS/2Z+OfR2Qd9Yp4HzaERlaSWphATYQQcgccgKtYyyKolWoqGOcaqVokW6nY3VejaJW1dF597dt7ph3+sYkjfFn6zLRoMvaeGqt2Sumj0uanG0C2Sa70tSXAJT9++hUKki8QthIf6zLbbNrUT0zznmyNe2DgPhl8+fP1u+mw6lYIR8a0LbTweJ5P0CW+YrtVEb6VkTT146nIwHofDkUlE6dgFvi4QCEngRa3k8lQra+pMXhQUD467m7EGdgIKdFr5ylnjXcwNdd875Ly0kmWYnpM7galAazphRxynr8bvupaQunTDoil2asqKpfKsKID8cTsDz0WmPX7hRqeQMQKB8u4zCsi3py+Lh5dWVncD5PJA8tr8opJu2w+fjqmuY+vQKOQPldGH8rPp9ang8fiwUbUoUJCfBJAO6YNpSZV5m36Sikor+vkGhKfsWDHDRFGBXtX8cSOfrVrVWGjEIalJPekqrLjpd05l0vBb+eFLBSIeVs+vxMyV+99d7LDfXYWmVdZCePAbmNAWnkxtwVzjC84sgraweZWYibJd5mKHgBFqdN1CWRdFA69WNb82ozeDqIy1JF4+xhVy6N+c4oVKdp1/yINXQN0q0WmtkjJQO02qTlY7pbEbubDRvtfiA89e23v1TtpCoEpbsavJdnjc9RfZBdQZPNMRIxFaCjlIPAKBQP3X3SGpC7zcJg/sbSWpwkVvX++BRCRAJWewYNP+konDBqwdP8xhqzwqSfqj50P9gs5fIRMJeGj/B3LjvY+SM3JG+QdfiIAdbmuky4UE2USo0i37ugYOBjwVqgQZ0+D70kyLRCRiIF8+eMd6UP/srC0ixEm3yR4hh8rmF2EiHEFq9kB1gccRgbgBnSOOLNaxkTAlkhZWUv+8z/LL/UrXDAwfYKFhl9D8uUDIo9QDW3ByskWRrIWVsV9aA1sKVvQLHm6j63Tlewo1yHxbyH8S02cfOnPlMF2C3KB6UWEqPJ3v5ebRmvKTNYpKys1/2/P3I/D8RHFlKRSK4DmilT97jPDoYWka2x5x1NDIoV2ITVx76VbKWgqJ1LJDEggwHTWVnC3LplmJn3f7urzK6Nc/Qx4BwqfhPpA4D3RWygr0wvVv1O9rWeQRIC7C9kOnrzx88XII/QOEK3p7/uWmxVN6AMWbLpaXuPV7jqflF5d1h52GRPnzp40ZMtOpj/VxnLwUqJTxJK/INiAs+mQVq9YC34oXVw9IvpelycV5k0dOBaTLkqdtNZxKlas5R3+58uyQD4XAaNnZCesxM2X7xNXOf/dvluWyRD23Winu6d+LIp7s3UzG03AtbWnA9BWtsn79Ibw7IGmZHTV7r/CK5967P4e2pnjXOJ3qZaxiJVcOkrnifV5Y3FeyYUFyU6BTK0BDtVdk0Ks7otEUFJf1AG4/UVKFQUJcOXX8uK5mBjfbmwaNSmmY7Oa0DlRucuQ/d1eKxxZhuKW8usakpKLaqJOW2rPPV1oEoKQ4ekD9Fk8aNuC3ccMcNkszf4BSdd9zPDIShl8+onLbREV1jV5JJcscCGUJD4SDTRo2cKNzX5vj/0XCPX359q/nrydugiEWSLjipAvrNofHw0YN7rvBc4Tjxo6yUYmiWjXRZuVaEp6CXXjs50MXCyGQcFSsuP55typ2qbYKXeu1rG1hkJVr3Lst3EIk0HihWRu2K4jZQgT2lbJfWJTWFRprKxo9/14Ur8yJt7GRqyDZp/KFAkxVWbGoo0i3mRdbzRA8XkQlk8qlmZCeltqL1txboBRJQqBsP3Y9DMOoqzIzVRQY+Y9eFLhRKSSs2VFh0EDDikvaFHs3/aeNC3+001BlFrcrNMTjk7YeDI97kv9qAMiHFuHKBi4PszY3imbV1hnD0FFbsXoxxQyfr1XfVYUp3xh1e1HNqlPffzLqRMbTfBeY5+IdNtQVHC4fo5BJ1fO9Rkyxs7a49LXYTSMzi9qQkVQRJqLK0xY1unYxDvd+nQGqk8gTNFKx7wjyGFwTteWeNruoe4IjIhOznriLN3RpAg5oLfcePdHepvOZjshk8Jzt8ucEIJ/wGA7vM8/TPft5Qb9th07dwBPw5OZBIqh+ORxup0W+B16NG9J/46QRjhu+JJ2E+w8n7Au9dJpKJmPiZQHTx4Qi7rp5no6WJvp3fXYFPRYBZfelkxFh2Ol45PV9wZHXd8sqz4Gqxi3wGDHD0d46tD33SX/8wulAWHRoHbtBG+YJJF1J9W6sq3V/kfeoiToaqrmfc++M4ptjg1N/9aMSGTKL93F4dcp0kspH2yWwxQXYsl+WtnD5bCUakYkhdMB0sra4qSkQL6ObfyOTlUVAjeIsTfUTQ7avoIDOKjQx47GneOycQaNiUbdS1t+6lzV3/Rv1+0lTdho5XKrvgbBbuUUldpJKDsYp+/fsemLRFPcpTSQsaFKw0micRLwM69/bMv/ikaKzV+LXno2949scToAx+mYvA/6GhOtkb3V01vhh8z42ha5NIhI0KJY3vjRtGXeURV7gP8UWxtdiCyJeeRlBInDIZBJbcgDqcyEUiQg8Pp/yPRTc4mmjJw/Pt9uxNSA8HlABvTnOCGdRAIWvs8g3oHDckH6+QP3++qH73EzOnHow/HIIjdpS5cLwBnAL67YuneZgrK+dISNPQGbq6ku8DFZNndr+0Et/p+fkuUqGE952OnCWi2CKm/OCYY62B9tro1DEJzbwXmPSntEhDgqehpHBS/iRNORhCxlHxSgE+kdtQcQrQ8AQQ/Og23wvt0nSuGdu0eu+6/ecSJQcQe9oyGrUHs4GObZtGeNgWPThuJTM2YpA/YreqV8KFn0rZd2N5Mw56xd62WmpqxS0cJEbOIzN+08mFJRUdKdJLCiAKndIv+4HZk90nS+jMAD206QRc536WB/+Gson80meY0DopTBWHVsHxs/FwwnNsW1VJYWCBT+6Tehiop8srXTt9d0CT3k2Bsry2a4/OzEn+MGqQwokjY/ZchrYItMpDvcLYzz23J0ZxiCqYd87ZE68FAqpQbJ/g+qMVctWB42fTqdR2NJKq/BVhQmfz8daI15Afo0tengM47R2DwFQSlw+X6r+VnFZlU4bhMyTtOtL8JPniDnDHXvv3LQ/NIkvEDCbB73gyjIOj6e1ZOuhl+6D7bf96ObsA9+PiU+dE3gu5hDI+6bFD+KKDk6l+2PVrD562urP2msXgYDn4/E4OBX6PdTU1at2dOU/dzVhzdmYO1vJJMKbcAL533BCcwdkbWF0deGPbl5MRUblf7GB8wRc1daDbUIB+MGXpy313GrlNtqBCHhXApmmzWExRaI2kxDKu1xkTryGOlopIlHGjBaJgkpewarV3RNy4ey8ySMnM5UU2j27Ie3Rs6HBETGHqBLuIfRm6VQK10BHI0v8fX0ttQzgWgvhuJW4IIUDVodPXQlaMXPcMF1NOBuhnYrjzoOpUbeS10rOY4bTjjRVlfI11ZTzpJHPBrqaj4/4LlEOOh/jf+V26lJx1Qb/jolPW5OcnjONTCSySypZZnSJQSJIMm7O9tunuDuvllbZqykrFaorM3MLS8q7iXeG0IW/eCN5rbmR7i1LU4NEeVZ4Vm296oGTl0Ie5OSOhPkCFW7L+tL0q8Hd2X7D2KH9d/yXVVVZ3UvDmGeByySXJPNEPEyLYpavTFMrlpctFfWvNKMfB/hQCYoS7YCPqVH1itQZnfJllXZOWbJDeNa2nXSSsoTIEmBMsnqZBl12aXcY8Q7qY33s6p3UlWWVLGPxxvd2makrUGNVQGG2a9Xam8EPYovwxb9uLRcbPqDXQeBqtyA4fR3N7D7dOwcBV3ymOCnC1WbVtfVma/2Dn3N47bMLKk5ol+TqNYhGLhcbNsB2G/kLB2bawvSxQ5eB++7e+NfJ5EYOV4NAwL97rjp2oy78W7wc4EIMJQbt1bZfZthrqjClun4epCkcOaj35r0nLoaJpwnLSCgUKvodPXuHA9x4oQxCvXVsDjZt9A9+E1wdVjU9J49P2nboVFz2i4IBsLwlZyf8a1tTZ007fTV++6krt7dLyx64Qs9YTyvTb+Usm+Y6ejrDb9uF7K2raWRNqT47XJIrAoKWTGA0zZOFK8Va1D1BLTbYxNMfjyO9k4AxT4J9AtOWbmFI2RaoMgVCHrCFBmyhtVjB1tQ+BSxsgsnKv6hERkMNp1JxY+yYR2xelQ4BRxS1Lw/4xEZBPZCyAoxOUGpakYeXGGtt4FdjLiYzAxUoKtXfHPEC961xxy8zzHcHRYQnZuSMZzCoLdblQ0KgEaS/aQkkFLjkd/Z4lzlDHHoeadVF9xgxS09LI/14ZNxu4GK3IEhI5m3tK9AeNAKiAenU+4DEbTob35JFnutoqOYFbFyoGRZ1c9OFuMRf4WyHVt2vhkbMw9XRZ6xL/22yKn8H227haipKeTuOnLkGOjKlFnODQT2gUmS3YQ0g+3fhpLTs58OaSfdjgHZJu+yJoOMB96xr0THhiEISQaFp8Eu6oInN5/iXv/jCRowjbBAt6XNknJ3+8AstxAuOICNbsDZs4TZ1APN6/+XtYDS2aSENruljEUGICQm4dg7AQaJta/Mh2BmwuK8wLyvftW5d58ll2b3cifet8hMsnzVuAvy76HW5RVJ6zvgnuYXOpVUsC+DiqgE1Ajep+MJ5JnAuMJ5PoZBqmQx6sY6marqNhfFlexuLCAadWvOxq0c62e2Br5raepV7Wc/GZubkuhaVVljXsBt0uFw+QySCG63gvsguQN5c0NhqmIqMQn0djfs9LU0ibbuZR4svEW414CQUEWEoorl/gn8LRKLPHjH0dBv0G+h0DmwJCIsvKq00oZCJb8mfD0Mt2bv+N3sgU0mh4vPVBGgcwCb4ag7nvLW11YzqYqJ/N3DrMqZAIMTff/jUFZDgmILiMtvqmnr9Bg6XCdQvWdpLVwXCpnni7+qUvU3nKG/3wYvPxt7ZxOcLlOW9TLm1MuQJOZQ6biEmwkl/ihVUdyQ8FWOQlCt0GCaPrLQdI/sZjQ5RoWmVtp5fvCZbcDKzhYzRSMpVmnSjbGttx6j+hqNC1Bl6Eh6WCNfAZzHreOXvKeMvT/sNCZMJdJEKVS+vi5pdjKPppIMmqt1TOzIMJPO9GhC+Hrwpaxz2H90aAQHhmwERZcH3AxxiXASErwJoGQkCAgICIl4EBAQERLwICAgICFKETGO8cEOVCzF35o8a0vcgiUjkSuOeEdeT5rkOtA2ikEkN8sokdiNH8WZyptdwx94H5Vk4j18UOhUWl1sRie/3j3CcjEjAV+lqqz006qT1gICXbR+a+Th39OtKln7/Hp1DGHTaR2eLwDPsktIeT+NwuYze1p3DVZUVXknLFjhbITkjx6uezVH+lGOZmseP7azNwxUYtDJp2VFaUW2cnpM3kkT4srwXvdl0n92nR5e/iQQCV1p2FZVU2GTk5DpaWxhe19PWyJZ2XYiJT53Vy8osWk1Z6ZMXYLAbOEpJD7K9VZUV83tYml6Uddt5XVFtmP30pVNBSVnPKladFlwdB9IuNNbVTIZ7baswFcuwDoRMiZdAwAsePi8Yezb2jv+mxVPtzAx00tqRkfprdwVlaqkpF4wa3OeAvDIIzgTYGXgu6v6j546g8miMH+bgK6+0k9Ife166de+n0U52WzXUVOAS3maWaaKS2npup8QH2VNTs18Mg8t9J490XD3OxWG7LGyJS0pfcDfrydCupnpRHyPelIwcd79j5yI1VZivNi6eagtI97WUywR/ITZpU209W9nNuc860Kg+uOQTJ3o37U2qU3iE4H48Hk8gFBJau68oJTPHI/tFofPwAb32gPJ7hEnOmYNTBvE4vrT3jHmSWzToQFj0nkVebjNlQbwlFVU9p6/588hEV4ed3qN/+OVTrqmpZ6seOx+718bCKFpWxJuY9mh84LnYgyUVLDVDHfXi3tbmp7sY6d1mKim8gpsmlVeyDB89f+kaEhl35FVpFdO4k2buT5Ncp3WzMLr9TRFvUywDj+Ph8XiCj39w6ghH293Txw5d+rn3iPonefHfF+N2k0hESObP5JlBgHQv5uQVORpoq+eHX7m9WY2pmO8kt5MTRDghIFRbK/NLlqYGd1r7xqgf+jYta4UbvWwJCL/xIPvFpPWLfrQHClgg78oED8T0O3I2OuFBtuvkEY7bJrs5+cgwLQKDRq11ceh5CNQvPtYB0FZXyRsxyK5NEVBcVtE1LTvXGZRfFCi/mG/JVVagU7FrCakrYu88WLp8+hhXeExWR9mSk1vY2zcgPEEE6t88zxEeDrbdzrf6RVP9hIF2VifnYNhs+O9FwCs3kjNnG3TSzJT3oQxEOTRGIpNB44we3HdpcGTcgXuZTycA9dtL9RNOyeVyeeTNB0Ljs58X2nmOcPxfQuqjRa0dWikrBIRFB9+6/9BtxfSx3oPsrY9v3h96+6+TUSHqKooFVp2Nb3xNDcHawujWwF5dT91OfeRR9Lq8q4GuZqY80wdKoj8g/ptwdVbAhgVmuppq380xLt8bgHuBg6c4b1jobZWUnjN7w77QmM6Gullrf/ZwVFJkVMnTluT0x+5bD5+J7N+9y+VVcyaM+Jxr3Z3t93RUHsplcK2Ry6P062kZeWzrMjJTgVE6c93ukpj41LkfuiYrJ9dp2ppdnPLqWv3Dmxdpjf6hrz+Hy1WUV8YEX4j1v3Tznvfcia7LIenC91bNnjAEuCfPfz96NraguKzz19QYTkTGbb+W+MADkO9JeZPu/pNRx//3R1CCs7114BHfJSpyIl0cjNt1lNr93gGjNgK+QHHamB+WHduyRIsvEFC81/hXBp2P/VNeNsBxhJOXbgYYaKuVLvYeNe6/lH9yW0ABCoaoSCLytiyf1uvSjZQl+8KiD8anPpqxdp6no+RGMQfDowOjbtybMXqw/cHZE11/hu/Vsxsg6cplmd25qwnrzlxNWDrFzXmLm7O9f/P7FDKJs26eZ59VO4898z0QlrTjlxlmX7Lk9nNBJhK5ryuqdQ6FRQcBsiHj8TgRny9gVLLq9MqqWLpwQ5jOhjoP/dfM6WGip50urzLNLyq13LjvRDKPLyCDtLubGehkyCttEonIzi0q6Txq/mZRW9WCw+HBAbV436XeAxFVyoZ/4Q94ft7O1XMs4AGpf4ZERMQmpi1c5j3GzdbKXKZHtleyavXKq2p0e1qaXqJSyK1ur8qqqVeNvHF3E+goyJIqUwQ6bhhnHz7Iboe6ipJcPbQOWbk20slud28bizPrdx9PnbZ6F2fN3ImDbYDrXlLJ0vttd0habX2D6vYV0x26mrUe15Qlrt66Pz8oIm6zh+sAf4+RjuskP4dE67vE23qV39EnkHx9l03rBgiZK0ubuHw+2VRNufi3BV7Dgs5e233xRspikDfJmxb9aAnsqZFn/jSvfgMewa6I2KRloNziq2pqjXz8g9MXerl5OdpZhcrDDri/h76Wet7vv8wwRvz3dcDepvPFk3+swh85fSVgc0D4ZXN97WwgrAYqy0icALJ8qaWm8vJZQXF/dkOjAp1GrZP8Dp1OrXOysz4KB2RFEis3z16J35yS9WS4Q6+uId8F8UJoqTKLAjYu1DoMCmnT/tB/DHU1nxaXVZpbGHa689c6j0FA0cjdhbx9L8szKCJ2nyKDikXeTFl24frdZW2qUDIRKyytMNt59OzVNT97OstrNe708S5LxgzpvxnGvmf4/MkCrt7/Rg/pJ5d9Y2GZvC6r0tl++Mzt4vIqvTU/TRppZ20RDT+LTUibs/t45Mmwy7f8fpvn6aCtoSrrPU7hJAU8HENA4YavC9BLHe/i8OvmA2Hx033+LB/v0n+X2yC7LdJesk4iEnlTRjnP8g0Ij9kVeP7K2vmeAyTTIBEJXH0djVZnUzHolIpWd+n/lom3GXNAIQ207Rb0294TN2eMHbJguGPv/R1hR+rDZ8P8gyNC7a3MY1f/NGnop1wTfz/Lwy/wfFhAaNTxeV5uU+VlqzJToRy4dl3upj8evSsk4sLFG8k+Pj97DDTR05ZZbBdUaAGfLyD6BZ69AzrHJL+VM03JYkp/iEPPw4P7dT+yJzji9Nz1f+UNtrM+s3ja6Il4PFqj8z0CzpPdtXpO5+SMHLc9xyMjr8WnLm86dUDKs216djWN9V89u8vGfSeTJ6/YLpo+dugc14G2R772/PkqNsnpYqKfdMp/dYcdUpn9oqDv70fOXLEyM0hZNXeiy6deN8DWKryyus7g8JlrO9SUlfI+drCktNGne5eI8J1dcAFhl44t3Xooo3/3LldXzBo3EigBWUwlw8FTKpZOHeXt1Kf16XSgUYmWzhg7wdPNyWjz/tCESUt/F831GD7HpY39kNsDPB4nZDdwFO9lPR0LO4VPuaabmWEcnUapxhDkGX6I+ttvJf7omav7Iv9Jhmf4SX2cxkBXM+folqXMzCd5gwLPXDuyL/TSYQ1VZr1tF5MLlqb6NxUV6IXYm2OOxOWwqLK6VpdI6JjzGQkbNmyQaQICgZCqr62RDTLgOiCE9pwvBufBk80NdJPMDHWTpGUfaLwKiWmPZvfobHx91sRhM0FBfBZpdTbRu2Ooo/68nFVroMZULFBSoJdLLe+EQrKGGjPf2sI4jkGjstr6Xm8riwhIbqzaeoOsp/nDtdSUsxn0tr//2T494LkXBcU2v8wcN+ZTjupRYNCqRzrZ7TI31E3Je1ViV1BcbmOsp5VCwOOF0ssbEQk8Z05tfUMnVi1b74Ovmnrwu17PqJNmKpVCrpVX43pbfgVvy09uZ7YJRUISk0Ert7Yw+kdVWemVLJ5LU5X50srCKO5T61mvrmbRQ/r1CCwsKTfu3sX0uixCc6A+5AO1u9dr5KCNQ/r22AvqYQkcgC6vqulWU8c2A3XBROxlClfeWRrrJ1iaGcTRqPLtkNF+vAgICAhyBgrAISAgICDiRUBAQEDEi4CAgICAiBcBAQEBES8CAgICAiJeBAQEBES8CAgICAiIeBEQEBAQ8SIgICAg4kVAQEBAkC7+Dz4XgX7Ufiv2AAAAAElFTkSuQmCC';\n const caso = summary.casoMontoPrincipal || null;\n const row = (label, value) => `\n \n ${label}\n ${value}\n `;\n const section = (title, rowsHtml) => `\n \n \n
\n

${title}

\n ${rowsHtml}
\n
`;\n const casoHtml = caso ? section('Caso de monto a revisar',\n row('Tipo', caso.tipo || 'Diferencia de monto') +\n row('Empleado / referencia', caso.empleado || 'No identificado') +\n row('Monto diferencia', `L ${fmt(caso.monto || Math.abs(diffMontos))}`) +\n `Observación: ${caso.observacion || summary.observacionIhss || 'Revisar con Administración.'}`\n ) : '';\n return `\n
\n \n \n
\n \"GomezLee\n
SEGURIDAD SOCIAL · HONDURAS
\n

Cruce mensual completado

\n

${periodo}

\n \n \n
${statusText}
\n \n \n \n \n \n \n
PLANILLAS IHSS
${summary.ihss || 0}
NÓMINA
${summary.nomina || 0}
DIF. MONTO
L ${fmt(diffMontos)}
\n ${section('Identidad',\n row('Resultado', diferenciasIdentidad ? 'Revisar diferencias' : 'Sin diferencias') +\n row('Coinciden en ambas', summary.ambas || 0) +\n row('Solo IHSS / Solo nómina', `${summary.soloIHSS || 0} / ${summary.soloNomina || 0}`)\n )}\n ${section('Montos IHSS',\n row('IHSS descontado en nómina', `L ${fmt(summary.payrollIhss)}`) +\n row('Cuota trabajador planillas', `L ${fmt(summary.pdfCuotaTrabajador)}`) +\n row('Diferencia', `L ${fmt(diffMontos)}`)\n )}\n ${casoHtml}\n
Abrir reporte en Google Sheets
\n
Archivos procesados
Nómina: ${summary.payrollFile || ''}
Planillas PDF: ${(summary.pdfFiles || []).join(', ')}
\n
\n
`;\n})() }}", + "options": { + "appendAttribution": false + } + }, + "type": "n8n-nodes-base.gmail", + "typeVersion": 2.2, + "position": [ + 2128, + 2896 + ], + "id": "306326b1-84c3-45b0-baa3-8a0c7379d13e", + "name": "Enviar correo", + "executeOnce": true, + "webhookId": "gmail-cruce-ihss-hn", + "credentials": { + "gmailOAuth2": { + "id": "UDcO1FLJqA453V2D", + "name": "Gmail account 3" + } + } + }, + { + "parameters": { + "content": "## INICIO DEL CRUCE HONDURAS\n\nEl usuario sube el mes a validar, la nómina mensual de Honduras y una o varias planillas IHSS en PDF. El flujo inicializa la ejecución, guarda el período seleccionado y prepara la memoria temporal para acumular los datos de identidad, planillas y montos IHSS.", + "height": 560, + "width": 800 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + -3056, + 2512 + ], + "id": "3151efbf-ed93-484a-a068-f6f132833033", + "name": "Sticky Note" + }, + { + "parameters": { + "content": "## LECTURA DE PLANILLAS Y NÓMINA\n\nEl flujo extrae identidades desde las planillas IHSS y desde las hojas válidas de la nómina. También lee la hoja IHSS-RAP para obtener el total mensual descontado por IHSS, separando el cruce de identidad del cruce de montos.", + "height": 976, + "width": 1968, + "color": 5 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + -1792, + 2480 + ], + "id": "7577fb2e-75b2-46e1-b423-4b24e25fb896", + "name": "Sticky Note1" + }, + { + "parameters": { + "content": "## REPORTE FINAL Y CORREO\n\nCuando ambas ramas terminan, el flujo genera un reporte en Google Sheets con cruce de identidad, cruce de montos IHSS, resumen de planillas y detalle de nómina. El correo resume los hallazgos y muestra si hay diferencias o casos puntuales para revisar.", + "height": 1344, + "width": 2816, + "color": 4 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 384, + 2384 + ], + "id": "7b17e3b0-a2ba-4558-ba65-284cf32d9b6b", + "name": "Sticky Note2" + }, + { + "parameters": { + "jsCode": "const init = $items('Inicializar memoria')[0].json;\n\nconst workflowName = (typeof $workflow !== 'undefined' && $workflow.name)\n ? $workflow.name\n : 'Cruce de Seguridad Social Honduras';\n\nconst workflowId = (typeof $workflow !== 'undefined' && $workflow.id)\n ? String($workflow.id)\n : '';\n\nconst executionId = (typeof $execution !== 'undefined' && $execution.id)\n ? String($execution.id)\n : '';\n\nreturn [{\n json: {\n id_ejecucion: init.runId,\n nombre_flujo: workflowName,\n id_flujo_n8n: workflowId || null,\n id_ejecucion_n8n: executionId || null,\n\n pais_codigo: 'HN',\n pais_nombre: 'Honduras',\n entidad_seguridad_social: 'IHSS',\n\n mes: Number(init.mes || 0),\n anio: Number(init.anio || 0),\n periodo: init.periodoLabel || `${init.mes}/${init.anio}`,\n\n estado: 'procesando',\n resultado: 'Cruce iniciado',\n fecha_inicio: new Date().toISOString(),\n\n destinatarios_correo: String(init.emailTo || '')\n .split(',')\n .map(e => e.trim())\n .filter(Boolean),\n\n moneda: 'HNL',\n archivos_procesados: {},\n conteos_fuente: {},\n metricas: {\n etapa: 'inicio',\n origen: 'n8n',\n formulario: 'Cruce IHSS vs Nómina Honduras'\n },\n resumen_completo: {\n reportTitle: init.reportTitle,\n runId: init.runId\n }\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -2736, + 2640 + ], + "id": "79fe75f9-432c-4a43-821f-d296c6af452b", + "name": "Preparar auditoría inicial Supabase", + "continueOnFail": true + }, + { + "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": [ + -2496, + 2640 + ], + "id": "177f9c58-b722-4975-b6c2-10c26d82c932", + "name": "Supabase - Crear auditoría inicial", + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "function num(v) {\n const n = Number(v || 0);\n return Number.isFinite(n) ? Number(n.toFixed(2)) : 0;\n}\nfunction arr(v) {\n return Array.isArray(v) ? v : [];\n}\n\nconst cruce = $('Construir reporte producción').item.json;\nconst init = $('Inicializar memoria').item.json;\nconst sheet = $('Crear Google Sheet Reporte').item.json;\nconst summary = cruce.summary || {};\n\nconst spreadsheetId = sheet.spreadsheetId || '';\nconst spreadsheetUrl = sheet.spreadsheetUrl || (spreadsheetId ? `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit` : '');\nconst recipients = String(cruce.emailTo || init.emailTo || '')\n .split(',')\n .map(e => e.trim())\n .filter(Boolean);\n\nconst identityDiffs = Number(summary.diferenciasIdentidad || 0);\nconst monetaryDiffs = Math.abs(Number(summary.diffMontos || 0)) > 1\n ? Math.max(Number(summary.casosMontoCount || 0), 1)\n : 0;\nconst documentAlerts = Number(summary.duplicatePlanillas || 0)\n + Number(summary.periodoMismatches || 0)\n + arr(summary.unresolvedSpecialEmployees).length;\n\nconst resultStatus = (identityDiffs || monetaryDiffs || documentAlerts)\n ? 'COMPLETADO · REVISAR HALLAZGOS'\n : 'COMPLETADO · SIN DIFERENCIAS';\n\nconst manualMinutes = 45;\nconst automationMinutes = 5;\n\nconst workflowName = (typeof $workflow !== 'undefined' && $workflow.name)\n ? $workflow.name\n : 'Cruce de Seguridad Social Honduras';\n\nconst workflowId = (typeof $workflow !== 'undefined' && $workflow.id)\n ? String($workflow.id)\n : '';\n\nconst executionId = (typeof $execution !== 'undefined' && $execution.id)\n ? String($execution.id)\n : '';\n\nreturn [{\n json: {\n id_ejecucion: cruce.runId,\n nombre_flujo: workflowName,\n id_flujo_n8n: workflowId || null,\n id_ejecucion_n8n: executionId || null,\n\n pais_codigo: 'HN',\n pais_nombre: 'Honduras',\n entidad_seguridad_social: 'IHSS',\n\n mes: Number(init.mes || 0),\n anio: Number(init.anio || 0),\n periodo: init.periodoLabel || `${init.mes}/${init.anio}`,\n\n estado: 'exitoso',\n resultado: resultStatus,\n fecha_fin: new Date().toISOString(),\n\n url_reporte: spreadsheetUrl,\n id_google_sheet: spreadsheetId || null,\n destinatarios_correo: recipients,\n\n moneda: 'HNL',\n\n empleados_nomina: Number(summary.nomina || 0),\n empleados_entidad: Number(summary.ihss || 0),\n empleados_coincidentes: Number(summary.ambas || 0),\n\n solo_nomina: Number(summary.soloNomina || 0),\n solo_entidad: Number(summary.soloIHSS || 0),\n\n diferencias_total: identityDiffs + monetaryDiffs + documentAlerts,\n diferencias_monetarias: monetaryDiffs,\n diferencias_identidad: identityDiffs,\n alertas_documentales: documentAlerts,\n registros_duplicados: Number(summary.duplicatePlanillas || 0),\n alertas_periodo: Number(summary.periodoMismatches || 0),\n\n total_nomina: num(summary.payrollIhss),\n total_entidad: num(summary.pdfCuotaTrabajador),\n diferencia_monto: num(summary.diffMontos),\n\n minutos_estimados_manual: manualMinutes,\n minutos_automatizacion: automationMinutes,\n minutos_ahorrados: Math.max(manualMinutes - automationMinutes, 0),\n\n archivos_procesados: {\n nomina: summary.payrollFile || '',\n planillas_pdf: arr(summary.pdfFiles)\n },\n conteos_fuente: {\n identidades_nomina: Number(summary.nomina || 0),\n identidades_ihss: Number(summary.ihss || 0),\n coincidentes: Number(summary.ambas || 0),\n solo_nomina: Number(summary.soloNomina || 0),\n solo_ihss: Number(summary.soloIHSS || 0),\n planillas_pdf: Number(summary.planillas || 0)\n },\n metricas: {\n ihss_nomina_base: num(summary.payrollBaseIhss),\n ajuste_ihss_personal_especial: num(summary.payrollAdjustmentIhss),\n ihss_nomina_ajustado: num(summary.payrollIhss),\n cuota_trabajador_planillas: num(summary.pdfCuotaTrabajador),\n cuota_patrono_planillas: num(summary.pdfCuotaPatrono),\n total_planillas_pdf: num(summary.pdfTotalPlanilla),\n diferencia_ihss: num(summary.diffMontos),\n trabajadores_pdf: Number(summary.pdfTrabajadores || 0),\n personal_especial_resuelto: arr(summary.resolvedSpecialEmployees).length,\n personal_especial_no_resuelto: arr(summary.unresolvedSpecialEmployees).length,\n planillas_duplicadas: Number(summary.duplicatePlanillas || 0),\n alertas_periodo: Number(summary.periodoMismatches || 0)\n },\n resumen_completo: {\n summary,\n rowCounts: cruce.rowCounts || {},\n reporte: {\n spreadsheetId,\n spreadsheetUrl\n }\n },\n\n mensaje_error: null,\n nodo_error: null\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2128, + 2672 + ], + "id": "f2cf79c4-1e28-4999-9a84-3b6dfdd0c227", + "name": "Preparar auditoría final Supabase", + "continueOnFail": true + }, + { + "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": "resolution=merge-duplicates,return=minimal" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 2368, + 2672 + ], + "id": "e40c7b0b-5e98-4f76-89be-40ca9cadb33c", + "name": "Supabase - Actualizar auditoría final", + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "function clean(v){ return String(v ?? '').replace(/\\s+/g,' ').trim(); }\nfunction n(v){ const x = Number(v || 0); return Number.isFinite(x) ? Number(x.toFixed(2)) : 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 && clean(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 diferencias')) continue;\n if (String(row[0] || '').toLowerCase().startsWith('no aplica')) continue;\n out.push(row);\n }\n return out;\n}\n\nconst cruce = $('Construir reporte producción').item.json;\nconst summary = cruce.summary || {};\nconst common = {\n id_ejecucion: cruce.runId,\n pais_codigo: 'HN',\n pais_nombre: 'Honduras',\n moneda: 'HNL'\n};\nconst out = [];\n\nfor (const r of parseSection(cruce.identidadRows, 'Empleados en planilla IHSS que NO aparecen en nómina')) {\n push(out, {\n ...common,\n categoria: 'Solo en IHSS',\n severidad: 'revision',\n identificador_empleado: clean(r[0]),\n nombre_empleado: clean(r[1]),\n fuente_a: 'Planilla IHSS',\n fuente_b: 'Nómina Honduras',\n diferencia_monto: null,\n descripcion: clean(r[4]) || 'Está en IHSS, pero no aparece en la nómina cargada.',\n accion_sugerida: 'Validar si el empleado debe estar en nómina o si pertenece a personal especial documentado.',\n datos_originales: { fila_reporte: r, numero_patronal: r[2], archivo_planilla: r[3] }\n });\n}\n\nfor (const r of parseSection(cruce.identidadRows, 'Empleados en nómina que NO aparecen en planilla IHSS')) {\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 Honduras',\n fuente_b: 'Planilla IHSS',\n diferencia_monto: null,\n descripcion: clean(r[3]) || 'Está en nómina, pero no aparece en las planillas IHSS cargadas.',\n accion_sugerida: 'Validar si falta planilla IHSS, si existe error de identidad o si no corresponde reportarlo en IHSS.',\n datos_originales: { fila_reporte: r, hoja_nomina: r[2] }\n });\n}\n\nfor (const c of Array.isArray(summary.casosMonto) ? summary.casosMonto : []) {\n if (!c || String(c.tipo || '').toLowerCase().startsWith('sin diferencias')) continue;\n push(out, {\n ...common,\n categoria: Math.abs(Number(summary.diffMontos || 0)) <= 1 ? 'Diferencia menor / redondeo' : 'Diferencia de monto IHSS',\n severidad: Math.abs(Number(summary.diffMontos || 0)) <= 1 ? 'info' : 'revision',\n identificador_empleado: null,\n nombre_empleado: clean(c.empleado),\n fuente_a: 'Nómina Honduras / IHSS-RAP',\n fuente_b: 'Planillas IHSS PDF',\n diferencia_monto: n(c.monto),\n descripcion: clean(c.observacion) || `Diferencia de IHSS detectada: ${clean(c.monto)}.`,\n accion_sugerida: clean(c.accion) || 'Revisar diferencia con Nómina/Administración.',\n datos_originales: { caso: c, diferencia_total: summary.diffMontos }\n });\n}\n\nfor (const d of Array.isArray(summary.duplicatePlanillasDetalle) ? summary.duplicatePlanillasDetalle : []) {\n push(out, {\n ...common,\n categoria: 'Planilla duplicada ignorada',\n severidad: 'advertencia',\n identificador_empleado: null,\n nombre_empleado: null,\n fuente_a: 'Planillas IHSS PDF',\n fuente_b: 'Planillas IHSS PDF',\n diferencia_monto: null,\n descripcion: `Se ignoró una planilla duplicada: ${clean(d.archivo_duplicado)}.`,\n accion_sugerida: 'Evitar subir el mismo PDF/recibo dos veces en próximos cruces.',\n datos_originales: d\n });\n}\n\nfor (const p of Array.isArray(summary.periodoMismatchesDetalle) ? summary.periodoMismatchesDetalle : []) {\n push(out, {\n ...common,\n categoria: 'Planilla de período distinto',\n severidad: 'advertencia',\n identificador_empleado: null,\n nombre_empleado: null,\n fuente_a: 'Planilla IHSS PDF',\n fuente_b: 'Formulario',\n diferencia_monto: null,\n descripcion: `La planilla ${clean(p.archivo_planilla)} tiene período ${clean(p.periodo_detectado)} y se esperaba ${clean(p.periodo_esperado)}.`,\n accion_sugerida: 'Validar que todas las planillas correspondan al mes seleccionado.',\n datos_originales: p\n });\n}\n\nfor (const sp of Array.isArray(summary.unresolvedSpecialEmployees) ? summary.unresolvedSpecialEmployees : []) {\n push(out, {\n ...common,\n categoria: 'Personal especial no resuelto automáticamente',\n severidad: 'revision',\n identificador_empleado: null,\n nombre_empleado: clean(sp.referencia),\n fuente_a: 'IHSS-RAP',\n fuente_b: 'Planilla IHSS',\n diferencia_monto: n(sp.monto),\n descripcion: 'El flujo detectó personal especial, pero no pudo resolverlo automáticamente contra una identidad única.',\n accion_sugerida: 'Revisar manualmente la referencia del personal especial en IHSS-RAP.',\n datos_originales: sp\n });\n}\n\nreturn out;" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2368, + 2496 + ], + "id": "6608f12c-681d-4b7e-a670-551c9cb51a1a", + "name": "Preparar hallazgos Supabase", + "continueOnFail": true + }, + { + "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": [ + 2608, + 2496 + ], + "id": "93bbccd0-f37b-4fdf-90e9-c646b9eac07c", + "name": "Supabase - Insertar hallazgos", + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "function clean(v){ return String(v ?? '').replace(/\\s+/g,' ').trim(); }\nfunction n(v){ const x = Number(v || 0); return Number.isFinite(x) ? Number(x.toFixed(2)) : null; }\nconst cruce = $('Construir reporte producción').item.json;\nconst summary = cruce.summary || {};\nconst common = { id_ejecucion: cruce.runId, pais_codigo: 'HN' };\nconst out = [];\n\nif (clean(summary.payrollFile)) {\n out.push({ json: {\n ...common,\n rol_archivo: 'Nómina Honduras',\n nombre_archivo: clean(summary.payrollFile),\n tipo_archivo: clean(summary.payrollFile).toLowerCase().endsWith('.csv') ? 'csv' : 'excel',\n registros_detectados: n(summary.nomina),\n monto_total: n(summary.payrollIhss),\n datos_originales: { key: 'nomina', summary }\n }});\n}\n\nconst pdfFiles = Array.isArray(summary.pdfFiles) ? summary.pdfFiles : [];\nfor (const name of pdfFiles) {\n if (!clean(name)) continue;\n out.push({ json: {\n ...common,\n rol_archivo: 'Planilla IHSS PDF',\n nombre_archivo: clean(name),\n tipo_archivo: 'pdf',\n registros_detectados: null,\n monto_total: null,\n datos_originales: { key: 'planilla_pdf', summary }\n }});\n}\n\nreturn out;" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2368, + 2864 + ], + "id": "11a04947-73d4-4152-abf9-91ccdb1d8798", + "name": "Preparar archivos Supabase", + "continueOnFail": true + }, + { + "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": [ + 2608, + 2864 + ], + "id": "335cf21c-fc50-4898-95a6-7c190a636c8a", + "name": "Supabase - Insertar archivos procesados", + "continueOnFail": true + }, + { + "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": [ + 1680, + 2896 + ], + "id": "d8926dbf-bbc7-4199-a76a-92fac0a4ee39", + "name": "Compartir con Destinatario 1", + "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": [ + 1904, + 2896 + ], + "id": "89367720-3e43-4371-9c69-0fdc034d32bc", + "name": "Compartir con Destinatario 2", + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + } + }, + { + "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": [ + 528, + 3472 + ], + "id": "a452436d-301e-4648-96cd-54cec4c28d48", + "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.hnCruceIHSSNomina = data.hnCruceIHSSNomina || {};\ndata.hnCruceIHSSNomina[init.runId] = data.hnCruceIHSSNomina[init.runId] || {};\ndata.hnCruceIHSSNomina[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": [ + 752, + 3472 + ], + "id": "98d575e0-97cd-4ea2-ad9c-026527f2d863", + "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": [ + 2128, + 3184 + ], + "id": "dab0678c-b102-42c4-b151-3b86a2833d4c", + "name": "Compartir con Destinatario 3", + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + } + }, + { + "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": [ + 2352, + 3184 + ], + "id": "f61bbb7c-2b7b-464c-ae17-a3ce42f826db", + "name": "Compartir con Destinatario 4", + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + } + }, + { + "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": [ + 2576, + 3184 + ], + "id": "706ec0e8-5d5c-4786-b769-91ae4c24f8da", + "name": "Compartir con Destinatario 5", + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + } + }, + { + "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": [ + 2800, + 3184 + ], + "id": "c9ec0743-dcec-46aa-8009-33f28fbf2ede", + "name": "Compartir con Destinatario 6", + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + } + }, + { + "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": [ + 3024, + 3184 + ], + "id": "9bc51788-7f60-41c5-bc25-2f1b9f5e432b", + "name": "Compartir con Destinatario 7", + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + } + } + ], + "pinData": {}, + "connections": { + "On form submission": { + "main": [ + [ + { + "node": "Inicializar memoria", + "type": "main", + "index": 0 + } + ] + ] + }, + "Inicializar memoria": { + "main": [ + [ + { + "node": "Separar planillas PDF", + "type": "main", + "index": 0 + }, + { + "node": "Preparar nómina", + "type": "main", + "index": 0 + }, + { + "node": "Preparar auditoría inicial Supabase", + "type": "main", + "index": 0 + } + ] + ] + }, + "Separar planillas PDF": { + "main": [ + [ + { + "node": "Extraer texto PDF", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extraer texto PDF": { + "main": [ + [ + { + "node": "Guardar IHSS en memoria", + "type": "main", + "index": 0 + } + ] + ] + }, + "Guardar IHSS en memoria": { + "main": [ + [ + { + "node": "Esperar ramas", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar nómina": { + "main": [ + [ + { + "node": "Actualizar staging Nómina", + "type": "main", + "index": 0 + } + ] + ] + }, + "Actualizar staging Nómina": { + "main": [ + [ + { + "node": "Esperar conversión", + "type": "main", + "index": 0 + } + ] + ] + }, + "Esperar conversión": { + "main": [ + [ + { + "node": "Listar hojas Nómina", + "type": "main", + "index": 0 + } + ] + ] + }, + "Listar hojas Nómina": { + "main": [ + [ + { + "node": "Preparar hojas Nómina", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar hojas Nómina": { + "main": [ + [ + { + "node": "Loop hojas Nómina", + "type": "main", + "index": 0 + } + ] + ] + }, + "Loop hojas Nómina": { + "main": [ + [ + { + "node": "Esperar ramas", + "type": "main", + "index": 1 + } + ], + [ + { + "node": "Leer hoja Nómina", + "type": "main", + "index": 0 + } + ] + ] + }, + "Leer hoja Nómina": { + "main": [ + [ + { + "node": "Acumular identidades Nómina", + "type": "main", + "index": 0 + } + ] + ] + }, + "Acumular identidades Nómina": { + "main": [ + [ + { + "node": "Loop hojas Nómina", + "type": "main", + "index": 0 + } + ] + ] + }, + "Esperar ramas": { + "main": [ + [ + { + "node": "Consultar empleados BambooHR", + "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 Destinatario 1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Construir reporte producción": { + "main": [ + [ + { + "node": "Crear Google Sheet Reporte", + "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 + } + ] + ] + }, + "Compartir con Destinatario 1": { + "main": [ + [ + { + "node": "Compartir con Destinatario 2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Compartir con Destinatario 2": { + "main": [ + [ + { + "node": "Compartir con Destinatario 3", + "type": "main", + "index": 0 + } + ] + ] + }, + "Consultar empleados BambooHR": { + "main": [ + [ + { + "node": "Guardar BambooHR en memoria", + "type": "main", + "index": 0 + } + ] + ] + }, + "Guardar BambooHR en memoria": { + "main": [ + [ + { + "node": "Construir reporte producción", + "type": "main", + "index": 0 + } + ] + ] + }, + "Compartir con Destinatario 3": { + "main": [ + [ + { + "node": "Compartir con Destinatario 4", + "type": "main", + "index": 0 + } + ] + ] + }, + "Compartir con Destinatario 4": { + "main": [ + [ + { + "node": "Compartir con Destinatario 5", + "type": "main", + "index": 0 + } + ] + ] + }, + "Compartir con Destinatario 5": { + "main": [ + [ + { + "node": "Compartir con Destinatario 6", + "type": "main", + "index": 0 + } + ] + ] + }, + "Compartir con Destinatario 6": { + "main": [ + [ + { + "node": "Compartir con Destinatario 7", + "type": "main", + "index": 0 + } + ] + ] + }, + "Compartir con Destinatario 7": { + "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": "f6c01e0c-c22b-41c4-bd6a-1bbbe99c482d", + "meta": { + "instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70" + }, + "id": "j04wjKihKPzBIqW8", + "tags": [] +} \ No newline at end of file diff --git a/Cruce de Seguridad Social - República Dominicana (6).json b/Cruce de Seguridad Social - República Dominicana (6).json new file mode 100644 index 0000000..f2fc52f --- /dev/null +++ b/Cruce de Seguridad Social - República Dominicana (6).json @@ -0,0 +1,1624 @@ +{ + "name": "Cruce de Seguridad Social - República Dominicana", + "nodes": [ + { + "parameters": { + "formTitle": "Cruce Seguridad Social RD", + "formDescription": "Sube Nómina 1, Nómina 2 y el archivo TSS del mes. El flujo genera reporte en Google Sheets y envía resumen por correo.", + "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", + "placeholder": "2026", + "requiredField": true + }, + { + "fieldLabel": "Nómina 1", + "fieldType": "file", + "multipleFiles": false, + "acceptFileTypes": ".xlsx,.xls", + "requiredField": true + }, + { + "fieldLabel": "Nómina 2", + "fieldType": "file", + "multipleFiles": false, + "acceptFileTypes": ".xlsx,.xls", + "requiredField": true + }, + { + "fieldLabel": "TSS", + "fieldType": "file", + "multipleFiles": false, + "acceptFileTypes": ".xlsx,.xls", + "requiredField": true + } + ] + }, + "responseMode": "lastNode", + "options": { + "ignoreBots": true + } + }, + "type": "n8n-nodes-base.formTrigger", + "typeVersion": 2.3, + "position": [ + -4432, + 3344 + ], + "id": "1b2e4628-d62f-4aad-9311-f39f4e2dc776", + "name": "On form submission", + "webhookId": "44280b50-f6d1-44dc-9a59-206a9c3d940f" + }, + { + "parameters": { + "jsCode": "const item = $input.first();\nconst now = new Date();\nconst pad = n => String(n).padStart(2, '0');\nconst mes = String(item.json['Mes a validar'] || '').replace(/\\D/g, '').padStart(2, '0');\nconst anioRaw = String(item.json['Año a validar'] || item.json['Ano a validar'] || '').trim();\nconst anio = Number(anioRaw.replace(/\\D/g, ''));\nif (!/^\\d{2}$/.test(mes) || Number(mes) < 1 || Number(mes) > 12) throw new Error('Selecciona un mes válido entre 01 y 12.');\nif (!/^\\d{4}$/.test(String(anio)) || anio < 2020 || anio > 2100) throw new Error('Escribe un año válido de 4 dígitos, por ejemplo 2026.');\nconst meses = { '01':'Enero','02':'Febrero','03':'Marzo','04':'Abril','05':'Mayo','06':'Junio','07':'Julio','08':'Agosto','09':'Septiembre','10':'Octubre','11':'Noviembre','12':'Diciembre' };\nconst ts = `${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;\nconst runId = `RD_TSS_${ts}_${Math.random().toString(36).slice(2,8)}`;\nconst periodoLabel = `${meses[mes]} ${anio}`;\nconst fechaTitulo = `${pad(now.getDate())}-${pad(now.getMonth()+1)}-${now.getFullYear()}`;\nconst reportTitle = `Cruce Seguridad Social RD - ${periodoLabel} - ${fechaTitulo}`;\nreturn [{ json: { runId, mes, anio, periodoLabel, reportTitle }, binary: item.binary }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -4192, + 3344 + ], + "id": "a81091a9-e1c6-4dbb-b10f-556524cb026d", + "name": "Inicializar ejecución" + }, + { + "parameters": { + "jsCode": "const base = $items('Inicializar ejecución')[0];\nconst bin = base.binary || {};\nconst wanted = \"n1\";\nfunction ascii(s) { return String(s || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase(); }\nfunction scoreFile(key, file) {\n const fileName = String(file.fileName || key);\n const s = ascii(`${key} ${fileName}`);\n let score = 0;\n if (!/\\.xlsx$|\\.xls$|spreadsheet|excel|nomina|n[oó]mina|tss|constancia|recepcion/.test(s)) return -999;\n if (wanted === 'tss') {\n if (/tss|constancia|recepcion/.test(s)) score += 200;\n if (/nomina|n[oó]mina|field force/.test(s)) score -= 100;\n }\n if (wanted === 'n1') {\n if (/nomina|n[oó]mina|field force/.test(s)) score += 50;\n if (/\\b15\\b|_15| 15 |quincena 1|nomina 1|n[oó]mina 1|1q|primera/.test(s)) score += 200;\n if (/\\b30\\b|\\b28\\b|_30|_28|quincena 2|nomina 2|n[oó]mina 2|2q|segunda/.test(s)) score -= 50;\n if (/tss|constancia|recepcion/.test(s)) score -= 200;\n }\n if (wanted === 'n2') {\n if (/nomina|n[oó]mina|field force/.test(s)) score += 50;\n if (/\\b30\\b|\\b28\\b|_30|_28| 30 | 28 |quincena 2|nomina 2|n[oó]mina 2|2q|segunda/.test(s)) score += 200;\n if (/\\b15\\b|_15| 15 |quincena 1|nomina 1|n[oó]mina 1|1q|primera/.test(s)) score -= 50;\n if (/tss|constancia|recepcion/.test(s)) score -= 200;\n }\n return score;\n}\nconst candidates = Object.entries(bin).map(([key,file]) => ({ key, file, score: scoreFile(key,file), fileName: file.fileName || key })).filter(x => x.score > -100).sort((a,b) => b.score - a.score);\nif (!candidates.length || candidates[0].score <= 0) throw new Error('No pude identificar el archivo Nómina 1. Revisa que el nombre/campo del formulario sea claro.');\nconst chosen = candidates[0];\nreturn [{ json: { runId: base.json.runId, mes: base.json.mes, anio: base.json.anio, periodoLabel: base.json.periodoLabel, reportTitle: base.json.reportTitle, fileName: chosen.fileName }, binary: { data: chosen.file } }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -3056, + 3120 + ], + "id": "fe4c4689-717d-4432-9b9f-3a9239dd0252", + "name": "Preparar Nómina 1" + }, + { + "parameters": { + "operation": "update", + "fileId": { + "__rl": true, + "value": "1Vfp9KE8YBxxB2QH7jNbVqrOxnSKYfaMV7HjD8oiSEJk", + "mode": "id" + }, + "changeFileContent": true, + "options": {} + }, + "type": "n8n-nodes-base.googleDrive", + "typeVersion": 3, + "position": [ + -2784, + 3120 + ], + "id": "4d4abd02-8b7c-4fa8-bb1d-937cfa006661", + "name": "Actualizar staging Nómina 1", + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + } + }, + { + "parameters": {}, + "type": "n8n-nodes-base.wait", + "typeVersion": 1.1, + "position": [ + -2544, + 3120 + ], + "id": "bc674328-2407-47e1-a195-41e361a63ba0", + "name": "Esperar conversión Nómina 1", + "webhookId": "d7b42aa0-1dc6-4677-a909-e591027d75bb" + }, + { + "parameters": { + "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.id || $json.spreadsheetId || '1Vfp9KE8YBxxB2QH7jNbVqrOxnSKYfaMV7HjD8oiSEJk' }}?fields=spreadsheetId,sheets(properties(title,sheetId,hidden))", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + -2304, + 3120 + ], + "id": "66089f5e-c78d-41c5-a4c2-36d1d16bc358", + "name": "Listar hojas Nómina 1", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const init = $items('Inicializar ejecución')[0].json;\nconst FALLBACK_ID = '1Vfp9KE8YBxxB2QH7jNbVqrOxnSKYfaMV7HjD8oiSEJk';\nconst SOURCE_LABEL = 'Nómina 1';\nconst PREP_NODE = 'Preparar Nómina 1';\nconst spreadsheetId = $json.spreadsheetId || $json.id || $json.fileId || FALLBACK_ID;\nfunction norm(s) { return String(s || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]/g, ''); }\nfunction quoteA1SheetName(title) {\n return \"'\" + String(title).replace(/'/g, \"''\") + \"'!A1:AZ3000\";\n}\nconst ignore = /resumen|validacion|nvscripts|^isr$|calculo|autocrat|do\\s*not\\s*delete|^hoja|^copia\\s*de/i;\nconst sheets = ($json.sheets || []).map(s => s.properties || {}).filter(p => p.title && !p.hidden);\nconst sheetTitles = [];\nconst sheetDisplayTitles = [];\nfor (const p of sheets) {\n // IMPORTANTÍSIMO: NO usar trim() para el título real de Google Sheets.\n // Algunas nóminas tienen hojas como \"GLM RD \" y \"Latam \" con espacio al final.\n // Si se recorta ese espacio, Google Sheets devuelve: Unable to parse range.\n const rawTitle = String(p.title || '');\n const displayTitle = rawTitle.trim();\n const n = norm(rawTitle);\n if (!displayTitle || ignore.test(displayTitle) || n.startsWith('copiade')) continue;\n sheetTitles.push(rawTitle);\n sheetDisplayTitles.push(displayTitle);\n}\nif (!sheetTitles.length) throw new Error(`No encontré hojas válidas de nómina en ${SOURCE_LABEL}.`);\nconst rangesQuery = sheetTitles\n .map(title => `ranges=${encodeURIComponent(quoteA1SheetName(title))}`)\n .join('&');\nreturn [{\n json: {\n runId: init.runId,\n mes: init.mes,\n anio: init.anio,\n spreadsheetId,\n rangesQuery,\n sheetTitles,\n sheetDisplayTitles,\n quarter: SOURCE_LABEL,\n fileName: $(PREP_NODE).item.json.fileName,\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -2064, + 3120 + ], + "id": "515516e1-09f6-4b71-89cb-ee907e246019", + "name": "Preparar hojas Nómina 1" + }, + { + "parameters": { + "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.spreadsheetId }}/values:batchGet?{{ $json.rangesQuery }}&majorDimension=ROWS&valueRenderOption=UNFORMATTED_VALUE&dateTimeRenderOption=SERIAL_NUMBER", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + -1824, + 3120 + ], + "id": "23fb2cde-b1c9-41da-a5f6-e852a970afba", + "name": "Leer hojas Nómina 1", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "function ascii(s){return String(s??'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'');}\nfunction clean(s){return String(s??'').replace(/\\s+/g,' ').trim();}\nfunction hnorm(s){return ascii(s).toLowerCase().replace(/[^a-z0-9]/g,'');}\nfunction nameNorm(s){return ascii(s).toUpperCase().replace(/[,.;:()\\[\\]{}]/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; let s=String(v).trim(); if(!s) return 0; s=s.replace(/RD\\$|DOP|USD|US\\$|\\$|,/gi,'').replace(/\\s/g,''); if(/^\\((.*)\\)$/.test(s)) s='-'+s.replace(/^\\(|\\)$/g,''); const n=Number(s); return Number.isFinite(n)?n:0; }\nfunction money(n){return Number((Number(n)||0).toFixed(2));}\nfunction rawDigits(v){ if(v===null||v===undefined) return ''; let s=String(v).trim(); if(/^\\d+(\\.0+)?$/.test(s)) s=s.replace(/\\.0+$/,''); return s.replace(/\\D/g,''); }\nfunction normalizeDoc(v){ const d=rawDigits(v); if(!d) return ''; if(d.length<=11) return d.padStart(11,'0'); return d; }\nfunction docIssue(v){ const d=rawDigits(v); if(!d) return 'vacío'; if(d.length<11) return 'documento corto normalizado con ceros'; if(d.length>11) return 'documento con más de 11 dígitos'; return ''; }\nfunction pad(n){return String(n).padStart(2,'0');}\nfunction excelSerialToYM(v){ const epoch=new Date(Date.UTC(1899,11,30)); const d=new Date(epoch.getTime()+Math.round(Number(v))*86400000); return `${pad(d.getUTCMonth()+1)}/${d.getUTCFullYear()}`; }\nfunction parseDateParts(a,b,y,targetMes){ a=Number(a); b=Number(b); let month; if(targetMes && String(a).padStart(2,'0')===targetMes) month=a; else if(targetMes && String(b).padStart(2,'0')===targetMes) month=b; else if(a>12 && b<=12) month=b; else if(b>12 && a<=12) month=a; else month=b; return `${pad(month)}/${String(y).length===2?'20'+y:y}`; }\nfunction parseYM(v,targetMes){ if(typeof v==='number'&&Number.isFinite(v)&&v>20000&&v<70000) return excelSerialToYM(v); const s=clean(v); if(!s) return ''; if(/^\\d+(\\.0+)?$/.test(s)){ const n=Number(s); if(n>20000&&n<70000) return excelSerialToYM(n); } let m=s.match(/(\\d{1,2})[\\/-](\\d{1,2})[\\/-](\\d{2,4})/); if(m) return parseDateParts(m[1],m[2],m[3],targetMes); m=s.match(/(\\d{4})[\\/-](\\d{1,2})[\\/-](\\d{1,2})/); if(m) return `${pad(Number(m[2]))}/${m[1]}`; m=s.match(/(\\d{1,2})[\\/-](\\d{4})/); if(m) return `${pad(Number(m[1]))}/${m[2]}`; return ''; }\nfunction col(headers,candidates,fallback=[]){ const hs=headers.map(hnorm); for(const c of candidates){ const i=hs.findIndex(h=>h===c); if(i>=0) return i; } for(const c of fallback){ const i=hs.findIndex(h=>h.includes(c)); if(i>=0) return i; } return -1; }\nfunction findHeader(values){ for(let r=0;rh==='cedula'||h.includes('cedula')||h.includes('documento')); const hasName=hs.some(h=>h==='nombre'||h.includes('nombre')); const hasPeriod=hs.some(h=>h.includes('periodo')); const hasAfp=hs.some(h=>h==='afp'||h.includes('afp')); const hasSfs=hs.some(h=>h==='sfs'||h==='ars'||h.includes('sfs')||h.includes('ars')); if(hasDoc&&hasName&&hasPeriod&&hasAfp&&hasSfs) return r; } return -1; }\nfunction sheetNameFromRange(range){ const s=String(range||''); if(s.startsWith(\"'\")){ let out=''; for(let i=1;i=0?clean(row[idx.email]):'', novedad: idx.novedad>=0?clean(row[idx.novedad]):'' };\n allRecords.push(rec); count++; salario+=salarioPeriodo; vac+=vacaciones; afp+=afpMonto; sfs+=sfsMonto; neto+=netoMonto;\n }\n sheetSummaries.push({fuente:'Nómina 1', archivo:prep.fileName, hoja:sheetTitle, empleados:count, salario_periodo:money(salario), vacaciones:money(vac), base_ss:money(salario+vac), afp:money(afp), sfs:money(sfs), neto:money(neto)});\n}\nreturn [{json:{runId:init.runId, source:'Nómina 1', payrollRecords:allRecords, sheetSummaries, warnings, sourceFiles:{'Nómina 1': prep.fileName} }}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -1568, + 3200 + ], + "id": "8014af0f-898a-4d77-b0a6-27e7aa7334ba", + "name": "Extraer Nómina 1" + }, + { + "parameters": { + "jsCode": "const base = $items('Inicializar ejecución')[0];\nconst bin = base.binary || {};\nconst wanted = \"n2\";\nfunction ascii(s) { return String(s || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase(); }\nfunction scoreFile(key, file) {\n const fileName = String(file.fileName || key);\n const s = ascii(`${key} ${fileName}`);\n let score = 0;\n if (!/\\.xlsx$|\\.xls$|spreadsheet|excel|nomina|n[oó]mina|tss|constancia|recepcion/.test(s)) return -999;\n if (wanted === 'tss') {\n if (/tss|constancia|recepcion/.test(s)) score += 200;\n if (/nomina|n[oó]mina|field force/.test(s)) score -= 100;\n }\n if (wanted === 'n1') {\n if (/nomina|n[oó]mina|field force/.test(s)) score += 50;\n if (/\\b15\\b|_15| 15 |quincena 1|nomina 1|n[oó]mina 1|1q|primera/.test(s)) score += 200;\n if (/\\b30\\b|\\b28\\b|_30|_28|quincena 2|nomina 2|n[oó]mina 2|2q|segunda/.test(s)) score -= 50;\n if (/tss|constancia|recepcion/.test(s)) score -= 200;\n }\n if (wanted === 'n2') {\n if (/nomina|n[oó]mina|field force/.test(s)) score += 50;\n if (/\\b30\\b|\\b28\\b|_30|_28| 30 | 28 |quincena 2|nomina 2|n[oó]mina 2|2q|segunda/.test(s)) score += 200;\n if (/\\b15\\b|_15| 15 |quincena 1|nomina 1|n[oó]mina 1|1q|primera/.test(s)) score -= 50;\n if (/tss|constancia|recepcion/.test(s)) score -= 200;\n }\n return score;\n}\nconst candidates = Object.entries(bin).map(([key,file]) => ({ key, file, score: scoreFile(key,file), fileName: file.fileName || key })).filter(x => x.score > -100).sort((a,b) => b.score - a.score);\nif (!candidates.length || candidates[0].score <= 0) throw new Error('No pude identificar el archivo Nómina 2. Revisa que el nombre/campo del formulario sea claro.');\nconst chosen = candidates[0];\nreturn [{ json: { runId: base.json.runId, mes: base.json.mes, anio: base.json.anio, periodoLabel: base.json.periodoLabel, reportTitle: base.json.reportTitle, fileName: chosen.fileName }, binary: { data: chosen.file } }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -3056, + 3408 + ], + "id": "27921c05-45a2-4188-956a-997554f7c1d8", + "name": "Preparar Nómina 2" + }, + { + "parameters": { + "operation": "update", + "fileId": { + "__rl": true, + "value": "1IkdCdR6ZjqCHSFZ8_SGQx6DrNGq-ai5GxByAtyg1R8g", + "mode": "id" + }, + "changeFileContent": true, + "options": {} + }, + "type": "n8n-nodes-base.googleDrive", + "typeVersion": 3, + "position": [ + -2784, + 3408 + ], + "id": "878795ff-8290-4b37-8d6c-54e751ab1fb2", + "name": "Actualizar staging Nómina 2", + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + } + }, + { + "parameters": {}, + "type": "n8n-nodes-base.wait", + "typeVersion": 1.1, + "position": [ + -2544, + 3408 + ], + "id": "0c8ba62e-3681-475a-abc7-065ada6d4a8d", + "name": "Esperar conversión Nómina 2", + "webhookId": "e3618269-c64b-4090-b9af-29cb7413fa8a" + }, + { + "parameters": { + "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.id || $json.spreadsheetId || '1IkdCdR6ZjqCHSFZ8_SGQx6DrNGq-ai5GxByAtyg1R8g' }}?fields=spreadsheetId,sheets(properties(title,sheetId,hidden))", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + -2304, + 3408 + ], + "id": "3e53e7ba-adbb-4234-adca-a009699d7065", + "name": "Listar hojas Nómina 2", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const init = $items('Inicializar ejecución')[0].json;\nconst FALLBACK_ID = '1IkdCdR6ZjqCHSFZ8_SGQx6DrNGq-ai5GxByAtyg1R8g';\nconst SOURCE_LABEL = 'Nómina 2';\nconst PREP_NODE = 'Preparar Nómina 2';\nconst spreadsheetId = $json.spreadsheetId || $json.id || $json.fileId || FALLBACK_ID;\nfunction norm(s) { return String(s || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]/g, ''); }\nfunction quoteA1SheetName(title) {\n return \"'\" + String(title).replace(/'/g, \"''\") + \"'!A1:AZ3000\";\n}\nconst ignore = /resumen|validacion|nvscripts|^isr$|calculo|autocrat|do\\s*not\\s*delete|^hoja|^copia\\s*de/i;\nconst sheets = ($json.sheets || []).map(s => s.properties || {}).filter(p => p.title && !p.hidden);\nconst sheetTitles = [];\nconst sheetDisplayTitles = [];\nfor (const p of sheets) {\n // IMPORTANTÍSIMO: NO usar trim() para el título real de Google Sheets.\n // Algunas nóminas tienen hojas como \"GLM RD \" y \"Latam \" con espacio al final.\n // Si se recorta ese espacio, Google Sheets devuelve: Unable to parse range.\n const rawTitle = String(p.title || '');\n const displayTitle = rawTitle.trim();\n const n = norm(rawTitle);\n if (!displayTitle || ignore.test(displayTitle) || n.startsWith('copiade')) continue;\n sheetTitles.push(rawTitle);\n sheetDisplayTitles.push(displayTitle);\n}\nif (!sheetTitles.length) throw new Error(`No encontré hojas válidas de nómina en ${SOURCE_LABEL}.`);\nconst rangesQuery = sheetTitles\n .map(title => `ranges=${encodeURIComponent(quoteA1SheetName(title))}`)\n .join('&');\nreturn [{\n json: {\n runId: init.runId,\n mes: init.mes,\n anio: init.anio,\n spreadsheetId,\n rangesQuery,\n sheetTitles,\n sheetDisplayTitles,\n quarter: SOURCE_LABEL,\n fileName: $(PREP_NODE).item.json.fileName,\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -2064, + 3408 + ], + "id": "f588456e-f6d0-4ee2-84a4-88c6a33f3914", + "name": "Preparar hojas Nómina 2" + }, + { + "parameters": { + "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.spreadsheetId }}/values:batchGet?{{ $json.rangesQuery }}&majorDimension=ROWS&valueRenderOption=UNFORMATTED_VALUE&dateTimeRenderOption=SERIAL_NUMBER", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + -1824, + 3408 + ], + "id": "347939ce-a9d9-4ae2-ac46-bbc50f5dd95e", + "name": "Leer hojas Nómina 2", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "function ascii(s){return String(s??'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'');}\nfunction clean(s){return String(s??'').replace(/\\s+/g,' ').trim();}\nfunction hnorm(s){return ascii(s).toLowerCase().replace(/[^a-z0-9]/g,'');}\nfunction nameNorm(s){return ascii(s).toUpperCase().replace(/[,.;:()\\[\\]{}]/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; let s=String(v).trim(); if(!s) return 0; s=s.replace(/RD\\$|DOP|USD|US\\$|\\$|,/gi,'').replace(/\\s/g,''); if(/^\\((.*)\\)$/.test(s)) s='-'+s.replace(/^\\(|\\)$/g,''); const n=Number(s); return Number.isFinite(n)?n:0; }\nfunction money(n){return Number((Number(n)||0).toFixed(2));}\nfunction rawDigits(v){ if(v===null||v===undefined) return ''; let s=String(v).trim(); if(/^\\d+(\\.0+)?$/.test(s)) s=s.replace(/\\.0+$/,''); return s.replace(/\\D/g,''); }\nfunction normalizeDoc(v){ const d=rawDigits(v); if(!d) return ''; if(d.length<=11) return d.padStart(11,'0'); return d; }\nfunction docIssue(v){ const d=rawDigits(v); if(!d) return 'vacío'; if(d.length<11) return 'documento corto normalizado con ceros'; if(d.length>11) return 'documento con más de 11 dígitos'; return ''; }\nfunction pad(n){return String(n).padStart(2,'0');}\nfunction excelSerialToYM(v){ const epoch=new Date(Date.UTC(1899,11,30)); const d=new Date(epoch.getTime()+Math.round(Number(v))*86400000); return `${pad(d.getUTCMonth()+1)}/${d.getUTCFullYear()}`; }\nfunction parseDateParts(a,b,y,targetMes){ a=Number(a); b=Number(b); let month; if(targetMes && String(a).padStart(2,'0')===targetMes) month=a; else if(targetMes && String(b).padStart(2,'0')===targetMes) month=b; else if(a>12 && b<=12) month=b; else if(b>12 && a<=12) month=a; else month=b; return `${pad(month)}/${String(y).length===2?'20'+y:y}`; }\nfunction parseYM(v,targetMes){ if(typeof v==='number'&&Number.isFinite(v)&&v>20000&&v<70000) return excelSerialToYM(v); const s=clean(v); if(!s) return ''; if(/^\\d+(\\.0+)?$/.test(s)){ const n=Number(s); if(n>20000&&n<70000) return excelSerialToYM(n); } let m=s.match(/(\\d{1,2})[\\/-](\\d{1,2})[\\/-](\\d{2,4})/); if(m) return parseDateParts(m[1],m[2],m[3],targetMes); m=s.match(/(\\d{4})[\\/-](\\d{1,2})[\\/-](\\d{1,2})/); if(m) return `${pad(Number(m[2]))}/${m[1]}`; m=s.match(/(\\d{1,2})[\\/-](\\d{4})/); if(m) return `${pad(Number(m[1]))}/${m[2]}`; return ''; }\nfunction col(headers,candidates,fallback=[]){ const hs=headers.map(hnorm); for(const c of candidates){ const i=hs.findIndex(h=>h===c); if(i>=0) return i; } for(const c of fallback){ const i=hs.findIndex(h=>h.includes(c)); if(i>=0) return i; } return -1; }\nfunction findHeader(values){ for(let r=0;rh==='cedula'||h.includes('cedula')||h.includes('documento')); const hasName=hs.some(h=>h==='nombre'||h.includes('nombre')); const hasPeriod=hs.some(h=>h.includes('periodo')); const hasAfp=hs.some(h=>h==='afp'||h.includes('afp')); const hasSfs=hs.some(h=>h==='sfs'||h==='ars'||h.includes('sfs')||h.includes('ars')); if(hasDoc&&hasName&&hasPeriod&&hasAfp&&hasSfs) return r; } return -1; }\nfunction sheetNameFromRange(range){ const s=String(range||''); if(s.startsWith(\"'\")){ let out=''; for(let i=1;i=0?clean(row[idx.email]):'', novedad: idx.novedad>=0?clean(row[idx.novedad]):'' };\n allRecords.push(rec); count++; salario+=salarioPeriodo; vac+=vacaciones; afp+=afpMonto; sfs+=sfsMonto; neto+=netoMonto;\n }\n sheetSummaries.push({fuente:'Nómina 2', archivo:prep.fileName, hoja:sheetTitle, empleados:count, salario_periodo:money(salario), vacaciones:money(vac), base_ss:money(salario+vac), afp:money(afp), sfs:money(sfs), neto:money(neto)});\n}\nreturn [{json:{runId:init.runId, source:'Nómina 2', payrollRecords:allRecords, sheetSummaries, warnings, sourceFiles:{'Nómina 2': prep.fileName} }}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -1568, + 3488 + ], + "id": "e16cdfc3-6944-4c6f-b65d-cbf9135f74f8", + "name": "Extraer Nómina 2" + }, + { + "parameters": { + "jsCode": "const base = $items('Inicializar ejecución')[0];\nconst bin = base.binary || {};\nconst wanted = \"tss\";\nfunction ascii(s) { return String(s || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase(); }\nfunction scoreFile(key, file) {\n const fileName = String(file.fileName || key);\n const s = ascii(`${key} ${fileName}`);\n let score = 0;\n if (!/\\.xlsx$|\\.xls$|spreadsheet|excel|nomina|n[oó]mina|tss|constancia|recepcion/.test(s)) return -999;\n if (wanted === 'tss') {\n if (/tss|constancia|recepcion/.test(s)) score += 200;\n if (/nomina|n[oó]mina|field force/.test(s)) score -= 100;\n }\n if (wanted === 'n1') {\n if (/nomina|n[oó]mina|field force/.test(s)) score += 50;\n if (/\\b15\\b|_15| 15 |quincena 1|nomina 1|n[oó]mina 1|1q|primera/.test(s)) score += 200;\n if (/\\b30\\b|\\b28\\b|_30|_28|quincena 2|nomina 2|n[oó]mina 2|2q|segunda/.test(s)) score -= 50;\n if (/tss|constancia|recepcion/.test(s)) score -= 200;\n }\n if (wanted === 'n2') {\n if (/nomina|n[oó]mina|field force/.test(s)) score += 50;\n if (/\\b30\\b|\\b28\\b|_30|_28| 30 | 28 |quincena 2|nomina 2|n[oó]mina 2|2q|segunda/.test(s)) score += 200;\n if (/\\b15\\b|_15| 15 |quincena 1|nomina 1|n[oó]mina 1|1q|primera/.test(s)) score -= 50;\n if (/tss|constancia|recepcion/.test(s)) score -= 200;\n }\n return score;\n}\nconst candidates = Object.entries(bin).map(([key,file]) => ({ key, file, score: scoreFile(key,file), fileName: file.fileName || key })).filter(x => x.score > -100).sort((a,b) => b.score - a.score);\nif (!candidates.length || candidates[0].score <= 0) throw new Error('No pude identificar el archivo TSS. Revisa que el nombre/campo del formulario sea claro.');\nconst chosen = candidates[0];\nreturn [{ json: { runId: base.json.runId, mes: base.json.mes, anio: base.json.anio, periodoLabel: base.json.periodoLabel, reportTitle: base.json.reportTitle, fileName: chosen.fileName }, binary: { data: chosen.file } }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -3056, + 3680 + ], + "id": "8b121e4f-ce50-41b0-860e-7fabe9787dfb", + "name": "Preparar TSS" + }, + { + "parameters": { + "operation": "update", + "fileId": { + "__rl": true, + "value": "1Lhm7wqqcaTsRGyDZCqTuRoT-VmAX_DXZm4jBM-mbrbs", + "mode": "id" + }, + "changeFileContent": true, + "options": {} + }, + "type": "n8n-nodes-base.googleDrive", + "typeVersion": 3, + "position": [ + -2784, + 3680 + ], + "id": "cf80a1eb-4d9a-4cb2-a60d-4798b1111a47", + "name": "Actualizar staging TSS", + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + } + }, + { + "parameters": {}, + "type": "n8n-nodes-base.wait", + "typeVersion": 1.1, + "position": [ + -2544, + 3680 + ], + "id": "80b699da-e417-4919-9fa6-7489ee65c06e", + "name": "Esperar conversión TSS", + "webhookId": "a10cfb0d-909d-4725-9b4b-20e3f16bcced" + }, + { + "parameters": { + "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.id || $json.spreadsheetId || '1Lhm7wqqcaTsRGyDZCqTuRoT-VmAX_DXZm4jBM-mbrbs' }}?fields=spreadsheetId,sheets(properties(title,sheetId,hidden))", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + -2304, + 3680 + ], + "id": "50b7e202-05dc-4049-b848-6dceed625dde", + "name": "Listar hojas TSS", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const init = $items('Inicializar ejecución')[0].json;\nconst spreadsheetId = $json.spreadsheetId || $json.id || $json.fileId || '1Lhm7wqqcaTsRGyDZCqTuRoT-VmAX_DXZm4jBM-mbrbs';\nconst sheets = ($json.sheets || []).map(s => s.properties || {}).filter(p => p.title && !p.hidden);\nconst preferred = sheets.find(s => String(s.title || '').trim().toLowerCase() === 'in') || sheets[0];\nif (!preferred) throw new Error('No encontré hoja visible en el archivo TSS.');\nconst sheetTitle = String(preferred.title || 'in').trim();\nconst rangesQuery = `ranges=${encodeURIComponent(\"'\" + sheetTitle.replace(/'/g, \"''\") + \"'!A1:AZ3000\")}`;\nreturn [{ json: { runId: init.runId, mes: init.mes, anio: init.anio, spreadsheetId, rangesQuery, sheetTitle, fileName: $('Preparar TSS').item.json.fileName } }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -2064, + 3680 + ], + "id": "d1d7f96d-c6de-4bc7-879a-78062f6e8b72", + "name": "Preparar hoja TSS" + }, + { + "parameters": { + "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.spreadsheetId }}/values:batchGet?{{ $json.rangesQuery }}&majorDimension=ROWS&valueRenderOption=UNFORMATTED_VALUE&dateTimeRenderOption=SERIAL_NUMBER", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + -1824, + 3680 + ], + "id": "870b54d6-c44d-4f95-b3ce-6245a096bd9c", + "name": "Leer hoja TSS", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const input = $input.first();\nconst init = $items('Inicializar ejecución')[0].json;\nconst prep = $items('Preparar TSS')[0].json;\n\nif (!input.binary || !input.binary.data) {\n throw new Error('No se recibió el archivo binario de TSS.');\n}\n\nconst buffer = await this.helpers.getBinaryDataBuffer(0, 'data');\n\nfunction clean(value) {\n return String(value ?? '').replace(/\\s+/g, ' ').trim();\n}\n\nfunction ascii(value) {\n return String(value ?? '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction hnorm(value) {\n return ascii(value).toLowerCase().replace(/[^a-z0-9]/g, '');\n}\n\nfunction decodeInputBuffer(data) {\n if (!Buffer.isBuffer(data)) data = Buffer.from(data);\n\n // Formato oficial observado: HTML UTF-16 LE con BOM FF FE.\n if (data.length >= 2 && data[0] === 0xFF && data[1] === 0xFE) {\n return data.subarray(2).toString('utf16le');\n }\n\n // Soporte defensivo para UTF-16 BE.\n if (data.length >= 2 && data[0] === 0xFE && data[1] === 0xFF) {\n const swapped = Buffer.from(data.subarray(2));\n if (swapped.length % 2 !== 0) {\n throw new Error('El archivo TSS UTF-16 BE tiene una longitud de bytes inválida.');\n }\n swapped.swap16();\n return swapped.toString('utf16le');\n }\n\n // Algunos exportadores omiten el BOM. Detectar UTF-16 LE por bytes nulos.\n const sampleLength = Math.min(data.length, 400);\n let oddNulls = 0;\n let evenNulls = 0;\n for (let i = 0; i < sampleLength; i++) {\n if (data[i] === 0) {\n if (i % 2 === 0) evenNulls++;\n else oddNulls++;\n }\n }\n if (oddNulls > sampleLength * 0.20 && oddNulls > evenNulls * 3) {\n return data.toString('utf16le');\n }\n\n // Compatibilidad defensiva con HTML UTF-8.\n return data.toString('utf8').replace(/^\\uFEFF/, '');\n}\n\nfunction decodeHtmlEntities(value) {\n const named = {\n amp: '&',\n lt: '<',\n gt: '>',\n quot: '\"',\n apos: \"'\",\n nbsp: ' ',\n aacute: 'á',\n eacute: 'é',\n iacute: 'í',\n oacute: 'ó',\n uacute: 'ú',\n Aacute: 'Á',\n Eacute: 'É',\n Iacute: 'Í',\n Oacute: 'Ó',\n Uacute: 'Ú',\n ntilde: 'ñ',\n Ntilde: 'Ñ',\n uuml: 'ü',\n Uuml: 'Ü'\n };\n\n return String(value ?? '')\n .replace(/&#x([0-9a-f]+);/gi, (_, hex) => {\n const cp = Number.parseInt(hex, 16);\n return Number.isFinite(cp) ? String.fromCodePoint(cp) : '';\n })\n .replace(/&#(\\d+);/g, (_, dec) => {\n const cp = Number.parseInt(dec, 10);\n return Number.isFinite(cp) ? String.fromCodePoint(cp) : '';\n })\n .replace(/&([a-zA-Z]+);/g, (all, name) => Object.prototype.hasOwnProperty.call(named, name) ? named[name] : all);\n}\n\nfunction cellText(html) {\n return clean(\n decodeHtmlEntities(\n String(html ?? '')\n .replace(//gi, ' ')\n .replace(/<\\/p\\s*>/gi, ' ')\n .replace(//g, '')\n .replace(/<[^>]+>/g, ' ')\n )\n );\n}\n\nfunction parseHtmlTable(html) {\n if (!/]*>([\\s\\S]*?)<\\/tr\\s*>/gi;\n let trMatch;\n\n while ((trMatch = trRegex.exec(html)) !== null) {\n const rowHtml = trMatch[1];\n const row = [];\n const cellRegex = /]*>([\\s\\S]*?)<\\/t[dh]\\s*>/gi;\n let cellMatch;\n\n while ((cellMatch = cellRegex.exec(rowHtml)) !== null) {\n row.push(cellText(cellMatch[1]));\n }\n\n if (row.length) rows.push(row);\n }\n\n if (!rows.length) {\n throw new Error('No se pudieron extraer filas de la tabla del archivo TSS oficial.');\n }\n\n return rows;\n}\n\nfunction findHeaderRow(rows) {\n const max = Math.min(rows.length, 30);\n for (let i = 0; i < max; i++) {\n const headers = (rows[i] || []).map(hnorm);\n const required = ['idnomina', 'nodocumento', 'nombres', 'salarioss', 'periodoaplicacion'];\n if (required.every(name => headers.includes(name))) return i;\n }\n return -1;\n}\n\nfunction findColumn(headers, exactNames, includesNames = []) {\n const normalized = headers.map(hnorm);\n for (const name of exactNames) {\n const index = normalized.findIndex(header => header === name);\n if (index >= 0) return index;\n }\n for (const name of includesNames) {\n const index = normalized.findIndex(header => header.includes(name));\n if (index >= 0) return index;\n }\n return -1;\n}\n\nfunction toNumber(value) {\n if (value === null || value === undefined || value === '') return 0;\n if (typeof value === 'number') return Number.isFinite(value) ? value : 0;\n\n let text = String(value)\n .trim()\n .replace(/RD\\$|DOP|USD|US\\$|\\$|,/gi, '')\n .replace(/\\s/g, '');\n\n if (/^\\((.*)\\)$/.test(text)) {\n text = '-' + text.replace(/^\\(|\\)$/g, '');\n }\n\n const number = Number(text);\n return Number.isFinite(number) ? number : 0;\n}\n\nfunction money(value) {\n return Number((Number(value) || 0).toFixed(2));\n}\n\nfunction rawDigits(value) {\n if (value === null || value === undefined) return '';\n let text = String(value).trim();\n if (/^\\d+(\\.0+)?$/.test(text)) text = text.replace(/\\.0+$/, '');\n return text.replace(/\\D/g, '');\n}\n\nfunction normalizeDocument(value) {\n const digits = rawDigits(value);\n if (!digits) return '';\n if (digits.length <= 11) return digits.padStart(11, '0');\n return digits;\n}\n\nfunction documentIssue(value) {\n const digits = rawDigits(value);\n if (!digits) return 'vacío';\n if (digits.length < 11) return 'documento corto normalizado con ceros';\n if (digits.length > 11) return 'documento con más de 11 dígitos';\n return '';\n}\n\nfunction pad(value) {\n return String(value).padStart(2, '0');\n}\n\nfunction excelSerialToYM(value) {\n const epoch = new Date(Date.UTC(1899, 11, 30));\n const date = new Date(epoch.getTime() + Math.round(Number(value)) * 86400000);\n return `${pad(date.getUTCMonth() + 1)}/${date.getUTCFullYear()}`;\n}\n\nfunction parseDateParts(a, b, year, targetMonth) {\n a = Number(a);\n b = Number(b);\n let month;\n\n if (targetMonth && String(a).padStart(2, '0') === targetMonth) month = a;\n else if (targetMonth && String(b).padStart(2, '0') === targetMonth) month = b;\n else if (a > 12 && b <= 12) month = b;\n else if (b > 12 && a <= 12) month = a;\n else month = b;\n\n return `${pad(month)}/${String(year).length === 2 ? '20' + year : year}`;\n}\n\nfunction parseYM(value, targetMonth) {\n if (typeof value === 'number' && Number.isFinite(value) && value > 20000 && value < 70000) {\n return excelSerialToYM(value);\n }\n\n const text = clean(value);\n if (!text) return '';\n\n if (/^\\d+(\\.0+)?$/.test(text)) {\n const number = Number(text);\n if (number > 20000 && number < 70000) return excelSerialToYM(number);\n }\n\n let match = text.match(/(\\d{1,2})[\\/-](\\d{1,2})[\\/-](\\d{2,4})/);\n if (match) return parseDateParts(match[1], match[2], match[3], targetMonth);\n\n match = text.match(/(\\d{4})[\\/-](\\d{1,2})[\\/-](\\d{1,2})/);\n if (match) return `${pad(Number(match[2]))}/${match[1]}`;\n\n match = text.match(/(\\d{1,2})[\\/-](\\d{4})/);\n if (match) return `${pad(Number(match[1]))}/${match[2]}`;\n\n return '';\n}\n\nconst html = decodeInputBuffer(buffer);\nconst rows = parseHtmlTable(html);\nconst headerRow = findHeaderRow(rows);\n\nif (headerRow < 0) {\n throw new Error(\n 'No se encontró el encabezado oficial de la TSS. ' +\n 'Se esperaban columnas como ID_NOMINA, NO_DOCUMENTO, NOMBRES, SALARIO_SS y PERIODO_APLICACION.'\n );\n}\n\nconst headers = rows[headerRow] || [];\nconst index = {\n idNomina: findColumn(headers, ['idnomina'], ['idnomina']),\n document: findColumn(headers, ['nodocumento'], ['documento']),\n names: findColumn(headers, ['nombres'], ['nombres', 'nombre']),\n reference: findColumn(headers, ['idreferencia'], ['referencia']),\n nss: findColumn(headers, ['idnss'], ['nss']),\n salary: findColumn(headers, ['salarioss'], ['salarioss']),\n sfsEmployee: findColumn(headers, ['aporteafiliadossfs'], ['afiliadossfs']),\n sfsEmployer: findColumn(headers, ['aporteempleadorsfs'], ['empleadorsfs']),\n afpEmployee: findColumn(headers, ['aporteafiliadossvds'], ['afiliadossvds']),\n afpEmployer: findColumn(headers, ['aporteempleadorsvds'], ['empleadorsvds']),\n adjustment: findColumn(headers, ['montoajuste'], ['ajuste']),\n srl: findColumn(headers, ['aportesrl'], ['srl']),\n voluntary: findColumn(headers, ['aportevoluntario'], ['voluntario']),\n perCapita: findColumn(headers, ['percapitaadicional'], ['percapita']),\n interests: findColumn(headers, ['totalinteresesrecargos'], ['intereses', 'recargos']),\n total: findColumn(headers, ['totalgeneraldetfactura'], ['totalgeneral']),\n period: findColumn(headers, ['periodoaplicacion'], ['periodo']),\n reportedSalary: findColumn(headers, ['salariossreportado'], ['salariossreportado'])\n};\n\nconst requiredColumns = {\n NO_DOCUMENTO: index.document,\n NOMBRES: index.names,\n SALARIO_SS: index.salary,\n APORTE_AFILIADOS_SFS: index.sfsEmployee,\n APORTE_AFILIADOS_SVDS: index.afpEmployee,\n PERIODO_APLICACION: index.period\n};\n\nconst missingColumns = Object.entries(requiredColumns)\n .filter(([, columnIndex]) => columnIndex < 0)\n .map(([name]) => name);\n\nif (missingColumns.length) {\n throw new Error(\n `El archivo TSS oficial no contiene las columnas requeridas: ${missingColumns.join(', ')}. ` +\n `Archivo: ${prep.fileName || 'TSS'}.`\n );\n}\n\nconst expectedPeriod = `${init.mes}/${init.anio}`;\nconst records = [];\nconst warnings = [];\nconst detectedPeriods = new Set();\n\nfor (let rowIndex = headerRow + 1; rowIndex < rows.length; rowIndex++) {\n const row = rows[rowIndex] || [];\n const periodYM = parseYM(row[index.period], init.mes);\n if (periodYM) detectedPeriods.add(periodYM);\n if (!periodYM || periodYM !== expectedPeriod) continue;\n\n const originalDocument = clean(row[index.document]);\n const document = normalizeDocument(originalDocument);\n const names = clean(row[index.names]);\n\n if (!document || rawDigits(originalDocument).length < 7 || !names) continue;\n\n const record = {\n archivo: prep.fileName,\n hoja: 'Tabla oficial TSS',\n id_nomina: index.idNomina >= 0 ? clean(row[index.idNomina]) : '',\n documento: document,\n documento_original: originalDocument,\n documento_issue: documentIssue(originalDocument),\n nombres: names,\n nombreKey: ascii(names).toUpperCase().replace(/[,.;:()\\[\\]{}]/g, ' ').replace(/\\s+/g, ' ').trim(),\n id_referencia: index.reference >= 0 ? rawDigits(row[index.reference]).padStart(16, '0') : '',\n id_nss: index.nss >= 0 ? clean(row[index.nss]) : '',\n salario_ss: money(toNumber(row[index.salary])),\n salario_ss_reportado: index.reportedSalary >= 0 ? money(toNumber(row[index.reportedSalary])) : 0,\n sfs_empleado: money(toNumber(row[index.sfsEmployee])),\n sfs_patronal: index.sfsEmployer >= 0 ? money(toNumber(row[index.sfsEmployer])) : 0,\n afp_empleado: money(toNumber(row[index.afpEmployee])),\n afp_patronal: index.afpEmployer >= 0 ? money(toNumber(row[index.afpEmployer])) : 0,\n ajuste: index.adjustment >= 0 ? money(toNumber(row[index.adjustment])) : 0,\n srl: index.srl >= 0 ? money(toNumber(row[index.srl])) : 0,\n voluntario: index.voluntary >= 0 ? money(toNumber(row[index.voluntary])) : 0,\n per_capita: index.perCapita >= 0 ? money(toNumber(row[index.perCapita])) : 0,\n intereses: index.interests >= 0 ? money(toNumber(row[index.interests])) : 0,\n total_factura: index.total >= 0 ? money(toNumber(row[index.total])) : 0,\n periodo: periodYM\n };\n\n const calculatedTotal = money(\n record.sfs_empleado +\n record.sfs_patronal +\n record.afp_empleado +\n record.afp_patronal +\n record.srl +\n record.per_capita +\n record.ajuste +\n record.voluntario +\n record.intereses\n );\n\n if (Math.abs(calculatedTotal - record.total_factura) > 0.02) {\n warnings.push({\n tipo: 'Diferencia interna TSS',\n documento: document,\n nombre: record.nombres,\n total_factura: record.total_factura,\n calculado: calculatedTotal,\n diferencia: money(calculatedTotal - record.total_factura)\n });\n }\n\n records.push(record);\n}\n\nif (!records.length) {\n const detected = [...detectedPeriods].join(', ') || 'ninguno';\n throw new Error(\n `El archivo TSS oficial fue leído, pero no contiene registros del período ${expectedPeriod}. ` +\n `Períodos detectados: ${detected}.`\n );\n}\n\nreturn [{\n json: {\n runId: init.runId,\n source: 'TSS',\n tssRecords: records,\n warnings,\n sourceFiles: { TSS: prep.fileName },\n tssInputFormat: 'HTML UTF-16 oficial TSS',\n tssRowsRead: records.length\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -1568, + 3680 + ], + "id": "83257326-2763-4d60-bd5c-3980ef9259ab", + "name": "Extraer TSS" + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + -1120, + 3344 + ], + "id": "77a68090-b5c5-4e19-8119-2da167cab39f", + "name": "Esperar nóminas" + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + -880, + 3488 + ], + "id": "bd0c5086-1d86-4555-8c90-2d1564f91291", + "name": "Esperar TSS y nóminas" + }, + { + "parameters": { + "jsCode": "function ascii(s){return String(s??'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'');}\nfunction clean(s){return String(s??'').replace(/\\s+/g,' ').trim();}\nfunction nameNorm(s){return ascii(s).toUpperCase().replace(/[,.;:()\\[\\]{}]/g,' ').replace(/\\s+/g,' ').trim();}\nfunction tokens(s){ return nameNorm(s).split(' ').filter(t=>t.length>=3&&!['DEL','LOS','LAS','UNA','UNO','DE','LA','EL','Y','DA','DAS','DOS'].includes(t)); }\nfunction nameScore(a,b){\n const A=[...new Set(tokens(a))], B=[...new Set(tokens(b))];\n if(!A.length||!B.length) return 0;\n const inter=A.filter(t=>B.includes(t)).length;\n return inter/Math.min(A.length,B.length);\n}\nfunction bestNameScore(names,b){ return Math.max(...[...names].map(n=>nameScore(n,b)), 0); }\nfunction money(n){return Number((Number(n)||0).toFixed(2));}\nfunction abs(n){return Math.abs(Number(n)||0);}\nfunction fmt(n){ return (Number(n)||0).toLocaleString('es-DO',{minimumFractionDigits:2,maximumFractionDigits:2}); }\nfunction fmt0(n){ return (Number(n)||0).toLocaleString('es-DO'); }\nfunction hasRealDiff(d){ return abs(d.base)>1 || abs(d.afp)>1 || abs(d.sfs)>1; }\nfunction totalDiff(d){ return abs(d.base)+abs(d.afp)+abs(d.sfs); }\nfunction amountDiffTotal(p,t){ return abs(money(p.base_ss-t.salario_ss))+abs(money(p.afp-t.afp))+abs(money(p.sfs-t.sfs)); }\nfunction maxRowNameScore(cluster, name){ return Math.max(...cluster.rows.map(r=>nameScore(r.nombre,name)), nameScore(cluster.nombre,name), 0); }\n\nfunction normalizeEmail(value){\n return clean(value).toLowerCase().replace(/^mailto:/,'').trim();\n}\nfunction bambooNormalizeName(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 BAMBOO_TOKEN_EQUIVALENCES = new Map([\n ['VASQUEZ','VAZQUEZ']\n]);\nfunction canonicalBambooToken(token){\n return BAMBOO_TOKEN_EQUIVALENCES.get(token)||token;\n}\nfunction canonicalBambooName(s){\n return bambooNormalizeName(s)\n .split(' ')\n .map(canonicalBambooToken)\n .join(' ');\n}\nfunction bambooTokens(name){\n const stop = new Set(['DE','DEL','LA','LAS','LOS','Y','DA','DAS','DO','VDA']);\n return [...new Set(\n canonicalBambooName(name)\n .split(' ')\n .filter(t=>t.length>1&&!stop.has(t))\n )];\n}\nfunction levenshteinDistance(a,b){\n a=String(a||'');\n b=String(b||'');\n if(a===b) return 0;\n if(!a.length) return b.length;\n if(!b.length) return a.length;\n if(a.length>b.length) [a,b]=[b,a];\n let previous=Array.from({length:a.length+1},(_,i)=>i);\n for(let j=1;j<=b.length;j++){\n const current=[j];\n for(let i=1;i<=a.length;i++){\n current[i]=Math.min(\n current[i-1]+1,\n previous[i]+1,\n previous[i-1]+(a[i-1]===b[j-1]?0:1)\n );\n }\n previous=current;\n }\n return previous[a.length];\n}\nfunction bambooTokenSimilarity(a,b){\n a=canonicalBambooToken(String(a||''));\n b=canonicalBambooToken(String(b||''));\n if(!a||!b) return 0;\n if(a===b) return 1;\n const maxLen=Math.max(a.length,b.length);\n const minLen=Math.min(a.length,b.length);\n if(minLen<3) return 0;\n const distance=levenshteinDistance(a,b);\n const ratio=1-(distance/maxLen);\n if(distance===1&&maxLen>=4) return 0.93;\n if(distance===2&&maxLen>=7&&ratio>=0.72) return 0.82;\n if((a.startsWith(b)||b.startsWith(a))&&minLen>=4&&ratio>=0.75) return 0.86;\n return ratio>=0.82?ratio:0;\n}\nfunction compareBambooNames(a,b){\n const A=bambooTokens(a);\n const B=bambooTokens(b);\n if(!A.length||!B.length){\n return {\n score:0,matched:0,exactShared:0,shortCoverage:0,longCoverage:0,\n firstNameSimilarity:0,strongSubset:false,broadStrong:false\n };\n }\n\n const pairs=[];\n for(let i=0;i=0.72) pairs.push({i,j,similarity,exact:A[i]===B[j]});\n }\n }\n pairs.sort((x,y)=>y.similarity-x.similarity||Number(y.exact)-Number(x.exact));\n\n const usedA=new Set();\n const usedB=new Set();\n let sum=0;\n let matched=0;\n let exactShared=0;\n for(const pair of pairs){\n if(usedA.has(pair.i)||usedB.has(pair.j)) continue;\n usedA.add(pair.i);\n usedB.add(pair.j);\n sum+=pair.similarity;\n matched++;\n if(pair.exact) exactShared++;\n }\n\n const minLen=Math.min(A.length,B.length);\n const maxLen=Math.max(A.length,B.length);\n const shortCoverage=sum/minLen;\n const longCoverage=sum/maxLen;\n const firstNameSimilarity=bambooTokenSimilarity(A[0],B[0]);\n const strongSubset=\n minLen>=2 &&\n matched===minLen &&\n shortCoverage>=0.88 &&\n exactShared>=2;\n const broadStrong=\n minLen>=3 &&\n matched>=minLen-1 &&\n shortCoverage>=0.82 &&\n exactShared>=2 &&\n firstNameSimilarity>=0.78;\n const score=\n (shortCoverage*0.65)+\n (longCoverage*0.25)+\n (firstNameSimilarity*0.10);\n\n return {\n score,matched,exactShared,shortCoverage,longCoverage,\n firstNameSimilarity,strongSubset,broadStrong\n };\n}\nfunction bambooStatusLabel(value){\n const status=bambooNormalizeName(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}\nfunction uniqueStrings(values,normalizer=clean){\n const byKey=new Map();\n for(const value of values||[]){\n const raw=clean(value);\n const key=normalizer(raw);\n if(raw&&key&&!byKey.has(key)) byKey.set(key,raw);\n }\n return [...byKey.values()];\n}\nfunction buildBambooNameIndex(records){\n const exact=new Map();\n const byToken=new Map();\n const byEmail=new Map();\n for(const record of records||[]){\n const canonical=canonicalBambooName(record.nombre_bamboo||'');\n if(canonical){\n if(!exact.has(canonical)) exact.set(canonical,[]);\n exact.get(canonical).push(record);\n for(const token of bambooTokens(canonical)){\n if(!byToken.has(token)) byToken.set(token,[]);\n byToken.get(token).push(record);\n }\n }\n const email=normalizeEmail(record.correo||'');\n if(email){\n if(!byEmail.has(email)) byEmail.set(email,[]);\n byEmail.get(email).push(record);\n }\n }\n return {exact,byToken,byEmail};\n}\nfunction indexedBambooCandidates(names,emails,index){\n const candidates=new Set();\n for(const name of names||[]){\n for(const token of bambooTokens(name)){\n for(const record of index.byToken.get(token)||[]) candidates.add(record);\n }\n }\n for(const email of emails||[]){\n for(const record of index.byEmail.get(normalizeEmail(email))||[]) candidates.add(record);\n }\n return [...candidates];\n}\nfunction evaluateBambooCandidate(names,emails,record){\n let bestName=null;\n for(const name of names){\n const comparison=compareBambooNames(name,record.nombre_bamboo||'');\n if(!bestName||comparison.score>bestName.comparison.score){\n bestName={name,comparison};\n }\n }\n const payrollEmails=new Set((emails||[]).map(normalizeEmail).filter(Boolean));\n const bambooEmail=normalizeEmail(record.correo||'');\n const emailExact=Boolean(bambooEmail&&payrollEmails.has(bambooEmail));\n const c=bestName?.comparison||compareBambooNames('','');\n const nameAccepted=c.strongSubset||c.broadStrong;\n const emailSupportedName=\n emailExact &&\n c.matched>=2 &&\n c.exactShared>=1 &&\n c.shortCoverage>=0.70 &&\n c.firstNameSimilarity>=0.75;\n const accepted=nameAccepted||emailSupportedName;\n const score=c.score+(emailExact?0.12:0)+(isBambooActive(record.estado_bamboo)?0.002:0);\n return {\n record,\n score,\n accepted,\n emailExact,\n nameAccepted,\n matchedName:bestName?.name||'',\n comparison:c\n };\n}\nfunction chooseExactBambooRecord(records,emails){\n const normalizedEmails=new Set((emails||[]).map(normalizeEmail).filter(Boolean));\n const byEmail=(records||[]).filter(record=>normalizedEmails.has(normalizeEmail(record.correo||'')));\n if(byEmail.length===1) return byEmail[0];\n const active=(records||[]).filter(record=>isBambooActive(record.estado_bamboo));\n if(active.length===1) return active[0];\n return (records||[])[0]||null;\n}\nfunction matchBambooEmployee(inputNames,inputEmails,index){\n const names=uniqueStrings(\n Array.isArray(inputNames)?inputNames:[inputNames],\n canonicalBambooName\n );\n const emails=uniqueStrings(\n Array.isArray(inputEmails)?inputEmails:[inputEmails],\n normalizeEmail\n );\n if(!names.length) return {record:null,score:0,method:'sin nombre'};\n\n const exactRecords=new Set();\n for(const name of names){\n for(const record of index.exact.get(canonicalBambooName(name))||[]) exactRecords.add(record);\n }\n if(exactRecords.size){\n const record=chooseExactBambooRecord([...exactRecords],emails);\n return {record,score:1,method:'nombre exacto'};\n }\n\n const candidates=indexedBambooCandidates(names,emails,index)\n .map(record=>evaluateBambooCandidate(names,emails,record))\n .filter(item=>item.accepted)\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 emailBreaksTie=best.emailExact&&(!second||!second.emailExact);\n const clearName=best.nameAccepted&&(\n !second ||\n margin>=0.08 ||\n best.comparison.strongSubset&&!second.comparison.strongSubset\n );\n\n if(emailBreaksTie||clearName||!second){\n return {\n record:best.record,\n score:best.score,\n method:best.emailExact?'nombre robusto + correo de apoyo':'nombre robusto'\n };\n }\n return {record:null,score:0,method:'coincidencia ambigua'};\n}\n\nconst init=$items('Inicializar ejecución')[0].json;\nconst incoming=$input.all().map(i=>i.json||{});\nconst payrollRecords=incoming.flatMap(j=>Array.isArray(j.payrollRecords)?j.payrollRecords:[]);\nconst tssRecords=incoming.flatMap(j=>Array.isArray(j.tssRecords)?j.tssRecords:[]);\nconst bambooRecords=incoming.flatMap(j=>Array.isArray(j.bambooRecords)?j.bambooRecords:[]);\nconst slot={\n sheetSummaries: incoming.flatMap(j=>Array.isArray(j.sheetSummaries)?j.sheetSummaries:[]),\n warnings: incoming.flatMap(j=>Array.isArray(j.warnings)?j.warnings:[]),\n sourceFiles: {},\n};\nfor (const j of incoming) Object.assign(slot.sourceFiles, j.sourceFiles || {});\n\nif(!payrollRecords.length) {\n throw new Error('No se extrajeron registros de nómina para el período seleccionado. Revisa PERÍODO, mes/año seleccionado y hojas válidas.');\n}\nif(!tssRecords.length) {\n throw new Error('No se extrajeron registros de TSS para el período seleccionado. Revisa PERIODO_APLICACION del mes/año seleccionado.');\n}\nif(!bambooRecords.length) {\n throw new Error('No se recibieron empleados de BambooHR para generar la hoja Nómina sin BambooHR.');\n}\n\nconst bambooNameIndex=buildBambooNameIndex(bambooRecords);\nconst bambooMatchCache=new Map();\nfunction getBambooMatch(names,emails=[]){\n const nameList=uniqueStrings(\n Array.isArray(names)?names:[names],\n canonicalBambooName\n );\n const emailList=uniqueStrings(\n Array.isArray(emails)?emails:[emails],\n normalizeEmail\n );\n const key=[\n nameList.map(canonicalBambooName).sort().join('|'),\n emailList.map(normalizeEmail).sort().join('|')\n ].join('::');\n if(!bambooMatchCache.has(key)){\n bambooMatchCache.set(key,matchBambooEmployee(nameList,emailList,bambooNameIndex));\n }\n return bambooMatchCache.get(key);\n}\nfunction bambooInfoForNames(names,emails=[]){\n const match=getBambooMatch(names,emails);\n return {\n esta_bamboo:match.record?'Sí':'No',\n fecha_ingreso:match.record?.fecha_ingreso||'',\n estado_bamboo:bambooStatusLabel(match.record?.estado_bamboo||''),\n nombre_bamboo:match.record?.nombre_bamboo||''\n };\n}\n\n// ============================================================\n// 1) CLUSTER DE NÓMINA: agrupa por identidad real.\n// - Mismo documento + nombre parecido = misma persona.\n// - Documento distinto + nombre muy parecido = cambio/corrección documental.\n// - Mismo documento + nombre diferente = NO se mezcla automáticamente.\n// Esto evita el falso cruce Juan Luis Paulino / Cesar Medina.\n// ============================================================\nfunction createCluster(r){\n const names=new Set([r.nombre]);\n const docs=new Set([r.documento]);\n const sheets=new Set([r.hoja]);\n const quarters=new Set([r.fuente]);\n const novedades=new Set(r.novedad ? [r.novedad] : []);\n const emails=new Set(r.correo ? [normalizeEmail(r.correo)] : []);\n return {\n nombre:r.nombre,\n names,\n docs,\n sheets,\n quarters,\n novedades,\n emails,\n rows:[r],\n base_ss:r.base_ss,\n afp:r.afp,\n sfs:r.sfs,\n neto:r.neto,\n shortDocs: r.documento_issue ? [r] : [],\n };\n}\nfunction mergeCluster(c,r){\n c.rows.push(r);\n c.names.add(r.nombre);\n c.docs.add(r.documento);\n c.sheets.add(r.hoja);\n c.quarters.add(r.fuente);\n if(r.novedad) c.novedades.add(r.novedad);\n if(r.correo) c.emails.add(normalizeEmail(r.correo));\n if(r.documento_issue) c.shortDocs.push(r);\n c.base_ss += r.base_ss;\n c.afp += r.afp;\n c.sfs += r.sfs;\n c.neto += r.neto;\n // Usar el nombre más completo como nombre visible.\n if(tokens(r.nombre).length > tokens(c.nombre).length) c.nombre = r.nombre;\n}\nfunction clusterPayroll(records){\n const clusters=[];\n for(const r of records){\n let best=null;\n for(const c of clusters){\n const sameDoc=c.docs.has(r.documento);\n const score=maxRowNameScore(c, r.nombre);\n const sameDocSimilar = sameDoc && score >= 0.45;\n const sameDocSingleKnown = sameDoc && c.names.size === 1 && score >= 0.35;\n const docChangeSamePerson = !sameDoc && score >= 0.82;\n if(sameDocSimilar || sameDocSingleKnown || docChangeSamePerson){\n const rank=(sameDoc?1000:500)+(score*100);\n if(!best || rank>best.rank) best={cluster:c,rank};\n }\n }\n if(best) mergeCluster(best.cluster,r);\n else clusters.push(createCluster(r));\n }\n for(const c of clusters){\n c.base_ss=money(c.base_ss); c.afp=money(c.afp); c.sfs=money(c.sfs); c.neto=money(c.neto);\n c.docsArray=[...c.docs]; c.namesArray=[...c.names]; c.sheetsArray=[...c.sheets]; c.quartersArray=[...c.quarters]; c.novedadesArray=[...c.novedades]; c.emailsArray=[...c.emails].filter(Boolean);\n }\n return clusters;\n}\n\n// ============================================================\n// 2) TSS por documento.\n// ============================================================\nfunction groupTssByDoc(records){\n const map=new Map();\n for(const r of records){\n if(!map.has(r.documento)){\n map.set(r.documento,{\n documento:r.documento,\n nombres:r.nombres,\n ids_nomina:new Set(),\n rows:[],\n salario_ss:0,\n afp:0,\n sfs:0,\n sfs_patronal:0,\n afp_patronal:0,\n srl:0,\n per_capita:0,\n total_factura:0\n });\n }\n const g=map.get(r.documento);\n g.ids_nomina.add(r.id_nomina);\n g.rows.push(r);\n g.salario_ss+=r.salario_ss;\n g.afp+=r.afp_empleado;\n g.sfs+=r.sfs_empleado;\n g.sfs_patronal+=r.sfs_patronal;\n g.afp_patronal+=r.afp_patronal;\n g.srl+=r.srl;\n g.per_capita+=r.per_capita;\n g.total_factura+=r.total_factura;\n if(tokens(r.nombres).length > tokens(g.nombres).length) g.nombres = r.nombres;\n }\n for(const g of map.values()){\n for(const k of ['salario_ss','afp','sfs','sfs_patronal','afp_patronal','srl','per_capita','total_factura']) g[k]=money(g[k]);\n g.idNominaArray=[...g.ids_nomina].filter(Boolean);\n }\n return map;\n}\n\n// ============================================================\n// 3) Match robusto nómina vs TSS.\n// Selecciona entre documentos posibles por nombre + monto.\n// Evita tomar el primer documento que exista en TSS.\n// ============================================================\nfunction candidateRank(cluster,tss,source){\n const score=bestNameScore(cluster.names,tss.nombres);\n const diff=amountDiffTotal(cluster,tss);\n let rank=0;\n rank += score*1000;\n if(diff <= 1.00) rank += 500;\n else if(diff <= 2.00) rank += 350;\n else if(diff <= 10.00) rank += 100;\n if(source === 'documento') rank += 250;\n if(score < 0.20 && cluster.docsArray.length > 1) rank -= 900;\n return {score,diff,rank};\n}\nfunction chooseTssForCluster(cluster,tssByDoc,usedTss){\n const docCandidates=[];\n for(const d of cluster.docsArray){\n const t=tssByDoc.get(d);\n if(t && !usedTss.has(t.documento)){\n const r=candidateRank(cluster,t,'documento');\n docCandidates.push({t,method:'documento',...r});\n }\n }\n if(docCandidates.length){\n docCandidates.sort((a,b)=>b.rank-a.rank);\n // Si hay varios documentos posibles, exigir que el elegido sea razonable.\n // Si solo hay uno, se usa por documento aunque tenga diferencia real.\n if(docCandidates.length === 1) return docCandidates[0];\n const strong=docCandidates.find(c=>c.score>=0.60 || c.diff<=2.00);\n return strong || docCandidates[0];\n }\n\n let best=null;\n for(const t of tssByDoc.values()){\n if(usedTss.has(t.documento)) continue;\n const score=bestNameScore(cluster.names,t.nombres);\n const diff=amountDiffTotal(cluster,t);\n const amountOk=diff<=2.00 || abs(cluster.base_ss-t.salario_ss)<=1.00;\n if(score>=0.75 && amountOk){\n const rank=score*1000 - diff;\n if(!best || rank>best.rank) best={t,method:'nombre+monto',score,diff,rank};\n }\n }\n return best;\n}\nfunction matchPayrollTss(payrollClusters,tssByDoc){\n const matched=[], usedTss=new Set(), cambiosDoc=[], payrollOnly=[];\n // Primero se procesan clusters con documento único y buen match de documento;\n // luego los clusters con cambios documentales. Esto reduce colisiones.\n const ordered=[...payrollClusters].sort((a,b)=>a.docsArray.length-b.docsArray.length);\n for(const p of ordered){\n const chosen=chooseTssForCluster(p,tssByDoc,usedTss);\n if(chosen && chosen.t){\n const t=chosen.t;\n usedTss.add(t.documento);\n const diff={base:money(p.base_ss-t.salario_ss),afp:money(p.afp-t.afp),sfs:money(p.sfs-t.sfs)};\n matched.push({p,t,method:chosen.method,diff,score:chosen.score});\n const hasDocChange=p.docsArray.length>1 || !p.docs.has(t.documento) || chosen.method==='nombre+monto';\n if(hasDocChange){\n cambiosDoc.push({\n tipo:'Cambio/corrección de documento resuelto',\n documentos_nomina:p.docsArray.join(' | '),\n documento_tss:t.documento,\n nombre_nomina:p.nombre,\n nombre_tss:t.nombres,\n base_nomina:p.base_ss,\n base_tss:t.salario_ss,\n afp_nomina:p.afp,\n afp_tss:t.afp,\n sfs_nomina:p.sfs,\n sfs_tss:t.sfs,\n metodo:chosen.method,\n accion:'Verificar cédula correcta en nómina/TSS. El monto cuadra o fue cruzado por nombre + monto.'\n });\n }\n } else {\n payrollOnly.push(p);\n }\n }\n const tssOnly=[...tssByDoc.values()].filter(t=>!usedTss.has(t.documento));\n return {matched,payrollOnly,tssOnly,cambiosDoc};\n}\n\nfunction sumPayroll(records){return records.reduce((a,r)=>{a.base+=r.base_ss;a.afp+=r.afp;a.sfs+=r.sfs;return a;},{base:0,afp:0,sfs:0});}\nfunction sumTss(records){return records.reduce((a,r)=>{a.base+=r.salario_ss;a.afp+=r.afp_empleado;a.sfs+=r.sfs_empleado;a.sfsPat+=r.sfs_patronal;a.afpPat+=r.afp_patronal;a.srl+=r.srl;a.perCapita+=r.per_capita;a.total+=r.total_factura;return a;},{base:0,afp:0,sfs:0,sfsPat:0,afpPat:0,srl:0,perCapita:0,total:0});}\n\nfunction duplicateDocumentAlerts(records){\n const byDoc=new Map();\n for(const r of records){\n if(!byDoc.has(r.documento)) byDoc.set(r.documento,[]);\n byDoc.get(r.documento).push(r);\n }\n const alerts=[];\n for(const [doc,rows] of byDoc.entries()){\n const names=[...new Set(rows.map(r=>r.nombre).filter(Boolean))];\n const sources=[...new Set(rows.map(r=>`${r.fuente}/${r.hoja}`))];\n // Alerta si el mismo documento aparece con nombres diferentes no similares\n // o si aparece más de una vez dentro de la misma quincena/hoja.\n const hasDifferentPeople=names.some(a=>names.some(b=>a!==b && nameScore(a,b)<0.45));\n const byQuarter=new Map();\n for(const r of rows){\n const k=`${r.fuente}|${r.hoja}`;\n byQuarter.set(k,(byQuarter.get(k)||0)+1);\n }\n const repeatedSameQuarter=[...byQuarter.values()].some(v=>v>1);\n if(hasDifferentPeople || repeatedSameQuarter){\n alerts.push({\n tipo:'Documento duplicado o compartido en nómina',\n documento:doc,\n nombres:names.join(' | '),\n detalle:sources.join(' / '),\n accion:'Revisar si la cédula fue cargada a más de una persona o si hay líneas duplicadas.'\n });\n }\n }\n return alerts;\n}\n\nconst payrollClusters=clusterPayroll(payrollRecords);\nconst tssByDoc=groupTssByDoc(tssRecords);\nconst tssGroups=[...tssByDoc.values()];\nconst match=matchPayrollTss(payrollClusters,tssByDoc);\n\nconst nominaBamboo=payrollClusters.map(cluster=>{\n const nombre=cluster.nombre||[...(cluster.names||[])][0]||'';\n return {\n cluster,\n nombre,\n match:getBambooMatch(cluster.namesArray||[nombre],cluster.emailsArray||[])\n };\n});\nconst nominaSinBamboo=nominaBamboo\n .filter(row=>!row.match.record)\n .sort((a,b)=>String(a.nombre).localeCompare(String(b.nombre),'es'));\n\nconst payrollTotalsRaw=sumPayroll(payrollRecords);\nconst tssTotalsRaw=sumTss(tssRecords);\nconst payrollTotals={base:money(payrollTotalsRaw.base),afp:money(payrollTotalsRaw.afp),sfs:money(payrollTotalsRaw.sfs)};\nconst tssTotals={base:money(tssTotalsRaw.base),afp:money(tssTotalsRaw.afp),sfs:money(tssTotalsRaw.sfs),sfsPat:money(tssTotalsRaw.sfsPat),afpPat:money(tssTotalsRaw.afpPat),srl:money(tssTotalsRaw.srl),perCapita:money(tssTotalsRaw.perCapita),total:money(tssTotalsRaw.total)};\nconst diffTotals={base:money(payrollTotals.base-tssTotals.base),afp:money(payrollTotals.afp-tssTotals.afp),sfs:money(payrollTotals.sfs-tssTotals.sfs)};\ndiffTotals.totalEmpleado=money(diffTotals.afp+diffTotals.sfs);\n\nconst diferencias=[];\nfor(const m of match.matched){\n if(hasRealDiff(m.diff)){\n diferencias.push({\n tipo:'Diferencia de monto',\n documento:m.t.documento,\n nombre_nomina:m.p.nombre,\n nombre_tss:m.t.nombres,\n base_nomina:m.p.base_ss,\n base_tss:m.t.salario_ss,\n dif_base:m.diff.base,\n afp_nomina:m.p.afp,\n afp_tss:m.t.afp,\n dif_afp:m.diff.afp,\n sfs_nomina:m.p.sfs,\n sfs_tss:m.t.sfs,\n dif_sfs:m.diff.sfs,\n accion:'Revisar base cotizable, AFP y SFS del colaborador en nómina vs TSS.'\n });\n }\n}\nfor(const p of match.payrollOnly){\n diferencias.push({\n tipo:'Solo nómina',\n documento:p.docsArray.join(' | '),\n nombre_nomina:p.nombre,\n nombre_tss:'',\n base_nomina:p.base_ss,\n base_tss:0,\n dif_base:p.base_ss,\n afp_nomina:p.afp,\n afp_tss:0,\n dif_afp:p.afp,\n sfs_nomina:p.sfs,\n sfs_tss:0,\n dif_sfs:p.sfs,\n bamboo_nombres:p.namesArray||[p.nombre],\n bamboo_correos:p.emailsArray||[],\n accion:'Aparece en nómina y no aparece en TSS. Verificar si debe reportarse en TSS o si corresponde excluirlo.'\n });\n}\nfor(const t of match.tssOnly){\n diferencias.push({\n tipo:'Solo TSS',\n documento:t.documento,\n nombre_nomina:'',\n nombre_tss:t.nombres,\n base_nomina:0,\n base_tss:t.salario_ss,\n dif_base:money(-t.salario_ss),\n afp_nomina:0,\n afp_tss:t.afp,\n dif_afp:money(-t.afp),\n sfs_nomina:0,\n sfs_tss:t.sfs,\n dif_sfs:money(-t.sfs),\n accion:'Aparece en TSS y no aparece en nómina. Verificar si fue cargado directo en TSS o si falta en nómina.'\n });\n}\n\nfor(const d of diferencias){\n if(d.tipo==='Solo nómina'){\n Object.assign(\n d,\n bambooInfoForNames(\n d.bamboo_nombres||[d.nombre_nomina],\n d.bamboo_correos||[]\n )\n );\n }else if(d.tipo==='Solo TSS'){\n Object.assign(d,bambooInfoForNames([d.nombre_tss],[]));\n }else{\n Object.assign(d,{esta_bamboo:'',fecha_ingreso:'',estado_bamboo:'',nombre_bamboo:''});\n }\n}\n\nconst docAlerts=duplicateDocumentAlerts(payrollRecords);\nconst normalizedAlerts=[\n ...payrollRecords.filter(r=>r.documento_issue).map(r=>({\n tipo:'Documento normalizado',\n documento:r.documento,\n nombre:r.nombre,\n detalle:`${r.fuente} / ${r.hoja} / original: ${r.cedula_original}`,\n accion:'Se completó con ceros a la izquierda para poder cruzar. Revisar formato en archivo fuente.'\n })),\n ...tssRecords.filter(r=>r.documento_issue).map(r=>({\n tipo:'Documento TSS normalizado',\n documento:r.documento,\n nombre:r.nombres,\n detalle:`TSS / original: ${r.documento_original}`,\n accion:'Se completó con ceros a la izquierda para poder cruzar. Revisar formato en archivo fuente.'\n }))\n];\nconst documentAlertRows=[];\nfor(const c of match.cambiosDoc){\n documentAlertRows.push({\n tipo:c.tipo,\n documento_nomina:c.documentos_nomina,\n documento_tss:c.documento_tss,\n nombre_nomina:c.nombre_nomina,\n nombre_tss:c.nombre_tss,\n accion:c.accion\n });\n}\nfor(const a of docAlerts){\n documentAlertRows.push({tipo:a.tipo, documento_nomina:a.documento, documento_tss:'', nombre_nomina:a.nombres, nombre_tss:'', accion:a.accion});\n}\n// No listamos cada documento normalizado en el reporte final.\n// La normalización de ceros a la izquierda es un ajuste técnico esperado para cruzar;\n// mostrar 50+ filas de esto hace el reporte confuso. Se mantiene solo como conteo informativo.\n\nconst hasReal=diferencias.some(d=>abs(d.dif_base)>1||abs(d.dif_afp)>1||abs(d.dif_sfs)>1);\nconst hasDocAlerts=documentAlertRows.length>0;\nlet statusText='COMPLETADO · SIN DIFERENCIAS';\nlet statusDetail='No hay diferencias reales mayores a RD$1.00.';\nif(hasReal) {\n statusText='COMPLETADO · REVISAR DIFERENCIAS';\n statusDetail='Hay diferencias reales que Nómina/Administración debe revisar.';\n} else if(hasDocAlerts) {\n statusText='COMPLETADO · REVISAR HALLAZGOS DOCUMENTALES';\n statusDetail='Los montos cuadran; solo hay alertas documentales o redondeos menores.';\n}\n\nconst reportTitle = init.reportTitle || `Cruce Seguridad Social RD - ${init.periodoLabel}`;\nconst archivos = {\n 'Nómina 1': slot.sourceFiles['Nómina 1'] || '',\n 'Nómina 2': slot.sourceFiles['Nómina 2'] || '',\n 'TSS': slot.sourceFiles['TSS'] || '',\n};\n\nconst nominaSinBambooRows=[\n ['Nombre en Nómina']\n];\nfor(const row of nominaSinBamboo){\n nominaSinBambooRows.push([row.nombre]);\n}\n\n// ============================================================\n// Reporte principal.\n// ============================================================\nconst rows=[];\nrows.push([reportTitle]);\nrows.push(['Período', init.periodoLabel, 'Estado', statusText]);\nrows.push(['Lectura rápida', statusDetail]);\nrows.push([]);\nrows.push(['Resumen']);\nrows.push(['Métrica','Valor','Comentario']);\nrows.push(['Personas consolidadas en nómina', payrollClusters.length, 'Nómina 1 + Nómina 2, consolidado por identidad/documento.']);\nrows.push(['Documentos en TSS', tssGroups.length, 'TSS consolidada por NO_DOCUMENTO.']);\nrows.push(['Coinciden / resueltos', match.matched.length, 'Incluye cambios de documento resueltos por nombre + monto.']);\nrows.push(['Solo nómina', match.payrollOnly.length, 'Debe ser 0 salvo casos reales a corregir.']);\nrows.push(['Solo TSS', match.tssOnly.length, 'Debe ser 0 salvo casos reales a corregir.']);\nrows.push(['Diferencias reales mayores a RD$1', diferencias.length, hasReal ? 'Revisar sección Diferencias para revisar.' : 'Sin diferencias reales.']);\nrows.push([]);\nrows.push(['Totales del mes']);\nrows.push(['Concepto','Nómina','TSS','Diferencia','Comentario']);\nrows.push(['Salario cotizable SS', `RD$ ${fmt(payrollTotals.base)}`, `RD$ ${fmt(tssTotals.base)}`, `RD$ ${fmt(diffTotals.base)}`, abs(diffTotals.base)<=1 ? 'OK / redondeo' : 'Revisar']);\nrows.push(['AFP / SVDS empleado', `RD$ ${fmt(payrollTotals.afp)}`, `RD$ ${fmt(tssTotals.afp)}`, `RD$ ${fmt(diffTotals.afp)}`, abs(diffTotals.afp)<=1 ? 'OK / redondeo' : 'Revisar']);\nrows.push(['SFS / ARS empleado', `RD$ ${fmt(payrollTotals.sfs)}`, `RD$ ${fmt(tssTotals.sfs)}`, `RD$ ${fmt(diffTotals.sfs)}`, abs(diffTotals.sfs)<=1 ? 'OK / redondeo' : 'Revisar']);\nrows.push(['AFP + SFS empleado', `RD$ ${fmt(payrollTotals.afp+payrollTotals.sfs)}`, `RD$ ${fmt(tssTotals.afp+tssTotals.sfs)}`, `RD$ ${fmt(diffTotals.totalEmpleado)}`, abs(diffTotals.totalEmpleado)<=1 ? 'OK / redondeo' : 'Revisar']);\nrows.push([]);\nrows.push(['Diferencias para revisar']);\nrows.push(['Tipo','Documento','Nombre nómina','Nombre TSS','Base nómina','Base TSS','Dif. base','AFP nómina','AFP TSS','Dif. AFP','SFS nómina','SFS TSS','Dif. SFS','Acción','Está en Bamboo?','Fecha de Ingreso','Activo/Inactivo','Nombre en BambooHR']);\nif(diferencias.length){\n for(const d of diferencias){\n rows.push([\n d.tipo,\n d.documento,\n d.nombre_nomina,\n d.nombre_tss,\n `RD$ ${fmt(d.base_nomina)}`,\n `RD$ ${fmt(d.base_tss)}`,\n `RD$ ${fmt(d.dif_base)}`,\n `RD$ ${fmt(d.afp_nomina)}`,\n `RD$ ${fmt(d.afp_tss)}`,\n `RD$ ${fmt(d.dif_afp)}`,\n `RD$ ${fmt(d.sfs_nomina)}`,\n `RD$ ${fmt(d.sfs_tss)}`,\n `RD$ ${fmt(d.dif_sfs)}`,\n d.accion,\n d.esta_bamboo||'',\n d.fecha_ingreso||'',\n d.estado_bamboo||'',\n d.nombre_bamboo||''\n ]);\n }\n} else {\n rows.push(['Sin diferencias reales','','','','','','','','','','','','','No hay diferencias mayores a RD$1.00.','','','','']);\n}\nrows.push([]);\nrows.push(['Alertas documentales / no monetarias']);\nrows.push(['Tipo','Documento nómina','Documento TSS','Nombre nómina','Nombre TSS','Acción']);\nif(documentAlertRows.length){\n for(const a of documentAlertRows.slice(0,20)){\n rows.push([a.tipo,a.documento_nomina,a.documento_tss,a.nombre_nomina,a.nombre_tss,a.accion]);\n }\n if(documentAlertRows.length>20) rows.push(['Nota','','','','',`Hay ${documentAlertRows.length-20} alertas documentales adicionales no mostradas para mantener el reporte simple.`]);\n} else {\n rows.push(['Sin alertas documentales accionables','','','','','']);\n}\nif(normalizedAlerts.length){\n rows.push(['Documentos normalizados (informativo)', String(normalizedAlerts.length), '', '', '', 'Se completaron ceros a la izquierda para cruzar. No requiere revisión individual si los montos cuadran.']);\n}\nrows.push([]);\nrows.push(['Archivos procesados']);\nrows.push(['Nómina 1', archivos['Nómina 1']]);\nrows.push(['Nómina 2', archivos['Nómina 2']]);\nrows.push(['TSS', archivos['TSS']]);\nrows.push([]);\nrows.push(['Regla de interpretación']);\nrows.push(['Solo se consideran diferencias reales los montos mayores a RD$1.00. Diferencias menores se tratan como redondeo.']);\nrows.push(['El cruce compara aportes del empleado: AFP/SVDS y SFS/ARS. Patronales, SRL y per cápita son informativos de factura TSS.']);\n\nconst summary={\n statusText,\n statusDetail,\n payrollPersons: payrollClusters.length,\n tssDocuments: tssGroups.length,\n matched: match.matched.length,\n soloNomina: match.payrollOnly.length,\n soloTss: match.tssOnly.length,\n realDifferencesCount: diferencias.length,\n cambiosDocumento: match.cambiosDoc.length,\n documentosDuplicados: docAlerts.length,\n documentosNormalizados: normalizedAlerts.length,\n empleadosBamboo: bambooRecords.length,\n nominaEvaluadaBamboo: payrollClusters.length,\n nominaSinBamboo: nominaSinBamboo.length,\n payrollTotals,\n tssTotals,\n diffTotals,\n archivos,\n};\n\nreturn [{\n json:{\n runId:init.runId,\n mes:init.mes,\n anio:init.anio,\n periodoLabel:init.periodoLabel,\n reportTitle,\n summary,\n reporteRows:rows,\n nominaSinBambooRows,\n diferencias,\n alertasDocumentales:documentAlertRows,\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -608, + 3488 + ], + "id": "cd1be4c3-796d-4f16-a1a1-2b4428ddf757", + "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: $('Construir reporte producción').item.json.reportTitle }, sheets: [{ properties: { title: 'Reporte' } }, { properties: { title: 'Nómina sin BambooHR' } }] }) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + -192, + 3488 + ], + "id": "7258a205-21e8-4346-9c35-3b04c3b451ac", + "name": "Crear Google Sheet Reporte", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "method": "POST", + "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.spreadsheetId }}/values:batchUpdate", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ valueInputOption: 'RAW', data: [{ range: 'Reporte!A1', majorDimension: 'ROWS', values: $('Construir reporte producción').item.json.reporteRows }, { range: 'Nómina sin BambooHR!A1', majorDimension: 'ROWS', values: $('Construir reporte producción').item.json.nominaSinBambooRows }] }) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 48, + 3488 + ], + "id": "be2d69fc-b9e7-4104-83b6-4dbc33d5eb1c", + "name": "Escribir reporte", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const input = $input.first().json || {};\nconst create = $('Crear Google Sheet Reporte').item.json || {};\nconst sheets = create.sheets || [];\nconst reporte = sheets.find(s => s && s.properties && s.properties.title === 'Reporte') || sheets[0];\nconst sid = reporte && reporte.properties ? reporte.properties.sheetId : undefined;\nconst bambooSheet = sheets.find(s => s && s.properties && s.properties.title === 'Nómina sin BambooHR');\nconst bambooSid = bambooSheet && bambooSheet.properties ? bambooSheet.properties.sheetId : undefined;\nconst rows = $('Construir reporte producción').item.json.reporteRows || [];\nconst bambooRows = $('Construir reporte producción').item.json.nominaSinBambooRows || [];\n\nfunction color(hex) {\n return {\n red: parseInt(hex.slice(0, 2), 16) / 255,\n green: parseInt(hex.slice(2, 4), 16) / 255,\n blue: parseInt(hex.slice(4, 6), 16) / 255,\n };\n}\n\nfunction clamp(n, min, max) {\n return Math.max(min, Math.min(max, n));\n}\n\nfunction textLen(v) {\n if (v === null || v === undefined) return 0;\n return String(v).length;\n}\n\nif (sid === undefined || sid === null) {\n return [{\n json: {\n ...input,\n spreadsheetId: create.spreadsheetId,\n spreadsheetUrl: create.spreadsheetUrl,\n formatBody: { requests: [] },\n },\n }];\n}\n\nconst requests = [];\nconst maxCols = 18;\nconst rowCount = Math.max(rows.length + 10, 80);\n\nrequests.push({\n updateSheetProperties: {\n properties: {\n sheetId: sid,\n gridProperties: {\n hideGridlines: true,\n frozenRowCount: 6,\n },\n },\n fields: 'gridProperties.hideGridlines,gridProperties.frozenRowCount',\n },\n});\n\nrequests.push({\n repeatCell: {\n range: {\n sheetId: sid,\n startRowIndex: 0,\n endRowIndex: rowCount,\n startColumnIndex: 0,\n endColumnIndex: maxCols,\n },\n cell: {\n userEnteredFormat: {\n textFormat: {\n fontFamily: 'Arial',\n fontSize: 10,\n foregroundColor: color('263238'),\n },\n verticalAlignment: 'TOP',\n wrapStrategy: 'WRAP',\n },\n },\n fields: 'userEnteredFormat.textFormat,userEnteredFormat.verticalAlignment,userEnteredFormat.wrapStrategy',\n },\n});\n\n// Título\nrequests.push({\n mergeCells: {\n range: {\n sheetId: sid,\n startRowIndex: 0,\n endRowIndex: 1,\n startColumnIndex: 0,\n endColumnIndex: maxCols,\n },\n mergeType: 'MERGE_ALL',\n },\n});\nrequests.push({\n repeatCell: {\n range: {\n sheetId: sid,\n startRowIndex: 0,\n endRowIndex: 1,\n startColumnIndex: 0,\n endColumnIndex: maxCols,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: color('4F758B'),\n textFormat: {\n foregroundColor: color('FFFFFF'),\n bold: true,\n fontSize: 13,\n fontFamily: 'Arial',\n },\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n },\n },\n fields: 'userEnteredFormat.backgroundColor,userEnteredFormat.textFormat,userEnteredFormat.horizontalAlignment,userEnteredFormat.verticalAlignment',\n },\n});\n\n// Filas de secciones\nconst sectionNames = new Set([\n 'Resumen',\n 'Totales del mes',\n 'Diferencias para revisar',\n 'Alertas documentales / no monetarias',\n 'Archivos procesados',\n 'Regla de interpretación',\n]);\nconst sectionRows = rows\n .map((r, i) => sectionNames.has(r[0]) ? i : -1)\n .filter(i => i >= 0);\n\nfor (const r of sectionRows) {\n requests.push({\n mergeCells: {\n range: {\n sheetId: sid,\n startRowIndex: r,\n endRowIndex: r + 1,\n startColumnIndex: 0,\n endColumnIndex: maxCols,\n },\n mergeType: 'MERGE_ALL',\n },\n });\n requests.push({\n repeatCell: {\n range: {\n sheetId: sid,\n startRowIndex: r,\n endRowIndex: r + 1,\n startColumnIndex: 0,\n endColumnIndex: maxCols,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: color('4F758B'),\n textFormat: {\n foregroundColor: color('FFFFFF'),\n bold: true,\n fontSize: 11,\n fontFamily: 'Arial',\n },\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n },\n },\n fields: 'userEnteredFormat.backgroundColor,userEnteredFormat.textFormat,userEnteredFormat.horizontalAlignment,userEnteredFormat.verticalAlignment',\n },\n });\n}\n\n// Encabezados de tablas\nconst headerRows = rows\n .map((r, i) => ['Métrica', 'Concepto', 'Tipo'].includes(r[0]) ? i : -1)\n .filter(i => i >= 0);\n\nfor (const r of headerRows) {\n requests.push({\n repeatCell: {\n range: {\n sheetId: sid,\n startRowIndex: r,\n endRowIndex: r + 1,\n startColumnIndex: 0,\n endColumnIndex: maxCols,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: color('E9F2F7'),\n textFormat: {\n foregroundColor: color('4F758B'),\n bold: true,\n fontFamily: 'Arial',\n },\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n },\n },\n fields: 'userEnteredFormat.backgroundColor,userEnteredFormat.textFormat,userEnteredFormat.horizontalAlignment,userEnteredFormat.verticalAlignment',\n },\n });\n}\n\n// Bordes ligeros a todo lo usado\nif (rows.length > 0) {\n requests.push({\n updateBorders: {\n range: {\n sheetId: sid,\n startRowIndex: 0,\n endRowIndex: rows.length,\n startColumnIndex: 0,\n endColumnIndex: maxCols,\n },\n top: { style: 'SOLID', width: 1, color: color('D5E2EA') },\n bottom: { style: 'SOLID', width: 1, color: color('D5E2EA') },\n left: { style: 'SOLID', width: 1, color: color('D5E2EA') },\n right: { style: 'SOLID', width: 1, color: color('D5E2EA') },\n innerHorizontal: { style: 'SOLID', width: 1, color: color('EEF4F8') },\n innerVertical: { style: 'SOLID', width: 1, color: color('EEF4F8') },\n },\n });\n}\n\n// Anchuras pensadas para una sola hoja legible.\n// Se amplían las columnas de texto para evitar que el contenido se vea apretado.\nconst widths = [220, 195, 230, 230, 125, 125, 125, 125, 125, 125, 125, 125, 125, 360, 125, 125, 135, 240];\nwidths.forEach((w, idx) => {\n requests.push({\n updateDimensionProperties: {\n range: {\n sheetId: sid,\n dimension: 'COLUMNS',\n startIndex: idx,\n endIndex: idx + 1,\n },\n properties: { pixelSize: w },\n fields: 'pixelSize',\n },\n });\n});\n\n// Altura dinámica por fila.\n// Antes todas las filas quedaban en 28px; eso comprimía texto largo y obligaba a Nómina a agrandar filas manualmente.\n// Esta lógica solo toca presentación del Google Sheet, no toca el cruce ni los cálculos.\nconst charCapacityByCol = widths.map(w => Math.max(8, Math.floor(w / 8.2)));\nconst sectionRowSet = new Set(sectionRows);\nconst headerRowSet = new Set(headerRows);\nfunction estimatedLines(row) {\n let maxLines = 1;\n for (let c = 0; c < Math.min(maxCols, row.length); c++) {\n const value = row[c];\n if (value === null || value === undefined || value === '') continue;\n const txt = String(value);\n const manualLines = txt.split(/\\n/);\n let lines = 0;\n for (const part of manualLines) {\n const len = part.length;\n const cap = charCapacityByCol[c] || 18;\n lines += Math.max(1, Math.ceil(len / cap));\n }\n maxLines = Math.max(maxLines, lines);\n }\n return maxLines;\n}\nfunction rowPixelHeight(row, index) {\n const first = String(row?.[0] || '');\n if (!row || row.every(v => v === '' || v === null || v === undefined)) return 14;\n if (index === 0) return 34;\n if (sectionRowSet.has(index)) return 30;\n if (headerRowSet.has(index)) return 30;\n\n const lines = estimatedLines(row);\n let height = 24 + (lines - 1) * 17;\n\n // Filas de lectura, comentarios, acciones y archivos necesitan más aire visual.\n if (index <= 3) height = Math.max(height, 46);\n if (['Solo se consideran diferencias reales los montos mayores a RD$1.00. Diferencias menores se tratan como redondeo.',\n 'El cruce compara aportes del empleado: AFP/SVDS y SFS/ARS. Patronales, SRL y per cápita son informativos de factura TSS.'].includes(first)) {\n height = Math.max(height, 48);\n }\n if (first === 'Nómina 1' || first === 'Nómina 2' || first === 'TSS') height = Math.max(height, 46);\n if (first === 'Sin diferencias reales') height = Math.max(height, 34);\n if (first === 'Nota' || first === 'Documentos normalizados (informativo)') height = Math.max(height, 44);\n if (first && !sectionRowSet.has(index) && !headerRowSet.has(index) && row.length >= 6) height = Math.max(height, 38);\n\n return clamp(height, 24, 120);\n}\n\n// Agrupa filas contiguas con la misma altura para mantener bajo el número de requests.\nlet start = 0;\nlet lastHeight = rows.length ? rowPixelHeight(rows[0], 0) : 28;\nfor (let i = 1; i <= Math.max(rows.length, 1); i++) {\n const h = i < rows.length ? rowPixelHeight(rows[i], i) : null;\n if (h !== lastHeight) {\n requests.push({\n updateDimensionProperties: {\n range: {\n sheetId: sid,\n dimension: 'ROWS',\n startIndex: start,\n endIndex: i,\n },\n properties: { pixelSize: lastHeight },\n fields: 'pixelSize',\n },\n });\n start = i;\n lastHeight = h;\n }\n}\n\n// Color suave para filas de diferencias reales\nconst diffStart = rows.findIndex(r => r[0] === 'Diferencias para revisar');\nconst diffHeader = rows.findIndex((r, i) => i > diffStart && r[0] === 'Tipo');\nif (diffHeader >= 0) {\n for (let i = diffHeader + 1; i < rows.length; i++) {\n if (!rows[i] || !rows[i][0]) break;\n if (rows[i][0] !== 'Sin diferencias reales') {\n requests.push({\n repeatCell: {\n range: {\n sheetId: sid,\n startRowIndex: i,\n endRowIndex: i + 1,\n startColumnIndex: 0,\n endColumnIndex: maxCols,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: color('FFF7E6'),\n },\n },\n fields: 'userEnteredFormat.backgroundColor',\n },\n });\n }\n }\n}\n\nif (bambooSid !== undefined && bambooSid !== null) {\n const bambooCols = 1;\n const bambooRowCount = Math.max(bambooRows.length + 5, 30);\n\n requests.push({\n updateSheetProperties: {\n properties: {\n sheetId: bambooSid,\n gridProperties: {\n hideGridlines: true,\n frozenRowCount: 1,\n },\n },\n fields: 'gridProperties.hideGridlines,gridProperties.frozenRowCount',\n },\n });\n\n requests.push({\n repeatCell: {\n range: {\n sheetId: bambooSid,\n startRowIndex: 0,\n endRowIndex: bambooRowCount,\n startColumnIndex: 0,\n endColumnIndex: bambooCols,\n },\n cell: {\n userEnteredFormat: {\n textFormat: {\n fontFamily: 'Arial',\n fontSize: 10,\n foregroundColor: color('263238'),\n },\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n },\n },\n fields: 'userEnteredFormat.textFormat,userEnteredFormat.verticalAlignment,userEnteredFormat.wrapStrategy',\n },\n });\n\n requests.push({\n repeatCell: {\n range: {\n sheetId: bambooSid,\n startRowIndex: 0,\n endRowIndex: 1,\n startColumnIndex: 0,\n endColumnIndex: bambooCols,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: color('4F758B'),\n textFormat: {\n foregroundColor: color('FFFFFF'),\n bold: true,\n fontFamily: 'Arial',\n },\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n },\n },\n fields: 'userEnteredFormat.backgroundColor,userEnteredFormat.textFormat,userEnteredFormat.horizontalAlignment,userEnteredFormat.verticalAlignment',\n },\n });\n\n if (bambooRows.length > 0) {\n requests.push({\n updateBorders: {\n range: {\n sheetId: bambooSid,\n startRowIndex: 0,\n endRowIndex: bambooRows.length,\n startColumnIndex: 0,\n endColumnIndex: bambooCols,\n },\n top: { style: 'SOLID', width: 1, color: color('D5E2EA') },\n bottom: { style: 'SOLID', width: 1, color: color('D5E2EA') },\n left: { style: 'SOLID', width: 1, color: color('D5E2EA') },\n right: { style: 'SOLID', width: 1, color: color('D5E2EA') },\n innerHorizontal: { style: 'SOLID', width: 1, color: color('EEF4F8') },\n innerVertical: { style: 'SOLID', width: 1, color: color('EEF4F8') },\n },\n });\n }\n\n [340].forEach((pixelSize, idx) => {\n requests.push({\n updateDimensionProperties: {\n range: {\n sheetId: bambooSid,\n dimension: 'COLUMNS',\n startIndex: idx,\n endIndex: idx + 1,\n },\n properties: { pixelSize },\n fields: 'pixelSize',\n },\n });\n });\n\n requests.push({\n updateDimensionProperties: {\n range: {\n sheetId: bambooSid,\n dimension: 'ROWS',\n startIndex: 0,\n endIndex: 1,\n },\n properties: { pixelSize: 32 },\n fields: 'pixelSize',\n },\n });\n}\n\nreturn [{\n json: {\n ...input,\n spreadsheetId: create.spreadsheetId,\n spreadsheetUrl: create.spreadsheetUrl,\n formatBody: { requests },\n },\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 288, + 3488 + ], + "id": "b3e08ff4-74ca-4c32-bc46-1d1f6aa62157", + "name": "Preparar formato reporte" + }, + { + "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($json.formatBody) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 528, + 3488 + ], + "id": "8a31ac94-260d-41eb-a506-5ec48db30934", + "name": "Formatear reporte", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "// ============================================================\n// CONFIGURACIÓN ÚNICA DE DESTINATARIOS\n// ============================================================\n// Escribe aquí los correos que deben recibir el correo y también\n// tener acceso al Google Sheet del reporte.\n// Puedes poner 1, 2, 10 o más correos separados por coma, punto y coma o salto de línea.\n// Ejemplo:\n// const DESTINATARIOS_TO = 'correo1@gomezleemarketing.com, correo2@gomezleemarketing.com';\n\nconst DESTINATARIOS_TO = 'iaracena@gomezleemarketing.com, ymadera@gomezleemarketing.com, iherrera@gomezleemarketing.com, vparamo@gomezleemarketing.com, asrodriguez@gomezleemarketing.com, mgomez@gomezleemarketing.com, msoto@gomezleemarketing.com';\nconst DESTINATARIOS_CC = '';\nconst DESTINATARIOS_BCC = '';\n\n// Permiso del Google Sheet generado: reader = solo lectura, writer = editor.\n// RD producción: los destinatarios configurados reciben acceso de edición al reporte.\nconst PERMISSION_ROLE = 'writer';\n\nfunction extractEmails(value) {\n if (value === null || value === undefined) return [];\n const text = Array.isArray(value) ? value.join(',') : String(value);\n return (text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/gi) || [])\n .map(e => e.trim().toLowerCase())\n .filter(Boolean);\n}\n\nconst toEmails = [...new Set(extractEmails(DESTINATARIOS_TO))];\nconst ccEmails = [...new Set(extractEmails(DESTINATARIOS_CC))];\nconst bccEmails = [...new Set(extractEmails(DESTINATARIOS_BCC))];\nconst allEmails = [...new Set([...toEmails, ...ccEmails, ...bccEmails])];\n\nif (!toEmails.length) {\n throw new Error('Configura al menos un correo en DESTINATARIOS_TO dentro del nodo \"CONFIG - Destinatarios correo y permisos\".');\n}\n\nconst spreadsheetId = $('Crear Google Sheet Reporte').item.json.spreadsheetId;\nconst spreadsheetUrl = $('Crear Google Sheet Reporte').item.json.spreadsheetUrl;\n\nreturn [{\n json: {\n toEmails,\n ccEmails,\n bccEmails,\n allEmails,\n sendTo: toEmails.join(', '),\n cc: ccEmails.join(', '),\n bcc: bccEmails.join(', '),\n spreadsheetId,\n spreadsheetUrl,\n permissionRole: PERMISSION_ROLE,\n },\n}];" + }, + "position": [ + 768, + 3488 + ], + "id": "a2f779e8-6d88-4bd3-85d7-e0817a1c3195", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "name": "CONFIG - Destinatarios correo y permisos" + }, + { + "parameters": { + "method": "POST", + "url": "=https://www.googleapis.com/drive/v3/files/{{ $json.spreadsheetId }}/permissions?sendNotificationEmail=false&supportsAllDrives=true", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleDriveOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ role: $json.permissionRole || 'reader', type: 'user', emailAddress: $json.emailAddress }) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 1184, + 3488 + ], + "id": "bb859891-f994-48f6-90c9-6dbc8de02b91", + "name": "Compartir reporte destinatarios Gmail", + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + }, + "continueOnFail": true + }, + { + "parameters": { + "sendTo": "={{ $json.sendTo }}", + "subject": "={{ `Cruce Seguridad Social RD - ${$('Construir reporte producción').item.json.periodoLabel}` }}", + "message": "=
\n
\n
\n\n \n  \n
\n\n
\n SEGURIDAD SOCIAL · REPÚBLICA DOMINICANA\n
\n\n

\n Cruce mensual completado\n

\n\n
\n {{ $('Construir reporte producción').item.json.periodoLabel }}\n
\n\n
\n {{ $('Construir reporte producción').item.json.summary.statusText }}\n
\n\n \n \n \n \n \n \n
\n
NÓMINA
\n
\n {{ Number($('Construir reporte producción').item.json.summary.payrollPersons || 0).toLocaleString('es-DO') }}\n
\n
\n
TSS
\n
\n {{ Number($('Construir reporte producción').item.json.summary.tssDocuments || 0).toLocaleString('es-DO') }}\n
\n
\n
DIF. AFP + SFS
\n
\n RD$ {{ Number($('Construir reporte producción').item.json.summary.diffTotals.totalEmpleado || 0).toLocaleString('es-DO', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}\n
\n
\n\n
\n

Identidad

\n \n \n \n \n \n \n \n \n \n \n \n \n \n
Coinciden / resueltos\n {{ Number($('Construir reporte producción').item.json.summary.matched || 0).toLocaleString('es-DO') }}\n
Solo nómina\n {{ Number($('Construir reporte producción').item.json.summary.soloNomina || 0).toLocaleString('es-DO') }}\n
Solo TSS\n {{ Number($('Construir reporte producción').item.json.summary.soloTss || 0).toLocaleString('es-DO') }}\n
\n
\n\n
\n

Montos TSS

\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
Salario cotizable nómina\n RD$ {{ Number($('Construir reporte producción').item.json.summary.payrollTotals.base || 0).toLocaleString('es-DO', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}\n
Salario cotizable TSS\n RD$ {{ Number($('Construir reporte producción').item.json.summary.tssTotals.base || 0).toLocaleString('es-DO', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}\n
Diferencia salario\n RD$ {{ Number($('Construir reporte producción').item.json.summary.diffTotals.base || 0).toLocaleString('es-DO', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}\n
AFP diferencia\n RD$ {{ Number($('Construir reporte producción').item.json.summary.diffTotals.afp || 0).toLocaleString('es-DO', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}\n
SFS/ARS diferencia\n RD$ {{ Number($('Construir reporte producción').item.json.summary.diffTotals.sfs || 0).toLocaleString('es-DO', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}\n
\n
\n\n
\n

Hallazgos

\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
Diferencias reales mayores a RD$1\n {{ Number($('Construir reporte producción').item.json.summary.realDifferencesCount || 0).toLocaleString('es-DO') }}\n
Cambios de documento\n {{ Number($('Construir reporte producción').item.json.summary.cambiosDocumento || 0).toLocaleString('es-DO') }}\n
Documentos duplicados\n {{ Number($('Construir reporte producción').item.json.summary.documentosDuplicados || 0).toLocaleString('es-DO') }}\n
Documentos normalizados (informativo)\n {{ Number($('Construir reporte producción').item.json.summary.documentosNormalizados || 0).toLocaleString('es-DO') }}\n
\n
\n\n \n\n
\n Archivos procesados:
\n Nómina 1: {{ $('Construir reporte producción').item.json.summary.archivos[\"Nómina 1\"] }}
\n Nómina 2: {{ $('Construir reporte producción').item.json.summary.archivos[\"Nómina 2\"] }}
\n TSS: {{ $('Construir reporte producción').item.json.summary.archivos[\"TSS\"] }}\n
\n\n
\n
\n", + "options": { + "appendAttribution": false + } + }, + "type": "n8n-nodes-base.gmail", + "typeVersion": 2.1, + "position": [ + 1600, + 3488 + ], + "id": "ec1ff324-65fc-4051-a95f-fbe5ce9df419", + "name": "Enviar correo resumen", + "webhookId": "5ba65efc-f980-4aef-8b08-4f8dfb1c855e", + "executeOnce": true, + "credentials": { + "gmailOAuth2": { + "id": "UDcO1FLJqA453V2D", + "name": "Gmail account 3" + } + } + }, + { + "parameters": { + "jsCode": "const cfg = $input.first().json;\nconst emails = cfg.allEmails || [];\nif (!emails.length) {\n throw new Error('No hay destinatarios para compartir el reporte. Revisa el nodo CONFIG - Destinatarios correo y permisos.');\n}\nreturn emails.map(email => ({\n json: {\n emailAddress: email,\n spreadsheetId: cfg.spreadsheetId,\n spreadsheetUrl: cfg.spreadsheetUrl,\n permissionRole: cfg.permissionRole || 'reader',\n },\n}));" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 992, + 3488 + ], + "id": "86620c74-9f65-45e3-8517-df3be32aa845", + "name": "Expandir permisos destinatarios" + }, + { + "parameters": { + "jsCode": "// Este nodo corre después de compartir permisos. Devuelve un solo item\n// para que Gmail se envíe una sola vez, aunque haya 10 destinatarios.\nconst cfg = $('CONFIG - Destinatarios correo y permisos').item.json;\nreturn [{\n json: {\n sendTo: cfg.sendTo,\n cc: cfg.cc || '',\n bcc: cfg.bcc || '',\n spreadsheetId: cfg.spreadsheetId,\n spreadsheetUrl: cfg.spreadsheetUrl,\n },\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1376, + 3488 + ], + "id": "66373341-ee6b-4aa5-8b57-5dae144ca76d", + "name": "Preparar correo único" + }, + { + "parameters": { + "content": "## Entrada mensual\n\nFormulario para seleccionar mes/año y cargar:\n- Nómina 1\n- Nómina 2\n- Archivo TSS\n\nEl flujo valida el período seleccionado y prepara los archivos para lectura desde Google Sheets.", + "height": 688, + "width": 1296 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + -4560, + 2832 + ], + "id": "c7204427-2a9a-40b0-96d5-e9afdcb68c97", + "name": "Sticky Note" + }, + { + "parameters": { + "content": "## Extracción y consolidación\n\nLee Nómina 1, Nómina 2 y TSS.\n\nLa nómina se consolida por documento normalizado.\nEl nombre se usa solo como apoyo para resolver variaciones entre quincenas.", + "height": 976, + "width": 1776, + "color": 5 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + -3168, + 2928 + ], + "id": "ec4e77c9-c91b-4011-9184-af8f52ede00e", + "name": "Sticky Note1" + }, + { + "parameters": { + "content": "## Cruce de seguridad social\n\nCompara nómina vs TSS por:\n- Identidad/documento\n- Salario cotizable\n- AFP empleado\n- SFS/ARS empleado\n\nSolo marca como diferencias reales los montos mayores a RD$1.", + "height": 544, + "width": 928, + "color": 6 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + -1280, + 3120 + ], + "id": "3f7eec21-0390-4d8e-9346-a3433f019d60", + "name": "Sticky Note2" + }, + { + "parameters": { + "content": "## Reporte y envío\n\nGenera un Google Sheet simple de una sola hoja.\nComparte el reporte con permisos de edición a los destinatarios configurados.\nEnvía correo HTML con resumen ejecutivo y enlace al reporte.", + "height": 400, + "width": 2064, + "color": 4 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + -272, + 3328 + ], + "id": "b1e2b263-7c8e-4791-83f7-cf931a75f808", + "name": "Sticky Note3" + }, + { + "parameters": { + "jsCode": "const init = $items('Inicializar ejecución')[0].json;\n\nconst workflowName = (typeof $workflow !== 'undefined' && $workflow.name)\n ? $workflow.name\n : 'Cruce Seguridad Social RD';\n\nconst workflowId = (typeof $workflow !== 'undefined' && $workflow.id)\n ? String($workflow.id)\n : '';\n\nconst executionId = (typeof $execution !== 'undefined' && $execution.id)\n ? String($execution.id)\n : '';\n\nreturn [{\n json: {\n id_ejecucion: init.runId,\n nombre_flujo: workflowName,\n id_flujo_n8n: workflowId || null,\n id_ejecucion_n8n: executionId || null,\n\n pais_codigo: 'RD',\n pais_nombre: 'República Dominicana',\n entidad_seguridad_social: 'TSS',\n\n mes: Number(init.mes || 0),\n anio: Number(init.anio || 0),\n periodo: init.periodoLabel || `${init.mes}/${init.anio}`,\n\n estado: 'procesando',\n resultado: 'Cruce iniciado',\n\n fecha_inicio: new Date().toISOString(),\n\n moneda: 'DOP',\n archivos_procesados: {},\n conteos_fuente: {},\n metricas: {\n etapa: 'inicio',\n origen: 'n8n',\n formulario: 'Cruce Seguridad Social RD'\n },\n resumen_completo: {\n reportTitle: init.reportTitle,\n runId: init.runId\n }\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -3696, + 3008 + ], + "id": "2d3dc332-3582-4afa-9e71-b836a33993c2", + "name": "Preparar auditoría inicial Supabase", + "continueOnFail": true + }, + { + "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": [ + -3456, + 3008 + ], + "id": "67e3e2ea-4875-49b1-a739-dd532b9a5885", + "name": "Registrar auditoría inicial Supabase", + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "function num(v) {\n const n = Number(v || 0);\n return Number.isFinite(n) ? Number(n.toFixed(2)) : 0;\n}\nfunction arr(v) {\n return Array.isArray(v) ? v : [];\n}\n\nconst cruce = $('Construir reporte producción').item.json;\nconst cfg = $('CONFIG - Destinatarios correo y permisos').item.json;\nconst sheet = $('Crear Google Sheet Reporte').item.json;\n\nconst summary = cruce.summary || {};\nconst payrollTotals = summary.payrollTotals || {};\nconst tssTotals = summary.tssTotals || {};\nconst diffTotals = summary.diffTotals || {};\n\nconst diferencias = arr(cruce.diferencias);\nconst alertasDocumentales = arr(cruce.alertasDocumentales);\n\nconst identityDiffs = num(summary.soloNomina) + num(summary.soloTss);\nconst monetaryDiffs = diferencias.filter(d => !/^solo\\s+/i.test(String(d.tipo || ''))).length;\nconst documentAlerts = alertasDocumentales.length;\n\nconst payrollEmployeeContrib = num(payrollTotals.afp) + num(payrollTotals.sfs);\nconst tssEmployeeContrib = num(tssTotals.afp) + num(tssTotals.sfs);\n\nconst spreadsheetId = cfg.spreadsheetId || sheet.spreadsheetId || '';\nconst spreadsheetUrl = cfg.spreadsheetUrl || sheet.spreadsheetUrl || (spreadsheetId ? `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit` : '');\n\nconst allEmails = arr(cfg.allEmails);\nconst manualMinutes = 60;\nconst automationMinutes = 5;\n\nconst workflowName = (typeof $workflow !== 'undefined' && $workflow.name)\n ? $workflow.name\n : 'Cruce Seguridad Social RD';\n\nconst workflowId = (typeof $workflow !== 'undefined' && $workflow.id)\n ? String($workflow.id)\n : '';\n\nconst executionId = (typeof $execution !== 'undefined' && $execution.id)\n ? String($execution.id)\n : '';\n\nreturn [{\n json: {\n id_ejecucion: cruce.runId,\n nombre_flujo: workflowName,\n id_flujo_n8n: workflowId || null,\n id_ejecucion_n8n: executionId || null,\n\n pais_codigo: 'RD',\n pais_nombre: 'República Dominicana',\n entidad_seguridad_social: 'TSS',\n\n mes: Number(cruce.mes || 0),\n anio: Number(cruce.anio || 0),\n periodo: cruce.periodoLabel || '',\n\n estado: 'exitoso',\n resultado: summary.statusText || 'Completado',\n\n fecha_fin: new Date().toISOString(),\n\n url_reporte: spreadsheetUrl,\n id_google_sheet: spreadsheetId || null,\n destinatarios_correo: allEmails,\n\n moneda: 'DOP',\n\n empleados_nomina: Number(summary.payrollPersons || 0),\n empleados_entidad: Number(summary.tssDocuments || 0),\n empleados_coincidentes: Number(summary.matched || 0),\n\n solo_nomina: Number(summary.soloNomina || 0),\n solo_entidad: Number(summary.soloTss || 0),\n\n diferencias_total: identityDiffs + monetaryDiffs + documentAlerts,\n diferencias_monetarias: monetaryDiffs,\n diferencias_identidad: identityDiffs,\n alertas_documentales: documentAlerts,\n registros_duplicados: Number(summary.documentosDuplicados || 0),\n alertas_periodo: 0,\n\n total_nomina: payrollEmployeeContrib,\n total_entidad: tssEmployeeContrib,\n diferencia_monto: num(diffTotals.totalEmpleado),\n\n minutos_estimados_manual: manualMinutes,\n minutos_automatizacion: automationMinutes,\n minutos_ahorrados: Math.max(manualMinutes - automationMinutes, 0),\n\n archivos_procesados: summary.archivos || {},\n conteos_fuente: {\n empleados_nomina: Number(summary.payrollPersons || 0),\n empleados_tss: Number(summary.tssDocuments || 0),\n coincidentes_resueltos: Number(summary.matched || 0),\n solo_nomina: Number(summary.soloNomina || 0),\n solo_tss: Number(summary.soloTss || 0)\n },\n metricas: {\n salario_cotizable_nomina: num(payrollTotals.base),\n salario_cotizable_tss: num(tssTotals.base),\n diferencia_salario_cotizable: num(diffTotals.base),\n afp_nomina: num(payrollTotals.afp),\n afp_tss: num(tssTotals.afp),\n diferencia_afp: num(diffTotals.afp),\n sfs_nomina: num(payrollTotals.sfs),\n sfs_tss: num(tssTotals.sfs),\n diferencia_sfs: num(diffTotals.sfs),\n cambios_documento: Number(summary.cambiosDocumento || 0),\n documentos_normalizados: Number(summary.documentosNormalizados || 0)\n },\n resumen_completo: {\n summary,\n diferencias,\n alertasDocumentales,\n reporte: {\n spreadsheetId,\n spreadsheetUrl\n }\n },\n\n mensaje_error: null,\n nodo_error: null\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1920, + 4064 + ], + "id": "81c4d558-c355-400c-8a87-ad3f60e134e8", + "name": "Preparar auditoría final Supabase", + "continueOnFail": true + }, + { + "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": "resolution=merge-duplicates,return=minimal" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 2160, + 4064 + ], + "id": "5d9965a0-e4a2-4099-84ff-a9c1ba4cd243", + "name": "Guardar auditoría final Supabase", + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "function num(v) {\n const n = Number(v || 0);\n return Number.isFinite(n) ? Number(n.toFixed(2)) : 0;\n}\nfunction clean(v) {\n return String(v || '').replace(/\\s+/g, ' ').trim();\n}\n\nconst cruce = $('Construir reporte producción').item.json;\nconst summary = cruce.summary || {};\nconst rows = [];\n\nfor (const d of (cruce.diferencias || [])) {\n const tipo = clean(d.tipo);\n const isIdentity = /^solo\\s+/i.test(tipo);\n const amountDiff = Math.abs(num(d.dif_base)) + Math.abs(num(d.dif_afp)) + Math.abs(num(d.dif_sfs));\n\n rows.push({\n id_ejecucion: cruce.runId,\n pais_codigo: 'RD',\n pais_nombre: 'República Dominicana',\n\n categoria: isIdentity ? 'diferencia_identidad' : 'diferencia_monetaria',\n severidad: isIdentity ? 'revision' : (amountDiff > 1000 ? 'critico' : 'revision'),\n\n identificador_empleado: clean(d.documento) || null,\n nombre_empleado: clean(d.nombre_nomina || d.nombre_tss) || null,\n\n fuente_a: 'Nómina',\n fuente_b: 'TSS',\n\n diferencia_monto: num(amountDiff),\n moneda: 'DOP',\n\n descripcion: isIdentity\n ? `${tipo}: empleado no aparece en ambas fuentes.`\n : `${tipo}: diferencia en salario cotizable, AFP o SFS/ARS.`,\n accion_sugerida: clean(d.accion) || 'Revisar con Nómina/Administración.',\n\n estado_resolucion: 'abierto',\n datos_originales: d\n });\n}\n\nfor (const a of (cruce.alertasDocumentales || [])) {\n rows.push({\n id_ejecucion: cruce.runId,\n pais_codigo: 'RD',\n pais_nombre: 'República Dominicana',\n\n categoria: 'alerta_documental',\n severidad: 'advertencia',\n\n identificador_empleado: clean(a.documento_nomina || a.documento_tss) || null,\n nombre_empleado: clean(a.nombre_nomina || a.nombre_tss) || null,\n\n fuente_a: 'Nómina',\n fuente_b: 'TSS',\n\n diferencia_monto: null,\n moneda: 'DOP',\n\n descripcion: clean(a.tipo) || 'Alerta documental detectada.',\n accion_sugerida: clean(a.accion) || 'Revisar documento/identidad en archivos fuente.',\n\n estado_resolucion: 'abierto',\n datos_originales: a\n });\n}\n\nreturn rows.map(r => ({ json: r }));" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1920, + 4256 + ], + "id": "78f93bbd-d67f-4c90-bebb-7926561730bf", + "name": "Preparar hallazgos auditoría Supabase", + "continueOnFail": true + }, + { + "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": [ + 2160, + 4256 + ], + "id": "66c92bb1-771d-4ac8-8c63-603571b6eb43", + "name": "Guardar hallazgos auditoría Supabase", + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "function clean(v) {\n return String(v || '').replace(/\\s+/g, ' ').trim();\n}\n\nconst cruce = $('Construir reporte producción').item.json;\nconst summary = cruce.summary || {};\nconst archivos = summary.archivos || {};\n\nconst rows = [];\n\nconst defs = [\n { rol: 'Nómina 1', nombre: archivos['Nómina 1'], registros: null },\n { rol: 'Nómina 2', nombre: archivos['Nómina 2'], registros: null },\n { rol: 'TSS', nombre: archivos['TSS'], registros: Number(summary.tssDocuments || 0) }\n];\n\nfor (const d of defs) {\n if (!clean(d.nombre)) continue;\n rows.push({\n id_ejecucion: cruce.runId,\n pais_codigo: 'RD',\n rol_archivo: d.rol,\n nombre_archivo: clean(d.nombre),\n tipo_archivo: 'Excel',\n registros_detectados: d.registros,\n monto_total: null,\n datos_originales: {\n periodo: cruce.periodoLabel,\n reportTitle: cruce.reportTitle\n }\n });\n}\n\nreturn rows.map(r => ({ json: r }));" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1920, + 4448 + ], + "id": "3c09292e-9ec9-414f-8294-f18d170c7446", + "name": "Preparar archivos auditoría Supabase", + "continueOnFail": true + }, + { + "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": [ + 2160, + 4448 + ], + "id": "02ed299e-09e6-4454-9485-b94927ddf1ec", + "name": "Guardar archivos auditoría Supabase", + "continueOnFail": true + }, + { + "parameters": { + "content": "## Auditoría Supabase\n\nGuarda uso del formulario, resultado del cruce, diferencias, archivos y enlace del reporte.\n\nUsa variables de entorno:\n- SUPABASE_URL\n- SUPABASE_SERVICE_ROLE_KEY\n\nLos nodos tienen Continue On Fail para no detener el cruce si Supabase falla.", + "height": 816, + "width": 568, + "color": 7 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 1808, + 3840 + ], + "id": "8327bacb-0777-4691-8e9e-13c9856beffc", + "name": "Sticky Note Supabase" + }, + { + "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": [ + -1152, + 4112 + ], + "id": "547e47b9-ee4c-4c2d-bfe8-0226fbed3e11", + "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 ejecución')[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 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\nconst rawByEmployee = new Map();\nrows.forEach((row, index) => {\n const nombre = row.fullName5 || row.displayName || [row.firstName, row.middleName, row.lastName].filter(Boolean).join(' ');\n const key =\n clean(row.employeeId || row.id) ||\n clean(row.employeeNumber) ||\n `${normalizeName(nombre)}|${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 bambooRecords = [...byEmployee.values()];\n\nif (!bambooRecords.length) {\n throw new Error(\n 'BambooHR no devolvió empleados legibles. Revisa la credencial y los permisos para nombres, fecha de ingreso y estado.'\n );\n}\n\nreturn [{\n json: {\n runId: init.runId,\n bambooRecords,\n empleadosBamboo: bambooRecords.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": [ + -912, + 4112 + ], + "id": "2db39e72-9ced-4c98-a87e-9d80c5101ae6", + "name": "Normalizar empleados BambooHR" + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + -768, + 3872 + ], + "id": "b15d09e1-9e16-45f0-923e-9caa617db1cd", + "name": "Esperar BambooHR y cruce" + }, + { + "parameters": { + "content": "## Consultar BambooHR\nConsulta de empleados en BambooHR para elaboración de hoja \"Nómina sin BambooHR\" y columnas en la hoja \"Reporte\"", + "height": 544, + "width": 688, + "color": "#672865" + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + -1280, + 3760 + ], + "id": "fcd7eeb2-a51b-485a-9249-3c6950211eac", + "name": "Sticky Note4" + }, + { + "parameters": { + "operation": "completion", + "respondWith": "showText", + "responseText": "
\n
\n\n

\n Proceso completado\n

\n\n

\n El reporte de Seguridad Social de República Dominicana fue generado, compartido y enviado por correo correctamente.\n

\n\n \n Regresar al formulario\n \n
" + }, + "type": "n8n-nodes-base.form", + "typeVersion": 2.5, + "position": [ + 2736, + 4448 + ], + "id": "4daf0a8b-6e45-4835-845b-1fe1907d4154", + "name": "Confirmación y regreso al formulario", + "webhookId": "5adbd6f7-496c-43ab-883e-3de67a498d14", + "notesInFlow": true, + "notes": "Página final mostrada después de enviar el correo y completar la auditoría. Incluye un botón para regresar al formulario en la misma pestaña." + }, + { + "parameters": { + "content": "## Regresar al formulario\nVolver al formulario luego de su ejecución", + "height": 320, + "width": 304, + "color": "#276347" + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 2640, + 4336 + ], + "id": "4531f817-ac38-4102-8f71-05bef2b00b98", + "name": "Sticky Note5" + } + ], + "pinData": {}, + "connections": { + "On form submission": { + "main": [ + [ + { + "node": "Inicializar ejecución", + "type": "main", + "index": 0 + } + ] + ] + }, + "Inicializar ejecución": { + "main": [ + [ + { + "node": "Preparar Nómina 1", + "type": "main", + "index": 0 + }, + { + "node": "Preparar Nómina 2", + "type": "main", + "index": 0 + }, + { + "node": "Preparar TSS", + "type": "main", + "index": 0 + }, + { + "node": "Preparar auditoría inicial Supabase", + "type": "main", + "index": 0 + }, + { + "node": "Consultar empleados BambooHR", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar Nómina 1": { + "main": [ + [ + { + "node": "Actualizar staging Nómina 1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Actualizar staging Nómina 1": { + "main": [ + [ + { + "node": "Esperar conversión Nómina 1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Esperar conversión Nómina 1": { + "main": [ + [ + { + "node": "Listar hojas Nómina 1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Listar hojas Nómina 1": { + "main": [ + [ + { + "node": "Preparar hojas Nómina 1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar hojas Nómina 1": { + "main": [ + [ + { + "node": "Leer hojas Nómina 1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Leer hojas Nómina 1": { + "main": [ + [ + { + "node": "Extraer Nómina 1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extraer Nómina 1": { + "main": [ + [ + { + "node": "Esperar nóminas", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar Nómina 2": { + "main": [ + [ + { + "node": "Actualizar staging Nómina 2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Actualizar staging Nómina 2": { + "main": [ + [ + { + "node": "Esperar conversión Nómina 2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Esperar conversión Nómina 2": { + "main": [ + [ + { + "node": "Listar hojas Nómina 2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Listar hojas Nómina 2": { + "main": [ + [ + { + "node": "Preparar hojas Nómina 2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar hojas Nómina 2": { + "main": [ + [ + { + "node": "Leer hojas Nómina 2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Leer hojas Nómina 2": { + "main": [ + [ + { + "node": "Extraer Nómina 2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extraer Nómina 2": { + "main": [ + [ + { + "node": "Esperar nóminas", + "type": "main", + "index": 1 + } + ] + ] + }, + "Esperar nóminas": { + "main": [ + [ + { + "node": "Esperar TSS y nóminas", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar TSS": { + "main": [ + [ + { + "node": "Extraer TSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Actualizar staging TSS": { + "main": [ + [ + { + "node": "Esperar conversión TSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Esperar conversión TSS": { + "main": [ + [ + { + "node": "Listar hojas TSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Listar hojas TSS": { + "main": [ + [ + { + "node": "Preparar hoja TSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar hoja TSS": { + "main": [ + [ + { + "node": "Leer hoja TSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extraer TSS": { + "main": [ + [ + { + "node": "Esperar TSS y nóminas", + "type": "main", + "index": 1 + } + ] + ] + }, + "Esperar TSS y nóminas": { + "main": [ + [ + { + "node": "Esperar BambooHR y cruce", + "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": "Preparar formato reporte", + "type": "main", + "index": 0 + } + ] + ] + }, + "Formatear reporte": { + "main": [ + [ + { + "node": "CONFIG - Destinatarios correo y permisos", + "type": "main", + "index": 0 + } + ] + ] + }, + "Compartir reporte destinatarios Gmail": { + "main": [ + [ + { + "node": "Preparar correo único", + "type": "main", + "index": 0 + } + ] + ] + }, + "CONFIG - Destinatarios correo y permisos": { + "main": [ + [ + { + "node": "Expandir permisos destinatarios", + "type": "main", + "index": 0 + } + ] + ] + }, + "Expandir permisos destinatarios": { + "main": [ + [ + { + "node": "Compartir reporte destinatarios Gmail", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar correo único": { + "main": [ + [ + { + "node": "Enviar correo resumen", + "type": "main", + "index": 0 + }, + { + "node": "Preparar auditoría final Supabase", + "type": "main", + "index": 0 + }, + { + "node": "Preparar hallazgos auditoría Supabase", + "type": "main", + "index": 0 + }, + { + "node": "Preparar archivos auditoría Supabase", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar formato reporte": { + "main": [ + [ + { + "node": "Formatear reporte", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar auditoría inicial Supabase": { + "main": [ + [ + { + "node": "Registrar auditoría inicial Supabase", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar auditoría final Supabase": { + "main": [ + [ + { + "node": "Guardar auditoría final Supabase", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar hallazgos auditoría Supabase": { + "main": [ + [ + { + "node": "Guardar hallazgos auditoría Supabase", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar archivos auditoría Supabase": { + "main": [ + [ + { + "node": "Guardar archivos auditoría Supabase", + "type": "main", + "index": 0 + } + ] + ] + }, + "Consultar empleados BambooHR": { + "main": [ + [ + { + "node": "Normalizar empleados BambooHR", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalizar empleados BambooHR": { + "main": [ + [ + { + "node": "Esperar BambooHR y cruce", + "type": "main", + "index": 1 + } + ] + ] + }, + "Esperar BambooHR y cruce": { + "main": [ + [ + { + "node": "Construir reporte producción", + "type": "main", + "index": 0 + } + ] + ] + }, + "Guardar archivos auditoría Supabase": { + "main": [ + [ + { + "node": "Confirmación y regreso al formulario", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": true, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate", + "availableInMCP": true, + "timeSavedMode": "fixed", + "errorWorkflow": "puF4LUczoSz3hcek", + "timezone": "America/Santo_Domingo", + "callerPolicy": "workflowsFromSameOwner" + }, + "versionId": "0e35d096-4f9e-40a1-b12c-3075978ec9c2", + "meta": { + "instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70" + }, + "id": "adEY9RJsE9o9AJPb", + "tags": [] +} \ No newline at end of file