Files
cruces-seguridad-social/Cruce de Seguridad Social - El Salvador.json

1366 lines
141 KiB
JSON

{
"name": "Cruce de Seguridad Social - El Salvador",
"nodes": [
{
"parameters": {
"formTitle": "Cruce de Seguridad Social · El Salvador",
"formDescription": "<div style=\"font-family:Arial,Helvetica,sans-serif\"><div style=\"color:#6CC24A;font-size:11px;font-weight:bold;letter-spacing:1.6px;margin-bottom:8px\">GOMEZLEE MARKETING</div><p style=\"margin:0;color:#4F758B;line-height:1.55\">Carga la nómina y la Planilla Única del mismo período. El proceso validará identidad, DUI, montos y presencia en BambooHR. El Excel con solo las diferencias se enviará a las siete personas autorizadas y quedará disponible en Google Drive con acceso de edición para cada una.</p></div>",
"formFields": {
"values": [
{
"fieldLabel": "Mes a validar",
"fieldType": "dropdown",
"fieldName": "mes",
"defaultValue": "={{ $now.toFormat(\"MM\") }}",
"fieldOptions": {
"values": [
{
"option": "01"
},
{
"option": "02"
},
{
"option": "03"
},
{
"option": "04"
},
{
"option": "05"
},
{
"option": "06"
},
{
"option": "07"
},
{
"option": "08"
},
{
"option": "09"
},
{
"option": "10"
},
{
"option": "11"
},
{
"option": "12"
}
]
},
"requiredField": true
},
{
"fieldLabel": "Año a validar",
"fieldType": "number",
"fieldName": "anio",
"defaultValue": "={{ $now.year }}",
"requiredField": true
},
{
"fieldLabel": "Nómina",
"fieldType": "file",
"fieldName": "nomina",
"multipleFiles": false,
"acceptFileTypes": ".xlsx,.xlsm",
"requiredField": true
},
{
"fieldLabel": "Planilla de Seguridad Social",
"fieldType": "file",
"fieldName": "planilla_seguridad_social",
"multipleFiles": false,
"acceptFileTypes": ".xlsx,.xlsm",
"requiredField": true
}
]
},
"responseMode": "lastNode",
"options": {
"appendAttribution": false,
"buttonLabel": "Procesar cruce",
"path": "ef422914-fb5b-4f33-8283-bc2d2ea9abfc",
"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",
"name": "Formulario de cruce",
"type": "n8n-nodes-base.formTrigger",
"typeVersion": 2.5,
"position": [
576,
768
],
"webhookId": "ef422914-fb5b-4f33-8283-bc2d2ea9abfc"
},
{
"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 }];"
},
"id": "a165b03b-eb8e-45c1-8694-0450a28ff20e",
"name": "Preparar archivo Nómina",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
752,
624
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"operation": "xlsx",
"options": {
"headerRow": true,
"includeEmptyCells": true,
"range": "A1:P100000",
"rawData": false,
"readAsString": false,
"sheetName": "Nomina"
}
},
"id": "27f85e3c-bac3-4ad0-adf7-ed6468f13b1a",
"name": "Extraer Nómina · hoja Nomina",
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
1088,
512
],
"onError": "continueErrorOutput"
},
{
"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",
"name": "Preparar archivo Planilla",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1600,
656
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"operation": "xlsx",
"options": {
"headerRow": true,
"includeEmptyCells": true,
"range": "A4:V100000",
"rawData": false,
"readAsString": false,
"sheetName": "PLANILLA_UNICA"
}
},
"id": "c6b2e409-97da-4abf-b525-c9198bf48579",
"name": "Extraer Planilla",
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
1856,
656
],
"onError": "continueErrorOutput"
},
{
"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",
"name": "Validar lecturas",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2208,
800
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"method": "POST",
"url": "https://glm.bamboohr.com/api/v1/reports/custom?format=JSON&onlyCurrent=false",
"authentication": "genericCredentialType",
"genericAuthType": "httpBasicAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Accept",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": {
"title": "Cruce de Seguridad Social El Salvador - Directorio completo",
"fields": [
"firstName",
"middleName",
"lastName",
"displayName",
"department",
"division",
"location",
"customPosicion-Cliente",
"hireDate",
"originalHireDate",
"status",
"employeeNumber"
]
},
"options": {
"response": {
"response": {
"responseFormat": "json"
}
},
"timeout": 300000
}
},
"id": "8e202b5d-0f0b-4228-acc7-43f06c0d79e0",
"name": "Obtener reporte completo BambooHR",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
2464,
800
],
"executeOnce": true,
"retryOnFail": true,
"credentials": {
"httpBasicAuth": {
"id": "7VrpNZ2jBLmiJ35q",
"name": "BambooHR GLM Full Access"
}
},
"onError": "continueErrorOutput"
},
{
"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 }];"
},
"id": "1358629e-a085-42c6-8d94-a7a8fc9da36b",
"name": "Analizar y construir reporte",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2720,
800
],
"onError": "continueErrorOutput"
},
{
"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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;').replace(/'/g, '&apos;');\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) => '<col min=\"' + (index + 1) + '\" max=\"' + (index + 1) + '\" width=\"' + (widths[index] || 15).toFixed(2) + '\" customWidth=\"1\"/>').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 '<c r=\"' + reference + '\" s=\"' + cellStyle + '\"><v>' + value + '</v></c>';\n if (typeof value === 'boolean') return '<c r=\"' + reference + '\" s=\"' + cellStyle + '\" t=\"b\"><v>' + (value ? 1 : 0) + '</v></c>';\n return '<c r=\"' + reference + '\" s=\"' + cellStyle + '\" t=\"inlineStr\"><is><t xml:space=\"preserve\">' + esc(value) + '</t></is></c>';\n }).join('');\n return '<row r=\"' + (rowIndex + 1) + '\" ht=\"' + height + '\" customHeight=\"1\">' + cells + '</row>';\n }).join('');\n const mergeCells = (sheet.merges || []).map(merge => '<mergeCell ref=\"' + col(merge.startColumnIndex || 0) + ((merge.startRowIndex || 0) + 1) + ':' + col((merge.endColumnIndex || 1) - 1) + (merge.endRowIndex || 1) + '\"/>');\n const merges = mergeCells.length ? '<mergeCells count=\"' + mergeCells.length + '\">' + mergeCells.join('') + '</mergeCells>' : '';\n const lastRow = Math.max(rows.length, 5);\n return '<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>' +\n '<worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"><sheetPr><tabColor rgb=\"' + tabColor + '\"/><pageSetUpPr fitToPage=\"1\"/></sheetPr>' +\n '<dimension ref=\"A1:' + lastColumn + lastRow + '\"/><sheetViews><sheetView showGridLines=\"0\" workbookViewId=\"0\"><pane ySplit=\"5\" topLeftCell=\"A6\" activePane=\"bottomLeft\" state=\"frozen\"/><selection pane=\"bottomLeft\" activeCell=\"A6\" sqref=\"A6\"/></sheetView></sheetViews>' +\n '<sheetFormatPr defaultRowHeight=\"18\"/><cols>' + columns + '</cols><sheetData>' + rowXml + '</sheetData><autoFilter ref=\"A5:' + lastColumn + lastRow + '\"/>' + merges +\n '<printOptions horizontalCentered=\"0\" verticalCentered=\"0\"/><pageMargins left=\"0.25\" right=\"0.25\" top=\"0.45\" bottom=\"0.45\" header=\"0.2\" footer=\"0.2\"/><pageSetup orientation=\"landscape\" fitToWidth=\"1\" fitToHeight=\"0\" paperSize=\"9\"/></worksheet>';\n }\n\n const fills = ['4F758B', '5B7F95', 'D6E8F4', 'FFFFFF', 'F5F5F5', 'FDECEC', 'FFF6E3', 'EEF6E8'].map(hex => '<fill><patternFill patternType=\"solid\"><fgColor rgb=\"FF' + hex + '\"/><bgColor indexed=\"64\"/></patternFill></fill>').join('');\n function xf(numFmt, font, fill, border, alignment) {\n return '<xf numFmtId=\"' + numFmt + '\" fontId=\"' + font + '\" fillId=\"' + fill + '\" borderId=\"' + border + '\" xfId=\"0\" applyNumberFormat=\"' + (numFmt ? 1 : 0) + '\" applyFont=\"1\" applyFill=\"1\" applyBorder=\"' + (border ? 1 : 0) + '\" applyAlignment=\"1\"><alignment ' + alignment + '/></xf>';\n }\n const xfs = [\n '<xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\"/>',\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 = '<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><styleSheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">' +\n '<numFmts count=\"1\"><numFmt numFmtId=\"164\" formatCode=\"$#,##0.0000;[Red]-$#,##0.0000\"/></numFmts><fonts count=\"7\">' +\n '<font><sz val=\"10\"/><color rgb=\"FF4A4A4A\"/><name val=\"Arial\"/><family val=\"2\"/></font><font><b/><sz val=\"16\"/><color rgb=\"FFFFFFFF\"/><name val=\"Arial\"/><family val=\"2\"/></font>' +\n '<font><b/><sz val=\"10\"/><color rgb=\"FFFFFFFF\"/><name val=\"Arial\"/><family val=\"2\"/></font><font><b/><sz val=\"10\"/><color rgb=\"FF4F758B\"/><name val=\"Arial\"/><family val=\"2\"/></font>' +\n '<font><sz val=\"10\"/><color rgb=\"FF9C2F2F\"/><name val=\"Arial\"/><family val=\"2\"/></font><font><sz val=\"10\"/><color rgb=\"FF8A5A00\"/><name val=\"Arial\"/><family val=\"2\"/></font>' +\n '<font><b/><sz val=\"10\"/><color rgb=\"FF347326\"/><name val=\"Arial\"/><family val=\"2\"/></font></fonts><fills count=\"10\"><fill><patternFill patternType=\"none\"/></fill><fill><patternFill patternType=\"gray125\"/></fill>' + fills + '</fills>' +\n '<borders count=\"2\"><border><left/><right/><top/><bottom/><diagonal/></border><border><left style=\"thin\"><color rgb=\"FFD0D0D0\"/></left><right style=\"thin\"><color rgb=\"FFD0D0D0\"/></right><top style=\"thin\"><color rgb=\"FFD0D0D0\"/></top><bottom style=\"thin\"><color rgb=\"FFD0D0D0\"/></bottom><diagonal/></border></borders>' +\n '<cellStyleXfs count=\"1\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/></cellStyleXfs><cellXfs count=\"17\">' + xfs + '</cellXfs><cellStyles count=\"1\"><cellStyle name=\"Normal\" xfId=\"0\" builtinId=\"0\"/></cellStyles><dxfs count=\"0\"/><tableStyles count=\"0\" defaultTableStyle=\"TableStyleMedium2\" defaultPivotStyle=\"PivotStyleLight16\"/></styleSheet>';\n\n const sheetNames = payload.sheets.map(sheet => sheet.properties?.title || 'Hoja');\n const overrides = payload.sheets.map((sheet, index) => '<Override PartName=\"/xl/worksheets/sheet' + (index + 1) + '.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml\"/>').join('');\n const workbookSheets = sheetNames.map((name, index) => '<sheet name=\"' + esc(name) + '\" sheetId=\"' + (index + 1) + '\" r:id=\"rId' + (index + 1) + '\"/>').join('');\n const relationships = payload.sheets.map((sheet, index) => '<Relationship Id=\"rId' + (index + 1) + '\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet\" Target=\"worksheets/sheet' + (index + 1) + '.xml\"/>').join('') +\n '<Relationship Id=\"rId' + (payload.sheets.length + 1) + '\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles\" Target=\"styles.xml\"/>';\n const nowIso = new Date().toISOString();\n const titleParts = sheetNames.map(name => '<vt:lpstr>' + esc(name) + '</vt:lpstr>').join('');\n const files = [\n { name: '[Content_Types].xml', data: '<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/><Default Extension=\"xml\" ContentType=\"application/xml\"/><Override PartName=\"/xl/workbook.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml\"/>' + overrides + '<Override PartName=\"/xl/styles.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml\"/><Override PartName=\"/docProps/core.xml\" ContentType=\"application/vnd.openxmlformats-package.core-properties+xml\"/><Override PartName=\"/docProps/app.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.extended-properties+xml\"/></Types>' },\n { name: '_rels/.rels', data: '<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" Target=\"xl/workbook.xml\"/><Relationship Id=\"rId2\" Type=\"http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties\" Target=\"docProps/core.xml\"/><Relationship Id=\"rId3\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties\" Target=\"docProps/app.xml\"/></Relationships>' },\n { name: 'docProps/core.xml', data: '<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><cp:coreProperties xmlns:cp=\"http://schemas.openxmlformats.org/package/2006/metadata/core-properties\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"><dc:creator>GomezLee Marketing</dc:creator><cp:lastModifiedBy>n8n</cp:lastModifiedBy><dc:title>' + esc(summary.nombreArchivo || 'Cruce Seguridad Social El Salvador') + '</dc:title><dcterms:created xsi:type=\"dcterms:W3CDTF\">' + nowIso + '</dcterms:created><dcterms:modified xsi:type=\"dcterms:W3CDTF\">' + nowIso + '</dcterms:modified></cp:coreProperties>' },\n { name: 'docProps/app.xml', data: '<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><Properties xmlns=\"http://schemas.openxmlformats.org/officeDocument/2006/extended-properties\" xmlns:vt=\"http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes\"><Application>Microsoft Excel</Application><HeadingPairs><vt:vector size=\"2\" baseType=\"variant\"><vt:variant><vt:lpstr>Hojas de cálculo</vt:lpstr></vt:variant><vt:variant><vt:i4>' + sheetNames.length + '</vt:i4></vt:variant></vt:vector></HeadingPairs><TitlesOfParts><vt:vector size=\"' + sheetNames.length + '\" baseType=\"lpstr\">' + titleParts + '</vt:vector></TitlesOfParts><Company>GomezLee Marketing</Company><AppVersion>16.0300</AppVersion></Properties>' },\n { name: 'xl/workbook.xml', data: '<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><workbook xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"><bookViews><workbookView xWindow=\"0\" yWindow=\"0\" windowWidth=\"24000\" windowHeight=\"12000\"/></bookViews><sheets>' + workbookSheets + '</sheets><calcPr calcId=\"191029\" fullCalcOnLoad=\"1\"/></workbook>' },\n { name: 'xl/_rels/workbook.xml.rels', data: '<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">' + relationships + '</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",
"name": "Generar Excel",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
3184,
816
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"inputDataFieldName": "reporteExcel",
"name": "={{ $(\"Generar Excel\").item.json.nombreArchivo }}",
"driveId": {
"__rl": true,
"mode": "list",
"value": "My Drive"
},
"folderId": {
"__rl": true,
"mode": "list",
"value": "root",
"cachedResultName": "/ (Root folder)"
},
"options": {
"simplifyOutput": false
}
},
"id": "57954526-8ffd-4856-94bb-3eaa1b357c77",
"name": "Subir reporte a Google Drive",
"type": "n8n-nodes-base.googleDrive",
"typeVersion": 3,
"position": [
3456,
656
],
"executeOnce": true,
"retryOnFail": true,
"credentials": {
"googleDriveOAuth2Api": {
"id": "g23xdGLZRzBGqKgH",
"name": "Isaac - Google Drive"
}
},
"onError": "continueErrorOutput"
},
{
"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})() }}",
"options": {}
},
"id": "1729e8fc-105c-411a-bf80-1be1dfb14614",
"name": "Preparar lista de destinatarios",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
3712,
560
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "pending-access-count",
"leftValue": "={{ $json.pendingAccessCount }}",
"rightValue": 0,
"operator": {
"type": "number",
"operation": "gt"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "9743bb45-f09b-454e-b787-73ee450b69b3",
"name": "¿Hay accesos pendientes?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
3952,
560
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"fieldToSplitOut": "recipientsToShare",
"options": {
"destinationFieldName": "recipient"
}
},
"id": "3d74bea6-b858-4ae2-b1ed-8d46ece27ea5",
"name": "Separar destinatarios",
"type": "n8n-nodes-base.splitOut",
"typeVersion": 1,
"position": [
4192,
480
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"method": "POST",
"url": "=https://www.googleapis.com/drive/v3/files/{{ $('Subir reporte a Google Drive').first().json.id }}/permissions?sendNotificationEmail=false&supportsAllDrives=true",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleDriveOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ role: 'writer', type: 'user', emailAddress: $json.recipient.email }) }}",
"options": {
"timeout": 300000
}
},
"id": "94d1d384-81f3-412e-907c-c888d92cef81",
"name": "Crear permiso de editor por API",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
4432,
480
],
"retryOnFail": true,
"credentials": {
"googleDriveOAuth2Api": {
"id": "g23xdGLZRzBGqKgH",
"name": "Isaac - Google Drive"
}
},
"onError": "continueErrorOutput"
},
{
"parameters": {
"aggregate": "aggregateAllItemData",
"destinationFieldName": "permisosCreados",
"options": {}
},
"id": "b114e1f6-fc06-49fb-ac36-52c2e0893428",
"name": "Agrupar permisos creados",
"type": "n8n-nodes-base.aggregate",
"typeVersion": 1,
"position": [
4672,
480
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "created-permission-count",
"leftValue": "={{ Array.isArray($json.permisosCreados) && $json.permisosCreados.length === Number($('Preparar lista de destinatarios').first().json.pendingAccessCount || 0) }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "d1679655-fc24-4f4c-b875-68c8bc002c24",
"name": "¿Se crearon todos los permisos?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
4928,
480
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"url": "=https://www.googleapis.com/drive/v3/files/{{ $('Subir reporte a Google Drive').first().json.id }}/permissions",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleDriveOAuth2Api",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "supportsAllDrives",
"value": "true"
},
{
"name": "fields",
"value": "permissions(id,type,role,emailAddress,deleted,pendingOwner)"
}
]
},
"options": {
"response": {
"response": {
"responseFormat": "json"
}
},
"timeout": 300000
}
},
"id": "480bcf25-fce8-4c7e-821b-608a879277d8",
"name": "Verificar accesos en Google Drive",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
5168,
400
],
"executeOnce": true,
"retryOnFail": true,
"credentials": {
"googleDriveOAuth2Api": {
"id": "g23xdGLZRzBGqKgH",
"name": "Isaac - Google Drive"
}
},
"onError": "continueErrorOutput"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "verified-editor-access",
"leftValue": "={{ (() => { 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']); return recipients.every(recipient=>permissions.some(permission=>!permission?.deleted&&norm(permission?.emailAddress)===norm(recipient.email)&&editable.has(String(permission?.role||'')))); })() }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "45086ef1-fb80-4c04-8860-681fbe2a1bd4",
"name": "¿Están confirmados los siete accesos?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
5424,
400
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"mode": "raw",
"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",
"name": "Confirmar acceso de siete personas",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
5424,
752
],
"executeOnce": true,
"onError": "continueErrorOutput"
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"options": {
"includeUnpaired": false
}
},
"id": "5e783156-45dc-41e1-a78a-ce90fa8d87a9",
"name": "Combinar reporte y acceso",
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
5184,
816
]
},
{
"parameters": {
"sendTo": "={{ $('Preparar lista de destinatarios').first().json.recipients.map(recipient => recipient.email).join(', ') }}",
"subject": "={{ \"Cruce Seguridad Social El Salvador - \" + $(\"Combinar reporte y acceso\").item.json.periodo + \" - \" + $(\"Combinar reporte y acceso\").item.json.hallazgosTotales + \" hallazgo(s)\" }}",
"message": "={{ (function emailExpressionRuntime() {\n const s = $('Combinar reporte y acceso').item.json || {};\n const report = $('Subir reporte a Google Drive').item.json || {};\n const reportUrl = report.webViewLink || ('https://drive.google.com/open?id=' + (report.id || ''));\n const esc = value => String(value ?? '').replace(/[&<>\"']/g, character => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' })[character]);\n const number = value => Number(value || 0).toLocaleString('es-SV');\n const total = Number(s.hallazgosTotales || 0);\n const status = total ? 'REQUIERE REVISIÓN' : 'COMPLETADO · SIN DIFERENCIAS';\n const statusBackground = total ? '#FFF6E3' : '#EEF6E8';\n const statusBorder = total ? '#E7C56A' : '#B9DDAA';\n const statusColor = total ? '#7A5200' : '#347326';\n const logo = 'data:image/png;base64,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';\n return '<div style=\"margin:0;padding:0;background:#F3F6F8;font-family:Arial,Helvetica,sans-serif;color:#4A4A4A\"><table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\" style=\"background:#F3F6F8\"><tr><td align=\"center\" style=\"padding:32px 14px\"><table role=\"presentation\" width=\"680\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\" style=\"width:100%;max-width:680px;background:#FFFFFF;border:1px solid #D0D0D0;border-collapse:separate\"><tr><td style=\"height:5px;background:#6CC24A;font-size:0;line-height:0\">&nbsp;</td></tr>' +\n '<tr><td style=\"padding:30px 36px 20px\"><img src=\"' + logo + '\" width=\"210\" alt=\"GomezLee Marketing\" style=\"display:block;width:210px;max-width:70%;height:auto;border:0;margin:0 0 26px\"><div style=\"font-size:11px;line-height:1.4;letter-spacing:1.8px;font-weight:bold;color:#6CC24A\">SEGURIDAD SOCIAL · EL SALVADOR</div><h1 style=\"margin:7px 0 8px;font-size:28px;line-height:1.18;color:#4F758B;font-weight:700\">Cruce mensual completado</h1><p style=\"margin:0;font-size:15px;line-height:1.5;color:#6B8FA3\">' + esc(s.periodo) + ' · Nómina, Planilla Única y BambooHR</p></td></tr>' +\n '<tr><td style=\"padding:0 36px 24px\"><div style=\"padding:14px 16px;background:' + statusBackground + ';border:1px solid ' + statusBorder + ';color:' + statusColor + ';font-size:13px;font-weight:bold;letter-spacing:.5px\">' + status + '</div></td></tr>' +\n '<tr><td style=\"padding:0 36px 26px\"><table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\" style=\"border-collapse:collapse;border-top:1px solid #D0D0D0;border-bottom:1px solid #D0D0D0\"><tr><td width=\"33.33%\" style=\"padding:18px 12px 18px 0;border-right:1px solid #D0D0D0\"><div style=\"font-size:11px;letter-spacing:1px;color:#6B8FA3;font-weight:bold\">NÓMINA</div><div style=\"margin-top:4px;font-size:26px;color:#4F758B;font-weight:bold\">' + number(s.empleadosNomina) + '</div><div style=\"font-size:12px;color:#6B8FA3\">personas</div></td><td width=\"33.33%\" style=\"padding:18px 12px;border-right:1px solid #D0D0D0\"><div style=\"font-size:11px;letter-spacing:1px;color:#6B8FA3;font-weight:bold\">PLANILLA</div><div style=\"margin-top:4px;font-size:26px;color:#4F758B;font-weight:bold\">' + number(s.empleadosPlanilla) + '</div><div style=\"font-size:12px;color:#6B8FA3\">personas</div></td><td width=\"33.33%\" style=\"padding:18px 0 18px 12px\"><div style=\"font-size:11px;letter-spacing:1px;color:#6B8FA3;font-weight:bold\">HALLAZGOS</div><div style=\"margin-top:4px;font-size:26px;color:' + (total ? '#8A5A00' : '#4F758B') + ';font-weight:bold\">' + number(total) + '</div><div style=\"font-size:12px;color:#6B8FA3\">a revisar</div></td></tr></table></td></tr>' +\n '<tr><td style=\"padding:0 36px 8px\"><h2 style=\"margin:0;font-size:17px;color:#4F758B\">Resumen del cruce</h2></td></tr><tr><td style=\"padding:0 36px 22px\"><table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\" style=\"font-size:14px;line-height:1.5;border-collapse:collapse\"><tr><td style=\"padding:9px 0;border-bottom:1px solid #E6E6E6\">Diferencias de identidad y nombres</td><td align=\"right\" style=\"padding:9px 0;border-bottom:1px solid #E6E6E6;color:#4F758B;font-weight:bold\">' + number(s.hallazgosIdentidad) + '</td></tr><tr><td style=\"padding:9px 0;border-bottom:1px solid #E6E6E6\">Diferencias de montos <span style=\"color:#6B8FA3\">(subcentavos: ' + number(s.hallazgosSubcentavo) + ')</span></td><td align=\"right\" style=\"padding:9px 0;border-bottom:1px solid #E6E6E6;color:#4F758B;font-weight:bold\">' + number(s.hallazgosMontos) + '</td></tr><tr><td style=\"padding:9px 0;border-bottom:1px solid #E6E6E6\">Nómina sin BambooHR</td><td align=\"right\" style=\"padding:9px 0;border-bottom:1px solid #E6E6E6;color:#4F758B;font-weight:bold\">' + number(s.nominaSinBamboo) + '</td></tr><tr><td style=\"padding:9px 0\">Planilla sin BambooHR</td><td align=\"right\" style=\"padding:9px 0;color:#4F758B;font-weight:bold\">' + number(s.planillaSinBamboo) + '</td></tr></table></td></tr>' +\n '<tr><td style=\"padding:0 36px 30px\"><div style=\"padding:16px;border-left:4px solid #6CC24A;background:#EEF6E8;color:#4A4A4A;font-size:13px;line-height:1.55\"><strong style=\"color:#4F758B\">Reporte Excel adjunto y disponible en Google Drive</strong><br>Incluye las hojas <strong>Diferencias</strong>, <strong>Nómina sin Bamboo</strong> y <strong>Planilla sin Bamboo</strong>.<div style=\"margin-top:16px\"><a href=\"' + esc(reportUrl) + '\" style=\"display:inline-block;background:#6CC24A;color:#FFFFFF;text-decoration:none;padding:12px 20px;border-radius:24px;font-weight:bold\">Abrir reporte en Google Drive</a></div></div></td></tr><tr><td style=\"padding:20px 36px;background:#F7F9FA;border-top:1px solid #E2E6E9;font-size:11px;line-height:1.65;color:#6B8FA3\"><strong style=\"color:#4F758B\">Archivos procesados</strong><br>Nómina: ' + esc(s.archivoNomina) + '<br>Planilla: ' + esc(s.archivoPlanilla) + '<br>Reporte: ' + esc(s.nombreArchivo) + '</td></tr></table></td></tr></table></div>';\n})() }}",
"options": {
"appendAttribution": false,
"attachmentsUi": {
"attachmentsBinary": [
{
"property": "reporteExcel"
}
]
},
"senderName": "GomezLee Marketing"
}
},
"id": "66c9e023-50e9-4c8b-b003-2d8c4780e7c3",
"name": "Enviar reporte Excel GLM",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2.2,
"position": [
5680,
864
],
"executeOnce": true,
"retryOnFail": true,
"webhookId": "9b4293aa-1dca-4aa3-9c7d-e957e5cd6c85",
"credentials": {
"gmailOAuth2": {
"id": "cehQBe6X1BQLmcD9",
"name": "Gmail account - Isaac"
}
},
"onError": "continueErrorOutput"
},
{
"parameters": {
"operation": "completion",
"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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' })[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 '<div style=\"font-family:Arial,Helvetica,sans-serif;max-width:680px;margin:30px auto;padding:34px;background:#FFFFFF;border:1px solid #D0D0D0;border-top:5px solid #6CC24A;color:#4A4A4A\">' +\n '<div style=\"font-size:11px;letter-spacing:1.7px;font-weight:bold;color:#6CC24A\">SEGURIDAD SOCIAL · EL SALVADOR</div>' +\n '<h1 style=\"margin:8px 0;color:#4F758B;font-size:28px\">Proceso completado</h1>' +\n '<p style=\"margin:0 0 8px;color:#6B8FA3;font-size:15px\">' + esc(s.periodo) + '</p>' +\n '<p style=\"margin:18px 0 22px;font-size:15px;line-height:1.55\">' + esc(status) +\n ' El Excel fue enviado a las <strong>siete personas autorizadas</strong> y quedó disponible en Google Drive con acceso de edición para cada una.</p>' +\n '<a href=\"' + esc(reportUrl) + '\" style=\"display:inline-block;padding:13px 20px;margin:0 10px 10px 0;background:#4F758B;color:#FFFFFF;text-decoration:none;font-weight:bold\">Abrir reporte</a>' +\n '<a href=\"https://agenteit.digitalcompass.agency/form/ef422914-fb5b-4f33-8283-bc2d2ea9abfc\" style=\"display:inline-block;padding:13px 20px;background:#6CC24A;color:#FFFFFF;text-decoration:none;font-weight:bold\">Realizar otro cruce</a>' +\n '</div>';\n})() }}"
},
"id": "6a51a26e-7c88-451c-b966-6f6dec925f7a",
"name": "Mostrar resultado y repetir",
"type": "n8n-nodes-base.form",
"typeVersion": 2.5,
"position": [
6016,
768
],
"webhookId": "2bbb060d-09d8-40c1-bcfa-6fb7c8c3d3da"
},
{
"parameters": {
"operation": "completion",
"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=>({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'})[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 '<div style=\"font-family:Arial,Helvetica,sans-serif;max-width:680px;margin:30px auto;padding:34px;background:#fff;border:1px solid #D0D0D0;border-top:5px solid #B55252;color:#4A4A4A\"><div style=\"font-size:11px;letter-spacing:1.7px;font-weight:bold;color:#6CC24A\">SEGURIDAD SOCIAL · EL SALVADOR</div><h1 style=\"margin:8px 0;color:#4F758B;font-size:28px\">No se pudo completar el cruce</h1><p style=\"margin:18px 0 14px;font-size:15px;line-height:1.55\">'+esc(guidance)+'</p><div style=\"margin:0 0 26px;padding:12px 14px;background:#F7F9FA;border-left:4px solid #B55252;color:#5F6870;font:12px/1.5 Consolas,monospace;word-break:break-word\">'+esc(detail)+'</div><a href=\"/form/cruce-seguridad-social-el-salvador\" style=\"display:inline-block;padding:13px 20px;background:#6CC24A;color:#fff;text-decoration:none;font-weight:bold\">Volver al formulario</a></div>';\n})() }}"
},
"id": "30d59ab7-e53a-4533-92dd-c9f4786cb37e",
"name": "Mostrar error y reintentar",
"type": "n8n-nodes-base.form",
"typeVersion": 2.5,
"position": [
5584,
1248
],
"webhookId": "b8aa8b32-d69c-45a5-aa33-dfe9040bb2f1"
},
{
"parameters": {
"mode": "raw",
"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",
"name": "Preparar accesos faltantes",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
5680,
528
],
"executeOnce": true,
"onError": "continueErrorOutput"
},
{
"parameters": {
"mode": "raw",
"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",
"name": "Preparar error de permisos",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
5168,
576
],
"executeOnce": true,
"onError": "continueErrorOutput"
},
{
"parameters": {
"content": "## 1 · Formulario\nRecibe período, nómina y planilla. Conserva las hojas **Nomina** y **PLANILLA_UNICA**.",
"height": 744,
"width": 1564,
"color": 4
},
"id": "fc9bcda7-c796-4f9e-aa23-426e11737463",
"name": "Guía · Formulario",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
496,
432
]
},
{
"parameters": {
"content": "## 2 · Cruces y BambooHR\nCompara identidad, DUI, montos y presencia en el directorio histórico completo de BambooHR.",
"height": 312,
"width": 812,
"color": 5
},
"id": "380a2c79-217e-4de4-b5dd-14f2c1a7a259",
"name": "Guía · Cruces y BambooHR",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
2160,
672
]
},
{
"parameters": {
"content": "## 3 · Reporte y entrega\nGenera el Excel, crea los permisos por la API de Drive, relee los permisos del archivo y envía el correo solo después de confirmar los siete accesos de edición.",
"height": 760,
"width": 3132,
"color": 3
},
"id": "6d234122-5e43-4473-a11c-eedcb76e31eb",
"name": "Guía · Reporte y entrega",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
3104,
288
]
},
{
"parameters": {
"content": "## Manejo de errores\nMuestra detalle seguro y permite volver al formulario. Si falla la creación o la verificación de cualquier acceso, el correo queda bloqueado.",
"height": 332,
"width": 420,
"color": 2
},
"id": "dedc732b-cda5-4d60-81fc-bfd660445a71",
"name": "Guía · Manejo de errores",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
5424,
1088
]
},
{
"parameters": {
"operation": "xlsx",
"options": {
"headerRow": true,
"includeEmptyCells": true,
"range": "A1:P100000",
"rawData": false,
"readAsString": false,
"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",
"name": "Extraer Nómina · hoja del mes",
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
1136,
720
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"operation": "xlsx",
"options": {
"headerRow": true,
"includeEmptyCells": true,
"range": "A1:P100000",
"rawData": false,
"readAsString": false,
"sheetName": "Nómina"
}
},
"id": "a86eaafa-db0f-46f9-97df-c59f2a707478",
"name": "Extraer Nómina · hoja Nómina",
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
1168,
944
],
"onError": "continueErrorOutput"
},
{
"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",
"name": "Extraer Nómina",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1344,
656
],
"onError": "continueErrorOutput"
},
{
"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",
"name": "Restaurar binario · intento hoja del mes",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
944,
736
],
"onError": "continueErrorOutput"
},
{
"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",
"name": "Restaurar binario · intento hoja Nómina",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
960,
960
],
"onError": "continueErrorOutput"
}
],
"pinData": {},
"connections": {
"Formulario de cruce": {
"main": [
[
{
"node": "Preparar archivo Nómina",
"type": "main",
"index": 0
}
]
]
},
"Preparar archivo Nómina": {
"main": [
[
{
"node": "Extraer Nómina · hoja Nomina",
"type": "main",
"index": 0
}
],
[
{
"node": "Mostrar error y reintentar",
"type": "main",
"index": 0
}
]
]
},
"Preparar archivo Planilla": {
"main": [
[
{
"node": "Extraer Planilla",
"type": "main",
"index": 0
}
],
[
{
"node": "Mostrar error y reintentar",
"type": "main",
"index": 0
}
]
]
},
"Extraer Planilla": {
"main": [
[
{
"node": "Validar lecturas",
"type": "main",
"index": 0
}
],
[
{
"node": "Mostrar error y reintentar",
"type": "main",
"index": 0
}
]
]
},
"Validar lecturas": {
"main": [
[
{
"node": "Obtener reporte completo BambooHR",
"type": "main",
"index": 0
}
],
[
{
"node": "Mostrar error y reintentar",
"type": "main",
"index": 0
}
]
]
},
"Obtener reporte completo BambooHR": {
"main": [
[
{
"node": "Analizar y construir reporte",
"type": "main",
"index": 0
}
],
[
{
"node": "Mostrar error y reintentar",
"type": "main",
"index": 0
}
]
]
},
"Analizar y construir reporte": {
"main": [
[
{
"node": "Generar Excel",
"type": "main",
"index": 0
}
],
[
{
"node": "Mostrar error y reintentar",
"type": "main",
"index": 0
}
]
]
},
"Generar Excel": {
"main": [
[
{
"node": "Subir reporte a Google Drive",
"type": "main",
"index": 0
},
{
"node": "Combinar reporte y acceso",
"type": "main",
"index": 0
}
],
[
{
"node": "Mostrar error y reintentar",
"type": "main",
"index": 0
}
]
]
},
"Subir reporte a Google Drive": {
"main": [
[
{
"node": "Preparar lista de destinatarios",
"type": "main",
"index": 0
}
],
[
{
"node": "Mostrar error y reintentar",
"type": "main",
"index": 0
}
]
]
},
"Preparar lista de destinatarios": {
"main": [
[
{
"node": "¿Hay accesos pendientes?",
"type": "main",
"index": 0
}
],
[
{
"node": "Mostrar error y reintentar",
"type": "main",
"index": 0
}
]
]
},
"¿Hay accesos pendientes?": {
"main": [
[
{
"node": "Separar destinatarios",
"type": "main",
"index": 0
}
],
[
{
"node": "Verificar accesos en Google Drive",
"type": "main",
"index": 0
}
],
[
{
"node": "Mostrar error y reintentar",
"type": "main",
"index": 0
}
]
]
},
"Separar destinatarios": {
"main": [
[
{
"node": "Crear permiso de editor por API",
"type": "main",
"index": 0
}
],
[
{
"node": "Mostrar error y reintentar",
"type": "main",
"index": 0
}
]
]
},
"Crear permiso de editor por API": {
"main": [
[
{
"node": "Agrupar permisos creados",
"type": "main",
"index": 0
}
],
[
{
"node": "Mostrar error y reintentar",
"type": "main",
"index": 0
}
]
]
},
"Agrupar permisos creados": {
"main": [
[
{
"node": "¿Se crearon todos los permisos?",
"type": "main",
"index": 0
}
],
[
{
"node": "Mostrar error y reintentar",
"type": "main",
"index": 0
}
]
]
},
"¿Se crearon todos los permisos?": {
"main": [
[
{
"node": "Verificar accesos en Google Drive",
"type": "main",
"index": 0
}
],
[
{
"node": "Preparar error de permisos",
"type": "main",
"index": 0
}
],
[
{
"node": "Mostrar error y reintentar",
"type": "main",
"index": 0
}
]
]
},
"Verificar accesos en Google Drive": {
"main": [
[
{
"node": "¿Están confirmados los siete accesos?",
"type": "main",
"index": 0
}
],
[
{
"node": "Mostrar error y reintentar",
"type": "main",
"index": 0
}
]
]
},
"¿Están confirmados los siete accesos?": {
"main": [
[
{
"node": "Confirmar acceso de siete personas",
"type": "main",
"index": 0
}
],
[
{
"node": "Preparar accesos faltantes",
"type": "main",
"index": 0
}
],
[
{
"node": "Mostrar error y reintentar",
"type": "main",
"index": 0
}
]
]
},
"Confirmar acceso de siete personas": {
"main": [
[
{
"node": "Combinar reporte y acceso",
"type": "main",
"index": 1
}
],
[
{
"node": "Mostrar error y reintentar",
"type": "main",
"index": 0
}
]
]
},
"Combinar reporte y acceso": {
"main": [
[
{
"node": "Enviar reporte Excel GLM",
"type": "main",
"index": 0
}
]
]
},
"Enviar reporte Excel GLM": {
"main": [
[
{
"node": "Mostrar resultado y repetir",
"type": "main",
"index": 0
}
],
[
{
"node": "Mostrar error y reintentar",
"type": "main",
"index": 0
}
]
]
},
"Preparar accesos faltantes": {
"main": [
[
{
"node": "Mostrar error y reintentar",
"type": "main",
"index": 0
}
],
[
{
"node": "Mostrar error y reintentar",
"type": "main",
"index": 0
}
]
]
},
"Preparar error de permisos": {
"main": [
[
{
"node": "Mostrar error y reintentar",
"type": "main",
"index": 0
}
],
[
{
"node": "Mostrar error y reintentar",
"type": "main",
"index": 0
}
]
]
},
"Extraer Nómina · hoja Nomina": {
"main": [
[
{
"node": "Extraer Nómina",
"type": "main",
"index": 0
}
],
[
{
"node": "Restaurar binario · intento hoja del mes",
"type": "main",
"index": 0
}
]
]
},
"Extraer Nómina · hoja del mes": {
"main": [
[
{
"node": "Extraer Nómina",
"type": "main",
"index": 0
}
],
[
{
"node": "Restaurar binario · intento hoja Nómina",
"type": "main",
"index": 0
}
]
]
},
"Extraer Nómina · hoja Nómina": {
"main": [
[
{
"node": "Extraer Nómina",
"type": "main",
"index": 0
}
],
[
{
"node": "Mostrar error y reintentar",
"type": "main",
"index": 0
}
]
]
},
"Extraer Nómina": {
"main": [
[
{
"node": "Preparar archivo Planilla",
"type": "main",
"index": 0
}
],
[
{
"node": "Mostrar error y reintentar",
"type": "main",
"index": 0
}
]
]
},
"Restaurar binario · intento hoja del mes": {
"main": [
[
{
"node": "Extraer Nómina · hoja del mes",
"type": "main",
"index": 0
}
],
[
{
"node": "Mostrar error y reintentar",
"type": "main",
"index": 0
}
]
]
},
"Restaurar binario · intento hoja Nómina": {
"main": [
[
{
"node": "Extraer Nómina · hoja Nómina",
"type": "main",
"index": 0
}
],
[
{
"node": "Mostrar error y reintentar",
"type": "main",
"index": 0
}
]
]
}
},
"active": true,
"settings": {
"executionOrder": "v1",
"binaryMode": "separate",
"availableInMCP": true,
"timeSavedMode": "fixed",
"errorWorkflow": "puF4LUczoSz3hcek",
"timezone": "America/Santo_Domingo",
"callerPolicy": "workflowsFromSameOwner"
},
"versionId": "0ceb0ca3-f520-4202-ab01-c299878c6ce9",
"meta": {
"aiBuilderAssisted": true,
"builderVariant": "mcp",
"instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70"
},
"id": "wCud4hOciMao0Ab3",
"tags": []
}