{ "name": "Seguimiento de Impuestos GLM - Buscar contacto en BambooHR", "nodes": [ { "parameters": { "httpMethod": "POST", "path": "seguimiento-impuestos-bamboohr-contacto", "responseMode": "responseNode", "options": {} }, "type": "n8n-nodes-base.webhook", "typeVersion": 2.1, "position": [ 320, 320 ], "id": "bd732e00-d574-41f7-a13c-bea5a2fbd589", "name": "Webhook - Buscar contacto BambooHR", "webhookId": "seguimiento-impuestos-bamboohr-contacto" }, { "parameters": { "jsCode": "let body = $json.body ?? $json;\nif (typeof body === 'string') {\n try { body = JSON.parse(body); }\n catch { body = {}; }\n}\nconst fullName = String(body.full_name ?? body.name ?? '').trim().replace(/\\s+/g, ' ');\nconst valid = fullName.split(' ').filter(Boolean).length >= 2 && fullName.length >= 5;\nreturn [{\n json: {\n valid,\n full_name: fullName,\n requested_by: String(body.requested_by ?? '').trim(),\n message: valid ? '' : 'Escribe el nombre y apellido de la persona.',\n },\n}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 560, 320 ], "id": "ec0dfdd1-8163-427b-af60-560a00011b9d", "name": "Validar solicitud" }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "ac176ddc-1e99-4d7b-b9f9-bea5a4182baa", "leftValue": "={{ $json.valid }}", "rightValue": true, "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 800, 320 ], "id": "967fe0fe-9857-44c6-a61f-76960961bbab", "name": "Solicitud válida" }, { "parameters": { "method": "POST", "url": "https://glm.bamboohr.com/api/v1/reports/custom?format=JSON&onlyCurrent=false", "authentication": "genericCredentialType", "genericAuthType": "httpBasicAuth", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "Accept", "value": "application/json" } ] }, "sendBody": true, "specifyBody": "json", "jsonBody": { "title": "Información completa de empleados BambooHR - Seguimiento de Impuestos", "fields": [ "firstName", "middleName", "lastName", "displayName", "department", "division", "location", "customPosicion-Cliente", "hireDate", "originalHireDate", "status", "employeeNumber", "workEmail", "terminationDate", "supervisor", "startDate", "jobTitle", "employmentStatus", "email", "country", "city", "address1" ] }, "options": { "response": { "response": { "responseFormat": "json" } }, "timeout": 300000 } }, "id": "e6a5c8a8-25b4-48dd-96e7-02e68912ca6a", "name": "Generar reporte completo BambooHR", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ 1056, 240 ], "retryOnFail": true, "maxTries": 3, "waitBetweenTries": 2000, "credentials": { "httpBasicAuth": { "id": "7VrpNZ2jBLmiJ35q", "name": "BambooHR GLM Full Access" } } }, { "parameters": { "jsCode": "const query = String($('Validar solicitud').item.json.full_name ?? '').trim();\nconst response = $json ?? {};\nconst employees = Array.isArray(response.employees)\n ? response.employees\n : Array.isArray(response.data)\n ? response.data\n : Array.isArray(response)\n ? response\n : [];\n\nconst normalize = (value) => String(value ?? '')\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase()\n .replace(/[^a-z0-9\\s]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n\nconst tokens = (value) => normalize(value).split(' ').filter((token) => token.length > 1);\n\nconst levenshtein = (a, b) => {\n if (!a) return b.length;\n if (!b) return a.length;\n const previous = Array.from({ length: b.length + 1 }, (_, index) => index);\n for (let i = 1; i <= a.length; i += 1) {\n let diagonal = previous[0];\n previous[0] = i;\n for (let j = 1; j <= b.length; j += 1) {\n const old = previous[j];\n const cost = a[i - 1] === b[j - 1] ? 0 : 1;\n previous[j] = Math.min(previous[j] + 1, previous[j - 1] + 1, diagonal + cost);\n diagonal = old;\n }\n }\n return previous[b.length];\n};\n\nconst similarity = (a, b) => {\n const longest = Math.max(a.length, b.length, 1);\n return 1 - levenshtein(a, b) / longest;\n};\n\nconst tokenF1 = (aTokens, bTokens) => {\n if (!aTokens.length || !bTokens.length) return 0;\n const a = new Set(aTokens);\n const b = new Set(bTokens);\n const common = [...a].filter((token) => b.has(token)).length;\n if (!common) return 0;\n const precision = common / b.size;\n const recall = common / a.size;\n return (2 * precision * recall) / (precision + recall);\n};\n\nconst countryAliases = [\n { code: 'DO', name: 'República Dominicana', aliases: ['dominican republic', 'republica dominicana', 'santo domingo', 'dominicana', 'rep dom'] },\n { code: 'PR', name: 'Puerto Rico', aliases: ['puerto rico'] },\n { code: 'JM', name: 'Jamaica', aliases: ['jamaica'] },\n { code: 'TT', name: 'Trinidad y Tobago', aliases: ['trinidad and tobago', 'trinidad & tobago', 'trinidad y tobago', 'trinidad', 'tobago'] },\n { code: 'SV', name: 'El Salvador', aliases: ['el salvador', 'salvador'] },\n { code: 'GT', name: 'Guatemala', aliases: ['guatemala'] },\n { code: 'HN', name: 'Honduras', aliases: ['honduras'] },\n { code: 'CR', name: 'Costa Rica', aliases: ['costa rica'] },\n { code: 'CO', name: 'Colombia', aliases: ['colombia'] },\n { code: 'MX', name: 'México', aliases: ['mexico', 'méxico'] },\n { code: 'NI', name: 'Nicaragua', aliases: ['nicaragua'] },\n { code: 'PA', name: 'Panamá', aliases: ['panama', 'panamá'] },\n];\n\nconst resolveCountry = (employee) => {\n const haystack = normalize([\n employee.country,\n employee.location,\n employee.city,\n employee.division,\n employee.department,\n employee.address1,\n ].filter(Boolean).join(' '));\n\n if (/\\bregional\\b|\\bregion\\b/.test(haystack)) {\n return { code: 'REG', name: 'Regional' };\n }\n\n for (const country of countryAliases) {\n if (country.aliases.some((alias) => haystack.includes(normalize(alias)))) {\n return { code: country.code, name: country.name };\n }\n }\n\n return { code: 'REG', name: 'Regional' };\n};\n\nconst resolveArea = (employee) => {\n const text = normalize([\n employee.department,\n employee.division,\n employee.jobTitle,\n employee['customPosicion-Cliente'],\n ].filter(Boolean).join(' '));\n\n if (/nomina|payroll|compensacion|compensation|beneficios|benefits/.test(text)) return 'nomina';\n if (/administracion|administrativo|administrative|finance|finanzas|contabilidad|accounting|impuesto|tax|tesoreria|treasury|conciliacion|compras|procurement/.test(text)) return 'admin';\n return 'other';\n};\n\n\nconst queryNormalized = normalize(query);\nconst queryTokens = tokens(query);\nconst candidates = [];\n\nfor (const employee of employees) {\n const constructedName = [employee.firstName, employee.middleName, employee.lastName]\n .filter(Boolean)\n .join(' ')\n .replace(/\\s+/g, ' ')\n .trim();\n const displayName = String(employee.displayName ?? '').trim();\n const fullName = displayName || constructedName;\n if (!fullName) continue;\n\n const nameVariants = [...new Set([fullName, constructedName, displayName].filter(Boolean))];\n let score = 0;\n for (const variant of nameVariants) {\n const normalizedVariant = normalize(variant);\n const variantTokens = tokens(variant);\n let current = 0;\n if (normalizedVariant === queryNormalized) current = 100;\n else if (normalizedVariant.startsWith(queryNormalized)) current = 91;\n else if (normalizedVariant.includes(queryNormalized)) current = 85;\n else if (queryNormalized.includes(normalizedVariant)) current = 78;\n\n const f1 = tokenF1(queryTokens, variantTokens);\n const editSimilarity = similarity(queryNormalized, normalizedVariant);\n current = Math.max(current, f1 * 82, editSimilarity * 76);\n if (queryTokens.every((token) => variantTokens.includes(token))) current += 8;\n score = Math.max(score, current);\n }\n\n const status = String(employee.status ?? employee.employmentStatus ?? '').trim();\n if (/active|activo/i.test(status)) score += 3;\n if (score < 45) continue;\n\n const country = resolveCountry(employee);\n const email = String(employee.workEmail || employee.email || '').trim().toLowerCase();\n\n candidates.push({\n id: String(employee.id ?? employee.employeeId ?? employee.employeeNumber ?? fullName),\n employeeNumber: String(employee.employeeNumber ?? ''),\n fullName,\n displayName: displayName || fullName,\n email,\n jobTitle: String(employee.jobTitle ?? employee['customPosicion-Cliente'] ?? '').trim(),\n department: String(employee.department ?? '').trim(),\n division: String(employee.division ?? '').trim(),\n location: String(employee.location ?? employee.city ?? '').trim(),\n countryCode: country.code,\n countryName: country.name,\n area: resolveArea(employee),\n status,\n score: Math.min(100, Math.round(score)),\n matchQuality: score >= 95 ? 'exacta' : score >= 72 ? 'alta' : 'media',\n });\n}\n\nconst unique = new Map();\nfor (const candidate of candidates.sort((a, b) => {\n const activeA = /active|activo/i.test(a.status) ? 1 : 0;\n const activeB = /active|activo/i.test(b.status) ? 1 : 0;\n return b.score - a.score || activeB - activeA || a.fullName.localeCompare(b.fullName);\n})) {\n const key = candidate.id || candidate.email || normalize(candidate.fullName);\n if (!unique.has(key)) unique.set(key, candidate);\n}\n\nreturn [{\n json: {\n ok: true,\n query,\n count: Math.min(unique.size, 8),\n candidates: [...unique.values()].slice(0, 8),\n },\n}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1312, 240 ], "id": "3edf99bc-ae3c-4473-a6cb-dd83e494f6cc", "name": "Buscar coincidencias robustas" }, { "parameters": { "respondWith": "json", "responseBody": "={{ $json }}", "options": { "responseCode": 200, "responseHeaders": { "entries": [ { "name": "Access-Control-Allow-Origin", "value": "*" }, { "name": "Cache-Control", "value": "no-store" } ] } } }, "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.4, "position": [ 1568, 240 ], "id": "9fd0868d-5c2c-44db-afcd-9233aaba7c68", "name": "Responder coincidencias" }, { "parameters": { "respondWith": "json", "responseBody": "={{ { ok: false, message: $json.message, candidates: [] } }}", "options": { "responseCode": 400, "responseHeaders": { "entries": [ { "name": "Access-Control-Allow-Origin", "value": "*" } ] } } }, "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.4, "position": [ 1056, 432 ], "id": "989420f5-bee2-492e-8710-a5bf97280ab8", "name": "Responder solicitud inválida" } ], "pinData": {}, "connections": { "Webhook - Buscar contacto BambooHR": { "main": [ [ { "node": "Validar solicitud", "type": "main", "index": 0 } ] ] }, "Validar solicitud": { "main": [ [ { "node": "Solicitud válida", "type": "main", "index": 0 } ] ] }, "Solicitud válida": { "main": [ [ { "node": "Generar reporte completo BambooHR", "type": "main", "index": 0 } ], [ { "node": "Responder solicitud inválida", "type": "main", "index": 0 } ] ] }, "Generar reporte completo BambooHR": { "main": [ [ { "node": "Buscar coincidencias robustas", "type": "main", "index": 0 } ] ] }, "Buscar coincidencias robustas": { "main": [ [ { "node": "Responder coincidencias", "type": "main", "index": 0 } ] ] } }, "active": false, "settings": { "executionOrder": "v1", "timezone": "America/Santo_Domingo", "callerPolicy": "workflowsFromSameOwner" }, "meta": { "templateCredsSetupCompleted": true } }