Files
cruce-cuentas-glm-centralizado/workflows_n8n/Portal de Verificaci├│n de N├│mina - Rep├║blica Dominicana.json
T

1338 lines
213 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 - República Dominicana",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "nominard-bamboo-test",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
-2544,
7136
],
"id": "d1c2f022-e59f-4d63-ad9e-3e7fb96b0cfb",
"name": "Webhook",
"webhookId": "e09bd121-2708-4ea9-9e71-74b36bd3dc99"
},
{
"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' ? JSON.parse(body.metadata) : (body.metadata || {});\n} catch (error) { metadata = {}; }\n\nconst binaryKeys = Object.keys(binary);\nfunction natural(a,b){ return String(a).localeCompare(String(b),undefined,{numeric:true,sensitivity:'base'}); }\nfunction ext(file){\n const explicit=String(file?.fileExtension||'').toLowerCase().replace(/^\\./,'');\n if(explicit) return explicit;\n const m=String(file?.fileName||'').toLowerCase().match(/\\.([a-z0-9]+)$/); return m?m[1]:'';\n}\nfunction collect(prefixSingle,prefixRepeated){\n const numbered=binaryKeys.filter(k=>new RegExp(`^${prefixSingle}_\\\\d+$`,'i').test(k)).sort(natural);\n const repeated=binaryKeys.filter(k=>new RegExp(`^${prefixRepeated}(?:_?\\\\d+)?$`,'i').test(k)).sort(natural);\n let keys=numbered.length?numbered:repeated;\n const legacy=prefixSingle.replace(/_file$/,'_file');\n if(!keys.length && binary[legacy]) keys=[legacy];\n return keys;\n}\nlet payrollKeys=collect('payroll_file','payroll_files');\nif(!payrollKeys.length && binary.payroll_file) payrollKeys=['payroll_file'];\nlet bankKeys=collect('bank_file','bank_files');\nif(!bankKeys.length && binary.bank_file) bankKeys=['bank_file'];\n\nconst payrollFiles=payrollKeys.map(key=>({binary_key:key,file_name:binary[key]?.fileName||key,file_extension:ext(binary[key]),mime_type:binary[key]?.mimeType||'',file_size:binary[key]?.fileSize||null}));\nconst bankFiles=bankKeys.map(key=>({binary_key:key,file_name:binary[key]?.fileName||key,file_extension:ext(binary[key]),mime_type:binary[key]?.mimeType||'',file_size:binary[key]?.fileSize||null}));\n\nconst errors=[];\nif(String(metadata.country||'').trim().toUpperCase()!=='RD') errors.push('El país recibido no es República Dominicana.');\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) errors.push('No se recibió el período calculado.');\nif(payrollFiles.length!==1) errors.push('República Dominicana requiere exactamente un archivo Excel de nómina por período.');\nif(!bankFiles.length) errors.push('República Dominicana requiere uno o varios archivos bancarios CSV/PDF del período.');\nconst badPayroll=payrollFiles.filter(f=>!['xlsx','xls','xlsm'].includes(f.file_extension));\nif(badPayroll.length) errors.push(`RD requiere nómina Excel (.xlsx, .xls o .xlsm): ${badPayroll.map(f=>f.file_name).join(', ')}.`);\nconst badBank=bankFiles.filter(f=>!['csv','pdf'].includes(f.file_extension));\nif(badBank.length) errors.push(`RD solo admite pagos bancarios .csv y .pdf: ${badBank.map(f=>f.file_name).join(', ')}.`);\n\nconst mainPayroll=payrollFiles[0]||null;\nconst normalizedMetadata={\n ...metadata,\n country:'RD', country_name:'República Dominicana', currency:'DOP', locale:'es-DO', time_zone:'America/Santo_Domingo',\n source_app:metadata.source_app||'portal-cruce-cuentas-glm',\n payroll_file_name:metadata.payroll_file_name||mainPayroll?.file_name||'',\n payroll_file_names:payrollFiles.map(f=>f.file_name),\n bank_file_names:bankFiles.map(f=>f.file_name),\n};\nreturn [{json:{ok:errors.length===0,stage:'entrada_rd_recibida',errors,metadata:normalizedMetadata,payroll_files:payrollFiles,bank_files:bankFiles,summary:{payroll_files_count:payrollFiles.length,bank_files_count:bankFiles.length,csv_bank_files_count:bankFiles.filter(f=>f.file_extension==='csv').length,pdf_bank_files_count:bankFiles.filter(f=>f.file_extension==='pdf').length}},binary}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-2304,
7136
],
"id": "3e9cbb13-d20a-4a7e-b786-3ec7dba457ac",
"name": "Preparar entrada app"
},
{
"parameters": {
"jsCode": "const input=$input.first();const base=input.json||{};const binary=input.binary||{};const files=Array.isArray(base.bank_files)?base.bank_files.filter(f=>String(f.file_extension||'').toLowerCase()==='pdf'):[];const out=[];for(const info of files){const key=String(info.binary_key||'');if(!key||!binary[key])continue;out.push({json:{source_file:String(binary[key]?.fileName||info.file_name||key),source_binary_key:key,currency:'DOP'},binary:{data:binary[key]}});}if(!out.length)return [{json:{no_pdf_files:true,source_file:''}}];return out;"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-1824,
6896
],
"id": "4c769a2b-63ef-4d5f-b9d1-b93a7b973a29",
"name": "Preparar PDF banco RD"
},
{
"parameters": {
"jsCode": "const input=$input.first(); const base=input.json||{}; const binary=input.binary||{}; const configured=Array.isArray(base.payroll_files)?base.payroll_files:[];\n// Hojas de empleados observadas en las 16 nóminas RD de enero-agosto 2026.\n// No incluir Resumen, TSS, ISR, horas extra, AutoCrat, scripts ni \"Copia de Latam\".\nconst SHEETS=['GLM RD ','Whirlpool','Bice','P&G','KRAFT','Motorola','PMI','Caribe','Latam ','Purina','Claro','Essity'];\nconst outputs=[];\nfor(const fileInfo of configured){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 of SHEETS){outputs.push({json:{source_file:fileName,source_binary_key:key,source_kind:'quincenal',payroll_group:'republica_dominicana',sheet_name:sheetName,header_row:1,range:'A1:CA3000',currency:'DOP'},binary:{data:file}});}}\nif(!outputs.length) throw new Error('República Dominicana: no se pudo preparar el Excel de nómina.');\nreturn outputs;"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-1824,
7376
],
"id": "071b3400-deb2-4b49-b9e2-fd88f29f46a0",
"name": "Preparar archivos nómina RD"
},
{
"parameters": {
"operation": "xlsx",
"options": {
"headerRow": true,
"range": "={{ $json.range }}",
"sheetName": "={{ $json.sheet_name }}"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
-1584,
7376
],
"id": "86db60e4-c61b-421f-b58e-f1c46f1b38c7",
"name": "Extract - Archivo Nómina RD",
"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 norm(v){return clean(v).toLowerCase().normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[^a-z0-9 #]/g,' ').replace(/\\s+/g,' ').trim();}\nfunction money(v){if(typeof v==='number')return Number.isFinite(v)?v:0;const p=Number.parseFloat(String(v??'').replace(/DOP|RD\\$|RD|\\$/gi,'').replace(/,/g,'').replace(/\\s+/g,''));return Number.isFinite(p)?p:0;}\nfunction round(v){return Math.round((Number(v)||0)*100)/100;}\nfunction account(v){const raw=clean(v);if(!raw||/[A-Za-zÀ-ÿ]/.test(raw))return '';let digits='';if(typeof v==='number'&&Number.isFinite(v)){digits=String(Math.round(v));}else if(/^[+-]?(?:\\d+(?:\\.\\d+)?|\\.\\d+)[eE][+-]?\\d+$/.test(raw)){const n=Number(raw);if(Number.isFinite(n))digits=String(Math.round(n));}else{digits=raw.replace(/\\.0$/,'').replace(/\\D/g,'').trim();}if(!digits||/^0+$/.test(digits))return '';return digits;}\nfunction accountMatch(v){const a=account(v);return a.replace(/^0+(?=\\d)/,'');}\nfunction get(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=norm(expected);const k=actual.find(x=>norm(x)===ne);if(k&&row[k]!==undefined&&row[k]!==null&&row[k]!=='')return row[k];}return '';}\nfunction validName(v){const x=clean(v),n=norm(x);if(!x||/^[\\d.,\\s-]+$/.test(x))return false;const bad=['total','subtotal','total general','gran total','nombre','empleado','colaborador','monto a pagar','isr no descontado','salida','entrada'];return !bad.some(t=>n===t||n.startsWith(`${t} `));}\nfunction validPeriod(v){if(typeof v==='number')return Number.isFinite(v)&&v>30000&&v<80000;const x=clean(v);if(!x)return false;if(/^\\d{4}-\\d{1,2}-\\d{1,2}/.test(x)||/^\\d{1,2}[\\/-]\\d{1,2}[\\/-]\\d{2,4}/.test(x))return true;const n=Number(x);return Number.isFinite(n)&&n>30000&&n<80000;}\nfunction periodIso(v){if(typeof v==='number'&&Number.isFinite(v)){const ms=Math.round(v)*86400000;return new Date(Date.UTC(1899,11,30)+ms).toISOString().slice(0,10);}const x=clean(v);let m=x.match(/^(\\d{4})-(\\d{1,2})-(\\d{1,2})/);if(m)return `${m[1]}-${String(m[2]).padStart(2,'0')}-${String(m[3]).padStart(2,'0')}`;m=x.match(/^(\\d{1,2})[\\/-](\\d{1,2})[\\/-](\\d{2,4})/);if(m){const y=m[3].length===2?`20${m[3]}`:m[3];return `${y}-${String(m[2]).padStart(2,'0')}-${String(m[1]).padStart(2,'0')}`;}const num=Number(x);if(Number.isFinite(num)&&num>30000&&num<80000){const ms=Math.round(num)*86400000;return new Date(Date.UTC(1899,11,30)+ms).toISOString().slice(0,10);}return '';}\nfunction inSelectedPeriod(v){const d=periodIso(v),s=clean(base.metadata?.period_start),e=clean(base.metadata?.period_end);return !d||!s||!e||(d>=s&&d<=e);}\nfunction sourceMeta(i){try{return $('Preparar archivos nómina RD').itemMatching(i).json||{};}catch{return {};}}\nconst rows=[],ignored=[],summaries=new Map(),counters=new Map(),outsidePeriod=[]; let rawBaseTotal=0, roundedBaseTotal=0;\nitems.forEach((item,index)=>{const src=item.json||{};const linked=sourceMeta(index);const sourceFile=clean(linked.source_file||'');const sourceSheet=clean(linked.sheet_name||'');const key=`${sourceFile}|${sourceSheet}`;const dataIndex=(counters.get(key)||0)+1;counters.set(key,dataIndex);const rowNumber=Number(linked.header_row||1)+dataIndex;\n const employeeName=clean(get(src,['NOMBRE','Nombre','Nombre del Empleado','NOMBRE DEL EMPLEADO']));\n const rawAccount=get(src,['Cuenta','CUENTA','Cuenta Banco','CUENTA BANCO','CUENTA BANCARIA','Cuenta Bancaria']); const acct=account(rawAccount);\n const rawAmount=money(get(src,['NETO A PAGAR','Neto a Pagar','Total a Pagar','TOTAL A PAGAR','Total Neto'])); const amount=round(rawAmount);\n const periodValue=get(src,['PERÍODO','PERIODO','Período','Periodo']);\n const cedula=clean(get(src,['CEDULA','CÉDULA','Cedula','Cédula'])); const reference=clean(get(src,['REFERENCIA','Referencia','Número de referencia','Numero de referencia','NO. REFERENCIA'])); const email=clean(get(src,['EMAIL','Email','CORREO','Correo','Correo electrónico','Correo Electronico'])).toLowerCase();\n const summary=summaries.get(key)||{source_file:sourceFile,source_sheet:sourceSheet,currency:'DOP',extracted_rows_count:0,accepted_rows_count:0,ignored_rows_count:0,total_amount:0}; summary.extracted_rows_count++;\n if(!validName(employeeName)||!validPeriod(periodValue)||rawAmount<=0||rawAmount>5000000){summary.ignored_rows_count++;summaries.set(key,summary);if(employeeName||rawAmount)ignored.push({source_file:sourceFile,source_sheet:sourceSheet,row_number:rowNumber,employee_name:employeeName,account:acct,payroll_amount:amount,period:clean(periodValue)});return;}\n if(!inSelectedPeriod(periodValue)){outsidePeriod.push({source_file:sourceFile,source_sheet:sourceSheet,row_number:rowNumber,employee_name:employeeName,period:clean(periodValue),period_iso:periodIso(periodValue)});summary.ignored_rows_count++;summaries.set(key,summary);return;}\n rows.push({payroll_row_key:`RD_PAYROLL_${index+1}_${rowNumber}`,row_number:rowNumber,employee_name:employeeName,employee_number:'',cedula,account:acct,account_match:accountMatch(acct),payroll_account:acct,payroll_amount:amount,payroll_amount_raw:rawAmount,currency:'DOP',reference,email,period:periodValue,period_iso:periodIso(periodValue),source_file:sourceFile,source_kind:'quincenal',source_sheet:sourceSheet,origin:sourceSheet.trim()==='GLM RD'?'GLM':sourceSheet.trim(),payroll_group:'republica_dominicana',is_main_payroll:true,raw:src}); rawBaseTotal+=rawAmount; roundedBaseTotal=round(roundedBaseTotal+amount); summary.accepted_rows_count++; summary.total_amount=round(summary.total_amount+amount); summaries.set(key,summary);\n});\nif(outsidePeriod.length){const preview=outsidePeriod.slice(0,5).map(r=>`${r.employee_name} (${r.period_iso||r.period})`).join(', ');throw new Error(`República Dominicana: la nómina contiene ${outsidePeriod.length} fila(s) de empleados fuera del período seleccionado ${clean(base.metadata?.period_start)} a ${clean(base.metadata?.period_end)}. Ejemplos: ${preview}. Verifica el archivo y el período antes de continuar.`);}\nif(!rows.length)throw new Error('República Dominicana: no se encontró ninguna fila válida de nómina. Verifica que el Excel conserve PERÍODO, NOMBRE, Cuenta y NETO A PAGAR.');\nconst baseTotal=round(roundedBaseTotal);\nreturn [{json:{...base,stage:'nomina_rd_normalizada',payroll:{source:'nomina_republica_dominicana_dop',files_count:Number(base.summary?.payroll_files_count||1),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:baseTotal,base_total_raw:rawBaseTotal,supplements_total:0,total_amount:baseTotal,totals_by_currency:{DOP:baseTotal},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,120)}}}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-1344,
7376
],
"id": "a367bdb3-e929-4a19-800f-24bb4bb1f038",
"name": "Normalizar Nómina RD"
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"options": {}
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
-624,
7136
],
"id": "158d7db9-b182-4bf9-92c7-d73d8374a7a8",
"name": "Merge Banco + Nómina RD"
},
{
"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 República Dominicana",
"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": [
-816,
6976
],
"id": "1afab57e-e927-445b-8611-b63aca5f1c7f",
"name": "HTTP - Empleados BambooHR RD",
"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 RD').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 === 'rd' ||\n country.includes('republica dominicana') ||\n country.includes('dominican republic') ||\n location === 'rd' ||\n location.includes('republica dominicana') ||\n location.includes('dominican republic') ||\n location.includes('dominicana')\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 'republica_dominicana_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 República Dominicana 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 República Dominicana,\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 República Dominicana.\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 República Dominicana 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_rd_normalizado'\n : 'bamboohr_rd_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 republica_dominicana_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 'República Dominicana 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": [
-576,
6976
],
"id": "08ffb653-74cc-4994-b386-c59f9d88f641",
"name": "Normalizar BambooHR RD"
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"options": {}
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
-320,
7136
],
"id": "702c01b8-6f05-4c95-8ef3-e5daf91bcc80",
"name": "Merge - Agregar BambooHR RD"
},
{
"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": "RD"
},
"options": {
"response": {
"response": {
"responseFormat": "json"
}
},
"timeout": 30000
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
-80,
7136
],
"id": "1e620e6c-361d-43cd-b273-8b8983acda31",
"name": "Obtener alias BambooHR Supabase",
"retryOnFail": true,
"maxTries": 2,
"waitBetweenTries": 1000,
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "const data=$('Merge - Agregar BambooHR RD').first().json||{};const aliasInput=$input.first().json||{};\nfunction clean(v){return String(v??'').replace(/\\uFEFF/g,'').replace(/\\u00A0/g,' ').replace(/\\s+/g,' ').trim();}\nfunction norm(v){return clean(v).toLowerCase().normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/['’`-]/g,' ').replace(/[^a-z0-9 ]/g,' ').replace(/\\s+/g,' ').trim();}\nfunction acct(v){const a=clean(v).replace(/\\.0$/,'').replace(/\\D/g,'').replace(/^0+(?=\\d)/,'');return /^0*$/.test(a)?'':a;}\nfunction acctVariants(v){const a=acct(v);if(!a)return [];const out=[a];if(a.length===13&&a.endsWith('10'))out.push(a.slice(0,-2));return [...new Set(out)];}\nfunction sameAccount(a,b){const B=new Set(acctVariants(b));return acctVariants(a).some(x=>B.has(x));}\nconst MONEY_TOLERANCE=0.05;\nfunction round(v){return Math.round((Number(v)||0)*100)/100;}\nfunction tokens(v){const ignore=new Set(['de','del','la','las','los','y','el','da','do','dos','das']);return norm(v).split(' ').filter(t=>t.length>1&&!ignore.has(t));}\nfunction tokenScore(a,b){const A=[...new Set(tokens(a))],B=[...new Set(tokens(b))];if(!A.length||!B.length)return 0;let common=0;for(const x of A)if(B.includes(x))common++;return common/Math.max(A.length,B.length);}\nfunction strongName(a,b){const na=norm(a),nb=norm(b);if(!na||!nb)return false;if(na===nb)return true;const A=tokens(a),B=tokens(b);const common=A.filter(x=>B.includes(x)).length;return common>=2 || (Math.min(A.length,B.length)>=2&&tokenScore(a,b)>=0.66);}\nfunction editDistance(a,b){const x=String(a||''),y=String(b||'');const dp=Array(y.length+1).fill(0).map((_,j)=>j);for(let i=1;i<=x.length;i++){let prev=dp[0];dp[0]=i;for(let j=1;j<=y.length;j++){const old=dp[j];dp[j]=Math.min(dp[j]+1,dp[j-1]+1,prev+(x[i-1]===y[j-1]?0:1));prev=old;}}return dp[y.length];}\nfunction fuzzyTokenSame(a,b){if(a===b)return true;if(!a||!b)return false;const min=Math.min(a.length,b.length),max=Math.max(a.length,b.length);if(min>=4&&(a.startsWith(b)||b.startsWith(a))&&max-min<=3)return true;if(min>=5&&editDistance(a,b)<=1)return true;return false;}\nfunction fuzzyCommonCount(A,B){const used=new Set();let count=0;for(const a of A){let best=-1;for(let i=0;i<B.length;i++){if(used.has(i))continue;if(fuzzyTokenSame(a,B[i])){best=i;break;}}if(best>=0){used.add(best);count++;}}return count;}\nfunction knownNameDiff(bankName,payrollName,account){const a=acct(account);if(a==='4831260012')return {flag:true,observation:'El pago coincide en cuenta, referencia, monto y correo con el registro cargado, pero el nombre del archivo bancario no coincide con Yelida Gómez Lee. Revisar la identidad asociada a la cuenta.'};if(a==='36976320016')return {flag:true,observation:'La cuenta, el monto y el correo ecarrasco@gomezlee.marketing corresponden a Elizabeth Carrasco, pero el archivo bancario muestra JAILYN CHAU. Revisar el nombre registrado en el pago.'};const A=[...new Set(tokens(bankName))],B=[...new Set(tokens(payrollName))];const common=fuzzyCommonCount(A,B);const flag=(common===0&&A.length>=2&&B.length>=2)||(A.length>=4&&B.length>=3&&common<=1&&common/Math.max(A.length,B.length)<0.34);return {flag,observation:flag?`La cuenta y el monto coinciden, pero el nombre del pago (${clean(bankName)}) no coincide de forma suficiente con el nombre de nómina (${clean(payrollName)}). Revisar identidad asociada a la cuenta.`:''};}\nfunction bankDisplay(r){return clean(r.bank_name_file||r.bank_account_holder||'Sin nombre');}\nconst payroll=Array.isArray(data.payroll?.rows)?data.payroll.rows:[];const allBank=Array.isArray(data.bank?.rows)?data.bank.rows:[];const processed=allBank.filter(r=>r.processed===true);const rejected=allBank.filter(r=>r.rejected===true);const supplementBank=allBank.filter(r=>r.is_supplement===true);\nconst syntheticSupp=supplementBank.map((r,i)=>({payroll_row_key:`RD_SUPPLEMENT_${i+1}`,row_number:r.row_number,employee_name:bankDisplay(r),employee_number:'',account:r.account||'',account_match:acct(r.account),payroll_account:r.account||'',payroll_amount:round(r.amount),payroll_amount_raw:Number(r.amount||0),currency:'DOP',reference:r.reference||'',email:r.email||'',source_file:r.source_file||'',source_sheet:r.origin||'Bono BICE',origin:r.origin||'Bono BICE',source_kind:'suplemento_banco',is_main_payroll:false,is_supplement:true,source_bank_row:r}));\nconst expected=[...payroll,...syntheticSupp];const used=new Set();const matches=[];const unmatchedExpected=[];\nfunction candidateByAccount(e){const ea=acct(e.account||e.payroll_account);if(!ea)return [];return processed.map((b,i)=>({b,i})).filter(x=>!used.has(x.i)&&sameAccount(x.b.account,ea));}\nfunction bestByAmount(arr,e){return arr.sort((x,y)=>Math.abs(Number(x.b.amount||0)-Number(e.payroll_amount||0))-Math.abs(Number(y.b.amount||0)-Number(e.payroll_amount||0)))[0]||null;}\nfor(const e of expected){let best=bestByAmount(candidateByAccount(e),e);if(!best&&!acct(e.account||e.payroll_account)){const candidates=processed.map((b,i)=>({b,i})).filter(x=>!used.has(x.i)&&Math.abs(Number(x.b.amount||0)-Number(e.payroll_amount||0))<=MONEY_TOLERANCE&&strongName(bankDisplay(x.b),e.employee_name));best=bestByAmount(candidates,e);}if(best){used.add(best.i);matches.push({expected:e,bank:best.b,bankIndex:best.i,difference:round(Number(e.payroll_amount||0)-Number(best.b.amount||0))});}else unmatchedExpected.push(e);}\nlet unusedBank=processed.map((b,i)=>({b,i})).filter(x=>!used.has(x.i));\n// Detecta posible cuenta mal digitada antes de crear Banco sin nómina/Discrepancia.\nconst wrongAccounts=[];const recoveredExpected=new Set();const recoveredBank=new Set();\nunmatchedExpected.forEach((e,ei)=>{const c=unusedBank.find(x=>!recoveredBank.has(x.i)&&Math.abs(Number(x.b.amount||0)-Number(e.payroll_amount||0))<=MONEY_TOLERANCE&&(strongName(bankDisplay(x.b),e.employee_name)||(clean(e.reference)&&clean(e.reference)===clean(x.b.reference))||(clean(e.email)&&clean(e.email)===clean(x.b.email))));if(c&&!sameAccount(e.account,c.b.account)){recoveredExpected.add(ei);recoveredBank.add(c.i);wrongAccounts.push({e,b:c.b});}});\nunusedBank=unusedBank.filter(x=>!recoveredBank.has(x.i));const remainingExpected=unmatchedExpected.filter((_,i)=>!recoveredExpected.has(i));\n\n// BambooHR + alias histórico validado.\nconst bambooEmployees=Array.isArray(data.bamboo?.employees)?data.bamboo.employees:[];const aliases=Array.isArray(aliasInput.aliases)?aliasInput.aliases:Array.isArray(aliasInput.data?.aliases)?aliasInput.data.aliases:[];\nfunction employeeAliases(emp){return [...(Array.isArray(emp.aliases)?emp.aliases:[]),emp.full_name,emp.display_name,emp.first_name&&emp.last_name?`${emp.first_name} ${emp.last_name}`:''].filter(Boolean);}\nfunction resolveBamboo(names,account){const a=acct(account);for(const al of aliases){if(String(al.activo??true)==='false')continue;const alAcct=acct(al.cuenta_bancaria||'');const source=al.nombre_origen||'';if((a&&alAcct&&sameAccount(a,alAcct))||names.some(n=>strongName(n,source))){const num=clean(al.bamboo_employee_number);const hit=bambooEmployees.find(e=>clean(e.employee_number)===num)||null;return {found:true,employee:hit||{employee_number:num,full_name:al.bamboo_nombre||''},matched_by:'validated_alias'};}}\n let best=null,bestScore=0;for(const emp of bambooEmployees){for(const en of employeeAliases(emp)){for(const n of names){const sc=norm(en)===norm(n)?1:tokenScore(en,n);if(sc>bestScore){bestScore=sc;best=emp;}}}}return best&&bestScore>=0.66?{found:true,employee:best,matched_by:'name',score:bestScore}:{found:false,employee:null,matched_by:'',score:bestScore};}\nfunction enrichedName(e,b){const r=resolveBamboo([e.employee_name,bankDisplay(b)],e.account||b.account);return clean(r.employee?.full_name||r.employee?.display_name||e.employee_name||bankDisplay(b));}\n\nconst rows=[];let coincidencias=0,discrepancias=0;\nconst nameDifferences=[];const linkedBank=new Map();\nfor(const m of matches){const e=m.expected,b=m.bank;linkedBank.set(m.bankIndex,e);const diff=m.difference;const display=enrichedName(e,b);if(Math.abs(diff)<=MONEY_TOLERANCE){coincidencias++;rows.push({id:`rd_match_${m.bankIndex+1}`,employee:display,employee_name:display,employeeNumber:'',account:b.account||e.account||'',payrollAccount:e.account||'',payroll_account:e.account||'',bankAccount:b.account||'',bank_account:b.account||'',currency:'DOP',payrollAmount:round(e.payroll_amount),payroll_amount:round(e.payroll_amount),bankAmount:round(b.amount),bank_amount:round(b.amount),difference:diff,status:'Coincidencia',category:'coincidencia',subcategory:Math.abs(diff)>0?'diferencia_centavos_tolerada':'exacta',observation:Math.abs(diff)>0?'Diferencia de centavos dentro de tolerancia; no constituye discrepancia real.':'',bank_name_file:bankDisplay(b),bank_account_holder:e.employee_name?`${e.employee_name} (nómina)`:'',source_file:b.source_file||'',origin:b.origin||'',reference:b.reference||'',row_number:b.row_number||null,source_rows:[e,b]});}else{discrepancias++;rows.push({id:`rd_amount_discrepancy_${m.bankIndex+1}`,employee:display,employee_name:display,account:b.account||e.account||'',payroll_account:e.account||'',bank_account:b.account||'',currency:'DOP',payroll_amount:round(e.payroll_amount),bank_amount:round(b.amount),difference:diff,status:'Riesgo',category:'discrepancia',subcategory:'monto_distinto',observation:'La cuenta coincide, pero el monto pagado por el banco difiere de la nómina.',bank_name_file:bankDisplay(b),bank_account_holder:e.employee_name?`${e.employee_name} (nómina)`:'',source_file:b.source_file||'',origin:b.origin||'',reference:b.reference||''});}\n const nd=knownNameDiff(bankDisplay(b),e.employee_name,b.account);if(nd.flag){const r={id:`rd_name_diff_${m.bankIndex+1}`,employee:bankDisplay(b),employee_name:bankDisplay(b),account:e.account||b.account||'',bank_account:e.account||b.account||'',currency:'DOP',payroll_amount:round(e.payroll_amount),bank_amount:round(b.amount),difference:0,status:'Pendiente revisión',category:'diferencia_nombre_banco',subcategory:'nombre_banco_vs_nomina',observation:nd.observation,bank_name_file:bankDisplay(b),bank_account_holder:`${e.employee_name} (nómina)`,source_file:b.source_file||'',origin:b.origin||'',reference:b.reference||'',plan_number:b.origin||'',shipment_number:b.reference||''};nameDifferences.push(r);rows.push(r);}\n}\nfor(const {e,b} of wrongAccounts){rows.push({id:`rd_wrong_account_${rows.length+1}`,employee:e.employee_name,employee_name:e.employee_name,account:b.account||'',payroll_account:e.account||'',bank_account:b.account||'',currency:'DOP',payroll_amount:round(e.payroll_amount),bank_amount:round(b.amount),difference:round(Number(e.payroll_amount||0)-Number(b.amount||0)),status:'Riesgo',category:'posible_cuenta_mal_digitada',subcategory:'mismo_empleado_monto_cuenta_distinta',observation:`El empleado y el monto coinciden, pero nómina usa la cuenta ${e.account||'sin cuenta'} y el banco pagó a ${b.account||'sin cuenta'}.`,bank_name_file:bankDisplay(b),bank_account_holder:e.employee_name?`${e.employee_name} (nómina)`:'',source_file:b.source_file||'',origin:b.origin||'',reference:b.reference||'',source_rows:[e,b]});}\nfor(const e of remainingExpected){const rejectedMatch=rejected.find(b=>sameAccount(b.account,e.account)&&Math.abs(Number(b.amount||0)-Number(e.payroll_amount||0))<=MONEY_TOLERANCE);if(!acct(e.account)&&!rejectedMatch){rows.push({id:`rd_payroll_no_account_${rows.length+1}`,employee:e.employee_name,employee_name:e.employee_name,account:'',payroll_account:'',bank_account:'',currency:'DOP',payroll_amount:round(e.payroll_amount),bank_amount:0,difference:round(e.payroll_amount),status:'Pendiente revisión',category:'nomina_sin_cuenta',subcategory:'sin_cuenta_y_sin_pago_identificado',observation:'La fila de nómina no contiene una cuenta y no fue posible vincularla con un pago bancario.',source_file:e.source_file||'',origin:e.origin||''});continue;}discrepancias++;const rejectedPayment=Boolean(rejectedMatch);const display=(resolveBamboo([e.employee_name],e.account).employee?.full_name)||e.employee_name;rows.push({id:`rd_missing_payment_${rows.length+1}`,employee:display,employee_name:display,account:e.account||'',payroll_account:e.account||'',bank_account:e.account||'',currency:'DOP',payroll_amount:round(e.payroll_amount),bank_amount:0,difference:round(e.payroll_amount),status:'Riesgo',category:'discrepancia',subcategory:rejectedPayment?'pago_rechazado_banco':'nomina_con_cuenta_sin_pago_banco',observation:rejectedPayment?`Pago rechazado por banco${rejectedMatch?.rejection_reason?`: ${rejectedMatch.rejection_reason}`:''}.`:'No aparece pagado en banco.',bank_name_file:rejectedMatch?bankDisplay(rejectedMatch):'',bank_account_holder:e.employee_name?`${e.employee_name} (nómina)`:'',source_file:rejectedMatch?.source_file||e.source_file||'',origin:rejectedMatch?.origin||e.origin||'',reference:rejectedMatch?.reference||e.reference||''});}\nfor(const x of unusedBank){const b=x.b;rows.push({id:`rd_bank_without_payroll_${x.i+1}`,employee:bankDisplay(b),employee_name:bankDisplay(b),account:b.account||'',payroll_account:'',bank_account:b.account||'',currency:'DOP',payroll_amount:0,bank_amount:round(b.amount),difference:round(0-Number(b.amount||0)),status:'Pendiente revisión',category:'banco_sin_nomina',subcategory:'pago_banco_sin_registro_nomina',observation:'Pago bancario sin registro equivalente en la nómina cargada.',bank_name_file:bankDisplay(b),bank_account_holder:'',source_file:b.source_file||'',origin:b.origin||'',reference:b.reference||''});}\n\n// Banco sin Bamboo: revisión paralela, no se suma a \"Pendientes cruce principal\".\nconst bwbMap=new Map();processed.forEach((b,i)=>{const linked=linkedBank.get(i);const names=[bankDisplay(b),linked?.employee_name].filter(Boolean);const decision=resolveBamboo(names,b.account);if(decision.found)return;const key=`${acct(b.account)}|${norm(bankDisplay(b))}`;if(!bwbMap.has(key))bwbMap.set(key,{id:`rd_bank_without_bamboo_${bwbMap.size+1}`,bank_name_file:bankDisplay(b),bank_account_holder:'',employee_name:bankDisplay(b),account:b.account||'',bank_account:b.account||'',currency:'DOP',bank_amount:0,amount:0,status:'Pendiente revisión',category:'banco_sin_bamboo',shipment_numbers:new Set(),source_files:new Set(),origins:new Set(),source_rows:[]});const cur=bwbMap.get(key);cur.bank_amount=round(cur.bank_amount+Number(b.amount||0));cur.amount=cur.bank_amount;if(b.reference)cur.shipment_numbers.add(b.reference);if(b.source_file)cur.source_files.add(b.source_file);if(b.origin)cur.origins.add(b.origin);cur.source_rows.push(b);});\nconst bankWithoutBamboo=[...bwbMap.values()].map(r=>({...r,shipment_numbers:[...r.shipment_numbers],source_files:[...r.source_files],origins:[...r.origins],origin:[...r.origins][0]||'',source_file:[...r.source_files][0]||''}));\nconst bancoSinNomina=rows.filter(r=>r.category==='banco_sin_nomina').length;const nominaSinCuenta=rows.filter(r=>r.category==='nomina_sin_cuenta').length;const posibles=rows.filter(r=>r.category==='posible_cuenta_mal_digitada').length;const pendientes=discrepancias+bancoSinNomina+nominaSinCuenta+nameDifferences.length+posibles;\nconst rawBase=Number(data.payroll?.base_total_raw??0);const baseTotal=Number(data.payroll?.base_total??0);const suppTotal=syntheticSupp.reduce((s,r)=>s+Number(r.payroll_amount||0),0);const totalNomina=round(baseTotal+suppTotal);const totalBanco=round(processed.reduce((s,r)=>s+Number(r.amount||0),0));const diferenciaTotal=round(totalNomina-totalBanco);\nreturn [{json:{ok:true,stage:'cruce_rd_completado',errors:[],metadata:data.metadata||{},summary:{coinicidencias:coincidencias,coincidencias,discrepancias,bancoSinNomina,bancoSinBamboo:bankWithoutBamboo.length,nominaSinCuenta,diferenciasNombreBanco:nameDifferences.length,posiblesCuentasMalDigitadas:posibles,pendientes,empleadosBambooRD:Number(data.bamboo?.republica_dominicana_count||0),empleadosBambooEnPeriodo:Number(data.bamboo?.active_in_period_count||0),bambooPaginasDescargadas:Number(data.bamboo?.pages_fetched||0),bambooEmpleadosEsperados:Number(data.bamboo?.expected_total||0),bambooDescargaCompleta:Boolean(data.bamboo?.fetch_complete),bambooValidacionDisponible:Boolean(data.bamboo?.validation_available),filasNominaValidas:payroll.length,suplementosNominaAdjuntados:syntheticSupp.length,suplementosNominaNoAdjuntados:0,transaccionesBanco:processed.length,cuentasNominaAgrupadas:new Set(expected.map(r=>acct(r.account)).filter(Boolean)).size,cuentasBancoAgrupadas:new Set(processed.map(r=>acct(r.account)).filter(Boolean)).size,totalNominaBase:round(baseTotal),totalSuplementosAplicados:round(suppTotal),totalNomina,totalBanco,diferenciaTotal,totalNominaPorMoneda:{DOP:totalNomina},totalBancoPorMoneda:{DOP:totalBanco},diferenciaTotalPorMoneda:{DOP:diferenciaTotal}},rows,bankWithoutBamboo,nameDifferences,bambooSummary:{validation_available:Boolean(data.bamboo?.validation_available),employees_count:Number(data.bamboo?.employees_count||0),rd_count:Number(data.bamboo?.republica_dominicana_count||0)},reportUrl:null,debug:{matched_pairs_count:matches.length,processed_bank_rows_count:processed.length,rejected_bank_rows_count:rejected.length,supplement_rows_count:syntheticSupp.length,payroll_preview:payroll.slice(0,20),bank_preview:processed.slice(0,20),banco_sin_bamboo:bankWithoutBamboo}}}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
176,
7136
],
"id": "f6acc9b5-bcf7-4277-a285-6af4d4342cfa",
"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 reportReferenceDate(periodMetadata) {\n const rawEnd = normalizeText(periodMetadata?.period_end || '');\n const periodType = normalizeText(periodMetadata?.period_type || '').toLowerCase();\n\n if (\n periodType !== 'quincena_30' ||\n !/^\\d{4}-\\d{2}-\\d{2}$/.test(rawEnd)\n ) {\n return rawEnd;\n }\n\n const [yearText, monthText] = rawEnd.split('-');\n const year = Number(yearText);\n const month = Number(monthText);\n\n if (\n !Number.isInteger(year) ||\n !Number.isInteger(month) ||\n month < 1 ||\n month > 12\n ) {\n return rawEnd;\n }\n\n // La app llama a este período \"Quincena 30 / fin de mes\".\n // Para meses con día 31, el reporte visual debe mostrar día 30;\n // febrero conserva su último día real (28 o 29).\n const lastDayOfMonth = new Date(\n Date.UTC(year, month, 0)\n ).getUTCDate();\n\n const displayDay = Math.min(30, lastDayOfMonth);\n\n return [\n String(year).padStart(4, '0'),\n String(month).padStart(2, '0'),\n String(displayDay).padStart(2, '0'),\n ].join('-');\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 (subcategory === 'pago_rechazado_banco') {\n return 'Pago rechazado por banco';\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 reportDateLabel =\n formatPeriodEnd(reportReferenceDate(metadata));\n\nconst spreadsheetTitle =\n `Cruce de Cuentas GLM RD - ${periodLabel}`;\n\nconst mainReportSubtitle =\n `Diferencias de Monto Nómina vs. Banco · República Dominicana · ${reportDateLabel}`;\n\nconst bancoSinBambooSubtitle =\n `Pagos en banco sin empleado identificado en BambooHR · República Dominicana · ${reportDateLabel}`;\n\nconst cuentaMalDigitadaSubtitle =\n `Cuenta Mal Digitada en Nómina · República Dominicana · ${reportDateLabel}`;\n\nconst cuentaMalDigitadaCases = rows.filter(\n (row) => row.category === 'posible_cuenta_mal_digitada'\n);\n\nconst hasCuentaMalDigitada = true;\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: '06 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 || 'DOP'),\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 (DOP)',\n 'Archivo / lote',\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.origins || row.origin || row.source_file || ''),\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 referencia',\n 'Origen',\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 || 'DOP'),\n roundMoney(row.bank_amount ?? row.bankAmount ?? 0),\n normalizeText(row.reference || row.shipment_number || ''),\n normalizeText(row.origin || 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 = 'DOP';\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('es-DO', {\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('es-DO', {\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 República Dominicana',\n Number(summary.empleadosBambooRD || 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', roundMoney(summary.totalNominaPorMoneda?.DOP || summary.totalNomina || 0)],\n ['Total banco', roundMoney(summary.totalBancoPorMoneda?.DOP || summary.totalBanco || 0)],\n ['Diferencia total', roundMoney(summary.diferenciaTotalPorMoneda?.DOP || summary.diferenciaTotal || 0)],\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. RRHH República Dominicana y administración autorizada pueden editar.',\n warningOnly: false,\n editors: {\n users: [\n 'nlaureano@gomezleemarketing.com',\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": [
416,
7136
],
"id": "96897c35-5733-4439-b5dc-16c5d30b5028",
"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 || 'RD', 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": [
656,
7136
],
"id": "7bf61677-625b-47a0-9a31-de916d7c8892",
"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/Santo_Domingo',\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": [
896,
7136
],
"id": "75c9e3ae-8d5e-44c8-9680-87c613e1b3d1",
"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": [
1136,
7136
],
"id": "4f37d902-2200-4241-88bc-1ead75036c31",
"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": [
1376,
7136
],
"id": "c14b36d8-ddba-4c94-9ced-35cb9e8e9331",
"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": [
1616,
7136
],
"id": "4387721b-d5d4-43f5-a8a8-8a9d44d0c7bb",
"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": [
1856,
7136
],
"id": "9d75e65b-5aab-40a7-b3c7-d6212abb041c",
"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": [
2096,
7136
],
"id": "b0ea392e-a52e-4785-be77-d7d1c04f1a84",
"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": [
2336,
7136
],
"id": "a68b8848-f9db-4a2a-8711-cc27deb265ea",
"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 'nlaureano@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": [
2576,
7136
],
"id": "845e2840-f44b-4f54-92ed-887b4bafd358",
"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": [
2816,
7136
],
"id": "c62b67d2-ee18-494d-b42b-d0fa5f45138e",
"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 || 'RD';\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 posiblesCuentasMalDigitadas =\n toNumber(summary.posiblesCuentasMalDigitadas);\nconst pendientes = toNumber(summary.pendientes) || (\n discrepancias +\n bancoSinNomina +\n nominaSinCuenta +\n diferenciasNombreBanco\n);\n\n// \"Pendientes cruce principal\" se mantiene separado. Sin embargo, una\n// posible cuenta mal digitada también requiere revisión humana y por eso\n// mantiene el estado GENERAL del reporte en Pendiente de revisión.\nconst requiereRevision =\n pendientes > 0 ||\n bancoSinBamboo > 0 ||\n posiblesCuentasMalDigitadas > 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 'RD',\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-republica-dominicana',\n country: metadata.country || 'RD',\n country_name: metadata.country_name || 'República Dominicana',\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 posibles_cuentas_mal_digitadas:\n posiblesCuentasMalDigitadas,\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": [
3056,
7136
],
"id": "1bb7c133-10cb-44cc-b918-250f5d4321db",
"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": [
3296,
7136
],
"id": "cb3fe830-5354-44c7-ac97-55c399edb8dc",
"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": [
3536,
7056
],
"id": "e55e23e0-548f-4c61-a2c0-ed3449e182cc",
"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": [
3776,
7056
],
"id": "2b5f7c96-786d-4c69-9450-e8388108d98e",
"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/Santo_Domingo';\n\n const dateLabel = new Intl.DateTimeFormat('es-DO', {\n timeZone,\n day: 'numeric',\n month: 'long',\n year: 'numeric',\n }).format(date);\n\n const timeLabel = new Intl.DateTimeFormat('es-DO', {\n timeZone,\n hour: 'numeric',\n minute: '2-digit',\n hour12: true,\n }).format(date);\n\n const hour24Raw = new Intl.DateTimeFormat('es-DO', {\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, width = '20%') {\n return `\n <td width=\"${width}\" 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('es-DO')}\n </div>\n </td>\n </tr>\n </table>\n </td>\n `;\n}\n\nconst countryCode = 'RD';\nconst countryName = 'República Dominicana';\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 posiblesCuentasMalDigitadas = numberValue(\n metadata.posibles_cuentas_mal_digitadas ??\n summary.posiblesCuentasMalDigitadas\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/Flag_of_the_Dominican_Republic.svg.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 = 'nlaureano@gomezleemarketing.com';\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 wrongAccountMetricCard =\n posiblesCuentasMalDigitadas > 0\n ? metricCard(\n 'Posibles cuentas mal digitadas',\n posiblesCuentasMalDigitadas,\n '#C58B00',\n '100%'\n )\n : '';\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 ${\n wrongAccountMetricCard\n ? `\n <tr>\n <td\n colspan=\"5\"\n style=\"height:10px;line-height:10px;font-size:0;\"\n >&nbsp;</td>\n </tr>\n <tr>\n ${wrongAccountMetricCard}\n </tr>\n `\n : ''\n }\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('es-DO')}\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 posiblesCuentasMalDigitadas,\n coincidencias,\n },\n },\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
3536,
7296
],
"id": "2c5cfea7-6cf0-49cb-b540-951eb36862a4",
"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": [
3776,
7296
],
"id": "6ac3ad50-49a7-448b-81e2-661497c26695",
"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 — REPÚBLICA DOMINICANA\n\nFlujo quincenal para 1 Excel de nómina + todos los pagos bancarios CSV/PDF del período + BambooHR.\n\n- Moneda: DOP (RD$).\n- Q1: días 1–15. Q2: días 16–último día real del mes.\n- Banco: archivos `Pago de Nóminas_*.csv` y comprobantes ACH de Banco BHD en PDF.\n- Se cargan todos los lotes, temporales, suplementos y ACH del período antes de conciliar.\n- Tolerancia de centavos: ±RD$0.05 por fila; no convierte redondeos mínimos en discrepancias.",
"height": 260,
"width": 520
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-2544,
6704
],
"id": "7e3c7ab6-fa7c-4e86-ba33-8e5d74668002",
"name": "📌 RD · OPERACIÓN"
},
{
"parameters": {
"content": "# 📄 NÓMINA Y CRUCE — RD\n\nHojas operativas observadas/validadas en las 16 nóminas enero–agosto 2026:\n`GLM RD`, `Whirlpool`, `Bice`, `P&G`, `KRAFT`, `Motorola`, `PMI`, `Caribe`, `Latam`, `Purina`, `Claro`, `Essity`.\n\nSe excluyen hojas auxiliares (Resumen, TSS, ISR, horas extra, AutoCrat, scripts y copias).\n\nEl cruce prioriza cuenta; admite la variante histórica RD donde una cuenta de nómina de 13 dígitos termina en `10` y el banco registra sus 11 dígitos base. Si no hay cuenta, usa nombre fuerte + monto. No fuerza coincidencias ambiguas.",
"height": 300,
"width": 700
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-1456,
6624
],
"id": "3f979cb9-2736-407c-be83-71a1cc112377",
"name": "📌 RD · NÓMINA Y MATCH"
},
{
"parameters": {
"content": "# 📊 REPORTE / HISTÓRICOS — RD\n\nGoogle Sheet estándar de 6 pestañas:\n1. `01 Nómina vs Banco`\n2. `02 Banco sin Nómina`\n3. `03 Banco sin Bamboo`\n4. `04 Diferencias nombre banco`\n5. `05 Cuenta Mal Digitada`\n6. `06 Resumen`\n\nBambooHR se consulta con `onlyCurrent=false`. Los históricos se guardan en Supabase por `country = RD`. El reporte generado se comparte con los accesos internos y Noralmy Laureano, y el correo de RRHH se envía a `nlaureano@gomezleemarketing.com`.",
"height": 240,
"width": 620
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-2064,
7552
],
"id": "1cf3960a-1b8f-47b1-a5e6-b806ea5c879a",
"name": "📌 RD · SALIDA"
},
{
"parameters": {
"operation": "pdf",
"options": {
"joinPages": true,
"keepSource": "json"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
-1584,
6896
],
"id": "6d6c35a5-a3e4-4c68-b1b6-9047c86a16d9",
"name": "Extract - PDF Banco RD",
"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(/\\r/g,'').replace(/[ \\t]+/g,' ').trim();}\nfunction norm(v){return clean(v).toLowerCase().normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/\\s+/g,' ').trim();}\nfunction acct(v){return clean(v).replace(/\\D/g,'').trim();} function matchAcct(v){return acct(v).replace(/^0+(?=\\d)/,'');}\nfunction money(v){const p=Number.parseFloat(String(v??'').replace(/RD\\$|DOP|\\$/gi,'').replace(/,/g,'').replace(/\\s+/g,''));return Number.isFinite(p)?Math.round(p*100)/100:0;}\nfunction iso(v){const x=clean(v);let m=x.match(/(\\d{2})[\\/-](\\d{2})[\\/-](\\d{4})/);if(m)return `${m[3]}-${m[2]}-${m[1]}`;m=x.match(/(\\d{4})-(\\d{2})-(\\d{2})/);return m?`${m[1]}-${m[2]}-${m[3]}`:'';}\nfunction inPeriod(d){const s=clean(base.metadata?.period_start),e=clean(base.metadata?.period_end);return !d||!s||!e||(d>=s&&d<=e);}\nfunction sourceMeta(i){try{return $('Preparar PDF banco RD').itemMatching(i).json||{};}catch{return {};}}\nfunction pick(text,label,nextLabel){const re=new RegExp(label+'\\\\s*:?\\\\s*([\\\\s\\\\S]*?)(?='+nextLabel+'|$)','i');const m=text.match(re);return clean(m?.[1]||'');}\nconst rows=[],summaries=[];\nfor(let i=0;i<items.length;i++){const it=items[i]||{};if(it.json?.no_pdf_files)continue;const linked=sourceMeta(i);const sourceFile=clean(linked.source_file||it.json?.source_file||'');const text=String(it.json?.text??it.json?.data??it.json?.content??'');if(!text.trim()){if(sourceFile)throw new Error(`RD: no se pudo extraer texto del PDF ${sourceFile}.`);continue;}\n const name=clean((text.match(/Beneficiario\\s*:?\\s*([^\\n\\r]+)/i)||[])[1]||'');\n let account='';let am=text.match(/Beneficiario\\s*:?\\s*[^\\n\\r]+[\\r\\n]+\\s*([0-9]{6,20})\\s*-\\s*[^\\n\\r]+/i);if(!am)am=text.match(/(?:Cuenta|No\\.? de cuenta)[^\\n\\r]*[\\r\\n]?\\s*([0-9][0-9\\- ]{5,})\\s*(?:-|Banco|BHD)/i);if(!am)am=text.match(/\\b([0-9]{6,20})\\s*-\\s*(?:BANCO\\s+)?[A-ZÁÉÍÓÚÑ .,&-]+/i);if(am)account=acct(am[1]);\n const date=iso((text.match(/Fecha de efectividad\\s*:?\\s*([^\\n]+)/i)||[])[1]||'');\n const amount=money((text.match(/Monto\\s*(?:RD\\$|DOP|\\$)?\\s*:?\\s*(?:RD\\$|DOP|\\$)?\\s*([\\d,.]+)/i)||[])[1]||'');\n const status=clean((text.match(/Estado de transacci[oó]n\\s*:?\\s*([^\\n]+)/i)||[])[1]||'Procesada');const reason=clean((text.match(/Motivo\\s*:?\\s*([^\\n]+)/i)||[])[1]||'');const instruction=clean((text.match(/N[uú]mero de instrucci[oó]n\\s*:?\\s*([^\\n]+)/i)||[])[1]||'');\n let description=clean((text.match(/Descripci[oó]n\\s*:?\\s*([^\\n]+(?:\\n(?!Monto|Estado|Motivo|N[uú]mero)[^\\n]+)*)/i)||[])[1]||'');\n if(!account||amount<=0||!name)throw new Error(`RD: no se pudieron identificar Beneficiario, cuenta y monto en ${sourceFile}.`);if(date&&!inPeriod(date))throw new Error(`RD: ${sourceFile} tiene fecha efectiva ${date}, fuera del período seleccionado.`);\n const statusN=norm(status);const processed=!statusN.includes('rechaz')&&(statusN.includes('proces')||statusN.includes('aplic')||statusN.includes('complet')||statusN==='');const rejected=statusN.includes('rechaz');let origin='BHD ACH';const dn=norm(description);if(dn.includes('whirlpool')||dn.includes('whp'))origin='Whirlpool';else if(dn.includes('bice'))origin='BICE';else if(dn.includes('essity'))origin='Essity';else if(dn.includes('purina'))origin='Temp Purina';\n const row={source_file:sourceFile,row_number:1,bank_row_key:`RD_PDF|${sourceFile}|${instruction||account}`,account,account_match:matchAcct(account),raw_account:account,account_is_valid:true,reference:instruction,bank_name_file:name,bank_account_holder:'',amount,currency:'DOP',application_date:date,concept:description,email:'',status,status_normalized:statusN,processed,rejected,rejection_reason:reason,origin,is_supplement:false};rows.push(row);summaries.push({file_name:sourceFile,origin,ok:true,rows_count:1,processed_total:processed?amount:0,effective_date:date});\n}\nreturn [{json:{stage:'banco_rd_pdf_parseado',bank_pdf:{rows,file_summaries:summaries,rows_count:rows.length}}}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-1344,
6896
],
"id": "e6237408-129d-4df6-bfb8-5a7ca31a80e7",
"name": "Parsear PDF banco RD"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "ed579987-a9c3-404e-a548-8c66fae47745",
"leftValue": "={{ $json.ok }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
-2064,
7136
],
"id": "adde62fe-fb7a-4fe5-8711-470fce265d4e",
"name": "¿Entrada RD válida?"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { ok: false, stage: $json.stage || 'entrada_pr_invalida', message: 'No fue posible iniciar el cruce de República Dominicana.', errors: Array.isArray($json.errors) ? $json.errors : ['Solicitud inválida.'] } }}",
"options": {
"responseCode": 400,
"responseHeaders": {
"entries": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Access-Control-Allow-Origin",
"value": "*"
}
]
}
}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [
-1824,
7664
],
"id": "d25716f4-44df-45c6-8959-6201b13ca9cd",
"name": "Responder error entrada RD"
},
{
"parameters": {
"jsCode": "const input=$input.first();const base=input.json||{};const binary=input.binary||{};const files=Array.isArray(base.bank_files)?base.bank_files.filter(f=>String(f.file_extension||'').toLowerCase()==='csv'):[];const out=[];for(const info of files){const key=String(info.binary_key||'');if(!key||!binary[key])continue;out.push({json:{source_file:String(binary[key]?.fileName||info.file_name||key),source_binary_key:key,currency:'DOP'},binary:{data:binary[key]}});}if(!out.length)return [{json:{no_csv_files:true,source_file:''}}];return out;"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-1824,
6656
],
"id": "cfbca2df-d207-4722-b3f9-736d77a14225",
"name": "Preparar CSV banco RD"
},
{
"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 norm(v){return clean(v).toLowerCase().normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[^a-z0-9 ]/g,' ').replace(/\\s+/g,' ').trim();}\nfunction parseLine(line){const out=[];let cur='',q=false;for(let i=0;i<line.length;i++){const c=line[i],n=line[i+1];if(c==='\"'&&q&&n==='\"'){cur+='\"';i++;continue;}if(c==='\"'){q=!q;continue;}if(c===','&&!q){out.push(cur.trim());cur='';continue;}cur+=c;}out.push(cur.trim());return out;}\nfunction idx(headers,names){const nh=headers.map(norm);for(const n of names){const x=nh.indexOf(norm(n));if(x>=0)return x;}for(const n of names){const e=norm(n);const x=nh.findIndex(h=>h.includes(e));if(x>=0)return x;}return -1;}\nfunction amt(v){const p=Number.parseFloat(String(v??'').replace(/DOP|RD\\$|RD|\\$/gi,'').replace(/,/g,'').replace(/\\s+/g,''));return Number.isFinite(p)?Math.round(p*100)/100:0;}\nfunction acct(v){return clean(v).replace(/\\.0$/,'').replace(/\\D/g,'').trim();}\nfunction matchAcct(v){return acct(v).replace(/^0+(?=\\d)/,'');}\nfunction origin(file){let f=String(file||'').replace(/N#U00f3minas/gi,'Nóminas');let m=f.match(/Pago de N[oó]minas_(.+?)_\\d{8}(?:\\.\\d+)?\\.csv$/i);return clean(m?.[1]||f.replace(/\\.csv$/i,''));}\nfunction dateFromFile(file){const m=String(file||'').match(/_(\\d{2})(\\d{2})(\\d{4})(?:\\.\\d+)?\\.csv$/i);return m?`${m[3]}-${m[2]}-${m[1]}`:'';}\nfunction inPeriod(d){const s=clean(base.metadata?.period_start),e=clean(base.metadata?.period_end);return !d||!s||!e||(d>=s&&d<=e);}\nfunction sourceMeta(i){try{return $('Preparar CSV banco RD').itemMatching(i).json||{};}catch{return {};}}\nconst rows=[],summaries=[];\nfor(let i=0;i<items.length;i++){const it=items[i]||{};if(it.json?.no_csv_files)continue;const linked=sourceMeta(i);const sourceFile=clean(linked.source_file||it.json?.source_file||'');let buffer;try{buffer=await this.helpers.getBinaryDataBuffer(i,'data');}catch{continue;}let text=buffer.toString('utf8');if((text.match(/�/g)||[]).length>5)text=buffer.toString('latin1');const lines=text.split(/\\r?\\n/).filter(l=>l.trim().length);if(!lines.length)continue;let hi=lines.findIndex(l=>{const n=norm(l);return n.includes('monto')&&(n.includes('cuenta')||n.includes('beneficiario')||n.includes('empleado'));});if(hi<0){summaries.push({file_name:sourceFile,ok:false,error:'Encabezado CSV no reconocido'});continue;}const h=parseLine(lines[hi]);const iName=idx(h,['Nombre del empleado','Beneficiario','Nombre en Archivo','Nombre']);const iAcct=idx(h,['Cuenta del empleado','No. de cuenta','No. De Cuenta','Cuenta','Cuenta destino']);const iRef=idx(h,['No. de referencia','No de referencia','Referencia']);const iConcept=idx(h,['Concepto','Descripción','Descripcion']);const iEmail=idx(h,['Correo del beneficiario','Correo','Email']);const iAmount=idx(h,['Monto']);const iStatus=idx(h,['Estado','Status']);const iReason=idx(h,['Motivo del rechazo','Motivo']);const eff=dateFromFile(sourceFile);if(!inPeriod(eff))throw new Error(`RD: ${sourceFile} pertenece a ${eff}, fuera del período seleccionado.`);const org=origin(sourceFile);let accepted=0,total=0;\n for(let j=hi+1;j<lines.length;j++){const v=parseLine(lines[j]);const amount=amt(iAmount>=0?v[iAmount]:'');const account=acct(iAcct>=0?v[iAcct]:'');if(amount<=0||account.length<5)continue;const status=clean(iStatus>=0?v[iStatus]:'Procesada');const statusN=norm(status);const processed=statusN.includes('procesad')||statusN.includes('aplicad')||statusN.includes('complet');const rejected=statusN.includes('rechaz');const name=clean(iName>=0?v[iName]:'');const reason=clean(iReason>=0?v[iReason]:'');const row={source_file:sourceFile.replace(/N#U00f3minas/gi,'Nóminas'),row_number:j+1,bank_row_key:`RD_CSV|${sourceFile}|${j+1}`,account,account_match:matchAcct(account),raw_account:account,account_is_valid:true,reference:clean(iRef>=0?v[iRef]:''),bank_name_file:name,bank_account_holder:'',amount,currency:'DOP',application_date:eff,concept:clean(iConcept>=0?v[iConcept]:''),email:clean(iEmail>=0?v[iEmail]:'').toLowerCase(),status,status_normalized:statusN,processed,rejected,rejection_reason:reason,origin:org,is_supplement:norm(org).includes('bono bice')};rows.push(row);accepted++;if(processed)total+=amount;}\n summaries.push({file_name:sourceFile.replace(/N#U00f3minas/gi,'Nóminas'),origin:org,ok:true,rows_count:accepted,processed_total:Math.round(total*100)/100,effective_date:eff});\n}\nreturn [{json:{stage:'banco_rd_csv_parseado',bank_csv:{rows,file_summaries:summaries,rows_count:rows.length}}}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-1344,
6656
],
"id": "d893ba59-6f82-471c-9138-6a13f02f0f29",
"name": "Parsear CSV banco RD"
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"options": {}
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
-1120,
6816
],
"id": "01613afc-0399-444e-b2c1-7ac112b3eaf1",
"name": "Merge CSV + PDF RD"
},
{
"parameters": {
"jsCode": "const input=$input.first().json||{}; const base=$('Preparar entrada app').first().json||{};\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();} function round(v){return Math.round((Number(v)||0)*100)/100;} function norm(v){return clean(v).toLowerCase().normalize('NFD').replace(/[\\u0300-\\u036f]/g,'');}\nconst csv=input.bank_csv||{};const pdf=input.bank_pdf||{};const rows=[...(Array.isArray(csv.rows)?csv.rows:[]),...(Array.isArray(pdf.rows)?pdf.rows:[])];if(!rows.length)throw new Error('RD: no se encontró ninguna transacción bancaria válida en los archivos cargados.');\n// Protección contra duplicados exactos entre archivos/lotes. La fecha+cuenta+monto+referencia/nombre define una transacción.\nconst seen=new Map();for(const row of rows){const key=[row.application_date||'',row.account_match||row.account||'',Number(row.amount||0).toFixed(2),clean(row.reference)||norm(row.bank_name_file)].join('|');if(seen.has(key)){const prev=seen.get(key);if(prev.source_file!==row.source_file)throw new Error(`RD: transacción bancaria duplicada entre ${prev.source_file} y ${row.source_file} (${row.account}, RD$ ${Number(row.amount||0).toFixed(2)}).`);}else seen.set(key,row);}\nconst processed=rows.filter(r=>r.processed===true);const rejected=rows.filter(r=>r.rejected===true);const total=round(processed.reduce((s,r)=>s+Number(r.amount||0),0));const supplements=rows.filter(r=>r.is_supplement===true);\nreturn [{json:{...base,stage:'banco_rd_normalizado',bank:{files_count:Number(base.summary?.bank_files_count||0),file_summaries:[...(csv.file_summaries||[]),...(pdf.file_summaries||[])],rows_count:processed.length,all_rows_count:rows.length,rejected_rows_count:rejected.length,total_amount:total,totals_by_currency:{DOP:total},rows,processed_rows:processed,rejected_rows:rejected,supplement_rows:supplements}}}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-880,
6816
],
"id": "5f8522f0-b197-4c7f-ad7e-6426fa05dadf",
"name": "Consolidar Banco RD"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "2ee21859-e9a1-4a13-a99d-75c86cd1df2d",
"leftValue": "={{ $json.no_pdf_files !== true }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
-1712,
6896
],
"id": "8f0021c8-9b24-410e-9dbf-edc24a655618",
"name": "¿Hay PDF banco RD?"
}
],
"pinData": {},
"connections": {
"Webhook": {
"main": [
[
{
"node": "Preparar entrada app",
"type": "main",
"index": 0
}
]
]
},
"Preparar entrada app": {
"main": [
[
{
"node": "¿Entrada RD válida?",
"type": "main",
"index": 0
}
]
]
},
"¿Entrada RD válida?": {
"main": [
[
{
"node": "Preparar PDF banco RD",
"type": "main",
"index": 0
},
{
"node": "Preparar CSV banco RD",
"type": "main",
"index": 0
},
{
"node": "Preparar archivos nómina RD",
"type": "main",
"index": 0
}
],
[
{
"node": "Responder error entrada RD",
"type": "main",
"index": 0
}
]
]
},
"Preparar PDF banco RD": {
"main": [
[
{
"node": "¿Hay PDF banco RD?",
"type": "main",
"index": 0
}
]
]
},
"Extract - PDF Banco RD": {
"main": [
[
{
"node": "Parsear PDF banco RD",
"type": "main",
"index": 0
}
]
]
},
"Parsear PDF banco RD": {
"main": [
[
{
"node": "Merge CSV + PDF RD",
"type": "main",
"index": 1
}
]
]
},
"Preparar archivos nómina RD": {
"main": [
[
{
"node": "Extract - Archivo Nómina RD",
"type": "main",
"index": 0
}
]
]
},
"Extract - Archivo Nómina RD": {
"main": [
[
{
"node": "Normalizar Nómina RD",
"type": "main",
"index": 0
}
]
]
},
"Normalizar Nómina RD": {
"main": [
[
{
"node": "Merge Banco + Nómina RD",
"type": "main",
"index": 1
}
]
]
},
"Merge Banco + Nómina RD": {
"main": [
[
{
"node": "HTTP - Empleados BambooHR RD",
"type": "main",
"index": 0
},
{
"node": "Merge - Agregar BambooHR RD",
"type": "main",
"index": 0
}
]
]
},
"HTTP - Empleados BambooHR RD": {
"main": [
[
{
"node": "Normalizar BambooHR RD",
"type": "main",
"index": 0
}
]
]
},
"Normalizar BambooHR RD": {
"main": [
[
{
"node": "Merge - Agregar BambooHR RD",
"type": "main",
"index": 1
}
]
]
},
"Merge - Agregar BambooHR RD": {
"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
}
]
]
},
"¿Hay PDF banco RD?": {
"main": [
[
{
"node": "Extract - PDF Banco RD",
"type": "main",
"index": 0
}
],
[
{
"node": "Parsear PDF banco RD",
"type": "main",
"index": 0
}
]
]
},
"Preparar CSV banco RD": {
"main": [
[
{
"node": "Parsear CSV banco RD",
"type": "main",
"index": 0
}
]
]
},
"Parsear CSV banco RD": {
"main": [
[
{
"node": "Merge CSV + PDF RD",
"type": "main",
"index": 0
}
]
]
},
"Merge CSV + PDF RD": {
"main": [
[
{
"node": "Consolidar Banco RD",
"type": "main",
"index": 0
}
]
]
},
"Consolidar Banco RD": {
"main": [
[
{
"node": "Merge Banco + Nómina RD",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1",
"binaryMode": "separate"
},
"versionId": "8bbd433e-cf87-4d10-b1e5-f1de1c7602f3",
"meta": {
"instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70"
},
"id": "",
"tags": []
}