Files
portal-de-verificacion-de-n…/portal-de-verificacion-de-nomina-tt.json

2156 lines
394 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"updatedAt": "2026-07-27T15:01:25.665Z",
"createdAt": "2026-07-14T14:51:32.549Z",
"id": "5AujMxduslftVg9z",
"name": "Portal de Verificación de Nómina - TT",
"description": "Automatiza el Portal de Verificación de Nómina de Trinidad y Tobago: recibe la nómina y los archivos bancarios, consulta y normaliza los empleados de BambooHR, consolida las diferentes fuentes bancarias, cruza empleados, cuentas y montos, genera y comparte el reporte de diferencias en Google Sheets, registra el resultado histórico en Supabase y devuelve el enlace final a la aplicación.",
"active": true,
"isArchived": false,
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "nominatt-bamboo-test",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
2848,
7344
],
"id": "0a3764cc-5cc2-4c62-93f8-1d201ec45e9d",
"name": "Webhook",
"webhookId": "9c730860-7790-43a5-a3c0-bf5984ced244"
},
{
"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(\n (key) => key === 'payroll_file'\n);\n\nconst bankKeys = binaryKeys\n .filter((key) => key.startsWith('bank_files'))\n .sort();\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 receivedCountry = String(\n metadata.country || ''\n).trim().toUpperCase();\n\nconst errors = [];\n\nif (!['TT', 'TTO'].includes(receivedCountry)) {\n errors.push(\n 'El país recibido no es Trinidad y Tobago.'\n );\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(\n 'No se recibió ningún archivo CSV del banco.'\n );\n}\n\nconst normalizedMetadata = {\n ...metadata,\n country: 'TT',\n country_name: 'Trinidad y Tobago',\n source_app:\n metadata.source_app ||\n 'cruce-cuentas-glm-trinidad-tobago',\n payroll_file_name:\n metadata.payroll_file_name ||\n payrollFile?.file_name ||\n '',\n bank_file_names:\n metadata.bank_file_names ||\n bankFiles.map((file) => file.file_name),\n};\n\nreturn [\n {\n json: {\n ok: errors.length === 0,\n stage: 'entrada_tt_recibida',\n errors,\n metadata: normalizedMetadata,\n payroll_file: payrollFile,\n bank_files: bankFiles,\n summary: {\n payroll_files_count:\n payrollFile ? 1 : 0,\n bank_files_count: bankFiles.length,\n },\n },\n binary,\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
3088,
7344
],
"id": "bdfa28aa-5a9e-4c08-9869-0bafa2a8cb52",
"name": "Preparar entrada app"
},
{
"parameters": {
"jsCode": "const input = $input.first();\nconst json = input.json || {};\nconst binary = input.binary || {};\n\nfunction parseCsvLine(line) {\n const result = [];\n let current = '';\n let insideQuotes = false;\n\n for (let index = 0; index < line.length; index++) {\n const character = line[index];\n const nextCharacter = line[index + 1];\n\n if (\n character === '\"' &&\n insideQuotes &&\n nextCharacter === '\"'\n ) {\n current += '\"';\n index += 1;\n continue;\n }\n\n if (character === '\"') {\n insideQuotes = !insideQuotes;\n continue;\n }\n\n if (character === ',' && !insideQuotes) {\n result.push(current.trim());\n current = '';\n continue;\n }\n\n current += character;\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(/\\u00A0/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeForCompare(value) {\n return normalizeText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/['`-]/g, '')\n .replace(/[^a-z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeAccount(value) {\n return String(value ?? '')\n .replace(/\\u00A0/g, '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim();\n}\n\nfunction isValidAccount(value) {\n const account = normalizeAccount(value);\n return (\n account.length >= 6 &&\n !/^0+$/.test(account)\n );\n}\n\nfunction parseMoney(value) {\n const cleaned = String(value ?? '')\n .replace(/TTD/gi, '')\n .replace(/TT\\$/gi, '')\n .replace(/\\$/g, '')\n .replace(/,/g, '')\n .replace(/\\s+/g, '')\n .trim();\n\n const parsed = Number.parseFloat(cleaned);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction roundMoney(value) {\n return Math.round(\n (Number(value) || 0) * 100\n ) / 100;\n}\n\nfunction getColumnIndex(headers, names) {\n const normalizedHeaders =\n headers.map(normalizeForCompare);\n\n for (const name of names) {\n const expected = normalizeForCompare(name);\n const index = normalizedHeaders.findIndex(\n (header) => header === expected\n );\n\n if (index >= 0) return index;\n }\n\n return -1;\n}\n\nconst bankKeys = Object.keys(binary)\n .filter((key) => key.startsWith('bank_files'))\n .sort();\n\nconst allBankRows = [];\nconst fileSummaries = [];\n\nfor (const key of bankKeys) {\n const file = binary[key];\n const buffer =\n await this.helpers.getBinaryDataBuffer(0, key);\n\n let text = buffer.toString('utf8');\n\n if (text.includes('\\uFFFD')) {\n text = buffer.toString('latin1');\n }\n\n const lines = text\n .split(/\\r?\\n/)\n .map((line) => line.trim())\n .filter(Boolean);\n\n const headerIndex = lines.findIndex((line) => {\n const normalized = normalizeForCompare(line);\n\n return (\n normalized.includes('identifier') &&\n normalized.includes('account number') &&\n normalized.includes('amount') &&\n normalized.includes('participant name')\n );\n });\n\n if (headerIndex < 0) {\n fileSummaries.push({\n file_name: file.fileName,\n ok: false,\n rows_count: 0,\n total_amount: 0,\n error:\n 'No se encontró el encabezado esperado del archivo bancario de Trinidad y Tobago.',\n });\n continue;\n }\n\n const headers = parseCsvLine(\n lines[headerIndex]\n ).map(normalizeText);\n\n const indexIdentifier = getColumnIndex(\n headers,\n ['Identifier']\n );\n const indexAccount = getColumnIndex(\n headers,\n ['Account Number']\n );\n const indexAccountType = getColumnIndex(\n headers,\n ['Account type']\n );\n const indexAmount = getColumnIndex(\n headers,\n ['Amount']\n );\n const indexInstitution = getColumnIndex(\n headers,\n ['Financial Institution ID']\n );\n const indexParticipantId = getColumnIndex(\n headers,\n ['Participant ID']\n );\n const indexParticipantName = getColumnIndex(\n headers,\n ['Participant Name']\n );\n const indexTransactionType = getColumnIndex(\n headers,\n ['TR Type']\n );\n const indexAddenda = getColumnIndex(\n headers,\n ['Addenda']\n );\n\n const rowsFromFile = [];\n\n for (\n let lineIndex = headerIndex + 1;\n lineIndex < lines.length;\n lineIndex++\n ) {\n const values = parseCsvLine(lines[lineIndex]);\n\n const identifier = normalizeText(\n indexIdentifier >= 0\n ? values[indexIdentifier]\n : ''\n ).toUpperCase();\n\n // T = transacción. C = fila de control/totales.\n if (identifier !== 'T') continue;\n\n const account = normalizeAccount(\n indexAccount >= 0\n ? values[indexAccount]\n : ''\n );\n\n const amount = roundMoney(\n parseMoney(\n indexAmount >= 0\n ? values[indexAmount]\n : ''\n )\n );\n\n const participantName = normalizeText(\n indexParticipantName >= 0\n ? values[indexParticipantName]\n : ''\n );\n\n if (amount <= 0 || !participantName) {\n continue;\n }\n\n const accountIsValid =\n isValidAccount(account);\n\n const groupKey = accountIsValid\n ? `ACCOUNT:${account}:TTD`\n : `ROW:${file.fileName}:${lineIndex + 1}:TTD`;\n\n const row = {\n source_file: file.fileName,\n row_number: lineIndex + 1,\n group_key: groupKey,\n account,\n raw_account: account,\n account_is_valid: accountIsValid,\n bank_name_file: participantName,\n bank_account_holder: '',\n participant_name: participantName,\n participant_id: normalizeText(\n indexParticipantId >= 0\n ? values[indexParticipantId]\n : ''\n ),\n financial_institution_id:\n normalizeText(\n indexInstitution >= 0\n ? values[indexInstitution]\n : ''\n ),\n account_type: normalizeText(\n indexAccountType >= 0\n ? values[indexAccountType]\n : ''\n ),\n transaction_type: normalizeText(\n indexTransactionType >= 0\n ? values[indexTransactionType]\n : ''\n ),\n reference: normalizeText(\n indexAddenda >= 0\n ? values[indexAddenda]\n : ''\n ),\n addenda: normalizeText(\n indexAddenda >= 0\n ? values[indexAddenda]\n : ''\n ),\n shipment_number: '',\n plan_number: '',\n amount,\n currency: 'TTD',\n status: 'Procesado',\n };\n\n rowsFromFile.push(row);\n allBankRows.push(row);\n }\n\n fileSummaries.push({\n file_name: file.fileName,\n ok: true,\n rows_count: rowsFromFile.length,\n total_amount: roundMoney(\n rowsFromFile.reduce(\n (sum, row) => sum + row.amount,\n 0\n )\n ),\n error: null,\n });\n}\n\nconst groupedMap = new Map();\n\nfor (const row of allBankRows) {\n const current =\n 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: 'TTD',\n transactions_count: 0,\n bank_name_files: new Set(),\n bank_account_holders: new Set(),\n source_files: new Set(),\n institution_ids: new Set(),\n source_rows: [],\n };\n\n current.amount = roundMoney(\n current.amount + row.amount\n );\n current.transactions_count += 1;\n\n if (row.bank_name_file) {\n current.bank_name_files.add(\n row.bank_name_file\n );\n }\n\n if (row.source_file) {\n current.source_files.add(row.source_file);\n }\n\n if (row.financial_institution_id) {\n current.institution_ids.add(\n row.financial_institution_id\n );\n }\n\n current.source_rows.push(row);\n groupedMap.set(row.group_key, current);\n}\n\nconst groupedByAccount = Array.from(\n groupedMap.values()\n).map((row) => {\n const names = Array.from(\n row.bank_name_files\n );\n\n return {\n ...row,\n bank_name_file: names[0] || '',\n bank_account_holder: '',\n bank_name_files: names,\n bank_account_holders: [],\n source_files: Array.from(\n row.source_files\n ),\n institution_ids: Array.from(\n row.institution_ids\n ),\n };\n});\n\nconst totalAmount = roundMoney(\n allBankRows.reduce(\n (sum, row) => sum + row.amount,\n 0\n )\n);\n\nreturn [\n {\n json: {\n ...json,\n stage: 'banco_tt_parseado',\n bank: {\n source:\n 'csv_ach_trinidad_tobago',\n files_count: bankKeys.length,\n valid_files_count:\n fileSummaries.filter(\n (file) => file.ok\n ).length,\n rows_count: allBankRows.length,\n grouped_accounts_count:\n groupedByAccount.length,\n total_amount: totalAmount,\n totals_by_currency: {\n TTD: totalAmount,\n },\n name_differences_count: 0,\n name_differences: [],\n file_summaries: fileSummaries,\n rows: allBankRows,\n grouped_by_account:\n groupedByAccount,\n },\n },\n binary,\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
3424,
7040
],
"id": "46e46f3c-85f9-40ef-a3cc-ee20acc46d73",
"name": "Parsear CSV banco TT"
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": true,
"sheetName": "BICE"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
3424,
7440
],
"id": "444192a4-bd82-4086-a87f-ab116517f723",
"name": "Extract - BICE",
"retryOnFail": false
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": true,
"sheetName": "Goldey Samuel"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
3424,
7616
],
"id": "15b0af6e-5e38-4c0c-9d30-496ad9df9413",
"name": "Extract - Goldey Samuel",
"retryOnFail": false
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": true,
"sheetName": "P&G"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
3424,
7776
],
"id": "22845609-e9b5-486b-88ce-bc5d73b96a2e",
"name": "Extract - P&G",
"retryOnFail": false
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": true,
"sheetName": "Whirlpool"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
3424,
7952
],
"id": "9a39edbc-1e2e-4d75-83ed-9ce48c808abf",
"name": "Extract - Whirlpool",
"retryOnFail": false
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": true,
"sheetName": "KAD"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
3424,
8128
],
"id": "1710469a-3b4a-4a3b-9c61-77d7d3c4fffb",
"name": "Extract - KAD",
"retryOnFail": false
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": true,
"sheetName": "GLM People"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
3424,
8288
],
"id": "72c876ce-f1e4-4f82-ac7d-7d409eb18e64",
"name": "Extract - GLM People",
"retryOnFail": false
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": true,
"sheetName": "GLM"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
3424,
8464
],
"id": "26d185f0-3f60-44cd-b20e-1fbfbae48fc8",
"name": "Extract - GLM",
"retryOnFail": false
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
5088,
7536
],
"id": "b98e1002-cf47-4cde-94d7-08777b928d36",
"name": "Merge Hojas TT 01-02"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
5088,
7696
],
"id": "c7c11485-0d25-4959-ba19-cf631591472a",
"name": "Merge Hojas TT 03"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
5088,
7872
],
"id": "706b5c48-c1f0-4a12-be90-51abd68f32f3",
"name": "Merge Hojas TT 04"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
5088,
8032
],
"id": "a6cff463-cf7d-4e70-b13d-213dbcefa388",
"name": "Merge Hojas TT 05"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
5088,
8208
],
"id": "25f84f87-c966-4b81-ac10-9021b7eeadc3",
"name": "Merge Hojas TT 06"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
5088,
8384
],
"id": "75156223-c7d3-4b98-bdc5-aa62a9185db1",
"name": "Merge Hojas TT 07"
},
{
"parameters": {
"jsCode": "function normalizeText(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\u00A0/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeForCompare(value) {\n return normalizeText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/['`-]/g, '')\n .replace(/[^a-z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeAccount(value) {\n if (\n value === null ||\n value === undefined ||\n value === ''\n ) {\n return '';\n }\n\n if (typeof value === 'number') {\n return String(Math.trunc(value));\n }\n\n return String(value)\n .replace(/\\u00A0/g, '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim();\n}\n\nfunction parseMoney(value) {\n if (typeof value === 'number') {\n return Number.isFinite(value)\n ? value\n : 0;\n }\n\n const cleaned = String(value ?? '')\n .replace(/TTD/gi, '')\n .replace(/TT\\$/gi, '')\n .replace(/\\$/g, '')\n .replace(/,/g, '')\n .replace(/\\s+/g, '')\n .trim();\n\n const parsed = Number.parseFloat(cleaned);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction roundMoney(value) {\n return Math.round(\n (Number(value) || 0) * 100\n ) / 100;\n}\n\nfunction getValue(row, possibleKeys) {\n for (const key of possibleKeys) {\n const value = row[key];\n\n if (\n value !== undefined &&\n value !== null &&\n value !== ''\n ) {\n return value;\n }\n }\n\n const rowKeys = Object.keys(row || {});\n\n for (const expected of possibleKeys) {\n const normalizedExpected =\n normalizeForCompare(expected);\n\n const matchingKey = rowKeys.find(\n (key) =>\n normalizeForCompare(key) ===\n normalizedExpected\n );\n\n if (!matchingKey) continue;\n\n const value = row[matchingKey];\n\n if (\n value !== undefined &&\n value !== null &&\n value !== ''\n ) {\n return value;\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 if (row.error) return false;\n\n const text = JSON.stringify(\n row || {}\n ).toLowerCase();\n\n return !(\n text.includes(\n 'spreadsheet does not contain sheet'\n ) ||\n text.includes('no sheet')\n );\n });\n } catch (error) {\n return [];\n }\n}\n\nfunction validEmployeeName(value) {\n const name = normalizeText(value);\n const normalized = normalizeForCompare(name);\n\n if (!name) return false;\n if (/^[\\d.,\\s]+$/.test(name)) return false;\n\n const invalid = [\n 'total',\n 'subtotal',\n 'gran total',\n 'total general',\n 'variable',\n 'empleado',\n 'first name',\n 'nombre',\n 'diferencia',\n 'total dias',\n ];\n\n return !invalid.some(\n (token) =>\n normalized === token ||\n normalized.startsWith(`${token} `)\n );\n}\n\nfunction validAccount(value) {\n const account = normalizeAccount(value);\n\n return (\n account.length >= 6 &&\n !/^0+$/.test(account)\n );\n}\n\nconst sheetConfigs = [\n {\n node: 'Extract - BICE',\n sheet: 'BICE',\n },\n {\n node: 'Extract - Goldey Samuel',\n sheet: 'Goldey Samuel',\n },\n {\n node: 'Extract - P&G',\n sheet: 'P&G',\n },\n {\n node: 'Extract - Whirlpool',\n sheet: 'Whirlpool',\n },\n {\n node: 'Extract - KAD',\n sheet: 'KAD',\n },\n {\n node: 'Extract - GLM People',\n sheet: 'GLM People',\n },\n {\n node: 'Extract - GLM',\n sheet: 'GLM',\n },\n];\n\nconst payrollRows = [];\nconst noAccountRows = [];\nconst ignoredRows = [];\nconst sheetSummaries = [];\n\nfor (const config of sheetConfigs) {\n const sourceRows = getNodeRows(\n config.node\n );\n\n let validRows = 0;\n let noAccountCount = 0;\n let ignoredCount = 0;\n let sheetTotal = 0;\n\n sourceRows.forEach((sourceRow, index) => {\n const period = normalizeText(\n getValue(sourceRow, ['Periodo'])\n );\n\n const employeeName = normalizeText(\n getValue(sourceRow, [\n 'First Name',\n 'Nombre completo',\n 'Empleado',\n 'Name',\n ])\n );\n\n const account = normalizeAccount(\n getValue(sourceRow, [\n 'Account #',\n 'Account Number',\n 'Cuenta bancaria',\n 'Cuenta Bancaria',\n ])\n );\n\n const email = normalizeText(\n getValue(sourceRow, [\n 'EMAIL',\n 'Email',\n 'Correo',\n ])\n ).toLowerCase();\n\n const amount = roundMoney(\n parseMoney(\n getValue(sourceRow, [\n 'NETO A PAGAR',\n 'Neto a Pagar',\n 'Net Pay',\n ])\n )\n );\n\n const client = normalizeText(\n getValue(sourceRow, ['Cuenta'])\n );\n\n const rowNumber = index + 2;\n\n const normalized = {\n source_sheet: config.sheet,\n row_number: rowNumber,\n period,\n employee_name: employeeName,\n employee_number: null,\n account,\n email,\n client,\n payroll_amount: amount,\n currency: 'TTD',\n };\n\n if (\n !period ||\n !validEmployeeName(employeeName) ||\n amount <= 0 ||\n amount > 500000\n ) {\n ignoredRows.push({\n ...normalized,\n reason:\n !period\n ? 'period_empty'\n : !validEmployeeName(employeeName)\n ? 'invalid_employee_name'\n : amount <= 0\n ? 'amount_zero_or_invalid'\n : 'suspicious_large_amount',\n });\n\n ignoredCount += 1;\n return;\n }\n\n sheetTotal = roundMoney(\n sheetTotal + amount\n );\n\n if (!validAccount(account)) {\n noAccountRows.push({\n ...normalized,\n account: '',\n });\n\n noAccountCount += 1;\n return;\n }\n\n payrollRows.push(normalized);\n validRows += 1;\n });\n\n sheetSummaries.push({\n sheet: config.sheet,\n node: config.node,\n raw_rows_count: sourceRows.length,\n valid_rows_count: validRows,\n no_account_rows_count:\n noAccountCount,\n ignored_rows_count: ignoredCount,\n total_amount: sheetTotal,\n });\n}\n\nconst groupedMap = new Map();\n\nfor (const row of payrollRows) {\n const groupKey =\n `${row.account}:${row.currency}`;\n\n const current =\n 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: 'TTD',\n payroll_amount: 0,\n rows_count: 0,\n source_sheets: new Set(),\n source_rows: [],\n };\n\n current.payroll_amount = roundMoney(\n current.payroll_amount +\n row.payroll_amount\n );\n\n current.rows_count += 1;\n\n if (!current.email && row.email) {\n current.email = row.email;\n }\n\n current.source_sheets.add(\n row.source_sheet\n );\n\n current.source_rows.push({\n source_sheet: row.source_sheet,\n row_number: row.row_number,\n account: row.account,\n amount: row.payroll_amount,\n employee_name: row.employee_name,\n });\n\n groupedMap.set(groupKey, current);\n}\n\nconst groupedByAccount = Array.from(\n groupedMap.values()\n).map((row) => ({\n ...row,\n source_sheets: Array.from(\n row.source_sheets\n ),\n}));\n\nconst totalAmount = roundMoney(\n payrollRows.reduce(\n (sum, row) => sum + row.payroll_amount,\n 0\n ) +\n noAccountRows.reduce(\n (sum, row) => sum + row.payroll_amount,\n 0\n )\n);\n\nreturn [\n {\n json: {\n payroll: {\n source:\n 'template_trinidad_tobago',\n sheets_count:\n sheetConfigs.length,\n sheet_summaries:\n sheetSummaries,\n raw_rows_count:\n sheetSummaries.reduce(\n (sum, sheet) =>\n sum + sheet.raw_rows_count,\n 0\n ),\n valid_rows_count:\n payrollRows.length,\n no_account_rows_count:\n noAccountRows.length,\n ignored_rows_count:\n ignoredRows.length,\n grouped_accounts_count:\n groupedByAccount.length,\n attached_supplements_count: 0,\n potential_supplements_count: 0,\n potential_supplements: [],\n unattached_supplements_count: 0,\n total_amount: totalAmount,\n totals_by_currency: {\n TTD: totalAmount,\n },\n rows: payrollRows,\n no_account_rows:\n noAccountRows,\n grouped_by_account:\n groupedByAccount,\n },\n debug_payroll: {\n attached_supplements: [],\n potential_supplements: [],\n unattached_supplements: [],\n ignored_rows_preview:\n ignoredRows.slice(0, 100),\n no_account_rows_preview:\n noAccountRows.slice(0, 50),\n },\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
5456,
7952
],
"id": "d036e80f-bf9c-4024-9bdb-f9222d8ee057",
"name": "Normalizar Nómina TT"
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"options": {}
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
5712,
7296
],
"id": "8d2116b6-d720-47a0-b532-aec6f16966c1",
"name": "Merge Banco + Nómina TT"
},
{
"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 TT",
"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": [
3424,
6816
],
"id": "84b74d83-a969-4f62-a11b-ace145d64e8c",
"name": "HTTP - Empleados BambooHR TT",
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 3000,
"credentials": {
"httpBasicAuth": {
"id": "7VrpNZ2jBLmiJ35q",
"name": "BambooHR GLM Full Access"
}
}
},
{
"parameters": {
"jsCode": "const inputItems = $input.all();\nconst base = $('Preparar entrada app').first().json || {};\nconst reconciliationData = $('Merge Banco + Nómina TT').first().json || {};\nconst metadata = base.metadata || {};\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 return Array.from(\n new Set(\n values\n .map(clean)\n .filter(Boolean)\n )\n );\n}\n\nfunction nameTokens(value) {\n const ignored = new Set([\n 'de', 'del', 'la', 'las', 'los',\n 'y', 'e', 'el', 'da', 'do',\n 'dos', 'das', 'van', 'von',\n ]);\n\n return normalize(value)\n .split(' ')\n .filter(\n (token) =>\n token.length > 1 &&\n !ignored.has(token)\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(/^(\\d{4})-(\\d{2})-(\\d{2})/);\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 === 'tt' ||\n country === 'tto' ||\n country.includes('trinidad') ||\n country.includes('tobago') ||\n location === 'tt' ||\n location === 'tto' ||\n location.includes('trinidad') ||\n location.includes('tobago')\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 strictInformativeContainment(\n left,\n right\n) {\n const leftTokens =\n Array.from(new Set(nameTokens(left)));\n const rightTokens =\n Array.from(new Set(nameTokens(right)));\n\n if (\n leftTokens.length < 3 ||\n rightTokens.length < 3\n ) {\n return false;\n }\n\n const leftSet = new Set(leftTokens);\n const rightSet = new Set(rightTokens);\n\n const leftInsideRight =\n leftTokens.every((token) =>\n rightSet.has(token)\n );\n\n const rightInsideLeft =\n rightTokens.every((token) =>\n leftSet.has(token)\n );\n\n return leftInsideRight || rightInsideLeft;\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 allNormalized = rawEmployees.map((employee) => {\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 return {\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\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 current =\n relevantNameMap.get(normalized);\n\n if (\n !current ||\n nameTokens(cleaned).length >\n nameTokens(current).length\n ) {\n relevantNameMap.set(\n normalized,\n cleaned\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\nconst targetEmployees =\n allNormalized\n .filter(isTargetCountry)\n .map((employee) => ({\n ...employee,\n validation_eligible: true,\n validation_scope:\n 'trinidad_tobago_country_or_location',\n }));\n\nconst outsideEmployees =\n allNormalized.filter(\n (employee) =>\n !isTargetCountry(employee)\n );\n\nconst contextualOutsideMap = new Map();\n\nfor (\n const relevantName of\n relevantNameMap.values()\n) {\n if (\n nameTokens(relevantName).length < 3\n ) {\n continue;\n }\n\n const matches = outsideEmployees\n .filter((employee) =>\n (employee.aliases || []).some(\n (alias) =>\n strictInformativeContainment(\n relevantName,\n alias\n )\n )\n );\n\n const uniqueMatches = new Map();\n\n for (const employee of matches) {\n const key =\n employee.bamboo_id ||\n employee.employee_number ||\n normalize(employee.full_name);\n\n if (key) {\n uniqueMatches.set(key, employee);\n }\n }\n\n // Solo se rescata un perfil fuera del país cuando un nombre\n // informativo identifica exactamente a una única persona.\n if (uniqueMatches.size !== 1) {\n continue;\n }\n\n const employee =\n uniqueMatches.values().next().value;\n\n const key =\n employee.bamboo_id ||\n employee.employee_number ||\n normalize(employee.full_name);\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 =\n employee.bamboo_id ||\n employee.employee_number ||\n normalize(employee.full_name);\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(\"ISAAC ST BERNARD\"), normalize(\"Isaac St Bernard\")],\n [normalize(\"VICTORIA ALPHONSO\"), normalize(\"Victoria Alphanso\")],\n [normalize(\"VICTORIA ALPHANSO\"), normalize(\"Victoria Alphanso\")],\n [normalize(\"ONELA FARREL\"), normalize(\"Onela Farrell\")],\n [normalize(\"ONELA FARRELL\"), normalize(\"Onela Farrell\")],\n [normalize(\"JESHAUGHN LOUIS\"), normalize(\"Je'Shaugn Louis\")],\n [normalize(\"JESHAUGN LOUIS\"), normalize(\"Je'Shaugn Louis\")],\n [normalize(\"JE SHAUGN LOUIS\"), normalize(\"Je'Shaugn Louis\")],\n [normalize(\"ANESSA ALI\"), normalize(\"Annesa Marina Ali\")],\n [normalize(\"ANNESA ALI\"), normalize(\"Annesa Marina Ali\")],\n [normalize(\"ANNESA MARINA ALI\"), normalize(\"Annesa Marina Ali\")],\n [normalize(\"ALANA KERCELUS\"), normalize(\"Alana Kercelus-Inalsingh\")],\n [normalize(\"ALANA KERCELUS INALSINGH\"), normalize(\"Alana Kercelus-Inalsingh\")]\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 Trinidad y Tobago 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_tt_normalizado'\n : 'bamboohr_tt_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 allNormalized.length,\n trinidad_tobago_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 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_confirmed_aliases',\n fetch_complete: fetchComplete,\n validation_available:\n fetchComplete &&\n validationEmployees.length > 0,\n validation_rule:\n 'Target country/location plus a unique informative contextual name outside the country',\n employees:\n validationEmployees,\n restricted_fields:\n restrictedFields,\n },\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
5088,
6816
],
"id": "d6d947b2-4a0e-4917-855c-7b69a27dae4e",
"name": "Normalizar BambooHR TT"
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"options": {}
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
5968,
7296
],
"id": "9760d7dd-776e-4ae7-b56b-2f8ac85cc768",
"name": "Merge - Agregar BambooHR TT"
},
{
"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(/['`-]/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 bambooTokenMatches(a, b) {\n if (tokenMatches(a, b)) return true;\n\n const minLength = Math.min(a.length, b.length);\n const maxLength = Math.max(a.length, b.length);\n const distance = editDistance(a, b);\n\n // Tolera variaciones pequeñas de escritura entre Banco/Nómina y BambooHR,\n // por ejemplo Anessa <-> Annesa, sin flexibilizar el cruce principal.\n if (minLength >= 6 && distance <= 2) {\n return true;\n }\n\n // Permite apellidos compuestos como BeharrySingh vs Singh,\n // pero evita aceptar coincidencias demasiado amplias.\n return (\n minLength >= 4 &&\n maxLength - minLength <= 10 &&\n (\n a.startsWith(b) ||\n b.startsWith(a) ||\n a.endsWith(b) ||\n b.endsWith(a)\n )\n );\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 compactA = normalizedA.replace(/\\s+/g, '');\n const compactB = normalizedB.replace(/\\s+/g, '');\n\n if (compactA === compactB) 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\nfunction bambooAliases(employee) {\n return Array.from(new Set([\n ...(Array.isArray(employee.aliases) ? employee.aliases : []),\n employee.full_name || '',\n [employee.first_name, employee.middle_name, employee.last_name]\n .filter(Boolean)\n .join(' '),\n [employee.preferred_name, employee.last_name]\n .filter(Boolean)\n .join(' '),\n ].map((value) => String(value || '').trim()).filter(Boolean)));\n}\n\nfunction bambooEmployeeNumber(employee) {\n return normalizeAccount(\n employee.employee_number ||\n employee.employeeNumber ||\n ''\n );\n}\n\nfunction isTrinidadTobagoBambooEmployee(employee) {\n const country = normalizeName(\n employee.country || ''\n );\n const location = normalizeName(\n employee.location || ''\n );\n\n return (\n country === 'tt' ||\n country === 'tto' ||\n country.includes('trinidad') ||\n country.includes('tobago') ||\n location === 'tt' ||\n location === 'tto' ||\n location.includes('trinidad') ||\n location.includes('tobago')\n );\n}\n\nfunction isBambooValidationEligible(employee) {\n // La versión corregida del normalizador declara este campo.\n if (employee.validation_eligible === true) {\n return true;\n }\n\n if (employee.validation_eligible === false) {\n return false;\n }\n\n // Compatibilidad defensiva si este nodo recibe datos de una ejecución\n // anterior: los perfiles de TT siguen siendo válidos. Un perfil de otro\n // país solo puede utilizarse cuando está Active y vigente en el período.\n if (isTrinidadTobagoBambooEmployee(employee)) {\n return true;\n }\n\n return (\n employee.overlaps_period === true &&\n normalizeName(employee.status) === 'active'\n );\n}\n\nfunction nameSimilarityScore(a, b) {\n const normalizedA = normalizeName(a);\n const normalizedB = normalizeName(b);\n\n if (!normalizedA || !normalizedB) return 0;\n if (normalizedA === normalizedB) return 1;\n\n const compactA = normalizedA.replace(/\\s+/g, '');\n const compactB = normalizedB.replace(/\\s+/g, '');\n\n if (compactA === compactB) return 1;\n\n const wordsA = nameWords(normalizedA);\n const wordsB = nameWords(normalizedB);\n\n if (!wordsA.length || !wordsB.length) return 0;\n\n const usedB = new Set();\n const usedA = new Set();\n let exactMatches = 0;\n let fuzzyMatches = 0;\n\n // Primero se reservan las coincidencias exactas para no perder\n // evidencia fuerte por el orden de las palabras.\n for (let indexA = 0; indexA < wordsA.length; indexA++) {\n const indexB = wordsB.findIndex(\n (wordB, currentIndexB) =>\n !usedB.has(currentIndexB) &&\n wordsA[indexA] === wordB\n );\n\n if (indexB >= 0) {\n usedA.add(indexA);\n usedB.add(indexB);\n exactMatches += 1;\n }\n }\n\n // Después se toleran errores ortográficos pequeños únicamente\n // para complementar una coincidencia que ya tiene evidencia exacta.\n for (let indexA = 0; indexA < wordsA.length; indexA++) {\n if (usedA.has(indexA)) continue;\n\n const indexB = wordsB.findIndex(\n (wordB, currentIndexB) =>\n !usedB.has(currentIndexB) &&\n bambooTokenMatches(wordsA[indexA], wordB)\n );\n\n if (indexB >= 0) {\n usedA.add(indexA);\n usedB.add(indexB);\n fuzzyMatches += 1;\n }\n }\n\n const matches = exactMatches + fuzzyMatches;\n\n if (matches < 2) return 0;\n\n // Dos palabras solo son suficientes cuando ambas coinciden exactamente.\n // Esto evita falsos positivos como un apellido correcto acompañado por\n // un nombre distinto que solo se parece parcialmente.\n if (matches === 2 && exactMatches < 2) return 0;\n\n // En nombres largos se exige al menos dos piezas exactas y se permite\n // que una tercera palabra tenga una diferencia ortográfica pequeña.\n if (matches >= 3 && exactMatches < 2) return 0;\n\n const ratioToShorter =\n matches / Math.min(wordsA.length, wordsB.length);\n const ratioToLonger =\n matches / Math.max(wordsA.length, wordsB.length);\n\n return (\n ratioToShorter * 0.7 +\n ratioToLonger * 0.3\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 ...linkedPayrollNames,\n ].map((value) => String(value || '').trim()).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 bankRowReferenceText(row) {\n return [\n row.reference || '',\n row.concept || '',\n row.bank_name_file || '',\n row.bank_account_holder || '',\n ...bankRowEmployeeNumbers(row),\n ].join(' ');\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 exactAliasMap = new Map();\n const tokenIndexSets = new Map();\n const employeeNumberMap = new Map();\n\n for (let index = 0; index < employees.length; index++) {\n const employee = employees[index];\n const aliases = bambooAliases(employee)\n .map((alias) => ({\n raw: alias,\n normalized: normalizeName(alias),\n }))\n .filter((alias) => alias.normalized);\n\n const uniqueAliases = [];\n const seenAliases = new Set();\n\n for (const alias of aliases) {\n if (seenAliases.has(alias.normalized)) continue;\n seenAliases.add(alias.normalized);\n uniqueAliases.push({\n ...alias,\n words: nameWords(alias.normalized),\n });\n\n const exact = exactAliasMap.get(alias.normalized) || [];\n exact.push(index);\n exactAliasMap.set(alias.normalized, exact);\n\n const uniqueTokens = Array.from(new Set(\n nameWords(alias.normalized)\n .filter((token) => token.length >= 3)\n ));\n\n for (const token of uniqueTokens) {\n const set = tokenIndexSets.get(token) || new Set();\n set.add(index);\n tokenIndexSets.set(token, set);\n }\n }\n\n const employeeNumber = bambooEmployeeNumber(employee);\n\n if (employeeNumber.length >= 6) {\n const matches = employeeNumberMap.get(employeeNumber) || [];\n matches.push(index);\n employeeNumberMap.set(employeeNumber, matches);\n }\n\n records.push({\n employee,\n aliases: uniqueAliases,\n employeeNumber,\n });\n }\n\n const tokenIndex = new Map();\n for (const [token, set] of tokenIndexSets.entries()) {\n tokenIndex.set(token, Array.from(set));\n }\n\n return {\n records,\n exactAliasMap,\n tokenIndex,\n employeeNumberMap,\n };\n}\n\nconst bambooMatchCache = new Map();\n\nfunction findBambooMatch(bankRow) {\n const names = bankRowNames(bankRow);\n const normalizedNames = Array.from(new Set(\n names.map(normalizeName).filter(Boolean)\n ));\n const directEmployeeNumbers = bankRowEmployeeNumbers(bankRow);\n const referenceNumberTokens = Array.from(new Set(\n (\n String(bankRowReferenceText(bankRow) || '')\n .match(/\\d{6,}/g) || []\n )\n .map(normalizeAccount)\n .filter((value) => value.length >= 6)\n ));\n\n const cacheKey = [\n ...directEmployeeNumbers.sort(),\n ...referenceNumberTokens.sort(),\n ...normalizedNames.sort(),\n ].join('|');\n\n if (bambooMatchCache.has(cacheKey)) {\n return bambooMatchCache.get(cacheKey);\n }\n\n const numberCandidateIndexes = new Set();\n\n for (const employeeNumber of directEmployeeNumbers) {\n for (\n const index of\n bambooSearch.employeeNumberMap.get(employeeNumber) || []\n ) {\n numberCandidateIndexes.add(index);\n }\n }\n\n if (!numberCandidateIndexes.size && referenceNumberTokens.length) {\n for (const referenceNumber of referenceNumberTokens) {\n for (\n const index of\n bambooSearch.employeeNumberMap.get(referenceNumber) || []\n ) {\n numberCandidateIndexes.add(index);\n }\n }\n }\n\n if (numberCandidateIndexes.size === 1) {\n const index = numberCandidateIndexes.values().next().value;\n const record = bambooSearch.records[index];\n\n // Un Employee Number enlazado desde la nómina es confiable.\n // Si proviene solamente de la referencia bancaria, también se exige\n // que el nombre corresponda para evitar falsos positivos por números\n // accidentales dentro del Addenda.\n const referenceNameScore = Math.max(\n 0,\n ...names.flatMap((currentBankName) =>\n record.aliases.map((alias) =>\n nameSimilarityScore(\n currentBankName,\n alias.normalized\n )\n )\n )\n );\n\n if (\n directEmployeeNumbers.length ||\n referenceNameScore >= 0.84\n ) {\n const result = {\n found: true,\n matched_by: directEmployeeNumbers.length\n ? 'employee_number_payroll'\n : 'employee_number_reference_and_name',\n confidence: directEmployeeNumbers.length\n ? 1\n : referenceNameScore,\n employee: record.employee,\n };\n bambooMatchCache.set(cacheKey, result);\n return result;\n }\n\n // La coincidencia numérica aislada se descarta y se continúa\n // con la validación por nombre.\n numberCandidateIndexes.clear();\n }\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 // Se prioriza el nombre más informativo de la fila. Esto evita que un\n // nombre corto y ambiguo bloquee un nombre completo que identifica a una\n // sola persona, por ejemplo \"Carlos De Leon\" frente a\n // \"Carlos Alexander De Leon Chajon\".\n const informativeNames = names\n .map((raw) => ({\n raw,\n tokens: nameWords(raw).length,\n }))\n .filter((entry) =>\n entry.tokens >= 3\n )\n .sort((left, right) =>\n right.tokens - left.tokens\n );\n\n for (const informativeName of informativeNames) {\n let bestInformative = null;\n let secondInformative = null;\n\n for (\n let index = 0;\n index < bambooSearch.records.length;\n index++\n ) {\n const record =\n bambooSearch.records[index];\n\n let score = 0;\n let bestAlias = '';\n\n for (const alias of record.aliases) {\n const currentScore =\n nameSimilarityScore(\n informativeName.raw,\n alias.normalized\n );\n\n if (currentScore > score) {\n score = currentScore;\n bestAlias = alias.raw;\n }\n }\n\n if (score <= 0) continue;\n\n const candidate = {\n index,\n record,\n score,\n bamboo_alias: bestAlias,\n };\n\n if (\n !bestInformative ||\n candidate.score >\n bestInformative.score\n ) {\n secondInformative =\n bestInformative;\n bestInformative =\n candidate;\n } else if (\n !secondInformative ||\n candidate.score >\n secondInformative.score\n ) {\n secondInformative =\n candidate;\n }\n }\n\n const informativeMargin =\n bestInformative\n ? bestInformative.score -\n (secondInformative?.score || 0)\n : 0;\n\n if (\n bestInformative &&\n bestInformative.score >= 0.90 &&\n informativeMargin >= 0.05\n ) {\n const result = {\n found: true,\n matched_by:\n bestInformative.score === 1\n ? 'exact_informative_name'\n : 'strong_informative_name',\n confidence:\n bestInformative.score,\n employee:\n bestInformative.record.employee,\n bank_name:\n informativeName.raw,\n bamboo_alias:\n bestInformative.bamboo_alias,\n };\n\n bambooMatchCache.set(\n cacheKey,\n result\n );\n\n return result;\n }\n }\n\n const exactCandidateIndexes = new Set();\n\n for (const name of normalizedNames) {\n for (\n const index of\n bambooSearch.exactAliasMap.get(name) || []\n ) {\n exactCandidateIndexes.add(index);\n }\n }\n\n if (exactCandidateIndexes.size === 1) {\n const index = exactCandidateIndexes.values().next().value;\n const result = {\n found: true,\n matched_by: 'exact_name',\n confidence: 1,\n employee: bambooSearch.records[index].employee,\n bank_name: names[0] || '',\n bamboo_alias:\n bambooSearch.records[index].aliases[0]?.raw || '',\n };\n bambooMatchCache.set(cacheKey, result);\n return result;\n }\n\n const candidateVotes = new Map();\n\n for (const name of normalizedNames) {\n const tokens = Array.from(new Set(\n nameWords(name)\n .filter((token) => token.length >= 3)\n ));\n\n for (const token of tokens) {\n const indexes = bambooSearch.tokenIndex.get(token) || [];\n\n // Evita que nombres demasiado comunes generen cientos de comparaciones.\n if (indexes.length > 180) continue;\n\n for (const index of indexes) {\n candidateVotes.set(\n index,\n (candidateVotes.get(index) || 0) + 1\n );\n }\n }\n }\n\n // Cuando una letra fue agregada, omitida o reemplazada, puede no existir\n // ningún token exacto compartido. En ese caso se buscan tokens cercanos\n // solamente entre palabras de longitud comparable.\n if (!candidateVotes.size) {\n for (const name of normalizedNames) {\n const queryTokens = Array.from(new Set(\n nameWords(name)\n .filter((token) => token.length >= 3)\n ));\n\n for (const queryToken of queryTokens) {\n for (\n const [indexedToken, indexes] of\n bambooSearch.tokenIndex.entries()\n ) {\n if (\n Math.abs(\n queryToken.length - indexedToken.length\n ) > 2\n ) {\n continue;\n }\n\n if (\n queryToken[0] !== indexedToken[0] &&\n queryToken.at(-1) !== indexedToken.at(-1)\n ) {\n continue;\n }\n\n if (\n !bambooTokenMatches(\n queryToken,\n indexedToken\n )\n ) {\n continue;\n }\n\n if (indexes.length > 180) continue;\n\n for (const index of indexes) {\n candidateVotes.set(\n index,\n (candidateVotes.get(index) || 0) + 0.75\n );\n }\n }\n }\n }\n }\n\n const candidateIndexes = Array.from(candidateVotes.entries())\n .sort((a, b) => b[1] - a[1])\n .slice(0, 180)\n .map(([index]) => index);\n\n let best = null;\n let second = null;\n\n for (const index of candidateIndexes) {\n const record = bambooSearch.records[index];\n let bestScoreForEmployee = 0;\n let bestBankName = '';\n let bestAlias = '';\n\n for (const currentBankName of names) {\n for (const alias of record.aliases) {\n const score = nameSimilarityScore(\n currentBankName,\n alias.normalized\n );\n\n if (score > bestScoreForEmployee) {\n bestScoreForEmployee = score;\n bestBankName = currentBankName;\n bestAlias = alias.raw;\n }\n }\n }\n\n if (bestScoreForEmployee <= 0) continue;\n\n const candidate = {\n employee: record.employee,\n score: bestScoreForEmployee,\n bank_name: bestBankName,\n bamboo_alias: bestAlias,\n };\n\n if (!best || candidate.score > best.score) {\n second = best;\n best = candidate;\n } else if (!second || candidate.score > second.score) {\n second = candidate;\n }\n }\n\n let result;\n\n if (\n best &&\n best.score >= 0.78 &&\n (!second || best.score - second.score >= 0.05)\n ) {\n result = {\n found: true,\n matched_by:\n normalizeName(best.bank_name) ===\n normalizeName(best.bamboo_alias)\n ? 'exact_name'\n : 'strong_name',\n confidence: best.score,\n employee: best.employee,\n bank_name: best.bank_name,\n bamboo_alias: best.bamboo_alias,\n };\n } else {\n result = {\n found: false,\n matched_by: null,\n confidence: best?.score || 0,\n employee: null,\n ambiguous: Boolean(\n best &&\n second &&\n best.score >= 0.7 &&\n best.score - second.score < 0.05\n ),\n best_candidate: best || null,\n };\n }\n\n bambooMatchCache.set(cacheKey, result);\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 || 'TTD'}`,\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 || 'TTD',\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 || 'TTD',\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 || 'TTD'}`,\n account: normalizeAccount(row.account),\n account_is_valid: Boolean(row.account_is_valid),\n currency: row.currency || 'TTD',\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 rawBambooValidationEmployees =\n Array.isArray(data.bamboo?.employees)\n ? data.bamboo.employees\n : [];\n\nconst bambooEmployees =\n rawBambooValidationEmployees.filter(\n isBambooValidationEligible\n );\n\nconst excludedBambooValidationEmployees =\n rawBambooValidationEmployees\n .filter(\n (employee) =>\n !isBambooValidationEligible(employee)\n )\n .map((employee) => ({\n bamboo_id:\n employee.bamboo_id || '',\n employee_number:\n employee.employee_number || '',\n full_name:\n employee.full_name || '',\n country:\n employee.country || '',\n location:\n employee.location || '',\n status:\n employee.status || '',\n overlaps_period:\n Boolean(employee.overlaps_period),\n validation_scope:\n employee.validation_scope || '',\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 || 'TTD',\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: `possible_missing_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: 'Riesgo',\n category: 'posible_cuenta_mal_digitada',\n subcategory:\n 'cuenta_faltante_en_nomina_nombre_y_monto_coinciden',\n observation:\n `El nombre y el monto coinciden, pero la nómina no tiene una cuenta bancaria válida registrada. El banco utilizó la cuenta ${bank.account}.`,\n source_sheet: payroll.source_sheet,\n row_number: payroll.row_number,\n source_rows: [\n {\n source_sheet: payroll.source_sheet,\n row_number: payroll.row_number,\n account: '',\n amount: payroll.payroll_amount,\n employee_name: payroll.employee_name,\n },\n ],\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(\n (row) => row.category === 'discrepancia'\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_country:\n match.employee?.country || '',\n bamboo_location:\n match.employee?.location || '',\n bamboo_validation_scope:\n match.employee?.validation_scope || '',\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 || 'TTD',\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 || 'TTD',\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_tt',\n observation:\n 'Se encontró un pago en el banco, pero no se encontró una coincidencia confiable con un empleado de Trinidad y Tobago 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 nameDifferenceMap = new Map();\n\nfor (const reconciliationRow of coreRows) {\n const payrollName = String(\n reconciliationRow.employee_name ||\n reconciliationRow.employee ||\n ''\n ).trim();\n\n if (!payrollName) continue;\n\n for (\n const bankSourceRow of\n reconciliationRow.bank_source_rows || []\n ) {\n const bankName = String(\n bankSourceRow.bank_name_file ||\n bankSourceRow.participant_name ||\n bankSourceRow.bank_account_holder ||\n ''\n ).trim();\n\n if (\n !bankName ||\n samePersonName(payrollName, bankName)\n ) {\n continue;\n }\n\n const account = normalizeAccount(\n bankSourceRow.account ||\n reconciliationRow.bank_account ||\n reconciliationRow.bankAccount ||\n reconciliationRow.account ||\n ''\n );\n\n const key = [\n normalizeName(payrollName),\n normalizeName(bankName),\n account,\n bankSourceRow.source_file || '',\n bankSourceRow.row_number || '',\n ].join('|');\n\n if (nameDifferenceMap.has(key)) {\n continue;\n }\n\n nameDifferenceMap.set(key, {\n id: `bank_name_difference_${key}`,\n employee: payrollName,\n employee_name: payrollName,\n payroll_name: payrollName,\n bank_name: bankName,\n employeeNumber:\n reconciliationRow.employee_number ||\n reconciliationRow.employeeNumber ||\n '',\n employee_number:\n reconciliationRow.employee_number ||\n reconciliationRow.employeeNumber ||\n '',\n account,\n payrollAccount:\n reconciliationRow.payroll_account ||\n reconciliationRow.payrollAccount ||\n '',\n payroll_account:\n reconciliationRow.payroll_account ||\n reconciliationRow.payrollAccount ||\n '',\n bankAccount: account,\n bank_account: account,\n currency:\n bankSourceRow.currency ||\n reconciliationRow.currency ||\n 'TTD',\n payrollAmount:\n reconciliationRow.payroll_amount ||\n reconciliationRow.payrollAmount ||\n 0,\n payroll_amount:\n reconciliationRow.payroll_amount ||\n reconciliationRow.payrollAmount ||\n 0,\n bankAmount:\n bankSourceRow.amount || 0,\n bank_amount:\n bankSourceRow.amount || 0,\n difference: 0,\n status: 'Pendiente revisión',\n category: 'diferencia_nombre_banco',\n subcategory:\n 'nombre_nomina_vs_participante_banco',\n observation:\n `El nombre registrado en la nómina (${payrollName}) ` +\n `es diferente al nombre enviado al banco (${bankName}).`,\n bank_name_file: payrollName,\n bank_account_holder: bankName,\n source_file:\n bankSourceRow.source_file || '',\n financial_institution_id:\n bankSourceRow.financial_institution_id || '',\n reference:\n bankSourceRow.reference || '',\n row_number:\n bankSourceRow.row_number || '',\n });\n }\n}\n\nconst nameDifferenceRows = Array.from(\n nameDifferenceMap.values()\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 corePosiblesCuentas +\n bankWithoutBamboo.length +\n diferenciasNombreBanco;\n\nconst unusedPotentialSupplements =\n potentialSupplements.filter((row) => {\n return !appliedSupplementKeys.has(\n supplementKey(row)\n );\n });\n\nreturn [\n {\n json: {\n ok: true,\n stage: 'cruce_nomina_tt_banco',\n errors: [],\n metadata: data.metadata || {},\n summary: {\n coincidencias: coreCoincidencias,\n // La tarjeta de la app agrupa todos los casos de discrepancia/riesgo.\n // Se conserva el detalle puro en discrepanciasMontoPago.\n discrepancias:\n coreDiscrepancias + corePosiblesCuentas,\n discrepanciasMontoPago:\n coreDiscrepancias,\n bancoSinNomina: coreBancoSinNomina,\n bancoSinBamboo: bankWithoutBamboo.length,\n nominaSinCuenta: coreNominaSinCuenta,\n diferenciasNombreBanco,\n posiblesCuentasMalDigitadas:\n corePosiblesCuentas,\n totalResultados:\n coreCoincidencias +\n coreDiscrepancias +\n coreBancoSinNomina +\n coreNominaSinCuenta +\n corePosiblesCuentas +\n bankWithoutBamboo.length +\n diferenciasNombreBanco,\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 empleadosBambooTT:\n Number(\n data.bamboo?.trinidad_tobago_count ||\n bambooEmployees.length\n ),\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: data.bamboo || {},\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_received:\n rawBambooValidationEmployees.length,\n employees_indexed:\n bambooSearch.records.length,\n employees_excluded:\n excludedBambooValidationEmployees.length,\n excluded_employees:\n excludedBambooValidationEmployees,\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": [
6224,
7296
],
"id": "ea4d4d89-a2f9-4c3f-9c53-803706173b27",
"name": "Cruzar Nómina vs Banco"
},
{
"parameters": {
"jsCode": "const data = $input.first().json || {};\n\nfunction normalizeText(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\u00A0/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction roundMoney(value) {\n return Math.round(\n (Number(value) || 0) * 100\n ) / 100;\n}\n\nfunction firstValue(value) {\n if (Array.isArray(value)) {\n return value\n .map(normalizeText)\n .filter(Boolean)\n .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\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 subcategory = normalizeText(\n row.subcategory\n ).toLowerCase();\n\n const payrollAmount = Number(\n row.payroll_amount ??\n row.payrollAmount ??\n 0\n );\n\n const bankAmount = Number(\n row.bank_amount ??\n row.bankAmount ??\n 0\n );\n\n if (\n subcategory ===\n 'nomina_con_cuenta_sin_pago_banco' ||\n (bankAmount === 0 && payrollAmount > 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\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(/\\u00A0/g, '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim()\n )\n .filter(\n (account) =>\n account.length >= 6 &&\n !/^0+$/.test(account)\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(\n (sourceRow) => [\n sourceRow.account,\n sourceRow.payroll_account,\n sourceRow.payrollAccount,\n ]\n )\n : []),\n ];\n\n return Array.from(\n new Set(\n candidates.flatMap(accountValues)\n )\n );\n}\n\nfunction bankAccountForWrongAccount(row) {\n return firstValue(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n );\n}\n\nfunction moneyLabel(value) {\n return Math.abs(\n roundMoney(value)\n ).toLocaleString('en-US', {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n });\n}\n\nfunction wrongAccountTotalStatus(row) {\n const payrollAmount = roundMoney(\n row.payroll_amount ??\n row.payrollAmount ??\n 0\n );\n\n const bankAmount = roundMoney(\n row.bank_amount ??\n row.bankAmount ??\n 0\n );\n\n const difference = roundMoney(\n payrollAmount - bankAmount\n );\n\n if (Math.abs(difference) <= 0.02) {\n return (\n 'El total de nómina coincide con ' +\n 'el total pagado por el banco.'\n );\n }\n\n if (difference > 0) {\n return (\n 'El total de nómina supera el total ' +\n `del banco por TT$${moneyLabel(difference)}.`\n );\n }\n\n return (\n 'El total pagado por el banco supera ' +\n `el total de nómina por TT$${moneyLabel(difference)}.`\n );\n}\n\nfunction wrongAccountFinding(row) {\n const existing = normalizeText(\n row.observation || ''\n );\n\n if (existing) return existing;\n\n const payrollAccounts =\n payrollAccountsForWrongAccount(row);\n\n const bankAccount =\n bankAccountForWrongAccount(row);\n\n return (\n 'El empleado presenta una posible ' +\n 'inconsistencia entre la cuenta registrada ' +\n `en nómina (${payrollAccounts.join(' y ') || 'sin cuenta identificada'}) ` +\n `y la cuenta utilizada por el banco (${bankAccount || 'sin cuenta identificada'}).`\n );\n}\n\nconst metadata = data.metadata || {};\nconst summary = data.summary || {};\n\nconst rows = Array.isArray(data.rows)\n ? data.rows\n : [];\n\nconst bankWithoutBamboo =\n 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 periodEndLabel = formatPeriodEnd(\n metadata.period_end || ''\n);\n\nconst spreadsheetTitle =\n `Cruce de Cuentas GLM TT - ${periodLabel}`;\n\nconst sheetIds = {\n nominaVsBanco: 201,\n bancoSinNomina: 202,\n bancoSinBamboo: 203,\n diferenciasNombreBanco: 204,\n cuentaMalDigitada: 205,\n resumen: 206,\n};\n\nconst cuentaMalDigitadaCases = rows.filter(\n (row) =>\n row.category ===\n 'posible_cuenta_mal_digitada'\n);\n\nconst hasCuentaMalDigitada =\n cuentaMalDigitadaCases.length > 0;\n\nconst sheetTitles = {\n nominaVsBanco:\n '01 Nómina vs Banco',\n bancoSinNomina:\n '02 Banco sin Nómina',\n bancoSinBamboo:\n '03 Banco sin Bamboo',\n diferenciasNombreBanco:\n '04 Diferencias de Nombre',\n cuentaMalDigitada:\n '05 Cuenta Mal Digitada',\n resumen: hasCuentaMalDigitada\n ? '06 Resumen'\n : '05 Resumen',\n};\n\nconst mainRows = rows\n .filter(\n (row) =>\n row.category === 'discrepancia'\n )\n .map((row, index) => {\n const payrollAmount = roundMoney(\n row.payroll_amount ??\n row.payrollAmount ??\n 0\n );\n\n const bankAmount = roundMoney(\n row.bank_amount ??\n row.bankAmount ??\n 0\n );\n\n const difference = roundMoney(\n row.difference ??\n (payrollAmount - bankAmount)\n );\n\n return [\n index + 1,\n normalizeText(\n row.employee_name ||\n row.employee ||\n ''\n ),\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(\n row,\n difference\n ),\n normalizeText(\n row.status || 'Riesgo'\n ).toUpperCase(),\n '',\n ];\n });\n\nconst bancoSinNominaRows = rows\n .filter(\n (row) =>\n row.category ===\n 'banco_sin_nomina'\n )\n .map((row, index) => [\n index + 1,\n normalizeText(\n row.employee_name ||\n row.employee ||\n ''\n ),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n roundMoney(\n row.bank_amount ??\n row.bankAmount ??\n 0\n ),\n firstValue(\n row.source_files ||\n row.source_file ||\n ''\n ),\n normalizeText(\n row.status ||\n 'Pendiente revisión'\n ).toUpperCase(),\n normalizeText(\n row.observation || ''\n ),\n '',\n ]);\n\nconst bancoSinBambooRows =\n bankWithoutBamboo.map(\n (row, index) => [\n index + 1,\n normalizeText(\n row.bank_name_file ||\n row.employee_name ||\n row.employee ||\n ''\n ),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n roundMoney(\n row.bank_amount ??\n row.bankAmount ??\n 0\n ),\n firstValue(\n row.source_files ||\n row.source_file ||\n ''\n ),\n 'PENDIENTE REVISIÓN',\n '',\n ]\n );\n\nconst diferenciasNombreRows = rows\n .filter(\n (row) =>\n row.category ===\n 'diferencia_nombre_banco'\n )\n .map((row, index) => [\n index + 1,\n normalizeText(\n row.payroll_name ||\n row.employee_name ||\n row.employee ||\n row.bank_name_file ||\n ''\n ),\n normalizeText(\n row.bank_name ||\n row.bank_account_holder ||\n ''\n ),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n roundMoney(\n row.bank_amount ??\n row.bankAmount ??\n 0\n ),\n normalizeText(\n row.status ||\n 'Pendiente revisión'\n ).toUpperCase(),\n normalizeText(\n row.observation || ''\n ),\n '',\n ]);\n\nconst cuentaMalDigitadaRows = [];\n\ncuentaMalDigitadaCases.forEach(\n (row, index) => {\n const payrollAccounts =\n payrollAccountsForWrongAccount(row);\n\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 registrada en nómina.',\n ],\n ];\n\n fields.forEach(\n (field, fieldIndex) => {\n cuentaMalDigitadaRows.push([\n fieldIndex === 0\n ? index + 1\n : '',\n field[0],\n field[1],\n '',\n ]);\n }\n );\n }\n);\n\nconst resumenRows = [\n ['Período', periodLabel],\n [\n 'Coincidencias',\n Number(summary.coincidencias || 0),\n ],\n [\n 'Discrepancias de monto o pago',\n Number(\n summary.discrepanciasMontoPago ??\n summary.discrepancias ??\n 0\n ),\n ],\n [\n 'Banco sin nómina',\n Number(summary.bancoSinNomina || 0),\n ],\n [\n 'Banco sin Bamboo',\n Number(summary.bancoSinBamboo || 0),\n ],\n [\n 'Nómina sin cuenta no conciliada',\n Number(summary.nominaSinCuenta || 0),\n ],\n [\n 'Diferencias de nombre',\n Number(\n summary.diferenciasNombreBanco || 0\n ),\n ],\n [\n 'Posibles cuentas mal digitadas',\n Number(\n summary.posiblesCuentasMalDigitadas || 0\n ),\n ],\n [\n 'Pendientes del cruce principal',\n Number(summary.pendientes || 0),\n ],\n [\n 'Empleados BambooHR Trinidad y Tobago',\n Number(summary.empleadosBambooTT || 0),\n ],\n [\n 'Empleados BambooHR en el período',\n Number(\n summary.empleadosBambooEnPeriodo || 0\n ),\n ],\n [\n 'Filas válidas de nómina',\n Number(\n summary.filasNominaValidas || 0\n ),\n ],\n [\n 'Filas de nómina sin cuenta detectadas',\n Number(\n summary.filasNominaSinCuenta || 0\n ),\n ],\n [\n 'Transacciones bancarias',\n Number(\n summary.transaccionesBanco || 0\n ),\n ],\n [\n 'Total nómina',\n roundMoney(summary.totalNomina || 0),\n ],\n [\n 'Total banco',\n roundMoney(summary.totalBanco || 0),\n ],\n [\n 'Diferencia total',\n roundMoney(\n summary.diferenciaTotal || 0\n ),\n ],\n];\n\nfunction reportValues(\n title,\n subtitle,\n header,\n body\n) {\n return [\n [\n title,\n ...Array(\n Math.max(header.length - 1, 0)\n ).fill(''),\n ],\n [\n subtitle,\n ...Array(\n Math.max(header.length - 1, 0)\n ).fill(''),\n ],\n Array(header.length).fill(''),\n header,\n ...body,\n ];\n}\n\nconst nominaVsBancoValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Diferencias de Monto Nómina vs. Banco · Trinidad y Tobago · ${periodEndLabel}`,\n [\n '#',\n 'Empleado',\n 'Cuenta',\n 'Monto en Nómina (TT$)',\n 'Monto en Banco (TT$)',\n 'Diferencia (TT$)',\n 'Sentido',\n 'Estado',\n 'Resolución',\n ],\n mainRows\n );\n\nconst bancoSinNominaValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Pagos bancarios sin registro en la nómina · Trinidad y Tobago · ${periodEndLabel}`,\n [\n '#',\n 'Nombre en banco',\n 'Cuenta',\n 'Monto en banco (TT$)',\n 'Archivo',\n 'Estado',\n 'Observación',\n 'Resolución',\n ],\n bancoSinNominaRows\n );\n\nconst bancoSinBambooValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Pagos en banco sin empleado identificado en BambooHR · Trinidad y Tobago · ${periodEndLabel}`,\n [\n '#',\n 'Nombre en banco',\n 'Cuenta',\n 'Monto en banco (TT$)',\n 'Archivo',\n 'Estado',\n 'Resolución',\n ],\n bancoSinBambooRows\n );\n\nconst diferenciasNombreValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Diferencias de nombre entre nómina y banco · Trinidad y Tobago · ${periodEndLabel}`,\n [\n '#',\n 'Nombre en nómina',\n 'Nombre en banco',\n 'Cuenta',\n 'Monto en banco (TT$)',\n 'Estado',\n 'Observación',\n 'Resolución',\n ],\n diferenciasNombreRows\n );\n\nconst cuentaMalDigitadaValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Cuenta Mal Digitada en Nómina · Trinidad y Tobago · ${periodEndLabel}`,\n ['#', 'Campo', 'Detalle', 'Resolución'],\n cuentaMalDigitadaRows\n );\n\nconst resumenValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Resumen del cruce Nómina vs. Banco · Trinidad y Tobago · ${periodEndLabel}`,\n ['Indicador', 'Valor'],\n resumenRows\n );\n\nconst valueData = [\n {\n range:\n `'${sheetTitles.nominaVsBanco}'!A1:I`,\n values: nominaVsBancoValues,\n },\n {\n range:\n `'${sheetTitles.bancoSinNomina}'!A1:H`,\n values: bancoSinNominaValues,\n },\n {\n range:\n `'${sheetTitles.bancoSinBamboo}'!A1:G`,\n values: bancoSinBambooValues,\n },\n {\n range:\n `'${sheetTitles.diferenciasNombreBanco}'!A1:H`,\n values: diferenciasNombreValues,\n },\n ...(hasCuentaMalDigitada\n ? [\n {\n range:\n `'${sheetTitles.cuentaMalDigitada}'!A1:D`,\n values:\n cuentaMalDigitadaValues,\n },\n ]\n : []),\n {\n range:\n `'${sheetTitles.resumen}'!A1:B`,\n values: resumenValues,\n },\n];\n\nconst brandColor = {\n red: 0.29,\n green: 0.49,\n blue: 0.58,\n};\n\nconst whiteColor = {\n red: 1,\n green: 1,\n blue: 1,\n};\n\nconst borderColor = {\n red: 0.82,\n green: 0.86,\n blue: 0.88,\n};\n\nfunction mergeRow(\n sheetId,\n rowIndex,\n columnCount\n) {\n return {\n mergeCells: {\n range: {\n sheetId,\n startRowIndex: rowIndex,\n endRowIndex: rowIndex + 1,\n startColumnIndex: 0,\n endColumnIndex: columnCount,\n },\n mergeType: 'MERGE_ALL',\n },\n };\n}\n\nfunction formatRange(\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n userEnteredFormat\n) {\n const formatFields =\n Object.keys(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(${formatFields.join(',')})`,\n },\n };\n}\n\nfunction titleFormat(\n sheetId,\n rowIndex,\n columnCount,\n options = {}\n) {\n return formatRange(\n sheetId,\n rowIndex,\n rowIndex + 1,\n 0,\n columnCount,\n {\n backgroundColor: brandColor,\n textFormat: {\n bold: options.bold ?? true,\n italic:\n options.italic ?? false,\n fontSize:\n options.fontSize ?? 12,\n foregroundColor:\n whiteColor,\n },\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n );\n}\n\nfunction headerFormat(\n sheetId,\n columnCount\n) {\n return formatRange(\n sheetId,\n 3,\n 4,\n 0,\n columnCount,\n {\n backgroundColor: brandColor,\n textFormat: {\n bold: true,\n foregroundColor:\n whiteColor,\n },\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n );\n}\n\nfunction freezeRows(\n sheetId,\n count\n) {\n return {\n updateSheetProperties: {\n properties: {\n sheetId,\n gridProperties: {\n frozenRowCount: count,\n },\n },\n fields:\n 'gridProperties.frozenRowCount',\n },\n };\n}\n\nfunction setFilter(\n sheetId,\n columnCount,\n endRowIndex\n) {\n return {\n setBasicFilter: {\n filter: {\n range: {\n sheetId,\n startRowIndex: 3,\n endRowIndex,\n startColumnIndex: 0,\n endColumnIndex:\n columnCount,\n },\n },\n },\n };\n}\n\nfunction setColumnWidth(\n sheetId,\n index,\n pixelSize\n) {\n return {\n updateDimensionProperties: {\n range: {\n sheetId,\n dimension: 'COLUMNS',\n startIndex: index,\n endIndex: index + 1,\n },\n properties: {\n pixelSize,\n },\n fields: 'pixelSize',\n },\n };\n}\n\nfunction setRowHeight(\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 borderFormat(\n sheetId,\n columnCount,\n endRowIndex\n) {\n const border = {\n style: 'SOLID',\n color: borderColor,\n };\n\n return [\n formatRange(\n sheetId,\n 3,\n endRowIndex,\n 0,\n columnCount,\n {\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n {\n updateBorders: {\n range: {\n sheetId,\n startRowIndex: 3,\n endRowIndex,\n startColumnIndex: 0,\n endColumnIndex: columnCount,\n },\n top: border,\n bottom: border,\n left: border,\n right: border,\n innerHorizontal: border,\n innerVertical: border,\n },\n },\n ];\n}\n\nfunction moneyFormat(\n sheetId,\n startColumnIndex,\n endColumnIndex,\n startRowIndex,\n endRowIndex\n) {\n return formatRange(\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n {\n numberFormat: {\n type: 'NUMBER',\n pattern:\n '\"TT$\"#,##0.00',\n },\n horizontalAlignment:\n 'RIGHT',\n verticalAlignment:\n 'MIDDLE',\n }\n );\n}\n\nfunction statusFormat(\n sheetId,\n columnIndex,\n endRowIndex\n) {\n return formatRange(\n sheetId,\n 4,\n endRowIndex,\n columnIndex,\n columnIndex + 1,\n {\n backgroundColor: {\n red: 1,\n green: 0.92,\n blue: 0.92,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.82,\n green: 0.08,\n blue: 0.08,\n },\n },\n horizontalAlignment:\n 'CENTER',\n verticalAlignment:\n 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n );\n}\n\nfunction conditionalDifference(\n sheetId,\n endRowIndex,\n formula,\n backgroundColor,\n textColor\n) {\n return {\n addConditionalFormatRule: {\n rule: {\n ranges: [\n {\n sheetId,\n startRowIndex: 4,\n endRowIndex,\n startColumnIndex: 5,\n endColumnIndex: 6,\n },\n ],\n booleanRule: {\n condition: {\n type: 'CUSTOM_FORMULA',\n values: [\n {\n userEnteredValue:\n formula,\n },\n ],\n },\n format: {\n backgroundColor,\n textFormat: {\n bold: true,\n foregroundColor:\n textColor,\n },\n },\n },\n },\n index: 0,\n },\n };\n}\n\nfunction styleReport(config) {\n const {\n sheetId,\n columnCount,\n bodyRowsCount,\n widths,\n moneyColumns = [],\n statusColumn = null,\n } = config;\n\n const endRowIndex = Math.max(\n 4 + bodyRowsCount,\n 4\n );\n\n const requests = [\n mergeRow(\n sheetId,\n 0,\n columnCount\n ),\n mergeRow(\n sheetId,\n 1,\n columnCount\n ),\n titleFormat(\n sheetId,\n 0,\n columnCount,\n {\n fontSize: 12,\n bold: true,\n }\n ),\n titleFormat(\n sheetId,\n 1,\n columnCount,\n {\n fontSize: 10,\n bold: false,\n italic: true,\n }\n ),\n headerFormat(\n sheetId,\n columnCount\n ),\n freezeRows(sheetId, 4),\n setFilter(\n sheetId,\n columnCount,\n endRowIndex\n ),\n ...borderFormat(\n sheetId,\n columnCount,\n endRowIndex\n ),\n setRowHeight(\n sheetId,\n 0,\n 1,\n 30\n ),\n setRowHeight(\n sheetId,\n 1,\n 2,\n 28\n ),\n setRowHeight(\n sheetId,\n 3,\n 4,\n 42\n ),\n ...widths.map(\n (width, index) =>\n setColumnWidth(\n sheetId,\n index,\n width\n )\n ),\n ];\n\n if (bodyRowsCount > 0) {\n requests.push(\n setRowHeight(\n sheetId,\n 4,\n endRowIndex,\n 30\n )\n );\n\n for (\n const [startColumn, endColumn] of\n moneyColumns\n ) {\n requests.push(\n moneyFormat(\n sheetId,\n startColumn,\n endColumn,\n 4,\n endRowIndex\n )\n );\n }\n\n if (\n Number.isInteger(\n statusColumn\n )\n ) {\n requests.push(\n statusFormat(\n sheetId,\n statusColumn,\n endRowIndex\n )\n );\n }\n }\n\n return requests;\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 ...styleReport({\n sheetId:\n sheetIds.nominaVsBanco,\n columnCount: 9,\n bodyRowsCount:\n mainRows.length,\n widths: [\n 48,\n 250,\n 145,\n 135,\n 135,\n 135,\n 180,\n 120,\n 260,\n ],\n moneyColumns: [\n [3, 6],\n ],\n statusColumn: 7,\n }),\n\n ...(mainRows.length > 0\n ? [\n conditionalDifference(\n sheetIds.nominaVsBanco,\n 4 + mainRows.length,\n '=$F5>0',\n {\n red: 1,\n green: 0.97,\n blue: 0.82,\n },\n {\n red: 0.45,\n green: 0.27,\n blue: 0,\n }\n ),\n conditionalDifference(\n sheetIds.nominaVsBanco,\n 4 + mainRows.length,\n '=$F5<0',\n {\n red: 1,\n green: 0.89,\n blue: 0.89,\n },\n {\n red: 0.85,\n green: 0.05,\n blue: 0.05,\n }\n ),\n ]\n : []),\n\n ...styleReport({\n sheetId:\n sheetIds.bancoSinNomina,\n columnCount: 8,\n bodyRowsCount:\n bancoSinNominaRows.length,\n widths: [\n 48,\n 230,\n 145,\n 135,\n 230,\n 140,\n 360,\n 260,\n ],\n moneyColumns: [[3, 4]],\n statusColumn: 5,\n }),\n\n ...styleReport({\n sheetId:\n sheetIds.bancoSinBamboo,\n columnCount: 7,\n bodyRowsCount:\n bancoSinBambooRows.length,\n widths: [\n 48,\n 250,\n 145,\n 140,\n 250,\n 150,\n 260,\n ],\n moneyColumns: [[3, 4]],\n statusColumn: 5,\n }),\n\n ...styleReport({\n sheetId:\n sheetIds.diferenciasNombreBanco,\n columnCount: 8,\n bodyRowsCount:\n diferenciasNombreRows.length,\n widths: [\n 48,\n 240,\n 240,\n 145,\n 140,\n 150,\n 420,\n 260,\n ],\n moneyColumns: [[4, 5]],\n statusColumn: 5,\n }),\n];\n\nif (hasCuentaMalDigitada) {\n const endRowIndex =\n 4 +\n cuentaMalDigitadaRows.length;\n\n formatRequests.push(\n ...styleReport({\n sheetId:\n sheetIds.cuentaMalDigitada,\n columnCount: 4,\n bodyRowsCount:\n cuentaMalDigitadaRows.length,\n widths: [\n 48,\n 300,\n 520,\n 260,\n ],\n statusColumn: null,\n })\n );\n\n cuentaMalDigitadaCases.forEach(\n (_, caseIndex) => {\n const startRowIndex =\n 4 + caseIndex * 6;\n\n const endCaseRowIndex =\n startRowIndex + 6;\n\n formatRequests.push(\n {\n mergeCells: {\n range: {\n sheetId:\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endRowIndex:\n endCaseRowIndex,\n startColumnIndex: 0,\n endColumnIndex: 1,\n },\n mergeType:\n 'MERGE_ALL',\n },\n },\n {\n mergeCells: {\n range: {\n sheetId:\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endRowIndex:\n endCaseRowIndex,\n startColumnIndex: 3,\n endColumnIndex: 4,\n },\n mergeType:\n 'MERGE_ALL',\n },\n },\n formatRange(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endCaseRowIndex,\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:\n 'CENTER',\n verticalAlignment:\n 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n formatRange(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endCaseRowIndex,\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:\n 'LEFT',\n verticalAlignment:\n 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n formatRange(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endCaseRowIndex,\n 2,\n 3,\n {\n horizontalAlignment:\n 'LEFT',\n verticalAlignment:\n 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n setRowHeight(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endCaseRowIndex,\n 52\n )\n );\n }\n );\n}\n\nconst resumenEndRowIndex =\n 4 + resumenRows.length;\n\nformatRequests.push(\n ...styleReport({\n sheetId:\n sheetIds.resumen,\n columnCount: 2,\n bodyRowsCount:\n resumenRows.length,\n widths: [340, 180],\n statusColumn: null,\n }),\n moneyFormat(\n sheetIds.resumen,\n 1,\n 2,\n resumenEndRowIndex - 3,\n resumenEndRowIndex\n )\n);\n\n\nconst mainReadabilityEndRow =\n 4 + mainRows.length;\n\nconst bancoSinNominaReadabilityEndRow =\n 4 + bancoSinNominaRows.length;\n\nconst bancoSinBambooReadabilityEndRow =\n 4 + bancoSinBambooRows.length;\n\nconst diferenciasNombreReadabilityEndRow =\n 4 + diferenciasNombreRows.length;\n\nconst resumenReadabilityEndRow =\n 4 + resumenRows.length;\n\n/*\n * Ajuste final de legibilidad.\n *\n * Los anchos definitivos se aplican antes del autoajuste vertical. De esta\n * forma Google Sheets calcula la altura real de cada fila después de envolver\n * el texto, evitando contenido cortado en nombres, observaciones y resúmenes.\n */\nformatRequests.push(\n // 01 Nómina vs Banco\n setColumnWidth(\n sheetIds.nominaVsBanco,\n 1,\n 300\n ),\n setColumnWidth(\n sheetIds.nominaVsBanco,\n 2,\n 150\n ),\n setColumnWidth(\n sheetIds.nominaVsBanco,\n 3,\n 145\n ),\n setColumnWidth(\n sheetIds.nominaVsBanco,\n 4,\n 145\n ),\n setColumnWidth(\n sheetIds.nominaVsBanco,\n 5,\n 145\n ),\n setColumnWidth(\n sheetIds.nominaVsBanco,\n 6,\n 220\n ),\n setColumnWidth(\n sheetIds.nominaVsBanco,\n 7,\n 140\n ),\n setColumnWidth(\n sheetIds.nominaVsBanco,\n 8,\n 320\n ),\n ...(mainRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.nominaVsBanco,\n 4,\n mainReadabilityEndRow,\n 0,\n 9\n ),\n autoResizeRowsRequest(\n sheetIds.nominaVsBanco,\n 4,\n mainReadabilityEndRow\n ),\n ]\n : []),\n\n // 02 Banco sin Nómina\n setColumnWidth(\n sheetIds.bancoSinNomina,\n 1,\n 320\n ),\n setColumnWidth(\n sheetIds.bancoSinNomina,\n 2,\n 160\n ),\n setColumnWidth(\n sheetIds.bancoSinNomina,\n 3,\n 145\n ),\n setColumnWidth(\n sheetIds.bancoSinNomina,\n 4,\n 260\n ),\n setColumnWidth(\n sheetIds.bancoSinNomina,\n 5,\n 170\n ),\n setColumnWidth(\n sheetIds.bancoSinNomina,\n 6,\n 560\n ),\n setColumnWidth(\n sheetIds.bancoSinNomina,\n 7,\n 320\n ),\n ...(bancoSinNominaRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.bancoSinNomina,\n 4,\n bancoSinNominaReadabilityEndRow,\n 0,\n 8\n ),\n autoResizeRowsRequest(\n sheetIds.bancoSinNomina,\n 4,\n bancoSinNominaReadabilityEndRow\n ),\n ]\n : []),\n\n // 03 Banco sin Bamboo\n setColumnWidth(\n sheetIds.bancoSinBamboo,\n 1,\n 320\n ),\n setColumnWidth(\n sheetIds.bancoSinBamboo,\n 2,\n 160\n ),\n setColumnWidth(\n sheetIds.bancoSinBamboo,\n 3,\n 145\n ),\n setColumnWidth(\n sheetIds.bancoSinBamboo,\n 4,\n 260\n ),\n setColumnWidth(\n sheetIds.bancoSinBamboo,\n 5,\n 170\n ),\n setColumnWidth(\n sheetIds.bancoSinBamboo,\n 6,\n 320\n ),\n ...(bancoSinBambooRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.bancoSinBamboo,\n 4,\n bancoSinBambooReadabilityEndRow,\n 0,\n 7\n ),\n autoResizeRowsRequest(\n sheetIds.bancoSinBamboo,\n 4,\n bancoSinBambooReadabilityEndRow\n ),\n ]\n : []),\n\n // 04 Diferencias de Nombre\n setColumnWidth(\n sheetIds.diferenciasNombreBanco,\n 1,\n 320\n ),\n setColumnWidth(\n sheetIds.diferenciasNombreBanco,\n 2,\n 320\n ),\n setColumnWidth(\n sheetIds.diferenciasNombreBanco,\n 3,\n 160\n ),\n setColumnWidth(\n sheetIds.diferenciasNombreBanco,\n 4,\n 145\n ),\n setColumnWidth(\n sheetIds.diferenciasNombreBanco,\n 5,\n 170\n ),\n setColumnWidth(\n sheetIds.diferenciasNombreBanco,\n 6,\n 600\n ),\n setColumnWidth(\n sheetIds.diferenciasNombreBanco,\n 7,\n 320\n ),\n ...(diferenciasNombreRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.diferenciasNombreBanco,\n 4,\n diferenciasNombreReadabilityEndRow,\n 0,\n 8\n ),\n autoResizeRowsRequest(\n sheetIds.diferenciasNombreBanco,\n 4,\n diferenciasNombreReadabilityEndRow\n ),\n ]\n : []),\n\n // 05 Cuenta Mal Digitada\n ...(hasCuentaMalDigitada\n ? [\n setColumnWidth(\n sheetIds.cuentaMalDigitada,\n 1,\n 300\n ),\n setColumnWidth(\n sheetIds.cuentaMalDigitada,\n 2,\n 600\n ),\n setColumnWidth(\n sheetIds.cuentaMalDigitada,\n 3,\n 320\n ),\n ]\n : []),\n\n // Resumen\n setColumnWidth(\n sheetIds.resumen,\n 0,\n 380\n ),\n setColumnWidth(\n sheetIds.resumen,\n 1,\n 320\n ),\n ...(resumenRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.resumen,\n 4,\n resumenReadabilityEndRow,\n 0,\n 2\n ),\n autoResizeRowsRequest(\n sheetIds.resumen,\n 4,\n resumenReadabilityEndRow\n ),\n ]\n : [])\n);\n\n\nreturn [\n {\n json: {\n ok: true,\n stage:\n 'preparar_google_sheet_tt',\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:\n sheetIds.nominaVsBanco,\n title:\n sheetTitles.nominaVsBanco,\n },\n },\n {\n properties: {\n sheetId:\n sheetIds.bancoSinNomina,\n title:\n sheetTitles.bancoSinNomina,\n },\n },\n {\n properties: {\n sheetId:\n sheetIds.bancoSinBamboo,\n title:\n 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:\n sheetIds.resumen,\n title:\n sheetTitles.resumen,\n },\n },\n ],\n },\n valueBatchBody: {\n valueInputOption:\n 'RAW',\n data: valueData,\n },\n formatBatchBody: {\n requests: formatRequests,\n },\n originalResponse: data,\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
7264,
7296
],
"id": "0269ecb0-63b1-43f6-9f34-d18d97a104b4",
"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/Port_of_Spain',\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": [
7520,
7296
],
"id": "1ef3b281-554e-41dd-b95f-28fa3a1e70be",
"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": [
7776,
7296
],
"id": "cf220e3d-0954-4b08-aa48-7d90afbb099d",
"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": [
8032,
7296
],
"id": "1e76a3ba-00f2-4455-ac64-e1f377e3d309",
"name": "Formatear Google Sheet",
"credentials": {
"googleOAuth2Api": {
"id": "dQ1MJSJSWcoWYcb8",
"name": "Google account - Isaac Producción"
}
}
},
{
"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": [
8304,
7296
],
"id": "bba45803-df53-41eb-9f04-988fc9456a3c",
"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": [
8560,
7296
],
"id": "00d29e58-0e73-4b96-8848-f3012b30189b",
"name": "Compartir Google Sheet",
"credentials": {
"googleOAuth2Api": {
"id": "dQ1MJSJSWcoWYcb8",
"name": "Google account - Isaac Producción"
}
}
},
{
"parameters": {
"jsCode": "const cruce =\n $('Cruzar Nómina vs Banco').first().json || {};\n\nconst createdSheet =\n $('Crear Google Sheet').first().json || {};\n\nconst metadata = cruce.metadata || {};\nconst summary = cruce.summary || {};\nconst debug = cruce.debug || {};\n\nconst spreadsheetId =\n createdSheet.spreadsheetId ||\n cruce.spreadsheetId ||\n '';\n\nconst reportUrl =\n createdSheet.spreadsheetUrl ||\n createdSheet.spreadsheet_url ||\n (\n spreadsheetId\n ? `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit`\n : null\n );\n\nfunction toNumber(value) {\n const parsed = Number(value);\n return Number.isFinite(parsed)\n ? parsed\n : 0;\n}\n\nfunction buildPeriodKey(periodMetadata) {\n const country =\n periodMetadata.country || 'TT';\n\n const year =\n periodMetadata.year || '';\n\n const month = String(\n periodMetadata.month || ''\n ).padStart(2, '0');\n\n const periodType =\n periodMetadata.period_type ||\n 'periodo';\n\n return (\n `${country}-${year}-${month}-${periodType}`\n );\n}\n\nconst discrepancias =\n toNumber(summary.discrepancias);\n\nconst discrepanciasMontoPago =\n toNumber(\n summary.discrepanciasMontoPago ??\n Math.max(\n 0,\n discrepancias -\n toNumber(\n summary.posiblesCuentasMalDigitadas\n )\n )\n );\n\nconst bancoSinNomina =\n toNumber(summary.bancoSinNomina);\n\nconst nominaSinCuenta =\n toNumber(summary.nominaSinCuenta);\n\nconst diferenciasNombreBanco =\n toNumber(\n summary.diferenciasNombreBanco\n );\n\nconst bancoSinBamboo =\n toNumber(summary.bancoSinBamboo);\n\nconst posiblesCuentasMalDigitadas =\n toNumber(\n summary.posiblesCuentasMalDigitadas\n );\n\nconst pendientes =\n toNumber(summary.pendientes) ||\n (\n discrepanciasMontoPago +\n bancoSinNomina +\n nominaSinCuenta +\n posiblesCuentasMalDigitadas +\n bancoSinBamboo +\n diferenciasNombreBanco\n );\n\nconst requiereRevision =\n pendientes > 0 ||\n bancoSinBamboo > 0;\n\nconst estado = requiereRevision\n ? 'pendiente_revision'\n : 'resuelto';\n\nconst payload = {\n source_app:\n metadata.source_app ||\n 'cruce-cuentas-glm-trinidad-tobago',\n\n country: 'TT',\n country_name:\n 'Trinidad y Tobago',\n\n year: toNumber(metadata.year),\n month: toNumber(metadata.month),\n period_type:\n metadata.period_type || '',\n period_label:\n metadata.period_label || '',\n period_start:\n metadata.period_start || null,\n period_end:\n metadata.period_end || null,\n period_key:\n buildPeriodKey({\n ...metadata,\n country: 'TT',\n }),\n\n payroll_file_name:\n metadata.payroll_file_name || '',\n\n bank_file_names:\n metadata.bank_file_names || [],\n\n coincidencias:\n toNumber(summary.coincidencias),\n\n discrepancias,\n\n banco_sin_bamboo:\n bancoSinBamboo,\n\n detalle_banco_sin_bamboo:\n Array.isArray(\n cruce.bankWithoutBamboo\n )\n ? cruce.bankWithoutBamboo\n : [],\n\n banco_sin_nomina:\n bancoSinNomina,\n\n nomina_sin_cuenta:\n nominaSinCuenta,\n\n nomina_sin_bamboo: 0,\n bamboo_sin_nomina: 0,\n\n filas_nomina_validas:\n toNumber(\n summary.filasNominaValidas\n ),\n\n cuentas_nomina_agrupadas:\n toNumber(\n summary.cuentasNominaAgrupadas\n ),\n\n transacciones_banco:\n toNumber(\n summary.transaccionesBanco\n ),\n\n cuentas_banco_agrupadas:\n toNumber(\n summary.cuentasBancoAgrupadas\n ),\n\n total_nomina:\n toNumber(summary.totalNomina),\n\n total_banco:\n toNumber(summary.totalBanco),\n\n diferencia_total:\n toNumber(\n summary.diferenciaTotal\n ),\n\n report_url: reportUrl,\n spreadsheet_id:\n spreadsheetId,\n estado,\n\n ejecutado_por_nombre:\n metadata.requested_by_name ||\n 'Usuario GLM',\n\n ejecutado_por_email:\n metadata.requested_by_email ||\n '',\n\n metadata: {\n ...metadata,\n country: 'TT',\n country_name:\n 'Trinidad y Tobago',\n diferencias_nombre_banco:\n diferenciasNombreBanco,\n banco_sin_bamboo:\n bancoSinBamboo,\n posibles_cuentas_mal_digitadas:\n toNumber(\n summary\n .posiblesCuentasMalDigitadas\n ),\n pendientes_cruce_principal:\n pendientes,\n requiere_revision:\n requiereRevision,\n },\n\n summary,\n\n debug: {\n sheet_summaries:\n debug.sheet_summaries || [],\n bank_name_differences:\n cruce.nameDifferences || [],\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\n // Se conserva la tabla histórica actual para\n // que la app pueda consultar todos los países\n // mediante el campo country y luego usar RPC.\n supabaseTable:\n 'cruces_cuentas_gt_reportes',\n\n supabasePayload: payload,\n reportUrl,\n spreadsheetId,\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
9904,
7296
],
"id": "09cfaf1e-f278-4f34-b3f2-09ccbc37c10d",
"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": [
10160,
7296
],
"id": "a828f0bf-44ef-452c-8aae-f089b34aace5",
"name": "Insertar histórico Supabase",
"onError": "continueRegularOutput"
},
{
"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 discrepanciasMontoPago:\n original.summary?.discrepanciasMontoPago ?? 0,\n posiblesCuentasMalDigitadas:\n original.summary?.posiblesCuentasMalDigitadas ?? 0,\n totalResultados:\n original.summary?.totalResultados ?? 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": [
10432,
7296
],
"id": "4321d98f-7d24-49d5-a03f-b19eb407f11e",
"name": "Preparar respuesta final"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{\n(() => {\n const data = $json || {};\n\n const original =\n data.originalResponse ||\n data.original_response ||\n data.response ||\n data.cruceResponse ||\n data.cruce_response ||\n data;\n\n const summary = original.summary || data.summary || {};\n const rows = original.rows || data.rows || [];\n const bankWithoutBamboo =\n original.bankWithoutBamboo ||\n data.bankWithoutBamboo ||\n [];\n const bambooSummary =\n original.bambooSummary ||\n data.bambooSummary ||\n {};\n\n const reportUrl =\n data.reportUrl ||\n data.report_url ||\n data.googleSheetUrl ||\n data.google_sheet_url ||\n data.spreadsheetUrl ||\n data.spreadsheet_url ||\n original.reportUrl ||\n original.report_url ||\n null;\n\n return {\n ok: original.ok ?? data.ok ?? true,\n message: 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": [
10688,
7296
],
"id": "5f85312f-d57b-462b-943a-a7339f81e69d",
"name": "Respond to Webhook"
},
{
"parameters": {
"content": "# 📥 RECEPCIÓN Y LECTURA DE ARCHIVOS — TT\n\nRecibe desde el Portal de Verificación de Nóminas los archivos y parámetros necesarios para procesar Trinidad y Tobago.\n\nFuentes utilizadas:\n\n- Directorio de empleados de BambooHR.\n- Archivo CSV del banco.\n- Libro de nómina con múltiples hojas o unidades.\n\nEste bloque:\n\n1. Recibe la solicitud enviada por la aplicación.\n2. Normaliza los parámetros del período.\n3. Consulta los empleados disponibles en BambooHR.\n4. Estandariza los datos del directorio.\n5. Convierte el CSV bancario en registros procesables.\n6. Extrae individualmente las hojas incluidas en el archivo de nómina.\n\nLas hojas extraídas pueden corresponder a diferentes clientes, marcas o unidades operativas.\n\nReglas:\n\n- No iniciar el cruce sin los archivos obligatorios.\n- Mantener separados los datos de banco, nómina y BambooHR.\n- Conservar el período recibido desde la aplicación.\n- No asumir que todas las hojas contienen la misma estructura.\n- Preparar una salida consistente para la etapa de consolidación.",
"height": 2016,
"width": 1424,
"color": 7
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
2576,
6624
],
"id": "d209ed03-9a6c-4377-aeb3-a25361644e5f",
"name": "Sticky Note"
},
{
"parameters": {
"content": "# 🔍 CONSOLIDACIÓN Y CRUCE — TRINIDAD Y TOBAGO\n\nConsolida todas las hojas de nómina y compara los empleados y valores contra el archivo bancario y BambooHR.\n\n## Consolidación de nómina\n\nLas hojas extraídas se unen progresivamente hasta formar una única nómina del período.\n\nDespués de combinarlas:\n\n- Se normalizan nombres.\n- Se limpian espacios y caracteres.\n- Se estandarizan correos e identificadores.\n- Se homogenizan los campos monetarios.\n- Se conserva la hoja o unidad de origen cuando sea necesario.\n\n## Cruce de fuentes\n\nEl flujo incorpora progresivamente:\n\n1. Nómina consolidada.\n2. Registros del banco.\n3. Información del empleado en BambooHR.\n\nEl cruce permite identificar casos como:\n\n- Empleados con diferencias entre nómina y banco.\n- Personas presentes únicamente en nómina.\n- Personas presentes únicamente en el banco.\n- Empleados que no pueden relacionarse con BambooHR.\n- Posibles diferencias de nombre, correo, cuenta o monto.\n\nReglas:\n\n- Evitar duplicar empleados al combinar hojas.\n- No depender únicamente del nombre cuando exista otro identificador.\n- Mantener disponibles los valores originales para validación.\n- Diferenciar una ausencia real de un problema de coincidencia.\n- Preparar los resultados en el formato requerido por el reporte final.",
"height": 1984,
"width": 1888,
"color": "#321764"
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
4528,
6560
],
"id": "17197e7f-e073-464e-b8c8-ff0856c584d7",
"name": "Sticky Note1"
},
{
"parameters": {
"content": "# 📊 GENERACIÓN DEL REPORTE EN GOOGLE SHEETS\n\nCrea el reporte final de verificación de nómina de Trinidad y Tobago.\n\nProceso:\n\n1. Organiza los resultados obtenidos durante el cruce.\n2. Define las hojas, encabezados y filas del reporte.\n3. Crea un nuevo archivo de Google Sheets.\n4. Escribe toda la información procesada.\n5. Aplica formato visual.\n6. Configura los permisos de acceso.\n7. Comparte el reporte con las personas autorizadas.\n\nEl reporte puede incluir:\n\n- Resultados del cruce.\n- Diferencias detectadas.\n- Registros sin correspondencia.\n- Información de BambooHR.\n- Resumen del período.\n- Datos necesarios para revisión y seguimiento.\n\nFormato aplicado:\n\n- Encabezados destacados.\n- Columnas ajustadas.\n- Valores monetarios con formato correcto.\n- Fechas normalizadas.\n- Filtros y congelación de encabezados cuando corresponda.\n\nReglas:\n\n- No compartir el archivo antes de terminar la escritura.\n- No devolver un enlace hasta confirmar que el Sheet existe.\n- Compartir solamente con los usuarios autorizados.\n- Mantener Google Sheets como entregable y no como fuente original de los datos.",
"height": 720,
"width": 2064,
"color": "#556822"
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
6752,
6944
],
"id": "656ae2c7-4c04-4346-90de-1e4db21a637d",
"name": "Sticky Note2"
},
{
"parameters": {
"content": "# 🗂️ HISTÓRICO Y RESPUESTA FINAL\n\nRegistra la ejecución en Supabase y devuelve el resultado al Portal de Verificación de Nóminas.\n\n## Registro histórico\n\nDespués de generar el reporte, se prepara un registro con información como:\n\n- País: Trinidad y Tobago.\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 hallazgos.\n- Enlace del Google Sheet.\n- Estado inicial del reporte.\n- Identificador de la ejecución.\n\nSupabase funciona como fuente oficial para los históricos mostrados posteriormente en el portal.\n\n## Respuesta a la aplicación\n\nEl flujo construye una respuesta final con:\n\n- Indicador de éxito.\n- Enlace al reporte.\n- Resumen de resultados.\n- Identificador del histórico.\n- Estado del proceso.\n- Mensaje apto para mostrar en la interfaz.\n\nReglas:\n\n- Registrar el histórico solamente después de crear el reporte.\n- No declarar éxito si el Sheet o el histórico fallaron.\n- No devolver credenciales ni datos internos.\n- Mantener una estructura estable para la aplicación.\n- Cerrar siempre la solicitud mediante Respond to Webhook.",
"height": 768,
"width": 2032,
"color": "#774B22"
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
8944,
6944
],
"id": "ac5189a6-802b-4f5e-a946-2aa574408974",
"name": "Sticky Note3"
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "Preparar entrada app",
"type": "main",
"index": 0
}
]
]
},
"Preparar entrada app": {
"main": [
[
{
"node": "Parsear CSV banco TT",
"type": "main",
"index": 0
},
{
"node": "Extract - BICE",
"type": "main",
"index": 0
},
{
"node": "Extract - Goldey Samuel",
"type": "main",
"index": 0
},
{
"node": "Extract - P&G",
"type": "main",
"index": 0
},
{
"node": "Extract - Whirlpool",
"type": "main",
"index": 0
},
{
"node": "Extract - KAD",
"type": "main",
"index": 0
},
{
"node": "Extract - GLM People",
"type": "main",
"index": 0
},
{
"node": "Extract - GLM",
"type": "main",
"index": 0
}
]
]
},
"Extract - BICE": {
"main": [
[
{
"node": "Merge Hojas TT 01-02",
"type": "main",
"index": 0
}
]
]
},
"Extract - Goldey Samuel": {
"main": [
[
{
"node": "Merge Hojas TT 01-02",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas TT 01-02": {
"main": [
[
{
"node": "Merge Hojas TT 03",
"type": "main",
"index": 0
}
]
]
},
"Extract - P&G": {
"main": [
[
{
"node": "Merge Hojas TT 03",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas TT 03": {
"main": [
[
{
"node": "Merge Hojas TT 04",
"type": "main",
"index": 0
}
]
]
},
"Extract - Whirlpool": {
"main": [
[
{
"node": "Merge Hojas TT 04",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas TT 04": {
"main": [
[
{
"node": "Merge Hojas TT 05",
"type": "main",
"index": 0
}
]
]
},
"Extract - KAD": {
"main": [
[
{
"node": "Merge Hojas TT 05",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas TT 05": {
"main": [
[
{
"node": "Merge Hojas TT 06",
"type": "main",
"index": 0
}
]
]
},
"Extract - GLM People": {
"main": [
[
{
"node": "Merge Hojas TT 06",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas TT 06": {
"main": [
[
{
"node": "Merge Hojas TT 07",
"type": "main",
"index": 0
}
]
]
},
"Extract - GLM": {
"main": [
[
{
"node": "Merge Hojas TT 07",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas TT 07": {
"main": [
[
{
"node": "Normalizar Nómina TT",
"type": "main",
"index": 0
}
]
]
},
"Parsear CSV banco TT": {
"main": [
[
{
"node": "Merge Banco + Nómina TT",
"type": "main",
"index": 0
}
]
]
},
"Normalizar Nómina TT": {
"main": [
[
{
"node": "Merge Banco + Nómina TT",
"type": "main",
"index": 1
}
]
]
},
"HTTP - Empleados BambooHR TT": {
"main": [
[
{
"node": "Normalizar BambooHR TT",
"type": "main",
"index": 0
}
]
]
},
"Merge Banco + Nómina TT": {
"main": [
[
{
"node": "HTTP - Empleados BambooHR TT",
"type": "main",
"index": 0
},
{
"node": "Merge - Agregar BambooHR TT",
"type": "main",
"index": 0
}
]
]
},
"Normalizar BambooHR TT": {
"main": [
[
{
"node": "Merge - Agregar BambooHR TT",
"type": "main",
"index": 1
}
]
]
},
"Merge - Agregar BambooHR TT": {
"main": [
[
{
"node": "Cruzar Nómina vs Banco",
"type": "main",
"index": 0
}
]
]
},
"Cruzar Nómina vs Banco": {
"main": [
[
{
"node": "Preparar Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Preparar Google Sheet": {
"main": [
[
{
"node": "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 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
}
]
]
},
"Preparar respuesta final": {
"main": [
[
{
"node": "Respond to Webhook",
"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": "2be11ec0-4659-47ca-9a58-38fd0f9eb4d9",
"activeVersionId": "2be11ec0-4659-47ca-9a58-38fd0f9eb4d9",
"versionCounter": 115,
"triggerCount": 1,
"shared": [
{
"updatedAt": "2026-07-14T14:51:32.557Z",
"createdAt": "2026-07-14T14:51:32.557Z",
"role": "workflow:owner",
"workflowId": "5AujMxduslftVg9z",
"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:01:27.000Z",
"createdAt": "2026-07-27T15:01:25.673Z",
"versionId": "2be11ec0-4659-47ca-9a58-38fd0f9eb4d9",
"workflowId": "5AujMxduslftVg9z",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "nominatt-bamboo-test",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
2848,
7344
],
"id": "0a3764cc-5cc2-4c62-93f8-1d201ec45e9d",
"name": "Webhook",
"webhookId": "9c730860-7790-43a5-a3c0-bf5984ced244"
},
{
"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(\n (key) => key === 'payroll_file'\n);\n\nconst bankKeys = binaryKeys\n .filter((key) => key.startsWith('bank_files'))\n .sort();\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 receivedCountry = String(\n metadata.country || ''\n).trim().toUpperCase();\n\nconst errors = [];\n\nif (!['TT', 'TTO'].includes(receivedCountry)) {\n errors.push(\n 'El país recibido no es Trinidad y Tobago.'\n );\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(\n 'No se recibió ningún archivo CSV del banco.'\n );\n}\n\nconst normalizedMetadata = {\n ...metadata,\n country: 'TT',\n country_name: 'Trinidad y Tobago',\n source_app:\n metadata.source_app ||\n 'cruce-cuentas-glm-trinidad-tobago',\n payroll_file_name:\n metadata.payroll_file_name ||\n payrollFile?.file_name ||\n '',\n bank_file_names:\n metadata.bank_file_names ||\n bankFiles.map((file) => file.file_name),\n};\n\nreturn [\n {\n json: {\n ok: errors.length === 0,\n stage: 'entrada_tt_recibida',\n errors,\n metadata: normalizedMetadata,\n payroll_file: payrollFile,\n bank_files: bankFiles,\n summary: {\n payroll_files_count:\n payrollFile ? 1 : 0,\n bank_files_count: bankFiles.length,\n },\n },\n binary,\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
3088,
7344
],
"id": "bdfa28aa-5a9e-4c08-9869-0bafa2a8cb52",
"name": "Preparar entrada app"
},
{
"parameters": {
"jsCode": "const input = $input.first();\nconst json = input.json || {};\nconst binary = input.binary || {};\n\nfunction parseCsvLine(line) {\n const result = [];\n let current = '';\n let insideQuotes = false;\n\n for (let index = 0; index < line.length; index++) {\n const character = line[index];\n const nextCharacter = line[index + 1];\n\n if (\n character === '\"' &&\n insideQuotes &&\n nextCharacter === '\"'\n ) {\n current += '\"';\n index += 1;\n continue;\n }\n\n if (character === '\"') {\n insideQuotes = !insideQuotes;\n continue;\n }\n\n if (character === ',' && !insideQuotes) {\n result.push(current.trim());\n current = '';\n continue;\n }\n\n current += character;\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(/\\u00A0/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeForCompare(value) {\n return normalizeText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/['`-]/g, '')\n .replace(/[^a-z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeAccount(value) {\n return String(value ?? '')\n .replace(/\\u00A0/g, '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim();\n}\n\nfunction isValidAccount(value) {\n const account = normalizeAccount(value);\n return (\n account.length >= 6 &&\n !/^0+$/.test(account)\n );\n}\n\nfunction parseMoney(value) {\n const cleaned = String(value ?? '')\n .replace(/TTD/gi, '')\n .replace(/TT\\$/gi, '')\n .replace(/\\$/g, '')\n .replace(/,/g, '')\n .replace(/\\s+/g, '')\n .trim();\n\n const parsed = Number.parseFloat(cleaned);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction roundMoney(value) {\n return Math.round(\n (Number(value) || 0) * 100\n ) / 100;\n}\n\nfunction getColumnIndex(headers, names) {\n const normalizedHeaders =\n headers.map(normalizeForCompare);\n\n for (const name of names) {\n const expected = normalizeForCompare(name);\n const index = normalizedHeaders.findIndex(\n (header) => header === expected\n );\n\n if (index >= 0) return index;\n }\n\n return -1;\n}\n\nconst bankKeys = Object.keys(binary)\n .filter((key) => key.startsWith('bank_files'))\n .sort();\n\nconst allBankRows = [];\nconst fileSummaries = [];\n\nfor (const key of bankKeys) {\n const file = binary[key];\n const buffer =\n await this.helpers.getBinaryDataBuffer(0, key);\n\n let text = buffer.toString('utf8');\n\n if (text.includes('\\uFFFD')) {\n text = buffer.toString('latin1');\n }\n\n const lines = text\n .split(/\\r?\\n/)\n .map((line) => line.trim())\n .filter(Boolean);\n\n const headerIndex = lines.findIndex((line) => {\n const normalized = normalizeForCompare(line);\n\n return (\n normalized.includes('identifier') &&\n normalized.includes('account number') &&\n normalized.includes('amount') &&\n normalized.includes('participant name')\n );\n });\n\n if (headerIndex < 0) {\n fileSummaries.push({\n file_name: file.fileName,\n ok: false,\n rows_count: 0,\n total_amount: 0,\n error:\n 'No se encontró el encabezado esperado del archivo bancario de Trinidad y Tobago.',\n });\n continue;\n }\n\n const headers = parseCsvLine(\n lines[headerIndex]\n ).map(normalizeText);\n\n const indexIdentifier = getColumnIndex(\n headers,\n ['Identifier']\n );\n const indexAccount = getColumnIndex(\n headers,\n ['Account Number']\n );\n const indexAccountType = getColumnIndex(\n headers,\n ['Account type']\n );\n const indexAmount = getColumnIndex(\n headers,\n ['Amount']\n );\n const indexInstitution = getColumnIndex(\n headers,\n ['Financial Institution ID']\n );\n const indexParticipantId = getColumnIndex(\n headers,\n ['Participant ID']\n );\n const indexParticipantName = getColumnIndex(\n headers,\n ['Participant Name']\n );\n const indexTransactionType = getColumnIndex(\n headers,\n ['TR Type']\n );\n const indexAddenda = getColumnIndex(\n headers,\n ['Addenda']\n );\n\n const rowsFromFile = [];\n\n for (\n let lineIndex = headerIndex + 1;\n lineIndex < lines.length;\n lineIndex++\n ) {\n const values = parseCsvLine(lines[lineIndex]);\n\n const identifier = normalizeText(\n indexIdentifier >= 0\n ? values[indexIdentifier]\n : ''\n ).toUpperCase();\n\n // T = transacción. C = fila de control/totales.\n if (identifier !== 'T') continue;\n\n const account = normalizeAccount(\n indexAccount >= 0\n ? values[indexAccount]\n : ''\n );\n\n const amount = roundMoney(\n parseMoney(\n indexAmount >= 0\n ? values[indexAmount]\n : ''\n )\n );\n\n const participantName = normalizeText(\n indexParticipantName >= 0\n ? values[indexParticipantName]\n : ''\n );\n\n if (amount <= 0 || !participantName) {\n continue;\n }\n\n const accountIsValid =\n isValidAccount(account);\n\n const groupKey = accountIsValid\n ? `ACCOUNT:${account}:TTD`\n : `ROW:${file.fileName}:${lineIndex + 1}:TTD`;\n\n const row = {\n source_file: file.fileName,\n row_number: lineIndex + 1,\n group_key: groupKey,\n account,\n raw_account: account,\n account_is_valid: accountIsValid,\n bank_name_file: participantName,\n bank_account_holder: '',\n participant_name: participantName,\n participant_id: normalizeText(\n indexParticipantId >= 0\n ? values[indexParticipantId]\n : ''\n ),\n financial_institution_id:\n normalizeText(\n indexInstitution >= 0\n ? values[indexInstitution]\n : ''\n ),\n account_type: normalizeText(\n indexAccountType >= 0\n ? values[indexAccountType]\n : ''\n ),\n transaction_type: normalizeText(\n indexTransactionType >= 0\n ? values[indexTransactionType]\n : ''\n ),\n reference: normalizeText(\n indexAddenda >= 0\n ? values[indexAddenda]\n : ''\n ),\n addenda: normalizeText(\n indexAddenda >= 0\n ? values[indexAddenda]\n : ''\n ),\n shipment_number: '',\n plan_number: '',\n amount,\n currency: 'TTD',\n status: 'Procesado',\n };\n\n rowsFromFile.push(row);\n allBankRows.push(row);\n }\n\n fileSummaries.push({\n file_name: file.fileName,\n ok: true,\n rows_count: rowsFromFile.length,\n total_amount: roundMoney(\n rowsFromFile.reduce(\n (sum, row) => sum + row.amount,\n 0\n )\n ),\n error: null,\n });\n}\n\nconst groupedMap = new Map();\n\nfor (const row of allBankRows) {\n const current =\n 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: 'TTD',\n transactions_count: 0,\n bank_name_files: new Set(),\n bank_account_holders: new Set(),\n source_files: new Set(),\n institution_ids: new Set(),\n source_rows: [],\n };\n\n current.amount = roundMoney(\n current.amount + row.amount\n );\n current.transactions_count += 1;\n\n if (row.bank_name_file) {\n current.bank_name_files.add(\n row.bank_name_file\n );\n }\n\n if (row.source_file) {\n current.source_files.add(row.source_file);\n }\n\n if (row.financial_institution_id) {\n current.institution_ids.add(\n row.financial_institution_id\n );\n }\n\n current.source_rows.push(row);\n groupedMap.set(row.group_key, current);\n}\n\nconst groupedByAccount = Array.from(\n groupedMap.values()\n).map((row) => {\n const names = Array.from(\n row.bank_name_files\n );\n\n return {\n ...row,\n bank_name_file: names[0] || '',\n bank_account_holder: '',\n bank_name_files: names,\n bank_account_holders: [],\n source_files: Array.from(\n row.source_files\n ),\n institution_ids: Array.from(\n row.institution_ids\n ),\n };\n});\n\nconst totalAmount = roundMoney(\n allBankRows.reduce(\n (sum, row) => sum + row.amount,\n 0\n )\n);\n\nreturn [\n {\n json: {\n ...json,\n stage: 'banco_tt_parseado',\n bank: {\n source:\n 'csv_ach_trinidad_tobago',\n files_count: bankKeys.length,\n valid_files_count:\n fileSummaries.filter(\n (file) => file.ok\n ).length,\n rows_count: allBankRows.length,\n grouped_accounts_count:\n groupedByAccount.length,\n total_amount: totalAmount,\n totals_by_currency: {\n TTD: totalAmount,\n },\n name_differences_count: 0,\n name_differences: [],\n file_summaries: fileSummaries,\n rows: allBankRows,\n grouped_by_account:\n groupedByAccount,\n },\n },\n binary,\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
3424,
7040
],
"id": "46e46f3c-85f9-40ef-a3cc-ee20acc46d73",
"name": "Parsear CSV banco TT"
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": true,
"sheetName": "BICE"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
3424,
7440
],
"id": "444192a4-bd82-4086-a87f-ab116517f723",
"name": "Extract - BICE",
"retryOnFail": false
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": true,
"sheetName": "Goldey Samuel"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
3424,
7616
],
"id": "15b0af6e-5e38-4c0c-9d30-496ad9df9413",
"name": "Extract - Goldey Samuel",
"retryOnFail": false
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": true,
"sheetName": "P&G"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
3424,
7776
],
"id": "22845609-e9b5-486b-88ce-bc5d73b96a2e",
"name": "Extract - P&G",
"retryOnFail": false
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": true,
"sheetName": "Whirlpool"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
3424,
7952
],
"id": "9a39edbc-1e2e-4d75-83ed-9ce48c808abf",
"name": "Extract - Whirlpool",
"retryOnFail": false
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": true,
"sheetName": "KAD"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
3424,
8128
],
"id": "1710469a-3b4a-4a3b-9c61-77d7d3c4fffb",
"name": "Extract - KAD",
"retryOnFail": false
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": true,
"sheetName": "GLM People"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
3424,
8288
],
"id": "72c876ce-f1e4-4f82-ac7d-7d409eb18e64",
"name": "Extract - GLM People",
"retryOnFail": false
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": true,
"sheetName": "GLM"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
3424,
8464
],
"id": "26d185f0-3f60-44cd-b20e-1fbfbae48fc8",
"name": "Extract - GLM",
"retryOnFail": false
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
5088,
7536
],
"id": "b98e1002-cf47-4cde-94d7-08777b928d36",
"name": "Merge Hojas TT 01-02"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
5088,
7696
],
"id": "c7c11485-0d25-4959-ba19-cf631591472a",
"name": "Merge Hojas TT 03"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
5088,
7872
],
"id": "706b5c48-c1f0-4a12-be90-51abd68f32f3",
"name": "Merge Hojas TT 04"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
5088,
8032
],
"id": "a6cff463-cf7d-4e70-b13d-213dbcefa388",
"name": "Merge Hojas TT 05"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
5088,
8208
],
"id": "25f84f87-c966-4b81-ac10-9021b7eeadc3",
"name": "Merge Hojas TT 06"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
5088,
8384
],
"id": "75156223-c7d3-4b98-bdc5-aa62a9185db1",
"name": "Merge Hojas TT 07"
},
{
"parameters": {
"jsCode": "function normalizeText(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\u00A0/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeForCompare(value) {\n return normalizeText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/['`-]/g, '')\n .replace(/[^a-z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeAccount(value) {\n if (\n value === null ||\n value === undefined ||\n value === ''\n ) {\n return '';\n }\n\n if (typeof value === 'number') {\n return String(Math.trunc(value));\n }\n\n return String(value)\n .replace(/\\u00A0/g, '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim();\n}\n\nfunction parseMoney(value) {\n if (typeof value === 'number') {\n return Number.isFinite(value)\n ? value\n : 0;\n }\n\n const cleaned = String(value ?? '')\n .replace(/TTD/gi, '')\n .replace(/TT\\$/gi, '')\n .replace(/\\$/g, '')\n .replace(/,/g, '')\n .replace(/\\s+/g, '')\n .trim();\n\n const parsed = Number.parseFloat(cleaned);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction roundMoney(value) {\n return Math.round(\n (Number(value) || 0) * 100\n ) / 100;\n}\n\nfunction getValue(row, possibleKeys) {\n for (const key of possibleKeys) {\n const value = row[key];\n\n if (\n value !== undefined &&\n value !== null &&\n value !== ''\n ) {\n return value;\n }\n }\n\n const rowKeys = Object.keys(row || {});\n\n for (const expected of possibleKeys) {\n const normalizedExpected =\n normalizeForCompare(expected);\n\n const matchingKey = rowKeys.find(\n (key) =>\n normalizeForCompare(key) ===\n normalizedExpected\n );\n\n if (!matchingKey) continue;\n\n const value = row[matchingKey];\n\n if (\n value !== undefined &&\n value !== null &&\n value !== ''\n ) {\n return value;\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 if (row.error) return false;\n\n const text = JSON.stringify(\n row || {}\n ).toLowerCase();\n\n return !(\n text.includes(\n 'spreadsheet does not contain sheet'\n ) ||\n text.includes('no sheet')\n );\n });\n } catch (error) {\n return [];\n }\n}\n\nfunction validEmployeeName(value) {\n const name = normalizeText(value);\n const normalized = normalizeForCompare(name);\n\n if (!name) return false;\n if (/^[\\d.,\\s]+$/.test(name)) return false;\n\n const invalid = [\n 'total',\n 'subtotal',\n 'gran total',\n 'total general',\n 'variable',\n 'empleado',\n 'first name',\n 'nombre',\n 'diferencia',\n 'total dias',\n ];\n\n return !invalid.some(\n (token) =>\n normalized === token ||\n normalized.startsWith(`${token} `)\n );\n}\n\nfunction validAccount(value) {\n const account = normalizeAccount(value);\n\n return (\n account.length >= 6 &&\n !/^0+$/.test(account)\n );\n}\n\nconst sheetConfigs = [\n {\n node: 'Extract - BICE',\n sheet: 'BICE',\n },\n {\n node: 'Extract - Goldey Samuel',\n sheet: 'Goldey Samuel',\n },\n {\n node: 'Extract - P&G',\n sheet: 'P&G',\n },\n {\n node: 'Extract - Whirlpool',\n sheet: 'Whirlpool',\n },\n {\n node: 'Extract - KAD',\n sheet: 'KAD',\n },\n {\n node: 'Extract - GLM People',\n sheet: 'GLM People',\n },\n {\n node: 'Extract - GLM',\n sheet: 'GLM',\n },\n];\n\nconst payrollRows = [];\nconst noAccountRows = [];\nconst ignoredRows = [];\nconst sheetSummaries = [];\n\nfor (const config of sheetConfigs) {\n const sourceRows = getNodeRows(\n config.node\n );\n\n let validRows = 0;\n let noAccountCount = 0;\n let ignoredCount = 0;\n let sheetTotal = 0;\n\n sourceRows.forEach((sourceRow, index) => {\n const period = normalizeText(\n getValue(sourceRow, ['Periodo'])\n );\n\n const employeeName = normalizeText(\n getValue(sourceRow, [\n 'First Name',\n 'Nombre completo',\n 'Empleado',\n 'Name',\n ])\n );\n\n const account = normalizeAccount(\n getValue(sourceRow, [\n 'Account #',\n 'Account Number',\n 'Cuenta bancaria',\n 'Cuenta Bancaria',\n ])\n );\n\n const email = normalizeText(\n getValue(sourceRow, [\n 'EMAIL',\n 'Email',\n 'Correo',\n ])\n ).toLowerCase();\n\n const amount = roundMoney(\n parseMoney(\n getValue(sourceRow, [\n 'NETO A PAGAR',\n 'Neto a Pagar',\n 'Net Pay',\n ])\n )\n );\n\n const client = normalizeText(\n getValue(sourceRow, ['Cuenta'])\n );\n\n const rowNumber = index + 2;\n\n const normalized = {\n source_sheet: config.sheet,\n row_number: rowNumber,\n period,\n employee_name: employeeName,\n employee_number: null,\n account,\n email,\n client,\n payroll_amount: amount,\n currency: 'TTD',\n };\n\n if (\n !period ||\n !validEmployeeName(employeeName) ||\n amount <= 0 ||\n amount > 500000\n ) {\n ignoredRows.push({\n ...normalized,\n reason:\n !period\n ? 'period_empty'\n : !validEmployeeName(employeeName)\n ? 'invalid_employee_name'\n : amount <= 0\n ? 'amount_zero_or_invalid'\n : 'suspicious_large_amount',\n });\n\n ignoredCount += 1;\n return;\n }\n\n sheetTotal = roundMoney(\n sheetTotal + amount\n );\n\n if (!validAccount(account)) {\n noAccountRows.push({\n ...normalized,\n account: '',\n });\n\n noAccountCount += 1;\n return;\n }\n\n payrollRows.push(normalized);\n validRows += 1;\n });\n\n sheetSummaries.push({\n sheet: config.sheet,\n node: config.node,\n raw_rows_count: sourceRows.length,\n valid_rows_count: validRows,\n no_account_rows_count:\n noAccountCount,\n ignored_rows_count: ignoredCount,\n total_amount: sheetTotal,\n });\n}\n\nconst groupedMap = new Map();\n\nfor (const row of payrollRows) {\n const groupKey =\n `${row.account}:${row.currency}`;\n\n const current =\n 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: 'TTD',\n payroll_amount: 0,\n rows_count: 0,\n source_sheets: new Set(),\n source_rows: [],\n };\n\n current.payroll_amount = roundMoney(\n current.payroll_amount +\n row.payroll_amount\n );\n\n current.rows_count += 1;\n\n if (!current.email && row.email) {\n current.email = row.email;\n }\n\n current.source_sheets.add(\n row.source_sheet\n );\n\n current.source_rows.push({\n source_sheet: row.source_sheet,\n row_number: row.row_number,\n account: row.account,\n amount: row.payroll_amount,\n employee_name: row.employee_name,\n });\n\n groupedMap.set(groupKey, current);\n}\n\nconst groupedByAccount = Array.from(\n groupedMap.values()\n).map((row) => ({\n ...row,\n source_sheets: Array.from(\n row.source_sheets\n ),\n}));\n\nconst totalAmount = roundMoney(\n payrollRows.reduce(\n (sum, row) => sum + row.payroll_amount,\n 0\n ) +\n noAccountRows.reduce(\n (sum, row) => sum + row.payroll_amount,\n 0\n )\n);\n\nreturn [\n {\n json: {\n payroll: {\n source:\n 'template_trinidad_tobago',\n sheets_count:\n sheetConfigs.length,\n sheet_summaries:\n sheetSummaries,\n raw_rows_count:\n sheetSummaries.reduce(\n (sum, sheet) =>\n sum + sheet.raw_rows_count,\n 0\n ),\n valid_rows_count:\n payrollRows.length,\n no_account_rows_count:\n noAccountRows.length,\n ignored_rows_count:\n ignoredRows.length,\n grouped_accounts_count:\n groupedByAccount.length,\n attached_supplements_count: 0,\n potential_supplements_count: 0,\n potential_supplements: [],\n unattached_supplements_count: 0,\n total_amount: totalAmount,\n totals_by_currency: {\n TTD: totalAmount,\n },\n rows: payrollRows,\n no_account_rows:\n noAccountRows,\n grouped_by_account:\n groupedByAccount,\n },\n debug_payroll: {\n attached_supplements: [],\n potential_supplements: [],\n unattached_supplements: [],\n ignored_rows_preview:\n ignoredRows.slice(0, 100),\n no_account_rows_preview:\n noAccountRows.slice(0, 50),\n },\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
5456,
7952
],
"id": "d036e80f-bf9c-4024-9bdb-f9222d8ee057",
"name": "Normalizar Nómina TT"
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"options": {}
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
5712,
7296
],
"id": "8d2116b6-d720-47a0-b532-aec6f16966c1",
"name": "Merge Banco + Nómina TT"
},
{
"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 TT",
"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": [
3424,
6816
],
"id": "84b74d83-a969-4f62-a11b-ace145d64e8c",
"name": "HTTP - Empleados BambooHR TT",
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 3000,
"credentials": {
"httpBasicAuth": {
"id": "7VrpNZ2jBLmiJ35q",
"name": "BambooHR GLM Full Access"
}
}
},
{
"parameters": {
"jsCode": "const inputItems = $input.all();\nconst base = $('Preparar entrada app').first().json || {};\nconst reconciliationData = $('Merge Banco + Nómina TT').first().json || {};\nconst metadata = base.metadata || {};\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 return Array.from(\n new Set(\n values\n .map(clean)\n .filter(Boolean)\n )\n );\n}\n\nfunction nameTokens(value) {\n const ignored = new Set([\n 'de', 'del', 'la', 'las', 'los',\n 'y', 'e', 'el', 'da', 'do',\n 'dos', 'das', 'van', 'von',\n ]);\n\n return normalize(value)\n .split(' ')\n .filter(\n (token) =>\n token.length > 1 &&\n !ignored.has(token)\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(/^(\\d{4})-(\\d{2})-(\\d{2})/);\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 === 'tt' ||\n country === 'tto' ||\n country.includes('trinidad') ||\n country.includes('tobago') ||\n location === 'tt' ||\n location === 'tto' ||\n location.includes('trinidad') ||\n location.includes('tobago')\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 strictInformativeContainment(\n left,\n right\n) {\n const leftTokens =\n Array.from(new Set(nameTokens(left)));\n const rightTokens =\n Array.from(new Set(nameTokens(right)));\n\n if (\n leftTokens.length < 3 ||\n rightTokens.length < 3\n ) {\n return false;\n }\n\n const leftSet = new Set(leftTokens);\n const rightSet = new Set(rightTokens);\n\n const leftInsideRight =\n leftTokens.every((token) =>\n rightSet.has(token)\n );\n\n const rightInsideLeft =\n rightTokens.every((token) =>\n leftSet.has(token)\n );\n\n return leftInsideRight || rightInsideLeft;\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 allNormalized = rawEmployees.map((employee) => {\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 return {\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\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 current =\n relevantNameMap.get(normalized);\n\n if (\n !current ||\n nameTokens(cleaned).length >\n nameTokens(current).length\n ) {\n relevantNameMap.set(\n normalized,\n cleaned\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\nconst targetEmployees =\n allNormalized\n .filter(isTargetCountry)\n .map((employee) => ({\n ...employee,\n validation_eligible: true,\n validation_scope:\n 'trinidad_tobago_country_or_location',\n }));\n\nconst outsideEmployees =\n allNormalized.filter(\n (employee) =>\n !isTargetCountry(employee)\n );\n\nconst contextualOutsideMap = new Map();\n\nfor (\n const relevantName of\n relevantNameMap.values()\n) {\n if (\n nameTokens(relevantName).length < 3\n ) {\n continue;\n }\n\n const matches = outsideEmployees\n .filter((employee) =>\n (employee.aliases || []).some(\n (alias) =>\n strictInformativeContainment(\n relevantName,\n alias\n )\n )\n );\n\n const uniqueMatches = new Map();\n\n for (const employee of matches) {\n const key =\n employee.bamboo_id ||\n employee.employee_number ||\n normalize(employee.full_name);\n\n if (key) {\n uniqueMatches.set(key, employee);\n }\n }\n\n // Solo se rescata un perfil fuera del país cuando un nombre\n // informativo identifica exactamente a una única persona.\n if (uniqueMatches.size !== 1) {\n continue;\n }\n\n const employee =\n uniqueMatches.values().next().value;\n\n const key =\n employee.bamboo_id ||\n employee.employee_number ||\n normalize(employee.full_name);\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 =\n employee.bamboo_id ||\n employee.employee_number ||\n normalize(employee.full_name);\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(\"ISAAC ST BERNARD\"), normalize(\"Isaac St Bernard\")],\n [normalize(\"VICTORIA ALPHONSO\"), normalize(\"Victoria Alphanso\")],\n [normalize(\"VICTORIA ALPHANSO\"), normalize(\"Victoria Alphanso\")],\n [normalize(\"ONELA FARREL\"), normalize(\"Onela Farrell\")],\n [normalize(\"ONELA FARRELL\"), normalize(\"Onela Farrell\")],\n [normalize(\"JESHAUGHN LOUIS\"), normalize(\"Je'Shaugn Louis\")],\n [normalize(\"JESHAUGN LOUIS\"), normalize(\"Je'Shaugn Louis\")],\n [normalize(\"JE SHAUGN LOUIS\"), normalize(\"Je'Shaugn Louis\")],\n [normalize(\"ANESSA ALI\"), normalize(\"Annesa Marina Ali\")],\n [normalize(\"ANNESA ALI\"), normalize(\"Annesa Marina Ali\")],\n [normalize(\"ANNESA MARINA ALI\"), normalize(\"Annesa Marina Ali\")],\n [normalize(\"ALANA KERCELUS\"), normalize(\"Alana Kercelus-Inalsingh\")],\n [normalize(\"ALANA KERCELUS INALSINGH\"), normalize(\"Alana Kercelus-Inalsingh\")]\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 Trinidad y Tobago 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_tt_normalizado'\n : 'bamboohr_tt_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 allNormalized.length,\n trinidad_tobago_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 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_confirmed_aliases',\n fetch_complete: fetchComplete,\n validation_available:\n fetchComplete &&\n validationEmployees.length > 0,\n validation_rule:\n 'Target country/location plus a unique informative contextual name outside the country',\n employees:\n validationEmployees,\n restricted_fields:\n restrictedFields,\n },\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
5088,
6816
],
"id": "d6d947b2-4a0e-4917-855c-7b69a27dae4e",
"name": "Normalizar BambooHR TT"
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"options": {}
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
5968,
7296
],
"id": "9760d7dd-776e-4ae7-b56b-2f8ac85cc768",
"name": "Merge - Agregar BambooHR TT"
},
{
"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(/['`-]/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 bambooTokenMatches(a, b) {\n if (tokenMatches(a, b)) return true;\n\n const minLength = Math.min(a.length, b.length);\n const maxLength = Math.max(a.length, b.length);\n const distance = editDistance(a, b);\n\n // Tolera variaciones pequeñas de escritura entre Banco/Nómina y BambooHR,\n // por ejemplo Anessa <-> Annesa, sin flexibilizar el cruce principal.\n if (minLength >= 6 && distance <= 2) {\n return true;\n }\n\n // Permite apellidos compuestos como BeharrySingh vs Singh,\n // pero evita aceptar coincidencias demasiado amplias.\n return (\n minLength >= 4 &&\n maxLength - minLength <= 10 &&\n (\n a.startsWith(b) ||\n b.startsWith(a) ||\n a.endsWith(b) ||\n b.endsWith(a)\n )\n );\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 compactA = normalizedA.replace(/\\s+/g, '');\n const compactB = normalizedB.replace(/\\s+/g, '');\n\n if (compactA === compactB) 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\nfunction bambooAliases(employee) {\n return Array.from(new Set([\n ...(Array.isArray(employee.aliases) ? employee.aliases : []),\n employee.full_name || '',\n [employee.first_name, employee.middle_name, employee.last_name]\n .filter(Boolean)\n .join(' '),\n [employee.preferred_name, employee.last_name]\n .filter(Boolean)\n .join(' '),\n ].map((value) => String(value || '').trim()).filter(Boolean)));\n}\n\nfunction bambooEmployeeNumber(employee) {\n return normalizeAccount(\n employee.employee_number ||\n employee.employeeNumber ||\n ''\n );\n}\n\nfunction isTrinidadTobagoBambooEmployee(employee) {\n const country = normalizeName(\n employee.country || ''\n );\n const location = normalizeName(\n employee.location || ''\n );\n\n return (\n country === 'tt' ||\n country === 'tto' ||\n country.includes('trinidad') ||\n country.includes('tobago') ||\n location === 'tt' ||\n location === 'tto' ||\n location.includes('trinidad') ||\n location.includes('tobago')\n );\n}\n\nfunction isBambooValidationEligible(employee) {\n // La versión corregida del normalizador declara este campo.\n if (employee.validation_eligible === true) {\n return true;\n }\n\n if (employee.validation_eligible === false) {\n return false;\n }\n\n // Compatibilidad defensiva si este nodo recibe datos de una ejecución\n // anterior: los perfiles de TT siguen siendo válidos. Un perfil de otro\n // país solo puede utilizarse cuando está Active y vigente en el período.\n if (isTrinidadTobagoBambooEmployee(employee)) {\n return true;\n }\n\n return (\n employee.overlaps_period === true &&\n normalizeName(employee.status) === 'active'\n );\n}\n\nfunction nameSimilarityScore(a, b) {\n const normalizedA = normalizeName(a);\n const normalizedB = normalizeName(b);\n\n if (!normalizedA || !normalizedB) return 0;\n if (normalizedA === normalizedB) return 1;\n\n const compactA = normalizedA.replace(/\\s+/g, '');\n const compactB = normalizedB.replace(/\\s+/g, '');\n\n if (compactA === compactB) return 1;\n\n const wordsA = nameWords(normalizedA);\n const wordsB = nameWords(normalizedB);\n\n if (!wordsA.length || !wordsB.length) return 0;\n\n const usedB = new Set();\n const usedA = new Set();\n let exactMatches = 0;\n let fuzzyMatches = 0;\n\n // Primero se reservan las coincidencias exactas para no perder\n // evidencia fuerte por el orden de las palabras.\n for (let indexA = 0; indexA < wordsA.length; indexA++) {\n const indexB = wordsB.findIndex(\n (wordB, currentIndexB) =>\n !usedB.has(currentIndexB) &&\n wordsA[indexA] === wordB\n );\n\n if (indexB >= 0) {\n usedA.add(indexA);\n usedB.add(indexB);\n exactMatches += 1;\n }\n }\n\n // Después se toleran errores ortográficos pequeños únicamente\n // para complementar una coincidencia que ya tiene evidencia exacta.\n for (let indexA = 0; indexA < wordsA.length; indexA++) {\n if (usedA.has(indexA)) continue;\n\n const indexB = wordsB.findIndex(\n (wordB, currentIndexB) =>\n !usedB.has(currentIndexB) &&\n bambooTokenMatches(wordsA[indexA], wordB)\n );\n\n if (indexB >= 0) {\n usedA.add(indexA);\n usedB.add(indexB);\n fuzzyMatches += 1;\n }\n }\n\n const matches = exactMatches + fuzzyMatches;\n\n if (matches < 2) return 0;\n\n // Dos palabras solo son suficientes cuando ambas coinciden exactamente.\n // Esto evita falsos positivos como un apellido correcto acompañado por\n // un nombre distinto que solo se parece parcialmente.\n if (matches === 2 && exactMatches < 2) return 0;\n\n // En nombres largos se exige al menos dos piezas exactas y se permite\n // que una tercera palabra tenga una diferencia ortográfica pequeña.\n if (matches >= 3 && exactMatches < 2) return 0;\n\n const ratioToShorter =\n matches / Math.min(wordsA.length, wordsB.length);\n const ratioToLonger =\n matches / Math.max(wordsA.length, wordsB.length);\n\n return (\n ratioToShorter * 0.7 +\n ratioToLonger * 0.3\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 ...linkedPayrollNames,\n ].map((value) => String(value || '').trim()).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 bankRowReferenceText(row) {\n return [\n row.reference || '',\n row.concept || '',\n row.bank_name_file || '',\n row.bank_account_holder || '',\n ...bankRowEmployeeNumbers(row),\n ].join(' ');\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 exactAliasMap = new Map();\n const tokenIndexSets = new Map();\n const employeeNumberMap = new Map();\n\n for (let index = 0; index < employees.length; index++) {\n const employee = employees[index];\n const aliases = bambooAliases(employee)\n .map((alias) => ({\n raw: alias,\n normalized: normalizeName(alias),\n }))\n .filter((alias) => alias.normalized);\n\n const uniqueAliases = [];\n const seenAliases = new Set();\n\n for (const alias of aliases) {\n if (seenAliases.has(alias.normalized)) continue;\n seenAliases.add(alias.normalized);\n uniqueAliases.push({\n ...alias,\n words: nameWords(alias.normalized),\n });\n\n const exact = exactAliasMap.get(alias.normalized) || [];\n exact.push(index);\n exactAliasMap.set(alias.normalized, exact);\n\n const uniqueTokens = Array.from(new Set(\n nameWords(alias.normalized)\n .filter((token) => token.length >= 3)\n ));\n\n for (const token of uniqueTokens) {\n const set = tokenIndexSets.get(token) || new Set();\n set.add(index);\n tokenIndexSets.set(token, set);\n }\n }\n\n const employeeNumber = bambooEmployeeNumber(employee);\n\n if (employeeNumber.length >= 6) {\n const matches = employeeNumberMap.get(employeeNumber) || [];\n matches.push(index);\n employeeNumberMap.set(employeeNumber, matches);\n }\n\n records.push({\n employee,\n aliases: uniqueAliases,\n employeeNumber,\n });\n }\n\n const tokenIndex = new Map();\n for (const [token, set] of tokenIndexSets.entries()) {\n tokenIndex.set(token, Array.from(set));\n }\n\n return {\n records,\n exactAliasMap,\n tokenIndex,\n employeeNumberMap,\n };\n}\n\nconst bambooMatchCache = new Map();\n\nfunction findBambooMatch(bankRow) {\n const names = bankRowNames(bankRow);\n const normalizedNames = Array.from(new Set(\n names.map(normalizeName).filter(Boolean)\n ));\n const directEmployeeNumbers = bankRowEmployeeNumbers(bankRow);\n const referenceNumberTokens = Array.from(new Set(\n (\n String(bankRowReferenceText(bankRow) || '')\n .match(/\\d{6,}/g) || []\n )\n .map(normalizeAccount)\n .filter((value) => value.length >= 6)\n ));\n\n const cacheKey = [\n ...directEmployeeNumbers.sort(),\n ...referenceNumberTokens.sort(),\n ...normalizedNames.sort(),\n ].join('|');\n\n if (bambooMatchCache.has(cacheKey)) {\n return bambooMatchCache.get(cacheKey);\n }\n\n const numberCandidateIndexes = new Set();\n\n for (const employeeNumber of directEmployeeNumbers) {\n for (\n const index of\n bambooSearch.employeeNumberMap.get(employeeNumber) || []\n ) {\n numberCandidateIndexes.add(index);\n }\n }\n\n if (!numberCandidateIndexes.size && referenceNumberTokens.length) {\n for (const referenceNumber of referenceNumberTokens) {\n for (\n const index of\n bambooSearch.employeeNumberMap.get(referenceNumber) || []\n ) {\n numberCandidateIndexes.add(index);\n }\n }\n }\n\n if (numberCandidateIndexes.size === 1) {\n const index = numberCandidateIndexes.values().next().value;\n const record = bambooSearch.records[index];\n\n // Un Employee Number enlazado desde la nómina es confiable.\n // Si proviene solamente de la referencia bancaria, también se exige\n // que el nombre corresponda para evitar falsos positivos por números\n // accidentales dentro del Addenda.\n const referenceNameScore = Math.max(\n 0,\n ...names.flatMap((currentBankName) =>\n record.aliases.map((alias) =>\n nameSimilarityScore(\n currentBankName,\n alias.normalized\n )\n )\n )\n );\n\n if (\n directEmployeeNumbers.length ||\n referenceNameScore >= 0.84\n ) {\n const result = {\n found: true,\n matched_by: directEmployeeNumbers.length\n ? 'employee_number_payroll'\n : 'employee_number_reference_and_name',\n confidence: directEmployeeNumbers.length\n ? 1\n : referenceNameScore,\n employee: record.employee,\n };\n bambooMatchCache.set(cacheKey, result);\n return result;\n }\n\n // La coincidencia numérica aislada se descarta y se continúa\n // con la validación por nombre.\n numberCandidateIndexes.clear();\n }\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 // Se prioriza el nombre más informativo de la fila. Esto evita que un\n // nombre corto y ambiguo bloquee un nombre completo que identifica a una\n // sola persona, por ejemplo \"Carlos De Leon\" frente a\n // \"Carlos Alexander De Leon Chajon\".\n const informativeNames = names\n .map((raw) => ({\n raw,\n tokens: nameWords(raw).length,\n }))\n .filter((entry) =>\n entry.tokens >= 3\n )\n .sort((left, right) =>\n right.tokens - left.tokens\n );\n\n for (const informativeName of informativeNames) {\n let bestInformative = null;\n let secondInformative = null;\n\n for (\n let index = 0;\n index < bambooSearch.records.length;\n index++\n ) {\n const record =\n bambooSearch.records[index];\n\n let score = 0;\n let bestAlias = '';\n\n for (const alias of record.aliases) {\n const currentScore =\n nameSimilarityScore(\n informativeName.raw,\n alias.normalized\n );\n\n if (currentScore > score) {\n score = currentScore;\n bestAlias = alias.raw;\n }\n }\n\n if (score <= 0) continue;\n\n const candidate = {\n index,\n record,\n score,\n bamboo_alias: bestAlias,\n };\n\n if (\n !bestInformative ||\n candidate.score >\n bestInformative.score\n ) {\n secondInformative =\n bestInformative;\n bestInformative =\n candidate;\n } else if (\n !secondInformative ||\n candidate.score >\n secondInformative.score\n ) {\n secondInformative =\n candidate;\n }\n }\n\n const informativeMargin =\n bestInformative\n ? bestInformative.score -\n (secondInformative?.score || 0)\n : 0;\n\n if (\n bestInformative &&\n bestInformative.score >= 0.90 &&\n informativeMargin >= 0.05\n ) {\n const result = {\n found: true,\n matched_by:\n bestInformative.score === 1\n ? 'exact_informative_name'\n : 'strong_informative_name',\n confidence:\n bestInformative.score,\n employee:\n bestInformative.record.employee,\n bank_name:\n informativeName.raw,\n bamboo_alias:\n bestInformative.bamboo_alias,\n };\n\n bambooMatchCache.set(\n cacheKey,\n result\n );\n\n return result;\n }\n }\n\n const exactCandidateIndexes = new Set();\n\n for (const name of normalizedNames) {\n for (\n const index of\n bambooSearch.exactAliasMap.get(name) || []\n ) {\n exactCandidateIndexes.add(index);\n }\n }\n\n if (exactCandidateIndexes.size === 1) {\n const index = exactCandidateIndexes.values().next().value;\n const result = {\n found: true,\n matched_by: 'exact_name',\n confidence: 1,\n employee: bambooSearch.records[index].employee,\n bank_name: names[0] || '',\n bamboo_alias:\n bambooSearch.records[index].aliases[0]?.raw || '',\n };\n bambooMatchCache.set(cacheKey, result);\n return result;\n }\n\n const candidateVotes = new Map();\n\n for (const name of normalizedNames) {\n const tokens = Array.from(new Set(\n nameWords(name)\n .filter((token) => token.length >= 3)\n ));\n\n for (const token of tokens) {\n const indexes = bambooSearch.tokenIndex.get(token) || [];\n\n // Evita que nombres demasiado comunes generen cientos de comparaciones.\n if (indexes.length > 180) continue;\n\n for (const index of indexes) {\n candidateVotes.set(\n index,\n (candidateVotes.get(index) || 0) + 1\n );\n }\n }\n }\n\n // Cuando una letra fue agregada, omitida o reemplazada, puede no existir\n // ningún token exacto compartido. En ese caso se buscan tokens cercanos\n // solamente entre palabras de longitud comparable.\n if (!candidateVotes.size) {\n for (const name of normalizedNames) {\n const queryTokens = Array.from(new Set(\n nameWords(name)\n .filter((token) => token.length >= 3)\n ));\n\n for (const queryToken of queryTokens) {\n for (\n const [indexedToken, indexes] of\n bambooSearch.tokenIndex.entries()\n ) {\n if (\n Math.abs(\n queryToken.length - indexedToken.length\n ) > 2\n ) {\n continue;\n }\n\n if (\n queryToken[0] !== indexedToken[0] &&\n queryToken.at(-1) !== indexedToken.at(-1)\n ) {\n continue;\n }\n\n if (\n !bambooTokenMatches(\n queryToken,\n indexedToken\n )\n ) {\n continue;\n }\n\n if (indexes.length > 180) continue;\n\n for (const index of indexes) {\n candidateVotes.set(\n index,\n (candidateVotes.get(index) || 0) + 0.75\n );\n }\n }\n }\n }\n }\n\n const candidateIndexes = Array.from(candidateVotes.entries())\n .sort((a, b) => b[1] - a[1])\n .slice(0, 180)\n .map(([index]) => index);\n\n let best = null;\n let second = null;\n\n for (const index of candidateIndexes) {\n const record = bambooSearch.records[index];\n let bestScoreForEmployee = 0;\n let bestBankName = '';\n let bestAlias = '';\n\n for (const currentBankName of names) {\n for (const alias of record.aliases) {\n const score = nameSimilarityScore(\n currentBankName,\n alias.normalized\n );\n\n if (score > bestScoreForEmployee) {\n bestScoreForEmployee = score;\n bestBankName = currentBankName;\n bestAlias = alias.raw;\n }\n }\n }\n\n if (bestScoreForEmployee <= 0) continue;\n\n const candidate = {\n employee: record.employee,\n score: bestScoreForEmployee,\n bank_name: bestBankName,\n bamboo_alias: bestAlias,\n };\n\n if (!best || candidate.score > best.score) {\n second = best;\n best = candidate;\n } else if (!second || candidate.score > second.score) {\n second = candidate;\n }\n }\n\n let result;\n\n if (\n best &&\n best.score >= 0.78 &&\n (!second || best.score - second.score >= 0.05)\n ) {\n result = {\n found: true,\n matched_by:\n normalizeName(best.bank_name) ===\n normalizeName(best.bamboo_alias)\n ? 'exact_name'\n : 'strong_name',\n confidence: best.score,\n employee: best.employee,\n bank_name: best.bank_name,\n bamboo_alias: best.bamboo_alias,\n };\n } else {\n result = {\n found: false,\n matched_by: null,\n confidence: best?.score || 0,\n employee: null,\n ambiguous: Boolean(\n best &&\n second &&\n best.score >= 0.7 &&\n best.score - second.score < 0.05\n ),\n best_candidate: best || null,\n };\n }\n\n bambooMatchCache.set(cacheKey, result);\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 || 'TTD'}`,\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 || 'TTD',\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 || 'TTD',\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 || 'TTD'}`,\n account: normalizeAccount(row.account),\n account_is_valid: Boolean(row.account_is_valid),\n currency: row.currency || 'TTD',\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 rawBambooValidationEmployees =\n Array.isArray(data.bamboo?.employees)\n ? data.bamboo.employees\n : [];\n\nconst bambooEmployees =\n rawBambooValidationEmployees.filter(\n isBambooValidationEligible\n );\n\nconst excludedBambooValidationEmployees =\n rawBambooValidationEmployees\n .filter(\n (employee) =>\n !isBambooValidationEligible(employee)\n )\n .map((employee) => ({\n bamboo_id:\n employee.bamboo_id || '',\n employee_number:\n employee.employee_number || '',\n full_name:\n employee.full_name || '',\n country:\n employee.country || '',\n location:\n employee.location || '',\n status:\n employee.status || '',\n overlaps_period:\n Boolean(employee.overlaps_period),\n validation_scope:\n employee.validation_scope || '',\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 || 'TTD',\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: `possible_missing_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: 'Riesgo',\n category: 'posible_cuenta_mal_digitada',\n subcategory:\n 'cuenta_faltante_en_nomina_nombre_y_monto_coinciden',\n observation:\n `El nombre y el monto coinciden, pero la nómina no tiene una cuenta bancaria válida registrada. El banco utilizó la cuenta ${bank.account}.`,\n source_sheet: payroll.source_sheet,\n row_number: payroll.row_number,\n source_rows: [\n {\n source_sheet: payroll.source_sheet,\n row_number: payroll.row_number,\n account: '',\n amount: payroll.payroll_amount,\n employee_name: payroll.employee_name,\n },\n ],\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(\n (row) => row.category === 'discrepancia'\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_country:\n match.employee?.country || '',\n bamboo_location:\n match.employee?.location || '',\n bamboo_validation_scope:\n match.employee?.validation_scope || '',\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 || 'TTD',\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 || 'TTD',\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_tt',\n observation:\n 'Se encontró un pago en el banco, pero no se encontró una coincidencia confiable con un empleado de Trinidad y Tobago 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 nameDifferenceMap = new Map();\n\nfor (const reconciliationRow of coreRows) {\n const payrollName = String(\n reconciliationRow.employee_name ||\n reconciliationRow.employee ||\n ''\n ).trim();\n\n if (!payrollName) continue;\n\n for (\n const bankSourceRow of\n reconciliationRow.bank_source_rows || []\n ) {\n const bankName = String(\n bankSourceRow.bank_name_file ||\n bankSourceRow.participant_name ||\n bankSourceRow.bank_account_holder ||\n ''\n ).trim();\n\n if (\n !bankName ||\n samePersonName(payrollName, bankName)\n ) {\n continue;\n }\n\n const account = normalizeAccount(\n bankSourceRow.account ||\n reconciliationRow.bank_account ||\n reconciliationRow.bankAccount ||\n reconciliationRow.account ||\n ''\n );\n\n const key = [\n normalizeName(payrollName),\n normalizeName(bankName),\n account,\n bankSourceRow.source_file || '',\n bankSourceRow.row_number || '',\n ].join('|');\n\n if (nameDifferenceMap.has(key)) {\n continue;\n }\n\n nameDifferenceMap.set(key, {\n id: `bank_name_difference_${key}`,\n employee: payrollName,\n employee_name: payrollName,\n payroll_name: payrollName,\n bank_name: bankName,\n employeeNumber:\n reconciliationRow.employee_number ||\n reconciliationRow.employeeNumber ||\n '',\n employee_number:\n reconciliationRow.employee_number ||\n reconciliationRow.employeeNumber ||\n '',\n account,\n payrollAccount:\n reconciliationRow.payroll_account ||\n reconciliationRow.payrollAccount ||\n '',\n payroll_account:\n reconciliationRow.payroll_account ||\n reconciliationRow.payrollAccount ||\n '',\n bankAccount: account,\n bank_account: account,\n currency:\n bankSourceRow.currency ||\n reconciliationRow.currency ||\n 'TTD',\n payrollAmount:\n reconciliationRow.payroll_amount ||\n reconciliationRow.payrollAmount ||\n 0,\n payroll_amount:\n reconciliationRow.payroll_amount ||\n reconciliationRow.payrollAmount ||\n 0,\n bankAmount:\n bankSourceRow.amount || 0,\n bank_amount:\n bankSourceRow.amount || 0,\n difference: 0,\n status: 'Pendiente revisión',\n category: 'diferencia_nombre_banco',\n subcategory:\n 'nombre_nomina_vs_participante_banco',\n observation:\n `El nombre registrado en la nómina (${payrollName}) ` +\n `es diferente al nombre enviado al banco (${bankName}).`,\n bank_name_file: payrollName,\n bank_account_holder: bankName,\n source_file:\n bankSourceRow.source_file || '',\n financial_institution_id:\n bankSourceRow.financial_institution_id || '',\n reference:\n bankSourceRow.reference || '',\n row_number:\n bankSourceRow.row_number || '',\n });\n }\n}\n\nconst nameDifferenceRows = Array.from(\n nameDifferenceMap.values()\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 corePosiblesCuentas +\n bankWithoutBamboo.length +\n diferenciasNombreBanco;\n\nconst unusedPotentialSupplements =\n potentialSupplements.filter((row) => {\n return !appliedSupplementKeys.has(\n supplementKey(row)\n );\n });\n\nreturn [\n {\n json: {\n ok: true,\n stage: 'cruce_nomina_tt_banco',\n errors: [],\n metadata: data.metadata || {},\n summary: {\n coincidencias: coreCoincidencias,\n // La tarjeta de la app agrupa todos los casos de discrepancia/riesgo.\n // Se conserva el detalle puro en discrepanciasMontoPago.\n discrepancias:\n coreDiscrepancias + corePosiblesCuentas,\n discrepanciasMontoPago:\n coreDiscrepancias,\n bancoSinNomina: coreBancoSinNomina,\n bancoSinBamboo: bankWithoutBamboo.length,\n nominaSinCuenta: coreNominaSinCuenta,\n diferenciasNombreBanco,\n posiblesCuentasMalDigitadas:\n corePosiblesCuentas,\n totalResultados:\n coreCoincidencias +\n coreDiscrepancias +\n coreBancoSinNomina +\n coreNominaSinCuenta +\n corePosiblesCuentas +\n bankWithoutBamboo.length +\n diferenciasNombreBanco,\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 empleadosBambooTT:\n Number(\n data.bamboo?.trinidad_tobago_count ||\n bambooEmployees.length\n ),\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: data.bamboo || {},\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_received:\n rawBambooValidationEmployees.length,\n employees_indexed:\n bambooSearch.records.length,\n employees_excluded:\n excludedBambooValidationEmployees.length,\n excluded_employees:\n excludedBambooValidationEmployees,\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": [
6224,
7296
],
"id": "ea4d4d89-a2f9-4c3f-9c53-803706173b27",
"name": "Cruzar Nómina vs Banco"
},
{
"parameters": {
"jsCode": "const data = $input.first().json || {};\n\nfunction normalizeText(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\u00A0/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction roundMoney(value) {\n return Math.round(\n (Number(value) || 0) * 100\n ) / 100;\n}\n\nfunction firstValue(value) {\n if (Array.isArray(value)) {\n return value\n .map(normalizeText)\n .filter(Boolean)\n .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\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 subcategory = normalizeText(\n row.subcategory\n ).toLowerCase();\n\n const payrollAmount = Number(\n row.payroll_amount ??\n row.payrollAmount ??\n 0\n );\n\n const bankAmount = Number(\n row.bank_amount ??\n row.bankAmount ??\n 0\n );\n\n if (\n subcategory ===\n 'nomina_con_cuenta_sin_pago_banco' ||\n (bankAmount === 0 && payrollAmount > 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\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(/\\u00A0/g, '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim()\n )\n .filter(\n (account) =>\n account.length >= 6 &&\n !/^0+$/.test(account)\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(\n (sourceRow) => [\n sourceRow.account,\n sourceRow.payroll_account,\n sourceRow.payrollAccount,\n ]\n )\n : []),\n ];\n\n return Array.from(\n new Set(\n candidates.flatMap(accountValues)\n )\n );\n}\n\nfunction bankAccountForWrongAccount(row) {\n return firstValue(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n );\n}\n\nfunction moneyLabel(value) {\n return Math.abs(\n roundMoney(value)\n ).toLocaleString('en-US', {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n });\n}\n\nfunction wrongAccountTotalStatus(row) {\n const payrollAmount = roundMoney(\n row.payroll_amount ??\n row.payrollAmount ??\n 0\n );\n\n const bankAmount = roundMoney(\n row.bank_amount ??\n row.bankAmount ??\n 0\n );\n\n const difference = roundMoney(\n payrollAmount - bankAmount\n );\n\n if (Math.abs(difference) <= 0.02) {\n return (\n 'El total de nómina coincide con ' +\n 'el total pagado por el banco.'\n );\n }\n\n if (difference > 0) {\n return (\n 'El total de nómina supera el total ' +\n `del banco por TT$${moneyLabel(difference)}.`\n );\n }\n\n return (\n 'El total pagado por el banco supera ' +\n `el total de nómina por TT$${moneyLabel(difference)}.`\n );\n}\n\nfunction wrongAccountFinding(row) {\n const existing = normalizeText(\n row.observation || ''\n );\n\n if (existing) return existing;\n\n const payrollAccounts =\n payrollAccountsForWrongAccount(row);\n\n const bankAccount =\n bankAccountForWrongAccount(row);\n\n return (\n 'El empleado presenta una posible ' +\n 'inconsistencia entre la cuenta registrada ' +\n `en nómina (${payrollAccounts.join(' y ') || 'sin cuenta identificada'}) ` +\n `y la cuenta utilizada por el banco (${bankAccount || 'sin cuenta identificada'}).`\n );\n}\n\nconst metadata = data.metadata || {};\nconst summary = data.summary || {};\n\nconst rows = Array.isArray(data.rows)\n ? data.rows\n : [];\n\nconst bankWithoutBamboo =\n 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 periodEndLabel = formatPeriodEnd(\n metadata.period_end || ''\n);\n\nconst spreadsheetTitle =\n `Cruce de Cuentas GLM TT - ${periodLabel}`;\n\nconst sheetIds = {\n nominaVsBanco: 201,\n bancoSinNomina: 202,\n bancoSinBamboo: 203,\n diferenciasNombreBanco: 204,\n cuentaMalDigitada: 205,\n resumen: 206,\n};\n\nconst cuentaMalDigitadaCases = rows.filter(\n (row) =>\n row.category ===\n 'posible_cuenta_mal_digitada'\n);\n\nconst hasCuentaMalDigitada =\n cuentaMalDigitadaCases.length > 0;\n\nconst sheetTitles = {\n nominaVsBanco:\n '01 Nómina vs Banco',\n bancoSinNomina:\n '02 Banco sin Nómina',\n bancoSinBamboo:\n '03 Banco sin Bamboo',\n diferenciasNombreBanco:\n '04 Diferencias de Nombre',\n cuentaMalDigitada:\n '05 Cuenta Mal Digitada',\n resumen: hasCuentaMalDigitada\n ? '06 Resumen'\n : '05 Resumen',\n};\n\nconst mainRows = rows\n .filter(\n (row) =>\n row.category === 'discrepancia'\n )\n .map((row, index) => {\n const payrollAmount = roundMoney(\n row.payroll_amount ??\n row.payrollAmount ??\n 0\n );\n\n const bankAmount = roundMoney(\n row.bank_amount ??\n row.bankAmount ??\n 0\n );\n\n const difference = roundMoney(\n row.difference ??\n (payrollAmount - bankAmount)\n );\n\n return [\n index + 1,\n normalizeText(\n row.employee_name ||\n row.employee ||\n ''\n ),\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(\n row,\n difference\n ),\n normalizeText(\n row.status || 'Riesgo'\n ).toUpperCase(),\n '',\n ];\n });\n\nconst bancoSinNominaRows = rows\n .filter(\n (row) =>\n row.category ===\n 'banco_sin_nomina'\n )\n .map((row, index) => [\n index + 1,\n normalizeText(\n row.employee_name ||\n row.employee ||\n ''\n ),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n roundMoney(\n row.bank_amount ??\n row.bankAmount ??\n 0\n ),\n firstValue(\n row.source_files ||\n row.source_file ||\n ''\n ),\n normalizeText(\n row.status ||\n 'Pendiente revisión'\n ).toUpperCase(),\n normalizeText(\n row.observation || ''\n ),\n '',\n ]);\n\nconst bancoSinBambooRows =\n bankWithoutBamboo.map(\n (row, index) => [\n index + 1,\n normalizeText(\n row.bank_name_file ||\n row.employee_name ||\n row.employee ||\n ''\n ),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n roundMoney(\n row.bank_amount ??\n row.bankAmount ??\n 0\n ),\n firstValue(\n row.source_files ||\n row.source_file ||\n ''\n ),\n 'PENDIENTE REVISIÓN',\n '',\n ]\n );\n\nconst diferenciasNombreRows = rows\n .filter(\n (row) =>\n row.category ===\n 'diferencia_nombre_banco'\n )\n .map((row, index) => [\n index + 1,\n normalizeText(\n row.payroll_name ||\n row.employee_name ||\n row.employee ||\n row.bank_name_file ||\n ''\n ),\n normalizeText(\n row.bank_name ||\n row.bank_account_holder ||\n ''\n ),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n roundMoney(\n row.bank_amount ??\n row.bankAmount ??\n 0\n ),\n normalizeText(\n row.status ||\n 'Pendiente revisión'\n ).toUpperCase(),\n normalizeText(\n row.observation || ''\n ),\n '',\n ]);\n\nconst cuentaMalDigitadaRows = [];\n\ncuentaMalDigitadaCases.forEach(\n (row, index) => {\n const payrollAccounts =\n payrollAccountsForWrongAccount(row);\n\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 registrada en nómina.',\n ],\n ];\n\n fields.forEach(\n (field, fieldIndex) => {\n cuentaMalDigitadaRows.push([\n fieldIndex === 0\n ? index + 1\n : '',\n field[0],\n field[1],\n '',\n ]);\n }\n );\n }\n);\n\nconst resumenRows = [\n ['Período', periodLabel],\n [\n 'Coincidencias',\n Number(summary.coincidencias || 0),\n ],\n [\n 'Discrepancias de monto o pago',\n Number(\n summary.discrepanciasMontoPago ??\n summary.discrepancias ??\n 0\n ),\n ],\n [\n 'Banco sin nómina',\n Number(summary.bancoSinNomina || 0),\n ],\n [\n 'Banco sin Bamboo',\n Number(summary.bancoSinBamboo || 0),\n ],\n [\n 'Nómina sin cuenta no conciliada',\n Number(summary.nominaSinCuenta || 0),\n ],\n [\n 'Diferencias de nombre',\n Number(\n summary.diferenciasNombreBanco || 0\n ),\n ],\n [\n 'Posibles cuentas mal digitadas',\n Number(\n summary.posiblesCuentasMalDigitadas || 0\n ),\n ],\n [\n 'Pendientes del cruce principal',\n Number(summary.pendientes || 0),\n ],\n [\n 'Empleados BambooHR Trinidad y Tobago',\n Number(summary.empleadosBambooTT || 0),\n ],\n [\n 'Empleados BambooHR en el período',\n Number(\n summary.empleadosBambooEnPeriodo || 0\n ),\n ],\n [\n 'Filas válidas de nómina',\n Number(\n summary.filasNominaValidas || 0\n ),\n ],\n [\n 'Filas de nómina sin cuenta detectadas',\n Number(\n summary.filasNominaSinCuenta || 0\n ),\n ],\n [\n 'Transacciones bancarias',\n Number(\n summary.transaccionesBanco || 0\n ),\n ],\n [\n 'Total nómina',\n roundMoney(summary.totalNomina || 0),\n ],\n [\n 'Total banco',\n roundMoney(summary.totalBanco || 0),\n ],\n [\n 'Diferencia total',\n roundMoney(\n summary.diferenciaTotal || 0\n ),\n ],\n];\n\nfunction reportValues(\n title,\n subtitle,\n header,\n body\n) {\n return [\n [\n title,\n ...Array(\n Math.max(header.length - 1, 0)\n ).fill(''),\n ],\n [\n subtitle,\n ...Array(\n Math.max(header.length - 1, 0)\n ).fill(''),\n ],\n Array(header.length).fill(''),\n header,\n ...body,\n ];\n}\n\nconst nominaVsBancoValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Diferencias de Monto Nómina vs. Banco · Trinidad y Tobago · ${periodEndLabel}`,\n [\n '#',\n 'Empleado',\n 'Cuenta',\n 'Monto en Nómina (TT$)',\n 'Monto en Banco (TT$)',\n 'Diferencia (TT$)',\n 'Sentido',\n 'Estado',\n 'Resolución',\n ],\n mainRows\n );\n\nconst bancoSinNominaValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Pagos bancarios sin registro en la nómina · Trinidad y Tobago · ${periodEndLabel}`,\n [\n '#',\n 'Nombre en banco',\n 'Cuenta',\n 'Monto en banco (TT$)',\n 'Archivo',\n 'Estado',\n 'Observación',\n 'Resolución',\n ],\n bancoSinNominaRows\n );\n\nconst bancoSinBambooValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Pagos en banco sin empleado identificado en BambooHR · Trinidad y Tobago · ${periodEndLabel}`,\n [\n '#',\n 'Nombre en banco',\n 'Cuenta',\n 'Monto en banco (TT$)',\n 'Archivo',\n 'Estado',\n 'Resolución',\n ],\n bancoSinBambooRows\n );\n\nconst diferenciasNombreValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Diferencias de nombre entre nómina y banco · Trinidad y Tobago · ${periodEndLabel}`,\n [\n '#',\n 'Nombre en nómina',\n 'Nombre en banco',\n 'Cuenta',\n 'Monto en banco (TT$)',\n 'Estado',\n 'Observación',\n 'Resolución',\n ],\n diferenciasNombreRows\n );\n\nconst cuentaMalDigitadaValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Cuenta Mal Digitada en Nómina · Trinidad y Tobago · ${periodEndLabel}`,\n ['#', 'Campo', 'Detalle', 'Resolución'],\n cuentaMalDigitadaRows\n );\n\nconst resumenValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Resumen del cruce Nómina vs. Banco · Trinidad y Tobago · ${periodEndLabel}`,\n ['Indicador', 'Valor'],\n resumenRows\n );\n\nconst valueData = [\n {\n range:\n `'${sheetTitles.nominaVsBanco}'!A1:I`,\n values: nominaVsBancoValues,\n },\n {\n range:\n `'${sheetTitles.bancoSinNomina}'!A1:H`,\n values: bancoSinNominaValues,\n },\n {\n range:\n `'${sheetTitles.bancoSinBamboo}'!A1:G`,\n values: bancoSinBambooValues,\n },\n {\n range:\n `'${sheetTitles.diferenciasNombreBanco}'!A1:H`,\n values: diferenciasNombreValues,\n },\n ...(hasCuentaMalDigitada\n ? [\n {\n range:\n `'${sheetTitles.cuentaMalDigitada}'!A1:D`,\n values:\n cuentaMalDigitadaValues,\n },\n ]\n : []),\n {\n range:\n `'${sheetTitles.resumen}'!A1:B`,\n values: resumenValues,\n },\n];\n\nconst brandColor = {\n red: 0.29,\n green: 0.49,\n blue: 0.58,\n};\n\nconst whiteColor = {\n red: 1,\n green: 1,\n blue: 1,\n};\n\nconst borderColor = {\n red: 0.82,\n green: 0.86,\n blue: 0.88,\n};\n\nfunction mergeRow(\n sheetId,\n rowIndex,\n columnCount\n) {\n return {\n mergeCells: {\n range: {\n sheetId,\n startRowIndex: rowIndex,\n endRowIndex: rowIndex + 1,\n startColumnIndex: 0,\n endColumnIndex: columnCount,\n },\n mergeType: 'MERGE_ALL',\n },\n };\n}\n\nfunction formatRange(\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n userEnteredFormat\n) {\n const formatFields =\n Object.keys(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(${formatFields.join(',')})`,\n },\n };\n}\n\nfunction titleFormat(\n sheetId,\n rowIndex,\n columnCount,\n options = {}\n) {\n return formatRange(\n sheetId,\n rowIndex,\n rowIndex + 1,\n 0,\n columnCount,\n {\n backgroundColor: brandColor,\n textFormat: {\n bold: options.bold ?? true,\n italic:\n options.italic ?? false,\n fontSize:\n options.fontSize ?? 12,\n foregroundColor:\n whiteColor,\n },\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n );\n}\n\nfunction headerFormat(\n sheetId,\n columnCount\n) {\n return formatRange(\n sheetId,\n 3,\n 4,\n 0,\n columnCount,\n {\n backgroundColor: brandColor,\n textFormat: {\n bold: true,\n foregroundColor:\n whiteColor,\n },\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n );\n}\n\nfunction freezeRows(\n sheetId,\n count\n) {\n return {\n updateSheetProperties: {\n properties: {\n sheetId,\n gridProperties: {\n frozenRowCount: count,\n },\n },\n fields:\n 'gridProperties.frozenRowCount',\n },\n };\n}\n\nfunction setFilter(\n sheetId,\n columnCount,\n endRowIndex\n) {\n return {\n setBasicFilter: {\n filter: {\n range: {\n sheetId,\n startRowIndex: 3,\n endRowIndex,\n startColumnIndex: 0,\n endColumnIndex:\n columnCount,\n },\n },\n },\n };\n}\n\nfunction setColumnWidth(\n sheetId,\n index,\n pixelSize\n) {\n return {\n updateDimensionProperties: {\n range: {\n sheetId,\n dimension: 'COLUMNS',\n startIndex: index,\n endIndex: index + 1,\n },\n properties: {\n pixelSize,\n },\n fields: 'pixelSize',\n },\n };\n}\n\nfunction setRowHeight(\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 borderFormat(\n sheetId,\n columnCount,\n endRowIndex\n) {\n const border = {\n style: 'SOLID',\n color: borderColor,\n };\n\n return [\n formatRange(\n sheetId,\n 3,\n endRowIndex,\n 0,\n columnCount,\n {\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n {\n updateBorders: {\n range: {\n sheetId,\n startRowIndex: 3,\n endRowIndex,\n startColumnIndex: 0,\n endColumnIndex: columnCount,\n },\n top: border,\n bottom: border,\n left: border,\n right: border,\n innerHorizontal: border,\n innerVertical: border,\n },\n },\n ];\n}\n\nfunction moneyFormat(\n sheetId,\n startColumnIndex,\n endColumnIndex,\n startRowIndex,\n endRowIndex\n) {\n return formatRange(\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n {\n numberFormat: {\n type: 'NUMBER',\n pattern:\n '\"TT$\"#,##0.00',\n },\n horizontalAlignment:\n 'RIGHT',\n verticalAlignment:\n 'MIDDLE',\n }\n );\n}\n\nfunction statusFormat(\n sheetId,\n columnIndex,\n endRowIndex\n) {\n return formatRange(\n sheetId,\n 4,\n endRowIndex,\n columnIndex,\n columnIndex + 1,\n {\n backgroundColor: {\n red: 1,\n green: 0.92,\n blue: 0.92,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.82,\n green: 0.08,\n blue: 0.08,\n },\n },\n horizontalAlignment:\n 'CENTER',\n verticalAlignment:\n 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n );\n}\n\nfunction conditionalDifference(\n sheetId,\n endRowIndex,\n formula,\n backgroundColor,\n textColor\n) {\n return {\n addConditionalFormatRule: {\n rule: {\n ranges: [\n {\n sheetId,\n startRowIndex: 4,\n endRowIndex,\n startColumnIndex: 5,\n endColumnIndex: 6,\n },\n ],\n booleanRule: {\n condition: {\n type: 'CUSTOM_FORMULA',\n values: [\n {\n userEnteredValue:\n formula,\n },\n ],\n },\n format: {\n backgroundColor,\n textFormat: {\n bold: true,\n foregroundColor:\n textColor,\n },\n },\n },\n },\n index: 0,\n },\n };\n}\n\nfunction styleReport(config) {\n const {\n sheetId,\n columnCount,\n bodyRowsCount,\n widths,\n moneyColumns = [],\n statusColumn = null,\n } = config;\n\n const endRowIndex = Math.max(\n 4 + bodyRowsCount,\n 4\n );\n\n const requests = [\n mergeRow(\n sheetId,\n 0,\n columnCount\n ),\n mergeRow(\n sheetId,\n 1,\n columnCount\n ),\n titleFormat(\n sheetId,\n 0,\n columnCount,\n {\n fontSize: 12,\n bold: true,\n }\n ),\n titleFormat(\n sheetId,\n 1,\n columnCount,\n {\n fontSize: 10,\n bold: false,\n italic: true,\n }\n ),\n headerFormat(\n sheetId,\n columnCount\n ),\n freezeRows(sheetId, 4),\n setFilter(\n sheetId,\n columnCount,\n endRowIndex\n ),\n ...borderFormat(\n sheetId,\n columnCount,\n endRowIndex\n ),\n setRowHeight(\n sheetId,\n 0,\n 1,\n 30\n ),\n setRowHeight(\n sheetId,\n 1,\n 2,\n 28\n ),\n setRowHeight(\n sheetId,\n 3,\n 4,\n 42\n ),\n ...widths.map(\n (width, index) =>\n setColumnWidth(\n sheetId,\n index,\n width\n )\n ),\n ];\n\n if (bodyRowsCount > 0) {\n requests.push(\n setRowHeight(\n sheetId,\n 4,\n endRowIndex,\n 30\n )\n );\n\n for (\n const [startColumn, endColumn] of\n moneyColumns\n ) {\n requests.push(\n moneyFormat(\n sheetId,\n startColumn,\n endColumn,\n 4,\n endRowIndex\n )\n );\n }\n\n if (\n Number.isInteger(\n statusColumn\n )\n ) {\n requests.push(\n statusFormat(\n sheetId,\n statusColumn,\n endRowIndex\n )\n );\n }\n }\n\n return requests;\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 ...styleReport({\n sheetId:\n sheetIds.nominaVsBanco,\n columnCount: 9,\n bodyRowsCount:\n mainRows.length,\n widths: [\n 48,\n 250,\n 145,\n 135,\n 135,\n 135,\n 180,\n 120,\n 260,\n ],\n moneyColumns: [\n [3, 6],\n ],\n statusColumn: 7,\n }),\n\n ...(mainRows.length > 0\n ? [\n conditionalDifference(\n sheetIds.nominaVsBanco,\n 4 + mainRows.length,\n '=$F5>0',\n {\n red: 1,\n green: 0.97,\n blue: 0.82,\n },\n {\n red: 0.45,\n green: 0.27,\n blue: 0,\n }\n ),\n conditionalDifference(\n sheetIds.nominaVsBanco,\n 4 + mainRows.length,\n '=$F5<0',\n {\n red: 1,\n green: 0.89,\n blue: 0.89,\n },\n {\n red: 0.85,\n green: 0.05,\n blue: 0.05,\n }\n ),\n ]\n : []),\n\n ...styleReport({\n sheetId:\n sheetIds.bancoSinNomina,\n columnCount: 8,\n bodyRowsCount:\n bancoSinNominaRows.length,\n widths: [\n 48,\n 230,\n 145,\n 135,\n 230,\n 140,\n 360,\n 260,\n ],\n moneyColumns: [[3, 4]],\n statusColumn: 5,\n }),\n\n ...styleReport({\n sheetId:\n sheetIds.bancoSinBamboo,\n columnCount: 7,\n bodyRowsCount:\n bancoSinBambooRows.length,\n widths: [\n 48,\n 250,\n 145,\n 140,\n 250,\n 150,\n 260,\n ],\n moneyColumns: [[3, 4]],\n statusColumn: 5,\n }),\n\n ...styleReport({\n sheetId:\n sheetIds.diferenciasNombreBanco,\n columnCount: 8,\n bodyRowsCount:\n diferenciasNombreRows.length,\n widths: [\n 48,\n 240,\n 240,\n 145,\n 140,\n 150,\n 420,\n 260,\n ],\n moneyColumns: [[4, 5]],\n statusColumn: 5,\n }),\n];\n\nif (hasCuentaMalDigitada) {\n const endRowIndex =\n 4 +\n cuentaMalDigitadaRows.length;\n\n formatRequests.push(\n ...styleReport({\n sheetId:\n sheetIds.cuentaMalDigitada,\n columnCount: 4,\n bodyRowsCount:\n cuentaMalDigitadaRows.length,\n widths: [\n 48,\n 300,\n 520,\n 260,\n ],\n statusColumn: null,\n })\n );\n\n cuentaMalDigitadaCases.forEach(\n (_, caseIndex) => {\n const startRowIndex =\n 4 + caseIndex * 6;\n\n const endCaseRowIndex =\n startRowIndex + 6;\n\n formatRequests.push(\n {\n mergeCells: {\n range: {\n sheetId:\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endRowIndex:\n endCaseRowIndex,\n startColumnIndex: 0,\n endColumnIndex: 1,\n },\n mergeType:\n 'MERGE_ALL',\n },\n },\n {\n mergeCells: {\n range: {\n sheetId:\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endRowIndex:\n endCaseRowIndex,\n startColumnIndex: 3,\n endColumnIndex: 4,\n },\n mergeType:\n 'MERGE_ALL',\n },\n },\n formatRange(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endCaseRowIndex,\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:\n 'CENTER',\n verticalAlignment:\n 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n formatRange(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endCaseRowIndex,\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:\n 'LEFT',\n verticalAlignment:\n 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n formatRange(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endCaseRowIndex,\n 2,\n 3,\n {\n horizontalAlignment:\n 'LEFT',\n verticalAlignment:\n 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n setRowHeight(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endCaseRowIndex,\n 52\n )\n );\n }\n );\n}\n\nconst resumenEndRowIndex =\n 4 + resumenRows.length;\n\nformatRequests.push(\n ...styleReport({\n sheetId:\n sheetIds.resumen,\n columnCount: 2,\n bodyRowsCount:\n resumenRows.length,\n widths: [340, 180],\n statusColumn: null,\n }),\n moneyFormat(\n sheetIds.resumen,\n 1,\n 2,\n resumenEndRowIndex - 3,\n resumenEndRowIndex\n )\n);\n\n\nconst mainReadabilityEndRow =\n 4 + mainRows.length;\n\nconst bancoSinNominaReadabilityEndRow =\n 4 + bancoSinNominaRows.length;\n\nconst bancoSinBambooReadabilityEndRow =\n 4 + bancoSinBambooRows.length;\n\nconst diferenciasNombreReadabilityEndRow =\n 4 + diferenciasNombreRows.length;\n\nconst resumenReadabilityEndRow =\n 4 + resumenRows.length;\n\n/*\n * Ajuste final de legibilidad.\n *\n * Los anchos definitivos se aplican antes del autoajuste vertical. De esta\n * forma Google Sheets calcula la altura real de cada fila después de envolver\n * el texto, evitando contenido cortado en nombres, observaciones y resúmenes.\n */\nformatRequests.push(\n // 01 Nómina vs Banco\n setColumnWidth(\n sheetIds.nominaVsBanco,\n 1,\n 300\n ),\n setColumnWidth(\n sheetIds.nominaVsBanco,\n 2,\n 150\n ),\n setColumnWidth(\n sheetIds.nominaVsBanco,\n 3,\n 145\n ),\n setColumnWidth(\n sheetIds.nominaVsBanco,\n 4,\n 145\n ),\n setColumnWidth(\n sheetIds.nominaVsBanco,\n 5,\n 145\n ),\n setColumnWidth(\n sheetIds.nominaVsBanco,\n 6,\n 220\n ),\n setColumnWidth(\n sheetIds.nominaVsBanco,\n 7,\n 140\n ),\n setColumnWidth(\n sheetIds.nominaVsBanco,\n 8,\n 320\n ),\n ...(mainRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.nominaVsBanco,\n 4,\n mainReadabilityEndRow,\n 0,\n 9\n ),\n autoResizeRowsRequest(\n sheetIds.nominaVsBanco,\n 4,\n mainReadabilityEndRow\n ),\n ]\n : []),\n\n // 02 Banco sin Nómina\n setColumnWidth(\n sheetIds.bancoSinNomina,\n 1,\n 320\n ),\n setColumnWidth(\n sheetIds.bancoSinNomina,\n 2,\n 160\n ),\n setColumnWidth(\n sheetIds.bancoSinNomina,\n 3,\n 145\n ),\n setColumnWidth(\n sheetIds.bancoSinNomina,\n 4,\n 260\n ),\n setColumnWidth(\n sheetIds.bancoSinNomina,\n 5,\n 170\n ),\n setColumnWidth(\n sheetIds.bancoSinNomina,\n 6,\n 560\n ),\n setColumnWidth(\n sheetIds.bancoSinNomina,\n 7,\n 320\n ),\n ...(bancoSinNominaRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.bancoSinNomina,\n 4,\n bancoSinNominaReadabilityEndRow,\n 0,\n 8\n ),\n autoResizeRowsRequest(\n sheetIds.bancoSinNomina,\n 4,\n bancoSinNominaReadabilityEndRow\n ),\n ]\n : []),\n\n // 03 Banco sin Bamboo\n setColumnWidth(\n sheetIds.bancoSinBamboo,\n 1,\n 320\n ),\n setColumnWidth(\n sheetIds.bancoSinBamboo,\n 2,\n 160\n ),\n setColumnWidth(\n sheetIds.bancoSinBamboo,\n 3,\n 145\n ),\n setColumnWidth(\n sheetIds.bancoSinBamboo,\n 4,\n 260\n ),\n setColumnWidth(\n sheetIds.bancoSinBamboo,\n 5,\n 170\n ),\n setColumnWidth(\n sheetIds.bancoSinBamboo,\n 6,\n 320\n ),\n ...(bancoSinBambooRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.bancoSinBamboo,\n 4,\n bancoSinBambooReadabilityEndRow,\n 0,\n 7\n ),\n autoResizeRowsRequest(\n sheetIds.bancoSinBamboo,\n 4,\n bancoSinBambooReadabilityEndRow\n ),\n ]\n : []),\n\n // 04 Diferencias de Nombre\n setColumnWidth(\n sheetIds.diferenciasNombreBanco,\n 1,\n 320\n ),\n setColumnWidth(\n sheetIds.diferenciasNombreBanco,\n 2,\n 320\n ),\n setColumnWidth(\n sheetIds.diferenciasNombreBanco,\n 3,\n 160\n ),\n setColumnWidth(\n sheetIds.diferenciasNombreBanco,\n 4,\n 145\n ),\n setColumnWidth(\n sheetIds.diferenciasNombreBanco,\n 5,\n 170\n ),\n setColumnWidth(\n sheetIds.diferenciasNombreBanco,\n 6,\n 600\n ),\n setColumnWidth(\n sheetIds.diferenciasNombreBanco,\n 7,\n 320\n ),\n ...(diferenciasNombreRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.diferenciasNombreBanco,\n 4,\n diferenciasNombreReadabilityEndRow,\n 0,\n 8\n ),\n autoResizeRowsRequest(\n sheetIds.diferenciasNombreBanco,\n 4,\n diferenciasNombreReadabilityEndRow\n ),\n ]\n : []),\n\n // 05 Cuenta Mal Digitada\n ...(hasCuentaMalDigitada\n ? [\n setColumnWidth(\n sheetIds.cuentaMalDigitada,\n 1,\n 300\n ),\n setColumnWidth(\n sheetIds.cuentaMalDigitada,\n 2,\n 600\n ),\n setColumnWidth(\n sheetIds.cuentaMalDigitada,\n 3,\n 320\n ),\n ]\n : []),\n\n // Resumen\n setColumnWidth(\n sheetIds.resumen,\n 0,\n 380\n ),\n setColumnWidth(\n sheetIds.resumen,\n 1,\n 320\n ),\n ...(resumenRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.resumen,\n 4,\n resumenReadabilityEndRow,\n 0,\n 2\n ),\n autoResizeRowsRequest(\n sheetIds.resumen,\n 4,\n resumenReadabilityEndRow\n ),\n ]\n : [])\n);\n\n\nreturn [\n {\n json: {\n ok: true,\n stage:\n 'preparar_google_sheet_tt',\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:\n sheetIds.nominaVsBanco,\n title:\n sheetTitles.nominaVsBanco,\n },\n },\n {\n properties: {\n sheetId:\n sheetIds.bancoSinNomina,\n title:\n sheetTitles.bancoSinNomina,\n },\n },\n {\n properties: {\n sheetId:\n sheetIds.bancoSinBamboo,\n title:\n 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:\n sheetIds.resumen,\n title:\n sheetTitles.resumen,\n },\n },\n ],\n },\n valueBatchBody: {\n valueInputOption:\n 'RAW',\n data: valueData,\n },\n formatBatchBody: {\n requests: formatRequests,\n },\n originalResponse: data,\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
7264,
7296
],
"id": "0269ecb0-63b1-43f6-9f34-d18d97a104b4",
"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/Port_of_Spain',\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": [
7520,
7296
],
"id": "1ef3b281-554e-41dd-b95f-28fa3a1e70be",
"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": [
7776,
7296
],
"id": "cf220e3d-0954-4b08-aa48-7d90afbb099d",
"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": [
8032,
7296
],
"id": "1e76a3ba-00f2-4455-ac64-e1f377e3d309",
"name": "Formatear Google Sheet",
"credentials": {
"googleOAuth2Api": {
"id": "dQ1MJSJSWcoWYcb8",
"name": "Google account - Isaac Producción"
}
}
},
{
"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": [
8304,
7296
],
"id": "bba45803-df53-41eb-9f04-988fc9456a3c",
"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": [
8560,
7296
],
"id": "00d29e58-0e73-4b96-8848-f3012b30189b",
"name": "Compartir Google Sheet",
"credentials": {
"googleOAuth2Api": {
"id": "dQ1MJSJSWcoWYcb8",
"name": "Google account - Isaac Producción"
}
}
},
{
"parameters": {
"jsCode": "const cruce =\n $('Cruzar Nómina vs Banco').first().json || {};\n\nconst createdSheet =\n $('Crear Google Sheet').first().json || {};\n\nconst metadata = cruce.metadata || {};\nconst summary = cruce.summary || {};\nconst debug = cruce.debug || {};\n\nconst spreadsheetId =\n createdSheet.spreadsheetId ||\n cruce.spreadsheetId ||\n '';\n\nconst reportUrl =\n createdSheet.spreadsheetUrl ||\n createdSheet.spreadsheet_url ||\n (\n spreadsheetId\n ? `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit`\n : null\n );\n\nfunction toNumber(value) {\n const parsed = Number(value);\n return Number.isFinite(parsed)\n ? parsed\n : 0;\n}\n\nfunction buildPeriodKey(periodMetadata) {\n const country =\n periodMetadata.country || 'TT';\n\n const year =\n periodMetadata.year || '';\n\n const month = String(\n periodMetadata.month || ''\n ).padStart(2, '0');\n\n const periodType =\n periodMetadata.period_type ||\n 'periodo';\n\n return (\n `${country}-${year}-${month}-${periodType}`\n );\n}\n\nconst discrepancias =\n toNumber(summary.discrepancias);\n\nconst discrepanciasMontoPago =\n toNumber(\n summary.discrepanciasMontoPago ??\n Math.max(\n 0,\n discrepancias -\n toNumber(\n summary.posiblesCuentasMalDigitadas\n )\n )\n );\n\nconst bancoSinNomina =\n toNumber(summary.bancoSinNomina);\n\nconst nominaSinCuenta =\n toNumber(summary.nominaSinCuenta);\n\nconst diferenciasNombreBanco =\n toNumber(\n summary.diferenciasNombreBanco\n );\n\nconst bancoSinBamboo =\n toNumber(summary.bancoSinBamboo);\n\nconst posiblesCuentasMalDigitadas =\n toNumber(\n summary.posiblesCuentasMalDigitadas\n );\n\nconst pendientes =\n toNumber(summary.pendientes) ||\n (\n discrepanciasMontoPago +\n bancoSinNomina +\n nominaSinCuenta +\n posiblesCuentasMalDigitadas +\n bancoSinBamboo +\n diferenciasNombreBanco\n );\n\nconst requiereRevision =\n pendientes > 0 ||\n bancoSinBamboo > 0;\n\nconst estado = requiereRevision\n ? 'pendiente_revision'\n : 'resuelto';\n\nconst payload = {\n source_app:\n metadata.source_app ||\n 'cruce-cuentas-glm-trinidad-tobago',\n\n country: 'TT',\n country_name:\n 'Trinidad y Tobago',\n\n year: toNumber(metadata.year),\n month: toNumber(metadata.month),\n period_type:\n metadata.period_type || '',\n period_label:\n metadata.period_label || '',\n period_start:\n metadata.period_start || null,\n period_end:\n metadata.period_end || null,\n period_key:\n buildPeriodKey({\n ...metadata,\n country: 'TT',\n }),\n\n payroll_file_name:\n metadata.payroll_file_name || '',\n\n bank_file_names:\n metadata.bank_file_names || [],\n\n coincidencias:\n toNumber(summary.coincidencias),\n\n discrepancias,\n\n banco_sin_bamboo:\n bancoSinBamboo,\n\n detalle_banco_sin_bamboo:\n Array.isArray(\n cruce.bankWithoutBamboo\n )\n ? cruce.bankWithoutBamboo\n : [],\n\n banco_sin_nomina:\n bancoSinNomina,\n\n nomina_sin_cuenta:\n nominaSinCuenta,\n\n nomina_sin_bamboo: 0,\n bamboo_sin_nomina: 0,\n\n filas_nomina_validas:\n toNumber(\n summary.filasNominaValidas\n ),\n\n cuentas_nomina_agrupadas:\n toNumber(\n summary.cuentasNominaAgrupadas\n ),\n\n transacciones_banco:\n toNumber(\n summary.transaccionesBanco\n ),\n\n cuentas_banco_agrupadas:\n toNumber(\n summary.cuentasBancoAgrupadas\n ),\n\n total_nomina:\n toNumber(summary.totalNomina),\n\n total_banco:\n toNumber(summary.totalBanco),\n\n diferencia_total:\n toNumber(\n summary.diferenciaTotal\n ),\n\n report_url: reportUrl,\n spreadsheet_id:\n spreadsheetId,\n estado,\n\n ejecutado_por_nombre:\n metadata.requested_by_name ||\n 'Usuario GLM',\n\n ejecutado_por_email:\n metadata.requested_by_email ||\n '',\n\n metadata: {\n ...metadata,\n country: 'TT',\n country_name:\n 'Trinidad y Tobago',\n diferencias_nombre_banco:\n diferenciasNombreBanco,\n banco_sin_bamboo:\n bancoSinBamboo,\n posibles_cuentas_mal_digitadas:\n toNumber(\n summary\n .posiblesCuentasMalDigitadas\n ),\n pendientes_cruce_principal:\n pendientes,\n requiere_revision:\n requiereRevision,\n },\n\n summary,\n\n debug: {\n sheet_summaries:\n debug.sheet_summaries || [],\n bank_name_differences:\n cruce.nameDifferences || [],\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\n // Se conserva la tabla histórica actual para\n // que la app pueda consultar todos los países\n // mediante el campo country y luego usar RPC.\n supabaseTable:\n 'cruces_cuentas_gt_reportes',\n\n supabasePayload: payload,\n reportUrl,\n spreadsheetId,\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
9904,
7296
],
"id": "09cfaf1e-f278-4f34-b3f2-09ccbc37c10d",
"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": [
10160,
7296
],
"id": "a828f0bf-44ef-452c-8aae-f089b34aace5",
"name": "Insertar histórico Supabase",
"onError": "continueRegularOutput"
},
{
"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 discrepanciasMontoPago:\n original.summary?.discrepanciasMontoPago ?? 0,\n posiblesCuentasMalDigitadas:\n original.summary?.posiblesCuentasMalDigitadas ?? 0,\n totalResultados:\n original.summary?.totalResultados ?? 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": [
10432,
7296
],
"id": "4321d98f-7d24-49d5-a03f-b19eb407f11e",
"name": "Preparar respuesta final"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{\n(() => {\n const data = $json || {};\n\n const original =\n data.originalResponse ||\n data.original_response ||\n data.response ||\n data.cruceResponse ||\n data.cruce_response ||\n data;\n\n const summary = original.summary || data.summary || {};\n const rows = original.rows || data.rows || [];\n const bankWithoutBamboo =\n original.bankWithoutBamboo ||\n data.bankWithoutBamboo ||\n [];\n const bambooSummary =\n original.bambooSummary ||\n data.bambooSummary ||\n {};\n\n const reportUrl =\n data.reportUrl ||\n data.report_url ||\n data.googleSheetUrl ||\n data.google_sheet_url ||\n data.spreadsheetUrl ||\n data.spreadsheet_url ||\n original.reportUrl ||\n original.report_url ||\n null;\n\n return {\n ok: original.ok ?? data.ok ?? true,\n message: 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": [
10688,
7296
],
"id": "5f85312f-d57b-462b-943a-a7339f81e69d",
"name": "Respond to Webhook"
},
{
"parameters": {
"content": "# 📥 RECEPCIÓN Y LECTURA DE ARCHIVOS — TT\n\nRecibe desde el Portal de Verificación de Nóminas los archivos y parámetros necesarios para procesar Trinidad y Tobago.\n\nFuentes utilizadas:\n\n- Directorio de empleados de BambooHR.\n- Archivo CSV del banco.\n- Libro de nómina con múltiples hojas o unidades.\n\nEste bloque:\n\n1. Recibe la solicitud enviada por la aplicación.\n2. Normaliza los parámetros del período.\n3. Consulta los empleados disponibles en BambooHR.\n4. Estandariza los datos del directorio.\n5. Convierte el CSV bancario en registros procesables.\n6. Extrae individualmente las hojas incluidas en el archivo de nómina.\n\nLas hojas extraídas pueden corresponder a diferentes clientes, marcas o unidades operativas.\n\nReglas:\n\n- No iniciar el cruce sin los archivos obligatorios.\n- Mantener separados los datos de banco, nómina y BambooHR.\n- Conservar el período recibido desde la aplicación.\n- No asumir que todas las hojas contienen la misma estructura.\n- Preparar una salida consistente para la etapa de consolidación.",
"height": 2016,
"width": 1424,
"color": 7
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
2576,
6624
],
"id": "d209ed03-9a6c-4377-aeb3-a25361644e5f",
"name": "Sticky Note"
},
{
"parameters": {
"content": "# 🔍 CONSOLIDACIÓN Y CRUCE — TRINIDAD Y TOBAGO\n\nConsolida todas las hojas de nómina y compara los empleados y valores contra el archivo bancario y BambooHR.\n\n## Consolidación de nómina\n\nLas hojas extraídas se unen progresivamente hasta formar una única nómina del período.\n\nDespués de combinarlas:\n\n- Se normalizan nombres.\n- Se limpian espacios y caracteres.\n- Se estandarizan correos e identificadores.\n- Se homogenizan los campos monetarios.\n- Se conserva la hoja o unidad de origen cuando sea necesario.\n\n## Cruce de fuentes\n\nEl flujo incorpora progresivamente:\n\n1. Nómina consolidada.\n2. Registros del banco.\n3. Información del empleado en BambooHR.\n\nEl cruce permite identificar casos como:\n\n- Empleados con diferencias entre nómina y banco.\n- Personas presentes únicamente en nómina.\n- Personas presentes únicamente en el banco.\n- Empleados que no pueden relacionarse con BambooHR.\n- Posibles diferencias de nombre, correo, cuenta o monto.\n\nReglas:\n\n- Evitar duplicar empleados al combinar hojas.\n- No depender únicamente del nombre cuando exista otro identificador.\n- Mantener disponibles los valores originales para validación.\n- Diferenciar una ausencia real de un problema de coincidencia.\n- Preparar los resultados en el formato requerido por el reporte final.",
"height": 1984,
"width": 1888,
"color": "#321764"
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
4528,
6560
],
"id": "17197e7f-e073-464e-b8c8-ff0856c584d7",
"name": "Sticky Note1"
},
{
"parameters": {
"content": "# 📊 GENERACIÓN DEL REPORTE EN GOOGLE SHEETS\n\nCrea el reporte final de verificación de nómina de Trinidad y Tobago.\n\nProceso:\n\n1. Organiza los resultados obtenidos durante el cruce.\n2. Define las hojas, encabezados y filas del reporte.\n3. Crea un nuevo archivo de Google Sheets.\n4. Escribe toda la información procesada.\n5. Aplica formato visual.\n6. Configura los permisos de acceso.\n7. Comparte el reporte con las personas autorizadas.\n\nEl reporte puede incluir:\n\n- Resultados del cruce.\n- Diferencias detectadas.\n- Registros sin correspondencia.\n- Información de BambooHR.\n- Resumen del período.\n- Datos necesarios para revisión y seguimiento.\n\nFormato aplicado:\n\n- Encabezados destacados.\n- Columnas ajustadas.\n- Valores monetarios con formato correcto.\n- Fechas normalizadas.\n- Filtros y congelación de encabezados cuando corresponda.\n\nReglas:\n\n- No compartir el archivo antes de terminar la escritura.\n- No devolver un enlace hasta confirmar que el Sheet existe.\n- Compartir solamente con los usuarios autorizados.\n- Mantener Google Sheets como entregable y no como fuente original de los datos.",
"height": 720,
"width": 2064,
"color": "#556822"
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
6752,
6944
],
"id": "656ae2c7-4c04-4346-90de-1e4db21a637d",
"name": "Sticky Note2"
},
{
"parameters": {
"content": "# 🗂️ HISTÓRICO Y RESPUESTA FINAL\n\nRegistra la ejecución en Supabase y devuelve el resultado al Portal de Verificación de Nóminas.\n\n## Registro histórico\n\nDespués de generar el reporte, se prepara un registro con información como:\n\n- País: Trinidad y Tobago.\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 hallazgos.\n- Enlace del Google Sheet.\n- Estado inicial del reporte.\n- Identificador de la ejecución.\n\nSupabase funciona como fuente oficial para los históricos mostrados posteriormente en el portal.\n\n## Respuesta a la aplicación\n\nEl flujo construye una respuesta final con:\n\n- Indicador de éxito.\n- Enlace al reporte.\n- Resumen de resultados.\n- Identificador del histórico.\n- Estado del proceso.\n- Mensaje apto para mostrar en la interfaz.\n\nReglas:\n\n- Registrar el histórico solamente después de crear el reporte.\n- No declarar éxito si el Sheet o el histórico fallaron.\n- No devolver credenciales ni datos internos.\n- Mantener una estructura estable para la aplicación.\n- Cerrar siempre la solicitud mediante Respond to Webhook.",
"height": 768,
"width": 2032,
"color": "#774B22"
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
8944,
6944
],
"id": "ac5189a6-802b-4f5e-a946-2aa574408974",
"name": "Sticky Note3"
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "Preparar entrada app",
"type": "main",
"index": 0
}
]
]
},
"Preparar entrada app": {
"main": [
[
{
"node": "Parsear CSV banco TT",
"type": "main",
"index": 0
},
{
"node": "Extract - BICE",
"type": "main",
"index": 0
},
{
"node": "Extract - Goldey Samuel",
"type": "main",
"index": 0
},
{
"node": "Extract - P&G",
"type": "main",
"index": 0
},
{
"node": "Extract - Whirlpool",
"type": "main",
"index": 0
},
{
"node": "Extract - KAD",
"type": "main",
"index": 0
},
{
"node": "Extract - GLM People",
"type": "main",
"index": 0
},
{
"node": "Extract - GLM",
"type": "main",
"index": 0
}
]
]
},
"Extract - BICE": {
"main": [
[
{
"node": "Merge Hojas TT 01-02",
"type": "main",
"index": 0
}
]
]
},
"Extract - Goldey Samuel": {
"main": [
[
{
"node": "Merge Hojas TT 01-02",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas TT 01-02": {
"main": [
[
{
"node": "Merge Hojas TT 03",
"type": "main",
"index": 0
}
]
]
},
"Extract - P&G": {
"main": [
[
{
"node": "Merge Hojas TT 03",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas TT 03": {
"main": [
[
{
"node": "Merge Hojas TT 04",
"type": "main",
"index": 0
}
]
]
},
"Extract - Whirlpool": {
"main": [
[
{
"node": "Merge Hojas TT 04",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas TT 04": {
"main": [
[
{
"node": "Merge Hojas TT 05",
"type": "main",
"index": 0
}
]
]
},
"Extract - KAD": {
"main": [
[
{
"node": "Merge Hojas TT 05",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas TT 05": {
"main": [
[
{
"node": "Merge Hojas TT 06",
"type": "main",
"index": 0
}
]
]
},
"Extract - GLM People": {
"main": [
[
{
"node": "Merge Hojas TT 06",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas TT 06": {
"main": [
[
{
"node": "Merge Hojas TT 07",
"type": "main",
"index": 0
}
]
]
},
"Extract - GLM": {
"main": [
[
{
"node": "Merge Hojas TT 07",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas TT 07": {
"main": [
[
{
"node": "Normalizar Nómina TT",
"type": "main",
"index": 0
}
]
]
},
"Parsear CSV banco TT": {
"main": [
[
{
"node": "Merge Banco + Nómina TT",
"type": "main",
"index": 0
}
]
]
},
"Normalizar Nómina TT": {
"main": [
[
{
"node": "Merge Banco + Nómina TT",
"type": "main",
"index": 1
}
]
]
},
"HTTP - Empleados BambooHR TT": {
"main": [
[
{
"node": "Normalizar BambooHR TT",
"type": "main",
"index": 0
}
]
]
},
"Merge Banco + Nómina TT": {
"main": [
[
{
"node": "HTTP - Empleados BambooHR TT",
"type": "main",
"index": 0
},
{
"node": "Merge - Agregar BambooHR TT",
"type": "main",
"index": 0
}
]
]
},
"Normalizar BambooHR TT": {
"main": [
[
{
"node": "Merge - Agregar BambooHR TT",
"type": "main",
"index": 1
}
]
]
},
"Merge - Agregar BambooHR TT": {
"main": [
[
{
"node": "Cruzar Nómina vs Banco",
"type": "main",
"index": 0
}
]
]
},
"Cruzar Nómina vs Banco": {
"main": [
[
{
"node": "Preparar Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Preparar Google Sheet": {
"main": [
[
{
"node": "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 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
}
]
]
},
"Preparar respuesta final": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
}
},
"authors": "Isaac Aracena",
"name": "Version 2be11ec0",
"description": "",
"autosaved": false,
"workflowPublishHistory": [
{
"createdAt": "2026-07-27T15:01:26.945Z",
"id": 3498,
"workflowId": "5AujMxduslftVg9z",
"versionId": "2be11ec0-4659-47ca-9a58-38fd0f9eb4d9",
"event": "activated",
"userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029"
}
]
}
}