1073 lines
68 KiB
JSON
1073 lines
68 KiB
JSON
{
|
|
"updatedAt": "2026-07-04T16:05:00.552Z",
|
|
"createdAt": "2026-07-03T16:04:36.199Z",
|
|
"id": "TNoTZLisReBssKjy",
|
|
"name": "Verificación de Nómina - Guatemala",
|
|
"description": null,
|
|
"active": true,
|
|
"isArchived": false,
|
|
"nodes": [
|
|
{
|
|
"parameters": {
|
|
"httpMethod": "POST",
|
|
"path": "nominagt",
|
|
"responseMode": "responseNode",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.webhook",
|
|
"typeVersion": 2.1,
|
|
"position": [
|
|
1008,
|
|
32
|
|
],
|
|
"id": "f58949bc-0ca7-4488-a5e5-925efd6a14a5",
|
|
"name": "Webhook",
|
|
"webhookId": "e9f8f7b6-ab97-40b1-93b9-9ee9131c94ae"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const item = $input.first();\n\nconst body = item.json.body || {};\nconst binary = item.binary || {};\n\nlet metadata = {};\n\ntry {\n metadata = typeof body.metadata === 'string'\n ? JSON.parse(body.metadata)\n : body.metadata || {};\n} catch (error) {\n metadata = {};\n}\n\nconst binaryKeys = Object.keys(binary);\n\nconst payrollKey = binaryKeys.find((key) => key === 'payroll_file');\nconst bankKeys = binaryKeys.filter((key) => key.startsWith('bank_files'));\n\nconst payrollFile = payrollKey\n ? {\n binary_key: payrollKey,\n file_name: binary[payrollKey].fileName,\n file_extension: binary[payrollKey].fileExtension,\n mime_type: binary[payrollKey].mimeType,\n file_size: binary[payrollKey].fileSize,\n }\n : null;\n\nconst bankFiles = bankKeys.map((key) => ({\n binary_key: key,\n file_name: binary[key].fileName,\n file_extension: binary[key].fileExtension,\n mime_type: binary[key].mimeType,\n file_size: binary[key].fileSize,\n}));\n\nconst errors = [];\n\nif (!metadata.country || metadata.country !== 'GT') {\n errors.push('El país recibido no es Guatemala.');\n}\n\nif (!metadata.year) {\n errors.push('No se recibió el año del cruce.');\n}\n\nif (!metadata.month) {\n errors.push('No se recibió el mes del cruce.');\n}\n\nif (!metadata.period_type) {\n errors.push('No se recibió el tipo de quincena.');\n}\n\nif (!metadata.period_start || !metadata.period_end) {\n errors.push('No se recibió el período calculado.');\n}\n\nif (!payrollFile) {\n errors.push('No se recibió el archivo de nómina.');\n}\n\nif (bankFiles.length === 0) {\n errors.push('No se recibió ningún archivo CSV del banco.');\n}\n\nreturn [\n {\n json: {\n ok: errors.length === 0,\n stage: 'entrada_recibida',\n errors,\n metadata,\n payroll_file: payrollFile,\n bank_files: bankFiles,\n summary: {\n payroll_files_count: payrollFile ? 1 : 0,\n bank_files_count: bankFiles.length,\n },\n },\n binary,\n },\n];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
1216,
|
|
32
|
|
],
|
|
"id": "cee26aa0-18a4-4b83-be54-9383496e91e3",
|
|
"name": "Preparar entrada app"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"respondWith": "json",
|
|
"responseBody": "={{\n {\n ok: $json.ok,\n message: \"Cruce preliminar generado correctamente.\",\n stage: $json.stage,\n errors: $json.errors,\n summary: $json.summary,\n rows: $json.rows,\n reportUrl: $json.reportUrl\n }\n}}",
|
|
"options": {
|
|
"responseCode": 200,
|
|
"responseHeaders": {
|
|
"entries": [
|
|
{
|
|
"name": "Content-Type",
|
|
"value": "application/json"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.respondToWebhook",
|
|
"typeVersion": 1.5,
|
|
"position": [
|
|
3472,
|
|
144
|
|
],
|
|
"id": "fd3dd3be-dbab-44c7-a6b0-8b74cf587f0b",
|
|
"name": "Respond to Webhook"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const input = $input.first();\nconst json = input.json || {};\nconst binary = input.binary || {};\n\nfunction parseCsvLine(line) {\n const result = [];\n let current = '';\n let insideQuotes = false;\n\n for (let i = 0; i < line.length; i++) {\n const char = line[i];\n const nextChar = line[i + 1];\n\n if (char === '\"' && insideQuotes && nextChar === '\"') {\n current += '\"';\n i++;\n continue;\n }\n\n if (char === '\"') {\n insideQuotes = !insideQuotes;\n continue;\n }\n\n if (char === ',' && !insideQuotes) {\n result.push(current.trim());\n current = '';\n continue;\n }\n\n current += char;\n }\n\n result.push(current.trim());\n return result;\n}\n\nfunction normalizeText(value) {\n return String(value || '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeAccount(value) {\n return String(value || '')\n .replace(/\\D/g, '')\n .trim();\n}\n\nfunction parseMoneyGTQ(value) {\n const cleaned = String(value || '')\n .replace(/QTZ/gi, '')\n .replace(/Q/gi, '')\n .replace(/,/g, '')\n .replace(/\\s+/g, '')\n .trim();\n\n const parsed = Number.parseFloat(cleaned);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction roundMoney(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n}\n\nconst bankKeys = Object.keys(binary).filter((key) => key.startsWith('bank_files'));\n\nconst allBankRows = [];\nconst fileSummaries = [];\n\nfor (const key of bankKeys) {\n const file = binary[key];\n\n const buffer = await this.helpers.getBinaryDataBuffer(0, key);\n\n // Los CSV del banco vienen en Latin-1 / ISO-8859-1.\n const text = buffer.toString('latin1');\n\n const lines = text\n .split(/\\r?\\n/)\n .map((line) => line.trim())\n .filter((line) => line.length > 0);\n\n const markerIndex = lines.findIndex((line) =>\n normalizeText(line).toLowerCase().includes('transacciones del envío')\n );\n\n if (markerIndex === -1) {\n fileSummaries.push({\n file_name: file.fileName,\n ok: false,\n rows_count: 0,\n total_amount: 0,\n error: 'No se encontró el bloque \"Transacciones del envío\".',\n });\n continue;\n }\n\n const headerIndex = lines.findIndex((line, index) => {\n if (index <= markerIndex) return false;\n const normalized = normalizeText(line).toLowerCase();\n return normalized.includes('cuenta destino') && normalized.includes('monto');\n });\n\n if (headerIndex === -1) {\n fileSummaries.push({\n file_name: file.fileName,\n ok: false,\n rows_count: 0,\n total_amount: 0,\n error: 'No se encontró el encabezado de transacciones.',\n });\n continue;\n }\n\n const headers = parseCsvLine(lines[headerIndex]).map(normalizeText);\n\n const idxCuentaDestino = headers.findIndex((h) => h.toLowerCase() === 'cuenta destino');\n const idxNombreArchivo = headers.findIndex((h) => h.toLowerCase() === 'nombre en archivo');\n const idxNombreCuentahabiente = headers.findIndex((h) => h.toLowerCase() === 'nombre del cuentahabiente');\n const idxMonto = headers.findIndex((h) => h.toLowerCase() === 'monto');\n const idxConcepto = headers.findIndex((h) => h.toLowerCase() === 'concepto');\n const idxEstado = headers.findIndex((h) => h.toLowerCase() === 'estado');\n const idxReferencia = headers.findIndex((h) => h.toLowerCase() === 'referencia');\n\n const rowsFromFile = [];\n\n for (let i = headerIndex + 1; i < lines.length; i++) {\n const values = parseCsvLine(lines[i]);\n\n const account = normalizeAccount(values[idxCuentaDestino]);\n const amount = parseMoneyGTQ(values[idxMonto]);\n\n if (!account || amount <= 0) {\n continue;\n }\n\n const row = {\n source_file: file.fileName,\n row_number: i + 1,\n account,\n reference: normalizeText(values[idxReferencia]),\n bank_name_file: normalizeText(values[idxNombreArchivo]),\n bank_account_holder: normalizeText(values[idxNombreCuentahabiente]),\n amount: roundMoney(amount),\n concept: normalizeText(values[idxConcepto]),\n status: normalizeText(values[idxEstado]),\n };\n\n rowsFromFile.push(row);\n allBankRows.push(row);\n }\n\n const fileTotal = roundMoney(rowsFromFile.reduce((sum, row) => sum + row.amount, 0));\n\n fileSummaries.push({\n file_name: file.fileName,\n ok: true,\n rows_count: rowsFromFile.length,\n total_amount: fileTotal,\n error: null,\n });\n}\n\nconst groupedByAccountMap = new Map();\n\nfor (const row of allBankRows) {\n const current = groupedByAccountMap.get(row.account) || {\n account: row.account,\n bank_name_file: row.bank_name_file,\n bank_account_holder: row.bank_account_holder,\n amount: 0,\n transactions_count: 0,\n source_files: new Set(),\n };\n\n current.amount = roundMoney(current.amount + row.amount);\n current.transactions_count += 1;\n current.source_files.add(row.source_file);\n\n groupedByAccountMap.set(row.account, current);\n}\n\nconst groupedByAccount = Array.from(groupedByAccountMap.values()).map((row) => ({\n ...row,\n source_files: Array.from(row.source_files),\n}));\n\nconst bankTotal = roundMoney(allBankRows.reduce((sum, row) => sum + row.amount, 0));\n\nreturn [\n {\n json: {\n ...json,\n stage: 'banco_parseado',\n bank: {\n files_count: bankKeys.length,\n valid_files_count: fileSummaries.filter((file) => file.ok).length,\n rows_count: allBankRows.length,\n grouped_accounts_count: groupedByAccount.length,\n total_amount: bankTotal,\n file_summaries: fileSummaries,\n rows: allBankRows,\n grouped_by_account: groupedByAccount,\n },\n },\n binary,\n },\n];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
1888,
|
|
-784
|
|
],
|
|
"id": "f6064046-b119-42bc-9dfb-a45e6b0f2e23",
|
|
"name": "Parsear CSV banco GT"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"mode": "combine",
|
|
"combineBy": "combineByPosition",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.merge",
|
|
"typeVersion": 3.2,
|
|
"position": [
|
|
3056,
|
|
144
|
|
],
|
|
"id": "d213d666-b0ee-41da-959e-321f853c2738",
|
|
"name": "Merge"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const data = $input.first().json || {};\n\nfunction roundMoney(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n}\n\nfunction moneyDiff(a, b) {\n return roundMoney((Number(a) || 0) - (Number(b) || 0));\n}\n\nconst payrollAccounts = data.payroll?.grouped_by_account || [];\nconst bankAccounts = data.bank?.grouped_by_account || [];\nconst payrollNoAccountRows = data.payroll?.no_account_rows || [];\n\nconst bankMap = new Map();\nfor (const bank of bankAccounts) {\n bankMap.set(bank.account, bank);\n}\n\nconst payrollMap = new Map();\nfor (const payroll of payrollAccounts) {\n payrollMap.set(payroll.account, payroll);\n}\n\nconst rows = [];\nlet coincidencias = 0;\nlet discrepancias = 0;\nlet bancoSinNomina = 0;\nlet nominaSinCuenta = 0;\n\nfor (const payroll of payrollAccounts) {\n const bank = bankMap.get(payroll.account);\n\n const payrollAmount = roundMoney(payroll.payroll_amount);\n const bankAmount = roundMoney(bank?.amount || 0);\n const diff = moneyDiff(payrollAmount, bankAmount);\n\n if (!bank) {\n discrepancias += 1;\n\n rows.push({\n id: `payroll_without_bank_${payroll.account}`,\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 payrollAmount,\n payroll_amount: payrollAmount,\n bankAmount: 0,\n bank_amount: 0,\n difference: diff,\n status: 'Riesgo',\n category: 'discrepancia',\n observation: 'Está en nómina, pero no aparece pagado en banco.',\n });\n\n continue;\n }\n\n if (Math.abs(diff) <= 0.01) {\n coincidencias += 1;\n\n rows.push({\n id: `match_${payroll.account}`,\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 payrollAmount,\n payroll_amount: payrollAmount,\n bankAmount,\n bank_amount: bankAmount,\n difference: 0,\n status: 'Coincidencia',\n category: 'coincidencia',\n observation: 'Conciliado correctamente.',\n });\n } else {\n discrepancias += 1;\n\n rows.push({\n id: `difference_${payroll.account}`,\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 payrollAmount,\n payroll_amount: payrollAmount,\n bankAmount,\n bank_amount: bankAmount,\n difference: diff,\n status: 'Riesgo',\n category: 'discrepancia',\n observation: `Diferencia de GTQ ${Math.abs(diff).toLocaleString('en-US', {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n })}.`,\n });\n }\n}\n\nfor (const bank of bankAccounts) {\n if (payrollMap.has(bank.account)) {\n continue;\n }\n\n bancoSinNomina += 1;\n discrepancias += 1;\n\n rows.push({\n id: `bank_without_payroll_${bank.account}`,\n employee: bank.bank_account_holder || bank.bank_name_file || 'Empleado banco sin nómina',\n employee_name: bank.bank_account_holder || bank.bank_name_file || 'Empleado banco sin nómina',\n employeeNumber: '',\n employee_number: '',\n account: bank.account,\n payrollAmount: 0,\n payroll_amount: 0,\n bankAmount: roundMoney(bank.amount),\n bank_amount: roundMoney(bank.amount),\n difference: roundMoney(0 - bank.amount),\n status: 'Pendiente revisión',\n category: 'banco_sin_nomina',\n observation: 'Recibió pago en banco, pero no aparece en la nómina cargada.',\n });\n}\n\nfor (const row of payrollNoAccountRows) {\n nominaSinCuenta += 1;\n discrepancias += 1;\n\n rows.push({\n id: `payroll_without_account_${row.source_sheet}_${row.row_number}`,\n employee: row.employee_name,\n employee_name: row.employee_name,\n employeeNumber: '',\n employee_number: '',\n account: '',\n payrollAmount: roundMoney(row.payroll_amount),\n payroll_amount: roundMoney(row.payroll_amount),\n bankAmount: 0,\n bank_amount: 0,\n difference: roundMoney(row.payroll_amount),\n status: 'Pendiente revisión',\n category: 'nomina_sin_cuenta',\n observation: 'Tiene monto en nómina, pero no tiene cuenta bancaria para cruzar contra banco.',\n });\n}\n\nconst totalNomina = roundMoney(data.payroll?.total_amount || 0);\nconst totalBanco = roundMoney(data.bank?.total_amount || 0);\n\nreturn [\n {\n json: {\n ok: true,\n stage: 'cruce_nomina_completa_banco',\n errors: [],\n metadata: data.metadata || {},\n summary: {\n coincidencias,\n discrepancias,\n bancoSinBamboo: 0,\n bancoSinNomina,\n nominaSinCuenta,\n filasNominaValidas: data.payroll?.valid_rows_count || 0,\n filasNominaSinCuenta: data.payroll?.no_account_rows_count || 0,\n cuentasNominaAgrupadas: data.payroll?.grouped_accounts_count || 0,\n transaccionesBanco: data.bank?.rows_count || 0,\n cuentasBancoAgrupadas: data.bank?.grouped_accounts_count || 0,\n totalNomina,\n totalBanco,\n diferenciaTotal: moneyDiff(totalNomina, totalBanco),\n },\n rows,\n reportUrl: null,\n debug: {\n sheet_summaries: data.payroll?.sheet_summaries || [],\n payroll_preview: payrollAccounts.slice(0, 5),\n bank_preview: bankAccounts.slice(0, 5),\n payroll_no_account_preview: payrollNoAccountRows.slice(0, 5),\n },\n },\n },\n];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
3264,
|
|
144
|
|
],
|
|
"id": "599ab057-016b-4157-bf14-48e290a16b97",
|
|
"name": "Cruzar Nómina vs Banco"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "xlsx",
|
|
"binaryPropertyName": "payroll_file",
|
|
"options": {
|
|
"headerRow": true,
|
|
"sheetName": "1) Nomina General"
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
1888,
|
|
-592
|
|
],
|
|
"id": "05afef3d-7661-4ab3-bdd3-330c57af017b",
|
|
"name": "Extract - Nomina General",
|
|
"retryOnFail": false
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "xlsx",
|
|
"binaryPropertyName": "payroll_file",
|
|
"options": {
|
|
"headerRow": true,
|
|
"sheetName": "2) Temporales"
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
1888,
|
|
-352
|
|
],
|
|
"id": "cb1c3b79-11c4-40a2-b82f-112b5b41709e",
|
|
"name": "Extract - Temporales",
|
|
"retryOnFail": false,
|
|
"onError": "continueRegularOutput"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "xlsx",
|
|
"binaryPropertyName": "payroll_file",
|
|
"options": {
|
|
"headerRow": true,
|
|
"sheetName": "3) Auditorias"
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
1888,
|
|
-112
|
|
],
|
|
"id": "4f454135-fb57-44b5-a35a-1afcfd905f09",
|
|
"name": "Extract - Auditorias",
|
|
"retryOnFail": false,
|
|
"onError": "continueRegularOutput"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "xlsx",
|
|
"binaryPropertyName": "payroll_file",
|
|
"options": {
|
|
"headerRow": true,
|
|
"sheetName": "4) Bono Mariana"
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
1904,
|
|
80
|
|
],
|
|
"id": "21823b43-7051-41cb-9a82-43ef2a62dfe8",
|
|
"name": "Extract - Bono Mariana",
|
|
"retryOnFail": false,
|
|
"onError": "continueRegularOutput"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "xlsx",
|
|
"binaryPropertyName": "payroll_file",
|
|
"options": {
|
|
"headerRow": true,
|
|
"sheetName": "5) Movilidad WP"
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
1904,
|
|
288
|
|
],
|
|
"id": "2323dcc2-9485-4aa7-90e8-6aec8db46558",
|
|
"name": "Extract - Movilidad WP",
|
|
"retryOnFail": false,
|
|
"onError": "continueRegularOutput"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "xlsx",
|
|
"binaryPropertyName": "payroll_file",
|
|
"options": {
|
|
"headerRow": true,
|
|
"sheetName": "6)Viaticos PMI"
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
1904,
|
|
496
|
|
],
|
|
"id": "71e98515-9b65-4d50-a959-72d9212261f0",
|
|
"name": "Extract - Viaticos PMI",
|
|
"retryOnFail": false,
|
|
"onError": "continueRegularOutput"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "xlsx",
|
|
"binaryPropertyName": "payroll_file",
|
|
"options": {
|
|
"headerRow": true,
|
|
"sheetName": "7) Combustible Purina"
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
1904,
|
|
704
|
|
],
|
|
"id": "2c1e1cfd-d4af-453d-a1ec-419d0dea32f6",
|
|
"name": "Extract - Combustible Purina",
|
|
"retryOnFail": false,
|
|
"onError": "continueRegularOutput"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "xlsx",
|
|
"binaryPropertyName": "payroll_file",
|
|
"options": {
|
|
"headerRow": true,
|
|
"sheetName": "8) Combustible P&G"
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
1904,
|
|
944
|
|
],
|
|
"id": "36115822-5519-41fe-93ed-69777568fe8f",
|
|
"name": "Extract - Combustible PG",
|
|
"retryOnFail": false,
|
|
"onError": "continueRegularOutput"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "xlsx",
|
|
"binaryPropertyName": "payroll_file",
|
|
"options": {
|
|
"headerRow": true,
|
|
"sheetName": "9) Combustibles Liquidables"
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
1904,
|
|
1168
|
|
],
|
|
"id": "84134be5-8d9d-4495-9cb6-f4de32a6f3a0",
|
|
"name": "Extract - Combustibles Liquidables",
|
|
"retryOnFail": false,
|
|
"onError": "continueRegularOutput"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "function normalizeText(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeAccount(value) {\n return String(value ?? '')\n .replace(/\\D/g, '')\n .trim();\n}\n\nfunction parseMoney(value) {\n if (typeof value === 'number') {\n return Number.isFinite(value) ? value : 0;\n }\n\n const cleaned = String(value ?? '')\n .replace(/GTQ/gi, '')\n .replace(/QTZ/gi, '')\n .replace(/Q/gi, '')\n .replace(/,/g, '')\n .replace(/\\s+/g, '')\n .trim();\n\n const parsed = Number.parseFloat(cleaned);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction roundMoney(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n}\n\nfunction getValue(row, possibleKeys) {\n for (const key of possibleKeys) {\n if (row[key] !== undefined && row[key] !== null && row[key] !== '') {\n return row[key];\n }\n }\n\n const rowKeys = Object.keys(row || {});\n for (const wanted of possibleKeys) {\n const found = rowKeys.find((key) =>\n normalizeText(key).toLowerCase() === normalizeText(wanted).toLowerCase()\n );\n\n if (found && row[found] !== undefined && row[found] !== null && row[found] !== '') {\n return row[found];\n }\n }\n\n return '';\n}\n\nfunction isTotalOrInvalidRow(name) {\n const value = normalizeText(name).toLowerCase();\n\n if (!value) return true;\n\n return (\n value === 'total' ||\n value.includes('total general') ||\n value.includes('gran total') ||\n value.includes('subtotal') ||\n value.includes('nombre completo') ||\n value.includes('nombre')\n );\n}\n\nfunction getNodeRows(nodeName) {\n try {\n return $items(nodeName).map((item) => item.json || {});\n } catch (error) {\n return [];\n }\n}\n\nconst sheetConfigs = [\n {\n nodeName: 'Extract - Nomina General',\n sourceSheet: '1) Nomina General',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE COMPLETO', 'Nombre Completo', 'NOMBRE', 'Nombre'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['NETO A PAGAR', 'Neto a Pagar', 'NETO', 'MONTO', 'Monto', 'VALOR', 'Valor'],\n },\n {\n nodeName: 'Extract - Temporales',\n sourceSheet: '2) Temporales',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE', 'Nombre', 'NOMBRE COMPLETO', 'Nombre Completo'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['NETO A PAGAR', 'Neto a Pagar', 'NETO', 'MONTO', 'Monto', 'VALOR', 'Valor'],\n },\n {\n nodeName: 'Extract - Auditorias',\n sourceSheet: '3) Auditorias',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE COMPLETO', 'Nombre Completo', 'NOMBRE', 'Nombre'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['NETO A PAGAR', 'Neto a Pagar', 'NETO', 'MONTO', 'Monto', 'VALOR', 'Valor'],\n },\n {\n nodeName: 'Extract - Bono Mariana',\n sourceSheet: '4) Bono Mariana',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE COMPLETO', 'Nombre Completo', 'NOMBRE', 'Nombre'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['NETO A PAGAR', 'Neto a Pagar', 'NETO', 'MONTO', 'Monto', 'VALOR', 'Valor'],\n },\n {\n nodeName: 'Extract - Movilidad WP',\n sourceSheet: '5) Movilidad WP',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE', 'Nombre', 'NOMBRE COMPLETO', 'Nombre Completo'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['NETO A PAGAR', 'Neto a Pagar', 'NETO', 'MONTO', 'Monto', 'VALOR', 'Valor'],\n },\n {\n nodeName: 'Extract - Viaticos PMI',\n sourceSheet: '6)Viaticos PMI',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['Nombre', 'NOMBRE', 'NOMBRE COMPLETO', 'Nombre Completo'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['Valor', 'VALOR', 'MONTO', 'Monto', 'NETO A PAGAR', 'Neto a Pagar'],\n },\n {\n nodeName: 'Extract - Combustible Purina',\n sourceSheet: '7) Combustible Purina',\n accountKeys: ['CUENTA', 'Cuenta', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE', 'Nombre', 'NOMBRE COMPLETO', 'Nombre Completo'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['MONTO', 'Monto', 'VALOR', 'Valor', 'TOTAL', 'Total'],\n },\n {\n nodeName: 'Extract - Combustible PG',\n sourceSheet: '8) Combustible P&G',\n accountKeys: ['CUENTA BANCARIA', 'Cuenta Bancaria', 'CUENTA', 'Cuenta'],\n nameKeys: ['NOMBRE', 'Nombre', 'NOMBRE COMPLETO', 'Nombre Completo'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['TOTAL', 'Total', 'MONTO', 'Monto', 'VALOR', 'Valor'],\n },\n {\n nodeName: 'Extract - Combustibles Liquidables',\n sourceSheet: '9) Combustibles Liquidables',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE', 'Nombre', 'NOMBRE COMPLETO', 'Nombre Completo'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['Valor a depositar', 'VALOR A DEPOSITAR', 'Valor', 'VALOR', 'MONTO', 'Monto'],\n },\n];\n\nconst payrollRows = [];\nconst noAccountRows = [];\nconst sheetSummaries = [];\n\nfor (const config of sheetConfigs) {\n const rows = getNodeRows(config.nodeName);\n let validRows = 0;\n let noAccountCount = 0;\n let sheetTotal = 0;\n\n rows.forEach((row, index) => {\n const employeeName = normalizeText(getValue(row, config.nameKeys));\n const account = normalizeAccount(getValue(row, config.accountKeys));\n const email = normalizeText(getValue(row, config.emailKeys)).toLowerCase();\n const amount = roundMoney(parseMoney(getValue(row, config.amountKeys)));\n\n if (isTotalOrInvalidRow(employeeName) || amount <= 0) {\n return;\n }\n\n const normalizedRow = {\n source_sheet: config.sourceSheet,\n row_number: index + 2,\n employee_name: employeeName,\n employee_number: null,\n account,\n email,\n payroll_amount: amount,\n };\n\n sheetTotal = roundMoney(sheetTotal + amount);\n\n if (!account) {\n noAccountCount += 1;\n noAccountRows.push({\n ...normalizedRow,\n status: 'Pendiente revisión',\n observation: 'Tiene monto en nómina, pero no tiene cuenta bancaria para cruzar contra banco.',\n });\n return;\n }\n\n validRows += 1;\n payrollRows.push(normalizedRow);\n });\n\n sheetSummaries.push({\n source_sheet: config.sourceSheet,\n raw_rows_count: rows.length,\n valid_rows_count: validRows,\n no_account_rows_count: noAccountCount,\n total_amount: sheetTotal,\n });\n}\n\nconst groupedMap = new Map();\n\nfor (const row of payrollRows) {\n const current = groupedMap.get(row.account) || {\n account: row.account,\n employee_name: row.employee_name,\n employee_number: null,\n email: row.email,\n payroll_amount: 0,\n rows_count: 0,\n source_sheets: new Set(),\n source_rows: [],\n };\n\n current.payroll_amount = roundMoney(current.payroll_amount + row.payroll_amount);\n current.rows_count += 1;\n\n if (!current.email && row.email) {\n current.email = row.email;\n }\n\n current.source_sheets.add(row.source_sheet);\n current.source_rows.push({\n source_sheet: row.source_sheet,\n row_number: row.row_number,\n amount: row.payroll_amount,\n });\n\n groupedMap.set(row.account, current);\n}\n\nconst groupedByAccount = Array.from(groupedMap.values()).map((row) => ({\n ...row,\n source_sheets: Array.from(row.source_sheets),\n}));\n\nconst totalPayroll = roundMoney(\n payrollRows.reduce((sum, row) => sum + row.payroll_amount, 0) +\n noAccountRows.reduce((sum, row) => sum + row.payroll_amount, 0)\n);\n\nreturn [\n {\n json: {\n payroll: {\n source: 'template_guatemala_completo',\n sheets_count: sheetConfigs.length,\n sheet_summaries: sheetSummaries,\n raw_rows_count: sheetSummaries.reduce((sum, sheet) => sum + sheet.raw_rows_count, 0),\n valid_rows_count: payrollRows.length,\n no_account_rows_count: noAccountRows.length,\n grouped_accounts_count: groupedByAccount.length,\n total_amount: totalPayroll,\n rows: payrollRows,\n no_account_rows: noAccountRows,\n grouped_by_account: groupedByAccount,\n },\n },\n },\n];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
2640,
|
|
160
|
|
],
|
|
"id": "8ddc0e47-335a-4e60-b15b-d3d4f9a66c4f",
|
|
"name": "Normalizar Nómina Completa"
|
|
}
|
|
],
|
|
"connections": {
|
|
"Webhook": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Preparar entrada app",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Preparar entrada app": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Parsear CSV banco GT",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Extract - Nomina General",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Extract - Temporales",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Extract - Auditorias",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Extract - Bono Mariana",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Extract - Movilidad WP",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Extract - Viaticos PMI",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Extract - Combustible Purina",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Extract - Combustible PG",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Extract - Combustibles Liquidables",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Respond to Webhook": {
|
|
"main": [
|
|
[]
|
|
]
|
|
},
|
|
"Parsear CSV banco GT": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Merge",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Merge": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Cruzar Nómina vs Banco",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Cruzar Nómina vs Banco": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Respond to Webhook",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Extract - Nomina General": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Normalizar Nómina Completa",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Extract - Temporales": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Normalizar Nómina Completa",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Extract - Auditorias": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Normalizar Nómina Completa",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Extract - Bono Mariana": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Normalizar Nómina Completa",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Extract - Movilidad WP": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Normalizar Nómina Completa",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Extract - Viaticos PMI": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Normalizar Nómina Completa",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Extract - Combustible Purina": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Normalizar Nómina Completa",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Extract - Combustible PG": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Normalizar Nómina Completa",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Extract - Combustibles Liquidables": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Normalizar Nómina Completa",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Normalizar Nómina Completa": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Merge",
|
|
"type": "main",
|
|
"index": 1
|
|
}
|
|
]
|
|
]
|
|
}
|
|
},
|
|
"settings": {
|
|
"executionOrder": "v1",
|
|
"binaryMode": "separate"
|
|
},
|
|
"staticData": null,
|
|
"meta": null,
|
|
"versionId": "f170c27e-eb23-4ba0-b34f-af24743d3952",
|
|
"activeVersionId": "f170c27e-eb23-4ba0-b34f-af24743d3952",
|
|
"versionCounter": 207,
|
|
"triggerCount": 1,
|
|
"shared": [
|
|
{
|
|
"updatedAt": "2026-07-03T16:04:36.204Z",
|
|
"createdAt": "2026-07-03T16:04:36.204Z",
|
|
"role": "workflow:owner",
|
|
"workflowId": "TNoTZLisReBssKjy",
|
|
"projectId": "PJpTANzTXIFibWsW",
|
|
"project": {
|
|
"updatedAt": "2026-04-22T14:25:09.686Z",
|
|
"createdAt": "2026-04-22T14:22:54.790Z",
|
|
"id": "PJpTANzTXIFibWsW",
|
|
"name": "Isaac Aracena <iaracena@gomezleemarketing.com>",
|
|
"type": "personal",
|
|
"icon": null,
|
|
"description": null,
|
|
"creatorId": "0a88c0b1-928e-4412-896e-c5d1c99b2029"
|
|
}
|
|
}
|
|
],
|
|
"tags": [],
|
|
"activeVersion": {
|
|
"updatedAt": "2026-07-04T16:05:08.000Z",
|
|
"createdAt": "2026-07-04T16:05:00.554Z",
|
|
"versionId": "f170c27e-eb23-4ba0-b34f-af24743d3952",
|
|
"workflowId": "TNoTZLisReBssKjy",
|
|
"nodes": [
|
|
{
|
|
"parameters": {
|
|
"httpMethod": "POST",
|
|
"path": "nominagt",
|
|
"responseMode": "responseNode",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.webhook",
|
|
"typeVersion": 2.1,
|
|
"position": [
|
|
1008,
|
|
32
|
|
],
|
|
"id": "f58949bc-0ca7-4488-a5e5-925efd6a14a5",
|
|
"name": "Webhook",
|
|
"webhookId": "e9f8f7b6-ab97-40b1-93b9-9ee9131c94ae"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const item = $input.first();\n\nconst body = item.json.body || {};\nconst binary = item.binary || {};\n\nlet metadata = {};\n\ntry {\n metadata = typeof body.metadata === 'string'\n ? JSON.parse(body.metadata)\n : body.metadata || {};\n} catch (error) {\n metadata = {};\n}\n\nconst binaryKeys = Object.keys(binary);\n\nconst payrollKey = binaryKeys.find((key) => key === 'payroll_file');\nconst bankKeys = binaryKeys.filter((key) => key.startsWith('bank_files'));\n\nconst payrollFile = payrollKey\n ? {\n binary_key: payrollKey,\n file_name: binary[payrollKey].fileName,\n file_extension: binary[payrollKey].fileExtension,\n mime_type: binary[payrollKey].mimeType,\n file_size: binary[payrollKey].fileSize,\n }\n : null;\n\nconst bankFiles = bankKeys.map((key) => ({\n binary_key: key,\n file_name: binary[key].fileName,\n file_extension: binary[key].fileExtension,\n mime_type: binary[key].mimeType,\n file_size: binary[key].fileSize,\n}));\n\nconst errors = [];\n\nif (!metadata.country || metadata.country !== 'GT') {\n errors.push('El país recibido no es Guatemala.');\n}\n\nif (!metadata.year) {\n errors.push('No se recibió el año del cruce.');\n}\n\nif (!metadata.month) {\n errors.push('No se recibió el mes del cruce.');\n}\n\nif (!metadata.period_type) {\n errors.push('No se recibió el tipo de quincena.');\n}\n\nif (!metadata.period_start || !metadata.period_end) {\n errors.push('No se recibió el período calculado.');\n}\n\nif (!payrollFile) {\n errors.push('No se recibió el archivo de nómina.');\n}\n\nif (bankFiles.length === 0) {\n errors.push('No se recibió ningún archivo CSV del banco.');\n}\n\nreturn [\n {\n json: {\n ok: errors.length === 0,\n stage: 'entrada_recibida',\n errors,\n metadata,\n payroll_file: payrollFile,\n bank_files: bankFiles,\n summary: {\n payroll_files_count: payrollFile ? 1 : 0,\n bank_files_count: bankFiles.length,\n },\n },\n binary,\n },\n];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
1216,
|
|
32
|
|
],
|
|
"id": "cee26aa0-18a4-4b83-be54-9383496e91e3",
|
|
"name": "Preparar entrada app"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"respondWith": "json",
|
|
"responseBody": "={{\n {\n ok: $json.ok,\n message: \"Cruce preliminar generado correctamente.\",\n stage: $json.stage,\n errors: $json.errors,\n summary: $json.summary,\n rows: $json.rows,\n reportUrl: $json.reportUrl\n }\n}}",
|
|
"options": {
|
|
"responseCode": 200,
|
|
"responseHeaders": {
|
|
"entries": [
|
|
{
|
|
"name": "Content-Type",
|
|
"value": "application/json"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.respondToWebhook",
|
|
"typeVersion": 1.5,
|
|
"position": [
|
|
3472,
|
|
144
|
|
],
|
|
"id": "fd3dd3be-dbab-44c7-a6b0-8b74cf587f0b",
|
|
"name": "Respond to Webhook"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const input = $input.first();\nconst json = input.json || {};\nconst binary = input.binary || {};\n\nfunction parseCsvLine(line) {\n const result = [];\n let current = '';\n let insideQuotes = false;\n\n for (let i = 0; i < line.length; i++) {\n const char = line[i];\n const nextChar = line[i + 1];\n\n if (char === '\"' && insideQuotes && nextChar === '\"') {\n current += '\"';\n i++;\n continue;\n }\n\n if (char === '\"') {\n insideQuotes = !insideQuotes;\n continue;\n }\n\n if (char === ',' && !insideQuotes) {\n result.push(current.trim());\n current = '';\n continue;\n }\n\n current += char;\n }\n\n result.push(current.trim());\n return result;\n}\n\nfunction normalizeText(value) {\n return String(value || '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeAccount(value) {\n return String(value || '')\n .replace(/\\D/g, '')\n .trim();\n}\n\nfunction parseMoneyGTQ(value) {\n const cleaned = String(value || '')\n .replace(/QTZ/gi, '')\n .replace(/Q/gi, '')\n .replace(/,/g, '')\n .replace(/\\s+/g, '')\n .trim();\n\n const parsed = Number.parseFloat(cleaned);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction roundMoney(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n}\n\nconst bankKeys = Object.keys(binary).filter((key) => key.startsWith('bank_files'));\n\nconst allBankRows = [];\nconst fileSummaries = [];\n\nfor (const key of bankKeys) {\n const file = binary[key];\n\n const buffer = await this.helpers.getBinaryDataBuffer(0, key);\n\n // Los CSV del banco vienen en Latin-1 / ISO-8859-1.\n const text = buffer.toString('latin1');\n\n const lines = text\n .split(/\\r?\\n/)\n .map((line) => line.trim())\n .filter((line) => line.length > 0);\n\n const markerIndex = lines.findIndex((line) =>\n normalizeText(line).toLowerCase().includes('transacciones del envío')\n );\n\n if (markerIndex === -1) {\n fileSummaries.push({\n file_name: file.fileName,\n ok: false,\n rows_count: 0,\n total_amount: 0,\n error: 'No se encontró el bloque \"Transacciones del envío\".',\n });\n continue;\n }\n\n const headerIndex = lines.findIndex((line, index) => {\n if (index <= markerIndex) return false;\n const normalized = normalizeText(line).toLowerCase();\n return normalized.includes('cuenta destino') && normalized.includes('monto');\n });\n\n if (headerIndex === -1) {\n fileSummaries.push({\n file_name: file.fileName,\n ok: false,\n rows_count: 0,\n total_amount: 0,\n error: 'No se encontró el encabezado de transacciones.',\n });\n continue;\n }\n\n const headers = parseCsvLine(lines[headerIndex]).map(normalizeText);\n\n const idxCuentaDestino = headers.findIndex((h) => h.toLowerCase() === 'cuenta destino');\n const idxNombreArchivo = headers.findIndex((h) => h.toLowerCase() === 'nombre en archivo');\n const idxNombreCuentahabiente = headers.findIndex((h) => h.toLowerCase() === 'nombre del cuentahabiente');\n const idxMonto = headers.findIndex((h) => h.toLowerCase() === 'monto');\n const idxConcepto = headers.findIndex((h) => h.toLowerCase() === 'concepto');\n const idxEstado = headers.findIndex((h) => h.toLowerCase() === 'estado');\n const idxReferencia = headers.findIndex((h) => h.toLowerCase() === 'referencia');\n\n const rowsFromFile = [];\n\n for (let i = headerIndex + 1; i < lines.length; i++) {\n const values = parseCsvLine(lines[i]);\n\n const account = normalizeAccount(values[idxCuentaDestino]);\n const amount = parseMoneyGTQ(values[idxMonto]);\n\n if (!account || amount <= 0) {\n continue;\n }\n\n const row = {\n source_file: file.fileName,\n row_number: i + 1,\n account,\n reference: normalizeText(values[idxReferencia]),\n bank_name_file: normalizeText(values[idxNombreArchivo]),\n bank_account_holder: normalizeText(values[idxNombreCuentahabiente]),\n amount: roundMoney(amount),\n concept: normalizeText(values[idxConcepto]),\n status: normalizeText(values[idxEstado]),\n };\n\n rowsFromFile.push(row);\n allBankRows.push(row);\n }\n\n const fileTotal = roundMoney(rowsFromFile.reduce((sum, row) => sum + row.amount, 0));\n\n fileSummaries.push({\n file_name: file.fileName,\n ok: true,\n rows_count: rowsFromFile.length,\n total_amount: fileTotal,\n error: null,\n });\n}\n\nconst groupedByAccountMap = new Map();\n\nfor (const row of allBankRows) {\n const current = groupedByAccountMap.get(row.account) || {\n account: row.account,\n bank_name_file: row.bank_name_file,\n bank_account_holder: row.bank_account_holder,\n amount: 0,\n transactions_count: 0,\n source_files: new Set(),\n };\n\n current.amount = roundMoney(current.amount + row.amount);\n current.transactions_count += 1;\n current.source_files.add(row.source_file);\n\n groupedByAccountMap.set(row.account, current);\n}\n\nconst groupedByAccount = Array.from(groupedByAccountMap.values()).map((row) => ({\n ...row,\n source_files: Array.from(row.source_files),\n}));\n\nconst bankTotal = roundMoney(allBankRows.reduce((sum, row) => sum + row.amount, 0));\n\nreturn [\n {\n json: {\n ...json,\n stage: 'banco_parseado',\n bank: {\n files_count: bankKeys.length,\n valid_files_count: fileSummaries.filter((file) => file.ok).length,\n rows_count: allBankRows.length,\n grouped_accounts_count: groupedByAccount.length,\n total_amount: bankTotal,\n file_summaries: fileSummaries,\n rows: allBankRows,\n grouped_by_account: groupedByAccount,\n },\n },\n binary,\n },\n];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
1888,
|
|
-784
|
|
],
|
|
"id": "f6064046-b119-42bc-9dfb-a45e6b0f2e23",
|
|
"name": "Parsear CSV banco GT"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"mode": "combine",
|
|
"combineBy": "combineByPosition",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.merge",
|
|
"typeVersion": 3.2,
|
|
"position": [
|
|
3056,
|
|
144
|
|
],
|
|
"id": "d213d666-b0ee-41da-959e-321f853c2738",
|
|
"name": "Merge"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const data = $input.first().json || {};\n\nfunction roundMoney(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n}\n\nfunction moneyDiff(a, b) {\n return roundMoney((Number(a) || 0) - (Number(b) || 0));\n}\n\nconst payrollAccounts = data.payroll?.grouped_by_account || [];\nconst bankAccounts = data.bank?.grouped_by_account || [];\nconst payrollNoAccountRows = data.payroll?.no_account_rows || [];\n\nconst bankMap = new Map();\nfor (const bank of bankAccounts) {\n bankMap.set(bank.account, bank);\n}\n\nconst payrollMap = new Map();\nfor (const payroll of payrollAccounts) {\n payrollMap.set(payroll.account, payroll);\n}\n\nconst rows = [];\nlet coincidencias = 0;\nlet discrepancias = 0;\nlet bancoSinNomina = 0;\nlet nominaSinCuenta = 0;\n\nfor (const payroll of payrollAccounts) {\n const bank = bankMap.get(payroll.account);\n\n const payrollAmount = roundMoney(payroll.payroll_amount);\n const bankAmount = roundMoney(bank?.amount || 0);\n const diff = moneyDiff(payrollAmount, bankAmount);\n\n if (!bank) {\n discrepancias += 1;\n\n rows.push({\n id: `payroll_without_bank_${payroll.account}`,\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 payrollAmount,\n payroll_amount: payrollAmount,\n bankAmount: 0,\n bank_amount: 0,\n difference: diff,\n status: 'Riesgo',\n category: 'discrepancia',\n observation: 'Está en nómina, pero no aparece pagado en banco.',\n });\n\n continue;\n }\n\n if (Math.abs(diff) <= 0.01) {\n coincidencias += 1;\n\n rows.push({\n id: `match_${payroll.account}`,\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 payrollAmount,\n payroll_amount: payrollAmount,\n bankAmount,\n bank_amount: bankAmount,\n difference: 0,\n status: 'Coincidencia',\n category: 'coincidencia',\n observation: 'Conciliado correctamente.',\n });\n } else {\n discrepancias += 1;\n\n rows.push({\n id: `difference_${payroll.account}`,\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 payrollAmount,\n payroll_amount: payrollAmount,\n bankAmount,\n bank_amount: bankAmount,\n difference: diff,\n status: 'Riesgo',\n category: 'discrepancia',\n observation: `Diferencia de GTQ ${Math.abs(diff).toLocaleString('en-US', {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n })}.`,\n });\n }\n}\n\nfor (const bank of bankAccounts) {\n if (payrollMap.has(bank.account)) {\n continue;\n }\n\n bancoSinNomina += 1;\n discrepancias += 1;\n\n rows.push({\n id: `bank_without_payroll_${bank.account}`,\n employee: bank.bank_account_holder || bank.bank_name_file || 'Empleado banco sin nómina',\n employee_name: bank.bank_account_holder || bank.bank_name_file || 'Empleado banco sin nómina',\n employeeNumber: '',\n employee_number: '',\n account: bank.account,\n payrollAmount: 0,\n payroll_amount: 0,\n bankAmount: roundMoney(bank.amount),\n bank_amount: roundMoney(bank.amount),\n difference: roundMoney(0 - bank.amount),\n status: 'Pendiente revisión',\n category: 'banco_sin_nomina',\n observation: 'Recibió pago en banco, pero no aparece en la nómina cargada.',\n });\n}\n\nfor (const row of payrollNoAccountRows) {\n nominaSinCuenta += 1;\n discrepancias += 1;\n\n rows.push({\n id: `payroll_without_account_${row.source_sheet}_${row.row_number}`,\n employee: row.employee_name,\n employee_name: row.employee_name,\n employeeNumber: '',\n employee_number: '',\n account: '',\n payrollAmount: roundMoney(row.payroll_amount),\n payroll_amount: roundMoney(row.payroll_amount),\n bankAmount: 0,\n bank_amount: 0,\n difference: roundMoney(row.payroll_amount),\n status: 'Pendiente revisión',\n category: 'nomina_sin_cuenta',\n observation: 'Tiene monto en nómina, pero no tiene cuenta bancaria para cruzar contra banco.',\n });\n}\n\nconst totalNomina = roundMoney(data.payroll?.total_amount || 0);\nconst totalBanco = roundMoney(data.bank?.total_amount || 0);\n\nreturn [\n {\n json: {\n ok: true,\n stage: 'cruce_nomina_completa_banco',\n errors: [],\n metadata: data.metadata || {},\n summary: {\n coincidencias,\n discrepancias,\n bancoSinBamboo: 0,\n bancoSinNomina,\n nominaSinCuenta,\n filasNominaValidas: data.payroll?.valid_rows_count || 0,\n filasNominaSinCuenta: data.payroll?.no_account_rows_count || 0,\n cuentasNominaAgrupadas: data.payroll?.grouped_accounts_count || 0,\n transaccionesBanco: data.bank?.rows_count || 0,\n cuentasBancoAgrupadas: data.bank?.grouped_accounts_count || 0,\n totalNomina,\n totalBanco,\n diferenciaTotal: moneyDiff(totalNomina, totalBanco),\n },\n rows,\n reportUrl: null,\n debug: {\n sheet_summaries: data.payroll?.sheet_summaries || [],\n payroll_preview: payrollAccounts.slice(0, 5),\n bank_preview: bankAccounts.slice(0, 5),\n payroll_no_account_preview: payrollNoAccountRows.slice(0, 5),\n },\n },\n },\n];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
3264,
|
|
144
|
|
],
|
|
"id": "599ab057-016b-4157-bf14-48e290a16b97",
|
|
"name": "Cruzar Nómina vs Banco"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "xlsx",
|
|
"binaryPropertyName": "payroll_file",
|
|
"options": {
|
|
"headerRow": true,
|
|
"sheetName": "1) Nomina General"
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
1888,
|
|
-592
|
|
],
|
|
"id": "05afef3d-7661-4ab3-bdd3-330c57af017b",
|
|
"name": "Extract - Nomina General",
|
|
"retryOnFail": false
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "xlsx",
|
|
"binaryPropertyName": "payroll_file",
|
|
"options": {
|
|
"headerRow": true,
|
|
"sheetName": "2) Temporales"
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
1888,
|
|
-352
|
|
],
|
|
"id": "cb1c3b79-11c4-40a2-b82f-112b5b41709e",
|
|
"name": "Extract - Temporales",
|
|
"retryOnFail": false,
|
|
"onError": "continueRegularOutput"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "xlsx",
|
|
"binaryPropertyName": "payroll_file",
|
|
"options": {
|
|
"headerRow": true,
|
|
"sheetName": "3) Auditorias"
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
1888,
|
|
-112
|
|
],
|
|
"id": "4f454135-fb57-44b5-a35a-1afcfd905f09",
|
|
"name": "Extract - Auditorias",
|
|
"retryOnFail": false,
|
|
"onError": "continueRegularOutput"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "xlsx",
|
|
"binaryPropertyName": "payroll_file",
|
|
"options": {
|
|
"headerRow": true,
|
|
"sheetName": "4) Bono Mariana"
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
1904,
|
|
80
|
|
],
|
|
"id": "21823b43-7051-41cb-9a82-43ef2a62dfe8",
|
|
"name": "Extract - Bono Mariana",
|
|
"retryOnFail": false,
|
|
"onError": "continueRegularOutput"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "xlsx",
|
|
"binaryPropertyName": "payroll_file",
|
|
"options": {
|
|
"headerRow": true,
|
|
"sheetName": "5) Movilidad WP"
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
1904,
|
|
288
|
|
],
|
|
"id": "2323dcc2-9485-4aa7-90e8-6aec8db46558",
|
|
"name": "Extract - Movilidad WP",
|
|
"retryOnFail": false,
|
|
"onError": "continueRegularOutput"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "xlsx",
|
|
"binaryPropertyName": "payroll_file",
|
|
"options": {
|
|
"headerRow": true,
|
|
"sheetName": "6)Viaticos PMI"
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
1904,
|
|
496
|
|
],
|
|
"id": "71e98515-9b65-4d50-a959-72d9212261f0",
|
|
"name": "Extract - Viaticos PMI",
|
|
"retryOnFail": false,
|
|
"onError": "continueRegularOutput"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "xlsx",
|
|
"binaryPropertyName": "payroll_file",
|
|
"options": {
|
|
"headerRow": true,
|
|
"sheetName": "7) Combustible Purina"
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
1904,
|
|
704
|
|
],
|
|
"id": "2c1e1cfd-d4af-453d-a1ec-419d0dea32f6",
|
|
"name": "Extract - Combustible Purina",
|
|
"retryOnFail": false,
|
|
"onError": "continueRegularOutput"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "xlsx",
|
|
"binaryPropertyName": "payroll_file",
|
|
"options": {
|
|
"headerRow": true,
|
|
"sheetName": "8) Combustible P&G"
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
1904,
|
|
944
|
|
],
|
|
"id": "36115822-5519-41fe-93ed-69777568fe8f",
|
|
"name": "Extract - Combustible PG",
|
|
"retryOnFail": false,
|
|
"onError": "continueRegularOutput"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "xlsx",
|
|
"binaryPropertyName": "payroll_file",
|
|
"options": {
|
|
"headerRow": true,
|
|
"sheetName": "9) Combustibles Liquidables"
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
1904,
|
|
1168
|
|
],
|
|
"id": "84134be5-8d9d-4495-9cb6-f4de32a6f3a0",
|
|
"name": "Extract - Combustibles Liquidables",
|
|
"retryOnFail": false,
|
|
"onError": "continueRegularOutput"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "function normalizeText(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeAccount(value) {\n return String(value ?? '')\n .replace(/\\D/g, '')\n .trim();\n}\n\nfunction parseMoney(value) {\n if (typeof value === 'number') {\n return Number.isFinite(value) ? value : 0;\n }\n\n const cleaned = String(value ?? '')\n .replace(/GTQ/gi, '')\n .replace(/QTZ/gi, '')\n .replace(/Q/gi, '')\n .replace(/,/g, '')\n .replace(/\\s+/g, '')\n .trim();\n\n const parsed = Number.parseFloat(cleaned);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction roundMoney(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n}\n\nfunction getValue(row, possibleKeys) {\n for (const key of possibleKeys) {\n if (row[key] !== undefined && row[key] !== null && row[key] !== '') {\n return row[key];\n }\n }\n\n const rowKeys = Object.keys(row || {});\n for (const wanted of possibleKeys) {\n const found = rowKeys.find((key) =>\n normalizeText(key).toLowerCase() === normalizeText(wanted).toLowerCase()\n );\n\n if (found && row[found] !== undefined && row[found] !== null && row[found] !== '') {\n return row[found];\n }\n }\n\n return '';\n}\n\nfunction isTotalOrInvalidRow(name) {\n const value = normalizeText(name).toLowerCase();\n\n if (!value) return true;\n\n return (\n value === 'total' ||\n value.includes('total general') ||\n value.includes('gran total') ||\n value.includes('subtotal') ||\n value.includes('nombre completo') ||\n value.includes('nombre')\n );\n}\n\nfunction getNodeRows(nodeName) {\n try {\n return $items(nodeName).map((item) => item.json || {});\n } catch (error) {\n return [];\n }\n}\n\nconst sheetConfigs = [\n {\n nodeName: 'Extract - Nomina General',\n sourceSheet: '1) Nomina General',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE COMPLETO', 'Nombre Completo', 'NOMBRE', 'Nombre'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['NETO A PAGAR', 'Neto a Pagar', 'NETO', 'MONTO', 'Monto', 'VALOR', 'Valor'],\n },\n {\n nodeName: 'Extract - Temporales',\n sourceSheet: '2) Temporales',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE', 'Nombre', 'NOMBRE COMPLETO', 'Nombre Completo'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['NETO A PAGAR', 'Neto a Pagar', 'NETO', 'MONTO', 'Monto', 'VALOR', 'Valor'],\n },\n {\n nodeName: 'Extract - Auditorias',\n sourceSheet: '3) Auditorias',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE COMPLETO', 'Nombre Completo', 'NOMBRE', 'Nombre'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['NETO A PAGAR', 'Neto a Pagar', 'NETO', 'MONTO', 'Monto', 'VALOR', 'Valor'],\n },\n {\n nodeName: 'Extract - Bono Mariana',\n sourceSheet: '4) Bono Mariana',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE COMPLETO', 'Nombre Completo', 'NOMBRE', 'Nombre'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['NETO A PAGAR', 'Neto a Pagar', 'NETO', 'MONTO', 'Monto', 'VALOR', 'Valor'],\n },\n {\n nodeName: 'Extract - Movilidad WP',\n sourceSheet: '5) Movilidad WP',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE', 'Nombre', 'NOMBRE COMPLETO', 'Nombre Completo'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['NETO A PAGAR', 'Neto a Pagar', 'NETO', 'MONTO', 'Monto', 'VALOR', 'Valor'],\n },\n {\n nodeName: 'Extract - Viaticos PMI',\n sourceSheet: '6)Viaticos PMI',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['Nombre', 'NOMBRE', 'NOMBRE COMPLETO', 'Nombre Completo'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['Valor', 'VALOR', 'MONTO', 'Monto', 'NETO A PAGAR', 'Neto a Pagar'],\n },\n {\n nodeName: 'Extract - Combustible Purina',\n sourceSheet: '7) Combustible Purina',\n accountKeys: ['CUENTA', 'Cuenta', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE', 'Nombre', 'NOMBRE COMPLETO', 'Nombre Completo'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['MONTO', 'Monto', 'VALOR', 'Valor', 'TOTAL', 'Total'],\n },\n {\n nodeName: 'Extract - Combustible PG',\n sourceSheet: '8) Combustible P&G',\n accountKeys: ['CUENTA BANCARIA', 'Cuenta Bancaria', 'CUENTA', 'Cuenta'],\n nameKeys: ['NOMBRE', 'Nombre', 'NOMBRE COMPLETO', 'Nombre Completo'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['TOTAL', 'Total', 'MONTO', 'Monto', 'VALOR', 'Valor'],\n },\n {\n nodeName: 'Extract - Combustibles Liquidables',\n sourceSheet: '9) Combustibles Liquidables',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE', 'Nombre', 'NOMBRE COMPLETO', 'Nombre Completo'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['Valor a depositar', 'VALOR A DEPOSITAR', 'Valor', 'VALOR', 'MONTO', 'Monto'],\n },\n];\n\nconst payrollRows = [];\nconst noAccountRows = [];\nconst sheetSummaries = [];\n\nfor (const config of sheetConfigs) {\n const rows = getNodeRows(config.nodeName);\n let validRows = 0;\n let noAccountCount = 0;\n let sheetTotal = 0;\n\n rows.forEach((row, index) => {\n const employeeName = normalizeText(getValue(row, config.nameKeys));\n const account = normalizeAccount(getValue(row, config.accountKeys));\n const email = normalizeText(getValue(row, config.emailKeys)).toLowerCase();\n const amount = roundMoney(parseMoney(getValue(row, config.amountKeys)));\n\n if (isTotalOrInvalidRow(employeeName) || amount <= 0) {\n return;\n }\n\n const normalizedRow = {\n source_sheet: config.sourceSheet,\n row_number: index + 2,\n employee_name: employeeName,\n employee_number: null,\n account,\n email,\n payroll_amount: amount,\n };\n\n sheetTotal = roundMoney(sheetTotal + amount);\n\n if (!account) {\n noAccountCount += 1;\n noAccountRows.push({\n ...normalizedRow,\n status: 'Pendiente revisión',\n observation: 'Tiene monto en nómina, pero no tiene cuenta bancaria para cruzar contra banco.',\n });\n return;\n }\n\n validRows += 1;\n payrollRows.push(normalizedRow);\n });\n\n sheetSummaries.push({\n source_sheet: config.sourceSheet,\n raw_rows_count: rows.length,\n valid_rows_count: validRows,\n no_account_rows_count: noAccountCount,\n total_amount: sheetTotal,\n });\n}\n\nconst groupedMap = new Map();\n\nfor (const row of payrollRows) {\n const current = groupedMap.get(row.account) || {\n account: row.account,\n employee_name: row.employee_name,\n employee_number: null,\n email: row.email,\n payroll_amount: 0,\n rows_count: 0,\n source_sheets: new Set(),\n source_rows: [],\n };\n\n current.payroll_amount = roundMoney(current.payroll_amount + row.payroll_amount);\n current.rows_count += 1;\n\n if (!current.email && row.email) {\n current.email = row.email;\n }\n\n current.source_sheets.add(row.source_sheet);\n current.source_rows.push({\n source_sheet: row.source_sheet,\n row_number: row.row_number,\n amount: row.payroll_amount,\n });\n\n groupedMap.set(row.account, current);\n}\n\nconst groupedByAccount = Array.from(groupedMap.values()).map((row) => ({\n ...row,\n source_sheets: Array.from(row.source_sheets),\n}));\n\nconst totalPayroll = roundMoney(\n payrollRows.reduce((sum, row) => sum + row.payroll_amount, 0) +\n noAccountRows.reduce((sum, row) => sum + row.payroll_amount, 0)\n);\n\nreturn [\n {\n json: {\n payroll: {\n source: 'template_guatemala_completo',\n sheets_count: sheetConfigs.length,\n sheet_summaries: sheetSummaries,\n raw_rows_count: sheetSummaries.reduce((sum, sheet) => sum + sheet.raw_rows_count, 0),\n valid_rows_count: payrollRows.length,\n no_account_rows_count: noAccountRows.length,\n grouped_accounts_count: groupedByAccount.length,\n total_amount: totalPayroll,\n rows: payrollRows,\n no_account_rows: noAccountRows,\n grouped_by_account: groupedByAccount,\n },\n },\n },\n];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
2640,
|
|
160
|
|
],
|
|
"id": "8ddc0e47-335a-4e60-b15b-d3d4f9a66c4f",
|
|
"name": "Normalizar Nómina Completa"
|
|
}
|
|
],
|
|
"connections": {
|
|
"Webhook": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Preparar entrada app",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Preparar entrada app": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Parsear CSV banco GT",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Extract - Nomina General",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Extract - Temporales",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Extract - Auditorias",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Extract - Bono Mariana",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Extract - Movilidad WP",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Extract - Viaticos PMI",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Extract - Combustible Purina",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Extract - Combustible PG",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Extract - Combustibles Liquidables",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Respond to Webhook": {
|
|
"main": [
|
|
[]
|
|
]
|
|
},
|
|
"Parsear CSV banco GT": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Merge",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Merge": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Cruzar Nómina vs Banco",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Cruzar Nómina vs Banco": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Respond to Webhook",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Extract - Nomina General": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Normalizar Nómina Completa",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Extract - Temporales": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Normalizar Nómina Completa",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Extract - Auditorias": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Normalizar Nómina Completa",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Extract - Bono Mariana": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Normalizar Nómina Completa",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Extract - Movilidad WP": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Normalizar Nómina Completa",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Extract - Viaticos PMI": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Normalizar Nómina Completa",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Extract - Combustible Purina": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Normalizar Nómina Completa",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Extract - Combustible PG": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Normalizar Nómina Completa",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Extract - Combustibles Liquidables": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Normalizar Nómina Completa",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Normalizar Nómina Completa": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Merge",
|
|
"type": "main",
|
|
"index": 1
|
|
}
|
|
]
|
|
]
|
|
}
|
|
},
|
|
"authors": "Isaac Aracena",
|
|
"name": "Version f170c27e",
|
|
"description": "",
|
|
"autosaved": true,
|
|
"workflowPublishHistory": [
|
|
{
|
|
"createdAt": "2026-07-04T16:05:08.215Z",
|
|
"id": 2452,
|
|
"workflowId": "TNoTZLisReBssKjy",
|
|
"versionId": "f170c27e-eb23-4ba0-b34f-af24743d3952",
|
|
"event": "activated",
|
|
"userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029"
|
|
}
|
|
]
|
|
}
|
|
} |