From 9cf20a3db4f34d300cd49890645c48050b824ea4 Mon Sep 17 00:00:00 2001 From: Isaac Aracena Date: Wed, 9 Sep 2026 11:17:28 -0400 Subject: [PATCH] Actualizar Honduras y dist validado para produccion --- .env.example | 5 + IMPLEMENTACION_HONDURAS.md | 133 +++ ... - Analizar N├│mina con Gemini GT TT PA SV HN.json | 279 +++++ ...e Verificaci├│n de N├│mina - Honduras.json | 1063 +++++++++++++++++ ... correcci├│n BambooHR a RRHH - GT TT PA SV HN.json | 514 ++++++++ ...n de N├│mina Honduras - Env├¡o de Reporte.json | 347 ++++++ ...├│n de N├│mina Honduras - Hist├│ricos.json | 163 +++ SUPABASE_Honduras_READY.sql | 112 ++ VALIDACION_HONDURAS.md | 111 ++ dist/index.html | 2 +- src/components/BambooCorrectionModal.tsx | 1 + src/components/Dashboard.tsx | 24 +- src/components/History.tsx | 18 +- src/config/countries.ts | 8 + src/types.ts | 2 +- src/vite-env.d.ts | 6 + ... - Analizar N├│mina con Gemini GT TT PA SV HN.json | 279 +++++ ...e Verificaci├│n de N├│mina - Honduras.json | 1063 +++++++++++++++++ ... correcci├│n BambooHR a RRHH - GT TT PA SV HN.json | 514 ++++++++ ...n de N├│mina Honduras - Env├¡o de Reporte.json | 347 ++++++ ...├│n de N├│mina Honduras - Hist├│ricos.json | 163 +++ 21 files changed, 5148 insertions(+), 6 deletions(-) create mode 100644 IMPLEMENTACION_HONDURAS.md create mode 100644 Portal de Verificaci├│n de N├│mina - Analizar N├│mina con Gemini GT TT PA SV HN.json create mode 100644 Portal de Verificaci├│n de N├│mina - Honduras.json create mode 100644 Portal de Verificaci├│n de N├│mina - Reportar correcci├│n BambooHR a RRHH - GT TT PA SV HN.json create mode 100644 Portal de Verificaci├│n de N├│mina Honduras - Env├¡o de Reporte.json create mode 100644 Portal de Verificaci├│n de N├│mina Honduras - Hist├│ricos.json create mode 100644 SUPABASE_Honduras_READY.sql create mode 100644 VALIDACION_HONDURAS.md create mode 100644 workflows_n8n/Portal de Verificaci├│n de N├│mina - Analizar N├│mina con Gemini GT TT PA SV HN.json create mode 100644 workflows_n8n/Portal de Verificaci├│n de N├│mina - Honduras.json create mode 100644 workflows_n8n/Portal de Verificaci├│n de N├│mina - Reportar correcci├│n BambooHR a RRHH - GT TT PA SV HN.json create mode 100644 workflows_n8n/Portal de Verificaci├│n de N├│mina Honduras - Env├¡o de Reporte.json create mode 100644 workflows_n8n/Portal de Verificaci├│n de N├│mina Honduras - Hist├│ricos.json diff --git a/.env.example b/.env.example index ba886f5..c176bf1 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,11 @@ VITE_N8N_EL_SALVADOR_WEBHOOK_URL="https://agenteit.digitalcompass.agency/webhook VITE_HISTORICOS_SV_URL="https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-sv-historicos" VITE_MARCAR_RESUELTO_SV_URL="https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-sv-marcar-resuelto" +# Honduras: cruce, históricos y resolución. +VITE_N8N_HONDURAS_WEBHOOK_URL="https://agenteit.digitalcompass.agency/webhook/nominahn-bamboo-test" +VITE_HISTORICOS_HN_URL="https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-hn-historicos" +VITE_MARCAR_RESUELTO_HN_URL="https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-hn-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" diff --git a/IMPLEMENTACION_HONDURAS.md b/IMPLEMENTACION_HONDURAS.md new file mode 100644 index 0000000..7e243a6 --- /dev/null +++ b/IMPLEMENTACION_HONDURAS.md @@ -0,0 +1,133 @@ +# Implementación — Cruce de Cuentas GLM · Honduras (HN) + +## Alcance + +Esta entrega agrega Honduras al portal centralizado sin retirar Guatemala (GT), Trinidad y Tobago (TT), Panamá (PA), El Salvador (SV) ni Bono 14 Guatemala. + +Honduras queda configurado con: + +- Código: `HN` +- Nombre: `Honduras` +- Moneda principal: `HNL` +- Soporte de pagos en `USD` dentro del mismo archivo de nómina +- Locale: `es-HN` +- Bandera: `https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/Bandera%20de%20Honduras.webp` +- Responsable de correcciones BambooHR: Melody Chang Blandin — `mchang@gomezleemarketing.com` + +## Archivos n8n + +### Nuevos + +1. `Portal de Verificación de Nómina - Honduras.json` + - Webhook de producción: `/webhook/nominahn-bamboo-test` + - Recibe un Excel de nómina y uno o varios CSV bancarios. + - Procesa planes BAC AR94 (HNL) y AR95US (USD). + - Genera/reutiliza el Google Sheet del período, registra el histórico en Supabase y envía el reporte. + +2. `Portal de Verificación de Nómina Honduras - Históricos.json` + - Webhook: `/webhook/cruce-cuentas-hn-historicos` + - Consulta únicamente históricos `country = HN`. + +3. `Portal de Verificación de Nómina Honduras - Envío de Reporte.json` + - Webhook: `/webhook/cruce-cuentas-hn-marcar-resuelto` + - Marca únicamente reportes HN como resueltos y envía la notificación correspondiente. + +### Actualizaciones compartidas + +4. `Portal de Verificación de Nómina - Analizar Nómina con Gemini GT TT PA SV HN.json` + - Conserva el webhook existente `/webhook/cruce-cuentas-analizar-nomina`. + - Agrega HN al validador, esquema, prompt y normalizador. + - Reconoce nombres compactos de Honduras como `Nomina 7.1 2026 GLM HN.xlsx` y `Nomina 7.2 2026 GLM HN.xlsx`. + - Calcula dinámicamente 1–15 y 16–último día real del mes, incluyendo febrero y años bisiestos. + +5. `Portal de Verificación de Nómina - Reportar corrección BambooHR a RRHH - GT TT PA SV HN.json` + - Conserva el webhook existente `/webhook/cruce-cuentas-bamboo-correccion`. + - Agrega HN y envía las correcciones de Honduras exclusivamente a `mchang@gomezleemarketing.com`. + +> Los dos workflows compartidos usan los mismos paths que los workflows actuales. No deben quedar dos versiones activas con el mismo webhook. Actualiza/reemplaza la versión existente o desactiva la anterior antes de publicar la nueva. + +## Destinatarios de reportes generados de Honduras + +El workflow principal usa estos seis destinatarios: + +```javascript +const to = [ + 'iaracena@gomezleemarketing.com', + 'jgomez@gomezleemarketing.com', + 'mgomez@gomezleemarketing.com', + 'vparamo@gomezleemarketing.com', + 'ymadera@gomezleemarketing.com', + 'adminregional@gomezleemarketing.com', +].join(','); +``` + +## Nómina Honduras soportada + +El workflow procesa las hojas reales observadas en junio/julio de 2026, con tolerancia a hojas opcionales ausentes: + +- Colgate Base +- Colgate Combustible +- POP Helper Base +- POP Helper Combustible +- Motorola Base +- Motorola Temporales +- Motorola KAM Dolares +- Nestle Base +- Nestle Combustible +- Whirlpool Base +- Whirlpool Combustible +- FF Regional WH Base +- FF Regional WH Combustible +- GLM Base Lempiras +- GLM Combustible Lempiras +- GLM Dolares Base +- GLM Dolares Combustible + +Las cuentas se preservan como texto y los montos se separan por moneda HNL/USD. + +## Regla de redondeo + +Para Honduras, una diferencia absoluta de hasta `0.05` entre nómina y banco se considera redondeo operativo y no una discrepancia. Los archivos reales mostraron diferencias de 0.01 y un caso USD de 0.03. Diferencias superiores continúan reportándose. + +## Supabase + +Ejecuta `SUPABASE_Honduras_READY.sql` antes de activar los workflows. + +El script: + +- Agrega `HN` a los constraints de aliases BambooHR, correcciones e históricos. +- Agrega `HN` al constraint `paises` de `cruce_cuentas_usuarios_autorizados`. +- Agrega HN a **todos los usuarios actuales**, preservando los demás permisos. +- Actualiza `cruce_cuentas_bamboo_aliases_activos` para cinco países. +- Actualiza `cruce_cuentas_get_historicos` para cinco países. +- Actualiza `cruce_cuentas_get_banco_sin_bamboo_reporte` para cinco países. +- Conserva la tabla histórica compartida `cruces_cuentas_gt_reportes`; el nombre es legado, la tabla es multi-país. + +## App + +Variables Honduras incorporadas: + +```env +VITE_N8N_HONDURAS_WEBHOOK_URL="https://agenteit.digitalcompass.agency/webhook/nominahn-bamboo-test" +VITE_HISTORICOS_HN_URL="https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-hn-historicos" +VITE_MARCAR_RESUELTO_HN_URL="https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-hn-marcar-resuelto" +``` + +El selector de país incluye Honduras y el `dist` entregado ya contiene la configuración HN, los tres endpoints, la bandera y el destinatario de correcciones. + +## Orden de implementación recomendado + +1. Ejecutar `SUPABASE_Honduras_READY.sql` y verificar que todos los usuarios tengan `HN` en `paises`. +2. Importar el workflow nuevo **Portal de Verificación de Nómina - Honduras** y revisar credenciales. +3. Importar **Honduras - Históricos**. +4. Importar **Honduras - Envío de Reporte / Marcar Resuelto**. +5. Reemplazar/actualizar el workflow compartido de Gemini; no dejar activa la versión anterior con el mismo webhook. +6. Reemplazar/actualizar el workflow compartido de correcciones BambooHR; no dejar activa la versión anterior con el mismo webhook. +7. Publicar/activar los cinco workflows una vez revisadas las credenciales. +8. Desplegar exactamente el contenido de `dist` del ZIP de aplicación bajo `/cruce-cuentas/`. +9. Hacer una prueba controlada de una Q15 y una Q30 desde la aplicación. +10. Verificar Históricos, “Marcar resuelto” y un caso de “Reportar a RRHH”. + +## Estado de activación de los JSON + +Los cinco JSON entregados tienen `active: false` intencionalmente para una importación segura. Actívalos únicamente después de confirmar credenciales y de evitar duplicidad de webhooks compartidos. diff --git a/Portal de Verificaci├│n de N├│mina - Analizar N├│mina con Gemini GT TT PA SV HN.json b/Portal de Verificaci├│n de N├│mina - Analizar N├│mina con Gemini GT TT PA SV HN.json new file mode 100644 index 0000000..ab4647f --- /dev/null +++ b/Portal de Verificaci├│n de N├│mina - Analizar N├│mina con Gemini GT TT PA SV HN.json @@ -0,0 +1,279 @@ +{ + "name": "Portal de Verificación de Nómina - Analizar Nómina con Gemini GT TT PA SV HN", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "cruce-cuentas-analizar-nomina", + "responseMode": "responseNode", + "options": {} + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + 6432, + 2784 + ], + "id": "8518e0cc-3c55-4c55-9c9b-3498dd6ab7d3", + "name": "Webhook - Analizar Nómina", + "webhookId": "f265d1ec-437f-4a05-a699-76dae8c4d89e" + }, + { + "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 PA: 'Panamá',\n SV: 'El Salvador',\n HN: 'Honduras',\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, Trinidad y Tobago, Panamá, El Salvador y Honduras.'\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": [ + 6640, + 2784 + ], + "id": "6f8d45c3-8dc9-4c58-9ba7-93a9a37877cc", + "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": [ + 6848, + 2784 + ], + "id": "10aaa8bb-d2b7-48b8-b538-d7f968bba2d0", + "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": [ + 7056, + 2880 + ], + "id": "3c592f6e-f2c5-4b3e-a1b6-56b86045f8b2", + "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 \"PA\",\n \"SV\",\n \"HN\"\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 - PA para Panamá.\n - SV para El Salvador.\n - HN para Honduras.\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. Para Honduras también reconoce nombres como \"Nomina 7.1 2026 GLM HN.xlsx\" (mes 7, primera quincena) y \"Nomina 7.2 2026 GLM HN.xlsx\" (mes 7, segunda quincena). En general, cuando ese patrón forma parte del nombre de nómina, M.1 = quincena_15 y M.2 = quincena_30.\n\n10. 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\n11. confidence debe estar entre 0 y 1.\n\n12. 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": [ + 7056, + 2512 + ], + "id": "95117474-e746-4a8a-9f7f-66eeb536b5d5", + "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": [ + 7056, + 2688 + ], + "id": "5f2f1579-5479-4be5-8303-3a03c7f4084c", + "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 PA: 'Panamá',\n SV: 'El Salvador',\n HN: 'Honduras',\n};\n\nconst country = ['GT', 'TT', 'PA', 'SV', 'HN'].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 hondurasDecimal = country === 'HN'\n ? fileName.match(/(?:nomina|nómina)?\\s*(\\d{1,2})\\.(1|2)\\s+(20\\d{2})/i)\n : null;\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 = hondurasDecimal\n ? Number(hondurasDecimal[3])\n : standaloneYearMatch\n ? Number(standaloneYearMatch[1])\n : compactDateMatch\n ? Number(compactDateMatch[1])\n : 0;\n\nlet fallbackMonth = hondurasDecimal ? Number(hondurasDecimal[1]) : 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 (hondurasDecimal) {\n fallbackPeriodType = hondurasDecimal[2] === '1' ? 'quincena_15' : 'quincena_30';\n fallbackConfidence = 1;\n strongPeriodEvidence = true;\n} else if (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": [ + 7392, + 2528 + ], + "id": "0c24e1d9-1abb-4c8b-934e-d1cd7d0db5fc", + "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": [ + 7600, + 2528 + ], + "id": "7c889159-aba3-4288-851e-d4c117453465", + "name": "Responder análisis" + }, + { + "parameters": { + "content": "# 🧠 ANÁLISIS AUTOMÁTICO DE NÓMINA — GT, TT, PA y SV\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, Trinidad y Tobago, Panamá y El Salvador.\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, Trinidad y Tobago, Panamá y El Salvador.", + "height": 1488, + "width": 2256, + "color": 2 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 5568, + 1968 + ], + "id": "a32f6dbb-6c5b-4505-a79e-593d8799c41c", + "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": false, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate", + "availableInMCP": true, + "timeSavedMode": "fixed", + "errorWorkflow": "puF4LUczoSz3hcek", + "timezone": "America/Santo_Domingo", + "callerPolicy": "workflowsFromSameOwner" + }, + "versionId": "6bf17633-b996-46d8-8ac2-ee59c9137093", + "meta": { + "templateCredsSetupCompleted": true, + "instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70" + }, + "tags": [] +} \ No newline at end of file diff --git a/Portal de Verificaci├│n de N├│mina - Honduras.json b/Portal de Verificaci├│n de N├│mina - Honduras.json new file mode 100644 index 0000000..090230c --- /dev/null +++ b/Portal de Verificaci├│n de N├│mina - Honduras.json @@ -0,0 +1,1063 @@ +{ + "name": "Portal de Verificación de Nómina - Honduras", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "nominahn-bamboo-test", + "responseMode": "responseNode", + "options": {} + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + -1088, + 512 + ], + "id": "9a529a55-ec46-4e0d-8901-d4a2e5c059f9", + "name": "Webhook", + "webhookId": "517b24db-a370-4701-8281-06b7d277b403" + }, + { + "parameters": { + "jsCode": "const item = $input.first();\nconst body = item.json.body || {};\nconst binary = item.binary || {};\n\nlet metadata = {};\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\nfunction naturalBinarySort(left, right) {\n return String(left).localeCompare(String(right), undefined, {\n numeric: true,\n sensitivity: 'base',\n });\n}\n\n// La app envía cada archivo dos veces por compatibilidad:\n// - campos multipart repetidos: payroll_files / bank_files\n// - campos únicos numerados: payroll_file_1 / bank_file_1\n//\n// En n8n los campos repetidos pueden materializarse como payroll_files0,\n// bank_files0, etc. Priorizamos los campos únicos numerados para evitar\n// duplicar archivos y usamos los repetidos solo como respaldo.\nconst numberedPayrollKeys = binaryKeys\n .filter((key) => /^payroll_file_\\d+$/i.test(key))\n .sort(naturalBinarySort);\n\nconst repeatedPayrollKeys = binaryKeys\n .filter((key) => /^payroll_files(?:_?\\d+)?$/i.test(key))\n .sort(naturalBinarySort);\n\nlet payrollKeys = numberedPayrollKeys.length\n ? numberedPayrollKeys\n : repeatedPayrollKeys;\n\nif (payrollKeys.length === 0 && binary.payroll_file) {\n payrollKeys = ['payroll_file'];\n}\n\nconst numberedBankKeys = binaryKeys\n .filter((key) => /^bank_file_\\d+$/i.test(key))\n .sort(naturalBinarySort);\n\nconst repeatedBankKeys = binaryKeys\n .filter((key) => /^bank_files(?:_?\\d+)?$/i.test(key))\n .sort(naturalBinarySort);\n\nconst bankKeys = numberedBankKeys.length\n ? numberedBankKeys\n : repeatedBankKeys;\n\nconst payrollFiles = payrollKeys.map((key) => ({\n binary_key: key,\n file_name: binary[key]?.fileName || key,\n file_extension: binary[key]?.fileExtension || '',\n mime_type: binary[key]?.mimeType || '',\n file_size: binary[key]?.fileSize || null,\n}));\n\nconst bankFiles = bankKeys.map((key) => ({\n binary_key: key,\n file_name: binary[key]?.fileName || key,\n file_extension: binary[key]?.fileExtension || '',\n mime_type: binary[key]?.mimeType || '',\n file_size: binary[key]?.fileSize || null,\n}));\n\nconst receivedCountry = String(metadata.country || '').trim().toUpperCase();\nconst errors = [];\n\nif (receivedCountry !== 'HN') {\n errors.push('El país recibido no es Honduras.');\n}\nif (!metadata.year) errors.push('No se recibió el año del cruce.');\nif (!metadata.month) errors.push('No se recibió el mes del cruce.');\nif (!metadata.period_type) errors.push('No se recibió el tipo de quincena.');\nif (!metadata.period_start || !metadata.period_end) {\n errors.push('No se recibió el período calculado.');\n}\nif (payrollFiles.length === 0) {\n errors.push('No se recibió ningún archivo Excel de nómina de Honduras.');\n}\nif (bankFiles.length === 0) {\n errors.push('No se recibió ningún archivo CSV bancario de Honduras.');\n}\n\nconst mainPayroll =\n payrollFiles.find((file) => /(?:general|nestle|nestlé|motorola|karla)/i.test(file.file_name)) ||\n payrollFiles[0] ||\n null;\n\nconst normalizedMetadata = {\n ...metadata,\n country: 'HN',\n country_name: 'Honduras',\n source_app: metadata.source_app || 'portal-cruce-cuentas-glm',\n payroll_file_name:\n metadata.payroll_file_name || mainPayroll?.file_name || '',\n payroll_file_names:\n Array.isArray(metadata.payroll_file_names) && metadata.payroll_file_names.length\n ? metadata.payroll_file_names\n : payrollFiles.map((file) => file.file_name),\n bank_file_names:\n Array.isArray(metadata.bank_file_names) && metadata.bank_file_names.length\n ? metadata.bank_file_names\n : bankFiles.map((file) => file.file_name),\n};\n\nreturn [{\n json: {\n ok: errors.length === 0,\n stage: 'entrada_hn_recibida',\n errors,\n metadata: normalizedMetadata,\n payroll_files: payrollFiles,\n bank_files: bankFiles,\n summary: {\n payroll_files_count: payrollFiles.length,\n bank_files_count: bankFiles.length,\n },\n },\n binary,\n}];\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -848, + 512 + ], + "id": "7daf7b32-2a8f-4724-b912-da5d6f80c86d", + "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 for (let i = 0; i < line.length; i++) {\n const char = line[i];\n const next = line[i + 1];\n if (char === '\"' && insideQuotes && next === '\"') { current += '\"'; i++; continue; }\n if (char === '\"') { insideQuotes = !insideQuotes; continue; }\n if (char === ',' && !insideQuotes) { result.push(current.trim()); current = ''; continue; }\n current += char;\n }\n result.push(current.trim());\n return result;\n}\nfunction clean(value) { return String(value ?? '').replace(/\\uFEFF/g,'').replace(/\\u00A0/g,' ').replace(/\\s+/g,' ').trim(); }\nfunction normalize(value) { return clean(value).toLowerCase().normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[^a-z0-9 ]/g,' ').replace(/\\s+/g,' ').trim(); }\nfunction normalizeAccount(value) { return String(value ?? '').replace(/\\u00A0/g,'').replace(/\\.0$/g,'').replace(/\\D/g,'').trim(); }\nfunction isValidAccount(value) { const a=normalizeAccount(value); return a.length>=6 && !/^0+$/.test(a); }\nfunction parseMoney(value) { const n=String(value??'').replace(/HNL|LPS|USD/gi,'').replace(/\\$/g,'').replace(/,/g,'').replace(/\\s+/g,'').trim(); const p=Number.parseFloat(n); return Number.isFinite(p)?p:0; }\nfunction roundMoney(v){ return Math.round((Number(v)||0)*100)/100; }\nfunction detectCurrency(value, fileName='') { const raw=String(value??'').toUpperCase(); const fn=String(fileName??'').toUpperCase(); if(raw.includes('USD') || fn.includes('AR95US')) return 'USD'; return 'HNL'; }\nfunction getColumnIndex(headers,names){ const nh=headers.map(normalize); for(const name of names){const e=normalize(name);const i=nh.findIndex(h=>h===e);if(i>=0)return i;} for(const name of names){const e=normalize(name);const i=nh.findIndex(h=>h.includes(e));if(i>=0)return i;} return -1; }\nfunction extractShipmentNumber(lines,fileName){ for(const line of lines.slice(0,35)){const m=normalize(line).match(/(?:detalle del envio|numero de envio)[^0-9]{0,30}(\\d{1,20})/);if(m?.[1])return m[1];} const f=String(fileName||'').match(/_(\\d{2,6})_\\d{6,8}/);return f?.[1]||''; }\nfunction extractPlanNumber(lines,fileName){ for(const line of lines.slice(0,35)){const m=normalize(line).match(/(?:numero de plan|plan)[^a-z0-9]{0,20}([a-z0-9-]{2,30})/);if(m?.[1])return String(m[1]).toUpperCase();} const f=String(fileName||'').match(/plan\\s+([A-Za-z0-9-]{2,30})/i);return f?.[1]?.toUpperCase()||''; }\nfunction extractPlanLabel(fileName, planNumber){ const f=String(fileName||'').match(/plan\\s+([A-Za-z0-9-]{2,30})/i); return f?.[1]?.toUpperCase() || planNumber || ''; }\nconst natural=(a,b)=>String(a).localeCompare(String(b),undefined,{numeric:true,sensitivity:'base'});\nlet bankKeys=Array.isArray(json.bank_files)?json.bank_files.map(f=>String(f?.binary_key||'')).filter(k=>k&&binary[k]):[];\nif(!bankKeys.length){const keys=Object.keys(binary);const numbered=keys.filter(k=>/^bank_file_\\d+$/i.test(k)).sort(natural);const repeated=keys.filter(k=>/^bank_files(?:_?\\d+)?$/i.test(k)).sort(natural);bankKeys=numbered.length?numbered:repeated;}\nbankKeys=Array.from(new Set(bankKeys)).sort(natural);\nconst allRows=[]; const fileSummaries=[];\nfor(const key of bankKeys){\n const file=binary[key]; const buffer=await this.helpers.getBinaryDataBuffer(0,key); const text=buffer.toString('latin1');\n const lines=text.split(/\\r?\\n/).map(l=>l.trim()).filter(Boolean);\n const shipmentNumber=extractShipmentNumber(lines,file.fileName); const planNumber=extractPlanNumber(lines,file.fileName); const planLabel=extractPlanLabel(file.fileName,planNumber);\n const markerIndex=lines.findIndex(line=>normalize(line).includes('transacciones del envio'));\n const headerIndex=lines.findIndex((line,index)=>{if(markerIndex>=0&&index<=markerIndex)return false;const n=normalize(line);return n.includes('cuenta destino')&&n.includes('monto');});\n if(headerIndex<0){fileSummaries.push({file_name:file.fileName,shipment_number:shipmentNumber,plan_number:planNumber,plan_label:planLabel,ok:false,rows_count:0,total_amount:0,error:'No se encontró el encabezado de transacciones del plano bancario de Honduras.'});continue;}\n const headers=parseCsvLine(lines[headerIndex]).map(clean);\n const idxCuenta=getColumnIndex(headers,['Cuenta Destino']); const idxNombre=getColumnIndex(headers,['Nombre en Archivo']); const idxHolder=getColumnIndex(headers,['Nombre del Cuentahabiente']); const idxMonto=getColumnIndex(headers,['Monto']); const idxFecha=getColumnIndex(headers,['Fecha/Hora de aplicación','Fecha/Hora de aplicacion']); const idxConcepto=getColumnIndex(headers,['Concepto']); const idxEstado=getColumnIndex(headers,['Estado']); const idxRef=getColumnIndex(headers,['Referencia']); const idxEnvio=getColumnIndex(headers,['Número de envío','Numero de envio']); const idxPlan=getColumnIndex(headers,['Número de plan','Numero de plan']);\n const rowsFromFile=[];\n for(let i=headerIndex+1;i=0?values[idxMonto]:''; const amount=roundMoney(parseMoney(rawAmount)); if(amount<=0)continue;\n const account=normalizeAccount(idxCuenta>=0?values[idxCuenta]:''); if(!isValidAccount(account))continue;\n const row={source_file:file.fileName,row_number:i+1,bank_row_key:`${file.fileName}|${i+1}`,account,raw_account:account,account_is_valid:true,reference:clean(idxRef>=0?values[idxRef]:''),shipment_number:clean(idxEnvio>=0?values[idxEnvio]:'')||shipmentNumber,plan_number:clean(idxPlan>=0?values[idxPlan]:'')||planNumber,plan_label:planLabel,bank_name_file:clean(idxNombre>=0?values[idxNombre]:''),bank_account_holder:clean(idxHolder>=0?values[idxHolder]:''),amount,currency:detectCurrency(rawAmount,file.fileName),application_date:clean(idxFecha>=0?values[idxFecha]:''),concept:clean(idxConcepto>=0?values[idxConcepto]:''),status:clean(idxEstado>=0?values[idxEstado]:'')};\n rowsFromFile.push(row); allRows.push(row);\n }\n const totalsByCurrency=rowsFromFile.reduce((acc,r)=>{acc[r.currency]=roundMoney((acc[r.currency]||0)+r.amount);return acc;},{});\n fileSummaries.push({file_name:file.fileName,shipment_number:shipmentNumber,plan_number:planNumber,plan_label:planLabel,ok:true,rows_count:rowsFromFile.length,total_amount:roundMoney(rowsFromFile.reduce((s,r)=>s+r.amount,0)),totals_by_currency:totalsByCurrency});\n}\nif(!allRows.length) throw new Error('Honduras: no se encontró ninguna transacción bancaria válida en los CSV cargados.');\nconst totalsByCurrency=allRows.reduce((acc,r)=>{acc[r.currency]=roundMoney((acc[r.currency]||0)+r.amount);return acc;},{});\nreturn [{json:{...json,stage:'banco_hn_normalizado',bank:{files_count:bankKeys.length,file_summaries:fileSummaries,rows_count:allRows.length,total_amount:roundMoney(allRows.reduce((s,r)=>s+r.amount,0)),totals_by_currency:totalsByCurrency,rows:allRows}}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -608, + 352 + ], + "id": "73b4ccb3-b19f-415e-8d2b-f7ef1d43dcfe", + "name": "Parsear CSV banco HN" + }, + { + "parameters": { + "jsCode": "const input=$input.first(); const base=input.json||{}; const binary=input.binary||{}; const configuredFiles=Array.isArray(base.payroll_files)?base.payroll_files:[];\nconst SHEETS=[\n ['Colgate Base',5,'HNL'],['Colgate Combustible',5,'HNL'],['POP Helper Base',5,'HNL'],['POP Helper Combustible',5,'HNL'],['Motorola Base',5,'HNL'],['Motorola Temporales',5,'HNL'],\n ['Motorola KAM Dolares',5,'USD'],['Nestle Base ',5,'HNL'],['Nestle Combustible',5,'HNL'],['Whirlpool Base',7,'USD'],['Whirlpool Combustible',6,'USD'],['FF Regional WH Base',7,'USD'],['FF Regional WH Combustible',6,'USD'],['GLM Base Lempiras ',3,'HNL'],['GLM Combustible Lempiras ',4,'HNL'],['GLM Dolares Base',5,'USD'],['GLM Dolares Combustible',4,'USD']\n];\nconst outputs=[];\nfor(const fileInfo of configuredFiles){const key=String(fileInfo.binary_key||'');const file=binary[key];if(!key||!file)continue;const fileName=String(file.fileName||fileInfo.file_name||key);for(const [sheetName,headerRow,currency] of SHEETS){outputs.push({json:{source_file:fileName,source_binary_key:key,source_kind:'quincenal',payroll_group:'honduras',sheet_name:sheetName,header_row:headerRow,range:`A${headerRow}:CA2000`,currency},binary:{data:file}});}}\nif(!outputs.length)throw new Error('No se pudo preparar el archivo de nómina de Honduras. Carga la nómina GLM HN en formato Excel.');\nreturn outputs;" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -608, + 672 + ], + "id": "5d72ed5f-ca78-4d72-9b64-2f92bc7735dc", + "name": "Preparar archivos nómina HN" + }, + { + "parameters": { + "operation": "xlsx", + "options": { + "headerRow": true, + "sheetName": "={{ $json.sheet_name }}", + "range": "={{ $json.range }}" + } + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + -368, + 672 + ], + "id": "460f89f9-a7dd-48ab-a6bf-7d96a33c2f8e", + "name": "Extract - Archivo Nómina HN", + "retryOnFail": false, + "onError": "continueRegularOutput" + }, + { + "parameters": { + "jsCode": "const items=$input.all(); const base=$('Preparar entrada app').first().json||{};\nfunction clean(v){return String(v??'').replace(/\\uFEFF/g,'').replace(/\\u00A0/g,' ').replace(/\\s+/g,' ').trim();}\nfunction normalize(v){return clean(v).toLowerCase().normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[^a-z0-9 #]/g,' ').replace(/\\s+/g,' ').trim();}\nfunction parseMoney(v){if(typeof v==='number')return Number.isFinite(v)?v:0;const s=String(v??'').replace(/HNL|LPS|USD/gi,'').replace(/\\$/g,'').replace(/,/g,'').replace(/\\s+/g,'').trim();const p=Number.parseFloat(s);return Number.isFinite(p)?p:0;}\nfunction roundMoney(v){return Math.round((Number(v)||0)*100)/100;}\nfunction normalizeAccount(v){if(typeof v==='number'&&Number.isFinite(v))return String(Math.round(v));const raw=clean(v);if(/^[+-]?(?:\\d+(?:\\.\\d+)?|\\.\\d+)[eE][+-]?\\d+$/.test(raw)){const n=Number(raw);if(Number.isFinite(n))return String(Math.round(n));}return raw.replace(/\\.0$/g,'').replace(/\\D/g,'').trim();}\nfunction getValue(row,keys){for(const k of keys){if(row[k]!==undefined&&row[k]!==null&&row[k]!=='')return row[k];}const actual=Object.keys(row||{});for(const expected of keys){const ne=normalize(expected);const k=actual.find(x=>normalize(x)===ne);if(k){const v=row[k];if(v!==undefined&&v!==null&&v!=='')return v;}}return '';}\nfunction validEmployeeName(v){const name=clean(v);const n=normalize(name);if(!name||/^[\\d.,\\s-]+$/.test(name))return false;const bad=['total','subtotal','total general','gran total','nombre','empleado','colaborador'];return !bad.some(t=>n===t||n.startsWith(`${t} `));}\nfunction sourceMeta(i){try{return $('Preparar archivos nómina HN').itemMatching(i).json||{};}catch{return {};}}\nconst rows=[];const ignored=[];const counters=new Map();const summaries=new Map();\nitems.forEach((item,index)=>{const source=item.json||{};const linked=sourceMeta(index);const sourceFile=clean(linked.source_file||'');const sourceSheet=clean(linked.sheet_name||'');const currency=clean(linked.currency||'HNL').toUpperCase()==='USD'?'USD':'HNL';const ck=`${sourceFile}|${sourceSheet}`;const dataIndex=(counters.get(ck)||0)+1;counters.set(ck,dataIndex);const rowNumber=Number(linked.header_row||1)+dataIndex;\n const employeeName=clean(getValue(source,['NOMBRE','Nombre','Nombre del Empleado','NOMBRE DEL EMPLEADO']));\n const employeeNumber=clean(getValue(source,['Identidad','Identidad ','IDENTIDAD','Employee Number','Employee #']));\n const account=normalizeAccount(getValue(source,['# de Cuenta Bac','# de Cuenta Banco','CUENTA BANCO','Cuenta Banco','CUENTA BANCARIA','Cuenta Bancaria','CUENTA']));\n const amount=roundMoney(parseMoney(getValue(source,['NETO A PAGAR','Neto a Pagar','Total a Pagar','TOTAL A PAGAR','Total Neto'])));\n const summary=summaries.get(ck)||{source_file:sourceFile,source_sheet:sourceSheet,currency,extracted_rows_count:0,accepted_rows_count:0,ignored_rows_count:0,total_amount:0};summary.extracted_rows_count++;\n if(!validEmployeeName(employeeName)||amount<=0||amount>5000000){summary.ignored_rows_count++;summaries.set(ck,summary);if(employeeName||amount)ignored.push({source_file:sourceFile,source_sheet:sourceSheet,row_number:rowNumber,employee_name:employeeName,account,payroll_amount:amount,currency});return;}\n rows.push({payroll_row_key:`HN_PAYROLL_${index+1}_${rowNumber}`,row_number:rowNumber,employee_name:employeeName,employee_number:employeeNumber,account,payroll_account:account,payroll_amount:amount,currency,source_file:sourceFile,source_kind:'quincenal',source_sheet:sourceSheet,payroll_group:'honduras',is_main_payroll:true,raw:source});summary.accepted_rows_count++;summary.total_amount=roundMoney(summary.total_amount+amount);summaries.set(ck,summary);\n});\nif(!rows.length)throw new Error('Honduras: no se encontró ninguna fila válida de nómina en el archivo cargado.');\nconst totalsByCurrency=rows.reduce((acc,r)=>{acc[r.currency]=roundMoney((acc[r.currency]||0)+r.payroll_amount);return acc;},{});const totalAmount=roundMoney(rows.reduce((s,r)=>s+r.payroll_amount,0));\nreturn [{json:{...base,stage:'nomina_hn_normalizada',payroll:{source:'nomina_honduras_multimoneda',files_count:Number(base.summary?.payroll_files_count||0),file_summaries:Array.from(summaries.values()),raw_rows_count:items.length,rows_count:rows.length,valid_rows_count:rows.length,base_rows_count:rows.length,supplement_rows_count:0,attached_supplements_count:0,unattached_supplements_count:0,no_account_rows_count:rows.filter(r=>!r.account).length,ignored_rows_count:ignored.length,base_total:totalAmount,supplements_total:0,total_amount:totalAmount,totals_by_currency:totalsByCurrency,rows,no_account_rows:rows.filter(r=>!r.account),grouped_by_account:[],potential_supplements:[]},debug_payroll:{file_summaries:Array.from(summaries.values()),ignored_rows_preview:ignored.slice(0,100)}}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -128, + 672 + ], + "id": "1495fff4-c34d-4885-b1a9-bf648765b25b", + "name": "Normalizar Nómina HN" + }, + { + "parameters": { + "mode": "combine", + "combineBy": "combineByPosition", + "options": {} + }, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 128, + 512 + ], + "id": "951d4aea-afdd-4e3b-bcff-30ac7232ccbe", + "name": "Merge Banco + Nómina HN" + }, + { + "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 Honduras", + "fields": [ + "firstName", + "middleName", + "lastName", + "displayName", + "department", + "division", + "location", + "customPosicion-Cliente", + "hireDate", + "originalHireDate", + "status", + "terminationDate", + "employeeNumber", + "country", + "workEmail", + "homeEmail", + "bestEmail" + ] + }, + "options": { + "response": { + "response": { + "responseFormat": "json" + } + }, + "timeout": 300000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 368, + 352 + ], + "id": "fe29b239-2467-40f5-b84f-822376bd4b10", + "name": "HTTP - Empleados BambooHR HN", + "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 HN').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 === 'hn' ||\n country.includes('honduras') ||\n location === 'hn' ||\n location.includes('honduras')\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 'honduras_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 Honduras 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 Honduras,\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 * - NOMBRE COMPLETO · Honduras\n * - un perfil con nombre más corto · otro país\n *\n * El rescate fuera del país permanece activo cuando no existe una\n * coincidencia exacta entre los perfiles de Honduras.\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\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 Honduras 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_hn_normalizado'\n : 'bamboohr_hn_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 honduras_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": [ + 608, + 352 + ], + "id": "52f020c5-b1d0-4a51-803b-2576f1c7ab4e", + "name": "Normalizar BambooHR HN" + }, + { + "parameters": { + "mode": "combine", + "combineBy": "combineByPosition", + "options": {} + }, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 848, + 512 + ], + "id": "87f27436-4cbd-44e6-926e-b66d2998741b", + "name": "Merge - Agregar BambooHR HN" + }, + { + "parameters": { + "method": "POST", + "url": "https://dbit.digitalcompass.agency/rest/v1/rpc/cruce_cuentas_bamboo_aliases_activos", + "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": { + "p_pais": "HN" + }, + "options": { + "response": { + "response": { + "responseFormat": "json" + } + }, + "timeout": 30000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1088, + 512 + ], + "id": "ec09ee1f-6974-4ac5-9f24-42c7747375ee", + "name": "Obtener alias BambooHR Supabase", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 1000, + "onError": "continueRegularOutput" + }, + { + "parameters": { + "jsCode": "const data = $('Merge - Agregar BambooHR HN').first().json || {};\nconst aliasResponse = $input.first().json || {};\nconst validatedBambooAliases = Array.isArray(aliasResponse.aliases)\n ? aliasResponse.aliases\n : Array.isArray(aliasResponse)\n ? aliasResponse\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\nconst normalizedNameCache = new Map();\nconst nameWordsCache = new Map();\nconst nameMatchScoreCache = new Map();\n\nfunction normalizeName(value) {\n const raw = clean(value);\n if (normalizedNameCache.has(raw)) {\n return normalizedNameCache.get(raw);\n }\n\n const normalized = raw\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 normalizedNameCache.set(raw, normalized);\n return normalized;\n}\n\nfunction normalizeAccount(value) {\n return String(value ?? '').replace(/\\.0$/g, '').replace(/\\D/g, '').trim();\n}\n\nfunction roundMoney(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n}\n\nfunction moneyDiff(payroll, bank) {\n return roundMoney((Number(payroll) || 0) - (Number(bank) || 0));\n}\n\n// Los archivos de Honduras presentan diferencias de redondeo de 0.01 y,\n// en algunos pagos USD, de hasta 0.03 entre la hoja y el plano bancario.\n// Esas variaciones no representan una discrepancia operativa.\nfunction sameCent(a, b) {\n return Math.abs(moneyDiff(a, b)) <= 0.05;\n}\n\nconst IGNORED_NAME_TOKENS = new Set([\n 'de', 'del', 'la', 'las', 'los', 'y', 'e', 'el',\n 'da', 'do', 'dos', 'das', 'van', 'von',\n]);\n\nfunction nameWords(value) {\n const normalized = normalizeName(value);\n if (nameWordsCache.has(normalized)) {\n return nameWordsCache.get(normalized);\n }\n\n const words = normalized\n .split(' ')\n .filter((word) => word.length > 1 && !IGNORED_NAME_TOKENS.has(word));\n\n nameWordsCache.set(normalized, words);\n return words;\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 let previous = Array.from({ length: b.length + 1 }, (_, index) => index);\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 previous = current;\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 = normalizeName(a);\n const normalizedB = normalizeName(b);\n if (!normalizedA || !normalizedB) return false;\n if (normalizedA === normalizedB) return true;\n\n const wordsA = nameWords(a);\n const wordsB = nameWords(b);\n if (!wordsA.length || !wordsB.length) return false;\n\n const usedB = new Set();\n let matches = 0;\n for (const wordA of wordsA) {\n const matchIndex = wordsB.findIndex((wordB, index) =>\n !usedB.has(index) && tokenMatches(wordA, wordB)\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 if (smallerLength <= 2) return matches === smallerLength && matches >= 2;\n return matches >= 2 && ratio >= 0.6;\n}\n\nfunction bankNameLooksConsistent(fileName, holderName) {\n const fileWords = nameWords(fileName);\n const holderWords = nameWords(holderName);\n if (!fileWords.length || !holderWords.length) return true;\n\n const used = new Set();\n for (const fw of fileWords) {\n let idx = holderWords.findIndex((hw, i) => !used.has(i) && hw === fw);\n\n // Los archivos BAC pueden truncar el último carácter del nombre\n // (ej. ALVARAD vs ALVARADO). Aceptamos solo truncamiento por prefijo,\n // no errores internos/transposiciones; por eso SANTAIGO vs SANTIAGO\n // sigue reportándose como diferencia de nombre.\n if (idx < 0 && fw.length >= 5) {\n idx = holderWords.findIndex((hw, i) =>\n !used.has(i) &&\n Math.abs(hw.length - fw.length) <= 2 &&\n (hw.startsWith(fw) || fw.startsWith(hw))\n );\n }\n\n if (idx < 0 && fw.length <= 3) {\n idx = holderWords.findIndex((hw, i) => !used.has(i) && hw.startsWith(fw));\n }\n\n if (idx < 0) return false;\n used.add(idx);\n }\n return true;\n}\n\nfunction nameMatchScore(a, b) {\n const na = normalizeName(a);\n const nb = normalizeName(b);\n if (!na || !nb) return 0;\n\n const cacheKey = na <= nb ? `${na}\\u0000${nb}` : `${nb}\\u0000${na}`;\n if (nameMatchScoreCache.has(cacheKey)) {\n return nameMatchScoreCache.get(cacheKey);\n }\n\n if (na === nb) {\n nameMatchScoreCache.set(cacheKey, 1);\n return 1;\n }\n\n const wa = nameWords(a);\n const wb = nameWords(b);\n if (!samePersonName(a, b)) {\n nameMatchScoreCache.set(cacheKey, 0);\n return 0;\n }\n\n const setA = new Set(wa);\n const setB = new Set(wb);\n let exact = 0;\n for (const w of setA) if (setB.has(w)) exact += 1;\n const min = Math.max(1, Math.min(setA.size, setB.size));\n const score = Math.min(\n 0.99,\n 0.75 + (exact / min) * 0.20 + (wa.length === wb.length ? 0.04 : 0)\n );\n\n nameMatchScoreCache.set(cacheKey, score);\n return score;\n}\n\nfunction bankNames(row) {\n return [row.bank_name_file, row.bank_account_holder]\n .map(clean)\n .filter(Boolean);\n}\n\nfunction bestNameScore(payrollName, bankRow) {\n return Math.max(0, ...bankNames(bankRow).map((name) => nameMatchScore(payrollName, name)));\n}\n\nfunction bankDisplayName(row) {\n return clean(row.bank_name_file) || clean(row.bank_account_holder) || 'Pago bancario';\n}\n\nfunction bankRowKey(row, index) {\n return clean(row.bank_row_key) || `${clean(row.source_file)}|${row.row_number || index + 1}`;\n}\n\n\nconst metadata = data.metadata || {};\n\nfunction parseBankDate(value, sourceFile = '') {\n const raw = clean(value);\n let match = raw.match(/^(\\d{1,2})\\/(\\d{1,2})\\/(\\d{4})/);\n if (match) {\n return {\n year: Number(match[3]),\n month: Number(match[2]),\n day: Number(match[1]),\n };\n }\n\n match = raw.match(/^(\\d{4})-(\\d{1,2})-(\\d{1,2})/);\n if (match) {\n return {\n year: Number(match[1]),\n month: Number(match[2]),\n day: Number(match[3]),\n };\n }\n\n // Respaldo por el patrón habitual del nombre: _DDMMYY / _DDMMYYYY.\n const fileMatch = clean(sourceFile).match(/_(\\d{2})(\\d{2})(\\d{2}|\\d{4})(?:\\D|$)/);\n if (!fileMatch) return null;\n\n const yearPart = Number(fileMatch[3]);\n return {\n year: fileMatch[3].length === 2 ? 2000 + yearPart : yearPart,\n month: Number(fileMatch[2]),\n day: Number(fileMatch[1]),\n };\n}\n\nfunction selectedPeriodKind() {\n const type = clean(metadata.period_type).toLowerCase();\n if (type === 'quincena_15') return 'q15';\n if (type === 'quincena_30') return 'q30';\n return 'unknown';\n}\n\nfunction conceptPeriodHint(row) {\n const concept = normalizeName(row.concept || '');\n if (!concept) return 'unknown';\n\n if (\n /(?:^| )1(?:ra|er|era)? quincena(?: |$)/.test(concept) ||\n concept.includes('primera quincena') ||\n concept.includes('quincena 1ra') ||\n concept.includes('quincena 1')\n ) {\n return 'q15';\n }\n\n if (\n /(?:^| )2(?:da|do|nda)? quincena(?: |$)/.test(concept) ||\n concept.includes('segunda quincena') ||\n concept.includes('quincena 2da') ||\n concept.includes('quincena 2') ||\n concept.includes('fin de mes')\n ) {\n return 'q30';\n }\n\n return 'unknown';\n}\n\nfunction periodInfoForBankRow(row) {\n const selected = selectedPeriodKind();\n const rowDate = parseBankDate(row.application_date, row.source_file);\n const selectedYear = Number(metadata.year || 0);\n const selectedMonth = Number(metadata.month || 0);\n const hint = conceptPeriodHint(row);\n\n if (selected === 'unknown') {\n return { known: false, selected, inPeriod: true, affinity: 1, rowDate, hint };\n }\n\n if (hint !== 'unknown' && hint === selected) {\n return { known: true, selected, inPeriod: true, affinity: 4, rowDate, hint };\n }\n\n if (!rowDate || !selectedYear || !selectedMonth) {\n return { known: false, selected, inPeriod: true, affinity: 1, rowDate, hint };\n }\n\n if (rowDate.year !== selectedYear || rowDate.month !== selectedMonth) {\n return { known: true, selected, inPeriod: false, affinity: 0, rowDate, hint };\n }\n\n const inHalf = selected === 'q15'\n ? rowDate.day <= 15\n : rowDate.day >= 16;\n\n return {\n known: true,\n selected,\n inPeriod: inHalf,\n affinity: inHalf ? 3 : 0,\n rowDate,\n hint,\n };\n}\n\nfunction isSupplementPayroll(payroll) {\n return clean(payroll?.source_kind).toLowerCase() !== 'quincenal';\n}\n\nfunction bankCandidatePeriodScore(payroll, bank) {\n const info = periodInfoForBankRow(bank);\n if (!info.known || info.inPeriod) return info.affinity || 1;\n\n // Una extraordinaria/vacación/XIII Mes puede pagarse fuera de la mitad\n // estricta del mes. Se permite como respaldo, siempre detrás de cualquier\n // candidato del período seleccionado.\n if (isSupplementPayroll(payroll) && info.rowDate) {\n const selectedYear = Number(metadata.year || 0);\n const selectedMonth = Number(metadata.month || 0);\n if (info.rowDate.year === selectedYear && info.rowDate.month === selectedMonth) {\n return 0.5;\n }\n }\n\n return -1;\n}\n\nconst payrollRows = Array.isArray(data.payroll?.rows) ? data.payroll.rows : [];\nconst bankRows = Array.isArray(data.bank?.rows) ? data.bank.rows : [];\n\n// Guardia de seguridad: nunca generar un reporte \"válido\" cuando el request\n// traía archivos pero el parser no consiguió ninguna fila. Esto evita que\n// todas las personas de nómina aparezcan falsamente como discrepancias.\nif (payrollRows.length === 0) {\n throw new Error(\n 'Honduras: no se pudo leer ninguna fila válida de nómina. ' +\n 'Revisa los archivos Excel de Honduras y las hojas de nómina configuradas.'\n );\n}\n\nif (bankRows.length === 0) {\n const detectedFiles = Number(data.bank?.files_count || data.summary?.bank_files_count || 0);\n const invalidFiles = Array.isArray(data.bank?.file_summaries)\n ? data.bank.file_summaries.filter((file) => file?.ok === false)\n : [];\n\n throw new Error(\n 'Honduras: no se pudo leer ninguna transacción bancaria. ' +\n `Archivos bancarios detectados: ${detectedFiles}. ` +\n (invalidFiles.length\n ? `Archivos con error de formato: ${invalidFiles.map((file) => file.file_name).join(', ')}.`\n : 'Verifica que los CSV sean los planos bancarios esperados.')\n );\n}\n\nconst bambooEmployees = Array.isArray(data.bamboo?.employees) ? data.bamboo.employees : [];\nconst resolvedNameMatches = data.bamboo?.resolved_name_matches || {};\nconst bambooValidationAvailable = data.bamboo?.fetch_complete === true &&\n data.bamboo?.validation_available === true && bambooEmployees.length > 0;\n\nconst bambooByEmployeeNumber = new Map();\nconst bambooById = new Map();\nconst bambooByExactName = new Map();\nfor (const employee of bambooEmployees) {\n const number = clean(employee.employee_number || employee.employeeNumber);\n const id = clean(employee.bamboo_id || employee.id);\n if (number) {\n const arr = bambooByEmployeeNumber.get(number) || [];\n arr.push(employee);\n bambooByEmployeeNumber.set(number, arr);\n }\n if (id) bambooById.set(id, employee);\n for (const alias of employee.aliases || [employee.full_name]) {\n const normalized = normalizeName(alias);\n if (!normalized) continue;\n const arr = bambooByExactName.get(normalized) || [];\n arr.push(employee);\n bambooByExactName.set(normalized, arr);\n }\n}\n\nfunction employeeKey(employee) {\n return clean(employee?.bamboo_id || employee?.id) ||\n clean(employee?.employee_number || employee?.employeeNumber) ||\n normalizeName(employee?.full_name || employee?.displayName);\n}\n\nfunction validatedAliasDecision(row) {\n if (!validatedBambooAliases.length) return null;\n const rowAccount = normalizeAccount(row.account);\n const names = bankNames(row).map(normalizeName).filter(Boolean);\n const candidates = validatedBambooAliases.filter((alias) => {\n const country = clean(alias.pais || alias.country).toUpperCase();\n if (country && country !== 'HN') return false;\n const source = normalizeName(alias.nombre_origen || alias.source_name || alias.alias || '');\n if (!source || !names.includes(source)) return false;\n const aliasAccount = normalizeAccount(alias.cuenta_bancaria || alias.cuenta_banco || alias.bank_account || '');\n return !aliasAccount || !rowAccount || aliasAccount === rowAccount;\n });\n if (candidates.length !== 1) return null;\n const alias = candidates[0];\n let employee = null;\n const employeeNumber = clean(alias.bamboo_employee_number || '');\n const bambooId = clean(alias.bamboo_employee_id || '');\n const bambooName = normalizeName(alias.bamboo_nombre || '');\n if (employeeNumber && (bambooByEmployeeNumber.get(employeeNumber) || []).length === 1) {\n employee = bambooByEmployeeNumber.get(employeeNumber)[0];\n } else if (bambooId && bambooById.has(bambooId)) {\n employee = bambooById.get(bambooId);\n } else if (bambooName && (bambooByExactName.get(bambooName) || []).length === 1) {\n employee = bambooByExactName.get(bambooName)[0];\n }\n if (!employee) return null;\n return {\n found: true,\n matched_by: 'validated_bamboo_alias',\n confidence: 1,\n employee,\n employee_key: employeeKey(employee),\n bamboo_alias: clean(alias.bamboo_nombre) || employee.full_name || '',\n };\n}\n\nfunction precomputedDecision(name) {\n const normalized = normalizeName(name);\n if (!normalized) return null;\n const decision = resolvedNameMatches[normalized];\n if (!decision || decision.found !== true) return null;\n const index = Number(decision.employee_index);\n const employee = Number.isInteger(index) && index >= 0 ? bambooEmployees[index] : null;\n const key = clean(decision.employee_key) || employeeKey(employee);\n if (!key) return null;\n return {\n found: true,\n matched_by: decision.matched_by || 'precomputed_name',\n confidence: Number(decision.confidence || 0),\n employee,\n employee_key: key,\n bamboo_alias: clean(decision.bamboo_alias) || employee?.full_name || '',\n query_name: clean(name),\n };\n}\n\nfunction resolveBamboo(row, linkedPayrollNames = []) {\n if (!bambooValidationAvailable) return { found: false, reason: 'validation_unavailable' };\n const validated = validatedAliasDecision(row);\n if (validated) return validated;\n\n const names = Array.from(new Set([...bankNames(row), ...linkedPayrollNames.map(clean)].filter(Boolean)));\n const decisions = names.map(precomputedDecision).filter(Boolean);\n if (!decisions.length) return { found: false, reason: 'no_confident_name_match' };\n\n const byKey = new Map();\n for (const d of decisions) {\n const current = byKey.get(d.employee_key);\n if (!current || d.confidence > current.confidence) byKey.set(d.employee_key, d);\n }\n const ranked = Array.from(byKey.values()).sort((a, b) => b.confidence - a.confidence);\n if (ranked.length === 1) return ranked[0];\n if ((ranked[0].confidence - ranked[1].confidence) >= 0.08) return ranked[0];\n\n // Si el nombre corto del archivo es ambiguo pero el nombre legal del\n // cuentahabiente apunta de forma única a BambooHR, damos prioridad a este\n // último. Caso real validado: \"Suyapa Palma\" vs\n // \"SUYAPA MELISA PALMA AGUILAR\".\n const holderNormalized = normalizeName(row.bank_account_holder || '');\n if (holderNormalized) {\n const holderMatches = ranked.filter((decision) =>\n normalizeName(decision.query_name || '') === holderNormalized\n );\n if (holderMatches.length === 1) return holderMatches[0];\n }\n\n return { found: false, reason: 'conflicting_precomputed_name_matches', candidates: ranked.slice(0, 3) };\n}\n\nconst usedBankKeys = new Set();\nconst matchedPayrollIndexes = new Set();\nconst matchedPairs = [];\nconst bankIndexByKey = new Map();\n\n// Índice por centavos para que la pasada principal no compare cada fila de\n// nómina contra todas las transacciones del banco. Esto preserva exactamente\n// la lógica original: la primera pasada ya exigía el mismo monto al centavo.\nconst bankIndexesByAmountCents = new Map();\n\nfunction amountCents(value) {\n return Math.round(roundMoney(value) * 100);\n}\n\nbankRows.forEach((row, index) => {\n const key = bankRowKey(row, index);\n bankIndexByKey.set(key, index);\n\n const amountKey = `${clean(row.currency || 'HNL').toUpperCase()}|${amountCents(row.amount)}`;\n const bucket = bankIndexesByAmountCents.get(amountKey) || [];\n bucket.push(index);\n bankIndexesByAmountCents.set(amountKey, bucket);\n});\n\nfunction availableNameCandidates(payroll, requireExactAmount) {\n const candidates = [];\n\n // La pasada exacta solo inspecciona filas con el mismo importe. La segunda\n // pasada conserva el barrido completo y el mismo criterio de persona fuerte\n // + menor diferencia que tenía el workflow validado.\n const candidateIndexes = requireExactAmount\n ? (bankIndexesByAmountCents.get(`${clean(payroll.currency || 'HNL').toUpperCase()}|${amountCents(payroll.payroll_amount)}`) || [])\n : null;\n\n const inspectBank = (bankIndex) => {\n const bank = bankRows[bankIndex];\n if (!bank) return;\n\n const key = bankRowKey(bank, bankIndex);\n if (usedBankKeys.has(key)) return;\n if (clean(payroll.currency || 'HNL').toUpperCase() !== clean(bank.currency || 'HNL').toUpperCase()) return;\n\n if (requireExactAmount && !sameCent(payroll.payroll_amount, bank.amount)) return;\n\n const periodScore = bankCandidatePeriodScore(payroll, bank);\n if (periodScore < 0) return;\n\n const score = bestNameScore(payroll.employee_name, bank);\n if (score <= 0) return;\n\n candidates.push({\n bank,\n bankIndex,\n key,\n score,\n periodScore,\n amountDiff: Math.abs(moneyDiff(payroll.payroll_amount, bank.amount)),\n });\n };\n\n if (candidateIndexes) {\n for (const bankIndex of candidateIndexes) {\n inspectBank(bankIndex);\n }\n } else {\n for (let bankIndex = 0; bankIndex < bankRows.length; bankIndex++) {\n inspectBank(bankIndex);\n }\n }\n\n candidates.sort((a, b) => {\n if (b.periodScore !== a.periodScore) return b.periodScore - a.periodScore;\n if (b.score !== a.score) return b.score - a.score;\n if (a.amountDiff !== b.amountDiff) return a.amountDiff - b.amountDiff;\n return a.bankIndex - b.bankIndex;\n });\n\n return candidates;\n}\n\n// Primera pasada: persona + monto exacto. Es la evidencia más fuerte y evita\n// que extraordinarias/vacaciones de una misma persona se mezclen entre sí.\npayrollRows.forEach((payroll, payrollIndex) => {\n const candidates = availableNameCandidates(payroll, true);\n if (!candidates.length) return;\n const chosen = candidates[0];\n usedBankKeys.add(chosen.key);\n matchedPayrollIndexes.add(payrollIndex);\n matchedPairs.push({ payroll, payrollIndex, ...chosen });\n});\n\n// Segunda pasada: persona fuerte aunque el monto sea distinto. Solo se toma\n// una contraparte bancaria por fila de nómina y se prioriza la menor diferencia.\npayrollRows.forEach((payroll, payrollIndex) => {\n if (matchedPayrollIndexes.has(payrollIndex)) return;\n const candidates = availableNameCandidates(payroll, false);\n if (!candidates.length) return;\n const chosen = candidates[0];\n usedBankKeys.add(chosen.key);\n matchedPayrollIndexes.add(payrollIndex);\n matchedPairs.push({ payroll, payrollIndex, ...chosen });\n});\n\nconst linkedPayrollNamesByBankKey = new Map();\nfor (const pair of matchedPairs) {\n const current = linkedPayrollNamesByBankKey.get(pair.key) || [];\n current.push(pair.payroll.employee_name);\n linkedPayrollNamesByBankKey.set(pair.key, current);\n}\n\n// Filas bancarias del período de Honduras.\n// Toda fila emparejada entra siempre. Para una fila no emparejada se usa la\n// fecha/concepto del plano para evitar arrastrar accidentalmente otra quincena.\nconst includedBankRows = [];\nconst excludedBankRows = [];\n\nfor (let index = 0; index < bankRows.length; index++) {\n const row = bankRows[index];\n const key = bankRowKey(row, index);\n\n if (usedBankKeys.has(key)) {\n includedBankRows.push({ row, key, index, inclusion: 'matched_payroll' });\n continue;\n }\n\n const periodInfo = periodInfoForBankRow(row);\n if (periodInfo.known && !periodInfo.inPeriod) {\n excludedBankRows.push({ row, key, reason: 'outside_selected_payroll_period' });\n continue;\n }\n\n includedBankRows.push({ row, key, index, inclusion: 'unmatched_current_period' });\n}\n\nconst includedBankKeySet = new Set(includedBankRows.map((entry) => entry.key));\n\nconst coreRows = [];\nconst wrongAccountGroups = new Map();\nfor (const pair of matchedPairs) {\n if (!includedBankKeySet.has(pair.key)) continue;\n const payrollAmount = roundMoney(pair.payroll.payroll_amount);\n const bankAmount = roundMoney(pair.bank.amount);\n const difference = moneyDiff(payrollAmount, bankAmount);\n const currency = clean(pair.payroll.currency || pair.bank.currency || 'HNL').toUpperCase() === 'USD' ? 'USD' : 'HNL';\n const payrollAccount = normalizeAccount(pair.payroll.account || pair.payroll.payroll_account);\n const bankAccount = normalizeAccount(pair.bank.account);\n const accountMismatch = Boolean(payrollAccount && bankAccount && payrollAccount !== bankAccount);\n if (accountMismatch) {\n const groupKey = `${normalizeName(pair.payroll.employee_name)}|${currency}|${payrollAccount}|${bankAccount}`;\n const current = wrongAccountGroups.get(groupKey) || {\n id: `hn_wrong_account_${wrongAccountGroups.size + 1}`,\n employee: pair.payroll.employee_name,\n employee_name: pair.payroll.employee_name,\n employeeNumber: pair.payroll.employee_number || '', employee_number: pair.payroll.employee_number || '',\n account: bankAccount, payrollAccount, payroll_account: payrollAccount, bankAccount, bank_account: bankAccount,\n currency, payrollAmount:0, payroll_amount:0, bankAmount:0, bank_amount:0, difference:0,\n status:'Pendiente revisión', category:'posible_cuenta_mal_digitada', subcategory:'nombre_y_monto_coinciden_cuenta_diferente',\n observation:`El nombre y el monto coinciden, pero la cuenta de nómina (${payrollAccount}) es diferente a la cuenta del banco (${bankAccount}).`,\n source_rows:[], source_sheets:new Set(), source_files:new Set(), shipment_numbers:new Set(), plan_numbers:new Set(),\n bank_name_file: pair.bank.bank_name_file || '', bank_account_holder: pair.bank.bank_account_holder || ''\n };\n current.payrollAmount=roundMoney(current.payrollAmount+payrollAmount); current.payroll_amount=current.payrollAmount;\n current.bankAmount=roundMoney(current.bankAmount+bankAmount); current.bank_amount=current.bankAmount; current.difference=moneyDiff(current.payrollAmount,current.bankAmount);\n current.source_rows.push(pair.payroll); if(pair.payroll.source_sheet)current.source_sheets.add(pair.payroll.source_sheet); if(pair.bank.source_file)current.source_files.add(pair.bank.source_file); if(pair.bank.shipment_number)current.shipment_numbers.add(pair.bank.shipment_number); if(pair.bank.plan_number)current.plan_numbers.add(pair.bank.plan_number);\n wrongAccountGroups.set(groupKey,current); continue;\n }\n const exact = sameCent(payrollAmount, bankAmount);\n coreRows.push({id:`hn_match_${pair.payrollIndex+1}_${pair.bankIndex+1}`,employee:pair.payroll.employee_name,employee_name:pair.payroll.employee_name,employeeNumber:pair.payroll.employee_number||'',employee_number:pair.payroll.employee_number||'',account:bankAccount||payrollAccount,payrollAccount:payrollAccount,payroll_account:payrollAccount,bankAccount:bankAccount,bank_account:bankAccount,currency,payrollAmount,payroll_amount:payrollAmount,bankAmount,bank_amount:bankAmount,difference,status:exact?'Coincide':'Pendiente revisión',category:exact?'coincidencia':'discrepancia',subcategory:exact?'persona_cuenta_y_monto_exactos':'persona_y_cuenta_coinciden_monto_diferente',observation:exact?'Coincidencia de persona, cuenta y monto.':difference>0?'Se pagó de menos.':'Se pagó de más.',source_kind:pair.payroll.source_kind||'',source_sheet:pair.payroll.source_sheet||'',source_file:pair.bank.source_file||'',shipment_number:pair.bank.shipment_number||'',plan_number:pair.bank.plan_number||'',plan_label:pair.bank.plan_label||'',reference:pair.bank.reference||'',bank_name_file:pair.bank.bank_name_file||'',bank_account_holder:pair.bank.bank_account_holder||''});\n}\nfor (let payrollIndex=0; payrollIndex({...row,source_sheets:Array.from(row.source_sheets),source_files:Array.from(row.source_files),shipment_numbers:Array.from(row.shipment_numbers),plan_numbers:Array.from(row.plan_numbers),source_sheet:Array.from(row.source_sheets).join(' / '),source_file:Array.from(row.source_files).join(' / '),shipment_number:Array.from(row.shipment_numbers).join(' / '),plan_number:Array.from(row.plan_numbers).join(' / ')}));\ncoreRows.push(...wrongAccountRows);\n\n// Banco sin Nómina se agrupa para que repeticiones de la misma persona/cuenta\n// se presenten como un solo hallazgo, tal como en los reportes manuales.\n// Banco sin nómina se conserva por transacción, no agrupado por cuenta.\n// Esto es importante porque una misma persona/cuenta puede aparecer en más de\n// un envío dentro del período y cada pago debe quedar auditado por separado.\nconst bankWithoutPayrollRows = [];\nfor (const entry of includedBankRows) {\n if (usedBankKeys.has(entry.key)) continue;\n const row = entry.row;\n const displayName = bankDisplayName(row);\n const rowCurrency = clean(row.currency || 'HNL').toUpperCase() === 'USD' ? 'USD' : 'HNL';\n const bankAmount = roundMoney(Number(row.amount || 0));\n\n bankWithoutPayrollRows.push({\n id: `hn_bank_without_payroll_${entry.index + 1}`,\n employee: displayName,\n employee_name: displayName,\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: rowCurrency,\n payrollAmount: 0,\n payroll_amount: 0,\n bankAmount,\n bank_amount: bankAmount,\n difference: roundMoney(0 - bankAmount),\n status: 'Pendiente revisión',\n category: 'banco_sin_nomina',\n subcategory: 'pago_banco_sin_registro_nomina',\n observation: 'Pago bancario sin registro equivalente en las planillas cargadas.',\n bank_name_file: row.bank_name_file || '',\n bank_account_holder: row.bank_account_holder || '',\n source_files: row.source_file ? [row.source_file] : [],\n shipment_numbers: row.shipment_number ? [row.shipment_number] : [],\n plan_numbers: row.plan_number ? [row.plan_number] : [],\n source_rows: [row],\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 || null,\n });\n}\ncoreRows.push(...bankWithoutPayrollRows);\n\nconst nameDifferenceRows = [];\nfor (const entry of includedBankRows) {\n const row = entry.row;\n const rowCurrency=clean(row.currency||'HNL').toUpperCase()==='USD'?'USD':'HNL';\n if (!clean(row.bank_name_file) || !clean(row.bank_account_holder)) continue;\n if (bankNameLooksConsistent(row.bank_name_file, row.bank_account_holder)) continue;\n nameDifferenceRows.push({\n id: `hn_bank_name_difference_${entry.index + 1}`,\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: rowCurrency,\n payrollAmount: 0,\n payroll_amount: 0,\n bankAmount: roundMoney(row.amount),\n bank_amount: roundMoney(row.amount),\n difference: 0,\n status: 'Pendiente revisión',\n category: 'diferencia_nombre_banco',\n subcategory: 'nombre_archivo_vs_cuentahabiente',\n observation: `El Nombre en Archivo (${row.bank_name_file}) no coincide con el Nombre del Cuentahabiente (${row.bank_account_holder}).`,\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 || null,\n });\n}\n\nconst bankWithoutBambooMap = new Map();\nconst bambooMatchDetails = [];\nfor (const entry of includedBankRows) {\n const row = entry.row;\n const linkedNames = linkedPayrollNamesByBankKey.get(entry.key) || [];\n const decision = resolveBamboo(row, linkedNames);\n bambooMatchDetails.push({\n bank_row_key: entry.key,\n bank_name_file: row.bank_name_file || '',\n bank_account_holder: row.bank_account_holder || '',\n account: row.account || '',\n amount: row.amount || 0,\n linked_payroll_names: linkedNames,\n ...decision,\n });\n if (decision.found) continue;\n\n const displayName = bankDisplayName(row);\n const rowCurrency=clean(row.currency||'HNL').toUpperCase()==='USD'?'USD':'HNL';\n const groupingKey = `${normalizeAccount(row.account)}|${normalizeName(displayName)}|${rowCurrency}`;\n const current = bankWithoutBambooMap.get(groupingKey) || {\n id: `hn_bank_without_bamboo_${bankWithoutBambooMap.size + 1}`,\n bank_name_file: row.bank_name_file || displayName,\n bank_account_holder: row.bank_account_holder || '',\n employee_name: row.bank_name_file || displayName,\n account: row.account || '',\n currency: rowCurrency,\n bank_amount: 0,\n amount: 0,\n status: 'Pendiente revisión',\n category: 'banco_sin_bamboo',\n shipment_numbers: new Set(),\n references: new Set(),\n source_files: new Set(),\n source_rows: [],\n };\n current.bank_amount = roundMoney(current.bank_amount + Number(row.amount || 0));\n current.amount = current.bank_amount;\n if (row.shipment_number) current.shipment_numbers.add(row.shipment_number);\n if (row.reference) current.references.add(row.reference);\n if (row.source_file) current.source_files.add(row.source_file);\n current.source_rows.push(row);\n bankWithoutBambooMap.set(groupingKey, current);\n}\n\nconst bankWithoutBamboo = Array.from(bankWithoutBambooMap.values()).map((row) => ({\n ...row,\n shipment_numbers: Array.from(row.shipment_numbers),\n shipment_number: Array.from(row.shipment_numbers).join(' / '),\n references: Array.from(row.references),\n source_files: Array.from(row.source_files),\n source_file: Array.from(row.source_files).join(' / '),\n difference: roundMoney(0 - row.bank_amount),\n}));\n\nfunction priority(row) {\n const category = clean(row.category).toLowerCase();\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 === 'diferencia_nombre_banco') return 5;\n if (category === 'coincidencia') return 99;\n return 50;\n}\n\nconst rowsFinales = [...coreRows, ...nameDifferenceRows].sort((a, b) => {\n const p = priority(a) - priority(b);\n if (p !== 0) return p;\n return clean(a.employee_name).localeCompare(clean(b.employee_name), 'es');\n});\n\nconst coincidencias = coreRows.filter((row) => row.category === 'coincidencia').length;\nconst discrepancias = coreRows.filter((row) => row.category === 'discrepancia').length;\nconst bancoSinNomina = bankWithoutPayrollRows.length;\nconst diferenciasNombreBanco = nameDifferenceRows.length;\nconst posiblesCuentasMalDigitadas = wrongAccountRows.length;\nconst pendientes = discrepancias + bancoSinNomina + diferenciasNombreBanco;\nconst totalNominaByCurrency = {HNL:0,USD:0};\nfor(const row of payrollRows){const c=clean(row.currency||'HNL').toUpperCase()==='USD'?'USD':'HNL';totalNominaByCurrency[c]=roundMoney(totalNominaByCurrency[c]+Number(row.payroll_amount||0));}\nconst totalBancoByCurrency = {HNL:0,USD:0};\nfor(const entry of includedBankRows){const c=clean(entry.row.currency||'HNL').toUpperCase()==='USD'?'USD':'HNL';totalBancoByCurrency[c]=roundMoney(totalBancoByCurrency[c]+Number(entry.row.amount||0));}\nconst diferenciaByCurrency={HNL:moneyDiff(totalNominaByCurrency.HNL,totalBancoByCurrency.HNL),USD:moneyDiff(totalNominaByCurrency.USD,totalBancoByCurrency.USD)};\nconst totalNomina=totalNominaByCurrency.HNL || totalNominaByCurrency.USD;\nconst totalBanco=totalBancoByCurrency.HNL || totalBancoByCurrency.USD;\nconst diferenciaTotal=totalNominaByCurrency.HNL||totalBancoByCurrency.HNL?diferenciaByCurrency.HNL:diferenciaByCurrency.USD;\n\nconst bambooSummaryCompact = { ...(data.bamboo || {}) };\ndelete bambooSummaryCompact.employees;\n\nreturn [{\n json: {\n ok: true,\n stage: 'cruce_nomina_banco_honduras',\n errors: [],\n metadata: data.metadata || {},\n summary: {\n coincidencias,\n discrepancias,\n bancoSinNomina,\n bancoSinBamboo: bankWithoutBamboo.length,\n nominaSinCuenta: 0,\n diferenciasNombreBanco,\n posiblesCuentasMalDigitadas,\n pendientes,\n filasNominaValidas: Number(data.payroll?.base_rows_count || data.payroll?.valid_rows_count || 0),\n filasNominaSinCuenta: 0,\n suplementosPotenciales: Number(data.payroll?.supplement_rows_count || 0),\n suplementosNominaAplicados: Number(data.payroll?.supplement_rows_count || 0),\n suplementosNominaNoAplicados: 0,\n suplementosNominaAdjuntados: Number(data.payroll?.supplement_rows_count || 0),\n suplementosNominaNoAdjuntados: Number(data.payroll?.unattached_supplements_count || 0),\n reconciliacionesExactasFinales: coincidencias,\n cuentasNominaAgrupadas: 0,\n transaccionesBanco: includedBankRows.length,\n cuentasBancoAgrupadas: new Set(includedBankRows.map((entry) => normalizeAccount(entry.row.account)).filter(Boolean)).size,\n empleadosBambooHN: Number(data.bamboo?.honduras_count || 0),\n empleadosBambooEnPeriodo: Number(data.bamboo?.active_in_period_count || 0),\n bambooPaginasDescargadas: Number(data.bamboo?.pages_fetched || 0),\n bambooEmpleadosEsperados: Number(data.bamboo?.expected_total || 0),\n bambooDescargaCompleta: Boolean(data.bamboo?.fetch_complete),\n bambooValidacionDisponible: bambooValidationAvailable,\n totalNominaBase: roundMoney(data.payroll?.base_total || 0),\n totalSuplementosAplicados: roundMoney(data.payroll?.supplements_total || 0),\n totalNomina,\n totalBanco,\n diferenciaTotal,\n totalNominaPorMoneda: totalNominaByCurrency,\n totalBancoPorMoneda: totalBancoByCurrency,\n diferenciaTotalPorMoneda: diferenciaByCurrency,\n },\n rows: rowsFinales,\n bankWithoutBamboo,\n nameDifferences: nameDifferenceRows,\n bambooSummary: bambooSummaryCompact,\n reportUrl: null,\n debug: {\n matched_pairs_count: matchedPairs.length,\n included_bank_rows_count: includedBankRows.length,\n excluded_bank_rows_count: excludedBankRows.length,\n excluded_bank_rows_preview: excludedBankRows.slice(0, 100),\n validated_bamboo_aliases_loaded: validatedBambooAliases.length,\n bamboo_matches: bambooMatchDetails,\n bamboo_validation_available: bambooValidationAvailable,\n banco_sin_bamboo: bankWithoutBamboo,\n payroll_preview: payrollRows.slice(0, 20),\n bank_included_preview: includedBankRows.slice(0, 20).map((entry) => entry.row),\n },\n },\n}];\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1328, + 512 + ], + "id": "6f3dcbf7-8940-45f7-b3dd-c9b96553d8da", + "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(/\\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 HN - ${periodLabel}`;\n\nconst mainReportSubtitle =\n `Diferencias de Monto Nómina vs. Banco · Honduras · ${\n formatPeriodEnd(metadata.period_end || '')\n }`;\n\nconst bancoSinBambooSubtitle =\n `Pagos en banco sin empleado identificado en BambooHR · Honduras · ${\n formatPeriodEnd(metadata.period_end || '')\n }`;\n\nconst cuentaMalDigitadaSubtitle =\n `Cuenta Mal Digitada en Nómina · Honduras · ${\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',\n 'Monto en Banco',\n 'Diferencia',\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 || 'HNL'),\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',\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 || 'HNL'),\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 currency = normalizeText(row.currency || 'HNL').toUpperCase() === 'USD' ? 'USD' : 'HNL';\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 `${currency} ${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 `${currency} ${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 Honduras',\n Number(summary.empleadosBambooHN || 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', `HNL ${roundMoney(summary.totalNominaPorMoneda?.HNL || 0).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})} | USD ${roundMoney(summary.totalNominaPorMoneda?.USD || 0).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}`],\n ['Total banco', `HNL ${roundMoney(summary.totalBancoPorMoneda?.HNL || 0).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})} | USD ${roundMoney(summary.totalBancoPorMoneda?.USD || 0).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}`],\n ['Diferencia total', `HNL ${roundMoney(summary.diferenciaTotalPorMoneda?.HNL || 0).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})} | USD ${roundMoney(summary.diferenciaTotalPorMoneda?.USD || 0).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}`],\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\n\n/*\n * Protección de integridad del reporte.\n *\n * La hoja completa queda protegida para impedir que los usuarios con acceso\n * al reporte modifiquen datos calculados, nombres, cuentas, montos, estados,\n * observaciones o cualquier otro resultado del cruce.\n *\n * Únicamente las celdas de datos de la columna \"Resolución\" quedan fuera de\n * la protección para que puedan completarse después de recibir el reporte.\n * Los encabezados y las filas fuera de la tabla permanecen protegidos.\n *\n * El propietario de la hoja conserva siempre el control. Además, el usuario\n * administrativo iaracena@gomezleemarketing.com queda registrado como editor explícito del\n * rango protegido para mantenimiento de IT.\n */\nfunction protectedSheetRequest(\n sheetId,\n description,\n resolutionColumnIndex = null,\n resolutionStartRowIndex = null,\n resolutionRowsCount = 0\n) {\n const protectedRange = {\n range: {\n sheetId,\n },\n description,\n warningOnly: false,\n editors: {\n users: [\n 'iaracena@gomezleemarketing.com',\n ],\n domainUsersCanEdit: false,\n },\n };\n\n if (\n Number.isInteger(resolutionColumnIndex) &&\n Number.isInteger(resolutionStartRowIndex) &&\n Number(resolutionRowsCount) > 0\n ) {\n protectedRange.unprotectedRanges = [\n {\n sheetId,\n startRowIndex:\n resolutionStartRowIndex,\n endRowIndex:\n resolutionStartRowIndex +\n Number(resolutionRowsCount),\n startColumnIndex:\n resolutionColumnIndex,\n endColumnIndex:\n resolutionColumnIndex + 1,\n },\n ];\n }\n\n return {\n addProtectedRange: {\n protectedRange,\n },\n };\n}\n\n\nfunction protectedResolutionRequest(\n sheetId,\n resolutionColumnIndex,\n resolutionStartRowIndex,\n resolutionRowsCount\n) {\n if (\n !Number.isInteger(resolutionColumnIndex) ||\n !Number.isInteger(resolutionStartRowIndex) ||\n Number(resolutionRowsCount) <= 0\n ) {\n return null;\n }\n\n return {\n addProtectedRange: {\n protectedRange: {\n range: {\n sheetId,\n startRowIndex:\n resolutionStartRowIndex,\n endRowIndex:\n resolutionStartRowIndex +\n Number(resolutionRowsCount),\n startColumnIndex:\n resolutionColumnIndex,\n endColumnIndex:\n resolutionColumnIndex + 1,\n },\n description:\n 'Protección GLM — Resolución. Solo Viviana Páramo y Liliana Benítez pueden editar.',\n warningOnly: false,\n editors: {\n users: [\n 'vparamo@gomezleemarketing.com',\n 'adminregional@gomezleemarketing.com',\n ],\n domainUsersCanEdit: false,\n },\n },\n },\n };\n}\n\nfunction addResolutionProtection(\n sheetId,\n resolutionColumnIndex,\n resolutionStartRowIndex,\n resolutionRowsCount\n) {\n const request =\n protectedResolutionRequest(\n sheetId,\n resolutionColumnIndex,\n resolutionStartRowIndex,\n resolutionRowsCount\n );\n\n if (request) {\n formatRequests.push(request);\n }\n}\n\n\nformatRequests.push(\n protectedSheetRequest(\n sheetIds.nominaVsBanco,\n 'Protección GLM — datos del cruce. Solo Resolución es editable.',\n 8,\n 4,\n mainReportRows.length\n ),\n protectedSheetRequest(\n sheetIds.bancoSinNomina,\n 'Protección GLM — datos del cruce. Solo Resolución es editable.',\n 6,\n 1,\n bancoSinNominaRows.length\n ),\n protectedSheetRequest(\n sheetIds.bancoSinBamboo,\n 'Protección GLM — datos del cruce. Solo Resolución es editable.',\n 7,\n 4,\n bancoSinBambooRows.length\n ),\n protectedSheetRequest(\n sheetIds.diferenciasNombreBanco,\n 'Protección GLM — datos del cruce. Solo Resolución es editable.',\n 10,\n 1,\n diferenciasNombreRows.length\n ),\n ...(hasCuentaMalDigitada\n ? [\n protectedSheetRequest(\n sheetIds.cuentaMalDigitada,\n 'Protección GLM — datos del cruce. Solo Resolución es editable.',\n 3,\n 4,\n cuentaMalDigitadaRows.length\n ),\n ]\n : []),\n protectedSheetRequest(\n sheetIds.resumen,\n 'Protección GLM — hoja de resumen de solo lectura.'\n )\n);\n\n\naddResolutionProtection(\n sheetIds.nominaVsBanco,\n 8,\n 4,\n mainReportRows.length\n);\naddResolutionProtection(\n sheetIds.bancoSinNomina,\n 6,\n 1,\n bancoSinNominaRows.length\n);\naddResolutionProtection(\n sheetIds.bancoSinBamboo,\n 7,\n 4,\n bancoSinBambooRows.length\n);\naddResolutionProtection(\n sheetIds.diferenciasNombreBanco,\n 10,\n 1,\n diferenciasNombreRows.length\n);\nif (hasCuentaMalDigitada) {\n addResolutionProtection(\n sheetIds.cuentaMalDigitada,\n 3,\n 4,\n cuentaMalDigitadaRows.length\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 // RAW conserva los números de cuenta como texto exacto (incluidos\n // ceros iniciales) y mantiene los montos numéricos porque ya se\n // envían como números en el payload.\n valueInputOption: '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": [ + 1568, + 512 + ], + "id": "9b692b9e-bc13-465b-8c91-804d3909752e", + "name": "Preparar Google Sheet" + }, + { + "parameters": { + "method": "POST", + "url": "https://dbit.digitalcompass.agency/rest/v1/rpc/cruce_cuentas_get_reporte_periodo", + "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": "={{ { p_country: $('Preparar Google Sheet').first().json.metadata.country || 'HN', p_year: Number($('Preparar Google Sheet').first().json.metadata.year || 0), p_month: Number($('Preparar Google Sheet').first().json.metadata.month || 0), p_period_type: $('Preparar Google Sheet').first().json.metadata.period_type || '' } }}", + "options": { + "response": { + "response": { + "responseFormat": "json" + } + }, + "timeout": 30000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1808, + 512 + ], + "id": "6f9b4cad-7771-4db4-b3a2-2e62d304147b", + "name": "Buscar reporte existente Supabase" + }, + { + "parameters": { + "method": "POST", + "url": "={{ (() => { const lookup = $('Buscar reporte existente Supabase').first().json || {}; const report = lookup.report || {}; const id = String(report.spreadsheet_id || '').trim(); return (lookup.found === true && id) ? 'https://sheets.googleapis.com/v4/spreadsheets/' + id + ':batchUpdate' : 'https://sheets.googleapis.com/v4/spreadsheets'; })() }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{\n(() => {\n const prepared = $('Preparar Google Sheet').first().json || {};\n const lookup = $('Buscar reporte existente Supabase').first().json || {};\n const report = lookup.report || {};\n const existingId = String(report.spreadsheet_id || '').trim();\n\n if (lookup.found === true && existingId) {\n return {\n requests: [\n {\n updateSpreadsheetProperties: {\n properties: {\n title: prepared.spreadsheetTitle || prepared.createSpreadsheetBody?.properties?.title || 'Cruce de Cuentas GLM',\n },\n fields: 'title',\n },\n },\n ],\n };\n }\n\n const createBody = prepared.createSpreadsheetBody || {};\n\n if (!Array.isArray(createBody.sheets) || createBody.sheets.length === 0) {\n throw new Error('Preparar Google Sheet no devolvió las hojas que deben crearse.');\n }\n\n return {\n properties: {\n ...(createBody.properties || {}),\n timeZone: 'America/Honduras',\n },\n sheets: createBody.sheets.map((sheet) => ({\n properties: {\n ...(sheet.properties || {}),\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": [ + 2048, + 512 + ], + "id": "c971dc31-ead1-4f97-82c6-40f8f421bd5c", + "name": "Crear Google Sheet", + "retryOnFail": true, + "credentials": { + "googleOAuth2Api": { + "id": "eHseMeH39kRcXgOF", + "name": "Google account 2" + } + } + }, + { + "parameters": { + "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $('Crear Google Sheet').first().json.spreadsheetId + '?includeGridData=true' }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleOAuth2Api", + "options": { + "timeout": 60000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 2288, + 512 + ], + "id": "abe06c36-f6cd-490d-a88b-4363c7d9211f", + "name": "Leer Google Sheet actual", + "retryOnFail": true, + "credentials": { + "googleOAuth2Api": { + "id": "dQ1MJSJSWcoWYcb8", + "name": "Google account - Isaac Producción" + } + } + }, + { + "parameters": { + "jsCode": "const prepared =\n $('Preparar Google Sheet').first().json || {};\n\nconst lookup =\n $('Buscar reporte existente Supabase').first().json || {};\n\nconst resolvedSheet =\n $('Crear Google Sheet').first().json || {};\n\nconst spreadsheet =\n $input.first().json || {};\n\nconst report =\n lookup && typeof lookup.report === 'object' && lookup.report\n ? lookup.report\n : {};\n\nconst historyExists =\n lookup.found === true &&\n Boolean(report.id);\n\nconst reusingSheet =\n historyExists &&\n Boolean(\n String(\n report.spreadsheet_id ||\n ''\n ).trim()\n );\n\nconst spreadsheetId =\n String(\n resolvedSheet.spreadsheetId ||\n report.spreadsheet_id ||\n spreadsheet.spreadsheetId ||\n ''\n ).trim();\n\nif (!spreadsheetId) {\n throw new Error(\n 'No se pudo resolver el spreadsheetId del reporte.'\n );\n}\n\nfunction normalizeText(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 normalizeAccount(value) {\n return String(value ?? '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim();\n}\n\nfunction cellValue(cell) {\n if (!cell || typeof cell !== 'object') {\n return '';\n }\n\n const entered =\n cell.userEnteredValue || {};\n\n if (\n entered.stringValue !== undefined\n ) {\n return String(entered.stringValue);\n }\n\n if (\n entered.numberValue !== undefined\n ) {\n return String(entered.numberValue);\n }\n\n if (\n entered.boolValue !== undefined\n ) {\n return entered.boolValue\n ? 'TRUE'\n : 'FALSE';\n }\n\n if (\n entered.formulaValue !== undefined\n ) {\n return String(\n cell.formattedValue ??\n entered.formulaValue\n );\n }\n\n return String(\n cell.formattedValue ?? ''\n );\n}\n\nfunction sheetMatrix(sheet) {\n const matrix = [];\n const blocks =\n Array.isArray(sheet?.data)\n ? sheet.data\n : [];\n\n for (const block of blocks) {\n const startRow =\n Number(block.startRow || 0);\n const rowData =\n Array.isArray(block.rowData)\n ? block.rowData\n : [];\n\n for (\n let rowOffset = 0;\n rowOffset < rowData.length;\n rowOffset++\n ) {\n const rowIndex =\n startRow + rowOffset;\n\n const cells =\n Array.isArray(\n rowData[rowOffset]?.values\n )\n ? rowData[rowOffset].values\n : [];\n\n matrix[rowIndex] =\n cells.map(cellValue);\n }\n }\n\n return matrix;\n}\n\nfunction findHeaderInfo(matrix) {\n for (\n let rowIndex = 0;\n rowIndex < matrix.length;\n rowIndex++\n ) {\n const row =\n Array.isArray(matrix[rowIndex])\n ? matrix[rowIndex]\n : [];\n\n const normalized =\n row.map(normalizeText);\n\n const resolutionIndex =\n normalized.findIndex(\n (value) =>\n value === 'resolucion'\n );\n\n if (resolutionIndex >= 0) {\n return {\n rowIndex,\n header: row,\n normalized,\n resolutionIndex,\n };\n }\n }\n\n return null;\n}\n\nfunction indexMatching(\n normalizedHeader,\n candidates\n) {\n for (const candidate of candidates) {\n const normalizedCandidate =\n normalizeText(candidate);\n\n const exact =\n normalizedHeader.findIndex(\n (header) =>\n header === normalizedCandidate\n );\n\n if (exact >= 0) {\n return exact;\n }\n }\n\n for (const candidate of candidates) {\n const normalizedCandidate =\n normalizeText(candidate);\n\n const partial =\n normalizedHeader.findIndex(\n (header) =>\n header.includes(\n normalizedCandidate\n )\n );\n\n if (partial >= 0) {\n return partial;\n }\n }\n\n return -1;\n}\n\nfunction firstAt(row, indexes) {\n for (const index of indexes) {\n if (\n index >= 0 &&\n String(row[index] ?? '').trim()\n ) {\n return String(row[index]);\n }\n }\n\n return '';\n}\n\nconst ids =\n prepared.sheetIds || {};\n\nfunction rowIdentity(\n sheetId,\n row,\n normalizedHeader\n) {\n const accountIndex =\n indexMatching(\n normalizedHeader,\n [\n 'Cuenta',\n 'Cuenta Banco',\n 'Cuenta Destino',\n ]\n );\n\n const account =\n accountIndex >= 0\n ? normalizeAccount(\n row[accountIndex]\n )\n : '';\n\n if (account) {\n return `account:${account}`;\n }\n\n if (\n Number(sheetId) ===\n Number(ids.nominaVsBanco)\n ) {\n const employeeIndex =\n indexMatching(\n normalizedHeader,\n ['Empleado', 'Nombre en nómina']\n );\n\n return `employee:${normalizeText(\n firstAt(\n row,\n [employeeIndex]\n )\n )}`;\n }\n\n if (\n Number(sheetId) ===\n Number(ids.bancoSinNomina)\n ) {\n const nameIndex =\n indexMatching(\n normalizedHeader,\n [\n 'Empleado Banco',\n 'Nombre en banco',\n ]\n );\n\n const currencyIndex =\n indexMatching(\n normalizedHeader,\n ['Moneda']\n );\n\n return [\n 'bank-no-payroll',\n normalizeText(\n firstAt(row, [nameIndex])\n ),\n normalizeText(\n firstAt(row, [currencyIndex])\n ),\n ].join(':');\n }\n\n if (\n Number(sheetId) ===\n Number(ids.bancoSinBamboo)\n ) {\n const nameIndex =\n indexMatching(\n normalizedHeader,\n ['Nombre en banco']\n );\n\n const holderIndex =\n indexMatching(\n normalizedHeader,\n ['Nombre del cuentahabiente']\n );\n\n return [\n 'bank-no-bamboo',\n normalizeText(\n firstAt(row, [nameIndex])\n ),\n normalizeText(\n firstAt(row, [holderIndex])\n ),\n ].join(':');\n }\n\n if (\n Number(sheetId) ===\n Number(ids.diferenciasNombreBanco)\n ) {\n const nameOne =\n indexMatching(\n normalizedHeader,\n [\n 'Nombre en Archivo',\n 'Nombre en nómina',\n ]\n );\n\n const nameTwo =\n indexMatching(\n normalizedHeader,\n [\n 'Nombre del Cuentahabiente',\n 'Nombre en banco',\n ]\n );\n\n return [\n 'name-difference',\n normalizeText(\n firstAt(row, [nameOne])\n ),\n normalizeText(\n firstAt(row, [nameTwo])\n ),\n ].join(':');\n }\n\n return '';\n}\n\nfunction collectRegularResolutions(\n sheetId,\n matrix\n) {\n const result = new Map();\n const info =\n findHeaderInfo(matrix);\n\n if (!info) {\n return result;\n }\n\n for (\n let rowIndex =\n info.rowIndex + 1;\n rowIndex < matrix.length;\n rowIndex++\n ) {\n const row =\n Array.isArray(matrix[rowIndex])\n ? matrix[rowIndex]\n : [];\n\n const resolution =\n String(\n row[\n info.resolutionIndex\n ] ?? ''\n ).trim();\n\n if (!resolution) {\n continue;\n }\n\n const key =\n rowIdentity(\n sheetId,\n row,\n info.normalized\n );\n\n if (key) {\n result.set(\n key,\n resolution\n );\n }\n }\n\n return result;\n}\n\nfunction collectWrongAccountResolutions(\n matrix\n) {\n const result = new Map();\n const info =\n findHeaderInfo(matrix);\n\n if (!info) {\n return result;\n }\n\n const fieldIndex =\n indexMatching(\n info.normalized,\n ['Campo']\n );\n\n const detailIndex =\n indexMatching(\n info.normalized,\n ['Detalle']\n );\n\n let currentEmployee = '';\n\n for (\n let rowIndex =\n info.rowIndex + 1;\n rowIndex < matrix.length;\n rowIndex++\n ) {\n const row =\n Array.isArray(matrix[rowIndex])\n ? matrix[rowIndex]\n : [];\n\n const field =\n normalizeText(\n row[fieldIndex] ?? ''\n );\n\n const detail =\n String(\n row[detailIndex] ?? ''\n );\n\n if (field === 'empleado') {\n currentEmployee =\n normalizeText(detail);\n }\n\n const resolution =\n String(\n row[\n info.resolutionIndex\n ] ?? ''\n ).trim();\n\n if (\n resolution &&\n currentEmployee &&\n field\n ) {\n result.set(\n `${currentEmployee}|${field}`,\n resolution\n );\n }\n }\n\n return result;\n}\n\nconst existingSheets =\n Array.isArray(spreadsheet.sheets)\n ? spreadsheet.sheets\n : [];\n\nconst sheetById =\n new Map();\n\nfor (const sheet of existingSheets) {\n const sheetId =\n sheet?.properties?.sheetId;\n\n if (\n sheetId !== null &&\n sheetId !== undefined\n ) {\n sheetById.set(\n Number(sheetId),\n sheet\n );\n }\n}\n\nconst resolutionMaps =\n new Map();\n\nif (reusingSheet) {\n for (\n const [key, rawId]\n of Object.entries(ids)\n ) {\n const sheetId =\n Number(rawId);\n\n const sheet =\n sheetById.get(sheetId);\n\n if (!sheet) {\n continue;\n }\n\n const matrix =\n sheetMatrix(sheet);\n\n const map =\n key === 'cuentaMalDigitada'\n ? collectWrongAccountResolutions(\n matrix\n )\n : collectRegularResolutions(\n sheetId,\n matrix\n );\n\n resolutionMaps.set(\n sheetId,\n map\n );\n }\n}\n\nfunction titleFromRange(range) {\n const match =\n String(range || '')\n .match(/^'([^']+)'!/);\n\n return match?.[1] || '';\n}\n\nconst desiredTitles =\n prepared.sheetTitles || {};\n\nconst titleToId =\n new Map();\n\nfor (\n const [key, title]\n of Object.entries(desiredTitles)\n) {\n if (\n ids[key] !== undefined\n ) {\n titleToId.set(\n String(title),\n Number(ids[key])\n );\n }\n}\n\nlet preservedResolutionsCount = 0;\n\nconst valueBatchBody =\n JSON.parse(\n JSON.stringify(\n prepared.valueBatchBody || {\n valueInputOption:\n 'USER_ENTERED',\n data: [],\n }\n )\n );\n\nfor (\n const dataEntry\n of valueBatchBody.data || []\n) {\n const title =\n titleFromRange(\n dataEntry.range\n );\n\n const sheetId =\n titleToId.get(title);\n\n if (\n sheetId === undefined ||\n !Array.isArray(\n dataEntry.values\n )\n ) {\n continue;\n }\n\n const info =\n findHeaderInfo(\n dataEntry.values\n );\n\n if (!info) {\n continue;\n }\n\n const oldMap =\n resolutionMaps.get(\n Number(sheetId)\n ) || new Map();\n\n if (\n Number(sheetId) ===\n Number(ids.cuentaMalDigitada)\n ) {\n const fieldIndex =\n indexMatching(\n info.normalized,\n ['Campo']\n );\n\n const detailIndex =\n indexMatching(\n info.normalized,\n ['Detalle']\n );\n\n let currentEmployee = '';\n\n for (\n let rowIndex =\n info.rowIndex + 1;\n rowIndex <\n dataEntry.values.length;\n rowIndex++\n ) {\n const row =\n dataEntry.values[\n rowIndex\n ];\n\n const field =\n normalizeText(\n row[fieldIndex] ?? ''\n );\n\n const detail =\n String(\n row[detailIndex] ?? ''\n );\n\n if (field === 'empleado') {\n currentEmployee =\n normalizeText(detail);\n }\n\n const key =\n currentEmployee && field\n ? `${currentEmployee}|${field}`\n : '';\n\n if (\n key &&\n oldMap.has(key)\n ) {\n while (\n row.length <=\n info.resolutionIndex\n ) {\n row.push('');\n }\n\n row[\n info.resolutionIndex\n ] = oldMap.get(key);\n\n preservedResolutionsCount++;\n }\n }\n\n continue;\n }\n\n for (\n let rowIndex =\n info.rowIndex + 1;\n rowIndex <\n dataEntry.values.length;\n rowIndex++\n ) {\n const row =\n dataEntry.values[\n rowIndex\n ];\n\n const key =\n rowIdentity(\n sheetId,\n row,\n info.normalized\n );\n\n if (\n key &&\n oldMap.has(key)\n ) {\n while (\n row.length <=\n info.resolutionIndex\n ) {\n row.push('');\n }\n\n row[\n info.resolutionIndex\n ] = oldMap.get(key);\n\n preservedResolutionsCount++;\n }\n }\n}\n\nconst desiredSheets =\n Array.isArray(\n prepared\n .createSpreadsheetBody\n ?.sheets\n )\n ? prepared\n .createSpreadsheetBody\n .sheets\n : [];\n\nconst existingIds =\n new Set(\n existingSheets\n .map(\n (sheet) =>\n Number(\n sheet?.properties\n ?.sheetId\n )\n )\n .filter(\n Number.isFinite\n )\n );\n\nconst desiredIds =\n new Set(\n desiredSheets\n .map(\n (sheet) =>\n Number(\n sheet?.properties\n ?.sheetId\n )\n )\n .filter(\n Number.isFinite\n )\n );\n\nconst newlyAddedSheetIds =\n new Set();\n\nconst structureRequests = [\n {\n updateSpreadsheetProperties: {\n properties: {\n title:\n prepared.spreadsheetTitle ||\n prepared\n .createSpreadsheetBody\n ?.properties?.title ||\n spreadsheet.properties\n ?.title ||\n 'Cruce de Cuentas GLM',\n },\n fields: 'title',\n },\n },\n];\n\nif (reusingSheet) {\n const optionalId =\n Number(\n ids.cuentaMalDigitada\n );\n\n if (\n Number.isFinite(optionalId) &&\n existingIds.has(optionalId) &&\n !desiredIds.has(optionalId)\n ) {\n structureRequests.push({\n deleteSheet: {\n sheetId: optionalId,\n },\n });\n\n existingIds.delete(\n optionalId\n );\n }\n\n for (\n let desiredIndex = 0;\n desiredIndex <\n desiredSheets.length;\n desiredIndex++\n ) {\n const desired =\n desiredSheets[\n desiredIndex\n ];\n\n const props =\n desired.properties || {};\n\n const desiredId =\n Number(props.sheetId);\n\n if (\n !Number.isFinite(\n desiredId\n )\n ) {\n continue;\n }\n\n const existing =\n existingSheets.find(\n (sheet) =>\n Number(\n sheet?.properties\n ?.sheetId\n ) === desiredId\n );\n\n if (!existing) {\n const sameTitle =\n existingSheets.find(\n (sheet) =>\n String(\n sheet?.properties\n ?.title || ''\n ) ===\n String(\n props.title || ''\n )\n );\n\n if (\n sameTitle &&\n Number(\n sameTitle\n ?.properties\n ?.sheetId\n ) !== desiredId\n ) {\n structureRequests.push({\n deleteSheet: {\n sheetId:\n Number(\n sameTitle\n .properties\n .sheetId\n ),\n },\n });\n }\n\n structureRequests.push({\n addSheet: {\n properties: {\n ...props,\n // El índice es 0-based. Así \"05 Cuenta Mal Digitada\"\n // vuelve exactamente antes de \"06 Resumen\" cuando reaparece.\n index:\n desiredIndex,\n gridProperties: {\n ...(props\n .gridProperties ||\n {}),\n frozenRowCount: 1,\n },\n },\n },\n });\n\n newlyAddedSheetIds.add(\n desiredId\n );\n\n continue;\n }\n\n const currentTitle =\n String(\n existing.properties\n ?.title || ''\n );\n\n const desiredTitle =\n String(\n props.title || ''\n );\n\n const currentIndex =\n Number(\n existing.properties\n ?.index\n );\n\n const titleNeedsUpdate =\n Boolean(\n desiredTitle &&\n currentTitle !==\n desiredTitle\n );\n\n const indexNeedsUpdate =\n Number.isFinite(\n currentIndex\n ) &&\n currentIndex !==\n desiredIndex;\n\n if (\n titleNeedsUpdate ||\n indexNeedsUpdate\n ) {\n const properties = {\n sheetId:\n desiredId,\n };\n\n const fields = [];\n\n if (titleNeedsUpdate) {\n properties.title =\n desiredTitle;\n fields.push('title');\n }\n\n if (indexNeedsUpdate) {\n properties.index =\n desiredIndex;\n fields.push('index');\n }\n\n structureRequests.push({\n updateSheetProperties: {\n properties,\n fields:\n fields.join(','),\n },\n });\n }\n }\n}\n\nconst originalFormatRequests =\n Array.isArray(\n prepared\n .formatBatchBody\n ?.requests\n )\n ? prepared\n .formatBatchBody\n .requests\n : [];\n\n/*\n * IMPORTANTE AL REUTILIZAR UN REPORTE:\n *\n * \"Actualizar estructura Google Sheet\" se ejecuta ANTES de\n * \"Formatear Google Sheet\". Cuando la estructura elimina una hoja\n * (por ejemplo, \"05 Cuenta Mal Digitada\" deja de ser necesaria),\n * Google elimina automáticamente también los ProtectedRange de esa hoja.\n *\n * Por eso NO debemos intentar borrar después esos protectedRangeId en el\n * batch de formato, porque ya dejaron de existir y Google responde:\n * \"Invalid requests[x].deleteProtectedRange: No protected range with id\".\n *\n * Se identifican aquí todas las hojas que serán eliminadas por el batch\n * de estructura y se excluyen sus protecciones del batch de formato.\n */\nconst sheetIdsScheduledForDeletion =\n new Set(\n structureRequests\n .map((request) =>\n Number(\n request?.deleteSheet?.sheetId\n )\n )\n .filter(Number.isFinite)\n );\n\nconst formatRequests = [];\nconst protectedRangeIdsScheduled =\n new Set();\n\nif (reusingSheet) {\n for (\n const sheet\n of existingSheets\n ) {\n const currentSheetId =\n Number(\n sheet?.properties?.sheetId\n );\n\n // Si esta hoja será eliminada en el paso de estructura, sus\n // protecciones también desaparecerán automáticamente.\n if (\n Number.isFinite(currentSheetId) &&\n sheetIdsScheduledForDeletion.has(\n currentSheetId\n )\n ) {\n continue;\n }\n\n const protections =\n Array.isArray(\n sheet.protectedRanges\n )\n ? sheet.protectedRanges\n : [];\n\n for (\n const protection\n of protections\n ) {\n const description =\n String(\n protection.description ||\n ''\n );\n\n const protectedRangeId =\n Number(\n protection\n .protectedRangeId\n );\n\n if (\n description.startsWith(\n 'Protección GLM'\n ) &&\n Number.isFinite(\n protectedRangeId\n ) &&\n !protectedRangeIdsScheduled.has(\n protectedRangeId\n )\n ) {\n protectedRangeIdsScheduled.add(\n protectedRangeId\n );\n\n formatRequests.push({\n deleteProtectedRange: {\n protectedRangeId,\n },\n });\n }\n }\n }\n}\n\n\n/*\n * Limpieza visual al reutilizar un reporte.\n *\n * values:batchClear elimina el CONTENIDO de las filas antiguas, pero Google\n * Sheets conserva su formato. Si una ejecución anterior tenía cientos de\n * discrepancias y la nueva tiene pocas, podían quedar celdas vacías con el\n * fondo rojo/amarillo aplicado por la ejecución anterior.\n *\n * La corrección es deliberadamente limitada: solamente se limpia el\n * background de las filas que quedan DESPUÉS de la última fila escrita por\n * la nueva ejecución. No se toca ninguna fila vigente, encabezado,\n * Resolución preservada, protección, borde, ancho, fórmula ni lógica del\n * cruce.\n */\nif (reusingSheet) {\n for (\n const dataEntry\n of valueBatchBody.data || []\n ) {\n const title =\n titleFromRange(\n dataEntry.range\n );\n\n const currentSheetId =\n Number(\n titleToId.get(title)\n );\n\n if (\n !Number.isFinite(\n currentSheetId\n ) ||\n sheetIdsScheduledForDeletion.has(\n currentSheetId\n ) ||\n newlyAddedSheetIds.has(\n currentSheetId\n )\n ) {\n continue;\n }\n\n const existingSheet =\n sheetById.get(\n currentSheetId\n );\n\n if (!existingSheet) {\n continue;\n }\n\n const currentValues =\n Array.isArray(\n dataEntry.values\n )\n ? dataEntry.values\n : [];\n\n const writtenRowCount =\n currentValues.length;\n\n const writtenColumnCount =\n currentValues.reduce(\n (maximum, row) =>\n Math.max(\n maximum,\n Array.isArray(row)\n ? row.length\n : 0\n ),\n 0\n );\n\n const gridRowCount =\n Number(\n existingSheet\n ?.properties\n ?.gridProperties\n ?.rowCount || 0\n );\n\n if (\n writtenRowCount <= 0 ||\n writtenColumnCount <= 0 ||\n !Number.isFinite(\n gridRowCount\n ) ||\n gridRowCount <=\n writtenRowCount\n ) {\n continue;\n }\n\n formatRequests.push({\n repeatCell: {\n range: {\n sheetId:\n currentSheetId,\n startRowIndex:\n writtenRowCount,\n endRowIndex:\n gridRowCount,\n startColumnIndex: 0,\n endColumnIndex:\n writtenColumnCount,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: {\n red: 1,\n green: 1,\n blue: 1,\n },\n },\n },\n fields:\n 'userEnteredFormat.backgroundColor',\n },\n });\n }\n}\n\nfor (\n const request\n of originalFormatRequests\n) {\n if (\n reusingSheet &&\n request.mergeCells\n ) {\n const mergeSheetId =\n Number(\n request\n .mergeCells\n ?.range\n ?.sheetId\n );\n\n if (\n !newlyAddedSheetIds.has(\n mergeSheetId\n )\n ) {\n continue;\n }\n }\n\n formatRequests.push(\n request\n );\n}\n\nconst clearRanges =\n (valueBatchBody.data || [])\n .map(\n (entry) =>\n entry.range\n )\n .filter(Boolean);\n\nreturn [\n {\n json: {\n ok: true,\n stage:\n 'google_sheet_destino_preparado',\n spreadsheetId,\n reportUrl:\n `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit`,\n historyExists,\n reportUpdated:\n historyExists,\n reusingSheet,\n existingHistoricalId:\n report.id\n ? String(report.id)\n : '',\n reportIdentity:\n String(\n report.report_identity ||\n ''\n ),\n previousReport:\n report,\n preservedResolutionsCount,\n valueBatchBody,\n structureBatchBody: {\n requests:\n structureRequests,\n },\n clearBatchBody: {\n ranges: clearRanges,\n },\n formatBatchBody: {\n requests:\n formatRequests,\n },\n originalResponse:\n prepared.originalResponse ||\n {},\n },\n },\n];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2528, + 512 + ], + "id": "e185a0db-f278-48df-a45f-bd8954d97531", + "name": "Preparar actualización Google Sheet" + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $('Preparar actualización Google Sheet').first().json.spreadsheetId + ':batchUpdate' }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $('Preparar actualización Google Sheet').first().json.structureBatchBody }}", + "options": { + "timeout": 60000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 2768, + 512 + ], + "id": "b7c10e3f-e8ce-4c1c-b258-a4bc0f3ee2fb", + "name": "Actualizar estructura Google Sheet", + "retryOnFail": true, + "credentials": { + "googleOAuth2Api": { + "id": "dQ1MJSJSWcoWYcb8", + "name": "Google account - Isaac Producción" + } + } + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $('Preparar actualización Google Sheet').first().json.spreadsheetId + '/values:batchClear' }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $('Preparar actualización Google Sheet').first().json.clearBatchBody }}", + "options": { + "timeout": 60000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 3008, + 512 + ], + "id": "9f6ad41c-5ac9-4555-921a-ecd8f122b045", + "name": "Limpiar Google Sheet", + "retryOnFail": true, + "credentials": { + "googleOAuth2Api": { + "id": "dQ1MJSJSWcoWYcb8", + "name": "Google account - Isaac Producción" + } + } + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $('Preparar actualización Google Sheet').first().json.spreadsheetId + '/values:batchUpdate' }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $('Preparar actualización Google Sheet').first().json.valueBatchBody }}", + "options": { + "timeout": 60000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 3248, + 512 + ], + "id": "972a7c61-0537-4aad-8a9c-781fc4d2749d", + "name": "Escribir Google Sheet", + "retryOnFail": true, + "credentials": { + "googleOAuth2Api": { + "id": "dQ1MJSJSWcoWYcb8", + "name": "Google account - Isaac Producción" + } + } + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $('Preparar actualización Google Sheet').first().json.spreadsheetId + ':batchUpdate' }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $('Preparar actualización Google Sheet').first().json.formatBatchBody }}", + "options": { + "timeout": 60000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 3488, + 512 + ], + "id": "85899c0a-f31a-4911-8d91-554d0da24e78", + "name": "Formatear Google Sheet", + "retryOnFail": true, + "credentials": { + "googleOAuth2Api": { + "id": "dQ1MJSJSWcoWYcb8", + "name": "Google account - Isaac Producción" + } + } + }, + { + "parameters": { + "jsCode": "const context =\n $('Preparar actualización Google Sheet').first().json || {};\n\nconst spreadsheetId =\n context.spreadsheetId;\n\nif (!spreadsheetId) {\n throw new Error(\n 'No se recibió spreadsheetId para configurar permisos.'\n );\n}\n\nconst allowedEmails = [\n 'iaracena@gomezleemarketing.com',\n 'ymadera@gomezleemarketing.com',\n 'mgomez@gomezleemarketing.com',\n 'jgomez@gomezleemarketing.com',\n 'vparamo@gomezleemarketing.com',\n 'adminregional@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 reportUpdated:\n context.reportUpdated === true,\n },\n}));" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3728, + 512 + ], + "id": "0508be37-3d2a-42c2-b7d5-be42098d813a", + "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": [ + 3968, + 512 + ], + "id": "8e29fb34-a901-4bf2-82c0-d4d9c81e35ce", + "name": "Compartir Google Sheet", + "credentials": { + "googleOAuth2Api": { + "id": "dQ1MJSJSWcoWYcb8", + "name": "Google account - Isaac Producción" + } + }, + "onError": "continueRegularOutput" + }, + { + "parameters": { + "jsCode": "const cruce = $('Cruzar Nómina vs Banco').first().json || {};\nconst sheetContext =\n $('Preparar actualización Google Sheet').first().json || {};\n\nconst metadata = cruce.metadata || {};\nconst summary = cruce.summary || {};\nconst debug = cruce.debug || {};\n\nconst spreadsheetId = sheetContext.spreadsheetId || cruce.spreadsheetId || '';\nconst reportUrl =\n sheetContext.reportUrl ||\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 || 'HN';\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 reportIdentity = [\n 'HN',\n String(metadata.year || ''),\n String(metadata.month || '').padStart(2, '0'),\n String(metadata.period_type || '').trim().toLowerCase(),\n].join('-');\n\nconst reportUpdated =\n sheetContext.reportUpdated === true;\n\nconst preservedResolutionsCount =\n Number(\n sheetContext.preservedResolutionsCount || 0\n );\n\nconst payload = {\n source_app: metadata.source_app || 'cruce-cuentas-glm-honduras',\n country: metadata.country || 'HN',\n country_name: metadata.country_name || 'Honduras',\n client_request_id: metadata.client_request_id || null,\n report_identity: reportIdentity,\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 report_identity: reportIdentity,\n report_updated: reportUpdated,\n preserved_resolutions_count: preservedResolutionsCount,\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\n\nif (\n reportUpdated &&\n estado !== 'resuelto'\n) {\n payload.resuelto_por_nombre = null;\n payload.resuelto_por_email = null;\n payload.resuelto_en = null;\n payload.comentario_resolucion = null;\n}\n\nreturn [\n {\n json: {\n ...cruce,\n reportUpdated,\n preservedResolutionsCount,\n existingHistoricalId: sheetContext.existingHistoricalId || '',\n reportIdentity,\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": [ + 4208, + 512 + ], + "id": "dca4a992-cb01-42eb-b64c-b6f0c1440c9e", + "name": "Preparar histórico Supabase" + }, + { + "parameters": { + "method": "POST", + "url": "https://dbit.digitalcompass.agency/rest/v1/cruces_cuentas_gt_reportes?on_conflict=report_identity", + "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": "resolution=merge-duplicates,return=representation" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.supabasePayload }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 4448, + 512 + ], + "id": "272ca2cf-cf2a-4531-bca3-c9f92753c614", + "name": "Insertar histórico Supabase", + "onError": "continueRegularOutput" + }, + { + "parameters": { + "jsCode": "const prepared = $('Preparar Google Sheet').first().json || {};\nconst sheetContext = $('Preparar actualización Google Sheet').first().json || {};\n\nconst original =\n prepared.originalResponse ||\n prepared.original_response ||\n prepared.response ||\n {};\n\nconst spreadsheetId = sheetContext.spreadsheetId || '';\nconst reportUrl =\n sheetContext.reportUrl ||\n (spreadsheetId ? `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit` : null);\n\n\nfunction collectHistoricalObjects(value, output) {\n if (Array.isArray(value)) {\n for (const item of value) collectHistoricalObjects(item, output);\n return;\n }\n\n if (!value || typeof value !== 'object') return;\n\n output.push(value);\n\n if (Array.isArray(value.data)) {\n for (const item of value.data) collectHistoricalObjects(item, output);\n }\n}\n\nconst historicalObjects = [];\ntry {\n for (const item of $items('Insertar histórico Supabase')) {\n collectHistoricalObjects(item.json, historicalObjects);\n }\n} catch (error) {\n // Si Supabase no devolvió representación, el reporte sigue respondiendo\n // igual que antes; únicamente executionId quedará vacío.\n}\n\nconst insertedHistoricalReport =\n historicalObjects.find((item) => item && item.id) || null;\n\nconst executionId = insertedHistoricalReport?.id\n ? String(insertedHistoricalReport.id)\n : '';\n\nreturn [\n {\n json: {\n ok: original.ok ?? true,\n message: reportUrl\n ? (sheetContext.reportUpdated === true\n ? `Reporte existente actualizado correctamente. Se conservaron ${Number(sheetContext.preservedResolutionsCount || 0)} resolución(es) previamente registradas.`\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 executionId: executionId || undefined,\n reportUpdated: sheetContext.reportUpdated === true,\n reusingSheet: sheetContext.reusingSheet === true,\n preservedResolutionsCount: Number(sheetContext.preservedResolutionsCount || 0),\n clientRequestId: original.metadata?.client_request_id || '',\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": [ + 4688, + 432 + ], + "id": "fd91d3a5-d496-4f08-945a-dac6d3ec8161", + "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: original.message || data.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 executionId: original.executionId || data.executionId || null,\n reportUpdated: original.reportUpdated ?? data.reportUpdated ?? false,\n reusingSheet: original.reusingSheet ?? data.reusingSheet ?? false,\n preservedResolutionsCount: Number(original.preservedResolutionsCount ?? data.preservedResolutionsCount ?? 0),\n clientRequestId: original.clientRequestId || data.clientRequestId || original.metadata?.client_request_id || data.metadata?.client_request_id || null,\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": [ + 4928, + 432 + ], + "id": "edfd725c-d971-4b46-bf64-8f29ea09f7af", + "name": "Respond to Webhook" + }, + { + "parameters": { + "jsCode": "const prepared = $('Preparar histórico Supabase').first().json || {};\nconst payload = prepared.supabasePayload || {};\nconst metadata = payload.metadata || {};\nconst summary = payload.summary || {};\nconst reportUpdated = prepared.reportUpdated === true;\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\nfunction formatGeneratedAt(date) {\n const timeZone = 'America/Tegucigalpa';\n\n const dateLabel = new Intl.DateTimeFormat('es-HN', {\n timeZone,\n day: 'numeric',\n month: 'long',\n year: 'numeric',\n }).format(date);\n\n const timeLabel = new Intl.DateTimeFormat('es-HN', {\n timeZone,\n hour: 'numeric',\n minute: '2-digit',\n hour12: true,\n }).format(date);\n\n const hour24Raw = new Intl.DateTimeFormat('en-US', {\n timeZone,\n hour: '2-digit',\n hour12: false,\n }).format(date);\n\n const hour24 =\n Number(String(hour24Raw).replace(/\\D/g, '')) % 24;\n\n const hourArticle =\n hour24 === 1 || hour24 === 13\n ? 'a la'\n : 'a las';\n\n return `${dateLabel} ${hourArticle} ${timeLabel}`;\n}\n\nfunction metricCard(label, value, accent) {\n return `\n \n \n \n \n \n ${escapeHtml(label)}\n \n\n \n ${numberValue(value).toLocaleString('en-US')}\n \n \n \n \n \n `;\n}\n\nconst countryCode = 'HN';\nconst countryName = 'Honduras';\n\nconst periodLabel =\n payload.period_label ||\n metadata.period_label ||\n [\n payload.year || metadata.year,\n payload.month || metadata.month,\n payload.period_type || metadata.period_type,\n ]\n .filter(Boolean)\n .join(' · ') ||\n 'Período no especificado';\n\nconst executedByName =\n payload.ejecutado_por_nombre ||\n metadata.requested_by_name ||\n 'Usuario GLM';\n\nconst executedByEmail =\n payload.ejecutado_por_email ||\n metadata.requested_by_email ||\n '';\n\nconst reportUrl =\n payload.report_url ||\n prepared.reportUrl ||\n '';\n\nconst discrepancias = numberValue(\n payload.discrepancias ??\n summary.discrepancias\n);\n\nconst bancoSinNomina = numberValue(\n payload.banco_sin_nomina ??\n summary.bancoSinNomina\n);\n\nconst bancoSinBamboo = numberValue(\n payload.banco_sin_bamboo ??\n summary.bancoSinBamboo\n);\n\nconst diferenciasNombreBanco = numberValue(\n metadata.diferencias_nombre_banco ??\n summary.diferenciasNombreBanco\n);\n\nconst nominaSinCuenta = numberValue(\n payload.nomina_sin_cuenta ??\n summary.nominaSinCuenta\n);\n\nconst coincidencias = numberValue(\n payload.coincidencias ??\n summary.coincidencias\n);\n\nconst generatedAt = new Date();\nconst generatedAtLabel =\n formatGeneratedAt(generatedAt);\n\nconst statusLabel =\n String(payload.estado || '').toLowerCase() === 'resuelto'\n ? 'Resuelto'\n : 'Pendiente de revisión';\n\nconst logoUrl =\n 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png';\n\nconst flagUrl =\n 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/Bandera%20de%20Honduras.webp';\n\nconst subject = reportUpdated\n ? `Reporte actualizado - Cruce de Cuentas ${countryName} - ${periodLabel}`\n : `Nuevo reporte generado - Cruce de Cuentas ${countryName} - ${periodLabel}`;\n\n/*\n * Todos son destinatarios directos.\n * Nadie se envía en CC.\n */\n\nconst to = [\n 'iaracena@gomezleemarketing.com',\n 'jgomez@gomezleemarketing.com',\n 'mgomez@gomezleemarketing.com',\n 'vparamo@gomezleemarketing.com',\n 'ymadera@gomezleemarketing.com',\n 'adminregional@gomezleemarketing.com',\n].join(',');\n\n\n// Paleta oficial GLM.\nconst metricCards = [\n metricCard(\n 'Discrepancias',\n discrepancias,\n '#FF6A13'\n ),\n metricCard(\n 'Banco sin nómina',\n bancoSinNomina,\n '#FF8C00'\n ),\n metricCard(\n 'Banco sin Bamboo',\n bancoSinBamboo,\n '#E87722'\n ),\n metricCard(\n 'Diferencias nombre banco',\n diferenciasNombreBanco,\n '#5B7F95'\n ),\n metricCard(\n 'Nómina sin cuenta',\n nominaSinCuenta,\n '#4F758B'\n ),\n].join('');\n\nconst htmlBody = `\n\n\n\n\n \n \n ${escapeHtml(subject)}\n\n\n\n\n \n \n \n\n \n\n \n \n \n  \n \n \n\n \n \n \n\n \n \n \n \n \n\n \n \n ${reportUpdated ? 'REPORTE ACTUALIZADO' : 'REPORTE GENERADO'}\n \n \n \n \n\n \n\n \n \n\n \n \n \n\n \n\n \n Cruce de Cuentas · ${escapeHtml(countryName)}\n \n\n \n ${escapeHtml(periodLabel)}\n \n\n \n\n \n \n\n \n \n\n \n \n \n\n \n ${reportUpdated ? 'Se actualizó el reporte existente del Portal de Cruce de Cuentas GLM conservando las resoluciones previamente registradas que continúan correspondiendo a los mismos casos.' : 'Se completó una nueva ejecución del Portal de Cruce de Cuentas GLM. Este es el resumen del reporte generado.'}\n \n\n \n \n\n \n \n \n\n \n \n ${metricCards}\n \n \n\n \n \n\n \n \n \n\n \n\n \n\n \n\n \n Ejecutado por\n \n\n \n ${escapeHtml(executedByName)}\n \n\n \n ${escapeHtml(\n executedByEmail ||\n 'Correo no disponible'\n )}\n \n\n \n\n \n\n \n País\n \n\n \n \n\n \n \n \n\n \n\n \n ${escapeHtml(countryName)}\n \n\n \n ${escapeHtml(\n countryCode || '—'\n )}\n \n\n \n\n \n \n\n \n\n \n\n \n\n \n\n \n Período\n \n\n \n ${escapeHtml(periodLabel)}\n \n\n \n ${escapeHtml(generatedAtLabel)}\n \n\n \n\n \n\n \n Estado inicial\n \n\n \n ${escapeHtml(statusLabel)}\n \n\n \n Coincidencias:\n ${coincidencias.toLocaleString('en-US')}\n \n\n \n\n \n\n \n\n \n \n\n \n ${\n reportUrl\n ? `\n \n \n\n \n Abrir reporte en Google Sheets\n \n\n \n \n `\n : ''\n }\n\n \n \n \n\n \n\n \n Notificación automática · Portal Cruce de Cuentas GLM\n
\n Los casos de revisión deben gestionarse directamente en el reporte generado.\n \n\n \n \n\n \n\n \n \n \n\n\n\n`;\n\nreturn [\n {\n json: {\n to,\n subject,\n htmlBody,\n\n notification: {\n country: countryCode,\n countryName,\n flagUrl,\n periodLabel,\n executedByName,\n executedByEmail,\n generatedAt: generatedAt.toISOString(),\n reportUrl,\n\n metrics: {\n discrepancias,\n bancoSinNomina,\n bancoSinBamboo,\n diferenciasNombreBanco,\n nominaSinCuenta,\n coincidencias,\n },\n },\n },\n },\n];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4688, + 672 + ], + "id": "a067ffc0-607f-4094-be4b-19bbd95f1171", + "name": "Preparar correo reporte generado" + }, + { + "parameters": { + "sendTo": "={{$json.to}}", + "subject": "={{$json.subject}}", + "message": "={{ $json.htmlBody }}", + "options": { + "appendAttribution": false + } + }, + "type": "n8n-nodes-base.gmail", + "typeVersion": 2.2, + "position": [ + 4928, + 672 + ], + "id": "c309bd25-b751-46a5-a2fd-fbc4442cb7c0", + "name": "Enviar correo reporte generado", + "webhookId": "4bcdbd7b-503d-46ac-aeaf-9207f59f01e7", + "credentials": { + "gmailOAuth2": { + "id": "UDcO1FLJqA453V2D", + "name": "Gmail account 3" + } + }, + "onError": "continueRegularOutput" + }, + { + "parameters": { + "content": "# 🇭🇳 CRUCE DE CUENTAS — HONDURAS\n\nFlujo principal para una nómina quincenal de Honduras + todos los planos bancarios BAC del período + BambooHR.\n\n- Monedas: HNL y USD.\n- BambooHR: `onlyCurrent=false`, filtrado por país/localidad Honduras.\n- Q1: días 1–15. Q2: días 16–último día real del mes.\n- Los meses/años se calculan dinámicamente, incluidos años bisiestos.\n- La nómina usa las hojas operativas validadas de GLM Honduras y omite hojas auxiliares/copias para evitar duplicados.", + "height": 260, + "width": 520 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + -1088, + 80 + ], + "id": "bfd68985-f8c4-4311-bc99-dd4f67042115", + "name": "📌 HONDURAS · V1" + }, + { + "parameters": { + "content": "# 🏦 REGLA DE PLANOS — HONDURAS\n\nSe procesan todos los CSV `Consulta Detalle de Envío_HON_...` cargados. Se reconocen planes AR94 (HNL) y AR95US (USD), además de la moneda informada dentro del archivo.\n\nEl período se filtra por la fecha de aplicación de cada transacción: Q1 = 1–15 y Q2 = 16–fin de mes. El cruce se realiza por persona, cuenta, moneda y monto, con una segunda pasada controlada para detectar diferencias o posibles cuentas mal digitadas.", + "height": 300, + "width": 700 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 0, + 0 + ], + "id": "91b14935-3200-4f6a-b926-6299f3a6141b", + "name": "📌 REGLA BANCO HN" + }, + { + "parameters": { + "content": "# 📄 ARCHIVO DE NÓMINA — HONDURAS\n\nLa app recibe un solo Excel por quincena, por ejemplo `Nomina 7.1 2026 GLM HN.xlsx` o `Nomina 7.2 2026 GLM HN.xlsx`.\n\nEl flujo extrae únicamente las hojas de pago validadas (Colgate, POP Helper, Motorola, Nestlé, Whirlpool, FF Regional y GLM) y normaliza `NOMBRE`, `Identidad`, `# de Cuenta Bac` y `NETO A PAGAR` / `Total a Pagar`. Las hojas de copia/auxiliares no se contabilizan.", + "height": 240, + "width": 620 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + -608, + 928 + ], + "id": "62ec2b77-ecfb-42a1-984c-874f84fd77ba", + "name": "📌 ARCHIVOS NÓMINA HN" + }, + { + "parameters": { + "content": "# 📊 SALIDA\n\nConserva la misma estructura de reportes de GT/TT/PA/SV/HN, reutiliza el Google Sheet del mismo período si existe, actualiza Supabase y envía correo tanto en creación como en actualización.\n\nLos valores se escriben en Google Sheets con `RAW` para preservar las cuentas bancarias como texto exacto, incluyendo ceros iniciales y evitando notación científica.", + "height": 250, + "width": 720 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 3120, + 832 + ], + "id": "70cdc5fd-3337-43dd-950d-64f68b311936", + "name": "📌 REPORTES" + } + ], + "pinData": {}, + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Preparar entrada app", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar entrada app": { + "main": [ + [ + { + "node": "Parsear CSV banco HN", + "type": "main", + "index": 0 + }, + { + "node": "Preparar archivos nómina HN", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar archivos nómina HN": { + "main": [ + [ + { + "node": "Extract - Archivo Nómina HN", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract - Archivo Nómina HN": { + "main": [ + [ + { + "node": "Normalizar Nómina HN", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parsear CSV banco HN": { + "main": [ + [ + { + "node": "Merge Banco + Nómina HN", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalizar Nómina HN": { + "main": [ + [ + { + "node": "Merge Banco + Nómina HN", + "type": "main", + "index": 1 + } + ] + ] + }, + "Merge Banco + Nómina HN": { + "main": [ + [ + { + "node": "HTTP - Empleados BambooHR HN", + "type": "main", + "index": 0 + }, + { + "node": "Merge - Agregar BambooHR HN", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Empleados BambooHR HN": { + "main": [ + [ + { + "node": "Normalizar BambooHR HN", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalizar BambooHR HN": { + "main": [ + [ + { + "node": "Merge - Agregar BambooHR HN", + "type": "main", + "index": 1 + } + ] + ] + }, + "Merge - Agregar BambooHR HN": { + "main": [ + [ + { + "node": "Obtener alias BambooHR Supabase", + "type": "main", + "index": 0 + } + ] + ] + }, + "Obtener alias BambooHR Supabase": { + "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": "Buscar reporte existente Supabase", + "type": "main", + "index": 0 + } + ] + ] + }, + "Buscar reporte existente Supabase": { + "main": [ + [ + { + "node": "Crear Google Sheet", + "type": "main", + "index": 0 + } + ] + ] + }, + "Crear Google Sheet": { + "main": [ + [ + { + "node": "Leer Google Sheet actual", + "type": "main", + "index": 0 + } + ] + ] + }, + "Leer Google Sheet actual": { + "main": [ + [ + { + "node": "Preparar actualización Google Sheet", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar actualización Google Sheet": { + "main": [ + [ + { + "node": "Actualizar estructura Google Sheet", + "type": "main", + "index": 0 + } + ] + ] + }, + "Actualizar estructura Google Sheet": { + "main": [ + [ + { + "node": "Limpiar Google Sheet", + "type": "main", + "index": 0 + } + ] + ] + }, + "Limpiar 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 + }, + { + "node": "Preparar correo reporte generado", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar respuesta final": { + "main": [ + [ + { + "node": "Respond to Webhook", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar correo reporte generado": { + "main": [ + [ + { + "node": "Enviar correo reporte generado", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate" + }, + "versionId": "a4e7c2cc-6784-43db-a676-1288cf3f718e", + "meta": { + "instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70" + }, + "tags": [] +} \ No newline at end of file diff --git a/Portal de Verificaci├│n de N├│mina - Reportar correcci├│n BambooHR a RRHH - GT TT PA SV HN.json b/Portal de Verificaci├│n de N├│mina - Reportar correcci├│n BambooHR a RRHH - GT TT PA SV HN.json new file mode 100644 index 0000000..5f85045 --- /dev/null +++ b/Portal de Verificaci├│n de N├│mina - Reportar correcci├│n BambooHR a RRHH - GT TT PA SV HN.json @@ -0,0 +1,514 @@ +{ + "name": "Portal de Verificación de Nómina - Reportar corrección BambooHR a RRHH - GT TT PA SV HN", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "cruce-cuentas-bamboo-correccion", + "responseMode": "responseNode", + "options": {} + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + 768, + 1984 + ], + "id": "e5de6fd4-03ff-4d15-b567-24209b9596cc", + "name": "Webhook", + "webhookId": "821fa25f-8b4c-417b-870c-73183194a709" + }, + { + "parameters": { + "jsCode": "const incoming = $input.first().json || {};\nconst rawBody = incoming.body ?? incoming;\n\nlet body = rawBody;\n\nif (rawBody && typeof rawBody === 'object' && typeof rawBody.payload === 'string') {\n try {\n body = JSON.parse(rawBody.payload);\n } catch (error) {\n throw new Error('El payload recibido no contiene JSON válido.');\n }\n}\n\nif (!body || typeof body !== 'object') {\n throw new Error('No se recibió una solicitud válida.');\n}\n\nconst accessToken = String(\n (rawBody && typeof rawBody === 'object' ? rawBody.access_token : '') ||\n body.access_token ||\n ''\n).trim();\n\nfunction clean(value) {\n return String(value ?? '').replace(/\\s+/g, ' ').trim();\n}\n\nfunction normalizeName(value) {\n return clean(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^a-z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nconst country = clean(body.country).toUpperCase();\nif (!['GT', 'TT', 'PA', 'SV', 'HN'].includes(country)) {\n throw new Error('País no permitido para reportar correcciones BambooHR.');\n}\n\nif (!accessToken) {\n throw new Error('No se recibió una sesión autenticada de Supabase.');\n}\n\nif (body.confirmed_same_person !== true) {\n throw new Error('La revisión manual debe confirmar que se trata de la misma persona.');\n}\n\nconst sourceNames = Array.from(new Set([\n ...(Array.isArray(body.source_names) ? body.source_names : []),\n body.source_name,\n body.bank_name_file,\n body.bank_account_holder,\n]\n .map(clean)\n .filter(Boolean)\n .filter((value) => normalizeName(value))));\n\nif (!sourceNames.length) {\n throw new Error('No se recibió el nombre detectado en Banco sin BambooHR.');\n}\n\nconst bambooName = clean(body.bamboo_name);\nif (!bambooName) {\n throw new Error('Debes indicar el nombre correcto en BambooHR.');\n}\n\nreturn [{\n json: {\n accessToken,\n request: {\n country,\n countryName: ({ GT: 'Guatemala', TT: 'Trinidad y Tobago', PA: 'Panamá', SV: 'El Salvador', HN: 'Honduras' })[country],\n sourceName: clean(body.source_name) || sourceNames[0],\n sourceNames,\n bankNameFile: clean(body.bank_name_file),\n bankAccountHolder: clean(body.bank_account_holder),\n bankAccount: clean(body.bank_account),\n bankAmount: Number.isFinite(Number(body.bank_amount)) ? Number(body.bank_amount) : null,\n currency: clean(body.currency) || ({ GT: 'GTQ', TT: 'TTD', PA: 'USD', SV: 'USD', HN: 'HNL' })[country],\n bambooName,\n bambooEmployeeNumber: clean(body.bamboo_employee_number),\n reason: clean(body.reason) || 'Corrección de identidad en BambooHR',\n comment: clean(body.comment),\n executionId: clean(body.execution_id),\n reportUrl: clean(body.report_url),\n periodLabel: clean(body.period_label),\n periodStart: clean(body.period_start),\n periodEnd: clean(body.period_end),\n requestedByNameClaimed: clean(body.requested_by_name),\n requestedByEmailClaimed: clean(body.requested_by_email).toLowerCase(),\n },\n },\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1008, + 1984 + ], + "id": "569fbf65-f297-416b-988a-c8ab108899bb", + "name": "Preparar solicitud" + }, + { + "parameters": { + "method": "POST", + "url": "https://dbit.digitalcompass.agency/rest/v1/rpc/cruce_cuentas_mi_acceso", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "apikey", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE" + }, + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $('Preparar solicitud').first().json.accessToken }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": {}, + "options": { + "timeout": 30000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1248, + 1984 + ], + "id": "4b3bd35e-8097-4b1b-a665-b482d887e8d7", + "name": "Validar acceso Supabase", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 1000 + }, + { + "parameters": { + "jsCode": "const raw = $input.first().json || {};\nconst access = Array.isArray(raw) ? (raw[0] || {}) : raw;\nconst prepared = $('Preparar solicitud').first().json || {};\nconst request = prepared.request || {};\n\nconst email = String(access.email || '').trim().toLowerCase();\nconst countries = Array.isArray(access.paises)\n ? access.paises.map((value) => String(value || '').trim().toUpperCase()).filter(Boolean)\n : [];\n\nif (\n access.ok !== true ||\n access.autenticado !== true ||\n access.autorizado !== true ||\n access.activo !== true ||\n access.acceso_app !== true ||\n !email\n) {\n throw new Error(access.motivo || 'El usuario no tiene acceso autorizado al Cruce de Cuentas.');\n}\n\nif (countries.length && !countries.includes(request.country)) {\n throw new Error(`El usuario autenticado no tiene acceso al país ${request.country}.`);\n}\n\nreturn [{\n json: {\n ...request,\n requestedByName: String(access.nombre || request.requestedByNameClaimed || email).trim(),\n requestedByEmail: email,\n },\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1488, + 1984 + ], + "id": "67e4c922-7c87-4567-a1d9-4d9b676684db", + "name": "Validar usuario" + }, + { + "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": "Validación manual - Corrección identidad Cruce de Cuentas", + "fields": [ + "firstName", + "middleName", + "lastName", + "displayName", + "location", + "status", + "employeeNumber", + "country" + ] + }, + "options": { + "response": { + "response": { + "responseFormat": "json" + } + }, + "timeout": 300000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1728, + 1984 + ], + "id": "9d9b8120-c80b-456c-8bbe-c945755c1507", + "name": "Consultar empleado BambooHR", + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 3000, + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "jsCode": "const request = $('Validar usuario').first().json || {};\nconst raw = $input.first().json || {};\n\nfunction clean(value) {\n return String(value ?? '').replace(/\\s+/g, ' ').trim();\n}\n\nfunction normalizeName(value) {\n return clean(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^a-z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeIdentifier(value) {\n return clean(value).toUpperCase().replace(/[^A-Z0-9]/g, '');\n}\n\nfunction countryMatches(employee) {\n const location = normalizeName(\n employee.location ||\n employee.jobInformationLocation ||\n employee.jobLocation ||\n ''\n );\n const country = normalizeName(employee.country || '');\n const combined = `${location} ${country}`.trim();\n\n if (request.country === 'GT') {\n return combined.includes('guatemala');\n }\n\n if (request.country === 'PA') {\n return combined.includes('panama');\n }\n\n if (request.country === 'SV') {\n return combined.includes('el salvador') || combined.includes('salvador');\n }\n\n if (request.country === 'HN') {\n return combined.includes('honduras');\n }\n\n return combined.includes('trinidad') || combined.includes('tobago');\n}\n\nfunction employeeAliases(employee) {\n const first = clean(employee.firstName || employee.first_name);\n const middle = clean(employee.middleName || employee.middle_name);\n const last = clean(employee.lastName || employee.last_name);\n const display = clean(employee.displayName || employee.fullName || employee.full_name);\n\n return Array.from(new Set([\n display,\n [first, middle, last].filter(Boolean).join(' '),\n [first, last].filter(Boolean).join(' '),\n ].map(clean).filter(Boolean)));\n}\n\nconst employees = Array.isArray(raw.employees)\n ? raw.employees\n : Array.isArray(raw.data?.employees)\n ? raw.data.employees\n : [];\n\nif (!employees.length) {\n throw new Error('BambooHR no devolvió empleados para validar la corrección.');\n}\n\nconst candidates = employees.filter(countryMatches);\nif (!candidates.length) {\n throw new Error(`No se encontraron empleados de ${request.countryName} en BambooHR.`);\n}\n\nconst requestedNumber = normalizeIdentifier(request.bambooEmployeeNumber);\nlet matches = [];\nlet matchedBy = '';\n\nif (requestedNumber) {\n matches = candidates.filter((employee) =>\n normalizeIdentifier(employee.employeeNumber || employee.employee_number) === requestedNumber\n );\n matchedBy = 'employee_number';\n}\n\nif (!matches.length) {\n const targetName = normalizeName(request.bambooName);\n matches = candidates.filter((employee) =>\n employeeAliases(employee).some((alias) => normalizeName(alias) === targetName)\n );\n matchedBy = 'exact_bamboo_name';\n}\n\nif (matches.length !== 1) {\n if (matches.length > 1) {\n throw new Error('La identificación en BambooHR es ambigua. Indica el Employee Number para seleccionar una sola persona.');\n }\n\n throw new Error(\n 'No se encontró exactamente ese empleado en BambooHR para el país seleccionado. Revisa el nombre o indica el Employee Number.'\n );\n}\n\nconst employee = matches[0];\nconst aliases = employeeAliases(employee);\nconst actualName = clean(employee.displayName) || aliases[0] || request.bambooName;\nconst actualEmployeeNumber = clean(employee.employeeNumber || employee.employee_number);\nconst bambooId = clean(employee.employeeId || employee.id);\nconst now = new Date().toISOString();\n\nconst recipientsByCountry = {\n GT: ['haguirre@gomezleemarketing.com'],\n TT: ['lnicomejack@gomezleemarketing.com'],\n PA: ['dpenalba@gomezleemarketing.com'],\n SV: ['cmalta@gomezleemarketing.com'],\n HN: ['mchang@gomezleemarketing.com'],\n};\nconst recipients = recipientsByCountry[request.country] || [];\n\nconst aliasPayload = request.sourceNames.map((sourceName) => ({\n pais: request.country,\n nombre_origen: sourceName,\n nombre_origen_normalizado: normalizeName(sourceName),\n cuenta_bancaria: String(request.bankAccount || '').replace(/[^0-9A-Za-z]+/g, ''),\n bamboo_employee_number: actualEmployeeNumber || null,\n bamboo_employee_id: bambooId || null,\n bamboo_nombre: actualName,\n motivo: request.reason,\n comentario: request.comment || null,\n activo: true,\n validado_por_email: request.requestedByEmail,\n validado_por_nombre: request.requestedByName,\n last_reported_at: now,\n updated_at: now,\n}));\n\nreturn [{\n json: {\n request,\n matchedBy,\n employee: {\n bambooId,\n employeeNumber: actualEmployeeNumber,\n name: actualName,\n status: clean(employee.status),\n location: clean(employee.location),\n },\n recipients,\n aliasPayload,\n },\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1968, + 1984 + ], + "id": "780c274d-995c-496f-bccf-70eee565bb00", + "name": "Resolver empleado BambooHR" + }, + { + "parameters": { + "method": "POST", + "url": "https://dbit.digitalcompass.agency/rest/v1/cruce_cuentas_bamboo_aliases?on_conflict=pais,nombre_origen_normalizado,cuenta_bancaria", + "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": "resolution=merge-duplicates,return=representation" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $('Resolver empleado BambooHR').first().json.aliasPayload }}", + "options": { + "timeout": 30000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 2208, + 1984 + ], + "id": "15016826-fa2c-4ca1-a103-a2dd00299c66", + "name": "Guardar alias Supabase", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 1000 + }, + { + "parameters": { + "jsCode": "const resolved = $('Resolver empleado BambooHR').first().json || {};\nconst request = resolved.request || {};\nconst employee = resolved.employee || {};\nconst aliasResponseItems = $input.all().map((item) => item.json || {});\n\nfunction flattenRows(items) {\n const rows = [];\n for (const item of items) {\n if (Array.isArray(item)) rows.push(...item);\n else if (Array.isArray(item.data)) rows.push(...item.data);\n else rows.push(item);\n }\n return rows.filter((row) => row && typeof row === 'object');\n}\n\nconst aliasRows = flattenRows(aliasResponseItems);\nconst aliasIds = aliasRows\n .map((row) => row.id)\n .filter(Boolean);\n\nconst requestPayload = {\n pais: request.country,\n nombre_origen_principal: request.sourceName,\n nombres_origen: request.sourceNames,\n cuenta_bancaria: request.bankAccount || null,\n monto_banco: request.bankAmount,\n moneda: request.currency,\n bamboo_employee_number: employee.employeeNumber || null,\n bamboo_employee_id: employee.bambooId || null,\n bamboo_nombre: employee.name,\n motivo: request.reason,\n comentario: request.comment || null,\n execution_id: request.executionId || null,\n report_url: request.reportUrl || null,\n period_label: request.periodLabel || null,\n period_start: request.periodStart || null,\n period_end: request.periodEnd || null,\n solicitado_por_email: request.requestedByEmail,\n solicitado_por_nombre: request.requestedByName,\n destinatarios_rrhh: resolved.recipients || [],\n alias_ids: aliasIds,\n estado: 'pendiente_notificacion',\n correo_enviado: false,\n};\n\nreturn [{ json: { requestPayload, aliasIds } }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2448, + 1984 + ], + "id": "51f90afa-04d0-4d8d-80c2-dbc8278b6ad7", + "name": "Preparar auditoría" + }, + { + "parameters": { + "method": "POST", + "url": "https://dbit.digitalcompass.agency/rest/v1/cruce_cuentas_bamboo_correcciones", + "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.requestPayload }}", + "options": { + "timeout": 30000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 2688, + 1984 + ], + "id": "e105a2df-aa71-4e0c-b895-87f2f6709d82", + "name": "Guardar solicitud Supabase", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 1000 + }, + { + "parameters": { + "jsCode": "const resolved = $('Resolver empleado BambooHR').first().json || {};\nconst request = resolved.request || {};\nconst employee = resolved.employee || {};\nconst auditInput = $input.all().map((item) => item.json || {});\n\nfunction firstObject(value) {\n if (Array.isArray(value)) return value[0] || {};\n if (value?.data && Array.isArray(value.data)) return value.data[0] || {};\n return value || {};\n}\n\nconst auditRow = firstObject(auditInput[0]);\nconst requestId = String(auditRow.id || '');\nif (!requestId) {\n throw new Error('Supabase no devolvió el ID de la solicitud de corrección.');\n}\n\nfunction escapeHtml(value) {\n return String(value ?? '')\n .replace(/&/g, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n\nconst logoUrl = 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png';\nconst flagUrl = request.country === 'TT'\n ? 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/Flag_of_Trinidad_and_Tobago.svg.webp'\n : request.country === 'PA'\n ? 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/Flag_of_Panama.svg'\n : request.country === 'SV'\n ? 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/Flag_of_El_Salvador.svg.webp'\n : request.country === 'HN'\n ? 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/Bandera%20de%20Honduras.webp'\n : 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/Flag_of_Guatemala.svg.webp';\n\nconst to = (resolved.recipients || []).join(',');\nconst subject = `Corrección requerida en BambooHR - ${request.countryName} - ${request.sourceName}`;\n\nconst reportButton = request.reportUrl\n ? `Abrir reporte de Cruce de Cuentas`\n : '';\n\nconst htmlBody = `\n\n\n\n\n
\n\n\n
 
