diff --git a/.env.example b/.env.example index 058403a..cf6d272 100644 --- a/.env.example +++ b/.env.example @@ -9,20 +9,24 @@ VITE_BASE_PATH="/cruce-cuentas/guatemala/" VITE_N8N_GUATEMALA_WEBHOOK_URL="https://agenteit.digitalcompass.agency/webhook/nominagt-bamboo-test" # Compatibilidad con instalaciones anteriores. VITE_N8N_WEBHOOK_URL="https://agenteit.digitalcompass.agency/webhook/nominagt-bamboo-test" +VITE_N8N_GUATEMALA_BONO14_WEBHOOK_URL="https://agenteit.digitalcompass.agency/webhook/bono14gt-bamboo-test" # Guatemala: históricos y resolución. VITE_HISTORICOS_GT_URL="https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-gt-historicos" VITE_MARCAR_RESUELTO_GT_URL="https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-gt-marcar-resuelto" # Trinidad y Tobago: completar cuando los workflows estén listos. -VITE_N8N_TRINIDAD_WEBHOOK_URL="" -VITE_HISTORICOS_TT_URL="" -VITE_MARCAR_RESUELTO_TT_URL="" +VITE_N8N_TRINIDAD_WEBHOOK_URL="https://agenteit.digitalcompass.agency/webhook/nominatt-bamboo-test" +VITE_HISTORICOS_TT_URL="https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-tt-historicos" +VITE_MARCAR_RESUELTO_TT_URL="https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-tt-marcar-resuelto" # Endpoint opcional de n8n + Gemini para analizar el nombre del archivo de nómina. # La app ya incluye detección local como respaldo. VITE_ANALIZAR_NOMINA_URL="https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-analizar-nomina" +# Reporte manual de falsos positivos Banco sin BambooHR -> RRHH. +VITE_REPORTAR_BAMBOO_URL="https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-bamboo-correccion" + # Login Google vía Supabase. VITE_ENABLE_SUPABASE_AUTH="true" VITE_SUPABASE_URL="https://dbit.digitalcompass.agency" diff --git a/Flujo de n8n: Portal de Verificación de Nómina - Analizar Nómina con Gemini GT y TT.json b/Flujo de n8n: Portal de Verificación de Nómina - Analizar Nómina con Gemini GT y TT.json deleted file mode 100644 index 6491010..0000000 --- a/Flujo de n8n: Portal de Verificación de Nómina - Analizar Nómina con Gemini GT y TT.json +++ /dev/null @@ -1,280 +0,0 @@ -{ - "name": "Portal de Verificación de Nómina - Analizar Nómina con Gemini GT y TT", - "nodes": [ - { - "parameters": { - "httpMethod": "POST", - "path": "cruce-cuentas-analizar-nomina", - "responseMode": "responseNode", - "options": {} - }, - "type": "n8n-nodes-base.webhook", - "typeVersion": 2.1, - "position": [ - -560, - 400 - ], - "id": "21d31196-75af-48c2-b9a2-6d79a53390a8", - "name": "Webhook - Analizar Nómina", - "webhookId": "bbf70625-109d-415b-ae60-755c317978ce" - }, - { - "parameters": { - "jsCode": "const input = $input.first().json || {};\n\nlet body = input.body ?? input;\n\nif (typeof body === 'string') {\n try {\n body = JSON.parse(body);\n } catch (error) {\n body = {};\n }\n}\n\nif (!body || typeof body !== 'object' || Array.isArray(body)) {\n body = {};\n}\n\nfunction clean(value) {\n return String(value ?? '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nconst fileName = clean(\n body.fileName ??\n body.filename ??\n body.file_name ??\n ''\n);\n\nconst country = clean(\n body.country ?? 'GT'\n).toUpperCase();\n\nconst supportedCountries = {\n GT: 'Guatemala',\n TT: 'Trinidad y Tobago',\n};\n\nconst countryName = clean(\n body.countryName ??\n body.country_name ??\n supportedCountries[country] ??\n ''\n);\n\nconst errors = [];\n\nif (!fileName) {\n errors.push(\n 'No se recibió el nombre del archivo de nómina.'\n );\n}\n\nif (!Object.prototype.hasOwnProperty.call(\n supportedCountries,\n country\n)) {\n errors.push(\n 'El analizador solo está habilitado para Guatemala y Trinidad y Tobago.'\n );\n}\n\nconst supportedExtensions = [\n '.xlsx',\n '.xls',\n '.xlsm',\n '.csv',\n];\n\nconst lowerFileName = fileName.toLowerCase();\n\nconst hasSupportedExtension =\n supportedExtensions.some((extension) =>\n lowerFileName.endsWith(extension)\n );\n\nif (fileName && !hasSupportedExtension) {\n errors.push(\n 'El archivo recibido no tiene una extensión de nómina compatible.'\n );\n}\n\nreturn [\n {\n json: {\n ok: errors.length === 0,\n errors,\n\n fileName,\n country,\n countryName:\n countryName ||\n supportedCountries[country] ||\n '',\n\n receivedAt: new Date().toISOString(),\n },\n },\n];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - -352, - 400 - ], - "id": "688ff563-63e0-47f5-a756-0c648d3af355", - "name": "Validar y preparar solicitud" - }, - { - "parameters": { - "conditions": { - "options": { - "caseSensitive": true, - "leftValue": "", - "typeValidation": "strict", - "version": 3 - }, - "conditions": [ - { - "id": "bd9fd675-d72b-4dba-ac03-a44f1123ecb0", - "leftValue": "={{ $json.ok }}", - "rightValue": "", - "operator": { - "type": "boolean", - "operation": "true", - "singleValue": true - } - } - ], - "combinator": "and" - }, - "options": {} - }, - "type": "n8n-nodes-base.if", - "typeVersion": 2.3, - "position": [ - -144, - 400 - ], - "id": "6f74ebb8-a476-4b69-bbca-dd298936bf53", - "name": "¿Solicitud válida?" - }, - { - "parameters": { - "respondWith": "json", - "responseBody": "={{\n{\n ok: false,\n message:\n 'No fue posible analizar el nombre del archivo.',\n errors: Array.isArray($json.errors)\n ? $json.errors\n : ['La solicitud recibida no es válida.'],\n detected: null\n}\n}}", - "options": { - "responseCode": 400, - "responseHeaders": { - "entries": [ - { - "name": "Content-Type", - "value": "application/json" - } - ] - } - } - }, - "type": "n8n-nodes-base.respondToWebhook", - "typeVersion": 1.5, - "position": [ - 64, - 496 - ], - "id": "c42f5bbb-858f-4bb6-a1d7-267679f4909b", - "name": "Responder error" - }, - { - "parameters": { - "text": "={{\n`Analiza este nombre de archivo de nómina:\n\nPaís seleccionado: ${$json.countryName}\nCódigo del país: ${$json.country}\nNombre del archivo: ${$json.fileName}`\n}}", - "schemaType": "manual", - "inputSchema": "{\n \"type\": \"object\",\n \"properties\": {\n \"country\": {\n \"type\": \"string\",\n \"enum\": [\n \"GT\",\n \"TT\"\n ]\n },\n \"year\": {\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 2100\n },\n \"month\": {\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 12\n },\n \"period_type\": {\n \"type\": \"string\",\n \"enum\": [\n \"quincena_15\",\n \"quincena_30\",\n \"mensual\",\n \"desconocido\"\n ]\n },\n \"confidence\": {\n \"type\": \"number\",\n \"minimum\": 0,\n \"maximum\": 1\n },\n \"explanation\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"country\",\n \"year\",\n \"month\",\n \"period_type\",\n \"confidence\",\n \"explanation\"\n ],\n \"additionalProperties\": false\n}", - "options": { - "systemPromptTemplate": "Eres un clasificador determinista de nombres de archivos de nómina.\n\nTu tarea es identificar el país seleccionado, el año, el mes y el período de pago utilizando únicamente la información presente en el nombre del archivo y el país recibido.\n\nNo inventes datos que no estén contenidos o razonablemente codificados en el nombre.\n\nREGLAS GENERALES:\n\n1. Devuelve exactamente el código de país recibido:\n - GT para Guatemala.\n - TT para Trinidad y Tobago.\n\n2. Detecta el año de cuatro dígitos. También puede aparecer dentro de una fecha compacta como YYYYMMDD; por ejemplo, 20260617 representa el año 2026.\n\n3. Convierte el mes a un número aceptando nombres en español o inglés:\n Enero / January = 1\n Febrero / February = 2\n Marzo / March = 3\n Abril / April = 4\n Mayo / May = 5\n Junio / June = 6\n Julio / July = 7\n Agosto / August = 8\n Septiembre / September = 9\n Octubre / October = 10\n Noviembre / November = 11\n Diciembre / December = 12\n\n4. Usa period_type = quincena_15 cuando aparezca cualquiera de estas señales:\n - 1Q\n - Q1\n - Primera quincena\n - 1ra quincena\n - First fortnight\n - First half\n - Mid month\n - Día 15\n - 15th\n\n5. Usa period_type = quincena_30 cuando aparezca cualquiera de estas señales:\n - 2Q\n - Q2\n - Segunda quincena\n - 2da quincena\n - Second fortnight\n - Second half\n - Fin de mes\n - End of month\n - Month end\n - Día 30\n - Día 31\n - 30th\n - 31st\n - El último día real del mes, incluyendo 28 o 29 de febrero\n\n6. Aunque el último día real del mes no sea 30, utiliza quincena_30 para representar la segunda quincena o cierre de mes.\n\n7. Si solamente aparecen el mes y el año, sin ninguna señal de quincena ni día de cierre, utiliza mensual.\n\n8. Para nombres como \"GLM_TT_Payroll June 30TH_20260617.xlsx\":\n - country = TT\n - year = 2026\n - month = 6\n - period_type = quincena_30\n\n9. Si no puedes identificar de manera confiable el año o el mes, utiliza:\n year = 0\n month = 0\n period_type = desconocido\n\n10. confidence debe estar entre 0 y 1.\n\n11. explanation debe ser breve y mencionar las señales encontradas en el nombre.\n\nNo agregues campos diferentes a los definidos por el esquema." - } - }, - "type": "@n8n/n8n-nodes-langchain.informationExtractor", - "typeVersion": 1.2, - "position": [ - 64, - 128 - ], - "id": "efe8a811-b868-4efb-8348-4bd0b378df4b", - "name": "Extraer período con Gemini", - "retryOnFail": true, - "waitBetweenTries": 2000, - "onError": "continueRegularOutput" - }, - { - "parameters": { - "modelName": "models/gemini-2.5-pro", - "options": {} - }, - "type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini", - "typeVersion": 1.1, - "position": [ - 64, - 304 - ], - "id": "38ff566c-2b41-40aa-873a-65ff7eee4668", - "name": "Gemini - Analizar título nómina", - "credentials": { - "googlePalmApi": { - "id": "jvsXYwL6IOoY2DBU", - "name": "Isaac - Gemini Api Pago" - } - } - }, - { - "parameters": { - "jsCode": "const request =\n $('Validar y preparar solicitud').first().json || {};\n\nconst incoming = $input.first().json || {};\n\nfunction cleanText(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeText(value) {\n return cleanText(value)\n .toUpperCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[_\\-.,()[\\]{}]+/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction parseObject(value) {\n if (\n value &&\n typeof value === 'object' &&\n !Array.isArray(value)\n ) {\n return value;\n }\n\n if (typeof value !== 'string') {\n return {};\n }\n\n const cleaned = value\n .replace(/```json/gi, '')\n .replace(/```/g, '')\n .trim();\n\n try {\n return JSON.parse(cleaned);\n } catch (error) {\n return {};\n }\n}\n\nfunction clamp(value, minimum, maximum) {\n const number = Number(value);\n\n if (!Number.isFinite(number)) {\n return minimum;\n }\n\n return Math.min(\n maximum,\n Math.max(minimum, number)\n );\n}\n\nfunction pad(number) {\n return String(number).padStart(2, '0');\n}\n\nfunction getLastDay(year, month) {\n return new Date(\n Date.UTC(year, month, 0)\n ).getUTCDate();\n}\n\nfunction buildPeriodDates(\n year,\n month,\n periodType\n) {\n if (\n !Number.isInteger(year) ||\n year < 2000 ||\n !Number.isInteger(month) ||\n month < 1 ||\n month > 12\n ) {\n return {\n periodStart: null,\n periodEnd: null,\n };\n }\n\n const lastDay = getLastDay(year, month);\n const yearMonth = `${year}-${pad(month)}`;\n\n if (periodType === 'quincena_15') {\n return {\n periodStart: `${yearMonth}-01`,\n periodEnd: `${yearMonth}-15`,\n };\n }\n\n if (periodType === 'quincena_30') {\n return {\n periodStart: `${yearMonth}-16`,\n periodEnd:\n `${yearMonth}-${pad(lastDay)}`,\n };\n }\n\n if (periodType === 'mensual') {\n return {\n periodStart: `${yearMonth}-01`,\n periodEnd:\n `${yearMonth}-${pad(lastDay)}`,\n };\n }\n\n return {\n periodStart: null,\n periodEnd: null,\n };\n}\n\nconst countryNames = {\n GT: 'Guatemala',\n TT: 'Trinidad y Tobago',\n};\n\nconst country = ['GT', 'TT'].includes(\n String(request.country || '').toUpperCase()\n)\n ? String(request.country).toUpperCase()\n : 'GT';\n\nconst countryName =\n request.countryName ||\n countryNames[country];\n\nconst monthNames = {\n 1: 'Enero',\n 2: 'Febrero',\n 3: 'Marzo',\n 4: 'Abril',\n 5: 'Mayo',\n 6: 'Junio',\n 7: 'Julio',\n 8: 'Agosto',\n 9: 'Septiembre',\n 10: 'Octubre',\n 11: 'Noviembre',\n 12: 'Diciembre',\n};\n\nconst monthPatterns = [\n ['ENERO', 1],\n ['JANUARY', 1],\n ['JAN', 1],\n\n ['FEBRERO', 2],\n ['FEBRUARY', 2],\n ['FEB', 2],\n\n ['MARZO', 3],\n ['MARCH', 3],\n ['MAR', 3],\n\n ['ABRIL', 4],\n ['APRIL', 4],\n ['APR', 4],\n\n ['MAYO', 5],\n ['MAY', 5],\n\n ['JUNIO', 6],\n ['JUNE', 6],\n ['JUN', 6],\n\n ['JULIO', 7],\n ['JULY', 7],\n ['JUL', 7],\n\n ['AGOSTO', 8],\n ['AUGUST', 8],\n ['AUG', 8],\n\n ['SEPTIEMBRE', 9],\n ['SETIEMBRE', 9],\n ['SEPTEMBER', 9],\n ['SEPT', 9],\n ['SEP', 9],\n\n ['OCTUBRE', 10],\n ['OCTOBER', 10],\n ['OCT', 10],\n\n ['NOVIEMBRE', 11],\n ['NOVEMBER', 11],\n ['NOV', 11],\n\n ['DICIEMBRE', 12],\n ['DECEMBER', 12],\n ['DEC', 12],\n];\n\nconst monthPatternText =\n monthPatterns\n .map(([name]) => name)\n .sort((a, b) => b.length - a.length)\n .join('|');\n\nconst validPeriodTypes = new Set([\n 'quincena_15',\n 'quincena_30',\n 'mensual',\n 'desconocido',\n]);\n\nconst fileName = cleanText(request.fileName);\nconst normalizedFileName =\n normalizeText(fileName);\n\nconst compactDateMatch =\n normalizedFileName.match(\n /\\b(20\\d{2})(0[1-9]|1[0-2])([0-2]\\d|3[01])\\b/\n );\n\nconst standaloneYearMatch =\n normalizedFileName.match(/\\b(20\\d{2})\\b/);\n\nconst fallbackYear = standaloneYearMatch\n ? Number(standaloneYearMatch[1])\n : compactDateMatch\n ? Number(compactDateMatch[1])\n : 0;\n\nlet fallbackMonth = 0;\n\nfor (const [monthName, monthNumber] of monthPatterns) {\n const monthRegex = new RegExp(\n `\\\\b${monthName}\\\\b`\n );\n\n if (monthRegex.test(normalizedFileName)) {\n fallbackMonth = monthNumber;\n break;\n }\n}\n\nif (!fallbackMonth && compactDateMatch) {\n fallbackMonth = Number(compactDateMatch[2]);\n}\n\nconst monthThenDayRegex = new RegExp(\n `\\\\b(?:${monthPatternText})\\\\s+([0-3]?\\\\d)(?:ST|ND|RD|TH)?\\\\b`\n);\n\nconst dayThenMonthRegex = new RegExp(\n `\\\\b([0-3]?\\\\d)(?:ST|ND|RD|TH)?\\\\s+(?:DE\\\\s+)?(?:${monthPatternText})\\\\b`\n);\n\nconst explicitDayRegex =\n /\\b(?:DIA|DAY)\\s+([0-3]?\\d)(?:ST|ND|RD|TH)?\\b/;\n\nconst monthThenDayMatch =\n normalizedFileName.match(monthThenDayRegex);\n\nconst dayThenMonthMatch =\n normalizedFileName.match(dayThenMonthRegex);\n\nconst explicitDayMatch =\n normalizedFileName.match(explicitDayRegex);\n\nconst detectedDay = monthThenDayMatch\n ? Number(monthThenDayMatch[1])\n : dayThenMonthMatch\n ? Number(dayThenMonthMatch[1])\n : explicitDayMatch\n ? Number(explicitDayMatch[1])\n : 0;\n\nconst firstFortnight =\n /\\b(1Q|Q1)\\b/.test(normalizedFileName) ||\n normalizedFileName.includes(\n 'PRIMERA QUINCENA'\n ) ||\n normalizedFileName.includes(\n '1RA QUINCENA'\n ) ||\n normalizedFileName.includes(\n 'FIRST FORTNIGHT'\n ) ||\n normalizedFileName.includes(\n 'FIRST HALF'\n ) ||\n normalizedFileName.includes(\n 'MID MONTH'\n ) ||\n normalizedFileName.includes(\n 'MIDMONTH'\n );\n\nconst secondFortnight =\n /\\b(2Q|Q2)\\b/.test(normalizedFileName) ||\n normalizedFileName.includes(\n 'SEGUNDA QUINCENA'\n ) ||\n normalizedFileName.includes(\n '2DA QUINCENA'\n ) ||\n normalizedFileName.includes(\n 'SECOND FORTNIGHT'\n ) ||\n normalizedFileName.includes(\n 'SECOND HALF'\n ) ||\n normalizedFileName.includes(\n 'FIN DE MES'\n ) ||\n normalizedFileName.includes(\n 'END OF MONTH'\n ) ||\n normalizedFileName.includes(\n 'MONTH END'\n ) ||\n normalizedFileName.includes(\n 'MONTHEND'\n );\n\nlet fallbackPeriodType = 'desconocido';\nlet fallbackConfidence = 0.2;\nlet strongPeriodEvidence = false;\n\nif (firstFortnight) {\n fallbackPeriodType = 'quincena_15';\n fallbackConfidence = 0.98;\n strongPeriodEvidence = true;\n} else if (secondFortnight) {\n fallbackPeriodType = 'quincena_30';\n fallbackConfidence = 0.98;\n strongPeriodEvidence = true;\n} else if (detectedDay === 15) {\n fallbackPeriodType = 'quincena_15';\n fallbackConfidence = 0.95;\n strongPeriodEvidence = true;\n} else if (\n fallbackYear &&\n fallbackMonth &&\n detectedDay === getLastDay(\n fallbackYear,\n fallbackMonth\n )\n) {\n fallbackPeriodType = 'quincena_30';\n fallbackConfidence = 0.95;\n strongPeriodEvidence = true;\n} else if (\n detectedDay === 30 ||\n detectedDay === 31\n) {\n fallbackPeriodType = 'quincena_30';\n fallbackConfidence = 0.94;\n strongPeriodEvidence = true;\n} else if (\n fallbackYear &&\n fallbackMonth\n) {\n fallbackPeriodType = 'mensual';\n fallbackConfidence = 0.75;\n}\n\nlet rawAI =\n incoming.output ??\n incoming.result ??\n incoming.text ??\n incoming;\n\nrawAI = parseObject(rawAI);\n\nconst aiCountry =\n String(rawAI.country || '').toUpperCase();\n\nconst aiYear = Number(rawAI.year);\nconst aiMonth = Number(rawAI.month);\n\nconst aiPeriodType =\n validPeriodTypes.has(rawAI.period_type)\n ? rawAI.period_type\n : 'desconocido';\n\nconst aiConfidence = clamp(\n rawAI.confidence,\n 0,\n 1\n);\n\nconst aiIsUsable =\n Number.isInteger(aiYear) &&\n aiYear >= 2000 &&\n aiYear <= 2100 &&\n Number.isInteger(aiMonth) &&\n aiMonth >= 1 &&\n aiMonth <= 12 &&\n aiPeriodType !== 'desconocido';\n\nlet year =\n fallbackYear ||\n (aiIsUsable ? aiYear : 0);\n\nlet month =\n fallbackMonth ||\n (aiIsUsable ? aiMonth : 0);\n\nlet periodType =\n aiIsUsable\n ? aiPeriodType\n : fallbackPeriodType;\n\nlet source =\n aiIsUsable\n ? 'gemini'\n : 'local_fallback';\n\nlet confidence =\n aiIsUsable\n ? aiConfidence\n : fallbackConfidence;\n\nlet conflictResolved = false;\n\nif (\n aiCountry &&\n aiCountry !== country\n) {\n source = 'gemini_validated_with_rules';\n conflictResolved = true;\n}\n\nif (\n strongPeriodEvidence &&\n periodType !== fallbackPeriodType\n) {\n periodType = fallbackPeriodType;\n source = 'gemini_validated_with_rules';\n confidence = Math.max(\n fallbackConfidence,\n Math.min(aiConfidence, 0.9)\n );\n conflictResolved = true;\n}\n\nif (\n fallbackYear &&\n aiIsUsable &&\n aiYear !== fallbackYear\n) {\n year = fallbackYear;\n source = 'gemini_validated_with_rules';\n conflictResolved = true;\n}\n\nif (\n fallbackMonth &&\n aiIsUsable &&\n aiMonth !== fallbackMonth\n) {\n month = fallbackMonth;\n source = 'gemini_validated_with_rules';\n conflictResolved = true;\n}\n\nif (\n !aiIsUsable &&\n fallbackYear &&\n fallbackMonth\n) {\n year = fallbackYear;\n month = fallbackMonth;\n periodType = fallbackPeriodType;\n confidence = fallbackConfidence;\n source = 'local_fallback';\n}\n\nconst complete =\n year >= 2000 &&\n month >= 1 &&\n month <= 12 &&\n periodType !== 'desconocido';\n\nconst dates = buildPeriodDates(\n year,\n month,\n periodType\n);\n\nlet periodDescription = 'Período desconocido';\n\nif (periodType === 'quincena_15') {\n periodDescription = 'Quincena 15';\n}\n\nif (periodType === 'quincena_30') {\n periodDescription =\n 'Quincena 30 / fin de mes';\n}\n\nif (periodType === 'mensual') {\n periodDescription = 'Mensual';\n}\n\nconst monthLabel =\n monthNames[month] || 'Mes desconocido';\n\nconst periodLabel = complete\n ? `${monthLabel} ${year} · ${periodDescription}`\n : 'Período pendiente de confirmar';\n\nconst requiresManualReview =\n !complete ||\n confidence < 0.75;\n\nconst explanation =\n cleanText(rawAI.explanation) ||\n (\n source === 'local_fallback'\n ? 'El período fue identificado mediante las reglas locales de respaldo.'\n : 'El período fue identificado mediante Gemini y validado con las reglas del portal.'\n );\n\nreturn [\n {\n json: {\n ok: true,\n\n message: requiresManualReview\n ? 'El archivo fue analizado, pero el período debe confirmarse manualmente.'\n : 'El período de la nómina fue detectado correctamente.',\n\n detected: {\n country,\n countryName,\n\n fileName,\n\n year,\n month,\n\n periodType,\n period_type: periodType,\n\n periodLabel,\n period_label: periodLabel,\n\n periodStart: dates.periodStart,\n period_start: dates.periodStart,\n\n periodEnd: dates.periodEnd,\n period_end: dates.periodEnd,\n\n confidence:\n Math.round(confidence * 100) / 100,\n\n complete,\n requiresManualReview,\n requires_manual_review:\n requiresManualReview,\n\n source,\n conflictResolved,\n explanation,\n },\n\n debug: {\n fallback: {\n country,\n year: fallbackYear,\n month: fallbackMonth,\n periodType: fallbackPeriodType,\n detectedDay,\n confidence: fallbackConfidence,\n strongPeriodEvidence,\n compactDate:\n compactDateMatch?.[0] || null,\n },\n\n gemini: {\n country: aiCountry || null,\n year: aiYear || 0,\n month: aiMonth || 0,\n periodType: aiPeriodType,\n confidence: aiConfidence,\n explanation:\n cleanText(rawAI.explanation),\n },\n },\n },\n },\n];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 400, - 144 - ], - "id": "3d608f18-2ed8-47a3-914c-c2494dfea8f7", - "name": "Normalizar análisis IA" - }, - { - "parameters": { - "options": { - "responseCode": 200, - "responseHeaders": { - "entries": [ - { - "name": "Content-Type", - "value": "application/json" - } - ] - } - } - }, - "type": "n8n-nodes-base.respondToWebhook", - "typeVersion": 1.5, - "position": [ - 608, - 144 - ], - "id": "b0843a40-9e7d-400f-b265-d563b789786f", - "name": "Responder análisis" - }, - { - "parameters": { - "content": "# 🧠 ANÁLISIS AUTOMÁTICO DE NÓMINA — GT Y TT\n\nAnaliza el nombre o título del archivo de nómina para identificar automáticamente el período correspondiente antes de ejecutar la verificación.\n\n## Recepción y validación\n\nEl Webhook recibe desde el Portal de Verificación de Nóminas la información del archivo seleccionado.\n\nLa solicitud puede incluir:\n\n- Nombre del archivo.\n- Título o descripción disponible.\n- País o portal de origen.\n- Identificador de la solicitud.\n- Información complementaria enviada por la aplicación.\n\nAntes de utilizar inteligencia artificial, el flujo valida que exista suficiente información para analizar el archivo.\n\n## Extracción con Gemini\n\nCuando la solicitud es válida, Gemini interpreta el nombre o título de la nómina e intenta determinar:\n\n- Año.\n- Mes.\n- Tipo de período.\n- Quincena 15.\n- Quincena 30 o fin de mes.\n- Nómina mensual.\n- País, cuando puede inferirse de forma confiable.\n- Nivel de confianza del análisis.\n\nGemini se utiliza únicamente para interpretar el texto recibido; no ejecuta el cruce de nómina ni modifica información.\n\n## Normalización del resultado\n\nLa respuesta de Gemini se transforma al formato requerido por la aplicación.\n\nEste bloque:\n\n- Limpia texto adicional generado por la IA.\n- Normaliza el año a formato numérico.\n- Convierte el mes al valor esperado por el portal.\n- Estandariza el tipo de período.\n- Corrige variaciones como “segunda quincena”, “fin de mes” o “30”.\n- Conserva los valores originales cuando la IA no puede determinar un dato.\n- Prepara una respuesta consistente para Guatemala y Trinidad y Tobago.\n\n## Respuesta al portal\n\nCuando el análisis termina, el workflow devuelve a la aplicación:\n\n- Indicador de éxito.\n- Año detectado.\n- Mes detectado.\n- Tipo de período detectado.\n- País identificado, cuando corresponda.\n- Nivel de confianza.\n- Datos que requieren revisión manual.\n\nLos valores detectados sirven para completar automáticamente el formulario, pero el usuario puede corregirlos antes de ejecutar el proceso.\n\n## Ruta de error\n\nCuando la solicitud es inválida:\n\n- No se llama a Gemini.\n- No se intenta interpretar información incompleta.\n- Se devuelve una respuesta de error al portal.\n- Se indica que falta el nombre del archivo u otro dato obligatorio.\n\n## Reglas\n\n- No inventar un período cuando el título no contiene información suficiente.\n- No ejecutar el cruce de nómina desde este workflow.\n- No tratar una predicción de baja confianza como un dato definitivo.\n- Mantener la opción de corrección manual en la aplicación.\n- Responder siempre al Webhook, tanto en la ruta válida como en la ruta de error.\n- Utilizar una estructura de respuesta compatible con los portales de Guatemala y Trinidad y Tobago.", - "height": 1488, - "width": 2256, - "color": 2 - }, - "type": "n8n-nodes-base.stickyNote", - "typeVersion": 1, - "position": [ - -1424, - -416 - ], - "id": "27e40ac1-a1eb-4ce5-ad0f-919b81b39dfb", - "name": "Sticky Note" - } - ], - "pinData": {}, - "connections": { - "Webhook - Analizar Nómina": { - "main": [ - [ - { - "node": "Validar y preparar solicitud", - "type": "main", - "index": 0 - } - ] - ] - }, - "Validar y preparar solicitud": { - "main": [ - [ - { - "node": "¿Solicitud válida?", - "type": "main", - "index": 0 - } - ] - ] - }, - "¿Solicitud válida?": { - "main": [ - [ - { - "node": "Extraer período con Gemini", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "Responder error", - "type": "main", - "index": 0 - } - ] - ] - }, - "Gemini - Analizar título nómina": { - "ai_languageModel": [ - [ - { - "node": "Extraer período con Gemini", - "type": "ai_languageModel", - "index": 0 - } - ] - ] - }, - "Extraer período con Gemini": { - "main": [ - [ - { - "node": "Normalizar análisis IA", - "type": "main", - "index": 0 - } - ] - ] - }, - "Normalizar análisis IA": { - "main": [ - [ - { - "node": "Responder análisis", - "type": "main", - "index": 0 - } - ] - ] - } - }, - "active": true, - "settings": { - "executionOrder": "v1", - "binaryMode": "separate", - "availableInMCP": true, - "timeSavedMode": "fixed", - "errorWorkflow": "puF4LUczoSz3hcek", - "timezone": "America/Santo_Domingo", - "callerPolicy": "workflowsFromSameOwner" - }, - "versionId": "1aba63da-c451-45a3-ab57-a88ac4bd4ea6", - "meta": { - "templateCredsSetupCompleted": true, - "instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70" - }, - "id": "E9cNPlUjeGnhFqW2", - "tags": [] -} \ No newline at end of file diff --git a/Flujo de n8n: Portal de Verificación de Nómina - GT.json b/Flujo de n8n: Portal de Verificación de Nómina - GT.json deleted file mode 100644 index 5752368..0000000 --- a/Flujo de n8n: Portal de Verificación de Nómina - GT.json +++ /dev/null @@ -1,1750 +0,0 @@ -{ - "name": "Portal de Verificación de Nómina - GT", - "nodes": [ - { - "parameters": { - "httpMethod": "POST", - "path": "nominagt-bamboo-test", - "responseMode": "responseNode", - "options": {} - }, - "type": "n8n-nodes-base.webhook", - "typeVersion": 2.1, - "position": [ - 14768, - 25968 - ], - "id": "bbb096bb-6f2c-48df-907c-06c9857943ac", - "name": "Webhook", - "webhookId": "4061b0e1-0d8e-4fb4-bea8-790c718447ee" - }, - { - "parameters": { - "jsCode": "const item = $input.first();\n\nconst body = item.json.body || {};\nconst binary = item.binary || {};\n\nlet metadata = {};\n\ntry {\n metadata = typeof body.metadata === 'string'\n ? JSON.parse(body.metadata)\n : body.metadata || {};\n} catch (error) {\n metadata = {};\n}\n\nconst binaryKeys = Object.keys(binary);\n\nconst payrollKey = binaryKeys.find((key) => key === 'payroll_file');\nconst bankKeys = binaryKeys.filter((key) => key.startsWith('bank_files'));\n\nconst payrollFile = payrollKey\n ? {\n binary_key: payrollKey,\n file_name: binary[payrollKey].fileName,\n file_extension: binary[payrollKey].fileExtension,\n mime_type: binary[payrollKey].mimeType,\n file_size: binary[payrollKey].fileSize,\n }\n : null;\n\nconst bankFiles = bankKeys.map((key) => ({\n binary_key: key,\n file_name: binary[key].fileName,\n file_extension: binary[key].fileExtension,\n mime_type: binary[key].mimeType,\n file_size: binary[key].fileSize,\n}));\n\nconst errors = [];\n\nif (!metadata.country || metadata.country !== 'GT') {\n errors.push('El país recibido no es Guatemala.');\n}\n\nif (!metadata.year) {\n errors.push('No se recibió el año del cruce.');\n}\n\nif (!metadata.month) {\n errors.push('No se recibió el mes del cruce.');\n}\n\nif (!metadata.period_type) {\n errors.push('No se recibió el tipo de quincena.');\n}\n\nif (!metadata.period_start || !metadata.period_end) {\n errors.push('No se recibió el período calculado.');\n}\n\nif (!payrollFile) {\n errors.push('No se recibió el archivo de nómina.');\n}\n\nif (bankFiles.length === 0) {\n errors.push('No se recibió ningún archivo CSV del banco.');\n}\n\nreturn [\n {\n json: {\n ok: errors.length === 0,\n stage: 'entrada_recibida',\n errors,\n metadata,\n payroll_file: payrollFile,\n bank_files: bankFiles,\n summary: {\n payroll_files_count: payrollFile ? 1 : 0,\n bank_files_count: bankFiles.length,\n },\n },\n binary,\n },\n];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 14976, - 25968 - ], - "id": "756188b6-dc61-4e3a-965a-47885e847e69", - "name": "Preparar entrada app" - }, - { - "parameters": { - "respondWith": "json", - "responseBody": "={{\n(() => {\n const data = $json || {};\n\n const original =\n data.originalResponse ||\n data.original_response ||\n data.response ||\n data.cruceResponse ||\n data.cruce_response ||\n data;\n\n const summary = original.summary || data.summary || {};\n const rows = original.rows || data.rows || [];\n const bankWithoutBamboo =\n original.bankWithoutBamboo ||\n data.bankWithoutBamboo ||\n [];\n const bambooSummary =\n original.bambooSummary ||\n data.bambooSummary ||\n {};\n\n const reportUrl =\n data.reportUrl ||\n data.report_url ||\n data.googleSheetUrl ||\n data.google_sheet_url ||\n data.spreadsheetUrl ||\n data.spreadsheet_url ||\n original.reportUrl ||\n original.report_url ||\n null;\n\n return {\n ok: original.ok ?? data.ok ?? true,\n message: reportUrl\n ? 'Cruce procesado correctamente. Google Sheet generado.'\n : 'Cruce procesado correctamente.',\n stage: reportUrl ? 'cruce_completado_con_reporte' : 'cruce_completado',\n errors: original.errors || data.errors || [],\n metadata: original.metadata || data.metadata || {},\n summary,\n rows,\n bankWithoutBamboo,\n bambooSummary,\n reportUrl,\n debug: {\n source_stage: data.stage || null,\n rows_returned:\n Array.isArray(rows) ? rows.length : 0,\n banco_sin_bamboo_rows:\n Array.isArray(bankWithoutBamboo)\n ? bankWithoutBamboo.length\n : 0,\n report_url_found: Boolean(reportUrl),\n },\n };\n})()\n}}", - "options": { - "responseCode": 200, - "responseHeaders": { - "entries": [ - { - "name": "Content-Type", - "value": "application/json" - } - ] - } - } - }, - "type": "n8n-nodes-base.respondToWebhook", - "typeVersion": 1.5, - "position": [ - 25056, - 26528 - ], - "id": "cfa673b1-08ba-41c0-b06c-15189672e1f2", - "name": "Respond to Webhook" - }, - { - "parameters": { - "jsCode": "const input = $input.first();\nconst json = input.json || {};\nconst binary = input.binary || {};\n\nfunction parseCsvLine(line) {\n const result = [];\n let current = '';\n let insideQuotes = false;\n\n for (let i = 0; i < line.length; i++) {\n const char = line[i];\n const nextChar = line[i + 1];\n\n if (char === '\"' && insideQuotes && nextChar === '\"') {\n current += '\"';\n i += 1;\n continue;\n }\n\n if (char === '\"') {\n insideQuotes = !insideQuotes;\n continue;\n }\n\n if (char === ',' && !insideQuotes) {\n result.push(current.trim());\n current = '';\n continue;\n }\n\n current += char;\n }\n\n result.push(current.trim());\n return result;\n}\n\nfunction normalizeText(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeForCompare(value) {\n return normalizeText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^a-z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeAccount(value) {\n return String(value ?? '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim();\n}\n\nfunction isValidAccount(value) {\n const account = normalizeAccount(value);\n return account.length >= 7 && !/^0+$/.test(account);\n}\n\nfunction parseMoney(value) {\n const raw = String(value ?? '');\n const cleaned = raw\n .replace(/USD/gi, '')\n .replace(/GTQ/gi, '')\n .replace(/QTZ/gi, '')\n .replace(/Q/gi, '')\n .replace(/,/g, '')\n .replace(/\\s+/g, '')\n .trim();\n\n const parsed = Number.parseFloat(cleaned);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction detectCurrency(value, concept) {\n const combined = `${value ?? ''} ${concept ?? ''}`.toUpperCase();\n return combined.includes('USD') ? 'USD' : 'QTZ';\n}\n\nfunction roundMoney(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n}\n\nfunction getColumnIndex(headers, expectedNames) {\n const normalizedHeaders = headers.map((header) => normalizeForCompare(header));\n\n for (const expected of expectedNames) {\n const normalizedExpected = normalizeForCompare(expected);\n const exact = normalizedHeaders.findIndex((header) => header === normalizedExpected);\n if (exact >= 0) return exact;\n }\n\n for (const expected of expectedNames) {\n const normalizedExpected = normalizeForCompare(expected);\n const partial = normalizedHeaders.findIndex((header) => header.includes(normalizedExpected));\n if (partial >= 0) return partial;\n }\n\n return -1;\n}\n\nfunction extractShipmentNumber(lines) {\n for (const line of lines.slice(0, 30)) {\n const normalized = normalizeForCompare(line);\n const match = normalized.match(/(?:detalle del envio|numero de envio)[^0-9]{0,30}(\\d{1,20})/);\n if (match?.[1]) return match[1];\n }\n\n return '';\n}\n\nfunction extractPlanNumber(lines) {\n for (const line of lines.slice(0, 30)) {\n const normalized = normalizeForCompare(line);\n const match = normalized.match(/(?:numero de plan|plan)[^a-z0-9]{0,20}([a-z0-9-]{2,30})/);\n if (match?.[1]) return match[1].toUpperCase();\n }\n\n return '';\n}\n\nfunction getNameWords(value) {\n const ignored = new Set(['de', 'del', 'la', 'las', 'los', 'y', 'e', 'el']);\n return normalizeForCompare(value)\n .split(' ')\n .filter((word) => word.length > 1 && !ignored.has(word));\n}\n\nfunction editDistance(a, b) {\n if (a === b) return 0;\n if (!a) return b.length;\n if (!b) return a.length;\n\n const previous = Array.from({ length: b.length + 1 }, (_, index) => index);\n\n for (let i = 1; i <= a.length; i++) {\n const current = [i];\n\n for (let j = 1; j <= b.length; j++) {\n const cost = a[i - 1] === b[j - 1] ? 0 : 1;\n current[j] = Math.min(\n current[j - 1] + 1,\n previous[j] + 1,\n previous[j - 1] + cost\n );\n }\n\n for (let j = 0; j < current.length; j++) previous[j] = current[j];\n }\n\n return previous[b.length];\n}\n\nfunction tokenMatches(a, b) {\n if (a === b) return true;\n const minLength = Math.min(a.length, b.length);\n if (minLength >= 8 && editDistance(a, b) <= 2) return true;\n if (minLength >= 5 && editDistance(a, b) <= 1) return true;\n return false;\n}\n\nfunction samePersonName(a, b) {\n const normalizedA = normalizeForCompare(a);\n const normalizedB = normalizeForCompare(b);\n\n if (!normalizedA || !normalizedB) return false;\n if (normalizedA === normalizedB) return true;\n\n const wordsA = getNameWords(a);\n const wordsB = getNameWords(b);\n if (!wordsA.length || !wordsB.length) return false;\n\n const usedB = new Set();\n let matches = 0;\n\n for (const wordA of wordsA) {\n const matchIndex = wordsB.findIndex((wordB, index) => {\n return !usedB.has(index) && tokenMatches(wordA, wordB);\n });\n\n if (matchIndex >= 0) {\n usedB.add(matchIndex);\n matches += 1;\n }\n }\n\n const smallerLength = Math.min(wordsA.length, wordsB.length);\n const ratio = matches / smallerLength;\n\n if (smallerLength <= 2) return matches === smallerLength && matches >= 2;\n return matches >= 2 && ratio >= 0.6;\n}\n\nconst bankKeys = Object.keys(binary).filter((key) => key.startsWith('bank_files'));\nconst allBankRows = [];\nconst fileSummaries = [];\nconst nameDifferences = [];\n\nfor (const key of bankKeys) {\n const file = binary[key];\n const buffer = await this.helpers.getBinaryDataBuffer(0, key);\n const text = buffer.toString('latin1');\n\n const lines = text\n .split(/\\r?\\n/)\n .map((line) => line.trim())\n .filter((line) => line.length > 0);\n\n const shipmentNumber = extractShipmentNumber(lines);\n const planNumber = extractPlanNumber(lines);\n\n const markerIndex = lines.findIndex((line) =>\n normalizeForCompare(line).includes('transacciones del envio')\n );\n\n if (markerIndex === -1) {\n fileSummaries.push({\n file_name: file.fileName,\n shipment_number: shipmentNumber,\n plan_number: planNumber,\n ok: false,\n rows_count: 0,\n total_amount: 0,\n error: 'No se encontró el bloque \"Transacciones del envío\".',\n });\n continue;\n }\n\n const headerIndex = lines.findIndex((line, index) => {\n if (index <= markerIndex) return false;\n const normalized = normalizeForCompare(line);\n return normalized.includes('cuenta destino') && normalized.includes('monto');\n });\n\n if (headerIndex === -1) {\n fileSummaries.push({\n file_name: file.fileName,\n shipment_number: shipmentNumber,\n plan_number: planNumber,\n ok: false,\n rows_count: 0,\n total_amount: 0,\n error: 'No se encontró el encabezado de transacciones.',\n });\n continue;\n }\n\n const headers = parseCsvLine(lines[headerIndex]).map(normalizeText);\n\n const idxCuentaDestino = getColumnIndex(headers, ['Cuenta Destino']);\n const idxNombreArchivo = getColumnIndex(headers, ['Nombre en Archivo']);\n const idxNombreCuentahabiente = getColumnIndex(headers, ['Nombre del Cuentahabiente']);\n const idxMonto = getColumnIndex(headers, ['Monto']);\n const idxConcepto = getColumnIndex(headers, ['Concepto']);\n const idxEstado = getColumnIndex(headers, ['Estado']);\n const idxReferencia = getColumnIndex(headers, ['Referencia']);\n const idxNumeroEnvio = getColumnIndex(headers, ['Número de envío', 'Numero de envio']);\n const idxNumeroPlan = getColumnIndex(headers, ['Número de plan', 'Numero de plan']);\n\n const rowsFromFile = [];\n\n for (let i = headerIndex + 1; i < lines.length; i++) {\n const values = parseCsvLine(lines[i]);\n\n const rawAccount = idxCuentaDestino >= 0 ? values[idxCuentaDestino] : '';\n const reference = normalizeText(idxReferencia >= 0 ? values[idxReferencia] : '');\n const referenceDigits = normalizeAccount(reference);\n const account = normalizeAccount(rawAccount);\n const amountRaw = idxMonto >= 0 ? values[idxMonto] : '';\n const amount = roundMoney(parseMoney(amountRaw));\n const concept = normalizeText(idxConcepto >= 0 ? values[idxConcepto] : '');\n\n if (amount <= 0) continue;\n\n const bankNameFile = normalizeText(idxNombreArchivo >= 0 ? values[idxNombreArchivo] : '');\n const bankAccountHolder = normalizeText(\n idxNombreCuentahabiente >= 0 ? values[idxNombreCuentahabiente] : ''\n );\n\n const validAccount = isValidAccount(account);\n const fallbackReference = isValidAccount(referenceDigits) ? referenceDigits : '';\n const displayAccount = validAccount ? account : fallbackReference;\n const currency = detectCurrency(amountRaw, concept);\n\n const rowShipment = normalizeText(idxNumeroEnvio >= 0 ? values[idxNumeroEnvio] : '') || shipmentNumber;\n const rowPlan = normalizeText(idxNumeroPlan >= 0 ? values[idxNumeroPlan] : '') || planNumber;\n\n const groupKey = validAccount\n ? `ACCOUNT:${account}:${currency}`\n : fallbackReference\n ? `REFERENCE:${fallbackReference}:${currency}`\n : `ROW:${file.fileName}:${i + 1}:${currency}`;\n\n const row = {\n source_file: file.fileName,\n row_number: i + 1,\n group_key: groupKey,\n account: displayAccount,\n raw_account: account,\n account_is_valid: validAccount,\n reference,\n shipment_number: rowShipment,\n plan_number: rowPlan,\n bank_name_file: bankNameFile,\n bank_account_holder: bankAccountHolder,\n amount,\n currency,\n concept,\n status: normalizeText(idxEstado >= 0 ? values[idxEstado] : ''),\n };\n\n rowsFromFile.push(row);\n allBankRows.push(row);\n\n if (\n bankNameFile &&\n bankAccountHolder &&\n !samePersonName(bankNameFile, bankAccountHolder)\n ) {\n nameDifferences.push({\n id: `bank_name_difference_${file.fileName}_${i + 1}`,\n source_file: file.fileName,\n row_number: i + 1,\n shipment_number: rowShipment,\n plan_number: rowPlan,\n account: displayAccount,\n reference,\n bank_name_file: bankNameFile,\n bank_account_holder: bankAccountHolder,\n amount,\n currency,\n status: 'Pendiente revisión',\n category: 'diferencia_nombre_banco',\n observation: `El Nombre en Archivo (${bankNameFile}) no coincide con el Nombre del Cuentahabiente (${bankAccountHolder}).`,\n });\n }\n }\n\n const fileTotal = roundMoney(rowsFromFile.reduce((sum, row) => sum + row.amount, 0));\n\n fileSummaries.push({\n file_name: file.fileName,\n shipment_number: shipmentNumber,\n plan_number: planNumber,\n ok: true,\n rows_count: rowsFromFile.length,\n total_amount: fileTotal,\n name_differences_count: nameDifferences.filter((row) => row.source_file === file.fileName).length,\n error: null,\n });\n}\n\nconst groupedMap = new Map();\n\nfor (const row of allBankRows) {\n const current = groupedMap.get(row.group_key) || {\n group_key: row.group_key,\n account: row.account,\n raw_account: row.raw_account,\n account_is_valid: row.account_is_valid,\n amount: 0,\n currency: row.currency,\n transactions_count: 0,\n bank_name_files: new Set(),\n bank_account_holders: new Set(),\n source_files: new Set(),\n shipment_numbers: new Set(),\n plan_numbers: new Set(),\n source_rows: [],\n };\n\n current.amount = roundMoney(current.amount + row.amount);\n current.transactions_count += 1;\n if (row.bank_name_file) current.bank_name_files.add(row.bank_name_file);\n if (row.bank_account_holder) current.bank_account_holders.add(row.bank_account_holder);\n if (row.source_file) current.source_files.add(row.source_file);\n if (row.shipment_number) current.shipment_numbers.add(row.shipment_number);\n if (row.plan_number) current.plan_numbers.add(row.plan_number);\n current.source_rows.push(row);\n\n groupedMap.set(row.group_key, current);\n}\n\nconst groupedByAccount = Array.from(groupedMap.values()).map((row) => {\n const bankNameFiles = Array.from(row.bank_name_files);\n const bankAccountHolders = Array.from(row.bank_account_holders);\n\n return {\n ...row,\n bank_name_file: bankNameFiles[0] || '',\n bank_account_holder: bankAccountHolders[0] || '',\n bank_name_files: bankNameFiles,\n bank_account_holders: bankAccountHolders,\n source_files: Array.from(row.source_files),\n shipment_numbers: Array.from(row.shipment_numbers),\n plan_numbers: Array.from(row.plan_numbers),\n };\n});\n\nconst totalsByCurrency = {};\nfor (const row of allBankRows) {\n totalsByCurrency[row.currency] = roundMoney((totalsByCurrency[row.currency] || 0) + row.amount);\n}\n\nconst bankTotal = roundMoney(allBankRows.reduce((sum, row) => sum + row.amount, 0));\n\nreturn [\n {\n json: {\n ...json,\n stage: 'banco_parseado',\n bank: {\n files_count: bankKeys.length,\n valid_files_count: fileSummaries.filter((file) => file.ok).length,\n rows_count: allBankRows.length,\n grouped_accounts_count: groupedByAccount.length,\n total_amount: bankTotal,\n totals_by_currency: totalsByCurrency,\n name_differences_count: nameDifferences.length,\n name_differences: nameDifferences,\n file_summaries: fileSummaries,\n rows: allBankRows,\n grouped_by_account: groupedByAccount,\n },\n },\n binary,\n },\n];\n" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 15648, - 25152 - ], - "id": "65e7f1a9-ac7a-46b5-8f9d-6729c5c80a31", - "name": "Parsear CSV banco GT" - }, - { - "parameters": { - "mode": "combine", - "combineBy": "combineByPosition", - "options": {} - }, - "type": "n8n-nodes-base.merge", - "typeVersion": 3.2, - "position": [ - 21264, - 26512 - ], - "id": "c47dde0f-72aa-49b6-b58b-f7051df5df62", - "name": "Merge" - }, - { - "parameters": { - "jsCode": "const data = $input.first().json || {};\n\nfunction roundMoney(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n}\n\nfunction moneyDiff(a, b) {\n return roundMoney((Number(a) || 0) - (Number(b) || 0));\n}\n\nfunction moneyEquals(a, b, tolerance = 0.02) {\n return Math.abs(roundMoney(a) - roundMoney(b)) <= tolerance;\n}\n\nfunction normalizeAccount(value) {\n return String(value ?? '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim();\n}\n\nfunction normalizeName(value) {\n return String(value ?? '')\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^a-z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction nameWords(value) {\n const ignored = new Set(['de', 'del', 'la', 'las', 'los', 'y', 'e', 'el']);\n return normalizeName(value)\n .split(' ')\n .filter((word) => word.length > 1 && !ignored.has(word));\n}\n\nfunction editDistance(a, b) {\n if (a === b) return 0;\n if (!a) return b.length;\n if (!b) return a.length;\n\n const previous = Array.from({ length: b.length + 1 }, (_, index) => index);\n\n for (let i = 1; i <= a.length; i++) {\n const current = [i];\n\n for (let j = 1; j <= b.length; j++) {\n const cost = a[i - 1] === b[j - 1] ? 0 : 1;\n\n current[j] = Math.min(\n current[j - 1] + 1,\n previous[j] + 1,\n previous[j - 1] + cost\n );\n }\n\n for (let j = 0; j < current.length; j++) {\n previous[j] = current[j];\n }\n }\n\n return previous[b.length];\n}\n\nfunction tokenMatches(a, b) {\n if (a === b) return true;\n\n const minLength = Math.min(a.length, b.length);\n\n if (minLength >= 8 && editDistance(a, b) <= 2) return true;\n if (minLength >= 5 && editDistance(a, b) <= 1) return true;\n\n return false;\n}\n\nfunction samePersonName(a, b) {\n const normalizedA = normalizeName(a);\n const normalizedB = normalizeName(b);\n\n if (!normalizedA || !normalizedB) return false;\n if (normalizedA === normalizedB) return true;\n\n const wordsA = nameWords(a);\n const wordsB = nameWords(b);\n\n if (!wordsA.length || !wordsB.length) return false;\n\n const usedB = new Set();\n let matches = 0;\n\n for (const wordA of wordsA) {\n const matchIndex = wordsB.findIndex((wordB, index) => {\n return !usedB.has(index) && tokenMatches(wordA, wordB);\n });\n\n if (matchIndex >= 0) {\n usedB.add(matchIndex);\n matches += 1;\n }\n }\n\n const smallerLength = Math.min(wordsA.length, wordsB.length);\n const ratio = matches / smallerLength;\n\n if (smallerLength <= 2) {\n return matches === smallerLength && matches >= 2;\n }\n\n return matches >= 2 && ratio >= 0.6;\n}\n\nfunction accountDistance(a, b) {\n return editDistance(normalizeAccount(a), normalizeAccount(b));\n}\n\nfunction accountRelationship(payrollAccount, bankAccount) {\n const payroll = normalizeAccount(payrollAccount);\n const bank = normalizeAccount(bankAccount);\n\n if (!payroll || !bank) {\n return { matches: false, type: 'none' };\n }\n\n if (payroll === bank) {\n return { matches: true, type: 'exact' };\n }\n\n const bankHasPayrollSuffix =\n bank.endsWith(payroll) &&\n bank.length > payroll.length &&\n bank.length - payroll.length <= 6;\n\n const payrollHasBankSuffix =\n payroll.endsWith(bank) &&\n payroll.length > bank.length &&\n payroll.length - bank.length <= 6;\n\n if (bankHasPayrollSuffix || payrollHasBankSuffix) {\n return { matches: true, type: 'reference_prefix' };\n }\n\n return { matches: false, type: 'none' };\n}\n\nfunction formatMoney(value) {\n return Math.abs(roundMoney(value)).toLocaleString('en-US', {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n });\n}\n\nfunction bankNames(bank) {\n return Array.from(new Set([\n ...(Array.isArray(bank.bank_name_files) ? bank.bank_name_files : []),\n ...(Array.isArray(bank.bank_account_holders) ? bank.bank_account_holders : []),\n bank.bank_name_file || '',\n bank.bank_account_holder || '',\n ].filter(Boolean)));\n}\n\nfunction bankMatchesName(bank, payrollName) {\n return bankNames(bank).some((name) => samePersonName(payrollName, name));\n}\n\nfunction bestBankDisplayName(bank) {\n return (\n bank.bank_name_file ||\n bank.bank_account_holder ||\n bankNames(bank)[0] ||\n ''\n );\n}\n\n\nfunction bambooAliases(employee) {\n return Array.from(new Set([\n ...(Array.isArray(employee.aliases) ? employee.aliases : []),\n employee.full_name || '',\n [\n employee.first_name,\n employee.middle_name,\n employee.last_name,\n ].filter(Boolean).join(' '),\n [\n employee.preferred_name,\n employee.last_name,\n ].filter(Boolean).join(' '),\n [\n employee.first_name,\n employee.last_name,\n ].filter(Boolean).join(' '),\n ].map((value) =>\n String(value || '').trim()\n ).filter(Boolean)));\n}\n\nfunction bambooEmployeeNumber(employee) {\n return normalizeAccount(\n employee.employee_number ||\n employee.employeeNumber ||\n ''\n );\n}\n\nfunction bankRowKey(row) {\n return [\n row.source_file || '',\n row.row_number || '',\n ].join('|');\n}\n\nfunction bankRowNames(row) {\n const rowKey = bankRowKey(row);\n\n const linkedPayrollNames =\n typeof linkedPayrollNamesByBankRow !== 'undefined'\n ? linkedPayrollNamesByBankRow.get(rowKey) || []\n : [];\n\n return Array.from(new Set([\n row.bank_name_file || '',\n row.bank_account_holder || '',\n row.participant_name || '',\n ...linkedPayrollNames,\n ].map((value) =>\n String(value || '').trim()\n ).filter(Boolean)));\n}\n\nfunction bankRowEmployeeNumbers(row) {\n const rowKey = bankRowKey(row);\n\n const linkedNumbers =\n typeof linkedPayrollNumbersByBankRow !== 'undefined'\n ? linkedPayrollNumbersByBankRow.get(rowKey) || []\n : [];\n\n return Array.from(new Set(\n linkedNumbers\n .map(normalizeAccount)\n .filter((value) => value.length >= 6)\n ));\n}\n\nfunction extractDigitSequences(value) {\n return Array.from(new Set(\n String(value || '')\n .match(/\\d{6,20}/g) || []\n )).map(normalizeAccount).filter(\n (value) => value.length >= 6\n );\n}\n\nfunction bankRowReferenceNumbers(row) {\n return Array.from(new Set([\n ...extractDigitSequences(row.reference),\n ...extractDigitSequences(row.concept),\n ...extractDigitSequences(row.addenda),\n ...extractDigitSequences(row.participant_id),\n ]));\n}\n\nfunction isClearlyNonEmployeePayment(row) {\n const normalized = normalizeName([\n row.concept || '',\n row.bank_name_file || '',\n row.bank_account_holder || '',\n ].join(' '));\n\n return [\n 'pension alimenticia',\n 'embargo judicial',\n 'retencion judicial',\n ].some((token) =>\n normalized.includes(normalizeName(token))\n );\n}\n\nfunction buildBambooSearchIndex(employees) {\n const records = [];\n const exactAliasSets = new Map();\n const tokenIndexSets = new Map();\n const employeeNumberSets = new Map();\n\n for (\n let employeeIndex = 0;\n employeeIndex < employees.length;\n employeeIndex++\n ) {\n const employee = employees[employeeIndex];\n const aliases = [];\n const seenAliases = new Set();\n\n for (const rawAlias of bambooAliases(employee)) {\n const normalized = normalizeName(rawAlias);\n\n if (\n !normalized ||\n seenAliases.has(normalized)\n ) {\n continue;\n }\n\n seenAliases.add(normalized);\n\n const words = Array.from(new Set(\n nameWords(normalized)\n ));\n\n if (!words.length) continue;\n\n const wordSet = new Set(words);\n\n aliases.push({\n raw: rawAlias,\n normalized,\n words,\n wordSet,\n });\n\n let exactSet =\n exactAliasSets.get(normalized);\n\n if (!exactSet) {\n exactSet = new Set();\n exactAliasSets.set(\n normalized,\n exactSet\n );\n }\n\n exactSet.add(employeeIndex);\n\n for (const token of words) {\n if (token.length < 3) continue;\n\n let tokenSet =\n tokenIndexSets.get(token);\n\n if (!tokenSet) {\n tokenSet = new Set();\n tokenIndexSets.set(\n token,\n tokenSet\n );\n }\n\n tokenSet.add(employeeIndex);\n }\n }\n\n const employeeNumber =\n bambooEmployeeNumber(employee);\n\n if (employeeNumber.length >= 6) {\n let numberSet =\n employeeNumberSets.get(\n employeeNumber\n );\n\n if (!numberSet) {\n numberSet = new Set();\n employeeNumberSets.set(\n employeeNumber,\n numberSet\n );\n }\n\n numberSet.add(employeeIndex);\n }\n\n records.push({\n employee,\n aliases,\n employeeNumber,\n });\n }\n\n const exactAliasMap = new Map();\n const tokenIndex = new Map();\n const employeeNumberMap = new Map();\n\n for (const [key, value] of exactAliasSets) {\n exactAliasMap.set(\n key,\n Array.from(value)\n );\n }\n\n for (const [key, value] of tokenIndexSets) {\n tokenIndex.set(\n key,\n Array.from(value)\n );\n }\n\n for (\n const [key, value] of\n employeeNumberSets\n ) {\n employeeNumberMap.set(\n key,\n Array.from(value)\n );\n }\n\n return {\n records,\n exactAliasMap,\n tokenIndex,\n employeeNumberMap,\n };\n}\n\nfunction aliasMatchDetails(\n queryName,\n alias\n) {\n const queryNormalized =\n normalizeName(queryName);\n\n if (!queryNormalized) return null;\n\n if (\n queryNormalized === alias.normalized\n ) {\n return {\n score: 1,\n exact: true,\n containment: true,\n matchedTokens:\n alias.words.length,\n exactMatches:\n alias.words.length,\n fuzzyMatches: 0,\n queryCoverage: 1,\n aliasCoverage: 1,\n };\n }\n\n const queryWords = Array.from(\n new Set(nameWords(queryNormalized))\n );\n\n if (\n queryWords.length < 2 ||\n alias.words.length < 2\n ) {\n return null;\n }\n\n const querySet = new Set(queryWords);\n\n let exactMatches = 0;\n\n for (const queryWord of queryWords) {\n if (alias.wordSet.has(queryWord)) {\n exactMatches += 1;\n }\n }\n\n const queryInsideAlias =\n exactMatches === queryWords.length;\n\n let aliasWordsInsideQuery = 0;\n\n for (const aliasWord of alias.words) {\n if (querySet.has(aliasWord)) {\n aliasWordsInsideQuery += 1;\n }\n }\n\n const aliasInsideQuery =\n aliasWordsInsideQuery ===\n alias.words.length;\n\n if (\n queryInsideAlias ||\n aliasInsideQuery\n ) {\n const shorterLength = Math.min(\n queryWords.length,\n alias.words.length\n );\n const longerLength = Math.max(\n queryWords.length,\n alias.words.length\n );\n\n return {\n score:\n 0.90 +\n (\n shorterLength /\n Math.max(1, longerLength)\n ) * 0.09,\n exact: false,\n containment: true,\n matchedTokens: exactMatches,\n exactMatches,\n fuzzyMatches: 0,\n queryCoverage:\n exactMatches / queryWords.length,\n aliasCoverage:\n aliasWordsInsideQuery /\n alias.words.length,\n };\n }\n\n if (exactMatches < 2) {\n return null;\n }\n\n const usedAliasWords = new Set();\n let fuzzyMatches = 0;\n\n for (let queryIndex = 0;\n queryIndex < queryWords.length;\n queryIndex++\n ) {\n const queryWord =\n queryWords[queryIndex];\n\n if (alias.wordSet.has(queryWord)) {\n continue;\n }\n\n const aliasIndex =\n alias.words.findIndex(\n (aliasWord, currentIndex) =>\n !usedAliasWords.has(\n currentIndex\n ) &&\n !querySet.has(aliasWord) &&\n tokenMatches(\n queryWord,\n aliasWord\n )\n );\n\n if (aliasIndex >= 0) {\n usedAliasWords.add(aliasIndex);\n fuzzyMatches += 1;\n }\n }\n\n const matchedTokens =\n exactMatches + fuzzyMatches;\n\n if (\n fuzzyMatches > 1 ||\n matchedTokens < 3\n ) {\n return null;\n }\n\n const queryCoverage =\n matchedTokens / queryWords.length;\n\n const aliasCoverage =\n matchedTokens / alias.words.length;\n\n if (\n queryCoverage < 0.67 ||\n aliasCoverage < 0.60\n ) {\n return null;\n }\n\n return {\n score:\n queryCoverage * 0.48 +\n aliasCoverage * 0.32 +\n (\n exactMatches /\n matchedTokens\n ) * 0.20,\n exact: false,\n containment: false,\n matchedTokens,\n exactMatches,\n fuzzyMatches,\n queryCoverage,\n aliasCoverage,\n };\n}\n\nfunction candidateIndexesForName(\n normalizedName\n) {\n const tokens = Array.from(\n new Set(nameWords(normalizedName))\n ).filter((token) =>\n token.length >= 3\n );\n\n const votes = new Map();\n\n for (const token of tokens) {\n const indexes =\n bambooSearch.tokenIndex.get(token) || [];\n\n /*\n * Los tokens muy comunes no aportan suficiente identidad.\n * Ignorarlos evita cientos de candidatos y mantiene el nodo rápido.\n */\n if (indexes.length > 240) continue;\n\n for (const index of indexes) {\n votes.set(\n index,\n (votes.get(index) || 0) + 1\n );\n }\n }\n\n return Array.from(votes.entries())\n .filter(([, voteCount]) =>\n voteCount >= 2 ||\n (\n tokens.length === 2 &&\n voteCount === 2\n )\n )\n .sort((left, right) =>\n right[1] - left[1]\n )\n .slice(0, 90)\n .map(([index]) => index);\n}\n\nconst bambooMatchCache = new Map();\n\nfunction findBambooMatch(bankRow) {\n const names = bankRowNames(bankRow)\n .map((raw) => ({\n raw,\n normalized:\n normalizeName(raw),\n tokenCount:\n nameWords(raw).length,\n }))\n .filter((entry) =>\n entry.normalized &&\n entry.tokenCount >= 2\n )\n .sort((left, right) =>\n right.tokenCount -\n left.tokenCount\n );\n\n const directEmployeeNumbers =\n bankRowEmployeeNumbers(bankRow);\n\n const referenceNumbers =\n bankRowReferenceNumbers(bankRow);\n\n const cacheKey = [\n ...directEmployeeNumbers\n .slice()\n .sort(),\n ...referenceNumbers\n .slice()\n .sort(),\n ...names\n .map((entry) =>\n entry.normalized\n )\n .sort(),\n ].join('|');\n\n if (bambooMatchCache.has(cacheKey)) {\n return bambooMatchCache.get(\n cacheKey\n );\n }\n\n const numberCandidates = new Set();\n\n for (const employeeNumber of [\n ...directEmployeeNumbers,\n ...referenceNumbers,\n ]) {\n for (\n const index of\n bambooSearch.employeeNumberMap\n .get(employeeNumber) || []\n ) {\n numberCandidates.add(index);\n }\n }\n\n if (numberCandidates.size === 1) {\n const index =\n numberCandidates.values()\n .next().value;\n\n const result = {\n found: true,\n matched_by:\n directEmployeeNumbers.length\n ? 'employee_number_payroll'\n : 'employee_number_reference',\n confidence: 1,\n employee:\n bambooSearch.records[index]\n .employee,\n };\n\n bambooMatchCache.set(\n cacheKey,\n result\n );\n\n return result;\n }\n\n\n /*\n * Consulta primero la resolución calculada una sola vez en el\n * normalizador. Esto evita repetir búsquedas aproximadas por cada fila\n * bancaria y mantiene el task runner estable incluso con miles de\n * empleados en BambooHR.\n */\n const precomputedNameMatches =\n data.bamboo?.resolved_name_matches ||\n {};\n\n const precomputedNameEntries =\n names.map((entry) => {\n const raw =\n typeof entry === 'string'\n ? entry\n : entry?.raw || '';\n\n return {\n raw,\n normalized:\n typeof entry === 'string'\n ? normalizeName(entry)\n : (\n entry?.normalized ||\n normalizeName(raw)\n ),\n token_count:\n typeof entry === 'string'\n ? nameWords(entry).length\n : (\n entry?.tokenCount ||\n nameWords(raw).length\n ),\n };\n }).filter((entry) =>\n entry.normalized\n );\n\n const precomputedFoundByEmployee =\n new Map();\n\n function resolutionEmployeeKey(\n employee\n ) {\n return (\n String(\n employee?.bamboo_id ||\n ''\n ).trim() ||\n normalizeAccount(\n employee?.employee_number ||\n employee?.employeeNumber ||\n ''\n ) ||\n normalizeName(\n employee?.full_name ||\n employee?.displayName ||\n ''\n )\n );\n }\n\n for (\n const nameEntry of\n precomputedNameEntries\n ) {\n const decision =\n precomputedNameMatches[\n nameEntry.normalized\n ];\n\n if (\n !decision ||\n decision.found !== true\n ) {\n continue;\n }\n\n let employee =\n Number.isInteger(\n decision.employee_index\n )\n ? bambooEmployees[\n decision.employee_index\n ]\n : null;\n\n const expectedKey =\n String(\n decision.employee_key ||\n ''\n ).trim();\n\n if (\n !employee ||\n (\n expectedKey &&\n resolutionEmployeeKey(\n employee\n ) !== expectedKey\n )\n ) {\n employee =\n bambooEmployees.find(\n (candidate) =>\n resolutionEmployeeKey(\n candidate\n ) === expectedKey\n ) || null;\n }\n\n if (!employee) continue;\n\n const employeeKey =\n resolutionEmployeeKey(employee);\n\n const candidate = {\n employee,\n employee_key:\n employeeKey,\n confidence:\n Number(\n decision.confidence || 0\n ),\n matched_by:\n decision.matched_by ||\n 'precomputed_name',\n bank_name:\n nameEntry.raw,\n bamboo_alias:\n decision.bamboo_alias ||\n employee.full_name ||\n '',\n informativeness:\n nameEntry.token_count,\n };\n\n const existing =\n precomputedFoundByEmployee\n .get(employeeKey);\n\n if (\n !existing ||\n candidate.confidence >\n existing.confidence ||\n (\n candidate.confidence ===\n existing.confidence &&\n candidate.informativeness >\n existing.informativeness\n )\n ) {\n precomputedFoundByEmployee.set(\n employeeKey,\n candidate\n );\n }\n }\n\n const precomputedRanked =\n Array.from(\n precomputedFoundByEmployee\n .values()\n ).sort((left, right) => {\n if (\n right.confidence !==\n left.confidence\n ) {\n return (\n right.confidence -\n left.confidence\n );\n }\n\n return (\n right.informativeness -\n left.informativeness\n );\n });\n\n if (precomputedRanked.length === 1) {\n const best =\n precomputedRanked[0];\n\n const result = {\n found: true,\n matched_by:\n best.matched_by,\n confidence:\n best.confidence,\n employee:\n best.employee,\n bank_name:\n best.bank_name,\n bamboo_alias:\n best.bamboo_alias,\n };\n\n bambooMatchCache.set(\n cacheKey,\n result\n );\n\n return result;\n }\n\n if (\n precomputedRanked.length > 1\n ) {\n const best =\n precomputedRanked[0];\n\n const second =\n precomputedRanked[1];\n\n if (\n best.confidence -\n second.confidence >= 0.08\n ) {\n const result = {\n found: true,\n matched_by:\n best.matched_by,\n confidence:\n best.confidence,\n employee:\n best.employee,\n bank_name:\n best.bank_name,\n bamboo_alias:\n best.bamboo_alias,\n };\n\n bambooMatchCache.set(\n cacheKey,\n result\n );\n\n return result;\n }\n\n const result = {\n found: false,\n matched_by: null,\n confidence:\n best.confidence,\n employee: null,\n ambiguous: true,\n reason:\n 'conflicting_precomputed_name_matches',\n best_candidate: {\n employee:\n best.employee,\n score:\n best.confidence,\n bank_name:\n best.bank_name,\n bamboo_alias:\n best.bamboo_alias,\n },\n };\n\n bambooMatchCache.set(\n cacheKey,\n result\n );\n\n return result;\n }\n\n\n /*\n * Primero se intenta una coincidencia textual exacta.\n * Se acepta solamente cuando todos los alias exactos apuntan\n * al mismo empleado.\n */\n const exactIndexes = new Set();\n\n for (const name of names) {\n for (\n const index of\n bambooSearch.exactAliasMap\n .get(name.normalized) || []\n ) {\n exactIndexes.add(index);\n }\n }\n\n if (exactIndexes.size === 1) {\n const index =\n exactIndexes.values()\n .next().value;\n\n const result = {\n found: true,\n matched_by: 'exact_name',\n confidence: 1,\n employee:\n bambooSearch.records[index]\n .employee,\n bank_name:\n names[0]?.raw || '',\n bamboo_alias:\n bambooSearch.records[index]\n .aliases[0]?.raw || '',\n };\n\n bambooMatchCache.set(\n cacheKey,\n result\n );\n\n return result;\n }\n\n const candidatesByEmployee =\n new Map();\n\n /*\n * La búsqueda ya no recorre todos los empleados.\n * Cada nombre consulta el índice invertido y solo compara\n * un máximo de 90 candidatos que comparten al menos dos palabras.\n */\n for (const name of names) {\n const candidateIndexes =\n candidateIndexesForName(\n name.normalized\n );\n\n for (const index of candidateIndexes) {\n const record =\n bambooSearch.records[index];\n\n let bestAliasMatch = null;\n let bestAlias = '';\n\n for (const alias of record.aliases) {\n const details =\n aliasMatchDetails(\n name.raw,\n alias\n );\n\n if (\n details &&\n (\n !bestAliasMatch ||\n details.score >\n bestAliasMatch.score\n )\n ) {\n bestAliasMatch = details;\n bestAlias = alias.raw;\n }\n }\n\n if (!bestAliasMatch) continue;\n\n const existing =\n candidatesByEmployee.get(index);\n\n const candidate = {\n index,\n employee: record.employee,\n score: bestAliasMatch.score,\n details: bestAliasMatch,\n bank_name: name.raw,\n bamboo_alias: bestAlias,\n informativeness:\n name.tokenCount,\n };\n\n if (\n !existing ||\n candidate.score >\n existing.score ||\n (\n candidate.score ===\n existing.score &&\n candidate.informativeness >\n existing.informativeness\n )\n ) {\n candidatesByEmployee.set(\n index,\n candidate\n );\n }\n }\n }\n\n const rankedCandidates =\n Array.from(\n candidatesByEmployee.values()\n ).sort((left, right) => {\n if (right.score !== left.score) {\n return right.score - left.score;\n }\n\n if (\n right.details.exactMatches !==\n left.details.exactMatches\n ) {\n return (\n right.details.exactMatches -\n left.details.exactMatches\n );\n }\n\n return (\n right.informativeness -\n left.informativeness\n );\n });\n\n const best =\n rankedCandidates[0] || null;\n\n const second =\n rankedCandidates[1] || null;\n\n const margin =\n best\n ? best.score -\n (second?.score || 0)\n : 0;\n\n const strongContainment =\n Boolean(\n best?.details?.containment &&\n best.details.exactMatches >= 2 &&\n (\n !second ||\n margin >= 0.035 ||\n best.details.exactMatches >\n second.details.exactMatches\n )\n );\n\n const strongPartial =\n Boolean(\n best &&\n !best.details.containment &&\n best.score >= 0.82 &&\n best.details.exactMatches >= 2 &&\n (\n !second ||\n margin >= 0.07\n )\n );\n\n let result;\n\n if (\n best &&\n (\n strongContainment ||\n strongPartial\n )\n ) {\n result = {\n found: true,\n matched_by:\n strongContainment\n ? 'unique_token_containment'\n : 'strong_indexed_name',\n confidence:\n Math.min(1, best.score),\n employee: best.employee,\n bank_name: best.bank_name,\n bamboo_alias:\n best.bamboo_alias,\n };\n } else {\n result = {\n found: false,\n matched_by: null,\n confidence:\n best?.score || 0,\n employee: null,\n ambiguous: Boolean(\n best &&\n second &&\n best.score >= 0.75 &&\n margin < 0.07\n ),\n best_candidate:\n best\n ? {\n employee:\n best.employee,\n score:\n best.score,\n bank_name:\n best.bank_name,\n bamboo_alias:\n best.bamboo_alias,\n }\n : null,\n };\n }\n\n bambooMatchCache.set(\n cacheKey,\n result\n );\n\n return result;\n}\n\nfunction supplementKey(supplement) {\n return [\n supplement.source_sheet || '',\n supplement.row_number || '',\n supplement.supplement_id || '',\n supplement.account || '',\n supplement.payroll_amount || 0,\n ].join('|');\n}\n\nconst payrollAccounts = (data.payroll?.grouped_by_account || [])\n .map((row) => ({\n ...row,\n group_key:\n row.group_key ||\n `${normalizeAccount(row.account)}:${row.currency || 'QTZ'}`,\n account: normalizeAccount(row.account),\n employee_name: row.employee_name || row.employee || '',\n employee_number: row.employee_number || row.employeeNumber || '',\n currency: row.currency || 'QTZ',\n payroll_amount: roundMoney(row.payroll_amount || row.payrollAmount),\n source_rows: Array.isArray(row.source_rows) ? [...row.source_rows] : [],\n source_sheets: Array.isArray(row.source_sheets)\n ? [...row.source_sheets]\n : [],\n }))\n .filter((row) => row.account && row.payroll_amount > 0);\n\nconst payrollNoAccountRows = (data.payroll?.no_account_rows || [])\n .map((row) => ({\n ...row,\n account: '',\n employee_name: row.employee_name || row.employee || '',\n employee_number: row.employee_number || row.employeeNumber || '',\n currency: row.currency || 'QTZ',\n payroll_amount: roundMoney(row.payroll_amount || row.payrollAmount),\n }))\n .filter((row) => row.payroll_amount > 0);\n\nconst bankAccounts = (data.bank?.grouped_by_account || [])\n .map((row) => ({\n ...row,\n group_key:\n row.group_key ||\n `ACCOUNT:${normalizeAccount(row.account)}:${row.currency || 'QTZ'}`,\n account: normalizeAccount(row.account),\n account_is_valid: Boolean(row.account_is_valid),\n currency: row.currency || 'QTZ',\n amount: roundMoney(row.amount || row.bank_amount || row.bankAmount),\n source_rows: Array.isArray(row.source_rows) ? [...row.source_rows] : [],\n }))\n .filter((row) => row.amount > 0);\n\nconst bambooEmployees = Array.isArray(data.bamboo?.employees)\n ? data.bamboo.employees\n : [];\n\nconst bambooValidationAvailable =\n data.bamboo?.fetch_complete === true &&\n data.bamboo?.validation_available === true &&\n bambooEmployees.length > 0;\n\nconst bambooValidationWarning =\n bambooValidationAvailable\n ? null\n : (\n data.errors?.find((error) =>\n String(error || '').toLowerCase().includes('bamboohr')\n ) ||\n 'La validación Banco sin Bamboo no estuvo disponible porque la descarga de empleados de BambooHR quedó incompleta.'\n );\n\nconst bambooSearch = buildBambooSearchIndex(\n bambooEmployees\n);\n\nconst bankDetailRows = Array.isArray(data.bank?.rows)\n ? data.bank.rows\n : [];\n\nconst potentialSupplements = (\n data.payroll?.potential_supplements ||\n data.debug_payroll?.potential_supplements ||\n data.debug_payroll?.attached_supplements ||\n []\n)\n .map((row) => ({\n ...row,\n account: normalizeAccount(row.account),\n currency: row.currency || 'QTZ',\n payroll_amount: roundMoney(row.payroll_amount || row.payrollAmount),\n }))\n .filter((row) => {\n const id = normalizeName(row.supplement_id || '');\n\n return (\n row.account &&\n row.payroll_amount >= 10 &&\n !id.includes('back up')\n );\n });\n\nconst supplementsByAccountCurrency = new Map();\n\nfor (const supplement of potentialSupplements) {\n const key = `${supplement.account}:${supplement.currency}`;\n const current = supplementsByAccountCurrency.get(key) || [];\n\n current.push(supplement);\n supplementsByAccountCurrency.set(key, current);\n}\n\nfunction chooseConditionalSupplements(payroll, bank) {\n const baseAmount = roundMoney(payroll.payroll_amount);\n const bankAmount = roundMoney(bank.amount);\n const candidates =\n supplementsByAccountCurrency.get(\n `${payroll.account}:${payroll.currency}`\n ) || [];\n\n if (\n !candidates.length ||\n bankAmount <= baseAmount + 0.02\n ) {\n return {\n selected: [],\n effectiveAmount: baseAmount,\n baseAmount,\n improvement: 0,\n };\n }\n\n const baseDifference = Math.abs(baseAmount - bankAmount);\n let bestSelected = [];\n let bestAmount = baseAmount;\n let bestDifference = baseDifference;\n\n if (candidates.length <= 12) {\n const combinations = 1 << candidates.length;\n\n for (let mask = 1; mask < combinations; mask++) {\n const selected = [];\n let selectedTotal = 0;\n\n for (let index = 0; index < candidates.length; index++) {\n if ((mask & (1 << index)) !== 0) {\n selected.push(candidates[index]);\n selectedTotal = roundMoney(\n selectedTotal + candidates[index].payroll_amount\n );\n }\n }\n\n const candidateAmount = roundMoney(baseAmount + selectedTotal);\n const candidateDifference = Math.abs(\n candidateAmount - bankAmount\n );\n\n if (candidateDifference < bestDifference) {\n bestSelected = selected;\n bestAmount = candidateAmount;\n bestDifference = candidateDifference;\n }\n }\n } else {\n const sorted = [...candidates].sort(\n (a, b) => b.payroll_amount - a.payroll_amount\n );\n\n let runningAmount = baseAmount;\n const selected = [];\n\n for (const candidate of sorted) {\n const nextAmount = roundMoney(\n runningAmount + candidate.payroll_amount\n );\n\n if (\n Math.abs(nextAmount - bankAmount) <\n Math.abs(runningAmount - bankAmount)\n ) {\n selected.push(candidate);\n runningAmount = nextAmount;\n }\n }\n\n bestSelected = selected;\n bestAmount = runningAmount;\n bestDifference = Math.abs(bestAmount - bankAmount);\n }\n\n const improvement = roundMoney(\n baseDifference - bestDifference\n );\n\n // Evita sumar valores accidentales o inmateriales, como un \"Asignado\" de Q1.\n if (!bestSelected.length || improvement < 5) {\n return {\n selected: [],\n effectiveAmount: baseAmount,\n baseAmount,\n improvement: 0,\n };\n }\n\n return {\n selected: bestSelected,\n effectiveAmount: roundMoney(bestAmount),\n baseAmount,\n improvement,\n };\n}\n\nfunction getDirectCandidates(payroll, matchedBankKeys) {\n return bankAccounts\n .filter((bank) => {\n if (matchedBankKeys.has(bank.group_key)) return false;\n if (bank.currency !== payroll.currency) return false;\n\n const relationship = accountRelationship(\n payroll.account,\n bank.account\n );\n\n if (!relationship.matches) return false;\n\n // Un sufijo de referencia solamente es válido cuando el nombre también\n // corresponde a la misma persona.\n if (\n relationship.type === 'reference_prefix' &&\n !bankMatchesName(bank, payroll.employee_name)\n ) {\n return false;\n }\n\n return true;\n })\n .map((bank) => {\n const relationship = accountRelationship(\n payroll.account,\n bank.account\n );\n const supplementDecision =\n chooseConditionalSupplements(payroll, bank);\n\n return {\n bank,\n relationship,\n supplementDecision,\n nameMatches: bankMatchesName(bank, payroll.employee_name),\n };\n })\n .sort((a, b) => {\n const exactDifference =\n Number(b.relationship.type === 'exact') -\n Number(a.relationship.type === 'exact');\n\n if (exactDifference !== 0) return exactDifference;\n\n const nameDifference =\n Number(b.nameMatches) - Number(a.nameMatches);\n\n if (nameDifference !== 0) return nameDifference;\n\n return (\n Math.abs(\n a.supplementDecision.effectiveAmount - a.bank.amount\n ) -\n Math.abs(\n b.supplementDecision.effectiveAmount - b.bank.amount\n )\n );\n });\n}\n\nfunction buildSources(payroll, selectedSupplements) {\n const supplementRows = selectedSupplements.map((row) => ({\n source_sheet: row.source_sheet,\n row_number: row.row_number,\n amount: row.payroll_amount,\n supplement_original_name:\n row.supplement_original_name || row.employee_name || '',\n supplement_id: row.supplement_id || '',\n applied_conditionally: true,\n }));\n\n const sourceRows = [\n ...(payroll.source_rows || []),\n ...supplementRows,\n ];\n\n const sourceSheets = Array.from(new Set([\n ...(payroll.source_sheets || []),\n ...selectedSupplements\n .map((row) => row.source_sheet)\n .filter(Boolean),\n ]));\n\n return { sourceRows, sourceSheets };\n}\n\nconst matchedPayrollKeys = new Set();\nconst matchedBankKeys = new Set();\nconst matchedNoAccountIndexes = new Set();\nconst appliedSupplementKeys = new Set();\nconst appliedSupplements = [];\nconst finalExactReconciliations = [];\nconst rows = [];\n\nfunction registerSupplements(selected) {\n for (const supplement of selected || []) {\n const key = supplementKey(supplement);\n\n if (!appliedSupplementKeys.has(key)) {\n appliedSupplementKeys.add(key);\n appliedSupplements.push(supplement);\n }\n }\n}\n\n// 1) Cuenta exacta o referencia con prefijo, y monto conciliado.\nfor (const payroll of payrollAccounts) {\n const candidates = getDirectCandidates(\n payroll,\n matchedBankKeys\n ).filter((candidate) => {\n return moneyEquals(\n candidate.supplementDecision.effectiveAmount,\n candidate.bank.amount\n );\n });\n\n if (candidates.length !== 1) continue;\n\n const candidate = candidates[0];\n const bank = candidate.bank;\n const decision = candidate.supplementDecision;\n const sources = buildSources(payroll, decision.selected);\n\n matchedPayrollKeys.add(payroll.group_key);\n matchedBankKeys.add(bank.group_key);\n registerSupplements(decision.selected);\n\n rows.push({\n id: `match_${payroll.group_key}_${bank.group_key}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: payroll.currency,\n payrollAmount: decision.effectiveAmount,\n payroll_amount: decision.effectiveAmount,\n payrollBaseAmount: decision.baseAmount,\n payroll_base_amount: decision.baseAmount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: 0,\n status: 'Coincidencia',\n category: 'coincidencia',\n subcategory:\n candidate.relationship.type === 'reference_prefix'\n ? 'referencia_bancaria_con_prefijo'\n : decision.selected.length\n ? 'cuenta_monto_y_suplemento_condicional'\n : 'cuenta_y_monto_coinciden',\n observation:\n candidate.relationship.type === 'reference_prefix'\n ? 'Conciliado por nombre, monto y referencia bancaria con prefijo.'\n : decision.selected.length\n ? 'Conciliado correctamente. Se aplicó un suplemento porque el banco mostró un pago adicional.'\n : 'Conciliado correctamente.',\n applied_supplements: decision.selected,\n source_sheets: sources.sourceSheets,\n source_rows: sources.sourceRows,\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 2) Cuenta diferente, pero nombre y monto coinciden.\n// Se ejecuta antes de crear diferencias directas para resolver casos como\n// Ashly/Ashley Ramos: la cuenta de la nómina apunta a otra transacción,\n// pero existe otra cuenta bancaria con el mismo nombre y monto correcto.\nfor (const payroll of payrollAccounts) {\n if (matchedPayrollKeys.has(payroll.group_key)) continue;\n\n const candidates = bankAccounts\n .filter((bank) => {\n if (matchedBankKeys.has(bank.group_key)) return false;\n if (bank.currency !== payroll.currency) return false;\n if (!bankMatchesName(bank, payroll.employee_name)) return false;\n\n const decision = chooseConditionalSupplements(\n payroll,\n bank\n );\n\n return moneyEquals(\n decision.effectiveAmount,\n bank.amount\n );\n })\n .map((bank) => ({\n bank,\n supplementDecision: chooseConditionalSupplements(\n payroll,\n bank\n ),\n }));\n\n if (candidates.length !== 1) continue;\n\n const candidate = candidates[0];\n const bank = candidate.bank;\n const decision = candidate.supplementDecision;\n const relationship = accountRelationship(\n payroll.account,\n bank.account\n );\n\n // Las referencias con prefijo ya debieron resolverse en el paso 1.\n if (relationship.type === 'reference_prefix') continue;\n\n const sources = buildSources(payroll, decision.selected);\n\n matchedPayrollKeys.add(payroll.group_key);\n matchedBankKeys.add(bank.group_key);\n registerSupplements(decision.selected);\n\n rows.push({\n id: `possible_wrong_account_${payroll.group_key}_${bank.group_key}`,\n employee: payroll.employee_name || bestBankDisplayName(bank),\n employee_name:\n payroll.employee_name || bestBankDisplayName(bank),\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: payroll.currency,\n payrollAmount: decision.effectiveAmount,\n payroll_amount: decision.effectiveAmount,\n payrollBaseAmount: decision.baseAmount,\n payroll_base_amount: decision.baseAmount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: 0,\n status: 'Riesgo',\n category: 'posible_cuenta_mal_digitada',\n subcategory:\n 'nombre_y_monto_coinciden_cuenta_diferente',\n observation:\n `El nombre y el monto coinciden, pero la cuenta de nómina ` +\n `(${payroll.account || 'sin cuenta'}) es diferente a la cuenta ` +\n `del banco (${bank.account || 'sin cuenta válida'}).`,\n applied_supplements: decision.selected,\n source_sheets: sources.sourceSheets,\n source_rows: sources.sourceRows,\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 3) Nómina sin cuenta válida: conciliar por nombre y monto.\nfor (\n let index = 0;\n index < payrollNoAccountRows.length;\n index++\n) {\n const payroll = payrollNoAccountRows[index];\n\n const candidates = bankAccounts.filter((bank) => {\n if (matchedBankKeys.has(bank.group_key)) return false;\n if (bank.currency !== payroll.currency) return false;\n if (!moneyEquals(bank.amount, payroll.payroll_amount)) {\n return false;\n }\n\n return bankMatchesName(bank, payroll.employee_name);\n });\n\n if (candidates.length !== 1) continue;\n\n const bank = candidates[0];\n\n matchedNoAccountIndexes.add(index);\n matchedBankKeys.add(bank.group_key);\n\n rows.push({\n id: `match_no_account_${index}_${bank.group_key}`,\n employee:\n payroll.employee_name || bestBankDisplayName(bank),\n employee_name:\n payroll.employee_name || bestBankDisplayName(bank),\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: bank.account,\n payrollAccount: '',\n payroll_account: '',\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: payroll.currency,\n payrollAmount: payroll.payroll_amount,\n payroll_amount: payroll.payroll_amount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: 0,\n status: 'Coincidencia',\n category: 'coincidencia',\n subcategory:\n 'conciliado_por_nombre_y_monto_sin_cuenta_nomina',\n observation:\n 'Conciliado por nombre y monto. La nómina no tenía una cuenta bancaria válida.',\n source_sheet: payroll.source_sheet,\n row_number: payroll.row_number,\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 4) Diferencias reales en una cuenta exacta o equivalente.\nfor (const payroll of payrollAccounts) {\n if (matchedPayrollKeys.has(payroll.group_key)) continue;\n\n const candidates = getDirectCandidates(\n payroll,\n matchedBankKeys\n );\n\n if (!candidates.length) continue;\n\n const candidate = candidates[0];\n const bank = candidate.bank;\n const decision = candidate.supplementDecision;\n const sources = buildSources(payroll, decision.selected);\n const difference = moneyDiff(\n decision.effectiveAmount,\n bank.amount\n );\n\n matchedPayrollKeys.add(payroll.group_key);\n matchedBankKeys.add(bank.group_key);\n registerSupplements(decision.selected);\n\n rows.push({\n id: `difference_${payroll.group_key}_${bank.group_key}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: payroll.currency,\n payrollAmount: decision.effectiveAmount,\n payroll_amount: decision.effectiveAmount,\n payrollBaseAmount: decision.baseAmount,\n payroll_base_amount: decision.baseAmount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference,\n status: 'Riesgo',\n category: 'discrepancia',\n subcategory: 'diferencia_monto',\n observation:\n `Diferencia de ${payroll.currency} ` +\n `${formatMoney(difference)}.`,\n applied_supplements: decision.selected,\n source_sheets: sources.sourceSheets,\n source_rows: sources.sourceRows,\n bank_source_rows: bank.source_rows,\n });\n}\n\n\n// 4.5) Reconciliación final exacta de pares residuales.\n//\n// Este paso corrige casos en los que nómina y banco contienen:\n// - la misma cuenta normalizada;\n// - el mismo empleado;\n// - el mismo monto;\n// pero no fueron enlazados en los pasos anteriores por diferencias técnicas\n// de agrupación, moneda inferida o metadatos del CSV.\n//\n// Es deliberadamente conservador: exige una única contraparte bancaria.\nfor (const payroll of payrollAccounts) {\n if (matchedPayrollKeys.has(payroll.group_key)) continue;\n\n const candidates = bankAccounts\n .filter((bank) => {\n if (matchedBankKeys.has(bank.group_key)) return false;\n\n const payrollAccount = normalizeAccount(payroll.account);\n const bankAccount = normalizeAccount(bank.account);\n\n if (!payrollAccount || payrollAccount !== bankAccount) {\n return false;\n }\n\n if (!bankMatchesName(bank, payroll.employee_name)) {\n return false;\n }\n\n const decision = chooseConditionalSupplements(payroll, bank);\n\n return moneyEquals(\n decision.effectiveAmount,\n bank.amount\n );\n })\n .map((bank) => ({\n bank,\n supplementDecision: chooseConditionalSupplements(\n payroll,\n bank\n ),\n }));\n\n if (candidates.length !== 1) continue;\n\n const candidate = candidates[0];\n const bank = candidate.bank;\n const decision = candidate.supplementDecision;\n const sources = buildSources(\n payroll,\n decision.selected\n );\n\n matchedPayrollKeys.add(payroll.group_key);\n matchedBankKeys.add(bank.group_key);\n registerSupplements(decision.selected);\n\n finalExactReconciliations.push({\n employee_name: payroll.employee_name,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payroll_currency: payroll.currency,\n bank_currency: bank.currency,\n payroll_amount: decision.effectiveAmount,\n bank_amount: bank.amount,\n payroll_group_key: payroll.group_key,\n bank_group_key: bank.group_key,\n });\n\n rows.push({\n id: `final_exact_match_${payroll.group_key}_${bank.group_key}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: bank.currency || payroll.currency,\n payrollAmount: decision.effectiveAmount,\n payroll_amount: decision.effectiveAmount,\n payrollBaseAmount: decision.baseAmount,\n payroll_base_amount: decision.baseAmount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: 0,\n status: 'Coincidencia',\n category: 'coincidencia',\n subcategory: 'reconciliacion_final_cuenta_nombre_monto',\n observation:\n 'Conciliado por cuenta, nombre y monto en la validación final.',\n applied_supplements: decision.selected,\n source_sheets: sources.sourceSheets,\n source_rows: sources.sourceRows,\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 5) Nómina con cuenta sin pago bancario.\nfor (const payroll of payrollAccounts) {\n if (matchedPayrollKeys.has(payroll.group_key)) continue;\n\n rows.push({\n id: `payroll_without_bank_${payroll.group_key}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: '',\n bank_account: '',\n currency: payroll.currency,\n payrollAmount: payroll.payroll_amount,\n payroll_amount: payroll.payroll_amount,\n payrollBaseAmount: payroll.payroll_amount,\n payroll_base_amount: payroll.payroll_amount,\n bankAmount: 0,\n bank_amount: 0,\n difference: payroll.payroll_amount,\n status: 'Riesgo',\n category: 'discrepancia',\n subcategory: 'nomina_con_cuenta_sin_pago_banco',\n observation:\n 'Está en nómina, pero no aparece pagado en el banco.',\n applied_supplements: [],\n source_sheets: payroll.source_sheets,\n source_rows: payroll.source_rows,\n });\n}\n\n// 6) Banco sin nómina.\nfor (const bank of bankAccounts) {\n if (matchedBankKeys.has(bank.group_key)) continue;\n\n rows.push({\n id: `bank_without_payroll_${bank.group_key}`,\n employee:\n bestBankDisplayName(bank) || 'Pago bancario sin nómina',\n employee_name:\n bestBankDisplayName(bank) || 'Pago bancario sin nómina',\n employeeNumber: '',\n employee_number: '',\n account: bank.account,\n payrollAccount: '',\n payroll_account: '',\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: bank.currency,\n payrollAmount: 0,\n payroll_amount: 0,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: roundMoney(0 - bank.amount),\n status: 'Pendiente revisión',\n category: 'banco_sin_nomina',\n subcategory: 'pago_banco_sin_fila_nomina',\n observation:\n 'Recibió un pago en el banco, pero no aparece en la nómina cargada.',\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 7) Nómina sin cuenta que no pudo conciliarse.\nfor (\n let index = 0;\n index < payrollNoAccountRows.length;\n index++\n) {\n if (matchedNoAccountIndexes.has(index)) continue;\n\n const payroll = payrollNoAccountRows[index];\n\n rows.push({\n id:\n `payroll_without_account_` +\n `${payroll.source_sheet}_${payroll.row_number}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: '',\n payrollAccount: '',\n payroll_account: '',\n bankAccount: '',\n bank_account: '',\n currency: payroll.currency,\n payrollAmount: payroll.payroll_amount,\n payroll_amount: payroll.payroll_amount,\n bankAmount: 0,\n bank_amount: 0,\n difference: payroll.payroll_amount,\n status: 'Pendiente revisión',\n category: 'nomina_sin_cuenta',\n subcategory: 'nomina_sin_cuenta_bancaria',\n observation:\n 'Tiene monto en nómina, pero no tiene una cuenta bancaria válida para cruzar contra el banco.',\n source_sheet: payroll.source_sheet,\n row_number: payroll.row_number,\n });\n}\n\n// 8) Consolidar el mismo empleado cuando aparece con dos cuentas de nómina.\nconst originalRows = [...rows];\nconst usedRowIds = new Set();\nconst consolidatedRows = [];\n\nfor (const differenceRow of originalRows) {\n if (\n differenceRow.category !== 'discrepancia' ||\n differenceRow.subcategory !== 'diferencia_monto' ||\n usedRowIds.has(differenceRow.id)\n ) {\n continue;\n }\n\n const extraPayrollRow = originalRows.find((candidate) => {\n if (\n candidate.id === differenceRow.id ||\n usedRowIds.has(candidate.id) ||\n candidate.subcategory !==\n 'nomina_con_cuenta_sin_pago_banco' ||\n candidate.currency !== differenceRow.currency\n ) {\n return false;\n }\n\n const samePerson = samePersonName(\n differenceRow.employee_name || differenceRow.employee,\n candidate.employee_name || candidate.employee\n );\n\n const similarAccounts =\n accountDistance(\n differenceRow.account,\n candidate.account\n ) <= 2;\n\n const combinedPayroll = roundMoney(\n differenceRow.payroll_amount +\n candidate.payroll_amount\n );\n\n const totalMatches = moneyEquals(\n combinedPayroll,\n differenceRow.bank_amount\n );\n\n return samePerson && similarAccounts && totalMatches;\n });\n\n if (!extraPayrollRow) continue;\n\n usedRowIds.add(differenceRow.id);\n usedRowIds.add(extraPayrollRow.id);\n\n const totalPayroll = roundMoney(\n differenceRow.payroll_amount +\n extraPayrollRow.payroll_amount\n );\n\n const accounts = Array.from(new Set([\n differenceRow.account,\n extraPayrollRow.account,\n ].filter(Boolean)));\n\n consolidatedRows.push({\n id:\n `split_account_` +\n `${differenceRow.account}_${extraPayrollRow.account}`,\n employee: differenceRow.employee_name,\n employee_name: differenceRow.employee_name,\n employeeNumber:\n differenceRow.employee_number ||\n extraPayrollRow.employee_number ||\n '',\n employee_number:\n differenceRow.employee_number ||\n extraPayrollRow.employee_number ||\n '',\n account:\n differenceRow.bank_account ||\n differenceRow.account,\n payrollAccount: accounts.join(' / '),\n payroll_account: accounts.join(' / '),\n bankAccount: differenceRow.bank_account,\n bank_account: differenceRow.bank_account,\n currency: differenceRow.currency,\n payrollAmount: totalPayroll,\n payroll_amount: totalPayroll,\n bankAmount: differenceRow.bank_amount,\n bank_amount: differenceRow.bank_amount,\n difference: moneyDiff(\n totalPayroll,\n differenceRow.bank_amount\n ),\n status: 'Riesgo',\n category: 'posible_cuenta_mal_digitada',\n subcategory:\n 'mismo_empleado_con_cuentas_distintas_en_nomina',\n observation:\n `El total de nómina coincide con el banco, pero el empleado ` +\n `aparece con cuentas distintas en la nómina: ` +\n `${accounts.join(' y ')}. La cuenta utilizada por el banco ` +\n `fue ${differenceRow.bank_account}.`,\n applied_supplements:\n differenceRow.applied_supplements || [],\n source_sheets: Array.from(new Set([\n ...(differenceRow.source_sheets || []),\n ...(extraPayrollRow.source_sheets || []),\n ])),\n source_rows: [\n ...(differenceRow.source_rows || []),\n ...(extraPayrollRow.source_rows || []),\n ],\n bank_source_rows:\n differenceRow.bank_source_rows || [],\n });\n}\n\nconst coreRows = [\n ...originalRows.filter(\n (row) => !usedRowIds.has(row.id)\n ),\n ...consolidatedRows,\n];\n\nconst coreCoincidencias = coreRows.filter(\n (row) => row.category === 'coincidencia'\n).length;\n\nconst coreDiscrepancias = coreRows.filter((row) => {\n return (\n row.category === 'discrepancia' ||\n row.category === 'posible_cuenta_mal_digitada'\n );\n}).length;\n\nconst coreBancoSinNomina = coreRows.filter(\n (row) => row.category === 'banco_sin_nomina'\n).length;\n\nconst coreNominaSinCuenta = coreRows.filter(\n (row) => row.category === 'nomina_sin_cuenta'\n).length;\n\nconst corePosiblesCuentas = coreRows.filter(\n (row) => row.category === 'posible_cuenta_mal_digitada'\n).length;\n\nconst linkedPayrollNamesByBankRow = new Map();\nconst linkedPayrollNumbersByBankRow = new Map();\n\nfor (const reconciliationRow of coreRows) {\n const linkedName =\n reconciliationRow.employee_name ||\n reconciliationRow.employee ||\n '';\n const linkedEmployeeNumber = normalizeAccount(\n reconciliationRow.employee_number ||\n reconciliationRow.employeeNumber ||\n ''\n );\n\n for (\n const bankSourceRow of\n reconciliationRow.bank_source_rows || []\n ) {\n const rowKey = bankRowKey(bankSourceRow);\n\n const names =\n linkedPayrollNamesByBankRow.get(rowKey) || [];\n const numbers =\n linkedPayrollNumbersByBankRow.get(rowKey) || [];\n\n if (linkedName) names.push(linkedName);\n if (linkedEmployeeNumber.length >= 6) {\n numbers.push(linkedEmployeeNumber);\n }\n\n linkedPayrollNamesByBankRow.set(\n rowKey,\n Array.from(new Set(names))\n );\n linkedPayrollNumbersByBankRow.set(\n rowKey,\n Array.from(new Set(numbers))\n );\n }\n}\n\nconst bambooMatchDetails = [];\nconst bambooExcludedPayments = [];\nconst bankWithoutBambooMap = new Map();\n\nif (bambooValidationAvailable) {\nfor (const bankRow of bankDetailRows) {\n if (isClearlyNonEmployeePayment(bankRow)) {\n bambooExcludedPayments.push({\n source_file: bankRow.source_file,\n row_number: bankRow.row_number,\n reason: 'pago_no_empleado_identificado',\n bank_name_file: bankRow.bank_name_file,\n bank_account_holder:\n bankRow.bank_account_holder,\n amount: bankRow.amount,\n currency: bankRow.currency,\n });\n continue;\n }\n\n const match = findBambooMatch(bankRow);\n\n if (match.found) {\n bambooMatchDetails.push({\n source_file: bankRow.source_file,\n row_number: bankRow.row_number,\n account: bankRow.account,\n amount: bankRow.amount,\n currency: bankRow.currency,\n bank_name_file: bankRow.bank_name_file,\n bank_account_holder:\n bankRow.bank_account_holder,\n matched_by: match.matched_by,\n confidence: roundMoney(match.confidence),\n bamboo_employee_number:\n match.employee?.employee_number || '',\n bamboo_employee_name:\n match.employee?.full_name || '',\n bamboo_status:\n match.employee?.status || '',\n bamboo_overlaps_period:\n Boolean(match.employee?.overlaps_period),\n });\n continue;\n }\n\n const displayName =\n bankRow.bank_name_file ||\n bankRow.bank_account_holder ||\n 'Pago bancario sin empleado identificado';\n\n const groupingKey = [\n normalizeAccount(bankRow.account),\n normalizeName(displayName),\n bankRow.currency || 'QTZ',\n ].join('|');\n\n const current =\n bankWithoutBambooMap.get(groupingKey) || {\n id: `bank_without_bamboo_${groupingKey}`,\n employee: displayName,\n employee_name: displayName,\n bank_name_file:\n bankRow.bank_name_file || '',\n bank_account_holder:\n bankRow.bank_account_holder || '',\n account: normalizeAccount(bankRow.account),\n bankAccount: normalizeAccount(bankRow.account),\n bank_account: normalizeAccount(bankRow.account),\n currency: bankRow.currency || 'QTZ',\n bankAmount: 0,\n bank_amount: 0,\n shipment_numbers: new Set(),\n references: new Set(),\n source_files: new Set(),\n source_rows: [],\n status: 'Pendiente revisión',\n category: 'banco_sin_bamboo',\n subcategory:\n 'pago_bancario_sin_empleado_bamboohr_gt',\n observation:\n 'Se encontró un pago en el banco, pero no se encontró una coincidencia confiable con un empleado de Guatemala en BambooHR.',\n best_bamboo_candidate:\n match.best_candidate\n ? {\n employee_number:\n match.best_candidate.employee\n ?.employee_number || '',\n employee_name:\n match.best_candidate.employee\n ?.full_name || '',\n score: roundMoney(\n match.best_candidate.score\n ),\n }\n : null,\n ambiguous_bamboo_match:\n Boolean(match.ambiguous),\n };\n\n current.bankAmount = roundMoney(\n current.bankAmount +\n Number(bankRow.amount || 0)\n );\n current.bank_amount = current.bankAmount;\n\n if (bankRow.shipment_number) {\n current.shipment_numbers.add(\n bankRow.shipment_number\n );\n }\n\n if (bankRow.reference) {\n current.references.add(bankRow.reference);\n }\n\n if (bankRow.source_file) {\n current.source_files.add(\n bankRow.source_file\n );\n }\n\n current.source_rows.push(bankRow);\n bankWithoutBambooMap.set(\n groupingKey,\n current\n );\n}\n}\n\nconst bankWithoutBamboo = Array.from(\n bankWithoutBambooMap.values()\n).map((row) => ({\n ...row,\n shipment_numbers: Array.from(\n row.shipment_numbers\n ),\n references: Array.from(row.references),\n source_files: Array.from(row.source_files),\n difference: roundMoney(\n 0 - row.bank_amount\n ),\n}));\n\nconst nameDifferenceRows = (\n data.bank?.name_differences || []\n).map((row) => ({\n id: row.id,\n employee: row.bank_name_file,\n employee_name: row.bank_name_file,\n employeeNumber: '',\n employee_number: '',\n account: row.account,\n payrollAccount: '',\n payroll_account: '',\n bankAccount: row.account,\n bank_account: row.account,\n currency: row.currency || 'QTZ',\n payrollAmount: 0,\n payroll_amount: 0,\n bankAmount: row.amount,\n bank_amount: row.amount,\n difference: 0,\n status: 'Pendiente revisión',\n category: 'diferencia_nombre_banco',\n subcategory: 'nombre_archivo_vs_cuentahabiente',\n observation: row.observation,\n bank_name_file: row.bank_name_file,\n bank_account_holder: row.bank_account_holder,\n source_file: row.source_file,\n shipment_number: row.shipment_number,\n plan_number: row.plan_number,\n reference: row.reference,\n row_number: row.row_number,\n}));\n\nfunction priority(row) {\n const category = String(\n row.category || ''\n ).toLowerCase();\n\n if (category === 'posible_cuenta_mal_digitada') return 1;\n if (category === 'discrepancia') return 2;\n if (category === 'banco_sin_nomina') return 3;\n if (category === 'nomina_sin_cuenta') return 4;\n if (category === 'diferencia_nombre_banco') return 5;\n if (category === 'coincidencia') return 99;\n\n return 50;\n}\n\nconst rowsFinales = [\n ...coreRows,\n ...nameDifferenceRows,\n].sort((a, b) => {\n const priorityDifference =\n priority(a) - priority(b);\n\n if (priorityDifference !== 0) {\n return priorityDifference;\n }\n\n return String(\n a.employee_name || ''\n ).localeCompare(\n String(b.employee_name || ''),\n 'es'\n );\n});\n\nconst appliedSupplementsTotal = roundMoney(\n appliedSupplements.reduce(\n (sum, row) => sum + row.payroll_amount,\n 0\n )\n);\n\nconst totalNominaBase = roundMoney(\n data.payroll?.total_amount || 0\n);\n\nconst totalNomina = roundMoney(\n totalNominaBase + appliedSupplementsTotal\n);\n\nconst totalBanco = roundMoney(\n data.bank?.total_amount || 0\n);\n\nconst diferenciasNombreBanco =\n nameDifferenceRows.length;\n\nconst pendientes =\n coreDiscrepancias +\n coreBancoSinNomina +\n coreNominaSinCuenta +\n diferenciasNombreBanco;\n\nconst unusedPotentialSupplements =\n potentialSupplements.filter((row) => {\n return !appliedSupplementKeys.has(\n supplementKey(row)\n );\n });\n\nconst bambooSummaryCompact = {\n ...(data.bamboo || {}),\n};\n\n/*\n * El arreglo completo de empleados solo se necesita dentro de este nodo.\n * No se reenvía a Google Sheets, Supabase ni al webhook para evitar cargar\n * cerca de 1 MB innecesario en todos los nodos posteriores.\n */\ndelete bambooSummaryCompact.employees;\n\nreturn [\n {\n json: {\n ok: true,\n stage: 'cruce_nomina_completa_banco_condicional',\n errors: [],\n metadata: data.metadata || {},\n summary: {\n coincidencias: coreCoincidencias,\n discrepancias: coreDiscrepancias,\n bancoSinNomina: coreBancoSinNomina,\n bancoSinBamboo: bankWithoutBamboo.length,\n nominaSinCuenta: coreNominaSinCuenta,\n diferenciasNombreBanco,\n posiblesCuentasMalDigitadas:\n corePosiblesCuentas,\n pendientes,\n filasNominaValidas:\n data.payroll?.valid_rows_count || 0,\n filasNominaSinCuenta:\n data.payroll?.no_account_rows_count || 0,\n suplementosPotenciales:\n potentialSupplements.length,\n suplementosNominaAplicados:\n appliedSupplements.length,\n suplementosNominaNoAplicados:\n unusedPotentialSupplements.length,\n suplementosNominaAdjuntados:\n appliedSupplements.length,\n suplementosNominaNoAdjuntados:\n data.payroll?.unattached_supplements_count || 0,\n reconciliacionesExactasFinales:\n finalExactReconciliations.length,\n cuentasNominaAgrupadas:\n payrollAccounts.length,\n transaccionesBanco:\n data.bank?.rows_count || 0,\n cuentasBancoAgrupadas:\n bankAccounts.length,\n empleadosBambooGT:\n bambooEmployees.length,\n empleadosBambooEnPeriodo:\n Number(\n data.bamboo?.active_in_period_count || 0\n ),\n bambooPaginasDescargadas:\n Number(\n data.bamboo?.pages_fetched || 0\n ),\n bambooEmpleadosEsperados:\n Number(\n data.bamboo?.expected_total || 0\n ),\n bambooDescargaCompleta:\n Boolean(\n data.bamboo?.fetch_complete\n ),\n bambooValidacionDisponible:\n bambooValidationAvailable,\n totalNominaBase,\n totalSuplementosAplicados:\n appliedSupplementsTotal,\n totalNomina,\n totalBanco,\n diferenciaTotal:\n moneyDiff(totalNomina, totalBanco),\n },\n rows: rowsFinales,\n bankWithoutBamboo,\n nameDifferences: nameDifferenceRows,\n bambooSummary: bambooSummaryCompact,\n reportUrl: null,\n debug: {\n sheet_summaries:\n data.payroll?.sheet_summaries || [],\n potential_supplements:\n potentialSupplements,\n applied_supplements:\n appliedSupplements,\n final_exact_reconciliations:\n finalExactReconciliations,\n bamboo_search:\n {\n employees_indexed:\n bambooSearch.records.length,\n exact_aliases:\n bambooSearch.exactAliasMap.size,\n indexed_tokens:\n bambooSearch.tokenIndex.size,\n cache_entries:\n bambooMatchCache.size,\n },\n bamboo_matches:\n bambooMatchDetails,\n bamboo_excluded_payments:\n bambooExcludedPayments,\n bamboo_validation_available:\n bambooValidationAvailable,\n bamboo_validation_warning:\n bambooValidationWarning,\n banco_sin_bamboo:\n bankWithoutBamboo,\n unused_potential_supplements:\n unusedPotentialSupplements,\n unattached_supplements:\n data.debug_payroll?.unattached_supplements || [],\n payroll_preview:\n payrollAccounts.slice(0, 10),\n bank_preview:\n bankAccounts.slice(0, 10),\n payroll_no_account_preview:\n payrollNoAccountRows.slice(0, 10),\n },\n },\n },\n];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 21696, - 26512 - ], - "id": "a9aeea33-6818-40c1-9eb9-b6a3521a4f66", - "name": "Cruzar Nómina vs Banco" - }, - { - "parameters": { - "operation": "xlsx", - "binaryPropertyName": "payroll_file", - "options": { - "headerRow": true, - "sheetName": "1) Nomina General" - } - }, - "type": "n8n-nodes-base.extractFromFile", - "typeVersion": 1.1, - "position": [ - 15648, - 25344 - ], - "id": "3e169c34-36e6-40d2-9751-4d3efdcf8c08", - "name": "Extract - Nomina General", - "retryOnFail": false - }, - { - "parameters": { - "operation": "xlsx", - "binaryPropertyName": "payroll_file", - "options": { - "headerRow": true, - "sheetName": "2) Temporales" - } - }, - "type": "n8n-nodes-base.extractFromFile", - "typeVersion": 1.1, - "position": [ - 15648, - 25584 - ], - "id": "c30318b3-98e8-44bf-8c0b-315f8a54f1e8", - "name": "Extract - Temporales", - "retryOnFail": false, - "onError": "continueRegularOutput" - }, - { - "parameters": { - "operation": "xlsx", - "binaryPropertyName": "payroll_file", - "options": { - "headerRow": true, - "sheetName": "3) Auditorias" - } - }, - "type": "n8n-nodes-base.extractFromFile", - "typeVersion": 1.1, - "position": [ - 15648, - 25824 - ], - "id": "b7b0d02e-7e91-428e-aecd-3a8a08e77175", - "name": "Extract - Auditorias", - "retryOnFail": false, - "onError": "continueRegularOutput" - }, - { - "parameters": { - "operation": "xlsx", - "binaryPropertyName": "payroll_file", - "options": { - "headerRow": true, - "sheetName": "4) Bono Mariana" - } - }, - "type": "n8n-nodes-base.extractFromFile", - "typeVersion": 1.1, - "position": [ - 15664, - 26016 - ], - "id": "cdfb15a2-d9d0-4544-80e1-e798e42e796f", - "name": "Extract - Bono Mariana", - "retryOnFail": false, - "onError": "continueRegularOutput" - }, - { - "parameters": { - "operation": "xlsx", - "binaryPropertyName": "payroll_file", - "options": { - "headerRow": true, - "sheetName": "5) Movilidad WP" - } - }, - "type": "n8n-nodes-base.extractFromFile", - "typeVersion": 1.1, - "position": [ - 15664, - 26224 - ], - "id": "12b2497a-642d-4f4a-b32c-d4faa535f565", - "name": "Extract - Movilidad WP", - "retryOnFail": false, - "onError": "continueRegularOutput" - }, - { - "parameters": { - "operation": "xlsx", - "binaryPropertyName": "payroll_file", - "options": { - "headerRow": true, - "sheetName": "6)Viaticos PMI" - } - }, - "type": "n8n-nodes-base.extractFromFile", - "typeVersion": 1.1, - "position": [ - 15664, - 26432 - ], - "id": "d33f7284-0947-44ad-8a03-47a0b8bb4122", - "name": "Extract - Viaticos PMI", - "retryOnFail": false, - "onError": "continueRegularOutput" - }, - { - "parameters": { - "operation": "xlsx", - "binaryPropertyName": "payroll_file", - "options": { - "headerRow": true, - "sheetName": "7) Combustible Purina" - } - }, - "type": "n8n-nodes-base.extractFromFile", - "typeVersion": 1.1, - "position": [ - 15680, - 26768 - ], - "id": "5b82536d-1e30-4fdf-9812-b2f93e8a19c9", - "name": "Extract - Combustible Purina", - "retryOnFail": false, - "onError": "continueRegularOutput" - }, - { - "parameters": { - "operation": "xlsx", - "binaryPropertyName": "payroll_file", - "options": { - "headerRow": true, - "sheetName": "8) Combustible P&G" - } - }, - "type": "n8n-nodes-base.extractFromFile", - "typeVersion": 1.1, - "position": [ - 15664, - 27104 - ], - "id": "fc8edacd-6084-42b1-8aa1-cbd18e96bf1b", - "name": "Extract - Combustible PG", - "retryOnFail": false, - "onError": "continueRegularOutput" - }, - { - "parameters": { - "operation": "xlsx", - "binaryPropertyName": "payroll_file", - "options": { - "headerRow": true, - "sheetName": "9) Combustibles Liquidables" - } - }, - "type": "n8n-nodes-base.extractFromFile", - "typeVersion": 1.1, - "position": [ - 15664, - 27392 - ], - "id": "ee4ee831-169e-4d33-88be-4d0d704ec095", - "name": "Extract - Combustibles Liquidables", - "retryOnFail": false, - "onError": "continueRegularOutput" - }, - { - "parameters": { - "jsCode": "function normalizeText(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeForCompare(value) {\n return normalizeText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^a-z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeAccount(value) {\n if (value === null || value === undefined || value === '') return '';\n\n if (typeof value === 'number') {\n return String(Math.trunc(value)).trim();\n }\n\n return String(value)\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim();\n}\n\nfunction parseMoney(value) {\n if (typeof value === 'number') {\n return Number.isFinite(value) ? value : 0;\n }\n\n const cleaned = String(value ?? '')\n .replace(/USD/gi, '')\n .replace(/GTQ/gi, '')\n .replace(/QTZ/gi, '')\n .replace(/Q/gi, '')\n .replace(/,/g, '')\n .replace(/\\s+/g, '')\n .trim();\n\n const parsed = Number.parseFloat(cleaned);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction roundMoney(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n}\n\nfunction getValue(row, possibleKeys) {\n for (const key of possibleKeys) {\n if (row[key] !== undefined && row[key] !== null && row[key] !== '') {\n return row[key];\n }\n }\n\n const rowKeys = Object.keys(row || {});\n\n for (const wanted of possibleKeys) {\n const wantedNormalized = normalizeForCompare(wanted);\n const found = rowKeys.find((key) => normalizeForCompare(key) === wantedNormalized);\n\n if (found && row[found] !== undefined && row[found] !== null && row[found] !== '') {\n return row[found];\n }\n }\n\n return '';\n}\n\nfunction getNodeRows(nodeName) {\n try {\n return $items(nodeName)\n .map((item) => item.json || {})\n .filter((row) => {\n const text = JSON.stringify(row || {}).toLowerCase();\n if (text.includes('spreadsheet does not contain sheet')) return false;\n if (text.includes('no sheet')) return false;\n if (row.error) return false;\n return true;\n });\n } catch (error) {\n return [];\n }\n}\n\nfunction isProbablyInvalidEmployeeName(value) {\n const name = normalizeText(value);\n const lower = normalizeForCompare(name);\n\n if (!name) return true;\n if (/^[\\d.,\\s]+$/.test(name)) return true;\n\n const invalidExact = new Set([\n 'nombre', 'nombre completo', 'empleado', 'colaborador', 'cuenta',\n 'cuenta bancaria', 'total', 'subtotal', 'gran total', 'total general',\n 'guatemala', 'coordinador', 'supervisor', 'kam', 'pais', 'país',\n 'proyecto', 'cliente', 'marca', 'canal', 'concepto', 'descripcion',\n 'descripción', 'ejecutado en tarjeta', 'ejecutado en efectivo',\n 'ajustes segun comentarios', 'ajuste segun comentarios', 'comentarios',\n 'disponible', 'ejecutado', 'pendiente'\n ]);\n\n if (invalidExact.has(lower)) return true;\n\n const invalidContains = [\n 'total ', 'total:', 'subtotal', 'resumen', 'observacion', 'observación',\n 'monto', 'cuenta', 'banco', 'nomina', 'nómina', 'bonificacion',\n 'bonificación', 'departamento', 'puesto', 'posicion', 'posición',\n 'spoc', 'gema hsm', 'gema hfs', 'hsm-dpp', 'lider de ejecucion',\n 'líder de ejecución', 'coordinador nacional', 'ejecutado en tarjeta',\n 'ejecutado en efectivo', 'ajustes segun comentarios',\n 'ajuste segun comentarios', 'segun comentarios', 'comentarios',\n 'presupuesto combustible', 'credito 30 dias', 'crédito 30 días',\n 'no se deposita', 'ejecucion mensual', 'ejecución mensual'\n ];\n\n if (invalidContains.some((token) => lower.includes(normalizeForCompare(token)))) {\n return true;\n }\n\n const words = name.split(/\\s+/).filter(Boolean);\n return words.length < 2;\n}\n\nfunction isValidCrossableAccount(account) {\n const normalized = normalizeAccount(account);\n return normalized.length >= 7 && !/^0+$/.test(normalized);\n}\n\nfunction buildRow({ sourceSheet, rowNumber, employeeName, account, email, amount, currency = 'QTZ', extra = {} }) {\n return {\n source_sheet: sourceSheet,\n row_number: rowNumber,\n employee_name: normalizeText(employeeName),\n employee_number: null,\n account: normalizeAccount(account),\n email: normalizeText(email).toLowerCase(),\n payroll_amount: roundMoney(amount),\n currency,\n ...extra,\n };\n}\n\nconst payrollRows = [];\nconst noAccountRows = [];\nconst supplementRows = [];\nconst ignoredRows = [];\nconst sheetSummaries = [];\n\nfunction addNormalizedRow(row, options = {}) {\n const amount = roundMoney(row.payroll_amount);\n\n if (amount <= 0) {\n ignoredRows.push({ ...row, reason: 'amount_zero_or_invalid' });\n return 'ignored';\n }\n\n if (amount > 150000) {\n ignoredRows.push({ ...row, reason: 'suspicious_large_amount' });\n return 'ignored';\n }\n\n if (isProbablyInvalidEmployeeName(row.employee_name)) {\n ignoredRows.push({ ...row, reason: 'invalid_employee_name' });\n return 'ignored';\n }\n\n if (options.supplementWithoutAccount) {\n supplementRows.push({ ...row, account: '' });\n return 'supplement';\n }\n\n if (!isValidCrossableAccount(row.account)) {\n noAccountRows.push({\n ...row,\n account: '',\n status: 'Pendiente revisión',\n observation: 'Tiene monto en nómina, pero no tiene cuenta bancaria válida para cruzar contra banco.',\n });\n return 'no_account';\n }\n\n payrollRows.push(row);\n return 'valid';\n}\n\nfunction addSheetSummary(sourceSheet, rawRows, validRows, noAccountCount, ignoredCount, supplementCount, totalAmount) {\n sheetSummaries.push({\n source_sheet: sourceSheet,\n raw_rows_count: rawRows,\n valid_rows_count: validRows,\n no_account_rows_count: noAccountCount,\n supplement_rows_count: supplementCount,\n ignored_rows_count: ignoredCount,\n total_amount: roundMoney(totalAmount),\n });\n}\n\nfunction processStandardSheet(config) {\n const rows = getNodeRows(config.nodeName);\n let validRows = 0;\n let noAccountCount = 0;\n let ignoredCount = 0;\n let sheetTotal = 0;\n\n rows.forEach((row, index) => {\n const employeeName = normalizeText(getValue(row, config.nameKeys));\n const account = normalizeAccount(getValue(row, config.accountKeys));\n const email = normalizeText(getValue(row, config.emailKeys || [])).toLowerCase();\n const amount = roundMoney(parseMoney(getValue(row, config.amountKeys)));\n const currencyValue = normalizeText(getValue(row, config.currencyKeys || []));\n const currency = normalizeForCompare(currencyValue).includes('dolar') || currencyValue.toUpperCase().includes('USD')\n ? 'USD'\n : 'QTZ';\n\n const normalizedRow = buildRow({\n sourceSheet: config.sourceSheet,\n rowNumber: index + 2,\n employeeName,\n account,\n email,\n amount,\n currency,\n });\n\n const result = addNormalizedRow(normalizedRow);\n if (result === 'valid') validRows += 1;\n else if (result === 'no_account') noAccountCount += 1;\n else ignoredCount += 1;\n\n if (result === 'valid' || result === 'no_account') {\n sheetTotal = roundMoney(sheetTotal + amount);\n }\n });\n\n addSheetSummary(config.sourceSheet, rows.length, validRows, noAccountCount, ignoredCount, 0, sheetTotal);\n}\n\nfunction valuesFromRow(row) {\n // Los Extract configurados con Header Row desactivado devuelven cada fila\n // dentro de una propiedad `row` como arreglo posicional.\n // Si usamos Object.values(row), obtenemos un arreglo anidado y el\n // normalizador no puede detectar cuenta, nombre, ID ni monto.\n const rawValues = Array.isArray(row?.row)\n ? row.row\n : Object.values(row || {});\n\n return rawValues.filter((value) => {\n return value !== null &&\n value !== undefined &&\n normalizeText(value) !== '';\n });\n}\n\nfunction looksLikeAccount(value) {\n const account = normalizeAccount(value);\n return account.length >= 6 && account.length <= 14;\n}\n\nfunction looksLikeMoney(value) {\n const amount = roundMoney(parseMoney(value));\n return amount > 0 && amount <= 150000;\n}\n\nfunction looksLikeName(value) {\n const text = normalizeText(value);\n if (!text || /\\d/.test(text)) return false;\n return !isProbablyInvalidEmployeeName(text);\n}\n\nfunction findNameAfter(values, startIndex) {\n for (let i = Math.max(0, startIndex); i < values.length; i++) {\n if (looksLikeName(values[i])) return { value: values[i], index: i };\n }\n return { value: '', index: -1 };\n}\n\nfunction processPositionalSheet(config) {\n const rows = getNodeRows(config.nodeName);\n let validRows = 0;\n let noAccountCount = 0;\n let ignoredCount = 0;\n let supplementCount = 0;\n let sheetTotal = 0;\n\n rows.forEach((row, index) => {\n const values = valuesFromRow(row);\n const rowText = normalizeForCompare(values.join(' '));\n\n if (config.excludeIfContains?.some((token) => rowText.includes(normalizeForCompare(token)))) {\n ignoredCount += 1;\n return;\n }\n\n let employeeName = '';\n let account = '';\n let email = '';\n let amount = 0;\n let supplementWithoutAccount = false;\n let extra = {};\n\n if (config.sourceSheet === 'Temporales WMC') {\n const accountIndex = values.findIndex(looksLikeAccount);\n account = accountIndex >= 0 ? normalizeAccount(values[accountIndex]) : '';\n const foundName = findNameAfter(values, accountIndex + 1);\n employeeName = foundName.value;\n\n const moneyCandidates = values\n .slice(foundName.index + 1)\n .map((value) => roundMoney(parseMoney(value)))\n .filter((candidate) => candidate > 0 && candidate <= 150000);\n\n amount = moneyCandidates.length ? moneyCandidates[moneyCandidates.length - 1] : 0;\n } else if (config.sourceSheet === '6)Viaticos PMI') {\n const accountIndex = values.findIndex(looksLikeAccount);\n account = accountIndex >= 0 ? normalizeAccount(values[accountIndex]) : '';\n employeeName = findNameAfter(values, accountIndex + 1).value;\n const moneyCandidates = values\n .slice(accountIndex + 1)\n .map((value) => roundMoney(parseMoney(value)))\n .filter((candidate) => candidate > 0 && candidate <= 150000);\n amount = moneyCandidates.length ? moneyCandidates[moneyCandidates.length - 1] : 0;\n } else if (config.sourceSheet === '7) Combustible Purina') {\n const accountIndex = values.findIndex(looksLikeAccount);\n account = accountIndex >= 0 ? normalizeAccount(values[accountIndex]) : '';\n\n for (let i = accountIndex - 1; i >= 0; i--) {\n if (looksLikeName(values[i])) {\n employeeName = normalizeText(values[i]);\n break;\n }\n }\n\n for (let i = accountIndex - 1; i >= 0; i--) {\n const candidate = roundMoney(parseMoney(values[i]));\n if (candidate > 0 && candidate <= 150000) {\n amount = candidate;\n break;\n }\n }\n } else if (config.sourceSheet === '8) Combustible P&G') {\n const accountIndex = values.findIndex(looksLikeAccount);\n account = accountIndex >= 0 ? normalizeAccount(values[accountIndex]) : '';\n const foundName = findNameAfter(values, accountIndex + 1);\n employeeName = foundName.value;\n\n for (let i = foundName.index + 1; i < values.length; i++) {\n const candidate = roundMoney(parseMoney(values[i]));\n if (candidate > 0 && candidate <= 150000) {\n amount = candidate;\n break;\n }\n }\n } else if (config.sourceSheet === '9) Combustibles Liquidables') {\n account = normalizeAccount(values[0]);\n employeeName = normalizeText(values[2]);\n email = normalizeText(values[6]).toLowerCase();\n const moneyCandidates = values\n .map((value) => roundMoney(parseMoney(value)))\n .filter((candidate) => candidate > 0 && candidate <= 150000);\n amount = moneyCandidates.length ? moneyCandidates[moneyCandidates.length - 1] : 0;\n } else if (\n config.sourceSheet === 'Combustibles PMI' ||\n config.sourceSheet === 'Combustible Tarjeta Motorola'\n ) {\n const idIndex = values.findIndex((value) => {\n const normalized = normalizeForCompare(value);\n return /^(spoc|xpert|moto)/.test(normalized);\n });\n\n // Opción B:\n // Solo procesar filas de la tabla principal que tengan un ID operativo\n // (SPOC / XPERT / MOTO). Esto excluye encabezados, totales y la lista\n // auxiliar/duplicada que aparece debajo de la tabla principal.\n if (idIndex < 0) {\n ignoredCount += 1;\n return;\n }\n\n const foundName = findNameAfter(values, idIndex + 1);\n employeeName = foundName.value;\n\n // Tomar únicamente el primer monto positivo después del nombre:\n // corresponde a la columna Asignado de la tabla principal.\n for (let i = foundName.index + 1; i < values.length; i++) {\n const candidate = roundMoney(parseMoney(values[i]));\n if (candidate > 0 && candidate <= 150000) {\n amount = candidate;\n break;\n }\n }\n\n supplementWithoutAccount = true;\n extra = {\n supplement_id: idIndex >= 0 ? normalizeText(values[idIndex]) : '',\n supplement_original_name: employeeName,\n };\n }\n\n const normalizedRow = buildRow({\n sourceSheet: config.sourceSheet,\n rowNumber: index + 1,\n employeeName,\n account,\n email,\n amount,\n extra,\n });\n\n const result = addNormalizedRow(normalizedRow, { supplementWithoutAccount });\n if (result === 'valid') validRows += 1;\n else if (result === 'no_account') noAccountCount += 1;\n else if (result === 'supplement') supplementCount += 1;\n else ignoredCount += 1;\n\n if (result !== 'ignored') sheetTotal = roundMoney(sheetTotal + amount);\n });\n\n addSheetSummary(\n config.sourceSheet,\n rows.length,\n validRows,\n noAccountCount,\n ignoredCount,\n supplementCount,\n sheetTotal\n );\n}\n\nconst standardSheetConfigs = [\n {\n nodeName: 'Extract - Nomina General',\n sourceSheet: '1) Nomina General',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE COMPLETO', 'Nombre Completo', 'NOMBRE', 'Nombre'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['NETO A PAGAR', 'Neto a Pagar', 'NETO', 'Neto'],\n currencyKeys: ['Moneda', 'MONEDA'],\n },\n {\n nodeName: 'Extract - Temporales',\n sourceSheet: '2) Temporales',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE', 'Nombre', 'NOMBRE COMPLETO', 'Nombre Completo'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['NETO A PAGAR', 'Neto a Pagar', 'NETO', 'Neto'],\n },\n {\n nodeName: 'Extract - Auditorias',\n sourceSheet: '3) Auditorias',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE COMPLETO', 'Nombre Completo', 'NOMBRE', 'Nombre'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['NETO A PAGAR', 'Neto a Pagar', 'NETO', 'Neto'],\n currencyKeys: ['Moneda', 'MONEDA'],\n },\n {\n nodeName: 'Extract - Bono Mariana',\n sourceSheet: '4) Bono Mariana',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE COMPLETO', 'Nombre Completo', 'NOMBRE', 'Nombre'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['NETO A PAGAR', 'Neto a Pagar', 'NETO', 'Neto', 'MONTO', 'Monto'],\n },\n {\n nodeName: 'Extract - Movilidad WP',\n sourceSheet: '5) Movilidad WP',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE', 'Nombre', 'NOMBRE COMPLETO', 'Nombre Completo'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['NETO A PAGAR', 'Neto a Pagar', 'NETO', 'Neto'],\n },\n {\n nodeName: 'Extract - Mot Variable Abril',\n sourceSheet: 'Mot Variable Abril',\n accountKeys: ['Cuenta', 'CUENTA', 'Cuenta Bancaria', 'CUENTA BANCARIA'],\n nameKeys: ['NOMBRE COMPLETO', 'Nombre Completo', 'NOMBRE', 'Nombre', 'Empleado', 'EMPLEADO'],\n emailKeys: ['EMAIL', 'Email', 'CORREO', 'Correo'],\n amountKeys: ['MONTO A PAGAR', 'Monto a pagar', 'NETO A PAGAR', 'Neto a Pagar', 'MONTO NETO', 'Monto Neto', 'VALOR A PAGAR', 'Valor a pagar'],\n },\n];\n\nconst positionalSheetConfigs = [\n { nodeName: 'Extract - Temporales WMC', sourceSheet: 'Temporales WMC' },\n { nodeName: 'Extract - Viaticos PMI', sourceSheet: '6)Viaticos PMI' },\n { nodeName: 'Extract - Combustible Purina', sourceSheet: '7) Combustible Purina' },\n { nodeName: 'Extract - Combustible PG', sourceSheet: '8) Combustible P&G' },\n { nodeName: 'Extract - Combustibles Liquidables', sourceSheet: '9) Combustibles Liquidables' },\n];\n\nfor (const config of standardSheetConfigs) processStandardSheet(config);\nfor (const config of positionalSheetConfigs) processPositionalSheet(config);\n\nfunction nameWords(value) {\n const ignored = new Set(['de', 'del', 'la', 'las', 'los', 'y', 'e', 'el']);\n return normalizeForCompare(value)\n .split(' ')\n .filter((word) => word.length > 1 && !ignored.has(word));\n}\n\nfunction editDistance(a, b) {\n if (a === b) return 0;\n if (!a) return b.length;\n if (!b) return a.length;\n\n const previous = Array.from({ length: b.length + 1 }, (_, index) => index);\n\n for (let i = 1; i <= a.length; i++) {\n const current = [i];\n for (let j = 1; j <= b.length; j++) {\n const cost = a[i - 1] === b[j - 1] ? 0 : 1;\n current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, previous[j - 1] + cost);\n }\n for (let j = 0; j < current.length; j++) previous[j] = current[j];\n }\n\n return previous[b.length];\n}\n\nfunction tokenMatches(a, b) {\n if (a === b) return true;\n const minLength = Math.min(a.length, b.length);\n if (minLength >= 8 && editDistance(a, b) <= 2) return true;\n if (minLength >= 5 && editDistance(a, b) <= 1) return true;\n return false;\n}\n\nfunction nameMatchScore(a, b) {\n const wordsA = nameWords(a);\n const wordsB = nameWords(b);\n if (!wordsA.length || !wordsB.length) return 0;\n\n const usedB = new Set();\n let matches = 0;\n\n for (const wordA of wordsA) {\n const matchIndex = wordsB.findIndex((wordB, index) => {\n return !usedB.has(index) && tokenMatches(wordA, wordB);\n });\n\n if (matchIndex >= 0) {\n usedB.add(matchIndex);\n matches += 1;\n }\n }\n\n const ratio = matches / Math.min(wordsA.length, wordsB.length);\n const firstTokenBonus = tokenMatches(wordsA[0], wordsB[0]) ? 0.15 : 0;\n return ratio + firstTokenBonus;\n}\n\nconst accountNameMap = new Map();\nfor (const row of payrollRows) {\n const current = accountNameMap.get(row.account) || {\n account: row.account,\n employee_name: row.employee_name,\n currency: row.currency,\n };\n accountNameMap.set(row.account, current);\n}\n\nconst canonicalPayrollPeople = Array.from(accountNameMap.values());\nconst attachedSupplements = [];\nconst unattachedSupplements = [];\n\nfor (const supplement of supplementRows) {\n const scored = canonicalPayrollPeople\n .map((candidate) => ({\n ...candidate,\n score: nameMatchScore(supplement.employee_name, candidate.employee_name),\n }))\n .sort((a, b) => b.score - a.score);\n\n const top = scored[0];\n const second = scored[1];\n const supplementTokens = nameWords(supplement.employee_name);\n const firstToken = supplementTokens[0] || '';\n const sameFirstTokenCandidates = canonicalPayrollPeople.filter((candidate) => {\n const candidateFirst = nameWords(candidate.employee_name)[0] || '';\n return firstToken && candidateFirst === firstToken;\n });\n\n const confidentByScore = top && top.score >= 0.75 && (!second || top.score - second.score >= 0.05 || top.score >= 1);\n const confidentShortUniqueFirstName = top && supplementTokens.length <= 2 && top.score >= 0.6 && sameFirstTokenCandidates.length === 1;\n\n if (confidentByScore || confidentShortUniqueFirstName) {\n const attached = {\n ...supplement,\n account: top.account,\n employee_name: top.employee_name,\n currency: top.currency || supplement.currency || 'QTZ',\n supplement_original_name: supplement.employee_name,\n supplement_match_score: roundMoney(top.score),\n attached_by_name: true,\n };\n\n // Opción B condicional:\n // El suplemento queda vinculado al empleado y a su cuenta, pero NO se\n // suma todavía a la nómina base. El nodo de cruce decidirá si debe\n // aplicarse según el monto realmente pagado por el banco.\n attachedSupplements.push(attached);\n } else {\n unattachedSupplements.push({\n ...supplement,\n best_candidate: top?.employee_name || '',\n best_score: roundMoney(top?.score || 0),\n });\n\n ignoredRows.push({\n ...supplement,\n reason: 'supplement_without_unique_payroll_match',\n best_candidate: top?.employee_name || '',\n best_score: roundMoney(top?.score || 0),\n });\n }\n}\n\nconst groupedMap = new Map();\n\nfor (const row of payrollRows) {\n const groupKey = `${row.account}:${row.currency || 'QTZ'}`;\n const current = groupedMap.get(groupKey) || {\n group_key: groupKey,\n account: row.account,\n employee_name: row.employee_name,\n employee_number: null,\n email: row.email,\n currency: row.currency || 'QTZ',\n payroll_amount: 0,\n rows_count: 0,\n source_sheets: new Set(),\n source_rows: [],\n };\n\n current.payroll_amount = roundMoney(current.payroll_amount + row.payroll_amount);\n current.rows_count += 1;\n if (!current.email && row.email) current.email = row.email;\n current.source_sheets.add(row.source_sheet);\n current.source_rows.push({\n source_sheet: row.source_sheet,\n row_number: row.row_number,\n amount: row.payroll_amount,\n supplement_original_name: row.supplement_original_name || '',\n attached_by_name: Boolean(row.attached_by_name),\n });\n\n groupedMap.set(groupKey, current);\n}\n\nconst groupedByAccount = Array.from(groupedMap.values()).map((row) => ({\n ...row,\n source_sheets: Array.from(row.source_sheets),\n}));\n\nconst totalsByCurrency = {};\nfor (const row of [...payrollRows, ...noAccountRows]) {\n const currency = row.currency || 'QTZ';\n totalsByCurrency[currency] = roundMoney((totalsByCurrency[currency] || 0) + row.payroll_amount);\n}\n\nconst totalPayroll = roundMoney(\n payrollRows.reduce((sum, row) => sum + row.payroll_amount, 0) +\n noAccountRows.reduce((sum, row) => sum + row.payroll_amount, 0)\n);\n\nreturn [\n {\n json: {\n payroll: {\n source: 'template_guatemala_completo',\n sheets_count: standardSheetConfigs.length + positionalSheetConfigs.length,\n sheet_summaries: sheetSummaries,\n raw_rows_count: sheetSummaries.reduce((sum, sheet) => sum + sheet.raw_rows_count, 0),\n valid_rows_count: payrollRows.length,\n no_account_rows_count: noAccountRows.length,\n ignored_rows_count: ignoredRows.length,\n grouped_accounts_count: groupedByAccount.length,\n // Los suplementos potenciales no forman parte del total base\n // hasta que el banco confirme que hubo un pago adicional.\n attached_supplements_count: 0,\n potential_supplements_count: attachedSupplements.length,\n potential_supplements: attachedSupplements,\n unattached_supplements_count: unattachedSupplements.length,\n total_amount: totalPayroll,\n totals_by_currency: totalsByCurrency,\n rows: payrollRows,\n no_account_rows: noAccountRows,\n grouped_by_account: groupedByAccount,\n },\n debug_payroll: {\n // Se conserva attached_supplements por compatibilidad con las\n // revisiones anteriores, pero ahora representa suplementos\n // potenciales vinculados, todavía no aplicados.\n attached_supplements: attachedSupplements,\n potential_supplements: attachedSupplements,\n unattached_supplements: unattachedSupplements,\n ignored_rows_preview: ignoredRows.slice(0, 100),\n no_account_rows_preview: noAccountRows.slice(0, 50),\n },\n },\n },\n];\n" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 19872, - 27120 - ], - "id": "44044451-fb64-44a9-8cd0-109b64855806", - "name": "Normalizar Nómina Completa" - }, - { - "parameters": {}, - "type": "n8n-nodes-base.merge", - "typeVersion": 3.2, - "position": [ - 16672, - 25648 - ], - "id": "5e8d1212-8780-40c0-ac54-eb65141bd71f", - "name": "Merge Hojas 01-02" - }, - { - "parameters": {}, - "type": "n8n-nodes-base.merge", - "typeVersion": 3.2, - "position": [ - 16864, - 25856 - ], - "id": "fabe0277-9275-4bb9-a4e4-606fc7e7a6ad", - "name": "Merge Hojas 03" - }, - { - "parameters": {}, - "type": "n8n-nodes-base.merge", - "typeVersion": 3.2, - "position": [ - 17152, - 25984 - ], - "id": "9730a653-c2b3-49bf-890f-56f2a6ba2567", - "name": "Merge Hojas 04" - }, - { - "parameters": {}, - "type": "n8n-nodes-base.merge", - "typeVersion": 3.2, - "position": [ - 17568, - 26112 - ], - "id": "e1672eb2-31e0-4040-9971-c3eb99972e3f", - "name": "Merge Hojas 05" - }, - { - "parameters": {}, - "type": "n8n-nodes-base.merge", - "typeVersion": 3.2, - "position": [ - 17872, - 26256 - ], - "id": "2906d58d-35b0-4112-a709-8b3097fd2537", - "name": "Merge Hojas 06" - }, - { - "parameters": {}, - "type": "n8n-nodes-base.merge", - "typeVersion": 3.2, - "position": [ - 18208, - 26528 - ], - "id": "2b809028-6720-49c0-9a9c-f14cffa78c14", - "name": "Merge Hojas 07" - }, - { - "parameters": {}, - "type": "n8n-nodes-base.merge", - "typeVersion": 3.2, - "position": [ - 18464, - 26656 - ], - "id": "652101ad-34b6-4893-8e83-683ad7e78fd4", - "name": "Merge Hojas 08" - }, - { - "parameters": {}, - "type": "n8n-nodes-base.merge", - "typeVersion": 3.2, - "position": [ - 18768, - 27040 - ], - "id": "3af433bb-32fe-4c06-bea7-d00e9fea9c95", - "name": "Merge Hojas 09" - }, - { - "parameters": { - "operation": "xlsx", - "binaryPropertyName": "payroll_file", - "options": { - "headerRow": true, - "sheetName": "Mot Variable Abril" - } - }, - "type": "n8n-nodes-base.extractFromFile", - "typeVersion": 1.1, - "position": [ - 15664, - 27648 - ], - "id": "f8d6e3d6-7448-4a54-99e6-eec7f4fdec7b", - "name": "Extract - Mot Variable Abril", - "retryOnFail": false, - "onError": "continueRegularOutput" - }, - { - "parameters": {}, - "type": "n8n-nodes-base.merge", - "typeVersion": 3.2, - "position": [ - 19072, - 27152 - ], - "id": "4672d0c6-3116-42b5-b1f3-d7807b5313e1", - "name": "Merge Hojas " - }, - { - "parameters": { - "jsCode": "const data = $input.first().json || {};\n\nfunction normalizeText(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction roundMoney(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n}\n\nfunction readableCategory(value) {\n const map = {\n coincidencia: 'Coincidencia',\n discrepancia: 'Discrepancia',\n posible_cuenta_mal_digitada: 'Posible cuenta mal digitada',\n banco_sin_nomina: 'Banco sin nómina',\n banco_sin_bamboo: 'Banco sin Bamboo',\n nomina_sin_cuenta: 'Nómina sin cuenta',\n diferencia_nombre_banco: 'Diferencia nombre banco',\n };\n\n return map[value] || normalizeText(value).replace(/_/g, ' ');\n}\n\nfunction firstValue(value) {\n if (Array.isArray(value)) {\n return value.filter(Boolean).join(' / ');\n }\n\n return normalizeText(value);\n}\n\nfunction formatPeriodEnd(value) {\n const raw = normalizeText(value);\n\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(raw)) {\n return raw;\n }\n\n const [year, month, day] = raw.split('-');\n const monthNames = {\n '01': 'ene',\n '02': 'feb',\n '03': 'mar',\n '04': 'abr',\n '05': 'may',\n '06': 'jun',\n '07': 'jul',\n '08': 'ago',\n '09': 'sep',\n '10': 'oct',\n '11': 'nov',\n '12': 'dic',\n };\n\n return `${day}-${monthNames[month] || month}-${year}`;\n}\n\nfunction mainReportSense(row, difference) {\n const category = normalizeText(row.category).toLowerCase();\n const subcategory = normalizeText(row.subcategory).toLowerCase();\n\n if (category === 'posible_cuenta_mal_digitada') {\n return 'Revisar cuenta';\n }\n\n if (\n subcategory === 'nomina_con_cuenta_sin_pago_banco' ||\n (Number(row.bank_amount ?? row.bankAmount ?? 0) === 0 &&\n Number(row.payroll_amount ?? row.payrollAmount ?? 0) > 0)\n ) {\n return 'No aparece pagado en banco';\n }\n\n if (difference > 0) {\n return 'Se pagó de menos';\n }\n\n if (difference < 0) {\n return 'Se pagó de más';\n }\n\n return 'Revisar';\n}\n\nconst metadata = data.metadata || {};\nconst summary = data.summary || {};\nconst rows = Array.isArray(data.rows) ? data.rows : [];\nconst bankWithoutBamboo = Array.isArray(data.bankWithoutBamboo)\n ? data.bankWithoutBamboo\n : [];\n\nconst periodLabel =\n metadata.period_label ||\n `${metadata.year || ''}-${metadata.month || ''}-${metadata.period_type || ''}`;\n\nconst spreadsheetTitle =\n `Cruce de Cuentas GLM GT - ${periodLabel}`;\n\nconst mainReportSubtitle =\n `Diferencias de Monto Nómina vs. Banco · Guatemala · ${\n formatPeriodEnd(metadata.period_end || '')\n }`;\n\nconst bancoSinBambooSubtitle =\n `Pagos en banco sin empleado identificado en BambooHR · Guatemala · ${\n formatPeriodEnd(metadata.period_end || '')\n }`;\n\nconst cuentaMalDigitadaSubtitle =\n `Cuenta Mal Digitada en Nómina · Guatemala · ${\n formatPeriodEnd(metadata.period_end || '')\n }`;\n\nconst cuentaMalDigitadaCases = rows.filter(\n (row) => row.category === 'posible_cuenta_mal_digitada'\n);\n\nconst hasCuentaMalDigitada =\n cuentaMalDigitadaCases.length > 0;\n\nconst sheetIds = {\n nominaVsBanco: 101,\n bancoSinNomina: 102,\n bancoSinBamboo: 103,\n diferenciasNombreBanco: 104,\n cuentaMalDigitada: 105,\n resumen: 106,\n};\n\nconst sheetTitles = {\n nominaVsBanco: '01 Nómina vs Banco',\n bancoSinNomina: '02 Banco sin Nómina',\n bancoSinBamboo: '03 Banco sin Bamboo',\n diferenciasNombreBanco: '04 Diferencias nombre banco',\n cuentaMalDigitada: '05 Cuenta Mal Digitada',\n resumen: hasCuentaMalDigitada\n ? '06 Resumen'\n : '05 Resumen',\n};\n\nconst nominaVsBancoHeader = [\n '#',\n 'Empleado',\n 'Cuenta',\n 'Monto en Nómina (Q)',\n 'Monto en Banco (Q)',\n 'Diferencia (Q)',\n 'Sentido',\n 'Estado',\n 'Resolución',\n];\n\nconst mainReportRows = rows\n .filter((row) => row.category === 'discrepancia')\n .map((row, index) => {\n const payrollAmount = roundMoney(\n row.payroll_amount ?? row.payrollAmount ?? 0\n );\n const bankAmount = roundMoney(\n row.bank_amount ?? row.bankAmount ?? 0\n );\n const difference = roundMoney(\n row.difference ?? (payrollAmount - bankAmount)\n );\n\n return [\n index + 1,\n normalizeText(row.employee_name || row.employee || ''),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.payroll_account ||\n row.payrollAccount ||\n row.account ||\n ''\n ),\n payrollAmount,\n bankAmount,\n difference,\n mainReportSense(row, difference),\n normalizeText(row.status || 'Riesgo').toUpperCase(),\n '',\n ];\n });\n\nconst nominaVsBancoValues = [\n [\n 'GOMEZLEE MARKETING',\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n ],\n [\n mainReportSubtitle,\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n ],\n ['', '', '', '', '', '', '', '', ''],\n nominaVsBancoHeader,\n ...mainReportRows,\n];\n\nconst bancoSinNominaHeader = [\n 'Empleado Banco',\n 'Cuenta Banco',\n 'Moneda',\n 'Monto Banco',\n 'Estado',\n 'Observación',\n 'Resolución',\n];\n\nconst bancoSinNominaRows = rows\n .filter((row) => row.category === 'banco_sin_nomina')\n .map((row) => [\n normalizeText(row.employee_name || row.employee || ''),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n normalizeText(row.currency || 'QTZ'),\n roundMoney(row.bank_amount ?? row.bankAmount ?? 0),\n normalizeText(row.status || ''),\n normalizeText(row.observation || ''),\n '',\n ]);\n\nconst bancoSinBambooHeader = [\n '#',\n 'Nombre en banco',\n 'Nombre del cuentahabiente',\n 'Cuenta',\n 'Monto en banco (Q)',\n 'Número de envío',\n 'Estado',\n 'Resolución',\n];\n\nconst bancoSinBambooRows = bankWithoutBamboo.map((row, index) => [\n index + 1,\n normalizeText(row.bank_name_file || row.employee_name || ''),\n normalizeText(row.bank_account_holder || ''),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n roundMoney(row.bank_amount ?? row.bankAmount ?? 0),\n firstValue(row.shipment_numbers || row.shipment_number || ''),\n 'PENDIENTE REVISIÓN',\n '',\n]);\n\nconst bancoSinBambooValues = [\n [\n 'GOMEZLEE MARKETING',\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n ],\n [\n bancoSinBambooSubtitle,\n '',\n '',\n '',\n '',\n '',\n '',\n '',\n ],\n ['', '', '', '', '', '', '', ''],\n bancoSinBambooHeader,\n ...bancoSinBambooRows,\n];\n\nconst diferenciasNombreHeader = [\n 'Nombre en Archivo',\n 'Nombre del Cuentahabiente',\n 'Cuenta Destino',\n 'Moneda',\n 'Monto',\n 'Número de envío',\n 'Número de plan',\n 'Archivo',\n 'Estado',\n 'Observación',\n 'Resolución',\n];\n\nconst diferenciasNombreRows = rows\n .filter((row) => row.category === 'diferencia_nombre_banco')\n .map((row) => [\n normalizeText(row.bank_name_file || row.employee_name || ''),\n normalizeText(row.bank_account_holder || ''),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n normalizeText(row.currency || 'QTZ'),\n roundMoney(row.bank_amount ?? row.bankAmount ?? 0),\n normalizeText(row.shipment_number || ''),\n normalizeText(row.plan_number || ''),\n normalizeText(row.source_file || ''),\n normalizeText(row.status || ''),\n normalizeText(row.observation || ''),\n '',\n ]);\n\n\nfunction accountValues(value) {\n const values = Array.isArray(value)\n ? value\n : String(value ?? '')\n .split(/\\s*(?:\\/|;|,|\\by\\b)\\s*/i);\n\n return values\n .map((item) =>\n String(item ?? '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim()\n )\n .filter((item) =>\n item.length >= 7 &&\n !/^0+$/.test(item)\n );\n}\n\nfunction payrollAccountsForWrongAccount(row) {\n const candidates = [\n row.payroll_account,\n row.payrollAccount,\n ...(Array.isArray(row.source_rows)\n ? row.source_rows.flatMap((sourceRow) => [\n sourceRow.account,\n sourceRow.payroll_account,\n sourceRow.payrollAccount,\n ])\n : []),\n ];\n\n return Array.from(new Set(\n candidates.flatMap(accountValues)\n ));\n}\n\nfunction bankAccountForWrongAccount(row) {\n return firstValue(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n );\n}\n\nfunction wrongAccountTotalStatus(row) {\n const payrollAmount = roundMoney(\n row.payroll_amount ?? row.payrollAmount ?? 0\n );\n const bankAmount = roundMoney(\n row.bank_amount ?? row.bankAmount ?? 0\n );\n const difference = roundMoney(\n payrollAmount - bankAmount\n );\n\n if (Math.abs(difference) <= 0.02) {\n return 'El total de nómina coincide con el total del banco.';\n }\n\n if (difference > 0) {\n return (\n `El total de nómina supera el total del banco por ` +\n `Q${Math.abs(difference).toLocaleString('en-US', {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n })}.`\n );\n }\n\n return (\n `El total pagado por el banco supera el total de nómina por ` +\n `Q${Math.abs(difference).toLocaleString('en-US', {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n })}.`\n );\n}\n\nfunction wrongAccountFinding(row) {\n const existing = normalizeText(row.observation || '');\n\n if (existing) {\n return existing;\n }\n\n const payrollAccounts =\n payrollAccountsForWrongAccount(row);\n const bankAccount =\n bankAccountForWrongAccount(row);\n\n return (\n `El empleado presenta una posible inconsistencia entre ` +\n `la cuenta registrada en nómina ` +\n `(${payrollAccounts.join(' y ') || 'sin cuenta identificada'}) ` +\n `y la cuenta utilizada por el banco ` +\n `(${bankAccount || 'sin cuenta identificada'}).`\n );\n}\n\nconst cuentaMalDigitadaHeader = [\n '#',\n 'Campo',\n 'Detalle',\n 'Resolución',\n];\n\nconst cuentaMalDigitadaRows = [];\n\nfor (\n let index = 0;\n index < cuentaMalDigitadaCases.length;\n index++\n) {\n const row = cuentaMalDigitadaCases[index];\n const payrollAccounts =\n payrollAccountsForWrongAccount(row);\n const bankAccount =\n bankAccountForWrongAccount(row);\n\n const fields = [\n [\n 'Empleado',\n normalizeText(\n row.employee_name ||\n row.employee ||\n ''\n ),\n ],\n [\n 'Cuentas registradas en las hojas de nómina',\n payrollAccounts.join(' y ') ||\n 'No se identificó una cuenta válida en la nómina.',\n ],\n [\n 'Cuenta utilizada por el banco',\n bankAccount ||\n 'No se identificó una cuenta válida en el banco.',\n ],\n [\n 'Estado del total',\n wrongAccountTotalStatus(row),\n ],\n [\n 'Hallazgo',\n wrongAccountFinding(row),\n ],\n [\n 'Clasificación',\n 'Posible cuenta mal digitada — revisar y unificar la cuenta en las hojas de nómina.',\n ],\n ];\n\n fields.forEach((field, fieldIndex) => {\n cuentaMalDigitadaRows.push([\n fieldIndex === 0 ? index + 1 : '',\n field[0],\n field[1],\n '',\n ]);\n });\n}\n\nconst cuentaMalDigitadaValues = [\n [\n 'GOMEZLEE MARKETING',\n '',\n '',\n '',\n ],\n [\n cuentaMalDigitadaSubtitle,\n '',\n '',\n '',\n ],\n ['', '', '', ''],\n cuentaMalDigitadaHeader,\n ...cuentaMalDigitadaRows,\n];\n\nconst resumenHeader = ['Indicador', 'Valor'];\nconst resumenRows = [\n ['Período', periodLabel],\n ['Coincidencias', Number(summary.coincidencias || 0)],\n ['Discrepancias', Number(summary.discrepancias || 0)],\n ['Banco sin nómina', Number(summary.bancoSinNomina || 0)],\n ['Banco sin Bamboo', Number(summary.bancoSinBamboo || 0)],\n ['Nómina sin cuenta', Number(summary.nominaSinCuenta || 0)],\n [\n 'Diferencias nombre banco',\n Number(summary.diferenciasNombreBanco || 0),\n ],\n [\n 'Posibles cuentas mal digitadas',\n Number(summary.posiblesCuentasMalDigitadas || 0),\n ],\n ['Pendientes cruce principal', Number(summary.pendientes || 0)],\n [\n 'Empleados BambooHR Guatemala',\n Number(summary.empleadosBambooGT || 0),\n ],\n [\n 'Empleados BambooHR en el período',\n Number(summary.empleadosBambooEnPeriodo || 0),\n ],\n ['Filas válidas de nómina', Number(summary.filasNominaValidas || 0)],\n [\n 'Suplementos adjuntados',\n Number(summary.suplementosNominaAdjuntados || 0),\n ],\n [\n 'Suplementos sin coincidencia',\n Number(summary.suplementosNominaNoAdjuntados || 0),\n ],\n ['Transacciones bancarias', Number(summary.transaccionesBanco || 0)],\n ['Total nómina', roundMoney(summary.totalNomina || 0)],\n ['Total banco', roundMoney(summary.totalBanco || 0)],\n ['Diferencia total', roundMoney(summary.diferenciaTotal || 0)],\n];\n\nfunction buildSheetValues(header, bodyRows) {\n return [header, ...bodyRows];\n}\n\nconst valueData = [\n {\n range: `'${sheetTitles.nominaVsBanco}'!A1:I`,\n values: nominaVsBancoValues,\n },\n {\n range: `'${sheetTitles.bancoSinNomina}'!A1:G`,\n values: buildSheetValues(\n bancoSinNominaHeader,\n bancoSinNominaRows\n ),\n },\n {\n range: `'${sheetTitles.bancoSinBamboo}'!A1:H`,\n values: bancoSinBambooValues,\n },\n {\n range: `'${sheetTitles.diferenciasNombreBanco}'!A1:K`,\n values: buildSheetValues(\n diferenciasNombreHeader,\n diferenciasNombreRows\n ),\n },\n ...(hasCuentaMalDigitada\n ? [\n {\n range:\n `'${sheetTitles.cuentaMalDigitada}'!A1:D`,\n values: cuentaMalDigitadaValues,\n },\n ]\n : []),\n {\n range: `'${sheetTitles.resumen}'!A1:B`,\n values: buildSheetValues(\n resumenHeader,\n resumenRows\n ),\n },\n];\n\nfunction headerFormatRequest(\n sheetId,\n endColumnIndex,\n startRowIndex = 0\n) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex: startRowIndex + 1,\n startColumnIndex: 0,\n endColumnIndex,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: {\n red: 0.29,\n green: 0.49,\n blue: 0.58,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 1,\n green: 1,\n blue: 1,\n },\n },\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n },\n },\n fields:\n 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment,verticalAlignment,wrapStrategy)',\n },\n };\n}\n\nfunction freezeHeaderRequest(sheetId, frozenRowCount = 1) {\n return {\n updateSheetProperties: {\n properties: {\n sheetId,\n gridProperties: {\n frozenRowCount,\n },\n },\n fields: 'gridProperties.frozenRowCount',\n },\n };\n}\n\nfunction autoResizeRequest(sheetId, endColumnIndex) {\n return {\n autoResizeDimensions: {\n dimensions: {\n sheetId,\n dimension: 'COLUMNS',\n startIndex: 0,\n endIndex: endColumnIndex,\n },\n },\n };\n}\n\nfunction moneyFormatRequest(\n sheetId,\n startColumnIndex,\n endColumnIndex,\n startRowIndex = 1,\n pattern = '#,##0.00',\n endRowIndex = null\n) {\n const range = {\n sheetId,\n startRowIndex,\n startColumnIndex,\n endColumnIndex,\n };\n\n if (Number.isInteger(endRowIndex)) {\n range.endRowIndex = endRowIndex;\n }\n\n return {\n repeatCell: {\n range,\n cell: {\n userEnteredFormat: {\n numberFormat: {\n type: 'NUMBER',\n pattern,\n },\n },\n },\n fields: 'userEnteredFormat.numberFormat',\n },\n };\n}\n\nfunction basicFilterRequest(\n sheetId,\n endColumnIndex,\n startRowIndex = 0,\n endRowIndex = null\n) {\n const range = {\n sheetId,\n startRowIndex,\n startColumnIndex: 0,\n endColumnIndex,\n };\n\n if (Number.isInteger(endRowIndex)) {\n range.endRowIndex = endRowIndex;\n }\n\n return {\n setBasicFilter: {\n filter: {\n range,\n },\n },\n };\n}\n\nfunction mergeRowRequest(\n sheetId,\n rowIndex,\n endColumnIndex\n) {\n return {\n mergeCells: {\n range: {\n sheetId,\n startRowIndex: rowIndex,\n endRowIndex: rowIndex + 1,\n startColumnIndex: 0,\n endColumnIndex,\n },\n mergeType: 'MERGE_ALL',\n },\n };\n}\n\nfunction titleRowFormatRequest(\n sheetId,\n rowIndex,\n endColumnIndex,\n options = {}\n) {\n const {\n fontSize = 12,\n bold = true,\n italic = false,\n horizontalAlignment = 'LEFT',\n } = options;\n\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: rowIndex,\n endRowIndex: rowIndex + 1,\n startColumnIndex: 0,\n endColumnIndex,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: {\n red: 0.29,\n green: 0.49,\n blue: 0.58,\n },\n textFormat: {\n bold,\n italic,\n fontSize,\n foregroundColor: {\n red: 1,\n green: 1,\n blue: 1,\n },\n },\n horizontalAlignment,\n verticalAlignment: 'MIDDLE',\n },\n },\n fields:\n 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment,verticalAlignment)',\n },\n };\n}\n\nfunction columnWidthRequest(\n sheetId,\n startIndex,\n endIndex,\n pixelSize\n) {\n return {\n updateDimensionProperties: {\n range: {\n sheetId,\n dimension: 'COLUMNS',\n startIndex,\n endIndex,\n },\n properties: {\n pixelSize,\n },\n fields: 'pixelSize',\n },\n };\n}\n\nfunction rowHeightRequest(\n sheetId,\n startIndex,\n endIndex,\n pixelSize\n) {\n return {\n updateDimensionProperties: {\n range: {\n sheetId,\n dimension: 'ROWS',\n startIndex,\n endIndex,\n },\n properties: {\n pixelSize,\n },\n fields: 'pixelSize',\n },\n };\n}\n\nfunction bodyAlignmentRequest(\n sheetId,\n startColumnIndex,\n endColumnIndex,\n horizontalAlignment,\n endRowIndex\n) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: 4,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n },\n cell: {\n userEnteredFormat: {\n horizontalAlignment,\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n },\n },\n fields:\n 'userEnteredFormat(horizontalAlignment,verticalAlignment,wrapStrategy)',\n },\n };\n}\n\nfunction statusFormatRequest(sheetId, endRowIndex) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: 4,\n endRowIndex,\n startColumnIndex: 7,\n endColumnIndex: 8,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: {\n red: 1,\n green: 0.92,\n blue: 0.92,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.85,\n green: 0.08,\n blue: 0.08,\n },\n },\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n },\n },\n fields:\n 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment,verticalAlignment)',\n },\n };\n}\n\nfunction bambooStatusFormatRequest(\n sheetId,\n endRowIndex\n) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: 4,\n endRowIndex,\n startColumnIndex: 6,\n endColumnIndex: 7,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: {\n red: 1,\n green: 0.97,\n blue: 0.82,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.70,\n green: 0.30,\n blue: 0.00,\n },\n },\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n },\n },\n fields:\n 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment,verticalAlignment,wrapStrategy)',\n },\n };\n}\n\n\nfunction mergeVerticalRequest(\n sheetId,\n startRowIndex,\n endRowIndex,\n columnIndex\n) {\n return {\n mergeCells: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex: columnIndex,\n endColumnIndex: columnIndex + 1,\n },\n mergeType: 'MERGE_ALL',\n },\n };\n}\n\nfunction rangeFormatRequest(\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n userEnteredFormat\n) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n },\n cell: {\n userEnteredFormat,\n },\n fields:\n 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment,verticalAlignment,wrapStrategy)',\n },\n };\n}\n\nfunction conditionalDifferenceRequest(\n sheetId,\n formula,\n backgroundColor,\n textColor,\n ranges,\n index\n) {\n return {\n addConditionalFormatRule: {\n index,\n rule: {\n ranges,\n booleanRule: {\n condition: {\n type: 'CUSTOM_FORMULA',\n values: [\n {\n userEnteredValue: formula,\n },\n ],\n },\n format: {\n backgroundColor,\n textFormat: {\n bold: true,\n foregroundColor: textColor,\n },\n },\n },\n },\n },\n };\n}\n\nfunction borderRequest(\n sheetId,\n startRowIndex,\n endRowIndex,\n endColumnIndex\n) {\n const border = {\n style: 'SOLID',\n color: {\n red: 0.72,\n green: 0.82,\n blue: 0.76,\n },\n };\n\n return {\n updateBorders: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex: 0,\n endColumnIndex,\n },\n top: border,\n bottom: border,\n left: border,\n right: border,\n innerHorizontal: border,\n innerVertical: border,\n },\n };\n}\n\nconst mainReportEndRow = Math.max(\n 4 + mainReportRows.length,\n 4\n);\n\nconst bancoSinBambooEndRow = Math.max(\n 4 + bancoSinBambooRows.length,\n 4\n);\n\nconst mainConditionalRanges = mainReportRows.length\n ? [\n {\n sheetId: sheetIds.nominaVsBanco,\n startRowIndex: 4,\n endRowIndex: mainReportEndRow,\n startColumnIndex: 5,\n endColumnIndex: 7,\n },\n ]\n : [];\n\nconst mainDataFormatRequests = mainReportRows.length\n ? [\n moneyFormatRequest(\n sheetIds.nominaVsBanco,\n 3,\n 6,\n 4,\n 'Q#,##0.00;[Red](Q#,##0.00)',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 0,\n 1,\n 'CENTER',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 1,\n 2,\n 'LEFT',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 2,\n 3,\n 'CENTER',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 3,\n 6,\n 'RIGHT',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 6,\n 8,\n 'CENTER',\n mainReportEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.nominaVsBanco,\n 8,\n 9,\n 'LEFT',\n mainReportEndRow\n ),\n statusFormatRequest(\n sheetIds.nominaVsBanco,\n mainReportEndRow\n ),\n conditionalDifferenceRequest(\n sheetIds.nominaVsBanco,\n '=$F5>0',\n {\n red: 1,\n green: 0.97,\n blue: 0.82,\n },\n {\n red: 0.25,\n green: 0.25,\n blue: 0.25,\n },\n mainConditionalRanges,\n 0\n ),\n conditionalDifferenceRequest(\n sheetIds.nominaVsBanco,\n '=$F5<0',\n {\n red: 1,\n green: 0.89,\n blue: 0.89,\n },\n {\n red: 0.9,\n green: 0.05,\n blue: 0.05,\n },\n mainConditionalRanges,\n 1\n ),\n rowHeightRequest(\n sheetIds.nominaVsBanco,\n 4,\n mainReportEndRow,\n 28\n ),\n ]\n : [];\n\n\nconst cuentaMalDigitadaEndRow = Math.max(\n 4 + cuentaMalDigitadaRows.length,\n 4\n);\n\nconst cuentaMalDigitadaCaseRequests =\n hasCuentaMalDigitada\n ? cuentaMalDigitadaCases.flatMap(\n (_, caseIndex) => {\n const startRowIndex =\n 4 + caseIndex * 6;\n const endRowIndex =\n startRowIndex + 6;\n\n return [\n mergeVerticalRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endRowIndex,\n 0\n ),\n mergeVerticalRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endRowIndex,\n 3\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n startRowIndex + 4,\n 34\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex + 4,\n startRowIndex + 5,\n 76\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex + 5,\n endRowIndex,\n 54\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex + 4,\n startRowIndex + 5,\n 2,\n 3,\n {\n backgroundColor: {\n red: 0.97,\n green: 0.97,\n blue: 0.97,\n },\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n startRowIndex + 5,\n endRowIndex,\n 2,\n 3,\n {\n backgroundColor: {\n red: 1,\n green: 0.97,\n blue: 0.82,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.55,\n green: 0.30,\n blue: 0.00,\n },\n },\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n ];\n }\n )\n : [];\n\nconst cuentaMalDigitadaFormatRequests =\n hasCuentaMalDigitada\n ? [\n mergeRowRequest(\n sheetIds.cuentaMalDigitada,\n 0,\n 4\n ),\n mergeRowRequest(\n sheetIds.cuentaMalDigitada,\n 1,\n 4\n ),\n titleRowFormatRequest(\n sheetIds.cuentaMalDigitada,\n 0,\n 4,\n {\n fontSize: 12,\n bold: true,\n italic: false,\n horizontalAlignment: 'LEFT',\n }\n ),\n titleRowFormatRequest(\n sheetIds.cuentaMalDigitada,\n 1,\n 4,\n {\n fontSize: 10,\n bold: false,\n italic: true,\n horizontalAlignment: 'LEFT',\n }\n ),\n headerFormatRequest(\n sheetIds.cuentaMalDigitada,\n 4,\n 3\n ),\n freezeHeaderRequest(\n sheetIds.cuentaMalDigitada,\n 4\n ),\n borderRequest(\n sheetIds.cuentaMalDigitada,\n 3,\n cuentaMalDigitadaEndRow,\n 4\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n 0,\n 1,\n 30\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n 1,\n 2,\n 26\n ),\n rowHeightRequest(\n sheetIds.cuentaMalDigitada,\n 3,\n 4,\n 40\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n 4,\n cuentaMalDigitadaEndRow,\n 0,\n 1,\n {\n backgroundColor: {\n red: 0.91,\n green: 0.95,\n blue: 0.99,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.20,\n green: 0.36,\n blue: 0.45,\n },\n },\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n 4,\n cuentaMalDigitadaEndRow,\n 1,\n 2,\n {\n backgroundColor: {\n red: 0.93,\n green: 0.97,\n blue: 0.90,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.20,\n green: 0.36,\n blue: 0.45,\n },\n },\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n 4,\n cuentaMalDigitadaEndRow,\n 2,\n 3,\n {\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n rangeFormatRequest(\n sheetIds.cuentaMalDigitada,\n 4,\n cuentaMalDigitadaEndRow,\n 3,\n 4,\n {\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 0,\n 1,\n 48\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 1,\n 2,\n 285\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 2,\n 3,\n 520\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 3,\n 4,\n 260\n ),\n ...cuentaMalDigitadaCaseRequests,\n ]\n : [];\n\n\nfunction wrapRangeRequest(\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex\n) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n },\n cell: {\n userEnteredFormat: {\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n },\n },\n fields:\n 'userEnteredFormat(verticalAlignment,wrapStrategy)',\n },\n };\n}\n\nfunction autoResizeRowsRequest(\n sheetId,\n startIndex,\n endIndex\n) {\n return {\n autoResizeDimensions: {\n dimensions: {\n sheetId,\n dimension: 'ROWS',\n startIndex,\n endIndex,\n },\n },\n };\n}\n\nconst formatRequests = [\n mergeRowRequest(sheetIds.nominaVsBanco, 0, 9),\n mergeRowRequest(sheetIds.nominaVsBanco, 1, 9),\n titleRowFormatRequest(\n sheetIds.nominaVsBanco,\n 0,\n 9,\n {\n fontSize: 12,\n bold: true,\n italic: false,\n horizontalAlignment: 'LEFT',\n }\n ),\n titleRowFormatRequest(\n sheetIds.nominaVsBanco,\n 1,\n 9,\n {\n fontSize: 10,\n bold: false,\n italic: true,\n horizontalAlignment: 'LEFT',\n }\n ),\n headerFormatRequest(\n sheetIds.nominaVsBanco,\n 9,\n 3\n ),\n freezeHeaderRequest(\n sheetIds.nominaVsBanco,\n 4\n ),\n basicFilterRequest(\n sheetIds.nominaVsBanco,\n 9,\n 3,\n mainReportEndRow\n ),\n borderRequest(\n sheetIds.nominaVsBanco,\n 3,\n mainReportEndRow,\n 9\n ),\n rowHeightRequest(\n sheetIds.nominaVsBanco,\n 0,\n 1,\n 30\n ),\n rowHeightRequest(\n sheetIds.nominaVsBanco,\n 1,\n 2,\n 26\n ),\n rowHeightRequest(\n sheetIds.nominaVsBanco,\n 3,\n 4,\n 42\n ),\n ...mainDataFormatRequests,\n\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 0,\n 1,\n 48\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 1,\n 2,\n 260\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 2,\n 3,\n 130\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 3,\n 6,\n 130\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 6,\n 7,\n 155\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 7,\n 8,\n 110\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 8,\n 9,\n 260\n ),\n\n headerFormatRequest(sheetIds.bancoSinNomina, 7),\n freezeHeaderRequest(sheetIds.bancoSinNomina),\n autoResizeRequest(sheetIds.bancoSinNomina, 7),\n moneyFormatRequest(sheetIds.bancoSinNomina, 3, 4),\n basicFilterRequest(sheetIds.bancoSinNomina, 7),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 6,\n 7,\n 260\n ),\n\n mergeRowRequest(sheetIds.bancoSinBamboo, 0, 8),\n mergeRowRequest(sheetIds.bancoSinBamboo, 1, 8),\n titleRowFormatRequest(\n sheetIds.bancoSinBamboo,\n 0,\n 8,\n {\n fontSize: 12,\n bold: true,\n italic: false,\n horizontalAlignment: 'LEFT',\n }\n ),\n titleRowFormatRequest(\n sheetIds.bancoSinBamboo,\n 1,\n 8,\n {\n fontSize: 10,\n bold: false,\n italic: true,\n horizontalAlignment: 'LEFT',\n }\n ),\n headerFormatRequest(\n sheetIds.bancoSinBamboo,\n 8,\n 3\n ),\n freezeHeaderRequest(\n sheetIds.bancoSinBamboo,\n 4\n ),\n basicFilterRequest(\n sheetIds.bancoSinBamboo,\n 8,\n 3,\n bancoSinBambooEndRow\n ),\n borderRequest(\n sheetIds.bancoSinBamboo,\n 3,\n bancoSinBambooEndRow,\n 8\n ),\n rowHeightRequest(\n sheetIds.bancoSinBamboo,\n 0,\n 1,\n 30\n ),\n rowHeightRequest(\n sheetIds.bancoSinBamboo,\n 1,\n 2,\n 26\n ),\n rowHeightRequest(\n sheetIds.bancoSinBamboo,\n 3,\n 4,\n 42\n ),\n moneyFormatRequest(\n sheetIds.bancoSinBamboo,\n 4,\n 5,\n 4,\n 'Q#,##0.00',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 0,\n 1,\n 'CENTER',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 1,\n 3,\n 'LEFT',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 3,\n 4,\n 'CENTER',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 4,\n 5,\n 'RIGHT',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 5,\n 7,\n 'CENTER',\n bancoSinBambooEndRow\n ),\n bodyAlignmentRequest(\n sheetIds.bancoSinBamboo,\n 7,\n 8,\n 'LEFT',\n bancoSinBambooEndRow\n ),\n bambooStatusFormatRequest(\n sheetIds.bancoSinBamboo,\n bancoSinBambooEndRow\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 0,\n 1,\n 48\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 1,\n 2,\n 220\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 2,\n 3,\n 270\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 3,\n 4,\n 135\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 4,\n 5,\n 130\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 5,\n 6,\n 110\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 6,\n 7,\n 140\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 7,\n 8,\n 260\n ),\n\n headerFormatRequest(\n sheetIds.diferenciasNombreBanco,\n 11\n ),\n freezeHeaderRequest(\n sheetIds.diferenciasNombreBanco\n ),\n autoResizeRequest(\n sheetIds.diferenciasNombreBanco,\n 11\n ),\n moneyFormatRequest(\n sheetIds.diferenciasNombreBanco,\n 4,\n 5\n ),\n basicFilterRequest(\n sheetIds.diferenciasNombreBanco,\n 11\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 10,\n 11,\n 260\n ),\n\n ...cuentaMalDigitadaFormatRequests,\n\n headerFormatRequest(sheetIds.resumen, 2),\n freezeHeaderRequest(sheetIds.resumen),\n autoResizeRequest(sheetIds.resumen, 2),\n moneyFormatRequest(sheetIds.resumen, 1, 2),\n];\n\n\nconst bancoSinNominaEndRow =\n 1 + bancoSinNominaRows.length;\n\nconst diferenciasNombreEndRow =\n 1 + diferenciasNombreRows.length;\n\nconst resumenReadabilityEndRow =\n 1 + resumenRows.length;\n\n/*\n * Ajuste final de legibilidad.\n *\n * Se ejecuta al final para que los anchos definitivos ya estén aplicados\n * cuando Google Sheets calcule automáticamente la altura de cada fila.\n * Así, cualquier texto largo queda envuelto y visible sin que el usuario\n * tenga que expandir columnas o filas manualmente.\n */\nformatRequests.push(\n // 01 Nómina vs Banco\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 1,\n 2,\n 300\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 2,\n 3,\n 150\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 3,\n 6,\n 145\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 6,\n 7,\n 220\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 7,\n 8,\n 140\n ),\n columnWidthRequest(\n sheetIds.nominaVsBanco,\n 8,\n 9,\n 320\n ),\n ...(mainReportRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.nominaVsBanco,\n 4,\n mainReportEndRow,\n 0,\n 9\n ),\n autoResizeRowsRequest(\n sheetIds.nominaVsBanco,\n 4,\n mainReportEndRow\n ),\n ]\n : []),\n\n // 02 Banco sin Nómina\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 0,\n 1,\n 320\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 1,\n 2,\n 165\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 2,\n 3,\n 95\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 3,\n 4,\n 140\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 4,\n 5,\n 170\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 5,\n 6,\n 560\n ),\n columnWidthRequest(\n sheetIds.bancoSinNomina,\n 6,\n 7,\n 320\n ),\n autoResizeRowsRequest(\n sheetIds.bancoSinNomina,\n 0,\n 1\n ),\n ...(bancoSinNominaRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.bancoSinNomina,\n 1,\n bancoSinNominaEndRow,\n 0,\n 7\n ),\n autoResizeRowsRequest(\n sheetIds.bancoSinNomina,\n 1,\n bancoSinNominaEndRow\n ),\n ]\n : []),\n\n // 03 Banco sin Bamboo\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 1,\n 2,\n 300\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 2,\n 3,\n 330\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 3,\n 4,\n 150\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 4,\n 5,\n 145\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 5,\n 6,\n 150\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 6,\n 7,\n 170\n ),\n columnWidthRequest(\n sheetIds.bancoSinBamboo,\n 7,\n 8,\n 320\n ),\n ...(bancoSinBambooRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.bancoSinBamboo,\n 4,\n bancoSinBambooEndRow,\n 0,\n 8\n ),\n autoResizeRowsRequest(\n sheetIds.bancoSinBamboo,\n 4,\n bancoSinBambooEndRow\n ),\n ]\n : []),\n\n // 04 Diferencias nombre banco\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 0,\n 1,\n 300\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 1,\n 2,\n 330\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 2,\n 3,\n 155\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 3,\n 4,\n 95\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 4,\n 5,\n 140\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 5,\n 7,\n 150\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 7,\n 8,\n 260\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 8,\n 9,\n 170\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 9,\n 10,\n 650\n ),\n columnWidthRequest(\n sheetIds.diferenciasNombreBanco,\n 10,\n 11,\n 320\n ),\n autoResizeRowsRequest(\n sheetIds.diferenciasNombreBanco,\n 0,\n 1\n ),\n ...(diferenciasNombreRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.diferenciasNombreBanco,\n 1,\n diferenciasNombreEndRow,\n 0,\n 11\n ),\n autoResizeRowsRequest(\n sheetIds.diferenciasNombreBanco,\n 1,\n diferenciasNombreEndRow\n ),\n ]\n : []),\n\n // 05 Cuenta Mal Digitada\n ...(hasCuentaMalDigitada\n ? [\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 1,\n 2,\n 300\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 2,\n 3,\n 600\n ),\n columnWidthRequest(\n sheetIds.cuentaMalDigitada,\n 3,\n 4,\n 320\n ),\n ]\n : []),\n\n // Resumen\n columnWidthRequest(\n sheetIds.resumen,\n 0,\n 1,\n 380\n ),\n columnWidthRequest(\n sheetIds.resumen,\n 1,\n 2,\n 320\n ),\n autoResizeRowsRequest(\n sheetIds.resumen,\n 0,\n 1\n ),\n ...(resumenRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.resumen,\n 1,\n resumenReadabilityEndRow,\n 0,\n 2\n ),\n autoResizeRowsRequest(\n sheetIds.resumen,\n 1,\n resumenReadabilityEndRow\n ),\n ]\n : [])\n);\n\n\nreturn [\n {\n json: {\n ok: true,\n stage: 'preparar_google_sheet',\n metadata,\n summary,\n spreadsheetTitle,\n sheetIds,\n sheetTitles,\n createSpreadsheetBody: {\n properties: {\n title: spreadsheetTitle,\n },\n sheets: [\n {\n properties: {\n sheetId: sheetIds.nominaVsBanco,\n title: sheetTitles.nominaVsBanco,\n },\n },\n {\n properties: {\n sheetId: sheetIds.bancoSinNomina,\n title: sheetTitles.bancoSinNomina,\n },\n },\n {\n properties: {\n sheetId: sheetIds.bancoSinBamboo,\n title: sheetTitles.bancoSinBamboo,\n },\n },\n {\n properties: {\n sheetId:\n sheetIds.diferenciasNombreBanco,\n title:\n sheetTitles.diferenciasNombreBanco,\n },\n },\n ...(hasCuentaMalDigitada\n ? [\n {\n properties: {\n sheetId:\n sheetIds.cuentaMalDigitada,\n title:\n sheetTitles.cuentaMalDigitada,\n },\n },\n ]\n : []),\n {\n properties: {\n sheetId: sheetIds.resumen,\n title: sheetTitles.resumen,\n },\n },\n ],\n },\n valueBatchBody: {\n valueInputOption: 'USER_ENTERED',\n data: valueData,\n },\n formatBatchBody: {\n requests: formatRequests,\n },\n originalResponse: data,\n },\n },\n];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 22048, - 26512 - ], - "id": "5c8239ca-2f93-41a3-be89-cb6fce94d2b2", - "name": "Preparar Google Sheet" - }, - { - "parameters": { - "method": "POST", - "url": "https://sheets.googleapis.com/v4/spreadsheets", - "authentication": "predefinedCredentialType", - "nodeCredentialType": "googleOAuth2Api", - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{\n(() => {\n const prepared =\n $('Preparar Google Sheet').first().json || {};\n\n const createBody =\n prepared.createSpreadsheetBody || {};\n\n if (\n !Array.isArray(createBody.sheets) ||\n createBody.sheets.length === 0\n ) {\n throw new Error(\n 'Preparar Google Sheet no devolvió las hojas que deben crearse.'\n );\n }\n\n return {\n properties: {\n ...(createBody.properties || {}),\n timeZone: 'America/Guatemala',\n },\n\n sheets: createBody.sheets.map((sheet) => ({\n properties: {\n ...(sheet.properties || {}),\n\n gridProperties: {\n ...((sheet.properties || {}).gridProperties || {}),\n frozenRowCount: 1,\n },\n },\n })),\n };\n})()\n}}", - "options": {} - }, - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.4, - "position": [ - 22256, - 26512 - ], - "id": "937c7727-c0e4-49c7-ab93-199919b81763", - "name": "Crear Google Sheet", - "credentials": { - "httpBasicAuth": { - "id": "nIxZ7elcHvuzsRKW", - "name": "Neo4j" - }, - "googleOAuth2Api": { - "id": "eHseMeH39kRcXgOF", - "name": "Google account 2" - } - } - }, - { - "parameters": { - "method": "POST", - "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $('Crear Google Sheet').first().json.spreadsheetId + '/values:batchUpdate' }}", - "authentication": "predefinedCredentialType", - "nodeCredentialType": "googleOAuth2Api", - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ $('Preparar Google Sheet').first().json.valueBatchBody }}", - "options": {} - }, - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.4, - "position": [ - 22464, - 26512 - ], - "id": "c71323e4-d046-44c9-b073-e8f389ffc86f", - "name": "Escribir Google Sheet", - "credentials": { - "googleOAuth2Api": { - "id": "dQ1MJSJSWcoWYcb8", - "name": "Google account - Isaac Producción" - } - } - }, - { - "parameters": { - "method": "POST", - "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $('Crear Google Sheet').first().json.spreadsheetId + ':batchUpdate' }}", - "authentication": "predefinedCredentialType", - "nodeCredentialType": "googleOAuth2Api", - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ $('Preparar Google Sheet').first().json.formatBatchBody }}", - "options": {} - }, - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.4, - "position": [ - 22672, - 26512 - ], - "id": "21d98cca-0e31-4471-a0ab-b2ad0f0bf7a3", - "name": "Formatear Google Sheet", - "credentials": { - "googleOAuth2Api": { - "id": "dQ1MJSJSWcoWYcb8", - "name": "Google account - Isaac Producción" - } - } - }, - { - "parameters": { - "jsCode": "const prepared = $('Preparar Google Sheet').first().json || {};\nconst createdSheet = $('Crear Google Sheet').first().json || {};\n\nconst original =\n prepared.originalResponse ||\n prepared.original_response ||\n prepared.response ||\n {};\n\nconst spreadsheetId = createdSheet.spreadsheetId || '';\nconst reportUrl =\n createdSheet.spreadsheetUrl ||\n (spreadsheetId ? `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit` : null);\n\nreturn [\n {\n json: {\n ok: original.ok ?? true,\n message: reportUrl\n ? 'Cruce procesado correctamente. Google Sheet generado.'\n : 'Cruce procesado correctamente, pero no se recibió URL del Google Sheet.',\n stage: reportUrl ? 'cruce_completado_con_reporte' : 'cruce_completado_sin_reporte',\n errors: original.errors || [],\n metadata: original.metadata || {},\n summary: original.summary || {},\n rows: original.rows || [],\n bankWithoutBamboo:\n original.bankWithoutBamboo || [],\n bambooSummary:\n original.bambooSummary || {},\n reportUrl,\n googleSheet: {\n spreadsheetId,\n spreadsheetUrl: reportUrl,\n },\n debug: {\n rows_returned: Array.isArray(original.rows) ? original.rows.length : 0,\n coincidencias: original.summary?.coincidencias ?? 0,\n discrepancias: original.summary?.discrepancias ?? 0,\n bancoSinBamboo:\n original.summary?.bancoSinBamboo ?? 0,\n bancoSinBambooRows:\n Array.isArray(original.bankWithoutBamboo)\n ? original.bankWithoutBamboo.length\n : 0,\n },\n },\n },\n];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 24800, - 26528 - ], - "id": "aa72c7f0-6d16-483e-b991-292c01415eef", - "name": "Preparar respuesta final" - }, - { - "parameters": { - "jsCode": "const createdSheet = $('Crear Google Sheet').first().json || {};\nconst spreadsheetId = createdSheet.spreadsheetId;\n\nif (!spreadsheetId) {\n throw new Error('No se recibió spreadsheetId desde Crear Google Sheet.');\n}\n\nconst allowedEmails = [\n 'iaracena@gomezleemarketing.com',\n 'ymadera@gomezleemarketing.com',\n 'mgomez@gomezleemarketing.com',\n 'jgomez@gomezleemarketing.com',\n];\n\nreturn allowedEmails.map((email) => ({\n json: {\n spreadsheetId,\n email,\n permissionBody: {\n type: 'user',\n role: 'writer',\n emailAddress: email,\n },\n },\n}));" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 22880, - 26512 - ], - "id": "e2b64399-0b45-40b9-b2be-b7b88bdfa002", - "name": "Preparar permisos Google Sheet" - }, - { - "parameters": { - "method": "POST", - "url": "={{ 'https://www.googleapis.com/drive/v3/files/' + $json.spreadsheetId + '/permissions?sendNotificationEmail=false' }}", - "authentication": "predefinedCredentialType", - "nodeCredentialType": "googleOAuth2Api", - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ $json.permissionBody }}", - "options": {} - }, - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.4, - "position": [ - 23088, - 26512 - ], - "id": "d9026869-a053-4a40-8d5f-fe92e6c11c7d", - "name": "Compartir Google Sheet", - "credentials": { - "googleOAuth2Api": { - "id": "dQ1MJSJSWcoWYcb8", - "name": "Google account - Isaac Producción" - } - } - }, - { - "parameters": { - "jsCode": "const cruce = $('Cruzar Nómina vs Banco').first().json || {};\nconst createdSheet = $('Crear Google Sheet').first().json || {};\n\nconst metadata = cruce.metadata || {};\nconst summary = cruce.summary || {};\nconst debug = cruce.debug || {};\n\nconst spreadsheetId = createdSheet.spreadsheetId || cruce.spreadsheetId || '';\nconst reportUrl =\n createdSheet.spreadsheetUrl ||\n createdSheet.spreadsheet_url ||\n (spreadsheetId ? `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit` : null);\n\nfunction toNumber(value) {\n const parsed = Number(value);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction buildPeriodKey(periodMetadata) {\n const country = periodMetadata.country || 'GT';\n const year = periodMetadata.year || '';\n const month = String(periodMetadata.month || '').padStart(2, '0');\n const periodType = periodMetadata.period_type || 'periodo';\n return `${country}-${year}-${month}-${periodType}`;\n}\n\nconst discrepancias = toNumber(summary.discrepancias);\nconst bancoSinNomina = toNumber(summary.bancoSinNomina);\nconst nominaSinCuenta = toNumber(summary.nominaSinCuenta);\nconst diferenciasNombreBanco =\n toNumber(summary.diferenciasNombreBanco);\nconst bancoSinBamboo =\n toNumber(summary.bancoSinBamboo);\nconst pendientes = toNumber(summary.pendientes) || (\n discrepancias +\n bancoSinNomina +\n nominaSinCuenta +\n diferenciasNombreBanco\n);\n\n// Banco sin Bamboo es una revisión independiente del cruce principal.\nconst requiereRevision =\n pendientes > 0 || bancoSinBamboo > 0;\n\n// La app final solo manejará Pendiente revisión y Resuelto.\nconst estado = requiereRevision\n ? 'pendiente_revision'\n : 'resuelto';\n\nconst payload = {\n source_app: metadata.source_app || 'cruce-cuentas-glm-guatemala',\n country: metadata.country || 'GT',\n country_name: metadata.country_name || 'Guatemala',\n\n year: toNumber(metadata.year),\n month: toNumber(metadata.month),\n period_type: metadata.period_type || '',\n period_label: metadata.period_label || '',\n period_start: metadata.period_start || null,\n period_end: metadata.period_end || null,\n period_key: buildPeriodKey(metadata),\n\n payroll_file_name: metadata.payroll_file_name || '',\n bank_file_names: metadata.bank_file_names || [],\n\n coincidencias: toNumber(summary.coincidencias),\n discrepancias,\n\n banco_sin_bamboo: bancoSinBamboo,\n detalle_banco_sin_bamboo:\n Array.isArray(cruce.bankWithoutBamboo)\n ? cruce.bankWithoutBamboo\n : [],\n banco_sin_nomina: bancoSinNomina,\n nomina_sin_cuenta: nominaSinCuenta,\n nomina_sin_bamboo: 0,\n bamboo_sin_nomina: 0,\n\n filas_nomina_validas: toNumber(summary.filasNominaValidas),\n cuentas_nomina_agrupadas: toNumber(summary.cuentasNominaAgrupadas),\n transacciones_banco: toNumber(summary.transaccionesBanco),\n cuentas_banco_agrupadas: toNumber(summary.cuentasBancoAgrupadas),\n\n total_nomina: toNumber(summary.totalNomina),\n total_banco: toNumber(summary.totalBanco),\n diferencia_total: toNumber(summary.diferenciaTotal),\n\n report_url: reportUrl,\n spreadsheet_id: spreadsheetId,\n estado,\n\n ejecutado_por_nombre: metadata.requested_by_name || 'Usuario GLM',\n ejecutado_por_email: metadata.requested_by_email || '',\n\n metadata: {\n ...metadata,\n diferencias_nombre_banco:\n diferenciasNombreBanco,\n banco_sin_bamboo:\n bancoSinBamboo,\n pendientes_cruce_principal:\n pendientes,\n requiere_revision:\n requiereRevision,\n },\n summary,\n debug: {\n sheet_summaries: debug.sheet_summaries || [],\n attached_supplements: debug.attached_supplements || [],\n unattached_supplements: debug.unattached_supplements || [],\n bank_name_differences_preview:\n debug.bank_name_differences_preview || [],\n bamboo_matches:\n debug.bamboo_matches || [],\n bamboo_excluded_payments:\n debug.bamboo_excluded_payments || [],\n banco_sin_bamboo:\n cruce.bankWithoutBamboo || [],\n },\n};\n\nreturn [\n {\n json: {\n ...cruce,\n supabaseTable: 'cruces_cuentas_gt_reportes',\n supabasePayload: payload,\n reportUrl,\n spreadsheetId,\n },\n },\n];\n" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 24240, - 26528 - ], - "id": "471f36ca-d750-4e92-8807-4794ce0cc4a0", - "name": "Preparar histórico Supabase" - }, - { - "parameters": { - "method": "POST", - "url": "https://dbit.digitalcompass.agency/rest/v1/cruces_cuentas_gt_reportes", - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "apikey", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" - }, - { - "name": "Authorization", - "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" - }, - { - "name": "Content-Type", - "value": "application/json" - }, - { - "name": "Prefer", - "value": "return=representation" - } - ] - }, - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ $json.supabasePayload }}", - "options": {} - }, - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.4, - "position": [ - 24448, - 26528 - ], - "id": "69a0dfa0-702a-4ed6-93cc-01a0b97d9676", - "name": "Insertar histórico Supabase", - "onError": "continueRegularOutput" - }, - { - "parameters": { - "operation": "xlsx", - "binaryPropertyName": "payroll_file", - "options": { - "headerRow": false, - "sheetName": "Temporales WMC" - } - }, - "type": "n8n-nodes-base.extractFromFile", - "typeVersion": 1.1, - "position": [ - 15664, - 27632 - ], - "id": "f545108c-5069-4208-be51-531a235a81e2", - "name": "Extract - Temporales WMC", - "retryOnFail": false, - "onError": "continueRegularOutput" - }, - { - "parameters": {}, - "type": "n8n-nodes-base.merge", - "typeVersion": 3.2, - "position": [ - 19232, - 27296 - ], - "id": "e4f39bb4-ad63-4611-8ddc-09a44817eaa1", - "name": "Merge Hojas 10" - }, - { - "parameters": { - "method": "POST", - "url": "https://glm.bamboohr.com/api/v1/reports/custom?format=JSON&onlyCurrent=false", - "authentication": "genericCredentialType", - "genericAuthType": "httpBasicAuth", - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "Accept", - "value": "application/json" - } - ] - }, - "sendBody": true, - "specifyBody": "json", - "jsonBody": { - "title": "Información de BambooHR - Cruce de Cuentas GT", - "fields": [ - "firstName", - "middleName", - "lastName", - "displayName", - "department", - "division", - "location", - "customPosicion-Cliente", - "hireDate", - "originalHireDate", - "status", - "employeeNumber" - ] - }, - "options": { - "response": { - "response": { - "responseFormat": "json" - } - }, - "timeout": 300000 - } - }, - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.4, - "position": [ - 15216, - 25568 - ], - "id": "4487c967-fd95-4865-977b-b232f1a7b69d", - "name": "HTTP - Empleados BambooHR GT", - "retryOnFail": true, - "maxTries": 3, - "waitBetweenTries": 3000, - "credentials": { - "httpBasicAuth": { - "id": "7VrpNZ2jBLmiJ35q", - "name": "BambooHR GLM Full Access" - } - } - }, - { - "parameters": { - "jsCode": "const inputItems = $input.all();\nconst base = $('Preparar entrada app').first().json || {};\nconst reconciliationData = $('Merge').first().json || {};\nconst metadata = base.metadata || {};\n\nconst IGNORED_NAME_TOKENS = new Set([\n 'de', 'del', 'la', 'las', 'los',\n 'y', 'e', 'el', 'da', 'do',\n 'dos', 'das', 'van', 'von',\n]);\n\nfunction clean(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\u00A0/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalize(value) {\n return clean(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/['’`-]/g, ' ')\n .replace(/[^a-z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction unique(values) {\n const result = [];\n const seen = new Set();\n\n for (const value of values) {\n const cleaned = clean(value);\n\n if (!cleaned || seen.has(cleaned)) {\n continue;\n }\n\n seen.add(cleaned);\n result.push(cleaned);\n }\n\n return result;\n}\n\nfunction nameTokens(value) {\n return normalize(value)\n .split(' ')\n .filter(\n (token) =>\n token.length > 1 &&\n !IGNORED_NAME_TOKENS.has(token)\n );\n}\n\nfunction uniqueNameTokens(value) {\n return Array.from(\n new Set(nameTokens(value))\n );\n}\n\nfunction parseDate(value) {\n const raw = clean(value);\n if (!raw || raw === '0000-00-00') return null;\n\n const direct = raw.match(\n /^(\\d{4})-(\\d{2})-(\\d{2})/\n );\n\n if (direct) {\n return `${direct[1]}-${direct[2]}-${direct[3]}`;\n }\n\n const date = new Date(raw);\n if (Number.isNaN(date.getTime())) return null;\n\n return date.toISOString().slice(0, 10);\n}\n\nfunction parseBoolean(value) {\n if (typeof value === 'boolean') return value;\n\n return [\n 'true', 'yes', 'si', 'sí', '1', 'y',\n ].includes(normalize(value));\n}\n\nfunction isTargetCountry(employee) {\n const country = normalize(employee.country);\n const location = normalize(\n employee.location ||\n employee.jobInformationLocation ||\n employee.jobLocation\n );\n\n return (\n country === 'gt' ||\n country === 'gtm' ||\n country.includes('guatemala') ||\n location === 'gt' ||\n location === 'gtm' ||\n location.includes('guatemala')\n );\n}\n\nfunction overlapsPeriod(\n hireDate,\n terminationDate,\n periodStart,\n periodEnd\n) {\n if (!periodStart || !periodEnd) return false;\n\n const hiredBeforeEnd =\n !hireDate || hireDate <= periodEnd;\n\n const notTerminatedBeforeStart =\n !terminationDate ||\n terminationDate >= periodStart;\n\n return hiredBeforeEnd && notTerminatedBeforeStart;\n}\n\nfunction collectPageObjects(value, pages) {\n if (!value) return;\n\n if (Array.isArray(value)) {\n for (const entry of value) {\n collectPageObjects(entry, pages);\n }\n return;\n }\n\n if (typeof value !== 'object') return;\n\n if (value.body && typeof value.body === 'object') {\n collectPageObjects(value.body, pages);\n return;\n }\n\n if (\n Array.isArray(value.data) ||\n Array.isArray(value.employees)\n ) {\n pages.push(value);\n return;\n }\n\n if (value.json && typeof value.json === 'object') {\n collectPageObjects(value.json, pages);\n }\n}\n\nfunction profileContains(\n leftTokens,\n leftTokenSet,\n rightTokens,\n rightTokenSet\n) {\n if (\n leftTokens.length < 3 ||\n rightTokens.length < 3\n ) {\n return false;\n }\n\n const leftInsideRight =\n leftTokens.every((token) =>\n rightTokenSet.has(token)\n );\n\n if (leftInsideRight) return true;\n\n return rightTokens.every((token) =>\n leftTokenSet.has(token)\n );\n}\n\nfunction employeeKey(employee) {\n return (\n employee.bamboo_id ||\n employee.employee_number ||\n normalize(employee.full_name)\n );\n}\n\nconst pageObjects = [];\n\nfor (const item of inputItems) {\n collectPageObjects(item.json, pageObjects);\n}\n\nconst employeeMap = new Map();\nlet expectedTotal = 0;\nlet restrictedFields = 0;\n\nfor (const page of pageObjects) {\n const pageEmployees =\n Array.isArray(page.data)\n ? page.data\n : Array.isArray(page.employees)\n ? page.employees\n : [];\n\n const pageTotal = Number(\n page.meta?.total ||\n page.total ||\n 0\n );\n\n if (Number.isFinite(pageTotal)) {\n expectedTotal = Math.max(\n expectedTotal,\n pageTotal\n );\n }\n\n for (const employee of pageEmployees) {\n const key =\n clean(employee.employeeId || employee.id) ||\n clean(employee.employeeNumber) ||\n clean(employee.bestEmail).toLowerCase() ||\n [\n clean(employee.firstName),\n clean(employee.middleName),\n clean(employee.lastName),\n ].filter(Boolean).join('|').toLowerCase();\n\n if (!key) continue;\n\n employeeMap.set(key, employee);\n\n restrictedFields += Array.isArray(\n employee._restrictedFields\n )\n ? employee._restrictedFields.length\n : 0;\n }\n}\n\nconst rawEmployees = Array.from(\n employeeMap.values()\n);\n\nconst periodStart = clean(metadata.period_start);\nconst periodEnd = clean(metadata.period_end);\n\nconst targetEmployees = [];\nconst outsideRecords = [];\nlet normalizedEmployeesCount = 0;\n\nfor (const employee of rawEmployees) {\n const firstName = clean(employee.firstName);\n const middleName = clean(employee.middleName);\n const lastName = clean(employee.lastName);\n const preferredName = clean(\n employee.preferredName\n );\n\n const constructedFullName = [\n firstName,\n middleName,\n lastName,\n ].filter(Boolean).join(' ');\n\n const aliases = unique([\n employee.displayName,\n employee.fullName1,\n employee.fullName2,\n employee.fullName3,\n employee.fullName4,\n employee.fullName5,\n constructedFullName,\n [preferredName, lastName]\n .filter(Boolean)\n .join(' '),\n [firstName, lastName]\n .filter(Boolean)\n .join(' '),\n ]);\n\n const hireDate = parseDate(\n employee.hireDate ||\n employee.originalHireDate\n );\n\n const terminationDate = parseDate(\n employee.terminationDate\n );\n\n const status = clean(\n employee.status ||\n employee.employmentStatus ||\n employee.employmentHistoryStatus\n );\n\n const employeeNumber = clean(\n employee.employeeNumber ||\n employee.employee_number\n );\n\n const normalizedEmployee = {\n bamboo_id: clean(\n employee.employeeId ||\n employee.id\n ),\n employee_number: employeeNumber,\n first_name: firstName,\n middle_name: middleName,\n last_name: lastName,\n preferred_name: preferredName,\n full_name:\n clean(employee.displayName) ||\n clean(employee.fullName1) ||\n constructedFullName,\n aliases,\n normalized_aliases:\n aliases.map(normalize).filter(Boolean),\n status,\n hire_date: hireDate,\n termination_date: terminationDate,\n location: clean(\n employee.location ||\n employee.jobInformationLocation ||\n employee.jobLocation\n ),\n country: clean(employee.country),\n include_in_payroll:\n parseBoolean(employee.includeInPayroll),\n work_email:\n clean(employee.workEmail).toLowerCase(),\n home_email:\n clean(employee.homeEmail).toLowerCase(),\n best_email: clean(\n employee.bestEmail ||\n employee.workEmail ||\n employee.homeEmail\n ).toLowerCase(),\n exists_in_bamboo: true,\n overlaps_period: overlapsPeriod(\n hireDate,\n terminationDate,\n periodStart,\n periodEnd\n ),\n };\n\n normalizedEmployeesCount += 1;\n\n if (isTargetCountry(normalizedEmployee)) {\n targetEmployees.push({\n ...normalizedEmployee,\n validation_eligible: true,\n validation_scope:\n 'guatemala_country_or_location',\n });\n continue;\n }\n\n const aliasProfiles = aliases\n .map((rawAlias) => {\n const tokens =\n uniqueNameTokens(rawAlias);\n\n return {\n raw: rawAlias,\n tokens,\n token_set: new Set(tokens),\n };\n })\n .filter(\n (profile) =>\n profile.tokens.length >= 3\n );\n\n const searchTokens = new Set();\n\n for (const profile of aliasProfiles) {\n for (const token of profile.tokens) {\n searchTokens.add(token);\n }\n }\n\n outsideRecords.push({\n employee: normalizedEmployee,\n alias_profiles: aliasProfiles,\n search_tokens: searchTokens,\n });\n}\n\nconst relevantNameMap = new Map();\n\nfunction addRelevantName(value) {\n const cleaned = clean(value);\n const normalized = normalize(cleaned);\n\n if (!normalized) return;\n\n const tokens = uniqueNameTokens(cleaned);\n const current =\n relevantNameMap.get(normalized);\n\n if (\n !current ||\n tokens.length > current.tokens.length\n ) {\n relevantNameMap.set(\n normalized,\n {\n raw: cleaned,\n tokens,\n token_set: new Set(tokens),\n }\n );\n }\n}\n\nfor (const row of reconciliationData.bank?.rows || []) {\n addRelevantName(row.bank_name_file);\n addRelevantName(row.bank_account_holder);\n addRelevantName(row.participant_name);\n}\n\nfor (\n const row of\n reconciliationData.bank?.grouped_by_account || []\n) {\n addRelevantName(row.bank_name_file);\n addRelevantName(row.bank_account_holder);\n\n for (const name of row.bank_name_files || []) {\n addRelevantName(name);\n }\n\n for (\n const name of\n row.bank_account_holders || []\n ) {\n addRelevantName(name);\n }\n}\n\nfor (const row of [\n ...(reconciliationData.payroll?.rows || []),\n ...(reconciliationData.payroll?.grouped_by_account || []),\n ...(reconciliationData.payroll?.no_account_rows || []),\n]) {\n addRelevantName(\n row.employee_name ||\n row.employee ||\n ''\n );\n}\n\n/*\n * Índice invertido para rescatar perfiles con país/localidad incorrectos.\n *\n * La versión anterior comparaba cada nombre relevante contra todos los\n * empleados fuera de Guatemala y todas sus variantes de nombre. Con casi\n * 10,000 perfiles, eso provocaba millones de normalizaciones y bloqueaba el\n * task runner. Aquí cada palabra apunta directamente a los pocos empleados\n * que la contienen; luego solo se revisan candidatos con al menos tres\n * palabras compartidas.\n */\nconst outsideTokenIndex = new Map();\n\nfor (\n let index = 0;\n index < outsideRecords.length;\n index++\n) {\n for (\n const token of\n outsideRecords[index].search_tokens\n ) {\n let bucket =\n outsideTokenIndex.get(token);\n\n if (!bucket) {\n bucket = [];\n outsideTokenIndex.set(\n token,\n bucket\n );\n }\n\n bucket.push(index);\n }\n}\n\nconst targetExactAliasSet = new Set();\n\nfor (const employee of targetEmployees) {\n for (\n const normalizedAlias of\n employee.normalized_aliases || []\n ) {\n if (normalizedAlias) {\n targetExactAliasSet.add(\n normalizedAlias\n );\n }\n }\n}\n\nlet contextualOutsideSkippedByTargetExact = 0;\nconst contextualOutsideMap = new Map();\n\nfor (\n const relevantProfile of\n relevantNameMap.values()\n) {\n if (relevantProfile.tokens.length < 3) {\n continue;\n }\n\n /*\n * Prioridad absoluta al país objetivo:\n *\n * Cuando el nombre completo recibido existe exactamente en Guatemala,\n * no se incorpora un perfil externo cuyo nombre más corto esté contenido\n * dentro de ese mismo texto. Esto evita conflictos entre dos personas\n * distintas, por ejemplo:\n *\n * - JORGE LUIS MORALES PEREZ · Guatemala\n * - Jorge Luis Morales · otro país\n *\n * El rescate fuera del país permanece activo cuando no existe una\n * coincidencia exacta entre los perfiles de Guatemala.\n */\n if (\n targetExactAliasSet.has(\n normalize(relevantProfile.raw)\n )\n ) {\n contextualOutsideSkippedByTargetExact += 1;\n continue;\n }\n\n const sharedTokenCounts = new Map();\n\n for (const token of relevantProfile.tokens) {\n for (\n const outsideIndex of\n outsideTokenIndex.get(token) || []\n ) {\n sharedTokenCounts.set(\n outsideIndex,\n (\n sharedTokenCounts.get(\n outsideIndex\n ) || 0\n ) + 1\n );\n }\n }\n\n const uniqueMatches = new Map();\n\n for (\n const [\n outsideIndex,\n sharedTokenCount,\n ] of sharedTokenCounts\n ) {\n if (sharedTokenCount < 3) {\n continue;\n }\n\n const record =\n outsideRecords[outsideIndex];\n\n const matches =\n record.alias_profiles.some(\n (aliasProfile) =>\n profileContains(\n relevantProfile.tokens,\n relevantProfile.token_set,\n aliasProfile.tokens,\n aliasProfile.token_set\n )\n );\n\n if (!matches) continue;\n\n const key =\n employeeKey(record.employee);\n\n if (key) {\n uniqueMatches.set(\n key,\n record.employee\n );\n }\n }\n\n /*\n * Solo se rescata un perfil fuera del país cuando un nombre informativo\n * identifica exactamente a una única persona. Así se corrigen localidades\n * erróneas sin convertir nombres comunes en falsos positivos.\n */\n if (uniqueMatches.size !== 1) {\n continue;\n }\n\n const employee =\n uniqueMatches.values().next().value;\n\n const key = employeeKey(employee);\n\n if (!key) continue;\n\n contextualOutsideMap.set(key, {\n ...employee,\n validation_eligible: true,\n validation_scope:\n 'outside_country_unique_informative_name',\n });\n}\n\nconst validationEmployeeMap = new Map();\n\nfor (const employee of [\n ...targetEmployees,\n ...contextualOutsideMap.values(),\n]) {\n const key = employeeKey(employee);\n\n if (key) {\n validationEmployeeMap.set(\n key,\n employee\n );\n }\n}\n\nconst validationEmployees =\n Array.from(\n validationEmployeeMap.values()\n );\n\n\n/*\n * Resolución previa de nombres contra BambooHR.\n *\n * Cada nombre distinto recibido desde banco y nómina se resuelve una sola\n * vez, usando índices de alias y palabras. El resultado queda disponible\n * para el nodo de cruce mediante resolved_name_matches.\n */\nconst CONFIRMED_BAMBOO_NAME_ALIASES = new Map([\n [normalize(\"CARLOS DE LEON\"), normalize(\"Carlos Alexander De leon chajon\")],\n [normalize(\"CARLOS ALEXANDER DE LEON CHAJON\"), normalize(\"Carlos Alexander De leon chajon\")],\n [normalize(\"LISANDRO LINARES\"), normalize(\"Lisandro Antonio Linares giron\")],\n [normalize(\"LISANDRO ANTONIO LINARES GIRON\"), normalize(\"Lisandro Antonio Linares giron\")],\n [normalize(\"JULIO VELASQUEZ\"), normalize(\"Julio Francisco Velásquez\")],\n [normalize(\"JULIO FRANCISCO VELASQUEZ RAMIREZ\"), normalize(\"Julio Francisco Velásquez\")],\n [normalize(\"PABLO GIRON\"), normalize(\"Pablo Augusto Giron Robles\")],\n [normalize(\"PABLO AUGUSTO GIRON ROBLES\"), normalize(\"Pablo Augusto Giron Robles\")],\n [normalize(\"VERONICA GARCIA\"), normalize(\"Verónica Beatriz García Quan\")],\n [normalize(\"VERONICA BEATRIZ GARCIA\"), normalize(\"Verónica Beatriz García Quan\")],\n [normalize(\"VERONICA BEATRIZ GARCIA QUAN\"), normalize(\"Verónica Beatriz García Quan\")],\n [normalize(\"SANDRA LOPEZ\"), normalize(\"Sandra Lisbeth López Godoy\")],\n [normalize(\"SANDRA LIZBETH LOPEZ GODOY\"), normalize(\"Sandra Lisbeth López Godoy\")],\n [normalize(\"SANDRA LISBETH LOPEZ GODOY\"), normalize(\"Sandra Lisbeth López Godoy\")]\n]);\n\nfunction relevantEntryRaw(entry) {\n if (typeof entry === 'string') return clean(entry);\n return clean(entry?.raw || entry?.name || '');\n}\n\nfunction bambooResolutionEmployeeKey(employee) {\n return (\n clean(employee.bamboo_id) ||\n clean(employee.employee_number) ||\n normalize(employee.full_name)\n );\n}\n\nfunction bambooResolutionEditDistance(left, right) {\n const a = String(left || '');\n const b = String(right || '');\n\n if (a === b) return 0;\n if (!a) return b.length;\n if (!b) return a.length;\n\n let previous = Array.from(\n { length: b.length + 1 },\n (_, index) => index\n );\n\n for (let row = 1; row <= a.length; row++) {\n const current = [row];\n\n for (let column = 1; column <= b.length; column++) {\n const cost =\n a[row - 1] === b[column - 1]\n ? 0\n : 1;\n\n current[column] = Math.min(\n current[column - 1] + 1,\n previous[column] + 1,\n previous[column - 1] + cost\n );\n }\n\n previous = current;\n }\n\n return previous[b.length];\n}\nfunction bambooResolutionTokenSimilarity(left, right) {\n const a = String(left || '');\n const b = String(right || '');\n\n if (!a || !b) return 0;\n if (a === b) return 1;\n\n const minimumLength = Math.min(\n a.length,\n b.length\n );\n\n const maximumLength = Math.max(\n a.length,\n b.length\n );\n\n const distance =\n bambooResolutionEditDistance(a, b);\n\n if (\n minimumLength >= 4 &&\n distance <= 1\n ) {\n return Math.max(\n 0.90,\n 1 - distance / maximumLength\n );\n }\n\n if (\n minimumLength >= 6 &&\n distance <= 2\n ) {\n return Math.max(\n 0.82,\n 1 - distance / maximumLength\n );\n }\n\n const prefixOrSuffix =\n a.startsWith(b) ||\n b.startsWith(a) ||\n a.endsWith(b) ||\n b.endsWith(a);\n\n if (\n prefixOrSuffix &&\n minimumLength >= 4\n ) {\n return Math.max(\n 0.78,\n minimumLength / maximumLength\n );\n }\n\n return 0;\n}\n\nfunction bambooResolutionAliasDetails(\n queryName,\n aliasProfile\n) {\n const queryWords = Array.from(\n new Set(nameTokens(queryName))\n );\n\n const aliasWords =\n aliasProfile.words;\n\n if (\n queryWords.length < 2 ||\n aliasWords.length < 2\n ) {\n return null;\n }\n\n const aliasWordSet =\n aliasProfile.word_set;\n\n const queryWordSet =\n new Set(queryWords);\n\n const queryInsideAlias =\n queryWords.every((word) =>\n aliasWordSet.has(word)\n );\n\n const aliasInsideQuery =\n aliasWords.every((word) =>\n queryWordSet.has(word)\n );\n\n const usedAliasIndexes = new Set();\n const usedQueryIndexes = new Set();\n const similarities = new Array(\n queryWords.length\n ).fill(0);\n\n let exactMatches = 0;\n\n for (\n let queryIndex = 0;\n queryIndex < queryWords.length;\n queryIndex++\n ) {\n const aliasIndex =\n aliasWords.findIndex(\n (aliasWord, currentAliasIndex) =>\n !usedAliasIndexes.has(\n currentAliasIndex\n ) &&\n aliasWord ===\n queryWords[queryIndex]\n );\n\n if (aliasIndex < 0) continue;\n\n usedQueryIndexes.add(queryIndex);\n usedAliasIndexes.add(aliasIndex);\n similarities[queryIndex] = 1;\n exactMatches += 1;\n }\n\n const remainingQueryIndexes =\n queryWords\n .map((word, index) => ({\n word,\n index,\n }))\n .filter((entry) =>\n !usedQueryIndexes.has(entry.index)\n )\n .sort((left, right) =>\n right.word.length -\n left.word.length\n );\n\n for (const queryEntry of remainingQueryIndexes) {\n let bestSimilarity = 0;\n let bestAliasIndex = -1;\n\n for (\n let aliasIndex = 0;\n aliasIndex < aliasWords.length;\n aliasIndex++\n ) {\n if (\n usedAliasIndexes.has(\n aliasIndex\n )\n ) {\n continue;\n }\n\n const similarity =\n bambooResolutionTokenSimilarity(\n queryEntry.word,\n aliasWords[aliasIndex]\n );\n\n if (similarity > bestSimilarity) {\n bestSimilarity = similarity;\n bestAliasIndex = aliasIndex;\n }\n }\n\n if (\n bestAliasIndex >= 0 &&\n bestSimilarity >= 0.78\n ) {\n usedAliasIndexes.add(\n bestAliasIndex\n );\n similarities[queryEntry.index] =\n bestSimilarity;\n }\n }\n\n const matchedTokens =\n similarities.filter(\n (value) => value >= 0.78\n ).length;\n\n const queryCoverage =\n similarities.reduce(\n (sum, value) => sum + value,\n 0\n ) / queryWords.length;\n\n const aliasCoverage =\n matchedTokens /\n aliasWords.length;\n\n const lengthBalance =\n Math.min(\n queryWords.length,\n aliasWords.length\n ) /\n Math.max(\n queryWords.length,\n aliasWords.length\n );\n\n const score =\n queryCoverage * 0.65 +\n aliasCoverage * 0.20 +\n (\n exactMatches /\n queryWords.length\n ) * 0.10 +\n lengthBalance * 0.05;\n\n return {\n score,\n exact_matches: exactMatches,\n matched_tokens: matchedTokens,\n query_tokens:\n queryWords.length,\n alias_tokens:\n aliasWords.length,\n query_coverage:\n queryCoverage,\n alias_coverage:\n aliasCoverage,\n containment:\n queryInsideAlias ||\n aliasInsideQuery,\n };\n}\n\nconst bambooResolutionProfiles =\n validationEmployees.map(\n (employee, employeeIndex) => {\n const aliases = [];\n const seenAliases = new Set();\n\n for (\n const rawAlias of\n employee.aliases || []\n ) {\n const normalizedAlias =\n normalize(rawAlias);\n\n if (\n !normalizedAlias ||\n seenAliases.has(\n normalizedAlias\n )\n ) {\n continue;\n }\n\n seenAliases.add(\n normalizedAlias\n );\n\n const words = Array.from(\n new Set(nameTokens(rawAlias))\n );\n\n if (!words.length) continue;\n\n aliases.push({\n raw: clean(rawAlias),\n normalized:\n normalizedAlias,\n words,\n word_set:\n new Set(words),\n });\n }\n\n return {\n employee,\n employee_index:\n employeeIndex,\n employee_key:\n bambooResolutionEmployeeKey(\n employee\n ),\n aliases,\n };\n }\n );\n\nconst bambooResolutionExactAliasSets =\n new Map();\n\nconst bambooResolutionTokenSets =\n new Map();\n\nconst bambooResolutionTokenShapeSets =\n new Map();\n\nfor (\n let employeeIndex = 0;\n employeeIndex <\n bambooResolutionProfiles.length;\n employeeIndex++\n) {\n const profile =\n bambooResolutionProfiles[\n employeeIndex\n ];\n\n for (const alias of profile.aliases) {\n let exactSet =\n bambooResolutionExactAliasSets\n .get(alias.normalized);\n\n if (!exactSet) {\n exactSet = new Set();\n bambooResolutionExactAliasSets\n .set(\n alias.normalized,\n exactSet\n );\n }\n\n exactSet.add(employeeIndex);\n\n for (const token of alias.words) {\n if (token.length < 3) continue;\n\n let tokenSet =\n bambooResolutionTokenSets\n .get(token);\n\n if (!tokenSet) {\n tokenSet = new Set();\n bambooResolutionTokenSets\n .set(token, tokenSet);\n }\n\n tokenSet.add(employeeIndex);\n\n const tokenShape =\n `${token[0]}:${token.length}`;\n\n let shapeSet =\n bambooResolutionTokenShapeSets\n .get(tokenShape);\n\n if (!shapeSet) {\n shapeSet = new Set();\n bambooResolutionTokenShapeSets\n .set(\n tokenShape,\n shapeSet\n );\n }\n\n shapeSet.add(employeeIndex);\n }\n }\n}\n\nconst bambooResolutionExactAliasMap =\n new Map();\n\nfor (\n const [alias, indexes] of\n bambooResolutionExactAliasSets\n) {\n bambooResolutionExactAliasMap.set(\n alias,\n Array.from(indexes)\n );\n}\n\nfunction bambooResolutionDecision(\n queryName\n) {\n const rawQuery = clean(queryName);\n const normalizedQuery =\n normalize(rawQuery);\n\n const queryWords = Array.from(\n new Set(nameTokens(rawQuery))\n );\n\n if (\n !normalizedQuery ||\n queryWords.length < 2\n ) {\n return {\n found: false,\n matched_by: null,\n confidence: 0,\n reason:\n 'insufficient_name_tokens',\n };\n }\n\n const confirmedCanonical =\n CONFIRMED_BAMBOO_NAME_ALIASES\n .get(normalizedQuery);\n\n if (confirmedCanonical) {\n const confirmedIndexes =\n bambooResolutionExactAliasMap\n .get(confirmedCanonical) || [];\n\n if (confirmedIndexes.length === 1) {\n const employeeIndex =\n confirmedIndexes[0];\n\n return {\n found: true,\n matched_by:\n 'confirmed_alias_catalog',\n confidence: 1,\n employee_index:\n employeeIndex,\n employee_key:\n bambooResolutionProfiles[\n employeeIndex\n ].employee_key,\n query_name:\n rawQuery,\n bamboo_alias:\n bambooResolutionProfiles[\n employeeIndex\n ].aliases.find(\n (alias) =>\n alias.normalized ===\n confirmedCanonical\n )?.raw ||\n bambooResolutionProfiles[\n employeeIndex\n ].employee.full_name ||\n '',\n };\n }\n }\n\n const exactIndexes =\n bambooResolutionExactAliasMap\n .get(normalizedQuery) || [];\n\n if (exactIndexes.length === 1) {\n const employeeIndex =\n exactIndexes[0];\n\n return {\n found: true,\n matched_by:\n 'exact_precomputed_name',\n confidence: 1,\n employee_index:\n employeeIndex,\n employee_key:\n bambooResolutionProfiles[\n employeeIndex\n ].employee_key,\n query_name:\n rawQuery,\n bamboo_alias:\n bambooResolutionProfiles[\n employeeIndex\n ].aliases.find(\n (alias) =>\n alias.normalized ===\n normalizedQuery\n )?.raw ||\n bambooResolutionProfiles[\n employeeIndex\n ].employee.full_name ||\n '',\n };\n }\n\n const candidateVotes = new Map();\n\n function addCandidateVotes(\n indexes,\n weight\n ) {\n for (const employeeIndex of indexes) {\n candidateVotes.set(\n employeeIndex,\n (\n candidateVotes.get(\n employeeIndex\n ) || 0\n ) + weight\n );\n }\n }\n\n for (const token of queryWords) {\n addCandidateVotes(\n bambooResolutionTokenSets\n .get(token) || [],\n 4\n );\n\n for (\n let lengthOffset = -2;\n lengthOffset <= 2;\n lengthOffset++\n ) {\n const candidateLength =\n token.length + lengthOffset;\n\n if (candidateLength < 3) {\n continue;\n }\n\n addCandidateVotes(\n bambooResolutionTokenShapeSets\n .get(\n `${token[0]}:${candidateLength}`\n ) || [],\n 1\n );\n }\n }\n\n const candidateIndexes =\n Array.from(\n candidateVotes.entries()\n )\n .sort((left, right) =>\n right[1] - left[1]\n )\n .slice(0, 120)\n .map(([employeeIndex]) =>\n employeeIndex\n );\n\n const rankedCandidates = [];\n\n for (\n const employeeIndex of\n candidateIndexes\n ) {\n const profile =\n bambooResolutionProfiles[\n employeeIndex\n ];\n\n let bestDetails = null;\n let bestAlias = '';\n\n for (const alias of profile.aliases) {\n const details =\n bambooResolutionAliasDetails(\n rawQuery,\n alias\n );\n\n if (\n details &&\n (\n !bestDetails ||\n details.score >\n bestDetails.score\n )\n ) {\n bestDetails = details;\n bestAlias = alias.raw;\n }\n }\n\n if (!bestDetails) continue;\n\n rankedCandidates.push({\n employee_index:\n employeeIndex,\n employee_key:\n profile.employee_key,\n details:\n bestDetails,\n bamboo_alias:\n bestAlias,\n });\n }\n\n rankedCandidates.sort(\n (left, right) => {\n if (\n right.details.score !==\n left.details.score\n ) {\n return (\n right.details.score -\n left.details.score\n );\n }\n\n if (\n right.details.exact_matches !==\n left.details.exact_matches\n ) {\n return (\n right.details.exact_matches -\n left.details.exact_matches\n );\n }\n\n return (\n right.details.query_coverage -\n left.details.query_coverage\n );\n }\n );\n\n const best =\n rankedCandidates[0] || null;\n\n const second =\n rankedCandidates[1] || null;\n\n const margin =\n best\n ? best.details.score -\n (\n second?.details.score ||\n 0\n )\n : 0;\n\n const details =\n best?.details || null;\n\n const exactContainment =\n Boolean(\n details?.containment &&\n details.exact_matches >= 2\n );\n\n const strongTwoTokenName =\n Boolean(\n details &&\n details.query_tokens === 2 &&\n details.matched_tokens === 2 &&\n details.exact_matches >= 1 &&\n details.query_coverage >= 0.90 &&\n details.score >= 0.88\n );\n\n const strongLongName =\n Boolean(\n details &&\n details.query_tokens >= 3 &&\n details.matched_tokens >=\n Math.min(\n 3,\n details.query_tokens\n ) &&\n details.exact_matches >= 2 &&\n details.query_coverage >= 0.85 &&\n details.score >= 0.84\n );\n\n const acceptableMargin =\n !second ||\n margin >= (\n exactContainment\n ? 0.04\n : 0.06\n ) ||\n (\n details?.exact_matches || 0\n ) >\n (\n second?.details\n ?.exact_matches || 0\n );\n\n if (\n best &&\n acceptableMargin &&\n (\n exactContainment ||\n strongTwoTokenName ||\n strongLongName\n )\n ) {\n return {\n found: true,\n matched_by:\n exactContainment\n ? 'unique_precomputed_containment'\n : 'strong_precomputed_fuzzy_name',\n confidence:\n Math.min(\n 1,\n details.score\n ),\n employee_index:\n best.employee_index,\n employee_key:\n best.employee_key,\n query_name:\n rawQuery,\n bamboo_alias:\n best.bamboo_alias,\n margin,\n exact_matches:\n details.exact_matches,\n matched_tokens:\n details.matched_tokens,\n };\n }\n\n return {\n found: false,\n matched_by: null,\n confidence:\n details?.score || 0,\n reason:\n best\n ? (\n acceptableMargin\n ? 'insufficient_name_evidence'\n : 'ambiguous_name'\n )\n : 'no_candidate',\n best_candidate:\n best\n ? {\n employee_index:\n best.employee_index,\n employee_key:\n best.employee_key,\n bamboo_alias:\n best.bamboo_alias,\n score:\n best.details.score,\n }\n : null,\n second_candidate:\n second\n ? {\n employee_index:\n second.employee_index,\n employee_key:\n second.employee_key,\n bamboo_alias:\n second.bamboo_alias,\n score:\n second.details.score,\n }\n : null,\n };\n}\n\nconst resolvedNameMatches = {};\nlet resolvedNameMatchesFound = 0;\n\nfor (\n const [\n normalizedRelevantName,\n relevantEntry,\n ] of relevantNameMap\n) {\n const rawRelevantName =\n relevantEntryRaw(relevantEntry);\n\n const decision =\n bambooResolutionDecision(\n rawRelevantName\n );\n\n resolvedNameMatches[\n normalizedRelevantName\n ] = decision;\n\n if (decision.found) {\n resolvedNameMatchesFound += 1;\n }\n}\n\n\nconst fetchedEmployeesCount =\n rawEmployees.length;\n\nconst fetchComplete =\n expectedTotal > 0\n ? fetchedEmployeesCount >= expectedTotal\n : (\n pageObjects.length > 0 &&\n !pageObjects.some(\n (page) =>\n Boolean(\n page?._links?.next?.href\n )\n )\n );\n\nconst errors = [];\n\nif (!pageObjects.length) {\n errors.push(\n 'BambooHR no devolvió páginas de empleados.'\n );\n}\n\nif (!fetchedEmployeesCount) {\n errors.push(\n 'BambooHR no devolvió empleados.'\n );\n}\n\nif (\n expectedTotal > 0 &&\n fetchedEmployeesCount < expectedTotal\n) {\n errors.push(\n `La descarga de BambooHR quedó incompleta: ` +\n `${fetchedEmployeesCount} de ${expectedTotal} empleados.`\n );\n}\n\nif (!targetEmployees.length) {\n errors.push(\n 'No se encontraron empleados de Guatemala en BambooHR.'\n );\n}\n\nreturn [\n {\n json: {\n ...base,\n ok:\n Boolean(base.ok ?? true) &&\n errors.length === 0,\n stage:\n errors.length === 0\n ? 'bamboohr_gt_normalizado'\n : 'bamboohr_gt_incompleto',\n errors: [\n ...(Array.isArray(base.errors)\n ? base.errors\n : []),\n ...errors,\n ],\n bamboo: {\n source:\n 'bamboohr_custom_report_only_current_false',\n period_start: periodStart,\n period_end: periodEnd,\n pages_fetched: pageObjects.length,\n expected_total: expectedTotal,\n raw_employees_count:\n fetchedEmployeesCount,\n employees_count:\n normalizedEmployeesCount,\n guatemala_count:\n targetEmployees.length,\n active_in_period_count:\n targetEmployees.filter(\n (employee) =>\n employee.overlaps_period\n ).length,\n active_status_count:\n targetEmployees.filter(\n (employee) =>\n normalize(employee.status) ===\n 'active'\n ).length,\n contextual_outside_country_count:\n contextualOutsideMap.size,\n contextual_outside_skipped_by_target_exact_count:\n contextualOutsideSkippedByTargetExact,\n validation_candidates_count:\n validationEmployees.length,\n resolved_name_matches:\n resolvedNameMatches,\n resolved_name_matches_count:\n Object.keys(\n resolvedNameMatches\n ).length,\n resolved_name_matches_found:\n resolvedNameMatchesFound,\n name_resolution_strategy:\n 'precomputed_indexed_fuzzy_matching_with_target_exact_precedence',\n fetch_complete: fetchComplete,\n validation_available:\n fetchComplete &&\n validationEmployees.length > 0,\n validation_rule:\n 'Target country/location first; outside-country contextual rescue only when no exact target alias exists',\n performance_strategy:\n 'precomputed_alias_tokens_and_inverted_index',\n employees:\n validationEmployees,\n restricted_fields:\n restrictedFields,\n },\n },\n },\n];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 15440, - 25568 - ], - "id": "a0b69501-6075-4d1c-819e-a7b15a683ddb", - "name": "Normalizar BambooHR GT" - }, - { - "parameters": { - "mode": "combine", - "combineBy": "combineByPosition", - "options": {} - }, - "type": "n8n-nodes-base.merge", - "typeVersion": 3.2, - "position": [ - 21472, - 26512 - ], - "id": "4ba30bd6-5082-4299-aaaa-4082014f781e", - "name": "Merge - Agregar BambooHR" - }, - { - "parameters": { - "content": "# 📥 ENTRADA Y EXTRACCIÓN DE DATOS — GUATEMALA\n\nRecibe desde el Portal de Verificación de Nóminas los archivos y parámetros necesarios para ejecutar el cruce de Guatemala.\n\nFuentes procesadas:\n\n- Directorio de empleados de BambooHR.\n- Archivo CSV del banco.\n- Archivo Excel de nómina con múltiples hojas.\n- Hojas adicionales de pagos, bonos, viáticos, combustibles, auditorías, temporales y otras unidades.\n\nEste bloque:\n\n1. Recibe la solicitud de la aplicación.\n2. Normaliza año, mes y tipo de período.\n3. Consulta los empleados disponibles en BambooHR.\n4. Estandariza nombres, correos e identificadores.\n5. Convierte el CSV bancario en registros procesables.\n6. Extrae cada hoja relevante del archivo Excel.\n7. Mantiene la hoja de origen de cada registro para facilitar validaciones.\n\nReglas:\n\n- No iniciar el cruce sin los archivos obligatorios.\n- Mantener separados banco, nómina y BambooHR.\n- No asumir que todas las hojas tienen la misma estructura.\n- No perder la procedencia de los registros.\n- Preparar todas las fuentes en un formato compatible con la consolidación.", - "height": 2992, - "width": 1760, - "color": "#2D305D" - }, - "type": "n8n-nodes-base.stickyNote", - "typeVersion": 1, - "position": [ - 14432, - 24992 - ], - "id": "a9eedba9-b326-4097-b31a-9cb977f38400", - "name": "Sticky Note" - }, - { - "parameters": { - "content": "# 🧩 CONSOLIDACIÓN DE NÓMINA — GUATEMALA\n\nUne progresivamente todas las hojas extraídas hasta construir una sola nómina consolidada del período.\n\nDurante la consolidación:\n\n- Se agregan las hojas en una secuencia controlada.\n- Se conserva la unidad o pestaña de procedencia.\n- Se eliminan filas completamente vacías.\n- Se estandarizan encabezados y tipos de datos.\n- Se normalizan nombres de empleados.\n- Se limpian espacios, símbolos y caracteres especiales.\n- Se convierten montos y fechas a formatos consistentes.\n\nLa salida de este bloque representa la nómina completa que será comparada con el banco y BambooHR.\n\nReglas:\n\n- No sobrescribir registros de hojas anteriores.\n- No eliminar empleados únicamente porque aparezcan en más de una hoja.\n- Identificar correctamente posibles duplicados reales.\n- Mantener los valores originales para revisión.\n- No enviar hojas individualmente a la etapa de cruce.", - "height": 2336, - "width": 3984, - "color": "#216353" - }, - "type": "n8n-nodes-base.stickyNote", - "typeVersion": 1, - "position": [ - 16368, - 25136 - ], - "id": "458cfcea-4a35-4a73-bfd9-c2689bb88d0b", - "name": "Sticky Note1" - }, - { - "parameters": { - "content": "# 🔍 CRUCE Y REPORTE FINAL — GUATEMALA\n\nCombina la nómina consolidada con el archivo bancario y la información oficial de BambooHR.\n\nEl cruce permite detectar:\n\n- Diferencias entre nómina y banco.\n- Empleados presentes solamente en nómina.\n- Registros presentes solamente en el banco.\n- Empleados no encontrados en BambooHR.\n- Posibles diferencias de nombres, cuentas o identificadores.\n- Diferencias en montos pagados.\n- Registros que requieren revisión manual.\n\nDespués del análisis:\n\n1. Se organizan los resultados por hoja y categoría.\n2. Se prepara la estructura del reporte.\n3. Se crea un nuevo Google Sheet.\n4. Se escriben encabezados, resultados y resúmenes.\n5. Se aplican formatos de moneda, fechas y columnas.\n6. Se configuran los permisos.\n7. Se comparte el archivo con los usuarios autorizados.\n\nReglas:\n\n- No depender únicamente del nombre para relacionar empleados.\n- No ocultar registros sin coincidencia.\n- No compartir el Sheet antes de finalizar su escritura.\n- No devolver el enlace hasta confirmar que el archivo existe.\n- Google Sheets funciona como entregable; las fuentes originales siguen siendo BambooHR, nómina y banco.", - "height": 608, - "width": 2608, - "color": "#5F721D" - }, - "type": "n8n-nodes-base.stickyNote", - "typeVersion": 1, - "position": [ - 20752, - 26288 - ], - "id": "74dfb3d9-327a-428e-a0d8-400436289f6c", - "name": "Sticky Note2" - }, - { - "parameters": { - "content": "# 🗂️ HISTÓRICO Y RESPUESTA FINAL\n\nRegistra en Supabase la ejecución completada y devuelve el resultado al Portal de Verificación de Nóminas.\n\nEl histórico puede almacenar:\n\n- País: Guatemala.\n- Año y mes procesados.\n- Tipo de período.\n- Fecha de ejecución.\n- Usuario que inició el proceso.\n- Cantidad de registros analizados.\n- Cantidad de diferencias o hallazgos.\n- Enlace del Google Sheet.\n- Estado inicial del reporte.\n- Identificador de la ejecución.\n\nDespués del registro:\n\n1. Se construye la respuesta para la aplicación.\n2. Se incluye el enlace al reporte generado.\n3. Se devuelve el resumen de resultados.\n4. Se informa si el procesamiento terminó correctamente.\n5. Se cierra la solicitud mediante Respond to Webhook.\n\nReglas:\n\n- Registrar el histórico solamente después de crear el reporte.\n- No declarar éxito si falló el Sheet o Supabase.\n- No devolver credenciales ni información interna.\n- Mantener una estructura estable para la aplicación.\n- Supabase es la fuente oficial de los históricos mostrados en el portal.", - "height": 656, - "width": 1760, - "color": 3 - }, - "type": "n8n-nodes-base.stickyNote", - "typeVersion": 1, - "position": [ - 23536, - 26256 - ], - "id": "86284c0d-1d6b-4ed5-84a6-2dea3aaed6e8", - "name": "Sticky Note3" - } - ], - "pinData": { - "Webhook": [ - { - "json": { - "headers": { - "host": "agenteit.digitalcompass.agency", - "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36", - "content-length": "823089", - "accept": "*/*", - "accept-encoding": "gzip, deflate, br, zstd", - "accept-language": "es-ES,es;q=0.9", - "content-type": "multipart/form-data; boundary=----WebKitFormBoundaryVu8EZkp7sVCkGzUo", - "origin": "https://digitalcompass.agency", - "priority": "u=1, i", - "referer": "https://digitalcompass.agency/", - "sec-ch-ua": "\"Not;A=Brand\";v=\"8\", \"Chromium\";v=\"150\", \"Google Chrome\";v=\"150\"", - "sec-ch-ua-mobile": "?0", - "sec-ch-ua-platform": "\"Windows\"", - "sec-fetch-dest": "empty", - "sec-fetch-mode": "cors", - "sec-fetch-site": "same-site", - "x-forwarded-for": "186.7.36.83", - "x-forwarded-host": "agenteit.digitalcompass.agency", - "x-forwarded-port": "443", - "x-forwarded-proto": "https", - "x-forwarded-server": "07b4f09d2c65", - "x-real-ip": "186.7.36.83" - }, - "params": {}, - "query": {}, - "body": { - "metadata": "{\"country\":\"GT\",\"country_name\":\"Guatemala\",\"year\":2026,\"month\":6,\"period_type\":\"quincena_30\",\"period_label\":\"Junio 2026 · Quincena 30 / fin de mes\",\"period_start\":\"2026-06-16\",\"period_end\":\"2026-06-30\",\"payroll_file_name\":\"2Q GT_Nómina P&G, Nestle, Purina, Whirpool, Motorola_GLM_JUNIO 30 2026.xlsx\",\"bank_file_names\":[\"Consulta Detalle de Envío (61).csv\",\"Consulta Detalle de Envío (62).csv\",\"Consulta Detalle de Envío (63).csv\",\"Consulta Detalle de Envío (64).csv\",\"Consulta Detalle de Envío (65).csv\",\"Consulta Detalle de Envío (66).csv\",\"Consulta Detalle de Envío (68).csv\",\"Consulta Detalle de Envío (69).csv\",\"Consulta Detalle de Envío (70).csv\",\"Consulta Detalle de Envío (71).csv\",\"Consulta Detalle de Envío (73).csv\",\"Consulta Detalle de Envío (74).csv\",\"Consulta Detalle de Envío (75).csv\",\"Consulta Detalle de Envío (76).csv\",\"Consulta Detalle de Envío (77).csv\",\"Consulta Detalle de Envío (78).csv\",\"Consulta Detalle de Envío (79).csv\",\"Consulta Detalle de Envío (80).csv\",\"Consulta Detalle de Envío (83).csv\"],\"requested_by_name\":\"Isaac Aracena\",\"requested_by_email\":\"iaracena@gomezleemarketing.com\",\"auth_mode\":\"supabase_google\",\"source_app\":\"portal-cruce-cuentas-glm\"}", - "country": "GT", - "country_name": "Guatemala", - "year": "2026", - "month": "6", - "period_type": "quincena_30", - "period_label": "Junio 2026 · Quincena 30 / fin de mes", - "period_start": "2026-06-16", - "period_end": "2026-06-30", - "payroll_file_name": "2Q GT_Nómina P&G, Nestle, Purina, Whirpool, Motorola_GLM_JUNIO 30 2026.xlsx", - "bank_file_names": "[\"Consulta Detalle de Envío (61).csv\",\"Consulta Detalle de Envío (62).csv\",\"Consulta Detalle de Envío (63).csv\",\"Consulta Detalle de Envío (64).csv\",\"Consulta Detalle de Envío (65).csv\",\"Consulta Detalle de Envío (66).csv\",\"Consulta Detalle de Envío (68).csv\",\"Consulta Detalle de Envío (69).csv\",\"Consulta Detalle de Envío (70).csv\",\"Consulta Detalle de Envío (71).csv\",\"Consulta Detalle de Envío (73).csv\",\"Consulta Detalle de Envío (74).csv\",\"Consulta Detalle de Envío (75).csv\",\"Consulta Detalle de Envío (76).csv\",\"Consulta Detalle de Envío (77).csv\",\"Consulta Detalle de Envío (78).csv\",\"Consulta Detalle de Envío (79).csv\",\"Consulta Detalle de Envío (80).csv\",\"Consulta Detalle de Envío (83).csv\"]", - "requested_by_name": "Isaac Aracena", - "requested_by_email": "iaracena@gomezleemarketing.com", - "auth_mode": "supabase_google", - "source_app": "portal-cruce-cuentas-glm" - }, - "webhookUrl": "https://agenteit.digitalcompass.agency/webhook/nominagt-bamboo-test", - "executionMode": "production" - }, - "binary": { - "payroll_file": { - "mimeType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "fileExtension": "xlsx", - "data": "filesystem-v2", - "fileName": "2Q GT_Nómina P&G, Nestle, Purina, Whirpool, Motorola_GLM_JUNIO 30 2026.xlsx", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/2a413d82-90aa-44bf-a25f-2fbc8c6825b0", - "fileSize": "636 kB", - "bytes": 636171 - }, - "bank_files0": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (61).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/7870646d-3320-49e8-a86a-f49021356b44", - "fileSize": "1.74 kB", - "bytes": 1739 - }, - "bank_files1": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (62).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/5f70d84b-10a6-4d41-8418-cc6ed32ba09d", - "fileSize": "881 B", - "bytes": 881 - }, - "bank_files2": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (63).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/0ace2ca2-8c32-430a-af36-54383bca5262", - "fileSize": "683 B", - "bytes": 683 - }, - "bank_files3": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (64).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/a94897d8-b548-44ce-8897-7516ecc56307", - "fileSize": "36.5 kB", - "bytes": 36547 - }, - "bank_files4": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (66).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/9792f09c-8e4b-455c-85fa-b36d7d46fe7a", - "fileSize": "1.01 kB", - "bytes": 1012 - }, - "bank_files5": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (68).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/04fcc1f2-ea0f-472f-a3d7-6932157d7f6a", - "fileSize": "1.01 kB", - "bytes": 1012 - }, - "bank_files6": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (69).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/07dbf710-b954-4308-835b-13dbe821bdb5", - "fileSize": "11.4 kB", - "bytes": 11445 - }, - "bank_files7": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (70).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/42bd4686-90ed-433a-a869-2bc53bfba8a7", - "fileSize": "7.74 kB", - "bytes": 7743 - }, - "bank_files8": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (65).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/a5e274a1-de2a-4eff-b0b2-16f71f33706e", - "fileSize": "5.65 kB", - "bytes": 5650 - }, - "bank_files9": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (71).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/ad1109c0-150c-49ab-b1c0-397f4e741919", - "fileSize": "2.74 kB", - "bytes": 2739 - }, - "bank_files10": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (73).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/9cc8023d-a12a-425e-a106-70cc982b1d0d", - "fileSize": "3.41 kB", - "bytes": 3413 - }, - "bank_files11": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (75).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/ad5e812a-7602-496b-a425-ad6f47f140bb", - "fileSize": "2.44 kB", - "bytes": 2443 - }, - "bank_files12": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (76).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/4e95c954-5903-42ea-b25d-2f7b919d6cbc", - "fileSize": "2.39 kB", - "bytes": 2393 - }, - "bank_files13": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (77).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/d04ec36f-71f5-4d09-8a75-295d2217a752", - "fileSize": "1.21 kB", - "bytes": 1208 - }, - "bank_files14": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (74).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/32750d16-6ae1-4297-8243-9b8895217384", - "fileSize": "3.23 kB", - "bytes": 3225 - }, - "bank_files15": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (78).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/c6098992-9b14-4078-a4b0-ce473be356ab", - "fileSize": "2.4 kB", - "bytes": 2402 - }, - "bank_files16": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (79).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/6b322b42-66ad-4b2c-bebf-eda89c484d2b", - "fileSize": "2.04 kB", - "bytes": 2043 - }, - "bank_files17": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (80).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/7c91ea6c-73d6-488a-9c5c-2b1f287b54a6", - "fileSize": "672 B", - "bytes": 672 - }, - "bank_files18": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (83).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/a49f2f66-49c0-4e12-800b-901aa0b48f52", - "fileSize": "1.01 kB", - "bytes": 1006 - }, - "bank_file_1": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (61).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/355c42f8-a692-4368-be11-15bad1cc6eb8", - "fileSize": "1.74 kB", - "bytes": 1739 - }, - "bank_file_2": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (62).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/9d4d9b3b-dd63-4a05-879b-bb0b588b61c3", - "fileSize": "881 B", - "bytes": 881 - }, - "bank_file_3": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (63).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/6f8b69af-f9a7-4c12-b5d3-fe23c5990fb9", - "fileSize": "683 B", - "bytes": 683 - }, - "bank_file_4": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (64).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/b4ab07ef-c57f-4b9d-b122-353d00702b49", - "fileSize": "36.5 kB", - "bytes": 36547 - }, - "bank_file_5": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (65).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/9cc78cdc-0d37-4992-b175-f5f90822a3ce", - "fileSize": "5.65 kB", - "bytes": 5650 - }, - "bank_file_6": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (66).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/b9d27045-f83c-4f5f-9837-935afcaae2d3", - "fileSize": "1.01 kB", - "bytes": 1012 - }, - "bank_file_7": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (68).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/de2db25a-ef53-4b06-a001-ad0f90ca6c6a", - "fileSize": "1.01 kB", - "bytes": 1012 - }, - "bank_file_8": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (69).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/54850c19-b3d2-4ad1-9388-61bb28472607", - "fileSize": "11.4 kB", - "bytes": 11445 - }, - "bank_file_9": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (70).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/77a463eb-250d-4740-984a-68ec2668bcaf", - "fileSize": "7.74 kB", - "bytes": 7743 - }, - "bank_file_10": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (71).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/f7c33325-205b-40e4-943e-0e0029da8349", - "fileSize": "2.74 kB", - "bytes": 2739 - }, - "bank_file_11": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (73).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/2881deb6-29e7-4bd9-af9b-7163a9ddabf0", - "fileSize": "3.41 kB", - "bytes": 3413 - }, - "bank_file_12": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (74).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/bac0f5cf-62fb-421a-9163-3429c21ad86d", - "fileSize": "3.23 kB", - "bytes": 3225 - }, - "bank_file_13": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (75).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/4ef606c3-08cf-4c72-b3f3-9d5afd36160a", - "fileSize": "2.44 kB", - "bytes": 2443 - }, - "bank_file_14": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (76).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/8eb4d533-0480-40e8-ac2f-c3ebf99fdfb9", - "fileSize": "2.39 kB", - "bytes": 2393 - }, - "bank_file_15": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (77).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/9983752c-c90f-43ce-a8df-7b4ef0598a48", - "fileSize": "1.21 kB", - "bytes": 1208 - }, - "bank_file_16": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (78).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/7c94ec4b-737d-4648-9785-7c4f395d7ccb", - "fileSize": "2.4 kB", - "bytes": 2402 - }, - "bank_file_17": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (79).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/3d1593bd-3e61-498f-a065-44d70e0c959a", - "fileSize": "2.04 kB", - "bytes": 2043 - }, - "bank_file_18": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (80).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/4c3ef003-1018-4de6-bcfd-c7aac67abf2a", - "fileSize": "672 B", - "bytes": 672 - }, - "bank_file_19": { - "mimeType": "text/csv", - "fileType": "text", - "fileExtension": "csv", - "data": "filesystem-v2", - "fileName": "Consulta Detalle de Envío (83).csv", - "id": "filesystem-v2:workflows/d3vXYr7ucbaiU5ct/executions/37128/binary_data/451f19d0-dc67-4f5e-9696-0ff8eab5d2d3", - "fileSize": "1.01 kB", - "bytes": 1006 - } - }, - "pairedItem": { - "item": 0 - } - } - ] - }, - "connections": { - "Webhook": { - "main": [ - [ - { - "node": "Preparar entrada app", - "type": "main", - "index": 0 - } - ] - ] - }, - "Preparar entrada app": { - "main": [ - [ - { - "node": "Parsear CSV banco GT", - "type": "main", - "index": 0 - }, - { - "node": "Extract - Nomina General", - "type": "main", - "index": 0 - }, - { - "node": "Extract - Temporales", - "type": "main", - "index": 0 - }, - { - "node": "Extract - Auditorias", - "type": "main", - "index": 0 - }, - { - "node": "Extract - Bono Mariana", - "type": "main", - "index": 0 - }, - { - "node": "Extract - Movilidad WP", - "type": "main", - "index": 0 - }, - { - "node": "Extract - Viaticos PMI", - "type": "main", - "index": 0 - }, - { - "node": "Extract - Combustible Purina", - "type": "main", - "index": 0 - }, - { - "node": "Extract - Combustible PG", - "type": "main", - "index": 0 - }, - { - "node": "Extract - Combustibles Liquidables", - "type": "main", - "index": 0 - }, - { - "node": "Extract - Mot Variable Abril", - "type": "main", - "index": 0 - }, - { - "node": "Extract - Temporales WMC", - "type": "main", - "index": 0 - } - ] - ] - }, - "Parsear CSV banco GT": { - "main": [ - [ - { - "node": "Merge", - "type": "main", - "index": 0 - } - ] - ] - }, - "Merge": { - "main": [ - [ - { - "node": "Merge - Agregar BambooHR", - "type": "main", - "index": 0 - }, - { - "node": "HTTP - Empleados BambooHR GT", - "type": "main", - "index": 0 - } - ] - ] - }, - "Cruzar Nómina vs Banco": { - "main": [ - [ - { - "node": "Preparar Google Sheet", - "type": "main", - "index": 0 - } - ] - ] - }, - "Extract - Nomina General": { - "main": [ - [ - { - "node": "Merge Hojas 01-02", - "type": "main", - "index": 0 - } - ] - ] - }, - "Extract - Temporales": { - "main": [ - [ - { - "node": "Merge Hojas 01-02", - "type": "main", - "index": 1 - } - ] - ] - }, - "Extract - Auditorias": { - "main": [ - [ - { - "node": "Merge Hojas 03", - "type": "main", - "index": 1 - } - ] - ] - }, - "Extract - Bono Mariana": { - "main": [ - [ - { - "node": "Merge Hojas 04", - "type": "main", - "index": 1 - } - ] - ] - }, - "Extract - Movilidad WP": { - "main": [ - [ - { - "node": "Merge Hojas 05", - "type": "main", - "index": 1 - } - ] - ] - }, - "Extract - Viaticos PMI": { - "main": [ - [ - { - "node": "Merge Hojas 06", - "type": "main", - "index": 1 - } - ] - ] - }, - "Extract - Combustible Purina": { - "main": [ - [ - { - "node": "Merge Hojas 07", - "type": "main", - "index": 1 - } - ] - ] - }, - "Extract - Combustible PG": { - "main": [ - [ - { - "node": "Merge Hojas 08", - "type": "main", - "index": 1 - } - ] - ] - }, - "Extract - Combustibles Liquidables": { - "main": [ - [ - { - "node": "Merge Hojas 09", - "type": "main", - "index": 1 - } - ] - ] - }, - "Normalizar Nómina Completa": { - "main": [ - [ - { - "node": "Merge", - "type": "main", - "index": 1 - } - ] - ] - }, - "Merge Hojas 01-02": { - "main": [ - [ - { - "node": "Merge Hojas 03", - "type": "main", - "index": 0 - } - ] - ] - }, - "Merge Hojas 03": { - "main": [ - [ - { - "node": "Merge Hojas 04", - "type": "main", - "index": 0 - } - ] - ] - }, - "Merge Hojas 04": { - "main": [ - [ - { - "node": "Merge Hojas 05", - "type": "main", - "index": 0 - } - ] - ] - }, - "Merge Hojas 05": { - "main": [ - [ - { - "node": "Merge Hojas 06", - "type": "main", - "index": 0 - } - ] - ] - }, - "Merge Hojas 06": { - "main": [ - [ - { - "node": "Merge Hojas 07", - "type": "main", - "index": 0 - } - ] - ] - }, - "Merge Hojas 07": { - "main": [ - [ - { - "node": "Merge Hojas 08", - "type": "main", - "index": 0 - } - ] - ] - }, - "Merge Hojas 08": { - "main": [ - [ - { - "node": "Merge Hojas 09", - "type": "main", - "index": 0 - } - ] - ] - }, - "Merge Hojas 09": { - "main": [ - [ - { - "node": "Merge Hojas ", - "type": "main", - "index": 0 - } - ] - ] - }, - "Extract - Mot Variable Abril": { - "main": [ - [ - { - "node": "Merge Hojas ", - "type": "main", - "index": 1 - } - ] - ] - }, - "Merge Hojas ": { - "main": [ - [ - { - "node": "Merge Hojas 10", - "type": "main", - "index": 0 - } - ] - ] - }, - "Preparar Google Sheet": { - "main": [ - [ - { - "node": "Crear Google Sheet", - "type": "main", - "index": 0 - } - ] - ] - }, - "Crear Google Sheet": { - "main": [ - [ - { - "node": "Escribir Google Sheet", - "type": "main", - "index": 0 - } - ] - ] - }, - "Escribir Google Sheet": { - "main": [ - [ - { - "node": "Formatear Google Sheet", - "type": "main", - "index": 0 - } - ] - ] - }, - "Formatear Google Sheet": { - "main": [ - [ - { - "node": "Preparar permisos Google Sheet", - "type": "main", - "index": 0 - } - ] - ] - }, - "Preparar respuesta final": { - "main": [ - [ - { - "node": "Respond to Webhook", - "type": "main", - "index": 0 - } - ] - ] - }, - "Preparar permisos Google Sheet": { - "main": [ - [ - { - "node": "Compartir Google Sheet", - "type": "main", - "index": 0 - } - ] - ] - }, - "Compartir Google Sheet": { - "main": [ - [ - { - "node": "Preparar histórico Supabase", - "type": "main", - "index": 0 - } - ] - ] - }, - "Preparar histórico Supabase": { - "main": [ - [ - { - "node": "Insertar histórico Supabase", - "type": "main", - "index": 0 - } - ] - ] - }, - "Insertar histórico Supabase": { - "main": [ - [ - { - "node": "Preparar respuesta final", - "type": "main", - "index": 0 - } - ] - ] - }, - "Extract - Temporales WMC": { - "main": [ - [ - { - "node": "Merge Hojas 10", - "type": "main", - "index": 1 - } - ] - ] - }, - "Merge Hojas 10": { - "main": [ - [ - { - "node": "Normalizar Nómina Completa", - "type": "main", - "index": 0 - } - ] - ] - }, - "HTTP - Empleados BambooHR GT": { - "main": [ - [ - { - "node": "Normalizar BambooHR GT", - "type": "main", - "index": 0 - } - ] - ] - }, - "Normalizar BambooHR GT": { - "main": [ - [ - { - "node": "Merge - Agregar BambooHR", - "type": "main", - "index": 1 - } - ] - ] - }, - "Merge - Agregar BambooHR": { - "main": [ - [ - { - "node": "Cruzar Nómina vs Banco", - "type": "main", - "index": 0 - } - ] - ] - } - }, - "active": true, - "settings": { - "executionOrder": "v1", - "binaryMode": "separate", - "availableInMCP": true, - "timeSavedMode": "fixed", - "errorWorkflow": "puF4LUczoSz3hcek", - "timezone": "America/Santo_Domingo", - "callerPolicy": "workflowsFromSameOwner" - }, - "versionId": "afef0d83-31fc-44b2-87dd-05536d3520f2", - "meta": { - "instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70" - }, - "id": "d3vXYr7ucbaiU5ct", - "tags": [] -} \ No newline at end of file diff --git a/Flujo de n8n: Portal de Verificación de Nómina - TT.json b/Flujo de n8n: Portal de Verificación de Nómina - TT.json deleted file mode 100644 index d2d8e86..0000000 --- a/Flujo de n8n: Portal de Verificación de Nómina - TT.json +++ /dev/null @@ -1,1066 +0,0 @@ -{ - "name": "Portal de Verificación de Nómina - TT", - "nodes": [ - { - "parameters": { - "httpMethod": "POST", - "path": "nominatt-bamboo-test", - "responseMode": "responseNode", - "options": {} - }, - "type": "n8n-nodes-base.webhook", - "typeVersion": 2.1, - "position": [ - 2848, - 7344 - ], - "id": "0a3764cc-5cc2-4c62-93f8-1d201ec45e9d", - "name": "Webhook", - "webhookId": "9c730860-7790-43a5-a3c0-bf5984ced244" - }, - { - "parameters": { - "jsCode": "const item = $input.first();\n\nconst body = item.json.body || {};\nconst binary = item.binary || {};\n\nlet metadata = {};\n\ntry {\n metadata = typeof body.metadata === 'string'\n ? JSON.parse(body.metadata)\n : body.metadata || {};\n} catch (error) {\n metadata = {};\n}\n\nconst binaryKeys = Object.keys(binary);\n\nconst payrollKey = binaryKeys.find(\n (key) => key === 'payroll_file'\n);\n\nconst bankKeys = binaryKeys\n .filter((key) => key.startsWith('bank_files'))\n .sort();\n\nconst payrollFile = payrollKey\n ? {\n binary_key: payrollKey,\n file_name: binary[payrollKey].fileName,\n file_extension: binary[payrollKey].fileExtension,\n mime_type: binary[payrollKey].mimeType,\n file_size: binary[payrollKey].fileSize,\n }\n : null;\n\nconst bankFiles = bankKeys.map((key) => ({\n binary_key: key,\n file_name: binary[key].fileName,\n file_extension: binary[key].fileExtension,\n mime_type: binary[key].mimeType,\n file_size: binary[key].fileSize,\n}));\n\nconst receivedCountry = String(\n metadata.country || ''\n).trim().toUpperCase();\n\nconst errors = [];\n\nif (!['TT', 'TTO'].includes(receivedCountry)) {\n errors.push(\n 'El país recibido no es Trinidad y Tobago.'\n );\n}\n\nif (!metadata.year) {\n errors.push('No se recibió el año del cruce.');\n}\n\nif (!metadata.month) {\n errors.push('No se recibió el mes del cruce.');\n}\n\nif (!metadata.period_type) {\n errors.push('No se recibió el tipo de quincena.');\n}\n\nif (!metadata.period_start || !metadata.period_end) {\n errors.push('No se recibió el período calculado.');\n}\n\nif (!payrollFile) {\n errors.push('No se recibió el archivo de nómina.');\n}\n\nif (bankFiles.length === 0) {\n errors.push(\n 'No se recibió ningún archivo CSV del banco.'\n );\n}\n\nconst normalizedMetadata = {\n ...metadata,\n country: 'TT',\n country_name: 'Trinidad y Tobago',\n source_app:\n metadata.source_app ||\n 'cruce-cuentas-glm-trinidad-tobago',\n payroll_file_name:\n metadata.payroll_file_name ||\n payrollFile?.file_name ||\n '',\n bank_file_names:\n metadata.bank_file_names ||\n bankFiles.map((file) => file.file_name),\n};\n\nreturn [\n {\n json: {\n ok: errors.length === 0,\n stage: 'entrada_tt_recibida',\n errors,\n metadata: normalizedMetadata,\n payroll_file: payrollFile,\n bank_files: bankFiles,\n summary: {\n payroll_files_count:\n payrollFile ? 1 : 0,\n bank_files_count: bankFiles.length,\n },\n },\n binary,\n },\n];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 3088, - 7344 - ], - "id": "bdfa28aa-5a9e-4c08-9869-0bafa2a8cb52", - "name": "Preparar entrada app" - }, - { - "parameters": { - "jsCode": "const input = $input.first();\nconst json = input.json || {};\nconst binary = input.binary || {};\n\nfunction parseCsvLine(line) {\n const result = [];\n let current = '';\n let insideQuotes = false;\n\n for (let index = 0; index < line.length; index++) {\n const character = line[index];\n const nextCharacter = line[index + 1];\n\n if (\n character === '\"' &&\n insideQuotes &&\n nextCharacter === '\"'\n ) {\n current += '\"';\n index += 1;\n continue;\n }\n\n if (character === '\"') {\n insideQuotes = !insideQuotes;\n continue;\n }\n\n if (character === ',' && !insideQuotes) {\n result.push(current.trim());\n current = '';\n continue;\n }\n\n current += character;\n }\n\n result.push(current.trim());\n return result;\n}\n\nfunction normalizeText(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\u00A0/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeForCompare(value) {\n return normalizeText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/['’`-]/g, '')\n .replace(/[^a-z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeAccount(value) {\n return String(value ?? '')\n .replace(/\\u00A0/g, '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim();\n}\n\nfunction isValidAccount(value) {\n const account = normalizeAccount(value);\n return (\n account.length >= 6 &&\n !/^0+$/.test(account)\n );\n}\n\nfunction parseMoney(value) {\n const cleaned = String(value ?? '')\n .replace(/TTD/gi, '')\n .replace(/TT\\$/gi, '')\n .replace(/\\$/g, '')\n .replace(/,/g, '')\n .replace(/\\s+/g, '')\n .trim();\n\n const parsed = Number.parseFloat(cleaned);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction roundMoney(value) {\n return Math.round(\n (Number(value) || 0) * 100\n ) / 100;\n}\n\nfunction getColumnIndex(headers, names) {\n const normalizedHeaders =\n headers.map(normalizeForCompare);\n\n for (const name of names) {\n const expected = normalizeForCompare(name);\n const index = normalizedHeaders.findIndex(\n (header) => header === expected\n );\n\n if (index >= 0) return index;\n }\n\n return -1;\n}\n\nconst bankKeys = Object.keys(binary)\n .filter((key) => key.startsWith('bank_files'))\n .sort();\n\nconst allBankRows = [];\nconst fileSummaries = [];\n\nfor (const key of bankKeys) {\n const file = binary[key];\n const buffer =\n await this.helpers.getBinaryDataBuffer(0, key);\n\n let text = buffer.toString('utf8');\n\n if (text.includes('\\uFFFD')) {\n text = buffer.toString('latin1');\n }\n\n const lines = text\n .split(/\\r?\\n/)\n .map((line) => line.trim())\n .filter(Boolean);\n\n const headerIndex = lines.findIndex((line) => {\n const normalized = normalizeForCompare(line);\n\n return (\n normalized.includes('identifier') &&\n normalized.includes('account number') &&\n normalized.includes('amount') &&\n normalized.includes('participant name')\n );\n });\n\n if (headerIndex < 0) {\n fileSummaries.push({\n file_name: file.fileName,\n ok: false,\n rows_count: 0,\n total_amount: 0,\n error:\n 'No se encontró el encabezado esperado del archivo bancario de Trinidad y Tobago.',\n });\n continue;\n }\n\n const headers = parseCsvLine(\n lines[headerIndex]\n ).map(normalizeText);\n\n const indexIdentifier = getColumnIndex(\n headers,\n ['Identifier']\n );\n const indexAccount = getColumnIndex(\n headers,\n ['Account Number']\n );\n const indexAccountType = getColumnIndex(\n headers,\n ['Account type']\n );\n const indexAmount = getColumnIndex(\n headers,\n ['Amount']\n );\n const indexInstitution = getColumnIndex(\n headers,\n ['Financial Institution ID']\n );\n const indexParticipantId = getColumnIndex(\n headers,\n ['Participant ID']\n );\n const indexParticipantName = getColumnIndex(\n headers,\n ['Participant Name']\n );\n const indexTransactionType = getColumnIndex(\n headers,\n ['TR Type']\n );\n const indexAddenda = getColumnIndex(\n headers,\n ['Addenda']\n );\n\n const rowsFromFile = [];\n\n for (\n let lineIndex = headerIndex + 1;\n lineIndex < lines.length;\n lineIndex++\n ) {\n const values = parseCsvLine(lines[lineIndex]);\n\n const identifier = normalizeText(\n indexIdentifier >= 0\n ? values[indexIdentifier]\n : ''\n ).toUpperCase();\n\n // T = transacción. C = fila de control/totales.\n if (identifier !== 'T') continue;\n\n const account = normalizeAccount(\n indexAccount >= 0\n ? values[indexAccount]\n : ''\n );\n\n const amount = roundMoney(\n parseMoney(\n indexAmount >= 0\n ? values[indexAmount]\n : ''\n )\n );\n\n const participantName = normalizeText(\n indexParticipantName >= 0\n ? values[indexParticipantName]\n : ''\n );\n\n if (amount <= 0 || !participantName) {\n continue;\n }\n\n const accountIsValid =\n isValidAccount(account);\n\n const groupKey = accountIsValid\n ? `ACCOUNT:${account}:TTD`\n : `ROW:${file.fileName}:${lineIndex + 1}:TTD`;\n\n const row = {\n source_file: file.fileName,\n row_number: lineIndex + 1,\n group_key: groupKey,\n account,\n raw_account: account,\n account_is_valid: accountIsValid,\n bank_name_file: participantName,\n bank_account_holder: '',\n participant_name: participantName,\n participant_id: normalizeText(\n indexParticipantId >= 0\n ? values[indexParticipantId]\n : ''\n ),\n financial_institution_id:\n normalizeText(\n indexInstitution >= 0\n ? values[indexInstitution]\n : ''\n ),\n account_type: normalizeText(\n indexAccountType >= 0\n ? values[indexAccountType]\n : ''\n ),\n transaction_type: normalizeText(\n indexTransactionType >= 0\n ? values[indexTransactionType]\n : ''\n ),\n reference: normalizeText(\n indexAddenda >= 0\n ? values[indexAddenda]\n : ''\n ),\n addenda: normalizeText(\n indexAddenda >= 0\n ? values[indexAddenda]\n : ''\n ),\n shipment_number: '',\n plan_number: '',\n amount,\n currency: 'TTD',\n status: 'Procesado',\n };\n\n rowsFromFile.push(row);\n allBankRows.push(row);\n }\n\n fileSummaries.push({\n file_name: file.fileName,\n ok: true,\n rows_count: rowsFromFile.length,\n total_amount: roundMoney(\n rowsFromFile.reduce(\n (sum, row) => sum + row.amount,\n 0\n )\n ),\n error: null,\n });\n}\n\nconst groupedMap = new Map();\n\nfor (const row of allBankRows) {\n const current =\n groupedMap.get(row.group_key) || {\n group_key: row.group_key,\n account: row.account,\n raw_account: row.raw_account,\n account_is_valid: row.account_is_valid,\n amount: 0,\n currency: 'TTD',\n transactions_count: 0,\n bank_name_files: new Set(),\n bank_account_holders: new Set(),\n source_files: new Set(),\n institution_ids: new Set(),\n source_rows: [],\n };\n\n current.amount = roundMoney(\n current.amount + row.amount\n );\n current.transactions_count += 1;\n\n if (row.bank_name_file) {\n current.bank_name_files.add(\n row.bank_name_file\n );\n }\n\n if (row.source_file) {\n current.source_files.add(row.source_file);\n }\n\n if (row.financial_institution_id) {\n current.institution_ids.add(\n row.financial_institution_id\n );\n }\n\n current.source_rows.push(row);\n groupedMap.set(row.group_key, current);\n}\n\nconst groupedByAccount = Array.from(\n groupedMap.values()\n).map((row) => {\n const names = Array.from(\n row.bank_name_files\n );\n\n return {\n ...row,\n bank_name_file: names[0] || '',\n bank_account_holder: '',\n bank_name_files: names,\n bank_account_holders: [],\n source_files: Array.from(\n row.source_files\n ),\n institution_ids: Array.from(\n row.institution_ids\n ),\n };\n});\n\nconst totalAmount = roundMoney(\n allBankRows.reduce(\n (sum, row) => sum + row.amount,\n 0\n )\n);\n\nreturn [\n {\n json: {\n ...json,\n stage: 'banco_tt_parseado',\n bank: {\n source:\n 'csv_ach_trinidad_tobago',\n files_count: bankKeys.length,\n valid_files_count:\n fileSummaries.filter(\n (file) => file.ok\n ).length,\n rows_count: allBankRows.length,\n grouped_accounts_count:\n groupedByAccount.length,\n total_amount: totalAmount,\n totals_by_currency: {\n TTD: totalAmount,\n },\n name_differences_count: 0,\n name_differences: [],\n file_summaries: fileSummaries,\n rows: allBankRows,\n grouped_by_account:\n groupedByAccount,\n },\n },\n binary,\n },\n];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 3424, - 7040 - ], - "id": "46e46f3c-85f9-40ef-a3cc-ee20acc46d73", - "name": "Parsear CSV banco TT" - }, - { - "parameters": { - "operation": "xlsx", - "binaryPropertyName": "payroll_file", - "options": { - "headerRow": true, - "sheetName": "BICE" - } - }, - "type": "n8n-nodes-base.extractFromFile", - "typeVersion": 1.1, - "position": [ - 3424, - 7440 - ], - "id": "444192a4-bd82-4086-a87f-ab116517f723", - "name": "Extract - BICE", - "retryOnFail": false - }, - { - "parameters": { - "operation": "xlsx", - "binaryPropertyName": "payroll_file", - "options": { - "headerRow": true, - "sheetName": "Goldey Samuel" - } - }, - "type": "n8n-nodes-base.extractFromFile", - "typeVersion": 1.1, - "position": [ - 3424, - 7616 - ], - "id": "15b0af6e-5e38-4c0c-9d30-496ad9df9413", - "name": "Extract - Goldey Samuel", - "retryOnFail": false - }, - { - "parameters": { - "operation": "xlsx", - "binaryPropertyName": "payroll_file", - "options": { - "headerRow": true, - "sheetName": "P&G" - } - }, - "type": "n8n-nodes-base.extractFromFile", - "typeVersion": 1.1, - "position": [ - 3424, - 7776 - ], - "id": "22845609-e9b5-486b-88ce-bc5d73b96a2e", - "name": "Extract - P&G", - "retryOnFail": false - }, - { - "parameters": { - "operation": "xlsx", - "binaryPropertyName": "payroll_file", - "options": { - "headerRow": true, - "sheetName": "Whirlpool" - } - }, - "type": "n8n-nodes-base.extractFromFile", - "typeVersion": 1.1, - "position": [ - 3424, - 7952 - ], - "id": "9a39edbc-1e2e-4d75-83ed-9ce48c808abf", - "name": "Extract - Whirlpool", - "retryOnFail": false - }, - { - "parameters": { - "operation": "xlsx", - "binaryPropertyName": "payroll_file", - "options": { - "headerRow": true, - "sheetName": "KAD" - } - }, - "type": "n8n-nodes-base.extractFromFile", - "typeVersion": 1.1, - "position": [ - 3424, - 8128 - ], - "id": "1710469a-3b4a-4a3b-9c61-77d7d3c4fffb", - "name": "Extract - KAD", - "retryOnFail": false - }, - { - "parameters": { - "operation": "xlsx", - "binaryPropertyName": "payroll_file", - "options": { - "headerRow": true, - "sheetName": "GLM People" - } - }, - "type": "n8n-nodes-base.extractFromFile", - "typeVersion": 1.1, - "position": [ - 3424, - 8288 - ], - "id": "72c876ce-f1e4-4f82-ac7d-7d409eb18e64", - "name": "Extract - GLM People", - "retryOnFail": false - }, - { - "parameters": { - "operation": "xlsx", - "binaryPropertyName": "payroll_file", - "options": { - "headerRow": true, - "sheetName": "GLM" - } - }, - "type": "n8n-nodes-base.extractFromFile", - "typeVersion": 1.1, - "position": [ - 3424, - 8464 - ], - "id": "26d185f0-3f60-44cd-b20e-1fbfbae48fc8", - "name": "Extract - GLM", - "retryOnFail": false - }, - { - "parameters": {}, - "type": "n8n-nodes-base.merge", - "typeVersion": 3.2, - "position": [ - 5088, - 7536 - ], - "id": "b98e1002-cf47-4cde-94d7-08777b928d36", - "name": "Merge Hojas TT 01-02" - }, - { - "parameters": {}, - "type": "n8n-nodes-base.merge", - "typeVersion": 3.2, - "position": [ - 5088, - 7696 - ], - "id": "c7c11485-0d25-4959-ba19-cf631591472a", - "name": "Merge Hojas TT 03" - }, - { - "parameters": {}, - "type": "n8n-nodes-base.merge", - "typeVersion": 3.2, - "position": [ - 5088, - 7872 - ], - "id": "706b5c48-c1f0-4a12-be90-51abd68f32f3", - "name": "Merge Hojas TT 04" - }, - { - "parameters": {}, - "type": "n8n-nodes-base.merge", - "typeVersion": 3.2, - "position": [ - 5088, - 8032 - ], - "id": "a6cff463-cf7d-4e70-b13d-213dbcefa388", - "name": "Merge Hojas TT 05" - }, - { - "parameters": {}, - "type": "n8n-nodes-base.merge", - "typeVersion": 3.2, - "position": [ - 5088, - 8208 - ], - "id": "25f84f87-c966-4b81-ac10-9021b7eeadc3", - "name": "Merge Hojas TT 06" - }, - { - "parameters": {}, - "type": "n8n-nodes-base.merge", - "typeVersion": 3.2, - "position": [ - 5088, - 8384 - ], - "id": "75156223-c7d3-4b98-bdc5-aa62a9185db1", - "name": "Merge Hojas TT 07" - }, - { - "parameters": { - "jsCode": "function normalizeText(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\u00A0/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeForCompare(value) {\n return normalizeText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/['’`-]/g, '')\n .replace(/[^a-z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeAccount(value) {\n if (\n value === null ||\n value === undefined ||\n value === ''\n ) {\n return '';\n }\n\n if (typeof value === 'number') {\n return String(Math.trunc(value));\n }\n\n return String(value)\n .replace(/\\u00A0/g, '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim();\n}\n\nfunction parseMoney(value) {\n if (typeof value === 'number') {\n return Number.isFinite(value)\n ? value\n : 0;\n }\n\n const cleaned = String(value ?? '')\n .replace(/TTD/gi, '')\n .replace(/TT\\$/gi, '')\n .replace(/\\$/g, '')\n .replace(/,/g, '')\n .replace(/\\s+/g, '')\n .trim();\n\n const parsed = Number.parseFloat(cleaned);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction roundMoney(value) {\n return Math.round(\n (Number(value) || 0) * 100\n ) / 100;\n}\n\nfunction getValue(row, possibleKeys) {\n for (const key of possibleKeys) {\n const value = row[key];\n\n if (\n value !== undefined &&\n value !== null &&\n value !== ''\n ) {\n return value;\n }\n }\n\n const rowKeys = Object.keys(row || {});\n\n for (const expected of possibleKeys) {\n const normalizedExpected =\n normalizeForCompare(expected);\n\n const matchingKey = rowKeys.find(\n (key) =>\n normalizeForCompare(key) ===\n normalizedExpected\n );\n\n if (!matchingKey) continue;\n\n const value = row[matchingKey];\n\n if (\n value !== undefined &&\n value !== null &&\n value !== ''\n ) {\n return value;\n }\n }\n\n return '';\n}\n\nfunction getNodeRows(nodeName) {\n try {\n return $items(nodeName)\n .map((item) => item.json || {})\n .filter((row) => {\n if (row.error) return false;\n\n const text = JSON.stringify(\n row || {}\n ).toLowerCase();\n\n return !(\n text.includes(\n 'spreadsheet does not contain sheet'\n ) ||\n text.includes('no sheet')\n );\n });\n } catch (error) {\n return [];\n }\n}\n\nfunction validEmployeeName(value) {\n const name = normalizeText(value);\n const normalized = normalizeForCompare(name);\n\n if (!name) return false;\n if (/^[\\d.,\\s]+$/.test(name)) return false;\n\n const invalid = [\n 'total',\n 'subtotal',\n 'gran total',\n 'total general',\n 'variable',\n 'empleado',\n 'first name',\n 'nombre',\n 'diferencia',\n 'total dias',\n ];\n\n return !invalid.some(\n (token) =>\n normalized === token ||\n normalized.startsWith(`${token} `)\n );\n}\n\nfunction validAccount(value) {\n const account = normalizeAccount(value);\n\n return (\n account.length >= 6 &&\n !/^0+$/.test(account)\n );\n}\n\nconst sheetConfigs = [\n {\n node: 'Extract - BICE',\n sheet: 'BICE',\n },\n {\n node: 'Extract - Goldey Samuel',\n sheet: 'Goldey Samuel',\n },\n {\n node: 'Extract - P&G',\n sheet: 'P&G',\n },\n {\n node: 'Extract - Whirlpool',\n sheet: 'Whirlpool',\n },\n {\n node: 'Extract - KAD',\n sheet: 'KAD',\n },\n {\n node: 'Extract - GLM People',\n sheet: 'GLM People',\n },\n {\n node: 'Extract - GLM',\n sheet: 'GLM',\n },\n];\n\nconst payrollRows = [];\nconst noAccountRows = [];\nconst ignoredRows = [];\nconst sheetSummaries = [];\n\nfor (const config of sheetConfigs) {\n const sourceRows = getNodeRows(\n config.node\n );\n\n let validRows = 0;\n let noAccountCount = 0;\n let ignoredCount = 0;\n let sheetTotal = 0;\n\n sourceRows.forEach((sourceRow, index) => {\n const period = normalizeText(\n getValue(sourceRow, ['Periodo'])\n );\n\n const employeeName = normalizeText(\n getValue(sourceRow, [\n 'First Name',\n 'Nombre completo',\n 'Empleado',\n 'Name',\n ])\n );\n\n const account = normalizeAccount(\n getValue(sourceRow, [\n 'Account #',\n 'Account Number',\n 'Cuenta bancaria',\n 'Cuenta Bancaria',\n ])\n );\n\n const email = normalizeText(\n getValue(sourceRow, [\n 'EMAIL',\n 'Email',\n 'Correo',\n ])\n ).toLowerCase();\n\n const amount = roundMoney(\n parseMoney(\n getValue(sourceRow, [\n 'NETO A PAGAR',\n 'Neto a Pagar',\n 'Net Pay',\n ])\n )\n );\n\n const client = normalizeText(\n getValue(sourceRow, ['Cuenta'])\n );\n\n const rowNumber = index + 2;\n\n const normalized = {\n source_sheet: config.sheet,\n row_number: rowNumber,\n period,\n employee_name: employeeName,\n employee_number: null,\n account,\n email,\n client,\n payroll_amount: amount,\n currency: 'TTD',\n };\n\n if (\n !period ||\n !validEmployeeName(employeeName) ||\n amount <= 0 ||\n amount > 500000\n ) {\n ignoredRows.push({\n ...normalized,\n reason:\n !period\n ? 'period_empty'\n : !validEmployeeName(employeeName)\n ? 'invalid_employee_name'\n : amount <= 0\n ? 'amount_zero_or_invalid'\n : 'suspicious_large_amount',\n });\n\n ignoredCount += 1;\n return;\n }\n\n sheetTotal = roundMoney(\n sheetTotal + amount\n );\n\n if (!validAccount(account)) {\n noAccountRows.push({\n ...normalized,\n account: '',\n });\n\n noAccountCount += 1;\n return;\n }\n\n payrollRows.push(normalized);\n validRows += 1;\n });\n\n sheetSummaries.push({\n sheet: config.sheet,\n node: config.node,\n raw_rows_count: sourceRows.length,\n valid_rows_count: validRows,\n no_account_rows_count:\n noAccountCount,\n ignored_rows_count: ignoredCount,\n total_amount: sheetTotal,\n });\n}\n\nconst groupedMap = new Map();\n\nfor (const row of payrollRows) {\n const groupKey =\n `${row.account}:${row.currency}`;\n\n const current =\n groupedMap.get(groupKey) || {\n group_key: groupKey,\n account: row.account,\n employee_name: row.employee_name,\n employee_number: null,\n email: row.email,\n currency: 'TTD',\n payroll_amount: 0,\n rows_count: 0,\n source_sheets: new Set(),\n source_rows: [],\n };\n\n current.payroll_amount = roundMoney(\n current.payroll_amount +\n row.payroll_amount\n );\n\n current.rows_count += 1;\n\n if (!current.email && row.email) {\n current.email = row.email;\n }\n\n current.source_sheets.add(\n row.source_sheet\n );\n\n current.source_rows.push({\n source_sheet: row.source_sheet,\n row_number: row.row_number,\n account: row.account,\n amount: row.payroll_amount,\n employee_name: row.employee_name,\n });\n\n groupedMap.set(groupKey, current);\n}\n\nconst groupedByAccount = Array.from(\n groupedMap.values()\n).map((row) => ({\n ...row,\n source_sheets: Array.from(\n row.source_sheets\n ),\n}));\n\nconst totalAmount = roundMoney(\n payrollRows.reduce(\n (sum, row) => sum + row.payroll_amount,\n 0\n ) +\n noAccountRows.reduce(\n (sum, row) => sum + row.payroll_amount,\n 0\n )\n);\n\nreturn [\n {\n json: {\n payroll: {\n source:\n 'template_trinidad_tobago',\n sheets_count:\n sheetConfigs.length,\n sheet_summaries:\n sheetSummaries,\n raw_rows_count:\n sheetSummaries.reduce(\n (sum, sheet) =>\n sum + sheet.raw_rows_count,\n 0\n ),\n valid_rows_count:\n payrollRows.length,\n no_account_rows_count:\n noAccountRows.length,\n ignored_rows_count:\n ignoredRows.length,\n grouped_accounts_count:\n groupedByAccount.length,\n attached_supplements_count: 0,\n potential_supplements_count: 0,\n potential_supplements: [],\n unattached_supplements_count: 0,\n total_amount: totalAmount,\n totals_by_currency: {\n TTD: totalAmount,\n },\n rows: payrollRows,\n no_account_rows:\n noAccountRows,\n grouped_by_account:\n groupedByAccount,\n },\n debug_payroll: {\n attached_supplements: [],\n potential_supplements: [],\n unattached_supplements: [],\n ignored_rows_preview:\n ignoredRows.slice(0, 100),\n no_account_rows_preview:\n noAccountRows.slice(0, 50),\n },\n },\n },\n];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 5456, - 7952 - ], - "id": "d036e80f-bf9c-4024-9bdb-f9222d8ee057", - "name": "Normalizar Nómina TT" - }, - { - "parameters": { - "mode": "combine", - "combineBy": "combineByPosition", - "options": {} - }, - "type": "n8n-nodes-base.merge", - "typeVersion": 3.2, - "position": [ - 5712, - 7296 - ], - "id": "8d2116b6-d720-47a0-b532-aec6f16966c1", - "name": "Merge Banco + Nómina TT" - }, - { - "parameters": { - "method": "POST", - "url": "https://glm.bamboohr.com/api/v1/reports/custom?format=JSON&onlyCurrent=false", - "authentication": "genericCredentialType", - "genericAuthType": "httpBasicAuth", - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "Accept", - "value": "application/json" - } - ] - }, - "sendBody": true, - "specifyBody": "json", - "jsonBody": { - "title": "Información de BambooHR - Cruce de Cuentas TT", - "fields": [ - "firstName", - "middleName", - "lastName", - "displayName", - "department", - "division", - "location", - "customPosicion-Cliente", - "hireDate", - "originalHireDate", - "status", - "employeeNumber" - ] - }, - "options": { - "response": { - "response": { - "responseFormat": "json" - } - }, - "timeout": 300000 - } - }, - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.4, - "position": [ - 3424, - 6816 - ], - "id": "84b74d83-a969-4f62-a11b-ace145d64e8c", - "name": "HTTP - Empleados BambooHR TT", - "retryOnFail": true, - "maxTries": 3, - "waitBetweenTries": 3000, - "credentials": { - "httpBasicAuth": { - "id": "7VrpNZ2jBLmiJ35q", - "name": "BambooHR GLM Full Access" - } - } - }, - { - "parameters": { - "jsCode": "const inputItems = $input.all();\nconst base = $('Preparar entrada app').first().json || {};\nconst reconciliationData = $('Merge Banco + Nómina TT').first().json || {};\nconst metadata = base.metadata || {};\n\nfunction clean(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\u00A0/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalize(value) {\n return clean(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/['’`-]/g, ' ')\n .replace(/[^a-z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction unique(values) {\n return Array.from(\n new Set(\n values\n .map(clean)\n .filter(Boolean)\n )\n );\n}\n\nfunction nameTokens(value) {\n const ignored = new Set([\n 'de', 'del', 'la', 'las', 'los',\n 'y', 'e', 'el', 'da', 'do',\n 'dos', 'das', 'van', 'von',\n ]);\n\n return normalize(value)\n .split(' ')\n .filter(\n (token) =>\n token.length > 1 &&\n !ignored.has(token)\n );\n}\n\nfunction parseDate(value) {\n const raw = clean(value);\n if (!raw || raw === '0000-00-00') return null;\n\n const direct = raw.match(/^(\\d{4})-(\\d{2})-(\\d{2})/);\n if (direct) {\n return `${direct[1]}-${direct[2]}-${direct[3]}`;\n }\n\n const date = new Date(raw);\n if (Number.isNaN(date.getTime())) return null;\n\n return date.toISOString().slice(0, 10);\n}\n\nfunction parseBoolean(value) {\n if (typeof value === 'boolean') return value;\n\n return [\n 'true', 'yes', 'si', 'sí', '1', 'y',\n ].includes(normalize(value));\n}\n\nfunction isTargetCountry(employee) {\n const country = normalize(employee.country);\n const location = normalize(\n employee.location ||\n employee.jobInformationLocation ||\n employee.jobLocation\n );\n\n return (\n country === 'tt' ||\n country === 'tto' ||\n country.includes('trinidad') ||\n country.includes('tobago') ||\n location === 'tt' ||\n location === 'tto' ||\n location.includes('trinidad') ||\n location.includes('tobago')\n );\n}\n\nfunction overlapsPeriod(\n hireDate,\n terminationDate,\n periodStart,\n periodEnd\n) {\n if (!periodStart || !periodEnd) return false;\n\n const hiredBeforeEnd =\n !hireDate || hireDate <= periodEnd;\n\n const notTerminatedBeforeStart =\n !terminationDate ||\n terminationDate >= periodStart;\n\n return hiredBeforeEnd && notTerminatedBeforeStart;\n}\n\nfunction collectPageObjects(value, pages) {\n if (!value) return;\n\n if (Array.isArray(value)) {\n for (const entry of value) {\n collectPageObjects(entry, pages);\n }\n return;\n }\n\n if (typeof value !== 'object') return;\n\n if (value.body && typeof value.body === 'object') {\n collectPageObjects(value.body, pages);\n return;\n }\n\n if (\n Array.isArray(value.data) ||\n Array.isArray(value.employees)\n ) {\n pages.push(value);\n return;\n }\n\n if (value.json && typeof value.json === 'object') {\n collectPageObjects(value.json, pages);\n }\n}\n\nfunction strictInformativeContainment(\n left,\n right\n) {\n const leftTokens =\n Array.from(new Set(nameTokens(left)));\n const rightTokens =\n Array.from(new Set(nameTokens(right)));\n\n if (\n leftTokens.length < 3 ||\n rightTokens.length < 3\n ) {\n return false;\n }\n\n const leftSet = new Set(leftTokens);\n const rightSet = new Set(rightTokens);\n\n const leftInsideRight =\n leftTokens.every((token) =>\n rightSet.has(token)\n );\n\n const rightInsideLeft =\n rightTokens.every((token) =>\n leftSet.has(token)\n );\n\n return leftInsideRight || rightInsideLeft;\n}\n\nconst pageObjects = [];\n\nfor (const item of inputItems) {\n collectPageObjects(item.json, pageObjects);\n}\n\nconst employeeMap = new Map();\nlet expectedTotal = 0;\nlet restrictedFields = 0;\n\nfor (const page of pageObjects) {\n const pageEmployees =\n Array.isArray(page.data)\n ? page.data\n : Array.isArray(page.employees)\n ? page.employees\n : [];\n\n const pageTotal = Number(\n page.meta?.total ||\n page.total ||\n 0\n );\n\n if (Number.isFinite(pageTotal)) {\n expectedTotal = Math.max(\n expectedTotal,\n pageTotal\n );\n }\n\n for (const employee of pageEmployees) {\n const key =\n clean(employee.employeeId || employee.id) ||\n clean(employee.employeeNumber) ||\n clean(employee.bestEmail).toLowerCase() ||\n [\n clean(employee.firstName),\n clean(employee.middleName),\n clean(employee.lastName),\n ].filter(Boolean).join('|').toLowerCase();\n\n if (!key) continue;\n\n employeeMap.set(key, employee);\n\n restrictedFields += Array.isArray(\n employee._restrictedFields\n )\n ? employee._restrictedFields.length\n : 0;\n }\n}\n\nconst rawEmployees = Array.from(\n employeeMap.values()\n);\n\nconst periodStart = clean(metadata.period_start);\nconst periodEnd = clean(metadata.period_end);\n\nconst allNormalized = rawEmployees.map((employee) => {\n const firstName = clean(employee.firstName);\n const middleName = clean(employee.middleName);\n const lastName = clean(employee.lastName);\n const preferredName = clean(\n employee.preferredName\n );\n\n const constructedFullName = [\n firstName,\n middleName,\n lastName,\n ].filter(Boolean).join(' ');\n\n const aliases = unique([\n employee.displayName,\n employee.fullName1,\n employee.fullName2,\n employee.fullName3,\n employee.fullName4,\n employee.fullName5,\n constructedFullName,\n [preferredName, lastName]\n .filter(Boolean)\n .join(' '),\n [firstName, lastName]\n .filter(Boolean)\n .join(' '),\n ]);\n\n const hireDate = parseDate(\n employee.hireDate ||\n employee.originalHireDate\n );\n\n const terminationDate = parseDate(\n employee.terminationDate\n );\n\n const status = clean(\n employee.status ||\n employee.employmentStatus ||\n employee.employmentHistoryStatus\n );\n\n const employeeNumber = clean(\n employee.employeeNumber ||\n employee.employee_number\n );\n\n return {\n bamboo_id: clean(\n employee.employeeId ||\n employee.id\n ),\n employee_number: employeeNumber,\n first_name: firstName,\n middle_name: middleName,\n last_name: lastName,\n preferred_name: preferredName,\n full_name:\n clean(employee.displayName) ||\n clean(employee.fullName1) ||\n constructedFullName,\n aliases,\n normalized_aliases:\n aliases.map(normalize).filter(Boolean),\n status,\n hire_date: hireDate,\n termination_date: terminationDate,\n location: clean(\n employee.location ||\n employee.jobInformationLocation ||\n employee.jobLocation\n ),\n country: clean(employee.country),\n include_in_payroll:\n parseBoolean(employee.includeInPayroll),\n work_email:\n clean(employee.workEmail).toLowerCase(),\n home_email:\n clean(employee.homeEmail).toLowerCase(),\n best_email: clean(\n employee.bestEmail ||\n employee.workEmail ||\n employee.homeEmail\n ).toLowerCase(),\n exists_in_bamboo: true,\n overlaps_period: overlapsPeriod(\n hireDate,\n terminationDate,\n periodStart,\n periodEnd\n ),\n };\n});\n\nconst relevantNameMap = new Map();\n\nfunction addRelevantName(value) {\n const cleaned = clean(value);\n const normalized = normalize(cleaned);\n\n if (!normalized) return;\n\n const current =\n relevantNameMap.get(normalized);\n\n if (\n !current ||\n nameTokens(cleaned).length >\n nameTokens(current).length\n ) {\n relevantNameMap.set(\n normalized,\n cleaned\n );\n }\n}\n\nfor (const row of reconciliationData.bank?.rows || []) {\n addRelevantName(row.bank_name_file);\n addRelevantName(row.bank_account_holder);\n addRelevantName(row.participant_name);\n}\n\nfor (\n const row of\n reconciliationData.bank?.grouped_by_account || []\n) {\n addRelevantName(row.bank_name_file);\n addRelevantName(row.bank_account_holder);\n\n for (const name of row.bank_name_files || []) {\n addRelevantName(name);\n }\n\n for (\n const name of\n row.bank_account_holders || []\n ) {\n addRelevantName(name);\n }\n}\n\nfor (const row of [\n ...(reconciliationData.payroll?.rows || []),\n ...(reconciliationData.payroll?.grouped_by_account || []),\n ...(reconciliationData.payroll?.no_account_rows || []),\n]) {\n addRelevantName(\n row.employee_name ||\n row.employee ||\n ''\n );\n}\n\nconst targetEmployees =\n allNormalized\n .filter(isTargetCountry)\n .map((employee) => ({\n ...employee,\n validation_eligible: true,\n validation_scope:\n 'trinidad_tobago_country_or_location',\n }));\n\nconst outsideEmployees =\n allNormalized.filter(\n (employee) =>\n !isTargetCountry(employee)\n );\n\nconst contextualOutsideMap = new Map();\n\nfor (\n const relevantName of\n relevantNameMap.values()\n) {\n if (\n nameTokens(relevantName).length < 3\n ) {\n continue;\n }\n\n const matches = outsideEmployees\n .filter((employee) =>\n (employee.aliases || []).some(\n (alias) =>\n strictInformativeContainment(\n relevantName,\n alias\n )\n )\n );\n\n const uniqueMatches = new Map();\n\n for (const employee of matches) {\n const key =\n employee.bamboo_id ||\n employee.employee_number ||\n normalize(employee.full_name);\n\n if (key) {\n uniqueMatches.set(key, employee);\n }\n }\n\n // Solo se rescata un perfil fuera del país cuando un nombre\n // informativo identifica exactamente a una única persona.\n if (uniqueMatches.size !== 1) {\n continue;\n }\n\n const employee =\n uniqueMatches.values().next().value;\n\n const key =\n employee.bamboo_id ||\n employee.employee_number ||\n normalize(employee.full_name);\n\n contextualOutsideMap.set(key, {\n ...employee,\n validation_eligible: true,\n validation_scope:\n 'outside_country_unique_informative_name',\n });\n}\n\nconst validationEmployeeMap = new Map();\n\nfor (const employee of [\n ...targetEmployees,\n ...contextualOutsideMap.values(),\n]) {\n const key =\n employee.bamboo_id ||\n employee.employee_number ||\n normalize(employee.full_name);\n\n if (key) {\n validationEmployeeMap.set(\n key,\n employee\n );\n }\n}\n\nconst validationEmployees =\n Array.from(\n validationEmployeeMap.values()\n );\n\n\n/*\n * Resolución previa de nombres contra BambooHR.\n *\n * Cada nombre distinto recibido desde banco y nómina se resuelve una sola\n * vez, usando índices de alias y palabras. El resultado queda disponible\n * para el nodo de cruce mediante resolved_name_matches.\n */\nconst CONFIRMED_BAMBOO_NAME_ALIASES = new Map([\n [normalize(\"ISAAC ST BERNARD\"), normalize(\"Isaac St Bernard\")],\n [normalize(\"VICTORIA ALPHONSO\"), normalize(\"Victoria Alphanso\")],\n [normalize(\"VICTORIA ALPHANSO\"), normalize(\"Victoria Alphanso\")],\n [normalize(\"ONELA FARREL\"), normalize(\"Onela Farrell\")],\n [normalize(\"ONELA FARRELL\"), normalize(\"Onela Farrell\")],\n [normalize(\"JESHAUGHN LOUIS\"), normalize(\"Je'Shaugn Louis\")],\n [normalize(\"JESHAUGN LOUIS\"), normalize(\"Je'Shaugn Louis\")],\n [normalize(\"JE SHAUGN LOUIS\"), normalize(\"Je'Shaugn Louis\")],\n [normalize(\"ANESSA ALI\"), normalize(\"Annesa Marina Ali\")],\n [normalize(\"ANNESA ALI\"), normalize(\"Annesa Marina Ali\")],\n [normalize(\"ANNESA MARINA ALI\"), normalize(\"Annesa Marina Ali\")],\n [normalize(\"ALANA KERCELUS\"), normalize(\"Alana Kercelus-Inalsingh\")],\n [normalize(\"ALANA KERCELUS INALSINGH\"), normalize(\"Alana Kercelus-Inalsingh\")]\n]);\n\nfunction relevantEntryRaw(entry) {\n if (typeof entry === 'string') return clean(entry);\n return clean(entry?.raw || entry?.name || '');\n}\n\nfunction bambooResolutionEmployeeKey(employee) {\n return (\n clean(employee.bamboo_id) ||\n clean(employee.employee_number) ||\n normalize(employee.full_name)\n );\n}\n\nfunction bambooResolutionEditDistance(left, right) {\n const a = String(left || '');\n const b = String(right || '');\n\n if (a === b) return 0;\n if (!a) return b.length;\n if (!b) return a.length;\n\n let previous = Array.from(\n { length: b.length + 1 },\n (_, index) => index\n );\n\n for (let row = 1; row <= a.length; row++) {\n const current = [row];\n\n for (let column = 1; column <= b.length; column++) {\n const cost =\n a[row - 1] === b[column - 1]\n ? 0\n : 1;\n\n current[column] = Math.min(\n current[column - 1] + 1,\n previous[column] + 1,\n previous[column - 1] + cost\n );\n }\n\n previous = current;\n }\n\n return previous[b.length];\n}\nfunction bambooResolutionTokenSimilarity(left, right) {\n const a = String(left || '');\n const b = String(right || '');\n\n if (!a || !b) return 0;\n if (a === b) return 1;\n\n const minimumLength = Math.min(\n a.length,\n b.length\n );\n\n const maximumLength = Math.max(\n a.length,\n b.length\n );\n\n const distance =\n bambooResolutionEditDistance(a, b);\n\n if (\n minimumLength >= 4 &&\n distance <= 1\n ) {\n return Math.max(\n 0.90,\n 1 - distance / maximumLength\n );\n }\n\n if (\n minimumLength >= 6 &&\n distance <= 2\n ) {\n return Math.max(\n 0.82,\n 1 - distance / maximumLength\n );\n }\n\n const prefixOrSuffix =\n a.startsWith(b) ||\n b.startsWith(a) ||\n a.endsWith(b) ||\n b.endsWith(a);\n\n if (\n prefixOrSuffix &&\n minimumLength >= 4\n ) {\n return Math.max(\n 0.78,\n minimumLength / maximumLength\n );\n }\n\n return 0;\n}\n\nfunction bambooResolutionAliasDetails(\n queryName,\n aliasProfile\n) {\n const queryWords = Array.from(\n new Set(nameTokens(queryName))\n );\n\n const aliasWords =\n aliasProfile.words;\n\n if (\n queryWords.length < 2 ||\n aliasWords.length < 2\n ) {\n return null;\n }\n\n const aliasWordSet =\n aliasProfile.word_set;\n\n const queryWordSet =\n new Set(queryWords);\n\n const queryInsideAlias =\n queryWords.every((word) =>\n aliasWordSet.has(word)\n );\n\n const aliasInsideQuery =\n aliasWords.every((word) =>\n queryWordSet.has(word)\n );\n\n const usedAliasIndexes = new Set();\n const usedQueryIndexes = new Set();\n const similarities = new Array(\n queryWords.length\n ).fill(0);\n\n let exactMatches = 0;\n\n for (\n let queryIndex = 0;\n queryIndex < queryWords.length;\n queryIndex++\n ) {\n const aliasIndex =\n aliasWords.findIndex(\n (aliasWord, currentAliasIndex) =>\n !usedAliasIndexes.has(\n currentAliasIndex\n ) &&\n aliasWord ===\n queryWords[queryIndex]\n );\n\n if (aliasIndex < 0) continue;\n\n usedQueryIndexes.add(queryIndex);\n usedAliasIndexes.add(aliasIndex);\n similarities[queryIndex] = 1;\n exactMatches += 1;\n }\n\n const remainingQueryIndexes =\n queryWords\n .map((word, index) => ({\n word,\n index,\n }))\n .filter((entry) =>\n !usedQueryIndexes.has(entry.index)\n )\n .sort((left, right) =>\n right.word.length -\n left.word.length\n );\n\n for (const queryEntry of remainingQueryIndexes) {\n let bestSimilarity = 0;\n let bestAliasIndex = -1;\n\n for (\n let aliasIndex = 0;\n aliasIndex < aliasWords.length;\n aliasIndex++\n ) {\n if (\n usedAliasIndexes.has(\n aliasIndex\n )\n ) {\n continue;\n }\n\n const similarity =\n bambooResolutionTokenSimilarity(\n queryEntry.word,\n aliasWords[aliasIndex]\n );\n\n if (similarity > bestSimilarity) {\n bestSimilarity = similarity;\n bestAliasIndex = aliasIndex;\n }\n }\n\n if (\n bestAliasIndex >= 0 &&\n bestSimilarity >= 0.78\n ) {\n usedAliasIndexes.add(\n bestAliasIndex\n );\n similarities[queryEntry.index] =\n bestSimilarity;\n }\n }\n\n const matchedTokens =\n similarities.filter(\n (value) => value >= 0.78\n ).length;\n\n const queryCoverage =\n similarities.reduce(\n (sum, value) => sum + value,\n 0\n ) / queryWords.length;\n\n const aliasCoverage =\n matchedTokens /\n aliasWords.length;\n\n const lengthBalance =\n Math.min(\n queryWords.length,\n aliasWords.length\n ) /\n Math.max(\n queryWords.length,\n aliasWords.length\n );\n\n const score =\n queryCoverage * 0.65 +\n aliasCoverage * 0.20 +\n (\n exactMatches /\n queryWords.length\n ) * 0.10 +\n lengthBalance * 0.05;\n\n return {\n score,\n exact_matches: exactMatches,\n matched_tokens: matchedTokens,\n query_tokens:\n queryWords.length,\n alias_tokens:\n aliasWords.length,\n query_coverage:\n queryCoverage,\n alias_coverage:\n aliasCoverage,\n containment:\n queryInsideAlias ||\n aliasInsideQuery,\n };\n}\n\nconst bambooResolutionProfiles =\n validationEmployees.map(\n (employee, employeeIndex) => {\n const aliases = [];\n const seenAliases = new Set();\n\n for (\n const rawAlias of\n employee.aliases || []\n ) {\n const normalizedAlias =\n normalize(rawAlias);\n\n if (\n !normalizedAlias ||\n seenAliases.has(\n normalizedAlias\n )\n ) {\n continue;\n }\n\n seenAliases.add(\n normalizedAlias\n );\n\n const words = Array.from(\n new Set(nameTokens(rawAlias))\n );\n\n if (!words.length) continue;\n\n aliases.push({\n raw: clean(rawAlias),\n normalized:\n normalizedAlias,\n words,\n word_set:\n new Set(words),\n });\n }\n\n return {\n employee,\n employee_index:\n employeeIndex,\n employee_key:\n bambooResolutionEmployeeKey(\n employee\n ),\n aliases,\n };\n }\n );\n\nconst bambooResolutionExactAliasSets =\n new Map();\n\nconst bambooResolutionTokenSets =\n new Map();\n\nconst bambooResolutionTokenShapeSets =\n new Map();\n\nfor (\n let employeeIndex = 0;\n employeeIndex <\n bambooResolutionProfiles.length;\n employeeIndex++\n) {\n const profile =\n bambooResolutionProfiles[\n employeeIndex\n ];\n\n for (const alias of profile.aliases) {\n let exactSet =\n bambooResolutionExactAliasSets\n .get(alias.normalized);\n\n if (!exactSet) {\n exactSet = new Set();\n bambooResolutionExactAliasSets\n .set(\n alias.normalized,\n exactSet\n );\n }\n\n exactSet.add(employeeIndex);\n\n for (const token of alias.words) {\n if (token.length < 3) continue;\n\n let tokenSet =\n bambooResolutionTokenSets\n .get(token);\n\n if (!tokenSet) {\n tokenSet = new Set();\n bambooResolutionTokenSets\n .set(token, tokenSet);\n }\n\n tokenSet.add(employeeIndex);\n\n const tokenShape =\n `${token[0]}:${token.length}`;\n\n let shapeSet =\n bambooResolutionTokenShapeSets\n .get(tokenShape);\n\n if (!shapeSet) {\n shapeSet = new Set();\n bambooResolutionTokenShapeSets\n .set(\n tokenShape,\n shapeSet\n );\n }\n\n shapeSet.add(employeeIndex);\n }\n }\n}\n\nconst bambooResolutionExactAliasMap =\n new Map();\n\nfor (\n const [alias, indexes] of\n bambooResolutionExactAliasSets\n) {\n bambooResolutionExactAliasMap.set(\n alias,\n Array.from(indexes)\n );\n}\n\nfunction bambooResolutionDecision(\n queryName\n) {\n const rawQuery = clean(queryName);\n const normalizedQuery =\n normalize(rawQuery);\n\n const queryWords = Array.from(\n new Set(nameTokens(rawQuery))\n );\n\n if (\n !normalizedQuery ||\n queryWords.length < 2\n ) {\n return {\n found: false,\n matched_by: null,\n confidence: 0,\n reason:\n 'insufficient_name_tokens',\n };\n }\n\n const confirmedCanonical =\n CONFIRMED_BAMBOO_NAME_ALIASES\n .get(normalizedQuery);\n\n if (confirmedCanonical) {\n const confirmedIndexes =\n bambooResolutionExactAliasMap\n .get(confirmedCanonical) || [];\n\n if (confirmedIndexes.length === 1) {\n const employeeIndex =\n confirmedIndexes[0];\n\n return {\n found: true,\n matched_by:\n 'confirmed_alias_catalog',\n confidence: 1,\n employee_index:\n employeeIndex,\n employee_key:\n bambooResolutionProfiles[\n employeeIndex\n ].employee_key,\n query_name:\n rawQuery,\n bamboo_alias:\n bambooResolutionProfiles[\n employeeIndex\n ].aliases.find(\n (alias) =>\n alias.normalized ===\n confirmedCanonical\n )?.raw ||\n bambooResolutionProfiles[\n employeeIndex\n ].employee.full_name ||\n '',\n };\n }\n }\n\n const exactIndexes =\n bambooResolutionExactAliasMap\n .get(normalizedQuery) || [];\n\n if (exactIndexes.length === 1) {\n const employeeIndex =\n exactIndexes[0];\n\n return {\n found: true,\n matched_by:\n 'exact_precomputed_name',\n confidence: 1,\n employee_index:\n employeeIndex,\n employee_key:\n bambooResolutionProfiles[\n employeeIndex\n ].employee_key,\n query_name:\n rawQuery,\n bamboo_alias:\n bambooResolutionProfiles[\n employeeIndex\n ].aliases.find(\n (alias) =>\n alias.normalized ===\n normalizedQuery\n )?.raw ||\n bambooResolutionProfiles[\n employeeIndex\n ].employee.full_name ||\n '',\n };\n }\n\n const candidateVotes = new Map();\n\n function addCandidateVotes(\n indexes,\n weight\n ) {\n for (const employeeIndex of indexes) {\n candidateVotes.set(\n employeeIndex,\n (\n candidateVotes.get(\n employeeIndex\n ) || 0\n ) + weight\n );\n }\n }\n\n for (const token of queryWords) {\n addCandidateVotes(\n bambooResolutionTokenSets\n .get(token) || [],\n 4\n );\n\n for (\n let lengthOffset = -2;\n lengthOffset <= 2;\n lengthOffset++\n ) {\n const candidateLength =\n token.length + lengthOffset;\n\n if (candidateLength < 3) {\n continue;\n }\n\n addCandidateVotes(\n bambooResolutionTokenShapeSets\n .get(\n `${token[0]}:${candidateLength}`\n ) || [],\n 1\n );\n }\n }\n\n const candidateIndexes =\n Array.from(\n candidateVotes.entries()\n )\n .sort((left, right) =>\n right[1] - left[1]\n )\n .slice(0, 120)\n .map(([employeeIndex]) =>\n employeeIndex\n );\n\n const rankedCandidates = [];\n\n for (\n const employeeIndex of\n candidateIndexes\n ) {\n const profile =\n bambooResolutionProfiles[\n employeeIndex\n ];\n\n let bestDetails = null;\n let bestAlias = '';\n\n for (const alias of profile.aliases) {\n const details =\n bambooResolutionAliasDetails(\n rawQuery,\n alias\n );\n\n if (\n details &&\n (\n !bestDetails ||\n details.score >\n bestDetails.score\n )\n ) {\n bestDetails = details;\n bestAlias = alias.raw;\n }\n }\n\n if (!bestDetails) continue;\n\n rankedCandidates.push({\n employee_index:\n employeeIndex,\n employee_key:\n profile.employee_key,\n details:\n bestDetails,\n bamboo_alias:\n bestAlias,\n });\n }\n\n rankedCandidates.sort(\n (left, right) => {\n if (\n right.details.score !==\n left.details.score\n ) {\n return (\n right.details.score -\n left.details.score\n );\n }\n\n if (\n right.details.exact_matches !==\n left.details.exact_matches\n ) {\n return (\n right.details.exact_matches -\n left.details.exact_matches\n );\n }\n\n return (\n right.details.query_coverage -\n left.details.query_coverage\n );\n }\n );\n\n const best =\n rankedCandidates[0] || null;\n\n const second =\n rankedCandidates[1] || null;\n\n const margin =\n best\n ? best.details.score -\n (\n second?.details.score ||\n 0\n )\n : 0;\n\n const details =\n best?.details || null;\n\n const exactContainment =\n Boolean(\n details?.containment &&\n details.exact_matches >= 2\n );\n\n const strongTwoTokenName =\n Boolean(\n details &&\n details.query_tokens === 2 &&\n details.matched_tokens === 2 &&\n details.exact_matches >= 1 &&\n details.query_coverage >= 0.90 &&\n details.score >= 0.88\n );\n\n const strongLongName =\n Boolean(\n details &&\n details.query_tokens >= 3 &&\n details.matched_tokens >=\n Math.min(\n 3,\n details.query_tokens\n ) &&\n details.exact_matches >= 2 &&\n details.query_coverage >= 0.85 &&\n details.score >= 0.84\n );\n\n const acceptableMargin =\n !second ||\n margin >= (\n exactContainment\n ? 0.04\n : 0.06\n ) ||\n (\n details?.exact_matches || 0\n ) >\n (\n second?.details\n ?.exact_matches || 0\n );\n\n if (\n best &&\n acceptableMargin &&\n (\n exactContainment ||\n strongTwoTokenName ||\n strongLongName\n )\n ) {\n return {\n found: true,\n matched_by:\n exactContainment\n ? 'unique_precomputed_containment'\n : 'strong_precomputed_fuzzy_name',\n confidence:\n Math.min(\n 1,\n details.score\n ),\n employee_index:\n best.employee_index,\n employee_key:\n best.employee_key,\n query_name:\n rawQuery,\n bamboo_alias:\n best.bamboo_alias,\n margin,\n exact_matches:\n details.exact_matches,\n matched_tokens:\n details.matched_tokens,\n };\n }\n\n return {\n found: false,\n matched_by: null,\n confidence:\n details?.score || 0,\n reason:\n best\n ? (\n acceptableMargin\n ? 'insufficient_name_evidence'\n : 'ambiguous_name'\n )\n : 'no_candidate',\n best_candidate:\n best\n ? {\n employee_index:\n best.employee_index,\n employee_key:\n best.employee_key,\n bamboo_alias:\n best.bamboo_alias,\n score:\n best.details.score,\n }\n : null,\n second_candidate:\n second\n ? {\n employee_index:\n second.employee_index,\n employee_key:\n second.employee_key,\n bamboo_alias:\n second.bamboo_alias,\n score:\n second.details.score,\n }\n : null,\n };\n}\n\nconst resolvedNameMatches = {};\nlet resolvedNameMatchesFound = 0;\n\nfor (\n const [\n normalizedRelevantName,\n relevantEntry,\n ] of relevantNameMap\n) {\n const rawRelevantName =\n relevantEntryRaw(relevantEntry);\n\n const decision =\n bambooResolutionDecision(\n rawRelevantName\n );\n\n resolvedNameMatches[\n normalizedRelevantName\n ] = decision;\n\n if (decision.found) {\n resolvedNameMatchesFound += 1;\n }\n}\n\n\nconst fetchedEmployeesCount =\n rawEmployees.length;\n\nconst fetchComplete =\n expectedTotal > 0\n ? fetchedEmployeesCount >= expectedTotal\n : (\n pageObjects.length > 0 &&\n !pageObjects.some(\n (page) =>\n Boolean(\n page?._links?.next?.href\n )\n )\n );\n\nconst errors = [];\n\nif (!pageObjects.length) {\n errors.push(\n 'BambooHR no devolvió páginas de empleados.'\n );\n}\n\nif (!fetchedEmployeesCount) {\n errors.push(\n 'BambooHR no devolvió empleados.'\n );\n}\n\nif (\n expectedTotal > 0 &&\n fetchedEmployeesCount < expectedTotal\n) {\n errors.push(\n `La descarga de BambooHR quedó incompleta: ` +\n `${fetchedEmployeesCount} de ${expectedTotal} empleados.`\n );\n}\n\nif (!targetEmployees.length) {\n errors.push(\n 'No se encontraron empleados de Trinidad y Tobago en BambooHR.'\n );\n}\n\nreturn [\n {\n json: {\n ...base,\n ok:\n Boolean(base.ok ?? true) &&\n errors.length === 0,\n stage:\n errors.length === 0\n ? 'bamboohr_tt_normalizado'\n : 'bamboohr_tt_incompleto',\n errors: [\n ...(Array.isArray(base.errors)\n ? base.errors\n : []),\n ...errors,\n ],\n bamboo: {\n source:\n 'bamboohr_custom_report_only_current_false',\n period_start: periodStart,\n period_end: periodEnd,\n pages_fetched: pageObjects.length,\n expected_total: expectedTotal,\n raw_employees_count:\n fetchedEmployeesCount,\n employees_count:\n allNormalized.length,\n trinidad_tobago_count:\n targetEmployees.length,\n active_in_period_count:\n targetEmployees.filter(\n (employee) =>\n employee.overlaps_period\n ).length,\n active_status_count:\n targetEmployees.filter(\n (employee) =>\n normalize(employee.status) ===\n 'active'\n ).length,\n contextual_outside_country_count:\n contextualOutsideMap.size,\n validation_candidates_count:\n validationEmployees.length,\n resolved_name_matches:\n resolvedNameMatches,\n resolved_name_matches_count:\n Object.keys(\n resolvedNameMatches\n ).length,\n resolved_name_matches_found:\n resolvedNameMatchesFound,\n name_resolution_strategy:\n 'precomputed_indexed_fuzzy_matching_with_confirmed_aliases',\n fetch_complete: fetchComplete,\n validation_available:\n fetchComplete &&\n validationEmployees.length > 0,\n validation_rule:\n 'Target country/location plus a unique informative contextual name outside the country',\n employees:\n validationEmployees,\n restricted_fields:\n restrictedFields,\n },\n },\n },\n];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 5088, - 6816 - ], - "id": "d6d947b2-4a0e-4917-855c-7b69a27dae4e", - "name": "Normalizar BambooHR TT" - }, - { - "parameters": { - "mode": "combine", - "combineBy": "combineByPosition", - "options": {} - }, - "type": "n8n-nodes-base.merge", - "typeVersion": 3.2, - "position": [ - 5968, - 7296 - ], - "id": "9760d7dd-776e-4ae7-b56b-2f8ac85cc768", - "name": "Merge - Agregar BambooHR TT" - }, - { - "parameters": { - "jsCode": "const data = $input.first().json || {};\n\nfunction roundMoney(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n}\n\nfunction moneyDiff(a, b) {\n return roundMoney((Number(a) || 0) - (Number(b) || 0));\n}\n\nfunction moneyEquals(a, b, tolerance = 0.02) {\n return Math.abs(roundMoney(a) - roundMoney(b)) <= tolerance;\n}\n\nfunction normalizeAccount(value) {\n return String(value ?? '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim();\n}\n\nfunction normalizeName(value) {\n return String(value ?? '')\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/['’`-]/g, '')\n .replace(/[^a-z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction nameWords(value) {\n const ignored = new Set(['de', 'del', 'la', 'las', 'los', 'y', 'e', 'el']);\n return normalizeName(value)\n .split(' ')\n .filter((word) => word.length > 1 && !ignored.has(word));\n}\n\nfunction editDistance(a, b) {\n if (a === b) return 0;\n if (!a) return b.length;\n if (!b) return a.length;\n\n const previous = Array.from({ length: b.length + 1 }, (_, index) => index);\n\n for (let i = 1; i <= a.length; i++) {\n const current = [i];\n\n for (let j = 1; j <= b.length; j++) {\n const cost = a[i - 1] === b[j - 1] ? 0 : 1;\n\n current[j] = Math.min(\n current[j - 1] + 1,\n previous[j] + 1,\n previous[j - 1] + cost\n );\n }\n\n for (let j = 0; j < current.length; j++) {\n previous[j] = current[j];\n }\n }\n\n return previous[b.length];\n}\n\nfunction tokenMatches(a, b) {\n if (a === b) return true;\n\n const minLength = Math.min(a.length, b.length);\n\n if (minLength >= 8 && editDistance(a, b) <= 2) return true;\n if (minLength >= 5 && editDistance(a, b) <= 1) return true;\n\n return false;\n}\n\nfunction bambooTokenMatches(a, b) {\n if (tokenMatches(a, b)) return true;\n\n const minLength = Math.min(a.length, b.length);\n const maxLength = Math.max(a.length, b.length);\n const distance = editDistance(a, b);\n\n // Tolera variaciones pequeñas de escritura entre Banco/Nómina y BambooHR,\n // por ejemplo Anessa <-> Annesa, sin flexibilizar el cruce principal.\n if (minLength >= 6 && distance <= 2) {\n return true;\n }\n\n // Permite apellidos compuestos como BeharrySingh vs Singh,\n // pero evita aceptar coincidencias demasiado amplias.\n return (\n minLength >= 4 &&\n maxLength - minLength <= 10 &&\n (\n a.startsWith(b) ||\n b.startsWith(a) ||\n a.endsWith(b) ||\n b.endsWith(a)\n )\n );\n}\n\nfunction samePersonName(a, b) {\n const normalizedA = normalizeName(a);\n const normalizedB = normalizeName(b);\n\n if (!normalizedA || !normalizedB) return false;\n if (normalizedA === normalizedB) return true;\n\n const compactA = normalizedA.replace(/\\s+/g, '');\n const compactB = normalizedB.replace(/\\s+/g, '');\n\n if (compactA === compactB) return true;\n\n const wordsA = nameWords(a);\n const wordsB = nameWords(b);\n\n if (!wordsA.length || !wordsB.length) return false;\n\n const usedB = new Set();\n let matches = 0;\n\n for (const wordA of wordsA) {\n const matchIndex = wordsB.findIndex((wordB, index) => {\n return !usedB.has(index) && tokenMatches(wordA, wordB);\n });\n\n if (matchIndex >= 0) {\n usedB.add(matchIndex);\n matches += 1;\n }\n }\n\n const smallerLength = Math.min(wordsA.length, wordsB.length);\n const ratio = matches / smallerLength;\n\n if (smallerLength <= 2) {\n return matches === smallerLength && matches >= 2;\n }\n\n return matches >= 2 && ratio >= 0.6;\n}\n\nfunction accountDistance(a, b) {\n return editDistance(normalizeAccount(a), normalizeAccount(b));\n}\n\nfunction accountRelationship(payrollAccount, bankAccount) {\n const payroll = normalizeAccount(payrollAccount);\n const bank = normalizeAccount(bankAccount);\n\n if (!payroll || !bank) {\n return { matches: false, type: 'none' };\n }\n\n if (payroll === bank) {\n return { matches: true, type: 'exact' };\n }\n\n const bankHasPayrollSuffix =\n bank.endsWith(payroll) &&\n bank.length > payroll.length &&\n bank.length - payroll.length <= 6;\n\n const payrollHasBankSuffix =\n payroll.endsWith(bank) &&\n payroll.length > bank.length &&\n payroll.length - bank.length <= 6;\n\n if (bankHasPayrollSuffix || payrollHasBankSuffix) {\n return { matches: true, type: 'reference_prefix' };\n }\n\n return { matches: false, type: 'none' };\n}\n\nfunction formatMoney(value) {\n return Math.abs(roundMoney(value)).toLocaleString('en-US', {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n });\n}\n\nfunction bankNames(bank) {\n return Array.from(new Set([\n ...(Array.isArray(bank.bank_name_files) ? bank.bank_name_files : []),\n ...(Array.isArray(bank.bank_account_holders) ? bank.bank_account_holders : []),\n bank.bank_name_file || '',\n bank.bank_account_holder || '',\n ].filter(Boolean)));\n}\n\nfunction bankMatchesName(bank, payrollName) {\n return bankNames(bank).some((name) => samePersonName(payrollName, name));\n}\n\nfunction bestBankDisplayName(bank) {\n return (\n bank.bank_name_file ||\n bank.bank_account_holder ||\n bankNames(bank)[0] ||\n ''\n );\n}\n\nfunction bambooAliases(employee) {\n return Array.from(new Set([\n ...(Array.isArray(employee.aliases) ? employee.aliases : []),\n employee.full_name || '',\n [employee.first_name, employee.middle_name, employee.last_name]\n .filter(Boolean)\n .join(' '),\n [employee.preferred_name, employee.last_name]\n .filter(Boolean)\n .join(' '),\n ].map((value) => String(value || '').trim()).filter(Boolean)));\n}\n\nfunction bambooEmployeeNumber(employee) {\n return normalizeAccount(\n employee.employee_number ||\n employee.employeeNumber ||\n ''\n );\n}\n\nfunction isTrinidadTobagoBambooEmployee(employee) {\n const country = normalizeName(\n employee.country || ''\n );\n const location = normalizeName(\n employee.location || ''\n );\n\n return (\n country === 'tt' ||\n country === 'tto' ||\n country.includes('trinidad') ||\n country.includes('tobago') ||\n location === 'tt' ||\n location === 'tto' ||\n location.includes('trinidad') ||\n location.includes('tobago')\n );\n}\n\nfunction isBambooValidationEligible(employee) {\n // La versión corregida del normalizador declara este campo.\n if (employee.validation_eligible === true) {\n return true;\n }\n\n if (employee.validation_eligible === false) {\n return false;\n }\n\n // Compatibilidad defensiva si este nodo recibe datos de una ejecución\n // anterior: los perfiles de TT siguen siendo válidos. Un perfil de otro\n // país solo puede utilizarse cuando está Active y vigente en el período.\n if (isTrinidadTobagoBambooEmployee(employee)) {\n return true;\n }\n\n return (\n employee.overlaps_period === true &&\n normalizeName(employee.status) === 'active'\n );\n}\n\nfunction nameSimilarityScore(a, b) {\n const normalizedA = normalizeName(a);\n const normalizedB = normalizeName(b);\n\n if (!normalizedA || !normalizedB) return 0;\n if (normalizedA === normalizedB) return 1;\n\n const compactA = normalizedA.replace(/\\s+/g, '');\n const compactB = normalizedB.replace(/\\s+/g, '');\n\n if (compactA === compactB) return 1;\n\n const wordsA = nameWords(normalizedA);\n const wordsB = nameWords(normalizedB);\n\n if (!wordsA.length || !wordsB.length) return 0;\n\n const usedB = new Set();\n const usedA = new Set();\n let exactMatches = 0;\n let fuzzyMatches = 0;\n\n // Primero se reservan las coincidencias exactas para no perder\n // evidencia fuerte por el orden de las palabras.\n for (let indexA = 0; indexA < wordsA.length; indexA++) {\n const indexB = wordsB.findIndex(\n (wordB, currentIndexB) =>\n !usedB.has(currentIndexB) &&\n wordsA[indexA] === wordB\n );\n\n if (indexB >= 0) {\n usedA.add(indexA);\n usedB.add(indexB);\n exactMatches += 1;\n }\n }\n\n // Después se toleran errores ortográficos pequeños únicamente\n // para complementar una coincidencia que ya tiene evidencia exacta.\n for (let indexA = 0; indexA < wordsA.length; indexA++) {\n if (usedA.has(indexA)) continue;\n\n const indexB = wordsB.findIndex(\n (wordB, currentIndexB) =>\n !usedB.has(currentIndexB) &&\n bambooTokenMatches(wordsA[indexA], wordB)\n );\n\n if (indexB >= 0) {\n usedA.add(indexA);\n usedB.add(indexB);\n fuzzyMatches += 1;\n }\n }\n\n const matches = exactMatches + fuzzyMatches;\n\n if (matches < 2) return 0;\n\n // Dos palabras solo son suficientes cuando ambas coinciden exactamente.\n // Esto evita falsos positivos como un apellido correcto acompañado por\n // un nombre distinto que solo se parece parcialmente.\n if (matches === 2 && exactMatches < 2) return 0;\n\n // En nombres largos se exige al menos dos piezas exactas y se permite\n // que una tercera palabra tenga una diferencia ortográfica pequeña.\n if (matches >= 3 && exactMatches < 2) return 0;\n\n const ratioToShorter =\n matches / Math.min(wordsA.length, wordsB.length);\n const ratioToLonger =\n matches / Math.max(wordsA.length, wordsB.length);\n\n return (\n ratioToShorter * 0.7 +\n ratioToLonger * 0.3\n );\n}\n\nfunction bankRowKey(row) {\n return [\n row.source_file || '',\n row.row_number || '',\n ].join('|');\n}\n\nfunction bankRowNames(row) {\n const rowKey = bankRowKey(row);\n\n const linkedPayrollNames =\n typeof linkedPayrollNamesByBankRow !== 'undefined'\n ? linkedPayrollNamesByBankRow.get(rowKey) || []\n : [];\n\n return Array.from(new Set([\n row.bank_name_file || '',\n row.bank_account_holder || '',\n ...linkedPayrollNames,\n ].map((value) => String(value || '').trim()).filter(Boolean)));\n}\n\nfunction bankRowEmployeeNumbers(row) {\n const rowKey = bankRowKey(row);\n\n const linkedNumbers =\n typeof linkedPayrollNumbersByBankRow !== 'undefined'\n ? linkedPayrollNumbersByBankRow.get(rowKey) || []\n : [];\n\n return Array.from(new Set(\n linkedNumbers\n .map(normalizeAccount)\n .filter((value) => value.length >= 6)\n ));\n}\n\nfunction bankRowReferenceText(row) {\n return [\n row.reference || '',\n row.concept || '',\n row.bank_name_file || '',\n row.bank_account_holder || '',\n ...bankRowEmployeeNumbers(row),\n ].join(' ');\n}\n\nfunction isClearlyNonEmployeePayment(row) {\n const normalized = normalizeName([\n row.concept || '',\n row.bank_name_file || '',\n row.bank_account_holder || '',\n ].join(' '));\n\n return [\n 'pension alimenticia',\n 'embargo judicial',\n 'retencion judicial',\n ].some((token) =>\n normalized.includes(normalizeName(token))\n );\n}\n\nfunction buildBambooSearchIndex(employees) {\n const records = [];\n const exactAliasMap = new Map();\n const tokenIndexSets = new Map();\n const employeeNumberMap = new Map();\n\n for (let index = 0; index < employees.length; index++) {\n const employee = employees[index];\n const aliases = bambooAliases(employee)\n .map((alias) => ({\n raw: alias,\n normalized: normalizeName(alias),\n }))\n .filter((alias) => alias.normalized);\n\n const uniqueAliases = [];\n const seenAliases = new Set();\n\n for (const alias of aliases) {\n if (seenAliases.has(alias.normalized)) continue;\n seenAliases.add(alias.normalized);\n uniqueAliases.push({\n ...alias,\n words: nameWords(alias.normalized),\n });\n\n const exact = exactAliasMap.get(alias.normalized) || [];\n exact.push(index);\n exactAliasMap.set(alias.normalized, exact);\n\n const uniqueTokens = Array.from(new Set(\n nameWords(alias.normalized)\n .filter((token) => token.length >= 3)\n ));\n\n for (const token of uniqueTokens) {\n const set = tokenIndexSets.get(token) || new Set();\n set.add(index);\n tokenIndexSets.set(token, set);\n }\n }\n\n const employeeNumber = bambooEmployeeNumber(employee);\n\n if (employeeNumber.length >= 6) {\n const matches = employeeNumberMap.get(employeeNumber) || [];\n matches.push(index);\n employeeNumberMap.set(employeeNumber, matches);\n }\n\n records.push({\n employee,\n aliases: uniqueAliases,\n employeeNumber,\n });\n }\n\n const tokenIndex = new Map();\n for (const [token, set] of tokenIndexSets.entries()) {\n tokenIndex.set(token, Array.from(set));\n }\n\n return {\n records,\n exactAliasMap,\n tokenIndex,\n employeeNumberMap,\n };\n}\n\nconst bambooMatchCache = new Map();\n\nfunction findBambooMatch(bankRow) {\n const names = bankRowNames(bankRow);\n const normalizedNames = Array.from(new Set(\n names.map(normalizeName).filter(Boolean)\n ));\n const directEmployeeNumbers = bankRowEmployeeNumbers(bankRow);\n const referenceNumberTokens = Array.from(new Set(\n (\n String(bankRowReferenceText(bankRow) || '')\n .match(/\\d{6,}/g) || []\n )\n .map(normalizeAccount)\n .filter((value) => value.length >= 6)\n ));\n\n const cacheKey = [\n ...directEmployeeNumbers.sort(),\n ...referenceNumberTokens.sort(),\n ...normalizedNames.sort(),\n ].join('|');\n\n if (bambooMatchCache.has(cacheKey)) {\n return bambooMatchCache.get(cacheKey);\n }\n\n const numberCandidateIndexes = new Set();\n\n for (const employeeNumber of directEmployeeNumbers) {\n for (\n const index of\n bambooSearch.employeeNumberMap.get(employeeNumber) || []\n ) {\n numberCandidateIndexes.add(index);\n }\n }\n\n if (!numberCandidateIndexes.size && referenceNumberTokens.length) {\n for (const referenceNumber of referenceNumberTokens) {\n for (\n const index of\n bambooSearch.employeeNumberMap.get(referenceNumber) || []\n ) {\n numberCandidateIndexes.add(index);\n }\n }\n }\n\n if (numberCandidateIndexes.size === 1) {\n const index = numberCandidateIndexes.values().next().value;\n const record = bambooSearch.records[index];\n\n // Un Employee Number enlazado desde la nómina es confiable.\n // Si proviene solamente de la referencia bancaria, también se exige\n // que el nombre corresponda para evitar falsos positivos por números\n // accidentales dentro del Addenda.\n const referenceNameScore = Math.max(\n 0,\n ...names.flatMap((currentBankName) =>\n record.aliases.map((alias) =>\n nameSimilarityScore(\n currentBankName,\n alias.normalized\n )\n )\n )\n );\n\n if (\n directEmployeeNumbers.length ||\n referenceNameScore >= 0.84\n ) {\n const result = {\n found: true,\n matched_by: directEmployeeNumbers.length\n ? 'employee_number_payroll'\n : 'employee_number_reference_and_name',\n confidence: directEmployeeNumbers.length\n ? 1\n : referenceNameScore,\n employee: record.employee,\n };\n bambooMatchCache.set(cacheKey, result);\n return result;\n }\n\n // La coincidencia numérica aislada se descarta y se continúa\n // con la validación por nombre.\n numberCandidateIndexes.clear();\n }\n\n\n\n /*\n * Consulta primero la resolución calculada una sola vez en el\n * normalizador. Esto evita repetir búsquedas aproximadas por cada fila\n * bancaria y mantiene el task runner estable incluso con miles de\n * empleados en BambooHR.\n */\n const precomputedNameMatches =\n data.bamboo?.resolved_name_matches ||\n {};\n\n const precomputedNameEntries =\n names.map((entry) => {\n const raw =\n typeof entry === 'string'\n ? entry\n : entry?.raw || '';\n\n return {\n raw,\n normalized:\n typeof entry === 'string'\n ? normalizeName(entry)\n : (\n entry?.normalized ||\n normalizeName(raw)\n ),\n token_count:\n typeof entry === 'string'\n ? nameWords(entry).length\n : (\n entry?.tokenCount ||\n nameWords(raw).length\n ),\n };\n }).filter((entry) =>\n entry.normalized\n );\n\n const precomputedFoundByEmployee =\n new Map();\n\n function resolutionEmployeeKey(\n employee\n ) {\n return (\n String(\n employee?.bamboo_id ||\n ''\n ).trim() ||\n normalizeAccount(\n employee?.employee_number ||\n employee?.employeeNumber ||\n ''\n ) ||\n normalizeName(\n employee?.full_name ||\n employee?.displayName ||\n ''\n )\n );\n }\n\n for (\n const nameEntry of\n precomputedNameEntries\n ) {\n const decision =\n precomputedNameMatches[\n nameEntry.normalized\n ];\n\n if (\n !decision ||\n decision.found !== true\n ) {\n continue;\n }\n\n let employee =\n Number.isInteger(\n decision.employee_index\n )\n ? bambooEmployees[\n decision.employee_index\n ]\n : null;\n\n const expectedKey =\n String(\n decision.employee_key ||\n ''\n ).trim();\n\n if (\n !employee ||\n (\n expectedKey &&\n resolutionEmployeeKey(\n employee\n ) !== expectedKey\n )\n ) {\n employee =\n bambooEmployees.find(\n (candidate) =>\n resolutionEmployeeKey(\n candidate\n ) === expectedKey\n ) || null;\n }\n\n if (!employee) continue;\n\n const employeeKey =\n resolutionEmployeeKey(employee);\n\n const candidate = {\n employee,\n employee_key:\n employeeKey,\n confidence:\n Number(\n decision.confidence || 0\n ),\n matched_by:\n decision.matched_by ||\n 'precomputed_name',\n bank_name:\n nameEntry.raw,\n bamboo_alias:\n decision.bamboo_alias ||\n employee.full_name ||\n '',\n informativeness:\n nameEntry.token_count,\n };\n\n const existing =\n precomputedFoundByEmployee\n .get(employeeKey);\n\n if (\n !existing ||\n candidate.confidence >\n existing.confidence ||\n (\n candidate.confidence ===\n existing.confidence &&\n candidate.informativeness >\n existing.informativeness\n )\n ) {\n precomputedFoundByEmployee.set(\n employeeKey,\n candidate\n );\n }\n }\n\n const precomputedRanked =\n Array.from(\n precomputedFoundByEmployee\n .values()\n ).sort((left, right) => {\n if (\n right.confidence !==\n left.confidence\n ) {\n return (\n right.confidence -\n left.confidence\n );\n }\n\n return (\n right.informativeness -\n left.informativeness\n );\n });\n\n if (precomputedRanked.length === 1) {\n const best =\n precomputedRanked[0];\n\n const result = {\n found: true,\n matched_by:\n best.matched_by,\n confidence:\n best.confidence,\n employee:\n best.employee,\n bank_name:\n best.bank_name,\n bamboo_alias:\n best.bamboo_alias,\n };\n\n bambooMatchCache.set(\n cacheKey,\n result\n );\n\n return result;\n }\n\n if (\n precomputedRanked.length > 1\n ) {\n const best =\n precomputedRanked[0];\n\n const second =\n precomputedRanked[1];\n\n if (\n best.confidence -\n second.confidence >= 0.08\n ) {\n const result = {\n found: true,\n matched_by:\n best.matched_by,\n confidence:\n best.confidence,\n employee:\n best.employee,\n bank_name:\n best.bank_name,\n bamboo_alias:\n best.bamboo_alias,\n };\n\n bambooMatchCache.set(\n cacheKey,\n result\n );\n\n return result;\n }\n\n const result = {\n found: false,\n matched_by: null,\n confidence:\n best.confidence,\n employee: null,\n ambiguous: true,\n reason:\n 'conflicting_precomputed_name_matches',\n best_candidate: {\n employee:\n best.employee,\n score:\n best.confidence,\n bank_name:\n best.bank_name,\n bamboo_alias:\n best.bamboo_alias,\n },\n };\n\n bambooMatchCache.set(\n cacheKey,\n result\n );\n\n return result;\n }\n\n\n // Se prioriza el nombre más informativo de la fila. Esto evita que un\n // nombre corto y ambiguo bloquee un nombre completo que identifica a una\n // sola persona, por ejemplo \"Carlos De Leon\" frente a\n // \"Carlos Alexander De Leon Chajon\".\n const informativeNames = names\n .map((raw) => ({\n raw,\n tokens: nameWords(raw).length,\n }))\n .filter((entry) =>\n entry.tokens >= 3\n )\n .sort((left, right) =>\n right.tokens - left.tokens\n );\n\n for (const informativeName of informativeNames) {\n let bestInformative = null;\n let secondInformative = null;\n\n for (\n let index = 0;\n index < bambooSearch.records.length;\n index++\n ) {\n const record =\n bambooSearch.records[index];\n\n let score = 0;\n let bestAlias = '';\n\n for (const alias of record.aliases) {\n const currentScore =\n nameSimilarityScore(\n informativeName.raw,\n alias.normalized\n );\n\n if (currentScore > score) {\n score = currentScore;\n bestAlias = alias.raw;\n }\n }\n\n if (score <= 0) continue;\n\n const candidate = {\n index,\n record,\n score,\n bamboo_alias: bestAlias,\n };\n\n if (\n !bestInformative ||\n candidate.score >\n bestInformative.score\n ) {\n secondInformative =\n bestInformative;\n bestInformative =\n candidate;\n } else if (\n !secondInformative ||\n candidate.score >\n secondInformative.score\n ) {\n secondInformative =\n candidate;\n }\n }\n\n const informativeMargin =\n bestInformative\n ? bestInformative.score -\n (secondInformative?.score || 0)\n : 0;\n\n if (\n bestInformative &&\n bestInformative.score >= 0.90 &&\n informativeMargin >= 0.05\n ) {\n const result = {\n found: true,\n matched_by:\n bestInformative.score === 1\n ? 'exact_informative_name'\n : 'strong_informative_name',\n confidence:\n bestInformative.score,\n employee:\n bestInformative.record.employee,\n bank_name:\n informativeName.raw,\n bamboo_alias:\n bestInformative.bamboo_alias,\n };\n\n bambooMatchCache.set(\n cacheKey,\n result\n );\n\n return result;\n }\n }\n\n const exactCandidateIndexes = new Set();\n\n for (const name of normalizedNames) {\n for (\n const index of\n bambooSearch.exactAliasMap.get(name) || []\n ) {\n exactCandidateIndexes.add(index);\n }\n }\n\n if (exactCandidateIndexes.size === 1) {\n const index = exactCandidateIndexes.values().next().value;\n const result = {\n found: true,\n matched_by: 'exact_name',\n confidence: 1,\n employee: bambooSearch.records[index].employee,\n bank_name: names[0] || '',\n bamboo_alias:\n bambooSearch.records[index].aliases[0]?.raw || '',\n };\n bambooMatchCache.set(cacheKey, result);\n return result;\n }\n\n const candidateVotes = new Map();\n\n for (const name of normalizedNames) {\n const tokens = Array.from(new Set(\n nameWords(name)\n .filter((token) => token.length >= 3)\n ));\n\n for (const token of tokens) {\n const indexes = bambooSearch.tokenIndex.get(token) || [];\n\n // Evita que nombres demasiado comunes generen cientos de comparaciones.\n if (indexes.length > 180) continue;\n\n for (const index of indexes) {\n candidateVotes.set(\n index,\n (candidateVotes.get(index) || 0) + 1\n );\n }\n }\n }\n\n // Cuando una letra fue agregada, omitida o reemplazada, puede no existir\n // ningún token exacto compartido. En ese caso se buscan tokens cercanos\n // solamente entre palabras de longitud comparable.\n if (!candidateVotes.size) {\n for (const name of normalizedNames) {\n const queryTokens = Array.from(new Set(\n nameWords(name)\n .filter((token) => token.length >= 3)\n ));\n\n for (const queryToken of queryTokens) {\n for (\n const [indexedToken, indexes] of\n bambooSearch.tokenIndex.entries()\n ) {\n if (\n Math.abs(\n queryToken.length - indexedToken.length\n ) > 2\n ) {\n continue;\n }\n\n if (\n queryToken[0] !== indexedToken[0] &&\n queryToken.at(-1) !== indexedToken.at(-1)\n ) {\n continue;\n }\n\n if (\n !bambooTokenMatches(\n queryToken,\n indexedToken\n )\n ) {\n continue;\n }\n\n if (indexes.length > 180) continue;\n\n for (const index of indexes) {\n candidateVotes.set(\n index,\n (candidateVotes.get(index) || 0) + 0.75\n );\n }\n }\n }\n }\n }\n\n const candidateIndexes = Array.from(candidateVotes.entries())\n .sort((a, b) => b[1] - a[1])\n .slice(0, 180)\n .map(([index]) => index);\n\n let best = null;\n let second = null;\n\n for (const index of candidateIndexes) {\n const record = bambooSearch.records[index];\n let bestScoreForEmployee = 0;\n let bestBankName = '';\n let bestAlias = '';\n\n for (const currentBankName of names) {\n for (const alias of record.aliases) {\n const score = nameSimilarityScore(\n currentBankName,\n alias.normalized\n );\n\n if (score > bestScoreForEmployee) {\n bestScoreForEmployee = score;\n bestBankName = currentBankName;\n bestAlias = alias.raw;\n }\n }\n }\n\n if (bestScoreForEmployee <= 0) continue;\n\n const candidate = {\n employee: record.employee,\n score: bestScoreForEmployee,\n bank_name: bestBankName,\n bamboo_alias: bestAlias,\n };\n\n if (!best || candidate.score > best.score) {\n second = best;\n best = candidate;\n } else if (!second || candidate.score > second.score) {\n second = candidate;\n }\n }\n\n let result;\n\n if (\n best &&\n best.score >= 0.78 &&\n (!second || best.score - second.score >= 0.05)\n ) {\n result = {\n found: true,\n matched_by:\n normalizeName(best.bank_name) ===\n normalizeName(best.bamboo_alias)\n ? 'exact_name'\n : 'strong_name',\n confidence: best.score,\n employee: best.employee,\n bank_name: best.bank_name,\n bamboo_alias: best.bamboo_alias,\n };\n } else {\n result = {\n found: false,\n matched_by: null,\n confidence: best?.score || 0,\n employee: null,\n ambiguous: Boolean(\n best &&\n second &&\n best.score >= 0.7 &&\n best.score - second.score < 0.05\n ),\n best_candidate: best || null,\n };\n }\n\n bambooMatchCache.set(cacheKey, result);\n return result;\n}\n\nfunction supplementKey(supplement) {\n return [\n supplement.source_sheet || '',\n supplement.row_number || '',\n supplement.supplement_id || '',\n supplement.account || '',\n supplement.payroll_amount || 0,\n ].join('|');\n}\n\nconst payrollAccounts = (data.payroll?.grouped_by_account || [])\n .map((row) => ({\n ...row,\n group_key:\n row.group_key ||\n `${normalizeAccount(row.account)}:${row.currency || 'TTD'}`,\n account: normalizeAccount(row.account),\n employee_name: row.employee_name || row.employee || '',\n employee_number: row.employee_number || row.employeeNumber || '',\n currency: row.currency || 'TTD',\n payroll_amount: roundMoney(row.payroll_amount || row.payrollAmount),\n source_rows: Array.isArray(row.source_rows) ? [...row.source_rows] : [],\n source_sheets: Array.isArray(row.source_sheets)\n ? [...row.source_sheets]\n : [],\n }))\n .filter((row) => row.account && row.payroll_amount > 0);\n\nconst payrollNoAccountRows = (data.payroll?.no_account_rows || [])\n .map((row) => ({\n ...row,\n account: '',\n employee_name: row.employee_name || row.employee || '',\n employee_number: row.employee_number || row.employeeNumber || '',\n currency: row.currency || 'TTD',\n payroll_amount: roundMoney(row.payroll_amount || row.payrollAmount),\n }))\n .filter((row) => row.payroll_amount > 0);\n\nconst bankAccounts = (data.bank?.grouped_by_account || [])\n .map((row) => ({\n ...row,\n group_key:\n row.group_key ||\n `ACCOUNT:${normalizeAccount(row.account)}:${row.currency || 'TTD'}`,\n account: normalizeAccount(row.account),\n account_is_valid: Boolean(row.account_is_valid),\n currency: row.currency || 'TTD',\n amount: roundMoney(row.amount || row.bank_amount || row.bankAmount),\n source_rows: Array.isArray(row.source_rows) ? [...row.source_rows] : [],\n }))\n .filter((row) => row.amount > 0);\n\nconst rawBambooValidationEmployees =\n Array.isArray(data.bamboo?.employees)\n ? data.bamboo.employees\n : [];\n\nconst bambooEmployees =\n rawBambooValidationEmployees.filter(\n isBambooValidationEligible\n );\n\nconst excludedBambooValidationEmployees =\n rawBambooValidationEmployees\n .filter(\n (employee) =>\n !isBambooValidationEligible(employee)\n )\n .map((employee) => ({\n bamboo_id:\n employee.bamboo_id || '',\n employee_number:\n employee.employee_number || '',\n full_name:\n employee.full_name || '',\n country:\n employee.country || '',\n location:\n employee.location || '',\n status:\n employee.status || '',\n overlaps_period:\n Boolean(employee.overlaps_period),\n validation_scope:\n employee.validation_scope || '',\n }));\n\nconst bambooValidationAvailable =\n data.bamboo?.fetch_complete === true &&\n data.bamboo?.validation_available === true &&\n bambooEmployees.length > 0;\n\nconst bambooValidationWarning =\n bambooValidationAvailable\n ? null\n : (\n data.errors?.find((error) =>\n String(error || '').toLowerCase().includes('bamboohr')\n ) ||\n 'La validación Banco sin Bamboo no estuvo disponible porque la descarga de empleados de BambooHR quedó incompleta.'\n );\n\nconst bambooSearch = buildBambooSearchIndex(\n bambooEmployees\n);\n\nconst bankDetailRows = Array.isArray(data.bank?.rows)\n ? data.bank.rows\n : [];\n\nconst potentialSupplements = (\n data.payroll?.potential_supplements ||\n data.debug_payroll?.potential_supplements ||\n data.debug_payroll?.attached_supplements ||\n []\n)\n .map((row) => ({\n ...row,\n account: normalizeAccount(row.account),\n currency: row.currency || 'TTD',\n payroll_amount: roundMoney(row.payroll_amount || row.payrollAmount),\n }))\n .filter((row) => {\n const id = normalizeName(row.supplement_id || '');\n\n return (\n row.account &&\n row.payroll_amount >= 10 &&\n !id.includes('back up')\n );\n });\n\nconst supplementsByAccountCurrency = new Map();\n\nfor (const supplement of potentialSupplements) {\n const key = `${supplement.account}:${supplement.currency}`;\n const current = supplementsByAccountCurrency.get(key) || [];\n\n current.push(supplement);\n supplementsByAccountCurrency.set(key, current);\n}\n\nfunction chooseConditionalSupplements(payroll, bank) {\n const baseAmount = roundMoney(payroll.payroll_amount);\n const bankAmount = roundMoney(bank.amount);\n const candidates =\n supplementsByAccountCurrency.get(\n `${payroll.account}:${payroll.currency}`\n ) || [];\n\n if (\n !candidates.length ||\n bankAmount <= baseAmount + 0.02\n ) {\n return {\n selected: [],\n effectiveAmount: baseAmount,\n baseAmount,\n improvement: 0,\n };\n }\n\n const baseDifference = Math.abs(baseAmount - bankAmount);\n let bestSelected = [];\n let bestAmount = baseAmount;\n let bestDifference = baseDifference;\n\n if (candidates.length <= 12) {\n const combinations = 1 << candidates.length;\n\n for (let mask = 1; mask < combinations; mask++) {\n const selected = [];\n let selectedTotal = 0;\n\n for (let index = 0; index < candidates.length; index++) {\n if ((mask & (1 << index)) !== 0) {\n selected.push(candidates[index]);\n selectedTotal = roundMoney(\n selectedTotal + candidates[index].payroll_amount\n );\n }\n }\n\n const candidateAmount = roundMoney(baseAmount + selectedTotal);\n const candidateDifference = Math.abs(\n candidateAmount - bankAmount\n );\n\n if (candidateDifference < bestDifference) {\n bestSelected = selected;\n bestAmount = candidateAmount;\n bestDifference = candidateDifference;\n }\n }\n } else {\n const sorted = [...candidates].sort(\n (a, b) => b.payroll_amount - a.payroll_amount\n );\n\n let runningAmount = baseAmount;\n const selected = [];\n\n for (const candidate of sorted) {\n const nextAmount = roundMoney(\n runningAmount + candidate.payroll_amount\n );\n\n if (\n Math.abs(nextAmount - bankAmount) <\n Math.abs(runningAmount - bankAmount)\n ) {\n selected.push(candidate);\n runningAmount = nextAmount;\n }\n }\n\n bestSelected = selected;\n bestAmount = runningAmount;\n bestDifference = Math.abs(bestAmount - bankAmount);\n }\n\n const improvement = roundMoney(\n baseDifference - bestDifference\n );\n\n // Evita sumar valores accidentales o inmateriales, como un \"Asignado\" de Q1.\n if (!bestSelected.length || improvement < 5) {\n return {\n selected: [],\n effectiveAmount: baseAmount,\n baseAmount,\n improvement: 0,\n };\n }\n\n return {\n selected: bestSelected,\n effectiveAmount: roundMoney(bestAmount),\n baseAmount,\n improvement,\n };\n}\n\nfunction getDirectCandidates(payroll, matchedBankKeys) {\n return bankAccounts\n .filter((bank) => {\n if (matchedBankKeys.has(bank.group_key)) return false;\n if (bank.currency !== payroll.currency) return false;\n\n const relationship = accountRelationship(\n payroll.account,\n bank.account\n );\n\n if (!relationship.matches) return false;\n\n // Un sufijo de referencia solamente es válido cuando el nombre también\n // corresponde a la misma persona.\n if (\n relationship.type === 'reference_prefix' &&\n !bankMatchesName(bank, payroll.employee_name)\n ) {\n return false;\n }\n\n return true;\n })\n .map((bank) => {\n const relationship = accountRelationship(\n payroll.account,\n bank.account\n );\n const supplementDecision =\n chooseConditionalSupplements(payroll, bank);\n\n return {\n bank,\n relationship,\n supplementDecision,\n nameMatches: bankMatchesName(bank, payroll.employee_name),\n };\n })\n .sort((a, b) => {\n const exactDifference =\n Number(b.relationship.type === 'exact') -\n Number(a.relationship.type === 'exact');\n\n if (exactDifference !== 0) return exactDifference;\n\n const nameDifference =\n Number(b.nameMatches) - Number(a.nameMatches);\n\n if (nameDifference !== 0) return nameDifference;\n\n return (\n Math.abs(\n a.supplementDecision.effectiveAmount - a.bank.amount\n ) -\n Math.abs(\n b.supplementDecision.effectiveAmount - b.bank.amount\n )\n );\n });\n}\n\nfunction buildSources(payroll, selectedSupplements) {\n const supplementRows = selectedSupplements.map((row) => ({\n source_sheet: row.source_sheet,\n row_number: row.row_number,\n amount: row.payroll_amount,\n supplement_original_name:\n row.supplement_original_name || row.employee_name || '',\n supplement_id: row.supplement_id || '',\n applied_conditionally: true,\n }));\n\n const sourceRows = [\n ...(payroll.source_rows || []),\n ...supplementRows,\n ];\n\n const sourceSheets = Array.from(new Set([\n ...(payroll.source_sheets || []),\n ...selectedSupplements\n .map((row) => row.source_sheet)\n .filter(Boolean),\n ]));\n\n return { sourceRows, sourceSheets };\n}\n\nconst matchedPayrollKeys = new Set();\nconst matchedBankKeys = new Set();\nconst matchedNoAccountIndexes = new Set();\nconst appliedSupplementKeys = new Set();\nconst appliedSupplements = [];\nconst finalExactReconciliations = [];\nconst rows = [];\n\nfunction registerSupplements(selected) {\n for (const supplement of selected || []) {\n const key = supplementKey(supplement);\n\n if (!appliedSupplementKeys.has(key)) {\n appliedSupplementKeys.add(key);\n appliedSupplements.push(supplement);\n }\n }\n}\n\n// 1) Cuenta exacta o referencia con prefijo, y monto conciliado.\nfor (const payroll of payrollAccounts) {\n const candidates = getDirectCandidates(\n payroll,\n matchedBankKeys\n ).filter((candidate) => {\n return moneyEquals(\n candidate.supplementDecision.effectiveAmount,\n candidate.bank.amount\n );\n });\n\n if (candidates.length !== 1) continue;\n\n const candidate = candidates[0];\n const bank = candidate.bank;\n const decision = candidate.supplementDecision;\n const sources = buildSources(payroll, decision.selected);\n\n matchedPayrollKeys.add(payroll.group_key);\n matchedBankKeys.add(bank.group_key);\n registerSupplements(decision.selected);\n\n rows.push({\n id: `match_${payroll.group_key}_${bank.group_key}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: payroll.currency,\n payrollAmount: decision.effectiveAmount,\n payroll_amount: decision.effectiveAmount,\n payrollBaseAmount: decision.baseAmount,\n payroll_base_amount: decision.baseAmount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: 0,\n status: 'Coincidencia',\n category: 'coincidencia',\n subcategory:\n candidate.relationship.type === 'reference_prefix'\n ? 'referencia_bancaria_con_prefijo'\n : decision.selected.length\n ? 'cuenta_monto_y_suplemento_condicional'\n : 'cuenta_y_monto_coinciden',\n observation:\n candidate.relationship.type === 'reference_prefix'\n ? 'Conciliado por nombre, monto y referencia bancaria con prefijo.'\n : decision.selected.length\n ? 'Conciliado correctamente. Se aplicó un suplemento porque el banco mostró un pago adicional.'\n : 'Conciliado correctamente.',\n applied_supplements: decision.selected,\n source_sheets: sources.sourceSheets,\n source_rows: sources.sourceRows,\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 2) Cuenta diferente, pero nombre y monto coinciden.\n// Se ejecuta antes de crear diferencias directas para resolver casos como\n// Ashly/Ashley Ramos: la cuenta de la nómina apunta a otra transacción,\n// pero existe otra cuenta bancaria con el mismo nombre y monto correcto.\nfor (const payroll of payrollAccounts) {\n if (matchedPayrollKeys.has(payroll.group_key)) continue;\n\n const candidates = bankAccounts\n .filter((bank) => {\n if (matchedBankKeys.has(bank.group_key)) return false;\n if (bank.currency !== payroll.currency) return false;\n if (!bankMatchesName(bank, payroll.employee_name)) return false;\n\n const decision = chooseConditionalSupplements(\n payroll,\n bank\n );\n\n return moneyEquals(\n decision.effectiveAmount,\n bank.amount\n );\n })\n .map((bank) => ({\n bank,\n supplementDecision: chooseConditionalSupplements(\n payroll,\n bank\n ),\n }));\n\n if (candidates.length !== 1) continue;\n\n const candidate = candidates[0];\n const bank = candidate.bank;\n const decision = candidate.supplementDecision;\n const relationship = accountRelationship(\n payroll.account,\n bank.account\n );\n\n // Las referencias con prefijo ya debieron resolverse en el paso 1.\n if (relationship.type === 'reference_prefix') continue;\n\n const sources = buildSources(payroll, decision.selected);\n\n matchedPayrollKeys.add(payroll.group_key);\n matchedBankKeys.add(bank.group_key);\n registerSupplements(decision.selected);\n\n rows.push({\n id: `possible_wrong_account_${payroll.group_key}_${bank.group_key}`,\n employee: payroll.employee_name || bestBankDisplayName(bank),\n employee_name:\n payroll.employee_name || bestBankDisplayName(bank),\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: payroll.currency,\n payrollAmount: decision.effectiveAmount,\n payroll_amount: decision.effectiveAmount,\n payrollBaseAmount: decision.baseAmount,\n payroll_base_amount: decision.baseAmount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: 0,\n status: 'Riesgo',\n category: 'posible_cuenta_mal_digitada',\n subcategory:\n 'nombre_y_monto_coinciden_cuenta_diferente',\n observation:\n `El nombre y el monto coinciden, pero la cuenta de nómina ` +\n `(${payroll.account || 'sin cuenta'}) es diferente a la cuenta ` +\n `del banco (${bank.account || 'sin cuenta válida'}).`,\n applied_supplements: decision.selected,\n source_sheets: sources.sourceSheets,\n source_rows: sources.sourceRows,\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 3) Nómina sin cuenta válida: conciliar por nombre y monto.\nfor (\n let index = 0;\n index < payrollNoAccountRows.length;\n index++\n) {\n const payroll = payrollNoAccountRows[index];\n\n const candidates = bankAccounts.filter((bank) => {\n if (matchedBankKeys.has(bank.group_key)) return false;\n if (bank.currency !== payroll.currency) return false;\n if (!moneyEquals(bank.amount, payroll.payroll_amount)) {\n return false;\n }\n\n return bankMatchesName(bank, payroll.employee_name);\n });\n\n if (candidates.length !== 1) continue;\n\n const bank = candidates[0];\n\n matchedNoAccountIndexes.add(index);\n matchedBankKeys.add(bank.group_key);\n\n rows.push({\n id: `possible_missing_account_${index}_${bank.group_key}`,\n employee:\n payroll.employee_name || bestBankDisplayName(bank),\n employee_name:\n payroll.employee_name || bestBankDisplayName(bank),\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: bank.account,\n payrollAccount: '',\n payroll_account: '',\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: payroll.currency,\n payrollAmount: payroll.payroll_amount,\n payroll_amount: payroll.payroll_amount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: 0,\n status: 'Riesgo',\n category: 'posible_cuenta_mal_digitada',\n subcategory:\n 'cuenta_faltante_en_nomina_nombre_y_monto_coinciden',\n observation:\n `El nombre y el monto coinciden, pero la nómina no tiene una cuenta bancaria válida registrada. El banco utilizó la cuenta ${bank.account}.`,\n source_sheet: payroll.source_sheet,\n row_number: payroll.row_number,\n source_rows: [\n {\n source_sheet: payroll.source_sheet,\n row_number: payroll.row_number,\n account: '',\n amount: payroll.payroll_amount,\n employee_name: payroll.employee_name,\n },\n ],\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 4) Diferencias reales en una cuenta exacta o equivalente.\nfor (const payroll of payrollAccounts) {\n if (matchedPayrollKeys.has(payroll.group_key)) continue;\n\n const candidates = getDirectCandidates(\n payroll,\n matchedBankKeys\n );\n\n if (!candidates.length) continue;\n\n const candidate = candidates[0];\n const bank = candidate.bank;\n const decision = candidate.supplementDecision;\n const sources = buildSources(payroll, decision.selected);\n const difference = moneyDiff(\n decision.effectiveAmount,\n bank.amount\n );\n\n matchedPayrollKeys.add(payroll.group_key);\n matchedBankKeys.add(bank.group_key);\n registerSupplements(decision.selected);\n\n rows.push({\n id: `difference_${payroll.group_key}_${bank.group_key}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: payroll.currency,\n payrollAmount: decision.effectiveAmount,\n payroll_amount: decision.effectiveAmount,\n payrollBaseAmount: decision.baseAmount,\n payroll_base_amount: decision.baseAmount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference,\n status: 'Riesgo',\n category: 'discrepancia',\n subcategory: 'diferencia_monto',\n observation:\n `Diferencia de ${payroll.currency} ` +\n `${formatMoney(difference)}.`,\n applied_supplements: decision.selected,\n source_sheets: sources.sourceSheets,\n source_rows: sources.sourceRows,\n bank_source_rows: bank.source_rows,\n });\n}\n\n\n// 4.5) Reconciliación final exacta de pares residuales.\n//\n// Este paso corrige casos en los que nómina y banco contienen:\n// - la misma cuenta normalizada;\n// - el mismo empleado;\n// - el mismo monto;\n// pero no fueron enlazados en los pasos anteriores por diferencias técnicas\n// de agrupación, moneda inferida o metadatos del CSV.\n//\n// Es deliberadamente conservador: exige una única contraparte bancaria.\nfor (const payroll of payrollAccounts) {\n if (matchedPayrollKeys.has(payroll.group_key)) continue;\n\n const candidates = bankAccounts\n .filter((bank) => {\n if (matchedBankKeys.has(bank.group_key)) return false;\n\n const payrollAccount = normalizeAccount(payroll.account);\n const bankAccount = normalizeAccount(bank.account);\n\n if (!payrollAccount || payrollAccount !== bankAccount) {\n return false;\n }\n\n if (!bankMatchesName(bank, payroll.employee_name)) {\n return false;\n }\n\n const decision = chooseConditionalSupplements(payroll, bank);\n\n return moneyEquals(\n decision.effectiveAmount,\n bank.amount\n );\n })\n .map((bank) => ({\n bank,\n supplementDecision: chooseConditionalSupplements(\n payroll,\n bank\n ),\n }));\n\n if (candidates.length !== 1) continue;\n\n const candidate = candidates[0];\n const bank = candidate.bank;\n const decision = candidate.supplementDecision;\n const sources = buildSources(\n payroll,\n decision.selected\n );\n\n matchedPayrollKeys.add(payroll.group_key);\n matchedBankKeys.add(bank.group_key);\n registerSupplements(decision.selected);\n\n finalExactReconciliations.push({\n employee_name: payroll.employee_name,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payroll_currency: payroll.currency,\n bank_currency: bank.currency,\n payroll_amount: decision.effectiveAmount,\n bank_amount: bank.amount,\n payroll_group_key: payroll.group_key,\n bank_group_key: bank.group_key,\n });\n\n rows.push({\n id: `final_exact_match_${payroll.group_key}_${bank.group_key}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: bank.currency || payroll.currency,\n payrollAmount: decision.effectiveAmount,\n payroll_amount: decision.effectiveAmount,\n payrollBaseAmount: decision.baseAmount,\n payroll_base_amount: decision.baseAmount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: 0,\n status: 'Coincidencia',\n category: 'coincidencia',\n subcategory: 'reconciliacion_final_cuenta_nombre_monto',\n observation:\n 'Conciliado por cuenta, nombre y monto en la validación final.',\n applied_supplements: decision.selected,\n source_sheets: sources.sourceSheets,\n source_rows: sources.sourceRows,\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 5) Nómina con cuenta sin pago bancario.\nfor (const payroll of payrollAccounts) {\n if (matchedPayrollKeys.has(payroll.group_key)) continue;\n\n rows.push({\n id: `payroll_without_bank_${payroll.group_key}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: '',\n bank_account: '',\n currency: payroll.currency,\n payrollAmount: payroll.payroll_amount,\n payroll_amount: payroll.payroll_amount,\n payrollBaseAmount: payroll.payroll_amount,\n payroll_base_amount: payroll.payroll_amount,\n bankAmount: 0,\n bank_amount: 0,\n difference: payroll.payroll_amount,\n status: 'Riesgo',\n category: 'discrepancia',\n subcategory: 'nomina_con_cuenta_sin_pago_banco',\n observation:\n 'Está en nómina, pero no aparece pagado en el banco.',\n applied_supplements: [],\n source_sheets: payroll.source_sheets,\n source_rows: payroll.source_rows,\n });\n}\n\n// 6) Banco sin nómina.\nfor (const bank of bankAccounts) {\n if (matchedBankKeys.has(bank.group_key)) continue;\n\n rows.push({\n id: `bank_without_payroll_${bank.group_key}`,\n employee:\n bestBankDisplayName(bank) || 'Pago bancario sin nómina',\n employee_name:\n bestBankDisplayName(bank) || 'Pago bancario sin nómina',\n employeeNumber: '',\n employee_number: '',\n account: bank.account,\n payrollAccount: '',\n payroll_account: '',\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: bank.currency,\n payrollAmount: 0,\n payroll_amount: 0,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: roundMoney(0 - bank.amount),\n status: 'Pendiente revisión',\n category: 'banco_sin_nomina',\n subcategory: 'pago_banco_sin_fila_nomina',\n observation:\n 'Recibió un pago en el banco, pero no aparece en la nómina cargada.',\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 7) Nómina sin cuenta que no pudo conciliarse.\nfor (\n let index = 0;\n index < payrollNoAccountRows.length;\n index++\n) {\n if (matchedNoAccountIndexes.has(index)) continue;\n\n const payroll = payrollNoAccountRows[index];\n\n rows.push({\n id:\n `payroll_without_account_` +\n `${payroll.source_sheet}_${payroll.row_number}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: '',\n payrollAccount: '',\n payroll_account: '',\n bankAccount: '',\n bank_account: '',\n currency: payroll.currency,\n payrollAmount: payroll.payroll_amount,\n payroll_amount: payroll.payroll_amount,\n bankAmount: 0,\n bank_amount: 0,\n difference: payroll.payroll_amount,\n status: 'Pendiente revisión',\n category: 'nomina_sin_cuenta',\n subcategory: 'nomina_sin_cuenta_bancaria',\n observation:\n 'Tiene monto en nómina, pero no tiene una cuenta bancaria válida para cruzar contra el banco.',\n source_sheet: payroll.source_sheet,\n row_number: payroll.row_number,\n });\n}\n\n// 8) Consolidar el mismo empleado cuando aparece con dos cuentas de nómina.\nconst originalRows = [...rows];\nconst usedRowIds = new Set();\nconst consolidatedRows = [];\n\nfor (const differenceRow of originalRows) {\n if (\n differenceRow.category !== 'discrepancia' ||\n differenceRow.subcategory !== 'diferencia_monto' ||\n usedRowIds.has(differenceRow.id)\n ) {\n continue;\n }\n\n const extraPayrollRow = originalRows.find((candidate) => {\n if (\n candidate.id === differenceRow.id ||\n usedRowIds.has(candidate.id) ||\n candidate.subcategory !==\n 'nomina_con_cuenta_sin_pago_banco' ||\n candidate.currency !== differenceRow.currency\n ) {\n return false;\n }\n\n const samePerson = samePersonName(\n differenceRow.employee_name || differenceRow.employee,\n candidate.employee_name || candidate.employee\n );\n\n const similarAccounts =\n accountDistance(\n differenceRow.account,\n candidate.account\n ) <= 2;\n\n const combinedPayroll = roundMoney(\n differenceRow.payroll_amount +\n candidate.payroll_amount\n );\n\n const totalMatches = moneyEquals(\n combinedPayroll,\n differenceRow.bank_amount\n );\n\n return samePerson && similarAccounts && totalMatches;\n });\n\n if (!extraPayrollRow) continue;\n\n usedRowIds.add(differenceRow.id);\n usedRowIds.add(extraPayrollRow.id);\n\n const totalPayroll = roundMoney(\n differenceRow.payroll_amount +\n extraPayrollRow.payroll_amount\n );\n\n const accounts = Array.from(new Set([\n differenceRow.account,\n extraPayrollRow.account,\n ].filter(Boolean)));\n\n consolidatedRows.push({\n id:\n `split_account_` +\n `${differenceRow.account}_${extraPayrollRow.account}`,\n employee: differenceRow.employee_name,\n employee_name: differenceRow.employee_name,\n employeeNumber:\n differenceRow.employee_number ||\n extraPayrollRow.employee_number ||\n '',\n employee_number:\n differenceRow.employee_number ||\n extraPayrollRow.employee_number ||\n '',\n account:\n differenceRow.bank_account ||\n differenceRow.account,\n payrollAccount: accounts.join(' / '),\n payroll_account: accounts.join(' / '),\n bankAccount: differenceRow.bank_account,\n bank_account: differenceRow.bank_account,\n currency: differenceRow.currency,\n payrollAmount: totalPayroll,\n payroll_amount: totalPayroll,\n bankAmount: differenceRow.bank_amount,\n bank_amount: differenceRow.bank_amount,\n difference: moneyDiff(\n totalPayroll,\n differenceRow.bank_amount\n ),\n status: 'Riesgo',\n category: 'posible_cuenta_mal_digitada',\n subcategory:\n 'mismo_empleado_con_cuentas_distintas_en_nomina',\n observation:\n `El total de nómina coincide con el banco, pero el empleado ` +\n `aparece con cuentas distintas en la nómina: ` +\n `${accounts.join(' y ')}. La cuenta utilizada por el banco ` +\n `fue ${differenceRow.bank_account}.`,\n applied_supplements:\n differenceRow.applied_supplements || [],\n source_sheets: Array.from(new Set([\n ...(differenceRow.source_sheets || []),\n ...(extraPayrollRow.source_sheets || []),\n ])),\n source_rows: [\n ...(differenceRow.source_rows || []),\n ...(extraPayrollRow.source_rows || []),\n ],\n bank_source_rows:\n differenceRow.bank_source_rows || [],\n });\n}\n\nconst coreRows = [\n ...originalRows.filter(\n (row) => !usedRowIds.has(row.id)\n ),\n ...consolidatedRows,\n];\n\nconst coreCoincidencias = coreRows.filter(\n (row) => row.category === 'coincidencia'\n).length;\n\nconst coreDiscrepancias = coreRows.filter(\n (row) => row.category === 'discrepancia'\n).length;\n\nconst coreBancoSinNomina = coreRows.filter(\n (row) => row.category === 'banco_sin_nomina'\n).length;\n\nconst coreNominaSinCuenta = coreRows.filter(\n (row) => row.category === 'nomina_sin_cuenta'\n).length;\n\nconst corePosiblesCuentas = coreRows.filter(\n (row) => row.category === 'posible_cuenta_mal_digitada'\n).length;\n\nconst linkedPayrollNamesByBankRow = new Map();\nconst linkedPayrollNumbersByBankRow = new Map();\n\nfor (const reconciliationRow of coreRows) {\n const linkedName =\n reconciliationRow.employee_name ||\n reconciliationRow.employee ||\n '';\n const linkedEmployeeNumber = normalizeAccount(\n reconciliationRow.employee_number ||\n reconciliationRow.employeeNumber ||\n ''\n );\n\n for (\n const bankSourceRow of\n reconciliationRow.bank_source_rows || []\n ) {\n const rowKey = bankRowKey(bankSourceRow);\n\n const names =\n linkedPayrollNamesByBankRow.get(rowKey) || [];\n const numbers =\n linkedPayrollNumbersByBankRow.get(rowKey) || [];\n\n if (linkedName) names.push(linkedName);\n if (linkedEmployeeNumber.length >= 6) {\n numbers.push(linkedEmployeeNumber);\n }\n\n linkedPayrollNamesByBankRow.set(\n rowKey,\n Array.from(new Set(names))\n );\n linkedPayrollNumbersByBankRow.set(\n rowKey,\n Array.from(new Set(numbers))\n );\n }\n}\n\nconst bambooMatchDetails = [];\nconst bambooExcludedPayments = [];\nconst bankWithoutBambooMap = new Map();\n\nif (bambooValidationAvailable) {\nfor (const bankRow of bankDetailRows) {\n if (isClearlyNonEmployeePayment(bankRow)) {\n bambooExcludedPayments.push({\n source_file: bankRow.source_file,\n row_number: bankRow.row_number,\n reason: 'pago_no_empleado_identificado',\n bank_name_file: bankRow.bank_name_file,\n bank_account_holder:\n bankRow.bank_account_holder,\n amount: bankRow.amount,\n currency: bankRow.currency,\n });\n continue;\n }\n\n const match = findBambooMatch(bankRow);\n\n if (match.found) {\n bambooMatchDetails.push({\n source_file: bankRow.source_file,\n row_number: bankRow.row_number,\n account: bankRow.account,\n amount: bankRow.amount,\n currency: bankRow.currency,\n bank_name_file: bankRow.bank_name_file,\n bank_account_holder:\n bankRow.bank_account_holder,\n matched_by: match.matched_by,\n confidence: roundMoney(match.confidence),\n bamboo_employee_number:\n match.employee?.employee_number || '',\n bamboo_employee_name:\n match.employee?.full_name || '',\n bamboo_status:\n match.employee?.status || '',\n bamboo_country:\n match.employee?.country || '',\n bamboo_location:\n match.employee?.location || '',\n bamboo_validation_scope:\n match.employee?.validation_scope || '',\n bamboo_overlaps_period:\n Boolean(match.employee?.overlaps_period),\n });\n continue;\n }\n\n const displayName =\n bankRow.bank_name_file ||\n bankRow.bank_account_holder ||\n 'Pago bancario sin empleado identificado';\n\n const groupingKey = [\n normalizeAccount(bankRow.account),\n normalizeName(displayName),\n bankRow.currency || 'TTD',\n ].join('|');\n\n const current =\n bankWithoutBambooMap.get(groupingKey) || {\n id: `bank_without_bamboo_${groupingKey}`,\n employee: displayName,\n employee_name: displayName,\n bank_name_file:\n bankRow.bank_name_file || '',\n bank_account_holder:\n bankRow.bank_account_holder || '',\n account: normalizeAccount(bankRow.account),\n bankAccount: normalizeAccount(bankRow.account),\n bank_account: normalizeAccount(bankRow.account),\n currency: bankRow.currency || 'TTD',\n bankAmount: 0,\n bank_amount: 0,\n shipment_numbers: new Set(),\n references: new Set(),\n source_files: new Set(),\n source_rows: [],\n status: 'Pendiente revisión',\n category: 'banco_sin_bamboo',\n subcategory:\n 'pago_bancario_sin_empleado_bamboohr_tt',\n observation:\n 'Se encontró un pago en el banco, pero no se encontró una coincidencia confiable con un empleado de Trinidad y Tobago en BambooHR.',\n best_bamboo_candidate:\n match.best_candidate\n ? {\n employee_number:\n match.best_candidate.employee\n ?.employee_number || '',\n employee_name:\n match.best_candidate.employee\n ?.full_name || '',\n score: roundMoney(\n match.best_candidate.score\n ),\n }\n : null,\n ambiguous_bamboo_match:\n Boolean(match.ambiguous),\n };\n\n current.bankAmount = roundMoney(\n current.bankAmount +\n Number(bankRow.amount || 0)\n );\n current.bank_amount = current.bankAmount;\n\n if (bankRow.shipment_number) {\n current.shipment_numbers.add(\n bankRow.shipment_number\n );\n }\n\n if (bankRow.reference) {\n current.references.add(bankRow.reference);\n }\n\n if (bankRow.source_file) {\n current.source_files.add(\n bankRow.source_file\n );\n }\n\n current.source_rows.push(bankRow);\n bankWithoutBambooMap.set(\n groupingKey,\n current\n );\n}\n}\n\nconst bankWithoutBamboo = Array.from(\n bankWithoutBambooMap.values()\n).map((row) => ({\n ...row,\n shipment_numbers: Array.from(\n row.shipment_numbers\n ),\n references: Array.from(row.references),\n source_files: Array.from(row.source_files),\n difference: roundMoney(\n 0 - row.bank_amount\n ),\n}));\n\nconst nameDifferenceMap = new Map();\n\nfor (const reconciliationRow of coreRows) {\n const payrollName = String(\n reconciliationRow.employee_name ||\n reconciliationRow.employee ||\n ''\n ).trim();\n\n if (!payrollName) continue;\n\n for (\n const bankSourceRow of\n reconciliationRow.bank_source_rows || []\n ) {\n const bankName = String(\n bankSourceRow.bank_name_file ||\n bankSourceRow.participant_name ||\n bankSourceRow.bank_account_holder ||\n ''\n ).trim();\n\n if (\n !bankName ||\n samePersonName(payrollName, bankName)\n ) {\n continue;\n }\n\n const account = normalizeAccount(\n bankSourceRow.account ||\n reconciliationRow.bank_account ||\n reconciliationRow.bankAccount ||\n reconciliationRow.account ||\n ''\n );\n\n const key = [\n normalizeName(payrollName),\n normalizeName(bankName),\n account,\n bankSourceRow.source_file || '',\n bankSourceRow.row_number || '',\n ].join('|');\n\n if (nameDifferenceMap.has(key)) {\n continue;\n }\n\n nameDifferenceMap.set(key, {\n id: `bank_name_difference_${key}`,\n employee: payrollName,\n employee_name: payrollName,\n payroll_name: payrollName,\n bank_name: bankName,\n employeeNumber:\n reconciliationRow.employee_number ||\n reconciliationRow.employeeNumber ||\n '',\n employee_number:\n reconciliationRow.employee_number ||\n reconciliationRow.employeeNumber ||\n '',\n account,\n payrollAccount:\n reconciliationRow.payroll_account ||\n reconciliationRow.payrollAccount ||\n '',\n payroll_account:\n reconciliationRow.payroll_account ||\n reconciliationRow.payrollAccount ||\n '',\n bankAccount: account,\n bank_account: account,\n currency:\n bankSourceRow.currency ||\n reconciliationRow.currency ||\n 'TTD',\n payrollAmount:\n reconciliationRow.payroll_amount ||\n reconciliationRow.payrollAmount ||\n 0,\n payroll_amount:\n reconciliationRow.payroll_amount ||\n reconciliationRow.payrollAmount ||\n 0,\n bankAmount:\n bankSourceRow.amount || 0,\n bank_amount:\n bankSourceRow.amount || 0,\n difference: 0,\n status: 'Pendiente revisión',\n category: 'diferencia_nombre_banco',\n subcategory:\n 'nombre_nomina_vs_participante_banco',\n observation:\n `El nombre registrado en la nómina (${payrollName}) ` +\n `es diferente al nombre enviado al banco (${bankName}).`,\n bank_name_file: payrollName,\n bank_account_holder: bankName,\n source_file:\n bankSourceRow.source_file || '',\n financial_institution_id:\n bankSourceRow.financial_institution_id || '',\n reference:\n bankSourceRow.reference || '',\n row_number:\n bankSourceRow.row_number || '',\n });\n }\n}\n\nconst nameDifferenceRows = Array.from(\n nameDifferenceMap.values()\n);\n\nfunction priority(row) {\n const category = String(\n row.category || ''\n ).toLowerCase();\n\n if (category === 'posible_cuenta_mal_digitada') return 1;\n if (category === 'discrepancia') return 2;\n if (category === 'banco_sin_nomina') return 3;\n if (category === 'nomina_sin_cuenta') return 4;\n if (category === 'diferencia_nombre_banco') return 5;\n if (category === 'coincidencia') return 99;\n\n return 50;\n}\n\nconst rowsFinales = [\n ...coreRows,\n ...nameDifferenceRows,\n].sort((a, b) => {\n const priorityDifference =\n priority(a) - priority(b);\n\n if (priorityDifference !== 0) {\n return priorityDifference;\n }\n\n return String(\n a.employee_name || ''\n ).localeCompare(\n String(b.employee_name || ''),\n 'es'\n );\n});\n\nconst appliedSupplementsTotal = roundMoney(\n appliedSupplements.reduce(\n (sum, row) => sum + row.payroll_amount,\n 0\n )\n);\n\nconst totalNominaBase = roundMoney(\n data.payroll?.total_amount || 0\n);\n\nconst totalNomina = roundMoney(\n totalNominaBase + appliedSupplementsTotal\n);\n\nconst totalBanco = roundMoney(\n data.bank?.total_amount || 0\n);\n\nconst diferenciasNombreBanco =\n nameDifferenceRows.length;\n\nconst pendientes =\n coreDiscrepancias +\n coreBancoSinNomina +\n coreNominaSinCuenta +\n corePosiblesCuentas +\n bankWithoutBamboo.length +\n diferenciasNombreBanco;\n\nconst unusedPotentialSupplements =\n potentialSupplements.filter((row) => {\n return !appliedSupplementKeys.has(\n supplementKey(row)\n );\n });\n\nreturn [\n {\n json: {\n ok: true,\n stage: 'cruce_nomina_tt_banco',\n errors: [],\n metadata: data.metadata || {},\n summary: {\n coincidencias: coreCoincidencias,\n // La tarjeta de la app agrupa todos los casos de discrepancia/riesgo.\n // Se conserva el detalle puro en discrepanciasMontoPago.\n discrepancias:\n coreDiscrepancias + corePosiblesCuentas,\n discrepanciasMontoPago:\n coreDiscrepancias,\n bancoSinNomina: coreBancoSinNomina,\n bancoSinBamboo: bankWithoutBamboo.length,\n nominaSinCuenta: coreNominaSinCuenta,\n diferenciasNombreBanco,\n posiblesCuentasMalDigitadas:\n corePosiblesCuentas,\n totalResultados:\n coreCoincidencias +\n coreDiscrepancias +\n coreBancoSinNomina +\n coreNominaSinCuenta +\n corePosiblesCuentas +\n bankWithoutBamboo.length +\n diferenciasNombreBanco,\n pendientes,\n filasNominaValidas:\n data.payroll?.valid_rows_count || 0,\n filasNominaSinCuenta:\n data.payroll?.no_account_rows_count || 0,\n suplementosPotenciales:\n potentialSupplements.length,\n suplementosNominaAplicados:\n appliedSupplements.length,\n suplementosNominaNoAplicados:\n unusedPotentialSupplements.length,\n suplementosNominaAdjuntados:\n appliedSupplements.length,\n suplementosNominaNoAdjuntados:\n data.payroll?.unattached_supplements_count || 0,\n reconciliacionesExactasFinales:\n finalExactReconciliations.length,\n cuentasNominaAgrupadas:\n payrollAccounts.length,\n transaccionesBanco:\n data.bank?.rows_count || 0,\n cuentasBancoAgrupadas:\n bankAccounts.length,\n empleadosBambooTT:\n Number(\n data.bamboo?.trinidad_tobago_count ||\n bambooEmployees.length\n ),\n empleadosBambooEnPeriodo:\n Number(\n data.bamboo?.active_in_period_count || 0\n ),\n bambooPaginasDescargadas:\n Number(\n data.bamboo?.pages_fetched || 0\n ),\n bambooEmpleadosEsperados:\n Number(\n data.bamboo?.expected_total || 0\n ),\n bambooDescargaCompleta:\n Boolean(\n data.bamboo?.fetch_complete\n ),\n bambooValidacionDisponible:\n bambooValidationAvailable,\n totalNominaBase,\n totalSuplementosAplicados:\n appliedSupplementsTotal,\n totalNomina,\n totalBanco,\n diferenciaTotal:\n moneyDiff(totalNomina, totalBanco),\n },\n rows: rowsFinales,\n bankWithoutBamboo,\n nameDifferences: nameDifferenceRows,\n bambooSummary: data.bamboo || {},\n reportUrl: null,\n debug: {\n sheet_summaries:\n data.payroll?.sheet_summaries || [],\n potential_supplements:\n potentialSupplements,\n applied_supplements:\n appliedSupplements,\n final_exact_reconciliations:\n finalExactReconciliations,\n bamboo_search:\n {\n employees_received:\n rawBambooValidationEmployees.length,\n employees_indexed:\n bambooSearch.records.length,\n employees_excluded:\n excludedBambooValidationEmployees.length,\n excluded_employees:\n excludedBambooValidationEmployees,\n exact_aliases:\n bambooSearch.exactAliasMap.size,\n indexed_tokens:\n bambooSearch.tokenIndex.size,\n cache_entries:\n bambooMatchCache.size,\n },\n bamboo_matches:\n bambooMatchDetails,\n bamboo_excluded_payments:\n bambooExcludedPayments,\n bamboo_validation_available:\n bambooValidationAvailable,\n bamboo_validation_warning:\n bambooValidationWarning,\n banco_sin_bamboo:\n bankWithoutBamboo,\n unused_potential_supplements:\n unusedPotentialSupplements,\n unattached_supplements:\n data.debug_payroll?.unattached_supplements || [],\n payroll_preview:\n payrollAccounts.slice(0, 10),\n bank_preview:\n bankAccounts.slice(0, 10),\n payroll_no_account_preview:\n payrollNoAccountRows.slice(0, 10),\n },\n },\n },\n];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 6224, - 7296 - ], - "id": "ea4d4d89-a2f9-4c3f-9c53-803706173b27", - "name": "Cruzar Nómina vs Banco" - }, - { - "parameters": { - "jsCode": "const data = $input.first().json || {};\n\nfunction normalizeText(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\u00A0/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction roundMoney(value) {\n return Math.round(\n (Number(value) || 0) * 100\n ) / 100;\n}\n\nfunction firstValue(value) {\n if (Array.isArray(value)) {\n return value\n .map(normalizeText)\n .filter(Boolean)\n .join(' / ');\n }\n\n return normalizeText(value);\n}\n\nfunction formatPeriodEnd(value) {\n const raw = normalizeText(value);\n\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(raw)) {\n return raw;\n }\n\n const [year, month, day] = raw.split('-');\n\n const monthNames = {\n '01': 'ene',\n '02': 'feb',\n '03': 'mar',\n '04': 'abr',\n '05': 'may',\n '06': 'jun',\n '07': 'jul',\n '08': 'ago',\n '09': 'sep',\n '10': 'oct',\n '11': 'nov',\n '12': 'dic',\n };\n\n return `${day}-${monthNames[month] || month}-${year}`;\n}\n\nfunction mainReportSense(row, difference) {\n const subcategory = normalizeText(\n row.subcategory\n ).toLowerCase();\n\n const payrollAmount = Number(\n row.payroll_amount ??\n row.payrollAmount ??\n 0\n );\n\n const bankAmount = Number(\n row.bank_amount ??\n row.bankAmount ??\n 0\n );\n\n if (\n subcategory ===\n 'nomina_con_cuenta_sin_pago_banco' ||\n (bankAmount === 0 && payrollAmount > 0)\n ) {\n return 'No aparece pagado en banco';\n }\n\n if (difference > 0) {\n return 'Se pagó de menos';\n }\n\n if (difference < 0) {\n return 'Se pagó de más';\n }\n\n return 'Revisar';\n}\n\nfunction accountValues(value) {\n const values = Array.isArray(value)\n ? value\n : String(value ?? '')\n .split(/\\s*(?:\\/|;|,|\\by\\b)\\s*/i);\n\n return values\n .map((item) =>\n String(item ?? '')\n .replace(/\\u00A0/g, '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim()\n )\n .filter(\n (account) =>\n account.length >= 6 &&\n !/^0+$/.test(account)\n );\n}\n\nfunction payrollAccountsForWrongAccount(row) {\n const candidates = [\n row.payroll_account,\n row.payrollAccount,\n ...(Array.isArray(row.source_rows)\n ? row.source_rows.flatMap(\n (sourceRow) => [\n sourceRow.account,\n sourceRow.payroll_account,\n sourceRow.payrollAccount,\n ]\n )\n : []),\n ];\n\n return Array.from(\n new Set(\n candidates.flatMap(accountValues)\n )\n );\n}\n\nfunction bankAccountForWrongAccount(row) {\n return firstValue(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n );\n}\n\nfunction moneyLabel(value) {\n return Math.abs(\n roundMoney(value)\n ).toLocaleString('en-US', {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n });\n}\n\nfunction wrongAccountTotalStatus(row) {\n const payrollAmount = roundMoney(\n row.payroll_amount ??\n row.payrollAmount ??\n 0\n );\n\n const bankAmount = roundMoney(\n row.bank_amount ??\n row.bankAmount ??\n 0\n );\n\n const difference = roundMoney(\n payrollAmount - bankAmount\n );\n\n if (Math.abs(difference) <= 0.02) {\n return (\n 'El total de nómina coincide con ' +\n 'el total pagado por el banco.'\n );\n }\n\n if (difference > 0) {\n return (\n 'El total de nómina supera el total ' +\n `del banco por TT$${moneyLabel(difference)}.`\n );\n }\n\n return (\n 'El total pagado por el banco supera ' +\n `el total de nómina por TT$${moneyLabel(difference)}.`\n );\n}\n\nfunction wrongAccountFinding(row) {\n const existing = normalizeText(\n row.observation || ''\n );\n\n if (existing) return existing;\n\n const payrollAccounts =\n payrollAccountsForWrongAccount(row);\n\n const bankAccount =\n bankAccountForWrongAccount(row);\n\n return (\n 'El empleado presenta una posible ' +\n 'inconsistencia entre la cuenta registrada ' +\n `en nómina (${payrollAccounts.join(' y ') || 'sin cuenta identificada'}) ` +\n `y la cuenta utilizada por el banco (${bankAccount || 'sin cuenta identificada'}).`\n );\n}\n\nconst metadata = data.metadata || {};\nconst summary = data.summary || {};\n\nconst rows = Array.isArray(data.rows)\n ? data.rows\n : [];\n\nconst bankWithoutBamboo =\n Array.isArray(data.bankWithoutBamboo)\n ? data.bankWithoutBamboo\n : [];\n\nconst periodLabel =\n metadata.period_label ||\n `${metadata.year || ''}-${metadata.month || ''}-${metadata.period_type || ''}`;\n\nconst periodEndLabel = formatPeriodEnd(\n metadata.period_end || ''\n);\n\nconst spreadsheetTitle =\n `Cruce de Cuentas GLM TT - ${periodLabel}`;\n\nconst sheetIds = {\n nominaVsBanco: 201,\n bancoSinNomina: 202,\n bancoSinBamboo: 203,\n diferenciasNombreBanco: 204,\n cuentaMalDigitada: 205,\n resumen: 206,\n};\n\nconst cuentaMalDigitadaCases = rows.filter(\n (row) =>\n row.category ===\n 'posible_cuenta_mal_digitada'\n);\n\nconst hasCuentaMalDigitada =\n cuentaMalDigitadaCases.length > 0;\n\nconst sheetTitles = {\n nominaVsBanco:\n '01 Nómina vs Banco',\n bancoSinNomina:\n '02 Banco sin Nómina',\n bancoSinBamboo:\n '03 Banco sin Bamboo',\n diferenciasNombreBanco:\n '04 Diferencias de Nombre',\n cuentaMalDigitada:\n '05 Cuenta Mal Digitada',\n resumen: hasCuentaMalDigitada\n ? '06 Resumen'\n : '05 Resumen',\n};\n\nconst mainRows = rows\n .filter(\n (row) =>\n row.category === 'discrepancia'\n )\n .map((row, index) => {\n const payrollAmount = roundMoney(\n row.payroll_amount ??\n row.payrollAmount ??\n 0\n );\n\n const bankAmount = roundMoney(\n row.bank_amount ??\n row.bankAmount ??\n 0\n );\n\n const difference = roundMoney(\n row.difference ??\n (payrollAmount - bankAmount)\n );\n\n return [\n index + 1,\n normalizeText(\n row.employee_name ||\n row.employee ||\n ''\n ),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.payroll_account ||\n row.payrollAccount ||\n row.account ||\n ''\n ),\n payrollAmount,\n bankAmount,\n difference,\n mainReportSense(\n row,\n difference\n ),\n normalizeText(\n row.status || 'Riesgo'\n ).toUpperCase(),\n '',\n ];\n });\n\nconst bancoSinNominaRows = rows\n .filter(\n (row) =>\n row.category ===\n 'banco_sin_nomina'\n )\n .map((row, index) => [\n index + 1,\n normalizeText(\n row.employee_name ||\n row.employee ||\n ''\n ),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n roundMoney(\n row.bank_amount ??\n row.bankAmount ??\n 0\n ),\n firstValue(\n row.source_files ||\n row.source_file ||\n ''\n ),\n normalizeText(\n row.status ||\n 'Pendiente revisión'\n ).toUpperCase(),\n normalizeText(\n row.observation || ''\n ),\n '',\n ]);\n\nconst bancoSinBambooRows =\n bankWithoutBamboo.map(\n (row, index) => [\n index + 1,\n normalizeText(\n row.bank_name_file ||\n row.employee_name ||\n row.employee ||\n ''\n ),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n roundMoney(\n row.bank_amount ??\n row.bankAmount ??\n 0\n ),\n firstValue(\n row.source_files ||\n row.source_file ||\n ''\n ),\n 'PENDIENTE REVISIÓN',\n '',\n ]\n );\n\nconst diferenciasNombreRows = rows\n .filter(\n (row) =>\n row.category ===\n 'diferencia_nombre_banco'\n )\n .map((row, index) => [\n index + 1,\n normalizeText(\n row.payroll_name ||\n row.employee_name ||\n row.employee ||\n row.bank_name_file ||\n ''\n ),\n normalizeText(\n row.bank_name ||\n row.bank_account_holder ||\n ''\n ),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n roundMoney(\n row.bank_amount ??\n row.bankAmount ??\n 0\n ),\n normalizeText(\n row.status ||\n 'Pendiente revisión'\n ).toUpperCase(),\n normalizeText(\n row.observation || ''\n ),\n '',\n ]);\n\nconst cuentaMalDigitadaRows = [];\n\ncuentaMalDigitadaCases.forEach(\n (row, index) => {\n const payrollAccounts =\n payrollAccountsForWrongAccount(row);\n\n const bankAccount =\n bankAccountForWrongAccount(row);\n\n const fields = [\n [\n 'Empleado',\n normalizeText(\n row.employee_name ||\n row.employee ||\n ''\n ),\n ],\n [\n 'Cuentas registradas en las hojas de nómina',\n payrollAccounts.join(' y ') ||\n 'No se identificó una cuenta válida en la nómina.',\n ],\n [\n 'Cuenta utilizada por el banco',\n bankAccount ||\n 'No se identificó una cuenta válida en el banco.',\n ],\n [\n 'Estado del total',\n wrongAccountTotalStatus(row),\n ],\n [\n 'Hallazgo',\n wrongAccountFinding(row),\n ],\n [\n 'Clasificación',\n 'Posible cuenta mal digitada — revisar y unificar la cuenta registrada en nómina.',\n ],\n ];\n\n fields.forEach(\n (field, fieldIndex) => {\n cuentaMalDigitadaRows.push([\n fieldIndex === 0\n ? index + 1\n : '',\n field[0],\n field[1],\n '',\n ]);\n }\n );\n }\n);\n\nconst resumenRows = [\n ['Período', periodLabel],\n [\n 'Coincidencias',\n Number(summary.coincidencias || 0),\n ],\n [\n 'Discrepancias de monto o pago',\n Number(\n summary.discrepanciasMontoPago ??\n summary.discrepancias ??\n 0\n ),\n ],\n [\n 'Banco sin nómina',\n Number(summary.bancoSinNomina || 0),\n ],\n [\n 'Banco sin Bamboo',\n Number(summary.bancoSinBamboo || 0),\n ],\n [\n 'Nómina sin cuenta no conciliada',\n Number(summary.nominaSinCuenta || 0),\n ],\n [\n 'Diferencias de nombre',\n Number(\n summary.diferenciasNombreBanco || 0\n ),\n ],\n [\n 'Posibles cuentas mal digitadas',\n Number(\n summary.posiblesCuentasMalDigitadas || 0\n ),\n ],\n [\n 'Pendientes del cruce principal',\n Number(summary.pendientes || 0),\n ],\n [\n 'Empleados BambooHR Trinidad y Tobago',\n Number(summary.empleadosBambooTT || 0),\n ],\n [\n 'Empleados BambooHR en el período',\n Number(\n summary.empleadosBambooEnPeriodo || 0\n ),\n ],\n [\n 'Filas válidas de nómina',\n Number(\n summary.filasNominaValidas || 0\n ),\n ],\n [\n 'Filas de nómina sin cuenta detectadas',\n Number(\n summary.filasNominaSinCuenta || 0\n ),\n ],\n [\n 'Transacciones bancarias',\n Number(\n summary.transaccionesBanco || 0\n ),\n ],\n [\n 'Total nómina',\n roundMoney(summary.totalNomina || 0),\n ],\n [\n 'Total banco',\n roundMoney(summary.totalBanco || 0),\n ],\n [\n 'Diferencia total',\n roundMoney(\n summary.diferenciaTotal || 0\n ),\n ],\n];\n\nfunction reportValues(\n title,\n subtitle,\n header,\n body\n) {\n return [\n [\n title,\n ...Array(\n Math.max(header.length - 1, 0)\n ).fill(''),\n ],\n [\n subtitle,\n ...Array(\n Math.max(header.length - 1, 0)\n ).fill(''),\n ],\n Array(header.length).fill(''),\n header,\n ...body,\n ];\n}\n\nconst nominaVsBancoValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Diferencias de Monto Nómina vs. Banco · Trinidad y Tobago · ${periodEndLabel}`,\n [\n '#',\n 'Empleado',\n 'Cuenta',\n 'Monto en Nómina (TT$)',\n 'Monto en Banco (TT$)',\n 'Diferencia (TT$)',\n 'Sentido',\n 'Estado',\n 'Resolución',\n ],\n mainRows\n );\n\nconst bancoSinNominaValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Pagos bancarios sin registro en la nómina · Trinidad y Tobago · ${periodEndLabel}`,\n [\n '#',\n 'Nombre en banco',\n 'Cuenta',\n 'Monto en banco (TT$)',\n 'Archivo',\n 'Estado',\n 'Observación',\n 'Resolución',\n ],\n bancoSinNominaRows\n );\n\nconst bancoSinBambooValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Pagos en banco sin empleado identificado en BambooHR · Trinidad y Tobago · ${periodEndLabel}`,\n [\n '#',\n 'Nombre en banco',\n 'Cuenta',\n 'Monto en banco (TT$)',\n 'Archivo',\n 'Estado',\n 'Resolución',\n ],\n bancoSinBambooRows\n );\n\nconst diferenciasNombreValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Diferencias de nombre entre nómina y banco · Trinidad y Tobago · ${periodEndLabel}`,\n [\n '#',\n 'Nombre en nómina',\n 'Nombre en banco',\n 'Cuenta',\n 'Monto en banco (TT$)',\n 'Estado',\n 'Observación',\n 'Resolución',\n ],\n diferenciasNombreRows\n );\n\nconst cuentaMalDigitadaValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Cuenta Mal Digitada en Nómina · Trinidad y Tobago · ${periodEndLabel}`,\n ['#', 'Campo', 'Detalle', 'Resolución'],\n cuentaMalDigitadaRows\n );\n\nconst resumenValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Resumen del cruce Nómina vs. Banco · Trinidad y Tobago · ${periodEndLabel}`,\n ['Indicador', 'Valor'],\n resumenRows\n );\n\nconst valueData = [\n {\n range:\n `'${sheetTitles.nominaVsBanco}'!A1:I`,\n values: nominaVsBancoValues,\n },\n {\n range:\n `'${sheetTitles.bancoSinNomina}'!A1:H`,\n values: bancoSinNominaValues,\n },\n {\n range:\n `'${sheetTitles.bancoSinBamboo}'!A1:G`,\n values: bancoSinBambooValues,\n },\n {\n range:\n `'${sheetTitles.diferenciasNombreBanco}'!A1:H`,\n values: diferenciasNombreValues,\n },\n ...(hasCuentaMalDigitada\n ? [\n {\n range:\n `'${sheetTitles.cuentaMalDigitada}'!A1:D`,\n values:\n cuentaMalDigitadaValues,\n },\n ]\n : []),\n {\n range:\n `'${sheetTitles.resumen}'!A1:B`,\n values: resumenValues,\n },\n];\n\nconst brandColor = {\n red: 0.29,\n green: 0.49,\n blue: 0.58,\n};\n\nconst whiteColor = {\n red: 1,\n green: 1,\n blue: 1,\n};\n\nconst borderColor = {\n red: 0.82,\n green: 0.86,\n blue: 0.88,\n};\n\nfunction mergeRow(\n sheetId,\n rowIndex,\n columnCount\n) {\n return {\n mergeCells: {\n range: {\n sheetId,\n startRowIndex: rowIndex,\n endRowIndex: rowIndex + 1,\n startColumnIndex: 0,\n endColumnIndex: columnCount,\n },\n mergeType: 'MERGE_ALL',\n },\n };\n}\n\nfunction formatRange(\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n userEnteredFormat\n) {\n const formatFields =\n Object.keys(userEnteredFormat || {});\n\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n },\n cell: {\n userEnteredFormat,\n },\n fields:\n `userEnteredFormat(${formatFields.join(',')})`,\n },\n };\n}\n\nfunction titleFormat(\n sheetId,\n rowIndex,\n columnCount,\n options = {}\n) {\n return formatRange(\n sheetId,\n rowIndex,\n rowIndex + 1,\n 0,\n columnCount,\n {\n backgroundColor: brandColor,\n textFormat: {\n bold: options.bold ?? true,\n italic:\n options.italic ?? false,\n fontSize:\n options.fontSize ?? 12,\n foregroundColor:\n whiteColor,\n },\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n );\n}\n\nfunction headerFormat(\n sheetId,\n columnCount\n) {\n return formatRange(\n sheetId,\n 3,\n 4,\n 0,\n columnCount,\n {\n backgroundColor: brandColor,\n textFormat: {\n bold: true,\n foregroundColor:\n whiteColor,\n },\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n );\n}\n\nfunction freezeRows(\n sheetId,\n count\n) {\n return {\n updateSheetProperties: {\n properties: {\n sheetId,\n gridProperties: {\n frozenRowCount: count,\n },\n },\n fields:\n 'gridProperties.frozenRowCount',\n },\n };\n}\n\nfunction setFilter(\n sheetId,\n columnCount,\n endRowIndex\n) {\n return {\n setBasicFilter: {\n filter: {\n range: {\n sheetId,\n startRowIndex: 3,\n endRowIndex,\n startColumnIndex: 0,\n endColumnIndex:\n columnCount,\n },\n },\n },\n };\n}\n\nfunction setColumnWidth(\n sheetId,\n index,\n pixelSize\n) {\n return {\n updateDimensionProperties: {\n range: {\n sheetId,\n dimension: 'COLUMNS',\n startIndex: index,\n endIndex: index + 1,\n },\n properties: {\n pixelSize,\n },\n fields: 'pixelSize',\n },\n };\n}\n\nfunction setRowHeight(\n sheetId,\n startIndex,\n endIndex,\n pixelSize\n) {\n return {\n updateDimensionProperties: {\n range: {\n sheetId,\n dimension: 'ROWS',\n startIndex,\n endIndex,\n },\n properties: {\n pixelSize,\n },\n fields: 'pixelSize',\n },\n };\n}\n\nfunction borderFormat(\n sheetId,\n columnCount,\n endRowIndex\n) {\n const border = {\n style: 'SOLID',\n color: borderColor,\n };\n\n return [\n formatRange(\n sheetId,\n 3,\n endRowIndex,\n 0,\n columnCount,\n {\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n {\n updateBorders: {\n range: {\n sheetId,\n startRowIndex: 3,\n endRowIndex,\n startColumnIndex: 0,\n endColumnIndex: columnCount,\n },\n top: border,\n bottom: border,\n left: border,\n right: border,\n innerHorizontal: border,\n innerVertical: border,\n },\n },\n ];\n}\n\nfunction moneyFormat(\n sheetId,\n startColumnIndex,\n endColumnIndex,\n startRowIndex,\n endRowIndex\n) {\n return formatRange(\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n {\n numberFormat: {\n type: 'NUMBER',\n pattern:\n '\"TT$\"#,##0.00',\n },\n horizontalAlignment:\n 'RIGHT',\n verticalAlignment:\n 'MIDDLE',\n }\n );\n}\n\nfunction statusFormat(\n sheetId,\n columnIndex,\n endRowIndex\n) {\n return formatRange(\n sheetId,\n 4,\n endRowIndex,\n columnIndex,\n columnIndex + 1,\n {\n backgroundColor: {\n red: 1,\n green: 0.92,\n blue: 0.92,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.82,\n green: 0.08,\n blue: 0.08,\n },\n },\n horizontalAlignment:\n 'CENTER',\n verticalAlignment:\n 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n );\n}\n\nfunction conditionalDifference(\n sheetId,\n endRowIndex,\n formula,\n backgroundColor,\n textColor\n) {\n return {\n addConditionalFormatRule: {\n rule: {\n ranges: [\n {\n sheetId,\n startRowIndex: 4,\n endRowIndex,\n startColumnIndex: 5,\n endColumnIndex: 6,\n },\n ],\n booleanRule: {\n condition: {\n type: 'CUSTOM_FORMULA',\n values: [\n {\n userEnteredValue:\n formula,\n },\n ],\n },\n format: {\n backgroundColor,\n textFormat: {\n bold: true,\n foregroundColor:\n textColor,\n },\n },\n },\n },\n index: 0,\n },\n };\n}\n\nfunction styleReport(config) {\n const {\n sheetId,\n columnCount,\n bodyRowsCount,\n widths,\n moneyColumns = [],\n statusColumn = null,\n } = config;\n\n const endRowIndex = Math.max(\n 4 + bodyRowsCount,\n 4\n );\n\n const requests = [\n mergeRow(\n sheetId,\n 0,\n columnCount\n ),\n mergeRow(\n sheetId,\n 1,\n columnCount\n ),\n titleFormat(\n sheetId,\n 0,\n columnCount,\n {\n fontSize: 12,\n bold: true,\n }\n ),\n titleFormat(\n sheetId,\n 1,\n columnCount,\n {\n fontSize: 10,\n bold: false,\n italic: true,\n }\n ),\n headerFormat(\n sheetId,\n columnCount\n ),\n freezeRows(sheetId, 4),\n setFilter(\n sheetId,\n columnCount,\n endRowIndex\n ),\n ...borderFormat(\n sheetId,\n columnCount,\n endRowIndex\n ),\n setRowHeight(\n sheetId,\n 0,\n 1,\n 30\n ),\n setRowHeight(\n sheetId,\n 1,\n 2,\n 28\n ),\n setRowHeight(\n sheetId,\n 3,\n 4,\n 42\n ),\n ...widths.map(\n (width, index) =>\n setColumnWidth(\n sheetId,\n index,\n width\n )\n ),\n ];\n\n if (bodyRowsCount > 0) {\n requests.push(\n setRowHeight(\n sheetId,\n 4,\n endRowIndex,\n 30\n )\n );\n\n for (\n const [startColumn, endColumn] of\n moneyColumns\n ) {\n requests.push(\n moneyFormat(\n sheetId,\n startColumn,\n endColumn,\n 4,\n endRowIndex\n )\n );\n }\n\n if (\n Number.isInteger(\n statusColumn\n )\n ) {\n requests.push(\n statusFormat(\n sheetId,\n statusColumn,\n endRowIndex\n )\n );\n }\n }\n\n return requests;\n}\n\n\nfunction wrapRangeRequest(\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex\n) {\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n },\n cell: {\n userEnteredFormat: {\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n },\n },\n fields:\n 'userEnteredFormat(verticalAlignment,wrapStrategy)',\n },\n };\n}\n\nfunction autoResizeRowsRequest(\n sheetId,\n startIndex,\n endIndex\n) {\n return {\n autoResizeDimensions: {\n dimensions: {\n sheetId,\n dimension: 'ROWS',\n startIndex,\n endIndex,\n },\n },\n };\n}\n\nconst formatRequests = [\n ...styleReport({\n sheetId:\n sheetIds.nominaVsBanco,\n columnCount: 9,\n bodyRowsCount:\n mainRows.length,\n widths: [\n 48,\n 250,\n 145,\n 135,\n 135,\n 135,\n 180,\n 120,\n 260,\n ],\n moneyColumns: [\n [3, 6],\n ],\n statusColumn: 7,\n }),\n\n ...(mainRows.length > 0\n ? [\n conditionalDifference(\n sheetIds.nominaVsBanco,\n 4 + mainRows.length,\n '=$F5>0',\n {\n red: 1,\n green: 0.97,\n blue: 0.82,\n },\n {\n red: 0.45,\n green: 0.27,\n blue: 0,\n }\n ),\n conditionalDifference(\n sheetIds.nominaVsBanco,\n 4 + mainRows.length,\n '=$F5<0',\n {\n red: 1,\n green: 0.89,\n blue: 0.89,\n },\n {\n red: 0.85,\n green: 0.05,\n blue: 0.05,\n }\n ),\n ]\n : []),\n\n ...styleReport({\n sheetId:\n sheetIds.bancoSinNomina,\n columnCount: 8,\n bodyRowsCount:\n bancoSinNominaRows.length,\n widths: [\n 48,\n 230,\n 145,\n 135,\n 230,\n 140,\n 360,\n 260,\n ],\n moneyColumns: [[3, 4]],\n statusColumn: 5,\n }),\n\n ...styleReport({\n sheetId:\n sheetIds.bancoSinBamboo,\n columnCount: 7,\n bodyRowsCount:\n bancoSinBambooRows.length,\n widths: [\n 48,\n 250,\n 145,\n 140,\n 250,\n 150,\n 260,\n ],\n moneyColumns: [[3, 4]],\n statusColumn: 5,\n }),\n\n ...styleReport({\n sheetId:\n sheetIds.diferenciasNombreBanco,\n columnCount: 8,\n bodyRowsCount:\n diferenciasNombreRows.length,\n widths: [\n 48,\n 240,\n 240,\n 145,\n 140,\n 150,\n 420,\n 260,\n ],\n moneyColumns: [[4, 5]],\n statusColumn: 5,\n }),\n];\n\nif (hasCuentaMalDigitada) {\n const endRowIndex =\n 4 +\n cuentaMalDigitadaRows.length;\n\n formatRequests.push(\n ...styleReport({\n sheetId:\n sheetIds.cuentaMalDigitada,\n columnCount: 4,\n bodyRowsCount:\n cuentaMalDigitadaRows.length,\n widths: [\n 48,\n 300,\n 520,\n 260,\n ],\n statusColumn: null,\n })\n );\n\n cuentaMalDigitadaCases.forEach(\n (_, caseIndex) => {\n const startRowIndex =\n 4 + caseIndex * 6;\n\n const endCaseRowIndex =\n startRowIndex + 6;\n\n formatRequests.push(\n {\n mergeCells: {\n range: {\n sheetId:\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endRowIndex:\n endCaseRowIndex,\n startColumnIndex: 0,\n endColumnIndex: 1,\n },\n mergeType:\n 'MERGE_ALL',\n },\n },\n {\n mergeCells: {\n range: {\n sheetId:\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endRowIndex:\n endCaseRowIndex,\n startColumnIndex: 3,\n endColumnIndex: 4,\n },\n mergeType:\n 'MERGE_ALL',\n },\n },\n formatRange(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endCaseRowIndex,\n 0,\n 1,\n {\n backgroundColor: {\n red: 0.91,\n green: 0.95,\n blue: 0.99,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.20,\n green: 0.36,\n blue: 0.45,\n },\n },\n horizontalAlignment:\n 'CENTER',\n verticalAlignment:\n 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n formatRange(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endCaseRowIndex,\n 1,\n 2,\n {\n backgroundColor: {\n red: 0.93,\n green: 0.97,\n blue: 0.90,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.20,\n green: 0.36,\n blue: 0.45,\n },\n },\n horizontalAlignment:\n 'LEFT',\n verticalAlignment:\n 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n formatRange(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endCaseRowIndex,\n 2,\n 3,\n {\n horizontalAlignment:\n 'LEFT',\n verticalAlignment:\n 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n setRowHeight(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endCaseRowIndex,\n 52\n )\n );\n }\n );\n}\n\nconst resumenEndRowIndex =\n 4 + resumenRows.length;\n\nformatRequests.push(\n ...styleReport({\n sheetId:\n sheetIds.resumen,\n columnCount: 2,\n bodyRowsCount:\n resumenRows.length,\n widths: [340, 180],\n statusColumn: null,\n }),\n moneyFormat(\n sheetIds.resumen,\n 1,\n 2,\n resumenEndRowIndex - 3,\n resumenEndRowIndex\n )\n);\n\n\nconst mainReadabilityEndRow =\n 4 + mainRows.length;\n\nconst bancoSinNominaReadabilityEndRow =\n 4 + bancoSinNominaRows.length;\n\nconst bancoSinBambooReadabilityEndRow =\n 4 + bancoSinBambooRows.length;\n\nconst diferenciasNombreReadabilityEndRow =\n 4 + diferenciasNombreRows.length;\n\nconst resumenReadabilityEndRow =\n 4 + resumenRows.length;\n\n/*\n * Ajuste final de legibilidad.\n *\n * Los anchos definitivos se aplican antes del autoajuste vertical. De esta\n * forma Google Sheets calcula la altura real de cada fila después de envolver\n * el texto, evitando contenido cortado en nombres, observaciones y resúmenes.\n */\nformatRequests.push(\n // 01 Nómina vs Banco\n setColumnWidth(\n sheetIds.nominaVsBanco,\n 1,\n 300\n ),\n setColumnWidth(\n sheetIds.nominaVsBanco,\n 2,\n 150\n ),\n setColumnWidth(\n sheetIds.nominaVsBanco,\n 3,\n 145\n ),\n setColumnWidth(\n sheetIds.nominaVsBanco,\n 4,\n 145\n ),\n setColumnWidth(\n sheetIds.nominaVsBanco,\n 5,\n 145\n ),\n setColumnWidth(\n sheetIds.nominaVsBanco,\n 6,\n 220\n ),\n setColumnWidth(\n sheetIds.nominaVsBanco,\n 7,\n 140\n ),\n setColumnWidth(\n sheetIds.nominaVsBanco,\n 8,\n 320\n ),\n ...(mainRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.nominaVsBanco,\n 4,\n mainReadabilityEndRow,\n 0,\n 9\n ),\n autoResizeRowsRequest(\n sheetIds.nominaVsBanco,\n 4,\n mainReadabilityEndRow\n ),\n ]\n : []),\n\n // 02 Banco sin Nómina\n setColumnWidth(\n sheetIds.bancoSinNomina,\n 1,\n 320\n ),\n setColumnWidth(\n sheetIds.bancoSinNomina,\n 2,\n 160\n ),\n setColumnWidth(\n sheetIds.bancoSinNomina,\n 3,\n 145\n ),\n setColumnWidth(\n sheetIds.bancoSinNomina,\n 4,\n 260\n ),\n setColumnWidth(\n sheetIds.bancoSinNomina,\n 5,\n 170\n ),\n setColumnWidth(\n sheetIds.bancoSinNomina,\n 6,\n 560\n ),\n setColumnWidth(\n sheetIds.bancoSinNomina,\n 7,\n 320\n ),\n ...(bancoSinNominaRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.bancoSinNomina,\n 4,\n bancoSinNominaReadabilityEndRow,\n 0,\n 8\n ),\n autoResizeRowsRequest(\n sheetIds.bancoSinNomina,\n 4,\n bancoSinNominaReadabilityEndRow\n ),\n ]\n : []),\n\n // 03 Banco sin Bamboo\n setColumnWidth(\n sheetIds.bancoSinBamboo,\n 1,\n 320\n ),\n setColumnWidth(\n sheetIds.bancoSinBamboo,\n 2,\n 160\n ),\n setColumnWidth(\n sheetIds.bancoSinBamboo,\n 3,\n 145\n ),\n setColumnWidth(\n sheetIds.bancoSinBamboo,\n 4,\n 260\n ),\n setColumnWidth(\n sheetIds.bancoSinBamboo,\n 5,\n 170\n ),\n setColumnWidth(\n sheetIds.bancoSinBamboo,\n 6,\n 320\n ),\n ...(bancoSinBambooRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.bancoSinBamboo,\n 4,\n bancoSinBambooReadabilityEndRow,\n 0,\n 7\n ),\n autoResizeRowsRequest(\n sheetIds.bancoSinBamboo,\n 4,\n bancoSinBambooReadabilityEndRow\n ),\n ]\n : []),\n\n // 04 Diferencias de Nombre\n setColumnWidth(\n sheetIds.diferenciasNombreBanco,\n 1,\n 320\n ),\n setColumnWidth(\n sheetIds.diferenciasNombreBanco,\n 2,\n 320\n ),\n setColumnWidth(\n sheetIds.diferenciasNombreBanco,\n 3,\n 160\n ),\n setColumnWidth(\n sheetIds.diferenciasNombreBanco,\n 4,\n 145\n ),\n setColumnWidth(\n sheetIds.diferenciasNombreBanco,\n 5,\n 170\n ),\n setColumnWidth(\n sheetIds.diferenciasNombreBanco,\n 6,\n 600\n ),\n setColumnWidth(\n sheetIds.diferenciasNombreBanco,\n 7,\n 320\n ),\n ...(diferenciasNombreRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.diferenciasNombreBanco,\n 4,\n diferenciasNombreReadabilityEndRow,\n 0,\n 8\n ),\n autoResizeRowsRequest(\n sheetIds.diferenciasNombreBanco,\n 4,\n diferenciasNombreReadabilityEndRow\n ),\n ]\n : []),\n\n // 05 Cuenta Mal Digitada\n ...(hasCuentaMalDigitada\n ? [\n setColumnWidth(\n sheetIds.cuentaMalDigitada,\n 1,\n 300\n ),\n setColumnWidth(\n sheetIds.cuentaMalDigitada,\n 2,\n 600\n ),\n setColumnWidth(\n sheetIds.cuentaMalDigitada,\n 3,\n 320\n ),\n ]\n : []),\n\n // Resumen\n setColumnWidth(\n sheetIds.resumen,\n 0,\n 380\n ),\n setColumnWidth(\n sheetIds.resumen,\n 1,\n 320\n ),\n ...(resumenRows.length > 0\n ? [\n wrapRangeRequest(\n sheetIds.resumen,\n 4,\n resumenReadabilityEndRow,\n 0,\n 2\n ),\n autoResizeRowsRequest(\n sheetIds.resumen,\n 4,\n resumenReadabilityEndRow\n ),\n ]\n : [])\n);\n\n\nreturn [\n {\n json: {\n ok: true,\n stage:\n 'preparar_google_sheet_tt',\n metadata,\n summary,\n spreadsheetTitle,\n sheetIds,\n sheetTitles,\n createSpreadsheetBody: {\n properties: {\n title: spreadsheetTitle,\n },\n sheets: [\n {\n properties: {\n sheetId:\n sheetIds.nominaVsBanco,\n title:\n sheetTitles.nominaVsBanco,\n },\n },\n {\n properties: {\n sheetId:\n sheetIds.bancoSinNomina,\n title:\n sheetTitles.bancoSinNomina,\n },\n },\n {\n properties: {\n sheetId:\n sheetIds.bancoSinBamboo,\n title:\n sheetTitles.bancoSinBamboo,\n },\n },\n {\n properties: {\n sheetId:\n sheetIds.diferenciasNombreBanco,\n title:\n sheetTitles.diferenciasNombreBanco,\n },\n },\n ...(hasCuentaMalDigitada\n ? [\n {\n properties: {\n sheetId:\n sheetIds.cuentaMalDigitada,\n title:\n sheetTitles.cuentaMalDigitada,\n },\n },\n ]\n : []),\n {\n properties: {\n sheetId:\n sheetIds.resumen,\n title:\n sheetTitles.resumen,\n },\n },\n ],\n },\n valueBatchBody: {\n valueInputOption:\n 'RAW',\n data: valueData,\n },\n formatBatchBody: {\n requests: formatRequests,\n },\n originalResponse: data,\n },\n },\n];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 7264, - 7296 - ], - "id": "0269ecb0-63b1-43f6-9f34-d18d97a104b4", - "name": "Preparar Google Sheet" - }, - { - "parameters": { - "method": "POST", - "url": "https://sheets.googleapis.com/v4/spreadsheets", - "authentication": "predefinedCredentialType", - "nodeCredentialType": "googleOAuth2Api", - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{\n(() => {\n const prepared =\n $('Preparar Google Sheet').first().json || {};\n\n const createBody =\n prepared.createSpreadsheetBody || {};\n\n if (\n !Array.isArray(createBody.sheets) ||\n createBody.sheets.length === 0\n ) {\n throw new Error(\n 'Preparar Google Sheet no devolvió las hojas que deben crearse.'\n );\n }\n\n return {\n properties: {\n ...(createBody.properties || {}),\n timeZone: 'America/Port_of_Spain',\n },\n\n sheets: createBody.sheets.map((sheet) => ({\n properties: {\n ...(sheet.properties || {}),\n\n gridProperties: {\n ...((sheet.properties || {}).gridProperties || {}),\n frozenRowCount: 1,\n },\n },\n })),\n };\n})()\n}}", - "options": {} - }, - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.4, - "position": [ - 7520, - 7296 - ], - "id": "1ef3b281-554e-41dd-b95f-28fa3a1e70be", - "name": "Crear Google Sheet", - "credentials": { - "httpBasicAuth": { - "id": "nIxZ7elcHvuzsRKW", - "name": "Neo4j" - }, - "googleOAuth2Api": { - "id": "eHseMeH39kRcXgOF", - "name": "Google account 2" - } - } - }, - { - "parameters": { - "method": "POST", - "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $('Crear Google Sheet').first().json.spreadsheetId + '/values:batchUpdate' }}", - "authentication": "predefinedCredentialType", - "nodeCredentialType": "googleOAuth2Api", - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ $('Preparar Google Sheet').first().json.valueBatchBody }}", - "options": {} - }, - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.4, - "position": [ - 7776, - 7296 - ], - "id": "cf220e3d-0954-4b08-aa48-7d90afbb099d", - "name": "Escribir Google Sheet", - "credentials": { - "googleOAuth2Api": { - "id": "dQ1MJSJSWcoWYcb8", - "name": "Google account - Isaac Producción" - } - } - }, - { - "parameters": { - "method": "POST", - "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $('Crear Google Sheet').first().json.spreadsheetId + ':batchUpdate' }}", - "authentication": "predefinedCredentialType", - "nodeCredentialType": "googleOAuth2Api", - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ $('Preparar Google Sheet').first().json.formatBatchBody }}", - "options": {} - }, - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.4, - "position": [ - 8032, - 7296 - ], - "id": "1e76a3ba-00f2-4455-ac64-e1f377e3d309", - "name": "Formatear Google Sheet", - "credentials": { - "googleOAuth2Api": { - "id": "dQ1MJSJSWcoWYcb8", - "name": "Google account - Isaac Producción" - } - } - }, - { - "parameters": { - "jsCode": "const createdSheet = $('Crear Google Sheet').first().json || {};\nconst spreadsheetId = createdSheet.spreadsheetId;\n\nif (!spreadsheetId) {\n throw new Error('No se recibió spreadsheetId desde Crear Google Sheet.');\n}\n\nconst allowedEmails = [\n 'iaracena@gomezleemarketing.com',\n 'ymadera@gomezleemarketing.com',\n 'mgomez@gomezleemarketing.com',\n 'jgomez@gomezleemarketing.com',\n];\n\nreturn allowedEmails.map((email) => ({\n json: {\n spreadsheetId,\n email,\n permissionBody: {\n type: 'user',\n role: 'writer',\n emailAddress: email,\n },\n },\n}));" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 8304, - 7296 - ], - "id": "bba45803-df53-41eb-9f04-988fc9456a3c", - "name": "Preparar permisos Google Sheet" - }, - { - "parameters": { - "method": "POST", - "url": "={{ 'https://www.googleapis.com/drive/v3/files/' + $json.spreadsheetId + '/permissions?sendNotificationEmail=false' }}", - "authentication": "predefinedCredentialType", - "nodeCredentialType": "googleOAuth2Api", - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ $json.permissionBody }}", - "options": {} - }, - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.4, - "position": [ - 8560, - 7296 - ], - "id": "00d29e58-0e73-4b96-8848-f3012b30189b", - "name": "Compartir Google Sheet", - "credentials": { - "googleOAuth2Api": { - "id": "dQ1MJSJSWcoWYcb8", - "name": "Google account - Isaac Producción" - } - } - }, - { - "parameters": { - "jsCode": "const cruce =\n $('Cruzar Nómina vs Banco').first().json || {};\n\nconst createdSheet =\n $('Crear Google Sheet').first().json || {};\n\nconst metadata = cruce.metadata || {};\nconst summary = cruce.summary || {};\nconst debug = cruce.debug || {};\n\nconst spreadsheetId =\n createdSheet.spreadsheetId ||\n cruce.spreadsheetId ||\n '';\n\nconst reportUrl =\n createdSheet.spreadsheetUrl ||\n createdSheet.spreadsheet_url ||\n (\n spreadsheetId\n ? `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit`\n : null\n );\n\nfunction toNumber(value) {\n const parsed = Number(value);\n return Number.isFinite(parsed)\n ? parsed\n : 0;\n}\n\nfunction buildPeriodKey(periodMetadata) {\n const country =\n periodMetadata.country || 'TT';\n\n const year =\n periodMetadata.year || '';\n\n const month = String(\n periodMetadata.month || ''\n ).padStart(2, '0');\n\n const periodType =\n periodMetadata.period_type ||\n 'periodo';\n\n return (\n `${country}-${year}-${month}-${periodType}`\n );\n}\n\nconst discrepancias =\n toNumber(summary.discrepancias);\n\nconst discrepanciasMontoPago =\n toNumber(\n summary.discrepanciasMontoPago ??\n Math.max(\n 0,\n discrepancias -\n toNumber(\n summary.posiblesCuentasMalDigitadas\n )\n )\n );\n\nconst bancoSinNomina =\n toNumber(summary.bancoSinNomina);\n\nconst nominaSinCuenta =\n toNumber(summary.nominaSinCuenta);\n\nconst diferenciasNombreBanco =\n toNumber(\n summary.diferenciasNombreBanco\n );\n\nconst bancoSinBamboo =\n toNumber(summary.bancoSinBamboo);\n\nconst posiblesCuentasMalDigitadas =\n toNumber(\n summary.posiblesCuentasMalDigitadas\n );\n\nconst pendientes =\n toNumber(summary.pendientes) ||\n (\n discrepanciasMontoPago +\n bancoSinNomina +\n nominaSinCuenta +\n posiblesCuentasMalDigitadas +\n bancoSinBamboo +\n diferenciasNombreBanco\n );\n\nconst requiereRevision =\n pendientes > 0 ||\n bancoSinBamboo > 0;\n\nconst estado = requiereRevision\n ? 'pendiente_revision'\n : 'resuelto';\n\nconst payload = {\n source_app:\n metadata.source_app ||\n 'cruce-cuentas-glm-trinidad-tobago',\n\n country: 'TT',\n country_name:\n 'Trinidad y Tobago',\n\n year: toNumber(metadata.year),\n month: toNumber(metadata.month),\n period_type:\n metadata.period_type || '',\n period_label:\n metadata.period_label || '',\n period_start:\n metadata.period_start || null,\n period_end:\n metadata.period_end || null,\n period_key:\n buildPeriodKey({\n ...metadata,\n country: 'TT',\n }),\n\n payroll_file_name:\n metadata.payroll_file_name || '',\n\n bank_file_names:\n metadata.bank_file_names || [],\n\n coincidencias:\n toNumber(summary.coincidencias),\n\n discrepancias,\n\n banco_sin_bamboo:\n bancoSinBamboo,\n\n detalle_banco_sin_bamboo:\n Array.isArray(\n cruce.bankWithoutBamboo\n )\n ? cruce.bankWithoutBamboo\n : [],\n\n banco_sin_nomina:\n bancoSinNomina,\n\n nomina_sin_cuenta:\n nominaSinCuenta,\n\n nomina_sin_bamboo: 0,\n bamboo_sin_nomina: 0,\n\n filas_nomina_validas:\n toNumber(\n summary.filasNominaValidas\n ),\n\n cuentas_nomina_agrupadas:\n toNumber(\n summary.cuentasNominaAgrupadas\n ),\n\n transacciones_banco:\n toNumber(\n summary.transaccionesBanco\n ),\n\n cuentas_banco_agrupadas:\n toNumber(\n summary.cuentasBancoAgrupadas\n ),\n\n total_nomina:\n toNumber(summary.totalNomina),\n\n total_banco:\n toNumber(summary.totalBanco),\n\n diferencia_total:\n toNumber(\n summary.diferenciaTotal\n ),\n\n report_url: reportUrl,\n spreadsheet_id:\n spreadsheetId,\n estado,\n\n ejecutado_por_nombre:\n metadata.requested_by_name ||\n 'Usuario GLM',\n\n ejecutado_por_email:\n metadata.requested_by_email ||\n '',\n\n metadata: {\n ...metadata,\n country: 'TT',\n country_name:\n 'Trinidad y Tobago',\n diferencias_nombre_banco:\n diferenciasNombreBanco,\n banco_sin_bamboo:\n bancoSinBamboo,\n posibles_cuentas_mal_digitadas:\n toNumber(\n summary\n .posiblesCuentasMalDigitadas\n ),\n pendientes_cruce_principal:\n pendientes,\n requiere_revision:\n requiereRevision,\n },\n\n summary,\n\n debug: {\n sheet_summaries:\n debug.sheet_summaries || [],\n bank_name_differences:\n cruce.nameDifferences || [],\n bamboo_matches:\n debug.bamboo_matches || [],\n bamboo_excluded_payments:\n debug.bamboo_excluded_payments || [],\n banco_sin_bamboo:\n cruce.bankWithoutBamboo || [],\n },\n};\n\nreturn [\n {\n json: {\n ...cruce,\n\n // Se conserva la tabla histórica actual para\n // que la app pueda consultar todos los países\n // mediante el campo country y luego usar RPC.\n supabaseTable:\n 'cruces_cuentas_gt_reportes',\n\n supabasePayload: payload,\n reportUrl,\n spreadsheetId,\n },\n },\n];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 9904, - 7296 - ], - "id": "09cfaf1e-f278-4f34-b3f2-09ccbc37c10d", - "name": "Preparar histórico Supabase" - }, - { - "parameters": { - "method": "POST", - "url": "https://dbit.digitalcompass.agency/rest/v1/cruces_cuentas_gt_reportes", - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "apikey", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" - }, - { - "name": "Authorization", - "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" - }, - { - "name": "Content-Type", - "value": "application/json" - }, - { - "name": "Prefer", - "value": "return=representation" - } - ] - }, - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ $json.supabasePayload }}", - "options": {} - }, - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.4, - "position": [ - 10160, - 7296 - ], - "id": "a828f0bf-44ef-452c-8aae-f089b34aace5", - "name": "Insertar histórico Supabase", - "onError": "continueRegularOutput" - }, - { - "parameters": { - "jsCode": "const prepared = $('Preparar Google Sheet').first().json || {};\nconst createdSheet = $('Crear Google Sheet').first().json || {};\n\nconst original =\n prepared.originalResponse ||\n prepared.original_response ||\n prepared.response ||\n {};\n\nconst spreadsheetId = createdSheet.spreadsheetId || '';\nconst reportUrl =\n createdSheet.spreadsheetUrl ||\n (spreadsheetId ? `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit` : null);\n\nreturn [\n {\n json: {\n ok: original.ok ?? true,\n message: reportUrl\n ? 'Cruce procesado correctamente. Google Sheet generado.'\n : 'Cruce procesado correctamente, pero no se recibió URL del Google Sheet.',\n stage: reportUrl ? 'cruce_completado_con_reporte' : 'cruce_completado_sin_reporte',\n errors: original.errors || [],\n metadata: original.metadata || {},\n summary: original.summary || {},\n rows: original.rows || [],\n bankWithoutBamboo:\n original.bankWithoutBamboo || [],\n bambooSummary:\n original.bambooSummary || {},\n reportUrl,\n googleSheet: {\n spreadsheetId,\n spreadsheetUrl: reportUrl,\n },\n debug: {\n rows_returned: Array.isArray(original.rows) ? original.rows.length : 0,\n coincidencias: original.summary?.coincidencias ?? 0,\n discrepancias: original.summary?.discrepancias ?? 0,\n discrepanciasMontoPago:\n original.summary?.discrepanciasMontoPago ?? 0,\n posiblesCuentasMalDigitadas:\n original.summary?.posiblesCuentasMalDigitadas ?? 0,\n totalResultados:\n original.summary?.totalResultados ?? 0,\n bancoSinBamboo:\n original.summary?.bancoSinBamboo ?? 0,\n bancoSinBambooRows:\n Array.isArray(original.bankWithoutBamboo)\n ? original.bankWithoutBamboo.length\n : 0,\n },\n },\n },\n];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 10432, - 7296 - ], - "id": "4321d98f-7d24-49d5-a03f-b19eb407f11e", - "name": "Preparar respuesta final" - }, - { - "parameters": { - "respondWith": "json", - "responseBody": "={{\n(() => {\n const data = $json || {};\n\n const original =\n data.originalResponse ||\n data.original_response ||\n data.response ||\n data.cruceResponse ||\n data.cruce_response ||\n data;\n\n const summary = original.summary || data.summary || {};\n const rows = original.rows || data.rows || [];\n const bankWithoutBamboo =\n original.bankWithoutBamboo ||\n data.bankWithoutBamboo ||\n [];\n const bambooSummary =\n original.bambooSummary ||\n data.bambooSummary ||\n {};\n\n const reportUrl =\n data.reportUrl ||\n data.report_url ||\n data.googleSheetUrl ||\n data.google_sheet_url ||\n data.spreadsheetUrl ||\n data.spreadsheet_url ||\n original.reportUrl ||\n original.report_url ||\n null;\n\n return {\n ok: original.ok ?? data.ok ?? true,\n message: reportUrl\n ? 'Cruce procesado correctamente. Google Sheet generado.'\n : 'Cruce procesado correctamente.',\n stage: reportUrl ? 'cruce_completado_con_reporte' : 'cruce_completado',\n errors: original.errors || data.errors || [],\n metadata: original.metadata || data.metadata || {},\n summary,\n rows,\n bankWithoutBamboo,\n bambooSummary,\n reportUrl,\n debug: {\n source_stage: data.stage || null,\n rows_returned:\n Array.isArray(rows) ? rows.length : 0,\n banco_sin_bamboo_rows:\n Array.isArray(bankWithoutBamboo)\n ? bankWithoutBamboo.length\n : 0,\n report_url_found: Boolean(reportUrl),\n },\n };\n})()\n}}", - "options": { - "responseCode": 200, - "responseHeaders": { - "entries": [ - { - "name": "Content-Type", - "value": "application/json" - } - ] - } - } - }, - "type": "n8n-nodes-base.respondToWebhook", - "typeVersion": 1.5, - "position": [ - 10688, - 7296 - ], - "id": "5f85312f-d57b-462b-943a-a7339f81e69d", - "name": "Respond to Webhook" - }, - { - "parameters": { - "content": "# 📥 RECEPCIÓN Y LECTURA DE ARCHIVOS — TT\n\nRecibe desde el Portal de Verificación de Nóminas los archivos y parámetros necesarios para procesar Trinidad y Tobago.\n\nFuentes utilizadas:\n\n- Directorio de empleados de BambooHR.\n- Archivo CSV del banco.\n- Libro de nómina con múltiples hojas o unidades.\n\nEste bloque:\n\n1. Recibe la solicitud enviada por la aplicación.\n2. Normaliza los parámetros del período.\n3. Consulta los empleados disponibles en BambooHR.\n4. Estandariza los datos del directorio.\n5. Convierte el CSV bancario en registros procesables.\n6. Extrae individualmente las hojas incluidas en el archivo de nómina.\n\nLas hojas extraídas pueden corresponder a diferentes clientes, marcas o unidades operativas.\n\nReglas:\n\n- No iniciar el cruce sin los archivos obligatorios.\n- Mantener separados los datos de banco, nómina y BambooHR.\n- Conservar el período recibido desde la aplicación.\n- No asumir que todas las hojas contienen la misma estructura.\n- Preparar una salida consistente para la etapa de consolidación.", - "height": 2016, - "width": 1424, - "color": 7 - }, - "type": "n8n-nodes-base.stickyNote", - "typeVersion": 1, - "position": [ - 2576, - 6624 - ], - "id": "d209ed03-9a6c-4377-aeb3-a25361644e5f", - "name": "Sticky Note" - }, - { - "parameters": { - "content": "# 🔍 CONSOLIDACIÓN Y CRUCE — TRINIDAD Y TOBAGO\n\nConsolida todas las hojas de nómina y compara los empleados y valores contra el archivo bancario y BambooHR.\n\n## Consolidación de nómina\n\nLas hojas extraídas se unen progresivamente hasta formar una única nómina del período.\n\nDespués de combinarlas:\n\n- Se normalizan nombres.\n- Se limpian espacios y caracteres.\n- Se estandarizan correos e identificadores.\n- Se homogenizan los campos monetarios.\n- Se conserva la hoja o unidad de origen cuando sea necesario.\n\n## Cruce de fuentes\n\nEl flujo incorpora progresivamente:\n\n1. Nómina consolidada.\n2. Registros del banco.\n3. Información del empleado en BambooHR.\n\nEl cruce permite identificar casos como:\n\n- Empleados con diferencias entre nómina y banco.\n- Personas presentes únicamente en nómina.\n- Personas presentes únicamente en el banco.\n- Empleados que no pueden relacionarse con BambooHR.\n- Posibles diferencias de nombre, correo, cuenta o monto.\n\nReglas:\n\n- Evitar duplicar empleados al combinar hojas.\n- No depender únicamente del nombre cuando exista otro identificador.\n- Mantener disponibles los valores originales para validación.\n- Diferenciar una ausencia real de un problema de coincidencia.\n- Preparar los resultados en el formato requerido por el reporte final.", - "height": 1984, - "width": 1888, - "color": "#321764" - }, - "type": "n8n-nodes-base.stickyNote", - "typeVersion": 1, - "position": [ - 4528, - 6560 - ], - "id": "17197e7f-e073-464e-b8c8-ff0856c584d7", - "name": "Sticky Note1" - }, - { - "parameters": { - "content": "# 📊 GENERACIÓN DEL REPORTE EN GOOGLE SHEETS\n\nCrea el reporte final de verificación de nómina de Trinidad y Tobago.\n\nProceso:\n\n1. Organiza los resultados obtenidos durante el cruce.\n2. Define las hojas, encabezados y filas del reporte.\n3. Crea un nuevo archivo de Google Sheets.\n4. Escribe toda la información procesada.\n5. Aplica formato visual.\n6. Configura los permisos de acceso.\n7. Comparte el reporte con las personas autorizadas.\n\nEl reporte puede incluir:\n\n- Resultados del cruce.\n- Diferencias detectadas.\n- Registros sin correspondencia.\n- Información de BambooHR.\n- Resumen del período.\n- Datos necesarios para revisión y seguimiento.\n\nFormato aplicado:\n\n- Encabezados destacados.\n- Columnas ajustadas.\n- Valores monetarios con formato correcto.\n- Fechas normalizadas.\n- Filtros y congelación de encabezados cuando corresponda.\n\nReglas:\n\n- No compartir el archivo antes de terminar la escritura.\n- No devolver un enlace hasta confirmar que el Sheet existe.\n- Compartir solamente con los usuarios autorizados.\n- Mantener Google Sheets como entregable y no como fuente original de los datos.", - "height": 720, - "width": 2064, - "color": "#556822" - }, - "type": "n8n-nodes-base.stickyNote", - "typeVersion": 1, - "position": [ - 6752, - 6944 - ], - "id": "656ae2c7-4c04-4346-90de-1e4db21a637d", - "name": "Sticky Note2" - }, - { - "parameters": { - "content": "# 🗂️ HISTÓRICO Y RESPUESTA FINAL\n\nRegistra la ejecución en Supabase y devuelve el resultado al Portal de Verificación de Nóminas.\n\n## Registro histórico\n\nDespués de generar el reporte, se prepara un registro con información como:\n\n- País: Trinidad y Tobago.\n- Año y mes procesados.\n- Tipo de período.\n- Fecha de ejecución.\n- Usuario que inició el proceso.\n- Cantidad de registros analizados.\n- Cantidad de hallazgos.\n- Enlace del Google Sheet.\n- Estado inicial del reporte.\n- Identificador de la ejecución.\n\nSupabase funciona como fuente oficial para los históricos mostrados posteriormente en el portal.\n\n## Respuesta a la aplicación\n\nEl flujo construye una respuesta final con:\n\n- Indicador de éxito.\n- Enlace al reporte.\n- Resumen de resultados.\n- Identificador del histórico.\n- Estado del proceso.\n- Mensaje apto para mostrar en la interfaz.\n\nReglas:\n\n- Registrar el histórico solamente después de crear el reporte.\n- No declarar éxito si el Sheet o el histórico fallaron.\n- No devolver credenciales ni datos internos.\n- Mantener una estructura estable para la aplicación.\n- Cerrar siempre la solicitud mediante Respond to Webhook.", - "height": 768, - "width": 2032, - "color": "#774B22" - }, - "type": "n8n-nodes-base.stickyNote", - "typeVersion": 1, - "position": [ - 8944, - 6944 - ], - "id": "ac5189a6-802b-4f5e-a946-2aa574408974", - "name": "Sticky Note3" - } - ], - "pinData": {}, - "connections": { - "Webhook": { - "main": [ - [ - { - "node": "Preparar entrada app", - "type": "main", - "index": 0 - } - ] - ] - }, - "Preparar entrada app": { - "main": [ - [ - { - "node": "Parsear CSV banco TT", - "type": "main", - "index": 0 - }, - { - "node": "Extract - BICE", - "type": "main", - "index": 0 - }, - { - "node": "Extract - Goldey Samuel", - "type": "main", - "index": 0 - }, - { - "node": "Extract - P&G", - "type": "main", - "index": 0 - }, - { - "node": "Extract - Whirlpool", - "type": "main", - "index": 0 - }, - { - "node": "Extract - KAD", - "type": "main", - "index": 0 - }, - { - "node": "Extract - GLM People", - "type": "main", - "index": 0 - }, - { - "node": "Extract - GLM", - "type": "main", - "index": 0 - } - ] - ] - }, - "Extract - BICE": { - "main": [ - [ - { - "node": "Merge Hojas TT 01-02", - "type": "main", - "index": 0 - } - ] - ] - }, - "Extract - Goldey Samuel": { - "main": [ - [ - { - "node": "Merge Hojas TT 01-02", - "type": "main", - "index": 1 - } - ] - ] - }, - "Merge Hojas TT 01-02": { - "main": [ - [ - { - "node": "Merge Hojas TT 03", - "type": "main", - "index": 0 - } - ] - ] - }, - "Extract - P&G": { - "main": [ - [ - { - "node": "Merge Hojas TT 03", - "type": "main", - "index": 1 - } - ] - ] - }, - "Merge Hojas TT 03": { - "main": [ - [ - { - "node": "Merge Hojas TT 04", - "type": "main", - "index": 0 - } - ] - ] - }, - "Extract - Whirlpool": { - "main": [ - [ - { - "node": "Merge Hojas TT 04", - "type": "main", - "index": 1 - } - ] - ] - }, - "Merge Hojas TT 04": { - "main": [ - [ - { - "node": "Merge Hojas TT 05", - "type": "main", - "index": 0 - } - ] - ] - }, - "Extract - KAD": { - "main": [ - [ - { - "node": "Merge Hojas TT 05", - "type": "main", - "index": 1 - } - ] - ] - }, - "Merge Hojas TT 05": { - "main": [ - [ - { - "node": "Merge Hojas TT 06", - "type": "main", - "index": 0 - } - ] - ] - }, - "Extract - GLM People": { - "main": [ - [ - { - "node": "Merge Hojas TT 06", - "type": "main", - "index": 1 - } - ] - ] - }, - "Merge Hojas TT 06": { - "main": [ - [ - { - "node": "Merge Hojas TT 07", - "type": "main", - "index": 0 - } - ] - ] - }, - "Extract - GLM": { - "main": [ - [ - { - "node": "Merge Hojas TT 07", - "type": "main", - "index": 1 - } - ] - ] - }, - "Merge Hojas TT 07": { - "main": [ - [ - { - "node": "Normalizar Nómina TT", - "type": "main", - "index": 0 - } - ] - ] - }, - "Parsear CSV banco TT": { - "main": [ - [ - { - "node": "Merge Banco + Nómina TT", - "type": "main", - "index": 0 - } - ] - ] - }, - "Normalizar Nómina TT": { - "main": [ - [ - { - "node": "Merge Banco + Nómina TT", - "type": "main", - "index": 1 - } - ] - ] - }, - "HTTP - Empleados BambooHR TT": { - "main": [ - [ - { - "node": "Normalizar BambooHR TT", - "type": "main", - "index": 0 - } - ] - ] - }, - "Merge Banco + Nómina TT": { - "main": [ - [ - { - "node": "HTTP - Empleados BambooHR TT", - "type": "main", - "index": 0 - }, - { - "node": "Merge - Agregar BambooHR TT", - "type": "main", - "index": 0 - } - ] - ] - }, - "Normalizar BambooHR TT": { - "main": [ - [ - { - "node": "Merge - Agregar BambooHR TT", - "type": "main", - "index": 1 - } - ] - ] - }, - "Merge - Agregar BambooHR TT": { - "main": [ - [ - { - "node": "Cruzar Nómina vs Banco", - "type": "main", - "index": 0 - } - ] - ] - }, - "Cruzar Nómina vs Banco": { - "main": [ - [ - { - "node": "Preparar Google Sheet", - "type": "main", - "index": 0 - } - ] - ] - }, - "Preparar Google Sheet": { - "main": [ - [ - { - "node": "Crear Google Sheet", - "type": "main", - "index": 0 - } - ] - ] - }, - "Crear Google Sheet": { - "main": [ - [ - { - "node": "Escribir Google Sheet", - "type": "main", - "index": 0 - } - ] - ] - }, - "Escribir Google Sheet": { - "main": [ - [ - { - "node": "Formatear Google Sheet", - "type": "main", - "index": 0 - } - ] - ] - }, - "Formatear Google Sheet": { - "main": [ - [ - { - "node": "Preparar permisos Google Sheet", - "type": "main", - "index": 0 - } - ] - ] - }, - "Preparar permisos Google Sheet": { - "main": [ - [ - { - "node": "Compartir Google Sheet", - "type": "main", - "index": 0 - } - ] - ] - }, - "Compartir Google Sheet": { - "main": [ - [ - { - "node": "Preparar histórico Supabase", - "type": "main", - "index": 0 - } - ] - ] - }, - "Preparar histórico Supabase": { - "main": [ - [ - { - "node": "Insertar histórico Supabase", - "type": "main", - "index": 0 - } - ] - ] - }, - "Insertar histórico Supabase": { - "main": [ - [ - { - "node": "Preparar respuesta final", - "type": "main", - "index": 0 - } - ] - ] - }, - "Preparar respuesta final": { - "main": [ - [ - { - "node": "Respond to Webhook", - "type": "main", - "index": 0 - } - ] - ] - } - }, - "active": true, - "settings": { - "executionOrder": "v1", - "binaryMode": "separate", - "availableInMCP": true, - "timeSavedMode": "fixed", - "errorWorkflow": "puF4LUczoSz3hcek", - "timezone": "America/Santo_Domingo", - "callerPolicy": "workflowsFromSameOwner" - }, - "versionId": "2be11ec0-4659-47ca-9a58-38fd0f9eb4d9", - "meta": { - "instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70" - }, - "id": "5AujMxduslftVg9z", - "tags": [] -} \ No newline at end of file diff --git a/Flujo de n8n: Portal de Verificación de Nómina GT - Envío de Reporte.json b/Flujo de n8n: Portal de Verificación de Nómina GT - Envío de Reporte.json deleted file mode 100644 index bef74ad..0000000 --- a/Flujo de n8n: Portal de Verificación de Nómina GT - Envío de Reporte.json +++ /dev/null @@ -1,354 +0,0 @@ -{ - "name": "Portal de Verificación de Nómina GT - Envío de Reporte", - "nodes": [ - { - "parameters": { - "httpMethod": "POST", - "path": "cruce-cuentas-gt-marcar-resuelto", - "responseMode": "responseNode", - "options": {} - }, - "type": "n8n-nodes-base.webhook", - "typeVersion": 2.1, - "position": [ - -560, - 32 - ], - "id": "681b06d3-4b12-441e-b5b2-61b283b8f418", - "name": "Webhook Marcar Resuelto", - "webhookId": "5b7d6bd6-caa6-47e6-b9b8-982f6e7e0a0f" - }, - { - "parameters": { - "jsCode": "const body = $input.first().json.body || $input.first().json || {};\n\nfunction clean(value) {\n return String(value ?? '').replace(/\\s+/g, ' ').trim();\n}\n\nconst reportId = clean(body.reportId || body.id);\nconst comentarioResolucion = clean(body.comentarioResolucion || body.comentario || '');\nconst resueltoPorNombre = clean(body.resueltoPorNombre || body.userName || 'Usuario GLM');\nconst resueltoPorEmail = clean(body.resueltoPorEmail || body.userEmail || '');\n\nconst errors = [];\n\nif (!reportId) {\n errors.push('No se recibió el ID del reporte.');\n}\n\nif (!comentarioResolucion) {\n errors.push('Debe indicar un comentario de resolución.');\n}\n\nif (comentarioResolucion.length < 10) {\n errors.push('El comentario de resolución debe ser más descriptivo.');\n}\n\nif (errors.length > 0) {\n return [\n {\n json: {\n ok: false,\n stage: 'validacion_resolucion',\n errors,\n reportId,\n },\n },\n ];\n}\n\nreturn [\n {\n json: {\n ok: true,\n stage: 'resolucion_validada',\n reportId,\n comentarioResolucion,\n resueltoPorNombre,\n resueltoPorEmail,\n resolvedAt: new Date().toISOString(),\n },\n },\n];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - -352, - 32 - ], - "id": "7df13555-97eb-4c62-be7d-13b12a631cd9", - "name": "Validar resolución" - }, - { - "parameters": { - "conditions": { - "options": { - "caseSensitive": true, - "leftValue": "", - "typeValidation": "strict", - "version": 3 - }, - "conditions": [ - { - "id": "c89884c6-de96-4b08-bd20-54644bb95bc7", - "leftValue": "={{ $json.ok }}", - "rightValue": "", - "operator": { - "type": "boolean", - "operation": "true", - "singleValue": true - } - } - ], - "combinator": "and" - }, - "options": {} - }, - "type": "n8n-nodes-base.if", - "typeVersion": 2.3, - "position": [ - -144, - 32 - ], - "id": "fdaa242f-9e63-4c95-82cd-27f645805f66", - "name": "¿Solicitud válida?" - }, - { - "parameters": { - "method": "PATCH", - "url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/cruces_cuentas_gt_reportes?id=eq.' + $json.reportId }}", - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "apikey", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" - }, - { - "name": "Authorization", - "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" - }, - { - "name": "Content-Type", - "value": "application/json" - }, - { - "name": "Prefer", - "value": "return=representation" - } - ] - }, - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{\n {\n estado: 'resuelto',\n resuelto_por_nombre: $json.resueltoPorNombre,\n resuelto_por_email: $json.resueltoPorEmail,\n resuelto_en: $json.resolvedAt,\n comentario_resolucion: $json.comentarioResolucion,\n updated_at: $json.resolvedAt\n }\n}}", - "options": {} - }, - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.4, - "position": [ - 192, - -80 - ], - "id": "31b2de6a-89f6-4d69-9712-59e5ee3957e8", - "name": "Actualizar reporte Supabase" - }, - { - "parameters": { - "jsCode": "const input = $input.first().json || {};\nconst report = Array.isArray(input) ? input[0] : input;\nconst original = $('Validar resolución').first().json || {};\n\nif (!report || !report.id) {\n throw new Error('No se encontró el reporte para preparar la notificación.');\n}\n\nfunction escapeHtml(value) {\n return String(value ?? '')\n .replace(/&/g, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n\nfunction numberValue(value) {\n const parsed = Number(value);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nconst periodo = report.period_label || 'Período no especificado';\nconst reportUrl = report.report_url || '';\n\nconst discrepancias = numberValue(report.discrepancias);\nconst bancoSinBamboo = numberValue(report.banco_sin_bamboo);\nconst nominaSinBamboo = numberValue(report.nomina_sin_bamboo);\nconst bambooSinNomina = numberValue(report.bamboo_sin_nomina);\n\nconst resolvedAt = original.resolvedAt || new Date().toISOString();\n\nconst resolvedDateLabel = new Date(resolvedAt).toLocaleString('es-GT', {\n timeZone: 'America/Guatemala',\n dateStyle: 'long',\n timeStyle: 'short',\n});\n\nconst resolvedByName =\n original.resueltoPorNombre || 'Usuario GLM';\n\nconst resolvedByEmail =\n original.resueltoPorEmail || '';\n\nconst resolutionComment =\n original.comentarioResolucion || 'Sin comentario registrado.';\n\nconst logoUrl =\n 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png';\n\nconst subject =\n `Reporte resuelto - Cruce de Cuentas Guatemala - ${periodo}`;\n\nconst textBody = `\nHola Yanelly,\n\nSe marcó como resuelto un reporte de Cruce de Cuentas Guatemala.\n\nPeríodo:\n${periodo}\n\nResuelto por:\n${resolvedByName}${resolvedByEmail ? ` (${resolvedByEmail})` : ''}\n\nFecha/hora:\n${resolvedDateLabel}\n\nResumen:\n- Discrepancias: ${discrepancias}\n- Banco sin Bamboo: ${bancoSinBamboo}\n- Nómina sin Bamboo: ${nominaSinBamboo}\n- Bamboo sin Nómina: ${bambooSinNomina}\n\nComentario:\n${resolutionComment}\n\nGoogle Sheet:\n${reportUrl || 'No disponible'}\n\nSaludos,\nPortal Cruce de Cuentas GLM\n`.trim();\n\nconst htmlBody = `\n\n\n
\n \n \n\n\n
| \n
\n\n
| \n
Banco sin BambooHR
++ Esta validación crea una equivalencia permanente para que el mismo falso positivo no vuelva a aparecer mientras RRHH corrige BambooHR. +
+Nombre detectado
+{row.employee}
+Cuenta bancaria
+{row.account || '—'}
+Se notificará a
+{recipientLabel}
+{file.name}
-{file.size} • CSV
-+ {uploadedBankFiles.length} {uploadedBankFiles.length === 1 ? 'archivo cargado' : 'archivos cargados'} +
++ Busque por nombre para localizar y eliminar una planilla rápidamente. +
+ Mostrando {filteredBankFiles.length} de {uploadedBankFiles.length} archivos. +
+ )} ++ {file.name} +
+No encontramos archivos con ese nombre.
+Pruebe con otra parte del nombre del CSV.
++ Esta sección aparece únicamente para Guatemala · Julio · Quincena 15. + El Bono 14 se procesa separado de la nómina quincenal y genera su propio + Google Sheet e histórico. +
++ Período del Bono 14 +
++ {bonus14PeriodRange.start} al {bonus14PeriodRange.end} +
++ Archivo anual con los montos calculados de Bono 14. +
+ + + + {uploadedBonus14Payroll ? ( ++ {uploadedBonus14Payroll.name} +
++ {uploadedBonus14Payroll.size} · Excel +
++ Arrastre el Excel de Bono 14 +
+Formato .xlsx, .xls
+ ++ Puede cargar todos los envíos bancarios correspondientes al Bono 14. +
+ + + ++ Arrastre uno o varios CSV +
+Formato .csv
+ ++ {uploadedBonus14BankFiles.length} archivo(s) cargado(s). +
++ {file.name} +
++ {file.size} +
++ Se guardará como un reporte separado: GT · {selectedYear} · Bono 14. +
+{response.summary.payrollWithoutAccount}
{periodRange.label}
+{resultsPeriodLabel}