diff --git a/cruce-de-seguridad-social-el-salvador.json b/cruce-de-seguridad-social-el-salvador.json index e68c44e..142b8e1 100644 --- a/cruce-de-seguridad-social-el-salvador.json +++ b/cruce-de-seguridad-social-el-salvador.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-08-07T13:18:07.284Z", + "updatedAt": "2026-08-20T12:23:25.849Z", "createdAt": "2026-07-17T18:30:45.873Z", "id": "wCud4hOciMao0Ab3", "name": "Cruce de Seguridad Social - El Salvador", @@ -89,33 +89,33 @@ "options": { "appendAttribution": false, "buttonLabel": "Procesar cruce", - "path": "ef422914-fb5b-4f33-8283-bc2d2ea9abfc", + "path": "07be2ab9-359b-43b1-ad55-2da000e92850", "ignoreBots": true, "useWorkflowTimezone": true, "customCss": ":root{--font-family:Arial,Helvetica,sans-serif;--color-primary:#6CC24A;--color-primary-shade-1:#5AAF3E;--color-secondary:#4F758B;--color-card-background:#FFFFFF;--color-card-border:#D0D0D0;--border-radius-card:14px;--border-radius-input:8px;}\nbody{background:linear-gradient(180deg,#EEF6E8 0,#F4F7F9 240px);font-family:Arial,Helvetica,sans-serif;color:#4A4A4A;}\nform{box-shadow:0 18px 45px rgba(79,117,139,.13);}\nh1{color:#4F758B!important;letter-spacing:-.3px;}\nbutton[type=submit]{font-weight:700;letter-spacing:.2px;box-shadow:none;}\ninput:focus,select:focus{border-color:#6CC24A!important;box-shadow:0 0 0 3px rgba(108,194,74,.14)!important;}" } }, - "id": "562937d1-e0f5-4f04-a534-e6155728fe28", + "id": "478b7167-dde1-4fba-b11f-42caa0f70b6a", "name": "Formulario de cruce", "type": "n8n-nodes-base.formTrigger", "typeVersion": 2.5, "position": [ - 576, - 768 + -1392, + 2832 ], - "webhookId": "ef422914-fb5b-4f33-8283-bc2d2ea9abfc" + "webhookId": "07be2ab9-359b-43b1-ad55-2da000e92850" }, { "parameters": { - "jsCode": "const formItem = $items('Formulario de cruce')[0] || {};\n const recipient = 'iaracena@gomezleemarketing.com, msoto@gomezleemarketing.com, iherrera@gomezleemarketing.com, mgomez@gomezleemarketing.com, vparamo@gomezleemarketing.com, asrodriguez@gomezleemarketing.com, jgomez@gomezleemarketing.com';\n const binaries = formItem.binary || {};\n const keys = Object.keys(binaries);\n function normalize(value) {\n return String(value || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase();\n }\n function score(key) {\n const file = binaries[key] || {};\n const text = normalize(key + ' ' + (file.fileName || ''));\n let value = 0;\n if (key === 'nomina') value += 100;\n if (text.includes('nomina')) value += 30;\n if (text.includes('payroll')) value += 15;\n if (text.includes('planilla')) value -= 50;\n return value;\n }\n const selectedKey = keys.slice().sort((a, b) => score(b) - score(a))[0];\n if (!selectedKey || score(selectedKey) <= 0) {\n throw new Error('No se encontró el archivo de nómina. Adjunta un .xlsx o .xlsm en el campo Nómina.');\n }\n const file = binaries[selectedKey];\n if (!/\\.(xlsx|xlsm)$/i.test(String(file.fileName || ''))) {\n throw new Error('La nómina debe estar en formato .xlsx o .xlsm.');\n }\n return [{\n json: {\n tipoArchivo: 'nomina',\n archivoNomina: file.fileName || 'nomina.xlsx',\n correoDestino: recipient,\n mes: formItem.json?.mes || '',\n anio: formItem.json?.anio || ''\n },\n binary: { data: file }\n }];" + "jsCode": "const formItem = $items('Formulario de cruce')[0] || {};\n const recipient = 'iaracena@gomezleemarketing.com, msoto@gomezleemarketing.com, iherrera@gomezleemarketing.com, mgomez@gomezleemarketing.com, vparamo@gomezleemarketing.com, asrodriguez@gomezleemarketing.com, jgomez@gomezleemarketing.com, ymadera@gomezleemarketing.com';\n const binaries = formItem.binary || {};\n const keys = Object.keys(binaries);\n function normalize(value) {\n return String(value || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase();\n }\n function score(key) {\n const file = binaries[key] || {};\n const text = normalize(key + ' ' + (file.fileName || ''));\n let value = 0;\n if (key === 'nomina') value += 100;\n if (text.includes('nomina')) value += 30;\n if (text.includes('payroll')) value += 15;\n if (text.includes('planilla')) value -= 50;\n return value;\n }\n const selectedKey = keys.slice().sort((a, b) => score(b) - score(a))[0];\n if (!selectedKey || score(selectedKey) <= 0) {\n throw new Error('No se encontró el archivo de nómina. Adjunta un .xlsx o .xlsm en el campo Nómina.');\n }\n const file = binaries[selectedKey];\n if (!/\\.(xlsx|xlsm)$/i.test(String(file.fileName || ''))) {\n throw new Error('La nómina debe estar en formato .xlsx o .xlsm.');\n }\n return [{\n json: {\n tipoArchivo: 'nomina',\n archivoNomina: file.fileName || 'nomina.xlsx',\n correoDestino: recipient,\n mes: formItem.json?.mes || '',\n anio: formItem.json?.anio || ''\n },\n binary: { data: file }\n }];" }, - "id": "a165b03b-eb8e-45c1-8694-0450a28ff20e", + "id": "21d518ca-65a3-4e90-93da-61eb1199cdef", "name": "Preparar archivo Nómina", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 752, - 624 + -1216, + 2688 ], "onError": "continueErrorOutput" }, @@ -131,13 +131,13 @@ "sheetName": "Nomina" } }, - "id": "27f85e3c-bac3-4ad0-adf7-ed6468f13b1a", + "id": "723c4de2-6227-4b21-9949-cd1da107f868", "name": "Extraer Nómina · hoja Nomina", "type": "n8n-nodes-base.extractFromFile", "typeVersion": 1.1, "position": [ - 1088, - 512 + -880, + 2576 ], "onError": "continueErrorOutput" }, @@ -145,13 +145,13 @@ "parameters": { "jsCode": "const formItem = $items('Formulario de cruce')[0] || {};\n const binaries = formItem.binary || {};\n const keys = Object.keys(binaries);\n function normalize(value) {\n return String(value || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase();\n }\n function score(key) {\n const file = binaries[key] || {};\n const text = normalize(key + ' ' + (file.fileName || ''));\n let value = 0;\n if (key === 'planilla_seguridad_social') value += 100;\n if (text.includes('planilla')) value += 35;\n if (text.includes('seguridad')) value += 20;\n if (text.includes('nomina')) value -= 50;\n return value;\n }\n const selectedKey = keys.slice().sort((a, b) => score(b) - score(a))[0];\n if (!selectedKey || score(selectedKey) <= 0) {\n throw new Error('No se encontró la planilla de Seguridad Social. Adjunta un .xlsx o .xlsm en el campo correspondiente.');\n }\n const file = binaries[selectedKey];\n if (!/\\.(xlsx|xlsm)$/i.test(String(file.fileName || ''))) {\n throw new Error('La planilla de Seguridad Social debe estar en formato .xlsx o .xlsm.');\n }\n return [{\n json: {\n tipoArchivo: 'planilla',\n archivoPlanilla: file.fileName || 'planilla.xlsm'\n },\n binary: { data: file }\n }];" }, - "id": "9e9f6a87-232a-4f0a-9352-aacd1e8b379a", + "id": "0f3dcb1c-31c2-4066-9fb2-096871529406", "name": "Preparar archivo Planilla", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 1600, - 656 + -368, + 2720 ], "onError": "continueErrorOutput" }, @@ -167,13 +167,13 @@ "sheetName": "PLANILLA_UNICA" } }, - "id": "c6b2e409-97da-4abf-b525-c9198bf48579", + "id": "deb63adf-9f0e-4c3e-b2e5-6af866b39b24", "name": "Extraer Planilla", "type": "n8n-nodes-base.extractFromFile", "typeVersion": 1.1, "position": [ - 1856, - 656 + -112, + 2720 ], "onError": "continueErrorOutput" }, @@ -181,13 +181,13 @@ "parameters": { "jsCode": "const payrollRows = $items('Extraer Nómina').length;\n const planRows = $items('Extraer Planilla').length;\n if (!payrollRows) {\n throw new Error('No se pudo leer una hoja de nómina válida con registros legibles.');\n }\n if (!planRows) {\n throw new Error('La hoja \"PLANILLA_UNICA\" no contiene registros legibles desde la fila 4.');\n }\n return [{ json: { payrollRows, planRows, listoParaBamboo: true } }];" }, - "id": "ceabea56-3cb4-4093-8ea4-0f847d44aafe", + "id": "693f4c9a-49d0-48fc-bb4d-ee9530d69388", "name": "Validar lecturas", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 2208, - 800 + 256, + 2864 ], "onError": "continueErrorOutput" }, @@ -234,13 +234,13 @@ "timeout": 300000 } }, - "id": "8e202b5d-0f0b-4228-acc7-43f06c0d79e0", + "id": "79a139c6-5b1a-43e6-a588-f1b52f41e231", "name": "Obtener reporte completo BambooHR", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ - 2464, - 800 + 512, + 2864 ], "executeOnce": true, "retryOnFail": true, @@ -254,15 +254,15 @@ }, { "parameters": { - "jsCode": "const payrollSource = $items('Extraer Nómina').map(item => item.json || {});\n const planSource = $items('Extraer Planilla').map(item => item.json || {});\n const formItem = $items('Formulario de cruce')[0] || {};\n const form = formItem.json || {};\n const bambooInput = $input.all();\n\n function clean(value) {\n return String(value ?? '').replace(/\\s+/g, ' ').trim();\n }\n function stripAccents(value) {\n return String(value || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '');\n }\n function normalizeHeader(value) {\n return stripAccents(clean(value)).toUpperCase().replace(/[^A-Z0-9]+/g, ' ').trim();\n }\n function normalizeName(value) {\n return stripAccents(clean(value)).toUpperCase().replace(/[^A-Z0-9 ]+/g, ' ').replace(/\\s+/g, ' ').trim();\n }\n const STOP = new Set(['DE', 'DEL', 'LA', 'LAS', 'LOS', 'Y', 'DA', 'DAS', 'DO', 'VDA']);\n function nameTokens(value) {\n return normalizeName(value).split(' ').filter(token => token.length > 1 && !STOP.has(token));\n }\n function canonicalName(value) {\n return nameTokens(value).slice().sort().join(' ');\n }\n function nameScore(a, b) {\n const A = [...new Set(nameTokens(a))];\n const B = [...new Set(nameTokens(b))];\n if (!A.length || !B.length) return { score: 0, shared: 0 };\n if (A.slice().sort().join(' ') === B.slice().sort().join(' ')) return { score: 1, shared: A.length };\n const setB = new Set(B);\n const shared = A.filter(token => setB.has(token)).length;\n if (shared < 2) return { score: 0, shared };\n const coverage = shared / Math.min(A.length, B.length);\n const dice = (2 * shared) / (A.length + B.length);\n return { score: 0.7 * coverage + 0.3 * dice, shared };\n }\n function parseNumber(value) {\n if (typeof value === 'number') return Number.isFinite(value) ? value : 0;\n let text = clean(value).replace(/[^\\d,.\\-]/g, '');\n if (!text) return 0;\n const comma = text.lastIndexOf(',');\n const dot = text.lastIndexOf('.');\n if (comma > dot) text = text.replace(/\\./g, '').replace(',', '.');\n else text = text.replace(/,/g, '');\n const number = Number(text);\n return Number.isFinite(number) ? number : 0;\n }\n function valueAt(row, aliases, fallbackIndex) {\n const entries = Object.entries(row || {});\n const wanted = aliases.map(normalizeHeader);\n for (const [key, value] of entries) {\n const normalized = normalizeHeader(key);\n if (wanted.includes(normalized)) return value;\n }\n return entries[fallbackIndex]?.[1];\n }\n function normalizeDui(value) {\n let digits = clean(value).replace(/\\D/g, '');\n if (digits.length === 8) digits = digits.padStart(9, '0');\n if (digits.length !== 9) return '';\n return digits.slice(0, 8) + '-' + digits.slice(8);\n }\n function chooseName(values) {\n const names = [...new Set((values || []).map(clean).filter(Boolean))];\n names.sort((a, b) => nameTokens(b).length - nameTokens(a).length || b.length - a.length);\n return names[0] || '';\n }\n function round2(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n }\n function uniqueJoin(values) {\n return [...new Set((values || []).map(clean).filter(Boolean))].join(' | ');\n }\n function isTotalName(value) {\n return /^TOTAL(?:\\s|$)/i.test(clean(value));\n }\n\n const monthNumber = String(form.mes || '').padStart(2, '0');\n const yearNumber = Number(form.anio);\n if (!/^(0[1-9]|1[0-2])$/.test(monthNumber)) {\n throw new Error('Selecciona un mes válido entre 01 y 12.');\n }\n if (!Number.isInteger(yearNumber) || yearNumber < 2020 || yearNumber > 2100) {\n throw new Error('Ingresa un año válido.');\n }\n const monthNames = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];\n const periodLabel = monthNames[Number(monthNumber) - 1] + ' ' + yearNumber;\n const periodFile = yearNumber + '-' + monthNumber;\n\n const payrollRecords = [];\n for (let index = 0; index < payrollSource.length; index++) {\n const row = payrollSource[index];\n const rawDui = valueAt(row, ['DUI'], 3);\n const name = clean(valueAt(row, ['COLABORADOR', 'EMPLEADO', 'NOMBRE'], 4));\n const project = clean(valueAt(row, ['PROYECTO'], 2));\n const amount = parseNumber(valueAt(row, ['SUB TOTAL PERCIBIDO', 'TOTAL PERCIBIDO'], 15));\n const dui = normalizeDui(rawDui);\n if ((!name && !dui) || isTotalName(name)) continue;\n payrollRecords.push({\n sourceIndex: index + 2,\n dui,\n rawDui: clean(rawDui),\n name,\n project,\n amount\n });\n }\n\n const planRecords = [];\n for (let index = 0; index < planSource.length; index++) {\n const row = planSource[index];\n const rawDui = valueAt(row, ['NUMERO DE DOCUMENTO AFILIADO', 'DUI'], 4);\n const nameParts = [\n valueAt(row, ['PRIMER NOMBRE'], 8),\n valueAt(row, ['SEGUNDO NOMBRE'], 9),\n valueAt(row, ['PRIMER APELLIDO'], 10),\n valueAt(row, ['SEGUNDO APELLIDO'], 11),\n valueAt(row, ['APELLIDO DE CASADA'], 12)\n ].map(clean).filter(Boolean);\n const name = clean(nameParts.join(' '));\n const salary = parseNumber(valueAt(row, ['SALARIO'], 13));\n const additional = parseNumber(valueAt(row, ['PAGO ADICIONAL'], 14));\n const vacation = parseNumber(valueAt(row, ['MONTO DE VACACION', 'MONTO DE VACACIONES'], 15));\n const observations = uniqueJoin([\n valueAt(row, ['CODIGO DE OBSERVACION 1'], 19),\n valueAt(row, ['CODIGO DE OBSERVACION 2'], 20)\n ]);\n const dui = normalizeDui(rawDui);\n if ((!name && !dui) || isTotalName(name)) continue;\n planRecords.push({\n sourceIndex: index + 5,\n dui,\n rawDui: clean(rawDui),\n name,\n amount: salary + additional + vacation,\n salary,\n additional,\n vacation,\n observations\n });\n }\n\n if (!payrollRecords.length) {\n throw new Error('No se encontraron empleados en la hoja \"Nomina\". Se esperaba el encabezado en la fila 1 y datos desde la fila 2.');\n }\n if (!planRecords.length) {\n throw new Error('No se encontraron empleados en \"PLANILLA_UNICA\". Se esperaba el encabezado en la fila 4 y datos desde la fila 5.');\n }\n if (!payrollRecords.some(record => record.dui)) {\n throw new Error('No se pudo leer ningún DUI válido en la columna D de la nómina.');\n }\n if (!planRecords.some(record => record.dui)) {\n throw new Error('No se pudo leer ningún DUI válido en la columna E de la planilla.');\n }\n\n function aggregate(records, source) {\n const groups = new Map();\n for (const record of records) {\n if (!record.dui) continue;\n if (!groups.has(record.dui)) {\n groups.set(record.dui, {\n dui: record.dui,\n source,\n amount: 0,\n names: [],\n projects: [],\n observations: [],\n rows: []\n });\n }\n const group = groups.get(record.dui);\n group.amount += Number(record.amount) || 0;\n group.names.push(record.name);\n if (record.project) group.projects.push(record.project);\n if (record.observations) group.observations.push(record.observations);\n group.rows.push(record.sourceIndex);\n }\n for (const group of groups.values()) {\n group.name = chooseName(group.names);\n group.project = uniqueJoin(group.projects);\n group.observation = uniqueJoin(group.observations);\n group.nameVariants = [...new Set(group.names.map(normalizeName).filter(Boolean))];\n }\n return groups;\n }\n\n const payrollGroups = aggregate(payrollRecords, 'Nómina');\n const planGroups = aggregate(planRecords, 'Planilla');\n const payrollMissingDui = payrollRecords.filter(record => !record.dui);\n const planMissingDui = planRecords.filter(record => !record.dui);\n const findings = [];\n\n function addFinding(data) {\n findings.push({\n tipo: data.tipo || '',\n severidad: data.severidad || 'Media',\n duiNomina: data.duiNomina || '',\n duiPlanilla: data.duiPlanilla || '',\n nombreNomina: data.nombreNomina || '',\n nombrePlanilla: data.nombrePlanilla || '',\n montoNomina: data.montoNomina === '' || data.montoNomina == null ? '' : Number(data.montoNomina),\n montoPlanilla: data.montoPlanilla === '' || data.montoPlanilla == null ? '' : Number(data.montoPlanilla),\n diferencia: data.diferencia === '' || data.diferencia == null ? '' : Number(data.diferencia),\n accion: data.accion || ''\n });\n }\n\n for (const record of payrollMissingDui) {\n addFinding({\n tipo: 'DUI faltante o inválido en nómina',\n severidad: 'Alta',\n nombreNomina: record.name,\n montoNomina: record.amount,\n accion: 'Completar o corregir el DUI en la nómina antes de presentar la planilla.'\n });\n }\n for (const record of planMissingDui) {\n addFinding({\n tipo: 'DUI faltante o inválido en planilla',\n severidad: 'Alta',\n nombrePlanilla: record.name,\n montoPlanilla: record.amount,\n accion: 'Completar o corregir el DUI en la planilla de Seguridad Social.'\n });\n }\n\n for (const [dui, group] of payrollGroups) {\n if (group.nameVariants.length > 1) {\n addFinding({\n tipo: 'Un DUI tiene varios nombres en nómina',\n severidad: 'Alta',\n duiNomina: dui,\n nombreNomina: uniqueJoin(group.names),\n montoNomina: group.amount,\n accion: 'Validar que el DUI no esté asociado a más de una persona.'\n });\n }\n }\n for (const [dui, group] of planGroups) {\n if (group.nameVariants.length > 1) {\n addFinding({\n tipo: 'Un DUI tiene varios nombres en planilla',\n severidad: 'Alta',\n duiPlanilla: dui,\n nombrePlanilla: uniqueJoin(group.names),\n montoPlanilla: group.amount,\n accion: 'Validar las filas duplicadas y la identidad del afiliado.'\n });\n }\n }\n\n function flagSameNameMultipleDuis(groups, sourceLabel) {\n const byName = new Map();\n for (const group of groups.values()) {\n const key = canonicalName(group.name);\n if (!key) continue;\n if (!byName.has(key)) byName.set(key, []);\n byName.get(key).push(group);\n }\n for (const list of byName.values()) {\n const duis = [...new Set(list.map(group => group.dui))];\n if (duis.length <= 1) continue;\n addFinding({\n tipo: 'Misma persona con más de un DUI en ' + sourceLabel.toLowerCase(),\n severidad: 'Alta',\n duiNomina: sourceLabel === 'Nómina' ? duis.join(' | ') : '',\n duiPlanilla: sourceLabel === 'Planilla' ? duis.join(' | ') : '',\n nombreNomina: sourceLabel === 'Nómina' ? list[0].name : '',\n nombrePlanilla: sourceLabel === 'Planilla' ? list[0].name : '',\n montoNomina: sourceLabel === 'Nómina' ? list.reduce((sum, group) => sum + group.amount, 0) : '',\n montoPlanilla: sourceLabel === 'Planilla' ? list.reduce((sum, group) => sum + group.amount, 0) : '',\n accion: 'Confirmar el DUI correcto y unificar el registro.'\n });\n }\n }\n flagSameNameMultipleDuis(payrollGroups, 'Nómina');\n flagSameNameMultipleDuis(planGroups, 'Planilla');\n\n const commonDuis = [...payrollGroups.keys()].filter(dui => planGroups.has(dui));\n for (const dui of commonDuis) {\n const payroll = payrollGroups.get(dui);\n const plan = planGroups.get(dui);\n if (normalizeName(payroll.name) !== normalizeName(plan.name)) {\n addFinding({\n tipo: 'Nombre no idéntico con el mismo DUI',\n severidad: 'Media',\n duiNomina: dui,\n duiPlanilla: dui,\n nombreNomina: payroll.name,\n nombrePlanilla: plan.name,\n montoNomina: payroll.amount,\n montoPlanilla: plan.amount,\n accion: 'Verificar la escritura del nombre; el DUI coincide en ambas fuentes.'\n });\n }\n const delta = payroll.amount - plan.amount;\n if (Math.abs(delta) > 0.0000001) {\n const isSubcent = Math.abs(round2(payroll.amount) - round2(plan.amount)) <= 0.0000001;\n addFinding({\n tipo: isSubcent ? 'Diferencia subcentavo' : 'Diferencia de monto',\n severidad: isSubcent ? 'Informativa' : 'Alta',\n duiNomina: dui,\n duiPlanilla: dui,\n nombreNomina: payroll.name,\n nombrePlanilla: plan.name,\n montoNomina: payroll.amount,\n montoPlanilla: plan.amount,\n diferencia: delta,\n accion: isSubcent\n ? 'Los montos coinciden a dos decimales; conservar como control de precisión.'\n : 'Revisar salario, pago adicional, vacaciones, subsidios y acumulación de filas.'\n });\n }\n }\n\n const onlyPayroll = [...payrollGroups.values()].filter(group => !planGroups.has(group.dui));\n const onlyPlan = [...planGroups.values()].filter(group => !payrollGroups.has(group.dui));\n const usedPlan = new Set();\n\n function bestUnmatchedName(targetName, candidates, excludedIndexes) {\n const scored = candidates.map((candidate, index) => {\n const scoreData = nameScore(targetName, candidate.name);\n return { candidate, index, score: scoreData.score, shared: scoreData.shared };\n }).filter(result => !excludedIndexes.has(result.index) && result.score > 0)\n .sort((a, b) => b.score - a.score);\n const best = scored[0] || null;\n const second = scored[1] || null;\n if (!best) return { accepted: false, best: null };\n const margin = second ? best.score - second.score : best.score;\n const exact = canonicalName(targetName) === canonicalName(best.candidate.name);\n const accepted = exact || (best.shared >= 2 && best.score >= 0.88 && (margin >= 0.08 || best.score >= 0.98));\n return { accepted, best, second, margin };\n }\n\n for (const payroll of onlyPayroll) {\n const match = bestUnmatchedName(payroll.name, onlyPlan, usedPlan);\n if (match.accepted) {\n usedPlan.add(match.best.index);\n const plan = match.best.candidate;\n addFinding({\n tipo: 'Posible misma persona con DUI diferente',\n severidad: 'Alta',\n duiNomina: payroll.dui,\n duiPlanilla: plan.dui,\n nombreNomina: payroll.name,\n nombrePlanilla: plan.name,\n montoNomina: payroll.amount,\n montoPlanilla: plan.amount,\n accion: 'Confirmar la identidad y corregir el DUI en la fuente que corresponda.'\n });\n } else {\n addFinding({\n tipo: 'Persona en nómina que no está en planilla',\n severidad: 'Alta',\n duiNomina: payroll.dui,\n nombreNomina: payroll.name,\n montoNomina: payroll.amount,\n accion: 'Validar inclusión en la planilla de Seguridad Social.'\n });\n }\n }\n onlyPlan.forEach((plan, index) => {\n if (usedPlan.has(index)) return;\n addFinding({\n tipo: 'Persona en planilla que no está en nómina',\n severidad: 'Alta',\n duiPlanilla: plan.dui,\n nombrePlanilla: plan.name,\n montoPlanilla: plan.amount,\n accion: 'Validar si corresponde excluirla o incorporarla a la nómina.'\n });\n });\n\n function collectBambooPayload(payload, rows, totals) {\n if (payload == null) return;\n if (Array.isArray(payload)) {\n for (const value of payload) collectBambooPayload(value, rows, totals);\n return;\n }\n if (typeof payload !== 'object') return;\n if (Number(payload?.meta?.total) > 0) totals.push(Number(payload.meta.total));\n if (payload.body && typeof payload.body === 'object') {\n collectBambooPayload(payload.body, rows, totals);\n return;\n }\n if (Array.isArray(payload.data)) {\n rows.push(...payload.data);\n return;\n }\n if (Array.isArray(payload.employees)) {\n rows.push(...payload.employees);\n return;\n }\n if (payload.fullName5 || payload.displayName || payload.firstName || payload.lastName) rows.push(payload);\n }\n\n const bambooRaw = [];\n const bambooTotals = [];\n for (const item of bambooInput) collectBambooPayload(item.json, bambooRaw, bambooTotals);\n\n function uniqueNames(values) {\n return [...new Set(values.map(clean).filter(Boolean))];\n }\n\n function normalizeBambooDui(value) {\n const digits = clean(value).replace(/\\D/g, '');\n return digits.length === 9 ? digits.slice(0, 8) + '-' + digits.slice(8) : '';\n }\n\n function isElSalvadorEmployee(row) {\n return /EL\\s*SALVADOR|SALVADOR/i.test([\n row.location,\n row.country,\n row.division,\n row.department\n ].map(clean).join(' '));\n }\n\n const bambooUnique = new Map();\n for (let index = 0; index < bambooRaw.length; index++) {\n const row = bambooRaw[index] || {};\n const constructedName = [row.firstName, row.middleName, row.lastName].map(clean).filter(Boolean).join(' ');\n const aliases = uniqueNames([\n row.fullName5,\n row.fullName1,\n row.fullName2,\n row.fullName3,\n row.fullName4,\n constructedName,\n [row.firstName, row.lastName].map(clean).filter(Boolean).join(' '),\n [row.preferredName, row.lastName].map(clean).filter(Boolean).join(' '),\n row.displayName\n ]);\n const name = clean(row.displayName || row.fullName5 || row.fullName1 || constructedName);\n if (!name || !aliases.length) continue;\n const identity = clean(row.id || row.employeeId);\n const fallbackIdentity = clean(row.employeeNumber) + '|' + canonicalName(name) + '|' + clean(row.hireDate || row.originalHireDate);\n const key = identity || fallbackIdentity || 'fila-' + index;\n if (!bambooUnique.has(key)) {\n bambooUnique.set(key, {\n id: identity || key,\n employeeId: clean(row.employeeId || row.id),\n employeeNumber: clean(row.employeeNumber),\n dui: normalizeBambooDui(row.employeeNumber),\n name,\n aliases,\n canonicalAliases: [...new Set(aliases.map(canonicalName).filter(Boolean))],\n status: clean(row.status || row.employmentStatus || row.employmentHistoryStatus),\n hireDate: clean(row.hireDate || row.originalHireDate).slice(0, 10),\n terminationDate: clean(row.terminationDate).slice(0, 10),\n location: clean(row.location),\n country: clean(row.country),\n division: clean(row.division),\n department: clean(row.department),\n email: clean(row.workEmail),\n isElSalvador: isElSalvadorEmployee(row)\n });\n }\n }\n\n const bambooEmployees = [...bambooUnique.values()];\n const reportedTotal = bambooTotals.length ? Math.max(...bambooTotals) : bambooEmployees.length;\n if (!bambooEmployees.length) {\n throw new Error('BambooHR no devolvió empleados legibles. Revisa la credencial y los permisos del reporte completo.');\n }\n if (reportedTotal > bambooEmployees.length) {\n throw new Error('BambooHR devolvió ' + bambooEmployees.length + ' empleados únicos de ' + reportedTotal + '. La consulta quedó incompleta.');\n }\n\n function phoneticToken(value) {\n return normalizeName(value)\n .replace(/LL/g, 'Y')\n .replace(/QU/g, 'K')\n .replace(/PH/g, 'F')\n .replace(/C(?=[EI])/g, 'S')\n .replace(/G(?=[EI])/g, 'J')\n .replace(/Z/g, 'S')\n .replace(/V/g, 'B')\n .replace(/H/g, '')\n .replace(/Q/g, 'K')\n .replace(/Y/g, 'I')\n .replace(/(.)\\1+/g, '$1');\n }\n\n function damerauLevenshtein(left, right) {\n const a = String(left || '');\n const b = String(right || '');\n const rows = a.length + 1;\n const columns = b.length + 1;\n const matrix = Array.from({ length: rows }, () => Array(columns).fill(0));\n for (let row = 0; row < rows; row++) matrix[row][0] = row;\n for (let column = 0; column < columns; column++) matrix[0][column] = column;\n for (let row = 1; row < rows; row++) {\n for (let column = 1; column < columns; column++) {\n const cost = a[row - 1] === b[column - 1] ? 0 : 1;\n matrix[row][column] = Math.min(\n matrix[row - 1][column] + 1,\n matrix[row][column - 1] + 1,\n matrix[row - 1][column - 1] + cost\n );\n if (\n row > 1 &&\n column > 1 &&\n a[row - 1] === b[column - 2] &&\n a[row - 2] === b[column - 1]\n ) {\n matrix[row][column] = Math.min(\n matrix[row][column],\n matrix[row - 2][column - 2] + cost\n );\n }\n }\n }\n return matrix[a.length][b.length];\n }\n\n function tokenSimilarity(left, right) {\n const rawLeft = normalizeName(left);\n const rawRight = normalizeName(right);\n const phoneticLeft = phoneticToken(left);\n const phoneticRight = phoneticToken(right);\n const rawScore = 1 - damerauLevenshtein(rawLeft, rawRight) / Math.max(rawLeft.length, rawRight.length, 1);\n const phoneticScore = 1 - damerauLevenshtein(phoneticLeft, phoneticRight) / Math.max(phoneticLeft.length, phoneticRight.length, 1);\n return Math.max(rawScore, phoneticScore);\n }\n\n function compareBambooNames(personName, bambooName) {\n const personTokens = nameTokens(personName);\n const bambooTokens = nameTokens(bambooName);\n if (!personTokens.length || !bambooTokens.length) {\n return {\n score: 0,\n coverage: 0,\n strong: 0,\n anchors: 0,\n exact: 0,\n weak: personTokens.length,\n targetCount: personTokens.length,\n candidateCount: bambooTokens.length,\n extra: bambooTokens.length,\n pairs: []\n };\n }\n\n const choices = [];\n for (let personIndex = 0; personIndex < personTokens.length; personIndex++) {\n for (let bambooIndex = 0; bambooIndex < bambooTokens.length; bambooIndex++) {\n choices.push({\n personIndex,\n bambooIndex,\n similarity: tokenSimilarity(personTokens[personIndex], bambooTokens[bambooIndex])\n });\n }\n }\n choices.sort((left, right) => right.similarity - left.similarity);\n\n const usedPerson = new Set();\n const usedBamboo = new Set();\n const pairs = [];\n for (const choice of choices) {\n if (usedPerson.has(choice.personIndex) || usedBamboo.has(choice.bambooIndex)) continue;\n usedPerson.add(choice.personIndex);\n usedBamboo.add(choice.bambooIndex);\n pairs.push({\n person: personTokens[choice.personIndex],\n bamboo: bambooTokens[choice.bambooIndex],\n similarity: choice.similarity\n });\n }\n for (let personIndex = 0; personIndex < personTokens.length; personIndex++) {\n if (!usedPerson.has(personIndex)) {\n pairs.push({ person: personTokens[personIndex], bamboo: '', similarity: 0 });\n }\n }\n\n const totalWeight = personTokens.reduce((sum, token) => sum + Math.min(Math.max(token.length, 3), 10), 0) || 1;\n const coverage = pairs.reduce((sum, pair) => {\n const weight = Math.min(Math.max(pair.person.length, 3), 10);\n return sum + pair.similarity * weight;\n }, 0) / totalWeight;\n const extra = Math.max(0, bambooTokens.length - usedBamboo.size);\n const score = Math.max(0, coverage - Math.min(0.03, extra * 0.008));\n\n return {\n score,\n coverage,\n strong: pairs.filter(pair => pair.similarity >= 0.78).length,\n anchors: pairs.filter(pair => pair.similarity >= 0.92).length,\n exact: pairs.filter(pair => pair.similarity >= 0.999).length,\n weak: pairs.filter(pair => pair.similarity < 0.70).length,\n targetCount: personTokens.length,\n candidateCount: bambooTokens.length,\n extra,\n pairs\n };\n }\n\n const exactBambooByDui = new Map();\n const exactBambooByName = new Map();\n const bambooTokenIndex = new Map();\n const bambooPhoneticIndex = new Map();\n const bambooElSalvador = bambooEmployees.filter(employee => employee.isElSalvador);\n\n for (const employee of bambooEmployees) {\n if (employee.dui) {\n if (!exactBambooByDui.has(employee.dui)) exactBambooByDui.set(employee.dui, new Set());\n exactBambooByDui.get(employee.dui).add(employee);\n }\n for (const canonical of employee.canonicalAliases) {\n if (!exactBambooByName.has(canonical)) exactBambooByName.set(canonical, new Set());\n exactBambooByName.get(canonical).add(employee);\n }\n for (const alias of employee.aliases) {\n for (const token of new Set(nameTokens(alias))) {\n if (!bambooTokenIndex.has(token)) bambooTokenIndex.set(token, new Set());\n bambooTokenIndex.get(token).add(employee);\n const phonetic = phoneticToken(token);\n if (!bambooPhoneticIndex.has(phonetic)) bambooPhoneticIndex.set(phonetic, new Set());\n bambooPhoneticIndex.get(phonetic).add(employee);\n }\n }\n }\n\n function bambooRank(employee) {\n const status = clean(employee.status);\n const active = /^(ACTIVE|ACTIVO)$/i.test(status) ? 2 : 0;\n return (employee.isElSalvador ? 4 : 0) + active;\n }\n\n function bestEmployeeNameScore(employee, personAliases) {\n let best = null;\n for (const personAlias of personAliases) {\n for (const bambooAlias of employee.aliases) {\n const comparison = compareBambooNames(personAlias, bambooAlias);\n if (!best || comparison.score > best.comparison.score) {\n best = { comparison, personAlias, bambooAlias };\n }\n }\n }\n return best || {\n comparison: compareBambooNames('', ''),\n personAlias: '',\n bambooAlias: ''\n };\n }\n\n function scoreEmployees(employees, personAliases) {\n return [...employees].map(employee => {\n const result = bestEmployeeNameScore(employee, personAliases);\n return {\n employee,\n comparison: result.comparison,\n personAlias: result.personAlias,\n bambooAlias: result.bambooAlias,\n rank: bambooRank(employee)\n };\n }).sort((left, right) =>\n right.comparison.score - left.comparison.score ||\n right.rank - left.rank\n );\n }\n\n function exactDuiNameCompatible(result) {\n const comparison = result.comparison;\n return (\n canonicalName(result.personAlias) === canonicalName(result.bambooAlias) ||\n (comparison.strong >= Math.min(2, comparison.targetCount) && comparison.score >= 0.62) ||\n comparison.score >= 0.78\n );\n }\n\n function identityIssueFor(person, matchedEmployee, duiOwner) {\n const matchedDui = clean(matchedEmployee?.dui);\n if (duiOwner && matchedEmployee && duiOwner.id !== matchedEmployee.id) {\n return {\n type: 'DUI_BAMBOO_OTRA_PERSONA',\n duiOwner,\n matchedEmployee,\n sourceDui: person.dui\n };\n }\n if (matchedEmployee && matchedDui && matchedDui !== person.dui) {\n return {\n type: 'DUI_BAMBOO_DIFERENTE',\n duiOwner: null,\n matchedEmployee,\n sourceDui: person.dui\n };\n }\n return null;\n }\n\n function acceptedMatch(methodCode, method, employee, score, margin, result, identityIssue) {\n return {\n accepted: true,\n review: false,\n methodCode,\n method,\n employee,\n candidate: employee,\n score,\n margin,\n sourceAlias: result?.personAlias || '',\n matchedAlias: result?.bambooAlias || '',\n identityIssue: identityIssue || null\n };\n }\n\n function matchBambooPerson(person) {\n const personAliases = uniqueNames(person.aliases || []);\n const exactDuiCandidates = [...(exactBambooByDui.get(person.dui) || [])];\n let duiOwner = null;\n\n if (exactDuiCandidates.length) {\n const scoredDui = scoreEmployees(exactDuiCandidates, personAliases);\n const compatibleDui = scoredDui.filter(exactDuiNameCompatible);\n const compatibleElSalvador = compatibleDui.filter(result => result.employee.isElSalvador);\n const compatiblePool = compatibleElSalvador.length ? compatibleElSalvador : compatibleDui;\n if (compatiblePool.length === 1) {\n const best = compatiblePool[0];\n return acceptedMatch(\n 'DUI_EXACTO',\n 'DUI exacto',\n best.employee,\n 1,\n 1,\n best,\n null\n );\n }\n if (compatiblePool.length > 1) {\n const best = compatiblePool[0];\n const second = compatiblePool[1];\n const margin = best.comparison.score - second.comparison.score;\n if (best.comparison.score >= 0.90 && margin >= 0.10) {\n return acceptedMatch(\n 'DUI_EXACTO_DESAMBIGUADO',\n 'DUI exacto desambiguado por nombre',\n best.employee,\n 1,\n margin,\n best,\n null\n );\n }\n }\n duiOwner = scoredDui[0]?.employee || exactDuiCandidates[0];\n }\n\n const exactNameEmployees = new Set();\n for (const alias of personAliases) {\n for (const employee of exactBambooByName.get(canonicalName(alias)) || []) {\n exactNameEmployees.add(employee);\n }\n }\n if (exactNameEmployees.size) {\n const exactList = [...exactNameEmployees];\n const exactElSalvador = exactList.filter(employee => employee.isElSalvador);\n const exactPool = exactElSalvador.length ? exactElSalvador : exactList;\n if (exactPool.length === 1) {\n const employee = exactPool[0];\n const result = bestEmployeeNameScore(employee, personAliases);\n return acceptedMatch(\n 'NOMBRE_EXACTO_UNICO',\n duiOwner ? 'Nombre exacto; revisar DUI en BambooHR' : 'Nombre exacto único',\n employee,\n 1,\n 1,\n result,\n identityIssueFor(person, employee, duiOwner)\n );\n }\n }\n\n const candidates = new Set(bambooElSalvador);\n for (const alias of personAliases) {\n for (const token of new Set(nameTokens(alias))) {\n const exactTokenMatches = bambooTokenIndex.get(token);\n if (exactTokenMatches && exactTokenMatches.size <= 100) {\n for (const employee of exactTokenMatches) candidates.add(employee);\n }\n const phoneticMatches = bambooPhoneticIndex.get(phoneticToken(token));\n if (phoneticMatches && phoneticMatches.size <= 100) {\n for (const employee of phoneticMatches) candidates.add(employee);\n }\n }\n }\n\n const scored = scoreEmployees(candidates, personAliases);\n const best = scored[0] || null;\n const second = scored[1] || null;\n if (!best) {\n return {\n accepted: false,\n review: Boolean(duiOwner),\n methodCode: duiOwner ? 'DUI_NOMBRE_CONFLICTO' : 'SIN_COINCIDENCIA',\n method: duiOwner ? 'DUI asociado a otro nombre' : 'Sin coincidencia',\n employee: null,\n candidate: duiOwner,\n score: 0,\n margin: 0,\n identityIssue: identityIssueFor(person, null, duiOwner)\n };\n }\n\n const comparison = best.comparison;\n const margin = second ? comparison.score - second.comparison.score : 1;\n const longStrong = (\n comparison.targetCount >= 3 &&\n comparison.weak === 0 &&\n comparison.strong >= 3 &&\n comparison.anchors >= 2 &&\n comparison.score >= 0.92 &&\n margin >= 0.10 &&\n (best.employee.isElSalvador || (comparison.score >= 0.97 && comparison.exact >= 3))\n );\n const shortStrong = (\n comparison.targetCount === 2 &&\n comparison.weak === 0 &&\n comparison.strong === 2 &&\n comparison.anchors === 2 &&\n comparison.exact >= 1 &&\n comparison.score >= 0.96 &&\n margin >= 0.15 &&\n best.employee.isElSalvador\n );\n\n if (longStrong || shortStrong) {\n return acceptedMatch(\n 'NOMBRE_TIPOGRAFICO_UNICO',\n duiOwner ? 'Nombre tipográfico; revisar DUI en BambooHR' : 'Nombre tipográfico único',\n best.employee,\n comparison.score,\n margin,\n best,\n identityIssueFor(person, best.employee, duiOwner)\n );\n }\n\n const review = Boolean(duiOwner) || (\n comparison.score >= 0.82 &&\n comparison.strong >= Math.min(2, comparison.targetCount)\n );\n return {\n accepted: false,\n review,\n methodCode: duiOwner ? 'DUI_NOMBRE_CONFLICTO' : (review ? 'COINCIDENCIA_AMBIGUA' : 'SIN_COINCIDENCIA'),\n method: duiOwner ? 'DUI asociado a otro nombre' : (review ? 'Coincidencia ambigua' : 'Sin coincidencia'),\n employee: null,\n candidate: duiOwner || best.employee,\n score: comparison.score,\n margin,\n sourceAlias: best.personAlias,\n matchedAlias: best.bambooAlias,\n identityIssue: identityIssueFor(person, null, duiOwner)\n };\n }\n\n function sourcePeople(groups, missingRecords, source) {\n const people = [...groups.values()].map(group => ({\n source,\n dui: group.dui,\n name: group.name,\n project: group.project || '',\n amount: group.amount\n }));\n for (const record of missingRecords) {\n people.push({\n source,\n dui: '',\n name: record.name,\n project: record.project || '',\n amount: record.amount\n });\n }\n return people;\n }\n\n const payrollPeople = sourcePeople(payrollGroups, payrollMissingDui, 'Nómina');\n const planPeople = sourcePeople(planGroups, planMissingDui, 'Planilla');\n const unifiedPeople = new Map();\n\n function addUnifiedPerson(person, index) {\n const key = person.dui\n ? 'DUI:' + person.dui\n : person.source + ':SIN_DUI:' + index + ':' + canonicalName(person.name);\n person.matchKey = key;\n if (!unifiedPeople.has(key)) {\n unifiedPeople.set(key, {\n key,\n dui: person.dui,\n aliases: [],\n payrollNames: [],\n planNames: [],\n payrollAmounts: [],\n planAmounts: []\n });\n }\n const unified = unifiedPeople.get(key);\n unified.aliases = uniqueNames([...unified.aliases, person.name]);\n if (person.source === 'Nómina') {\n unified.payrollNames = uniqueNames([...unified.payrollNames, person.name]);\n unified.payrollAmounts.push(person.amount);\n } else {\n unified.planNames = uniqueNames([...unified.planNames, person.name]);\n unified.planAmounts.push(person.amount);\n }\n }\n\n payrollPeople.forEach(addUnifiedPerson);\n planPeople.forEach(addUnifiedPerson);\n\n const matchByKey = new Map();\n for (const person of unifiedPeople.values()) {\n matchByKey.set(person.key, matchBambooPerson(person));\n }\n\n const bambooUsage = new Map();\n for (const [key, match] of matchByKey) {\n if (!match.accepted || !match.employee?.id) continue;\n if (!bambooUsage.has(match.employee.id)) bambooUsage.set(match.employee.id, []);\n bambooUsage.get(match.employee.id).push({ key, match });\n }\n for (const uses of bambooUsage.values()) {\n if (uses.length <= 1) continue;\n const exactDuiUses = uses.filter(use => /^DUI_EXACTO/.test(use.match.methodCode));\n if (exactDuiUses.length === 1) {\n for (const use of uses) {\n if (use === exactDuiUses[0]) continue;\n use.match.accepted = false;\n use.match.review = true;\n use.match.methodCode = 'REGISTRO_BAMBOO_REUTILIZADO';\n use.match.method = 'Coincidencia ambigua: registro BambooHR ya asignado a otro DUI';\n }\n } else {\n for (const use of uses) {\n use.match.accepted = false;\n use.match.review = true;\n use.match.methodCode = 'REGISTRO_BAMBOO_REUTILIZADO';\n use.match.method = 'Coincidencia ambigua: registro BambooHR asociado a más de un DUI';\n }\n }\n }\n\n const bambooMatchStats = {\n duiExacto: 0,\n nombreExacto: 0,\n nombreTipografico: 0,\n inactivos: 0,\n conflictosDui: 0,\n ambiguos: 0,\n sinCoincidencia: 0\n };\n\n for (const person of unifiedPeople.values()) {\n const match = matchByKey.get(person.key);\n if (match.accepted) {\n if (/^DUI_EXACTO/.test(match.methodCode)) bambooMatchStats.duiExacto++;\n else if (match.methodCode === 'NOMBRE_EXACTO_UNICO') bambooMatchStats.nombreExacto++;\n else if (match.methodCode === 'NOMBRE_TIPOGRAFICO_UNICO') bambooMatchStats.nombreTipografico++;\n if (clean(match.employee?.status) && !/^(ACTIVE|ACTIVO)$/i.test(match.employee.status)) {\n bambooMatchStats.inactivos++;\n }\n } else if (match.review) {\n bambooMatchStats.ambiguos++;\n } else {\n bambooMatchStats.sinCoincidencia++;\n }\n\n if (match.identityIssue) {\n bambooMatchStats.conflictosDui++;\n const issue = match.identityIssue;\n const matchedEmployee = issue.matchedEmployee || match.employee;\n const duiOwner = issue.duiOwner;\n const detail = duiOwner\n ? 'En BambooHR el DUI/employeeNumber ' + person.dui + ' está asociado a ' + duiOwner.name +\n '. La persona coincide por nombre con ' + (matchedEmployee?.name || 'otro registro') +\n (matchedEmployee?.employeeNumber ? ' (employeeNumber ' + matchedEmployee.employeeNumber + ')' : '') + '.'\n : 'La persona coincide por nombre con ' + (matchedEmployee?.name || 'un registro de BambooHR') +\n ', pero BambooHR contiene el employeeNumber ' + (matchedEmployee?.employeeNumber || 'vacío o inválido') +\n ' y las fuentes del cruce contienen ' + person.dui + '.';\n addFinding({\n tipo: 'DUI distinto o asociado a otra persona en BambooHR',\n severidad: 'Alta',\n duiNomina: person.payrollNames.length ? person.dui : '',\n duiPlanilla: person.planNames.length ? person.dui : '',\n nombreNomina: person.payrollNames.join(' | '),\n nombrePlanilla: person.planNames.join(' | '),\n montoNomina: person.payrollAmounts.length ? person.payrollAmounts.reduce((sum, value) => sum + Number(value || 0), 0) : '',\n montoPlanilla: person.planAmounts.length ? person.planAmounts.reduce((sum, value) => sum + Number(value || 0), 0) : '',\n accion: detail + ' Corregir y validar el identificador en BambooHR.'\n });\n }\n }\n\n const payrollWithoutBamboo = [];\n const planWithoutBamboo = [];\n\n function appendBambooException(person, output) {\n const match = matchByKey.get(person.matchKey);\n if (match?.accepted) return;\n const candidate = match?.candidate;\n output.push({\n dui: person.dui,\n name: person.name,\n project: person.project || '',\n amount: person.amount,\n candidateName: candidate?.name || '',\n score: Number(match?.score || 0),\n method: match?.method || 'Sin coincidencia',\n candidateStatus: candidate?.status || '',\n candidateLocation: candidate?.location || '',\n candidateCountry: candidate?.country || '',\n action: match?.review\n ? 'Revisar manualmente la coincidencia; no se clasificó automáticamente como persona ausente.'\n : 'Crear o validar el registro de la persona en BambooHR.'\n });\n }\n\n for (const person of payrollPeople) appendBambooException(person, payrollWithoutBamboo);\n for (const person of planPeople) appendBambooException(person, planWithoutBamboo);\n payrollWithoutBamboo.sort((left, right) => left.name.localeCompare(right.name, 'es'));\n planWithoutBamboo.sort((left, right) => left.name.localeCompare(right.name, 'es'));\n\n const severityOrder = { Alta: 0, Media: 1, Informativa: 2 };\n findings.sort((a, b) => (severityOrder[a.severidad] ?? 9) - (severityOrder[b.severidad] ?? 9) || a.tipo.localeCompare(b.tipo, 'es') || (a.nombreNomina || a.nombrePlanilla).localeCompare(b.nombreNomina || b.nombrePlanilla, 'es'));\n\n const amountFindings = findings.filter(item => item.tipo === 'Diferencia de monto' || item.tipo === 'Diferencia subcentavo').length;\n const subcentFindings = findings.filter(item => item.tipo === 'Diferencia subcentavo').length;\n const identityFindings = findings.length - amountFindings;\n const nameFindings = findings.filter(item => item.tipo === 'Nombre no idéntico con el mismo DUI').length;\n const highFindings = findings.filter(item => item.severidad === 'Alta').length;\n const totalFindings = findings.length + payrollWithoutBamboo.length + planWithoutBamboo.length;\n\n function color(hex) {\n const text = hex.replace('#', '');\n return {\n red: parseInt(text.slice(0, 2), 16) / 255,\n green: parseInt(text.slice(2, 4), 16) / 255,\n blue: parseInt(text.slice(4, 6), 16) / 255\n };\n }\n const COLORS = {\n blue: color('#4F758B'),\n mediumBlue: color('#5B7F95'),\n steel: color('#6B8FA3'),\n green: color('#6CC24A'),\n greenBg: color('#EEF6E8'),\n blueBg: color('#D6E8F4'),\n gray: color('#4A4A4A'),\n border: color('#D0D0D0'),\n zebra: color('#F5F5F5'),\n white: color('#FFFFFF'),\n redBg: color('#FDECEC'),\n redText: color('#9C2F2F'),\n amberBg: color('#FFF6E3'),\n amberText: color('#8A5A00')\n };\n function gridBorder() {\n return { style: 'SOLID', color: COLORS.border };\n }\n function valueObject(value) {\n if (typeof value === 'number' && Number.isFinite(value)) return { numberValue: value };\n if (typeof value === 'boolean') return { boolValue: value };\n return { stringValue: value == null ? '' : String(value) };\n }\n function sheetDefinition(options) {\n const columnCount = options.headers.length;\n const empty = Array(columnCount).fill('');\n const dataRows = options.rows.length ? options.rows : [[options.emptyLabel || 'Sin diferencias', ...Array(columnCount - 1).fill('')]];\n const rawRows = [\n [options.title, ...Array(columnCount - 1).fill('')],\n [options.subtitle, ...Array(columnCount - 1).fill('')],\n [options.note, ...Array(columnCount - 1).fill('')],\n empty.slice(),\n options.headers,\n ...dataRows\n ];\n const rowData = rawRows.map((row, rowIndex) => {\n let background = COLORS.white;\n let foreground = COLORS.gray;\n let bold = false;\n let fontSize = 10;\n let horizontal = 'LEFT';\n let wrap = 'WRAP';\n if (rowIndex === 0) {\n background = COLORS.blue;\n foreground = COLORS.white;\n bold = true;\n fontSize = 15;\n horizontal = 'LEFT';\n } else if (rowIndex === 1) {\n background = COLORS.mediumBlue;\n foreground = COLORS.white;\n bold = true;\n } else if (rowIndex === 2) {\n background = COLORS.blueBg;\n foreground = COLORS.blue;\n bold = true;\n } else if (rowIndex === 4) {\n background = COLORS.blue;\n foreground = COLORS.white;\n bold = true;\n horizontal = 'CENTER';\n } else if (rowIndex >= 5) {\n background = (rowIndex - 5) % 2 ? COLORS.zebra : COLORS.white;\n if (row[0] === 'Sin diferencias' || row[0] === 'Sin personas fuera de BambooHR') {\n background = COLORS.greenBg;\n foreground = COLORS.green;\n bold = true;\n }\n if (options.kind === 'main' && row[1] === 'Alta') {\n background = COLORS.redBg;\n foreground = COLORS.redText;\n } else if (options.kind === 'main' && row[1] === 'Media') {\n background = COLORS.amberBg;\n foreground = COLORS.amberText;\n } else if (options.kind === 'main' && row[1] === 'Informativa') {\n background = COLORS.blueBg;\n foreground = COLORS.blue;\n }\n }\n return {\n values: row.map((value, columnIndex) => {\n const format = {\n backgroundColor: background,\n textFormat: {\n foregroundColor: foreground,\n bold,\n fontFamily: 'Arial',\n fontSize\n },\n verticalAlignment: 'MIDDLE',\n horizontalAlignment: rowIndex === 4 ? 'CENTER' : (options.moneyColumns.includes(columnIndex) && rowIndex >= 5 ? 'RIGHT' : horizontal),\n wrapStrategy: wrap,\n padding: { top: 5, bottom: 5, left: 7, right: 7 }\n };\n if (rowIndex >= 4) {\n format.borders = {\n top: gridBorder(),\n bottom: gridBorder(),\n left: gridBorder(),\n right: gridBorder()\n };\n }\n if (options.moneyColumns.includes(columnIndex) && rowIndex >= 5 && typeof value === 'number') {\n format.numberFormat = { type: 'CURRENCY', pattern: '$#,##0.0000;[Red]-$#,##0.0000' };\n }\n return { userEnteredValue: valueObject(value), userEnteredFormat: format };\n })\n };\n });\n const rowMetadata = rawRows.map((row, index) => ({ pixelSize: index === 0 ? 38 : index === 3 ? 10 : index === 4 ? 34 : index < 3 ? 27 : 31 }));\n return {\n properties: {\n sheetId: options.sheetId,\n title: options.sheetTitle,\n tabColor: options.tabColor,\n gridProperties: {\n rowCount: Math.max(rawRows.length + 20, 50),\n columnCount,\n frozenRowCount: 5,\n hideGridlines: true\n }\n },\n data: [{\n startRow: 0,\n startColumn: 0,\n rowData,\n rowMetadata,\n columnMetadata: options.widths.map(pixelSize => ({ pixelSize }))\n }],\n merges: [0, 1, 2].map(rowIndex => ({\n sheetId: options.sheetId,\n startRowIndex: rowIndex,\n endRowIndex: rowIndex + 1,\n startColumnIndex: 0,\n endColumnIndex: columnCount\n })),\n basicFilter: {\n range: {\n sheetId: options.sheetId,\n startRowIndex: 4,\n endRowIndex: rawRows.length,\n startColumnIndex: 0,\n endColumnIndex: columnCount\n }\n }\n };\n }\n\n const mainHeaders = ['Tipo de diferencia', 'Severidad', 'DUI Nómina', 'DUI Planilla', 'Nombre Nómina', 'Nombre Planilla', 'Monto Nómina (USD)', 'Monto Planilla (USD)', 'Diferencia (USD)', 'Observación / Acción'];\n const mainRows = findings.map(item => [\n item.tipo,\n item.severidad,\n item.duiNomina,\n item.duiPlanilla,\n item.nombreNomina,\n item.nombrePlanilla,\n item.montoNomina,\n item.montoPlanilla,\n item.diferencia,\n item.accion\n ]);\n const payrollBambooHeaders = ['DUI', 'Nombre en Nómina', 'Proyecto', 'Monto Nómina (USD)', 'Mejor candidato en BambooHR', 'Similitud', 'Resultado automático', 'Estado candidato', 'Ubicación / País', 'Acción'];\n const payrollBambooRows = payrollWithoutBamboo.map(item => [\n item.dui,\n item.name,\n item.project,\n item.amount,\n item.candidateName,\n item.score ? Math.round(item.score * 100) + '%' : '',\n item.method,\n item.candidateStatus,\n uniqueJoin([item.candidateLocation, item.candidateCountry]),\n item.action\n ]);\n const planBambooHeaders = ['DUI', 'Nombre en Planilla', 'Monto Planilla (USD)', 'Mejor candidato en BambooHR', 'Similitud', 'Resultado automático', 'Estado candidato', 'Ubicación / País', 'Acción'];\n const planBambooRows = planWithoutBamboo.map(item => [\n item.dui,\n item.name,\n item.amount,\n item.candidateName,\n item.score ? Math.round(item.score * 100) + '%' : '',\n item.method,\n item.candidateStatus,\n uniqueJoin([item.candidateLocation, item.candidateCountry]),\n item.action\n ]);\n\n const timestamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 16);\n const reportTitle = 'Cruce Seguridad Social El Salvador - ' + periodLabel + ' - ' + timestamp;\n const spreadsheetPayload = {\n properties: {\n title: reportTitle,\n locale: 'es_SV',\n timeZone: 'America/El_Salvador'\n },\n sheets: [\n sheetDefinition({\n sheetId: 0,\n sheetTitle: 'Diferencias',\n title: 'CRUCE DE SEGURIDAD SOCIAL · EL SALVADOR',\n subtitle: 'Período: ' + periodLabel + ' · Nómina vs. Planilla Única',\n note: 'Solo se muestran hallazgos · Identidad/nombres: ' + identityFindings + ' · Montos: ' + amountFindings + ' · Total: ' + findings.length,\n headers: mainHeaders,\n rows: mainRows,\n widths: [210, 95, 115, 115, 190, 190, 130, 130, 125, 330],\n moneyColumns: [6, 7, 8],\n tabColor: COLORS.blue,\n kind: 'main',\n emptyLabel: 'Sin diferencias'\n }),\n sheetDefinition({\n sheetId: 1,\n sheetTitle: 'Nómina sin Bamboo',\n title: 'NÓMINA SIN BAMBOO',\n subtitle: 'Período: ' + periodLabel + ' · Personas de nómina sin coincidencia concluyente en BambooHR',\n note: 'Solo se muestran excepciones · Total: ' + payrollWithoutBamboo.length + ' · Las coincidencias ambiguas requieren revisión manual',\n headers: payrollBambooHeaders,\n rows: payrollBambooRows,\n widths: [115, 210, 150, 130, 210, 85, 145, 110, 170, 310],\n moneyColumns: [3],\n tabColor: COLORS.green,\n kind: 'bamboo',\n emptyLabel: 'Sin personas fuera de BambooHR'\n }),\n sheetDefinition({\n sheetId: 2,\n sheetTitle: 'Planilla sin Bamboo',\n title: 'PLANILLA SIN BAMBOO',\n subtitle: 'Período: ' + periodLabel + ' · Personas de planilla sin coincidencia concluyente en BambooHR',\n note: 'Solo se muestran excepciones · Total: ' + planWithoutBamboo.length + ' · Las coincidencias ambiguas requieren revisión manual',\n headers: planBambooHeaders,\n rows: planBambooRows,\n widths: [115, 220, 135, 220, 85, 145, 110, 180, 330],\n moneyColumns: [2],\n tabColor: COLORS.steel,\n kind: 'bamboo',\n emptyLabel: 'Sin personas fuera de BambooHR'\n })\n ]\n };\n\n const binaryValues = formItem.binary || {};\n const payrollBinary = binaryValues.nomina || Object.values(binaryValues).find(file => /nomina/i.test((file.fileName || '') + '')) || {};\n const planBinary = binaryValues.planilla_seguridad_social || Object.values(binaryValues).find(file => /planilla/i.test((file.fileName || '') + '')) || {};\n\n return [{\n json: {\n spreadsheetPayload,\n summary: {\n periodo: periodLabel,\n periodoArchivo: periodFile,\n correoDestino: 'iaracena@gomezleemarketing.com, msoto@gomezleemarketing.com, iherrera@gomezleemarketing.com, mgomez@gomezleemarketing.com, vparamo@gomezleemarketing.com, asrodriguez@gomezleemarketing.com, jgomez@gomezleemarketing.com',\n archivoNomina: payrollBinary.fileName || 'Nómina',\n archivoPlanilla: planBinary.fileName || 'Planilla',\n registrosNomina: payrollRecords.length,\n registrosPlanilla: planRecords.length,\n empleadosNomina: payrollGroups.size + payrollMissingDui.length,\n empleadosPlanilla: planGroups.size + planMissingDui.length,\n empleadosBamboo: bambooEmployees.length,\n hallazgosIdentidad: identityFindings,\n hallazgosMontos: amountFindings,\n hallazgosSubcentavo: subcentFindings,\n hallazgosNombres: nameFindings,\n hallazgosAlta: highFindings,\n hallazgosPrincipales: findings.length,\n nominaSinBamboo: payrollWithoutBamboo.length,\n planillaSinBamboo: planWithoutBamboo.length,\n coincidenciasBambooInactivas: bambooMatchStats.inactivos,\n coincidenciasBambooDuiExacto: bambooMatchStats.duiExacto,\n coincidenciasBambooNombreExacto: bambooMatchStats.nombreExacto,\n coincidenciasBambooNombreTipografico: bambooMatchStats.nombreTipografico,\n conflictosDuiBamboo: bambooMatchStats.conflictosDui,\n coincidenciasBambooAmbiguas: bambooMatchStats.ambiguos,\n hallazgosTotales: totalFindings,\n nombreArchivo: 'Cruce_Seguridad_Social_El_Salvador_' + periodFile + '.xlsx'\n }\n }\n }];" + "jsCode": "const payrollSource = $items('Extraer Nómina').map(item => item.json || {});\n const planSource = $items('Extraer Planilla').map(item => item.json || {});\n const formItem = $items('Formulario de cruce')[0] || {};\n const form = formItem.json || {};\n const bambooInput = $input.all();\n\n function clean(value) {\n return String(value ?? '').replace(/\\s+/g, ' ').trim();\n }\n function stripAccents(value) {\n return String(value || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '');\n }\n function normalizeHeader(value) {\n return stripAccents(clean(value)).toUpperCase().replace(/[^A-Z0-9]+/g, ' ').trim();\n }\n function normalizeName(value) {\n return stripAccents(clean(value)).toUpperCase().replace(/[^A-Z0-9 ]+/g, ' ').replace(/\\s+/g, ' ').trim();\n }\n const STOP = new Set(['DE', 'DEL', 'LA', 'LAS', 'LOS', 'Y', 'DA', 'DAS', 'DO', 'VDA']);\n function nameTokens(value) {\n return normalizeName(value).split(' ').filter(token => token.length > 1 && !STOP.has(token));\n }\n function canonicalName(value) {\n return nameTokens(value).slice().sort().join(' ');\n }\n function nameScore(a, b) {\n const A = [...new Set(nameTokens(a))];\n const B = [...new Set(nameTokens(b))];\n if (!A.length || !B.length) return { score: 0, shared: 0 };\n if (A.slice().sort().join(' ') === B.slice().sort().join(' ')) return { score: 1, shared: A.length };\n const setB = new Set(B);\n const shared = A.filter(token => setB.has(token)).length;\n if (shared < 2) return { score: 0, shared };\n const coverage = shared / Math.min(A.length, B.length);\n const dice = (2 * shared) / (A.length + B.length);\n return { score: 0.7 * coverage + 0.3 * dice, shared };\n }\n function parseNumber(value) {\n if (typeof value === 'number') return Number.isFinite(value) ? value : 0;\n let text = clean(value).replace(/[^\\d,.\\-]/g, '');\n if (!text) return 0;\n const comma = text.lastIndexOf(',');\n const dot = text.lastIndexOf('.');\n if (comma > dot) text = text.replace(/\\./g, '').replace(',', '.');\n else text = text.replace(/,/g, '');\n const number = Number(text);\n return Number.isFinite(number) ? number : 0;\n }\n function valueAt(row, aliases, fallbackIndex) {\n const entries = Object.entries(row || {});\n const wanted = aliases.map(normalizeHeader);\n for (const [key, value] of entries) {\n const normalized = normalizeHeader(key);\n if (wanted.includes(normalized)) return value;\n }\n return entries[fallbackIndex]?.[1];\n }\n function normalizeDui(value) {\n let digits = clean(value).replace(/\\D/g, '');\n if (digits.length === 8) digits = digits.padStart(9, '0');\n if (digits.length !== 9) return '';\n return digits.slice(0, 8) + '-' + digits.slice(8);\n }\n function chooseName(values) {\n const names = [...new Set((values || []).map(clean).filter(Boolean))];\n names.sort((a, b) => nameTokens(b).length - nameTokens(a).length || b.length - a.length);\n return names[0] || '';\n }\n function round2(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n }\n function uniqueJoin(values) {\n return [...new Set((values || []).map(clean).filter(Boolean))].join(' | ');\n }\n function isTotalName(value) {\n return /^TOTAL(?:\\s|$)/i.test(clean(value));\n }\n\n const monthNumber = String(form.mes || '').padStart(2, '0');\n const yearNumber = Number(form.anio);\n if (!/^(0[1-9]|1[0-2])$/.test(monthNumber)) {\n throw new Error('Selecciona un mes válido entre 01 y 12.');\n }\n if (!Number.isInteger(yearNumber) || yearNumber < 2020 || yearNumber > 2100) {\n throw new Error('Ingresa un año válido.');\n }\n const monthNames = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];\n const periodLabel = monthNames[Number(monthNumber) - 1] + ' ' + yearNumber;\n const periodFile = yearNumber + '-' + monthNumber;\n\n const payrollRecords = [];\n for (let index = 0; index < payrollSource.length; index++) {\n const row = payrollSource[index];\n const rawDui = valueAt(row, ['DUI'], 3);\n const name = clean(valueAt(row, ['COLABORADOR', 'EMPLEADO', 'NOMBRE'], 4));\n const project = clean(valueAt(row, ['PROYECTO'], 2));\n const amount = parseNumber(valueAt(row, ['SUB TOTAL PERCIBIDO', 'TOTAL PERCIBIDO'], 15));\n const dui = normalizeDui(rawDui);\n if ((!name && !dui) || isTotalName(name)) continue;\n payrollRecords.push({\n sourceIndex: index + 2,\n dui,\n rawDui: clean(rawDui),\n name,\n project,\n amount\n });\n }\n\n const planRecords = [];\n for (let index = 0; index < planSource.length; index++) {\n const row = planSource[index];\n const rawDui = valueAt(row, ['NUMERO DE DOCUMENTO AFILIADO', 'DUI'], 4);\n const nameParts = [\n valueAt(row, ['PRIMER NOMBRE'], 8),\n valueAt(row, ['SEGUNDO NOMBRE'], 9),\n valueAt(row, ['PRIMER APELLIDO'], 10),\n valueAt(row, ['SEGUNDO APELLIDO'], 11),\n valueAt(row, ['APELLIDO DE CASADA'], 12)\n ].map(clean).filter(Boolean);\n const name = clean(nameParts.join(' '));\n const salary = parseNumber(valueAt(row, ['SALARIO'], 13));\n const additional = parseNumber(valueAt(row, ['PAGO ADICIONAL'], 14));\n const vacation = parseNumber(valueAt(row, ['MONTO DE VACACION', 'MONTO DE VACACIONES'], 15));\n const observations = uniqueJoin([\n valueAt(row, ['CODIGO DE OBSERVACION 1'], 19),\n valueAt(row, ['CODIGO DE OBSERVACION 2'], 20)\n ]);\n const dui = normalizeDui(rawDui);\n if ((!name && !dui) || isTotalName(name)) continue;\n planRecords.push({\n sourceIndex: index + 5,\n dui,\n rawDui: clean(rawDui),\n name,\n amount: salary + additional + vacation,\n salary,\n additional,\n vacation,\n observations\n });\n }\n\n if (!payrollRecords.length) {\n throw new Error('No se encontraron empleados en la hoja \"Nomina\". Se esperaba el encabezado en la fila 1 y datos desde la fila 2.');\n }\n if (!planRecords.length) {\n throw new Error('No se encontraron empleados en \"PLANILLA_UNICA\". Se esperaba el encabezado en la fila 4 y datos desde la fila 5.');\n }\n if (!payrollRecords.some(record => record.dui)) {\n throw new Error('No se pudo leer ningún DUI válido en la columna D de la nómina.');\n }\n if (!planRecords.some(record => record.dui)) {\n throw new Error('No se pudo leer ningún DUI válido en la columna E de la planilla.');\n }\n\n function aggregate(records, source) {\n const groups = new Map();\n for (const record of records) {\n if (!record.dui) continue;\n if (!groups.has(record.dui)) {\n groups.set(record.dui, {\n dui: record.dui,\n source,\n amount: 0,\n names: [],\n projects: [],\n observations: [],\n rows: []\n });\n }\n const group = groups.get(record.dui);\n group.amount += Number(record.amount) || 0;\n group.names.push(record.name);\n if (record.project) group.projects.push(record.project);\n if (record.observations) group.observations.push(record.observations);\n group.rows.push(record.sourceIndex);\n }\n for (const group of groups.values()) {\n group.name = chooseName(group.names);\n group.project = uniqueJoin(group.projects);\n group.observation = uniqueJoin(group.observations);\n group.nameVariants = [...new Set(group.names.map(normalizeName).filter(Boolean))];\n }\n return groups;\n }\n\n const payrollGroups = aggregate(payrollRecords, 'Nómina');\n const planGroups = aggregate(planRecords, 'Planilla');\n const payrollMissingDui = payrollRecords.filter(record => !record.dui);\n const planMissingDui = planRecords.filter(record => !record.dui);\n const findings = [];\n\n function addFinding(data) {\n findings.push({\n tipo: data.tipo || '',\n severidad: data.severidad || 'Media',\n duiNomina: data.duiNomina || '',\n duiPlanilla: data.duiPlanilla || '',\n nombreNomina: data.nombreNomina || '',\n nombrePlanilla: data.nombrePlanilla || '',\n montoNomina: data.montoNomina === '' || data.montoNomina == null ? '' : Number(data.montoNomina),\n montoPlanilla: data.montoPlanilla === '' || data.montoPlanilla == null ? '' : Number(data.montoPlanilla),\n diferencia: data.diferencia === '' || data.diferencia == null ? '' : Number(data.diferencia),\n accion: data.accion || ''\n });\n }\n\n for (const record of payrollMissingDui) {\n addFinding({\n tipo: 'DUI faltante o inválido en nómina',\n severidad: 'Alta',\n nombreNomina: record.name,\n montoNomina: record.amount,\n accion: 'Completar o corregir el DUI en la nómina antes de presentar la planilla.'\n });\n }\n for (const record of planMissingDui) {\n addFinding({\n tipo: 'DUI faltante o inválido en planilla',\n severidad: 'Alta',\n nombrePlanilla: record.name,\n montoPlanilla: record.amount,\n accion: 'Completar o corregir el DUI en la planilla de Seguridad Social.'\n });\n }\n\n for (const [dui, group] of payrollGroups) {\n if (group.nameVariants.length > 1) {\n addFinding({\n tipo: 'Un DUI tiene varios nombres en nómina',\n severidad: 'Alta',\n duiNomina: dui,\n nombreNomina: uniqueJoin(group.names),\n montoNomina: group.amount,\n accion: 'Validar que el DUI no esté asociado a más de una persona.'\n });\n }\n }\n for (const [dui, group] of planGroups) {\n if (group.nameVariants.length > 1) {\n addFinding({\n tipo: 'Un DUI tiene varios nombres en planilla',\n severidad: 'Alta',\n duiPlanilla: dui,\n nombrePlanilla: uniqueJoin(group.names),\n montoPlanilla: group.amount,\n accion: 'Validar las filas duplicadas y la identidad del afiliado.'\n });\n }\n }\n\n function flagSameNameMultipleDuis(groups, sourceLabel) {\n const byName = new Map();\n for (const group of groups.values()) {\n const key = canonicalName(group.name);\n if (!key) continue;\n if (!byName.has(key)) byName.set(key, []);\n byName.get(key).push(group);\n }\n for (const list of byName.values()) {\n const duis = [...new Set(list.map(group => group.dui))];\n if (duis.length <= 1) continue;\n addFinding({\n tipo: 'Misma persona con más de un DUI en ' + sourceLabel.toLowerCase(),\n severidad: 'Alta',\n duiNomina: sourceLabel === 'Nómina' ? duis.join(' | ') : '',\n duiPlanilla: sourceLabel === 'Planilla' ? duis.join(' | ') : '',\n nombreNomina: sourceLabel === 'Nómina' ? list[0].name : '',\n nombrePlanilla: sourceLabel === 'Planilla' ? list[0].name : '',\n montoNomina: sourceLabel === 'Nómina' ? list.reduce((sum, group) => sum + group.amount, 0) : '',\n montoPlanilla: sourceLabel === 'Planilla' ? list.reduce((sum, group) => sum + group.amount, 0) : '',\n accion: 'Confirmar el DUI correcto y unificar el registro.'\n });\n }\n }\n flagSameNameMultipleDuis(payrollGroups, 'Nómina');\n flagSameNameMultipleDuis(planGroups, 'Planilla');\n\n const commonDuis = [...payrollGroups.keys()].filter(dui => planGroups.has(dui));\n for (const dui of commonDuis) {\n const payroll = payrollGroups.get(dui);\n const plan = planGroups.get(dui);\n if (normalizeName(payroll.name) !== normalizeName(plan.name)) {\n addFinding({\n tipo: 'Nombre no idéntico con el mismo DUI',\n severidad: 'Media',\n duiNomina: dui,\n duiPlanilla: dui,\n nombreNomina: payroll.name,\n nombrePlanilla: plan.name,\n montoNomina: payroll.amount,\n montoPlanilla: plan.amount,\n accion: 'Verificar la escritura del nombre; el DUI coincide en ambas fuentes.'\n });\n }\n const delta = payroll.amount - plan.amount;\n if (Math.abs(delta) > 0.0000001) {\n const isSubcent = Math.abs(round2(payroll.amount) - round2(plan.amount)) <= 0.0000001;\n addFinding({\n tipo: isSubcent ? 'Diferencia subcentavo' : 'Diferencia de monto',\n severidad: isSubcent ? 'Informativa' : 'Alta',\n duiNomina: dui,\n duiPlanilla: dui,\n nombreNomina: payroll.name,\n nombrePlanilla: plan.name,\n montoNomina: payroll.amount,\n montoPlanilla: plan.amount,\n diferencia: delta,\n accion: isSubcent\n ? 'Los montos coinciden a dos decimales; conservar como control de precisión.'\n : 'Revisar salario, pago adicional, vacaciones, subsidios y acumulación de filas.'\n });\n }\n }\n\n const onlyPayroll = [...payrollGroups.values()].filter(group => !planGroups.has(group.dui));\n const onlyPlan = [...planGroups.values()].filter(group => !payrollGroups.has(group.dui));\n const usedPlan = new Set();\n\n function bestUnmatchedName(targetName, candidates, excludedIndexes) {\n const scored = candidates.map((candidate, index) => {\n const scoreData = nameScore(targetName, candidate.name);\n return { candidate, index, score: scoreData.score, shared: scoreData.shared };\n }).filter(result => !excludedIndexes.has(result.index) && result.score > 0)\n .sort((a, b) => b.score - a.score);\n const best = scored[0] || null;\n const second = scored[1] || null;\n if (!best) return { accepted: false, best: null };\n const margin = second ? best.score - second.score : best.score;\n const exact = canonicalName(targetName) === canonicalName(best.candidate.name);\n const accepted = exact || (best.shared >= 2 && best.score >= 0.88 && (margin >= 0.08 || best.score >= 0.98));\n return { accepted, best, second, margin };\n }\n\n for (const payroll of onlyPayroll) {\n const match = bestUnmatchedName(payroll.name, onlyPlan, usedPlan);\n if (match.accepted) {\n usedPlan.add(match.best.index);\n const plan = match.best.candidate;\n addFinding({\n tipo: 'Posible misma persona con DUI diferente',\n severidad: 'Alta',\n duiNomina: payroll.dui,\n duiPlanilla: plan.dui,\n nombreNomina: payroll.name,\n nombrePlanilla: plan.name,\n montoNomina: payroll.amount,\n montoPlanilla: plan.amount,\n accion: 'Confirmar la identidad y corregir el DUI en la fuente que corresponda.'\n });\n } else {\n addFinding({\n tipo: 'Persona en nómina que no está en planilla',\n severidad: 'Alta',\n duiNomina: payroll.dui,\n nombreNomina: payroll.name,\n montoNomina: payroll.amount,\n accion: 'Validar inclusión en la planilla de Seguridad Social.'\n });\n }\n }\n onlyPlan.forEach((plan, index) => {\n if (usedPlan.has(index)) return;\n addFinding({\n tipo: 'Persona en planilla que no está en nómina',\n severidad: 'Alta',\n duiPlanilla: plan.dui,\n nombrePlanilla: plan.name,\n montoPlanilla: plan.amount,\n accion: 'Validar si corresponde excluirla o incorporarla a la nómina.'\n });\n });\n\n function collectBambooPayload(payload, rows, totals) {\n if (payload == null) return;\n if (Array.isArray(payload)) {\n for (const value of payload) collectBambooPayload(value, rows, totals);\n return;\n }\n if (typeof payload !== 'object') return;\n if (Number(payload?.meta?.total) > 0) totals.push(Number(payload.meta.total));\n if (payload.body && typeof payload.body === 'object') {\n collectBambooPayload(payload.body, rows, totals);\n return;\n }\n if (Array.isArray(payload.data)) {\n rows.push(...payload.data);\n return;\n }\n if (Array.isArray(payload.employees)) {\n rows.push(...payload.employees);\n return;\n }\n if (payload.fullName5 || payload.displayName || payload.firstName || payload.lastName) rows.push(payload);\n }\n\n const bambooRaw = [];\n const bambooTotals = [];\n for (const item of bambooInput) collectBambooPayload(item.json, bambooRaw, bambooTotals);\n\n function uniqueNames(values) {\n return [...new Set(values.map(clean).filter(Boolean))];\n }\n\n function normalizeBambooDui(value) {\n const digits = clean(value).replace(/\\D/g, '');\n return digits.length === 9 ? digits.slice(0, 8) + '-' + digits.slice(8) : '';\n }\n\n function isElSalvadorEmployee(row) {\n return /EL\\s*SALVADOR|SALVADOR/i.test([\n row.location,\n row.country,\n row.division,\n row.department\n ].map(clean).join(' '));\n }\n\n const bambooUnique = new Map();\n for (let index = 0; index < bambooRaw.length; index++) {\n const row = bambooRaw[index] || {};\n const constructedName = [row.firstName, row.middleName, row.lastName].map(clean).filter(Boolean).join(' ');\n const aliases = uniqueNames([\n row.fullName5,\n row.fullName1,\n row.fullName2,\n row.fullName3,\n row.fullName4,\n constructedName,\n [row.firstName, row.lastName].map(clean).filter(Boolean).join(' '),\n [row.preferredName, row.lastName].map(clean).filter(Boolean).join(' '),\n row.displayName\n ]);\n const name = clean(row.displayName || row.fullName5 || row.fullName1 || constructedName);\n if (!name || !aliases.length) continue;\n const identity = clean(row.id || row.employeeId);\n const fallbackIdentity = clean(row.employeeNumber) + '|' + canonicalName(name) + '|' + clean(row.hireDate || row.originalHireDate);\n const key = identity || fallbackIdentity || 'fila-' + index;\n if (!bambooUnique.has(key)) {\n bambooUnique.set(key, {\n id: identity || key,\n employeeId: clean(row.employeeId || row.id),\n employeeNumber: clean(row.employeeNumber),\n dui: normalizeBambooDui(row.employeeNumber),\n name,\n aliases,\n canonicalAliases: [...new Set(aliases.map(canonicalName).filter(Boolean))],\n status: clean(row.status || row.employmentStatus || row.employmentHistoryStatus),\n hireDate: clean(row.hireDate || row.originalHireDate).slice(0, 10),\n terminationDate: clean(row.terminationDate).slice(0, 10),\n location: clean(row.location),\n country: clean(row.country),\n division: clean(row.division),\n department: clean(row.department),\n email: clean(row.workEmail),\n isElSalvador: isElSalvadorEmployee(row)\n });\n }\n }\n\n const bambooEmployees = [...bambooUnique.values()];\n const reportedTotal = bambooTotals.length ? Math.max(...bambooTotals) : bambooEmployees.length;\n if (!bambooEmployees.length) {\n throw new Error('BambooHR no devolvió empleados legibles. Revisa la credencial y los permisos del reporte completo.');\n }\n if (reportedTotal > bambooEmployees.length) {\n throw new Error('BambooHR devolvió ' + bambooEmployees.length + ' empleados únicos de ' + reportedTotal + '. La consulta quedó incompleta.');\n }\n\n function phoneticToken(value) {\n return normalizeName(value)\n .replace(/LL/g, 'Y')\n .replace(/QU/g, 'K')\n .replace(/PH/g, 'F')\n .replace(/C(?=[EI])/g, 'S')\n .replace(/G(?=[EI])/g, 'J')\n .replace(/Z/g, 'S')\n .replace(/V/g, 'B')\n .replace(/H/g, '')\n .replace(/Q/g, 'K')\n .replace(/Y/g, 'I')\n .replace(/(.)\\1+/g, '$1');\n }\n\n function damerauLevenshtein(left, right) {\n const a = String(left || '');\n const b = String(right || '');\n const rows = a.length + 1;\n const columns = b.length + 1;\n const matrix = Array.from({ length: rows }, () => Array(columns).fill(0));\n for (let row = 0; row < rows; row++) matrix[row][0] = row;\n for (let column = 0; column < columns; column++) matrix[0][column] = column;\n for (let row = 1; row < rows; row++) {\n for (let column = 1; column < columns; column++) {\n const cost = a[row - 1] === b[column - 1] ? 0 : 1;\n matrix[row][column] = Math.min(\n matrix[row - 1][column] + 1,\n matrix[row][column - 1] + 1,\n matrix[row - 1][column - 1] + cost\n );\n if (\n row > 1 &&\n column > 1 &&\n a[row - 1] === b[column - 2] &&\n a[row - 2] === b[column - 1]\n ) {\n matrix[row][column] = Math.min(\n matrix[row][column],\n matrix[row - 2][column - 2] + cost\n );\n }\n }\n }\n return matrix[a.length][b.length];\n }\n\n function tokenSimilarity(left, right) {\n const rawLeft = normalizeName(left);\n const rawRight = normalizeName(right);\n const phoneticLeft = phoneticToken(left);\n const phoneticRight = phoneticToken(right);\n const rawScore = 1 - damerauLevenshtein(rawLeft, rawRight) / Math.max(rawLeft.length, rawRight.length, 1);\n const phoneticScore = 1 - damerauLevenshtein(phoneticLeft, phoneticRight) / Math.max(phoneticLeft.length, phoneticRight.length, 1);\n return Math.max(rawScore, phoneticScore);\n }\n\n function compareBambooNames(personName, bambooName) {\n const personTokens = nameTokens(personName);\n const bambooTokens = nameTokens(bambooName);\n if (!personTokens.length || !bambooTokens.length) {\n return {\n score: 0,\n coverage: 0,\n strong: 0,\n anchors: 0,\n exact: 0,\n weak: personTokens.length,\n targetCount: personTokens.length,\n candidateCount: bambooTokens.length,\n extra: bambooTokens.length,\n pairs: []\n };\n }\n\n const choices = [];\n for (let personIndex = 0; personIndex < personTokens.length; personIndex++) {\n for (let bambooIndex = 0; bambooIndex < bambooTokens.length; bambooIndex++) {\n choices.push({\n personIndex,\n bambooIndex,\n similarity: tokenSimilarity(personTokens[personIndex], bambooTokens[bambooIndex])\n });\n }\n }\n choices.sort((left, right) => right.similarity - left.similarity);\n\n const usedPerson = new Set();\n const usedBamboo = new Set();\n const pairs = [];\n for (const choice of choices) {\n if (usedPerson.has(choice.personIndex) || usedBamboo.has(choice.bambooIndex)) continue;\n usedPerson.add(choice.personIndex);\n usedBamboo.add(choice.bambooIndex);\n pairs.push({\n person: personTokens[choice.personIndex],\n bamboo: bambooTokens[choice.bambooIndex],\n similarity: choice.similarity\n });\n }\n for (let personIndex = 0; personIndex < personTokens.length; personIndex++) {\n if (!usedPerson.has(personIndex)) {\n pairs.push({ person: personTokens[personIndex], bamboo: '', similarity: 0 });\n }\n }\n\n const totalWeight = personTokens.reduce((sum, token) => sum + Math.min(Math.max(token.length, 3), 10), 0) || 1;\n const coverage = pairs.reduce((sum, pair) => {\n const weight = Math.min(Math.max(pair.person.length, 3), 10);\n return sum + pair.similarity * weight;\n }, 0) / totalWeight;\n const extra = Math.max(0, bambooTokens.length - usedBamboo.size);\n const score = Math.max(0, coverage - Math.min(0.03, extra * 0.008));\n\n return {\n score,\n coverage,\n strong: pairs.filter(pair => pair.similarity >= 0.78).length,\n anchors: pairs.filter(pair => pair.similarity >= 0.92).length,\n exact: pairs.filter(pair => pair.similarity >= 0.999).length,\n weak: pairs.filter(pair => pair.similarity < 0.70).length,\n targetCount: personTokens.length,\n candidateCount: bambooTokens.length,\n extra,\n pairs\n };\n }\n\n const exactBambooByDui = new Map();\n const exactBambooByName = new Map();\n const bambooTokenIndex = new Map();\n const bambooPhoneticIndex = new Map();\n const bambooElSalvador = bambooEmployees.filter(employee => employee.isElSalvador);\n\n for (const employee of bambooEmployees) {\n if (employee.dui) {\n if (!exactBambooByDui.has(employee.dui)) exactBambooByDui.set(employee.dui, new Set());\n exactBambooByDui.get(employee.dui).add(employee);\n }\n for (const canonical of employee.canonicalAliases) {\n if (!exactBambooByName.has(canonical)) exactBambooByName.set(canonical, new Set());\n exactBambooByName.get(canonical).add(employee);\n }\n for (const alias of employee.aliases) {\n for (const token of new Set(nameTokens(alias))) {\n if (!bambooTokenIndex.has(token)) bambooTokenIndex.set(token, new Set());\n bambooTokenIndex.get(token).add(employee);\n const phonetic = phoneticToken(token);\n if (!bambooPhoneticIndex.has(phonetic)) bambooPhoneticIndex.set(phonetic, new Set());\n bambooPhoneticIndex.get(phonetic).add(employee);\n }\n }\n }\n\n function bambooRank(employee) {\n const status = clean(employee.status);\n const active = /^(ACTIVE|ACTIVO)$/i.test(status) ? 2 : 0;\n return (employee.isElSalvador ? 4 : 0) + active;\n }\n\n function bestEmployeeNameScore(employee, personAliases) {\n let best = null;\n for (const personAlias of personAliases) {\n for (const bambooAlias of employee.aliases) {\n const comparison = compareBambooNames(personAlias, bambooAlias);\n if (!best || comparison.score > best.comparison.score) {\n best = { comparison, personAlias, bambooAlias };\n }\n }\n }\n return best || {\n comparison: compareBambooNames('', ''),\n personAlias: '',\n bambooAlias: ''\n };\n }\n\n function scoreEmployees(employees, personAliases) {\n return [...employees].map(employee => {\n const result = bestEmployeeNameScore(employee, personAliases);\n return {\n employee,\n comparison: result.comparison,\n personAlias: result.personAlias,\n bambooAlias: result.bambooAlias,\n rank: bambooRank(employee)\n };\n }).sort((left, right) =>\n right.comparison.score - left.comparison.score ||\n right.rank - left.rank\n );\n }\n\n function exactDuiNameCompatible(result) {\n const comparison = result.comparison;\n return (\n canonicalName(result.personAlias) === canonicalName(result.bambooAlias) ||\n (comparison.strong >= Math.min(2, comparison.targetCount) && comparison.score >= 0.62) ||\n comparison.score >= 0.78\n );\n }\n\n function identityIssueFor(person, matchedEmployee, duiOwner) {\n const matchedDui = clean(matchedEmployee?.dui);\n if (duiOwner && matchedEmployee && duiOwner.id !== matchedEmployee.id) {\n return {\n type: 'DUI_BAMBOO_OTRA_PERSONA',\n duiOwner,\n matchedEmployee,\n sourceDui: person.dui\n };\n }\n if (matchedEmployee && matchedDui && matchedDui !== person.dui) {\n return {\n type: 'DUI_BAMBOO_DIFERENTE',\n duiOwner: null,\n matchedEmployee,\n sourceDui: person.dui\n };\n }\n return null;\n }\n\n function acceptedMatch(methodCode, method, employee, score, margin, result, identityIssue) {\n return {\n accepted: true,\n review: false,\n methodCode,\n method,\n employee,\n candidate: employee,\n score,\n margin,\n sourceAlias: result?.personAlias || '',\n matchedAlias: result?.bambooAlias || '',\n identityIssue: identityIssue || null\n };\n }\n\n function matchBambooPerson(person) {\n const personAliases = uniqueNames(person.aliases || []);\n const exactDuiCandidates = [...(exactBambooByDui.get(person.dui) || [])];\n let duiOwner = null;\n\n if (exactDuiCandidates.length) {\n const scoredDui = scoreEmployees(exactDuiCandidates, personAliases);\n const compatibleDui = scoredDui.filter(exactDuiNameCompatible);\n const compatibleElSalvador = compatibleDui.filter(result => result.employee.isElSalvador);\n const compatiblePool = compatibleElSalvador.length ? compatibleElSalvador : compatibleDui;\n if (compatiblePool.length === 1) {\n const best = compatiblePool[0];\n return acceptedMatch(\n 'DUI_EXACTO',\n 'DUI exacto',\n best.employee,\n 1,\n 1,\n best,\n null\n );\n }\n if (compatiblePool.length > 1) {\n const best = compatiblePool[0];\n const second = compatiblePool[1];\n const margin = best.comparison.score - second.comparison.score;\n if (best.comparison.score >= 0.90 && margin >= 0.10) {\n return acceptedMatch(\n 'DUI_EXACTO_DESAMBIGUADO',\n 'DUI exacto desambiguado por nombre',\n best.employee,\n 1,\n margin,\n best,\n null\n );\n }\n }\n duiOwner = scoredDui[0]?.employee || exactDuiCandidates[0];\n }\n\n const exactNameEmployees = new Set();\n for (const alias of personAliases) {\n for (const employee of exactBambooByName.get(canonicalName(alias)) || []) {\n exactNameEmployees.add(employee);\n }\n }\n if (exactNameEmployees.size) {\n const exactList = [...exactNameEmployees];\n const exactElSalvador = exactList.filter(employee => employee.isElSalvador);\n const exactPool = exactElSalvador.length ? exactElSalvador : exactList;\n if (exactPool.length === 1) {\n const employee = exactPool[0];\n const result = bestEmployeeNameScore(employee, personAliases);\n return acceptedMatch(\n 'NOMBRE_EXACTO_UNICO',\n duiOwner ? 'Nombre exacto; revisar DUI en BambooHR' : 'Nombre exacto único',\n employee,\n 1,\n 1,\n result,\n identityIssueFor(person, employee, duiOwner)\n );\n }\n }\n\n const candidates = new Set(bambooElSalvador);\n for (const alias of personAliases) {\n for (const token of new Set(nameTokens(alias))) {\n const exactTokenMatches = bambooTokenIndex.get(token);\n if (exactTokenMatches && exactTokenMatches.size <= 100) {\n for (const employee of exactTokenMatches) candidates.add(employee);\n }\n const phoneticMatches = bambooPhoneticIndex.get(phoneticToken(token));\n if (phoneticMatches && phoneticMatches.size <= 100) {\n for (const employee of phoneticMatches) candidates.add(employee);\n }\n }\n }\n\n const scored = scoreEmployees(candidates, personAliases);\n const best = scored[0] || null;\n const second = scored[1] || null;\n if (!best) {\n return {\n accepted: false,\n review: Boolean(duiOwner),\n methodCode: duiOwner ? 'DUI_NOMBRE_CONFLICTO' : 'SIN_COINCIDENCIA',\n method: duiOwner ? 'DUI asociado a otro nombre' : 'Sin coincidencia',\n employee: null,\n candidate: duiOwner,\n score: 0,\n margin: 0,\n identityIssue: identityIssueFor(person, null, duiOwner)\n };\n }\n\n const comparison = best.comparison;\n const margin = second ? comparison.score - second.comparison.score : 1;\n const longStrong = (\n comparison.targetCount >= 3 &&\n comparison.weak === 0 &&\n comparison.strong >= 3 &&\n comparison.anchors >= 2 &&\n comparison.score >= 0.92 &&\n margin >= 0.10 &&\n (best.employee.isElSalvador || (comparison.score >= 0.97 && comparison.exact >= 3))\n );\n const shortStrong = (\n comparison.targetCount === 2 &&\n comparison.weak === 0 &&\n comparison.strong === 2 &&\n comparison.anchors === 2 &&\n comparison.exact >= 1 &&\n comparison.score >= 0.96 &&\n margin >= 0.15 &&\n best.employee.isElSalvador\n );\n\n if (longStrong || shortStrong) {\n return acceptedMatch(\n 'NOMBRE_TIPOGRAFICO_UNICO',\n duiOwner ? 'Nombre tipográfico; revisar DUI en BambooHR' : 'Nombre tipográfico único',\n best.employee,\n comparison.score,\n margin,\n best,\n identityIssueFor(person, best.employee, duiOwner)\n );\n }\n\n const review = Boolean(duiOwner) || (\n comparison.score >= 0.82 &&\n comparison.strong >= Math.min(2, comparison.targetCount)\n );\n return {\n accepted: false,\n review,\n methodCode: duiOwner ? 'DUI_NOMBRE_CONFLICTO' : (review ? 'COINCIDENCIA_AMBIGUA' : 'SIN_COINCIDENCIA'),\n method: duiOwner ? 'DUI asociado a otro nombre' : (review ? 'Coincidencia ambigua' : 'Sin coincidencia'),\n employee: null,\n candidate: duiOwner || best.employee,\n score: comparison.score,\n margin,\n sourceAlias: best.personAlias,\n matchedAlias: best.bambooAlias,\n identityIssue: identityIssueFor(person, null, duiOwner)\n };\n }\n\n function sourcePeople(groups, missingRecords, source) {\n const people = [...groups.values()].map(group => ({\n source,\n dui: group.dui,\n name: group.name,\n project: group.project || '',\n amount: group.amount\n }));\n for (const record of missingRecords) {\n people.push({\n source,\n dui: '',\n name: record.name,\n project: record.project || '',\n amount: record.amount\n });\n }\n return people;\n }\n\n const payrollPeople = sourcePeople(payrollGroups, payrollMissingDui, 'Nómina');\n const planPeople = sourcePeople(planGroups, planMissingDui, 'Planilla');\n const unifiedPeople = new Map();\n\n function addUnifiedPerson(person, index) {\n const key = person.dui\n ? 'DUI:' + person.dui\n : person.source + ':SIN_DUI:' + index + ':' + canonicalName(person.name);\n person.matchKey = key;\n if (!unifiedPeople.has(key)) {\n unifiedPeople.set(key, {\n key,\n dui: person.dui,\n aliases: [],\n payrollNames: [],\n planNames: [],\n payrollAmounts: [],\n planAmounts: []\n });\n }\n const unified = unifiedPeople.get(key);\n unified.aliases = uniqueNames([...unified.aliases, person.name]);\n if (person.source === 'Nómina') {\n unified.payrollNames = uniqueNames([...unified.payrollNames, person.name]);\n unified.payrollAmounts.push(person.amount);\n } else {\n unified.planNames = uniqueNames([...unified.planNames, person.name]);\n unified.planAmounts.push(person.amount);\n }\n }\n\n payrollPeople.forEach(addUnifiedPerson);\n planPeople.forEach(addUnifiedPerson);\n\n const matchByKey = new Map();\n for (const person of unifiedPeople.values()) {\n matchByKey.set(person.key, matchBambooPerson(person));\n }\n\n const bambooUsage = new Map();\n for (const [key, match] of matchByKey) {\n if (!match.accepted || !match.employee?.id) continue;\n if (!bambooUsage.has(match.employee.id)) bambooUsage.set(match.employee.id, []);\n bambooUsage.get(match.employee.id).push({ key, match });\n }\n for (const uses of bambooUsage.values()) {\n if (uses.length <= 1) continue;\n const exactDuiUses = uses.filter(use => /^DUI_EXACTO/.test(use.match.methodCode));\n if (exactDuiUses.length === 1) {\n for (const use of uses) {\n if (use === exactDuiUses[0]) continue;\n use.match.accepted = false;\n use.match.review = true;\n use.match.methodCode = 'REGISTRO_BAMBOO_REUTILIZADO';\n use.match.method = 'Coincidencia ambigua: registro BambooHR ya asignado a otro DUI';\n }\n } else {\n for (const use of uses) {\n use.match.accepted = false;\n use.match.review = true;\n use.match.methodCode = 'REGISTRO_BAMBOO_REUTILIZADO';\n use.match.method = 'Coincidencia ambigua: registro BambooHR asociado a más de un DUI';\n }\n }\n }\n\n const bambooMatchStats = {\n duiExacto: 0,\n nombreExacto: 0,\n nombreTipografico: 0,\n inactivos: 0,\n conflictosDui: 0,\n ambiguos: 0,\n sinCoincidencia: 0\n };\n\n for (const person of unifiedPeople.values()) {\n const match = matchByKey.get(person.key);\n if (match.accepted) {\n if (/^DUI_EXACTO/.test(match.methodCode)) bambooMatchStats.duiExacto++;\n else if (match.methodCode === 'NOMBRE_EXACTO_UNICO') bambooMatchStats.nombreExacto++;\n else if (match.methodCode === 'NOMBRE_TIPOGRAFICO_UNICO') bambooMatchStats.nombreTipografico++;\n if (clean(match.employee?.status) && !/^(ACTIVE|ACTIVO)$/i.test(match.employee.status)) {\n bambooMatchStats.inactivos++;\n }\n } else if (match.review) {\n bambooMatchStats.ambiguos++;\n } else {\n bambooMatchStats.sinCoincidencia++;\n }\n\n if (match.identityIssue) {\n bambooMatchStats.conflictosDui++;\n const issue = match.identityIssue;\n const matchedEmployee = issue.matchedEmployee || match.employee;\n const duiOwner = issue.duiOwner;\n const detail = duiOwner\n ? 'En BambooHR el DUI/employeeNumber ' + person.dui + ' está asociado a ' + duiOwner.name +\n '. La persona coincide por nombre con ' + (matchedEmployee?.name || 'otro registro') +\n (matchedEmployee?.employeeNumber ? ' (employeeNumber ' + matchedEmployee.employeeNumber + ')' : '') + '.'\n : 'La persona coincide por nombre con ' + (matchedEmployee?.name || 'un registro de BambooHR') +\n ', pero BambooHR contiene el employeeNumber ' + (matchedEmployee?.employeeNumber || 'vacío o inválido') +\n ' y las fuentes del cruce contienen ' + person.dui + '.';\n addFinding({\n tipo: 'DUI distinto o asociado a otra persona en BambooHR',\n severidad: 'Alta',\n duiNomina: person.payrollNames.length ? person.dui : '',\n duiPlanilla: person.planNames.length ? person.dui : '',\n nombreNomina: person.payrollNames.join(' | '),\n nombrePlanilla: person.planNames.join(' | '),\n montoNomina: person.payrollAmounts.length ? person.payrollAmounts.reduce((sum, value) => sum + Number(value || 0), 0) : '',\n montoPlanilla: person.planAmounts.length ? person.planAmounts.reduce((sum, value) => sum + Number(value || 0), 0) : '',\n accion: detail + ' Corregir y validar el identificador en BambooHR.'\n });\n }\n }\n\n const payrollWithoutBamboo = [];\n const planWithoutBamboo = [];\n\n function appendBambooException(person, output) {\n const match = matchByKey.get(person.matchKey);\n if (match?.accepted) return;\n const candidate = match?.candidate;\n output.push({\n dui: person.dui,\n name: person.name,\n project: person.project || '',\n amount: person.amount,\n candidateName: candidate?.name || '',\n score: Number(match?.score || 0),\n method: match?.method || 'Sin coincidencia',\n candidateStatus: candidate?.status || '',\n candidateLocation: candidate?.location || '',\n candidateCountry: candidate?.country || '',\n action: match?.review\n ? 'Revisar manualmente la coincidencia; no se clasificó automáticamente como persona ausente.'\n : 'Crear o validar el registro de la persona en BambooHR.'\n });\n }\n\n for (const person of payrollPeople) appendBambooException(person, payrollWithoutBamboo);\n for (const person of planPeople) appendBambooException(person, planWithoutBamboo);\n payrollWithoutBamboo.sort((left, right) => left.name.localeCompare(right.name, 'es'));\n planWithoutBamboo.sort((left, right) => left.name.localeCompare(right.name, 'es'));\n\n const severityOrder = { Alta: 0, Media: 1, Informativa: 2 };\n findings.sort((a, b) => (severityOrder[a.severidad] ?? 9) - (severityOrder[b.severidad] ?? 9) || a.tipo.localeCompare(b.tipo, 'es') || (a.nombreNomina || a.nombrePlanilla).localeCompare(b.nombreNomina || b.nombrePlanilla, 'es'));\n\n const amountFindings = findings.filter(item => item.tipo === 'Diferencia de monto' || item.tipo === 'Diferencia subcentavo').length;\n const subcentFindings = findings.filter(item => item.tipo === 'Diferencia subcentavo').length;\n const identityFindings = findings.length - amountFindings;\n const nameFindings = findings.filter(item => item.tipo === 'Nombre no idéntico con el mismo DUI').length;\n const highFindings = findings.filter(item => item.severidad === 'Alta').length;\n const totalFindings = findings.length + payrollWithoutBamboo.length + planWithoutBamboo.length;\n\n function color(hex) {\n const text = hex.replace('#', '');\n return {\n red: parseInt(text.slice(0, 2), 16) / 255,\n green: parseInt(text.slice(2, 4), 16) / 255,\n blue: parseInt(text.slice(4, 6), 16) / 255\n };\n }\n const COLORS = {\n blue: color('#4F758B'),\n mediumBlue: color('#5B7F95'),\n steel: color('#6B8FA3'),\n green: color('#6CC24A'),\n greenBg: color('#EEF6E8'),\n blueBg: color('#D6E8F4'),\n gray: color('#4A4A4A'),\n border: color('#D0D0D0'),\n zebra: color('#F5F5F5'),\n white: color('#FFFFFF'),\n redBg: color('#FDECEC'),\n redText: color('#9C2F2F'),\n amberBg: color('#FFF6E3'),\n amberText: color('#8A5A00')\n };\n function gridBorder() {\n return { style: 'SOLID', color: COLORS.border };\n }\n function valueObject(value) {\n if (typeof value === 'number' && Number.isFinite(value)) return { numberValue: value };\n if (typeof value === 'boolean') return { boolValue: value };\n return { stringValue: value == null ? '' : String(value) };\n }\n function sheetDefinition(options) {\n const columnCount = options.headers.length;\n const empty = Array(columnCount).fill('');\n const dataRows = options.rows.length ? options.rows : [[options.emptyLabel || 'Sin diferencias', ...Array(columnCount - 1).fill('')]];\n const rawRows = [\n [options.title, ...Array(columnCount - 1).fill('')],\n [options.subtitle, ...Array(columnCount - 1).fill('')],\n [options.note, ...Array(columnCount - 1).fill('')],\n empty.slice(),\n options.headers,\n ...dataRows\n ];\n const rowData = rawRows.map((row, rowIndex) => {\n let background = COLORS.white;\n let foreground = COLORS.gray;\n let bold = false;\n let fontSize = 10;\n let horizontal = 'LEFT';\n let wrap = 'WRAP';\n if (rowIndex === 0) {\n background = COLORS.blue;\n foreground = COLORS.white;\n bold = true;\n fontSize = 15;\n horizontal = 'LEFT';\n } else if (rowIndex === 1) {\n background = COLORS.mediumBlue;\n foreground = COLORS.white;\n bold = true;\n } else if (rowIndex === 2) {\n background = COLORS.blueBg;\n foreground = COLORS.blue;\n bold = true;\n } else if (rowIndex === 4) {\n background = COLORS.blue;\n foreground = COLORS.white;\n bold = true;\n horizontal = 'CENTER';\n } else if (rowIndex >= 5) {\n background = (rowIndex - 5) % 2 ? COLORS.zebra : COLORS.white;\n if (row[0] === 'Sin diferencias' || row[0] === 'Sin personas fuera de BambooHR') {\n background = COLORS.greenBg;\n foreground = COLORS.green;\n bold = true;\n }\n if (options.kind === 'main' && row[1] === 'Alta') {\n background = COLORS.redBg;\n foreground = COLORS.redText;\n } else if (options.kind === 'main' && row[1] === 'Media') {\n background = COLORS.amberBg;\n foreground = COLORS.amberText;\n } else if (options.kind === 'main' && row[1] === 'Informativa') {\n background = COLORS.blueBg;\n foreground = COLORS.blue;\n }\n }\n return {\n values: row.map((value, columnIndex) => {\n const format = {\n backgroundColor: background,\n textFormat: {\n foregroundColor: foreground,\n bold,\n fontFamily: 'Arial',\n fontSize\n },\n verticalAlignment: 'MIDDLE',\n horizontalAlignment: rowIndex === 4 ? 'CENTER' : (options.moneyColumns.includes(columnIndex) && rowIndex >= 5 ? 'RIGHT' : horizontal),\n wrapStrategy: wrap,\n padding: { top: 5, bottom: 5, left: 7, right: 7 }\n };\n if (rowIndex >= 4) {\n format.borders = {\n top: gridBorder(),\n bottom: gridBorder(),\n left: gridBorder(),\n right: gridBorder()\n };\n }\n if (options.moneyColumns.includes(columnIndex) && rowIndex >= 5 && typeof value === 'number') {\n format.numberFormat = { type: 'CURRENCY', pattern: '$#,##0.0000;[Red]-$#,##0.0000' };\n }\n return { userEnteredValue: valueObject(value), userEnteredFormat: format };\n })\n };\n });\n const rowMetadata = rawRows.map((row, index) => ({ pixelSize: index === 0 ? 38 : index === 3 ? 10 : index === 4 ? 34 : index < 3 ? 27 : 31 }));\n return {\n properties: {\n sheetId: options.sheetId,\n title: options.sheetTitle,\n tabColor: options.tabColor,\n gridProperties: {\n rowCount: Math.max(rawRows.length + 20, 50),\n columnCount,\n frozenRowCount: 5,\n hideGridlines: true\n }\n },\n data: [{\n startRow: 0,\n startColumn: 0,\n rowData,\n rowMetadata,\n columnMetadata: options.widths.map(pixelSize => ({ pixelSize }))\n }],\n merges: [0, 1, 2].map(rowIndex => ({\n sheetId: options.sheetId,\n startRowIndex: rowIndex,\n endRowIndex: rowIndex + 1,\n startColumnIndex: 0,\n endColumnIndex: columnCount\n })),\n basicFilter: {\n range: {\n sheetId: options.sheetId,\n startRowIndex: 4,\n endRowIndex: rawRows.length,\n startColumnIndex: 0,\n endColumnIndex: columnCount\n }\n }\n };\n }\n\n const mainHeaders = ['Tipo de diferencia', 'Severidad', 'DUI Nómina', 'DUI Planilla', 'Nombre Nómina', 'Nombre Planilla', 'Monto Nómina (USD)', 'Monto Planilla (USD)', 'Diferencia (USD)', 'Observación / Acción'];\n const mainRows = findings.map(item => [\n item.tipo,\n item.severidad,\n item.duiNomina,\n item.duiPlanilla,\n item.nombreNomina,\n item.nombrePlanilla,\n item.montoNomina,\n item.montoPlanilla,\n item.diferencia,\n item.accion\n ]);\n const payrollBambooHeaders = ['DUI', 'Nombre en Nómina', 'Proyecto', 'Monto Nómina (USD)', 'Mejor candidato en BambooHR', 'Similitud', 'Resultado automático', 'Estado candidato', 'Ubicación / País', 'Acción'];\n const payrollBambooRows = payrollWithoutBamboo.map(item => [\n item.dui,\n item.name,\n item.project,\n item.amount,\n item.candidateName,\n item.score ? Math.round(item.score * 100) + '%' : '',\n item.method,\n item.candidateStatus,\n uniqueJoin([item.candidateLocation, item.candidateCountry]),\n item.action\n ]);\n const planBambooHeaders = ['DUI', 'Nombre en Planilla', 'Monto Planilla (USD)', 'Mejor candidato en BambooHR', 'Similitud', 'Resultado automático', 'Estado candidato', 'Ubicación / País', 'Acción'];\n const planBambooRows = planWithoutBamboo.map(item => [\n item.dui,\n item.name,\n item.amount,\n item.candidateName,\n item.score ? Math.round(item.score * 100) + '%' : '',\n item.method,\n item.candidateStatus,\n uniqueJoin([item.candidateLocation, item.candidateCountry]),\n item.action\n ]);\n\n const timestamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 16);\n const reportTitle = 'Cruce Seguridad Social El Salvador - ' + periodLabel + ' - ' + timestamp;\n const spreadsheetPayload = {\n properties: {\n title: reportTitle,\n locale: 'es_SV',\n timeZone: 'America/El_Salvador'\n },\n sheets: [\n sheetDefinition({\n sheetId: 0,\n sheetTitle: 'Diferencias',\n title: 'CRUCE DE SEGURIDAD SOCIAL · EL SALVADOR',\n subtitle: 'Período: ' + periodLabel + ' · Nómina vs. Planilla Única',\n note: 'Solo se muestran hallazgos · Identidad/nombres: ' + identityFindings + ' · Montos: ' + amountFindings + ' · Total: ' + findings.length,\n headers: mainHeaders,\n rows: mainRows,\n widths: [210, 95, 115, 115, 190, 190, 130, 130, 125, 330],\n moneyColumns: [6, 7, 8],\n tabColor: COLORS.blue,\n kind: 'main',\n emptyLabel: 'Sin diferencias'\n }),\n sheetDefinition({\n sheetId: 1,\n sheetTitle: 'Nómina sin Bamboo',\n title: 'NÓMINA SIN BAMBOO',\n subtitle: 'Período: ' + periodLabel + ' · Personas de nómina sin coincidencia concluyente en BambooHR',\n note: 'Solo se muestran excepciones · Total: ' + payrollWithoutBamboo.length + ' · Las coincidencias ambiguas requieren revisión manual',\n headers: payrollBambooHeaders,\n rows: payrollBambooRows,\n widths: [115, 210, 150, 130, 210, 85, 145, 110, 170, 310],\n moneyColumns: [3],\n tabColor: COLORS.green,\n kind: 'bamboo',\n emptyLabel: 'Sin personas fuera de BambooHR'\n }),\n sheetDefinition({\n sheetId: 2,\n sheetTitle: 'Planilla sin Bamboo',\n title: 'PLANILLA SIN BAMBOO',\n subtitle: 'Período: ' + periodLabel + ' · Personas de planilla sin coincidencia concluyente en BambooHR',\n note: 'Solo se muestran excepciones · Total: ' + planWithoutBamboo.length + ' · Las coincidencias ambiguas requieren revisión manual',\n headers: planBambooHeaders,\n rows: planBambooRows,\n widths: [115, 220, 135, 220, 85, 145, 110, 180, 330],\n moneyColumns: [2],\n tabColor: COLORS.steel,\n kind: 'bamboo',\n emptyLabel: 'Sin personas fuera de BambooHR'\n })\n ]\n };\n\n const binaryValues = formItem.binary || {};\n const payrollBinary = binaryValues.nomina || Object.values(binaryValues).find(file => /nomina/i.test((file.fileName || '') + '')) || {};\n const planBinary = binaryValues.planilla_seguridad_social || Object.values(binaryValues).find(file => /planilla/i.test((file.fileName || '') + '')) || {};\n\n return [{\n json: {\n spreadsheetPayload,\n summary: {\n periodo: periodLabel,\n periodoArchivo: periodFile,\n correoDestino: 'iaracena@gomezleemarketing.com, msoto@gomezleemarketing.com, iherrera@gomezleemarketing.com, mgomez@gomezleemarketing.com, vparamo@gomezleemarketing.com, asrodriguez@gomezleemarketing.com, jgomez@gomezleemarketing.com, ymadera@gomezleemarketing.com',\n archivoNomina: payrollBinary.fileName || 'Nómina',\n archivoPlanilla: planBinary.fileName || 'Planilla',\n registrosNomina: payrollRecords.length,\n registrosPlanilla: planRecords.length,\n empleadosNomina: payrollGroups.size + payrollMissingDui.length,\n empleadosPlanilla: planGroups.size + planMissingDui.length,\n empleadosBamboo: bambooEmployees.length,\n hallazgosIdentidad: identityFindings,\n hallazgosMontos: amountFindings,\n hallazgosSubcentavo: subcentFindings,\n hallazgosNombres: nameFindings,\n hallazgosAlta: highFindings,\n hallazgosPrincipales: findings.length,\n nominaSinBamboo: payrollWithoutBamboo.length,\n planillaSinBamboo: planWithoutBamboo.length,\n coincidenciasBambooInactivas: bambooMatchStats.inactivos,\n coincidenciasBambooDuiExacto: bambooMatchStats.duiExacto,\n coincidenciasBambooNombreExacto: bambooMatchStats.nombreExacto,\n coincidenciasBambooNombreTipografico: bambooMatchStats.nombreTipografico,\n conflictosDuiBamboo: bambooMatchStats.conflictosDui,\n coincidenciasBambooAmbiguas: bambooMatchStats.ambiguos,\n hallazgosTotales: totalFindings,\n nombreArchivo: 'Cruce_Seguridad_Social_El_Salvador_' + periodFile + '.xlsx'\n }\n }\n }];" }, - "id": "1358629e-a085-42c6-8d94-a7a8fc9da36b", + "id": "63f94660-bf18-49af-aea4-dfcff86b78e8", "name": "Analizar y construir reporte", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 2720, - 800 + 768, + 2864 ], "onError": "continueErrorOutput" }, @@ -270,13 +270,13 @@ "parameters": { "jsCode": "const input = $input.first();\n const payload = input.json?.spreadsheetPayload;\n const summary = input.json?.summary || {};\n if (!payload?.sheets?.length) throw new Error('No se encontró la estructura del reporte para generar el Excel.');\n\n function esc(value) {\n return String(value ?? '').replace(/[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F]/g, '').replace(/&/g, '&').replace(//g, '>').replace(/\"/g, '"').replace(/'/g, ''');\n }\n function col(index) {\n let value = index + 1;\n let result = '';\n while (value > 0) {\n const remainder = (value - 1) % 26;\n result = String.fromCharCode(65 + remainder) + result;\n value = Math.floor((value - 1) / 26);\n }\n return result;\n }\n function valueOf(cell) {\n const value = cell?.userEnteredValue || {};\n if ('numberValue' in value) return value.numberValue;\n if ('boolValue' in value) return value.boolValue;\n if ('stringValue' in value) return value.stringValue;\n return '';\n }\n function rgbHex(rgb, fallback) {\n if (!rgb) return fallback;\n const channel = value => Math.max(0, Math.min(255, Math.round(Number(value || 0) * 255))).toString(16).padStart(2, '0').toUpperCase();\n return 'FF' + channel(rgb.red) + channel(rgb.green) + channel(rgb.blue);\n }\n function baseStyle(sheetTitle, rowIndex, values) {\n if (rowIndex === 0) return 1;\n if (rowIndex === 1) return 2;\n if (rowIndex === 2) return 3;\n if (rowIndex === 4) return 4;\n if (rowIndex < 5) return 0;\n if (values[0] === 'Sin diferencias' || values[0] === 'Sin personas fuera de BambooHR') return 10;\n if (sheetTitle === 'Diferencias') {\n if (values[1] === 'Alta') return 7;\n if (values[1] === 'Media') return 8;\n if (values[1] === 'Informativa') return 9;\n }\n return (rowIndex - 5) % 2 ? 6 : 5;\n }\n function moneyStyle(style) {\n return ({ 5: 11, 6: 12, 7: 13, 8: 14, 9: 15, 10: 16 })[style] || style;\n }\n function makeSheetXml(sheet) {\n const grid = sheet.data?.[0] || {};\n const rows = grid.rowData || [];\n const columnCount = sheet.properties?.gridProperties?.columnCount || Math.max(1, ...rows.map(row => row.values?.length || 0));\n const lastColumn = col(columnCount - 1);\n const sheetTitle = sheet.properties?.title || 'Hoja';\n const tabColor = rgbHex(sheet.properties?.tabColor, sheetTitle === 'Diferencias' ? 'FF4F758B' : 'FF6CC24A');\n const widths = (grid.columnMetadata || []).map(meta => Math.max(8, Math.min(60, Number(meta.pixelSize || 100) / 7)));\n const columns = Array.from({ length: columnCount }, (_, index) => '').join('');\n const rowXml = rows.map((row, rowIndex) => {\n const values = Array.from({ length: columnCount }, (_, index) => valueOf(row.values?.[index]));\n const style = baseStyle(sheetTitle, rowIndex, values);\n const height = rowIndex === 0 ? 30 : rowIndex === 3 ? 9 : rowIndex === 4 ? 28 : rowIndex < 3 ? 22 : 24;\n const cells = Array.from({ length: columnCount }, (_, columnIndex) => {\n const cell = row.values?.[columnIndex] || {};\n const value = values[columnIndex];\n const cellStyle = cell.userEnteredFormat?.numberFormat ? moneyStyle(style) : style;\n const reference = col(columnIndex) + (rowIndex + 1);\n if (typeof value === 'number' && Number.isFinite(value)) return '' + value + '';\n if (typeof value === 'boolean') return '' + (value ? 1 : 0) + '';\n return '' + esc(value) + '';\n }).join('');\n return '' + cells + '';\n }).join('');\n const mergeCells = (sheet.merges || []).map(merge => '');\n const merges = mergeCells.length ? '' + mergeCells.join('') + '' : '';\n const lastRow = Math.max(rows.length, 5);\n return '' +\n '' +\n '' +\n '' + columns + '' + rowXml + '' + merges +\n '';\n }\n\n const fills = ['4F758B', '5B7F95', 'D6E8F4', 'FFFFFF', 'F5F5F5', 'FDECEC', 'FFF6E3', 'EEF6E8'].map(hex => '').join('');\n function xf(numFmt, font, fill, border, alignment) {\n return '';\n }\n const xfs = [\n '',\n xf(0, 1, 2, 0, 'horizontal=\"left\" vertical=\"center\"'),\n xf(0, 2, 3, 0, 'horizontal=\"left\" vertical=\"center\"'),\n xf(0, 3, 4, 0, 'horizontal=\"left\" vertical=\"center\" wrapText=\"1\"'),\n xf(0, 2, 2, 1, 'horizontal=\"center\" vertical=\"center\" wrapText=\"1\"'),\n xf(0, 0, 5, 1, 'vertical=\"center\" wrapText=\"1\"'),\n xf(0, 0, 6, 1, 'vertical=\"center\" wrapText=\"1\"'),\n xf(0, 4, 7, 1, 'vertical=\"center\" wrapText=\"1\"'),\n xf(0, 5, 8, 1, 'vertical=\"center\" wrapText=\"1\"'),\n xf(0, 3, 4, 1, 'vertical=\"center\" wrapText=\"1\"'),\n xf(0, 6, 9, 1, 'vertical=\"center\" wrapText=\"1\"'),\n xf(164, 0, 5, 1, 'horizontal=\"right\" vertical=\"center\"'),\n xf(164, 0, 6, 1, 'horizontal=\"right\" vertical=\"center\"'),\n xf(164, 4, 7, 1, 'horizontal=\"right\" vertical=\"center\"'),\n xf(164, 5, 8, 1, 'horizontal=\"right\" vertical=\"center\"'),\n xf(164, 3, 4, 1, 'horizontal=\"right\" vertical=\"center\"'),\n xf(164, 6, 9, 1, 'horizontal=\"right\" vertical=\"center\"')\n ].join('');\n const styles = '' +\n '' +\n '' +\n '' +\n '' +\n '' + fills + '' +\n '' +\n '' + xfs + '';\n\n const sheetNames = payload.sheets.map(sheet => sheet.properties?.title || 'Hoja');\n const overrides = payload.sheets.map((sheet, index) => '').join('');\n const workbookSheets = sheetNames.map((name, index) => '').join('');\n const relationships = payload.sheets.map((sheet, index) => '').join('') +\n '';\n const nowIso = new Date().toISOString();\n const titleParts = sheetNames.map(name => '' + esc(name) + '').join('');\n const files = [\n { name: '[Content_Types].xml', data: '' + overrides + '' },\n { name: '_rels/.rels', data: '' },\n { name: 'docProps/core.xml', data: 'GomezLee Marketingn8n' + esc(summary.nombreArchivo || 'Cruce Seguridad Social El Salvador') + '' + nowIso + '' + nowIso + '' },\n { name: 'docProps/app.xml', data: 'Microsoft ExcelHojas de cálculo' + sheetNames.length + '' + titleParts + 'GomezLee Marketing16.0300' },\n { name: 'xl/workbook.xml', data: '' + workbookSheets + '' },\n { name: 'xl/_rels/workbook.xml.rels', data: '' + relationships + '' },\n { name: 'xl/styles.xml', data: styles }\n ];\n payload.sheets.forEach((sheet, index) => files.push({ name: 'xl/worksheets/sheet' + (index + 1) + '.xml', data: makeSheetXml(sheet) }));\n\n function crc32(buffer) {\n if (!crc32.table) crc32.table = Array.from({ length: 256 }, (_, number) => {\n let value = number;\n for (let bit = 0; bit < 8; bit++) value = (value & 1) ? (0xEDB88320 ^ (value >>> 1)) : (value >>> 1);\n return value >>> 0;\n });\n let crc = 0xFFFFFFFF;\n for (const byte of buffer) crc = crc32.table[(crc ^ byte) & 0xFF] ^ (crc >>> 8);\n return (crc ^ 0xFFFFFFFF) >>> 0;\n }\n function zip(entries) {\n const now = new Date();\n const year = Math.max(1980, now.getFullYear());\n const dosTime = (now.getHours() << 11) | (now.getMinutes() << 5) | Math.floor(now.getSeconds() / 2);\n const dosDate = ((year - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate();\n const localParts = [];\n const centralParts = [];\n let offset = 0;\n for (const entry of entries) {\n const name = Buffer.from(entry.name, 'utf8');\n const data = Buffer.from(entry.data, 'utf8');\n const crc = crc32(data);\n const local = Buffer.alloc(30);\n local.writeUInt32LE(0x04034B50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0x0800, 6); local.writeUInt16LE(0, 8);\n local.writeUInt16LE(dosTime, 10); local.writeUInt16LE(dosDate, 12); local.writeUInt32LE(crc, 14); local.writeUInt32LE(data.length, 18); local.writeUInt32LE(data.length, 22); local.writeUInt16LE(name.length, 26);\n const localEntry = Buffer.concat([local, name, data]);\n localParts.push(localEntry);\n const central = Buffer.alloc(46);\n central.writeUInt32LE(0x02014B50, 0); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6); central.writeUInt16LE(0x0800, 8); central.writeUInt16LE(0, 10);\n central.writeUInt16LE(dosTime, 12); central.writeUInt16LE(dosDate, 14); central.writeUInt32LE(crc, 16); central.writeUInt32LE(data.length, 20); central.writeUInt32LE(data.length, 24); central.writeUInt16LE(name.length, 28); central.writeUInt32LE(offset, 42);\n centralParts.push(Buffer.concat([central, name]));\n offset += localEntry.length;\n }\n const directory = Buffer.concat(centralParts);\n const end = Buffer.alloc(22);\n end.writeUInt32LE(0x06054B50, 0); end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10); end.writeUInt32LE(directory.length, 12); end.writeUInt32LE(offset, 16);\n return Buffer.concat([...localParts, directory, end]);\n }\n\n const workbook = zip(files);\n const fileName = summary.nombreArchivo || 'Cruce_Seguridad_Social_El_Salvador.xlsx';\n return [{\n json: { ...summary },\n binary: { reporteExcel: { data: workbook.toString('base64'), mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', fileName, fileExtension: 'xlsx' } }\n }];" }, - "id": "f017b8e9-f5b4-4dac-98c5-223cdfdb4dec", + "id": "c2be1797-f1d3-45f6-a020-210d6c2c4a7e", "name": "Generar Excel", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 3184, - 816 + 1232, + 2880 ], "onError": "continueErrorOutput" }, @@ -299,13 +299,13 @@ "simplifyOutput": false } }, - "id": "57954526-8ffd-4856-94bb-3eaa1b357c77", + "id": "a74f4640-cac0-4b94-b5a8-7cbdd83d4b6b", "name": "Subir reporte a Google Drive", "type": "n8n-nodes-base.googleDrive", "typeVersion": 3, "position": [ - 3456, - 656 + 1504, + 2720 ], "executeOnce": true, "retryOnFail": true, @@ -320,16 +320,16 @@ { "parameters": { "mode": "raw", - "jsonOutput": "={{ (() => {\nconst recipients=[{\"name\":\"Isaac Aracena\",\"email\":\"iaracena@gomezleemarketing.com\"},{\"name\":\"Mati Soto\",\"email\":\"msoto@gomezleemarketing.com\"},{\"name\":\"Iveth Herrera\",\"email\":\"iherrera@gomezleemarketing.com\"},{\"name\":\"Máximo Gomez\",\"email\":\"mgomez@gomezleemarketing.com\"},{\"name\":\"Viviana Paramo\",\"email\":\"vparamo@gomezleemarketing.com\"},{\"name\":\"Ada Rodriguez\",\"email\":\"asrodriguez@gomezleemarketing.com\"}];\nconst norm=value=>String(value??'').trim().toLowerCase();\nconst file=$('Subir reporte a Google Drive').first().json||{};\nconst owners=Array.isArray(file.owners)?file.owners:[];\nconst permissions=Array.isArray(file.permissions)?file.permissions:[];\nconst editable=new Set(['owner','writer','organizer','fileOrganizer']);\nconst hasAccess=recipient=>{\n const email=norm(recipient.email);\n return owners.some(o=>!o?.deleted&&norm(o?.emailAddress)===email)||\n permissions.some(p=>!p?.deleted&&norm(p?.emailAddress)===email&&editable.has(String(p?.role||'')));\n};\nconst recipientsToShare=recipients.filter(r=>!hasAccess(r));\nreturn {recipients,recipientsToShare,pendingAccessCount:recipientsToShare.length,existingAccessCount:recipients.length-recipientsToShare.length};\n})() }}", + "jsonOutput": "={{ (() => {\nconst recipients=[{\"name\":\"Isaac Aracena\",\"email\":\"iaracena@gomezleemarketing.com\"},{\"name\":\"Mati Soto\",\"email\":\"msoto@gomezleemarketing.com\"},{\"name\":\"Iveth Herrera\",\"email\":\"iherrera@gomezleemarketing.com\"},{\"name\":\"Máximo Gomez\",\"email\":\"mgomez@gomezleemarketing.com\"},{\"name\":\"Viviana Paramo\",\"email\":\"vparamo@gomezleemarketing.com\"},{\"name\":\"Ada Rodriguez\",\"email\":\"asrodriguez@gomezleemarketing.com\"},{\"name\":\"Yanelly Madera\",\"email\":\"ymadera@gomezleemarketing.com\"}];\nconst norm=value=>String(value??'').trim().toLowerCase();\nconst file=$('Subir reporte a Google Drive').first().json||{};\nconst owners=Array.isArray(file.owners)?file.owners:[];\nconst permissions=Array.isArray(file.permissions)?file.permissions:[];\nconst editable=new Set(['owner','writer','organizer','fileOrganizer']);\nconst hasAccess=recipient=>{\n const email=norm(recipient.email);\n return owners.some(o=>!o?.deleted&&norm(o?.emailAddress)===email)||\n permissions.some(p=>!p?.deleted&&norm(p?.emailAddress)===email&&editable.has(String(p?.role||'')));\n};\nconst recipientsToShare=recipients.filter(r=>!hasAccess(r));\nreturn {recipients,recipientsToShare,pendingAccessCount:recipientsToShare.length,existingAccessCount:recipients.length-recipientsToShare.length};\n})() }}", "options": {} }, - "id": "1729e8fc-105c-411a-bf80-1be1dfb14614", + "id": "a1314c39-c438-420f-a7aa-7ae86118f205", "name": "Preparar lista de destinatarios", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [ - 3712, - 560 + 1760, + 2624 ], "onError": "continueErrorOutput" }, @@ -357,13 +357,13 @@ }, "options": {} }, - "id": "9743bb45-f09b-454e-b787-73ee450b69b3", + "id": "f979be33-7c07-46a3-be77-7dcd0e0e4710", "name": "¿Hay accesos pendientes?", "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ - 3952, - 560 + 2000, + 2624 ], "onError": "continueErrorOutput" }, @@ -374,13 +374,13 @@ "destinationFieldName": "recipient" } }, - "id": "3d74bea6-b858-4ae2-b1ed-8d46ece27ea5", + "id": "772c8da4-7d5b-4a0f-b9c8-be9b8573e2e0", "name": "Separar destinatarios", "type": "n8n-nodes-base.splitOut", "typeVersion": 1, "position": [ - 4192, - 480 + 2240, + 2544 ], "onError": "continueErrorOutput" }, @@ -397,13 +397,13 @@ "timeout": 300000 } }, - "id": "94d1d384-81f3-412e-907c-c888d92cef81", + "id": "87beafcc-62d5-4006-93f1-234fe3f38190", "name": "Crear permiso de editor por API", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ - 4432, - 480 + 2480, + 2544 ], "retryOnFail": true, "credentials": { @@ -420,13 +420,13 @@ "destinationFieldName": "permisosCreados", "options": {} }, - "id": "b114e1f6-fc06-49fb-ac36-52c2e0893428", + "id": "ec561a34-2b2e-47ad-86c7-1562995a7e01", "name": "Agrupar permisos creados", "type": "n8n-nodes-base.aggregate", "typeVersion": 1, "position": [ - 4672, - 480 + 2720, + 2544 ], "onError": "continueErrorOutput" }, @@ -455,13 +455,13 @@ }, "options": {} }, - "id": "d1679655-fc24-4f4c-b875-68c8bc002c24", + "id": "5d5ce711-4832-4ec3-be29-fcbbe0ec60df", "name": "¿Se crearon todos los permisos?", "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ - 4928, - 480 + 2976, + 2544 ], "onError": "continueErrorOutput" }, @@ -492,13 +492,13 @@ "timeout": 300000 } }, - "id": "480bcf25-fce8-4c7e-821b-608a879277d8", + "id": "baeeb2bc-4a96-4060-9b8b-e57b44c4b2ee", "name": "Verificar accesos en Google Drive", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ - 5168, - 400 + 3216, + 2464 ], "executeOnce": true, "retryOnFail": true, @@ -535,13 +535,13 @@ }, "options": {} }, - "id": "45086ef1-fb80-4c04-8860-681fbe2a1bd4", + "id": "bb1b8118-f9a7-419a-9e00-870ebe710007", "name": "¿Están confirmados los siete accesos?", "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ - 5424, - 400 + 3472, + 2464 ], "onError": "continueErrorOutput" }, @@ -551,13 +551,13 @@ "jsonOutput": "={{ ({ accesosConfirmados: $('Preparar lista de destinatarios').first().json.recipients.length, permisosNuevos: $('Preparar lista de destinatarios').first().json.pendingAccessCount, accesosPrevios: $('Preparar lista de destinatarios').first().json.existingAccessCount, verificacionDrive: true }) }}", "options": {} }, - "id": "e6dafe32-01f9-44d8-8130-25152709d028", + "id": "20c03975-8e24-4e85-b098-4d441462aa7f", "name": "Confirmar acceso de siete personas", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [ - 5424, - 752 + 3472, + 2816 ], "executeOnce": true, "onError": "continueErrorOutput" @@ -570,13 +570,13 @@ "includeUnpaired": false } }, - "id": "5e783156-45dc-41e1-a78a-ce90fa8d87a9", + "id": "c6bf6d2a-38eb-4bd7-ad8e-1e61a0ba914a", "name": "Combinar reporte y acceso", "type": "n8n-nodes-base.merge", "typeVersion": 3.2, "position": [ - 5184, - 816 + 3232, + 2880 ] }, { @@ -596,13 +596,13 @@ "senderName": "GomezLee Marketing" } }, - "id": "66c9e023-50e9-4c8b-b003-2d8c4780e7c3", + "id": "8e033ad0-924c-4490-89a5-f8ff930179a0", "name": "Enviar reporte Excel GLM", "type": "n8n-nodes-base.gmail", "typeVersion": 2.2, "position": [ - 5680, - 864 + 3728, + 2928 ], "executeOnce": true, "retryOnFail": true, @@ -621,13 +621,13 @@ "respondWith": "showText", "responseText": "={{ (function successExpressionRuntime() {\n const s = $('Combinar reporte y acceso').item.json || {};\n const report = $('Subir reporte a Google Drive').item.json || {};\n const esc = value => String(value ?? '').replace(/[&<>\"']/g, character => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' })[character]);\n const total = Number(s.hallazgosTotales || 0);\n const status = total\n ? 'El cruce terminó y hay ' + total + ' hallazgo(s) para revisar.'\n : 'El cruce terminó sin diferencias.';\n const reportUrl = report.webViewLink || ('https://drive.google.com/open?id=' + (report.id || ''));\n return '
' +\n '
SEGURIDAD SOCIAL · EL SALVADOR
' +\n '

Proceso completado

' +\n '

' + esc(s.periodo) + '

' +\n '

' + esc(status) +\n ' El Excel fue enviado a las siete personas autorizadas y quedó disponible en Google Drive con acceso de edición para cada una.

' +\n 'Abrir reporte' +\n 'Realizar otro cruce' +\n '
';\n})() }}" }, - "id": "6a51a26e-7c88-451c-b966-6f6dec925f7a", + "id": "47863bc3-cc73-47fa-8208-22bc9349feac", "name": "Mostrar resultado y repetir", "type": "n8n-nodes-base.form", "typeVersion": 2.5, "position": [ - 6016, - 768 + 4064, + 2832 ], "webhookId": "2bbb060d-09d8-40c1-bcfa-6fb7c8c3d3da" }, @@ -637,13 +637,13 @@ "respondWith": "showText", "responseText": "={{ (() => {\nconst source=$json?.error?.message??$json?.error??$json?.message??'Error no identificado';\nlet detail; try{detail=typeof source==='string'?source:JSON.stringify(source);}catch{detail=String(source);}\ndetail=String(detail||'Error no identificado').replace(/\\s+/g,' ').trim().slice(0,300);\nconst esc=value=>String(value??'').replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''})[c]);\nconst isDrive=/forbidden|permission|permiso|share|compart/i.test(detail);\nconst guidance=isDrive?'Google Drive no pudo confirmar uno de los accesos de edición. Verifica que la cuenta conectada pueda compartir el archivo con el destinatario indicado y vuelve a intentarlo.':'Revisa el detalle mostrado abajo y vuelve a intentarlo. Si el problema persiste, consulta esta ejecución en n8n.';\nreturn '
SEGURIDAD SOCIAL · EL SALVADOR

No se pudo completar el cruce

'+esc(guidance)+'

'+esc(detail)+'
Volver al formulario
';\n})() }}" }, - "id": "30d59ab7-e53a-4533-92dd-c9f4786cb37e", + "id": "3702f1d1-8392-46e1-b22c-ec4e150ce4e0", "name": "Mostrar error y reintentar", "type": "n8n-nodes-base.form", "typeVersion": 2.5, "position": [ - 5584, - 1248 + 3632, + 3312 ], "webhookId": "b8aa8b32-d69c-45a5-aa33-dfe9040bb2f1" }, @@ -653,13 +653,13 @@ "jsonOutput": "={{ (() => { const norm=value=>String(value??'').trim().toLowerCase(); const recipients=$('Preparar lista de destinatarios').first().json.recipients||[]; const permissions=Array.isArray($json.permissions)?$json.permissions:[]; const editable=new Set(['owner','writer','organizer','fileOrganizer']); const missing=recipients.filter(recipient=>!permissions.some(permission=>!permission?.deleted&&norm(permission?.emailAddress)===norm(recipient.email)&&editable.has(String(permission?.role||'')))); return { error: 'Google Drive respondió, pero no confirmó acceso de edición para: '+missing.map(person=>person.name+' ('+person.email+')').join(', ')+'. No se envió el correo.' }; })() }}", "options": {} }, - "id": "a8f07990-7948-4037-8ece-16fc38f78991", + "id": "4ed09a3f-edaa-4ade-be4d-d14c96d0603f", "name": "Preparar accesos faltantes", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [ - 5680, - 528 + 3728, + 2592 ], "executeOnce": true, "onError": "continueErrorOutput" @@ -670,13 +670,13 @@ "jsonOutput": "={{ ({ error: 'Google Drive no confirmó la creación de todos los permisos de edición requeridos. No se envió el correo para evitar una entrega incompleta.' }) }}", "options": {} }, - "id": "a135fd1f-b61f-4e45-be6c-03021b7d3378", + "id": "3838e0be-1806-4323-9454-80347f7e49ac", "name": "Preparar error de permisos", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [ - 5168, - 576 + 3216, + 2640 ], "executeOnce": true, "onError": "continueErrorOutput" @@ -688,13 +688,13 @@ "width": 1564, "color": 4 }, - "id": "fc9bcda7-c796-4f9e-aa23-426e11737463", + "id": "7052bd0d-7c7e-41b5-8a3e-850958e281d3", "name": "Guía · Formulario", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ - 496, - 432 + -1472, + 2496 ] }, { @@ -704,13 +704,13 @@ "width": 812, "color": 5 }, - "id": "380a2c79-217e-4de4-b5dd-14f2c1a7a259", + "id": "645a9be9-dd8c-4703-9678-5355d5178cf2", "name": "Guía · Cruces y BambooHR", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ - 2160, - 672 + 208, + 2736 ] }, { @@ -720,13 +720,13 @@ "width": 3132, "color": 3 }, - "id": "6d234122-5e43-4473-a11c-eedcb76e31eb", + "id": "7b7ebb08-f204-4b5f-843e-f74b4321b945", "name": "Guía · Reporte y entrega", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ - 3104, - 288 + 1152, + 2352 ] }, { @@ -736,13 +736,13 @@ "width": 420, "color": 2 }, - "id": "dedc732b-cda5-4d60-81fc-bfd660445a71", + "id": "f21b71ea-4f59-447f-aff8-053081d0ab56", "name": "Guía · Manejo de errores", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ - 5424, - 1088 + 3472, + 3152 ] }, { @@ -757,13 +757,13 @@ "sheetName": "={{ ['ENERO','FEBRERO','MARZO','ABRIL','MAYO','JUNIO','JULIO','AGOSTO','SEPTIEMBRE','OCTUBRE','NOVIEMBRE','DICIEMBRE'][Number($items('Formulario de cruce')[0].json.mes)-1] }}" } }, - "id": "1f75b85f-a535-41fc-a09c-f4975d2b8311", + "id": "7c10293b-da0a-4d8e-abf9-f0bb69feb89f", "name": "Extraer Nómina · hoja del mes", "type": "n8n-nodes-base.extractFromFile", "typeVersion": 1.1, "position": [ - 1136, - 720 + -832, + 2784 ], "onError": "continueErrorOutput" }, @@ -779,13 +779,13 @@ "sheetName": "Nómina" } }, - "id": "a86eaafa-db0f-46f9-97df-c59f2a707478", + "id": "576589f4-9c78-4e65-a880-256df59a985f", "name": "Extraer Nómina · hoja Nómina", "type": "n8n-nodes-base.extractFromFile", "typeVersion": 1.1, "position": [ - 1168, - 944 + -800, + 3008 ], "onError": "continueErrorOutput" }, @@ -793,13 +793,13 @@ "parameters": { "jsCode": "const rows = $input.all();\nif (!rows.length) {\n throw new Error(\n 'No se encontraron registros en la hoja de nómina seleccionada.'\n );\n}\n\nfunction normalize(value) {\n return String(value ?? '')\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toUpperCase()\n .replace(/[^A-Z0-9]+/g, ' ')\n .trim();\n}\n\nconst headers = new Set();\nfor (const item of rows.slice(0, 25)) {\n for (const key of Object.keys(item.json || {})) {\n headers.add(normalize(key));\n }\n}\n\nfunction hasAny(patterns) {\n return [...headers].some(header =>\n patterns.some(pattern => pattern.test(header))\n );\n}\n\nconst hasDui = hasAny([/^DUI$/, /DOCUMENTO.*AFILIADO/]);\nconst hasName = hasAny([\n /^COLABORADOR$/,\n /^EMPLEADO$/,\n /^NOMBRE$/,\n /NOMBRE.*COLABORADOR/\n]);\nconst hasAmount = hasAny([\n /SUB TOTAL PERCIBIDO/,\n /TOTAL PERCIBIDO/,\n /SALARIO PERCIBIDO/\n]);\n\nif (!hasDui || !hasName || !hasAmount) {\n throw new Error(\n 'La hoja encontrada no parece ser la nómina de El Salvador: ' +\n 'debe contener DUI, nombre del colaborador y total percibido.'\n );\n}\n\nreturn rows;" }, - "id": "d018c32f-57a6-4f13-9a01-4a7be1c5ab5d", + "id": "9612b22d-6420-4c3d-ac90-78588f4c850d", "name": "Extraer Nómina", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 1344, - 656 + -624, + 2720 ], "onError": "continueErrorOutput" }, @@ -807,13 +807,13 @@ "parameters": { "jsCode": "const source = $items('Preparar archivo Nómina')[0];\nif (!source?.binary?.data) {\n throw new Error('No fue posible recuperar el archivo binario original de la nómina para continuar con la detección automática de hojas.');\n}\nreturn [{\n json: {\n ...(source.json || {}),\n intentoHoja: 'hoja del mes seleccionada'\n },\n binary: {\n data: source.binary.data\n }\n}];" }, - "id": "f1a0d4b2-2820-45a5-8238-fa0a34c4c784", + "id": "de2a4370-6700-4769-99cd-6c649e484ab0", "name": "Restaurar binario · intento hoja del mes", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 944, - 736 + -1024, + 2800 ], "onError": "continueErrorOutput" }, @@ -821,13 +821,13 @@ "parameters": { "jsCode": "const source = $items('Preparar archivo Nómina')[0];\nif (!source?.binary?.data) {\n throw new Error('No fue posible recuperar el archivo binario original de la nómina para continuar con la detección automática de hojas.');\n}\nreturn [{\n json: {\n ...(source.json || {}),\n intentoHoja: 'hoja Nómina con tilde'\n },\n binary: {\n data: source.binary.data\n }\n}];" }, - "id": "84004e83-b649-4871-b83d-9b0f490541ed", + "id": "f8d21eaa-0be8-4294-b4f4-3a1c5c12ad5b", "name": "Restaurar binario · intento hoja Nómina", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 960, - 960 + -1008, + 3024 ], "onError": "continueErrorOutput" } @@ -1364,9 +1364,9 @@ "aiBuilderAssisted": true, "builderVariant": "mcp" }, - "versionId": "0ceb0ca3-f520-4202-ab01-c299878c6ce9", - "activeVersionId": "0ceb0ca3-f520-4202-ab01-c299878c6ce9", - "versionCounter": 144, + "versionId": "ff877274-6391-43ce-a1f6-85ff5e47f25a", + "activeVersionId": "ff877274-6391-43ce-a1f6-85ff5e47f25a", + "versionCounter": 147, "triggerCount": 1, "shared": [ { @@ -1389,9 +1389,9 @@ ], "tags": [], "activeVersion": { - "updatedAt": "2026-08-07T13:18:09.000Z", - "createdAt": "2026-08-07T13:18:07.288Z", - "versionId": "0ceb0ca3-f520-4202-ab01-c299878c6ce9", + "updatedAt": "2026-08-20T12:23:27.000Z", + "createdAt": "2026-08-20T12:23:25.854Z", + "versionId": "ff877274-6391-43ce-a1f6-85ff5e47f25a", "workflowId": "wCud4hOciMao0Ab3", "nodes": [ { @@ -1476,33 +1476,33 @@ "options": { "appendAttribution": false, "buttonLabel": "Procesar cruce", - "path": "ef422914-fb5b-4f33-8283-bc2d2ea9abfc", + "path": "07be2ab9-359b-43b1-ad55-2da000e92850", "ignoreBots": true, "useWorkflowTimezone": true, "customCss": ":root{--font-family:Arial,Helvetica,sans-serif;--color-primary:#6CC24A;--color-primary-shade-1:#5AAF3E;--color-secondary:#4F758B;--color-card-background:#FFFFFF;--color-card-border:#D0D0D0;--border-radius-card:14px;--border-radius-input:8px;}\nbody{background:linear-gradient(180deg,#EEF6E8 0,#F4F7F9 240px);font-family:Arial,Helvetica,sans-serif;color:#4A4A4A;}\nform{box-shadow:0 18px 45px rgba(79,117,139,.13);}\nh1{color:#4F758B!important;letter-spacing:-.3px;}\nbutton[type=submit]{font-weight:700;letter-spacing:.2px;box-shadow:none;}\ninput:focus,select:focus{border-color:#6CC24A!important;box-shadow:0 0 0 3px rgba(108,194,74,.14)!important;}" } }, - "id": "562937d1-e0f5-4f04-a534-e6155728fe28", + "id": "478b7167-dde1-4fba-b11f-42caa0f70b6a", "name": "Formulario de cruce", "type": "n8n-nodes-base.formTrigger", "typeVersion": 2.5, "position": [ - 576, - 768 + -1392, + 2832 ], - "webhookId": "ef422914-fb5b-4f33-8283-bc2d2ea9abfc" + "webhookId": "07be2ab9-359b-43b1-ad55-2da000e92850" }, { "parameters": { - "jsCode": "const formItem = $items('Formulario de cruce')[0] || {};\n const recipient = 'iaracena@gomezleemarketing.com, msoto@gomezleemarketing.com, iherrera@gomezleemarketing.com, mgomez@gomezleemarketing.com, vparamo@gomezleemarketing.com, asrodriguez@gomezleemarketing.com, jgomez@gomezleemarketing.com';\n const binaries = formItem.binary || {};\n const keys = Object.keys(binaries);\n function normalize(value) {\n return String(value || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase();\n }\n function score(key) {\n const file = binaries[key] || {};\n const text = normalize(key + ' ' + (file.fileName || ''));\n let value = 0;\n if (key === 'nomina') value += 100;\n if (text.includes('nomina')) value += 30;\n if (text.includes('payroll')) value += 15;\n if (text.includes('planilla')) value -= 50;\n return value;\n }\n const selectedKey = keys.slice().sort((a, b) => score(b) - score(a))[0];\n if (!selectedKey || score(selectedKey) <= 0) {\n throw new Error('No se encontró el archivo de nómina. Adjunta un .xlsx o .xlsm en el campo Nómina.');\n }\n const file = binaries[selectedKey];\n if (!/\\.(xlsx|xlsm)$/i.test(String(file.fileName || ''))) {\n throw new Error('La nómina debe estar en formato .xlsx o .xlsm.');\n }\n return [{\n json: {\n tipoArchivo: 'nomina',\n archivoNomina: file.fileName || 'nomina.xlsx',\n correoDestino: recipient,\n mes: formItem.json?.mes || '',\n anio: formItem.json?.anio || ''\n },\n binary: { data: file }\n }];" + "jsCode": "const formItem = $items('Formulario de cruce')[0] || {};\n const recipient = 'iaracena@gomezleemarketing.com, msoto@gomezleemarketing.com, iherrera@gomezleemarketing.com, mgomez@gomezleemarketing.com, vparamo@gomezleemarketing.com, asrodriguez@gomezleemarketing.com, jgomez@gomezleemarketing.com, ymadera@gomezleemarketing.com';\n const binaries = formItem.binary || {};\n const keys = Object.keys(binaries);\n function normalize(value) {\n return String(value || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase();\n }\n function score(key) {\n const file = binaries[key] || {};\n const text = normalize(key + ' ' + (file.fileName || ''));\n let value = 0;\n if (key === 'nomina') value += 100;\n if (text.includes('nomina')) value += 30;\n if (text.includes('payroll')) value += 15;\n if (text.includes('planilla')) value -= 50;\n return value;\n }\n const selectedKey = keys.slice().sort((a, b) => score(b) - score(a))[0];\n if (!selectedKey || score(selectedKey) <= 0) {\n throw new Error('No se encontró el archivo de nómina. Adjunta un .xlsx o .xlsm en el campo Nómina.');\n }\n const file = binaries[selectedKey];\n if (!/\\.(xlsx|xlsm)$/i.test(String(file.fileName || ''))) {\n throw new Error('La nómina debe estar en formato .xlsx o .xlsm.');\n }\n return [{\n json: {\n tipoArchivo: 'nomina',\n archivoNomina: file.fileName || 'nomina.xlsx',\n correoDestino: recipient,\n mes: formItem.json?.mes || '',\n anio: formItem.json?.anio || ''\n },\n binary: { data: file }\n }];" }, - "id": "a165b03b-eb8e-45c1-8694-0450a28ff20e", + "id": "21d518ca-65a3-4e90-93da-61eb1199cdef", "name": "Preparar archivo Nómina", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 752, - 624 + -1216, + 2688 ], "onError": "continueErrorOutput" }, @@ -1518,13 +1518,13 @@ "sheetName": "Nomina" } }, - "id": "27f85e3c-bac3-4ad0-adf7-ed6468f13b1a", + "id": "723c4de2-6227-4b21-9949-cd1da107f868", "name": "Extraer Nómina · hoja Nomina", "type": "n8n-nodes-base.extractFromFile", "typeVersion": 1.1, "position": [ - 1088, - 512 + -880, + 2576 ], "onError": "continueErrorOutput" }, @@ -1532,13 +1532,13 @@ "parameters": { "jsCode": "const formItem = $items('Formulario de cruce')[0] || {};\n const binaries = formItem.binary || {};\n const keys = Object.keys(binaries);\n function normalize(value) {\n return String(value || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase();\n }\n function score(key) {\n const file = binaries[key] || {};\n const text = normalize(key + ' ' + (file.fileName || ''));\n let value = 0;\n if (key === 'planilla_seguridad_social') value += 100;\n if (text.includes('planilla')) value += 35;\n if (text.includes('seguridad')) value += 20;\n if (text.includes('nomina')) value -= 50;\n return value;\n }\n const selectedKey = keys.slice().sort((a, b) => score(b) - score(a))[0];\n if (!selectedKey || score(selectedKey) <= 0) {\n throw new Error('No se encontró la planilla de Seguridad Social. Adjunta un .xlsx o .xlsm en el campo correspondiente.');\n }\n const file = binaries[selectedKey];\n if (!/\\.(xlsx|xlsm)$/i.test(String(file.fileName || ''))) {\n throw new Error('La planilla de Seguridad Social debe estar en formato .xlsx o .xlsm.');\n }\n return [{\n json: {\n tipoArchivo: 'planilla',\n archivoPlanilla: file.fileName || 'planilla.xlsm'\n },\n binary: { data: file }\n }];" }, - "id": "9e9f6a87-232a-4f0a-9352-aacd1e8b379a", + "id": "0f3dcb1c-31c2-4066-9fb2-096871529406", "name": "Preparar archivo Planilla", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 1600, - 656 + -368, + 2720 ], "onError": "continueErrorOutput" }, @@ -1554,13 +1554,13 @@ "sheetName": "PLANILLA_UNICA" } }, - "id": "c6b2e409-97da-4abf-b525-c9198bf48579", + "id": "deb63adf-9f0e-4c3e-b2e5-6af866b39b24", "name": "Extraer Planilla", "type": "n8n-nodes-base.extractFromFile", "typeVersion": 1.1, "position": [ - 1856, - 656 + -112, + 2720 ], "onError": "continueErrorOutput" }, @@ -1568,13 +1568,13 @@ "parameters": { "jsCode": "const payrollRows = $items('Extraer Nómina').length;\n const planRows = $items('Extraer Planilla').length;\n if (!payrollRows) {\n throw new Error('No se pudo leer una hoja de nómina válida con registros legibles.');\n }\n if (!planRows) {\n throw new Error('La hoja \"PLANILLA_UNICA\" no contiene registros legibles desde la fila 4.');\n }\n return [{ json: { payrollRows, planRows, listoParaBamboo: true } }];" }, - "id": "ceabea56-3cb4-4093-8ea4-0f847d44aafe", + "id": "693f4c9a-49d0-48fc-bb4d-ee9530d69388", "name": "Validar lecturas", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 2208, - 800 + 256, + 2864 ], "onError": "continueErrorOutput" }, @@ -1621,13 +1621,13 @@ "timeout": 300000 } }, - "id": "8e202b5d-0f0b-4228-acc7-43f06c0d79e0", + "id": "79a139c6-5b1a-43e6-a588-f1b52f41e231", "name": "Obtener reporte completo BambooHR", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ - 2464, - 800 + 512, + 2864 ], "executeOnce": true, "retryOnFail": true, @@ -1641,15 +1641,15 @@ }, { "parameters": { - "jsCode": "const payrollSource = $items('Extraer Nómina').map(item => item.json || {});\n const planSource = $items('Extraer Planilla').map(item => item.json || {});\n const formItem = $items('Formulario de cruce')[0] || {};\n const form = formItem.json || {};\n const bambooInput = $input.all();\n\n function clean(value) {\n return String(value ?? '').replace(/\\s+/g, ' ').trim();\n }\n function stripAccents(value) {\n return String(value || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '');\n }\n function normalizeHeader(value) {\n return stripAccents(clean(value)).toUpperCase().replace(/[^A-Z0-9]+/g, ' ').trim();\n }\n function normalizeName(value) {\n return stripAccents(clean(value)).toUpperCase().replace(/[^A-Z0-9 ]+/g, ' ').replace(/\\s+/g, ' ').trim();\n }\n const STOP = new Set(['DE', 'DEL', 'LA', 'LAS', 'LOS', 'Y', 'DA', 'DAS', 'DO', 'VDA']);\n function nameTokens(value) {\n return normalizeName(value).split(' ').filter(token => token.length > 1 && !STOP.has(token));\n }\n function canonicalName(value) {\n return nameTokens(value).slice().sort().join(' ');\n }\n function nameScore(a, b) {\n const A = [...new Set(nameTokens(a))];\n const B = [...new Set(nameTokens(b))];\n if (!A.length || !B.length) return { score: 0, shared: 0 };\n if (A.slice().sort().join(' ') === B.slice().sort().join(' ')) return { score: 1, shared: A.length };\n const setB = new Set(B);\n const shared = A.filter(token => setB.has(token)).length;\n if (shared < 2) return { score: 0, shared };\n const coverage = shared / Math.min(A.length, B.length);\n const dice = (2 * shared) / (A.length + B.length);\n return { score: 0.7 * coverage + 0.3 * dice, shared };\n }\n function parseNumber(value) {\n if (typeof value === 'number') return Number.isFinite(value) ? value : 0;\n let text = clean(value).replace(/[^\\d,.\\-]/g, '');\n if (!text) return 0;\n const comma = text.lastIndexOf(',');\n const dot = text.lastIndexOf('.');\n if (comma > dot) text = text.replace(/\\./g, '').replace(',', '.');\n else text = text.replace(/,/g, '');\n const number = Number(text);\n return Number.isFinite(number) ? number : 0;\n }\n function valueAt(row, aliases, fallbackIndex) {\n const entries = Object.entries(row || {});\n const wanted = aliases.map(normalizeHeader);\n for (const [key, value] of entries) {\n const normalized = normalizeHeader(key);\n if (wanted.includes(normalized)) return value;\n }\n return entries[fallbackIndex]?.[1];\n }\n function normalizeDui(value) {\n let digits = clean(value).replace(/\\D/g, '');\n if (digits.length === 8) digits = digits.padStart(9, '0');\n if (digits.length !== 9) return '';\n return digits.slice(0, 8) + '-' + digits.slice(8);\n }\n function chooseName(values) {\n const names = [...new Set((values || []).map(clean).filter(Boolean))];\n names.sort((a, b) => nameTokens(b).length - nameTokens(a).length || b.length - a.length);\n return names[0] || '';\n }\n function round2(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n }\n function uniqueJoin(values) {\n return [...new Set((values || []).map(clean).filter(Boolean))].join(' | ');\n }\n function isTotalName(value) {\n return /^TOTAL(?:\\s|$)/i.test(clean(value));\n }\n\n const monthNumber = String(form.mes || '').padStart(2, '0');\n const yearNumber = Number(form.anio);\n if (!/^(0[1-9]|1[0-2])$/.test(monthNumber)) {\n throw new Error('Selecciona un mes válido entre 01 y 12.');\n }\n if (!Number.isInteger(yearNumber) || yearNumber < 2020 || yearNumber > 2100) {\n throw new Error('Ingresa un año válido.');\n }\n const monthNames = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];\n const periodLabel = monthNames[Number(monthNumber) - 1] + ' ' + yearNumber;\n const periodFile = yearNumber + '-' + monthNumber;\n\n const payrollRecords = [];\n for (let index = 0; index < payrollSource.length; index++) {\n const row = payrollSource[index];\n const rawDui = valueAt(row, ['DUI'], 3);\n const name = clean(valueAt(row, ['COLABORADOR', 'EMPLEADO', 'NOMBRE'], 4));\n const project = clean(valueAt(row, ['PROYECTO'], 2));\n const amount = parseNumber(valueAt(row, ['SUB TOTAL PERCIBIDO', 'TOTAL PERCIBIDO'], 15));\n const dui = normalizeDui(rawDui);\n if ((!name && !dui) || isTotalName(name)) continue;\n payrollRecords.push({\n sourceIndex: index + 2,\n dui,\n rawDui: clean(rawDui),\n name,\n project,\n amount\n });\n }\n\n const planRecords = [];\n for (let index = 0; index < planSource.length; index++) {\n const row = planSource[index];\n const rawDui = valueAt(row, ['NUMERO DE DOCUMENTO AFILIADO', 'DUI'], 4);\n const nameParts = [\n valueAt(row, ['PRIMER NOMBRE'], 8),\n valueAt(row, ['SEGUNDO NOMBRE'], 9),\n valueAt(row, ['PRIMER APELLIDO'], 10),\n valueAt(row, ['SEGUNDO APELLIDO'], 11),\n valueAt(row, ['APELLIDO DE CASADA'], 12)\n ].map(clean).filter(Boolean);\n const name = clean(nameParts.join(' '));\n const salary = parseNumber(valueAt(row, ['SALARIO'], 13));\n const additional = parseNumber(valueAt(row, ['PAGO ADICIONAL'], 14));\n const vacation = parseNumber(valueAt(row, ['MONTO DE VACACION', 'MONTO DE VACACIONES'], 15));\n const observations = uniqueJoin([\n valueAt(row, ['CODIGO DE OBSERVACION 1'], 19),\n valueAt(row, ['CODIGO DE OBSERVACION 2'], 20)\n ]);\n const dui = normalizeDui(rawDui);\n if ((!name && !dui) || isTotalName(name)) continue;\n planRecords.push({\n sourceIndex: index + 5,\n dui,\n rawDui: clean(rawDui),\n name,\n amount: salary + additional + vacation,\n salary,\n additional,\n vacation,\n observations\n });\n }\n\n if (!payrollRecords.length) {\n throw new Error('No se encontraron empleados en la hoja \"Nomina\". Se esperaba el encabezado en la fila 1 y datos desde la fila 2.');\n }\n if (!planRecords.length) {\n throw new Error('No se encontraron empleados en \"PLANILLA_UNICA\". Se esperaba el encabezado en la fila 4 y datos desde la fila 5.');\n }\n if (!payrollRecords.some(record => record.dui)) {\n throw new Error('No se pudo leer ningún DUI válido en la columna D de la nómina.');\n }\n if (!planRecords.some(record => record.dui)) {\n throw new Error('No se pudo leer ningún DUI válido en la columna E de la planilla.');\n }\n\n function aggregate(records, source) {\n const groups = new Map();\n for (const record of records) {\n if (!record.dui) continue;\n if (!groups.has(record.dui)) {\n groups.set(record.dui, {\n dui: record.dui,\n source,\n amount: 0,\n names: [],\n projects: [],\n observations: [],\n rows: []\n });\n }\n const group = groups.get(record.dui);\n group.amount += Number(record.amount) || 0;\n group.names.push(record.name);\n if (record.project) group.projects.push(record.project);\n if (record.observations) group.observations.push(record.observations);\n group.rows.push(record.sourceIndex);\n }\n for (const group of groups.values()) {\n group.name = chooseName(group.names);\n group.project = uniqueJoin(group.projects);\n group.observation = uniqueJoin(group.observations);\n group.nameVariants = [...new Set(group.names.map(normalizeName).filter(Boolean))];\n }\n return groups;\n }\n\n const payrollGroups = aggregate(payrollRecords, 'Nómina');\n const planGroups = aggregate(planRecords, 'Planilla');\n const payrollMissingDui = payrollRecords.filter(record => !record.dui);\n const planMissingDui = planRecords.filter(record => !record.dui);\n const findings = [];\n\n function addFinding(data) {\n findings.push({\n tipo: data.tipo || '',\n severidad: data.severidad || 'Media',\n duiNomina: data.duiNomina || '',\n duiPlanilla: data.duiPlanilla || '',\n nombreNomina: data.nombreNomina || '',\n nombrePlanilla: data.nombrePlanilla || '',\n montoNomina: data.montoNomina === '' || data.montoNomina == null ? '' : Number(data.montoNomina),\n montoPlanilla: data.montoPlanilla === '' || data.montoPlanilla == null ? '' : Number(data.montoPlanilla),\n diferencia: data.diferencia === '' || data.diferencia == null ? '' : Number(data.diferencia),\n accion: data.accion || ''\n });\n }\n\n for (const record of payrollMissingDui) {\n addFinding({\n tipo: 'DUI faltante o inválido en nómina',\n severidad: 'Alta',\n nombreNomina: record.name,\n montoNomina: record.amount,\n accion: 'Completar o corregir el DUI en la nómina antes de presentar la planilla.'\n });\n }\n for (const record of planMissingDui) {\n addFinding({\n tipo: 'DUI faltante o inválido en planilla',\n severidad: 'Alta',\n nombrePlanilla: record.name,\n montoPlanilla: record.amount,\n accion: 'Completar o corregir el DUI en la planilla de Seguridad Social.'\n });\n }\n\n for (const [dui, group] of payrollGroups) {\n if (group.nameVariants.length > 1) {\n addFinding({\n tipo: 'Un DUI tiene varios nombres en nómina',\n severidad: 'Alta',\n duiNomina: dui,\n nombreNomina: uniqueJoin(group.names),\n montoNomina: group.amount,\n accion: 'Validar que el DUI no esté asociado a más de una persona.'\n });\n }\n }\n for (const [dui, group] of planGroups) {\n if (group.nameVariants.length > 1) {\n addFinding({\n tipo: 'Un DUI tiene varios nombres en planilla',\n severidad: 'Alta',\n duiPlanilla: dui,\n nombrePlanilla: uniqueJoin(group.names),\n montoPlanilla: group.amount,\n accion: 'Validar las filas duplicadas y la identidad del afiliado.'\n });\n }\n }\n\n function flagSameNameMultipleDuis(groups, sourceLabel) {\n const byName = new Map();\n for (const group of groups.values()) {\n const key = canonicalName(group.name);\n if (!key) continue;\n if (!byName.has(key)) byName.set(key, []);\n byName.get(key).push(group);\n }\n for (const list of byName.values()) {\n const duis = [...new Set(list.map(group => group.dui))];\n if (duis.length <= 1) continue;\n addFinding({\n tipo: 'Misma persona con más de un DUI en ' + sourceLabel.toLowerCase(),\n severidad: 'Alta',\n duiNomina: sourceLabel === 'Nómina' ? duis.join(' | ') : '',\n duiPlanilla: sourceLabel === 'Planilla' ? duis.join(' | ') : '',\n nombreNomina: sourceLabel === 'Nómina' ? list[0].name : '',\n nombrePlanilla: sourceLabel === 'Planilla' ? list[0].name : '',\n montoNomina: sourceLabel === 'Nómina' ? list.reduce((sum, group) => sum + group.amount, 0) : '',\n montoPlanilla: sourceLabel === 'Planilla' ? list.reduce((sum, group) => sum + group.amount, 0) : '',\n accion: 'Confirmar el DUI correcto y unificar el registro.'\n });\n }\n }\n flagSameNameMultipleDuis(payrollGroups, 'Nómina');\n flagSameNameMultipleDuis(planGroups, 'Planilla');\n\n const commonDuis = [...payrollGroups.keys()].filter(dui => planGroups.has(dui));\n for (const dui of commonDuis) {\n const payroll = payrollGroups.get(dui);\n const plan = planGroups.get(dui);\n if (normalizeName(payroll.name) !== normalizeName(plan.name)) {\n addFinding({\n tipo: 'Nombre no idéntico con el mismo DUI',\n severidad: 'Media',\n duiNomina: dui,\n duiPlanilla: dui,\n nombreNomina: payroll.name,\n nombrePlanilla: plan.name,\n montoNomina: payroll.amount,\n montoPlanilla: plan.amount,\n accion: 'Verificar la escritura del nombre; el DUI coincide en ambas fuentes.'\n });\n }\n const delta = payroll.amount - plan.amount;\n if (Math.abs(delta) > 0.0000001) {\n const isSubcent = Math.abs(round2(payroll.amount) - round2(plan.amount)) <= 0.0000001;\n addFinding({\n tipo: isSubcent ? 'Diferencia subcentavo' : 'Diferencia de monto',\n severidad: isSubcent ? 'Informativa' : 'Alta',\n duiNomina: dui,\n duiPlanilla: dui,\n nombreNomina: payroll.name,\n nombrePlanilla: plan.name,\n montoNomina: payroll.amount,\n montoPlanilla: plan.amount,\n diferencia: delta,\n accion: isSubcent\n ? 'Los montos coinciden a dos decimales; conservar como control de precisión.'\n : 'Revisar salario, pago adicional, vacaciones, subsidios y acumulación de filas.'\n });\n }\n }\n\n const onlyPayroll = [...payrollGroups.values()].filter(group => !planGroups.has(group.dui));\n const onlyPlan = [...planGroups.values()].filter(group => !payrollGroups.has(group.dui));\n const usedPlan = new Set();\n\n function bestUnmatchedName(targetName, candidates, excludedIndexes) {\n const scored = candidates.map((candidate, index) => {\n const scoreData = nameScore(targetName, candidate.name);\n return { candidate, index, score: scoreData.score, shared: scoreData.shared };\n }).filter(result => !excludedIndexes.has(result.index) && result.score > 0)\n .sort((a, b) => b.score - a.score);\n const best = scored[0] || null;\n const second = scored[1] || null;\n if (!best) return { accepted: false, best: null };\n const margin = second ? best.score - second.score : best.score;\n const exact = canonicalName(targetName) === canonicalName(best.candidate.name);\n const accepted = exact || (best.shared >= 2 && best.score >= 0.88 && (margin >= 0.08 || best.score >= 0.98));\n return { accepted, best, second, margin };\n }\n\n for (const payroll of onlyPayroll) {\n const match = bestUnmatchedName(payroll.name, onlyPlan, usedPlan);\n if (match.accepted) {\n usedPlan.add(match.best.index);\n const plan = match.best.candidate;\n addFinding({\n tipo: 'Posible misma persona con DUI diferente',\n severidad: 'Alta',\n duiNomina: payroll.dui,\n duiPlanilla: plan.dui,\n nombreNomina: payroll.name,\n nombrePlanilla: plan.name,\n montoNomina: payroll.amount,\n montoPlanilla: plan.amount,\n accion: 'Confirmar la identidad y corregir el DUI en la fuente que corresponda.'\n });\n } else {\n addFinding({\n tipo: 'Persona en nómina que no está en planilla',\n severidad: 'Alta',\n duiNomina: payroll.dui,\n nombreNomina: payroll.name,\n montoNomina: payroll.amount,\n accion: 'Validar inclusión en la planilla de Seguridad Social.'\n });\n }\n }\n onlyPlan.forEach((plan, index) => {\n if (usedPlan.has(index)) return;\n addFinding({\n tipo: 'Persona en planilla que no está en nómina',\n severidad: 'Alta',\n duiPlanilla: plan.dui,\n nombrePlanilla: plan.name,\n montoPlanilla: plan.amount,\n accion: 'Validar si corresponde excluirla o incorporarla a la nómina.'\n });\n });\n\n function collectBambooPayload(payload, rows, totals) {\n if (payload == null) return;\n if (Array.isArray(payload)) {\n for (const value of payload) collectBambooPayload(value, rows, totals);\n return;\n }\n if (typeof payload !== 'object') return;\n if (Number(payload?.meta?.total) > 0) totals.push(Number(payload.meta.total));\n if (payload.body && typeof payload.body === 'object') {\n collectBambooPayload(payload.body, rows, totals);\n return;\n }\n if (Array.isArray(payload.data)) {\n rows.push(...payload.data);\n return;\n }\n if (Array.isArray(payload.employees)) {\n rows.push(...payload.employees);\n return;\n }\n if (payload.fullName5 || payload.displayName || payload.firstName || payload.lastName) rows.push(payload);\n }\n\n const bambooRaw = [];\n const bambooTotals = [];\n for (const item of bambooInput) collectBambooPayload(item.json, bambooRaw, bambooTotals);\n\n function uniqueNames(values) {\n return [...new Set(values.map(clean).filter(Boolean))];\n }\n\n function normalizeBambooDui(value) {\n const digits = clean(value).replace(/\\D/g, '');\n return digits.length === 9 ? digits.slice(0, 8) + '-' + digits.slice(8) : '';\n }\n\n function isElSalvadorEmployee(row) {\n return /EL\\s*SALVADOR|SALVADOR/i.test([\n row.location,\n row.country,\n row.division,\n row.department\n ].map(clean).join(' '));\n }\n\n const bambooUnique = new Map();\n for (let index = 0; index < bambooRaw.length; index++) {\n const row = bambooRaw[index] || {};\n const constructedName = [row.firstName, row.middleName, row.lastName].map(clean).filter(Boolean).join(' ');\n const aliases = uniqueNames([\n row.fullName5,\n row.fullName1,\n row.fullName2,\n row.fullName3,\n row.fullName4,\n constructedName,\n [row.firstName, row.lastName].map(clean).filter(Boolean).join(' '),\n [row.preferredName, row.lastName].map(clean).filter(Boolean).join(' '),\n row.displayName\n ]);\n const name = clean(row.displayName || row.fullName5 || row.fullName1 || constructedName);\n if (!name || !aliases.length) continue;\n const identity = clean(row.id || row.employeeId);\n const fallbackIdentity = clean(row.employeeNumber) + '|' + canonicalName(name) + '|' + clean(row.hireDate || row.originalHireDate);\n const key = identity || fallbackIdentity || 'fila-' + index;\n if (!bambooUnique.has(key)) {\n bambooUnique.set(key, {\n id: identity || key,\n employeeId: clean(row.employeeId || row.id),\n employeeNumber: clean(row.employeeNumber),\n dui: normalizeBambooDui(row.employeeNumber),\n name,\n aliases,\n canonicalAliases: [...new Set(aliases.map(canonicalName).filter(Boolean))],\n status: clean(row.status || row.employmentStatus || row.employmentHistoryStatus),\n hireDate: clean(row.hireDate || row.originalHireDate).slice(0, 10),\n terminationDate: clean(row.terminationDate).slice(0, 10),\n location: clean(row.location),\n country: clean(row.country),\n division: clean(row.division),\n department: clean(row.department),\n email: clean(row.workEmail),\n isElSalvador: isElSalvadorEmployee(row)\n });\n }\n }\n\n const bambooEmployees = [...bambooUnique.values()];\n const reportedTotal = bambooTotals.length ? Math.max(...bambooTotals) : bambooEmployees.length;\n if (!bambooEmployees.length) {\n throw new Error('BambooHR no devolvió empleados legibles. Revisa la credencial y los permisos del reporte completo.');\n }\n if (reportedTotal > bambooEmployees.length) {\n throw new Error('BambooHR devolvió ' + bambooEmployees.length + ' empleados únicos de ' + reportedTotal + '. La consulta quedó incompleta.');\n }\n\n function phoneticToken(value) {\n return normalizeName(value)\n .replace(/LL/g, 'Y')\n .replace(/QU/g, 'K')\n .replace(/PH/g, 'F')\n .replace(/C(?=[EI])/g, 'S')\n .replace(/G(?=[EI])/g, 'J')\n .replace(/Z/g, 'S')\n .replace(/V/g, 'B')\n .replace(/H/g, '')\n .replace(/Q/g, 'K')\n .replace(/Y/g, 'I')\n .replace(/(.)\\1+/g, '$1');\n }\n\n function damerauLevenshtein(left, right) {\n const a = String(left || '');\n const b = String(right || '');\n const rows = a.length + 1;\n const columns = b.length + 1;\n const matrix = Array.from({ length: rows }, () => Array(columns).fill(0));\n for (let row = 0; row < rows; row++) matrix[row][0] = row;\n for (let column = 0; column < columns; column++) matrix[0][column] = column;\n for (let row = 1; row < rows; row++) {\n for (let column = 1; column < columns; column++) {\n const cost = a[row - 1] === b[column - 1] ? 0 : 1;\n matrix[row][column] = Math.min(\n matrix[row - 1][column] + 1,\n matrix[row][column - 1] + 1,\n matrix[row - 1][column - 1] + cost\n );\n if (\n row > 1 &&\n column > 1 &&\n a[row - 1] === b[column - 2] &&\n a[row - 2] === b[column - 1]\n ) {\n matrix[row][column] = Math.min(\n matrix[row][column],\n matrix[row - 2][column - 2] + cost\n );\n }\n }\n }\n return matrix[a.length][b.length];\n }\n\n function tokenSimilarity(left, right) {\n const rawLeft = normalizeName(left);\n const rawRight = normalizeName(right);\n const phoneticLeft = phoneticToken(left);\n const phoneticRight = phoneticToken(right);\n const rawScore = 1 - damerauLevenshtein(rawLeft, rawRight) / Math.max(rawLeft.length, rawRight.length, 1);\n const phoneticScore = 1 - damerauLevenshtein(phoneticLeft, phoneticRight) / Math.max(phoneticLeft.length, phoneticRight.length, 1);\n return Math.max(rawScore, phoneticScore);\n }\n\n function compareBambooNames(personName, bambooName) {\n const personTokens = nameTokens(personName);\n const bambooTokens = nameTokens(bambooName);\n if (!personTokens.length || !bambooTokens.length) {\n return {\n score: 0,\n coverage: 0,\n strong: 0,\n anchors: 0,\n exact: 0,\n weak: personTokens.length,\n targetCount: personTokens.length,\n candidateCount: bambooTokens.length,\n extra: bambooTokens.length,\n pairs: []\n };\n }\n\n const choices = [];\n for (let personIndex = 0; personIndex < personTokens.length; personIndex++) {\n for (let bambooIndex = 0; bambooIndex < bambooTokens.length; bambooIndex++) {\n choices.push({\n personIndex,\n bambooIndex,\n similarity: tokenSimilarity(personTokens[personIndex], bambooTokens[bambooIndex])\n });\n }\n }\n choices.sort((left, right) => right.similarity - left.similarity);\n\n const usedPerson = new Set();\n const usedBamboo = new Set();\n const pairs = [];\n for (const choice of choices) {\n if (usedPerson.has(choice.personIndex) || usedBamboo.has(choice.bambooIndex)) continue;\n usedPerson.add(choice.personIndex);\n usedBamboo.add(choice.bambooIndex);\n pairs.push({\n person: personTokens[choice.personIndex],\n bamboo: bambooTokens[choice.bambooIndex],\n similarity: choice.similarity\n });\n }\n for (let personIndex = 0; personIndex < personTokens.length; personIndex++) {\n if (!usedPerson.has(personIndex)) {\n pairs.push({ person: personTokens[personIndex], bamboo: '', similarity: 0 });\n }\n }\n\n const totalWeight = personTokens.reduce((sum, token) => sum + Math.min(Math.max(token.length, 3), 10), 0) || 1;\n const coverage = pairs.reduce((sum, pair) => {\n const weight = Math.min(Math.max(pair.person.length, 3), 10);\n return sum + pair.similarity * weight;\n }, 0) / totalWeight;\n const extra = Math.max(0, bambooTokens.length - usedBamboo.size);\n const score = Math.max(0, coverage - Math.min(0.03, extra * 0.008));\n\n return {\n score,\n coverage,\n strong: pairs.filter(pair => pair.similarity >= 0.78).length,\n anchors: pairs.filter(pair => pair.similarity >= 0.92).length,\n exact: pairs.filter(pair => pair.similarity >= 0.999).length,\n weak: pairs.filter(pair => pair.similarity < 0.70).length,\n targetCount: personTokens.length,\n candidateCount: bambooTokens.length,\n extra,\n pairs\n };\n }\n\n const exactBambooByDui = new Map();\n const exactBambooByName = new Map();\n const bambooTokenIndex = new Map();\n const bambooPhoneticIndex = new Map();\n const bambooElSalvador = bambooEmployees.filter(employee => employee.isElSalvador);\n\n for (const employee of bambooEmployees) {\n if (employee.dui) {\n if (!exactBambooByDui.has(employee.dui)) exactBambooByDui.set(employee.dui, new Set());\n exactBambooByDui.get(employee.dui).add(employee);\n }\n for (const canonical of employee.canonicalAliases) {\n if (!exactBambooByName.has(canonical)) exactBambooByName.set(canonical, new Set());\n exactBambooByName.get(canonical).add(employee);\n }\n for (const alias of employee.aliases) {\n for (const token of new Set(nameTokens(alias))) {\n if (!bambooTokenIndex.has(token)) bambooTokenIndex.set(token, new Set());\n bambooTokenIndex.get(token).add(employee);\n const phonetic = phoneticToken(token);\n if (!bambooPhoneticIndex.has(phonetic)) bambooPhoneticIndex.set(phonetic, new Set());\n bambooPhoneticIndex.get(phonetic).add(employee);\n }\n }\n }\n\n function bambooRank(employee) {\n const status = clean(employee.status);\n const active = /^(ACTIVE|ACTIVO)$/i.test(status) ? 2 : 0;\n return (employee.isElSalvador ? 4 : 0) + active;\n }\n\n function bestEmployeeNameScore(employee, personAliases) {\n let best = null;\n for (const personAlias of personAliases) {\n for (const bambooAlias of employee.aliases) {\n const comparison = compareBambooNames(personAlias, bambooAlias);\n if (!best || comparison.score > best.comparison.score) {\n best = { comparison, personAlias, bambooAlias };\n }\n }\n }\n return best || {\n comparison: compareBambooNames('', ''),\n personAlias: '',\n bambooAlias: ''\n };\n }\n\n function scoreEmployees(employees, personAliases) {\n return [...employees].map(employee => {\n const result = bestEmployeeNameScore(employee, personAliases);\n return {\n employee,\n comparison: result.comparison,\n personAlias: result.personAlias,\n bambooAlias: result.bambooAlias,\n rank: bambooRank(employee)\n };\n }).sort((left, right) =>\n right.comparison.score - left.comparison.score ||\n right.rank - left.rank\n );\n }\n\n function exactDuiNameCompatible(result) {\n const comparison = result.comparison;\n return (\n canonicalName(result.personAlias) === canonicalName(result.bambooAlias) ||\n (comparison.strong >= Math.min(2, comparison.targetCount) && comparison.score >= 0.62) ||\n comparison.score >= 0.78\n );\n }\n\n function identityIssueFor(person, matchedEmployee, duiOwner) {\n const matchedDui = clean(matchedEmployee?.dui);\n if (duiOwner && matchedEmployee && duiOwner.id !== matchedEmployee.id) {\n return {\n type: 'DUI_BAMBOO_OTRA_PERSONA',\n duiOwner,\n matchedEmployee,\n sourceDui: person.dui\n };\n }\n if (matchedEmployee && matchedDui && matchedDui !== person.dui) {\n return {\n type: 'DUI_BAMBOO_DIFERENTE',\n duiOwner: null,\n matchedEmployee,\n sourceDui: person.dui\n };\n }\n return null;\n }\n\n function acceptedMatch(methodCode, method, employee, score, margin, result, identityIssue) {\n return {\n accepted: true,\n review: false,\n methodCode,\n method,\n employee,\n candidate: employee,\n score,\n margin,\n sourceAlias: result?.personAlias || '',\n matchedAlias: result?.bambooAlias || '',\n identityIssue: identityIssue || null\n };\n }\n\n function matchBambooPerson(person) {\n const personAliases = uniqueNames(person.aliases || []);\n const exactDuiCandidates = [...(exactBambooByDui.get(person.dui) || [])];\n let duiOwner = null;\n\n if (exactDuiCandidates.length) {\n const scoredDui = scoreEmployees(exactDuiCandidates, personAliases);\n const compatibleDui = scoredDui.filter(exactDuiNameCompatible);\n const compatibleElSalvador = compatibleDui.filter(result => result.employee.isElSalvador);\n const compatiblePool = compatibleElSalvador.length ? compatibleElSalvador : compatibleDui;\n if (compatiblePool.length === 1) {\n const best = compatiblePool[0];\n return acceptedMatch(\n 'DUI_EXACTO',\n 'DUI exacto',\n best.employee,\n 1,\n 1,\n best,\n null\n );\n }\n if (compatiblePool.length > 1) {\n const best = compatiblePool[0];\n const second = compatiblePool[1];\n const margin = best.comparison.score - second.comparison.score;\n if (best.comparison.score >= 0.90 && margin >= 0.10) {\n return acceptedMatch(\n 'DUI_EXACTO_DESAMBIGUADO',\n 'DUI exacto desambiguado por nombre',\n best.employee,\n 1,\n margin,\n best,\n null\n );\n }\n }\n duiOwner = scoredDui[0]?.employee || exactDuiCandidates[0];\n }\n\n const exactNameEmployees = new Set();\n for (const alias of personAliases) {\n for (const employee of exactBambooByName.get(canonicalName(alias)) || []) {\n exactNameEmployees.add(employee);\n }\n }\n if (exactNameEmployees.size) {\n const exactList = [...exactNameEmployees];\n const exactElSalvador = exactList.filter(employee => employee.isElSalvador);\n const exactPool = exactElSalvador.length ? exactElSalvador : exactList;\n if (exactPool.length === 1) {\n const employee = exactPool[0];\n const result = bestEmployeeNameScore(employee, personAliases);\n return acceptedMatch(\n 'NOMBRE_EXACTO_UNICO',\n duiOwner ? 'Nombre exacto; revisar DUI en BambooHR' : 'Nombre exacto único',\n employee,\n 1,\n 1,\n result,\n identityIssueFor(person, employee, duiOwner)\n );\n }\n }\n\n const candidates = new Set(bambooElSalvador);\n for (const alias of personAliases) {\n for (const token of new Set(nameTokens(alias))) {\n const exactTokenMatches = bambooTokenIndex.get(token);\n if (exactTokenMatches && exactTokenMatches.size <= 100) {\n for (const employee of exactTokenMatches) candidates.add(employee);\n }\n const phoneticMatches = bambooPhoneticIndex.get(phoneticToken(token));\n if (phoneticMatches && phoneticMatches.size <= 100) {\n for (const employee of phoneticMatches) candidates.add(employee);\n }\n }\n }\n\n const scored = scoreEmployees(candidates, personAliases);\n const best = scored[0] || null;\n const second = scored[1] || null;\n if (!best) {\n return {\n accepted: false,\n review: Boolean(duiOwner),\n methodCode: duiOwner ? 'DUI_NOMBRE_CONFLICTO' : 'SIN_COINCIDENCIA',\n method: duiOwner ? 'DUI asociado a otro nombre' : 'Sin coincidencia',\n employee: null,\n candidate: duiOwner,\n score: 0,\n margin: 0,\n identityIssue: identityIssueFor(person, null, duiOwner)\n };\n }\n\n const comparison = best.comparison;\n const margin = second ? comparison.score - second.comparison.score : 1;\n const longStrong = (\n comparison.targetCount >= 3 &&\n comparison.weak === 0 &&\n comparison.strong >= 3 &&\n comparison.anchors >= 2 &&\n comparison.score >= 0.92 &&\n margin >= 0.10 &&\n (best.employee.isElSalvador || (comparison.score >= 0.97 && comparison.exact >= 3))\n );\n const shortStrong = (\n comparison.targetCount === 2 &&\n comparison.weak === 0 &&\n comparison.strong === 2 &&\n comparison.anchors === 2 &&\n comparison.exact >= 1 &&\n comparison.score >= 0.96 &&\n margin >= 0.15 &&\n best.employee.isElSalvador\n );\n\n if (longStrong || shortStrong) {\n return acceptedMatch(\n 'NOMBRE_TIPOGRAFICO_UNICO',\n duiOwner ? 'Nombre tipográfico; revisar DUI en BambooHR' : 'Nombre tipográfico único',\n best.employee,\n comparison.score,\n margin,\n best,\n identityIssueFor(person, best.employee, duiOwner)\n );\n }\n\n const review = Boolean(duiOwner) || (\n comparison.score >= 0.82 &&\n comparison.strong >= Math.min(2, comparison.targetCount)\n );\n return {\n accepted: false,\n review,\n methodCode: duiOwner ? 'DUI_NOMBRE_CONFLICTO' : (review ? 'COINCIDENCIA_AMBIGUA' : 'SIN_COINCIDENCIA'),\n method: duiOwner ? 'DUI asociado a otro nombre' : (review ? 'Coincidencia ambigua' : 'Sin coincidencia'),\n employee: null,\n candidate: duiOwner || best.employee,\n score: comparison.score,\n margin,\n sourceAlias: best.personAlias,\n matchedAlias: best.bambooAlias,\n identityIssue: identityIssueFor(person, null, duiOwner)\n };\n }\n\n function sourcePeople(groups, missingRecords, source) {\n const people = [...groups.values()].map(group => ({\n source,\n dui: group.dui,\n name: group.name,\n project: group.project || '',\n amount: group.amount\n }));\n for (const record of missingRecords) {\n people.push({\n source,\n dui: '',\n name: record.name,\n project: record.project || '',\n amount: record.amount\n });\n }\n return people;\n }\n\n const payrollPeople = sourcePeople(payrollGroups, payrollMissingDui, 'Nómina');\n const planPeople = sourcePeople(planGroups, planMissingDui, 'Planilla');\n const unifiedPeople = new Map();\n\n function addUnifiedPerson(person, index) {\n const key = person.dui\n ? 'DUI:' + person.dui\n : person.source + ':SIN_DUI:' + index + ':' + canonicalName(person.name);\n person.matchKey = key;\n if (!unifiedPeople.has(key)) {\n unifiedPeople.set(key, {\n key,\n dui: person.dui,\n aliases: [],\n payrollNames: [],\n planNames: [],\n payrollAmounts: [],\n planAmounts: []\n });\n }\n const unified = unifiedPeople.get(key);\n unified.aliases = uniqueNames([...unified.aliases, person.name]);\n if (person.source === 'Nómina') {\n unified.payrollNames = uniqueNames([...unified.payrollNames, person.name]);\n unified.payrollAmounts.push(person.amount);\n } else {\n unified.planNames = uniqueNames([...unified.planNames, person.name]);\n unified.planAmounts.push(person.amount);\n }\n }\n\n payrollPeople.forEach(addUnifiedPerson);\n planPeople.forEach(addUnifiedPerson);\n\n const matchByKey = new Map();\n for (const person of unifiedPeople.values()) {\n matchByKey.set(person.key, matchBambooPerson(person));\n }\n\n const bambooUsage = new Map();\n for (const [key, match] of matchByKey) {\n if (!match.accepted || !match.employee?.id) continue;\n if (!bambooUsage.has(match.employee.id)) bambooUsage.set(match.employee.id, []);\n bambooUsage.get(match.employee.id).push({ key, match });\n }\n for (const uses of bambooUsage.values()) {\n if (uses.length <= 1) continue;\n const exactDuiUses = uses.filter(use => /^DUI_EXACTO/.test(use.match.methodCode));\n if (exactDuiUses.length === 1) {\n for (const use of uses) {\n if (use === exactDuiUses[0]) continue;\n use.match.accepted = false;\n use.match.review = true;\n use.match.methodCode = 'REGISTRO_BAMBOO_REUTILIZADO';\n use.match.method = 'Coincidencia ambigua: registro BambooHR ya asignado a otro DUI';\n }\n } else {\n for (const use of uses) {\n use.match.accepted = false;\n use.match.review = true;\n use.match.methodCode = 'REGISTRO_BAMBOO_REUTILIZADO';\n use.match.method = 'Coincidencia ambigua: registro BambooHR asociado a más de un DUI';\n }\n }\n }\n\n const bambooMatchStats = {\n duiExacto: 0,\n nombreExacto: 0,\n nombreTipografico: 0,\n inactivos: 0,\n conflictosDui: 0,\n ambiguos: 0,\n sinCoincidencia: 0\n };\n\n for (const person of unifiedPeople.values()) {\n const match = matchByKey.get(person.key);\n if (match.accepted) {\n if (/^DUI_EXACTO/.test(match.methodCode)) bambooMatchStats.duiExacto++;\n else if (match.methodCode === 'NOMBRE_EXACTO_UNICO') bambooMatchStats.nombreExacto++;\n else if (match.methodCode === 'NOMBRE_TIPOGRAFICO_UNICO') bambooMatchStats.nombreTipografico++;\n if (clean(match.employee?.status) && !/^(ACTIVE|ACTIVO)$/i.test(match.employee.status)) {\n bambooMatchStats.inactivos++;\n }\n } else if (match.review) {\n bambooMatchStats.ambiguos++;\n } else {\n bambooMatchStats.sinCoincidencia++;\n }\n\n if (match.identityIssue) {\n bambooMatchStats.conflictosDui++;\n const issue = match.identityIssue;\n const matchedEmployee = issue.matchedEmployee || match.employee;\n const duiOwner = issue.duiOwner;\n const detail = duiOwner\n ? 'En BambooHR el DUI/employeeNumber ' + person.dui + ' está asociado a ' + duiOwner.name +\n '. La persona coincide por nombre con ' + (matchedEmployee?.name || 'otro registro') +\n (matchedEmployee?.employeeNumber ? ' (employeeNumber ' + matchedEmployee.employeeNumber + ')' : '') + '.'\n : 'La persona coincide por nombre con ' + (matchedEmployee?.name || 'un registro de BambooHR') +\n ', pero BambooHR contiene el employeeNumber ' + (matchedEmployee?.employeeNumber || 'vacío o inválido') +\n ' y las fuentes del cruce contienen ' + person.dui + '.';\n addFinding({\n tipo: 'DUI distinto o asociado a otra persona en BambooHR',\n severidad: 'Alta',\n duiNomina: person.payrollNames.length ? person.dui : '',\n duiPlanilla: person.planNames.length ? person.dui : '',\n nombreNomina: person.payrollNames.join(' | '),\n nombrePlanilla: person.planNames.join(' | '),\n montoNomina: person.payrollAmounts.length ? person.payrollAmounts.reduce((sum, value) => sum + Number(value || 0), 0) : '',\n montoPlanilla: person.planAmounts.length ? person.planAmounts.reduce((sum, value) => sum + Number(value || 0), 0) : '',\n accion: detail + ' Corregir y validar el identificador en BambooHR.'\n });\n }\n }\n\n const payrollWithoutBamboo = [];\n const planWithoutBamboo = [];\n\n function appendBambooException(person, output) {\n const match = matchByKey.get(person.matchKey);\n if (match?.accepted) return;\n const candidate = match?.candidate;\n output.push({\n dui: person.dui,\n name: person.name,\n project: person.project || '',\n amount: person.amount,\n candidateName: candidate?.name || '',\n score: Number(match?.score || 0),\n method: match?.method || 'Sin coincidencia',\n candidateStatus: candidate?.status || '',\n candidateLocation: candidate?.location || '',\n candidateCountry: candidate?.country || '',\n action: match?.review\n ? 'Revisar manualmente la coincidencia; no se clasificó automáticamente como persona ausente.'\n : 'Crear o validar el registro de la persona en BambooHR.'\n });\n }\n\n for (const person of payrollPeople) appendBambooException(person, payrollWithoutBamboo);\n for (const person of planPeople) appendBambooException(person, planWithoutBamboo);\n payrollWithoutBamboo.sort((left, right) => left.name.localeCompare(right.name, 'es'));\n planWithoutBamboo.sort((left, right) => left.name.localeCompare(right.name, 'es'));\n\n const severityOrder = { Alta: 0, Media: 1, Informativa: 2 };\n findings.sort((a, b) => (severityOrder[a.severidad] ?? 9) - (severityOrder[b.severidad] ?? 9) || a.tipo.localeCompare(b.tipo, 'es') || (a.nombreNomina || a.nombrePlanilla).localeCompare(b.nombreNomina || b.nombrePlanilla, 'es'));\n\n const amountFindings = findings.filter(item => item.tipo === 'Diferencia de monto' || item.tipo === 'Diferencia subcentavo').length;\n const subcentFindings = findings.filter(item => item.tipo === 'Diferencia subcentavo').length;\n const identityFindings = findings.length - amountFindings;\n const nameFindings = findings.filter(item => item.tipo === 'Nombre no idéntico con el mismo DUI').length;\n const highFindings = findings.filter(item => item.severidad === 'Alta').length;\n const totalFindings = findings.length + payrollWithoutBamboo.length + planWithoutBamboo.length;\n\n function color(hex) {\n const text = hex.replace('#', '');\n return {\n red: parseInt(text.slice(0, 2), 16) / 255,\n green: parseInt(text.slice(2, 4), 16) / 255,\n blue: parseInt(text.slice(4, 6), 16) / 255\n };\n }\n const COLORS = {\n blue: color('#4F758B'),\n mediumBlue: color('#5B7F95'),\n steel: color('#6B8FA3'),\n green: color('#6CC24A'),\n greenBg: color('#EEF6E8'),\n blueBg: color('#D6E8F4'),\n gray: color('#4A4A4A'),\n border: color('#D0D0D0'),\n zebra: color('#F5F5F5'),\n white: color('#FFFFFF'),\n redBg: color('#FDECEC'),\n redText: color('#9C2F2F'),\n amberBg: color('#FFF6E3'),\n amberText: color('#8A5A00')\n };\n function gridBorder() {\n return { style: 'SOLID', color: COLORS.border };\n }\n function valueObject(value) {\n if (typeof value === 'number' && Number.isFinite(value)) return { numberValue: value };\n if (typeof value === 'boolean') return { boolValue: value };\n return { stringValue: value == null ? '' : String(value) };\n }\n function sheetDefinition(options) {\n const columnCount = options.headers.length;\n const empty = Array(columnCount).fill('');\n const dataRows = options.rows.length ? options.rows : [[options.emptyLabel || 'Sin diferencias', ...Array(columnCount - 1).fill('')]];\n const rawRows = [\n [options.title, ...Array(columnCount - 1).fill('')],\n [options.subtitle, ...Array(columnCount - 1).fill('')],\n [options.note, ...Array(columnCount - 1).fill('')],\n empty.slice(),\n options.headers,\n ...dataRows\n ];\n const rowData = rawRows.map((row, rowIndex) => {\n let background = COLORS.white;\n let foreground = COLORS.gray;\n let bold = false;\n let fontSize = 10;\n let horizontal = 'LEFT';\n let wrap = 'WRAP';\n if (rowIndex === 0) {\n background = COLORS.blue;\n foreground = COLORS.white;\n bold = true;\n fontSize = 15;\n horizontal = 'LEFT';\n } else if (rowIndex === 1) {\n background = COLORS.mediumBlue;\n foreground = COLORS.white;\n bold = true;\n } else if (rowIndex === 2) {\n background = COLORS.blueBg;\n foreground = COLORS.blue;\n bold = true;\n } else if (rowIndex === 4) {\n background = COLORS.blue;\n foreground = COLORS.white;\n bold = true;\n horizontal = 'CENTER';\n } else if (rowIndex >= 5) {\n background = (rowIndex - 5) % 2 ? COLORS.zebra : COLORS.white;\n if (row[0] === 'Sin diferencias' || row[0] === 'Sin personas fuera de BambooHR') {\n background = COLORS.greenBg;\n foreground = COLORS.green;\n bold = true;\n }\n if (options.kind === 'main' && row[1] === 'Alta') {\n background = COLORS.redBg;\n foreground = COLORS.redText;\n } else if (options.kind === 'main' && row[1] === 'Media') {\n background = COLORS.amberBg;\n foreground = COLORS.amberText;\n } else if (options.kind === 'main' && row[1] === 'Informativa') {\n background = COLORS.blueBg;\n foreground = COLORS.blue;\n }\n }\n return {\n values: row.map((value, columnIndex) => {\n const format = {\n backgroundColor: background,\n textFormat: {\n foregroundColor: foreground,\n bold,\n fontFamily: 'Arial',\n fontSize\n },\n verticalAlignment: 'MIDDLE',\n horizontalAlignment: rowIndex === 4 ? 'CENTER' : (options.moneyColumns.includes(columnIndex) && rowIndex >= 5 ? 'RIGHT' : horizontal),\n wrapStrategy: wrap,\n padding: { top: 5, bottom: 5, left: 7, right: 7 }\n };\n if (rowIndex >= 4) {\n format.borders = {\n top: gridBorder(),\n bottom: gridBorder(),\n left: gridBorder(),\n right: gridBorder()\n };\n }\n if (options.moneyColumns.includes(columnIndex) && rowIndex >= 5 && typeof value === 'number') {\n format.numberFormat = { type: 'CURRENCY', pattern: '$#,##0.0000;[Red]-$#,##0.0000' };\n }\n return { userEnteredValue: valueObject(value), userEnteredFormat: format };\n })\n };\n });\n const rowMetadata = rawRows.map((row, index) => ({ pixelSize: index === 0 ? 38 : index === 3 ? 10 : index === 4 ? 34 : index < 3 ? 27 : 31 }));\n return {\n properties: {\n sheetId: options.sheetId,\n title: options.sheetTitle,\n tabColor: options.tabColor,\n gridProperties: {\n rowCount: Math.max(rawRows.length + 20, 50),\n columnCount,\n frozenRowCount: 5,\n hideGridlines: true\n }\n },\n data: [{\n startRow: 0,\n startColumn: 0,\n rowData,\n rowMetadata,\n columnMetadata: options.widths.map(pixelSize => ({ pixelSize }))\n }],\n merges: [0, 1, 2].map(rowIndex => ({\n sheetId: options.sheetId,\n startRowIndex: rowIndex,\n endRowIndex: rowIndex + 1,\n startColumnIndex: 0,\n endColumnIndex: columnCount\n })),\n basicFilter: {\n range: {\n sheetId: options.sheetId,\n startRowIndex: 4,\n endRowIndex: rawRows.length,\n startColumnIndex: 0,\n endColumnIndex: columnCount\n }\n }\n };\n }\n\n const mainHeaders = ['Tipo de diferencia', 'Severidad', 'DUI Nómina', 'DUI Planilla', 'Nombre Nómina', 'Nombre Planilla', 'Monto Nómina (USD)', 'Monto Planilla (USD)', 'Diferencia (USD)', 'Observación / Acción'];\n const mainRows = findings.map(item => [\n item.tipo,\n item.severidad,\n item.duiNomina,\n item.duiPlanilla,\n item.nombreNomina,\n item.nombrePlanilla,\n item.montoNomina,\n item.montoPlanilla,\n item.diferencia,\n item.accion\n ]);\n const payrollBambooHeaders = ['DUI', 'Nombre en Nómina', 'Proyecto', 'Monto Nómina (USD)', 'Mejor candidato en BambooHR', 'Similitud', 'Resultado automático', 'Estado candidato', 'Ubicación / País', 'Acción'];\n const payrollBambooRows = payrollWithoutBamboo.map(item => [\n item.dui,\n item.name,\n item.project,\n item.amount,\n item.candidateName,\n item.score ? Math.round(item.score * 100) + '%' : '',\n item.method,\n item.candidateStatus,\n uniqueJoin([item.candidateLocation, item.candidateCountry]),\n item.action\n ]);\n const planBambooHeaders = ['DUI', 'Nombre en Planilla', 'Monto Planilla (USD)', 'Mejor candidato en BambooHR', 'Similitud', 'Resultado automático', 'Estado candidato', 'Ubicación / País', 'Acción'];\n const planBambooRows = planWithoutBamboo.map(item => [\n item.dui,\n item.name,\n item.amount,\n item.candidateName,\n item.score ? Math.round(item.score * 100) + '%' : '',\n item.method,\n item.candidateStatus,\n uniqueJoin([item.candidateLocation, item.candidateCountry]),\n item.action\n ]);\n\n const timestamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 16);\n const reportTitle = 'Cruce Seguridad Social El Salvador - ' + periodLabel + ' - ' + timestamp;\n const spreadsheetPayload = {\n properties: {\n title: reportTitle,\n locale: 'es_SV',\n timeZone: 'America/El_Salvador'\n },\n sheets: [\n sheetDefinition({\n sheetId: 0,\n sheetTitle: 'Diferencias',\n title: 'CRUCE DE SEGURIDAD SOCIAL · EL SALVADOR',\n subtitle: 'Período: ' + periodLabel + ' · Nómina vs. Planilla Única',\n note: 'Solo se muestran hallazgos · Identidad/nombres: ' + identityFindings + ' · Montos: ' + amountFindings + ' · Total: ' + findings.length,\n headers: mainHeaders,\n rows: mainRows,\n widths: [210, 95, 115, 115, 190, 190, 130, 130, 125, 330],\n moneyColumns: [6, 7, 8],\n tabColor: COLORS.blue,\n kind: 'main',\n emptyLabel: 'Sin diferencias'\n }),\n sheetDefinition({\n sheetId: 1,\n sheetTitle: 'Nómina sin Bamboo',\n title: 'NÓMINA SIN BAMBOO',\n subtitle: 'Período: ' + periodLabel + ' · Personas de nómina sin coincidencia concluyente en BambooHR',\n note: 'Solo se muestran excepciones · Total: ' + payrollWithoutBamboo.length + ' · Las coincidencias ambiguas requieren revisión manual',\n headers: payrollBambooHeaders,\n rows: payrollBambooRows,\n widths: [115, 210, 150, 130, 210, 85, 145, 110, 170, 310],\n moneyColumns: [3],\n tabColor: COLORS.green,\n kind: 'bamboo',\n emptyLabel: 'Sin personas fuera de BambooHR'\n }),\n sheetDefinition({\n sheetId: 2,\n sheetTitle: 'Planilla sin Bamboo',\n title: 'PLANILLA SIN BAMBOO',\n subtitle: 'Período: ' + periodLabel + ' · Personas de planilla sin coincidencia concluyente en BambooHR',\n note: 'Solo se muestran excepciones · Total: ' + planWithoutBamboo.length + ' · Las coincidencias ambiguas requieren revisión manual',\n headers: planBambooHeaders,\n rows: planBambooRows,\n widths: [115, 220, 135, 220, 85, 145, 110, 180, 330],\n moneyColumns: [2],\n tabColor: COLORS.steel,\n kind: 'bamboo',\n emptyLabel: 'Sin personas fuera de BambooHR'\n })\n ]\n };\n\n const binaryValues = formItem.binary || {};\n const payrollBinary = binaryValues.nomina || Object.values(binaryValues).find(file => /nomina/i.test((file.fileName || '') + '')) || {};\n const planBinary = binaryValues.planilla_seguridad_social || Object.values(binaryValues).find(file => /planilla/i.test((file.fileName || '') + '')) || {};\n\n return [{\n json: {\n spreadsheetPayload,\n summary: {\n periodo: periodLabel,\n periodoArchivo: periodFile,\n correoDestino: 'iaracena@gomezleemarketing.com, msoto@gomezleemarketing.com, iherrera@gomezleemarketing.com, mgomez@gomezleemarketing.com, vparamo@gomezleemarketing.com, asrodriguez@gomezleemarketing.com, jgomez@gomezleemarketing.com',\n archivoNomina: payrollBinary.fileName || 'Nómina',\n archivoPlanilla: planBinary.fileName || 'Planilla',\n registrosNomina: payrollRecords.length,\n registrosPlanilla: planRecords.length,\n empleadosNomina: payrollGroups.size + payrollMissingDui.length,\n empleadosPlanilla: planGroups.size + planMissingDui.length,\n empleadosBamboo: bambooEmployees.length,\n hallazgosIdentidad: identityFindings,\n hallazgosMontos: amountFindings,\n hallazgosSubcentavo: subcentFindings,\n hallazgosNombres: nameFindings,\n hallazgosAlta: highFindings,\n hallazgosPrincipales: findings.length,\n nominaSinBamboo: payrollWithoutBamboo.length,\n planillaSinBamboo: planWithoutBamboo.length,\n coincidenciasBambooInactivas: bambooMatchStats.inactivos,\n coincidenciasBambooDuiExacto: bambooMatchStats.duiExacto,\n coincidenciasBambooNombreExacto: bambooMatchStats.nombreExacto,\n coincidenciasBambooNombreTipografico: bambooMatchStats.nombreTipografico,\n conflictosDuiBamboo: bambooMatchStats.conflictosDui,\n coincidenciasBambooAmbiguas: bambooMatchStats.ambiguos,\n hallazgosTotales: totalFindings,\n nombreArchivo: 'Cruce_Seguridad_Social_El_Salvador_' + periodFile + '.xlsx'\n }\n }\n }];" + "jsCode": "const payrollSource = $items('Extraer Nómina').map(item => item.json || {});\n const planSource = $items('Extraer Planilla').map(item => item.json || {});\n const formItem = $items('Formulario de cruce')[0] || {};\n const form = formItem.json || {};\n const bambooInput = $input.all();\n\n function clean(value) {\n return String(value ?? '').replace(/\\s+/g, ' ').trim();\n }\n function stripAccents(value) {\n return String(value || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '');\n }\n function normalizeHeader(value) {\n return stripAccents(clean(value)).toUpperCase().replace(/[^A-Z0-9]+/g, ' ').trim();\n }\n function normalizeName(value) {\n return stripAccents(clean(value)).toUpperCase().replace(/[^A-Z0-9 ]+/g, ' ').replace(/\\s+/g, ' ').trim();\n }\n const STOP = new Set(['DE', 'DEL', 'LA', 'LAS', 'LOS', 'Y', 'DA', 'DAS', 'DO', 'VDA']);\n function nameTokens(value) {\n return normalizeName(value).split(' ').filter(token => token.length > 1 && !STOP.has(token));\n }\n function canonicalName(value) {\n return nameTokens(value).slice().sort().join(' ');\n }\n function nameScore(a, b) {\n const A = [...new Set(nameTokens(a))];\n const B = [...new Set(nameTokens(b))];\n if (!A.length || !B.length) return { score: 0, shared: 0 };\n if (A.slice().sort().join(' ') === B.slice().sort().join(' ')) return { score: 1, shared: A.length };\n const setB = new Set(B);\n const shared = A.filter(token => setB.has(token)).length;\n if (shared < 2) return { score: 0, shared };\n const coverage = shared / Math.min(A.length, B.length);\n const dice = (2 * shared) / (A.length + B.length);\n return { score: 0.7 * coverage + 0.3 * dice, shared };\n }\n function parseNumber(value) {\n if (typeof value === 'number') return Number.isFinite(value) ? value : 0;\n let text = clean(value).replace(/[^\\d,.\\-]/g, '');\n if (!text) return 0;\n const comma = text.lastIndexOf(',');\n const dot = text.lastIndexOf('.');\n if (comma > dot) text = text.replace(/\\./g, '').replace(',', '.');\n else text = text.replace(/,/g, '');\n const number = Number(text);\n return Number.isFinite(number) ? number : 0;\n }\n function valueAt(row, aliases, fallbackIndex) {\n const entries = Object.entries(row || {});\n const wanted = aliases.map(normalizeHeader);\n for (const [key, value] of entries) {\n const normalized = normalizeHeader(key);\n if (wanted.includes(normalized)) return value;\n }\n return entries[fallbackIndex]?.[1];\n }\n function normalizeDui(value) {\n let digits = clean(value).replace(/\\D/g, '');\n if (digits.length === 8) digits = digits.padStart(9, '0');\n if (digits.length !== 9) return '';\n return digits.slice(0, 8) + '-' + digits.slice(8);\n }\n function chooseName(values) {\n const names = [...new Set((values || []).map(clean).filter(Boolean))];\n names.sort((a, b) => nameTokens(b).length - nameTokens(a).length || b.length - a.length);\n return names[0] || '';\n }\n function round2(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n }\n function uniqueJoin(values) {\n return [...new Set((values || []).map(clean).filter(Boolean))].join(' | ');\n }\n function isTotalName(value) {\n return /^TOTAL(?:\\s|$)/i.test(clean(value));\n }\n\n const monthNumber = String(form.mes || '').padStart(2, '0');\n const yearNumber = Number(form.anio);\n if (!/^(0[1-9]|1[0-2])$/.test(monthNumber)) {\n throw new Error('Selecciona un mes válido entre 01 y 12.');\n }\n if (!Number.isInteger(yearNumber) || yearNumber < 2020 || yearNumber > 2100) {\n throw new Error('Ingresa un año válido.');\n }\n const monthNames = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];\n const periodLabel = monthNames[Number(monthNumber) - 1] + ' ' + yearNumber;\n const periodFile = yearNumber + '-' + monthNumber;\n\n const payrollRecords = [];\n for (let index = 0; index < payrollSource.length; index++) {\n const row = payrollSource[index];\n const rawDui = valueAt(row, ['DUI'], 3);\n const name = clean(valueAt(row, ['COLABORADOR', 'EMPLEADO', 'NOMBRE'], 4));\n const project = clean(valueAt(row, ['PROYECTO'], 2));\n const amount = parseNumber(valueAt(row, ['SUB TOTAL PERCIBIDO', 'TOTAL PERCIBIDO'], 15));\n const dui = normalizeDui(rawDui);\n if ((!name && !dui) || isTotalName(name)) continue;\n payrollRecords.push({\n sourceIndex: index + 2,\n dui,\n rawDui: clean(rawDui),\n name,\n project,\n amount\n });\n }\n\n const planRecords = [];\n for (let index = 0; index < planSource.length; index++) {\n const row = planSource[index];\n const rawDui = valueAt(row, ['NUMERO DE DOCUMENTO AFILIADO', 'DUI'], 4);\n const nameParts = [\n valueAt(row, ['PRIMER NOMBRE'], 8),\n valueAt(row, ['SEGUNDO NOMBRE'], 9),\n valueAt(row, ['PRIMER APELLIDO'], 10),\n valueAt(row, ['SEGUNDO APELLIDO'], 11),\n valueAt(row, ['APELLIDO DE CASADA'], 12)\n ].map(clean).filter(Boolean);\n const name = clean(nameParts.join(' '));\n const salary = parseNumber(valueAt(row, ['SALARIO'], 13));\n const additional = parseNumber(valueAt(row, ['PAGO ADICIONAL'], 14));\n const vacation = parseNumber(valueAt(row, ['MONTO DE VACACION', 'MONTO DE VACACIONES'], 15));\n const observations = uniqueJoin([\n valueAt(row, ['CODIGO DE OBSERVACION 1'], 19),\n valueAt(row, ['CODIGO DE OBSERVACION 2'], 20)\n ]);\n const dui = normalizeDui(rawDui);\n if ((!name && !dui) || isTotalName(name)) continue;\n planRecords.push({\n sourceIndex: index + 5,\n dui,\n rawDui: clean(rawDui),\n name,\n amount: salary + additional + vacation,\n salary,\n additional,\n vacation,\n observations\n });\n }\n\n if (!payrollRecords.length) {\n throw new Error('No se encontraron empleados en la hoja \"Nomina\". Se esperaba el encabezado en la fila 1 y datos desde la fila 2.');\n }\n if (!planRecords.length) {\n throw new Error('No se encontraron empleados en \"PLANILLA_UNICA\". Se esperaba el encabezado en la fila 4 y datos desde la fila 5.');\n }\n if (!payrollRecords.some(record => record.dui)) {\n throw new Error('No se pudo leer ningún DUI válido en la columna D de la nómina.');\n }\n if (!planRecords.some(record => record.dui)) {\n throw new Error('No se pudo leer ningún DUI válido en la columna E de la planilla.');\n }\n\n function aggregate(records, source) {\n const groups = new Map();\n for (const record of records) {\n if (!record.dui) continue;\n if (!groups.has(record.dui)) {\n groups.set(record.dui, {\n dui: record.dui,\n source,\n amount: 0,\n names: [],\n projects: [],\n observations: [],\n rows: []\n });\n }\n const group = groups.get(record.dui);\n group.amount += Number(record.amount) || 0;\n group.names.push(record.name);\n if (record.project) group.projects.push(record.project);\n if (record.observations) group.observations.push(record.observations);\n group.rows.push(record.sourceIndex);\n }\n for (const group of groups.values()) {\n group.name = chooseName(group.names);\n group.project = uniqueJoin(group.projects);\n group.observation = uniqueJoin(group.observations);\n group.nameVariants = [...new Set(group.names.map(normalizeName).filter(Boolean))];\n }\n return groups;\n }\n\n const payrollGroups = aggregate(payrollRecords, 'Nómina');\n const planGroups = aggregate(planRecords, 'Planilla');\n const payrollMissingDui = payrollRecords.filter(record => !record.dui);\n const planMissingDui = planRecords.filter(record => !record.dui);\n const findings = [];\n\n function addFinding(data) {\n findings.push({\n tipo: data.tipo || '',\n severidad: data.severidad || 'Media',\n duiNomina: data.duiNomina || '',\n duiPlanilla: data.duiPlanilla || '',\n nombreNomina: data.nombreNomina || '',\n nombrePlanilla: data.nombrePlanilla || '',\n montoNomina: data.montoNomina === '' || data.montoNomina == null ? '' : Number(data.montoNomina),\n montoPlanilla: data.montoPlanilla === '' || data.montoPlanilla == null ? '' : Number(data.montoPlanilla),\n diferencia: data.diferencia === '' || data.diferencia == null ? '' : Number(data.diferencia),\n accion: data.accion || ''\n });\n }\n\n for (const record of payrollMissingDui) {\n addFinding({\n tipo: 'DUI faltante o inválido en nómina',\n severidad: 'Alta',\n nombreNomina: record.name,\n montoNomina: record.amount,\n accion: 'Completar o corregir el DUI en la nómina antes de presentar la planilla.'\n });\n }\n for (const record of planMissingDui) {\n addFinding({\n tipo: 'DUI faltante o inválido en planilla',\n severidad: 'Alta',\n nombrePlanilla: record.name,\n montoPlanilla: record.amount,\n accion: 'Completar o corregir el DUI en la planilla de Seguridad Social.'\n });\n }\n\n for (const [dui, group] of payrollGroups) {\n if (group.nameVariants.length > 1) {\n addFinding({\n tipo: 'Un DUI tiene varios nombres en nómina',\n severidad: 'Alta',\n duiNomina: dui,\n nombreNomina: uniqueJoin(group.names),\n montoNomina: group.amount,\n accion: 'Validar que el DUI no esté asociado a más de una persona.'\n });\n }\n }\n for (const [dui, group] of planGroups) {\n if (group.nameVariants.length > 1) {\n addFinding({\n tipo: 'Un DUI tiene varios nombres en planilla',\n severidad: 'Alta',\n duiPlanilla: dui,\n nombrePlanilla: uniqueJoin(group.names),\n montoPlanilla: group.amount,\n accion: 'Validar las filas duplicadas y la identidad del afiliado.'\n });\n }\n }\n\n function flagSameNameMultipleDuis(groups, sourceLabel) {\n const byName = new Map();\n for (const group of groups.values()) {\n const key = canonicalName(group.name);\n if (!key) continue;\n if (!byName.has(key)) byName.set(key, []);\n byName.get(key).push(group);\n }\n for (const list of byName.values()) {\n const duis = [...new Set(list.map(group => group.dui))];\n if (duis.length <= 1) continue;\n addFinding({\n tipo: 'Misma persona con más de un DUI en ' + sourceLabel.toLowerCase(),\n severidad: 'Alta',\n duiNomina: sourceLabel === 'Nómina' ? duis.join(' | ') : '',\n duiPlanilla: sourceLabel === 'Planilla' ? duis.join(' | ') : '',\n nombreNomina: sourceLabel === 'Nómina' ? list[0].name : '',\n nombrePlanilla: sourceLabel === 'Planilla' ? list[0].name : '',\n montoNomina: sourceLabel === 'Nómina' ? list.reduce((sum, group) => sum + group.amount, 0) : '',\n montoPlanilla: sourceLabel === 'Planilla' ? list.reduce((sum, group) => sum + group.amount, 0) : '',\n accion: 'Confirmar el DUI correcto y unificar el registro.'\n });\n }\n }\n flagSameNameMultipleDuis(payrollGroups, 'Nómina');\n flagSameNameMultipleDuis(planGroups, 'Planilla');\n\n const commonDuis = [...payrollGroups.keys()].filter(dui => planGroups.has(dui));\n for (const dui of commonDuis) {\n const payroll = payrollGroups.get(dui);\n const plan = planGroups.get(dui);\n if (normalizeName(payroll.name) !== normalizeName(plan.name)) {\n addFinding({\n tipo: 'Nombre no idéntico con el mismo DUI',\n severidad: 'Media',\n duiNomina: dui,\n duiPlanilla: dui,\n nombreNomina: payroll.name,\n nombrePlanilla: plan.name,\n montoNomina: payroll.amount,\n montoPlanilla: plan.amount,\n accion: 'Verificar la escritura del nombre; el DUI coincide en ambas fuentes.'\n });\n }\n const delta = payroll.amount - plan.amount;\n if (Math.abs(delta) > 0.0000001) {\n const isSubcent = Math.abs(round2(payroll.amount) - round2(plan.amount)) <= 0.0000001;\n addFinding({\n tipo: isSubcent ? 'Diferencia subcentavo' : 'Diferencia de monto',\n severidad: isSubcent ? 'Informativa' : 'Alta',\n duiNomina: dui,\n duiPlanilla: dui,\n nombreNomina: payroll.name,\n nombrePlanilla: plan.name,\n montoNomina: payroll.amount,\n montoPlanilla: plan.amount,\n diferencia: delta,\n accion: isSubcent\n ? 'Los montos coinciden a dos decimales; conservar como control de precisión.'\n : 'Revisar salario, pago adicional, vacaciones, subsidios y acumulación de filas.'\n });\n }\n }\n\n const onlyPayroll = [...payrollGroups.values()].filter(group => !planGroups.has(group.dui));\n const onlyPlan = [...planGroups.values()].filter(group => !payrollGroups.has(group.dui));\n const usedPlan = new Set();\n\n function bestUnmatchedName(targetName, candidates, excludedIndexes) {\n const scored = candidates.map((candidate, index) => {\n const scoreData = nameScore(targetName, candidate.name);\n return { candidate, index, score: scoreData.score, shared: scoreData.shared };\n }).filter(result => !excludedIndexes.has(result.index) && result.score > 0)\n .sort((a, b) => b.score - a.score);\n const best = scored[0] || null;\n const second = scored[1] || null;\n if (!best) return { accepted: false, best: null };\n const margin = second ? best.score - second.score : best.score;\n const exact = canonicalName(targetName) === canonicalName(best.candidate.name);\n const accepted = exact || (best.shared >= 2 && best.score >= 0.88 && (margin >= 0.08 || best.score >= 0.98));\n return { accepted, best, second, margin };\n }\n\n for (const payroll of onlyPayroll) {\n const match = bestUnmatchedName(payroll.name, onlyPlan, usedPlan);\n if (match.accepted) {\n usedPlan.add(match.best.index);\n const plan = match.best.candidate;\n addFinding({\n tipo: 'Posible misma persona con DUI diferente',\n severidad: 'Alta',\n duiNomina: payroll.dui,\n duiPlanilla: plan.dui,\n nombreNomina: payroll.name,\n nombrePlanilla: plan.name,\n montoNomina: payroll.amount,\n montoPlanilla: plan.amount,\n accion: 'Confirmar la identidad y corregir el DUI en la fuente que corresponda.'\n });\n } else {\n addFinding({\n tipo: 'Persona en nómina que no está en planilla',\n severidad: 'Alta',\n duiNomina: payroll.dui,\n nombreNomina: payroll.name,\n montoNomina: payroll.amount,\n accion: 'Validar inclusión en la planilla de Seguridad Social.'\n });\n }\n }\n onlyPlan.forEach((plan, index) => {\n if (usedPlan.has(index)) return;\n addFinding({\n tipo: 'Persona en planilla que no está en nómina',\n severidad: 'Alta',\n duiPlanilla: plan.dui,\n nombrePlanilla: plan.name,\n montoPlanilla: plan.amount,\n accion: 'Validar si corresponde excluirla o incorporarla a la nómina.'\n });\n });\n\n function collectBambooPayload(payload, rows, totals) {\n if (payload == null) return;\n if (Array.isArray(payload)) {\n for (const value of payload) collectBambooPayload(value, rows, totals);\n return;\n }\n if (typeof payload !== 'object') return;\n if (Number(payload?.meta?.total) > 0) totals.push(Number(payload.meta.total));\n if (payload.body && typeof payload.body === 'object') {\n collectBambooPayload(payload.body, rows, totals);\n return;\n }\n if (Array.isArray(payload.data)) {\n rows.push(...payload.data);\n return;\n }\n if (Array.isArray(payload.employees)) {\n rows.push(...payload.employees);\n return;\n }\n if (payload.fullName5 || payload.displayName || payload.firstName || payload.lastName) rows.push(payload);\n }\n\n const bambooRaw = [];\n const bambooTotals = [];\n for (const item of bambooInput) collectBambooPayload(item.json, bambooRaw, bambooTotals);\n\n function uniqueNames(values) {\n return [...new Set(values.map(clean).filter(Boolean))];\n }\n\n function normalizeBambooDui(value) {\n const digits = clean(value).replace(/\\D/g, '');\n return digits.length === 9 ? digits.slice(0, 8) + '-' + digits.slice(8) : '';\n }\n\n function isElSalvadorEmployee(row) {\n return /EL\\s*SALVADOR|SALVADOR/i.test([\n row.location,\n row.country,\n row.division,\n row.department\n ].map(clean).join(' '));\n }\n\n const bambooUnique = new Map();\n for (let index = 0; index < bambooRaw.length; index++) {\n const row = bambooRaw[index] || {};\n const constructedName = [row.firstName, row.middleName, row.lastName].map(clean).filter(Boolean).join(' ');\n const aliases = uniqueNames([\n row.fullName5,\n row.fullName1,\n row.fullName2,\n row.fullName3,\n row.fullName4,\n constructedName,\n [row.firstName, row.lastName].map(clean).filter(Boolean).join(' '),\n [row.preferredName, row.lastName].map(clean).filter(Boolean).join(' '),\n row.displayName\n ]);\n const name = clean(row.displayName || row.fullName5 || row.fullName1 || constructedName);\n if (!name || !aliases.length) continue;\n const identity = clean(row.id || row.employeeId);\n const fallbackIdentity = clean(row.employeeNumber) + '|' + canonicalName(name) + '|' + clean(row.hireDate || row.originalHireDate);\n const key = identity || fallbackIdentity || 'fila-' + index;\n if (!bambooUnique.has(key)) {\n bambooUnique.set(key, {\n id: identity || key,\n employeeId: clean(row.employeeId || row.id),\n employeeNumber: clean(row.employeeNumber),\n dui: normalizeBambooDui(row.employeeNumber),\n name,\n aliases,\n canonicalAliases: [...new Set(aliases.map(canonicalName).filter(Boolean))],\n status: clean(row.status || row.employmentStatus || row.employmentHistoryStatus),\n hireDate: clean(row.hireDate || row.originalHireDate).slice(0, 10),\n terminationDate: clean(row.terminationDate).slice(0, 10),\n location: clean(row.location),\n country: clean(row.country),\n division: clean(row.division),\n department: clean(row.department),\n email: clean(row.workEmail),\n isElSalvador: isElSalvadorEmployee(row)\n });\n }\n }\n\n const bambooEmployees = [...bambooUnique.values()];\n const reportedTotal = bambooTotals.length ? Math.max(...bambooTotals) : bambooEmployees.length;\n if (!bambooEmployees.length) {\n throw new Error('BambooHR no devolvió empleados legibles. Revisa la credencial y los permisos del reporte completo.');\n }\n if (reportedTotal > bambooEmployees.length) {\n throw new Error('BambooHR devolvió ' + bambooEmployees.length + ' empleados únicos de ' + reportedTotal + '. La consulta quedó incompleta.');\n }\n\n function phoneticToken(value) {\n return normalizeName(value)\n .replace(/LL/g, 'Y')\n .replace(/QU/g, 'K')\n .replace(/PH/g, 'F')\n .replace(/C(?=[EI])/g, 'S')\n .replace(/G(?=[EI])/g, 'J')\n .replace(/Z/g, 'S')\n .replace(/V/g, 'B')\n .replace(/H/g, '')\n .replace(/Q/g, 'K')\n .replace(/Y/g, 'I')\n .replace(/(.)\\1+/g, '$1');\n }\n\n function damerauLevenshtein(left, right) {\n const a = String(left || '');\n const b = String(right || '');\n const rows = a.length + 1;\n const columns = b.length + 1;\n const matrix = Array.from({ length: rows }, () => Array(columns).fill(0));\n for (let row = 0; row < rows; row++) matrix[row][0] = row;\n for (let column = 0; column < columns; column++) matrix[0][column] = column;\n for (let row = 1; row < rows; row++) {\n for (let column = 1; column < columns; column++) {\n const cost = a[row - 1] === b[column - 1] ? 0 : 1;\n matrix[row][column] = Math.min(\n matrix[row - 1][column] + 1,\n matrix[row][column - 1] + 1,\n matrix[row - 1][column - 1] + cost\n );\n if (\n row > 1 &&\n column > 1 &&\n a[row - 1] === b[column - 2] &&\n a[row - 2] === b[column - 1]\n ) {\n matrix[row][column] = Math.min(\n matrix[row][column],\n matrix[row - 2][column - 2] + cost\n );\n }\n }\n }\n return matrix[a.length][b.length];\n }\n\n function tokenSimilarity(left, right) {\n const rawLeft = normalizeName(left);\n const rawRight = normalizeName(right);\n const phoneticLeft = phoneticToken(left);\n const phoneticRight = phoneticToken(right);\n const rawScore = 1 - damerauLevenshtein(rawLeft, rawRight) / Math.max(rawLeft.length, rawRight.length, 1);\n const phoneticScore = 1 - damerauLevenshtein(phoneticLeft, phoneticRight) / Math.max(phoneticLeft.length, phoneticRight.length, 1);\n return Math.max(rawScore, phoneticScore);\n }\n\n function compareBambooNames(personName, bambooName) {\n const personTokens = nameTokens(personName);\n const bambooTokens = nameTokens(bambooName);\n if (!personTokens.length || !bambooTokens.length) {\n return {\n score: 0,\n coverage: 0,\n strong: 0,\n anchors: 0,\n exact: 0,\n weak: personTokens.length,\n targetCount: personTokens.length,\n candidateCount: bambooTokens.length,\n extra: bambooTokens.length,\n pairs: []\n };\n }\n\n const choices = [];\n for (let personIndex = 0; personIndex < personTokens.length; personIndex++) {\n for (let bambooIndex = 0; bambooIndex < bambooTokens.length; bambooIndex++) {\n choices.push({\n personIndex,\n bambooIndex,\n similarity: tokenSimilarity(personTokens[personIndex], bambooTokens[bambooIndex])\n });\n }\n }\n choices.sort((left, right) => right.similarity - left.similarity);\n\n const usedPerson = new Set();\n const usedBamboo = new Set();\n const pairs = [];\n for (const choice of choices) {\n if (usedPerson.has(choice.personIndex) || usedBamboo.has(choice.bambooIndex)) continue;\n usedPerson.add(choice.personIndex);\n usedBamboo.add(choice.bambooIndex);\n pairs.push({\n person: personTokens[choice.personIndex],\n bamboo: bambooTokens[choice.bambooIndex],\n similarity: choice.similarity\n });\n }\n for (let personIndex = 0; personIndex < personTokens.length; personIndex++) {\n if (!usedPerson.has(personIndex)) {\n pairs.push({ person: personTokens[personIndex], bamboo: '', similarity: 0 });\n }\n }\n\n const totalWeight = personTokens.reduce((sum, token) => sum + Math.min(Math.max(token.length, 3), 10), 0) || 1;\n const coverage = pairs.reduce((sum, pair) => {\n const weight = Math.min(Math.max(pair.person.length, 3), 10);\n return sum + pair.similarity * weight;\n }, 0) / totalWeight;\n const extra = Math.max(0, bambooTokens.length - usedBamboo.size);\n const score = Math.max(0, coverage - Math.min(0.03, extra * 0.008));\n\n return {\n score,\n coverage,\n strong: pairs.filter(pair => pair.similarity >= 0.78).length,\n anchors: pairs.filter(pair => pair.similarity >= 0.92).length,\n exact: pairs.filter(pair => pair.similarity >= 0.999).length,\n weak: pairs.filter(pair => pair.similarity < 0.70).length,\n targetCount: personTokens.length,\n candidateCount: bambooTokens.length,\n extra,\n pairs\n };\n }\n\n const exactBambooByDui = new Map();\n const exactBambooByName = new Map();\n const bambooTokenIndex = new Map();\n const bambooPhoneticIndex = new Map();\n const bambooElSalvador = bambooEmployees.filter(employee => employee.isElSalvador);\n\n for (const employee of bambooEmployees) {\n if (employee.dui) {\n if (!exactBambooByDui.has(employee.dui)) exactBambooByDui.set(employee.dui, new Set());\n exactBambooByDui.get(employee.dui).add(employee);\n }\n for (const canonical of employee.canonicalAliases) {\n if (!exactBambooByName.has(canonical)) exactBambooByName.set(canonical, new Set());\n exactBambooByName.get(canonical).add(employee);\n }\n for (const alias of employee.aliases) {\n for (const token of new Set(nameTokens(alias))) {\n if (!bambooTokenIndex.has(token)) bambooTokenIndex.set(token, new Set());\n bambooTokenIndex.get(token).add(employee);\n const phonetic = phoneticToken(token);\n if (!bambooPhoneticIndex.has(phonetic)) bambooPhoneticIndex.set(phonetic, new Set());\n bambooPhoneticIndex.get(phonetic).add(employee);\n }\n }\n }\n\n function bambooRank(employee) {\n const status = clean(employee.status);\n const active = /^(ACTIVE|ACTIVO)$/i.test(status) ? 2 : 0;\n return (employee.isElSalvador ? 4 : 0) + active;\n }\n\n function bestEmployeeNameScore(employee, personAliases) {\n let best = null;\n for (const personAlias of personAliases) {\n for (const bambooAlias of employee.aliases) {\n const comparison = compareBambooNames(personAlias, bambooAlias);\n if (!best || comparison.score > best.comparison.score) {\n best = { comparison, personAlias, bambooAlias };\n }\n }\n }\n return best || {\n comparison: compareBambooNames('', ''),\n personAlias: '',\n bambooAlias: ''\n };\n }\n\n function scoreEmployees(employees, personAliases) {\n return [...employees].map(employee => {\n const result = bestEmployeeNameScore(employee, personAliases);\n return {\n employee,\n comparison: result.comparison,\n personAlias: result.personAlias,\n bambooAlias: result.bambooAlias,\n rank: bambooRank(employee)\n };\n }).sort((left, right) =>\n right.comparison.score - left.comparison.score ||\n right.rank - left.rank\n );\n }\n\n function exactDuiNameCompatible(result) {\n const comparison = result.comparison;\n return (\n canonicalName(result.personAlias) === canonicalName(result.bambooAlias) ||\n (comparison.strong >= Math.min(2, comparison.targetCount) && comparison.score >= 0.62) ||\n comparison.score >= 0.78\n );\n }\n\n function identityIssueFor(person, matchedEmployee, duiOwner) {\n const matchedDui = clean(matchedEmployee?.dui);\n if (duiOwner && matchedEmployee && duiOwner.id !== matchedEmployee.id) {\n return {\n type: 'DUI_BAMBOO_OTRA_PERSONA',\n duiOwner,\n matchedEmployee,\n sourceDui: person.dui\n };\n }\n if (matchedEmployee && matchedDui && matchedDui !== person.dui) {\n return {\n type: 'DUI_BAMBOO_DIFERENTE',\n duiOwner: null,\n matchedEmployee,\n sourceDui: person.dui\n };\n }\n return null;\n }\n\n function acceptedMatch(methodCode, method, employee, score, margin, result, identityIssue) {\n return {\n accepted: true,\n review: false,\n methodCode,\n method,\n employee,\n candidate: employee,\n score,\n margin,\n sourceAlias: result?.personAlias || '',\n matchedAlias: result?.bambooAlias || '',\n identityIssue: identityIssue || null\n };\n }\n\n function matchBambooPerson(person) {\n const personAliases = uniqueNames(person.aliases || []);\n const exactDuiCandidates = [...(exactBambooByDui.get(person.dui) || [])];\n let duiOwner = null;\n\n if (exactDuiCandidates.length) {\n const scoredDui = scoreEmployees(exactDuiCandidates, personAliases);\n const compatibleDui = scoredDui.filter(exactDuiNameCompatible);\n const compatibleElSalvador = compatibleDui.filter(result => result.employee.isElSalvador);\n const compatiblePool = compatibleElSalvador.length ? compatibleElSalvador : compatibleDui;\n if (compatiblePool.length === 1) {\n const best = compatiblePool[0];\n return acceptedMatch(\n 'DUI_EXACTO',\n 'DUI exacto',\n best.employee,\n 1,\n 1,\n best,\n null\n );\n }\n if (compatiblePool.length > 1) {\n const best = compatiblePool[0];\n const second = compatiblePool[1];\n const margin = best.comparison.score - second.comparison.score;\n if (best.comparison.score >= 0.90 && margin >= 0.10) {\n return acceptedMatch(\n 'DUI_EXACTO_DESAMBIGUADO',\n 'DUI exacto desambiguado por nombre',\n best.employee,\n 1,\n margin,\n best,\n null\n );\n }\n }\n duiOwner = scoredDui[0]?.employee || exactDuiCandidates[0];\n }\n\n const exactNameEmployees = new Set();\n for (const alias of personAliases) {\n for (const employee of exactBambooByName.get(canonicalName(alias)) || []) {\n exactNameEmployees.add(employee);\n }\n }\n if (exactNameEmployees.size) {\n const exactList = [...exactNameEmployees];\n const exactElSalvador = exactList.filter(employee => employee.isElSalvador);\n const exactPool = exactElSalvador.length ? exactElSalvador : exactList;\n if (exactPool.length === 1) {\n const employee = exactPool[0];\n const result = bestEmployeeNameScore(employee, personAliases);\n return acceptedMatch(\n 'NOMBRE_EXACTO_UNICO',\n duiOwner ? 'Nombre exacto; revisar DUI en BambooHR' : 'Nombre exacto único',\n employee,\n 1,\n 1,\n result,\n identityIssueFor(person, employee, duiOwner)\n );\n }\n }\n\n const candidates = new Set(bambooElSalvador);\n for (const alias of personAliases) {\n for (const token of new Set(nameTokens(alias))) {\n const exactTokenMatches = bambooTokenIndex.get(token);\n if (exactTokenMatches && exactTokenMatches.size <= 100) {\n for (const employee of exactTokenMatches) candidates.add(employee);\n }\n const phoneticMatches = bambooPhoneticIndex.get(phoneticToken(token));\n if (phoneticMatches && phoneticMatches.size <= 100) {\n for (const employee of phoneticMatches) candidates.add(employee);\n }\n }\n }\n\n const scored = scoreEmployees(candidates, personAliases);\n const best = scored[0] || null;\n const second = scored[1] || null;\n if (!best) {\n return {\n accepted: false,\n review: Boolean(duiOwner),\n methodCode: duiOwner ? 'DUI_NOMBRE_CONFLICTO' : 'SIN_COINCIDENCIA',\n method: duiOwner ? 'DUI asociado a otro nombre' : 'Sin coincidencia',\n employee: null,\n candidate: duiOwner,\n score: 0,\n margin: 0,\n identityIssue: identityIssueFor(person, null, duiOwner)\n };\n }\n\n const comparison = best.comparison;\n const margin = second ? comparison.score - second.comparison.score : 1;\n const longStrong = (\n comparison.targetCount >= 3 &&\n comparison.weak === 0 &&\n comparison.strong >= 3 &&\n comparison.anchors >= 2 &&\n comparison.score >= 0.92 &&\n margin >= 0.10 &&\n (best.employee.isElSalvador || (comparison.score >= 0.97 && comparison.exact >= 3))\n );\n const shortStrong = (\n comparison.targetCount === 2 &&\n comparison.weak === 0 &&\n comparison.strong === 2 &&\n comparison.anchors === 2 &&\n comparison.exact >= 1 &&\n comparison.score >= 0.96 &&\n margin >= 0.15 &&\n best.employee.isElSalvador\n );\n\n if (longStrong || shortStrong) {\n return acceptedMatch(\n 'NOMBRE_TIPOGRAFICO_UNICO',\n duiOwner ? 'Nombre tipográfico; revisar DUI en BambooHR' : 'Nombre tipográfico único',\n best.employee,\n comparison.score,\n margin,\n best,\n identityIssueFor(person, best.employee, duiOwner)\n );\n }\n\n const review = Boolean(duiOwner) || (\n comparison.score >= 0.82 &&\n comparison.strong >= Math.min(2, comparison.targetCount)\n );\n return {\n accepted: false,\n review,\n methodCode: duiOwner ? 'DUI_NOMBRE_CONFLICTO' : (review ? 'COINCIDENCIA_AMBIGUA' : 'SIN_COINCIDENCIA'),\n method: duiOwner ? 'DUI asociado a otro nombre' : (review ? 'Coincidencia ambigua' : 'Sin coincidencia'),\n employee: null,\n candidate: duiOwner || best.employee,\n score: comparison.score,\n margin,\n sourceAlias: best.personAlias,\n matchedAlias: best.bambooAlias,\n identityIssue: identityIssueFor(person, null, duiOwner)\n };\n }\n\n function sourcePeople(groups, missingRecords, source) {\n const people = [...groups.values()].map(group => ({\n source,\n dui: group.dui,\n name: group.name,\n project: group.project || '',\n amount: group.amount\n }));\n for (const record of missingRecords) {\n people.push({\n source,\n dui: '',\n name: record.name,\n project: record.project || '',\n amount: record.amount\n });\n }\n return people;\n }\n\n const payrollPeople = sourcePeople(payrollGroups, payrollMissingDui, 'Nómina');\n const planPeople = sourcePeople(planGroups, planMissingDui, 'Planilla');\n const unifiedPeople = new Map();\n\n function addUnifiedPerson(person, index) {\n const key = person.dui\n ? 'DUI:' + person.dui\n : person.source + ':SIN_DUI:' + index + ':' + canonicalName(person.name);\n person.matchKey = key;\n if (!unifiedPeople.has(key)) {\n unifiedPeople.set(key, {\n key,\n dui: person.dui,\n aliases: [],\n payrollNames: [],\n planNames: [],\n payrollAmounts: [],\n planAmounts: []\n });\n }\n const unified = unifiedPeople.get(key);\n unified.aliases = uniqueNames([...unified.aliases, person.name]);\n if (person.source === 'Nómina') {\n unified.payrollNames = uniqueNames([...unified.payrollNames, person.name]);\n unified.payrollAmounts.push(person.amount);\n } else {\n unified.planNames = uniqueNames([...unified.planNames, person.name]);\n unified.planAmounts.push(person.amount);\n }\n }\n\n payrollPeople.forEach(addUnifiedPerson);\n planPeople.forEach(addUnifiedPerson);\n\n const matchByKey = new Map();\n for (const person of unifiedPeople.values()) {\n matchByKey.set(person.key, matchBambooPerson(person));\n }\n\n const bambooUsage = new Map();\n for (const [key, match] of matchByKey) {\n if (!match.accepted || !match.employee?.id) continue;\n if (!bambooUsage.has(match.employee.id)) bambooUsage.set(match.employee.id, []);\n bambooUsage.get(match.employee.id).push({ key, match });\n }\n for (const uses of bambooUsage.values()) {\n if (uses.length <= 1) continue;\n const exactDuiUses = uses.filter(use => /^DUI_EXACTO/.test(use.match.methodCode));\n if (exactDuiUses.length === 1) {\n for (const use of uses) {\n if (use === exactDuiUses[0]) continue;\n use.match.accepted = false;\n use.match.review = true;\n use.match.methodCode = 'REGISTRO_BAMBOO_REUTILIZADO';\n use.match.method = 'Coincidencia ambigua: registro BambooHR ya asignado a otro DUI';\n }\n } else {\n for (const use of uses) {\n use.match.accepted = false;\n use.match.review = true;\n use.match.methodCode = 'REGISTRO_BAMBOO_REUTILIZADO';\n use.match.method = 'Coincidencia ambigua: registro BambooHR asociado a más de un DUI';\n }\n }\n }\n\n const bambooMatchStats = {\n duiExacto: 0,\n nombreExacto: 0,\n nombreTipografico: 0,\n inactivos: 0,\n conflictosDui: 0,\n ambiguos: 0,\n sinCoincidencia: 0\n };\n\n for (const person of unifiedPeople.values()) {\n const match = matchByKey.get(person.key);\n if (match.accepted) {\n if (/^DUI_EXACTO/.test(match.methodCode)) bambooMatchStats.duiExacto++;\n else if (match.methodCode === 'NOMBRE_EXACTO_UNICO') bambooMatchStats.nombreExacto++;\n else if (match.methodCode === 'NOMBRE_TIPOGRAFICO_UNICO') bambooMatchStats.nombreTipografico++;\n if (clean(match.employee?.status) && !/^(ACTIVE|ACTIVO)$/i.test(match.employee.status)) {\n bambooMatchStats.inactivos++;\n }\n } else if (match.review) {\n bambooMatchStats.ambiguos++;\n } else {\n bambooMatchStats.sinCoincidencia++;\n }\n\n if (match.identityIssue) {\n bambooMatchStats.conflictosDui++;\n const issue = match.identityIssue;\n const matchedEmployee = issue.matchedEmployee || match.employee;\n const duiOwner = issue.duiOwner;\n const detail = duiOwner\n ? 'En BambooHR el DUI/employeeNumber ' + person.dui + ' está asociado a ' + duiOwner.name +\n '. La persona coincide por nombre con ' + (matchedEmployee?.name || 'otro registro') +\n (matchedEmployee?.employeeNumber ? ' (employeeNumber ' + matchedEmployee.employeeNumber + ')' : '') + '.'\n : 'La persona coincide por nombre con ' + (matchedEmployee?.name || 'un registro de BambooHR') +\n ', pero BambooHR contiene el employeeNumber ' + (matchedEmployee?.employeeNumber || 'vacío o inválido') +\n ' y las fuentes del cruce contienen ' + person.dui + '.';\n addFinding({\n tipo: 'DUI distinto o asociado a otra persona en BambooHR',\n severidad: 'Alta',\n duiNomina: person.payrollNames.length ? person.dui : '',\n duiPlanilla: person.planNames.length ? person.dui : '',\n nombreNomina: person.payrollNames.join(' | '),\n nombrePlanilla: person.planNames.join(' | '),\n montoNomina: person.payrollAmounts.length ? person.payrollAmounts.reduce((sum, value) => sum + Number(value || 0), 0) : '',\n montoPlanilla: person.planAmounts.length ? person.planAmounts.reduce((sum, value) => sum + Number(value || 0), 0) : '',\n accion: detail + ' Corregir y validar el identificador en BambooHR.'\n });\n }\n }\n\n const payrollWithoutBamboo = [];\n const planWithoutBamboo = [];\n\n function appendBambooException(person, output) {\n const match = matchByKey.get(person.matchKey);\n if (match?.accepted) return;\n const candidate = match?.candidate;\n output.push({\n dui: person.dui,\n name: person.name,\n project: person.project || '',\n amount: person.amount,\n candidateName: candidate?.name || '',\n score: Number(match?.score || 0),\n method: match?.method || 'Sin coincidencia',\n candidateStatus: candidate?.status || '',\n candidateLocation: candidate?.location || '',\n candidateCountry: candidate?.country || '',\n action: match?.review\n ? 'Revisar manualmente la coincidencia; no se clasificó automáticamente como persona ausente.'\n : 'Crear o validar el registro de la persona en BambooHR.'\n });\n }\n\n for (const person of payrollPeople) appendBambooException(person, payrollWithoutBamboo);\n for (const person of planPeople) appendBambooException(person, planWithoutBamboo);\n payrollWithoutBamboo.sort((left, right) => left.name.localeCompare(right.name, 'es'));\n planWithoutBamboo.sort((left, right) => left.name.localeCompare(right.name, 'es'));\n\n const severityOrder = { Alta: 0, Media: 1, Informativa: 2 };\n findings.sort((a, b) => (severityOrder[a.severidad] ?? 9) - (severityOrder[b.severidad] ?? 9) || a.tipo.localeCompare(b.tipo, 'es') || (a.nombreNomina || a.nombrePlanilla).localeCompare(b.nombreNomina || b.nombrePlanilla, 'es'));\n\n const amountFindings = findings.filter(item => item.tipo === 'Diferencia de monto' || item.tipo === 'Diferencia subcentavo').length;\n const subcentFindings = findings.filter(item => item.tipo === 'Diferencia subcentavo').length;\n const identityFindings = findings.length - amountFindings;\n const nameFindings = findings.filter(item => item.tipo === 'Nombre no idéntico con el mismo DUI').length;\n const highFindings = findings.filter(item => item.severidad === 'Alta').length;\n const totalFindings = findings.length + payrollWithoutBamboo.length + planWithoutBamboo.length;\n\n function color(hex) {\n const text = hex.replace('#', '');\n return {\n red: parseInt(text.slice(0, 2), 16) / 255,\n green: parseInt(text.slice(2, 4), 16) / 255,\n blue: parseInt(text.slice(4, 6), 16) / 255\n };\n }\n const COLORS = {\n blue: color('#4F758B'),\n mediumBlue: color('#5B7F95'),\n steel: color('#6B8FA3'),\n green: color('#6CC24A'),\n greenBg: color('#EEF6E8'),\n blueBg: color('#D6E8F4'),\n gray: color('#4A4A4A'),\n border: color('#D0D0D0'),\n zebra: color('#F5F5F5'),\n white: color('#FFFFFF'),\n redBg: color('#FDECEC'),\n redText: color('#9C2F2F'),\n amberBg: color('#FFF6E3'),\n amberText: color('#8A5A00')\n };\n function gridBorder() {\n return { style: 'SOLID', color: COLORS.border };\n }\n function valueObject(value) {\n if (typeof value === 'number' && Number.isFinite(value)) return { numberValue: value };\n if (typeof value === 'boolean') return { boolValue: value };\n return { stringValue: value == null ? '' : String(value) };\n }\n function sheetDefinition(options) {\n const columnCount = options.headers.length;\n const empty = Array(columnCount).fill('');\n const dataRows = options.rows.length ? options.rows : [[options.emptyLabel || 'Sin diferencias', ...Array(columnCount - 1).fill('')]];\n const rawRows = [\n [options.title, ...Array(columnCount - 1).fill('')],\n [options.subtitle, ...Array(columnCount - 1).fill('')],\n [options.note, ...Array(columnCount - 1).fill('')],\n empty.slice(),\n options.headers,\n ...dataRows\n ];\n const rowData = rawRows.map((row, rowIndex) => {\n let background = COLORS.white;\n let foreground = COLORS.gray;\n let bold = false;\n let fontSize = 10;\n let horizontal = 'LEFT';\n let wrap = 'WRAP';\n if (rowIndex === 0) {\n background = COLORS.blue;\n foreground = COLORS.white;\n bold = true;\n fontSize = 15;\n horizontal = 'LEFT';\n } else if (rowIndex === 1) {\n background = COLORS.mediumBlue;\n foreground = COLORS.white;\n bold = true;\n } else if (rowIndex === 2) {\n background = COLORS.blueBg;\n foreground = COLORS.blue;\n bold = true;\n } else if (rowIndex === 4) {\n background = COLORS.blue;\n foreground = COLORS.white;\n bold = true;\n horizontal = 'CENTER';\n } else if (rowIndex >= 5) {\n background = (rowIndex - 5) % 2 ? COLORS.zebra : COLORS.white;\n if (row[0] === 'Sin diferencias' || row[0] === 'Sin personas fuera de BambooHR') {\n background = COLORS.greenBg;\n foreground = COLORS.green;\n bold = true;\n }\n if (options.kind === 'main' && row[1] === 'Alta') {\n background = COLORS.redBg;\n foreground = COLORS.redText;\n } else if (options.kind === 'main' && row[1] === 'Media') {\n background = COLORS.amberBg;\n foreground = COLORS.amberText;\n } else if (options.kind === 'main' && row[1] === 'Informativa') {\n background = COLORS.blueBg;\n foreground = COLORS.blue;\n }\n }\n return {\n values: row.map((value, columnIndex) => {\n const format = {\n backgroundColor: background,\n textFormat: {\n foregroundColor: foreground,\n bold,\n fontFamily: 'Arial',\n fontSize\n },\n verticalAlignment: 'MIDDLE',\n horizontalAlignment: rowIndex === 4 ? 'CENTER' : (options.moneyColumns.includes(columnIndex) && rowIndex >= 5 ? 'RIGHT' : horizontal),\n wrapStrategy: wrap,\n padding: { top: 5, bottom: 5, left: 7, right: 7 }\n };\n if (rowIndex >= 4) {\n format.borders = {\n top: gridBorder(),\n bottom: gridBorder(),\n left: gridBorder(),\n right: gridBorder()\n };\n }\n if (options.moneyColumns.includes(columnIndex) && rowIndex >= 5 && typeof value === 'number') {\n format.numberFormat = { type: 'CURRENCY', pattern: '$#,##0.0000;[Red]-$#,##0.0000' };\n }\n return { userEnteredValue: valueObject(value), userEnteredFormat: format };\n })\n };\n });\n const rowMetadata = rawRows.map((row, index) => ({ pixelSize: index === 0 ? 38 : index === 3 ? 10 : index === 4 ? 34 : index < 3 ? 27 : 31 }));\n return {\n properties: {\n sheetId: options.sheetId,\n title: options.sheetTitle,\n tabColor: options.tabColor,\n gridProperties: {\n rowCount: Math.max(rawRows.length + 20, 50),\n columnCount,\n frozenRowCount: 5,\n hideGridlines: true\n }\n },\n data: [{\n startRow: 0,\n startColumn: 0,\n rowData,\n rowMetadata,\n columnMetadata: options.widths.map(pixelSize => ({ pixelSize }))\n }],\n merges: [0, 1, 2].map(rowIndex => ({\n sheetId: options.sheetId,\n startRowIndex: rowIndex,\n endRowIndex: rowIndex + 1,\n startColumnIndex: 0,\n endColumnIndex: columnCount\n })),\n basicFilter: {\n range: {\n sheetId: options.sheetId,\n startRowIndex: 4,\n endRowIndex: rawRows.length,\n startColumnIndex: 0,\n endColumnIndex: columnCount\n }\n }\n };\n }\n\n const mainHeaders = ['Tipo de diferencia', 'Severidad', 'DUI Nómina', 'DUI Planilla', 'Nombre Nómina', 'Nombre Planilla', 'Monto Nómina (USD)', 'Monto Planilla (USD)', 'Diferencia (USD)', 'Observación / Acción'];\n const mainRows = findings.map(item => [\n item.tipo,\n item.severidad,\n item.duiNomina,\n item.duiPlanilla,\n item.nombreNomina,\n item.nombrePlanilla,\n item.montoNomina,\n item.montoPlanilla,\n item.diferencia,\n item.accion\n ]);\n const payrollBambooHeaders = ['DUI', 'Nombre en Nómina', 'Proyecto', 'Monto Nómina (USD)', 'Mejor candidato en BambooHR', 'Similitud', 'Resultado automático', 'Estado candidato', 'Ubicación / País', 'Acción'];\n const payrollBambooRows = payrollWithoutBamboo.map(item => [\n item.dui,\n item.name,\n item.project,\n item.amount,\n item.candidateName,\n item.score ? Math.round(item.score * 100) + '%' : '',\n item.method,\n item.candidateStatus,\n uniqueJoin([item.candidateLocation, item.candidateCountry]),\n item.action\n ]);\n const planBambooHeaders = ['DUI', 'Nombre en Planilla', 'Monto Planilla (USD)', 'Mejor candidato en BambooHR', 'Similitud', 'Resultado automático', 'Estado candidato', 'Ubicación / País', 'Acción'];\n const planBambooRows = planWithoutBamboo.map(item => [\n item.dui,\n item.name,\n item.amount,\n item.candidateName,\n item.score ? Math.round(item.score * 100) + '%' : '',\n item.method,\n item.candidateStatus,\n uniqueJoin([item.candidateLocation, item.candidateCountry]),\n item.action\n ]);\n\n const timestamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 16);\n const reportTitle = 'Cruce Seguridad Social El Salvador - ' + periodLabel + ' - ' + timestamp;\n const spreadsheetPayload = {\n properties: {\n title: reportTitle,\n locale: 'es_SV',\n timeZone: 'America/El_Salvador'\n },\n sheets: [\n sheetDefinition({\n sheetId: 0,\n sheetTitle: 'Diferencias',\n title: 'CRUCE DE SEGURIDAD SOCIAL · EL SALVADOR',\n subtitle: 'Período: ' + periodLabel + ' · Nómina vs. Planilla Única',\n note: 'Solo se muestran hallazgos · Identidad/nombres: ' + identityFindings + ' · Montos: ' + amountFindings + ' · Total: ' + findings.length,\n headers: mainHeaders,\n rows: mainRows,\n widths: [210, 95, 115, 115, 190, 190, 130, 130, 125, 330],\n moneyColumns: [6, 7, 8],\n tabColor: COLORS.blue,\n kind: 'main',\n emptyLabel: 'Sin diferencias'\n }),\n sheetDefinition({\n sheetId: 1,\n sheetTitle: 'Nómina sin Bamboo',\n title: 'NÓMINA SIN BAMBOO',\n subtitle: 'Período: ' + periodLabel + ' · Personas de nómina sin coincidencia concluyente en BambooHR',\n note: 'Solo se muestran excepciones · Total: ' + payrollWithoutBamboo.length + ' · Las coincidencias ambiguas requieren revisión manual',\n headers: payrollBambooHeaders,\n rows: payrollBambooRows,\n widths: [115, 210, 150, 130, 210, 85, 145, 110, 170, 310],\n moneyColumns: [3],\n tabColor: COLORS.green,\n kind: 'bamboo',\n emptyLabel: 'Sin personas fuera de BambooHR'\n }),\n sheetDefinition({\n sheetId: 2,\n sheetTitle: 'Planilla sin Bamboo',\n title: 'PLANILLA SIN BAMBOO',\n subtitle: 'Período: ' + periodLabel + ' · Personas de planilla sin coincidencia concluyente en BambooHR',\n note: 'Solo se muestran excepciones · Total: ' + planWithoutBamboo.length + ' · Las coincidencias ambiguas requieren revisión manual',\n headers: planBambooHeaders,\n rows: planBambooRows,\n widths: [115, 220, 135, 220, 85, 145, 110, 180, 330],\n moneyColumns: [2],\n tabColor: COLORS.steel,\n kind: 'bamboo',\n emptyLabel: 'Sin personas fuera de BambooHR'\n })\n ]\n };\n\n const binaryValues = formItem.binary || {};\n const payrollBinary = binaryValues.nomina || Object.values(binaryValues).find(file => /nomina/i.test((file.fileName || '') + '')) || {};\n const planBinary = binaryValues.planilla_seguridad_social || Object.values(binaryValues).find(file => /planilla/i.test((file.fileName || '') + '')) || {};\n\n return [{\n json: {\n spreadsheetPayload,\n summary: {\n periodo: periodLabel,\n periodoArchivo: periodFile,\n correoDestino: 'iaracena@gomezleemarketing.com, msoto@gomezleemarketing.com, iherrera@gomezleemarketing.com, mgomez@gomezleemarketing.com, vparamo@gomezleemarketing.com, asrodriguez@gomezleemarketing.com, jgomez@gomezleemarketing.com, ymadera@gomezleemarketing.com',\n archivoNomina: payrollBinary.fileName || 'Nómina',\n archivoPlanilla: planBinary.fileName || 'Planilla',\n registrosNomina: payrollRecords.length,\n registrosPlanilla: planRecords.length,\n empleadosNomina: payrollGroups.size + payrollMissingDui.length,\n empleadosPlanilla: planGroups.size + planMissingDui.length,\n empleadosBamboo: bambooEmployees.length,\n hallazgosIdentidad: identityFindings,\n hallazgosMontos: amountFindings,\n hallazgosSubcentavo: subcentFindings,\n hallazgosNombres: nameFindings,\n hallazgosAlta: highFindings,\n hallazgosPrincipales: findings.length,\n nominaSinBamboo: payrollWithoutBamboo.length,\n planillaSinBamboo: planWithoutBamboo.length,\n coincidenciasBambooInactivas: bambooMatchStats.inactivos,\n coincidenciasBambooDuiExacto: bambooMatchStats.duiExacto,\n coincidenciasBambooNombreExacto: bambooMatchStats.nombreExacto,\n coincidenciasBambooNombreTipografico: bambooMatchStats.nombreTipografico,\n conflictosDuiBamboo: bambooMatchStats.conflictosDui,\n coincidenciasBambooAmbiguas: bambooMatchStats.ambiguos,\n hallazgosTotales: totalFindings,\n nombreArchivo: 'Cruce_Seguridad_Social_El_Salvador_' + periodFile + '.xlsx'\n }\n }\n }];" }, - "id": "1358629e-a085-42c6-8d94-a7a8fc9da36b", + "id": "63f94660-bf18-49af-aea4-dfcff86b78e8", "name": "Analizar y construir reporte", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 2720, - 800 + 768, + 2864 ], "onError": "continueErrorOutput" }, @@ -1657,13 +1657,13 @@ "parameters": { "jsCode": "const input = $input.first();\n const payload = input.json?.spreadsheetPayload;\n const summary = input.json?.summary || {};\n if (!payload?.sheets?.length) throw new Error('No se encontró la estructura del reporte para generar el Excel.');\n\n function esc(value) {\n return String(value ?? '').replace(/[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F]/g, '').replace(/&/g, '&').replace(//g, '>').replace(/\"/g, '"').replace(/'/g, ''');\n }\n function col(index) {\n let value = index + 1;\n let result = '';\n while (value > 0) {\n const remainder = (value - 1) % 26;\n result = String.fromCharCode(65 + remainder) + result;\n value = Math.floor((value - 1) / 26);\n }\n return result;\n }\n function valueOf(cell) {\n const value = cell?.userEnteredValue || {};\n if ('numberValue' in value) return value.numberValue;\n if ('boolValue' in value) return value.boolValue;\n if ('stringValue' in value) return value.stringValue;\n return '';\n }\n function rgbHex(rgb, fallback) {\n if (!rgb) return fallback;\n const channel = value => Math.max(0, Math.min(255, Math.round(Number(value || 0) * 255))).toString(16).padStart(2, '0').toUpperCase();\n return 'FF' + channel(rgb.red) + channel(rgb.green) + channel(rgb.blue);\n }\n function baseStyle(sheetTitle, rowIndex, values) {\n if (rowIndex === 0) return 1;\n if (rowIndex === 1) return 2;\n if (rowIndex === 2) return 3;\n if (rowIndex === 4) return 4;\n if (rowIndex < 5) return 0;\n if (values[0] === 'Sin diferencias' || values[0] === 'Sin personas fuera de BambooHR') return 10;\n if (sheetTitle === 'Diferencias') {\n if (values[1] === 'Alta') return 7;\n if (values[1] === 'Media') return 8;\n if (values[1] === 'Informativa') return 9;\n }\n return (rowIndex - 5) % 2 ? 6 : 5;\n }\n function moneyStyle(style) {\n return ({ 5: 11, 6: 12, 7: 13, 8: 14, 9: 15, 10: 16 })[style] || style;\n }\n function makeSheetXml(sheet) {\n const grid = sheet.data?.[0] || {};\n const rows = grid.rowData || [];\n const columnCount = sheet.properties?.gridProperties?.columnCount || Math.max(1, ...rows.map(row => row.values?.length || 0));\n const lastColumn = col(columnCount - 1);\n const sheetTitle = sheet.properties?.title || 'Hoja';\n const tabColor = rgbHex(sheet.properties?.tabColor, sheetTitle === 'Diferencias' ? 'FF4F758B' : 'FF6CC24A');\n const widths = (grid.columnMetadata || []).map(meta => Math.max(8, Math.min(60, Number(meta.pixelSize || 100) / 7)));\n const columns = Array.from({ length: columnCount }, (_, index) => '').join('');\n const rowXml = rows.map((row, rowIndex) => {\n const values = Array.from({ length: columnCount }, (_, index) => valueOf(row.values?.[index]));\n const style = baseStyle(sheetTitle, rowIndex, values);\n const height = rowIndex === 0 ? 30 : rowIndex === 3 ? 9 : rowIndex === 4 ? 28 : rowIndex < 3 ? 22 : 24;\n const cells = Array.from({ length: columnCount }, (_, columnIndex) => {\n const cell = row.values?.[columnIndex] || {};\n const value = values[columnIndex];\n const cellStyle = cell.userEnteredFormat?.numberFormat ? moneyStyle(style) : style;\n const reference = col(columnIndex) + (rowIndex + 1);\n if (typeof value === 'number' && Number.isFinite(value)) return '' + value + '';\n if (typeof value === 'boolean') return '' + (value ? 1 : 0) + '';\n return '' + esc(value) + '';\n }).join('');\n return '' + cells + '';\n }).join('');\n const mergeCells = (sheet.merges || []).map(merge => '');\n const merges = mergeCells.length ? '' + mergeCells.join('') + '' : '';\n const lastRow = Math.max(rows.length, 5);\n return '' +\n '' +\n '' +\n '' + columns + '' + rowXml + '' + merges +\n '';\n }\n\n const fills = ['4F758B', '5B7F95', 'D6E8F4', 'FFFFFF', 'F5F5F5', 'FDECEC', 'FFF6E3', 'EEF6E8'].map(hex => '').join('');\n function xf(numFmt, font, fill, border, alignment) {\n return '';\n }\n const xfs = [\n '',\n xf(0, 1, 2, 0, 'horizontal=\"left\" vertical=\"center\"'),\n xf(0, 2, 3, 0, 'horizontal=\"left\" vertical=\"center\"'),\n xf(0, 3, 4, 0, 'horizontal=\"left\" vertical=\"center\" wrapText=\"1\"'),\n xf(0, 2, 2, 1, 'horizontal=\"center\" vertical=\"center\" wrapText=\"1\"'),\n xf(0, 0, 5, 1, 'vertical=\"center\" wrapText=\"1\"'),\n xf(0, 0, 6, 1, 'vertical=\"center\" wrapText=\"1\"'),\n xf(0, 4, 7, 1, 'vertical=\"center\" wrapText=\"1\"'),\n xf(0, 5, 8, 1, 'vertical=\"center\" wrapText=\"1\"'),\n xf(0, 3, 4, 1, 'vertical=\"center\" wrapText=\"1\"'),\n xf(0, 6, 9, 1, 'vertical=\"center\" wrapText=\"1\"'),\n xf(164, 0, 5, 1, 'horizontal=\"right\" vertical=\"center\"'),\n xf(164, 0, 6, 1, 'horizontal=\"right\" vertical=\"center\"'),\n xf(164, 4, 7, 1, 'horizontal=\"right\" vertical=\"center\"'),\n xf(164, 5, 8, 1, 'horizontal=\"right\" vertical=\"center\"'),\n xf(164, 3, 4, 1, 'horizontal=\"right\" vertical=\"center\"'),\n xf(164, 6, 9, 1, 'horizontal=\"right\" vertical=\"center\"')\n ].join('');\n const styles = '' +\n '' +\n '' +\n '' +\n '' +\n '' + fills + '' +\n '' +\n '' + xfs + '';\n\n const sheetNames = payload.sheets.map(sheet => sheet.properties?.title || 'Hoja');\n const overrides = payload.sheets.map((sheet, index) => '').join('');\n const workbookSheets = sheetNames.map((name, index) => '').join('');\n const relationships = payload.sheets.map((sheet, index) => '').join('') +\n '';\n const nowIso = new Date().toISOString();\n const titleParts = sheetNames.map(name => '' + esc(name) + '').join('');\n const files = [\n { name: '[Content_Types].xml', data: '' + overrides + '' },\n { name: '_rels/.rels', data: '' },\n { name: 'docProps/core.xml', data: 'GomezLee Marketingn8n' + esc(summary.nombreArchivo || 'Cruce Seguridad Social El Salvador') + '' + nowIso + '' + nowIso + '' },\n { name: 'docProps/app.xml', data: 'Microsoft ExcelHojas de cálculo' + sheetNames.length + '' + titleParts + 'GomezLee Marketing16.0300' },\n { name: 'xl/workbook.xml', data: '' + workbookSheets + '' },\n { name: 'xl/_rels/workbook.xml.rels', data: '' + relationships + '' },\n { name: 'xl/styles.xml', data: styles }\n ];\n payload.sheets.forEach((sheet, index) => files.push({ name: 'xl/worksheets/sheet' + (index + 1) + '.xml', data: makeSheetXml(sheet) }));\n\n function crc32(buffer) {\n if (!crc32.table) crc32.table = Array.from({ length: 256 }, (_, number) => {\n let value = number;\n for (let bit = 0; bit < 8; bit++) value = (value & 1) ? (0xEDB88320 ^ (value >>> 1)) : (value >>> 1);\n return value >>> 0;\n });\n let crc = 0xFFFFFFFF;\n for (const byte of buffer) crc = crc32.table[(crc ^ byte) & 0xFF] ^ (crc >>> 8);\n return (crc ^ 0xFFFFFFFF) >>> 0;\n }\n function zip(entries) {\n const now = new Date();\n const year = Math.max(1980, now.getFullYear());\n const dosTime = (now.getHours() << 11) | (now.getMinutes() << 5) | Math.floor(now.getSeconds() / 2);\n const dosDate = ((year - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate();\n const localParts = [];\n const centralParts = [];\n let offset = 0;\n for (const entry of entries) {\n const name = Buffer.from(entry.name, 'utf8');\n const data = Buffer.from(entry.data, 'utf8');\n const crc = crc32(data);\n const local = Buffer.alloc(30);\n local.writeUInt32LE(0x04034B50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0x0800, 6); local.writeUInt16LE(0, 8);\n local.writeUInt16LE(dosTime, 10); local.writeUInt16LE(dosDate, 12); local.writeUInt32LE(crc, 14); local.writeUInt32LE(data.length, 18); local.writeUInt32LE(data.length, 22); local.writeUInt16LE(name.length, 26);\n const localEntry = Buffer.concat([local, name, data]);\n localParts.push(localEntry);\n const central = Buffer.alloc(46);\n central.writeUInt32LE(0x02014B50, 0); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6); central.writeUInt16LE(0x0800, 8); central.writeUInt16LE(0, 10);\n central.writeUInt16LE(dosTime, 12); central.writeUInt16LE(dosDate, 14); central.writeUInt32LE(crc, 16); central.writeUInt32LE(data.length, 20); central.writeUInt32LE(data.length, 24); central.writeUInt16LE(name.length, 28); central.writeUInt32LE(offset, 42);\n centralParts.push(Buffer.concat([central, name]));\n offset += localEntry.length;\n }\n const directory = Buffer.concat(centralParts);\n const end = Buffer.alloc(22);\n end.writeUInt32LE(0x06054B50, 0); end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10); end.writeUInt32LE(directory.length, 12); end.writeUInt32LE(offset, 16);\n return Buffer.concat([...localParts, directory, end]);\n }\n\n const workbook = zip(files);\n const fileName = summary.nombreArchivo || 'Cruce_Seguridad_Social_El_Salvador.xlsx';\n return [{\n json: { ...summary },\n binary: { reporteExcel: { data: workbook.toString('base64'), mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', fileName, fileExtension: 'xlsx' } }\n }];" }, - "id": "f017b8e9-f5b4-4dac-98c5-223cdfdb4dec", + "id": "c2be1797-f1d3-45f6-a020-210d6c2c4a7e", "name": "Generar Excel", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 3184, - 816 + 1232, + 2880 ], "onError": "continueErrorOutput" }, @@ -1686,13 +1686,13 @@ "simplifyOutput": false } }, - "id": "57954526-8ffd-4856-94bb-3eaa1b357c77", + "id": "a74f4640-cac0-4b94-b5a8-7cbdd83d4b6b", "name": "Subir reporte a Google Drive", "type": "n8n-nodes-base.googleDrive", "typeVersion": 3, "position": [ - 3456, - 656 + 1504, + 2720 ], "executeOnce": true, "retryOnFail": true, @@ -1707,16 +1707,16 @@ { "parameters": { "mode": "raw", - "jsonOutput": "={{ (() => {\nconst recipients=[{\"name\":\"Isaac Aracena\",\"email\":\"iaracena@gomezleemarketing.com\"},{\"name\":\"Mati Soto\",\"email\":\"msoto@gomezleemarketing.com\"},{\"name\":\"Iveth Herrera\",\"email\":\"iherrera@gomezleemarketing.com\"},{\"name\":\"Máximo Gomez\",\"email\":\"mgomez@gomezleemarketing.com\"},{\"name\":\"Viviana Paramo\",\"email\":\"vparamo@gomezleemarketing.com\"},{\"name\":\"Ada Rodriguez\",\"email\":\"asrodriguez@gomezleemarketing.com\"}];\nconst norm=value=>String(value??'').trim().toLowerCase();\nconst file=$('Subir reporte a Google Drive').first().json||{};\nconst owners=Array.isArray(file.owners)?file.owners:[];\nconst permissions=Array.isArray(file.permissions)?file.permissions:[];\nconst editable=new Set(['owner','writer','organizer','fileOrganizer']);\nconst hasAccess=recipient=>{\n const email=norm(recipient.email);\n return owners.some(o=>!o?.deleted&&norm(o?.emailAddress)===email)||\n permissions.some(p=>!p?.deleted&&norm(p?.emailAddress)===email&&editable.has(String(p?.role||'')));\n};\nconst recipientsToShare=recipients.filter(r=>!hasAccess(r));\nreturn {recipients,recipientsToShare,pendingAccessCount:recipientsToShare.length,existingAccessCount:recipients.length-recipientsToShare.length};\n})() }}", + "jsonOutput": "={{ (() => {\nconst recipients=[{\"name\":\"Isaac Aracena\",\"email\":\"iaracena@gomezleemarketing.com\"},{\"name\":\"Mati Soto\",\"email\":\"msoto@gomezleemarketing.com\"},{\"name\":\"Iveth Herrera\",\"email\":\"iherrera@gomezleemarketing.com\"},{\"name\":\"Máximo Gomez\",\"email\":\"mgomez@gomezleemarketing.com\"},{\"name\":\"Viviana Paramo\",\"email\":\"vparamo@gomezleemarketing.com\"},{\"name\":\"Ada Rodriguez\",\"email\":\"asrodriguez@gomezleemarketing.com\"},{\"name\":\"Yanelly Madera\",\"email\":\"ymadera@gomezleemarketing.com\"}];\nconst norm=value=>String(value??'').trim().toLowerCase();\nconst file=$('Subir reporte a Google Drive').first().json||{};\nconst owners=Array.isArray(file.owners)?file.owners:[];\nconst permissions=Array.isArray(file.permissions)?file.permissions:[];\nconst editable=new Set(['owner','writer','organizer','fileOrganizer']);\nconst hasAccess=recipient=>{\n const email=norm(recipient.email);\n return owners.some(o=>!o?.deleted&&norm(o?.emailAddress)===email)||\n permissions.some(p=>!p?.deleted&&norm(p?.emailAddress)===email&&editable.has(String(p?.role||'')));\n};\nconst recipientsToShare=recipients.filter(r=>!hasAccess(r));\nreturn {recipients,recipientsToShare,pendingAccessCount:recipientsToShare.length,existingAccessCount:recipients.length-recipientsToShare.length};\n})() }}", "options": {} }, - "id": "1729e8fc-105c-411a-bf80-1be1dfb14614", + "id": "a1314c39-c438-420f-a7aa-7ae86118f205", "name": "Preparar lista de destinatarios", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [ - 3712, - 560 + 1760, + 2624 ], "onError": "continueErrorOutput" }, @@ -1744,13 +1744,13 @@ }, "options": {} }, - "id": "9743bb45-f09b-454e-b787-73ee450b69b3", + "id": "f979be33-7c07-46a3-be77-7dcd0e0e4710", "name": "¿Hay accesos pendientes?", "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ - 3952, - 560 + 2000, + 2624 ], "onError": "continueErrorOutput" }, @@ -1761,13 +1761,13 @@ "destinationFieldName": "recipient" } }, - "id": "3d74bea6-b858-4ae2-b1ed-8d46ece27ea5", + "id": "772c8da4-7d5b-4a0f-b9c8-be9b8573e2e0", "name": "Separar destinatarios", "type": "n8n-nodes-base.splitOut", "typeVersion": 1, "position": [ - 4192, - 480 + 2240, + 2544 ], "onError": "continueErrorOutput" }, @@ -1784,13 +1784,13 @@ "timeout": 300000 } }, - "id": "94d1d384-81f3-412e-907c-c888d92cef81", + "id": "87beafcc-62d5-4006-93f1-234fe3f38190", "name": "Crear permiso de editor por API", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ - 4432, - 480 + 2480, + 2544 ], "retryOnFail": true, "credentials": { @@ -1807,13 +1807,13 @@ "destinationFieldName": "permisosCreados", "options": {} }, - "id": "b114e1f6-fc06-49fb-ac36-52c2e0893428", + "id": "ec561a34-2b2e-47ad-86c7-1562995a7e01", "name": "Agrupar permisos creados", "type": "n8n-nodes-base.aggregate", "typeVersion": 1, "position": [ - 4672, - 480 + 2720, + 2544 ], "onError": "continueErrorOutput" }, @@ -1842,13 +1842,13 @@ }, "options": {} }, - "id": "d1679655-fc24-4f4c-b875-68c8bc002c24", + "id": "5d5ce711-4832-4ec3-be29-fcbbe0ec60df", "name": "¿Se crearon todos los permisos?", "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ - 4928, - 480 + 2976, + 2544 ], "onError": "continueErrorOutput" }, @@ -1879,13 +1879,13 @@ "timeout": 300000 } }, - "id": "480bcf25-fce8-4c7e-821b-608a879277d8", + "id": "baeeb2bc-4a96-4060-9b8b-e57b44c4b2ee", "name": "Verificar accesos en Google Drive", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ - 5168, - 400 + 3216, + 2464 ], "executeOnce": true, "retryOnFail": true, @@ -1922,13 +1922,13 @@ }, "options": {} }, - "id": "45086ef1-fb80-4c04-8860-681fbe2a1bd4", + "id": "bb1b8118-f9a7-419a-9e00-870ebe710007", "name": "¿Están confirmados los siete accesos?", "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ - 5424, - 400 + 3472, + 2464 ], "onError": "continueErrorOutput" }, @@ -1938,13 +1938,13 @@ "jsonOutput": "={{ ({ accesosConfirmados: $('Preparar lista de destinatarios').first().json.recipients.length, permisosNuevos: $('Preparar lista de destinatarios').first().json.pendingAccessCount, accesosPrevios: $('Preparar lista de destinatarios').first().json.existingAccessCount, verificacionDrive: true }) }}", "options": {} }, - "id": "e6dafe32-01f9-44d8-8130-25152709d028", + "id": "20c03975-8e24-4e85-b098-4d441462aa7f", "name": "Confirmar acceso de siete personas", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [ - 5424, - 752 + 3472, + 2816 ], "executeOnce": true, "onError": "continueErrorOutput" @@ -1957,13 +1957,13 @@ "includeUnpaired": false } }, - "id": "5e783156-45dc-41e1-a78a-ce90fa8d87a9", + "id": "c6bf6d2a-38eb-4bd7-ad8e-1e61a0ba914a", "name": "Combinar reporte y acceso", "type": "n8n-nodes-base.merge", "typeVersion": 3.2, "position": [ - 5184, - 816 + 3232, + 2880 ] }, { @@ -1983,13 +1983,13 @@ "senderName": "GomezLee Marketing" } }, - "id": "66c9e023-50e9-4c8b-b003-2d8c4780e7c3", + "id": "8e033ad0-924c-4490-89a5-f8ff930179a0", "name": "Enviar reporte Excel GLM", "type": "n8n-nodes-base.gmail", "typeVersion": 2.2, "position": [ - 5680, - 864 + 3728, + 2928 ], "executeOnce": true, "retryOnFail": true, @@ -2008,13 +2008,13 @@ "respondWith": "showText", "responseText": "={{ (function successExpressionRuntime() {\n const s = $('Combinar reporte y acceso').item.json || {};\n const report = $('Subir reporte a Google Drive').item.json || {};\n const esc = value => String(value ?? '').replace(/[&<>\"']/g, character => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' })[character]);\n const total = Number(s.hallazgosTotales || 0);\n const status = total\n ? 'El cruce terminó y hay ' + total + ' hallazgo(s) para revisar.'\n : 'El cruce terminó sin diferencias.';\n const reportUrl = report.webViewLink || ('https://drive.google.com/open?id=' + (report.id || ''));\n return '
' +\n '
SEGURIDAD SOCIAL · EL SALVADOR
' +\n '

Proceso completado

' +\n '

' + esc(s.periodo) + '

' +\n '

' + esc(status) +\n ' El Excel fue enviado a las siete personas autorizadas y quedó disponible en Google Drive con acceso de edición para cada una.

' +\n 'Abrir reporte' +\n 'Realizar otro cruce' +\n '
';\n})() }}" }, - "id": "6a51a26e-7c88-451c-b966-6f6dec925f7a", + "id": "47863bc3-cc73-47fa-8208-22bc9349feac", "name": "Mostrar resultado y repetir", "type": "n8n-nodes-base.form", "typeVersion": 2.5, "position": [ - 6016, - 768 + 4064, + 2832 ], "webhookId": "2bbb060d-09d8-40c1-bcfa-6fb7c8c3d3da" }, @@ -2024,13 +2024,13 @@ "respondWith": "showText", "responseText": "={{ (() => {\nconst source=$json?.error?.message??$json?.error??$json?.message??'Error no identificado';\nlet detail; try{detail=typeof source==='string'?source:JSON.stringify(source);}catch{detail=String(source);}\ndetail=String(detail||'Error no identificado').replace(/\\s+/g,' ').trim().slice(0,300);\nconst esc=value=>String(value??'').replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''})[c]);\nconst isDrive=/forbidden|permission|permiso|share|compart/i.test(detail);\nconst guidance=isDrive?'Google Drive no pudo confirmar uno de los accesos de edición. Verifica que la cuenta conectada pueda compartir el archivo con el destinatario indicado y vuelve a intentarlo.':'Revisa el detalle mostrado abajo y vuelve a intentarlo. Si el problema persiste, consulta esta ejecución en n8n.';\nreturn '
SEGURIDAD SOCIAL · EL SALVADOR

No se pudo completar el cruce

'+esc(guidance)+'

'+esc(detail)+'
Volver al formulario
';\n})() }}" }, - "id": "30d59ab7-e53a-4533-92dd-c9f4786cb37e", + "id": "3702f1d1-8392-46e1-b22c-ec4e150ce4e0", "name": "Mostrar error y reintentar", "type": "n8n-nodes-base.form", "typeVersion": 2.5, "position": [ - 5584, - 1248 + 3632, + 3312 ], "webhookId": "b8aa8b32-d69c-45a5-aa33-dfe9040bb2f1" }, @@ -2040,13 +2040,13 @@ "jsonOutput": "={{ (() => { const norm=value=>String(value??'').trim().toLowerCase(); const recipients=$('Preparar lista de destinatarios').first().json.recipients||[]; const permissions=Array.isArray($json.permissions)?$json.permissions:[]; const editable=new Set(['owner','writer','organizer','fileOrganizer']); const missing=recipients.filter(recipient=>!permissions.some(permission=>!permission?.deleted&&norm(permission?.emailAddress)===norm(recipient.email)&&editable.has(String(permission?.role||'')))); return { error: 'Google Drive respondió, pero no confirmó acceso de edición para: '+missing.map(person=>person.name+' ('+person.email+')').join(', ')+'. No se envió el correo.' }; })() }}", "options": {} }, - "id": "a8f07990-7948-4037-8ece-16fc38f78991", + "id": "4ed09a3f-edaa-4ade-be4d-d14c96d0603f", "name": "Preparar accesos faltantes", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [ - 5680, - 528 + 3728, + 2592 ], "executeOnce": true, "onError": "continueErrorOutput" @@ -2057,13 +2057,13 @@ "jsonOutput": "={{ ({ error: 'Google Drive no confirmó la creación de todos los permisos de edición requeridos. No se envió el correo para evitar una entrega incompleta.' }) }}", "options": {} }, - "id": "a135fd1f-b61f-4e45-be6c-03021b7d3378", + "id": "3838e0be-1806-4323-9454-80347f7e49ac", "name": "Preparar error de permisos", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [ - 5168, - 576 + 3216, + 2640 ], "executeOnce": true, "onError": "continueErrorOutput" @@ -2075,13 +2075,13 @@ "width": 1564, "color": 4 }, - "id": "fc9bcda7-c796-4f9e-aa23-426e11737463", + "id": "7052bd0d-7c7e-41b5-8a3e-850958e281d3", "name": "Guía · Formulario", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ - 496, - 432 + -1472, + 2496 ] }, { @@ -2091,13 +2091,13 @@ "width": 812, "color": 5 }, - "id": "380a2c79-217e-4de4-b5dd-14f2c1a7a259", + "id": "645a9be9-dd8c-4703-9678-5355d5178cf2", "name": "Guía · Cruces y BambooHR", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ - 2160, - 672 + 208, + 2736 ] }, { @@ -2107,13 +2107,13 @@ "width": 3132, "color": 3 }, - "id": "6d234122-5e43-4473-a11c-eedcb76e31eb", + "id": "7b7ebb08-f204-4b5f-843e-f74b4321b945", "name": "Guía · Reporte y entrega", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ - 3104, - 288 + 1152, + 2352 ] }, { @@ -2123,13 +2123,13 @@ "width": 420, "color": 2 }, - "id": "dedc732b-cda5-4d60-81fc-bfd660445a71", + "id": "f21b71ea-4f59-447f-aff8-053081d0ab56", "name": "Guía · Manejo de errores", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ - 5424, - 1088 + 3472, + 3152 ] }, { @@ -2144,13 +2144,13 @@ "sheetName": "={{ ['ENERO','FEBRERO','MARZO','ABRIL','MAYO','JUNIO','JULIO','AGOSTO','SEPTIEMBRE','OCTUBRE','NOVIEMBRE','DICIEMBRE'][Number($items('Formulario de cruce')[0].json.mes)-1] }}" } }, - "id": "1f75b85f-a535-41fc-a09c-f4975d2b8311", + "id": "7c10293b-da0a-4d8e-abf9-f0bb69feb89f", "name": "Extraer Nómina · hoja del mes", "type": "n8n-nodes-base.extractFromFile", "typeVersion": 1.1, "position": [ - 1136, - 720 + -832, + 2784 ], "onError": "continueErrorOutput" }, @@ -2166,13 +2166,13 @@ "sheetName": "Nómina" } }, - "id": "a86eaafa-db0f-46f9-97df-c59f2a707478", + "id": "576589f4-9c78-4e65-a880-256df59a985f", "name": "Extraer Nómina · hoja Nómina", "type": "n8n-nodes-base.extractFromFile", "typeVersion": 1.1, "position": [ - 1168, - 944 + -800, + 3008 ], "onError": "continueErrorOutput" }, @@ -2180,13 +2180,13 @@ "parameters": { "jsCode": "const rows = $input.all();\nif (!rows.length) {\n throw new Error(\n 'No se encontraron registros en la hoja de nómina seleccionada.'\n );\n}\n\nfunction normalize(value) {\n return String(value ?? '')\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toUpperCase()\n .replace(/[^A-Z0-9]+/g, ' ')\n .trim();\n}\n\nconst headers = new Set();\nfor (const item of rows.slice(0, 25)) {\n for (const key of Object.keys(item.json || {})) {\n headers.add(normalize(key));\n }\n}\n\nfunction hasAny(patterns) {\n return [...headers].some(header =>\n patterns.some(pattern => pattern.test(header))\n );\n}\n\nconst hasDui = hasAny([/^DUI$/, /DOCUMENTO.*AFILIADO/]);\nconst hasName = hasAny([\n /^COLABORADOR$/,\n /^EMPLEADO$/,\n /^NOMBRE$/,\n /NOMBRE.*COLABORADOR/\n]);\nconst hasAmount = hasAny([\n /SUB TOTAL PERCIBIDO/,\n /TOTAL PERCIBIDO/,\n /SALARIO PERCIBIDO/\n]);\n\nif (!hasDui || !hasName || !hasAmount) {\n throw new Error(\n 'La hoja encontrada no parece ser la nómina de El Salvador: ' +\n 'debe contener DUI, nombre del colaborador y total percibido.'\n );\n}\n\nreturn rows;" }, - "id": "d018c32f-57a6-4f13-9a01-4a7be1c5ab5d", + "id": "9612b22d-6420-4c3d-ac90-78588f4c850d", "name": "Extraer Nómina", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 1344, - 656 + -624, + 2720 ], "onError": "continueErrorOutput" }, @@ -2194,13 +2194,13 @@ "parameters": { "jsCode": "const source = $items('Preparar archivo Nómina')[0];\nif (!source?.binary?.data) {\n throw new Error('No fue posible recuperar el archivo binario original de la nómina para continuar con la detección automática de hojas.');\n}\nreturn [{\n json: {\n ...(source.json || {}),\n intentoHoja: 'hoja del mes seleccionada'\n },\n binary: {\n data: source.binary.data\n }\n}];" }, - "id": "f1a0d4b2-2820-45a5-8238-fa0a34c4c784", + "id": "de2a4370-6700-4769-99cd-6c649e484ab0", "name": "Restaurar binario · intento hoja del mes", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 944, - 736 + -1024, + 2800 ], "onError": "continueErrorOutput" }, @@ -2208,13 +2208,13 @@ "parameters": { "jsCode": "const source = $items('Preparar archivo Nómina')[0];\nif (!source?.binary?.data) {\n throw new Error('No fue posible recuperar el archivo binario original de la nómina para continuar con la detección automática de hojas.');\n}\nreturn [{\n json: {\n ...(source.json || {}),\n intentoHoja: 'hoja Nómina con tilde'\n },\n binary: {\n data: source.binary.data\n }\n}];" }, - "id": "84004e83-b649-4871-b83d-9b0f490541ed", + "id": "f8d21eaa-0be8-4294-b4f4-3a1c5c12ad5b", "name": "Restaurar binario · intento hoja Nómina", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 960, - 960 + -1008, + 3024 ], "onError": "continueErrorOutput" } @@ -2738,15 +2738,15 @@ } }, "authors": "Isaac Aracena", - "name": "Version 0ceb0ca3", + "name": "Version ff877274", "description": "", "autosaved": true, "workflowPublishHistory": [ { - "createdAt": "2026-08-07T13:18:09.315Z", - "id": 4288, + "createdAt": "2026-08-20T12:23:27.103Z", + "id": 5014, "workflowId": "wCud4hOciMao0Ab3", - "versionId": "0ceb0ca3-f520-4202-ab01-c299878c6ce9", + "versionId": "ff877274-6391-43ce-a1f6-85ff5e47f25a", "event": "activated", "userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029" }