{
"updatedAt": "2026-08-14T12:11:58.924Z",
"createdAt": "2026-08-12T12:10:09.908Z",
"id": "ox03EQlSmgjUCQuK",
"name": "Portal de Verificación de Nómina - Bono 14 Guatemala",
"description": null,
"active": true,
"isArchived": false,
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "bono14gt-bamboo-test",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
464,
2736
],
"id": "e3fa9074-3d8c-4986-a921-750c71cc5eee",
"name": "Webhook",
"webhookId": "83f02b5a-2322-42a3-9ca9-263622bd7a34"
},
{
"parameters": {
"jsCode": "const item = $input.first();\nconst body = item.json.body || {};\nconst binary = item.binary || {};\n\nlet metadata = {};\n\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);\nconst payrollKey = binaryKeys.find((key) => key === 'payroll_file');\nconst bankKeys = binaryKeys.filter((key) => key.startsWith('bank_files'));\n\nconst payrollFile = payrollKey\n ? {\n binary_key: payrollKey,\n file_name: binary[payrollKey].fileName,\n file_extension: binary[payrollKey].fileExtension,\n mime_type: binary[payrollKey].mimeType,\n file_size: binary[payrollKey].fileSize,\n }\n : null;\n\nconst bankFiles = bankKeys.map((key) => ({\n binary_key: key,\n file_name: binary[key].fileName,\n file_extension: binary[key].fileExtension,\n mime_type: binary[key].mimeType,\n file_size: binary[key].fileSize,\n}));\n\nconst year = Number(metadata.year || 0);\nconst previousYear = year > 0 ? year - 1 : 0;\nconst periodStart = previousYear ? `${previousYear}-07-01` : '';\nconst periodEnd = year ? `${year}-06-30` : '';\nconst errors = [];\n\nif (String(metadata.country || '').trim().toUpperCase() !== 'GT') {\n errors.push('El país recibido no es Guatemala.');\n}\n\nif (!Number.isInteger(year) || year < 2020 || year > 2100) {\n errors.push('No se recibió un año válido para Bono 14.');\n}\n\nif (Number(metadata.month || 7) !== 7) {\n errors.push('El Bono 14 debe registrarse en julio.');\n}\n\nif (String(metadata.period_type || '').trim().toLowerCase() !== 'bono14') {\n errors.push('El tipo de período recibido no corresponde a Bono 14.');\n}\n\nif (!payrollFile) {\n errors.push('No se recibió el archivo Excel de Bono 14.');\n}\n\nif (bankFiles.length === 0) {\n errors.push('No se recibió ningún archivo CSV bancario del Bono 14.');\n}\n\nconst normalizedMetadata = {\n ...metadata,\n country: 'GT',\n country_name: 'Guatemala',\n year,\n month: 7,\n period_type: 'bono14',\n report_kind: 'bono14',\n period_start: periodStart,\n period_end: periodEnd,\n period_label:\n metadata.period_label ||\n `Bono 14 ${year} · Julio ${previousYear} a junio ${year}`,\n source_app:\n metadata.source_app ||\n 'portal-cruce-cuentas-glm-bono14',\n payroll_file_name:\n metadata.payroll_file_name ||\n payrollFile?.file_name ||\n '',\n bank_file_names:\n metadata.bank_file_names ||\n bankFiles.map((file) => file.file_name),\n};\n\nreturn [\n {\n json: {\n ok: errors.length === 0,\n stage: 'entrada_bono14_recibida',\n errors,\n metadata: normalizedMetadata,\n payroll_file: payrollFile,\n bank_files: bankFiles,\n summary: {\n payroll_files_count: payrollFile ? 1 : 0,\n bank_files_count: bankFiles.length,\n },\n },\n binary,\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
672,
2736
],
"id": "57bcbea7-e1f7-44be-a754-6758d0ff48b9",
"name": "Preparar entrada app"
},
{
"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": [
11888,
3120
],
"id": "fb71b850-d364-4c3e-aa2d-f29684c272fd",
"name": "Respond to Webhook"
},
{
"parameters": {
"jsCode": "const input = $input.first();\nconst json = input.json || {};\nconst binary = input.binary || {};\n\nfunction parseCsvLine(line) {\n const result = [];\n let current = '';\n let insideQuotes = false;\n\n for (let i = 0; i < line.length; i++) {\n const char = line[i];\n const nextChar = line[i + 1];\n\n if (char === '\"' && insideQuotes && nextChar === '\"') {\n current += '\"';\n i += 1;\n continue;\n }\n\n if (char === '\"') {\n insideQuotes = !insideQuotes;\n continue;\n }\n\n if (char === ',' && !insideQuotes) {\n result.push(current.trim());\n current = '';\n continue;\n }\n\n current += char;\n }\n\n result.push(current.trim());\n return result;\n}\n\nfunction normalizeText(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeForCompare(value) {\n return normalizeText(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 isValidAccount(value) {\n const account = normalizeAccount(value);\n return account.length >= 7 && !/^0+$/.test(account);\n}\n\nfunction parseMoney(value) {\n const raw = String(value ?? '');\n const cleaned = raw\n .replace(/USD/gi, '')\n .replace(/GTQ/gi, '')\n .replace(/QTZ/gi, '')\n .replace(/Q/gi, '')\n .replace(/,/g, '')\n .replace(/\\s+/g, '')\n .trim();\n\n const parsed = Number.parseFloat(cleaned);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction detectCurrency(value, concept) {\n const combined = `${value ?? ''} ${concept ?? ''}`.toUpperCase();\n return combined.includes('USD') ? 'USD' : 'QTZ';\n}\n\nfunction roundMoney(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n}\n\nfunction getColumnIndex(headers, expectedNames) {\n const normalizedHeaders = headers.map((header) => normalizeForCompare(header));\n\n for (const expected of expectedNames) {\n const normalizedExpected = normalizeForCompare(expected);\n const exact = normalizedHeaders.findIndex((header) => header === normalizedExpected);\n if (exact >= 0) return exact;\n }\n\n for (const expected of expectedNames) {\n const normalizedExpected = normalizeForCompare(expected);\n const partial = normalizedHeaders.findIndex((header) => header.includes(normalizedExpected));\n if (partial >= 0) return partial;\n }\n\n return -1;\n}\n\nfunction extractShipmentNumber(lines) {\n for (const line of lines.slice(0, 30)) {\n const normalized = normalizeForCompare(line);\n const match = normalized.match(/(?:detalle del envio|numero de envio)[^0-9]{0,30}(\\d{1,20})/);\n if (match?.[1]) return match[1];\n }\n\n return '';\n}\n\nfunction extractPlanNumber(lines) {\n for (const line of lines.slice(0, 30)) {\n const normalized = normalizeForCompare(line);\n const match = normalized.match(/(?:numero de plan|plan)[^a-z0-9]{0,20}([a-z0-9-]{2,30})/);\n if (match?.[1]) return match[1].toUpperCase();\n }\n\n return '';\n}\n\nfunction getNameWords(value) {\n const ignored = new Set(['de', 'del', 'la', 'las', 'los', 'y', 'e', 'el']);\n return normalizeForCompare(value)\n .split(' ')\n .filter((word) => word.length > 1 && !ignored.has(word));\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\n const previous = Array.from({ length: b.length + 1 }, (_, index) => index);\n\n for (let i = 1; i <= a.length; i++) {\n const current = [i];\n\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(\n current[j - 1] + 1,\n previous[j] + 1,\n previous[j - 1] + cost\n );\n }\n\n for (let j = 0; j < current.length; j++) previous[j] = current[j];\n }\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 = normalizeForCompare(a);\n const normalizedB = normalizeForCompare(b);\n\n if (!normalizedA || !normalizedB) return false;\n if (normalizedA === normalizedB) return true;\n\n const wordsA = getNameWords(a);\n const wordsB = getNameWords(b);\n if (!wordsA.length || !wordsB.length) return false;\n\n const usedB = new Set();\n let matches = 0;\n\n for (const wordA of wordsA) {\n const matchIndex = wordsB.findIndex((wordB, index) => {\n return !usedB.has(index) && tokenMatches(wordA, wordB);\n });\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\n if (smallerLength <= 2) return matches === smallerLength && matches >= 2;\n return matches >= 2 && ratio >= 0.6;\n}\n\nconst bankKeys = Object.keys(binary).filter((key) => key.startsWith('bank_files'));\nconst allBankRows = [];\nconst fileSummaries = [];\nconst nameDifferences = [];\n\nfor (const key of bankKeys) {\n const file = binary[key];\n const buffer = await this.helpers.getBinaryDataBuffer(0, key);\n const text = buffer.toString('latin1');\n\n const lines = text\n .split(/\\r?\\n/)\n .map((line) => line.trim())\n .filter((line) => line.length > 0);\n\n const shipmentNumber = extractShipmentNumber(lines);\n const planNumber = extractPlanNumber(lines);\n\n const markerIndex = lines.findIndex((line) =>\n normalizeForCompare(line).includes('transacciones del envio')\n );\n\n if (markerIndex === -1) {\n fileSummaries.push({\n file_name: file.fileName,\n shipment_number: shipmentNumber,\n plan_number: planNumber,\n ok: false,\n rows_count: 0,\n total_amount: 0,\n error: 'No se encontró el bloque \"Transacciones del envío\".',\n });\n continue;\n }\n\n const headerIndex = lines.findIndex((line, index) => {\n if (index <= markerIndex) return false;\n const normalized = normalizeForCompare(line);\n return normalized.includes('cuenta destino') && normalized.includes('monto');\n });\n\n if (headerIndex === -1) {\n fileSummaries.push({\n file_name: file.fileName,\n shipment_number: shipmentNumber,\n plan_number: planNumber,\n ok: false,\n rows_count: 0,\n total_amount: 0,\n error: 'No se encontró el encabezado de transacciones.',\n });\n continue;\n }\n\n const headers = parseCsvLine(lines[headerIndex]).map(normalizeText);\n\n const idxCuentaDestino = getColumnIndex(headers, ['Cuenta Destino']);\n const idxNombreArchivo = getColumnIndex(headers, ['Nombre en Archivo']);\n const idxNombreCuentahabiente = getColumnIndex(headers, ['Nombre del Cuentahabiente']);\n const idxMonto = getColumnIndex(headers, ['Monto']);\n const idxConcepto = getColumnIndex(headers, ['Concepto']);\n const idxEstado = getColumnIndex(headers, ['Estado']);\n const idxReferencia = getColumnIndex(headers, ['Referencia']);\n const idxNumeroEnvio = getColumnIndex(headers, ['Número de envío', 'Numero de envio']);\n const idxNumeroPlan = getColumnIndex(headers, ['Número de plan', 'Numero de plan']);\n\n const rowsFromFile = [];\n\n for (let i = headerIndex + 1; i < lines.length; i++) {\n const values = parseCsvLine(lines[i]);\n\n const rawAccount = idxCuentaDestino >= 0 ? values[idxCuentaDestino] : '';\n const reference = normalizeText(idxReferencia >= 0 ? values[idxReferencia] : '');\n const referenceDigits = normalizeAccount(reference);\n const account = normalizeAccount(rawAccount);\n const amountRaw = idxMonto >= 0 ? values[idxMonto] : '';\n const amount = roundMoney(parseMoney(amountRaw));\n const concept = normalizeText(idxConcepto >= 0 ? values[idxConcepto] : '');\n\n if (amount <= 0) continue;\n\n const bankNameFile = normalizeText(idxNombreArchivo >= 0 ? values[idxNombreArchivo] : '');\n const bankAccountHolder = normalizeText(\n idxNombreCuentahabiente >= 0 ? values[idxNombreCuentahabiente] : ''\n );\n\n const validAccount = isValidAccount(account);\n const fallbackReference = isValidAccount(referenceDigits) ? referenceDigits : '';\n const displayAccount = validAccount ? account : fallbackReference;\n const currency = detectCurrency(amountRaw, concept);\n\n const rowShipment = normalizeText(idxNumeroEnvio >= 0 ? values[idxNumeroEnvio] : '') || shipmentNumber;\n const rowPlan = normalizeText(idxNumeroPlan >= 0 ? values[idxNumeroPlan] : '') || planNumber;\n\n const groupKey = validAccount\n ? `ACCOUNT:${account}:${currency}`\n : fallbackReference\n ? `REFERENCE:${fallbackReference}:${currency}`\n : `ROW:${file.fileName}:${i + 1}:${currency}`;\n\n const row = {\n source_file: file.fileName,\n row_number: i + 1,\n group_key: groupKey,\n account: displayAccount,\n raw_account: account,\n account_is_valid: validAccount,\n reference,\n shipment_number: rowShipment,\n plan_number: rowPlan,\n bank_name_file: bankNameFile,\n bank_account_holder: bankAccountHolder,\n amount,\n currency,\n concept,\n status: normalizeText(idxEstado >= 0 ? values[idxEstado] : ''),\n };\n\n rowsFromFile.push(row);\n allBankRows.push(row);\n\n if (\n bankNameFile &&\n bankAccountHolder &&\n !samePersonName(bankNameFile, bankAccountHolder)\n ) {\n nameDifferences.push({\n id: `bank_name_difference_${file.fileName}_${i + 1}`,\n source_file: file.fileName,\n row_number: i + 1,\n shipment_number: rowShipment,\n plan_number: rowPlan,\n account: displayAccount,\n reference,\n bank_name_file: bankNameFile,\n bank_account_holder: bankAccountHolder,\n amount,\n currency,\n status: 'Pendiente revisión',\n category: 'diferencia_nombre_banco',\n observation: `El Nombre en Archivo (${bankNameFile}) no coincide con el Nombre del Cuentahabiente (${bankAccountHolder}).`,\n });\n }\n }\n\n const fileTotal = roundMoney(rowsFromFile.reduce((sum, row) => sum + row.amount, 0));\n\n fileSummaries.push({\n file_name: file.fileName,\n shipment_number: shipmentNumber,\n plan_number: planNumber,\n ok: true,\n rows_count: rowsFromFile.length,\n total_amount: fileTotal,\n name_differences_count: nameDifferences.filter((row) => row.source_file === file.fileName).length,\n error: null,\n });\n}\n\nconst groupedMap = new Map();\n\nfor (const row of allBankRows) {\n const current = groupedMap.get(row.group_key) || {\n group_key: row.group_key,\n account: row.account,\n raw_account: row.raw_account,\n account_is_valid: row.account_is_valid,\n amount: 0,\n currency: row.currency,\n transactions_count: 0,\n bank_name_files: new Set(),\n bank_account_holders: new Set(),\n source_files: new Set(),\n shipment_numbers: new Set(),\n plan_numbers: new Set(),\n source_rows: [],\n };\n\n current.amount = roundMoney(current.amount + row.amount);\n current.transactions_count += 1;\n if (row.bank_name_file) current.bank_name_files.add(row.bank_name_file);\n if (row.bank_account_holder) current.bank_account_holders.add(row.bank_account_holder);\n if (row.source_file) current.source_files.add(row.source_file);\n if (row.shipment_number) current.shipment_numbers.add(row.shipment_number);\n if (row.plan_number) current.plan_numbers.add(row.plan_number);\n current.source_rows.push(row);\n\n groupedMap.set(row.group_key, current);\n}\n\nconst groupedByAccount = Array.from(groupedMap.values()).map((row) => {\n const bankNameFiles = Array.from(row.bank_name_files);\n const bankAccountHolders = Array.from(row.bank_account_holders);\n\n return {\n ...row,\n bank_name_file: bankNameFiles[0] || '',\n bank_account_holder: bankAccountHolders[0] || '',\n bank_name_files: bankNameFiles,\n bank_account_holders: bankAccountHolders,\n source_files: Array.from(row.source_files),\n shipment_numbers: Array.from(row.shipment_numbers),\n plan_numbers: Array.from(row.plan_numbers),\n };\n});\n\nconst totalsByCurrency = {};\nfor (const row of allBankRows) {\n totalsByCurrency[row.currency] = roundMoney((totalsByCurrency[row.currency] || 0) + row.amount);\n}\n\nconst bankTotal = roundMoney(allBankRows.reduce((sum, row) => sum + row.amount, 0));\n\nreturn [\n {\n json: {\n ...json,\n stage: 'banco_parseado',\n bank: {\n files_count: bankKeys.length,\n valid_files_count: fileSummaries.filter((file) => file.ok).length,\n rows_count: allBankRows.length,\n grouped_accounts_count: groupedByAccount.length,\n total_amount: bankTotal,\n totals_by_currency: totalsByCurrency,\n name_differences_count: nameDifferences.length,\n name_differences: nameDifferences,\n file_summaries: fileSummaries,\n rows: allBankRows,\n grouped_by_account: groupedByAccount,\n },\n },\n binary,\n },\n];\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1344,
1920
],
"id": "c3fcbd08-860d-4940-8d0e-55142327d07e",
"name": "Parsear CSV banco Bono 14 GT"
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"options": {}
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
7152,
3280
],
"id": "e8a66686-6888-4b30-9f0e-38e8d896d57e",
"name": "Merge"
},
{
"parameters": {
"jsCode": "const data = $('Merge - Agregar BambooHR Bono14').first().json || {};\nconst aliasResponse = $input.first().json || {};\nconst validatedBambooAliases = Array.isArray(aliasResponse.aliases)\n ? aliasResponse.aliases.filter((alias) => alias && alias.activo !== false)\n : [];\n\nfunction roundMoney(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n}\n\nfunction moneyDiff(a, b) {\n return roundMoney((Number(a) || 0) - (Number(b) || 0));\n}\n\nfunction moneyEquals(a, b, tolerance = 0.02) {\n return Math.abs(roundMoney(a) - roundMoney(b)) <= tolerance;\n}\n\nfunction normalizeAccount(value) {\n return String(value ?? '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim();\n}\n\nfunction normalizeName(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 nameWords(value) {\n const ignored = new Set(['de', 'del', 'la', 'las', 'los', 'y', 'e', 'el']);\n return normalizeName(value)\n .split(' ')\n .filter((word) => word.length > 1 && !ignored.has(word));\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\n const previous = Array.from({ length: b.length + 1 }, (_, index) => index);\n\n for (let i = 1; i <= a.length; i++) {\n const current = [i];\n\n for (let j = 1; j <= b.length; j++) {\n const cost = a[i - 1] === b[j - 1] ? 0 : 1;\n\n current[j] = Math.min(\n current[j - 1] + 1,\n previous[j] + 1,\n previous[j - 1] + cost\n );\n }\n\n for (let j = 0; j < current.length; j++) {\n previous[j] = current[j];\n }\n }\n\n return previous[b.length];\n}\n\nfunction tokenMatches(a, b) {\n if (a === b) return true;\n\n const minLength = Math.min(a.length, b.length);\n\n if (minLength >= 8 && editDistance(a, b) <= 2) return true;\n if (minLength >= 5 && editDistance(a, b) <= 1) return true;\n\n return false;\n}\n\nfunction samePersonName(a, b) {\n const normalizedA = normalizeName(a);\n const normalizedB = normalizeName(b);\n\n if (!normalizedA || !normalizedB) return false;\n if (normalizedA === normalizedB) return true;\n\n const wordsA = nameWords(a);\n const wordsB = nameWords(b);\n\n if (!wordsA.length || !wordsB.length) return false;\n\n const usedB = new Set();\n let matches = 0;\n\n for (const wordA of wordsA) {\n const matchIndex = wordsB.findIndex((wordB, index) => {\n return !usedB.has(index) && tokenMatches(wordA, wordB);\n });\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\n if (smallerLength <= 2) {\n return matches === smallerLength && matches >= 2;\n }\n\n return matches >= 2 && ratio >= 0.6;\n}\n\nfunction accountDistance(a, b) {\n return editDistance(normalizeAccount(a), normalizeAccount(b));\n}\n\nfunction accountRelationship(payrollAccount, bankAccount) {\n const payroll = normalizeAccount(payrollAccount);\n const bank = normalizeAccount(bankAccount);\n\n if (!payroll || !bank) {\n return { matches: false, type: 'none' };\n }\n\n if (payroll === bank) {\n return { matches: true, type: 'exact' };\n }\n\n const bankHasPayrollSuffix =\n bank.endsWith(payroll) &&\n bank.length > payroll.length &&\n bank.length - payroll.length <= 6;\n\n const payrollHasBankSuffix =\n payroll.endsWith(bank) &&\n payroll.length > bank.length &&\n payroll.length - bank.length <= 6;\n\n if (bankHasPayrollSuffix || payrollHasBankSuffix) {\n return { matches: true, type: 'reference_prefix' };\n }\n\n return { matches: false, type: 'none' };\n}\n\nfunction formatMoney(value) {\n return Math.abs(roundMoney(value)).toLocaleString('en-US', {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n });\n}\n\nfunction bankNames(bank) {\n return Array.from(new Set([\n ...(Array.isArray(bank.bank_name_files) ? bank.bank_name_files : []),\n ...(Array.isArray(bank.bank_account_holders) ? bank.bank_account_holders : []),\n bank.bank_name_file || '',\n bank.bank_account_holder || '',\n ].filter(Boolean)));\n}\n\nfunction bankMatchesName(bank, payrollName) {\n return bankNames(bank).some((name) => samePersonName(payrollName, name));\n}\n\nfunction bestBankDisplayName(bank) {\n return (\n bank.bank_name_file ||\n bank.bank_account_holder ||\n bankNames(bank)[0] ||\n ''\n );\n}\n\n\nfunction bambooAliases(employee) {\n return Array.from(new Set([\n ...(Array.isArray(employee.aliases) ? employee.aliases : []),\n employee.full_name || '',\n [\n employee.first_name,\n employee.middle_name,\n employee.last_name,\n ].filter(Boolean).join(' '),\n [\n employee.preferred_name,\n employee.last_name,\n ].filter(Boolean).join(' '),\n [\n employee.first_name,\n employee.last_name,\n ].filter(Boolean).join(' '),\n ].map((value) =>\n String(value || '').trim()\n ).filter(Boolean)));\n}\n\nfunction bambooEmployeeNumber(employee) {\n return normalizeAccount(\n employee.employee_number ||\n employee.employeeNumber ||\n ''\n );\n}\n\nfunction bankRowKey(row) {\n return [\n row.source_file || '',\n row.row_number || '',\n ].join('|');\n}\n\nfunction bankRowNames(row) {\n const rowKey = bankRowKey(row);\n\n const linkedPayrollNames =\n typeof linkedPayrollNamesByBankRow !== 'undefined'\n ? linkedPayrollNamesByBankRow.get(rowKey) || []\n : [];\n\n return Array.from(new Set([\n row.bank_name_file || '',\n row.bank_account_holder || '',\n row.participant_name || '',\n ...linkedPayrollNames,\n ].map((value) =>\n String(value || '').trim()\n ).filter(Boolean)));\n}\n\nfunction bankRowEmployeeNumbers(row) {\n const rowKey = bankRowKey(row);\n\n const linkedNumbers =\n typeof linkedPayrollNumbersByBankRow !== 'undefined'\n ? linkedPayrollNumbersByBankRow.get(rowKey) || []\n : [];\n\n return Array.from(new Set(\n linkedNumbers\n .map(normalizeAccount)\n .filter((value) => value.length >= 6)\n ));\n}\n\nfunction extractDigitSequences(value) {\n return Array.from(new Set(\n String(value || '')\n .match(/\\d{6,20}/g) || []\n )).map(normalizeAccount).filter(\n (value) => value.length >= 6\n );\n}\n\nfunction bankRowReferenceNumbers(row) {\n return Array.from(new Set([\n ...extractDigitSequences(row.reference),\n ...extractDigitSequences(row.concept),\n ...extractDigitSequences(row.addenda),\n ...extractDigitSequences(row.participant_id),\n ]));\n}\n\nfunction isClearlyNonEmployeePayment(row) {\n const normalized = normalizeName([\n row.concept || '',\n row.bank_name_file || '',\n row.bank_account_holder || '',\n ].join(' '));\n\n return [\n 'pension alimenticia',\n 'embargo judicial',\n 'retencion judicial',\n ].some((token) =>\n normalized.includes(normalizeName(token))\n );\n}\n\nfunction buildBambooSearchIndex(employees) {\n const records = [];\n const exactAliasSets = new Map();\n const tokenIndexSets = new Map();\n const employeeNumberSets = new Map();\n\n for (\n let employeeIndex = 0;\n employeeIndex < employees.length;\n employeeIndex++\n ) {\n const employee = employees[employeeIndex];\n const aliases = [];\n const seenAliases = new Set();\n\n for (const rawAlias of bambooAliases(employee)) {\n const normalized = normalizeName(rawAlias);\n\n if (\n !normalized ||\n seenAliases.has(normalized)\n ) {\n continue;\n }\n\n seenAliases.add(normalized);\n\n const words = Array.from(new Set(\n nameWords(normalized)\n ));\n\n if (!words.length) continue;\n\n const wordSet = new Set(words);\n\n aliases.push({\n raw: rawAlias,\n normalized,\n words,\n wordSet,\n });\n\n let exactSet =\n exactAliasSets.get(normalized);\n\n if (!exactSet) {\n exactSet = new Set();\n exactAliasSets.set(\n normalized,\n exactSet\n );\n }\n\n exactSet.add(employeeIndex);\n\n for (const token of words) {\n if (token.length < 3) continue;\n\n let tokenSet =\n tokenIndexSets.get(token);\n\n if (!tokenSet) {\n tokenSet = new Set();\n tokenIndexSets.set(\n token,\n tokenSet\n );\n }\n\n tokenSet.add(employeeIndex);\n }\n }\n\n const employeeNumber =\n bambooEmployeeNumber(employee);\n\n if (employeeNumber.length >= 6) {\n let numberSet =\n employeeNumberSets.get(\n employeeNumber\n );\n\n if (!numberSet) {\n numberSet = new Set();\n employeeNumberSets.set(\n employeeNumber,\n numberSet\n );\n }\n\n numberSet.add(employeeIndex);\n }\n\n records.push({\n employee,\n aliases,\n employeeNumber,\n });\n }\n\n const exactAliasMap = new Map();\n const tokenIndex = new Map();\n const employeeNumberMap = new Map();\n\n for (const [key, value] of exactAliasSets) {\n exactAliasMap.set(\n key,\n Array.from(value)\n );\n }\n\n for (const [key, value] of tokenIndexSets) {\n tokenIndex.set(\n key,\n Array.from(value)\n );\n }\n\n for (\n const [key, value] of\n employeeNumberSets\n ) {\n employeeNumberMap.set(\n key,\n Array.from(value)\n );\n }\n\n return {\n records,\n exactAliasMap,\n tokenIndex,\n employeeNumberMap,\n };\n}\n\nfunction aliasMatchDetails(\n queryName,\n alias\n) {\n const queryNormalized =\n normalizeName(queryName);\n\n if (!queryNormalized) return null;\n\n if (\n queryNormalized === alias.normalized\n ) {\n return {\n score: 1,\n exact: true,\n containment: true,\n matchedTokens:\n alias.words.length,\n exactMatches:\n alias.words.length,\n fuzzyMatches: 0,\n queryCoverage: 1,\n aliasCoverage: 1,\n };\n }\n\n const queryWords = Array.from(\n new Set(nameWords(queryNormalized))\n );\n\n if (\n queryWords.length < 2 ||\n alias.words.length < 2\n ) {\n return null;\n }\n\n const querySet = new Set(queryWords);\n\n let exactMatches = 0;\n\n for (const queryWord of queryWords) {\n if (alias.wordSet.has(queryWord)) {\n exactMatches += 1;\n }\n }\n\n const queryInsideAlias =\n exactMatches === queryWords.length;\n\n let aliasWordsInsideQuery = 0;\n\n for (const aliasWord of alias.words) {\n if (querySet.has(aliasWord)) {\n aliasWordsInsideQuery += 1;\n }\n }\n\n const aliasInsideQuery =\n aliasWordsInsideQuery ===\n alias.words.length;\n\n if (\n queryInsideAlias ||\n aliasInsideQuery\n ) {\n const shorterLength = Math.min(\n queryWords.length,\n alias.words.length\n );\n const longerLength = Math.max(\n queryWords.length,\n alias.words.length\n );\n\n return {\n score:\n 0.90 +\n (\n shorterLength /\n Math.max(1, longerLength)\n ) * 0.09,\n exact: false,\n containment: true,\n matchedTokens: exactMatches,\n exactMatches,\n fuzzyMatches: 0,\n queryCoverage:\n exactMatches / queryWords.length,\n aliasCoverage:\n aliasWordsInsideQuery /\n alias.words.length,\n };\n }\n\n if (exactMatches < 2) {\n return null;\n }\n\n const usedAliasWords = new Set();\n let fuzzyMatches = 0;\n\n for (let queryIndex = 0;\n queryIndex < queryWords.length;\n queryIndex++\n ) {\n const queryWord =\n queryWords[queryIndex];\n\n if (alias.wordSet.has(queryWord)) {\n continue;\n }\n\n const aliasIndex =\n alias.words.findIndex(\n (aliasWord, currentIndex) =>\n !usedAliasWords.has(\n currentIndex\n ) &&\n !querySet.has(aliasWord) &&\n tokenMatches(\n queryWord,\n aliasWord\n )\n );\n\n if (aliasIndex >= 0) {\n usedAliasWords.add(aliasIndex);\n fuzzyMatches += 1;\n }\n }\n\n const matchedTokens =\n exactMatches + fuzzyMatches;\n\n if (\n fuzzyMatches > 1 ||\n matchedTokens < 3\n ) {\n return null;\n }\n\n const queryCoverage =\n matchedTokens / queryWords.length;\n\n const aliasCoverage =\n matchedTokens / alias.words.length;\n\n if (\n queryCoverage < 0.67 ||\n aliasCoverage < 0.60\n ) {\n return null;\n }\n\n return {\n score:\n queryCoverage * 0.48 +\n aliasCoverage * 0.32 +\n (\n exactMatches /\n matchedTokens\n ) * 0.20,\n exact: false,\n containment: false,\n matchedTokens,\n exactMatches,\n fuzzyMatches,\n queryCoverage,\n aliasCoverage,\n };\n}\n\nfunction candidateIndexesForName(\n normalizedName\n) {\n const tokens = Array.from(\n new Set(nameWords(normalizedName))\n ).filter((token) =>\n token.length >= 3\n );\n\n const votes = new Map();\n\n for (const token of tokens) {\n const indexes =\n bambooSearch.tokenIndex.get(token) || [];\n\n /*\n * Los tokens muy comunes no aportan suficiente identidad.\n * Ignorarlos evita cientos de candidatos y mantiene el nodo rápido.\n */\n if (indexes.length > 240) continue;\n\n for (const index of indexes) {\n votes.set(\n index,\n (votes.get(index) || 0) + 1\n );\n }\n }\n\n return Array.from(votes.entries())\n .filter(([, voteCount]) =>\n voteCount >= 2 ||\n (\n tokens.length === 2 &&\n voteCount === 2\n )\n )\n .sort((left, right) =>\n right[1] - left[1]\n )\n .slice(0, 90)\n .map(([index]) => index);\n}\n\nconst bambooMatchCache = new Map();\n\nfunction findBambooMatch(bankRow) {\n const names = bankRowNames(bankRow)\n .map((raw) => ({\n raw,\n normalized:\n normalizeName(raw),\n tokenCount:\n nameWords(raw).length,\n }))\n .filter((entry) =>\n entry.normalized &&\n entry.tokenCount >= 2\n )\n .sort((left, right) =>\n right.tokenCount -\n left.tokenCount\n );\n\n const directEmployeeNumbers =\n bankRowEmployeeNumbers(bankRow);\n\n const referenceNumbers =\n bankRowReferenceNumbers(bankRow);\n\n const cacheKey = [\n ...directEmployeeNumbers\n .slice()\n .sort(),\n ...referenceNumbers\n .slice()\n .sort(),\n ...names\n .map((entry) =>\n entry.normalized\n )\n .sort(),\n ].join('|');\n\n if (bambooMatchCache.has(cacheKey)) {\n return bambooMatchCache.get(\n cacheKey\n );\n }\n\n const numberCandidates = new Set();\n\n for (const employeeNumber of [\n ...directEmployeeNumbers,\n ...referenceNumbers,\n ]) {\n for (\n const index of\n bambooSearch.employeeNumberMap\n .get(employeeNumber) || []\n ) {\n numberCandidates.add(index);\n }\n }\n\n if (numberCandidates.size === 1) {\n const index =\n numberCandidates.values()\n .next().value;\n\n const result = {\n found: true,\n matched_by:\n directEmployeeNumbers.length\n ? 'employee_number_payroll'\n : 'employee_number_reference',\n confidence: 1,\n employee:\n bambooSearch.records[index]\n .employee,\n };\n\n bambooMatchCache.set(\n cacheKey,\n result\n );\n\n return result;\n }\n\n\n /*\n * Consulta primero la resolución calculada una sola vez en el\n * normalizador. Esto evita repetir búsquedas aproximadas por cada fila\n * bancaria y mantiene el task runner estable incluso con miles de\n * empleados en BambooHR.\n */\n const precomputedNameMatches =\n data.bamboo?.resolved_name_matches ||\n {};\n\n const precomputedNameEntries =\n names.map((entry) => {\n const raw =\n typeof entry === 'string'\n ? entry\n : entry?.raw || '';\n\n return {\n raw,\n normalized:\n typeof entry === 'string'\n ? normalizeName(entry)\n : (\n entry?.normalized ||\n normalizeName(raw)\n ),\n token_count:\n typeof entry === 'string'\n ? nameWords(entry).length\n : (\n entry?.tokenCount ||\n nameWords(raw).length\n ),\n };\n }).filter((entry) =>\n entry.normalized\n );\n\n const precomputedFoundByEmployee =\n new Map();\n\n function resolutionEmployeeKey(\n employee\n ) {\n return (\n String(\n employee?.bamboo_id ||\n ''\n ).trim() ||\n normalizeAccount(\n employee?.employee_number ||\n employee?.employeeNumber ||\n ''\n ) ||\n normalizeName(\n employee?.full_name ||\n employee?.displayName ||\n ''\n )\n );\n }\n\n for (\n const nameEntry of\n precomputedNameEntries\n ) {\n const decision =\n precomputedNameMatches[\n nameEntry.normalized\n ];\n\n if (\n !decision ||\n decision.found !== true\n ) {\n continue;\n }\n\n let employee =\n Number.isInteger(\n decision.employee_index\n )\n ? bambooEmployees[\n decision.employee_index\n ]\n : null;\n\n const expectedKey =\n String(\n decision.employee_key ||\n ''\n ).trim();\n\n if (\n !employee ||\n (\n expectedKey &&\n resolutionEmployeeKey(\n employee\n ) !== expectedKey\n )\n ) {\n employee =\n bambooEmployees.find(\n (candidate) =>\n resolutionEmployeeKey(\n candidate\n ) === expectedKey\n ) || null;\n }\n\n if (!employee) continue;\n\n const employeeKey =\n resolutionEmployeeKey(employee);\n\n const candidate = {\n employee,\n employee_key:\n employeeKey,\n confidence:\n Number(\n decision.confidence || 0\n ),\n matched_by:\n decision.matched_by ||\n 'precomputed_name',\n bank_name:\n nameEntry.raw,\n bamboo_alias:\n decision.bamboo_alias ||\n employee.full_name ||\n '',\n informativeness:\n nameEntry.token_count,\n };\n\n const existing =\n precomputedFoundByEmployee\n .get(employeeKey);\n\n if (\n !existing ||\n candidate.confidence >\n existing.confidence ||\n (\n candidate.confidence ===\n existing.confidence &&\n candidate.informativeness >\n existing.informativeness\n )\n ) {\n precomputedFoundByEmployee.set(\n employeeKey,\n candidate\n );\n }\n }\n\n const precomputedRanked =\n Array.from(\n precomputedFoundByEmployee\n .values()\n ).sort((left, right) => {\n if (\n right.confidence !==\n left.confidence\n ) {\n return (\n right.confidence -\n left.confidence\n );\n }\n\n return (\n right.informativeness -\n left.informativeness\n );\n });\n\n if (precomputedRanked.length === 1) {\n const best =\n precomputedRanked[0];\n\n const result = {\n found: true,\n matched_by:\n best.matched_by,\n confidence:\n best.confidence,\n employee:\n best.employee,\n bank_name:\n best.bank_name,\n bamboo_alias:\n best.bamboo_alias,\n };\n\n bambooMatchCache.set(\n cacheKey,\n result\n );\n\n return result;\n }\n\n if (\n precomputedRanked.length > 1\n ) {\n const best =\n precomputedRanked[0];\n\n const second =\n precomputedRanked[1];\n\n if (\n best.confidence -\n second.confidence >= 0.08\n ) {\n const result = {\n found: true,\n matched_by:\n best.matched_by,\n confidence:\n best.confidence,\n employee:\n best.employee,\n bank_name:\n best.bank_name,\n bamboo_alias:\n best.bamboo_alias,\n };\n\n bambooMatchCache.set(\n cacheKey,\n result\n );\n\n return result;\n }\n\n const result = {\n found: false,\n matched_by: null,\n confidence:\n best.confidence,\n employee: null,\n ambiguous: true,\n reason:\n 'conflicting_precomputed_name_matches',\n best_candidate: {\n employee:\n best.employee,\n score:\n best.confidence,\n bank_name:\n best.bank_name,\n bamboo_alias:\n best.bamboo_alias,\n },\n };\n\n bambooMatchCache.set(\n cacheKey,\n result\n );\n\n return result;\n }\n\n\n /*\n * Primero se intenta una coincidencia textual exacta.\n * Se acepta solamente cuando todos los alias exactos apuntan\n * al mismo empleado.\n */\n const exactIndexes = new Set();\n\n for (const name of names) {\n for (\n const index of\n bambooSearch.exactAliasMap\n .get(name.normalized) || []\n ) {\n exactIndexes.add(index);\n }\n }\n\n if (exactIndexes.size === 1) {\n const index =\n exactIndexes.values()\n .next().value;\n\n const result = {\n found: true,\n matched_by: 'exact_name',\n confidence: 1,\n employee:\n bambooSearch.records[index]\n .employee,\n bank_name:\n names[0]?.raw || '',\n bamboo_alias:\n bambooSearch.records[index]\n .aliases[0]?.raw || '',\n };\n\n bambooMatchCache.set(\n cacheKey,\n result\n );\n\n return result;\n }\n\n const candidatesByEmployee =\n new Map();\n\n /*\n * La búsqueda ya no recorre todos los empleados.\n * Cada nombre consulta el índice invertido y solo compara\n * un máximo de 90 candidatos que comparten al menos dos palabras.\n */\n for (const name of names) {\n const candidateIndexes =\n candidateIndexesForName(\n name.normalized\n );\n\n for (const index of candidateIndexes) {\n const record =\n bambooSearch.records[index];\n\n let bestAliasMatch = null;\n let bestAlias = '';\n\n for (const alias of record.aliases) {\n const details =\n aliasMatchDetails(\n name.raw,\n alias\n );\n\n if (\n details &&\n (\n !bestAliasMatch ||\n details.score >\n bestAliasMatch.score\n )\n ) {\n bestAliasMatch = details;\n bestAlias = alias.raw;\n }\n }\n\n if (!bestAliasMatch) continue;\n\n const existing =\n candidatesByEmployee.get(index);\n\n const candidate = {\n index,\n employee: record.employee,\n score: bestAliasMatch.score,\n details: bestAliasMatch,\n bank_name: name.raw,\n bamboo_alias: bestAlias,\n informativeness:\n name.tokenCount,\n };\n\n if (\n !existing ||\n candidate.score >\n existing.score ||\n (\n candidate.score ===\n existing.score &&\n candidate.informativeness >\n existing.informativeness\n )\n ) {\n candidatesByEmployee.set(\n index,\n candidate\n );\n }\n }\n }\n\n const rankedCandidates =\n Array.from(\n candidatesByEmployee.values()\n ).sort((left, right) => {\n if (right.score !== left.score) {\n return right.score - left.score;\n }\n\n if (\n right.details.exactMatches !==\n left.details.exactMatches\n ) {\n return (\n right.details.exactMatches -\n left.details.exactMatches\n );\n }\n\n return (\n right.informativeness -\n left.informativeness\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.score -\n (second?.score || 0)\n : 0;\n\n const strongContainment =\n Boolean(\n best?.details?.containment &&\n best.details.exactMatches >= 2 &&\n (\n !second ||\n margin >= 0.035 ||\n best.details.exactMatches >\n second.details.exactMatches\n )\n );\n\n const strongPartial =\n Boolean(\n best &&\n !best.details.containment &&\n best.score >= 0.82 &&\n best.details.exactMatches >= 2 &&\n (\n !second ||\n margin >= 0.07\n )\n );\n\n let result;\n\n if (\n best &&\n (\n strongContainment ||\n strongPartial\n )\n ) {\n result = {\n found: true,\n matched_by:\n strongContainment\n ? 'unique_token_containment'\n : 'strong_indexed_name',\n confidence:\n Math.min(1, best.score),\n employee: best.employee,\n bank_name: best.bank_name,\n bamboo_alias:\n best.bamboo_alias,\n };\n } else {\n result = {\n found: false,\n matched_by: null,\n confidence:\n best?.score || 0,\n employee: null,\n ambiguous: Boolean(\n best &&\n second &&\n best.score >= 0.75 &&\n margin < 0.07\n ),\n best_candidate:\n best\n ? {\n employee:\n best.employee,\n score:\n best.score,\n bank_name:\n best.bank_name,\n bamboo_alias:\n best.bamboo_alias,\n }\n : null,\n };\n }\n\n bambooMatchCache.set(\n cacheKey,\n result\n );\n\n return result;\n}\n\nfunction supplementKey(supplement) {\n return [\n supplement.source_sheet || '',\n supplement.row_number || '',\n supplement.supplement_id || '',\n supplement.account || '',\n supplement.payroll_amount || 0,\n ].join('|');\n}\n\nconst payrollAccounts = (data.payroll?.grouped_by_account || [])\n .map((row) => ({\n ...row,\n group_key:\n row.group_key ||\n `${normalizeAccount(row.account)}:${row.currency || 'QTZ'}`,\n account: normalizeAccount(row.account),\n employee_name: row.employee_name || row.employee || '',\n employee_number: row.employee_number || row.employeeNumber || '',\n currency: row.currency || 'QTZ',\n payroll_amount: roundMoney(row.payroll_amount || row.payrollAmount),\n source_rows: Array.isArray(row.source_rows) ? [...row.source_rows] : [],\n source_sheets: Array.isArray(row.source_sheets)\n ? [...row.source_sheets]\n : [],\n }))\n .filter((row) => row.account && row.payroll_amount > 0);\n\nconst payrollNoAccountRows = (data.payroll?.no_account_rows || [])\n .map((row) => ({\n ...row,\n account: '',\n employee_name: row.employee_name || row.employee || '',\n employee_number: row.employee_number || row.employeeNumber || '',\n currency: row.currency || 'QTZ',\n payroll_amount: roundMoney(row.payroll_amount || row.payrollAmount),\n }))\n .filter((row) => row.payroll_amount > 0);\n\nconst bankAccounts = (data.bank?.grouped_by_account || [])\n .map((row) => ({\n ...row,\n group_key:\n row.group_key ||\n `ACCOUNT:${normalizeAccount(row.account)}:${row.currency || 'QTZ'}`,\n account: normalizeAccount(row.account),\n account_is_valid: Boolean(row.account_is_valid),\n currency: row.currency || 'QTZ',\n amount: roundMoney(row.amount || row.bank_amount || row.bankAmount),\n source_rows: Array.isArray(row.source_rows) ? [...row.source_rows] : [],\n }))\n .filter((row) => row.amount > 0);\n\nconst bambooEmployees = Array.isArray(data.bamboo?.employees)\n ? data.bamboo.employees\n : [];\n\nconst bambooValidationAvailable =\n data.bamboo?.fetch_complete === true &&\n data.bamboo?.validation_available === true &&\n bambooEmployees.length > 0;\n\nconst bambooValidationWarning =\n bambooValidationAvailable\n ? null\n : (\n data.errors?.find((error) =>\n String(error || '').toLowerCase().includes('bamboohr')\n ) ||\n 'La validación Banco sin Bamboo no estuvo disponible porque la descarga de empleados de BambooHR quedó incompleta.'\n );\n\nconst bambooSearch = buildBambooSearchIndex(\n bambooEmployees\n);\n\n/*\n * Equivalencias de identidad validadas manualmente por las revisoras.\n * Solo se aplican cuando el nombre detectado coincide EXACTAMENTE (ya\n * normalizado) con un alias activo y el empleado objetivo puede resolverse\n * de forma única en la descarga actual de BambooHR. Si existe cualquier\n * ambigüedad, el caso NO se oculta y continúa en Banco sin Bamboo.\n */\nfunction findValidatedAliasMatch(bankRow) {\n if (!validatedBambooAliases.length) return null;\n\n const sourceNames = new Set(\n bankRowNames(bankRow)\n .map((value) => normalizeName(value))\n .filter(Boolean)\n );\n\n if (!sourceNames.size) return null;\n\n const matchingAliases = validatedBambooAliases.filter((alias) => {\n if (!alias || alias.activo === false) return false;\n\n // Normalizamos el nombre ORIGINAL con las mismas reglas locales del\n // workflow. Así GT y TT conservan exactamente su tratamiento actual de\n // apóstrofes/guiones y no dependemos de cómo Supabase normalizó el texto.\n const source = normalizeName(\n alias.nombre_origen ||\n alias.nombre_origen_normalizado ||\n ''\n );\n\n if (!source || !sourceNames.has(source)) return false;\n\n // Si la validación humana se registró con una cuenta bancaria, exigimos\n // también esa cuenta exacta. Esto evita que dos personas con el mismo\n // nombre hereden una equivalencia incorrecta.\n const aliasAccount = normalizeAccount(alias.cuenta_bancaria || '');\n const currentAccount = normalizeAccount(bankRow.account || '');\n\n if (aliasAccount && aliasAccount !== currentAccount) return false;\n\n return true;\n });\n\n if (!matchingAliases.length) return null;\n\n const resolvedMatches = [];\n\n for (const alias of matchingAliases) {\n let resolvedIndex = null;\n let resolvedBy = '';\n\n const employeeNumber = normalizeAccount(\n alias.bamboo_employee_number || ''\n );\n\n if (employeeNumber.length >= 6) {\n const indexes =\n bambooSearch.employeeNumberMap.get(employeeNumber) || [];\n\n if (indexes.length === 1) {\n resolvedIndex = indexes[0];\n resolvedBy = 'validated_alias_employee_number';\n }\n }\n\n if (resolvedIndex === null) {\n const bambooId = String(\n alias.bamboo_employee_id || ''\n ).trim();\n\n if (bambooId) {\n const indexes = bambooSearch.records\n .map((record, index) => ({ record, index }))\n .filter(({ record }) =>\n String(\n record.employee?.bamboo_id ||\n record.employee?.employeeId ||\n record.employee?.id ||\n ''\n ).trim() === bambooId\n )\n .map(({ index }) => index);\n\n if (indexes.length === 1) {\n resolvedIndex = indexes[0];\n resolvedBy = 'validated_alias_bamboo_id';\n }\n }\n }\n\n if (resolvedIndex === null) {\n const bambooName = normalizeName(\n alias.bamboo_nombre || ''\n );\n\n if (bambooName) {\n const indexes =\n bambooSearch.exactAliasMap.get(bambooName) || [];\n\n if (indexes.length === 1) {\n resolvedIndex = indexes[0];\n resolvedBy = 'validated_alias_exact_bamboo_name';\n }\n }\n }\n\n if (resolvedIndex !== null) {\n resolvedMatches.push({\n index: resolvedIndex,\n alias,\n resolvedBy,\n });\n }\n }\n\n if (!resolvedMatches.length) return null;\n\n const employeeIndexes = Array.from(\n new Set(resolvedMatches.map((match) => match.index))\n );\n\n // Nunca suprimir un hallazgo si dos equivalencias apuntan a personas distintas.\n if (employeeIndexes.length !== 1) return null;\n\n const chosen = resolvedMatches.find(\n (match) => match.index === employeeIndexes[0]\n );\n const record = bambooSearch.records[employeeIndexes[0]];\n\n if (!chosen || !record?.employee) return null;\n\n return {\n found: true,\n matched_by: 'validated_bamboo_alias',\n confidence: 1,\n employee: record.employee,\n bank_name:\n bankRowNames(bankRow)[0] || '',\n bamboo_alias:\n chosen.alias.bamboo_nombre ||\n record.aliases?.[0]?.raw ||\n '',\n validated_alias_id:\n chosen.alias.id || '',\n validated_alias_resolution:\n chosen.resolvedBy,\n };\n}\n\n\nconst bankDetailRows = Array.isArray(data.bank?.rows)\n ? data.bank.rows\n : [];\n\nconst potentialSupplements = (\n data.payroll?.potential_supplements ||\n data.debug_payroll?.potential_supplements ||\n data.debug_payroll?.attached_supplements ||\n []\n)\n .map((row) => ({\n ...row,\n account: normalizeAccount(row.account),\n currency: row.currency || 'QTZ',\n payroll_amount: roundMoney(row.payroll_amount || row.payrollAmount),\n }))\n .filter((row) => {\n const id = normalizeName(row.supplement_id || '');\n\n return (\n row.account &&\n row.payroll_amount >= 10 &&\n !id.includes('back up')\n );\n });\n\nconst supplementsByAccountCurrency = new Map();\n\nfor (const supplement of potentialSupplements) {\n const key = `${supplement.account}:${supplement.currency}`;\n const current = supplementsByAccountCurrency.get(key) || [];\n\n current.push(supplement);\n supplementsByAccountCurrency.set(key, current);\n}\n\nfunction chooseConditionalSupplements(payroll, bank) {\n const baseAmount = roundMoney(payroll.payroll_amount);\n const bankAmount = roundMoney(bank.amount);\n const candidates =\n supplementsByAccountCurrency.get(\n `${payroll.account}:${payroll.currency}`\n ) || [];\n\n if (\n !candidates.length ||\n bankAmount <= baseAmount + 0.02\n ) {\n return {\n selected: [],\n effectiveAmount: baseAmount,\n baseAmount,\n improvement: 0,\n };\n }\n\n const baseDifference = Math.abs(baseAmount - bankAmount);\n let bestSelected = [];\n let bestAmount = baseAmount;\n let bestDifference = baseDifference;\n\n if (candidates.length <= 12) {\n const combinations = 1 << candidates.length;\n\n for (let mask = 1; mask < combinations; mask++) {\n const selected = [];\n let selectedTotal = 0;\n\n for (let index = 0; index < candidates.length; index++) {\n if ((mask & (1 << index)) !== 0) {\n selected.push(candidates[index]);\n selectedTotal = roundMoney(\n selectedTotal + candidates[index].payroll_amount\n );\n }\n }\n\n const candidateAmount = roundMoney(baseAmount + selectedTotal);\n const candidateDifference = Math.abs(\n candidateAmount - bankAmount\n );\n\n if (candidateDifference < bestDifference) {\n bestSelected = selected;\n bestAmount = candidateAmount;\n bestDifference = candidateDifference;\n }\n }\n } else {\n const sorted = [...candidates].sort(\n (a, b) => b.payroll_amount - a.payroll_amount\n );\n\n let runningAmount = baseAmount;\n const selected = [];\n\n for (const candidate of sorted) {\n const nextAmount = roundMoney(\n runningAmount + candidate.payroll_amount\n );\n\n if (\n Math.abs(nextAmount - bankAmount) <\n Math.abs(runningAmount - bankAmount)\n ) {\n selected.push(candidate);\n runningAmount = nextAmount;\n }\n }\n\n bestSelected = selected;\n bestAmount = runningAmount;\n bestDifference = Math.abs(bestAmount - bankAmount);\n }\n\n const improvement = roundMoney(\n baseDifference - bestDifference\n );\n\n // Evita sumar valores accidentales o inmateriales, como un \"Asignado\" de Q1.\n if (!bestSelected.length || improvement < 5) {\n return {\n selected: [],\n effectiveAmount: baseAmount,\n baseAmount,\n improvement: 0,\n };\n }\n\n return {\n selected: bestSelected,\n effectiveAmount: roundMoney(bestAmount),\n baseAmount,\n improvement,\n };\n}\n\nfunction getDirectCandidates(payroll, matchedBankKeys) {\n return bankAccounts\n .filter((bank) => {\n if (matchedBankKeys.has(bank.group_key)) return false;\n if (bank.currency !== payroll.currency) return false;\n\n const relationship = accountRelationship(\n payroll.account,\n bank.account\n );\n\n if (!relationship.matches) return false;\n\n // Un sufijo de referencia solamente es válido cuando el nombre también\n // corresponde a la misma persona.\n if (\n relationship.type === 'reference_prefix' &&\n !bankMatchesName(bank, payroll.employee_name)\n ) {\n return false;\n }\n\n return true;\n })\n .map((bank) => {\n const relationship = accountRelationship(\n payroll.account,\n bank.account\n );\n const supplementDecision =\n chooseConditionalSupplements(payroll, bank);\n\n return {\n bank,\n relationship,\n supplementDecision,\n nameMatches: bankMatchesName(bank, payroll.employee_name),\n };\n })\n .sort((a, b) => {\n const exactDifference =\n Number(b.relationship.type === 'exact') -\n Number(a.relationship.type === 'exact');\n\n if (exactDifference !== 0) return exactDifference;\n\n const nameDifference =\n Number(b.nameMatches) - Number(a.nameMatches);\n\n if (nameDifference !== 0) return nameDifference;\n\n return (\n Math.abs(\n a.supplementDecision.effectiveAmount - a.bank.amount\n ) -\n Math.abs(\n b.supplementDecision.effectiveAmount - b.bank.amount\n )\n );\n });\n}\n\nfunction buildSources(payroll, selectedSupplements) {\n const supplementRows = selectedSupplements.map((row) => ({\n source_sheet: row.source_sheet,\n row_number: row.row_number,\n amount: row.payroll_amount,\n supplement_original_name:\n row.supplement_original_name || row.employee_name || '',\n supplement_id: row.supplement_id || '',\n applied_conditionally: true,\n }));\n\n const sourceRows = [\n ...(payroll.source_rows || []),\n ...supplementRows,\n ];\n\n const sourceSheets = Array.from(new Set([\n ...(payroll.source_sheets || []),\n ...selectedSupplements\n .map((row) => row.source_sheet)\n .filter(Boolean),\n ]));\n\n return { sourceRows, sourceSheets };\n}\n\nconst matchedPayrollKeys = new Set();\nconst matchedBankKeys = new Set();\nconst matchedNoAccountIndexes = new Set();\nconst appliedSupplementKeys = new Set();\nconst appliedSupplements = [];\nconst finalExactReconciliations = [];\nconst rows = [];\n\nfunction registerSupplements(selected) {\n for (const supplement of selected || []) {\n const key = supplementKey(supplement);\n\n if (!appliedSupplementKeys.has(key)) {\n appliedSupplementKeys.add(key);\n appliedSupplements.push(supplement);\n }\n }\n}\n\n// 1) Cuenta exacta o referencia con prefijo, y monto conciliado.\nfor (const payroll of payrollAccounts) {\n const candidates = getDirectCandidates(\n payroll,\n matchedBankKeys\n ).filter((candidate) => {\n return moneyEquals(\n candidate.supplementDecision.effectiveAmount,\n candidate.bank.amount\n );\n });\n\n if (candidates.length !== 1) continue;\n\n const candidate = candidates[0];\n const bank = candidate.bank;\n const decision = candidate.supplementDecision;\n const sources = buildSources(payroll, decision.selected);\n\n matchedPayrollKeys.add(payroll.group_key);\n matchedBankKeys.add(bank.group_key);\n registerSupplements(decision.selected);\n\n rows.push({\n id: `match_${payroll.group_key}_${bank.group_key}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: payroll.currency,\n payrollAmount: decision.effectiveAmount,\n payroll_amount: decision.effectiveAmount,\n payrollBaseAmount: decision.baseAmount,\n payroll_base_amount: decision.baseAmount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: 0,\n status: 'Coincidencia',\n category: 'coincidencia',\n subcategory:\n candidate.relationship.type === 'reference_prefix'\n ? 'referencia_bancaria_con_prefijo'\n : decision.selected.length\n ? 'cuenta_monto_y_suplemento_condicional'\n : 'cuenta_y_monto_coinciden',\n observation:\n candidate.relationship.type === 'reference_prefix'\n ? 'Conciliado por nombre, monto y referencia bancaria con prefijo.'\n : decision.selected.length\n ? 'Conciliado correctamente. Se aplicó un suplemento porque el banco mostró un pago adicional.'\n : 'Conciliado correctamente.',\n applied_supplements: decision.selected,\n source_sheets: sources.sourceSheets,\n source_rows: sources.sourceRows,\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 2) Cuenta diferente, pero nombre y monto coinciden.\n// Se ejecuta antes de crear diferencias directas para resolver casos como\n// Ashly/Ashley Ramos: la cuenta de la nómina apunta a otra transacción,\n// pero existe otra cuenta bancaria con el mismo nombre y monto correcto.\nfor (const payroll of payrollAccounts) {\n if (matchedPayrollKeys.has(payroll.group_key)) continue;\n\n const candidates = bankAccounts\n .filter((bank) => {\n if (matchedBankKeys.has(bank.group_key)) return false;\n if (bank.currency !== payroll.currency) return false;\n if (!bankMatchesName(bank, payroll.employee_name)) return false;\n\n const decision = chooseConditionalSupplements(\n payroll,\n bank\n );\n\n return moneyEquals(\n decision.effectiveAmount,\n bank.amount\n );\n })\n .map((bank) => ({\n bank,\n supplementDecision: chooseConditionalSupplements(\n payroll,\n bank\n ),\n }));\n\n if (candidates.length !== 1) continue;\n\n const candidate = candidates[0];\n const bank = candidate.bank;\n const decision = candidate.supplementDecision;\n const relationship = accountRelationship(\n payroll.account,\n bank.account\n );\n\n // Las referencias con prefijo ya debieron resolverse en el paso 1.\n if (relationship.type === 'reference_prefix') continue;\n\n const sources = buildSources(payroll, decision.selected);\n\n matchedPayrollKeys.add(payroll.group_key);\n matchedBankKeys.add(bank.group_key);\n registerSupplements(decision.selected);\n\n rows.push({\n id: `possible_wrong_account_${payroll.group_key}_${bank.group_key}`,\n employee: payroll.employee_name || bestBankDisplayName(bank),\n employee_name:\n payroll.employee_name || bestBankDisplayName(bank),\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: payroll.currency,\n payrollAmount: decision.effectiveAmount,\n payroll_amount: decision.effectiveAmount,\n payrollBaseAmount: decision.baseAmount,\n payroll_base_amount: decision.baseAmount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: 0,\n status: 'Riesgo',\n category: 'posible_cuenta_mal_digitada',\n subcategory:\n 'nombre_y_monto_coinciden_cuenta_diferente',\n observation:\n `El nombre y el monto coinciden, pero la cuenta de nómina ` +\n `(${payroll.account || 'sin cuenta'}) es diferente a la cuenta ` +\n `del banco (${bank.account || 'sin cuenta válida'}).`,\n applied_supplements: decision.selected,\n source_sheets: sources.sourceSheets,\n source_rows: sources.sourceRows,\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 3) Nómina sin cuenta válida: conciliar por nombre y monto.\nfor (\n let index = 0;\n index < payrollNoAccountRows.length;\n index++\n) {\n const payroll = payrollNoAccountRows[index];\n\n const candidates = bankAccounts.filter((bank) => {\n if (matchedBankKeys.has(bank.group_key)) return false;\n if (bank.currency !== payroll.currency) return false;\n if (!moneyEquals(bank.amount, payroll.payroll_amount)) {\n return false;\n }\n\n return bankMatchesName(bank, payroll.employee_name);\n });\n\n if (candidates.length !== 1) continue;\n\n const bank = candidates[0];\n\n matchedNoAccountIndexes.add(index);\n matchedBankKeys.add(bank.group_key);\n\n rows.push({\n id: `match_no_account_${index}_${bank.group_key}`,\n employee:\n payroll.employee_name || bestBankDisplayName(bank),\n employee_name:\n payroll.employee_name || bestBankDisplayName(bank),\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: bank.account,\n payrollAccount: '',\n payroll_account: '',\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: payroll.currency,\n payrollAmount: payroll.payroll_amount,\n payroll_amount: payroll.payroll_amount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: 0,\n status: 'Coincidencia',\n category: 'coincidencia',\n subcategory:\n 'conciliado_por_nombre_y_monto_sin_cuenta_nomina',\n observation:\n 'Conciliado por nombre y monto. La nómina no tenía una cuenta bancaria válida.',\n source_sheet: payroll.source_sheet,\n row_number: payroll.row_number,\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 4) Diferencias reales en una cuenta exacta o equivalente.\nfor (const payroll of payrollAccounts) {\n if (matchedPayrollKeys.has(payroll.group_key)) continue;\n\n const candidates = getDirectCandidates(\n payroll,\n matchedBankKeys\n );\n\n if (!candidates.length) continue;\n\n const candidate = candidates[0];\n const bank = candidate.bank;\n const decision = candidate.supplementDecision;\n const sources = buildSources(payroll, decision.selected);\n const difference = moneyDiff(\n decision.effectiveAmount,\n bank.amount\n );\n\n matchedPayrollKeys.add(payroll.group_key);\n matchedBankKeys.add(bank.group_key);\n registerSupplements(decision.selected);\n\n rows.push({\n id: `difference_${payroll.group_key}_${bank.group_key}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: payroll.currency,\n payrollAmount: decision.effectiveAmount,\n payroll_amount: decision.effectiveAmount,\n payrollBaseAmount: decision.baseAmount,\n payroll_base_amount: decision.baseAmount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference,\n status: 'Riesgo',\n category: 'discrepancia',\n subcategory: 'diferencia_monto',\n observation:\n `Diferencia de ${payroll.currency} ` +\n `${formatMoney(difference)}.`,\n applied_supplements: decision.selected,\n source_sheets: sources.sourceSheets,\n source_rows: sources.sourceRows,\n bank_source_rows: bank.source_rows,\n });\n}\n\n\n// 4.5) Reconciliación final exacta de pares residuales.\n//\n// Este paso corrige casos en los que nómina y banco contienen:\n// - la misma cuenta normalizada;\n// - el mismo empleado;\n// - el mismo monto;\n// pero no fueron enlazados en los pasos anteriores por diferencias técnicas\n// de agrupación, moneda inferida o metadatos del CSV.\n//\n// Es deliberadamente conservador: exige una única contraparte bancaria.\nfor (const payroll of payrollAccounts) {\n if (matchedPayrollKeys.has(payroll.group_key)) continue;\n\n const candidates = bankAccounts\n .filter((bank) => {\n if (matchedBankKeys.has(bank.group_key)) return false;\n\n const payrollAccount = normalizeAccount(payroll.account);\n const bankAccount = normalizeAccount(bank.account);\n\n if (!payrollAccount || payrollAccount !== bankAccount) {\n return false;\n }\n\n if (!bankMatchesName(bank, payroll.employee_name)) {\n return false;\n }\n\n const decision = chooseConditionalSupplements(payroll, bank);\n\n return moneyEquals(\n decision.effectiveAmount,\n bank.amount\n );\n })\n .map((bank) => ({\n bank,\n supplementDecision: chooseConditionalSupplements(\n payroll,\n bank\n ),\n }));\n\n if (candidates.length !== 1) continue;\n\n const candidate = candidates[0];\n const bank = candidate.bank;\n const decision = candidate.supplementDecision;\n const sources = buildSources(\n payroll,\n decision.selected\n );\n\n matchedPayrollKeys.add(payroll.group_key);\n matchedBankKeys.add(bank.group_key);\n registerSupplements(decision.selected);\n\n finalExactReconciliations.push({\n employee_name: payroll.employee_name,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payroll_currency: payroll.currency,\n bank_currency: bank.currency,\n payroll_amount: decision.effectiveAmount,\n bank_amount: bank.amount,\n payroll_group_key: payroll.group_key,\n bank_group_key: bank.group_key,\n });\n\n rows.push({\n id: `final_exact_match_${payroll.group_key}_${bank.group_key}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: bank.currency || payroll.currency,\n payrollAmount: decision.effectiveAmount,\n payroll_amount: decision.effectiveAmount,\n payrollBaseAmount: decision.baseAmount,\n payroll_base_amount: decision.baseAmount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: 0,\n status: 'Coincidencia',\n category: 'coincidencia',\n subcategory: 'reconciliacion_final_cuenta_nombre_monto',\n observation:\n 'Conciliado por cuenta, nombre y monto en la validación final.',\n applied_supplements: decision.selected,\n source_sheets: sources.sourceSheets,\n source_rows: sources.sourceRows,\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 5) Nómina con cuenta sin pago bancario.\nfor (const payroll of payrollAccounts) {\n if (matchedPayrollKeys.has(payroll.group_key)) continue;\n\n rows.push({\n id: `payroll_without_bank_${payroll.group_key}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: '',\n bank_account: '',\n currency: payroll.currency,\n payrollAmount: payroll.payroll_amount,\n payroll_amount: payroll.payroll_amount,\n payrollBaseAmount: payroll.payroll_amount,\n payroll_base_amount: payroll.payroll_amount,\n bankAmount: 0,\n bank_amount: 0,\n difference: payroll.payroll_amount,\n status: 'Riesgo',\n category: 'discrepancia',\n subcategory: 'nomina_con_cuenta_sin_pago_banco',\n observation:\n 'Está en nómina, pero no aparece pagado en el banco.',\n applied_supplements: [],\n source_sheets: payroll.source_sheets,\n source_rows: payroll.source_rows,\n });\n}\n\n// 6) Banco sin nómina.\nfor (const bank of bankAccounts) {\n if (matchedBankKeys.has(bank.group_key)) continue;\n\n rows.push({\n id: `bank_without_payroll_${bank.group_key}`,\n employee:\n bestBankDisplayName(bank) || 'Pago bancario sin nómina',\n employee_name:\n bestBankDisplayName(bank) || 'Pago bancario sin nómina',\n employeeNumber: '',\n employee_number: '',\n account: bank.account,\n payrollAccount: '',\n payroll_account: '',\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: bank.currency,\n payrollAmount: 0,\n payroll_amount: 0,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: roundMoney(0 - bank.amount),\n status: 'Pendiente revisión',\n category: 'banco_sin_nomina',\n subcategory: 'pago_banco_sin_fila_nomina',\n observation:\n 'Recibió un pago en el banco, pero no aparece en la nómina cargada.',\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 7) Nómina sin cuenta que no pudo conciliarse.\nfor (\n let index = 0;\n index < payrollNoAccountRows.length;\n index++\n) {\n if (matchedNoAccountIndexes.has(index)) continue;\n\n const payroll = payrollNoAccountRows[index];\n\n rows.push({\n id:\n `payroll_without_account_` +\n `${payroll.source_sheet}_${payroll.row_number}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: '',\n payrollAccount: '',\n payroll_account: '',\n bankAccount: '',\n bank_account: '',\n currency: payroll.currency,\n payrollAmount: payroll.payroll_amount,\n payroll_amount: payroll.payroll_amount,\n bankAmount: 0,\n bank_amount: 0,\n difference: payroll.payroll_amount,\n status: 'Pendiente revisión',\n category: 'nomina_sin_cuenta',\n subcategory: 'nomina_sin_cuenta_bancaria',\n observation:\n 'Tiene monto en nómina, pero no tiene una cuenta bancaria válida para cruzar contra el banco.',\n source_sheet: payroll.source_sheet,\n row_number: payroll.row_number,\n });\n}\n\n// 8) Consolidar el mismo empleado cuando aparece con dos cuentas de nómina.\nconst originalRows = [...rows];\nconst usedRowIds = new Set();\nconst consolidatedRows = [];\n\nfor (const differenceRow of originalRows) {\n if (\n differenceRow.category !== 'discrepancia' ||\n differenceRow.subcategory !== 'diferencia_monto' ||\n usedRowIds.has(differenceRow.id)\n ) {\n continue;\n }\n\n const extraPayrollRow = originalRows.find((candidate) => {\n if (\n candidate.id === differenceRow.id ||\n usedRowIds.has(candidate.id) ||\n candidate.subcategory !==\n 'nomina_con_cuenta_sin_pago_banco' ||\n candidate.currency !== differenceRow.currency\n ) {\n return false;\n }\n\n const samePerson = samePersonName(\n differenceRow.employee_name || differenceRow.employee,\n candidate.employee_name || candidate.employee\n );\n\n const similarAccounts =\n accountDistance(\n differenceRow.account,\n candidate.account\n ) <= 2;\n\n const combinedPayroll = roundMoney(\n differenceRow.payroll_amount +\n candidate.payroll_amount\n );\n\n const totalMatches = moneyEquals(\n combinedPayroll,\n differenceRow.bank_amount\n );\n\n return samePerson && similarAccounts && totalMatches;\n });\n\n if (!extraPayrollRow) continue;\n\n usedRowIds.add(differenceRow.id);\n usedRowIds.add(extraPayrollRow.id);\n\n const totalPayroll = roundMoney(\n differenceRow.payroll_amount +\n extraPayrollRow.payroll_amount\n );\n\n const accounts = Array.from(new Set([\n differenceRow.account,\n extraPayrollRow.account,\n ].filter(Boolean)));\n\n consolidatedRows.push({\n id:\n `split_account_` +\n `${differenceRow.account}_${extraPayrollRow.account}`,\n employee: differenceRow.employee_name,\n employee_name: differenceRow.employee_name,\n employeeNumber:\n differenceRow.employee_number ||\n extraPayrollRow.employee_number ||\n '',\n employee_number:\n differenceRow.employee_number ||\n extraPayrollRow.employee_number ||\n '',\n account:\n differenceRow.bank_account ||\n differenceRow.account,\n payrollAccount: accounts.join(' / '),\n payroll_account: accounts.join(' / '),\n bankAccount: differenceRow.bank_account,\n bank_account: differenceRow.bank_account,\n currency: differenceRow.currency,\n payrollAmount: totalPayroll,\n payroll_amount: totalPayroll,\n bankAmount: differenceRow.bank_amount,\n bank_amount: differenceRow.bank_amount,\n difference: moneyDiff(\n totalPayroll,\n differenceRow.bank_amount\n ),\n status: 'Riesgo',\n category: 'posible_cuenta_mal_digitada',\n subcategory:\n 'mismo_empleado_con_cuentas_distintas_en_nomina',\n observation:\n `El total de nómina coincide con el banco, pero el empleado ` +\n `aparece con cuentas distintas en la nómina: ` +\n `${accounts.join(' y ')}. La cuenta utilizada por el banco ` +\n `fue ${differenceRow.bank_account}.`,\n applied_supplements:\n differenceRow.applied_supplements || [],\n source_sheets: Array.from(new Set([\n ...(differenceRow.source_sheets || []),\n ...(extraPayrollRow.source_sheets || []),\n ])),\n source_rows: [\n ...(differenceRow.source_rows || []),\n ...(extraPayrollRow.source_rows || []),\n ],\n bank_source_rows:\n differenceRow.bank_source_rows || [],\n });\n}\n\nconst coreRows = [\n ...originalRows.filter(\n (row) => !usedRowIds.has(row.id)\n ),\n ...consolidatedRows,\n];\n\nconst coreCoincidencias = coreRows.filter(\n (row) => row.category === 'coincidencia'\n).length;\n\nconst coreDiscrepancias = coreRows.filter((row) => {\n return (\n row.category === 'discrepancia' ||\n row.category === 'posible_cuenta_mal_digitada'\n );\n}).length;\n\nconst coreBancoSinNomina = coreRows.filter(\n (row) => row.category === 'banco_sin_nomina'\n).length;\n\nconst coreNominaSinCuenta = coreRows.filter(\n (row) => row.category === 'nomina_sin_cuenta'\n).length;\n\nconst corePosiblesCuentas = coreRows.filter(\n (row) => row.category === 'posible_cuenta_mal_digitada'\n).length;\n\nconst linkedPayrollNamesByBankRow = new Map();\nconst linkedPayrollNumbersByBankRow = new Map();\n\nfor (const reconciliationRow of coreRows) {\n const linkedName =\n reconciliationRow.employee_name ||\n reconciliationRow.employee ||\n '';\n const linkedEmployeeNumber = normalizeAccount(\n reconciliationRow.employee_number ||\n reconciliationRow.employeeNumber ||\n ''\n );\n\n for (\n const bankSourceRow of\n reconciliationRow.bank_source_rows || []\n ) {\n const rowKey = bankRowKey(bankSourceRow);\n\n const names =\n linkedPayrollNamesByBankRow.get(rowKey) || [];\n const numbers =\n linkedPayrollNumbersByBankRow.get(rowKey) || [];\n\n if (linkedName) names.push(linkedName);\n if (linkedEmployeeNumber.length >= 6) {\n numbers.push(linkedEmployeeNumber);\n }\n\n linkedPayrollNamesByBankRow.set(\n rowKey,\n Array.from(new Set(names))\n );\n linkedPayrollNumbersByBankRow.set(\n rowKey,\n Array.from(new Set(numbers))\n );\n }\n}\n\nconst bambooMatchDetails = [];\nconst bambooExcludedPayments = [];\nconst bankWithoutBambooMap = new Map();\n\nif (bambooValidationAvailable) {\nfor (const bankRow of bankDetailRows) {\n if (isClearlyNonEmployeePayment(bankRow)) {\n bambooExcludedPayments.push({\n source_file: bankRow.source_file,\n row_number: bankRow.row_number,\n reason: 'pago_no_empleado_identificado',\n bank_name_file: bankRow.bank_name_file,\n bank_account_holder:\n bankRow.bank_account_holder,\n amount: bankRow.amount,\n currency: bankRow.currency,\n });\n continue;\n }\n\n const validatedAliasMatch =\n findValidatedAliasMatch(bankRow);\n\n const match =\n validatedAliasMatch ||\n findBambooMatch(bankRow);\n\n if (match.found) {\n bambooMatchDetails.push({\n source_file: bankRow.source_file,\n row_number: bankRow.row_number,\n account: bankRow.account,\n amount: bankRow.amount,\n currency: bankRow.currency,\n bank_name_file: bankRow.bank_name_file,\n bank_account_holder:\n bankRow.bank_account_holder,\n matched_by: match.matched_by,\n confidence: roundMoney(match.confidence),\n validated_alias_id:\n match.validated_alias_id || '',\n validated_alias_resolution:\n match.validated_alias_resolution || '',\n bamboo_employee_number:\n match.employee?.employee_number || '',\n bamboo_employee_name:\n match.employee?.full_name || '',\n bamboo_status:\n match.employee?.status || '',\n bamboo_overlaps_period:\n Boolean(match.employee?.overlaps_period),\n });\n continue;\n }\n\n const displayName =\n bankRow.bank_name_file ||\n bankRow.bank_account_holder ||\n 'Pago bancario sin empleado identificado';\n\n const groupingKey = [\n normalizeAccount(bankRow.account),\n normalizeName(displayName),\n bankRow.currency || 'QTZ',\n ].join('|');\n\n const current =\n bankWithoutBambooMap.get(groupingKey) || {\n id: `bank_without_bamboo_${groupingKey}`,\n employee: displayName,\n employee_name: displayName,\n bank_name_file:\n bankRow.bank_name_file || '',\n bank_account_holder:\n bankRow.bank_account_holder || '',\n account: normalizeAccount(bankRow.account),\n bankAccount: normalizeAccount(bankRow.account),\n bank_account: normalizeAccount(bankRow.account),\n currency: bankRow.currency || 'QTZ',\n bankAmount: 0,\n bank_amount: 0,\n shipment_numbers: new Set(),\n references: new Set(),\n source_files: new Set(),\n source_rows: [],\n status: 'Pendiente revisión',\n category: 'banco_sin_bamboo',\n subcategory:\n 'pago_bancario_sin_empleado_bamboohr_gt',\n observation:\n 'Se encontró un pago en el banco, pero no se encontró una coincidencia confiable con un empleado de Guatemala en BambooHR.',\n best_bamboo_candidate:\n match.best_candidate\n ? {\n employee_number:\n match.best_candidate.employee\n ?.employee_number || '',\n employee_name:\n match.best_candidate.employee\n ?.full_name || '',\n score: roundMoney(\n match.best_candidate.score\n ),\n }\n : null,\n ambiguous_bamboo_match:\n Boolean(match.ambiguous),\n };\n\n current.bankAmount = roundMoney(\n current.bankAmount +\n Number(bankRow.amount || 0)\n );\n current.bank_amount = current.bankAmount;\n\n if (bankRow.shipment_number) {\n current.shipment_numbers.add(\n bankRow.shipment_number\n );\n }\n\n if (bankRow.reference) {\n current.references.add(bankRow.reference);\n }\n\n if (bankRow.source_file) {\n current.source_files.add(\n bankRow.source_file\n );\n }\n\n current.source_rows.push(bankRow);\n bankWithoutBambooMap.set(\n groupingKey,\n current\n );\n}\n}\n\nconst bankWithoutBamboo = Array.from(\n bankWithoutBambooMap.values()\n).map((row) => ({\n ...row,\n shipment_numbers: Array.from(\n row.shipment_numbers\n ),\n references: Array.from(row.references),\n source_files: Array.from(row.source_files),\n difference: roundMoney(\n 0 - row.bank_amount\n ),\n}));\n\nconst nameDifferenceRows = (\n data.bank?.name_differences || []\n).map((row) => ({\n id: row.id,\n employee: row.bank_name_file,\n employee_name: row.bank_name_file,\n employeeNumber: '',\n employee_number: '',\n account: row.account,\n payrollAccount: '',\n payroll_account: '',\n bankAccount: row.account,\n bank_account: row.account,\n currency: row.currency || 'QTZ',\n payrollAmount: 0,\n payroll_amount: 0,\n bankAmount: row.amount,\n bank_amount: row.amount,\n difference: 0,\n status: 'Pendiente revisión',\n category: 'diferencia_nombre_banco',\n subcategory: 'nombre_archivo_vs_cuentahabiente',\n observation: row.observation,\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,\n}));\n\nfunction priority(row) {\n const category = String(\n row.category || ''\n ).toLowerCase();\n\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 === 'nomina_sin_cuenta') return 4;\n if (category === 'diferencia_nombre_banco') return 5;\n if (category === 'coincidencia') return 99;\n\n return 50;\n}\n\nconst rowsFinales = [\n ...coreRows,\n ...nameDifferenceRows,\n].sort((a, b) => {\n const priorityDifference =\n priority(a) - priority(b);\n\n if (priorityDifference !== 0) {\n return priorityDifference;\n }\n\n return String(\n a.employee_name || ''\n ).localeCompare(\n String(b.employee_name || ''),\n 'es'\n );\n});\n\nconst appliedSupplementsTotal = roundMoney(\n appliedSupplements.reduce(\n (sum, row) => sum + row.payroll_amount,\n 0\n )\n);\n\nconst payrollTotalsByCurrency =\n data.payroll?.totals_by_currency || {};\nconst bankTotalsByCurrency =\n data.bank?.totals_by_currency || {};\n\nconst totalNominaBase = roundMoney(\n payrollTotalsByCurrency.QTZ ??\n payrollTotalsByCurrency.GTQ ??\n data.payroll?.total_amount ??\n 0\n);\n\nconst totalNomina = roundMoney(\n totalNominaBase + appliedSupplementsTotal\n);\n\nconst totalBanco = roundMoney(\n bankTotalsByCurrency.QTZ ??\n bankTotalsByCurrency.GTQ ??\n data.bank?.total_amount ??\n 0\n);\n\nconst totalNominaUSD = roundMoney(\n payrollTotalsByCurrency.USD ?? 0\n);\nconst totalBancoUSD = roundMoney(\n bankTotalsByCurrency.USD ?? 0\n);\nconst diferenciaUSD =\n moneyDiff(totalNominaUSD, totalBancoUSD);\n\nconst diferenciasNombreBanco =\n nameDifferenceRows.length;\n\nconst pendientes =\n coreDiscrepancias +\n coreBancoSinNomina +\n coreNominaSinCuenta +\n diferenciasNombreBanco;\n\nconst unusedPotentialSupplements =\n potentialSupplements.filter((row) => {\n return !appliedSupplementKeys.has(\n supplementKey(row)\n );\n });\n\nconst bambooSummaryCompact = {\n ...(data.bamboo || {}),\n};\n\n/*\n * El arreglo completo de empleados solo se necesita dentro de este nodo.\n * No se reenvía a Google Sheets, Supabase ni al webhook para evitar cargar\n * cerca de 1 MB innecesario en todos los nodos posteriores.\n */\ndelete bambooSummaryCompact.employees;\n\nreturn [\n {\n json: {\n ok: true,\n stage: 'cruce_nomina_completa_banco_condicional',\n errors: [],\n metadata: data.metadata || {},\n summary: {\n coincidencias: coreCoincidencias,\n discrepancias: coreDiscrepancias,\n bancoSinNomina: coreBancoSinNomina,\n bancoSinBamboo: bankWithoutBamboo.length,\n nominaSinCuenta: coreNominaSinCuenta,\n diferenciasNombreBanco,\n posiblesCuentasMalDigitadas:\n corePosiblesCuentas,\n pendientes,\n filasNominaValidas:\n data.payroll?.valid_rows_count || 0,\n filasNominaSinCuenta:\n data.payroll?.no_account_rows_count || 0,\n suplementosPotenciales:\n potentialSupplements.length,\n suplementosNominaAplicados:\n appliedSupplements.length,\n suplementosNominaNoAplicados:\n unusedPotentialSupplements.length,\n suplementosNominaAdjuntados:\n appliedSupplements.length,\n suplementosNominaNoAdjuntados:\n data.payroll?.unattached_supplements_count || 0,\n reconciliacionesExactasFinales:\n finalExactReconciliations.length,\n cuentasNominaAgrupadas:\n payrollAccounts.length,\n transaccionesBanco:\n data.bank?.rows_count || 0,\n cuentasBancoAgrupadas:\n bankAccounts.length,\n empleadosBambooGT:\n bambooEmployees.length,\n empleadosBambooEnPeriodo:\n Number(\n data.bamboo?.active_in_period_count || 0\n ),\n bambooPaginasDescargadas:\n Number(\n data.bamboo?.pages_fetched || 0\n ),\n bambooEmpleadosEsperados:\n Number(\n data.bamboo?.expected_total || 0\n ),\n bambooDescargaCompleta:\n Boolean(\n data.bamboo?.fetch_complete\n ),\n bambooValidacionDisponible:\n bambooValidationAvailable,\n totalNominaBase,\n totalSuplementosAplicados:\n appliedSupplementsTotal,\n // Compatibilidad histórica: totalNomina/totalBanco/diferenciaTotal\n // representan QTZ. Los USD viajan por campos separados.\n totalNomina,\n totalBanco,\n diferenciaTotal:\n moneyDiff(totalNomina, totalBanco),\n totalNominaQTZ: totalNomina,\n totalBancoQTZ: totalBanco,\n diferenciaQTZ:\n moneyDiff(totalNomina, totalBanco),\n totalNominaUSD,\n totalBancoUSD,\n diferenciaUSD,\n totalesNominaPorMoneda: {\n QTZ: totalNomina,\n USD: totalNominaUSD,\n },\n totalesBancoPorMoneda: {\n QTZ: totalBanco,\n USD: totalBancoUSD,\n },\n diferenciaPorMoneda: {\n QTZ: moneyDiff(totalNomina, totalBanco),\n USD: diferenciaUSD,\n },\n },\n rows: rowsFinales,\n bankWithoutBamboo,\n nameDifferences: nameDifferenceRows,\n bambooSummary: bambooSummaryCompact,\n reportUrl: null,\n debug: {\n sheet_summaries:\n data.payroll?.sheet_summaries || [],\n potential_supplements:\n potentialSupplements,\n applied_supplements:\n appliedSupplements,\n final_exact_reconciliations:\n finalExactReconciliations,\n bamboo_search:\n {\n employees_indexed:\n bambooSearch.records.length,\n exact_aliases:\n bambooSearch.exactAliasMap.size,\n indexed_tokens:\n bambooSearch.tokenIndex.size,\n cache_entries:\n bambooMatchCache.size,\n },\n validated_bamboo_aliases_loaded:\n validatedBambooAliases.length,\n validated_bamboo_alias_matches:\n bambooMatchDetails.filter(\n (match) => match.matched_by === 'validated_bamboo_alias'\n ).length,\n bamboo_matches:\n bambooMatchDetails,\n bamboo_excluded_payments:\n bambooExcludedPayments,\n bamboo_validation_available:\n bambooValidationAvailable,\n bamboo_validation_warning:\n bambooValidationWarning,\n banco_sin_bamboo:\n bankWithoutBamboo,\n unused_potential_supplements:\n unusedPotentialSupplements,\n unattached_supplements:\n data.debug_payroll?.unattached_supplements || [],\n payroll_preview:\n payrollAccounts.slice(0, 10),\n bank_preview:\n bankAccounts.slice(0, 10),\n payroll_no_account_preview:\n payrollNoAccountRows.slice(0, 10),\n },\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
7824,
3120
],
"id": "9ab13ae8-f5f1-439a-84cf-875b06ba593c",
"name": "Cruzar Bono 14 vs Banco"
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": false,
"sheetName": "P&G"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
1344,
2112
],
"id": "b7121eac-c58a-4378-bf17-8866ffc818a0",
"name": "Extract Bono14 - P&G",
"retryOnFail": false
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": false,
"sheetName": "B14 MOTO"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
1344,
2352
],
"id": "dd787c02-74cb-453f-8611-b515b026c6ab",
"name": "Extract Bono14 - Motorola",
"retryOnFail": false,
"onError": "continueRegularOutput"
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": false,
"sheetName": "B14 WP"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
1344,
2592
],
"id": "9dd753e9-e9e7-47e9-8dce-c9ceef7accfd",
"name": "Extract Bono14 - Whirlpool",
"retryOnFail": false,
"onError": "continueRegularOutput"
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": false,
"sheetName": "PMI"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
1360,
2784
],
"id": "ef502b8a-3ea6-42a9-b911-70d5e72ea877",
"name": "Extract Bono14 - PMI",
"retryOnFail": false,
"onError": "continueRegularOutput"
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": false,
"sheetName": "B14 Nestle"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
1360,
2992
],
"id": "db4c4942-830e-47c2-a8e2-982252a1ebc8",
"name": "Extract Bono14 - Nestle",
"retryOnFail": false,
"onError": "continueRegularOutput"
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": false,
"sheetName": "B14 GLM Q"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
1360,
3200
],
"id": "14b7b95a-d583-4788-a8b0-7afab135d0e4",
"name": "Extract Bono14 - GLM Q",
"retryOnFail": false,
"onError": "continueRegularOutput"
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": false,
"sheetName": "B14 GLM $"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
1376,
3536
],
"id": "2c5de156-7bf8-4d69-b71d-bcb38f98b332",
"name": "Extract Bono14 - GLM USD",
"retryOnFail": false,
"onError": "continueRegularOutput"
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": false,
"sheetName": "RESUMEN"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
1360,
3872
],
"id": "24b36bbe-2a0b-4058-a4c6-a47f5103991e",
"name": "Extract Bono14 - Resumen 1",
"retryOnFail": false,
"onError": "continueRegularOutput"
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": false,
"sheetName": "RESUMEN"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
1360,
4160
],
"id": "cd86a313-2e21-435c-9725-31098528cae1",
"name": "Extract Bono14 - Resumen 2",
"retryOnFail": false,
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "function normalizeText(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\u00A0/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeForCompare(value) {\n return normalizeText(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 if (value === null || value === undefined || value === '') return '';\n\n if (typeof value === 'number') {\n return String(Math.trunc(value));\n }\n\n return String(value)\n .replace(/\\u00A0/g, '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim();\n}\n\nfunction parseMoney(value) {\n if (typeof value === 'number') {\n return Number.isFinite(value) ? value : 0;\n }\n\n const cleaned = String(value ?? '')\n .replace(/USD/gi, '')\n .replace(/GTQ/gi, '')\n .replace(/QTZ/gi, '')\n .replace(/Q/gi, '')\n .replace(/\\$/g, '')\n .replace(/,/g, '')\n .replace(/\\s+/g, '')\n .trim();\n\n const parsed = Number.parseFloat(cleaned);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction roundMoney(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n}\n\nfunction getNodeRows(nodeName) {\n try {\n return $items(nodeName)\n .map((item) => item.json || {})\n .filter((row) => {\n const text = JSON.stringify(row || {}).toLowerCase();\n if (row.error) return false;\n if (text.includes('spreadsheet does not contain sheet')) return false;\n if (text.includes('no sheet')) return false;\n return true;\n });\n } catch (error) {\n return [];\n }\n}\n\nfunction excelColumnIndex(letters) {\n const raw = String(letters || '').trim().toUpperCase();\n if (!/^[A-Z]+$/.test(raw)) return -1;\n\n let value = 0;\n for (const character of raw) {\n value = value * 26 + (character.charCodeAt(0) - 64);\n }\n return value - 1;\n}\n\nfunction rawRowValues(row) {\n if (Array.isArray(row?.row)) return row.row;\n if (Array.isArray(row)) return row;\n if (!row || typeof row !== 'object') return [];\n\n const keys = Object.keys(row);\n\n // Extract From File con headerRow=false normalmente conserva el orden\n // posicional como índices numéricos. Esta es la ruta principal.\n const numericKeys = keys\n .filter((key) => /^\\d+$/.test(key))\n .sort((a, b) => Number(a) - Number(b));\n\n if (numericKeys.length) {\n const maxIndex = Number(numericKeys[numericKeys.length - 1]);\n const values = new Array(maxIndex + 1).fill(null);\n for (const key of numericKeys) values[Number(key)] = row[key];\n return values;\n }\n\n // Respaldo para representaciones con letras de Excel: A, B, ..., AA.\n const letterKeys = keys.filter((key) => /^[A-Z]{1,3}$/i.test(key));\n if (letterKeys.length) {\n const indexed = letterKeys\n .map((key) => ({ key, index: excelColumnIndex(key) }))\n .filter((entry) => entry.index >= 0)\n .sort((left, right) => left.index - right.index);\n\n if (indexed.length) {\n const values = new Array(indexed[indexed.length - 1].index + 1).fill(null);\n for (const entry of indexed) values[entry.index] = row[entry.key];\n return values;\n }\n }\n\n // Respaldo para objetos generados con nombres tipo column_1 / Column 1.\n const generatedColumns = keys\n .map((key) => {\n const match = key.match(/^column[_\\s-]?(\\d+)$/i);\n return match ? { key, index: Math.max(0, Number(match[1]) - 1) } : null;\n })\n .filter(Boolean)\n .sort((left, right) => left.index - right.index);\n\n if (generatedColumns.length) {\n const values = new Array(generatedColumns[generatedColumns.length - 1].index + 1).fill(null);\n for (const entry of generatedColumns) values[entry.index] = row[entry.key];\n return values;\n }\n\n return [];\n}\n\nfunction detectBonus14Columns(sourceRows, config) {\n const detected = {\n accountIndex: config.accountIndex,\n nameIndex: config.nameIndex,\n emailIndex: config.emailIndex,\n amountIndex: config.amountIndex,\n detectedFromHeader: false,\n headerRowIndex: -1,\n };\n\n const previewRows = sourceRows.slice(0, 12);\n\n for (let previewIndex = 0; previewIndex < previewRows.length; previewIndex++) {\n const sourceRow = previewRows[previewIndex];\n const values = rawRowValues(sourceRow);\n if (!values.length) continue;\n\n const normalized = values.map((value) => normalizeForCompare(value));\n\n const accountIndex = normalized.findIndex((value) =>\n value === 'cuenta' ||\n value === 'cuenta no' ||\n value === 'numero de cuenta' ||\n value === 'account number'\n );\n\n const nameIndex = normalized.findIndex((value) =>\n value === 'nombre' ||\n value === 'nombre completo' ||\n value === 'empleado' ||\n value === 'name'\n );\n\n const emailIndex = normalized.findIndex((value) =>\n value === 'email' ||\n value === 'correo' ||\n value === 'correo electronico'\n );\n\n const amountCandidates = normalized\n .map((value, index) => ({ value, index }))\n .filter(({ value }) =>\n (value.includes('bono 14') || value.includes('bono14')) &&\n !value.includes('dia bono')\n );\n\n const amountIndex = amountCandidates.length\n ? amountCandidates[amountCandidates.length - 1].index\n : -1;\n\n if (\n accountIndex >= 0 &&\n nameIndex >= 0 &&\n amountIndex >= 0\n ) {\n detected.accountIndex = accountIndex;\n detected.nameIndex = nameIndex;\n detected.amountIndex = amountIndex;\n if (emailIndex >= 0) detected.emailIndex = emailIndex;\n detected.detectedFromHeader = true;\n detected.headerRowIndex = previewIndex;\n break;\n }\n }\n\n return detected;\n}\n\nfunction validEmployeeName(value) {\n const name = normalizeText(value);\n const normalized = normalizeForCompare(name);\n\n if (!name) return false;\n if (/^[\\d.,\\s]+$/.test(name)) return false;\n\n const invalid = [\n 'nombre',\n 'nombre completo',\n 'empleado',\n 'total',\n 'subtotal',\n 'gran total',\n 'total general',\n 'bono 14',\n 'bono14',\n ];\n\n if (\n invalid.some((token) =>\n normalized === normalizeForCompare(token) ||\n normalized.startsWith(`${normalizeForCompare(token)} `)\n )\n ) {\n return false;\n }\n\n return name.split(/\\s+/).filter(Boolean).length >= 2;\n}\n\nfunction validAccount(value) {\n const account = normalizeAccount(value);\n return account.length >= 7 && !/^0+$/.test(account);\n}\n\nconst configs = [\n {\n node: 'Extract Bono14 - P&G',\n sheet: 'P&G',\n accountIndex: 0,\n nameIndex: 1,\n emailIndex: 6,\n amountIndex: 21,\n currency: 'QTZ',\n },\n {\n node: 'Extract Bono14 - Motorola',\n sheet: 'B14 MOTO',\n accountIndex: 0,\n nameIndex: 3,\n emailIndex: 7,\n amountIndex: 46,\n currency: 'QTZ',\n },\n {\n node: 'Extract Bono14 - Whirlpool',\n sheet: 'B14 WP',\n accountIndex: 0,\n nameIndex: 3,\n emailIndex: 7,\n amountIndex: 34,\n currency: 'QTZ',\n },\n {\n node: 'Extract Bono14 - PMI',\n sheet: 'PMI',\n accountIndex: 0,\n nameIndex: 3,\n emailIndex: 7,\n amountIndex: 34,\n currency: 'QTZ',\n },\n {\n node: 'Extract Bono14 - Nestle',\n sheet: 'B14 Nestle',\n accountIndex: 0,\n nameIndex: 3,\n emailIndex: 7,\n amountIndex: 22,\n currency: 'QTZ',\n },\n {\n node: 'Extract Bono14 - GLM Q',\n sheet: 'B14 GLM Q',\n accountIndex: 0,\n nameIndex: 3,\n emailIndex: 6,\n amountIndex: 33,\n currency: 'QTZ',\n },\n {\n node: 'Extract Bono14 - GLM USD',\n sheet: 'B14 GLM $',\n accountIndex: 0,\n nameIndex: 3,\n emailIndex: 6,\n amountIndex: 33,\n currency: 'USD',\n },\n];\n\nconst payrollRows = [];\nconst noAccountRows = [];\nconst ignoredRows = [];\nconst sheetSummaries = [];\n\nfor (const config of configs) {\n const sourceRows = getNodeRows(config.node);\n const columns = detectBonus14Columns(sourceRows, config);\n let validRows = 0;\n let noAccountCount = 0;\n let ignoredCount = 0;\n let sheetTotal = 0;\n\n let officialSheetTotal = null;\n let officialTotalRow = null;\n\n for (\n let sourceIndex = 0;\n sourceIndex < sourceRows.length;\n sourceIndex++\n ) {\n const sourceRow = sourceRows[sourceIndex];\n const values = rawRowValues(sourceRow);\n\n if (!values.length) {\n ignoredCount += 1;\n continue;\n }\n\n const employeeName = normalizeText(values[columns.nameIndex]);\n const account = normalizeAccount(values[columns.accountIndex]);\n const email = normalizeText(values[columns.emailIndex]).toLowerCase();\n const amount = roundMoney(parseMoney(values[columns.amountIndex]));\n\n /*\n * Las hojas del Bono 14 tienen un TOTAL OFICIAL y, en algunos casos,\n * filas auxiliares/\"Ajustes\" debajo de ese total. Esas filas posteriores\n * no forman parte del monto canónico que RESUMEN envía al banco.\n *\n * Por eso el lector cierra la tabla al encontrar la primera fila, después\n * del encabezado, sin empleado/cuenta pero con un monto positivo en la\n * columna Bono 14. Con el archivo 2026 esto identifica exactamente los\n * totales oficiales de las siete hojas.\n */\n const afterHeader =\n columns.headerRowIndex < 0 ||\n sourceIndex > columns.headerRowIndex;\n\n if (\n afterHeader &&\n !employeeName &&\n !account &&\n amount > 0\n ) {\n officialSheetTotal = amount;\n officialTotalRow = sourceIndex + 1;\n break;\n }\n\n // Los totales/formulas y encabezados quedan fuera por nombre inválido.\n if (!validEmployeeName(employeeName) || amount <= 0 || amount > 1000000) {\n ignoredRows.push({\n source_sheet: config.sheet,\n row_number: sourceIndex + 1,\n employee_name: employeeName,\n account,\n email,\n payroll_amount: amount,\n currency: config.currency,\n reason:\n !validEmployeeName(employeeName)\n ? 'invalid_employee_name'\n : amount <= 0\n ? 'amount_zero_or_invalid'\n : 'suspicious_large_amount',\n });\n ignoredCount += 1;\n continue;\n }\n\n const normalized = {\n source_sheet: config.sheet,\n row_number: sourceIndex + 1,\n employee_name: employeeName,\n employee_number: null,\n account,\n email,\n payroll_amount: amount,\n currency: config.currency,\n };\n\n sheetTotal = roundMoney(sheetTotal + amount);\n\n if (!validAccount(account)) {\n noAccountRows.push({\n ...normalized,\n account: '',\n });\n noAccountCount += 1;\n continue;\n }\n\n payrollRows.push(normalized);\n validRows += 1;\n }\n\n if (\n officialSheetTotal !== null &&\n Math.abs(roundMoney(sheetTotal - officialSheetTotal)) > 0.05\n ) {\n throw new Error(\n `El total leído en ${config.sheet} (${sheetTotal.toFixed(2)}) ` +\n `no coincide con su total oficial (${officialSheetTotal.toFixed(2)}). ` +\n 'Se detuvo el proceso para evitar generar un reporte incorrecto.'\n );\n }\n\n sheetSummaries.push({\n sheet: config.sheet,\n node: config.node,\n raw_rows_count: sourceRows.length,\n valid_rows_count: validRows,\n no_account_rows_count: noAccountCount,\n ignored_rows_count: ignoredCount,\n total_amount: sheetTotal,\n currency: config.currency,\n detected_columns: {\n account: columns.accountIndex,\n name: columns.nameIndex,\n email: columns.emailIndex,\n amount: columns.amountIndex,\n from_header: columns.detectedFromHeader,\n header_row: columns.headerRowIndex >= 0\n ? columns.headerRowIndex + 1\n : null,\n },\n official_total: officialSheetTotal,\n official_total_row: officialTotalRow,\n total_validation_ok:\n officialSheetTotal === null\n ? null\n : Math.abs(roundMoney(sheetTotal - officialSheetTotal)) <= 0.05,\n });\n}\n\nif (payrollRows.length === 0 && noAccountRows.length === 0) {\n throw new Error(\n 'No se encontraron filas válidas de Bono 14 en las hojas esperadas del Excel.'\n );\n}\n\nconst groupedMap = new Map();\n\nfor (const row of payrollRows) {\n const groupKey = `${row.account}:${row.currency}`;\n const current = groupedMap.get(groupKey) || {\n group_key: groupKey,\n account: row.account,\n employee_name: row.employee_name,\n employee_number: null,\n email: row.email,\n currency: row.currency,\n payroll_amount: 0,\n rows_count: 0,\n source_sheets: new Set(),\n source_rows: [],\n };\n\n current.payroll_amount = roundMoney(\n current.payroll_amount + row.payroll_amount\n );\n current.rows_count += 1;\n\n if (!current.email && row.email) current.email = row.email;\n if (!current.employee_name && row.employee_name) current.employee_name = row.employee_name;\n\n current.source_sheets.add(row.source_sheet);\n current.source_rows.push({\n source_sheet: row.source_sheet,\n row_number: row.row_number,\n account: row.account,\n amount: row.payroll_amount,\n employee_name: row.employee_name,\n });\n\n groupedMap.set(groupKey, current);\n}\n\nconst groupedByAccount = Array.from(groupedMap.values()).map((row) => ({\n ...row,\n source_sheets: Array.from(row.source_sheets),\n}));\n\nconst totalsByCurrency = {};\nfor (const row of [...payrollRows, ...noAccountRows]) {\n totalsByCurrency[row.currency] = roundMoney(\n (totalsByCurrency[row.currency] || 0) + row.payroll_amount\n );\n}\n\nreturn [\n {\n json: {\n payroll: {\n source: 'bono14_guatemala',\n sheets_count: configs.length,\n sheet_summaries: sheetSummaries,\n raw_rows_count: sheetSummaries.reduce(\n (sum, sheet) => sum + sheet.raw_rows_count,\n 0\n ),\n valid_rows_count: payrollRows.length,\n no_account_rows_count: noAccountRows.length,\n ignored_rows_count: ignoredRows.length,\n grouped_accounts_count: groupedByAccount.length,\n attached_supplements_count: 0,\n potential_supplements_count: 0,\n potential_supplements: [],\n unattached_supplements_count: 0,\n total_amount: roundMoney(totalsByCurrency.QTZ || 0),\n totals_by_currency: totalsByCurrency,\n rows: payrollRows,\n no_account_rows: noAccountRows,\n grouped_by_account: groupedByAccount,\n },\n debug_payroll: {\n attached_supplements: [],\n potential_supplements: [],\n unattached_supplements: [],\n ignored_rows_preview: ignoredRows.slice(0, 100),\n no_account_rows_preview: noAccountRows.slice(0, 50),\n totals_by_currency: totalsByCurrency,\n },\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
5568,
3888
],
"id": "926de4e1-8954-4f6f-830f-2e6f724f4c6e",
"name": "Normalizar Bono 14"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
2368,
2416
],
"id": "ae4ba7a6-9e0c-492f-adff-5dd5bdb20692",
"name": "Merge Hojas 01-02"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
2560,
2624
],
"id": "14da77f8-838f-4bdf-90ed-4786990b145c",
"name": "Merge Hojas 03"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
2848,
2752
],
"id": "eb0de9de-c940-45b9-8f5c-b427f9fa198c",
"name": "Merge Hojas 04"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
3264,
2880
],
"id": "0910a078-76c0-4010-8daa-ffd11381cfa8",
"name": "Merge Hojas 05"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
3568,
3024
],
"id": "eda17990-ef1d-4b96-a17c-3c68a3617ee6",
"name": "Merge Hojas 06"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
3904,
3296
],
"id": "efe11e4e-9fe0-4836-b815-beeaedec2b3c",
"name": "Merge Hojas 07"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
4160,
3424
],
"id": "7ff3bcf8-0bfd-43da-bc6b-96c4bd236034",
"name": "Merge Hojas 08"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
4464,
3808
],
"id": "3751c607-4ef5-423e-b611-592bb9bad758",
"name": "Merge Hojas 09"
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": false,
"sheetName": "RESUMEN"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
1360,
4416
],
"id": "9e0d0649-cecb-4ddf-b499-1c0ccd84cc09",
"name": "Extract Bono14 - Resumen 3",
"retryOnFail": false,
"onError": "continueRegularOutput"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
4768,
3920
],
"id": "1a6b94fa-bee4-4c72-8adc-389a0084a0b2",
"name": "Merge Hojas "
},
{
"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 firstValue(value) {\n if (Array.isArray(value)) {\n return value.filter(Boolean).join(' / ');\n }\n return normalizeText(value);\n}\n\nfunction currencyOf(row) {\n return normalizeText(row.currency || 'QTZ').toUpperCase() || 'QTZ';\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') return 'Revisar cuenta';\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) return 'Se pagó de menos';\n if (difference < 0) return 'Se pagó de más';\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 year = Number(metadata.year || 0);\nconst periodLabel =\n metadata.period_label ||\n `Bono 14 ${year}`;\n\nconst spreadsheetTitle =\n `Cruce de Cuentas GLM GT - Bono 14 ${year}`;\n\nconst mainReportSubtitle =\n `Bono 14 vs. Banco · Guatemala · ${periodLabel}`;\nconst bancoSinBambooSubtitle =\n `Pagos de Bono 14 en banco sin empleado identificado en BambooHR · Guatemala · ${periodLabel}`;\nconst cuentaMalDigitadaSubtitle =\n `Posible Cuenta Mal Digitada · Bono 14 · Guatemala · ${periodLabel}`;\n\nconst cuentaMalDigitadaCases = rows.filter(\n (row) => row.category === 'posible_cuenta_mal_digitada'\n);\nconst hasCuentaMalDigitada = 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 Bono 14 vs Banco',\n bancoSinNomina: '02 Banco sin Bono 14',\n bancoSinBamboo: '03 Banco sin Bamboo',\n diferenciasNombreBanco: '04 Diferencias nombre banco',\n cuentaMalDigitada: '05 Cuenta Mal Digitada',\n resumen: hasCuentaMalDigitada ? '06 Resumen' : '05 Resumen',\n};\n\nconst mainHeader = [\n '#',\n 'Empleado',\n 'Cuenta',\n 'Moneda',\n 'Monto Bono 14',\n 'Monto Banco',\n 'Diferencia',\n 'Sentido',\n 'Estado',\n 'Resolución',\n];\n\nconst mainRows = rows\n .filter((row) => row.category === 'discrepancia')\n .map((row, index) => {\n const payrollAmount = roundMoney(row.payroll_amount ?? row.payrollAmount ?? 0);\n const bankAmount = roundMoney(row.bank_amount ?? row.bankAmount ?? 0);\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 currencyOf(row),\n payrollAmount,\n bankAmount,\n difference,\n mainReportSense(row, difference),\n normalizeText(row.status || 'Riesgo').toUpperCase(),\n '',\n ];\n });\n\nconst mainValues = [\n ['GOMEZLEE MARKETING', '', '', '', '', '', '', '', '', ''],\n [mainReportSubtitle, '', '', '', '', '', '', '', '', ''],\n ['', '', '', '', '', '', '', '', '', ''],\n mainHeader,\n ...mainRows,\n];\n\nconst bancoSinNominaHeader = [\n 'Empleado Banco',\n 'Cuenta Banco',\n 'Moneda',\n 'Monto Banco',\n 'Estado',\n 'Observación',\n 'Resolució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(row.bank_account || row.bankAccount || row.account || ''),\n currencyOf(row),\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 'Moneda',\n 'Monto en banco',\n 'Número de envío',\n 'Estado',\n 'Resolució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(row.bank_account || row.bankAccount || row.account || ''),\n currencyOf(row),\n roundMoney(row.bank_amount ?? row.bankAmount ?? row.amount ?? 0),\n firstValue(row.shipment_numbers || row.shipment_number || ''),\n 'PENDIENTE REVISIÓN',\n '',\n]);\nconst bancoSinBambooValues = [\n ['GOMEZLEE MARKETING', '', '', '', '', '', '', '', ''],\n [bancoSinBambooSubtitle, '', '', '', '', '', '', '', ''],\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];\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(row.bank_account || row.bankAccount || row.account || ''),\n currencyOf(row),\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\nfunction accountValues(value) {\n const values = Array.isArray(value)\n ? value\n : String(value ?? '').split(/\\s*(?:\\/|;|,|\\by\\b)\\s*/i);\n\n return values\n .map((item) => String(item ?? '').replace(/\\.0$/g, '').replace(/\\D/g, '').trim())\n .filter((item) => item.length >= 7 && !/^0+$/.test(item));\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 return Array.from(new Set(candidates.flatMap(accountValues)));\n}\n\nfunction bankAccountForWrongAccount(row) {\n return firstValue(row.bank_account || row.bankAccount || row.account || '');\n}\n\nfunction moneyLabel(value, currency) {\n const amount = Math.abs(roundMoney(value)).toLocaleString('en-US', {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n });\n return `${currency === 'USD' ? 'USD ' : 'Q'}${amount}`;\n}\n\nfunction wrongAccountTotalStatus(row) {\n const payrollAmount = roundMoney(row.payroll_amount ?? row.payrollAmount ?? 0);\n const bankAmount = roundMoney(row.bank_amount ?? row.bankAmount ?? 0);\n const difference = roundMoney(payrollAmount - bankAmount);\n const currency = currencyOf(row);\n\n if (Math.abs(difference) <= 0.02) {\n return 'El total del Bono 14 coincide con el total del banco.';\n }\n if (difference > 0) {\n return `El total del Bono 14 supera el total del banco por ${moneyLabel(difference, currency)}.`;\n }\n return `El total pagado por el banco supera el Bono 14 por ${moneyLabel(difference, currency)}.`;\n}\n\nfunction wrongAccountFinding(row) {\n const existing = normalizeText(row.observation || '');\n if (existing) return existing;\n\n const payrollAccounts = payrollAccountsForWrongAccount(row);\n const bankAccount = bankAccountForWrongAccount(row);\n return (\n `El empleado presenta una posible inconsistencia entre la cuenta del Excel de Bono 14 ` +\n `(${payrollAccounts.join(' y ') || 'sin cuenta identificada'}) y la cuenta utilizada por el banco ` +\n `(${bankAccount || 'sin cuenta identificada'}).`\n );\n}\n\nconst cuentaMalDigitadaHeader = ['#', 'Campo', 'Detalle', 'Resolución'];\nconst cuentaMalDigitadaRows = [];\n\nfor (let index = 0; index < cuentaMalDigitadaCases.length; index++) {\n const row = cuentaMalDigitadaCases[index];\n const payrollAccounts = payrollAccountsForWrongAccount(row);\n const bankAccount = bankAccountForWrongAccount(row);\n const fields = [\n ['Empleado', normalizeText(row.employee_name || row.employee || '')],\n ['Moneda', currencyOf(row)],\n [\n 'Cuentas registradas en el Excel de Bono 14',\n payrollAccounts.join(' y ') || 'No se identificó una cuenta válida en el Excel de Bono 14.',\n ],\n [\n 'Cuenta utilizada por el banco',\n bankAccount || 'No se identificó una cuenta válida en el banco.',\n ],\n ['Estado del total', wrongAccountTotalStatus(row)],\n ['Hallazgo', wrongAccountFinding(row)],\n [\n 'Clasificación',\n 'Posible cuenta mal digitada — revisar y unificar la cuenta del Bono 14 con la utilizada por el banco.',\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 ['GOMEZLEE MARKETING', '', '', ''],\n [cuentaMalDigitadaSubtitle, '', '', ''],\n ['', '', '', ''],\n cuentaMalDigitadaHeader,\n ...cuentaMalDigitadaRows,\n];\n\nconst resumenHeader = ['Indicador', 'Valor'];\nconst resumenRows = [\n ['Reporte', `Bono 14 ${year}`],\n ['Período de devengo', periodLabel],\n ['Coincidencias', Number(summary.coincidencias || 0)],\n ['Discrepancias', Number(summary.discrepancias || 0)],\n ['Banco sin Bono 14', Number(summary.bancoSinNomina || 0)],\n ['Banco sin Bamboo', Number(summary.bancoSinBamboo || 0)],\n ['Bono 14 sin cuenta', Number(summary.nominaSinCuenta || 0)],\n ['Diferencias nombre banco', Number(summary.diferenciasNombreBanco || 0)],\n ['Posibles cuentas mal digitadas', Number(summary.posiblesCuentasMalDigitadas || 0)],\n ['Transacciones bancarias', Number(summary.transaccionesBanco || 0)],\n ['Total Bono 14 QTZ', roundMoney(summary.totalNominaQTZ || summary.totalNomina || 0)],\n ['Total Banco QTZ', roundMoney(summary.totalBancoQTZ || summary.totalBanco || 0)],\n ['Diferencia QTZ', roundMoney(summary.diferenciaQTZ || summary.diferenciaTotal || 0)],\n ['Total Bono 14 USD', roundMoney(summary.totalNominaUSD || 0)],\n ['Total Banco USD', roundMoney(summary.totalBancoUSD || 0)],\n ['Diferencia USD', roundMoney(summary.diferenciaUSD || 0)],\n];\n\nfunction buildSheetValues(header, bodyRows) {\n return [header, ...bodyRows];\n}\n\nconst valueData = [\n { range: `'${sheetTitles.nominaVsBanco}'!A1:J`, values: mainValues },\n {\n range: `'${sheetTitles.bancoSinNomina}'!A1:G`,\n values: buildSheetValues(bancoSinNominaHeader, bancoSinNominaRows),\n },\n { range: `'${sheetTitles.bancoSinBamboo}'!A1:I`, values: bancoSinBambooValues },\n {\n range: `'${sheetTitles.diferenciasNombreBanco}'!A1:K`,\n values: buildSheetValues(diferenciasNombreHeader, diferenciasNombreRows),\n },\n ...(hasCuentaMalDigitada\n ? [{ range: `'${sheetTitles.cuentaMalDigitada}'!A1:D`, values: cuentaMalDigitadaValues }]\n : []),\n {\n range: `'${sheetTitles.resumen}'!A1:B`,\n values: buildSheetValues(resumenHeader, resumenRows),\n },\n];\n\nfunction color(red, green, blue) {\n return { red, green, blue };\n}\n\nconst BLUE = color(0.29, 0.49, 0.58);\nconst GREEN = color(0.42, 0.76, 0.29);\nconst WHITE = color(1, 1, 1);\nconst LIGHT_RED = color(1, 0.92, 0.92);\nconst LIGHT_YELLOW = color(1, 0.97, 0.84);\nconst LIGHT_GREEN = color(0.91, 0.96, 0.91);\nconst LIGHT_BLUE = color(0.93, 0.96, 0.98);\nconst DARK = color(0.29, 0.29, 0.29);\n\nfunction repeatFormat(sheetId, startRowIndex, endRowIndex, startColumnIndex, endColumnIndex, userEnteredFormat, fields) {\n return {\n repeatCell: {\n range: { sheetId, startRowIndex, endRowIndex, startColumnIndex, endColumnIndex },\n cell: { userEnteredFormat },\n fields,\n },\n };\n}\n\nfunction freeze(sheetId, frozenRowCount) {\n return {\n updateSheetProperties: {\n properties: { sheetId, gridProperties: { frozenRowCount } },\n fields: 'gridProperties.frozenRowCount',\n },\n };\n}\n\nfunction setWidth(sheetId, startIndex, endIndex, pixelSize) {\n return {\n updateDimensionProperties: {\n range: { sheetId, dimension: 'COLUMNS', startIndex, endIndex },\n properties: { pixelSize },\n fields: 'pixelSize',\n },\n };\n}\n\nfunction autoRows(sheetId, endRowIndex) {\n return {\n autoResizeDimensions: {\n dimensions: {\n sheetId,\n dimension: 'ROWS',\n startIndex: 0,\n endIndex: Math.max(1, endRowIndex),\n },\n },\n };\n}\n\nfunction basicFilter(sheetId, startRowIndex, endRowIndex, endColumnIndex) {\n if (endRowIndex <= startRowIndex) return null;\n return {\n setBasicFilter: {\n filter: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex: 0,\n endColumnIndex,\n },\n },\n },\n };\n}\n\nfunction mergeRow(sheetId, rowIndex, endColumnIndex) {\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 moneyFormat(sheetId, startRowIndex, endRowIndex, startColumnIndex, endColumnIndex) {\n if (endRowIndex <= startRowIndex) return null;\n return repeatFormat(\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n { numberFormat: { type: 'NUMBER', pattern: '#,##0.00' } },\n 'userEnteredFormat.numberFormat'\n );\n}\n\nfunction addProtection(sheetId, endRowIndex, resolutionColumnIndex = null, resolutionStartRowIndex = 1) {\n const protectedRange = {\n range: { sheetId },\n description: `Protección GLM · Bono 14 · ${sheetId}`,\n warningOnly: false,\n editors: {\n users: ['iaracena@gomezleemarketing.com'],\n domainUsersCanEdit: false,\n },\n };\n\n if (\n Number.isInteger(resolutionColumnIndex) &&\n Number.isInteger(endRowIndex) &&\n endRowIndex > resolutionStartRowIndex\n ) {\n protectedRange.unprotectedRanges = [\n {\n sheetId,\n startRowIndex: resolutionStartRowIndex,\n endRowIndex,\n startColumnIndex: resolutionColumnIndex,\n endColumnIndex: resolutionColumnIndex + 1,\n },\n ];\n }\n\n return { addProtectedRange: { protectedRange } };\n}\n\nfunction standardBodyFormat(sheetId, startRowIndex, endRowIndex, endColumnIndex) {\n if (endRowIndex <= startRowIndex) return null;\n return repeatFormat(\n sheetId,\n startRowIndex,\n endRowIndex,\n 0,\n endColumnIndex,\n {\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n textFormat: { foregroundColor: DARK },\n },\n 'userEnteredFormat(verticalAlignment,wrapStrategy,textFormat)'\n );\n}\n\nfunction headerFormat(sheetId, rowIndex, endColumnIndex) {\n return repeatFormat(\n sheetId,\n rowIndex,\n rowIndex + 1,\n 0,\n endColumnIndex,\n {\n backgroundColor: BLUE,\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n textFormat: { bold: true, foregroundColor: WHITE },\n },\n 'userEnteredFormat(backgroundColor,horizontalAlignment,verticalAlignment,wrapStrategy,textFormat)'\n );\n}\n\nfunction titleFormat(sheetId, rowIndex, endColumnIndex, fontSize = 12) {\n return repeatFormat(\n sheetId,\n rowIndex,\n rowIndex + 1,\n 0,\n endColumnIndex,\n {\n backgroundColor: BLUE,\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n textFormat: { bold: true, fontSize, foregroundColor: WHITE },\n },\n 'userEnteredFormat(backgroundColor,horizontalAlignment,verticalAlignment,wrapStrategy,textFormat)'\n );\n}\n\nconst formatRequests = [];\nfunction push(request) {\n if (request) formatRequests.push(request);\n}\n\n// 01 Bono 14 vs Banco\nconst mainEnd = mainValues.length;\npush(mergeRow(sheetIds.nominaVsBanco, 0, 10));\npush(mergeRow(sheetIds.nominaVsBanco, 1, 10));\npush(titleFormat(sheetIds.nominaVsBanco, 0, 10, 13));\npush(titleFormat(sheetIds.nominaVsBanco, 1, 10, 11));\npush(headerFormat(sheetIds.nominaVsBanco, 3, 10));\npush(standardBodyFormat(sheetIds.nominaVsBanco, 4, mainEnd, 10));\npush(moneyFormat(sheetIds.nominaVsBanco, 4, mainEnd, 4, 7));\npush(\n repeatFormat(\n sheetIds.nominaVsBanco,\n 4,\n mainEnd,\n 8,\n 9,\n {\n backgroundColor: LIGHT_RED,\n textFormat: { bold: true, foregroundColor: color(0.70, 0.20, 0.20) },\n horizontalAlignment: 'CENTER',\n },\n 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment)'\n )\n);\npush(basicFilter(sheetIds.nominaVsBanco, 3, mainEnd, 10));\npush(freeze(sheetIds.nominaVsBanco, 4));\n[\n [0, 1, 55], [1, 2, 220], [2, 3, 145], [3, 4, 75], [4, 7, 125],\n [7, 8, 185], [8, 9, 120], [9, 10, 230],\n].forEach(([s, e, px]) => push(setWidth(sheetIds.nominaVsBanco, s, e, px)));\npush(autoRows(sheetIds.nominaVsBanco, mainEnd));\n\nif (mainEnd > 4) {\n push({\n addConditionalFormatRule: {\n rule: {\n ranges: [{ sheetId: sheetIds.nominaVsBanco, startRowIndex: 4, endRowIndex: mainEnd, startColumnIndex: 6, endColumnIndex: 7 }],\n booleanRule: {\n condition: { type: 'CUSTOM_FORMULA', values: [{ userEnteredValue: '=$G5>0' }] },\n format: { backgroundColor: LIGHT_YELLOW, textFormat: { bold: true } },\n },\n },\n index: 0,\n },\n });\n push({\n addConditionalFormatRule: {\n rule: {\n ranges: [{ sheetId: sheetIds.nominaVsBanco, startRowIndex: 4, endRowIndex: mainEnd, startColumnIndex: 6, endColumnIndex: 7 }],\n booleanRule: {\n condition: { type: 'CUSTOM_FORMULA', values: [{ userEnteredValue: '=$G5<0' }] },\n format: { backgroundColor: LIGHT_RED, textFormat: { bold: true } },\n },\n },\n index: 0,\n },\n });\n}\npush(addProtection(sheetIds.nominaVsBanco, mainEnd, 9, 4));\n\n// 02 Banco sin Bono 14\nconst bankNoBonusEnd = 1 + bancoSinNominaRows.length;\npush(headerFormat(sheetIds.bancoSinNomina, 0, 7));\npush(standardBodyFormat(sheetIds.bancoSinNomina, 1, bankNoBonusEnd, 7));\npush(moneyFormat(sheetIds.bancoSinNomina, 1, bankNoBonusEnd, 3, 4));\npush(basicFilter(sheetIds.bancoSinNomina, 0, bankNoBonusEnd, 7));\npush(freeze(sheetIds.bancoSinNomina, 1));\n[[0,1,220],[1,2,145],[2,3,75],[3,4,125],[4,5,130],[5,6,320],[6,7,230]].forEach(([s,e,px]) => push(setWidth(sheetIds.bancoSinNomina,s,e,px)));\npush(autoRows(sheetIds.bancoSinNomina, bankNoBonusEnd));\npush(addProtection(sheetIds.bancoSinNomina, bankNoBonusEnd, 6, 1));\n\n// 03 Banco sin Bamboo\nconst bambooEnd = bancoSinBambooValues.length;\npush(mergeRow(sheetIds.bancoSinBamboo, 0, 9));\npush(mergeRow(sheetIds.bancoSinBamboo, 1, 9));\npush(titleFormat(sheetIds.bancoSinBamboo, 0, 9, 13));\npush(titleFormat(sheetIds.bancoSinBamboo, 1, 9, 11));\npush(headerFormat(sheetIds.bancoSinBamboo, 3, 9));\npush(standardBodyFormat(sheetIds.bancoSinBamboo, 4, bambooEnd, 9));\npush(moneyFormat(sheetIds.bancoSinBamboo, 4, bambooEnd, 5, 6));\npush(\n repeatFormat(\n sheetIds.bancoSinBamboo,\n 4,\n bambooEnd,\n 7,\n 8,\n { backgroundColor: LIGHT_YELLOW, textFormat: { bold: true }, horizontalAlignment: 'CENTER' },\n 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment)'\n )\n);\npush(basicFilter(sheetIds.bancoSinBamboo, 3, bambooEnd, 9));\npush(freeze(sheetIds.bancoSinBamboo, 4));\n[[0,1,55],[1,2,220],[2,3,220],[3,4,145],[4,5,75],[5,6,125],[6,7,120],[7,8,155],[8,9,230]].forEach(([s,e,px]) => push(setWidth(sheetIds.bancoSinBamboo,s,e,px)));\npush(autoRows(sheetIds.bancoSinBamboo, bambooEnd));\npush(addProtection(sheetIds.bancoSinBamboo, bambooEnd, 8, 4));\n\n// 04 Diferencias nombre banco\nconst diffNameEnd = 1 + diferenciasNombreRows.length;\npush(headerFormat(sheetIds.diferenciasNombreBanco, 0, 11));\npush(standardBodyFormat(sheetIds.diferenciasNombreBanco, 1, diffNameEnd, 11));\npush(moneyFormat(sheetIds.diferenciasNombreBanco, 1, diffNameEnd, 4, 5));\npush(basicFilter(sheetIds.diferenciasNombreBanco, 0, diffNameEnd, 11));\npush(freeze(sheetIds.diferenciasNombreBanco, 1));\n[[0,1,220],[1,2,220],[2,3,145],[3,4,75],[4,5,125],[5,7,115],[7,8,220],[8,9,130],[9,10,320],[10,11,230]].forEach(([s,e,px]) => push(setWidth(sheetIds.diferenciasNombreBanco,s,e,px)));\npush(autoRows(sheetIds.diferenciasNombreBanco, diffNameEnd));\npush(addProtection(sheetIds.diferenciasNombreBanco, diffNameEnd, 10, 1));\n\n// 05 Cuenta Mal Digitada (opcional)\nif (hasCuentaMalDigitada) {\n const wrongEnd = cuentaMalDigitadaValues.length;\n push(mergeRow(sheetIds.cuentaMalDigitada, 0, 4));\n push(mergeRow(sheetIds.cuentaMalDigitada, 1, 4));\n push(titleFormat(sheetIds.cuentaMalDigitada, 0, 4, 13));\n push(titleFormat(sheetIds.cuentaMalDigitada, 1, 4, 11));\n push(headerFormat(sheetIds.cuentaMalDigitada, 3, 4));\n push(standardBodyFormat(sheetIds.cuentaMalDigitada, 4, wrongEnd, 4));\n push(freeze(sheetIds.cuentaMalDigitada, 4));\n [[0,1,55],[1,2,270],[2,3,640],[3,4,230]].forEach(([s,e,px]) => push(setWidth(sheetIds.cuentaMalDigitada,s,e,px)));\n push(autoRows(sheetIds.cuentaMalDigitada, wrongEnd));\n push(addProtection(sheetIds.cuentaMalDigitada, wrongEnd, 3, 4));\n}\n\n// Resumen\nconst summaryEnd = 1 + resumenRows.length;\npush(headerFormat(sheetIds.resumen, 0, 2));\npush(standardBodyFormat(sheetIds.resumen, 1, summaryEnd, 2));\npush(\n repeatFormat(\n sheetIds.resumen,\n 1,\n summaryEnd,\n 0,\n 1,\n { backgroundColor: LIGHT_BLUE, textFormat: { bold: true, foregroundColor: DARK } },\n 'userEnteredFormat(backgroundColor,textFormat)'\n )\n);\npush(freeze(sheetIds.resumen, 1));\npush(setWidth(sheetIds.resumen, 0, 1, 310));\npush(setWidth(sheetIds.resumen, 1, 2, 260));\npush(autoRows(sheetIds.resumen, summaryEnd));\npush(addProtection(sheetIds.resumen, summaryEnd, null, 1));\n\nconst desiredSheets = [\n {\n properties: {\n sheetId: sheetIds.nominaVsBanco,\n title: sheetTitles.nominaVsBanco,\n index: 0,\n gridProperties: { rowCount: Math.max(200, mainEnd + 20), columnCount: 10 },\n },\n },\n {\n properties: {\n sheetId: sheetIds.bancoSinNomina,\n title: sheetTitles.bancoSinNomina,\n index: 1,\n gridProperties: { rowCount: Math.max(200, bankNoBonusEnd + 20), columnCount: 7 },\n },\n },\n {\n properties: {\n sheetId: sheetIds.bancoSinBamboo,\n title: sheetTitles.bancoSinBamboo,\n index: 2,\n gridProperties: { rowCount: Math.max(200, bambooEnd + 20), columnCount: 9 },\n },\n },\n {\n properties: {\n sheetId: sheetIds.diferenciasNombreBanco,\n title: sheetTitles.diferenciasNombreBanco,\n index: 3,\n gridProperties: { rowCount: Math.max(200, diffNameEnd + 20), columnCount: 11 },\n },\n },\n ...(hasCuentaMalDigitada\n ? [\n {\n properties: {\n sheetId: sheetIds.cuentaMalDigitada,\n title: sheetTitles.cuentaMalDigitada,\n index: 4,\n gridProperties: {\n rowCount: Math.max(200, cuentaMalDigitadaValues.length + 20),\n columnCount: 4,\n },\n },\n },\n ]\n : []),\n {\n properties: {\n sheetId: sheetIds.resumen,\n title: sheetTitles.resumen,\n index: hasCuentaMalDigitada ? 5 : 4,\n gridProperties: { rowCount: 200, columnCount: 2 },\n },\n },\n];\n\nreturn [\n {\n json: {\n ok: data.ok ?? true,\n stage: 'google_sheet_bono14_preparado',\n metadata,\n summary,\n sheetIds,\n sheetTitles,\n spreadsheetTitle,\n createSpreadsheetBody: {\n properties: {\n title: spreadsheetTitle,\n locale: 'es_GT',\n timeZone: 'America/Guatemala',\n },\n sheets: desiredSheets,\n },\n valueBatchBody: {\n valueInputOption: 'USER_ENTERED',\n data: valueData,\n },\n formatBatchBody: {\n requests: formatRequests,\n },\n originalResponse: data,\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
8016,
3280
],
"id": "9c64018c-0cb3-486f-9d23-7130d711cb97",
"name": "Preparar Google Sheet Bono 14"
},
{
"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 Bono 14').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 /*\n * Google Sheets rechazó el locale \"es_GT\" al crear el spreadsheet\n * (\"Unsupported locale: es_GT\").\n *\n * No necesitamos enviar locale para este reporte: Google aplicará el\n * locale predeterminado de la cuenta/archivo y todos los formatos\n * importantes del reporte se establecen explícitamente después.\n *\n * Se elimina SOLO esa propiedad del request de creación y se conserva\n * el resto de propiedades y toda la lógica existente.\n */\n const {\n locale: _unsupportedLocale,\n ...safeSpreadsheetProperties\n } = createBody.properties || {};\n\n return {\n properties: {\n ...safeSpreadsheetProperties,\n timeZone: 'America/Guatemala',\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": [
8256,
3296
],
"id": "c5ef1b72-12c6-48c0-bd6c-6e410704a2e6",
"name": "Crear Google Sheet",
"credentials": {
"httpBasicAuth": {
"id": "nIxZ7elcHvuzsRKW",
"name": "Neo4j"
},
"googleOAuth2Api": {
"id": "eHseMeH39kRcXgOF",
"name": "Google account 2"
}
}
},
{
"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": [
9472,
3280
],
"id": "64e1694d-6b0a-411c-86f7-636042887223",
"name": "Escribir Google Sheet",
"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": [
9712,
3280
],
"id": "c5d3a10e-4614-4f85-a624-5d9c99fb2629",
"name": "Formatear Google Sheet",
"credentials": {
"googleOAuth2Api": {
"id": "dQ1MJSJSWcoWYcb8",
"name": "Google account - Isaac Producción"
}
}
},
{
"parameters": {
"jsCode": "const prepared = $('Preparar Google Sheet Bono 14').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 de Bono 14 existente actualizado correctamente. Se conservaron ${Number(sheetContext.preservedResolutionsCount || 0)} resolución(es) previamente registradas.`\n : 'Bono 14 procesado correctamente. Google Sheet generado.')\n : 'Bono 14 procesado correctamente, pero no se recibió URL del Google Sheet.',\n stage: reportUrl ? 'bono14_completado_con_reporte' : 'bono14_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": [
11648,
3280
],
"id": "02afa713-5438-4ab4-afaf-bb8cb1d4a3d6",
"name": "Preparar respuesta final"
},
{
"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 'jsepulveda@gomezlee.marketing',\n 'mbenitez@gomezlee.marketing',\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": [
10608,
3280
],
"id": "c465e89a-e976-4f8d-96fd-21ad85aa965c",
"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": [
10848,
3280
],
"id": "e36f25f7-f99b-4a7b-ac0e-2e653d86a967",
"name": "Compartir Google Sheet",
"credentials": {
"googleOAuth2Api": {
"id": "dQ1MJSJSWcoWYcb8",
"name": "Google account - Isaac Producción"
}
},
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "const cruce = $('Cruzar Bono 14 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 || 'GT';\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 'GT',\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-bono14-guatemala',\n country: metadata.country || 'GT',\n country_name: metadata.country_name || 'Guatemala',\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 report_kind: 'bono14',\n total_bono14_qtz: toNumber(summary.totalNominaQTZ),\n total_banco_qtz: toNumber(summary.totalBancoQTZ),\n diferencia_qtz: toNumber(summary.diferenciaQTZ),\n total_bono14_usd: toNumber(summary.totalNominaUSD),\n total_banco_usd: toNumber(summary.totalBancoUSD),\n diferencia_usd: toNumber(summary.diferenciaUSD),\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": [
11168,
3280
],
"id": "9a375998-957b-4e2f-9ac0-aea3b5ce3cdd",
"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": [
11408,
3280
],
"id": "aee129de-29ee-4808-a480-a0d0c1c9fa4f",
"name": "Insertar histórico Supabase",
"onError": "continueRegularOutput"
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": false,
"sheetName": "RESUMEN"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
1360,
4400
],
"id": "dcd3aab6-326d-4a6e-9f80-5645203de828",
"name": "Extract Bono14 - Resumen 4",
"retryOnFail": false,
"onError": "continueRegularOutput"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
4928,
4064
],
"id": "1e8c09ae-f71c-4bb7-905c-4f873c1a9811",
"name": "Merge Hojas 10"
},
{
"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 GT",
"fields": [
"firstName",
"middleName",
"lastName",
"displayName",
"department",
"division",
"location",
"customPosicion-Cliente",
"hireDate",
"originalHireDate",
"status",
"employeeNumber"
]
},
"options": {
"response": {
"response": {
"responseFormat": "json"
}
},
"timeout": 300000
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
912,
2336
],
"id": "ca5bc0bf-fd2a-4bdf-b7ac-34628f666975",
"name": "HTTP - Empleados BambooHR Bono14 GT",
"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').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 === 'gt' ||\n country === 'gtm' ||\n country.includes('guatemala') ||\n location === 'gt' ||\n location === 'gtm' ||\n location.includes('guatemala')\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 'guatemala_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 Guatemala 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 Guatemala,\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 * - JORGE LUIS MORALES PEREZ · Guatemala\n * - Jorge Luis Morales · otro país\n *\n * El rescate fuera del país permanece activo cuando no existe una\n * coincidencia exacta entre los perfiles de Guatemala.\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 [normalize(\"CARLOS DE LEON\"), normalize(\"Carlos Alexander De leon chajon\")],\n [normalize(\"CARLOS ALEXANDER DE LEON CHAJON\"), normalize(\"Carlos Alexander De leon chajon\")],\n [normalize(\"LISANDRO LINARES\"), normalize(\"Lisandro Antonio Linares giron\")],\n [normalize(\"LISANDRO ANTONIO LINARES GIRON\"), normalize(\"Lisandro Antonio Linares giron\")],\n [normalize(\"JULIO VELASQUEZ\"), normalize(\"Julio Francisco Velásquez\")],\n [normalize(\"JULIO FRANCISCO VELASQUEZ RAMIREZ\"), normalize(\"Julio Francisco Velásquez\")],\n [normalize(\"PABLO GIRON\"), normalize(\"Pablo Augusto Giron Robles\")],\n [normalize(\"PABLO AUGUSTO GIRON ROBLES\"), normalize(\"Pablo Augusto Giron Robles\")],\n [normalize(\"VERONICA GARCIA\"), normalize(\"Verónica Beatriz García Quan\")],\n [normalize(\"VERONICA BEATRIZ GARCIA\"), normalize(\"Verónica Beatriz García Quan\")],\n [normalize(\"VERONICA BEATRIZ GARCIA QUAN\"), normalize(\"Verónica Beatriz García Quan\")],\n [normalize(\"SANDRA LOPEZ\"), normalize(\"Sandra Lisbeth López Godoy\")],\n [normalize(\"SANDRA LIZBETH LOPEZ GODOY\"), normalize(\"Sandra Lisbeth López Godoy\")],\n [normalize(\"SANDRA LISBETH LOPEZ GODOY\"), normalize(\"Sandra Lisbeth López Godoy\")]\n]);\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 Guatemala 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_gt_normalizado'\n : 'bamboohr_gt_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 guatemala_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": [
1136,
2336
],
"id": "867f76e7-7db9-4259-9e61-6d0dc9acbbeb",
"name": "Normalizar BambooHR Bono14 GT"
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"options": {}
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
7360,
3280
],
"id": "b27bfa42-456c-45de-98f0-c7fe919ee8af",
"name": "Merge - Agregar BambooHR Bono14"
},
{
"parameters": {
"content": "# 📥 ENTRADA Y EXTRACCIÓN DE DATOS — GUATEMALA\n\nRecibe desde el Portal de Verificación de Bono 14s los archivos y parámetros necesarios para ejecutar el cruce de Guatemala.\n\nFuentes procesadas:\n\n- Directorio de empleados de BambooHR.\n- Archivo CSV del banco.\n- Archivo Excel de Bono 14 con múltiples hojas.\n- Hojas adicionales de pagos, bonos, viáticos, combustibles, auditorías, temporales y otras unidades.\n\nEste bloque:\n\n1. Recibe la solicitud de la aplicación.\n2. Normaliza año, mes y tipo de período.\n3. Consulta los empleados disponibles en BambooHR.\n4. Estandariza nombres, correos e identificadores.\n5. Convierte el CSV bancario en registros procesables.\n6. Extrae cada hoja relevante del archivo Excel.\n7. Mantiene la hoja de origen de cada registro para facilitar validaciones.\n\nReglas:\n\n- No iniciar el cruce sin los archivos obligatorios.\n- Mantener separados banco, Bono 14 y BambooHR.\n- No asumir que todas las hojas tienen la misma estructura.\n- No perder la procedencia de los registros.\n- Preparar todas las fuentes en un formato compatible con la consolidación.",
"height": 2992,
"width": 1760,
"color": "#2D305D"
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
128,
1760
],
"id": "2e44f1cc-edb2-4fb8-af15-2441068ba6f1",
"name": "Sticky Note"
},
{
"parameters": {
"content": "# 🧩 CONSOLIDACIÓN DE NÓMINA — GUATEMALA\n\nUne progresivamente todas las hojas extraídas hasta construir una sola Bono 14 consolidada del período.\n\nDurante la consolidación:\n\n- Se agregan las hojas en una secuencia controlada.\n- Se conserva la unidad o pestaña de procedencia.\n- Se eliminan filas completamente vacías.\n- Se estandarizan encabezados y tipos de datos.\n- Se normalizan nombres de empleados.\n- Se limpian espacios, símbolos y caracteres especiales.\n- Se convierten montos y fechas a formatos consistentes.\n\nLa salida de este bloque representa la Bono 14 completa que será comparada con el banco y BambooHR.\n\nReglas:\n\n- No sobrescribir registros de hojas anteriores.\n- No eliminar empleados únicamente porque aparezcan en más de una hoja.\n- Identificar correctamente posibles duplicados reales.\n- Mantener los valores originales para revisión.\n- No enviar hojas individualmente a la etapa de cruce.",
"height": 2336,
"width": 3984,
"color": "#216353"
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
2064,
1904
],
"id": "c346fd57-b420-473e-8131-7b60bde1d960",
"name": "Sticky Note1"
},
{
"parameters": {
"content": "# 🔍 CRUCE Y REPORTE FINAL — GUATEMALA\n\nCombina la Bono 14 consolidada con el archivo bancario y la información oficial de BambooHR.\n\nEl cruce permite detectar:\n\n- Diferencias entre Bono 14 y banco.\n- Empleados presentes solamente en Bono 14.\n- Registros presentes solamente en el banco.\n- Empleados no encontrados en BambooHR.\n- Posibles diferencias de nombres, cuentas o identificadores.\n- Diferencias en montos pagados.\n- Registros que requieren revisión manual.\n\nDespués del análisis:\n\n1. Se organizan los resultados por hoja y categoría.\n2. Se prepara la estructura del reporte.\n3. Se crea un nuevo Google Sheet.\n4. Se escriben encabezados, resultados y resúmenes.\n5. Se aplican formatos de moneda, fechas y columnas.\n6. Se configuran los permisos.\n7. Se comparte el archivo con los usuarios autorizados.\n\nReglas:\n\n- No depender únicamente del nombre para relacionar empleados.\n- No ocultar registros sin coincidencia.\n- No compartir el Sheet antes de finalizar su escritura.\n- No devolver el enlace hasta confirmar que el archivo existe.\n- Google Sheets funciona como entregable; las fuentes originales siguen siendo BambooHR, Bono 14 y banco.",
"height": 608,
"width": 3472,
"color": "#5F721D"
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
6448,
3056
],
"id": "ad3da1da-1a16-4980-9d1b-fa11c0048b95",
"name": "Sticky Note2"
},
{
"parameters": {
"content": "# 🗂️ HISTÓRICO Y RESPUESTA FINAL\n\nRegistra en Supabase la ejecución completada y devuelve el resultado al Portal de Verificación de Bono 14s.\n\nEl histórico puede almacenar:\n\n- País: Guatemala.\n- Año y mes procesados.\n- Tipo de período.\n- Fecha de ejecución.\n- Usuario que inició el proceso.\n- Cantidad de registros analizados.\n- Cantidad de diferencias o hallazgos.\n- Enlace del Google Sheet.\n- Estado inicial del reporte.\n- Identificador de la ejecución.\n\nDespués del registro:\n\n1. Se construye la respuesta para la aplicación.\n2. Se incluye el enlace al reporte generado.\n3. Se devuelve el resumen de resultados.\n4. Se informa si el procesamiento terminó correctamente.\n5. Se cierra la solicitud mediante Respond to Webhook.\n\nReglas:\n\n- Registrar el histórico solamente después de crear el reporte.\n- No declarar éxito si falló el Sheet o Supabase.\n- No devolver credenciales ni información interna.\n- Mantener una estructura estable para la aplicación.\n- Supabase es la fuente oficial de los históricos mostrados en el portal.",
"height": 992,
"width": 2048,
"color": 3
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
10064,
2976
],
"id": "46289c23-7cda-434d-827c-f47ad3ba9146",
"name": "Sticky Note3"
},
{
"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, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n\nfunction numberValue(value) {\n const parsed = Number(value);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction formatGeneratedAt(date) {\n const timeZone = 'America/Santo_Domingo';\n\n const dateLabel = new Intl.DateTimeFormat('es-DO', {\n timeZone,\n day: 'numeric',\n month: 'long',\n year: 'numeric',\n }).format(date);\n\n const timeLabel = new Intl.DateTimeFormat('es-DO', {\n timeZone,\n hour: 'numeric',\n minute: '2-digit',\n hour12: true,\n }).format(date);\n\n const hour24Raw = new Intl.DateTimeFormat('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
\n \n \n | \n \n ${escapeHtml(label)}\n \n\n \n ${numberValue(value).toLocaleString('en-US')}\n \n | \n \n \n | \n `;\n}\n\nconst countryName =\n payload.country_name ||\n metadata.country_name ||\n (\n String(payload.country || '').toUpperCase() === 'TT'\n ? 'Trinidad y Tobago'\n : 'Guatemala'\n );\n\nconst countryCode = String(\n payload.country ||\n metadata.country ||\n ''\n).toUpperCase();\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 countryCode === 'TT'\n ? 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/Flag_of_Trinidad_and_Tobago.svg.webp'\n : 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/Flag_of_Guatemala.svg.webp';\n\nconst subject = reportUpdated\n ? `Reporte actualizado - Bono 14 ${countryName} - ${periodLabel}`\n : `Nuevo reporte generado - Bono 14 ${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 'jgomez@gomezleemarketing.com',\n 'mgomez@gomezleemarketing.com',\n 'vparamo@gomezleemarketing.com',\n 'ymadera@gomezleemarketing.com',\n 'jsepulveda@gomezlee.marketing',\n 'mbenitez@gomezlee.marketing',\n].join(',');\n\n// Paleta oficial GLM.\nconst metricCards = [\n metricCard(\n 'Discrepancias',\n discrepancias,\n '#FF6A13'\n ),\n metricCard(\n 'Banco sin Bono 14',\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 'Bono 14 sin cuenta',\n nominaSinCuenta,\n '#4F758B'\n ),\n].join('');\n\nconst htmlBody = `\n\n\n\n\n \n \n ${escapeHtml(subject)}\n\n\n\n\n \n \n \n\n \n\n \n \n | \n \n | \n \n\n \n \n \n\n \n \n \n \n | \n\n \n \n ${reportUpdated ? 'REPORTE ACTUALIZADO' : 'REPORTE GENERADO'}\n \n | \n \n \n\n \n\n \n \n\n \n \n | \n\n \n\n \n Bono 14 · ${escapeHtml(countryName)}\n \n\n \n ${escapeHtml(periodLabel)}\n \n\n | \n\n \n \n\n | \n \n\n \n \n | \n\n \n ${reportUpdated ? 'Se actualizó el reporte existente de Bono 14 conservando las resoluciones previamente registradas que continúan correspondiendo a los mismos casos.' : 'Se completó una nueva ejecución del cruce de Bono 14 de Guatemala. Este es el resumen del reporte generado.'}\n \n\n | \n \n\n \n \n \n\n \n \n ${metricCards}\n \n \n\n | \n \n\n \n \n \n\n \n\n \n\n | \n\n \n Ejecutado por\n \n\n \n ${escapeHtml(executedByName)}\n \n\n \n ${escapeHtml(\n executedByEmail ||\n 'Correo no disponible'\n )}\n \n\n | \n\n \n\n \n País\n \n\n \n \n\n \n \n | \n\n \n\n \n ${escapeHtml(countryName)}\n \n\n \n ${escapeHtml(\n countryCode || '—'\n )}\n \n\n | \n\n \n \n\n | \n\n \n\n \n\n | \n\n \n Período\n \n\n \n ${escapeHtml(periodLabel)}\n \n\n \n ${escapeHtml(generatedAtLabel)}\n \n\n | \n\n \n\n \n Estado inicial\n \n\n \n ${escapeHtml(statusLabel)}\n \n\n \n Coincidencias:\n ${coincidencias.toLocaleString('en-US')}\n \n\n | \n\n \n\n \n\n | \n \n\n \n ${\n reportUrl\n ? `\n \n | \n\n \n Abrir reporte en Google Sheets\n \n\n | \n \n `\n : ''\n }\n\n \n \n | \n\n \n\n \n Notificación automática · Bono 14 · Portal Cruce de Cuentas GLM\n \n Los casos de revisión deben gestionarse directamente en el reporte generado.\n \n\n | \n \n\n \n\n | \n
\n
\n\n\n\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": [
11648,
3600
],
"id": "1a73c8b6-7f06-4e5d-a83a-f731bd96a1ad",
"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": [
11888,
3600
],
"id": "fbf05240-1dd2-4cde-b7ca-7ebb81136295",
"name": "Enviar correo reporte generado",
"webhookId": "c10f5aec-68bc-44d9-8152-2e14f8898e02",
"credentials": {
"gmailOAuth2": {
"id": "UDcO1FLJqA453V2D",
"name": "Gmail account 3"
}
},
"onError": "continueRegularOutput"
},
{
"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": "GT"
},
"options": {
"response": {
"response": {
"responseFormat": "json"
}
},
"timeout": 30000
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
7584,
3264
],
"id": "aa995943-d367-449f-a720-7aa37c2862b5",
"name": "Obtener alias BambooHR Bono14 Supabase",
"retryOnFail": true,
"maxTries": 2,
"waitBetweenTries": 1000,
"onError": "continueRegularOutput"
},
{
"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 Bono 14').first().json.metadata.country || 'GT', p_year: Number($('Preparar Google Sheet Bono 14').first().json.metadata.year || 0), p_month: Number($('Preparar Google Sheet Bono 14').first().json.metadata.month || 0), p_period_type: $('Preparar Google Sheet Bono 14').first().json.metadata.period_type || '' } }}",
"options": {
"response": {
"response": {
"responseFormat": "json"
}
},
"timeout": 30000
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
7792,
3280
],
"id": "47e29f81-ee8d-429f-a461-0288c86bf5e2",
"name": "Buscar reporte existente Supabase"
},
{
"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": [
8512,
3280
],
"id": "26b3e1a2-ebfd-4cbd-a53d-5fd9244a4de1",
"name": "Leer Google Sheet actual",
"credentials": {
"googleOAuth2Api": {
"id": "dQ1MJSJSWcoWYcb8",
"name": "Google account - Isaac Producción"
}
}
},
{
"parameters": {
"jsCode": "const prepared =\n $('Preparar Google Sheet Bono 14').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": [
8752,
3280
],
"id": "09a08613-346b-4121-9025-08a84a4a9329",
"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": [
8992,
3280
],
"id": "c530c0ac-227b-4596-a261-540bc0799dce",
"name": "Actualizar estructura Google Sheet",
"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": [
9232,
3280
],
"id": "65330e06-3297-4213-9385-de6ad2b62026",
"name": "Limpiar Google Sheet",
"credentials": {
"googleOAuth2Api": {
"id": "dQ1MJSJSWcoWYcb8",
"name": "Google account - Isaac Producción"
}
}
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "Preparar entrada app",
"type": "main",
"index": 0
}
]
]
},
"Preparar entrada app": {
"main": [
[
{
"node": "Parsear CSV banco Bono 14 GT",
"type": "main",
"index": 0
},
{
"node": "Extract Bono14 - P&G",
"type": "main",
"index": 0
},
{
"node": "Extract Bono14 - Motorola",
"type": "main",
"index": 0
},
{
"node": "Extract Bono14 - Whirlpool",
"type": "main",
"index": 0
},
{
"node": "Extract Bono14 - PMI",
"type": "main",
"index": 0
},
{
"node": "Extract Bono14 - Nestle",
"type": "main",
"index": 0
},
{
"node": "Extract Bono14 - GLM Q",
"type": "main",
"index": 0
},
{
"node": "Extract Bono14 - GLM USD",
"type": "main",
"index": 0
},
{
"node": "Extract Bono14 - Resumen 1",
"type": "main",
"index": 0
},
{
"node": "Extract Bono14 - Resumen 2",
"type": "main",
"index": 0
},
{
"node": "Extract Bono14 - Resumen 3",
"type": "main",
"index": 0
},
{
"node": "Extract Bono14 - Resumen 4",
"type": "main",
"index": 0
}
]
]
},
"Merge": {
"main": [
[
{
"node": "Merge - Agregar BambooHR Bono14",
"type": "main",
"index": 0
},
{
"node": "HTTP - Empleados BambooHR Bono14 GT",
"type": "main",
"index": 0
}
]
]
},
"Extract Bono14 - P&G": {
"main": [
[
{
"node": "Merge Hojas 01-02",
"type": "main",
"index": 0
}
]
]
},
"Extract Bono14 - Motorola": {
"main": [
[
{
"node": "Merge Hojas 01-02",
"type": "main",
"index": 1
}
]
]
},
"Extract Bono14 - Whirlpool": {
"main": [
[
{
"node": "Merge Hojas 03",
"type": "main",
"index": 1
}
]
]
},
"Extract Bono14 - PMI": {
"main": [
[
{
"node": "Merge Hojas 04",
"type": "main",
"index": 1
}
]
]
},
"Extract Bono14 - Nestle": {
"main": [
[
{
"node": "Merge Hojas 05",
"type": "main",
"index": 1
}
]
]
},
"Extract Bono14 - GLM Q": {
"main": [
[
{
"node": "Merge Hojas 06",
"type": "main",
"index": 1
}
]
]
},
"Extract Bono14 - GLM USD": {
"main": [
[
{
"node": "Merge Hojas 07",
"type": "main",
"index": 1
}
]
]
},
"Extract Bono14 - Resumen 1": {
"main": [
[
{
"node": "Merge Hojas 08",
"type": "main",
"index": 1
}
]
]
},
"Extract Bono14 - Resumen 2": {
"main": [
[
{
"node": "Merge Hojas 09",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas 01-02": {
"main": [
[
{
"node": "Merge Hojas 03",
"type": "main",
"index": 0
}
]
]
},
"Merge Hojas 03": {
"main": [
[
{
"node": "Merge Hojas 04",
"type": "main",
"index": 0
}
]
]
},
"Merge Hojas 04": {
"main": [
[
{
"node": "Merge Hojas 05",
"type": "main",
"index": 0
}
]
]
},
"Merge Hojas 05": {
"main": [
[
{
"node": "Merge Hojas 06",
"type": "main",
"index": 0
}
]
]
},
"Merge Hojas 06": {
"main": [
[
{
"node": "Merge Hojas 07",
"type": "main",
"index": 0
}
]
]
},
"Merge Hojas 07": {
"main": [
[
{
"node": "Merge Hojas 08",
"type": "main",
"index": 0
}
]
]
},
"Merge Hojas 08": {
"main": [
[
{
"node": "Merge Hojas 09",
"type": "main",
"index": 0
}
]
]
},
"Merge Hojas 09": {
"main": [
[
{
"node": "Merge Hojas ",
"type": "main",
"index": 0
}
]
]
},
"Extract Bono14 - Resumen 3": {
"main": [
[
{
"node": "Merge Hojas ",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas ": {
"main": [
[
{
"node": "Merge Hojas 10",
"type": "main",
"index": 0
}
]
]
},
"Crear Google Sheet": {
"main": [
[
{
"node": "Leer Google Sheet actual",
"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 respuesta final": {
"main": [
[
{
"node": "Respond to Webhook",
"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
}
]
]
},
"Extract Bono14 - Resumen 4": {
"main": [
[
{
"node": "Merge Hojas 10",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas 10": {
"main": [
[
{
"node": "Normalizar Bono 14",
"type": "main",
"index": 0
}
]
]
},
"Preparar correo reporte generado": {
"main": [
[
{
"node": "Enviar correo reporte generado",
"type": "main",
"index": 0
}
]
]
},
"Buscar reporte existente Supabase": {
"main": [
[
{
"node": "Crear Google Sheet",
"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
}
]
]
},
"Parsear CSV banco Bono 14 GT": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 0
}
]
]
},
"Normalizar Bono 14": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 1
}
]
]
},
"Cruzar Bono 14 vs Banco": {
"main": [
[
{
"node": "Preparar Google Sheet Bono 14",
"type": "main",
"index": 0
}
]
]
},
"Preparar Google Sheet Bono 14": {
"main": [
[
{
"node": "Buscar reporte existente Supabase",
"type": "main",
"index": 0
}
]
]
},
"HTTP - Empleados BambooHR Bono14 GT": {
"main": [
[
{
"node": "Normalizar BambooHR Bono14 GT",
"type": "main",
"index": 0
}
]
]
},
"Normalizar BambooHR Bono14 GT": {
"main": [
[
{
"node": "Merge - Agregar BambooHR Bono14",
"type": "main",
"index": 1
}
]
]
},
"Merge - Agregar BambooHR Bono14": {
"main": [
[
{
"node": "Obtener alias BambooHR Bono14 Supabase",
"type": "main",
"index": 0
}
]
]
},
"Obtener alias BambooHR Bono14 Supabase": {
"main": [
[
{
"node": "Cruzar Bono 14 vs Banco",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"binaryMode": "separate"
},
"staticData": null,
"meta": null,
"versionId": "fa138609-9867-4b15-b685-759ea211b234",
"activeVersionId": "fa138609-9867-4b15-b685-759ea211b234",
"versionCounter": 37,
"triggerCount": 1,
"shared": [
{
"updatedAt": "2026-08-12T12:10:09.929Z",
"createdAt": "2026-08-12T12:10:09.929Z",
"role": "workflow:owner",
"workflowId": "ox03EQlSmgjUCQuK",
"projectId": "PJpTANzTXIFibWsW",
"project": {
"updatedAt": "2026-04-22T14:25:09.686Z",
"createdAt": "2026-04-22T14:22:54.790Z",
"id": "PJpTANzTXIFibWsW",
"name": "Isaac Aracena ",
"type": "personal",
"icon": null,
"description": null,
"creatorId": "0a88c0b1-928e-4412-896e-c5d1c99b2029"
}
}
],
"tags": [],
"activeVersion": {
"updatedAt": "2026-08-14T12:12:03.000Z",
"createdAt": "2026-08-14T12:11:58.931Z",
"versionId": "fa138609-9867-4b15-b685-759ea211b234",
"workflowId": "ox03EQlSmgjUCQuK",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "bono14gt-bamboo-test",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
464,
2736
],
"id": "e3fa9074-3d8c-4986-a921-750c71cc5eee",
"name": "Webhook",
"webhookId": "83f02b5a-2322-42a3-9ca9-263622bd7a34"
},
{
"parameters": {
"jsCode": "const item = $input.first();\nconst body = item.json.body || {};\nconst binary = item.binary || {};\n\nlet metadata = {};\n\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);\nconst payrollKey = binaryKeys.find((key) => key === 'payroll_file');\nconst bankKeys = binaryKeys.filter((key) => key.startsWith('bank_files'));\n\nconst payrollFile = payrollKey\n ? {\n binary_key: payrollKey,\n file_name: binary[payrollKey].fileName,\n file_extension: binary[payrollKey].fileExtension,\n mime_type: binary[payrollKey].mimeType,\n file_size: binary[payrollKey].fileSize,\n }\n : null;\n\nconst bankFiles = bankKeys.map((key) => ({\n binary_key: key,\n file_name: binary[key].fileName,\n file_extension: binary[key].fileExtension,\n mime_type: binary[key].mimeType,\n file_size: binary[key].fileSize,\n}));\n\nconst year = Number(metadata.year || 0);\nconst previousYear = year > 0 ? year - 1 : 0;\nconst periodStart = previousYear ? `${previousYear}-07-01` : '';\nconst periodEnd = year ? `${year}-06-30` : '';\nconst errors = [];\n\nif (String(metadata.country || '').trim().toUpperCase() !== 'GT') {\n errors.push('El país recibido no es Guatemala.');\n}\n\nif (!Number.isInteger(year) || year < 2020 || year > 2100) {\n errors.push('No se recibió un año válido para Bono 14.');\n}\n\nif (Number(metadata.month || 7) !== 7) {\n errors.push('El Bono 14 debe registrarse en julio.');\n}\n\nif (String(metadata.period_type || '').trim().toLowerCase() !== 'bono14') {\n errors.push('El tipo de período recibido no corresponde a Bono 14.');\n}\n\nif (!payrollFile) {\n errors.push('No se recibió el archivo Excel de Bono 14.');\n}\n\nif (bankFiles.length === 0) {\n errors.push('No se recibió ningún archivo CSV bancario del Bono 14.');\n}\n\nconst normalizedMetadata = {\n ...metadata,\n country: 'GT',\n country_name: 'Guatemala',\n year,\n month: 7,\n period_type: 'bono14',\n report_kind: 'bono14',\n period_start: periodStart,\n period_end: periodEnd,\n period_label:\n metadata.period_label ||\n `Bono 14 ${year} · Julio ${previousYear} a junio ${year}`,\n source_app:\n metadata.source_app ||\n 'portal-cruce-cuentas-glm-bono14',\n payroll_file_name:\n metadata.payroll_file_name ||\n payrollFile?.file_name ||\n '',\n bank_file_names:\n metadata.bank_file_names ||\n bankFiles.map((file) => file.file_name),\n};\n\nreturn [\n {\n json: {\n ok: errors.length === 0,\n stage: 'entrada_bono14_recibida',\n errors,\n metadata: normalizedMetadata,\n payroll_file: payrollFile,\n bank_files: bankFiles,\n summary: {\n payroll_files_count: payrollFile ? 1 : 0,\n bank_files_count: bankFiles.length,\n },\n },\n binary,\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
672,
2736
],
"id": "57bcbea7-e1f7-44be-a754-6758d0ff48b9",
"name": "Preparar entrada app"
},
{
"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": [
11888,
3120
],
"id": "fb71b850-d364-4c3e-aa2d-f29684c272fd",
"name": "Respond to Webhook"
},
{
"parameters": {
"jsCode": "const input = $input.first();\nconst json = input.json || {};\nconst binary = input.binary || {};\n\nfunction parseCsvLine(line) {\n const result = [];\n let current = '';\n let insideQuotes = false;\n\n for (let i = 0; i < line.length; i++) {\n const char = line[i];\n const nextChar = line[i + 1];\n\n if (char === '\"' && insideQuotes && nextChar === '\"') {\n current += '\"';\n i += 1;\n continue;\n }\n\n if (char === '\"') {\n insideQuotes = !insideQuotes;\n continue;\n }\n\n if (char === ',' && !insideQuotes) {\n result.push(current.trim());\n current = '';\n continue;\n }\n\n current += char;\n }\n\n result.push(current.trim());\n return result;\n}\n\nfunction normalizeText(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeForCompare(value) {\n return normalizeText(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 isValidAccount(value) {\n const account = normalizeAccount(value);\n return account.length >= 7 && !/^0+$/.test(account);\n}\n\nfunction parseMoney(value) {\n const raw = String(value ?? '');\n const cleaned = raw\n .replace(/USD/gi, '')\n .replace(/GTQ/gi, '')\n .replace(/QTZ/gi, '')\n .replace(/Q/gi, '')\n .replace(/,/g, '')\n .replace(/\\s+/g, '')\n .trim();\n\n const parsed = Number.parseFloat(cleaned);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction detectCurrency(value, concept) {\n const combined = `${value ?? ''} ${concept ?? ''}`.toUpperCase();\n return combined.includes('USD') ? 'USD' : 'QTZ';\n}\n\nfunction roundMoney(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n}\n\nfunction getColumnIndex(headers, expectedNames) {\n const normalizedHeaders = headers.map((header) => normalizeForCompare(header));\n\n for (const expected of expectedNames) {\n const normalizedExpected = normalizeForCompare(expected);\n const exact = normalizedHeaders.findIndex((header) => header === normalizedExpected);\n if (exact >= 0) return exact;\n }\n\n for (const expected of expectedNames) {\n const normalizedExpected = normalizeForCompare(expected);\n const partial = normalizedHeaders.findIndex((header) => header.includes(normalizedExpected));\n if (partial >= 0) return partial;\n }\n\n return -1;\n}\n\nfunction extractShipmentNumber(lines) {\n for (const line of lines.slice(0, 30)) {\n const normalized = normalizeForCompare(line);\n const match = normalized.match(/(?:detalle del envio|numero de envio)[^0-9]{0,30}(\\d{1,20})/);\n if (match?.[1]) return match[1];\n }\n\n return '';\n}\n\nfunction extractPlanNumber(lines) {\n for (const line of lines.slice(0, 30)) {\n const normalized = normalizeForCompare(line);\n const match = normalized.match(/(?:numero de plan|plan)[^a-z0-9]{0,20}([a-z0-9-]{2,30})/);\n if (match?.[1]) return match[1].toUpperCase();\n }\n\n return '';\n}\n\nfunction getNameWords(value) {\n const ignored = new Set(['de', 'del', 'la', 'las', 'los', 'y', 'e', 'el']);\n return normalizeForCompare(value)\n .split(' ')\n .filter((word) => word.length > 1 && !ignored.has(word));\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\n const previous = Array.from({ length: b.length + 1 }, (_, index) => index);\n\n for (let i = 1; i <= a.length; i++) {\n const current = [i];\n\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(\n current[j - 1] + 1,\n previous[j] + 1,\n previous[j - 1] + cost\n );\n }\n\n for (let j = 0; j < current.length; j++) previous[j] = current[j];\n }\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 = normalizeForCompare(a);\n const normalizedB = normalizeForCompare(b);\n\n if (!normalizedA || !normalizedB) return false;\n if (normalizedA === normalizedB) return true;\n\n const wordsA = getNameWords(a);\n const wordsB = getNameWords(b);\n if (!wordsA.length || !wordsB.length) return false;\n\n const usedB = new Set();\n let matches = 0;\n\n for (const wordA of wordsA) {\n const matchIndex = wordsB.findIndex((wordB, index) => {\n return !usedB.has(index) && tokenMatches(wordA, wordB);\n });\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\n if (smallerLength <= 2) return matches === smallerLength && matches >= 2;\n return matches >= 2 && ratio >= 0.6;\n}\n\nconst bankKeys = Object.keys(binary).filter((key) => key.startsWith('bank_files'));\nconst allBankRows = [];\nconst fileSummaries = [];\nconst nameDifferences = [];\n\nfor (const key of bankKeys) {\n const file = binary[key];\n const buffer = await this.helpers.getBinaryDataBuffer(0, key);\n const text = buffer.toString('latin1');\n\n const lines = text\n .split(/\\r?\\n/)\n .map((line) => line.trim())\n .filter((line) => line.length > 0);\n\n const shipmentNumber = extractShipmentNumber(lines);\n const planNumber = extractPlanNumber(lines);\n\n const markerIndex = lines.findIndex((line) =>\n normalizeForCompare(line).includes('transacciones del envio')\n );\n\n if (markerIndex === -1) {\n fileSummaries.push({\n file_name: file.fileName,\n shipment_number: shipmentNumber,\n plan_number: planNumber,\n ok: false,\n rows_count: 0,\n total_amount: 0,\n error: 'No se encontró el bloque \"Transacciones del envío\".',\n });\n continue;\n }\n\n const headerIndex = lines.findIndex((line, index) => {\n if (index <= markerIndex) return false;\n const normalized = normalizeForCompare(line);\n return normalized.includes('cuenta destino') && normalized.includes('monto');\n });\n\n if (headerIndex === -1) {\n fileSummaries.push({\n file_name: file.fileName,\n shipment_number: shipmentNumber,\n plan_number: planNumber,\n ok: false,\n rows_count: 0,\n total_amount: 0,\n error: 'No se encontró el encabezado de transacciones.',\n });\n continue;\n }\n\n const headers = parseCsvLine(lines[headerIndex]).map(normalizeText);\n\n const idxCuentaDestino = getColumnIndex(headers, ['Cuenta Destino']);\n const idxNombreArchivo = getColumnIndex(headers, ['Nombre en Archivo']);\n const idxNombreCuentahabiente = getColumnIndex(headers, ['Nombre del Cuentahabiente']);\n const idxMonto = getColumnIndex(headers, ['Monto']);\n const idxConcepto = getColumnIndex(headers, ['Concepto']);\n const idxEstado = getColumnIndex(headers, ['Estado']);\n const idxReferencia = getColumnIndex(headers, ['Referencia']);\n const idxNumeroEnvio = getColumnIndex(headers, ['Número de envío', 'Numero de envio']);\n const idxNumeroPlan = getColumnIndex(headers, ['Número de plan', 'Numero de plan']);\n\n const rowsFromFile = [];\n\n for (let i = headerIndex + 1; i < lines.length; i++) {\n const values = parseCsvLine(lines[i]);\n\n const rawAccount = idxCuentaDestino >= 0 ? values[idxCuentaDestino] : '';\n const reference = normalizeText(idxReferencia >= 0 ? values[idxReferencia] : '');\n const referenceDigits = normalizeAccount(reference);\n const account = normalizeAccount(rawAccount);\n const amountRaw = idxMonto >= 0 ? values[idxMonto] : '';\n const amount = roundMoney(parseMoney(amountRaw));\n const concept = normalizeText(idxConcepto >= 0 ? values[idxConcepto] : '');\n\n if (amount <= 0) continue;\n\n const bankNameFile = normalizeText(idxNombreArchivo >= 0 ? values[idxNombreArchivo] : '');\n const bankAccountHolder = normalizeText(\n idxNombreCuentahabiente >= 0 ? values[idxNombreCuentahabiente] : ''\n );\n\n const validAccount = isValidAccount(account);\n const fallbackReference = isValidAccount(referenceDigits) ? referenceDigits : '';\n const displayAccount = validAccount ? account : fallbackReference;\n const currency = detectCurrency(amountRaw, concept);\n\n const rowShipment = normalizeText(idxNumeroEnvio >= 0 ? values[idxNumeroEnvio] : '') || shipmentNumber;\n const rowPlan = normalizeText(idxNumeroPlan >= 0 ? values[idxNumeroPlan] : '') || planNumber;\n\n const groupKey = validAccount\n ? `ACCOUNT:${account}:${currency}`\n : fallbackReference\n ? `REFERENCE:${fallbackReference}:${currency}`\n : `ROW:${file.fileName}:${i + 1}:${currency}`;\n\n const row = {\n source_file: file.fileName,\n row_number: i + 1,\n group_key: groupKey,\n account: displayAccount,\n raw_account: account,\n account_is_valid: validAccount,\n reference,\n shipment_number: rowShipment,\n plan_number: rowPlan,\n bank_name_file: bankNameFile,\n bank_account_holder: bankAccountHolder,\n amount,\n currency,\n concept,\n status: normalizeText(idxEstado >= 0 ? values[idxEstado] : ''),\n };\n\n rowsFromFile.push(row);\n allBankRows.push(row);\n\n if (\n bankNameFile &&\n bankAccountHolder &&\n !samePersonName(bankNameFile, bankAccountHolder)\n ) {\n nameDifferences.push({\n id: `bank_name_difference_${file.fileName}_${i + 1}`,\n source_file: file.fileName,\n row_number: i + 1,\n shipment_number: rowShipment,\n plan_number: rowPlan,\n account: displayAccount,\n reference,\n bank_name_file: bankNameFile,\n bank_account_holder: bankAccountHolder,\n amount,\n currency,\n status: 'Pendiente revisión',\n category: 'diferencia_nombre_banco',\n observation: `El Nombre en Archivo (${bankNameFile}) no coincide con el Nombre del Cuentahabiente (${bankAccountHolder}).`,\n });\n }\n }\n\n const fileTotal = roundMoney(rowsFromFile.reduce((sum, row) => sum + row.amount, 0));\n\n fileSummaries.push({\n file_name: file.fileName,\n shipment_number: shipmentNumber,\n plan_number: planNumber,\n ok: true,\n rows_count: rowsFromFile.length,\n total_amount: fileTotal,\n name_differences_count: nameDifferences.filter((row) => row.source_file === file.fileName).length,\n error: null,\n });\n}\n\nconst groupedMap = new Map();\n\nfor (const row of allBankRows) {\n const current = groupedMap.get(row.group_key) || {\n group_key: row.group_key,\n account: row.account,\n raw_account: row.raw_account,\n account_is_valid: row.account_is_valid,\n amount: 0,\n currency: row.currency,\n transactions_count: 0,\n bank_name_files: new Set(),\n bank_account_holders: new Set(),\n source_files: new Set(),\n shipment_numbers: new Set(),\n plan_numbers: new Set(),\n source_rows: [],\n };\n\n current.amount = roundMoney(current.amount + row.amount);\n current.transactions_count += 1;\n if (row.bank_name_file) current.bank_name_files.add(row.bank_name_file);\n if (row.bank_account_holder) current.bank_account_holders.add(row.bank_account_holder);\n if (row.source_file) current.source_files.add(row.source_file);\n if (row.shipment_number) current.shipment_numbers.add(row.shipment_number);\n if (row.plan_number) current.plan_numbers.add(row.plan_number);\n current.source_rows.push(row);\n\n groupedMap.set(row.group_key, current);\n}\n\nconst groupedByAccount = Array.from(groupedMap.values()).map((row) => {\n const bankNameFiles = Array.from(row.bank_name_files);\n const bankAccountHolders = Array.from(row.bank_account_holders);\n\n return {\n ...row,\n bank_name_file: bankNameFiles[0] || '',\n bank_account_holder: bankAccountHolders[0] || '',\n bank_name_files: bankNameFiles,\n bank_account_holders: bankAccountHolders,\n source_files: Array.from(row.source_files),\n shipment_numbers: Array.from(row.shipment_numbers),\n plan_numbers: Array.from(row.plan_numbers),\n };\n});\n\nconst totalsByCurrency = {};\nfor (const row of allBankRows) {\n totalsByCurrency[row.currency] = roundMoney((totalsByCurrency[row.currency] || 0) + row.amount);\n}\n\nconst bankTotal = roundMoney(allBankRows.reduce((sum, row) => sum + row.amount, 0));\n\nreturn [\n {\n json: {\n ...json,\n stage: 'banco_parseado',\n bank: {\n files_count: bankKeys.length,\n valid_files_count: fileSummaries.filter((file) => file.ok).length,\n rows_count: allBankRows.length,\n grouped_accounts_count: groupedByAccount.length,\n total_amount: bankTotal,\n totals_by_currency: totalsByCurrency,\n name_differences_count: nameDifferences.length,\n name_differences: nameDifferences,\n file_summaries: fileSummaries,\n rows: allBankRows,\n grouped_by_account: groupedByAccount,\n },\n },\n binary,\n },\n];\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1344,
1920
],
"id": "c3fcbd08-860d-4940-8d0e-55142327d07e",
"name": "Parsear CSV banco Bono 14 GT"
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"options": {}
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
7152,
3280
],
"id": "e8a66686-6888-4b30-9f0e-38e8d896d57e",
"name": "Merge"
},
{
"parameters": {
"jsCode": "const data = $('Merge - Agregar BambooHR Bono14').first().json || {};\nconst aliasResponse = $input.first().json || {};\nconst validatedBambooAliases = Array.isArray(aliasResponse.aliases)\n ? aliasResponse.aliases.filter((alias) => alias && alias.activo !== false)\n : [];\n\nfunction roundMoney(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n}\n\nfunction moneyDiff(a, b) {\n return roundMoney((Number(a) || 0) - (Number(b) || 0));\n}\n\nfunction moneyEquals(a, b, tolerance = 0.02) {\n return Math.abs(roundMoney(a) - roundMoney(b)) <= tolerance;\n}\n\nfunction normalizeAccount(value) {\n return String(value ?? '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim();\n}\n\nfunction normalizeName(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 nameWords(value) {\n const ignored = new Set(['de', 'del', 'la', 'las', 'los', 'y', 'e', 'el']);\n return normalizeName(value)\n .split(' ')\n .filter((word) => word.length > 1 && !ignored.has(word));\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\n const previous = Array.from({ length: b.length + 1 }, (_, index) => index);\n\n for (let i = 1; i <= a.length; i++) {\n const current = [i];\n\n for (let j = 1; j <= b.length; j++) {\n const cost = a[i - 1] === b[j - 1] ? 0 : 1;\n\n current[j] = Math.min(\n current[j - 1] + 1,\n previous[j] + 1,\n previous[j - 1] + cost\n );\n }\n\n for (let j = 0; j < current.length; j++) {\n previous[j] = current[j];\n }\n }\n\n return previous[b.length];\n}\n\nfunction tokenMatches(a, b) {\n if (a === b) return true;\n\n const minLength = Math.min(a.length, b.length);\n\n if (minLength >= 8 && editDistance(a, b) <= 2) return true;\n if (minLength >= 5 && editDistance(a, b) <= 1) return true;\n\n return false;\n}\n\nfunction samePersonName(a, b) {\n const normalizedA = normalizeName(a);\n const normalizedB = normalizeName(b);\n\n if (!normalizedA || !normalizedB) return false;\n if (normalizedA === normalizedB) return true;\n\n const wordsA = nameWords(a);\n const wordsB = nameWords(b);\n\n if (!wordsA.length || !wordsB.length) return false;\n\n const usedB = new Set();\n let matches = 0;\n\n for (const wordA of wordsA) {\n const matchIndex = wordsB.findIndex((wordB, index) => {\n return !usedB.has(index) && tokenMatches(wordA, wordB);\n });\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\n if (smallerLength <= 2) {\n return matches === smallerLength && matches >= 2;\n }\n\n return matches >= 2 && ratio >= 0.6;\n}\n\nfunction accountDistance(a, b) {\n return editDistance(normalizeAccount(a), normalizeAccount(b));\n}\n\nfunction accountRelationship(payrollAccount, bankAccount) {\n const payroll = normalizeAccount(payrollAccount);\n const bank = normalizeAccount(bankAccount);\n\n if (!payroll || !bank) {\n return { matches: false, type: 'none' };\n }\n\n if (payroll === bank) {\n return { matches: true, type: 'exact' };\n }\n\n const bankHasPayrollSuffix =\n bank.endsWith(payroll) &&\n bank.length > payroll.length &&\n bank.length - payroll.length <= 6;\n\n const payrollHasBankSuffix =\n payroll.endsWith(bank) &&\n payroll.length > bank.length &&\n payroll.length - bank.length <= 6;\n\n if (bankHasPayrollSuffix || payrollHasBankSuffix) {\n return { matches: true, type: 'reference_prefix' };\n }\n\n return { matches: false, type: 'none' };\n}\n\nfunction formatMoney(value) {\n return Math.abs(roundMoney(value)).toLocaleString('en-US', {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n });\n}\n\nfunction bankNames(bank) {\n return Array.from(new Set([\n ...(Array.isArray(bank.bank_name_files) ? bank.bank_name_files : []),\n ...(Array.isArray(bank.bank_account_holders) ? bank.bank_account_holders : []),\n bank.bank_name_file || '',\n bank.bank_account_holder || '',\n ].filter(Boolean)));\n}\n\nfunction bankMatchesName(bank, payrollName) {\n return bankNames(bank).some((name) => samePersonName(payrollName, name));\n}\n\nfunction bestBankDisplayName(bank) {\n return (\n bank.bank_name_file ||\n bank.bank_account_holder ||\n bankNames(bank)[0] ||\n ''\n );\n}\n\n\nfunction bambooAliases(employee) {\n return Array.from(new Set([\n ...(Array.isArray(employee.aliases) ? employee.aliases : []),\n employee.full_name || '',\n [\n employee.first_name,\n employee.middle_name,\n employee.last_name,\n ].filter(Boolean).join(' '),\n [\n employee.preferred_name,\n employee.last_name,\n ].filter(Boolean).join(' '),\n [\n employee.first_name,\n employee.last_name,\n ].filter(Boolean).join(' '),\n ].map((value) =>\n String(value || '').trim()\n ).filter(Boolean)));\n}\n\nfunction bambooEmployeeNumber(employee) {\n return normalizeAccount(\n employee.employee_number ||\n employee.employeeNumber ||\n ''\n );\n}\n\nfunction bankRowKey(row) {\n return [\n row.source_file || '',\n row.row_number || '',\n ].join('|');\n}\n\nfunction bankRowNames(row) {\n const rowKey = bankRowKey(row);\n\n const linkedPayrollNames =\n typeof linkedPayrollNamesByBankRow !== 'undefined'\n ? linkedPayrollNamesByBankRow.get(rowKey) || []\n : [];\n\n return Array.from(new Set([\n row.bank_name_file || '',\n row.bank_account_holder || '',\n row.participant_name || '',\n ...linkedPayrollNames,\n ].map((value) =>\n String(value || '').trim()\n ).filter(Boolean)));\n}\n\nfunction bankRowEmployeeNumbers(row) {\n const rowKey = bankRowKey(row);\n\n const linkedNumbers =\n typeof linkedPayrollNumbersByBankRow !== 'undefined'\n ? linkedPayrollNumbersByBankRow.get(rowKey) || []\n : [];\n\n return Array.from(new Set(\n linkedNumbers\n .map(normalizeAccount)\n .filter((value) => value.length >= 6)\n ));\n}\n\nfunction extractDigitSequences(value) {\n return Array.from(new Set(\n String(value || '')\n .match(/\\d{6,20}/g) || []\n )).map(normalizeAccount).filter(\n (value) => value.length >= 6\n );\n}\n\nfunction bankRowReferenceNumbers(row) {\n return Array.from(new Set([\n ...extractDigitSequences(row.reference),\n ...extractDigitSequences(row.concept),\n ...extractDigitSequences(row.addenda),\n ...extractDigitSequences(row.participant_id),\n ]));\n}\n\nfunction isClearlyNonEmployeePayment(row) {\n const normalized = normalizeName([\n row.concept || '',\n row.bank_name_file || '',\n row.bank_account_holder || '',\n ].join(' '));\n\n return [\n 'pension alimenticia',\n 'embargo judicial',\n 'retencion judicial',\n ].some((token) =>\n normalized.includes(normalizeName(token))\n );\n}\n\nfunction buildBambooSearchIndex(employees) {\n const records = [];\n const exactAliasSets = new Map();\n const tokenIndexSets = new Map();\n const employeeNumberSets = new Map();\n\n for (\n let employeeIndex = 0;\n employeeIndex < employees.length;\n employeeIndex++\n ) {\n const employee = employees[employeeIndex];\n const aliases = [];\n const seenAliases = new Set();\n\n for (const rawAlias of bambooAliases(employee)) {\n const normalized = normalizeName(rawAlias);\n\n if (\n !normalized ||\n seenAliases.has(normalized)\n ) {\n continue;\n }\n\n seenAliases.add(normalized);\n\n const words = Array.from(new Set(\n nameWords(normalized)\n ));\n\n if (!words.length) continue;\n\n const wordSet = new Set(words);\n\n aliases.push({\n raw: rawAlias,\n normalized,\n words,\n wordSet,\n });\n\n let exactSet =\n exactAliasSets.get(normalized);\n\n if (!exactSet) {\n exactSet = new Set();\n exactAliasSets.set(\n normalized,\n exactSet\n );\n }\n\n exactSet.add(employeeIndex);\n\n for (const token of words) {\n if (token.length < 3) continue;\n\n let tokenSet =\n tokenIndexSets.get(token);\n\n if (!tokenSet) {\n tokenSet = new Set();\n tokenIndexSets.set(\n token,\n tokenSet\n );\n }\n\n tokenSet.add(employeeIndex);\n }\n }\n\n const employeeNumber =\n bambooEmployeeNumber(employee);\n\n if (employeeNumber.length >= 6) {\n let numberSet =\n employeeNumberSets.get(\n employeeNumber\n );\n\n if (!numberSet) {\n numberSet = new Set();\n employeeNumberSets.set(\n employeeNumber,\n numberSet\n );\n }\n\n numberSet.add(employeeIndex);\n }\n\n records.push({\n employee,\n aliases,\n employeeNumber,\n });\n }\n\n const exactAliasMap = new Map();\n const tokenIndex = new Map();\n const employeeNumberMap = new Map();\n\n for (const [key, value] of exactAliasSets) {\n exactAliasMap.set(\n key,\n Array.from(value)\n );\n }\n\n for (const [key, value] of tokenIndexSets) {\n tokenIndex.set(\n key,\n Array.from(value)\n );\n }\n\n for (\n const [key, value] of\n employeeNumberSets\n ) {\n employeeNumberMap.set(\n key,\n Array.from(value)\n );\n }\n\n return {\n records,\n exactAliasMap,\n tokenIndex,\n employeeNumberMap,\n };\n}\n\nfunction aliasMatchDetails(\n queryName,\n alias\n) {\n const queryNormalized =\n normalizeName(queryName);\n\n if (!queryNormalized) return null;\n\n if (\n queryNormalized === alias.normalized\n ) {\n return {\n score: 1,\n exact: true,\n containment: true,\n matchedTokens:\n alias.words.length,\n exactMatches:\n alias.words.length,\n fuzzyMatches: 0,\n queryCoverage: 1,\n aliasCoverage: 1,\n };\n }\n\n const queryWords = Array.from(\n new Set(nameWords(queryNormalized))\n );\n\n if (\n queryWords.length < 2 ||\n alias.words.length < 2\n ) {\n return null;\n }\n\n const querySet = new Set(queryWords);\n\n let exactMatches = 0;\n\n for (const queryWord of queryWords) {\n if (alias.wordSet.has(queryWord)) {\n exactMatches += 1;\n }\n }\n\n const queryInsideAlias =\n exactMatches === queryWords.length;\n\n let aliasWordsInsideQuery = 0;\n\n for (const aliasWord of alias.words) {\n if (querySet.has(aliasWord)) {\n aliasWordsInsideQuery += 1;\n }\n }\n\n const aliasInsideQuery =\n aliasWordsInsideQuery ===\n alias.words.length;\n\n if (\n queryInsideAlias ||\n aliasInsideQuery\n ) {\n const shorterLength = Math.min(\n queryWords.length,\n alias.words.length\n );\n const longerLength = Math.max(\n queryWords.length,\n alias.words.length\n );\n\n return {\n score:\n 0.90 +\n (\n shorterLength /\n Math.max(1, longerLength)\n ) * 0.09,\n exact: false,\n containment: true,\n matchedTokens: exactMatches,\n exactMatches,\n fuzzyMatches: 0,\n queryCoverage:\n exactMatches / queryWords.length,\n aliasCoverage:\n aliasWordsInsideQuery /\n alias.words.length,\n };\n }\n\n if (exactMatches < 2) {\n return null;\n }\n\n const usedAliasWords = new Set();\n let fuzzyMatches = 0;\n\n for (let queryIndex = 0;\n queryIndex < queryWords.length;\n queryIndex++\n ) {\n const queryWord =\n queryWords[queryIndex];\n\n if (alias.wordSet.has(queryWord)) {\n continue;\n }\n\n const aliasIndex =\n alias.words.findIndex(\n (aliasWord, currentIndex) =>\n !usedAliasWords.has(\n currentIndex\n ) &&\n !querySet.has(aliasWord) &&\n tokenMatches(\n queryWord,\n aliasWord\n )\n );\n\n if (aliasIndex >= 0) {\n usedAliasWords.add(aliasIndex);\n fuzzyMatches += 1;\n }\n }\n\n const matchedTokens =\n exactMatches + fuzzyMatches;\n\n if (\n fuzzyMatches > 1 ||\n matchedTokens < 3\n ) {\n return null;\n }\n\n const queryCoverage =\n matchedTokens / queryWords.length;\n\n const aliasCoverage =\n matchedTokens / alias.words.length;\n\n if (\n queryCoverage < 0.67 ||\n aliasCoverage < 0.60\n ) {\n return null;\n }\n\n return {\n score:\n queryCoverage * 0.48 +\n aliasCoverage * 0.32 +\n (\n exactMatches /\n matchedTokens\n ) * 0.20,\n exact: false,\n containment: false,\n matchedTokens,\n exactMatches,\n fuzzyMatches,\n queryCoverage,\n aliasCoverage,\n };\n}\n\nfunction candidateIndexesForName(\n normalizedName\n) {\n const tokens = Array.from(\n new Set(nameWords(normalizedName))\n ).filter((token) =>\n token.length >= 3\n );\n\n const votes = new Map();\n\n for (const token of tokens) {\n const indexes =\n bambooSearch.tokenIndex.get(token) || [];\n\n /*\n * Los tokens muy comunes no aportan suficiente identidad.\n * Ignorarlos evita cientos de candidatos y mantiene el nodo rápido.\n */\n if (indexes.length > 240) continue;\n\n for (const index of indexes) {\n votes.set(\n index,\n (votes.get(index) || 0) + 1\n );\n }\n }\n\n return Array.from(votes.entries())\n .filter(([, voteCount]) =>\n voteCount >= 2 ||\n (\n tokens.length === 2 &&\n voteCount === 2\n )\n )\n .sort((left, right) =>\n right[1] - left[1]\n )\n .slice(0, 90)\n .map(([index]) => index);\n}\n\nconst bambooMatchCache = new Map();\n\nfunction findBambooMatch(bankRow) {\n const names = bankRowNames(bankRow)\n .map((raw) => ({\n raw,\n normalized:\n normalizeName(raw),\n tokenCount:\n nameWords(raw).length,\n }))\n .filter((entry) =>\n entry.normalized &&\n entry.tokenCount >= 2\n )\n .sort((left, right) =>\n right.tokenCount -\n left.tokenCount\n );\n\n const directEmployeeNumbers =\n bankRowEmployeeNumbers(bankRow);\n\n const referenceNumbers =\n bankRowReferenceNumbers(bankRow);\n\n const cacheKey = [\n ...directEmployeeNumbers\n .slice()\n .sort(),\n ...referenceNumbers\n .slice()\n .sort(),\n ...names\n .map((entry) =>\n entry.normalized\n )\n .sort(),\n ].join('|');\n\n if (bambooMatchCache.has(cacheKey)) {\n return bambooMatchCache.get(\n cacheKey\n );\n }\n\n const numberCandidates = new Set();\n\n for (const employeeNumber of [\n ...directEmployeeNumbers,\n ...referenceNumbers,\n ]) {\n for (\n const index of\n bambooSearch.employeeNumberMap\n .get(employeeNumber) || []\n ) {\n numberCandidates.add(index);\n }\n }\n\n if (numberCandidates.size === 1) {\n const index =\n numberCandidates.values()\n .next().value;\n\n const result = {\n found: true,\n matched_by:\n directEmployeeNumbers.length\n ? 'employee_number_payroll'\n : 'employee_number_reference',\n confidence: 1,\n employee:\n bambooSearch.records[index]\n .employee,\n };\n\n bambooMatchCache.set(\n cacheKey,\n result\n );\n\n return result;\n }\n\n\n /*\n * Consulta primero la resolución calculada una sola vez en el\n * normalizador. Esto evita repetir búsquedas aproximadas por cada fila\n * bancaria y mantiene el task runner estable incluso con miles de\n * empleados en BambooHR.\n */\n const precomputedNameMatches =\n data.bamboo?.resolved_name_matches ||\n {};\n\n const precomputedNameEntries =\n names.map((entry) => {\n const raw =\n typeof entry === 'string'\n ? entry\n : entry?.raw || '';\n\n return {\n raw,\n normalized:\n typeof entry === 'string'\n ? normalizeName(entry)\n : (\n entry?.normalized ||\n normalizeName(raw)\n ),\n token_count:\n typeof entry === 'string'\n ? nameWords(entry).length\n : (\n entry?.tokenCount ||\n nameWords(raw).length\n ),\n };\n }).filter((entry) =>\n entry.normalized\n );\n\n const precomputedFoundByEmployee =\n new Map();\n\n function resolutionEmployeeKey(\n employee\n ) {\n return (\n String(\n employee?.bamboo_id ||\n ''\n ).trim() ||\n normalizeAccount(\n employee?.employee_number ||\n employee?.employeeNumber ||\n ''\n ) ||\n normalizeName(\n employee?.full_name ||\n employee?.displayName ||\n ''\n )\n );\n }\n\n for (\n const nameEntry of\n precomputedNameEntries\n ) {\n const decision =\n precomputedNameMatches[\n nameEntry.normalized\n ];\n\n if (\n !decision ||\n decision.found !== true\n ) {\n continue;\n }\n\n let employee =\n Number.isInteger(\n decision.employee_index\n )\n ? bambooEmployees[\n decision.employee_index\n ]\n : null;\n\n const expectedKey =\n String(\n decision.employee_key ||\n ''\n ).trim();\n\n if (\n !employee ||\n (\n expectedKey &&\n resolutionEmployeeKey(\n employee\n ) !== expectedKey\n )\n ) {\n employee =\n bambooEmployees.find(\n (candidate) =>\n resolutionEmployeeKey(\n candidate\n ) === expectedKey\n ) || null;\n }\n\n if (!employee) continue;\n\n const employeeKey =\n resolutionEmployeeKey(employee);\n\n const candidate = {\n employee,\n employee_key:\n employeeKey,\n confidence:\n Number(\n decision.confidence || 0\n ),\n matched_by:\n decision.matched_by ||\n 'precomputed_name',\n bank_name:\n nameEntry.raw,\n bamboo_alias:\n decision.bamboo_alias ||\n employee.full_name ||\n '',\n informativeness:\n nameEntry.token_count,\n };\n\n const existing =\n precomputedFoundByEmployee\n .get(employeeKey);\n\n if (\n !existing ||\n candidate.confidence >\n existing.confidence ||\n (\n candidate.confidence ===\n existing.confidence &&\n candidate.informativeness >\n existing.informativeness\n )\n ) {\n precomputedFoundByEmployee.set(\n employeeKey,\n candidate\n );\n }\n }\n\n const precomputedRanked =\n Array.from(\n precomputedFoundByEmployee\n .values()\n ).sort((left, right) => {\n if (\n right.confidence !==\n left.confidence\n ) {\n return (\n right.confidence -\n left.confidence\n );\n }\n\n return (\n right.informativeness -\n left.informativeness\n );\n });\n\n if (precomputedRanked.length === 1) {\n const best =\n precomputedRanked[0];\n\n const result = {\n found: true,\n matched_by:\n best.matched_by,\n confidence:\n best.confidence,\n employee:\n best.employee,\n bank_name:\n best.bank_name,\n bamboo_alias:\n best.bamboo_alias,\n };\n\n bambooMatchCache.set(\n cacheKey,\n result\n );\n\n return result;\n }\n\n if (\n precomputedRanked.length > 1\n ) {\n const best =\n precomputedRanked[0];\n\n const second =\n precomputedRanked[1];\n\n if (\n best.confidence -\n second.confidence >= 0.08\n ) {\n const result = {\n found: true,\n matched_by:\n best.matched_by,\n confidence:\n best.confidence,\n employee:\n best.employee,\n bank_name:\n best.bank_name,\n bamboo_alias:\n best.bamboo_alias,\n };\n\n bambooMatchCache.set(\n cacheKey,\n result\n );\n\n return result;\n }\n\n const result = {\n found: false,\n matched_by: null,\n confidence:\n best.confidence,\n employee: null,\n ambiguous: true,\n reason:\n 'conflicting_precomputed_name_matches',\n best_candidate: {\n employee:\n best.employee,\n score:\n best.confidence,\n bank_name:\n best.bank_name,\n bamboo_alias:\n best.bamboo_alias,\n },\n };\n\n bambooMatchCache.set(\n cacheKey,\n result\n );\n\n return result;\n }\n\n\n /*\n * Primero se intenta una coincidencia textual exacta.\n * Se acepta solamente cuando todos los alias exactos apuntan\n * al mismo empleado.\n */\n const exactIndexes = new Set();\n\n for (const name of names) {\n for (\n const index of\n bambooSearch.exactAliasMap\n .get(name.normalized) || []\n ) {\n exactIndexes.add(index);\n }\n }\n\n if (exactIndexes.size === 1) {\n const index =\n exactIndexes.values()\n .next().value;\n\n const result = {\n found: true,\n matched_by: 'exact_name',\n confidence: 1,\n employee:\n bambooSearch.records[index]\n .employee,\n bank_name:\n names[0]?.raw || '',\n bamboo_alias:\n bambooSearch.records[index]\n .aliases[0]?.raw || '',\n };\n\n bambooMatchCache.set(\n cacheKey,\n result\n );\n\n return result;\n }\n\n const candidatesByEmployee =\n new Map();\n\n /*\n * La búsqueda ya no recorre todos los empleados.\n * Cada nombre consulta el índice invertido y solo compara\n * un máximo de 90 candidatos que comparten al menos dos palabras.\n */\n for (const name of names) {\n const candidateIndexes =\n candidateIndexesForName(\n name.normalized\n );\n\n for (const index of candidateIndexes) {\n const record =\n bambooSearch.records[index];\n\n let bestAliasMatch = null;\n let bestAlias = '';\n\n for (const alias of record.aliases) {\n const details =\n aliasMatchDetails(\n name.raw,\n alias\n );\n\n if (\n details &&\n (\n !bestAliasMatch ||\n details.score >\n bestAliasMatch.score\n )\n ) {\n bestAliasMatch = details;\n bestAlias = alias.raw;\n }\n }\n\n if (!bestAliasMatch) continue;\n\n const existing =\n candidatesByEmployee.get(index);\n\n const candidate = {\n index,\n employee: record.employee,\n score: bestAliasMatch.score,\n details: bestAliasMatch,\n bank_name: name.raw,\n bamboo_alias: bestAlias,\n informativeness:\n name.tokenCount,\n };\n\n if (\n !existing ||\n candidate.score >\n existing.score ||\n (\n candidate.score ===\n existing.score &&\n candidate.informativeness >\n existing.informativeness\n )\n ) {\n candidatesByEmployee.set(\n index,\n candidate\n );\n }\n }\n }\n\n const rankedCandidates =\n Array.from(\n candidatesByEmployee.values()\n ).sort((left, right) => {\n if (right.score !== left.score) {\n return right.score - left.score;\n }\n\n if (\n right.details.exactMatches !==\n left.details.exactMatches\n ) {\n return (\n right.details.exactMatches -\n left.details.exactMatches\n );\n }\n\n return (\n right.informativeness -\n left.informativeness\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.score -\n (second?.score || 0)\n : 0;\n\n const strongContainment =\n Boolean(\n best?.details?.containment &&\n best.details.exactMatches >= 2 &&\n (\n !second ||\n margin >= 0.035 ||\n best.details.exactMatches >\n second.details.exactMatches\n )\n );\n\n const strongPartial =\n Boolean(\n best &&\n !best.details.containment &&\n best.score >= 0.82 &&\n best.details.exactMatches >= 2 &&\n (\n !second ||\n margin >= 0.07\n )\n );\n\n let result;\n\n if (\n best &&\n (\n strongContainment ||\n strongPartial\n )\n ) {\n result = {\n found: true,\n matched_by:\n strongContainment\n ? 'unique_token_containment'\n : 'strong_indexed_name',\n confidence:\n Math.min(1, best.score),\n employee: best.employee,\n bank_name: best.bank_name,\n bamboo_alias:\n best.bamboo_alias,\n };\n } else {\n result = {\n found: false,\n matched_by: null,\n confidence:\n best?.score || 0,\n employee: null,\n ambiguous: Boolean(\n best &&\n second &&\n best.score >= 0.75 &&\n margin < 0.07\n ),\n best_candidate:\n best\n ? {\n employee:\n best.employee,\n score:\n best.score,\n bank_name:\n best.bank_name,\n bamboo_alias:\n best.bamboo_alias,\n }\n : null,\n };\n }\n\n bambooMatchCache.set(\n cacheKey,\n result\n );\n\n return result;\n}\n\nfunction supplementKey(supplement) {\n return [\n supplement.source_sheet || '',\n supplement.row_number || '',\n supplement.supplement_id || '',\n supplement.account || '',\n supplement.payroll_amount || 0,\n ].join('|');\n}\n\nconst payrollAccounts = (data.payroll?.grouped_by_account || [])\n .map((row) => ({\n ...row,\n group_key:\n row.group_key ||\n `${normalizeAccount(row.account)}:${row.currency || 'QTZ'}`,\n account: normalizeAccount(row.account),\n employee_name: row.employee_name || row.employee || '',\n employee_number: row.employee_number || row.employeeNumber || '',\n currency: row.currency || 'QTZ',\n payroll_amount: roundMoney(row.payroll_amount || row.payrollAmount),\n source_rows: Array.isArray(row.source_rows) ? [...row.source_rows] : [],\n source_sheets: Array.isArray(row.source_sheets)\n ? [...row.source_sheets]\n : [],\n }))\n .filter((row) => row.account && row.payroll_amount > 0);\n\nconst payrollNoAccountRows = (data.payroll?.no_account_rows || [])\n .map((row) => ({\n ...row,\n account: '',\n employee_name: row.employee_name || row.employee || '',\n employee_number: row.employee_number || row.employeeNumber || '',\n currency: row.currency || 'QTZ',\n payroll_amount: roundMoney(row.payroll_amount || row.payrollAmount),\n }))\n .filter((row) => row.payroll_amount > 0);\n\nconst bankAccounts = (data.bank?.grouped_by_account || [])\n .map((row) => ({\n ...row,\n group_key:\n row.group_key ||\n `ACCOUNT:${normalizeAccount(row.account)}:${row.currency || 'QTZ'}`,\n account: normalizeAccount(row.account),\n account_is_valid: Boolean(row.account_is_valid),\n currency: row.currency || 'QTZ',\n amount: roundMoney(row.amount || row.bank_amount || row.bankAmount),\n source_rows: Array.isArray(row.source_rows) ? [...row.source_rows] : [],\n }))\n .filter((row) => row.amount > 0);\n\nconst bambooEmployees = Array.isArray(data.bamboo?.employees)\n ? data.bamboo.employees\n : [];\n\nconst bambooValidationAvailable =\n data.bamboo?.fetch_complete === true &&\n data.bamboo?.validation_available === true &&\n bambooEmployees.length > 0;\n\nconst bambooValidationWarning =\n bambooValidationAvailable\n ? null\n : (\n data.errors?.find((error) =>\n String(error || '').toLowerCase().includes('bamboohr')\n ) ||\n 'La validación Banco sin Bamboo no estuvo disponible porque la descarga de empleados de BambooHR quedó incompleta.'\n );\n\nconst bambooSearch = buildBambooSearchIndex(\n bambooEmployees\n);\n\n/*\n * Equivalencias de identidad validadas manualmente por las revisoras.\n * Solo se aplican cuando el nombre detectado coincide EXACTAMENTE (ya\n * normalizado) con un alias activo y el empleado objetivo puede resolverse\n * de forma única en la descarga actual de BambooHR. Si existe cualquier\n * ambigüedad, el caso NO se oculta y continúa en Banco sin Bamboo.\n */\nfunction findValidatedAliasMatch(bankRow) {\n if (!validatedBambooAliases.length) return null;\n\n const sourceNames = new Set(\n bankRowNames(bankRow)\n .map((value) => normalizeName(value))\n .filter(Boolean)\n );\n\n if (!sourceNames.size) return null;\n\n const matchingAliases = validatedBambooAliases.filter((alias) => {\n if (!alias || alias.activo === false) return false;\n\n // Normalizamos el nombre ORIGINAL con las mismas reglas locales del\n // workflow. Así GT y TT conservan exactamente su tratamiento actual de\n // apóstrofes/guiones y no dependemos de cómo Supabase normalizó el texto.\n const source = normalizeName(\n alias.nombre_origen ||\n alias.nombre_origen_normalizado ||\n ''\n );\n\n if (!source || !sourceNames.has(source)) return false;\n\n // Si la validación humana se registró con una cuenta bancaria, exigimos\n // también esa cuenta exacta. Esto evita que dos personas con el mismo\n // nombre hereden una equivalencia incorrecta.\n const aliasAccount = normalizeAccount(alias.cuenta_bancaria || '');\n const currentAccount = normalizeAccount(bankRow.account || '');\n\n if (aliasAccount && aliasAccount !== currentAccount) return false;\n\n return true;\n });\n\n if (!matchingAliases.length) return null;\n\n const resolvedMatches = [];\n\n for (const alias of matchingAliases) {\n let resolvedIndex = null;\n let resolvedBy = '';\n\n const employeeNumber = normalizeAccount(\n alias.bamboo_employee_number || ''\n );\n\n if (employeeNumber.length >= 6) {\n const indexes =\n bambooSearch.employeeNumberMap.get(employeeNumber) || [];\n\n if (indexes.length === 1) {\n resolvedIndex = indexes[0];\n resolvedBy = 'validated_alias_employee_number';\n }\n }\n\n if (resolvedIndex === null) {\n const bambooId = String(\n alias.bamboo_employee_id || ''\n ).trim();\n\n if (bambooId) {\n const indexes = bambooSearch.records\n .map((record, index) => ({ record, index }))\n .filter(({ record }) =>\n String(\n record.employee?.bamboo_id ||\n record.employee?.employeeId ||\n record.employee?.id ||\n ''\n ).trim() === bambooId\n )\n .map(({ index }) => index);\n\n if (indexes.length === 1) {\n resolvedIndex = indexes[0];\n resolvedBy = 'validated_alias_bamboo_id';\n }\n }\n }\n\n if (resolvedIndex === null) {\n const bambooName = normalizeName(\n alias.bamboo_nombre || ''\n );\n\n if (bambooName) {\n const indexes =\n bambooSearch.exactAliasMap.get(bambooName) || [];\n\n if (indexes.length === 1) {\n resolvedIndex = indexes[0];\n resolvedBy = 'validated_alias_exact_bamboo_name';\n }\n }\n }\n\n if (resolvedIndex !== null) {\n resolvedMatches.push({\n index: resolvedIndex,\n alias,\n resolvedBy,\n });\n }\n }\n\n if (!resolvedMatches.length) return null;\n\n const employeeIndexes = Array.from(\n new Set(resolvedMatches.map((match) => match.index))\n );\n\n // Nunca suprimir un hallazgo si dos equivalencias apuntan a personas distintas.\n if (employeeIndexes.length !== 1) return null;\n\n const chosen = resolvedMatches.find(\n (match) => match.index === employeeIndexes[0]\n );\n const record = bambooSearch.records[employeeIndexes[0]];\n\n if (!chosen || !record?.employee) return null;\n\n return {\n found: true,\n matched_by: 'validated_bamboo_alias',\n confidence: 1,\n employee: record.employee,\n bank_name:\n bankRowNames(bankRow)[0] || '',\n bamboo_alias:\n chosen.alias.bamboo_nombre ||\n record.aliases?.[0]?.raw ||\n '',\n validated_alias_id:\n chosen.alias.id || '',\n validated_alias_resolution:\n chosen.resolvedBy,\n };\n}\n\n\nconst bankDetailRows = Array.isArray(data.bank?.rows)\n ? data.bank.rows\n : [];\n\nconst potentialSupplements = (\n data.payroll?.potential_supplements ||\n data.debug_payroll?.potential_supplements ||\n data.debug_payroll?.attached_supplements ||\n []\n)\n .map((row) => ({\n ...row,\n account: normalizeAccount(row.account),\n currency: row.currency || 'QTZ',\n payroll_amount: roundMoney(row.payroll_amount || row.payrollAmount),\n }))\n .filter((row) => {\n const id = normalizeName(row.supplement_id || '');\n\n return (\n row.account &&\n row.payroll_amount >= 10 &&\n !id.includes('back up')\n );\n });\n\nconst supplementsByAccountCurrency = new Map();\n\nfor (const supplement of potentialSupplements) {\n const key = `${supplement.account}:${supplement.currency}`;\n const current = supplementsByAccountCurrency.get(key) || [];\n\n current.push(supplement);\n supplementsByAccountCurrency.set(key, current);\n}\n\nfunction chooseConditionalSupplements(payroll, bank) {\n const baseAmount = roundMoney(payroll.payroll_amount);\n const bankAmount = roundMoney(bank.amount);\n const candidates =\n supplementsByAccountCurrency.get(\n `${payroll.account}:${payroll.currency}`\n ) || [];\n\n if (\n !candidates.length ||\n bankAmount <= baseAmount + 0.02\n ) {\n return {\n selected: [],\n effectiveAmount: baseAmount,\n baseAmount,\n improvement: 0,\n };\n }\n\n const baseDifference = Math.abs(baseAmount - bankAmount);\n let bestSelected = [];\n let bestAmount = baseAmount;\n let bestDifference = baseDifference;\n\n if (candidates.length <= 12) {\n const combinations = 1 << candidates.length;\n\n for (let mask = 1; mask < combinations; mask++) {\n const selected = [];\n let selectedTotal = 0;\n\n for (let index = 0; index < candidates.length; index++) {\n if ((mask & (1 << index)) !== 0) {\n selected.push(candidates[index]);\n selectedTotal = roundMoney(\n selectedTotal + candidates[index].payroll_amount\n );\n }\n }\n\n const candidateAmount = roundMoney(baseAmount + selectedTotal);\n const candidateDifference = Math.abs(\n candidateAmount - bankAmount\n );\n\n if (candidateDifference < bestDifference) {\n bestSelected = selected;\n bestAmount = candidateAmount;\n bestDifference = candidateDifference;\n }\n }\n } else {\n const sorted = [...candidates].sort(\n (a, b) => b.payroll_amount - a.payroll_amount\n );\n\n let runningAmount = baseAmount;\n const selected = [];\n\n for (const candidate of sorted) {\n const nextAmount = roundMoney(\n runningAmount + candidate.payroll_amount\n );\n\n if (\n Math.abs(nextAmount - bankAmount) <\n Math.abs(runningAmount - bankAmount)\n ) {\n selected.push(candidate);\n runningAmount = nextAmount;\n }\n }\n\n bestSelected = selected;\n bestAmount = runningAmount;\n bestDifference = Math.abs(bestAmount - bankAmount);\n }\n\n const improvement = roundMoney(\n baseDifference - bestDifference\n );\n\n // Evita sumar valores accidentales o inmateriales, como un \"Asignado\" de Q1.\n if (!bestSelected.length || improvement < 5) {\n return {\n selected: [],\n effectiveAmount: baseAmount,\n baseAmount,\n improvement: 0,\n };\n }\n\n return {\n selected: bestSelected,\n effectiveAmount: roundMoney(bestAmount),\n baseAmount,\n improvement,\n };\n}\n\nfunction getDirectCandidates(payroll, matchedBankKeys) {\n return bankAccounts\n .filter((bank) => {\n if (matchedBankKeys.has(bank.group_key)) return false;\n if (bank.currency !== payroll.currency) return false;\n\n const relationship = accountRelationship(\n payroll.account,\n bank.account\n );\n\n if (!relationship.matches) return false;\n\n // Un sufijo de referencia solamente es válido cuando el nombre también\n // corresponde a la misma persona.\n if (\n relationship.type === 'reference_prefix' &&\n !bankMatchesName(bank, payroll.employee_name)\n ) {\n return false;\n }\n\n return true;\n })\n .map((bank) => {\n const relationship = accountRelationship(\n payroll.account,\n bank.account\n );\n const supplementDecision =\n chooseConditionalSupplements(payroll, bank);\n\n return {\n bank,\n relationship,\n supplementDecision,\n nameMatches: bankMatchesName(bank, payroll.employee_name),\n };\n })\n .sort((a, b) => {\n const exactDifference =\n Number(b.relationship.type === 'exact') -\n Number(a.relationship.type === 'exact');\n\n if (exactDifference !== 0) return exactDifference;\n\n const nameDifference =\n Number(b.nameMatches) - Number(a.nameMatches);\n\n if (nameDifference !== 0) return nameDifference;\n\n return (\n Math.abs(\n a.supplementDecision.effectiveAmount - a.bank.amount\n ) -\n Math.abs(\n b.supplementDecision.effectiveAmount - b.bank.amount\n )\n );\n });\n}\n\nfunction buildSources(payroll, selectedSupplements) {\n const supplementRows = selectedSupplements.map((row) => ({\n source_sheet: row.source_sheet,\n row_number: row.row_number,\n amount: row.payroll_amount,\n supplement_original_name:\n row.supplement_original_name || row.employee_name || '',\n supplement_id: row.supplement_id || '',\n applied_conditionally: true,\n }));\n\n const sourceRows = [\n ...(payroll.source_rows || []),\n ...supplementRows,\n ];\n\n const sourceSheets = Array.from(new Set([\n ...(payroll.source_sheets || []),\n ...selectedSupplements\n .map((row) => row.source_sheet)\n .filter(Boolean),\n ]));\n\n return { sourceRows, sourceSheets };\n}\n\nconst matchedPayrollKeys = new Set();\nconst matchedBankKeys = new Set();\nconst matchedNoAccountIndexes = new Set();\nconst appliedSupplementKeys = new Set();\nconst appliedSupplements = [];\nconst finalExactReconciliations = [];\nconst rows = [];\n\nfunction registerSupplements(selected) {\n for (const supplement of selected || []) {\n const key = supplementKey(supplement);\n\n if (!appliedSupplementKeys.has(key)) {\n appliedSupplementKeys.add(key);\n appliedSupplements.push(supplement);\n }\n }\n}\n\n// 1) Cuenta exacta o referencia con prefijo, y monto conciliado.\nfor (const payroll of payrollAccounts) {\n const candidates = getDirectCandidates(\n payroll,\n matchedBankKeys\n ).filter((candidate) => {\n return moneyEquals(\n candidate.supplementDecision.effectiveAmount,\n candidate.bank.amount\n );\n });\n\n if (candidates.length !== 1) continue;\n\n const candidate = candidates[0];\n const bank = candidate.bank;\n const decision = candidate.supplementDecision;\n const sources = buildSources(payroll, decision.selected);\n\n matchedPayrollKeys.add(payroll.group_key);\n matchedBankKeys.add(bank.group_key);\n registerSupplements(decision.selected);\n\n rows.push({\n id: `match_${payroll.group_key}_${bank.group_key}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: payroll.currency,\n payrollAmount: decision.effectiveAmount,\n payroll_amount: decision.effectiveAmount,\n payrollBaseAmount: decision.baseAmount,\n payroll_base_amount: decision.baseAmount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: 0,\n status: 'Coincidencia',\n category: 'coincidencia',\n subcategory:\n candidate.relationship.type === 'reference_prefix'\n ? 'referencia_bancaria_con_prefijo'\n : decision.selected.length\n ? 'cuenta_monto_y_suplemento_condicional'\n : 'cuenta_y_monto_coinciden',\n observation:\n candidate.relationship.type === 'reference_prefix'\n ? 'Conciliado por nombre, monto y referencia bancaria con prefijo.'\n : decision.selected.length\n ? 'Conciliado correctamente. Se aplicó un suplemento porque el banco mostró un pago adicional.'\n : 'Conciliado correctamente.',\n applied_supplements: decision.selected,\n source_sheets: sources.sourceSheets,\n source_rows: sources.sourceRows,\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 2) Cuenta diferente, pero nombre y monto coinciden.\n// Se ejecuta antes de crear diferencias directas para resolver casos como\n// Ashly/Ashley Ramos: la cuenta de la nómina apunta a otra transacción,\n// pero existe otra cuenta bancaria con el mismo nombre y monto correcto.\nfor (const payroll of payrollAccounts) {\n if (matchedPayrollKeys.has(payroll.group_key)) continue;\n\n const candidates = bankAccounts\n .filter((bank) => {\n if (matchedBankKeys.has(bank.group_key)) return false;\n if (bank.currency !== payroll.currency) return false;\n if (!bankMatchesName(bank, payroll.employee_name)) return false;\n\n const decision = chooseConditionalSupplements(\n payroll,\n bank\n );\n\n return moneyEquals(\n decision.effectiveAmount,\n bank.amount\n );\n })\n .map((bank) => ({\n bank,\n supplementDecision: chooseConditionalSupplements(\n payroll,\n bank\n ),\n }));\n\n if (candidates.length !== 1) continue;\n\n const candidate = candidates[0];\n const bank = candidate.bank;\n const decision = candidate.supplementDecision;\n const relationship = accountRelationship(\n payroll.account,\n bank.account\n );\n\n // Las referencias con prefijo ya debieron resolverse en el paso 1.\n if (relationship.type === 'reference_prefix') continue;\n\n const sources = buildSources(payroll, decision.selected);\n\n matchedPayrollKeys.add(payroll.group_key);\n matchedBankKeys.add(bank.group_key);\n registerSupplements(decision.selected);\n\n rows.push({\n id: `possible_wrong_account_${payroll.group_key}_${bank.group_key}`,\n employee: payroll.employee_name || bestBankDisplayName(bank),\n employee_name:\n payroll.employee_name || bestBankDisplayName(bank),\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: payroll.currency,\n payrollAmount: decision.effectiveAmount,\n payroll_amount: decision.effectiveAmount,\n payrollBaseAmount: decision.baseAmount,\n payroll_base_amount: decision.baseAmount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: 0,\n status: 'Riesgo',\n category: 'posible_cuenta_mal_digitada',\n subcategory:\n 'nombre_y_monto_coinciden_cuenta_diferente',\n observation:\n `El nombre y el monto coinciden, pero la cuenta de nómina ` +\n `(${payroll.account || 'sin cuenta'}) es diferente a la cuenta ` +\n `del banco (${bank.account || 'sin cuenta válida'}).`,\n applied_supplements: decision.selected,\n source_sheets: sources.sourceSheets,\n source_rows: sources.sourceRows,\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 3) Nómina sin cuenta válida: conciliar por nombre y monto.\nfor (\n let index = 0;\n index < payrollNoAccountRows.length;\n index++\n) {\n const payroll = payrollNoAccountRows[index];\n\n const candidates = bankAccounts.filter((bank) => {\n if (matchedBankKeys.has(bank.group_key)) return false;\n if (bank.currency !== payroll.currency) return false;\n if (!moneyEquals(bank.amount, payroll.payroll_amount)) {\n return false;\n }\n\n return bankMatchesName(bank, payroll.employee_name);\n });\n\n if (candidates.length !== 1) continue;\n\n const bank = candidates[0];\n\n matchedNoAccountIndexes.add(index);\n matchedBankKeys.add(bank.group_key);\n\n rows.push({\n id: `match_no_account_${index}_${bank.group_key}`,\n employee:\n payroll.employee_name || bestBankDisplayName(bank),\n employee_name:\n payroll.employee_name || bestBankDisplayName(bank),\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: bank.account,\n payrollAccount: '',\n payroll_account: '',\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: payroll.currency,\n payrollAmount: payroll.payroll_amount,\n payroll_amount: payroll.payroll_amount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: 0,\n status: 'Coincidencia',\n category: 'coincidencia',\n subcategory:\n 'conciliado_por_nombre_y_monto_sin_cuenta_nomina',\n observation:\n 'Conciliado por nombre y monto. La nómina no tenía una cuenta bancaria válida.',\n source_sheet: payroll.source_sheet,\n row_number: payroll.row_number,\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 4) Diferencias reales en una cuenta exacta o equivalente.\nfor (const payroll of payrollAccounts) {\n if (matchedPayrollKeys.has(payroll.group_key)) continue;\n\n const candidates = getDirectCandidates(\n payroll,\n matchedBankKeys\n );\n\n if (!candidates.length) continue;\n\n const candidate = candidates[0];\n const bank = candidate.bank;\n const decision = candidate.supplementDecision;\n const sources = buildSources(payroll, decision.selected);\n const difference = moneyDiff(\n decision.effectiveAmount,\n bank.amount\n );\n\n matchedPayrollKeys.add(payroll.group_key);\n matchedBankKeys.add(bank.group_key);\n registerSupplements(decision.selected);\n\n rows.push({\n id: `difference_${payroll.group_key}_${bank.group_key}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: payroll.currency,\n payrollAmount: decision.effectiveAmount,\n payroll_amount: decision.effectiveAmount,\n payrollBaseAmount: decision.baseAmount,\n payroll_base_amount: decision.baseAmount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference,\n status: 'Riesgo',\n category: 'discrepancia',\n subcategory: 'diferencia_monto',\n observation:\n `Diferencia de ${payroll.currency} ` +\n `${formatMoney(difference)}.`,\n applied_supplements: decision.selected,\n source_sheets: sources.sourceSheets,\n source_rows: sources.sourceRows,\n bank_source_rows: bank.source_rows,\n });\n}\n\n\n// 4.5) Reconciliación final exacta de pares residuales.\n//\n// Este paso corrige casos en los que nómina y banco contienen:\n// - la misma cuenta normalizada;\n// - el mismo empleado;\n// - el mismo monto;\n// pero no fueron enlazados en los pasos anteriores por diferencias técnicas\n// de agrupación, moneda inferida o metadatos del CSV.\n//\n// Es deliberadamente conservador: exige una única contraparte bancaria.\nfor (const payroll of payrollAccounts) {\n if (matchedPayrollKeys.has(payroll.group_key)) continue;\n\n const candidates = bankAccounts\n .filter((bank) => {\n if (matchedBankKeys.has(bank.group_key)) return false;\n\n const payrollAccount = normalizeAccount(payroll.account);\n const bankAccount = normalizeAccount(bank.account);\n\n if (!payrollAccount || payrollAccount !== bankAccount) {\n return false;\n }\n\n if (!bankMatchesName(bank, payroll.employee_name)) {\n return false;\n }\n\n const decision = chooseConditionalSupplements(payroll, bank);\n\n return moneyEquals(\n decision.effectiveAmount,\n bank.amount\n );\n })\n .map((bank) => ({\n bank,\n supplementDecision: chooseConditionalSupplements(\n payroll,\n bank\n ),\n }));\n\n if (candidates.length !== 1) continue;\n\n const candidate = candidates[0];\n const bank = candidate.bank;\n const decision = candidate.supplementDecision;\n const sources = buildSources(\n payroll,\n decision.selected\n );\n\n matchedPayrollKeys.add(payroll.group_key);\n matchedBankKeys.add(bank.group_key);\n registerSupplements(decision.selected);\n\n finalExactReconciliations.push({\n employee_name: payroll.employee_name,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payroll_currency: payroll.currency,\n bank_currency: bank.currency,\n payroll_amount: decision.effectiveAmount,\n bank_amount: bank.amount,\n payroll_group_key: payroll.group_key,\n bank_group_key: bank.group_key,\n });\n\n rows.push({\n id: `final_exact_match_${payroll.group_key}_${bank.group_key}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: bank.currency || payroll.currency,\n payrollAmount: decision.effectiveAmount,\n payroll_amount: decision.effectiveAmount,\n payrollBaseAmount: decision.baseAmount,\n payroll_base_amount: decision.baseAmount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: 0,\n status: 'Coincidencia',\n category: 'coincidencia',\n subcategory: 'reconciliacion_final_cuenta_nombre_monto',\n observation:\n 'Conciliado por cuenta, nombre y monto en la validación final.',\n applied_supplements: decision.selected,\n source_sheets: sources.sourceSheets,\n source_rows: sources.sourceRows,\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 5) Nómina con cuenta sin pago bancario.\nfor (const payroll of payrollAccounts) {\n if (matchedPayrollKeys.has(payroll.group_key)) continue;\n\n rows.push({\n id: `payroll_without_bank_${payroll.group_key}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: '',\n bank_account: '',\n currency: payroll.currency,\n payrollAmount: payroll.payroll_amount,\n payroll_amount: payroll.payroll_amount,\n payrollBaseAmount: payroll.payroll_amount,\n payroll_base_amount: payroll.payroll_amount,\n bankAmount: 0,\n bank_amount: 0,\n difference: payroll.payroll_amount,\n status: 'Riesgo',\n category: 'discrepancia',\n subcategory: 'nomina_con_cuenta_sin_pago_banco',\n observation:\n 'Está en nómina, pero no aparece pagado en el banco.',\n applied_supplements: [],\n source_sheets: payroll.source_sheets,\n source_rows: payroll.source_rows,\n });\n}\n\n// 6) Banco sin nómina.\nfor (const bank of bankAccounts) {\n if (matchedBankKeys.has(bank.group_key)) continue;\n\n rows.push({\n id: `bank_without_payroll_${bank.group_key}`,\n employee:\n bestBankDisplayName(bank) || 'Pago bancario sin nómina',\n employee_name:\n bestBankDisplayName(bank) || 'Pago bancario sin nómina',\n employeeNumber: '',\n employee_number: '',\n account: bank.account,\n payrollAccount: '',\n payroll_account: '',\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: bank.currency,\n payrollAmount: 0,\n payroll_amount: 0,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: roundMoney(0 - bank.amount),\n status: 'Pendiente revisión',\n category: 'banco_sin_nomina',\n subcategory: 'pago_banco_sin_fila_nomina',\n observation:\n 'Recibió un pago en el banco, pero no aparece en la nómina cargada.',\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 7) Nómina sin cuenta que no pudo conciliarse.\nfor (\n let index = 0;\n index < payrollNoAccountRows.length;\n index++\n) {\n if (matchedNoAccountIndexes.has(index)) continue;\n\n const payroll = payrollNoAccountRows[index];\n\n rows.push({\n id:\n `payroll_without_account_` +\n `${payroll.source_sheet}_${payroll.row_number}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: '',\n payrollAccount: '',\n payroll_account: '',\n bankAccount: '',\n bank_account: '',\n currency: payroll.currency,\n payrollAmount: payroll.payroll_amount,\n payroll_amount: payroll.payroll_amount,\n bankAmount: 0,\n bank_amount: 0,\n difference: payroll.payroll_amount,\n status: 'Pendiente revisión',\n category: 'nomina_sin_cuenta',\n subcategory: 'nomina_sin_cuenta_bancaria',\n observation:\n 'Tiene monto en nómina, pero no tiene una cuenta bancaria válida para cruzar contra el banco.',\n source_sheet: payroll.source_sheet,\n row_number: payroll.row_number,\n });\n}\n\n// 8) Consolidar el mismo empleado cuando aparece con dos cuentas de nómina.\nconst originalRows = [...rows];\nconst usedRowIds = new Set();\nconst consolidatedRows = [];\n\nfor (const differenceRow of originalRows) {\n if (\n differenceRow.category !== 'discrepancia' ||\n differenceRow.subcategory !== 'diferencia_monto' ||\n usedRowIds.has(differenceRow.id)\n ) {\n continue;\n }\n\n const extraPayrollRow = originalRows.find((candidate) => {\n if (\n candidate.id === differenceRow.id ||\n usedRowIds.has(candidate.id) ||\n candidate.subcategory !==\n 'nomina_con_cuenta_sin_pago_banco' ||\n candidate.currency !== differenceRow.currency\n ) {\n return false;\n }\n\n const samePerson = samePersonName(\n differenceRow.employee_name || differenceRow.employee,\n candidate.employee_name || candidate.employee\n );\n\n const similarAccounts =\n accountDistance(\n differenceRow.account,\n candidate.account\n ) <= 2;\n\n const combinedPayroll = roundMoney(\n differenceRow.payroll_amount +\n candidate.payroll_amount\n );\n\n const totalMatches = moneyEquals(\n combinedPayroll,\n differenceRow.bank_amount\n );\n\n return samePerson && similarAccounts && totalMatches;\n });\n\n if (!extraPayrollRow) continue;\n\n usedRowIds.add(differenceRow.id);\n usedRowIds.add(extraPayrollRow.id);\n\n const totalPayroll = roundMoney(\n differenceRow.payroll_amount +\n extraPayrollRow.payroll_amount\n );\n\n const accounts = Array.from(new Set([\n differenceRow.account,\n extraPayrollRow.account,\n ].filter(Boolean)));\n\n consolidatedRows.push({\n id:\n `split_account_` +\n `${differenceRow.account}_${extraPayrollRow.account}`,\n employee: differenceRow.employee_name,\n employee_name: differenceRow.employee_name,\n employeeNumber:\n differenceRow.employee_number ||\n extraPayrollRow.employee_number ||\n '',\n employee_number:\n differenceRow.employee_number ||\n extraPayrollRow.employee_number ||\n '',\n account:\n differenceRow.bank_account ||\n differenceRow.account,\n payrollAccount: accounts.join(' / '),\n payroll_account: accounts.join(' / '),\n bankAccount: differenceRow.bank_account,\n bank_account: differenceRow.bank_account,\n currency: differenceRow.currency,\n payrollAmount: totalPayroll,\n payroll_amount: totalPayroll,\n bankAmount: differenceRow.bank_amount,\n bank_amount: differenceRow.bank_amount,\n difference: moneyDiff(\n totalPayroll,\n differenceRow.bank_amount\n ),\n status: 'Riesgo',\n category: 'posible_cuenta_mal_digitada',\n subcategory:\n 'mismo_empleado_con_cuentas_distintas_en_nomina',\n observation:\n `El total de nómina coincide con el banco, pero el empleado ` +\n `aparece con cuentas distintas en la nómina: ` +\n `${accounts.join(' y ')}. La cuenta utilizada por el banco ` +\n `fue ${differenceRow.bank_account}.`,\n applied_supplements:\n differenceRow.applied_supplements || [],\n source_sheets: Array.from(new Set([\n ...(differenceRow.source_sheets || []),\n ...(extraPayrollRow.source_sheets || []),\n ])),\n source_rows: [\n ...(differenceRow.source_rows || []),\n ...(extraPayrollRow.source_rows || []),\n ],\n bank_source_rows:\n differenceRow.bank_source_rows || [],\n });\n}\n\nconst coreRows = [\n ...originalRows.filter(\n (row) => !usedRowIds.has(row.id)\n ),\n ...consolidatedRows,\n];\n\nconst coreCoincidencias = coreRows.filter(\n (row) => row.category === 'coincidencia'\n).length;\n\nconst coreDiscrepancias = coreRows.filter((row) => {\n return (\n row.category === 'discrepancia' ||\n row.category === 'posible_cuenta_mal_digitada'\n );\n}).length;\n\nconst coreBancoSinNomina = coreRows.filter(\n (row) => row.category === 'banco_sin_nomina'\n).length;\n\nconst coreNominaSinCuenta = coreRows.filter(\n (row) => row.category === 'nomina_sin_cuenta'\n).length;\n\nconst corePosiblesCuentas = coreRows.filter(\n (row) => row.category === 'posible_cuenta_mal_digitada'\n).length;\n\nconst linkedPayrollNamesByBankRow = new Map();\nconst linkedPayrollNumbersByBankRow = new Map();\n\nfor (const reconciliationRow of coreRows) {\n const linkedName =\n reconciliationRow.employee_name ||\n reconciliationRow.employee ||\n '';\n const linkedEmployeeNumber = normalizeAccount(\n reconciliationRow.employee_number ||\n reconciliationRow.employeeNumber ||\n ''\n );\n\n for (\n const bankSourceRow of\n reconciliationRow.bank_source_rows || []\n ) {\n const rowKey = bankRowKey(bankSourceRow);\n\n const names =\n linkedPayrollNamesByBankRow.get(rowKey) || [];\n const numbers =\n linkedPayrollNumbersByBankRow.get(rowKey) || [];\n\n if (linkedName) names.push(linkedName);\n if (linkedEmployeeNumber.length >= 6) {\n numbers.push(linkedEmployeeNumber);\n }\n\n linkedPayrollNamesByBankRow.set(\n rowKey,\n Array.from(new Set(names))\n );\n linkedPayrollNumbersByBankRow.set(\n rowKey,\n Array.from(new Set(numbers))\n );\n }\n}\n\nconst bambooMatchDetails = [];\nconst bambooExcludedPayments = [];\nconst bankWithoutBambooMap = new Map();\n\nif (bambooValidationAvailable) {\nfor (const bankRow of bankDetailRows) {\n if (isClearlyNonEmployeePayment(bankRow)) {\n bambooExcludedPayments.push({\n source_file: bankRow.source_file,\n row_number: bankRow.row_number,\n reason: 'pago_no_empleado_identificado',\n bank_name_file: bankRow.bank_name_file,\n bank_account_holder:\n bankRow.bank_account_holder,\n amount: bankRow.amount,\n currency: bankRow.currency,\n });\n continue;\n }\n\n const validatedAliasMatch =\n findValidatedAliasMatch(bankRow);\n\n const match =\n validatedAliasMatch ||\n findBambooMatch(bankRow);\n\n if (match.found) {\n bambooMatchDetails.push({\n source_file: bankRow.source_file,\n row_number: bankRow.row_number,\n account: bankRow.account,\n amount: bankRow.amount,\n currency: bankRow.currency,\n bank_name_file: bankRow.bank_name_file,\n bank_account_holder:\n bankRow.bank_account_holder,\n matched_by: match.matched_by,\n confidence: roundMoney(match.confidence),\n validated_alias_id:\n match.validated_alias_id || '',\n validated_alias_resolution:\n match.validated_alias_resolution || '',\n bamboo_employee_number:\n match.employee?.employee_number || '',\n bamboo_employee_name:\n match.employee?.full_name || '',\n bamboo_status:\n match.employee?.status || '',\n bamboo_overlaps_period:\n Boolean(match.employee?.overlaps_period),\n });\n continue;\n }\n\n const displayName =\n bankRow.bank_name_file ||\n bankRow.bank_account_holder ||\n 'Pago bancario sin empleado identificado';\n\n const groupingKey = [\n normalizeAccount(bankRow.account),\n normalizeName(displayName),\n bankRow.currency || 'QTZ',\n ].join('|');\n\n const current =\n bankWithoutBambooMap.get(groupingKey) || {\n id: `bank_without_bamboo_${groupingKey}`,\n employee: displayName,\n employee_name: displayName,\n bank_name_file:\n bankRow.bank_name_file || '',\n bank_account_holder:\n bankRow.bank_account_holder || '',\n account: normalizeAccount(bankRow.account),\n bankAccount: normalizeAccount(bankRow.account),\n bank_account: normalizeAccount(bankRow.account),\n currency: bankRow.currency || 'QTZ',\n bankAmount: 0,\n bank_amount: 0,\n shipment_numbers: new Set(),\n references: new Set(),\n source_files: new Set(),\n source_rows: [],\n status: 'Pendiente revisión',\n category: 'banco_sin_bamboo',\n subcategory:\n 'pago_bancario_sin_empleado_bamboohr_gt',\n observation:\n 'Se encontró un pago en el banco, pero no se encontró una coincidencia confiable con un empleado de Guatemala en BambooHR.',\n best_bamboo_candidate:\n match.best_candidate\n ? {\n employee_number:\n match.best_candidate.employee\n ?.employee_number || '',\n employee_name:\n match.best_candidate.employee\n ?.full_name || '',\n score: roundMoney(\n match.best_candidate.score\n ),\n }\n : null,\n ambiguous_bamboo_match:\n Boolean(match.ambiguous),\n };\n\n current.bankAmount = roundMoney(\n current.bankAmount +\n Number(bankRow.amount || 0)\n );\n current.bank_amount = current.bankAmount;\n\n if (bankRow.shipment_number) {\n current.shipment_numbers.add(\n bankRow.shipment_number\n );\n }\n\n if (bankRow.reference) {\n current.references.add(bankRow.reference);\n }\n\n if (bankRow.source_file) {\n current.source_files.add(\n bankRow.source_file\n );\n }\n\n current.source_rows.push(bankRow);\n bankWithoutBambooMap.set(\n groupingKey,\n current\n );\n}\n}\n\nconst bankWithoutBamboo = Array.from(\n bankWithoutBambooMap.values()\n).map((row) => ({\n ...row,\n shipment_numbers: Array.from(\n row.shipment_numbers\n ),\n references: Array.from(row.references),\n source_files: Array.from(row.source_files),\n difference: roundMoney(\n 0 - row.bank_amount\n ),\n}));\n\nconst nameDifferenceRows = (\n data.bank?.name_differences || []\n).map((row) => ({\n id: row.id,\n employee: row.bank_name_file,\n employee_name: row.bank_name_file,\n employeeNumber: '',\n employee_number: '',\n account: row.account,\n payrollAccount: '',\n payroll_account: '',\n bankAccount: row.account,\n bank_account: row.account,\n currency: row.currency || 'QTZ',\n payrollAmount: 0,\n payroll_amount: 0,\n bankAmount: row.amount,\n bank_amount: row.amount,\n difference: 0,\n status: 'Pendiente revisión',\n category: 'diferencia_nombre_banco',\n subcategory: 'nombre_archivo_vs_cuentahabiente',\n observation: row.observation,\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,\n}));\n\nfunction priority(row) {\n const category = String(\n row.category || ''\n ).toLowerCase();\n\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 === 'nomina_sin_cuenta') return 4;\n if (category === 'diferencia_nombre_banco') return 5;\n if (category === 'coincidencia') return 99;\n\n return 50;\n}\n\nconst rowsFinales = [\n ...coreRows,\n ...nameDifferenceRows,\n].sort((a, b) => {\n const priorityDifference =\n priority(a) - priority(b);\n\n if (priorityDifference !== 0) {\n return priorityDifference;\n }\n\n return String(\n a.employee_name || ''\n ).localeCompare(\n String(b.employee_name || ''),\n 'es'\n );\n});\n\nconst appliedSupplementsTotal = roundMoney(\n appliedSupplements.reduce(\n (sum, row) => sum + row.payroll_amount,\n 0\n )\n);\n\nconst payrollTotalsByCurrency =\n data.payroll?.totals_by_currency || {};\nconst bankTotalsByCurrency =\n data.bank?.totals_by_currency || {};\n\nconst totalNominaBase = roundMoney(\n payrollTotalsByCurrency.QTZ ??\n payrollTotalsByCurrency.GTQ ??\n data.payroll?.total_amount ??\n 0\n);\n\nconst totalNomina = roundMoney(\n totalNominaBase + appliedSupplementsTotal\n);\n\nconst totalBanco = roundMoney(\n bankTotalsByCurrency.QTZ ??\n bankTotalsByCurrency.GTQ ??\n data.bank?.total_amount ??\n 0\n);\n\nconst totalNominaUSD = roundMoney(\n payrollTotalsByCurrency.USD ?? 0\n);\nconst totalBancoUSD = roundMoney(\n bankTotalsByCurrency.USD ?? 0\n);\nconst diferenciaUSD =\n moneyDiff(totalNominaUSD, totalBancoUSD);\n\nconst diferenciasNombreBanco =\n nameDifferenceRows.length;\n\nconst pendientes =\n coreDiscrepancias +\n coreBancoSinNomina +\n coreNominaSinCuenta +\n diferenciasNombreBanco;\n\nconst unusedPotentialSupplements =\n potentialSupplements.filter((row) => {\n return !appliedSupplementKeys.has(\n supplementKey(row)\n );\n });\n\nconst bambooSummaryCompact = {\n ...(data.bamboo || {}),\n};\n\n/*\n * El arreglo completo de empleados solo se necesita dentro de este nodo.\n * No se reenvía a Google Sheets, Supabase ni al webhook para evitar cargar\n * cerca de 1 MB innecesario en todos los nodos posteriores.\n */\ndelete bambooSummaryCompact.employees;\n\nreturn [\n {\n json: {\n ok: true,\n stage: 'cruce_nomina_completa_banco_condicional',\n errors: [],\n metadata: data.metadata || {},\n summary: {\n coincidencias: coreCoincidencias,\n discrepancias: coreDiscrepancias,\n bancoSinNomina: coreBancoSinNomina,\n bancoSinBamboo: bankWithoutBamboo.length,\n nominaSinCuenta: coreNominaSinCuenta,\n diferenciasNombreBanco,\n posiblesCuentasMalDigitadas:\n corePosiblesCuentas,\n pendientes,\n filasNominaValidas:\n data.payroll?.valid_rows_count || 0,\n filasNominaSinCuenta:\n data.payroll?.no_account_rows_count || 0,\n suplementosPotenciales:\n potentialSupplements.length,\n suplementosNominaAplicados:\n appliedSupplements.length,\n suplementosNominaNoAplicados:\n unusedPotentialSupplements.length,\n suplementosNominaAdjuntados:\n appliedSupplements.length,\n suplementosNominaNoAdjuntados:\n data.payroll?.unattached_supplements_count || 0,\n reconciliacionesExactasFinales:\n finalExactReconciliations.length,\n cuentasNominaAgrupadas:\n payrollAccounts.length,\n transaccionesBanco:\n data.bank?.rows_count || 0,\n cuentasBancoAgrupadas:\n bankAccounts.length,\n empleadosBambooGT:\n bambooEmployees.length,\n empleadosBambooEnPeriodo:\n Number(\n data.bamboo?.active_in_period_count || 0\n ),\n bambooPaginasDescargadas:\n Number(\n data.bamboo?.pages_fetched || 0\n ),\n bambooEmpleadosEsperados:\n Number(\n data.bamboo?.expected_total || 0\n ),\n bambooDescargaCompleta:\n Boolean(\n data.bamboo?.fetch_complete\n ),\n bambooValidacionDisponible:\n bambooValidationAvailable,\n totalNominaBase,\n totalSuplementosAplicados:\n appliedSupplementsTotal,\n // Compatibilidad histórica: totalNomina/totalBanco/diferenciaTotal\n // representan QTZ. Los USD viajan por campos separados.\n totalNomina,\n totalBanco,\n diferenciaTotal:\n moneyDiff(totalNomina, totalBanco),\n totalNominaQTZ: totalNomina,\n totalBancoQTZ: totalBanco,\n diferenciaQTZ:\n moneyDiff(totalNomina, totalBanco),\n totalNominaUSD,\n totalBancoUSD,\n diferenciaUSD,\n totalesNominaPorMoneda: {\n QTZ: totalNomina,\n USD: totalNominaUSD,\n },\n totalesBancoPorMoneda: {\n QTZ: totalBanco,\n USD: totalBancoUSD,\n },\n diferenciaPorMoneda: {\n QTZ: moneyDiff(totalNomina, totalBanco),\n USD: diferenciaUSD,\n },\n },\n rows: rowsFinales,\n bankWithoutBamboo,\n nameDifferences: nameDifferenceRows,\n bambooSummary: bambooSummaryCompact,\n reportUrl: null,\n debug: {\n sheet_summaries:\n data.payroll?.sheet_summaries || [],\n potential_supplements:\n potentialSupplements,\n applied_supplements:\n appliedSupplements,\n final_exact_reconciliations:\n finalExactReconciliations,\n bamboo_search:\n {\n employees_indexed:\n bambooSearch.records.length,\n exact_aliases:\n bambooSearch.exactAliasMap.size,\n indexed_tokens:\n bambooSearch.tokenIndex.size,\n cache_entries:\n bambooMatchCache.size,\n },\n validated_bamboo_aliases_loaded:\n validatedBambooAliases.length,\n validated_bamboo_alias_matches:\n bambooMatchDetails.filter(\n (match) => match.matched_by === 'validated_bamboo_alias'\n ).length,\n bamboo_matches:\n bambooMatchDetails,\n bamboo_excluded_payments:\n bambooExcludedPayments,\n bamboo_validation_available:\n bambooValidationAvailable,\n bamboo_validation_warning:\n bambooValidationWarning,\n banco_sin_bamboo:\n bankWithoutBamboo,\n unused_potential_supplements:\n unusedPotentialSupplements,\n unattached_supplements:\n data.debug_payroll?.unattached_supplements || [],\n payroll_preview:\n payrollAccounts.slice(0, 10),\n bank_preview:\n bankAccounts.slice(0, 10),\n payroll_no_account_preview:\n payrollNoAccountRows.slice(0, 10),\n },\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
7824,
3120
],
"id": "9ab13ae8-f5f1-439a-84cf-875b06ba593c",
"name": "Cruzar Bono 14 vs Banco"
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": false,
"sheetName": "P&G"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
1344,
2112
],
"id": "b7121eac-c58a-4378-bf17-8866ffc818a0",
"name": "Extract Bono14 - P&G",
"retryOnFail": false
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": false,
"sheetName": "B14 MOTO"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
1344,
2352
],
"id": "dd787c02-74cb-453f-8611-b515b026c6ab",
"name": "Extract Bono14 - Motorola",
"retryOnFail": false,
"onError": "continueRegularOutput"
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": false,
"sheetName": "B14 WP"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
1344,
2592
],
"id": "9dd753e9-e9e7-47e9-8dce-c9ceef7accfd",
"name": "Extract Bono14 - Whirlpool",
"retryOnFail": false,
"onError": "continueRegularOutput"
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": false,
"sheetName": "PMI"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
1360,
2784
],
"id": "ef502b8a-3ea6-42a9-b911-70d5e72ea877",
"name": "Extract Bono14 - PMI",
"retryOnFail": false,
"onError": "continueRegularOutput"
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": false,
"sheetName": "B14 Nestle"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
1360,
2992
],
"id": "db4c4942-830e-47c2-a8e2-982252a1ebc8",
"name": "Extract Bono14 - Nestle",
"retryOnFail": false,
"onError": "continueRegularOutput"
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": false,
"sheetName": "B14 GLM Q"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
1360,
3200
],
"id": "14b7b95a-d583-4788-a8b0-7afab135d0e4",
"name": "Extract Bono14 - GLM Q",
"retryOnFail": false,
"onError": "continueRegularOutput"
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": false,
"sheetName": "B14 GLM $"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
1376,
3536
],
"id": "2c5de156-7bf8-4d69-b71d-bcb38f98b332",
"name": "Extract Bono14 - GLM USD",
"retryOnFail": false,
"onError": "continueRegularOutput"
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": false,
"sheetName": "RESUMEN"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
1360,
3872
],
"id": "24b36bbe-2a0b-4058-a4c6-a47f5103991e",
"name": "Extract Bono14 - Resumen 1",
"retryOnFail": false,
"onError": "continueRegularOutput"
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": false,
"sheetName": "RESUMEN"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
1360,
4160
],
"id": "cd86a313-2e21-435c-9725-31098528cae1",
"name": "Extract Bono14 - Resumen 2",
"retryOnFail": false,
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "function normalizeText(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\u00A0/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeForCompare(value) {\n return normalizeText(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 if (value === null || value === undefined || value === '') return '';\n\n if (typeof value === 'number') {\n return String(Math.trunc(value));\n }\n\n return String(value)\n .replace(/\\u00A0/g, '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim();\n}\n\nfunction parseMoney(value) {\n if (typeof value === 'number') {\n return Number.isFinite(value) ? value : 0;\n }\n\n const cleaned = String(value ?? '')\n .replace(/USD/gi, '')\n .replace(/GTQ/gi, '')\n .replace(/QTZ/gi, '')\n .replace(/Q/gi, '')\n .replace(/\\$/g, '')\n .replace(/,/g, '')\n .replace(/\\s+/g, '')\n .trim();\n\n const parsed = Number.parseFloat(cleaned);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction roundMoney(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n}\n\nfunction getNodeRows(nodeName) {\n try {\n return $items(nodeName)\n .map((item) => item.json || {})\n .filter((row) => {\n const text = JSON.stringify(row || {}).toLowerCase();\n if (row.error) return false;\n if (text.includes('spreadsheet does not contain sheet')) return false;\n if (text.includes('no sheet')) return false;\n return true;\n });\n } catch (error) {\n return [];\n }\n}\n\nfunction excelColumnIndex(letters) {\n const raw = String(letters || '').trim().toUpperCase();\n if (!/^[A-Z]+$/.test(raw)) return -1;\n\n let value = 0;\n for (const character of raw) {\n value = value * 26 + (character.charCodeAt(0) - 64);\n }\n return value - 1;\n}\n\nfunction rawRowValues(row) {\n if (Array.isArray(row?.row)) return row.row;\n if (Array.isArray(row)) return row;\n if (!row || typeof row !== 'object') return [];\n\n const keys = Object.keys(row);\n\n // Extract From File con headerRow=false normalmente conserva el orden\n // posicional como índices numéricos. Esta es la ruta principal.\n const numericKeys = keys\n .filter((key) => /^\\d+$/.test(key))\n .sort((a, b) => Number(a) - Number(b));\n\n if (numericKeys.length) {\n const maxIndex = Number(numericKeys[numericKeys.length - 1]);\n const values = new Array(maxIndex + 1).fill(null);\n for (const key of numericKeys) values[Number(key)] = row[key];\n return values;\n }\n\n // Respaldo para representaciones con letras de Excel: A, B, ..., AA.\n const letterKeys = keys.filter((key) => /^[A-Z]{1,3}$/i.test(key));\n if (letterKeys.length) {\n const indexed = letterKeys\n .map((key) => ({ key, index: excelColumnIndex(key) }))\n .filter((entry) => entry.index >= 0)\n .sort((left, right) => left.index - right.index);\n\n if (indexed.length) {\n const values = new Array(indexed[indexed.length - 1].index + 1).fill(null);\n for (const entry of indexed) values[entry.index] = row[entry.key];\n return values;\n }\n }\n\n // Respaldo para objetos generados con nombres tipo column_1 / Column 1.\n const generatedColumns = keys\n .map((key) => {\n const match = key.match(/^column[_\\s-]?(\\d+)$/i);\n return match ? { key, index: Math.max(0, Number(match[1]) - 1) } : null;\n })\n .filter(Boolean)\n .sort((left, right) => left.index - right.index);\n\n if (generatedColumns.length) {\n const values = new Array(generatedColumns[generatedColumns.length - 1].index + 1).fill(null);\n for (const entry of generatedColumns) values[entry.index] = row[entry.key];\n return values;\n }\n\n return [];\n}\n\nfunction detectBonus14Columns(sourceRows, config) {\n const detected = {\n accountIndex: config.accountIndex,\n nameIndex: config.nameIndex,\n emailIndex: config.emailIndex,\n amountIndex: config.amountIndex,\n detectedFromHeader: false,\n headerRowIndex: -1,\n };\n\n const previewRows = sourceRows.slice(0, 12);\n\n for (let previewIndex = 0; previewIndex < previewRows.length; previewIndex++) {\n const sourceRow = previewRows[previewIndex];\n const values = rawRowValues(sourceRow);\n if (!values.length) continue;\n\n const normalized = values.map((value) => normalizeForCompare(value));\n\n const accountIndex = normalized.findIndex((value) =>\n value === 'cuenta' ||\n value === 'cuenta no' ||\n value === 'numero de cuenta' ||\n value === 'account number'\n );\n\n const nameIndex = normalized.findIndex((value) =>\n value === 'nombre' ||\n value === 'nombre completo' ||\n value === 'empleado' ||\n value === 'name'\n );\n\n const emailIndex = normalized.findIndex((value) =>\n value === 'email' ||\n value === 'correo' ||\n value === 'correo electronico'\n );\n\n const amountCandidates = normalized\n .map((value, index) => ({ value, index }))\n .filter(({ value }) =>\n (value.includes('bono 14') || value.includes('bono14')) &&\n !value.includes('dia bono')\n );\n\n const amountIndex = amountCandidates.length\n ? amountCandidates[amountCandidates.length - 1].index\n : -1;\n\n if (\n accountIndex >= 0 &&\n nameIndex >= 0 &&\n amountIndex >= 0\n ) {\n detected.accountIndex = accountIndex;\n detected.nameIndex = nameIndex;\n detected.amountIndex = amountIndex;\n if (emailIndex >= 0) detected.emailIndex = emailIndex;\n detected.detectedFromHeader = true;\n detected.headerRowIndex = previewIndex;\n break;\n }\n }\n\n return detected;\n}\n\nfunction validEmployeeName(value) {\n const name = normalizeText(value);\n const normalized = normalizeForCompare(name);\n\n if (!name) return false;\n if (/^[\\d.,\\s]+$/.test(name)) return false;\n\n const invalid = [\n 'nombre',\n 'nombre completo',\n 'empleado',\n 'total',\n 'subtotal',\n 'gran total',\n 'total general',\n 'bono 14',\n 'bono14',\n ];\n\n if (\n invalid.some((token) =>\n normalized === normalizeForCompare(token) ||\n normalized.startsWith(`${normalizeForCompare(token)} `)\n )\n ) {\n return false;\n }\n\n return name.split(/\\s+/).filter(Boolean).length >= 2;\n}\n\nfunction validAccount(value) {\n const account = normalizeAccount(value);\n return account.length >= 7 && !/^0+$/.test(account);\n}\n\nconst configs = [\n {\n node: 'Extract Bono14 - P&G',\n sheet: 'P&G',\n accountIndex: 0,\n nameIndex: 1,\n emailIndex: 6,\n amountIndex: 21,\n currency: 'QTZ',\n },\n {\n node: 'Extract Bono14 - Motorola',\n sheet: 'B14 MOTO',\n accountIndex: 0,\n nameIndex: 3,\n emailIndex: 7,\n amountIndex: 46,\n currency: 'QTZ',\n },\n {\n node: 'Extract Bono14 - Whirlpool',\n sheet: 'B14 WP',\n accountIndex: 0,\n nameIndex: 3,\n emailIndex: 7,\n amountIndex: 34,\n currency: 'QTZ',\n },\n {\n node: 'Extract Bono14 - PMI',\n sheet: 'PMI',\n accountIndex: 0,\n nameIndex: 3,\n emailIndex: 7,\n amountIndex: 34,\n currency: 'QTZ',\n },\n {\n node: 'Extract Bono14 - Nestle',\n sheet: 'B14 Nestle',\n accountIndex: 0,\n nameIndex: 3,\n emailIndex: 7,\n amountIndex: 22,\n currency: 'QTZ',\n },\n {\n node: 'Extract Bono14 - GLM Q',\n sheet: 'B14 GLM Q',\n accountIndex: 0,\n nameIndex: 3,\n emailIndex: 6,\n amountIndex: 33,\n currency: 'QTZ',\n },\n {\n node: 'Extract Bono14 - GLM USD',\n sheet: 'B14 GLM $',\n accountIndex: 0,\n nameIndex: 3,\n emailIndex: 6,\n amountIndex: 33,\n currency: 'USD',\n },\n];\n\nconst payrollRows = [];\nconst noAccountRows = [];\nconst ignoredRows = [];\nconst sheetSummaries = [];\n\nfor (const config of configs) {\n const sourceRows = getNodeRows(config.node);\n const columns = detectBonus14Columns(sourceRows, config);\n let validRows = 0;\n let noAccountCount = 0;\n let ignoredCount = 0;\n let sheetTotal = 0;\n\n let officialSheetTotal = null;\n let officialTotalRow = null;\n\n for (\n let sourceIndex = 0;\n sourceIndex < sourceRows.length;\n sourceIndex++\n ) {\n const sourceRow = sourceRows[sourceIndex];\n const values = rawRowValues(sourceRow);\n\n if (!values.length) {\n ignoredCount += 1;\n continue;\n }\n\n const employeeName = normalizeText(values[columns.nameIndex]);\n const account = normalizeAccount(values[columns.accountIndex]);\n const email = normalizeText(values[columns.emailIndex]).toLowerCase();\n const amount = roundMoney(parseMoney(values[columns.amountIndex]));\n\n /*\n * Las hojas del Bono 14 tienen un TOTAL OFICIAL y, en algunos casos,\n * filas auxiliares/\"Ajustes\" debajo de ese total. Esas filas posteriores\n * no forman parte del monto canónico que RESUMEN envía al banco.\n *\n * Por eso el lector cierra la tabla al encontrar la primera fila, después\n * del encabezado, sin empleado/cuenta pero con un monto positivo en la\n * columna Bono 14. Con el archivo 2026 esto identifica exactamente los\n * totales oficiales de las siete hojas.\n */\n const afterHeader =\n columns.headerRowIndex < 0 ||\n sourceIndex > columns.headerRowIndex;\n\n if (\n afterHeader &&\n !employeeName &&\n !account &&\n amount > 0\n ) {\n officialSheetTotal = amount;\n officialTotalRow = sourceIndex + 1;\n break;\n }\n\n // Los totales/formulas y encabezados quedan fuera por nombre inválido.\n if (!validEmployeeName(employeeName) || amount <= 0 || amount > 1000000) {\n ignoredRows.push({\n source_sheet: config.sheet,\n row_number: sourceIndex + 1,\n employee_name: employeeName,\n account,\n email,\n payroll_amount: amount,\n currency: config.currency,\n reason:\n !validEmployeeName(employeeName)\n ? 'invalid_employee_name'\n : amount <= 0\n ? 'amount_zero_or_invalid'\n : 'suspicious_large_amount',\n });\n ignoredCount += 1;\n continue;\n }\n\n const normalized = {\n source_sheet: config.sheet,\n row_number: sourceIndex + 1,\n employee_name: employeeName,\n employee_number: null,\n account,\n email,\n payroll_amount: amount,\n currency: config.currency,\n };\n\n sheetTotal = roundMoney(sheetTotal + amount);\n\n if (!validAccount(account)) {\n noAccountRows.push({\n ...normalized,\n account: '',\n });\n noAccountCount += 1;\n continue;\n }\n\n payrollRows.push(normalized);\n validRows += 1;\n }\n\n if (\n officialSheetTotal !== null &&\n Math.abs(roundMoney(sheetTotal - officialSheetTotal)) > 0.05\n ) {\n throw new Error(\n `El total leído en ${config.sheet} (${sheetTotal.toFixed(2)}) ` +\n `no coincide con su total oficial (${officialSheetTotal.toFixed(2)}). ` +\n 'Se detuvo el proceso para evitar generar un reporte incorrecto.'\n );\n }\n\n sheetSummaries.push({\n sheet: config.sheet,\n node: config.node,\n raw_rows_count: sourceRows.length,\n valid_rows_count: validRows,\n no_account_rows_count: noAccountCount,\n ignored_rows_count: ignoredCount,\n total_amount: sheetTotal,\n currency: config.currency,\n detected_columns: {\n account: columns.accountIndex,\n name: columns.nameIndex,\n email: columns.emailIndex,\n amount: columns.amountIndex,\n from_header: columns.detectedFromHeader,\n header_row: columns.headerRowIndex >= 0\n ? columns.headerRowIndex + 1\n : null,\n },\n official_total: officialSheetTotal,\n official_total_row: officialTotalRow,\n total_validation_ok:\n officialSheetTotal === null\n ? null\n : Math.abs(roundMoney(sheetTotal - officialSheetTotal)) <= 0.05,\n });\n}\n\nif (payrollRows.length === 0 && noAccountRows.length === 0) {\n throw new Error(\n 'No se encontraron filas válidas de Bono 14 en las hojas esperadas del Excel.'\n );\n}\n\nconst groupedMap = new Map();\n\nfor (const row of payrollRows) {\n const groupKey = `${row.account}:${row.currency}`;\n const current = groupedMap.get(groupKey) || {\n group_key: groupKey,\n account: row.account,\n employee_name: row.employee_name,\n employee_number: null,\n email: row.email,\n currency: row.currency,\n payroll_amount: 0,\n rows_count: 0,\n source_sheets: new Set(),\n source_rows: [],\n };\n\n current.payroll_amount = roundMoney(\n current.payroll_amount + row.payroll_amount\n );\n current.rows_count += 1;\n\n if (!current.email && row.email) current.email = row.email;\n if (!current.employee_name && row.employee_name) current.employee_name = row.employee_name;\n\n current.source_sheets.add(row.source_sheet);\n current.source_rows.push({\n source_sheet: row.source_sheet,\n row_number: row.row_number,\n account: row.account,\n amount: row.payroll_amount,\n employee_name: row.employee_name,\n });\n\n groupedMap.set(groupKey, current);\n}\n\nconst groupedByAccount = Array.from(groupedMap.values()).map((row) => ({\n ...row,\n source_sheets: Array.from(row.source_sheets),\n}));\n\nconst totalsByCurrency = {};\nfor (const row of [...payrollRows, ...noAccountRows]) {\n totalsByCurrency[row.currency] = roundMoney(\n (totalsByCurrency[row.currency] || 0) + row.payroll_amount\n );\n}\n\nreturn [\n {\n json: {\n payroll: {\n source: 'bono14_guatemala',\n sheets_count: configs.length,\n sheet_summaries: sheetSummaries,\n raw_rows_count: sheetSummaries.reduce(\n (sum, sheet) => sum + sheet.raw_rows_count,\n 0\n ),\n valid_rows_count: payrollRows.length,\n no_account_rows_count: noAccountRows.length,\n ignored_rows_count: ignoredRows.length,\n grouped_accounts_count: groupedByAccount.length,\n attached_supplements_count: 0,\n potential_supplements_count: 0,\n potential_supplements: [],\n unattached_supplements_count: 0,\n total_amount: roundMoney(totalsByCurrency.QTZ || 0),\n totals_by_currency: totalsByCurrency,\n rows: payrollRows,\n no_account_rows: noAccountRows,\n grouped_by_account: groupedByAccount,\n },\n debug_payroll: {\n attached_supplements: [],\n potential_supplements: [],\n unattached_supplements: [],\n ignored_rows_preview: ignoredRows.slice(0, 100),\n no_account_rows_preview: noAccountRows.slice(0, 50),\n totals_by_currency: totalsByCurrency,\n },\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
5568,
3888
],
"id": "926de4e1-8954-4f6f-830f-2e6f724f4c6e",
"name": "Normalizar Bono 14"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
2368,
2416
],
"id": "ae4ba7a6-9e0c-492f-adff-5dd5bdb20692",
"name": "Merge Hojas 01-02"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
2560,
2624
],
"id": "14da77f8-838f-4bdf-90ed-4786990b145c",
"name": "Merge Hojas 03"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
2848,
2752
],
"id": "eb0de9de-c940-45b9-8f5c-b427f9fa198c",
"name": "Merge Hojas 04"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
3264,
2880
],
"id": "0910a078-76c0-4010-8daa-ffd11381cfa8",
"name": "Merge Hojas 05"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
3568,
3024
],
"id": "eda17990-ef1d-4b96-a17c-3c68a3617ee6",
"name": "Merge Hojas 06"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
3904,
3296
],
"id": "efe11e4e-9fe0-4836-b815-beeaedec2b3c",
"name": "Merge Hojas 07"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
4160,
3424
],
"id": "7ff3bcf8-0bfd-43da-bc6b-96c4bd236034",
"name": "Merge Hojas 08"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
4464,
3808
],
"id": "3751c607-4ef5-423e-b611-592bb9bad758",
"name": "Merge Hojas 09"
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": false,
"sheetName": "RESUMEN"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
1360,
4416
],
"id": "9e0d0649-cecb-4ddf-b499-1c0ccd84cc09",
"name": "Extract Bono14 - Resumen 3",
"retryOnFail": false,
"onError": "continueRegularOutput"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
4768,
3920
],
"id": "1a6b94fa-bee4-4c72-8adc-389a0084a0b2",
"name": "Merge Hojas "
},
{
"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 firstValue(value) {\n if (Array.isArray(value)) {\n return value.filter(Boolean).join(' / ');\n }\n return normalizeText(value);\n}\n\nfunction currencyOf(row) {\n return normalizeText(row.currency || 'QTZ').toUpperCase() || 'QTZ';\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') return 'Revisar cuenta';\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) return 'Se pagó de menos';\n if (difference < 0) return 'Se pagó de más';\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 year = Number(metadata.year || 0);\nconst periodLabel =\n metadata.period_label ||\n `Bono 14 ${year}`;\n\nconst spreadsheetTitle =\n `Cruce de Cuentas GLM GT - Bono 14 ${year}`;\n\nconst mainReportSubtitle =\n `Bono 14 vs. Banco · Guatemala · ${periodLabel}`;\nconst bancoSinBambooSubtitle =\n `Pagos de Bono 14 en banco sin empleado identificado en BambooHR · Guatemala · ${periodLabel}`;\nconst cuentaMalDigitadaSubtitle =\n `Posible Cuenta Mal Digitada · Bono 14 · Guatemala · ${periodLabel}`;\n\nconst cuentaMalDigitadaCases = rows.filter(\n (row) => row.category === 'posible_cuenta_mal_digitada'\n);\nconst hasCuentaMalDigitada = 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 Bono 14 vs Banco',\n bancoSinNomina: '02 Banco sin Bono 14',\n bancoSinBamboo: '03 Banco sin Bamboo',\n diferenciasNombreBanco: '04 Diferencias nombre banco',\n cuentaMalDigitada: '05 Cuenta Mal Digitada',\n resumen: hasCuentaMalDigitada ? '06 Resumen' : '05 Resumen',\n};\n\nconst mainHeader = [\n '#',\n 'Empleado',\n 'Cuenta',\n 'Moneda',\n 'Monto Bono 14',\n 'Monto Banco',\n 'Diferencia',\n 'Sentido',\n 'Estado',\n 'Resolución',\n];\n\nconst mainRows = rows\n .filter((row) => row.category === 'discrepancia')\n .map((row, index) => {\n const payrollAmount = roundMoney(row.payroll_amount ?? row.payrollAmount ?? 0);\n const bankAmount = roundMoney(row.bank_amount ?? row.bankAmount ?? 0);\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 currencyOf(row),\n payrollAmount,\n bankAmount,\n difference,\n mainReportSense(row, difference),\n normalizeText(row.status || 'Riesgo').toUpperCase(),\n '',\n ];\n });\n\nconst mainValues = [\n ['GOMEZLEE MARKETING', '', '', '', '', '', '', '', '', ''],\n [mainReportSubtitle, '', '', '', '', '', '', '', '', ''],\n ['', '', '', '', '', '', '', '', '', ''],\n mainHeader,\n ...mainRows,\n];\n\nconst bancoSinNominaHeader = [\n 'Empleado Banco',\n 'Cuenta Banco',\n 'Moneda',\n 'Monto Banco',\n 'Estado',\n 'Observación',\n 'Resolució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(row.bank_account || row.bankAccount || row.account || ''),\n currencyOf(row),\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 'Moneda',\n 'Monto en banco',\n 'Número de envío',\n 'Estado',\n 'Resolució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(row.bank_account || row.bankAccount || row.account || ''),\n currencyOf(row),\n roundMoney(row.bank_amount ?? row.bankAmount ?? row.amount ?? 0),\n firstValue(row.shipment_numbers || row.shipment_number || ''),\n 'PENDIENTE REVISIÓN',\n '',\n]);\nconst bancoSinBambooValues = [\n ['GOMEZLEE MARKETING', '', '', '', '', '', '', '', ''],\n [bancoSinBambooSubtitle, '', '', '', '', '', '', '', ''],\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];\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(row.bank_account || row.bankAccount || row.account || ''),\n currencyOf(row),\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\nfunction accountValues(value) {\n const values = Array.isArray(value)\n ? value\n : String(value ?? '').split(/\\s*(?:\\/|;|,|\\by\\b)\\s*/i);\n\n return values\n .map((item) => String(item ?? '').replace(/\\.0$/g, '').replace(/\\D/g, '').trim())\n .filter((item) => item.length >= 7 && !/^0+$/.test(item));\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 return Array.from(new Set(candidates.flatMap(accountValues)));\n}\n\nfunction bankAccountForWrongAccount(row) {\n return firstValue(row.bank_account || row.bankAccount || row.account || '');\n}\n\nfunction moneyLabel(value, currency) {\n const amount = Math.abs(roundMoney(value)).toLocaleString('en-US', {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n });\n return `${currency === 'USD' ? 'USD ' : 'Q'}${amount}`;\n}\n\nfunction wrongAccountTotalStatus(row) {\n const payrollAmount = roundMoney(row.payroll_amount ?? row.payrollAmount ?? 0);\n const bankAmount = roundMoney(row.bank_amount ?? row.bankAmount ?? 0);\n const difference = roundMoney(payrollAmount - bankAmount);\n const currency = currencyOf(row);\n\n if (Math.abs(difference) <= 0.02) {\n return 'El total del Bono 14 coincide con el total del banco.';\n }\n if (difference > 0) {\n return `El total del Bono 14 supera el total del banco por ${moneyLabel(difference, currency)}.`;\n }\n return `El total pagado por el banco supera el Bono 14 por ${moneyLabel(difference, currency)}.`;\n}\n\nfunction wrongAccountFinding(row) {\n const existing = normalizeText(row.observation || '');\n if (existing) return existing;\n\n const payrollAccounts = payrollAccountsForWrongAccount(row);\n const bankAccount = bankAccountForWrongAccount(row);\n return (\n `El empleado presenta una posible inconsistencia entre la cuenta del Excel de Bono 14 ` +\n `(${payrollAccounts.join(' y ') || 'sin cuenta identificada'}) y la cuenta utilizada por el banco ` +\n `(${bankAccount || 'sin cuenta identificada'}).`\n );\n}\n\nconst cuentaMalDigitadaHeader = ['#', 'Campo', 'Detalle', 'Resolución'];\nconst cuentaMalDigitadaRows = [];\n\nfor (let index = 0; index < cuentaMalDigitadaCases.length; index++) {\n const row = cuentaMalDigitadaCases[index];\n const payrollAccounts = payrollAccountsForWrongAccount(row);\n const bankAccount = bankAccountForWrongAccount(row);\n const fields = [\n ['Empleado', normalizeText(row.employee_name || row.employee || '')],\n ['Moneda', currencyOf(row)],\n [\n 'Cuentas registradas en el Excel de Bono 14',\n payrollAccounts.join(' y ') || 'No se identificó una cuenta válida en el Excel de Bono 14.',\n ],\n [\n 'Cuenta utilizada por el banco',\n bankAccount || 'No se identificó una cuenta válida en el banco.',\n ],\n ['Estado del total', wrongAccountTotalStatus(row)],\n ['Hallazgo', wrongAccountFinding(row)],\n [\n 'Clasificación',\n 'Posible cuenta mal digitada — revisar y unificar la cuenta del Bono 14 con la utilizada por el banco.',\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 ['GOMEZLEE MARKETING', '', '', ''],\n [cuentaMalDigitadaSubtitle, '', '', ''],\n ['', '', '', ''],\n cuentaMalDigitadaHeader,\n ...cuentaMalDigitadaRows,\n];\n\nconst resumenHeader = ['Indicador', 'Valor'];\nconst resumenRows = [\n ['Reporte', `Bono 14 ${year}`],\n ['Período de devengo', periodLabel],\n ['Coincidencias', Number(summary.coincidencias || 0)],\n ['Discrepancias', Number(summary.discrepancias || 0)],\n ['Banco sin Bono 14', Number(summary.bancoSinNomina || 0)],\n ['Banco sin Bamboo', Number(summary.bancoSinBamboo || 0)],\n ['Bono 14 sin cuenta', Number(summary.nominaSinCuenta || 0)],\n ['Diferencias nombre banco', Number(summary.diferenciasNombreBanco || 0)],\n ['Posibles cuentas mal digitadas', Number(summary.posiblesCuentasMalDigitadas || 0)],\n ['Transacciones bancarias', Number(summary.transaccionesBanco || 0)],\n ['Total Bono 14 QTZ', roundMoney(summary.totalNominaQTZ || summary.totalNomina || 0)],\n ['Total Banco QTZ', roundMoney(summary.totalBancoQTZ || summary.totalBanco || 0)],\n ['Diferencia QTZ', roundMoney(summary.diferenciaQTZ || summary.diferenciaTotal || 0)],\n ['Total Bono 14 USD', roundMoney(summary.totalNominaUSD || 0)],\n ['Total Banco USD', roundMoney(summary.totalBancoUSD || 0)],\n ['Diferencia USD', roundMoney(summary.diferenciaUSD || 0)],\n];\n\nfunction buildSheetValues(header, bodyRows) {\n return [header, ...bodyRows];\n}\n\nconst valueData = [\n { range: `'${sheetTitles.nominaVsBanco}'!A1:J`, values: mainValues },\n {\n range: `'${sheetTitles.bancoSinNomina}'!A1:G`,\n values: buildSheetValues(bancoSinNominaHeader, bancoSinNominaRows),\n },\n { range: `'${sheetTitles.bancoSinBamboo}'!A1:I`, values: bancoSinBambooValues },\n {\n range: `'${sheetTitles.diferenciasNombreBanco}'!A1:K`,\n values: buildSheetValues(diferenciasNombreHeader, diferenciasNombreRows),\n },\n ...(hasCuentaMalDigitada\n ? [{ range: `'${sheetTitles.cuentaMalDigitada}'!A1:D`, values: cuentaMalDigitadaValues }]\n : []),\n {\n range: `'${sheetTitles.resumen}'!A1:B`,\n values: buildSheetValues(resumenHeader, resumenRows),\n },\n];\n\nfunction color(red, green, blue) {\n return { red, green, blue };\n}\n\nconst BLUE = color(0.29, 0.49, 0.58);\nconst GREEN = color(0.42, 0.76, 0.29);\nconst WHITE = color(1, 1, 1);\nconst LIGHT_RED = color(1, 0.92, 0.92);\nconst LIGHT_YELLOW = color(1, 0.97, 0.84);\nconst LIGHT_GREEN = color(0.91, 0.96, 0.91);\nconst LIGHT_BLUE = color(0.93, 0.96, 0.98);\nconst DARK = color(0.29, 0.29, 0.29);\n\nfunction repeatFormat(sheetId, startRowIndex, endRowIndex, startColumnIndex, endColumnIndex, userEnteredFormat, fields) {\n return {\n repeatCell: {\n range: { sheetId, startRowIndex, endRowIndex, startColumnIndex, endColumnIndex },\n cell: { userEnteredFormat },\n fields,\n },\n };\n}\n\nfunction freeze(sheetId, frozenRowCount) {\n return {\n updateSheetProperties: {\n properties: { sheetId, gridProperties: { frozenRowCount } },\n fields: 'gridProperties.frozenRowCount',\n },\n };\n}\n\nfunction setWidth(sheetId, startIndex, endIndex, pixelSize) {\n return {\n updateDimensionProperties: {\n range: { sheetId, dimension: 'COLUMNS', startIndex, endIndex },\n properties: { pixelSize },\n fields: 'pixelSize',\n },\n };\n}\n\nfunction autoRows(sheetId, endRowIndex) {\n return {\n autoResizeDimensions: {\n dimensions: {\n sheetId,\n dimension: 'ROWS',\n startIndex: 0,\n endIndex: Math.max(1, endRowIndex),\n },\n },\n };\n}\n\nfunction basicFilter(sheetId, startRowIndex, endRowIndex, endColumnIndex) {\n if (endRowIndex <= startRowIndex) return null;\n return {\n setBasicFilter: {\n filter: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex: 0,\n endColumnIndex,\n },\n },\n },\n };\n}\n\nfunction mergeRow(sheetId, rowIndex, endColumnIndex) {\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 moneyFormat(sheetId, startRowIndex, endRowIndex, startColumnIndex, endColumnIndex) {\n if (endRowIndex <= startRowIndex) return null;\n return repeatFormat(\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n { numberFormat: { type: 'NUMBER', pattern: '#,##0.00' } },\n 'userEnteredFormat.numberFormat'\n );\n}\n\nfunction addProtection(sheetId, endRowIndex, resolutionColumnIndex = null, resolutionStartRowIndex = 1) {\n const protectedRange = {\n range: { sheetId },\n description: `Protección GLM · Bono 14 · ${sheetId}`,\n warningOnly: false,\n editors: {\n users: ['iaracena@gomezleemarketing.com'],\n domainUsersCanEdit: false,\n },\n };\n\n if (\n Number.isInteger(resolutionColumnIndex) &&\n Number.isInteger(endRowIndex) &&\n endRowIndex > resolutionStartRowIndex\n ) {\n protectedRange.unprotectedRanges = [\n {\n sheetId,\n startRowIndex: resolutionStartRowIndex,\n endRowIndex,\n startColumnIndex: resolutionColumnIndex,\n endColumnIndex: resolutionColumnIndex + 1,\n },\n ];\n }\n\n return { addProtectedRange: { protectedRange } };\n}\n\nfunction standardBodyFormat(sheetId, startRowIndex, endRowIndex, endColumnIndex) {\n if (endRowIndex <= startRowIndex) return null;\n return repeatFormat(\n sheetId,\n startRowIndex,\n endRowIndex,\n 0,\n endColumnIndex,\n {\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n textFormat: { foregroundColor: DARK },\n },\n 'userEnteredFormat(verticalAlignment,wrapStrategy,textFormat)'\n );\n}\n\nfunction headerFormat(sheetId, rowIndex, endColumnIndex) {\n return repeatFormat(\n sheetId,\n rowIndex,\n rowIndex + 1,\n 0,\n endColumnIndex,\n {\n backgroundColor: BLUE,\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n textFormat: { bold: true, foregroundColor: WHITE },\n },\n 'userEnteredFormat(backgroundColor,horizontalAlignment,verticalAlignment,wrapStrategy,textFormat)'\n );\n}\n\nfunction titleFormat(sheetId, rowIndex, endColumnIndex, fontSize = 12) {\n return repeatFormat(\n sheetId,\n rowIndex,\n rowIndex + 1,\n 0,\n endColumnIndex,\n {\n backgroundColor: BLUE,\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n textFormat: { bold: true, fontSize, foregroundColor: WHITE },\n },\n 'userEnteredFormat(backgroundColor,horizontalAlignment,verticalAlignment,wrapStrategy,textFormat)'\n );\n}\n\nconst formatRequests = [];\nfunction push(request) {\n if (request) formatRequests.push(request);\n}\n\n// 01 Bono 14 vs Banco\nconst mainEnd = mainValues.length;\npush(mergeRow(sheetIds.nominaVsBanco, 0, 10));\npush(mergeRow(sheetIds.nominaVsBanco, 1, 10));\npush(titleFormat(sheetIds.nominaVsBanco, 0, 10, 13));\npush(titleFormat(sheetIds.nominaVsBanco, 1, 10, 11));\npush(headerFormat(sheetIds.nominaVsBanco, 3, 10));\npush(standardBodyFormat(sheetIds.nominaVsBanco, 4, mainEnd, 10));\npush(moneyFormat(sheetIds.nominaVsBanco, 4, mainEnd, 4, 7));\npush(\n repeatFormat(\n sheetIds.nominaVsBanco,\n 4,\n mainEnd,\n 8,\n 9,\n {\n backgroundColor: LIGHT_RED,\n textFormat: { bold: true, foregroundColor: color(0.70, 0.20, 0.20) },\n horizontalAlignment: 'CENTER',\n },\n 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment)'\n )\n);\npush(basicFilter(sheetIds.nominaVsBanco, 3, mainEnd, 10));\npush(freeze(sheetIds.nominaVsBanco, 4));\n[\n [0, 1, 55], [1, 2, 220], [2, 3, 145], [3, 4, 75], [4, 7, 125],\n [7, 8, 185], [8, 9, 120], [9, 10, 230],\n].forEach(([s, e, px]) => push(setWidth(sheetIds.nominaVsBanco, s, e, px)));\npush(autoRows(sheetIds.nominaVsBanco, mainEnd));\n\nif (mainEnd > 4) {\n push({\n addConditionalFormatRule: {\n rule: {\n ranges: [{ sheetId: sheetIds.nominaVsBanco, startRowIndex: 4, endRowIndex: mainEnd, startColumnIndex: 6, endColumnIndex: 7 }],\n booleanRule: {\n condition: { type: 'CUSTOM_FORMULA', values: [{ userEnteredValue: '=$G5>0' }] },\n format: { backgroundColor: LIGHT_YELLOW, textFormat: { bold: true } },\n },\n },\n index: 0,\n },\n });\n push({\n addConditionalFormatRule: {\n rule: {\n ranges: [{ sheetId: sheetIds.nominaVsBanco, startRowIndex: 4, endRowIndex: mainEnd, startColumnIndex: 6, endColumnIndex: 7 }],\n booleanRule: {\n condition: { type: 'CUSTOM_FORMULA', values: [{ userEnteredValue: '=$G5<0' }] },\n format: { backgroundColor: LIGHT_RED, textFormat: { bold: true } },\n },\n },\n index: 0,\n },\n });\n}\npush(addProtection(sheetIds.nominaVsBanco, mainEnd, 9, 4));\n\n// 02 Banco sin Bono 14\nconst bankNoBonusEnd = 1 + bancoSinNominaRows.length;\npush(headerFormat(sheetIds.bancoSinNomina, 0, 7));\npush(standardBodyFormat(sheetIds.bancoSinNomina, 1, bankNoBonusEnd, 7));\npush(moneyFormat(sheetIds.bancoSinNomina, 1, bankNoBonusEnd, 3, 4));\npush(basicFilter(sheetIds.bancoSinNomina, 0, bankNoBonusEnd, 7));\npush(freeze(sheetIds.bancoSinNomina, 1));\n[[0,1,220],[1,2,145],[2,3,75],[3,4,125],[4,5,130],[5,6,320],[6,7,230]].forEach(([s,e,px]) => push(setWidth(sheetIds.bancoSinNomina,s,e,px)));\npush(autoRows(sheetIds.bancoSinNomina, bankNoBonusEnd));\npush(addProtection(sheetIds.bancoSinNomina, bankNoBonusEnd, 6, 1));\n\n// 03 Banco sin Bamboo\nconst bambooEnd = bancoSinBambooValues.length;\npush(mergeRow(sheetIds.bancoSinBamboo, 0, 9));\npush(mergeRow(sheetIds.bancoSinBamboo, 1, 9));\npush(titleFormat(sheetIds.bancoSinBamboo, 0, 9, 13));\npush(titleFormat(sheetIds.bancoSinBamboo, 1, 9, 11));\npush(headerFormat(sheetIds.bancoSinBamboo, 3, 9));\npush(standardBodyFormat(sheetIds.bancoSinBamboo, 4, bambooEnd, 9));\npush(moneyFormat(sheetIds.bancoSinBamboo, 4, bambooEnd, 5, 6));\npush(\n repeatFormat(\n sheetIds.bancoSinBamboo,\n 4,\n bambooEnd,\n 7,\n 8,\n { backgroundColor: LIGHT_YELLOW, textFormat: { bold: true }, horizontalAlignment: 'CENTER' },\n 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment)'\n )\n);\npush(basicFilter(sheetIds.bancoSinBamboo, 3, bambooEnd, 9));\npush(freeze(sheetIds.bancoSinBamboo, 4));\n[[0,1,55],[1,2,220],[2,3,220],[3,4,145],[4,5,75],[5,6,125],[6,7,120],[7,8,155],[8,9,230]].forEach(([s,e,px]) => push(setWidth(sheetIds.bancoSinBamboo,s,e,px)));\npush(autoRows(sheetIds.bancoSinBamboo, bambooEnd));\npush(addProtection(sheetIds.bancoSinBamboo, bambooEnd, 8, 4));\n\n// 04 Diferencias nombre banco\nconst diffNameEnd = 1 + diferenciasNombreRows.length;\npush(headerFormat(sheetIds.diferenciasNombreBanco, 0, 11));\npush(standardBodyFormat(sheetIds.diferenciasNombreBanco, 1, diffNameEnd, 11));\npush(moneyFormat(sheetIds.diferenciasNombreBanco, 1, diffNameEnd, 4, 5));\npush(basicFilter(sheetIds.diferenciasNombreBanco, 0, diffNameEnd, 11));\npush(freeze(sheetIds.diferenciasNombreBanco, 1));\n[[0,1,220],[1,2,220],[2,3,145],[3,4,75],[4,5,125],[5,7,115],[7,8,220],[8,9,130],[9,10,320],[10,11,230]].forEach(([s,e,px]) => push(setWidth(sheetIds.diferenciasNombreBanco,s,e,px)));\npush(autoRows(sheetIds.diferenciasNombreBanco, diffNameEnd));\npush(addProtection(sheetIds.diferenciasNombreBanco, diffNameEnd, 10, 1));\n\n// 05 Cuenta Mal Digitada (opcional)\nif (hasCuentaMalDigitada) {\n const wrongEnd = cuentaMalDigitadaValues.length;\n push(mergeRow(sheetIds.cuentaMalDigitada, 0, 4));\n push(mergeRow(sheetIds.cuentaMalDigitada, 1, 4));\n push(titleFormat(sheetIds.cuentaMalDigitada, 0, 4, 13));\n push(titleFormat(sheetIds.cuentaMalDigitada, 1, 4, 11));\n push(headerFormat(sheetIds.cuentaMalDigitada, 3, 4));\n push(standardBodyFormat(sheetIds.cuentaMalDigitada, 4, wrongEnd, 4));\n push(freeze(sheetIds.cuentaMalDigitada, 4));\n [[0,1,55],[1,2,270],[2,3,640],[3,4,230]].forEach(([s,e,px]) => push(setWidth(sheetIds.cuentaMalDigitada,s,e,px)));\n push(autoRows(sheetIds.cuentaMalDigitada, wrongEnd));\n push(addProtection(sheetIds.cuentaMalDigitada, wrongEnd, 3, 4));\n}\n\n// Resumen\nconst summaryEnd = 1 + resumenRows.length;\npush(headerFormat(sheetIds.resumen, 0, 2));\npush(standardBodyFormat(sheetIds.resumen, 1, summaryEnd, 2));\npush(\n repeatFormat(\n sheetIds.resumen,\n 1,\n summaryEnd,\n 0,\n 1,\n { backgroundColor: LIGHT_BLUE, textFormat: { bold: true, foregroundColor: DARK } },\n 'userEnteredFormat(backgroundColor,textFormat)'\n )\n);\npush(freeze(sheetIds.resumen, 1));\npush(setWidth(sheetIds.resumen, 0, 1, 310));\npush(setWidth(sheetIds.resumen, 1, 2, 260));\npush(autoRows(sheetIds.resumen, summaryEnd));\npush(addProtection(sheetIds.resumen, summaryEnd, null, 1));\n\nconst desiredSheets = [\n {\n properties: {\n sheetId: sheetIds.nominaVsBanco,\n title: sheetTitles.nominaVsBanco,\n index: 0,\n gridProperties: { rowCount: Math.max(200, mainEnd + 20), columnCount: 10 },\n },\n },\n {\n properties: {\n sheetId: sheetIds.bancoSinNomina,\n title: sheetTitles.bancoSinNomina,\n index: 1,\n gridProperties: { rowCount: Math.max(200, bankNoBonusEnd + 20), columnCount: 7 },\n },\n },\n {\n properties: {\n sheetId: sheetIds.bancoSinBamboo,\n title: sheetTitles.bancoSinBamboo,\n index: 2,\n gridProperties: { rowCount: Math.max(200, bambooEnd + 20), columnCount: 9 },\n },\n },\n {\n properties: {\n sheetId: sheetIds.diferenciasNombreBanco,\n title: sheetTitles.diferenciasNombreBanco,\n index: 3,\n gridProperties: { rowCount: Math.max(200, diffNameEnd + 20), columnCount: 11 },\n },\n },\n ...(hasCuentaMalDigitada\n ? [\n {\n properties: {\n sheetId: sheetIds.cuentaMalDigitada,\n title: sheetTitles.cuentaMalDigitada,\n index: 4,\n gridProperties: {\n rowCount: Math.max(200, cuentaMalDigitadaValues.length + 20),\n columnCount: 4,\n },\n },\n },\n ]\n : []),\n {\n properties: {\n sheetId: sheetIds.resumen,\n title: sheetTitles.resumen,\n index: hasCuentaMalDigitada ? 5 : 4,\n gridProperties: { rowCount: 200, columnCount: 2 },\n },\n },\n];\n\nreturn [\n {\n json: {\n ok: data.ok ?? true,\n stage: 'google_sheet_bono14_preparado',\n metadata,\n summary,\n sheetIds,\n sheetTitles,\n spreadsheetTitle,\n createSpreadsheetBody: {\n properties: {\n title: spreadsheetTitle,\n locale: 'es_GT',\n timeZone: 'America/Guatemala',\n },\n sheets: desiredSheets,\n },\n valueBatchBody: {\n valueInputOption: 'USER_ENTERED',\n data: valueData,\n },\n formatBatchBody: {\n requests: formatRequests,\n },\n originalResponse: data,\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
8016,
3280
],
"id": "9c64018c-0cb3-486f-9d23-7130d711cb97",
"name": "Preparar Google Sheet Bono 14"
},
{
"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 Bono 14').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 /*\n * Google Sheets rechazó el locale \"es_GT\" al crear el spreadsheet\n * (\"Unsupported locale: es_GT\").\n *\n * No necesitamos enviar locale para este reporte: Google aplicará el\n * locale predeterminado de la cuenta/archivo y todos los formatos\n * importantes del reporte se establecen explícitamente después.\n *\n * Se elimina SOLO esa propiedad del request de creación y se conserva\n * el resto de propiedades y toda la lógica existente.\n */\n const {\n locale: _unsupportedLocale,\n ...safeSpreadsheetProperties\n } = createBody.properties || {};\n\n return {\n properties: {\n ...safeSpreadsheetProperties,\n timeZone: 'America/Guatemala',\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": [
8256,
3296
],
"id": "c5ef1b72-12c6-48c0-bd6c-6e410704a2e6",
"name": "Crear Google Sheet",
"credentials": {
"httpBasicAuth": {
"id": "nIxZ7elcHvuzsRKW",
"name": "Neo4j"
},
"googleOAuth2Api": {
"id": "eHseMeH39kRcXgOF",
"name": "Google account 2"
}
}
},
{
"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": [
9472,
3280
],
"id": "64e1694d-6b0a-411c-86f7-636042887223",
"name": "Escribir Google Sheet",
"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": [
9712,
3280
],
"id": "c5d3a10e-4614-4f85-a624-5d9c99fb2629",
"name": "Formatear Google Sheet",
"credentials": {
"googleOAuth2Api": {
"id": "dQ1MJSJSWcoWYcb8",
"name": "Google account - Isaac Producción"
}
}
},
{
"parameters": {
"jsCode": "const prepared = $('Preparar Google Sheet Bono 14').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 de Bono 14 existente actualizado correctamente. Se conservaron ${Number(sheetContext.preservedResolutionsCount || 0)} resolución(es) previamente registradas.`\n : 'Bono 14 procesado correctamente. Google Sheet generado.')\n : 'Bono 14 procesado correctamente, pero no se recibió URL del Google Sheet.',\n stage: reportUrl ? 'bono14_completado_con_reporte' : 'bono14_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": [
11648,
3280
],
"id": "02afa713-5438-4ab4-afaf-bb8cb1d4a3d6",
"name": "Preparar respuesta final"
},
{
"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 'jsepulveda@gomezlee.marketing',\n 'mbenitez@gomezlee.marketing',\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": [
10608,
3280
],
"id": "c465e89a-e976-4f8d-96fd-21ad85aa965c",
"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": [
10848,
3280
],
"id": "e36f25f7-f99b-4a7b-ac0e-2e653d86a967",
"name": "Compartir Google Sheet",
"credentials": {
"googleOAuth2Api": {
"id": "dQ1MJSJSWcoWYcb8",
"name": "Google account - Isaac Producción"
}
},
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "const cruce = $('Cruzar Bono 14 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 || 'GT';\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 'GT',\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-bono14-guatemala',\n country: metadata.country || 'GT',\n country_name: metadata.country_name || 'Guatemala',\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 report_kind: 'bono14',\n total_bono14_qtz: toNumber(summary.totalNominaQTZ),\n total_banco_qtz: toNumber(summary.totalBancoQTZ),\n diferencia_qtz: toNumber(summary.diferenciaQTZ),\n total_bono14_usd: toNumber(summary.totalNominaUSD),\n total_banco_usd: toNumber(summary.totalBancoUSD),\n diferencia_usd: toNumber(summary.diferenciaUSD),\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": [
11168,
3280
],
"id": "9a375998-957b-4e2f-9ac0-aea3b5ce3cdd",
"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": [
11408,
3280
],
"id": "aee129de-29ee-4808-a480-a0d0c1c9fa4f",
"name": "Insertar histórico Supabase",
"onError": "continueRegularOutput"
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": false,
"sheetName": "RESUMEN"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
1360,
4400
],
"id": "dcd3aab6-326d-4a6e-9f80-5645203de828",
"name": "Extract Bono14 - Resumen 4",
"retryOnFail": false,
"onError": "continueRegularOutput"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
4928,
4064
],
"id": "1e8c09ae-f71c-4bb7-905c-4f873c1a9811",
"name": "Merge Hojas 10"
},
{
"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 GT",
"fields": [
"firstName",
"middleName",
"lastName",
"displayName",
"department",
"division",
"location",
"customPosicion-Cliente",
"hireDate",
"originalHireDate",
"status",
"employeeNumber"
]
},
"options": {
"response": {
"response": {
"responseFormat": "json"
}
},
"timeout": 300000
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
912,
2336
],
"id": "ca5bc0bf-fd2a-4bdf-b7ac-34628f666975",
"name": "HTTP - Empleados BambooHR Bono14 GT",
"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').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 === 'gt' ||\n country === 'gtm' ||\n country.includes('guatemala') ||\n location === 'gt' ||\n location === 'gtm' ||\n location.includes('guatemala')\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 'guatemala_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 Guatemala 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 Guatemala,\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 * - JORGE LUIS MORALES PEREZ · Guatemala\n * - Jorge Luis Morales · otro país\n *\n * El rescate fuera del país permanece activo cuando no existe una\n * coincidencia exacta entre los perfiles de Guatemala.\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 [normalize(\"CARLOS DE LEON\"), normalize(\"Carlos Alexander De leon chajon\")],\n [normalize(\"CARLOS ALEXANDER DE LEON CHAJON\"), normalize(\"Carlos Alexander De leon chajon\")],\n [normalize(\"LISANDRO LINARES\"), normalize(\"Lisandro Antonio Linares giron\")],\n [normalize(\"LISANDRO ANTONIO LINARES GIRON\"), normalize(\"Lisandro Antonio Linares giron\")],\n [normalize(\"JULIO VELASQUEZ\"), normalize(\"Julio Francisco Velásquez\")],\n [normalize(\"JULIO FRANCISCO VELASQUEZ RAMIREZ\"), normalize(\"Julio Francisco Velásquez\")],\n [normalize(\"PABLO GIRON\"), normalize(\"Pablo Augusto Giron Robles\")],\n [normalize(\"PABLO AUGUSTO GIRON ROBLES\"), normalize(\"Pablo Augusto Giron Robles\")],\n [normalize(\"VERONICA GARCIA\"), normalize(\"Verónica Beatriz García Quan\")],\n [normalize(\"VERONICA BEATRIZ GARCIA\"), normalize(\"Verónica Beatriz García Quan\")],\n [normalize(\"VERONICA BEATRIZ GARCIA QUAN\"), normalize(\"Verónica Beatriz García Quan\")],\n [normalize(\"SANDRA LOPEZ\"), normalize(\"Sandra Lisbeth López Godoy\")],\n [normalize(\"SANDRA LIZBETH LOPEZ GODOY\"), normalize(\"Sandra Lisbeth López Godoy\")],\n [normalize(\"SANDRA LISBETH LOPEZ GODOY\"), normalize(\"Sandra Lisbeth López Godoy\")]\n]);\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 Guatemala 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_gt_normalizado'\n : 'bamboohr_gt_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 guatemala_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": [
1136,
2336
],
"id": "867f76e7-7db9-4259-9e61-6d0dc9acbbeb",
"name": "Normalizar BambooHR Bono14 GT"
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"options": {}
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
7360,
3280
],
"id": "b27bfa42-456c-45de-98f0-c7fe919ee8af",
"name": "Merge - Agregar BambooHR Bono14"
},
{
"parameters": {
"content": "# 📥 ENTRADA Y EXTRACCIÓN DE DATOS — GUATEMALA\n\nRecibe desde el Portal de Verificación de Bono 14s los archivos y parámetros necesarios para ejecutar el cruce de Guatemala.\n\nFuentes procesadas:\n\n- Directorio de empleados de BambooHR.\n- Archivo CSV del banco.\n- Archivo Excel de Bono 14 con múltiples hojas.\n- Hojas adicionales de pagos, bonos, viáticos, combustibles, auditorías, temporales y otras unidades.\n\nEste bloque:\n\n1. Recibe la solicitud de la aplicación.\n2. Normaliza año, mes y tipo de período.\n3. Consulta los empleados disponibles en BambooHR.\n4. Estandariza nombres, correos e identificadores.\n5. Convierte el CSV bancario en registros procesables.\n6. Extrae cada hoja relevante del archivo Excel.\n7. Mantiene la hoja de origen de cada registro para facilitar validaciones.\n\nReglas:\n\n- No iniciar el cruce sin los archivos obligatorios.\n- Mantener separados banco, Bono 14 y BambooHR.\n- No asumir que todas las hojas tienen la misma estructura.\n- No perder la procedencia de los registros.\n- Preparar todas las fuentes en un formato compatible con la consolidación.",
"height": 2992,
"width": 1760,
"color": "#2D305D"
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
128,
1760
],
"id": "2e44f1cc-edb2-4fb8-af15-2441068ba6f1",
"name": "Sticky Note"
},
{
"parameters": {
"content": "# 🧩 CONSOLIDACIÓN DE NÓMINA — GUATEMALA\n\nUne progresivamente todas las hojas extraídas hasta construir una sola Bono 14 consolidada del período.\n\nDurante la consolidación:\n\n- Se agregan las hojas en una secuencia controlada.\n- Se conserva la unidad o pestaña de procedencia.\n- Se eliminan filas completamente vacías.\n- Se estandarizan encabezados y tipos de datos.\n- Se normalizan nombres de empleados.\n- Se limpian espacios, símbolos y caracteres especiales.\n- Se convierten montos y fechas a formatos consistentes.\n\nLa salida de este bloque representa la Bono 14 completa que será comparada con el banco y BambooHR.\n\nReglas:\n\n- No sobrescribir registros de hojas anteriores.\n- No eliminar empleados únicamente porque aparezcan en más de una hoja.\n- Identificar correctamente posibles duplicados reales.\n- Mantener los valores originales para revisión.\n- No enviar hojas individualmente a la etapa de cruce.",
"height": 2336,
"width": 3984,
"color": "#216353"
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
2064,
1904
],
"id": "c346fd57-b420-473e-8131-7b60bde1d960",
"name": "Sticky Note1"
},
{
"parameters": {
"content": "# 🔍 CRUCE Y REPORTE FINAL — GUATEMALA\n\nCombina la Bono 14 consolidada con el archivo bancario y la información oficial de BambooHR.\n\nEl cruce permite detectar:\n\n- Diferencias entre Bono 14 y banco.\n- Empleados presentes solamente en Bono 14.\n- Registros presentes solamente en el banco.\n- Empleados no encontrados en BambooHR.\n- Posibles diferencias de nombres, cuentas o identificadores.\n- Diferencias en montos pagados.\n- Registros que requieren revisión manual.\n\nDespués del análisis:\n\n1. Se organizan los resultados por hoja y categoría.\n2. Se prepara la estructura del reporte.\n3. Se crea un nuevo Google Sheet.\n4. Se escriben encabezados, resultados y resúmenes.\n5. Se aplican formatos de moneda, fechas y columnas.\n6. Se configuran los permisos.\n7. Se comparte el archivo con los usuarios autorizados.\n\nReglas:\n\n- No depender únicamente del nombre para relacionar empleados.\n- No ocultar registros sin coincidencia.\n- No compartir el Sheet antes de finalizar su escritura.\n- No devolver el enlace hasta confirmar que el archivo existe.\n- Google Sheets funciona como entregable; las fuentes originales siguen siendo BambooHR, Bono 14 y banco.",
"height": 608,
"width": 3472,
"color": "#5F721D"
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
6448,
3056
],
"id": "ad3da1da-1a16-4980-9d1b-fa11c0048b95",
"name": "Sticky Note2"
},
{
"parameters": {
"content": "# 🗂️ HISTÓRICO Y RESPUESTA FINAL\n\nRegistra en Supabase la ejecución completada y devuelve el resultado al Portal de Verificación de Bono 14s.\n\nEl histórico puede almacenar:\n\n- País: Guatemala.\n- Año y mes procesados.\n- Tipo de período.\n- Fecha de ejecución.\n- Usuario que inició el proceso.\n- Cantidad de registros analizados.\n- Cantidad de diferencias o hallazgos.\n- Enlace del Google Sheet.\n- Estado inicial del reporte.\n- Identificador de la ejecución.\n\nDespués del registro:\n\n1. Se construye la respuesta para la aplicación.\n2. Se incluye el enlace al reporte generado.\n3. Se devuelve el resumen de resultados.\n4. Se informa si el procesamiento terminó correctamente.\n5. Se cierra la solicitud mediante Respond to Webhook.\n\nReglas:\n\n- Registrar el histórico solamente después de crear el reporte.\n- No declarar éxito si falló el Sheet o Supabase.\n- No devolver credenciales ni información interna.\n- Mantener una estructura estable para la aplicación.\n- Supabase es la fuente oficial de los históricos mostrados en el portal.",
"height": 992,
"width": 2048,
"color": 3
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
10064,
2976
],
"id": "46289c23-7cda-434d-827c-f47ad3ba9146",
"name": "Sticky Note3"
},
{
"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, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n\nfunction numberValue(value) {\n const parsed = Number(value);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction formatGeneratedAt(date) {\n const timeZone = 'America/Santo_Domingo';\n\n const dateLabel = new Intl.DateTimeFormat('es-DO', {\n timeZone,\n day: 'numeric',\n month: 'long',\n year: 'numeric',\n }).format(date);\n\n const timeLabel = new Intl.DateTimeFormat('es-DO', {\n timeZone,\n hour: 'numeric',\n minute: '2-digit',\n hour12: true,\n }).format(date);\n\n const hour24Raw = new Intl.DateTimeFormat('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 \n \n \n | \n \n ${escapeHtml(label)}\n \n\n \n ${numberValue(value).toLocaleString('en-US')}\n \n | \n \n \n | \n `;\n}\n\nconst countryName =\n payload.country_name ||\n metadata.country_name ||\n (\n String(payload.country || '').toUpperCase() === 'TT'\n ? 'Trinidad y Tobago'\n : 'Guatemala'\n );\n\nconst countryCode = String(\n payload.country ||\n metadata.country ||\n ''\n).toUpperCase();\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 countryCode === 'TT'\n ? 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/Flag_of_Trinidad_and_Tobago.svg.webp'\n : 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/Flag_of_Guatemala.svg.webp';\n\nconst subject = reportUpdated\n ? `Reporte actualizado - Bono 14 ${countryName} - ${periodLabel}`\n : `Nuevo reporte generado - Bono 14 ${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 'jgomez@gomezleemarketing.com',\n 'mgomez@gomezleemarketing.com',\n 'vparamo@gomezleemarketing.com',\n 'ymadera@gomezleemarketing.com',\n 'jsepulveda@gomezlee.marketing',\n 'mbenitez@gomezlee.marketing',\n].join(',');\n\n// Paleta oficial GLM.\nconst metricCards = [\n metricCard(\n 'Discrepancias',\n discrepancias,\n '#FF6A13'\n ),\n metricCard(\n 'Banco sin Bono 14',\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 'Bono 14 sin cuenta',\n nominaSinCuenta,\n '#4F758B'\n ),\n].join('');\n\nconst htmlBody = `\n\n\n\n\n \n \n ${escapeHtml(subject)}\n\n\n\n\n \n \n \n\n \n\n \n \n | \n \n | \n \n\n \n \n \n\n \n \n \n \n | \n\n \n \n ${reportUpdated ? 'REPORTE ACTUALIZADO' : 'REPORTE GENERADO'}\n \n | \n \n \n\n \n\n \n \n\n \n \n | \n\n \n\n \n Bono 14 · ${escapeHtml(countryName)}\n \n\n \n ${escapeHtml(periodLabel)}\n \n\n | \n\n \n \n\n | \n \n\n \n \n | \n\n \n ${reportUpdated ? 'Se actualizó el reporte existente de Bono 14 conservando las resoluciones previamente registradas que continúan correspondiendo a los mismos casos.' : 'Se completó una nueva ejecución del cruce de Bono 14 de Guatemala. Este es el resumen del reporte generado.'}\n \n\n | \n \n\n \n \n \n\n \n \n ${metricCards}\n \n \n\n | \n \n\n \n \n \n\n \n\n \n\n | \n\n \n Ejecutado por\n \n\n \n ${escapeHtml(executedByName)}\n \n\n \n ${escapeHtml(\n executedByEmail ||\n 'Correo no disponible'\n )}\n \n\n | \n\n \n\n \n País\n \n\n \n \n\n \n \n | \n\n \n\n \n ${escapeHtml(countryName)}\n \n\n \n ${escapeHtml(\n countryCode || '—'\n )}\n \n\n | \n\n \n \n\n | \n\n \n\n \n\n | \n\n \n Período\n \n\n \n ${escapeHtml(periodLabel)}\n \n\n \n ${escapeHtml(generatedAtLabel)}\n \n\n | \n\n \n\n \n Estado inicial\n \n\n \n ${escapeHtml(statusLabel)}\n \n\n \n Coincidencias:\n ${coincidencias.toLocaleString('en-US')}\n \n\n | \n\n \n\n \n\n | \n \n\n \n ${\n reportUrl\n ? `\n \n | \n\n \n Abrir reporte en Google Sheets\n \n\n | \n \n `\n : ''\n }\n\n \n \n | \n\n \n\n \n Notificación automática · Bono 14 · Portal Cruce de Cuentas GLM\n \n Los casos de revisión deben gestionarse directamente en el reporte generado.\n \n\n | \n \n\n \n\n | \n
\n
\n\n\n\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": [
11648,
3600
],
"id": "1a73c8b6-7f06-4e5d-a83a-f731bd96a1ad",
"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": [
11888,
3600
],
"id": "fbf05240-1dd2-4cde-b7ca-7ebb81136295",
"name": "Enviar correo reporte generado",
"webhookId": "c10f5aec-68bc-44d9-8152-2e14f8898e02",
"credentials": {
"gmailOAuth2": {
"id": "UDcO1FLJqA453V2D",
"name": "Gmail account 3"
}
},
"onError": "continueRegularOutput"
},
{
"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": "GT"
},
"options": {
"response": {
"response": {
"responseFormat": "json"
}
},
"timeout": 30000
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
7584,
3264
],
"id": "aa995943-d367-449f-a720-7aa37c2862b5",
"name": "Obtener alias BambooHR Bono14 Supabase",
"retryOnFail": true,
"maxTries": 2,
"waitBetweenTries": 1000,
"onError": "continueRegularOutput"
},
{
"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 Bono 14').first().json.metadata.country || 'GT', p_year: Number($('Preparar Google Sheet Bono 14').first().json.metadata.year || 0), p_month: Number($('Preparar Google Sheet Bono 14').first().json.metadata.month || 0), p_period_type: $('Preparar Google Sheet Bono 14').first().json.metadata.period_type || '' } }}",
"options": {
"response": {
"response": {
"responseFormat": "json"
}
},
"timeout": 30000
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
7792,
3280
],
"id": "47e29f81-ee8d-429f-a461-0288c86bf5e2",
"name": "Buscar reporte existente Supabase"
},
{
"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": [
8512,
3280
],
"id": "26b3e1a2-ebfd-4cbd-a53d-5fd9244a4de1",
"name": "Leer Google Sheet actual",
"credentials": {
"googleOAuth2Api": {
"id": "dQ1MJSJSWcoWYcb8",
"name": "Google account - Isaac Producción"
}
}
},
{
"parameters": {
"jsCode": "const prepared =\n $('Preparar Google Sheet Bono 14').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": [
8752,
3280
],
"id": "09a08613-346b-4121-9025-08a84a4a9329",
"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": [
8992,
3280
],
"id": "c530c0ac-227b-4596-a261-540bc0799dce",
"name": "Actualizar estructura Google Sheet",
"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": [
9232,
3280
],
"id": "65330e06-3297-4213-9385-de6ad2b62026",
"name": "Limpiar Google Sheet",
"credentials": {
"googleOAuth2Api": {
"id": "dQ1MJSJSWcoWYcb8",
"name": "Google account - Isaac Producción"
}
}
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "Preparar entrada app",
"type": "main",
"index": 0
}
]
]
},
"Preparar entrada app": {
"main": [
[
{
"node": "Parsear CSV banco Bono 14 GT",
"type": "main",
"index": 0
},
{
"node": "Extract Bono14 - P&G",
"type": "main",
"index": 0
},
{
"node": "Extract Bono14 - Motorola",
"type": "main",
"index": 0
},
{
"node": "Extract Bono14 - Whirlpool",
"type": "main",
"index": 0
},
{
"node": "Extract Bono14 - PMI",
"type": "main",
"index": 0
},
{
"node": "Extract Bono14 - Nestle",
"type": "main",
"index": 0
},
{
"node": "Extract Bono14 - GLM Q",
"type": "main",
"index": 0
},
{
"node": "Extract Bono14 - GLM USD",
"type": "main",
"index": 0
},
{
"node": "Extract Bono14 - Resumen 1",
"type": "main",
"index": 0
},
{
"node": "Extract Bono14 - Resumen 2",
"type": "main",
"index": 0
},
{
"node": "Extract Bono14 - Resumen 3",
"type": "main",
"index": 0
},
{
"node": "Extract Bono14 - Resumen 4",
"type": "main",
"index": 0
}
]
]
},
"Merge": {
"main": [
[
{
"node": "Merge - Agregar BambooHR Bono14",
"type": "main",
"index": 0
},
{
"node": "HTTP - Empleados BambooHR Bono14 GT",
"type": "main",
"index": 0
}
]
]
},
"Extract Bono14 - P&G": {
"main": [
[
{
"node": "Merge Hojas 01-02",
"type": "main",
"index": 0
}
]
]
},
"Extract Bono14 - Motorola": {
"main": [
[
{
"node": "Merge Hojas 01-02",
"type": "main",
"index": 1
}
]
]
},
"Extract Bono14 - Whirlpool": {
"main": [
[
{
"node": "Merge Hojas 03",
"type": "main",
"index": 1
}
]
]
},
"Extract Bono14 - PMI": {
"main": [
[
{
"node": "Merge Hojas 04",
"type": "main",
"index": 1
}
]
]
},
"Extract Bono14 - Nestle": {
"main": [
[
{
"node": "Merge Hojas 05",
"type": "main",
"index": 1
}
]
]
},
"Extract Bono14 - GLM Q": {
"main": [
[
{
"node": "Merge Hojas 06",
"type": "main",
"index": 1
}
]
]
},
"Extract Bono14 - GLM USD": {
"main": [
[
{
"node": "Merge Hojas 07",
"type": "main",
"index": 1
}
]
]
},
"Extract Bono14 - Resumen 1": {
"main": [
[
{
"node": "Merge Hojas 08",
"type": "main",
"index": 1
}
]
]
},
"Extract Bono14 - Resumen 2": {
"main": [
[
{
"node": "Merge Hojas 09",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas 01-02": {
"main": [
[
{
"node": "Merge Hojas 03",
"type": "main",
"index": 0
}
]
]
},
"Merge Hojas 03": {
"main": [
[
{
"node": "Merge Hojas 04",
"type": "main",
"index": 0
}
]
]
},
"Merge Hojas 04": {
"main": [
[
{
"node": "Merge Hojas 05",
"type": "main",
"index": 0
}
]
]
},
"Merge Hojas 05": {
"main": [
[
{
"node": "Merge Hojas 06",
"type": "main",
"index": 0
}
]
]
},
"Merge Hojas 06": {
"main": [
[
{
"node": "Merge Hojas 07",
"type": "main",
"index": 0
}
]
]
},
"Merge Hojas 07": {
"main": [
[
{
"node": "Merge Hojas 08",
"type": "main",
"index": 0
}
]
]
},
"Merge Hojas 08": {
"main": [
[
{
"node": "Merge Hojas 09",
"type": "main",
"index": 0
}
]
]
},
"Merge Hojas 09": {
"main": [
[
{
"node": "Merge Hojas ",
"type": "main",
"index": 0
}
]
]
},
"Extract Bono14 - Resumen 3": {
"main": [
[
{
"node": "Merge Hojas ",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas ": {
"main": [
[
{
"node": "Merge Hojas 10",
"type": "main",
"index": 0
}
]
]
},
"Crear Google Sheet": {
"main": [
[
{
"node": "Leer Google Sheet actual",
"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 respuesta final": {
"main": [
[
{
"node": "Respond to Webhook",
"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
}
]
]
},
"Extract Bono14 - Resumen 4": {
"main": [
[
{
"node": "Merge Hojas 10",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas 10": {
"main": [
[
{
"node": "Normalizar Bono 14",
"type": "main",
"index": 0
}
]
]
},
"Preparar correo reporte generado": {
"main": [
[
{
"node": "Enviar correo reporte generado",
"type": "main",
"index": 0
}
]
]
},
"Buscar reporte existente Supabase": {
"main": [
[
{
"node": "Crear Google Sheet",
"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
}
]
]
},
"Parsear CSV banco Bono 14 GT": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 0
}
]
]
},
"Normalizar Bono 14": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 1
}
]
]
},
"Cruzar Bono 14 vs Banco": {
"main": [
[
{
"node": "Preparar Google Sheet Bono 14",
"type": "main",
"index": 0
}
]
]
},
"Preparar Google Sheet Bono 14": {
"main": [
[
{
"node": "Buscar reporte existente Supabase",
"type": "main",
"index": 0
}
]
]
},
"HTTP - Empleados BambooHR Bono14 GT": {
"main": [
[
{
"node": "Normalizar BambooHR Bono14 GT",
"type": "main",
"index": 0
}
]
]
},
"Normalizar BambooHR Bono14 GT": {
"main": [
[
{
"node": "Merge - Agregar BambooHR Bono14",
"type": "main",
"index": 1
}
]
]
},
"Merge - Agregar BambooHR Bono14": {
"main": [
[
{
"node": "Obtener alias BambooHR Bono14 Supabase",
"type": "main",
"index": 0
}
]
]
},
"Obtener alias BambooHR Bono14 Supabase": {
"main": [
[
{
"node": "Cruzar Bono 14 vs Banco",
"type": "main",
"index": 0
}
]
]
}
},
"authors": "Isaac Aracena",
"name": "Version fa138609",
"description": "",
"autosaved": true,
"workflowPublishHistory": [
{
"createdAt": "2026-08-14T12:12:02.974Z",
"id": 4688,
"workflowId": "ox03EQlSmgjUCQuK",
"versionId": "fa138609-9867-4b15-b685-759ea211b234",
"event": "activated",
"userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029"
}
]
}
}