diff --git a/portal-de-verificacion-de-nomina-tt.json b/portal-de-verificacion-de-nomina-tt.json new file mode 100644 index 0000000..2d46349 --- /dev/null +++ b/portal-de-verificacion-de-nomina-tt.json @@ -0,0 +1,2020 @@ +{ + "updatedAt": "2026-07-16T16:22:13.609Z", + "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": [ + -512, + 1424 + ], + "id": "670c388d-cd62-435c-a1fa-550e32dffc38", + "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": [ + -272, + 1424 + ], + "id": "91d42eab-5010-4db8-aa38-c71e5fb49b37", + "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": [ + 64, + 1120 + ], + "id": "25638e1d-310a-492a-b1db-b8814bf14344", + "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": [ + 64, + 1520 + ], + "id": "d5f93467-cd0a-493b-973e-5abc9645ccd0", + "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": [ + 64, + 1696 + ], + "id": "94b7ba5c-45dd-4c34-93a9-1af811c81941", + "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": [ + 64, + 1856 + ], + "id": "6d91ab0f-1aab-4fd8-92ff-3f3dbeabed1d", + "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": [ + 64, + 2032 + ], + "id": "09716bc9-0431-433d-bd58-71a76a5bb31d", + "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": [ + 64, + 2208 + ], + "id": "27ab7829-5bfe-491d-9b8d-ce5dc90efb53", + "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": [ + 64, + 2368 + ], + "id": "5fcc84ba-9c13-4d98-815f-3e246f0f3fc0", + "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": [ + 64, + 2544 + ], + "id": "f5edbec8-7e3e-4ed2-9ca8-74374a302919", + "name": "Extract - GLM", + "retryOnFail": false + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 464, + 1616 + ], + "id": "81c9547b-8ce5-4b83-acaf-87768239c463", + "name": "Merge Hojas TT 01-02" + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 464, + 1776 + ], + "id": "303f35ae-c7c3-4f79-8682-00ebbc23b6f9", + "name": "Merge Hojas TT 03" + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 464, + 1952 + ], + "id": "38ff1b61-1e0d-4896-b54c-44e47262f825", + "name": "Merge Hojas TT 04" + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 464, + 2112 + ], + "id": "2ccfeb6e-96a9-4107-b2ca-f4d2f34f2874", + "name": "Merge Hojas TT 05" + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 464, + 2288 + ], + "id": "058d4ed4-3ac8-4235-afdc-10a0e8355ef2", + "name": "Merge Hojas TT 06" + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 464, + 2464 + ], + "id": "39dc6b0c-2cf2-4728-9d71-ae60b943973f", + "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": [ + 832, + 2032 + ], + "id": "564cf732-be79-434c-b876-051fb7bcd698", + "name": "Normalizar Nómina TT" + }, + { + "parameters": { + "mode": "combine", + "combineBy": "combineByPosition", + "options": {} + }, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 1088, + 1376 + ], + "id": "6152c18c-460c-49e0-b513-bf95b2b30865", + "name": "Merge Banco + Nómina TT" + }, + { + "parameters": { + "url": "https://glm.bamboohr.com/api/v1/employees", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "fields", + "value": "employeeNumber,firstName,middleName,lastName,preferredName,displayName,fullName1,fullName2,fullName3,fullName4,fullName5,status,employmentStatus,employmentHistoryStatus,hireDate,originalHireDate,terminationDate,location,country,includeInPayroll,workEmail,homeEmail,bestEmail" + }, + { + "name": "page[limit]", + "value": "2500" + } + ] + }, + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": { + "pagination": { + "pagination": { + "paginationMode": "responseContainsNextURL", + "nextURL": "={{ $response.body._links?.next?.href || '' }}", + "paginationCompleteWhen": "other", + "completeExpression": "={{ !$response.body._links?.next?.href }}", + "limitPagesFetched": true, + "maxRequests": 10 + } + }, + "timeout": 120000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 64, + 896 + ], + "id": "83d1cefc-0284-4ea6-9d82-bc2224ed5559", + "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 =\n $('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(/\\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(/[^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 parseDate(value) {\n const raw = clean(value);\n if (!raw) 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 const normalized = normalize(value);\n\n return [\n 'true',\n 'yes',\n 'si',\n 'sí',\n '1',\n 'y',\n ].includes(normalized);\n}\n\nfunction isTrinidadTobago(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\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\n// Algunos perfiles pueden existir en BambooHR, pero tener vacío o\n// incorrecto el campo country/location. Para que no aparezcan como\n// \"Banco sin Bamboo\", se incorporan como candidatos únicamente cuando\n// su nombre coincide exactamente con un nombre del banco o la nómina\n// de esta misma ejecución.\nconst relevantNames = new Set();\n\nfunction addRelevantName(value) {\n const normalized = normalize(value);\n if (normalized) relevantNames.add(normalized);\n}\n\nfor (const row of reconciliationData.bank?.rows || []) {\n addRelevantName(\n row.bank_name_file ||\n row.participant_name ||\n row.bank_account_holder ||\n ''\n );\n}\n\nfor (\n const row of\n reconciliationData.bank?.grouped_by_account || []\n) {\n for (const name of row.bank_name_files || []) {\n addRelevantName(name);\n }\n for (const name of row.bank_account_holders || []) {\n addRelevantName(name);\n }\n addRelevantName(row.bank_name_file || '');\n addRelevantName(row.bank_account_holder || '');\n}\n\nfor (const row of reconciliationData.payroll?.rows || []) {\n addRelevantName(\n row.employee_name ||\n row.employee ||\n ''\n );\n}\n\nfor (\n const row of\n reconciliationData.payroll?.no_account_rows || []\n) {\n addRelevantName(\n row.employee_name ||\n row.employee ||\n ''\n );\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\n// El universo principal para “Banco sin Bamboo” son los perfiles\n// pertenecientes a Trinidad y Tobago. Se permite rescatar un perfil con\n// país/location incorrecto únicamente cuando:\n// 1) su nombre coincide exactamente con un nombre del banco o nómina,\n// 2) está Active, y\n// 3) se encontraba vigente dentro del período procesado.\n// Esto conserva perfiles activos con metadata de país incorrecta, pero evita\n// que un registro histórico de otro país o fuera del período produzca un\n// falso positivo. Ejemplo confirmado: Isaac St Bernard tiene un perfil\n// inactivo de República Dominicana, terminado antes del 16-06-2026; ese\n// registro no es válido para confirmar su presencia en BambooHR TT.\nconst trinidadTobagoEmployees =\n allNormalized\n .filter(isTrinidadTobago)\n .map((employee) => ({\n ...employee,\n validation_eligible: true,\n validation_scope:\n 'trinidad_tobago_country_or_location',\n }));\n\nconst relevantEmployeesOutsideCountry =\n allNormalized.filter((employee) => {\n if (isTrinidadTobago(employee)) return false;\n\n return (\n employee.normalized_aliases || []\n ).some((alias) =>\n relevantNames.has(alias)\n );\n });\n\nconst eligibleRelevantEmployeesOutsideCountry =\n relevantEmployeesOutsideCountry\n .filter((employee) =>\n employee.overlaps_period === true &&\n normalize(employee.status) === 'active'\n )\n .map((employee) => ({\n ...employee,\n validation_eligible: true,\n validation_scope:\n 'outside_country_exact_name_active_in_period',\n }));\n\nconst excludedRelevantEmployeesOutsideCountry =\n relevantEmployeesOutsideCountry\n .filter((employee) =>\n !(\n employee.overlaps_period === true &&\n normalize(employee.status) === 'active'\n )\n )\n .map((employee) => ({\n bamboo_id: employee.bamboo_id,\n employee_number: employee.employee_number,\n full_name: employee.full_name,\n country: employee.country,\n location: employee.location,\n status: employee.status,\n hire_date: employee.hire_date,\n termination_date:\n employee.termination_date,\n overlaps_period:\n employee.overlaps_period,\n exclusion_reason:\n employee.overlaps_period !== true\n ? 'outside_processing_period'\n : 'status_not_active',\n }));\n\nconst validationEmployeeMap = new Map();\n\nfor (const employee of [\n ...trinidadTobagoEmployees,\n ...eligibleRelevantEmployeesOutsideCountry,\n]) {\n const key =\n employee.bamboo_id ||\n employee.employee_number ||\n normalize(employee.full_name);\n\n if (key) {\n validationEmployeeMap.set(key, employee);\n }\n}\n\nconst bambooValidationEmployees =\n Array.from(validationEmployeeMap.values());\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 (!trinidadTobagoEmployees.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_list_employees_paginado',\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 trinidadTobagoEmployees.length,\n active_in_period_count:\n trinidadTobagoEmployees.filter(\n (employee) =>\n employee.overlaps_period\n ).length,\n active_status_count:\n trinidadTobagoEmployees.filter(\n (employee) =>\n normalize(employee.status) ===\n 'active'\n ).length,\n fetch_complete: fetchComplete,\n validation_available:\n fetchComplete &&\n bambooValidationEmployees.length > 0,\n validation_candidates_count:\n bambooValidationEmployees.length,\n relevant_outside_country_count:\n eligibleRelevantEmployeesOutsideCountry.length,\n relevant_outside_country_total_count:\n relevantEmployeesOutsideCountry.length,\n relevant_outside_country_excluded_count:\n excludedRelevantEmployeesOutsideCountry.length,\n relevant_outside_country_excluded:\n excludedRelevantEmployeesOutsideCountry,\n validation_rule:\n 'TT country/location, or exact contextual name that is Active and overlaps the period',\n employees:\n bambooValidationEmployees,\n restricted_fields:\n restrictedFields,\n },\n },\n },\n];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 464, + 896 + ], + "id": "bceb89b4-08e9-41c6-b412-f996a61bbcd9", + "name": "Normalizar BambooHR TT" + }, + { + "parameters": { + "mode": "combine", + "combineBy": "combineByPosition", + "options": {} + }, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 1344, + 1376 + ], + "id": "63b76f71-f399-4e62-8eac-1c60d0ad24cb", + "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 let matches = 0;\n\n for (const wordA of wordsA) {\n const index = wordsB.findIndex(\n (wordB, wordIndex) =>\n !usedB.has(wordIndex) &&\n bambooTokenMatches(wordA, wordB)\n );\n\n if (index >= 0) {\n usedB.add(index);\n matches += 1;\n }\n }\n\n if (matches < 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 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 // Confirmado manualmente en el directorio de BambooHR:\n // no debe aceptarse una coincidencia aproximada para este nombre.\n // Si en el futuro se crea el perfil con un alias exacto, el bloque\n // anterior lo reconocerá automáticamente antes de llegar aquí.\n const confirmedAbsentNames = new Set([\n 'isaac st bernard',\n ]);\n\n if (\n normalizedNames.some((name) =>\n confirmedAbsentNames.has(name)\n )\n ) {\n const result = {\n found: false,\n matched_by: null,\n confidence: 0,\n employee: null,\n ambiguous: false,\n best_candidate: null,\n reason: 'confirmed_absent_in_bamboohr',\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": [ + 1600, + 1376 + ], + "id": "d510b344-a903-40d4-9f07-3ebf7c7a3301", + "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\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\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\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\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\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 ],\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 ],\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 ],\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 ],\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'],\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:H`,\n values: nominaVsBancoValues,\n },\n {\n range:\n `'${sheetTitles.bancoSinNomina}'!A1:G`,\n values: bancoSinNominaValues,\n },\n {\n range:\n `'${sheetTitles.bancoSinBamboo}'!A1:F`,\n values: bancoSinBambooValues,\n },\n {\n range:\n `'${sheetTitles.diferenciasNombreBanco}'!A1:G`,\n values: diferenciasNombreValues,\n },\n ...(hasCuentaMalDigitada\n ? [\n {\n range:\n `'${sheetTitles.cuentaMalDigitada}'!A1:C`,\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\nconst formatRequests = [\n ...styleReport({\n sheetId:\n sheetIds.nominaVsBanco,\n columnCount: 8,\n bodyRowsCount:\n mainRows.length,\n widths: [\n 48,\n 250,\n 145,\n 135,\n 135,\n 135,\n 180,\n 120,\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: 7,\n bodyRowsCount:\n bancoSinNominaRows.length,\n widths: [\n 48,\n 230,\n 145,\n 135,\n 230,\n 140,\n 360,\n ],\n moneyColumns: [[3, 4]],\n statusColumn: 5,\n }),\n\n ...styleReport({\n sheetId:\n sheetIds.bancoSinBamboo,\n columnCount: 6,\n bodyRowsCount:\n bancoSinBambooRows.length,\n widths: [\n 48,\n 250,\n 145,\n 140,\n 250,\n 150,\n ],\n moneyColumns: [[3, 4]],\n statusColumn: 5,\n }),\n\n ...styleReport({\n sheetId:\n sheetIds.diferenciasNombreBanco,\n columnCount: 7,\n bodyRowsCount:\n diferenciasNombreRows.length,\n widths: [\n 48,\n 240,\n 240,\n 145,\n 140,\n 150,\n 420,\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: 3,\n bodyRowsCount:\n cuentaMalDigitadaRows.length,\n widths: [\n 48,\n 300,\n 520,\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 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\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": [ + 1872, + 1376 + ], + "id": "46cce451-8fb6-4deb-bca3-b8cd8a7711ef", + "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": [ + 2128, + 1376 + ], + "id": "79de4fa4-4d0f-45f6-af0f-6b1acb494cf0", + "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": [ + 2384, + 1376 + ], + "id": "6c5feb7b-b33a-4488-8231-e31622d67a30", + "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": [ + 2640, + 1376 + ], + "id": "41316f8a-f4fa-4b5f-852b-bdccfcfbdb7b", + "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": [ + 2912, + 1376 + ], + "id": "b7581ad4-9305-4757-bb96-5716f68fd6d3", + "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": [ + 3168, + 1376 + ], + "id": "a3ceb103-f683-48e5-8f71-c952c6d44626", + "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": [ + 3424, + 1376 + ], + "id": "2a352284-56df-4be7-bf28-e58d5aa59494", + "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": [ + 3680, + 1376 + ], + "id": "ad36607d-a0d7-43e6-a261-aa1d5fd99b85", + "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": [ + 3952, + 1376 + ], + "id": "bc8c0691-dfe8-4980-9da9-022728ee77d1", + "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": [ + 4208, + 1376 + ], + "id": "7aac07dc-6bb1-45ec-9784-0242de3a3a9d", + "name": "Respond to Webhook" + } + ], + "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 + }, + "staticData": null, + "meta": null, + "versionId": "8d7fee0e-1568-42f6-b897-795c78f921ca", + "activeVersionId": "8d7fee0e-1568-42f6-b897-795c78f921ca", + "versionCounter": 35, + "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 ", + "type": "personal", + "icon": null, + "description": null, + "creatorId": "0a88c0b1-928e-4412-896e-c5d1c99b2029" + } + } + ], + "tags": [], + "activeVersion": { + "updatedAt": "2026-07-14T17:21:03.000Z", + "createdAt": "2026-07-14T17:21:00.395Z", + "versionId": "8d7fee0e-1568-42f6-b897-795c78f921ca", + "workflowId": "5AujMxduslftVg9z", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "nominatt-bamboo-test", + "responseMode": "responseNode", + "options": {} + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + -512, + 1424 + ], + "id": "670c388d-cd62-435c-a1fa-550e32dffc38", + "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": [ + -272, + 1424 + ], + "id": "91d42eab-5010-4db8-aa38-c71e5fb49b37", + "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": [ + 64, + 1120 + ], + "id": "25638e1d-310a-492a-b1db-b8814bf14344", + "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": [ + 64, + 1520 + ], + "id": "d5f93467-cd0a-493b-973e-5abc9645ccd0", + "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": [ + 64, + 1696 + ], + "id": "94b7ba5c-45dd-4c34-93a9-1af811c81941", + "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": [ + 64, + 1856 + ], + "id": "6d91ab0f-1aab-4fd8-92ff-3f3dbeabed1d", + "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": [ + 64, + 2032 + ], + "id": "09716bc9-0431-433d-bd58-71a76a5bb31d", + "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": [ + 64, + 2208 + ], + "id": "27ab7829-5bfe-491d-9b8d-ce5dc90efb53", + "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": [ + 64, + 2368 + ], + "id": "5fcc84ba-9c13-4d98-815f-3e246f0f3fc0", + "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": [ + 64, + 2544 + ], + "id": "f5edbec8-7e3e-4ed2-9ca8-74374a302919", + "name": "Extract - GLM", + "retryOnFail": false + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 464, + 1616 + ], + "id": "81c9547b-8ce5-4b83-acaf-87768239c463", + "name": "Merge Hojas TT 01-02" + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 464, + 1776 + ], + "id": "303f35ae-c7c3-4f79-8682-00ebbc23b6f9", + "name": "Merge Hojas TT 03" + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 464, + 1952 + ], + "id": "38ff1b61-1e0d-4896-b54c-44e47262f825", + "name": "Merge Hojas TT 04" + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 464, + 2112 + ], + "id": "2ccfeb6e-96a9-4107-b2ca-f4d2f34f2874", + "name": "Merge Hojas TT 05" + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 464, + 2288 + ], + "id": "058d4ed4-3ac8-4235-afdc-10a0e8355ef2", + "name": "Merge Hojas TT 06" + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 464, + 2464 + ], + "id": "39dc6b0c-2cf2-4728-9d71-ae60b943973f", + "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": [ + 832, + 2032 + ], + "id": "564cf732-be79-434c-b876-051fb7bcd698", + "name": "Normalizar Nómina TT" + }, + { + "parameters": { + "mode": "combine", + "combineBy": "combineByPosition", + "options": {} + }, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 1088, + 1376 + ], + "id": "6152c18c-460c-49e0-b513-bf95b2b30865", + "name": "Merge Banco + Nómina TT" + }, + { + "parameters": { + "url": "https://glm.bamboohr.com/api/v1/employees", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "fields", + "value": "employeeNumber,firstName,middleName,lastName,preferredName,displayName,fullName1,fullName2,fullName3,fullName4,fullName5,status,employmentStatus,employmentHistoryStatus,hireDate,originalHireDate,terminationDate,location,country,includeInPayroll,workEmail,homeEmail,bestEmail" + }, + { + "name": "page[limit]", + "value": "2500" + } + ] + }, + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": { + "pagination": { + "pagination": { + "paginationMode": "responseContainsNextURL", + "nextURL": "={{ $response.body._links?.next?.href || '' }}", + "paginationCompleteWhen": "other", + "completeExpression": "={{ !$response.body._links?.next?.href }}", + "limitPagesFetched": true, + "maxRequests": 10 + } + }, + "timeout": 120000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 64, + 896 + ], + "id": "83d1cefc-0284-4ea6-9d82-bc2224ed5559", + "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 =\n $('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(/\\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(/[^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 parseDate(value) {\n const raw = clean(value);\n if (!raw) 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 const normalized = normalize(value);\n\n return [\n 'true',\n 'yes',\n 'si',\n 'sí',\n '1',\n 'y',\n ].includes(normalized);\n}\n\nfunction isTrinidadTobago(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\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\n// Algunos perfiles pueden existir en BambooHR, pero tener vacío o\n// incorrecto el campo country/location. Para que no aparezcan como\n// \"Banco sin Bamboo\", se incorporan como candidatos únicamente cuando\n// su nombre coincide exactamente con un nombre del banco o la nómina\n// de esta misma ejecución.\nconst relevantNames = new Set();\n\nfunction addRelevantName(value) {\n const normalized = normalize(value);\n if (normalized) relevantNames.add(normalized);\n}\n\nfor (const row of reconciliationData.bank?.rows || []) {\n addRelevantName(\n row.bank_name_file ||\n row.participant_name ||\n row.bank_account_holder ||\n ''\n );\n}\n\nfor (\n const row of\n reconciliationData.bank?.grouped_by_account || []\n) {\n for (const name of row.bank_name_files || []) {\n addRelevantName(name);\n }\n for (const name of row.bank_account_holders || []) {\n addRelevantName(name);\n }\n addRelevantName(row.bank_name_file || '');\n addRelevantName(row.bank_account_holder || '');\n}\n\nfor (const row of reconciliationData.payroll?.rows || []) {\n addRelevantName(\n row.employee_name ||\n row.employee ||\n ''\n );\n}\n\nfor (\n const row of\n reconciliationData.payroll?.no_account_rows || []\n) {\n addRelevantName(\n row.employee_name ||\n row.employee ||\n ''\n );\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\n// El universo principal para “Banco sin Bamboo” son los perfiles\n// pertenecientes a Trinidad y Tobago. Se permite rescatar un perfil con\n// país/location incorrecto únicamente cuando:\n// 1) su nombre coincide exactamente con un nombre del banco o nómina,\n// 2) está Active, y\n// 3) se encontraba vigente dentro del período procesado.\n// Esto conserva perfiles activos con metadata de país incorrecta, pero evita\n// que un registro histórico de otro país o fuera del período produzca un\n// falso positivo. Ejemplo confirmado: Isaac St Bernard tiene un perfil\n// inactivo de República Dominicana, terminado antes del 16-06-2026; ese\n// registro no es válido para confirmar su presencia en BambooHR TT.\nconst trinidadTobagoEmployees =\n allNormalized\n .filter(isTrinidadTobago)\n .map((employee) => ({\n ...employee,\n validation_eligible: true,\n validation_scope:\n 'trinidad_tobago_country_or_location',\n }));\n\nconst relevantEmployeesOutsideCountry =\n allNormalized.filter((employee) => {\n if (isTrinidadTobago(employee)) return false;\n\n return (\n employee.normalized_aliases || []\n ).some((alias) =>\n relevantNames.has(alias)\n );\n });\n\nconst eligibleRelevantEmployeesOutsideCountry =\n relevantEmployeesOutsideCountry\n .filter((employee) =>\n employee.overlaps_period === true &&\n normalize(employee.status) === 'active'\n )\n .map((employee) => ({\n ...employee,\n validation_eligible: true,\n validation_scope:\n 'outside_country_exact_name_active_in_period',\n }));\n\nconst excludedRelevantEmployeesOutsideCountry =\n relevantEmployeesOutsideCountry\n .filter((employee) =>\n !(\n employee.overlaps_period === true &&\n normalize(employee.status) === 'active'\n )\n )\n .map((employee) => ({\n bamboo_id: employee.bamboo_id,\n employee_number: employee.employee_number,\n full_name: employee.full_name,\n country: employee.country,\n location: employee.location,\n status: employee.status,\n hire_date: employee.hire_date,\n termination_date:\n employee.termination_date,\n overlaps_period:\n employee.overlaps_period,\n exclusion_reason:\n employee.overlaps_period !== true\n ? 'outside_processing_period'\n : 'status_not_active',\n }));\n\nconst validationEmployeeMap = new Map();\n\nfor (const employee of [\n ...trinidadTobagoEmployees,\n ...eligibleRelevantEmployeesOutsideCountry,\n]) {\n const key =\n employee.bamboo_id ||\n employee.employee_number ||\n normalize(employee.full_name);\n\n if (key) {\n validationEmployeeMap.set(key, employee);\n }\n}\n\nconst bambooValidationEmployees =\n Array.from(validationEmployeeMap.values());\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 (!trinidadTobagoEmployees.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_list_employees_paginado',\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 trinidadTobagoEmployees.length,\n active_in_period_count:\n trinidadTobagoEmployees.filter(\n (employee) =>\n employee.overlaps_period\n ).length,\n active_status_count:\n trinidadTobagoEmployees.filter(\n (employee) =>\n normalize(employee.status) ===\n 'active'\n ).length,\n fetch_complete: fetchComplete,\n validation_available:\n fetchComplete &&\n bambooValidationEmployees.length > 0,\n validation_candidates_count:\n bambooValidationEmployees.length,\n relevant_outside_country_count:\n eligibleRelevantEmployeesOutsideCountry.length,\n relevant_outside_country_total_count:\n relevantEmployeesOutsideCountry.length,\n relevant_outside_country_excluded_count:\n excludedRelevantEmployeesOutsideCountry.length,\n relevant_outside_country_excluded:\n excludedRelevantEmployeesOutsideCountry,\n validation_rule:\n 'TT country/location, or exact contextual name that is Active and overlaps the period',\n employees:\n bambooValidationEmployees,\n restricted_fields:\n restrictedFields,\n },\n },\n },\n];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 464, + 896 + ], + "id": "bceb89b4-08e9-41c6-b412-f996a61bbcd9", + "name": "Normalizar BambooHR TT" + }, + { + "parameters": { + "mode": "combine", + "combineBy": "combineByPosition", + "options": {} + }, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 1344, + 1376 + ], + "id": "63b76f71-f399-4e62-8eac-1c60d0ad24cb", + "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 let matches = 0;\n\n for (const wordA of wordsA) {\n const index = wordsB.findIndex(\n (wordB, wordIndex) =>\n !usedB.has(wordIndex) &&\n bambooTokenMatches(wordA, wordB)\n );\n\n if (index >= 0) {\n usedB.add(index);\n matches += 1;\n }\n }\n\n if (matches < 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 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 // Confirmado manualmente en el directorio de BambooHR:\n // no debe aceptarse una coincidencia aproximada para este nombre.\n // Si en el futuro se crea el perfil con un alias exacto, el bloque\n // anterior lo reconocerá automáticamente antes de llegar aquí.\n const confirmedAbsentNames = new Set([\n 'isaac st bernard',\n ]);\n\n if (\n normalizedNames.some((name) =>\n confirmedAbsentNames.has(name)\n )\n ) {\n const result = {\n found: false,\n matched_by: null,\n confidence: 0,\n employee: null,\n ambiguous: false,\n best_candidate: null,\n reason: 'confirmed_absent_in_bamboohr',\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": [ + 1600, + 1376 + ], + "id": "d510b344-a903-40d4-9f07-3ebf7c7a3301", + "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\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\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\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\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\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 ],\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 ],\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 ],\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 ],\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'],\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:H`,\n values: nominaVsBancoValues,\n },\n {\n range:\n `'${sheetTitles.bancoSinNomina}'!A1:G`,\n values: bancoSinNominaValues,\n },\n {\n range:\n `'${sheetTitles.bancoSinBamboo}'!A1:F`,\n values: bancoSinBambooValues,\n },\n {\n range:\n `'${sheetTitles.diferenciasNombreBanco}'!A1:G`,\n values: diferenciasNombreValues,\n },\n ...(hasCuentaMalDigitada\n ? [\n {\n range:\n `'${sheetTitles.cuentaMalDigitada}'!A1:C`,\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\nconst formatRequests = [\n ...styleReport({\n sheetId:\n sheetIds.nominaVsBanco,\n columnCount: 8,\n bodyRowsCount:\n mainRows.length,\n widths: [\n 48,\n 250,\n 145,\n 135,\n 135,\n 135,\n 180,\n 120,\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: 7,\n bodyRowsCount:\n bancoSinNominaRows.length,\n widths: [\n 48,\n 230,\n 145,\n 135,\n 230,\n 140,\n 360,\n ],\n moneyColumns: [[3, 4]],\n statusColumn: 5,\n }),\n\n ...styleReport({\n sheetId:\n sheetIds.bancoSinBamboo,\n columnCount: 6,\n bodyRowsCount:\n bancoSinBambooRows.length,\n widths: [\n 48,\n 250,\n 145,\n 140,\n 250,\n 150,\n ],\n moneyColumns: [[3, 4]],\n statusColumn: 5,\n }),\n\n ...styleReport({\n sheetId:\n sheetIds.diferenciasNombreBanco,\n columnCount: 7,\n bodyRowsCount:\n diferenciasNombreRows.length,\n widths: [\n 48,\n 240,\n 240,\n 145,\n 140,\n 150,\n 420,\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: 3,\n bodyRowsCount:\n cuentaMalDigitadaRows.length,\n widths: [\n 48,\n 300,\n 520,\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 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\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": [ + 1872, + 1376 + ], + "id": "46cce451-8fb6-4deb-bca3-b8cd8a7711ef", + "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": [ + 2128, + 1376 + ], + "id": "79de4fa4-4d0f-45f6-af0f-6b1acb494cf0", + "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": [ + 2384, + 1376 + ], + "id": "6c5feb7b-b33a-4488-8231-e31622d67a30", + "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": [ + 2640, + 1376 + ], + "id": "41316f8a-f4fa-4b5f-852b-bdccfcfbdb7b", + "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": [ + 2912, + 1376 + ], + "id": "b7581ad4-9305-4757-bb96-5716f68fd6d3", + "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": [ + 3168, + 1376 + ], + "id": "a3ceb103-f683-48e5-8f71-c952c6d44626", + "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": [ + 3424, + 1376 + ], + "id": "2a352284-56df-4be7-bf28-e58d5aa59494", + "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": [ + 3680, + 1376 + ], + "id": "ad36607d-a0d7-43e6-a261-aa1d5fd99b85", + "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": [ + 3952, + 1376 + ], + "id": "bc8c0691-dfe8-4980-9da9-022728ee77d1", + "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": [ + 4208, + 1376 + ], + "id": "7aac07dc-6bb1-45ec-9784-0242de3a3a9d", + "name": "Respond to Webhook" + } + ], + "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 8d7fee0e", + "description": "", + "autosaved": true, + "workflowPublishHistory": [ + { + "createdAt": "2026-07-14T17:21:03.796Z", + "id": 2921, + "workflowId": "5AujMxduslftVg9z", + "versionId": "8d7fee0e-1568-42f6-b897-795c78f921ca", + "event": "activated", + "userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029" + } + ] + } +} \ No newline at end of file