{ "updatedAt": "2026-08-08T13:40:10.794Z", "createdAt": "2026-07-13T18:23:09.602Z", "id": "E9cNPlUjeGnhFqW2", "name": "Portal de Verificación de Nómina - Analizar Nómina con Gemini Guatemala y Trinidad y Tobago", "description": "Analiza con Gemini el nombre o título de los archivos de nómina de Guatemala y Trinidad y Tobago para identificar automáticamente el país, año, mes y tipo de período —quincena 15, quincena 30/fin de mes o mensual—, normaliza el resultado y lo devuelve al Portal de Verificación de Nómina para completar los campos correspondientes, incluyendo validación de la solicitud y manejo de errores.", "active": true, "isArchived": false, "nodes": [ { "parameters": { "httpMethod": "POST", "path": "cruce-cuentas-analizar-nomina", "responseMode": "responseNode", "options": {} }, "type": "n8n-nodes-base.webhook", "typeVersion": 2.1, "position": [ -560, 400 ], "id": "21d31196-75af-48c2-b9a2-6d79a53390a8", "name": "Webhook - Analizar Nómina", "webhookId": "bbf70625-109d-415b-ae60-755c317978ce" }, { "parameters": { "jsCode": "const input = $input.first().json || {};\n\nlet body = input.body ?? input;\n\nif (typeof body === 'string') {\n try {\n body = JSON.parse(body);\n } catch (error) {\n body = {};\n }\n}\n\nif (!body || typeof body !== 'object' || Array.isArray(body)) {\n body = {};\n}\n\nfunction clean(value) {\n return String(value ?? '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nconst fileName = clean(\n body.fileName ??\n body.filename ??\n body.file_name ??\n ''\n);\n\nconst country = clean(\n body.country ?? 'GT'\n).toUpperCase();\n\nconst supportedCountries = {\n GT: 'Guatemala',\n TT: 'Trinidad y Tobago',\n};\n\nconst countryName = clean(\n body.countryName ??\n body.country_name ??\n supportedCountries[country] ??\n ''\n);\n\nconst errors = [];\n\nif (!fileName) {\n errors.push(\n 'No se recibió el nombre del archivo de nómina.'\n );\n}\n\nif (!Object.prototype.hasOwnProperty.call(\n supportedCountries,\n country\n)) {\n errors.push(\n 'El analizador solo está habilitado para Guatemala y Trinidad y Tobago.'\n );\n}\n\nconst supportedExtensions = [\n '.xlsx',\n '.xls',\n '.xlsm',\n '.csv',\n];\n\nconst lowerFileName = fileName.toLowerCase();\n\nconst hasSupportedExtension =\n supportedExtensions.some((extension) =>\n lowerFileName.endsWith(extension)\n );\n\nif (fileName && !hasSupportedExtension) {\n errors.push(\n 'El archivo recibido no tiene una extensión de nómina compatible.'\n );\n}\n\nreturn [\n {\n json: {\n ok: errors.length === 0,\n errors,\n\n fileName,\n country,\n countryName:\n countryName ||\n supportedCountries[country] ||\n '',\n\n receivedAt: new Date().toISOString(),\n },\n },\n];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -352, 400 ], "id": "688ff563-63e0-47f5-a756-0c648d3af355", "name": "Validar y preparar solicitud" }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 3 }, "conditions": [ { "id": "bd9fd675-d72b-4dba-ac03-a44f1123ecb0", "leftValue": "={{ $json.ok }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ -144, 400 ], "id": "6f74ebb8-a476-4b69-bbca-dd298936bf53", "name": "¿Solicitud válida?" }, { "parameters": { "respondWith": "json", "responseBody": "={{\n{\n ok: false,\n message:\n 'No fue posible analizar el nombre del archivo.',\n errors: Array.isArray($json.errors)\n ? $json.errors\n : ['La solicitud recibida no es válida.'],\n detected: null\n}\n}}", "options": { "responseCode": 400, "responseHeaders": { "entries": [ { "name": "Content-Type", "value": "application/json" } ] } } }, "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.5, "position": [ 64, 496 ], "id": "c42f5bbb-858f-4bb6-a1d7-267679f4909b", "name": "Responder error" }, { "parameters": { "text": "={{\n`Analiza este nombre de archivo de nómina:\n\nPaís seleccionado: ${$json.countryName}\nCódigo del país: ${$json.country}\nNombre del archivo: ${$json.fileName}`\n}}", "schemaType": "manual", "inputSchema": "{\n \"type\": \"object\",\n \"properties\": {\n \"country\": {\n \"type\": \"string\",\n \"enum\": [\n \"GT\",\n \"TT\"\n ]\n },\n \"year\": {\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 2100\n },\n \"month\": {\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 12\n },\n \"period_type\": {\n \"type\": \"string\",\n \"enum\": [\n \"quincena_15\",\n \"quincena_30\",\n \"mensual\",\n \"desconocido\"\n ]\n },\n \"confidence\": {\n \"type\": \"number\",\n \"minimum\": 0,\n \"maximum\": 1\n },\n \"explanation\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"country\",\n \"year\",\n \"month\",\n \"period_type\",\n \"confidence\",\n \"explanation\"\n ],\n \"additionalProperties\": false\n}", "options": { "systemPromptTemplate": "Eres un clasificador determinista de nombres de archivos de nómina.\n\nTu tarea es identificar el país seleccionado, el año, el mes y el período de pago utilizando únicamente la información presente en el nombre del archivo y el país recibido.\n\nNo inventes datos que no estén contenidos o razonablemente codificados en el nombre.\n\nREGLAS GENERALES:\n\n1. Devuelve exactamente el código de país recibido:\n - GT para Guatemala.\n - TT para Trinidad y Tobago.\n\n2. Detecta el año de cuatro dígitos. También puede aparecer dentro de una fecha compacta como YYYYMMDD; por ejemplo, 20260617 representa el año 2026.\n\n3. Convierte el mes a un número aceptando nombres en español o inglés:\n Enero / January = 1\n Febrero / February = 2\n Marzo / March = 3\n Abril / April = 4\n Mayo / May = 5\n Junio / June = 6\n Julio / July = 7\n Agosto / August = 8\n Septiembre / September = 9\n Octubre / October = 10\n Noviembre / November = 11\n Diciembre / December = 12\n\n4. Usa period_type = quincena_15 cuando aparezca cualquiera de estas señales:\n - 1Q\n - Q1\n - Primera quincena\n - 1ra quincena\n - First fortnight\n - First half\n - Mid month\n - Día 15\n - 15th\n\n5. Usa period_type = quincena_30 cuando aparezca cualquiera de estas señales:\n - 2Q\n - Q2\n - Segunda quincena\n - 2da quincena\n - Second fortnight\n - Second half\n - Fin de mes\n - End of month\n - Month end\n - Día 30\n - Día 31\n - 30th\n - 31st\n - El último día real del mes, incluyendo 28 o 29 de febrero\n\n6. Aunque el último día real del mes no sea 30, utiliza quincena_30 para representar la segunda quincena o cierre de mes.\n\n7. Si solamente aparecen el mes y el año, sin ninguna señal de quincena ni día de cierre, utiliza mensual.\n\n8. Para nombres como \"GLM_TT_Payroll June 30TH_20260617.xlsx\":\n - country = TT\n - year = 2026\n - month = 6\n - period_type = quincena_30\n\n9. Si no puedes identificar de manera confiable el año o el mes, utiliza:\n year = 0\n month = 0\n period_type = desconocido\n\n10. confidence debe estar entre 0 y 1.\n\n11. explanation debe ser breve y mencionar las señales encontradas en el nombre.\n\nNo agregues campos diferentes a los definidos por el esquema." } }, "type": "@n8n/n8n-nodes-langchain.informationExtractor", "typeVersion": 1.2, "position": [ 64, 128 ], "id": "efe8a811-b868-4efb-8348-4bd0b378df4b", "name": "Extraer período con Gemini", "retryOnFail": true, "waitBetweenTries": 2000, "onError": "continueRegularOutput" }, { "parameters": { "modelName": "models/gemini-2.5-pro", "options": {} }, "type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini", "typeVersion": 1.1, "position": [ 64, 304 ], "id": "38ff566c-2b41-40aa-873a-65ff7eee4668", "name": "Gemini - Analizar título nómina", "credentials": { "googlePalmApi": { "id": "jvsXYwL6IOoY2DBU", "name": "Isaac - Gemini Api Pago" } } }, { "parameters": { "jsCode": "const request =\n $('Validar y preparar solicitud').first().json || {};\n\nconst incoming = $input.first().json || {};\n\nfunction cleanText(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeText(value) {\n return cleanText(value)\n .toUpperCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[_\\-.,()[\\]{}]+/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction parseObject(value) {\n if (\n value &&\n typeof value === 'object' &&\n !Array.isArray(value)\n ) {\n return value;\n }\n\n if (typeof value !== 'string') {\n return {};\n }\n\n const cleaned = value\n .replace(/```json/gi, '')\n .replace(/```/g, '')\n .trim();\n\n try {\n return JSON.parse(cleaned);\n } catch (error) {\n return {};\n }\n}\n\nfunction clamp(value, minimum, maximum) {\n const number = Number(value);\n\n if (!Number.isFinite(number)) {\n return minimum;\n }\n\n return Math.min(\n maximum,\n Math.max(minimum, number)\n );\n}\n\nfunction pad(number) {\n return String(number).padStart(2, '0');\n}\n\nfunction getLastDay(year, month) {\n return new Date(\n Date.UTC(year, month, 0)\n ).getUTCDate();\n}\n\nfunction buildPeriodDates(\n year,\n month,\n periodType\n) {\n if (\n !Number.isInteger(year) ||\n year < 2000 ||\n !Number.isInteger(month) ||\n month < 1 ||\n month > 12\n ) {\n return {\n periodStart: null,\n periodEnd: null,\n };\n }\n\n const lastDay = getLastDay(year, month);\n const yearMonth = `${year}-${pad(month)}`;\n\n if (periodType === 'quincena_15') {\n return {\n periodStart: `${yearMonth}-01`,\n periodEnd: `${yearMonth}-15`,\n };\n }\n\n if (periodType === 'quincena_30') {\n return {\n periodStart: `${yearMonth}-16`,\n periodEnd:\n `${yearMonth}-${pad(lastDay)}`,\n };\n }\n\n if (periodType === 'mensual') {\n return {\n periodStart: `${yearMonth}-01`,\n periodEnd:\n `${yearMonth}-${pad(lastDay)}`,\n };\n }\n\n return {\n periodStart: null,\n periodEnd: null,\n };\n}\n\nconst countryNames = {\n GT: 'Guatemala',\n TT: 'Trinidad y Tobago',\n};\n\nconst country = ['GT', 'TT'].includes(\n String(request.country || '').toUpperCase()\n)\n ? String(request.country).toUpperCase()\n : 'GT';\n\nconst countryName =\n request.countryName ||\n countryNames[country];\n\nconst monthNames = {\n 1: 'Enero',\n 2: 'Febrero',\n 3: 'Marzo',\n 4: 'Abril',\n 5: 'Mayo',\n 6: 'Junio',\n 7: 'Julio',\n 8: 'Agosto',\n 9: 'Septiembre',\n 10: 'Octubre',\n 11: 'Noviembre',\n 12: 'Diciembre',\n};\n\nconst monthPatterns = [\n ['ENERO', 1],\n ['JANUARY', 1],\n ['JAN', 1],\n\n ['FEBRERO', 2],\n ['FEBRUARY', 2],\n ['FEB', 2],\n\n ['MARZO', 3],\n ['MARCH', 3],\n ['MAR', 3],\n\n ['ABRIL', 4],\n ['APRIL', 4],\n ['APR', 4],\n\n ['MAYO', 5],\n ['MAY', 5],\n\n ['JUNIO', 6],\n ['JUNE', 6],\n ['JUN', 6],\n\n ['JULIO', 7],\n ['JULY', 7],\n ['JUL', 7],\n\n ['AGOSTO', 8],\n ['AUGUST', 8],\n ['AUG', 8],\n\n ['SEPTIEMBRE', 9],\n ['SETIEMBRE', 9],\n ['SEPTEMBER', 9],\n ['SEPT', 9],\n ['SEP', 9],\n\n ['OCTUBRE', 10],\n ['OCTOBER', 10],\n ['OCT', 10],\n\n ['NOVIEMBRE', 11],\n ['NOVEMBER', 11],\n ['NOV', 11],\n\n ['DICIEMBRE', 12],\n ['DECEMBER', 12],\n ['DEC', 12],\n];\n\nconst monthPatternText =\n monthPatterns\n .map(([name]) => name)\n .sort((a, b) => b.length - a.length)\n .join('|');\n\nconst validPeriodTypes = new Set([\n 'quincena_15',\n 'quincena_30',\n 'mensual',\n 'desconocido',\n]);\n\nconst fileName = cleanText(request.fileName);\nconst normalizedFileName =\n normalizeText(fileName);\n\nconst compactDateMatch =\n normalizedFileName.match(\n /\\b(20\\d{2})(0[1-9]|1[0-2])([0-2]\\d|3[01])\\b/\n );\n\nconst standaloneYearMatch =\n normalizedFileName.match(/\\b(20\\d{2})\\b/);\n\nconst fallbackYear = standaloneYearMatch\n ? Number(standaloneYearMatch[1])\n : compactDateMatch\n ? Number(compactDateMatch[1])\n : 0;\n\nlet fallbackMonth = 0;\n\nfor (const [monthName, monthNumber] of monthPatterns) {\n const monthRegex = new RegExp(\n `\\\\b${monthName}\\\\b`\n );\n\n if (monthRegex.test(normalizedFileName)) {\n fallbackMonth = monthNumber;\n break;\n }\n}\n\nif (!fallbackMonth && compactDateMatch) {\n fallbackMonth = Number(compactDateMatch[2]);\n}\n\nconst monthThenDayRegex = new RegExp(\n `\\\\b(?:${monthPatternText})\\\\s+([0-3]?\\\\d)(?:ST|ND|RD|TH)?\\\\b`\n);\n\nconst dayThenMonthRegex = new RegExp(\n `\\\\b([0-3]?\\\\d)(?:ST|ND|RD|TH)?\\\\s+(?:DE\\\\s+)?(?:${monthPatternText})\\\\b`\n);\n\nconst explicitDayRegex =\n /\\b(?:DIA|DAY)\\s+([0-3]?\\d)(?:ST|ND|RD|TH)?\\b/;\n\nconst monthThenDayMatch =\n normalizedFileName.match(monthThenDayRegex);\n\nconst dayThenMonthMatch =\n normalizedFileName.match(dayThenMonthRegex);\n\nconst explicitDayMatch =\n normalizedFileName.match(explicitDayRegex);\n\nconst detectedDay = monthThenDayMatch\n ? Number(monthThenDayMatch[1])\n : dayThenMonthMatch\n ? Number(dayThenMonthMatch[1])\n : explicitDayMatch\n ? Number(explicitDayMatch[1])\n : 0;\n\nconst firstFortnight =\n /\\b(1Q|Q1)\\b/.test(normalizedFileName) ||\n normalizedFileName.includes(\n 'PRIMERA QUINCENA'\n ) ||\n normalizedFileName.includes(\n '1RA QUINCENA'\n ) ||\n normalizedFileName.includes(\n 'FIRST FORTNIGHT'\n ) ||\n normalizedFileName.includes(\n 'FIRST HALF'\n ) ||\n normalizedFileName.includes(\n 'MID MONTH'\n ) ||\n normalizedFileName.includes(\n 'MIDMONTH'\n );\n\nconst secondFortnight =\n /\\b(2Q|Q2)\\b/.test(normalizedFileName) ||\n normalizedFileName.includes(\n 'SEGUNDA QUINCENA'\n ) ||\n normalizedFileName.includes(\n '2DA QUINCENA'\n ) ||\n normalizedFileName.includes(\n 'SECOND FORTNIGHT'\n ) ||\n normalizedFileName.includes(\n 'SECOND HALF'\n ) ||\n normalizedFileName.includes(\n 'FIN DE MES'\n ) ||\n normalizedFileName.includes(\n 'END OF MONTH'\n ) ||\n normalizedFileName.includes(\n 'MONTH END'\n ) ||\n normalizedFileName.includes(\n 'MONTHEND'\n );\n\nlet fallbackPeriodType = 'desconocido';\nlet fallbackConfidence = 0.2;\nlet strongPeriodEvidence = false;\n\nif (firstFortnight) {\n fallbackPeriodType = 'quincena_15';\n fallbackConfidence = 0.98;\n strongPeriodEvidence = true;\n} else if (secondFortnight) {\n fallbackPeriodType = 'quincena_30';\n fallbackConfidence = 0.98;\n strongPeriodEvidence = true;\n} else if (detectedDay === 15) {\n fallbackPeriodType = 'quincena_15';\n fallbackConfidence = 0.95;\n strongPeriodEvidence = true;\n} else if (\n fallbackYear &&\n fallbackMonth &&\n detectedDay === getLastDay(\n fallbackYear,\n fallbackMonth\n )\n) {\n fallbackPeriodType = 'quincena_30';\n fallbackConfidence = 0.95;\n strongPeriodEvidence = true;\n} else if (\n detectedDay === 30 ||\n detectedDay === 31\n) {\n fallbackPeriodType = 'quincena_30';\n fallbackConfidence = 0.94;\n strongPeriodEvidence = true;\n} else if (\n fallbackYear &&\n fallbackMonth\n) {\n fallbackPeriodType = 'mensual';\n fallbackConfidence = 0.75;\n}\n\nlet rawAI =\n incoming.output ??\n incoming.result ??\n incoming.text ??\n incoming;\n\nrawAI = parseObject(rawAI);\n\nconst aiCountry =\n String(rawAI.country || '').toUpperCase();\n\nconst aiYear = Number(rawAI.year);\nconst aiMonth = Number(rawAI.month);\n\nconst aiPeriodType =\n validPeriodTypes.has(rawAI.period_type)\n ? rawAI.period_type\n : 'desconocido';\n\nconst aiConfidence = clamp(\n rawAI.confidence,\n 0,\n 1\n);\n\nconst aiIsUsable =\n Number.isInteger(aiYear) &&\n aiYear >= 2000 &&\n aiYear <= 2100 &&\n Number.isInteger(aiMonth) &&\n aiMonth >= 1 &&\n aiMonth <= 12 &&\n aiPeriodType !== 'desconocido';\n\nlet year =\n fallbackYear ||\n (aiIsUsable ? aiYear : 0);\n\nlet month =\n fallbackMonth ||\n (aiIsUsable ? aiMonth : 0);\n\nlet periodType =\n aiIsUsable\n ? aiPeriodType\n : fallbackPeriodType;\n\nlet source =\n aiIsUsable\n ? 'gemini'\n : 'local_fallback';\n\nlet confidence =\n aiIsUsable\n ? aiConfidence\n : fallbackConfidence;\n\nlet conflictResolved = false;\n\nif (\n aiCountry &&\n aiCountry !== country\n) {\n source = 'gemini_validated_with_rules';\n conflictResolved = true;\n}\n\nif (\n strongPeriodEvidence &&\n periodType !== fallbackPeriodType\n) {\n periodType = fallbackPeriodType;\n source = 'gemini_validated_with_rules';\n confidence = Math.max(\n fallbackConfidence,\n Math.min(aiConfidence, 0.9)\n );\n conflictResolved = true;\n}\n\nif (\n fallbackYear &&\n aiIsUsable &&\n aiYear !== fallbackYear\n) {\n year = fallbackYear;\n source = 'gemini_validated_with_rules';\n conflictResolved = true;\n}\n\nif (\n fallbackMonth &&\n aiIsUsable &&\n aiMonth !== fallbackMonth\n) {\n month = fallbackMonth;\n source = 'gemini_validated_with_rules';\n conflictResolved = true;\n}\n\nif (\n !aiIsUsable &&\n fallbackYear &&\n fallbackMonth\n) {\n year = fallbackYear;\n month = fallbackMonth;\n periodType = fallbackPeriodType;\n confidence = fallbackConfidence;\n source = 'local_fallback';\n}\n\nconst complete =\n year >= 2000 &&\n month >= 1 &&\n month <= 12 &&\n periodType !== 'desconocido';\n\nconst dates = buildPeriodDates(\n year,\n month,\n periodType\n);\n\nlet periodDescription = 'Período desconocido';\n\nif (periodType === 'quincena_15') {\n periodDescription = 'Quincena 15';\n}\n\nif (periodType === 'quincena_30') {\n periodDescription =\n 'Quincena 30 / fin de mes';\n}\n\nif (periodType === 'mensual') {\n periodDescription = 'Mensual';\n}\n\nconst monthLabel =\n monthNames[month] || 'Mes desconocido';\n\nconst periodLabel = complete\n ? `${monthLabel} ${year} · ${periodDescription}`\n : 'Período pendiente de confirmar';\n\nconst requiresManualReview =\n !complete ||\n confidence < 0.75;\n\nconst explanation =\n cleanText(rawAI.explanation) ||\n (\n source === 'local_fallback'\n ? 'El período fue identificado mediante las reglas locales de respaldo.'\n : 'El período fue identificado mediante Gemini y validado con las reglas del portal.'\n );\n\nreturn [\n {\n json: {\n ok: true,\n\n message: requiresManualReview\n ? 'El archivo fue analizado, pero el período debe confirmarse manualmente.'\n : 'El período de la nómina fue detectado correctamente.',\n\n detected: {\n country,\n countryName,\n\n fileName,\n\n year,\n month,\n\n periodType,\n period_type: periodType,\n\n periodLabel,\n period_label: periodLabel,\n\n periodStart: dates.periodStart,\n period_start: dates.periodStart,\n\n periodEnd: dates.periodEnd,\n period_end: dates.periodEnd,\n\n confidence:\n Math.round(confidence * 100) / 100,\n\n complete,\n requiresManualReview,\n requires_manual_review:\n requiresManualReview,\n\n source,\n conflictResolved,\n explanation,\n },\n\n debug: {\n fallback: {\n country,\n year: fallbackYear,\n month: fallbackMonth,\n periodType: fallbackPeriodType,\n detectedDay,\n confidence: fallbackConfidence,\n strongPeriodEvidence,\n compactDate:\n compactDateMatch?.[0] || null,\n },\n\n gemini: {\n country: aiCountry || null,\n year: aiYear || 0,\n month: aiMonth || 0,\n periodType: aiPeriodType,\n confidence: aiConfidence,\n explanation:\n cleanText(rawAI.explanation),\n },\n },\n },\n },\n];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 400, 144 ], "id": "3d608f18-2ed8-47a3-914c-c2494dfea8f7", "name": "Normalizar análisis IA" }, { "parameters": { "options": { "responseCode": 200, "responseHeaders": { "entries": [ { "name": "Content-Type", "value": "application/json" } ] } } }, "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.5, "position": [ 608, 144 ], "id": "b0843a40-9e7d-400f-b265-d563b789786f", "name": "Responder análisis" }, { "parameters": { "content": "# 🧠 ANÁLISIS AUTOMÁTICO DE NÓMINA — GT Y TT\n\nAnaliza el nombre o título del archivo de nómina para identificar automáticamente el período correspondiente antes de ejecutar la verificación.\n\n## Recepción y validación\n\nEl Webhook recibe desde el Portal de Verificación de Nóminas la información del archivo seleccionado.\n\nLa solicitud puede incluir:\n\n- Nombre del archivo.\n- Título o descripción disponible.\n- País o portal de origen.\n- Identificador de la solicitud.\n- Información complementaria enviada por la aplicación.\n\nAntes de utilizar inteligencia artificial, el flujo valida que exista suficiente información para analizar el archivo.\n\n## Extracción con Gemini\n\nCuando la solicitud es válida, Gemini interpreta el nombre o título de la nómina e intenta determinar:\n\n- Año.\n- Mes.\n- Tipo de período.\n- Quincena 15.\n- Quincena 30 o fin de mes.\n- Nómina mensual.\n- País, cuando puede inferirse de forma confiable.\n- Nivel de confianza del análisis.\n\nGemini se utiliza únicamente para interpretar el texto recibido; no ejecuta el cruce de nómina ni modifica información.\n\n## Normalización del resultado\n\nLa respuesta de Gemini se transforma al formato requerido por la aplicación.\n\nEste bloque:\n\n- Limpia texto adicional generado por la IA.\n- Normaliza el año a formato numérico.\n- Convierte el mes al valor esperado por el portal.\n- Estandariza el tipo de período.\n- Corrige variaciones como “segunda quincena”, “fin de mes” o “30”.\n- Conserva los valores originales cuando la IA no puede determinar un dato.\n- Prepara una respuesta consistente para Guatemala y Trinidad y Tobago.\n\n## Respuesta al portal\n\nCuando el análisis termina, el workflow devuelve a la aplicación:\n\n- Indicador de éxito.\n- Año detectado.\n- Mes detectado.\n- Tipo de período detectado.\n- País identificado, cuando corresponda.\n- Nivel de confianza.\n- Datos que requieren revisión manual.\n\nLos valores detectados sirven para completar automáticamente el formulario, pero el usuario puede corregirlos antes de ejecutar el proceso.\n\n## Ruta de error\n\nCuando la solicitud es inválida:\n\n- No se llama a Gemini.\n- No se intenta interpretar información incompleta.\n- Se devuelve una respuesta de error al portal.\n- Se indica que falta el nombre del archivo u otro dato obligatorio.\n\n## Reglas\n\n- No inventar un período cuando el título no contiene información suficiente.\n- No ejecutar el cruce de nómina desde este workflow.\n- No tratar una predicción de baja confianza como un dato definitivo.\n- Mantener la opción de corrección manual en la aplicación.\n- Responder siempre al Webhook, tanto en la ruta válida como en la ruta de error.\n- Utilizar una estructura de respuesta compatible con los portales de Guatemala y Trinidad y Tobago.", "height": 1488, "width": 2256, "color": 2 }, "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ -1424, -416 ], "id": "27e40ac1-a1eb-4ce5-ad0f-919b81b39dfb", "name": "Sticky Note" } ], "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 } ] ] } }, "settings": { "executionOrder": "v1", "binaryMode": "separate", "availableInMCP": true, "timeSavedMode": "fixed", "errorWorkflow": "puF4LUczoSz3hcek", "timezone": "America/Santo_Domingo", "callerPolicy": "workflowsFromSameOwner" }, "staticData": null, "meta": { "templateCredsSetupCompleted": true }, "versionId": "1aba63da-c451-45a3-ab57-a88ac4bd4ea6", "activeVersionId": "1aba63da-c451-45a3-ab57-a88ac4bd4ea6", "versionCounter": 103, "triggerCount": 1, "shared": [ { "updatedAt": "2026-07-13T18:23:09.611Z", "createdAt": "2026-07-13T18:23:09.611Z", "role": "workflow:owner", "workflowId": "E9cNPlUjeGnhFqW2", "projectId": "PJpTANzTXIFibWsW", "project": { "updatedAt": "2026-04-22T14:25:09.686Z", "createdAt": "2026-04-22T14:22:54.790Z", "id": "PJpTANzTXIFibWsW", "name": "Isaac Aracena ", "type": "personal", "icon": null, "description": null, "creatorId": "0a88c0b1-928e-4412-896e-c5d1c99b2029" } } ], "tags": [], "activeVersion": { "updatedAt": "2026-07-21T21:08:40.000Z", "createdAt": "2026-07-21T21:08:34.343Z", "versionId": "1aba63da-c451-45a3-ab57-a88ac4bd4ea6", "workflowId": "E9cNPlUjeGnhFqW2", "nodes": [ { "parameters": { "httpMethod": "POST", "path": "cruce-cuentas-analizar-nomina", "responseMode": "responseNode", "options": {} }, "type": "n8n-nodes-base.webhook", "typeVersion": 2.1, "position": [ -560, 400 ], "id": "21d31196-75af-48c2-b9a2-6d79a53390a8", "name": "Webhook - Analizar Nómina", "webhookId": "bbf70625-109d-415b-ae60-755c317978ce" }, { "parameters": { "jsCode": "const input = $input.first().json || {};\n\nlet body = input.body ?? input;\n\nif (typeof body === 'string') {\n try {\n body = JSON.parse(body);\n } catch (error) {\n body = {};\n }\n}\n\nif (!body || typeof body !== 'object' || Array.isArray(body)) {\n body = {};\n}\n\nfunction clean(value) {\n return String(value ?? '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nconst fileName = clean(\n body.fileName ??\n body.filename ??\n body.file_name ??\n ''\n);\n\nconst country = clean(\n body.country ?? 'GT'\n).toUpperCase();\n\nconst supportedCountries = {\n GT: 'Guatemala',\n TT: 'Trinidad y Tobago',\n};\n\nconst countryName = clean(\n body.countryName ??\n body.country_name ??\n supportedCountries[country] ??\n ''\n);\n\nconst errors = [];\n\nif (!fileName) {\n errors.push(\n 'No se recibió el nombre del archivo de nómina.'\n );\n}\n\nif (!Object.prototype.hasOwnProperty.call(\n supportedCountries,\n country\n)) {\n errors.push(\n 'El analizador solo está habilitado para Guatemala y Trinidad y Tobago.'\n );\n}\n\nconst supportedExtensions = [\n '.xlsx',\n '.xls',\n '.xlsm',\n '.csv',\n];\n\nconst lowerFileName = fileName.toLowerCase();\n\nconst hasSupportedExtension =\n supportedExtensions.some((extension) =>\n lowerFileName.endsWith(extension)\n );\n\nif (fileName && !hasSupportedExtension) {\n errors.push(\n 'El archivo recibido no tiene una extensión de nómina compatible.'\n );\n}\n\nreturn [\n {\n json: {\n ok: errors.length === 0,\n errors,\n\n fileName,\n country,\n countryName:\n countryName ||\n supportedCountries[country] ||\n '',\n\n receivedAt: new Date().toISOString(),\n },\n },\n];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -352, 400 ], "id": "688ff563-63e0-47f5-a756-0c648d3af355", "name": "Validar y preparar solicitud" }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 3 }, "conditions": [ { "id": "bd9fd675-d72b-4dba-ac03-a44f1123ecb0", "leftValue": "={{ $json.ok }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ -144, 400 ], "id": "6f74ebb8-a476-4b69-bbca-dd298936bf53", "name": "¿Solicitud válida?" }, { "parameters": { "respondWith": "json", "responseBody": "={{\n{\n ok: false,\n message:\n 'No fue posible analizar el nombre del archivo.',\n errors: Array.isArray($json.errors)\n ? $json.errors\n : ['La solicitud recibida no es válida.'],\n detected: null\n}\n}}", "options": { "responseCode": 400, "responseHeaders": { "entries": [ { "name": "Content-Type", "value": "application/json" } ] } } }, "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.5, "position": [ 64, 496 ], "id": "c42f5bbb-858f-4bb6-a1d7-267679f4909b", "name": "Responder error" }, { "parameters": { "text": "={{\n`Analiza este nombre de archivo de nómina:\n\nPaís seleccionado: ${$json.countryName}\nCódigo del país: ${$json.country}\nNombre del archivo: ${$json.fileName}`\n}}", "schemaType": "manual", "inputSchema": "{\n \"type\": \"object\",\n \"properties\": {\n \"country\": {\n \"type\": \"string\",\n \"enum\": [\n \"GT\",\n \"TT\"\n ]\n },\n \"year\": {\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 2100\n },\n \"month\": {\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 12\n },\n \"period_type\": {\n \"type\": \"string\",\n \"enum\": [\n \"quincena_15\",\n \"quincena_30\",\n \"mensual\",\n \"desconocido\"\n ]\n },\n \"confidence\": {\n \"type\": \"number\",\n \"minimum\": 0,\n \"maximum\": 1\n },\n \"explanation\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"country\",\n \"year\",\n \"month\",\n \"period_type\",\n \"confidence\",\n \"explanation\"\n ],\n \"additionalProperties\": false\n}", "options": { "systemPromptTemplate": "Eres un clasificador determinista de nombres de archivos de nómina.\n\nTu tarea es identificar el país seleccionado, el año, el mes y el período de pago utilizando únicamente la información presente en el nombre del archivo y el país recibido.\n\nNo inventes datos que no estén contenidos o razonablemente codificados en el nombre.\n\nREGLAS GENERALES:\n\n1. Devuelve exactamente el código de país recibido:\n - GT para Guatemala.\n - TT para Trinidad y Tobago.\n\n2. Detecta el año de cuatro dígitos. También puede aparecer dentro de una fecha compacta como YYYYMMDD; por ejemplo, 20260617 representa el año 2026.\n\n3. Convierte el mes a un número aceptando nombres en español o inglés:\n Enero / January = 1\n Febrero / February = 2\n Marzo / March = 3\n Abril / April = 4\n Mayo / May = 5\n Junio / June = 6\n Julio / July = 7\n Agosto / August = 8\n Septiembre / September = 9\n Octubre / October = 10\n Noviembre / November = 11\n Diciembre / December = 12\n\n4. Usa period_type = quincena_15 cuando aparezca cualquiera de estas señales:\n - 1Q\n - Q1\n - Primera quincena\n - 1ra quincena\n - First fortnight\n - First half\n - Mid month\n - Día 15\n - 15th\n\n5. Usa period_type = quincena_30 cuando aparezca cualquiera de estas señales:\n - 2Q\n - Q2\n - Segunda quincena\n - 2da quincena\n - Second fortnight\n - Second half\n - Fin de mes\n - End of month\n - Month end\n - Día 30\n - Día 31\n - 30th\n - 31st\n - El último día real del mes, incluyendo 28 o 29 de febrero\n\n6. Aunque el último día real del mes no sea 30, utiliza quincena_30 para representar la segunda quincena o cierre de mes.\n\n7. Si solamente aparecen el mes y el año, sin ninguna señal de quincena ni día de cierre, utiliza mensual.\n\n8. Para nombres como \"GLM_TT_Payroll June 30TH_20260617.xlsx\":\n - country = TT\n - year = 2026\n - month = 6\n - period_type = quincena_30\n\n9. Si no puedes identificar de manera confiable el año o el mes, utiliza:\n year = 0\n month = 0\n period_type = desconocido\n\n10. confidence debe estar entre 0 y 1.\n\n11. explanation debe ser breve y mencionar las señales encontradas en el nombre.\n\nNo agregues campos diferentes a los definidos por el esquema." } }, "type": "@n8n/n8n-nodes-langchain.informationExtractor", "typeVersion": 1.2, "position": [ 64, 128 ], "id": "efe8a811-b868-4efb-8348-4bd0b378df4b", "name": "Extraer período con Gemini", "retryOnFail": true, "waitBetweenTries": 2000, "onError": "continueRegularOutput" }, { "parameters": { "modelName": "models/gemini-2.5-pro", "options": {} }, "type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini", "typeVersion": 1.1, "position": [ 64, 304 ], "id": "38ff566c-2b41-40aa-873a-65ff7eee4668", "name": "Gemini - Analizar título nómina", "credentials": { "googlePalmApi": { "id": "jvsXYwL6IOoY2DBU", "name": "Isaac - Gemini Api Pago" } } }, { "parameters": { "jsCode": "const request =\n $('Validar y preparar solicitud').first().json || {};\n\nconst incoming = $input.first().json || {};\n\nfunction cleanText(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeText(value) {\n return cleanText(value)\n .toUpperCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[_\\-.,()[\\]{}]+/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction parseObject(value) {\n if (\n value &&\n typeof value === 'object' &&\n !Array.isArray(value)\n ) {\n return value;\n }\n\n if (typeof value !== 'string') {\n return {};\n }\n\n const cleaned = value\n .replace(/```json/gi, '')\n .replace(/```/g, '')\n .trim();\n\n try {\n return JSON.parse(cleaned);\n } catch (error) {\n return {};\n }\n}\n\nfunction clamp(value, minimum, maximum) {\n const number = Number(value);\n\n if (!Number.isFinite(number)) {\n return minimum;\n }\n\n return Math.min(\n maximum,\n Math.max(minimum, number)\n );\n}\n\nfunction pad(number) {\n return String(number).padStart(2, '0');\n}\n\nfunction getLastDay(year, month) {\n return new Date(\n Date.UTC(year, month, 0)\n ).getUTCDate();\n}\n\nfunction buildPeriodDates(\n year,\n month,\n periodType\n) {\n if (\n !Number.isInteger(year) ||\n year < 2000 ||\n !Number.isInteger(month) ||\n month < 1 ||\n month > 12\n ) {\n return {\n periodStart: null,\n periodEnd: null,\n };\n }\n\n const lastDay = getLastDay(year, month);\n const yearMonth = `${year}-${pad(month)}`;\n\n if (periodType === 'quincena_15') {\n return {\n periodStart: `${yearMonth}-01`,\n periodEnd: `${yearMonth}-15`,\n };\n }\n\n if (periodType === 'quincena_30') {\n return {\n periodStart: `${yearMonth}-16`,\n periodEnd:\n `${yearMonth}-${pad(lastDay)}`,\n };\n }\n\n if (periodType === 'mensual') {\n return {\n periodStart: `${yearMonth}-01`,\n periodEnd:\n `${yearMonth}-${pad(lastDay)}`,\n };\n }\n\n return {\n periodStart: null,\n periodEnd: null,\n };\n}\n\nconst countryNames = {\n GT: 'Guatemala',\n TT: 'Trinidad y Tobago',\n};\n\nconst country = ['GT', 'TT'].includes(\n String(request.country || '').toUpperCase()\n)\n ? String(request.country).toUpperCase()\n : 'GT';\n\nconst countryName =\n request.countryName ||\n countryNames[country];\n\nconst monthNames = {\n 1: 'Enero',\n 2: 'Febrero',\n 3: 'Marzo',\n 4: 'Abril',\n 5: 'Mayo',\n 6: 'Junio',\n 7: 'Julio',\n 8: 'Agosto',\n 9: 'Septiembre',\n 10: 'Octubre',\n 11: 'Noviembre',\n 12: 'Diciembre',\n};\n\nconst monthPatterns = [\n ['ENERO', 1],\n ['JANUARY', 1],\n ['JAN', 1],\n\n ['FEBRERO', 2],\n ['FEBRUARY', 2],\n ['FEB', 2],\n\n ['MARZO', 3],\n ['MARCH', 3],\n ['MAR', 3],\n\n ['ABRIL', 4],\n ['APRIL', 4],\n ['APR', 4],\n\n ['MAYO', 5],\n ['MAY', 5],\n\n ['JUNIO', 6],\n ['JUNE', 6],\n ['JUN', 6],\n\n ['JULIO', 7],\n ['JULY', 7],\n ['JUL', 7],\n\n ['AGOSTO', 8],\n ['AUGUST', 8],\n ['AUG', 8],\n\n ['SEPTIEMBRE', 9],\n ['SETIEMBRE', 9],\n ['SEPTEMBER', 9],\n ['SEPT', 9],\n ['SEP', 9],\n\n ['OCTUBRE', 10],\n ['OCTOBER', 10],\n ['OCT', 10],\n\n ['NOVIEMBRE', 11],\n ['NOVEMBER', 11],\n ['NOV', 11],\n\n ['DICIEMBRE', 12],\n ['DECEMBER', 12],\n ['DEC', 12],\n];\n\nconst monthPatternText =\n monthPatterns\n .map(([name]) => name)\n .sort((a, b) => b.length - a.length)\n .join('|');\n\nconst validPeriodTypes = new Set([\n 'quincena_15',\n 'quincena_30',\n 'mensual',\n 'desconocido',\n]);\n\nconst fileName = cleanText(request.fileName);\nconst normalizedFileName =\n normalizeText(fileName);\n\nconst compactDateMatch =\n normalizedFileName.match(\n /\\b(20\\d{2})(0[1-9]|1[0-2])([0-2]\\d|3[01])\\b/\n );\n\nconst standaloneYearMatch =\n normalizedFileName.match(/\\b(20\\d{2})\\b/);\n\nconst fallbackYear = standaloneYearMatch\n ? Number(standaloneYearMatch[1])\n : compactDateMatch\n ? Number(compactDateMatch[1])\n : 0;\n\nlet fallbackMonth = 0;\n\nfor (const [monthName, monthNumber] of monthPatterns) {\n const monthRegex = new RegExp(\n `\\\\b${monthName}\\\\b`\n );\n\n if (monthRegex.test(normalizedFileName)) {\n fallbackMonth = monthNumber;\n break;\n }\n}\n\nif (!fallbackMonth && compactDateMatch) {\n fallbackMonth = Number(compactDateMatch[2]);\n}\n\nconst monthThenDayRegex = new RegExp(\n `\\\\b(?:${monthPatternText})\\\\s+([0-3]?\\\\d)(?:ST|ND|RD|TH)?\\\\b`\n);\n\nconst dayThenMonthRegex = new RegExp(\n `\\\\b([0-3]?\\\\d)(?:ST|ND|RD|TH)?\\\\s+(?:DE\\\\s+)?(?:${monthPatternText})\\\\b`\n);\n\nconst explicitDayRegex =\n /\\b(?:DIA|DAY)\\s+([0-3]?\\d)(?:ST|ND|RD|TH)?\\b/;\n\nconst monthThenDayMatch =\n normalizedFileName.match(monthThenDayRegex);\n\nconst dayThenMonthMatch =\n normalizedFileName.match(dayThenMonthRegex);\n\nconst explicitDayMatch =\n normalizedFileName.match(explicitDayRegex);\n\nconst detectedDay = monthThenDayMatch\n ? Number(monthThenDayMatch[1])\n : dayThenMonthMatch\n ? Number(dayThenMonthMatch[1])\n : explicitDayMatch\n ? Number(explicitDayMatch[1])\n : 0;\n\nconst firstFortnight =\n /\\b(1Q|Q1)\\b/.test(normalizedFileName) ||\n normalizedFileName.includes(\n 'PRIMERA QUINCENA'\n ) ||\n normalizedFileName.includes(\n '1RA QUINCENA'\n ) ||\n normalizedFileName.includes(\n 'FIRST FORTNIGHT'\n ) ||\n normalizedFileName.includes(\n 'FIRST HALF'\n ) ||\n normalizedFileName.includes(\n 'MID MONTH'\n ) ||\n normalizedFileName.includes(\n 'MIDMONTH'\n );\n\nconst secondFortnight =\n /\\b(2Q|Q2)\\b/.test(normalizedFileName) ||\n normalizedFileName.includes(\n 'SEGUNDA QUINCENA'\n ) ||\n normalizedFileName.includes(\n '2DA QUINCENA'\n ) ||\n normalizedFileName.includes(\n 'SECOND FORTNIGHT'\n ) ||\n normalizedFileName.includes(\n 'SECOND HALF'\n ) ||\n normalizedFileName.includes(\n 'FIN DE MES'\n ) ||\n normalizedFileName.includes(\n 'END OF MONTH'\n ) ||\n normalizedFileName.includes(\n 'MONTH END'\n ) ||\n normalizedFileName.includes(\n 'MONTHEND'\n );\n\nlet fallbackPeriodType = 'desconocido';\nlet fallbackConfidence = 0.2;\nlet strongPeriodEvidence = false;\n\nif (firstFortnight) {\n fallbackPeriodType = 'quincena_15';\n fallbackConfidence = 0.98;\n strongPeriodEvidence = true;\n} else if (secondFortnight) {\n fallbackPeriodType = 'quincena_30';\n fallbackConfidence = 0.98;\n strongPeriodEvidence = true;\n} else if (detectedDay === 15) {\n fallbackPeriodType = 'quincena_15';\n fallbackConfidence = 0.95;\n strongPeriodEvidence = true;\n} else if (\n fallbackYear &&\n fallbackMonth &&\n detectedDay === getLastDay(\n fallbackYear,\n fallbackMonth\n )\n) {\n fallbackPeriodType = 'quincena_30';\n fallbackConfidence = 0.95;\n strongPeriodEvidence = true;\n} else if (\n detectedDay === 30 ||\n detectedDay === 31\n) {\n fallbackPeriodType = 'quincena_30';\n fallbackConfidence = 0.94;\n strongPeriodEvidence = true;\n} else if (\n fallbackYear &&\n fallbackMonth\n) {\n fallbackPeriodType = 'mensual';\n fallbackConfidence = 0.75;\n}\n\nlet rawAI =\n incoming.output ??\n incoming.result ??\n incoming.text ??\n incoming;\n\nrawAI = parseObject(rawAI);\n\nconst aiCountry =\n String(rawAI.country || '').toUpperCase();\n\nconst aiYear = Number(rawAI.year);\nconst aiMonth = Number(rawAI.month);\n\nconst aiPeriodType =\n validPeriodTypes.has(rawAI.period_type)\n ? rawAI.period_type\n : 'desconocido';\n\nconst aiConfidence = clamp(\n rawAI.confidence,\n 0,\n 1\n);\n\nconst aiIsUsable =\n Number.isInteger(aiYear) &&\n aiYear >= 2000 &&\n aiYear <= 2100 &&\n Number.isInteger(aiMonth) &&\n aiMonth >= 1 &&\n aiMonth <= 12 &&\n aiPeriodType !== 'desconocido';\n\nlet year =\n fallbackYear ||\n (aiIsUsable ? aiYear : 0);\n\nlet month =\n fallbackMonth ||\n (aiIsUsable ? aiMonth : 0);\n\nlet periodType =\n aiIsUsable\n ? aiPeriodType\n : fallbackPeriodType;\n\nlet source =\n aiIsUsable\n ? 'gemini'\n : 'local_fallback';\n\nlet confidence =\n aiIsUsable\n ? aiConfidence\n : fallbackConfidence;\n\nlet conflictResolved = false;\n\nif (\n aiCountry &&\n aiCountry !== country\n) {\n source = 'gemini_validated_with_rules';\n conflictResolved = true;\n}\n\nif (\n strongPeriodEvidence &&\n periodType !== fallbackPeriodType\n) {\n periodType = fallbackPeriodType;\n source = 'gemini_validated_with_rules';\n confidence = Math.max(\n fallbackConfidence,\n Math.min(aiConfidence, 0.9)\n );\n conflictResolved = true;\n}\n\nif (\n fallbackYear &&\n aiIsUsable &&\n aiYear !== fallbackYear\n) {\n year = fallbackYear;\n source = 'gemini_validated_with_rules';\n conflictResolved = true;\n}\n\nif (\n fallbackMonth &&\n aiIsUsable &&\n aiMonth !== fallbackMonth\n) {\n month = fallbackMonth;\n source = 'gemini_validated_with_rules';\n conflictResolved = true;\n}\n\nif (\n !aiIsUsable &&\n fallbackYear &&\n fallbackMonth\n) {\n year = fallbackYear;\n month = fallbackMonth;\n periodType = fallbackPeriodType;\n confidence = fallbackConfidence;\n source = 'local_fallback';\n}\n\nconst complete =\n year >= 2000 &&\n month >= 1 &&\n month <= 12 &&\n periodType !== 'desconocido';\n\nconst dates = buildPeriodDates(\n year,\n month,\n periodType\n);\n\nlet periodDescription = 'Período desconocido';\n\nif (periodType === 'quincena_15') {\n periodDescription = 'Quincena 15';\n}\n\nif (periodType === 'quincena_30') {\n periodDescription =\n 'Quincena 30 / fin de mes';\n}\n\nif (periodType === 'mensual') {\n periodDescription = 'Mensual';\n}\n\nconst monthLabel =\n monthNames[month] || 'Mes desconocido';\n\nconst periodLabel = complete\n ? `${monthLabel} ${year} · ${periodDescription}`\n : 'Período pendiente de confirmar';\n\nconst requiresManualReview =\n !complete ||\n confidence < 0.75;\n\nconst explanation =\n cleanText(rawAI.explanation) ||\n (\n source === 'local_fallback'\n ? 'El período fue identificado mediante las reglas locales de respaldo.'\n : 'El período fue identificado mediante Gemini y validado con las reglas del portal.'\n );\n\nreturn [\n {\n json: {\n ok: true,\n\n message: requiresManualReview\n ? 'El archivo fue analizado, pero el período debe confirmarse manualmente.'\n : 'El período de la nómina fue detectado correctamente.',\n\n detected: {\n country,\n countryName,\n\n fileName,\n\n year,\n month,\n\n periodType,\n period_type: periodType,\n\n periodLabel,\n period_label: periodLabel,\n\n periodStart: dates.periodStart,\n period_start: dates.periodStart,\n\n periodEnd: dates.periodEnd,\n period_end: dates.periodEnd,\n\n confidence:\n Math.round(confidence * 100) / 100,\n\n complete,\n requiresManualReview,\n requires_manual_review:\n requiresManualReview,\n\n source,\n conflictResolved,\n explanation,\n },\n\n debug: {\n fallback: {\n country,\n year: fallbackYear,\n month: fallbackMonth,\n periodType: fallbackPeriodType,\n detectedDay,\n confidence: fallbackConfidence,\n strongPeriodEvidence,\n compactDate:\n compactDateMatch?.[0] || null,\n },\n\n gemini: {\n country: aiCountry || null,\n year: aiYear || 0,\n month: aiMonth || 0,\n periodType: aiPeriodType,\n confidence: aiConfidence,\n explanation:\n cleanText(rawAI.explanation),\n },\n },\n },\n },\n];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 400, 144 ], "id": "3d608f18-2ed8-47a3-914c-c2494dfea8f7", "name": "Normalizar análisis IA" }, { "parameters": { "options": { "responseCode": 200, "responseHeaders": { "entries": [ { "name": "Content-Type", "value": "application/json" } ] } } }, "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.5, "position": [ 608, 144 ], "id": "b0843a40-9e7d-400f-b265-d563b789786f", "name": "Responder análisis" }, { "parameters": { "content": "# 🧠 ANÁLISIS AUTOMÁTICO DE NÓMINA — GT Y TT\n\nAnaliza el nombre o título del archivo de nómina para identificar automáticamente el período correspondiente antes de ejecutar la verificación.\n\n## Recepción y validación\n\nEl Webhook recibe desde el Portal de Verificación de Nóminas la información del archivo seleccionado.\n\nLa solicitud puede incluir:\n\n- Nombre del archivo.\n- Título o descripción disponible.\n- País o portal de origen.\n- Identificador de la solicitud.\n- Información complementaria enviada por la aplicación.\n\nAntes de utilizar inteligencia artificial, el flujo valida que exista suficiente información para analizar el archivo.\n\n## Extracción con Gemini\n\nCuando la solicitud es válida, Gemini interpreta el nombre o título de la nómina e intenta determinar:\n\n- Año.\n- Mes.\n- Tipo de período.\n- Quincena 15.\n- Quincena 30 o fin de mes.\n- Nómina mensual.\n- País, cuando puede inferirse de forma confiable.\n- Nivel de confianza del análisis.\n\nGemini se utiliza únicamente para interpretar el texto recibido; no ejecuta el cruce de nómina ni modifica información.\n\n## Normalización del resultado\n\nLa respuesta de Gemini se transforma al formato requerido por la aplicación.\n\nEste bloque:\n\n- Limpia texto adicional generado por la IA.\n- Normaliza el año a formato numérico.\n- Convierte el mes al valor esperado por el portal.\n- Estandariza el tipo de período.\n- Corrige variaciones como “segunda quincena”, “fin de mes” o “30”.\n- Conserva los valores originales cuando la IA no puede determinar un dato.\n- Prepara una respuesta consistente para Guatemala y Trinidad y Tobago.\n\n## Respuesta al portal\n\nCuando el análisis termina, el workflow devuelve a la aplicación:\n\n- Indicador de éxito.\n- Año detectado.\n- Mes detectado.\n- Tipo de período detectado.\n- País identificado, cuando corresponda.\n- Nivel de confianza.\n- Datos que requieren revisión manual.\n\nLos valores detectados sirven para completar automáticamente el formulario, pero el usuario puede corregirlos antes de ejecutar el proceso.\n\n## Ruta de error\n\nCuando la solicitud es inválida:\n\n- No se llama a Gemini.\n- No se intenta interpretar información incompleta.\n- Se devuelve una respuesta de error al portal.\n- Se indica que falta el nombre del archivo u otro dato obligatorio.\n\n## Reglas\n\n- No inventar un período cuando el título no contiene información suficiente.\n- No ejecutar el cruce de nómina desde este workflow.\n- No tratar una predicción de baja confianza como un dato definitivo.\n- Mantener la opción de corrección manual en la aplicación.\n- Responder siempre al Webhook, tanto en la ruta válida como en la ruta de error.\n- Utilizar una estructura de respuesta compatible con los portales de Guatemala y Trinidad y Tobago.", "height": 1488, "width": 2256, "color": 2 }, "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ -1424, -416 ], "id": "27e40ac1-a1eb-4ce5-ad0f-919b81b39dfb", "name": "Sticky Note" } ], "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 } ] ] } }, "authors": "Isaac Aracena", "name": "Version 1aba63da", "description": "", "autosaved": true, "workflowPublishHistory": [ { "createdAt": "2026-07-21T21:08:40.957Z", "id": 3314, "workflowId": "E9cNPlUjeGnhFqW2", "versionId": "1aba63da-c451-45a3-ab57-a88ac4bd4ea6", "event": "activated", "userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029" }, { "createdAt": "2026-07-25T17:29:06.303Z", "id": 3466, "workflowId": "E9cNPlUjeGnhFqW2", "versionId": "1aba63da-c451-45a3-ab57-a88ac4bd4ea6", "event": "activated", "userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029" }, { "createdAt": "2026-07-25T17:29:06.261Z", "id": 3465, "workflowId": "E9cNPlUjeGnhFqW2", "versionId": "1aba63da-c451-45a3-ab57-a88ac4bd4ea6", "event": "deactivated", "userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029" } ] } }