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

1197 lines
234 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 - Puerto Rico",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "nominapr-bamboo-test",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
-1088,
512
],
"id": "cce6050e-e39e-4f9b-8af2-c1b7a2f756ad",
"name": "Webhook",
"webhookId": "00132835-9daf-4f36-95ee-233ff6f7b691"
},
{
"parameters": {
"jsCode": "const item = $input.first();\nconst body = item.json.body || {};\nconst binary = item.binary || {};\n\nlet metadata = {};\ntry {\n metadata = typeof body.metadata === 'string'\n ? JSON.parse(body.metadata)\n : body.metadata || {};\n} catch (error) {\n metadata = {};\n}\n\nconst binaryKeys = Object.keys(binary);\n\nfunction naturalBinarySort(left, right) {\n return String(left).localeCompare(String(right), undefined, {\n numeric: true,\n sensitivity: 'base',\n });\n}\n\nfunction extensionOf(file) {\n const explicit = String(file?.fileExtension || '').toLowerCase().replace(/^\\./, '');\n if (explicit) return explicit;\n const name = String(file?.fileName || '').toLowerCase();\n const match = name.match(/\\.([a-z0-9]+)$/i);\n return match ? match[1] : '';\n}\n\nconst numberedPayrollKeys = binaryKeys\n .filter((key) => /^payroll_file_\\d+$/i.test(key))\n .sort(naturalBinarySort);\nconst repeatedPayrollKeys = binaryKeys\n .filter((key) => /^payroll_files(?:_?\\d+)?$/i.test(key))\n .sort(naturalBinarySort);\nlet payrollKeys = numberedPayrollKeys.length ? numberedPayrollKeys : repeatedPayrollKeys;\nif (payrollKeys.length === 0 && binary.payroll_file) payrollKeys = ['payroll_file'];\n\nconst numberedBankKeys = binaryKeys\n .filter((key) => /^bank_file_\\d+$/i.test(key))\n .sort(naturalBinarySort);\nconst repeatedBankKeys = binaryKeys\n .filter((key) => /^bank_files(?:_?\\d+)?$/i.test(key))\n .sort(naturalBinarySort);\nlet bankKeys = numberedBankKeys.length ? numberedBankKeys : repeatedBankKeys;\nif (bankKeys.length === 0 && binary.bank_file) bankKeys = ['bank_file'];\n\nconst payrollFiles = payrollKeys.map((key) => ({\n binary_key: key,\n file_name: binary[key]?.fileName || key,\n file_extension: extensionOf(binary[key]),\n mime_type: binary[key]?.mimeType || '',\n file_size: binary[key]?.fileSize || null,\n}));\n\nconst bankFiles = bankKeys.map((key) => ({\n binary_key: key,\n file_name: binary[key]?.fileName || key,\n file_extension: extensionOf(binary[key]),\n mime_type: binary[key]?.mimeType || '',\n file_size: binary[key]?.fileSize || null,\n}));\n\nconst receivedCountry = String(metadata.country || '').trim().toUpperCase();\nconst errors = [];\n\nif (receivedCountry !== 'PR') errors.push('El país recibido no es Puerto Rico.');\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 === 0) errors.push('No se recibió el archivo Excel de nómina de Puerto Rico.');\nif (bankFiles.length === 0) errors.push('No se recibió el PDF de pagos de Banco Popular de Puerto Rico.');\n\nconst badPayroll = payrollFiles.filter((file) => !['xlsx', 'xls', 'xlsm'].includes(file.file_extension));\nif (badPayroll.length) {\n errors.push(`Puerto Rico requiere nómina Excel (.xlsx, .xls o .xlsm). Archivo(s) inválido(s): ${badPayroll.map((f) => f.file_name).join(', ')}.`);\n}\nconst badBank = bankFiles.filter((file) => file.file_extension !== 'pdf');\nif (badBank.length) {\n errors.push(`Puerto Rico requiere el Transaction Detail Report de Banco Popular en PDF. Archivo(s) inválido(s): ${badBank.map((f) => f.file_name).join(', ')}.`);\n}\n\nconst mainPayroll = payrollFiles[0] || null;\nconst normalizedMetadata = {\n ...metadata,\n country: 'PR',\n country_name: 'Puerto Rico',\n currency: 'USD',\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: Array.isArray(metadata.payroll_file_names) && metadata.payroll_file_names.length\n ? metadata.payroll_file_names\n : payrollFiles.map((file) => file.file_name),\n bank_file_names: Array.isArray(metadata.bank_file_names) && metadata.bank_file_names.length\n ? metadata.bank_file_names\n : bankFiles.map((file) => file.file_name),\n};\n\nreturn [{\n json: {\n ok: errors.length === 0,\n stage: 'entrada_pr_recibida',\n errors,\n metadata: normalizedMetadata,\n payroll_files: payrollFiles,\n bank_files: bankFiles,\n summary: {\n payroll_files_count: payrollFiles.length,\n bank_files_count: bankFiles.length,\n },\n },\n binary,\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-848,
512
],
"id": "0b3dff96-467b-4a56-8da0-e93ae51f4c44",
"name": "Preparar entrada app"
},
{
"parameters": {
"jsCode": "const input = $input.first();\nconst base = input.json || {};\nconst binary = input.binary || {};\nconst configuredFiles = Array.isArray(base.bank_files) ? base.bank_files : [];\nconst outputs = [];\n\nfor (const fileInfo of configuredFiles) {\n const key = String(fileInfo.binary_key || '');\n const file = binary[key];\n if (!key || !file) continue;\n outputs.push({\n json: {\n source_file: String(file.fileName || fileInfo.file_name || key),\n source_binary_key: key,\n source_kind: 'bppr_pdf',\n currency: 'USD',\n },\n binary: { data: file },\n });\n}\n\nif (!outputs.length) {\n throw new Error('Puerto Rico: no se pudo preparar el PDF de Banco Popular.');\n}\nreturn outputs;"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-368,
272
],
"id": "af3c16fb-60a1-4f0f-a604-bf33ee3c7ac8",
"name": "Preparar PDF banco PR"
},
{
"parameters": {
"jsCode": "const input = $input.first();\nconst base = input.json || {};\nconst binary = input.binary || {};\nconst configuredFiles = Array.isArray(base.payroll_files) ? base.payroll_files : [];\nconst SHEETS = [\n ['Whirlpool', 1, 'USD'],\n ['Personal 2', 1, 'USD'],\n ['Personal 3', 1, 'USD'],\n];\nconst outputs = [];\nfor (const fileInfo of configuredFiles) {\n const key = String(fileInfo.binary_key || '');\n const file = binary[key];\n if (!key || !file) continue;\n const fileName = String(file.fileName || fileInfo.file_name || key);\n for (const [sheetName, headerRow, currency] of SHEETS) {\n outputs.push({\n json: {\n source_file: fileName,\n source_binary_key: key,\n source_kind: 'quincenal',\n payroll_group: 'puerto_rico',\n sheet_name: sheetName,\n header_row: headerRow,\n range: `A${headerRow}:CA2000`,\n currency,\n },\n binary: { data: file },\n });\n }\n}\nif (!outputs.length) {\n throw new Error('Puerto Rico: no se pudo preparar el archivo Excel de nómina.');\n}\nreturn outputs;"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-368,
752
],
"id": "5e49fc7d-692e-467f-9618-509f13a8674e",
"name": "Preparar archivos nómina PR"
},
{
"parameters": {
"operation": "xlsx",
"options": {
"headerRow": true,
"range": "={{ $json.range }}",
"sheetName": "={{ $json.sheet_name }}"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
-128,
752
],
"id": "f52d1ad9-6b32-4ea3-891b-2a16f804c4c9",
"name": "Extract - Archivo Nómina PR",
"retryOnFail": false,
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "const items = $input.all();\nconst base = $('Preparar entrada app').first().json || {};\n\nfunction clean(value) {\n return String(value ?? '').replace(/\\uFEFF/g, '').replace(/\\u00A0/g, ' ').replace(/\\s+/g, ' ').trim();\n}\nfunction normalize(value) {\n return clean(value).toLowerCase().normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').replace(/[^a-z0-9 #]/g, ' ').replace(/\\s+/g, ' ').trim();\n}\nfunction parseMoney(value) {\n if (typeof value === 'number') return Number.isFinite(value) ? value : 0;\n const parsed = Number.parseFloat(String(value ?? '').replace(/USD/gi, '').replace(/\\$/g, '').replace(/,/g, '').replace(/\\s+/g, '').trim());\n return Number.isFinite(parsed) ? parsed : 0;\n}\nfunction roundMoney(value) { return Math.round((Number(value) || 0) * 100) / 100; }\nfunction normalizeAccount(value) {\n const raw = clean(value);\n if (!raw || /[A-Za-zÀ-ÿ]/.test(raw)) return '';\n if (typeof value === 'number' && Number.isFinite(value)) return String(Math.round(value));\n if (/^[+-]?(?:\\d+(?:\\.\\d+)?|\\.\\d+)[eE][+-]?\\d+$/.test(raw)) {\n const number = Number(raw);\n if (Number.isFinite(number)) return String(Math.round(number));\n }\n const account = raw.replace(/\\.0$/g, '').replace(/\\D/g, '').trim();\n return account.length >= 5 && !/^0+$/.test(account) ? account : '';\n}\nfunction getValue(row, keys) {\n for (const key of keys) if (row[key] !== undefined && row[key] !== null && row[key] !== '') return row[key];\n const actual = Object.keys(row || {});\n for (const expected of keys) {\n const normalizedExpected = normalize(expected);\n const key = actual.find((candidate) => normalize(candidate) === normalizedExpected);\n if (key) {\n const value = row[key];\n if (value !== undefined && value !== null && value !== '') return value;\n }\n }\n return '';\n}\nfunction validEmployeeName(value) {\n const name = clean(value);\n const normalized = normalize(name);\n if (!name || /^[\\d.,\\s-]+$/.test(name)) return false;\n const bad = ['total', 'subtotal', 'total general', 'gran total', 'nombre', 'empleado', 'colaborador'];\n return !bad.some((term) => normalized === term || normalized.startsWith(`${term} `));\n}\nfunction sourceMeta(index) {\n try { return $('Preparar archivos nómina PR').itemMatching(index).json || {}; }\n catch { return {}; }\n}\n\nconst rows = [];\nconst ignored = [];\nconst counters = new Map();\nconst summaries = new Map();\n\nitems.forEach((item, index) => {\n const source = item.json || {};\n const linked = sourceMeta(index);\n const sourceFile = clean(linked.source_file || '');\n const sourceSheet = clean(linked.sheet_name || '');\n const currency = 'USD';\n const key = `${sourceFile}|${sourceSheet}`;\n const dataIndex = (counters.get(key) || 0) + 1;\n counters.set(key, dataIndex);\n const rowNumber = Number(linked.header_row || 1) + dataIndex;\n\n const employeeName = clean(getValue(source, ['NOMBRE', 'Nombre', 'Nombre del Empleado', 'NOMBRE DEL EMPLEADO']));\n const account = normalizeAccount(getValue(source, ['Cuenta', 'CUENTA', 'Cuenta Banco', 'CUENTA BANCO', 'CUENTA BANCARIA']));\n const amount = roundMoney(parseMoney(getValue(source, ['NETO A PAGAR', 'Neto a Pagar', 'Total a Pagar', 'TOTAL A PAGAR', 'Total Neto'])));\n\n const summary = summaries.get(key) || {\n source_file: sourceFile,\n source_sheet: sourceSheet,\n currency,\n extracted_rows_count: 0,\n accepted_rows_count: 0,\n ignored_rows_count: 0,\n total_amount: 0,\n };\n summary.extracted_rows_count += 1;\n\n if (!validEmployeeName(employeeName) || amount <= 0 || amount > 5000000) {\n summary.ignored_rows_count += 1;\n summaries.set(key, summary);\n if (employeeName || amount) ignored.push({ source_file: sourceFile, source_sheet: sourceSheet, row_number: rowNumber, employee_name: employeeName, account, payroll_amount: amount, currency });\n return;\n }\n\n rows.push({\n payroll_row_key: `PR_PAYROLL_${index + 1}_${rowNumber}`,\n row_number: rowNumber,\n employee_name: employeeName,\n employee_number: '',\n account,\n payroll_account: account,\n payroll_amount: amount,\n currency,\n source_file: sourceFile,\n source_kind: 'quincenal',\n source_sheet: sourceSheet,\n payroll_group: 'puerto_rico',\n is_main_payroll: true,\n raw: source,\n });\n summary.accepted_rows_count += 1;\n summary.total_amount = roundMoney(summary.total_amount + amount);\n summaries.set(key, summary);\n});\n\nif (!rows.length) {\n throw new Error('Puerto Rico: no se encontró ninguna fila válida en las hojas Whirlpool, Personal 2 y Personal 3.');\n}\nconst totalAmount = roundMoney(rows.reduce((sum, row) => sum + row.payroll_amount, 0));\nreturn [{\n json: {\n ...base,\n stage: 'nomina_pr_normalizada',\n payroll: {\n source: 'nomina_puerto_rico_usd',\n files_count: Number(base.summary?.payroll_files_count || 0),\n file_summaries: Array.from(summaries.values()),\n raw_rows_count: items.length,\n rows_count: rows.length,\n valid_rows_count: rows.length,\n base_rows_count: rows.length,\n supplement_rows_count: 0,\n attached_supplements_count: 0,\n unattached_supplements_count: 0,\n no_account_rows_count: rows.filter((row) => !row.account).length,\n ignored_rows_count: ignored.length,\n base_total: totalAmount,\n supplements_total: 0,\n total_amount: totalAmount,\n totals_by_currency: { USD: totalAmount },\n rows,\n no_account_rows: [],\n grouped_by_account: [],\n potential_supplements: [],\n },\n debug_payroll: {\n file_summaries: Array.from(summaries.values()),\n ignored_rows_preview: ignored.slice(0, 100),\n },\n },\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
112,
752
],
"id": "b74e9e31-da6e-4eca-a620-ec70c92b1fa3",
"name": "Normalizar Nómina PR"
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"options": {}
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
400,
512
],
"id": "6ddf574c-188e-40a3-b657-8a415114d2b0",
"name": "Merge Banco + Nómina PR"
},
{
"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 Puerto Rico",
"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": [
640,
352
],
"id": "a0035219-8292-4154-b840-8fcf8d8b32d4",
"name": "HTTP - Empleados BambooHR PR",
"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 PR').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 === 'pr' ||\n country.includes('puerto rico') ||\n location === 'pr' ||\n location.includes('puerto rico')\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 'puerto_rico_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 Puerto Rico 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 Puerto Rico,\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 Puerto Rico.\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 Puerto Rico 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_pr_normalizado'\n : 'bamboohr_pr_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 puerto_rico_count:\n targetEmployees.length,\n active_in_period_count:\n targetEmployees.filter(\n (employee) =>\n employee.overlaps_period\n ).length,\n active_status_count:\n targetEmployees.filter(\n (employee) =>\n normalize(employee.status) ===\n 'active'\n ).length,\n contextual_outside_country_count:\n contextualOutsideMap.size,\n contextual_outside_skipped_by_target_exact_count:\n contextualOutsideSkippedByTargetExact,\n validation_candidates_count:\n validationEmployees.length,\n resolved_name_matches:\n resolvedNameMatches,\n resolved_name_matches_count:\n Object.keys(\n resolvedNameMatches\n ).length,\n resolved_name_matches_found:\n resolvedNameMatchesFound,\n name_resolution_strategy:\n 'precomputed_indexed_fuzzy_matching_with_target_exact_precedence',\n fetch_complete: fetchComplete,\n validation_available:\n fetchComplete &&\n validationEmployees.length > 0,\n validation_rule:\n 'Target country/location first; outside-country contextual rescue only when no exact target alias exists',\n performance_strategy:\n 'precomputed_alias_tokens_and_inverted_index',\n employees:\n validationEmployees,\n restricted_fields:\n restrictedFields,\n },\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
880,
352
],
"id": "ed8d309b-9742-4eba-a0c4-6b3fbe77c9cd",
"name": "Normalizar BambooHR PR"
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"options": {}
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
1120,
512
],
"id": "17f7daaa-1093-4885-86e8-071ba9bdc366",
"name": "Merge - Agregar BambooHR PR"
},
{
"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": "PR"
},
"options": {
"response": {
"response": {
"responseFormat": "json"
}
},
"timeout": 30000
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
1360,
512
],
"id": "f0d44f40-cf96-4385-b4f3-4220af926fbd",
"name": "Obtener alias BambooHR Supabase",
"retryOnFail": true,
"maxTries": 2,
"waitBetweenTries": 1000,
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "const data = $('Merge - Agregar BambooHR PR').first().json || {};\nconst aliasResponse = $input.first().json || {};\nconst validatedBambooAliases = Array.isArray(aliasResponse.aliases)\n ? aliasResponse.aliases\n : Array.isArray(aliasResponse)\n ? aliasResponse\n : [];\n\nfunction clean(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\u00A0/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nconst normalizedNameCache = new Map();\nconst nameWordsCache = new Map();\nconst nameMatchScoreCache = new Map();\n\nfunction normalizeName(value) {\n const raw = clean(value);\n if (normalizedNameCache.has(raw)) {\n return normalizedNameCache.get(raw);\n }\n\n const normalized = raw\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/['’`-]/g, ' ')\n .replace(/[^a-z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n\n normalizedNameCache.set(raw, normalized);\n return normalized;\n}\n\nfunction normalizeAccount(value) {\n return String(value ?? '').replace(/\\.0$/g, '').replace(/\\D/g, '').trim();\n}\n\nfunction normalizeEmployeeNumber(value) {\n return String(value ?? '')\n .replace(/\\u00A0/g, '')\n .replace(/\\D/g, '')\n .trim();\n}\n\nfunction roundMoney(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n}\n\nfunction moneyDiff(payroll, bank) {\n return roundMoney((Number(payroll) || 0) - (Number(bank) || 0));\n}\n\nconst MONEY_TOLERANCE = 0.01;\nconst MONEY_TOLERANCE_CENTS = Math.round(MONEY_TOLERANCE * 100);\n\nfunction sameCent(a, b) {\n return Math.abs(moneyDiff(a, b)) <= MONEY_TOLERANCE + 1e-9;\n}\n\nconst IGNORED_NAME_TOKENS = new Set([\n 'de', 'del', 'la', 'las', 'los', 'y', 'e', 'el',\n 'da', 'do', 'dos', 'das', 'van', 'von',\n]);\n\nfunction nameWords(value) {\n const normalized = normalizeName(value);\n if (nameWordsCache.has(normalized)) {\n return nameWordsCache.get(normalized);\n }\n\n const words = normalized\n .split(' ')\n .filter((word) => word.length > 1 && !IGNORED_NAME_TOKENS.has(word));\n\n nameWordsCache.set(normalized, words);\n return words;\n}\n\nfunction editDistance(a, b) {\n if (a === b) return 0;\n if (!a) return b.length;\n if (!b) return a.length;\n let previous = Array.from({ length: b.length + 1 }, (_, index) => index);\n for (let i = 1; i <= a.length; i++) {\n const current = [i];\n for (let j = 1; j <= b.length; j++) {\n const cost = a[i - 1] === b[j - 1] ? 0 : 1;\n current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, previous[j - 1] + cost);\n }\n previous = current;\n }\n return previous[b.length];\n}\n\nfunction tokenMatches(a, b) {\n if (a === b) return true;\n const minLength = Math.min(a.length, b.length);\n if (minLength >= 8 && editDistance(a, b) <= 2) return true;\n if (minLength >= 5 && editDistance(a, b) <= 1) return true;\n return false;\n}\n\nfunction samePersonName(a, b) {\n const normalizedA = normalizeName(a);\n const normalizedB = normalizeName(b);\n if (!normalizedA || !normalizedB) return false;\n if (normalizedA === normalizedB) return true;\n\n const wordsA = nameWords(a);\n const wordsB = nameWords(b);\n if (!wordsA.length || !wordsB.length) return false;\n\n const usedB = new Set();\n let matches = 0;\n for (const wordA of wordsA) {\n const matchIndex = wordsB.findIndex((wordB, index) =>\n !usedB.has(index) && tokenMatches(wordA, wordB)\n );\n if (matchIndex >= 0) {\n usedB.add(matchIndex);\n matches += 1;\n }\n }\n\n const smallerLength = Math.min(wordsA.length, wordsB.length);\n const ratio = matches / smallerLength;\n if (smallerLength <= 2) return matches === smallerLength && matches >= 2;\n return matches >= 2 && ratio >= 0.6;\n}\n\nfunction bankNameLooksConsistent(fileName, holderName) {\n const fileWords = nameWords(fileName);\n const holderWords = nameWords(holderName);\n\n if (!fileWords.length || !holderWords.length) return true;\n\n const used = new Set();\n\n for (const fileWord of fileWords) {\n let matchIndex = holderWords.findIndex(\n (holderWord, index) =>\n !used.has(index) &&\n holderWord === fileWord\n );\n\n // Algunos planos de Puerto Rico recortan el último carácter del apellido\n // por longitud de campo (ej.: ALVARAD -> ALVARADO). Eso no debe\n // convertirse en una diferencia de nombre. No se aceptan sustituciones\n // ortográficas (ej.: GONZALES -> GONZALEZ), porque sí requieren revisión.\n if (matchIndex < 0) {\n matchIndex = holderWords.findIndex(\n (holderWord, index) =>\n !used.has(index) &&\n holderWord.startsWith(fileWord) &&\n (\n (fileWord.length <= 3) ||\n (\n fileWord.length >= 4 &&\n holderWord.length === fileWord.length + 1\n )\n )\n );\n }\n\n if (matchIndex < 0) return false;\n used.add(matchIndex);\n }\n\n return true;\n}\n\nfunction nameMatchScore(a, b) {\n const na = normalizeName(a);\n const nb = normalizeName(b);\n if (!na || !nb) return 0;\n\n const cacheKey = na <= nb ? `${na}\\u0000${nb}` : `${nb}\\u0000${na}`;\n if (nameMatchScoreCache.has(cacheKey)) {\n return nameMatchScoreCache.get(cacheKey);\n }\n\n if (na === nb) {\n nameMatchScoreCache.set(cacheKey, 1);\n return 1;\n }\n\n const wa = nameWords(a);\n const wb = nameWords(b);\n if (!samePersonName(a, b)) {\n nameMatchScoreCache.set(cacheKey, 0);\n return 0;\n }\n\n const setA = new Set(wa);\n const setB = new Set(wb);\n let exact = 0;\n for (const w of setA) if (setB.has(w)) exact += 1;\n const min = Math.max(1, Math.min(setA.size, setB.size));\n const score = Math.min(\n 0.99,\n 0.75 + (exact / min) * 0.20 + (wa.length === wb.length ? 0.04 : 0)\n );\n\n nameMatchScoreCache.set(cacheKey, score);\n return score;\n}\n\nfunction bankNames(row) {\n return [row.bank_name_file, row.bank_account_holder]\n .map(clean)\n .filter(Boolean);\n}\n\nfunction bestNameScore(payrollName, bankRow) {\n return Math.max(0, ...bankNames(bankRow).map((name) => nameMatchScore(payrollName, name)));\n}\n\nfunction bankDisplayName(row) {\n return clean(row.bank_name_file) || clean(row.bank_account_holder) || 'Pago bancario';\n}\n\nfunction bankRowKey(row, index) {\n return clean(row.bank_row_key) || `${clean(row.source_file)}|${row.row_number || index + 1}`;\n}\n\n\nconst metadata = data.metadata || {};\n\nfunction parseBankDate(value, sourceFile = '') {\n const raw = clean(value);\n let match = raw.match(/^(\\d{1,2})\\/(\\d{1,2})\\/(\\d{4})/);\n if (match) {\n return {\n year: Number(match[3]),\n month: Number(match[2]),\n day: Number(match[1]),\n };\n }\n\n match = raw.match(/^(\\d{4})-(\\d{1,2})-(\\d{1,2})/);\n if (match) {\n return {\n year: Number(match[1]),\n month: Number(match[2]),\n day: Number(match[3]),\n };\n }\n\n // Respaldo por el patrón habitual del nombre: _DDMMYY / _DDMMYYYY.\n const fileMatch = clean(sourceFile).match(/_(\\d{2})(\\d{2})(\\d{2}|\\d{4})(?:\\D|$)/);\n if (!fileMatch) return null;\n\n const yearPart = Number(fileMatch[3]);\n return {\n year: fileMatch[3].length === 2 ? 2000 + yearPart : yearPart,\n month: Number(fileMatch[2]),\n day: Number(fileMatch[1]),\n };\n}\n\nfunction selectedPeriodKind() {\n const type = clean(metadata.period_type).toLowerCase();\n if (type === 'quincena_15') return 'q15';\n if (type === 'quincena_30') return 'q30';\n return 'unknown';\n}\n\nfunction conceptPeriodHint(row) {\n const concept = normalizeName(row.concept || '');\n if (!concept) return 'unknown';\n\n if (\n /(?:^| )1(?:ra|er|era)? quincena(?: |$)/.test(concept) ||\n concept.includes('primera quincena') ||\n concept.includes('quincena 1ra') ||\n concept.includes('quincena 1')\n ) {\n return 'q15';\n }\n\n if (\n /(?:^| )2(?:da|do|nda)? quincena(?: |$)/.test(concept) ||\n concept.includes('segunda quincena') ||\n concept.includes('quincena 2da') ||\n concept.includes('quincena 2') ||\n concept.includes('fin de mes')\n ) {\n return 'q30';\n }\n\n return 'unknown';\n}\n\nfunction periodInfoForBankRow(row) {\n const selected = selectedPeriodKind();\n const rowDate = parseBankDate(row.application_date, row.source_file);\n const selectedYear = Number(metadata.year || 0);\n const selectedMonth = Number(metadata.month || 0);\n const hint = conceptPeriodHint(row);\n\n if (selected === 'unknown') {\n return { known: false, selected, inPeriod: true, affinity: 1, rowDate, hint };\n }\n\n if (hint !== 'unknown' && hint === selected) {\n return { known: true, selected, inPeriod: true, affinity: 4, rowDate, hint };\n }\n\n if (!rowDate || !selectedYear || !selectedMonth) {\n return { known: false, selected, inPeriod: true, affinity: 1, rowDate, hint };\n }\n\n if (rowDate.year !== selectedYear || rowDate.month !== selectedMonth) {\n return { known: true, selected, inPeriod: false, affinity: 0, rowDate, hint };\n }\n\n const inHalf = selected === 'q15'\n ? rowDate.day <= 15\n : rowDate.day >= 16;\n\n return {\n known: true,\n selected,\n inPeriod: inHalf,\n affinity: inHalf ? 3 : 0,\n rowDate,\n hint,\n };\n}\n\nfunction isSupplementPayroll(payroll) {\n return clean(payroll?.source_kind).toLowerCase() !== 'quincenal';\n}\n\nfunction bankCandidatePeriodScore(payroll, bank) {\n const info = periodInfoForBankRow(bank);\n if (!info.known || info.inPeriod) return info.affinity || 1;\n\n // Una extraordinaria/vacación/XIII Mes puede pagarse fuera de la mitad\n // estricta del mes. Se permite como respaldo, siempre detrás de cualquier\n // candidato del período seleccionado.\n if (isSupplementPayroll(payroll) && info.rowDate) {\n const selectedYear = Number(metadata.year || 0);\n const selectedMonth = Number(metadata.month || 0);\n if (info.rowDate.year === selectedYear && info.rowDate.month === selectedMonth) {\n return 0.5;\n }\n }\n\n return -1;\n}\n\nconst payrollRows = Array.isArray(data.payroll?.rows) ? data.payroll.rows : [];\nconst bankRows = Array.isArray(data.bank?.rows) ? data.bank.rows : [];\n\n// Guardia de seguridad: nunca generar un reporte \"válido\" cuando el request\n// traía archivos pero el parser no consiguió ninguna fila. Esto evita que\n// todas las personas de nómina aparezcan falsamente como discrepancias.\nif (payrollRows.length === 0) {\n throw new Error(\n 'Puerto Rico: no se pudo leer ninguna fila válida de nómina. ' +\n 'Revisa los archivo Excel de Puerto Rico y las hojas Whirlpool, Personal 2 y Personal 3.'\n );\n}\n\nif (bankRows.length === 0) {\n const detectedFiles = Number(data.bank?.files_count || data.summary?.bank_files_count || 0);\n const invalidFiles = Array.isArray(data.bank?.file_summaries)\n ? data.bank.file_summaries.filter((file) => file?.ok === false)\n : [];\n\n throw new Error(\n 'Puerto Rico: no se pudo leer ninguna transacción bancaria. ' +\n `Archivos bancarios detectados: ${detectedFiles}. ` +\n (invalidFiles.length\n ? `Archivos con error de formato: ${invalidFiles.map((file) => file.file_name).join(', ')}.`\n : 'Verifica que el PDF sea el Transaction Detail Report de Banco Popular de Puerto Rico.')\n );\n}\n\nconst bambooEmployees = Array.isArray(data.bamboo?.employees) ? data.bamboo.employees : [];\nconst resolvedNameMatches = data.bamboo?.resolved_name_matches || {};\nconst bambooValidationAvailable = data.bamboo?.fetch_complete === true &&\n data.bamboo?.validation_available === true && bambooEmployees.length > 0;\n\nconst bambooByEmployeeNumber = new Map();\nconst bambooById = new Map();\nconst bambooByExactName = new Map();\nfor (const employee of bambooEmployees) {\n const number = normalizeEmployeeNumber(\n employee.employee_number || employee.employeeNumber\n );\n const id = clean(employee.bamboo_id || employee.id);\n if (number) {\n const arr = bambooByEmployeeNumber.get(number) || [];\n arr.push(employee);\n bambooByEmployeeNumber.set(number, arr);\n }\n if (id) bambooById.set(id, employee);\n for (const alias of employee.aliases || [employee.full_name]) {\n const normalized = normalizeName(alias);\n if (!normalized) continue;\n const arr = bambooByExactName.get(normalized) || [];\n arr.push(employee);\n bambooByExactName.set(normalized, arr);\n }\n}\n\nfunction employeeKey(employee) {\n return clean(employee?.bamboo_id || employee?.id) ||\n clean(employee?.employee_number || employee?.employeeNumber) ||\n normalizeName(employee?.full_name || employee?.displayName);\n}\n\nfunction validatedAliasDecision(row) {\n if (!validatedBambooAliases.length) return null;\n const rowAccount = normalizeAccount(row.account);\n const names = bankNames(row).map(normalizeName).filter(Boolean);\n const candidates = validatedBambooAliases.filter((alias) => {\n const country = clean(alias.pais || alias.country).toUpperCase();\n if (country && country !== 'PR') return false;\n const source = normalizeName(alias.nombre_origen || alias.source_name || alias.alias || '');\n if (!source || !names.includes(source)) return false;\n const aliasAccount = normalizeAccount(alias.cuenta_bancaria || alias.cuenta_banco || alias.bank_account || '');\n return !aliasAccount || !rowAccount || aliasAccount === rowAccount;\n });\n if (candidates.length !== 1) return null;\n const alias = candidates[0];\n let employee = null;\n const employeeNumber = normalizeEmployeeNumber(\n alias.bamboo_employee_number || ''\n );\n const bambooId = clean(alias.bamboo_employee_id || '');\n const bambooName = normalizeName(alias.bamboo_nombre || '');\n if (employeeNumber && (bambooByEmployeeNumber.get(employeeNumber) || []).length === 1) {\n employee = bambooByEmployeeNumber.get(employeeNumber)[0];\n } else if (bambooId && bambooById.has(bambooId)) {\n employee = bambooById.get(bambooId);\n } else if (bambooName && (bambooByExactName.get(bambooName) || []).length === 1) {\n employee = bambooByExactName.get(bambooName)[0];\n }\n if (!employee) return null;\n return {\n found: true,\n matched_by: 'validated_bamboo_alias',\n confidence: 1,\n employee,\n employee_key: employeeKey(employee),\n bamboo_alias: clean(alias.bamboo_nombre) || employee.full_name || '',\n };\n}\n\nfunction precomputedDecision(name) {\n const normalized = normalizeName(name);\n if (!normalized) return null;\n const decision = resolvedNameMatches[normalized];\n if (!decision || decision.found !== true) return null;\n const index = Number(decision.employee_index);\n const employee = Number.isInteger(index) && index >= 0 ? bambooEmployees[index] : null;\n const key = clean(decision.employee_key) || employeeKey(employee);\n if (!key) return null;\n return {\n found: true,\n matched_by: decision.matched_by || 'precomputed_name',\n confidence: Number(decision.confidence || 0),\n employee,\n employee_key: key,\n bamboo_alias: clean(decision.bamboo_alias) || employee?.full_name || '',\n query_name: clean(name),\n };\n}\n\nfunction resolveBamboo(\n row,\n linkedPayrollNames = [],\n linkedPayrollEmployeeNumbers = []\n) {\n if (!bambooValidationAvailable) {\n return {\n found: false,\n reason: 'validation_unavailable',\n };\n }\n\n const validated = validatedAliasDecision(row);\n if (validated) return validated;\n\n /*\n * Puerto Rico: si existe un identificador fuerte procedente de nómina se puede usar como respaldo para determinar\n * si el pago pertenece a una persona existente en BambooHR.\n *\n * Se normalizan guiones/espacios tanto en banco como en BambooHR:\n * 0611198400414 <-> 0611-1984-00414\n *\n * Como respaldo, cuando la fila bancaria ya fue conciliada con nómina,\n * también se usa el Employee Number/Identidad de esa fila de nómina.\n * Solo se acepta el identificador cuando apunta a UNA sola persona.\n */\n const referenceEmployeeNumbers = Array.from(\n new Set(\n [\n row.reference,\n row.participant_id,\n row.employee_number,\n row.employeeNumber,\n ...linkedPayrollEmployeeNumbers,\n ]\n .flatMap((value) =>\n String(value ?? '').match(/\\d{6,20}/g) || []\n )\n .map(normalizeEmployeeNumber)\n .filter((value) => value.length >= 6)\n )\n );\n\n const employeesByStrongIdentifier = new Map();\n\n for (const employeeNumber of referenceEmployeeNumbers) {\n const matches =\n bambooByEmployeeNumber.get(employeeNumber) || [];\n\n if (matches.length !== 1) continue;\n\n const employee = matches[0];\n const key = employeeKey(employee);\n\n if (key) {\n employeesByStrongIdentifier.set(\n key,\n {\n employee,\n employee_number: employeeNumber,\n }\n );\n }\n }\n\n if (employeesByStrongIdentifier.size === 1) {\n const resolved =\n employeesByStrongIdentifier.values().next().value;\n\n return {\n found: true,\n matched_by: 'bank_or_payroll_employee_number',\n confidence: 1,\n employee: resolved.employee,\n employee_key: employeeKey(resolved.employee),\n bamboo_alias:\n clean(resolved.employee.full_name) ||\n clean(resolved.employee.displayName) ||\n '',\n matched_employee_number:\n resolved.employee_number,\n };\n }\n\n const holderName = clean(row.bank_account_holder);\n const fileName = clean(row.bank_name_file);\n\n // El nombre legal del cuentahabiente es la evidencia bancaria más fuerte.\n // Si identifica exactamente a una persona que pertenecía al período,\n // prevalece sobre un nombre abreviado que pueda coincidir con un perfil\n // histórico distinto (caso real: Suyapa Palma).\n const holderDecision = holderName\n ? precomputedDecision(holderName)\n : null;\n\n const holderMatchedBy =\n clean(holderDecision?.matched_by).toLowerCase();\n\n const holderIsExactUnique =\n holderDecision?.found === true &&\n (\n holderDecision.confidence >= 0.999 ||\n holderMatchedBy.includes('exact_precomputed_name') ||\n holderMatchedBy.includes('confirmed_alias_catalog')\n );\n\n // \"Banco sin Bamboo\" valida existencia en BambooHR, no que la persona\n // siguiera activa durante toda la quincena. Un finiquito/pago final puede\n // ocurrir después de la fecha de terminación. Por eso un nombre legal\n // completo y único del cuentahabiente es suficiente evidencia.\n if (holderIsExactUnique) {\n return {\n ...holderDecision,\n matched_by:\n `${holderDecision.matched_by || 'precomputed_name'}_holder_exact`,\n };\n }\n\n if (\n holderDecision?.found &&\n holderDecision.employee?.overlaps_period === true\n ) {\n return {\n ...holderDecision,\n matched_by:\n `${holderDecision.matched_by || 'precomputed_name'}_holder_in_period`,\n };\n }\n\n const names = Array.from(\n new Set(\n [\n holderName,\n fileName,\n ...linkedPayrollNames.map(clean),\n ].filter(Boolean)\n )\n );\n\n const decisions = names\n .map(precomputedDecision)\n .filter(Boolean);\n\n if (!decisions.length) {\n return {\n found: false,\n reason: 'no_confident_name_match',\n };\n }\n\n // Antes de declarar conflicto, descartar perfiles históricos que no\n // pertenecen al período cuando existe una única persona vigente/en período.\n const inPeriodByKey = new Map();\n\n for (const decision of decisions) {\n if (decision.employee?.overlaps_period !== true) continue;\n\n const current =\n inPeriodByKey.get(decision.employee_key);\n\n if (\n !current ||\n decision.confidence > current.confidence\n ) {\n inPeriodByKey.set(\n decision.employee_key,\n decision\n );\n }\n }\n\n const inPeriodRanked =\n Array.from(inPeriodByKey.values())\n .sort(\n (left, right) =>\n right.confidence - left.confidence\n );\n\n if (inPeriodRanked.length === 1) {\n return {\n ...inPeriodRanked[0],\n matched_by:\n `${inPeriodRanked[0].matched_by || 'precomputed_name'}_unique_in_period`,\n };\n }\n\n if (\n inPeriodRanked.length >= 2 &&\n (\n inPeriodRanked[0].confidence -\n inPeriodRanked[1].confidence\n ) >= 0.08\n ) {\n return inPeriodRanked[0];\n }\n\n const byKey = new Map();\n\n for (const decision of decisions) {\n const current =\n byKey.get(decision.employee_key);\n\n if (\n !current ||\n decision.confidence > current.confidence\n ) {\n byKey.set(\n decision.employee_key,\n decision\n );\n }\n }\n\n const ranked =\n Array.from(byKey.values())\n .sort(\n (left, right) =>\n right.confidence - left.confidence\n );\n\n if (ranked.length === 1) {\n return ranked[0];\n }\n\n if (\n (\n ranked[0].confidence -\n ranked[1].confidence\n ) >= 0.08\n ) {\n return ranked[0];\n }\n\n return {\n found: false,\n reason: 'conflicting_precomputed_name_matches',\n candidates: ranked.slice(0, 3),\n };\n}\n\nconst usedBankKeys = new Set();\nconst matchedPayrollIndexes = new Set();\nconst matchedPairs = [];\nconst bankIndexByKey = new Map();\n\n// Índice por centavos para que la pasada principal no compare cada fila de\n// nómina contra todas las transacciones del banco. Esto preserva exactamente\n// la lógica original: la primera pasada ya exigía el mismo monto al centavo.\nconst bankIndexesByAmountCents = new Map();\n\nfunction amountCents(value) {\n return Math.round(roundMoney(value) * 100);\n}\n\nbankRows.forEach((row, index) => {\n const key = bankRowKey(row, index);\n bankIndexByKey.set(key, index);\n\n const amountKey = `${clean(row.currency || 'USD').toUpperCase()}|${amountCents(row.amount)}`;\n const bucket = bankIndexesByAmountCents.get(amountKey) || [];\n bucket.push(index);\n bankIndexesByAmountCents.set(amountKey, bucket);\n});\n\nfunction availableNameCandidates(payroll, requireExactAmount) {\n const candidates = [];\n\n // La pasada exacta solo inspecciona filas con el mismo importe. La segunda\n // pasada conserva el barrido completo y el mismo criterio de persona fuerte\n // + menor diferencia que tenía el workflow validado.\n let candidateIndexes = null;\n\n if (requireExactAmount) {\n const currency =\n clean(payroll.currency || 'USD')\n .toUpperCase();\n\n const center =\n amountCents(payroll.payroll_amount);\n\n const indexSet = new Set();\n\n for (\n let delta = -MONEY_TOLERANCE_CENTS;\n delta <= MONEY_TOLERANCE_CENTS;\n delta++\n ) {\n const bucket =\n bankIndexesByAmountCents.get(\n `${currency}|${center + delta}`\n ) || [];\n\n for (const bankIndex of bucket) {\n indexSet.add(bankIndex);\n }\n }\n\n candidateIndexes =\n Array.from(indexSet);\n }\n\n const inspectBank = (bankIndex) => {\n const bank = bankRows[bankIndex];\n if (!bank) return;\n\n const key = bankRowKey(bank, bankIndex);\n if (usedBankKeys.has(key)) return;\n if (clean(payroll.currency || 'USD').toUpperCase() !== clean(bank.currency || 'HNL').toUpperCase()) return;\n\n if (requireExactAmount && !sameCent(payroll.payroll_amount, bank.amount)) return;\n\n const periodScore = bankCandidatePeriodScore(payroll, bank);\n if (periodScore < 0) return;\n\n const score = bestNameScore(payroll.employee_name, bank);\n if (score <= 0) return;\n\n candidates.push({\n bank,\n bankIndex,\n key,\n score,\n periodScore,\n amountDiff: Math.abs(moneyDiff(payroll.payroll_amount, bank.amount)),\n });\n };\n\n if (candidateIndexes) {\n for (const bankIndex of candidateIndexes) {\n inspectBank(bankIndex);\n }\n } else {\n for (let bankIndex = 0; bankIndex < bankRows.length; bankIndex++) {\n inspectBank(bankIndex);\n }\n }\n\n candidates.sort((a, b) => {\n if (b.periodScore !== a.periodScore) return b.periodScore - a.periodScore;\n if (b.score !== a.score) return b.score - a.score;\n if (a.amountDiff !== b.amountDiff) return a.amountDiff - b.amountDiff;\n return a.bankIndex - b.bankIndex;\n });\n\n return candidates;\n}\n\n// Primera pasada: persona + monto exacto. Es la evidencia más fuerte y evita\n// que extraordinarias/vacaciones de una misma persona se mezclen entre sí.\npayrollRows.forEach((payroll, payrollIndex) => {\n const candidates = availableNameCandidates(payroll, true);\n if (!candidates.length) return;\n const chosen = candidates[0];\n usedBankKeys.add(chosen.key);\n matchedPayrollIndexes.add(payrollIndex);\n matchedPairs.push({ payroll, payrollIndex, ...chosen });\n});\n\n// Segunda pasada: persona fuerte aunque el monto sea distinto. Solo se toma\n// una contraparte bancaria por fila de nómina y se prioriza la menor diferencia.\npayrollRows.forEach((payroll, payrollIndex) => {\n if (matchedPayrollIndexes.has(payrollIndex)) return;\n const candidates = availableNameCandidates(payroll, false);\n if (!candidates.length) return;\n const chosen = candidates[0];\n usedBankKeys.add(chosen.key);\n matchedPayrollIndexes.add(payrollIndex);\n matchedPairs.push({ payroll, payrollIndex, ...chosen });\n});\n\nconst linkedPayrollNamesByBankKey = new Map();\nconst linkedPayrollEmployeeNumbersByBankKey = new Map();\n\nfor (const pair of matchedPairs) {\n const currentNames =\n linkedPayrollNamesByBankKey.get(pair.key) || [];\n\n currentNames.push(pair.payroll.employee_name);\n\n linkedPayrollNamesByBankKey.set(\n pair.key,\n currentNames\n );\n\n const employeeNumber =\n normalizeEmployeeNumber(\n pair.payroll.employee_number ||\n pair.payroll.employeeNumber ||\n ''\n );\n\n if (employeeNumber) {\n const currentNumbers =\n linkedPayrollEmployeeNumbersByBankKey.get(\n pair.key\n ) || [];\n\n currentNumbers.push(employeeNumber);\n\n linkedPayrollEmployeeNumbersByBankKey.set(\n pair.key,\n currentNumbers\n );\n }\n}\n\n// Filas bancarias del período de Puerto Rico.\n// Toda fila emparejada entra siempre. Para una fila no emparejada se usa la\n// fecha/concepto del plano para evitar arrastrar accidentalmente otra quincena.\nconst includedBankRows = [];\nconst excludedBankRows = [];\n\nfor (let index = 0; index < bankRows.length; index++) {\n const row = bankRows[index];\n const key = bankRowKey(row, index);\n\n if (usedBankKeys.has(key)) {\n includedBankRows.push({ row, key, index, inclusion: 'matched_payroll' });\n continue;\n }\n\n const periodInfo = periodInfoForBankRow(row);\n if (periodInfo.known && !periodInfo.inPeriod) {\n excludedBankRows.push({ row, key, reason: 'outside_selected_payroll_period' });\n continue;\n }\n\n includedBankRows.push({ row, key, index, inclusion: 'unmatched_current_period' });\n}\n\nconst includedBankKeySet = new Set(includedBankRows.map((entry) => entry.key));\n\n/*\n * Employee Number para la interfaz.\n *\n * La nómina de Puerto Rico no trae Employee Number, pero en este punto ya\n * contamos con la resolución de identidad contra BambooHR. La versión\n * anterior mantenía employeeNumber vacío porque copiaba únicamente el valor\n * de nómina. Reutilizamos exactamente la misma resolución BambooHR que ya\n * valida Banco sin Bamboo para completar el Employee Number sin alterar la\n * lógica de conciliación, montos, cuentas ni categorías.\n */\nfunction bambooEmployeeNumberFromDecision(decision) {\n return clean(\n decision?.employee?.employee_number ||\n decision?.employee?.employeeNumber ||\n decision?.matched_employee_number ||\n ''\n );\n}\n\nfunction resolvedEmployeeNumberForPair(pair) {\n const payrollNumber = clean(\n pair?.payroll?.employee_number ||\n pair?.payroll?.employeeNumber ||\n ''\n );\n\n if (payrollNumber) return payrollNumber;\n\n const decision = resolveBamboo(\n pair?.bank || {},\n [pair?.payroll?.employee_name || ''].filter(Boolean),\n []\n );\n\n return bambooEmployeeNumberFromDecision(decision);\n}\n\nfunction resolvedEmployeeNumberForPayroll(payroll) {\n const payrollNumber = clean(\n payroll?.employee_number ||\n payroll?.employeeNumber ||\n ''\n );\n\n if (payrollNumber) return payrollNumber;\n\n const decision = precomputedDecision(\n payroll?.employee_name || ''\n );\n\n return bambooEmployeeNumberFromDecision(decision);\n}\n\nfunction resolvedEmployeeNumberForBankRow(\n bankRow,\n linkedNames = []\n) {\n const decision = resolveBamboo(\n bankRow || {},\n linkedNames.filter(Boolean),\n []\n );\n\n return bambooEmployeeNumberFromDecision(decision);\n}\n\nconst coreRows = [];\nconst wrongAccountGroups = new Map();\nfor (const pair of matchedPairs) {\n if (!includedBankKeySet.has(pair.key)) continue;\n const payrollAmount = roundMoney(pair.payroll.payroll_amount);\n const bankAmount = roundMoney(pair.bank.amount);\n const difference = moneyDiff(payrollAmount, bankAmount);\n const currency = clean(pair.payroll.currency || pair.bank.currency || 'USD').toUpperCase() === 'USD' ? 'USD' : 'HNL';\n const payrollAccount = normalizeAccount(pair.payroll.account || pair.payroll.payroll_account);\n const bankAccount = normalizeAccount(pair.bank.account);\n const accountMismatch = Boolean(payrollAccount && bankAccount && payrollAccount !== bankAccount);\n const resolvedEmployeeNumber = resolvedEmployeeNumberForPair(pair);\n if (accountMismatch) {\n const groupKey = `${normalizeName(pair.payroll.employee_name)}|${currency}|${payrollAccount}|${bankAccount}`;\n const current = wrongAccountGroups.get(groupKey) || {\n id: `pr_wrong_account_${wrongAccountGroups.size + 1}`,\n employee: pair.payroll.employee_name,\n employee_name: pair.payroll.employee_name,\n employeeNumber: resolvedEmployeeNumber, employee_number: resolvedEmployeeNumber,\n account: bankAccount, payrollAccount, payroll_account: payrollAccount, bankAccount, bank_account: bankAccount,\n currency, payrollAmount:0, payroll_amount:0, bankAmount:0, bank_amount:0, difference:0,\n status:'Pendiente revisión', category:'posible_cuenta_mal_digitada', subcategory:'nombre_y_monto_coinciden_cuenta_diferente',\n observation:`El nombre y el monto coinciden, pero la cuenta de nómina (${payrollAccount}) es diferente a la cuenta del banco (${bankAccount}).`,\n source_rows:[], source_sheets:new Set(), source_files:new Set(), shipment_numbers:new Set(), plan_numbers:new Set(),\n bank_name_file: pair.bank.bank_name_file || '', bank_account_holder: pair.bank.bank_account_holder || ''\n };\n current.payrollAmount=roundMoney(current.payrollAmount+payrollAmount); current.payroll_amount=current.payrollAmount;\n current.bankAmount=roundMoney(current.bankAmount+bankAmount); current.bank_amount=current.bankAmount; current.difference=moneyDiff(current.payrollAmount,current.bankAmount);\n current.source_rows.push(pair.payroll); if(pair.payroll.source_sheet)current.source_sheets.add(pair.payroll.source_sheet); if(pair.bank.source_file)current.source_files.add(pair.bank.source_file); if(pair.bank.shipment_number)current.shipment_numbers.add(pair.bank.shipment_number); if(pair.bank.plan_number)current.plan_numbers.add(pair.bank.plan_number);\n wrongAccountGroups.set(groupKey,current); continue;\n }\n const exact = sameCent(payrollAmount, bankAmount);\n coreRows.push({id:`pr_match_${pair.payrollIndex+1}_${pair.bankIndex+1}`,employee:pair.payroll.employee_name,employee_name:pair.payroll.employee_name,employeeNumber:resolvedEmployeeNumber,employee_number:resolvedEmployeeNumber,account:bankAccount||payrollAccount,payrollAccount:payrollAccount,payroll_account:payrollAccount,bankAccount:bankAccount,bank_account:bankAccount,currency,payrollAmount,payroll_amount:payrollAmount,bankAmount,bank_amount:bankAmount,difference,status:exact?'Coincide':'Pendiente revisión',category:exact?'coincidencia':'discrepancia',subcategory:exact?'persona_cuenta_y_monto_exactos':'persona_y_cuenta_coinciden_monto_diferente',observation:exact?'Coincidencia de persona, cuenta y monto.':difference>0?'Se pagó de menos.':'Se pagó de más.',source_kind:pair.payroll.source_kind||'',source_sheet:pair.payroll.source_sheet||'',source_file:pair.bank.source_file||'',shipment_number:pair.bank.shipment_number||'',plan_number:pair.bank.plan_number||'',plan_label:pair.bank.plan_label||'',reference:pair.bank.reference||'',bank_name_file:pair.bank.bank_name_file||'',bank_account_holder:pair.bank.bank_account_holder||''});\n}\nfor (let payrollIndex=0; payrollIndex<payrollRows.length; payrollIndex++) {\n if (matchedPayrollIndexes.has(payrollIndex)) continue;\n const payroll=payrollRows[payrollIndex]; const payrollAmount=roundMoney(payroll.payroll_amount); const currency=clean(payroll.currency||'USD').toUpperCase()==='USD'?'USD':'HNL';\n const resolvedEmployeeNumber = resolvedEmployeeNumberForPayroll(payroll);\n coreRows.push({id:`pr_payroll_without_bank_${payrollIndex+1}`,employee:payroll.employee_name,employee_name:payroll.employee_name,employeeNumber:resolvedEmployeeNumber,employee_number:resolvedEmployeeNumber,account:payroll.account||payroll.payroll_account||'',payrollAccount:payroll.account||payroll.payroll_account||'',payroll_account:payroll.account||payroll.payroll_account||'',bankAccount:'',bank_account:'',currency,payrollAmount,payroll_amount:payrollAmount,bankAmount:0,bank_amount:0,difference:payrollAmount,status:'Pendiente revisión',category:'discrepancia',subcategory:'nomina_sin_pago_banco',observation:'No aparece pagado en banco.',source_kind:payroll.source_kind||'',source_sheet:payroll.source_sheet||''});\n}\nconst wrongAccountRows=Array.from(wrongAccountGroups.values()).map(row=>({...row,source_sheets:Array.from(row.source_sheets),source_files:Array.from(row.source_files),shipment_numbers:Array.from(row.shipment_numbers),plan_numbers:Array.from(row.plan_numbers),source_sheet:Array.from(row.source_sheets).join(' / '),source_file:Array.from(row.source_files).join(' / '),shipment_number:Array.from(row.shipment_numbers).join(' / '),plan_number:Array.from(row.plan_numbers).join(' / ')}));\ncoreRows.push(...wrongAccountRows);\n\n// Banco sin Nómina se conserva a nivel de TRANSACCIÓN.\n// Una misma persona/cuenta puede tener más de un pago bancario legítimamente\n// separado; agruparlos reduce artificialmente el número de hallazgos.\nconst bankWithoutPayrollRows = [];\n\nfor (const entry of includedBankRows) {\n if (usedBankKeys.has(entry.key)) continue;\n\n const row = entry.row;\n const displayName =\n bankDisplayName(row);\n\n const rowCurrency =\n clean(row.currency || 'USD')\n .toUpperCase() === 'USD'\n ? 'USD'\n : 'HNL';\n\n const bankAmount =\n roundMoney(row.amount || 0);\n\n const resolvedEmployeeNumber =\n resolvedEmployeeNumberForBankRow(\n row,\n [displayName]\n );\n\n bankWithoutPayrollRows.push({\n id:\n `pr_bank_without_payroll_${entry.index + 1}`,\n employee: displayName,\n employee_name: displayName,\n employeeNumber: resolvedEmployeeNumber,\n employee_number: resolvedEmployeeNumber,\n account: row.account || '',\n payrollAccount: '',\n payroll_account: '',\n bankAccount: row.account || '',\n bank_account: row.account || '',\n currency: rowCurrency,\n payrollAmount: 0,\n payroll_amount: 0,\n bankAmount,\n bank_amount: bankAmount,\n difference:\n roundMoney(0 - bankAmount),\n status: 'Pendiente revisión',\n category: 'banco_sin_nomina',\n subcategory:\n 'pago_banco_sin_registro_nomina',\n observation:\n 'Pago bancario sin registro equivalente en las planillas cargadas.',\n bank_name_file:\n row.bank_name_file || '',\n bank_account_holder:\n row.bank_account_holder || '',\n source_file:\n row.source_file || '',\n shipment_number:\n row.shipment_number || '',\n plan_number:\n row.plan_number || '',\n reference:\n row.reference || '',\n row_number:\n row.row_number || null,\n source_rows: [row],\n });\n}\n\ncoreRows.push(...bankWithoutPayrollRows);\n\nconst nameDifferenceRows = [];\nfor (const entry of includedBankRows) {\n const row = entry.row;\n const rowCurrency=clean(row.currency||'USD').toUpperCase()==='USD'?'USD':'HNL';\n if (!clean(row.bank_name_file) || !clean(row.bank_account_holder)) continue;\n if (bankNameLooksConsistent(row.bank_name_file, row.bank_account_holder)) continue;\n const resolvedEmployeeNumber =\n resolvedEmployeeNumberForBankRow(\n row,\n [row.bank_name_file, row.bank_account_holder]\n );\n nameDifferenceRows.push({\n id: `pr_bank_name_difference_${entry.index + 1}`,\n employee: row.bank_name_file,\n employee_name: row.bank_name_file,\n employeeNumber: resolvedEmployeeNumber,\n employee_number: resolvedEmployeeNumber,\n account: row.account || '',\n payrollAccount: '',\n payroll_account: '',\n bankAccount: row.account || '',\n bank_account: row.account || '',\n currency: rowCurrency,\n payrollAmount: 0,\n payroll_amount: 0,\n bankAmount: roundMoney(row.amount),\n bank_amount: roundMoney(row.amount),\n difference: 0,\n status: 'Pendiente revisión',\n category: 'diferencia_nombre_banco',\n subcategory: 'nombre_archivo_vs_cuentahabiente',\n observation: `El Nombre en Archivo (${row.bank_name_file}) no coincide con el Nombre del Cuentahabiente (${row.bank_account_holder}).`,\n bank_name_file: row.bank_name_file,\n bank_account_holder: row.bank_account_holder,\n source_file: row.source_file || '',\n shipment_number: row.shipment_number || '',\n plan_number: row.plan_number || '',\n reference: row.reference || '',\n row_number: row.row_number || null,\n });\n}\n\nconst bankWithoutBambooMap = new Map();\nconst bambooMatchDetails = [];\nfor (const entry of includedBankRows) {\n const row = entry.row;\n const linkedNames =\n linkedPayrollNamesByBankKey.get(entry.key) || [];\n\n const linkedEmployeeNumbers =\n linkedPayrollEmployeeNumbersByBankKey.get(\n entry.key\n ) || [];\n\n const decision = resolveBamboo(\n row,\n linkedNames,\n linkedEmployeeNumbers\n );\n bambooMatchDetails.push({\n bank_row_key: entry.key,\n bank_name_file: row.bank_name_file || '',\n bank_account_holder: row.bank_account_holder || '',\n account: row.account || '',\n amount: row.amount || 0,\n linked_payroll_names: linkedNames,\n ...decision,\n });\n if (decision.found) continue;\n\n const displayName = bankDisplayName(row);\n const rowCurrency=clean(row.currency||'USD').toUpperCase()==='USD'?'USD':'HNL';\n const groupingKey = `${normalizeAccount(row.account)}|${normalizeName(displayName)}|${rowCurrency}`;\n const current = bankWithoutBambooMap.get(groupingKey) || {\n id: `pr_bank_without_bamboo_${bankWithoutBambooMap.size + 1}`,\n bank_name_file: row.bank_name_file || displayName,\n bank_account_holder: row.bank_account_holder || '',\n employee_name: row.bank_name_file || displayName,\n account: row.account || '',\n currency: rowCurrency,\n bank_amount: 0,\n amount: 0,\n status: 'Pendiente revisión',\n category: 'banco_sin_bamboo',\n shipment_numbers: new Set(),\n references: new Set(),\n source_files: new Set(),\n source_rows: [],\n };\n current.bank_amount = roundMoney(current.bank_amount + Number(row.amount || 0));\n current.amount = current.bank_amount;\n if (row.shipment_number) current.shipment_numbers.add(row.shipment_number);\n if (row.reference) current.references.add(row.reference);\n if (row.source_file) current.source_files.add(row.source_file);\n current.source_rows.push(row);\n bankWithoutBambooMap.set(groupingKey, current);\n}\n\nconst bankWithoutBamboo = Array.from(bankWithoutBambooMap.values()).map((row) => ({\n ...row,\n shipment_numbers: Array.from(row.shipment_numbers),\n shipment_number: Array.from(row.shipment_numbers).join(' / '),\n references: Array.from(row.references),\n source_files: Array.from(row.source_files),\n source_file: Array.from(row.source_files).join(' / '),\n difference: roundMoney(0 - row.bank_amount),\n}));\n\nfunction priority(row) {\n const category = clean(row.category).toLowerCase();\n if (category === 'posible_cuenta_mal_digitada') return 1;\n if (category === 'discrepancia') return 2;\n if (category === 'banco_sin_nomina') return 3;\n if (category === 'diferencia_nombre_banco') return 5;\n if (category === 'coincidencia') return 99;\n return 50;\n}\n\nconst rowsFinales = [...coreRows, ...nameDifferenceRows].sort((a, b) => {\n const p = priority(a) - priority(b);\n if (p !== 0) return p;\n return clean(a.employee_name).localeCompare(clean(b.employee_name), 'es');\n});\n\nconst coincidencias = coreRows.filter((row) => row.category === 'coincidencia').length;\nconst discrepancias = coreRows.filter((row) => row.category === 'discrepancia').length;\nconst bancoSinNomina = bankWithoutPayrollRows.length;\nconst diferenciasNombreBanco = nameDifferenceRows.length;\nconst posiblesCuentasMalDigitadas = wrongAccountRows.length;\nconst pendientes = discrepancias + bancoSinNomina + diferenciasNombreBanco;\nconst totalNominaByCurrency = {HNL:0,USD:0};\nfor(const row of payrollRows){const c=clean(row.currency||'USD').toUpperCase()==='USD'?'USD':'HNL';totalNominaByCurrency[c]=roundMoney(totalNominaByCurrency[c]+Number(row.payroll_amount||0));}\nconst totalBancoByCurrency = {HNL:0,USD:0};\nfor(const entry of includedBankRows){const c=clean(entry.row.currency||'USD').toUpperCase()==='USD'?'USD':'HNL';totalBancoByCurrency[c]=roundMoney(totalBancoByCurrency[c]+Number(entry.row.amount||0));}\nconst diferenciaByCurrency={HNL:moneyDiff(totalNominaByCurrency.HNL,totalBancoByCurrency.HNL),USD:moneyDiff(totalNominaByCurrency.USD,totalBancoByCurrency.USD)};\nconst totalNomina=totalNominaByCurrency.HNL || totalNominaByCurrency.USD;\nconst totalBanco=totalBancoByCurrency.HNL || totalBancoByCurrency.USD;\nconst diferenciaTotal=totalNominaByCurrency.HNL||totalBancoByCurrency.HNL?diferenciaByCurrency.HNL:diferenciaByCurrency.USD;\n\nconst bambooSummaryCompact = { ...(data.bamboo || {}) };\ndelete bambooSummaryCompact.employees;\n\nreturn [{\n json: {\n ok: true,\n stage: 'cruce_nomina_banco_puerto_rico',\n errors: [],\n metadata: data.metadata || {},\n summary: {\n coincidencias,\n discrepancias,\n bancoSinNomina,\n bancoSinBamboo: bankWithoutBamboo.length,\n nominaSinCuenta: 0,\n diferenciasNombreBanco,\n posiblesCuentasMalDigitadas,\n pendientes,\n filasNominaValidas: Number(data.payroll?.base_rows_count || data.payroll?.valid_rows_count || 0),\n filasNominaSinCuenta: 0,\n suplementosPotenciales: Number(data.payroll?.supplement_rows_count || 0),\n suplementosNominaAplicados: Number(data.payroll?.supplement_rows_count || 0),\n suplementosNominaNoAplicados: 0,\n suplementosNominaAdjuntados: Number(data.payroll?.supplement_rows_count || 0),\n suplementosNominaNoAdjuntados: Number(data.payroll?.unattached_supplements_count || 0),\n reconciliacionesExactasFinales: coincidencias,\n cuentasNominaAgrupadas: 0,\n transaccionesBanco: includedBankRows.length,\n cuentasBancoAgrupadas: new Set(includedBankRows.map((entry) => normalizeAccount(entry.row.account)).filter(Boolean)).size,\n empleadosBambooPR: Number(data.bamboo?.puerto_rico_count || 0),\n empleadosBambooEnPeriodo: Number(data.bamboo?.active_in_period_count || 0),\n bambooPaginasDescargadas: Number(data.bamboo?.pages_fetched || 0),\n bambooEmpleadosEsperados: Number(data.bamboo?.expected_total || 0),\n bambooDescargaCompleta: Boolean(data.bamboo?.fetch_complete),\n bambooValidacionDisponible: bambooValidationAvailable,\n totalNominaBase: roundMoney(data.payroll?.base_total || 0),\n totalSuplementosAplicados: roundMoney(data.payroll?.supplements_total || 0),\n totalNomina,\n totalBanco,\n diferenciaTotal,\n totalNominaPorMoneda: totalNominaByCurrency,\n totalBancoPorMoneda: totalBancoByCurrency,\n diferenciaTotalPorMoneda: diferenciaByCurrency,\n },\n rows: rowsFinales,\n bankWithoutBamboo,\n nameDifferences: nameDifferenceRows,\n bambooSummary: bambooSummaryCompact,\n reportUrl: null,\n debug: {\n matched_pairs_count: matchedPairs.length,\n included_bank_rows_count: includedBankRows.length,\n excluded_bank_rows_count: excludedBankRows.length,\n excluded_bank_rows_preview: excludedBankRows.slice(0, 100),\n validated_bamboo_aliases_loaded: validatedBambooAliases.length,\n bamboo_matches: bambooMatchDetails,\n rows_with_employee_number: rowsFinales.filter((row) => clean(row.employeeNumber || row.employee_number)).length,\n bamboo_validation_available: bambooValidationAvailable,\n banco_sin_bamboo: bankWithoutBamboo,\n payroll_preview: payrollRows.slice(0, 20),\n bank_included_preview: includedBankRows.slice(0, 20).map((entry) => entry.row),\n },\n },\n}];\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1600,
512
],
"id": "cb9e73f3-01f2-4f5c-a0df-930cbeb2c785",
"name": "Cruzar Nómina vs Banco"
},
{
"parameters": {
"jsCode": "const data = $input.first().json || {};\n\nfunction normalizeText(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction roundMoney(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n}\n\nfunction readableCategory(value) {\n const map = {\n coincidencia: 'Coincidencia',\n discrepancia: 'Discrepancia',\n posible_cuenta_mal_digitada: 'Posible cuenta mal digitada',\n banco_sin_nomina: 'Banco sin nómina',\n banco_sin_bamboo: 'Banco sin Bamboo',\n nomina_sin_cuenta: 'Nómina sin cuenta',\n diferencia_nombre_banco: 'Diferencia nombre banco',\n };\n\n return map[value] || normalizeText(value).replace(/_/g, ' ');\n}\n\nfunction firstValue(value) {\n if (Array.isArray(value)) {\n return value.filter(Boolean).join(' / ');\n }\n\n return normalizeText(value);\n}\n\nfunction formatPeriodEnd(value) {\n const raw = normalizeText(value);\n\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(raw)) {\n return raw;\n }\n\n const [year, month, day] = raw.split('-');\n const monthNames = {\n '01': 'ene',\n '02': 'feb',\n '03': 'mar',\n '04': 'abr',\n '05': 'may',\n '06': 'jun',\n '07': 'jul',\n '08': 'ago',\n '09': 'sep',\n '10': 'oct',\n '11': 'nov',\n '12': 'dic',\n };\n\n return `${day}-${monthNames[month] || month}-${year}`;\n}\n\nfunction mainReportSense(row, difference) {\n const category = normalizeText(row.category).toLowerCase();\n const subcategory = normalizeText(row.subcategory).toLowerCase();\n\n if (category === 'posible_cuenta_mal_digitada') {\n return 'Revisar cuenta';\n }\n\n if (\n subcategory === 'nomina_con_cuenta_sin_pago_banco' ||\n (Number(row.bank_amount ?? row.bankAmount ?? 0) === 0 &&\n Number(row.payroll_amount ?? row.payrollAmount ?? 0) > 0)\n ) {\n return 'No aparece pagado en banco';\n }\n\n if (difference > 0) {\n return 'Se pagó de menos';\n }\n\n if (difference < 0) {\n return 'Se pagó de más';\n }\n\n return 'Revisar';\n}\n\nconst metadata = data.metadata || {};\nconst summary = data.summary || {};\nconst rows = Array.isArray(data.rows) ? data.rows : [];\nconst bankWithoutBamboo = Array.isArray(data.bankWithoutBamboo)\n ? data.bankWithoutBamboo\n : [];\n\nconst periodLabel =\n metadata.period_label ||\n `${metadata.year || ''}-${metadata.month || ''}-${metadata.period_type || ''}`;\n\nconst spreadsheetTitle =\n `Cruce de Cuentas GLM PR - ${periodLabel}`;\n\nconst mainReportSubtitle =\n `Diferencias de Monto Nómina vs. Banco · Puerto Rico · ${\n formatPeriodEnd(metadata.period_end || '')\n }`;\n\nconst bancoSinBambooSubtitle =\n `Pagos en banco sin empleado identificado en BambooHR · Puerto Rico · ${\n formatPeriodEnd(metadata.period_end || '')\n }`;\n\nconst cuentaMalDigitadaSubtitle =\n `Cuenta Mal Digitada en Nómina · Puerto Rico · ${\n formatPeriodEnd(metadata.period_end || '')\n }`;\n\nconst cuentaMalDigitadaCases = rows.filter(\n (row) => row.category === 'posible_cuenta_mal_digitada'\n);\n\nconst hasCuentaMalDigitada =\n cuentaMalDigitadaCases.length > 0;\n\nconst sheetIds = {\n nominaVsBanco: 101,\n bancoSinNomina: 102,\n bancoSinBamboo: 103,\n diferenciasNombreBanco: 104,\n cuentaMalDigitada: 105,\n resumen: 106,\n};\n\nconst sheetTitles = {\n nominaVsBanco: '01 Nómina vs Banco',\n bancoSinNomina: '02 Banco sin Nómina',\n bancoSinBamboo: '03 Banco sin Bamboo',\n diferenciasNombreBanco: '04 Diferencias nombre banco',\n cuentaMalDigitada: '05 Cuenta Mal Digitada',\n resumen: hasCuentaMalDigitada\n ? '06 Resumen'\n : '05 Resumen',\n};\n\nconst nominaVsBancoHeader = [\n '#',\n 'Empleado',\n 'Cuenta',\n 'Monto en Nómina',\n 'Monto en Banco',\n 'Diferencia',\n 'Sentido',\n 'Estado',\n 'Resolución',\n];\n\nconst mainReportRows = rows\n .filter((row) => row.category === 'discrepancia')\n .map((row, index) => {\n const payrollAmount = roundMoney(\n row.payroll_amount ?? row.payrollAmount ?? 0\n );\n const bankAmount = roundMoney(\n row.bank_amount ?? row.bankAmount ?? 0\n );\n const difference = roundMoney(\n row.difference ?? (payrollAmount - bankAmount)\n );\n\n return [\n index + 1,\n normalizeText(row.employee_name || row.employee || ''),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.payroll_account ||\n row.payrollAccount ||\n row.account ||\n ''\n ),\n payrollAmount,\n bankAmount,\n difference,\n mainReportSense(row, difference),\n normalizeText(row.status || 'Riesgo').toUpperCase(),\n '',\n ];\n });\n\nconst nominaVsBancoValues = [\n [\n 'GOMEZLEE MARKETING',\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n ],\n [\n mainReportSubtitle,\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n ],\n ['', '', '', '', '', '', '', '', ''],\n nominaVsBancoHeader,\n ...mainReportRows,\n];\n\nconst bancoSinNominaHeader = [\n 'Empleado Banco',\n 'Cuenta Banco',\n 'Moneda',\n 'Monto Banco',\n 'Estado',\n 'Observación',\n 'Resolución',\n];\n\nconst bancoSinNominaRows = rows\n .filter((row) => row.category === 'banco_sin_nomina')\n .map((row) => [\n normalizeText(row.employee_name || row.employee || ''),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n normalizeText(row.currency || 'USD'),\n roundMoney(row.bank_amount ?? row.bankAmount ?? 0),\n normalizeText(row.status || ''),\n normalizeText(row.observation || ''),\n '',\n ]);\n\nconst bancoSinBambooHeader = [\n '#',\n 'Nombre en banco',\n 'Nombre del cuentahabiente',\n 'Cuenta',\n 'Monto en banco',\n 'Número de envío',\n 'Estado',\n 'Resolución',\n];\n\nconst bancoSinBambooRows = bankWithoutBamboo.map((row, index) => [\n index + 1,\n normalizeText(row.bank_name_file || row.employee_name || ''),\n normalizeText(row.bank_account_holder || ''),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n roundMoney(row.bank_amount ?? row.bankAmount ?? 0),\n firstValue(row.shipment_numbers || row.shipment_number || ''),\n 'PENDIENTE REVISIÓN',\n '',\n]);\n\nconst bancoSinBambooValues = [\n [\n 'GOMEZLEE MARKETING',\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n ],\n [\n bancoSinBambooSubtitle,\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n ],\n ['', '', '', '', '', '', '', ''],\n bancoSinBambooHeader,\n ...bancoSinBambooRows,\n];\n\nconst diferenciasNombreHeader = [\n 'Nombre en Archivo',\n 'Nombre del Cuentahabiente',\n 'Cuenta Destino',\n 'Moneda',\n 'Monto',\n 'Número de envío',\n 'Número de plan',\n 'Archivo',\n 'Estado',\n 'Observación',\n 'Resolución',\n];\n\nconst diferenciasNombreRows = rows\n .filter((row) => row.category === 'diferencia_nombre_banco')\n .map((row) => [\n normalizeText(row.bank_name_file || row.employee_name || ''),\n normalizeText(row.bank_account_holder || ''),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n normalizeText(row.currency || 'USD'),\n roundMoney(row.bank_amount ?? row.bankAmount ?? 0),\n normalizeText(row.shipment_number || ''),\n normalizeText(row.plan_number || ''),\n normalizeText(row.source_file || ''),\n normalizeText(row.status || ''),\n normalizeText(row.observation || ''),\n '',\n ]);\n\n\nfunction accountValues(value) {\n const values = Array.isArray(value)\n ? value\n : String(value ?? '')\n .split(/\\s*(?:\\/|;|,|\\by\\b)\\s*/i);\n\n return values\n .map((item) =>\n String(item ?? '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim()\n )\n .filter((item) =>\n item.length >= 7 &&\n !/^0+$/.test(item)\n );\n}\n\nfunction payrollAccountsForWrongAccount(row) {\n const candidates = [\n row.payroll_account,\n row.payrollAccount,\n ...(Array.isArray(row.source_rows)\n ? row.source_rows.flatMap((sourceRow) => [\n sourceRow.account,\n sourceRow.payroll_account,\n sourceRow.payrollAccount,\n ])\n : []),\n ];\n\n return Array.from(new Set(\n candidates.flatMap(accountValues)\n ));\n}\n\nfunction bankAccountForWrongAccount(row) {\n return firstValue(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n );\n}\n\nfunction wrongAccountTotalStatus(row) {\n const currency = normalizeText(row.currency || 'USD').toUpperCase() === 'USD' ? 'USD' : 'HNL';\n const payrollAmount = roundMoney(\n row.payroll_amount ?? row.payrollAmount ?? 0\n );\n const bankAmount = roundMoney(\n row.bank_amount ?? row.bankAmount ?? 0\n );\n const difference = roundMoney(\n payrollAmount - bankAmount\n );\n\n if (Math.abs(difference) <= 0.02) {\n return 'El total de nómina coincide con el total del banco.';\n }\n\n if (difference > 0) {\n return (\n `El total de nómina supera el total del banco por ` +\n `${currency} ${Math.abs(difference).toLocaleString('en-US', {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n })}.`\n );\n }\n\n return (\n `El total pagado por el banco supera el total de nómina por ` +\n `${currency} ${Math.abs(difference).toLocaleString('en-US', {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n })}.`\n );\n}\n\nfunction wrongAccountFinding(row) {\n const existing = normalizeText(row.observation || '');\n\n if (existing) {\n return existing;\n }\n\n const payrollAccounts =\n payrollAccountsForWrongAccount(row);\n const bankAccount =\n bankAccountForWrongAccount(row);\n\n return (\n `El empleado presenta una posible inconsistencia entre ` +\n `la cuenta registrada en nómina ` +\n `(${payrollAccounts.join(' y ') || 'sin cuenta identificada'}) ` +\n `y la cuenta utilizada por el banco ` +\n `(${bankAccount || 'sin cuenta identificada'}).`\n );\n}\n\nconst cuentaMalDigitadaHeader = [\n '#',\n 'Campo',\n 'Detalle',\n 'Resolución',\n];\n\nconst cuentaMalDigitadaRows = [];\n\nfor (\n let index = 0;\n index < cuentaMalDigitadaCases.length;\n index++\n) {\n const row = cuentaMalDigitadaCases[index];\n const payrollAccounts =\n payrollAccountsForWrongAccount(row);\n const bankAccount =\n bankAccountForWrongAccount(row);\n\n const fields = [\n [\n 'Empleado',\n normalizeText(\n row.employee_name ||\n row.employee ||\n ''\n ),\n ],\n [\n 'Cuentas registradas en las hojas de nómina',\n payrollAccounts.join(' y ') ||\n 'No se identificó una cuenta válida en la nómina.',\n ],\n [\n 'Cuenta utilizada por el banco',\n bankAccount ||\n 'No se identificó una cuenta válida en el banco.',\n ],\n [\n 'Estado del total',\n wrongAccountTotalStatus(row),\n ],\n [\n 'Hallazgo',\n wrongAccountFinding(row),\n ],\n [\n 'Clasificación',\n 'Posible cuenta mal digitada — revisar y unificar la cuenta en las hojas de nómina.',\n ],\n ];\n\n fields.forEach((field, fieldIndex) => {\n cuentaMalDigitadaRows.push([\n fieldIndex === 0 ? index + 1 : '',\n field[0],\n field[1],\n '',\n ]);\n });\n}\n\nconst cuentaMalDigitadaValues = [\n [\n 'GOMEZLEE MARKETING',\n '',\n '',\n '',\n ],\n [\n cuentaMalDigitadaSubtitle,\n '',\n '',\n '',\n ],\n ['', '', '', ''],\n cuentaMalDigitadaHeader,\n ...cuentaMalDigitadaRows,\n];\n\nconst resumenHeader = ['Indicador', 'Valor'];\nconst resumenRows = [\n ['Período', periodLabel],\n ['Coincidencias', Number(summary.coincidencias || 0)],\n ['Discrepancias', Number(summary.discrepancias || 0)],\n ['Banco sin nómina', Number(summary.bancoSinNomina || 0)],\n ['Banco sin Bamboo', Number(summary.bancoSinBamboo || 0)],\n ['Nómina sin cuenta', Number(summary.nominaSinCuenta || 0)],\n [\n 'Diferencias nombre banco',\n Number(summary.diferenciasNombreBanco || 0),\n ],\n [\n 'Posibles cuentas mal digitadas',\n Number(summary.posiblesCuentasMalDigitadas || 0),\n ],\n ['Pendientes cruce principal', Number(summary.pendientes || 0)],\n [\n 'Empleados BambooHR Puerto Rico',\n Number(summary.empleadosBambooPR || 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', `USD ${roundMoney(summary.totalNominaPorMoneda?.USD || summary.totalNomina || 0).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}`],\n ['Total banco', `USD ${roundMoney(summary.totalBancoPorMoneda?.USD || summary.totalBanco || 0).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}`],\n ['Diferencia total', `USD ${roundMoney(summary.diferenciaTotalPorMoneda?.USD || summary.diferenciaTotal || 0).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}`],\n];\n\nfunction buildSheetValues(header, bodyRows) {\n return [header, ...bodyRows];\n}\n\nconst valueData = [\n {\n range: `'${sheetTitles.nominaVsBanco}'!A1:I`,\n values: nominaVsBancoValues,\n },\n {\n range: `'${sheetTitles.bancoSinNomina}'!A1:G`,\n values: buildSheetValues(\n bancoSinNominaHeader,\n bancoSinNominaRows\n ),\n },\n {\n range: `'${sheetTitles.bancoSinBamboo}'!A1:H`,\n values: bancoSinBambooValues,\n },\n {\n range: `'${sheetTitles.diferenciasNombreBanco}'!A1:K`,\n values: buildSheetValues(\n diferenciasNombreHeader,\n diferenciasNombreRows\n ),\n },\n ...(hasCuentaMalDigitada\n ? [\n {\n range:\n `'${sheetTitles.cuentaMalDigitada}'!A1:D`,\n values: cuentaMalDigitadaValues,\n },\n ]\n : []),\n {\n range: `'${sheetTitles.resumen}'!A1:B`,\n values: buildSheetValues(\n resumenHeader,\n resumenRows\n ),\n },\n];\n\nfunction headerFormatRequest(\n sheetId,\n endColumnIndex,\n startRowIndex = 0\n) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex: startRowIndex + 1,\n startColumnIndex: 0,\n endColumnIndex,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: {\n red: 0.29,\n green: 0.49,\n blue: 0.58,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 1,\n green: 1,\n blue: 1,\n },\n },\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n },\n },\n fields:\n 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment,verticalAlignment,wrapStrategy)',\n },\n };\n}\n\nfunction freezeHeaderRequest(sheetId, frozenRowCount = 1) {\n return {\n updateSheetProperties: {\n properties: {\n sheetId,\n gridProperties: {\n frozenRowCount,\n },\n },\n fields: 'gridProperties.frozenRowCount',\n },\n };\n}\n\nfunction autoResizeRequest(sheetId, endColumnIndex) {\n return {\n autoResizeDimensions: {\n dimensions: {\n sheetId,\n dimension: 'COLUMNS',\n startIndex: 0,\n endIndex: endColumnIndex,\n },\n },\n };\n}\n\nfunction moneyFormatRequest(\n sheetId,\n startColumnIndex,\n endColumnIndex,\n startRowIndex = 1,\n pattern = '#,##0.00',\n endRowIndex = null\n) {\n const range = {\n sheetId,\n startRowIndex,\n startColumnIndex,\n endColumnIndex,\n };\n\n if (Number.isInteger(endRowIndex)) {\n range.endRowIndex = endRowIndex;\n }\n\n return {\n repeatCell: {\n range,\n cell: {\n userEnteredFormat: {\n numberFormat: {\n type: 'NUMBER',\n pattern,\n },\n },\n },\n fields: 'userEnteredFormat.numberFormat',\n },\n };\n}\n\nfunction basicFilterRequest(\n sheetId,\n endColumnIndex,\n startRowIndex = 0,\n endRowIndex = null\n) {\n const range = {\n sheetId,\n startRowIndex,\n startColumnIndex: 0,\n endColumnIndex,\n };\n\n if (Number.isInteger(endRowIndex)) {\n range.endRowIndex = endRowIndex;\n }\n\n return {\n setBasicFilter: {\n filter: {\n range,\n },\n },\n };\n}\n\nfunction mergeRowRequest(\n sheetId,\n rowIndex,\n endColumnIndex\n) {\n return {\n mergeCells: {\n range: {\n sheetId,\n startRowIndex: rowIndex,\n endRowIndex: rowIndex + 1,\n startColumnIndex: 0,\n endColumnIndex,\n },\n mergeType: 'MERGE_ALL',\n },\n };\n}\n\nfunction titleRowFormatRequest(\n sheetId,\n rowIndex,\n endColumnIndex,\n options = {}\n) {\n const {\n fontSize = 12,\n bold = true,\n italic = false,\n horizontalAlignment = 'LEFT',\n } = options;\n\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: rowIndex,\n endRowIndex: rowIndex + 1,\n startColumnIndex: 0,\n endColumnIndex,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: {\n red: 0.29,\n green: 0.49,\n blue: 0.58,\n },\n textFormat: {\n bold,\n italic,\n fontSize,\n foregroundColor: {\n red: 1,\n green: 1,\n blue: 1,\n },\n },\n horizontalAlignment,\n verticalAlignment: 'MIDDLE',\n },\n },\n fields:\n 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment,verticalAlignment)',\n },\n };\n}\n\nfunction columnWidthRequest(\n sheetId,\n startIndex,\n endIndex,\n pixelSize\n) {\n return {\n updateDimensionProperties: {\n range: {\n sheetId,\n dimension: 'COLUMNS',\n startIndex,\n endIndex,\n },\n properties: {\n pixelSize,\n },\n fields: 'pixelSize',\n },\n };\n}\n\nfunction rowHeightRequest(\n sheetId,\n startIndex,\n endIndex,\n pixelSize\n) {\n return {\n updateDimensionProperties: {\n range: {\n sheetId,\n dimension: 'ROWS',\n startIndex,\n endIndex,\n },\n properties: {\n pixelSize,\n },\n fields: 'pixelSize',\n },\n };\n}\n\nfunction bodyAlignmentRequest(\n sheetId,\n startColumnIndex,\n endColumnIndex,\n horizontalAlignment,\n endRowIndex\n) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: 4,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n },\n cell: {\n userEnteredFormat: {\n horizontalAlignment,\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n },\n },\n fields:\n 'userEnteredFormat(horizontalAlignment,verticalAlignment,wrapStrategy)',\n },\n };\n}\n\nfunction statusFormatRequest(sheetId, endRowIndex) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: 4,\n endRowIndex,\n startColumnIndex: 7,\n endColumnIndex: 8,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: {\n red: 1,\n green: 0.92,\n blue: 0.92,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.85,\n green: 0.08,\n blue: 0.08,\n },\n },\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n },\n },\n fields:\n 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment,verticalAlignment)',\n },\n };\n}\n\nfunction bambooStatusFormatRequest(\n sheetId,\n endRowIndex\n) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: 4,\n endRowIndex,\n startColumnIndex: 6,\n endColumnIndex: 7,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: {\n red: 1,\n green: 0.97,\n blue: 0.82,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.70,\n green: 0.30,\n blue: 0.00,\n },\n },\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n },\n },\n fields:\n 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment,verticalAlignment,wrapStrategy)',\n },\n };\n}\n\n\nfunction mergeVerticalRequest(\n sheetId,\n startRowIndex,\n endRowIndex,\n columnIndex\n) {\n return {\n mergeCells: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex: columnIndex,\n endColumnIndex: columnIndex + 1,\n },\n mergeType: 'MERGE_ALL',\n },\n };\n}\n\nfunction rangeFormatRequest(\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n userEnteredFormat\n) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n },\n cell: {\n userEnteredFormat,\n },\n fields:\n 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment,verticalAlignment,wrapStrategy)',\n },\n };\n}\n\nfunction conditionalDifferenceRequest(\n sheetId,\n formula,\n backgroundColor,\n textColor,\n ranges,\n index\n) {\n return {\n addConditionalFormatRule: {\n index,\n rule: {\n ranges,\n booleanRule: {\n condition: {\n type: 'CUSTOM_FORMULA',\n values: [\n {\n userEnteredValue: formula,\n },\n ],\n },\n format: {\n backgroundColor,\n textFormat: {\n bold: true,\n foregroundColor: textColor,\n },\n },\n },\n },\n },\n };\n}\n\nfunction borderRequest(\n sheetId,\n startRowIndex,\n endRowIndex,\n endColumnIndex\n) {\n const border = {\n style: 'SOLID',\n color: {\n red: 0.72,\n green: 0.82,\n blue: 0.76,\n },\n };\n\n return {\n updateBorders: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex: 0,\n endColumnIndex,\n },\n top: border,\n bottom: border,\n left: border,\n right: border,\n innerHorizontal: border,\n innerVertical: border,\n },\n };\n}\n\nconst mainReportEndRow = Math.max(\n 4 + mainReportRows.length,\n 4\n);\n\nconst bancoSinBambooEndRow = Math.max(\n 4 + bancoSinBambooRows.length,\n 4\n);\n\nconst mainConditionalRanges = mainReportRows.length\n ? [\n {\n sheetId: sheetIds.nominaVsBanco,\n startRowIndex: 4,\n endRowIndex: mainReportEndRow,\n startColumnIndex: 5,\n endColumnIndex: 7,\n },\n ]\n : [];\n\nconst mainDataFormatRequests = mainReportRows.length\n ? [\n moneyFormatRequest(\n sheetIds.nominaVsBanco,\n 3,\n 6,\n 4,\n 'Q#,##0.00;[Red](Q#,##0.00)',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 0,\n 1,\n 'CENTER',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 1,\n 2,\n 'LEFT',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 2,\n 3,\n 'CENTER',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 3,\n 6,\n 'RIGHT',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 6,\n 8,\n 'CENTER',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 8,\n 9,\n 'LEFT',\n mainReportEndRow\n ),\n statusFormatRequest(\n sheetIds.nominaVsBanco,\n mainReportEndRow\n ),\n conditionalDifferenceRequest(\n sheetIds.nominaVsBanco,\n '=$F5>0',\n {\n red: 1,\n green: 0.97,\n blue: 0.82,\n },\n {\n red: 0.25,\n green: 0.25,\n blue: 0.25,\n },\n mainConditionalRanges,\n 0\n ),\n conditionalDifferenceRequest(\n sheetIds.nominaVsBanco,\n '=$F5<0',\n {\n red: 1,\n green: 0.89,\n blue: 0.89,\n },\n {\n red: 0.9,\n green: 0.05,\n blue: 0.05,\n },\n mainConditionalRanges,\n 1\n ),\n rowHeightRequest(\n sheetIds.nominaVsBanco,\n 4,\n mainReportEndRow,\n 28\n ),\n ]\n : [];\n\n\nconst cuentaMalDigitadaEndRow = Math.max(\n 4 + cuentaMalDigitadaRows.length,\n 4\n);\n\nconst cuentaMalDigitadaCaseRequests =\n hasCuentaMalDigitada\n ? cuentaMalDigitadaCases.flatMap(\n (_, caseIndex) => {\n const startRowIndex =\n 4 + caseIndex * 6;\n const endRowIndex =\n startRowIndex + 6;\n\n return [\n mergeVerticalRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endRowIndex,\n 0\n ),\n mergeVerticalRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endRowIndex,\n 3\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n startRowIndex + 4,\n 34\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex + 4,\n startRowIndex + 5,\n 76\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex + 5,\n endRowIndex,\n 54\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex + 4,\n startRowIndex + 5,\n 2,\n 3,\n {\n backgroundColor: {\n red: 0.97,\n green: 0.97,\n blue: 0.97,\n },\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex + 5,\n endRowIndex,\n 2,\n 3,\n {\n backgroundColor: {\n red: 1,\n green: 0.97,\n blue: 0.82,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.55,\n green: 0.30,\n blue: 0.00,\n },\n },\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n ];\n }\n )\n : [];\n\nconst cuentaMalDigitadaFormatRequests =\n hasCuentaMalDigitada\n ? [\n mergeRowRequest(\n sheetIds.cuentaMalDigitada,\n 0,\n 4\n ),\n mergeRowRequest(\n sheetIds.cuentaMalDigitada,\n 1,\n 4\n ),\n titleRowFormatRequest(\n sheetIds.cuentaMalDigitada,\n 0,\n 4,\n {\n fontSize: 12,\n bold: true,\n italic: false,\n horizontalAlignment: 'LEFT',\n }\n ),\n titleRowFormatRequest(\n sheetIds.cuentaMalDigitada,\n 1,\n 4,\n {\n fontSize: 10,\n bold: false,\n italic: true,\n horizontalAlignment: 'LEFT',\n }\n ),\n headerFormatRequest(\n sheetIds.cuentaMalDigitada,\n 4,\n 3\n ),\n freezeHeaderRequest(\n sheetIds.cuentaMalDigitada,\n 4\n ),\n borderRequest(\n sheetIds.cuentaMalDigitada,\n 3,\n cuentaMalDigitadaEndRow,\n 4\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n 0,\n 1,\n 30\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n 1,\n 2,\n 26\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n 3,\n 4,\n 40\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n 4,\n cuentaMalDigitadaEndRow,\n 0,\n 1,\n {\n backgroundColor: {\n red: 0.91,\n green: 0.95,\n blue: 0.99,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.20,\n green: 0.36,\n blue: 0.45,\n },\n },\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n 4,\n cuentaMalDigitadaEndRow,\n 1,\n 2,\n {\n backgroundColor: {\n red: 0.93,\n green: 0.97,\n blue: 0.90,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.20,\n green: 0.36,\n blue: 0.45,\n },\n },\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n 4,\n cuentaMalDigitadaEndRow,\n 2,\n 3,\n {\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n 4,\n cuentaMalDigitadaEndRow,\n 3,\n 4,\n {\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 0,\n 1,\n 48\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 1,\n 2,\n 285\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 2,\n 3,\n 520\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 3,\n 4,\n 260\n ),\n ...cuentaMalDigitadaCaseRequests,\n ]\n : [];\n\n\nfunction wrapRangeRequest(\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex\n) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n },\n cell: {\n userEnteredFormat: {\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n },\n },\n fields:\n 'userEnteredFormat(verticalAlignment,wrapStrategy)',\n },\n };\n}\n\nfunction autoResizeRowsRequest(\n sheetId,\n startIndex,\n endIndex\n) {\n return {\n autoResizeDimensions: {\n dimensions: {\n sheetId,\n dimension: 'ROWS',\n startIndex,\n endIndex,\n },\n },\n };\n}\n\nconst formatRequests = [\n mergeRowRequest(sheetIds.nominaVsBanco, 0, 9),\n mergeRowRequest(sheetIds.nominaVsBanco, 1, 9),\n titleRowFormatRequest(\n sheetIds.nominaVsBanco,\n 0,\n 9,\n {\n fontSize: 12,\n bold: true,\n italic: false,\n horizontalAlignment: 'LEFT',\n }\n ),\n titleRowFormatRequest(\n sheetIds.nominaVsBanco,\n 1,\n 9,\n {\n fontSize: 10,\n bold: false,\n italic: true,\n horizontalAlignment: 'LEFT',\n }\n ),\n headerFormatRequest(\n sheetIds.nominaVsBanco,\n 9,\n 3\n ),\n freezeHeaderRequest(\n sheetIds.nominaVsBanco,\n 4\n ),\n basicFilterRequest(\n sheetIds.nominaVsBanco,\n 9,\n 3,\n mainReportEndRow\n ),\n borderRequest(\n sheetIds.nominaVsBanco,\n 3,\n mainReportEndRow,\n 9\n ),\n rowHeightRequest(\n sheetIds.nominaVsBanco,\n 0,\n 1,\n 30\n ),\n rowHeightRequest(\n sheetIds.nominaVsBanco,\n 1,\n 2,\n 26\n ),\n rowHeightRequest(\n sheetIds.nominaVsBanco,\n 3,\n 4,\n 42\n ),\n ...mainDataFormatRequests,\n\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 0,\n 1,\n 48\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 1,\n 2,\n 260\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 2,\n 3,\n 130\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 3,\n 6,\n 130\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 6,\n 7,\n 155\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 7,\n 8,\n 110\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 8,\n 9,\n 260\n ),\n\n headerFormatRequest(sheetIds.bancoSinNomina, 7),\n freezeHeaderRequest(sheetIds.bancoSinNomina),\n autoResizeRequest(sheetIds.bancoSinNomina, 7),\n moneyFormatRequest(sheetIds.bancoSinNomina, 3, 4),\n basicFilterRequest(sheetIds.bancoSinNomina, 7),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 6,\n 7,\n 260\n ),\n\n mergeRowRequest(sheetIds.bancoSinBamboo, 0, 8),\n mergeRowRequest(sheetIds.bancoSinBamboo, 1, 8),\n titleRowFormatRequest(\n sheetIds.bancoSinBamboo,\n 0,\n 8,\n {\n fontSize: 12,\n bold: true,\n italic: false,\n horizontalAlignment: 'LEFT',\n }\n ),\n titleRowFormatRequest(\n sheetIds.bancoSinBamboo,\n 1,\n 8,\n {\n fontSize: 10,\n bold: false,\n italic: true,\n horizontalAlignment: 'LEFT',\n }\n ),\n headerFormatRequest(\n sheetIds.bancoSinBamboo,\n 8,\n 3\n ),\n freezeHeaderRequest(\n sheetIds.bancoSinBamboo,\n 4\n ),\n basicFilterRequest(\n sheetIds.bancoSinBamboo,\n 8,\n 3,\n bancoSinBambooEndRow\n ),\n borderRequest(\n sheetIds.bancoSinBamboo,\n 3,\n bancoSinBambooEndRow,\n 8\n ),\n rowHeightRequest(\n sheetIds.bancoSinBamboo,\n 0,\n 1,\n 30\n ),\n rowHeightRequest(\n sheetIds.bancoSinBamboo,\n 1,\n 2,\n 26\n ),\n rowHeightRequest(\n sheetIds.bancoSinBamboo,\n 3,\n 4,\n 42\n ),\n moneyFormatRequest(\n sheetIds.bancoSinBamboo,\n 4,\n 5,\n 4,\n 'Q#,##0.00',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 0,\n 1,\n 'CENTER',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 1,\n 3,\n 'LEFT',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 3,\n 4,\n 'CENTER',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 4,\n 5,\n 'RIGHT',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 5,\n 7,\n 'CENTER',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 7,\n 8,\n 'LEFT',\n bancoSinBambooEndRow\n ),\n bambooStatusFormatRequest(\n sheetIds.bancoSinBamboo,\n bancoSinBambooEndRow\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 0,\n 1,\n 48\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 1,\n 2,\n 220\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 2,\n 3,\n 270\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 3,\n 4,\n 135\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 4,\n 5,\n 130\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 5,\n 6,\n 110\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 6,\n 7,\n 140\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 7,\n 8,\n 260\n ),\n\n headerFormatRequest(\n sheetIds.diferenciasNombreBanco,\n 11\n ),\n freezeHeaderRequest(\n sheetIds.diferenciasNombreBanco\n ),\n autoResizeRequest(\n sheetIds.diferenciasNombreBanco,\n 11\n ),\n moneyFormatRequest(\n sheetIds.diferenciasNombreBanco,\n 4,\n 5\n ),\n basicFilterRequest(\n sheetIds.diferenciasNombreBanco,\n 11\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 10,\n 11,\n 260\n ),\n\n ...cuentaMalDigitadaFormatRequests,\n\n headerFormatRequest(sheetIds.resumen, 2),\n freezeHeaderRequest(sheetIds.resumen),\n autoResizeRequest(sheetIds.resumen, 2),\n moneyFormatRequest(sheetIds.resumen, 1, 2),\n];\n\n\nconst bancoSinNominaEndRow =\n 1 + bancoSinNominaRows.length;\n\nconst diferenciasNombreEndRow =\n 1 + diferenciasNombreRows.length;\n\nconst resumenReadabilityEndRow =\n 1 + resumenRows.length;\n\n/*\n * Ajuste final de legibilidad.\n *\n * Se ejecuta al final para que los anchos definitivos ya estén aplicados\n * cuando Google Sheets calcule automáticamente la altura de cada fila.\n * Así, cualquier texto largo queda envuelto y visible sin que el usuario\n * tenga que expandir columnas o filas manualmente.\n */\nformatRequests.push(\n // 01 Nómina vs Banco\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 1,\n 2,\n 300\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 2,\n 3,\n 150\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 3,\n 6,\n 145\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 6,\n 7,\n 220\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 7,\n 8,\n 140\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 8,\n 9,\n 320\n ),\n ...(mainReportRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.nominaVsBanco,\n 4,\n mainReportEndRow,\n 0,\n 9\n ),\n autoResizeRowsRequest(\n sheetIds.nominaVsBanco,\n 4,\n mainReportEndRow\n ),\n ]\n : []),\n\n // 02 Banco sin Nómina\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 0,\n 1,\n 320\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 1,\n 2,\n 165\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 2,\n 3,\n 95\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 3,\n 4,\n 140\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 4,\n 5,\n 170\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 5,\n 6,\n 560\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 6,\n 7,\n 320\n ),\n autoResizeRowsRequest(\n sheetIds.bancoSinNomina,\n 0,\n 1\n ),\n ...(bancoSinNominaRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.bancoSinNomina,\n 1,\n bancoSinNominaEndRow,\n 0,\n 7\n ),\n autoResizeRowsRequest(\n sheetIds.bancoSinNomina,\n 1,\n bancoSinNominaEndRow\n ),\n ]\n : []),\n\n // 03 Banco sin Bamboo\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 1,\n 2,\n 300\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 2,\n 3,\n 330\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 3,\n 4,\n 150\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 4,\n 5,\n 145\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 5,\n 6,\n 150\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 6,\n 7,\n 170\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 7,\n 8,\n 320\n ),\n ...(bancoSinBambooRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.bancoSinBamboo,\n 4,\n bancoSinBambooEndRow,\n 0,\n 8\n ),\n autoResizeRowsRequest(\n sheetIds.bancoSinBamboo,\n 4,\n bancoSinBambooEndRow\n ),\n ]\n : []),\n\n // 04 Diferencias nombre banco\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 0,\n 1,\n 300\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 1,\n 2,\n 330\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 2,\n 3,\n 155\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 3,\n 4,\n 95\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 4,\n 5,\n 140\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 5,\n 7,\n 150\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 7,\n 8,\n 260\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 8,\n 9,\n 170\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 9,\n 10,\n 650\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 10,\n 11,\n 320\n ),\n autoResizeRowsRequest(\n sheetIds.diferenciasNombreBanco,\n 0,\n 1\n ),\n ...(diferenciasNombreRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.diferenciasNombreBanco,\n 1,\n diferenciasNombreEndRow,\n 0,\n 11\n ),\n autoResizeRowsRequest(\n sheetIds.diferenciasNombreBanco,\n 1,\n diferenciasNombreEndRow\n ),\n ]\n : []),\n\n // 05 Cuenta Mal Digitada\n ...(hasCuentaMalDigitada\n ? [\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 1,\n 2,\n 300\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 2,\n 3,\n 600\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 3,\n 4,\n 320\n ),\n ]\n : []),\n\n // Resumen\n columnWidthRequest(\n sheetIds.resumen,\n 0,\n 1,\n 380\n ),\n columnWidthRequest(\n sheetIds.resumen,\n 1,\n 2,\n 320\n ),\n autoResizeRowsRequest(\n sheetIds.resumen,\n 0,\n 1\n ),\n ...(resumenRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.resumen,\n 1,\n resumenReadabilityEndRow,\n 0,\n 2\n ),\n autoResizeRowsRequest(\n sheetIds.resumen,\n 1,\n resumenReadabilityEndRow\n ),\n ]\n : [])\n);\n\n\n\n/*\n * Protección de integridad del reporte.\n *\n * La hoja completa queda protegida para impedir que los usuarios con acceso\n * al reporte modifiquen datos calculados, nombres, cuentas, montos, estados,\n * observaciones o cualquier otro resultado del cruce.\n *\n * Únicamente las celdas de datos de la columna \"Resolución\" quedan fuera de\n * la protección para que puedan completarse después de recibir el reporte.\n * Los encabezados y las filas fuera de la tabla permanecen protegidos.\n *\n * El propietario de la hoja conserva siempre el control. Además, el usuario\n * administrativo iaracena@gomezleemarketing.com queda registrado como editor explícito del\n * rango protegido para mantenimiento de IT.\n */\nfunction protectedSheetRequest(\n sheetId,\n description,\n resolutionColumnIndex = null,\n resolutionStartRowIndex = null,\n resolutionRowsCount = 0\n) {\n const protectedRange = {\n range: {\n sheetId,\n },\n description,\n warningOnly: false,\n editors: {\n users: [\n 'iaracena@gomezleemarketing.com',\n ],\n domainUsersCanEdit: false,\n },\n };\n\n if (\n Number.isInteger(resolutionColumnIndex) &&\n Number.isInteger(resolutionStartRowIndex) &&\n Number(resolutionRowsCount) > 0\n ) {\n protectedRange.unprotectedRanges = [\n {\n sheetId,\n startRowIndex:\n resolutionStartRowIndex,\n endRowIndex:\n resolutionStartRowIndex +\n Number(resolutionRowsCount),\n startColumnIndex:\n resolutionColumnIndex,\n endColumnIndex:\n resolutionColumnIndex + 1,\n },\n ];\n }\n\n return {\n addProtectedRange: {\n protectedRange,\n },\n };\n}\n\n\nfunction protectedResolutionRequest(\n sheetId,\n resolutionColumnIndex,\n resolutionStartRowIndex,\n resolutionRowsCount\n) {\n if (\n !Number.isInteger(resolutionColumnIndex) ||\n !Number.isInteger(resolutionStartRowIndex) ||\n Number(resolutionRowsCount) <= 0\n ) {\n return null;\n }\n\n return {\n addProtectedRange: {\n protectedRange: {\n range: {\n sheetId,\n startRowIndex:\n resolutionStartRowIndex,\n endRowIndex:\n resolutionStartRowIndex +\n Number(resolutionRowsCount),\n startColumnIndex:\n resolutionColumnIndex,\n endColumnIndex:\n resolutionColumnIndex + 1,\n },\n description:\n 'Protección GLM — Resolución. Solo Viviana Páramo y Liliana Benítez pueden editar.',\n warningOnly: false,\n editors: {\n users: [\n 'vparamo@gomezleemarketing.com',\n 'adminregional@gomezleemarketing.com',\n ],\n domainUsersCanEdit: false,\n },\n },\n },\n };\n}\n\nfunction addResolutionProtection(\n sheetId,\n resolutionColumnIndex,\n resolutionStartRowIndex,\n resolutionRowsCount\n) {\n const request =\n protectedResolutionRequest(\n sheetId,\n resolutionColumnIndex,\n resolutionStartRowIndex,\n resolutionRowsCount\n );\n\n if (request) {\n formatRequests.push(request);\n }\n}\n\n\nformatRequests.push(\n protectedSheetRequest(\n sheetIds.nominaVsBanco,\n 'Protección GLM — datos del cruce. Solo Resolución es editable.',\n 8,\n 4,\n mainReportRows.length\n ),\n protectedSheetRequest(\n sheetIds.bancoSinNomina,\n 'Protección GLM — datos del cruce. Solo Resolución es editable.',\n 6,\n 1,\n bancoSinNominaRows.length\n ),\n protectedSheetRequest(\n sheetIds.bancoSinBamboo,\n 'Protección GLM — datos del cruce. Solo Resolución es editable.',\n 7,\n 4,\n bancoSinBambooRows.length\n ),\n protectedSheetRequest(\n sheetIds.diferenciasNombreBanco,\n 'Protección GLM — datos del cruce. Solo Resolución es editable.',\n 10,\n 1,\n diferenciasNombreRows.length\n ),\n ...(hasCuentaMalDigitada\n ? [\n protectedSheetRequest(\n sheetIds.cuentaMalDigitada,\n 'Protección GLM — datos del cruce. Solo Resolución es editable.',\n 3,\n 4,\n cuentaMalDigitadaRows.length\n ),\n ]\n : []),\n protectedSheetRequest(\n sheetIds.resumen,\n 'Protección GLM — hoja de resumen de solo lectura.'\n )\n);\n\n\naddResolutionProtection(\n sheetIds.nominaVsBanco,\n 8,\n 4,\n mainReportRows.length\n);\naddResolutionProtection(\n sheetIds.bancoSinNomina,\n 6,\n 1,\n bancoSinNominaRows.length\n);\naddResolutionProtection(\n sheetIds.bancoSinBamboo,\n 7,\n 4,\n bancoSinBambooRows.length\n);\naddResolutionProtection(\n sheetIds.diferenciasNombreBanco,\n 10,\n 1,\n diferenciasNombreRows.length\n);\nif (hasCuentaMalDigitada) {\n addResolutionProtection(\n sheetIds.cuentaMalDigitada,\n 3,\n 4,\n cuentaMalDigitadaRows.length\n );\n}\n\n\nreturn [\n {\n json: {\n ok: true,\n stage: 'preparar_google_sheet',\n metadata,\n summary,\n spreadsheetTitle,\n sheetIds,\n sheetTitles,\n createSpreadsheetBody: {\n properties: {\n title: spreadsheetTitle,\n },\n sheets: [\n {\n properties: {\n sheetId: sheetIds.nominaVsBanco,\n title: sheetTitles.nominaVsBanco,\n },\n },\n {\n properties: {\n sheetId: sheetIds.bancoSinNomina,\n title: sheetTitles.bancoSinNomina,\n },\n },\n {\n properties: {\n sheetId: sheetIds.bancoSinBamboo,\n title: sheetTitles.bancoSinBamboo,\n },\n },\n {\n properties: {\n sheetId:\n sheetIds.diferenciasNombreBanco,\n title:\n sheetTitles.diferenciasNombreBanco,\n },\n },\n ...(hasCuentaMalDigitada\n ? [\n {\n properties: {\n sheetId:\n sheetIds.cuentaMalDigitada,\n title:\n sheetTitles.cuentaMalDigitada,\n },\n },\n ]\n : []),\n {\n properties: {\n sheetId: sheetIds.resumen,\n title: sheetTitles.resumen,\n },\n },\n ],\n },\n valueBatchBody: {\n // RAW conserva los números de cuenta como texto exacto (incluidos\n // ceros iniciales) y mantiene los montos numéricos porque ya se\n // envían como números en el payload.\n valueInputOption: 'RAW',\n data: valueData,\n },\n formatBatchBody: {\n requests: formatRequests,\n },\n originalResponse: data,\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1840,
512
],
"id": "b5d8cdc8-e213-42ab-abfb-a7a1c8c1e933",
"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 || 'PR', 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": [
2080,
512
],
"id": "52000c75-ab56-457f-93c6-9881ed06ceb5",
"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/Puerto_Rico',\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": [
2320,
512
],
"id": "b43f794a-99ce-493c-9351-11efad8ff4e8",
"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": [
2560,
512
],
"id": "529d496a-8835-4400-b6fe-fed68ededa4f",
"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": [
2800,
512
],
"id": "a870ce54-541e-4153-8a39-16b40c4473ee",
"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": [
3040,
512
],
"id": "1e8e1a60-3c44-4032-90a2-f730cfc7e923",
"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": [
3280,
512
],
"id": "17a9f487-226d-4e5e-a25f-211307a82eba",
"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": [
3520,
512
],
"id": "d6360b18-64d0-4bac-bd8a-0c36408ae17a",
"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": [
3760,
512
],
"id": "a0548db1-391c-44ac-8ecb-dcf743e43288",
"name": "Formatear Google Sheet",
"retryOnFail": true,
"credentials": {
"googleOAuth2Api": {
"id": "dQ1MJSJSWcoWYcb8",
"name": "Google account - Isaac Producción"
}
}
},
{
"parameters": {
"jsCode": "const context =\n $('Preparar actualización Google Sheet').first().json || {};\n\nconst spreadsheetId =\n context.spreadsheetId;\n\nif (!spreadsheetId) {\n throw new Error(\n 'No se recibió spreadsheetId para configurar permisos.'\n );\n}\n\nconst allowedEmails = [\n 'iaracena@gomezleemarketing.com',\n 'ymadera@gomezleemarketing.com',\n 'mgomez@gomezleemarketing.com',\n 'jgomez@gomezleemarketing.com',\n 'vparamo@gomezleemarketing.com',\n 'adminregional@gomezleemarketing.com'\n];\n\nreturn allowedEmails.map((email) => ({\n json: {\n spreadsheetId,\n email,\n permissionBody: {\n type: 'user',\n role: 'writer',\n emailAddress: email,\n },\n reportUpdated:\n context.reportUpdated === true,\n },\n}));"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
4000,
512
],
"id": "33a944c6-427c-49ff-8357-815e04819b67",
"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": [
4240,
512
],
"id": "c6f70254-2628-4bc1-9fce-c69fb28e9fde",
"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 || 'PR';\n const year = periodMetadata.year || '';\n const month = String(periodMetadata.month || '').padStart(2, '0');\n const periodType = periodMetadata.period_type || 'periodo';\n return `${country}-${year}-${month}-${periodType}`;\n}\n\nconst discrepancias = toNumber(summary.discrepancias);\nconst bancoSinNomina = toNumber(summary.bancoSinNomina);\nconst nominaSinCuenta = toNumber(summary.nominaSinCuenta);\nconst diferenciasNombreBanco =\n toNumber(summary.diferenciasNombreBanco);\nconst bancoSinBamboo =\n toNumber(summary.bancoSinBamboo);\nconst pendientes = toNumber(summary.pendientes) || (\n discrepancias +\n bancoSinNomina +\n nominaSinCuenta +\n diferenciasNombreBanco\n);\n\n// Banco sin Bamboo es una revisión independiente del cruce principal.\nconst requiereRevision =\n pendientes > 0 || bancoSinBamboo > 0;\n\n// La app final solo manejará Pendiente revisión y Resuelto.\nconst estado = requiereRevision\n ? 'pendiente_revision'\n : 'resuelto';\n\nconst reportIdentity = [\n 'PR',\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-puerto-rico',\n country: metadata.country || 'PR',\n country_name: metadata.country_name || 'Puerto Rico',\n client_request_id: metadata.client_request_id || null,\n report_identity: reportIdentity,\n\n year: toNumber(metadata.year),\n month: toNumber(metadata.month),\n period_type: metadata.period_type || '',\n period_label: metadata.period_label || '',\n period_start: metadata.period_start || null,\n period_end: metadata.period_end || null,\n period_key: buildPeriodKey(metadata),\n\n payroll_file_name: metadata.payroll_file_name || '',\n bank_file_names: metadata.bank_file_names || [],\n\n coincidencias: toNumber(summary.coincidencias),\n discrepancias,\n\n banco_sin_bamboo: bancoSinBamboo,\n detalle_banco_sin_bamboo:\n Array.isArray(cruce.bankWithoutBamboo)\n ? cruce.bankWithoutBamboo\n : [],\n banco_sin_nomina: bancoSinNomina,\n nomina_sin_cuenta: nominaSinCuenta,\n nomina_sin_bamboo: 0,\n bamboo_sin_nomina: 0,\n\n filas_nomina_validas: toNumber(summary.filasNominaValidas),\n cuentas_nomina_agrupadas: toNumber(summary.cuentasNominaAgrupadas),\n transacciones_banco: toNumber(summary.transaccionesBanco),\n cuentas_banco_agrupadas: toNumber(summary.cuentasBancoAgrupadas),\n\n total_nomina: toNumber(summary.totalNomina),\n total_banco: toNumber(summary.totalBanco),\n diferencia_total: toNumber(summary.diferenciaTotal),\n\n report_url: reportUrl,\n spreadsheet_id: spreadsheetId,\n estado,\n\n ejecutado_por_nombre: metadata.requested_by_name || 'Usuario GLM',\n ejecutado_por_email: metadata.requested_by_email || '',\n\n metadata: {\n ...metadata,\n report_identity: reportIdentity,\n report_updated: reportUpdated,\n preserved_resolutions_count: preservedResolutionsCount,\n diferencias_nombre_banco:\n diferenciasNombreBanco,\n banco_sin_bamboo:\n bancoSinBamboo,\n pendientes_cruce_principal:\n pendientes,\n requiere_revision:\n requiereRevision,\n },\n summary,\n debug: {\n sheet_summaries: debug.sheet_summaries || [],\n attached_supplements: debug.attached_supplements || [],\n unattached_supplements: debug.unattached_supplements || [],\n bank_name_differences_preview:\n debug.bank_name_differences_preview || [],\n bamboo_matches:\n debug.bamboo_matches || [],\n bamboo_excluded_payments:\n debug.bamboo_excluded_payments || [],\n banco_sin_bamboo:\n cruce.bankWithoutBamboo || [],\n },\n};\n\n\nif (\n reportUpdated &&\n estado !== 'resuelto'\n) {\n payload.resuelto_por_nombre = null;\n payload.resuelto_por_email = null;\n payload.resuelto_en = null;\n payload.comentario_resolucion = null;\n}\n\nreturn [\n {\n json: {\n ...cruce,\n reportUpdated,\n preservedResolutionsCount,\n existingHistoricalId: sheetContext.existingHistoricalId || '',\n reportIdentity,\n supabaseTable: 'cruces_cuentas_gt_reportes',\n supabasePayload: payload,\n reportUrl,\n spreadsheetId,\n },\n },\n];\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
4480,
512
],
"id": "d61a3429-2611-4bb7-9047-001b70f9bbfe",
"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": [
4720,
512
],
"id": "cd030296-381a-4c2e-ac83-12387336cb99",
"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": [
4960,
432
],
"id": "acc74d61-7462-4c74-bb5a-8bf5e85700f4",
"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": [
5200,
432
],
"id": "bf03d628-bf89-4be8-a451-09ac9d561ebe",
"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/Puerto_Rico';\n\n const dateLabel = new Intl.DateTimeFormat('es-PR', {\n timeZone,\n day: 'numeric',\n month: 'long',\n year: 'numeric',\n }).format(date);\n\n const timeLabel = new Intl.DateTimeFormat('es-PR', {\n timeZone,\n hour: 'numeric',\n minute: '2-digit',\n hour12: true,\n }).format(date);\n\n const hour24Raw = new Intl.DateTimeFormat('en-US', {\n timeZone,\n hour: '2-digit',\n hour12: false,\n }).format(date);\n\n const hour24 =\n Number(String(hour24Raw).replace(/\\D/g, '')) % 24;\n\n const hourArticle =\n hour24 === 1 || hour24 === 13\n ? 'a la'\n : 'a las';\n\n return `${dateLabel} ${hourArticle} ${timeLabel}`;\n}\n\nfunction metricCard(label, value, accent) {\n return `\n <td width=\"20%\" valign=\"top\" style=\"padding:0 5px;\">\n <table\n role=\"presentation\"\n width=\"100%\"\n cellspacing=\"0\"\n cellpadding=\"0\"\n border=\"0\"\n style=\"background:#FFFFFF;border:1px solid #D0D0D0;border-top:3px solid ${accent};border-radius:8px;\"\n >\n <tr>\n <td style=\"padding:14px 12px 13px;\">\n <div\n style=\"font-family:Arial,sans-serif;font-size:9px;line-height:1.25;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:#4F758B;min-height:25px;\"\n >\n ${escapeHtml(label)}\n </div>\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:27px;line-height:1;font-weight:700;color:#4A4A4A;margin-top:8px;\"\n >\n ${numberValue(value).toLocaleString('en-US')}\n </div>\n </td>\n </tr>\n </table>\n </td>\n `;\n}\n\nconst countryCode = 'PR';\nconst countryName = 'Puerto Rico';\n\nconst periodLabel =\n payload.period_label ||\n metadata.period_label ||\n [\n payload.year || metadata.year,\n payload.month || metadata.month,\n payload.period_type || metadata.period_type,\n ]\n .filter(Boolean)\n .join(' · ') ||\n 'Período no especificado';\n\nconst executedByName =\n payload.ejecutado_por_nombre ||\n metadata.requested_by_name ||\n 'Usuario GLM';\n\nconst executedByEmail =\n payload.ejecutado_por_email ||\n metadata.requested_by_email ||\n '';\n\nconst reportUrl =\n payload.report_url ||\n prepared.reportUrl ||\n '';\n\nconst discrepancias = numberValue(\n payload.discrepancias ??\n summary.discrepancias\n);\n\nconst bancoSinNomina = numberValue(\n payload.banco_sin_nomina ??\n summary.bancoSinNomina\n);\n\nconst bancoSinBamboo = numberValue(\n payload.banco_sin_bamboo ??\n summary.bancoSinBamboo\n);\n\nconst diferenciasNombreBanco = numberValue(\n metadata.diferencias_nombre_banco ??\n summary.diferenciasNombreBanco\n);\n\nconst nominaSinCuenta = numberValue(\n payload.nomina_sin_cuenta ??\n summary.nominaSinCuenta\n);\n\nconst coincidencias = numberValue(\n payload.coincidencias ??\n summary.coincidencias\n);\n\nconst generatedAt = new Date();\nconst generatedAtLabel =\n formatGeneratedAt(generatedAt);\n\nconst statusLabel =\n String(payload.estado || '').toLowerCase() === 'resuelto'\n ? 'Resuelto'\n : 'Pendiente de revisión';\n\nconst logoUrl =\n 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png';\n\nconst flagUrl =\n 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/bandera-nacional-tela-puerto-rico_113767-1759.avif';\n\nconst subject = reportUpdated\n ? `Reporte actualizado - Cruce de Cuentas ${countryName} - ${periodLabel}`\n : `Nuevo reporte generado - Cruce de Cuentas ${countryName} - ${periodLabel}`;\n\n/*\n * Todos son destinatarios directos.\n * Nadie se envía en CC.\n */\n\nconst to = [\n 'iaracena@gomezleemarketing.com'\n].join(',');\n\n\n// Paleta oficial GLM.\nconst metricCards = [\n metricCard(\n 'Discrepancias',\n discrepancias,\n '#FF6A13'\n ),\n metricCard(\n 'Banco sin nómina',\n bancoSinNomina,\n '#FF8C00'\n ),\n metricCard(\n 'Banco sin Bamboo',\n bancoSinBamboo,\n '#E87722'\n ),\n metricCard(\n 'Diferencias nombre banco',\n diferenciasNombreBanco,\n '#5B7F95'\n ),\n metricCard(\n 'Nómina sin cuenta',\n nominaSinCuenta,\n '#4F758B'\n ),\n].join('');\n\nconst htmlBody = `\n<!doctype html>\n<html lang=\"es\">\n\n<head>\n <meta charset=\"UTF-8\">\n <meta\n name=\"viewport\"\n content=\"width=device-width, initial-scale=1.0\"\n >\n <title>${escapeHtml(subject)}</title>\n</head>\n\n<body\n style=\"margin:0;padding:0;background:#F5F5F5;font-family:Arial,sans-serif;color:#4A4A4A;\"\n>\n\n <table\n role=\"presentation\"\n width=\"100%\"\n cellspacing=\"0\"\n cellpadding=\"0\"\n border=\"0\"\n style=\"width:100%;background:#F5F5F5;padding:28px 12px;\"\n >\n <tr>\n <td align=\"center\">\n\n <table\n role=\"presentation\"\n width=\"100%\"\n cellspacing=\"0\"\n cellpadding=\"0\"\n border=\"0\"\n style=\"max-width:760px;background:#FFFFFF;border:1px solid #E5E5E5;border-radius:16px;overflow:hidden;\"\n >\n\n <!-- Acento superior GLM -->\n <tr>\n <td\n style=\"height:6px;line-height:6px;font-size:0;background:#6CC24A;\"\n >\n &nbsp;\n </td>\n </tr>\n\n <!-- Header -->\n <tr>\n <td\n style=\"padding:24px 30px 18px;background:#FFFFFF;\"\n >\n\n <table\n role=\"presentation\"\n width=\"100%\"\n cellspacing=\"0\"\n cellpadding=\"0\"\n border=\"0\"\n >\n <tr>\n <td valign=\"middle\">\n <img\n src=\"${logoUrl}\"\n alt=\"GomezLee Marketing\"\n style=\"display:block;width:188px;max-width:188px;height:auto;border:0;\"\n >\n </td>\n\n <td\n align=\"right\"\n valign=\"middle\"\n >\n <span\n style=\"display:inline-block;border:1px solid #D0D0D0;color:#4F758B;border-radius:999px;padding:7px 12px;font-family:Arial,sans-serif;font-size:10px;font-weight:700;letter-spacing:.45px;\"\n >\n ${reportUpdated ? 'REPORTE ACTUALIZADO' : 'REPORTE GENERADO'}\n </span>\n </td>\n </tr>\n </table>\n\n <div\n style=\"height:1px;background:#E5E5E5;margin:20px 0 18px;\"\n ></div>\n\n <table\n role=\"presentation\"\n cellspacing=\"0\"\n cellpadding=\"0\"\n border=\"0\"\n >\n <tr>\n\n <td\n valign=\"middle\"\n style=\"padding-right:12px;\"\n >\n <img\n src=\"${flagUrl}\"\n alt=\"Bandera de ${escapeHtml(countryName)}\"\n width=\"42\"\n style=\"display:block;width:42px;height:auto;border:1px solid #D0D0D0;\"\n >\n </td>\n\n <td valign=\"middle\">\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:25px;line-height:1.2;font-weight:700;color:#4F758B;\"\n >\n Cruce de Cuentas · ${escapeHtml(countryName)}\n </div>\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:13px;line-height:1.5;color:#6B8FA3;margin-top:5px;\"\n >\n ${escapeHtml(periodLabel)}\n </div>\n\n </td>\n\n </tr>\n </table>\n\n </td>\n </tr>\n\n <!-- Introducción -->\n <tr>\n <td style=\"padding:6px 30px 18px;\">\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:14px;line-height:1.7;color:#4A4A4A;\"\n >\n ${reportUpdated ? 'Se actualizó el reporte existente del Portal de Cruce de Cuentas GLM conservando las resoluciones previamente registradas que continúan correspondiendo a los mismos casos.' : 'Se completó una nueva ejecución del Portal de Cruce de Cuentas GLM. Este es el resumen del reporte generado.'}\n </div>\n\n </td>\n </tr>\n\n <!-- KPIs -->\n <tr>\n <td style=\"padding:6px 25px 6px;\">\n\n <table\n role=\"presentation\"\n width=\"100%\"\n cellspacing=\"0\"\n cellpadding=\"0\"\n border=\"0\"\n >\n <tr>\n ${metricCards}\n </tr>\n </table>\n\n </td>\n </tr>\n\n <!-- Información del reporte -->\n <tr>\n <td style=\"padding:22px 30px 0;\">\n\n <table\n role=\"presentation\"\n width=\"100%\"\n cellspacing=\"0\"\n cellpadding=\"0\"\n border=\"0\"\n style=\"background:#FFFFFF;border:1px solid #D0D0D0;border-radius:10px;overflow:hidden;\"\n >\n\n <tr>\n\n <td\n width=\"50%\"\n valign=\"top\"\n style=\"padding:18px 20px;border-bottom:1px solid #E5E5E5;\"\n >\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:10px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:#4F758B;\"\n >\n Ejecutado por\n </div>\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:15px;font-weight:700;color:#4A4A4A;margin-top:7px;\"\n >\n ${escapeHtml(executedByName)}\n </div>\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:12px;color:#6B8FA3;margin-top:4px;\"\n >\n ${escapeHtml(\n executedByEmail ||\n 'Correo no disponible'\n )}\n </div>\n\n </td>\n\n <td\n width=\"50%\"\n valign=\"top\"\n style=\"padding:18px 20px;border-bottom:1px solid #E5E5E5;border-left:1px solid #E5E5E5;\"\n >\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:10px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:#4F758B;\"\n >\n País\n </div>\n\n <table\n role=\"presentation\"\n cellspacing=\"0\"\n cellpadding=\"0\"\n border=\"0\"\n style=\"margin-top:7px;\"\n >\n <tr>\n\n <td\n valign=\"middle\"\n style=\"padding-right:9px;\"\n >\n <img\n src=\"${flagUrl}\"\n alt=\"Bandera de ${escapeHtml(countryName)}\"\n width=\"30\"\n style=\"display:block;width:30px;height:auto;border:1px solid #D0D0D0;\"\n >\n </td>\n\n <td valign=\"middle\">\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:15px;font-weight:700;color:#4A4A4A;\"\n >\n ${escapeHtml(countryName)}\n </div>\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:12px;color:#6B8FA3;margin-top:2px;\"\n >\n ${escapeHtml(\n countryCode || '—'\n )}\n </div>\n\n </td>\n\n </tr>\n </table>\n\n </td>\n\n </tr>\n\n <tr>\n\n <td\n width=\"50%\"\n valign=\"top\"\n style=\"padding:18px 20px;\"\n >\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:10px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:#4F758B;\"\n >\n Período\n </div>\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:15px;font-weight:700;color:#4A4A4A;margin-top:7px;\"\n >\n ${escapeHtml(periodLabel)}\n </div>\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:12px;color:#6B8FA3;margin-top:4px;\"\n >\n ${escapeHtml(generatedAtLabel)}\n </div>\n\n </td>\n\n <td\n width=\"50%\"\n valign=\"top\"\n style=\"padding:18px 20px;border-left:1px solid #E5E5E5;\"\n >\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:10px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:#4F758B;\"\n >\n Estado inicial\n </div>\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:15px;font-weight:700;color:#4A4A4A;margin-top:7px;\"\n >\n ${escapeHtml(statusLabel)}\n </div>\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:12px;color:#6B8FA3;margin-top:4px;\"\n >\n Coincidencias:\n ${coincidencias.toLocaleString('en-US')}\n </div>\n\n </td>\n\n </tr>\n\n </table>\n\n </td>\n </tr>\n\n <!-- CTA -->\n ${\n reportUrl\n ? `\n <tr>\n <td\n align=\"center\"\n style=\"padding:28px 30px 10px;\"\n >\n\n <a\n href=\"${escapeHtml(reportUrl)}\"\n target=\"_blank\"\n style=\"display:inline-block;background:#6CC24A;color:#FFFFFF;text-decoration:none;font-family:Arial,sans-serif;font-size:14px;font-weight:700;padding:13px 24px;border-radius:8px;\"\n >\n Abrir reporte en Google Sheets\n </a>\n\n </td>\n </tr>\n `\n : ''\n }\n\n <!-- Footer -->\n <tr>\n <td style=\"padding:20px 30px 27px;\">\n\n <div\n style=\"height:1px;background:#D0D0D0;margin-bottom:16px;\"\n ></div>\n\n <div\n style=\"font-family:Arial,sans-serif;font-size:11px;line-height:1.6;color:#6B8FA3;text-align:center;\"\n >\n Notificación automática · Portal Cruce de Cuentas GLM\n <br>\n Los casos de revisión deben gestionarse directamente en el reporte generado.\n </div>\n\n </td>\n </tr>\n\n </table>\n\n </td>\n </tr>\n </table>\n\n</body>\n</html>\n`;\n\nreturn [\n {\n json: {\n to,\n subject,\n htmlBody,\n\n notification: {\n country: countryCode,\n countryName,\n flagUrl,\n periodLabel,\n executedByName,\n executedByEmail,\n generatedAt: generatedAt.toISOString(),\n reportUrl,\n\n metrics: {\n discrepancias,\n bancoSinNomina,\n bancoSinBamboo,\n diferenciasNombreBanco,\n nominaSinCuenta,\n coincidencias,\n },\n },\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
4960,
672
],
"id": "94f01fb4-e536-4b86-aebc-8ab27b61381f",
"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": [
5200,
672
],
"id": "5bd3e192-88b5-400e-acd5-57511f78c440",
"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 — PUERTO RICO\n\nFlujo principal para una nómina quincenal de Puerto Rico + todos los planos bancarios BAC del período + BambooHR.\n\n- Monedas: PRL y USD.\n- BambooHR: `onlyCurrent=false`, filtrado por país/localidad Puerto Rico.\n- Q1: días 1–15. Q2: días 16–último día real del mes.\n- Los meses/años se calculan dinámicamente, incluidos años bisiestos.\n- La nómina usa las hojas operativas validadas de GLM Puerto Rico y omite hojas auxiliares/copias para evitar duplicados.\n\n\n### Correcciones de conciliación PR · 08-09-2026\n- Tolerancia monetaria operativa: ±0.05 por redondeos bancarios.\n- Banco sin Nómina se mantiene por transacción, sin agrupar pagos distintos.\n- Nombre en archivo truncado por un carácter al final no genera falso positivo.\n- BambooHR prioriza el nombre legal del cuentahabiente y perfiles vigentes en el período.\n- La cuenta mal digitada se determina por la cuenta real de la nómina vs. la cuenta real del banco.\n",
"height": 260,
"width": 520
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-1088,
80
],
"id": "43fccfea-0ca0-4500-acbd-259f5bec978e",
"name": "📌 PUERTO RICO · V1"
},
{
"parameters": {
"content": "# 🏦 REGLA DE PLANOS — PUERTO RICO\n\nSe procesan todos los PDF BPPR `Consulta Detalle de Envío_HON_...` cargados. Se reconocen planes AR94 (PRL) y AR95US (USD), además de la moneda informada dentro del archivo.\n\nEl período se filtra por la fecha de aplicación de cada transacción: Q1 = 1–15 y Q2 = 16–fin de mes. El cruce se realiza por persona, cuenta, moneda y monto, con una segunda pasada controlada para detectar diferencias o posibles cuentas mal digitadas.",
"height": 300,
"width": 700
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
0,
0
],
"id": "aaa2fb59-9745-48b6-9b8b-3d5442493347",
"name": "📌 REGLA BANCO PR"
},
{
"parameters": {
"content": "# 📄 ARCHIVO DE NÓMINA — PUERTO RICO\n\nLa app recibe un solo Excel por quincena, por ejemplo `Nomina 7.1 2026 GLM PR.xlsx` o `Nomina 7.2 2026 GLM PR.xlsx`.\n\nEl flujo extrae únicamente las hojas de pago validadas (Colgate, POP Helper, Motorola, Nestlé, Whirlpool, FF Regional y GLM) y normaliza `NOMBRE`, `Identidad`, `# de Cuenta Bac` y `NETO A PAGAR` / `Total a Pagar`. Las hojas de copia/auxiliares no se contabilizan.",
"height": 240,
"width": 620
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-608,
928
],
"id": "c2f319b9-18d2-4b48-90b0-adb64308b513",
"name": "📌 ARCHIVOS NÓMINA PR"
},
{
"parameters": {
"content": "# 📊 SALIDA\n\nConserva la misma estructura de reportes de GT/TT/PA/SV, reutiliza el Google Sheet del mismo período si existe, actualiza Supabase y envía correo tanto en creación como en actualización.\n\nLos valores se escriben en Google Sheets con `RAW` para preservar las cuentas bancarias como texto exacto, incluyendo ceros iniciales y evitando notación científica.",
"height": 250,
"width": 720
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
3392,
832
],
"id": "2fc5ac1d-e386-4997-8e24-7dd5205bc966",
"name": "📌 REPORTES"
},
{
"parameters": {
"operation": "pdf",
"options": {
"joinPages": true,
"keepSource": "json"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
-128,
272
],
"id": "3ae0b89c-da34-4fdc-aef8-46a4c8a3ff8a",
"name": "Extract - PDF Banco PR"
},
{
"parameters": {
"jsCode": "const items = $input.all();\nconst base = $('Preparar entrada app').first().json || {};\n\nfunction clean(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\u00A0/g, ' ')\n .replace(/\\r/g, '')\n .replace(/[ \\t]+/g, ' ')\n .trim();\n}\nfunction normalizeAccount(value) {\n return String(value ?? '').replace(/\\D/g, '').trim();\n}\nfunction roundMoney(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n}\nfunction parseMoney(value) {\n const parsed = Number.parseFloat(String(value ?? '').replace(/[$,\\s]/g, ''));\n return Number.isFinite(parsed) ? parsed : 0;\n}\nfunction toIsoDate(raw) {\n const value = clean(raw);\n let match = value.match(/^(\\d{4})-(\\d{1,2})-(\\d{1,2})$/);\n if (match) return `${match[1]}-${String(match[2]).padStart(2, '0')}-${String(match[3]).padStart(2, '0')}`;\n match = value.match(/^(\\d{1,2})\\/(\\d{1,2})\\/(\\d{2}|\\d{4})$/);\n if (!match) return '';\n const year = match[3].length === 2 ? 2000 + Number(match[3]) : Number(match[3]);\n return `${year}-${String(match[1]).padStart(2, '0')}-${String(match[2]).padStart(2, '0')}`;\n}\nfunction cleanNameFragment(value) {\n let text = clean(value).replace(/\\f/g, ' ');\n if (!text) return '';\n const normalized = text.toLowerCase();\n const noise = [\n 'transaction details:', 'transaction details', 'name', 'id', 'number',\n 'amount d/c bank id account#', 'acct', 'type', 'trace # addenda',\n 'batch status', 'extracted entry class: directdeposit', 'entry class: directdeposit',\n 'company', 'company name:', 'name:', 'effective', 'date:', 'template',\n 'release date', 'and time:', 'extract file', 'total # credits:',\n 'total # debits:', 'total $ offset credit:', 'subtotal # credits:',\n 'grand totals:', 'grand total # credits:'\n ];\n if (noise.some((entry) => normalized === entry || normalized.startsWith(entry))) return '';\n if (/^[\\d\\-\\s]+$/.test(text)) return '';\n text = text.replace(/\\s*\\d[\\d-]{2,}\\s*$/, '').trim();\n if (!text || /^[\\d\\-\\s]+$/.test(text)) return '';\n return text;\n}\nfunction sourceMeta(index) {\n try { return $('Preparar PDF banco PR').itemMatching(index).json || {}; }\n catch { return {}; }\n}\nfunction templateLabel(templateId) {\n const id = clean(templateId).toUpperCase();\n if (id === 'MOTOP') return 'Motorola Payroll';\n if (id === 'APPLE PAYR') return 'APPLE PAYR';\n if (id === 'PAYROLLV2') return 'PayrollV2';\n return clean(templateId) || 'BPPR';\n}\nfunction expectedTotals(text) {\n const grandCount = text.match(/Grand Total # Credits:\\s*(\\d+)/i);\n const grandAmount = text.match(/Grand Total Credit Amt:\\s*\\$([\\d,]+\\.\\d{2})/i);\n if (grandCount && grandAmount) {\n return { count: Number(grandCount[1]), amount: roundMoney(parseMoney(grandAmount[1])), source: 'grand_total' };\n }\n let count = 0;\n let amount = 0;\n const regex = /(?:^|\\n)Total # Credits:\\s*(\\d+)[^\\n]*Total Credit Amt:\\s*\\$([\\d,]+\\.\\d{2})/gim;\n for (const match of text.matchAll(regex)) {\n count += Number(match[1] || 0);\n amount = roundMoney(amount + parseMoney(match[2]));\n }\n return count > 0 ? { count, amount, source: 'batch_totals' } : null;\n}\nfunction effectiveDateFromText(text) {\n const subtotal = text.match(/Subtotals? for Effective Date:\\s*(\\d{4}-\\d{2}-\\d{2})/i);\n if (subtotal) return toIsoDate(subtotal[1]);\n const raw = text.match(/Effective\\s*(?:\\n|\\s)*Date:\\s*(?:\\n|\\s)*(\\d{1,2}\\/\\d{1,2}\\/\\d{2,4})/i);\n if (raw) return toIsoDate(raw[1]);\n return '';\n}\nfunction inSelectedPeriod(dateValue) {\n const start = clean(base.metadata?.period_start);\n const end = clean(base.metadata?.period_end);\n if (!dateValue || !start || !end) return true;\n return dateValue >= start && dateValue <= end;\n}\n\nconst allRows = [];\nconst fileSummaries = [];\nconst seenTransactions = new Set();\n\nfor (let itemIndex = 0; itemIndex < items.length; itemIndex++) {\n const item = items[itemIndex] || {};\n const linked = sourceMeta(itemIndex);\n const sourceFile = clean(linked.source_file || item.json?.source_file || `Banco PR ${itemIndex + 1}.pdf`);\n const text = String(item.json?.text ?? item.json?.data ?? item.json?.content ?? '');\n if (!text.trim()) {\n throw new Error(`Puerto Rico: no se pudo extraer texto del PDF bancario ${sourceFile}.`);\n }\n\n const effectiveDate = effectiveDateFromText(text);\n if (!effectiveDate) {\n throw new Error(`Puerto Rico: no se encontró la Effective Date en ${sourceFile}.`);\n }\n if (!inSelectedPeriod(effectiveDate)) {\n throw new Error(`Puerto Rico: ${sourceFile} tiene Effective Date ${effectiveDate}, fuera del período seleccionado ${base.metadata?.period_start || ''} a ${base.metadata?.period_end || ''}.`);\n }\n\n const expected = expectedTotals(text);\n const lines = text.split(/\\n+/).map((line) => clean(line)).filter(Boolean);\n let currentTemplate = '';\n let pendingLines = [];\n const rowsFromFile = [];\n\n for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {\n const line = lines[lineIndex];\n const templateMatch = line.match(/^Template ID:\\s*(.+)$/i);\n if (templateMatch) {\n currentTemplate = clean(templateMatch[1]);\n pendingLines = [];\n continue;\n }\n\n if (/^(?:Total|Subtotal|Grand Total)/i.test(line)) {\n pendingLines = [];\n continue;\n }\n\n const tx = line.match(/^(.*?)\\$([\\d,]+\\.\\d{2})\\s+([CD])\\s+(\\d{6,12})\\s+([0-9-]{5,25})\\s+(Checking|Savings|CHK)\\s+(\\d{6,30})(?:\\s+(.*))?$/i);\n if (!tx) {\n const fragment = cleanNameFragment(line);\n if (fragment) pendingLines.push(fragment);\n if (pendingLines.length > 8) pendingLines = pendingLines.slice(-8);\n continue;\n }\n\n let prefix = clean(tx[1]);\n let idNumber = '';\n const idAtEnd = prefix.match(/(\\d[\\d-]{5,})\\s*$/);\n if (idAtEnd) {\n idNumber = normalizeAccount(idAtEnd[1]);\n prefix = clean(prefix.slice(0, idAtEnd.index));\n }\n\n const debitCredit = String(tx[3]).toUpperCase();\n if (debitCredit !== 'C') {\n pendingLines = [];\n continue;\n }\n\n const prefixName = cleanNameFragment(prefix);\n const nameParts = [...pendingLines, prefixName].filter(Boolean);\n const name = clean(nameParts.join(' '));\n const amount = roundMoney(parseMoney(tx[2]));\n const bankId = normalizeAccount(tx[4]);\n const account = normalizeAccount(tx[5]);\n const accountType = clean(tx[6]);\n const trace = normalizeAccount(tx[7]);\n const addenda = clean(tx[8]);\n const templateId = currentTemplate || 'BPPR';\n const duplicateKey = `${effectiveDate}|${trace}|${account}|${amount.toFixed(2)}`;\n\n if (seenTransactions.has(duplicateKey)) {\n throw new Error(`Puerto Rico: se detectó una transacción bancaria duplicada (${trace}) al consolidar los PDF cargados.`);\n }\n seenTransactions.add(duplicateKey);\n\n const row = {\n source_file: sourceFile,\n row_number: lineIndex + 1,\n bank_row_key: `PR|${sourceFile}|${templateId}|${trace}|${lineIndex + 1}`,\n account,\n raw_account: account,\n account_is_valid: account.length >= 5,\n reference: '',\n participant_id: '',\n bank_id_number: idNumber,\n bank_id: bankId,\n shipment_number: trace,\n plan_number: templateId,\n plan_label: templateLabel(templateId),\n template_id: templateId,\n bank_name_file: name || `Pago BPPR ${trace}`,\n bank_account_holder: '',\n amount,\n currency: 'USD',\n application_date: effectiveDate,\n concept: '',\n addenda,\n account_type: accountType,\n status: 'C',\n };\n rowsFromFile.push(row);\n allRows.push(row);\n pendingLines = [];\n }\n\n const parsedTotal = roundMoney(rowsFromFile.reduce((sum, row) => sum + row.amount, 0));\n if (expected) {\n if (rowsFromFile.length !== expected.count) {\n throw new Error(`Puerto Rico: ${sourceFile} reporta ${expected.count} créditos, pero se pudieron leer ${rowsFromFile.length}. No se generará un reporte incompleto.`);\n }\n if (Math.abs(parsedTotal - expected.amount) > 0.01) {\n throw new Error(`Puerto Rico: el total leído de ${sourceFile} (USD ${parsedTotal.toFixed(2)}) no coincide con el total del PDF (USD ${expected.amount.toFixed(2)}).`);\n }\n }\n\n const batchSummaryMap = new Map();\n for (const row of rowsFromFile) {\n const key = row.template_id;\n const current = batchSummaryMap.get(key) || { template_id: key, template_name: row.plan_label, rows_count: 0, total_amount: 0 };\n current.rows_count += 1;\n current.total_amount = roundMoney(current.total_amount + row.amount);\n batchSummaryMap.set(key, current);\n }\n\n fileSummaries.push({\n file_name: sourceFile,\n ok: true,\n effective_date: effectiveDate,\n rows_count: rowsFromFile.length,\n total_amount: parsedTotal,\n currency: 'USD',\n expected_count: expected?.count ?? null,\n expected_total: expected?.amount ?? null,\n expected_source: expected?.source ?? null,\n batches: Array.from(batchSummaryMap.values()),\n });\n}\n\nif (!allRows.length) {\n throw new Error('Puerto Rico: no se encontró ninguna transacción bancaria válida en los PDF cargados.');\n}\n\nconst totalAmount = roundMoney(allRows.reduce((sum, row) => sum + row.amount, 0));\nreturn [{\n json: {\n ...base,\n stage: 'banco_pr_normalizado',\n bank: {\n source: 'bppr_batch_summary_pdf',\n files_count: fileSummaries.length,\n file_summaries: fileSummaries,\n rows_count: allRows.length,\n valid_rows_count: allRows.length,\n total_amount: totalAmount,\n totals_by_currency: { USD: totalAmount },\n rows: allRows,\n },\n debug_bank: {\n file_summaries: fileSummaries,\n parsed_rows_preview: allRows.slice(0, 100),\n },\n },\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
112,
272
],
"id": "15cc9f68-6c58-4ca2-b8bc-67198a69c6f2",
"name": "Parsear PDF banco PR"
},
{
"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": [
-608,
512
],
"id": "8b489512-819e-4f62-a6b9-309cdbb42732",
"name": "¿Entrada PR válida?"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { ok: false, stage: $json.stage || 'entrada_pr_invalida', message: 'No fue posible iniciar el cruce de Puerto Rico.', 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": [
-368,
1040
],
"id": "d9247c61-bd3a-4fd8-a706-e15518665037",
"name": "Responder error entrada PR"
}
],
"pinData": {},
"connections": {
"Webhook": {
"main": [
[
{
"node": "Preparar entrada app",
"type": "main",
"index": 0
}
]
]
},
"Preparar entrada app": {
"main": [
[
{
"node": "¿Entrada PR válida?",
"type": "main",
"index": 0
}
]
]
},
"¿Entrada PR válida?": {
"main": [
[
{
"node": "Preparar PDF banco PR",
"type": "main",
"index": 0
},
{
"node": "Preparar archivos nómina PR",
"type": "main",
"index": 0
}
],
[
{
"node": "Responder error entrada PR",
"type": "main",
"index": 0
}
]
]
},
"Preparar PDF banco PR": {
"main": [
[
{
"node": "Extract - PDF Banco PR",
"type": "main",
"index": 0
}
]
]
},
"Extract - PDF Banco PR": {
"main": [
[
{
"node": "Parsear PDF banco PR",
"type": "main",
"index": 0
}
]
]
},
"Parsear PDF banco PR": {
"main": [
[
{
"node": "Merge Banco + Nómina PR",
"type": "main",
"index": 0
}
]
]
},
"Preparar archivos nómina PR": {
"main": [
[
{
"node": "Extract - Archivo Nómina PR",
"type": "main",
"index": 0
}
]
]
},
"Extract - Archivo Nómina PR": {
"main": [
[
{
"node": "Normalizar Nómina PR",
"type": "main",
"index": 0
}
]
]
},
"Normalizar Nómina PR": {
"main": [
[
{
"node": "Merge Banco + Nómina PR",
"type": "main",
"index": 1
}
]
]
},
"Merge Banco + Nómina PR": {
"main": [
[
{
"node": "HTTP - Empleados BambooHR PR",
"type": "main",
"index": 0
},
{
"node": "Merge - Agregar BambooHR PR",
"type": "main",
"index": 0
}
]
]
},
"HTTP - Empleados BambooHR PR": {
"main": [
[
{
"node": "Normalizar BambooHR PR",
"type": "main",
"index": 0
}
]
]
},
"Normalizar BambooHR PR": {
"main": [
[
{
"node": "Merge - Agregar BambooHR PR",
"type": "main",
"index": 1
}
]
]
},
"Merge - Agregar BambooHR PR": {
"main": [
[
{
"node": "Obtener alias BambooHR Supabase",
"type": "main",
"index": 0
}
]
]
},
"Obtener alias BambooHR Supabase": {
"main": [
[
{
"node": "Cruzar Nómina vs Banco",
"type": "main",
"index": 0
}
]
]
},
"Cruzar Nómina vs Banco": {
"main": [
[
{
"node": "Preparar Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Preparar Google Sheet": {
"main": [
[
{
"node": "Buscar reporte existente Supabase",
"type": "main",
"index": 0
}
]
]
},
"Buscar reporte existente Supabase": {
"main": [
[
{
"node": "Crear Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Crear Google Sheet": {
"main": [
[
{
"node": "Leer Google Sheet actual",
"type": "main",
"index": 0
}
]
]
},
"Leer Google Sheet actual": {
"main": [
[
{
"node": "Preparar actualización Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Preparar actualización Google Sheet": {
"main": [
[
{
"node": "Actualizar estructura Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Actualizar estructura Google Sheet": {
"main": [
[
{
"node": "Limpiar Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Limpiar Google Sheet": {
"main": [
[
{
"node": "Escribir Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Escribir Google Sheet": {
"main": [
[
{
"node": "Formatear Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Formatear Google Sheet": {
"main": [
[
{
"node": "Preparar permisos Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Preparar permisos Google Sheet": {
"main": [
[
{
"node": "Compartir Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Compartir Google Sheet": {
"main": [
[
{
"node": "Preparar histórico Supabase",
"type": "main",
"index": 0
}
]
]
},
"Preparar histórico Supabase": {
"main": [
[
{
"node": "Insertar histórico Supabase",
"type": "main",
"index": 0
}
]
]
},
"Insertar histórico Supabase": {
"main": [
[
{
"node": "Preparar respuesta final",
"type": "main",
"index": 0
},
{
"node": "Preparar correo reporte generado",
"type": "main",
"index": 0
}
]
]
},
"Preparar respuesta final": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
},
"Preparar correo reporte generado": {
"main": [
[
{
"node": "Enviar correo reporte generado",
"type": "main",
"index": 0
}
]
]
}
},
"active": true,
"settings": {
"executionOrder": "v1",
"binaryMode": "separate"
},
"versionId": "9b578dec-b1d2-410a-9920-4d888faf128f",
"meta": {
"instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70"
},
"id": "1ddUHqgdN3lZQx6T",
"tags": []
}