From d6349ce19206450e16cf0c58c3b03cdc821179bc Mon Sep 17 00:00:00 2001 From: Isaac_Aracena Date: Sun, 9 Aug 2026 04:01:16 +0000 Subject: [PATCH] Create backup: Finiquitos Guatemala - Procesar 1 empleado (1) --- ...uitos-guatemala-procesar-1-empleado-1.json | 2500 +++++++++++++++++ 1 file changed, 2500 insertions(+) create mode 100644 finiquitos-guatemala-procesar-1-empleado-1.json diff --git a/finiquitos-guatemala-procesar-1-empleado-1.json b/finiquitos-guatemala-procesar-1-empleado-1.json new file mode 100644 index 0000000..755e936 --- /dev/null +++ b/finiquitos-guatemala-procesar-1-empleado-1.json @@ -0,0 +1,2500 @@ +{ + "updatedAt": "2026-08-06T18:36:10.806Z", + "createdAt": "2026-07-10T14:34:52.976Z", + "id": "KBV3AEEpys8KqPLX", + "name": "Finiquitos Guatemala - Procesar 1 empleado (1)", + "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": [ + 1568, + 48 + ], + "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": [ + 1776, + 48 + ], + "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": [ + 2000, + 48 + ], + "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": [ + 2208, + 48 + ], + "id": "9a11b238-a0fa-408e-9427-16cfb83e6059", + "name": "Validar descarga DPI" + }, + { + "parameters": { + "resource": "document", + "modelId": { + "__rl": true, + "value": "models/gemini-2.5-flash-lite", + "mode": "list", + "cachedResultName": "models/gemini-2.5-flash-lite" + }, + "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": [ + 2416, + 48 + ], + "id": "19e4c7a6-286e-4e72-8fc5-72e222cd78e4", + "name": "Gemini - Extraer datos DPI", + "credentials": { + "googlePalmApi": { + "id": "6Jdcd9FONxG35YuJ", + "name": "Isaac - Gemini Api Gratis" + } + } + }, + { + "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": [ + 2640, + 48 + ], + "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": [ + 3392, + 64 + ], + "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": [ + 3600, + 64 + ], + "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": [ + 3808, + 64 + ], + "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": [ + 4016, + 64 + ], + "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": [ + 6112, + -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": [ + 6528, + 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": [ + 6352, + -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": [ + 6288, + 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": [ + 1168, + 48 + ], + "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": [ + 1376, + 48 + ], + "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": [ + 2848, + -96 + ], + "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": [ + 5872, + 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": [ + 7024, + -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": [ + 7296, + 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": [ + 6288, + 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": [ + 6144, + -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": [ + 6080, + 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": [ + 3088, + -96 + ], + "id": "60d5567b-9006-42ad-8ba4-6fdc869f5ddf", + "name": "Preparar datos carpeta empleado" + }, + { + "parameters": { + "content": "# 🔎 VALIDACIÓN Y RESOLUCIÓN DEL EMPLEADO\n\nRecibe la información de un empleado que debe procesarse para la generación de su finiquito en Guatemala.\n\n## Validación inicial\n\nEl bloque comprueba que la entrada contenga los datos mínimos necesarios para iniciar, como:\n\n- Nombre del empleado.\n- Identificador disponible.\n- Información enviada por el flujo principal.\n- Datos requeridos para localizar su carpeta y expediente.\n\n## Control de carpeta\n\nAntes de crear documentos, se busca si ya existe una carpeta asociada al empleado.\n\nEl flujo:\n\n1. Consulta las carpetas disponibles en Google Drive.\n2. Evalúa las coincidencias encontradas.\n3. Determina si corresponde continuar con una carpeta nueva.\n4. Evita procesar nuevamente a un empleado cuya carpeta ya existe.\n\nCuando se detecta una carpeta previamente creada, el flujo devuelve un resultado controlado y no duplica documentos.\n\n## Resolución en BambooHR\n\nSi el proceso puede continuar:\n\n1. Busca al empleado en BambooHR.\n2. Compara nombres, IDs y otros datos disponibles.\n3. Selecciona únicamente una coincidencia exacta.\n4. Conserva el ID oficial para consultar documentos y datos completos.\n\nCuando no existe una coincidencia suficientemente confiable:\n\n- No se descargan documentos.\n- No se crea una carpeta.\n- No se generan formatos.\n- Se devuelve el resultado “empleado no identificado”.\n\n## Reglas\n\n- No continuar con información incompleta.\n- No crear carpetas duplicadas.\n- No seleccionar automáticamente empleados ambiguos.\n- No confiar únicamente en una coincidencia parcial de nombre.\n- Utilizar el ID confirmado de BambooHR en las etapas posteriores.", + "height": 960, + "width": 3136, + "color": 2 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + -3040, + -448 + ], + "id": "05fa4267-462a-43fc-991a-b21f54240b78", + "name": "Sticky Note" + }, + { + "parameters": { + "content": "# 🪪 EXPEDIENTE, DPI Y CARPETA DEL EMPLEADO\n\nRecupera la información completa del empleado y guarda su documento de identificación dentro de una carpeta individual en Google Drive.\n\n## Consulta del expediente\n\nEl flujo obtiene desde BambooHR:\n\n- Datos personales.\n- Datos laborales.\n- Fechas relevantes.\n- Información necesaria para los documentos de finiquito.\n- Lista de archivos asociados al empleado.\n\n## Selección del DPI\n\nEntre los documentos disponibles:\n\n1. Identifica los posibles archivos de DPI.\n2. Filtra documentos que no corresponden.\n3. Selecciona un único candidato válido.\n4. Descarga el PDF original desde BambooHR.\n5. Verifica que exista contenido binario utilizable.\n\nNo debe continuar cuando:\n\n- No existe un DPI.\n- Existen varios candidatos ambiguos.\n- La descarga está vacía.\n- El archivo no puede procesarse correctamente.\n\n## Extracción con Gemini\n\nGemini analiza el documento para recuperar información útil del DPI.\n\nDespués del análisis:\n\n- Se limpia la respuesta de la IA.\n- Se normalizan nombres y números.\n- Se validan los campos detectados.\n- Se combinan los datos del DPI con el expediente de BambooHR.\n\nGemini complementa la información, pero no reemplaza las validaciones del workflow.\n\n## Carpeta y almacenamiento\n\nEl bloque crea una carpeta individual para el empleado y conserva:\n\n- ID de la carpeta.\n- Enlace de Google Drive.\n- Nombre normalizado del empleado.\n- Datos que utilizarán los documentos posteriores.\n\nEl DPI se guarda como PDF original dentro de esa carpeta, con un nombre identificable para facilitar su revisión.\n\n## Reglas\n\n- Mantener el DPI en formato PDF.\n- No modificar el documento original.\n- No subir el archivo antes de crear la carpeta.\n- No declarar éxito hasta verificar que el PDF existe en Drive.\n- Mantener relacionados el empleado, la carpeta y el DPI mediante sus IDs.", + "height": 1168, + "width": 3984, + "color": "#722C2C" + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 304, + -736 + ], + "id": "054ab1c2-12f4-433f-92c2-d83af4168990", + "name": "Sticky Note1" + }, + { + "parameters": { + "content": "# 📄 GENERACIÓN DE DOCUMENTOS DEL FINIQUITO\n\nPrepara y genera todos los documentos requeridos para completar el expediente de finiquito del empleado.\n\n## Preparación de datos\n\nAntes de modificar las plantillas, el flujo consolida información como:\n\n- Nombre completo.\n- Datos del DPI.\n- Cargo y departamento.\n- Fechas de ingreso y salida.\n- Montos calculados.\n- Información bancaria o de pago.\n- Identificador del empleado.\n- Carpeta de Google Drive donde se guardarán los archivos.\n\n## Voucher\n\nEl flujo:\n\n1. Copia la plantilla oficial del voucher.\n2. Crea una versión exclusiva para el empleado.\n3. Reemplaza los campos de la plantilla.\n4. Conserva el documento dentro de la carpeta correspondiente.\n\n## Consolidado\n\nActualiza el archivo consolidado con la información procesada del empleado para mantener control general sobre los finiquitos generados.\n\n## Finiquito\n\nEl flujo:\n\n1. Copia la plantilla oficial del finiquito.\n2. Personaliza nombres, fechas, datos laborales y montos.\n3. Conserva el documento generado como parte del expediente individual.\n\n## Formato de cheque\n\nTambién:\n\n1. Copia el formato base del cheque.\n2. Completa los datos del beneficiario y el pago.\n3. Guarda el archivo junto con los demás documentos.\n\n## Sincronización y cierre\n\nLas diferentes ramas se ejecutan en paralelo.\n\nEl nodo de espera solamente permite finalizar cuando se han completado:\n\n- Voucher.\n- Consolidado.\n- Finiquito.\n- Formato de cheque.\n\nAl terminar, se genera un resultado único con el estado del empleado procesado y las referencias de sus documentos.\n\n## Reglas\n\n- No modificar las plantillas originales.\n- Crear una copia independiente por empleado.\n- No finalizar mientras falte alguno de los documentos obligatorios.\n- Mantener todos los archivos dentro de la carpeta individual.\n- No informar éxito cuando alguna rama haya fallado.\n- Conservar los IDs y enlaces de los documentos para el flujo principal.", + "height": 1280, + "width": 2832, + "color": "#33704B" + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 4736, + -592 + ], + "id": "e53ea834-35ec-4e31-b004-c6fd11e77f48", + "name": "Sticky Note2" + } + ], + "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, + "timeSavedMode": "fixed", + "errorWorkflow": "puF4LUczoSz3hcek", + "timezone": "America/Santo_Domingo", + "callerPolicy": "workflowsFromSameOwner" + }, + "staticData": null, + "meta": { + "templateCredsSetupCompleted": true + }, + "versionId": "00f0a2be-e144-4cf4-8185-781988351da7", + "activeVersionId": "00f0a2be-e144-4cf4-8185-781988351da7", + "versionCounter": 241, + "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 ", + "type": "personal", + "icon": null, + "description": null, + "creatorId": "0a88c0b1-928e-4412-896e-c5d1c99b2029" + } + } + ], + "tags": [], + "activeVersion": { + "updatedAt": "2026-07-21T22:14:51.000Z", + "createdAt": "2026-07-21T22:14:47.297Z", + "versionId": "00f0a2be-e144-4cf4-8185-781988351da7", + "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": [ + 1568, + 48 + ], + "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": [ + 1776, + 48 + ], + "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": [ + 2000, + 48 + ], + "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": [ + 2208, + 48 + ], + "id": "9a11b238-a0fa-408e-9427-16cfb83e6059", + "name": "Validar descarga DPI" + }, + { + "parameters": { + "resource": "document", + "modelId": { + "__rl": true, + "value": "models/gemini-2.5-flash-lite", + "mode": "list", + "cachedResultName": "models/gemini-2.5-flash-lite" + }, + "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": [ + 2416, + 48 + ], + "id": "19e4c7a6-286e-4e72-8fc5-72e222cd78e4", + "name": "Gemini - Extraer datos DPI", + "credentials": { + "googlePalmApi": { + "id": "6Jdcd9FONxG35YuJ", + "name": "Isaac - Gemini Api Gratis" + } + } + }, + { + "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": [ + 2640, + 48 + ], + "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": [ + 3392, + 64 + ], + "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": [ + 3600, + 64 + ], + "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": [ + 3808, + 64 + ], + "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": [ + 4016, + 64 + ], + "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": [ + 6112, + -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": [ + 6528, + 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": [ + 6352, + -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": [ + 6288, + 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": [ + 1168, + 48 + ], + "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": [ + 1376, + 48 + ], + "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": [ + 2848, + -96 + ], + "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": [ + 5872, + 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": [ + 7024, + -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": [ + 7296, + 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": [ + 6288, + 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": [ + 6144, + -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": [ + 6080, + 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": [ + 3088, + -96 + ], + "id": "60d5567b-9006-42ad-8ba4-6fdc869f5ddf", + "name": "Preparar datos carpeta empleado" + }, + { + "parameters": { + "content": "# 🔎 VALIDACIÓN Y RESOLUCIÓN DEL EMPLEADO\n\nRecibe la información de un empleado que debe procesarse para la generación de su finiquito en Guatemala.\n\n## Validación inicial\n\nEl bloque comprueba que la entrada contenga los datos mínimos necesarios para iniciar, como:\n\n- Nombre del empleado.\n- Identificador disponible.\n- Información enviada por el flujo principal.\n- Datos requeridos para localizar su carpeta y expediente.\n\n## Control de carpeta\n\nAntes de crear documentos, se busca si ya existe una carpeta asociada al empleado.\n\nEl flujo:\n\n1. Consulta las carpetas disponibles en Google Drive.\n2. Evalúa las coincidencias encontradas.\n3. Determina si corresponde continuar con una carpeta nueva.\n4. Evita procesar nuevamente a un empleado cuya carpeta ya existe.\n\nCuando se detecta una carpeta previamente creada, el flujo devuelve un resultado controlado y no duplica documentos.\n\n## Resolución en BambooHR\n\nSi el proceso puede continuar:\n\n1. Busca al empleado en BambooHR.\n2. Compara nombres, IDs y otros datos disponibles.\n3. Selecciona únicamente una coincidencia exacta.\n4. Conserva el ID oficial para consultar documentos y datos completos.\n\nCuando no existe una coincidencia suficientemente confiable:\n\n- No se descargan documentos.\n- No se crea una carpeta.\n- No se generan formatos.\n- Se devuelve el resultado “empleado no identificado”.\n\n## Reglas\n\n- No continuar con información incompleta.\n- No crear carpetas duplicadas.\n- No seleccionar automáticamente empleados ambiguos.\n- No confiar únicamente en una coincidencia parcial de nombre.\n- Utilizar el ID confirmado de BambooHR en las etapas posteriores.", + "height": 960, + "width": 3136, + "color": 2 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + -3040, + -448 + ], + "id": "05fa4267-462a-43fc-991a-b21f54240b78", + "name": "Sticky Note" + }, + { + "parameters": { + "content": "# 🪪 EXPEDIENTE, DPI Y CARPETA DEL EMPLEADO\n\nRecupera la información completa del empleado y guarda su documento de identificación dentro de una carpeta individual en Google Drive.\n\n## Consulta del expediente\n\nEl flujo obtiene desde BambooHR:\n\n- Datos personales.\n- Datos laborales.\n- Fechas relevantes.\n- Información necesaria para los documentos de finiquito.\n- Lista de archivos asociados al empleado.\n\n## Selección del DPI\n\nEntre los documentos disponibles:\n\n1. Identifica los posibles archivos de DPI.\n2. Filtra documentos que no corresponden.\n3. Selecciona un único candidato válido.\n4. Descarga el PDF original desde BambooHR.\n5. Verifica que exista contenido binario utilizable.\n\nNo debe continuar cuando:\n\n- No existe un DPI.\n- Existen varios candidatos ambiguos.\n- La descarga está vacía.\n- El archivo no puede procesarse correctamente.\n\n## Extracción con Gemini\n\nGemini analiza el documento para recuperar información útil del DPI.\n\nDespués del análisis:\n\n- Se limpia la respuesta de la IA.\n- Se normalizan nombres y números.\n- Se validan los campos detectados.\n- Se combinan los datos del DPI con el expediente de BambooHR.\n\nGemini complementa la información, pero no reemplaza las validaciones del workflow.\n\n## Carpeta y almacenamiento\n\nEl bloque crea una carpeta individual para el empleado y conserva:\n\n- ID de la carpeta.\n- Enlace de Google Drive.\n- Nombre normalizado del empleado.\n- Datos que utilizarán los documentos posteriores.\n\nEl DPI se guarda como PDF original dentro de esa carpeta, con un nombre identificable para facilitar su revisión.\n\n## Reglas\n\n- Mantener el DPI en formato PDF.\n- No modificar el documento original.\n- No subir el archivo antes de crear la carpeta.\n- No declarar éxito hasta verificar que el PDF existe en Drive.\n- Mantener relacionados el empleado, la carpeta y el DPI mediante sus IDs.", + "height": 1168, + "width": 3984, + "color": "#722C2C" + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 304, + -736 + ], + "id": "054ab1c2-12f4-433f-92c2-d83af4168990", + "name": "Sticky Note1" + }, + { + "parameters": { + "content": "# 📄 GENERACIÓN DE DOCUMENTOS DEL FINIQUITO\n\nPrepara y genera todos los documentos requeridos para completar el expediente de finiquito del empleado.\n\n## Preparación de datos\n\nAntes de modificar las plantillas, el flujo consolida información como:\n\n- Nombre completo.\n- Datos del DPI.\n- Cargo y departamento.\n- Fechas de ingreso y salida.\n- Montos calculados.\n- Información bancaria o de pago.\n- Identificador del empleado.\n- Carpeta de Google Drive donde se guardarán los archivos.\n\n## Voucher\n\nEl flujo:\n\n1. Copia la plantilla oficial del voucher.\n2. Crea una versión exclusiva para el empleado.\n3. Reemplaza los campos de la plantilla.\n4. Conserva el documento dentro de la carpeta correspondiente.\n\n## Consolidado\n\nActualiza el archivo consolidado con la información procesada del empleado para mantener control general sobre los finiquitos generados.\n\n## Finiquito\n\nEl flujo:\n\n1. Copia la plantilla oficial del finiquito.\n2. Personaliza nombres, fechas, datos laborales y montos.\n3. Conserva el documento generado como parte del expediente individual.\n\n## Formato de cheque\n\nTambién:\n\n1. Copia el formato base del cheque.\n2. Completa los datos del beneficiario y el pago.\n3. Guarda el archivo junto con los demás documentos.\n\n## Sincronización y cierre\n\nLas diferentes ramas se ejecutan en paralelo.\n\nEl nodo de espera solamente permite finalizar cuando se han completado:\n\n- Voucher.\n- Consolidado.\n- Finiquito.\n- Formato de cheque.\n\nAl terminar, se genera un resultado único con el estado del empleado procesado y las referencias de sus documentos.\n\n## Reglas\n\n- No modificar las plantillas originales.\n- Crear una copia independiente por empleado.\n- No finalizar mientras falte alguno de los documentos obligatorios.\n- Mantener todos los archivos dentro de la carpeta individual.\n- No informar éxito cuando alguna rama haya fallado.\n- Conservar los IDs y enlaces de los documentos para el flujo principal.", + "height": 1280, + "width": 2832, + "color": "#33704B" + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 4736, + -592 + ], + "id": "e53ea834-35ec-4e31-b004-c6fd11e77f48", + "name": "Sticky Note2" + } + ], + "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 00f0a2be", + "description": "", + "autosaved": true, + "workflowPublishHistory": [ + { + "createdAt": "2026-07-21T22:14:51.434Z", + "id": 3342, + "workflowId": "KBV3AEEpys8KqPLX", + "versionId": "00f0a2be-e144-4cf4-8185-781988351da7", + "event": "activated", + "userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029" + }, + { + "createdAt": "2026-07-25T17:32:19.421Z", + "id": 3478, + "workflowId": "KBV3AEEpys8KqPLX", + "versionId": "00f0a2be-e144-4cf4-8185-781988351da7", + "event": "activated", + "userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029" + }, + { + "createdAt": "2026-07-25T17:32:19.372Z", + "id": 3477, + "workflowId": "KBV3AEEpys8KqPLX", + "versionId": "00f0a2be-e144-4cf4-8185-781988351da7", + "event": "deactivated", + "userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029" + } + ] + } +} \ No newline at end of file