Files
cruce-cuentas-glm-centralizado/Portal de Verificaci├│n de N├│mina - Honduras.json
T

1064 lines
220 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"name": "Portal de Verificación de Nómina - Honduras",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "nominahn-bamboo-test",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
-1088,
512
],
"id": "5d3b8ad0-7348-447e-b5e2-7a00235bb69c",
"name": "Webhook",
"webhookId": "517b24db-a370-4701-8281-06b7d277b403"
},
{
"parameters": {
"jsCode": "const item = $input.first();\nconst body = item.json.body || {};\nconst binary = item.binary || {};\n\nlet metadata = {};\ntry {\n metadata = typeof body.metadata === 'string'\n ? JSON.parse(body.metadata)\n : body.metadata || {};\n} catch (error) {\n metadata = {};\n}\n\nconst binaryKeys = Object.keys(binary);\n\nfunction naturalBinarySort(left, right) {\n return String(left).localeCompare(String(right), undefined, {\n numeric: true,\n sensitivity: 'base',\n });\n}\n\n// La app envía cada archivo dos veces por compatibilidad:\n// - campos multipart repetidos: payroll_files / bank_files\n// - campos únicos numerados: payroll_file_1 / bank_file_1\n//\n// En n8n los campos repetidos pueden materializarse como payroll_files0,\n// bank_files0, etc. Priorizamos los campos únicos numerados para evitar\n// duplicar archivos y usamos los repetidos solo como respaldo.\nconst numberedPayrollKeys = binaryKeys\n .filter((key) => /^payroll_file_\\d+$/i.test(key))\n .sort(naturalBinarySort);\n\nconst repeatedPayrollKeys = binaryKeys\n .filter((key) => /^payroll_files(?:_?\\d+)?$/i.test(key))\n .sort(naturalBinarySort);\n\nlet payrollKeys = numberedPayrollKeys.length\n ? numberedPayrollKeys\n : repeatedPayrollKeys;\n\nif (payrollKeys.length === 0 && binary.payroll_file) {\n payrollKeys = ['payroll_file'];\n}\n\nconst numberedBankKeys = binaryKeys\n .filter((key) => /^bank_file_\\d+$/i.test(key))\n .sort(naturalBinarySort);\n\nconst repeatedBankKeys = binaryKeys\n .filter((key) => /^bank_files(?:_?\\d+)?$/i.test(key))\n .sort(naturalBinarySort);\n\nconst bankKeys = numberedBankKeys.length\n ? numberedBankKeys\n : repeatedBankKeys;\n\nconst payrollFiles = payrollKeys.map((key) => ({\n binary_key: key,\n file_name: binary[key]?.fileName || key,\n file_extension: binary[key]?.fileExtension || '',\n mime_type: binary[key]?.mimeType || '',\n file_size: binary[key]?.fileSize || null,\n}));\n\nconst bankFiles = bankKeys.map((key) => ({\n binary_key: key,\n file_name: binary[key]?.fileName || key,\n file_extension: binary[key]?.fileExtension || '',\n mime_type: binary[key]?.mimeType || '',\n file_size: binary[key]?.fileSize || null,\n}));\n\nconst receivedCountry = String(metadata.country || '').trim().toUpperCase();\nconst errors = [];\n\nif (receivedCountry !== 'HN') {\n errors.push('El país recibido no es Honduras.');\n}\nif (!metadata.year) errors.push('No se recibió el año del cruce.');\nif (!metadata.month) errors.push('No se recibió el mes del cruce.');\nif (!metadata.period_type) errors.push('No se recibió el tipo de quincena.');\nif (!metadata.period_start || !metadata.period_end) {\n errors.push('No se recibió el período calculado.');\n}\nif (payrollFiles.length === 0) {\n errors.push('No se recibió ningún archivo Excel de nómina de Honduras.');\n}\nif (bankFiles.length === 0) {\n errors.push('No se recibió ningún archivo CSV bancario de Honduras.');\n}\n\nconst mainPayroll =\n payrollFiles.find((file) => /(?:general|nestle|nestlé|motorola|karla)/i.test(file.file_name)) ||\n payrollFiles[0] ||\n null;\n\nconst normalizedMetadata = {\n ...metadata,\n country: 'HN',\n country_name: 'Honduras',\n source_app: metadata.source_app || 'portal-cruce-cuentas-glm',\n payroll_file_name:\n metadata.payroll_file_name || mainPayroll?.file_name || '',\n payroll_file_names:\n Array.isArray(metadata.payroll_file_names) && metadata.payroll_file_names.length\n ? metadata.payroll_file_names\n : payrollFiles.map((file) => file.file_name),\n bank_file_names:\n Array.isArray(metadata.bank_file_names) && metadata.bank_file_names.length\n ? metadata.bank_file_names\n : bankFiles.map((file) => file.file_name),\n};\n\nreturn [{\n json: {\n ok: errors.length === 0,\n stage: 'entrada_hn_recibida',\n errors,\n metadata: normalizedMetadata,\n payroll_files: payrollFiles,\n bank_files: bankFiles,\n summary: {\n payroll_files_count: payrollFiles.length,\n bank_files_count: bankFiles.length,\n },\n },\n binary,\n}];\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-848,
512
],
"id": "0983b28d-86ea-4453-85e2-90f2b0fd159f",
"name": "Preparar entrada app"
},
{
"parameters": {
"jsCode": "const input = $input.first();\nconst json = input.json || {};\nconst binary = input.binary || {};\n\nfunction parseCsvLine(line) {\n const result = [];\n let current = '';\n let insideQuotes = false;\n for (let i = 0; i < line.length; i++) {\n const char = line[i];\n const next = line[i + 1];\n if (char === '\"' && insideQuotes && next === '\"') { current += '\"'; i++; continue; }\n if (char === '\"') { insideQuotes = !insideQuotes; continue; }\n if (char === ',' && !insideQuotes) { result.push(current.trim()); current = ''; continue; }\n current += char;\n }\n result.push(current.trim());\n return result;\n}\nfunction clean(value) { return String(value ?? '').replace(/\\uFEFF/g,'').replace(/\\u00A0/g,' ').replace(/\\s+/g,' ').trim(); }\nfunction normalize(value) { return clean(value).toLowerCase().normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[^a-z0-9 ]/g,' ').replace(/\\s+/g,' ').trim(); }\nfunction normalizeAccount(value) { return String(value ?? '').replace(/\\u00A0/g,'').replace(/\\.0$/g,'').replace(/\\D/g,'').trim(); }\nfunction isValidAccount(value) { const a=normalizeAccount(value); return a.length>=6 && !/^0+$/.test(a); }\nfunction parseMoney(value) { const n=String(value??'').replace(/HNL|LPS|USD/gi,'').replace(/\\$/g,'').replace(/,/g,'').replace(/\\s+/g,'').trim(); const p=Number.parseFloat(n); return Number.isFinite(p)?p:0; }\nfunction roundMoney(v){ return Math.round((Number(v)||0)*100)/100; }\nfunction detectCurrency(value, fileName='') { const raw=String(value??'').toUpperCase(); const fn=String(fileName??'').toUpperCase(); if(raw.includes('USD') || fn.includes('AR95US')) return 'USD'; return 'HNL'; }\nfunction getColumnIndex(headers,names){ const nh=headers.map(normalize); for(const name of names){const e=normalize(name);const i=nh.findIndex(h=>h===e);if(i>=0)return i;} for(const name of names){const e=normalize(name);const i=nh.findIndex(h=>h.includes(e));if(i>=0)return i;} return -1; }\nfunction extractShipmentNumber(lines,fileName){ for(const line of lines.slice(0,35)){const m=normalize(line).match(/(?:detalle del envio|numero de envio)[^0-9]{0,30}(\\d{1,20})/);if(m?.[1])return m[1];} const f=String(fileName||'').match(/_(\\d{2,6})_\\d{6,8}/);return f?.[1]||''; }\nfunction extractPlanNumber(lines,fileName){ for(const line of lines.slice(0,35)){const m=normalize(line).match(/(?:numero de plan|plan)[^a-z0-9]{0,20}([a-z0-9-]{2,30})/);if(m?.[1])return String(m[1]).toUpperCase();} const f=String(fileName||'').match(/plan\\s+([A-Za-z0-9-]{2,30})/i);return f?.[1]?.toUpperCase()||''; }\nfunction extractPlanLabel(fileName, planNumber){ const f=String(fileName||'').match(/plan\\s+([A-Za-z0-9-]{2,30})/i); return f?.[1]?.toUpperCase() || planNumber || ''; }\nconst natural=(a,b)=>String(a).localeCompare(String(b),undefined,{numeric:true,sensitivity:'base'});\nlet bankKeys=Array.isArray(json.bank_files)?json.bank_files.map(f=>String(f?.binary_key||'')).filter(k=>k&&binary[k]):[];\nif(!bankKeys.length){const keys=Object.keys(binary);const numbered=keys.filter(k=>/^bank_file_\\d+$/i.test(k)).sort(natural);const repeated=keys.filter(k=>/^bank_files(?:_?\\d+)?$/i.test(k)).sort(natural);bankKeys=numbered.length?numbered:repeated;}\nbankKeys=Array.from(new Set(bankKeys)).sort(natural);\nconst allRows=[]; const fileSummaries=[];\nfor(const key of bankKeys){\n const file=binary[key]; const buffer=await this.helpers.getBinaryDataBuffer(0,key); const text=buffer.toString('latin1');\n const lines=text.split(/\\r?\\n/).map(l=>l.trim()).filter(Boolean);\n const shipmentNumber=extractShipmentNumber(lines,file.fileName); const planNumber=extractPlanNumber(lines,file.fileName); const planLabel=extractPlanLabel(file.fileName,planNumber);\n const markerIndex=lines.findIndex(line=>normalize(line).includes('transacciones del envio'));\n const headerIndex=lines.findIndex((line,index)=>{if(markerIndex>=0&&index<=markerIndex)return false;const n=normalize(line);return n.includes('cuenta destino')&&n.includes('monto');});\n if(headerIndex<0){fileSummaries.push({file_name:file.fileName,shipment_number:shipmentNumber,plan_number:planNumber,plan_label:planLabel,ok:false,rows_count:0,total_amount:0,error:'No se encontró el encabezado de transacciones del plano bancario de Honduras.'});continue;}\n const headers=parseCsvLine(lines[headerIndex]).map(clean);\n const idxCuenta=getColumnIndex(headers,['Cuenta Destino']); const idxNombre=getColumnIndex(headers,['Nombre en Archivo']); const idxHolder=getColumnIndex(headers,['Nombre del Cuentahabiente']); const idxMonto=getColumnIndex(headers,['Monto']); const idxFecha=getColumnIndex(headers,['Fecha/Hora de aplicación','Fecha/Hora de aplicacion']); const idxConcepto=getColumnIndex(headers,['Concepto']); const idxEstado=getColumnIndex(headers,['Estado']); const idxRef=getColumnIndex(headers,['Referencia']); const idxEnvio=getColumnIndex(headers,['Número de envío','Numero de envio']); const idxPlan=getColumnIndex(headers,['Número de plan','Numero de plan']);\n const rowsFromFile=[];\n for(let i=headerIndex+1;i<lines.length;i++){\n const values=parseCsvLine(lines[i]); const rawAmount=idxMonto>=0?values[idxMonto]:''; const amount=roundMoney(parseMoney(rawAmount)); if(amount<=0)continue;\n const account=normalizeAccount(idxCuenta>=0?values[idxCuenta]:''); if(!isValidAccount(account))continue;\n const row={source_file:file.fileName,row_number:i+1,bank_row_key:`${file.fileName}|${i+1}`,account,raw_account:account,account_is_valid:true,reference:clean(idxRef>=0?values[idxRef]:''),shipment_number:clean(idxEnvio>=0?values[idxEnvio]:'')||shipmentNumber,plan_number:clean(idxPlan>=0?values[idxPlan]:'')||planNumber,plan_label:planLabel,bank_name_file:clean(idxNombre>=0?values[idxNombre]:''),bank_account_holder:clean(idxHolder>=0?values[idxHolder]:''),amount,currency:detectCurrency(rawAmount,file.fileName),application_date:clean(idxFecha>=0?values[idxFecha]:''),concept:clean(idxConcepto>=0?values[idxConcepto]:''),status:clean(idxEstado>=0?values[idxEstado]:'')};\n rowsFromFile.push(row); allRows.push(row);\n }\n const totalsByCurrency=rowsFromFile.reduce((acc,r)=>{acc[r.currency]=roundMoney((acc[r.currency]||0)+r.amount);return acc;},{});\n fileSummaries.push({file_name:file.fileName,shipment_number:shipmentNumber,plan_number:planNumber,plan_label:planLabel,ok:true,rows_count:rowsFromFile.length,total_amount:roundMoney(rowsFromFile.reduce((s,r)=>s+r.amount,0)),totals_by_currency:totalsByCurrency});\n}\nif(!allRows.length) throw new Error('Honduras: no se encontró ninguna transacción bancaria válida en los CSV cargados.');\nconst totalsByCurrency=allRows.reduce((acc,r)=>{acc[r.currency]=roundMoney((acc[r.currency]||0)+r.amount);return acc;},{});\nreturn [{json:{...json,stage:'banco_hn_normalizado',bank:{files_count:bankKeys.length,file_summaries:fileSummaries,rows_count:allRows.length,total_amount:roundMoney(allRows.reduce((s,r)=>s+r.amount,0)),totals_by_currency:totalsByCurrency,rows:allRows}}}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-608,
352
],
"id": "c7d5a5c0-0044-4570-800a-1069eef2a92e",
"name": "Parsear CSV banco HN"
},
{
"parameters": {
"jsCode": "const input=$input.first(); const base=input.json||{}; const binary=input.binary||{}; const configuredFiles=Array.isArray(base.payroll_files)?base.payroll_files:[];\nconst SHEETS=[\n ['Colgate Base',5,'HNL'],['Colgate Combustible',5,'HNL'],['POP Helper Base',5,'HNL'],['POP Helper Combustible',5,'HNL'],['Motorola Base',5,'HNL'],['Motorola Temporales',5,'HNL'],\n ['Motorola KAM Dolares',5,'USD'],['Nestle Base ',5,'HNL'],['Nestle Combustible',5,'HNL'],['Whirlpool Base',7,'USD'],['Whirlpool Combustible',6,'USD'],['FF Regional WH Base',7,'USD'],['FF Regional WH Combustible',6,'USD'],['GLM Base Lempiras ',3,'HNL'],['GLM Combustible Lempiras ',4,'HNL'],['GLM Dolares Base',5,'USD'],['GLM Dolares Combustible',4,'USD']\n];\nconst outputs=[];\nfor(const fileInfo of configuredFiles){const key=String(fileInfo.binary_key||'');const file=binary[key];if(!key||!file)continue;const fileName=String(file.fileName||fileInfo.file_name||key);for(const [sheetName,headerRow,currency] of SHEETS){outputs.push({json:{source_file:fileName,source_binary_key:key,source_kind:'quincenal',payroll_group:'honduras',sheet_name:sheetName,header_row:headerRow,range:`A${headerRow}:CA2000`,currency},binary:{data:file}});}}\nif(!outputs.length)throw new Error('No se pudo preparar el archivo de nómina de Honduras. Carga la nómina GLM HN en formato Excel.');\nreturn outputs;"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-608,
672
],
"id": "01c05dab-e323-4d59-97ab-348cb3e2e25b",
"name": "Preparar archivos nómina HN"
},
{
"parameters": {
"operation": "xlsx",
"options": {
"headerRow": true,
"range": "={{ $json.range }}",
"sheetName": "={{ $json.sheet_name }}"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
-368,
672
],
"id": "d10dc605-66f2-4f44-9ed5-60473d57fd24",
"name": "Extract - Archivo Nómina HN",
"retryOnFail": false,
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "const items=$input.all(); const base=$('Preparar entrada app').first().json||{};\nfunction clean(v){return String(v??'').replace(/\\uFEFF/g,'').replace(/\\u00A0/g,' ').replace(/\\s+/g,' ').trim();}\nfunction normalize(v){return clean(v).toLowerCase().normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[^a-z0-9 #]/g,' ').replace(/\\s+/g,' ').trim();}\nfunction parseMoney(v){if(typeof v==='number')return Number.isFinite(v)?v:0;const s=String(v??'').replace(/HNL|LPS|USD/gi,'').replace(/\\$/g,'').replace(/,/g,'').replace(/\\s+/g,'').trim();const p=Number.parseFloat(s);return Number.isFinite(p)?p:0;}\nfunction roundMoney(v){return Math.round((Number(v)||0)*100)/100;}\nfunction normalizeAccount(v){if(typeof v==='number'&&Number.isFinite(v))return String(Math.round(v));const raw=clean(v);if(/^[+-]?(?:\\d+(?:\\.\\d+)?|\\.\\d+)[eE][+-]?\\d+$/.test(raw)){const n=Number(raw);if(Number.isFinite(n))return String(Math.round(n));}return raw.replace(/\\.0$/g,'').replace(/\\D/g,'').trim();}\nfunction getValue(row,keys){for(const k of keys){if(row[k]!==undefined&&row[k]!==null&&row[k]!=='')return row[k];}const actual=Object.keys(row||{});for(const expected of keys){const ne=normalize(expected);const k=actual.find(x=>normalize(x)===ne);if(k){const v=row[k];if(v!==undefined&&v!==null&&v!=='')return v;}}return '';}\nfunction validEmployeeName(v){const name=clean(v);const n=normalize(name);if(!name||/^[\\d.,\\s-]+$/.test(name))return false;const bad=['total','subtotal','total general','gran total','nombre','empleado','colaborador'];return !bad.some(t=>n===t||n.startsWith(`${t} `));}\nfunction sourceMeta(i){try{return $('Preparar archivos nómina HN').itemMatching(i).json||{};}catch{return {};}}\nconst rows=[];const ignored=[];const counters=new Map();const summaries=new Map();\nitems.forEach((item,index)=>{const source=item.json||{};const linked=sourceMeta(index);const sourceFile=clean(linked.source_file||'');const sourceSheet=clean(linked.sheet_name||'');const currency=clean(linked.currency||'HNL').toUpperCase()==='USD'?'USD':'HNL';const ck=`${sourceFile}|${sourceSheet}`;const dataIndex=(counters.get(ck)||0)+1;counters.set(ck,dataIndex);const rowNumber=Number(linked.header_row||1)+dataIndex;\n const employeeName=clean(getValue(source,['NOMBRE','Nombre','Nombre del Empleado','NOMBRE DEL EMPLEADO']));\n const employeeNumber=clean(getValue(source,['Identidad','Identidad ','IDENTIDAD','Employee Number','Employee #']));\n const account=normalizeAccount(getValue(source,['# de Cuenta Bac','# de Cuenta Banco','CUENTA BANCO','Cuenta Banco','CUENTA BANCARIA','Cuenta Bancaria','CUENTA']));\n const amount=roundMoney(parseMoney(getValue(source,['NETO A PAGAR','Neto a Pagar','Total a Pagar','TOTAL A PAGAR','Total Neto'])));\n const summary=summaries.get(ck)||{source_file:sourceFile,source_sheet:sourceSheet,currency,extracted_rows_count:0,accepted_rows_count:0,ignored_rows_count:0,total_amount:0};summary.extracted_rows_count++;\n if(!validEmployeeName(employeeName)||amount<=0||amount>5000000){summary.ignored_rows_count++;summaries.set(ck,summary);if(employeeName||amount)ignored.push({source_file:sourceFile,source_sheet:sourceSheet,row_number:rowNumber,employee_name:employeeName,account,payroll_amount:amount,currency});return;}\n rows.push({payroll_row_key:`HN_PAYROLL_${index+1}_${rowNumber}`,row_number:rowNumber,employee_name:employeeName,employee_number:employeeNumber,account,payroll_account:account,payroll_amount:amount,currency,source_file:sourceFile,source_kind:'quincenal',source_sheet:sourceSheet,payroll_group:'honduras',is_main_payroll:true,raw:source});summary.accepted_rows_count++;summary.total_amount=roundMoney(summary.total_amount+amount);summaries.set(ck,summary);\n});\nif(!rows.length)throw new Error('Honduras: no se encontró ninguna fila válida de nómina en el archivo cargado.');\nconst totalsByCurrency=rows.reduce((acc,r)=>{acc[r.currency]=roundMoney((acc[r.currency]||0)+r.payroll_amount);return acc;},{});const totalAmount=roundMoney(rows.reduce((s,r)=>s+r.payroll_amount,0));\nreturn [{json:{...base,stage:'nomina_hn_normalizada',payroll:{source:'nomina_honduras_multimoneda',files_count:Number(base.summary?.payroll_files_count||0),file_summaries:Array.from(summaries.values()),raw_rows_count:items.length,rows_count:rows.length,valid_rows_count:rows.length,base_rows_count:rows.length,supplement_rows_count:0,attached_supplements_count:0,unattached_supplements_count:0,no_account_rows_count:rows.filter(r=>!r.account).length,ignored_rows_count:ignored.length,base_total:totalAmount,supplements_total:0,total_amount:totalAmount,totals_by_currency:totalsByCurrency,rows,no_account_rows:rows.filter(r=>!r.account),grouped_by_account:[],potential_supplements:[]},debug_payroll:{file_summaries:Array.from(summaries.values()),ignored_rows_preview:ignored.slice(0,100)}}}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-128,
672
],
"id": "da41c543-d4dd-44e1-90fe-39c81866adce",
"name": "Normalizar Nómina HN"
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"options": {}
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
128,
512
],
"id": "a0eac81e-b6b4-475a-b9e9-f72e421e052b",
"name": "Merge Banco + Nómina HN"
},
{
"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": "Información de BambooHR - Cruce de Cuentas Honduras",
"fields": [
"firstName",
"middleName",
"lastName",
"displayName",
"department",
"division",
"location",
"customPosicion-Cliente",
"hireDate",
"originalHireDate",
"status",
"terminationDate",
"employeeNumber",
"country",
"workEmail",
"homeEmail",
"bestEmail"
]
},
"options": {
"response": {
"response": {
"responseFormat": "json"
}
},
"timeout": 300000
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
368,
352
],
"id": "b0c95a03-b147-4380-beb3-f822cb7025dd",
"name": "HTTP - Empleados BambooHR HN",
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 3000,
"credentials": {
"httpBasicAuth": {
"id": "7VrpNZ2jBLmiJ35q",
"name": "BambooHR GLM Full Access"
}
}
},
{
"parameters": {
"jsCode": "const inputItems = $input.all();\nconst base = $('Preparar entrada app').first().json || {};\nconst reconciliationData = $('Merge Banco + Nómina HN').first().json || {};\nconst metadata = base.metadata || {};\n\nconst IGNORED_NAME_TOKENS = new Set([\n 'de', 'del', 'la', 'las', 'los',\n 'y', 'e', 'el', 'da', 'do',\n 'dos', 'das', 'van', 'von',\n]);\n\nfunction clean(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\u00A0/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalize(value) {\n return clean(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/['’`-]/g, ' ')\n .replace(/[^a-z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction unique(values) {\n const result = [];\n const seen = new Set();\n\n for (const value of values) {\n const cleaned = clean(value);\n\n if (!cleaned || seen.has(cleaned)) {\n continue;\n }\n\n seen.add(cleaned);\n result.push(cleaned);\n }\n\n return result;\n}\n\nfunction nameTokens(value) {\n return normalize(value)\n .split(' ')\n .filter(\n (token) =>\n token.length > 1 &&\n !IGNORED_NAME_TOKENS.has(token)\n );\n}\n\nfunction uniqueNameTokens(value) {\n return Array.from(\n new Set(nameTokens(value))\n );\n}\n\nfunction parseDate(value) {\n const raw = clean(value);\n if (!raw || raw === '0000-00-00') return null;\n\n const direct = raw.match(\n /^(\\d{4})-(\\d{2})-(\\d{2})/\n );\n\n if (direct) {\n return `${direct[1]}-${direct[2]}-${direct[3]}`;\n }\n\n const date = new Date(raw);\n if (Number.isNaN(date.getTime())) return null;\n\n return date.toISOString().slice(0, 10);\n}\n\nfunction parseBoolean(value) {\n if (typeof value === 'boolean') return value;\n\n return [\n 'true', 'yes', 'si', 'sí', '1', 'y',\n ].includes(normalize(value));\n}\n\nfunction isTargetCountry(employee) {\n const country = normalize(employee.country);\n const location = normalize(\n employee.location ||\n employee.jobInformationLocation ||\n employee.jobLocation\n );\n\n return (\n country === 'hn' ||\n country.includes('honduras') ||\n location === 'hn' ||\n location.includes('honduras')\n );\n}\n\nfunction overlapsPeriod(\n hireDate,\n terminationDate,\n periodStart,\n periodEnd\n) {\n if (!periodStart || !periodEnd) return false;\n\n const hiredBeforeEnd =\n !hireDate || hireDate <= periodEnd;\n\n const notTerminatedBeforeStart =\n !terminationDate ||\n terminationDate >= periodStart;\n\n return hiredBeforeEnd && notTerminatedBeforeStart;\n}\n\nfunction collectPageObjects(value, pages) {\n if (!value) return;\n\n if (Array.isArray(value)) {\n for (const entry of value) {\n collectPageObjects(entry, pages);\n }\n return;\n }\n\n if (typeof value !== 'object') return;\n\n if (value.body && typeof value.body === 'object') {\n collectPageObjects(value.body, pages);\n return;\n }\n\n if (\n Array.isArray(value.data) ||\n Array.isArray(value.employees)\n ) {\n pages.push(value);\n return;\n }\n\n if (value.json && typeof value.json === 'object') {\n collectPageObjects(value.json, pages);\n }\n}\n\nfunction profileContains(\n leftTokens,\n leftTokenSet,\n rightTokens,\n rightTokenSet\n) {\n if (\n leftTokens.length < 3 ||\n rightTokens.length < 3\n ) {\n return false;\n }\n\n const leftInsideRight =\n leftTokens.every((token) =>\n rightTokenSet.has(token)\n );\n\n if (leftInsideRight) return true;\n\n return rightTokens.every((token) =>\n leftTokenSet.has(token)\n );\n}\n\nfunction employeeKey(employee) {\n return (\n employee.bamboo_id ||\n employee.employee_number ||\n normalize(employee.full_name)\n );\n}\n\nconst pageObjects = [];\n\nfor (const item of inputItems) {\n collectPageObjects(item.json, pageObjects);\n}\n\nconst employeeMap = new Map();\nlet expectedTotal = 0;\nlet restrictedFields = 0;\n\nfor (const page of pageObjects) {\n const pageEmployees =\n Array.isArray(page.data)\n ? page.data\n : Array.isArray(page.employees)\n ? page.employees\n : [];\n\n const pageTotal = Number(\n page.meta?.total ||\n page.total ||\n 0\n );\n\n if (Number.isFinite(pageTotal)) {\n expectedTotal = Math.max(\n expectedTotal,\n pageTotal\n );\n }\n\n for (const employee of pageEmployees) {\n const key =\n clean(employee.employeeId || employee.id) ||\n clean(employee.employeeNumber) ||\n clean(employee.bestEmail).toLowerCase() ||\n [\n clean(employee.firstName),\n clean(employee.middleName),\n clean(employee.lastName),\n ].filter(Boolean).join('|').toLowerCase();\n\n if (!key) continue;\n\n employeeMap.set(key, employee);\n\n restrictedFields += Array.isArray(\n employee._restrictedFields\n )\n ? employee._restrictedFields.length\n : 0;\n }\n}\n\nconst rawEmployees = Array.from(\n employeeMap.values()\n);\n\nconst periodStart = clean(metadata.period_start);\nconst periodEnd = clean(metadata.period_end);\n\nconst targetEmployees = [];\nconst outsideRecords = [];\nlet normalizedEmployeesCount = 0;\n\nfor (const employee of rawEmployees) {\n const firstName = clean(employee.firstName);\n const middleName = clean(employee.middleName);\n const lastName = clean(employee.lastName);\n const preferredName = clean(\n employee.preferredName\n );\n\n const constructedFullName = [\n firstName,\n middleName,\n lastName,\n ].filter(Boolean).join(' ');\n\n const aliases = unique([\n employee.displayName,\n employee.fullName1,\n employee.fullName2,\n employee.fullName3,\n employee.fullName4,\n employee.fullName5,\n constructedFullName,\n [preferredName, lastName]\n .filter(Boolean)\n .join(' '),\n [firstName, lastName]\n .filter(Boolean)\n .join(' '),\n ]);\n\n const hireDate = parseDate(\n employee.hireDate ||\n employee.originalHireDate\n );\n\n const terminationDate = parseDate(\n employee.terminationDate\n );\n\n const status = clean(\n employee.status ||\n employee.employmentStatus ||\n employee.employmentHistoryStatus\n );\n\n const employeeNumber = clean(\n employee.employeeNumber ||\n employee.employee_number\n );\n\n const normalizedEmployee = {\n bamboo_id: clean(\n employee.employeeId ||\n employee.id\n ),\n employee_number: employeeNumber,\n first_name: firstName,\n middle_name: middleName,\n last_name: lastName,\n preferred_name: preferredName,\n full_name:\n clean(employee.displayName) ||\n clean(employee.fullName1) ||\n constructedFullName,\n aliases,\n normalized_aliases:\n aliases.map(normalize).filter(Boolean),\n status,\n hire_date: hireDate,\n termination_date: terminationDate,\n location: clean(\n employee.location ||\n employee.jobInformationLocation ||\n employee.jobLocation\n ),\n country: clean(employee.country),\n include_in_payroll:\n parseBoolean(employee.includeInPayroll),\n work_email:\n clean(employee.workEmail).toLowerCase(),\n home_email:\n clean(employee.homeEmail).toLowerCase(),\n best_email: clean(\n employee.bestEmail ||\n employee.workEmail ||\n employee.homeEmail\n ).toLowerCase(),\n exists_in_bamboo: true,\n overlaps_period: overlapsPeriod(\n hireDate,\n terminationDate,\n periodStart,\n periodEnd\n ),\n };\n\n normalizedEmployeesCount += 1;\n\n if (isTargetCountry(normalizedEmployee)) {\n targetEmployees.push({\n ...normalizedEmployee,\n validation_eligible: true,\n validation_scope:\n 'honduras_country_or_location',\n });\n continue;\n }\n\n const aliasProfiles = aliases\n .map((rawAlias) => {\n const tokens =\n uniqueNameTokens(rawAlias);\n\n return {\n raw: rawAlias,\n tokens,\n token_set: new Set(tokens),\n };\n })\n .filter(\n (profile) =>\n profile.tokens.length >= 3\n );\n\n const searchTokens = new Set();\n\n for (const profile of aliasProfiles) {\n for (const token of profile.tokens) {\n searchTokens.add(token);\n }\n }\n\n outsideRecords.push({\n employee: normalizedEmployee,\n alias_profiles: aliasProfiles,\n search_tokens: searchTokens,\n });\n}\n\nconst relevantNameMap = new Map();\n\nfunction addRelevantName(value) {\n const cleaned = clean(value);\n const normalized = normalize(cleaned);\n\n if (!normalized) return;\n\n const tokens = uniqueNameTokens(cleaned);\n const current =\n relevantNameMap.get(normalized);\n\n if (\n !current ||\n tokens.length > current.tokens.length\n ) {\n relevantNameMap.set(\n normalized,\n {\n raw: cleaned,\n tokens,\n token_set: new Set(tokens),\n }\n );\n }\n}\n\nfor (const row of reconciliationData.bank?.rows || []) {\n addRelevantName(row.bank_name_file);\n addRelevantName(row.bank_account_holder);\n addRelevantName(row.participant_name);\n}\n\nfor (\n const row of\n reconciliationData.bank?.grouped_by_account || []\n) {\n addRelevantName(row.bank_name_file);\n addRelevantName(row.bank_account_holder);\n\n for (const name of row.bank_name_files || []) {\n addRelevantName(name);\n }\n\n for (\n const name of\n row.bank_account_holders || []\n ) {\n addRelevantName(name);\n }\n}\n\nfor (const row of [\n ...(reconciliationData.payroll?.rows || []),\n ...(reconciliationData.payroll?.grouped_by_account || []),\n ...(reconciliationData.payroll?.no_account_rows || []),\n]) {\n addRelevantName(\n row.employee_name ||\n row.employee ||\n ''\n );\n}\n\n/*\n * Índice invertido para rescatar perfiles con país/localidad incorrectos.\n *\n * La versión anterior comparaba cada nombre relevante contra todos los\n * empleados fuera de Honduras y todas sus variantes de nombre. Con casi\n * 10,000 perfiles, eso provocaba millones de normalizaciones y bloqueaba el\n * task runner. Aquí cada palabra apunta directamente a los pocos empleados\n * que la contienen; luego solo se revisan candidatos con al menos tres\n * palabras compartidas.\n */\nconst outsideTokenIndex = new Map();\n\nfor (\n let index = 0;\n index < outsideRecords.length;\n index++\n) {\n for (\n const token of\n outsideRecords[index].search_tokens\n ) {\n let bucket =\n outsideTokenIndex.get(token);\n\n if (!bucket) {\n bucket = [];\n outsideTokenIndex.set(\n token,\n bucket\n );\n }\n\n bucket.push(index);\n }\n}\n\nconst targetExactAliasSet = new Set();\n\nfor (const employee of targetEmployees) {\n for (\n const normalizedAlias of\n employee.normalized_aliases || []\n ) {\n if (normalizedAlias) {\n targetExactAliasSet.add(\n normalizedAlias\n );\n }\n }\n}\n\nlet contextualOutsideSkippedByTargetExact = 0;\nconst contextualOutsideMap = new Map();\n\nfor (\n const relevantProfile of\n relevantNameMap.values()\n) {\n if (relevantProfile.tokens.length < 3) {\n continue;\n }\n\n /*\n * Prioridad absoluta al país objetivo:\n *\n * Cuando el nombre completo recibido existe exactamente en Honduras,\n * no se incorpora un perfil externo cuyo nombre más corto esté contenido\n * dentro de ese mismo texto. Esto evita conflictos entre dos personas\n * distintas, por ejemplo:\n *\n * - NOMBRE COMPLETO · Honduras\n * - un perfil con nombre más corto · otro país\n *\n * El rescate fuera del país permanece activo cuando no existe una\n * coincidencia exacta entre los perfiles de Honduras.\n */\n if (\n targetExactAliasSet.has(\n normalize(relevantProfile.raw)\n )\n ) {\n contextualOutsideSkippedByTargetExact += 1;\n continue;\n }\n\n const sharedTokenCounts = new Map();\n\n for (const token of relevantProfile.tokens) {\n for (\n const outsideIndex of\n outsideTokenIndex.get(token) || []\n ) {\n sharedTokenCounts.set(\n outsideIndex,\n (\n sharedTokenCounts.get(\n outsideIndex\n ) || 0\n ) + 1\n );\n }\n }\n\n const uniqueMatches = new Map();\n\n for (\n const [\n outsideIndex,\n sharedTokenCount,\n ] of sharedTokenCounts\n ) {\n if (sharedTokenCount < 3) {\n continue;\n }\n\n const record =\n outsideRecords[outsideIndex];\n\n const matches =\n record.alias_profiles.some(\n (aliasProfile) =>\n profileContains(\n relevantProfile.tokens,\n relevantProfile.token_set,\n aliasProfile.tokens,\n aliasProfile.token_set\n )\n );\n\n if (!matches) continue;\n\n const key =\n employeeKey(record.employee);\n\n if (key) {\n uniqueMatches.set(\n key,\n record.employee\n );\n }\n }\n\n /*\n * Solo se rescata un perfil fuera del país cuando un nombre informativo\n * identifica exactamente a una única persona. Así se corrigen localidades\n * erróneas sin convertir nombres comunes en falsos positivos.\n */\n if (uniqueMatches.size !== 1) {\n continue;\n }\n\n const employee =\n uniqueMatches.values().next().value;\n\n const key = employeeKey(employee);\n\n if (!key) continue;\n\n contextualOutsideMap.set(key, {\n ...employee,\n validation_eligible: true,\n validation_scope:\n 'outside_country_unique_informative_name',\n });\n}\n\nconst validationEmployeeMap = new Map();\n\nfor (const employee of [\n ...targetEmployees,\n ...contextualOutsideMap.values(),\n]) {\n const key = employeeKey(employee);\n\n if (key) {\n validationEmployeeMap.set(\n key,\n employee\n );\n }\n}\n\nconst validationEmployees =\n Array.from(\n validationEmployeeMap.values()\n );\n\n\n/*\n * Resolución previa de nombres contra BambooHR.\n *\n * Cada nombre distinto recibido desde banco y nómina se resuelve una sola\n * vez, usando índices de alias y palabras. El resultado queda disponible\n * para el nodo de cruce mediante resolved_name_matches.\n */\nconst CONFIRMED_BAMBOO_NAME_ALIASES = new Map([]);\n\nfunction relevantEntryRaw(entry) {\n if (typeof entry === 'string') return clean(entry);\n return clean(entry?.raw || entry?.name || '');\n}\n\nfunction bambooResolutionEmployeeKey(employee) {\n return (\n clean(employee.bamboo_id) ||\n clean(employee.employee_number) ||\n normalize(employee.full_name)\n );\n}\n\nfunction bambooResolutionEditDistance(left, right) {\n const a = String(left || '');\n const b = String(right || '');\n\n if (a === b) return 0;\n if (!a) return b.length;\n if (!b) return a.length;\n\n let previous = Array.from(\n { length: b.length + 1 },\n (_, index) => index\n );\n\n for (let row = 1; row <= a.length; row++) {\n const current = [row];\n\n for (let column = 1; column <= b.length; column++) {\n const cost =\n a[row - 1] === b[column - 1]\n ? 0\n : 1;\n\n current[column] = Math.min(\n current[column - 1] + 1,\n previous[column] + 1,\n previous[column - 1] + cost\n );\n }\n\n previous = current;\n }\n\n return previous[b.length];\n}\nfunction bambooResolutionTokenSimilarity(left, right) {\n const a = String(left || '');\n const b = String(right || '');\n\n if (!a || !b) return 0;\n if (a === b) return 1;\n\n const minimumLength = Math.min(\n a.length,\n b.length\n );\n\n const maximumLength = Math.max(\n a.length,\n b.length\n );\n\n const distance =\n bambooResolutionEditDistance(a, b);\n\n if (\n minimumLength >= 4 &&\n distance <= 1\n ) {\n return Math.max(\n 0.90,\n 1 - distance / maximumLength\n );\n }\n\n if (\n minimumLength >= 6 &&\n distance <= 2\n ) {\n return Math.max(\n 0.82,\n 1 - distance / maximumLength\n );\n }\n\n const prefixOrSuffix =\n a.startsWith(b) ||\n b.startsWith(a) ||\n a.endsWith(b) ||\n b.endsWith(a);\n\n if (\n prefixOrSuffix &&\n minimumLength >= 4\n ) {\n return Math.max(\n 0.78,\n minimumLength / maximumLength\n );\n }\n\n return 0;\n}\n\nfunction bambooResolutionAliasDetails(\n queryName,\n aliasProfile\n) {\n const queryWords = Array.from(\n new Set(nameTokens(queryName))\n );\n\n const aliasWords =\n aliasProfile.words;\n\n if (\n queryWords.length < 2 ||\n aliasWords.length < 2\n ) {\n return null;\n }\n\n const aliasWordSet =\n aliasProfile.word_set;\n\n const queryWordSet =\n new Set(queryWords);\n\n const queryInsideAlias =\n queryWords.every((word) =>\n aliasWordSet.has(word)\n );\n\n const aliasInsideQuery =\n aliasWords.every((word) =>\n queryWordSet.has(word)\n );\n\n const usedAliasIndexes = new Set();\n const usedQueryIndexes = new Set();\n const similarities = new Array(\n queryWords.length\n ).fill(0);\n\n let exactMatches = 0;\n\n for (\n let queryIndex = 0;\n queryIndex < queryWords.length;\n queryIndex++\n ) {\n const aliasIndex =\n aliasWords.findIndex(\n (aliasWord, currentAliasIndex) =>\n !usedAliasIndexes.has(\n currentAliasIndex\n ) &&\n aliasWord ===\n queryWords[queryIndex]\n );\n\n if (aliasIndex < 0) continue;\n\n usedQueryIndexes.add(queryIndex);\n usedAliasIndexes.add(aliasIndex);\n similarities[queryIndex] = 1;\n exactMatches += 1;\n }\n\n const remainingQueryIndexes =\n queryWords\n .map((word, index) => ({\n word,\n index,\n }))\n .filter((entry) =>\n !usedQueryIndexes.has(entry.index)\n )\n .sort((left, right) =>\n right.word.length -\n left.word.length\n );\n\n for (const queryEntry of remainingQueryIndexes) {\n let bestSimilarity = 0;\n let bestAliasIndex = -1;\n\n for (\n let aliasIndex = 0;\n aliasIndex < aliasWords.length;\n aliasIndex++\n ) {\n if (\n usedAliasIndexes.has(\n aliasIndex\n )\n ) {\n continue;\n }\n\n const similarity =\n bambooResolutionTokenSimilarity(\n queryEntry.word,\n aliasWords[aliasIndex]\n );\n\n if (similarity > bestSimilarity) {\n bestSimilarity = similarity;\n bestAliasIndex = aliasIndex;\n }\n }\n\n if (\n bestAliasIndex >= 0 &&\n bestSimilarity >= 0.78\n ) {\n usedAliasIndexes.add(\n bestAliasIndex\n );\n similarities[queryEntry.index] =\n bestSimilarity;\n }\n }\n\n const matchedTokens =\n similarities.filter(\n (value) => value >= 0.78\n ).length;\n\n const queryCoverage =\n similarities.reduce(\n (sum, value) => sum + value,\n 0\n ) / queryWords.length;\n\n const aliasCoverage =\n matchedTokens /\n aliasWords.length;\n\n const lengthBalance =\n Math.min(\n queryWords.length,\n aliasWords.length\n ) /\n Math.max(\n queryWords.length,\n aliasWords.length\n );\n\n const score =\n queryCoverage * 0.65 +\n aliasCoverage * 0.20 +\n (\n exactMatches /\n queryWords.length\n ) * 0.10 +\n lengthBalance * 0.05;\n\n return {\n score,\n exact_matches: exactMatches,\n matched_tokens: matchedTokens,\n query_tokens:\n queryWords.length,\n alias_tokens:\n aliasWords.length,\n query_coverage:\n queryCoverage,\n alias_coverage:\n aliasCoverage,\n containment:\n queryInsideAlias ||\n aliasInsideQuery,\n };\n}\n\nconst bambooResolutionProfiles =\n validationEmployees.map(\n (employee, employeeIndex) => {\n const aliases = [];\n const seenAliases = new Set();\n\n for (\n const rawAlias of\n employee.aliases || []\n ) {\n const normalizedAlias =\n normalize(rawAlias);\n\n if (\n !normalizedAlias ||\n seenAliases.has(\n normalizedAlias\n )\n ) {\n continue;\n }\n\n seenAliases.add(\n normalizedAlias\n );\n\n const words = Array.from(\n new Set(nameTokens(rawAlias))\n );\n\n if (!words.length) continue;\n\n aliases.push({\n raw: clean(rawAlias),\n normalized:\n normalizedAlias,\n words,\n word_set:\n new Set(words),\n });\n }\n\n return {\n employee,\n employee_index:\n employeeIndex,\n employee_key:\n bambooResolutionEmployeeKey(\n employee\n ),\n aliases,\n };\n }\n );\n\nconst bambooResolutionExactAliasSets =\n new Map();\n\nconst bambooResolutionTokenSets =\n new Map();\n\nconst bambooResolutionTokenShapeSets =\n new Map();\n\nfor (\n let employeeIndex = 0;\n employeeIndex <\n bambooResolutionProfiles.length;\n employeeIndex++\n) {\n const profile =\n bambooResolutionProfiles[\n employeeIndex\n ];\n\n for (const alias of profile.aliases) {\n let exactSet =\n bambooResolutionExactAliasSets\n .get(alias.normalized);\n\n if (!exactSet) {\n exactSet = new Set();\n bambooResolutionExactAliasSets\n .set(\n alias.normalized,\n exactSet\n );\n }\n\n exactSet.add(employeeIndex);\n\n for (const token of alias.words) {\n if (token.length < 3) continue;\n\n let tokenSet =\n bambooResolutionTokenSets\n .get(token);\n\n if (!tokenSet) {\n tokenSet = new Set();\n bambooResolutionTokenSets\n .set(token, tokenSet);\n }\n\n tokenSet.add(employeeIndex);\n\n const tokenShape =\n `${token[0]}:${token.length}`;\n\n let shapeSet =\n bambooResolutionTokenShapeSets\n .get(tokenShape);\n\n if (!shapeSet) {\n shapeSet = new Set();\n bambooResolutionTokenShapeSets\n .set(\n tokenShape,\n shapeSet\n );\n }\n\n shapeSet.add(employeeIndex);\n }\n }\n}\n\nconst bambooResolutionExactAliasMap =\n new Map();\n\nfor (\n const [alias, indexes] of\n bambooResolutionExactAliasSets\n) {\n bambooResolutionExactAliasMap.set(\n alias,\n Array.from(indexes)\n );\n}\n\nfunction bambooResolutionDecision(\n queryName\n) {\n const rawQuery = clean(queryName);\n const normalizedQuery =\n normalize(rawQuery);\n\n const queryWords = Array.from(\n new Set(nameTokens(rawQuery))\n );\n\n if (\n !normalizedQuery ||\n queryWords.length < 2\n ) {\n return {\n found: false,\n matched_by: null,\n confidence: 0,\n reason:\n 'insufficient_name_tokens',\n };\n }\n\n const confirmedCanonical =\n CONFIRMED_BAMBOO_NAME_ALIASES\n .get(normalizedQuery);\n\n if (confirmedCanonical) {\n const confirmedIndexes =\n bambooResolutionExactAliasMap\n .get(confirmedCanonical) || [];\n\n if (confirmedIndexes.length === 1) {\n const employeeIndex =\n confirmedIndexes[0];\n\n return {\n found: true,\n matched_by:\n 'confirmed_alias_catalog',\n confidence: 1,\n employee_index:\n employeeIndex,\n employee_key:\n bambooResolutionProfiles[\n employeeIndex\n ].employee_key,\n query_name:\n rawQuery,\n bamboo_alias:\n bambooResolutionProfiles[\n employeeIndex\n ].aliases.find(\n (alias) =>\n alias.normalized ===\n confirmedCanonical\n )?.raw ||\n bambooResolutionProfiles[\n employeeIndex\n ].employee.full_name ||\n '',\n };\n }\n }\n\n const exactIndexes =\n bambooResolutionExactAliasMap\n .get(normalizedQuery) || [];\n\n if (exactIndexes.length === 1) {\n const employeeIndex =\n exactIndexes[0];\n\n return {\n found: true,\n matched_by:\n 'exact_precomputed_name',\n confidence: 1,\n employee_index:\n employeeIndex,\n employee_key:\n bambooResolutionProfiles[\n employeeIndex\n ].employee_key,\n query_name:\n rawQuery,\n bamboo_alias:\n bambooResolutionProfiles[\n employeeIndex\n ].aliases.find(\n (alias) =>\n alias.normalized ===\n normalizedQuery\n )?.raw ||\n bambooResolutionProfiles[\n employeeIndex\n ].employee.full_name ||\n '',\n };\n }\n\n const candidateVotes = new Map();\n\n function addCandidateVotes(\n indexes,\n weight\n ) {\n for (const employeeIndex of indexes) {\n candidateVotes.set(\n employeeIndex,\n (\n candidateVotes.get(\n employeeIndex\n ) || 0\n ) + weight\n );\n }\n }\n\n for (const token of queryWords) {\n addCandidateVotes(\n bambooResolutionTokenSets\n .get(token) || [],\n 4\n );\n\n for (\n let lengthOffset = -2;\n lengthOffset <= 2;\n lengthOffset++\n ) {\n const candidateLength =\n token.length + lengthOffset;\n\n if (candidateLength < 3) {\n continue;\n }\n\n addCandidateVotes(\n bambooResolutionTokenShapeSets\n .get(\n `${token[0]}:${candidateLength}`\n ) || [],\n 1\n );\n }\n }\n\n const candidateIndexes =\n Array.from(\n candidateVotes.entries()\n )\n .sort((left, right) =>\n right[1] - left[1]\n )\n .slice(0, 120)\n .map(([employeeIndex]) =>\n employeeIndex\n );\n\n const rankedCandidates = [];\n\n for (\n const employeeIndex of\n candidateIndexes\n ) {\n const profile =\n bambooResolutionProfiles[\n employeeIndex\n ];\n\n let bestDetails = null;\n let bestAlias = '';\n\n for (const alias of profile.aliases) {\n const details =\n bambooResolutionAliasDetails(\n rawQuery,\n alias\n );\n\n if (\n details &&\n (\n !bestDetails ||\n details.score >\n bestDetails.score\n )\n ) {\n bestDetails = details;\n bestAlias = alias.raw;\n }\n }\n\n if (!bestDetails) continue;\n\n rankedCandidates.push({\n employee_index:\n employeeIndex,\n employee_key:\n profile.employee_key,\n details:\n bestDetails,\n bamboo_alias:\n bestAlias,\n });\n }\n\n rankedCandidates.sort(\n (left, right) => {\n if (\n right.details.score !==\n left.details.score\n ) {\n return (\n right.details.score -\n left.details.score\n );\n }\n\n if (\n right.details.exact_matches !==\n left.details.exact_matches\n ) {\n return (\n right.details.exact_matches -\n left.details.exact_matches\n );\n }\n\n return (\n right.details.query_coverage -\n left.details.query_coverage\n );\n }\n );\n\n const best =\n rankedCandidates[0] || null;\n\n const second =\n rankedCandidates[1] || null;\n\n const margin =\n best\n ? best.details.score -\n (\n second?.details.score ||\n 0\n )\n : 0;\n\n const details =\n best?.details || null;\n\n const exactContainment =\n Boolean(\n details?.containment &&\n details.exact_matches >= 2\n );\n\n const strongTwoTokenName =\n Boolean(\n details &&\n details.query_tokens === 2 &&\n details.matched_tokens === 2 &&\n details.exact_matches >= 1 &&\n details.query_coverage >= 0.90 &&\n details.score >= 0.88\n );\n\n const strongLongName =\n Boolean(\n details &&\n details.query_tokens >= 3 &&\n details.matched_tokens >=\n Math.min(\n 3,\n details.query_tokens\n ) &&\n details.exact_matches >= 2 &&\n details.query_coverage >= 0.85 &&\n details.score >= 0.84\n );\n\n const acceptableMargin =\n !second ||\n margin >= (\n exactContainment\n ? 0.04\n : 0.06\n ) ||\n (\n details?.exact_matches || 0\n ) >\n (\n second?.details\n ?.exact_matches || 0\n );\n\n if (\n best &&\n acceptableMargin &&\n (\n exactContainment ||\n strongTwoTokenName ||\n strongLongName\n )\n ) {\n return {\n found: true,\n matched_by:\n exactContainment\n ? 'unique_precomputed_containment'\n : 'strong_precomputed_fuzzy_name',\n confidence:\n Math.min(\n 1,\n details.score\n ),\n employee_index:\n best.employee_index,\n employee_key:\n best.employee_key,\n query_name:\n rawQuery,\n bamboo_alias:\n best.bamboo_alias,\n margin,\n exact_matches:\n details.exact_matches,\n matched_tokens:\n details.matched_tokens,\n };\n }\n\n return {\n found: false,\n matched_by: null,\n confidence:\n details?.score || 0,\n reason:\n best\n ? (\n acceptableMargin\n ? 'insufficient_name_evidence'\n : 'ambiguous_name'\n )\n : 'no_candidate',\n best_candidate:\n best\n ? {\n employee_index:\n best.employee_index,\n employee_key:\n best.employee_key,\n bamboo_alias:\n best.bamboo_alias,\n score:\n best.details.score,\n }\n : null,\n second_candidate:\n second\n ? {\n employee_index:\n second.employee_index,\n employee_key:\n second.employee_key,\n bamboo_alias:\n second.bamboo_alias,\n score:\n second.details.score,\n }\n : null,\n };\n}\n\nconst resolvedNameMatches = {};\nlet resolvedNameMatchesFound = 0;\n\nfor (\n const [\n normalizedRelevantName,\n relevantEntry,\n ] of relevantNameMap\n) {\n const rawRelevantName =\n relevantEntryRaw(relevantEntry);\n\n const decision =\n bambooResolutionDecision(\n rawRelevantName\n );\n\n resolvedNameMatches[\n normalizedRelevantName\n ] = decision;\n\n if (decision.found) {\n resolvedNameMatchesFound += 1;\n }\n}\n\n\nconst fetchedEmployeesCount =\n rawEmployees.length;\n\nconst fetchComplete =\n expectedTotal > 0\n ? fetchedEmployeesCount >= expectedTotal\n : (\n pageObjects.length > 0 &&\n !pageObjects.some(\n (page) =>\n Boolean(\n page?._links?.next?.href\n )\n )\n );\n\nconst errors = [];\n\nif (!pageObjects.length) {\n errors.push(\n 'BambooHR no devolvió páginas de empleados.'\n );\n}\n\nif (!fetchedEmployeesCount) {\n errors.push(\n 'BambooHR no devolvió empleados.'\n );\n}\n\nif (\n expectedTotal > 0 &&\n fetchedEmployeesCount < expectedTotal\n) {\n errors.push(\n `La descarga de BambooHR quedó incompleta: ` +\n `${fetchedEmployeesCount} de ${expectedTotal} empleados.`\n );\n}\n\nif (!targetEmployees.length) {\n errors.push(\n 'No se encontraron empleados de Honduras en BambooHR.'\n );\n}\n\nreturn [\n {\n json: {\n ...base,\n ok:\n Boolean(base.ok ?? true) &&\n errors.length === 0,\n stage:\n errors.length === 0\n ? 'bamboohr_hn_normalizado'\n : 'bamboohr_hn_incompleto',\n errors: [\n ...(Array.isArray(base.errors)\n ? base.errors\n : []),\n ...errors,\n ],\n bamboo: {\n source:\n 'bamboohr_custom_report_only_current_false',\n period_start: periodStart,\n period_end: periodEnd,\n pages_fetched: pageObjects.length,\n expected_total: expectedTotal,\n raw_employees_count:\n fetchedEmployeesCount,\n employees_count:\n normalizedEmployeesCount,\n honduras_count:\n targetEmployees.length,\n active_in_period_count:\n targetEmployees.filter(\n (employee) =>\n employee.overlaps_period\n ).length,\n active_status_count:\n targetEmployees.filter(\n (employee) =>\n normalize(employee.status) ===\n 'active'\n ).length,\n contextual_outside_country_count:\n contextualOutsideMap.size,\n contextual_outside_skipped_by_target_exact_count:\n contextualOutsideSkippedByTargetExact,\n validation_candidates_count:\n validationEmployees.length,\n resolved_name_matches:\n resolvedNameMatches,\n resolved_name_matches_count:\n Object.keys(\n resolvedNameMatches\n ).length,\n resolved_name_matches_found:\n resolvedNameMatchesFound,\n name_resolution_strategy:\n 'precomputed_indexed_fuzzy_matching_with_target_exact_precedence',\n fetch_complete: fetchComplete,\n validation_available:\n fetchComplete &&\n validationEmployees.length > 0,\n validation_rule:\n 'Target country/location first; outside-country contextual rescue only when no exact target alias exists',\n performance_strategy:\n 'precomputed_alias_tokens_and_inverted_index',\n employees:\n validationEmployees,\n restricted_fields:\n restrictedFields,\n },\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
608,
352
],
"id": "36f26790-e15c-4900-be40-e6d031053a4f",
"name": "Normalizar BambooHR HN"
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"options": {}
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
848,
512
],
"id": "318d9c84-08fc-4124-b360-fafecc6ff320",
"name": "Merge - Agregar BambooHR HN"
},
{
"parameters": {
"method": "POST",
"url": "https://dbit.digitalcompass.agency/rest/v1/rpc/cruce_cuentas_bamboo_aliases_activos",
"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=representation"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": {
"p_pais": "HN"
},
"options": {
"response": {
"response": {
"responseFormat": "json"
}
},
"timeout": 30000
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
1088,
512
],
"id": "e1578802-aadc-4b81-ad17-a623912a20d4",
"name": "Obtener alias BambooHR Supabase",
"retryOnFail": true,
"maxTries": 2,
"waitBetweenTries": 1000,
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "const data = $('Merge - Agregar BambooHR HN').first().json || {};\nconst aliasResponse = $input.first().json || {};\nconst validatedBambooAliases = Array.isArray(aliasResponse.aliases)\n ? aliasResponse.aliases\n : Array.isArray(aliasResponse)\n ? aliasResponse\n : [];\n\nfunction clean(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\u00A0/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nconst normalizedNameCache = new Map();\nconst nameWordsCache = new Map();\nconst nameMatchScoreCache = new Map();\n\nfunction normalizeName(value) {\n const raw = clean(value);\n if (normalizedNameCache.has(raw)) {\n return normalizedNameCache.get(raw);\n }\n\n const normalized = raw\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/['’`-]/g, ' ')\n .replace(/[^a-z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n\n normalizedNameCache.set(raw, normalized);\n return normalized;\n}\n\nfunction normalizeAccount(value) {\n return String(value ?? '').replace(/\\.0$/g, '').replace(/\\D/g, '').trim();\n}\n\nfunction roundMoney(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n}\n\nfunction moneyDiff(payroll, bank) {\n return roundMoney((Number(payroll) || 0) - (Number(bank) || 0));\n}\n\nconst MONEY_TOLERANCE = 0.05;\nconst MONEY_TOLERANCE_CENTS = Math.round(MONEY_TOLERANCE * 100);\n\nfunction sameCent(a, b) {\n return Math.abs(moneyDiff(a, b)) <= MONEY_TOLERANCE + 1e-9;\n}\n\nconst IGNORED_NAME_TOKENS = new Set([\n 'de', 'del', 'la', 'las', 'los', 'y', 'e', 'el',\n 'da', 'do', 'dos', 'das', 'van', 'von',\n]);\n\nfunction nameWords(value) {\n const normalized = normalizeName(value);\n if (nameWordsCache.has(normalized)) {\n return nameWordsCache.get(normalized);\n }\n\n const words = normalized\n .split(' ')\n .filter((word) => word.length > 1 && !IGNORED_NAME_TOKENS.has(word));\n\n nameWordsCache.set(normalized, words);\n return words;\n}\n\nfunction editDistance(a, b) {\n if (a === b) return 0;\n if (!a) return b.length;\n if (!b) return a.length;\n let previous = Array.from({ length: b.length + 1 }, (_, index) => index);\n for (let i = 1; i <= a.length; i++) {\n const current = [i];\n for (let j = 1; j <= b.length; j++) {\n const cost = a[i - 1] === b[j - 1] ? 0 : 1;\n current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, previous[j - 1] + cost);\n }\n previous = current;\n }\n return previous[b.length];\n}\n\nfunction tokenMatches(a, b) {\n if (a === b) return true;\n const minLength = Math.min(a.length, b.length);\n if (minLength >= 8 && editDistance(a, b) <= 2) return true;\n if (minLength >= 5 && editDistance(a, b) <= 1) return true;\n return false;\n}\n\nfunction samePersonName(a, b) {\n const normalizedA = normalizeName(a);\n const normalizedB = normalizeName(b);\n if (!normalizedA || !normalizedB) return false;\n if (normalizedA === normalizedB) return true;\n\n const wordsA = nameWords(a);\n const wordsB = nameWords(b);\n if (!wordsA.length || !wordsB.length) return false;\n\n const usedB = new Set();\n let matches = 0;\n for (const wordA of wordsA) {\n const matchIndex = wordsB.findIndex((wordB, index) =>\n !usedB.has(index) && tokenMatches(wordA, wordB)\n );\n if (matchIndex >= 0) {\n usedB.add(matchIndex);\n matches += 1;\n }\n }\n\n const smallerLength = Math.min(wordsA.length, wordsB.length);\n const ratio = matches / smallerLength;\n if (smallerLength <= 2) return matches === smallerLength && matches >= 2;\n return matches >= 2 && ratio >= 0.6;\n}\n\nfunction bankNameLooksConsistent(fileName, holderName) {\n const fileWords = nameWords(fileName);\n const holderWords = nameWords(holderName);\n\n if (!fileWords.length || !holderWords.length) return true;\n\n const used = new Set();\n\n for (const fileWord of fileWords) {\n let matchIndex = holderWords.findIndex(\n (holderWord, index) =>\n !used.has(index) &&\n holderWord === fileWord\n );\n\n // Algunos planos de Honduras recortan el último carácter del apellido\n // por longitud de campo (ej.: ALVARAD -> ALVARADO). Eso no debe\n // convertirse en una diferencia de nombre. No se aceptan sustituciones\n // ortográficas (ej.: GONZALES -> GONZALEZ), porque sí requieren revisión.\n if (matchIndex < 0) {\n matchIndex = holderWords.findIndex(\n (holderWord, index) =>\n !used.has(index) &&\n holderWord.startsWith(fileWord) &&\n (\n (fileWord.length <= 3) ||\n (\n fileWord.length >= 4 &&\n holderWord.length === fileWord.length + 1\n )\n )\n );\n }\n\n if (matchIndex < 0) return false;\n used.add(matchIndex);\n }\n\n return true;\n}\n\nfunction nameMatchScore(a, b) {\n const na = normalizeName(a);\n const nb = normalizeName(b);\n if (!na || !nb) return 0;\n\n const cacheKey = na <= nb ? `${na}\\u0000${nb}` : `${nb}\\u0000${na}`;\n if (nameMatchScoreCache.has(cacheKey)) {\n return nameMatchScoreCache.get(cacheKey);\n }\n\n if (na === nb) {\n nameMatchScoreCache.set(cacheKey, 1);\n return 1;\n }\n\n const wa = nameWords(a);\n const wb = nameWords(b);\n if (!samePersonName(a, b)) {\n nameMatchScoreCache.set(cacheKey, 0);\n return 0;\n }\n\n const setA = new Set(wa);\n const setB = new Set(wb);\n let exact = 0;\n for (const w of setA) if (setB.has(w)) exact += 1;\n const min = Math.max(1, Math.min(setA.size, setB.size));\n const score = Math.min(\n 0.99,\n 0.75 + (exact / min) * 0.20 + (wa.length === wb.length ? 0.04 : 0)\n );\n\n nameMatchScoreCache.set(cacheKey, score);\n return score;\n}\n\nfunction bankNames(row) {\n return [row.bank_name_file, row.bank_account_holder]\n .map(clean)\n .filter(Boolean);\n}\n\nfunction bestNameScore(payrollName, bankRow) {\n return Math.max(0, ...bankNames(bankRow).map((name) => nameMatchScore(payrollName, name)));\n}\n\nfunction bankDisplayName(row) {\n return clean(row.bank_name_file) || clean(row.bank_account_holder) || 'Pago bancario';\n}\n\nfunction bankRowKey(row, index) {\n return clean(row.bank_row_key) || `${clean(row.source_file)}|${row.row_number || index + 1}`;\n}\n\n\nconst metadata = data.metadata || {};\n\nfunction parseBankDate(value, sourceFile = '') {\n const raw = clean(value);\n let match = raw.match(/^(\\d{1,2})\\/(\\d{1,2})\\/(\\d{4})/);\n if (match) {\n return {\n year: Number(match[3]),\n month: Number(match[2]),\n day: Number(match[1]),\n };\n }\n\n match = raw.match(/^(\\d{4})-(\\d{1,2})-(\\d{1,2})/);\n if (match) {\n return {\n year: Number(match[1]),\n month: Number(match[2]),\n day: Number(match[3]),\n };\n }\n\n // Respaldo por el patrón habitual del nombre: _DDMMYY / _DDMMYYYY.\n const fileMatch = clean(sourceFile).match(/_(\\d{2})(\\d{2})(\\d{2}|\\d{4})(?:\\D|$)/);\n if (!fileMatch) return null;\n\n const yearPart = Number(fileMatch[3]);\n return {\n year: fileMatch[3].length === 2 ? 2000 + yearPart : yearPart,\n month: Number(fileMatch[2]),\n day: Number(fileMatch[1]),\n };\n}\n\nfunction selectedPeriodKind() {\n const type = clean(metadata.period_type).toLowerCase();\n if (type === 'quincena_15') return 'q15';\n if (type === 'quincena_30') return 'q30';\n return 'unknown';\n}\n\nfunction conceptPeriodHint(row) {\n const concept = normalizeName(row.concept || '');\n if (!concept) return 'unknown';\n\n if (\n /(?:^| )1(?:ra|er|era)? quincena(?: |$)/.test(concept) ||\n concept.includes('primera quincena') ||\n concept.includes('quincena 1ra') ||\n concept.includes('quincena 1')\n ) {\n return 'q15';\n }\n\n if (\n /(?:^| )2(?:da|do|nda)? quincena(?: |$)/.test(concept) ||\n concept.includes('segunda quincena') ||\n concept.includes('quincena 2da') ||\n concept.includes('quincena 2') ||\n concept.includes('fin de mes')\n ) {\n return 'q30';\n }\n\n return 'unknown';\n}\n\nfunction periodInfoForBankRow(row) {\n const selected = selectedPeriodKind();\n const rowDate = parseBankDate(row.application_date, row.source_file);\n const selectedYear = Number(metadata.year || 0);\n const selectedMonth = Number(metadata.month || 0);\n const hint = conceptPeriodHint(row);\n\n if (selected === 'unknown') {\n return { known: false, selected, inPeriod: true, affinity: 1, rowDate, hint };\n }\n\n if (hint !== 'unknown' && hint === selected) {\n return { known: true, selected, inPeriod: true, affinity: 4, rowDate, hint };\n }\n\n if (!rowDate || !selectedYear || !selectedMonth) {\n return { known: false, selected, inPeriod: true, affinity: 1, rowDate, hint };\n }\n\n if (rowDate.year !== selectedYear || rowDate.month !== selectedMonth) {\n return { known: true, selected, inPeriod: false, affinity: 0, rowDate, hint };\n }\n\n const inHalf = selected === 'q15'\n ? rowDate.day <= 15\n : rowDate.day >= 16;\n\n return {\n known: true,\n selected,\n inPeriod: inHalf,\n affinity: inHalf ? 3 : 0,\n rowDate,\n hint,\n };\n}\n\nfunction isSupplementPayroll(payroll) {\n return clean(payroll?.source_kind).toLowerCase() !== 'quincenal';\n}\n\nfunction bankCandidatePeriodScore(payroll, bank) {\n const info = periodInfoForBankRow(bank);\n if (!info.known || info.inPeriod) return info.affinity || 1;\n\n // Una extraordinaria/vacación/XIII Mes puede pagarse fuera de la mitad\n // estricta del mes. Se permite como respaldo, siempre detrás de cualquier\n // candidato del período seleccionado.\n if (isSupplementPayroll(payroll) && info.rowDate) {\n const selectedYear = Number(metadata.year || 0);\n const selectedMonth = Number(metadata.month || 0);\n if (info.rowDate.year === selectedYear && info.rowDate.month === selectedMonth) {\n return 0.5;\n }\n }\n\n return -1;\n}\n\nconst payrollRows = Array.isArray(data.payroll?.rows) ? data.payroll.rows : [];\nconst bankRows = Array.isArray(data.bank?.rows) ? data.bank.rows : [];\n\n// Guardia de seguridad: nunca generar un reporte \"válido\" cuando el request\n// traía archivos pero el parser no consiguió ninguna fila. Esto evita que\n// todas las personas de nómina aparezcan falsamente como discrepancias.\nif (payrollRows.length === 0) {\n throw new Error(\n 'Honduras: no se pudo leer ninguna fila válida de nómina. ' +\n 'Revisa los archivos Excel de Honduras y las hojas de nómina configuradas.'\n );\n}\n\nif (bankRows.length === 0) {\n const detectedFiles = Number(data.bank?.files_count || data.summary?.bank_files_count || 0);\n const invalidFiles = Array.isArray(data.bank?.file_summaries)\n ? data.bank.file_summaries.filter((file) => file?.ok === false)\n : [];\n\n throw new Error(\n 'Honduras: no se pudo leer ninguna transacción bancaria. ' +\n `Archivos bancarios detectados: ${detectedFiles}. ` +\n (invalidFiles.length\n ? `Archivos con error de formato: ${invalidFiles.map((file) => file.file_name).join(', ')}.`\n : 'Verifica que los CSV sean los planos bancarios esperados.')\n );\n}\n\nconst bambooEmployees = Array.isArray(data.bamboo?.employees) ? data.bamboo.employees : [];\nconst resolvedNameMatches = data.bamboo?.resolved_name_matches || {};\nconst bambooValidationAvailable = data.bamboo?.fetch_complete === true &&\n data.bamboo?.validation_available === true && bambooEmployees.length > 0;\n\nconst bambooByEmployeeNumber = new Map();\nconst bambooById = new Map();\nconst bambooByExactName = new Map();\nfor (const employee of bambooEmployees) {\n const number = clean(employee.employee_number || employee.employeeNumber);\n const id = clean(employee.bamboo_id || employee.id);\n if (number) {\n const arr = bambooByEmployeeNumber.get(number) || [];\n arr.push(employee);\n bambooByEmployeeNumber.set(number, arr);\n }\n if (id) bambooById.set(id, employee);\n for (const alias of employee.aliases || [employee.full_name]) {\n const normalized = normalizeName(alias);\n if (!normalized) continue;\n const arr = bambooByExactName.get(normalized) || [];\n arr.push(employee);\n bambooByExactName.set(normalized, arr);\n }\n}\n\nfunction employeeKey(employee) {\n return clean(employee?.bamboo_id || employee?.id) ||\n clean(employee?.employee_number || employee?.employeeNumber) ||\n normalizeName(employee?.full_name || employee?.displayName);\n}\n\nfunction validatedAliasDecision(row) {\n if (!validatedBambooAliases.length) return null;\n const rowAccount = normalizeAccount(row.account);\n const names = bankNames(row).map(normalizeName).filter(Boolean);\n const candidates = validatedBambooAliases.filter((alias) => {\n const country = clean(alias.pais || alias.country).toUpperCase();\n if (country && country !== 'HN') return false;\n const source = normalizeName(alias.nombre_origen || alias.source_name || alias.alias || '');\n if (!source || !names.includes(source)) return false;\n const aliasAccount = normalizeAccount(alias.cuenta_bancaria || alias.cuenta_banco || alias.bank_account || '');\n return !aliasAccount || !rowAccount || aliasAccount === rowAccount;\n });\n if (candidates.length !== 1) return null;\n const alias = candidates[0];\n let employee = null;\n const employeeNumber = clean(alias.bamboo_employee_number || '');\n const bambooId = clean(alias.bamboo_employee_id || '');\n const bambooName = normalizeName(alias.bamboo_nombre || '');\n if (employeeNumber && (bambooByEmployeeNumber.get(employeeNumber) || []).length === 1) {\n employee = bambooByEmployeeNumber.get(employeeNumber)[0];\n } else if (bambooId && bambooById.has(bambooId)) {\n employee = bambooById.get(bambooId);\n } else if (bambooName && (bambooByExactName.get(bambooName) || []).length === 1) {\n employee = bambooByExactName.get(bambooName)[0];\n }\n if (!employee) return null;\n return {\n found: true,\n matched_by: 'validated_bamboo_alias',\n confidence: 1,\n employee,\n employee_key: employeeKey(employee),\n bamboo_alias: clean(alias.bamboo_nombre) || employee.full_name || '',\n };\n}\n\nfunction precomputedDecision(name) {\n const normalized = normalizeName(name);\n if (!normalized) return null;\n const decision = resolvedNameMatches[normalized];\n if (!decision || decision.found !== true) return null;\n const index = Number(decision.employee_index);\n const employee = Number.isInteger(index) && index >= 0 ? bambooEmployees[index] : null;\n const key = clean(decision.employee_key) || employeeKey(employee);\n if (!key) return null;\n return {\n found: true,\n matched_by: decision.matched_by || 'precomputed_name',\n confidence: Number(decision.confidence || 0),\n employee,\n employee_key: key,\n bamboo_alias: clean(decision.bamboo_alias) || employee?.full_name || '',\n query_name: clean(name),\n };\n}\n\nfunction resolveBamboo(row, linkedPayrollNames = []) {\n if (!bambooValidationAvailable) {\n return {\n found: false,\n reason: 'validation_unavailable',\n };\n }\n\n const validated = validatedAliasDecision(row);\n if (validated) return validated;\n\n const holderName = clean(row.bank_account_holder);\n const fileName = clean(row.bank_name_file);\n\n // El nombre legal del cuentahabiente es la evidencia bancaria más fuerte.\n // Si identifica exactamente a una persona que pertenecía al período,\n // prevalece sobre un nombre abreviado que pueda coincidir con un perfil\n // histórico distinto (caso real: Suyapa Palma).\n const holderDecision = holderName\n ? precomputedDecision(holderName)\n : null;\n\n if (\n holderDecision?.found &&\n holderDecision.employee?.overlaps_period === true\n ) {\n return {\n ...holderDecision,\n matched_by:\n `${holderDecision.matched_by || 'precomputed_name'}_holder_in_period`,\n };\n }\n\n const names = Array.from(\n new Set(\n [\n holderName,\n fileName,\n ...linkedPayrollNames.map(clean),\n ].filter(Boolean)\n )\n );\n\n const decisions = names\n .map(precomputedDecision)\n .filter(Boolean);\n\n if (!decisions.length) {\n return {\n found: false,\n reason: 'no_confident_name_match',\n };\n }\n\n // Antes de declarar conflicto, descartar perfiles históricos que no\n // pertenecen al período cuando existe una única persona vigente/en período.\n const inPeriodByKey = new Map();\n\n for (const decision of decisions) {\n if (decision.employee?.overlaps_period !== true) continue;\n\n const current =\n inPeriodByKey.get(decision.employee_key);\n\n if (\n !current ||\n decision.confidence > current.confidence\n ) {\n inPeriodByKey.set(\n decision.employee_key,\n decision\n );\n }\n }\n\n const inPeriodRanked =\n Array.from(inPeriodByKey.values())\n .sort(\n (left, right) =>\n right.confidence - left.confidence\n );\n\n if (inPeriodRanked.length === 1) {\n return {\n ...inPeriodRanked[0],\n matched_by:\n `${inPeriodRanked[0].matched_by || 'precomputed_name'}_unique_in_period`,\n };\n }\n\n if (\n inPeriodRanked.length >= 2 &&\n (\n inPeriodRanked[0].confidence -\n inPeriodRanked[1].confidence\n ) >= 0.08\n ) {\n return inPeriodRanked[0];\n }\n\n const byKey = new Map();\n\n for (const decision of decisions) {\n const current =\n byKey.get(decision.employee_key);\n\n if (\n !current ||\n decision.confidence > current.confidence\n ) {\n byKey.set(\n decision.employee_key,\n decision\n );\n }\n }\n\n const ranked =\n Array.from(byKey.values())\n .sort(\n (left, right) =>\n right.confidence - left.confidence\n );\n\n if (ranked.length === 1) {\n return ranked[0];\n }\n\n if (\n (\n ranked[0].confidence -\n ranked[1].confidence\n ) >= 0.08\n ) {\n return ranked[0];\n }\n\n return {\n found: false,\n reason: 'conflicting_precomputed_name_matches',\n candidates: ranked.slice(0, 3),\n };\n}\n\nconst usedBankKeys = new Set();\nconst matchedPayrollIndexes = new Set();\nconst matchedPairs = [];\nconst bankIndexByKey = new Map();\n\n// Índice por centavos para que la pasada principal no compare cada fila de\n// nómina contra todas las transacciones del banco. Esto preserva exactamente\n// la lógica original: la primera pasada ya exigía el mismo monto al centavo.\nconst bankIndexesByAmountCents = new Map();\n\nfunction amountCents(value) {\n return Math.round(roundMoney(value) * 100);\n}\n\nbankRows.forEach((row, index) => {\n const key = bankRowKey(row, index);\n bankIndexByKey.set(key, index);\n\n const amountKey = `${clean(row.currency || 'HNL').toUpperCase()}|${amountCents(row.amount)}`;\n const bucket = bankIndexesByAmountCents.get(amountKey) || [];\n bucket.push(index);\n bankIndexesByAmountCents.set(amountKey, bucket);\n});\n\nfunction availableNameCandidates(payroll, requireExactAmount) {\n const candidates = [];\n\n // La pasada exacta solo inspecciona filas con el mismo importe. La segunda\n // pasada conserva el barrido completo y el mismo criterio de persona fuerte\n // + menor diferencia que tenía el workflow validado.\n let candidateIndexes = null;\n\n if (requireExactAmount) {\n const currency =\n clean(payroll.currency || 'HNL')\n .toUpperCase();\n\n const center =\n amountCents(payroll.payroll_amount);\n\n const indexSet = new Set();\n\n for (\n let delta = -MONEY_TOLERANCE_CENTS;\n delta <= MONEY_TOLERANCE_CENTS;\n delta++\n ) {\n const bucket =\n bankIndexesByAmountCents.get(\n `${currency}|${center + delta}`\n ) || [];\n\n for (const bankIndex of bucket) {\n indexSet.add(bankIndex);\n }\n }\n\n candidateIndexes =\n Array.from(indexSet);\n }\n\n const inspectBank = (bankIndex) => {\n const bank = bankRows[bankIndex];\n if (!bank) return;\n\n const key = bankRowKey(bank, bankIndex);\n if (usedBankKeys.has(key)) return;\n if (clean(payroll.currency || 'HNL').toUpperCase() !== clean(bank.currency || 'HNL').toUpperCase()) return;\n\n if (requireExactAmount && !sameCent(payroll.payroll_amount, bank.amount)) return;\n\n const periodScore = bankCandidatePeriodScore(payroll, bank);\n if (periodScore < 0) return;\n\n const score = bestNameScore(payroll.employee_name, bank);\n if (score <= 0) return;\n\n candidates.push({\n bank,\n bankIndex,\n key,\n score,\n periodScore,\n amountDiff: Math.abs(moneyDiff(payroll.payroll_amount, bank.amount)),\n });\n };\n\n if (candidateIndexes) {\n for (const bankIndex of candidateIndexes) {\n inspectBank(bankIndex);\n }\n } else {\n for (let bankIndex = 0; bankIndex < bankRows.length; bankIndex++) {\n inspectBank(bankIndex);\n }\n }\n\n candidates.sort((a, b) => {\n if (b.periodScore !== a.periodScore) return b.periodScore - a.periodScore;\n if (b.score !== a.score) return b.score - a.score;\n if (a.amountDiff !== b.amountDiff) return a.amountDiff - b.amountDiff;\n return a.bankIndex - b.bankIndex;\n });\n\n return candidates;\n}\n\n// Primera pasada: persona + monto exacto. Es la evidencia más fuerte y evita\n// que extraordinarias/vacaciones de una misma persona se mezclen entre sí.\npayrollRows.forEach((payroll, payrollIndex) => {\n const candidates = availableNameCandidates(payroll, true);\n if (!candidates.length) return;\n const chosen = candidates[0];\n usedBankKeys.add(chosen.key);\n matchedPayrollIndexes.add(payrollIndex);\n matchedPairs.push({ payroll, payrollIndex, ...chosen });\n});\n\n// Segunda pasada: persona fuerte aunque el monto sea distinto. Solo se toma\n// una contraparte bancaria por fila de nómina y se prioriza la menor diferencia.\npayrollRows.forEach((payroll, payrollIndex) => {\n if (matchedPayrollIndexes.has(payrollIndex)) return;\n const candidates = availableNameCandidates(payroll, false);\n if (!candidates.length) return;\n const chosen = candidates[0];\n usedBankKeys.add(chosen.key);\n matchedPayrollIndexes.add(payrollIndex);\n matchedPairs.push({ payroll, payrollIndex, ...chosen });\n});\n\nconst linkedPayrollNamesByBankKey = new Map();\nfor (const pair of matchedPairs) {\n const current = linkedPayrollNamesByBankKey.get(pair.key) || [];\n current.push(pair.payroll.employee_name);\n linkedPayrollNamesByBankKey.set(pair.key, current);\n}\n\n// Filas bancarias del período de Honduras.\n// Toda fila emparejada entra siempre. Para una fila no emparejada se usa la\n// fecha/concepto del plano para evitar arrastrar accidentalmente otra quincena.\nconst includedBankRows = [];\nconst excludedBankRows = [];\n\nfor (let index = 0; index < bankRows.length; index++) {\n const row = bankRows[index];\n const key = bankRowKey(row, index);\n\n if (usedBankKeys.has(key)) {\n includedBankRows.push({ row, key, index, inclusion: 'matched_payroll' });\n continue;\n }\n\n const periodInfo = periodInfoForBankRow(row);\n if (periodInfo.known && !periodInfo.inPeriod) {\n excludedBankRows.push({ row, key, reason: 'outside_selected_payroll_period' });\n continue;\n }\n\n includedBankRows.push({ row, key, index, inclusion: 'unmatched_current_period' });\n}\n\nconst includedBankKeySet = new Set(includedBankRows.map((entry) => entry.key));\n\nconst coreRows = [];\nconst wrongAccountGroups = new Map();\nfor (const pair of matchedPairs) {\n if (!includedBankKeySet.has(pair.key)) continue;\n const payrollAmount = roundMoney(pair.payroll.payroll_amount);\n const bankAmount = roundMoney(pair.bank.amount);\n const difference = moneyDiff(payrollAmount, bankAmount);\n const currency = clean(pair.payroll.currency || pair.bank.currency || 'HNL').toUpperCase() === 'USD' ? 'USD' : 'HNL';\n const payrollAccount = normalizeAccount(pair.payroll.account || pair.payroll.payroll_account);\n const bankAccount = normalizeAccount(pair.bank.account);\n const accountMismatch = Boolean(payrollAccount && bankAccount && payrollAccount !== bankAccount);\n if (accountMismatch) {\n const groupKey = `${normalizeName(pair.payroll.employee_name)}|${currency}|${payrollAccount}|${bankAccount}`;\n const current = wrongAccountGroups.get(groupKey) || {\n id: `hn_wrong_account_${wrongAccountGroups.size + 1}`,\n employee: pair.payroll.employee_name,\n employee_name: pair.payroll.employee_name,\n employeeNumber: pair.payroll.employee_number || '', employee_number: pair.payroll.employee_number || '',\n account: bankAccount, payrollAccount, payroll_account: payrollAccount, bankAccount, bank_account: bankAccount,\n currency, payrollAmount:0, payroll_amount:0, bankAmount:0, bank_amount:0, difference:0,\n status:'Pendiente revisión', category:'posible_cuenta_mal_digitada', subcategory:'nombre_y_monto_coinciden_cuenta_diferente',\n observation:`El nombre y el monto coinciden, pero la cuenta de nómina (${payrollAccount}) es diferente a la cuenta del banco (${bankAccount}).`,\n source_rows:[], source_sheets:new Set(), source_files:new Set(), shipment_numbers:new Set(), plan_numbers:new Set(),\n bank_name_file: pair.bank.bank_name_file || '', bank_account_holder: pair.bank.bank_account_holder || ''\n };\n current.payrollAmount=roundMoney(current.payrollAmount+payrollAmount); current.payroll_amount=current.payrollAmount;\n current.bankAmount=roundMoney(current.bankAmount+bankAmount); current.bank_amount=current.bankAmount; current.difference=moneyDiff(current.payrollAmount,current.bankAmount);\n current.source_rows.push(pair.payroll); if(pair.payroll.source_sheet)current.source_sheets.add(pair.payroll.source_sheet); if(pair.bank.source_file)current.source_files.add(pair.bank.source_file); if(pair.bank.shipment_number)current.shipment_numbers.add(pair.bank.shipment_number); if(pair.bank.plan_number)current.plan_numbers.add(pair.bank.plan_number);\n wrongAccountGroups.set(groupKey,current); continue;\n }\n const exact = sameCent(payrollAmount, bankAmount);\n coreRows.push({id:`hn_match_${pair.payrollIndex+1}_${pair.bankIndex+1}`,employee:pair.payroll.employee_name,employee_name:pair.payroll.employee_name,employeeNumber:pair.payroll.employee_number||'',employee_number:pair.payroll.employee_number||'',account:bankAccount||payrollAccount,payrollAccount:payrollAccount,payroll_account:payrollAccount,bankAccount:bankAccount,bank_account:bankAccount,currency,payrollAmount,payroll_amount:payrollAmount,bankAmount,bank_amount:bankAmount,difference,status:exact?'Coincide':'Pendiente revisión',category:exact?'coincidencia':'discrepancia',subcategory:exact?'persona_cuenta_y_monto_exactos':'persona_y_cuenta_coinciden_monto_diferente',observation:exact?'Coincidencia de persona, cuenta y monto.':difference>0?'Se pagó de menos.':'Se pagó de más.',source_kind:pair.payroll.source_kind||'',source_sheet:pair.payroll.source_sheet||'',source_file:pair.bank.source_file||'',shipment_number:pair.bank.shipment_number||'',plan_number:pair.bank.plan_number||'',plan_label:pair.bank.plan_label||'',reference:pair.bank.reference||'',bank_name_file:pair.bank.bank_name_file||'',bank_account_holder:pair.bank.bank_account_holder||''});\n}\nfor (let payrollIndex=0; payrollIndex<payrollRows.length; payrollIndex++) {\n if (matchedPayrollIndexes.has(payrollIndex)) continue;\n const payroll=payrollRows[payrollIndex]; const payrollAmount=roundMoney(payroll.payroll_amount); const currency=clean(payroll.currency||'HNL').toUpperCase()==='USD'?'USD':'HNL';\n coreRows.push({id:`hn_payroll_without_bank_${payrollIndex+1}`,employee:payroll.employee_name,employee_name:payroll.employee_name,employeeNumber:payroll.employee_number||'',employee_number:payroll.employee_number||'',account:payroll.account||payroll.payroll_account||'',payrollAccount:payroll.account||payroll.payroll_account||'',payroll_account:payroll.account||payroll.payroll_account||'',bankAccount:'',bank_account:'',currency,payrollAmount,payroll_amount:payrollAmount,bankAmount:0,bank_amount:0,difference:payrollAmount,status:'Pendiente revisión',category:'discrepancia',subcategory:'nomina_sin_pago_banco',observation:'No aparece pagado en banco.',source_kind:payroll.source_kind||'',source_sheet:payroll.source_sheet||''});\n}\nconst wrongAccountRows=Array.from(wrongAccountGroups.values()).map(row=>({...row,source_sheets:Array.from(row.source_sheets),source_files:Array.from(row.source_files),shipment_numbers:Array.from(row.shipment_numbers),plan_numbers:Array.from(row.plan_numbers),source_sheet:Array.from(row.source_sheets).join(' / '),source_file:Array.from(row.source_files).join(' / '),shipment_number:Array.from(row.shipment_numbers).join(' / '),plan_number:Array.from(row.plan_numbers).join(' / ')}));\ncoreRows.push(...wrongAccountRows);\n\n// Banco sin Nómina se conserva a nivel de TRANSACCIÓN.\n// Una misma persona/cuenta puede tener más de un pago bancario legítimamente\n// separado; agruparlos reduce artificialmente el número de hallazgos.\nconst bankWithoutPayrollRows = [];\n\nfor (const entry of includedBankRows) {\n if (usedBankKeys.has(entry.key)) continue;\n\n const row = entry.row;\n const displayName =\n bankDisplayName(row);\n\n const rowCurrency =\n clean(row.currency || 'HNL')\n .toUpperCase() === 'USD'\n ? 'USD'\n : 'HNL';\n\n const bankAmount =\n roundMoney(row.amount || 0);\n\n bankWithoutPayrollRows.push({\n id:\n `hn_bank_without_payroll_${entry.index + 1}`,\n employee: displayName,\n employee_name: displayName,\n employeeNumber: '',\n employee_number: '',\n account: row.account || '',\n payrollAccount: '',\n payroll_account: '',\n bankAccount: row.account || '',\n bank_account: row.account || '',\n currency: rowCurrency,\n payrollAmount: 0,\n payroll_amount: 0,\n bankAmount,\n bank_amount: bankAmount,\n difference:\n roundMoney(0 - bankAmount),\n status: 'Pendiente revisión',\n category: 'banco_sin_nomina',\n subcategory:\n 'pago_banco_sin_registro_nomina',\n observation:\n 'Pago bancario sin registro equivalente en las planillas cargadas.',\n bank_name_file:\n row.bank_name_file || '',\n bank_account_holder:\n row.bank_account_holder || '',\n source_file:\n row.source_file || '',\n shipment_number:\n row.shipment_number || '',\n plan_number:\n row.plan_number || '',\n reference:\n row.reference || '',\n row_number:\n row.row_number || null,\n source_rows: [row],\n });\n}\n\ncoreRows.push(...bankWithoutPayrollRows);\n\nconst nameDifferenceRows = [];\nfor (const entry of includedBankRows) {\n const row = entry.row;\n const rowCurrency=clean(row.currency||'HNL').toUpperCase()==='USD'?'USD':'HNL';\n if (!clean(row.bank_name_file) || !clean(row.bank_account_holder)) continue;\n if (bankNameLooksConsistent(row.bank_name_file, row.bank_account_holder)) continue;\n nameDifferenceRows.push({\n id: `hn_bank_name_difference_${entry.index + 1}`,\n employee: row.bank_name_file,\n employee_name: row.bank_name_file,\n employeeNumber: '',\n employee_number: '',\n account: row.account || '',\n payrollAccount: '',\n payroll_account: '',\n bankAccount: row.account || '',\n bank_account: row.account || '',\n currency: rowCurrency,\n payrollAmount: 0,\n payroll_amount: 0,\n bankAmount: roundMoney(row.amount),\n bank_amount: roundMoney(row.amount),\n difference: 0,\n status: 'Pendiente revisión',\n category: 'diferencia_nombre_banco',\n subcategory: 'nombre_archivo_vs_cuentahabiente',\n observation: `El Nombre en Archivo (${row.bank_name_file}) no coincide con el Nombre del Cuentahabiente (${row.bank_account_holder}).`,\n bank_name_file: row.bank_name_file,\n bank_account_holder: row.bank_account_holder,\n source_file: row.source_file || '',\n shipment_number: row.shipment_number || '',\n plan_number: row.plan_number || '',\n reference: row.reference || '',\n row_number: row.row_number || null,\n });\n}\n\nconst bankWithoutBambooMap = new Map();\nconst bambooMatchDetails = [];\nfor (const entry of includedBankRows) {\n const row = entry.row;\n const linkedNames = linkedPayrollNamesByBankKey.get(entry.key) || [];\n const decision = resolveBamboo(row, linkedNames);\n bambooMatchDetails.push({\n bank_row_key: entry.key,\n bank_name_file: row.bank_name_file || '',\n bank_account_holder: row.bank_account_holder || '',\n account: row.account || '',\n amount: row.amount || 0,\n linked_payroll_names: linkedNames,\n ...decision,\n });\n if (decision.found) continue;\n\n const displayName = bankDisplayName(row);\n const rowCurrency=clean(row.currency||'HNL').toUpperCase()==='USD'?'USD':'HNL';\n const groupingKey = `${normalizeAccount(row.account)}|${normalizeName(displayName)}|${rowCurrency}`;\n const current = bankWithoutBambooMap.get(groupingKey) || {\n id: `hn_bank_without_bamboo_${bankWithoutBambooMap.size + 1}`,\n bank_name_file: row.bank_name_file || displayName,\n bank_account_holder: row.bank_account_holder || '',\n employee_name: row.bank_name_file || displayName,\n account: row.account || '',\n currency: rowCurrency,\n bank_amount: 0,\n amount: 0,\n status: 'Pendiente revisión',\n category: 'banco_sin_bamboo',\n shipment_numbers: new Set(),\n references: new Set(),\n source_files: new Set(),\n source_rows: [],\n };\n current.bank_amount = roundMoney(current.bank_amount + Number(row.amount || 0));\n current.amount = current.bank_amount;\n if (row.shipment_number) current.shipment_numbers.add(row.shipment_number);\n if (row.reference) current.references.add(row.reference);\n if (row.source_file) current.source_files.add(row.source_file);\n current.source_rows.push(row);\n bankWithoutBambooMap.set(groupingKey, current);\n}\n\nconst bankWithoutBamboo = Array.from(bankWithoutBambooMap.values()).map((row) => ({\n ...row,\n shipment_numbers: Array.from(row.shipment_numbers),\n shipment_number: Array.from(row.shipment_numbers).join(' / '),\n references: Array.from(row.references),\n source_files: Array.from(row.source_files),\n source_file: Array.from(row.source_files).join(' / '),\n difference: roundMoney(0 - row.bank_amount),\n}));\n\nfunction priority(row) {\n const category = clean(row.category).toLowerCase();\n if (category === 'posible_cuenta_mal_digitada') return 1;\n if (category === 'discrepancia') return 2;\n if (category === 'banco_sin_nomina') return 3;\n if (category === 'diferencia_nombre_banco') return 5;\n if (category === 'coincidencia') return 99;\n return 50;\n}\n\nconst rowsFinales = [...coreRows, ...nameDifferenceRows].sort((a, b) => {\n const p = priority(a) - priority(b);\n if (p !== 0) return p;\n return clean(a.employee_name).localeCompare(clean(b.employee_name), 'es');\n});\n\nconst coincidencias = coreRows.filter((row) => row.category === 'coincidencia').length;\nconst discrepancias = coreRows.filter((row) => row.category === 'discrepancia').length;\nconst bancoSinNomina = bankWithoutPayrollRows.length;\nconst diferenciasNombreBanco = nameDifferenceRows.length;\nconst posiblesCuentasMalDigitadas = wrongAccountRows.length;\nconst pendientes = discrepancias + bancoSinNomina + diferenciasNombreBanco;\nconst totalNominaByCurrency = {HNL:0,USD:0};\nfor(const row of payrollRows){const c=clean(row.currency||'HNL').toUpperCase()==='USD'?'USD':'HNL';totalNominaByCurrency[c]=roundMoney(totalNominaByCurrency[c]+Number(row.payroll_amount||0));}\nconst totalBancoByCurrency = {HNL:0,USD:0};\nfor(const entry of includedBankRows){const c=clean(entry.row.currency||'HNL').toUpperCase()==='USD'?'USD':'HNL';totalBancoByCurrency[c]=roundMoney(totalBancoByCurrency[c]+Number(entry.row.amount||0));}\nconst diferenciaByCurrency={HNL:moneyDiff(totalNominaByCurrency.HNL,totalBancoByCurrency.HNL),USD:moneyDiff(totalNominaByCurrency.USD,totalBancoByCurrency.USD)};\nconst totalNomina=totalNominaByCurrency.HNL || totalNominaByCurrency.USD;\nconst totalBanco=totalBancoByCurrency.HNL || totalBancoByCurrency.USD;\nconst diferenciaTotal=totalNominaByCurrency.HNL||totalBancoByCurrency.HNL?diferenciaByCurrency.HNL:diferenciaByCurrency.USD;\n\nconst bambooSummaryCompact = { ...(data.bamboo || {}) };\ndelete bambooSummaryCompact.employees;\n\nreturn [{\n json: {\n ok: true,\n stage: 'cruce_nomina_banco_honduras',\n errors: [],\n metadata: data.metadata || {},\n summary: {\n coincidencias,\n discrepancias,\n bancoSinNomina,\n bancoSinBamboo: bankWithoutBamboo.length,\n nominaSinCuenta: 0,\n diferenciasNombreBanco,\n posiblesCuentasMalDigitadas,\n pendientes,\n filasNominaValidas: Number(data.payroll?.base_rows_count || data.payroll?.valid_rows_count || 0),\n filasNominaSinCuenta: 0,\n suplementosPotenciales: Number(data.payroll?.supplement_rows_count || 0),\n suplementosNominaAplicados: Number(data.payroll?.supplement_rows_count || 0),\n suplementosNominaNoAplicados: 0,\n suplementosNominaAdjuntados: Number(data.payroll?.supplement_rows_count || 0),\n suplementosNominaNoAdjuntados: Number(data.payroll?.unattached_supplements_count || 0),\n reconciliacionesExactasFinales: coincidencias,\n cuentasNominaAgrupadas: 0,\n transaccionesBanco: includedBankRows.length,\n cuentasBancoAgrupadas: new Set(includedBankRows.map((entry) => normalizeAccount(entry.row.account)).filter(Boolean)).size,\n empleadosBambooHN: Number(data.bamboo?.honduras_count || 0),\n empleadosBambooEnPeriodo: Number(data.bamboo?.active_in_period_count || 0),\n bambooPaginasDescargadas: Number(data.bamboo?.pages_fetched || 0),\n bambooEmpleadosEsperados: Number(data.bamboo?.expected_total || 0),\n bambooDescargaCompleta: Boolean(data.bamboo?.fetch_complete),\n bambooValidacionDisponible: bambooValidationAvailable,\n totalNominaBase: roundMoney(data.payroll?.base_total || 0),\n totalSuplementosAplicados: roundMoney(data.payroll?.supplements_total || 0),\n totalNomina,\n totalBanco,\n diferenciaTotal,\n totalNominaPorMoneda: totalNominaByCurrency,\n totalBancoPorMoneda: totalBancoByCurrency,\n diferenciaTotalPorMoneda: diferenciaByCurrency,\n },\n rows: rowsFinales,\n bankWithoutBamboo,\n nameDifferences: nameDifferenceRows,\n bambooSummary: bambooSummaryCompact,\n reportUrl: null,\n debug: {\n matched_pairs_count: matchedPairs.length,\n included_bank_rows_count: includedBankRows.length,\n excluded_bank_rows_count: excludedBankRows.length,\n excluded_bank_rows_preview: excludedBankRows.slice(0, 100),\n validated_bamboo_aliases_loaded: validatedBambooAliases.length,\n bamboo_matches: bambooMatchDetails,\n bamboo_validation_available: bambooValidationAvailable,\n banco_sin_bamboo: bankWithoutBamboo,\n payroll_preview: payrollRows.slice(0, 20),\n bank_included_preview: includedBankRows.slice(0, 20).map((entry) => entry.row),\n },\n },\n}];\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1328,
512
],
"id": "c061625b-f317-41d2-b10a-dd0bec96269c",
"name": "Cruzar Nómina vs Banco"
},
{
"parameters": {
"jsCode": "const data = $input.first().json || {};\n\nfunction normalizeText(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction roundMoney(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n}\n\nfunction readableCategory(value) {\n const map = {\n coincidencia: 'Coincidencia',\n discrepancia: 'Discrepancia',\n posible_cuenta_mal_digitada: 'Posible cuenta mal digitada',\n banco_sin_nomina: 'Banco sin nómina',\n banco_sin_bamboo: 'Banco sin Bamboo',\n nomina_sin_cuenta: 'Nómina sin cuenta',\n diferencia_nombre_banco: 'Diferencia nombre banco',\n };\n\n return map[value] || normalizeText(value).replace(/_/g, ' ');\n}\n\nfunction firstValue(value) {\n if (Array.isArray(value)) {\n return value.filter(Boolean).join(' / ');\n }\n\n return normalizeText(value);\n}\n\nfunction formatPeriodEnd(value) {\n const raw = normalizeText(value);\n\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(raw)) {\n return raw;\n }\n\n const [year, month, day] = raw.split('-');\n const monthNames = {\n '01': 'ene',\n '02': 'feb',\n '03': 'mar',\n '04': 'abr',\n '05': 'may',\n '06': 'jun',\n '07': 'jul',\n '08': 'ago',\n '09': 'sep',\n '10': 'oct',\n '11': 'nov',\n '12': 'dic',\n };\n\n return `${day}-${monthNames[month] || month}-${year}`;\n}\n\nfunction mainReportSense(row, difference) {\n const category = normalizeText(row.category).toLowerCase();\n const subcategory = normalizeText(row.subcategory).toLowerCase();\n\n if (category === 'posible_cuenta_mal_digitada') {\n return 'Revisar cuenta';\n }\n\n if (\n subcategory === 'nomina_con_cuenta_sin_pago_banco' ||\n (Number(row.bank_amount ?? row.bankAmount ?? 0) === 0 &&\n Number(row.payroll_amount ?? row.payrollAmount ?? 0) > 0)\n ) {\n return 'No aparece pagado en banco';\n }\n\n if (difference > 0) {\n return 'Se pagó de menos';\n }\n\n if (difference < 0) {\n return 'Se pagó de más';\n }\n\n return 'Revisar';\n}\n\nconst metadata = data.metadata || {};\nconst summary = data.summary || {};\nconst rows = Array.isArray(data.rows) ? data.rows : [];\nconst bankWithoutBamboo = Array.isArray(data.bankWithoutBamboo)\n ? data.bankWithoutBamboo\n : [];\n\nconst periodLabel =\n metadata.period_label ||\n `${metadata.year || ''}-${metadata.month || ''}-${metadata.period_type || ''}`;\n\nconst spreadsheetTitle =\n `Cruce de Cuentas GLM HN - ${periodLabel}`;\n\nconst mainReportSubtitle =\n `Diferencias de Monto Nómina vs. Banco · Honduras · ${\n formatPeriodEnd(metadata.period_end || '')\n }`;\n\nconst bancoSinBambooSubtitle =\n `Pagos en banco sin empleado identificado en BambooHR · Honduras · ${\n formatPeriodEnd(metadata.period_end || '')\n }`;\n\nconst cuentaMalDigitadaSubtitle =\n `Cuenta Mal Digitada en Nómina · Honduras · ${\n formatPeriodEnd(metadata.period_end || '')\n }`;\n\nconst cuentaMalDigitadaCases = rows.filter(\n (row) => row.category === 'posible_cuenta_mal_digitada'\n);\n\nconst hasCuentaMalDigitada =\n cuentaMalDigitadaCases.length > 0;\n\nconst sheetIds = {\n nominaVsBanco: 101,\n bancoSinNomina: 102,\n bancoSinBamboo: 103,\n diferenciasNombreBanco: 104,\n cuentaMalDigitada: 105,\n resumen: 106,\n};\n\nconst sheetTitles = {\n nominaVsBanco: '01 Nómina vs Banco',\n bancoSinNomina: '02 Banco sin Nómina',\n bancoSinBamboo: '03 Banco sin Bamboo',\n diferenciasNombreBanco: '04 Diferencias nombre banco',\n cuentaMalDigitada: '05 Cuenta Mal Digitada',\n resumen: hasCuentaMalDigitada\n ? '06 Resumen'\n : '05 Resumen',\n};\n\nconst nominaVsBancoHeader = [\n '#',\n 'Empleado',\n 'Cuenta',\n 'Monto en Nómina',\n 'Monto en Banco',\n 'Diferencia',\n 'Sentido',\n 'Estado',\n 'Resolución',\n];\n\nconst mainReportRows = rows\n .filter((row) => row.category === 'discrepancia')\n .map((row, index) => {\n const payrollAmount = roundMoney(\n row.payroll_amount ?? row.payrollAmount ?? 0\n );\n const bankAmount = roundMoney(\n row.bank_amount ?? row.bankAmount ?? 0\n );\n const difference = roundMoney(\n row.difference ?? (payrollAmount - bankAmount)\n );\n\n return [\n index + 1,\n normalizeText(row.employee_name || row.employee || ''),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.payroll_account ||\n row.payrollAccount ||\n row.account ||\n ''\n ),\n payrollAmount,\n bankAmount,\n difference,\n mainReportSense(row, difference),\n normalizeText(row.status || 'Riesgo').toUpperCase(),\n '',\n ];\n });\n\nconst nominaVsBancoValues = [\n [\n 'GOMEZLEE MARKETING',\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n ],\n [\n mainReportSubtitle,\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n ],\n ['', '', '', '', '', '', '', '', ''],\n nominaVsBancoHeader,\n ...mainReportRows,\n];\n\nconst bancoSinNominaHeader = [\n 'Empleado Banco',\n 'Cuenta Banco',\n 'Moneda',\n 'Monto Banco',\n 'Estado',\n 'Observación',\n 'Resolución',\n];\n\nconst bancoSinNominaRows = rows\n .filter((row) => row.category === 'banco_sin_nomina')\n .map((row) => [\n normalizeText(row.employee_name || row.employee || ''),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n normalizeText(row.currency || 'HNL'),\n roundMoney(row.bank_amount ?? row.bankAmount ?? 0),\n normalizeText(row.status || ''),\n normalizeText(row.observation || ''),\n '',\n ]);\n\nconst bancoSinBambooHeader = [\n '#',\n 'Nombre en banco',\n 'Nombre del cuentahabiente',\n 'Cuenta',\n 'Monto en banco',\n 'Número de envío',\n 'Estado',\n 'Resolución',\n];\n\nconst bancoSinBambooRows = bankWithoutBamboo.map((row, index) => [\n index + 1,\n normalizeText(row.bank_name_file || row.employee_name || ''),\n normalizeText(row.bank_account_holder || ''),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n roundMoney(row.bank_amount ?? row.bankAmount ?? 0),\n firstValue(row.shipment_numbers || row.shipment_number || ''),\n 'PENDIENTE REVISIÓN',\n '',\n]);\n\nconst bancoSinBambooValues = [\n [\n 'GOMEZLEE MARKETING',\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n ],\n [\n bancoSinBambooSubtitle,\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n ],\n ['', '', '', '', '', '', '', ''],\n bancoSinBambooHeader,\n ...bancoSinBambooRows,\n];\n\nconst diferenciasNombreHeader = [\n 'Nombre en Archivo',\n 'Nombre del Cuentahabiente',\n 'Cuenta Destino',\n 'Moneda',\n 'Monto',\n 'Número de envío',\n 'Número de plan',\n 'Archivo',\n 'Estado',\n 'Observación',\n 'Resolución',\n];\n\nconst diferenciasNombreRows = rows\n .filter((row) => row.category === 'diferencia_nombre_banco')\n .map((row) => [\n normalizeText(row.bank_name_file || row.employee_name || ''),\n normalizeText(row.bank_account_holder || ''),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n normalizeText(row.currency || 'HNL'),\n roundMoney(row.bank_amount ?? row.bankAmount ?? 0),\n normalizeText(row.shipment_number || ''),\n normalizeText(row.plan_number || ''),\n normalizeText(row.source_file || ''),\n normalizeText(row.status || ''),\n normalizeText(row.observation || ''),\n '',\n ]);\n\n\nfunction accountValues(value) {\n const values = Array.isArray(value)\n ? value\n : String(value ?? '')\n .split(/\\s*(?:\\/|;|,|\\by\\b)\\s*/i);\n\n return values\n .map((item) =>\n String(item ?? '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim()\n )\n .filter((item) =>\n item.length >= 7 &&\n !/^0+$/.test(item)\n );\n}\n\nfunction payrollAccountsForWrongAccount(row) {\n const candidates = [\n row.payroll_account,\n row.payrollAccount,\n ...(Array.isArray(row.source_rows)\n ? row.source_rows.flatMap((sourceRow) => [\n sourceRow.account,\n sourceRow.payroll_account,\n sourceRow.payrollAccount,\n ])\n : []),\n ];\n\n return Array.from(new Set(\n candidates.flatMap(accountValues)\n ));\n}\n\nfunction bankAccountForWrongAccount(row) {\n return firstValue(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n );\n}\n\nfunction wrongAccountTotalStatus(row) {\n const currency = normalizeText(row.currency || 'HNL').toUpperCase() === 'USD' ? 'USD' : 'HNL';\n const payrollAmount = roundMoney(\n row.payroll_amount ?? row.payrollAmount ?? 0\n );\n const bankAmount = roundMoney(\n row.bank_amount ?? row.bankAmount ?? 0\n );\n const difference = roundMoney(\n payrollAmount - bankAmount\n );\n\n if (Math.abs(difference) <= 0.02) {\n return 'El total de nómina coincide con el total del banco.';\n }\n\n if (difference > 0) {\n return (\n `El total de nómina supera el total del banco por ` +\n `${currency} ${Math.abs(difference).toLocaleString('en-US', {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n })}.`\n );\n }\n\n return (\n `El total pagado por el banco supera el total de nómina por ` +\n `${currency} ${Math.abs(difference).toLocaleString('en-US', {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n })}.`\n );\n}\n\nfunction wrongAccountFinding(row) {\n const existing = normalizeText(row.observation || '');\n\n if (existing) {\n return existing;\n }\n\n const payrollAccounts =\n payrollAccountsForWrongAccount(row);\n const bankAccount =\n bankAccountForWrongAccount(row);\n\n return (\n `El empleado presenta una posible inconsistencia entre ` +\n `la cuenta registrada en nómina ` +\n `(${payrollAccounts.join(' y ') || 'sin cuenta identificada'}) ` +\n `y la cuenta utilizada por el banco ` +\n `(${bankAccount || 'sin cuenta identificada'}).`\n );\n}\n\nconst cuentaMalDigitadaHeader = [\n '#',\n 'Campo',\n 'Detalle',\n 'Resolución',\n];\n\nconst cuentaMalDigitadaRows = [];\n\nfor (\n let index = 0;\n index < cuentaMalDigitadaCases.length;\n index++\n) {\n const row = cuentaMalDigitadaCases[index];\n const payrollAccounts =\n payrollAccountsForWrongAccount(row);\n const bankAccount =\n bankAccountForWrongAccount(row);\n\n const fields = [\n [\n 'Empleado',\n normalizeText(\n row.employee_name ||\n row.employee ||\n ''\n ),\n ],\n [\n 'Cuentas registradas en las hojas de nómina',\n payrollAccounts.join(' y ') ||\n 'No se identificó una cuenta válida en la nómina.',\n ],\n [\n 'Cuenta utilizada por el banco',\n bankAccount ||\n 'No se identificó una cuenta válida en el banco.',\n ],\n [\n 'Estado del total',\n wrongAccountTotalStatus(row),\n ],\n [\n 'Hallazgo',\n wrongAccountFinding(row),\n ],\n [\n 'Clasificación',\n 'Posible cuenta mal digitada — revisar y unificar la cuenta en las hojas de nómina.',\n ],\n ];\n\n fields.forEach((field, fieldIndex) => {\n cuentaMalDigitadaRows.push([\n fieldIndex === 0 ? index + 1 : '',\n field[0],\n field[1],\n '',\n ]);\n });\n}\n\nconst cuentaMalDigitadaValues = [\n [\n 'GOMEZLEE MARKETING',\n '',\n '',\n '',\n ],\n [\n cuentaMalDigitadaSubtitle,\n '',\n '',\n '',\n ],\n ['', '', '', ''],\n cuentaMalDigitadaHeader,\n ...cuentaMalDigitadaRows,\n];\n\nconst resumenHeader = ['Indicador', 'Valor'];\nconst resumenRows = [\n ['Período', periodLabel],\n ['Coincidencias', Number(summary.coincidencias || 0)],\n ['Discrepancias', Number(summary.discrepancias || 0)],\n ['Banco sin nómina', Number(summary.bancoSinNomina || 0)],\n ['Banco sin Bamboo', Number(summary.bancoSinBamboo || 0)],\n ['Nómina sin cuenta', Number(summary.nominaSinCuenta || 0)],\n [\n 'Diferencias nombre banco',\n Number(summary.diferenciasNombreBanco || 0),\n ],\n [\n 'Posibles cuentas mal digitadas',\n Number(summary.posiblesCuentasMalDigitadas || 0),\n ],\n ['Pendientes cruce principal', Number(summary.pendientes || 0)],\n [\n 'Empleados BambooHR Honduras',\n Number(summary.empleadosBambooHN || 0),\n ],\n [\n 'Empleados BambooHR en el período',\n Number(summary.empleadosBambooEnPeriodo || 0),\n ],\n ['Filas válidas de nómina', Number(summary.filasNominaValidas || 0)],\n [\n 'Suplementos adjuntados',\n Number(summary.suplementosNominaAdjuntados || 0),\n ],\n [\n 'Suplementos sin coincidencia',\n Number(summary.suplementosNominaNoAdjuntados || 0),\n ],\n ['Transacciones bancarias', Number(summary.transaccionesBanco || 0)],\n ['Total nómina', `HNL ${roundMoney(summary.totalNominaPorMoneda?.HNL || 0).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})} | USD ${roundMoney(summary.totalNominaPorMoneda?.USD || 0).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}`],\n ['Total banco', `HNL ${roundMoney(summary.totalBancoPorMoneda?.HNL || 0).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})} | USD ${roundMoney(summary.totalBancoPorMoneda?.USD || 0).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}`],\n ['Diferencia total', `HNL ${roundMoney(summary.diferenciaTotalPorMoneda?.HNL || 0).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})} | USD ${roundMoney(summary.diferenciaTotalPorMoneda?.USD || 0).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}`],\n];\n\nfunction buildSheetValues(header, bodyRows) {\n return [header, ...bodyRows];\n}\n\nconst valueData = [\n {\n range: `'${sheetTitles.nominaVsBanco}'!A1:I`,\n values: nominaVsBancoValues,\n },\n {\n range: `'${sheetTitles.bancoSinNomina}'!A1:G`,\n values: buildSheetValues(\n bancoSinNominaHeader,\n bancoSinNominaRows\n ),\n },\n {\n range: `'${sheetTitles.bancoSinBamboo}'!A1:H`,\n values: bancoSinBambooValues,\n },\n {\n range: `'${sheetTitles.diferenciasNombreBanco}'!A1:K`,\n values: buildSheetValues(\n diferenciasNombreHeader,\n diferenciasNombreRows\n ),\n },\n ...(hasCuentaMalDigitada\n ? [\n {\n range:\n `'${sheetTitles.cuentaMalDigitada}'!A1:D`,\n values: cuentaMalDigitadaValues,\n },\n ]\n : []),\n {\n range: `'${sheetTitles.resumen}'!A1:B`,\n values: buildSheetValues(\n resumenHeader,\n resumenRows\n ),\n },\n];\n\nfunction headerFormatRequest(\n sheetId,\n endColumnIndex,\n startRowIndex = 0\n) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex: startRowIndex + 1,\n startColumnIndex: 0,\n endColumnIndex,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: {\n red: 0.29,\n green: 0.49,\n blue: 0.58,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 1,\n green: 1,\n blue: 1,\n },\n },\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n },\n },\n fields:\n 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment,verticalAlignment,wrapStrategy)',\n },\n };\n}\n\nfunction freezeHeaderRequest(sheetId, frozenRowCount = 1) {\n return {\n updateSheetProperties: {\n properties: {\n sheetId,\n gridProperties: {\n frozenRowCount,\n },\n },\n fields: 'gridProperties.frozenRowCount',\n },\n };\n}\n\nfunction autoResizeRequest(sheetId, endColumnIndex) {\n return {\n autoResizeDimensions: {\n dimensions: {\n sheetId,\n dimension: 'COLUMNS',\n startIndex: 0,\n endIndex: endColumnIndex,\n },\n },\n };\n}\n\nfunction moneyFormatRequest(\n sheetId,\n startColumnIndex,\n endColumnIndex,\n startRowIndex = 1,\n pattern = '#,##0.00',\n endRowIndex = null\n) {\n const range = {\n sheetId,\n startRowIndex,\n startColumnIndex,\n endColumnIndex,\n };\n\n if (Number.isInteger(endRowIndex)) {\n range.endRowIndex = endRowIndex;\n }\n\n return {\n repeatCell: {\n range,\n cell: {\n userEnteredFormat: {\n numberFormat: {\n type: 'NUMBER',\n pattern,\n },\n },\n },\n fields: 'userEnteredFormat.numberFormat',\n },\n };\n}\n\nfunction basicFilterRequest(\n sheetId,\n endColumnIndex,\n startRowIndex = 0,\n endRowIndex = null\n) {\n const range = {\n sheetId,\n startRowIndex,\n startColumnIndex: 0,\n endColumnIndex,\n };\n\n if (Number.isInteger(endRowIndex)) {\n range.endRowIndex = endRowIndex;\n }\n\n return {\n setBasicFilter: {\n filter: {\n range,\n },\n },\n };\n}\n\nfunction mergeRowRequest(\n sheetId,\n rowIndex,\n endColumnIndex\n) {\n return {\n mergeCells: {\n range: {\n sheetId,\n startRowIndex: rowIndex,\n endRowIndex: rowIndex + 1,\n startColumnIndex: 0,\n endColumnIndex,\n },\n mergeType: 'MERGE_ALL',\n },\n };\n}\n\nfunction titleRowFormatRequest(\n sheetId,\n rowIndex,\n endColumnIndex,\n options = {}\n) {\n const {\n fontSize = 12,\n bold = true,\n italic = false,\n horizontalAlignment = 'LEFT',\n } = options;\n\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: rowIndex,\n endRowIndex: rowIndex + 1,\n startColumnIndex: 0,\n endColumnIndex,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: {\n red: 0.29,\n green: 0.49,\n blue: 0.58,\n },\n textFormat: {\n bold,\n italic,\n fontSize,\n foregroundColor: {\n red: 1,\n green: 1,\n blue: 1,\n },\n },\n horizontalAlignment,\n verticalAlignment: 'MIDDLE',\n },\n },\n fields:\n 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment,verticalAlignment)',\n },\n };\n}\n\nfunction columnWidthRequest(\n sheetId,\n startIndex,\n endIndex,\n pixelSize\n) {\n return {\n updateDimensionProperties: {\n range: {\n sheetId,\n dimension: 'COLUMNS',\n startIndex,\n endIndex,\n },\n properties: {\n pixelSize,\n },\n fields: 'pixelSize',\n },\n };\n}\n\nfunction rowHeightRequest(\n sheetId,\n startIndex,\n endIndex,\n pixelSize\n) {\n return {\n updateDimensionProperties: {\n range: {\n sheetId,\n dimension: 'ROWS',\n startIndex,\n endIndex,\n },\n properties: {\n pixelSize,\n },\n fields: 'pixelSize',\n },\n };\n}\n\nfunction bodyAlignmentRequest(\n sheetId,\n startColumnIndex,\n endColumnIndex,\n horizontalAlignment,\n endRowIndex\n) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: 4,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n },\n cell: {\n userEnteredFormat: {\n horizontalAlignment,\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n },\n },\n fields:\n 'userEnteredFormat(horizontalAlignment,verticalAlignment,wrapStrategy)',\n },\n };\n}\n\nfunction statusFormatRequest(sheetId, endRowIndex) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: 4,\n endRowIndex,\n startColumnIndex: 7,\n endColumnIndex: 8,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: {\n red: 1,\n green: 0.92,\n blue: 0.92,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.85,\n green: 0.08,\n blue: 0.08,\n },\n },\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n },\n },\n fields:\n 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment,verticalAlignment)',\n },\n };\n}\n\nfunction bambooStatusFormatRequest(\n sheetId,\n endRowIndex\n) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: 4,\n endRowIndex,\n startColumnIndex: 6,\n endColumnIndex: 7,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: {\n red: 1,\n green: 0.97,\n blue: 0.82,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.70,\n green: 0.30,\n blue: 0.00,\n },\n },\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n },\n },\n fields:\n 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment,verticalAlignment,wrapStrategy)',\n },\n };\n}\n\n\nfunction mergeVerticalRequest(\n sheetId,\n startRowIndex,\n endRowIndex,\n columnIndex\n) {\n return {\n mergeCells: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex: columnIndex,\n endColumnIndex: columnIndex + 1,\n },\n mergeType: 'MERGE_ALL',\n },\n };\n}\n\nfunction rangeFormatRequest(\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n userEnteredFormat\n) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n },\n cell: {\n userEnteredFormat,\n },\n fields:\n 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment,verticalAlignment,wrapStrategy)',\n },\n };\n}\n\nfunction conditionalDifferenceRequest(\n sheetId,\n formula,\n backgroundColor,\n textColor,\n ranges,\n index\n) {\n return {\n addConditionalFormatRule: {\n index,\n rule: {\n ranges,\n booleanRule: {\n condition: {\n type: 'CUSTOM_FORMULA',\n values: [\n {\n userEnteredValue: formula,\n },\n ],\n },\n format: {\n backgroundColor,\n textFormat: {\n bold: true,\n foregroundColor: textColor,\n },\n },\n },\n },\n },\n };\n}\n\nfunction borderRequest(\n sheetId,\n startRowIndex,\n endRowIndex,\n endColumnIndex\n) {\n const border = {\n style: 'SOLID',\n color: {\n red: 0.72,\n green: 0.82,\n blue: 0.76,\n },\n };\n\n return {\n updateBorders: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex: 0,\n endColumnIndex,\n },\n top: border,\n bottom: border,\n left: border,\n right: border,\n innerHorizontal: border,\n innerVertical: border,\n },\n };\n}\n\nconst mainReportEndRow = Math.max(\n 4 + mainReportRows.length,\n 4\n);\n\nconst bancoSinBambooEndRow = Math.max(\n 4 + bancoSinBambooRows.length,\n 4\n);\n\nconst mainConditionalRanges = mainReportRows.length\n ? [\n {\n sheetId: sheetIds.nominaVsBanco,\n startRowIndex: 4,\n endRowIndex: mainReportEndRow,\n startColumnIndex: 5,\n endColumnIndex: 7,\n },\n ]\n : [];\n\nconst mainDataFormatRequests = mainReportRows.length\n ? [\n moneyFormatRequest(\n sheetIds.nominaVsBanco,\n 3,\n 6,\n 4,\n 'Q#,##0.00;[Red](Q#,##0.00)',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 0,\n 1,\n 'CENTER',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 1,\n 2,\n 'LEFT',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 2,\n 3,\n 'CENTER',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 3,\n 6,\n 'RIGHT',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 6,\n 8,\n 'CENTER',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 8,\n 9,\n 'LEFT',\n mainReportEndRow\n ),\n statusFormatRequest(\n sheetIds.nominaVsBanco,\n mainReportEndRow\n ),\n conditionalDifferenceRequest(\n sheetIds.nominaVsBanco,\n '=$F5>0',\n {\n red: 1,\n green: 0.97,\n blue: 0.82,\n },\n {\n red: 0.25,\n green: 0.25,\n blue: 0.25,\n },\n mainConditionalRanges,\n 0\n ),\n conditionalDifferenceRequest(\n sheetIds.nominaVsBanco,\n '=$F5<0',\n {\n red: 1,\n green: 0.89,\n blue: 0.89,\n },\n {\n red: 0.9,\n green: 0.05,\n blue: 0.05,\n },\n mainConditionalRanges,\n 1\n ),\n rowHeightRequest(\n sheetIds.nominaVsBanco,\n 4,\n mainReportEndRow,\n 28\n ),\n ]\n : [];\n\n\nconst cuentaMalDigitadaEndRow = Math.max(\n 4 + cuentaMalDigitadaRows.length,\n 4\n);\n\nconst cuentaMalDigitadaCaseRequests =\n hasCuentaMalDigitada\n ? cuentaMalDigitadaCases.flatMap(\n (_, caseIndex) => {\n const startRowIndex =\n 4 + caseIndex * 6;\n const endRowIndex =\n startRowIndex + 6;\n\n return [\n mergeVerticalRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endRowIndex,\n 0\n ),\n mergeVerticalRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endRowIndex,\n 3\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n startRowIndex + 4,\n 34\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex + 4,\n startRowIndex + 5,\n 76\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex + 5,\n endRowIndex,\n 54\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex + 4,\n startRowIndex + 5,\n 2,\n 3,\n {\n backgroundColor: {\n red: 0.97,\n green: 0.97,\n blue: 0.97,\n },\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex + 5,\n endRowIndex,\n 2,\n 3,\n {\n backgroundColor: {\n red: 1,\n green: 0.97,\n blue: 0.82,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.55,\n green: 0.30,\n blue: 0.00,\n },\n },\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n ];\n }\n )\n : [];\n\nconst cuentaMalDigitadaFormatRequests =\n hasCuentaMalDigitada\n ? [\n mergeRowRequest(\n sheetIds.cuentaMalDigitada,\n 0,\n 4\n ),\n mergeRowRequest(\n sheetIds.cuentaMalDigitada,\n 1,\n 4\n ),\n titleRowFormatRequest(\n sheetIds.cuentaMalDigitada,\n 0,\n 4,\n {\n fontSize: 12,\n bold: true,\n italic: false,\n horizontalAlignment: 'LEFT',\n }\n ),\n titleRowFormatRequest(\n sheetIds.cuentaMalDigitada,\n 1,\n 4,\n {\n fontSize: 10,\n bold: false,\n italic: true,\n horizontalAlignment: 'LEFT',\n }\n ),\n headerFormatRequest(\n sheetIds.cuentaMalDigitada,\n 4,\n 3\n ),\n freezeHeaderRequest(\n sheetIds.cuentaMalDigitada,\n 4\n ),\n borderRequest(\n sheetIds.cuentaMalDigitada,\n 3,\n cuentaMalDigitadaEndRow,\n 4\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n 0,\n 1,\n 30\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n 1,\n 2,\n 26\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n 3,\n 4,\n 40\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n 4,\n cuentaMalDigitadaEndRow,\n 0,\n 1,\n {\n backgroundColor: {\n red: 0.91,\n green: 0.95,\n blue: 0.99,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.20,\n green: 0.36,\n blue: 0.45,\n },\n },\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n 4,\n cuentaMalDigitadaEndRow,\n 1,\n 2,\n {\n backgroundColor: {\n red: 0.93,\n green: 0.97,\n blue: 0.90,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.20,\n green: 0.36,\n blue: 0.45,\n },\n },\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n 4,\n cuentaMalDigitadaEndRow,\n 2,\n 3,\n {\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n 4,\n cuentaMalDigitadaEndRow,\n 3,\n 4,\n {\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 0,\n 1,\n 48\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 1,\n 2,\n 285\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 2,\n 3,\n 520\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 3,\n 4,\n 260\n ),\n ...cuentaMalDigitadaCaseRequests,\n ]\n : [];\n\n\nfunction wrapRangeRequest(\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex\n) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n },\n cell: {\n userEnteredFormat: {\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n },\n },\n fields:\n 'userEnteredFormat(verticalAlignment,wrapStrategy)',\n },\n };\n}\n\nfunction autoResizeRowsRequest(\n sheetId,\n startIndex,\n endIndex\n) {\n return {\n autoResizeDimensions: {\n dimensions: {\n sheetId,\n dimension: 'ROWS',\n startIndex,\n endIndex,\n },\n },\n };\n}\n\nconst formatRequests = [\n mergeRowRequest(sheetIds.nominaVsBanco, 0, 9),\n mergeRowRequest(sheetIds.nominaVsBanco, 1, 9),\n titleRowFormatRequest(\n sheetIds.nominaVsBanco,\n 0,\n 9,\n {\n fontSize: 12,\n bold: true,\n italic: false,\n horizontalAlignment: 'LEFT',\n }\n ),\n titleRowFormatRequest(\n sheetIds.nominaVsBanco,\n 1,\n 9,\n {\n fontSize: 10,\n bold: false,\n italic: true,\n horizontalAlignment: 'LEFT',\n }\n ),\n headerFormatRequest(\n sheetIds.nominaVsBanco,\n 9,\n 3\n ),\n freezeHeaderRequest(\n sheetIds.nominaVsBanco,\n 4\n ),\n basicFilterRequest(\n sheetIds.nominaVsBanco,\n 9,\n 3,\n mainReportEndRow\n ),\n borderRequest(\n sheetIds.nominaVsBanco,\n 3,\n mainReportEndRow,\n 9\n ),\n rowHeightRequest(\n sheetIds.nominaVsBanco,\n 0,\n 1,\n 30\n ),\n rowHeightRequest(\n sheetIds.nominaVsBanco,\n 1,\n 2,\n 26\n ),\n rowHeightRequest(\n sheetIds.nominaVsBanco,\n 3,\n 4,\n 42\n ),\n ...mainDataFormatRequests,\n\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 0,\n 1,\n 48\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 1,\n 2,\n 260\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 2,\n 3,\n 130\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 3,\n 6,\n 130\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 6,\n 7,\n 155\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 7,\n 8,\n 110\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 8,\n 9,\n 260\n ),\n\n headerFormatRequest(sheetIds.bancoSinNomina, 7),\n freezeHeaderRequest(sheetIds.bancoSinNomina),\n autoResizeRequest(sheetIds.bancoSinNomina, 7),\n moneyFormatRequest(sheetIds.bancoSinNomina, 3, 4),\n basicFilterRequest(sheetIds.bancoSinNomina, 7),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 6,\n 7,\n 260\n ),\n\n mergeRowRequest(sheetIds.bancoSinBamboo, 0, 8),\n mergeRowRequest(sheetIds.bancoSinBamboo, 1, 8),\n titleRowFormatRequest(\n sheetIds.bancoSinBamboo,\n 0,\n 8,\n {\n fontSize: 12,\n bold: true,\n italic: false,\n horizontalAlignment: 'LEFT',\n }\n ),\n titleRowFormatRequest(\n sheetIds.bancoSinBamboo,\n 1,\n 8,\n {\n fontSize: 10,\n bold: false,\n italic: true,\n horizontalAlignment: 'LEFT',\n }\n ),\n headerFormatRequest(\n sheetIds.bancoSinBamboo,\n 8,\n 3\n ),\n freezeHeaderRequest(\n sheetIds.bancoSinBamboo,\n 4\n ),\n basicFilterRequest(\n sheetIds.bancoSinBamboo,\n 8,\n 3,\n bancoSinBambooEndRow\n ),\n borderRequest(\n sheetIds.bancoSinBamboo,\n 3,\n bancoSinBambooEndRow,\n 8\n ),\n rowHeightRequest(\n sheetIds.bancoSinBamboo,\n 0,\n 1,\n 30\n ),\n rowHeightRequest(\n sheetIds.bancoSinBamboo,\n 1,\n 2,\n 26\n ),\n rowHeightRequest(\n sheetIds.bancoSinBamboo,\n 3,\n 4,\n 42\n ),\n moneyFormatRequest(\n sheetIds.bancoSinBamboo,\n 4,\n 5,\n 4,\n 'Q#,##0.00',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 0,\n 1,\n 'CENTER',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 1,\n 3,\n 'LEFT',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 3,\n 4,\n 'CENTER',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 4,\n 5,\n 'RIGHT',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 5,\n 7,\n 'CENTER',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 7,\n 8,\n 'LEFT',\n bancoSinBambooEndRow\n ),\n bambooStatusFormatRequest(\n sheetIds.bancoSinBamboo,\n bancoSinBambooEndRow\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 0,\n 1,\n 48\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 1,\n 2,\n 220\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 2,\n 3,\n 270\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 3,\n 4,\n 135\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 4,\n 5,\n 130\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 5,\n 6,\n 110\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 6,\n 7,\n 140\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 7,\n 8,\n 260\n ),\n\n headerFormatRequest(\n sheetIds.diferenciasNombreBanco,\n 11\n ),\n freezeHeaderRequest(\n sheetIds.diferenciasNombreBanco\n ),\n autoResizeRequest(\n sheetIds.diferenciasNombreBanco,\n 11\n ),\n moneyFormatRequest(\n sheetIds.diferenciasNombreBanco,\n 4,\n 5\n ),\n basicFilterRequest(\n sheetIds.diferenciasNombreBanco,\n 11\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 10,\n 11,\n 260\n ),\n\n ...cuentaMalDigitadaFormatRequests,\n\n headerFormatRequest(sheetIds.resumen, 2),\n freezeHeaderRequest(sheetIds.resumen),\n autoResizeRequest(sheetIds.resumen, 2),\n moneyFormatRequest(sheetIds.resumen, 1, 2),\n];\n\n\nconst bancoSinNominaEndRow =\n 1 + bancoSinNominaRows.length;\n\nconst diferenciasNombreEndRow =\n 1 + diferenciasNombreRows.length;\n\nconst resumenReadabilityEndRow =\n 1 + resumenRows.length;\n\n/*\n * Ajuste final de legibilidad.\n *\n * Se ejecuta al final para que los anchos definitivos ya estén aplicados\n * cuando Google Sheets calcule automáticamente la altura de cada fila.\n * Así, cualquier texto largo queda envuelto y visible sin que el usuario\n * tenga que expandir columnas o filas manualmente.\n */\nformatRequests.push(\n // 01 Nómina vs Banco\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 1,\n 2,\n 300\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 2,\n 3,\n 150\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 3,\n 6,\n 145\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 6,\n 7,\n 220\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 7,\n 8,\n 140\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 8,\n 9,\n 320\n ),\n ...(mainReportRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.nominaVsBanco,\n 4,\n mainReportEndRow,\n 0,\n 9\n ),\n autoResizeRowsRequest(\n sheetIds.nominaVsBanco,\n 4,\n mainReportEndRow\n ),\n ]\n : []),\n\n // 02 Banco sin Nómina\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 0,\n 1,\n 320\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 1,\n 2,\n 165\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 2,\n 3,\n 95\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 3,\n 4,\n 140\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 4,\n 5,\n 170\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 5,\n 6,\n 560\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 6,\n 7,\n 320\n ),\n autoResizeRowsRequest(\n sheetIds.bancoSinNomina,\n 0,\n 1\n ),\n ...(bancoSinNominaRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.bancoSinNomina,\n 1,\n bancoSinNominaEndRow,\n 0,\n 7\n ),\n autoResizeRowsRequest(\n sheetIds.bancoSinNomina,\n 1,\n bancoSinNominaEndRow\n ),\n ]\n : []),\n\n // 03 Banco sin Bamboo\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 1,\n 2,\n 300\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 2,\n 3,\n 330\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 3,\n 4,\n 150\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 4,\n 5,\n 145\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 5,\n 6,\n 150\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 6,\n 7,\n 170\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 7,\n 8,\n 320\n ),\n ...(bancoSinBambooRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.bancoSinBamboo,\n 4,\n bancoSinBambooEndRow,\n 0,\n 8\n ),\n autoResizeRowsRequest(\n sheetIds.bancoSinBamboo,\n 4,\n bancoSinBambooEndRow\n ),\n ]\n : []),\n\n // 04 Diferencias nombre banco\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 0,\n 1,\n 300\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 1,\n 2,\n 330\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 2,\n 3,\n 155\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 3,\n 4,\n 95\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 4,\n 5,\n 140\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 5,\n 7,\n 150\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 7,\n 8,\n 260\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 8,\n 9,\n 170\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 9,\n 10,\n 650\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 10,\n 11,\n 320\n ),\n autoResizeRowsRequest(\n sheetIds.diferenciasNombreBanco,\n 0,\n 1\n ),\n ...(diferenciasNombreRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.diferenciasNombreBanco,\n 1,\n diferenciasNombreEndRow,\n 0,\n 11\n ),\n autoResizeRowsRequest(\n sheetIds.diferenciasNombreBanco,\n 1,\n diferenciasNombreEndRow\n ),\n ]\n : []),\n\n // 05 Cuenta Mal Digitada\n ...(hasCuentaMalDigitada\n ? [\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 1,\n 2,\n 300\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 2,\n 3,\n 600\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 3,\n 4,\n 320\n ),\n ]\n : []),\n\n // Resumen\n columnWidthRequest(\n sheetIds.resumen,\n 0,\n 1,\n 380\n ),\n columnWidthRequest(\n sheetIds.resumen,\n 1,\n 2,\n 320\n ),\n autoResizeRowsRequest(\n sheetIds.resumen,\n 0,\n 1\n ),\n ...(resumenRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.resumen,\n 1,\n resumenReadabilityEndRow,\n 0,\n 2\n ),\n autoResizeRowsRequest(\n sheetIds.resumen,\n 1,\n resumenReadabilityEndRow\n ),\n ]\n : [])\n);\n\n\n\n/*\n * Protección de integridad del reporte.\n *\n * La hoja completa queda protegida para impedir que los usuarios con acceso\n * al reporte modifiquen datos calculados, nombres, cuentas, montos, estados,\n * observaciones o cualquier otro resultado del cruce.\n *\n * Únicamente las celdas de datos de la columna \"Resolución\" quedan fuera de\n * la protección para que puedan completarse después de recibir el reporte.\n * Los encabezados y las filas fuera de la tabla permanecen protegidos.\n *\n * El propietario de la hoja conserva siempre el control. Además, el usuario\n * administrativo iaracena@gomezleemarketing.com queda registrado como editor explícito del\n * rango protegido para mantenimiento de IT.\n */\nfunction protectedSheetRequest(\n sheetId,\n description,\n resolutionColumnIndex = null,\n resolutionStartRowIndex = null,\n resolutionRowsCount = 0\n) {\n const protectedRange = {\n range: {\n sheetId,\n },\n description,\n warningOnly: false,\n editors: {\n users: [\n 'iaracena@gomezleemarketing.com',\n ],\n domainUsersCanEdit: false,\n },\n };\n\n if (\n Number.isInteger(resolutionColumnIndex) &&\n Number.isInteger(resolutionStartRowIndex) &&\n Number(resolutionRowsCount) > 0\n ) {\n protectedRange.unprotectedRanges = [\n {\n sheetId,\n startRowIndex:\n resolutionStartRowIndex,\n endRowIndex:\n resolutionStartRowIndex +\n Number(resolutionRowsCount),\n startColumnIndex:\n resolutionColumnIndex,\n endColumnIndex:\n resolutionColumnIndex + 1,\n },\n ];\n }\n\n return {\n addProtectedRange: {\n protectedRange,\n },\n };\n}\n\n\nfunction protectedResolutionRequest(\n sheetId,\n resolutionColumnIndex,\n resolutionStartRowIndex,\n resolutionRowsCount\n) {\n if (\n !Number.isInteger(resolutionColumnIndex) ||\n !Number.isInteger(resolutionStartRowIndex) ||\n Number(resolutionRowsCount) <= 0\n ) {\n return null;\n }\n\n return {\n addProtectedRange: {\n protectedRange: {\n range: {\n sheetId,\n startRowIndex:\n resolutionStartRowIndex,\n endRowIndex:\n resolutionStartRowIndex +\n Number(resolutionRowsCount),\n startColumnIndex:\n resolutionColumnIndex,\n endColumnIndex:\n resolutionColumnIndex + 1,\n },\n description:\n 'Protección GLM — Resolución. Solo Viviana Páramo y Liliana Benítez pueden editar.',\n warningOnly: false,\n editors: {\n users: [\n 'vparamo@gomezleemarketing.com',\n 'adminregional@gomezleemarketing.com',\n ],\n domainUsersCanEdit: false,\n },\n },\n },\n };\n}\n\nfunction addResolutionProtection(\n sheetId,\n resolutionColumnIndex,\n resolutionStartRowIndex,\n resolutionRowsCount\n) {\n const request =\n protectedResolutionRequest(\n sheetId,\n resolutionColumnIndex,\n resolutionStartRowIndex,\n resolutionRowsCount\n );\n\n if (request) {\n formatRequests.push(request);\n }\n}\n\n\nformatRequests.push(\n protectedSheetRequest(\n sheetIds.nominaVsBanco,\n 'Protección GLM — datos del cruce. Solo Resolución es editable.',\n 8,\n 4,\n mainReportRows.length\n ),\n protectedSheetRequest(\n sheetIds.bancoSinNomina,\n 'Protección GLM — datos del cruce. Solo Resolución es editable.',\n 6,\n 1,\n bancoSinNominaRows.length\n ),\n protectedSheetRequest(\n sheetIds.bancoSinBamboo,\n 'Protección GLM — datos del cruce. Solo Resolución es editable.',\n 7,\n 4,\n bancoSinBambooRows.length\n ),\n protectedSheetRequest(\n sheetIds.diferenciasNombreBanco,\n 'Protección GLM — datos del cruce. Solo Resolución es editable.',\n 10,\n 1,\n diferenciasNombreRows.length\n ),\n ...(hasCuentaMalDigitada\n ? [\n protectedSheetRequest(\n sheetIds.cuentaMalDigitada,\n 'Protección GLM — datos del cruce. Solo Resolución es editable.',\n 3,\n 4,\n cuentaMalDigitadaRows.length\n ),\n ]\n : []),\n protectedSheetRequest(\n sheetIds.resumen,\n 'Protección GLM — hoja de resumen de solo lectura.'\n )\n);\n\n\naddResolutionProtection(\n sheetIds.nominaVsBanco,\n 8,\n 4,\n mainReportRows.length\n);\naddResolutionProtection(\n sheetIds.bancoSinNomina,\n 6,\n 1,\n bancoSinNominaRows.length\n);\naddResolutionProtection(\n sheetIds.bancoSinBamboo,\n 7,\n 4,\n bancoSinBambooRows.length\n);\naddResolutionProtection(\n sheetIds.diferenciasNombreBanco,\n 10,\n 1,\n diferenciasNombreRows.length\n);\nif (hasCuentaMalDigitada) {\n addResolutionProtection(\n sheetIds.cuentaMalDigitada,\n 3,\n 4,\n cuentaMalDigitadaRows.length\n );\n}\n\n\nreturn [\n {\n json: {\n ok: true,\n stage: 'preparar_google_sheet',\n metadata,\n summary,\n spreadsheetTitle,\n sheetIds,\n sheetTitles,\n createSpreadsheetBody: {\n properties: {\n title: spreadsheetTitle,\n },\n sheets: [\n {\n properties: {\n sheetId: sheetIds.nominaVsBanco,\n title: sheetTitles.nominaVsBanco,\n },\n },\n {\n properties: {\n sheetId: sheetIds.bancoSinNomina,\n title: sheetTitles.bancoSinNomina,\n },\n },\n {\n properties: {\n sheetId: sheetIds.bancoSinBamboo,\n title: sheetTitles.bancoSinBamboo,\n },\n },\n {\n properties: {\n sheetId:\n sheetIds.diferenciasNombreBanco,\n title:\n sheetTitles.diferenciasNombreBanco,\n },\n },\n ...(hasCuentaMalDigitada\n ? [\n {\n properties: {\n sheetId:\n sheetIds.cuentaMalDigitada,\n title:\n sheetTitles.cuentaMalDigitada,\n },\n },\n ]\n : []),\n {\n properties: {\n sheetId: sheetIds.resumen,\n title: sheetTitles.resumen,\n },\n },\n ],\n },\n valueBatchBody: {\n // RAW conserva los números de cuenta como texto exacto (incluidos\n // ceros iniciales) y mantiene los montos numéricos porque ya se\n // envían como números en el payload.\n valueInputOption: 'RAW',\n data: valueData,\n },\n formatBatchBody: {\n requests: formatRequests,\n },\n originalResponse: data,\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1568,
512
],
"id": "d27e1baa-53ec-4862-9467-8b708916955b",
"name": "Preparar Google Sheet"
},
{
"parameters": {
"method": "POST",
"url": "https://dbit.digitalcompass.agency/rest/v1/rpc/cruce_cuentas_get_reporte_periodo",
"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=representation"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ { p_country: $('Preparar Google Sheet').first().json.metadata.country || 'HN', p_year: Number($('Preparar Google Sheet').first().json.metadata.year || 0), p_month: Number($('Preparar Google Sheet').first().json.metadata.month || 0), p_period_type: $('Preparar Google Sheet').first().json.metadata.period_type || '' } }}",
"options": {
"response": {
"response": {
"responseFormat": "json"
}
},
"timeout": 30000
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
1808,
512
],
"id": "c9c2472c-8e08-47dc-814c-9f878e11730e",
"name": "Buscar reporte existente Supabase"
},
{
"parameters": {
"method": "POST",
"url": "={{ (() => { const lookup = $('Buscar reporte existente Supabase').first().json || {}; const report = lookup.report || {}; const id = String(report.spreadsheet_id || '').trim(); return (lookup.found === true && id) ? 'https://sheets.googleapis.com/v4/spreadsheets/' + id + ':batchUpdate' : 'https://sheets.googleapis.com/v4/spreadsheets'; })() }}",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{\n(() => {\n const prepared = $('Preparar Google Sheet').first().json || {};\n const lookup = $('Buscar reporte existente Supabase').first().json || {};\n const report = lookup.report || {};\n const existingId = String(report.spreadsheet_id || '').trim();\n\n if (lookup.found === true && existingId) {\n return {\n requests: [\n {\n updateSpreadsheetProperties: {\n properties: {\n title: prepared.spreadsheetTitle || prepared.createSpreadsheetBody?.properties?.title || 'Cruce de Cuentas GLM',\n },\n fields: 'title',\n },\n },\n ],\n };\n }\n\n const createBody = prepared.createSpreadsheetBody || {};\n\n if (!Array.isArray(createBody.sheets) || createBody.sheets.length === 0) {\n throw new Error('Preparar Google Sheet no devolvió las hojas que deben crearse.');\n }\n\n return {\n properties: {\n ...(createBody.properties || {}),\n timeZone: 'America/Honduras',\n },\n sheets: createBody.sheets.map((sheet) => ({\n properties: {\n ...(sheet.properties || {}),\n gridProperties: {\n ...((sheet.properties || {}).gridProperties || {}),\n frozenRowCount: 1,\n },\n },\n })),\n };\n})()\n}}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
2048,
512
],
"id": "398d6e91-0f62-4d90-a493-a507d7ee9d8a",
"name": "Crear Google Sheet",
"retryOnFail": true,
"credentials": {
"googleOAuth2Api": {
"id": "eHseMeH39kRcXgOF",
"name": "Google account 2"
}
}
},
{
"parameters": {
"url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $('Crear Google Sheet').first().json.spreadsheetId + '?includeGridData=true' }}",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleOAuth2Api",
"options": {
"timeout": 60000
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
2288,
512
],
"id": "ce89510d-5f8f-4378-a548-f98297279add",
"name": "Leer Google Sheet actual",
"retryOnFail": true,
"credentials": {
"googleOAuth2Api": {
"id": "dQ1MJSJSWcoWYcb8",
"name": "Google account - Isaac Producción"
}
}
},
{
"parameters": {
"jsCode": "const prepared =\n $('Preparar Google Sheet').first().json || {};\n\nconst lookup =\n $('Buscar reporte existente Supabase').first().json || {};\n\nconst resolvedSheet =\n $('Crear Google Sheet').first().json || {};\n\nconst spreadsheet =\n $input.first().json || {};\n\nconst report =\n lookup && typeof lookup.report === 'object' && lookup.report\n ? lookup.report\n : {};\n\nconst historyExists =\n lookup.found === true &&\n Boolean(report.id);\n\nconst reusingSheet =\n historyExists &&\n Boolean(\n String(\n report.spreadsheet_id ||\n ''\n ).trim()\n );\n\nconst spreadsheetId =\n String(\n resolvedSheet.spreadsheetId ||\n report.spreadsheet_id ||\n spreadsheet.spreadsheetId ||\n ''\n ).trim();\n\nif (!spreadsheetId) {\n throw new Error(\n 'No se pudo resolver el spreadsheetId del reporte.'\n );\n}\n\nfunction normalizeText(value) {\n return String(value ?? '')\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^a-z0-9]+/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeAccount(value) {\n return String(value ?? '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim();\n}\n\nfunction cellValue(cell) {\n if (!cell || typeof cell !== 'object') {\n return '';\n }\n\n const entered =\n cell.userEnteredValue || {};\n\n if (\n entered.stringValue !== undefined\n ) {\n return String(entered.stringValue);\n }\n\n if (\n entered.numberValue !== undefined\n ) {\n return String(entered.numberValue);\n }\n\n if (\n entered.boolValue !== undefined\n ) {\n return entered.boolValue\n ? 'TRUE'\n : 'FALSE';\n }\n\n if (\n entered.formulaValue !== undefined\n ) {\n return String(\n cell.formattedValue ??\n entered.formulaValue\n );\n }\n\n return String(\n cell.formattedValue ?? ''\n );\n}\n\nfunction sheetMatrix(sheet) {\n const matrix = [];\n const blocks =\n Array.isArray(sheet?.data)\n ? sheet.data\n : [];\n\n for (const block of blocks) {\n const startRow =\n Number(block.startRow || 0);\n const rowData =\n Array.isArray(block.rowData)\n ? block.rowData\n : [];\n\n for (\n let rowOffset = 0;\n rowOffset < rowData.length;\n rowOffset++\n ) {\n const rowIndex =\n startRow + rowOffset;\n\n const cells =\n Array.isArray(\n rowData[rowOffset]?.values\n )\n ? rowData[rowOffset].values\n : [];\n\n matrix[rowIndex] =\n cells.map(cellValue);\n }\n }\n\n return matrix;\n}\n\nfunction findHeaderInfo(matrix) {\n for (\n let rowIndex = 0;\n rowIndex < matrix.length;\n rowIndex++\n ) {\n const row =\n Array.isArray(matrix[rowIndex])\n ? matrix[rowIndex]\n : [];\n\n const normalized =\n row.map(normalizeText);\n\n const resolutionIndex =\n normalized.findIndex(\n (value) =>\n value === 'resolucion'\n );\n\n if (resolutionIndex >= 0) {\n return {\n rowIndex,\n header: row,\n normalized,\n resolutionIndex,\n };\n }\n }\n\n return null;\n}\n\nfunction indexMatching(\n normalizedHeader,\n candidates\n) {\n for (const candidate of candidates) {\n const normalizedCandidate =\n normalizeText(candidate);\n\n const exact =\n normalizedHeader.findIndex(\n (header) =>\n header === normalizedCandidate\n );\n\n if (exact >= 0) {\n return exact;\n }\n }\n\n for (const candidate of candidates) {\n const normalizedCandidate =\n normalizeText(candidate);\n\n const partial =\n normalizedHeader.findIndex(\n (header) =>\n header.includes(\n normalizedCandidate\n )\n );\n\n if (partial >= 0) {\n return partial;\n }\n }\n\n return -1;\n}\n\nfunction firstAt(row, indexes) {\n for (const index of indexes) {\n if (\n index >= 0 &&\n String(row[index] ?? '').trim()\n ) {\n return String(row[index]);\n }\n }\n\n return '';\n}\n\nconst ids =\n prepared.sheetIds || {};\n\nfunction rowIdentity(\n sheetId,\n row,\n normalizedHeader\n) {\n const accountIndex =\n indexMatching(\n normalizedHeader,\n [\n 'Cuenta',\n 'Cuenta Banco',\n 'Cuenta Destino',\n ]\n );\n\n const account =\n accountIndex >= 0\n ? normalizeAccount(\n row[accountIndex]\n )\n : '';\n\n if (account) {\n return `account:${account}`;\n }\n\n if (\n Number(sheetId) ===\n Number(ids.nominaVsBanco)\n ) {\n const employeeIndex =\n indexMatching(\n normalizedHeader,\n ['Empleado', 'Nombre en nómina']\n );\n\n return `employee:${normalizeText(\n firstAt(\n row,\n [employeeIndex]\n )\n )}`;\n }\n\n if (\n Number(sheetId) ===\n Number(ids.bancoSinNomina)\n ) {\n const nameIndex =\n indexMatching(\n normalizedHeader,\n [\n 'Empleado Banco',\n 'Nombre en banco',\n ]\n );\n\n const currencyIndex =\n indexMatching(\n normalizedHeader,\n ['Moneda']\n );\n\n return [\n 'bank-no-payroll',\n normalizeText(\n firstAt(row, [nameIndex])\n ),\n normalizeText(\n firstAt(row, [currencyIndex])\n ),\n ].join(':');\n }\n\n if (\n Number(sheetId) ===\n Number(ids.bancoSinBamboo)\n ) {\n const nameIndex =\n indexMatching(\n normalizedHeader,\n ['Nombre en banco']\n );\n\n const holderIndex =\n indexMatching(\n normalizedHeader,\n ['Nombre del cuentahabiente']\n );\n\n return [\n 'bank-no-bamboo',\n normalizeText(\n firstAt(row, [nameIndex])\n ),\n normalizeText(\n firstAt(row, [holderIndex])\n ),\n ].join(':');\n }\n\n if (\n Number(sheetId) ===\n Number(ids.diferenciasNombreBanco)\n ) {\n const nameOne =\n indexMatching(\n normalizedHeader,\n [\n 'Nombre en Archivo',\n 'Nombre en nómina',\n ]\n );\n\n const nameTwo =\n indexMatching(\n normalizedHeader,\n [\n 'Nombre del Cuentahabiente',\n 'Nombre en banco',\n ]\n );\n\n return [\n 'name-difference',\n normalizeText(\n firstAt(row, [nameOne])\n ),\n normalizeText(\n firstAt(row, [nameTwo])\n ),\n ].join(':');\n }\n\n return '';\n}\n\nfunction collectRegularResolutions(\n sheetId,\n matrix\n) {\n const result = new Map();\n const info =\n findHeaderInfo(matrix);\n\n if (!info) {\n return result;\n }\n\n for (\n let rowIndex =\n info.rowIndex + 1;\n rowIndex < matrix.length;\n rowIndex++\n ) {\n const row =\n Array.isArray(matrix[rowIndex])\n ? matrix[rowIndex]\n : [];\n\n const resolution =\n String(\n row[\n info.resolutionIndex\n ] ?? ''\n ).trim();\n\n if (!resolution) {\n continue;\n }\n\n const key =\n rowIdentity(\n sheetId,\n row,\n info.normalized\n );\n\n if (key) {\n result.set(\n key,\n resolution\n );\n }\n }\n\n return result;\n}\n\nfunction collectWrongAccountResolutions(\n matrix\n) {\n const result = new Map();\n const info =\n findHeaderInfo(matrix);\n\n if (!info) {\n return result;\n }\n\n const fieldIndex =\n indexMatching(\n info.normalized,\n ['Campo']\n );\n\n const detailIndex =\n indexMatching(\n info.normalized,\n ['Detalle']\n );\n\n let currentEmployee = '';\n\n for (\n let rowIndex =\n info.rowIndex + 1;\n rowIndex < matrix.length;\n rowIndex++\n ) {\n const row =\n Array.isArray(matrix[rowIndex])\n ? matrix[rowIndex]\n : [];\n\n const field =\n normalizeText(\n row[fieldIndex] ?? ''\n );\n\n const detail =\n String(\n row[detailIndex] ?? ''\n );\n\n if (field === 'empleado') {\n currentEmployee =\n normalizeText(detail);\n }\n\n const resolution =\n String(\n row[\n info.resolutionIndex\n ] ?? ''\n ).trim();\n\n if (\n resolution &&\n currentEmployee &&\n field\n ) {\n result.set(\n `${currentEmployee}|${field}`,\n resolution\n );\n }\n }\n\n return result;\n}\n\nconst existingSheets =\n Array.isArray(spreadsheet.sheets)\n ? spreadsheet.sheets\n : [];\n\nconst sheetById =\n new Map();\n\nfor (const sheet of existingSheets) {\n const sheetId =\n sheet?.properties?.sheetId;\n\n if (\n sheetId !== null &&\n sheetId !== undefined\n ) {\n sheetById.set(\n Number(sheetId),\n sheet\n );\n }\n}\n\nconst resolutionMaps =\n new Map();\n\nif (reusingSheet) {\n for (\n const [key, rawId]\n of Object.entries(ids)\n ) {\n const sheetId =\n Number(rawId);\n\n const sheet =\n sheetById.get(sheetId);\n\n if (!sheet) {\n continue;\n }\n\n const matrix =\n sheetMatrix(sheet);\n\n const map =\n key === 'cuentaMalDigitada'\n ? collectWrongAccountResolutions(\n matrix\n )\n : collectRegularResolutions(\n sheetId,\n matrix\n );\n\n resolutionMaps.set(\n sheetId,\n map\n );\n }\n}\n\nfunction titleFromRange(range) {\n const match =\n String(range || '')\n .match(/^'([^']+)'!/);\n\n return match?.[1] || '';\n}\n\nconst desiredTitles =\n prepared.sheetTitles || {};\n\nconst titleToId =\n new Map();\n\nfor (\n const [key, title]\n of Object.entries(desiredTitles)\n) {\n if (\n ids[key] !== undefined\n ) {\n titleToId.set(\n String(title),\n Number(ids[key])\n );\n }\n}\n\nlet preservedResolutionsCount = 0;\n\nconst valueBatchBody =\n JSON.parse(\n JSON.stringify(\n prepared.valueBatchBody || {\n valueInputOption:\n 'USER_ENTERED',\n data: [],\n }\n )\n );\n\nfor (\n const dataEntry\n of valueBatchBody.data || []\n) {\n const title =\n titleFromRange(\n dataEntry.range\n );\n\n const sheetId =\n titleToId.get(title);\n\n if (\n sheetId === undefined ||\n !Array.isArray(\n dataEntry.values\n )\n ) {\n continue;\n }\n\n const info =\n findHeaderInfo(\n dataEntry.values\n );\n\n if (!info) {\n continue;\n }\n\n const oldMap =\n resolutionMaps.get(\n Number(sheetId)\n ) || new Map();\n\n if (\n Number(sheetId) ===\n Number(ids.cuentaMalDigitada)\n ) {\n const fieldIndex =\n indexMatching(\n info.normalized,\n ['Campo']\n );\n\n const detailIndex =\n indexMatching(\n info.normalized,\n ['Detalle']\n );\n\n let currentEmployee = '';\n\n for (\n let rowIndex =\n info.rowIndex + 1;\n rowIndex <\n dataEntry.values.length;\n rowIndex++\n ) {\n const row =\n dataEntry.values[\n rowIndex\n ];\n\n const field =\n normalizeText(\n row[fieldIndex] ?? ''\n );\n\n const detail =\n String(\n row[detailIndex] ?? ''\n );\n\n if (field === 'empleado') {\n currentEmployee =\n normalizeText(detail);\n }\n\n const key =\n currentEmployee && field\n ? `${currentEmployee}|${field}`\n : '';\n\n if (\n key &&\n oldMap.has(key)\n ) {\n while (\n row.length <=\n info.resolutionIndex\n ) {\n row.push('');\n }\n\n row[\n info.resolutionIndex\n ] = oldMap.get(key);\n\n preservedResolutionsCount++;\n }\n }\n\n continue;\n }\n\n for (\n let rowIndex =\n info.rowIndex + 1;\n rowIndex <\n dataEntry.values.length;\n rowIndex++\n ) {\n const row =\n dataEntry.values[\n rowIndex\n ];\n\n const key =\n rowIdentity(\n sheetId,\n row,\n info.normalized\n );\n\n if (\n key &&\n oldMap.has(key)\n ) {\n while (\n row.length <=\n info.resolutionIndex\n ) {\n row.push('');\n }\n\n row[\n info.resolutionIndex\n ] = oldMap.get(key);\n\n preservedResolutionsCount++;\n }\n }\n}\n\nconst desiredSheets =\n Array.isArray(\n prepared\n .createSpreadsheetBody\n ?.sheets\n )\n ? prepared\n .createSpreadsheetBody\n .sheets\n : [];\n\nconst existingIds =\n new Set(\n existingSheets\n .map(\n (sheet) =>\n Number(\n sheet?.properties\n ?.sheetId\n )\n )\n .filter(\n Number.isFinite\n )\n );\n\nconst desiredIds =\n new Set(\n desiredSheets\n .map(\n (sheet) =>\n Number(\n sheet?.properties\n ?.sheetId\n )\n )\n .filter(\n Number.isFinite\n )\n );\n\nconst newlyAddedSheetIds =\n new Set();\n\nconst structureRequests = [\n {\n updateSpreadsheetProperties: {\n properties: {\n title:\n prepared.spreadsheetTitle ||\n prepared\n .createSpreadsheetBody\n ?.properties?.title ||\n spreadsheet.properties\n ?.title ||\n 'Cruce de Cuentas GLM',\n },\n fields: 'title',\n },\n },\n];\n\nif (reusingSheet) {\n const optionalId =\n Number(\n ids.cuentaMalDigitada\n );\n\n if (\n Number.isFinite(optionalId) &&\n existingIds.has(optionalId) &&\n !desiredIds.has(optionalId)\n ) {\n structureRequests.push({\n deleteSheet: {\n sheetId: optionalId,\n },\n });\n\n existingIds.delete(\n optionalId\n );\n }\n\n for (\n let desiredIndex = 0;\n desiredIndex <\n desiredSheets.length;\n desiredIndex++\n ) {\n const desired =\n desiredSheets[\n desiredIndex\n ];\n\n const props =\n desired.properties || {};\n\n const desiredId =\n Number(props.sheetId);\n\n if (\n !Number.isFinite(\n desiredId\n )\n ) {\n continue;\n }\n\n const existing =\n existingSheets.find(\n (sheet) =>\n Number(\n sheet?.properties\n ?.sheetId\n ) === desiredId\n );\n\n if (!existing) {\n const sameTitle =\n existingSheets.find(\n (sheet) =>\n String(\n sheet?.properties\n ?.title || ''\n ) ===\n String(\n props.title || ''\n )\n );\n\n if (\n sameTitle &&\n Number(\n sameTitle\n ?.properties\n ?.sheetId\n ) !== desiredId\n ) {\n structureRequests.push({\n deleteSheet: {\n sheetId:\n Number(\n sameTitle\n .properties\n .sheetId\n ),\n },\n });\n }\n\n structureRequests.push({\n addSheet: {\n properties: {\n ...props,\n // El índice es 0-based. Así \"05 Cuenta Mal Digitada\"\n // vuelve exactamente antes de \"06 Resumen\" cuando reaparece.\n index:\n desiredIndex,\n gridProperties: {\n ...(props\n .gridProperties ||\n {}),\n frozenRowCount: 1,\n },\n },\n },\n });\n\n newlyAddedSheetIds.add(\n desiredId\n );\n\n continue;\n }\n\n const currentTitle =\n String(\n existing.properties\n ?.title || ''\n );\n\n const desiredTitle =\n String(\n props.title || ''\n );\n\n const currentIndex =\n Number(\n existing.properties\n ?.index\n );\n\n const titleNeedsUpdate =\n Boolean(\n desiredTitle &&\n currentTitle !==\n desiredTitle\n );\n\n const indexNeedsUpdate =\n Number.isFinite(\n currentIndex\n ) &&\n currentIndex !==\n desiredIndex;\n\n if (\n titleNeedsUpdate ||\n indexNeedsUpdate\n ) {\n const properties = {\n sheetId:\n desiredId,\n };\n\n const fields = [];\n\n if (titleNeedsUpdate) {\n properties.title =\n desiredTitle;\n fields.push('title');\n }\n\n if (indexNeedsUpdate) {\n properties.index =\n desiredIndex;\n fields.push('index');\n }\n\n structureRequests.push({\n updateSheetProperties: {\n properties,\n fields:\n fields.join(','),\n },\n });\n }\n }\n}\n\nconst originalFormatRequests =\n Array.isArray(\n prepared\n .formatBatchBody\n ?.requests\n )\n ? prepared\n .formatBatchBody\n .requests\n : [];\n\n/*\n * IMPORTANTE AL REUTILIZAR UN REPORTE:\n *\n * \"Actualizar estructura Google Sheet\" se ejecuta ANTES de\n * \"Formatear Google Sheet\". Cuando la estructura elimina una hoja\n * (por ejemplo, \"05 Cuenta Mal Digitada\" deja de ser necesaria),\n * Google elimina automáticamente también los ProtectedRange de esa hoja.\n *\n * Por eso NO debemos intentar borrar después esos protectedRangeId en el\n * batch de formato, porque ya dejaron de existir y Google responde:\n * \"Invalid requests[x].deleteProtectedRange: No protected range with id\".\n *\n * Se identifican aquí todas las hojas que serán eliminadas por el batch\n * de estructura y se excluyen sus protecciones del batch de formato.\n */\nconst sheetIdsScheduledForDeletion =\n new Set(\n structureRequests\n .map((request) =>\n Number(\n request?.deleteSheet?.sheetId\n )\n )\n .filter(Number.isFinite)\n );\n\nconst formatRequests = [];\nconst protectedRangeIdsScheduled =\n new Set();\n\nif (reusingSheet) {\n for (\n const sheet\n of existingSheets\n ) {\n const currentSheetId =\n Number(\n sheet?.properties?.sheetId\n );\n\n // Si esta hoja será eliminada en el paso de estructura, sus\n // protecciones también desaparecerán automáticamente.\n if (\n Number.isFinite(currentSheetId) &&\n sheetIdsScheduledForDeletion.has(\n currentSheetId\n )\n ) {\n continue;\n }\n\n const protections =\n Array.isArray(\n sheet.protectedRanges\n )\n ? sheet.protectedRanges\n : [];\n\n for (\n const protection\n of protections\n ) {\n const description =\n String(\n protection.description ||\n ''\n );\n\n const protectedRangeId =\n Number(\n protection\n .protectedRangeId\n );\n\n if (\n description.startsWith(\n 'Protección GLM'\n ) &&\n Number.isFinite(\n protectedRangeId\n ) &&\n !protectedRangeIdsScheduled.has(\n protectedRangeId\n )\n ) {\n protectedRangeIdsScheduled.add(\n protectedRangeId\n );\n\n formatRequests.push({\n deleteProtectedRange: {\n protectedRangeId,\n },\n });\n }\n }\n }\n}\n\n\n/*\n * Limpieza visual al reutilizar un reporte.\n *\n * values:batchClear elimina el CONTENIDO de las filas antiguas, pero Google\n * Sheets conserva su formato. Si una ejecución anterior tenía cientos de\n * discrepancias y la nueva tiene pocas, podían quedar celdas vacías con el\n * fondo rojo/amarillo aplicado por la ejecución anterior.\n *\n * La corrección es deliberadamente limitada: solamente se limpia el\n * background de las filas que quedan DESPUÉS de la última fila escrita por\n * la nueva ejecución. No se toca ninguna fila vigente, encabezado,\n * Resolución preservada, protección, borde, ancho, fórmula ni lógica del\n * cruce.\n */\nif (reusingSheet) {\n for (\n const dataEntry\n of valueBatchBody.data || []\n ) {\n const title =\n titleFromRange(\n dataEntry.range\n );\n\n const currentSheetId =\n Number(\n titleToId.get(title)\n );\n\n if (\n !Number.isFinite(\n currentSheetId\n ) ||\n sheetIdsScheduledForDeletion.has(\n currentSheetId\n ) ||\n newlyAddedSheetIds.has(\n currentSheetId\n )\n ) {\n continue;\n }\n\n const existingSheet =\n sheetById.get(\n currentSheetId\n );\n\n if (!existingSheet) {\n continue;\n }\n\n const currentValues =\n Array.isArray(\n dataEntry.values\n )\n ? dataEntry.values\n : [];\n\n const writtenRowCount =\n currentValues.length;\n\n const writtenColumnCount =\n currentValues.reduce(\n (maximum, row) =>\n Math.max(\n maximum,\n Array.isArray(row)\n ? row.length\n : 0\n ),\n 0\n );\n\n const gridRowCount =\n Number(\n existingSheet\n ?.properties\n ?.gridProperties\n ?.rowCount || 0\n );\n\n if (\n writtenRowCount <= 0 ||\n writtenColumnCount <= 0 ||\n !Number.isFinite(\n gridRowCount\n ) ||\n gridRowCount <=\n writtenRowCount\n ) {\n continue;\n }\n\n formatRequests.push({\n repeatCell: {\n range: {\n sheetId:\n currentSheetId,\n startRowIndex:\n writtenRowCount,\n endRowIndex:\n gridRowCount,\n startColumnIndex: 0,\n endColumnIndex:\n writtenColumnCount,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: {\n red: 1,\n green: 1,\n blue: 1,\n },\n },\n },\n fields:\n 'userEnteredFormat.backgroundColor',\n },\n });\n }\n}\n\nfor (\n const request\n of originalFormatRequests\n) {\n if (\n reusingSheet &&\n request.mergeCells\n ) {\n const mergeSheetId =\n Number(\n request\n .mergeCells\n ?.range\n ?.sheetId\n );\n\n if (\n !newlyAddedSheetIds.has(\n mergeSheetId\n )\n ) {\n continue;\n }\n }\n\n formatRequests.push(\n request\n );\n}\n\nconst clearRanges =\n (valueBatchBody.data || [])\n .map(\n (entry) =>\n entry.range\n )\n .filter(Boolean);\n\nreturn [\n {\n json: {\n ok: true,\n stage:\n 'google_sheet_destino_preparado',\n spreadsheetId,\n reportUrl:\n `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit`,\n historyExists,\n reportUpdated:\n historyExists,\n reusingSheet,\n existingHistoricalId:\n report.id\n ? String(report.id)\n : '',\n reportIdentity:\n String(\n report.report_identity ||\n ''\n ),\n previousReport:\n report,\n preservedResolutionsCount,\n valueBatchBody,\n structureBatchBody: {\n requests:\n structureRequests,\n },\n clearBatchBody: {\n ranges: clearRanges,\n },\n formatBatchBody: {\n requests:\n formatRequests,\n },\n originalResponse:\n prepared.originalResponse ||\n {},\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2528,
512
],
"id": "4fab55a2-a11e-4fda-ae21-93147793a8ee",
"name": "Preparar actualización Google Sheet"
},
{
"parameters": {
"method": "POST",
"url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $('Preparar actualización Google Sheet').first().json.spreadsheetId + ':batchUpdate' }}",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ $('Preparar actualización Google Sheet').first().json.structureBatchBody }}",
"options": {
"timeout": 60000
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
2768,
512
],
"id": "cc3b923a-6c4d-47f0-902e-98f6123947b9",
"name": "Actualizar estructura Google Sheet",
"retryOnFail": true,
"credentials": {
"googleOAuth2Api": {
"id": "dQ1MJSJSWcoWYcb8",
"name": "Google account - Isaac Producción"
}
}
},
{
"parameters": {
"method": "POST",
"url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $('Preparar actualización Google Sheet').first().json.spreadsheetId + '/values:batchClear' }}",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ $('Preparar actualización Google Sheet').first().json.clearBatchBody }}",
"options": {
"timeout": 60000
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
3008,
512
],
"id": "407c9c50-6e67-4e06-ad8f-def748f51f5b",
"name": "Limpiar Google Sheet",
"retryOnFail": true,
"credentials": {
"googleOAuth2Api": {
"id": "dQ1MJSJSWcoWYcb8",
"name": "Google account - Isaac Producción"
}
}
},
{
"parameters": {
"method": "POST",
"url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $('Preparar actualización Google Sheet').first().json.spreadsheetId + '/values:batchUpdate' }}",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ $('Preparar actualización Google Sheet').first().json.valueBatchBody }}",
"options": {
"timeout": 60000
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
3248,
512
],
"id": "e9262d82-fea5-46b8-a46c-e44503058b3d",
"name": "Escribir Google Sheet",
"retryOnFail": true,
"credentials": {
"googleOAuth2Api": {
"id": "dQ1MJSJSWcoWYcb8",
"name": "Google account - Isaac Producción"
}
}
},
{
"parameters": {
"method": "POST",
"url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $('Preparar actualización Google Sheet').first().json.spreadsheetId + ':batchUpdate' }}",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ $('Preparar actualización Google Sheet').first().json.formatBatchBody }}",
"options": {
"timeout": 60000
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
3488,
512
],
"id": "eeff3ec4-35a7-4643-8e44-8ea5644247e1",
"name": "Formatear Google Sheet",
"retryOnFail": true,
"credentials": {
"googleOAuth2Api": {
"id": "dQ1MJSJSWcoWYcb8",
"name": "Google account - Isaac Producción"
}
}
},
{
"parameters": {
"jsCode": "const context =\n $('Preparar actualización Google Sheet').first().json || {};\n\nconst spreadsheetId =\n context.spreadsheetId;\n\nif (!spreadsheetId) {\n throw new Error(\n 'No se recibió spreadsheetId para configurar permisos.'\n );\n}\n\nconst allowedEmails = [\n 'iaracena@gomezleemarketing.com',\n 'ymadera@gomezleemarketing.com',\n 'mgomez@gomezleemarketing.com',\n 'jgomez@gomezleemarketing.com',\n 'vparamo@gomezleemarketing.com',\n 'adminregional@gomezleemarketing.com'\n];\n\nreturn allowedEmails.map((email) => ({\n json: {\n spreadsheetId,\n email,\n permissionBody: {\n type: 'user',\n role: 'writer',\n emailAddress: email,\n },\n reportUpdated:\n context.reportUpdated === true,\n },\n}));"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
3728,
512
],
"id": "df55c597-5e31-4237-8eac-cd2598993e20",
"name": "Preparar permisos Google Sheet"
},
{
"parameters": {
"method": "POST",
"url": "={{ 'https://www.googleapis.com/drive/v3/files/' + $json.spreadsheetId + '/permissions?sendNotificationEmail=false' }}",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ $json.permissionBody }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
3968,
512
],
"id": "083436a6-f137-4679-aa9c-658cece3301b",
"name": "Compartir Google Sheet",
"credentials": {
"googleOAuth2Api": {
"id": "dQ1MJSJSWcoWYcb8",
"name": "Google account - Isaac Producción"
}
},
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "const cruce = $('Cruzar Nómina vs Banco').first().json || {};\nconst sheetContext =\n $('Preparar actualización Google Sheet').first().json || {};\n\nconst metadata = cruce.metadata || {};\nconst summary = cruce.summary || {};\nconst debug = cruce.debug || {};\n\nconst spreadsheetId = sheetContext.spreadsheetId || cruce.spreadsheetId || '';\nconst reportUrl =\n sheetContext.reportUrl ||\n (spreadsheetId ? `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit` : null);\n\nfunction toNumber(value) {\n const parsed = Number(value);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction buildPeriodKey(periodMetadata) {\n const country = periodMetadata.country || 'HN';\n const year = periodMetadata.year || '';\n const month = String(periodMetadata.month || '').padStart(2, '0');\n const periodType = periodMetadata.period_type || 'periodo';\n return `${country}-${year}-${month}-${periodType}`;\n}\n\nconst discrepancias = toNumber(summary.discrepancias);\nconst bancoSinNomina = toNumber(summary.bancoSinNomina);\nconst nominaSinCuenta = toNumber(summary.nominaSinCuenta);\nconst diferenciasNombreBanco =\n toNumber(summary.diferenciasNombreBanco);\nconst bancoSinBamboo =\n toNumber(summary.bancoSinBamboo);\nconst pendientes = toNumber(summary.pendientes) || (\n discrepancias +\n bancoSinNomina +\n nominaSinCuenta +\n diferenciasNombreBanco\n);\n\n// Banco sin Bamboo es una revisión independiente del cruce principal.\nconst requiereRevision =\n pendientes > 0 || bancoSinBamboo > 0;\n\n// La app final solo manejará Pendiente revisión y Resuelto.\nconst estado = requiereRevision\n ? 'pendiente_revision'\n : 'resuelto';\n\nconst reportIdentity = [\n 'HN',\n String(metadata.year || ''),\n String(metadata.month || '').padStart(2, '0'),\n String(metadata.period_type || '').trim().toLowerCase(),\n].join('-');\n\nconst reportUpdated =\n sheetContext.reportUpdated === true;\n\nconst preservedResolutionsCount =\n Number(\n sheetContext.preservedResolutionsCount || 0\n );\n\nconst payload = {\n source_app: metadata.source_app || 'cruce-cuentas-glm-honduras',\n country: metadata.country || 'HN',\n country_name: metadata.country_name || 'Honduras',\n client_request_id: metadata.client_request_id || null,\n report_identity: reportIdentity,\n\n year: toNumber(metadata.year),\n month: toNumber(metadata.month),\n period_type: metadata.period_type || '',\n period_label: metadata.period_label || '',\n period_start: metadata.period_start || null,\n period_end: metadata.period_end || null,\n period_key: buildPeriodKey(metadata),\n\n payroll_file_name: metadata.payroll_file_name || '',\n bank_file_names: metadata.bank_file_names || [],\n\n coincidencias: toNumber(summary.coincidencias),\n discrepancias,\n\n banco_sin_bamboo: bancoSinBamboo,\n detalle_banco_sin_bamboo:\n Array.isArray(cruce.bankWithoutBamboo)\n ? cruce.bankWithoutBamboo\n : [],\n banco_sin_nomina: bancoSinNomina,\n nomina_sin_cuenta: nominaSinCuenta,\n nomina_sin_bamboo: 0,\n bamboo_sin_nomina: 0,\n\n filas_nomina_validas: toNumber(summary.filasNominaValidas),\n cuentas_nomina_agrupadas: toNumber(summary.cuentasNominaAgrupadas),\n transacciones_banco: toNumber(summary.transaccionesBanco),\n cuentas_banco_agrupadas: toNumber(summary.cuentasBancoAgrupadas),\n\n total_nomina: toNumber(summary.totalNomina),\n total_banco: toNumber(summary.totalBanco),\n diferencia_total: toNumber(summary.diferenciaTotal),\n\n report_url: reportUrl,\n spreadsheet_id: spreadsheetId,\n estado,\n\n ejecutado_por_nombre: metadata.requested_by_name || 'Usuario GLM',\n ejecutado_por_email: metadata.requested_by_email || '',\n\n metadata: {\n ...metadata,\n report_identity: reportIdentity,\n report_updated: reportUpdated,\n preserved_resolutions_count: preservedResolutionsCount,\n diferencias_nombre_banco:\n diferenciasNombreBanco,\n banco_sin_bamboo:\n bancoSinBamboo,\n pendientes_cruce_principal:\n pendientes,\n requiere_revision:\n requiereRevision,\n },\n summary,\n debug: {\n sheet_summaries: debug.sheet_summaries || [],\n attached_supplements: debug.attached_supplements || [],\n unattached_supplements: debug.unattached_supplements || [],\n bank_name_differences_preview:\n debug.bank_name_differences_preview || [],\n bamboo_matches:\n debug.bamboo_matches || [],\n bamboo_excluded_payments:\n debug.bamboo_excluded_payments || [],\n banco_sin_bamboo:\n cruce.bankWithoutBamboo || [],\n },\n};\n\n\nif (\n reportUpdated &&\n estado !== 'resuelto'\n) {\n payload.resuelto_por_nombre = null;\n payload.resuelto_por_email = null;\n payload.resuelto_en = null;\n payload.comentario_resolucion = null;\n}\n\nreturn [\n {\n json: {\n ...cruce,\n reportUpdated,\n preservedResolutionsCount,\n existingHistoricalId: sheetContext.existingHistoricalId || '',\n reportIdentity,\n supabaseTable: 'cruces_cuentas_gt_reportes',\n supabasePayload: payload,\n reportUrl,\n spreadsheetId,\n },\n },\n];\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
4208,
512
],
"id": "a2397022-6257-4617-88e4-1a5859428e2c",
"name": "Preparar histórico Supabase"
},
{
"parameters": {
"method": "POST",
"url": "https://dbit.digitalcompass.agency/rest/v1/cruces_cuentas_gt_reportes?on_conflict=report_identity",
"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=representation"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ $json.supabasePayload }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
4448,
512
],
"id": "0a1e75d6-ec4e-454d-9eb9-34ef27517ea8",
"name": "Insertar histórico Supabase",
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "const prepared = $('Preparar Google Sheet').first().json || {};\nconst sheetContext = $('Preparar actualización Google Sheet').first().json || {};\n\nconst original =\n prepared.originalResponse ||\n prepared.original_response ||\n prepared.response ||\n {};\n\nconst spreadsheetId = sheetContext.spreadsheetId || '';\nconst reportUrl =\n sheetContext.reportUrl ||\n (spreadsheetId ? `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit` : null);\n\n\nfunction collectHistoricalObjects(value, output) {\n if (Array.isArray(value)) {\n for (const item of value) collectHistoricalObjects(item, output);\n return;\n }\n\n if (!value || typeof value !== 'object') return;\n\n output.push(value);\n\n if (Array.isArray(value.data)) {\n for (const item of value.data) collectHistoricalObjects(item, output);\n }\n}\n\nconst historicalObjects = [];\ntry {\n for (const item of $items('Insertar histórico Supabase')) {\n collectHistoricalObjects(item.json, historicalObjects);\n }\n} catch (error) {\n // Si Supabase no devolvió representación, el reporte sigue respondiendo\n // igual que antes; únicamente executionId quedará vacío.\n}\n\nconst insertedHistoricalReport =\n historicalObjects.find((item) => item && item.id) || null;\n\nconst executionId = insertedHistoricalReport?.id\n ? String(insertedHistoricalReport.id)\n : '';\n\nreturn [\n {\n json: {\n ok: original.ok ?? true,\n message: reportUrl\n ? (sheetContext.reportUpdated === true\n ? `Reporte existente actualizado correctamente. Se conservaron ${Number(sheetContext.preservedResolutionsCount || 0)} resolución(es) previamente registradas.`\n : 'Cruce procesado correctamente. Google Sheet generado.')\n : 'Cruce procesado correctamente, pero no se recibió URL del Google Sheet.',\n stage: reportUrl ? 'cruce_completado_con_reporte' : 'cruce_completado_sin_reporte',\n errors: original.errors || [],\n metadata: original.metadata || {},\n summary: original.summary || {},\n rows: original.rows || [],\n bankWithoutBamboo:\n original.bankWithoutBamboo || [],\n bambooSummary:\n original.bambooSummary || {},\n reportUrl,\n executionId: executionId || undefined,\n reportUpdated: sheetContext.reportUpdated === true,\n reusingSheet: sheetContext.reusingSheet === true,\n preservedResolutionsCount: Number(sheetContext.preservedResolutionsCount || 0),\n clientRequestId: original.metadata?.client_request_id || '',\n googleSheet: {\n spreadsheetId,\n spreadsheetUrl: reportUrl,\n },\n debug: {\n rows_returned: Array.isArray(original.rows) ? original.rows.length : 0,\n coincidencias: original.summary?.coincidencias ?? 0,\n discrepancias: original.summary?.discrepancias ?? 0,\n bancoSinBamboo:\n original.summary?.bancoSinBamboo ?? 0,\n bancoSinBambooRows:\n Array.isArray(original.bankWithoutBamboo)\n ? original.bankWithoutBamboo.length\n : 0,\n },\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
4688,
432
],
"id": "6776e4cf-01e3-4ece-878a-3b48cef3e2ca",
"name": "Preparar respuesta final"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{\n(() => {\n const data = $json || {};\n\n const original =\n data.originalResponse ||\n data.original_response ||\n data.response ||\n data.cruceResponse ||\n data.cruce_response ||\n data;\n\n const summary = original.summary || data.summary || {};\n const rows = original.rows || data.rows || [];\n const bankWithoutBamboo =\n original.bankWithoutBamboo ||\n data.bankWithoutBamboo ||\n [];\n const bambooSummary =\n original.bambooSummary ||\n data.bambooSummary ||\n {};\n\n const reportUrl =\n data.reportUrl ||\n data.report_url ||\n data.googleSheetUrl ||\n data.google_sheet_url ||\n data.spreadsheetUrl ||\n data.spreadsheet_url ||\n original.reportUrl ||\n original.report_url ||\n null;\n\n return {\n ok: original.ok ?? data.ok ?? true,\n message: original.message || data.message || (reportUrl\n ? 'Cruce procesado correctamente. Google Sheet generado.'\n : 'Cruce procesado correctamente.'),\n stage: reportUrl ? 'cruce_completado_con_reporte' : 'cruce_completado',\n errors: original.errors || data.errors || [],\n metadata: original.metadata || data.metadata || {},\n summary,\n rows,\n bankWithoutBamboo,\n bambooSummary,\n reportUrl,\n executionId: original.executionId || data.executionId || null,\n reportUpdated: original.reportUpdated ?? data.reportUpdated ?? false,\n reusingSheet: original.reusingSheet ?? data.reusingSheet ?? false,\n preservedResolutionsCount: Number(original.preservedResolutionsCount ?? data.preservedResolutionsCount ?? 0),\n clientRequestId: original.clientRequestId || data.clientRequestId || original.metadata?.client_request_id || data.metadata?.client_request_id || null,\n debug: {\n source_stage: data.stage || null,\n rows_returned:\n Array.isArray(rows) ? rows.length : 0,\n banco_sin_bamboo_rows:\n Array.isArray(bankWithoutBamboo)\n ? bankWithoutBamboo.length\n : 0,\n report_url_found: Boolean(reportUrl),\n },\n };\n})()\n}}",
"options": {
"responseCode": 200,
"responseHeaders": {
"entries": [
{
"name": "Content-Type",
"value": "application/json"
}
]
}
}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [
4928,
432
],
"id": "1f4078d3-7294-4173-8a97-416b2c83f7e9",
"name": "Respond to Webhook"
},
{
"parameters": {
"jsCode": "const prepared = $('Preparar histórico Supabase').first().json || {};\nconst payload = prepared.supabasePayload || {};\nconst metadata = payload.metadata || {};\nconst summary = payload.summary || {};\nconst reportUpdated = prepared.reportUpdated === true;\n\nfunction escapeHtml(value) {\n return String(value ?? '')\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#039;');\n}\n\nfunction numberValue(value) {\n const parsed = Number(value);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction formatGeneratedAt(date) {\n const timeZone = 'America/Tegucigalpa';\n\n const dateLabel = new Intl.DateTimeFormat('es-HN', {\n timeZone,\n day: 'numeric',\n month: 'long',\n year: 'numeric',\n }).format(date);\n\n const timeLabel = new Intl.DateTimeFormat('es-HN', {\n timeZone,\n hour: 'numeric',\n minute: '2-digit',\n hour12: true,\n }).format(date);\n\n const hour24Raw = new Intl.DateTimeFormat('en-US', {\n timeZone,\n hour: '2-digit',\n hour12: false,\n }).format(date);\n\n const hour24 =\n Number(String(hour24Raw).replace(/\\D/g, '')) % 24;\n\n const hourArticle =\n hour24 === 1 || hour24 === 13\n ? 'a la'\n : 'a las';\n\n return `${dateLabel} ${hourArticle} ${timeLabel}`;\n}\n\nfunction metricCard(label, value, accent) {\n return `\n <td width=\"20%\" valign=\"top\" style=\"padding:0 5px;\">\n <table\n role=\"presentation\"\n width=\"100%\"\n cellspacing=\"0\"\n cellpadding=\"0\"\n border=\"0\"\n style=\"background:#FFFFFF;border:1px solid #D0D0D0;border-top:3px solid ${accent};border-radius:8px;\"\n >\n <tr>\n <td style=\"padding:14px 12px 13px;\">\n <div\n style=\"font-family:Arial,sans-serif;font-size:9px;line-height:1.25;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:#4F758B;min-height:25px;\"\n >\n ${escapeHtml(label)}\n </div>\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:27px;line-height:1;font-weight:700;color:#4A4A4A;margin-top:8px;\"\n >\n ${numberValue(value).toLocaleString('en-US')}\n </div>\n </td>\n </tr>\n </table>\n </td>\n `;\n}\n\nconst countryCode = 'HN';\nconst countryName = 'Honduras';\n\nconst periodLabel =\n payload.period_label ||\n metadata.period_label ||\n [\n payload.year || metadata.year,\n payload.month || metadata.month,\n payload.period_type || metadata.period_type,\n ]\n .filter(Boolean)\n .join(' · ') ||\n 'Período no especificado';\n\nconst executedByName =\n payload.ejecutado_por_nombre ||\n metadata.requested_by_name ||\n 'Usuario GLM';\n\nconst executedByEmail =\n payload.ejecutado_por_email ||\n metadata.requested_by_email ||\n '';\n\nconst reportUrl =\n payload.report_url ||\n prepared.reportUrl ||\n '';\n\nconst discrepancias = numberValue(\n payload.discrepancias ??\n summary.discrepancias\n);\n\nconst bancoSinNomina = numberValue(\n payload.banco_sin_nomina ??\n summary.bancoSinNomina\n);\n\nconst bancoSinBamboo = numberValue(\n payload.banco_sin_bamboo ??\n summary.bancoSinBamboo\n);\n\nconst diferenciasNombreBanco = numberValue(\n metadata.diferencias_nombre_banco ??\n summary.diferenciasNombreBanco\n);\n\nconst nominaSinCuenta = numberValue(\n payload.nomina_sin_cuenta ??\n summary.nominaSinCuenta\n);\n\nconst coincidencias = numberValue(\n payload.coincidencias ??\n summary.coincidencias\n);\n\nconst generatedAt = new Date();\nconst generatedAtLabel =\n formatGeneratedAt(generatedAt);\n\nconst statusLabel =\n String(payload.estado || '').toLowerCase() === 'resuelto'\n ? 'Resuelto'\n : 'Pendiente de revisión';\n\nconst logoUrl =\n 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png';\n\nconst flagUrl =\n 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/Bandera%20de%20Honduras.webp';\n\nconst subject = reportUpdated\n ? `Reporte actualizado - Cruce de Cuentas ${countryName} - ${periodLabel}`\n : `Nuevo reporte generado - Cruce de Cuentas ${countryName} - ${periodLabel}`;\n\n/*\n * Todos son destinatarios directos.\n * Nadie se envía en CC.\n */\n\nconst to = [\n 'iaracena@gomezleemarketing.com',\n].join(',');\n\n\n// Paleta oficial GLM.\nconst metricCards = [\n metricCard(\n 'Discrepancias',\n discrepancias,\n '#FF6A13'\n ),\n metricCard(\n 'Banco sin nómina',\n bancoSinNomina,\n '#FF8C00'\n ),\n metricCard(\n 'Banco sin Bamboo',\n bancoSinBamboo,\n '#E87722'\n ),\n metricCard(\n 'Diferencias nombre banco',\n diferenciasNombreBanco,\n '#5B7F95'\n ),\n metricCard(\n 'Nómina sin cuenta',\n nominaSinCuenta,\n '#4F758B'\n ),\n].join('');\n\nconst htmlBody = `\n<!doctype html>\n<html lang=\"es\">\n\n<head>\n <meta charset=\"UTF-8\">\n <meta\n name=\"viewport\"\n content=\"width=device-width, initial-scale=1.0\"\n >\n <title>${escapeHtml(subject)}</title>\n</head>\n\n<body\n style=\"margin:0;padding:0;background:#F5F5F5;font-family:Arial,sans-serif;color:#4A4A4A;\"\n>\n\n <table\n role=\"presentation\"\n width=\"100%\"\n cellspacing=\"0\"\n cellpadding=\"0\"\n border=\"0\"\n style=\"width:100%;background:#F5F5F5;padding:28px 12px;\"\n >\n <tr>\n <td align=\"center\">\n\n <table\n role=\"presentation\"\n width=\"100%\"\n cellspacing=\"0\"\n cellpadding=\"0\"\n border=\"0\"\n style=\"max-width:760px;background:#FFFFFF;border:1px solid #E5E5E5;border-radius:16px;overflow:hidden;\"\n >\n\n <!-- Acento superior GLM -->\n <tr>\n <td\n style=\"height:6px;line-height:6px;font-size:0;background:#6CC24A;\"\n >\n &nbsp;\n </td>\n </tr>\n\n <!-- Header -->\n <tr>\n <td\n style=\"padding:24px 30px 18px;background:#FFFFFF;\"\n >\n\n <table\n role=\"presentation\"\n width=\"100%\"\n cellspacing=\"0\"\n cellpadding=\"0\"\n border=\"0\"\n >\n <tr>\n <td valign=\"middle\">\n <img\n src=\"${logoUrl}\"\n alt=\"GomezLee Marketing\"\n style=\"display:block;width:188px;max-width:188px;height:auto;border:0;\"\n >\n </td>\n\n <td\n align=\"right\"\n valign=\"middle\"\n >\n <span\n style=\"display:inline-block;border:1px solid #D0D0D0;color:#4F758B;border-radius:999px;padding:7px 12px;font-family:Arial,sans-serif;font-size:10px;font-weight:700;letter-spacing:.45px;\"\n >\n ${reportUpdated ? 'REPORTE ACTUALIZADO' : 'REPORTE GENERADO'}\n </span>\n </td>\n </tr>\n </table>\n\n <div\n style=\"height:1px;background:#E5E5E5;margin:20px 0 18px;\"\n ></div>\n\n <table\n role=\"presentation\"\n cellspacing=\"0\"\n cellpadding=\"0\"\n border=\"0\"\n >\n <tr>\n\n <td\n valign=\"middle\"\n style=\"padding-right:12px;\"\n >\n <img\n src=\"${flagUrl}\"\n alt=\"Bandera de ${escapeHtml(countryName)}\"\n width=\"42\"\n style=\"display:block;width:42px;height:auto;border:1px solid #D0D0D0;\"\n >\n </td>\n\n <td valign=\"middle\">\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:25px;line-height:1.2;font-weight:700;color:#4F758B;\"\n >\n Cruce de Cuentas · ${escapeHtml(countryName)}\n </div>\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:13px;line-height:1.5;color:#6B8FA3;margin-top:5px;\"\n >\n ${escapeHtml(periodLabel)}\n </div>\n\n </td>\n\n </tr>\n </table>\n\n </td>\n </tr>\n\n <!-- Introducción -->\n <tr>\n <td style=\"padding:6px 30px 18px;\">\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:14px;line-height:1.7;color:#4A4A4A;\"\n >\n ${reportUpdated ? 'Se actualizó el reporte existente del Portal de Cruce de Cuentas GLM conservando las resoluciones previamente registradas que continúan correspondiendo a los mismos casos.' : 'Se completó una nueva ejecución del Portal de Cruce de Cuentas GLM. Este es el resumen del reporte generado.'}\n </div>\n\n </td>\n </tr>\n\n <!-- KPIs -->\n <tr>\n <td style=\"padding:6px 25px 6px;\">\n\n <table\n role=\"presentation\"\n width=\"100%\"\n cellspacing=\"0\"\n cellpadding=\"0\"\n border=\"0\"\n >\n <tr>\n ${metricCards}\n </tr>\n </table>\n\n </td>\n </tr>\n\n <!-- Información del reporte -->\n <tr>\n <td style=\"padding:22px 30px 0;\">\n\n <table\n role=\"presentation\"\n width=\"100%\"\n cellspacing=\"0\"\n cellpadding=\"0\"\n border=\"0\"\n style=\"background:#FFFFFF;border:1px solid #D0D0D0;border-radius:10px;overflow:hidden;\"\n >\n\n <tr>\n\n <td\n width=\"50%\"\n valign=\"top\"\n style=\"padding:18px 20px;border-bottom:1px solid #E5E5E5;\"\n >\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:10px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:#4F758B;\"\n >\n Ejecutado por\n </div>\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:15px;font-weight:700;color:#4A4A4A;margin-top:7px;\"\n >\n ${escapeHtml(executedByName)}\n </div>\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:12px;color:#6B8FA3;margin-top:4px;\"\n >\n ${escapeHtml(\n executedByEmail ||\n 'Correo no disponible'\n )}\n </div>\n\n </td>\n\n <td\n width=\"50%\"\n valign=\"top\"\n style=\"padding:18px 20px;border-bottom:1px solid #E5E5E5;border-left:1px solid #E5E5E5;\"\n >\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:10px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:#4F758B;\"\n >\n País\n </div>\n\n <table\n role=\"presentation\"\n cellspacing=\"0\"\n cellpadding=\"0\"\n border=\"0\"\n style=\"margin-top:7px;\"\n >\n <tr>\n\n <td\n valign=\"middle\"\n style=\"padding-right:9px;\"\n >\n <img\n src=\"${flagUrl}\"\n alt=\"Bandera de ${escapeHtml(countryName)}\"\n width=\"30\"\n style=\"display:block;width:30px;height:auto;border:1px solid #D0D0D0;\"\n >\n </td>\n\n <td valign=\"middle\">\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:15px;font-weight:700;color:#4A4A4A;\"\n >\n ${escapeHtml(countryName)}\n </div>\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:12px;color:#6B8FA3;margin-top:2px;\"\n >\n ${escapeHtml(\n countryCode || '—'\n )}\n </div>\n\n </td>\n\n </tr>\n </table>\n\n </td>\n\n </tr>\n\n <tr>\n\n <td\n width=\"50%\"\n valign=\"top\"\n style=\"padding:18px 20px;\"\n >\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:10px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:#4F758B;\"\n >\n Período\n </div>\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:15px;font-weight:700;color:#4A4A4A;margin-top:7px;\"\n >\n ${escapeHtml(periodLabel)}\n </div>\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:12px;color:#6B8FA3;margin-top:4px;\"\n >\n ${escapeHtml(generatedAtLabel)}\n </div>\n\n </td>\n\n <td\n width=\"50%\"\n valign=\"top\"\n style=\"padding:18px 20px;border-left:1px solid #E5E5E5;\"\n >\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:10px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:#4F758B;\"\n >\n Estado inicial\n </div>\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:15px;font-weight:700;color:#4A4A4A;margin-top:7px;\"\n >\n ${escapeHtml(statusLabel)}\n </div>\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:12px;color:#6B8FA3;margin-top:4px;\"\n >\n Coincidencias:\n ${coincidencias.toLocaleString('en-US')}\n </div>\n\n </td>\n\n </tr>\n\n </table>\n\n </td>\n </tr>\n\n <!-- CTA -->\n ${\n reportUrl\n ? `\n <tr>\n <td\n align=\"center\"\n style=\"padding:28px 30px 10px;\"\n >\n\n <a\n href=\"${escapeHtml(reportUrl)}\"\n target=\"_blank\"\n style=\"display:inline-block;background:#6CC24A;color:#FFFFFF;text-decoration:none;font-family:Arial,sans-serif;font-size:14px;font-weight:700;padding:13px 24px;border-radius:8px;\"\n >\n Abrir reporte en Google Sheets\n </a>\n\n </td>\n </tr>\n `\n : ''\n }\n\n <!-- Footer -->\n <tr>\n <td style=\"padding:20px 30px 27px;\">\n\n <div\n style=\"height:1px;background:#D0D0D0;margin-bottom:16px;\"\n ></div>\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:11px;line-height:1.6;color:#6B8FA3;text-align:center;\"\n >\n Notificación automática · Portal Cruce de Cuentas GLM\n <br>\n Los casos de revisión deben gestionarse directamente en el reporte generado.\n </div>\n\n </td>\n </tr>\n\n </table>\n\n </td>\n </tr>\n </table>\n\n</body>\n</html>\n`;\n\nreturn [\n {\n json: {\n to,\n subject,\n htmlBody,\n\n notification: {\n country: countryCode,\n countryName,\n flagUrl,\n periodLabel,\n executedByName,\n executedByEmail,\n generatedAt: generatedAt.toISOString(),\n reportUrl,\n\n metrics: {\n discrepancias,\n bancoSinNomina,\n bancoSinBamboo,\n diferenciasNombreBanco,\n nominaSinCuenta,\n coincidencias,\n },\n },\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
4688,
672
],
"id": "e3ee2bf6-38a8-4e38-8f13-b706aa667650",
"name": "Preparar correo reporte generado"
},
{
"parameters": {
"sendTo": "={{$json.to}}",
"subject": "={{$json.subject}}",
"message": "={{ $json.htmlBody }}",
"options": {
"appendAttribution": false
}
},
"type": "n8n-nodes-base.gmail",
"typeVersion": 2.2,
"position": [
4928,
672
],
"id": "f29d8f4e-5156-41a6-ae07-874de0e8bd83",
"name": "Enviar correo reporte generado",
"webhookId": "4bcdbd7b-503d-46ac-aeaf-9207f59f01e7",
"credentials": {
"gmailOAuth2": {
"id": "UDcO1FLJqA453V2D",
"name": "Gmail account 3"
}
},
"onError": "continueRegularOutput"
},
{
"parameters": {
"content": "# 🇭🇳 CRUCE DE CUENTAS — HONDURAS\n\nFlujo principal para una nómina quincenal de Honduras + todos los planos bancarios BAC del período + BambooHR.\n\n- Monedas: HNL y USD.\n- BambooHR: `onlyCurrent=false`, filtrado por país/localidad Honduras.\n- Q1: días 1–15. Q2: días 16–último día real del mes.\n- Los meses/años se calculan dinámicamente, incluidos años bisiestos.\n- La nómina usa las hojas operativas validadas de GLM Honduras y omite hojas auxiliares/copias para evitar duplicados.\n\n\n### Correcciones de conciliación HN · 08-09-2026\n- Tolerancia monetaria operativa: ±0.05 por redondeos bancarios.\n- Banco sin Nómina se mantiene por transacción, sin agrupar pagos distintos.\n- Nombre en archivo truncado por un carácter al final no genera falso positivo.\n- BambooHR prioriza el nombre legal del cuentahabiente y perfiles vigentes en el período.\n- La cuenta mal digitada se determina por la cuenta real de la nómina vs. la cuenta real del banco.\n",
"height": 260,
"width": 520
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-1088,
80
],
"id": "8a0379c3-9a89-4428-a42d-53f18a7b3250",
"name": "📌 HONDURAS · V1"
},
{
"parameters": {
"content": "# 🏦 REGLA DE PLANOS — HONDURAS\n\nSe procesan todos los CSV `Consulta Detalle de Envío_HON_...` cargados. Se reconocen planes AR94 (HNL) y AR95US (USD), además de la moneda informada dentro del archivo.\n\nEl período se filtra por la fecha de aplicación de cada transacción: Q1 = 1–15 y Q2 = 16–fin de mes. El cruce se realiza por persona, cuenta, moneda y monto, con una segunda pasada controlada para detectar diferencias o posibles cuentas mal digitadas.",
"height": 300,
"width": 700
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
0,
0
],
"id": "8640e03f-2128-4eb4-aae8-a2ee3ec221d3",
"name": "📌 REGLA BANCO HN"
},
{
"parameters": {
"content": "# 📄 ARCHIVO DE NÓMINA — HONDURAS\n\nLa app recibe un solo Excel por quincena, por ejemplo `Nomina 7.1 2026 GLM HN.xlsx` o `Nomina 7.2 2026 GLM HN.xlsx`.\n\nEl flujo extrae únicamente las hojas de pago validadas (Colgate, POP Helper, Motorola, Nestlé, Whirlpool, FF Regional y GLM) y normaliza `NOMBRE`, `Identidad`, `# de Cuenta Bac` y `NETO A PAGAR` / `Total a Pagar`. Las hojas de copia/auxiliares no se contabilizan.",
"height": 240,
"width": 620
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-608,
928
],
"id": "ce7cb681-290e-404b-a3de-fa879e57fb2e",
"name": "📌 ARCHIVOS NÓMINA HN"
},
{
"parameters": {
"content": "# 📊 SALIDA\n\nConserva la misma estructura de reportes de GT/TT/PA/SV, reutiliza el Google Sheet del mismo período si existe, actualiza Supabase y envía correo tanto en creación como en actualización.\n\nLos valores se escriben en Google Sheets con `RAW` para preservar las cuentas bancarias como texto exacto, incluyendo ceros iniciales y evitando notación científica.",
"height": 250,
"width": 720
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
3120,
832
],
"id": "cfcb9b0f-32a1-4859-81ee-910ba6aa89dc",
"name": "📌 REPORTES"
}
],
"pinData": {},
"connections": {
"Webhook": {
"main": [
[
{
"node": "Preparar entrada app",
"type": "main",
"index": 0
}
]
]
},
"Preparar entrada app": {
"main": [
[
{
"node": "Parsear CSV banco HN",
"type": "main",
"index": 0
},
{
"node": "Preparar archivos nómina HN",
"type": "main",
"index": 0
}
]
]
},
"Preparar archivos nómina HN": {
"main": [
[
{
"node": "Extract - Archivo Nómina HN",
"type": "main",
"index": 0
}
]
]
},
"Extract - Archivo Nómina HN": {
"main": [
[
{
"node": "Normalizar Nómina HN",
"type": "main",
"index": 0
}
]
]
},
"Parsear CSV banco HN": {
"main": [
[
{
"node": "Merge Banco + Nómina HN",
"type": "main",
"index": 0
}
]
]
},
"Normalizar Nómina HN": {
"main": [
[
{
"node": "Merge Banco + Nómina HN",
"type": "main",
"index": 1
}
]
]
},
"Merge Banco + Nómina HN": {
"main": [
[
{
"node": "HTTP - Empleados BambooHR HN",
"type": "main",
"index": 0
},
{
"node": "Merge - Agregar BambooHR HN",
"type": "main",
"index": 0
}
]
]
},
"HTTP - Empleados BambooHR HN": {
"main": [
[
{
"node": "Normalizar BambooHR HN",
"type": "main",
"index": 0
}
]
]
},
"Normalizar BambooHR HN": {
"main": [
[
{
"node": "Merge - Agregar BambooHR HN",
"type": "main",
"index": 1
}
]
]
},
"Merge - Agregar BambooHR HN": {
"main": [
[
{
"node": "Obtener alias BambooHR Supabase",
"type": "main",
"index": 0
}
]
]
},
"Obtener alias BambooHR Supabase": {
"main": [
[
{
"node": "Cruzar Nómina vs Banco",
"type": "main",
"index": 0
}
]
]
},
"Cruzar Nómina vs Banco": {
"main": [
[
{
"node": "Preparar Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Preparar Google Sheet": {
"main": [
[
{
"node": "Buscar reporte existente Supabase",
"type": "main",
"index": 0
}
]
]
},
"Buscar reporte existente Supabase": {
"main": [
[
{
"node": "Crear Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Crear Google Sheet": {
"main": [
[
{
"node": "Leer Google Sheet actual",
"type": "main",
"index": 0
}
]
]
},
"Leer Google Sheet actual": {
"main": [
[
{
"node": "Preparar actualización Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Preparar actualización Google Sheet": {
"main": [
[
{
"node": "Actualizar estructura Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Actualizar estructura Google Sheet": {
"main": [
[
{
"node": "Limpiar Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Limpiar Google Sheet": {
"main": [
[
{
"node": "Escribir Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Escribir Google Sheet": {
"main": [
[
{
"node": "Formatear Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Formatear Google Sheet": {
"main": [
[
{
"node": "Preparar permisos Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Preparar permisos Google Sheet": {
"main": [
[
{
"node": "Compartir Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Compartir Google Sheet": {
"main": [
[
{
"node": "Preparar histórico Supabase",
"type": "main",
"index": 0
}
]
]
},
"Preparar histórico Supabase": {
"main": [
[
{
"node": "Insertar histórico Supabase",
"type": "main",
"index": 0
}
]
]
},
"Insertar histórico Supabase": {
"main": [
[
{
"node": "Preparar respuesta final",
"type": "main",
"index": 0
},
{
"node": "Preparar correo reporte generado",
"type": "main",
"index": 0
}
]
]
},
"Preparar respuesta final": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
},
"Preparar correo reporte generado": {
"main": [
[
{
"node": "Enviar correo reporte generado",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1",
"binaryMode": "separate"
},
"versionId": "e9ce02ee-af02-439b-8679-30525f6173ba",
"meta": {
"instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70"
},
"id": "fu6dsueqDQax1c43",
"tags": []
}