\n\n\n\n
\"GomezLee\"Bandera
\n
\n
Corrección de identidad · BambooHR
\n

Revisión solicitada para ${escapeHtml(request.countryName)}

\n

Durante la revisión manual de Banco sin BambooHR se confirmó que el pago corresponde a una persona existente en BambooHR, pero su nombre no está permitiendo identificarla correctamente en el cruce.

\n\n\n\n\n\n\n\n\n
Nombre detectado${escapeHtml(request.sourceName)}
Empleado en BambooHR${escapeHtml(employee.name)}
Employee Number${escapeHtml(employee.employeeNumber || 'No disponible')}
Motivo${escapeHtml(request.reason)}
Período${escapeHtml(request.periodLabel || 'No especificado')}
Reportado por${escapeHtml(request.requestedByName)} · ${escapeHtml(request.requestedByEmail)}
\n\n${request.comment ? `
Comentario: ${escapeHtml(request.comment)}
` : ''}\n\n

Por favor, revisen y corrijan el nombre o la información correspondiente en BambooHR. La equivalencia validada ya quedó registrada en el Cruce de Cuentas para evitar que este mismo falso positivo se repita mientras se realiza la corrección.

\n\n${reportButton ? `
${reportButton}
` : ''}\n\n
\n
Notificación automática · Portal Cruce de Cuentas GLM · Uso interno
\n
\n
\n`;\n\nreturn [{\n json: {\n requestId,\n to,\n subject,\n htmlBody,\n country: request.country,\n countryName: request.countryName,\n aliasCount: Array.isArray(resolved.aliasPayload) ? resolved.aliasPayload.length : 0,\n },\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2928, + 1984 + ], + "id": "8a6facdd-60ee-4713-bb2c-5121d8449fea", + "name": "Preparar correo RRHH" + }, + { + "parameters": { + "sendTo": "={{$json.to}}", + "subject": "={{$json.subject}}", + "message": "={{$json.htmlBody}}", + "options": { + "appendAttribution": false + } + }, + "type": "n8n-nodes-base.gmail", + "typeVersion": 2.2, + "position": [ + 3168, + 1984 + ], + "id": "8cbb778d-b36e-4ae9-b55e-79bf8dca9d28", + "name": "Enviar correo RRHH", + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 2000, + "webhookId": "f1d0bb21-fe2a-4742-9dcf-6d6f5eb60fa3", + "credentials": { + "gmailOAuth2": { + "id": "UDcO1FLJqA453V2D", + "name": "Gmail account 3" + } + } + }, + { + "parameters": { + "method": "PATCH", + "url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/cruce_cuentas_bamboo_correcciones?id=eq.' + $('Preparar correo RRHH').first().json.requestId }}", + "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=minimal" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ { correo_enviado: true, correo_enviado_at: new Date().toISOString(), estado: 'notificado_rrhh' } }}", + "options": { + "timeout": 30000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 3408, + 1984 + ], + "id": "8dbc3f8e-0ec7-48b2-95a5-6c9905cb6580", + "name": "Marcar correo enviado", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 1000 + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ { ok: true, message: 'Caso validado, equivalencia registrada y correo enviado a RRHH.', country: $('Preparar correo RRHH').first().json.country, countryName: $('Preparar correo RRHH').first().json.countryName, aliasCount: $('Preparar correo RRHH').first().json.aliasCount, requestId: $('Preparar correo RRHH').first().json.requestId } }}", + "options": { + "responseCode": 200, + "responseHeaders": { + "entries": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + } + } + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 3648, + 1984 + ], + "id": "ad4e2147-041e-43d6-b952-303d991635ff", + "name": "Respond to Webhook" + } + ], + "pinData": {}, + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Preparar solicitud", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar solicitud": { + "main": [ + [ + { + "node": "Validar acceso Supabase", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validar acceso Supabase": { + "main": [ + [ + { + "node": "Validar usuario", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validar usuario": { + "main": [ + [ + { + "node": "Consultar empleado BambooHR", + "type": "main", + "index": 0 + } + ] + ] + }, + "Consultar empleado BambooHR": { + "main": [ + [ + { + "node": "Resolver empleado BambooHR", + "type": "main", + "index": 0 + } + ] + ] + }, + "Resolver empleado BambooHR": { + "main": [ + [ + { + "node": "Guardar alias Supabase", + "type": "main", + "index": 0 + } + ] + ] + }, + "Guardar alias Supabase": { + "main": [ + [ + { + "node": "Preparar auditoría", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar auditoría": { + "main": [ + [ + { + "node": "Guardar solicitud Supabase", + "type": "main", + "index": 0 + } + ] + ] + }, + "Guardar solicitud Supabase": { + "main": [ + [ + { + "node": "Preparar correo RRHH", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar correo RRHH": { + "main": [ + [ + { + "node": "Enviar correo RRHH", + "type": "main", + "index": 0 + } + ] + ] + }, + "Enviar correo RRHH": { + "main": [ + [ + { + "node": "Marcar correo enviado", + "type": "main", + "index": 0 + } + ] + ] + }, + "Marcar correo enviado": { + "main": [ + [ + { + "node": "Respond to Webhook", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate" + }, + "versionId": "a6157be9-0bb2-4937-845f-1769af596051", + "meta": { + "instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70" + }, + "tags": [] +} \ No newline at end of file diff --git a/Portal de Verificaci├│n de N├│mina Honduras - Env├¡o de Reporte.json b/Portal de Verificaci├│n de N├│mina Honduras - Env├¡o de Reporte.json new file mode 100644 index 0000000..dce3391 --- /dev/null +++ b/Portal de Verificaci├│n de N├│mina Honduras - Env├¡o de Reporte.json @@ -0,0 +1,347 @@ +{ + "name": "Portal de Verificación de Nómina Honduras - Envío de Reporte", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "cruce-cuentas-hn-marcar-resuelto", + "responseMode": "responseNode", + "options": {} + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + 784, + 592 + ], + "id": "521b0b8a-b2da-481c-a23c-8a7ef4859f2d", + "name": "Webhook Marcar Resuelto", + "webhookId": "93bcd7d9-d9d1-474e-970b-50ba5d7d7d67" + }, + { + "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": [ + 992, + 592 + ], + "id": "4874d8fb-1de2-45c9-bcfd-1a7a86d9a6fd", + "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": [ + 1200, + 592 + ], + "id": "2b288fbd-ce10-4ca1-814c-3876ffd7e326", + "name": "¿Solicitud válida?" + }, + { + "parameters": { + "method": "PATCH", + "url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/cruces_cuentas_gt_reportes?id=eq.' + encodeURIComponent($json.reportId) + '&country=eq.HN' }}", + "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": [ + 1536, + 480 + ], + "id": "7cf64404-fbec-4967-8d8f-37cce6e94694", + "name": "Actualizar reporte Supabase" + }, + { + "parameters": { + "jsCode": "const input = $input.first().json || {};\nconst report = Array.isArray(input) ? input[0] : input;\n\nif (!report || !report.id) {\n throw new Error(\n 'No se encontró un reporte de Honduras con el ID recibido.'\n );\n}\n\nif (String(report.country || '').toUpperCase() !== 'HN') {\n throw new Error(\n 'El reporte localizado no pertenece a Honduras.'\n );\n}\nconst original = $('Validar resolución').first().json || {};\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 bancoSinNomina = numberValue(report.banco_sin_nomina);\nconst bancoSinBamboo = numberValue(report.banco_sin_bamboo);\nconst nominaSinCuenta = numberValue(report.nomina_sin_cuenta);\n\nconst resolvedAt = original.resolvedAt || new Date().toISOString();\n\nconst resolvedDateLabel = new Date(resolvedAt).toLocaleString('es-HN', {\n timeZone: 'America/Tegucigalpa',\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 flagUrl =\n 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/Bandera%20de%20Honduras.webp';\n\nconst subject =\n `Reporte resuelto - Cruce de Cuentas Honduras - ${periodo}`;\n\nconst textBody = `\nHola equipo,\n\nSe marcó como resuelto un reporte de Cruce de Cuentas Honduras.\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 nómina: ${bancoSinNomina}\n- Banco sin Bamboo: ${bancoSinBamboo}\n- Nómina sin cuenta: ${nominaSinCuenta}\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 ${escapeHtml(subject)}\n\n\n\n\n \n \n \n\n \n\n \n \n \n \n \n\n \n  \n \n\n \n \n
\n Cruce de Cuentas Honduras\n \"Bandera\n
\n\n

