diff --git a/cruce-de-cuentas-glm.json b/cruce-de-cuentas-glm.json new file mode 100644 index 0000000..a16dbd2 --- /dev/null +++ b/cruce-de-cuentas-glm.json @@ -0,0 +1,2382 @@ +{ + "updatedAt": "2026-05-12T19:01:56.419Z", + "createdAt": "2026-05-05T20:13:14.293Z", + "id": "8jCi4gPc5kfHZvk2", + "name": "Cruce de Cuentas - GLM", + "description": null, + "active": true, + "isArchived": false, + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "74a99fa2-c587-44ad-8762-bbda81891db0", + "responseMode": "responseNode", + "options": {} + }, + "id": "e4895765-699e-4087-8095-ebe4ae2aca5f", + "name": "Webhook - Recibir Archivos Cruce", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + -528, + -192 + ], + "webhookId": "74a99fa2-c587-44ad-8762-bbda81891db0" + }, + { + "parameters": { + "jsCode": "const item = $input.first();\nconst binaryKeys = Object.keys(item.binary || {});\nconst binaryInfo = binaryKeys.map(key => {\n const file = item.binary[key];\n return {\n key,\n fileName: file.fileName,\n mimeType: file.mimeType,\n fileExtension: file.fileExtension,\n fileSize: file.fileSize\n };\n});\n\nreturn [{\n json: {\n ...item.json,\n binaryDiagnosticsWebhook: {\n success: true,\n stage: \"binary_diagnostico_webhook\",\n binaryKeys,\n binaryInfo,\n jsonKeys: Object.keys(item.json || {})\n }\n },\n binary: item.binary\n}];" + }, + "id": "6036307c-557e-4e82-9539-265332fa8dde", + "name": "Code - Diagnosticar Binaries Webhook", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -320, + -192 + ] + }, + { + "parameters": { + "jsCode": "const item = $input.first();\nconst body = item.json?.body || {};\nconst binary = item.binary || {};\n\nconst errors = [];\n\nconst camposRequeridos = ['pais', 'mes', 'anio', 'tipoCruce'];\nfor (const campo of camposRequeridos) {\n const valor = body[campo];\n if (!valor || String(valor).trim() === '') {\n errors.push(`Campo requerido faltante: ${campo}`);\n }\n}\n\nconst tipoCruce = String(body.tipoCruce || '').trim();\nconst quincena = String(body.quincena || '').trim();\nif (tipoCruce.toLowerCase() === 'quincenal') {\n if (!quincena || (quincena !== '15' && quincena !== '30')) {\n errors.push('Campo requerido faltante o inválido: quincena');\n }\n}\n\nconst binaryKeys = Object.keys(binary);\nfunction findFile(fieldName) {\n for (const key of binaryKeys) {\n const file = binary[key];\n const keyLower = key.toLowerCase();\n const fieldLower = fieldName.toLowerCase();\n if (keyLower === fieldLower || keyLower.startsWith(fieldLower)) return { key, file };\n const fileName = String(file?.fileName || '').toLowerCase();\n if (fileName && keyLower.includes(fieldLower)) return { key, file };\n }\n return null;\n}\nfunction findFiles(fieldPrefix) {\n const results = [];\n const prefixLower = fieldPrefix.toLowerCase();\n for (const key of binaryKeys) {\n const keyLower = key.toLowerCase();\n if (keyLower === prefixLower || keyLower.startsWith(prefixLower)) {\n results.push({ key, file: binary[key] });\n }\n }\n return results;\n}\nfunction validateExtension(file, allowedExtensions, label) {\n const fileName = String(file?.fileName || '').toLowerCase();\n const ext = fileName.split('.').pop();\n if (!allowedExtensions.includes(ext)) {\n errors.push(`Archivo ${label} tiene extensión .${ext}, se esperaba: ${allowedExtensions.join(', ')}`);\n return false;\n }\n return true;\n}\n\nconst bambooActivo = findFile('bambooActivo');\nif (!bambooActivo) errors.push('Archivo requerido faltante: bambooActivo');\nelse validateExtension(bambooActivo.file, ['xlsx', 'xls'], 'bambooActivo');\n\nconst bambooAT = findFile('bambooAdditionsTerminations');\nif (!bambooAT) errors.push('Archivo requerido faltante: bambooAdditionsTerminations');\nelse validateExtension(bambooAT.file, ['xlsx', 'xls'], 'bambooAdditionsTerminations');\n\nconst nominaExcel = findFile('nominaExcel');\nif (!nominaExcel) errors.push('Archivo requerido faltante: nominaExcel');\nelse validateExtension(nominaExcel.file, ['xlsx', 'xls'], 'nominaExcel');\n\nlet bancoFiles = findFiles('bancoFiles');\nif (bancoFiles.length === 0) bancoFiles = findFiles('banco');\nif (bancoFiles.length === 0) errors.push('Archivo requerido faltante: bancoFiles');\nfor (const bf of bancoFiles) validateExtension(bf.file, ['csv'], `bancoFiles`);\n\nif (errors.length > 0) return [{\n json: { \n success: false, \n stage: 'validacion', \n errors,\n binaryDiagnosticsWebhook: item.json.binaryDiagnosticsWebhook\n }, \n binary: item.binary \n}];\n\nlet cruceSuffix = tipoCruce;\nif (tipoCruce.toLowerCase() === 'quincenal' && quincena) cruceSuffix += ' ' + quincena;\n\nreturn [{\n json: {\n success: true,\n stage: 'validacion',\n message: 'Archivos recibidos correctamente',\n received: {\n pais: String(body.pais || '').trim(),\n mes: String(body.mes || '').trim(),\n anio: String(body.anio || '').trim(),\n tipoCruce: String(body.tipoCruce || '').trim(),\n quincena: quincena || 'N/A',\n cruceSuffix: cruceSuffix,\n userEmail: String(body.userEmail || '').trim(),\n bambooActivo: bambooActivo.file?.fileName,\n bambooAdditionsTerminations: bambooAT.file?.fileName,\n nominaExcel: nominaExcel.file?.fileName,\n bancoFilesCount: bancoFiles.length,\n bancoFiles: bancoFiles.map(bf => bf.file?.fileName)\n },\n binaryDiagnosticsWebhook: item.json.binaryDiagnosticsWebhook\n },\n binary: item.binary\n}];" + }, + "id": "69fd5251-e3fe-47fc-9059-2d9693de003e", + "name": "Code - Validar Entrada", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -128, + -192 + ] + }, + { + "parameters": { + "jsCode": "const item = $input.first();\nconst binaryKeys = Object.keys(item.binary || {});\nconst binaryInfo = binaryKeys.map(key => {\n const file = item.binary[key];\n return {\n key,\n fileName: file.fileName,\n mimeType: file.mimeType,\n fileExtension: file.fileExtension,\n fileSize: file.fileSize\n };\n});\n\nreturn [{\n json: {\n ...item.json,\n binaryDiagnosticsPostValidacion: {\n success: true,\n stage: \"binary_diagnostico_post_validacion\",\n binaryKeys,\n binaryInfo,\n jsonKeys: Object.keys(item.json || {})\n }\n },\n binary: item.binary\n}];" + }, + "id": "f3361997-a254-4e79-bc44-f48bca340758", + "name": "Code - Diagnosticar Binaries Post Validacion", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 80, + -192 + ] + }, + { + "parameters": { + "conditions": { + "combinator": "and", + "conditions": [ + { + "id": "check-success", + "leftValue": "={{$json.success}}", + "operator": { + "operation": "equals", + "type": "boolean" + }, + "rightValue": true + } + ], + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 2 + } + }, + "options": {} + }, + "id": "5ff190a6-06fc-4e32-bd72-88b591b6791e", + "name": "IF - Validación OK", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [ + 272, + -192 + ] + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": { + "responseCode": 400 + } + }, + "id": "d62412d5-793a-4ee3-b6bd-d4847ac69568", + "name": "Respond - Error Validación", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 272, + 0 + ] + }, + { + "parameters": { + "jsCode": "const item = $input.first();\nconst binaryKeys = Object.keys(item.binary || {});\n\nif (binaryKeys.length === 0) {\n return [{\n json: {\n success: false,\n stage: \"binary_missing\",\n error: \"El nodo de normalización no recibió archivos binarios\",\n availableJsonKeys: Object.keys(item.json || {}),\n binaryKeys: []\n },\n binary: item.binary\n }];\n}\n\nconst bancoRows = [];\nconst logs = [];\nlet bancoFilesDetectados = 0;\nlet bancoFilesProcesados = 0;\nlet bancoFilesConError = 0;\n\nfunction decodeBuffer(buffer) {\n const utf8 = buffer.toString('utf8');\n\n // Caracter de reemplazo que aparece cuando el encoding no fue leído bien.\n const badChars = (utf8.match(/\\uFFFD/g) || []).length;\n\n if (badChars > 0) {\n if (typeof TextDecoder !== 'undefined') {\n try {\n return {\n text: new TextDecoder('windows-1252').decode(buffer),\n encoding: 'windows-1252'\n };\n } catch (e) {\n return {\n text: buffer.toString('latin1'),\n encoding: 'latin1'\n };\n }\n }\n\n return {\n text: buffer.toString('latin1'),\n encoding: 'latin1'\n };\n }\n\n return {\n text: utf8.replace(/^\\uFEFF/, ''),\n encoding: 'utf-8'\n };\n}\n\nfunction parseCsvLine(line, delimiter = ',') {\n const cells = [];\n let cell = '';\n let inQuotes = false;\n\n for (let i = 0; i < line.length; i++) {\n const char = line[i];\n const next = line[i + 1];\n\n if (char === '\"') {\n if (inQuotes && next === '\"') {\n cell += '\"';\n i++;\n } else {\n inQuotes = !inQuotes;\n }\n continue;\n }\n\n if (char === delimiter && !inQuotes) {\n cells.push(cell.trim());\n cell = '';\n continue;\n }\n\n cell += char;\n }\n\n cells.push(cell.trim());\n\n return cells.map(c => String(c || '').replace(/^\"|\"$/g, '').trim());\n}\n\nfunction normalizeLoose(value) {\n return String(value || '')\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/\\uFFFD/g, '')\n .toUpperCase()\n .replace(/[^A-Z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction isBancoFile(key, file) {\n const keyLower = String(key || '').toLowerCase();\n const fileName = String(file?.fileName || '').toLowerCase();\n const mimeType = String(file?.mimeType || '').toLowerCase();\n\n return (\n keyLower.startsWith('bancofiles') ||\n keyLower.includes('banco') ||\n fileName.endsWith('.csv') ||\n mimeType.includes('csv') ||\n mimeType.includes('text')\n );\n}\n\nfor (const key of binaryKeys) {\n const file = item.binary[key];\n\n if (!isBancoFile(key, file)) {\n continue;\n }\n\n bancoFilesDetectados++;\n\n try {\n const buffer = await this.helpers.getBinaryDataBuffer(0, key);\n const decoded = decodeBuffer(buffer);\n\n const lines = decoded.text\n .replace(/\\r\\n/g, '\\n')\n .replace(/\\r/g, '\\n')\n .split('\\n')\n .map(line => line.trim())\n .filter(line => line !== '');\n\n const delimiter = ',';\n const rows = lines.map(line => parseCsvLine(line, delimiter));\n\n const transactionMarkerIndex = rows.findIndex(row => {\n const text = normalizeLoose(row.join(' '));\n return text.includes('TRANSACCIONES') && text.includes('ENV');\n });\n\n const headerIndex = rows.findIndex((row, idx) => {\n if (transactionMarkerIndex !== -1 && idx <= transactionMarkerIndex) {\n return false;\n }\n\n const text = normalizeLoose(row.join(' '));\n\n return (\n text.includes('REFERENCIA') &&\n text.includes('CUENTA DESTINO') &&\n text.includes('NOMBRE EN ARCHIVO') &&\n text.includes('NOMBRE DEL CUENTAHABIENTE') &&\n text.includes('MONTO')\n );\n });\n\n let filasExtraidas = 0;\n let filasExcluidasProveedor = 0;\n let filasIgnoradas = 0;\n let warningsMonto = 0;\n\n if (headerIndex !== -1) {\n for (let j = headerIndex + 1; j < rows.length; j++) {\n const row = rows[j];\n\n if (!row || row.length < 7) {\n filasIgnoradas++;\n continue;\n }\n\n const cuenta = String(row[0] || '').trim();\n const numeroPlan = String(row[1] || '').trim();\n const referencia = String(row[2] || '').trim();\n const cuentaDestino = String(row[3] || '').trim();\n const nombreArchivo = String(row[4] || '').trim();\n const nombreCuentahabiente = String(row[5] || '').trim();\n const monto = String(row[6] || '').trim();\n const fechaAplicacion = String(row[7] || '').trim();\n const factura = String(row[8] || '').trim();\n const concepto = String(row[9] || '').trim();\n const estado = String(row[10] || '').trim();\n\n if (!cuenta && !referencia && !cuentaDestino && !nombreArchivo && !nombreCuentahabiente && !monto) {\n filasIgnoradas++;\n continue;\n }\n\n if (!monto) {\n warningsMonto++;\n filasIgnoradas++;\n continue;\n }\n\n const conceptoNormalizado = normalizeLoose(concepto);\n\n // Regla validada: NO contar pagos a proveedores en el cruce de nómina.\n if (\n conceptoNormalizado.includes('PAGO PRO') ||\n conceptoNormalizado.includes('PAGO A PRO') ||\n conceptoNormalizado.includes('PROVEEDOR') ||\n conceptoNormalizado.includes('PROVEEDORES')\n ) {\n filasExcluidasProveedor++;\n continue;\n }\n\n bancoRows.push({\n Cuenta: cuenta,\n 'Número de plan': numeroPlan,\n Referencia: referencia,\n 'Cuenta Destino': cuentaDestino,\n 'Nombre en Archivo': nombreArchivo,\n 'Nombre del Cuentahabiente': nombreCuentahabiente,\n Monto: monto,\n 'Fecha/Hora de aplicación': fechaAplicacion,\n Factura: factura,\n Concepto: concepto,\n Estado: estado,\n ArchivoOrigen: file.fileName || key\n });\n\n filasExtraidas++;\n }\n } else {\n bancoFilesConError++;\n\n logs.push({\n tipo: 'Error',\n archivo: file.fileName || key,\n binaryKey: key,\n mensaje: 'No se encontró la fila de encabezados reales del bloque Transacciones del envío.',\n transactionMarkerIndex,\n headerIndex,\n encoding: decoded.encoding,\n firstParsedRows: rows.slice(0, 8)\n });\n\n continue;\n }\n\n bancoFilesProcesados++;\n\n logs.push({\n tipo: 'Info',\n archivo: file.fileName || key,\n binaryKey: key,\n encoding: decoded.encoding,\n delimiter,\n totalLines: lines.length,\n totalRowsParsed: rows.length,\n transactionMarkerIndex,\n headerIndex,\n filasExtraidas,\n filasExcluidasProveedor,\n filasIgnoradas,\n warningsMonto,\n headerDetected: rows[headerIndex],\n firstParsedRows: rows.slice(0, 5),\n firstBancoRows: bancoRows.slice(Math.max(0, bancoRows.length - Math.min(filasExtraidas, 3)))\n });\n\n } catch (err) {\n bancoFilesConError++;\n\n logs.push({\n tipo: 'Error',\n archivo: file.fileName || key,\n binaryKey: key,\n mensaje: 'Fallo al parsear CSV: ' + err.message\n });\n }\n}\n\nreturn [{\n json: {\n ...item.json,\n success: true,\n stage: \"banco_line_parser_fix\",\n bancoRows,\n normalizationSummary: {\n ...(item.json.normalizationSummary || {}),\n bancoFilesDetectados,\n bancoFilesProcesados,\n bancoFilesConError,\n bancoRows: bancoRows.length,\n logsCount: logs.length\n },\n samples: {\n ...(item.json.samples || {}),\n bancoRows: bancoRows.slice(0, 10),\n bancoLogs: logs\n }\n },\n binary: item.binary\n}];" + }, + "id": "01c73741-088d-4249-a6ad-a5711517d867", + "name": "Code - Normalizar Banco CSV", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 560, + -176 + ] + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": { + "responseCode": 200 + } + }, + "id": "52b5b0b1-573f-4168-a7ed-cb302ff5967c", + "name": "Respond - Éxito", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 5552, + -160 + ] + }, + { + "parameters": { + "jsCode": "// Code node: Code - Ejecutar Cruce de Cuentas\n// IMPORTANTE EN n8n:\n// - Mode: Run Once for All Items\n// - Este nodo debe recibir:\n// 1) El item normalizado con bambooActivoRows, bambooAdditionsTerminationsRows, nominaRows y bancoRows.\n// 2) Las filas de Google Sheets de la pestaña Excepciones_Moneda, entrando por un Merge en modo Append.\n//\n// Estructura esperada para Excepciones_Moneda:\n// Cuenta | Nombre | MonedaCorrecta | Comentario\n\nconst FECHA_REVISION = new Date().toISOString();\nconst inputItems = $input.all();\n\nfunction normalizeText(value) {\n return String(value ?? '')\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toUpperCase()\n .replace(/[^A-Z0-9,\\s]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeKey(value) {\n return normalizeText(value).replace(/,/g, '');\n}\n\nfunction getValue(row, possibleKeys) {\n const keys = Object.keys(row || {});\n const normalizedMap = {};\n\n for (const key of keys) {\n normalizedMap[normalizeKey(key)] = key;\n }\n\n for (const key of possibleKeys) {\n if (row && row[key] !== undefined && row[key] !== null && String(row[key]).trim() !== '') {\n return row[key];\n }\n\n const normalizedKey = normalizeKey(key);\n\n if (normalizedMap[normalizedKey] !== undefined) {\n const realKey = normalizedMap[normalizedKey];\n const value = row[realKey];\n\n if (value !== undefined && value !== null && String(value).trim() !== '') {\n return value;\n }\n }\n }\n\n return '';\n}\n\nfunction accountKey(value) {\n return String(value ?? '').replace(/\\D/g, '').trim();\n}\n\nfunction parseCurrency(value) {\n const text = normalizeText(value);\n\n if (text.includes('USD') || text.includes('DOLAR') || text.includes('DOLLAR') || text.includes('$')) return 'USD';\n if (text.includes('QTZ') || text.includes('QUETZAL')) return 'QTZ';\n\n return '';\n}\n\nfunction parseAmount(value) {\n if (value === null || value === undefined || value === '') return null;\n if (typeof value === 'number') return value;\n\n const text = String(value);\n const match = text.match(/-?\\d[\\d,]*\\.?\\d*/);\n\n if (!match) return null;\n\n const amount = Number(match[0].replace(/,/g, ''));\n\n if (Number.isNaN(amount)) return null;\n\n return amount;\n}\n\nfunction round2(value) {\n return Number(Number(value || 0).toFixed(2));\n}\n\n// ============================================================================\n// 1) ENTRADAS DESDE MERGE\n// ============================================================================\n\nconst normalizedItem =\n inputItems.find(i =>\n Array.isArray(i.json?.bambooActivoRows) ||\n Array.isArray(i.json?.bambooAdditionsTerminationsRows) ||\n Array.isArray(i.json?.nominaRows) ||\n Array.isArray(i.json?.bancoRows)\n ) || inputItems[0] || { json: {} };\n\nconst exceptionItems = inputItems.filter(i => i !== normalizedItem);\n\nconst bambooActivoRows = normalizedItem.json.bambooActivoRows || [];\nconst bambooAdditionsTerminationsRows = normalizedItem.json.bambooAdditionsTerminationsRows || [];\nconst bambooInputRows = [...bambooActivoRows, ...bambooAdditionsTerminationsRows];\n\nconst nominaInputRows = normalizedItem.json.nominaRows || [];\nconst bancoInputRows = normalizedItem.json.bancoRows || [];\n\n// Excepciones leídas desde Google Sheets.\n// Si no existen o vienen vacías, el flujo sigue funcionando con moneda detectada / QTZ.\nconst excepcionesRows = exceptionItems\n .map(item => item.json || {})\n .filter(row => {\n const cuenta = accountKey(getValue(row, ['Cuenta']));\n const moneda = parseCurrency(getValue(row, ['MonedaCorrecta', 'Moneda Correcta', 'Moneda']));\n return cuenta && moneda;\n });\n\nconst TOLERANCIA_REDONDEO_OK = 0.50;\nconst TOLERANCIA_DIFERENCIA_MENOR = 5.00;\n\nfunction normalizeText(value) {\n return String(value ?? '')\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toUpperCase()\n .replace(/[^A-Z0-9,\\s]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeKey(value) {\n return normalizeText(value).replace(/,/g, '');\n}\n\nfunction canonicalName(value) {\n const text = normalizeText(value);\n\n if (text.includes(',')) {\n const parts = text.split(',').map(p => p.trim()).filter(Boolean);\n\n if (parts.length >= 2) {\n return `${parts.slice(1).join(' ')} ${parts[0]}`.trim();\n }\n }\n\n return text;\n}\n\nfunction getValue(row, possibleKeys) {\n const keys = Object.keys(row || {});\n const normalizedMap = {};\n\n for (const key of keys) {\n normalizedMap[normalizeKey(key)] = key;\n }\n\n for (const key of possibleKeys) {\n if (row && row[key] !== undefined && row[key] !== null && String(row[key]).trim() !== '') {\n return row[key];\n }\n\n const normalizedKey = normalizeKey(key);\n\n if (normalizedMap[normalizedKey] !== undefined) {\n const realKey = normalizedMap[normalizedKey];\n const value = row[realKey];\n\n if (value !== undefined && value !== null && String(value).trim() !== '') {\n return value;\n }\n }\n }\n\n return '';\n}\n\nfunction accountKey(value) {\n return String(value ?? '').replace(/\\D/g, '').trim();\n}\n\nfunction parseAmount(value) {\n if (value === null || value === undefined || value === '') return null;\n if (typeof value === 'number') return value;\n\n const text = String(value);\n const match = text.match(/-?\\d[\\d,]*\\.?\\d*/);\n\n if (!match) return null;\n\n const amount = Number(match[0].replace(/,/g, ''));\n\n if (Number.isNaN(amount)) return null;\n\n return amount;\n}\n\nfunction parseCurrency(value) {\n const text = normalizeText(value);\n\n if (text.includes('USD') || text.includes('DOLAR') || text.includes('DOLLAR') || text.includes('$')) return 'USD';\n if (text.includes('QTZ') || text.includes('QUETZAL')) return 'QTZ';\n\n return '';\n}\n\nfunction round2(value) {\n return Number(Number(value || 0).toFixed(2));\n}\n\nconst STOPWORDS = new Set([\n 'DE',\n 'DEL',\n 'LA',\n 'LAS',\n 'LOS',\n 'Y',\n 'DA',\n 'DO',\n 'DAS',\n 'DOS',\n]);\n\nfunction nameTokens(name) {\n return canonicalName(name)\n .replace(/,/g, ' ')\n .split(' ')\n .filter(t => t && !STOPWORDS.has(t));\n}\n\nfunction limitedDistance(a, b, maxDistance) {\n a = String(a ?? '');\n b = String(b ?? '');\n\n if (Math.abs(a.length - b.length) > maxDistance) return maxDistance + 1;\n\n const previous = [];\n\n for (let j = 0; j <= b.length; j++) previous[j] = j;\n\n for (let i = 1; i <= a.length; i++) {\n const current = [i];\n let rowMin = current[0];\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 previous[j] + 1,\n current[j - 1] + 1,\n previous[j - 1] + cost\n );\n\n if (current[j] < rowMin) rowMin = current[j];\n }\n\n if (rowMin > maxDistance) return maxDistance + 1;\n\n for (let j = 0; j <= b.length; j++) previous[j] = current[j];\n }\n\n return previous[b.length];\n}\n\nfunction tokenClose(a, b) {\n if (!a || !b) return false;\n if (a === b) return true;\n\n if (a.length === 1 && b.startsWith(a)) return true;\n if (b.length === 1 && a.startsWith(b)) return true;\n\n if (a.length >= 4 && b.length >= 4) {\n if (a.startsWith(b) || b.startsWith(a)) return true;\n\n const prefixA = a.slice(0, 4);\n const prefixB = b.slice(0, 4);\n\n if (prefixA === prefixB) return true;\n }\n\n if (a.length >= 5 && b.length >= 5) {\n return limitedDistance(a, b, 1) <= 1;\n }\n\n return false;\n}\n\nconst nameScoreCache = new Map();\n\nfunction nameScore(nameA, nameB) {\n const cacheKey = `${normalizeKey(nameA)}||${normalizeKey(nameB)}`;\n\n if (nameScoreCache.has(cacheKey)) return nameScoreCache.get(cacheKey);\n\n const aTokens = nameTokens(nameA);\n const bTokens = nameTokens(nameB);\n\n if (!aTokens.length || !bTokens.length) {\n nameScoreCache.set(cacheKey, 0);\n return 0;\n }\n\n const usedB = new Set();\n let matches = 0;\n\n for (const a of aTokens) {\n let bestIndex = -1;\n\n for (let i = 0; i < bTokens.length; i++) {\n if (usedB.has(i)) continue;\n\n const b = bTokens[i];\n\n if (a === b) {\n bestIndex = i;\n break;\n }\n\n if (bestIndex < 0 && tokenClose(a, b)) {\n bestIndex = i;\n }\n }\n\n if (bestIndex >= 0) {\n matches++;\n usedB.add(bestIndex);\n }\n }\n\n const coverageSmall = matches / Math.max(1, Math.min(aTokens.length, bTokens.length));\n const coverageLarge = matches / Math.max(1, Math.max(aTokens.length, bTokens.length));\n const score = Number((0.75 * coverageSmall + 0.25 * coverageLarge).toFixed(4));\n\n nameScoreCache.set(cacheKey, score);\n\n return score;\n}\n\nfunction firstNameCompatible(sourceName, targetName) {\n const sourceTokens = nameTokens(sourceName);\n const targetTokens = nameTokens(targetName);\n\n if (!sourceTokens.length || !targetTokens.length) return false;\n\n const first = sourceTokens[0];\n\n return targetTokens.some(t => tokenClose(first, t));\n}\n\nfunction exactOverlapCount(nameA, nameB) {\n const aTokens = [...new Set(nameTokens(nameA))];\n const bTokens = [...new Set(nameTokens(nameB))];\n const bSet = new Set(bTokens);\n\n return aTokens.filter(t => bSet.has(t)).length;\n}\n\nfunction fuzzyOverlapCount(nameA, nameB) {\n const aTokens = nameTokens(nameA);\n const bTokens = nameTokens(nameB);\n const used = new Set();\n let count = 0;\n\n for (const a of aTokens) {\n let found = -1;\n\n for (let i = 0; i < bTokens.length; i++) {\n if (used.has(i)) continue;\n\n if (tokenClose(a, bTokens[i])) {\n found = i;\n break;\n }\n }\n\n if (found >= 0) {\n used.add(found);\n count++;\n }\n }\n\n return count;\n}\n\nconst strongMatchCache = new Map();\n\nfunction strongPersonMatch(nameA, nameB) {\n const cacheKey = `${normalizeKey(nameA)}||${normalizeKey(nameB)}`;\n\n if (strongMatchCache.has(cacheKey)) return strongMatchCache.get(cacheKey);\n\n const aTokens = nameTokens(nameA);\n const bTokens = nameTokens(nameB);\n\n if (!aTokens.length || !bTokens.length) {\n const result = {\n ok: false,\n score: 0,\n exactOverlap: 0,\n fuzzyOverlap: 0,\n firstOk: false,\n reason: 'sin_tokens',\n };\n\n strongMatchCache.set(cacheKey, result);\n return result;\n }\n\n const score = nameScore(nameA, nameB);\n const exactOverlap = exactOverlapCount(nameA, nameB);\n const fuzzyOverlap = fuzzyOverlapCount(nameA, nameB);\n const firstOk = firstNameCompatible(nameA, nameB);\n const aLen = aTokens.length;\n const bLen = bTokens.length;\n const minLen = Math.min(aLen, bLen);\n\n let result;\n\n if (aLen <= 2 && bLen >= 4) {\n result = {\n ok: false,\n score,\n exactOverlap,\n fuzzyOverlap,\n firstOk,\n reason: 'source_short_vs_target_very_long',\n };\n\n strongMatchCache.set(cacheKey, result);\n return result;\n }\n\n if (aLen <= 2 && bLen === 3) {\n const ok = exactOverlap >= 2 && score >= 0.82;\n\n result = {\n ok,\n score,\n exactOverlap,\n fuzzyOverlap,\n firstOk,\n reason: ok ? 'source_short_target_3_exact_overlap' : 'source_short_target_3_not_enough_exact_overlap',\n };\n\n strongMatchCache.set(cacheKey, result);\n return result;\n }\n\n if (bLen <= 2 && aLen >= 4) {\n result = {\n ok: false,\n score,\n exactOverlap,\n fuzzyOverlap,\n firstOk,\n reason: 'source_long_vs_target_short_requires_review',\n };\n\n strongMatchCache.set(cacheKey, result);\n return result;\n }\n\n if (minLen === 1) {\n result = {\n ok: score >= 1 && exactOverlap >= 1,\n score,\n exactOverlap,\n fuzzyOverlap,\n firstOk,\n reason: 'una_palabra',\n };\n\n strongMatchCache.set(cacheKey, result);\n return result;\n }\n\n if (minLen <= 2) {\n const ok = exactOverlap >= 2 || (firstOk && fuzzyOverlap >= 2 && score >= 0.88);\n\n result = {\n ok,\n score,\n exactOverlap,\n fuzzyOverlap,\n firstOk,\n reason: ok ? 'nombre_corto_con_dos_tokens_confiables' : 'nombre_corto_no_confiable',\n };\n\n strongMatchCache.set(cacheKey, result);\n return result;\n }\n\n if (exactOverlap >= 3 && score >= 0.80) {\n result = {\n ok: true,\n score,\n exactOverlap,\n fuzzyOverlap,\n firstOk,\n reason: 'tres_tokens_exactos',\n };\n\n strongMatchCache.set(cacheKey, result);\n return result;\n }\n\n if (fuzzyOverlap >= 3 && score >= 0.88) {\n result = {\n ok: true,\n score,\n exactOverlap,\n fuzzyOverlap,\n firstOk,\n reason: 'tres_tokens_fuzzy',\n };\n\n strongMatchCache.set(cacheKey, result);\n return result;\n }\n\n if (firstOk && exactOverlap >= 2 && score >= 0.82) {\n result = {\n ok: true,\n score,\n exactOverlap,\n fuzzyOverlap,\n firstOk,\n reason: 'primer_nombre_y_dos_tokens',\n };\n\n strongMatchCache.set(cacheKey, result);\n return result;\n }\n\n if (score >= 0.94 && fuzzyOverlap >= Math.min(3, minLen)) {\n result = {\n ok: true,\n score,\n exactOverlap,\n fuzzyOverlap,\n firstOk,\n reason: 'score_muy_alto',\n };\n\n strongMatchCache.set(cacheKey, result);\n return result;\n }\n\n result = {\n ok: false,\n score,\n exactOverlap,\n fuzzyOverlap,\n firstOk,\n reason: 'no_supera_regla_estricta',\n };\n\n strongMatchCache.set(cacheKey, result);\n return result;\n}\n\nfunction isRealValue(value) {\n const text = normalizeKey(value);\n\n return text && text !== '-' && text !== 'N A' && text !== 'NA' && text !== 'NULL';\n}\n\nfunction isInactiveBamboo(row) {\n const status = normalizeKey(row.status);\n const terminationDate = normalizeKey(row.terminationDate);\n\n if (\n status.includes('TERMINATED') ||\n status.includes('INACTIVE') ||\n status.includes('INACTIVO') ||\n status.includes('BAJA') ||\n status.includes('TERMINADO')\n ) {\n return true;\n }\n\n if (isRealValue(terminationDate)) return true;\n\n return false;\n}\n\nfunction isAdjustmentOrCopy(row) {\n const fuente = normalizeKey(row.fuente);\n const concepto = normalizeKey(row.concepto);\n const sourceSheet = normalizeKey(row.sourceSheet);\n\n return (\n fuente.includes('AJUSTE') ||\n fuente.includes('COPIA') ||\n concepto.includes('AJUSTE') ||\n concepto.includes('CORRECCION') ||\n sourceSheet.includes('AJUSTE') ||\n sourceSheet.includes('COPIA')\n );\n}\n\nfunction bankConceptIsAllowed(row) {\n const concepto = normalizeKey(row.concepto);\n\n if (!concepto) return true;\n\n if (\n concepto.includes('PAGO PRO') ||\n concepto.includes('PAGO A PRO') ||\n concepto.includes('PROVEEDOR') ||\n concepto.includes('PROVEEDORES')\n ) {\n return false;\n }\n\n if (concepto.includes('PLANILLA')) return true;\n\n return false;\n}\n\nfunction isOperationalText(value) {\n const text = normalizeKey(value);\n\n if (!text) return false;\n\n return (\n text.includes('COMBUSTIBLE') ||\n text.includes('TARJETA') ||\n text.includes('VIATICO') ||\n text.includes('VIATICOS') ||\n text.includes('MOVILIDAD') ||\n text.includes('MOVIL') ||\n text.includes('REEMBOLSO') ||\n text.includes('REEMBOLSOS') ||\n text.includes('ASIGNADO') ||\n text.includes('ASIGNACION') ||\n text.includes('ASIGNACIONES') ||\n text.includes('GASOLINA') ||\n text.includes('PARQUEO') ||\n text.includes('PEAJE') ||\n text.includes('KILOMETRAJE') ||\n text.includes('KM')\n );\n}\n\nfunction isOperationalNominaRow(row) {\n return (\n isOperationalText(row.fuente) ||\n isOperationalText(row.proyecto) ||\n isOperationalText(row.concepto) ||\n isOperationalText(row.sourceSheet) ||\n isOperationalText(row.sourceFile)\n );\n}\n\nfunction isOperationalGroup(group) {\n if (!group) return false;\n\n return (group.rows || []).some(row => isOperationalNominaRow(row));\n}\nconst excepcionesMonedaPorCuenta = {};\n\nfor (const row of excepcionesRows) {\n const cuenta = accountKey(getValue(row, ['Cuenta']));\n const moneda = parseCurrency(getValue(row, ['MonedaCorrecta', 'Moneda Correcta', 'Moneda']));\n\n if (cuenta && moneda && cuenta !== 'NOAPLICA') {\n excepcionesMonedaPorCuenta[cuenta] = moneda;\n }\n}\n\nconst bamboo = bambooInputRows\n .map((row, index) => {\n const name = getValue(row, ['Name', 'Nombre', 'Nombre Completo', 'Employee Name']);\n\n return {\n index,\n employeeNumber: getValue(row, ['EmployeeNumber', 'Employee Number', 'Numero Empleado', 'Código', 'Codigo']),\n name,\n canonical: canonicalName(name),\n tokens: nameTokens(name),\n status: getValue(row, ['Status', 'Estado', 'Employment Status']),\n terminationDate: getValue(row, ['TerminationDate', 'Termination Date', 'Fecha Terminacion', 'Fecha de Baja']),\n sourceSheet: getValue(row, ['SourceSheet']),\n sourceFile: getValue(row, ['SourceFile']),\n raw: row,\n };\n })\n .filter(r => normalizeKey(r.name));\n\nfunction addToIndex(index, token, value) {\n if (!token) return;\n\n if (!index[token]) index[token] = [];\n\n index[token].push(value);\n}\n\nconst bambooByToken = {};\n\nfor (let i = 0; i < bamboo.length; i++) {\n const uniqueTokens = [...new Set(bamboo[i].tokens)];\n\n for (const token of uniqueTokens) {\n addToIndex(bambooByToken, token, i);\n\n if (token.length >= 4) {\n addToIndex(bambooByToken, token.slice(0, 4), i);\n }\n }\n}\n\nfunction candidateIndexesFromName(nombre, index) {\n const tokens = [...new Set(nameTokens(nombre))];\n const candidates = new Set();\n\n for (const token of tokens) {\n const exactList = index[token] || [];\n\n for (const idx of exactList) candidates.add(idx);\n\n if (token.length >= 4) {\n const prefixList = index[token.slice(0, 4)] || [];\n\n for (const idx of prefixList) candidates.add(idx);\n }\n }\n\n return [...candidates];\n}\n\nconst bambooMatchCache = new Map();\n\nfunction bestBambooMatch(nombre) {\n const cacheKey = normalizeKey(nombre);\n\n if (bambooMatchCache.has(cacheKey)) return bambooMatchCache.get(cacheKey);\n\n const candidateIndexes = candidateIndexesFromName(nombre, bambooByToken);\n\n if (!candidateIndexes.length) {\n const empty = null;\n bambooMatchCache.set(cacheKey, empty);\n return empty;\n }\n\n let best = null;\n\n for (const idx of candidateIndexes) {\n const b = bamboo[idx];\n const strong = strongPersonMatch(nombre, b.name);\n\n const ranking =\n strong.score +\n (strong.ok ? 0.35 : 0) +\n (strong.firstOk ? 0.05 : 0) +\n strong.exactOverlap * 0.03 +\n strong.fuzzyOverlap * 0.01;\n\n if (!best || ranking > best.ranking) {\n best = {\n ranking,\n score: strong.score,\n firstOk: strong.firstOk,\n accepted: strong.ok && strong.score >= 0.60,\n strongReason: strong.reason,\n exactOverlap: strong.exactOverlap,\n fuzzyOverlap: strong.fuzzyOverlap,\n row: b,\n };\n }\n }\n\n bambooMatchCache.set(cacheKey, best);\n return best;\n}\n\nconst nomina = nominaInputRows\n .map((row, index) => {\n const cuenta = getValue(row, ['Cuenta', 'Cuenta Bancaria', 'Cuenta Banco']);\n const nombre = getValue(row, ['Nombre', 'Nombre Completo', 'NOMBRE COMPLETO', 'Empleado']);\n const monto = parseAmount(getValue(row, ['Monto (QTZ)', 'Monto', 'Neto a pagar', 'NETO A PAGAR', 'Monto Neto', 'Amount', 'Valor', 'Pago', 'Líquido', 'Liquido']));\n const fuente = getValue(row, ['Fuente', 'Source']);\n const proyecto = getValue(row, ['Proyecto', 'Project']);\n const concepto = getValue(row, ['Concepto', 'Concept', 'Descripción', 'Descripcion']);\n const monedaDetectada = parseCurrency(getValue(row, ['Moneda'])) || parseCurrency(getValue(row, ['Monto']));\n const sourceSheet = getValue(row, ['SourceSheet']);\n const sourceFile = getValue(row, ['SourceFile']);\n\n return {\n index,\n fuente,\n proyecto,\n concepto,\n sourceSheet,\n sourceFile,\n cuentaOriginal: cuenta,\n cuenta: accountKey(cuenta),\n nombre,\n monto,\n monedaDetectada,\n raw: row,\n };\n })\n .filter(r => {\n const cuentaText = normalizeKey(r.cuentaOriginal);\n const nombreText = normalizeKey(r.nombre);\n const conceptoText = normalizeKey(r.concepto);\n\n if (cuentaText === 'TOTAL') return false;\n if (nombreText === 'TOTAL') return false;\n if (nombreText.includes('REGISTROS')) return false;\n if (conceptoText === 'TOTAL') return false;\n if (!r.nombre && !r.cuenta && r.monto === null) return false;\n\n return true;\n });\n\nconst banco = bancoInputRows\n .map((row, index) => {\n const montoOriginal = getValue(row, ['Monto']);\n const cuentaDestino = getValue(row, ['Cuenta Destino', 'Cuenta', 'Referencia']);\n const concepto = getValue(row, ['Concepto']);\n\n return {\n index,\n cuenta: accountKey(cuentaDestino),\n cuentaOriginal: cuentaDestino,\n planBanco: getValue(row, ['Número de plan', 'Numero de plan', 'Plan']),\n referenciaBanco: getValue(row, ['Referencia']),\n nombreArchivo: getValue(row, ['Nombre en Archivo']),\n nombreCuentahabiente: getValue(row, ['Nombre del Cuentahabiente']),\n monto: parseAmount(montoOriginal),\n moneda: parseCurrency(montoOriginal) || parseCurrency(getValue(row, ['Moneda'])),\n fechaAplicacion: getValue(row, ['Fecha/Hora de aplicación', 'Fecha Aplicacion', 'Fecha Aplicación', 'Fecha', 'Fecha Pago']),\n factura: getValue(row, ['Factura']),\n concepto,\n estado: getValue(row, ['Estado']),\n archivoOrigen: getValue(row, ['ArchivoOrigen']),\n raw: row,\n };\n })\n .filter(r => (r.cuenta || r.nombreCuentahabiente || r.nombreArchivo || r.monto !== null) && bankConceptIsAllowed(r));\n\nfunction emptyGroup(key, cuenta, source) {\n return {\n key,\n cuenta,\n source,\n rows: [],\n excludedRows: [],\n notas: [],\n };\n}\n\nfunction addRowToGroup(group, row, note = '') {\n group.rows.push(row);\n\n if (note) group.notas.push(note);\n}\n\nfunction groupTotal(group) {\n return round2((group?.rows || []).reduce((sum, row) => sum + Number(row.monto || 0), 0));\n}\n\nfunction groupNames(group, source) {\n const rows = group?.rows || [];\n\n return rows\n .map(row => {\n if (source === 'BANCO') return row.nombreCuentahabiente || row.nombreArchivo;\n return row.nombre;\n })\n .filter(Boolean);\n}\n\nfunction bestNameFromGroup(group, source = 'NOMINA') {\n const names = groupNames(group, source);\n\n if (!names.length) return '';\n\n names.sort((a, b) => String(b).length - String(a).length);\n\n return names[0];\n}\n\nfunction uniqueJoined(values) {\n return [...new Set(values.filter(Boolean))].join(' | ');\n}\n\nfunction groupConceptos(group) {\n return uniqueJoined((group?.rows || []).map(row => row.concepto));\n}\n\nfunction groupFuentes(group) {\n return uniqueJoined((group?.rows || []).map(row => row.fuente));\n}\n\nfunction groupProyectos(group) {\n return uniqueJoined((group?.rows || []).map(row => row.proyecto));\n}\n\nfunction groupNotas(group) {\n return [...(group?.notas || [])].filter(Boolean).join(' | ');\n}\n\nfunction groupMoneda(nominaGroup, bancoGroup) {\n const cuenta = nominaGroup?.cuenta || bancoGroup?.cuenta;\n const exception = excepcionesMonedaPorCuenta[cuenta];\n\n if (exception) return exception;\n\n const bancoMoneda = (bancoGroup?.rows || []).map(row => row.moneda).find(Boolean);\n if (bancoMoneda) return bancoMoneda;\n\n const nominaMoneda = (nominaGroup?.rows || []).map(row => row.monedaDetectada).find(Boolean);\n if (nominaMoneda) return nominaMoneda;\n\n return 'QTZ';\n}\n\nfunction groupNominaWithCuenta(rows) {\n const groups = {};\n\n for (const row of rows.filter(r => r.cuenta)) {\n const key = row.cuenta;\n\n if (!groups[key]) groups[key] = emptyGroup(key, row.cuenta, 'NOMINA');\n\n addRowToGroup(groups[key], row);\n }\n\n return groups;\n}\n\nfunction groupBancoByCuenta(rows) {\n const groups = {};\n\n for (const row of rows) {\n const key = row.cuenta || `SIN_CUENTA_BANCO_${row.index}`;\n\n if (!groups[key]) groups[key] = emptyGroup(key, row.cuenta, 'BANCO');\n\n addRowToGroup(groups[key], row);\n }\n\n return groups;\n}\n\nfunction buildGroupTokenIndex(groups, source) {\n const index = {};\n\n for (const group of Object.values(groups)) {\n const name = bestNameFromGroup(group, source);\n const tokens = [...new Set(nameTokens(name))];\n\n for (const token of tokens) {\n addToIndex(index, token, group.key);\n\n if (token.length >= 4) {\n addToIndex(index, token.slice(0, 4), group.key);\n }\n }\n }\n\n return index;\n}\n\nfunction candidateGroupKeysFromName(name, index) {\n const tokens = [...new Set(nameTokens(name))];\n const candidates = new Set();\n\n for (const token of tokens) {\n const exactList = index[token] || [];\n\n for (const key of exactList) candidates.add(key);\n\n if (token.length >= 4) {\n const prefixList = index[token.slice(0, 4)] || [];\n\n for (const key of prefixList) candidates.add(key);\n }\n }\n\n return [...candidates];\n}\n\nfunction subsetSumRows(rows, target, tolerance) {\n const candidates = rows\n .filter(row => row.monto !== null && row.monto !== undefined && Number(row.monto) > 0)\n .slice(0, 15);\n\n let best = null;\n\n function search(start, selected, sum) {\n const diff = Math.abs(round2(sum - target));\n\n if (diff <= tolerance) {\n best = selected.slice();\n return true;\n }\n\n if (sum > target + tolerance) return false;\n\n for (let i = start; i < candidates.length; i++) {\n selected.push(candidates[i]);\n\n if (search(i + 1, selected, round2(sum + Number(candidates[i].monto || 0)))) {\n return true;\n }\n\n selected.pop();\n }\n\n return false;\n }\n\n search(0, [], 0);\n\n return best;\n}\n\nfunction applyAdjustmentCleanup(nGroup, bGroup) {\n if (!nGroup || !bGroup) return;\n\n const nominaTotal = groupTotal(nGroup);\n const bancoTotal = groupTotal(bGroup);\n const diff = round2(nominaTotal - bancoTotal);\n\n if (diff <= TOLERANCIA_REDONDEO_OK) return;\n\n const adjustmentRows = nGroup.rows.filter(row => isAdjustmentOrCopy(row));\n if (!adjustmentRows.length) return;\n\n const rowsToRemove = subsetSumRows(adjustmentRows, diff, TOLERANCIA_REDONDEO_OK);\n\n if (!rowsToRemove || !rowsToRemove.length) return;\n\n const removeIndexes = new Set(rowsToRemove.map(row => row.index));\n const removedTotal = round2(rowsToRemove.reduce((sum, row) => sum + Number(row.monto || 0), 0));\n\n nGroup.rows = nGroup.rows.filter(row => !removeIndexes.has(row.index));\n nGroup.excludedRows.push(...rowsToRemove);\n nGroup.notas.push(\n `Se excluyeron ${rowsToRemove.length} fila(s) de ajuste/copia porque explicaban la diferencia contra banco. Total excluido: ${removedTotal}.`\n );\n}\n\nfunction bestGroupForNominaWithoutCuenta(row, nominaGroups, bancoGroups) {\n let best = null;\n\n for (const group of Object.values(nominaGroups)) {\n const groupName = bestNameFromGroup(group, 'NOMINA');\n const strong = strongPersonMatch(row.nombre, groupName);\n\n if (!strong.ok) continue;\n\n const bancoGroup = group.cuenta ? bancoGroups[group.cuenta] : null;\n\n if (!bancoGroup) continue;\n\n const projectedNominaTotal = round2(groupTotal(group) + Number(row.monto || 0));\n const diffAfterAdding = round2(projectedNominaTotal - groupTotal(bancoGroup));\n const absDiffAfterAdding = Math.abs(diffAfterAdding);\n\n let ranking = strong.score + (strong.firstOk ? 0.25 : 0);\n\n if (absDiffAfterAdding <= TOLERANCIA_REDONDEO_OK) ranking += 3.0;\n else if (absDiffAfterAdding <= TOLERANCIA_DIFERENCIA_MENOR) ranking += 1.0;\n\n const isCandidate = absDiffAfterAdding <= TOLERANCIA_DIFERENCIA_MENOR && strong.score >= 0.70;\n\n if (!isCandidate) continue;\n\n const candidate = {\n group,\n score: strong.score,\n firstOk: strong.firstOk,\n strongReason: strong.reason,\n bancoGroup,\n projectedNominaTotal,\n diffAfterAdding,\n absDiffAfterAdding,\n ranking,\n };\n\n if (!best || candidate.ranking > best.ranking) best = candidate;\n }\n\n return best;\n}\n\nfunction bestGroupMatchByNameAndAmount(sourceGroup, sourceKind, targetGroups, targetIndex, targetKind, usedTargetKeys = new Set()) {\n const sourceName = bestNameFromGroup(sourceGroup, sourceKind);\n const candidateKeys = candidateGroupKeysFromName(sourceName, targetIndex);\n\n let best = null;\n\n for (const key of candidateKeys) {\n if (usedTargetKeys.has(key)) continue;\n\n const target = targetGroups[key];\n\n if (!target) continue;\n\n const targetName = bestNameFromGroup(target, targetKind);\n const strong = strongPersonMatch(sourceName, targetName);\n const diff = round2(groupTotal(sourceGroup) - groupTotal(target));\n const amountExact = Math.abs(diff) <= TOLERANCIA_REDONDEO_OK;\n\n if (!strong.ok && !(amountExact && strong.score >= 0.80)) continue;\n\n let ranking = strong.score;\n\n if (strong.firstOk) ranking += 0.20;\n if (amountExact) ranking += 1.50;\n if (sourceGroup.cuenta && target.cuenta && sourceGroup.cuenta === target.cuenta) ranking += 1.50;\n\n const result = {\n group: target,\n score: strong.score,\n firstOk: strong.firstOk,\n strongReason: strong.reason,\n diff,\n amountExact,\n ranking,\n };\n\n if (!best || result.ranking > best.ranking) best = result;\n }\n\n return best;\n}\n\nfunction bestIndividualBancoForNominaGroup(nGroup) {\n const nominaName = bestNameFromGroup(nGroup, 'NOMINA');\n const nominaTotal = groupTotal(nGroup);\n\n let best = null;\n\n for (const b of banco) {\n if (b.monto === null || b.monto === undefined) continue;\n\n const diff = round2(nominaTotal - Number(b.monto || 0));\n const amountExact = Math.abs(diff) <= TOLERANCIA_REDONDEO_OK;\n\n if (!amountExact) continue;\n\n const bankName = b.nombreCuentahabiente || b.nombreArchivo;\n const strong = strongPersonMatch(nominaName, bankName);\n\n if (!strong.ok && strong.score < 0.85) continue;\n\n const ranking = strong.score + (strong.ok ? 1 : 0) + (strong.firstOk ? 0.2 : 0);\n\n const candidate = {\n row: b,\n score: strong.score,\n firstOk: strong.firstOk,\n strongReason: strong.reason,\n diff,\n ranking,\n };\n\n if (!best || candidate.ranking > best.ranking) best = candidate;\n }\n\n return best;\n}\n\nfunction bambooStatusForName(name) {\n const match = bestBambooMatch(name);\n\n if (!match) {\n return {\n ok: false,\n prioridad: 'ALTO',\n categoria: 'Empleado no encontrado en Bamboo',\n match,\n score: 0,\n motivo: 'El empleado de nómina no tiene una coincidencia confiable en Bamboo.',\n evidencia: `Nombre nómina: ${name}. Mejor coincidencia Bamboo: sin coincidencia. Score Bamboo: 0.`,\n accion: 'Validar con RRHH si es empleado activo, externo, temporal no identificado o posible empleado fantasma.',\n };\n }\n\n if (!match.accepted) {\n if (\n match.score >= 0.70 &&\n (\n match.fuzzyOverlap >= 2 ||\n match.exactOverlap >= 2 ||\n match.strongReason === 'source_long_vs_target_short_requires_review' ||\n match.strongReason === 'source_short_vs_target_very_long'\n )\n ) {\n return {\n ok: false,\n prioridad: 'MEDIO',\n categoria: 'Nombre en Bamboo requiere revisión',\n match,\n score: match.score,\n motivo: 'Hay similitud parcial con Bamboo, pero no alcanza una coincidencia automática confiable. No se clasifica como no encontrado automáticamente.',\n evidencia: `Nombre nómina: ${name}. Coincidencia Bamboo: ${match.row?.name || 'sin coincidencia'}. Score Bamboo: ${match.score}. Regla: ${match.strongReason}. Tokens exactos: ${match.exactOverlap}. Tokens fuzzy: ${match.fuzzyOverlap}.`,\n accion: 'Revisar manualmente si corresponde a la misma persona antes de clasificarlo como irregularidad.',\n };\n }\n\n return {\n ok: false,\n prioridad: 'ALTO',\n categoria: 'Empleado no encontrado en Bamboo',\n match,\n score: match.score,\n motivo: 'El empleado de nómina no tiene una coincidencia confiable en Bamboo.',\n evidencia: `Nombre nómina: ${name}. Mejor coincidencia Bamboo: ${match.row?.name || 'sin coincidencia'}. Score Bamboo: ${match.score}. Regla: ${match.strongReason}. Tokens exactos: ${match.exactOverlap}. Tokens fuzzy: ${match.fuzzyOverlap}.`,\n accion: 'Validar con RRHH si es empleado activo, externo, temporal no identificado o posible empleado fantasma.',\n };\n }\n\n if (isInactiveBamboo(match.row)) {\n return {\n ok: false,\n prioridad: 'MEDIO',\n categoria: 'Empleado en Bamboo con estado inactivo o baja',\n match,\n score: match.score,\n motivo: 'El empleado aparece en Bamboo, pero su estado o fecha de baja requiere revisión.',\n evidencia: `Nombre Bamboo: ${match.row.name}. Status: ${match.row.status || 'sin status'}. Fecha baja: ${match.row.terminationDate || 'sin fecha'}.`,\n accion: 'Confirmar con RRHH si correspondía pagarle en este período.',\n };\n }\n\n if (!match.firstOk && match.score >= 0.70) {\n return {\n ok: false,\n prioridad: 'MEDIO',\n categoria: 'Nombre en Bamboo requiere revisión',\n match,\n score: match.score,\n motivo: 'Hay similitud con Bamboo, pero el primer nombre no coincide claramente. No se clasifica como no encontrado porque la coincidencia por tokens es fuerte.',\n evidencia: `Nombre nómina: ${name}. Coincidencia Bamboo: ${match.row.name}. Score Bamboo: ${match.score}. Regla: ${match.strongReason}.`,\n accion: 'Revisar manualmente si corresponde a la misma persona antes de clasificarlo como irregularidad.',\n };\n }\n\n return {\n ok: true,\n match,\n score: match.score,\n estado: 'OK_BAMBOO',\n };\n}\n\nfunction adjustIssueForOperationalConcept(row) {\n const isOperational =\n isOperationalText(row.Fuente) ||\n isOperationalText(row.Proyecto) ||\n isOperationalText(row.Concepto);\n\n if (!isOperational) return row;\n\n const updated = { ...row };\n\n if (\n updated.Categoria === 'Nómina sin pago en banco' ||\n updated.Categoria === 'Nómina sin cuenta con pago bancario encontrado a otra cuenta'\n ) {\n updated.Prioridad = 'MEDIO';\n updated.Categoria = 'Concepto operativo / asignación sin conciliación bancaria';\n updated.MontoRiesgo = 0;\n updated.Motivo = 'La fila corresponde a combustible, tarjeta, viático, movilidad, reembolso o asignación operativa. Se reporta para revisión, pero no como riesgo alto de nómina.';\n updated.AccionSugerida = 'Validar con Finanzas/Operaciones si el concepto operativo fue liquidado por otro canal, tarjeta corporativa, reembolso, caja chica o archivo de banco distinto.';\n }\n\n if (\n updated.Categoria === 'Monto total diferente entre nómina y banco' ||\n updated.Categoria === 'Diferencia menor por redondeo o ajuste mínimo'\n ) {\n updated.Prioridad = 'MEDIO';\n updated.Categoria = 'Concepto operativo / asignación con diferencia contra banco';\n updated.MontoRiesgo = 0;\n updated.Motivo = 'La diferencia corresponde a un concepto operativo o asignación. Se reporta como revisión operativa y no como irregularidad alta de nómina.';\n updated.AccionSugerida = 'Validar con Finanzas/Operaciones si corresponde a tarjeta, combustible, movilidad, viático, reembolso o ajuste operativo.';\n }\n\n return updated;\n}\n\nfunction makeIssue(row) {\n const adjusted = adjustIssueForOperationalConcept(row);\n\n return {\n Prioridad: adjusted.Prioridad ?? '',\n Categoria: adjusted.Categoria ?? '',\n EmpleadoNomina: adjusted.EmpleadoNomina ?? '',\n BeneficiarioBanco: adjusted.BeneficiarioBanco ?? '',\n CoincidenciaBamboo: adjusted.CoincidenciaBamboo ?? '',\n CuentaNomina: adjusted.CuentaNomina ?? '',\n CuentaBanco: adjusted.CuentaBanco ?? '',\n MontoNomina: adjusted.MontoNomina ?? '',\n MontoBanco: adjusted.MontoBanco ?? '',\n Moneda: adjusted.Moneda ?? '',\n Diferencia: adjusted.Diferencia ?? '',\n MontoRiesgo: adjusted.MontoRiesgo ?? 0,\n Motivo: adjusted.Motivo ?? '',\n Evidencia: adjusted.Evidencia ?? '',\n AccionSugerida: adjusted.AccionSugerida ?? '',\n Fuente: adjusted.Fuente ?? '',\n Proyecto: adjusted.Proyecto ?? '',\n Concepto: adjusted.Concepto ?? '',\n ScoreBanco: adjusted.ScoreBanco ?? '',\n ScoreBamboo: adjusted.ScoreBamboo ?? '',\n FechaRevision: FECHA_REVISION,\n };\n}\n\nconst nominaGroups = groupNominaWithCuenta(nomina);\nconst bancoGroups = groupBancoByCuenta(banco);\n\nconst nominaSinCuenta = nomina.filter(row => !row.cuenta);\n\nfor (const row of nominaSinCuenta) {\n const match = bestGroupForNominaWithoutCuenta(row, nominaGroups, bancoGroups);\n\n if (match) {\n addRowToGroup(\n match.group,\n row,\n `Se sumó fila de nómina sin cuenta por coincidencia estricta de nombre y cierre de monto contra banco. Nombre: ${row.nombre}. Monto: ${row.monto}. Fuente: ${row.fuente}. Concepto: ${row.concepto}. Score: ${match.score}. Diferencia proyectada contra banco: ${match.diffAfterAdding}. Regla: ${match.strongReason}.`\n );\n } else {\n const key = `SIN_CUENTA_NOMINA_${row.index}`;\n const group = emptyGroup(key, '', 'NOMINA');\n\n addRowToGroup(group, row, 'No se pudo asignar esta fila sin cuenta a banco ni a un empleado con cuenta usando reglas estrictas.');\n\n nominaGroups[key] = group;\n }\n}\n\nfor (const [cuenta, nGroup] of Object.entries(nominaGroups)) {\n if (!nGroup.cuenta) continue;\n\n const bGroup = bancoGroups[cuenta];\n\n if (!bGroup) continue;\n\n applyAdjustmentCleanup(nGroup, bGroup);\n}\n\nconst bancoGroupIndex = buildGroupTokenIndex(bancoGroups, 'BANCO');\nconst nominaGroupIndex = buildGroupTokenIndex(nominaGroups, 'NOMINA');\n\nconst issues = [];\nconst matchedNominaGroups = new Set();\nconst matchedBancoGroups = new Set();\nconst bambooIssueKeys = new Set();\n\nfunction addBambooIssueForGroup(nGroup) {\n if (isOperationalGroup(nGroup)) return;\n\n const name = bestNameFromGroup(nGroup, 'NOMINA');\n\n if (!name) return;\n\n const key = `${normalizeKey(name)}|${nGroup.cuenta || nGroup.key}`;\n\n if (bambooIssueKeys.has(key)) return;\n\n const evalBamboo = bambooStatusForName(name);\n\n if (evalBamboo.ok) return;\n\n bambooIssueKeys.add(key);\n\n issues.push(makeIssue({\n Prioridad: evalBamboo.prioridad,\n Categoria: evalBamboo.categoria,\n EmpleadoNomina: name,\n BeneficiarioBanco: '',\n CoincidenciaBamboo: evalBamboo.match?.row?.name || 'SIN COINCIDENCIA EN BAMBOO',\n CuentaNomina: nGroup.cuenta || 'SIN CUENTA EN NÓMINA',\n CuentaBanco: '',\n MontoNomina: groupTotal(nGroup),\n MontoBanco: '',\n Moneda: groupMoneda(nGroup, null),\n Diferencia: '',\n MontoRiesgo: evalBamboo.prioridad === 'ALTO' ? groupTotal(nGroup) : 0,\n Motivo: evalBamboo.motivo,\n Evidencia: evalBamboo.evidencia,\n AccionSugerida: evalBamboo.accion,\n Fuente: groupFuentes(nGroup),\n Proyecto: groupProyectos(nGroup),\n Concepto: groupConceptos(nGroup),\n ScoreBanco: '',\n ScoreBamboo: evalBamboo.score,\n }));\n}\n\nfor (const nGroup of Object.values(nominaGroups)) {\n addBambooIssueForGroup(nGroup);\n}\n\nfor (const [cuenta, nGroup] of Object.entries(nominaGroups)) {\n if (!nGroup.cuenta) continue;\n\n const bGroup = bancoGroups[cuenta];\n\n if (!bGroup) continue;\n\n const diff = round2(groupTotal(nGroup) - groupTotal(bGroup));\n const absDiff = Math.abs(diff);\n const moneda = groupMoneda(nGroup, bGroup);\n\n matchedNominaGroups.add(nGroup.key);\n matchedBancoGroups.add(bGroup.key);\n\n if (absDiff <= TOLERANCIA_REDONDEO_OK) {\n continue;\n }\n\n const prioridad = absDiff <= TOLERANCIA_DIFERENCIA_MENOR ? 'MEDIO' : 'ALTO';\n\n const categoria = absDiff <= TOLERANCIA_DIFERENCIA_MENOR\n ? 'Diferencia menor por redondeo o ajuste mínimo'\n : 'Monto total diferente entre nómina y banco';\n\n const bName = bestNameFromGroup(bGroup, 'BANCO');\n const nName = bestNameFromGroup(nGroup, 'NOMINA');\n const bambooEval = bambooStatusForName(nName);\n\n issues.push(makeIssue({\n Prioridad: prioridad,\n Categoria: categoria,\n EmpleadoNomina: nName,\n BeneficiarioBanco: bName,\n CoincidenciaBamboo: bambooEval.match?.row?.name || 'SIN COINCIDENCIA EN BAMBOO',\n CuentaNomina: nGroup.cuenta,\n CuentaBanco: bGroup.cuenta,\n MontoNomina: groupTotal(nGroup),\n MontoBanco: groupTotal(bGroup),\n Moneda: moneda,\n Diferencia: diff,\n MontoRiesgo: prioridad === 'ALTO' ? absDiff : 0,\n Motivo: prioridad === 'ALTO'\n ? 'La cuenta existe tanto en nómina como en banco, pero el total consolidado pagado no coincide.'\n : 'La diferencia entre nómina y banco es menor y puede corresponder a redondeo o ajuste mínimo.',\n Evidencia: `Cuenta: ${cuenta}. Total nómina consolidado: ${groupTotal(nGroup)}. Total banco consolidado: ${groupTotal(bGroup)}. Diferencia nómina - banco: ${diff}. Conceptos nómina: ${groupConceptos(nGroup)}. Filas nómina incluidas: ${nGroup.rows.length}. Filas banco: ${bGroup.rows.length}. Filas nómina excluidas por ajuste/copia: ${nGroup.excludedRows.length}. Notas: ${groupNotas(nGroup) || 'sin notas'}. Filtro banco aplicado: se excluyen pagos a proveedores/PAGO PRO; se incluyen pagos de planilla y concepto vacío.`,\n AccionSugerida: prioridad === 'ALTO'\n ? 'Revisar si existen ajustes, pagos parciales, deducciones, duplicados, pagos a cuenta equivocada o diferencias de moneda antes de clasificar como irregularidad definitiva.'\n : 'Validar si la diferencia corresponde a redondeo permitido o ajuste menor.',\n Fuente: groupFuentes(nGroup),\n Proyecto: groupProyectos(nGroup),\n Concepto: groupConceptos(nGroup),\n ScoreBanco: nameScore(nName, bName),\n ScoreBamboo: bambooEval.score ?? '',\n }));\n}\n\nfor (const [key, nGroup] of Object.entries(nominaGroups)) {\n if (matchedNominaGroups.has(key)) continue;\n\n const possibleBanco = bestGroupMatchByNameAndAmount(\n nGroup,\n 'NOMINA',\n bancoGroups,\n bancoGroupIndex,\n 'BANCO',\n matchedBancoGroups\n );\n\n if (possibleBanco && possibleBanco.amountExact && possibleBanco.score >= 0.80) {\n matchedNominaGroups.add(key);\n matchedBancoGroups.add(possibleBanco.group.key);\n\n const nName = bestNameFromGroup(nGroup, 'NOMINA');\n const bName = bestNameFromGroup(possibleBanco.group, 'BANCO');\n const bambooEval = bambooStatusForName(nName);\n\n issues.push(makeIssue({\n Prioridad: 'MEDIO',\n Categoria: 'Cuenta diferente, pero nombre y monto coinciden',\n EmpleadoNomina: nName,\n BeneficiarioBanco: bName,\n CoincidenciaBamboo: bambooEval.match?.row?.name || 'SIN COINCIDENCIA EN BAMBOO',\n CuentaNomina: nGroup.cuenta || 'SIN CUENTA EN NÓMINA',\n CuentaBanco: possibleBanco.group.cuenta || 'SIN CUENTA EN BANCO',\n MontoNomina: groupTotal(nGroup),\n MontoBanco: groupTotal(possibleBanco.group),\n Moneda: groupMoneda(nGroup, possibleBanco.group),\n Diferencia: possibleBanco.diff,\n MontoRiesgo: 0,\n Motivo: 'No coincidió la cuenta bancaria, pero el nombre y el monto total sí coinciden de forma confiable.',\n Evidencia: `Nombre nómina: ${nName}. Nombre banco: ${bName}. Cuenta nómina: ${nGroup.cuenta || 'vacía'}. Cuenta banco: ${possibleBanco.group.cuenta || 'vacía'}. Score nombre: ${possibleBanco.score}. Total nómina: ${groupTotal(nGroup)}. Total banco: ${groupTotal(possibleBanco.group)}. Regla: ${possibleBanco.strongReason}. Notas: ${groupNotas(nGroup) || 'sin notas'}.`,\n AccionSugerida: 'Confirmar si la cuenta cambió, si fue digitada diferente o si se pagó a una cuenta autorizada distinta.',\n Fuente: groupFuentes(nGroup),\n Proyecto: groupProyectos(nGroup),\n Concepto: groupConceptos(nGroup),\n ScoreBanco: possibleBanco.score,\n ScoreBamboo: bambooEval.score ?? '',\n }));\n\n continue;\n }\n\n const individualBanco = !nGroup.cuenta ? bestIndividualBancoForNominaGroup(nGroup) : null;\n const nName = bestNameFromGroup(nGroup, 'NOMINA');\n const bambooEval = bambooStatusForName(nName);\n\n if (individualBanco) {\n const b = individualBanco.row;\n\n issues.push(makeIssue({\n Prioridad: 'ALTO',\n Categoria: 'Nómina sin cuenta con pago bancario encontrado a otra cuenta',\n EmpleadoNomina: nName,\n BeneficiarioBanco: b.nombreCuentahabiente || b.nombreArchivo,\n CoincidenciaBamboo: bambooEval.match?.row?.name || 'SIN COINCIDENCIA EN BAMBOO',\n CuentaNomina: 'SIN CUENTA EN NÓMINA',\n CuentaBanco: b.cuenta || 'SIN CUENTA EN BANCO',\n MontoNomina: groupTotal(nGroup),\n MontoBanco: b.monto,\n Moneda: groupMoneda(nGroup, { cuenta: b.cuenta, rows: [b] }),\n Diferencia: individualBanco.diff,\n MontoRiesgo: groupTotal(nGroup),\n Motivo: 'La fila de nómina no tiene cuenta, pero se encontró un pago bancario por mismo nombre y monto hacia una cuenta bancaria. Debe validarse si esa cuenta pertenece al empleado o a un tercero.',\n Evidencia: `Nombre nómina: ${nName}. Monto nómina: ${groupTotal(nGroup)}. Pago banco encontrado: beneficiario ${b.nombreCuentahabiente || b.nombreArchivo}, cuenta ${b.cuenta}, monto ${b.monto}, archivo ${b.archivoOrigen || 'sin archivo'}, concepto ${b.concepto || 'sin concepto'}. Score banco: ${individualBanco.score}. Regla: ${individualBanco.strongReason}.`,\n AccionSugerida: 'Validar con Finanzas y RRHH si la cuenta bancaria pertenece al empleado o si el pago fue enviado a una cuenta no autorizada.',\n Fuente: groupFuentes(nGroup),\n Proyecto: groupProyectos(nGroup),\n Concepto: groupConceptos(nGroup),\n ScoreBanco: individualBanco.score,\n ScoreBamboo: bambooEval.score ?? '',\n }));\n\n continue;\n }\n\n issues.push(makeIssue({\n Prioridad: 'ALTO',\n Categoria: 'Nómina sin pago en banco',\n EmpleadoNomina: nName,\n BeneficiarioBanco: possibleBanco ? bestNameFromGroup(possibleBanco.group, 'BANCO') : '',\n CoincidenciaBamboo: bambooEval.match?.row?.name || 'SIN COINCIDENCIA EN BAMBOO',\n CuentaNomina: nGroup.cuenta || 'SIN CUENTA EN NÓMINA',\n CuentaBanco: possibleBanco?.group?.cuenta || '',\n MontoNomina: groupTotal(nGroup),\n MontoBanco: possibleBanco ? groupTotal(possibleBanco.group) : '',\n Moneda: groupMoneda(nGroup, possibleBanco?.group),\n Diferencia: possibleBanco?.diff ?? '',\n MontoRiesgo: groupTotal(nGroup),\n Motivo: 'El total consolidado de nómina no tiene un pago bancario confiable asociado.',\n Evidencia: `Cuenta nómina: ${nGroup.cuenta || 'vacía'}. Nombre nómina: ${nName}. Total nómina: ${groupTotal(nGroup)}. Mejor posible banco: ${possibleBanco ? bestNameFromGroup(possibleBanco.group, 'BANCO') : 'sin coincidencia'}. Score banco: ${possibleBanco?.score ?? 0}. Estado Bamboo: ${bambooEval.match?.row?.name || 'sin coincidencia'}. Score Bamboo: ${bambooEval.score ?? 0}. Notas: ${groupNotas(nGroup) || 'sin notas'}.`,\n AccionSugerida: 'Verificar si el pago fue omitido, pagado en otra cuenta, enviado por otro canal o si falta cargar un archivo del banco.',\n Fuente: groupFuentes(nGroup),\n Proyecto: groupProyectos(nGroup),\n Concepto: groupConceptos(nGroup),\n ScoreBanco: possibleBanco?.score ?? '',\n ScoreBamboo: bambooEval.score ?? '',\n }));\n}\n\nfor (const [key, bGroup] of Object.entries(bancoGroups)) {\n if (matchedBancoGroups.has(key)) continue;\n\n const possibleNomina = bestGroupMatchByNameAndAmount(\n bGroup,\n 'BANCO',\n nominaGroups,\n nominaGroupIndex,\n 'NOMINA',\n matchedNominaGroups\n );\n\n const bancoName = bestNameFromGroup(bGroup, 'BANCO');\n const bambooEval = bambooStatusForName(bancoName);\n\n if (possibleNomina && possibleNomina.amountExact && possibleNomina.score >= 0.80) {\n matchedBancoGroups.add(key);\n matchedNominaGroups.add(possibleNomina.group.key);\n\n issues.push(makeIssue({\n Prioridad: 'MEDIO',\n Categoria: 'Pago bancario aparece en nómina, pero no fue conciliado por cuenta',\n EmpleadoNomina: bestNameFromGroup(possibleNomina.group, 'NOMINA'),\n BeneficiarioBanco: bancoName,\n CoincidenciaBamboo: bambooEval.match?.row?.name || 'SIN COINCIDENCIA EN BAMBOO',\n CuentaNomina: possibleNomina.group.cuenta || 'SIN CUENTA EN NÓMINA',\n CuentaBanco: bGroup.cuenta || 'SIN CUENTA EN BANCO',\n MontoNomina: groupTotal(possibleNomina.group),\n MontoBanco: groupTotal(bGroup),\n Moneda: groupMoneda(possibleNomina.group, bGroup),\n Diferencia: possibleNomina.diff,\n MontoRiesgo: 0,\n Motivo: 'El pago bancario sí tiene coincidencia probable en nómina por nombre y monto, pero la cuenta no permitió conciliarlo automáticamente en el primer pase.',\n Evidencia: `Nombre banco: ${bancoName}. Nombre nómina: ${bestNameFromGroup(possibleNomina.group, 'NOMINA')}. Cuenta banco: ${bGroup.cuenta || 'vacía'}. Cuenta nómina: ${possibleNomina.group.cuenta || 'vacía'}. Total banco: ${groupTotal(bGroup)}. Total nómina: ${groupTotal(possibleNomina.group)}. Score nombre: ${possibleNomina.score}. Regla: ${possibleNomina.strongReason}.`,\n AccionSugerida: 'Revisar manualmente. No tratar como pago fuera de nómina hasta validar cuenta, nombre y concepto.',\n Fuente: groupFuentes(possibleNomina.group),\n Proyecto: groupProyectos(possibleNomina.group),\n Concepto: groupConceptos(possibleNomina.group) || groupConceptos(bGroup),\n ScoreBanco: possibleNomina.score,\n ScoreBamboo: bambooEval.score ?? '',\n }));\n\n continue;\n }\n\n const bancoBeneficiarioExisteEnBamboo = bambooEval.match && bambooEval.match.accepted;\n\n const categoriaBancoSinMatch = bancoBeneficiarioExisteEnBamboo\n ? 'Pago en banco sin registro en nómina - beneficiario aparece en Bamboo'\n : 'Pago en banco sin registro en nómina - beneficiario no encontrado en Bamboo';\n\n issues.push(makeIssue({\n Prioridad: 'ALTO',\n Categoria: categoriaBancoSinMatch,\n EmpleadoNomina: possibleNomina ? bestNameFromGroup(possibleNomina.group, 'NOMINA') : 'NO CONCILIADO AUTOMÁTICAMENTE',\n BeneficiarioBanco: bancoName,\n CoincidenciaBamboo: bambooEval.match?.row?.name || 'SIN COINCIDENCIA EN BAMBOO',\n CuentaNomina: possibleNomina?.group?.cuenta || 'NO CONCILIADO',\n CuentaBanco: bGroup.cuenta || 'SIN CUENTA EN BANCO',\n MontoNomina: possibleNomina ? groupTotal(possibleNomina.group) : '',\n MontoBanco: groupTotal(bGroup),\n Moneda: groupMoneda(possibleNomina?.group, bGroup),\n Diferencia: possibleNomina?.diff ?? '',\n MontoRiesgo: groupTotal(bGroup),\n Motivo: bancoBeneficiarioExisteEnBamboo\n ? 'Existe un pago en banco que no fue conciliado contra la nómina cargada, aunque el beneficiario sí parece existir en Bamboo.'\n : 'Existe un pago en banco que no fue conciliado contra la nómina cargada y el beneficiario tampoco tiene una coincidencia confiable en Bamboo.',\n Evidencia: `Cuenta banco: ${bGroup.cuenta || 'vacía'}. Beneficiario banco: ${bancoName}. Total banco consolidado: ${groupTotal(bGroup)}. Filas banco: ${bGroup.rows.length}. Mejor posible nómina: ${possibleNomina ? bestNameFromGroup(possibleNomina.group, 'NOMINA') : 'sin coincidencia'}. Total posible nómina: ${possibleNomina ? groupTotal(possibleNomina.group) : ''}. Score nómina/banco: ${possibleNomina?.score ?? 0}. Mejor coincidencia Bamboo: ${bambooEval.match?.row?.name || 'sin coincidencia'}. Score Bamboo: ${bambooEval.score ?? 0}.`,\n AccionSugerida: bancoBeneficiarioExisteEnBamboo\n ? 'Confirmar si este pago pertenece a otra nómina, ajuste, anticipo, bono, pago operativo, pago duplicado o concepto no cargado en Nomina_Raw.'\n : 'Revisar con Finanzas y RRHH si corresponde a pago externo autorizado, proveedor, error de archivo o posible pago fuera de nómina.',\n Fuente: 'BANCO SIN MATCH CONFIABLE',\n Proyecto: possibleNomina ? groupProyectos(possibleNomina.group) : '',\n Concepto: groupConceptos(bGroup),\n ScoreBanco: possibleNomina?.score ?? '',\n ScoreBamboo: bambooEval.score ?? '',\n }));\n}\n\n// AJUSTE FINAL:\n// Eliminar falsos positivos de nómina sin pago cuando el monto es 0.\n// Si no hay monto en nómina ni monto de riesgo, no debe contarse como irregularidad alta.\nfor (let i = issues.length - 1; i >= 0; i--) {\n const issue = issues[i];\n const montoNomina = Number(issue.MontoNomina || 0);\n const montoBanco = Number(issue.MontoBanco || 0);\n const montoRiesgo = Number(issue.MontoRiesgo || 0);\n\n if (\n issue.Categoria === 'Nómina sin pago en banco' &&\n montoNomina === 0 &&\n montoBanco === 0 &&\n montoRiesgo === 0\n ) {\n issues.splice(i, 1);\n }\n}\n\nconst prioridadOrden = { ALTO: 1, MEDIO: 2, BAJO: 3, OK: 4 };\n\nissues.sort((a, b) => {\n const p = (prioridadOrden[a.Prioridad] ?? 99) - (prioridadOrden[b.Prioridad] ?? 99);\n\n if (p !== 0) return p;\n\n return Number(b.MontoRiesgo || 0) - Number(a.MontoRiesgo || 0);\n});\n\nif (!issues.length) {\n issues.push(makeIssue({\n Prioridad: 'OK',\n Categoria: 'Sin irregularidades detectadas',\n Motivo: 'No se detectaron irregularidades con las reglas actuales.',\n }));\n}\n\nconst irregularidades = issues;\nconst resumenCategorias = {};\n\nfor (const issue of irregularidades) {\n resumenCategorias[issue.Categoria] = (resumenCategorias[issue.Categoria] || 0) + 1;\n}\n\nconst notaMoneda = excepcionesRows.length\n ? `excepcionesRows conectado: ${excepcionesRows.length} excepción(es) de moneda cargada(s). Se usa MonedaCorrecta por cuenta cuando aplica.`\n : 'excepcionesRows sin datos; se usa moneda detectada en banco/nómina o QTZ por defecto.';\n\nconst summary = {\n totalBambooActivoRows: bambooActivoRows.length,\n totalBambooAdditionsTerminationsRows: bambooAdditionsTerminationsRows.length,\n totalBambooRows: bamboo.length,\n totalNominaRowsEntrada: nominaInputRows.length,\n totalNominaRowsValidas: nomina.length,\n totalBancoRowsEntrada: bancoInputRows.length,\n totalBancoRowsValidas: banco.length,\n totalExcepcionesMonedaRows: excepcionesRows.length,\n totalIrregularidades: irregularidades.length,\n totalAltas: irregularidades.filter(i => i.Prioridad === 'ALTO').length,\n totalMedias: irregularidades.filter(i => i.Prioridad === 'MEDIO').length,\n totalBajas: irregularidades.filter(i => i.Prioridad === 'BAJO').length,\n montoRiesgoTotal: round2(irregularidades.reduce((sum, i) => sum + Number(i.MontoRiesgo || 0), 0)),\n categorias: resumenCategorias,\n notaMoneda,\n notaConceptosOperativos: 'Combustible, tarjeta, viáticos, movilidad, reembolsos y asignaciones se reportan como revisión operativa MEDIO y no suman al monto de riesgo alto.',\n};\n\nreturn [{\n json: {\n success: true,\n stage: 'cruce_cuentas_resultado',\n message: 'Cruce de cuentas ejecutado correctamente',\n summary,\n irregularidades,\n samples: {\n irregularidades: irregularidades.slice(0, 20),\n },\n },\n}];" + }, + "id": "014d8703-3428-4f66-b79b-4a53b7473422", + "name": "Code - Ejecutar Cruce de Cuentas", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3216, + -160 + ] + }, + { + "parameters": { + "jsCode": "const item = $input.first();\nconst data = item.json || {};\n\nconst summaryOriginal = data.summary || {};\nconst irregularidadesOriginales = Array.isArray(data.irregularidades)\n ? data.irregularidades\n : Array.isArray(data.samples?.irregularidades)\n ? data.samples.irregularidades\n : [];\n\nconst FECHA_REPORTE = new Date().toISOString();\n\nfunction toNumber(value) {\n if (value === null || value === undefined || value === '') return 0;\n const n = Number(value);\n return Number.isFinite(n) ? n : 0;\n}\n\nfunction round2(value) {\n return Number(Number(value || 0).toFixed(2));\n}\n\nfunction formatMoney(value, moneda = 'QTZ') {\n const n = toNumber(value);\n\n return `${moneda} ${n.toLocaleString('en-US', {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n })}`;\n}\n\nfunction cleanText(value) {\n return String(value ?? '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction priorityWeight(priority) {\n const p = cleanText(priority).toUpperCase();\n\n if (p === 'ALTO') return 1;\n if (p === 'MEDIO') return 2;\n if (p === 'BAJO') return 3;\n if (p === 'OK') return 4;\n\n return 99;\n}\n\nfunction riskWeight(issue) {\n return toNumber(issue.MontoRiesgo);\n}\n\nfunction cleanIssue(issue, index) {\n const moneda = cleanText(issue.Moneda) || 'QTZ';\n\n return {\n No: index + 1,\n Prioridad: cleanText(issue.Prioridad),\n Categoria: cleanText(issue.Categoria),\n EmpleadoNomina: cleanText(issue.EmpleadoNomina),\n BeneficiarioBanco: cleanText(issue.BeneficiarioBanco),\n CoincidenciaBamboo: cleanText(issue.CoincidenciaBamboo),\n CuentaNomina: cleanText(issue.CuentaNomina),\n CuentaBanco: cleanText(issue.CuentaBanco),\n MontoNomina: issue.MontoNomina === '' ? '' : round2(issue.MontoNomina),\n MontoBanco: issue.MontoBanco === '' ? '' : round2(issue.MontoBanco),\n Moneda: moneda,\n Diferencia: issue.Diferencia === '' ? '' : round2(issue.Diferencia),\n MontoRiesgo: round2(issue.MontoRiesgo),\n MontoRiesgoFormateado: formatMoney(issue.MontoRiesgo, moneda),\n Motivo: cleanText(issue.Motivo),\n Evidencia: cleanText(issue.Evidencia),\n AccionSugerida: cleanText(issue.AccionSugerida),\n Fuente: cleanText(issue.Fuente),\n Proyecto: cleanText(issue.Proyecto),\n Concepto: cleanText(issue.Concepto),\n ScoreBanco: issue.ScoreBanco === '' ? '' : issue.ScoreBanco,\n ScoreBamboo: issue.ScoreBamboo === '' ? '' : issue.ScoreBamboo,\n FechaRevision: cleanText(issue.FechaRevision),\n };\n}\n\nconst irregularidades = irregularidadesOriginales\n .map(cleanIssue)\n .sort((a, b) => {\n const prioridadDiff = priorityWeight(a.Prioridad) - priorityWeight(b.Prioridad);\n if (prioridadDiff !== 0) return prioridadDiff;\n\n return riskWeight(b) - riskWeight(a);\n });\n\nconst totalAltas = irregularidades.filter(i => i.Prioridad === 'ALTO').length;\nconst totalMedias = irregularidades.filter(i => i.Prioridad === 'MEDIO').length;\nconst totalBajas = irregularidades.filter(i => i.Prioridad === 'BAJO').length;\nconst totalOk = irregularidades.filter(i => i.Prioridad === 'OK').length;\n\nconst montoRiesgoTotal = round2(\n irregularidades.reduce((sum, i) => sum + toNumber(i.MontoRiesgo), 0)\n);\n\nconst categorias = {};\n\nfor (const issue of irregularidades) {\n const categoria = issue.Categoria || 'Sin categoría';\n categorias[categoria] = (categorias[categoria] || 0) + 1;\n}\n\nconst categoriasOrdenadas = Object.entries(categorias)\n .map(([categoria, cantidad]) => ({\n categoria,\n cantidad,\n }))\n .sort((a, b) => b.cantidad - a.cantidad);\n\nconst topAltas = irregularidades\n .filter(i => i.Prioridad === 'ALTO')\n .slice(0, 20);\n\nconst topMedias = irregularidades\n .filter(i => i.Prioridad === 'MEDIO')\n .slice(0, 20);\n\nconst topGeneral = irregularidades.slice(0, 25);\n\nlet estadoResultado = 'OK';\nlet tituloResultado = 'Sin irregularidades críticas detectadas';\nlet recomendacionPrincipal = 'Revisar el resumen y conservar evidencia de la corrida.';\n\nif (totalAltas > 0) {\n estadoResultado = 'REQUIERE_REVISION_ALTA';\n tituloResultado = 'Cruce completado con irregularidades de prioridad alta';\n recomendacionPrincipal = 'Revisar primero las irregularidades ALTO, especialmente empleados no encontrados en Bamboo y pagos sin conciliación bancaria.';\n} else if (totalMedias > 0) {\n estadoResultado = 'REQUIERE_REVISION_MEDIA';\n tituloResultado = 'Cruce completado con observaciones de prioridad media';\n recomendacionPrincipal = 'Revisar manualmente las coincidencias parciales y conceptos operativos antes de cerrar el análisis.';\n}\n\nconst resumenEjecutivo = {\n estadoResultado,\n tituloResultado,\n fechaReporte: FECHA_REPORTE,\n\n totalIrregularidades: irregularidades.length,\n totalAltas,\n totalMedias,\n totalBajas,\n totalOk,\n\n montoRiesgoTotal,\n montoRiesgoTotalFormateado: formatMoney(montoRiesgoTotal, 'QTZ'),\n\n totalBambooActivoRows: summaryOriginal.totalBambooActivoRows ?? '',\n totalBambooAdditionsTerminationsRows: summaryOriginal.totalBambooAdditionsTerminationsRows ?? '',\n totalBambooRows: summaryOriginal.totalBambooRows ?? '',\n totalNominaRowsEntrada: summaryOriginal.totalNominaRowsEntrada ?? '',\n totalNominaRowsValidas: summaryOriginal.totalNominaRowsValidas ?? '',\n totalBancoRowsEntrada: summaryOriginal.totalBancoRowsEntrada ?? '',\n totalBancoRowsValidas: summaryOriginal.totalBancoRowsValidas ?? '',\n\n recomendacionPrincipal,\n};\n\nconst alertas = [];\n\nif (summaryOriginal.notaMoneda) {\n alertas.push(summaryOriginal.notaMoneda);\n}\n\nif (summaryOriginal.notaConceptosOperativos) {\n alertas.push(summaryOriginal.notaConceptosOperativos);\n}\n\nalertas.push('Antes de producción se debe conectar excepcionesRows para manejar excepciones de moneda correctamente.');\n\nconst reporteTexto = [\n 'RESUMEN EJECUTIVO - CRUCE DE CUENTAS',\n '',\n `Estado: ${tituloResultado}`,\n `Fecha de reporte: ${FECHA_REPORTE}`,\n '',\n `Total de irregularidades: ${irregularidades.length}`,\n `Prioridad alta: ${totalAltas}`,\n `Prioridad media: ${totalMedias}`,\n `Prioridad baja: ${totalBajas}`,\n `Monto de riesgo total: ${formatMoney(montoRiesgoTotal, 'QTZ')}`,\n '',\n 'Categorías principales:',\n ...categoriasOrdenadas.map(c => `- ${c.categoria}: ${c.cantidad}`),\n '',\n `Recomendación: ${recomendacionPrincipal}`,\n '',\n 'Notas:',\n ...alertas.map(a => `- ${a}`),\n].join('\\n');\n\nconst response = {\n success: true,\n stage: 'resultado_final_ligero',\n message: 'Cruce de cuentas ejecutado y preparado correctamente.',\n resumenEjecutivo,\n categorias: categoriasOrdenadas,\n topGeneral,\n topAltas,\n topMedias,\n reporteTexto,\n alertas,\n};\n\nreturn [\n {\n json: {\n success: true,\n stage: 'salida_final_preparada',\n message: 'Salida final preparada correctamente para el frontend.',\n response,\n diagnostics: {\n irregularidadesOriginales: irregularidadesOriginales.length,\n irregularidadesEnRespuestaTopGeneral: topGeneral.length,\n irregularidadesAltasEnRespuesta: topAltas.length,\n irregularidadesMediasEnRespuesta: topMedias.length,\n categoriasDetectadas: categoriasOrdenadas.length,\n },\n },\n },\n];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3424, + -160 + ], + "id": "e4aac2db-69e2-4e62-a391-1242a083753a", + "name": "Code - Preparar Salida Final" + }, + { + "parameters": { + "resource": "spreadsheet", + "title": "={{ 'Cruce de Cuentas - ' + $now.toFormat('yyyy-LL-dd HH-mm-ss') }}", + "options": {} + }, + "type": "n8n-nodes-base.googleSheets", + "typeVersion": 4.7, + "position": [ + 3696, + -160 + ], + "id": "de8a2623-9e93-43ae-8f2b-d35b3aea3add", + "name": "Create spreadsheet", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const createdSheet = $input.first().json || {};\n\nlet salidaFinal = {};\nlet cruceCompleto = {};\n\ntry {\n salidaFinal = $('Code - Preparar Salida Final').first().json || {};\n} catch (e) {\n salidaFinal = {};\n}\n\ntry {\n cruceCompleto = $('Code - Ejecutar Cruce de Cuentas').first().json || {};\n} catch (e) {\n cruceCompleto = {};\n}\n\nconst response = salidaFinal.response || salidaFinal;\n\nconst resumen = response.resumenEjecutivo || {};\nconst categorias = Array.isArray(response.categorias) ? response.categorias : [];\nconst topGeneral = Array.isArray(response.topGeneral) ? response.topGeneral : [];\nconst topAltas = Array.isArray(response.topAltas) ? response.topAltas : [];\nconst topMedias = Array.isArray(response.topMedias) ? response.topMedias : [];\nconst alertas = Array.isArray(response.alertas) ? response.alertas : [];\nconst reporteTexto = response.reporteTexto || '';\n\nconst irregularidadesCompletas = Array.isArray(cruceCompleto.irregularidades)\n ? cruceCompleto.irregularidades\n : Array.isArray(response.irregularidades)\n ? response.irregularidades\n : topGeneral;\n\nconst spreadsheetId =\n createdSheet.spreadsheetId ||\n createdSheet.id ||\n createdSheet.documentId ||\n createdSheet?.spreadsheet?.spreadsheetId ||\n '';\n\nconst firstSheetId =\n createdSheet?.sheets?.[0]?.properties?.sheetId ??\n createdSheet?.spreadsheet?.sheets?.[0]?.properties?.sheetId ??\n 0;\n\nif (!spreadsheetId) {\n return [{\n json: {\n success: false,\n stage: 'google_sheets_missing_spreadsheet_id',\n error: 'No pude detectar el spreadsheetId del Google Sheet creado.',\n createdSheetKeys: Object.keys(createdSheet),\n createdSheet,\n },\n }];\n}\n\nconst spreadsheetUrl =\n createdSheet.spreadsheetUrl ||\n createdSheet.url ||\n `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit`;\n\nconst reportTitle =\n createdSheet?.properties?.title ||\n createdSheet?.spreadsheet?.properties?.title ||\n `Cruce de Cuentas - ${new Date().toISOString().replace(/[:.]/g, '-')}`;\n\nfunction clean(value) {\n if (value === null || value === undefined) return '';\n\n if (typeof value === 'object') {\n return JSON.stringify(value);\n }\n\n return String(value);\n}\n\nfunction money(value) {\n const n = Number(value || 0);\n return `QTZ ${n.toLocaleString('en-US', {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n })}`;\n}\n\nfunction rowsFromObjects(items, headers) {\n const rows = [headers];\n\n for (const item of items || []) {\n rows.push(headers.map(header => clean(item?.[header])));\n }\n\n return rows;\n}\n\nconst irregularidadHeaders = [\n 'No',\n 'Prioridad',\n 'Categoria',\n 'EmpleadoNomina',\n 'BeneficiarioBanco',\n 'CoincidenciaBamboo',\n 'CuentaNomina',\n 'CuentaBanco',\n 'MontoNomina',\n 'MontoBanco',\n 'Moneda',\n 'Diferencia',\n 'MontoRiesgo',\n 'MontoRiesgoFormateado',\n 'Motivo',\n 'Evidencia',\n 'AccionSugerida',\n 'Fuente',\n 'Proyecto',\n 'Concepto',\n 'ScoreBanco',\n 'ScoreBamboo',\n 'FechaRevision',\n];\n\nfunction normalizeIssueRows(items) {\n return (items || []).map((item, index) => ({\n No: item.No || index + 1,\n Prioridad: item.Prioridad || '',\n Categoria: item.Categoria || '',\n EmpleadoNomina: item.EmpleadoNomina || '',\n BeneficiarioBanco: item.BeneficiarioBanco || '',\n CoincidenciaBamboo: item.CoincidenciaBamboo || '',\n CuentaNomina: item.CuentaNomina || '',\n CuentaBanco: item.CuentaBanco || '',\n MontoNomina: item.MontoNomina ?? '',\n MontoBanco: item.MontoBanco ?? '',\n Moneda: item.Moneda || '',\n Diferencia: item.Diferencia ?? '',\n MontoRiesgo: item.MontoRiesgo ?? 0,\n MontoRiesgoFormateado: item.MontoRiesgoFormateado || money(item.MontoRiesgo || 0),\n Motivo: item.Motivo || '',\n Evidencia: item.Evidencia || '',\n AccionSugerida: item.AccionSugerida || '',\n Fuente: item.Fuente || '',\n Proyecto: item.Proyecto || '',\n Concepto: item.Concepto || '',\n ScoreBanco: item.ScoreBanco ?? '',\n ScoreBamboo: item.ScoreBamboo ?? '',\n FechaRevision: item.FechaRevision || '',\n }));\n}\n\nconst resumenRows = [\n ['Campo', 'Valor'],\n ['Estado resultado', resumen.estadoResultado || ''],\n ['Título resultado', resumen.tituloResultado || ''],\n ['Fecha reporte', resumen.fechaReporte || new Date().toISOString()],\n ['Total irregularidades', resumen.totalIrregularidades ?? ''],\n ['Total altas', resumen.totalAltas ?? ''],\n ['Total medias', resumen.totalMedias ?? ''],\n ['Total bajas', resumen.totalBajas ?? ''],\n ['Total OK', resumen.totalOk ?? 0],\n ['Monto riesgo total', resumen.montoRiesgoTotal ?? 0],\n ['Monto riesgo total formateado', resumen.montoRiesgoTotalFormateado || money(resumen.montoRiesgoTotal || 0)],\n ['Bamboo activo rows', resumen.totalBambooActivoRows ?? ''],\n ['Bamboo additions/terminations rows', resumen.totalBambooAdditionsTerminationsRows ?? ''],\n ['Bamboo rows total', resumen.totalBambooRows ?? ''],\n ['Nómina rows entrada', resumen.totalNominaRowsEntrada ?? ''],\n ['Nómina rows válidas', resumen.totalNominaRowsValidas ?? ''],\n ['Banco rows entrada', resumen.totalBancoRowsEntrada ?? ''],\n ['Banco rows válidas', resumen.totalBancoRowsValidas ?? ''],\n ['Recomendación principal', resumen.recomendacionPrincipal || ''],\n ['Link del reporte', spreadsheetUrl],\n];\n\nconst categoriasRows = rowsFromObjects(categorias, ['categoria', 'cantidad']);\n\nconst irregularidadesRows = rowsFromObjects(\n normalizeIssueRows(irregularidadesCompletas),\n irregularidadHeaders\n);\n\nconst topGeneralRows = rowsFromObjects(\n normalizeIssueRows(topGeneral),\n irregularidadHeaders\n);\n\nconst topAltasRows = rowsFromObjects(\n normalizeIssueRows(topAltas),\n irregularidadHeaders\n);\n\nconst topMediasRows = rowsFromObjects(\n normalizeIssueRows(topMedias),\n irregularidadHeaders\n);\n\nconst alertasRows = [\n ['No', 'Alerta'],\n ...alertas.map((alerta, index) => [index + 1, alerta]),\n];\n\nconst reporteTextoRows = [\n ['Reporte Texto'],\n ...String(reporteTexto || '').split('\\n').map(line => [line]),\n];\n\nconst tabs = [\n {\n title: 'Resumen',\n values: resumenRows,\n },\n {\n title: 'Categorias',\n values: categoriasRows,\n },\n {\n title: 'Irregularidades',\n values: irregularidadesRows,\n },\n {\n title: 'Top_General',\n values: topGeneralRows,\n },\n {\n title: 'Top_Altas',\n values: topAltasRows,\n },\n {\n title: 'Top_Medias',\n values: topMediasRows,\n },\n {\n title: 'Alertas',\n values: alertasRows,\n },\n {\n title: 'Reporte_Texto',\n values: reporteTextoRows,\n },\n];\n\nconst batchUpdateBody = {\n requests: [\n {\n updateSheetProperties: {\n properties: {\n sheetId: firstSheetId,\n title: 'Resumen',\n },\n fields: 'title',\n },\n },\n ...tabs.slice(1).map(tab => ({\n addSheet: {\n properties: {\n title: tab.title,\n },\n },\n })),\n ],\n};\n\nconst valuesBatchUpdateBody = {\n valueInputOption: 'USER_ENTERED',\n data: tabs.map(tab => ({\n range: `'${tab.title}'!A1`,\n majorDimension: 'ROWS',\n values: tab.values,\n })),\n};\n\nconst responseConGoogleSheet = {\n ...response,\n googleSheet: {\n id: spreadsheetId,\n title: reportTitle,\n url: spreadsheetUrl,\n },\n reporteGoogleSheetsUrl: spreadsheetUrl,\n message: `${response.message || 'Cruce de cuentas ejecutado correctamente.'} Reporte creado en Google Sheets.`,\n};\n\nreturn [{\n json: {\n success: true,\n stage: 'google_sheets_batch_preparado',\n spreadsheetId,\n spreadsheetUrl,\n reportTitle,\n batchUpdateBody,\n valuesBatchUpdateBody,\n response: responseConGoogleSheet,\n },\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3904, + -160 + ], + "id": "32b337e6-ac3a-419b-bcf2-752fdc4d73c5", + "name": "Code - Preparar Google Sheets Batch" + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $json.spreadsheetId + ':batchUpdate' }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json.batchUpdateBody) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 4112, + -160 + ], + "id": "df18d2da-fd1e-4654-a400-6a1a762d54ae", + "name": "HTTP - Crear Pestañas Google Sheets", + "credentials": { + "googleOAuth2Api": { + "id": "eHseMeH39kRcXgOF", + "name": "Google account 2" + } + } + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $('Code - Preparar Google Sheets Batch').first().json.spreadsheetId + '/values:batchUpdate' }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $('Code - Preparar Google Sheets Batch').first().json.valuesBatchUpdateBody }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 4320, + -160 + ], + "id": "60ea8f16-c922-44e6-a29a-d17d873086ac", + "name": "HTTP - Escribir Datos Google Sheets", + "credentials": { + "googleOAuth2Api": { + "id": "eHseMeH39kRcXgOF", + "name": "Google account 2" + } + } + }, + { + "parameters": { + "jsCode": "const sheetPayload = $('Code - Preparar Google Sheets Batch').first().json;\n\nreturn [{\n json: {\n success: true,\n stage: 'salida_final_con_google_sheet',\n message: 'Cruce de cuentas ejecutado y reporte creado en Google Sheets.',\n response: sheetPayload.response,\n googleSheet: {\n id: sheetPayload.spreadsheetId,\n title: sheetPayload.reportTitle,\n url: sheetPayload.spreadsheetUrl,\n },\n },\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4528, + -160 + ], + "id": "145917b4-8253-41ed-806b-453ee8fffaa4", + "name": "Code - Preparar Respuesta con Link" + }, + { + "parameters": { + "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + ($json.googleSheet?.id || $json.response?.googleSheet?.id) + '?fields=spreadsheetId,sheets(properties(sheetId,title,gridProperties(rowCount,columnCount)))' }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 4736, + -160 + ], + "id": "506ed98a-ca01-4e1b-8b4a-e3584e0f19d7", + "name": "HTTP - Leer Metadata Google Sheet", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const metadata = $input.first().json;\n\nconst spreadsheetId = metadata.spreadsheetId;\n\nif (!spreadsheetId) {\n return [{\n json: {\n success: false,\n stage: 'google_sheets_format_error',\n error: 'No se encontró spreadsheetId en la metadata del Google Sheet.',\n metadata,\n },\n }];\n}\n\nconst sheets = metadata.sheets || [];\n\nfunction normalizeTitle(value) {\n return String(value || '')\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase()\n .trim();\n}\n\nfunction getSheetInfo(sheet) {\n return {\n sheetId: sheet.properties.sheetId,\n title: sheet.properties.title,\n rowCount: sheet.properties.gridProperties?.rowCount || 1000,\n columnCount: sheet.properties.gridProperties?.columnCount || 26,\n };\n}\n\nconst sheetInfos = sheets.map(getSheetInfo);\n\nfunction isResumen(title) {\n const t = normalizeTitle(title);\n return t.includes('resumen');\n}\n\nfunction isCategorias(title) {\n const t = normalizeTitle(title);\n return t.includes('categoria');\n}\n\nfunction isAlertas(title) {\n const t = normalizeTitle(title);\n return t.includes('alerta') || t.includes('reporte') || t.includes('texto');\n}\n\nfunction isDetailSheet(title) {\n const t = normalizeTitle(title);\n\n return (\n t.includes('general') ||\n t.includes('alta') ||\n t.includes('media') ||\n t.includes('irregular') ||\n t.includes('detalle')\n );\n}\n\nconst requests = [];\n\nconst COLORS = {\n darkHeader: { red: 0.12, green: 0.16, blue: 0.22 },\n white: { red: 1, green: 1, blue: 1 },\n lightGray: { red: 0.94, green: 0.95, blue: 0.97 },\n red: { red: 0.83, green: 0.18, blue: 0.18 },\n redSoft: { red: 0.98, green: 0.82, blue: 0.82 },\n amber: { red: 1.0, green: 0.74, blue: 0.28 },\n amberSoft: { red: 1.0, green: 0.91, blue: 0.70 },\n green: { red: 0.27, green: 0.62, blue: 0.39 },\n blueSoft: { red: 0.82, green: 0.89, blue: 1.0 },\n};\n\nfunction freezeHeader(sheetId, frozenColumns = 0) {\n requests.push({\n updateSheetProperties: {\n properties: {\n sheetId,\n gridProperties: {\n frozenRowCount: 1,\n frozenColumnCount: frozenColumns,\n },\n },\n fields: 'gridProperties.frozenRowCount,gridProperties.frozenColumnCount',\n },\n });\n}\n\nfunction styleHeader(sheetId, columns) {\n requests.push({\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: 0,\n endRowIndex: 1,\n startColumnIndex: 0,\n endColumnIndex: columns,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: COLORS.darkHeader,\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n textFormat: {\n bold: true,\n foregroundColor: COLORS.white,\n fontSize: 11,\n },\n },\n },\n fields: 'userEnteredFormat(backgroundColor,horizontalAlignment,verticalAlignment,wrapStrategy,textFormat)',\n },\n });\n}\n\nfunction formatBody(sheetId, columns, rows = 1000) {\n requests.push({\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: 1,\n endRowIndex: rows,\n startColumnIndex: 0,\n endColumnIndex: columns,\n },\n cell: {\n userEnteredFormat: {\n verticalAlignment: 'TOP',\n wrapStrategy: 'WRAP',\n textFormat: {\n fontSize: 10,\n },\n },\n },\n fields: 'userEnteredFormat(verticalAlignment,wrapStrategy,textFormat.fontSize)',\n },\n });\n}\n\nfunction autoResize(sheetId, columns) {\n requests.push({\n autoResizeDimensions: {\n dimensions: {\n sheetId,\n dimension: 'COLUMNS',\n startIndex: 0,\n endIndex: columns,\n },\n },\n });\n}\n\nfunction setWidth(sheetId, startCol, endCol, pixelSize) {\n requests.push({\n updateDimensionProperties: {\n range: {\n sheetId,\n dimension: 'COLUMNS',\n startIndex: startCol,\n endIndex: endCol,\n },\n properties: {\n pixelSize,\n },\n fields: 'pixelSize',\n },\n });\n}\n\nfunction setBasicFilter(sheetId, columns, rows = 1000) {\n requests.push({\n setBasicFilter: {\n filter: {\n range: {\n sheetId,\n startRowIndex: 0,\n endRowIndex: rows,\n startColumnIndex: 0,\n endColumnIndex: columns,\n },\n },\n },\n });\n}\n\nfunction formatCurrencyColumn(sheetId, colIndex, rows = 1000) {\n requests.push({\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: 1,\n endRowIndex: rows,\n startColumnIndex: colIndex,\n endColumnIndex: colIndex + 1,\n },\n cell: {\n userEnteredFormat: {\n numberFormat: {\n type: 'NUMBER',\n pattern: '\"QTZ\" #,##0.00',\n },\n },\n },\n fields: 'userEnteredFormat.numberFormat',\n },\n });\n}\n\nfunction formatTextColumn(sheetId, colIndex, rows = 1000) {\n requests.push({\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: 1,\n endRowIndex: rows,\n startColumnIndex: colIndex,\n endColumnIndex: colIndex + 1,\n },\n cell: {\n userEnteredFormat: {\n numberFormat: {\n type: 'TEXT',\n },\n },\n },\n fields: 'userEnteredFormat.numberFormat',\n },\n });\n}\n\nfunction addPriorityConditionalFormatting(sheetId, rows = 1000) {\n // Columna B = Prioridad\n requests.push({\n addConditionalFormatRule: {\n rule: {\n ranges: [{\n sheetId,\n startRowIndex: 1,\n endRowIndex: rows,\n startColumnIndex: 1,\n endColumnIndex: 2,\n }],\n booleanRule: {\n condition: {\n type: 'TEXT_EQ',\n values: [{ userEnteredValue: 'ALTO' }],\n },\n format: {\n backgroundColor: COLORS.red,\n textFormat: {\n foregroundColor: COLORS.white,\n bold: true,\n },\n },\n },\n },\n index: 0,\n },\n });\n\n requests.push({\n addConditionalFormatRule: {\n rule: {\n ranges: [{\n sheetId,\n startRowIndex: 1,\n endRowIndex: rows,\n startColumnIndex: 1,\n endColumnIndex: 2,\n }],\n booleanRule: {\n condition: {\n type: 'TEXT_EQ',\n values: [{ userEnteredValue: 'MEDIO' }],\n },\n format: {\n backgroundColor: COLORS.amber,\n textFormat: {\n bold: true,\n },\n },\n },\n },\n index: 1,\n },\n });\n\n requests.push({\n addConditionalFormatRule: {\n rule: {\n ranges: [{\n sheetId,\n startRowIndex: 1,\n endRowIndex: rows,\n startColumnIndex: 1,\n endColumnIndex: 2,\n }],\n booleanRule: {\n condition: {\n type: 'TEXT_EQ',\n values: [{ userEnteredValue: 'BAJO' }],\n },\n format: {\n backgroundColor: COLORS.green,\n textFormat: {\n foregroundColor: COLORS.white,\n bold: true,\n },\n },\n },\n },\n index: 2,\n },\n });\n}\n\nfunction styleResumen(sheet) {\n const sheetId = sheet.sheetId;\n const rows = Math.max(sheet.rowCount, 100);\n const columns = Math.max(sheet.columnCount, 8);\n\n freezeHeader(sheetId, 0);\n styleHeader(sheetId, Math.min(columns, 8));\n formatBody(sheetId, Math.min(columns, 8), rows);\n autoResize(sheetId, Math.min(columns, 8));\n\n setWidth(sheetId, 0, 1, 280);\n setWidth(sheetId, 1, 2, 220);\n setWidth(sheetId, 2, 8, 180);\n\n requests.push({\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: 1,\n endRowIndex: rows,\n startColumnIndex: 0,\n endColumnIndex: 1,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: COLORS.lightGray,\n textFormat: {\n bold: true,\n },\n },\n },\n fields: 'userEnteredFormat(backgroundColor,textFormat.bold)',\n },\n });\n}\n\nfunction styleCategorias(sheet) {\n const sheetId = sheet.sheetId;\n const rows = Math.max(sheet.rowCount, 100);\n const columns = Math.min(Math.max(sheet.columnCount, 2), 6);\n\n freezeHeader(sheetId, 0);\n styleHeader(sheetId, columns);\n formatBody(sheetId, columns, rows);\n setBasicFilter(sheetId, columns, rows);\n autoResize(sheetId, columns);\n\n setWidth(sheetId, 0, 1, 420);\n setWidth(sheetId, 1, 2, 120);\n}\n\nfunction styleAlertas(sheet) {\n const sheetId = sheet.sheetId;\n const rows = Math.max(sheet.rowCount, 100);\n const columns = Math.min(Math.max(sheet.columnCount, 2), 8);\n\n freezeHeader(sheetId, 0);\n styleHeader(sheetId, columns);\n formatBody(sheetId, columns, rows);\n autoResize(sheetId, columns);\n\n setWidth(sheetId, 0, 1, 220);\n setWidth(sheetId, 1, 8, 520);\n}\n\nfunction styleDetail(sheet) {\n const sheetId = sheet.sheetId;\n const rows = Math.max(sheet.rowCount, 1000);\n const columns = Math.min(Math.max(sheet.columnCount, 23), 30);\n\n freezeHeader(sheetId, 2);\n styleHeader(sheetId, columns);\n formatBody(sheetId, columns, rows);\n setBasicFilter(sheetId, columns, rows);\n autoResize(sheetId, columns);\n addPriorityConditionalFormatting(sheetId, rows);\n\n // Anchos principales\n setWidth(sheetId, 0, 1, 60); // No\n setWidth(sheetId, 1, 2, 110); // Prioridad\n setWidth(sheetId, 2, 3, 260); // Categoría\n setWidth(sheetId, 3, 6, 230); // Nombres\n setWidth(sheetId, 6, 8, 135); // Cuentas\n setWidth(sheetId, 8, 14, 135); // Montos / moneda\n setWidth(sheetId, 14, 17, 420); // Motivo / evidencia / acción\n setWidth(sheetId, 17, 20, 200); // Fuente / proyecto / concepto\n setWidth(sheetId, 20, 23, 140); // Scores / fecha\n\n // Columnas de cuenta como texto\n formatTextColumn(sheetId, 6, rows); // CuentaNomina\n formatTextColumn(sheetId, 7, rows); // CuentaBanco\n\n // Columnas de montos\n formatCurrencyColumn(sheetId, 8, rows); // MontoNomina\n formatCurrencyColumn(sheetId, 9, rows); // MontoBanco\n formatCurrencyColumn(sheetId, 11, rows); // Diferencia\n formatCurrencyColumn(sheetId, 12, rows); // MontoRiesgo\n}\n\nfor (const sheet of sheetInfos) {\n if (isResumen(sheet.title)) {\n styleResumen(sheet);\n } else if (isCategorias(sheet.title)) {\n styleCategorias(sheet);\n } else if (isAlertas(sheet.title)) {\n styleAlertas(sheet);\n } else if (isDetailSheet(sheet.title)) {\n styleDetail(sheet);\n } else {\n // Formato general para cualquier pestaña no reconocida\n const rows = Math.max(sheet.rowCount, 1000);\n const columns = Math.min(Math.max(sheet.columnCount, 10), 30);\n\n freezeHeader(sheet.sheetId, 0);\n styleHeader(sheet.sheetId, columns);\n formatBody(sheet.sheetId, columns, rows);\n autoResize(sheet.sheetId, columns);\n }\n}\n\nreturn [{\n json: {\n success: true,\n stage: 'google_sheets_format_preparado',\n spreadsheetId,\n sheetsDetected: sheetInfos.map(s => ({\n title: s.title,\n sheetId: s.sheetId,\n rowCount: s.rowCount,\n columnCount: s.columnCount,\n })),\n formatRequests: requests,\n formatRequestsCount: requests.length,\n },\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4944, + -160 + ], + "id": "e761645d-fc59-4dee-81cd-9617b1af32e0", + "name": "Code - Preparar Formato Google Sheets" + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $json.spreadsheetId + ':batchUpdate' }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "sendBody": true, + "bodyParameters": { + "parameters": [ + { + "name": "requests", + "value": "={{ $json.formatRequests }}" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 5152, + -160 + ], + "id": "04e6d55f-ce35-4c40-b4e0-60d6502eeeed", + "name": "HTTP - Aplicar Formato Google Sheets", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const finalData = $('Code - Preparar Salida Final').first().json;\nconst sheetData = $('Code - Preparar Formato Google Sheets').first().json;\n\nconst response = finalData.response || finalData;\n\nconst googleSheetUrl =\n response.reporteGoogleSheetsUrl ||\n response.googleSheet?.url ||\n sheetData.spreadsheetUrl ||\n `https://docs.google.com/spreadsheets/d/${sheetData.spreadsheetId}/edit`;\n\nreturn [\n {\n json: {\n success: true,\n stage: 'resultado_final_ligero',\n message: 'Cruce de cuentas ejecutado correctamente. Reporte creado y formateado en Google Sheets.',\n resumenEjecutivo: response.resumenEjecutivo || {},\n categorias: response.categorias || [],\n topGeneral: response.topGeneral || [],\n topAltas: response.topAltas || [],\n topMedias: response.topMedias || [],\n reporteTexto: response.reporteTexto || '',\n alertas: response.alertas || [],\n googleSheet: {\n id: sheetData.spreadsheetId || response.googleSheet?.id || '',\n title: response.googleSheet?.title || sheetData.reportTitle || '',\n url: googleSheetUrl,\n },\n reporteGoogleSheetsUrl: googleSheetUrl,\n formatoGoogleSheetsAplicado: true,\n },\n },\n];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 5360, + -160 + ], + "id": "97e8cb53-3a49-44c9-bbf3-bb2f9f0bc4eb", + "name": "Code - Respuesta Final" + }, + { + "parameters": { + "documentId": { + "__rl": true, + "value": "19FJ_2zH5ZyTn36jE10M8eLZFGVDb5bzXJQy5yQ-Qmkw", + "mode": "list", + "cachedResultName": "Configuración - Cruce de Cuentas GLM", + "cachedResultUrl": "https://docs.google.com/spreadsheets/d/19FJ_2zH5ZyTn36jE10M8eLZFGVDb5bzXJQy5yQ-Qmkw/edit?usp=drivesdk" + }, + "sheetName": { + "__rl": true, + "value": "gid=0", + "mode": "list", + "cachedResultName": "Excepciones_Moneda", + "cachedResultUrl": "https://docs.google.com/spreadsheets/d/19FJ_2zH5ZyTn36jE10M8eLZFGVDb5bzXJQy5yQ-Qmkw/edit#gid=0" + }, + "options": {} + }, + "type": "n8n-nodes-base.googleSheets", + "typeVersion": 4.7, + "position": [ + 2624, + 32 + ], + "id": "16879d3f-aae3-4649-ba95-33f06b5739a1", + "name": "GS - Leer Excepciones Moneda", + "alwaysOutputData": true, + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 2896, + -176 + ], + "id": "9cb0095b-7eeb-4651-a005-fbfad3a7f980", + "name": "Merge - Normalización + Excepciones" + }, + { + "parameters": { + "inputDataFieldName": "nominaExcel", + "name": "={{ 'TEMP_Nomina_XLSX_' + $now.toFormat('yyyy-LL-dd_HH-mm-ss') + '.xlsx' }}", + "driveId": { + "__rl": true, + "mode": "list", + "value": "My Drive" + }, + "folderId": { + "__rl": true, + "mode": "list", + "value": "root", + "cachedResultName": "/ (Root folder)" + }, + "options": {} + }, + "type": "n8n-nodes-base.googleDrive", + "typeVersion": 3, + "position": [ + 848, + 368 + ], + "id": "8d5537af-c0b9-4b82-8faf-194163f16aff", + "name": "Google Drive - Subir Nómina XLSX", + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + } + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'https://www.googleapis.com/drive/v3/files/' + $json.id + '/copy?fields=id,name,mimeType,webViewLink' }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ {\n name: 'TEMP_Nomina_Cruce_' + $now.toFormat('yyyy-LL-dd_HH-mm-ss'),\n mimeType: 'application/vnd.google-apps.spreadsheet'\n} }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1056, + 384 + ], + "id": "bd941a5c-9323-47bc-84aa-172063e914a3", + "name": "HTTP - Convertir Nómina XLSX a Google Sheet", + "credentials": { + "googleOAuth2Api": { + "id": "eHseMeH39kRcXgOF", + "name": "Google account 2" + } + } + }, + { + "parameters": { + "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $json.id + '?fields=spreadsheetId,sheets(properties(sheetId,title,gridProperties(rowCount,columnCount)))' }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1264, + 384 + ], + "id": "555493c6-0b57-4f1b-91b0-5a145c87b63c", + "name": "HTTP - Leer Metadata Nómina Temporal", + "credentials": { + "googleOAuth2Api": { + "id": "eHseMeH39kRcXgOF", + "name": "Google account 2" + } + } + }, + { + "parameters": { + "jsCode": "const meta = $input.first().json;\n\nconst spreadsheetId = meta.spreadsheetId;\n\nconst sheets = (meta.sheets || [])\n .map(s => {\n const props = s.properties || {};\n return {\n title: props.title || '',\n sheetId: props.sheetId,\n rowCount: props.gridProperties?.rowCount || 5000,\n columnCount: props.gridProperties?.columnCount || 80,\n };\n })\n .filter(s => s.title);\n\nfunction columnToLetter(column) {\n let temp = '';\n let letter = '';\n\n while (column > 0) {\n temp = (column - 1) % 26;\n letter = String.fromCharCode(temp + 65) + letter;\n column = (column - temp - 1) / 26;\n }\n\n return letter;\n}\n\nfunction escapeSheetName(name) {\n return String(name).replace(/'/g, \"''\");\n}\n\nconst ranges = sheets.map(sheet => {\n const lastCol = columnToLetter(Math.min(sheet.columnCount || 80, 120));\n const lastRow = Math.min(sheet.rowCount || 5000, 10000);\n\n return `'${escapeSheetName(sheet.title)}'!A1:${lastCol}${lastRow}`;\n});\n\nconst rangesQuery = ranges\n .map(range => 'ranges=' + encodeURIComponent(range))\n .join('&');\n\nreturn [\n {\n json: {\n success: true,\n stage: 'nomina_ranges_preparados',\n spreadsheetId,\n sheets,\n ranges,\n rangesQuery,\n totalSheets: sheets.length,\n },\n },\n];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1472, + 384 + ], + "id": "b3f3c30b-533c-4bfd-8475-c62426592488", + "name": "Code - Preparar Ranges Nomina" + }, + { + "parameters": { + "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $json.spreadsheetId + '/values:batchGet?majorDimension=ROWS&valueRenderOption=UNFORMATTED_VALUE&dateTimeRenderOption=FORMATTED_STRING&' + $json.rangesQuery }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1680, + 384 + ], + "id": "afd4ca7f-9555-499b-a7d6-0e0023972842", + "name": "HTTP - Leer Pestañas Nomina", + "credentials": { + "googleOAuth2Api": { + "id": "eHseMeH39kRcXgOF", + "name": "Google account 2" + } + } + }, + { + "parameters": { + "jsCode": "// =====================================================\n// Code - Normalizar Excel Extraído\n// SIN require('xlsx')\n// Lee desde Merge - Excel Sin XLSX Listo:\n// - Bamboo Activo: rows directos del Extract\n// - Bamboo Additions/Terminations: valueRanges de Google Sheet temporal\n// - Nómina: valueRanges de Google Sheet temporal\n// - Banco: desde Code - Normalizar Banco CSV\n// =====================================================\n\nconst NODE_BANCO = 'Code - Normalizar Banco CSV';\n\nfunction cleanText(value) {\n return String(value ?? '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeText(value) {\n return cleanText(value)\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toUpperCase();\n}\n\nfunction sheetNameFromRange(range) {\n const raw = String(range || '').split('!')[0] || '';\n return raw\n .replace(/^'/, '')\n .replace(/'$/, '')\n .replace(/''/g, \"'\");\n}\n\nfunction makeUniqueHeaders(headers) {\n const counts = {};\n\n return headers.map((header, index) => {\n const base = cleanText(header) || `Columna_${index + 1}`;\n const key = normalizeText(base);\n\n counts[key] = (counts[key] || 0) + 1;\n\n if (counts[key] === 1) return base;\n return `${base}_${counts[key]}`;\n });\n}\n\nfunction hasAnyText(row, words) {\n const joined = (row || []).map(normalizeText).join(' | ');\n return words.some(word => joined.includes(normalizeText(word)));\n}\n\nfunction isNominaHeaderRow(row) {\n const hasName = hasAnyText(row, [\n 'NOMBRE',\n 'EMPLEADO',\n 'COLABORADOR',\n 'BENEFICIARIO',\n 'NOMBRE COMPLETO',\n ]);\n\n const hasAmount = hasAnyText(row, [\n 'MONTO',\n 'NETO',\n 'NETO A PAGAR',\n 'PAGAR',\n 'VALOR',\n 'TOTAL',\n 'SUELDO',\n 'PAGO',\n ]);\n\n const hasAccount = hasAnyText(row, [\n 'CUENTA',\n 'CTA',\n 'BANCO',\n 'CUENTA DESTINO',\n 'NUMERO DE CUENTA',\n 'NÚMERO DE CUENTA',\n ]);\n\n const hasProjectOrConcept = hasAnyText(row, [\n 'PROYECTO',\n 'CONCEPTO',\n 'FUENTE',\n 'CLIENTE',\n 'MARCA',\n 'PAIS',\n 'PAÍS',\n ]);\n\n return hasName && (hasAmount || hasAccount || hasProjectOrConcept);\n}\n\nfunction isBambooHeaderRow(row) {\n const hasEmployeeNumber = hasAnyText(row, [\n 'EMPLOYEE NUMBER',\n 'EMPLOYEE #',\n 'NUMERO DE EMPLEADO',\n 'NÚMERO DE EMPLEADO',\n 'NO. EMPLEADO',\n 'ID EMPLEADO',\n ]);\n\n const hasName = hasAnyText(row, [\n 'NAME',\n 'NOMBRE',\n 'EMPLOYEE',\n 'COLABORADOR',\n ]);\n\n const hasStatus = hasAnyText(row, [\n 'STATUS',\n 'ESTADO',\n 'EMPLOYMENT STATUS',\n ]);\n\n const hasDates = hasAnyText(row, [\n 'HIRE DATE',\n 'TERMINATION DATE',\n 'FECHA DE BAJA',\n 'FECHA DE INGRESO',\n 'FECHA TERMINACION',\n 'FECHA TERMINACIÓN',\n ]);\n\n const hasOrgFields = hasAnyText(row, [\n 'DEPARTMENT',\n 'LOCATION',\n 'JOB TITLE',\n 'DEPARTAMENTO',\n 'UBICACION',\n 'UBICACIÓN',\n 'PUESTO',\n 'DIVISION',\n 'DIVISIÓN',\n ]);\n\n return hasName && (hasEmployeeNumber || hasStatus || hasDates || hasOrgFields);\n}\n\nfunction rowsToObjects(values, sheetName, detectorFn) {\n if (!Array.isArray(values) || !values.length) return [];\n\n let headerIndex = -1;\n\n for (let i = 0; i < Math.min(values.length, 150); i++) {\n if (detectorFn(values[i] || [])) {\n headerIndex = i;\n break;\n }\n }\n\n if (headerIndex === -1) return [];\n\n const headers = makeUniqueHeaders(values[headerIndex] || []);\n const output = [];\n\n for (let i = headerIndex + 1; i < values.length; i++) {\n const row = values[i] || [];\n const hasData = row.some(cell => cleanText(cell) !== '');\n\n if (!hasData) continue;\n\n const obj = {\n Fuente: sheetName,\n __sheetName: sheetName,\n __rowNumber: i + 1,\n };\n\n headers.forEach((header, index) => {\n obj[header] = row[index] ?? '';\n });\n\n output.push(obj);\n }\n\n return output;\n}\n\nfunction getValue(row, names) {\n for (const name of names) {\n if (row[name] !== undefined && row[name] !== null && cleanText(row[name]) !== '') {\n return row[name];\n }\n }\n\n const normalizedMap = {};\n for (const key of Object.keys(row || {})) {\n normalizedMap[normalizeText(key)] = row[key];\n }\n\n for (const name of names) {\n const value = normalizedMap[normalizeText(name)];\n if (value !== undefined && value !== null && cleanText(value) !== '') {\n return value;\n }\n }\n\n return '';\n}\n\nfunction isMeaningfulBambooRow(row) {\n const name = getValue(row, [\n 'Name',\n 'Nombre',\n 'Employee',\n 'Employee Name',\n 'Nombre Completo',\n ]);\n\n const employeeNumber = getValue(row, [\n 'Employee Number',\n 'Employee #',\n 'Número de empleado',\n 'Numero de empleado',\n 'ID Empleado',\n ]);\n\n const status = getValue(row, [\n 'Status',\n 'Employment Status',\n 'Estado',\n ]);\n\n const hireDate = getValue(row, [\n 'Hire Date',\n 'Fecha de ingreso',\n ]);\n\n const terminationDate = getValue(row, [\n 'Termination Date',\n 'Fecha de baja',\n ]);\n\n return Boolean(\n cleanText(name) ||\n cleanText(employeeNumber) ||\n cleanText(status) ||\n cleanText(hireDate) ||\n cleanText(terminationDate)\n );\n}\n\nfunction isMeaningfulNominaRow(row) {\n const name = getValue(row, [\n 'Nombre',\n 'Empleado',\n 'Colaborador',\n 'Beneficiario',\n 'Nombre Completo',\n 'EmpleadoNomina',\n ]);\n\n const account = getValue(row, [\n 'Cuenta',\n 'Cuenta Nomina',\n 'Cuenta Nómina',\n 'Cuenta Destino',\n 'Número de cuenta',\n 'Numero de cuenta',\n 'CuentaNomina',\n ]);\n\n const amount = getValue(row, [\n 'Monto',\n 'Monto Nomina',\n 'Monto Nómina',\n 'Neto a pagar',\n 'NETO A PAGAR',\n 'Total',\n 'Valor',\n 'MontoNomina',\n ]);\n\n return Boolean(cleanText(name) || cleanText(account) || cleanText(amount));\n}\n\nfunction parseValueRangesToRows(data, detectorFn, rowFilterFn, sheetFilterFn) {\n const sheets = [];\n const rows = [];\n\n for (const valueRange of data.valueRanges || []) {\n const sheetName = sheetNameFromRange(valueRange.range);\n\n if (sheetFilterFn && !sheetFilterFn(sheetName)) {\n sheets.push({\n sheetName,\n totalRowsDetected: 0,\n skipped: true,\n });\n continue;\n }\n\n const detectedRows = rowsToObjects(valueRange.values || [], sheetName, detectorFn)\n .filter(rowFilterFn || (() => true));\n\n sheets.push({\n sheetName,\n totalRowsDetected: detectedRows.length,\n skipped: false,\n });\n\n rows.push(...detectedRows);\n }\n\n return { sheets, rows };\n}\n\nfunction isBambooAdditionsSheet(sheetName) {\n const name = normalizeText(sheetName);\n\n return (\n name.includes('ADDITION') ||\n name.includes('ADDITIONS') ||\n name.includes('TERMINATION') ||\n name.includes('TERMINATIONS') ||\n name.includes('ALTAS') ||\n name.includes('BAJAS')\n );\n}\n\nfunction isNominaDataSheet(sheetName) {\n const name = normalizeText(sheetName);\n\n // Evitar pestañas que claramente son cálculo/resumen.\n if (\n name.includes('CALCULO') ||\n name.includes('CALCULOS') ||\n name.includes('IGSS') ||\n name.includes('RESUMEN') ||\n name.includes('SUMMARY')\n ) {\n return false;\n }\n\n // Para nómina permitimos cualquier otra pestaña.\n // El detector de encabezados decide si tiene datos reales.\n return true;\n}\n\nfunction classifyValueRangeDataset(data) {\n const sheetNames = (data.valueRanges || []).map(vr => sheetNameFromRange(vr.range));\n const joined = sheetNames.map(normalizeText).join(' | ');\n\n if (\n joined.includes('ADDITION') ||\n joined.includes('TERMINATION') ||\n joined.includes('ALTAS') ||\n joined.includes('BAJAS')\n ) {\n return 'bamboo_additions';\n }\n\n return 'nomina';\n}\n\nfunction safeGetNodeItems(nodeName) {\n try {\n return $items(nodeName) || [];\n } catch (error) {\n return [];\n }\n}\n\n// =====================================================\n// 1) Input desde Merge - Excel Sin XLSX Listo\n// =====================================================\nconst inputItems = $input.all();\nconst inputJsons = inputItems.map(item => item.json || {});\n\nconst valueRangeItems = inputJsons.filter(j => Array.isArray(j.valueRanges));\n\nconst bambooActivoRows = inputJsons\n .filter(j => !Array.isArray(j.valueRanges))\n .filter(isMeaningfulBambooRow)\n .map(row => ({\n Fuente: row.Fuente || 'Bamboo Activo',\n ...row,\n }));\n\n// =====================================================\n// 2) Separar Bamboo Additions y Nómina\n// =====================================================\nlet bambooAdditionsTerminationsRows = [];\nlet nominaRows = [];\nlet bambooAdditionsSheets = [];\nlet nominaSheets = [];\n\nfor (const data of valueRangeItems) {\n const kind = classifyValueRangeDataset(data);\n\n if (kind === 'bamboo_additions') {\n const parsed = parseValueRangesToRows(\n data,\n isBambooHeaderRow,\n isMeaningfulBambooRow,\n isBambooAdditionsSheet\n );\n\n bambooAdditionsTerminationsRows.push(...parsed.rows);\n bambooAdditionsSheets.push(...parsed.sheets);\n } else {\n const parsed = parseValueRangesToRows(\n data,\n isNominaHeaderRow,\n isMeaningfulNominaRow,\n isNominaDataSheet\n );\n\n nominaRows.push(...parsed.rows);\n nominaSheets.push(...parsed.sheets);\n }\n}\n\n// =====================================================\n// 3) Banco desde nodo existente\n// =====================================================\nconst bancoItems = safeGetNodeItems(NODE_BANCO);\nconst bancoNodeItem = bancoItems[0] || {};\nconst bancoJson = bancoNodeItem.json || {};\n\nconst bancoRows =\n bancoJson.bancoRows ||\n bancoJson.rows ||\n bancoJson.data ||\n [];\n\n// =====================================================\n// 4) Salida final compatible con el cruce\n// =====================================================\nconst bambooRows = [\n ...bambooActivoRows,\n ...bambooAdditionsTerminationsRows,\n];\n\nreturn [\n {\n json: {\n success: true,\n stage: 'excel_normalization_sin_xlsx',\n message: 'Archivos Excel normalizados sin dependencia xlsx. Nómina y Bamboo Additions convertidos temporalmente a Google Sheets.',\n\n bambooActivoRows,\n bambooAdditionsTerminationsRows,\n bambooRows,\n nominaRows,\n bancoRows,\n\n bambooAdditionsSheets,\n nominaSheets,\n\n normalizationSummary: {\n totalBambooActivoRows: bambooActivoRows.length,\n totalBambooAdditionsTerminationsRows: bambooAdditionsTerminationsRows.length,\n totalBambooRows: bambooRows.length,\n totalNominaRowsEntrada: nominaRows.length,\n totalBancoRows: Array.isArray(bancoRows) ? bancoRows.length : 0,\n metodoBambooActivo: 'extract_from_file',\n metodoBambooAdditions: 'google_sheet_temporal',\n metodoNomina: 'google_sheet_temporal',\n usaXlsxRequire: false,\n },\n\n samples: {\n bambooActivo: bambooActivoRows.slice(0, 3),\n bambooAdditionsTerminations: bambooAdditionsTerminationsRows.slice(0, 5),\n nomina: nominaRows.slice(0, 5),\n banco: Array.isArray(bancoRows) ? bancoRows.slice(0, 5) : [],\n },\n },\n binary: bancoNodeItem.binary,\n },\n];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2320, + -48 + ], + "id": "3f7d542a-f2c4-4d15-b2b4-bc6cc4b1c42b", + "name": "Code - Normalizar Excel Extraído" + }, + { + "parameters": { + "inputDataFieldName": "bambooAdditionsTerminations", + "name": "={{ 'TEMP_Bamboo_Additions_' + $now.toFormat('yyyy-LL-dd_HH-mm-ss') + '.xlsx' }}", + "driveId": { + "__rl": true, + "mode": "list", + "value": "My Drive" + }, + "folderId": { + "__rl": true, + "mode": "list", + "value": "root", + "cachedResultName": "/ (Root folder)" + }, + "options": {} + }, + "type": "n8n-nodes-base.googleDrive", + "typeVersion": 3, + "position": [ + 848, + 48 + ], + "id": "faa96a4c-f7ca-401c-bb30-79e185953145", + "name": "Google Drive - Subir Bamboo Additions XLSX", + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + } + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'https://www.googleapis.com/drive/v3/files/' + $json.id + '/copy?fields=id,name,mimeType,webViewLink' }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ {\n name: 'TEMP_Bamboo_Additions_' + $now.toFormat('yyyy-LL-dd_HH-mm-ss'),\n mimeType: 'application/vnd.google-apps.spreadsheet'\n} }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1056, + 48 + ], + "id": "7512bb3e-96a7-4835-9159-b3e1b611d592", + "name": "HTTP - Convertir Bamboo Additions a Google Sheet", + "credentials": { + "googleOAuth2Api": { + "id": "eHseMeH39kRcXgOF", + "name": "Google account 2" + } + } + }, + { + "parameters": { + "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $json.id + '?fields=spreadsheetId,sheets(properties(sheetId,title,gridProperties(rowCount,columnCount)))' }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1248, + 48 + ], + "id": "9d473c18-5ad7-4a4a-8fcc-3c255054e296", + "name": "HTTP - Leer Metadata Bamboo Additions", + "credentials": { + "googleOAuth2Api": { + "id": "eHseMeH39kRcXgOF", + "name": "Google account 2" + } + } + }, + { + "parameters": { + "jsCode": "const meta = $input.first().json;\n\nconst spreadsheetId = meta.spreadsheetId;\n\nconst sheets = (meta.sheets || [])\n .map(s => {\n const props = s.properties || {};\n return {\n title: props.title || '',\n sheetId: props.sheetId,\n rowCount: props.gridProperties?.rowCount || 5000,\n columnCount: props.gridProperties?.columnCount || 80,\n };\n })\n .filter(s => s.title);\n\nfunction columnToLetter(column) {\n let temp = '';\n let letter = '';\n\n while (column > 0) {\n temp = (column - 1) % 26;\n letter = String.fromCharCode(temp + 65) + letter;\n column = (column - temp - 1) / 26;\n }\n\n return letter;\n}\n\nfunction escapeSheetName(name) {\n return String(name).replace(/'/g, \"''\");\n}\n\nconst ranges = sheets.map(sheet => {\n const lastCol = columnToLetter(Math.min(sheet.columnCount || 80, 120));\n const lastRow = Math.min(sheet.rowCount || 5000, 10000);\n\n return `'${escapeSheetName(sheet.title)}'!A1:${lastCol}${lastRow}`;\n});\n\nconst rangesQuery = ranges\n .map(range => 'ranges=' + encodeURIComponent(range))\n .join('&');\n\nreturn [\n {\n json: {\n success: true,\n stage: 'nomina_ranges_preparados',\n spreadsheetId,\n sheets,\n ranges,\n rangesQuery,\n totalSheets: sheets.length,\n },\n },\n];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1472, + 48 + ], + "id": "10ec56a7-4e74-4a76-a03c-ed453a65f7f3", + "name": "Code - Preparar Ranges Bamboo Additions" + }, + { + "parameters": { + "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $json.spreadsheetId + '/values:batchGet?majorDimension=ROWS&valueRenderOption=UNFORMATTED_VALUE&dateTimeRenderOption=FORMATTED_STRING&' + $json.rangesQuery }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1664, + 48 + ], + "id": "d0c67046-1308-4555-8c2d-1e256066ae58", + "name": "HTTP - Leer Pestañas Bamboo Additions", + "credentials": { + "googleOAuth2Api": { + "id": "eHseMeH39kRcXgOF", + "name": "Google account 2" + } + } + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 1904, + -48 + ], + "id": "84be7d30-f7c6-44fa-a5d9-3f8b21141b04", + "name": "Merge - Bamboo Activo + Additions" + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 2112, + -48 + ], + "id": "3aede4f2-11f8-42b4-8ff4-92b43fa455bb", + "name": "Merge - Excel Sin XLSX Listo" + }, + { + "parameters": { + "operation": "xls", + "binaryPropertyName": "bambooActivo", + "options": {} + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + 1328, + -160 + ], + "id": "a4e9a36b-132e-4845-8515-bad490d557c0", + "name": "Extract - Bamboo Activo" + }, + { + "parameters": { + "content": "RECEPCIÓN Y VALIDACIÓN DE ARCHIVOS\n\n\nRecibe Bamboo Activo, Bamboo Additions/Terminations, Nómina y archivos del banco.\n\nValida que lleguen los archivos requeridos antes de continuar.\n\nSi falta algún archivo obligatorio, el flujo responde error y no ejecuta el cruce.", + "height": 704, + "width": 1040, + "color": 5 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + -592, + -496 + ], + "id": "baf279b1-113a-4090-ad69-454afdbfc219", + "name": "Sticky Note" + }, + { + "parameters": { + "content": "NORMALIZACIÓN DE BANCO\n\nConvierte los archivos CSV del banco en filas normalizadas para el cruce.\n\nAquí se consolidan múltiples archivos bancarios en una sola estructura bancoRows.\n\nEl banco debe venir en CSV para producción.", + "height": 496, + "width": 288, + "color": "#314927" + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 480, + -432 + ], + "id": "091d0baf-2a59-4aa5-b919-24210d983f34", + "name": "Sticky Note1" + }, + { + "parameters": { + "content": "LECTURA DE EXCEL SIN DEPENDENCIA XLSX\n\nBloque nuevo de producción.\n\nEvita usar require('xlsx') en n8n.\n\nBamboo Activo se lee con Extract From File.\n\nBamboo Additions/Terminations y Nómina se suben temporalmente a Google Drive, se convierten a Google Sheets y se leen por pestañas.\n\nEsto permite procesar archivos Excel aunque la información no esté en la primera hoja.", + "height": 944, + "width": 1680 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 800, + -368 + ], + "id": "a11495f8-73bb-447c-8958-b185c04c0af4", + "name": "Sticky Note2" + }, + { + "parameters": { + "content": "EXCEPCIONES DE MONEDA\n\nLee la pestaña Excepciones_Moneda desde el Google Sheet fuente.\n\nColumnas requeridas:\nCuenta | Nombre | MonedaCorrecta | Comentario\n\nMonedaCorrecta debe ser USD o QTZ.\n\nEstas excepciones corrigen casos donde una cuenta debe tratarse con una moneda específica.", + "height": 624, + "width": 560, + "color": "#395C65" + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 2512, + -368 + ], + "id": "ac5801ec-9121-4c6e-8298-b10b9ca7a19e", + "name": "Sticky Note3" + }, + { + "parameters": { + "content": "MOTOR DE CRUCE DE CUENTAS\n\nEjecuta la comparación entre Bamboo, nómina, banco y excepciones de moneda.\n\nDetecta irregularidades, calcula prioridad, monto de riesgo y resumen ejecutivo.\n\nLa salida queda preparada para crear el Google Sheet final.", + "height": 368, + "width": 464, + "color": 6 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 3136, + -352 + ], + "id": "48795b60-df0f-4ec8-ae2d-fc3f9f683944", + "name": "Sticky Note4" + }, + { + "parameters": { + "content": "REPORTE FINAL EN GOOGLE SHEETS Y RESPUESTA\n\nCrea el Google Sheet final por corrida.\n\nIncluye pestañas de resumen, irregularidades y detalle.\n\nAplica formato visual al reporte y devuelve al frontend el resumen ejecutivo junto con el enlace del Google Sheet.", + "height": 416, + "width": 2128, + "color": 4 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 3648, + -336 + ], + "id": "d5df4c42-4593-4f9c-8b0f-40597beb14c3", + "name": "Sticky Note5" + } + ], + "connections": { + "Webhook - Recibir Archivos Cruce": { + "main": [ + [ + { + "node": "Code - Diagnosticar Binaries Webhook", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Diagnosticar Binaries Webhook": { + "main": [ + [ + { + "node": "Code - Validar Entrada", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Validar Entrada": { + "main": [ + [ + { + "node": "Code - Diagnosticar Binaries Post Validacion", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Diagnosticar Binaries Post Validacion": { + "main": [ + [ + { + "node": "IF - Validación OK", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Validación OK": { + "main": [ + [ + { + "node": "Code - Normalizar Banco CSV", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Respond - Error Validación", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Normalizar Banco CSV": { + "main": [ + [ + { + "node": "Google Drive - Subir Nómina XLSX", + "type": "main", + "index": 0 + }, + { + "node": "Google Drive - Subir Bamboo Additions XLSX", + "type": "main", + "index": 0 + }, + { + "node": "Extract - Bamboo Activo", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Ejecutar Cruce de Cuentas": { + "main": [ + [ + { + "node": "Code - Preparar Salida Final", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar Salida Final": { + "main": [ + [ + { + "node": "Create spreadsheet", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create spreadsheet": { + "main": [ + [ + { + "node": "Code - Preparar Google Sheets Batch", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar Google Sheets Batch": { + "main": [ + [ + { + "node": "HTTP - Crear Pestañas Google Sheets", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Crear Pestañas Google Sheets": { + "main": [ + [ + { + "node": "HTTP - Escribir Datos Google Sheets", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Escribir Datos Google Sheets": { + "main": [ + [ + { + "node": "Code - Preparar Respuesta con Link", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar Respuesta con Link": { + "main": [ + [ + { + "node": "HTTP - Leer Metadata Google Sheet", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Leer Metadata Google Sheet": { + "main": [ + [ + { + "node": "Code - Preparar Formato Google Sheets", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar Formato Google Sheets": { + "main": [ + [ + { + "node": "HTTP - Aplicar Formato Google Sheets", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Aplicar Formato Google Sheets": { + "main": [ + [ + { + "node": "Code - Respuesta Final", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Respuesta Final": { + "main": [ + [ + { + "node": "Respond - Éxito", + "type": "main", + "index": 0 + } + ] + ] + }, + "GS - Leer Excepciones Moneda": { + "main": [ + [ + { + "node": "Merge - Normalización + Excepciones", + "type": "main", + "index": 1 + } + ] + ] + }, + "Merge - Normalización + Excepciones": { + "main": [ + [ + { + "node": "Code - Ejecutar Cruce de Cuentas", + "type": "main", + "index": 0 + } + ] + ] + }, + "Google Drive - Subir Nómina XLSX": { + "main": [ + [ + { + "node": "HTTP - Convertir Nómina XLSX a Google Sheet", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Convertir Nómina XLSX a Google Sheet": { + "main": [ + [ + { + "node": "HTTP - Leer Metadata Nómina Temporal", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Leer Metadata Nómina Temporal": { + "main": [ + [ + { + "node": "Code - Preparar Ranges Nomina", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar Ranges Nomina": { + "main": [ + [ + { + "node": "HTTP - Leer Pestañas Nomina", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Leer Pestañas Nomina": { + "main": [ + [ + { + "node": "Merge - Excel Sin XLSX Listo", + "type": "main", + "index": 1 + } + ] + ] + }, + "Code - Normalizar Excel Extraído": { + "main": [ + [ + { + "node": "GS - Leer Excepciones Moneda", + "type": "main", + "index": 0 + }, + { + "node": "Merge - Normalización + Excepciones", + "type": "main", + "index": 0 + } + ] + ] + }, + "Google Drive - Subir Bamboo Additions XLSX": { + "main": [ + [ + { + "node": "HTTP - Convertir Bamboo Additions a Google Sheet", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Convertir Bamboo Additions a Google Sheet": { + "main": [ + [ + { + "node": "HTTP - Leer Metadata Bamboo Additions", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Leer Metadata Bamboo Additions": { + "main": [ + [ + { + "node": "Code - Preparar Ranges Bamboo Additions", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar Ranges Bamboo Additions": { + "main": [ + [ + { + "node": "HTTP - Leer Pestañas Bamboo Additions", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Leer Pestañas Bamboo Additions": { + "main": [ + [ + { + "node": "Merge - Bamboo Activo + Additions", + "type": "main", + "index": 1 + } + ] + ] + }, + "Merge - Bamboo Activo + Additions": { + "main": [ + [ + { + "node": "Merge - Excel Sin XLSX Listo", + "type": "main", + "index": 0 + } + ] + ] + }, + "Merge - Excel Sin XLSX Listo": { + "main": [ + [ + { + "node": "Code - Normalizar Excel Extraído", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract - Bamboo Activo": { + "main": [ + [ + { + "node": "Merge - Bamboo Activo + Additions", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate", + "timeSavedMode": "fixed", + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": false, + "errorWorkflow": "puF4LUczoSz3hcek", + "timezone": "America/Santo_Domingo" + }, + "staticData": null, + "meta": { + "templateCredsSetupCompleted": true + }, + "versionId": "2d1f998e-8d92-408d-94cb-1c4131f8e675", + "activeVersionId": "2d1f998e-8d92-408d-94cb-1c4131f8e675", + "versionCounter": 210, + "triggerCount": 1, + "shared": [ + { + "updatedAt": "2026-05-05T20:13:14.303Z", + "createdAt": "2026-05-05T20:13:14.303Z", + "role": "workflow:owner", + "workflowId": "8jCi4gPc5kfHZvk2", + "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-05-08T12:25:57.000Z", + "createdAt": "2026-05-07T21:05:15.530Z", + "versionId": "2d1f998e-8d92-408d-94cb-1c4131f8e675", + "workflowId": "8jCi4gPc5kfHZvk2", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "74a99fa2-c587-44ad-8762-bbda81891db0", + "responseMode": "responseNode", + "options": {} + }, + "id": "e4895765-699e-4087-8095-ebe4ae2aca5f", + "name": "Webhook - Recibir Archivos Cruce", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + -528, + -192 + ], + "webhookId": "74a99fa2-c587-44ad-8762-bbda81891db0" + }, + { + "parameters": { + "jsCode": "const item = $input.first();\nconst binaryKeys = Object.keys(item.binary || {});\nconst binaryInfo = binaryKeys.map(key => {\n const file = item.binary[key];\n return {\n key,\n fileName: file.fileName,\n mimeType: file.mimeType,\n fileExtension: file.fileExtension,\n fileSize: file.fileSize\n };\n});\n\nreturn [{\n json: {\n ...item.json,\n binaryDiagnosticsWebhook: {\n success: true,\n stage: \"binary_diagnostico_webhook\",\n binaryKeys,\n binaryInfo,\n jsonKeys: Object.keys(item.json || {})\n }\n },\n binary: item.binary\n}];" + }, + "id": "6036307c-557e-4e82-9539-265332fa8dde", + "name": "Code - Diagnosticar Binaries Webhook", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -320, + -192 + ] + }, + { + "parameters": { + "jsCode": "const item = $input.first();\nconst body = item.json?.body || {};\nconst binary = item.binary || {};\n\nconst errors = [];\n\nconst camposRequeridos = ['pais', 'mes', 'anio', 'tipoCruce'];\nfor (const campo of camposRequeridos) {\n const valor = body[campo];\n if (!valor || String(valor).trim() === '') {\n errors.push(`Campo requerido faltante: ${campo}`);\n }\n}\n\nconst tipoCruce = String(body.tipoCruce || '').trim();\nconst quincena = String(body.quincena || '').trim();\nif (tipoCruce.toLowerCase() === 'quincenal') {\n if (!quincena || (quincena !== '15' && quincena !== '30')) {\n errors.push('Campo requerido faltante o inválido: quincena');\n }\n}\n\nconst binaryKeys = Object.keys(binary);\nfunction findFile(fieldName) {\n for (const key of binaryKeys) {\n const file = binary[key];\n const keyLower = key.toLowerCase();\n const fieldLower = fieldName.toLowerCase();\n if (keyLower === fieldLower || keyLower.startsWith(fieldLower)) return { key, file };\n const fileName = String(file?.fileName || '').toLowerCase();\n if (fileName && keyLower.includes(fieldLower)) return { key, file };\n }\n return null;\n}\nfunction findFiles(fieldPrefix) {\n const results = [];\n const prefixLower = fieldPrefix.toLowerCase();\n for (const key of binaryKeys) {\n const keyLower = key.toLowerCase();\n if (keyLower === prefixLower || keyLower.startsWith(prefixLower)) {\n results.push({ key, file: binary[key] });\n }\n }\n return results;\n}\nfunction validateExtension(file, allowedExtensions, label) {\n const fileName = String(file?.fileName || '').toLowerCase();\n const ext = fileName.split('.').pop();\n if (!allowedExtensions.includes(ext)) {\n errors.push(`Archivo ${label} tiene extensión .${ext}, se esperaba: ${allowedExtensions.join(', ')}`);\n return false;\n }\n return true;\n}\n\nconst bambooActivo = findFile('bambooActivo');\nif (!bambooActivo) errors.push('Archivo requerido faltante: bambooActivo');\nelse validateExtension(bambooActivo.file, ['xlsx', 'xls'], 'bambooActivo');\n\nconst bambooAT = findFile('bambooAdditionsTerminations');\nif (!bambooAT) errors.push('Archivo requerido faltante: bambooAdditionsTerminations');\nelse validateExtension(bambooAT.file, ['xlsx', 'xls'], 'bambooAdditionsTerminations');\n\nconst nominaExcel = findFile('nominaExcel');\nif (!nominaExcel) errors.push('Archivo requerido faltante: nominaExcel');\nelse validateExtension(nominaExcel.file, ['xlsx', 'xls'], 'nominaExcel');\n\nlet bancoFiles = findFiles('bancoFiles');\nif (bancoFiles.length === 0) bancoFiles = findFiles('banco');\nif (bancoFiles.length === 0) errors.push('Archivo requerido faltante: bancoFiles');\nfor (const bf of bancoFiles) validateExtension(bf.file, ['csv'], `bancoFiles`);\n\nif (errors.length > 0) return [{\n json: { \n success: false, \n stage: 'validacion', \n errors,\n binaryDiagnosticsWebhook: item.json.binaryDiagnosticsWebhook\n }, \n binary: item.binary \n}];\n\nlet cruceSuffix = tipoCruce;\nif (tipoCruce.toLowerCase() === 'quincenal' && quincena) cruceSuffix += ' ' + quincena;\n\nreturn [{\n json: {\n success: true,\n stage: 'validacion',\n message: 'Archivos recibidos correctamente',\n received: {\n pais: String(body.pais || '').trim(),\n mes: String(body.mes || '').trim(),\n anio: String(body.anio || '').trim(),\n tipoCruce: String(body.tipoCruce || '').trim(),\n quincena: quincena || 'N/A',\n cruceSuffix: cruceSuffix,\n userEmail: String(body.userEmail || '').trim(),\n bambooActivo: bambooActivo.file?.fileName,\n bambooAdditionsTerminations: bambooAT.file?.fileName,\n nominaExcel: nominaExcel.file?.fileName,\n bancoFilesCount: bancoFiles.length,\n bancoFiles: bancoFiles.map(bf => bf.file?.fileName)\n },\n binaryDiagnosticsWebhook: item.json.binaryDiagnosticsWebhook\n },\n binary: item.binary\n}];" + }, + "id": "69fd5251-e3fe-47fc-9059-2d9693de003e", + "name": "Code - Validar Entrada", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -128, + -192 + ] + }, + { + "parameters": { + "jsCode": "const item = $input.first();\nconst binaryKeys = Object.keys(item.binary || {});\nconst binaryInfo = binaryKeys.map(key => {\n const file = item.binary[key];\n return {\n key,\n fileName: file.fileName,\n mimeType: file.mimeType,\n fileExtension: file.fileExtension,\n fileSize: file.fileSize\n };\n});\n\nreturn [{\n json: {\n ...item.json,\n binaryDiagnosticsPostValidacion: {\n success: true,\n stage: \"binary_diagnostico_post_validacion\",\n binaryKeys,\n binaryInfo,\n jsonKeys: Object.keys(item.json || {})\n }\n },\n binary: item.binary\n}];" + }, + "id": "f3361997-a254-4e79-bc44-f48bca340758", + "name": "Code - Diagnosticar Binaries Post Validacion", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 80, + -192 + ] + }, + { + "parameters": { + "conditions": { + "combinator": "and", + "conditions": [ + { + "id": "check-success", + "leftValue": "={{$json.success}}", + "operator": { + "operation": "equals", + "type": "boolean" + }, + "rightValue": true + } + ], + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 2 + } + }, + "options": {} + }, + "id": "5ff190a6-06fc-4e32-bd72-88b591b6791e", + "name": "IF - Validación OK", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [ + 272, + -192 + ] + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": { + "responseCode": 400 + } + }, + "id": "d62412d5-793a-4ee3-b6bd-d4847ac69568", + "name": "Respond - Error Validación", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 272, + 0 + ] + }, + { + "parameters": { + "jsCode": "const item = $input.first();\nconst binaryKeys = Object.keys(item.binary || {});\n\nif (binaryKeys.length === 0) {\n return [{\n json: {\n success: false,\n stage: \"binary_missing\",\n error: \"El nodo de normalización no recibió archivos binarios\",\n availableJsonKeys: Object.keys(item.json || {}),\n binaryKeys: []\n },\n binary: item.binary\n }];\n}\n\nconst bancoRows = [];\nconst logs = [];\nlet bancoFilesDetectados = 0;\nlet bancoFilesProcesados = 0;\nlet bancoFilesConError = 0;\n\nfunction decodeBuffer(buffer) {\n const utf8 = buffer.toString('utf8');\n\n // Caracter de reemplazo que aparece cuando el encoding no fue leído bien.\n const badChars = (utf8.match(/\\uFFFD/g) || []).length;\n\n if (badChars > 0) {\n if (typeof TextDecoder !== 'undefined') {\n try {\n return {\n text: new TextDecoder('windows-1252').decode(buffer),\n encoding: 'windows-1252'\n };\n } catch (e) {\n return {\n text: buffer.toString('latin1'),\n encoding: 'latin1'\n };\n }\n }\n\n return {\n text: buffer.toString('latin1'),\n encoding: 'latin1'\n };\n }\n\n return {\n text: utf8.replace(/^\\uFEFF/, ''),\n encoding: 'utf-8'\n };\n}\n\nfunction parseCsvLine(line, delimiter = ',') {\n const cells = [];\n let cell = '';\n let inQuotes = false;\n\n for (let i = 0; i < line.length; i++) {\n const char = line[i];\n const next = line[i + 1];\n\n if (char === '\"') {\n if (inQuotes && next === '\"') {\n cell += '\"';\n i++;\n } else {\n inQuotes = !inQuotes;\n }\n continue;\n }\n\n if (char === delimiter && !inQuotes) {\n cells.push(cell.trim());\n cell = '';\n continue;\n }\n\n cell += char;\n }\n\n cells.push(cell.trim());\n\n return cells.map(c => String(c || '').replace(/^\"|\"$/g, '').trim());\n}\n\nfunction normalizeLoose(value) {\n return String(value || '')\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/\\uFFFD/g, '')\n .toUpperCase()\n .replace(/[^A-Z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction isBancoFile(key, file) {\n const keyLower = String(key || '').toLowerCase();\n const fileName = String(file?.fileName || '').toLowerCase();\n const mimeType = String(file?.mimeType || '').toLowerCase();\n\n return (\n keyLower.startsWith('bancofiles') ||\n keyLower.includes('banco') ||\n fileName.endsWith('.csv') ||\n mimeType.includes('csv') ||\n mimeType.includes('text')\n );\n}\n\nfor (const key of binaryKeys) {\n const file = item.binary[key];\n\n if (!isBancoFile(key, file)) {\n continue;\n }\n\n bancoFilesDetectados++;\n\n try {\n const buffer = await this.helpers.getBinaryDataBuffer(0, key);\n const decoded = decodeBuffer(buffer);\n\n const lines = decoded.text\n .replace(/\\r\\n/g, '\\n')\n .replace(/\\r/g, '\\n')\n .split('\\n')\n .map(line => line.trim())\n .filter(line => line !== '');\n\n const delimiter = ',';\n const rows = lines.map(line => parseCsvLine(line, delimiter));\n\n const transactionMarkerIndex = rows.findIndex(row => {\n const text = normalizeLoose(row.join(' '));\n return text.includes('TRANSACCIONES') && text.includes('ENV');\n });\n\n const headerIndex = rows.findIndex((row, idx) => {\n if (transactionMarkerIndex !== -1 && idx <= transactionMarkerIndex) {\n return false;\n }\n\n const text = normalizeLoose(row.join(' '));\n\n return (\n text.includes('REFERENCIA') &&\n text.includes('CUENTA DESTINO') &&\n text.includes('NOMBRE EN ARCHIVO') &&\n text.includes('NOMBRE DEL CUENTAHABIENTE') &&\n text.includes('MONTO')\n );\n });\n\n let filasExtraidas = 0;\n let filasExcluidasProveedor = 0;\n let filasIgnoradas = 0;\n let warningsMonto = 0;\n\n if (headerIndex !== -1) {\n for (let j = headerIndex + 1; j < rows.length; j++) {\n const row = rows[j];\n\n if (!row || row.length < 7) {\n filasIgnoradas++;\n continue;\n }\n\n const cuenta = String(row[0] || '').trim();\n const numeroPlan = String(row[1] || '').trim();\n const referencia = String(row[2] || '').trim();\n const cuentaDestino = String(row[3] || '').trim();\n const nombreArchivo = String(row[4] || '').trim();\n const nombreCuentahabiente = String(row[5] || '').trim();\n const monto = String(row[6] || '').trim();\n const fechaAplicacion = String(row[7] || '').trim();\n const factura = String(row[8] || '').trim();\n const concepto = String(row[9] || '').trim();\n const estado = String(row[10] || '').trim();\n\n if (!cuenta && !referencia && !cuentaDestino && !nombreArchivo && !nombreCuentahabiente && !monto) {\n filasIgnoradas++;\n continue;\n }\n\n if (!monto) {\n warningsMonto++;\n filasIgnoradas++;\n continue;\n }\n\n const conceptoNormalizado = normalizeLoose(concepto);\n\n // Regla validada: NO contar pagos a proveedores en el cruce de nómina.\n if (\n conceptoNormalizado.includes('PAGO PRO') ||\n conceptoNormalizado.includes('PAGO A PRO') ||\n conceptoNormalizado.includes('PROVEEDOR') ||\n conceptoNormalizado.includes('PROVEEDORES')\n ) {\n filasExcluidasProveedor++;\n continue;\n }\n\n bancoRows.push({\n Cuenta: cuenta,\n 'Número de plan': numeroPlan,\n Referencia: referencia,\n 'Cuenta Destino': cuentaDestino,\n 'Nombre en Archivo': nombreArchivo,\n 'Nombre del Cuentahabiente': nombreCuentahabiente,\n Monto: monto,\n 'Fecha/Hora de aplicación': fechaAplicacion,\n Factura: factura,\n Concepto: concepto,\n Estado: estado,\n ArchivoOrigen: file.fileName || key\n });\n\n filasExtraidas++;\n }\n } else {\n bancoFilesConError++;\n\n logs.push({\n tipo: 'Error',\n archivo: file.fileName || key,\n binaryKey: key,\n mensaje: 'No se encontró la fila de encabezados reales del bloque Transacciones del envío.',\n transactionMarkerIndex,\n headerIndex,\n encoding: decoded.encoding,\n firstParsedRows: rows.slice(0, 8)\n });\n\n continue;\n }\n\n bancoFilesProcesados++;\n\n logs.push({\n tipo: 'Info',\n archivo: file.fileName || key,\n binaryKey: key,\n encoding: decoded.encoding,\n delimiter,\n totalLines: lines.length,\n totalRowsParsed: rows.length,\n transactionMarkerIndex,\n headerIndex,\n filasExtraidas,\n filasExcluidasProveedor,\n filasIgnoradas,\n warningsMonto,\n headerDetected: rows[headerIndex],\n firstParsedRows: rows.slice(0, 5),\n firstBancoRows: bancoRows.slice(Math.max(0, bancoRows.length - Math.min(filasExtraidas, 3)))\n });\n\n } catch (err) {\n bancoFilesConError++;\n\n logs.push({\n tipo: 'Error',\n archivo: file.fileName || key,\n binaryKey: key,\n mensaje: 'Fallo al parsear CSV: ' + err.message\n });\n }\n}\n\nreturn [{\n json: {\n ...item.json,\n success: true,\n stage: \"banco_line_parser_fix\",\n bancoRows,\n normalizationSummary: {\n ...(item.json.normalizationSummary || {}),\n bancoFilesDetectados,\n bancoFilesProcesados,\n bancoFilesConError,\n bancoRows: bancoRows.length,\n logsCount: logs.length\n },\n samples: {\n ...(item.json.samples || {}),\n bancoRows: bancoRows.slice(0, 10),\n bancoLogs: logs\n }\n },\n binary: item.binary\n}];" + }, + "id": "01c73741-088d-4249-a6ad-a5711517d867", + "name": "Code - Normalizar Banco CSV", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 560, + -176 + ] + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": { + "responseCode": 200 + } + }, + "id": "52b5b0b1-573f-4168-a7ed-cb302ff5967c", + "name": "Respond - Éxito", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 5552, + -160 + ] + }, + { + "parameters": { + "jsCode": "// Code node: Code - Ejecutar Cruce de Cuentas\n// IMPORTANTE EN n8n:\n// - Mode: Run Once for All Items\n// - Este nodo debe recibir:\n// 1) El item normalizado con bambooActivoRows, bambooAdditionsTerminationsRows, nominaRows y bancoRows.\n// 2) Las filas de Google Sheets de la pestaña Excepciones_Moneda, entrando por un Merge en modo Append.\n//\n// Estructura esperada para Excepciones_Moneda:\n// Cuenta | Nombre | MonedaCorrecta | Comentario\n\nconst FECHA_REVISION = new Date().toISOString();\nconst inputItems = $input.all();\n\nfunction normalizeText(value) {\n return String(value ?? '')\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toUpperCase()\n .replace(/[^A-Z0-9,\\s]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeKey(value) {\n return normalizeText(value).replace(/,/g, '');\n}\n\nfunction getValue(row, possibleKeys) {\n const keys = Object.keys(row || {});\n const normalizedMap = {};\n\n for (const key of keys) {\n normalizedMap[normalizeKey(key)] = key;\n }\n\n for (const key of possibleKeys) {\n if (row && row[key] !== undefined && row[key] !== null && String(row[key]).trim() !== '') {\n return row[key];\n }\n\n const normalizedKey = normalizeKey(key);\n\n if (normalizedMap[normalizedKey] !== undefined) {\n const realKey = normalizedMap[normalizedKey];\n const value = row[realKey];\n\n if (value !== undefined && value !== null && String(value).trim() !== '') {\n return value;\n }\n }\n }\n\n return '';\n}\n\nfunction accountKey(value) {\n return String(value ?? '').replace(/\\D/g, '').trim();\n}\n\nfunction parseCurrency(value) {\n const text = normalizeText(value);\n\n if (text.includes('USD') || text.includes('DOLAR') || text.includes('DOLLAR') || text.includes('$')) return 'USD';\n if (text.includes('QTZ') || text.includes('QUETZAL')) return 'QTZ';\n\n return '';\n}\n\nfunction parseAmount(value) {\n if (value === null || value === undefined || value === '') return null;\n if (typeof value === 'number') return value;\n\n const text = String(value);\n const match = text.match(/-?\\d[\\d,]*\\.?\\d*/);\n\n if (!match) return null;\n\n const amount = Number(match[0].replace(/,/g, ''));\n\n if (Number.isNaN(amount)) return null;\n\n return amount;\n}\n\nfunction round2(value) {\n return Number(Number(value || 0).toFixed(2));\n}\n\n// ============================================================================\n// 1) ENTRADAS DESDE MERGE\n// ============================================================================\n\nconst normalizedItem =\n inputItems.find(i =>\n Array.isArray(i.json?.bambooActivoRows) ||\n Array.isArray(i.json?.bambooAdditionsTerminationsRows) ||\n Array.isArray(i.json?.nominaRows) ||\n Array.isArray(i.json?.bancoRows)\n ) || inputItems[0] || { json: {} };\n\nconst exceptionItems = inputItems.filter(i => i !== normalizedItem);\n\nconst bambooActivoRows = normalizedItem.json.bambooActivoRows || [];\nconst bambooAdditionsTerminationsRows = normalizedItem.json.bambooAdditionsTerminationsRows || [];\nconst bambooInputRows = [...bambooActivoRows, ...bambooAdditionsTerminationsRows];\n\nconst nominaInputRows = normalizedItem.json.nominaRows || [];\nconst bancoInputRows = normalizedItem.json.bancoRows || [];\n\n// Excepciones leídas desde Google Sheets.\n// Si no existen o vienen vacías, el flujo sigue funcionando con moneda detectada / QTZ.\nconst excepcionesRows = exceptionItems\n .map(item => item.json || {})\n .filter(row => {\n const cuenta = accountKey(getValue(row, ['Cuenta']));\n const moneda = parseCurrency(getValue(row, ['MonedaCorrecta', 'Moneda Correcta', 'Moneda']));\n return cuenta && moneda;\n });\n\nconst TOLERANCIA_REDONDEO_OK = 0.50;\nconst TOLERANCIA_DIFERENCIA_MENOR = 5.00;\n\nfunction normalizeText(value) {\n return String(value ?? '')\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toUpperCase()\n .replace(/[^A-Z0-9,\\s]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeKey(value) {\n return normalizeText(value).replace(/,/g, '');\n}\n\nfunction canonicalName(value) {\n const text = normalizeText(value);\n\n if (text.includes(',')) {\n const parts = text.split(',').map(p => p.trim()).filter(Boolean);\n\n if (parts.length >= 2) {\n return `${parts.slice(1).join(' ')} ${parts[0]}`.trim();\n }\n }\n\n return text;\n}\n\nfunction getValue(row, possibleKeys) {\n const keys = Object.keys(row || {});\n const normalizedMap = {};\n\n for (const key of keys) {\n normalizedMap[normalizeKey(key)] = key;\n }\n\n for (const key of possibleKeys) {\n if (row && row[key] !== undefined && row[key] !== null && String(row[key]).trim() !== '') {\n return row[key];\n }\n\n const normalizedKey = normalizeKey(key);\n\n if (normalizedMap[normalizedKey] !== undefined) {\n const realKey = normalizedMap[normalizedKey];\n const value = row[realKey];\n\n if (value !== undefined && value !== null && String(value).trim() !== '') {\n return value;\n }\n }\n }\n\n return '';\n}\n\nfunction accountKey(value) {\n return String(value ?? '').replace(/\\D/g, '').trim();\n}\n\nfunction parseAmount(value) {\n if (value === null || value === undefined || value === '') return null;\n if (typeof value === 'number') return value;\n\n const text = String(value);\n const match = text.match(/-?\\d[\\d,]*\\.?\\d*/);\n\n if (!match) return null;\n\n const amount = Number(match[0].replace(/,/g, ''));\n\n if (Number.isNaN(amount)) return null;\n\n return amount;\n}\n\nfunction parseCurrency(value) {\n const text = normalizeText(value);\n\n if (text.includes('USD') || text.includes('DOLAR') || text.includes('DOLLAR') || text.includes('$')) return 'USD';\n if (text.includes('QTZ') || text.includes('QUETZAL')) return 'QTZ';\n\n return '';\n}\n\nfunction round2(value) {\n return Number(Number(value || 0).toFixed(2));\n}\n\nconst STOPWORDS = new Set([\n 'DE',\n 'DEL',\n 'LA',\n 'LAS',\n 'LOS',\n 'Y',\n 'DA',\n 'DO',\n 'DAS',\n 'DOS',\n]);\n\nfunction nameTokens(name) {\n return canonicalName(name)\n .replace(/,/g, ' ')\n .split(' ')\n .filter(t => t && !STOPWORDS.has(t));\n}\n\nfunction limitedDistance(a, b, maxDistance) {\n a = String(a ?? '');\n b = String(b ?? '');\n\n if (Math.abs(a.length - b.length) > maxDistance) return maxDistance + 1;\n\n const previous = [];\n\n for (let j = 0; j <= b.length; j++) previous[j] = j;\n\n for (let i = 1; i <= a.length; i++) {\n const current = [i];\n let rowMin = current[0];\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 previous[j] + 1,\n current[j - 1] + 1,\n previous[j - 1] + cost\n );\n\n if (current[j] < rowMin) rowMin = current[j];\n }\n\n if (rowMin > maxDistance) return maxDistance + 1;\n\n for (let j = 0; j <= b.length; j++) previous[j] = current[j];\n }\n\n return previous[b.length];\n}\n\nfunction tokenClose(a, b) {\n if (!a || !b) return false;\n if (a === b) return true;\n\n if (a.length === 1 && b.startsWith(a)) return true;\n if (b.length === 1 && a.startsWith(b)) return true;\n\n if (a.length >= 4 && b.length >= 4) {\n if (a.startsWith(b) || b.startsWith(a)) return true;\n\n const prefixA = a.slice(0, 4);\n const prefixB = b.slice(0, 4);\n\n if (prefixA === prefixB) return true;\n }\n\n if (a.length >= 5 && b.length >= 5) {\n return limitedDistance(a, b, 1) <= 1;\n }\n\n return false;\n}\n\nconst nameScoreCache = new Map();\n\nfunction nameScore(nameA, nameB) {\n const cacheKey = `${normalizeKey(nameA)}||${normalizeKey(nameB)}`;\n\n if (nameScoreCache.has(cacheKey)) return nameScoreCache.get(cacheKey);\n\n const aTokens = nameTokens(nameA);\n const bTokens = nameTokens(nameB);\n\n if (!aTokens.length || !bTokens.length) {\n nameScoreCache.set(cacheKey, 0);\n return 0;\n }\n\n const usedB = new Set();\n let matches = 0;\n\n for (const a of aTokens) {\n let bestIndex = -1;\n\n for (let i = 0; i < bTokens.length; i++) {\n if (usedB.has(i)) continue;\n\n const b = bTokens[i];\n\n if (a === b) {\n bestIndex = i;\n break;\n }\n\n if (bestIndex < 0 && tokenClose(a, b)) {\n bestIndex = i;\n }\n }\n\n if (bestIndex >= 0) {\n matches++;\n usedB.add(bestIndex);\n }\n }\n\n const coverageSmall = matches / Math.max(1, Math.min(aTokens.length, bTokens.length));\n const coverageLarge = matches / Math.max(1, Math.max(aTokens.length, bTokens.length));\n const score = Number((0.75 * coverageSmall + 0.25 * coverageLarge).toFixed(4));\n\n nameScoreCache.set(cacheKey, score);\n\n return score;\n}\n\nfunction firstNameCompatible(sourceName, targetName) {\n const sourceTokens = nameTokens(sourceName);\n const targetTokens = nameTokens(targetName);\n\n if (!sourceTokens.length || !targetTokens.length) return false;\n\n const first = sourceTokens[0];\n\n return targetTokens.some(t => tokenClose(first, t));\n}\n\nfunction exactOverlapCount(nameA, nameB) {\n const aTokens = [...new Set(nameTokens(nameA))];\n const bTokens = [...new Set(nameTokens(nameB))];\n const bSet = new Set(bTokens);\n\n return aTokens.filter(t => bSet.has(t)).length;\n}\n\nfunction fuzzyOverlapCount(nameA, nameB) {\n const aTokens = nameTokens(nameA);\n const bTokens = nameTokens(nameB);\n const used = new Set();\n let count = 0;\n\n for (const a of aTokens) {\n let found = -1;\n\n for (let i = 0; i < bTokens.length; i++) {\n if (used.has(i)) continue;\n\n if (tokenClose(a, bTokens[i])) {\n found = i;\n break;\n }\n }\n\n if (found >= 0) {\n used.add(found);\n count++;\n }\n }\n\n return count;\n}\n\nconst strongMatchCache = new Map();\n\nfunction strongPersonMatch(nameA, nameB) {\n const cacheKey = `${normalizeKey(nameA)}||${normalizeKey(nameB)}`;\n\n if (strongMatchCache.has(cacheKey)) return strongMatchCache.get(cacheKey);\n\n const aTokens = nameTokens(nameA);\n const bTokens = nameTokens(nameB);\n\n if (!aTokens.length || !bTokens.length) {\n const result = {\n ok: false,\n score: 0,\n exactOverlap: 0,\n fuzzyOverlap: 0,\n firstOk: false,\n reason: 'sin_tokens',\n };\n\n strongMatchCache.set(cacheKey, result);\n return result;\n }\n\n const score = nameScore(nameA, nameB);\n const exactOverlap = exactOverlapCount(nameA, nameB);\n const fuzzyOverlap = fuzzyOverlapCount(nameA, nameB);\n const firstOk = firstNameCompatible(nameA, nameB);\n const aLen = aTokens.length;\n const bLen = bTokens.length;\n const minLen = Math.min(aLen, bLen);\n\n let result;\n\n if (aLen <= 2 && bLen >= 4) {\n result = {\n ok: false,\n score,\n exactOverlap,\n fuzzyOverlap,\n firstOk,\n reason: 'source_short_vs_target_very_long',\n };\n\n strongMatchCache.set(cacheKey, result);\n return result;\n }\n\n if (aLen <= 2 && bLen === 3) {\n const ok = exactOverlap >= 2 && score >= 0.82;\n\n result = {\n ok,\n score,\n exactOverlap,\n fuzzyOverlap,\n firstOk,\n reason: ok ? 'source_short_target_3_exact_overlap' : 'source_short_target_3_not_enough_exact_overlap',\n };\n\n strongMatchCache.set(cacheKey, result);\n return result;\n }\n\n if (bLen <= 2 && aLen >= 4) {\n result = {\n ok: false,\n score,\n exactOverlap,\n fuzzyOverlap,\n firstOk,\n reason: 'source_long_vs_target_short_requires_review',\n };\n\n strongMatchCache.set(cacheKey, result);\n return result;\n }\n\n if (minLen === 1) {\n result = {\n ok: score >= 1 && exactOverlap >= 1,\n score,\n exactOverlap,\n fuzzyOverlap,\n firstOk,\n reason: 'una_palabra',\n };\n\n strongMatchCache.set(cacheKey, result);\n return result;\n }\n\n if (minLen <= 2) {\n const ok = exactOverlap >= 2 || (firstOk && fuzzyOverlap >= 2 && score >= 0.88);\n\n result = {\n ok,\n score,\n exactOverlap,\n fuzzyOverlap,\n firstOk,\n reason: ok ? 'nombre_corto_con_dos_tokens_confiables' : 'nombre_corto_no_confiable',\n };\n\n strongMatchCache.set(cacheKey, result);\n return result;\n }\n\n if (exactOverlap >= 3 && score >= 0.80) {\n result = {\n ok: true,\n score,\n exactOverlap,\n fuzzyOverlap,\n firstOk,\n reason: 'tres_tokens_exactos',\n };\n\n strongMatchCache.set(cacheKey, result);\n return result;\n }\n\n if (fuzzyOverlap >= 3 && score >= 0.88) {\n result = {\n ok: true,\n score,\n exactOverlap,\n fuzzyOverlap,\n firstOk,\n reason: 'tres_tokens_fuzzy',\n };\n\n strongMatchCache.set(cacheKey, result);\n return result;\n }\n\n if (firstOk && exactOverlap >= 2 && score >= 0.82) {\n result = {\n ok: true,\n score,\n exactOverlap,\n fuzzyOverlap,\n firstOk,\n reason: 'primer_nombre_y_dos_tokens',\n };\n\n strongMatchCache.set(cacheKey, result);\n return result;\n }\n\n if (score >= 0.94 && fuzzyOverlap >= Math.min(3, minLen)) {\n result = {\n ok: true,\n score,\n exactOverlap,\n fuzzyOverlap,\n firstOk,\n reason: 'score_muy_alto',\n };\n\n strongMatchCache.set(cacheKey, result);\n return result;\n }\n\n result = {\n ok: false,\n score,\n exactOverlap,\n fuzzyOverlap,\n firstOk,\n reason: 'no_supera_regla_estricta',\n };\n\n strongMatchCache.set(cacheKey, result);\n return result;\n}\n\nfunction isRealValue(value) {\n const text = normalizeKey(value);\n\n return text && text !== '-' && text !== 'N A' && text !== 'NA' && text !== 'NULL';\n}\n\nfunction isInactiveBamboo(row) {\n const status = normalizeKey(row.status);\n const terminationDate = normalizeKey(row.terminationDate);\n\n if (\n status.includes('TERMINATED') ||\n status.includes('INACTIVE') ||\n status.includes('INACTIVO') ||\n status.includes('BAJA') ||\n status.includes('TERMINADO')\n ) {\n return true;\n }\n\n if (isRealValue(terminationDate)) return true;\n\n return false;\n}\n\nfunction isAdjustmentOrCopy(row) {\n const fuente = normalizeKey(row.fuente);\n const concepto = normalizeKey(row.concepto);\n const sourceSheet = normalizeKey(row.sourceSheet);\n\n return (\n fuente.includes('AJUSTE') ||\n fuente.includes('COPIA') ||\n concepto.includes('AJUSTE') ||\n concepto.includes('CORRECCION') ||\n sourceSheet.includes('AJUSTE') ||\n sourceSheet.includes('COPIA')\n );\n}\n\nfunction bankConceptIsAllowed(row) {\n const concepto = normalizeKey(row.concepto);\n\n if (!concepto) return true;\n\n if (\n concepto.includes('PAGO PRO') ||\n concepto.includes('PAGO A PRO') ||\n concepto.includes('PROVEEDOR') ||\n concepto.includes('PROVEEDORES')\n ) {\n return false;\n }\n\n if (concepto.includes('PLANILLA')) return true;\n\n return false;\n}\n\nfunction isOperationalText(value) {\n const text = normalizeKey(value);\n\n if (!text) return false;\n\n return (\n text.includes('COMBUSTIBLE') ||\n text.includes('TARJETA') ||\n text.includes('VIATICO') ||\n text.includes('VIATICOS') ||\n text.includes('MOVILIDAD') ||\n text.includes('MOVIL') ||\n text.includes('REEMBOLSO') ||\n text.includes('REEMBOLSOS') ||\n text.includes('ASIGNADO') ||\n text.includes('ASIGNACION') ||\n text.includes('ASIGNACIONES') ||\n text.includes('GASOLINA') ||\n text.includes('PARQUEO') ||\n text.includes('PEAJE') ||\n text.includes('KILOMETRAJE') ||\n text.includes('KM')\n );\n}\n\nfunction isOperationalNominaRow(row) {\n return (\n isOperationalText(row.fuente) ||\n isOperationalText(row.proyecto) ||\n isOperationalText(row.concepto) ||\n isOperationalText(row.sourceSheet) ||\n isOperationalText(row.sourceFile)\n );\n}\n\nfunction isOperationalGroup(group) {\n if (!group) return false;\n\n return (group.rows || []).some(row => isOperationalNominaRow(row));\n}\nconst excepcionesMonedaPorCuenta = {};\n\nfor (const row of excepcionesRows) {\n const cuenta = accountKey(getValue(row, ['Cuenta']));\n const moneda = parseCurrency(getValue(row, ['MonedaCorrecta', 'Moneda Correcta', 'Moneda']));\n\n if (cuenta && moneda && cuenta !== 'NOAPLICA') {\n excepcionesMonedaPorCuenta[cuenta] = moneda;\n }\n}\n\nconst bamboo = bambooInputRows\n .map((row, index) => {\n const name = getValue(row, ['Name', 'Nombre', 'Nombre Completo', 'Employee Name']);\n\n return {\n index,\n employeeNumber: getValue(row, ['EmployeeNumber', 'Employee Number', 'Numero Empleado', 'Código', 'Codigo']),\n name,\n canonical: canonicalName(name),\n tokens: nameTokens(name),\n status: getValue(row, ['Status', 'Estado', 'Employment Status']),\n terminationDate: getValue(row, ['TerminationDate', 'Termination Date', 'Fecha Terminacion', 'Fecha de Baja']),\n sourceSheet: getValue(row, ['SourceSheet']),\n sourceFile: getValue(row, ['SourceFile']),\n raw: row,\n };\n })\n .filter(r => normalizeKey(r.name));\n\nfunction addToIndex(index, token, value) {\n if (!token) return;\n\n if (!index[token]) index[token] = [];\n\n index[token].push(value);\n}\n\nconst bambooByToken = {};\n\nfor (let i = 0; i < bamboo.length; i++) {\n const uniqueTokens = [...new Set(bamboo[i].tokens)];\n\n for (const token of uniqueTokens) {\n addToIndex(bambooByToken, token, i);\n\n if (token.length >= 4) {\n addToIndex(bambooByToken, token.slice(0, 4), i);\n }\n }\n}\n\nfunction candidateIndexesFromName(nombre, index) {\n const tokens = [...new Set(nameTokens(nombre))];\n const candidates = new Set();\n\n for (const token of tokens) {\n const exactList = index[token] || [];\n\n for (const idx of exactList) candidates.add(idx);\n\n if (token.length >= 4) {\n const prefixList = index[token.slice(0, 4)] || [];\n\n for (const idx of prefixList) candidates.add(idx);\n }\n }\n\n return [...candidates];\n}\n\nconst bambooMatchCache = new Map();\n\nfunction bestBambooMatch(nombre) {\n const cacheKey = normalizeKey(nombre);\n\n if (bambooMatchCache.has(cacheKey)) return bambooMatchCache.get(cacheKey);\n\n const candidateIndexes = candidateIndexesFromName(nombre, bambooByToken);\n\n if (!candidateIndexes.length) {\n const empty = null;\n bambooMatchCache.set(cacheKey, empty);\n return empty;\n }\n\n let best = null;\n\n for (const idx of candidateIndexes) {\n const b = bamboo[idx];\n const strong = strongPersonMatch(nombre, b.name);\n\n const ranking =\n strong.score +\n (strong.ok ? 0.35 : 0) +\n (strong.firstOk ? 0.05 : 0) +\n strong.exactOverlap * 0.03 +\n strong.fuzzyOverlap * 0.01;\n\n if (!best || ranking > best.ranking) {\n best = {\n ranking,\n score: strong.score,\n firstOk: strong.firstOk,\n accepted: strong.ok && strong.score >= 0.60,\n strongReason: strong.reason,\n exactOverlap: strong.exactOverlap,\n fuzzyOverlap: strong.fuzzyOverlap,\n row: b,\n };\n }\n }\n\n bambooMatchCache.set(cacheKey, best);\n return best;\n}\n\nconst nomina = nominaInputRows\n .map((row, index) => {\n const cuenta = getValue(row, ['Cuenta', 'Cuenta Bancaria', 'Cuenta Banco']);\n const nombre = getValue(row, ['Nombre', 'Nombre Completo', 'NOMBRE COMPLETO', 'Empleado']);\n const monto = parseAmount(getValue(row, ['Monto (QTZ)', 'Monto', 'Neto a pagar', 'NETO A PAGAR', 'Monto Neto', 'Amount', 'Valor', 'Pago', 'Líquido', 'Liquido']));\n const fuente = getValue(row, ['Fuente', 'Source']);\n const proyecto = getValue(row, ['Proyecto', 'Project']);\n const concepto = getValue(row, ['Concepto', 'Concept', 'Descripción', 'Descripcion']);\n const monedaDetectada = parseCurrency(getValue(row, ['Moneda'])) || parseCurrency(getValue(row, ['Monto']));\n const sourceSheet = getValue(row, ['SourceSheet']);\n const sourceFile = getValue(row, ['SourceFile']);\n\n return {\n index,\n fuente,\n proyecto,\n concepto,\n sourceSheet,\n sourceFile,\n cuentaOriginal: cuenta,\n cuenta: accountKey(cuenta),\n nombre,\n monto,\n monedaDetectada,\n raw: row,\n };\n })\n .filter(r => {\n const cuentaText = normalizeKey(r.cuentaOriginal);\n const nombreText = normalizeKey(r.nombre);\n const conceptoText = normalizeKey(r.concepto);\n\n if (cuentaText === 'TOTAL') return false;\n if (nombreText === 'TOTAL') return false;\n if (nombreText.includes('REGISTROS')) return false;\n if (conceptoText === 'TOTAL') return false;\n if (!r.nombre && !r.cuenta && r.monto === null) return false;\n\n return true;\n });\n\nconst banco = bancoInputRows\n .map((row, index) => {\n const montoOriginal = getValue(row, ['Monto']);\n const cuentaDestino = getValue(row, ['Cuenta Destino', 'Cuenta', 'Referencia']);\n const concepto = getValue(row, ['Concepto']);\n\n return {\n index,\n cuenta: accountKey(cuentaDestino),\n cuentaOriginal: cuentaDestino,\n planBanco: getValue(row, ['Número de plan', 'Numero de plan', 'Plan']),\n referenciaBanco: getValue(row, ['Referencia']),\n nombreArchivo: getValue(row, ['Nombre en Archivo']),\n nombreCuentahabiente: getValue(row, ['Nombre del Cuentahabiente']),\n monto: parseAmount(montoOriginal),\n moneda: parseCurrency(montoOriginal) || parseCurrency(getValue(row, ['Moneda'])),\n fechaAplicacion: getValue(row, ['Fecha/Hora de aplicación', 'Fecha Aplicacion', 'Fecha Aplicación', 'Fecha', 'Fecha Pago']),\n factura: getValue(row, ['Factura']),\n concepto,\n estado: getValue(row, ['Estado']),\n archivoOrigen: getValue(row, ['ArchivoOrigen']),\n raw: row,\n };\n })\n .filter(r => (r.cuenta || r.nombreCuentahabiente || r.nombreArchivo || r.monto !== null) && bankConceptIsAllowed(r));\n\nfunction emptyGroup(key, cuenta, source) {\n return {\n key,\n cuenta,\n source,\n rows: [],\n excludedRows: [],\n notas: [],\n };\n}\n\nfunction addRowToGroup(group, row, note = '') {\n group.rows.push(row);\n\n if (note) group.notas.push(note);\n}\n\nfunction groupTotal(group) {\n return round2((group?.rows || []).reduce((sum, row) => sum + Number(row.monto || 0), 0));\n}\n\nfunction groupNames(group, source) {\n const rows = group?.rows || [];\n\n return rows\n .map(row => {\n if (source === 'BANCO') return row.nombreCuentahabiente || row.nombreArchivo;\n return row.nombre;\n })\n .filter(Boolean);\n}\n\nfunction bestNameFromGroup(group, source = 'NOMINA') {\n const names = groupNames(group, source);\n\n if (!names.length) return '';\n\n names.sort((a, b) => String(b).length - String(a).length);\n\n return names[0];\n}\n\nfunction uniqueJoined(values) {\n return [...new Set(values.filter(Boolean))].join(' | ');\n}\n\nfunction groupConceptos(group) {\n return uniqueJoined((group?.rows || []).map(row => row.concepto));\n}\n\nfunction groupFuentes(group) {\n return uniqueJoined((group?.rows || []).map(row => row.fuente));\n}\n\nfunction groupProyectos(group) {\n return uniqueJoined((group?.rows || []).map(row => row.proyecto));\n}\n\nfunction groupNotas(group) {\n return [...(group?.notas || [])].filter(Boolean).join(' | ');\n}\n\nfunction groupMoneda(nominaGroup, bancoGroup) {\n const cuenta = nominaGroup?.cuenta || bancoGroup?.cuenta;\n const exception = excepcionesMonedaPorCuenta[cuenta];\n\n if (exception) return exception;\n\n const bancoMoneda = (bancoGroup?.rows || []).map(row => row.moneda).find(Boolean);\n if (bancoMoneda) return bancoMoneda;\n\n const nominaMoneda = (nominaGroup?.rows || []).map(row => row.monedaDetectada).find(Boolean);\n if (nominaMoneda) return nominaMoneda;\n\n return 'QTZ';\n}\n\nfunction groupNominaWithCuenta(rows) {\n const groups = {};\n\n for (const row of rows.filter(r => r.cuenta)) {\n const key = row.cuenta;\n\n if (!groups[key]) groups[key] = emptyGroup(key, row.cuenta, 'NOMINA');\n\n addRowToGroup(groups[key], row);\n }\n\n return groups;\n}\n\nfunction groupBancoByCuenta(rows) {\n const groups = {};\n\n for (const row of rows) {\n const key = row.cuenta || `SIN_CUENTA_BANCO_${row.index}`;\n\n if (!groups[key]) groups[key] = emptyGroup(key, row.cuenta, 'BANCO');\n\n addRowToGroup(groups[key], row);\n }\n\n return groups;\n}\n\nfunction buildGroupTokenIndex(groups, source) {\n const index = {};\n\n for (const group of Object.values(groups)) {\n const name = bestNameFromGroup(group, source);\n const tokens = [...new Set(nameTokens(name))];\n\n for (const token of tokens) {\n addToIndex(index, token, group.key);\n\n if (token.length >= 4) {\n addToIndex(index, token.slice(0, 4), group.key);\n }\n }\n }\n\n return index;\n}\n\nfunction candidateGroupKeysFromName(name, index) {\n const tokens = [...new Set(nameTokens(name))];\n const candidates = new Set();\n\n for (const token of tokens) {\n const exactList = index[token] || [];\n\n for (const key of exactList) candidates.add(key);\n\n if (token.length >= 4) {\n const prefixList = index[token.slice(0, 4)] || [];\n\n for (const key of prefixList) candidates.add(key);\n }\n }\n\n return [...candidates];\n}\n\nfunction subsetSumRows(rows, target, tolerance) {\n const candidates = rows\n .filter(row => row.monto !== null && row.monto !== undefined && Number(row.monto) > 0)\n .slice(0, 15);\n\n let best = null;\n\n function search(start, selected, sum) {\n const diff = Math.abs(round2(sum - target));\n\n if (diff <= tolerance) {\n best = selected.slice();\n return true;\n }\n\n if (sum > target + tolerance) return false;\n\n for (let i = start; i < candidates.length; i++) {\n selected.push(candidates[i]);\n\n if (search(i + 1, selected, round2(sum + Number(candidates[i].monto || 0)))) {\n return true;\n }\n\n selected.pop();\n }\n\n return false;\n }\n\n search(0, [], 0);\n\n return best;\n}\n\nfunction applyAdjustmentCleanup(nGroup, bGroup) {\n if (!nGroup || !bGroup) return;\n\n const nominaTotal = groupTotal(nGroup);\n const bancoTotal = groupTotal(bGroup);\n const diff = round2(nominaTotal - bancoTotal);\n\n if (diff <= TOLERANCIA_REDONDEO_OK) return;\n\n const adjustmentRows = nGroup.rows.filter(row => isAdjustmentOrCopy(row));\n if (!adjustmentRows.length) return;\n\n const rowsToRemove = subsetSumRows(adjustmentRows, diff, TOLERANCIA_REDONDEO_OK);\n\n if (!rowsToRemove || !rowsToRemove.length) return;\n\n const removeIndexes = new Set(rowsToRemove.map(row => row.index));\n const removedTotal = round2(rowsToRemove.reduce((sum, row) => sum + Number(row.monto || 0), 0));\n\n nGroup.rows = nGroup.rows.filter(row => !removeIndexes.has(row.index));\n nGroup.excludedRows.push(...rowsToRemove);\n nGroup.notas.push(\n `Se excluyeron ${rowsToRemove.length} fila(s) de ajuste/copia porque explicaban la diferencia contra banco. Total excluido: ${removedTotal}.`\n );\n}\n\nfunction bestGroupForNominaWithoutCuenta(row, nominaGroups, bancoGroups) {\n let best = null;\n\n for (const group of Object.values(nominaGroups)) {\n const groupName = bestNameFromGroup(group, 'NOMINA');\n const strong = strongPersonMatch(row.nombre, groupName);\n\n if (!strong.ok) continue;\n\n const bancoGroup = group.cuenta ? bancoGroups[group.cuenta] : null;\n\n if (!bancoGroup) continue;\n\n const projectedNominaTotal = round2(groupTotal(group) + Number(row.monto || 0));\n const diffAfterAdding = round2(projectedNominaTotal - groupTotal(bancoGroup));\n const absDiffAfterAdding = Math.abs(diffAfterAdding);\n\n let ranking = strong.score + (strong.firstOk ? 0.25 : 0);\n\n if (absDiffAfterAdding <= TOLERANCIA_REDONDEO_OK) ranking += 3.0;\n else if (absDiffAfterAdding <= TOLERANCIA_DIFERENCIA_MENOR) ranking += 1.0;\n\n const isCandidate = absDiffAfterAdding <= TOLERANCIA_DIFERENCIA_MENOR && strong.score >= 0.70;\n\n if (!isCandidate) continue;\n\n const candidate = {\n group,\n score: strong.score,\n firstOk: strong.firstOk,\n strongReason: strong.reason,\n bancoGroup,\n projectedNominaTotal,\n diffAfterAdding,\n absDiffAfterAdding,\n ranking,\n };\n\n if (!best || candidate.ranking > best.ranking) best = candidate;\n }\n\n return best;\n}\n\nfunction bestGroupMatchByNameAndAmount(sourceGroup, sourceKind, targetGroups, targetIndex, targetKind, usedTargetKeys = new Set()) {\n const sourceName = bestNameFromGroup(sourceGroup, sourceKind);\n const candidateKeys = candidateGroupKeysFromName(sourceName, targetIndex);\n\n let best = null;\n\n for (const key of candidateKeys) {\n if (usedTargetKeys.has(key)) continue;\n\n const target = targetGroups[key];\n\n if (!target) continue;\n\n const targetName = bestNameFromGroup(target, targetKind);\n const strong = strongPersonMatch(sourceName, targetName);\n const diff = round2(groupTotal(sourceGroup) - groupTotal(target));\n const amountExact = Math.abs(diff) <= TOLERANCIA_REDONDEO_OK;\n\n if (!strong.ok && !(amountExact && strong.score >= 0.80)) continue;\n\n let ranking = strong.score;\n\n if (strong.firstOk) ranking += 0.20;\n if (amountExact) ranking += 1.50;\n if (sourceGroup.cuenta && target.cuenta && sourceGroup.cuenta === target.cuenta) ranking += 1.50;\n\n const result = {\n group: target,\n score: strong.score,\n firstOk: strong.firstOk,\n strongReason: strong.reason,\n diff,\n amountExact,\n ranking,\n };\n\n if (!best || result.ranking > best.ranking) best = result;\n }\n\n return best;\n}\n\nfunction bestIndividualBancoForNominaGroup(nGroup) {\n const nominaName = bestNameFromGroup(nGroup, 'NOMINA');\n const nominaTotal = groupTotal(nGroup);\n\n let best = null;\n\n for (const b of banco) {\n if (b.monto === null || b.monto === undefined) continue;\n\n const diff = round2(nominaTotal - Number(b.monto || 0));\n const amountExact = Math.abs(diff) <= TOLERANCIA_REDONDEO_OK;\n\n if (!amountExact) continue;\n\n const bankName = b.nombreCuentahabiente || b.nombreArchivo;\n const strong = strongPersonMatch(nominaName, bankName);\n\n if (!strong.ok && strong.score < 0.85) continue;\n\n const ranking = strong.score + (strong.ok ? 1 : 0) + (strong.firstOk ? 0.2 : 0);\n\n const candidate = {\n row: b,\n score: strong.score,\n firstOk: strong.firstOk,\n strongReason: strong.reason,\n diff,\n ranking,\n };\n\n if (!best || candidate.ranking > best.ranking) best = candidate;\n }\n\n return best;\n}\n\nfunction bambooStatusForName(name) {\n const match = bestBambooMatch(name);\n\n if (!match) {\n return {\n ok: false,\n prioridad: 'ALTO',\n categoria: 'Empleado no encontrado en Bamboo',\n match,\n score: 0,\n motivo: 'El empleado de nómina no tiene una coincidencia confiable en Bamboo.',\n evidencia: `Nombre nómina: ${name}. Mejor coincidencia Bamboo: sin coincidencia. Score Bamboo: 0.`,\n accion: 'Validar con RRHH si es empleado activo, externo, temporal no identificado o posible empleado fantasma.',\n };\n }\n\n if (!match.accepted) {\n if (\n match.score >= 0.70 &&\n (\n match.fuzzyOverlap >= 2 ||\n match.exactOverlap >= 2 ||\n match.strongReason === 'source_long_vs_target_short_requires_review' ||\n match.strongReason === 'source_short_vs_target_very_long'\n )\n ) {\n return {\n ok: false,\n prioridad: 'MEDIO',\n categoria: 'Nombre en Bamboo requiere revisión',\n match,\n score: match.score,\n motivo: 'Hay similitud parcial con Bamboo, pero no alcanza una coincidencia automática confiable. No se clasifica como no encontrado automáticamente.',\n evidencia: `Nombre nómina: ${name}. Coincidencia Bamboo: ${match.row?.name || 'sin coincidencia'}. Score Bamboo: ${match.score}. Regla: ${match.strongReason}. Tokens exactos: ${match.exactOverlap}. Tokens fuzzy: ${match.fuzzyOverlap}.`,\n accion: 'Revisar manualmente si corresponde a la misma persona antes de clasificarlo como irregularidad.',\n };\n }\n\n return {\n ok: false,\n prioridad: 'ALTO',\n categoria: 'Empleado no encontrado en Bamboo',\n match,\n score: match.score,\n motivo: 'El empleado de nómina no tiene una coincidencia confiable en Bamboo.',\n evidencia: `Nombre nómina: ${name}. Mejor coincidencia Bamboo: ${match.row?.name || 'sin coincidencia'}. Score Bamboo: ${match.score}. Regla: ${match.strongReason}. Tokens exactos: ${match.exactOverlap}. Tokens fuzzy: ${match.fuzzyOverlap}.`,\n accion: 'Validar con RRHH si es empleado activo, externo, temporal no identificado o posible empleado fantasma.',\n };\n }\n\n if (isInactiveBamboo(match.row)) {\n return {\n ok: false,\n prioridad: 'MEDIO',\n categoria: 'Empleado en Bamboo con estado inactivo o baja',\n match,\n score: match.score,\n motivo: 'El empleado aparece en Bamboo, pero su estado o fecha de baja requiere revisión.',\n evidencia: `Nombre Bamboo: ${match.row.name}. Status: ${match.row.status || 'sin status'}. Fecha baja: ${match.row.terminationDate || 'sin fecha'}.`,\n accion: 'Confirmar con RRHH si correspondía pagarle en este período.',\n };\n }\n\n if (!match.firstOk && match.score >= 0.70) {\n return {\n ok: false,\n prioridad: 'MEDIO',\n categoria: 'Nombre en Bamboo requiere revisión',\n match,\n score: match.score,\n motivo: 'Hay similitud con Bamboo, pero el primer nombre no coincide claramente. No se clasifica como no encontrado porque la coincidencia por tokens es fuerte.',\n evidencia: `Nombre nómina: ${name}. Coincidencia Bamboo: ${match.row.name}. Score Bamboo: ${match.score}. Regla: ${match.strongReason}.`,\n accion: 'Revisar manualmente si corresponde a la misma persona antes de clasificarlo como irregularidad.',\n };\n }\n\n return {\n ok: true,\n match,\n score: match.score,\n estado: 'OK_BAMBOO',\n };\n}\n\nfunction adjustIssueForOperationalConcept(row) {\n const isOperational =\n isOperationalText(row.Fuente) ||\n isOperationalText(row.Proyecto) ||\n isOperationalText(row.Concepto);\n\n if (!isOperational) return row;\n\n const updated = { ...row };\n\n if (\n updated.Categoria === 'Nómina sin pago en banco' ||\n updated.Categoria === 'Nómina sin cuenta con pago bancario encontrado a otra cuenta'\n ) {\n updated.Prioridad = 'MEDIO';\n updated.Categoria = 'Concepto operativo / asignación sin conciliación bancaria';\n updated.MontoRiesgo = 0;\n updated.Motivo = 'La fila corresponde a combustible, tarjeta, viático, movilidad, reembolso o asignación operativa. Se reporta para revisión, pero no como riesgo alto de nómina.';\n updated.AccionSugerida = 'Validar con Finanzas/Operaciones si el concepto operativo fue liquidado por otro canal, tarjeta corporativa, reembolso, caja chica o archivo de banco distinto.';\n }\n\n if (\n updated.Categoria === 'Monto total diferente entre nómina y banco' ||\n updated.Categoria === 'Diferencia menor por redondeo o ajuste mínimo'\n ) {\n updated.Prioridad = 'MEDIO';\n updated.Categoria = 'Concepto operativo / asignación con diferencia contra banco';\n updated.MontoRiesgo = 0;\n updated.Motivo = 'La diferencia corresponde a un concepto operativo o asignación. Se reporta como revisión operativa y no como irregularidad alta de nómina.';\n updated.AccionSugerida = 'Validar con Finanzas/Operaciones si corresponde a tarjeta, combustible, movilidad, viático, reembolso o ajuste operativo.';\n }\n\n return updated;\n}\n\nfunction makeIssue(row) {\n const adjusted = adjustIssueForOperationalConcept(row);\n\n return {\n Prioridad: adjusted.Prioridad ?? '',\n Categoria: adjusted.Categoria ?? '',\n EmpleadoNomina: adjusted.EmpleadoNomina ?? '',\n BeneficiarioBanco: adjusted.BeneficiarioBanco ?? '',\n CoincidenciaBamboo: adjusted.CoincidenciaBamboo ?? '',\n CuentaNomina: adjusted.CuentaNomina ?? '',\n CuentaBanco: adjusted.CuentaBanco ?? '',\n MontoNomina: adjusted.MontoNomina ?? '',\n MontoBanco: adjusted.MontoBanco ?? '',\n Moneda: adjusted.Moneda ?? '',\n Diferencia: adjusted.Diferencia ?? '',\n MontoRiesgo: adjusted.MontoRiesgo ?? 0,\n Motivo: adjusted.Motivo ?? '',\n Evidencia: adjusted.Evidencia ?? '',\n AccionSugerida: adjusted.AccionSugerida ?? '',\n Fuente: adjusted.Fuente ?? '',\n Proyecto: adjusted.Proyecto ?? '',\n Concepto: adjusted.Concepto ?? '',\n ScoreBanco: adjusted.ScoreBanco ?? '',\n ScoreBamboo: adjusted.ScoreBamboo ?? '',\n FechaRevision: FECHA_REVISION,\n };\n}\n\nconst nominaGroups = groupNominaWithCuenta(nomina);\nconst bancoGroups = groupBancoByCuenta(banco);\n\nconst nominaSinCuenta = nomina.filter(row => !row.cuenta);\n\nfor (const row of nominaSinCuenta) {\n const match = bestGroupForNominaWithoutCuenta(row, nominaGroups, bancoGroups);\n\n if (match) {\n addRowToGroup(\n match.group,\n row,\n `Se sumó fila de nómina sin cuenta por coincidencia estricta de nombre y cierre de monto contra banco. Nombre: ${row.nombre}. Monto: ${row.monto}. Fuente: ${row.fuente}. Concepto: ${row.concepto}. Score: ${match.score}. Diferencia proyectada contra banco: ${match.diffAfterAdding}. Regla: ${match.strongReason}.`\n );\n } else {\n const key = `SIN_CUENTA_NOMINA_${row.index}`;\n const group = emptyGroup(key, '', 'NOMINA');\n\n addRowToGroup(group, row, 'No se pudo asignar esta fila sin cuenta a banco ni a un empleado con cuenta usando reglas estrictas.');\n\n nominaGroups[key] = group;\n }\n}\n\nfor (const [cuenta, nGroup] of Object.entries(nominaGroups)) {\n if (!nGroup.cuenta) continue;\n\n const bGroup = bancoGroups[cuenta];\n\n if (!bGroup) continue;\n\n applyAdjustmentCleanup(nGroup, bGroup);\n}\n\nconst bancoGroupIndex = buildGroupTokenIndex(bancoGroups, 'BANCO');\nconst nominaGroupIndex = buildGroupTokenIndex(nominaGroups, 'NOMINA');\n\nconst issues = [];\nconst matchedNominaGroups = new Set();\nconst matchedBancoGroups = new Set();\nconst bambooIssueKeys = new Set();\n\nfunction addBambooIssueForGroup(nGroup) {\n if (isOperationalGroup(nGroup)) return;\n\n const name = bestNameFromGroup(nGroup, 'NOMINA');\n\n if (!name) return;\n\n const key = `${normalizeKey(name)}|${nGroup.cuenta || nGroup.key}`;\n\n if (bambooIssueKeys.has(key)) return;\n\n const evalBamboo = bambooStatusForName(name);\n\n if (evalBamboo.ok) return;\n\n bambooIssueKeys.add(key);\n\n issues.push(makeIssue({\n Prioridad: evalBamboo.prioridad,\n Categoria: evalBamboo.categoria,\n EmpleadoNomina: name,\n BeneficiarioBanco: '',\n CoincidenciaBamboo: evalBamboo.match?.row?.name || 'SIN COINCIDENCIA EN BAMBOO',\n CuentaNomina: nGroup.cuenta || 'SIN CUENTA EN NÓMINA',\n CuentaBanco: '',\n MontoNomina: groupTotal(nGroup),\n MontoBanco: '',\n Moneda: groupMoneda(nGroup, null),\n Diferencia: '',\n MontoRiesgo: evalBamboo.prioridad === 'ALTO' ? groupTotal(nGroup) : 0,\n Motivo: evalBamboo.motivo,\n Evidencia: evalBamboo.evidencia,\n AccionSugerida: evalBamboo.accion,\n Fuente: groupFuentes(nGroup),\n Proyecto: groupProyectos(nGroup),\n Concepto: groupConceptos(nGroup),\n ScoreBanco: '',\n ScoreBamboo: evalBamboo.score,\n }));\n}\n\nfor (const nGroup of Object.values(nominaGroups)) {\n addBambooIssueForGroup(nGroup);\n}\n\nfor (const [cuenta, nGroup] of Object.entries(nominaGroups)) {\n if (!nGroup.cuenta) continue;\n\n const bGroup = bancoGroups[cuenta];\n\n if (!bGroup) continue;\n\n const diff = round2(groupTotal(nGroup) - groupTotal(bGroup));\n const absDiff = Math.abs(diff);\n const moneda = groupMoneda(nGroup, bGroup);\n\n matchedNominaGroups.add(nGroup.key);\n matchedBancoGroups.add(bGroup.key);\n\n if (absDiff <= TOLERANCIA_REDONDEO_OK) {\n continue;\n }\n\n const prioridad = absDiff <= TOLERANCIA_DIFERENCIA_MENOR ? 'MEDIO' : 'ALTO';\n\n const categoria = absDiff <= TOLERANCIA_DIFERENCIA_MENOR\n ? 'Diferencia menor por redondeo o ajuste mínimo'\n : 'Monto total diferente entre nómina y banco';\n\n const bName = bestNameFromGroup(bGroup, 'BANCO');\n const nName = bestNameFromGroup(nGroup, 'NOMINA');\n const bambooEval = bambooStatusForName(nName);\n\n issues.push(makeIssue({\n Prioridad: prioridad,\n Categoria: categoria,\n EmpleadoNomina: nName,\n BeneficiarioBanco: bName,\n CoincidenciaBamboo: bambooEval.match?.row?.name || 'SIN COINCIDENCIA EN BAMBOO',\n CuentaNomina: nGroup.cuenta,\n CuentaBanco: bGroup.cuenta,\n MontoNomina: groupTotal(nGroup),\n MontoBanco: groupTotal(bGroup),\n Moneda: moneda,\n Diferencia: diff,\n MontoRiesgo: prioridad === 'ALTO' ? absDiff : 0,\n Motivo: prioridad === 'ALTO'\n ? 'La cuenta existe tanto en nómina como en banco, pero el total consolidado pagado no coincide.'\n : 'La diferencia entre nómina y banco es menor y puede corresponder a redondeo o ajuste mínimo.',\n Evidencia: `Cuenta: ${cuenta}. Total nómina consolidado: ${groupTotal(nGroup)}. Total banco consolidado: ${groupTotal(bGroup)}. Diferencia nómina - banco: ${diff}. Conceptos nómina: ${groupConceptos(nGroup)}. Filas nómina incluidas: ${nGroup.rows.length}. Filas banco: ${bGroup.rows.length}. Filas nómina excluidas por ajuste/copia: ${nGroup.excludedRows.length}. Notas: ${groupNotas(nGroup) || 'sin notas'}. Filtro banco aplicado: se excluyen pagos a proveedores/PAGO PRO; se incluyen pagos de planilla y concepto vacío.`,\n AccionSugerida: prioridad === 'ALTO'\n ? 'Revisar si existen ajustes, pagos parciales, deducciones, duplicados, pagos a cuenta equivocada o diferencias de moneda antes de clasificar como irregularidad definitiva.'\n : 'Validar si la diferencia corresponde a redondeo permitido o ajuste menor.',\n Fuente: groupFuentes(nGroup),\n Proyecto: groupProyectos(nGroup),\n Concepto: groupConceptos(nGroup),\n ScoreBanco: nameScore(nName, bName),\n ScoreBamboo: bambooEval.score ?? '',\n }));\n}\n\nfor (const [key, nGroup] of Object.entries(nominaGroups)) {\n if (matchedNominaGroups.has(key)) continue;\n\n const possibleBanco = bestGroupMatchByNameAndAmount(\n nGroup,\n 'NOMINA',\n bancoGroups,\n bancoGroupIndex,\n 'BANCO',\n matchedBancoGroups\n );\n\n if (possibleBanco && possibleBanco.amountExact && possibleBanco.score >= 0.80) {\n matchedNominaGroups.add(key);\n matchedBancoGroups.add(possibleBanco.group.key);\n\n const nName = bestNameFromGroup(nGroup, 'NOMINA');\n const bName = bestNameFromGroup(possibleBanco.group, 'BANCO');\n const bambooEval = bambooStatusForName(nName);\n\n issues.push(makeIssue({\n Prioridad: 'MEDIO',\n Categoria: 'Cuenta diferente, pero nombre y monto coinciden',\n EmpleadoNomina: nName,\n BeneficiarioBanco: bName,\n CoincidenciaBamboo: bambooEval.match?.row?.name || 'SIN COINCIDENCIA EN BAMBOO',\n CuentaNomina: nGroup.cuenta || 'SIN CUENTA EN NÓMINA',\n CuentaBanco: possibleBanco.group.cuenta || 'SIN CUENTA EN BANCO',\n MontoNomina: groupTotal(nGroup),\n MontoBanco: groupTotal(possibleBanco.group),\n Moneda: groupMoneda(nGroup, possibleBanco.group),\n Diferencia: possibleBanco.diff,\n MontoRiesgo: 0,\n Motivo: 'No coincidió la cuenta bancaria, pero el nombre y el monto total sí coinciden de forma confiable.',\n Evidencia: `Nombre nómina: ${nName}. Nombre banco: ${bName}. Cuenta nómina: ${nGroup.cuenta || 'vacía'}. Cuenta banco: ${possibleBanco.group.cuenta || 'vacía'}. Score nombre: ${possibleBanco.score}. Total nómina: ${groupTotal(nGroup)}. Total banco: ${groupTotal(possibleBanco.group)}. Regla: ${possibleBanco.strongReason}. Notas: ${groupNotas(nGroup) || 'sin notas'}.`,\n AccionSugerida: 'Confirmar si la cuenta cambió, si fue digitada diferente o si se pagó a una cuenta autorizada distinta.',\n Fuente: groupFuentes(nGroup),\n Proyecto: groupProyectos(nGroup),\n Concepto: groupConceptos(nGroup),\n ScoreBanco: possibleBanco.score,\n ScoreBamboo: bambooEval.score ?? '',\n }));\n\n continue;\n }\n\n const individualBanco = !nGroup.cuenta ? bestIndividualBancoForNominaGroup(nGroup) : null;\n const nName = bestNameFromGroup(nGroup, 'NOMINA');\n const bambooEval = bambooStatusForName(nName);\n\n if (individualBanco) {\n const b = individualBanco.row;\n\n issues.push(makeIssue({\n Prioridad: 'ALTO',\n Categoria: 'Nómina sin cuenta con pago bancario encontrado a otra cuenta',\n EmpleadoNomina: nName,\n BeneficiarioBanco: b.nombreCuentahabiente || b.nombreArchivo,\n CoincidenciaBamboo: bambooEval.match?.row?.name || 'SIN COINCIDENCIA EN BAMBOO',\n CuentaNomina: 'SIN CUENTA EN NÓMINA',\n CuentaBanco: b.cuenta || 'SIN CUENTA EN BANCO',\n MontoNomina: groupTotal(nGroup),\n MontoBanco: b.monto,\n Moneda: groupMoneda(nGroup, { cuenta: b.cuenta, rows: [b] }),\n Diferencia: individualBanco.diff,\n MontoRiesgo: groupTotal(nGroup),\n Motivo: 'La fila de nómina no tiene cuenta, pero se encontró un pago bancario por mismo nombre y monto hacia una cuenta bancaria. Debe validarse si esa cuenta pertenece al empleado o a un tercero.',\n Evidencia: `Nombre nómina: ${nName}. Monto nómina: ${groupTotal(nGroup)}. Pago banco encontrado: beneficiario ${b.nombreCuentahabiente || b.nombreArchivo}, cuenta ${b.cuenta}, monto ${b.monto}, archivo ${b.archivoOrigen || 'sin archivo'}, concepto ${b.concepto || 'sin concepto'}. Score banco: ${individualBanco.score}. Regla: ${individualBanco.strongReason}.`,\n AccionSugerida: 'Validar con Finanzas y RRHH si la cuenta bancaria pertenece al empleado o si el pago fue enviado a una cuenta no autorizada.',\n Fuente: groupFuentes(nGroup),\n Proyecto: groupProyectos(nGroup),\n Concepto: groupConceptos(nGroup),\n ScoreBanco: individualBanco.score,\n ScoreBamboo: bambooEval.score ?? '',\n }));\n\n continue;\n }\n\n issues.push(makeIssue({\n Prioridad: 'ALTO',\n Categoria: 'Nómina sin pago en banco',\n EmpleadoNomina: nName,\n BeneficiarioBanco: possibleBanco ? bestNameFromGroup(possibleBanco.group, 'BANCO') : '',\n CoincidenciaBamboo: bambooEval.match?.row?.name || 'SIN COINCIDENCIA EN BAMBOO',\n CuentaNomina: nGroup.cuenta || 'SIN CUENTA EN NÓMINA',\n CuentaBanco: possibleBanco?.group?.cuenta || '',\n MontoNomina: groupTotal(nGroup),\n MontoBanco: possibleBanco ? groupTotal(possibleBanco.group) : '',\n Moneda: groupMoneda(nGroup, possibleBanco?.group),\n Diferencia: possibleBanco?.diff ?? '',\n MontoRiesgo: groupTotal(nGroup),\n Motivo: 'El total consolidado de nómina no tiene un pago bancario confiable asociado.',\n Evidencia: `Cuenta nómina: ${nGroup.cuenta || 'vacía'}. Nombre nómina: ${nName}. Total nómina: ${groupTotal(nGroup)}. Mejor posible banco: ${possibleBanco ? bestNameFromGroup(possibleBanco.group, 'BANCO') : 'sin coincidencia'}. Score banco: ${possibleBanco?.score ?? 0}. Estado Bamboo: ${bambooEval.match?.row?.name || 'sin coincidencia'}. Score Bamboo: ${bambooEval.score ?? 0}. Notas: ${groupNotas(nGroup) || 'sin notas'}.`,\n AccionSugerida: 'Verificar si el pago fue omitido, pagado en otra cuenta, enviado por otro canal o si falta cargar un archivo del banco.',\n Fuente: groupFuentes(nGroup),\n Proyecto: groupProyectos(nGroup),\n Concepto: groupConceptos(nGroup),\n ScoreBanco: possibleBanco?.score ?? '',\n ScoreBamboo: bambooEval.score ?? '',\n }));\n}\n\nfor (const [key, bGroup] of Object.entries(bancoGroups)) {\n if (matchedBancoGroups.has(key)) continue;\n\n const possibleNomina = bestGroupMatchByNameAndAmount(\n bGroup,\n 'BANCO',\n nominaGroups,\n nominaGroupIndex,\n 'NOMINA',\n matchedNominaGroups\n );\n\n const bancoName = bestNameFromGroup(bGroup, 'BANCO');\n const bambooEval = bambooStatusForName(bancoName);\n\n if (possibleNomina && possibleNomina.amountExact && possibleNomina.score >= 0.80) {\n matchedBancoGroups.add(key);\n matchedNominaGroups.add(possibleNomina.group.key);\n\n issues.push(makeIssue({\n Prioridad: 'MEDIO',\n Categoria: 'Pago bancario aparece en nómina, pero no fue conciliado por cuenta',\n EmpleadoNomina: bestNameFromGroup(possibleNomina.group, 'NOMINA'),\n BeneficiarioBanco: bancoName,\n CoincidenciaBamboo: bambooEval.match?.row?.name || 'SIN COINCIDENCIA EN BAMBOO',\n CuentaNomina: possibleNomina.group.cuenta || 'SIN CUENTA EN NÓMINA',\n CuentaBanco: bGroup.cuenta || 'SIN CUENTA EN BANCO',\n MontoNomina: groupTotal(possibleNomina.group),\n MontoBanco: groupTotal(bGroup),\n Moneda: groupMoneda(possibleNomina.group, bGroup),\n Diferencia: possibleNomina.diff,\n MontoRiesgo: 0,\n Motivo: 'El pago bancario sí tiene coincidencia probable en nómina por nombre y monto, pero la cuenta no permitió conciliarlo automáticamente en el primer pase.',\n Evidencia: `Nombre banco: ${bancoName}. Nombre nómina: ${bestNameFromGroup(possibleNomina.group, 'NOMINA')}. Cuenta banco: ${bGroup.cuenta || 'vacía'}. Cuenta nómina: ${possibleNomina.group.cuenta || 'vacía'}. Total banco: ${groupTotal(bGroup)}. Total nómina: ${groupTotal(possibleNomina.group)}. Score nombre: ${possibleNomina.score}. Regla: ${possibleNomina.strongReason}.`,\n AccionSugerida: 'Revisar manualmente. No tratar como pago fuera de nómina hasta validar cuenta, nombre y concepto.',\n Fuente: groupFuentes(possibleNomina.group),\n Proyecto: groupProyectos(possibleNomina.group),\n Concepto: groupConceptos(possibleNomina.group) || groupConceptos(bGroup),\n ScoreBanco: possibleNomina.score,\n ScoreBamboo: bambooEval.score ?? '',\n }));\n\n continue;\n }\n\n const bancoBeneficiarioExisteEnBamboo = bambooEval.match && bambooEval.match.accepted;\n\n const categoriaBancoSinMatch = bancoBeneficiarioExisteEnBamboo\n ? 'Pago en banco sin registro en nómina - beneficiario aparece en Bamboo'\n : 'Pago en banco sin registro en nómina - beneficiario no encontrado en Bamboo';\n\n issues.push(makeIssue({\n Prioridad: 'ALTO',\n Categoria: categoriaBancoSinMatch,\n EmpleadoNomina: possibleNomina ? bestNameFromGroup(possibleNomina.group, 'NOMINA') : 'NO CONCILIADO AUTOMÁTICAMENTE',\n BeneficiarioBanco: bancoName,\n CoincidenciaBamboo: bambooEval.match?.row?.name || 'SIN COINCIDENCIA EN BAMBOO',\n CuentaNomina: possibleNomina?.group?.cuenta || 'NO CONCILIADO',\n CuentaBanco: bGroup.cuenta || 'SIN CUENTA EN BANCO',\n MontoNomina: possibleNomina ? groupTotal(possibleNomina.group) : '',\n MontoBanco: groupTotal(bGroup),\n Moneda: groupMoneda(possibleNomina?.group, bGroup),\n Diferencia: possibleNomina?.diff ?? '',\n MontoRiesgo: groupTotal(bGroup),\n Motivo: bancoBeneficiarioExisteEnBamboo\n ? 'Existe un pago en banco que no fue conciliado contra la nómina cargada, aunque el beneficiario sí parece existir en Bamboo.'\n : 'Existe un pago en banco que no fue conciliado contra la nómina cargada y el beneficiario tampoco tiene una coincidencia confiable en Bamboo.',\n Evidencia: `Cuenta banco: ${bGroup.cuenta || 'vacía'}. Beneficiario banco: ${bancoName}. Total banco consolidado: ${groupTotal(bGroup)}. Filas banco: ${bGroup.rows.length}. Mejor posible nómina: ${possibleNomina ? bestNameFromGroup(possibleNomina.group, 'NOMINA') : 'sin coincidencia'}. Total posible nómina: ${possibleNomina ? groupTotal(possibleNomina.group) : ''}. Score nómina/banco: ${possibleNomina?.score ?? 0}. Mejor coincidencia Bamboo: ${bambooEval.match?.row?.name || 'sin coincidencia'}. Score Bamboo: ${bambooEval.score ?? 0}.`,\n AccionSugerida: bancoBeneficiarioExisteEnBamboo\n ? 'Confirmar si este pago pertenece a otra nómina, ajuste, anticipo, bono, pago operativo, pago duplicado o concepto no cargado en Nomina_Raw.'\n : 'Revisar con Finanzas y RRHH si corresponde a pago externo autorizado, proveedor, error de archivo o posible pago fuera de nómina.',\n Fuente: 'BANCO SIN MATCH CONFIABLE',\n Proyecto: possibleNomina ? groupProyectos(possibleNomina.group) : '',\n Concepto: groupConceptos(bGroup),\n ScoreBanco: possibleNomina?.score ?? '',\n ScoreBamboo: bambooEval.score ?? '',\n }));\n}\n\n// AJUSTE FINAL:\n// Eliminar falsos positivos de nómina sin pago cuando el monto es 0.\n// Si no hay monto en nómina ni monto de riesgo, no debe contarse como irregularidad alta.\nfor (let i = issues.length - 1; i >= 0; i--) {\n const issue = issues[i];\n const montoNomina = Number(issue.MontoNomina || 0);\n const montoBanco = Number(issue.MontoBanco || 0);\n const montoRiesgo = Number(issue.MontoRiesgo || 0);\n\n if (\n issue.Categoria === 'Nómina sin pago en banco' &&\n montoNomina === 0 &&\n montoBanco === 0 &&\n montoRiesgo === 0\n ) {\n issues.splice(i, 1);\n }\n}\n\nconst prioridadOrden = { ALTO: 1, MEDIO: 2, BAJO: 3, OK: 4 };\n\nissues.sort((a, b) => {\n const p = (prioridadOrden[a.Prioridad] ?? 99) - (prioridadOrden[b.Prioridad] ?? 99);\n\n if (p !== 0) return p;\n\n return Number(b.MontoRiesgo || 0) - Number(a.MontoRiesgo || 0);\n});\n\nif (!issues.length) {\n issues.push(makeIssue({\n Prioridad: 'OK',\n Categoria: 'Sin irregularidades detectadas',\n Motivo: 'No se detectaron irregularidades con las reglas actuales.',\n }));\n}\n\nconst irregularidades = issues;\nconst resumenCategorias = {};\n\nfor (const issue of irregularidades) {\n resumenCategorias[issue.Categoria] = (resumenCategorias[issue.Categoria] || 0) + 1;\n}\n\nconst notaMoneda = excepcionesRows.length\n ? `excepcionesRows conectado: ${excepcionesRows.length} excepción(es) de moneda cargada(s). Se usa MonedaCorrecta por cuenta cuando aplica.`\n : 'excepcionesRows sin datos; se usa moneda detectada en banco/nómina o QTZ por defecto.';\n\nconst summary = {\n totalBambooActivoRows: bambooActivoRows.length,\n totalBambooAdditionsTerminationsRows: bambooAdditionsTerminationsRows.length,\n totalBambooRows: bamboo.length,\n totalNominaRowsEntrada: nominaInputRows.length,\n totalNominaRowsValidas: nomina.length,\n totalBancoRowsEntrada: bancoInputRows.length,\n totalBancoRowsValidas: banco.length,\n totalExcepcionesMonedaRows: excepcionesRows.length,\n totalIrregularidades: irregularidades.length,\n totalAltas: irregularidades.filter(i => i.Prioridad === 'ALTO').length,\n totalMedias: irregularidades.filter(i => i.Prioridad === 'MEDIO').length,\n totalBajas: irregularidades.filter(i => i.Prioridad === 'BAJO').length,\n montoRiesgoTotal: round2(irregularidades.reduce((sum, i) => sum + Number(i.MontoRiesgo || 0), 0)),\n categorias: resumenCategorias,\n notaMoneda,\n notaConceptosOperativos: 'Combustible, tarjeta, viáticos, movilidad, reembolsos y asignaciones se reportan como revisión operativa MEDIO y no suman al monto de riesgo alto.',\n};\n\nreturn [{\n json: {\n success: true,\n stage: 'cruce_cuentas_resultado',\n message: 'Cruce de cuentas ejecutado correctamente',\n summary,\n irregularidades,\n samples: {\n irregularidades: irregularidades.slice(0, 20),\n },\n },\n}];" + }, + "id": "014d8703-3428-4f66-b79b-4a53b7473422", + "name": "Code - Ejecutar Cruce de Cuentas", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3216, + -160 + ] + }, + { + "parameters": { + "jsCode": "const item = $input.first();\nconst data = item.json || {};\n\nconst summaryOriginal = data.summary || {};\nconst irregularidadesOriginales = Array.isArray(data.irregularidades)\n ? data.irregularidades\n : Array.isArray(data.samples?.irregularidades)\n ? data.samples.irregularidades\n : [];\n\nconst FECHA_REPORTE = new Date().toISOString();\n\nfunction toNumber(value) {\n if (value === null || value === undefined || value === '') return 0;\n const n = Number(value);\n return Number.isFinite(n) ? n : 0;\n}\n\nfunction round2(value) {\n return Number(Number(value || 0).toFixed(2));\n}\n\nfunction formatMoney(value, moneda = 'QTZ') {\n const n = toNumber(value);\n\n return `${moneda} ${n.toLocaleString('en-US', {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n })}`;\n}\n\nfunction cleanText(value) {\n return String(value ?? '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction priorityWeight(priority) {\n const p = cleanText(priority).toUpperCase();\n\n if (p === 'ALTO') return 1;\n if (p === 'MEDIO') return 2;\n if (p === 'BAJO') return 3;\n if (p === 'OK') return 4;\n\n return 99;\n}\n\nfunction riskWeight(issue) {\n return toNumber(issue.MontoRiesgo);\n}\n\nfunction cleanIssue(issue, index) {\n const moneda = cleanText(issue.Moneda) || 'QTZ';\n\n return {\n No: index + 1,\n Prioridad: cleanText(issue.Prioridad),\n Categoria: cleanText(issue.Categoria),\n EmpleadoNomina: cleanText(issue.EmpleadoNomina),\n BeneficiarioBanco: cleanText(issue.BeneficiarioBanco),\n CoincidenciaBamboo: cleanText(issue.CoincidenciaBamboo),\n CuentaNomina: cleanText(issue.CuentaNomina),\n CuentaBanco: cleanText(issue.CuentaBanco),\n MontoNomina: issue.MontoNomina === '' ? '' : round2(issue.MontoNomina),\n MontoBanco: issue.MontoBanco === '' ? '' : round2(issue.MontoBanco),\n Moneda: moneda,\n Diferencia: issue.Diferencia === '' ? '' : round2(issue.Diferencia),\n MontoRiesgo: round2(issue.MontoRiesgo),\n MontoRiesgoFormateado: formatMoney(issue.MontoRiesgo, moneda),\n Motivo: cleanText(issue.Motivo),\n Evidencia: cleanText(issue.Evidencia),\n AccionSugerida: cleanText(issue.AccionSugerida),\n Fuente: cleanText(issue.Fuente),\n Proyecto: cleanText(issue.Proyecto),\n Concepto: cleanText(issue.Concepto),\n ScoreBanco: issue.ScoreBanco === '' ? '' : issue.ScoreBanco,\n ScoreBamboo: issue.ScoreBamboo === '' ? '' : issue.ScoreBamboo,\n FechaRevision: cleanText(issue.FechaRevision),\n };\n}\n\nconst irregularidades = irregularidadesOriginales\n .map(cleanIssue)\n .sort((a, b) => {\n const prioridadDiff = priorityWeight(a.Prioridad) - priorityWeight(b.Prioridad);\n if (prioridadDiff !== 0) return prioridadDiff;\n\n return riskWeight(b) - riskWeight(a);\n });\n\nconst totalAltas = irregularidades.filter(i => i.Prioridad === 'ALTO').length;\nconst totalMedias = irregularidades.filter(i => i.Prioridad === 'MEDIO').length;\nconst totalBajas = irregularidades.filter(i => i.Prioridad === 'BAJO').length;\nconst totalOk = irregularidades.filter(i => i.Prioridad === 'OK').length;\n\nconst montoRiesgoTotal = round2(\n irregularidades.reduce((sum, i) => sum + toNumber(i.MontoRiesgo), 0)\n);\n\nconst categorias = {};\n\nfor (const issue of irregularidades) {\n const categoria = issue.Categoria || 'Sin categoría';\n categorias[categoria] = (categorias[categoria] || 0) + 1;\n}\n\nconst categoriasOrdenadas = Object.entries(categorias)\n .map(([categoria, cantidad]) => ({\n categoria,\n cantidad,\n }))\n .sort((a, b) => b.cantidad - a.cantidad);\n\nconst topAltas = irregularidades\n .filter(i => i.Prioridad === 'ALTO')\n .slice(0, 20);\n\nconst topMedias = irregularidades\n .filter(i => i.Prioridad === 'MEDIO')\n .slice(0, 20);\n\nconst topGeneral = irregularidades.slice(0, 25);\n\nlet estadoResultado = 'OK';\nlet tituloResultado = 'Sin irregularidades críticas detectadas';\nlet recomendacionPrincipal = 'Revisar el resumen y conservar evidencia de la corrida.';\n\nif (totalAltas > 0) {\n estadoResultado = 'REQUIERE_REVISION_ALTA';\n tituloResultado = 'Cruce completado con irregularidades de prioridad alta';\n recomendacionPrincipal = 'Revisar primero las irregularidades ALTO, especialmente empleados no encontrados en Bamboo y pagos sin conciliación bancaria.';\n} else if (totalMedias > 0) {\n estadoResultado = 'REQUIERE_REVISION_MEDIA';\n tituloResultado = 'Cruce completado con observaciones de prioridad media';\n recomendacionPrincipal = 'Revisar manualmente las coincidencias parciales y conceptos operativos antes de cerrar el análisis.';\n}\n\nconst resumenEjecutivo = {\n estadoResultado,\n tituloResultado,\n fechaReporte: FECHA_REPORTE,\n\n totalIrregularidades: irregularidades.length,\n totalAltas,\n totalMedias,\n totalBajas,\n totalOk,\n\n montoRiesgoTotal,\n montoRiesgoTotalFormateado: formatMoney(montoRiesgoTotal, 'QTZ'),\n\n totalBambooActivoRows: summaryOriginal.totalBambooActivoRows ?? '',\n totalBambooAdditionsTerminationsRows: summaryOriginal.totalBambooAdditionsTerminationsRows ?? '',\n totalBambooRows: summaryOriginal.totalBambooRows ?? '',\n totalNominaRowsEntrada: summaryOriginal.totalNominaRowsEntrada ?? '',\n totalNominaRowsValidas: summaryOriginal.totalNominaRowsValidas ?? '',\n totalBancoRowsEntrada: summaryOriginal.totalBancoRowsEntrada ?? '',\n totalBancoRowsValidas: summaryOriginal.totalBancoRowsValidas ?? '',\n\n recomendacionPrincipal,\n};\n\nconst alertas = [];\n\nif (summaryOriginal.notaMoneda) {\n alertas.push(summaryOriginal.notaMoneda);\n}\n\nif (summaryOriginal.notaConceptosOperativos) {\n alertas.push(summaryOriginal.notaConceptosOperativos);\n}\n\nalertas.push('Antes de producción se debe conectar excepcionesRows para manejar excepciones de moneda correctamente.');\n\nconst reporteTexto = [\n 'RESUMEN EJECUTIVO - CRUCE DE CUENTAS',\n '',\n `Estado: ${tituloResultado}`,\n `Fecha de reporte: ${FECHA_REPORTE}`,\n '',\n `Total de irregularidades: ${irregularidades.length}`,\n `Prioridad alta: ${totalAltas}`,\n `Prioridad media: ${totalMedias}`,\n `Prioridad baja: ${totalBajas}`,\n `Monto de riesgo total: ${formatMoney(montoRiesgoTotal, 'QTZ')}`,\n '',\n 'Categorías principales:',\n ...categoriasOrdenadas.map(c => `- ${c.categoria}: ${c.cantidad}`),\n '',\n `Recomendación: ${recomendacionPrincipal}`,\n '',\n 'Notas:',\n ...alertas.map(a => `- ${a}`),\n].join('\\n');\n\nconst response = {\n success: true,\n stage: 'resultado_final_ligero',\n message: 'Cruce de cuentas ejecutado y preparado correctamente.',\n resumenEjecutivo,\n categorias: categoriasOrdenadas,\n topGeneral,\n topAltas,\n topMedias,\n reporteTexto,\n alertas,\n};\n\nreturn [\n {\n json: {\n success: true,\n stage: 'salida_final_preparada',\n message: 'Salida final preparada correctamente para el frontend.',\n response,\n diagnostics: {\n irregularidadesOriginales: irregularidadesOriginales.length,\n irregularidadesEnRespuestaTopGeneral: topGeneral.length,\n irregularidadesAltasEnRespuesta: topAltas.length,\n irregularidadesMediasEnRespuesta: topMedias.length,\n categoriasDetectadas: categoriasOrdenadas.length,\n },\n },\n },\n];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3424, + -160 + ], + "id": "e4aac2db-69e2-4e62-a391-1242a083753a", + "name": "Code - Preparar Salida Final" + }, + { + "parameters": { + "resource": "spreadsheet", + "title": "={{ 'Cruce de Cuentas - ' + $now.toFormat('yyyy-LL-dd HH-mm-ss') }}", + "options": {} + }, + "type": "n8n-nodes-base.googleSheets", + "typeVersion": 4.7, + "position": [ + 3696, + -160 + ], + "id": "de8a2623-9e93-43ae-8f2b-d35b3aea3add", + "name": "Create spreadsheet", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const createdSheet = $input.first().json || {};\n\nlet salidaFinal = {};\nlet cruceCompleto = {};\n\ntry {\n salidaFinal = $('Code - Preparar Salida Final').first().json || {};\n} catch (e) {\n salidaFinal = {};\n}\n\ntry {\n cruceCompleto = $('Code - Ejecutar Cruce de Cuentas').first().json || {};\n} catch (e) {\n cruceCompleto = {};\n}\n\nconst response = salidaFinal.response || salidaFinal;\n\nconst resumen = response.resumenEjecutivo || {};\nconst categorias = Array.isArray(response.categorias) ? response.categorias : [];\nconst topGeneral = Array.isArray(response.topGeneral) ? response.topGeneral : [];\nconst topAltas = Array.isArray(response.topAltas) ? response.topAltas : [];\nconst topMedias = Array.isArray(response.topMedias) ? response.topMedias : [];\nconst alertas = Array.isArray(response.alertas) ? response.alertas : [];\nconst reporteTexto = response.reporteTexto || '';\n\nconst irregularidadesCompletas = Array.isArray(cruceCompleto.irregularidades)\n ? cruceCompleto.irregularidades\n : Array.isArray(response.irregularidades)\n ? response.irregularidades\n : topGeneral;\n\nconst spreadsheetId =\n createdSheet.spreadsheetId ||\n createdSheet.id ||\n createdSheet.documentId ||\n createdSheet?.spreadsheet?.spreadsheetId ||\n '';\n\nconst firstSheetId =\n createdSheet?.sheets?.[0]?.properties?.sheetId ??\n createdSheet?.spreadsheet?.sheets?.[0]?.properties?.sheetId ??\n 0;\n\nif (!spreadsheetId) {\n return [{\n json: {\n success: false,\n stage: 'google_sheets_missing_spreadsheet_id',\n error: 'No pude detectar el spreadsheetId del Google Sheet creado.',\n createdSheetKeys: Object.keys(createdSheet),\n createdSheet,\n },\n }];\n}\n\nconst spreadsheetUrl =\n createdSheet.spreadsheetUrl ||\n createdSheet.url ||\n `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit`;\n\nconst reportTitle =\n createdSheet?.properties?.title ||\n createdSheet?.spreadsheet?.properties?.title ||\n `Cruce de Cuentas - ${new Date().toISOString().replace(/[:.]/g, '-')}`;\n\nfunction clean(value) {\n if (value === null || value === undefined) return '';\n\n if (typeof value === 'object') {\n return JSON.stringify(value);\n }\n\n return String(value);\n}\n\nfunction money(value) {\n const n = Number(value || 0);\n return `QTZ ${n.toLocaleString('en-US', {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n })}`;\n}\n\nfunction rowsFromObjects(items, headers) {\n const rows = [headers];\n\n for (const item of items || []) {\n rows.push(headers.map(header => clean(item?.[header])));\n }\n\n return rows;\n}\n\nconst irregularidadHeaders = [\n 'No',\n 'Prioridad',\n 'Categoria',\n 'EmpleadoNomina',\n 'BeneficiarioBanco',\n 'CoincidenciaBamboo',\n 'CuentaNomina',\n 'CuentaBanco',\n 'MontoNomina',\n 'MontoBanco',\n 'Moneda',\n 'Diferencia',\n 'MontoRiesgo',\n 'MontoRiesgoFormateado',\n 'Motivo',\n 'Evidencia',\n 'AccionSugerida',\n 'Fuente',\n 'Proyecto',\n 'Concepto',\n 'ScoreBanco',\n 'ScoreBamboo',\n 'FechaRevision',\n];\n\nfunction normalizeIssueRows(items) {\n return (items || []).map((item, index) => ({\n No: item.No || index + 1,\n Prioridad: item.Prioridad || '',\n Categoria: item.Categoria || '',\n EmpleadoNomina: item.EmpleadoNomina || '',\n BeneficiarioBanco: item.BeneficiarioBanco || '',\n CoincidenciaBamboo: item.CoincidenciaBamboo || '',\n CuentaNomina: item.CuentaNomina || '',\n CuentaBanco: item.CuentaBanco || '',\n MontoNomina: item.MontoNomina ?? '',\n MontoBanco: item.MontoBanco ?? '',\n Moneda: item.Moneda || '',\n Diferencia: item.Diferencia ?? '',\n MontoRiesgo: item.MontoRiesgo ?? 0,\n MontoRiesgoFormateado: item.MontoRiesgoFormateado || money(item.MontoRiesgo || 0),\n Motivo: item.Motivo || '',\n Evidencia: item.Evidencia || '',\n AccionSugerida: item.AccionSugerida || '',\n Fuente: item.Fuente || '',\n Proyecto: item.Proyecto || '',\n Concepto: item.Concepto || '',\n ScoreBanco: item.ScoreBanco ?? '',\n ScoreBamboo: item.ScoreBamboo ?? '',\n FechaRevision: item.FechaRevision || '',\n }));\n}\n\nconst resumenRows = [\n ['Campo', 'Valor'],\n ['Estado resultado', resumen.estadoResultado || ''],\n ['Título resultado', resumen.tituloResultado || ''],\n ['Fecha reporte', resumen.fechaReporte || new Date().toISOString()],\n ['Total irregularidades', resumen.totalIrregularidades ?? ''],\n ['Total altas', resumen.totalAltas ?? ''],\n ['Total medias', resumen.totalMedias ?? ''],\n ['Total bajas', resumen.totalBajas ?? ''],\n ['Total OK', resumen.totalOk ?? 0],\n ['Monto riesgo total', resumen.montoRiesgoTotal ?? 0],\n ['Monto riesgo total formateado', resumen.montoRiesgoTotalFormateado || money(resumen.montoRiesgoTotal || 0)],\n ['Bamboo activo rows', resumen.totalBambooActivoRows ?? ''],\n ['Bamboo additions/terminations rows', resumen.totalBambooAdditionsTerminationsRows ?? ''],\n ['Bamboo rows total', resumen.totalBambooRows ?? ''],\n ['Nómina rows entrada', resumen.totalNominaRowsEntrada ?? ''],\n ['Nómina rows válidas', resumen.totalNominaRowsValidas ?? ''],\n ['Banco rows entrada', resumen.totalBancoRowsEntrada ?? ''],\n ['Banco rows válidas', resumen.totalBancoRowsValidas ?? ''],\n ['Recomendación principal', resumen.recomendacionPrincipal || ''],\n ['Link del reporte', spreadsheetUrl],\n];\n\nconst categoriasRows = rowsFromObjects(categorias, ['categoria', 'cantidad']);\n\nconst irregularidadesRows = rowsFromObjects(\n normalizeIssueRows(irregularidadesCompletas),\n irregularidadHeaders\n);\n\nconst topGeneralRows = rowsFromObjects(\n normalizeIssueRows(topGeneral),\n irregularidadHeaders\n);\n\nconst topAltasRows = rowsFromObjects(\n normalizeIssueRows(topAltas),\n irregularidadHeaders\n);\n\nconst topMediasRows = rowsFromObjects(\n normalizeIssueRows(topMedias),\n irregularidadHeaders\n);\n\nconst alertasRows = [\n ['No', 'Alerta'],\n ...alertas.map((alerta, index) => [index + 1, alerta]),\n];\n\nconst reporteTextoRows = [\n ['Reporte Texto'],\n ...String(reporteTexto || '').split('\\n').map(line => [line]),\n];\n\nconst tabs = [\n {\n title: 'Resumen',\n values: resumenRows,\n },\n {\n title: 'Categorias',\n values: categoriasRows,\n },\n {\n title: 'Irregularidades',\n values: irregularidadesRows,\n },\n {\n title: 'Top_General',\n values: topGeneralRows,\n },\n {\n title: 'Top_Altas',\n values: topAltasRows,\n },\n {\n title: 'Top_Medias',\n values: topMediasRows,\n },\n {\n title: 'Alertas',\n values: alertasRows,\n },\n {\n title: 'Reporte_Texto',\n values: reporteTextoRows,\n },\n];\n\nconst batchUpdateBody = {\n requests: [\n {\n updateSheetProperties: {\n properties: {\n sheetId: firstSheetId,\n title: 'Resumen',\n },\n fields: 'title',\n },\n },\n ...tabs.slice(1).map(tab => ({\n addSheet: {\n properties: {\n title: tab.title,\n },\n },\n })),\n ],\n};\n\nconst valuesBatchUpdateBody = {\n valueInputOption: 'USER_ENTERED',\n data: tabs.map(tab => ({\n range: `'${tab.title}'!A1`,\n majorDimension: 'ROWS',\n values: tab.values,\n })),\n};\n\nconst responseConGoogleSheet = {\n ...response,\n googleSheet: {\n id: spreadsheetId,\n title: reportTitle,\n url: spreadsheetUrl,\n },\n reporteGoogleSheetsUrl: spreadsheetUrl,\n message: `${response.message || 'Cruce de cuentas ejecutado correctamente.'} Reporte creado en Google Sheets.`,\n};\n\nreturn [{\n json: {\n success: true,\n stage: 'google_sheets_batch_preparado',\n spreadsheetId,\n spreadsheetUrl,\n reportTitle,\n batchUpdateBody,\n valuesBatchUpdateBody,\n response: responseConGoogleSheet,\n },\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3904, + -160 + ], + "id": "32b337e6-ac3a-419b-bcf2-752fdc4d73c5", + "name": "Code - Preparar Google Sheets Batch" + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $json.spreadsheetId + ':batchUpdate' }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json.batchUpdateBody) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 4112, + -160 + ], + "id": "df18d2da-fd1e-4654-a400-6a1a762d54ae", + "name": "HTTP - Crear Pestañas Google Sheets", + "credentials": { + "googleOAuth2Api": { + "id": "eHseMeH39kRcXgOF", + "name": "Google account 2" + } + } + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $('Code - Preparar Google Sheets Batch').first().json.spreadsheetId + '/values:batchUpdate' }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $('Code - Preparar Google Sheets Batch').first().json.valuesBatchUpdateBody }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 4320, + -160 + ], + "id": "60ea8f16-c922-44e6-a29a-d17d873086ac", + "name": "HTTP - Escribir Datos Google Sheets", + "credentials": { + "googleOAuth2Api": { + "id": "eHseMeH39kRcXgOF", + "name": "Google account 2" + } + } + }, + { + "parameters": { + "jsCode": "const sheetPayload = $('Code - Preparar Google Sheets Batch').first().json;\n\nreturn [{\n json: {\n success: true,\n stage: 'salida_final_con_google_sheet',\n message: 'Cruce de cuentas ejecutado y reporte creado en Google Sheets.',\n response: sheetPayload.response,\n googleSheet: {\n id: sheetPayload.spreadsheetId,\n title: sheetPayload.reportTitle,\n url: sheetPayload.spreadsheetUrl,\n },\n },\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4528, + -160 + ], + "id": "145917b4-8253-41ed-806b-453ee8fffaa4", + "name": "Code - Preparar Respuesta con Link" + }, + { + "parameters": { + "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + ($json.googleSheet?.id || $json.response?.googleSheet?.id) + '?fields=spreadsheetId,sheets(properties(sheetId,title,gridProperties(rowCount,columnCount)))' }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 4736, + -160 + ], + "id": "506ed98a-ca01-4e1b-8b4a-e3584e0f19d7", + "name": "HTTP - Leer Metadata Google Sheet", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const metadata = $input.first().json;\n\nconst spreadsheetId = metadata.spreadsheetId;\n\nif (!spreadsheetId) {\n return [{\n json: {\n success: false,\n stage: 'google_sheets_format_error',\n error: 'No se encontró spreadsheetId en la metadata del Google Sheet.',\n metadata,\n },\n }];\n}\n\nconst sheets = metadata.sheets || [];\n\nfunction normalizeTitle(value) {\n return String(value || '')\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase()\n .trim();\n}\n\nfunction getSheetInfo(sheet) {\n return {\n sheetId: sheet.properties.sheetId,\n title: sheet.properties.title,\n rowCount: sheet.properties.gridProperties?.rowCount || 1000,\n columnCount: sheet.properties.gridProperties?.columnCount || 26,\n };\n}\n\nconst sheetInfos = sheets.map(getSheetInfo);\n\nfunction isResumen(title) {\n const t = normalizeTitle(title);\n return t.includes('resumen');\n}\n\nfunction isCategorias(title) {\n const t = normalizeTitle(title);\n return t.includes('categoria');\n}\n\nfunction isAlertas(title) {\n const t = normalizeTitle(title);\n return t.includes('alerta') || t.includes('reporte') || t.includes('texto');\n}\n\nfunction isDetailSheet(title) {\n const t = normalizeTitle(title);\n\n return (\n t.includes('general') ||\n t.includes('alta') ||\n t.includes('media') ||\n t.includes('irregular') ||\n t.includes('detalle')\n );\n}\n\nconst requests = [];\n\nconst COLORS = {\n darkHeader: { red: 0.12, green: 0.16, blue: 0.22 },\n white: { red: 1, green: 1, blue: 1 },\n lightGray: { red: 0.94, green: 0.95, blue: 0.97 },\n red: { red: 0.83, green: 0.18, blue: 0.18 },\n redSoft: { red: 0.98, green: 0.82, blue: 0.82 },\n amber: { red: 1.0, green: 0.74, blue: 0.28 },\n amberSoft: { red: 1.0, green: 0.91, blue: 0.70 },\n green: { red: 0.27, green: 0.62, blue: 0.39 },\n blueSoft: { red: 0.82, green: 0.89, blue: 1.0 },\n};\n\nfunction freezeHeader(sheetId, frozenColumns = 0) {\n requests.push({\n updateSheetProperties: {\n properties: {\n sheetId,\n gridProperties: {\n frozenRowCount: 1,\n frozenColumnCount: frozenColumns,\n },\n },\n fields: 'gridProperties.frozenRowCount,gridProperties.frozenColumnCount',\n },\n });\n}\n\nfunction styleHeader(sheetId, columns) {\n requests.push({\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: 0,\n endRowIndex: 1,\n startColumnIndex: 0,\n endColumnIndex: columns,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: COLORS.darkHeader,\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n textFormat: {\n bold: true,\n foregroundColor: COLORS.white,\n fontSize: 11,\n },\n },\n },\n fields: 'userEnteredFormat(backgroundColor,horizontalAlignment,verticalAlignment,wrapStrategy,textFormat)',\n },\n });\n}\n\nfunction formatBody(sheetId, columns, rows = 1000) {\n requests.push({\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: 1,\n endRowIndex: rows,\n startColumnIndex: 0,\n endColumnIndex: columns,\n },\n cell: {\n userEnteredFormat: {\n verticalAlignment: 'TOP',\n wrapStrategy: 'WRAP',\n textFormat: {\n fontSize: 10,\n },\n },\n },\n fields: 'userEnteredFormat(verticalAlignment,wrapStrategy,textFormat.fontSize)',\n },\n });\n}\n\nfunction autoResize(sheetId, columns) {\n requests.push({\n autoResizeDimensions: {\n dimensions: {\n sheetId,\n dimension: 'COLUMNS',\n startIndex: 0,\n endIndex: columns,\n },\n },\n });\n}\n\nfunction setWidth(sheetId, startCol, endCol, pixelSize) {\n requests.push({\n updateDimensionProperties: {\n range: {\n sheetId,\n dimension: 'COLUMNS',\n startIndex: startCol,\n endIndex: endCol,\n },\n properties: {\n pixelSize,\n },\n fields: 'pixelSize',\n },\n });\n}\n\nfunction setBasicFilter(sheetId, columns, rows = 1000) {\n requests.push({\n setBasicFilter: {\n filter: {\n range: {\n sheetId,\n startRowIndex: 0,\n endRowIndex: rows,\n startColumnIndex: 0,\n endColumnIndex: columns,\n },\n },\n },\n });\n}\n\nfunction formatCurrencyColumn(sheetId, colIndex, rows = 1000) {\n requests.push({\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: 1,\n endRowIndex: rows,\n startColumnIndex: colIndex,\n endColumnIndex: colIndex + 1,\n },\n cell: {\n userEnteredFormat: {\n numberFormat: {\n type: 'NUMBER',\n pattern: '\"QTZ\" #,##0.00',\n },\n },\n },\n fields: 'userEnteredFormat.numberFormat',\n },\n });\n}\n\nfunction formatTextColumn(sheetId, colIndex, rows = 1000) {\n requests.push({\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: 1,\n endRowIndex: rows,\n startColumnIndex: colIndex,\n endColumnIndex: colIndex + 1,\n },\n cell: {\n userEnteredFormat: {\n numberFormat: {\n type: 'TEXT',\n },\n },\n },\n fields: 'userEnteredFormat.numberFormat',\n },\n });\n}\n\nfunction addPriorityConditionalFormatting(sheetId, rows = 1000) {\n // Columna B = Prioridad\n requests.push({\n addConditionalFormatRule: {\n rule: {\n ranges: [{\n sheetId,\n startRowIndex: 1,\n endRowIndex: rows,\n startColumnIndex: 1,\n endColumnIndex: 2,\n }],\n booleanRule: {\n condition: {\n type: 'TEXT_EQ',\n values: [{ userEnteredValue: 'ALTO' }],\n },\n format: {\n backgroundColor: COLORS.red,\n textFormat: {\n foregroundColor: COLORS.white,\n bold: true,\n },\n },\n },\n },\n index: 0,\n },\n });\n\n requests.push({\n addConditionalFormatRule: {\n rule: {\n ranges: [{\n sheetId,\n startRowIndex: 1,\n endRowIndex: rows,\n startColumnIndex: 1,\n endColumnIndex: 2,\n }],\n booleanRule: {\n condition: {\n type: 'TEXT_EQ',\n values: [{ userEnteredValue: 'MEDIO' }],\n },\n format: {\n backgroundColor: COLORS.amber,\n textFormat: {\n bold: true,\n },\n },\n },\n },\n index: 1,\n },\n });\n\n requests.push({\n addConditionalFormatRule: {\n rule: {\n ranges: [{\n sheetId,\n startRowIndex: 1,\n endRowIndex: rows,\n startColumnIndex: 1,\n endColumnIndex: 2,\n }],\n booleanRule: {\n condition: {\n type: 'TEXT_EQ',\n values: [{ userEnteredValue: 'BAJO' }],\n },\n format: {\n backgroundColor: COLORS.green,\n textFormat: {\n foregroundColor: COLORS.white,\n bold: true,\n },\n },\n },\n },\n index: 2,\n },\n });\n}\n\nfunction styleResumen(sheet) {\n const sheetId = sheet.sheetId;\n const rows = Math.max(sheet.rowCount, 100);\n const columns = Math.max(sheet.columnCount, 8);\n\n freezeHeader(sheetId, 0);\n styleHeader(sheetId, Math.min(columns, 8));\n formatBody(sheetId, Math.min(columns, 8), rows);\n autoResize(sheetId, Math.min(columns, 8));\n\n setWidth(sheetId, 0, 1, 280);\n setWidth(sheetId, 1, 2, 220);\n setWidth(sheetId, 2, 8, 180);\n\n requests.push({\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: 1,\n endRowIndex: rows,\n startColumnIndex: 0,\n endColumnIndex: 1,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: COLORS.lightGray,\n textFormat: {\n bold: true,\n },\n },\n },\n fields: 'userEnteredFormat(backgroundColor,textFormat.bold)',\n },\n });\n}\n\nfunction styleCategorias(sheet) {\n const sheetId = sheet.sheetId;\n const rows = Math.max(sheet.rowCount, 100);\n const columns = Math.min(Math.max(sheet.columnCount, 2), 6);\n\n freezeHeader(sheetId, 0);\n styleHeader(sheetId, columns);\n formatBody(sheetId, columns, rows);\n setBasicFilter(sheetId, columns, rows);\n autoResize(sheetId, columns);\n\n setWidth(sheetId, 0, 1, 420);\n setWidth(sheetId, 1, 2, 120);\n}\n\nfunction styleAlertas(sheet) {\n const sheetId = sheet.sheetId;\n const rows = Math.max(sheet.rowCount, 100);\n const columns = Math.min(Math.max(sheet.columnCount, 2), 8);\n\n freezeHeader(sheetId, 0);\n styleHeader(sheetId, columns);\n formatBody(sheetId, columns, rows);\n autoResize(sheetId, columns);\n\n setWidth(sheetId, 0, 1, 220);\n setWidth(sheetId, 1, 8, 520);\n}\n\nfunction styleDetail(sheet) {\n const sheetId = sheet.sheetId;\n const rows = Math.max(sheet.rowCount, 1000);\n const columns = Math.min(Math.max(sheet.columnCount, 23), 30);\n\n freezeHeader(sheetId, 2);\n styleHeader(sheetId, columns);\n formatBody(sheetId, columns, rows);\n setBasicFilter(sheetId, columns, rows);\n autoResize(sheetId, columns);\n addPriorityConditionalFormatting(sheetId, rows);\n\n // Anchos principales\n setWidth(sheetId, 0, 1, 60); // No\n setWidth(sheetId, 1, 2, 110); // Prioridad\n setWidth(sheetId, 2, 3, 260); // Categoría\n setWidth(sheetId, 3, 6, 230); // Nombres\n setWidth(sheetId, 6, 8, 135); // Cuentas\n setWidth(sheetId, 8, 14, 135); // Montos / moneda\n setWidth(sheetId, 14, 17, 420); // Motivo / evidencia / acción\n setWidth(sheetId, 17, 20, 200); // Fuente / proyecto / concepto\n setWidth(sheetId, 20, 23, 140); // Scores / fecha\n\n // Columnas de cuenta como texto\n formatTextColumn(sheetId, 6, rows); // CuentaNomina\n formatTextColumn(sheetId, 7, rows); // CuentaBanco\n\n // Columnas de montos\n formatCurrencyColumn(sheetId, 8, rows); // MontoNomina\n formatCurrencyColumn(sheetId, 9, rows); // MontoBanco\n formatCurrencyColumn(sheetId, 11, rows); // Diferencia\n formatCurrencyColumn(sheetId, 12, rows); // MontoRiesgo\n}\n\nfor (const sheet of sheetInfos) {\n if (isResumen(sheet.title)) {\n styleResumen(sheet);\n } else if (isCategorias(sheet.title)) {\n styleCategorias(sheet);\n } else if (isAlertas(sheet.title)) {\n styleAlertas(sheet);\n } else if (isDetailSheet(sheet.title)) {\n styleDetail(sheet);\n } else {\n // Formato general para cualquier pestaña no reconocida\n const rows = Math.max(sheet.rowCount, 1000);\n const columns = Math.min(Math.max(sheet.columnCount, 10), 30);\n\n freezeHeader(sheet.sheetId, 0);\n styleHeader(sheet.sheetId, columns);\n formatBody(sheet.sheetId, columns, rows);\n autoResize(sheet.sheetId, columns);\n }\n}\n\nreturn [{\n json: {\n success: true,\n stage: 'google_sheets_format_preparado',\n spreadsheetId,\n sheetsDetected: sheetInfos.map(s => ({\n title: s.title,\n sheetId: s.sheetId,\n rowCount: s.rowCount,\n columnCount: s.columnCount,\n })),\n formatRequests: requests,\n formatRequestsCount: requests.length,\n },\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4944, + -160 + ], + "id": "e761645d-fc59-4dee-81cd-9617b1af32e0", + "name": "Code - Preparar Formato Google Sheets" + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $json.spreadsheetId + ':batchUpdate' }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "sendBody": true, + "bodyParameters": { + "parameters": [ + { + "name": "requests", + "value": "={{ $json.formatRequests }}" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 5152, + -160 + ], + "id": "04e6d55f-ce35-4c40-b4e0-60d6502eeeed", + "name": "HTTP - Aplicar Formato Google Sheets", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const finalData = $('Code - Preparar Salida Final').first().json;\nconst sheetData = $('Code - Preparar Formato Google Sheets').first().json;\n\nconst response = finalData.response || finalData;\n\nconst googleSheetUrl =\n response.reporteGoogleSheetsUrl ||\n response.googleSheet?.url ||\n sheetData.spreadsheetUrl ||\n `https://docs.google.com/spreadsheets/d/${sheetData.spreadsheetId}/edit`;\n\nreturn [\n {\n json: {\n success: true,\n stage: 'resultado_final_ligero',\n message: 'Cruce de cuentas ejecutado correctamente. Reporte creado y formateado en Google Sheets.',\n resumenEjecutivo: response.resumenEjecutivo || {},\n categorias: response.categorias || [],\n topGeneral: response.topGeneral || [],\n topAltas: response.topAltas || [],\n topMedias: response.topMedias || [],\n reporteTexto: response.reporteTexto || '',\n alertas: response.alertas || [],\n googleSheet: {\n id: sheetData.spreadsheetId || response.googleSheet?.id || '',\n title: response.googleSheet?.title || sheetData.reportTitle || '',\n url: googleSheetUrl,\n },\n reporteGoogleSheetsUrl: googleSheetUrl,\n formatoGoogleSheetsAplicado: true,\n },\n },\n];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 5360, + -160 + ], + "id": "97e8cb53-3a49-44c9-bbf3-bb2f9f0bc4eb", + "name": "Code - Respuesta Final" + }, + { + "parameters": { + "documentId": { + "__rl": true, + "value": "19FJ_2zH5ZyTn36jE10M8eLZFGVDb5bzXJQy5yQ-Qmkw", + "mode": "list", + "cachedResultName": "Configuración - Cruce de Cuentas GLM", + "cachedResultUrl": "https://docs.google.com/spreadsheets/d/19FJ_2zH5ZyTn36jE10M8eLZFGVDb5bzXJQy5yQ-Qmkw/edit?usp=drivesdk" + }, + "sheetName": { + "__rl": true, + "value": "gid=0", + "mode": "list", + "cachedResultName": "Excepciones_Moneda", + "cachedResultUrl": "https://docs.google.com/spreadsheets/d/19FJ_2zH5ZyTn36jE10M8eLZFGVDb5bzXJQy5yQ-Qmkw/edit#gid=0" + }, + "options": {} + }, + "type": "n8n-nodes-base.googleSheets", + "typeVersion": 4.7, + "position": [ + 2624, + 32 + ], + "id": "16879d3f-aae3-4649-ba95-33f06b5739a1", + "name": "GS - Leer Excepciones Moneda", + "alwaysOutputData": true, + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 2896, + -176 + ], + "id": "9cb0095b-7eeb-4651-a005-fbfad3a7f980", + "name": "Merge - Normalización + Excepciones" + }, + { + "parameters": { + "inputDataFieldName": "nominaExcel", + "name": "={{ 'TEMP_Nomina_XLSX_' + $now.toFormat('yyyy-LL-dd_HH-mm-ss') + '.xlsx' }}", + "driveId": { + "__rl": true, + "mode": "list", + "value": "My Drive" + }, + "folderId": { + "__rl": true, + "mode": "list", + "value": "root", + "cachedResultName": "/ (Root folder)" + }, + "options": {} + }, + "type": "n8n-nodes-base.googleDrive", + "typeVersion": 3, + "position": [ + 848, + 368 + ], + "id": "8d5537af-c0b9-4b82-8faf-194163f16aff", + "name": "Google Drive - Subir Nómina XLSX", + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + } + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'https://www.googleapis.com/drive/v3/files/' + $json.id + '/copy?fields=id,name,mimeType,webViewLink' }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ {\n name: 'TEMP_Nomina_Cruce_' + $now.toFormat('yyyy-LL-dd_HH-mm-ss'),\n mimeType: 'application/vnd.google-apps.spreadsheet'\n} }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1056, + 384 + ], + "id": "bd941a5c-9323-47bc-84aa-172063e914a3", + "name": "HTTP - Convertir Nómina XLSX a Google Sheet", + "credentials": { + "googleOAuth2Api": { + "id": "eHseMeH39kRcXgOF", + "name": "Google account 2" + } + } + }, + { + "parameters": { + "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $json.id + '?fields=spreadsheetId,sheets(properties(sheetId,title,gridProperties(rowCount,columnCount)))' }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1264, + 384 + ], + "id": "555493c6-0b57-4f1b-91b0-5a145c87b63c", + "name": "HTTP - Leer Metadata Nómina Temporal", + "credentials": { + "googleOAuth2Api": { + "id": "eHseMeH39kRcXgOF", + "name": "Google account 2" + } + } + }, + { + "parameters": { + "jsCode": "const meta = $input.first().json;\n\nconst spreadsheetId = meta.spreadsheetId;\n\nconst sheets = (meta.sheets || [])\n .map(s => {\n const props = s.properties || {};\n return {\n title: props.title || '',\n sheetId: props.sheetId,\n rowCount: props.gridProperties?.rowCount || 5000,\n columnCount: props.gridProperties?.columnCount || 80,\n };\n })\n .filter(s => s.title);\n\nfunction columnToLetter(column) {\n let temp = '';\n let letter = '';\n\n while (column > 0) {\n temp = (column - 1) % 26;\n letter = String.fromCharCode(temp + 65) + letter;\n column = (column - temp - 1) / 26;\n }\n\n return letter;\n}\n\nfunction escapeSheetName(name) {\n return String(name).replace(/'/g, \"''\");\n}\n\nconst ranges = sheets.map(sheet => {\n const lastCol = columnToLetter(Math.min(sheet.columnCount || 80, 120));\n const lastRow = Math.min(sheet.rowCount || 5000, 10000);\n\n return `'${escapeSheetName(sheet.title)}'!A1:${lastCol}${lastRow}`;\n});\n\nconst rangesQuery = ranges\n .map(range => 'ranges=' + encodeURIComponent(range))\n .join('&');\n\nreturn [\n {\n json: {\n success: true,\n stage: 'nomina_ranges_preparados',\n spreadsheetId,\n sheets,\n ranges,\n rangesQuery,\n totalSheets: sheets.length,\n },\n },\n];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1472, + 384 + ], + "id": "b3f3c30b-533c-4bfd-8475-c62426592488", + "name": "Code - Preparar Ranges Nomina" + }, + { + "parameters": { + "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $json.spreadsheetId + '/values:batchGet?majorDimension=ROWS&valueRenderOption=UNFORMATTED_VALUE&dateTimeRenderOption=FORMATTED_STRING&' + $json.rangesQuery }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1680, + 384 + ], + "id": "afd4ca7f-9555-499b-a7d6-0e0023972842", + "name": "HTTP - Leer Pestañas Nomina", + "credentials": { + "googleOAuth2Api": { + "id": "eHseMeH39kRcXgOF", + "name": "Google account 2" + } + } + }, + { + "parameters": { + "jsCode": "// =====================================================\n// Code - Normalizar Excel Extraído\n// SIN require('xlsx')\n// Lee desde Merge - Excel Sin XLSX Listo:\n// - Bamboo Activo: rows directos del Extract\n// - Bamboo Additions/Terminations: valueRanges de Google Sheet temporal\n// - Nómina: valueRanges de Google Sheet temporal\n// - Banco: desde Code - Normalizar Banco CSV\n// =====================================================\n\nconst NODE_BANCO = 'Code - Normalizar Banco CSV';\n\nfunction cleanText(value) {\n return String(value ?? '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeText(value) {\n return cleanText(value)\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toUpperCase();\n}\n\nfunction sheetNameFromRange(range) {\n const raw = String(range || '').split('!')[0] || '';\n return raw\n .replace(/^'/, '')\n .replace(/'$/, '')\n .replace(/''/g, \"'\");\n}\n\nfunction makeUniqueHeaders(headers) {\n const counts = {};\n\n return headers.map((header, index) => {\n const base = cleanText(header) || `Columna_${index + 1}`;\n const key = normalizeText(base);\n\n counts[key] = (counts[key] || 0) + 1;\n\n if (counts[key] === 1) return base;\n return `${base}_${counts[key]}`;\n });\n}\n\nfunction hasAnyText(row, words) {\n const joined = (row || []).map(normalizeText).join(' | ');\n return words.some(word => joined.includes(normalizeText(word)));\n}\n\nfunction isNominaHeaderRow(row) {\n const hasName = hasAnyText(row, [\n 'NOMBRE',\n 'EMPLEADO',\n 'COLABORADOR',\n 'BENEFICIARIO',\n 'NOMBRE COMPLETO',\n ]);\n\n const hasAmount = hasAnyText(row, [\n 'MONTO',\n 'NETO',\n 'NETO A PAGAR',\n 'PAGAR',\n 'VALOR',\n 'TOTAL',\n 'SUELDO',\n 'PAGO',\n ]);\n\n const hasAccount = hasAnyText(row, [\n 'CUENTA',\n 'CTA',\n 'BANCO',\n 'CUENTA DESTINO',\n 'NUMERO DE CUENTA',\n 'NÚMERO DE CUENTA',\n ]);\n\n const hasProjectOrConcept = hasAnyText(row, [\n 'PROYECTO',\n 'CONCEPTO',\n 'FUENTE',\n 'CLIENTE',\n 'MARCA',\n 'PAIS',\n 'PAÍS',\n ]);\n\n return hasName && (hasAmount || hasAccount || hasProjectOrConcept);\n}\n\nfunction isBambooHeaderRow(row) {\n const hasEmployeeNumber = hasAnyText(row, [\n 'EMPLOYEE NUMBER',\n 'EMPLOYEE #',\n 'NUMERO DE EMPLEADO',\n 'NÚMERO DE EMPLEADO',\n 'NO. EMPLEADO',\n 'ID EMPLEADO',\n ]);\n\n const hasName = hasAnyText(row, [\n 'NAME',\n 'NOMBRE',\n 'EMPLOYEE',\n 'COLABORADOR',\n ]);\n\n const hasStatus = hasAnyText(row, [\n 'STATUS',\n 'ESTADO',\n 'EMPLOYMENT STATUS',\n ]);\n\n const hasDates = hasAnyText(row, [\n 'HIRE DATE',\n 'TERMINATION DATE',\n 'FECHA DE BAJA',\n 'FECHA DE INGRESO',\n 'FECHA TERMINACION',\n 'FECHA TERMINACIÓN',\n ]);\n\n const hasOrgFields = hasAnyText(row, [\n 'DEPARTMENT',\n 'LOCATION',\n 'JOB TITLE',\n 'DEPARTAMENTO',\n 'UBICACION',\n 'UBICACIÓN',\n 'PUESTO',\n 'DIVISION',\n 'DIVISIÓN',\n ]);\n\n return hasName && (hasEmployeeNumber || hasStatus || hasDates || hasOrgFields);\n}\n\nfunction rowsToObjects(values, sheetName, detectorFn) {\n if (!Array.isArray(values) || !values.length) return [];\n\n let headerIndex = -1;\n\n for (let i = 0; i < Math.min(values.length, 150); i++) {\n if (detectorFn(values[i] || [])) {\n headerIndex = i;\n break;\n }\n }\n\n if (headerIndex === -1) return [];\n\n const headers = makeUniqueHeaders(values[headerIndex] || []);\n const output = [];\n\n for (let i = headerIndex + 1; i < values.length; i++) {\n const row = values[i] || [];\n const hasData = row.some(cell => cleanText(cell) !== '');\n\n if (!hasData) continue;\n\n const obj = {\n Fuente: sheetName,\n __sheetName: sheetName,\n __rowNumber: i + 1,\n };\n\n headers.forEach((header, index) => {\n obj[header] = row[index] ?? '';\n });\n\n output.push(obj);\n }\n\n return output;\n}\n\nfunction getValue(row, names) {\n for (const name of names) {\n if (row[name] !== undefined && row[name] !== null && cleanText(row[name]) !== '') {\n return row[name];\n }\n }\n\n const normalizedMap = {};\n for (const key of Object.keys(row || {})) {\n normalizedMap[normalizeText(key)] = row[key];\n }\n\n for (const name of names) {\n const value = normalizedMap[normalizeText(name)];\n if (value !== undefined && value !== null && cleanText(value) !== '') {\n return value;\n }\n }\n\n return '';\n}\n\nfunction isMeaningfulBambooRow(row) {\n const name = getValue(row, [\n 'Name',\n 'Nombre',\n 'Employee',\n 'Employee Name',\n 'Nombre Completo',\n ]);\n\n const employeeNumber = getValue(row, [\n 'Employee Number',\n 'Employee #',\n 'Número de empleado',\n 'Numero de empleado',\n 'ID Empleado',\n ]);\n\n const status = getValue(row, [\n 'Status',\n 'Employment Status',\n 'Estado',\n ]);\n\n const hireDate = getValue(row, [\n 'Hire Date',\n 'Fecha de ingreso',\n ]);\n\n const terminationDate = getValue(row, [\n 'Termination Date',\n 'Fecha de baja',\n ]);\n\n return Boolean(\n cleanText(name) ||\n cleanText(employeeNumber) ||\n cleanText(status) ||\n cleanText(hireDate) ||\n cleanText(terminationDate)\n );\n}\n\nfunction isMeaningfulNominaRow(row) {\n const name = getValue(row, [\n 'Nombre',\n 'Empleado',\n 'Colaborador',\n 'Beneficiario',\n 'Nombre Completo',\n 'EmpleadoNomina',\n ]);\n\n const account = getValue(row, [\n 'Cuenta',\n 'Cuenta Nomina',\n 'Cuenta Nómina',\n 'Cuenta Destino',\n 'Número de cuenta',\n 'Numero de cuenta',\n 'CuentaNomina',\n ]);\n\n const amount = getValue(row, [\n 'Monto',\n 'Monto Nomina',\n 'Monto Nómina',\n 'Neto a pagar',\n 'NETO A PAGAR',\n 'Total',\n 'Valor',\n 'MontoNomina',\n ]);\n\n return Boolean(cleanText(name) || cleanText(account) || cleanText(amount));\n}\n\nfunction parseValueRangesToRows(data, detectorFn, rowFilterFn, sheetFilterFn) {\n const sheets = [];\n const rows = [];\n\n for (const valueRange of data.valueRanges || []) {\n const sheetName = sheetNameFromRange(valueRange.range);\n\n if (sheetFilterFn && !sheetFilterFn(sheetName)) {\n sheets.push({\n sheetName,\n totalRowsDetected: 0,\n skipped: true,\n });\n continue;\n }\n\n const detectedRows = rowsToObjects(valueRange.values || [], sheetName, detectorFn)\n .filter(rowFilterFn || (() => true));\n\n sheets.push({\n sheetName,\n totalRowsDetected: detectedRows.length,\n skipped: false,\n });\n\n rows.push(...detectedRows);\n }\n\n return { sheets, rows };\n}\n\nfunction isBambooAdditionsSheet(sheetName) {\n const name = normalizeText(sheetName);\n\n return (\n name.includes('ADDITION') ||\n name.includes('ADDITIONS') ||\n name.includes('TERMINATION') ||\n name.includes('TERMINATIONS') ||\n name.includes('ALTAS') ||\n name.includes('BAJAS')\n );\n}\n\nfunction isNominaDataSheet(sheetName) {\n const name = normalizeText(sheetName);\n\n // Evitar pestañas que claramente son cálculo/resumen.\n if (\n name.includes('CALCULO') ||\n name.includes('CALCULOS') ||\n name.includes('IGSS') ||\n name.includes('RESUMEN') ||\n name.includes('SUMMARY')\n ) {\n return false;\n }\n\n // Para nómina permitimos cualquier otra pestaña.\n // El detector de encabezados decide si tiene datos reales.\n return true;\n}\n\nfunction classifyValueRangeDataset(data) {\n const sheetNames = (data.valueRanges || []).map(vr => sheetNameFromRange(vr.range));\n const joined = sheetNames.map(normalizeText).join(' | ');\n\n if (\n joined.includes('ADDITION') ||\n joined.includes('TERMINATION') ||\n joined.includes('ALTAS') ||\n joined.includes('BAJAS')\n ) {\n return 'bamboo_additions';\n }\n\n return 'nomina';\n}\n\nfunction safeGetNodeItems(nodeName) {\n try {\n return $items(nodeName) || [];\n } catch (error) {\n return [];\n }\n}\n\n// =====================================================\n// 1) Input desde Merge - Excel Sin XLSX Listo\n// =====================================================\nconst inputItems = $input.all();\nconst inputJsons = inputItems.map(item => item.json || {});\n\nconst valueRangeItems = inputJsons.filter(j => Array.isArray(j.valueRanges));\n\nconst bambooActivoRows = inputJsons\n .filter(j => !Array.isArray(j.valueRanges))\n .filter(isMeaningfulBambooRow)\n .map(row => ({\n Fuente: row.Fuente || 'Bamboo Activo',\n ...row,\n }));\n\n// =====================================================\n// 2) Separar Bamboo Additions y Nómina\n// =====================================================\nlet bambooAdditionsTerminationsRows = [];\nlet nominaRows = [];\nlet bambooAdditionsSheets = [];\nlet nominaSheets = [];\n\nfor (const data of valueRangeItems) {\n const kind = classifyValueRangeDataset(data);\n\n if (kind === 'bamboo_additions') {\n const parsed = parseValueRangesToRows(\n data,\n isBambooHeaderRow,\n isMeaningfulBambooRow,\n isBambooAdditionsSheet\n );\n\n bambooAdditionsTerminationsRows.push(...parsed.rows);\n bambooAdditionsSheets.push(...parsed.sheets);\n } else {\n const parsed = parseValueRangesToRows(\n data,\n isNominaHeaderRow,\n isMeaningfulNominaRow,\n isNominaDataSheet\n );\n\n nominaRows.push(...parsed.rows);\n nominaSheets.push(...parsed.sheets);\n }\n}\n\n// =====================================================\n// 3) Banco desde nodo existente\n// =====================================================\nconst bancoItems = safeGetNodeItems(NODE_BANCO);\nconst bancoNodeItem = bancoItems[0] || {};\nconst bancoJson = bancoNodeItem.json || {};\n\nconst bancoRows =\n bancoJson.bancoRows ||\n bancoJson.rows ||\n bancoJson.data ||\n [];\n\n// =====================================================\n// 4) Salida final compatible con el cruce\n// =====================================================\nconst bambooRows = [\n ...bambooActivoRows,\n ...bambooAdditionsTerminationsRows,\n];\n\nreturn [\n {\n json: {\n success: true,\n stage: 'excel_normalization_sin_xlsx',\n message: 'Archivos Excel normalizados sin dependencia xlsx. Nómina y Bamboo Additions convertidos temporalmente a Google Sheets.',\n\n bambooActivoRows,\n bambooAdditionsTerminationsRows,\n bambooRows,\n nominaRows,\n bancoRows,\n\n bambooAdditionsSheets,\n nominaSheets,\n\n normalizationSummary: {\n totalBambooActivoRows: bambooActivoRows.length,\n totalBambooAdditionsTerminationsRows: bambooAdditionsTerminationsRows.length,\n totalBambooRows: bambooRows.length,\n totalNominaRowsEntrada: nominaRows.length,\n totalBancoRows: Array.isArray(bancoRows) ? bancoRows.length : 0,\n metodoBambooActivo: 'extract_from_file',\n metodoBambooAdditions: 'google_sheet_temporal',\n metodoNomina: 'google_sheet_temporal',\n usaXlsxRequire: false,\n },\n\n samples: {\n bambooActivo: bambooActivoRows.slice(0, 3),\n bambooAdditionsTerminations: bambooAdditionsTerminationsRows.slice(0, 5),\n nomina: nominaRows.slice(0, 5),\n banco: Array.isArray(bancoRows) ? bancoRows.slice(0, 5) : [],\n },\n },\n binary: bancoNodeItem.binary,\n },\n];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2320, + -48 + ], + "id": "3f7d542a-f2c4-4d15-b2b4-bc6cc4b1c42b", + "name": "Code - Normalizar Excel Extraído" + }, + { + "parameters": { + "inputDataFieldName": "bambooAdditionsTerminations", + "name": "={{ 'TEMP_Bamboo_Additions_' + $now.toFormat('yyyy-LL-dd_HH-mm-ss') + '.xlsx' }}", + "driveId": { + "__rl": true, + "mode": "list", + "value": "My Drive" + }, + "folderId": { + "__rl": true, + "mode": "list", + "value": "root", + "cachedResultName": "/ (Root folder)" + }, + "options": {} + }, + "type": "n8n-nodes-base.googleDrive", + "typeVersion": 3, + "position": [ + 848, + 48 + ], + "id": "faa96a4c-f7ca-401c-bb30-79e185953145", + "name": "Google Drive - Subir Bamboo Additions XLSX", + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + } + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'https://www.googleapis.com/drive/v3/files/' + $json.id + '/copy?fields=id,name,mimeType,webViewLink' }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ {\n name: 'TEMP_Bamboo_Additions_' + $now.toFormat('yyyy-LL-dd_HH-mm-ss'),\n mimeType: 'application/vnd.google-apps.spreadsheet'\n} }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1056, + 48 + ], + "id": "7512bb3e-96a7-4835-9159-b3e1b611d592", + "name": "HTTP - Convertir Bamboo Additions a Google Sheet", + "credentials": { + "googleOAuth2Api": { + "id": "eHseMeH39kRcXgOF", + "name": "Google account 2" + } + } + }, + { + "parameters": { + "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $json.id + '?fields=spreadsheetId,sheets(properties(sheetId,title,gridProperties(rowCount,columnCount)))' }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1248, + 48 + ], + "id": "9d473c18-5ad7-4a4a-8fcc-3c255054e296", + "name": "HTTP - Leer Metadata Bamboo Additions", + "credentials": { + "googleOAuth2Api": { + "id": "eHseMeH39kRcXgOF", + "name": "Google account 2" + } + } + }, + { + "parameters": { + "jsCode": "const meta = $input.first().json;\n\nconst spreadsheetId = meta.spreadsheetId;\n\nconst sheets = (meta.sheets || [])\n .map(s => {\n const props = s.properties || {};\n return {\n title: props.title || '',\n sheetId: props.sheetId,\n rowCount: props.gridProperties?.rowCount || 5000,\n columnCount: props.gridProperties?.columnCount || 80,\n };\n })\n .filter(s => s.title);\n\nfunction columnToLetter(column) {\n let temp = '';\n let letter = '';\n\n while (column > 0) {\n temp = (column - 1) % 26;\n letter = String.fromCharCode(temp + 65) + letter;\n column = (column - temp - 1) / 26;\n }\n\n return letter;\n}\n\nfunction escapeSheetName(name) {\n return String(name).replace(/'/g, \"''\");\n}\n\nconst ranges = sheets.map(sheet => {\n const lastCol = columnToLetter(Math.min(sheet.columnCount || 80, 120));\n const lastRow = Math.min(sheet.rowCount || 5000, 10000);\n\n return `'${escapeSheetName(sheet.title)}'!A1:${lastCol}${lastRow}`;\n});\n\nconst rangesQuery = ranges\n .map(range => 'ranges=' + encodeURIComponent(range))\n .join('&');\n\nreturn [\n {\n json: {\n success: true,\n stage: 'nomina_ranges_preparados',\n spreadsheetId,\n sheets,\n ranges,\n rangesQuery,\n totalSheets: sheets.length,\n },\n },\n];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1472, + 48 + ], + "id": "10ec56a7-4e74-4a76-a03c-ed453a65f7f3", + "name": "Code - Preparar Ranges Bamboo Additions" + }, + { + "parameters": { + "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $json.spreadsheetId + '/values:batchGet?majorDimension=ROWS&valueRenderOption=UNFORMATTED_VALUE&dateTimeRenderOption=FORMATTED_STRING&' + $json.rangesQuery }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1664, + 48 + ], + "id": "d0c67046-1308-4555-8c2d-1e256066ae58", + "name": "HTTP - Leer Pestañas Bamboo Additions", + "credentials": { + "googleOAuth2Api": { + "id": "eHseMeH39kRcXgOF", + "name": "Google account 2" + } + } + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 1904, + -48 + ], + "id": "84be7d30-f7c6-44fa-a5d9-3f8b21141b04", + "name": "Merge - Bamboo Activo + Additions" + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 2112, + -48 + ], + "id": "3aede4f2-11f8-42b4-8ff4-92b43fa455bb", + "name": "Merge - Excel Sin XLSX Listo" + }, + { + "parameters": { + "operation": "xls", + "binaryPropertyName": "bambooActivo", + "options": {} + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + 1328, + -160 + ], + "id": "a4e9a36b-132e-4845-8515-bad490d557c0", + "name": "Extract - Bamboo Activo" + }, + { + "parameters": { + "content": "RECEPCIÓN Y VALIDACIÓN DE ARCHIVOS\n\n\nRecibe Bamboo Activo, Bamboo Additions/Terminations, Nómina y archivos del banco.\n\nValida que lleguen los archivos requeridos antes de continuar.\n\nSi falta algún archivo obligatorio, el flujo responde error y no ejecuta el cruce.", + "height": 704, + "width": 1040, + "color": 5 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + -592, + -496 + ], + "id": "baf279b1-113a-4090-ad69-454afdbfc219", + "name": "Sticky Note" + }, + { + "parameters": { + "content": "NORMALIZACIÓN DE BANCO\n\nConvierte los archivos CSV del banco en filas normalizadas para el cruce.\n\nAquí se consolidan múltiples archivos bancarios en una sola estructura bancoRows.\n\nEl banco debe venir en CSV para producción.", + "height": 496, + "width": 288, + "color": "#314927" + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 480, + -432 + ], + "id": "091d0baf-2a59-4aa5-b919-24210d983f34", + "name": "Sticky Note1" + }, + { + "parameters": { + "content": "LECTURA DE EXCEL SIN DEPENDENCIA XLSX\n\nBloque nuevo de producción.\n\nEvita usar require('xlsx') en n8n.\n\nBamboo Activo se lee con Extract From File.\n\nBamboo Additions/Terminations y Nómina se suben temporalmente a Google Drive, se convierten a Google Sheets y se leen por pestañas.\n\nEsto permite procesar archivos Excel aunque la información no esté en la primera hoja.", + "height": 944, + "width": 1680 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 800, + -368 + ], + "id": "a11495f8-73bb-447c-8958-b185c04c0af4", + "name": "Sticky Note2" + }, + { + "parameters": { + "content": "EXCEPCIONES DE MONEDA\n\nLee la pestaña Excepciones_Moneda desde el Google Sheet fuente.\n\nColumnas requeridas:\nCuenta | Nombre | MonedaCorrecta | Comentario\n\nMonedaCorrecta debe ser USD o QTZ.\n\nEstas excepciones corrigen casos donde una cuenta debe tratarse con una moneda específica.", + "height": 624, + "width": 560, + "color": "#395C65" + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 2512, + -368 + ], + "id": "ac5801ec-9121-4c6e-8298-b10b9ca7a19e", + "name": "Sticky Note3" + }, + { + "parameters": { + "content": "MOTOR DE CRUCE DE CUENTAS\n\nEjecuta la comparación entre Bamboo, nómina, banco y excepciones de moneda.\n\nDetecta irregularidades, calcula prioridad, monto de riesgo y resumen ejecutivo.\n\nLa salida queda preparada para crear el Google Sheet final.", + "height": 368, + "width": 464, + "color": 6 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 3136, + -352 + ], + "id": "48795b60-df0f-4ec8-ae2d-fc3f9f683944", + "name": "Sticky Note4" + }, + { + "parameters": { + "content": "REPORTE FINAL EN GOOGLE SHEETS Y RESPUESTA\n\nCrea el Google Sheet final por corrida.\n\nIncluye pestañas de resumen, irregularidades y detalle.\n\nAplica formato visual al reporte y devuelve al frontend el resumen ejecutivo junto con el enlace del Google Sheet.", + "height": 416, + "width": 2128, + "color": 4 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 3648, + -336 + ], + "id": "d5df4c42-4593-4f9c-8b0f-40597beb14c3", + "name": "Sticky Note5" + } + ], + "connections": { + "Webhook - Recibir Archivos Cruce": { + "main": [ + [ + { + "node": "Code - Diagnosticar Binaries Webhook", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Diagnosticar Binaries Webhook": { + "main": [ + [ + { + "node": "Code - Validar Entrada", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Validar Entrada": { + "main": [ + [ + { + "node": "Code - Diagnosticar Binaries Post Validacion", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Diagnosticar Binaries Post Validacion": { + "main": [ + [ + { + "node": "IF - Validación OK", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Validación OK": { + "main": [ + [ + { + "node": "Code - Normalizar Banco CSV", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Respond - Error Validación", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Normalizar Banco CSV": { + "main": [ + [ + { + "node": "Google Drive - Subir Nómina XLSX", + "type": "main", + "index": 0 + }, + { + "node": "Google Drive - Subir Bamboo Additions XLSX", + "type": "main", + "index": 0 + }, + { + "node": "Extract - Bamboo Activo", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Ejecutar Cruce de Cuentas": { + "main": [ + [ + { + "node": "Code - Preparar Salida Final", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar Salida Final": { + "main": [ + [ + { + "node": "Create spreadsheet", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create spreadsheet": { + "main": [ + [ + { + "node": "Code - Preparar Google Sheets Batch", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar Google Sheets Batch": { + "main": [ + [ + { + "node": "HTTP - Crear Pestañas Google Sheets", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Crear Pestañas Google Sheets": { + "main": [ + [ + { + "node": "HTTP - Escribir Datos Google Sheets", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Escribir Datos Google Sheets": { + "main": [ + [ + { + "node": "Code - Preparar Respuesta con Link", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar Respuesta con Link": { + "main": [ + [ + { + "node": "HTTP - Leer Metadata Google Sheet", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Leer Metadata Google Sheet": { + "main": [ + [ + { + "node": "Code - Preparar Formato Google Sheets", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar Formato Google Sheets": { + "main": [ + [ + { + "node": "HTTP - Aplicar Formato Google Sheets", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Aplicar Formato Google Sheets": { + "main": [ + [ + { + "node": "Code - Respuesta Final", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Respuesta Final": { + "main": [ + [ + { + "node": "Respond - Éxito", + "type": "main", + "index": 0 + } + ] + ] + }, + "GS - Leer Excepciones Moneda": { + "main": [ + [ + { + "node": "Merge - Normalización + Excepciones", + "type": "main", + "index": 1 + } + ] + ] + }, + "Merge - Normalización + Excepciones": { + "main": [ + [ + { + "node": "Code - Ejecutar Cruce de Cuentas", + "type": "main", + "index": 0 + } + ] + ] + }, + "Google Drive - Subir Nómina XLSX": { + "main": [ + [ + { + "node": "HTTP - Convertir Nómina XLSX a Google Sheet", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Convertir Nómina XLSX a Google Sheet": { + "main": [ + [ + { + "node": "HTTP - Leer Metadata Nómina Temporal", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Leer Metadata Nómina Temporal": { + "main": [ + [ + { + "node": "Code - Preparar Ranges Nomina", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar Ranges Nomina": { + "main": [ + [ + { + "node": "HTTP - Leer Pestañas Nomina", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Leer Pestañas Nomina": { + "main": [ + [ + { + "node": "Merge - Excel Sin XLSX Listo", + "type": "main", + "index": 1 + } + ] + ] + }, + "Code - Normalizar Excel Extraído": { + "main": [ + [ + { + "node": "GS - Leer Excepciones Moneda", + "type": "main", + "index": 0 + }, + { + "node": "Merge - Normalización + Excepciones", + "type": "main", + "index": 0 + } + ] + ] + }, + "Google Drive - Subir Bamboo Additions XLSX": { + "main": [ + [ + { + "node": "HTTP - Convertir Bamboo Additions a Google Sheet", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Convertir Bamboo Additions a Google Sheet": { + "main": [ + [ + { + "node": "HTTP - Leer Metadata Bamboo Additions", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Leer Metadata Bamboo Additions": { + "main": [ + [ + { + "node": "Code - Preparar Ranges Bamboo Additions", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar Ranges Bamboo Additions": { + "main": [ + [ + { + "node": "HTTP - Leer Pestañas Bamboo Additions", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Leer Pestañas Bamboo Additions": { + "main": [ + [ + { + "node": "Merge - Bamboo Activo + Additions", + "type": "main", + "index": 1 + } + ] + ] + }, + "Merge - Bamboo Activo + Additions": { + "main": [ + [ + { + "node": "Merge - Excel Sin XLSX Listo", + "type": "main", + "index": 0 + } + ] + ] + }, + "Merge - Excel Sin XLSX Listo": { + "main": [ + [ + { + "node": "Code - Normalizar Excel Extraído", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract - Bamboo Activo": { + "main": [ + [ + { + "node": "Merge - Bamboo Activo + Additions", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "authors": "Isaac Aracena", + "name": "Cruce de cuentas", + "description": "", + "autosaved": true, + "workflowPublishHistory": [ + { + "createdAt": "2026-05-08T12:25:57.096Z", + "id": 141, + "workflowId": "8jCi4gPc5kfHZvk2", + "versionId": "2d1f998e-8d92-408d-94cb-1c4131f8e675", + "event": "activated", + "userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029" + }, + { + "createdAt": "2026-05-12T14:00:34.980Z", + "id": 217, + "workflowId": "8jCi4gPc5kfHZvk2", + "versionId": "2d1f998e-8d92-408d-94cb-1c4131f8e675", + "event": "activated", + "userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029" + }, + { + "createdAt": "2026-05-12T14:00:34.932Z", + "id": 216, + "workflowId": "8jCi4gPc5kfHZvk2", + "versionId": "2d1f998e-8d92-408d-94cb-1c4131f8e675", + "event": "deactivated", + "userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029" + } + ] + } +} \ No newline at end of file