Files
finiquitos-gt-procesar-1-em…/finiquitos-gt-procesar-1-empleado-1-al-18-julio-2026.json

2477 lines
216 KiB
JSON

{
"updatedAt": "2026-07-31T14:09:46.052Z",
"createdAt": "2026-07-31T13:20:58.389Z",
"id": "osgTmV6UU3JmhogE",
"name": "Finiquitos GT - Procesar 1 empleado - 1 al 18 julio 2026",
"description": null,
"active": true,
"isArchived": false,
"nodes": [
{
"parameters": {
"jsCode": "const inputItems = $input.all();\n\nconst contextoEmpleado =\n $('Seleccionar empleado exacto').first().json || {};\n\nconst employeeId = String(\n contextoEmpleado.employeeId || ''\n).trim();\n\nconst nombreEmpleado = String(\n contextoEmpleado.empleado ||\n contextoEmpleado.bambooDisplayName ||\n ''\n).trim();\n\nif (!employeeId) {\n throw new Error(\n 'Seleccionar empleado exacto no devolvió un employeeId válido.'\n );\n}\n\nif (!nombreEmpleado) {\n throw new Error(\n 'Seleccionar empleado exacto no devolvió el nombre del empleado.'\n );\n}\n\nfunction normalizar(valor) {\n return String(valor ?? '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^a-z0-9./\\s_-]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction primerValor(objeto, claves) {\n for (const clave of claves) {\n const valor = objeto?.[clave];\n\n if (\n valor !== undefined &&\n valor !== null &&\n String(valor).trim() !== ''\n ) {\n return valor;\n }\n }\n\n return '';\n}\n\nfunction inferirExtension(nombreArchivo) {\n const nombre = String(nombreArchivo || '').trim();\n\n const coincidencia = nombre.match(\n /\\.([a-zA-Z0-9]{2,10})$/\n );\n\n return coincidencia\n ? coincidencia[1].toLowerCase()\n : '';\n}\n\nlet documentos = [];\n\nfor (const item of inputItems) {\n const json = item.json || {};\n\n /*\n * Caso normal del nodo BambooHR:\n * un documento por cada item.\n */\n const pareceDocumento =\n json.id ||\n json.documentId ||\n json.fileId ||\n json.employeeDocumentId ||\n json.name ||\n json.fileName ||\n json.filename ||\n json.originalFileName ||\n json.title;\n\n if (pareceDocumento) {\n documentos.push(json);\n continue;\n }\n\n /*\n * Casos alternativos:\n * BambooHR o n8n podrían devolver los documentos\n * agrupados dentro de un arreglo.\n */\n const arraysPosibles = [\n json.data,\n json.documents,\n json.employeeDocuments,\n json.files,\n json.results\n ].filter(Array.isArray);\n\n for (const array of arraysPosibles) {\n documentos.push(...array);\n }\n}\n\n/*\n * Eliminar documentos repetidos por ID.\n */\nconst documentosUnicos = [];\nconst idsVistos = new Set();\n\nfor (let index = 0; index < documentos.length; index++) {\n const documento = documentos[index] || {};\n\n const idDocumento = String(\n primerValor(documento, [\n 'documentId',\n 'employeeDocumentId',\n 'fileId',\n 'id'\n ])\n ).trim();\n\n const claveUnica = idDocumento\n ? `id:${idDocumento}`\n : `index:${index}`;\n\n if (idsVistos.has(claveUnica)) {\n continue;\n }\n\n idsVistos.add(claveUnica);\n documentosUnicos.push(documento);\n}\n\ndocumentos = documentosUnicos;\n\nif (!documentos.length) {\n return [\n {\n json: {\n employeeId,\n empleado: nombreEmpleado,\n\n documentosEncontrados: 0,\n candidatosDpiEncontrados: 0,\n\n documentId: '',\n nombre: '',\n categoria: '',\n extension: '',\n mimeType: '',\n\n puntajeDpi: 0,\n coincidencias: [],\n penalizaciones: [],\n\n esCandidatoDpi: false,\n\n estado: 'SIN_DOCUMENTOS_VISIBLES',\n\n observacion:\n 'BambooHR no devolvió documentos visibles para este empleado. Puede ser ausencia de archivos o falta de permisos.',\n\n metadataOriginal: null\n }\n }\n ];\n}\n\nconst analizados = documentos\n .map((documento, index) => {\n const documentId = String(\n primerValor(documento, [\n 'documentId',\n 'employeeDocumentId',\n 'fileId',\n 'id'\n ])\n ).trim();\n\n /*\n * Preferimos el nombre asignado en BambooHR.\n * originalFileName se usa como respaldo.\n */\n const nombre = String(\n primerValor(documento, [\n 'name',\n 'documentName',\n 'fileName',\n 'filename',\n 'title',\n 'originalFileName'\n ])\n ).trim();\n\n const nombreOriginal = String(\n primerValor(documento, [\n 'originalFileName',\n 'originalFilename',\n 'sourceFileName'\n ])\n ).trim();\n\n const categoria = String(\n primerValor(documento, [\n 'categoryName',\n 'documentCategory',\n 'category',\n 'folderName',\n 'sectionName',\n 'type'\n ])\n ).trim();\n\n let extension = String(\n primerValor(documento, [\n 'extension',\n 'fileExtension',\n 'ext'\n ])\n )\n .trim()\n .toLowerCase();\n\n if (!extension) {\n extension = inferirExtension(\n nombre || nombreOriginal\n );\n }\n\n const mimeType = String(\n primerValor(documento, [\n 'mimeType',\n 'contentType',\n 'fileType'\n ])\n )\n .trim()\n .toLowerCase();\n\n const textoCompleto = normalizar(\n [\n nombre,\n nombreOriginal,\n categoria,\n extension,\n mimeType\n ].join(' ')\n );\n\n let puntajeDpi = 0;\n\n const coincidencias = [];\n const penalizaciones = [];\n\n function sumar(puntos, etiqueta) {\n puntajeDpi += puntos;\n\n if (!coincidencias.includes(etiqueta)) {\n coincidencias.push(etiqueta);\n }\n }\n\n function restar(puntos, etiqueta) {\n puntajeDpi -= puntos;\n\n if (!penalizaciones.includes(etiqueta)) {\n penalizaciones.push(etiqueta);\n }\n }\n\n /*\n * Señales fuertes de que sí es DPI.\n */\n if (\n textoCompleto.includes(\n 'documento personal de identificacion'\n )\n ) {\n sumar(\n 150,\n 'Documento Personal de Identificación'\n );\n }\n\n if (\n /(^|[\\s_.-])dpi($|[\\s_.-])/.test(\n textoCompleto\n )\n ) {\n sumar(130, 'DPI');\n }\n\n if (textoCompleto.includes('cedula')) {\n sumar(110, 'Cédula');\n }\n\n if (textoCompleto.includes('renap')) {\n sumar(100, 'RENAP');\n }\n\n if (\n textoCompleto.includes(\n 'documento de identificacion'\n )\n ) {\n sumar(\n 90,\n 'Documento de identificación'\n );\n }\n\n if (\n textoCompleto.includes(\n 'identificacion personal'\n )\n ) {\n sumar(\n 80,\n 'Identificación personal'\n );\n }\n\n if (textoCompleto.includes('identificacion')) {\n sumar(60, 'Identificación');\n }\n\n if (textoCompleto.includes('identification')) {\n sumar(50, 'Identification');\n }\n\n if (textoCompleto.includes('frente')) {\n sumar(20, 'Frente');\n }\n\n if (textoCompleto.includes('reverso')) {\n sumar(20, 'Reverso');\n }\n\n if (textoCompleto.includes('anverso')) {\n sumar(20, 'Anverso');\n }\n\n /*\n * Tipo de archivo.\n */\n const esPdf =\n mimeType.includes('application/pdf') ||\n extension === 'pdf' ||\n textoCompleto.includes('.pdf');\n\n const esImagen =\n mimeType.includes('image/jpeg') ||\n mimeType.includes('image/jpg') ||\n mimeType.includes('image/png') ||\n ['jpg', 'jpeg', 'png'].includes(extension);\n\n if (esPdf) {\n sumar(10, 'PDF');\n }\n\n if (esImagen) {\n sumar(10, 'Imagen');\n }\n\n /*\n * Señales de documentos que normalmente\n * no son un DPI.\n */\n if (textoCompleto.includes('finiquito')) {\n restar(150, 'Finiquito');\n }\n\n if (\n textoCompleto.includes('oferta laboral') ||\n textoCompleto.includes('carta oferta') ||\n textoCompleto.includes('carta ofera')\n ) {\n restar(120, 'Oferta laboral');\n }\n\n if (\n textoCompleto.includes('curriculum') ||\n textoCompleto.includes('resume') ||\n textoCompleto.includes('hoja de vida')\n ) {\n restar(120, 'Currículum');\n }\n\n if (textoCompleto.includes('gafete')) {\n restar(100, 'Gafete');\n }\n\n if (textoCompleto.includes('suspension')) {\n restar(100, 'Suspensión');\n }\n\n if (\n textoCompleto.includes(\n 'entrega de activos'\n )\n ) {\n restar(100, 'Entrega de activos');\n }\n\n if (textoCompleto.includes('confidencialidad')) {\n restar(100, 'Confidencialidad');\n }\n\n if (\n textoCompleto.includes('comunicado') ||\n textoCompleto.includes('capacitacion')\n ) {\n restar(80, 'Comunicado o capacitación');\n }\n\n if (\n textoCompleto.includes('juicio') ||\n textoCompleto.includes('jucio')\n ) {\n restar(100, 'Documento judicial');\n }\n\n /*\n * Reglas finales:\n *\n * - Debe tener ID.\n * - Debe ser PDF o imagen.\n * - Debe alcanzar al menos 60 puntos.\n */\n const formatoPermitido =\n esPdf || esImagen;\n\n const esCandidatoDpi =\n Boolean(documentId) &&\n formatoPermitido &&\n puntajeDpi >= 60;\n\n return {\n originalIndex: index,\n\n employeeId,\n empleado: nombreEmpleado,\n\n documentId,\n nombre,\n nombreOriginal,\n categoria,\n extension,\n mimeType,\n\n esPdf,\n esImagen,\n\n puntajeDpi,\n coincidencias,\n penalizaciones,\n\n esCandidatoDpi,\n\n fechaCreacion:\n documento.dateCreated ||\n documento.createdAt ||\n null,\n\n creadoPor:\n documento.createdBy ||\n documento.creator ||\n null,\n\n metadataOriginal: documento\n };\n })\n .sort((a, b) => {\n /*\n * Primero los candidatos.\n * Después, el puntaje más alto.\n */\n if (\n a.esCandidatoDpi !==\n b.esCandidatoDpi\n ) {\n return a.esCandidatoDpi ? -1 : 1;\n }\n\n return b.puntajeDpi - a.puntajeDpi;\n });\n\nconst candidatos = analizados.filter(\n documento =>\n documento.esCandidatoDpi === true\n);\n\nreturn analizados.map(documento => ({\n json: {\n documentosEncontrados:\n analizados.length,\n\n candidatosDpiEncontrados:\n candidatos.length,\n\n estado:\n candidatos.length === 0\n ? 'SIN_CANDIDATO_DPI'\n : candidatos.length === 1\n ? 'CANDIDATO_DPI_UNICO'\n : 'MULTIPLES_CANDIDATOS_DPI',\n\n ...documento\n }\n}));"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1264,
784
],
"id": "7e043c2c-6708-4f96-96a3-abc7b953d6e1",
"name": "Identificar candidatos DPI"
},
{
"parameters": {
"jsCode": "const items = $input.all();\n\nfunction normalizarTexto(valor) {\n return String(valor ?? '')\n .trim()\n .toUpperCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^A-Z0-9\\s._-]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction obtenerTimestamp(documento) {\n const fecha =\n documento.fechaCreacion ||\n documento.metadataOriginal?.dateCreated ||\n documento.metadataOriginal?.createdAt ||\n '';\n\n const timestamp = Date.parse(fecha);\n\n return Number.isFinite(timestamp)\n ? timestamp\n : 0;\n}\n\nfunction obtenerIdNumerico(documento) {\n return Number(\n String(\n documento.documentId ||\n documento.fileId ||\n ''\n ).replace(/\\D/g, '')\n ) || 0;\n}\n\nfunction puntajeNombreDocumento(documento) {\n const texto = normalizarTexto(\n [\n documento.nombre,\n documento.nombreOriginal,\n documento.metadataOriginal?.name,\n documento.metadataOriginal?.originalFileName\n ].join(' ')\n );\n\n let puntos = 0;\n\n if (/(^|[\\s_.-])DPI($|[\\s_.-])/.test(texto)) {\n puntos += 100;\n }\n\n if (\n texto.includes(\n 'DOCUMENTO PERSONAL DE IDENTIFICACION'\n )\n ) {\n puntos += 90;\n }\n\n if (texto.includes('RENAP')) {\n puntos += 80;\n }\n\n if (texto.includes('CEDULA')) {\n puntos += 70;\n }\n\n if (texto.includes('IDENTIFICACION')) {\n puntos += 40;\n }\n\n if (texto.includes('CURRICULUM')) {\n puntos -= 150;\n }\n\n if (texto.includes('RESUME')) {\n puntos -= 150;\n }\n\n if (texto.includes('FINIQUITO')) {\n puntos -= 150;\n }\n\n if (texto.includes('OFERTA')) {\n puntos -= 120;\n }\n\n if (texto.includes('GAFETE')) {\n puntos -= 100;\n }\n\n return puntos;\n}\n\nconst candidatos = items\n .map((item, index) => ({\n ...(item.json || {}),\n indiceOriginal: index\n }))\n .filter(documento => {\n const documentId = String(\n documento.documentId ||\n documento.fileId ||\n ''\n ).trim();\n\n return (\n documento.esCandidatoDpi === true &&\n documentId !== ''\n );\n });\n\nif (candidatos.length === 0) {\n const primerItem = items[0]?.json || {};\n\n throw new Error(\n `No se encontró ningún documento candidato a DPI para ${\n primerItem.empleado || 'este empleado'\n }.`\n );\n}\n\n/*\n * Selecciones manuales confirmadas.\n *\n * Clave: employeeId de BambooHR\n * Valor: documentId correcto del DPI.\n */\nconst OVERRIDES_DPI = {};\n\nconst employeeIdActual = String(\n candidatos[0]?.employeeId || ''\n).trim();\n\nconst documentIdForzado =\n OVERRIDES_DPI[employeeIdActual] || '';\n\nlet dpi = null;\n\nif (documentIdForzado) {\n dpi = candidatos.find(candidato =>\n String(\n candidato.documentId ||\n candidato.fileId ||\n ''\n ).trim() === documentIdForzado\n );\n\n if (!dpi) {\n throw new Error(\n `Existe una selección manual para el empleado ${employeeIdActual}, ` +\n `pero no apareció el documento ${documentIdForzado} entre los candidatos.`\n );\n }\n}\n\n/*\n * Cuando no hay selección manual,\n * utilizamos la clasificación automática.\n */\nif (!dpi) {\n const candidatosOrdenados =\n [...candidatos].sort((a, b) => {\n const diferenciaNombre =\n puntajeNombreDocumento(b) -\n puntajeNombreDocumento(a);\n\n if (diferenciaNombre !== 0) {\n return diferenciaNombre;\n }\n\n const diferenciaPuntaje =\n Number(b.puntajeDpi || 0) -\n Number(a.puntajeDpi || 0);\n\n if (diferenciaPuntaje !== 0) {\n return diferenciaPuntaje;\n }\n\n const diferenciaFecha =\n obtenerTimestamp(b) -\n obtenerTimestamp(a);\n\n if (diferenciaFecha !== 0) {\n return diferenciaFecha;\n }\n\n return (\n obtenerIdNumerico(b) -\n obtenerIdNumerico(a)\n );\n });\n\n dpi = candidatosOrdenados[0];\n}\n\nconst employeeId = String(\n dpi.employeeId || ''\n).trim();\n\nconst documentId = String(\n dpi.documentId ||\n dpi.fileId ||\n ''\n).trim();\n\nconst nombreArchivo = String(\n dpi.nombre ||\n dpi.nombreOriginal ||\n dpi.metadataOriginal?.name ||\n dpi.metadataOriginal?.originalFileName ||\n ''\n).trim();\n\nif (!employeeId) {\n throw new Error(\n 'El candidato DPI seleccionado no contiene employeeId.'\n );\n}\n\nif (!documentId) {\n throw new Error(\n 'El candidato DPI seleccionado no contiene documentId/fileId.'\n );\n}\n\nif (!nombreArchivo) {\n throw new Error(\n 'El candidato DPI seleccionado no contiene nombre de archivo.'\n );\n}\n\nreturn [\n {\n json: {\n employeeId,\n empleado: dpi.empleado || '',\n\n documentId,\n dpiFileId: documentId,\n\n nombre: nombreArchivo,\n dpiFileName: nombreArchivo,\n\n dpiPuntaje:\n Number(dpi.puntajeDpi || 0),\n\n dpiCoincidencias:\n Array.isArray(dpi.coincidencias)\n ? dpi.coincidencias\n : [],\n\n dpiPenalizaciones:\n Array.isArray(dpi.penalizaciones)\n ? dpi.penalizaciones\n : [],\n\n dpiMetadataOriginal:\n dpi.metadataOriginal || {},\n\n candidatosDpiEncontrados:\n candidatos.length,\n\n seleccionAutomatica:\n !documentIdForzado,\n\n seleccionManualAplicada:\n Boolean(documentIdForzado),\n\n criterioSeleccion:\n documentIdForzado\n ? `Documento seleccionado mediante override: ${documentIdForzado}.`\n : 'Documento seleccionado automáticamente por nombre, puntaje y fecha.'\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1472,
784
],
"id": "8ffc069e-c62f-4a61-955b-d132c07df9c2",
"name": "Seleccionar DPI único"
},
{
"parameters": {
"resource": "employeeDocument",
"operation": "download",
"employeeId": "={{$json.employeeId}}",
"fileId": "={{$json.dpiFileId}}"
},
"type": "n8n-nodes-base.bambooHr",
"typeVersion": 1,
"position": [
1696,
784
],
"id": "cdd79134-df97-4213-9239-dc5eb3986209",
"name": "BambooHR - Descargar DPI",
"retryOnFail": true,
"credentials": {
"bambooHrApi": {
"id": "oH1KdJFaXZGHARuk",
"name": "BambooHR account"
}
}
},
{
"parameters": {
"jsCode": "const item = $input.first();\n\nconst binary = item.binary || {};\nconst binaryKeys = Object.keys(binary);\n\nif (binaryKeys.length === 0) {\n throw new Error(\n 'BambooHR no devolvió ningún archivo binario.'\n );\n}\n\nif (binaryKeys.length > 1) {\n throw new Error(\n `La descarga devolvió más de un archivo: ${binaryKeys.join(', ')}`\n );\n}\n\nconst binaryProperty = binaryKeys[0];\nconst file = binary[binaryProperty];\n\nconst seleccionado =\n $('Seleccionar DPI único')\n .first()\n .json || {};\n\nconst fileName = String(\n file.fileName ||\n seleccionado.dpiFileName ||\n seleccionado.nombre ||\n ''\n).trim();\n\nconst mimeType = String(\n file.mimeType || ''\n)\n .trim()\n .toLowerCase();\n\nif (!fileName) {\n throw new Error(\n 'El archivo descargado no tiene nombre.'\n );\n}\n\nconst nombreLower =\n fileName.toLowerCase();\n\nconst esPdf =\n mimeType.includes('application/pdf') ||\n mimeType.includes('pdf') ||\n nombreLower.endsWith('.pdf');\n\nconst esJpg =\n mimeType.includes('image/jpeg') ||\n mimeType.includes('image/jpg') ||\n nombreLower.endsWith('.jpg') ||\n nombreLower.endsWith('.jpeg');\n\nconst esPng =\n mimeType.includes('image/png') ||\n nombreLower.endsWith('.png');\n\nconst formatoPermitido =\n esPdf || esJpg || esPng;\n\nif (!formatoPermitido) {\n throw new Error(\n `El supuesto DPI no tiene un formato permitido. ` +\n `Archivo: ${fileName}; MIME: ${mimeType || 'no informado'}`\n );\n}\n\nlet extension = 'bin';\n\nif (esPdf) {\n extension = 'pdf';\n} else if (esPng) {\n extension = 'png';\n} else if (esJpg) {\n extension = 'jpg';\n}\n\nreturn [\n {\n json: {\n employeeId:\n seleccionado.employeeId,\n\n empleado:\n seleccionado.empleado,\n\n dpiFileId:\n seleccionado.dpiFileId,\n\n dpiFileName:\n fileName,\n\n dpiMimeType:\n mimeType ||\n (\n esPdf\n ? 'application/pdf'\n : esPng\n ? 'image/png'\n : 'image/jpeg'\n ),\n\n dpiExtension:\n extension,\n\n dpiEsPdf:\n esPdf,\n\n dpiEsImagen:\n esJpg || esPng,\n\n binaryProperty,\n\n descargaDpiValida:\n true,\n\n estado:\n 'DPI_DESCARGADO_PENDIENTE_REVISION_VISUAL'\n },\n\n binary:\n item.binary\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1904,
784
],
"id": "edae3402-ff58-43f1-9e33-7a8a6c1a382b",
"name": "Validar descarga DPI"
},
{
"parameters": {
"resource": "document",
"modelId": {
"__rl": true,
"value": "models/gemini-2.5-pro",
"mode": "list",
"cachedResultName": "models/gemini-2.5-pro"
},
"text": "Analiza exclusivamente el Documento Personal de Identificación de Guatemala\nincluido en este PDF.\n\nEl documento puede contener el frente y el reverso del DPI.\n\nDevuelve ÚNICAMENTE un objeto JSON válido.\nNo utilices Markdown.\nNo incluyas explicaciones antes ni después del JSON.\nNo envuelvas la respuesta en bloques ```json.\nNo inventes información.\nCuando un dato no sea visible o no pueda determinarse con seguridad, utiliza null.\n\nUsa exactamente esta estructura:\n\n{\n \"documentoEsDpiGuatemala\": true,\n \"tieneFrente\": true,\n \"tieneReverso\": true,\n \"nombreCompleto\": \"\",\n \"nombres\": \"\",\n \"apellidos\": \"\",\n \"numeroDpi\": \"\",\n \"nacionalidadCodigo\": \"\",\n \"sexo\": \"\",\n \"fechaNacimiento\": \"\",\n \"lugarNacimientoMunicipio\": \"\",\n \"lugarNacimientoDepartamento\": \"\",\n \"vecindadMunicipio\": \"\",\n \"vecindadDepartamento\": \"\",\n \"estadoCivil\": \"\",\n \"fechaEmision\": \"\",\n \"fechaVencimiento\": \"\",\n \"municipioExpedicion\": null,\n \"departamentoExpedicion\": null,\n \"observaciones\": []\n}\n\nReglas de extracción:\n\n1. numeroDpi:\n - Devuelve únicamente los 13 dígitos.\n - No incluyas espacios, guiones ni otros caracteres.\n\n2. fechaNacimiento, fechaEmision y fechaVencimiento:\n - Usa formato YYYY-MM-DD.\n - Si no es legible, devuelve null.\n\n3. sexo:\n - Usa exclusivamente MASCULINO, FEMENINO o null.\n\n4. estadoCivil:\n - Usa el valor visible en el documento, en mayúsculas.\n - Ejemplos: CASADO, SOLTERO.\n\n5. nacionalidadCodigo:\n - Conserva el código impreso en el documento, por ejemplo GTM.\n\n6. lugarNacimiento:\n - Extrae únicamente la sección titulada LUGAR DE NACIMIENTO.\n\n7. vecindad:\n - Extrae únicamente la sección titulada VECINDAD.\n\n8. municipioExpedicion y departamentoExpedicion:\n - No confundas lugar de nacimiento ni vecindad con lugar de expedición.\n - Si el documento no contiene explícitamente el lugar de expedición,\n devuelve null.\n\n9. tieneFrente y tieneReverso:\n - Indica true solo cuando cada lado realmente esté visible.\n\n10. observaciones:\n - Incluye advertencias de legibilidad, campos dudosos o inconsistencias.\n - Si no hay observaciones, devuelve un arreglo vacío.",
"inputType": "binary",
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.googleGemini",
"typeVersion": 1.2,
"position": [
2112,
784
],
"id": "0b09e33f-8a80-4c04-9136-c135d1c9ba58",
"name": "Gemini - Extraer datos DPI",
"credentials": {
"googlePalmApi": {
"id": "jvsXYwL6IOoY2DBU",
"name": "Isaac - Gemini Api Pago"
}
}
},
{
"parameters": {
"jsCode": "const geminiItem = $input.first();\nconst geminiJson = geminiItem.json || {};\n\nconst bamboo =\n $('BambooHR - Datos completos')\n .first()\n .json || {};\n\nconst seleccion =\n $('Seleccionar DPI único')\n .first()\n .json || {};\n\nconst descarga =\n $('Validar descarga DPI')\n .first();\n\n/*\n * ============================================================\n * FUNCIONES GENERALES\n * ============================================================\n */\n\nfunction obtenerTextoRespuesta(objeto) {\n const candidatos = [\n objeto.text,\n objeto.response,\n objeto.output,\n objeto.outputText,\n objeto.answer,\n objeto.result,\n objeto.content?.parts?.[0]?.text,\n objeto.candidates?.[0]?.content?.parts?.[0]?.text,\n objeto.data?.candidates?.[0]?.content?.parts?.[0]?.text\n ];\n\n for (const candidato of candidatos) {\n if (\n typeof candidato === 'string' &&\n candidato.trim() !== ''\n ) {\n return candidato.trim();\n }\n }\n\n return '';\n}\n\nfunction limpiarBloqueJson(valor) {\n return String(valor || '')\n .trim()\n .replace(/^```json\\s*/i, '')\n .replace(/^```\\s*/i, '')\n .replace(/\\s*```$/i, '')\n .trim();\n}\n\nfunction normalizarTexto(valor) {\n return String(valor ?? '')\n .trim()\n .toUpperCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^A-Z0-9\\s]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction soloDigitos(valor) {\n return String(valor ?? '')\n .replace(/\\D/g, '');\n}\n\nfunction normalizarFecha(valor) {\n if (\n valor === null ||\n valor === undefined ||\n String(valor).trim() === ''\n ) {\n return null;\n }\n\n const texto =\n String(valor).trim();\n\n if (\n /^\\d{4}-\\d{2}-\\d{2}$/.test(texto)\n ) {\n return texto;\n }\n\n return null;\n}\n\nfunction traducirSexoBamboo(valor) {\n const normalizado =\n normalizarTexto(valor);\n\n if (\n normalizado === 'MALE' ||\n normalizado === 'MASCULINO'\n ) {\n return 'MASCULINO';\n }\n\n if (\n normalizado === 'FEMALE' ||\n normalizado === 'FEMENINO'\n ) {\n return 'FEMENINO';\n }\n\n return '';\n}\n\nfunction traducirEstadoCivilBamboo(valor) {\n const normalizado =\n normalizarTexto(valor);\n\n const mapa = {\n MARRIED: 'CASADO',\n SINGLE: 'SOLTERO',\n DIVORCED: 'DIVORCIADO',\n WIDOWED: 'VIUDO',\n SEPARATED: 'SEPARADO',\n\n CASADO: 'CASADO',\n CASADA: 'CASADO',\n\n SOLTERO: 'SOLTERO',\n SOLTERA: 'SOLTERO',\n\n DIVORCIADO: 'DIVORCIADO',\n DIVORCIADA: 'DIVORCIADO',\n\n VIUDO: 'VIUDO',\n VIUDA: 'VIUDO',\n\n SEPARADO: 'SEPARADO',\n SEPARADA: 'SEPARADO'\n };\n\n return (\n mapa[normalizado] ||\n normalizado\n );\n}\n\n/*\n * Compara nombres de forma flexible.\n *\n * Esta validación es informativa.\n * Aunque devuelva false, no detendrá el flujo.\n */\nfunction compararNombresFlexible(\n nombreDpi,\n nombreBamboo\n) {\n const dpi =\n normalizarTexto(nombreDpi);\n\n const bambooNombre =\n normalizarTexto(nombreBamboo);\n\n if (!dpi || !bambooNombre) {\n return false;\n }\n\n if (dpi === bambooNombre) {\n return true;\n }\n\n const tokensDpi =\n dpi.split(' ').filter(Boolean);\n\n const tokensBamboo =\n bambooNombre\n .split(' ')\n .filter(Boolean);\n\n const comunes =\n tokensBamboo.filter(\n token =>\n tokensDpi.includes(token)\n );\n\n /*\n * Aceptamos como coincidencia informativa\n * cuando coinciden al menos 2 palabras\n * y representan una parte significativa\n * del nombre de BambooHR.\n */\n return (\n comunes.length >= 2 &&\n comunes.length >=\n Math.ceil(\n tokensBamboo.length * 0.6\n )\n );\n}\n\n/*\n * ============================================================\n * OBTENER JSON DE GEMINI\n * ============================================================\n */\n\nlet datosDpi;\n\nif (\n geminiJson.documentoEsDpiGuatemala !==\n undefined &&\n typeof geminiJson === 'object'\n) {\n datosDpi = geminiJson;\n} else {\n const textoRespuesta =\n obtenerTextoRespuesta(\n geminiJson\n );\n\n if (!textoRespuesta) {\n throw new Error(\n 'No pude localizar el texto de respuesta producido por Gemini.'\n );\n }\n\n const jsonLimpio =\n limpiarBloqueJson(\n textoRespuesta\n );\n\n try {\n datosDpi =\n JSON.parse(jsonLimpio);\n } catch (error) {\n throw new Error(\n 'Gemini no devolvió un JSON válido. Respuesta recibida: ' +\n jsonLimpio.slice(0, 1000)\n );\n }\n}\n\n/*\n * ============================================================\n * NORMALIZAR DATOS\n * ============================================================\n */\n\nconst numeroDpiExtraido =\n soloDigitos(\n datosDpi.numeroDpi\n );\n\nconst numeroDpiBamboo =\n soloDigitos(\n bamboo['4323'] ??\n bamboo.customIDCedula ??\n bamboo['customID/Cedula'] ??\n bamboo.employeeNumber ??\n ''\n );\n\nconst nombreDpi =\n normalizarTexto(\n datosDpi.nombreCompleto\n );\n\nconst nombreBamboo =\n normalizarTexto(\n [\n bamboo.firstName,\n bamboo.middleName,\n bamboo.lastName\n ]\n .filter(Boolean)\n .join(' ')\n );\n\nconst fechaNacimientoDpi =\n normalizarFecha(\n datosDpi.fechaNacimiento\n );\n\nconst fechaNacimientoBamboo =\n normalizarFecha(\n bamboo.dateOfBirth\n );\n\nconst sexoDpi =\n normalizarTexto(\n datosDpi.sexo\n );\n\nconst sexoBamboo =\n traducirSexoBamboo(\n bamboo.gender\n );\n\nconst estadoCivilDpi =\n normalizarTexto(\n datosDpi.estadoCivil\n );\n\nconst estadoCivilBamboo =\n traducirEstadoCivilBamboo(\n bamboo.maritalStatus\n );\n\n/*\n * ============================================================\n * VALIDACIONES\n * ============================================================\n */\n\nconst validaciones = {\n esDpiGuatemala:\n datosDpi.documentoEsDpiGuatemala ===\n true,\n\n contieneFrente:\n datosDpi.tieneFrente === true,\n\n contieneReverso:\n datosDpi.tieneReverso === true,\n\n dpiTiene13Digitos:\n numeroDpiExtraido.length === 13,\n\n dpiCoincideConBamboo:\n numeroDpiExtraido !== '' &&\n numeroDpiBamboo !== '' &&\n numeroDpiExtraido ===\n numeroDpiBamboo,\n\n /*\n * Estas validaciones quedan informativas.\n * No detienen la automatización.\n */\n nombreCoincide:\n compararNombresFlexible(\n nombreDpi,\n nombreBamboo\n ),\n\n fechaNacimientoCoincide:\n fechaNacimientoDpi !== null &&\n fechaNacimientoBamboo !== null &&\n fechaNacimientoDpi ===\n fechaNacimientoBamboo,\n\n sexoCoincide:\n sexoDpi !== '' &&\n sexoBamboo !== '' &&\n sexoDpi === sexoBamboo,\n\n estadoCivilCoincide:\n estadoCivilDpi !== '' &&\n estadoCivilBamboo !== '' &&\n estadoCivilDpi ===\n estadoCivilBamboo\n};\n\n/*\n * Solo estas validaciones detienen el flujo.\n *\n * La diferencia de nombre, fecha, sexo o estado civil\n * ya NO bloquea al empleado.\n */\nconst validacionesCriticas = [\n validaciones.esDpiGuatemala,\n validaciones.contieneFrente,\n validaciones.contieneReverso,\n validaciones.dpiTiene13Digitos\n];\n\nconst validacionCriticaCorrecta =\n validacionesCriticas.every(\n Boolean\n );\n\nif (!validacionCriticaCorrecta) {\n throw new Error(\n 'La validación crítica del DPI falló: ' +\n JSON.stringify(validaciones)\n );\n}\n\n/*\n * ============================================================\n * ADVERTENCIAS NO BLOQUEANTES\n * ============================================================\n */\n\nconst advertenciasValidacion = [];\n\nif (!validaciones.dpiCoincideConBamboo) {\n advertenciasValidacion.push(\n `El número de DPI extraído (${numeroDpiExtraido || 'vacío'}) no pudo validarse contra el campo disponible en BambooHR (${numeroDpiBamboo || 'vacío'}). Se continuará porque el documento fue descargado directamente del perfil confirmado del empleado.`\n );\n}\n\nif (!validaciones.nombreCoincide) {\n advertenciasValidacion.push(\n `El nombre del DPI (${datosDpi.nombreCompleto || 'vacío'}) no coincide exactamente con BambooHR (${nombreBamboo || 'vacío'}). Se continuará porque el número de DPI sí coincide.`\n );\n}\n\nif (\n fechaNacimientoDpi &&\n fechaNacimientoBamboo &&\n !validaciones.fechaNacimientoCoincide\n) {\n advertenciasValidacion.push(\n `La fecha de nacimiento del DPI (${fechaNacimientoDpi}) no coincide con BambooHR (${fechaNacimientoBamboo}).`\n );\n}\n\nif (\n sexoDpi &&\n sexoBamboo &&\n !validaciones.sexoCoincide\n) {\n advertenciasValidacion.push(\n `El sexo del DPI (${sexoDpi}) no coincide con BambooHR (${sexoBamboo}).`\n );\n}\n\nif (\n estadoCivilDpi &&\n estadoCivilBamboo &&\n !validaciones.estadoCivilCoincide\n) {\n advertenciasValidacion.push(\n `El estado civil del DPI (${estadoCivilDpi}) no coincide con BambooHR (${estadoCivilBamboo}).`\n );\n}\n\n/*\n * ============================================================\n * DATOS LEGALES\n * ============================================================\n */\n\nlet nacionalidadLegal = null;\n\nconst codigoNacionalidad =\n normalizarTexto(\n datosDpi.nacionalidadCodigo\n );\n\nif (\n codigoNacionalidad === 'GTM'\n) {\n if (\n sexoDpi === 'FEMENINO'\n ) {\n nacionalidadLegal =\n 'guatemalteca';\n } else if (\n sexoDpi === 'MASCULINO'\n ) {\n nacionalidadLegal =\n 'guatemalteco';\n } else {\n nacionalidadLegal =\n 'guatemalteco';\n }\n}\n\nlet estadoCivilLegal = null;\n\nif (estadoCivilDpi) {\n const estadoLower =\n estadoCivilDpi.toLowerCase();\n\n if (\n sexoDpi === 'FEMENINO' &&\n estadoLower.endsWith('o')\n ) {\n estadoCivilLegal =\n estadoLower.slice(0, -1) +\n 'a';\n } else {\n estadoCivilLegal =\n estadoLower;\n }\n}\n\n/*\n * Lugar que se insertará después de RENAP en el finiquito.\n *\n * El DPI normalmente no imprime una oficina de expedición.\n * Para mantener el mismo criterio usado en las correcciones\n * anteriores, se utiliza primero un lugar de expedición\n * explícito y, si no existe, el LUGAR DE NACIMIENTO visible\n * en el reverso del DPI.\n */\nconst municipioRenap = String(\n datosDpi.municipioExpedicion ||\n datosDpi.lugarNacimientoMunicipio ||\n ''\n).trim();\n\nconst departamentoRenap = String(\n datosDpi.departamentoExpedicion ||\n datosDpi.lugarNacimientoDepartamento ||\n ''\n).trim();\n\nconst lugarRenapDisponible =\n Boolean(\n municipioRenap &&\n departamentoRenap\n );\n\nif (!lugarRenapDisponible) {\n throw new Error(\n 'No se pudo extraer el municipio y departamento requeridos para completar las menciones del RENAP.'\n );\n}\n\nconst lugarExpedicionDisponible =\n Boolean(\n datosDpi.municipioExpedicion &&\n datosDpi.departamentoExpedicion\n );\n\nlet estadoProceso;\n\nif (\n advertenciasValidacion.length > 0\n) {\n estadoProceso =\n 'DPI_VALIDADO_CON_ADVERTENCIAS';\n} else {\n estadoProceso =\n 'DPI_VALIDADO_COMPLETO';\n}\n\n/*\n * ============================================================\n * RESULTADO\n * ============================================================\n */\n\nreturn [\n {\n json: {\n employeeId:\n seleccion.employeeId ??\n bamboo.id ??\n '',\n\n empleado:\n seleccion.empleado ??\n nombreBamboo,\n\n dpiFileId:\n seleccion.dpiFileId ??\n seleccion.documentId ??\n '',\n\n dpiFileName:\n seleccion.dpiFileName ??\n seleccion.nombre ??\n '',\n\n numeroDpi:\n numeroDpiExtraido,\n\n numeroDpiBamboo,\n\n nombreCompletoDpi:\n datosDpi.nombreCompleto ??\n null,\n\n nombreBamboo:\n nombreBamboo ||\n null,\n\n nombresDpi:\n datosDpi.nombres ??\n null,\n\n apellidosDpi:\n datosDpi.apellidos ??\n null,\n\n nacionalidadCodigo:\n datosDpi.nacionalidadCodigo ??\n null,\n\n nacionalidadLegal,\n\n sexo:\n sexoDpi || null,\n\n fechaNacimiento:\n fechaNacimientoDpi,\n\n estadoCivil:\n estadoCivilDpi || null,\n\n estadoCivilLegal,\n\n lugarNacimientoMunicipio:\n datosDpi.lugarNacimientoMunicipio ??\n null,\n\n lugarNacimientoDepartamento:\n datosDpi.lugarNacimientoDepartamento ??\n null,\n\n vecindadMunicipio:\n datosDpi.vecindadMunicipio ??\n null,\n\n vecindadDepartamento:\n datosDpi.vecindadDepartamento ??\n null,\n\n fechaEmision:\n normalizarFecha(\n datosDpi.fechaEmision\n ),\n\n fechaVencimiento:\n normalizarFecha(\n datosDpi.fechaVencimiento\n ),\n\n municipioExpedicion:\n datosDpi.municipioExpedicion ??\n null,\n\n departamentoExpedicion:\n datosDpi.departamentoExpedicion ??\n null,\n\n lugarExpedicionDisponible,\n\n municipioRenap,\n departamentoRenap,\n lugarRenapDisponible,\n\n validaciones,\n\n validacionCriticaCorrecta,\n\n advertenciasValidacion,\n\n observacionesGemini:\n Array.isArray(\n datosDpi.observaciones\n )\n ? datosDpi.observaciones\n : [],\n\n estadoProceso\n },\n\n binary:\n descarga.binary\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2336,
784
],
"id": "196fdad7-2cfa-4ca7-9357-1029cd544b8c",
"name": "Normalizar y validar datos DPI"
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"options": {
"clashHandling": {
"values": {
"resolveClash": "preferInput1"
}
},
"includeUnpaired": false
}
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
3088,
800
],
"id": "c0f1e06f-e44c-4af3-bd76-aee166330f60",
"name": "Merge"
},
{
"parameters": {
"jsCode": "const item = $input.first();\n\nif (!item.json) {\n throw new Error(\n 'No se recibieron los datos JSON del empleado.'\n );\n}\n\nconst binary = item.binary || {};\nconst binaryKeys = Object.keys(binary);\n\nif (binaryKeys.length === 0) {\n throw new Error(\n 'No se recibió el archivo del DPI.'\n );\n}\n\nconst binaryProperty =\n binaryKeys[0];\n\nconst archivoOriginal =\n binary[binaryProperty];\n\nconst empleado = String(\n item.json.empleado || ''\n).trim();\n\nconst carpetaEmpleadoId = String(\n item.json.carpetaEmpleadoId || ''\n).trim();\n\nif (!empleado) {\n throw new Error(\n 'Falta el nombre del empleado.'\n );\n}\n\nif (!carpetaEmpleadoId) {\n throw new Error(\n 'Falta el ID de la carpeta del empleado.'\n );\n}\n\nconst mimeType = String(\n archivoOriginal.mimeType ||\n item.json.dpiMimeType ||\n ''\n)\n .trim()\n .toLowerCase();\n\nconst nombreOriginal = String(\n archivoOriginal.fileName ||\n item.json.dpiFileName ||\n ''\n).trim();\n\nlet extension = 'pdf';\n\nif (\n mimeType.includes('image/jpeg') ||\n mimeType.includes('image/jpg') ||\n /\\.jpe?g$/i.test(nombreOriginal)\n) {\n extension = 'jpg';\n} else if (\n mimeType.includes('image/png') ||\n /\\.png$/i.test(nombreOriginal)\n) {\n extension = 'png';\n}\n\nconst mimeFinal =\n extension === 'pdf'\n ? 'application/pdf'\n : extension === 'png'\n ? 'image/png'\n : 'image/jpeg';\n\nconst nombreArchivo =\n `DPI - ${empleado}.${extension}`;\n\nreturn [\n {\n json: {\n ...item.json,\n\n dpiDriveFileName:\n nombreArchivo,\n\n dpiDriveFolderId:\n carpetaEmpleadoId,\n\n dpiDriveMimeTypeEsperado:\n mimeFinal,\n\n dpiDriveExtension:\n extension,\n\n estadoDpiDrive:\n 'LISTO_PARA_SUBIR_DPI'\n },\n\n binary: {\n data: {\n ...archivoOriginal,\n\n fileName:\n nombreArchivo,\n\n fileExtension:\n extension,\n\n mimeType:\n mimeFinal\n }\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
3296,
800
],
"id": "99f12ea1-80a5-4b99-bf8d-71fa6c0b795d",
"name": "Preparar DPI PDF para Drive"
},
{
"parameters": {
"name": "={{$json.dpiDriveFileName}}",
"driveId": {
"__rl": true,
"mode": "list",
"value": "My Drive"
},
"folderId": {
"__rl": true,
"value": "={{$json.carpetaEmpleadoId}}",
"mode": "id"
},
"options": {}
},
"type": "n8n-nodes-base.googleDrive",
"typeVersion": 3,
"position": [
3504,
800
],
"id": "ab0c7173-2e3c-4024-b191-e5ac04c26392",
"name": "Google Drive - Subir DPI PDF",
"credentials": {
"googleDriveOAuth2Api": {
"id": "g23xdGLZRzBGqKgH",
"name": "Isaac - Google Drive"
}
}
},
{
"parameters": {
"jsCode": "const archivoDrive =\n $input.first().json || {};\n\nconst preparado =\n $('Preparar DPI PDF para Drive')\n .first()\n .json || {};\n\nconst id = String(\n archivoDrive.id || ''\n).trim();\n\nconst nombre = String(\n archivoDrive.name || ''\n).trim();\n\nconst mimeType = String(\n archivoDrive.mimeType ||\n preparado.dpiDriveMimeTypeEsperado ||\n ''\n)\n .trim()\n .toLowerCase();\n\nconst parents = Array.isArray(\n archivoDrive.parents\n)\n ? archivoDrive.parents\n : [];\n\nif (!id) {\n throw new Error(\n 'Google Drive no devolvió el ID del archivo.'\n );\n}\n\nif (\n nombre &&\n preparado.dpiDriveFileName &&\n nombre !== preparado.dpiDriveFileName\n) {\n throw new Error(\n `Nombre incorrecto. Esperado: ` +\n `${preparado.dpiDriveFileName}. ` +\n `Recibido: ${nombre}.`\n );\n}\n\n/*\n * Se permiten DPI en:\n * - PDF\n * - JPG/JPEG\n * - PNG\n */\nconst mimePermitido =\n !mimeType ||\n mimeType === 'application/pdf' ||\n mimeType === 'image/jpeg' ||\n mimeType === 'image/jpg' ||\n mimeType === 'image/png';\n\nif (!mimePermitido) {\n throw new Error(\n `El DPI quedó con un formato no permitido. ` +\n `MIME recibido: ${mimeType || 'no informado'}`\n );\n}\n\nif (\n parents.length > 0 &&\n preparado.carpetaEmpleadoId &&\n !parents.includes(\n preparado.carpetaEmpleadoId\n )\n) {\n throw new Error(\n 'El DPI no quedó dentro de la carpeta correcta.'\n );\n}\n\nreturn [\n {\n json: {\n employeeId:\n preparado.employeeId,\n\n empleado:\n preparado.empleado,\n\n carpetaEmpleadoId:\n preparado.carpetaEmpleadoId,\n\n carpetaEmpleadoUrl:\n preparado.carpetaEmpleadoUrl,\n\n dpiDriveFileId:\n id,\n\n dpiDriveFileName:\n nombre ||\n preparado.dpiDriveFileName,\n\n dpiDriveMimeType:\n mimeType ||\n preparado.dpiDriveMimeTypeEsperado ||\n 'application/octet-stream',\n\n dpiDriveFileUrl:\n archivoDrive.webViewLink ||\n `https://drive.google.com/file/d/${id}/view`,\n\n numeroDpi:\n preparado.numeroDpi,\n\n estado:\n 'DPI_PDF_SUBIDO_CORRECTAMENTE'\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
3712,
800
],
"id": "e180e783-27df-407b-bc46-2ec69c45f3ef",
"name": "Validar DPI PDF subido"
},
{
"parameters": {
"method": "POST",
"url": "=https://sheets.googleapis.com/v4/spreadsheets/{{$('Validar entrada empleado').first().json.spreadsheetId}}/values:batchUpdate",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{\n {\n valueInputOption: 'USER_ENTERED',\n includeValuesInResponse: true,\n data: [\n {\n range:\n $('Validar entrada empleado')\n .first()\n .json\n .hojaConsolidado +\n '!X1:Y1',\n values: [\n [\n 'Valor en letras',\n 'No. DPI'\n ]\n ]\n },\n {\n range:\n $('Validar entrada empleado')\n .first()\n .json\n .hojaConsolidado +\n '!X' +\n $json.filaConsolidado,\n values: [\n [$json.montoLetras]\n ]\n },\n {\n range:\n $('Validar entrada empleado')\n .first()\n .json\n .hojaConsolidado +\n '!Y' +\n $json.filaConsolidado,\n values: [\n [$json.numeroDpi]\n ]\n }\n ]\n }\n}}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
5808,
672
],
"id": "fd279b0c-e41b-4b7d-9a50-dba7b6281113",
"name": "Actualizar Consolidado",
"credentials": {
"googleOAuth2Api": {
"id": "eHseMeH39kRcXgOF",
"name": "Google account 2"
}
}
},
{
"parameters": {
"method": "POST",
"url": "=https://docs.googleapis.com/v1/documents/{{$json.id}}:batchUpdate",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{\n {\n requests: Object.entries(\n $('Preparar datos documentos')\n .first()\n .json\n .reemplazosFiniquito\n ).map(([buscar, reemplazar]) => ({\n replaceAllText: {\n containsText: {\n text: buscar,\n matchCase: true\n },\n replaceText:\n String(reemplazar ?? '')\n }\n }))\n }\n}}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
6224,
768
],
"id": "2bd60729-2750-4784-aab7-0481884d8784",
"name": "Personalizar Finiquito",
"credentials": {
"googleOAuth2Api": {
"id": "eHseMeH39kRcXgOF",
"name": "Google account 2"
}
}
},
{
"parameters": {
"method": "POST",
"url": "=https://docs.googleapis.com/v1/documents/{{$json.id}}:batchUpdate",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleDocsOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{\n {\n requests: Object.entries(\n $('Preparar datos documentos')\n .first()\n .json\n .reemplazosVoucher\n ).map(([buscar, reemplazar]) => ({\n replaceAllText: {\n containsText: {\n text: buscar,\n matchCase: true\n },\n replaceText:\n String(reemplazar ?? '')\n }\n }))\n }\n}}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
6048,
448
],
"id": "a31d51bd-fdbb-430b-8047-dbd884126661",
"name": "Personalizar Voucher",
"credentials": {
"googleDocsOAuth2Api": {
"id": "2bGzUIxSb9LeUeNO",
"name": "Isaac - Google Docs"
}
}
},
{
"parameters": {
"method": "POST",
"url": "=https://sheets.googleapis.com/v4/spreadsheets/{{$json.id}}/values:batchUpdate",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleSheetsOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{\n {\n valueInputOption: 'USER_ENTERED',\n includeValuesInResponse: true,\n data: [\n {\n range: 'PLANTILLA!B5',\n values: [\n ['Guatemala']\n ]\n },\n {\n range: 'PLANTILLA!C5',\n values: [\n [\n $('Preparar datos documentos')\n .first()\n .json\n .fechaDocumento\n ]\n ]\n },\n {\n range: 'PLANTILLA!E5',\n values: [\n [\n $('Preparar datos documentos')\n .first()\n .json\n .monto\n ]\n ]\n },\n {\n range: 'PLANTILLA!B6',\n values: [\n [\n $('Preparar datos documentos')\n .first()\n .json\n .empleado\n ]\n ]\n },\n {\n range: 'PLANTILLA!B7',\n values: [\n [\n $('Preparar datos documentos')\n .first()\n .json\n .montoLetras\n ]\n ]\n }\n ]\n }\n}}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
5984,
1056
],
"id": "e546f20c-b92f-459a-b5db-a553300dbf41",
"name": "Llenar formato cheque",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
}
},
{
"parameters": {
"inputSource": "passthrough"
},
"type": "n8n-nodes-base.executeWorkflowTrigger",
"typeVersion": 1.1,
"position": [
-2400,
768
],
"id": "d2d0eb40-e9cf-4547-94b6-8b07af9c72f0",
"name": "Entrada empleado"
},
{
"parameters": {
"jsCode": "const entrada = $input.first().json || {};\n\nconst filaConsolidado = Number(\n entrada.filaConsolidado\n);\n\nconst numeroAprobacion = String(\n entrada.numeroAprobacion ||\n entrada.noPrestacion ||\n ''\n).trim();\n\nconst empleado = String(\n entrada.empleado || ''\n)\n .replace(/\\s+/g, ' ')\n .trim();\n\nconst primerNombre = String(\n entrada.primerNombre || ''\n).trim();\n\nconst encabezadosOriginales =\n entrada.encabezadosOriginales;\n\nconst filaOriginal =\n entrada.filaOriginal;\n\nconst spreadsheetId = String(\n entrada.spreadsheetId || ''\n).trim();\n\nconst hojaConsolidado = String(\n entrada.hojaConsolidado ||\n 'Consolidado'\n).trim();\n\nconst carpetaRaizId = String(\n entrada.carpetaRaizId || ''\n).trim();\n\nconst plantillaFiniquitoId = String(\n entrada.plantillaFiniquitoId || ''\n).trim();\n\nconst plantillaVoucherId = String(\n entrada.plantillaVoucherId || ''\n).trim();\n\nconst plantillaChequeId = String(\n entrada.plantillaChequeId || ''\n).trim();\n\nif (\n !Number.isInteger(filaConsolidado) ||\n filaConsolidado < 2\n) {\n throw new Error(\n 'La fila del Consolidado no es válida.'\n );\n}\n\nif (!numeroAprobacion) {\n throw new Error(\n 'No se recibió el NO. aprobado.'\n );\n}\n\nif (!empleado) {\n throw new Error(\n 'No se recibió el nombre del empleado.'\n );\n}\n\nif (!primerNombre) {\n throw new Error(\n `No pude determinar el primer nombre de ${empleado}.`\n );\n}\n\nif (\n !Array.isArray(encabezadosOriginales) ||\n !Array.isArray(filaOriginal)\n) {\n throw new Error(\n `No se recibió la fila completa del Consolidado para ${empleado}.`\n );\n}\n\nconst configuracionesObligatorias = {\n spreadsheetId,\n carpetaRaizId,\n plantillaFiniquitoId,\n plantillaVoucherId,\n plantillaChequeId\n};\n\nfor (\n const [campo, valor] of\n Object.entries(configuracionesObligatorias)\n) {\n if (\n !valor ||\n valor.startsWith('REEMPLAZAR_')\n ) {\n throw new Error(\n `Falta completar ${campo} para ${empleado}.`\n );\n }\n}\n\n// Escapar nombres para una búsqueda segura en Google Drive.\nconst nombreDriveEscapado = empleado\n .replace(/\\\\/g, '\\\\\\\\')\n .replace(/'/g, \"\\\\'\");\n\nreturn [\n {\n json: {\n ...entrada,\n\n filaConsolidado,\n numeroAprobacion,\n empleado,\n primerNombre,\n\n encabezadosOriginales,\n filaOriginal,\n\n spreadsheetId,\n hojaConsolidado,\n carpetaRaizId,\n\n plantillaFiniquitoId,\n plantillaVoucherId,\n plantillaChequeId,\n\n nombreDriveEscapado\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-2192,
768
],
"id": "8df4f9a0-c954-48b3-be66-4a29b310e43a",
"name": "Validar entrada empleado"
},
{
"parameters": {
"url": "https://www.googleapis.com/drive/v3/files",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleOAuth2Api",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "q",
"value": "={{ \"'\" + $json.carpetaRaizId + \"' in parents and name = '\" + $json.nombreDriveEscapado + \"' and mimeType = 'application/vnd.google-apps.folder' and trashed = false\" }}"
},
{
"name": "spaces",
"value": "drive"
},
{
"name": "pageSize",
"value": "10"
},
{
"name": "fields",
"value": "files(id,name,mimeType,parents,webViewLink)"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
-1984,
768
],
"id": "5799bb4a-a38b-4aab-a9e5-e39264d71664",
"name": "Buscar carpeta existente",
"credentials": {
"googleOAuth2Api": {
"id": "eHseMeH39kRcXgOF",
"name": "Google account 2"
}
}
},
{
"parameters": {
"jsCode": "const respuesta = $input.first().json || {};\n\nconst contexto =\n $('Validar entrada empleado')\n .first()\n .json;\n\nconst carpetas = Array.isArray(\n respuesta.files\n)\n ? respuesta.files\n : [];\n\nlet estadoCarpeta;\n\nif (carpetas.length === 0) {\n estadoCarpeta = 'NUEVA';\n} else if (carpetas.length === 1) {\n estadoCarpeta = 'EXISTENTE';\n} else {\n estadoCarpeta = 'DUPLICADA';\n}\n\nreturn [\n {\n json: {\n ...contexto,\n\n estadoCarpeta,\n carpetasEncontradas:\n carpetas.length,\n\n carpetaExistenteId:\n carpetas[0]?.id || null,\n\n carpetaExistenteUrl:\n carpetas[0]?.webViewLink ||\n (\n carpetas[0]?.id\n ? `https://drive.google.com/drive/folders/${carpetas[0].id}`\n : null\n )\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-1776,
768
],
"id": "a534cc50-ba01-44b5-8808-6ab50c805e0f",
"name": "Evaluar carpeta existente"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "71e15b33-ec4f-4d09-9b2d-687a465c5efe",
"leftValue": "={{$json.estadoCarpeta}}",
"rightValue": "NUEVA",
"operator": {
"type": "string",
"operation": "equals",
"name": "filter.operator.equals"
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
-1584,
768
],
"id": "f0fabaee-74f1-4a7e-982d-478cc44fbf6c",
"name": "¿Carpeta nueva?"
},
{
"parameters": {
"jsCode": "const item = $input.first().json;\n\nreturn [\n {\n json: {\n filaConsolidado:\n item.filaConsolidado,\n\n empleado:\n item.empleado,\n\n estado:\n item.estadoCarpeta === 'DUPLICADA'\n ? 'REQUIERE_REVISION_CARPETAS_DUPLICADAS'\n : 'OMITIDO_CARPETA_EXISTENTE',\n\n detalle:\n item.estadoCarpeta === 'DUPLICADA'\n ? `Se encontraron ${item.carpetasEncontradas} carpetas con el mismo nombre.`\n : 'Ya existe una carpeta para este empleado. No se creó ningún duplicado.',\n\n carpetaUrl:\n item.carpetaExistenteUrl,\n\n documentosGenerados: false\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-1376,
864
],
"id": "8303bd23-5879-4307-9974-dfb51b235320",
"name": "Resultado carpeta existente"
},
{
"parameters": {
"url": "https://glm.bamboohr.com/api/v1/employees",
"authentication": "genericCredentialType",
"genericAuthType": "httpBasicAuth",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "page[limit]",
"value": "250"
},
{
"name": "fields",
"value": "employeeNumber,middleName,homeEmail,workEmail,hireDate,originalHireDate,terminationDate,dateOfBirth,maritalStatus,gender"
},
{
"name": "filter[firstName]",
"value": "={{$json.primerNombre}}"
}
]
},
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Accept",
"value": "application/json"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
-1088,
768
],
"id": "283d93f6-84ce-4538-a989-20d15aa923c2",
"name": "BambooHR - Buscar empleado",
"credentials": {
"httpBasicAuth": {
"id": "7VrpNZ2jBLmiJ35q",
"name": "BambooHR GLM Full Access"
}
}
},
{
"parameters": {
"jsCode": "const entrada =\n $('Validar entrada empleado')\n .first()\n .json || {};\n\nconst items = $input.all();\n\nfunction normalizar(valor) {\n return String(valor ?? '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^a-z0-9\\s]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction obtenerNombreCompleto(empleado) {\n const nombreDirecto = String(\n empleado?.displayName ||\n empleado?.fullName ||\n empleado?.name ||\n ''\n ).trim();\n\n if (nombreDirecto) {\n return nombreDirecto;\n }\n\n return [\n empleado?.firstName,\n empleado?.middleName,\n empleado?.lastName\n ]\n .filter(valor =>\n valor !== undefined &&\n valor !== null &&\n String(valor).trim() !== ''\n )\n .join(' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction obtenerEmployeeId(empleado) {\n return String(\n empleado?.employeeId ||\n empleado?.id ||\n ''\n ).trim();\n}\n\nfunction tokens(valor) {\n return normalizar(valor)\n .split(' ')\n .filter(Boolean);\n}\n\nconst candidatos = [];\n\nfor (const item of items) {\n const json = item.json || {};\n\n if (Array.isArray(json.data)) {\n candidatos.push(...json.data);\n }\n\n if (Array.isArray(json.data?.data)) {\n candidatos.push(...json.data.data);\n }\n\n if (Array.isArray(json.employees)) {\n candidatos.push(...json.employees);\n }\n\n if (Array.isArray(json.results)) {\n candidatos.push(...json.results);\n }\n\n if (\n obtenerEmployeeId(json) &&\n !Array.isArray(json.data)\n ) {\n candidatos.push(json);\n }\n}\n\nconst candidatosUnicos = [];\nconst idsVistos = new Set();\n\nfor (const candidato of candidatos) {\n const employeeId =\n obtenerEmployeeId(candidato);\n\n if (\n !employeeId ||\n idsVistos.has(employeeId)\n ) {\n continue;\n }\n\n idsVistos.add(employeeId);\n candidatosUnicos.push(candidato);\n}\n\nconst nombreConsolidado = String(\n entrada.empleado || ''\n).trim();\n\nconst nombreNormalizado =\n normalizar(nombreConsolidado);\n\nconst filaConsolidado = Number(\n entrada.filaConsolidado || 0\n);\n\nif (!nombreNormalizado) {\n throw new Error(\n 'No se recibió el nombre del empleado desde el Consolidado.'\n );\n}\n\n/*\n * Excepciones manuales.\n *\n * Se deja vacío para este lote. Si BambooHR tuviera un nombre\n * incompleto, agrega aquí:\n *\n * 'nombre normalizado del consolidado': {\n * employeeId: '12345',\n * displayName: 'Nombre BambooHR'\n * }\n */\nconst OVERRIDES_EMPLEADOS = {\n 'melannie muguett rivera lopez': {\n employeeId: '50586',\n displayName:\n 'Melannie Muguett Rivera Lopez'\n },\n\n 'jordi josue paredes rac': {\n employeeId: '46824',\n displayName:\n 'Jordi Josué Paredes Rac'\n },\n\n 'julio eduardo zapeta': {\n employeeId: '50082',\n displayName:\n 'Eduardo Zapeta'\n }\n};\n\nconst override =\n OVERRIDES_EMPLEADOS[\n nombreNormalizado\n ] || null;\n\nlet seleccionado = null;\nlet seleccionManual = false;\nlet criterioSeleccion = '';\n\nif (override) {\n seleccionado =\n candidatosUnicos.find(candidato =>\n obtenerEmployeeId(candidato) ===\n String(override.employeeId)\n );\n\n if (!seleccionado) {\n seleccionado = {\n id: override.employeeId,\n employeeId: override.employeeId,\n displayName: override.displayName\n };\n }\n\n seleccionManual = true;\n criterioSeleccion =\n 'OVERRIDE_MANUAL';\n} else {\n const exactas =\n candidatosUnicos.filter(candidato =>\n normalizar(\n obtenerNombreCompleto(candidato)\n ) === nombreNormalizado\n );\n\n if (exactas.length === 1) {\n seleccionado = exactas[0];\n criterioSeleccion =\n 'COINCIDENCIA_EXACTA';\n } else {\n const objetivoTokens =\n tokens(nombreConsolidado);\n\n const evaluados =\n candidatosUnicos.map(candidato => {\n const displayName =\n obtenerNombreCompleto(candidato);\n\n const candidatoTokens =\n tokens(displayName);\n\n const comunes =\n objetivoTokens.filter(\n token =>\n candidatoTokens.includes(token)\n );\n\n const comunesUnicos =\n [...new Set(comunes)];\n\n const primerNombreCoincide =\n objetivoTokens[0] &&\n candidatoTokens[0] &&\n objetivoTokens[0] ===\n candidatoTokens[0];\n\n const ultimoObjetivo =\n objetivoTokens[\n objetivoTokens.length - 1\n ];\n\n const ultimoCandidato =\n candidatoTokens[\n candidatoTokens.length - 1\n ];\n\n let puntaje =\n comunesUnicos.length * 10;\n\n if (primerNombreCoincide) {\n puntaje += 12;\n }\n\n if (\n ultimoObjetivo &&\n candidatoTokens.includes(\n ultimoObjetivo\n )\n ) {\n puntaje += 8;\n }\n\n if (\n ultimoCandidato &&\n objetivoTokens.includes(\n ultimoCandidato\n )\n ) {\n puntaje += 5;\n }\n\n const coberturaObjetivo =\n objetivoTokens.length\n ? (\n comunesUnicos.length /\n objetivoTokens.length\n )\n : 0;\n\n return {\n candidato,\n employeeId:\n obtenerEmployeeId(candidato),\n displayName,\n comunes: comunesUnicos,\n puntaje,\n coberturaObjetivo,\n primerNombreCoincide\n };\n })\n .filter(item =>\n item.primerNombreCoincide &&\n item.comunes.length >= 2 &&\n item.coberturaObjetivo >= 0.5\n )\n .sort((a, b) =>\n b.puntaje - a.puntaje\n );\n\n const mejor = evaluados[0];\n const segundo = evaluados[1];\n\n const esUnico =\n mejor &&\n (\n !segundo ||\n mejor.puntaje >\n segundo.puntaje\n );\n\n if (esUnico) {\n seleccionado =\n mejor.candidato;\n\n criterioSeleccion =\n 'COINCIDENCIA_FLEXIBLE_UNICA';\n } else {\n return [\n {\n json: {\n ...entrada,\n\n filaConsolidado,\n empleado:\n nombreConsolidado,\n\n nombreConsolidado,\n\n estadoMatch: 'NO',\n\n estado:\n candidatosUnicos.length\n ? 'SIN_COINCIDENCIA_UNICA'\n : 'SIN_CANDIDATOS_BAMBOOHR',\n\n detalle:\n 'No fue posible identificar de forma única al empleado en BambooHR.',\n\n candidatos:\n candidatosUnicos.map(\n candidato => ({\n employeeId:\n obtenerEmployeeId(\n candidato\n ),\n\n displayName:\n obtenerNombreCompleto(\n candidato\n )\n })\n ),\n\n documentosGenerados: false\n }\n }\n ];\n }\n }\n}\n\nconst employeeId =\n obtenerEmployeeId(seleccionado);\n\nconst displayName =\n obtenerNombreCompleto(seleccionado) ||\n override?.displayName ||\n nombreConsolidado;\n\nif (!employeeId) {\n throw new Error(\n 'El empleado seleccionado no contiene employeeId.'\n );\n}\n\nreturn [\n {\n json: {\n ...entrada,\n ...seleccionado,\n\n filaConsolidado,\n\n id: employeeId,\n employeeId,\n\n empleado:\n nombreConsolidado,\n\n nombreConsolidado,\n displayName,\n\n estadoMatch: 'OK',\n\n estado:\n seleccionManual\n ? 'COINCIDENCIA_MANUAL_CONFIRMADA'\n : criterioSeleccion,\n\n coincidenciaExacta:\n criterioSeleccion ===\n 'COINCIDENCIA_EXACTA',\n\n seleccionManual,\n criterioSeleccion,\n\n detalle:\n seleccionManual\n ? `Vinculación manual confirmada: ${nombreConsolidado} → ${displayName}, employeeId ${employeeId}.`\n : `Empleado identificado: ${nombreConsolidado} → ${displayName}, employeeId ${employeeId}, criterio ${criterioSeleccion}.`,\n\n documentosGenerados: null\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-880,
768
],
"id": "12642efc-5576-4ea5-b587-5424d850b29d",
"name": "Seleccionar empleado exacto"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "a03d97b4-6869-4d4a-a24b-17f214009052",
"leftValue": "={{$json.estadoMatch}}",
"rightValue": "OK",
"operator": {
"type": "string",
"operation": "equals",
"name": "filter.operator.equals"
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
-672,
768
],
"id": "8d98a951-e561-4744-98d2-1992b47c1ff5",
"name": "¿Empleado exacto?"
},
{
"parameters": {
"jsCode": "const item =\n $input.first().json || {};\n\nreturn [\n {\n json: {\n filaConsolidado:\n item.filaConsolidado,\n\n numeroAprobacion:\n item.numeroAprobacion || '',\n\n empleado:\n item.empleado,\n\n estado:\n item.estadoMatch ||\n item.estado ||\n 'SIN_COINCIDENCIA',\n\n detalle:\n item.detalle ||\n 'No fue posible identificar de forma única al empleado en BambooHR.',\n\n candidatos:\n item.candidatosEncontrados ||\n item.candidatos ||\n [],\n\n documentosGenerados: false\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-464,
864
],
"id": "a647fa32-c9c1-4340-b735-8880ebe0b2d4",
"name": "Resultado empleado no identificado"
},
{
"parameters": {
"url": "=https://glm.bamboohr.com/api/v1/employees/{{ $('Seleccionar empleado exacto').first().json.employeeId }}",
"authentication": "genericCredentialType",
"genericAuthType": "httpBasicAuth",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "fields",
"value": "firstName,middleName,lastName,preferredName,employeeNumber,status,workEmail,homeEmail,gender,dateOfBirth,maritalStatus,address1,address2,city,state,zipcode,country,hireDate,originalHireDate,terminationDate,jobTitle,department,division,location,employmentHistoryStatus,employeeStatusDate,4323,4440,4584,4725,4726,4046,4047,4383,4384,4385,4667"
},
{
"name": "onlyCurrent",
"value": "true"
}
]
},
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Accept",
"value": "application/json"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
864,
784
],
"id": "f61a80f1-a6be-45f3-b826-a80e263d80b1",
"name": "BambooHR - Datos completos",
"credentials": {
"httpBasicAuth": {
"id": "7VrpNZ2jBLmiJ35q",
"name": "BambooHR GLM Full Access"
}
}
},
{
"parameters": {
"resource": "employeeDocument",
"operation": "getAll",
"employeeId": "={{$('Seleccionar empleado exacto').first().json.employeeId}}",
"returnAll": true
},
"type": "n8n-nodes-base.bambooHr",
"typeVersion": 1,
"position": [
1072,
784
],
"id": "00ac1c8e-b3b9-46be-8bf2-c7f9dbbe549a",
"name": "BambooHR - Listar documentos",
"alwaysOutputData": true,
"credentials": {
"bambooHrApi": {
"id": "oH1KdJFaXZGHARuk",
"name": "BambooHR account"
}
}
},
{
"parameters": {
"resource": "folder",
"name": "={{$json.empleado}}",
"driveId": {
"__rl": true,
"mode": "list",
"value": "My Drive"
},
"folderId": {
"__rl": true,
"value": "={{$('Validar entrada empleado').first().json.carpetaRaizId}}",
"mode": "id"
},
"options": {}
},
"type": "n8n-nodes-base.googleDrive",
"typeVersion": 3,
"position": [
2544,
640
],
"id": "8de4170e-1416-45c2-ad46-05aa24b01878",
"name": "Google Drive - Crear carpeta empleado",
"credentials": {
"googleDriveOAuth2Api": {
"id": "g23xdGLZRzBGqKgH",
"name": "Isaac - Google Drive"
}
}
},
{
"parameters": {
"jsCode": "const entrada =\n $('Validar entrada empleado')\n .first()\n .json || {};\n\nconst filaEmpleado =\n entrada.filaOriginal;\n\nconst filaConsolidado =\n Number(entrada.filaConsolidado);\n\nconst encabezadosOriginales =\n entrada.encabezadosOriginales;\n\nif (\n !Array.isArray(filaEmpleado) ||\n !Array.isArray(encabezadosOriginales)\n) {\n throw new Error(\n 'No se recibió correctamente la fila del Consolidado.'\n );\n}\n\nif (\n !Number.isInteger(filaConsolidado) ||\n filaConsolidado < 2\n) {\n throw new Error(\n `La fila del Consolidado no es válida: ${entrada.filaConsolidado}`\n );\n}\n\nconst dpi =\n $('Normalizar y validar datos DPI')\n .first()\n .json || {};\n\nconst bamboo =\n $('BambooHR - Datos completos')\n .first()\n .json || {};\n\nconst carpeta =\n $('Preparar datos carpeta empleado')\n .first()\n .json || {};\n\nconst dpiSubido =\n $('Validar DPI PDF subido')\n .first()\n .json || {};\n\n/*\n * ============================================================\n * FUNCIONES GENERALES\n * ============================================================\n */\n\nfunction normalizar(valor) {\n return String(valor ?? '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^a-z0-9\\s]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nconst encabezados =\n encabezadosOriginales.map(normalizar);\n\nfunction buscarColumna(...posiblesNombres) {\n const nombresNormalizados =\n posiblesNombres.map(normalizar);\n\n for (\n const nombre of nombresNormalizados\n ) {\n const index =\n encabezados.indexOf(nombre);\n\n if (index !== -1) {\n return index;\n }\n }\n\n return -1;\n}\n\nfunction obtenerCelda(\n fila,\n ...posiblesNombres\n) {\n const index =\n buscarColumna(...posiblesNombres);\n\n if (index === -1) {\n return '';\n }\n\n return fila[index] ?? '';\n}\n\n/*\n * ============================================================\n * MANEJO DE MONTOS\n * ============================================================\n */\n\nfunction parsearMonto(valor) {\n if (typeof valor === 'number') {\n return valor;\n }\n\n let texto = String(valor ?? '')\n .trim()\n .replace(/[Q$\\s]/gi, '');\n\n if (!texto) {\n return NaN;\n }\n\n /*\n * Ejemplo:\n * 49,354.69 → 49354.69\n */\n if (\n texto.includes(',') &&\n texto.includes('.')\n ) {\n texto =\n texto.replace(/,/g, '');\n } else if (\n texto.includes(',') &&\n !texto.includes('.')\n ) {\n /*\n * Puede venir:\n * 49354,69\n * o\n * 49,354\n */\n const partes =\n texto.split(',');\n\n if (\n partes.length === 2 &&\n partes[1].length <= 2\n ) {\n texto =\n partes[0].replace(/\\./g, '') +\n '.' +\n partes[1];\n } else {\n texto =\n texto.replace(/,/g, '');\n }\n }\n\n const resultado =\n Number(texto);\n\n return resultado;\n}\n\nconst unidades = [\n 'CERO',\n 'UNO',\n 'DOS',\n 'TRES',\n 'CUATRO',\n 'CINCO',\n 'SEIS',\n 'SIETE',\n 'OCHO',\n 'NUEVE'\n];\n\nconst especiales = {\n 10: 'DIEZ',\n 11: 'ONCE',\n 12: 'DOCE',\n 13: 'TRECE',\n 14: 'CATORCE',\n 15: 'QUINCE',\n 16: 'DIECISÉIS',\n 17: 'DIECISIETE',\n 18: 'DIECIOCHO',\n 19: 'DIECINUEVE',\n 20: 'VEINTE',\n 21: 'VEINTIUNO',\n 22: 'VEINTIDÓS',\n 23: 'VEINTITRÉS',\n 24: 'VEINTICUATRO',\n 25: 'VEINTICINCO',\n 26: 'VEINTISÉIS',\n 27: 'VEINTISIETE',\n 28: 'VEINTIOCHO',\n 29: 'VEINTINUEVE'\n};\n\nconst decenas = {\n 30: 'TREINTA',\n 40: 'CUARENTA',\n 50: 'CINCUENTA',\n 60: 'SESENTA',\n 70: 'SETENTA',\n 80: 'OCHENTA',\n 90: 'NOVENTA'\n};\n\nconst centenas = {\n 200: 'DOSCIENTOS',\n 300: 'TRESCIENTOS',\n 400: 'CUATROCIENTOS',\n 500: 'QUINIENTOS',\n 600: 'SEISCIENTOS',\n 700: 'SETECIENTOS',\n 800: 'OCHOCIENTOS',\n 900: 'NOVECIENTOS'\n};\n\nfunction apocoparUno(texto) {\n return String(texto)\n .replace(\n /VEINTIUNO$/g,\n 'VEINTIÚN'\n )\n .replace(\n / Y UNO$/g,\n ' Y UN'\n )\n .replace(\n / UNO$/g,\n ' UN'\n )\n .replace(\n /^UNO$/g,\n 'UN'\n );\n}\n\nfunction menorDeCien(numero) {\n if (numero < 10) {\n return unidades[numero];\n }\n\n if (especiales[numero]) {\n return especiales[numero];\n }\n\n const base =\n Math.floor(numero / 10) * 10;\n\n const resto =\n numero % 10;\n\n if (resto === 0) {\n return decenas[base];\n }\n\n return (\n `${decenas[base]} Y ` +\n `${unidades[resto]}`\n );\n}\n\nfunction menorDeMil(numero) {\n if (numero < 100) {\n return menorDeCien(numero);\n }\n\n if (numero === 100) {\n return 'CIEN';\n }\n\n const base =\n Math.floor(numero / 100) * 100;\n\n const resto =\n numero % 100;\n\n const inicio =\n base === 100\n ? 'CIENTO'\n : centenas[base];\n\n return resto\n ? `${inicio} ${menorDeCien(resto)}`\n : inicio;\n}\n\nfunction numeroALetras(numero) {\n numero =\n Math.trunc(Number(numero));\n\n if (!Number.isFinite(numero)) {\n throw new Error(\n `Número inválido para convertir a letras: ${numero}`\n );\n }\n\n if (numero === 0) {\n return 'CERO';\n }\n\n if (numero < 0) {\n return (\n `MENOS ${numeroALetras(\n Math.abs(numero)\n )}`\n );\n }\n\n if (numero < 1000) {\n return menorDeMil(numero);\n }\n\n if (numero < 1000000) {\n const miles =\n Math.floor(numero / 1000);\n\n const resto =\n numero % 1000;\n\n const textoMiles =\n miles === 1\n ? 'MIL'\n : (\n `${apocoparUno(\n numeroALetras(miles)\n )} MIL`\n );\n\n return resto\n ? (\n `${textoMiles} ` +\n `${numeroALetras(resto)}`\n )\n : textoMiles;\n }\n\n if (numero < 1000000000000) {\n const millones =\n Math.floor(numero / 1000000);\n\n const resto =\n numero % 1000000;\n\n const textoMillones =\n millones === 1\n ? 'UN MILLÓN'\n : (\n `${apocoparUno(\n numeroALetras(millones)\n )} MILLONES`\n );\n\n return resto\n ? (\n `${textoMillones} ` +\n `${numeroALetras(resto)}`\n )\n : textoMillones;\n }\n\n throw new Error(\n 'El monto excede el máximo soportado.'\n );\n}\n\nfunction montoALetras(monto) {\n const redondeado =\n Math.round(\n Number(monto) * 100\n ) / 100;\n\n const entero =\n Math.floor(redondeado);\n\n const centavos =\n Math.round(\n (redondeado - entero) * 100\n );\n\n let textoEntero;\n\n if (entero === 1) {\n textoEntero =\n 'UN QUETZAL';\n } else {\n textoEntero =\n `${apocoparUno(\n numeroALetras(entero)\n )} QUETZALES`;\n }\n\n return (\n `${textoEntero} CON ` +\n `${String(centavos).padStart(\n 2,\n '0'\n )}/100`\n );\n}\n\n/*\n * ============================================================\n * MANEJO DE FECHAS\n * ============================================================\n */\n\nfunction parsearFecha(valor) {\n if (!valor) {\n return null;\n }\n\n if (valor instanceof Date) {\n return valor;\n }\n\n const texto =\n String(valor).trim();\n\n /*\n * YYYY-MM-DD\n */\n let match = texto.match(\n /^(\\d{4})-(\\d{2})-(\\d{2})/\n );\n\n if (match) {\n return new Date(\n Date.UTC(\n Number(match[1]),\n Number(match[2]) - 1,\n Number(match[3])\n )\n );\n }\n\n /*\n * DD/MM/YYYY\n * DD-MM-YYYY\n */\n match = texto.match(\n /^(\\d{1,2})[\\/-](\\d{1,2})[\\/-](\\d{4})$/\n );\n\n if (match) {\n return new Date(\n Date.UTC(\n Number(match[3]),\n Number(match[2]) - 1,\n Number(match[1])\n )\n );\n }\n\n return null;\n}\n\nconst meses = [\n 'enero',\n 'febrero',\n 'marzo',\n 'abril',\n 'mayo',\n 'junio',\n 'julio',\n 'agosto',\n 'septiembre',\n 'octubre',\n 'noviembre',\n 'diciembre'\n];\n\nfunction fechaALetras(fecha) {\n if (!(fecha instanceof Date)) {\n return '';\n }\n\n return (\n `${numeroALetras(\n fecha.getUTCDate()\n ).toLowerCase()} de ` +\n `${meses[\n fecha.getUTCMonth()\n ]} de ` +\n `${numeroALetras(\n fecha.getUTCFullYear()\n ).toLowerCase()}`\n );\n}\n\nfunction fechaCorta(fecha) {\n return [\n String(\n fecha.getUTCDate()\n ).padStart(2, '0'),\n\n String(\n fecha.getUTCMonth() + 1\n ).padStart(2, '0'),\n\n fecha.getUTCFullYear()\n ].join('/');\n}\n\nfunction obtenerFechaGuatemala() {\n const partes =\n new Intl.DateTimeFormat(\n 'en-CA',\n {\n timeZone:\n 'America/Guatemala',\n\n year:\n 'numeric',\n\n month:\n '2-digit',\n\n day:\n '2-digit'\n }\n ).formatToParts(\n new Date()\n );\n\n const mapa = {};\n\n for (\n const parte of partes\n ) {\n mapa[parte.type] =\n parte.value;\n }\n\n return new Date(\n Date.UTC(\n Number(mapa.year),\n Number(mapa.month) - 1,\n Number(mapa.day)\n )\n );\n}\n\nfunction calcularEdad(\n fechaNacimiento,\n fechaReferencia\n) {\n let edad =\n fechaReferencia.getUTCFullYear() -\n fechaNacimiento.getUTCFullYear();\n\n const diferenciaMes =\n fechaReferencia.getUTCMonth() -\n fechaNacimiento.getUTCMonth();\n\n if (\n diferenciaMes < 0 ||\n (\n diferenciaMes === 0 &&\n fechaReferencia.getUTCDate() <\n fechaNacimiento.getUTCDate()\n )\n ) {\n edad--;\n }\n\n return edad;\n}\n\n/*\n * ============================================================\n * OBTENER DATOS DEL EMPLEADO\n * ============================================================\n */\n\nconst empleado = String(\n obtenerCelda(\n filaEmpleado,\n 'Nombre',\n 'Nombre completo',\n 'Colaborador'\n ) ||\n entrada.empleado ||\n dpi.empleado\n).trim();\n\nconst proyecto = String(\n obtenerCelda(\n filaEmpleado,\n 'Proyecto',\n 'Cliente',\n 'Cliente Proyecto',\n 'Proyecto Cliente'\n ) ||\n bamboo['4584'] ||\n bamboo.customPosicionCliente ||\n ''\n).trim();\n\nconst puesto = String(\n obtenerCelda(\n filaEmpleado,\n 'Puesto',\n 'Posición',\n 'Posicion',\n 'Cargo'\n ) ||\n bamboo.jobTitle ||\n bamboo.jobTitleName ||\n ''\n).trim();\n\nconst motivo = String(\n obtenerCelda(\n filaEmpleado,\n 'Motivo',\n 'Motivo de salida',\n 'Razón de salida',\n 'Razon de salida'\n ) ||\n bamboo['4384'] ||\n bamboo.terminationReason ||\n ''\n).trim();\n\nconst fechaInicioTexto =\n obtenerCelda(\n filaEmpleado,\n 'Fecha de inicio',\n 'Fecha inicio',\n 'Fecha de contratación',\n 'Fecha de contratacion',\n 'Fecha ingreso'\n ) ||\n bamboo.originalHireDate ||\n bamboo.hireDate ||\n '';\n\nconst fechaFinTexto =\n obtenerCelda(\n filaEmpleado,\n 'Fecha de finalización',\n 'Fecha de finalizacion',\n 'Fecha finalización',\n 'Fecha finalizacion',\n 'Fecha de salida',\n 'Fecha salida'\n ) ||\n bamboo.terminationDate ||\n '';\n\nconst montoOriginal =\n obtenerCelda(\n filaEmpleado,\n 'Valor a pagar',\n 'Total a pagar',\n 'Total pagar',\n 'Total'\n );\n\nconst numeroCheque = String(\n obtenerCelda(\n filaEmpleado,\n 'No. Cheque',\n 'No Cheque',\n 'Número de cheque',\n 'Numero de cheque'\n ) || ''\n).trim();\n\n/*\n * ============================================================\n * VALIDAR DATOS CRÍTICOS\n * ============================================================\n */\n\nif (!empleado) {\n throw new Error(\n 'El nombre del empleado está vacío.'\n );\n}\n\nif (!carpeta.carpetaEmpleadoId) {\n throw new Error(\n `No se obtuvo la carpeta de Google Drive de ${empleado}.`\n );\n}\n\nif (!dpiSubido.dpiDriveFileId) {\n throw new Error(\n `El PDF del DPI de ${empleado} no fue subido correctamente a Google Drive.`\n );\n}\n\nconst monto =\n parsearMonto(montoOriginal);\n\nif (!Number.isFinite(monto)) {\n throw new Error(\n `No pude interpretar el Total a pagar de ${empleado}: ${montoOriginal}`\n );\n}\n\nif (monto < 0) {\n throw new Error(\n `El Total a pagar de ${empleado} es negativo: ${monto}`\n );\n}\n\nconst fechaInicio =\n parsearFecha(fechaInicioTexto);\n\nconst fechaFin =\n parsearFecha(fechaFinTexto);\n\nconst fechaNacimiento =\n parsearFecha(\n dpi.fechaNacimiento ||\n bamboo.dateOfBirth\n );\n\nif (!fechaInicio) {\n throw new Error(\n `Fecha de inicio inválida para ${empleado}: ${fechaInicioTexto}`\n );\n}\n\nif (!fechaFin) {\n throw new Error(\n `Fecha de finalización inválida para ${empleado}: ${fechaFinTexto}`\n );\n}\n\nif (!fechaNacimiento) {\n throw new Error(\n `No se encontró una fecha de nacimiento válida para ${empleado}.`\n );\n}\n\nif (!dpi.numeroDpi) {\n throw new Error(\n `No se obtuvo el número de DPI de ${empleado}.`\n );\n}\n\nif (!dpi.estadoCivilLegal) {\n throw new Error(\n `No se obtuvo el estado civil de ${empleado}.`\n );\n}\n\nif (!dpi.nacionalidadLegal) {\n throw new Error(\n `No se obtuvo la nacionalidad de ${empleado}.`\n );\n}\n\nif (!motivo) {\n throw new Error(\n `No se encontró el motivo de salida de ${empleado}.`\n );\n}\n\n/*\n * ============================================================\n * PREPARAR DATOS CALCULADOS\n * ============================================================\n */\n\nconst fechaDocumento =\n obtenerFechaGuatemala();\n\nconst edad =\n calcularEdad(\n fechaNacimiento,\n fechaDocumento\n );\n\nif (\n !Number.isInteger(edad) ||\n edad < 0 ||\n edad > 120\n) {\n throw new Error(\n `La edad calculada de ${empleado} no es válida: ${edad}`\n );\n}\n\nconst montoLetras =\n montoALetras(monto);\n\nconst montoNumericoSinQ =\n monto.toLocaleString(\n 'en-US',\n {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2\n }\n );\n\n/*\n * La plantilla del voucher ya contiene\n * el símbolo Q antes del marcador.\n */\nconst montoNumericoVoucher =\n montoNumericoSinQ;\n\n/*\n * Algunos datos no bloquean la ejecución,\n * pero deben quedar registrados.\n */\nconst advertencias = [];\n\nif (!proyecto) {\n advertencias.push(\n 'No se encontró proyecto o cliente para el voucher.'\n );\n}\n\nif (!puesto) {\n advertencias.push(\n 'No se encontró el puesto del empleado.'\n );\n}\n\nif (!numeroCheque) {\n advertencias.push(\n 'El número de cheque está vacío.'\n );\n}\n\nconst municipioRenap = String(\n dpi.municipioRenap ||\n dpi.lugarNacimientoMunicipio ||\n ''\n).trim();\n\nconst departamentoRenap = String(\n dpi.departamentoRenap ||\n dpi.lugarNacimientoDepartamento ||\n ''\n).trim();\n\nif (\n !municipioRenap ||\n !departamentoRenap\n) {\n throw new Error(\n `No se obtuvo municipio y departamento para completar el RENAP de ${empleado}.`\n );\n}\n\nconst textoRenapCompleto =\n `extendido por el Registro Nacional de las Personas (RENAP) ` +\n `del Municipio de ${municipioRenap}, ` +\n `departamento de ${departamentoRenap},`;\n\n/*\n * ============================================================\n * REEMPLAZOS PARA EL FINIQUITO\n * ============================================================\n */\n\nconst reemplazosFiniquito = {\n 'extendido por el Registro Nacional de las Personas (RENAP),':\n textoRenapCompleto,\n\n '{{NOMBRE_COMPLETO}}':\n empleado,\n\n '{{EDAD_LETRAS}}':\n numeroALetras(\n edad\n ).toLowerCase(),\n\n '{{ESTADO_CIVIL}}':\n dpi.estadoCivilLegal,\n\n '{{NACIONALIDAD}}':\n dpi.nacionalidadLegal,\n\n '{{NO_DPI}}':\n String(\n dpi.numeroDpi\n ),\n\n '{{FECHA_INGRESO_LETRAS}}':\n fechaALetras(\n fechaInicio\n ),\n\n '{{FECHA_EGRESO_LETRAS}}':\n fechaALetras(\n fechaFin\n ),\n\n '{{MOTIVO_MAYUSCULA}}':\n motivo.toUpperCase(),\n\n '{{MONTO_LETRAS}}':\n montoLetras,\n\n '{{MONTO_NUMERICO}}':\n montoNumericoSinQ,\n\n '{{FECHA_DOCUMENTO_LETRAS}}':\n fechaALetras(\n fechaDocumento\n )\n};\n\n/*\n * ============================================================\n * REEMPLAZOS PARA EL VOUCHER\n * ============================================================\n */\n\nconst reemplazosVoucher = {\n '{{FECHA_DOCUMENTO}}':\n fechaCorta(\n fechaDocumento\n ),\n\n '{{PAIS}}':\n 'Guatemala',\n\n '{{NO_CHEQUE}}':\n numeroCheque,\n\n '{{MONTO_NUMERICO}}':\n montoNumericoVoucher,\n\n '{{NOMBRE_COMPLETO}}':\n empleado,\n\n '{{PROYECTO}}':\n proyecto,\n\n '{{MOTIVO}}':\n motivo\n ? (\n `Prestaciones laborales - ${motivo}`\n )\n : 'Prestaciones laborales'\n};\n\n/*\n * ============================================================\n * RESULTADO\n * ============================================================\n */\n\nreturn [\n {\n json: {\n filaConsolidado,\n\n empleado,\n\n nombreNormalizado:\n entrada.nombreNormalizado ||\n normalizar(empleado),\n\n employeeId:\n String(\n dpi.employeeId ||\n entrada.employeeId ||\n bamboo.id ||\n ''\n ),\n\n employeeNumber:\n String(\n bamboo.employeeNumber ||\n entrada.employeeNumber ||\n ''\n ),\n\n carpetaEmpleadoId:\n carpeta.carpetaEmpleadoId,\n\n carpetaEmpleadoNombre:\n carpeta.carpetaEmpleadoNombre ||\n empleado,\n\n carpetaEmpleadoUrl:\n carpeta.carpetaEmpleadoUrl ||\n (\n `https://drive.google.com/drive/folders/` +\n carpeta.carpetaEmpleadoId\n ),\n\n numeroDpi:\n String(\n dpi.numeroDpi\n ),\n\n proyecto,\n puesto,\n motivo,\n numeroCheque,\n\n fechaInicio:\n fechaInicioTexto,\n\n fechaFin:\n fechaFinTexto,\n\n fechaNacimiento:\n dpi.fechaNacimiento ||\n bamboo.dateOfBirth ||\n '',\n\n fechaDocumento:\n fechaCorta(\n fechaDocumento\n ),\n\n fechaDocumentoLetras:\n fechaALetras(\n fechaDocumento\n ),\n\n fechaContratacionBamboo:\n bamboo.originalHireDate ||\n bamboo.hireDate ||\n '',\n\n comentarioSituacionLaboral:\n bamboo['4046'] ||\n bamboo.employmentStatusComments ||\n '',\n\n edad,\n\n edadLetras:\n numeroALetras(\n edad\n ).toLowerCase(),\n\n estadoCivilLegal:\n dpi.estadoCivilLegal,\n\n nacionalidadLegal:\n dpi.nacionalidadLegal,\n\n municipioRenap,\n departamentoRenap,\n\n monto,\n\n montoNumericoSinQ,\n\n montoNumericoVoucher,\n\n montoLetras,\n\n dpiDriveFileId:\n dpiSubido.dpiDriveFileId,\n\n dpiDriveFileName:\n dpiSubido.dpiDriveFileName,\n\n dpiDriveFileUrl:\n dpiSubido.dpiDriveFileUrl,\n\n reemplazosFiniquito,\n\n reemplazosVoucher,\n\n advertencias,\n\n estadoPreparacion:\n advertencias.length === 0\n ? 'DATOS_DOCUMENTOS_COMPLETOS'\n : 'DATOS_DOCUMENTOS_CON_ADVERTENCIAS'\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
5568,
768
],
"id": "e0a6adec-4c6f-4fcb-b056-a4eb01ce14ca",
"name": "Preparar datos documentos"
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"numberInputs": 4,
"options": {
"clashHandling": {
"values": {
"resolveClash": "addSuffix"
}
},
"includeUnpaired": false
}
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
6720,
720
],
"id": "475e0940-a1d1-4fdc-a526-906e0bc9060f",
"name": "Esperar documentos del empleado"
},
{
"parameters": {
"jsCode": "const datos =\n $('Preparar datos documentos')\n .first()\n .json;\n\nconst finiquito =\n $('Copiar plantilla Finiquito')\n .first()\n .json;\n\nconst voucher =\n $('Copiar plantilla Voucher')\n .first()\n .json;\n\nconst formato =\n $('Copiar formato cheque')\n .first()\n .json;\n\nreturn [\n {\n json: {\n filaConsolidado:\n datos.filaConsolidado,\n\n empleado:\n datos.empleado,\n\n estado:\n 'PROCESADO_OK',\n\n documentosGenerados: true,\n\n carpetaId:\n datos.carpetaEmpleadoId,\n\n carpetaUrl:\n datos.carpetaEmpleadoUrl,\n\n dpiFileId:\n datos.dpiDriveFileId,\n\n finiquitoId:\n finiquito.id,\n\n voucherId:\n voucher.id,\n\n formatoChequeId:\n formato.id,\n\n numeroDpi:\n datos.numeroDpi,\n\n monto:\n datos.monto,\n\n pendienteDocumentoCheque:\n true\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
6992,
752
],
"id": "b245d4bd-3315-43f6-ae26-9e53f56ec373",
"name": "Resultado empleado procesado"
},
{
"parameters": {
"method": "POST",
"url": "=https://www.googleapis.com/drive/v3/files/{{$('Validar entrada empleado').first().json.plantillaFiniquitoId}}/copy?fields=id,name,mimeType,parents,webViewLink",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{\n {\n name:\n 'Finiquito - ' +\n $('Preparar datos documentos')\n .first()\n .json\n .empleado,\n\n parents: [\n $('Preparar datos documentos')\n .first()\n .json\n .carpetaEmpleadoId\n ]\n }\n}}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
5984,
768
],
"id": "4afdb912-a9d6-4275-857b-1a5e2823cc69",
"name": "Copiar plantilla Finiquito",
"credentials": {
"googleOAuth2Api": {
"id": "eHseMeH39kRcXgOF",
"name": "Google account 2"
}
}
},
{
"parameters": {
"method": "POST",
"url": "=https://www.googleapis.com/drive/v3/files/{{$('Validar entrada empleado').first().json.plantillaVoucherId}}/copy?fields=id,name,mimeType,parents,webViewLink",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{\n {\n name:\n 'Voucher - ' +\n $json.empleado,\n\n parents: [\n $json.carpetaEmpleadoId\n ]\n }\n}}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
5840,
448
],
"id": "8bb994c9-e4fd-4cd9-8de0-dd4d9401c632",
"name": "Copiar plantilla Voucher",
"credentials": {
"googleOAuth2Api": {
"id": "eHseMeH39kRcXgOF",
"name": "Google account 2"
}
}
},
{
"parameters": {
"method": "POST",
"url": "=https://www.googleapis.com/drive/v3/files/{{$('Validar entrada empleado').first().json.plantillaChequeId}}/copy?fields=id,name,mimeType,parents,webViewLink",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleDriveOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{\n {\n name:\n 'Formato impresión cheque - ' +\n $json.empleado,\n\n parents: [\n $json.carpetaEmpleadoId\n ]\n }\n}}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
5776,
1056
],
"id": "10fadc7c-c3a2-4e5d-b0d4-757d1ead7367",
"name": "Copiar formato cheque",
"credentials": {
"googleDriveOAuth2Api": {
"id": "g23xdGLZRzBGqKgH",
"name": "Isaac - Google Drive"
}
}
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "b6c0f64d-72d2-4df6-89f4-c0c1cfd96292",
"name": "carpetaEmpleadoId",
"value": "={{$json.id}}",
"type": "string"
},
{
"id": "9ce24c17-5fff-4063-8492-5fcd07bf90da",
"name": "carpetaEmpleadoNombre",
"value": "={{$json.name}}",
"type": "string"
},
{
"id": "ba4ff0c5-2b62-45dc-af0b-6072a45bfab0",
"name": "carpetaEmpleadoUrl",
"value": "=https://drive.google.com/drive/folders/{{$json.id}}",
"type": "string"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
2784,
640
],
"id": "a2d9c3e4-798f-4f03-b43c-036230009fa2",
"name": "Preparar datos carpeta empleado"
},
{
"parameters": {
"content": "# PROCESADOR INDIVIDUAL — FINIQUITOS GUATEMALA\n\nRecibe un empleado desde el flujo masivo y:\n\n1. Valida el NO., nombre, Google Sheet, carpeta raíz y plantillas.\n2. Busca al empleado en BambooHR con coincidencia exacta o flexible segura.\n3. Localiza y descarga el DPI.\n4. Gemini valida el DPI y extrae sus datos.\n5. Crea la carpeta del empleado y guarda el DPI.\n6. Genera y personaliza finiquito, voucher y formato de cheque.\n7. Actualiza las columnas X/Y de Consolidado.\n8. Agrega automáticamente municipio y departamento a las dos menciones del RENAP. Usa el lugar de expedición explícito y, cuando el DPI no lo muestra, el lugar de nacimiento visible, que fue el criterio usado en las correcciones anteriores.\n\nNo requiere editar la plantilla con marcadores nuevos: sustituye la frase RENAP existente en ambas apariciones.",
"height": 960,
"width": 3136,
"color": 2
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-3344,
288
],
"id": "90e08db3-833e-46cc-90d9-e5fe52838f6e",
"name": "Sticky Note"
},
{
"parameters": {
"content": "# 🪪 EXPEDIENTE, DPI Y CARPETA DEL EMPLEADO\n\nRecupera la información completa del empleado y guarda su documento de identificación dentro de una carpeta individual en Google Drive.\n\n## Consulta del expediente\n\nEl flujo obtiene desde BambooHR:\n\n- Datos personales.\n- Datos laborales.\n- Fechas relevantes.\n- Información necesaria para los documentos de finiquito.\n- Lista de archivos asociados al empleado.\n\n## Selección del DPI\n\nEntre los documentos disponibles:\n\n1. Identifica los posibles archivos de DPI.\n2. Filtra documentos que no corresponden.\n3. Selecciona un único candidato válido.\n4. Descarga el PDF original desde BambooHR.\n5. Verifica que exista contenido binario utilizable.\n\nNo debe continuar cuando:\n\n- No existe un DPI.\n- Existen varios candidatos ambiguos.\n- La descarga está vacía.\n- El archivo no puede procesarse correctamente.\n\n## Extracción con Gemini\n\nGemini analiza el documento para recuperar información útil del DPI.\n\nDespués del análisis:\n\n- Se limpia la respuesta de la IA.\n- Se normalizan nombres y números.\n- Se validan los campos detectados.\n- Se combinan los datos del DPI con el expediente de BambooHR.\n\nGemini complementa la información, pero no reemplaza las validaciones del workflow.\n\n## Carpeta y almacenamiento\n\nEl bloque crea una carpeta individual para el empleado y conserva:\n\n- ID de la carpeta.\n- Enlace de Google Drive.\n- Nombre normalizado del empleado.\n- Datos que utilizarán los documentos posteriores.\n\nEl DPI se guarda como PDF original dentro de esa carpeta, con un nombre identificable para facilitar su revisión.\n\n## Reglas\n\n- Mantener el DPI en formato PDF.\n- No modificar el documento original.\n- No subir el archivo antes de crear la carpeta.\n- No declarar éxito hasta verificar que el PDF existe en Drive.\n- Mantener relacionados el empleado, la carpeta y el DPI mediante sus IDs.",
"height": 1168,
"width": 3984,
"color": "#722C2C"
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
0,
0
],
"id": "b03d6699-08f1-4f4e-87ec-7329fd595130",
"name": "Sticky Note1"
},
{
"parameters": {
"content": "# 📄 GENERACIÓN DE DOCUMENTOS DEL FINIQUITO\n\nPrepara y genera todos los documentos requeridos para completar el expediente de finiquito del empleado.\n\n## Preparación de datos\n\nAntes de modificar las plantillas, el flujo consolida información como:\n\n- Nombre completo.\n- Datos del DPI.\n- Cargo y departamento.\n- Fechas de ingreso y salida.\n- Montos calculados.\n- Información bancaria o de pago.\n- Identificador del empleado.\n- Carpeta de Google Drive donde se guardarán los archivos.\n\n## Voucher\n\nEl flujo:\n\n1. Copia la plantilla oficial del voucher.\n2. Crea una versión exclusiva para el empleado.\n3. Reemplaza los campos de la plantilla.\n4. Conserva el documento dentro de la carpeta correspondiente.\n\n## Consolidado\n\nActualiza el archivo consolidado con la información procesada del empleado para mantener control general sobre los finiquitos generados.\n\n## Finiquito\n\nEl flujo:\n\n1. Copia la plantilla oficial del finiquito.\n2. Personaliza nombres, fechas, datos laborales y montos.\n3. Conserva el documento generado como parte del expediente individual.\n\n## Formato de cheque\n\nTambién:\n\n1. Copia el formato base del cheque.\n2. Completa los datos del beneficiario y el pago.\n3. Guarda el archivo junto con los demás documentos.\n\n## Sincronización y cierre\n\nLas diferentes ramas se ejecutan en paralelo.\n\nEl nodo de espera solamente permite finalizar cuando se han completado:\n\n- Voucher.\n- Consolidado.\n- Finiquito.\n- Formato de cheque.\n\nAl terminar, se genera un resultado único con el estado del empleado procesado y las referencias de sus documentos.\n\n## Reglas\n\n- No modificar las plantillas originales.\n- Crear una copia independiente por empleado.\n- No finalizar mientras falte alguno de los documentos obligatorios.\n- Mantener todos los archivos dentro de la carpeta individual.\n- No informar éxito cuando alguna rama haya fallado.\n- Conservar los IDs y enlaces de los documentos para el flujo principal.",
"height": 1280,
"width": 2832,
"color": "#33704B"
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
4432,
144
],
"id": "6420d6a0-2e6a-4fec-b276-deef1258bd93",
"name": "Sticky Note2"
}
],
"connections": {
"Identificar candidatos DPI": {
"main": [
[
{
"node": "Seleccionar DPI único",
"type": "main",
"index": 0
}
]
]
},
"Seleccionar DPI único": {
"main": [
[
{
"node": "BambooHR - Descargar DPI",
"type": "main",
"index": 0
}
]
]
},
"BambooHR - Descargar DPI": {
"main": [
[
{
"node": "Validar descarga DPI",
"type": "main",
"index": 0
}
]
]
},
"Validar descarga DPI": {
"main": [
[
{
"node": "Gemini - Extraer datos DPI",
"type": "main",
"index": 0
}
]
]
},
"Gemini - Extraer datos DPI": {
"main": [
[
{
"node": "Normalizar y validar datos DPI",
"type": "main",
"index": 0
}
]
]
},
"Normalizar y validar datos DPI": {
"main": [
[
{
"node": "Google Drive - Crear carpeta empleado",
"type": "main",
"index": 0
},
{
"node": "Merge",
"type": "main",
"index": 0
}
]
]
},
"Merge": {
"main": [
[
{
"node": "Preparar DPI PDF para Drive",
"type": "main",
"index": 0
}
]
]
},
"Preparar DPI PDF para Drive": {
"main": [
[
{
"node": "Google Drive - Subir DPI PDF",
"type": "main",
"index": 0
}
]
]
},
"Google Drive - Subir DPI PDF": {
"main": [
[
{
"node": "Validar DPI PDF subido",
"type": "main",
"index": 0
}
]
]
},
"Validar DPI PDF subido": {
"main": [
[
{
"node": "Preparar datos documentos",
"type": "main",
"index": 0
}
]
]
},
"Actualizar Consolidado": {
"main": [
[
{
"node": "Esperar documentos del empleado",
"type": "main",
"index": 1
}
]
]
},
"Entrada empleado": {
"main": [
[
{
"node": "Validar entrada empleado",
"type": "main",
"index": 0
}
]
]
},
"Validar entrada empleado": {
"main": [
[
{
"node": "Buscar carpeta existente",
"type": "main",
"index": 0
}
]
]
},
"Buscar carpeta existente": {
"main": [
[
{
"node": "Evaluar carpeta existente",
"type": "main",
"index": 0
}
]
]
},
"Evaluar carpeta existente": {
"main": [
[
{
"node": "¿Carpeta nueva?",
"type": "main",
"index": 0
}
]
]
},
"¿Carpeta nueva?": {
"main": [
[
{
"node": "BambooHR - Buscar empleado",
"type": "main",
"index": 0
}
],
[
{
"node": "Resultado carpeta existente",
"type": "main",
"index": 0
}
]
]
},
"BambooHR - Buscar empleado": {
"main": [
[
{
"node": "Seleccionar empleado exacto",
"type": "main",
"index": 0
}
]
]
},
"Seleccionar empleado exacto": {
"main": [
[
{
"node": "¿Empleado exacto?",
"type": "main",
"index": 0
}
]
]
},
"¿Empleado exacto?": {
"main": [
[
{
"node": "BambooHR - Datos completos",
"type": "main",
"index": 0
}
],
[
{
"node": "Resultado empleado no identificado",
"type": "main",
"index": 0
}
]
]
},
"BambooHR - Datos completos": {
"main": [
[
{
"node": "BambooHR - Listar documentos",
"type": "main",
"index": 0
}
]
]
},
"BambooHR - Listar documentos": {
"main": [
[
{
"node": "Identificar candidatos DPI",
"type": "main",
"index": 0
}
]
]
},
"Google Drive - Crear carpeta empleado": {
"main": [
[
{
"node": "Preparar datos carpeta empleado",
"type": "main",
"index": 0
}
]
]
},
"Preparar datos documentos": {
"main": [
[
{
"node": "Copiar plantilla Voucher",
"type": "main",
"index": 0
},
{
"node": "Copiar formato cheque",
"type": "main",
"index": 0
},
{
"node": "Actualizar Consolidado",
"type": "main",
"index": 0
},
{
"node": "Copiar plantilla Finiquito",
"type": "main",
"index": 0
}
]
]
},
"Personalizar Voucher": {
"main": [
[
{
"node": "Esperar documentos del empleado",
"type": "main",
"index": 0
}
]
]
},
"Personalizar Finiquito": {
"main": [
[
{
"node": "Esperar documentos del empleado",
"type": "main",
"index": 2
}
]
]
},
"Llenar formato cheque": {
"main": [
[
{
"node": "Esperar documentos del empleado",
"type": "main",
"index": 3
}
]
]
},
"Esperar documentos del empleado": {
"main": [
[
{
"node": "Resultado empleado procesado",
"type": "main",
"index": 0
}
]
]
},
"Copiar plantilla Finiquito": {
"main": [
[
{
"node": "Personalizar Finiquito",
"type": "main",
"index": 0
}
]
]
},
"Copiar plantilla Voucher": {
"main": [
[
{
"node": "Personalizar Voucher",
"type": "main",
"index": 0
}
]
]
},
"Copiar formato cheque": {
"main": [
[
{
"node": "Llenar formato cheque",
"type": "main",
"index": 0
}
]
]
},
"Preparar datos carpeta empleado": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 1
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"binaryMode": "separate"
},
"staticData": null,
"meta": null,
"versionId": "61974fea-84b3-419a-94e7-fd0c25850e62",
"activeVersionId": "61974fea-84b3-419a-94e7-fd0c25850e62",
"versionCounter": 28,
"triggerCount": 0,
"shared": [
{
"updatedAt": "2026-07-31T13:20:58.396Z",
"createdAt": "2026-07-31T13:20:58.396Z",
"role": "workflow:owner",
"workflowId": "osgTmV6UU3JmhogE",
"projectId": "PJpTANzTXIFibWsW",
"project": {
"updatedAt": "2026-04-22T14:25:09.686Z",
"createdAt": "2026-04-22T14:22:54.790Z",
"id": "PJpTANzTXIFibWsW",
"name": "Isaac Aracena <iaracena@gomezleemarketing.com>",
"type": "personal",
"icon": null,
"description": null,
"creatorId": "0a88c0b1-928e-4412-896e-c5d1c99b2029"
}
}
],
"tags": [],
"activeVersion": {
"updatedAt": "2026-07-31T14:09:53.000Z",
"createdAt": "2026-07-31T14:09:46.059Z",
"versionId": "61974fea-84b3-419a-94e7-fd0c25850e62",
"workflowId": "osgTmV6UU3JmhogE",
"nodes": [
{
"parameters": {
"jsCode": "const inputItems = $input.all();\n\nconst contextoEmpleado =\n $('Seleccionar empleado exacto').first().json || {};\n\nconst employeeId = String(\n contextoEmpleado.employeeId || ''\n).trim();\n\nconst nombreEmpleado = String(\n contextoEmpleado.empleado ||\n contextoEmpleado.bambooDisplayName ||\n ''\n).trim();\n\nif (!employeeId) {\n throw new Error(\n 'Seleccionar empleado exacto no devolvió un employeeId válido.'\n );\n}\n\nif (!nombreEmpleado) {\n throw new Error(\n 'Seleccionar empleado exacto no devolvió el nombre del empleado.'\n );\n}\n\nfunction normalizar(valor) {\n return String(valor ?? '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^a-z0-9./\\s_-]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction primerValor(objeto, claves) {\n for (const clave of claves) {\n const valor = objeto?.[clave];\n\n if (\n valor !== undefined &&\n valor !== null &&\n String(valor).trim() !== ''\n ) {\n return valor;\n }\n }\n\n return '';\n}\n\nfunction inferirExtension(nombreArchivo) {\n const nombre = String(nombreArchivo || '').trim();\n\n const coincidencia = nombre.match(\n /\\.([a-zA-Z0-9]{2,10})$/\n );\n\n return coincidencia\n ? coincidencia[1].toLowerCase()\n : '';\n}\n\nlet documentos = [];\n\nfor (const item of inputItems) {\n const json = item.json || {};\n\n /*\n * Caso normal del nodo BambooHR:\n * un documento por cada item.\n */\n const pareceDocumento =\n json.id ||\n json.documentId ||\n json.fileId ||\n json.employeeDocumentId ||\n json.name ||\n json.fileName ||\n json.filename ||\n json.originalFileName ||\n json.title;\n\n if (pareceDocumento) {\n documentos.push(json);\n continue;\n }\n\n /*\n * Casos alternativos:\n * BambooHR o n8n podrían devolver los documentos\n * agrupados dentro de un arreglo.\n */\n const arraysPosibles = [\n json.data,\n json.documents,\n json.employeeDocuments,\n json.files,\n json.results\n ].filter(Array.isArray);\n\n for (const array of arraysPosibles) {\n documentos.push(...array);\n }\n}\n\n/*\n * Eliminar documentos repetidos por ID.\n */\nconst documentosUnicos = [];\nconst idsVistos = new Set();\n\nfor (let index = 0; index < documentos.length; index++) {\n const documento = documentos[index] || {};\n\n const idDocumento = String(\n primerValor(documento, [\n 'documentId',\n 'employeeDocumentId',\n 'fileId',\n 'id'\n ])\n ).trim();\n\n const claveUnica = idDocumento\n ? `id:${idDocumento}`\n : `index:${index}`;\n\n if (idsVistos.has(claveUnica)) {\n continue;\n }\n\n idsVistos.add(claveUnica);\n documentosUnicos.push(documento);\n}\n\ndocumentos = documentosUnicos;\n\nif (!documentos.length) {\n return [\n {\n json: {\n employeeId,\n empleado: nombreEmpleado,\n\n documentosEncontrados: 0,\n candidatosDpiEncontrados: 0,\n\n documentId: '',\n nombre: '',\n categoria: '',\n extension: '',\n mimeType: '',\n\n puntajeDpi: 0,\n coincidencias: [],\n penalizaciones: [],\n\n esCandidatoDpi: false,\n\n estado: 'SIN_DOCUMENTOS_VISIBLES',\n\n observacion:\n 'BambooHR no devolvió documentos visibles para este empleado. Puede ser ausencia de archivos o falta de permisos.',\n\n metadataOriginal: null\n }\n }\n ];\n}\n\nconst analizados = documentos\n .map((documento, index) => {\n const documentId = String(\n primerValor(documento, [\n 'documentId',\n 'employeeDocumentId',\n 'fileId',\n 'id'\n ])\n ).trim();\n\n /*\n * Preferimos el nombre asignado en BambooHR.\n * originalFileName se usa como respaldo.\n */\n const nombre = String(\n primerValor(documento, [\n 'name',\n 'documentName',\n 'fileName',\n 'filename',\n 'title',\n 'originalFileName'\n ])\n ).trim();\n\n const nombreOriginal = String(\n primerValor(documento, [\n 'originalFileName',\n 'originalFilename',\n 'sourceFileName'\n ])\n ).trim();\n\n const categoria = String(\n primerValor(documento, [\n 'categoryName',\n 'documentCategory',\n 'category',\n 'folderName',\n 'sectionName',\n 'type'\n ])\n ).trim();\n\n let extension = String(\n primerValor(documento, [\n 'extension',\n 'fileExtension',\n 'ext'\n ])\n )\n .trim()\n .toLowerCase();\n\n if (!extension) {\n extension = inferirExtension(\n nombre || nombreOriginal\n );\n }\n\n const mimeType = String(\n primerValor(documento, [\n 'mimeType',\n 'contentType',\n 'fileType'\n ])\n )\n .trim()\n .toLowerCase();\n\n const textoCompleto = normalizar(\n [\n nombre,\n nombreOriginal,\n categoria,\n extension,\n mimeType\n ].join(' ')\n );\n\n let puntajeDpi = 0;\n\n const coincidencias = [];\n const penalizaciones = [];\n\n function sumar(puntos, etiqueta) {\n puntajeDpi += puntos;\n\n if (!coincidencias.includes(etiqueta)) {\n coincidencias.push(etiqueta);\n }\n }\n\n function restar(puntos, etiqueta) {\n puntajeDpi -= puntos;\n\n if (!penalizaciones.includes(etiqueta)) {\n penalizaciones.push(etiqueta);\n }\n }\n\n /*\n * Señales fuertes de que sí es DPI.\n */\n if (\n textoCompleto.includes(\n 'documento personal de identificacion'\n )\n ) {\n sumar(\n 150,\n 'Documento Personal de Identificación'\n );\n }\n\n if (\n /(^|[\\s_.-])dpi($|[\\s_.-])/.test(\n textoCompleto\n )\n ) {\n sumar(130, 'DPI');\n }\n\n if (textoCompleto.includes('cedula')) {\n sumar(110, 'Cédula');\n }\n\n if (textoCompleto.includes('renap')) {\n sumar(100, 'RENAP');\n }\n\n if (\n textoCompleto.includes(\n 'documento de identificacion'\n )\n ) {\n sumar(\n 90,\n 'Documento de identificación'\n );\n }\n\n if (\n textoCompleto.includes(\n 'identificacion personal'\n )\n ) {\n sumar(\n 80,\n 'Identificación personal'\n );\n }\n\n if (textoCompleto.includes('identificacion')) {\n sumar(60, 'Identificación');\n }\n\n if (textoCompleto.includes('identification')) {\n sumar(50, 'Identification');\n }\n\n if (textoCompleto.includes('frente')) {\n sumar(20, 'Frente');\n }\n\n if (textoCompleto.includes('reverso')) {\n sumar(20, 'Reverso');\n }\n\n if (textoCompleto.includes('anverso')) {\n sumar(20, 'Anverso');\n }\n\n /*\n * Tipo de archivo.\n */\n const esPdf =\n mimeType.includes('application/pdf') ||\n extension === 'pdf' ||\n textoCompleto.includes('.pdf');\n\n const esImagen =\n mimeType.includes('image/jpeg') ||\n mimeType.includes('image/jpg') ||\n mimeType.includes('image/png') ||\n ['jpg', 'jpeg', 'png'].includes(extension);\n\n if (esPdf) {\n sumar(10, 'PDF');\n }\n\n if (esImagen) {\n sumar(10, 'Imagen');\n }\n\n /*\n * Señales de documentos que normalmente\n * no son un DPI.\n */\n if (textoCompleto.includes('finiquito')) {\n restar(150, 'Finiquito');\n }\n\n if (\n textoCompleto.includes('oferta laboral') ||\n textoCompleto.includes('carta oferta') ||\n textoCompleto.includes('carta ofera')\n ) {\n restar(120, 'Oferta laboral');\n }\n\n if (\n textoCompleto.includes('curriculum') ||\n textoCompleto.includes('resume') ||\n textoCompleto.includes('hoja de vida')\n ) {\n restar(120, 'Currículum');\n }\n\n if (textoCompleto.includes('gafete')) {\n restar(100, 'Gafete');\n }\n\n if (textoCompleto.includes('suspension')) {\n restar(100, 'Suspensión');\n }\n\n if (\n textoCompleto.includes(\n 'entrega de activos'\n )\n ) {\n restar(100, 'Entrega de activos');\n }\n\n if (textoCompleto.includes('confidencialidad')) {\n restar(100, 'Confidencialidad');\n }\n\n if (\n textoCompleto.includes('comunicado') ||\n textoCompleto.includes('capacitacion')\n ) {\n restar(80, 'Comunicado o capacitación');\n }\n\n if (\n textoCompleto.includes('juicio') ||\n textoCompleto.includes('jucio')\n ) {\n restar(100, 'Documento judicial');\n }\n\n /*\n * Reglas finales:\n *\n * - Debe tener ID.\n * - Debe ser PDF o imagen.\n * - Debe alcanzar al menos 60 puntos.\n */\n const formatoPermitido =\n esPdf || esImagen;\n\n const esCandidatoDpi =\n Boolean(documentId) &&\n formatoPermitido &&\n puntajeDpi >= 60;\n\n return {\n originalIndex: index,\n\n employeeId,\n empleado: nombreEmpleado,\n\n documentId,\n nombre,\n nombreOriginal,\n categoria,\n extension,\n mimeType,\n\n esPdf,\n esImagen,\n\n puntajeDpi,\n coincidencias,\n penalizaciones,\n\n esCandidatoDpi,\n\n fechaCreacion:\n documento.dateCreated ||\n documento.createdAt ||\n null,\n\n creadoPor:\n documento.createdBy ||\n documento.creator ||\n null,\n\n metadataOriginal: documento\n };\n })\n .sort((a, b) => {\n /*\n * Primero los candidatos.\n * Después, el puntaje más alto.\n */\n if (\n a.esCandidatoDpi !==\n b.esCandidatoDpi\n ) {\n return a.esCandidatoDpi ? -1 : 1;\n }\n\n return b.puntajeDpi - a.puntajeDpi;\n });\n\nconst candidatos = analizados.filter(\n documento =>\n documento.esCandidatoDpi === true\n);\n\nreturn analizados.map(documento => ({\n json: {\n documentosEncontrados:\n analizados.length,\n\n candidatosDpiEncontrados:\n candidatos.length,\n\n estado:\n candidatos.length === 0\n ? 'SIN_CANDIDATO_DPI'\n : candidatos.length === 1\n ? 'CANDIDATO_DPI_UNICO'\n : 'MULTIPLES_CANDIDATOS_DPI',\n\n ...documento\n }\n}));"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1264,
784
],
"id": "7e043c2c-6708-4f96-96a3-abc7b953d6e1",
"name": "Identificar candidatos DPI"
},
{
"parameters": {
"jsCode": "const items = $input.all();\n\nfunction normalizarTexto(valor) {\n return String(valor ?? '')\n .trim()\n .toUpperCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^A-Z0-9\\s._-]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction obtenerTimestamp(documento) {\n const fecha =\n documento.fechaCreacion ||\n documento.metadataOriginal?.dateCreated ||\n documento.metadataOriginal?.createdAt ||\n '';\n\n const timestamp = Date.parse(fecha);\n\n return Number.isFinite(timestamp)\n ? timestamp\n : 0;\n}\n\nfunction obtenerIdNumerico(documento) {\n return Number(\n String(\n documento.documentId ||\n documento.fileId ||\n ''\n ).replace(/\\D/g, '')\n ) || 0;\n}\n\nfunction puntajeNombreDocumento(documento) {\n const texto = normalizarTexto(\n [\n documento.nombre,\n documento.nombreOriginal,\n documento.metadataOriginal?.name,\n documento.metadataOriginal?.originalFileName\n ].join(' ')\n );\n\n let puntos = 0;\n\n if (/(^|[\\s_.-])DPI($|[\\s_.-])/.test(texto)) {\n puntos += 100;\n }\n\n if (\n texto.includes(\n 'DOCUMENTO PERSONAL DE IDENTIFICACION'\n )\n ) {\n puntos += 90;\n }\n\n if (texto.includes('RENAP')) {\n puntos += 80;\n }\n\n if (texto.includes('CEDULA')) {\n puntos += 70;\n }\n\n if (texto.includes('IDENTIFICACION')) {\n puntos += 40;\n }\n\n if (texto.includes('CURRICULUM')) {\n puntos -= 150;\n }\n\n if (texto.includes('RESUME')) {\n puntos -= 150;\n }\n\n if (texto.includes('FINIQUITO')) {\n puntos -= 150;\n }\n\n if (texto.includes('OFERTA')) {\n puntos -= 120;\n }\n\n if (texto.includes('GAFETE')) {\n puntos -= 100;\n }\n\n return puntos;\n}\n\nconst candidatos = items\n .map((item, index) => ({\n ...(item.json || {}),\n indiceOriginal: index\n }))\n .filter(documento => {\n const documentId = String(\n documento.documentId ||\n documento.fileId ||\n ''\n ).trim();\n\n return (\n documento.esCandidatoDpi === true &&\n documentId !== ''\n );\n });\n\nif (candidatos.length === 0) {\n const primerItem = items[0]?.json || {};\n\n throw new Error(\n `No se encontró ningún documento candidato a DPI para ${\n primerItem.empleado || 'este empleado'\n }.`\n );\n}\n\n/*\n * Selecciones manuales confirmadas.\n *\n * Clave: employeeId de BambooHR\n * Valor: documentId correcto del DPI.\n */\nconst OVERRIDES_DPI = {};\n\nconst employeeIdActual = String(\n candidatos[0]?.employeeId || ''\n).trim();\n\nconst documentIdForzado =\n OVERRIDES_DPI[employeeIdActual] || '';\n\nlet dpi = null;\n\nif (documentIdForzado) {\n dpi = candidatos.find(candidato =>\n String(\n candidato.documentId ||\n candidato.fileId ||\n ''\n ).trim() === documentIdForzado\n );\n\n if (!dpi) {\n throw new Error(\n `Existe una selección manual para el empleado ${employeeIdActual}, ` +\n `pero no apareció el documento ${documentIdForzado} entre los candidatos.`\n );\n }\n}\n\n/*\n * Cuando no hay selección manual,\n * utilizamos la clasificación automática.\n */\nif (!dpi) {\n const candidatosOrdenados =\n [...candidatos].sort((a, b) => {\n const diferenciaNombre =\n puntajeNombreDocumento(b) -\n puntajeNombreDocumento(a);\n\n if (diferenciaNombre !== 0) {\n return diferenciaNombre;\n }\n\n const diferenciaPuntaje =\n Number(b.puntajeDpi || 0) -\n Number(a.puntajeDpi || 0);\n\n if (diferenciaPuntaje !== 0) {\n return diferenciaPuntaje;\n }\n\n const diferenciaFecha =\n obtenerTimestamp(b) -\n obtenerTimestamp(a);\n\n if (diferenciaFecha !== 0) {\n return diferenciaFecha;\n }\n\n return (\n obtenerIdNumerico(b) -\n obtenerIdNumerico(a)\n );\n });\n\n dpi = candidatosOrdenados[0];\n}\n\nconst employeeId = String(\n dpi.employeeId || ''\n).trim();\n\nconst documentId = String(\n dpi.documentId ||\n dpi.fileId ||\n ''\n).trim();\n\nconst nombreArchivo = String(\n dpi.nombre ||\n dpi.nombreOriginal ||\n dpi.metadataOriginal?.name ||\n dpi.metadataOriginal?.originalFileName ||\n ''\n).trim();\n\nif (!employeeId) {\n throw new Error(\n 'El candidato DPI seleccionado no contiene employeeId.'\n );\n}\n\nif (!documentId) {\n throw new Error(\n 'El candidato DPI seleccionado no contiene documentId/fileId.'\n );\n}\n\nif (!nombreArchivo) {\n throw new Error(\n 'El candidato DPI seleccionado no contiene nombre de archivo.'\n );\n}\n\nreturn [\n {\n json: {\n employeeId,\n empleado: dpi.empleado || '',\n\n documentId,\n dpiFileId: documentId,\n\n nombre: nombreArchivo,\n dpiFileName: nombreArchivo,\n\n dpiPuntaje:\n Number(dpi.puntajeDpi || 0),\n\n dpiCoincidencias:\n Array.isArray(dpi.coincidencias)\n ? dpi.coincidencias\n : [],\n\n dpiPenalizaciones:\n Array.isArray(dpi.penalizaciones)\n ? dpi.penalizaciones\n : [],\n\n dpiMetadataOriginal:\n dpi.metadataOriginal || {},\n\n candidatosDpiEncontrados:\n candidatos.length,\n\n seleccionAutomatica:\n !documentIdForzado,\n\n seleccionManualAplicada:\n Boolean(documentIdForzado),\n\n criterioSeleccion:\n documentIdForzado\n ? `Documento seleccionado mediante override: ${documentIdForzado}.`\n : 'Documento seleccionado automáticamente por nombre, puntaje y fecha.'\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1472,
784
],
"id": "8ffc069e-c62f-4a61-955b-d132c07df9c2",
"name": "Seleccionar DPI único"
},
{
"parameters": {
"resource": "employeeDocument",
"operation": "download",
"employeeId": "={{$json.employeeId}}",
"fileId": "={{$json.dpiFileId}}"
},
"type": "n8n-nodes-base.bambooHr",
"typeVersion": 1,
"position": [
1696,
784
],
"id": "cdd79134-df97-4213-9239-dc5eb3986209",
"name": "BambooHR - Descargar DPI",
"retryOnFail": true,
"credentials": {
"bambooHrApi": {
"id": "oH1KdJFaXZGHARuk",
"name": "BambooHR account"
}
}
},
{
"parameters": {
"jsCode": "const item = $input.first();\n\nconst binary = item.binary || {};\nconst binaryKeys = Object.keys(binary);\n\nif (binaryKeys.length === 0) {\n throw new Error(\n 'BambooHR no devolvió ningún archivo binario.'\n );\n}\n\nif (binaryKeys.length > 1) {\n throw new Error(\n `La descarga devolvió más de un archivo: ${binaryKeys.join(', ')}`\n );\n}\n\nconst binaryProperty = binaryKeys[0];\nconst file = binary[binaryProperty];\n\nconst seleccionado =\n $('Seleccionar DPI único')\n .first()\n .json || {};\n\nconst fileName = String(\n file.fileName ||\n seleccionado.dpiFileName ||\n seleccionado.nombre ||\n ''\n).trim();\n\nconst mimeType = String(\n file.mimeType || ''\n)\n .trim()\n .toLowerCase();\n\nif (!fileName) {\n throw new Error(\n 'El archivo descargado no tiene nombre.'\n );\n}\n\nconst nombreLower =\n fileName.toLowerCase();\n\nconst esPdf =\n mimeType.includes('application/pdf') ||\n mimeType.includes('pdf') ||\n nombreLower.endsWith('.pdf');\n\nconst esJpg =\n mimeType.includes('image/jpeg') ||\n mimeType.includes('image/jpg') ||\n nombreLower.endsWith('.jpg') ||\n nombreLower.endsWith('.jpeg');\n\nconst esPng =\n mimeType.includes('image/png') ||\n nombreLower.endsWith('.png');\n\nconst formatoPermitido =\n esPdf || esJpg || esPng;\n\nif (!formatoPermitido) {\n throw new Error(\n `El supuesto DPI no tiene un formato permitido. ` +\n `Archivo: ${fileName}; MIME: ${mimeType || 'no informado'}`\n );\n}\n\nlet extension = 'bin';\n\nif (esPdf) {\n extension = 'pdf';\n} else if (esPng) {\n extension = 'png';\n} else if (esJpg) {\n extension = 'jpg';\n}\n\nreturn [\n {\n json: {\n employeeId:\n seleccionado.employeeId,\n\n empleado:\n seleccionado.empleado,\n\n dpiFileId:\n seleccionado.dpiFileId,\n\n dpiFileName:\n fileName,\n\n dpiMimeType:\n mimeType ||\n (\n esPdf\n ? 'application/pdf'\n : esPng\n ? 'image/png'\n : 'image/jpeg'\n ),\n\n dpiExtension:\n extension,\n\n dpiEsPdf:\n esPdf,\n\n dpiEsImagen:\n esJpg || esPng,\n\n binaryProperty,\n\n descargaDpiValida:\n true,\n\n estado:\n 'DPI_DESCARGADO_PENDIENTE_REVISION_VISUAL'\n },\n\n binary:\n item.binary\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1904,
784
],
"id": "edae3402-ff58-43f1-9e33-7a8a6c1a382b",
"name": "Validar descarga DPI"
},
{
"parameters": {
"resource": "document",
"modelId": {
"__rl": true,
"value": "models/gemini-2.5-pro",
"mode": "list",
"cachedResultName": "models/gemini-2.5-pro"
},
"text": "Analiza exclusivamente el Documento Personal de Identificación de Guatemala\nincluido en este PDF.\n\nEl documento puede contener el frente y el reverso del DPI.\n\nDevuelve ÚNICAMENTE un objeto JSON válido.\nNo utilices Markdown.\nNo incluyas explicaciones antes ni después del JSON.\nNo envuelvas la respuesta en bloques ```json.\nNo inventes información.\nCuando un dato no sea visible o no pueda determinarse con seguridad, utiliza null.\n\nUsa exactamente esta estructura:\n\n{\n \"documentoEsDpiGuatemala\": true,\n \"tieneFrente\": true,\n \"tieneReverso\": true,\n \"nombreCompleto\": \"\",\n \"nombres\": \"\",\n \"apellidos\": \"\",\n \"numeroDpi\": \"\",\n \"nacionalidadCodigo\": \"\",\n \"sexo\": \"\",\n \"fechaNacimiento\": \"\",\n \"lugarNacimientoMunicipio\": \"\",\n \"lugarNacimientoDepartamento\": \"\",\n \"vecindadMunicipio\": \"\",\n \"vecindadDepartamento\": \"\",\n \"estadoCivil\": \"\",\n \"fechaEmision\": \"\",\n \"fechaVencimiento\": \"\",\n \"municipioExpedicion\": null,\n \"departamentoExpedicion\": null,\n \"observaciones\": []\n}\n\nReglas de extracción:\n\n1. numeroDpi:\n - Devuelve únicamente los 13 dígitos.\n - No incluyas espacios, guiones ni otros caracteres.\n\n2. fechaNacimiento, fechaEmision y fechaVencimiento:\n - Usa formato YYYY-MM-DD.\n - Si no es legible, devuelve null.\n\n3. sexo:\n - Usa exclusivamente MASCULINO, FEMENINO o null.\n\n4. estadoCivil:\n - Usa el valor visible en el documento, en mayúsculas.\n - Ejemplos: CASADO, SOLTERO.\n\n5. nacionalidadCodigo:\n - Conserva el código impreso en el documento, por ejemplo GTM.\n\n6. lugarNacimiento:\n - Extrae únicamente la sección titulada LUGAR DE NACIMIENTO.\n\n7. vecindad:\n - Extrae únicamente la sección titulada VECINDAD.\n\n8. municipioExpedicion y departamentoExpedicion:\n - No confundas lugar de nacimiento ni vecindad con lugar de expedición.\n - Si el documento no contiene explícitamente el lugar de expedición,\n devuelve null.\n\n9. tieneFrente y tieneReverso:\n - Indica true solo cuando cada lado realmente esté visible.\n\n10. observaciones:\n - Incluye advertencias de legibilidad, campos dudosos o inconsistencias.\n - Si no hay observaciones, devuelve un arreglo vacío.",
"inputType": "binary",
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.googleGemini",
"typeVersion": 1.2,
"position": [
2112,
784
],
"id": "0b09e33f-8a80-4c04-9136-c135d1c9ba58",
"name": "Gemini - Extraer datos DPI",
"credentials": {
"googlePalmApi": {
"id": "jvsXYwL6IOoY2DBU",
"name": "Isaac - Gemini Api Pago"
}
}
},
{
"parameters": {
"jsCode": "const geminiItem = $input.first();\nconst geminiJson = geminiItem.json || {};\n\nconst bamboo =\n $('BambooHR - Datos completos')\n .first()\n .json || {};\n\nconst seleccion =\n $('Seleccionar DPI único')\n .first()\n .json || {};\n\nconst descarga =\n $('Validar descarga DPI')\n .first();\n\n/*\n * ============================================================\n * FUNCIONES GENERALES\n * ============================================================\n */\n\nfunction obtenerTextoRespuesta(objeto) {\n const candidatos = [\n objeto.text,\n objeto.response,\n objeto.output,\n objeto.outputText,\n objeto.answer,\n objeto.result,\n objeto.content?.parts?.[0]?.text,\n objeto.candidates?.[0]?.content?.parts?.[0]?.text,\n objeto.data?.candidates?.[0]?.content?.parts?.[0]?.text\n ];\n\n for (const candidato of candidatos) {\n if (\n typeof candidato === 'string' &&\n candidato.trim() !== ''\n ) {\n return candidato.trim();\n }\n }\n\n return '';\n}\n\nfunction limpiarBloqueJson(valor) {\n return String(valor || '')\n .trim()\n .replace(/^```json\\s*/i, '')\n .replace(/^```\\s*/i, '')\n .replace(/\\s*```$/i, '')\n .trim();\n}\n\nfunction normalizarTexto(valor) {\n return String(valor ?? '')\n .trim()\n .toUpperCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^A-Z0-9\\s]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction soloDigitos(valor) {\n return String(valor ?? '')\n .replace(/\\D/g, '');\n}\n\nfunction normalizarFecha(valor) {\n if (\n valor === null ||\n valor === undefined ||\n String(valor).trim() === ''\n ) {\n return null;\n }\n\n const texto =\n String(valor).trim();\n\n if (\n /^\\d{4}-\\d{2}-\\d{2}$/.test(texto)\n ) {\n return texto;\n }\n\n return null;\n}\n\nfunction traducirSexoBamboo(valor) {\n const normalizado =\n normalizarTexto(valor);\n\n if (\n normalizado === 'MALE' ||\n normalizado === 'MASCULINO'\n ) {\n return 'MASCULINO';\n }\n\n if (\n normalizado === 'FEMALE' ||\n normalizado === 'FEMENINO'\n ) {\n return 'FEMENINO';\n }\n\n return '';\n}\n\nfunction traducirEstadoCivilBamboo(valor) {\n const normalizado =\n normalizarTexto(valor);\n\n const mapa = {\n MARRIED: 'CASADO',\n SINGLE: 'SOLTERO',\n DIVORCED: 'DIVORCIADO',\n WIDOWED: 'VIUDO',\n SEPARATED: 'SEPARADO',\n\n CASADO: 'CASADO',\n CASADA: 'CASADO',\n\n SOLTERO: 'SOLTERO',\n SOLTERA: 'SOLTERO',\n\n DIVORCIADO: 'DIVORCIADO',\n DIVORCIADA: 'DIVORCIADO',\n\n VIUDO: 'VIUDO',\n VIUDA: 'VIUDO',\n\n SEPARADO: 'SEPARADO',\n SEPARADA: 'SEPARADO'\n };\n\n return (\n mapa[normalizado] ||\n normalizado\n );\n}\n\n/*\n * Compara nombres de forma flexible.\n *\n * Esta validación es informativa.\n * Aunque devuelva false, no detendrá el flujo.\n */\nfunction compararNombresFlexible(\n nombreDpi,\n nombreBamboo\n) {\n const dpi =\n normalizarTexto(nombreDpi);\n\n const bambooNombre =\n normalizarTexto(nombreBamboo);\n\n if (!dpi || !bambooNombre) {\n return false;\n }\n\n if (dpi === bambooNombre) {\n return true;\n }\n\n const tokensDpi =\n dpi.split(' ').filter(Boolean);\n\n const tokensBamboo =\n bambooNombre\n .split(' ')\n .filter(Boolean);\n\n const comunes =\n tokensBamboo.filter(\n token =>\n tokensDpi.includes(token)\n );\n\n /*\n * Aceptamos como coincidencia informativa\n * cuando coinciden al menos 2 palabras\n * y representan una parte significativa\n * del nombre de BambooHR.\n */\n return (\n comunes.length >= 2 &&\n comunes.length >=\n Math.ceil(\n tokensBamboo.length * 0.6\n )\n );\n}\n\n/*\n * ============================================================\n * OBTENER JSON DE GEMINI\n * ============================================================\n */\n\nlet datosDpi;\n\nif (\n geminiJson.documentoEsDpiGuatemala !==\n undefined &&\n typeof geminiJson === 'object'\n) {\n datosDpi = geminiJson;\n} else {\n const textoRespuesta =\n obtenerTextoRespuesta(\n geminiJson\n );\n\n if (!textoRespuesta) {\n throw new Error(\n 'No pude localizar el texto de respuesta producido por Gemini.'\n );\n }\n\n const jsonLimpio =\n limpiarBloqueJson(\n textoRespuesta\n );\n\n try {\n datosDpi =\n JSON.parse(jsonLimpio);\n } catch (error) {\n throw new Error(\n 'Gemini no devolvió un JSON válido. Respuesta recibida: ' +\n jsonLimpio.slice(0, 1000)\n );\n }\n}\n\n/*\n * ============================================================\n * NORMALIZAR DATOS\n * ============================================================\n */\n\nconst numeroDpiExtraido =\n soloDigitos(\n datosDpi.numeroDpi\n );\n\nconst numeroDpiBamboo =\n soloDigitos(\n bamboo['4323'] ??\n bamboo.customIDCedula ??\n bamboo['customID/Cedula'] ??\n bamboo.employeeNumber ??\n ''\n );\n\nconst nombreDpi =\n normalizarTexto(\n datosDpi.nombreCompleto\n );\n\nconst nombreBamboo =\n normalizarTexto(\n [\n bamboo.firstName,\n bamboo.middleName,\n bamboo.lastName\n ]\n .filter(Boolean)\n .join(' ')\n );\n\nconst fechaNacimientoDpi =\n normalizarFecha(\n datosDpi.fechaNacimiento\n );\n\nconst fechaNacimientoBamboo =\n normalizarFecha(\n bamboo.dateOfBirth\n );\n\nconst sexoDpi =\n normalizarTexto(\n datosDpi.sexo\n );\n\nconst sexoBamboo =\n traducirSexoBamboo(\n bamboo.gender\n );\n\nconst estadoCivilDpi =\n normalizarTexto(\n datosDpi.estadoCivil\n );\n\nconst estadoCivilBamboo =\n traducirEstadoCivilBamboo(\n bamboo.maritalStatus\n );\n\n/*\n * ============================================================\n * VALIDACIONES\n * ============================================================\n */\n\nconst validaciones = {\n esDpiGuatemala:\n datosDpi.documentoEsDpiGuatemala ===\n true,\n\n contieneFrente:\n datosDpi.tieneFrente === true,\n\n contieneReverso:\n datosDpi.tieneReverso === true,\n\n dpiTiene13Digitos:\n numeroDpiExtraido.length === 13,\n\n dpiCoincideConBamboo:\n numeroDpiExtraido !== '' &&\n numeroDpiBamboo !== '' &&\n numeroDpiExtraido ===\n numeroDpiBamboo,\n\n /*\n * Estas validaciones quedan informativas.\n * No detienen la automatización.\n */\n nombreCoincide:\n compararNombresFlexible(\n nombreDpi,\n nombreBamboo\n ),\n\n fechaNacimientoCoincide:\n fechaNacimientoDpi !== null &&\n fechaNacimientoBamboo !== null &&\n fechaNacimientoDpi ===\n fechaNacimientoBamboo,\n\n sexoCoincide:\n sexoDpi !== '' &&\n sexoBamboo !== '' &&\n sexoDpi === sexoBamboo,\n\n estadoCivilCoincide:\n estadoCivilDpi !== '' &&\n estadoCivilBamboo !== '' &&\n estadoCivilDpi ===\n estadoCivilBamboo\n};\n\n/*\n * Solo estas validaciones detienen el flujo.\n *\n * La diferencia de nombre, fecha, sexo o estado civil\n * ya NO bloquea al empleado.\n */\nconst validacionesCriticas = [\n validaciones.esDpiGuatemala,\n validaciones.contieneFrente,\n validaciones.contieneReverso,\n validaciones.dpiTiene13Digitos\n];\n\nconst validacionCriticaCorrecta =\n validacionesCriticas.every(\n Boolean\n );\n\nif (!validacionCriticaCorrecta) {\n throw new Error(\n 'La validación crítica del DPI falló: ' +\n JSON.stringify(validaciones)\n );\n}\n\n/*\n * ============================================================\n * ADVERTENCIAS NO BLOQUEANTES\n * ============================================================\n */\n\nconst advertenciasValidacion = [];\n\nif (!validaciones.dpiCoincideConBamboo) {\n advertenciasValidacion.push(\n `El número de DPI extraído (${numeroDpiExtraido || 'vacío'}) no pudo validarse contra el campo disponible en BambooHR (${numeroDpiBamboo || 'vacío'}). Se continuará porque el documento fue descargado directamente del perfil confirmado del empleado.`\n );\n}\n\nif (!validaciones.nombreCoincide) {\n advertenciasValidacion.push(\n `El nombre del DPI (${datosDpi.nombreCompleto || 'vacío'}) no coincide exactamente con BambooHR (${nombreBamboo || 'vacío'}). Se continuará porque el número de DPI sí coincide.`\n );\n}\n\nif (\n fechaNacimientoDpi &&\n fechaNacimientoBamboo &&\n !validaciones.fechaNacimientoCoincide\n) {\n advertenciasValidacion.push(\n `La fecha de nacimiento del DPI (${fechaNacimientoDpi}) no coincide con BambooHR (${fechaNacimientoBamboo}).`\n );\n}\n\nif (\n sexoDpi &&\n sexoBamboo &&\n !validaciones.sexoCoincide\n) {\n advertenciasValidacion.push(\n `El sexo del DPI (${sexoDpi}) no coincide con BambooHR (${sexoBamboo}).`\n );\n}\n\nif (\n estadoCivilDpi &&\n estadoCivilBamboo &&\n !validaciones.estadoCivilCoincide\n) {\n advertenciasValidacion.push(\n `El estado civil del DPI (${estadoCivilDpi}) no coincide con BambooHR (${estadoCivilBamboo}).`\n );\n}\n\n/*\n * ============================================================\n * DATOS LEGALES\n * ============================================================\n */\n\nlet nacionalidadLegal = null;\n\nconst codigoNacionalidad =\n normalizarTexto(\n datosDpi.nacionalidadCodigo\n );\n\nif (\n codigoNacionalidad === 'GTM'\n) {\n if (\n sexoDpi === 'FEMENINO'\n ) {\n nacionalidadLegal =\n 'guatemalteca';\n } else if (\n sexoDpi === 'MASCULINO'\n ) {\n nacionalidadLegal =\n 'guatemalteco';\n } else {\n nacionalidadLegal =\n 'guatemalteco';\n }\n}\n\nlet estadoCivilLegal = null;\n\nif (estadoCivilDpi) {\n const estadoLower =\n estadoCivilDpi.toLowerCase();\n\n if (\n sexoDpi === 'FEMENINO' &&\n estadoLower.endsWith('o')\n ) {\n estadoCivilLegal =\n estadoLower.slice(0, -1) +\n 'a';\n } else {\n estadoCivilLegal =\n estadoLower;\n }\n}\n\n/*\n * Lugar que se insertará después de RENAP en el finiquito.\n *\n * El DPI normalmente no imprime una oficina de expedición.\n * Para mantener el mismo criterio usado en las correcciones\n * anteriores, se utiliza primero un lugar de expedición\n * explícito y, si no existe, el LUGAR DE NACIMIENTO visible\n * en el reverso del DPI.\n */\nconst municipioRenap = String(\n datosDpi.municipioExpedicion ||\n datosDpi.lugarNacimientoMunicipio ||\n ''\n).trim();\n\nconst departamentoRenap = String(\n datosDpi.departamentoExpedicion ||\n datosDpi.lugarNacimientoDepartamento ||\n ''\n).trim();\n\nconst lugarRenapDisponible =\n Boolean(\n municipioRenap &&\n departamentoRenap\n );\n\nif (!lugarRenapDisponible) {\n throw new Error(\n 'No se pudo extraer el municipio y departamento requeridos para completar las menciones del RENAP.'\n );\n}\n\nconst lugarExpedicionDisponible =\n Boolean(\n datosDpi.municipioExpedicion &&\n datosDpi.departamentoExpedicion\n );\n\nlet estadoProceso;\n\nif (\n advertenciasValidacion.length > 0\n) {\n estadoProceso =\n 'DPI_VALIDADO_CON_ADVERTENCIAS';\n} else {\n estadoProceso =\n 'DPI_VALIDADO_COMPLETO';\n}\n\n/*\n * ============================================================\n * RESULTADO\n * ============================================================\n */\n\nreturn [\n {\n json: {\n employeeId:\n seleccion.employeeId ??\n bamboo.id ??\n '',\n\n empleado:\n seleccion.empleado ??\n nombreBamboo,\n\n dpiFileId:\n seleccion.dpiFileId ??\n seleccion.documentId ??\n '',\n\n dpiFileName:\n seleccion.dpiFileName ??\n seleccion.nombre ??\n '',\n\n numeroDpi:\n numeroDpiExtraido,\n\n numeroDpiBamboo,\n\n nombreCompletoDpi:\n datosDpi.nombreCompleto ??\n null,\n\n nombreBamboo:\n nombreBamboo ||\n null,\n\n nombresDpi:\n datosDpi.nombres ??\n null,\n\n apellidosDpi:\n datosDpi.apellidos ??\n null,\n\n nacionalidadCodigo:\n datosDpi.nacionalidadCodigo ??\n null,\n\n nacionalidadLegal,\n\n sexo:\n sexoDpi || null,\n\n fechaNacimiento:\n fechaNacimientoDpi,\n\n estadoCivil:\n estadoCivilDpi || null,\n\n estadoCivilLegal,\n\n lugarNacimientoMunicipio:\n datosDpi.lugarNacimientoMunicipio ??\n null,\n\n lugarNacimientoDepartamento:\n datosDpi.lugarNacimientoDepartamento ??\n null,\n\n vecindadMunicipio:\n datosDpi.vecindadMunicipio ??\n null,\n\n vecindadDepartamento:\n datosDpi.vecindadDepartamento ??\n null,\n\n fechaEmision:\n normalizarFecha(\n datosDpi.fechaEmision\n ),\n\n fechaVencimiento:\n normalizarFecha(\n datosDpi.fechaVencimiento\n ),\n\n municipioExpedicion:\n datosDpi.municipioExpedicion ??\n null,\n\n departamentoExpedicion:\n datosDpi.departamentoExpedicion ??\n null,\n\n lugarExpedicionDisponible,\n\n municipioRenap,\n departamentoRenap,\n lugarRenapDisponible,\n\n validaciones,\n\n validacionCriticaCorrecta,\n\n advertenciasValidacion,\n\n observacionesGemini:\n Array.isArray(\n datosDpi.observaciones\n )\n ? datosDpi.observaciones\n : [],\n\n estadoProceso\n },\n\n binary:\n descarga.binary\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2336,
784
],
"id": "196fdad7-2cfa-4ca7-9357-1029cd544b8c",
"name": "Normalizar y validar datos DPI"
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"options": {
"clashHandling": {
"values": {
"resolveClash": "preferInput1"
}
},
"includeUnpaired": false
}
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
3088,
800
],
"id": "c0f1e06f-e44c-4af3-bd76-aee166330f60",
"name": "Merge"
},
{
"parameters": {
"jsCode": "const item = $input.first();\n\nif (!item.json) {\n throw new Error(\n 'No se recibieron los datos JSON del empleado.'\n );\n}\n\nconst binary = item.binary || {};\nconst binaryKeys = Object.keys(binary);\n\nif (binaryKeys.length === 0) {\n throw new Error(\n 'No se recibió el archivo del DPI.'\n );\n}\n\nconst binaryProperty =\n binaryKeys[0];\n\nconst archivoOriginal =\n binary[binaryProperty];\n\nconst empleado = String(\n item.json.empleado || ''\n).trim();\n\nconst carpetaEmpleadoId = String(\n item.json.carpetaEmpleadoId || ''\n).trim();\n\nif (!empleado) {\n throw new Error(\n 'Falta el nombre del empleado.'\n );\n}\n\nif (!carpetaEmpleadoId) {\n throw new Error(\n 'Falta el ID de la carpeta del empleado.'\n );\n}\n\nconst mimeType = String(\n archivoOriginal.mimeType ||\n item.json.dpiMimeType ||\n ''\n)\n .trim()\n .toLowerCase();\n\nconst nombreOriginal = String(\n archivoOriginal.fileName ||\n item.json.dpiFileName ||\n ''\n).trim();\n\nlet extension = 'pdf';\n\nif (\n mimeType.includes('image/jpeg') ||\n mimeType.includes('image/jpg') ||\n /\\.jpe?g$/i.test(nombreOriginal)\n) {\n extension = 'jpg';\n} else if (\n mimeType.includes('image/png') ||\n /\\.png$/i.test(nombreOriginal)\n) {\n extension = 'png';\n}\n\nconst mimeFinal =\n extension === 'pdf'\n ? 'application/pdf'\n : extension === 'png'\n ? 'image/png'\n : 'image/jpeg';\n\nconst nombreArchivo =\n `DPI - ${empleado}.${extension}`;\n\nreturn [\n {\n json: {\n ...item.json,\n\n dpiDriveFileName:\n nombreArchivo,\n\n dpiDriveFolderId:\n carpetaEmpleadoId,\n\n dpiDriveMimeTypeEsperado:\n mimeFinal,\n\n dpiDriveExtension:\n extension,\n\n estadoDpiDrive:\n 'LISTO_PARA_SUBIR_DPI'\n },\n\n binary: {\n data: {\n ...archivoOriginal,\n\n fileName:\n nombreArchivo,\n\n fileExtension:\n extension,\n\n mimeType:\n mimeFinal\n }\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
3296,
800
],
"id": "99f12ea1-80a5-4b99-bf8d-71fa6c0b795d",
"name": "Preparar DPI PDF para Drive"
},
{
"parameters": {
"name": "={{$json.dpiDriveFileName}}",
"driveId": {
"__rl": true,
"mode": "list",
"value": "My Drive"
},
"folderId": {
"__rl": true,
"value": "={{$json.carpetaEmpleadoId}}",
"mode": "id"
},
"options": {}
},
"type": "n8n-nodes-base.googleDrive",
"typeVersion": 3,
"position": [
3504,
800
],
"id": "ab0c7173-2e3c-4024-b191-e5ac04c26392",
"name": "Google Drive - Subir DPI PDF",
"credentials": {
"googleDriveOAuth2Api": {
"id": "g23xdGLZRzBGqKgH",
"name": "Isaac - Google Drive"
}
}
},
{
"parameters": {
"jsCode": "const archivoDrive =\n $input.first().json || {};\n\nconst preparado =\n $('Preparar DPI PDF para Drive')\n .first()\n .json || {};\n\nconst id = String(\n archivoDrive.id || ''\n).trim();\n\nconst nombre = String(\n archivoDrive.name || ''\n).trim();\n\nconst mimeType = String(\n archivoDrive.mimeType ||\n preparado.dpiDriveMimeTypeEsperado ||\n ''\n)\n .trim()\n .toLowerCase();\n\nconst parents = Array.isArray(\n archivoDrive.parents\n)\n ? archivoDrive.parents\n : [];\n\nif (!id) {\n throw new Error(\n 'Google Drive no devolvió el ID del archivo.'\n );\n}\n\nif (\n nombre &&\n preparado.dpiDriveFileName &&\n nombre !== preparado.dpiDriveFileName\n) {\n throw new Error(\n `Nombre incorrecto. Esperado: ` +\n `${preparado.dpiDriveFileName}. ` +\n `Recibido: ${nombre}.`\n );\n}\n\n/*\n * Se permiten DPI en:\n * - PDF\n * - JPG/JPEG\n * - PNG\n */\nconst mimePermitido =\n !mimeType ||\n mimeType === 'application/pdf' ||\n mimeType === 'image/jpeg' ||\n mimeType === 'image/jpg' ||\n mimeType === 'image/png';\n\nif (!mimePermitido) {\n throw new Error(\n `El DPI quedó con un formato no permitido. ` +\n `MIME recibido: ${mimeType || 'no informado'}`\n );\n}\n\nif (\n parents.length > 0 &&\n preparado.carpetaEmpleadoId &&\n !parents.includes(\n preparado.carpetaEmpleadoId\n )\n) {\n throw new Error(\n 'El DPI no quedó dentro de la carpeta correcta.'\n );\n}\n\nreturn [\n {\n json: {\n employeeId:\n preparado.employeeId,\n\n empleado:\n preparado.empleado,\n\n carpetaEmpleadoId:\n preparado.carpetaEmpleadoId,\n\n carpetaEmpleadoUrl:\n preparado.carpetaEmpleadoUrl,\n\n dpiDriveFileId:\n id,\n\n dpiDriveFileName:\n nombre ||\n preparado.dpiDriveFileName,\n\n dpiDriveMimeType:\n mimeType ||\n preparado.dpiDriveMimeTypeEsperado ||\n 'application/octet-stream',\n\n dpiDriveFileUrl:\n archivoDrive.webViewLink ||\n `https://drive.google.com/file/d/${id}/view`,\n\n numeroDpi:\n preparado.numeroDpi,\n\n estado:\n 'DPI_PDF_SUBIDO_CORRECTAMENTE'\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
3712,
800
],
"id": "e180e783-27df-407b-bc46-2ec69c45f3ef",
"name": "Validar DPI PDF subido"
},
{
"parameters": {
"method": "POST",
"url": "=https://sheets.googleapis.com/v4/spreadsheets/{{$('Validar entrada empleado').first().json.spreadsheetId}}/values:batchUpdate",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{\n {\n valueInputOption: 'USER_ENTERED',\n includeValuesInResponse: true,\n data: [\n {\n range:\n $('Validar entrada empleado')\n .first()\n .json\n .hojaConsolidado +\n '!X1:Y1',\n values: [\n [\n 'Valor en letras',\n 'No. DPI'\n ]\n ]\n },\n {\n range:\n $('Validar entrada empleado')\n .first()\n .json\n .hojaConsolidado +\n '!X' +\n $json.filaConsolidado,\n values: [\n [$json.montoLetras]\n ]\n },\n {\n range:\n $('Validar entrada empleado')\n .first()\n .json\n .hojaConsolidado +\n '!Y' +\n $json.filaConsolidado,\n values: [\n [$json.numeroDpi]\n ]\n }\n ]\n }\n}}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
5808,
672
],
"id": "fd279b0c-e41b-4b7d-9a50-dba7b6281113",
"name": "Actualizar Consolidado",
"credentials": {
"googleOAuth2Api": {
"id": "eHseMeH39kRcXgOF",
"name": "Google account 2"
}
}
},
{
"parameters": {
"method": "POST",
"url": "=https://docs.googleapis.com/v1/documents/{{$json.id}}:batchUpdate",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{\n {\n requests: Object.entries(\n $('Preparar datos documentos')\n .first()\n .json\n .reemplazosFiniquito\n ).map(([buscar, reemplazar]) => ({\n replaceAllText: {\n containsText: {\n text: buscar,\n matchCase: true\n },\n replaceText:\n String(reemplazar ?? '')\n }\n }))\n }\n}}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
6224,
768
],
"id": "2bd60729-2750-4784-aab7-0481884d8784",
"name": "Personalizar Finiquito",
"credentials": {
"googleOAuth2Api": {
"id": "eHseMeH39kRcXgOF",
"name": "Google account 2"
}
}
},
{
"parameters": {
"method": "POST",
"url": "=https://docs.googleapis.com/v1/documents/{{$json.id}}:batchUpdate",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleDocsOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{\n {\n requests: Object.entries(\n $('Preparar datos documentos')\n .first()\n .json\n .reemplazosVoucher\n ).map(([buscar, reemplazar]) => ({\n replaceAllText: {\n containsText: {\n text: buscar,\n matchCase: true\n },\n replaceText:\n String(reemplazar ?? '')\n }\n }))\n }\n}}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
6048,
448
],
"id": "a31d51bd-fdbb-430b-8047-dbd884126661",
"name": "Personalizar Voucher",
"credentials": {
"googleDocsOAuth2Api": {
"id": "2bGzUIxSb9LeUeNO",
"name": "Isaac - Google Docs"
}
}
},
{
"parameters": {
"method": "POST",
"url": "=https://sheets.googleapis.com/v4/spreadsheets/{{$json.id}}/values:batchUpdate",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleSheetsOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{\n {\n valueInputOption: 'USER_ENTERED',\n includeValuesInResponse: true,\n data: [\n {\n range: 'PLANTILLA!B5',\n values: [\n ['Guatemala']\n ]\n },\n {\n range: 'PLANTILLA!C5',\n values: [\n [\n $('Preparar datos documentos')\n .first()\n .json\n .fechaDocumento\n ]\n ]\n },\n {\n range: 'PLANTILLA!E5',\n values: [\n [\n $('Preparar datos documentos')\n .first()\n .json\n .monto\n ]\n ]\n },\n {\n range: 'PLANTILLA!B6',\n values: [\n [\n $('Preparar datos documentos')\n .first()\n .json\n .empleado\n ]\n ]\n },\n {\n range: 'PLANTILLA!B7',\n values: [\n [\n $('Preparar datos documentos')\n .first()\n .json\n .montoLetras\n ]\n ]\n }\n ]\n }\n}}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
5984,
1056
],
"id": "e546f20c-b92f-459a-b5db-a553300dbf41",
"name": "Llenar formato cheque",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
}
},
{
"parameters": {
"inputSource": "passthrough"
},
"type": "n8n-nodes-base.executeWorkflowTrigger",
"typeVersion": 1.1,
"position": [
-2400,
768
],
"id": "d2d0eb40-e9cf-4547-94b6-8b07af9c72f0",
"name": "Entrada empleado"
},
{
"parameters": {
"jsCode": "const entrada = $input.first().json || {};\n\nconst filaConsolidado = Number(\n entrada.filaConsolidado\n);\n\nconst numeroAprobacion = String(\n entrada.numeroAprobacion ||\n entrada.noPrestacion ||\n ''\n).trim();\n\nconst empleado = String(\n entrada.empleado || ''\n)\n .replace(/\\s+/g, ' ')\n .trim();\n\nconst primerNombre = String(\n entrada.primerNombre || ''\n).trim();\n\nconst encabezadosOriginales =\n entrada.encabezadosOriginales;\n\nconst filaOriginal =\n entrada.filaOriginal;\n\nconst spreadsheetId = String(\n entrada.spreadsheetId || ''\n).trim();\n\nconst hojaConsolidado = String(\n entrada.hojaConsolidado ||\n 'Consolidado'\n).trim();\n\nconst carpetaRaizId = String(\n entrada.carpetaRaizId || ''\n).trim();\n\nconst plantillaFiniquitoId = String(\n entrada.plantillaFiniquitoId || ''\n).trim();\n\nconst plantillaVoucherId = String(\n entrada.plantillaVoucherId || ''\n).trim();\n\nconst plantillaChequeId = String(\n entrada.plantillaChequeId || ''\n).trim();\n\nif (\n !Number.isInteger(filaConsolidado) ||\n filaConsolidado < 2\n) {\n throw new Error(\n 'La fila del Consolidado no es válida.'\n );\n}\n\nif (!numeroAprobacion) {\n throw new Error(\n 'No se recibió el NO. aprobado.'\n );\n}\n\nif (!empleado) {\n throw new Error(\n 'No se recibió el nombre del empleado.'\n );\n}\n\nif (!primerNombre) {\n throw new Error(\n `No pude determinar el primer nombre de ${empleado}.`\n );\n}\n\nif (\n !Array.isArray(encabezadosOriginales) ||\n !Array.isArray(filaOriginal)\n) {\n throw new Error(\n `No se recibió la fila completa del Consolidado para ${empleado}.`\n );\n}\n\nconst configuracionesObligatorias = {\n spreadsheetId,\n carpetaRaizId,\n plantillaFiniquitoId,\n plantillaVoucherId,\n plantillaChequeId\n};\n\nfor (\n const [campo, valor] of\n Object.entries(configuracionesObligatorias)\n) {\n if (\n !valor ||\n valor.startsWith('REEMPLAZAR_')\n ) {\n throw new Error(\n `Falta completar ${campo} para ${empleado}.`\n );\n }\n}\n\n// Escapar nombres para una búsqueda segura en Google Drive.\nconst nombreDriveEscapado = empleado\n .replace(/\\\\/g, '\\\\\\\\')\n .replace(/'/g, \"\\\\'\");\n\nreturn [\n {\n json: {\n ...entrada,\n\n filaConsolidado,\n numeroAprobacion,\n empleado,\n primerNombre,\n\n encabezadosOriginales,\n filaOriginal,\n\n spreadsheetId,\n hojaConsolidado,\n carpetaRaizId,\n\n plantillaFiniquitoId,\n plantillaVoucherId,\n plantillaChequeId,\n\n nombreDriveEscapado\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-2192,
768
],
"id": "8df4f9a0-c954-48b3-be66-4a29b310e43a",
"name": "Validar entrada empleado"
},
{
"parameters": {
"url": "https://www.googleapis.com/drive/v3/files",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleOAuth2Api",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "q",
"value": "={{ \"'\" + $json.carpetaRaizId + \"' in parents and name = '\" + $json.nombreDriveEscapado + \"' and mimeType = 'application/vnd.google-apps.folder' and trashed = false\" }}"
},
{
"name": "spaces",
"value": "drive"
},
{
"name": "pageSize",
"value": "10"
},
{
"name": "fields",
"value": "files(id,name,mimeType,parents,webViewLink)"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
-1984,
768
],
"id": "5799bb4a-a38b-4aab-a9e5-e39264d71664",
"name": "Buscar carpeta existente",
"credentials": {
"googleOAuth2Api": {
"id": "eHseMeH39kRcXgOF",
"name": "Google account 2"
}
}
},
{
"parameters": {
"jsCode": "const respuesta = $input.first().json || {};\n\nconst contexto =\n $('Validar entrada empleado')\n .first()\n .json;\n\nconst carpetas = Array.isArray(\n respuesta.files\n)\n ? respuesta.files\n : [];\n\nlet estadoCarpeta;\n\nif (carpetas.length === 0) {\n estadoCarpeta = 'NUEVA';\n} else if (carpetas.length === 1) {\n estadoCarpeta = 'EXISTENTE';\n} else {\n estadoCarpeta = 'DUPLICADA';\n}\n\nreturn [\n {\n json: {\n ...contexto,\n\n estadoCarpeta,\n carpetasEncontradas:\n carpetas.length,\n\n carpetaExistenteId:\n carpetas[0]?.id || null,\n\n carpetaExistenteUrl:\n carpetas[0]?.webViewLink ||\n (\n carpetas[0]?.id\n ? `https://drive.google.com/drive/folders/${carpetas[0].id}`\n : null\n )\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-1776,
768
],
"id": "a534cc50-ba01-44b5-8808-6ab50c805e0f",
"name": "Evaluar carpeta existente"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "71e15b33-ec4f-4d09-9b2d-687a465c5efe",
"leftValue": "={{$json.estadoCarpeta}}",
"rightValue": "NUEVA",
"operator": {
"type": "string",
"operation": "equals",
"name": "filter.operator.equals"
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
-1584,
768
],
"id": "f0fabaee-74f1-4a7e-982d-478cc44fbf6c",
"name": "¿Carpeta nueva?"
},
{
"parameters": {
"jsCode": "const item = $input.first().json;\n\nreturn [\n {\n json: {\n filaConsolidado:\n item.filaConsolidado,\n\n empleado:\n item.empleado,\n\n estado:\n item.estadoCarpeta === 'DUPLICADA'\n ? 'REQUIERE_REVISION_CARPETAS_DUPLICADAS'\n : 'OMITIDO_CARPETA_EXISTENTE',\n\n detalle:\n item.estadoCarpeta === 'DUPLICADA'\n ? `Se encontraron ${item.carpetasEncontradas} carpetas con el mismo nombre.`\n : 'Ya existe una carpeta para este empleado. No se creó ningún duplicado.',\n\n carpetaUrl:\n item.carpetaExistenteUrl,\n\n documentosGenerados: false\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-1376,
864
],
"id": "8303bd23-5879-4307-9974-dfb51b235320",
"name": "Resultado carpeta existente"
},
{
"parameters": {
"url": "https://glm.bamboohr.com/api/v1/employees",
"authentication": "genericCredentialType",
"genericAuthType": "httpBasicAuth",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "page[limit]",
"value": "250"
},
{
"name": "fields",
"value": "employeeNumber,middleName,homeEmail,workEmail,hireDate,originalHireDate,terminationDate,dateOfBirth,maritalStatus,gender"
},
{
"name": "filter[firstName]",
"value": "={{$json.primerNombre}}"
}
]
},
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Accept",
"value": "application/json"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
-1088,
768
],
"id": "283d93f6-84ce-4538-a989-20d15aa923c2",
"name": "BambooHR - Buscar empleado",
"credentials": {
"httpBasicAuth": {
"id": "7VrpNZ2jBLmiJ35q",
"name": "BambooHR GLM Full Access"
}
}
},
{
"parameters": {
"jsCode": "const entrada =\n $('Validar entrada empleado')\n .first()\n .json || {};\n\nconst items = $input.all();\n\nfunction normalizar(valor) {\n return String(valor ?? '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^a-z0-9\\s]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction obtenerNombreCompleto(empleado) {\n const nombreDirecto = String(\n empleado?.displayName ||\n empleado?.fullName ||\n empleado?.name ||\n ''\n ).trim();\n\n if (nombreDirecto) {\n return nombreDirecto;\n }\n\n return [\n empleado?.firstName,\n empleado?.middleName,\n empleado?.lastName\n ]\n .filter(valor =>\n valor !== undefined &&\n valor !== null &&\n String(valor).trim() !== ''\n )\n .join(' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction obtenerEmployeeId(empleado) {\n return String(\n empleado?.employeeId ||\n empleado?.id ||\n ''\n ).trim();\n}\n\nfunction tokens(valor) {\n return normalizar(valor)\n .split(' ')\n .filter(Boolean);\n}\n\nconst candidatos = [];\n\nfor (const item of items) {\n const json = item.json || {};\n\n if (Array.isArray(json.data)) {\n candidatos.push(...json.data);\n }\n\n if (Array.isArray(json.data?.data)) {\n candidatos.push(...json.data.data);\n }\n\n if (Array.isArray(json.employees)) {\n candidatos.push(...json.employees);\n }\n\n if (Array.isArray(json.results)) {\n candidatos.push(...json.results);\n }\n\n if (\n obtenerEmployeeId(json) &&\n !Array.isArray(json.data)\n ) {\n candidatos.push(json);\n }\n}\n\nconst candidatosUnicos = [];\nconst idsVistos = new Set();\n\nfor (const candidato of candidatos) {\n const employeeId =\n obtenerEmployeeId(candidato);\n\n if (\n !employeeId ||\n idsVistos.has(employeeId)\n ) {\n continue;\n }\n\n idsVistos.add(employeeId);\n candidatosUnicos.push(candidato);\n}\n\nconst nombreConsolidado = String(\n entrada.empleado || ''\n).trim();\n\nconst nombreNormalizado =\n normalizar(nombreConsolidado);\n\nconst filaConsolidado = Number(\n entrada.filaConsolidado || 0\n);\n\nif (!nombreNormalizado) {\n throw new Error(\n 'No se recibió el nombre del empleado desde el Consolidado.'\n );\n}\n\n/*\n * Excepciones manuales.\n *\n * Se deja vacío para este lote. Si BambooHR tuviera un nombre\n * incompleto, agrega aquí:\n *\n * 'nombre normalizado del consolidado': {\n * employeeId: '12345',\n * displayName: 'Nombre BambooHR'\n * }\n */\nconst OVERRIDES_EMPLEADOS = {\n 'melannie muguett rivera lopez': {\n employeeId: '50586',\n displayName:\n 'Melannie Muguett Rivera Lopez'\n },\n\n 'jordi josue paredes rac': {\n employeeId: '46824',\n displayName:\n 'Jordi Josué Paredes Rac'\n },\n\n 'julio eduardo zapeta': {\n employeeId: '50082',\n displayName:\n 'Eduardo Zapeta'\n }\n};\n\nconst override =\n OVERRIDES_EMPLEADOS[\n nombreNormalizado\n ] || null;\n\nlet seleccionado = null;\nlet seleccionManual = false;\nlet criterioSeleccion = '';\n\nif (override) {\n seleccionado =\n candidatosUnicos.find(candidato =>\n obtenerEmployeeId(candidato) ===\n String(override.employeeId)\n );\n\n if (!seleccionado) {\n seleccionado = {\n id: override.employeeId,\n employeeId: override.employeeId,\n displayName: override.displayName\n };\n }\n\n seleccionManual = true;\n criterioSeleccion =\n 'OVERRIDE_MANUAL';\n} else {\n const exactas =\n candidatosUnicos.filter(candidato =>\n normalizar(\n obtenerNombreCompleto(candidato)\n ) === nombreNormalizado\n );\n\n if (exactas.length === 1) {\n seleccionado = exactas[0];\n criterioSeleccion =\n 'COINCIDENCIA_EXACTA';\n } else {\n const objetivoTokens =\n tokens(nombreConsolidado);\n\n const evaluados =\n candidatosUnicos.map(candidato => {\n const displayName =\n obtenerNombreCompleto(candidato);\n\n const candidatoTokens =\n tokens(displayName);\n\n const comunes =\n objetivoTokens.filter(\n token =>\n candidatoTokens.includes(token)\n );\n\n const comunesUnicos =\n [...new Set(comunes)];\n\n const primerNombreCoincide =\n objetivoTokens[0] &&\n candidatoTokens[0] &&\n objetivoTokens[0] ===\n candidatoTokens[0];\n\n const ultimoObjetivo =\n objetivoTokens[\n objetivoTokens.length - 1\n ];\n\n const ultimoCandidato =\n candidatoTokens[\n candidatoTokens.length - 1\n ];\n\n let puntaje =\n comunesUnicos.length * 10;\n\n if (primerNombreCoincide) {\n puntaje += 12;\n }\n\n if (\n ultimoObjetivo &&\n candidatoTokens.includes(\n ultimoObjetivo\n )\n ) {\n puntaje += 8;\n }\n\n if (\n ultimoCandidato &&\n objetivoTokens.includes(\n ultimoCandidato\n )\n ) {\n puntaje += 5;\n }\n\n const coberturaObjetivo =\n objetivoTokens.length\n ? (\n comunesUnicos.length /\n objetivoTokens.length\n )\n : 0;\n\n return {\n candidato,\n employeeId:\n obtenerEmployeeId(candidato),\n displayName,\n comunes: comunesUnicos,\n puntaje,\n coberturaObjetivo,\n primerNombreCoincide\n };\n })\n .filter(item =>\n item.primerNombreCoincide &&\n item.comunes.length >= 2 &&\n item.coberturaObjetivo >= 0.5\n )\n .sort((a, b) =>\n b.puntaje - a.puntaje\n );\n\n const mejor = evaluados[0];\n const segundo = evaluados[1];\n\n const esUnico =\n mejor &&\n (\n !segundo ||\n mejor.puntaje >\n segundo.puntaje\n );\n\n if (esUnico) {\n seleccionado =\n mejor.candidato;\n\n criterioSeleccion =\n 'COINCIDENCIA_FLEXIBLE_UNICA';\n } else {\n return [\n {\n json: {\n ...entrada,\n\n filaConsolidado,\n empleado:\n nombreConsolidado,\n\n nombreConsolidado,\n\n estadoMatch: 'NO',\n\n estado:\n candidatosUnicos.length\n ? 'SIN_COINCIDENCIA_UNICA'\n : 'SIN_CANDIDATOS_BAMBOOHR',\n\n detalle:\n 'No fue posible identificar de forma única al empleado en BambooHR.',\n\n candidatos:\n candidatosUnicos.map(\n candidato => ({\n employeeId:\n obtenerEmployeeId(\n candidato\n ),\n\n displayName:\n obtenerNombreCompleto(\n candidato\n )\n })\n ),\n\n documentosGenerados: false\n }\n }\n ];\n }\n }\n}\n\nconst employeeId =\n obtenerEmployeeId(seleccionado);\n\nconst displayName =\n obtenerNombreCompleto(seleccionado) ||\n override?.displayName ||\n nombreConsolidado;\n\nif (!employeeId) {\n throw new Error(\n 'El empleado seleccionado no contiene employeeId.'\n );\n}\n\nreturn [\n {\n json: {\n ...entrada,\n ...seleccionado,\n\n filaConsolidado,\n\n id: employeeId,\n employeeId,\n\n empleado:\n nombreConsolidado,\n\n nombreConsolidado,\n displayName,\n\n estadoMatch: 'OK',\n\n estado:\n seleccionManual\n ? 'COINCIDENCIA_MANUAL_CONFIRMADA'\n : criterioSeleccion,\n\n coincidenciaExacta:\n criterioSeleccion ===\n 'COINCIDENCIA_EXACTA',\n\n seleccionManual,\n criterioSeleccion,\n\n detalle:\n seleccionManual\n ? `Vinculación manual confirmada: ${nombreConsolidado} → ${displayName}, employeeId ${employeeId}.`\n : `Empleado identificado: ${nombreConsolidado} → ${displayName}, employeeId ${employeeId}, criterio ${criterioSeleccion}.`,\n\n documentosGenerados: null\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-880,
768
],
"id": "12642efc-5576-4ea5-b587-5424d850b29d",
"name": "Seleccionar empleado exacto"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "a03d97b4-6869-4d4a-a24b-17f214009052",
"leftValue": "={{$json.estadoMatch}}",
"rightValue": "OK",
"operator": {
"type": "string",
"operation": "equals",
"name": "filter.operator.equals"
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
-672,
768
],
"id": "8d98a951-e561-4744-98d2-1992b47c1ff5",
"name": "¿Empleado exacto?"
},
{
"parameters": {
"jsCode": "const item =\n $input.first().json || {};\n\nreturn [\n {\n json: {\n filaConsolidado:\n item.filaConsolidado,\n\n numeroAprobacion:\n item.numeroAprobacion || '',\n\n empleado:\n item.empleado,\n\n estado:\n item.estadoMatch ||\n item.estado ||\n 'SIN_COINCIDENCIA',\n\n detalle:\n item.detalle ||\n 'No fue posible identificar de forma única al empleado en BambooHR.',\n\n candidatos:\n item.candidatosEncontrados ||\n item.candidatos ||\n [],\n\n documentosGenerados: false\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-464,
864
],
"id": "a647fa32-c9c1-4340-b735-8880ebe0b2d4",
"name": "Resultado empleado no identificado"
},
{
"parameters": {
"url": "=https://glm.bamboohr.com/api/v1/employees/{{ $('Seleccionar empleado exacto').first().json.employeeId }}",
"authentication": "genericCredentialType",
"genericAuthType": "httpBasicAuth",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "fields",
"value": "firstName,middleName,lastName,preferredName,employeeNumber,status,workEmail,homeEmail,gender,dateOfBirth,maritalStatus,address1,address2,city,state,zipcode,country,hireDate,originalHireDate,terminationDate,jobTitle,department,division,location,employmentHistoryStatus,employeeStatusDate,4323,4440,4584,4725,4726,4046,4047,4383,4384,4385,4667"
},
{
"name": "onlyCurrent",
"value": "true"
}
]
},
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Accept",
"value": "application/json"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
864,
784
],
"id": "f61a80f1-a6be-45f3-b826-a80e263d80b1",
"name": "BambooHR - Datos completos",
"credentials": {
"httpBasicAuth": {
"id": "7VrpNZ2jBLmiJ35q",
"name": "BambooHR GLM Full Access"
}
}
},
{
"parameters": {
"resource": "employeeDocument",
"operation": "getAll",
"employeeId": "={{$('Seleccionar empleado exacto').first().json.employeeId}}",
"returnAll": true
},
"type": "n8n-nodes-base.bambooHr",
"typeVersion": 1,
"position": [
1072,
784
],
"id": "00ac1c8e-b3b9-46be-8bf2-c7f9dbbe549a",
"name": "BambooHR - Listar documentos",
"alwaysOutputData": true,
"credentials": {
"bambooHrApi": {
"id": "oH1KdJFaXZGHARuk",
"name": "BambooHR account"
}
}
},
{
"parameters": {
"resource": "folder",
"name": "={{$json.empleado}}",
"driveId": {
"__rl": true,
"mode": "list",
"value": "My Drive"
},
"folderId": {
"__rl": true,
"value": "={{$('Validar entrada empleado').first().json.carpetaRaizId}}",
"mode": "id"
},
"options": {}
},
"type": "n8n-nodes-base.googleDrive",
"typeVersion": 3,
"position": [
2544,
640
],
"id": "8de4170e-1416-45c2-ad46-05aa24b01878",
"name": "Google Drive - Crear carpeta empleado",
"credentials": {
"googleDriveOAuth2Api": {
"id": "g23xdGLZRzBGqKgH",
"name": "Isaac - Google Drive"
}
}
},
{
"parameters": {
"jsCode": "const entrada =\n $('Validar entrada empleado')\n .first()\n .json || {};\n\nconst filaEmpleado =\n entrada.filaOriginal;\n\nconst filaConsolidado =\n Number(entrada.filaConsolidado);\n\nconst encabezadosOriginales =\n entrada.encabezadosOriginales;\n\nif (\n !Array.isArray(filaEmpleado) ||\n !Array.isArray(encabezadosOriginales)\n) {\n throw new Error(\n 'No se recibió correctamente la fila del Consolidado.'\n );\n}\n\nif (\n !Number.isInteger(filaConsolidado) ||\n filaConsolidado < 2\n) {\n throw new Error(\n `La fila del Consolidado no es válida: ${entrada.filaConsolidado}`\n );\n}\n\nconst dpi =\n $('Normalizar y validar datos DPI')\n .first()\n .json || {};\n\nconst bamboo =\n $('BambooHR - Datos completos')\n .first()\n .json || {};\n\nconst carpeta =\n $('Preparar datos carpeta empleado')\n .first()\n .json || {};\n\nconst dpiSubido =\n $('Validar DPI PDF subido')\n .first()\n .json || {};\n\n/*\n * ============================================================\n * FUNCIONES GENERALES\n * ============================================================\n */\n\nfunction normalizar(valor) {\n return String(valor ?? '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^a-z0-9\\s]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nconst encabezados =\n encabezadosOriginales.map(normalizar);\n\nfunction buscarColumna(...posiblesNombres) {\n const nombresNormalizados =\n posiblesNombres.map(normalizar);\n\n for (\n const nombre of nombresNormalizados\n ) {\n const index =\n encabezados.indexOf(nombre);\n\n if (index !== -1) {\n return index;\n }\n }\n\n return -1;\n}\n\nfunction obtenerCelda(\n fila,\n ...posiblesNombres\n) {\n const index =\n buscarColumna(...posiblesNombres);\n\n if (index === -1) {\n return '';\n }\n\n return fila[index] ?? '';\n}\n\n/*\n * ============================================================\n * MANEJO DE MONTOS\n * ============================================================\n */\n\nfunction parsearMonto(valor) {\n if (typeof valor === 'number') {\n return valor;\n }\n\n let texto = String(valor ?? '')\n .trim()\n .replace(/[Q$\\s]/gi, '');\n\n if (!texto) {\n return NaN;\n }\n\n /*\n * Ejemplo:\n * 49,354.69 → 49354.69\n */\n if (\n texto.includes(',') &&\n texto.includes('.')\n ) {\n texto =\n texto.replace(/,/g, '');\n } else if (\n texto.includes(',') &&\n !texto.includes('.')\n ) {\n /*\n * Puede venir:\n * 49354,69\n * o\n * 49,354\n */\n const partes =\n texto.split(',');\n\n if (\n partes.length === 2 &&\n partes[1].length <= 2\n ) {\n texto =\n partes[0].replace(/\\./g, '') +\n '.' +\n partes[1];\n } else {\n texto =\n texto.replace(/,/g, '');\n }\n }\n\n const resultado =\n Number(texto);\n\n return resultado;\n}\n\nconst unidades = [\n 'CERO',\n 'UNO',\n 'DOS',\n 'TRES',\n 'CUATRO',\n 'CINCO',\n 'SEIS',\n 'SIETE',\n 'OCHO',\n 'NUEVE'\n];\n\nconst especiales = {\n 10: 'DIEZ',\n 11: 'ONCE',\n 12: 'DOCE',\n 13: 'TRECE',\n 14: 'CATORCE',\n 15: 'QUINCE',\n 16: 'DIECISÉIS',\n 17: 'DIECISIETE',\n 18: 'DIECIOCHO',\n 19: 'DIECINUEVE',\n 20: 'VEINTE',\n 21: 'VEINTIUNO',\n 22: 'VEINTIDÓS',\n 23: 'VEINTITRÉS',\n 24: 'VEINTICUATRO',\n 25: 'VEINTICINCO',\n 26: 'VEINTISÉIS',\n 27: 'VEINTISIETE',\n 28: 'VEINTIOCHO',\n 29: 'VEINTINUEVE'\n};\n\nconst decenas = {\n 30: 'TREINTA',\n 40: 'CUARENTA',\n 50: 'CINCUENTA',\n 60: 'SESENTA',\n 70: 'SETENTA',\n 80: 'OCHENTA',\n 90: 'NOVENTA'\n};\n\nconst centenas = {\n 200: 'DOSCIENTOS',\n 300: 'TRESCIENTOS',\n 400: 'CUATROCIENTOS',\n 500: 'QUINIENTOS',\n 600: 'SEISCIENTOS',\n 700: 'SETECIENTOS',\n 800: 'OCHOCIENTOS',\n 900: 'NOVECIENTOS'\n};\n\nfunction apocoparUno(texto) {\n return String(texto)\n .replace(\n /VEINTIUNO$/g,\n 'VEINTIÚN'\n )\n .replace(\n / Y UNO$/g,\n ' Y UN'\n )\n .replace(\n / UNO$/g,\n ' UN'\n )\n .replace(\n /^UNO$/g,\n 'UN'\n );\n}\n\nfunction menorDeCien(numero) {\n if (numero < 10) {\n return unidades[numero];\n }\n\n if (especiales[numero]) {\n return especiales[numero];\n }\n\n const base =\n Math.floor(numero / 10) * 10;\n\n const resto =\n numero % 10;\n\n if (resto === 0) {\n return decenas[base];\n }\n\n return (\n `${decenas[base]} Y ` +\n `${unidades[resto]}`\n );\n}\n\nfunction menorDeMil(numero) {\n if (numero < 100) {\n return menorDeCien(numero);\n }\n\n if (numero === 100) {\n return 'CIEN';\n }\n\n const base =\n Math.floor(numero / 100) * 100;\n\n const resto =\n numero % 100;\n\n const inicio =\n base === 100\n ? 'CIENTO'\n : centenas[base];\n\n return resto\n ? `${inicio} ${menorDeCien(resto)}`\n : inicio;\n}\n\nfunction numeroALetras(numero) {\n numero =\n Math.trunc(Number(numero));\n\n if (!Number.isFinite(numero)) {\n throw new Error(\n `Número inválido para convertir a letras: ${numero}`\n );\n }\n\n if (numero === 0) {\n return 'CERO';\n }\n\n if (numero < 0) {\n return (\n `MENOS ${numeroALetras(\n Math.abs(numero)\n )}`\n );\n }\n\n if (numero < 1000) {\n return menorDeMil(numero);\n }\n\n if (numero < 1000000) {\n const miles =\n Math.floor(numero / 1000);\n\n const resto =\n numero % 1000;\n\n const textoMiles =\n miles === 1\n ? 'MIL'\n : (\n `${apocoparUno(\n numeroALetras(miles)\n )} MIL`\n );\n\n return resto\n ? (\n `${textoMiles} ` +\n `${numeroALetras(resto)}`\n )\n : textoMiles;\n }\n\n if (numero < 1000000000000) {\n const millones =\n Math.floor(numero / 1000000);\n\n const resto =\n numero % 1000000;\n\n const textoMillones =\n millones === 1\n ? 'UN MILLÓN'\n : (\n `${apocoparUno(\n numeroALetras(millones)\n )} MILLONES`\n );\n\n return resto\n ? (\n `${textoMillones} ` +\n `${numeroALetras(resto)}`\n )\n : textoMillones;\n }\n\n throw new Error(\n 'El monto excede el máximo soportado.'\n );\n}\n\nfunction montoALetras(monto) {\n const redondeado =\n Math.round(\n Number(monto) * 100\n ) / 100;\n\n const entero =\n Math.floor(redondeado);\n\n const centavos =\n Math.round(\n (redondeado - entero) * 100\n );\n\n let textoEntero;\n\n if (entero === 1) {\n textoEntero =\n 'UN QUETZAL';\n } else {\n textoEntero =\n `${apocoparUno(\n numeroALetras(entero)\n )} QUETZALES`;\n }\n\n return (\n `${textoEntero} CON ` +\n `${String(centavos).padStart(\n 2,\n '0'\n )}/100`\n );\n}\n\n/*\n * ============================================================\n * MANEJO DE FECHAS\n * ============================================================\n */\n\nfunction parsearFecha(valor) {\n if (!valor) {\n return null;\n }\n\n if (valor instanceof Date) {\n return valor;\n }\n\n const texto =\n String(valor).trim();\n\n /*\n * YYYY-MM-DD\n */\n let match = texto.match(\n /^(\\d{4})-(\\d{2})-(\\d{2})/\n );\n\n if (match) {\n return new Date(\n Date.UTC(\n Number(match[1]),\n Number(match[2]) - 1,\n Number(match[3])\n )\n );\n }\n\n /*\n * DD/MM/YYYY\n * DD-MM-YYYY\n */\n match = texto.match(\n /^(\\d{1,2})[\\/-](\\d{1,2})[\\/-](\\d{4})$/\n );\n\n if (match) {\n return new Date(\n Date.UTC(\n Number(match[3]),\n Number(match[2]) - 1,\n Number(match[1])\n )\n );\n }\n\n return null;\n}\n\nconst meses = [\n 'enero',\n 'febrero',\n 'marzo',\n 'abril',\n 'mayo',\n 'junio',\n 'julio',\n 'agosto',\n 'septiembre',\n 'octubre',\n 'noviembre',\n 'diciembre'\n];\n\nfunction fechaALetras(fecha) {\n if (!(fecha instanceof Date)) {\n return '';\n }\n\n return (\n `${numeroALetras(\n fecha.getUTCDate()\n ).toLowerCase()} de ` +\n `${meses[\n fecha.getUTCMonth()\n ]} de ` +\n `${numeroALetras(\n fecha.getUTCFullYear()\n ).toLowerCase()}`\n );\n}\n\nfunction fechaCorta(fecha) {\n return [\n String(\n fecha.getUTCDate()\n ).padStart(2, '0'),\n\n String(\n fecha.getUTCMonth() + 1\n ).padStart(2, '0'),\n\n fecha.getUTCFullYear()\n ].join('/');\n}\n\nfunction obtenerFechaGuatemala() {\n const partes =\n new Intl.DateTimeFormat(\n 'en-CA',\n {\n timeZone:\n 'America/Guatemala',\n\n year:\n 'numeric',\n\n month:\n '2-digit',\n\n day:\n '2-digit'\n }\n ).formatToParts(\n new Date()\n );\n\n const mapa = {};\n\n for (\n const parte of partes\n ) {\n mapa[parte.type] =\n parte.value;\n }\n\n return new Date(\n Date.UTC(\n Number(mapa.year),\n Number(mapa.month) - 1,\n Number(mapa.day)\n )\n );\n}\n\nfunction calcularEdad(\n fechaNacimiento,\n fechaReferencia\n) {\n let edad =\n fechaReferencia.getUTCFullYear() -\n fechaNacimiento.getUTCFullYear();\n\n const diferenciaMes =\n fechaReferencia.getUTCMonth() -\n fechaNacimiento.getUTCMonth();\n\n if (\n diferenciaMes < 0 ||\n (\n diferenciaMes === 0 &&\n fechaReferencia.getUTCDate() <\n fechaNacimiento.getUTCDate()\n )\n ) {\n edad--;\n }\n\n return edad;\n}\n\n/*\n * ============================================================\n * OBTENER DATOS DEL EMPLEADO\n * ============================================================\n */\n\nconst empleado = String(\n obtenerCelda(\n filaEmpleado,\n 'Nombre',\n 'Nombre completo',\n 'Colaborador'\n ) ||\n entrada.empleado ||\n dpi.empleado\n).trim();\n\nconst proyecto = String(\n obtenerCelda(\n filaEmpleado,\n 'Proyecto',\n 'Cliente',\n 'Cliente Proyecto',\n 'Proyecto Cliente'\n ) ||\n bamboo['4584'] ||\n bamboo.customPosicionCliente ||\n ''\n).trim();\n\nconst puesto = String(\n obtenerCelda(\n filaEmpleado,\n 'Puesto',\n 'Posición',\n 'Posicion',\n 'Cargo'\n ) ||\n bamboo.jobTitle ||\n bamboo.jobTitleName ||\n ''\n).trim();\n\nconst motivo = String(\n obtenerCelda(\n filaEmpleado,\n 'Motivo',\n 'Motivo de salida',\n 'Razón de salida',\n 'Razon de salida'\n ) ||\n bamboo['4384'] ||\n bamboo.terminationReason ||\n ''\n).trim();\n\nconst fechaInicioTexto =\n obtenerCelda(\n filaEmpleado,\n 'Fecha de inicio',\n 'Fecha inicio',\n 'Fecha de contratación',\n 'Fecha de contratacion',\n 'Fecha ingreso'\n ) ||\n bamboo.originalHireDate ||\n bamboo.hireDate ||\n '';\n\nconst fechaFinTexto =\n obtenerCelda(\n filaEmpleado,\n 'Fecha de finalización',\n 'Fecha de finalizacion',\n 'Fecha finalización',\n 'Fecha finalizacion',\n 'Fecha de salida',\n 'Fecha salida'\n ) ||\n bamboo.terminationDate ||\n '';\n\nconst montoOriginal =\n obtenerCelda(\n filaEmpleado,\n 'Valor a pagar',\n 'Total a pagar',\n 'Total pagar',\n 'Total'\n );\n\nconst numeroCheque = String(\n obtenerCelda(\n filaEmpleado,\n 'No. Cheque',\n 'No Cheque',\n 'Número de cheque',\n 'Numero de cheque'\n ) || ''\n).trim();\n\n/*\n * ============================================================\n * VALIDAR DATOS CRÍTICOS\n * ============================================================\n */\n\nif (!empleado) {\n throw new Error(\n 'El nombre del empleado está vacío.'\n );\n}\n\nif (!carpeta.carpetaEmpleadoId) {\n throw new Error(\n `No se obtuvo la carpeta de Google Drive de ${empleado}.`\n );\n}\n\nif (!dpiSubido.dpiDriveFileId) {\n throw new Error(\n `El PDF del DPI de ${empleado} no fue subido correctamente a Google Drive.`\n );\n}\n\nconst monto =\n parsearMonto(montoOriginal);\n\nif (!Number.isFinite(monto)) {\n throw new Error(\n `No pude interpretar el Total a pagar de ${empleado}: ${montoOriginal}`\n );\n}\n\nif (monto < 0) {\n throw new Error(\n `El Total a pagar de ${empleado} es negativo: ${monto}`\n );\n}\n\nconst fechaInicio =\n parsearFecha(fechaInicioTexto);\n\nconst fechaFin =\n parsearFecha(fechaFinTexto);\n\nconst fechaNacimiento =\n parsearFecha(\n dpi.fechaNacimiento ||\n bamboo.dateOfBirth\n );\n\nif (!fechaInicio) {\n throw new Error(\n `Fecha de inicio inválida para ${empleado}: ${fechaInicioTexto}`\n );\n}\n\nif (!fechaFin) {\n throw new Error(\n `Fecha de finalización inválida para ${empleado}: ${fechaFinTexto}`\n );\n}\n\nif (!fechaNacimiento) {\n throw new Error(\n `No se encontró una fecha de nacimiento válida para ${empleado}.`\n );\n}\n\nif (!dpi.numeroDpi) {\n throw new Error(\n `No se obtuvo el número de DPI de ${empleado}.`\n );\n}\n\nif (!dpi.estadoCivilLegal) {\n throw new Error(\n `No se obtuvo el estado civil de ${empleado}.`\n );\n}\n\nif (!dpi.nacionalidadLegal) {\n throw new Error(\n `No se obtuvo la nacionalidad de ${empleado}.`\n );\n}\n\nif (!motivo) {\n throw new Error(\n `No se encontró el motivo de salida de ${empleado}.`\n );\n}\n\n/*\n * ============================================================\n * PREPARAR DATOS CALCULADOS\n * ============================================================\n */\n\nconst fechaDocumento =\n obtenerFechaGuatemala();\n\nconst edad =\n calcularEdad(\n fechaNacimiento,\n fechaDocumento\n );\n\nif (\n !Number.isInteger(edad) ||\n edad < 0 ||\n edad > 120\n) {\n throw new Error(\n `La edad calculada de ${empleado} no es válida: ${edad}`\n );\n}\n\nconst montoLetras =\n montoALetras(monto);\n\nconst montoNumericoSinQ =\n monto.toLocaleString(\n 'en-US',\n {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2\n }\n );\n\n/*\n * La plantilla del voucher ya contiene\n * el símbolo Q antes del marcador.\n */\nconst montoNumericoVoucher =\n montoNumericoSinQ;\n\n/*\n * Algunos datos no bloquean la ejecución,\n * pero deben quedar registrados.\n */\nconst advertencias = [];\n\nif (!proyecto) {\n advertencias.push(\n 'No se encontró proyecto o cliente para el voucher.'\n );\n}\n\nif (!puesto) {\n advertencias.push(\n 'No se encontró el puesto del empleado.'\n );\n}\n\nif (!numeroCheque) {\n advertencias.push(\n 'El número de cheque está vacío.'\n );\n}\n\nconst municipioRenap = String(\n dpi.municipioRenap ||\n dpi.lugarNacimientoMunicipio ||\n ''\n).trim();\n\nconst departamentoRenap = String(\n dpi.departamentoRenap ||\n dpi.lugarNacimientoDepartamento ||\n ''\n).trim();\n\nif (\n !municipioRenap ||\n !departamentoRenap\n) {\n throw new Error(\n `No se obtuvo municipio y departamento para completar el RENAP de ${empleado}.`\n );\n}\n\nconst textoRenapCompleto =\n `extendido por el Registro Nacional de las Personas (RENAP) ` +\n `del Municipio de ${municipioRenap}, ` +\n `departamento de ${departamentoRenap},`;\n\n/*\n * ============================================================\n * REEMPLAZOS PARA EL FINIQUITO\n * ============================================================\n */\n\nconst reemplazosFiniquito = {\n 'extendido por el Registro Nacional de las Personas (RENAP),':\n textoRenapCompleto,\n\n '{{NOMBRE_COMPLETO}}':\n empleado,\n\n '{{EDAD_LETRAS}}':\n numeroALetras(\n edad\n ).toLowerCase(),\n\n '{{ESTADO_CIVIL}}':\n dpi.estadoCivilLegal,\n\n '{{NACIONALIDAD}}':\n dpi.nacionalidadLegal,\n\n '{{NO_DPI}}':\n String(\n dpi.numeroDpi\n ),\n\n '{{FECHA_INGRESO_LETRAS}}':\n fechaALetras(\n fechaInicio\n ),\n\n '{{FECHA_EGRESO_LETRAS}}':\n fechaALetras(\n fechaFin\n ),\n\n '{{MOTIVO_MAYUSCULA}}':\n motivo.toUpperCase(),\n\n '{{MONTO_LETRAS}}':\n montoLetras,\n\n '{{MONTO_NUMERICO}}':\n montoNumericoSinQ,\n\n '{{FECHA_DOCUMENTO_LETRAS}}':\n fechaALetras(\n fechaDocumento\n )\n};\n\n/*\n * ============================================================\n * REEMPLAZOS PARA EL VOUCHER\n * ============================================================\n */\n\nconst reemplazosVoucher = {\n '{{FECHA_DOCUMENTO}}':\n fechaCorta(\n fechaDocumento\n ),\n\n '{{PAIS}}':\n 'Guatemala',\n\n '{{NO_CHEQUE}}':\n numeroCheque,\n\n '{{MONTO_NUMERICO}}':\n montoNumericoVoucher,\n\n '{{NOMBRE_COMPLETO}}':\n empleado,\n\n '{{PROYECTO}}':\n proyecto,\n\n '{{MOTIVO}}':\n motivo\n ? (\n `Prestaciones laborales - ${motivo}`\n )\n : 'Prestaciones laborales'\n};\n\n/*\n * ============================================================\n * RESULTADO\n * ============================================================\n */\n\nreturn [\n {\n json: {\n filaConsolidado,\n\n empleado,\n\n nombreNormalizado:\n entrada.nombreNormalizado ||\n normalizar(empleado),\n\n employeeId:\n String(\n dpi.employeeId ||\n entrada.employeeId ||\n bamboo.id ||\n ''\n ),\n\n employeeNumber:\n String(\n bamboo.employeeNumber ||\n entrada.employeeNumber ||\n ''\n ),\n\n carpetaEmpleadoId:\n carpeta.carpetaEmpleadoId,\n\n carpetaEmpleadoNombre:\n carpeta.carpetaEmpleadoNombre ||\n empleado,\n\n carpetaEmpleadoUrl:\n carpeta.carpetaEmpleadoUrl ||\n (\n `https://drive.google.com/drive/folders/` +\n carpeta.carpetaEmpleadoId\n ),\n\n numeroDpi:\n String(\n dpi.numeroDpi\n ),\n\n proyecto,\n puesto,\n motivo,\n numeroCheque,\n\n fechaInicio:\n fechaInicioTexto,\n\n fechaFin:\n fechaFinTexto,\n\n fechaNacimiento:\n dpi.fechaNacimiento ||\n bamboo.dateOfBirth ||\n '',\n\n fechaDocumento:\n fechaCorta(\n fechaDocumento\n ),\n\n fechaDocumentoLetras:\n fechaALetras(\n fechaDocumento\n ),\n\n fechaContratacionBamboo:\n bamboo.originalHireDate ||\n bamboo.hireDate ||\n '',\n\n comentarioSituacionLaboral:\n bamboo['4046'] ||\n bamboo.employmentStatusComments ||\n '',\n\n edad,\n\n edadLetras:\n numeroALetras(\n edad\n ).toLowerCase(),\n\n estadoCivilLegal:\n dpi.estadoCivilLegal,\n\n nacionalidadLegal:\n dpi.nacionalidadLegal,\n\n municipioRenap,\n departamentoRenap,\n\n monto,\n\n montoNumericoSinQ,\n\n montoNumericoVoucher,\n\n montoLetras,\n\n dpiDriveFileId:\n dpiSubido.dpiDriveFileId,\n\n dpiDriveFileName:\n dpiSubido.dpiDriveFileName,\n\n dpiDriveFileUrl:\n dpiSubido.dpiDriveFileUrl,\n\n reemplazosFiniquito,\n\n reemplazosVoucher,\n\n advertencias,\n\n estadoPreparacion:\n advertencias.length === 0\n ? 'DATOS_DOCUMENTOS_COMPLETOS'\n : 'DATOS_DOCUMENTOS_CON_ADVERTENCIAS'\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
5568,
768
],
"id": "e0a6adec-4c6f-4fcb-b056-a4eb01ce14ca",
"name": "Preparar datos documentos"
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"numberInputs": 4,
"options": {
"clashHandling": {
"values": {
"resolveClash": "addSuffix"
}
},
"includeUnpaired": false
}
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
6720,
720
],
"id": "475e0940-a1d1-4fdc-a526-906e0bc9060f",
"name": "Esperar documentos del empleado"
},
{
"parameters": {
"jsCode": "const datos =\n $('Preparar datos documentos')\n .first()\n .json;\n\nconst finiquito =\n $('Copiar plantilla Finiquito')\n .first()\n .json;\n\nconst voucher =\n $('Copiar plantilla Voucher')\n .first()\n .json;\n\nconst formato =\n $('Copiar formato cheque')\n .first()\n .json;\n\nreturn [\n {\n json: {\n filaConsolidado:\n datos.filaConsolidado,\n\n empleado:\n datos.empleado,\n\n estado:\n 'PROCESADO_OK',\n\n documentosGenerados: true,\n\n carpetaId:\n datos.carpetaEmpleadoId,\n\n carpetaUrl:\n datos.carpetaEmpleadoUrl,\n\n dpiFileId:\n datos.dpiDriveFileId,\n\n finiquitoId:\n finiquito.id,\n\n voucherId:\n voucher.id,\n\n formatoChequeId:\n formato.id,\n\n numeroDpi:\n datos.numeroDpi,\n\n monto:\n datos.monto,\n\n pendienteDocumentoCheque:\n true\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
6992,
752
],
"id": "b245d4bd-3315-43f6-ae26-9e53f56ec373",
"name": "Resultado empleado procesado"
},
{
"parameters": {
"method": "POST",
"url": "=https://www.googleapis.com/drive/v3/files/{{$('Validar entrada empleado').first().json.plantillaFiniquitoId}}/copy?fields=id,name,mimeType,parents,webViewLink",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{\n {\n name:\n 'Finiquito - ' +\n $('Preparar datos documentos')\n .first()\n .json\n .empleado,\n\n parents: [\n $('Preparar datos documentos')\n .first()\n .json\n .carpetaEmpleadoId\n ]\n }\n}}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
5984,
768
],
"id": "4afdb912-a9d6-4275-857b-1a5e2823cc69",
"name": "Copiar plantilla Finiquito",
"credentials": {
"googleOAuth2Api": {
"id": "eHseMeH39kRcXgOF",
"name": "Google account 2"
}
}
},
{
"parameters": {
"method": "POST",
"url": "=https://www.googleapis.com/drive/v3/files/{{$('Validar entrada empleado').first().json.plantillaVoucherId}}/copy?fields=id,name,mimeType,parents,webViewLink",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{\n {\n name:\n 'Voucher - ' +\n $json.empleado,\n\n parents: [\n $json.carpetaEmpleadoId\n ]\n }\n}}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
5840,
448
],
"id": "8bb994c9-e4fd-4cd9-8de0-dd4d9401c632",
"name": "Copiar plantilla Voucher",
"credentials": {
"googleOAuth2Api": {
"id": "eHseMeH39kRcXgOF",
"name": "Google account 2"
}
}
},
{
"parameters": {
"method": "POST",
"url": "=https://www.googleapis.com/drive/v3/files/{{$('Validar entrada empleado').first().json.plantillaChequeId}}/copy?fields=id,name,mimeType,parents,webViewLink",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleDriveOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{\n {\n name:\n 'Formato impresión cheque - ' +\n $json.empleado,\n\n parents: [\n $json.carpetaEmpleadoId\n ]\n }\n}}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
5776,
1056
],
"id": "10fadc7c-c3a2-4e5d-b0d4-757d1ead7367",
"name": "Copiar formato cheque",
"credentials": {
"googleDriveOAuth2Api": {
"id": "g23xdGLZRzBGqKgH",
"name": "Isaac - Google Drive"
}
}
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "b6c0f64d-72d2-4df6-89f4-c0c1cfd96292",
"name": "carpetaEmpleadoId",
"value": "={{$json.id}}",
"type": "string"
},
{
"id": "9ce24c17-5fff-4063-8492-5fcd07bf90da",
"name": "carpetaEmpleadoNombre",
"value": "={{$json.name}}",
"type": "string"
},
{
"id": "ba4ff0c5-2b62-45dc-af0b-6072a45bfab0",
"name": "carpetaEmpleadoUrl",
"value": "=https://drive.google.com/drive/folders/{{$json.id}}",
"type": "string"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
2784,
640
],
"id": "a2d9c3e4-798f-4f03-b43c-036230009fa2",
"name": "Preparar datos carpeta empleado"
},
{
"parameters": {
"content": "# PROCESADOR INDIVIDUAL — FINIQUITOS GUATEMALA\n\nRecibe un empleado desde el flujo masivo y:\n\n1. Valida el NO., nombre, Google Sheet, carpeta raíz y plantillas.\n2. Busca al empleado en BambooHR con coincidencia exacta o flexible segura.\n3. Localiza y descarga el DPI.\n4. Gemini valida el DPI y extrae sus datos.\n5. Crea la carpeta del empleado y guarda el DPI.\n6. Genera y personaliza finiquito, voucher y formato de cheque.\n7. Actualiza las columnas X/Y de Consolidado.\n8. Agrega automáticamente municipio y departamento a las dos menciones del RENAP. Usa el lugar de expedición explícito y, cuando el DPI no lo muestra, el lugar de nacimiento visible, que fue el criterio usado en las correcciones anteriores.\n\nNo requiere editar la plantilla con marcadores nuevos: sustituye la frase RENAP existente en ambas apariciones.",
"height": 960,
"width": 3136,
"color": 2
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-3344,
288
],
"id": "90e08db3-833e-46cc-90d9-e5fe52838f6e",
"name": "Sticky Note"
},
{
"parameters": {
"content": "# 🪪 EXPEDIENTE, DPI Y CARPETA DEL EMPLEADO\n\nRecupera la información completa del empleado y guarda su documento de identificación dentro de una carpeta individual en Google Drive.\n\n## Consulta del expediente\n\nEl flujo obtiene desde BambooHR:\n\n- Datos personales.\n- Datos laborales.\n- Fechas relevantes.\n- Información necesaria para los documentos de finiquito.\n- Lista de archivos asociados al empleado.\n\n## Selección del DPI\n\nEntre los documentos disponibles:\n\n1. Identifica los posibles archivos de DPI.\n2. Filtra documentos que no corresponden.\n3. Selecciona un único candidato válido.\n4. Descarga el PDF original desde BambooHR.\n5. Verifica que exista contenido binario utilizable.\n\nNo debe continuar cuando:\n\n- No existe un DPI.\n- Existen varios candidatos ambiguos.\n- La descarga está vacía.\n- El archivo no puede procesarse correctamente.\n\n## Extracción con Gemini\n\nGemini analiza el documento para recuperar información útil del DPI.\n\nDespués del análisis:\n\n- Se limpia la respuesta de la IA.\n- Se normalizan nombres y números.\n- Se validan los campos detectados.\n- Se combinan los datos del DPI con el expediente de BambooHR.\n\nGemini complementa la información, pero no reemplaza las validaciones del workflow.\n\n## Carpeta y almacenamiento\n\nEl bloque crea una carpeta individual para el empleado y conserva:\n\n- ID de la carpeta.\n- Enlace de Google Drive.\n- Nombre normalizado del empleado.\n- Datos que utilizarán los documentos posteriores.\n\nEl DPI se guarda como PDF original dentro de esa carpeta, con un nombre identificable para facilitar su revisión.\n\n## Reglas\n\n- Mantener el DPI en formato PDF.\n- No modificar el documento original.\n- No subir el archivo antes de crear la carpeta.\n- No declarar éxito hasta verificar que el PDF existe en Drive.\n- Mantener relacionados el empleado, la carpeta y el DPI mediante sus IDs.",
"height": 1168,
"width": 3984,
"color": "#722C2C"
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
0,
0
],
"id": "b03d6699-08f1-4f4e-87ec-7329fd595130",
"name": "Sticky Note1"
},
{
"parameters": {
"content": "# 📄 GENERACIÓN DE DOCUMENTOS DEL FINIQUITO\n\nPrepara y genera todos los documentos requeridos para completar el expediente de finiquito del empleado.\n\n## Preparación de datos\n\nAntes de modificar las plantillas, el flujo consolida información como:\n\n- Nombre completo.\n- Datos del DPI.\n- Cargo y departamento.\n- Fechas de ingreso y salida.\n- Montos calculados.\n- Información bancaria o de pago.\n- Identificador del empleado.\n- Carpeta de Google Drive donde se guardarán los archivos.\n\n## Voucher\n\nEl flujo:\n\n1. Copia la plantilla oficial del voucher.\n2. Crea una versión exclusiva para el empleado.\n3. Reemplaza los campos de la plantilla.\n4. Conserva el documento dentro de la carpeta correspondiente.\n\n## Consolidado\n\nActualiza el archivo consolidado con la información procesada del empleado para mantener control general sobre los finiquitos generados.\n\n## Finiquito\n\nEl flujo:\n\n1. Copia la plantilla oficial del finiquito.\n2. Personaliza nombres, fechas, datos laborales y montos.\n3. Conserva el documento generado como parte del expediente individual.\n\n## Formato de cheque\n\nTambién:\n\n1. Copia el formato base del cheque.\n2. Completa los datos del beneficiario y el pago.\n3. Guarda el archivo junto con los demás documentos.\n\n## Sincronización y cierre\n\nLas diferentes ramas se ejecutan en paralelo.\n\nEl nodo de espera solamente permite finalizar cuando se han completado:\n\n- Voucher.\n- Consolidado.\n- Finiquito.\n- Formato de cheque.\n\nAl terminar, se genera un resultado único con el estado del empleado procesado y las referencias de sus documentos.\n\n## Reglas\n\n- No modificar las plantillas originales.\n- Crear una copia independiente por empleado.\n- No finalizar mientras falte alguno de los documentos obligatorios.\n- Mantener todos los archivos dentro de la carpeta individual.\n- No informar éxito cuando alguna rama haya fallado.\n- Conservar los IDs y enlaces de los documentos para el flujo principal.",
"height": 1280,
"width": 2832,
"color": "#33704B"
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
4432,
144
],
"id": "6420d6a0-2e6a-4fec-b276-deef1258bd93",
"name": "Sticky Note2"
}
],
"connections": {
"Identificar candidatos DPI": {
"main": [
[
{
"node": "Seleccionar DPI único",
"type": "main",
"index": 0
}
]
]
},
"Seleccionar DPI único": {
"main": [
[
{
"node": "BambooHR - Descargar DPI",
"type": "main",
"index": 0
}
]
]
},
"BambooHR - Descargar DPI": {
"main": [
[
{
"node": "Validar descarga DPI",
"type": "main",
"index": 0
}
]
]
},
"Validar descarga DPI": {
"main": [
[
{
"node": "Gemini - Extraer datos DPI",
"type": "main",
"index": 0
}
]
]
},
"Gemini - Extraer datos DPI": {
"main": [
[
{
"node": "Normalizar y validar datos DPI",
"type": "main",
"index": 0
}
]
]
},
"Normalizar y validar datos DPI": {
"main": [
[
{
"node": "Google Drive - Crear carpeta empleado",
"type": "main",
"index": 0
},
{
"node": "Merge",
"type": "main",
"index": 0
}
]
]
},
"Merge": {
"main": [
[
{
"node": "Preparar DPI PDF para Drive",
"type": "main",
"index": 0
}
]
]
},
"Preparar DPI PDF para Drive": {
"main": [
[
{
"node": "Google Drive - Subir DPI PDF",
"type": "main",
"index": 0
}
]
]
},
"Google Drive - Subir DPI PDF": {
"main": [
[
{
"node": "Validar DPI PDF subido",
"type": "main",
"index": 0
}
]
]
},
"Validar DPI PDF subido": {
"main": [
[
{
"node": "Preparar datos documentos",
"type": "main",
"index": 0
}
]
]
},
"Actualizar Consolidado": {
"main": [
[
{
"node": "Esperar documentos del empleado",
"type": "main",
"index": 1
}
]
]
},
"Entrada empleado": {
"main": [
[
{
"node": "Validar entrada empleado",
"type": "main",
"index": 0
}
]
]
},
"Validar entrada empleado": {
"main": [
[
{
"node": "Buscar carpeta existente",
"type": "main",
"index": 0
}
]
]
},
"Buscar carpeta existente": {
"main": [
[
{
"node": "Evaluar carpeta existente",
"type": "main",
"index": 0
}
]
]
},
"Evaluar carpeta existente": {
"main": [
[
{
"node": "¿Carpeta nueva?",
"type": "main",
"index": 0
}
]
]
},
"¿Carpeta nueva?": {
"main": [
[
{
"node": "BambooHR - Buscar empleado",
"type": "main",
"index": 0
}
],
[
{
"node": "Resultado carpeta existente",
"type": "main",
"index": 0
}
]
]
},
"BambooHR - Buscar empleado": {
"main": [
[
{
"node": "Seleccionar empleado exacto",
"type": "main",
"index": 0
}
]
]
},
"Seleccionar empleado exacto": {
"main": [
[
{
"node": "¿Empleado exacto?",
"type": "main",
"index": 0
}
]
]
},
"¿Empleado exacto?": {
"main": [
[
{
"node": "BambooHR - Datos completos",
"type": "main",
"index": 0
}
],
[
{
"node": "Resultado empleado no identificado",
"type": "main",
"index": 0
}
]
]
},
"BambooHR - Datos completos": {
"main": [
[
{
"node": "BambooHR - Listar documentos",
"type": "main",
"index": 0
}
]
]
},
"BambooHR - Listar documentos": {
"main": [
[
{
"node": "Identificar candidatos DPI",
"type": "main",
"index": 0
}
]
]
},
"Google Drive - Crear carpeta empleado": {
"main": [
[
{
"node": "Preparar datos carpeta empleado",
"type": "main",
"index": 0
}
]
]
},
"Preparar datos documentos": {
"main": [
[
{
"node": "Copiar plantilla Voucher",
"type": "main",
"index": 0
},
{
"node": "Copiar formato cheque",
"type": "main",
"index": 0
},
{
"node": "Actualizar Consolidado",
"type": "main",
"index": 0
},
{
"node": "Copiar plantilla Finiquito",
"type": "main",
"index": 0
}
]
]
},
"Personalizar Voucher": {
"main": [
[
{
"node": "Esperar documentos del empleado",
"type": "main",
"index": 0
}
]
]
},
"Personalizar Finiquito": {
"main": [
[
{
"node": "Esperar documentos del empleado",
"type": "main",
"index": 2
}
]
]
},
"Llenar formato cheque": {
"main": [
[
{
"node": "Esperar documentos del empleado",
"type": "main",
"index": 3
}
]
]
},
"Esperar documentos del empleado": {
"main": [
[
{
"node": "Resultado empleado procesado",
"type": "main",
"index": 0
}
]
]
},
"Copiar plantilla Finiquito": {
"main": [
[
{
"node": "Personalizar Finiquito",
"type": "main",
"index": 0
}
]
]
},
"Copiar plantilla Voucher": {
"main": [
[
{
"node": "Personalizar Voucher",
"type": "main",
"index": 0
}
]
]
},
"Copiar formato cheque": {
"main": [
[
{
"node": "Llenar formato cheque",
"type": "main",
"index": 0
}
]
]
},
"Preparar datos carpeta empleado": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 1
}
]
]
}
},
"authors": "Isaac Aracena",
"name": "Version 61974fea",
"description": "",
"autosaved": true,
"workflowPublishHistory": [
{
"createdAt": "2026-07-31T14:09:53.202Z",
"id": 3740,
"workflowId": "osgTmV6UU3JmhogE",
"versionId": "61974fea-84b3-419a-94e7-fd0c25850e62",
"event": "activated",
"userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029"
}
]
}
}