\n Reporte marcado como resuelto\n

\n \n \n\n \n \n

\n Hola equipo,\n

\n\n

\n El siguiente reporte fue revisado y marcado como\n resuelto.\n

\n \n \n\n \n \n \n \n \n Período\n \n\n \n ${escapeHtml(periodo)}\n \n \n\n \n \n Resuelto por\n \n\n \n ${escapeHtml(resolvedByName)}\n ${\n resolvedByEmail\n ? `
${escapeHtml(resolvedByEmail)}`\n : ''\n }\n \n \n\n \n \n Fecha y hora\n \n\n \n ${escapeHtml(resolvedDateLabel)}\n \n \n \n \n \n\n \n \n
\n Resumen del reporte\n
\n \n \n\n \n \n \n \n \n
\n ${discrepancias}\n
\n\n
\n Discrepancias\n
\n \n\n \n
\n ${bancoSinBamboo}\n
\n\n
\n Banco sin Bamboo\n
\n \n\n \n
\n ${bancoSinNomina}\n
\n\n
\n Banco sin nómina\n
\n \n\n \n
\n ${nominaSinCuenta}\n
\n\n
\n Nómina sin cuenta\n
\n \n \n \n \n \n\n \n \n
\n Comentario de resolución\n
\n \n \n\n \n \n
\n ${escapeHtml(resolutionComment).replace(/\\n/g, '
')}\n
\n \n \n\n ${\n reportUrl\n ? `\n \n \n \n Abrir Google Sheet\n \n \n \n `\n : ''\n }\n\n \n \n Mensaje automático del Portal Cruce de Cuentas GLM.\n \n \n\n \n \n \n \n\n\n\n`.trim();\n\nreturn [\n {\n json: {\n to: 'jgomez@gomezleemarketing.com, mgomez@gomezleemarketing.com, ymadera@gomezleemarketing.com',\n cc: 'iaracena@gomezleemarketing.com',\n subject,\n textBody,\n htmlBody,\n report,\n resolution: original,\n },\n },\n];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1744, + 480 + ], + "id": "db7305e1-ae76-4cd8-b4b7-adea597ab2d8", + "name": "Preparar correo equipo HN" + }, + { + "parameters": { + "sendTo": "={{$json.to}}", + "subject": "={{$json.subject}}", + "message": "={{ $json.htmlBody }}", + "options": { + "appendAttribution": false, + "ccList": "={{ $json.cc }}" + } + }, + "type": "n8n-nodes-base.gmail", + "typeVersion": 2.2, + "position": [ + 1952, + 480 + ], + "id": "ae456f45-b90d-469d-9dfc-0bdea9ef989f", + "name": "Enviar correo equipo HN", + "webhookId": "ad819366-b163-4e58-86de-a7cd158630b1", + "credentials": { + "gmailOAuth2": { + "id": "UDcO1FLJqA453V2D", + "name": "Gmail account 3" + } + } + }, + { + "parameters": { + "jsCode": "const original = $('Validar resolución').first().json || {};\nconst updatedResponse = $('Actualizar reporte Supabase').first().json || {};\nconst updatedReport = Array.isArray(updatedResponse) ? updatedResponse[0] : updatedResponse;\n\nreturn [\n {\n json: {\n ok: true,\n stage: 'reporte_resuelto',\n message: 'Reporte marcado como resuelto correctamente.',\n report: {\n id: original.reportId,\n estado: 'resuelto',\n estadoLabel: 'RESUELTO',\n country: 'HN',\n countryName: 'Honduras',\n resueltoPorNombre: original.resueltoPorNombre,\n resueltoPorEmail: original.resueltoPorEmail,\n resueltoEn: original.resolvedAt,\n comentarioResolucion: original.comentarioResolucion,\n reportUrl: updatedReport.report_url || '',\n },\n },\n },\n];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2160, + 480 + ], + "id": "52313fe1-f491-45e0-94cf-89029a666e58", + "name": "Preparar respuesta resuelto" + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": { + "responseCode": 200, + "responseHeaders": { + "entries": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Access-Control-Allow-Origin", + "value": "*" + }, + { + "name": "Access-Control-Allow-Methods", + "value": "GET,POST,OPTIONS" + }, + { + "name": "Access-Control-Allow-Headers", + "value": "Content-Type, Authorization" + } + ] + } + } + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 2368, + 480 + ], + "id": "ae1e7827-6e9d-4506-9365-4a075e5f2c05", + "name": "Responder resuelto app" + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": { + "responseCode": 400, + "responseHeaders": { + "entries": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Access-Control-Allow-Origin", + "value": "*" + }, + { + "name": "Access-Control-Allow-Methods", + "value": "GET,POST,OPTIONS" + }, + { + "name": "Access-Control-Allow-Headers", + "value": "Content-Type, Authorization" + } + ] + } + } + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 1408, + 688 + ], + "id": "424eb861-9fd3-4b38-bfb3-4adb08301ff8", + "name": "Responder error resolución" + }, + { + "parameters": { + "content": "# ✅ CIERRE DE REPORTE — HONDURAS\n\nRecibe desde el Portal de Verificación de Nóminas la solicitud para marcar un reporte de Honduras como resuelto.\n\n## Validación inicial\n\nEl Webhook recibe la información enviada por la aplicación y valida datos como:\n\n- Identificador del reporte.\n- Usuario que realizó el cierre.\n- Estado solicitado.\n- Comentario o detalle de resolución.\n- Datos necesarios para localizar el registro en Supabase.\n\nLa solicitud solamente continúa cuando contiene información válida y suficiente.\n\n## Actualización del reporte\n\nCuando la solicitud es correcta:\n\n1. Localiza el reporte correspondiente en Supabase.\n2. Cambia su estado a RESUELTO.\n3. Registra quién realizó la resolución.\n4. Guarda la fecha y hora del cierre.\n5. Conserva el comentario o información de seguimiento disponible.\n\nSupabase es la fuente oficial del estado del reporte dentro del portal.\n\n## Notificación al equipo de Honduras\n\nDespués de actualizar el registro:\n\n1. Se prepara un correo para el equipo responsable de Honduras.\n2. Se incluyen los datos principales del reporte.\n3. Se informa que el caso fue marcado como resuelto.\n4. Se identifica al usuario que realizó el cierre.\n5. Se envía el correo mediante Gmail.\n\nLa notificación permite que el equipo conozca el cambio sin tener que revisar manualmente el portal.\n\n## Respuesta a la aplicación\n\nCuando el proceso termina correctamente:\n\n- Se construye una respuesta de éxito.\n- Se confirma que el reporte fue actualizado.\n- Se devuelve el nuevo estado a la aplicación.\n- El portal puede actualizar inmediatamente su interfaz.\n\n## Ruta de error\n\nCuando la solicitud es inválida:\n\n- No se modifica Supabase.\n- No se envía el correo al equipo de Honduras.\n- Se devuelve una respuesta de error a la aplicación.\n- Se informa que faltan datos o que la solicitud no pudo procesarse.\n\n## Reglas\n\n- No marcar un reporte como resuelto sin un identificador válido.\n- No enviar la notificación antes de confirmar la actualización en Supabase.\n- No declarar éxito cuando la base de datos no fue actualizada.\n- Toda respuesta del Webhook debe cerrarse correctamente para evitar solicitudes abiertas.\n- La ruta de error debe terminar sin realizar cambios parciales.", + "height": 1264, + "width": 2624, + "color": "#28765F" + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 0, + 0 + ], + "id": "4c5b8763-d795-4d97-a01c-5fdf7f31e839", + "name": "Sticky Note" + } + ], + "pinData": {}, + "connections": { + "Webhook Marcar Resuelto": { + "main": [ + [ + { + "node": "Validar resolución", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validar resolución": { + "main": [ + [ + { + "node": "¿Solicitud válida?", + "type": "main", + "index": 0 + } + ] + ] + }, + "¿Solicitud válida?": { + "main": [ + [ + { + "node": "Actualizar reporte Supabase", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Responder error resolución", + "type": "main", + "index": 0 + } + ] + ] + }, + "Actualizar reporte Supabase": { + "main": [ + [ + { + "node": "Preparar correo equipo HN", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar respuesta resuelto": { + "main": [ + [ + { + "node": "Responder resuelto app", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar correo equipo HN": { + "main": [ + [ + { + "node": "Enviar correo equipo HN", + "type": "main", + "index": 0 + } + ] + ] + }, + "Enviar correo equipo HN": { + "main": [ + [ + { + "node": "Preparar respuesta resuelto", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate" + }, + "versionId": "e048ffce-bcd3-4332-88e7-9fbf607a8a38", + "meta": { + "instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70" + }, + "tags": [] +} \ No newline at end of file diff --git a/Portal de Verificaci├│n de N├│mina Honduras - Hist├│ricos.json b/Portal de Verificaci├│n de N├│mina Honduras - Hist├│ricos.json new file mode 100644 index 0000000..fc45009 --- /dev/null +++ b/Portal de Verificaci├│n de N├│mina Honduras - Hist├│ricos.json @@ -0,0 +1,163 @@ +{ + "name": "Portal de Verificación de Nómina Honduras - Históricos", + "nodes": [ + { + "parameters": { + "path": "cruce-cuentas-hn-historicos", + "responseMode": "responseNode", + "options": {} + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + 304, + 272 + ], + "id": "2fe1cba5-882d-4054-afa7-d331d33220aa", + "name": "Webhook Históricos HN", + "webhookId": "48f69298-ea9a-4ff2-a051-1052bbed0c1d" + }, + { + "parameters": { + "jsCode": "const items = $input.all();\n\nfunction formatDate(value) {\n if (!value) return '';\n const date = new Date(value);\n if (Number.isNaN(date.getTime())) return String(value).slice(0, 10);\n return date.toISOString().slice(0, 10);\n}\n\nfunction toNumber(value) {\n const parsed = Number(value);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction normalizeStatus(value, discrepancias) {\n const status = String(value || '').toLowerCase();\n\n if (status === 'resuelto') return 'resuelto';\n if (status === 'procesado' && toNumber(discrepancias) === 0) return 'procesado';\n\n return 'pendiente_revision';\n}\n\nfunction statusLabel(status) {\n if (status === 'resuelto') return 'RESUELTO';\n if (status === 'procesado') return 'PROCESADO';\n return 'PENDIENTE REVISIÓN';\n}\n\nconst reports = items.map((item) => {\n const row = item.json || {};\n\n const discrepancias = toNumber(row.discrepancias);\n const bancoSinNomina = toNumber(row.banco_sin_nomina);\n const nominaSinCuenta = toNumber(row.nomina_sin_cuenta);\n const bancoSinBamboo = toNumber(row.banco_sin_bamboo);\n const nominaSinBamboo = toNumber(row.nomina_sin_bamboo);\n const bambooSinNomina = toNumber(row.bamboo_sin_nomina);\n\n const totalPendientes =\n discrepancias +\n bancoSinNomina +\n nominaSinCuenta +\n bancoSinBamboo +\n nominaSinBamboo +\n bambooSinNomina;\n\n const estado = normalizeStatus(row.estado, totalPendientes);\n\n return {\n id: row.id,\n\n fecha: formatDate(row.period_end || row.ejecutado_en || row.created_at),\n fechaEjecucion: row.ejecutado_en || row.created_at,\n\n periodo: row.period_label || '',\n year: row.year,\n month: row.month,\n periodType: row.period_type,\n periodStart: row.period_start,\n periodEnd: row.period_end,\n\n archivoNomina: row.payroll_file_name || '',\n archivosBanco: row.bank_file_names || [],\n\n coincidencias: toNumber(row.coincidencias),\n discrepancias,\n bancoSinNomina,\n nominaSinCuenta,\n bancoSinBamboo,\n nominaSinBamboo,\n bambooSinNomina,\n totalPendientes,\n\n totalNomina: toNumber(row.total_nomina),\n totalBanco: toNumber(row.total_banco),\n diferenciaTotal: toNumber(row.diferencia_total),\n\n estado,\n estadoLabel: statusLabel(estado),\n\n reportUrl: row.report_url || '',\n spreadsheetId: row.spreadsheet_id || '',\n\n ejecutadoPorNombre: row.ejecutado_por_nombre || '',\n ejecutadoPorEmail: row.ejecutado_por_email || '',\n\n resueltoPorNombre: row.resuelto_por_nombre || '',\n resueltoPorEmail: row.resuelto_por_email || '',\n resueltoEn: row.resuelto_en || null,\n comentarioResolucion: row.comentario_resolucion || '',\n };\n});\n\nreturn [\n {\n json: {\n ok: true,\n stage: 'historicos_hn_listos',\n count: reports.length,\n reports,\n },\n },\n];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 720, + 272 + ], + "id": "dcd21660-393d-4ce2-9092-e61fc909fe0a", + "name": "Normalizar históricos para app" + }, + { + "parameters": { + "url": "https://dbit.digitalcompass.agency/rest/v1/cruces_cuentas_gt_reportes?select=*&country=eq.HN&order=ejecutado_en.desc&limit=100", + "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" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 512, + 272 + ], + "id": "60a88bd9-4050-43d7-a88d-b415f90d1543", + "name": "Consultar históricos Supabase" + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": { + "responseHeaders": { + "entries": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Access-Control-Allow-Origin", + "value": "*" + }, + { + "name": "Access-Control-Allow-Methods", + "value": "GET,POST,OPTIONS" + }, + { + "name": "Access-Control-Allow-Headers", + "value": "Content-Type, Authorization" + } + ] + } + } + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 944, + 272 + ], + "id": "9f35b767-e0f2-40a5-8097-8bc623669b8d", + "name": "Responder históricos app" + }, + { + "parameters": { + "content": "# 📚 HISTÓRICOS DE VERIFICACIÓN — HONDURAS\n\nEntrega a la aplicación el historial de reportes procesados para Honduras.\n\n- Consulta únicamente registros con `country = HN`.\n- Supabase continúa usando la tabla histórica compartida `cruces_cuentas_gt_reportes`; el nombre de la tabla es legado y funciona como tabla multi-país.\n- Devuelve la misma estructura de históricos utilizada por los demás países.\n- Este webhook sirve como respaldo; la app intenta primero la RPC paginada de Supabase.", + "height": 496, + "width": 1360, + "color": 6 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 0, + 0 + ], + "id": "8e6b3835-6bd6-4f64-827e-24744543d76c", + "name": "Sticky Note" + } + ], + "pinData": {}, + "connections": { + "Consultar históricos Supabase": { + "main": [ + [ + { + "node": "Normalizar históricos para app", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalizar históricos para app": { + "main": [ + [ + { + "node": "Responder históricos app", + "type": "main", + "index": 0 + } + ] + ] + }, + "Webhook Históricos HN": { + "main": [ + [ + { + "node": "Consultar históricos Supabase", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate" + }, + "versionId": "c2ddfc50-7d6a-4750-9f9c-1e7e423b6380", + "meta": { + "instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70" + }, + "tags": [] +} \ No newline at end of file diff --git a/SUPABASE_Honduras_READY.sql b/SUPABASE_Honduras_READY.sql new file mode 100644 index 0000000..0812af5 --- /dev/null +++ b/SUPABASE_Honduras_READY.sql @@ -0,0 +1,112 @@ +-- ============================================================ +-- CRUCE DE CUENTAS GLM · HABILITAR HONDURAS (HN) +-- GT + TT + PA + SV + HN +-- ============================================================ +-- Ejecutar en el SQL Editor del Supabase empresarial. +-- Conserva datos existentes y puede ejecutarse nuevamente de forma segura. + +begin; + +-- 1) Constraints de país: aliases, correcciones, históricos y accesos. +do $$ +declare r record; +begin + if to_regclass('public.cruce_cuentas_bamboo_aliases') is null then raise exception 'No existe public.cruce_cuentas_bamboo_aliases.'; end if; + if to_regclass('public.cruce_cuentas_bamboo_correcciones') is null then raise exception 'No existe public.cruce_cuentas_bamboo_correcciones.'; end if; + if to_regclass('public.cruces_cuentas_gt_reportes') is null then raise exception 'No existe public.cruces_cuentas_gt_reportes.'; end if; + if to_regclass('public.cruce_cuentas_usuarios_autorizados') is null then raise exception 'No existe public.cruce_cuentas_usuarios_autorizados.'; end if; + + for r in select conname from pg_constraint where conrelid='public.cruce_cuentas_bamboo_aliases'::regclass and contype='c' and pg_get_constraintdef(oid) ilike '%pais%' and pg_get_constraintdef(oid) ilike '%GT%' and pg_get_constraintdef(oid) not ilike '%HN%' loop + execute format('alter table public.cruce_cuentas_bamboo_aliases drop constraint %I',r.conname); + end loop; + alter table public.cruce_cuentas_bamboo_aliases drop constraint if exists ck_cruce_cuentas_bamboo_aliases_pais; + alter table public.cruce_cuentas_bamboo_aliases add constraint ck_cruce_cuentas_bamboo_aliases_pais check (upper(trim(pais)) in ('GT','TT','PA','SV','HN')); + + for r in select conname from pg_constraint where conrelid='public.cruce_cuentas_bamboo_correcciones'::regclass and contype='c' and pg_get_constraintdef(oid) ilike '%pais%' and pg_get_constraintdef(oid) ilike '%GT%' and pg_get_constraintdef(oid) not ilike '%HN%' loop + execute format('alter table public.cruce_cuentas_bamboo_correcciones drop constraint %I',r.conname); + end loop; + alter table public.cruce_cuentas_bamboo_correcciones drop constraint if exists ck_cruce_cuentas_bamboo_correcciones_pais; + alter table public.cruce_cuentas_bamboo_correcciones add constraint ck_cruce_cuentas_bamboo_correcciones_pais check (upper(trim(pais)) in ('GT','TT','PA','SV','HN')); + + for r in select conname from pg_constraint where conrelid='public.cruces_cuentas_gt_reportes'::regclass and contype='c' and pg_get_constraintdef(oid) ilike '%country%' and pg_get_constraintdef(oid) ilike '%GT%' and pg_get_constraintdef(oid) not ilike '%HN%' loop + execute format('alter table public.cruces_cuentas_gt_reportes drop constraint %I',r.conname); + end loop; + alter table public.cruces_cuentas_gt_reportes drop constraint if exists ck_cruces_cuentas_reportes_country; + alter table public.cruces_cuentas_gt_reportes add constraint ck_cruces_cuentas_reportes_country check (country is null or upper(trim(country)) in ('GT','TT','PA','SV','HN')); + + -- La tabla de acceso usa text[] en paises. + alter table public.cruce_cuentas_usuarios_autorizados drop constraint if exists cruce_cuentas_paises_validos_check; + alter table public.cruce_cuentas_usuarios_autorizados add constraint cruce_cuentas_paises_validos_check check (paises is null or paises <@ array['GT','TT','PA','SV','HN']::text[]); +end; +$$; + +-- 2) Dar HN a TODOS los usuarios autorizados actuales, preservando permisos. +update public.cruce_cuentas_usuarios_autorizados +set paises = case + when paises is null then array['GT','TT','PA','SV','HN']::text[] + when not ('HN'=any(paises)) then array_append(paises,'HN') + else paises +end, +updated_at=now(); + +-- 3) Alias BambooHR activos: cinco países. +create or replace function public.cruce_cuentas_bamboo_aliases_activos(p_pais text) +returns jsonb language sql stable security definer set search_path=public as $$ + select jsonb_build_object('ok',true,'pais',upper(trim(coalesce(p_pais,''))),'aliases',coalesce(jsonb_agg(jsonb_build_object('id',a.id,'pais',a.pais,'nombre_origen',a.nombre_origen,'nombre_origen_normalizado',a.nombre_origen_normalizado,'cuenta_bancaria',a.cuenta_bancaria,'bamboo_employee_number',a.bamboo_employee_number,'bamboo_employee_id',a.bamboo_employee_id,'bamboo_nombre',a.bamboo_nombre,'motivo',a.motivo,'activo',a.activo) order by a.updated_at desc) filter (where a.id is not null),'[]'::jsonb)) + from public.cruce_cuentas_bamboo_aliases a where a.activo=true and a.pais=upper(trim(coalesce(p_pais,''))) and upper(trim(coalesce(p_pais,''))) in ('GT','TT','PA','SV','HN'); +$$; +revoke all on function public.cruce_cuentas_bamboo_aliases_activos(text) from public,anon,authenticated; +grant execute on function public.cruce_cuentas_bamboo_aliases_activos(text) to service_role; + +-- 4) Históricos paginados: cinco países. +create index if not exists idx_cruces_cuentas_reportes_country_created on public.cruces_cuentas_gt_reportes(country,created_at desc); +create index if not exists idx_cruces_cuentas_reportes_country_status_created on public.cruces_cuentas_gt_reportes(country,estado,created_at desc); + +create or replace function public.cruce_cuentas_get_historicos(p_country text,p_page integer default 1,p_page_size integer default 10,p_search text default null,p_status text default null) +returns jsonb language plpgsql security definer set search_path=public as $$ +declare + v_country text := upper(trim(coalesce(p_country,''))); + v_page integer := greatest(coalesce(p_page,1),1); + v_page_size integer := least(greatest(coalesce(p_page_size,10),1),100); + v_offset integer; + v_search text := nullif(trim(coalesce(p_search,'')),''); + v_status text := nullif(trim(coalesce(p_status,'')),''); + v_total bigint; v_filtered_total bigint; v_reports jsonb; +begin + if v_country not in ('GT','TT','PA','SV','HN') then raise exception 'País no permitido: %',v_country; end if; + v_offset := (v_page-1)*v_page_size; + select count(*) into v_total from public.cruces_cuentas_gt_reportes r where upper(coalesce(r.country,'GT'))=v_country; + select count(*) into v_filtered_total from public.cruces_cuentas_gt_reportes r where upper(coalesce(r.country,'GT'))=v_country and (v_status is null or r.estado=v_status) and (v_search is null or coalesce(r.period_label,'') ilike '%'||v_search||'%' or coalesce(r.payroll_file_name,'') ilike '%'||v_search||'%' or coalesce(r.ejecutado_por_nombre,'') ilike '%'||v_search||'%' or coalesce(r.resuelto_por_nombre,'') ilike '%'||v_search||'%' or r.id::text ilike '%'||v_search||'%'); + select coalesce(jsonb_agg((to_jsonb(q)-'detalle_banco_sin_bamboo') order by q.created_at desc),'[]'::jsonb) into v_reports from (select r.* from public.cruces_cuentas_gt_reportes r where upper(coalesce(r.country,'GT'))=v_country and (v_status is null or r.estado=v_status) and (v_search is null or coalesce(r.period_label,'') ilike '%'||v_search||'%' or coalesce(r.payroll_file_name,'') ilike '%'||v_search||'%' or coalesce(r.ejecutado_por_nombre,'') ilike '%'||v_search||'%' or coalesce(r.resuelto_por_nombre,'') ilike '%'||v_search||'%' or r.id::text ilike '%'||v_search||'%') order by r.created_at desc limit v_page_size offset v_offset) q; + return jsonb_build_object('ok',true,'country',v_country,'page',v_page,'page_size',v_page_size,'total',v_total,'filtered_total',v_filtered_total,'total_pages',greatest(ceil(v_filtered_total::numeric/v_page_size)::integer,1),'reports',v_reports); +end; +$$; +revoke all on function public.cruce_cuentas_get_historicos(text,integer,integer,text,text) from public,anon; +grant execute on function public.cruce_cuentas_get_historicos(text,integer,integer,text,text) to authenticated; + +-- 5) Detalle Banco sin Bamboo de históricos. +create or replace function public.cruce_cuentas_get_banco_sin_bamboo_reporte(p_report_id text,p_country text) +returns jsonb language plpgsql stable security definer set search_path=public as $$ +declare + v_country text := upper(trim(coalesce(p_country,''))); + v_report public.cruces_cuentas_gt_reportes%rowtype; + v_corrections jsonb := '[]'::jsonb; +begin + if auth.uid() is null then raise exception 'No autenticado.'; end if; + if v_country not in ('GT','TT','PA','SV','HN') then raise exception 'País no permitido: %',v_country; end if; + select r.* into v_report from public.cruces_cuentas_gt_reportes r where r.id::text=trim(coalesce(p_report_id,'')) and upper(coalesce(r.country,'GT'))=v_country limit 1; + if not found then return jsonb_build_object('ok',false,'message','No se encontró el reporte solicitado.'); end if; + select coalesce(jsonb_agg(jsonb_build_object('id',c.id,'pais',c.pais,'nombre_origen_principal',c.nombre_origen_principal,'nombres_origen',c.nombres_origen,'cuenta_bancaria',c.cuenta_bancaria,'bamboo_employee_number',c.bamboo_employee_number,'bamboo_nombre',c.bamboo_nombre,'estado',c.estado,'correo_enviado',c.correo_enviado,'correo_enviado_at',c.correo_enviado_at,'created_at',c.created_at) order by c.created_at desc),'[]'::jsonb) into v_corrections from public.cruce_cuentas_bamboo_correcciones c where c.pais=v_country and (c.execution_id=v_report.id::text or (nullif(trim(coalesce(c.execution_id,'')),'') is null and nullif(trim(coalesce(c.report_url,'')),'') is not null and c.report_url=v_report.report_url)); + return jsonb_build_object('ok',true,'report',jsonb_build_object('id',v_report.id,'country',v_report.country,'period_label',v_report.period_label,'period_start',v_report.period_start,'period_end',v_report.period_end,'report_url',v_report.report_url,'banco_sin_bamboo',v_report.banco_sin_bamboo),'cases',coalesce(v_report.detalle_banco_sin_bamboo,'[]'::jsonb),'corrections',v_corrections); +end; +$$; +revoke all on function public.cruce_cuentas_get_banco_sin_bamboo_reporte(text,text) from public,anon; +grant execute on function public.cruce_cuentas_get_banco_sin_bamboo_reporte(text,text) to authenticated; + +commit; + +-- VALIDACIÓN (solo lectura) +select email,nombre,paises from public.cruce_cuentas_usuarios_autorizados order by email; +select public.cruce_cuentas_bamboo_aliases_activos('HN'); +select public.cruce_cuentas_get_historicos('HN',1,10,null,null); +select conrelid::regclass as tabla,conname,pg_get_constraintdef(oid) as definicion from pg_constraint where conrelid in ('public.cruce_cuentas_bamboo_aliases'::regclass,'public.cruce_cuentas_bamboo_correcciones'::regclass,'public.cruces_cuentas_gt_reportes'::regclass,'public.cruce_cuentas_usuarios_autorizados'::regclass) and contype='c' order by 1,2; diff --git a/VALIDACION_HONDURAS.md b/VALIDACION_HONDURAS.md new file mode 100644 index 0000000..9a7a63e --- /dev/null +++ b/VALIDACION_HONDURAS.md @@ -0,0 +1,111 @@ +# Validación — Cruce de Cuentas GLM · Honduras + +## Datos reales utilizados + +Se validó la lógica contra cuatro períodos reales: + +- Junio 2026 · Quincena 15 +- Junio 2026 · Quincena 30 / fin de mes +- Julio 2026 · Quincena 15 +- Julio 2026 · Quincena 30 / fin de mes + +Se utilizaron los Excel reales de nómina, los CSV BAC de planes AR94/AR95US y el reporte completo de BambooHR proporcionado para las pruebas. + +## Normalización de nómina + +| Período | Filas válidas | Total HNL | Total USD | +|---|---:|---:|---:| +| Junio Q15 | 266 | 2,111,731.15 | 0.00 | +| Junio Q30 | 332 | 1,810,119.17 | 23,474.54 | +| Julio Q15 | 264 | 1,450,958.81 | 0.00 | +| Julio Q30 | 336 | 1,760,132.78 | 24,013.15 | + +## Parser bancario + +| Período | Transacciones | Total HNL | Total USD | +|---|---:|---:|---:| +| Junio Q15 | 266 | 2,128,362.38 | 0.00 | +| Junio Q30 | 508 | 4,603,602.47 | 43,859.02 | +| Julio Q15 | 264 | 1,450,959.26 | 0.00 | +| Julio Q30 | 337 | 1,760,133.30 | 24,780.81 | + +Los archivos bancarios de junio Q30 incluyen envíos del 23/06 y 30/06; ambos pertenecen a la segunda mitad del mes y por diseño se auditan dentro de Q30. Por eso existen pagos “Banco sin nómina” correspondientes a envíos adicionales del período. + +## Resultado del reconciliador final + +| Período | Coincidencias | Discrepancias | Banco sin nómina | Banco sin Bamboo | Diferencias nombre banco | Cuenta mal digitada | +|---|---:|---:|---:|---:|---:|---:| +| Junio Q15 | 143 | 122 | 0 | 5 | 1 | 1 | +| Junio Q30 | 331 | 0 | 176 | 5 | 5 | 1 | +| Julio Q15 | 263 | 0 | 0 | 4 | 1 | 1 | +| Julio Q30 | 335 | 0 | 1 | 5 | 3 | 1 | + +### Redondeos + +La validación final aplica una tolerancia de hasta 0.05. Esto elimina falsos positivos de 0.01 observados repetidamente y el caso real de 0.03 USD, sin ocultar diferencias materiales. En Junio Q15, después de eliminar únicamente esos redondeos, las 122 discrepancias restantes empiezan en 58.18 HNL, por lo que siguen siendo hallazgos materiales. + +### Cuenta mal digitada confirmada + +El caso consistente en los períodos es **Brenda Baquedano**: + +- Nómina: `745899511` +- Banco: `745899611` + +Durante una revisión preliminar se había señalado también a Harold Panchame en junio Q30. La revisión directa del Excel original mostró que la celda de nómina contiene `756669711.0`, que normaliza a `756669711`, exactamente igual al banco; por tanto el reconciliador final no lo clasifica como cuenta mal digitada. + +### Diferencias de nombre banco + +La regla final distingue truncamientos normales del archivo bancario de diferencias reales: + +- `Oscar Rene Flores Alvarad` vs `OSCAR RENE FLORES ALVARADO` se considera truncamiento y no genera falso positivo. +- `Erick Santaigo Herrera He` vs `ERICK SANTIAGO HERRERA HERRERA` sigue reportándose porque contiene una diferencia interna de escritura. +- Los registros de `Suhan Gonzales` vs `SUHAN ELIZABETH GONZALEZ DUBON` siguen reportándose cuando aparecen. + +### BambooHR + +El normalizador identifica 456 registros de Honduras en el reporte completo utilizado para prueba. Se corrigió el caso ambiguo de `Suyapa Palma`: cuando el nombre corto coincide con un registro histórico y el nombre completo del cuentahabiente identifica de forma única a `Suyapa Melisa Palma Aguilar`, el workflow prioriza el nombre legal completo y evita un falso “Banco sin Bamboo”. + +## Gemini / todas las quincenas y años + +El fallback determinista y el flujo Gemini fueron probados con: + +- `Nomina 6.1 2026 GLM HN.xlsx` → junio 1–15. +- `Nomina 6.2 2026 GLM HN.xlsx` → junio 16–30. +- `Nomina 7.1 2026 GLM HN.xlsx` → julio 1–15. +- `Nomina 7.2 2026 GLM HN.xlsx` → julio 16–31. +- Febrero 2027 → segunda quincena 16–28. +- Febrero 2028 → segunda quincena 16–29 (bisiesto). +- Diciembre 2031 → segunda quincena 16–31. + +El último día se calcula en tiempo de ejecución; no está fijado a 30 y no depende de 2026. + +## App / dist + +Validaciones realizadas sobre el paquete final: + +- `CountryCode` incluye `HN`. +- Configuración HN usa HNL, `es-HN` y la URL exacta de la bandera proporcionada. +- Webhook principal HN incorporado. +- Endpoint de Históricos HN incorporado. +- Endpoint de Marcar Resuelto HN incorporado. +- Modal de corrección muestra Melody Chang Blandin. +- El JavaScript compilado `dist/assets/index-pa.js` pasó `node --check`. +- `index.html`, JavaScript, CSS y favicon fueron servidos localmente bajo `/cruce-cuentas/` y respondieron HTTP 200. +- Los 16 archivos TypeScript/TSX de `src` pasaron una validación de sintaxis con TypeScript `transpileModule` sin errores. + +### Nota sobre el build + +El entorno aislado usado para preparar esta entrega no pudo completar una instalación nueva de dependencias desde npm. Por ello no se afirma que se haya ejecutado un `npm run build` fresco en esta sesión. El `dist` final parte del `dist` de El Salvador que el usuario ya había probado y desplegado, fue actualizado directamente con las mismas incorporaciones HN del código fuente y luego fue validado por sintaxis y servido estáticamente bajo la ruta real `/cruce-cuentas/`. + +## n8n + +Los cinco JSON finales: + +- parsean como JSON; +- están en `active: false`; +- tienen nombres e IDs de nodo únicos dentro de cada workflow; +- no contienen conexiones hacia nodos inexistentes; +- conservan los paths esperados; +- los nodos Code pasan validación de sintaxis JavaScript; +- las correcciones HN se dirigen a `mchang@gomezleemarketing.com`; +- el reporte general HN contiene los seis destinatarios definidos. diff --git a/dist/index.html b/dist/index.html index 91e2305..6d0ac05 100644 --- a/dist/index.html +++ b/dist/index.html @@ -9,7 +9,7 @@ Portal de Cruce de Cuentas GLM - + diff --git a/src/components/BambooCorrectionModal.tsx b/src/components/BambooCorrectionModal.tsx index d982352..39e9698 100644 --- a/src/components/BambooCorrectionModal.tsx +++ b/src/components/BambooCorrectionModal.tsx @@ -67,6 +67,7 @@ export default function BambooCorrectionModal({ if (country.code === 'TT') return 'Laurele Nicome-Jack'; if (country.code === 'PA') return 'Delarys Peñalba'; if (country.code === 'SV') return 'Carlos Antonio Malta Méndez'; + if (country.code === 'HN') return 'Melody Chang Blandin'; return 'haguirre@gomezleemarketing.com'; }, [country.code]); diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 5c9fdd4..19a6797 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -91,6 +91,13 @@ function getReconciliationUrl(countryCode: string) { ); } + if (countryCode === 'HN') { + return ( + (import.meta.env.VITE_N8N_HONDURAS_WEBHOOK_URL as string | undefined) || + 'https://agenteit.digitalcompass.agency/webhook/nominahn-bamboo-test' + ); + } + return ( (import.meta.env.VITE_N8N_GUATEMALA_WEBHOOK_URL as string | undefined) || (import.meta.env.VITE_N8N_WEBHOOK_URL as string | undefined) || @@ -674,8 +681,19 @@ function detectPeriodFromPayrollFilename(fileName: string): Partial readStoredPeriod(country.code), [country.code]); const isPanama = country.code === 'PA'; const isElSalvador = country.code === 'SV'; + const isHonduras = country.code === 'HN'; const isMultiPayrollCountry = isPanama || isElSalvador; const [isProcessing, setIsProcessing] = useState(false); const [processingStep, setProcessingStep] = useState(''); @@ -1648,7 +1667,8 @@ export default function Dashboard({ - {isMultiPayrollCountry && (uploadedPayrollFiles.length > 0 || uploadedBankFiles.length > 0) && ( + {(isMultiPayrollCountry || isHonduras) && + (uploadedPayroll !== null || uploadedPayrollFiles.length > 0 || uploadedBankFiles.length > 0) && (
\n\n \n ${numberValue(value).toLocaleString('en-US')}\n \n \n \n \n \n `;\n}\n\nconst countryCode = 'HN';\nconst countryName = 'Honduras';\n\nconst periodLabel =\n payload.period_label ||\n metadata.period_label ||\n [\n payload.year || metadata.year,\n payload.month || metadata.month,\n payload.period_type || metadata.period_type,\n ]\n .filter(Boolean)\n .join(' · ') ||\n 'Período no especificado';\n\nconst executedByName =\n payload.ejecutado_por_nombre ||\n metadata.requested_by_name ||\n 'Usuario GLM';\n\nconst executedByEmail =\n payload.ejecutado_por_email ||\n metadata.requested_by_email ||\n '';\n\nconst reportUrl =\n payload.report_url ||\n prepared.reportUrl ||\n '';\n\nconst discrepancias = numberValue(\n payload.discrepancias ??\n summary.discrepancias\n);\n\nconst bancoSinNomina = numberValue(\n payload.banco_sin_nomina ??\n summary.bancoSinNomina\n);\n\nconst bancoSinBamboo = numberValue(\n payload.banco_sin_bamboo ??\n summary.bancoSinBamboo\n);\n\nconst diferenciasNombreBanco = numberValue(\n metadata.diferencias_nombre_banco ??\n summary.diferenciasNombreBanco\n);\n\nconst nominaSinCuenta = numberValue(\n payload.nomina_sin_cuenta ??\n summary.nominaSinCuenta\n);\n\nconst coincidencias = numberValue(\n payload.coincidencias ??\n summary.coincidencias\n);\n\nconst generatedAt = new Date();\nconst generatedAtLabel =\n formatGeneratedAt(generatedAt);\n\nconst statusLabel =\n String(payload.estado || '').toLowerCase() === 'resuelto'\n ? 'Resuelto'\n : 'Pendiente de revisión';\n\nconst logoUrl =\n 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png';\n\nconst flagUrl =\n 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/Bandera%20de%20Honduras.webp';\n\nconst subject = reportUpdated\n ? `Reporte actualizado - Cruce de Cuentas ${countryName} - ${periodLabel}`\n : `Nuevo reporte generado - Cruce de Cuentas ${countryName} - ${periodLabel}`;\n\n/*\n * Todos son destinatarios directos.\n * Nadie se envía en CC.\n */\n\nconst to = [\n 'iaracena@gomezleemarketing.com',\n 'jgomez@gomezleemarketing.com',\n 'mgomez@gomezleemarketing.com',\n 'vparamo@gomezleemarketing.com',\n 'ymadera@gomezleemarketing.com',\n 'adminregional@gomezleemarketing.com',\n].join(',');\n\n\n// Paleta oficial GLM.\nconst metricCards = [\n metricCard(\n 'Discrepancias',\n discrepancias,\n '#FF6A13'\n ),\n metricCard(\n 'Banco sin nómina',\n bancoSinNomina,\n '#FF8C00'\n ),\n metricCard(\n 'Banco sin Bamboo',\n bancoSinBamboo,\n '#E87722'\n ),\n metricCard(\n 'Diferencias nombre banco',\n diferenciasNombreBanco,\n '#5B7F95'\n ),\n metricCard(\n 'Nómina sin cuenta',\n nominaSinCuenta,\n '#4F758B'\n ),\n].join('');\n\nconst htmlBody = `\n\n\n\n\n \n \n ${escapeHtml(subject)}\n\n\n\n\n \n \n \n\n \n\n \n \n \n  \n \n \n\n \n \n \n\n \n \n \n \n \n\n \n \n ${reportUpdated ? 'REPORTE ACTUALIZADO' : 'REPORTE GENERADO'}\n \n \n \n \n\n \n\n \n \n\n \n \n \n\n \n\n \n Cruce de Cuentas · ${escapeHtml(countryName)}\n \n\n \n ${escapeHtml(periodLabel)}\n \n\n \n\n \n \n\n \n \n\n \n \n \n\n \n ${reportUpdated ? 'Se actualizó el reporte existente del Portal de Cruce de Cuentas GLM conservando las resoluciones previamente registradas que continúan correspondiendo a los mismos casos.' : 'Se completó una nueva ejecución del Portal de Cruce de Cuentas GLM. Este es el resumen del reporte generado.'}\n \n\n \n \n\n \n \n \n\n \n \n ${metricCards}\n \n \n\n \n \n\n \n \n \n\n \n\n \n\n \n\n \n Ejecutado por\n \n\n \n ${escapeHtml(executedByName)}\n \n\n \n ${escapeHtml(\n executedByEmail ||\n 'Correo no disponible'\n )}\n \n\n \n\n \n\n \n País\n \n\n \n \n\n \n \n \n\n \n\n \n ${escapeHtml(countryName)}\n \n\n \n ${escapeHtml(\n countryCode || '—'\n )}\n \n\n \n\n \n \n\n \n\n \n\n \n\n \n\n \n Período\n \n\n \n ${escapeHtml(periodLabel)}\n \n\n \n ${escapeHtml(generatedAtLabel)}\n \n\n \n\n \n\n \n Estado inicial\n \n\n \n ${escapeHtml(statusLabel)}\n \n\n \n Coincidencias:\n ${coincidencias.toLocaleString('en-US')}\n \n\n \n\n \n\n \n\n \n \n\n \n ${\n reportUrl\n ? `\n \n \n\n \n Abrir reporte en Google Sheets\n \n\n \n \n `\n : ''\n }\n\n \n \n \n\n \n\n \n Notificación automática · Portal Cruce de Cuentas GLM\n
\n Los casos de revisión deben gestionarse directamente en el reporte generado.\n \n\n \n \n\n \n\n \n \n \n\n\n\n`;\n\nreturn [\n {\n json: {\n to,\n subject,\n htmlBody,\n\n notification: {\n country: countryCode,\n countryName,\n flagUrl,\n periodLabel,\n executedByName,\n executedByEmail,\n generatedAt: generatedAt.toISOString(),\n reportUrl,\n\n metrics: {\n discrepancias,\n bancoSinNomina,\n bancoSinBamboo,\n diferenciasNombreBanco,\n nominaSinCuenta,\n coincidencias,\n },\n },\n },\n },\n];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4688, + 672 + ], + "id": "a067ffc0-607f-4094-be4b-19bbd95f1171", + "name": "Preparar correo reporte generado" + }, + { + "parameters": { + "sendTo": "={{$json.to}}", + "subject": "={{$json.subject}}", + "message": "={{ $json.htmlBody }}", + "options": { + "appendAttribution": false + } + }, + "type": "n8n-nodes-base.gmail", + "typeVersion": 2.2, + "position": [ + 4928, + 672 + ], + "id": "c309bd25-b751-46a5-a2fd-fbc4442cb7c0", + "name": "Enviar correo reporte generado", + "webhookId": "4bcdbd7b-503d-46ac-aeaf-9207f59f01e7", + "credentials": { + "gmailOAuth2": { + "id": "UDcO1FLJqA453V2D", + "name": "Gmail account 3" + } + }, + "onError": "continueRegularOutput" + }, + { + "parameters": { + "content": "# 🇭🇳 CRUCE DE CUENTAS — HONDURAS\n\nFlujo principal para una nómina quincenal de Honduras + todos los planos bancarios BAC del período + BambooHR.\n\n- Monedas: HNL y USD.\n- BambooHR: `onlyCurrent=false`, filtrado por país/localidad Honduras.\n- Q1: días 1–15. Q2: días 16–último día real del mes.\n- Los meses/años se calculan dinámicamente, incluidos años bisiestos.\n- La nómina usa las hojas operativas validadas de GLM Honduras y omite hojas auxiliares/copias para evitar duplicados.", + "height": 260, + "width": 520 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + -1088, + 80 + ], + "id": "bfd68985-f8c4-4311-bc99-dd4f67042115", + "name": "📌 HONDURAS · V1" + }, + { + "parameters": { + "content": "# 🏦 REGLA DE PLANOS — HONDURAS\n\nSe procesan todos los CSV `Consulta Detalle de Envío_HON_...` cargados. Se reconocen planes AR94 (HNL) y AR95US (USD), además de la moneda informada dentro del archivo.\n\nEl período se filtra por la fecha de aplicación de cada transacción: Q1 = 1–15 y Q2 = 16–fin de mes. El cruce se realiza por persona, cuenta, moneda y monto, con una segunda pasada controlada para detectar diferencias o posibles cuentas mal digitadas.", + "height": 300, + "width": 700 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 0, + 0 + ], + "id": "91b14935-3200-4f6a-b926-6299f3a6141b", + "name": "📌 REGLA BANCO HN" + }, + { + "parameters": { + "content": "# 📄 ARCHIVO DE NÓMINA — HONDURAS\n\nLa app recibe un solo Excel por quincena, por ejemplo `Nomina 7.1 2026 GLM HN.xlsx` o `Nomina 7.2 2026 GLM HN.xlsx`.\n\nEl flujo extrae únicamente las hojas de pago validadas (Colgate, POP Helper, Motorola, Nestlé, Whirlpool, FF Regional y GLM) y normaliza `NOMBRE`, `Identidad`, `# de Cuenta Bac` y `NETO A PAGAR` / `Total a Pagar`. Las hojas de copia/auxiliares no se contabilizan.", + "height": 240, + "width": 620 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + -608, + 928 + ], + "id": "62ec2b77-ecfb-42a1-984c-874f84fd77ba", + "name": "📌 ARCHIVOS NÓMINA HN" + }, + { + "parameters": { + "content": "# 📊 SALIDA\n\nConserva la misma estructura de reportes de GT/TT/PA/SV/HN, reutiliza el Google Sheet del mismo período si existe, actualiza Supabase y envía correo tanto en creación como en actualización.\n\nLos valores se escriben en Google Sheets con `RAW` para preservar las cuentas bancarias como texto exacto, incluyendo ceros iniciales y evitando notación científica.", + "height": 250, + "width": 720 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 3120, + 832 + ], + "id": "70cdc5fd-3337-43dd-950d-64f68b311936", + "name": "📌 REPORTES" + } + ], + "pinData": {}, + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Preparar entrada app", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar entrada app": { + "main": [ + [ + { + "node": "Parsear CSV banco HN", + "type": "main", + "index": 0 + }, + { + "node": "Preparar archivos nómina HN", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar archivos nómina HN": { + "main": [ + [ + { + "node": "Extract - Archivo Nómina HN", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract - Archivo Nómina HN": { + "main": [ + [ + { + "node": "Normalizar Nómina HN", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parsear CSV banco HN": { + "main": [ + [ + { + "node": "Merge Banco + Nómina HN", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalizar Nómina HN": { + "main": [ + [ + { + "node": "Merge Banco + Nómina HN", + "type": "main", + "index": 1 + } + ] + ] + }, + "Merge Banco + Nómina HN": { + "main": [ + [ + { + "node": "HTTP - Empleados BambooHR HN", + "type": "main", + "index": 0 + }, + { + "node": "Merge - Agregar BambooHR HN", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Empleados BambooHR HN": { + "main": [ + [ + { + "node": "Normalizar BambooHR HN", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalizar BambooHR HN": { + "main": [ + [ + { + "node": "Merge - Agregar BambooHR HN", + "type": "main", + "index": 1 + } + ] + ] + }, + "Merge - Agregar BambooHR HN": { + "main": [ + [ + { + "node": "Obtener alias BambooHR Supabase", + "type": "main", + "index": 0 + } + ] + ] + }, + "Obtener alias BambooHR Supabase": { + "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": "Buscar reporte existente Supabase", + "type": "main", + "index": 0 + } + ] + ] + }, + "Buscar reporte existente Supabase": { + "main": [ + [ + { + "node": "Crear Google Sheet", + "type": "main", + "index": 0 + } + ] + ] + }, + "Crear Google Sheet": { + "main": [ + [ + { + "node": "Leer Google Sheet actual", + "type": "main", + "index": 0 + } + ] + ] + }, + "Leer Google Sheet actual": { + "main": [ + [ + { + "node": "Preparar actualización Google Sheet", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar actualización Google Sheet": { + "main": [ + [ + { + "node": "Actualizar estructura Google Sheet", + "type": "main", + "index": 0 + } + ] + ] + }, + "Actualizar estructura Google Sheet": { + "main": [ + [ + { + "node": "Limpiar Google Sheet", + "type": "main", + "index": 0 + } + ] + ] + }, + "Limpiar 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 + }, + { + "node": "Preparar correo reporte generado", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar respuesta final": { + "main": [ + [ + { + "node": "Respond to Webhook", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar correo reporte generado": { + "main": [ + [ + { + "node": "Enviar correo reporte generado", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate" + }, + "versionId": "a4e7c2cc-6784-43db-a676-1288cf3f718e", + "meta": { + "instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70" + }, + "tags": [] +} \ No newline at end of file diff --git a/workflows_n8n/Portal de Verificaci├│n de N├│mina - Reportar correcci├│n BambooHR a RRHH - GT TT PA SV HN.json b/workflows_n8n/Portal de Verificaci├│n de N├│mina - Reportar correcci├│n BambooHR a RRHH - GT TT PA SV HN.json new file mode 100644 index 0000000..5f85045 --- /dev/null +++ b/workflows_n8n/Portal de Verificaci├│n de N├│mina - Reportar correcci├│n BambooHR a RRHH - GT TT PA SV HN.json @@ -0,0 +1,514 @@ +{ + "name": "Portal de Verificación de Nómina - Reportar corrección BambooHR a RRHH - GT TT PA SV HN", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "cruce-cuentas-bamboo-correccion", + "responseMode": "responseNode", + "options": {} + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + 768, + 1984 + ], + "id": "e5de6fd4-03ff-4d15-b567-24209b9596cc", + "name": "Webhook", + "webhookId": "821fa25f-8b4c-417b-870c-73183194a709" + }, + { + "parameters": { + "jsCode": "const incoming = $input.first().json || {};\nconst rawBody = incoming.body ?? incoming;\n\nlet body = rawBody;\n\nif (rawBody && typeof rawBody === 'object' && typeof rawBody.payload === 'string') {\n try {\n body = JSON.parse(rawBody.payload);\n } catch (error) {\n throw new Error('El payload recibido no contiene JSON válido.');\n }\n}\n\nif (!body || typeof body !== 'object') {\n throw new Error('No se recibió una solicitud válida.');\n}\n\nconst accessToken = String(\n (rawBody && typeof rawBody === 'object' ? rawBody.access_token : '') ||\n body.access_token ||\n ''\n).trim();\n\nfunction clean(value) {\n return String(value ?? '').replace(/\\s+/g, ' ').trim();\n}\n\nfunction normalizeName(value) {\n return clean(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^a-z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nconst country = clean(body.country).toUpperCase();\nif (!['GT', 'TT', 'PA', 'SV', 'HN'].includes(country)) {\n throw new Error('País no permitido para reportar correcciones BambooHR.');\n}\n\nif (!accessToken) {\n throw new Error('No se recibió una sesión autenticada de Supabase.');\n}\n\nif (body.confirmed_same_person !== true) {\n throw new Error('La revisión manual debe confirmar que se trata de la misma persona.');\n}\n\nconst sourceNames = Array.from(new Set([\n ...(Array.isArray(body.source_names) ? body.source_names : []),\n body.source_name,\n body.bank_name_file,\n body.bank_account_holder,\n]\n .map(clean)\n .filter(Boolean)\n .filter((value) => normalizeName(value))));\n\nif (!sourceNames.length) {\n throw new Error('No se recibió el nombre detectado en Banco sin BambooHR.');\n}\n\nconst bambooName = clean(body.bamboo_name);\nif (!bambooName) {\n throw new Error('Debes indicar el nombre correcto en BambooHR.');\n}\n\nreturn [{\n json: {\n accessToken,\n request: {\n country,\n countryName: ({ GT: 'Guatemala', TT: 'Trinidad y Tobago', PA: 'Panamá', SV: 'El Salvador', HN: 'Honduras' })[country],\n sourceName: clean(body.source_name) || sourceNames[0],\n sourceNames,\n bankNameFile: clean(body.bank_name_file),\n bankAccountHolder: clean(body.bank_account_holder),\n bankAccount: clean(body.bank_account),\n bankAmount: Number.isFinite(Number(body.bank_amount)) ? Number(body.bank_amount) : null,\n currency: clean(body.currency) || ({ GT: 'GTQ', TT: 'TTD', PA: 'USD', SV: 'USD', HN: 'HNL' })[country],\n bambooName,\n bambooEmployeeNumber: clean(body.bamboo_employee_number),\n reason: clean(body.reason) || 'Corrección de identidad en BambooHR',\n comment: clean(body.comment),\n executionId: clean(body.execution_id),\n reportUrl: clean(body.report_url),\n periodLabel: clean(body.period_label),\n periodStart: clean(body.period_start),\n periodEnd: clean(body.period_end),\n requestedByNameClaimed: clean(body.requested_by_name),\n requestedByEmailClaimed: clean(body.requested_by_email).toLowerCase(),\n },\n },\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1008, + 1984 + ], + "id": "569fbf65-f297-416b-988a-c8ab108899bb", + "name": "Preparar solicitud" + }, + { + "parameters": { + "method": "POST", + "url": "https://dbit.digitalcompass.agency/rest/v1/rpc/cruce_cuentas_mi_acceso", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "apikey", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE" + }, + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $('Preparar solicitud').first().json.accessToken }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": {}, + "options": { + "timeout": 30000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1248, + 1984 + ], + "id": "4b3bd35e-8097-4b1b-a665-b482d887e8d7", + "name": "Validar acceso Supabase", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 1000 + }, + { + "parameters": { + "jsCode": "const raw = $input.first().json || {};\nconst access = Array.isArray(raw) ? (raw[0] || {}) : raw;\nconst prepared = $('Preparar solicitud').first().json || {};\nconst request = prepared.request || {};\n\nconst email = String(access.email || '').trim().toLowerCase();\nconst countries = Array.isArray(access.paises)\n ? access.paises.map((value) => String(value || '').trim().toUpperCase()).filter(Boolean)\n : [];\n\nif (\n access.ok !== true ||\n access.autenticado !== true ||\n access.autorizado !== true ||\n access.activo !== true ||\n access.acceso_app !== true ||\n !email\n) {\n throw new Error(access.motivo || 'El usuario no tiene acceso autorizado al Cruce de Cuentas.');\n}\n\nif (countries.length && !countries.includes(request.country)) {\n throw new Error(`El usuario autenticado no tiene acceso al país ${request.country}.`);\n}\n\nreturn [{\n json: {\n ...request,\n requestedByName: String(access.nombre || request.requestedByNameClaimed || email).trim(),\n requestedByEmail: email,\n },\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1488, + 1984 + ], + "id": "67e4c922-7c87-4567-a1d9-4d9b676684db", + "name": "Validar usuario" + }, + { + "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": "Validación manual - Corrección identidad Cruce de Cuentas", + "fields": [ + "firstName", + "middleName", + "lastName", + "displayName", + "location", + "status", + "employeeNumber", + "country" + ] + }, + "options": { + "response": { + "response": { + "responseFormat": "json" + } + }, + "timeout": 300000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1728, + 1984 + ], + "id": "9d9b8120-c80b-456c-8bbe-c945755c1507", + "name": "Consultar empleado BambooHR", + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 3000, + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "jsCode": "const request = $('Validar usuario').first().json || {};\nconst raw = $input.first().json || {};\n\nfunction clean(value) {\n return String(value ?? '').replace(/\\s+/g, ' ').trim();\n}\n\nfunction normalizeName(value) {\n return clean(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^a-z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeIdentifier(value) {\n return clean(value).toUpperCase().replace(/[^A-Z0-9]/g, '');\n}\n\nfunction countryMatches(employee) {\n const location = normalizeName(\n employee.location ||\n employee.jobInformationLocation ||\n employee.jobLocation ||\n ''\n );\n const country = normalizeName(employee.country || '');\n const combined = `${location} ${country}`.trim();\n\n if (request.country === 'GT') {\n return combined.includes('guatemala');\n }\n\n if (request.country === 'PA') {\n return combined.includes('panama');\n }\n\n if (request.country === 'SV') {\n return combined.includes('el salvador') || combined.includes('salvador');\n }\n\n if (request.country === 'HN') {\n return combined.includes('honduras');\n }\n\n return combined.includes('trinidad') || combined.includes('tobago');\n}\n\nfunction employeeAliases(employee) {\n const first = clean(employee.firstName || employee.first_name);\n const middle = clean(employee.middleName || employee.middle_name);\n const last = clean(employee.lastName || employee.last_name);\n const display = clean(employee.displayName || employee.fullName || employee.full_name);\n\n return Array.from(new Set([\n display,\n [first, middle, last].filter(Boolean).join(' '),\n [first, last].filter(Boolean).join(' '),\n ].map(clean).filter(Boolean)));\n}\n\nconst employees = Array.isArray(raw.employees)\n ? raw.employees\n : Array.isArray(raw.data?.employees)\n ? raw.data.employees\n : [];\n\nif (!employees.length) {\n throw new Error('BambooHR no devolvió empleados para validar la corrección.');\n}\n\nconst candidates = employees.filter(countryMatches);\nif (!candidates.length) {\n throw new Error(`No se encontraron empleados de ${request.countryName} en BambooHR.`);\n}\n\nconst requestedNumber = normalizeIdentifier(request.bambooEmployeeNumber);\nlet matches = [];\nlet matchedBy = '';\n\nif (requestedNumber) {\n matches = candidates.filter((employee) =>\n normalizeIdentifier(employee.employeeNumber || employee.employee_number) === requestedNumber\n );\n matchedBy = 'employee_number';\n}\n\nif (!matches.length) {\n const targetName = normalizeName(request.bambooName);\n matches = candidates.filter((employee) =>\n employeeAliases(employee).some((alias) => normalizeName(alias) === targetName)\n );\n matchedBy = 'exact_bamboo_name';\n}\n\nif (matches.length !== 1) {\n if (matches.length > 1) {\n throw new Error('La identificación en BambooHR es ambigua. Indica el Employee Number para seleccionar una sola persona.');\n }\n\n throw new Error(\n 'No se encontró exactamente ese empleado en BambooHR para el país seleccionado. Revisa el nombre o indica el Employee Number.'\n );\n}\n\nconst employee = matches[0];\nconst aliases = employeeAliases(employee);\nconst actualName = clean(employee.displayName) || aliases[0] || request.bambooName;\nconst actualEmployeeNumber = clean(employee.employeeNumber || employee.employee_number);\nconst bambooId = clean(employee.employeeId || employee.id);\nconst now = new Date().toISOString();\n\nconst recipientsByCountry = {\n GT: ['haguirre@gomezleemarketing.com'],\n TT: ['lnicomejack@gomezleemarketing.com'],\n PA: ['dpenalba@gomezleemarketing.com'],\n SV: ['cmalta@gomezleemarketing.com'],\n HN: ['mchang@gomezleemarketing.com'],\n};\nconst recipients = recipientsByCountry[request.country] || [];\n\nconst aliasPayload = request.sourceNames.map((sourceName) => ({\n pais: request.country,\n nombre_origen: sourceName,\n nombre_origen_normalizado: normalizeName(sourceName),\n cuenta_bancaria: String(request.bankAccount || '').replace(/[^0-9A-Za-z]+/g, ''),\n bamboo_employee_number: actualEmployeeNumber || null,\n bamboo_employee_id: bambooId || null,\n bamboo_nombre: actualName,\n motivo: request.reason,\n comentario: request.comment || null,\n activo: true,\n validado_por_email: request.requestedByEmail,\n validado_por_nombre: request.requestedByName,\n last_reported_at: now,\n updated_at: now,\n}));\n\nreturn [{\n json: {\n request,\n matchedBy,\n employee: {\n bambooId,\n employeeNumber: actualEmployeeNumber,\n name: actualName,\n status: clean(employee.status),\n location: clean(employee.location),\n },\n recipients,\n aliasPayload,\n },\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1968, + 1984 + ], + "id": "780c274d-995c-496f-bccf-70eee565bb00", + "name": "Resolver empleado BambooHR" + }, + { + "parameters": { + "method": "POST", + "url": "https://dbit.digitalcompass.agency/rest/v1/cruce_cuentas_bamboo_aliases?on_conflict=pais,nombre_origen_normalizado,cuenta_bancaria", + "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": "resolution=merge-duplicates,return=representation" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $('Resolver empleado BambooHR').first().json.aliasPayload }}", + "options": { + "timeout": 30000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 2208, + 1984 + ], + "id": "15016826-fa2c-4ca1-a103-a2dd00299c66", + "name": "Guardar alias Supabase", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 1000 + }, + { + "parameters": { + "jsCode": "const resolved = $('Resolver empleado BambooHR').first().json || {};\nconst request = resolved.request || {};\nconst employee = resolved.employee || {};\nconst aliasResponseItems = $input.all().map((item) => item.json || {});\n\nfunction flattenRows(items) {\n const rows = [];\n for (const item of items) {\n if (Array.isArray(item)) rows.push(...item);\n else if (Array.isArray(item.data)) rows.push(...item.data);\n else rows.push(item);\n }\n return rows.filter((row) => row && typeof row === 'object');\n}\n\nconst aliasRows = flattenRows(aliasResponseItems);\nconst aliasIds = aliasRows\n .map((row) => row.id)\n .filter(Boolean);\n\nconst requestPayload = {\n pais: request.country,\n nombre_origen_principal: request.sourceName,\n nombres_origen: request.sourceNames,\n cuenta_bancaria: request.bankAccount || null,\n monto_banco: request.bankAmount,\n moneda: request.currency,\n bamboo_employee_number: employee.employeeNumber || null,\n bamboo_employee_id: employee.bambooId || null,\n bamboo_nombre: employee.name,\n motivo: request.reason,\n comentario: request.comment || null,\n execution_id: request.executionId || null,\n report_url: request.reportUrl || null,\n period_label: request.periodLabel || null,\n period_start: request.periodStart || null,\n period_end: request.periodEnd || null,\n solicitado_por_email: request.requestedByEmail,\n solicitado_por_nombre: request.requestedByName,\n destinatarios_rrhh: resolved.recipients || [],\n alias_ids: aliasIds,\n estado: 'pendiente_notificacion',\n correo_enviado: false,\n};\n\nreturn [{ json: { requestPayload, aliasIds } }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2448, + 1984 + ], + "id": "51f90afa-04d0-4d8d-80c2-dbc8278b6ad7", + "name": "Preparar auditoría" + }, + { + "parameters": { + "method": "POST", + "url": "https://dbit.digitalcompass.agency/rest/v1/cruce_cuentas_bamboo_correcciones", + "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.requestPayload }}", + "options": { + "timeout": 30000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 2688, + 1984 + ], + "id": "e105a2df-aa71-4e0c-b895-87f2f6709d82", + "name": "Guardar solicitud Supabase", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 1000 + }, + { + "parameters": { + "jsCode": "const resolved = $('Resolver empleado BambooHR').first().json || {};\nconst request = resolved.request || {};\nconst employee = resolved.employee || {};\nconst auditInput = $input.all().map((item) => item.json || {});\n\nfunction firstObject(value) {\n if (Array.isArray(value)) return value[0] || {};\n if (value?.data && Array.isArray(value.data)) return value.data[0] || {};\n return value || {};\n}\n\nconst auditRow = firstObject(auditInput[0]);\nconst requestId = String(auditRow.id || '');\nif (!requestId) {\n throw new Error('Supabase no devolvió el ID de la solicitud de corrección.');\n}\n\nfunction escapeHtml(value) {\n return String(value ?? '')\n .replace(/&/g, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n\nconst logoUrl = 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png';\nconst flagUrl = request.country === 'TT'\n ? 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/Flag_of_Trinidad_and_Tobago.svg.webp'\n : request.country === 'PA'\n ? 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/Flag_of_Panama.svg'\n : request.country === 'SV'\n ? 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/Flag_of_El_Salvador.svg.webp'\n : request.country === 'HN'\n ? 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/Bandera%20de%20Honduras.webp'\n : 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/Flag_of_Guatemala.svg.webp';\n\nconst to = (resolved.recipients || []).join(',');\nconst subject = `Corrección requerida en BambooHR - ${request.countryName} - ${request.sourceName}`;\n\nconst reportButton = request.reportUrl\n ? `Abrir reporte de Cruce de Cuentas`\n : '';\n\nconst htmlBody = `\n\n\n\n\n
\n\n\n
 
\n\n\n\n
\"GomezLee\"Bandera
\n
\n
Corrección de identidad · BambooHR
\n

Revisión solicitada para ${escapeHtml(request.countryName)}

\n

Durante la revisión manual de Banco sin BambooHR se confirmó que el pago corresponde a una persona existente en BambooHR, pero su nombre no está permitiendo identificarla correctamente en el cruce.

\n\n\n\n\n\n\n\n\n
Nombre detectado${escapeHtml(request.sourceName)}
Empleado en BambooHR${escapeHtml(employee.name)}
Employee Number${escapeHtml(employee.employeeNumber || 'No disponible')}
Motivo${escapeHtml(request.reason)}
Período${escapeHtml(request.periodLabel || 'No especificado')}
Reportado por${escapeHtml(request.requestedByName)} · ${escapeHtml(request.requestedByEmail)}
\n\n${request.comment ? `
Comentario: ${escapeHtml(request.comment)}
` : ''}\n\n

Por favor, revisen y corrijan el nombre o la información correspondiente en BambooHR. La equivalencia validada ya quedó registrada en el Cruce de Cuentas para evitar que este mismo falso positivo se repita mientras se realiza la corrección.

\n\n${reportButton ? `
${reportButton}
` : ''}\n\n
\n
Notificación automática · Portal Cruce de Cuentas GLM · Uso interno
\n
\n
\n`;\n\nreturn [{\n json: {\n requestId,\n to,\n subject,\n htmlBody,\n country: request.country,\n countryName: request.countryName,\n aliasCount: Array.isArray(resolved.aliasPayload) ? resolved.aliasPayload.length : 0,\n },\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2928, + 1984 + ], + "id": "8a6facdd-60ee-4713-bb2c-5121d8449fea", + "name": "Preparar correo RRHH" + }, + { + "parameters": { + "sendTo": "={{$json.to}}", + "subject": "={{$json.subject}}", + "message": "={{$json.htmlBody}}", + "options": { + "appendAttribution": false + } + }, + "type": "n8n-nodes-base.gmail", + "typeVersion": 2.2, + "position": [ + 3168, + 1984 + ], + "id": "8cbb778d-b36e-4ae9-b55e-79bf8dca9d28", + "name": "Enviar correo RRHH", + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 2000, + "webhookId": "f1d0bb21-fe2a-4742-9dcf-6d6f5eb60fa3", + "credentials": { + "gmailOAuth2": { + "id": "UDcO1FLJqA453V2D", + "name": "Gmail account 3" + } + } + }, + { + "parameters": { + "method": "PATCH", + "url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/cruce_cuentas_bamboo_correcciones?id=eq.' + $('Preparar correo RRHH').first().json.requestId }}", + "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=minimal" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ { correo_enviado: true, correo_enviado_at: new Date().toISOString(), estado: 'notificado_rrhh' } }}", + "options": { + "timeout": 30000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 3408, + 1984 + ], + "id": "8dbc3f8e-0ec7-48b2-95a5-6c9905cb6580", + "name": "Marcar correo enviado", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 1000 + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ { ok: true, message: 'Caso validado, equivalencia registrada y correo enviado a RRHH.', country: $('Preparar correo RRHH').first().json.country, countryName: $('Preparar correo RRHH').first().json.countryName, aliasCount: $('Preparar correo RRHH').first().json.aliasCount, requestId: $('Preparar correo RRHH').first().json.requestId } }}", + "options": { + "responseCode": 200, + "responseHeaders": { + "entries": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + } + } + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 3648, + 1984 + ], + "id": "ad4e2147-041e-43d6-b952-303d991635ff", + "name": "Respond to Webhook" + } + ], + "pinData": {}, + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Preparar solicitud", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar solicitud": { + "main": [ + [ + { + "node": "Validar acceso Supabase", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validar acceso Supabase": { + "main": [ + [ + { + "node": "Validar usuario", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validar usuario": { + "main": [ + [ + { + "node": "Consultar empleado BambooHR", + "type": "main", + "index": 0 + } + ] + ] + }, + "Consultar empleado BambooHR": { + "main": [ + [ + { + "node": "Resolver empleado BambooHR", + "type": "main", + "index": 0 + } + ] + ] + }, + "Resolver empleado BambooHR": { + "main": [ + [ + { + "node": "Guardar alias Supabase", + "type": "main", + "index": 0 + } + ] + ] + }, + "Guardar alias Supabase": { + "main": [ + [ + { + "node": "Preparar auditoría", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar auditoría": { + "main": [ + [ + { + "node": "Guardar solicitud Supabase", + "type": "main", + "index": 0 + } + ] + ] + }, + "Guardar solicitud Supabase": { + "main": [ + [ + { + "node": "Preparar correo RRHH", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar correo RRHH": { + "main": [ + [ + { + "node": "Enviar correo RRHH", + "type": "main", + "index": 0 + } + ] + ] + }, + "Enviar correo RRHH": { + "main": [ + [ + { + "node": "Marcar correo enviado", + "type": "main", + "index": 0 + } + ] + ] + }, + "Marcar correo enviado": { + "main": [ + [ + { + "node": "Respond to Webhook", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate" + }, + "versionId": "a6157be9-0bb2-4937-845f-1769af596051", + "meta": { + "instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70" + }, + "tags": [] +} \ No newline at end of file diff --git a/workflows_n8n/Portal de Verificaci├│n de N├│mina Honduras - Env├¡o de Reporte.json b/workflows_n8n/Portal de Verificaci├│n de N├│mina Honduras - Env├¡o de Reporte.json new file mode 100644 index 0000000..dce3391 --- /dev/null +++ b/workflows_n8n/Portal de Verificaci├│n de N├│mina Honduras - Env├¡o de Reporte.json @@ -0,0 +1,347 @@ +{ + "name": "Portal de Verificación de Nómina Honduras - Envío de Reporte", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "cruce-cuentas-hn-marcar-resuelto", + "responseMode": "responseNode", + "options": {} + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + 784, + 592 + ], + "id": "521b0b8a-b2da-481c-a23c-8a7ef4859f2d", + "name": "Webhook Marcar Resuelto", + "webhookId": "93bcd7d9-d9d1-474e-970b-50ba5d7d7d67" + }, + { + "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": [ + 992, + 592 + ], + "id": "4874d8fb-1de2-45c9-bcfd-1a7a86d9a6fd", + "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": [ + 1200, + 592 + ], + "id": "2b288fbd-ce10-4ca1-814c-3876ffd7e326", + "name": "¿Solicitud válida?" + }, + { + "parameters": { + "method": "PATCH", + "url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/cruces_cuentas_gt_reportes?id=eq.' + encodeURIComponent($json.reportId) + '&country=eq.HN' }}", + "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": [ + 1536, + 480 + ], + "id": "7cf64404-fbec-4967-8d8f-37cce6e94694", + "name": "Actualizar reporte Supabase" + }, + { + "parameters": { + "jsCode": "const input = $input.first().json || {};\nconst report = Array.isArray(input) ? input[0] : input;\n\nif (!report || !report.id) {\n throw new Error(\n 'No se encontró un reporte de Honduras con el ID recibido.'\n );\n}\n\nif (String(report.country || '').toUpperCase() !== 'HN') {\n throw new Error(\n 'El reporte localizado no pertenece a Honduras.'\n );\n}\nconst original = $('Validar resolución').first().json || {};\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 bancoSinNomina = numberValue(report.banco_sin_nomina);\nconst bancoSinBamboo = numberValue(report.banco_sin_bamboo);\nconst nominaSinCuenta = numberValue(report.nomina_sin_cuenta);\n\nconst resolvedAt = original.resolvedAt || new Date().toISOString();\n\nconst resolvedDateLabel = new Date(resolvedAt).toLocaleString('es-HN', {\n timeZone: 'America/Tegucigalpa',\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 flagUrl =\n 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/Bandera%20de%20Honduras.webp';\n\nconst subject =\n `Reporte resuelto - Cruce de Cuentas Honduras - ${periodo}`;\n\nconst textBody = `\nHola equipo,\n\nSe marcó como resuelto un reporte de Cruce de Cuentas Honduras.\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 nómina: ${bancoSinNomina}\n- Banco sin Bamboo: ${bancoSinBamboo}\n- Nómina sin cuenta: ${nominaSinCuenta}\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 ${escapeHtml(subject)}\n\n\n\n\n \n \n \n\n \n\n \n \n \n \n \n\n \n  \n \n\n \n \n
\n Cruce de Cuentas Honduras\n \"Bandera\n
\n\n

\n Reporte marcado como resuelto\n

\n \n \n\n \n \n

\n Hola equipo,\n

\n\n

\n El siguiente reporte fue revisado y marcado como\n resuelto.\n

\n \n \n\n \n \n \n \n \n Período\n \n\n \n ${escapeHtml(periodo)}\n \n \n\n \n \n Resuelto por\n \n\n \n ${escapeHtml(resolvedByName)}\n ${\n resolvedByEmail\n ? `
${escapeHtml(resolvedByEmail)}`\n : ''\n }\n \n \n\n \n \n Fecha y hora\n \n\n \n ${escapeHtml(resolvedDateLabel)}\n \n \n \n \n \n\n \n \n
\n Resumen del reporte\n
\n \n \n\n \n \n \n \n \n
\n ${discrepancias}\n
\n\n
\n Discrepancias\n
\n \n\n \n
\n ${bancoSinBamboo}\n
\n\n
\n Banco sin Bamboo\n
\n \n\n \n
\n ${bancoSinNomina}\n
\n\n
\n Banco sin nómina\n
\n \n\n \n
\n ${nominaSinCuenta}\n
\n\n
\n Nómina sin cuenta\n
\n \n \n \n \n \n\n \n \n
\n Comentario de resolución\n
\n \n \n\n \n \n
\n ${escapeHtml(resolutionComment).replace(/\\n/g, '
')}\n
\n \n \n\n ${\n reportUrl\n ? `\n \n \n \n Abrir Google Sheet\n \n \n \n `\n : ''\n }\n\n \n \n Mensaje automático del Portal Cruce de Cuentas GLM.\n \n \n\n \n \n \n \n\n\n\n`.trim();\n\nreturn [\n {\n json: {\n to: 'jgomez@gomezleemarketing.com, mgomez@gomezleemarketing.com, ymadera@gomezleemarketing.com',\n cc: 'iaracena@gomezleemarketing.com',\n subject,\n textBody,\n htmlBody,\n report,\n resolution: original,\n },\n },\n];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1744, + 480 + ], + "id": "db7305e1-ae76-4cd8-b4b7-adea597ab2d8", + "name": "Preparar correo equipo HN" + }, + { + "parameters": { + "sendTo": "={{$json.to}}", + "subject": "={{$json.subject}}", + "message": "={{ $json.htmlBody }}", + "options": { + "appendAttribution": false, + "ccList": "={{ $json.cc }}" + } + }, + "type": "n8n-nodes-base.gmail", + "typeVersion": 2.2, + "position": [ + 1952, + 480 + ], + "id": "ae456f45-b90d-469d-9dfc-0bdea9ef989f", + "name": "Enviar correo equipo HN", + "webhookId": "ad819366-b163-4e58-86de-a7cd158630b1", + "credentials": { + "gmailOAuth2": { + "id": "UDcO1FLJqA453V2D", + "name": "Gmail account 3" + } + } + }, + { + "parameters": { + "jsCode": "const original = $('Validar resolución').first().json || {};\nconst updatedResponse = $('Actualizar reporte Supabase').first().json || {};\nconst updatedReport = Array.isArray(updatedResponse) ? updatedResponse[0] : updatedResponse;\n\nreturn [\n {\n json: {\n ok: true,\n stage: 'reporte_resuelto',\n message: 'Reporte marcado como resuelto correctamente.',\n report: {\n id: original.reportId,\n estado: 'resuelto',\n estadoLabel: 'RESUELTO',\n country: 'HN',\n countryName: 'Honduras',\n resueltoPorNombre: original.resueltoPorNombre,\n resueltoPorEmail: original.resueltoPorEmail,\n resueltoEn: original.resolvedAt,\n comentarioResolucion: original.comentarioResolucion,\n reportUrl: updatedReport.report_url || '',\n },\n },\n },\n];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2160, + 480 + ], + "id": "52313fe1-f491-45e0-94cf-89029a666e58", + "name": "Preparar respuesta resuelto" + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": { + "responseCode": 200, + "responseHeaders": { + "entries": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Access-Control-Allow-Origin", + "value": "*" + }, + { + "name": "Access-Control-Allow-Methods", + "value": "GET,POST,OPTIONS" + }, + { + "name": "Access-Control-Allow-Headers", + "value": "Content-Type, Authorization" + } + ] + } + } + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 2368, + 480 + ], + "id": "ae1e7827-6e9d-4506-9365-4a075e5f2c05", + "name": "Responder resuelto app" + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": { + "responseCode": 400, + "responseHeaders": { + "entries": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Access-Control-Allow-Origin", + "value": "*" + }, + { + "name": "Access-Control-Allow-Methods", + "value": "GET,POST,OPTIONS" + }, + { + "name": "Access-Control-Allow-Headers", + "value": "Content-Type, Authorization" + } + ] + } + } + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 1408, + 688 + ], + "id": "424eb861-9fd3-4b38-bfb3-4adb08301ff8", + "name": "Responder error resolución" + }, + { + "parameters": { + "content": "# ✅ CIERRE DE REPORTE — HONDURAS\n\nRecibe desde el Portal de Verificación de Nóminas la solicitud para marcar un reporte de Honduras como resuelto.\n\n## Validación inicial\n\nEl Webhook recibe la información enviada por la aplicación y valida datos como:\n\n- Identificador del reporte.\n- Usuario que realizó el cierre.\n- Estado solicitado.\n- Comentario o detalle de resolución.\n- Datos necesarios para localizar el registro en Supabase.\n\nLa solicitud solamente continúa cuando contiene información válida y suficiente.\n\n## Actualización del reporte\n\nCuando la solicitud es correcta:\n\n1. Localiza el reporte correspondiente en Supabase.\n2. Cambia su estado a RESUELTO.\n3. Registra quién realizó la resolución.\n4. Guarda la fecha y hora del cierre.\n5. Conserva el comentario o información de seguimiento disponible.\n\nSupabase es la fuente oficial del estado del reporte dentro del portal.\n\n## Notificación al equipo de Honduras\n\nDespués de actualizar el registro:\n\n1. Se prepara un correo para el equipo responsable de Honduras.\n2. Se incluyen los datos principales del reporte.\n3. Se informa que el caso fue marcado como resuelto.\n4. Se identifica al usuario que realizó el cierre.\n5. Se envía el correo mediante Gmail.\n\nLa notificación permite que el equipo conozca el cambio sin tener que revisar manualmente el portal.\n\n## Respuesta a la aplicación\n\nCuando el proceso termina correctamente:\n\n- Se construye una respuesta de éxito.\n- Se confirma que el reporte fue actualizado.\n- Se devuelve el nuevo estado a la aplicación.\n- El portal puede actualizar inmediatamente su interfaz.\n\n## Ruta de error\n\nCuando la solicitud es inválida:\n\n- No se modifica Supabase.\n- No se envía el correo al equipo de Honduras.\n- Se devuelve una respuesta de error a la aplicación.\n- Se informa que faltan datos o que la solicitud no pudo procesarse.\n\n## Reglas\n\n- No marcar un reporte como resuelto sin un identificador válido.\n- No enviar la notificación antes de confirmar la actualización en Supabase.\n- No declarar éxito cuando la base de datos no fue actualizada.\n- Toda respuesta del Webhook debe cerrarse correctamente para evitar solicitudes abiertas.\n- La ruta de error debe terminar sin realizar cambios parciales.", + "height": 1264, + "width": 2624, + "color": "#28765F" + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 0, + 0 + ], + "id": "4c5b8763-d795-4d97-a01c-5fdf7f31e839", + "name": "Sticky Note" + } + ], + "pinData": {}, + "connections": { + "Webhook Marcar Resuelto": { + "main": [ + [ + { + "node": "Validar resolución", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validar resolución": { + "main": [ + [ + { + "node": "¿Solicitud válida?", + "type": "main", + "index": 0 + } + ] + ] + }, + "¿Solicitud válida?": { + "main": [ + [ + { + "node": "Actualizar reporte Supabase", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Responder error resolución", + "type": "main", + "index": 0 + } + ] + ] + }, + "Actualizar reporte Supabase": { + "main": [ + [ + { + "node": "Preparar correo equipo HN", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar respuesta resuelto": { + "main": [ + [ + { + "node": "Responder resuelto app", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar correo equipo HN": { + "main": [ + [ + { + "node": "Enviar correo equipo HN", + "type": "main", + "index": 0 + } + ] + ] + }, + "Enviar correo equipo HN": { + "main": [ + [ + { + "node": "Preparar respuesta resuelto", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate" + }, + "versionId": "e048ffce-bcd3-4332-88e7-9fbf607a8a38", + "meta": { + "instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70" + }, + "tags": [] +} \ No newline at end of file diff --git a/workflows_n8n/Portal de Verificaci├│n de N├│mina Honduras - Hist├│ricos.json b/workflows_n8n/Portal de Verificaci├│n de N├│mina Honduras - Hist├│ricos.json new file mode 100644 index 0000000..fc45009 --- /dev/null +++ b/workflows_n8n/Portal de Verificaci├│n de N├│mina Honduras - Hist├│ricos.json @@ -0,0 +1,163 @@ +{ + "name": "Portal de Verificación de Nómina Honduras - Históricos", + "nodes": [ + { + "parameters": { + "path": "cruce-cuentas-hn-historicos", + "responseMode": "responseNode", + "options": {} + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + 304, + 272 + ], + "id": "2fe1cba5-882d-4054-afa7-d331d33220aa", + "name": "Webhook Históricos HN", + "webhookId": "48f69298-ea9a-4ff2-a051-1052bbed0c1d" + }, + { + "parameters": { + "jsCode": "const items = $input.all();\n\nfunction formatDate(value) {\n if (!value) return '';\n const date = new Date(value);\n if (Number.isNaN(date.getTime())) return String(value).slice(0, 10);\n return date.toISOString().slice(0, 10);\n}\n\nfunction toNumber(value) {\n const parsed = Number(value);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction normalizeStatus(value, discrepancias) {\n const status = String(value || '').toLowerCase();\n\n if (status === 'resuelto') return 'resuelto';\n if (status === 'procesado' && toNumber(discrepancias) === 0) return 'procesado';\n\n return 'pendiente_revision';\n}\n\nfunction statusLabel(status) {\n if (status === 'resuelto') return 'RESUELTO';\n if (status === 'procesado') return 'PROCESADO';\n return 'PENDIENTE REVISIÓN';\n}\n\nconst reports = items.map((item) => {\n const row = item.json || {};\n\n const discrepancias = toNumber(row.discrepancias);\n const bancoSinNomina = toNumber(row.banco_sin_nomina);\n const nominaSinCuenta = toNumber(row.nomina_sin_cuenta);\n const bancoSinBamboo = toNumber(row.banco_sin_bamboo);\n const nominaSinBamboo = toNumber(row.nomina_sin_bamboo);\n const bambooSinNomina = toNumber(row.bamboo_sin_nomina);\n\n const totalPendientes =\n discrepancias +\n bancoSinNomina +\n nominaSinCuenta +\n bancoSinBamboo +\n nominaSinBamboo +\n bambooSinNomina;\n\n const estado = normalizeStatus(row.estado, totalPendientes);\n\n return {\n id: row.id,\n\n fecha: formatDate(row.period_end || row.ejecutado_en || row.created_at),\n fechaEjecucion: row.ejecutado_en || row.created_at,\n\n periodo: row.period_label || '',\n year: row.year,\n month: row.month,\n periodType: row.period_type,\n periodStart: row.period_start,\n periodEnd: row.period_end,\n\n archivoNomina: row.payroll_file_name || '',\n archivosBanco: row.bank_file_names || [],\n\n coincidencias: toNumber(row.coincidencias),\n discrepancias,\n bancoSinNomina,\n nominaSinCuenta,\n bancoSinBamboo,\n nominaSinBamboo,\n bambooSinNomina,\n totalPendientes,\n\n totalNomina: toNumber(row.total_nomina),\n totalBanco: toNumber(row.total_banco),\n diferenciaTotal: toNumber(row.diferencia_total),\n\n estado,\n estadoLabel: statusLabel(estado),\n\n reportUrl: row.report_url || '',\n spreadsheetId: row.spreadsheet_id || '',\n\n ejecutadoPorNombre: row.ejecutado_por_nombre || '',\n ejecutadoPorEmail: row.ejecutado_por_email || '',\n\n resueltoPorNombre: row.resuelto_por_nombre || '',\n resueltoPorEmail: row.resuelto_por_email || '',\n resueltoEn: row.resuelto_en || null,\n comentarioResolucion: row.comentario_resolucion || '',\n };\n});\n\nreturn [\n {\n json: {\n ok: true,\n stage: 'historicos_hn_listos',\n count: reports.length,\n reports,\n },\n },\n];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 720, + 272 + ], + "id": "dcd21660-393d-4ce2-9092-e61fc909fe0a", + "name": "Normalizar históricos para app" + }, + { + "parameters": { + "url": "https://dbit.digitalcompass.agency/rest/v1/cruces_cuentas_gt_reportes?select=*&country=eq.HN&order=ejecutado_en.desc&limit=100", + "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" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 512, + 272 + ], + "id": "60a88bd9-4050-43d7-a88d-b415f90d1543", + "name": "Consultar históricos Supabase" + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": { + "responseHeaders": { + "entries": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Access-Control-Allow-Origin", + "value": "*" + }, + { + "name": "Access-Control-Allow-Methods", + "value": "GET,POST,OPTIONS" + }, + { + "name": "Access-Control-Allow-Headers", + "value": "Content-Type, Authorization" + } + ] + } + } + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 944, + 272 + ], + "id": "9f35b767-e0f2-40a5-8097-8bc623669b8d", + "name": "Responder históricos app" + }, + { + "parameters": { + "content": "# 📚 HISTÓRICOS DE VERIFICACIÓN — HONDURAS\n\nEntrega a la aplicación el historial de reportes procesados para Honduras.\n\n- Consulta únicamente registros con `country = HN`.\n- Supabase continúa usando la tabla histórica compartida `cruces_cuentas_gt_reportes`; el nombre de la tabla es legado y funciona como tabla multi-país.\n- Devuelve la misma estructura de históricos utilizada por los demás países.\n- Este webhook sirve como respaldo; la app intenta primero la RPC paginada de Supabase.", + "height": 496, + "width": 1360, + "color": 6 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 0, + 0 + ], + "id": "8e6b3835-6bd6-4f64-827e-24744543d76c", + "name": "Sticky Note" + } + ], + "pinData": {}, + "connections": { + "Consultar históricos Supabase": { + "main": [ + [ + { + "node": "Normalizar históricos para app", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalizar históricos para app": { + "main": [ + [ + { + "node": "Responder históricos app", + "type": "main", + "index": 0 + } + ] + ] + }, + "Webhook Históricos HN": { + "main": [ + [ + { + "node": "Consultar históricos Supabase", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate" + }, + "versionId": "c2ddfc50-7d6a-4750-9f9c-1e7e423b6380", + "meta": { + "instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70" + }, + "tags": [] +} \ No newline at end of file