Files
finiquitos-gt-procesar-1-em…/finiquitos-gt-procesar-1-empleado.json
T

2384 lines
196 KiB
JSON

{
"updatedAt": "2026-07-16T16:20:47.768Z",
"createdAt": "2026-07-10T14:34:52.976Z",
"id": "KBV3AEEpys8KqPLX",
"name": "Finiquitos GT - Procesar 1 empleado",
"description": "Procesa el finiquito de un empleado, crea su carpeta individual y genera los documentos correspondientes, incluyendo finiquito, voucher, formato de impresión y DPI.",
"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": [
656,
16
],
"id": "5b002dd7-d1af-43e0-9902-f6fc8fec1081",
"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 '47322': '47476'\n};\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": [
864,
16
],
"id": "eb8977c1-3a2e-4a4a-9e93-656b10155b20",
"name": "Seleccionar DPI único"
},
{
"parameters": {
"resource": "employeeDocument",
"operation": "download",
"employeeId": "={{$json.employeeId}}",
"fileId": "={{$json.dpiFileId}}"
},
"type": "n8n-nodes-base.bambooHr",
"typeVersion": 1,
"position": [
1088,
16
],
"id": "eae54fc8-1cdb-4d29-b2a8-cb6c315e4238",
"name": "BambooHR - Descargar DPI Anderson",
"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 'El nodo 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 binario: ${binaryKeys.join(', ')}`\n );\n}\n\nconst binaryProperty = binaryKeys[0];\nconst file = binary[binaryProperty];\n\nconst fileName = String(\n file.fileName ||\n $('Seleccionar DPI único').first().json.dpiFileName ||\n ''\n).trim();\n\nconst mimeType = String(\n file.mimeType || ''\n).trim().toLowerCase();\n\nif (!fileName) {\n throw new Error(\n 'El archivo descargado no tiene nombre.'\n );\n}\n\nconst parecePdfPorNombre =\n fileName.toLowerCase().endsWith('.pdf');\n\nconst parecePdfPorMime =\n mimeType === 'application/pdf' ||\n mimeType.includes('pdf');\n\nif (!parecePdfPorNombre && !parecePdfPorMime) {\n throw new Error(\n `El supuesto DPI no parece ser PDF. ` +\n `Archivo: ${fileName}; MIME: ${mimeType || 'no informado'}`\n );\n}\n\nconst seleccionado =\n $('Seleccionar DPI único').first().json;\n\nreturn [\n {\n json: {\n employeeId: seleccionado.employeeId,\n empleado: seleccionado.empleado,\n dpiFileId: seleccionado.dpiFileId,\n dpiFileName: fileName,\n dpiMimeType: mimeType || 'application/pdf',\n binaryProperty,\n descargaDpiValida: true,\n estado: 'DPI_DESCARGADO_PENDIENTE_REVISION_VISUAL'\n },\n binary: item.binary\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1296,
16
],
"id": "9a11b238-a0fa-408e-9427-16cfb83e6059",
"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": [
1504,
16
],
"id": "19e4c7a6-286e-4e72-8fc5-72e222cd78e4",
"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 validaciones.dpiCoincideConBamboo\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.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 * La plantilla corregida ya no necesita\n * municipio y departamento de expedición.\n * Conservamos los datos solo como referencia.\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 if (\n lugarExpedicionDisponible\n) {\n estadoProceso =\n 'DPI_VALIDADO_COMPLETO';\n} else {\n estadoProceso =\n 'DPI_VALIDADO_PENDIENTE_LUGAR_EXPEDICION';\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 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": [
1728,
16
],
"id": "22a89eb1-5167-42b8-9110-e18a03504fe4",
"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": [
2480,
32
],
"id": "0c831877-7bca-4dd3-983c-9d1c8f23a51e",
"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\nif (!item.binary?.data) {\n throw new Error(\n 'No se recibió el PDF en Binary → data.'\n );\n}\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 nombreArchivo =\n `DPI - ${empleado}.pdf`;\n\nconst binarioOriginal = item.binary.data;\n\nreturn [\n {\n json: {\n ...item.json,\n\n dpiDriveFileName: nombreArchivo,\n\n dpiDriveFolderId:\n carpetaEmpleadoId,\n\n estadoDpiDrive:\n 'LISTO_PARA_SUBIR_PDF'\n },\n\n binary: {\n ...item.binary,\n\n data: {\n ...binarioOriginal,\n\n fileName:\n nombreArchivo,\n\n fileExtension:\n 'pdf',\n\n mimeType:\n 'application/pdf'\n }\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2688,
32
],
"id": "bcef7f92-dbba-4bd4-9982-22d2581d196e",
"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": [
2896,
32
],
"id": "906e1835-8214-4162-b8ef-cc7edac4935c",
"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).trim();\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 nombre !== preparado.dpiDriveFileName\n) {\n throw new Error(\n `Nombre incorrecto. Esperado: ` +\n `${preparado.dpiDriveFileName}. ` +\n `Recibido: ${nombre}.`\n );\n}\n\nif (\n mimeType &&\n mimeType !== 'application/pdf'\n) {\n throw new Error(\n `El archivo no quedó como PDF. ` +\n `MIME recibido: ${mimeType}`\n );\n}\n\nif (\n parents.length > 0 &&\n !parents.includes(\n preparado.carpetaEmpleadoId\n )\n) {\n throw new Error(\n 'El PDF 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 'application/pdf',\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": [
3104,
32
],
"id": "7da435cd-e674-48cc-96b8-22e0957badfc",
"name": "Validar DPI PDF subido"
},
{
"parameters": {
"method": "POST",
"url": "https://sheets.googleapis.com/v4/spreadsheets/1Ym9L_-uTXDZlFOUyq5vIDdnkyVx1nkBrlLmbXo2NI3I/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 'Consolidado!X' +\n $json.filaConsolidado,\n values: [\n [$json.montoLetras]\n ]\n },\n {\n range:\n 'Consolidado!Y' +\n $json.filaConsolidado,\n values: [\n [$json.numeroDpi]\n ]\n }\n ]\n }\n}}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
3664,
-64
],
"id": "d96b3b82-2c7d-4ba5-be52-ba04c4a0f006",
"name": "Actualizar Consolidado Anderson",
"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": [
4080,
32
],
"id": "0e60792f-c844-457c-a222-ed71df39f2f9",
"name": "Personalizar Finiquito Anderson",
"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": [
3904,
-288
],
"id": "c26696c1-2330-4d2c-bec0-50836dbfd0ec",
"name": "Personalizar Voucher Anderson",
"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": [
3840,
320
],
"id": "e620258a-47ae-46cc-b4bf-f7d694d5c293",
"name": "Llenar formato cheque Anderson",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
}
},
{
"parameters": {
"inputSource": "passthrough"
},
"type": "n8n-nodes-base.executeWorkflowTrigger",
"typeVersion": 1.1,
"position": [
-2096,
32
],
"id": "b0ea1cc1-8907-4685-9d19-745332d8f45a",
"name": "Entrada empleado"
},
{
"parameters": {
"jsCode": "const entrada = $input.first().json || {};\n\nconst filaConsolidado = Number(\n entrada.filaConsolidado\n);\n\nconst empleado = String(\n entrada.empleado || ''\n).trim();\n\nconst primerNombre = String(\n entrada.primerNombre || ''\n).trim();\n\nconst encabezadosOriginales =\n entrada.encabezadosOriginales;\n\nconst filaOriginal =\n entrada.filaOriginal;\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 (!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\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 filaConsolidado,\n empleado,\n primerNombre,\n nombreNormalizado:\n entrada.nombreNormalizado || '',\n\n encabezadosOriginales,\n filaOriginal,\n\n nombreDriveEscapado,\n\n carpetaRaizId:\n '1mR9TagCwpMmGUXqoC9mnz6wcH9gUS4sa'\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-1888,
32
],
"id": "38a0e23c-94c2-4aa9-b28f-4d9c40cce70c",
"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": [
-1680,
32
],
"id": "a8e34d52-1cec-4e96-892f-92666a20c178",
"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": [
-1472,
32
],
"id": "8eed08d8-f4e4-4d63-bbc5-4802314fc658",
"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": [
-1280,
32
],
"id": "cf5b7b50-7c4d-432e-ba5d-3181b6c1c1f5",
"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": [
-1072,
128
],
"id": "3beb5f17-ca3a-4aeb-96c8-149fbfcf5963",
"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": [
-784,
32
],
"id": "1b408287-ad77-43c4-b5d4-a7c887753085",
"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\n/*\n * ============================================================\n * FUNCIONES AUXILIARES\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 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\n/*\n * ============================================================\n * RECOPILAR EMPLEADOS DEVUELTOS POR BAMBOOHR\n * ============================================================\n */\n\nconst candidatos = [];\n\nfor (const item of items) {\n const json = item.json || {};\n\n /*\n * Respuesta frecuente:\n * {\n * data: [...]\n * }\n */\n if (Array.isArray(json.data)) {\n candidatos.push(...json.data);\n }\n\n /*\n * Respuesta alternativa:\n * {\n * data: {\n * data: [...]\n * }\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 (Array.isArray(json.candidatos)) {\n candidatos.push(...json.candidatos);\n }\n\n /*\n * Caso en que cada empleado llega como un item individual.\n */\n if (\n obtenerEmployeeId(json) &&\n !Array.isArray(json.data)\n ) {\n candidatos.push(json);\n }\n}\n\n/*\n * ============================================================\n * ELIMINAR EMPLEADOS DUPLICADOS\n * ============================================================\n */\n\nconst candidatosUnicos = [];\nconst idsVistos = new Set();\n\nfor (const candidato of candidatos) {\n const employeeId =\n obtenerEmployeeId(candidato);\n\n if (!employeeId) {\n continue;\n }\n\n if (idsVistos.has(employeeId)) {\n continue;\n }\n\n idsVistos.add(employeeId);\n candidatosUnicos.push(candidato);\n}\n\n/*\n * ============================================================\n * EMPLEADO SOLICITADO DESDE EL CONSOLIDADO\n * ============================================================\n */\n\nconst nombreConsolidado = String(\n entrada.empleado ||\n entrada.nombreConsolidado ||\n ''\n).trim();\n\nconst filaConsolidado = Number(\n entrada.filaConsolidado || 0\n);\n\nif (!nombreConsolidado) {\n throw new Error(\n 'No se recibió el nombre del empleado desde el Consolidado.'\n );\n}\n\nconst nombreNormalizado =\n normalizar(nombreConsolidado);\n\n/*\n * ============================================================\n * EXCEPCIONES MANUALES CONFIRMADAS\n * ============================================================\n *\n * Clave:\n * nombre normalizado tal como aparece en el Consolidado.\n *\n * Valor:\n * employeeId interno y nombre actual en BambooHR.\n */\n\nconst OVERRIDES_EMPLEADOS = {\n 'axel daniel flores sosa': {\n employeeId: '50699',\n displayName: 'Axel Flores'\n },\n\n 'wilson julian gil sica': {\n employeeId: '49914',\n displayName: 'Wilson Gil Gil Sicá'\n },\n\n 'eddysson alexander herrera amaya': {\n employeeId: '50073',\n displayName: 'Eddysson Herrera'\n },\n\n 'katia jackeline lorena garcia': {\n employeeId: '49764',\n displayName: 'Katia Jackeline Lorena Garcia Guzmán'\n },\n\n 'victoria florian rodriguez': {\n employeeId: '48629',\n displayName: 'Victoria Rodríguez Florián'\n },\n\n 'vivian paola monroy': {\n employeeId: '47971',\n displayName: 'Vivian Paola Monroy pacheco'\n\n },\n\n 'walter geovanny garcia diaz': {\n\n employeeId: '49498',\n\n displayName: 'Walter geovani García diaz'\n\n }\n \n};\n\nconst override =\n OVERRIDES_EMPLEADOS[\n nombreNormalizado\n ] || null;\n\nlet seleccionado = null;\nlet seleccionManual = false;\n\n/*\n * ============================================================\n * SELECCIÓN MEDIANTE OVERRIDE\n * ============================================================\n */\n\nif (override) {\n seleccionado =\n candidatosUnicos.find(candidato =>\n obtenerEmployeeId(candidato) ===\n String(override.employeeId)\n );\n\n /*\n * Respaldo:\n * si el empleado confirmado no vino en la respuesta de\n * búsqueda, construimos el mínimo necesario para que el\n * siguiente nodo consulte directamente por employeeId.\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}\n\n/*\n * ============================================================\n * SELECCIÓN MEDIANTE COINCIDENCIA EXACTA\n * ============================================================\n */\n\nelse {\n const coincidenciasExactas =\n candidatosUnicos.filter(candidato => {\n const nombreBamboo =\n obtenerNombreCompleto(candidato);\n\n return (\n normalizar(nombreBamboo) ===\n nombreNormalizado\n );\n });\n\n if (coincidenciasExactas.length === 1) {\n seleccionado =\n coincidenciasExactas[0];\n } else {\n return [\n {\n json: {\n ...entrada,\n\n filaConsolidado,\n\n empleado:\n nombreConsolidado,\n\n nombreConsolidado,\n\n /*\n * El IF \"¿Empleado exacto?\" espera este campo.\n */\n estadoMatch: 'NO',\n\n estado:\n coincidenciasExactas.length > 1\n ? 'MULTIPLES_COINCIDENCIAS_EXACTAS'\n : 'SIN_COINCIDENCIA_EXACTA',\n\n detalle:\n coincidenciasExactas.length > 1\n ? 'Se encontraron varias coincidencias exactas en BambooHR.'\n : 'No fue posible identificar de forma única al empleado en BambooHR.',\n\n candidatos:\n candidatosUnicos.map(candidato => {\n const displayName =\n obtenerNombreCompleto(\n candidato\n );\n\n return {\n employeeId:\n obtenerEmployeeId(\n candidato\n ),\n\n displayName,\n\n coincidenciaExacta:\n normalizar(displayName) ===\n nombreNormalizado\n };\n }),\n\n documentosGenerados: false\n }\n }\n ];\n }\n}\n\n/*\n * ============================================================\n * VALIDAR EMPLEADO SELECCIONADO\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\n/*\n * ============================================================\n * RESULTADO CORRECTO\n * ============================================================\n */\n\nreturn [\n {\n json: {\n /*\n * Conservamos toda la información proveniente del\n * Consolidado.\n */\n ...entrada,\n\n /*\n * Agregamos la información del perfil de BambooHR.\n */\n ...seleccionado,\n\n filaConsolidado,\n\n id: employeeId,\n employeeId,\n\n /*\n * Este nombre se utiliza para carpetas y documentos.\n */\n empleado:\n nombreConsolidado,\n\n nombreConsolidado,\n\n /*\n * Este es el nombre actual del perfil en BambooHR.\n */\n displayName,\n\n /*\n * Campo requerido por el IF \"¿Empleado exacto?\".\n */\n estadoMatch: 'OK',\n\n estado:\n seleccionManual\n ? 'COINCIDENCIA_MANUAL_CONFIRMADA'\n : 'COINCIDENCIA_EXACTA',\n\n coincidenciaExacta:\n !seleccionManual,\n\n seleccionManual,\n\n detalle:\n seleccionManual\n ? `Vinculación manual confirmada: ${nombreConsolidado} → ${displayName}, employeeId ${employeeId}.`\n : `Coincidencia exacta confirmada en BambooHR: ${displayName}, employeeId ${employeeId}.`,\n\n documentosGenerados: null\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-576,
32
],
"id": "3ffc7b27-0d30-4d77-90b7-f609fc25f8a2",
"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": [
-368,
32
],
"id": "56dd5b25-e947-4980-ac4a-ddf7ca15eb6b",
"name": "¿Empleado exacto?"
},
{
"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.estadoMatch,\n\n detalle:\n 'No fue posible identificar de forma única al empleado en BambooHR.',\n\n candidatos:\n item.candidatosEncontrados,\n\n documentosGenerados: false\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-160,
128
],
"id": "f0e9c660-81c1-4697-94e3-c37f54e69958",
"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": [
256,
16
],
"id": "931249ae-3c90-4b94-b066-e3f520b362e0",
"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": [
464,
16
],
"id": "09694117-c52e-4939-a156-04a170c163ef",
"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": "1mR9TagCwpMmGUXqoC9mnz6wcH9gUS4sa",
"mode": "id"
},
"options": {}
},
"type": "n8n-nodes-base.googleDrive",
"typeVersion": 3,
"position": [
1936,
-128
],
"id": "591938b6-e98e-4bfa-b549-407cdcbaf365",
"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 '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\n/*\n * ============================================================\n * REEMPLAZOS PARA EL FINIQUITO\n * ============================================================\n */\n\nconst reemplazosFiniquito = {\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 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": [
3424,
32
],
"id": "471a42bd-85d1-4a1c-95fd-549995b30476",
"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": [
4576,
-16
],
"id": "7f9cede5-f86f-4ee2-8307-035c40d99e2c",
"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": [
4848,
16
],
"id": "9eac5b6e-84a4-458e-b1fa-f1c3b556b7a0",
"name": "Resultado empleado procesado"
},
{
"parameters": {
"method": "POST",
"url": "https://www.googleapis.com/drive/v3/files/1HTnNL9bT4V3Sh0t9qHcVZwV2NznjOZ4pUkAAtt1-w8Q/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": [
3840,
32
],
"id": "6364ebf4-a990-4aaf-89cb-6e167faac977",
"name": "Copiar plantilla Finiquito",
"credentials": {
"googleOAuth2Api": {
"id": "eHseMeH39kRcXgOF",
"name": "Google account 2"
}
}
},
{
"parameters": {
"method": "POST",
"url": "https://www.googleapis.com/drive/v3/files/1obH3VCa9VNLYEJyBhd_T4xRKP8msQYluZrbLbp2_8WY/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": [
3696,
-288
],
"id": "4c9098bc-52e2-4b32-84ec-955b7b63239e",
"name": "Copiar plantilla Voucher",
"credentials": {
"googleOAuth2Api": {
"id": "eHseMeH39kRcXgOF",
"name": "Google account 2"
}
}
},
{
"parameters": {
"method": "POST",
"url": "https://www.googleapis.com/drive/v3/files/1SK42DQioyQUuB4EIM_i2dDyiqxzwoyfD-A2DVwAzHPE/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": [
3632,
320
],
"id": "9f47c165-87df-4bd4-add3-164593ff6e54",
"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": [
2176,
-128
],
"id": "60d5567b-9006-42ad-8ba4-6fdc869f5ddf",
"name": "Preparar datos carpeta empleado"
}
],
"connections": {
"Identificar candidatos DPI": {
"main": [
[
{
"node": "Seleccionar DPI único",
"type": "main",
"index": 0
}
]
]
},
"Seleccionar DPI único": {
"main": [
[
{
"node": "BambooHR - Descargar DPI Anderson",
"type": "main",
"index": 0
}
]
]
},
"BambooHR - Descargar DPI Anderson": {
"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 Anderson": {
"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 Anderson",
"type": "main",
"index": 0
},
{
"node": "Copiar plantilla Finiquito",
"type": "main",
"index": 0
}
]
]
},
"Personalizar Voucher Anderson": {
"main": [
[
{
"node": "Esperar documentos del empleado",
"type": "main",
"index": 0
}
]
]
},
"Personalizar Finiquito Anderson": {
"main": [
[
{
"node": "Esperar documentos del empleado",
"type": "main",
"index": 2
}
]
]
},
"Llenar formato cheque Anderson": {
"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 Anderson",
"type": "main",
"index": 0
}
]
]
},
"Copiar plantilla Voucher": {
"main": [
[
{
"node": "Personalizar Voucher Anderson",
"type": "main",
"index": 0
}
]
]
},
"Copiar formato cheque": {
"main": [
[
{
"node": "Llenar formato cheque Anderson",
"type": "main",
"index": 0
}
]
]
},
"Preparar datos carpeta empleado": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 1
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"binaryMode": "separate",
"availableInMCP": true
},
"staticData": null,
"meta": {
"templateCredsSetupCompleted": true
},
"versionId": "7aedbe70-75f8-4dc0-971f-130f9ced8ec1",
"activeVersionId": "7aedbe70-75f8-4dc0-971f-130f9ced8ec1",
"versionCounter": 193,
"triggerCount": 0,
"shared": [
{
"updatedAt": "2026-07-10T14:34:52.987Z",
"createdAt": "2026-07-10T14:34:52.987Z",
"role": "workflow:owner",
"workflowId": "KBV3AEEpys8KqPLX",
"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-11T01:47:01.000Z",
"createdAt": "2026-07-11T01:46:53.229Z",
"versionId": "7aedbe70-75f8-4dc0-971f-130f9ced8ec1",
"workflowId": "KBV3AEEpys8KqPLX",
"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": [
656,
16
],
"id": "5b002dd7-d1af-43e0-9902-f6fc8fec1081",
"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 '47322': '47476'\n};\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": [
864,
16
],
"id": "eb8977c1-3a2e-4a4a-9e93-656b10155b20",
"name": "Seleccionar DPI único"
},
{
"parameters": {
"resource": "employeeDocument",
"operation": "download",
"employeeId": "={{$json.employeeId}}",
"fileId": "={{$json.dpiFileId}}"
},
"type": "n8n-nodes-base.bambooHr",
"typeVersion": 1,
"position": [
1088,
16
],
"id": "eae54fc8-1cdb-4d29-b2a8-cb6c315e4238",
"name": "BambooHR - Descargar DPI Anderson",
"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 'El nodo 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 binario: ${binaryKeys.join(', ')}`\n );\n}\n\nconst binaryProperty = binaryKeys[0];\nconst file = binary[binaryProperty];\n\nconst fileName = String(\n file.fileName ||\n $('Seleccionar DPI único').first().json.dpiFileName ||\n ''\n).trim();\n\nconst mimeType = String(\n file.mimeType || ''\n).trim().toLowerCase();\n\nif (!fileName) {\n throw new Error(\n 'El archivo descargado no tiene nombre.'\n );\n}\n\nconst parecePdfPorNombre =\n fileName.toLowerCase().endsWith('.pdf');\n\nconst parecePdfPorMime =\n mimeType === 'application/pdf' ||\n mimeType.includes('pdf');\n\nif (!parecePdfPorNombre && !parecePdfPorMime) {\n throw new Error(\n `El supuesto DPI no parece ser PDF. ` +\n `Archivo: ${fileName}; MIME: ${mimeType || 'no informado'}`\n );\n}\n\nconst seleccionado =\n $('Seleccionar DPI único').first().json;\n\nreturn [\n {\n json: {\n employeeId: seleccionado.employeeId,\n empleado: seleccionado.empleado,\n dpiFileId: seleccionado.dpiFileId,\n dpiFileName: fileName,\n dpiMimeType: mimeType || 'application/pdf',\n binaryProperty,\n descargaDpiValida: true,\n estado: 'DPI_DESCARGADO_PENDIENTE_REVISION_VISUAL'\n },\n binary: item.binary\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1296,
16
],
"id": "9a11b238-a0fa-408e-9427-16cfb83e6059",
"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": [
1504,
16
],
"id": "19e4c7a6-286e-4e72-8fc5-72e222cd78e4",
"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 validaciones.dpiCoincideConBamboo\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.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 * La plantilla corregida ya no necesita\n * municipio y departamento de expedición.\n * Conservamos los datos solo como referencia.\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 if (\n lugarExpedicionDisponible\n) {\n estadoProceso =\n 'DPI_VALIDADO_COMPLETO';\n} else {\n estadoProceso =\n 'DPI_VALIDADO_PENDIENTE_LUGAR_EXPEDICION';\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 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": [
1728,
16
],
"id": "22a89eb1-5167-42b8-9110-e18a03504fe4",
"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": [
2480,
32
],
"id": "0c831877-7bca-4dd3-983c-9d1c8f23a51e",
"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\nif (!item.binary?.data) {\n throw new Error(\n 'No se recibió el PDF en Binary → data.'\n );\n}\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 nombreArchivo =\n `DPI - ${empleado}.pdf`;\n\nconst binarioOriginal = item.binary.data;\n\nreturn [\n {\n json: {\n ...item.json,\n\n dpiDriveFileName: nombreArchivo,\n\n dpiDriveFolderId:\n carpetaEmpleadoId,\n\n estadoDpiDrive:\n 'LISTO_PARA_SUBIR_PDF'\n },\n\n binary: {\n ...item.binary,\n\n data: {\n ...binarioOriginal,\n\n fileName:\n nombreArchivo,\n\n fileExtension:\n 'pdf',\n\n mimeType:\n 'application/pdf'\n }\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2688,
32
],
"id": "bcef7f92-dbba-4bd4-9982-22d2581d196e",
"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": [
2896,
32
],
"id": "906e1835-8214-4162-b8ef-cc7edac4935c",
"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).trim();\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 nombre !== preparado.dpiDriveFileName\n) {\n throw new Error(\n `Nombre incorrecto. Esperado: ` +\n `${preparado.dpiDriveFileName}. ` +\n `Recibido: ${nombre}.`\n );\n}\n\nif (\n mimeType &&\n mimeType !== 'application/pdf'\n) {\n throw new Error(\n `El archivo no quedó como PDF. ` +\n `MIME recibido: ${mimeType}`\n );\n}\n\nif (\n parents.length > 0 &&\n !parents.includes(\n preparado.carpetaEmpleadoId\n )\n) {\n throw new Error(\n 'El PDF 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 'application/pdf',\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": [
3104,
32
],
"id": "7da435cd-e674-48cc-96b8-22e0957badfc",
"name": "Validar DPI PDF subido"
},
{
"parameters": {
"method": "POST",
"url": "https://sheets.googleapis.com/v4/spreadsheets/1Ym9L_-uTXDZlFOUyq5vIDdnkyVx1nkBrlLmbXo2NI3I/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 'Consolidado!X' +\n $json.filaConsolidado,\n values: [\n [$json.montoLetras]\n ]\n },\n {\n range:\n 'Consolidado!Y' +\n $json.filaConsolidado,\n values: [\n [$json.numeroDpi]\n ]\n }\n ]\n }\n}}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
3664,
-64
],
"id": "d96b3b82-2c7d-4ba5-be52-ba04c4a0f006",
"name": "Actualizar Consolidado Anderson",
"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": [
4080,
32
],
"id": "0e60792f-c844-457c-a222-ed71df39f2f9",
"name": "Personalizar Finiquito Anderson",
"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": [
3904,
-288
],
"id": "c26696c1-2330-4d2c-bec0-50836dbfd0ec",
"name": "Personalizar Voucher Anderson",
"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": [
3840,
320
],
"id": "e620258a-47ae-46cc-b4bf-f7d694d5c293",
"name": "Llenar formato cheque Anderson",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
}
},
{
"parameters": {
"inputSource": "passthrough"
},
"type": "n8n-nodes-base.executeWorkflowTrigger",
"typeVersion": 1.1,
"position": [
-2096,
32
],
"id": "b0ea1cc1-8907-4685-9d19-745332d8f45a",
"name": "Entrada empleado"
},
{
"parameters": {
"jsCode": "const entrada = $input.first().json || {};\n\nconst filaConsolidado = Number(\n entrada.filaConsolidado\n);\n\nconst empleado = String(\n entrada.empleado || ''\n).trim();\n\nconst primerNombre = String(\n entrada.primerNombre || ''\n).trim();\n\nconst encabezadosOriginales =\n entrada.encabezadosOriginales;\n\nconst filaOriginal =\n entrada.filaOriginal;\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 (!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\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 filaConsolidado,\n empleado,\n primerNombre,\n nombreNormalizado:\n entrada.nombreNormalizado || '',\n\n encabezadosOriginales,\n filaOriginal,\n\n nombreDriveEscapado,\n\n carpetaRaizId:\n '1mR9TagCwpMmGUXqoC9mnz6wcH9gUS4sa'\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-1888,
32
],
"id": "38a0e23c-94c2-4aa9-b28f-4d9c40cce70c",
"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": [
-1680,
32
],
"id": "a8e34d52-1cec-4e96-892f-92666a20c178",
"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": [
-1472,
32
],
"id": "8eed08d8-f4e4-4d63-bbc5-4802314fc658",
"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": [
-1280,
32
],
"id": "cf5b7b50-7c4d-432e-ba5d-3181b6c1c1f5",
"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": [
-1072,
128
],
"id": "3beb5f17-ca3a-4aeb-96c8-149fbfcf5963",
"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": [
-784,
32
],
"id": "1b408287-ad77-43c4-b5d4-a7c887753085",
"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\n/*\n * ============================================================\n * FUNCIONES AUXILIARES\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 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\n/*\n * ============================================================\n * RECOPILAR EMPLEADOS DEVUELTOS POR BAMBOOHR\n * ============================================================\n */\n\nconst candidatos = [];\n\nfor (const item of items) {\n const json = item.json || {};\n\n /*\n * Respuesta frecuente:\n * {\n * data: [...]\n * }\n */\n if (Array.isArray(json.data)) {\n candidatos.push(...json.data);\n }\n\n /*\n * Respuesta alternativa:\n * {\n * data: {\n * data: [...]\n * }\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 (Array.isArray(json.candidatos)) {\n candidatos.push(...json.candidatos);\n }\n\n /*\n * Caso en que cada empleado llega como un item individual.\n */\n if (\n obtenerEmployeeId(json) &&\n !Array.isArray(json.data)\n ) {\n candidatos.push(json);\n }\n}\n\n/*\n * ============================================================\n * ELIMINAR EMPLEADOS DUPLICADOS\n * ============================================================\n */\n\nconst candidatosUnicos = [];\nconst idsVistos = new Set();\n\nfor (const candidato of candidatos) {\n const employeeId =\n obtenerEmployeeId(candidato);\n\n if (!employeeId) {\n continue;\n }\n\n if (idsVistos.has(employeeId)) {\n continue;\n }\n\n idsVistos.add(employeeId);\n candidatosUnicos.push(candidato);\n}\n\n/*\n * ============================================================\n * EMPLEADO SOLICITADO DESDE EL CONSOLIDADO\n * ============================================================\n */\n\nconst nombreConsolidado = String(\n entrada.empleado ||\n entrada.nombreConsolidado ||\n ''\n).trim();\n\nconst filaConsolidado = Number(\n entrada.filaConsolidado || 0\n);\n\nif (!nombreConsolidado) {\n throw new Error(\n 'No se recibió el nombre del empleado desde el Consolidado.'\n );\n}\n\nconst nombreNormalizado =\n normalizar(nombreConsolidado);\n\n/*\n * ============================================================\n * EXCEPCIONES MANUALES CONFIRMADAS\n * ============================================================\n *\n * Clave:\n * nombre normalizado tal como aparece en el Consolidado.\n *\n * Valor:\n * employeeId interno y nombre actual en BambooHR.\n */\n\nconst OVERRIDES_EMPLEADOS = {\n 'axel daniel flores sosa': {\n employeeId: '50699',\n displayName: 'Axel Flores'\n },\n\n 'wilson julian gil sica': {\n employeeId: '49914',\n displayName: 'Wilson Gil Gil Sicá'\n },\n\n 'eddysson alexander herrera amaya': {\n employeeId: '50073',\n displayName: 'Eddysson Herrera'\n },\n\n 'katia jackeline lorena garcia': {\n employeeId: '49764',\n displayName: 'Katia Jackeline Lorena Garcia Guzmán'\n },\n\n 'victoria florian rodriguez': {\n employeeId: '48629',\n displayName: 'Victoria Rodríguez Florián'\n },\n\n 'vivian paola monroy': {\n employeeId: '47971',\n displayName: 'Vivian Paola Monroy pacheco'\n\n },\n\n 'walter geovanny garcia diaz': {\n\n employeeId: '49498',\n\n displayName: 'Walter geovani García diaz'\n\n }\n \n};\n\nconst override =\n OVERRIDES_EMPLEADOS[\n nombreNormalizado\n ] || null;\n\nlet seleccionado = null;\nlet seleccionManual = false;\n\n/*\n * ============================================================\n * SELECCIÓN MEDIANTE OVERRIDE\n * ============================================================\n */\n\nif (override) {\n seleccionado =\n candidatosUnicos.find(candidato =>\n obtenerEmployeeId(candidato) ===\n String(override.employeeId)\n );\n\n /*\n * Respaldo:\n * si el empleado confirmado no vino en la respuesta de\n * búsqueda, construimos el mínimo necesario para que el\n * siguiente nodo consulte directamente por employeeId.\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}\n\n/*\n * ============================================================\n * SELECCIÓN MEDIANTE COINCIDENCIA EXACTA\n * ============================================================\n */\n\nelse {\n const coincidenciasExactas =\n candidatosUnicos.filter(candidato => {\n const nombreBamboo =\n obtenerNombreCompleto(candidato);\n\n return (\n normalizar(nombreBamboo) ===\n nombreNormalizado\n );\n });\n\n if (coincidenciasExactas.length === 1) {\n seleccionado =\n coincidenciasExactas[0];\n } else {\n return [\n {\n json: {\n ...entrada,\n\n filaConsolidado,\n\n empleado:\n nombreConsolidado,\n\n nombreConsolidado,\n\n /*\n * El IF \"¿Empleado exacto?\" espera este campo.\n */\n estadoMatch: 'NO',\n\n estado:\n coincidenciasExactas.length > 1\n ? 'MULTIPLES_COINCIDENCIAS_EXACTAS'\n : 'SIN_COINCIDENCIA_EXACTA',\n\n detalle:\n coincidenciasExactas.length > 1\n ? 'Se encontraron varias coincidencias exactas en BambooHR.'\n : 'No fue posible identificar de forma única al empleado en BambooHR.',\n\n candidatos:\n candidatosUnicos.map(candidato => {\n const displayName =\n obtenerNombreCompleto(\n candidato\n );\n\n return {\n employeeId:\n obtenerEmployeeId(\n candidato\n ),\n\n displayName,\n\n coincidenciaExacta:\n normalizar(displayName) ===\n nombreNormalizado\n };\n }),\n\n documentosGenerados: false\n }\n }\n ];\n }\n}\n\n/*\n * ============================================================\n * VALIDAR EMPLEADO SELECCIONADO\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\n/*\n * ============================================================\n * RESULTADO CORRECTO\n * ============================================================\n */\n\nreturn [\n {\n json: {\n /*\n * Conservamos toda la información proveniente del\n * Consolidado.\n */\n ...entrada,\n\n /*\n * Agregamos la información del perfil de BambooHR.\n */\n ...seleccionado,\n\n filaConsolidado,\n\n id: employeeId,\n employeeId,\n\n /*\n * Este nombre se utiliza para carpetas y documentos.\n */\n empleado:\n nombreConsolidado,\n\n nombreConsolidado,\n\n /*\n * Este es el nombre actual del perfil en BambooHR.\n */\n displayName,\n\n /*\n * Campo requerido por el IF \"¿Empleado exacto?\".\n */\n estadoMatch: 'OK',\n\n estado:\n seleccionManual\n ? 'COINCIDENCIA_MANUAL_CONFIRMADA'\n : 'COINCIDENCIA_EXACTA',\n\n coincidenciaExacta:\n !seleccionManual,\n\n seleccionManual,\n\n detalle:\n seleccionManual\n ? `Vinculación manual confirmada: ${nombreConsolidado} → ${displayName}, employeeId ${employeeId}.`\n : `Coincidencia exacta confirmada en BambooHR: ${displayName}, employeeId ${employeeId}.`,\n\n documentosGenerados: null\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-576,
32
],
"id": "3ffc7b27-0d30-4d77-90b7-f609fc25f8a2",
"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": [
-368,
32
],
"id": "56dd5b25-e947-4980-ac4a-ddf7ca15eb6b",
"name": "¿Empleado exacto?"
},
{
"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.estadoMatch,\n\n detalle:\n 'No fue posible identificar de forma única al empleado en BambooHR.',\n\n candidatos:\n item.candidatosEncontrados,\n\n documentosGenerados: false\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-160,
128
],
"id": "f0e9c660-81c1-4697-94e3-c37f54e69958",
"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": [
256,
16
],
"id": "931249ae-3c90-4b94-b066-e3f520b362e0",
"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": [
464,
16
],
"id": "09694117-c52e-4939-a156-04a170c163ef",
"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": "1mR9TagCwpMmGUXqoC9mnz6wcH9gUS4sa",
"mode": "id"
},
"options": {}
},
"type": "n8n-nodes-base.googleDrive",
"typeVersion": 3,
"position": [
1936,
-128
],
"id": "591938b6-e98e-4bfa-b549-407cdcbaf365",
"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 '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\n/*\n * ============================================================\n * REEMPLAZOS PARA EL FINIQUITO\n * ============================================================\n */\n\nconst reemplazosFiniquito = {\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 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": [
3424,
32
],
"id": "471a42bd-85d1-4a1c-95fd-549995b30476",
"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": [
4576,
-16
],
"id": "7f9cede5-f86f-4ee2-8307-035c40d99e2c",
"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": [
4848,
16
],
"id": "9eac5b6e-84a4-458e-b1fa-f1c3b556b7a0",
"name": "Resultado empleado procesado"
},
{
"parameters": {
"method": "POST",
"url": "https://www.googleapis.com/drive/v3/files/1HTnNL9bT4V3Sh0t9qHcVZwV2NznjOZ4pUkAAtt1-w8Q/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": [
3840,
32
],
"id": "6364ebf4-a990-4aaf-89cb-6e167faac977",
"name": "Copiar plantilla Finiquito",
"credentials": {
"googleOAuth2Api": {
"id": "eHseMeH39kRcXgOF",
"name": "Google account 2"
}
}
},
{
"parameters": {
"method": "POST",
"url": "https://www.googleapis.com/drive/v3/files/1obH3VCa9VNLYEJyBhd_T4xRKP8msQYluZrbLbp2_8WY/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": [
3696,
-288
],
"id": "4c9098bc-52e2-4b32-84ec-955b7b63239e",
"name": "Copiar plantilla Voucher",
"credentials": {
"googleOAuth2Api": {
"id": "eHseMeH39kRcXgOF",
"name": "Google account 2"
}
}
},
{
"parameters": {
"method": "POST",
"url": "https://www.googleapis.com/drive/v3/files/1SK42DQioyQUuB4EIM_i2dDyiqxzwoyfD-A2DVwAzHPE/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": [
3632,
320
],
"id": "9f47c165-87df-4bd4-add3-164593ff6e54",
"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": [
2176,
-128
],
"id": "60d5567b-9006-42ad-8ba4-6fdc869f5ddf",
"name": "Preparar datos carpeta empleado"
}
],
"connections": {
"Identificar candidatos DPI": {
"main": [
[
{
"node": "Seleccionar DPI único",
"type": "main",
"index": 0
}
]
]
},
"Seleccionar DPI único": {
"main": [
[
{
"node": "BambooHR - Descargar DPI Anderson",
"type": "main",
"index": 0
}
]
]
},
"BambooHR - Descargar DPI Anderson": {
"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 Anderson": {
"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 Anderson",
"type": "main",
"index": 0
},
{
"node": "Copiar plantilla Finiquito",
"type": "main",
"index": 0
}
]
]
},
"Personalizar Voucher Anderson": {
"main": [
[
{
"node": "Esperar documentos del empleado",
"type": "main",
"index": 0
}
]
]
},
"Personalizar Finiquito Anderson": {
"main": [
[
{
"node": "Esperar documentos del empleado",
"type": "main",
"index": 2
}
]
]
},
"Llenar formato cheque Anderson": {
"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 Anderson",
"type": "main",
"index": 0
}
]
]
},
"Copiar plantilla Voucher": {
"main": [
[
{
"node": "Personalizar Voucher Anderson",
"type": "main",
"index": 0
}
]
]
},
"Copiar formato cheque": {
"main": [
[
{
"node": "Llenar formato cheque Anderson",
"type": "main",
"index": 0
}
]
]
},
"Preparar datos carpeta empleado": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 1
}
]
]
}
},
"authors": "Isaac Aracena",
"name": "Version 7aedbe70",
"description": "",
"autosaved": true,
"workflowPublishHistory": [
{
"createdAt": "2026-07-11T01:47:01.856Z",
"id": 2793,
"workflowId": "KBV3AEEpys8KqPLX",
"versionId": "7aedbe70-75f8-4dc0-971f-130f9ced8ec1",
"event": "activated",
"userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029"
}
]
}
}