2632 lines
453 KiB
JSON
2632 lines
453 KiB
JSON
{
|
||
"updatedAt": "2026-08-08T13:38:52.526Z",
|
||
"createdAt": "2026-07-13T18:02:24.389Z",
|
||
"id": "d3vXYr7ucbaiU5ct",
|
||
"name": "Portal de Verificación de Nómina - Guatemala",
|
||
"description": "Procesa los archivos cargados desde el Portal de Verificación de Nómina, ejecuta los cruces entre nómina, BambooHR y datos bancarios, y genera reportes de diferencias e históricos.",
|
||
"active": true,
|
||
"isArchived": false,
|
||
"nodes": [
|
||
{
|
||
"parameters": {
|
||
"httpMethod": "POST",
|
||
"path": "nominagt-bamboo-test",
|
||
"responseMode": "responseNode",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.webhook",
|
||
"typeVersion": 2.1,
|
||
"position": [
|
||
14768,
|
||
25968
|
||
],
|
||
"id": "bbb096bb-6f2c-48df-907c-06c9857943ac",
|
||
"name": "Webhook",
|
||
"webhookId": "4061b0e1-0d8e-4fb4-bea8-790c718447ee"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const item = $input.first();\n\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);\n\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 errors = [];\n\nif (!metadata.country || metadata.country !== 'GT') {\n errors.push('El país recibido no es Guatemala.');\n}\n\nif (!metadata.year) {\n errors.push('No se recibió el año del cruce.');\n}\n\nif (!metadata.month) {\n errors.push('No se recibió el mes del cruce.');\n}\n\nif (!metadata.period_type) {\n errors.push('No se recibió el tipo de quincena.');\n}\n\nif (!metadata.period_start || !metadata.period_end) {\n errors.push('No se recibió el período calculado.');\n}\n\nif (!payrollFile) {\n errors.push('No se recibió el archivo de nómina.');\n}\n\nif (bankFiles.length === 0) {\n errors.push('No se recibió ningún archivo CSV del banco.');\n}\n\nreturn [\n {\n json: {\n ok: errors.length === 0,\n stage: 'entrada_recibida',\n errors,\n metadata,\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": [
|
||
14976,
|
||
25968
|
||
],
|
||
"id": "756188b6-dc61-4e3a-965a-47885e847e69",
|
||
"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: 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 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": [
|
||
25056,
|
||
26528
|
||
],
|
||
"id": "cfa673b1-08ba-41c0-b06c-15189672e1f2",
|
||
"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": [
|
||
15648,
|
||
25152
|
||
],
|
||
"id": "65e7f1a9-ac7a-46b5-8f9d-6729c5c80a31",
|
||
"name": "Parsear CSV banco GT"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "combine",
|
||
"combineBy": "combineByPosition",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.merge",
|
||
"typeVersion": 3.2,
|
||
"position": [
|
||
21264,
|
||
26512
|
||
],
|
||
"id": "c47dde0f-72aa-49b6-b58b-f7051df5df62",
|
||
"name": "Merge"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const data = $input.first().json || {};\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\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 match = 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 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 totalNominaBase = roundMoney(\n data.payroll?.total_amount || 0\n);\n\nconst totalNomina = roundMoney(\n totalNominaBase + appliedSupplementsTotal\n);\n\nconst totalBanco = roundMoney(\n data.bank?.total_amount || 0\n);\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 totalNomina,\n totalBanco,\n diferenciaTotal:\n moneyDiff(totalNomina, totalBanco),\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 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": [
|
||
21696,
|
||
26512
|
||
],
|
||
"id": "a9aeea33-6818-40c1-9eb9-b6a3521a4f66",
|
||
"name": "Cruzar Nómina vs Banco"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "xlsx",
|
||
"binaryPropertyName": "payroll_file",
|
||
"options": {
|
||
"headerRow": true,
|
||
"sheetName": "1) Nomina General"
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.extractFromFile",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
15648,
|
||
25344
|
||
],
|
||
"id": "3e169c34-36e6-40d2-9751-4d3efdcf8c08",
|
||
"name": "Extract - Nomina General",
|
||
"retryOnFail": false
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "xlsx",
|
||
"binaryPropertyName": "payroll_file",
|
||
"options": {
|
||
"headerRow": true,
|
||
"sheetName": "2) Temporales"
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.extractFromFile",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
15648,
|
||
25584
|
||
],
|
||
"id": "c30318b3-98e8-44bf-8c0b-315f8a54f1e8",
|
||
"name": "Extract - Temporales",
|
||
"retryOnFail": false,
|
||
"onError": "continueRegularOutput"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "xlsx",
|
||
"binaryPropertyName": "payroll_file",
|
||
"options": {
|
||
"headerRow": true,
|
||
"sheetName": "3) Auditorias"
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.extractFromFile",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
15648,
|
||
25824
|
||
],
|
||
"id": "b7b0d02e-7e91-428e-aecd-3a8a08e77175",
|
||
"name": "Extract - Auditorias",
|
||
"retryOnFail": false,
|
||
"onError": "continueRegularOutput"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "xlsx",
|
||
"binaryPropertyName": "payroll_file",
|
||
"options": {
|
||
"headerRow": true,
|
||
"sheetName": "4) Bono Mariana"
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.extractFromFile",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
15664,
|
||
26016
|
||
],
|
||
"id": "cdfb15a2-d9d0-4544-80e1-e798e42e796f",
|
||
"name": "Extract - Bono Mariana",
|
||
"retryOnFail": false,
|
||
"onError": "continueRegularOutput"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "xlsx",
|
||
"binaryPropertyName": "payroll_file",
|
||
"options": {
|
||
"headerRow": true,
|
||
"sheetName": "5) Movilidad WP"
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.extractFromFile",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
15664,
|
||
26224
|
||
],
|
||
"id": "12b2497a-642d-4f4a-b32c-d4faa535f565",
|
||
"name": "Extract - Movilidad WP",
|
||
"retryOnFail": false,
|
||
"onError": "continueRegularOutput"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "xlsx",
|
||
"binaryPropertyName": "payroll_file",
|
||
"options": {
|
||
"headerRow": true,
|
||
"sheetName": "6)Viaticos PMI"
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.extractFromFile",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
15664,
|
||
26432
|
||
],
|
||
"id": "d33f7284-0947-44ad-8a03-47a0b8bb4122",
|
||
"name": "Extract - Viaticos PMI",
|
||
"retryOnFail": false,
|
||
"onError": "continueRegularOutput"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "xlsx",
|
||
"binaryPropertyName": "payroll_file",
|
||
"options": {
|
||
"headerRow": true,
|
||
"sheetName": "7) Combustible Purina"
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.extractFromFile",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
15680,
|
||
26768
|
||
],
|
||
"id": "5b82536d-1e30-4fdf-9812-b2f93e8a19c9",
|
||
"name": "Extract - Combustible Purina",
|
||
"retryOnFail": false,
|
||
"onError": "continueRegularOutput"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "xlsx",
|
||
"binaryPropertyName": "payroll_file",
|
||
"options": {
|
||
"headerRow": true,
|
||
"sheetName": "8) Combustible P&G"
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.extractFromFile",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
15664,
|
||
27104
|
||
],
|
||
"id": "fc8edacd-6084-42b1-8aa1-cbd18e96bf1b",
|
||
"name": "Extract - Combustible PG",
|
||
"retryOnFail": false,
|
||
"onError": "continueRegularOutput"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "xlsx",
|
||
"binaryPropertyName": "payroll_file",
|
||
"options": {
|
||
"headerRow": true,
|
||
"sheetName": "9) Combustibles Liquidables"
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.extractFromFile",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
15664,
|
||
27392
|
||
],
|
||
"id": "ee4ee831-169e-4d33-88be-4d0d704ec095",
|
||
"name": "Extract - Combustibles Liquidables",
|
||
"retryOnFail": false,
|
||
"onError": "continueRegularOutput"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "function 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 if (value === null || value === undefined || value === '') return '';\n\n if (typeof value === 'number') {\n return String(Math.trunc(value)).trim();\n }\n\n return String(value)\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(/\\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 getValue(row, possibleKeys) {\n for (const key of possibleKeys) {\n if (row[key] !== undefined && row[key] !== null && row[key] !== '') {\n return row[key];\n }\n }\n\n const rowKeys = Object.keys(row || {});\n\n for (const wanted of possibleKeys) {\n const wantedNormalized = normalizeForCompare(wanted);\n const found = rowKeys.find((key) => normalizeForCompare(key) === wantedNormalized);\n\n if (found && row[found] !== undefined && row[found] !== null && row[found] !== '') {\n return row[found];\n }\n }\n\n return '';\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 (text.includes('spreadsheet does not contain sheet')) return false;\n if (text.includes('no sheet')) return false;\n if (row.error) return false;\n return true;\n });\n } catch (error) {\n return [];\n }\n}\n\nfunction isProbablyInvalidEmployeeName(value) {\n const name = normalizeText(value);\n const lower = normalizeForCompare(name);\n\n if (!name) return true;\n if (/^[\\d.,\\s]+$/.test(name)) return true;\n\n const invalidExact = new Set([\n 'nombre', 'nombre completo', 'empleado', 'colaborador', 'cuenta',\n 'cuenta bancaria', 'total', 'subtotal', 'gran total', 'total general',\n 'guatemala', 'coordinador', 'supervisor', 'kam', 'pais', 'país',\n 'proyecto', 'cliente', 'marca', 'canal', 'concepto', 'descripcion',\n 'descripción', 'ejecutado en tarjeta', 'ejecutado en efectivo',\n 'ajustes segun comentarios', 'ajuste segun comentarios', 'comentarios',\n 'disponible', 'ejecutado', 'pendiente'\n ]);\n\n if (invalidExact.has(lower)) return true;\n\n const invalidContains = [\n 'total ', 'total:', 'subtotal', 'resumen', 'observacion', 'observación',\n 'monto', 'cuenta', 'banco', 'nomina', 'nómina', 'bonificacion',\n 'bonificación', 'departamento', 'puesto', 'posicion', 'posición',\n 'spoc', 'gema hsm', 'gema hfs', 'hsm-dpp', 'lider de ejecucion',\n 'líder de ejecución', 'coordinador nacional', 'ejecutado en tarjeta',\n 'ejecutado en efectivo', 'ajustes segun comentarios',\n 'ajuste segun comentarios', 'segun comentarios', 'comentarios',\n 'presupuesto combustible', 'credito 30 dias', 'crédito 30 días',\n 'no se deposita', 'ejecucion mensual', 'ejecución mensual'\n ];\n\n if (invalidContains.some((token) => lower.includes(normalizeForCompare(token)))) {\n return true;\n }\n\n const words = name.split(/\\s+/).filter(Boolean);\n return words.length < 2;\n}\n\nfunction isValidCrossableAccount(account) {\n const normalized = normalizeAccount(account);\n return normalized.length >= 7 && !/^0+$/.test(normalized);\n}\n\nfunction buildRow({ sourceSheet, rowNumber, employeeName, account, email, amount, currency = 'QTZ', extra = {} }) {\n return {\n source_sheet: sourceSheet,\n row_number: rowNumber,\n employee_name: normalizeText(employeeName),\n employee_number: null,\n account: normalizeAccount(account),\n email: normalizeText(email).toLowerCase(),\n payroll_amount: roundMoney(amount),\n currency,\n ...extra,\n };\n}\n\nconst payrollRows = [];\nconst noAccountRows = [];\nconst supplementRows = [];\nconst ignoredRows = [];\nconst sheetSummaries = [];\n\nfunction addNormalizedRow(row, options = {}) {\n const amount = roundMoney(row.payroll_amount);\n\n if (amount <= 0) {\n ignoredRows.push({ ...row, reason: 'amount_zero_or_invalid' });\n return 'ignored';\n }\n\n if (amount > 150000) {\n ignoredRows.push({ ...row, reason: 'suspicious_large_amount' });\n return 'ignored';\n }\n\n if (isProbablyInvalidEmployeeName(row.employee_name)) {\n ignoredRows.push({ ...row, reason: 'invalid_employee_name' });\n return 'ignored';\n }\n\n if (options.supplementWithoutAccount) {\n supplementRows.push({ ...row, account: '' });\n return 'supplement';\n }\n\n if (!isValidCrossableAccount(row.account)) {\n noAccountRows.push({\n ...row,\n account: '',\n status: 'Pendiente revisión',\n observation: 'Tiene monto en nómina, pero no tiene cuenta bancaria válida para cruzar contra banco.',\n });\n return 'no_account';\n }\n\n payrollRows.push(row);\n return 'valid';\n}\n\nfunction addSheetSummary(sourceSheet, rawRows, validRows, noAccountCount, ignoredCount, supplementCount, totalAmount) {\n sheetSummaries.push({\n source_sheet: sourceSheet,\n raw_rows_count: rawRows,\n valid_rows_count: validRows,\n no_account_rows_count: noAccountCount,\n supplement_rows_count: supplementCount,\n ignored_rows_count: ignoredCount,\n total_amount: roundMoney(totalAmount),\n });\n}\n\nfunction processStandardSheet(config) {\n const rows = getNodeRows(config.nodeName);\n let validRows = 0;\n let noAccountCount = 0;\n let ignoredCount = 0;\n let sheetTotal = 0;\n\n rows.forEach((row, index) => {\n const employeeName = normalizeText(getValue(row, config.nameKeys));\n const account = normalizeAccount(getValue(row, config.accountKeys));\n const email = normalizeText(getValue(row, config.emailKeys || [])).toLowerCase();\n const amount = roundMoney(parseMoney(getValue(row, config.amountKeys)));\n const currencyValue = normalizeText(getValue(row, config.currencyKeys || []));\n const currency = normalizeForCompare(currencyValue).includes('dolar') || currencyValue.toUpperCase().includes('USD')\n ? 'USD'\n : 'QTZ';\n\n const normalizedRow = buildRow({\n sourceSheet: config.sourceSheet,\n rowNumber: index + 2,\n employeeName,\n account,\n email,\n amount,\n currency,\n });\n\n const result = addNormalizedRow(normalizedRow);\n if (result === 'valid') validRows += 1;\n else if (result === 'no_account') noAccountCount += 1;\n else ignoredCount += 1;\n\n if (result === 'valid' || result === 'no_account') {\n sheetTotal = roundMoney(sheetTotal + amount);\n }\n });\n\n addSheetSummary(config.sourceSheet, rows.length, validRows, noAccountCount, ignoredCount, 0, sheetTotal);\n}\n\nfunction valuesFromRow(row) {\n // Los Extract configurados con Header Row desactivado devuelven cada fila\n // dentro de una propiedad `row` como arreglo posicional.\n // Si usamos Object.values(row), obtenemos un arreglo anidado y el\n // normalizador no puede detectar cuenta, nombre, ID ni monto.\n const rawValues = Array.isArray(row?.row)\n ? row.row\n : Object.values(row || {});\n\n return rawValues.filter((value) => {\n return value !== null &&\n value !== undefined &&\n normalizeText(value) !== '';\n });\n}\n\nfunction looksLikeAccount(value) {\n const account = normalizeAccount(value);\n return account.length >= 6 && account.length <= 14;\n}\n\nfunction looksLikeMoney(value) {\n const amount = roundMoney(parseMoney(value));\n return amount > 0 && amount <= 150000;\n}\n\nfunction looksLikeName(value) {\n const text = normalizeText(value);\n if (!text || /\\d/.test(text)) return false;\n return !isProbablyInvalidEmployeeName(text);\n}\n\nfunction findNameAfter(values, startIndex) {\n for (let i = Math.max(0, startIndex); i < values.length; i++) {\n if (looksLikeName(values[i])) return { value: values[i], index: i };\n }\n return { value: '', index: -1 };\n}\n\nfunction processPositionalSheet(config) {\n const rows = getNodeRows(config.nodeName);\n let validRows = 0;\n let noAccountCount = 0;\n let ignoredCount = 0;\n let supplementCount = 0;\n let sheetTotal = 0;\n\n rows.forEach((row, index) => {\n const values = valuesFromRow(row);\n const rowText = normalizeForCompare(values.join(' '));\n\n if (config.excludeIfContains?.some((token) => rowText.includes(normalizeForCompare(token)))) {\n ignoredCount += 1;\n return;\n }\n\n let employeeName = '';\n let account = '';\n let email = '';\n let amount = 0;\n let supplementWithoutAccount = false;\n let extra = {};\n\n if (config.sourceSheet === 'Temporales WMC') {\n const accountIndex = values.findIndex(looksLikeAccount);\n account = accountIndex >= 0 ? normalizeAccount(values[accountIndex]) : '';\n const foundName = findNameAfter(values, accountIndex + 1);\n employeeName = foundName.value;\n\n const moneyCandidates = values\n .slice(foundName.index + 1)\n .map((value) => roundMoney(parseMoney(value)))\n .filter((candidate) => candidate > 0 && candidate <= 150000);\n\n amount = moneyCandidates.length ? moneyCandidates[moneyCandidates.length - 1] : 0;\n } else if (config.sourceSheet === '6)Viaticos PMI') {\n const accountIndex = values.findIndex(looksLikeAccount);\n account = accountIndex >= 0 ? normalizeAccount(values[accountIndex]) : '';\n employeeName = findNameAfter(values, accountIndex + 1).value;\n const moneyCandidates = values\n .slice(accountIndex + 1)\n .map((value) => roundMoney(parseMoney(value)))\n .filter((candidate) => candidate > 0 && candidate <= 150000);\n amount = moneyCandidates.length ? moneyCandidates[moneyCandidates.length - 1] : 0;\n } else if (config.sourceSheet === '7) Combustible Purina') {\n const accountIndex = values.findIndex(looksLikeAccount);\n account = accountIndex >= 0 ? normalizeAccount(values[accountIndex]) : '';\n\n for (let i = accountIndex - 1; i >= 0; i--) {\n if (looksLikeName(values[i])) {\n employeeName = normalizeText(values[i]);\n break;\n }\n }\n\n for (let i = accountIndex - 1; i >= 0; i--) {\n const candidate = roundMoney(parseMoney(values[i]));\n if (candidate > 0 && candidate <= 150000) {\n amount = candidate;\n break;\n }\n }\n } else if (config.sourceSheet === '8) Combustible P&G') {\n const accountIndex = values.findIndex(looksLikeAccount);\n account = accountIndex >= 0 ? normalizeAccount(values[accountIndex]) : '';\n const foundName = findNameAfter(values, accountIndex + 1);\n employeeName = foundName.value;\n\n for (let i = foundName.index + 1; i < values.length; i++) {\n const candidate = roundMoney(parseMoney(values[i]));\n if (candidate > 0 && candidate <= 150000) {\n amount = candidate;\n break;\n }\n }\n } else if (config.sourceSheet === '9) Combustibles Liquidables') {\n account = normalizeAccount(values[0]);\n employeeName = normalizeText(values[2]);\n email = normalizeText(values[6]).toLowerCase();\n const moneyCandidates = values\n .map((value) => roundMoney(parseMoney(value)))\n .filter((candidate) => candidate > 0 && candidate <= 150000);\n amount = moneyCandidates.length ? moneyCandidates[moneyCandidates.length - 1] : 0;\n } else if (\n config.sourceSheet === 'Combustibles PMI' ||\n config.sourceSheet === 'Combustible Tarjeta Motorola'\n ) {\n const idIndex = values.findIndex((value) => {\n const normalized = normalizeForCompare(value);\n return /^(spoc|xpert|moto)/.test(normalized);\n });\n\n // Opción B:\n // Solo procesar filas de la tabla principal que tengan un ID operativo\n // (SPOC / XPERT / MOTO). Esto excluye encabezados, totales y la lista\n // auxiliar/duplicada que aparece debajo de la tabla principal.\n if (idIndex < 0) {\n ignoredCount += 1;\n return;\n }\n\n const foundName = findNameAfter(values, idIndex + 1);\n employeeName = foundName.value;\n\n // Tomar únicamente el primer monto positivo después del nombre:\n // corresponde a la columna Asignado de la tabla principal.\n for (let i = foundName.index + 1; i < values.length; i++) {\n const candidate = roundMoney(parseMoney(values[i]));\n if (candidate > 0 && candidate <= 150000) {\n amount = candidate;\n break;\n }\n }\n\n supplementWithoutAccount = true;\n extra = {\n supplement_id: idIndex >= 0 ? normalizeText(values[idIndex]) : '',\n supplement_original_name: employeeName,\n };\n }\n\n const normalizedRow = buildRow({\n sourceSheet: config.sourceSheet,\n rowNumber: index + 1,\n employeeName,\n account,\n email,\n amount,\n extra,\n });\n\n const result = addNormalizedRow(normalizedRow, { supplementWithoutAccount });\n if (result === 'valid') validRows += 1;\n else if (result === 'no_account') noAccountCount += 1;\n else if (result === 'supplement') supplementCount += 1;\n else ignoredCount += 1;\n\n if (result !== 'ignored') sheetTotal = roundMoney(sheetTotal + amount);\n });\n\n addSheetSummary(\n config.sourceSheet,\n rows.length,\n validRows,\n noAccountCount,\n ignoredCount,\n supplementCount,\n sheetTotal\n );\n}\n\nconst standardSheetConfigs = [\n {\n nodeName: 'Extract - Nomina General',\n sourceSheet: '1) Nomina General',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE COMPLETO', 'Nombre Completo', 'NOMBRE', 'Nombre'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['NETO A PAGAR', 'Neto a Pagar', 'NETO', 'Neto'],\n currencyKeys: ['Moneda', 'MONEDA'],\n },\n {\n nodeName: 'Extract - Temporales',\n sourceSheet: '2) Temporales',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE', 'Nombre', 'NOMBRE COMPLETO', 'Nombre Completo'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['NETO A PAGAR', 'Neto a Pagar', 'NETO', 'Neto'],\n },\n {\n nodeName: 'Extract - Auditorias',\n sourceSheet: '3) Auditorias',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE COMPLETO', 'Nombre Completo', 'NOMBRE', 'Nombre'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['NETO A PAGAR', 'Neto a Pagar', 'NETO', 'Neto'],\n currencyKeys: ['Moneda', 'MONEDA'],\n },\n {\n nodeName: 'Extract - Bono Mariana',\n sourceSheet: '4) Bono Mariana',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE COMPLETO', 'Nombre Completo', 'NOMBRE', 'Nombre'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['NETO A PAGAR', 'Neto a Pagar', 'NETO', 'Neto', 'MONTO', 'Monto'],\n },\n {\n nodeName: 'Extract - Movilidad WP',\n sourceSheet: '5) Movilidad WP',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE', 'Nombre', 'NOMBRE COMPLETO', 'Nombre Completo'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['NETO A PAGAR', 'Neto a Pagar', 'NETO', 'Neto'],\n },\n {\n nodeName: 'Extract - Mot Variable Abril',\n sourceSheet: 'Mot Variable Abril',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE COMPLETO', 'Nombre Completo', 'NOMBRE', 'Nombre', 'Empleado', 'EMPLEADO'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['MONTO A PAGAR', 'Monto a pagar', 'NETO A PAGAR', 'Neto a Pagar', 'MONTO NETO', 'Monto Neto', 'VALOR A PAGAR', 'Valor a pagar'],\n },\n];\n\nconst positionalSheetConfigs = [\n { nodeName: 'Extract - Temporales WMC', sourceSheet: 'Temporales WMC' },\n { nodeName: 'Extract - Viaticos PMI', sourceSheet: '6)Viaticos PMI' },\n { nodeName: 'Extract - Combustible Purina', sourceSheet: '7) Combustible Purina' },\n { nodeName: 'Extract - Combustible PG', sourceSheet: '8) Combustible P&G' },\n { nodeName: 'Extract - Combustibles Liquidables', sourceSheet: '9) Combustibles Liquidables' },\n];\n\nfor (const config of standardSheetConfigs) processStandardSheet(config);\nfor (const config of positionalSheetConfigs) processPositionalSheet(config);\n\nfunction nameWords(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 for (let j = 1; j <= b.length; j++) {\n const cost = a[i - 1] === b[j - 1] ? 0 : 1;\n current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, previous[j - 1] + cost);\n }\n 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 nameMatchScore(a, b) {\n const wordsA = nameWords(a);\n const wordsB = nameWords(b);\n if (!wordsA.length || !wordsB.length) return 0;\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 ratio = matches / Math.min(wordsA.length, wordsB.length);\n const firstTokenBonus = tokenMatches(wordsA[0], wordsB[0]) ? 0.15 : 0;\n return ratio + firstTokenBonus;\n}\n\nconst accountNameMap = new Map();\nfor (const row of payrollRows) {\n const current = accountNameMap.get(row.account) || {\n account: row.account,\n employee_name: row.employee_name,\n currency: row.currency,\n };\n accountNameMap.set(row.account, current);\n}\n\nconst canonicalPayrollPeople = Array.from(accountNameMap.values());\nconst attachedSupplements = [];\nconst unattachedSupplements = [];\n\nfor (const supplement of supplementRows) {\n const scored = canonicalPayrollPeople\n .map((candidate) => ({\n ...candidate,\n score: nameMatchScore(supplement.employee_name, candidate.employee_name),\n }))\n .sort((a, b) => b.score - a.score);\n\n const top = scored[0];\n const second = scored[1];\n const supplementTokens = nameWords(supplement.employee_name);\n const firstToken = supplementTokens[0] || '';\n const sameFirstTokenCandidates = canonicalPayrollPeople.filter((candidate) => {\n const candidateFirst = nameWords(candidate.employee_name)[0] || '';\n return firstToken && candidateFirst === firstToken;\n });\n\n const confidentByScore = top && top.score >= 0.75 && (!second || top.score - second.score >= 0.05 || top.score >= 1);\n const confidentShortUniqueFirstName = top && supplementTokens.length <= 2 && top.score >= 0.6 && sameFirstTokenCandidates.length === 1;\n\n if (confidentByScore || confidentShortUniqueFirstName) {\n const attached = {\n ...supplement,\n account: top.account,\n employee_name: top.employee_name,\n currency: top.currency || supplement.currency || 'QTZ',\n supplement_original_name: supplement.employee_name,\n supplement_match_score: roundMoney(top.score),\n attached_by_name: true,\n };\n\n // Opción B condicional:\n // El suplemento queda vinculado al empleado y a su cuenta, pero NO se\n // suma todavía a la nómina base. El nodo de cruce decidirá si debe\n // aplicarse según el monto realmente pagado por el banco.\n attachedSupplements.push(attached);\n } else {\n unattachedSupplements.push({\n ...supplement,\n best_candidate: top?.employee_name || '',\n best_score: roundMoney(top?.score || 0),\n });\n\n ignoredRows.push({\n ...supplement,\n reason: 'supplement_without_unique_payroll_match',\n best_candidate: top?.employee_name || '',\n best_score: roundMoney(top?.score || 0),\n });\n }\n}\n\nconst groupedMap = new Map();\n\nfor (const row of payrollRows) {\n const groupKey = `${row.account}:${row.currency || 'QTZ'}`;\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 || 'QTZ',\n payroll_amount: 0,\n rows_count: 0,\n source_sheets: new Set(),\n source_rows: [],\n };\n\n current.payroll_amount = roundMoney(current.payroll_amount + row.payroll_amount);\n current.rows_count += 1;\n if (!current.email && row.email) current.email = row.email;\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 amount: row.payroll_amount,\n supplement_original_name: row.supplement_original_name || '',\n attached_by_name: Boolean(row.attached_by_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 const currency = row.currency || 'QTZ';\n totalsByCurrency[currency] = roundMoney((totalsByCurrency[currency] || 0) + row.payroll_amount);\n}\n\nconst totalPayroll = roundMoney(\n payrollRows.reduce((sum, row) => sum + row.payroll_amount, 0) +\n noAccountRows.reduce((sum, row) => sum + row.payroll_amount, 0)\n);\n\nreturn [\n {\n json: {\n payroll: {\n source: 'template_guatemala_completo',\n sheets_count: standardSheetConfigs.length + positionalSheetConfigs.length,\n sheet_summaries: sheetSummaries,\n raw_rows_count: sheetSummaries.reduce((sum, sheet) => sum + sheet.raw_rows_count, 0),\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 // Los suplementos potenciales no forman parte del total base\n // hasta que el banco confirme que hubo un pago adicional.\n attached_supplements_count: 0,\n potential_supplements_count: attachedSupplements.length,\n potential_supplements: attachedSupplements,\n unattached_supplements_count: unattachedSupplements.length,\n total_amount: totalPayroll,\n totals_by_currency: totalsByCurrency,\n rows: payrollRows,\n no_account_rows: noAccountRows,\n grouped_by_account: groupedByAccount,\n },\n debug_payroll: {\n // Se conserva attached_supplements por compatibilidad con las\n // revisiones anteriores, pero ahora representa suplementos\n // potenciales vinculados, todavía no aplicados.\n attached_supplements: attachedSupplements,\n potential_supplements: attachedSupplements,\n unattached_supplements: unattachedSupplements,\n ignored_rows_preview: ignoredRows.slice(0, 100),\n no_account_rows_preview: noAccountRows.slice(0, 50),\n },\n },\n },\n];\n"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
19872,
|
||
27120
|
||
],
|
||
"id": "44044451-fb64-44a9-8cd0-109b64855806",
|
||
"name": "Normalizar Nómina Completa"
|
||
},
|
||
{
|
||
"parameters": {},
|
||
"type": "n8n-nodes-base.merge",
|
||
"typeVersion": 3.2,
|
||
"position": [
|
||
16672,
|
||
25648
|
||
],
|
||
"id": "5e8d1212-8780-40c0-ac54-eb65141bd71f",
|
||
"name": "Merge Hojas 01-02"
|
||
},
|
||
{
|
||
"parameters": {},
|
||
"type": "n8n-nodes-base.merge",
|
||
"typeVersion": 3.2,
|
||
"position": [
|
||
16864,
|
||
25856
|
||
],
|
||
"id": "fabe0277-9275-4bb9-a4e4-606fc7e7a6ad",
|
||
"name": "Merge Hojas 03"
|
||
},
|
||
{
|
||
"parameters": {},
|
||
"type": "n8n-nodes-base.merge",
|
||
"typeVersion": 3.2,
|
||
"position": [
|
||
17152,
|
||
25984
|
||
],
|
||
"id": "9730a653-c2b3-49bf-890f-56f2a6ba2567",
|
||
"name": "Merge Hojas 04"
|
||
},
|
||
{
|
||
"parameters": {},
|
||
"type": "n8n-nodes-base.merge",
|
||
"typeVersion": 3.2,
|
||
"position": [
|
||
17568,
|
||
26112
|
||
],
|
||
"id": "e1672eb2-31e0-4040-9971-c3eb99972e3f",
|
||
"name": "Merge Hojas 05"
|
||
},
|
||
{
|
||
"parameters": {},
|
||
"type": "n8n-nodes-base.merge",
|
||
"typeVersion": 3.2,
|
||
"position": [
|
||
17872,
|
||
26256
|
||
],
|
||
"id": "2906d58d-35b0-4112-a709-8b3097fd2537",
|
||
"name": "Merge Hojas 06"
|
||
},
|
||
{
|
||
"parameters": {},
|
||
"type": "n8n-nodes-base.merge",
|
||
"typeVersion": 3.2,
|
||
"position": [
|
||
18208,
|
||
26528
|
||
],
|
||
"id": "2b809028-6720-49c0-9a9c-f14cffa78c14",
|
||
"name": "Merge Hojas 07"
|
||
},
|
||
{
|
||
"parameters": {},
|
||
"type": "n8n-nodes-base.merge",
|
||
"typeVersion": 3.2,
|
||
"position": [
|
||
18464,
|
||
26656
|
||
],
|
||
"id": "652101ad-34b6-4893-8e83-683ad7e78fd4",
|
||
"name": "Merge Hojas 08"
|
||
},
|
||
{
|
||
"parameters": {},
|
||
"type": "n8n-nodes-base.merge",
|
||
"typeVersion": 3.2,
|
||
"position": [
|
||
18768,
|
||
27040
|
||
],
|
||
"id": "3af433bb-32fe-4c06-bea7-d00e9fea9c95",
|
||
"name": "Merge Hojas 09"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "xlsx",
|
||
"binaryPropertyName": "payroll_file",
|
||
"options": {
|
||
"headerRow": true,
|
||
"sheetName": "Mot Variable Abril"
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.extractFromFile",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
15664,
|
||
27648
|
||
],
|
||
"id": "f8d6e3d6-7448-4a54-99e6-eec7f4fdec7b",
|
||
"name": "Extract - Mot Variable Abril",
|
||
"retryOnFail": false,
|
||
"onError": "continueRegularOutput"
|
||
},
|
||
{
|
||
"parameters": {},
|
||
"type": "n8n-nodes-base.merge",
|
||
"typeVersion": 3.2,
|
||
"position": [
|
||
19072,
|
||
27152
|
||
],
|
||
"id": "4672d0c6-3116-42b5-b1f3-d7807b5313e1",
|
||
"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 readableCategory(value) {\n const map = {\n coincidencia: 'Coincidencia',\n discrepancia: 'Discrepancia',\n posible_cuenta_mal_digitada: 'Posible cuenta mal digitada',\n banco_sin_nomina: 'Banco sin nómina',\n banco_sin_bamboo: 'Banco sin Bamboo',\n nomina_sin_cuenta: 'Nómina sin cuenta',\n diferencia_nombre_banco: 'Diferencia nombre banco',\n };\n\n return map[value] || normalizeText(value).replace(/_/g, ' ');\n}\n\nfunction firstValue(value) {\n if (Array.isArray(value)) {\n return value.filter(Boolean).join(' / ');\n }\n\n return normalizeText(value);\n}\n\nfunction formatPeriodEnd(value) {\n const raw = normalizeText(value);\n\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(raw)) {\n return raw;\n }\n\n const [year, month, day] = raw.split('-');\n const monthNames = {\n '01': 'ene',\n '02': 'feb',\n '03': 'mar',\n '04': 'abr',\n '05': 'may',\n '06': 'jun',\n '07': 'jul',\n '08': 'ago',\n '09': 'sep',\n '10': 'oct',\n '11': 'nov',\n '12': 'dic',\n };\n\n return `${day}-${monthNames[month] || month}-${year}`;\n}\n\nfunction mainReportSense(row, difference) {\n const category = normalizeText(row.category).toLowerCase();\n const subcategory = normalizeText(row.subcategory).toLowerCase();\n\n if (category === 'posible_cuenta_mal_digitada') {\n return 'Revisar cuenta';\n }\n\n if (\n subcategory === 'nomina_con_cuenta_sin_pago_banco' ||\n (Number(row.bank_amount ?? row.bankAmount ?? 0) === 0 &&\n Number(row.payroll_amount ?? row.payrollAmount ?? 0) > 0)\n ) {\n return 'No aparece pagado en banco';\n }\n\n if (difference > 0) {\n return 'Se pagó de menos';\n }\n\n if (difference < 0) {\n return 'Se pagó de más';\n }\n\n return 'Revisar';\n}\n\nconst metadata = data.metadata || {};\nconst summary = data.summary || {};\nconst rows = Array.isArray(data.rows) ? data.rows : [];\nconst bankWithoutBamboo = Array.isArray(data.bankWithoutBamboo)\n ? data.bankWithoutBamboo\n : [];\n\nconst periodLabel =\n metadata.period_label ||\n `${metadata.year || ''}-${metadata.month || ''}-${metadata.period_type || ''}`;\n\nconst spreadsheetTitle =\n `Cruce de Cuentas GLM GT - ${periodLabel}`;\n\nconst mainReportSubtitle =\n `Diferencias de Monto Nómina vs. Banco · Guatemala · ${\n formatPeriodEnd(metadata.period_end || '')\n }`;\n\nconst bancoSinBambooSubtitle =\n `Pagos en banco sin empleado identificado en BambooHR · Guatemala · ${\n formatPeriodEnd(metadata.period_end || '')\n }`;\n\nconst cuentaMalDigitadaSubtitle =\n `Cuenta Mal Digitada en Nómina · Guatemala · ${\n formatPeriodEnd(metadata.period_end || '')\n }`;\n\nconst cuentaMalDigitadaCases = rows.filter(\n (row) => row.category === 'posible_cuenta_mal_digitada'\n);\n\nconst hasCuentaMalDigitada =\n cuentaMalDigitadaCases.length > 0;\n\nconst sheetIds = {\n nominaVsBanco: 101,\n bancoSinNomina: 102,\n bancoSinBamboo: 103,\n diferenciasNombreBanco: 104,\n cuentaMalDigitada: 105,\n resumen: 106,\n};\n\nconst sheetTitles = {\n nominaVsBanco: '01 Nómina vs Banco',\n bancoSinNomina: '02 Banco sin Nómina',\n bancoSinBamboo: '03 Banco sin Bamboo',\n diferenciasNombreBanco: '04 Diferencias nombre banco',\n cuentaMalDigitada: '05 Cuenta Mal Digitada',\n resumen: hasCuentaMalDigitada\n ? '06 Resumen'\n : '05 Resumen',\n};\n\nconst nominaVsBancoHeader = [\n '#',\n 'Empleado',\n 'Cuenta',\n 'Monto en Nómina (Q)',\n 'Monto en Banco (Q)',\n 'Diferencia (Q)',\n 'Sentido',\n 'Estado',\n 'Resolución',\n];\n\nconst mainReportRows = rows\n .filter((row) => row.category === 'discrepancia')\n .map((row, index) => {\n const payrollAmount = roundMoney(\n row.payroll_amount ?? row.payrollAmount ?? 0\n );\n const bankAmount = roundMoney(\n row.bank_amount ?? row.bankAmount ?? 0\n );\n const difference = roundMoney(\n row.difference ?? (payrollAmount - bankAmount)\n );\n\n return [\n index + 1,\n normalizeText(row.employee_name || row.employee || ''),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.payroll_account ||\n row.payrollAccount ||\n row.account ||\n ''\n ),\n payrollAmount,\n bankAmount,\n difference,\n mainReportSense(row, difference),\n normalizeText(row.status || 'Riesgo').toUpperCase(),\n '',\n ];\n });\n\nconst nominaVsBancoValues = [\n [\n 'GOMEZLEE MARKETING',\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n ],\n [\n mainReportSubtitle,\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n ],\n ['', '', '', '', '', '', '', '', ''],\n nominaVsBancoHeader,\n ...mainReportRows,\n];\n\nconst bancoSinNominaHeader = [\n 'Empleado Banco',\n 'Cuenta Banco',\n 'Moneda',\n 'Monto Banco',\n 'Estado',\n 'Observación',\n 'Resolución',\n];\n\nconst bancoSinNominaRows = rows\n .filter((row) => row.category === 'banco_sin_nomina')\n .map((row) => [\n normalizeText(row.employee_name || row.employee || ''),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n normalizeText(row.currency || 'QTZ'),\n roundMoney(row.bank_amount ?? row.bankAmount ?? 0),\n normalizeText(row.status || ''),\n normalizeText(row.observation || ''),\n '',\n ]);\n\nconst bancoSinBambooHeader = [\n '#',\n 'Nombre en banco',\n 'Nombre del cuentahabiente',\n 'Cuenta',\n 'Monto en banco (Q)',\n 'Número de envío',\n 'Estado',\n 'Resolución',\n];\n\nconst bancoSinBambooRows = bankWithoutBamboo.map((row, index) => [\n index + 1,\n normalizeText(row.bank_name_file || row.employee_name || ''),\n normalizeText(row.bank_account_holder || ''),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n roundMoney(row.bank_amount ?? row.bankAmount ?? 0),\n firstValue(row.shipment_numbers || row.shipment_number || ''),\n 'PENDIENTE REVISIÓN',\n '',\n]);\n\nconst bancoSinBambooValues = [\n [\n 'GOMEZLEE MARKETING',\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n ],\n [\n bancoSinBambooSubtitle,\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n ],\n ['', '', '', '', '', '', '', ''],\n bancoSinBambooHeader,\n ...bancoSinBambooRows,\n];\n\nconst diferenciasNombreHeader = [\n 'Nombre en Archivo',\n 'Nombre del Cuentahabiente',\n 'Cuenta Destino',\n 'Moneda',\n 'Monto',\n 'Número de envío',\n 'Número de plan',\n 'Archivo',\n 'Estado',\n 'Observación',\n 'Resolución',\n];\n\nconst diferenciasNombreRows = rows\n .filter((row) => row.category === 'diferencia_nombre_banco')\n .map((row) => [\n normalizeText(row.bank_name_file || row.employee_name || ''),\n normalizeText(row.bank_account_holder || ''),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n normalizeText(row.currency || 'QTZ'),\n roundMoney(row.bank_amount ?? row.bankAmount ?? 0),\n normalizeText(row.shipment_number || ''),\n normalizeText(row.plan_number || ''),\n normalizeText(row.source_file || ''),\n normalizeText(row.status || ''),\n normalizeText(row.observation || ''),\n '',\n ]);\n\n\nfunction accountValues(value) {\n const values = Array.isArray(value)\n ? value\n : String(value ?? '')\n .split(/\\s*(?:\\/|;|,|\\by\\b)\\s*/i);\n\n return values\n .map((item) =>\n String(item ?? '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim()\n )\n .filter((item) =>\n item.length >= 7 &&\n !/^0+$/.test(item)\n );\n}\n\nfunction payrollAccountsForWrongAccount(row) {\n const candidates = [\n row.payroll_account,\n row.payrollAccount,\n ...(Array.isArray(row.source_rows)\n ? row.source_rows.flatMap((sourceRow) => [\n sourceRow.account,\n sourceRow.payroll_account,\n sourceRow.payrollAccount,\n ])\n : []),\n ];\n\n return Array.from(new Set(\n candidates.flatMap(accountValues)\n ));\n}\n\nfunction bankAccountForWrongAccount(row) {\n return firstValue(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n );\n}\n\nfunction wrongAccountTotalStatus(row) {\n const payrollAmount = roundMoney(\n row.payroll_amount ?? row.payrollAmount ?? 0\n );\n const bankAmount = roundMoney(\n row.bank_amount ?? row.bankAmount ?? 0\n );\n const difference = roundMoney(\n payrollAmount - bankAmount\n );\n\n if (Math.abs(difference) <= 0.02) {\n return 'El total de nómina coincide con el total del banco.';\n }\n\n if (difference > 0) {\n return (\n `El total de nómina supera el total del banco por ` +\n `Q${Math.abs(difference).toLocaleString('en-US', {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n })}.`\n );\n }\n\n return (\n `El total pagado por el banco supera el total de nómina por ` +\n `Q${Math.abs(difference).toLocaleString('en-US', {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n })}.`\n );\n}\n\nfunction wrongAccountFinding(row) {\n const existing = normalizeText(row.observation || '');\n\n if (existing) {\n return existing;\n }\n\n const payrollAccounts =\n payrollAccountsForWrongAccount(row);\n const bankAccount =\n bankAccountForWrongAccount(row);\n\n return (\n `El empleado presenta una posible inconsistencia entre ` +\n `la cuenta registrada en nómina ` +\n `(${payrollAccounts.join(' y ') || 'sin cuenta identificada'}) ` +\n `y la cuenta utilizada por el banco ` +\n `(${bankAccount || 'sin cuenta identificada'}).`\n );\n}\n\nconst cuentaMalDigitadaHeader = [\n '#',\n 'Campo',\n 'Detalle',\n 'Resolución',\n];\n\nconst cuentaMalDigitadaRows = [];\n\nfor (\n let index = 0;\n index < cuentaMalDigitadaCases.length;\n index++\n) {\n const row = cuentaMalDigitadaCases[index];\n const payrollAccounts =\n payrollAccountsForWrongAccount(row);\n const bankAccount =\n bankAccountForWrongAccount(row);\n\n const fields = [\n [\n 'Empleado',\n normalizeText(\n row.employee_name ||\n row.employee ||\n ''\n ),\n ],\n [\n 'Cuentas registradas en las hojas de nómina',\n payrollAccounts.join(' y ') ||\n 'No se identificó una cuenta válida en la nómina.',\n ],\n [\n 'Cuenta utilizada por el banco',\n bankAccount ||\n 'No se identificó una cuenta válida en el banco.',\n ],\n [\n 'Estado del total',\n wrongAccountTotalStatus(row),\n ],\n [\n 'Hallazgo',\n wrongAccountFinding(row),\n ],\n [\n 'Clasificación',\n 'Posible cuenta mal digitada — revisar y unificar la cuenta en las hojas de nómina.',\n ],\n ];\n\n fields.forEach((field, fieldIndex) => {\n cuentaMalDigitadaRows.push([\n fieldIndex === 0 ? index + 1 : '',\n field[0],\n field[1],\n '',\n ]);\n });\n}\n\nconst cuentaMalDigitadaValues = [\n [\n 'GOMEZLEE MARKETING',\n '',\n '',\n '',\n ],\n [\n cuentaMalDigitadaSubtitle,\n '',\n '',\n '',\n ],\n ['', '', '', ''],\n cuentaMalDigitadaHeader,\n ...cuentaMalDigitadaRows,\n];\n\nconst resumenHeader = ['Indicador', 'Valor'];\nconst resumenRows = [\n ['Período', periodLabel],\n ['Coincidencias', Number(summary.coincidencias || 0)],\n ['Discrepancias', Number(summary.discrepancias || 0)],\n ['Banco sin nómina', Number(summary.bancoSinNomina || 0)],\n ['Banco sin Bamboo', Number(summary.bancoSinBamboo || 0)],\n ['Nómina sin cuenta', Number(summary.nominaSinCuenta || 0)],\n [\n 'Diferencias nombre banco',\n Number(summary.diferenciasNombreBanco || 0),\n ],\n [\n 'Posibles cuentas mal digitadas',\n Number(summary.posiblesCuentasMalDigitadas || 0),\n ],\n ['Pendientes cruce principal', Number(summary.pendientes || 0)],\n [\n 'Empleados BambooHR Guatemala',\n Number(summary.empleadosBambooGT || 0),\n ],\n [\n 'Empleados BambooHR en el período',\n Number(summary.empleadosBambooEnPeriodo || 0),\n ],\n ['Filas válidas de nómina', Number(summary.filasNominaValidas || 0)],\n [\n 'Suplementos adjuntados',\n Number(summary.suplementosNominaAdjuntados || 0),\n ],\n [\n 'Suplementos sin coincidencia',\n Number(summary.suplementosNominaNoAdjuntados || 0),\n ],\n ['Transacciones bancarias', Number(summary.transaccionesBanco || 0)],\n ['Total nómina', roundMoney(summary.totalNomina || 0)],\n ['Total banco', roundMoney(summary.totalBanco || 0)],\n ['Diferencia total', roundMoney(summary.diferenciaTotal || 0)],\n];\n\nfunction buildSheetValues(header, bodyRows) {\n return [header, ...bodyRows];\n}\n\nconst valueData = [\n {\n range: `'${sheetTitles.nominaVsBanco}'!A1:I`,\n values: nominaVsBancoValues,\n },\n {\n range: `'${sheetTitles.bancoSinNomina}'!A1:G`,\n values: buildSheetValues(\n bancoSinNominaHeader,\n bancoSinNominaRows\n ),\n },\n {\n range: `'${sheetTitles.bancoSinBamboo}'!A1:H`,\n values: bancoSinBambooValues,\n },\n {\n range: `'${sheetTitles.diferenciasNombreBanco}'!A1:K`,\n values: buildSheetValues(\n diferenciasNombreHeader,\n diferenciasNombreRows\n ),\n },\n ...(hasCuentaMalDigitada\n ? [\n {\n range:\n `'${sheetTitles.cuentaMalDigitada}'!A1:D`,\n values: cuentaMalDigitadaValues,\n },\n ]\n : []),\n {\n range: `'${sheetTitles.resumen}'!A1:B`,\n values: buildSheetValues(\n resumenHeader,\n resumenRows\n ),\n },\n];\n\nfunction headerFormatRequest(\n sheetId,\n endColumnIndex,\n startRowIndex = 0\n) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex: startRowIndex + 1,\n startColumnIndex: 0,\n endColumnIndex,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: {\n red: 0.29,\n green: 0.49,\n blue: 0.58,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 1,\n green: 1,\n blue: 1,\n },\n },\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n },\n },\n fields:\n 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment,verticalAlignment,wrapStrategy)',\n },\n };\n}\n\nfunction freezeHeaderRequest(sheetId, frozenRowCount = 1) {\n return {\n updateSheetProperties: {\n properties: {\n sheetId,\n gridProperties: {\n frozenRowCount,\n },\n },\n fields: 'gridProperties.frozenRowCount',\n },\n };\n}\n\nfunction autoResizeRequest(sheetId, endColumnIndex) {\n return {\n autoResizeDimensions: {\n dimensions: {\n sheetId,\n dimension: 'COLUMNS',\n startIndex: 0,\n endIndex: endColumnIndex,\n },\n },\n };\n}\n\nfunction moneyFormatRequest(\n sheetId,\n startColumnIndex,\n endColumnIndex,\n startRowIndex = 1,\n pattern = '#,##0.00',\n endRowIndex = null\n) {\n const range = {\n sheetId,\n startRowIndex,\n startColumnIndex,\n endColumnIndex,\n };\n\n if (Number.isInteger(endRowIndex)) {\n range.endRowIndex = endRowIndex;\n }\n\n return {\n repeatCell: {\n range,\n cell: {\n userEnteredFormat: {\n numberFormat: {\n type: 'NUMBER',\n pattern,\n },\n },\n },\n fields: 'userEnteredFormat.numberFormat',\n },\n };\n}\n\nfunction basicFilterRequest(\n sheetId,\n endColumnIndex,\n startRowIndex = 0,\n endRowIndex = null\n) {\n const range = {\n sheetId,\n startRowIndex,\n startColumnIndex: 0,\n endColumnIndex,\n };\n\n if (Number.isInteger(endRowIndex)) {\n range.endRowIndex = endRowIndex;\n }\n\n return {\n setBasicFilter: {\n filter: {\n range,\n },\n },\n };\n}\n\nfunction mergeRowRequest(\n sheetId,\n rowIndex,\n endColumnIndex\n) {\n return {\n mergeCells: {\n range: {\n sheetId,\n startRowIndex: rowIndex,\n endRowIndex: rowIndex + 1,\n startColumnIndex: 0,\n endColumnIndex,\n },\n mergeType: 'MERGE_ALL',\n },\n };\n}\n\nfunction titleRowFormatRequest(\n sheetId,\n rowIndex,\n endColumnIndex,\n options = {}\n) {\n const {\n fontSize = 12,\n bold = true,\n italic = false,\n horizontalAlignment = 'LEFT',\n } = options;\n\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: rowIndex,\n endRowIndex: rowIndex + 1,\n startColumnIndex: 0,\n endColumnIndex,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: {\n red: 0.29,\n green: 0.49,\n blue: 0.58,\n },\n textFormat: {\n bold,\n italic,\n fontSize,\n foregroundColor: {\n red: 1,\n green: 1,\n blue: 1,\n },\n },\n horizontalAlignment,\n verticalAlignment: 'MIDDLE',\n },\n },\n fields:\n 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment,verticalAlignment)',\n },\n };\n}\n\nfunction columnWidthRequest(\n sheetId,\n startIndex,\n endIndex,\n pixelSize\n) {\n return {\n updateDimensionProperties: {\n range: {\n sheetId,\n dimension: 'COLUMNS',\n startIndex,\n endIndex,\n },\n properties: {\n pixelSize,\n },\n fields: 'pixelSize',\n },\n };\n}\n\nfunction rowHeightRequest(\n sheetId,\n startIndex,\n endIndex,\n pixelSize\n) {\n return {\n updateDimensionProperties: {\n range: {\n sheetId,\n dimension: 'ROWS',\n startIndex,\n endIndex,\n },\n properties: {\n pixelSize,\n },\n fields: 'pixelSize',\n },\n };\n}\n\nfunction bodyAlignmentRequest(\n sheetId,\n startColumnIndex,\n endColumnIndex,\n horizontalAlignment,\n endRowIndex\n) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: 4,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n },\n cell: {\n userEnteredFormat: {\n horizontalAlignment,\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n },\n },\n fields:\n 'userEnteredFormat(horizontalAlignment,verticalAlignment,wrapStrategy)',\n },\n };\n}\n\nfunction statusFormatRequest(sheetId, endRowIndex) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: 4,\n endRowIndex,\n startColumnIndex: 7,\n endColumnIndex: 8,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: {\n red: 1,\n green: 0.92,\n blue: 0.92,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.85,\n green: 0.08,\n blue: 0.08,\n },\n },\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n },\n },\n fields:\n 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment,verticalAlignment)',\n },\n };\n}\n\nfunction bambooStatusFormatRequest(\n sheetId,\n endRowIndex\n) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: 4,\n endRowIndex,\n startColumnIndex: 6,\n endColumnIndex: 7,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: {\n red: 1,\n green: 0.97,\n blue: 0.82,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.70,\n green: 0.30,\n blue: 0.00,\n },\n },\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n },\n },\n fields:\n 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment,verticalAlignment,wrapStrategy)',\n },\n };\n}\n\n\nfunction mergeVerticalRequest(\n sheetId,\n startRowIndex,\n endRowIndex,\n columnIndex\n) {\n return {\n mergeCells: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex: columnIndex,\n endColumnIndex: columnIndex + 1,\n },\n mergeType: 'MERGE_ALL',\n },\n };\n}\n\nfunction rangeFormatRequest(\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n userEnteredFormat\n) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n },\n cell: {\n userEnteredFormat,\n },\n fields:\n 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment,verticalAlignment,wrapStrategy)',\n },\n };\n}\n\nfunction conditionalDifferenceRequest(\n sheetId,\n formula,\n backgroundColor,\n textColor,\n ranges,\n index\n) {\n return {\n addConditionalFormatRule: {\n index,\n rule: {\n ranges,\n booleanRule: {\n condition: {\n type: 'CUSTOM_FORMULA',\n values: [\n {\n userEnteredValue: formula,\n },\n ],\n },\n format: {\n backgroundColor,\n textFormat: {\n bold: true,\n foregroundColor: textColor,\n },\n },\n },\n },\n },\n };\n}\n\nfunction borderRequest(\n sheetId,\n startRowIndex,\n endRowIndex,\n endColumnIndex\n) {\n const border = {\n style: 'SOLID',\n color: {\n red: 0.72,\n green: 0.82,\n blue: 0.76,\n },\n };\n\n return {\n updateBorders: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex: 0,\n endColumnIndex,\n },\n top: border,\n bottom: border,\n left: border,\n right: border,\n innerHorizontal: border,\n innerVertical: border,\n },\n };\n}\n\nconst mainReportEndRow = Math.max(\n 4 + mainReportRows.length,\n 4\n);\n\nconst bancoSinBambooEndRow = Math.max(\n 4 + bancoSinBambooRows.length,\n 4\n);\n\nconst mainConditionalRanges = mainReportRows.length\n ? [\n {\n sheetId: sheetIds.nominaVsBanco,\n startRowIndex: 4,\n endRowIndex: mainReportEndRow,\n startColumnIndex: 5,\n endColumnIndex: 7,\n },\n ]\n : [];\n\nconst mainDataFormatRequests = mainReportRows.length\n ? [\n moneyFormatRequest(\n sheetIds.nominaVsBanco,\n 3,\n 6,\n 4,\n 'Q#,##0.00;[Red](Q#,##0.00)',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 0,\n 1,\n 'CENTER',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 1,\n 2,\n 'LEFT',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 2,\n 3,\n 'CENTER',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 3,\n 6,\n 'RIGHT',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 6,\n 8,\n 'CENTER',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 8,\n 9,\n 'LEFT',\n mainReportEndRow\n ),\n statusFormatRequest(\n sheetIds.nominaVsBanco,\n mainReportEndRow\n ),\n conditionalDifferenceRequest(\n sheetIds.nominaVsBanco,\n '=$F5>0',\n {\n red: 1,\n green: 0.97,\n blue: 0.82,\n },\n {\n red: 0.25,\n green: 0.25,\n blue: 0.25,\n },\n mainConditionalRanges,\n 0\n ),\n conditionalDifferenceRequest(\n sheetIds.nominaVsBanco,\n '=$F5<0',\n {\n red: 1,\n green: 0.89,\n blue: 0.89,\n },\n {\n red: 0.9,\n green: 0.05,\n blue: 0.05,\n },\n mainConditionalRanges,\n 1\n ),\n rowHeightRequest(\n sheetIds.nominaVsBanco,\n 4,\n mainReportEndRow,\n 28\n ),\n ]\n : [];\n\n\nconst cuentaMalDigitadaEndRow = Math.max(\n 4 + cuentaMalDigitadaRows.length,\n 4\n);\n\nconst cuentaMalDigitadaCaseRequests =\n hasCuentaMalDigitada\n ? cuentaMalDigitadaCases.flatMap(\n (_, caseIndex) => {\n const startRowIndex =\n 4 + caseIndex * 6;\n const endRowIndex =\n startRowIndex + 6;\n\n return [\n mergeVerticalRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endRowIndex,\n 0\n ),\n mergeVerticalRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endRowIndex,\n 3\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n startRowIndex + 4,\n 34\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex + 4,\n startRowIndex + 5,\n 76\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex + 5,\n endRowIndex,\n 54\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex + 4,\n startRowIndex + 5,\n 2,\n 3,\n {\n backgroundColor: {\n red: 0.97,\n green: 0.97,\n blue: 0.97,\n },\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex + 5,\n endRowIndex,\n 2,\n 3,\n {\n backgroundColor: {\n red: 1,\n green: 0.97,\n blue: 0.82,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.55,\n green: 0.30,\n blue: 0.00,\n },\n },\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n ];\n }\n )\n : [];\n\nconst cuentaMalDigitadaFormatRequests =\n hasCuentaMalDigitada\n ? [\n mergeRowRequest(\n sheetIds.cuentaMalDigitada,\n 0,\n 4\n ),\n mergeRowRequest(\n sheetIds.cuentaMalDigitada,\n 1,\n 4\n ),\n titleRowFormatRequest(\n sheetIds.cuentaMalDigitada,\n 0,\n 4,\n {\n fontSize: 12,\n bold: true,\n italic: false,\n horizontalAlignment: 'LEFT',\n }\n ),\n titleRowFormatRequest(\n sheetIds.cuentaMalDigitada,\n 1,\n 4,\n {\n fontSize: 10,\n bold: false,\n italic: true,\n horizontalAlignment: 'LEFT',\n }\n ),\n headerFormatRequest(\n sheetIds.cuentaMalDigitada,\n 4,\n 3\n ),\n freezeHeaderRequest(\n sheetIds.cuentaMalDigitada,\n 4\n ),\n borderRequest(\n sheetIds.cuentaMalDigitada,\n 3,\n cuentaMalDigitadaEndRow,\n 4\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n 0,\n 1,\n 30\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n 1,\n 2,\n 26\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n 3,\n 4,\n 40\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n 4,\n cuentaMalDigitadaEndRow,\n 0,\n 1,\n {\n backgroundColor: {\n red: 0.91,\n green: 0.95,\n blue: 0.99,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.20,\n green: 0.36,\n blue: 0.45,\n },\n },\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n 4,\n cuentaMalDigitadaEndRow,\n 1,\n 2,\n {\n backgroundColor: {\n red: 0.93,\n green: 0.97,\n blue: 0.90,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.20,\n green: 0.36,\n blue: 0.45,\n },\n },\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n 4,\n cuentaMalDigitadaEndRow,\n 2,\n 3,\n {\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n 4,\n cuentaMalDigitadaEndRow,\n 3,\n 4,\n {\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 0,\n 1,\n 48\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 1,\n 2,\n 285\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 2,\n 3,\n 520\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 3,\n 4,\n 260\n ),\n ...cuentaMalDigitadaCaseRequests,\n ]\n : [];\n\n\nfunction wrapRangeRequest(\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex\n) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n },\n cell: {\n userEnteredFormat: {\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n },\n },\n fields:\n 'userEnteredFormat(verticalAlignment,wrapStrategy)',\n },\n };\n}\n\nfunction autoResizeRowsRequest(\n sheetId,\n startIndex,\n endIndex\n) {\n return {\n autoResizeDimensions: {\n dimensions: {\n sheetId,\n dimension: 'ROWS',\n startIndex,\n endIndex,\n },\n },\n };\n}\n\nconst formatRequests = [\n mergeRowRequest(sheetIds.nominaVsBanco, 0, 9),\n mergeRowRequest(sheetIds.nominaVsBanco, 1, 9),\n titleRowFormatRequest(\n sheetIds.nominaVsBanco,\n 0,\n 9,\n {\n fontSize: 12,\n bold: true,\n italic: false,\n horizontalAlignment: 'LEFT',\n }\n ),\n titleRowFormatRequest(\n sheetIds.nominaVsBanco,\n 1,\n 9,\n {\n fontSize: 10,\n bold: false,\n italic: true,\n horizontalAlignment: 'LEFT',\n }\n ),\n headerFormatRequest(\n sheetIds.nominaVsBanco,\n 9,\n 3\n ),\n freezeHeaderRequest(\n sheetIds.nominaVsBanco,\n 4\n ),\n basicFilterRequest(\n sheetIds.nominaVsBanco,\n 9,\n 3,\n mainReportEndRow\n ),\n borderRequest(\n sheetIds.nominaVsBanco,\n 3,\n mainReportEndRow,\n 9\n ),\n rowHeightRequest(\n sheetIds.nominaVsBanco,\n 0,\n 1,\n 30\n ),\n rowHeightRequest(\n sheetIds.nominaVsBanco,\n 1,\n 2,\n 26\n ),\n rowHeightRequest(\n sheetIds.nominaVsBanco,\n 3,\n 4,\n 42\n ),\n ...mainDataFormatRequests,\n\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 0,\n 1,\n 48\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 1,\n 2,\n 260\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 2,\n 3,\n 130\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 3,\n 6,\n 130\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 6,\n 7,\n 155\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 7,\n 8,\n 110\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 8,\n 9,\n 260\n ),\n\n headerFormatRequest(sheetIds.bancoSinNomina, 7),\n freezeHeaderRequest(sheetIds.bancoSinNomina),\n autoResizeRequest(sheetIds.bancoSinNomina, 7),\n moneyFormatRequest(sheetIds.bancoSinNomina, 3, 4),\n basicFilterRequest(sheetIds.bancoSinNomina, 7),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 6,\n 7,\n 260\n ),\n\n mergeRowRequest(sheetIds.bancoSinBamboo, 0, 8),\n mergeRowRequest(sheetIds.bancoSinBamboo, 1, 8),\n titleRowFormatRequest(\n sheetIds.bancoSinBamboo,\n 0,\n 8,\n {\n fontSize: 12,\n bold: true,\n italic: false,\n horizontalAlignment: 'LEFT',\n }\n ),\n titleRowFormatRequest(\n sheetIds.bancoSinBamboo,\n 1,\n 8,\n {\n fontSize: 10,\n bold: false,\n italic: true,\n horizontalAlignment: 'LEFT',\n }\n ),\n headerFormatRequest(\n sheetIds.bancoSinBamboo,\n 8,\n 3\n ),\n freezeHeaderRequest(\n sheetIds.bancoSinBamboo,\n 4\n ),\n basicFilterRequest(\n sheetIds.bancoSinBamboo,\n 8,\n 3,\n bancoSinBambooEndRow\n ),\n borderRequest(\n sheetIds.bancoSinBamboo,\n 3,\n bancoSinBambooEndRow,\n 8\n ),\n rowHeightRequest(\n sheetIds.bancoSinBamboo,\n 0,\n 1,\n 30\n ),\n rowHeightRequest(\n sheetIds.bancoSinBamboo,\n 1,\n 2,\n 26\n ),\n rowHeightRequest(\n sheetIds.bancoSinBamboo,\n 3,\n 4,\n 42\n ),\n moneyFormatRequest(\n sheetIds.bancoSinBamboo,\n 4,\n 5,\n 4,\n 'Q#,##0.00',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 0,\n 1,\n 'CENTER',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 1,\n 3,\n 'LEFT',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 3,\n 4,\n 'CENTER',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 4,\n 5,\n 'RIGHT',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 5,\n 7,\n 'CENTER',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 7,\n 8,\n 'LEFT',\n bancoSinBambooEndRow\n ),\n bambooStatusFormatRequest(\n sheetIds.bancoSinBamboo,\n bancoSinBambooEndRow\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 0,\n 1,\n 48\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 1,\n 2,\n 220\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 2,\n 3,\n 270\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 3,\n 4,\n 135\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 4,\n 5,\n 130\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 5,\n 6,\n 110\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 6,\n 7,\n 140\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 7,\n 8,\n 260\n ),\n\n headerFormatRequest(\n sheetIds.diferenciasNombreBanco,\n 11\n ),\n freezeHeaderRequest(\n sheetIds.diferenciasNombreBanco\n ),\n autoResizeRequest(\n sheetIds.diferenciasNombreBanco,\n 11\n ),\n moneyFormatRequest(\n sheetIds.diferenciasNombreBanco,\n 4,\n 5\n ),\n basicFilterRequest(\n sheetIds.diferenciasNombreBanco,\n 11\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 10,\n 11,\n 260\n ),\n\n ...cuentaMalDigitadaFormatRequests,\n\n headerFormatRequest(sheetIds.resumen, 2),\n freezeHeaderRequest(sheetIds.resumen),\n autoResizeRequest(sheetIds.resumen, 2),\n moneyFormatRequest(sheetIds.resumen, 1, 2),\n];\n\n\nconst bancoSinNominaEndRow =\n 1 + bancoSinNominaRows.length;\n\nconst diferenciasNombreEndRow =\n 1 + diferenciasNombreRows.length;\n\nconst resumenReadabilityEndRow =\n 1 + resumenRows.length;\n\n/*\n * Ajuste final de legibilidad.\n *\n * Se ejecuta al final para que los anchos definitivos ya estén aplicados\n * cuando Google Sheets calcule automáticamente la altura de cada fila.\n * Así, cualquier texto largo queda envuelto y visible sin que el usuario\n * tenga que expandir columnas o filas manualmente.\n */\nformatRequests.push(\n // 01 Nómina vs Banco\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 1,\n 2,\n 300\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 2,\n 3,\n 150\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 3,\n 6,\n 145\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 6,\n 7,\n 220\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 7,\n 8,\n 140\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 8,\n 9,\n 320\n ),\n ...(mainReportRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.nominaVsBanco,\n 4,\n mainReportEndRow,\n 0,\n 9\n ),\n autoResizeRowsRequest(\n sheetIds.nominaVsBanco,\n 4,\n mainReportEndRow\n ),\n ]\n : []),\n\n // 02 Banco sin Nómina\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 0,\n 1,\n 320\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 1,\n 2,\n 165\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 2,\n 3,\n 95\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 3,\n 4,\n 140\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 4,\n 5,\n 170\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 5,\n 6,\n 560\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 6,\n 7,\n 320\n ),\n autoResizeRowsRequest(\n sheetIds.bancoSinNomina,\n 0,\n 1\n ),\n ...(bancoSinNominaRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.bancoSinNomina,\n 1,\n bancoSinNominaEndRow,\n 0,\n 7\n ),\n autoResizeRowsRequest(\n sheetIds.bancoSinNomina,\n 1,\n bancoSinNominaEndRow\n ),\n ]\n : []),\n\n // 03 Banco sin Bamboo\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 1,\n 2,\n 300\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 2,\n 3,\n 330\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 3,\n 4,\n 150\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 4,\n 5,\n 145\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 5,\n 6,\n 150\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 6,\n 7,\n 170\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 7,\n 8,\n 320\n ),\n ...(bancoSinBambooRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.bancoSinBamboo,\n 4,\n bancoSinBambooEndRow,\n 0,\n 8\n ),\n autoResizeRowsRequest(\n sheetIds.bancoSinBamboo,\n 4,\n bancoSinBambooEndRow\n ),\n ]\n : []),\n\n // 04 Diferencias nombre banco\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 0,\n 1,\n 300\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 1,\n 2,\n 330\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 2,\n 3,\n 155\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 3,\n 4,\n 95\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 4,\n 5,\n 140\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 5,\n 7,\n 150\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 7,\n 8,\n 260\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 8,\n 9,\n 170\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 9,\n 10,\n 650\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 10,\n 11,\n 320\n ),\n autoResizeRowsRequest(\n sheetIds.diferenciasNombreBanco,\n 0,\n 1\n ),\n ...(diferenciasNombreRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.diferenciasNombreBanco,\n 1,\n diferenciasNombreEndRow,\n 0,\n 11\n ),\n autoResizeRowsRequest(\n sheetIds.diferenciasNombreBanco,\n 1,\n diferenciasNombreEndRow\n ),\n ]\n : []),\n\n // 05 Cuenta Mal Digitada\n ...(hasCuentaMalDigitada\n ? [\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 1,\n 2,\n 300\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 2,\n 3,\n 600\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 3,\n 4,\n 320\n ),\n ]\n : []),\n\n // Resumen\n columnWidthRequest(\n sheetIds.resumen,\n 0,\n 1,\n 380\n ),\n columnWidthRequest(\n sheetIds.resumen,\n 1,\n 2,\n 320\n ),\n autoResizeRowsRequest(\n sheetIds.resumen,\n 0,\n 1\n ),\n ...(resumenRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.resumen,\n 1,\n resumenReadabilityEndRow,\n 0,\n 2\n ),\n autoResizeRowsRequest(\n sheetIds.resumen,\n 1,\n resumenReadabilityEndRow\n ),\n ]\n : [])\n);\n\n\nreturn [\n {\n json: {\n ok: true,\n stage: 'preparar_google_sheet',\n metadata,\n summary,\n spreadsheetTitle,\n sheetIds,\n sheetTitles,\n createSpreadsheetBody: {\n properties: {\n title: spreadsheetTitle,\n },\n sheets: [\n {\n properties: {\n sheetId: sheetIds.nominaVsBanco,\n title: sheetTitles.nominaVsBanco,\n },\n },\n {\n properties: {\n sheetId: sheetIds.bancoSinNomina,\n title: sheetTitles.bancoSinNomina,\n },\n },\n {\n properties: {\n sheetId: sheetIds.bancoSinBamboo,\n title: sheetTitles.bancoSinBamboo,\n },\n },\n {\n properties: {\n sheetId:\n sheetIds.diferenciasNombreBanco,\n title:\n sheetTitles.diferenciasNombreBanco,\n },\n },\n ...(hasCuentaMalDigitada\n ? [\n {\n properties: {\n sheetId:\n sheetIds.cuentaMalDigitada,\n title:\n sheetTitles.cuentaMalDigitada,\n },\n },\n ]\n : []),\n {\n properties: {\n sheetId: sheetIds.resumen,\n title: sheetTitles.resumen,\n },\n },\n ],\n },\n valueBatchBody: {\n 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": [
|
||
22048,
|
||
26512
|
||
],
|
||
"id": "5c8239ca-2f93-41a3-be89-cb6fce94d2b2",
|
||
"name": "Preparar Google Sheet"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "https://sheets.googleapis.com/v4/spreadsheets",
|
||
"authentication": "predefinedCredentialType",
|
||
"nodeCredentialType": "googleOAuth2Api",
|
||
"sendBody": true,
|
||
"specifyBody": "json",
|
||
"jsonBody": "={{\n(() => {\n const prepared =\n $('Preparar Google Sheet').first().json || {};\n\n const createBody =\n prepared.createSpreadsheetBody || {};\n\n if (\n !Array.isArray(createBody.sheets) ||\n createBody.sheets.length === 0\n ) {\n throw new Error(\n 'Preparar Google Sheet no devolvió las hojas que deben crearse.'\n );\n }\n\n return {\n properties: {\n ...(createBody.properties || {}),\n timeZone: 'America/Guatemala',\n },\n\n sheets: createBody.sheets.map((sheet) => ({\n properties: {\n ...(sheet.properties || {}),\n\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": [
|
||
22256,
|
||
26512
|
||
],
|
||
"id": "937c7727-c0e4-49c7-ab93-199919b81763",
|
||
"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/' + $('Crear Google Sheet').first().json.spreadsheetId + '/values:batchUpdate' }}",
|
||
"authentication": "predefinedCredentialType",
|
||
"nodeCredentialType": "googleOAuth2Api",
|
||
"sendBody": true,
|
||
"specifyBody": "json",
|
||
"jsonBody": "={{ $('Preparar Google Sheet').first().json.valueBatchBody }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
22464,
|
||
26512
|
||
],
|
||
"id": "c71323e4-d046-44c9-b073-e8f389ffc86f",
|
||
"name": "Escribir Google Sheet",
|
||
"credentials": {
|
||
"googleOAuth2Api": {
|
||
"id": "dQ1MJSJSWcoWYcb8",
|
||
"name": "Google account - Isaac Producción"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $('Crear Google Sheet').first().json.spreadsheetId + ':batchUpdate' }}",
|
||
"authentication": "predefinedCredentialType",
|
||
"nodeCredentialType": "googleOAuth2Api",
|
||
"sendBody": true,
|
||
"specifyBody": "json",
|
||
"jsonBody": "={{ $('Preparar Google Sheet').first().json.formatBatchBody }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
22672,
|
||
26512
|
||
],
|
||
"id": "21d98cca-0e31-4471-a0ab-b2ad0f0bf7a3",
|
||
"name": "Formatear Google Sheet",
|
||
"credentials": {
|
||
"googleOAuth2Api": {
|
||
"id": "dQ1MJSJSWcoWYcb8",
|
||
"name": "Google account - Isaac Producción"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const prepared = $('Preparar Google Sheet').first().json || {};\nconst createdSheet = $('Crear Google Sheet').first().json || {};\n\nconst original =\n prepared.originalResponse ||\n prepared.original_response ||\n prepared.response ||\n {};\n\nconst spreadsheetId = createdSheet.spreadsheetId || '';\nconst reportUrl =\n createdSheet.spreadsheetUrl ||\n (spreadsheetId ? `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit` : null);\n\nreturn [\n {\n json: {\n ok: original.ok ?? true,\n message: reportUrl\n ? 'Cruce procesado correctamente. Google Sheet generado.'\n : 'Cruce procesado correctamente, pero no se recibió URL del Google Sheet.',\n stage: reportUrl ? 'cruce_completado_con_reporte' : 'cruce_completado_sin_reporte',\n errors: original.errors || [],\n metadata: original.metadata || {},\n summary: original.summary || {},\n rows: original.rows || [],\n bankWithoutBamboo:\n original.bankWithoutBamboo || [],\n bambooSummary:\n original.bambooSummary || {},\n reportUrl,\n 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": [
|
||
24800,
|
||
26528
|
||
],
|
||
"id": "aa72c7f0-6d16-483e-b991-292c01415eef",
|
||
"name": "Preparar respuesta final"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const createdSheet = $('Crear Google Sheet').first().json || {};\nconst spreadsheetId = createdSheet.spreadsheetId;\n\nif (!spreadsheetId) {\n throw new Error('No se recibió spreadsheetId desde Crear Google Sheet.');\n}\n\nconst allowedEmails = [\n 'iaracena@gomezleemarketing.com',\n 'ymadera@gomezleemarketing.com',\n 'mgomez@gomezleemarketing.com',\n 'jgomez@gomezleemarketing.com',\n];\n\nreturn allowedEmails.map((email) => ({\n json: {\n spreadsheetId,\n email,\n permissionBody: {\n type: 'user',\n role: 'writer',\n emailAddress: email,\n },\n },\n}));"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
22880,
|
||
26512
|
||
],
|
||
"id": "e2b64399-0b45-40b9-b2be-b7b88bdfa002",
|
||
"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": [
|
||
23088,
|
||
26512
|
||
],
|
||
"id": "d9026869-a053-4a40-8d5f-fe92e6c11c7d",
|
||
"name": "Compartir Google Sheet",
|
||
"credentials": {
|
||
"googleOAuth2Api": {
|
||
"id": "dQ1MJSJSWcoWYcb8",
|
||
"name": "Google account - Isaac Producción"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const cruce = $('Cruzar Nómina vs Banco').first().json || {};\nconst createdSheet = $('Crear Google Sheet').first().json || {};\n\nconst metadata = cruce.metadata || {};\nconst summary = cruce.summary || {};\nconst debug = cruce.debug || {};\n\nconst spreadsheetId = createdSheet.spreadsheetId || cruce.spreadsheetId || '';\nconst reportUrl =\n createdSheet.spreadsheetUrl ||\n createdSheet.spreadsheet_url ||\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 payload = {\n source_app: metadata.source_app || 'cruce-cuentas-glm-guatemala',\n country: metadata.country || 'GT',\n country_name: metadata.country_name || 'Guatemala',\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 diferencias_nombre_banco:\n diferenciasNombreBanco,\n banco_sin_bamboo:\n bancoSinBamboo,\n pendientes_cruce_principal:\n pendientes,\n requiere_revision:\n requiereRevision,\n },\n summary,\n debug: {\n sheet_summaries: debug.sheet_summaries || [],\n attached_supplements: debug.attached_supplements || [],\n unattached_supplements: debug.unattached_supplements || [],\n bank_name_differences_preview:\n debug.bank_name_differences_preview || [],\n bamboo_matches:\n debug.bamboo_matches || [],\n bamboo_excluded_payments:\n debug.bamboo_excluded_payments || [],\n banco_sin_bamboo:\n cruce.bankWithoutBamboo || [],\n },\n};\n\nreturn [\n {\n json: {\n ...cruce,\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": [
|
||
24240,
|
||
26528
|
||
],
|
||
"id": "471f36ca-d750-4e92-8807-4794ce0cc4a0",
|
||
"name": "Preparar histórico Supabase"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "https://dbit.digitalcompass.agency/rest/v1/cruces_cuentas_gt_reportes",
|
||
"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": "={{ $json.supabasePayload }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
24448,
|
||
26528
|
||
],
|
||
"id": "69a0dfa0-702a-4ed6-93cc-01a0b97d9676",
|
||
"name": "Insertar histórico Supabase",
|
||
"onError": "continueRegularOutput"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "xlsx",
|
||
"binaryPropertyName": "payroll_file",
|
||
"options": {
|
||
"headerRow": false,
|
||
"sheetName": "Temporales WMC"
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.extractFromFile",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
15664,
|
||
27632
|
||
],
|
||
"id": "f545108c-5069-4208-be51-531a235a81e2",
|
||
"name": "Extract - Temporales WMC",
|
||
"retryOnFail": false,
|
||
"onError": "continueRegularOutput"
|
||
},
|
||
{
|
||
"parameters": {},
|
||
"type": "n8n-nodes-base.merge",
|
||
"typeVersion": 3.2,
|
||
"position": [
|
||
19232,
|
||
27296
|
||
],
|
||
"id": "e4f39bb4-ad63-4611-8ddc-09a44817eaa1",
|
||
"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": [
|
||
15216,
|
||
25568
|
||
],
|
||
"id": "4487c967-fd95-4865-977b-b232f1a7b69d",
|
||
"name": "HTTP - Empleados BambooHR 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": [
|
||
15440,
|
||
25568
|
||
],
|
||
"id": "a0b69501-6075-4d1c-819e-a7b15a683ddb",
|
||
"name": "Normalizar BambooHR GT"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "combine",
|
||
"combineBy": "combineByPosition",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.merge",
|
||
"typeVersion": 3.2,
|
||
"position": [
|
||
21472,
|
||
26512
|
||
],
|
||
"id": "4ba30bd6-5082-4299-aaaa-4082014f781e",
|
||
"name": "Merge - Agregar BambooHR"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"content": "# 📥 ENTRADA Y EXTRACCIÓN DE DATOS — GUATEMALA\n\nRecibe desde el Portal de Verificación de Nóminas 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 nómina 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, nómina 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": [
|
||
14432,
|
||
24992
|
||
],
|
||
"id": "a9eedba9-b326-4097-b31a-9cb977f38400",
|
||
"name": "Sticky Note"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"content": "# 🧩 CONSOLIDACIÓN DE NÓMINA — GUATEMALA\n\nUne progresivamente todas las hojas extraídas hasta construir una sola nómina 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 nómina 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": [
|
||
16368,
|
||
25136
|
||
],
|
||
"id": "458cfcea-4a35-4a73-bfd9-c2689bb88d0b",
|
||
"name": "Sticky Note1"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"content": "# 🔍 CRUCE Y REPORTE FINAL — GUATEMALA\n\nCombina la nómina consolidada con el archivo bancario y la información oficial de BambooHR.\n\nEl cruce permite detectar:\n\n- Diferencias entre nómina y banco.\n- Empleados presentes solamente en nómina.\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, nómina y banco.",
|
||
"height": 608,
|
||
"width": 2608,
|
||
"color": "#5F721D"
|
||
},
|
||
"type": "n8n-nodes-base.stickyNote",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
20752,
|
||
26288
|
||
],
|
||
"id": "74dfb3d9-327a-428e-a0d8-400436289f6c",
|
||
"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 Nóminas.\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": 656,
|
||
"width": 1760,
|
||
"color": 3
|
||
},
|
||
"type": "n8n-nodes-base.stickyNote",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
23536,
|
||
26256
|
||
],
|
||
"id": "86284c0d-1d6b-4ed5-84a6-2dea3aaed6e8",
|
||
"name": "Sticky Note3"
|
||
}
|
||
],
|
||
"connections": {
|
||
"Webhook": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Preparar entrada app",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Preparar entrada app": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Parsear CSV banco GT",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Extract - Nomina General",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Extract - Temporales",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Extract - Auditorias",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Extract - Bono Mariana",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Extract - Movilidad WP",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Extract - Viaticos PMI",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Extract - Combustible Purina",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Extract - Combustible PG",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Extract - Combustibles Liquidables",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Extract - Mot Variable Abril",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Extract - Temporales WMC",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Parsear CSV banco GT": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Merge": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge - Agregar BambooHR",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "HTTP - Empleados BambooHR GT",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Cruzar Nómina vs Banco": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Preparar Google Sheet",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Extract - Nomina General": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge Hojas 01-02",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Extract - Temporales": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge Hojas 01-02",
|
||
"type": "main",
|
||
"index": 1
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Extract - Auditorias": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge Hojas 03",
|
||
"type": "main",
|
||
"index": 1
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Extract - Bono Mariana": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge Hojas 04",
|
||
"type": "main",
|
||
"index": 1
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Extract - Movilidad WP": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge Hojas 05",
|
||
"type": "main",
|
||
"index": 1
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Extract - Viaticos PMI": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge Hojas 06",
|
||
"type": "main",
|
||
"index": 1
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Extract - Combustible Purina": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge Hojas 07",
|
||
"type": "main",
|
||
"index": 1
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Extract - Combustible PG": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge Hojas 08",
|
||
"type": "main",
|
||
"index": 1
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Extract - Combustibles Liquidables": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge Hojas 09",
|
||
"type": "main",
|
||
"index": 1
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Normalizar Nómina Completa": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge",
|
||
"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 - Mot Variable Abril": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge Hojas ",
|
||
"type": "main",
|
||
"index": 1
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Merge Hojas ": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge Hojas 10",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Preparar Google Sheet": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Crear Google Sheet",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Crear Google Sheet": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Escribir Google Sheet",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Escribir Google Sheet": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Formatear Google Sheet",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Formatear Google Sheet": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Preparar permisos Google Sheet",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Preparar 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
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Extract - Temporales WMC": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge Hojas 10",
|
||
"type": "main",
|
||
"index": 1
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Merge Hojas 10": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Normalizar Nómina Completa",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Empleados BambooHR GT": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Normalizar BambooHR GT",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Normalizar BambooHR GT": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge - Agregar BambooHR",
|
||
"type": "main",
|
||
"index": 1
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Merge - Agregar BambooHR": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Cruzar Nómina vs Banco",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
}
|
||
},
|
||
"settings": {
|
||
"executionOrder": "v1",
|
||
"binaryMode": "separate",
|
||
"availableInMCP": true,
|
||
"timeSavedMode": "fixed",
|
||
"errorWorkflow": "puF4LUczoSz3hcek",
|
||
"timezone": "America/Santo_Domingo",
|
||
"callerPolicy": "workflowsFromSameOwner"
|
||
},
|
||
"staticData": null,
|
||
"meta": null,
|
||
"versionId": "afef0d83-31fc-44b2-87dd-05536d3520f2",
|
||
"activeVersionId": "afef0d83-31fc-44b2-87dd-05536d3520f2",
|
||
"versionCounter": 163,
|
||
"triggerCount": 1,
|
||
"shared": [
|
||
{
|
||
"updatedAt": "2026-07-13T18:02:24.393Z",
|
||
"createdAt": "2026-07-13T18:02:24.393Z",
|
||
"role": "workflow:owner",
|
||
"workflowId": "d3vXYr7ucbaiU5ct",
|
||
"projectId": "PJpTANzTXIFibWsW",
|
||
"project": {
|
||
"updatedAt": "2026-04-22T14:25:09.686Z",
|
||
"createdAt": "2026-04-22T14:22:54.790Z",
|
||
"id": "PJpTANzTXIFibWsW",
|
||
"name": "Isaac Aracena <iaracena@gomezleemarketing.com>",
|
||
"type": "personal",
|
||
"icon": null,
|
||
"description": null,
|
||
"creatorId": "0a88c0b1-928e-4412-896e-c5d1c99b2029"
|
||
}
|
||
}
|
||
],
|
||
"tags": [],
|
||
"activeVersion": {
|
||
"updatedAt": "2026-07-27T15:00:48.000Z",
|
||
"createdAt": "2026-07-27T15:00:47.639Z",
|
||
"versionId": "afef0d83-31fc-44b2-87dd-05536d3520f2",
|
||
"workflowId": "d3vXYr7ucbaiU5ct",
|
||
"nodes": [
|
||
{
|
||
"parameters": {
|
||
"httpMethod": "POST",
|
||
"path": "nominagt-bamboo-test",
|
||
"responseMode": "responseNode",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.webhook",
|
||
"typeVersion": 2.1,
|
||
"position": [
|
||
14768,
|
||
25968
|
||
],
|
||
"id": "bbb096bb-6f2c-48df-907c-06c9857943ac",
|
||
"name": "Webhook",
|
||
"webhookId": "4061b0e1-0d8e-4fb4-bea8-790c718447ee"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const item = $input.first();\n\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);\n\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 errors = [];\n\nif (!metadata.country || metadata.country !== 'GT') {\n errors.push('El país recibido no es Guatemala.');\n}\n\nif (!metadata.year) {\n errors.push('No se recibió el año del cruce.');\n}\n\nif (!metadata.month) {\n errors.push('No se recibió el mes del cruce.');\n}\n\nif (!metadata.period_type) {\n errors.push('No se recibió el tipo de quincena.');\n}\n\nif (!metadata.period_start || !metadata.period_end) {\n errors.push('No se recibió el período calculado.');\n}\n\nif (!payrollFile) {\n errors.push('No se recibió el archivo de nómina.');\n}\n\nif (bankFiles.length === 0) {\n errors.push('No se recibió ningún archivo CSV del banco.');\n}\n\nreturn [\n {\n json: {\n ok: errors.length === 0,\n stage: 'entrada_recibida',\n errors,\n metadata,\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": [
|
||
14976,
|
||
25968
|
||
],
|
||
"id": "756188b6-dc61-4e3a-965a-47885e847e69",
|
||
"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: 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 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": [
|
||
25056,
|
||
26528
|
||
],
|
||
"id": "cfa673b1-08ba-41c0-b06c-15189672e1f2",
|
||
"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": [
|
||
15648,
|
||
25152
|
||
],
|
||
"id": "65e7f1a9-ac7a-46b5-8f9d-6729c5c80a31",
|
||
"name": "Parsear CSV banco GT"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "combine",
|
||
"combineBy": "combineByPosition",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.merge",
|
||
"typeVersion": 3.2,
|
||
"position": [
|
||
21264,
|
||
26512
|
||
],
|
||
"id": "c47dde0f-72aa-49b6-b58b-f7051df5df62",
|
||
"name": "Merge"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const data = $input.first().json || {};\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\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 match = 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 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 totalNominaBase = roundMoney(\n data.payroll?.total_amount || 0\n);\n\nconst totalNomina = roundMoney(\n totalNominaBase + appliedSupplementsTotal\n);\n\nconst totalBanco = roundMoney(\n data.bank?.total_amount || 0\n);\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 totalNomina,\n totalBanco,\n diferenciaTotal:\n moneyDiff(totalNomina, totalBanco),\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 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": [
|
||
21696,
|
||
26512
|
||
],
|
||
"id": "a9aeea33-6818-40c1-9eb9-b6a3521a4f66",
|
||
"name": "Cruzar Nómina vs Banco"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "xlsx",
|
||
"binaryPropertyName": "payroll_file",
|
||
"options": {
|
||
"headerRow": true,
|
||
"sheetName": "1) Nomina General"
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.extractFromFile",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
15648,
|
||
25344
|
||
],
|
||
"id": "3e169c34-36e6-40d2-9751-4d3efdcf8c08",
|
||
"name": "Extract - Nomina General",
|
||
"retryOnFail": false
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "xlsx",
|
||
"binaryPropertyName": "payroll_file",
|
||
"options": {
|
||
"headerRow": true,
|
||
"sheetName": "2) Temporales"
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.extractFromFile",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
15648,
|
||
25584
|
||
],
|
||
"id": "c30318b3-98e8-44bf-8c0b-315f8a54f1e8",
|
||
"name": "Extract - Temporales",
|
||
"retryOnFail": false,
|
||
"onError": "continueRegularOutput"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "xlsx",
|
||
"binaryPropertyName": "payroll_file",
|
||
"options": {
|
||
"headerRow": true,
|
||
"sheetName": "3) Auditorias"
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.extractFromFile",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
15648,
|
||
25824
|
||
],
|
||
"id": "b7b0d02e-7e91-428e-aecd-3a8a08e77175",
|
||
"name": "Extract - Auditorias",
|
||
"retryOnFail": false,
|
||
"onError": "continueRegularOutput"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "xlsx",
|
||
"binaryPropertyName": "payroll_file",
|
||
"options": {
|
||
"headerRow": true,
|
||
"sheetName": "4) Bono Mariana"
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.extractFromFile",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
15664,
|
||
26016
|
||
],
|
||
"id": "cdfb15a2-d9d0-4544-80e1-e798e42e796f",
|
||
"name": "Extract - Bono Mariana",
|
||
"retryOnFail": false,
|
||
"onError": "continueRegularOutput"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "xlsx",
|
||
"binaryPropertyName": "payroll_file",
|
||
"options": {
|
||
"headerRow": true,
|
||
"sheetName": "5) Movilidad WP"
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.extractFromFile",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
15664,
|
||
26224
|
||
],
|
||
"id": "12b2497a-642d-4f4a-b32c-d4faa535f565",
|
||
"name": "Extract - Movilidad WP",
|
||
"retryOnFail": false,
|
||
"onError": "continueRegularOutput"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "xlsx",
|
||
"binaryPropertyName": "payroll_file",
|
||
"options": {
|
||
"headerRow": true,
|
||
"sheetName": "6)Viaticos PMI"
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.extractFromFile",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
15664,
|
||
26432
|
||
],
|
||
"id": "d33f7284-0947-44ad-8a03-47a0b8bb4122",
|
||
"name": "Extract - Viaticos PMI",
|
||
"retryOnFail": false,
|
||
"onError": "continueRegularOutput"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "xlsx",
|
||
"binaryPropertyName": "payroll_file",
|
||
"options": {
|
||
"headerRow": true,
|
||
"sheetName": "7) Combustible Purina"
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.extractFromFile",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
15680,
|
||
26768
|
||
],
|
||
"id": "5b82536d-1e30-4fdf-9812-b2f93e8a19c9",
|
||
"name": "Extract - Combustible Purina",
|
||
"retryOnFail": false,
|
||
"onError": "continueRegularOutput"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "xlsx",
|
||
"binaryPropertyName": "payroll_file",
|
||
"options": {
|
||
"headerRow": true,
|
||
"sheetName": "8) Combustible P&G"
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.extractFromFile",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
15664,
|
||
27104
|
||
],
|
||
"id": "fc8edacd-6084-42b1-8aa1-cbd18e96bf1b",
|
||
"name": "Extract - Combustible PG",
|
||
"retryOnFail": false,
|
||
"onError": "continueRegularOutput"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "xlsx",
|
||
"binaryPropertyName": "payroll_file",
|
||
"options": {
|
||
"headerRow": true,
|
||
"sheetName": "9) Combustibles Liquidables"
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.extractFromFile",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
15664,
|
||
27392
|
||
],
|
||
"id": "ee4ee831-169e-4d33-88be-4d0d704ec095",
|
||
"name": "Extract - Combustibles Liquidables",
|
||
"retryOnFail": false,
|
||
"onError": "continueRegularOutput"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "function 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 if (value === null || value === undefined || value === '') return '';\n\n if (typeof value === 'number') {\n return String(Math.trunc(value)).trim();\n }\n\n return String(value)\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(/\\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 getValue(row, possibleKeys) {\n for (const key of possibleKeys) {\n if (row[key] !== undefined && row[key] !== null && row[key] !== '') {\n return row[key];\n }\n }\n\n const rowKeys = Object.keys(row || {});\n\n for (const wanted of possibleKeys) {\n const wantedNormalized = normalizeForCompare(wanted);\n const found = rowKeys.find((key) => normalizeForCompare(key) === wantedNormalized);\n\n if (found && row[found] !== undefined && row[found] !== null && row[found] !== '') {\n return row[found];\n }\n }\n\n return '';\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 (text.includes('spreadsheet does not contain sheet')) return false;\n if (text.includes('no sheet')) return false;\n if (row.error) return false;\n return true;\n });\n } catch (error) {\n return [];\n }\n}\n\nfunction isProbablyInvalidEmployeeName(value) {\n const name = normalizeText(value);\n const lower = normalizeForCompare(name);\n\n if (!name) return true;\n if (/^[\\d.,\\s]+$/.test(name)) return true;\n\n const invalidExact = new Set([\n 'nombre', 'nombre completo', 'empleado', 'colaborador', 'cuenta',\n 'cuenta bancaria', 'total', 'subtotal', 'gran total', 'total general',\n 'guatemala', 'coordinador', 'supervisor', 'kam', 'pais', 'país',\n 'proyecto', 'cliente', 'marca', 'canal', 'concepto', 'descripcion',\n 'descripción', 'ejecutado en tarjeta', 'ejecutado en efectivo',\n 'ajustes segun comentarios', 'ajuste segun comentarios', 'comentarios',\n 'disponible', 'ejecutado', 'pendiente'\n ]);\n\n if (invalidExact.has(lower)) return true;\n\n const invalidContains = [\n 'total ', 'total:', 'subtotal', 'resumen', 'observacion', 'observación',\n 'monto', 'cuenta', 'banco', 'nomina', 'nómina', 'bonificacion',\n 'bonificación', 'departamento', 'puesto', 'posicion', 'posición',\n 'spoc', 'gema hsm', 'gema hfs', 'hsm-dpp', 'lider de ejecucion',\n 'líder de ejecución', 'coordinador nacional', 'ejecutado en tarjeta',\n 'ejecutado en efectivo', 'ajustes segun comentarios',\n 'ajuste segun comentarios', 'segun comentarios', 'comentarios',\n 'presupuesto combustible', 'credito 30 dias', 'crédito 30 días',\n 'no se deposita', 'ejecucion mensual', 'ejecución mensual'\n ];\n\n if (invalidContains.some((token) => lower.includes(normalizeForCompare(token)))) {\n return true;\n }\n\n const words = name.split(/\\s+/).filter(Boolean);\n return words.length < 2;\n}\n\nfunction isValidCrossableAccount(account) {\n const normalized = normalizeAccount(account);\n return normalized.length >= 7 && !/^0+$/.test(normalized);\n}\n\nfunction buildRow({ sourceSheet, rowNumber, employeeName, account, email, amount, currency = 'QTZ', extra = {} }) {\n return {\n source_sheet: sourceSheet,\n row_number: rowNumber,\n employee_name: normalizeText(employeeName),\n employee_number: null,\n account: normalizeAccount(account),\n email: normalizeText(email).toLowerCase(),\n payroll_amount: roundMoney(amount),\n currency,\n ...extra,\n };\n}\n\nconst payrollRows = [];\nconst noAccountRows = [];\nconst supplementRows = [];\nconst ignoredRows = [];\nconst sheetSummaries = [];\n\nfunction addNormalizedRow(row, options = {}) {\n const amount = roundMoney(row.payroll_amount);\n\n if (amount <= 0) {\n ignoredRows.push({ ...row, reason: 'amount_zero_or_invalid' });\n return 'ignored';\n }\n\n if (amount > 150000) {\n ignoredRows.push({ ...row, reason: 'suspicious_large_amount' });\n return 'ignored';\n }\n\n if (isProbablyInvalidEmployeeName(row.employee_name)) {\n ignoredRows.push({ ...row, reason: 'invalid_employee_name' });\n return 'ignored';\n }\n\n if (options.supplementWithoutAccount) {\n supplementRows.push({ ...row, account: '' });\n return 'supplement';\n }\n\n if (!isValidCrossableAccount(row.account)) {\n noAccountRows.push({\n ...row,\n account: '',\n status: 'Pendiente revisión',\n observation: 'Tiene monto en nómina, pero no tiene cuenta bancaria válida para cruzar contra banco.',\n });\n return 'no_account';\n }\n\n payrollRows.push(row);\n return 'valid';\n}\n\nfunction addSheetSummary(sourceSheet, rawRows, validRows, noAccountCount, ignoredCount, supplementCount, totalAmount) {\n sheetSummaries.push({\n source_sheet: sourceSheet,\n raw_rows_count: rawRows,\n valid_rows_count: validRows,\n no_account_rows_count: noAccountCount,\n supplement_rows_count: supplementCount,\n ignored_rows_count: ignoredCount,\n total_amount: roundMoney(totalAmount),\n });\n}\n\nfunction processStandardSheet(config) {\n const rows = getNodeRows(config.nodeName);\n let validRows = 0;\n let noAccountCount = 0;\n let ignoredCount = 0;\n let sheetTotal = 0;\n\n rows.forEach((row, index) => {\n const employeeName = normalizeText(getValue(row, config.nameKeys));\n const account = normalizeAccount(getValue(row, config.accountKeys));\n const email = normalizeText(getValue(row, config.emailKeys || [])).toLowerCase();\n const amount = roundMoney(parseMoney(getValue(row, config.amountKeys)));\n const currencyValue = normalizeText(getValue(row, config.currencyKeys || []));\n const currency = normalizeForCompare(currencyValue).includes('dolar') || currencyValue.toUpperCase().includes('USD')\n ? 'USD'\n : 'QTZ';\n\n const normalizedRow = buildRow({\n sourceSheet: config.sourceSheet,\n rowNumber: index + 2,\n employeeName,\n account,\n email,\n amount,\n currency,\n });\n\n const result = addNormalizedRow(normalizedRow);\n if (result === 'valid') validRows += 1;\n else if (result === 'no_account') noAccountCount += 1;\n else ignoredCount += 1;\n\n if (result === 'valid' || result === 'no_account') {\n sheetTotal = roundMoney(sheetTotal + amount);\n }\n });\n\n addSheetSummary(config.sourceSheet, rows.length, validRows, noAccountCount, ignoredCount, 0, sheetTotal);\n}\n\nfunction valuesFromRow(row) {\n // Los Extract configurados con Header Row desactivado devuelven cada fila\n // dentro de una propiedad `row` como arreglo posicional.\n // Si usamos Object.values(row), obtenemos un arreglo anidado y el\n // normalizador no puede detectar cuenta, nombre, ID ni monto.\n const rawValues = Array.isArray(row?.row)\n ? row.row\n : Object.values(row || {});\n\n return rawValues.filter((value) => {\n return value !== null &&\n value !== undefined &&\n normalizeText(value) !== '';\n });\n}\n\nfunction looksLikeAccount(value) {\n const account = normalizeAccount(value);\n return account.length >= 6 && account.length <= 14;\n}\n\nfunction looksLikeMoney(value) {\n const amount = roundMoney(parseMoney(value));\n return amount > 0 && amount <= 150000;\n}\n\nfunction looksLikeName(value) {\n const text = normalizeText(value);\n if (!text || /\\d/.test(text)) return false;\n return !isProbablyInvalidEmployeeName(text);\n}\n\nfunction findNameAfter(values, startIndex) {\n for (let i = Math.max(0, startIndex); i < values.length; i++) {\n if (looksLikeName(values[i])) return { value: values[i], index: i };\n }\n return { value: '', index: -1 };\n}\n\nfunction processPositionalSheet(config) {\n const rows = getNodeRows(config.nodeName);\n let validRows = 0;\n let noAccountCount = 0;\n let ignoredCount = 0;\n let supplementCount = 0;\n let sheetTotal = 0;\n\n rows.forEach((row, index) => {\n const values = valuesFromRow(row);\n const rowText = normalizeForCompare(values.join(' '));\n\n if (config.excludeIfContains?.some((token) => rowText.includes(normalizeForCompare(token)))) {\n ignoredCount += 1;\n return;\n }\n\n let employeeName = '';\n let account = '';\n let email = '';\n let amount = 0;\n let supplementWithoutAccount = false;\n let extra = {};\n\n if (config.sourceSheet === 'Temporales WMC') {\n const accountIndex = values.findIndex(looksLikeAccount);\n account = accountIndex >= 0 ? normalizeAccount(values[accountIndex]) : '';\n const foundName = findNameAfter(values, accountIndex + 1);\n employeeName = foundName.value;\n\n const moneyCandidates = values\n .slice(foundName.index + 1)\n .map((value) => roundMoney(parseMoney(value)))\n .filter((candidate) => candidate > 0 && candidate <= 150000);\n\n amount = moneyCandidates.length ? moneyCandidates[moneyCandidates.length - 1] : 0;\n } else if (config.sourceSheet === '6)Viaticos PMI') {\n const accountIndex = values.findIndex(looksLikeAccount);\n account = accountIndex >= 0 ? normalizeAccount(values[accountIndex]) : '';\n employeeName = findNameAfter(values, accountIndex + 1).value;\n const moneyCandidates = values\n .slice(accountIndex + 1)\n .map((value) => roundMoney(parseMoney(value)))\n .filter((candidate) => candidate > 0 && candidate <= 150000);\n amount = moneyCandidates.length ? moneyCandidates[moneyCandidates.length - 1] : 0;\n } else if (config.sourceSheet === '7) Combustible Purina') {\n const accountIndex = values.findIndex(looksLikeAccount);\n account = accountIndex >= 0 ? normalizeAccount(values[accountIndex]) : '';\n\n for (let i = accountIndex - 1; i >= 0; i--) {\n if (looksLikeName(values[i])) {\n employeeName = normalizeText(values[i]);\n break;\n }\n }\n\n for (let i = accountIndex - 1; i >= 0; i--) {\n const candidate = roundMoney(parseMoney(values[i]));\n if (candidate > 0 && candidate <= 150000) {\n amount = candidate;\n break;\n }\n }\n } else if (config.sourceSheet === '8) Combustible P&G') {\n const accountIndex = values.findIndex(looksLikeAccount);\n account = accountIndex >= 0 ? normalizeAccount(values[accountIndex]) : '';\n const foundName = findNameAfter(values, accountIndex + 1);\n employeeName = foundName.value;\n\n for (let i = foundName.index + 1; i < values.length; i++) {\n const candidate = roundMoney(parseMoney(values[i]));\n if (candidate > 0 && candidate <= 150000) {\n amount = candidate;\n break;\n }\n }\n } else if (config.sourceSheet === '9) Combustibles Liquidables') {\n account = normalizeAccount(values[0]);\n employeeName = normalizeText(values[2]);\n email = normalizeText(values[6]).toLowerCase();\n const moneyCandidates = values\n .map((value) => roundMoney(parseMoney(value)))\n .filter((candidate) => candidate > 0 && candidate <= 150000);\n amount = moneyCandidates.length ? moneyCandidates[moneyCandidates.length - 1] : 0;\n } else if (\n config.sourceSheet === 'Combustibles PMI' ||\n config.sourceSheet === 'Combustible Tarjeta Motorola'\n ) {\n const idIndex = values.findIndex((value) => {\n const normalized = normalizeForCompare(value);\n return /^(spoc|xpert|moto)/.test(normalized);\n });\n\n // Opción B:\n // Solo procesar filas de la tabla principal que tengan un ID operativo\n // (SPOC / XPERT / MOTO). Esto excluye encabezados, totales y la lista\n // auxiliar/duplicada que aparece debajo de la tabla principal.\n if (idIndex < 0) {\n ignoredCount += 1;\n return;\n }\n\n const foundName = findNameAfter(values, idIndex + 1);\n employeeName = foundName.value;\n\n // Tomar únicamente el primer monto positivo después del nombre:\n // corresponde a la columna Asignado de la tabla principal.\n for (let i = foundName.index + 1; i < values.length; i++) {\n const candidate = roundMoney(parseMoney(values[i]));\n if (candidate > 0 && candidate <= 150000) {\n amount = candidate;\n break;\n }\n }\n\n supplementWithoutAccount = true;\n extra = {\n supplement_id: idIndex >= 0 ? normalizeText(values[idIndex]) : '',\n supplement_original_name: employeeName,\n };\n }\n\n const normalizedRow = buildRow({\n sourceSheet: config.sourceSheet,\n rowNumber: index + 1,\n employeeName,\n account,\n email,\n amount,\n extra,\n });\n\n const result = addNormalizedRow(normalizedRow, { supplementWithoutAccount });\n if (result === 'valid') validRows += 1;\n else if (result === 'no_account') noAccountCount += 1;\n else if (result === 'supplement') supplementCount += 1;\n else ignoredCount += 1;\n\n if (result !== 'ignored') sheetTotal = roundMoney(sheetTotal + amount);\n });\n\n addSheetSummary(\n config.sourceSheet,\n rows.length,\n validRows,\n noAccountCount,\n ignoredCount,\n supplementCount,\n sheetTotal\n );\n}\n\nconst standardSheetConfigs = [\n {\n nodeName: 'Extract - Nomina General',\n sourceSheet: '1) Nomina General',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE COMPLETO', 'Nombre Completo', 'NOMBRE', 'Nombre'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['NETO A PAGAR', 'Neto a Pagar', 'NETO', 'Neto'],\n currencyKeys: ['Moneda', 'MONEDA'],\n },\n {\n nodeName: 'Extract - Temporales',\n sourceSheet: '2) Temporales',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE', 'Nombre', 'NOMBRE COMPLETO', 'Nombre Completo'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['NETO A PAGAR', 'Neto a Pagar', 'NETO', 'Neto'],\n },\n {\n nodeName: 'Extract - Auditorias',\n sourceSheet: '3) Auditorias',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE COMPLETO', 'Nombre Completo', 'NOMBRE', 'Nombre'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['NETO A PAGAR', 'Neto a Pagar', 'NETO', 'Neto'],\n currencyKeys: ['Moneda', 'MONEDA'],\n },\n {\n nodeName: 'Extract - Bono Mariana',\n sourceSheet: '4) Bono Mariana',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE COMPLETO', 'Nombre Completo', 'NOMBRE', 'Nombre'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['NETO A PAGAR', 'Neto a Pagar', 'NETO', 'Neto', 'MONTO', 'Monto'],\n },\n {\n nodeName: 'Extract - Movilidad WP',\n sourceSheet: '5) Movilidad WP',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE', 'Nombre', 'NOMBRE COMPLETO', 'Nombre Completo'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['NETO A PAGAR', 'Neto a Pagar', 'NETO', 'Neto'],\n },\n {\n nodeName: 'Extract - Mot Variable Abril',\n sourceSheet: 'Mot Variable Abril',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE COMPLETO', 'Nombre Completo', 'NOMBRE', 'Nombre', 'Empleado', 'EMPLEADO'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['MONTO A PAGAR', 'Monto a pagar', 'NETO A PAGAR', 'Neto a Pagar', 'MONTO NETO', 'Monto Neto', 'VALOR A PAGAR', 'Valor a pagar'],\n },\n];\n\nconst positionalSheetConfigs = [\n { nodeName: 'Extract - Temporales WMC', sourceSheet: 'Temporales WMC' },\n { nodeName: 'Extract - Viaticos PMI', sourceSheet: '6)Viaticos PMI' },\n { nodeName: 'Extract - Combustible Purina', sourceSheet: '7) Combustible Purina' },\n { nodeName: 'Extract - Combustible PG', sourceSheet: '8) Combustible P&G' },\n { nodeName: 'Extract - Combustibles Liquidables', sourceSheet: '9) Combustibles Liquidables' },\n];\n\nfor (const config of standardSheetConfigs) processStandardSheet(config);\nfor (const config of positionalSheetConfigs) processPositionalSheet(config);\n\nfunction nameWords(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 for (let j = 1; j <= b.length; j++) {\n const cost = a[i - 1] === b[j - 1] ? 0 : 1;\n current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, previous[j - 1] + cost);\n }\n 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 nameMatchScore(a, b) {\n const wordsA = nameWords(a);\n const wordsB = nameWords(b);\n if (!wordsA.length || !wordsB.length) return 0;\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 ratio = matches / Math.min(wordsA.length, wordsB.length);\n const firstTokenBonus = tokenMatches(wordsA[0], wordsB[0]) ? 0.15 : 0;\n return ratio + firstTokenBonus;\n}\n\nconst accountNameMap = new Map();\nfor (const row of payrollRows) {\n const current = accountNameMap.get(row.account) || {\n account: row.account,\n employee_name: row.employee_name,\n currency: row.currency,\n };\n accountNameMap.set(row.account, current);\n}\n\nconst canonicalPayrollPeople = Array.from(accountNameMap.values());\nconst attachedSupplements = [];\nconst unattachedSupplements = [];\n\nfor (const supplement of supplementRows) {\n const scored = canonicalPayrollPeople\n .map((candidate) => ({\n ...candidate,\n score: nameMatchScore(supplement.employee_name, candidate.employee_name),\n }))\n .sort((a, b) => b.score - a.score);\n\n const top = scored[0];\n const second = scored[1];\n const supplementTokens = nameWords(supplement.employee_name);\n const firstToken = supplementTokens[0] || '';\n const sameFirstTokenCandidates = canonicalPayrollPeople.filter((candidate) => {\n const candidateFirst = nameWords(candidate.employee_name)[0] || '';\n return firstToken && candidateFirst === firstToken;\n });\n\n const confidentByScore = top && top.score >= 0.75 && (!second || top.score - second.score >= 0.05 || top.score >= 1);\n const confidentShortUniqueFirstName = top && supplementTokens.length <= 2 && top.score >= 0.6 && sameFirstTokenCandidates.length === 1;\n\n if (confidentByScore || confidentShortUniqueFirstName) {\n const attached = {\n ...supplement,\n account: top.account,\n employee_name: top.employee_name,\n currency: top.currency || supplement.currency || 'QTZ',\n supplement_original_name: supplement.employee_name,\n supplement_match_score: roundMoney(top.score),\n attached_by_name: true,\n };\n\n // Opción B condicional:\n // El suplemento queda vinculado al empleado y a su cuenta, pero NO se\n // suma todavía a la nómina base. El nodo de cruce decidirá si debe\n // aplicarse según el monto realmente pagado por el banco.\n attachedSupplements.push(attached);\n } else {\n unattachedSupplements.push({\n ...supplement,\n best_candidate: top?.employee_name || '',\n best_score: roundMoney(top?.score || 0),\n });\n\n ignoredRows.push({\n ...supplement,\n reason: 'supplement_without_unique_payroll_match',\n best_candidate: top?.employee_name || '',\n best_score: roundMoney(top?.score || 0),\n });\n }\n}\n\nconst groupedMap = new Map();\n\nfor (const row of payrollRows) {\n const groupKey = `${row.account}:${row.currency || 'QTZ'}`;\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 || 'QTZ',\n payroll_amount: 0,\n rows_count: 0,\n source_sheets: new Set(),\n source_rows: [],\n };\n\n current.payroll_amount = roundMoney(current.payroll_amount + row.payroll_amount);\n current.rows_count += 1;\n if (!current.email && row.email) current.email = row.email;\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 amount: row.payroll_amount,\n supplement_original_name: row.supplement_original_name || '',\n attached_by_name: Boolean(row.attached_by_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 const currency = row.currency || 'QTZ';\n totalsByCurrency[currency] = roundMoney((totalsByCurrency[currency] || 0) + row.payroll_amount);\n}\n\nconst totalPayroll = roundMoney(\n payrollRows.reduce((sum, row) => sum + row.payroll_amount, 0) +\n noAccountRows.reduce((sum, row) => sum + row.payroll_amount, 0)\n);\n\nreturn [\n {\n json: {\n payroll: {\n source: 'template_guatemala_completo',\n sheets_count: standardSheetConfigs.length + positionalSheetConfigs.length,\n sheet_summaries: sheetSummaries,\n raw_rows_count: sheetSummaries.reduce((sum, sheet) => sum + sheet.raw_rows_count, 0),\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 // Los suplementos potenciales no forman parte del total base\n // hasta que el banco confirme que hubo un pago adicional.\n attached_supplements_count: 0,\n potential_supplements_count: attachedSupplements.length,\n potential_supplements: attachedSupplements,\n unattached_supplements_count: unattachedSupplements.length,\n total_amount: totalPayroll,\n totals_by_currency: totalsByCurrency,\n rows: payrollRows,\n no_account_rows: noAccountRows,\n grouped_by_account: groupedByAccount,\n },\n debug_payroll: {\n // Se conserva attached_supplements por compatibilidad con las\n // revisiones anteriores, pero ahora representa suplementos\n // potenciales vinculados, todavía no aplicados.\n attached_supplements: attachedSupplements,\n potential_supplements: attachedSupplements,\n unattached_supplements: unattachedSupplements,\n ignored_rows_preview: ignoredRows.slice(0, 100),\n no_account_rows_preview: noAccountRows.slice(0, 50),\n },\n },\n },\n];\n"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
19872,
|
||
27120
|
||
],
|
||
"id": "44044451-fb64-44a9-8cd0-109b64855806",
|
||
"name": "Normalizar Nómina Completa"
|
||
},
|
||
{
|
||
"parameters": {},
|
||
"type": "n8n-nodes-base.merge",
|
||
"typeVersion": 3.2,
|
||
"position": [
|
||
16672,
|
||
25648
|
||
],
|
||
"id": "5e8d1212-8780-40c0-ac54-eb65141bd71f",
|
||
"name": "Merge Hojas 01-02"
|
||
},
|
||
{
|
||
"parameters": {},
|
||
"type": "n8n-nodes-base.merge",
|
||
"typeVersion": 3.2,
|
||
"position": [
|
||
16864,
|
||
25856
|
||
],
|
||
"id": "fabe0277-9275-4bb9-a4e4-606fc7e7a6ad",
|
||
"name": "Merge Hojas 03"
|
||
},
|
||
{
|
||
"parameters": {},
|
||
"type": "n8n-nodes-base.merge",
|
||
"typeVersion": 3.2,
|
||
"position": [
|
||
17152,
|
||
25984
|
||
],
|
||
"id": "9730a653-c2b3-49bf-890f-56f2a6ba2567",
|
||
"name": "Merge Hojas 04"
|
||
},
|
||
{
|
||
"parameters": {},
|
||
"type": "n8n-nodes-base.merge",
|
||
"typeVersion": 3.2,
|
||
"position": [
|
||
17568,
|
||
26112
|
||
],
|
||
"id": "e1672eb2-31e0-4040-9971-c3eb99972e3f",
|
||
"name": "Merge Hojas 05"
|
||
},
|
||
{
|
||
"parameters": {},
|
||
"type": "n8n-nodes-base.merge",
|
||
"typeVersion": 3.2,
|
||
"position": [
|
||
17872,
|
||
26256
|
||
],
|
||
"id": "2906d58d-35b0-4112-a709-8b3097fd2537",
|
||
"name": "Merge Hojas 06"
|
||
},
|
||
{
|
||
"parameters": {},
|
||
"type": "n8n-nodes-base.merge",
|
||
"typeVersion": 3.2,
|
||
"position": [
|
||
18208,
|
||
26528
|
||
],
|
||
"id": "2b809028-6720-49c0-9a9c-f14cffa78c14",
|
||
"name": "Merge Hojas 07"
|
||
},
|
||
{
|
||
"parameters": {},
|
||
"type": "n8n-nodes-base.merge",
|
||
"typeVersion": 3.2,
|
||
"position": [
|
||
18464,
|
||
26656
|
||
],
|
||
"id": "652101ad-34b6-4893-8e83-683ad7e78fd4",
|
||
"name": "Merge Hojas 08"
|
||
},
|
||
{
|
||
"parameters": {},
|
||
"type": "n8n-nodes-base.merge",
|
||
"typeVersion": 3.2,
|
||
"position": [
|
||
18768,
|
||
27040
|
||
],
|
||
"id": "3af433bb-32fe-4c06-bea7-d00e9fea9c95",
|
||
"name": "Merge Hojas 09"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "xlsx",
|
||
"binaryPropertyName": "payroll_file",
|
||
"options": {
|
||
"headerRow": true,
|
||
"sheetName": "Mot Variable Abril"
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.extractFromFile",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
15664,
|
||
27648
|
||
],
|
||
"id": "f8d6e3d6-7448-4a54-99e6-eec7f4fdec7b",
|
||
"name": "Extract - Mot Variable Abril",
|
||
"retryOnFail": false,
|
||
"onError": "continueRegularOutput"
|
||
},
|
||
{
|
||
"parameters": {},
|
||
"type": "n8n-nodes-base.merge",
|
||
"typeVersion": 3.2,
|
||
"position": [
|
||
19072,
|
||
27152
|
||
],
|
||
"id": "4672d0c6-3116-42b5-b1f3-d7807b5313e1",
|
||
"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 readableCategory(value) {\n const map = {\n coincidencia: 'Coincidencia',\n discrepancia: 'Discrepancia',\n posible_cuenta_mal_digitada: 'Posible cuenta mal digitada',\n banco_sin_nomina: 'Banco sin nómina',\n banco_sin_bamboo: 'Banco sin Bamboo',\n nomina_sin_cuenta: 'Nómina sin cuenta',\n diferencia_nombre_banco: 'Diferencia nombre banco',\n };\n\n return map[value] || normalizeText(value).replace(/_/g, ' ');\n}\n\nfunction firstValue(value) {\n if (Array.isArray(value)) {\n return value.filter(Boolean).join(' / ');\n }\n\n return normalizeText(value);\n}\n\nfunction formatPeriodEnd(value) {\n const raw = normalizeText(value);\n\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(raw)) {\n return raw;\n }\n\n const [year, month, day] = raw.split('-');\n const monthNames = {\n '01': 'ene',\n '02': 'feb',\n '03': 'mar',\n '04': 'abr',\n '05': 'may',\n '06': 'jun',\n '07': 'jul',\n '08': 'ago',\n '09': 'sep',\n '10': 'oct',\n '11': 'nov',\n '12': 'dic',\n };\n\n return `${day}-${monthNames[month] || month}-${year}`;\n}\n\nfunction mainReportSense(row, difference) {\n const category = normalizeText(row.category).toLowerCase();\n const subcategory = normalizeText(row.subcategory).toLowerCase();\n\n if (category === 'posible_cuenta_mal_digitada') {\n return 'Revisar cuenta';\n }\n\n if (\n subcategory === 'nomina_con_cuenta_sin_pago_banco' ||\n (Number(row.bank_amount ?? row.bankAmount ?? 0) === 0 &&\n Number(row.payroll_amount ?? row.payrollAmount ?? 0) > 0)\n ) {\n return 'No aparece pagado en banco';\n }\n\n if (difference > 0) {\n return 'Se pagó de menos';\n }\n\n if (difference < 0) {\n return 'Se pagó de más';\n }\n\n return 'Revisar';\n}\n\nconst metadata = data.metadata || {};\nconst summary = data.summary || {};\nconst rows = Array.isArray(data.rows) ? data.rows : [];\nconst bankWithoutBamboo = Array.isArray(data.bankWithoutBamboo)\n ? data.bankWithoutBamboo\n : [];\n\nconst periodLabel =\n metadata.period_label ||\n `${metadata.year || ''}-${metadata.month || ''}-${metadata.period_type || ''}`;\n\nconst spreadsheetTitle =\n `Cruce de Cuentas GLM GT - ${periodLabel}`;\n\nconst mainReportSubtitle =\n `Diferencias de Monto Nómina vs. Banco · Guatemala · ${\n formatPeriodEnd(metadata.period_end || '')\n }`;\n\nconst bancoSinBambooSubtitle =\n `Pagos en banco sin empleado identificado en BambooHR · Guatemala · ${\n formatPeriodEnd(metadata.period_end || '')\n }`;\n\nconst cuentaMalDigitadaSubtitle =\n `Cuenta Mal Digitada en Nómina · Guatemala · ${\n formatPeriodEnd(metadata.period_end || '')\n }`;\n\nconst cuentaMalDigitadaCases = rows.filter(\n (row) => row.category === 'posible_cuenta_mal_digitada'\n);\n\nconst hasCuentaMalDigitada =\n cuentaMalDigitadaCases.length > 0;\n\nconst sheetIds = {\n nominaVsBanco: 101,\n bancoSinNomina: 102,\n bancoSinBamboo: 103,\n diferenciasNombreBanco: 104,\n cuentaMalDigitada: 105,\n resumen: 106,\n};\n\nconst sheetTitles = {\n nominaVsBanco: '01 Nómina vs Banco',\n bancoSinNomina: '02 Banco sin Nómina',\n bancoSinBamboo: '03 Banco sin Bamboo',\n diferenciasNombreBanco: '04 Diferencias nombre banco',\n cuentaMalDigitada: '05 Cuenta Mal Digitada',\n resumen: hasCuentaMalDigitada\n ? '06 Resumen'\n : '05 Resumen',\n};\n\nconst nominaVsBancoHeader = [\n '#',\n 'Empleado',\n 'Cuenta',\n 'Monto en Nómina (Q)',\n 'Monto en Banco (Q)',\n 'Diferencia (Q)',\n 'Sentido',\n 'Estado',\n 'Resolución',\n];\n\nconst mainReportRows = rows\n .filter((row) => row.category === 'discrepancia')\n .map((row, index) => {\n const payrollAmount = roundMoney(\n row.payroll_amount ?? row.payrollAmount ?? 0\n );\n const bankAmount = roundMoney(\n row.bank_amount ?? row.bankAmount ?? 0\n );\n const difference = roundMoney(\n row.difference ?? (payrollAmount - bankAmount)\n );\n\n return [\n index + 1,\n normalizeText(row.employee_name || row.employee || ''),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.payroll_account ||\n row.payrollAccount ||\n row.account ||\n ''\n ),\n payrollAmount,\n bankAmount,\n difference,\n mainReportSense(row, difference),\n normalizeText(row.status || 'Riesgo').toUpperCase(),\n '',\n ];\n });\n\nconst nominaVsBancoValues = [\n [\n 'GOMEZLEE MARKETING',\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n ],\n [\n mainReportSubtitle,\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n ],\n ['', '', '', '', '', '', '', '', ''],\n nominaVsBancoHeader,\n ...mainReportRows,\n];\n\nconst bancoSinNominaHeader = [\n 'Empleado Banco',\n 'Cuenta Banco',\n 'Moneda',\n 'Monto Banco',\n 'Estado',\n 'Observación',\n 'Resolución',\n];\n\nconst bancoSinNominaRows = rows\n .filter((row) => row.category === 'banco_sin_nomina')\n .map((row) => [\n normalizeText(row.employee_name || row.employee || ''),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n normalizeText(row.currency || 'QTZ'),\n roundMoney(row.bank_amount ?? row.bankAmount ?? 0),\n normalizeText(row.status || ''),\n normalizeText(row.observation || ''),\n '',\n ]);\n\nconst bancoSinBambooHeader = [\n '#',\n 'Nombre en banco',\n 'Nombre del cuentahabiente',\n 'Cuenta',\n 'Monto en banco (Q)',\n 'Número de envío',\n 'Estado',\n 'Resolución',\n];\n\nconst bancoSinBambooRows = bankWithoutBamboo.map((row, index) => [\n index + 1,\n normalizeText(row.bank_name_file || row.employee_name || ''),\n normalizeText(row.bank_account_holder || ''),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n roundMoney(row.bank_amount ?? row.bankAmount ?? 0),\n firstValue(row.shipment_numbers || row.shipment_number || ''),\n 'PENDIENTE REVISIÓN',\n '',\n]);\n\nconst bancoSinBambooValues = [\n [\n 'GOMEZLEE MARKETING',\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n ],\n [\n bancoSinBambooSubtitle,\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n ],\n ['', '', '', '', '', '', '', ''],\n bancoSinBambooHeader,\n ...bancoSinBambooRows,\n];\n\nconst diferenciasNombreHeader = [\n 'Nombre en Archivo',\n 'Nombre del Cuentahabiente',\n 'Cuenta Destino',\n 'Moneda',\n 'Monto',\n 'Número de envío',\n 'Número de plan',\n 'Archivo',\n 'Estado',\n 'Observación',\n 'Resolución',\n];\n\nconst diferenciasNombreRows = rows\n .filter((row) => row.category === 'diferencia_nombre_banco')\n .map((row) => [\n normalizeText(row.bank_name_file || row.employee_name || ''),\n normalizeText(row.bank_account_holder || ''),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n normalizeText(row.currency || 'QTZ'),\n roundMoney(row.bank_amount ?? row.bankAmount ?? 0),\n normalizeText(row.shipment_number || ''),\n normalizeText(row.plan_number || ''),\n normalizeText(row.source_file || ''),\n normalizeText(row.status || ''),\n normalizeText(row.observation || ''),\n '',\n ]);\n\n\nfunction accountValues(value) {\n const values = Array.isArray(value)\n ? value\n : String(value ?? '')\n .split(/\\s*(?:\\/|;|,|\\by\\b)\\s*/i);\n\n return values\n .map((item) =>\n String(item ?? '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim()\n )\n .filter((item) =>\n item.length >= 7 &&\n !/^0+$/.test(item)\n );\n}\n\nfunction payrollAccountsForWrongAccount(row) {\n const candidates = [\n row.payroll_account,\n row.payrollAccount,\n ...(Array.isArray(row.source_rows)\n ? row.source_rows.flatMap((sourceRow) => [\n sourceRow.account,\n sourceRow.payroll_account,\n sourceRow.payrollAccount,\n ])\n : []),\n ];\n\n return Array.from(new Set(\n candidates.flatMap(accountValues)\n ));\n}\n\nfunction bankAccountForWrongAccount(row) {\n return firstValue(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n );\n}\n\nfunction wrongAccountTotalStatus(row) {\n const payrollAmount = roundMoney(\n row.payroll_amount ?? row.payrollAmount ?? 0\n );\n const bankAmount = roundMoney(\n row.bank_amount ?? row.bankAmount ?? 0\n );\n const difference = roundMoney(\n payrollAmount - bankAmount\n );\n\n if (Math.abs(difference) <= 0.02) {\n return 'El total de nómina coincide con el total del banco.';\n }\n\n if (difference > 0) {\n return (\n `El total de nómina supera el total del banco por ` +\n `Q${Math.abs(difference).toLocaleString('en-US', {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n })}.`\n );\n }\n\n return (\n `El total pagado por el banco supera el total de nómina por ` +\n `Q${Math.abs(difference).toLocaleString('en-US', {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n })}.`\n );\n}\n\nfunction wrongAccountFinding(row) {\n const existing = normalizeText(row.observation || '');\n\n if (existing) {\n return existing;\n }\n\n const payrollAccounts =\n payrollAccountsForWrongAccount(row);\n const bankAccount =\n bankAccountForWrongAccount(row);\n\n return (\n `El empleado presenta una posible inconsistencia entre ` +\n `la cuenta registrada en nómina ` +\n `(${payrollAccounts.join(' y ') || 'sin cuenta identificada'}) ` +\n `y la cuenta utilizada por el banco ` +\n `(${bankAccount || 'sin cuenta identificada'}).`\n );\n}\n\nconst cuentaMalDigitadaHeader = [\n '#',\n 'Campo',\n 'Detalle',\n 'Resolución',\n];\n\nconst cuentaMalDigitadaRows = [];\n\nfor (\n let index = 0;\n index < cuentaMalDigitadaCases.length;\n index++\n) {\n const row = cuentaMalDigitadaCases[index];\n const payrollAccounts =\n payrollAccountsForWrongAccount(row);\n const bankAccount =\n bankAccountForWrongAccount(row);\n\n const fields = [\n [\n 'Empleado',\n normalizeText(\n row.employee_name ||\n row.employee ||\n ''\n ),\n ],\n [\n 'Cuentas registradas en las hojas de nómina',\n payrollAccounts.join(' y ') ||\n 'No se identificó una cuenta válida en la nómina.',\n ],\n [\n 'Cuenta utilizada por el banco',\n bankAccount ||\n 'No se identificó una cuenta válida en el banco.',\n ],\n [\n 'Estado del total',\n wrongAccountTotalStatus(row),\n ],\n [\n 'Hallazgo',\n wrongAccountFinding(row),\n ],\n [\n 'Clasificación',\n 'Posible cuenta mal digitada — revisar y unificar la cuenta en las hojas de nómina.',\n ],\n ];\n\n fields.forEach((field, fieldIndex) => {\n cuentaMalDigitadaRows.push([\n fieldIndex === 0 ? index + 1 : '',\n field[0],\n field[1],\n '',\n ]);\n });\n}\n\nconst cuentaMalDigitadaValues = [\n [\n 'GOMEZLEE MARKETING',\n '',\n '',\n '',\n ],\n [\n cuentaMalDigitadaSubtitle,\n '',\n '',\n '',\n ],\n ['', '', '', ''],\n cuentaMalDigitadaHeader,\n ...cuentaMalDigitadaRows,\n];\n\nconst resumenHeader = ['Indicador', 'Valor'];\nconst resumenRows = [\n ['Período', periodLabel],\n ['Coincidencias', Number(summary.coincidencias || 0)],\n ['Discrepancias', Number(summary.discrepancias || 0)],\n ['Banco sin nómina', Number(summary.bancoSinNomina || 0)],\n ['Banco sin Bamboo', Number(summary.bancoSinBamboo || 0)],\n ['Nómina sin cuenta', Number(summary.nominaSinCuenta || 0)],\n [\n 'Diferencias nombre banco',\n Number(summary.diferenciasNombreBanco || 0),\n ],\n [\n 'Posibles cuentas mal digitadas',\n Number(summary.posiblesCuentasMalDigitadas || 0),\n ],\n ['Pendientes cruce principal', Number(summary.pendientes || 0)],\n [\n 'Empleados BambooHR Guatemala',\n Number(summary.empleadosBambooGT || 0),\n ],\n [\n 'Empleados BambooHR en el período',\n Number(summary.empleadosBambooEnPeriodo || 0),\n ],\n ['Filas válidas de nómina', Number(summary.filasNominaValidas || 0)],\n [\n 'Suplementos adjuntados',\n Number(summary.suplementosNominaAdjuntados || 0),\n ],\n [\n 'Suplementos sin coincidencia',\n Number(summary.suplementosNominaNoAdjuntados || 0),\n ],\n ['Transacciones bancarias', Number(summary.transaccionesBanco || 0)],\n ['Total nómina', roundMoney(summary.totalNomina || 0)],\n ['Total banco', roundMoney(summary.totalBanco || 0)],\n ['Diferencia total', roundMoney(summary.diferenciaTotal || 0)],\n];\n\nfunction buildSheetValues(header, bodyRows) {\n return [header, ...bodyRows];\n}\n\nconst valueData = [\n {\n range: `'${sheetTitles.nominaVsBanco}'!A1:I`,\n values: nominaVsBancoValues,\n },\n {\n range: `'${sheetTitles.bancoSinNomina}'!A1:G`,\n values: buildSheetValues(\n bancoSinNominaHeader,\n bancoSinNominaRows\n ),\n },\n {\n range: `'${sheetTitles.bancoSinBamboo}'!A1:H`,\n values: bancoSinBambooValues,\n },\n {\n range: `'${sheetTitles.diferenciasNombreBanco}'!A1:K`,\n values: buildSheetValues(\n diferenciasNombreHeader,\n diferenciasNombreRows\n ),\n },\n ...(hasCuentaMalDigitada\n ? [\n {\n range:\n `'${sheetTitles.cuentaMalDigitada}'!A1:D`,\n values: cuentaMalDigitadaValues,\n },\n ]\n : []),\n {\n range: `'${sheetTitles.resumen}'!A1:B`,\n values: buildSheetValues(\n resumenHeader,\n resumenRows\n ),\n },\n];\n\nfunction headerFormatRequest(\n sheetId,\n endColumnIndex,\n startRowIndex = 0\n) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex: startRowIndex + 1,\n startColumnIndex: 0,\n endColumnIndex,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: {\n red: 0.29,\n green: 0.49,\n blue: 0.58,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 1,\n green: 1,\n blue: 1,\n },\n },\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n },\n },\n fields:\n 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment,verticalAlignment,wrapStrategy)',\n },\n };\n}\n\nfunction freezeHeaderRequest(sheetId, frozenRowCount = 1) {\n return {\n updateSheetProperties: {\n properties: {\n sheetId,\n gridProperties: {\n frozenRowCount,\n },\n },\n fields: 'gridProperties.frozenRowCount',\n },\n };\n}\n\nfunction autoResizeRequest(sheetId, endColumnIndex) {\n return {\n autoResizeDimensions: {\n dimensions: {\n sheetId,\n dimension: 'COLUMNS',\n startIndex: 0,\n endIndex: endColumnIndex,\n },\n },\n };\n}\n\nfunction moneyFormatRequest(\n sheetId,\n startColumnIndex,\n endColumnIndex,\n startRowIndex = 1,\n pattern = '#,##0.00',\n endRowIndex = null\n) {\n const range = {\n sheetId,\n startRowIndex,\n startColumnIndex,\n endColumnIndex,\n };\n\n if (Number.isInteger(endRowIndex)) {\n range.endRowIndex = endRowIndex;\n }\n\n return {\n repeatCell: {\n range,\n cell: {\n userEnteredFormat: {\n numberFormat: {\n type: 'NUMBER',\n pattern,\n },\n },\n },\n fields: 'userEnteredFormat.numberFormat',\n },\n };\n}\n\nfunction basicFilterRequest(\n sheetId,\n endColumnIndex,\n startRowIndex = 0,\n endRowIndex = null\n) {\n const range = {\n sheetId,\n startRowIndex,\n startColumnIndex: 0,\n endColumnIndex,\n };\n\n if (Number.isInteger(endRowIndex)) {\n range.endRowIndex = endRowIndex;\n }\n\n return {\n setBasicFilter: {\n filter: {\n range,\n },\n },\n };\n}\n\nfunction mergeRowRequest(\n sheetId,\n rowIndex,\n endColumnIndex\n) {\n return {\n mergeCells: {\n range: {\n sheetId,\n startRowIndex: rowIndex,\n endRowIndex: rowIndex + 1,\n startColumnIndex: 0,\n endColumnIndex,\n },\n mergeType: 'MERGE_ALL',\n },\n };\n}\n\nfunction titleRowFormatRequest(\n sheetId,\n rowIndex,\n endColumnIndex,\n options = {}\n) {\n const {\n fontSize = 12,\n bold = true,\n italic = false,\n horizontalAlignment = 'LEFT',\n } = options;\n\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: rowIndex,\n endRowIndex: rowIndex + 1,\n startColumnIndex: 0,\n endColumnIndex,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: {\n red: 0.29,\n green: 0.49,\n blue: 0.58,\n },\n textFormat: {\n bold,\n italic,\n fontSize,\n foregroundColor: {\n red: 1,\n green: 1,\n blue: 1,\n },\n },\n horizontalAlignment,\n verticalAlignment: 'MIDDLE',\n },\n },\n fields:\n 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment,verticalAlignment)',\n },\n };\n}\n\nfunction columnWidthRequest(\n sheetId,\n startIndex,\n endIndex,\n pixelSize\n) {\n return {\n updateDimensionProperties: {\n range: {\n sheetId,\n dimension: 'COLUMNS',\n startIndex,\n endIndex,\n },\n properties: {\n pixelSize,\n },\n fields: 'pixelSize',\n },\n };\n}\n\nfunction rowHeightRequest(\n sheetId,\n startIndex,\n endIndex,\n pixelSize\n) {\n return {\n updateDimensionProperties: {\n range: {\n sheetId,\n dimension: 'ROWS',\n startIndex,\n endIndex,\n },\n properties: {\n pixelSize,\n },\n fields: 'pixelSize',\n },\n };\n}\n\nfunction bodyAlignmentRequest(\n sheetId,\n startColumnIndex,\n endColumnIndex,\n horizontalAlignment,\n endRowIndex\n) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: 4,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n },\n cell: {\n userEnteredFormat: {\n horizontalAlignment,\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n },\n },\n fields:\n 'userEnteredFormat(horizontalAlignment,verticalAlignment,wrapStrategy)',\n },\n };\n}\n\nfunction statusFormatRequest(sheetId, endRowIndex) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: 4,\n endRowIndex,\n startColumnIndex: 7,\n endColumnIndex: 8,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: {\n red: 1,\n green: 0.92,\n blue: 0.92,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.85,\n green: 0.08,\n blue: 0.08,\n },\n },\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n },\n },\n fields:\n 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment,verticalAlignment)',\n },\n };\n}\n\nfunction bambooStatusFormatRequest(\n sheetId,\n endRowIndex\n) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: 4,\n endRowIndex,\n startColumnIndex: 6,\n endColumnIndex: 7,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: {\n red: 1,\n green: 0.97,\n blue: 0.82,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.70,\n green: 0.30,\n blue: 0.00,\n },\n },\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n },\n },\n fields:\n 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment,verticalAlignment,wrapStrategy)',\n },\n };\n}\n\n\nfunction mergeVerticalRequest(\n sheetId,\n startRowIndex,\n endRowIndex,\n columnIndex\n) {\n return {\n mergeCells: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex: columnIndex,\n endColumnIndex: columnIndex + 1,\n },\n mergeType: 'MERGE_ALL',\n },\n };\n}\n\nfunction rangeFormatRequest(\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n userEnteredFormat\n) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n },\n cell: {\n userEnteredFormat,\n },\n fields:\n 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment,verticalAlignment,wrapStrategy)',\n },\n };\n}\n\nfunction conditionalDifferenceRequest(\n sheetId,\n formula,\n backgroundColor,\n textColor,\n ranges,\n index\n) {\n return {\n addConditionalFormatRule: {\n index,\n rule: {\n ranges,\n booleanRule: {\n condition: {\n type: 'CUSTOM_FORMULA',\n values: [\n {\n userEnteredValue: formula,\n },\n ],\n },\n format: {\n backgroundColor,\n textFormat: {\n bold: true,\n foregroundColor: textColor,\n },\n },\n },\n },\n },\n };\n}\n\nfunction borderRequest(\n sheetId,\n startRowIndex,\n endRowIndex,\n endColumnIndex\n) {\n const border = {\n style: 'SOLID',\n color: {\n red: 0.72,\n green: 0.82,\n blue: 0.76,\n },\n };\n\n return {\n updateBorders: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex: 0,\n endColumnIndex,\n },\n top: border,\n bottom: border,\n left: border,\n right: border,\n innerHorizontal: border,\n innerVertical: border,\n },\n };\n}\n\nconst mainReportEndRow = Math.max(\n 4 + mainReportRows.length,\n 4\n);\n\nconst bancoSinBambooEndRow = Math.max(\n 4 + bancoSinBambooRows.length,\n 4\n);\n\nconst mainConditionalRanges = mainReportRows.length\n ? [\n {\n sheetId: sheetIds.nominaVsBanco,\n startRowIndex: 4,\n endRowIndex: mainReportEndRow,\n startColumnIndex: 5,\n endColumnIndex: 7,\n },\n ]\n : [];\n\nconst mainDataFormatRequests = mainReportRows.length\n ? [\n moneyFormatRequest(\n sheetIds.nominaVsBanco,\n 3,\n 6,\n 4,\n 'Q#,##0.00;[Red](Q#,##0.00)',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 0,\n 1,\n 'CENTER',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 1,\n 2,\n 'LEFT',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 2,\n 3,\n 'CENTER',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 3,\n 6,\n 'RIGHT',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 6,\n 8,\n 'CENTER',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 8,\n 9,\n 'LEFT',\n mainReportEndRow\n ),\n statusFormatRequest(\n sheetIds.nominaVsBanco,\n mainReportEndRow\n ),\n conditionalDifferenceRequest(\n sheetIds.nominaVsBanco,\n '=$F5>0',\n {\n red: 1,\n green: 0.97,\n blue: 0.82,\n },\n {\n red: 0.25,\n green: 0.25,\n blue: 0.25,\n },\n mainConditionalRanges,\n 0\n ),\n conditionalDifferenceRequest(\n sheetIds.nominaVsBanco,\n '=$F5<0',\n {\n red: 1,\n green: 0.89,\n blue: 0.89,\n },\n {\n red: 0.9,\n green: 0.05,\n blue: 0.05,\n },\n mainConditionalRanges,\n 1\n ),\n rowHeightRequest(\n sheetIds.nominaVsBanco,\n 4,\n mainReportEndRow,\n 28\n ),\n ]\n : [];\n\n\nconst cuentaMalDigitadaEndRow = Math.max(\n 4 + cuentaMalDigitadaRows.length,\n 4\n);\n\nconst cuentaMalDigitadaCaseRequests =\n hasCuentaMalDigitada\n ? cuentaMalDigitadaCases.flatMap(\n (_, caseIndex) => {\n const startRowIndex =\n 4 + caseIndex * 6;\n const endRowIndex =\n startRowIndex + 6;\n\n return [\n mergeVerticalRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endRowIndex,\n 0\n ),\n mergeVerticalRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endRowIndex,\n 3\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n startRowIndex + 4,\n 34\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex + 4,\n startRowIndex + 5,\n 76\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex + 5,\n endRowIndex,\n 54\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex + 4,\n startRowIndex + 5,\n 2,\n 3,\n {\n backgroundColor: {\n red: 0.97,\n green: 0.97,\n blue: 0.97,\n },\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex + 5,\n endRowIndex,\n 2,\n 3,\n {\n backgroundColor: {\n red: 1,\n green: 0.97,\n blue: 0.82,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.55,\n green: 0.30,\n blue: 0.00,\n },\n },\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n ];\n }\n )\n : [];\n\nconst cuentaMalDigitadaFormatRequests =\n hasCuentaMalDigitada\n ? [\n mergeRowRequest(\n sheetIds.cuentaMalDigitada,\n 0,\n 4\n ),\n mergeRowRequest(\n sheetIds.cuentaMalDigitada,\n 1,\n 4\n ),\n titleRowFormatRequest(\n sheetIds.cuentaMalDigitada,\n 0,\n 4,\n {\n fontSize: 12,\n bold: true,\n italic: false,\n horizontalAlignment: 'LEFT',\n }\n ),\n titleRowFormatRequest(\n sheetIds.cuentaMalDigitada,\n 1,\n 4,\n {\n fontSize: 10,\n bold: false,\n italic: true,\n horizontalAlignment: 'LEFT',\n }\n ),\n headerFormatRequest(\n sheetIds.cuentaMalDigitada,\n 4,\n 3\n ),\n freezeHeaderRequest(\n sheetIds.cuentaMalDigitada,\n 4\n ),\n borderRequest(\n sheetIds.cuentaMalDigitada,\n 3,\n cuentaMalDigitadaEndRow,\n 4\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n 0,\n 1,\n 30\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n 1,\n 2,\n 26\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n 3,\n 4,\n 40\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n 4,\n cuentaMalDigitadaEndRow,\n 0,\n 1,\n {\n backgroundColor: {\n red: 0.91,\n green: 0.95,\n blue: 0.99,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.20,\n green: 0.36,\n blue: 0.45,\n },\n },\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n 4,\n cuentaMalDigitadaEndRow,\n 1,\n 2,\n {\n backgroundColor: {\n red: 0.93,\n green: 0.97,\n blue: 0.90,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.20,\n green: 0.36,\n blue: 0.45,\n },\n },\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n 4,\n cuentaMalDigitadaEndRow,\n 2,\n 3,\n {\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n 4,\n cuentaMalDigitadaEndRow,\n 3,\n 4,\n {\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 0,\n 1,\n 48\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 1,\n 2,\n 285\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 2,\n 3,\n 520\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 3,\n 4,\n 260\n ),\n ...cuentaMalDigitadaCaseRequests,\n ]\n : [];\n\n\nfunction wrapRangeRequest(\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex\n) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n },\n cell: {\n userEnteredFormat: {\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n },\n },\n fields:\n 'userEnteredFormat(verticalAlignment,wrapStrategy)',\n },\n };\n}\n\nfunction autoResizeRowsRequest(\n sheetId,\n startIndex,\n endIndex\n) {\n return {\n autoResizeDimensions: {\n dimensions: {\n sheetId,\n dimension: 'ROWS',\n startIndex,\n endIndex,\n },\n },\n };\n}\n\nconst formatRequests = [\n mergeRowRequest(sheetIds.nominaVsBanco, 0, 9),\n mergeRowRequest(sheetIds.nominaVsBanco, 1, 9),\n titleRowFormatRequest(\n sheetIds.nominaVsBanco,\n 0,\n 9,\n {\n fontSize: 12,\n bold: true,\n italic: false,\n horizontalAlignment: 'LEFT',\n }\n ),\n titleRowFormatRequest(\n sheetIds.nominaVsBanco,\n 1,\n 9,\n {\n fontSize: 10,\n bold: false,\n italic: true,\n horizontalAlignment: 'LEFT',\n }\n ),\n headerFormatRequest(\n sheetIds.nominaVsBanco,\n 9,\n 3\n ),\n freezeHeaderRequest(\n sheetIds.nominaVsBanco,\n 4\n ),\n basicFilterRequest(\n sheetIds.nominaVsBanco,\n 9,\n 3,\n mainReportEndRow\n ),\n borderRequest(\n sheetIds.nominaVsBanco,\n 3,\n mainReportEndRow,\n 9\n ),\n rowHeightRequest(\n sheetIds.nominaVsBanco,\n 0,\n 1,\n 30\n ),\n rowHeightRequest(\n sheetIds.nominaVsBanco,\n 1,\n 2,\n 26\n ),\n rowHeightRequest(\n sheetIds.nominaVsBanco,\n 3,\n 4,\n 42\n ),\n ...mainDataFormatRequests,\n\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 0,\n 1,\n 48\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 1,\n 2,\n 260\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 2,\n 3,\n 130\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 3,\n 6,\n 130\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 6,\n 7,\n 155\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 7,\n 8,\n 110\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 8,\n 9,\n 260\n ),\n\n headerFormatRequest(sheetIds.bancoSinNomina, 7),\n freezeHeaderRequest(sheetIds.bancoSinNomina),\n autoResizeRequest(sheetIds.bancoSinNomina, 7),\n moneyFormatRequest(sheetIds.bancoSinNomina, 3, 4),\n basicFilterRequest(sheetIds.bancoSinNomina, 7),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 6,\n 7,\n 260\n ),\n\n mergeRowRequest(sheetIds.bancoSinBamboo, 0, 8),\n mergeRowRequest(sheetIds.bancoSinBamboo, 1, 8),\n titleRowFormatRequest(\n sheetIds.bancoSinBamboo,\n 0,\n 8,\n {\n fontSize: 12,\n bold: true,\n italic: false,\n horizontalAlignment: 'LEFT',\n }\n ),\n titleRowFormatRequest(\n sheetIds.bancoSinBamboo,\n 1,\n 8,\n {\n fontSize: 10,\n bold: false,\n italic: true,\n horizontalAlignment: 'LEFT',\n }\n ),\n headerFormatRequest(\n sheetIds.bancoSinBamboo,\n 8,\n 3\n ),\n freezeHeaderRequest(\n sheetIds.bancoSinBamboo,\n 4\n ),\n basicFilterRequest(\n sheetIds.bancoSinBamboo,\n 8,\n 3,\n bancoSinBambooEndRow\n ),\n borderRequest(\n sheetIds.bancoSinBamboo,\n 3,\n bancoSinBambooEndRow,\n 8\n ),\n rowHeightRequest(\n sheetIds.bancoSinBamboo,\n 0,\n 1,\n 30\n ),\n rowHeightRequest(\n sheetIds.bancoSinBamboo,\n 1,\n 2,\n 26\n ),\n rowHeightRequest(\n sheetIds.bancoSinBamboo,\n 3,\n 4,\n 42\n ),\n moneyFormatRequest(\n sheetIds.bancoSinBamboo,\n 4,\n 5,\n 4,\n 'Q#,##0.00',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 0,\n 1,\n 'CENTER',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 1,\n 3,\n 'LEFT',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 3,\n 4,\n 'CENTER',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 4,\n 5,\n 'RIGHT',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 5,\n 7,\n 'CENTER',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 7,\n 8,\n 'LEFT',\n bancoSinBambooEndRow\n ),\n bambooStatusFormatRequest(\n sheetIds.bancoSinBamboo,\n bancoSinBambooEndRow\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 0,\n 1,\n 48\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 1,\n 2,\n 220\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 2,\n 3,\n 270\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 3,\n 4,\n 135\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 4,\n 5,\n 130\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 5,\n 6,\n 110\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 6,\n 7,\n 140\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 7,\n 8,\n 260\n ),\n\n headerFormatRequest(\n sheetIds.diferenciasNombreBanco,\n 11\n ),\n freezeHeaderRequest(\n sheetIds.diferenciasNombreBanco\n ),\n autoResizeRequest(\n sheetIds.diferenciasNombreBanco,\n 11\n ),\n moneyFormatRequest(\n sheetIds.diferenciasNombreBanco,\n 4,\n 5\n ),\n basicFilterRequest(\n sheetIds.diferenciasNombreBanco,\n 11\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 10,\n 11,\n 260\n ),\n\n ...cuentaMalDigitadaFormatRequests,\n\n headerFormatRequest(sheetIds.resumen, 2),\n freezeHeaderRequest(sheetIds.resumen),\n autoResizeRequest(sheetIds.resumen, 2),\n moneyFormatRequest(sheetIds.resumen, 1, 2),\n];\n\n\nconst bancoSinNominaEndRow =\n 1 + bancoSinNominaRows.length;\n\nconst diferenciasNombreEndRow =\n 1 + diferenciasNombreRows.length;\n\nconst resumenReadabilityEndRow =\n 1 + resumenRows.length;\n\n/*\n * Ajuste final de legibilidad.\n *\n * Se ejecuta al final para que los anchos definitivos ya estén aplicados\n * cuando Google Sheets calcule automáticamente la altura de cada fila.\n * Así, cualquier texto largo queda envuelto y visible sin que el usuario\n * tenga que expandir columnas o filas manualmente.\n */\nformatRequests.push(\n // 01 Nómina vs Banco\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 1,\n 2,\n 300\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 2,\n 3,\n 150\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 3,\n 6,\n 145\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 6,\n 7,\n 220\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 7,\n 8,\n 140\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 8,\n 9,\n 320\n ),\n ...(mainReportRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.nominaVsBanco,\n 4,\n mainReportEndRow,\n 0,\n 9\n ),\n autoResizeRowsRequest(\n sheetIds.nominaVsBanco,\n 4,\n mainReportEndRow\n ),\n ]\n : []),\n\n // 02 Banco sin Nómina\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 0,\n 1,\n 320\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 1,\n 2,\n 165\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 2,\n 3,\n 95\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 3,\n 4,\n 140\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 4,\n 5,\n 170\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 5,\n 6,\n 560\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 6,\n 7,\n 320\n ),\n autoResizeRowsRequest(\n sheetIds.bancoSinNomina,\n 0,\n 1\n ),\n ...(bancoSinNominaRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.bancoSinNomina,\n 1,\n bancoSinNominaEndRow,\n 0,\n 7\n ),\n autoResizeRowsRequest(\n sheetIds.bancoSinNomina,\n 1,\n bancoSinNominaEndRow\n ),\n ]\n : []),\n\n // 03 Banco sin Bamboo\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 1,\n 2,\n 300\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 2,\n 3,\n 330\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 3,\n 4,\n 150\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 4,\n 5,\n 145\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 5,\n 6,\n 150\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 6,\n 7,\n 170\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 7,\n 8,\n 320\n ),\n ...(bancoSinBambooRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.bancoSinBamboo,\n 4,\n bancoSinBambooEndRow,\n 0,\n 8\n ),\n autoResizeRowsRequest(\n sheetIds.bancoSinBamboo,\n 4,\n bancoSinBambooEndRow\n ),\n ]\n : []),\n\n // 04 Diferencias nombre banco\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 0,\n 1,\n 300\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 1,\n 2,\n 330\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 2,\n 3,\n 155\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 3,\n 4,\n 95\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 4,\n 5,\n 140\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 5,\n 7,\n 150\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 7,\n 8,\n 260\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 8,\n 9,\n 170\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 9,\n 10,\n 650\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 10,\n 11,\n 320\n ),\n autoResizeRowsRequest(\n sheetIds.diferenciasNombreBanco,\n 0,\n 1\n ),\n ...(diferenciasNombreRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.diferenciasNombreBanco,\n 1,\n diferenciasNombreEndRow,\n 0,\n 11\n ),\n autoResizeRowsRequest(\n sheetIds.diferenciasNombreBanco,\n 1,\n diferenciasNombreEndRow\n ),\n ]\n : []),\n\n // 05 Cuenta Mal Digitada\n ...(hasCuentaMalDigitada\n ? [\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 1,\n 2,\n 300\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 2,\n 3,\n 600\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 3,\n 4,\n 320\n ),\n ]\n : []),\n\n // Resumen\n columnWidthRequest(\n sheetIds.resumen,\n 0,\n 1,\n 380\n ),\n columnWidthRequest(\n sheetIds.resumen,\n 1,\n 2,\n 320\n ),\n autoResizeRowsRequest(\n sheetIds.resumen,\n 0,\n 1\n ),\n ...(resumenRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.resumen,\n 1,\n resumenReadabilityEndRow,\n 0,\n 2\n ),\n autoResizeRowsRequest(\n sheetIds.resumen,\n 1,\n resumenReadabilityEndRow\n ),\n ]\n : [])\n);\n\n\nreturn [\n {\n json: {\n ok: true,\n stage: 'preparar_google_sheet',\n metadata,\n summary,\n spreadsheetTitle,\n sheetIds,\n sheetTitles,\n createSpreadsheetBody: {\n properties: {\n title: spreadsheetTitle,\n },\n sheets: [\n {\n properties: {\n sheetId: sheetIds.nominaVsBanco,\n title: sheetTitles.nominaVsBanco,\n },\n },\n {\n properties: {\n sheetId: sheetIds.bancoSinNomina,\n title: sheetTitles.bancoSinNomina,\n },\n },\n {\n properties: {\n sheetId: sheetIds.bancoSinBamboo,\n title: sheetTitles.bancoSinBamboo,\n },\n },\n {\n properties: {\n sheetId:\n sheetIds.diferenciasNombreBanco,\n title:\n sheetTitles.diferenciasNombreBanco,\n },\n },\n ...(hasCuentaMalDigitada\n ? [\n {\n properties: {\n sheetId:\n sheetIds.cuentaMalDigitada,\n title:\n sheetTitles.cuentaMalDigitada,\n },\n },\n ]\n : []),\n {\n properties: {\n sheetId: sheetIds.resumen,\n title: sheetTitles.resumen,\n },\n },\n ],\n },\n valueBatchBody: {\n 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": [
|
||
22048,
|
||
26512
|
||
],
|
||
"id": "5c8239ca-2f93-41a3-be89-cb6fce94d2b2",
|
||
"name": "Preparar Google Sheet"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "https://sheets.googleapis.com/v4/spreadsheets",
|
||
"authentication": "predefinedCredentialType",
|
||
"nodeCredentialType": "googleOAuth2Api",
|
||
"sendBody": true,
|
||
"specifyBody": "json",
|
||
"jsonBody": "={{\n(() => {\n const prepared =\n $('Preparar Google Sheet').first().json || {};\n\n const createBody =\n prepared.createSpreadsheetBody || {};\n\n if (\n !Array.isArray(createBody.sheets) ||\n createBody.sheets.length === 0\n ) {\n throw new Error(\n 'Preparar Google Sheet no devolvió las hojas que deben crearse.'\n );\n }\n\n return {\n properties: {\n ...(createBody.properties || {}),\n timeZone: 'America/Guatemala',\n },\n\n sheets: createBody.sheets.map((sheet) => ({\n properties: {\n ...(sheet.properties || {}),\n\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": [
|
||
22256,
|
||
26512
|
||
],
|
||
"id": "937c7727-c0e4-49c7-ab93-199919b81763",
|
||
"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/' + $('Crear Google Sheet').first().json.spreadsheetId + '/values:batchUpdate' }}",
|
||
"authentication": "predefinedCredentialType",
|
||
"nodeCredentialType": "googleOAuth2Api",
|
||
"sendBody": true,
|
||
"specifyBody": "json",
|
||
"jsonBody": "={{ $('Preparar Google Sheet').first().json.valueBatchBody }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
22464,
|
||
26512
|
||
],
|
||
"id": "c71323e4-d046-44c9-b073-e8f389ffc86f",
|
||
"name": "Escribir Google Sheet",
|
||
"credentials": {
|
||
"googleOAuth2Api": {
|
||
"id": "dQ1MJSJSWcoWYcb8",
|
||
"name": "Google account - Isaac Producción"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $('Crear Google Sheet').first().json.spreadsheetId + ':batchUpdate' }}",
|
||
"authentication": "predefinedCredentialType",
|
||
"nodeCredentialType": "googleOAuth2Api",
|
||
"sendBody": true,
|
||
"specifyBody": "json",
|
||
"jsonBody": "={{ $('Preparar Google Sheet').first().json.formatBatchBody }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
22672,
|
||
26512
|
||
],
|
||
"id": "21d98cca-0e31-4471-a0ab-b2ad0f0bf7a3",
|
||
"name": "Formatear Google Sheet",
|
||
"credentials": {
|
||
"googleOAuth2Api": {
|
||
"id": "dQ1MJSJSWcoWYcb8",
|
||
"name": "Google account - Isaac Producción"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const prepared = $('Preparar Google Sheet').first().json || {};\nconst createdSheet = $('Crear Google Sheet').first().json || {};\n\nconst original =\n prepared.originalResponse ||\n prepared.original_response ||\n prepared.response ||\n {};\n\nconst spreadsheetId = createdSheet.spreadsheetId || '';\nconst reportUrl =\n createdSheet.spreadsheetUrl ||\n (spreadsheetId ? `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit` : null);\n\nreturn [\n {\n json: {\n ok: original.ok ?? true,\n message: reportUrl\n ? 'Cruce procesado correctamente. Google Sheet generado.'\n : 'Cruce procesado correctamente, pero no se recibió URL del Google Sheet.',\n stage: reportUrl ? 'cruce_completado_con_reporte' : 'cruce_completado_sin_reporte',\n errors: original.errors || [],\n metadata: original.metadata || {},\n summary: original.summary || {},\n rows: original.rows || [],\n bankWithoutBamboo:\n original.bankWithoutBamboo || [],\n bambooSummary:\n original.bambooSummary || {},\n reportUrl,\n 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": [
|
||
24800,
|
||
26528
|
||
],
|
||
"id": "aa72c7f0-6d16-483e-b991-292c01415eef",
|
||
"name": "Preparar respuesta final"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const createdSheet = $('Crear Google Sheet').first().json || {};\nconst spreadsheetId = createdSheet.spreadsheetId;\n\nif (!spreadsheetId) {\n throw new Error('No se recibió spreadsheetId desde Crear Google Sheet.');\n}\n\nconst allowedEmails = [\n 'iaracena@gomezleemarketing.com',\n 'ymadera@gomezleemarketing.com',\n 'mgomez@gomezleemarketing.com',\n 'jgomez@gomezleemarketing.com',\n];\n\nreturn allowedEmails.map((email) => ({\n json: {\n spreadsheetId,\n email,\n permissionBody: {\n type: 'user',\n role: 'writer',\n emailAddress: email,\n },\n },\n}));"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
22880,
|
||
26512
|
||
],
|
||
"id": "e2b64399-0b45-40b9-b2be-b7b88bdfa002",
|
||
"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": [
|
||
23088,
|
||
26512
|
||
],
|
||
"id": "d9026869-a053-4a40-8d5f-fe92e6c11c7d",
|
||
"name": "Compartir Google Sheet",
|
||
"credentials": {
|
||
"googleOAuth2Api": {
|
||
"id": "dQ1MJSJSWcoWYcb8",
|
||
"name": "Google account - Isaac Producción"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const cruce = $('Cruzar Nómina vs Banco').first().json || {};\nconst createdSheet = $('Crear Google Sheet').first().json || {};\n\nconst metadata = cruce.metadata || {};\nconst summary = cruce.summary || {};\nconst debug = cruce.debug || {};\n\nconst spreadsheetId = createdSheet.spreadsheetId || cruce.spreadsheetId || '';\nconst reportUrl =\n createdSheet.spreadsheetUrl ||\n createdSheet.spreadsheet_url ||\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 payload = {\n source_app: metadata.source_app || 'cruce-cuentas-glm-guatemala',\n country: metadata.country || 'GT',\n country_name: metadata.country_name || 'Guatemala',\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 diferencias_nombre_banco:\n diferenciasNombreBanco,\n banco_sin_bamboo:\n bancoSinBamboo,\n pendientes_cruce_principal:\n pendientes,\n requiere_revision:\n requiereRevision,\n },\n summary,\n debug: {\n sheet_summaries: debug.sheet_summaries || [],\n attached_supplements: debug.attached_supplements || [],\n unattached_supplements: debug.unattached_supplements || [],\n bank_name_differences_preview:\n debug.bank_name_differences_preview || [],\n bamboo_matches:\n debug.bamboo_matches || [],\n bamboo_excluded_payments:\n debug.bamboo_excluded_payments || [],\n banco_sin_bamboo:\n cruce.bankWithoutBamboo || [],\n },\n};\n\nreturn [\n {\n json: {\n ...cruce,\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": [
|
||
24240,
|
||
26528
|
||
],
|
||
"id": "471f36ca-d750-4e92-8807-4794ce0cc4a0",
|
||
"name": "Preparar histórico Supabase"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "https://dbit.digitalcompass.agency/rest/v1/cruces_cuentas_gt_reportes",
|
||
"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": "={{ $json.supabasePayload }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
24448,
|
||
26528
|
||
],
|
||
"id": "69a0dfa0-702a-4ed6-93cc-01a0b97d9676",
|
||
"name": "Insertar histórico Supabase",
|
||
"onError": "continueRegularOutput"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "xlsx",
|
||
"binaryPropertyName": "payroll_file",
|
||
"options": {
|
||
"headerRow": false,
|
||
"sheetName": "Temporales WMC"
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.extractFromFile",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
15664,
|
||
27632
|
||
],
|
||
"id": "f545108c-5069-4208-be51-531a235a81e2",
|
||
"name": "Extract - Temporales WMC",
|
||
"retryOnFail": false,
|
||
"onError": "continueRegularOutput"
|
||
},
|
||
{
|
||
"parameters": {},
|
||
"type": "n8n-nodes-base.merge",
|
||
"typeVersion": 3.2,
|
||
"position": [
|
||
19232,
|
||
27296
|
||
],
|
||
"id": "e4f39bb4-ad63-4611-8ddc-09a44817eaa1",
|
||
"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": [
|
||
15216,
|
||
25568
|
||
],
|
||
"id": "4487c967-fd95-4865-977b-b232f1a7b69d",
|
||
"name": "HTTP - Empleados BambooHR 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": [
|
||
15440,
|
||
25568
|
||
],
|
||
"id": "a0b69501-6075-4d1c-819e-a7b15a683ddb",
|
||
"name": "Normalizar BambooHR GT"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "combine",
|
||
"combineBy": "combineByPosition",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.merge",
|
||
"typeVersion": 3.2,
|
||
"position": [
|
||
21472,
|
||
26512
|
||
],
|
||
"id": "4ba30bd6-5082-4299-aaaa-4082014f781e",
|
||
"name": "Merge - Agregar BambooHR"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"content": "# 📥 ENTRADA Y EXTRACCIÓN DE DATOS — GUATEMALA\n\nRecibe desde el Portal de Verificación de Nóminas 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 nómina 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, nómina 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": [
|
||
14432,
|
||
24992
|
||
],
|
||
"id": "a9eedba9-b326-4097-b31a-9cb977f38400",
|
||
"name": "Sticky Note"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"content": "# 🧩 CONSOLIDACIÓN DE NÓMINA — GUATEMALA\n\nUne progresivamente todas las hojas extraídas hasta construir una sola nómina 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 nómina 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": [
|
||
16368,
|
||
25136
|
||
],
|
||
"id": "458cfcea-4a35-4a73-bfd9-c2689bb88d0b",
|
||
"name": "Sticky Note1"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"content": "# 🔍 CRUCE Y REPORTE FINAL — GUATEMALA\n\nCombina la nómina consolidada con el archivo bancario y la información oficial de BambooHR.\n\nEl cruce permite detectar:\n\n- Diferencias entre nómina y banco.\n- Empleados presentes solamente en nómina.\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, nómina y banco.",
|
||
"height": 608,
|
||
"width": 2608,
|
||
"color": "#5F721D"
|
||
},
|
||
"type": "n8n-nodes-base.stickyNote",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
20752,
|
||
26288
|
||
],
|
||
"id": "74dfb3d9-327a-428e-a0d8-400436289f6c",
|
||
"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 Nóminas.\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": 656,
|
||
"width": 1760,
|
||
"color": 3
|
||
},
|
||
"type": "n8n-nodes-base.stickyNote",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
23536,
|
||
26256
|
||
],
|
||
"id": "86284c0d-1d6b-4ed5-84a6-2dea3aaed6e8",
|
||
"name": "Sticky Note3"
|
||
}
|
||
],
|
||
"connections": {
|
||
"Webhook": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Preparar entrada app",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Preparar entrada app": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Parsear CSV banco GT",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Extract - Nomina General",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Extract - Temporales",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Extract - Auditorias",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Extract - Bono Mariana",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Extract - Movilidad WP",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Extract - Viaticos PMI",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Extract - Combustible Purina",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Extract - Combustible PG",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Extract - Combustibles Liquidables",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Extract - Mot Variable Abril",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Extract - Temporales WMC",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Parsear CSV banco GT": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Merge": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge - Agregar BambooHR",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "HTTP - Empleados BambooHR GT",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Cruzar Nómina vs Banco": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Preparar Google Sheet",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Extract - Nomina General": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge Hojas 01-02",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Extract - Temporales": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge Hojas 01-02",
|
||
"type": "main",
|
||
"index": 1
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Extract - Auditorias": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge Hojas 03",
|
||
"type": "main",
|
||
"index": 1
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Extract - Bono Mariana": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge Hojas 04",
|
||
"type": "main",
|
||
"index": 1
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Extract - Movilidad WP": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge Hojas 05",
|
||
"type": "main",
|
||
"index": 1
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Extract - Viaticos PMI": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge Hojas 06",
|
||
"type": "main",
|
||
"index": 1
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Extract - Combustible Purina": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge Hojas 07",
|
||
"type": "main",
|
||
"index": 1
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Extract - Combustible PG": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge Hojas 08",
|
||
"type": "main",
|
||
"index": 1
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Extract - Combustibles Liquidables": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge Hojas 09",
|
||
"type": "main",
|
||
"index": 1
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Normalizar Nómina Completa": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge",
|
||
"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 - Mot Variable Abril": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge Hojas ",
|
||
"type": "main",
|
||
"index": 1
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Merge Hojas ": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge Hojas 10",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Preparar Google Sheet": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Crear Google Sheet",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Crear Google Sheet": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Escribir Google Sheet",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Escribir Google Sheet": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Formatear Google Sheet",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Formatear Google Sheet": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Preparar permisos Google Sheet",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Preparar 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
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Extract - Temporales WMC": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge Hojas 10",
|
||
"type": "main",
|
||
"index": 1
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Merge Hojas 10": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Normalizar Nómina Completa",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Empleados BambooHR GT": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Normalizar BambooHR GT",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Normalizar BambooHR GT": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Merge - Agregar BambooHR",
|
||
"type": "main",
|
||
"index": 1
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Merge - Agregar BambooHR": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Cruzar Nómina vs Banco",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
}
|
||
},
|
||
"authors": "Isaac Aracena",
|
||
"name": "Version afef0d83",
|
||
"description": "",
|
||
"autosaved": false,
|
||
"workflowPublishHistory": [
|
||
{
|
||
"createdAt": "2026-07-27T15:00:48.936Z",
|
||
"id": 3496,
|
||
"workflowId": "d3vXYr7ucbaiU5ct",
|
||
"versionId": "afef0d83-31fc-44b2-87dd-05536d3520f2",
|
||
"event": "activated",
|
||
"userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029"
|
||
}
|
||
]
|
||
}
|
||
} |