Files
cruces-seguridad-social/Cruce de Seguridad Social - República Dominicana.json

1624 lines
150 KiB
JSON

{
"name": "Cruce de Seguridad Social - República Dominicana",
"nodes": [
{
"parameters": {
"formTitle": "Cruce Seguridad Social RD",
"formDescription": "Sube Nómina 1, Nómina 2 y el archivo TSS del mes. El flujo genera reporte en Google Sheets y envía resumen por correo.",
"formFields": {
"values": [
{
"fieldLabel": "Mes a validar",
"fieldType": "dropdown",
"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",
"placeholder": "2026",
"requiredField": true
},
{
"fieldLabel": "Nómina 1",
"fieldType": "file",
"multipleFiles": false,
"acceptFileTypes": ".xlsx,.xls",
"requiredField": true
},
{
"fieldLabel": "Nómina 2",
"fieldType": "file",
"multipleFiles": false,
"acceptFileTypes": ".xlsx,.xls",
"requiredField": true
},
{
"fieldLabel": "TSS",
"fieldType": "file",
"multipleFiles": false,
"acceptFileTypes": ".xlsx,.xls",
"requiredField": true
}
]
},
"responseMode": "lastNode",
"options": {
"ignoreBots": true
}
},
"type": "n8n-nodes-base.formTrigger",
"typeVersion": 2.3,
"position": [
-4432,
3344
],
"id": "1b2e4628-d62f-4aad-9311-f39f4e2dc776",
"name": "On form submission",
"webhookId": "44280b50-f6d1-44dc-9a59-206a9c3d940f"
},
{
"parameters": {
"jsCode": "const item = $input.first();\nconst now = new Date();\nconst pad = n => String(n).padStart(2, '0');\nconst mes = String(item.json['Mes a validar'] || '').replace(/\\D/g, '').padStart(2, '0');\nconst anioRaw = String(item.json['Año a validar'] || item.json['Ano a validar'] || '').trim();\nconst anio = Number(anioRaw.replace(/\\D/g, ''));\nif (!/^\\d{2}$/.test(mes) || Number(mes) < 1 || Number(mes) > 12) throw new Error('Selecciona un mes válido entre 01 y 12.');\nif (!/^\\d{4}$/.test(String(anio)) || anio < 2020 || anio > 2100) throw new Error('Escribe un año válido de 4 dígitos, por ejemplo 2026.');\nconst meses = { '01':'Enero','02':'Febrero','03':'Marzo','04':'Abril','05':'Mayo','06':'Junio','07':'Julio','08':'Agosto','09':'Septiembre','10':'Octubre','11':'Noviembre','12':'Diciembre' };\nconst ts = `${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;\nconst runId = `RD_TSS_${ts}_${Math.random().toString(36).slice(2,8)}`;\nconst periodoLabel = `${meses[mes]} ${anio}`;\nconst fechaTitulo = `${pad(now.getDate())}-${pad(now.getMonth()+1)}-${now.getFullYear()}`;\nconst reportTitle = `Cruce Seguridad Social RD - ${periodoLabel} - ${fechaTitulo}`;\nreturn [{ json: { runId, mes, anio, periodoLabel, reportTitle }, binary: item.binary }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-4192,
3344
],
"id": "a81091a9-e1c6-4dbb-b10f-556524cb026d",
"name": "Inicializar ejecución"
},
{
"parameters": {
"jsCode": "const base = $items('Inicializar ejecución')[0];\nconst bin = base.binary || {};\nconst wanted = \"n1\";\nfunction ascii(s) { return String(s || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase(); }\nfunction scoreFile(key, file) {\n const fileName = String(file.fileName || key);\n const s = ascii(`${key} ${fileName}`);\n let score = 0;\n if (!/\\.xlsx$|\\.xls$|spreadsheet|excel|nomina|n[oó]mina|tss|constancia|recepcion/.test(s)) return -999;\n if (wanted === 'tss') {\n if (/tss|constancia|recepcion/.test(s)) score += 200;\n if (/nomina|n[oó]mina|field force/.test(s)) score -= 100;\n }\n if (wanted === 'n1') {\n if (/nomina|n[oó]mina|field force/.test(s)) score += 50;\n if (/\\b15\\b|_15| 15 |quincena 1|nomina 1|n[oó]mina 1|1q|primera/.test(s)) score += 200;\n if (/\\b30\\b|\\b28\\b|_30|_28|quincena 2|nomina 2|n[oó]mina 2|2q|segunda/.test(s)) score -= 50;\n if (/tss|constancia|recepcion/.test(s)) score -= 200;\n }\n if (wanted === 'n2') {\n if (/nomina|n[oó]mina|field force/.test(s)) score += 50;\n if (/\\b30\\b|\\b28\\b|_30|_28| 30 | 28 |quincena 2|nomina 2|n[oó]mina 2|2q|segunda/.test(s)) score += 200;\n if (/\\b15\\b|_15| 15 |quincena 1|nomina 1|n[oó]mina 1|1q|primera/.test(s)) score -= 50;\n if (/tss|constancia|recepcion/.test(s)) score -= 200;\n }\n return score;\n}\nconst candidates = Object.entries(bin).map(([key,file]) => ({ key, file, score: scoreFile(key,file), fileName: file.fileName || key })).filter(x => x.score > -100).sort((a,b) => b.score - a.score);\nif (!candidates.length || candidates[0].score <= 0) throw new Error('No pude identificar el archivo Nómina 1. Revisa que el nombre/campo del formulario sea claro.');\nconst chosen = candidates[0];\nreturn [{ json: { runId: base.json.runId, mes: base.json.mes, anio: base.json.anio, periodoLabel: base.json.periodoLabel, reportTitle: base.json.reportTitle, fileName: chosen.fileName }, binary: { data: chosen.file } }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-3056,
3120
],
"id": "fe4c4689-717d-4432-9b9f-3a9239dd0252",
"name": "Preparar Nómina 1"
},
{
"parameters": {
"operation": "update",
"fileId": {
"__rl": true,
"value": "1Vfp9KE8YBxxB2QH7jNbVqrOxnSKYfaMV7HjD8oiSEJk",
"mode": "id"
},
"changeFileContent": true,
"options": {}
},
"type": "n8n-nodes-base.googleDrive",
"typeVersion": 3,
"position": [
-2784,
3120
],
"id": "4d4abd02-8b7c-4fa8-bb1d-937cfa006661",
"name": "Actualizar staging Nómina 1",
"credentials": {
"googleDriveOAuth2Api": {
"id": "g23xdGLZRzBGqKgH",
"name": "Isaac - Google Drive"
}
}
},
{
"parameters": {},
"type": "n8n-nodes-base.wait",
"typeVersion": 1.1,
"position": [
-2544,
3120
],
"id": "bc674328-2407-47e1-a195-41e361a63ba0",
"name": "Esperar conversión Nómina 1",
"webhookId": "d7b42aa0-1dc6-4677-a909-e591027d75bb"
},
{
"parameters": {
"url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.id || $json.spreadsheetId || '1Vfp9KE8YBxxB2QH7jNbVqrOxnSKYfaMV7HjD8oiSEJk' }}?fields=spreadsheetId,sheets(properties(title,sheetId,hidden))",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleSheetsOAuth2Api",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.3,
"position": [
-2304,
3120
],
"id": "66089f5e-c78d-41c5-a4c2-36d1d16bc358",
"name": "Listar hojas Nómina 1",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
}
},
{
"parameters": {
"jsCode": "const init = $items('Inicializar ejecución')[0].json;\nconst FALLBACK_ID = '1Vfp9KE8YBxxB2QH7jNbVqrOxnSKYfaMV7HjD8oiSEJk';\nconst SOURCE_LABEL = 'Nómina 1';\nconst PREP_NODE = 'Preparar Nómina 1';\nconst spreadsheetId = $json.spreadsheetId || $json.id || $json.fileId || FALLBACK_ID;\nfunction norm(s) { return String(s || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]/g, ''); }\nfunction quoteA1SheetName(title) {\n return \"'\" + String(title).replace(/'/g, \"''\") + \"'!A1:AZ3000\";\n}\nconst ignore = /resumen|validacion|nvscripts|^isr$|calculo|autocrat|do\\s*not\\s*delete|^hoja|^copia\\s*de/i;\nconst sheets = ($json.sheets || []).map(s => s.properties || {}).filter(p => p.title && !p.hidden);\nconst sheetTitles = [];\nconst sheetDisplayTitles = [];\nfor (const p of sheets) {\n // IMPORTANTÍSIMO: NO usar trim() para el título real de Google Sheets.\n // Algunas nóminas tienen hojas como \"GLM RD \" y \"Latam \" con espacio al final.\n // Si se recorta ese espacio, Google Sheets devuelve: Unable to parse range.\n const rawTitle = String(p.title || '');\n const displayTitle = rawTitle.trim();\n const n = norm(rawTitle);\n if (!displayTitle || ignore.test(displayTitle) || n.startsWith('copiade')) continue;\n sheetTitles.push(rawTitle);\n sheetDisplayTitles.push(displayTitle);\n}\nif (!sheetTitles.length) throw new Error(`No encontré hojas válidas de nómina en ${SOURCE_LABEL}.`);\nconst rangesQuery = sheetTitles\n .map(title => `ranges=${encodeURIComponent(quoteA1SheetName(title))}`)\n .join('&');\nreturn [{\n json: {\n runId: init.runId,\n mes: init.mes,\n anio: init.anio,\n spreadsheetId,\n rangesQuery,\n sheetTitles,\n sheetDisplayTitles,\n quarter: SOURCE_LABEL,\n fileName: $(PREP_NODE).item.json.fileName,\n }\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-2064,
3120
],
"id": "515516e1-09f6-4b71-89cb-ee907e246019",
"name": "Preparar hojas Nómina 1"
},
{
"parameters": {
"url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.spreadsheetId }}/values:batchGet?{{ $json.rangesQuery }}&majorDimension=ROWS&valueRenderOption=UNFORMATTED_VALUE&dateTimeRenderOption=SERIAL_NUMBER",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleSheetsOAuth2Api",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.3,
"position": [
-1824,
3120
],
"id": "23fb2cde-b1c9-41da-a5f6-e852a970afba",
"name": "Leer hojas Nómina 1",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
}
},
{
"parameters": {
"jsCode": "function ascii(s){return String(s??'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'');}\nfunction clean(s){return String(s??'').replace(/\\s+/g,' ').trim();}\nfunction hnorm(s){return ascii(s).toLowerCase().replace(/[^a-z0-9]/g,'');}\nfunction nameNorm(s){return ascii(s).toUpperCase().replace(/[,.;:()\\[\\]{}]/g,' ').replace(/\\s+/g,' ').trim();}\nfunction toNumber(v){ if(v===null||v===undefined||v==='') return 0; if(typeof v==='number') return Number.isFinite(v)?v:0; let s=String(v).trim(); if(!s) return 0; s=s.replace(/RD\\$|DOP|USD|US\\$|\\$|,/gi,'').replace(/\\s/g,''); if(/^\\((.*)\\)$/.test(s)) s='-'+s.replace(/^\\(|\\)$/g,''); const n=Number(s); return Number.isFinite(n)?n:0; }\nfunction money(n){return Number((Number(n)||0).toFixed(2));}\nfunction rawDigits(v){ if(v===null||v===undefined) return ''; let s=String(v).trim(); if(/^\\d+(\\.0+)?$/.test(s)) s=s.replace(/\\.0+$/,''); return s.replace(/\\D/g,''); }\nfunction normalizeDoc(v){ const d=rawDigits(v); if(!d) return ''; if(d.length<=11) return d.padStart(11,'0'); return d; }\nfunction docIssue(v){ const d=rawDigits(v); if(!d) return 'vacío'; if(d.length<11) return 'documento corto normalizado con ceros'; if(d.length>11) return 'documento con más de 11 dígitos'; return ''; }\nfunction pad(n){return String(n).padStart(2,'0');}\nfunction excelSerialToYM(v){ const epoch=new Date(Date.UTC(1899,11,30)); const d=new Date(epoch.getTime()+Math.round(Number(v))*86400000); return `${pad(d.getUTCMonth()+1)}/${d.getUTCFullYear()}`; }\nfunction parseDateParts(a,b,y,targetMes){ a=Number(a); b=Number(b); let month; if(targetMes && String(a).padStart(2,'0')===targetMes) month=a; else if(targetMes && String(b).padStart(2,'0')===targetMes) month=b; else if(a>12 && b<=12) month=b; else if(b>12 && a<=12) month=a; else month=b; return `${pad(month)}/${String(y).length===2?'20'+y:y}`; }\nfunction parseYM(v,targetMes){ if(typeof v==='number'&&Number.isFinite(v)&&v>20000&&v<70000) return excelSerialToYM(v); const s=clean(v); if(!s) return ''; if(/^\\d+(\\.0+)?$/.test(s)){ const n=Number(s); if(n>20000&&n<70000) return excelSerialToYM(n); } let m=s.match(/(\\d{1,2})[\\/-](\\d{1,2})[\\/-](\\d{2,4})/); if(m) return parseDateParts(m[1],m[2],m[3],targetMes); m=s.match(/(\\d{4})[\\/-](\\d{1,2})[\\/-](\\d{1,2})/); if(m) return `${pad(Number(m[2]))}/${m[1]}`; m=s.match(/(\\d{1,2})[\\/-](\\d{4})/); if(m) return `${pad(Number(m[1]))}/${m[2]}`; return ''; }\nfunction col(headers,candidates,fallback=[]){ const hs=headers.map(hnorm); for(const c of candidates){ const i=hs.findIndex(h=>h===c); if(i>=0) return i; } for(const c of fallback){ const i=hs.findIndex(h=>h.includes(c)); if(i>=0) return i; } return -1; }\nfunction findHeader(values){ for(let r=0;r<Math.min(values.length,45);r++){ const row=values[r]||[]; const hs=row.map(hnorm); const hasDoc=hs.some(h=>h==='cedula'||h.includes('cedula')||h.includes('documento')); const hasName=hs.some(h=>h==='nombre'||h.includes('nombre')); const hasPeriod=hs.some(h=>h.includes('periodo')); const hasAfp=hs.some(h=>h==='afp'||h.includes('afp')); const hasSfs=hs.some(h=>h==='sfs'||h==='ars'||h.includes('sfs')||h.includes('ars')); if(hasDoc&&hasName&&hasPeriod&&hasAfp&&hasSfs) return r; } return -1; }\nfunction sheetNameFromRange(range){ const s=String(range||''); if(s.startsWith(\"'\")){ let out=''; for(let i=1;i<s.length;i++){ if(s[i]===\"'\" && s[i+1]===\"'\"){ out+=\"'\"; i++; continue; } if(s[i]===\"'\" && s[i+1]==='!') return out; out+=s[i]; } } return s.split('!')[0].replace(/^'|'$/g,''); }\nconst init=$items('Inicializar ejecución')[0].json;\nconst prep=$items('Preparar hojas Nómina 1')[0].json;\nconst expected=`${init.mes}/${init.anio}`;\nconst valueRanges=$json.valueRanges||[];\nconst allRecords=[]; const sheetSummaries=[]; const warnings=[];\nfor (let i=0;i<valueRanges.length;i++){\n const vr=valueRanges[i]||{};\n const values=vr.values||[];\n const sheetTitle=((prep.sheetDisplayTitles&&prep.sheetDisplayTitles[i])||(prep.sheetTitles&&prep.sheetTitles[i])||sheetNameFromRange(vr.range)||'Hoja').trim();\n const headerRow=findHeader(values);\n if(headerRow<0){ warnings.push({tipo:'Hoja ignorada sin encabezado de nómina',archivo:prep.fileName,hoja:sheetTitle,detalle:'No se detectó encabezado con PERÍODO, NOMBRE, CÉDULA, AFP y SFS/ARS.'}); continue; }\n const headers=values[headerRow]||[];\n const idx={ periodo:col(headers,['periodo'],['periodo']), nombre:col(headers,['nombre','nombrecompleto'],['nombre']), cedula:col(headers,['cedula','cedulaidentidad','documento','nodocumento'],['cedula','documento']), salPeriodo:col(headers,['salariodelperiodo','salarioperiodo'],['salariodelperiodo','salarioperiodo']), vacaciones:col(headers,['vacaciones'],['vacaciones']), afp:col(headers,['afp'],['afp']), sfs:col(headers,['sfs','ars'],['sfs','ars']), neto:col(headers,['netoapagar','neto'],['neto']), email:col(headers,['email','correo','correoelectronico','emailaddress','workemail','correotrabajo'],['email','correo']), novedad:col(headers,['novedad','observacion','comentario','comentarios'],['novedad','observacion','comentario']) };\n let count=0, salario=0, vac=0, afp=0, sfs=0, neto=0;\n for(let r=headerRow+1;r<values.length;r++){\n const row=values[r]||[];\n const periodoYM=parseYM(row[idx.periodo], init.mes);\n if(!periodoYM || periodoYM!==expected) continue;\n const nombre=clean(row[idx.nombre]);\n const docRaw=row[idx.cedula];\n const doc=normalizeDoc(docRaw);\n if(!nombre || !doc || rawDigits(docRaw).length<7) continue;\n const salarioPeriodo=money(toNumber(row[idx.salPeriodo]));\n const vacaciones=money(toNumber(row[idx.vacaciones]));\n const afpMonto=money(toNumber(row[idx.afp]));\n const sfsMonto=money(toNumber(row[idx.sfs]));\n const netoMonto=money(toNumber(row[idx.neto]));\n const baseSS=money(salarioPeriodo+vacaciones);\n if(baseSS===0 && afpMonto===0 && sfsMonto===0 && netoMonto===0) continue;\n const rec={ fuente:'Nómina 1', archivo:prep.fileName, hoja:sheetTitle, periodo:periodoYM, nombre, nombreKey:nameNorm(nombre), cedula_original:clean(docRaw), documento:doc, documento_issue:docIssue(docRaw), salario_periodo:salarioPeriodo, vacaciones, base_ss:baseSS, afp:afpMonto, sfs:sfsMonto, neto:netoMonto, correo: idx.email>=0?clean(row[idx.email]):'', novedad: idx.novedad>=0?clean(row[idx.novedad]):'' };\n allRecords.push(rec); count++; salario+=salarioPeriodo; vac+=vacaciones; afp+=afpMonto; sfs+=sfsMonto; neto+=netoMonto;\n }\n sheetSummaries.push({fuente:'Nómina 1', archivo:prep.fileName, hoja:sheetTitle, empleados:count, salario_periodo:money(salario), vacaciones:money(vac), base_ss:money(salario+vac), afp:money(afp), sfs:money(sfs), neto:money(neto)});\n}\nreturn [{json:{runId:init.runId, source:'Nómina 1', payrollRecords:allRecords, sheetSummaries, warnings, sourceFiles:{'Nómina 1': prep.fileName} }}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-1568,
3200
],
"id": "8014af0f-898a-4d77-b0a6-27e7aa7334ba",
"name": "Extraer Nómina 1"
},
{
"parameters": {
"jsCode": "const base = $items('Inicializar ejecución')[0];\nconst bin = base.binary || {};\nconst wanted = \"n2\";\nfunction ascii(s) { return String(s || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase(); }\nfunction scoreFile(key, file) {\n const fileName = String(file.fileName || key);\n const s = ascii(`${key} ${fileName}`);\n let score = 0;\n if (!/\\.xlsx$|\\.xls$|spreadsheet|excel|nomina|n[oó]mina|tss|constancia|recepcion/.test(s)) return -999;\n if (wanted === 'tss') {\n if (/tss|constancia|recepcion/.test(s)) score += 200;\n if (/nomina|n[oó]mina|field force/.test(s)) score -= 100;\n }\n if (wanted === 'n1') {\n if (/nomina|n[oó]mina|field force/.test(s)) score += 50;\n if (/\\b15\\b|_15| 15 |quincena 1|nomina 1|n[oó]mina 1|1q|primera/.test(s)) score += 200;\n if (/\\b30\\b|\\b28\\b|_30|_28|quincena 2|nomina 2|n[oó]mina 2|2q|segunda/.test(s)) score -= 50;\n if (/tss|constancia|recepcion/.test(s)) score -= 200;\n }\n if (wanted === 'n2') {\n if (/nomina|n[oó]mina|field force/.test(s)) score += 50;\n if (/\\b30\\b|\\b28\\b|_30|_28| 30 | 28 |quincena 2|nomina 2|n[oó]mina 2|2q|segunda/.test(s)) score += 200;\n if (/\\b15\\b|_15| 15 |quincena 1|nomina 1|n[oó]mina 1|1q|primera/.test(s)) score -= 50;\n if (/tss|constancia|recepcion/.test(s)) score -= 200;\n }\n return score;\n}\nconst candidates = Object.entries(bin).map(([key,file]) => ({ key, file, score: scoreFile(key,file), fileName: file.fileName || key })).filter(x => x.score > -100).sort((a,b) => b.score - a.score);\nif (!candidates.length || candidates[0].score <= 0) throw new Error('No pude identificar el archivo Nómina 2. Revisa que el nombre/campo del formulario sea claro.');\nconst chosen = candidates[0];\nreturn [{ json: { runId: base.json.runId, mes: base.json.mes, anio: base.json.anio, periodoLabel: base.json.periodoLabel, reportTitle: base.json.reportTitle, fileName: chosen.fileName }, binary: { data: chosen.file } }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-3056,
3408
],
"id": "27921c05-45a2-4188-956a-997554f7c1d8",
"name": "Preparar Nómina 2"
},
{
"parameters": {
"operation": "update",
"fileId": {
"__rl": true,
"value": "1IkdCdR6ZjqCHSFZ8_SGQx6DrNGq-ai5GxByAtyg1R8g",
"mode": "id"
},
"changeFileContent": true,
"options": {}
},
"type": "n8n-nodes-base.googleDrive",
"typeVersion": 3,
"position": [
-2784,
3408
],
"id": "878795ff-8290-4b37-8d6c-54e751ab1fb2",
"name": "Actualizar staging Nómina 2",
"credentials": {
"googleDriveOAuth2Api": {
"id": "g23xdGLZRzBGqKgH",
"name": "Isaac - Google Drive"
}
}
},
{
"parameters": {},
"type": "n8n-nodes-base.wait",
"typeVersion": 1.1,
"position": [
-2544,
3408
],
"id": "0c8ba62e-3681-475a-abc7-065ada6d4a8d",
"name": "Esperar conversión Nómina 2",
"webhookId": "e3618269-c64b-4090-b9af-29cb7413fa8a"
},
{
"parameters": {
"url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.id || $json.spreadsheetId || '1IkdCdR6ZjqCHSFZ8_SGQx6DrNGq-ai5GxByAtyg1R8g' }}?fields=spreadsheetId,sheets(properties(title,sheetId,hidden))",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleSheetsOAuth2Api",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.3,
"position": [
-2304,
3408
],
"id": "3e53e7ba-adbb-4234-adca-a009699d7065",
"name": "Listar hojas Nómina 2",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
}
},
{
"parameters": {
"jsCode": "const init = $items('Inicializar ejecución')[0].json;\nconst FALLBACK_ID = '1IkdCdR6ZjqCHSFZ8_SGQx6DrNGq-ai5GxByAtyg1R8g';\nconst SOURCE_LABEL = 'Nómina 2';\nconst PREP_NODE = 'Preparar Nómina 2';\nconst spreadsheetId = $json.spreadsheetId || $json.id || $json.fileId || FALLBACK_ID;\nfunction norm(s) { return String(s || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]/g, ''); }\nfunction quoteA1SheetName(title) {\n return \"'\" + String(title).replace(/'/g, \"''\") + \"'!A1:AZ3000\";\n}\nconst ignore = /resumen|validacion|nvscripts|^isr$|calculo|autocrat|do\\s*not\\s*delete|^hoja|^copia\\s*de/i;\nconst sheets = ($json.sheets || []).map(s => s.properties || {}).filter(p => p.title && !p.hidden);\nconst sheetTitles = [];\nconst sheetDisplayTitles = [];\nfor (const p of sheets) {\n // IMPORTANTÍSIMO: NO usar trim() para el título real de Google Sheets.\n // Algunas nóminas tienen hojas como \"GLM RD \" y \"Latam \" con espacio al final.\n // Si se recorta ese espacio, Google Sheets devuelve: Unable to parse range.\n const rawTitle = String(p.title || '');\n const displayTitle = rawTitle.trim();\n const n = norm(rawTitle);\n if (!displayTitle || ignore.test(displayTitle) || n.startsWith('copiade')) continue;\n sheetTitles.push(rawTitle);\n sheetDisplayTitles.push(displayTitle);\n}\nif (!sheetTitles.length) throw new Error(`No encontré hojas válidas de nómina en ${SOURCE_LABEL}.`);\nconst rangesQuery = sheetTitles\n .map(title => `ranges=${encodeURIComponent(quoteA1SheetName(title))}`)\n .join('&');\nreturn [{\n json: {\n runId: init.runId,\n mes: init.mes,\n anio: init.anio,\n spreadsheetId,\n rangesQuery,\n sheetTitles,\n sheetDisplayTitles,\n quarter: SOURCE_LABEL,\n fileName: $(PREP_NODE).item.json.fileName,\n }\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-2064,
3408
],
"id": "f588456e-f6d0-4ee2-84a4-88c6a33f3914",
"name": "Preparar hojas Nómina 2"
},
{
"parameters": {
"url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.spreadsheetId }}/values:batchGet?{{ $json.rangesQuery }}&majorDimension=ROWS&valueRenderOption=UNFORMATTED_VALUE&dateTimeRenderOption=SERIAL_NUMBER",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleSheetsOAuth2Api",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.3,
"position": [
-1824,
3408
],
"id": "347939ce-a9d9-4ae2-ac46-bbc50f5dd95e",
"name": "Leer hojas Nómina 2",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
}
},
{
"parameters": {
"jsCode": "function ascii(s){return String(s??'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'');}\nfunction clean(s){return String(s??'').replace(/\\s+/g,' ').trim();}\nfunction hnorm(s){return ascii(s).toLowerCase().replace(/[^a-z0-9]/g,'');}\nfunction nameNorm(s){return ascii(s).toUpperCase().replace(/[,.;:()\\[\\]{}]/g,' ').replace(/\\s+/g,' ').trim();}\nfunction toNumber(v){ if(v===null||v===undefined||v==='') return 0; if(typeof v==='number') return Number.isFinite(v)?v:0; let s=String(v).trim(); if(!s) return 0; s=s.replace(/RD\\$|DOP|USD|US\\$|\\$|,/gi,'').replace(/\\s/g,''); if(/^\\((.*)\\)$/.test(s)) s='-'+s.replace(/^\\(|\\)$/g,''); const n=Number(s); return Number.isFinite(n)?n:0; }\nfunction money(n){return Number((Number(n)||0).toFixed(2));}\nfunction rawDigits(v){ if(v===null||v===undefined) return ''; let s=String(v).trim(); if(/^\\d+(\\.0+)?$/.test(s)) s=s.replace(/\\.0+$/,''); return s.replace(/\\D/g,''); }\nfunction normalizeDoc(v){ const d=rawDigits(v); if(!d) return ''; if(d.length<=11) return d.padStart(11,'0'); return d; }\nfunction docIssue(v){ const d=rawDigits(v); if(!d) return 'vacío'; if(d.length<11) return 'documento corto normalizado con ceros'; if(d.length>11) return 'documento con más de 11 dígitos'; return ''; }\nfunction pad(n){return String(n).padStart(2,'0');}\nfunction excelSerialToYM(v){ const epoch=new Date(Date.UTC(1899,11,30)); const d=new Date(epoch.getTime()+Math.round(Number(v))*86400000); return `${pad(d.getUTCMonth()+1)}/${d.getUTCFullYear()}`; }\nfunction parseDateParts(a,b,y,targetMes){ a=Number(a); b=Number(b); let month; if(targetMes && String(a).padStart(2,'0')===targetMes) month=a; else if(targetMes && String(b).padStart(2,'0')===targetMes) month=b; else if(a>12 && b<=12) month=b; else if(b>12 && a<=12) month=a; else month=b; return `${pad(month)}/${String(y).length===2?'20'+y:y}`; }\nfunction parseYM(v,targetMes){ if(typeof v==='number'&&Number.isFinite(v)&&v>20000&&v<70000) return excelSerialToYM(v); const s=clean(v); if(!s) return ''; if(/^\\d+(\\.0+)?$/.test(s)){ const n=Number(s); if(n>20000&&n<70000) return excelSerialToYM(n); } let m=s.match(/(\\d{1,2})[\\/-](\\d{1,2})[\\/-](\\d{2,4})/); if(m) return parseDateParts(m[1],m[2],m[3],targetMes); m=s.match(/(\\d{4})[\\/-](\\d{1,2})[\\/-](\\d{1,2})/); if(m) return `${pad(Number(m[2]))}/${m[1]}`; m=s.match(/(\\d{1,2})[\\/-](\\d{4})/); if(m) return `${pad(Number(m[1]))}/${m[2]}`; return ''; }\nfunction col(headers,candidates,fallback=[]){ const hs=headers.map(hnorm); for(const c of candidates){ const i=hs.findIndex(h=>h===c); if(i>=0) return i; } for(const c of fallback){ const i=hs.findIndex(h=>h.includes(c)); if(i>=0) return i; } return -1; }\nfunction findHeader(values){ for(let r=0;r<Math.min(values.length,45);r++){ const row=values[r]||[]; const hs=row.map(hnorm); const hasDoc=hs.some(h=>h==='cedula'||h.includes('cedula')||h.includes('documento')); const hasName=hs.some(h=>h==='nombre'||h.includes('nombre')); const hasPeriod=hs.some(h=>h.includes('periodo')); const hasAfp=hs.some(h=>h==='afp'||h.includes('afp')); const hasSfs=hs.some(h=>h==='sfs'||h==='ars'||h.includes('sfs')||h.includes('ars')); if(hasDoc&&hasName&&hasPeriod&&hasAfp&&hasSfs) return r; } return -1; }\nfunction sheetNameFromRange(range){ const s=String(range||''); if(s.startsWith(\"'\")){ let out=''; for(let i=1;i<s.length;i++){ if(s[i]===\"'\" && s[i+1]===\"'\"){ out+=\"'\"; i++; continue; } if(s[i]===\"'\" && s[i+1]==='!') return out; out+=s[i]; } } return s.split('!')[0].replace(/^'|'$/g,''); }\nconst init=$items('Inicializar ejecución')[0].json;\nconst prep=$items('Preparar hojas Nómina 2')[0].json;\nconst expected=`${init.mes}/${init.anio}`;\nconst valueRanges=$json.valueRanges||[];\nconst allRecords=[]; const sheetSummaries=[]; const warnings=[];\nfor (let i=0;i<valueRanges.length;i++){\n const vr=valueRanges[i]||{};\n const values=vr.values||[];\n const sheetTitle=((prep.sheetDisplayTitles&&prep.sheetDisplayTitles[i])||(prep.sheetTitles&&prep.sheetTitles[i])||sheetNameFromRange(vr.range)||'Hoja').trim();\n const headerRow=findHeader(values);\n if(headerRow<0){ warnings.push({tipo:'Hoja ignorada sin encabezado de nómina',archivo:prep.fileName,hoja:sheetTitle,detalle:'No se detectó encabezado con PERÍODO, NOMBRE, CÉDULA, AFP y SFS/ARS.'}); continue; }\n const headers=values[headerRow]||[];\n const idx={ periodo:col(headers,['periodo'],['periodo']), nombre:col(headers,['nombre','nombrecompleto'],['nombre']), cedula:col(headers,['cedula','cedulaidentidad','documento','nodocumento'],['cedula','documento']), salPeriodo:col(headers,['salariodelperiodo','salarioperiodo'],['salariodelperiodo','salarioperiodo']), vacaciones:col(headers,['vacaciones'],['vacaciones']), afp:col(headers,['afp'],['afp']), sfs:col(headers,['sfs','ars'],['sfs','ars']), neto:col(headers,['netoapagar','neto'],['neto']), email:col(headers,['email','correo','correoelectronico','emailaddress','workemail','correotrabajo'],['email','correo']), novedad:col(headers,['novedad','observacion','comentario','comentarios'],['novedad','observacion','comentario']) };\n let count=0, salario=0, vac=0, afp=0, sfs=0, neto=0;\n for(let r=headerRow+1;r<values.length;r++){\n const row=values[r]||[];\n const periodoYM=parseYM(row[idx.periodo], init.mes);\n if(!periodoYM || periodoYM!==expected) continue;\n const nombre=clean(row[idx.nombre]);\n const docRaw=row[idx.cedula];\n const doc=normalizeDoc(docRaw);\n if(!nombre || !doc || rawDigits(docRaw).length<7) continue;\n const salarioPeriodo=money(toNumber(row[idx.salPeriodo]));\n const vacaciones=money(toNumber(row[idx.vacaciones]));\n const afpMonto=money(toNumber(row[idx.afp]));\n const sfsMonto=money(toNumber(row[idx.sfs]));\n const netoMonto=money(toNumber(row[idx.neto]));\n const baseSS=money(salarioPeriodo+vacaciones);\n if(baseSS===0 && afpMonto===0 && sfsMonto===0 && netoMonto===0) continue;\n const rec={ fuente:'Nómina 2', archivo:prep.fileName, hoja:sheetTitle, periodo:periodoYM, nombre, nombreKey:nameNorm(nombre), cedula_original:clean(docRaw), documento:doc, documento_issue:docIssue(docRaw), salario_periodo:salarioPeriodo, vacaciones, base_ss:baseSS, afp:afpMonto, sfs:sfsMonto, neto:netoMonto, correo: idx.email>=0?clean(row[idx.email]):'', novedad: idx.novedad>=0?clean(row[idx.novedad]):'' };\n allRecords.push(rec); count++; salario+=salarioPeriodo; vac+=vacaciones; afp+=afpMonto; sfs+=sfsMonto; neto+=netoMonto;\n }\n sheetSummaries.push({fuente:'Nómina 2', archivo:prep.fileName, hoja:sheetTitle, empleados:count, salario_periodo:money(salario), vacaciones:money(vac), base_ss:money(salario+vac), afp:money(afp), sfs:money(sfs), neto:money(neto)});\n}\nreturn [{json:{runId:init.runId, source:'Nómina 2', payrollRecords:allRecords, sheetSummaries, warnings, sourceFiles:{'Nómina 2': prep.fileName} }}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-1568,
3488
],
"id": "e16cdfc3-6944-4c6f-b65d-cbf9135f74f8",
"name": "Extraer Nómina 2"
},
{
"parameters": {
"jsCode": "const base = $items('Inicializar ejecución')[0];\nconst bin = base.binary || {};\nconst wanted = \"tss\";\nfunction ascii(s) { return String(s || '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase(); }\nfunction scoreFile(key, file) {\n const fileName = String(file.fileName || key);\n const s = ascii(`${key} ${fileName}`);\n let score = 0;\n if (!/\\.xlsx$|\\.xls$|spreadsheet|excel|nomina|n[oó]mina|tss|constancia|recepcion/.test(s)) return -999;\n if (wanted === 'tss') {\n if (/tss|constancia|recepcion/.test(s)) score += 200;\n if (/nomina|n[oó]mina|field force/.test(s)) score -= 100;\n }\n if (wanted === 'n1') {\n if (/nomina|n[oó]mina|field force/.test(s)) score += 50;\n if (/\\b15\\b|_15| 15 |quincena 1|nomina 1|n[oó]mina 1|1q|primera/.test(s)) score += 200;\n if (/\\b30\\b|\\b28\\b|_30|_28|quincena 2|nomina 2|n[oó]mina 2|2q|segunda/.test(s)) score -= 50;\n if (/tss|constancia|recepcion/.test(s)) score -= 200;\n }\n if (wanted === 'n2') {\n if (/nomina|n[oó]mina|field force/.test(s)) score += 50;\n if (/\\b30\\b|\\b28\\b|_30|_28| 30 | 28 |quincena 2|nomina 2|n[oó]mina 2|2q|segunda/.test(s)) score += 200;\n if (/\\b15\\b|_15| 15 |quincena 1|nomina 1|n[oó]mina 1|1q|primera/.test(s)) score -= 50;\n if (/tss|constancia|recepcion/.test(s)) score -= 200;\n }\n return score;\n}\nconst candidates = Object.entries(bin).map(([key,file]) => ({ key, file, score: scoreFile(key,file), fileName: file.fileName || key })).filter(x => x.score > -100).sort((a,b) => b.score - a.score);\nif (!candidates.length || candidates[0].score <= 0) throw new Error('No pude identificar el archivo TSS. Revisa que el nombre/campo del formulario sea claro.');\nconst chosen = candidates[0];\nreturn [{ json: { runId: base.json.runId, mes: base.json.mes, anio: base.json.anio, periodoLabel: base.json.periodoLabel, reportTitle: base.json.reportTitle, fileName: chosen.fileName }, binary: { data: chosen.file } }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-3056,
3680
],
"id": "8b121e4f-ce50-41b0-860e-7fabe9787dfb",
"name": "Preparar TSS"
},
{
"parameters": {
"operation": "update",
"fileId": {
"__rl": true,
"value": "1Lhm7wqqcaTsRGyDZCqTuRoT-VmAX_DXZm4jBM-mbrbs",
"mode": "id"
},
"changeFileContent": true,
"options": {}
},
"type": "n8n-nodes-base.googleDrive",
"typeVersion": 3,
"position": [
-2784,
3680
],
"id": "cf80a1eb-4d9a-4cb2-a60d-4798b1111a47",
"name": "Actualizar staging TSS",
"credentials": {
"googleDriveOAuth2Api": {
"id": "g23xdGLZRzBGqKgH",
"name": "Isaac - Google Drive"
}
}
},
{
"parameters": {},
"type": "n8n-nodes-base.wait",
"typeVersion": 1.1,
"position": [
-2544,
3680
],
"id": "80b699da-e417-4919-9fa6-7489ee65c06e",
"name": "Esperar conversión TSS",
"webhookId": "a10cfb0d-909d-4725-9b4b-20e3f16bcced"
},
{
"parameters": {
"url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.id || $json.spreadsheetId || '1Lhm7wqqcaTsRGyDZCqTuRoT-VmAX_DXZm4jBM-mbrbs' }}?fields=spreadsheetId,sheets(properties(title,sheetId,hidden))",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleSheetsOAuth2Api",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.3,
"position": [
-2304,
3680
],
"id": "50b7e202-05dc-4049-b848-6dceed625dde",
"name": "Listar hojas TSS",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
}
},
{
"parameters": {
"jsCode": "const init = $items('Inicializar ejecución')[0].json;\nconst spreadsheetId = $json.spreadsheetId || $json.id || $json.fileId || '1Lhm7wqqcaTsRGyDZCqTuRoT-VmAX_DXZm4jBM-mbrbs';\nconst sheets = ($json.sheets || []).map(s => s.properties || {}).filter(p => p.title && !p.hidden);\nconst preferred = sheets.find(s => String(s.title || '').trim().toLowerCase() === 'in') || sheets[0];\nif (!preferred) throw new Error('No encontré hoja visible en el archivo TSS.');\nconst sheetTitle = String(preferred.title || 'in').trim();\nconst rangesQuery = `ranges=${encodeURIComponent(\"'\" + sheetTitle.replace(/'/g, \"''\") + \"'!A1:AZ3000\")}`;\nreturn [{ json: { runId: init.runId, mes: init.mes, anio: init.anio, spreadsheetId, rangesQuery, sheetTitle, fileName: $('Preparar TSS').item.json.fileName } }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-2064,
3680
],
"id": "d1d7f96d-c6de-4bc7-879a-78062f6e8b72",
"name": "Preparar hoja TSS"
},
{
"parameters": {
"url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.spreadsheetId }}/values:batchGet?{{ $json.rangesQuery }}&majorDimension=ROWS&valueRenderOption=UNFORMATTED_VALUE&dateTimeRenderOption=SERIAL_NUMBER",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleSheetsOAuth2Api",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.3,
"position": [
-1824,
3680
],
"id": "870b54d6-c44d-4f95-b3ce-6245a096bd9c",
"name": "Leer hoja TSS",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
}
},
{
"parameters": {
"jsCode": "const input = $input.first();\nconst init = $items('Inicializar ejecución')[0].json;\nconst prep = $items('Preparar TSS')[0].json;\n\nif (!input.binary || !input.binary.data) {\n throw new Error('No se recibió el archivo binario de TSS.');\n}\n\nconst buffer = await this.helpers.getBinaryDataBuffer(0, 'data');\n\nfunction clean(value) {\n return String(value ?? '').replace(/\\s+/g, ' ').trim();\n}\n\nfunction ascii(value) {\n return String(value ?? '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction hnorm(value) {\n return ascii(value).toLowerCase().replace(/[^a-z0-9]/g, '');\n}\n\nfunction decodeInputBuffer(data) {\n if (!Buffer.isBuffer(data)) data = Buffer.from(data);\n\n // Formato oficial observado: HTML UTF-16 LE con BOM FF FE.\n if (data.length >= 2 && data[0] === 0xFF && data[1] === 0xFE) {\n return data.subarray(2).toString('utf16le');\n }\n\n // Soporte defensivo para UTF-16 BE.\n if (data.length >= 2 && data[0] === 0xFE && data[1] === 0xFF) {\n const swapped = Buffer.from(data.subarray(2));\n if (swapped.length % 2 !== 0) {\n throw new Error('El archivo TSS UTF-16 BE tiene una longitud de bytes inválida.');\n }\n swapped.swap16();\n return swapped.toString('utf16le');\n }\n\n // Algunos exportadores omiten el BOM. Detectar UTF-16 LE por bytes nulos.\n const sampleLength = Math.min(data.length, 400);\n let oddNulls = 0;\n let evenNulls = 0;\n for (let i = 0; i < sampleLength; i++) {\n if (data[i] === 0) {\n if (i % 2 === 0) evenNulls++;\n else oddNulls++;\n }\n }\n if (oddNulls > sampleLength * 0.20 && oddNulls > evenNulls * 3) {\n return data.toString('utf16le');\n }\n\n // Compatibilidad defensiva con HTML UTF-8.\n return data.toString('utf8').replace(/^\\uFEFF/, '');\n}\n\nfunction decodeHtmlEntities(value) {\n const named = {\n amp: '&',\n lt: '<',\n gt: '>',\n quot: '\"',\n apos: \"'\",\n nbsp: ' ',\n aacute: 'á',\n eacute: 'é',\n iacute: 'í',\n oacute: 'ó',\n uacute: 'ú',\n Aacute: 'Á',\n Eacute: 'É',\n Iacute: 'Í',\n Oacute: 'Ó',\n Uacute: 'Ú',\n ntilde: 'ñ',\n Ntilde: 'Ñ',\n uuml: 'ü',\n Uuml: 'Ü'\n };\n\n return String(value ?? '')\n .replace(/&#x([0-9a-f]+);/gi, (_, hex) => {\n const cp = Number.parseInt(hex, 16);\n return Number.isFinite(cp) ? String.fromCodePoint(cp) : '';\n })\n .replace(/&#(\\d+);/g, (_, dec) => {\n const cp = Number.parseInt(dec, 10);\n return Number.isFinite(cp) ? String.fromCodePoint(cp) : '';\n })\n .replace(/&([a-zA-Z]+);/g, (all, name) => Object.prototype.hasOwnProperty.call(named, name) ? named[name] : all);\n}\n\nfunction cellText(html) {\n return clean(\n decodeHtmlEntities(\n String(html ?? '')\n .replace(/<br\\s*\\/?>/gi, ' ')\n .replace(/<\\/p\\s*>/gi, ' ')\n .replace(/<!--[\\s\\S]*?-->/g, '')\n .replace(/<[^>]+>/g, ' ')\n )\n );\n}\n\nfunction parseHtmlTable(html) {\n if (!/<table\\b/i.test(html) || !/<tr\\b/i.test(html) || !/<t[dh]\\b/i.test(html)) {\n throw new Error(\n 'El archivo TSS no contiene la tabla HTML oficial esperada. ' +\n 'Carga directamente el archivo .xls descargado de la TSS, sin abrirlo ni volverlo a guardar.'\n );\n }\n\n const rows = [];\n const trRegex = /<tr\\b[^>]*>([\\s\\S]*?)<\\/tr\\s*>/gi;\n let trMatch;\n\n while ((trMatch = trRegex.exec(html)) !== null) {\n const rowHtml = trMatch[1];\n const row = [];\n const cellRegex = /<t[dh]\\b[^>]*>([\\s\\S]*?)<\\/t[dh]\\s*>/gi;\n let cellMatch;\n\n while ((cellMatch = cellRegex.exec(rowHtml)) !== null) {\n row.push(cellText(cellMatch[1]));\n }\n\n if (row.length) rows.push(row);\n }\n\n if (!rows.length) {\n throw new Error('No se pudieron extraer filas de la tabla del archivo TSS oficial.');\n }\n\n return rows;\n}\n\nfunction findHeaderRow(rows) {\n const max = Math.min(rows.length, 30);\n for (let i = 0; i < max; i++) {\n const headers = (rows[i] || []).map(hnorm);\n const required = ['idnomina', 'nodocumento', 'nombres', 'salarioss', 'periodoaplicacion'];\n if (required.every(name => headers.includes(name))) return i;\n }\n return -1;\n}\n\nfunction findColumn(headers, exactNames, includesNames = []) {\n const normalized = headers.map(hnorm);\n for (const name of exactNames) {\n const index = normalized.findIndex(header => header === name);\n if (index >= 0) return index;\n }\n for (const name of includesNames) {\n const index = normalized.findIndex(header => header.includes(name));\n if (index >= 0) return index;\n }\n return -1;\n}\n\nfunction toNumber(value) {\n if (value === null || value === undefined || value === '') return 0;\n if (typeof value === 'number') return Number.isFinite(value) ? value : 0;\n\n let text = String(value)\n .trim()\n .replace(/RD\\$|DOP|USD|US\\$|\\$|,/gi, '')\n .replace(/\\s/g, '');\n\n if (/^\\((.*)\\)$/.test(text)) {\n text = '-' + text.replace(/^\\(|\\)$/g, '');\n }\n\n const number = Number(text);\n return Number.isFinite(number) ? number : 0;\n}\n\nfunction money(value) {\n return Number((Number(value) || 0).toFixed(2));\n}\n\nfunction rawDigits(value) {\n if (value === null || value === undefined) return '';\n let text = String(value).trim();\n if (/^\\d+(\\.0+)?$/.test(text)) text = text.replace(/\\.0+$/, '');\n return text.replace(/\\D/g, '');\n}\n\nfunction normalizeDocument(value) {\n const digits = rawDigits(value);\n if (!digits) return '';\n if (digits.length <= 11) return digits.padStart(11, '0');\n return digits;\n}\n\nfunction documentIssue(value) {\n const digits = rawDigits(value);\n if (!digits) return 'vacío';\n if (digits.length < 11) return 'documento corto normalizado con ceros';\n if (digits.length > 11) return 'documento con más de 11 dígitos';\n return '';\n}\n\nfunction pad(value) {\n return String(value).padStart(2, '0');\n}\n\nfunction excelSerialToYM(value) {\n const epoch = new Date(Date.UTC(1899, 11, 30));\n const date = new Date(epoch.getTime() + Math.round(Number(value)) * 86400000);\n return `${pad(date.getUTCMonth() + 1)}/${date.getUTCFullYear()}`;\n}\n\nfunction parseDateParts(a, b, year, targetMonth) {\n a = Number(a);\n b = Number(b);\n let month;\n\n if (targetMonth && String(a).padStart(2, '0') === targetMonth) month = a;\n else if (targetMonth && String(b).padStart(2, '0') === targetMonth) month = b;\n else if (a > 12 && b <= 12) month = b;\n else if (b > 12 && a <= 12) month = a;\n else month = b;\n\n return `${pad(month)}/${String(year).length === 2 ? '20' + year : year}`;\n}\n\nfunction parseYM(value, targetMonth) {\n if (typeof value === 'number' && Number.isFinite(value) && value > 20000 && value < 70000) {\n return excelSerialToYM(value);\n }\n\n const text = clean(value);\n if (!text) return '';\n\n if (/^\\d+(\\.0+)?$/.test(text)) {\n const number = Number(text);\n if (number > 20000 && number < 70000) return excelSerialToYM(number);\n }\n\n let match = text.match(/(\\d{1,2})[\\/-](\\d{1,2})[\\/-](\\d{2,4})/);\n if (match) return parseDateParts(match[1], match[2], match[3], targetMonth);\n\n match = text.match(/(\\d{4})[\\/-](\\d{1,2})[\\/-](\\d{1,2})/);\n if (match) return `${pad(Number(match[2]))}/${match[1]}`;\n\n match = text.match(/(\\d{1,2})[\\/-](\\d{4})/);\n if (match) return `${pad(Number(match[1]))}/${match[2]}`;\n\n return '';\n}\n\nconst html = decodeInputBuffer(buffer);\nconst rows = parseHtmlTable(html);\nconst headerRow = findHeaderRow(rows);\n\nif (headerRow < 0) {\n throw new Error(\n 'No se encontró el encabezado oficial de la TSS. ' +\n 'Se esperaban columnas como ID_NOMINA, NO_DOCUMENTO, NOMBRES, SALARIO_SS y PERIODO_APLICACION.'\n );\n}\n\nconst headers = rows[headerRow] || [];\nconst index = {\n idNomina: findColumn(headers, ['idnomina'], ['idnomina']),\n document: findColumn(headers, ['nodocumento'], ['documento']),\n names: findColumn(headers, ['nombres'], ['nombres', 'nombre']),\n reference: findColumn(headers, ['idreferencia'], ['referencia']),\n nss: findColumn(headers, ['idnss'], ['nss']),\n salary: findColumn(headers, ['salarioss'], ['salarioss']),\n sfsEmployee: findColumn(headers, ['aporteafiliadossfs'], ['afiliadossfs']),\n sfsEmployer: findColumn(headers, ['aporteempleadorsfs'], ['empleadorsfs']),\n afpEmployee: findColumn(headers, ['aporteafiliadossvds'], ['afiliadossvds']),\n afpEmployer: findColumn(headers, ['aporteempleadorsvds'], ['empleadorsvds']),\n adjustment: findColumn(headers, ['montoajuste'], ['ajuste']),\n srl: findColumn(headers, ['aportesrl'], ['srl']),\n voluntary: findColumn(headers, ['aportevoluntario'], ['voluntario']),\n perCapita: findColumn(headers, ['percapitaadicional'], ['percapita']),\n interests: findColumn(headers, ['totalinteresesrecargos'], ['intereses', 'recargos']),\n total: findColumn(headers, ['totalgeneraldetfactura'], ['totalgeneral']),\n period: findColumn(headers, ['periodoaplicacion'], ['periodo']),\n reportedSalary: findColumn(headers, ['salariossreportado'], ['salariossreportado'])\n};\n\nconst requiredColumns = {\n NO_DOCUMENTO: index.document,\n NOMBRES: index.names,\n SALARIO_SS: index.salary,\n APORTE_AFILIADOS_SFS: index.sfsEmployee,\n APORTE_AFILIADOS_SVDS: index.afpEmployee,\n PERIODO_APLICACION: index.period\n};\n\nconst missingColumns = Object.entries(requiredColumns)\n .filter(([, columnIndex]) => columnIndex < 0)\n .map(([name]) => name);\n\nif (missingColumns.length) {\n throw new Error(\n `El archivo TSS oficial no contiene las columnas requeridas: ${missingColumns.join(', ')}. ` +\n `Archivo: ${prep.fileName || 'TSS'}.`\n );\n}\n\nconst expectedPeriod = `${init.mes}/${init.anio}`;\nconst records = [];\nconst warnings = [];\nconst detectedPeriods = new Set();\n\nfor (let rowIndex = headerRow + 1; rowIndex < rows.length; rowIndex++) {\n const row = rows[rowIndex] || [];\n const periodYM = parseYM(row[index.period], init.mes);\n if (periodYM) detectedPeriods.add(periodYM);\n if (!periodYM || periodYM !== expectedPeriod) continue;\n\n const originalDocument = clean(row[index.document]);\n const document = normalizeDocument(originalDocument);\n const names = clean(row[index.names]);\n\n if (!document || rawDigits(originalDocument).length < 7 || !names) continue;\n\n const record = {\n archivo: prep.fileName,\n hoja: 'Tabla oficial TSS',\n id_nomina: index.idNomina >= 0 ? clean(row[index.idNomina]) : '',\n documento: document,\n documento_original: originalDocument,\n documento_issue: documentIssue(originalDocument),\n nombres: names,\n nombreKey: ascii(names).toUpperCase().replace(/[,.;:()\\[\\]{}]/g, ' ').replace(/\\s+/g, ' ').trim(),\n id_referencia: index.reference >= 0 ? rawDigits(row[index.reference]).padStart(16, '0') : '',\n id_nss: index.nss >= 0 ? clean(row[index.nss]) : '',\n salario_ss: money(toNumber(row[index.salary])),\n salario_ss_reportado: index.reportedSalary >= 0 ? money(toNumber(row[index.reportedSalary])) : 0,\n sfs_empleado: money(toNumber(row[index.sfsEmployee])),\n sfs_patronal: index.sfsEmployer >= 0 ? money(toNumber(row[index.sfsEmployer])) : 0,\n afp_empleado: money(toNumber(row[index.afpEmployee])),\n afp_patronal: index.afpEmployer >= 0 ? money(toNumber(row[index.afpEmployer])) : 0,\n ajuste: index.adjustment >= 0 ? money(toNumber(row[index.adjustment])) : 0,\n srl: index.srl >= 0 ? money(toNumber(row[index.srl])) : 0,\n voluntario: index.voluntary >= 0 ? money(toNumber(row[index.voluntary])) : 0,\n per_capita: index.perCapita >= 0 ? money(toNumber(row[index.perCapita])) : 0,\n intereses: index.interests >= 0 ? money(toNumber(row[index.interests])) : 0,\n total_factura: index.total >= 0 ? money(toNumber(row[index.total])) : 0,\n periodo: periodYM\n };\n\n const calculatedTotal = money(\n record.sfs_empleado +\n record.sfs_patronal +\n record.afp_empleado +\n record.afp_patronal +\n record.srl +\n record.per_capita +\n record.ajuste +\n record.voluntario +\n record.intereses\n );\n\n if (Math.abs(calculatedTotal - record.total_factura) > 0.02) {\n warnings.push({\n tipo: 'Diferencia interna TSS',\n documento: document,\n nombre: record.nombres,\n total_factura: record.total_factura,\n calculado: calculatedTotal,\n diferencia: money(calculatedTotal - record.total_factura)\n });\n }\n\n records.push(record);\n}\n\nif (!records.length) {\n const detected = [...detectedPeriods].join(', ') || 'ninguno';\n throw new Error(\n `El archivo TSS oficial fue leído, pero no contiene registros del período ${expectedPeriod}. ` +\n `Períodos detectados: ${detected}.`\n );\n}\n\nreturn [{\n json: {\n runId: init.runId,\n source: 'TSS',\n tssRecords: records,\n warnings,\n sourceFiles: { TSS: prep.fileName },\n tssInputFormat: 'HTML UTF-16 oficial TSS',\n tssRowsRead: records.length\n }\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-1568,
3680
],
"id": "83257326-2763-4d60-bd5c-3980ef9259ab",
"name": "Extraer TSS"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
-1120,
3344
],
"id": "77a68090-b5c5-4e19-8119-2da167cab39f",
"name": "Esperar nóminas"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
-880,
3488
],
"id": "bd0c5086-1d86-4555-8c90-2d1564f91291",
"name": "Esperar TSS y nóminas"
},
{
"parameters": {
"jsCode": "function ascii(s){return String(s??'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'');}\nfunction clean(s){return String(s??'').replace(/\\s+/g,' ').trim();}\nfunction nameNorm(s){return ascii(s).toUpperCase().replace(/[,.;:()\\[\\]{}]/g,' ').replace(/\\s+/g,' ').trim();}\nfunction tokens(s){ return nameNorm(s).split(' ').filter(t=>t.length>=3&&!['DEL','LOS','LAS','UNA','UNO','DE','LA','EL','Y','DA','DAS','DOS'].includes(t)); }\nfunction nameScore(a,b){\n const A=[...new Set(tokens(a))], B=[...new Set(tokens(b))];\n if(!A.length||!B.length) return 0;\n const inter=A.filter(t=>B.includes(t)).length;\n return inter/Math.min(A.length,B.length);\n}\nfunction bestNameScore(names,b){ return Math.max(...[...names].map(n=>nameScore(n,b)), 0); }\nfunction money(n){return Number((Number(n)||0).toFixed(2));}\nfunction abs(n){return Math.abs(Number(n)||0);}\nfunction fmt(n){ return (Number(n)||0).toLocaleString('es-DO',{minimumFractionDigits:2,maximumFractionDigits:2}); }\nfunction fmt0(n){ return (Number(n)||0).toLocaleString('es-DO'); }\nfunction hasRealDiff(d){ return abs(d.base)>1 || abs(d.afp)>1 || abs(d.sfs)>1; }\nfunction totalDiff(d){ return abs(d.base)+abs(d.afp)+abs(d.sfs); }\nfunction amountDiffTotal(p,t){ return abs(money(p.base_ss-t.salario_ss))+abs(money(p.afp-t.afp))+abs(money(p.sfs-t.sfs)); }\nfunction maxRowNameScore(cluster, name){ return Math.max(...cluster.rows.map(r=>nameScore(r.nombre,name)), nameScore(cluster.nombre,name), 0); }\n\nfunction normalizeEmail(value){\n return clean(value).toLowerCase().replace(/^mailto:/,'').trim();\n}\nfunction bambooNormalizeName(s){\n return clean(s)\n .normalize('NFD').replace(/[\\u0300-\\u036f]/g,'')\n .toUpperCase()\n .replace(/[^A-Z0-9 ]/g,' ')\n .replace(/\\s+/g,' ')\n .trim();\n}\nconst BAMBOO_TOKEN_EQUIVALENCES = new Map([\n ['VASQUEZ','VAZQUEZ']\n]);\nfunction canonicalBambooToken(token){\n return BAMBOO_TOKEN_EQUIVALENCES.get(token)||token;\n}\nfunction canonicalBambooName(s){\n return bambooNormalizeName(s)\n .split(' ')\n .map(canonicalBambooToken)\n .join(' ');\n}\nfunction bambooTokens(name){\n const stop = new Set(['DE','DEL','LA','LAS','LOS','Y','DA','DAS','DO','VDA']);\n return [...new Set(\n canonicalBambooName(name)\n .split(' ')\n .filter(t=>t.length>1&&!stop.has(t))\n )];\n}\nfunction levenshteinDistance(a,b){\n a=String(a||'');\n b=String(b||'');\n if(a===b) return 0;\n if(!a.length) return b.length;\n if(!b.length) return a.length;\n if(a.length>b.length) [a,b]=[b,a];\n let previous=Array.from({length:a.length+1},(_,i)=>i);\n for(let j=1;j<=b.length;j++){\n const current=[j];\n for(let i=1;i<=a.length;i++){\n current[i]=Math.min(\n current[i-1]+1,\n previous[i]+1,\n previous[i-1]+(a[i-1]===b[j-1]?0:1)\n );\n }\n previous=current;\n }\n return previous[a.length];\n}\nfunction bambooTokenSimilarity(a,b){\n a=canonicalBambooToken(String(a||''));\n b=canonicalBambooToken(String(b||''));\n if(!a||!b) return 0;\n if(a===b) return 1;\n const maxLen=Math.max(a.length,b.length);\n const minLen=Math.min(a.length,b.length);\n if(minLen<3) return 0;\n const distance=levenshteinDistance(a,b);\n const ratio=1-(distance/maxLen);\n if(distance===1&&maxLen>=4) return 0.93;\n if(distance===2&&maxLen>=7&&ratio>=0.72) return 0.82;\n if((a.startsWith(b)||b.startsWith(a))&&minLen>=4&&ratio>=0.75) return 0.86;\n return ratio>=0.82?ratio:0;\n}\nfunction compareBambooNames(a,b){\n const A=bambooTokens(a);\n const B=bambooTokens(b);\n if(!A.length||!B.length){\n return {\n score:0,matched:0,exactShared:0,shortCoverage:0,longCoverage:0,\n firstNameSimilarity:0,strongSubset:false,broadStrong:false\n };\n }\n\n const pairs=[];\n for(let i=0;i<A.length;i++){\n for(let j=0;j<B.length;j++){\n const similarity=bambooTokenSimilarity(A[i],B[j]);\n if(similarity>=0.72) pairs.push({i,j,similarity,exact:A[i]===B[j]});\n }\n }\n pairs.sort((x,y)=>y.similarity-x.similarity||Number(y.exact)-Number(x.exact));\n\n const usedA=new Set();\n const usedB=new Set();\n let sum=0;\n let matched=0;\n let exactShared=0;\n for(const pair of pairs){\n if(usedA.has(pair.i)||usedB.has(pair.j)) continue;\n usedA.add(pair.i);\n usedB.add(pair.j);\n sum+=pair.similarity;\n matched++;\n if(pair.exact) exactShared++;\n }\n\n const minLen=Math.min(A.length,B.length);\n const maxLen=Math.max(A.length,B.length);\n const shortCoverage=sum/minLen;\n const longCoverage=sum/maxLen;\n const firstNameSimilarity=bambooTokenSimilarity(A[0],B[0]);\n const strongSubset=\n minLen>=2 &&\n matched===minLen &&\n shortCoverage>=0.88 &&\n exactShared>=2;\n const broadStrong=\n minLen>=3 &&\n matched>=minLen-1 &&\n shortCoverage>=0.82 &&\n exactShared>=2 &&\n firstNameSimilarity>=0.78;\n const score=\n (shortCoverage*0.65)+\n (longCoverage*0.25)+\n (firstNameSimilarity*0.10);\n\n return {\n score,matched,exactShared,shortCoverage,longCoverage,\n firstNameSimilarity,strongSubset,broadStrong\n };\n}\nfunction bambooStatusLabel(value){\n const status=bambooNormalizeName(value);\n if(status==='ACTIVE'||status==='ACTIVO') return 'Activo';\n if(status==='INACTIVE'||status==='INACTIVO') return 'Inactivo';\n return clean(value);\n}\nfunction isBambooActive(value){\n return bambooStatusLabel(value)==='Activo';\n}\nfunction uniqueStrings(values,normalizer=clean){\n const byKey=new Map();\n for(const value of values||[]){\n const raw=clean(value);\n const key=normalizer(raw);\n if(raw&&key&&!byKey.has(key)) byKey.set(key,raw);\n }\n return [...byKey.values()];\n}\nfunction buildBambooNameIndex(records){\n const exact=new Map();\n const byToken=new Map();\n const byEmail=new Map();\n for(const record of records||[]){\n const canonical=canonicalBambooName(record.nombre_bamboo||'');\n if(canonical){\n if(!exact.has(canonical)) exact.set(canonical,[]);\n exact.get(canonical).push(record);\n for(const token of bambooTokens(canonical)){\n if(!byToken.has(token)) byToken.set(token,[]);\n byToken.get(token).push(record);\n }\n }\n const email=normalizeEmail(record.correo||'');\n if(email){\n if(!byEmail.has(email)) byEmail.set(email,[]);\n byEmail.get(email).push(record);\n }\n }\n return {exact,byToken,byEmail};\n}\nfunction indexedBambooCandidates(names,emails,index){\n const candidates=new Set();\n for(const name of names||[]){\n for(const token of bambooTokens(name)){\n for(const record of index.byToken.get(token)||[]) candidates.add(record);\n }\n }\n for(const email of emails||[]){\n for(const record of index.byEmail.get(normalizeEmail(email))||[]) candidates.add(record);\n }\n return [...candidates];\n}\nfunction evaluateBambooCandidate(names,emails,record){\n let bestName=null;\n for(const name of names){\n const comparison=compareBambooNames(name,record.nombre_bamboo||'');\n if(!bestName||comparison.score>bestName.comparison.score){\n bestName={name,comparison};\n }\n }\n const payrollEmails=new Set((emails||[]).map(normalizeEmail).filter(Boolean));\n const bambooEmail=normalizeEmail(record.correo||'');\n const emailExact=Boolean(bambooEmail&&payrollEmails.has(bambooEmail));\n const c=bestName?.comparison||compareBambooNames('','');\n const nameAccepted=c.strongSubset||c.broadStrong;\n const emailSupportedName=\n emailExact &&\n c.matched>=2 &&\n c.exactShared>=1 &&\n c.shortCoverage>=0.70 &&\n c.firstNameSimilarity>=0.75;\n const accepted=nameAccepted||emailSupportedName;\n const score=c.score+(emailExact?0.12:0)+(isBambooActive(record.estado_bamboo)?0.002:0);\n return {\n record,\n score,\n accepted,\n emailExact,\n nameAccepted,\n matchedName:bestName?.name||'',\n comparison:c\n };\n}\nfunction chooseExactBambooRecord(records,emails){\n const normalizedEmails=new Set((emails||[]).map(normalizeEmail).filter(Boolean));\n const byEmail=(records||[]).filter(record=>normalizedEmails.has(normalizeEmail(record.correo||'')));\n if(byEmail.length===1) return byEmail[0];\n const active=(records||[]).filter(record=>isBambooActive(record.estado_bamboo));\n if(active.length===1) return active[0];\n return (records||[])[0]||null;\n}\nfunction matchBambooEmployee(inputNames,inputEmails,index){\n const names=uniqueStrings(\n Array.isArray(inputNames)?inputNames:[inputNames],\n canonicalBambooName\n );\n const emails=uniqueStrings(\n Array.isArray(inputEmails)?inputEmails:[inputEmails],\n normalizeEmail\n );\n if(!names.length) return {record:null,score:0,method:'sin nombre'};\n\n const exactRecords=new Set();\n for(const name of names){\n for(const record of index.exact.get(canonicalBambooName(name))||[]) exactRecords.add(record);\n }\n if(exactRecords.size){\n const record=chooseExactBambooRecord([...exactRecords],emails);\n return {record,score:1,method:'nombre exacto'};\n }\n\n const candidates=indexedBambooCandidates(names,emails,index)\n .map(record=>evaluateBambooCandidate(names,emails,record))\n .filter(item=>item.accepted)\n .sort((a,b)=>b.score-a.score);\n\n if(!candidates.length) return {record:null,score:0,method:'sin coincidencia'};\n\n const best=candidates[0];\n const second=candidates[1];\n const margin=second?best.score-second.score:best.score;\n const emailBreaksTie=best.emailExact&&(!second||!second.emailExact);\n const clearName=best.nameAccepted&&(\n !second ||\n margin>=0.08 ||\n best.comparison.strongSubset&&!second.comparison.strongSubset\n );\n\n if(emailBreaksTie||clearName||!second){\n return {\n record:best.record,\n score:best.score,\n method:best.emailExact?'nombre robusto + correo de apoyo':'nombre robusto'\n };\n }\n return {record:null,score:0,method:'coincidencia ambigua'};\n}\n\nconst init=$items('Inicializar ejecución')[0].json;\nconst incoming=$input.all().map(i=>i.json||{});\nconst payrollRecords=incoming.flatMap(j=>Array.isArray(j.payrollRecords)?j.payrollRecords:[]);\nconst tssRecords=incoming.flatMap(j=>Array.isArray(j.tssRecords)?j.tssRecords:[]);\nconst bambooRecords=incoming.flatMap(j=>Array.isArray(j.bambooRecords)?j.bambooRecords:[]);\nconst slot={\n sheetSummaries: incoming.flatMap(j=>Array.isArray(j.sheetSummaries)?j.sheetSummaries:[]),\n warnings: incoming.flatMap(j=>Array.isArray(j.warnings)?j.warnings:[]),\n sourceFiles: {},\n};\nfor (const j of incoming) Object.assign(slot.sourceFiles, j.sourceFiles || {});\n\nif(!payrollRecords.length) {\n throw new Error('No se extrajeron registros de nómina para el período seleccionado. Revisa PERÍODO, mes/año seleccionado y hojas válidas.');\n}\nif(!tssRecords.length) {\n throw new Error('No se extrajeron registros de TSS para el período seleccionado. Revisa PERIODO_APLICACION del mes/año seleccionado.');\n}\nif(!bambooRecords.length) {\n throw new Error('No se recibieron empleados de BambooHR para generar la hoja Nómina sin BambooHR.');\n}\n\nconst bambooNameIndex=buildBambooNameIndex(bambooRecords);\nconst bambooMatchCache=new Map();\nfunction getBambooMatch(names,emails=[]){\n const nameList=uniqueStrings(\n Array.isArray(names)?names:[names],\n canonicalBambooName\n );\n const emailList=uniqueStrings(\n Array.isArray(emails)?emails:[emails],\n normalizeEmail\n );\n const key=[\n nameList.map(canonicalBambooName).sort().join('|'),\n emailList.map(normalizeEmail).sort().join('|')\n ].join('::');\n if(!bambooMatchCache.has(key)){\n bambooMatchCache.set(key,matchBambooEmployee(nameList,emailList,bambooNameIndex));\n }\n return bambooMatchCache.get(key);\n}\nfunction bambooInfoForNames(names,emails=[]){\n const match=getBambooMatch(names,emails);\n return {\n esta_bamboo:match.record?'Sí':'No',\n fecha_ingreso:match.record?.fecha_ingreso||'',\n estado_bamboo:bambooStatusLabel(match.record?.estado_bamboo||''),\n nombre_bamboo:match.record?.nombre_bamboo||''\n };\n}\n\n// ============================================================\n// 1) CLUSTER DE NÓMINA: agrupa por identidad real.\n// - Mismo documento + nombre parecido = misma persona.\n// - Documento distinto + nombre muy parecido = cambio/corrección documental.\n// - Mismo documento + nombre diferente = NO se mezcla automáticamente.\n// Esto evita el falso cruce Juan Luis Paulino / Cesar Medina.\n// ============================================================\nfunction createCluster(r){\n const names=new Set([r.nombre]);\n const docs=new Set([r.documento]);\n const sheets=new Set([r.hoja]);\n const quarters=new Set([r.fuente]);\n const novedades=new Set(r.novedad ? [r.novedad] : []);\n const emails=new Set(r.correo ? [normalizeEmail(r.correo)] : []);\n return {\n nombre:r.nombre,\n names,\n docs,\n sheets,\n quarters,\n novedades,\n emails,\n rows:[r],\n base_ss:r.base_ss,\n afp:r.afp,\n sfs:r.sfs,\n neto:r.neto,\n shortDocs: r.documento_issue ? [r] : [],\n };\n}\nfunction mergeCluster(c,r){\n c.rows.push(r);\n c.names.add(r.nombre);\n c.docs.add(r.documento);\n c.sheets.add(r.hoja);\n c.quarters.add(r.fuente);\n if(r.novedad) c.novedades.add(r.novedad);\n if(r.correo) c.emails.add(normalizeEmail(r.correo));\n if(r.documento_issue) c.shortDocs.push(r);\n c.base_ss += r.base_ss;\n c.afp += r.afp;\n c.sfs += r.sfs;\n c.neto += r.neto;\n // Usar el nombre más completo como nombre visible.\n if(tokens(r.nombre).length > tokens(c.nombre).length) c.nombre = r.nombre;\n}\nfunction clusterPayroll(records){\n const clusters=[];\n for(const r of records){\n let best=null;\n for(const c of clusters){\n const sameDoc=c.docs.has(r.documento);\n const score=maxRowNameScore(c, r.nombre);\n const sameDocSimilar = sameDoc && score >= 0.45;\n const sameDocSingleKnown = sameDoc && c.names.size === 1 && score >= 0.35;\n const docChangeSamePerson = !sameDoc && score >= 0.82;\n if(sameDocSimilar || sameDocSingleKnown || docChangeSamePerson){\n const rank=(sameDoc?1000:500)+(score*100);\n if(!best || rank>best.rank) best={cluster:c,rank};\n }\n }\n if(best) mergeCluster(best.cluster,r);\n else clusters.push(createCluster(r));\n }\n for(const c of clusters){\n c.base_ss=money(c.base_ss); c.afp=money(c.afp); c.sfs=money(c.sfs); c.neto=money(c.neto);\n c.docsArray=[...c.docs]; c.namesArray=[...c.names]; c.sheetsArray=[...c.sheets]; c.quartersArray=[...c.quarters]; c.novedadesArray=[...c.novedades]; c.emailsArray=[...c.emails].filter(Boolean);\n }\n return clusters;\n}\n\n// ============================================================\n// 2) TSS por documento.\n// ============================================================\nfunction groupTssByDoc(records){\n const map=new Map();\n for(const r of records){\n if(!map.has(r.documento)){\n map.set(r.documento,{\n documento:r.documento,\n nombres:r.nombres,\n ids_nomina:new Set(),\n rows:[],\n salario_ss:0,\n afp:0,\n sfs:0,\n sfs_patronal:0,\n afp_patronal:0,\n srl:0,\n per_capita:0,\n total_factura:0\n });\n }\n const g=map.get(r.documento);\n g.ids_nomina.add(r.id_nomina);\n g.rows.push(r);\n g.salario_ss+=r.salario_ss;\n g.afp+=r.afp_empleado;\n g.sfs+=r.sfs_empleado;\n g.sfs_patronal+=r.sfs_patronal;\n g.afp_patronal+=r.afp_patronal;\n g.srl+=r.srl;\n g.per_capita+=r.per_capita;\n g.total_factura+=r.total_factura;\n if(tokens(r.nombres).length > tokens(g.nombres).length) g.nombres = r.nombres;\n }\n for(const g of map.values()){\n for(const k of ['salario_ss','afp','sfs','sfs_patronal','afp_patronal','srl','per_capita','total_factura']) g[k]=money(g[k]);\n g.idNominaArray=[...g.ids_nomina].filter(Boolean);\n }\n return map;\n}\n\n// ============================================================\n// 3) Match robusto nómina vs TSS.\n// Selecciona entre documentos posibles por nombre + monto.\n// Evita tomar el primer documento que exista en TSS.\n// ============================================================\nfunction candidateRank(cluster,tss,source){\n const score=bestNameScore(cluster.names,tss.nombres);\n const diff=amountDiffTotal(cluster,tss);\n let rank=0;\n rank += score*1000;\n if(diff <= 1.00) rank += 500;\n else if(diff <= 2.00) rank += 350;\n else if(diff <= 10.00) rank += 100;\n if(source === 'documento') rank += 250;\n if(score < 0.20 && cluster.docsArray.length > 1) rank -= 900;\n return {score,diff,rank};\n}\nfunction chooseTssForCluster(cluster,tssByDoc,usedTss){\n const docCandidates=[];\n for(const d of cluster.docsArray){\n const t=tssByDoc.get(d);\n if(t && !usedTss.has(t.documento)){\n const r=candidateRank(cluster,t,'documento');\n docCandidates.push({t,method:'documento',...r});\n }\n }\n if(docCandidates.length){\n docCandidates.sort((a,b)=>b.rank-a.rank);\n // Si hay varios documentos posibles, exigir que el elegido sea razonable.\n // Si solo hay uno, se usa por documento aunque tenga diferencia real.\n if(docCandidates.length === 1) return docCandidates[0];\n const strong=docCandidates.find(c=>c.score>=0.60 || c.diff<=2.00);\n return strong || docCandidates[0];\n }\n\n let best=null;\n for(const t of tssByDoc.values()){\n if(usedTss.has(t.documento)) continue;\n const score=bestNameScore(cluster.names,t.nombres);\n const diff=amountDiffTotal(cluster,t);\n const amountOk=diff<=2.00 || abs(cluster.base_ss-t.salario_ss)<=1.00;\n if(score>=0.75 && amountOk){\n const rank=score*1000 - diff;\n if(!best || rank>best.rank) best={t,method:'nombre+monto',score,diff,rank};\n }\n }\n return best;\n}\nfunction matchPayrollTss(payrollClusters,tssByDoc){\n const matched=[], usedTss=new Set(), cambiosDoc=[], payrollOnly=[];\n // Primero se procesan clusters con documento único y buen match de documento;\n // luego los clusters con cambios documentales. Esto reduce colisiones.\n const ordered=[...payrollClusters].sort((a,b)=>a.docsArray.length-b.docsArray.length);\n for(const p of ordered){\n const chosen=chooseTssForCluster(p,tssByDoc,usedTss);\n if(chosen && chosen.t){\n const t=chosen.t;\n usedTss.add(t.documento);\n const diff={base:money(p.base_ss-t.salario_ss),afp:money(p.afp-t.afp),sfs:money(p.sfs-t.sfs)};\n matched.push({p,t,method:chosen.method,diff,score:chosen.score});\n const hasDocChange=p.docsArray.length>1 || !p.docs.has(t.documento) || chosen.method==='nombre+monto';\n if(hasDocChange){\n cambiosDoc.push({\n tipo:'Cambio/corrección de documento resuelto',\n documentos_nomina:p.docsArray.join(' | '),\n documento_tss:t.documento,\n nombre_nomina:p.nombre,\n nombre_tss:t.nombres,\n base_nomina:p.base_ss,\n base_tss:t.salario_ss,\n afp_nomina:p.afp,\n afp_tss:t.afp,\n sfs_nomina:p.sfs,\n sfs_tss:t.sfs,\n metodo:chosen.method,\n accion:'Verificar cédula correcta en nómina/TSS. El monto cuadra o fue cruzado por nombre + monto.'\n });\n }\n } else {\n payrollOnly.push(p);\n }\n }\n const tssOnly=[...tssByDoc.values()].filter(t=>!usedTss.has(t.documento));\n return {matched,payrollOnly,tssOnly,cambiosDoc};\n}\n\nfunction sumPayroll(records){return records.reduce((a,r)=>{a.base+=r.base_ss;a.afp+=r.afp;a.sfs+=r.sfs;return a;},{base:0,afp:0,sfs:0});}\nfunction sumTss(records){return records.reduce((a,r)=>{a.base+=r.salario_ss;a.afp+=r.afp_empleado;a.sfs+=r.sfs_empleado;a.sfsPat+=r.sfs_patronal;a.afpPat+=r.afp_patronal;a.srl+=r.srl;a.perCapita+=r.per_capita;a.total+=r.total_factura;return a;},{base:0,afp:0,sfs:0,sfsPat:0,afpPat:0,srl:0,perCapita:0,total:0});}\n\nfunction duplicateDocumentAlerts(records){\n const byDoc=new Map();\n for(const r of records){\n if(!byDoc.has(r.documento)) byDoc.set(r.documento,[]);\n byDoc.get(r.documento).push(r);\n }\n const alerts=[];\n for(const [doc,rows] of byDoc.entries()){\n const names=[...new Set(rows.map(r=>r.nombre).filter(Boolean))];\n const sources=[...new Set(rows.map(r=>`${r.fuente}/${r.hoja}`))];\n // Alerta si el mismo documento aparece con nombres diferentes no similares\n // o si aparece más de una vez dentro de la misma quincena/hoja.\n const hasDifferentPeople=names.some(a=>names.some(b=>a!==b && nameScore(a,b)<0.45));\n const byQuarter=new Map();\n for(const r of rows){\n const k=`${r.fuente}|${r.hoja}`;\n byQuarter.set(k,(byQuarter.get(k)||0)+1);\n }\n const repeatedSameQuarter=[...byQuarter.values()].some(v=>v>1);\n if(hasDifferentPeople || repeatedSameQuarter){\n alerts.push({\n tipo:'Documento duplicado o compartido en nómina',\n documento:doc,\n nombres:names.join(' | '),\n detalle:sources.join(' / '),\n accion:'Revisar si la cédula fue cargada a más de una persona o si hay líneas duplicadas.'\n });\n }\n }\n return alerts;\n}\n\nconst payrollClusters=clusterPayroll(payrollRecords);\nconst tssByDoc=groupTssByDoc(tssRecords);\nconst tssGroups=[...tssByDoc.values()];\nconst match=matchPayrollTss(payrollClusters,tssByDoc);\n\nconst nominaBamboo=payrollClusters.map(cluster=>{\n const nombre=cluster.nombre||[...(cluster.names||[])][0]||'';\n return {\n cluster,\n nombre,\n match:getBambooMatch(cluster.namesArray||[nombre],cluster.emailsArray||[])\n };\n});\nconst nominaSinBamboo=nominaBamboo\n .filter(row=>!row.match.record)\n .sort((a,b)=>String(a.nombre).localeCompare(String(b.nombre),'es'));\n\nconst payrollTotalsRaw=sumPayroll(payrollRecords);\nconst tssTotalsRaw=sumTss(tssRecords);\nconst payrollTotals={base:money(payrollTotalsRaw.base),afp:money(payrollTotalsRaw.afp),sfs:money(payrollTotalsRaw.sfs)};\nconst tssTotals={base:money(tssTotalsRaw.base),afp:money(tssTotalsRaw.afp),sfs:money(tssTotalsRaw.sfs),sfsPat:money(tssTotalsRaw.sfsPat),afpPat:money(tssTotalsRaw.afpPat),srl:money(tssTotalsRaw.srl),perCapita:money(tssTotalsRaw.perCapita),total:money(tssTotalsRaw.total)};\nconst diffTotals={base:money(payrollTotals.base-tssTotals.base),afp:money(payrollTotals.afp-tssTotals.afp),sfs:money(payrollTotals.sfs-tssTotals.sfs)};\ndiffTotals.totalEmpleado=money(diffTotals.afp+diffTotals.sfs);\n\nconst diferencias=[];\nfor(const m of match.matched){\n if(hasRealDiff(m.diff)){\n diferencias.push({\n tipo:'Diferencia de monto',\n documento:m.t.documento,\n nombre_nomina:m.p.nombre,\n nombre_tss:m.t.nombres,\n base_nomina:m.p.base_ss,\n base_tss:m.t.salario_ss,\n dif_base:m.diff.base,\n afp_nomina:m.p.afp,\n afp_tss:m.t.afp,\n dif_afp:m.diff.afp,\n sfs_nomina:m.p.sfs,\n sfs_tss:m.t.sfs,\n dif_sfs:m.diff.sfs,\n accion:'Revisar base cotizable, AFP y SFS del colaborador en nómina vs TSS.'\n });\n }\n}\nfor(const p of match.payrollOnly){\n diferencias.push({\n tipo:'Solo nómina',\n documento:p.docsArray.join(' | '),\n nombre_nomina:p.nombre,\n nombre_tss:'',\n base_nomina:p.base_ss,\n base_tss:0,\n dif_base:p.base_ss,\n afp_nomina:p.afp,\n afp_tss:0,\n dif_afp:p.afp,\n sfs_nomina:p.sfs,\n sfs_tss:0,\n dif_sfs:p.sfs,\n bamboo_nombres:p.namesArray||[p.nombre],\n bamboo_correos:p.emailsArray||[],\n accion:'Aparece en nómina y no aparece en TSS. Verificar si debe reportarse en TSS o si corresponde excluirlo.'\n });\n}\nfor(const t of match.tssOnly){\n diferencias.push({\n tipo:'Solo TSS',\n documento:t.documento,\n nombre_nomina:'',\n nombre_tss:t.nombres,\n base_nomina:0,\n base_tss:t.salario_ss,\n dif_base:money(-t.salario_ss),\n afp_nomina:0,\n afp_tss:t.afp,\n dif_afp:money(-t.afp),\n sfs_nomina:0,\n sfs_tss:t.sfs,\n dif_sfs:money(-t.sfs),\n accion:'Aparece en TSS y no aparece en nómina. Verificar si fue cargado directo en TSS o si falta en nómina.'\n });\n}\n\nfor(const d of diferencias){\n if(d.tipo==='Solo nómina'){\n Object.assign(\n d,\n bambooInfoForNames(\n d.bamboo_nombres||[d.nombre_nomina],\n d.bamboo_correos||[]\n )\n );\n }else if(d.tipo==='Solo TSS'){\n Object.assign(d,bambooInfoForNames([d.nombre_tss],[]));\n }else{\n Object.assign(d,{esta_bamboo:'',fecha_ingreso:'',estado_bamboo:'',nombre_bamboo:''});\n }\n}\n\nconst docAlerts=duplicateDocumentAlerts(payrollRecords);\nconst normalizedAlerts=[\n ...payrollRecords.filter(r=>r.documento_issue).map(r=>({\n tipo:'Documento normalizado',\n documento:r.documento,\n nombre:r.nombre,\n detalle:`${r.fuente} / ${r.hoja} / original: ${r.cedula_original}`,\n accion:'Se completó con ceros a la izquierda para poder cruzar. Revisar formato en archivo fuente.'\n })),\n ...tssRecords.filter(r=>r.documento_issue).map(r=>({\n tipo:'Documento TSS normalizado',\n documento:r.documento,\n nombre:r.nombres,\n detalle:`TSS / original: ${r.documento_original}`,\n accion:'Se completó con ceros a la izquierda para poder cruzar. Revisar formato en archivo fuente.'\n }))\n];\nconst documentAlertRows=[];\nfor(const c of match.cambiosDoc){\n documentAlertRows.push({\n tipo:c.tipo,\n documento_nomina:c.documentos_nomina,\n documento_tss:c.documento_tss,\n nombre_nomina:c.nombre_nomina,\n nombre_tss:c.nombre_tss,\n accion:c.accion\n });\n}\nfor(const a of docAlerts){\n documentAlertRows.push({tipo:a.tipo, documento_nomina:a.documento, documento_tss:'', nombre_nomina:a.nombres, nombre_tss:'', accion:a.accion});\n}\n// No listamos cada documento normalizado en el reporte final.\n// La normalización de ceros a la izquierda es un ajuste técnico esperado para cruzar;\n// mostrar 50+ filas de esto hace el reporte confuso. Se mantiene solo como conteo informativo.\n\nconst hasReal=diferencias.some(d=>abs(d.dif_base)>1||abs(d.dif_afp)>1||abs(d.dif_sfs)>1);\nconst hasDocAlerts=documentAlertRows.length>0;\nlet statusText='COMPLETADO · SIN DIFERENCIAS';\nlet statusDetail='No hay diferencias reales mayores a RD$1.00.';\nif(hasReal) {\n statusText='COMPLETADO · REVISAR DIFERENCIAS';\n statusDetail='Hay diferencias reales que Nómina/Administración debe revisar.';\n} else if(hasDocAlerts) {\n statusText='COMPLETADO · REVISAR HALLAZGOS DOCUMENTALES';\n statusDetail='Los montos cuadran; solo hay alertas documentales o redondeos menores.';\n}\n\nconst reportTitle = init.reportTitle || `Cruce Seguridad Social RD - ${init.periodoLabel}`;\nconst archivos = {\n 'Nómina 1': slot.sourceFiles['Nómina 1'] || '',\n 'Nómina 2': slot.sourceFiles['Nómina 2'] || '',\n 'TSS': slot.sourceFiles['TSS'] || '',\n};\n\nconst nominaSinBambooRows=[\n ['Nombre en Nómina']\n];\nfor(const row of nominaSinBamboo){\n nominaSinBambooRows.push([row.nombre]);\n}\n\n// ============================================================\n// Reporte principal.\n// ============================================================\nconst rows=[];\nrows.push([reportTitle]);\nrows.push(['Período', init.periodoLabel, 'Estado', statusText]);\nrows.push(['Lectura rápida', statusDetail]);\nrows.push([]);\nrows.push(['Resumen']);\nrows.push(['Métrica','Valor','Comentario']);\nrows.push(['Personas consolidadas en nómina', payrollClusters.length, 'Nómina 1 + Nómina 2, consolidado por identidad/documento.']);\nrows.push(['Documentos en TSS', tssGroups.length, 'TSS consolidada por NO_DOCUMENTO.']);\nrows.push(['Coinciden / resueltos', match.matched.length, 'Incluye cambios de documento resueltos por nombre + monto.']);\nrows.push(['Solo nómina', match.payrollOnly.length, 'Debe ser 0 salvo casos reales a corregir.']);\nrows.push(['Solo TSS', match.tssOnly.length, 'Debe ser 0 salvo casos reales a corregir.']);\nrows.push(['Diferencias reales mayores a RD$1', diferencias.length, hasReal ? 'Revisar sección Diferencias para revisar.' : 'Sin diferencias reales.']);\nrows.push([]);\nrows.push(['Totales del mes']);\nrows.push(['Concepto','Nómina','TSS','Diferencia','Comentario']);\nrows.push(['Salario cotizable SS', `RD$ ${fmt(payrollTotals.base)}`, `RD$ ${fmt(tssTotals.base)}`, `RD$ ${fmt(diffTotals.base)}`, abs(diffTotals.base)<=1 ? 'OK / redondeo' : 'Revisar']);\nrows.push(['AFP / SVDS empleado', `RD$ ${fmt(payrollTotals.afp)}`, `RD$ ${fmt(tssTotals.afp)}`, `RD$ ${fmt(diffTotals.afp)}`, abs(diffTotals.afp)<=1 ? 'OK / redondeo' : 'Revisar']);\nrows.push(['SFS / ARS empleado', `RD$ ${fmt(payrollTotals.sfs)}`, `RD$ ${fmt(tssTotals.sfs)}`, `RD$ ${fmt(diffTotals.sfs)}`, abs(diffTotals.sfs)<=1 ? 'OK / redondeo' : 'Revisar']);\nrows.push(['AFP + SFS empleado', `RD$ ${fmt(payrollTotals.afp+payrollTotals.sfs)}`, `RD$ ${fmt(tssTotals.afp+tssTotals.sfs)}`, `RD$ ${fmt(diffTotals.totalEmpleado)}`, abs(diffTotals.totalEmpleado)<=1 ? 'OK / redondeo' : 'Revisar']);\nrows.push([]);\nrows.push(['Diferencias para revisar']);\nrows.push(['Tipo','Documento','Nombre nómina','Nombre TSS','Base nómina','Base TSS','Dif. base','AFP nómina','AFP TSS','Dif. AFP','SFS nómina','SFS TSS','Dif. SFS','Acción','Está en Bamboo?','Fecha de Ingreso','Activo/Inactivo','Nombre en BambooHR']);\nif(diferencias.length){\n for(const d of diferencias){\n rows.push([\n d.tipo,\n d.documento,\n d.nombre_nomina,\n d.nombre_tss,\n `RD$ ${fmt(d.base_nomina)}`,\n `RD$ ${fmt(d.base_tss)}`,\n `RD$ ${fmt(d.dif_base)}`,\n `RD$ ${fmt(d.afp_nomina)}`,\n `RD$ ${fmt(d.afp_tss)}`,\n `RD$ ${fmt(d.dif_afp)}`,\n `RD$ ${fmt(d.sfs_nomina)}`,\n `RD$ ${fmt(d.sfs_tss)}`,\n `RD$ ${fmt(d.dif_sfs)}`,\n d.accion,\n d.esta_bamboo||'',\n d.fecha_ingreso||'',\n d.estado_bamboo||'',\n d.nombre_bamboo||''\n ]);\n }\n} else {\n rows.push(['Sin diferencias reales','','','','','','','','','','','','','No hay diferencias mayores a RD$1.00.','','','','']);\n}\nrows.push([]);\nrows.push(['Alertas documentales / no monetarias']);\nrows.push(['Tipo','Documento nómina','Documento TSS','Nombre nómina','Nombre TSS','Acción']);\nif(documentAlertRows.length){\n for(const a of documentAlertRows.slice(0,20)){\n rows.push([a.tipo,a.documento_nomina,a.documento_tss,a.nombre_nomina,a.nombre_tss,a.accion]);\n }\n if(documentAlertRows.length>20) rows.push(['Nota','','','','',`Hay ${documentAlertRows.length-20} alertas documentales adicionales no mostradas para mantener el reporte simple.`]);\n} else {\n rows.push(['Sin alertas documentales accionables','','','','','']);\n}\nif(normalizedAlerts.length){\n rows.push(['Documentos normalizados (informativo)', String(normalizedAlerts.length), '', '', '', 'Se completaron ceros a la izquierda para cruzar. No requiere revisión individual si los montos cuadran.']);\n}\nrows.push([]);\nrows.push(['Archivos procesados']);\nrows.push(['Nómina 1', archivos['Nómina 1']]);\nrows.push(['Nómina 2', archivos['Nómina 2']]);\nrows.push(['TSS', archivos['TSS']]);\nrows.push([]);\nrows.push(['Regla de interpretación']);\nrows.push(['Solo se consideran diferencias reales los montos mayores a RD$1.00. Diferencias menores se tratan como redondeo.']);\nrows.push(['El cruce compara aportes del empleado: AFP/SVDS y SFS/ARS. Patronales, SRL y per cápita son informativos de factura TSS.']);\n\nconst summary={\n statusText,\n statusDetail,\n payrollPersons: payrollClusters.length,\n tssDocuments: tssGroups.length,\n matched: match.matched.length,\n soloNomina: match.payrollOnly.length,\n soloTss: match.tssOnly.length,\n realDifferencesCount: diferencias.length,\n cambiosDocumento: match.cambiosDoc.length,\n documentosDuplicados: docAlerts.length,\n documentosNormalizados: normalizedAlerts.length,\n empleadosBamboo: bambooRecords.length,\n nominaEvaluadaBamboo: payrollClusters.length,\n nominaSinBamboo: nominaSinBamboo.length,\n payrollTotals,\n tssTotals,\n diffTotals,\n archivos,\n};\n\nreturn [{\n json:{\n runId:init.runId,\n mes:init.mes,\n anio:init.anio,\n periodoLabel:init.periodoLabel,\n reportTitle,\n summary,\n reporteRows:rows,\n nominaSinBambooRows,\n diferencias,\n alertasDocumentales:documentAlertRows,\n }\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-608,
3488
],
"id": "cd1be4c3-796d-4f16-a1a1-2b4428ddf757",
"name": "Construir reporte producción"
},
{
"parameters": {
"method": "POST",
"url": "https://sheets.googleapis.com/v4/spreadsheets",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleSheetsOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ properties: { title: $('Construir reporte producción').item.json.reportTitle }, sheets: [{ properties: { title: 'Reporte' } }, { properties: { title: 'Nómina sin BambooHR' } }] }) }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.3,
"position": [
-192,
3488
],
"id": "7258a205-21e8-4346-9c35-3b04c3b451ac",
"name": "Crear Google Sheet Reporte",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
}
},
{
"parameters": {
"method": "POST",
"url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.spreadsheetId }}/values:batchUpdate",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleSheetsOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ valueInputOption: 'RAW', data: [{ range: 'Reporte!A1', majorDimension: 'ROWS', values: $('Construir reporte producción').item.json.reporteRows }, { range: 'Nómina sin BambooHR!A1', majorDimension: 'ROWS', values: $('Construir reporte producción').item.json.nominaSinBambooRows }] }) }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.3,
"position": [
48,
3488
],
"id": "be2d69fc-b9e7-4104-83b6-4dbc33d5eb1c",
"name": "Escribir reporte",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
}
},
{
"parameters": {
"jsCode": "const input = $input.first().json || {};\nconst create = $('Crear Google Sheet Reporte').item.json || {};\nconst sheets = create.sheets || [];\nconst reporte = sheets.find(s => s && s.properties && s.properties.title === 'Reporte') || sheets[0];\nconst sid = reporte && reporte.properties ? reporte.properties.sheetId : undefined;\nconst bambooSheet = sheets.find(s => s && s.properties && s.properties.title === 'Nómina sin BambooHR');\nconst bambooSid = bambooSheet && bambooSheet.properties ? bambooSheet.properties.sheetId : undefined;\nconst rows = $('Construir reporte producción').item.json.reporteRows || [];\nconst bambooRows = $('Construir reporte producción').item.json.nominaSinBambooRows || [];\n\nfunction color(hex) {\n return {\n red: parseInt(hex.slice(0, 2), 16) / 255,\n green: parseInt(hex.slice(2, 4), 16) / 255,\n blue: parseInt(hex.slice(4, 6), 16) / 255,\n };\n}\n\nfunction clamp(n, min, max) {\n return Math.max(min, Math.min(max, n));\n}\n\nfunction textLen(v) {\n if (v === null || v === undefined) return 0;\n return String(v).length;\n}\n\nif (sid === undefined || sid === null) {\n return [{\n json: {\n ...input,\n spreadsheetId: create.spreadsheetId,\n spreadsheetUrl: create.spreadsheetUrl,\n formatBody: { requests: [] },\n },\n }];\n}\n\nconst requests = [];\nconst maxCols = 18;\nconst rowCount = Math.max(rows.length + 10, 80);\n\nrequests.push({\n updateSheetProperties: {\n properties: {\n sheetId: sid,\n gridProperties: {\n hideGridlines: true,\n frozenRowCount: 6,\n },\n },\n fields: 'gridProperties.hideGridlines,gridProperties.frozenRowCount',\n },\n});\n\nrequests.push({\n repeatCell: {\n range: {\n sheetId: sid,\n startRowIndex: 0,\n endRowIndex: rowCount,\n startColumnIndex: 0,\n endColumnIndex: maxCols,\n },\n cell: {\n userEnteredFormat: {\n textFormat: {\n fontFamily: 'Arial',\n fontSize: 10,\n foregroundColor: color('263238'),\n },\n verticalAlignment: 'TOP',\n wrapStrategy: 'WRAP',\n },\n },\n fields: 'userEnteredFormat.textFormat,userEnteredFormat.verticalAlignment,userEnteredFormat.wrapStrategy',\n },\n});\n\n// Título\nrequests.push({\n mergeCells: {\n range: {\n sheetId: sid,\n startRowIndex: 0,\n endRowIndex: 1,\n startColumnIndex: 0,\n endColumnIndex: maxCols,\n },\n mergeType: 'MERGE_ALL',\n },\n});\nrequests.push({\n repeatCell: {\n range: {\n sheetId: sid,\n startRowIndex: 0,\n endRowIndex: 1,\n startColumnIndex: 0,\n endColumnIndex: maxCols,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: color('4F758B'),\n textFormat: {\n foregroundColor: color('FFFFFF'),\n bold: true,\n fontSize: 13,\n fontFamily: 'Arial',\n },\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n },\n },\n fields: 'userEnteredFormat.backgroundColor,userEnteredFormat.textFormat,userEnteredFormat.horizontalAlignment,userEnteredFormat.verticalAlignment',\n },\n});\n\n// Filas de secciones\nconst sectionNames = new Set([\n 'Resumen',\n 'Totales del mes',\n 'Diferencias para revisar',\n 'Alertas documentales / no monetarias',\n 'Archivos procesados',\n 'Regla de interpretación',\n]);\nconst sectionRows = rows\n .map((r, i) => sectionNames.has(r[0]) ? i : -1)\n .filter(i => i >= 0);\n\nfor (const r of sectionRows) {\n requests.push({\n mergeCells: {\n range: {\n sheetId: sid,\n startRowIndex: r,\n endRowIndex: r + 1,\n startColumnIndex: 0,\n endColumnIndex: maxCols,\n },\n mergeType: 'MERGE_ALL',\n },\n });\n requests.push({\n repeatCell: {\n range: {\n sheetId: sid,\n startRowIndex: r,\n endRowIndex: r + 1,\n startColumnIndex: 0,\n endColumnIndex: maxCols,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: color('4F758B'),\n textFormat: {\n foregroundColor: color('FFFFFF'),\n bold: true,\n fontSize: 11,\n fontFamily: 'Arial',\n },\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n },\n },\n fields: 'userEnteredFormat.backgroundColor,userEnteredFormat.textFormat,userEnteredFormat.horizontalAlignment,userEnteredFormat.verticalAlignment',\n },\n });\n}\n\n// Encabezados de tablas\nconst headerRows = rows\n .map((r, i) => ['Métrica', 'Concepto', 'Tipo'].includes(r[0]) ? i : -1)\n .filter(i => i >= 0);\n\nfor (const r of headerRows) {\n requests.push({\n repeatCell: {\n range: {\n sheetId: sid,\n startRowIndex: r,\n endRowIndex: r + 1,\n startColumnIndex: 0,\n endColumnIndex: maxCols,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: color('E9F2F7'),\n textFormat: {\n foregroundColor: color('4F758B'),\n bold: true,\n fontFamily: 'Arial',\n },\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n },\n },\n fields: 'userEnteredFormat.backgroundColor,userEnteredFormat.textFormat,userEnteredFormat.horizontalAlignment,userEnteredFormat.verticalAlignment',\n },\n });\n}\n\n// Bordes ligeros a todo lo usado\nif (rows.length > 0) {\n requests.push({\n updateBorders: {\n range: {\n sheetId: sid,\n startRowIndex: 0,\n endRowIndex: rows.length,\n startColumnIndex: 0,\n endColumnIndex: maxCols,\n },\n top: { style: 'SOLID', width: 1, color: color('D5E2EA') },\n bottom: { style: 'SOLID', width: 1, color: color('D5E2EA') },\n left: { style: 'SOLID', width: 1, color: color('D5E2EA') },\n right: { style: 'SOLID', width: 1, color: color('D5E2EA') },\n innerHorizontal: { style: 'SOLID', width: 1, color: color('EEF4F8') },\n innerVertical: { style: 'SOLID', width: 1, color: color('EEF4F8') },\n },\n });\n}\n\n// Anchuras pensadas para una sola hoja legible.\n// Se amplían las columnas de texto para evitar que el contenido se vea apretado.\nconst widths = [220, 195, 230, 230, 125, 125, 125, 125, 125, 125, 125, 125, 125, 360, 125, 125, 135, 240];\nwidths.forEach((w, idx) => {\n requests.push({\n updateDimensionProperties: {\n range: {\n sheetId: sid,\n dimension: 'COLUMNS',\n startIndex: idx,\n endIndex: idx + 1,\n },\n properties: { pixelSize: w },\n fields: 'pixelSize',\n },\n });\n});\n\n// Altura dinámica por fila.\n// Antes todas las filas quedaban en 28px; eso comprimía texto largo y obligaba a Nómina a agrandar filas manualmente.\n// Esta lógica solo toca presentación del Google Sheet, no toca el cruce ni los cálculos.\nconst charCapacityByCol = widths.map(w => Math.max(8, Math.floor(w / 8.2)));\nconst sectionRowSet = new Set(sectionRows);\nconst headerRowSet = new Set(headerRows);\nfunction estimatedLines(row) {\n let maxLines = 1;\n for (let c = 0; c < Math.min(maxCols, row.length); c++) {\n const value = row[c];\n if (value === null || value === undefined || value === '') continue;\n const txt = String(value);\n const manualLines = txt.split(/\\n/);\n let lines = 0;\n for (const part of manualLines) {\n const len = part.length;\n const cap = charCapacityByCol[c] || 18;\n lines += Math.max(1, Math.ceil(len / cap));\n }\n maxLines = Math.max(maxLines, lines);\n }\n return maxLines;\n}\nfunction rowPixelHeight(row, index) {\n const first = String(row?.[0] || '');\n if (!row || row.every(v => v === '' || v === null || v === undefined)) return 14;\n if (index === 0) return 34;\n if (sectionRowSet.has(index)) return 30;\n if (headerRowSet.has(index)) return 30;\n\n const lines = estimatedLines(row);\n let height = 24 + (lines - 1) * 17;\n\n // Filas de lectura, comentarios, acciones y archivos necesitan más aire visual.\n if (index <= 3) height = Math.max(height, 46);\n if (['Solo se consideran diferencias reales los montos mayores a RD$1.00. Diferencias menores se tratan como redondeo.',\n 'El cruce compara aportes del empleado: AFP/SVDS y SFS/ARS. Patronales, SRL y per cápita son informativos de factura TSS.'].includes(first)) {\n height = Math.max(height, 48);\n }\n if (first === 'Nómina 1' || first === 'Nómina 2' || first === 'TSS') height = Math.max(height, 46);\n if (first === 'Sin diferencias reales') height = Math.max(height, 34);\n if (first === 'Nota' || first === 'Documentos normalizados (informativo)') height = Math.max(height, 44);\n if (first && !sectionRowSet.has(index) && !headerRowSet.has(index) && row.length >= 6) height = Math.max(height, 38);\n\n return clamp(height, 24, 120);\n}\n\n// Agrupa filas contiguas con la misma altura para mantener bajo el número de requests.\nlet start = 0;\nlet lastHeight = rows.length ? rowPixelHeight(rows[0], 0) : 28;\nfor (let i = 1; i <= Math.max(rows.length, 1); i++) {\n const h = i < rows.length ? rowPixelHeight(rows[i], i) : null;\n if (h !== lastHeight) {\n requests.push({\n updateDimensionProperties: {\n range: {\n sheetId: sid,\n dimension: 'ROWS',\n startIndex: start,\n endIndex: i,\n },\n properties: { pixelSize: lastHeight },\n fields: 'pixelSize',\n },\n });\n start = i;\n lastHeight = h;\n }\n}\n\n// Color suave para filas de diferencias reales\nconst diffStart = rows.findIndex(r => r[0] === 'Diferencias para revisar');\nconst diffHeader = rows.findIndex((r, i) => i > diffStart && r[0] === 'Tipo');\nif (diffHeader >= 0) {\n for (let i = diffHeader + 1; i < rows.length; i++) {\n if (!rows[i] || !rows[i][0]) break;\n if (rows[i][0] !== 'Sin diferencias reales') {\n requests.push({\n repeatCell: {\n range: {\n sheetId: sid,\n startRowIndex: i,\n endRowIndex: i + 1,\n startColumnIndex: 0,\n endColumnIndex: maxCols,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: color('FFF7E6'),\n },\n },\n fields: 'userEnteredFormat.backgroundColor',\n },\n });\n }\n }\n}\n\nif (bambooSid !== undefined && bambooSid !== null) {\n const bambooCols = 1;\n const bambooRowCount = Math.max(bambooRows.length + 5, 30);\n\n requests.push({\n updateSheetProperties: {\n properties: {\n sheetId: bambooSid,\n gridProperties: {\n hideGridlines: true,\n frozenRowCount: 1,\n },\n },\n fields: 'gridProperties.hideGridlines,gridProperties.frozenRowCount',\n },\n });\n\n requests.push({\n repeatCell: {\n range: {\n sheetId: bambooSid,\n startRowIndex: 0,\n endRowIndex: bambooRowCount,\n startColumnIndex: 0,\n endColumnIndex: bambooCols,\n },\n cell: {\n userEnteredFormat: {\n textFormat: {\n fontFamily: 'Arial',\n fontSize: 10,\n foregroundColor: color('263238'),\n },\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n },\n },\n fields: 'userEnteredFormat.textFormat,userEnteredFormat.verticalAlignment,userEnteredFormat.wrapStrategy',\n },\n });\n\n requests.push({\n repeatCell: {\n range: {\n sheetId: bambooSid,\n startRowIndex: 0,\n endRowIndex: 1,\n startColumnIndex: 0,\n endColumnIndex: bambooCols,\n },\n cell: {\n userEnteredFormat: {\n backgroundColor: color('4F758B'),\n textFormat: {\n foregroundColor: color('FFFFFF'),\n bold: true,\n fontFamily: 'Arial',\n },\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n },\n },\n fields: 'userEnteredFormat.backgroundColor,userEnteredFormat.textFormat,userEnteredFormat.horizontalAlignment,userEnteredFormat.verticalAlignment',\n },\n });\n\n if (bambooRows.length > 0) {\n requests.push({\n updateBorders: {\n range: {\n sheetId: bambooSid,\n startRowIndex: 0,\n endRowIndex: bambooRows.length,\n startColumnIndex: 0,\n endColumnIndex: bambooCols,\n },\n top: { style: 'SOLID', width: 1, color: color('D5E2EA') },\n bottom: { style: 'SOLID', width: 1, color: color('D5E2EA') },\n left: { style: 'SOLID', width: 1, color: color('D5E2EA') },\n right: { style: 'SOLID', width: 1, color: color('D5E2EA') },\n innerHorizontal: { style: 'SOLID', width: 1, color: color('EEF4F8') },\n innerVertical: { style: 'SOLID', width: 1, color: color('EEF4F8') },\n },\n });\n }\n\n [340].forEach((pixelSize, idx) => {\n requests.push({\n updateDimensionProperties: {\n range: {\n sheetId: bambooSid,\n dimension: 'COLUMNS',\n startIndex: idx,\n endIndex: idx + 1,\n },\n properties: { pixelSize },\n fields: 'pixelSize',\n },\n });\n });\n\n requests.push({\n updateDimensionProperties: {\n range: {\n sheetId: bambooSid,\n dimension: 'ROWS',\n startIndex: 0,\n endIndex: 1,\n },\n properties: { pixelSize: 32 },\n fields: 'pixelSize',\n },\n });\n}\n\nreturn [{\n json: {\n ...input,\n spreadsheetId: create.spreadsheetId,\n spreadsheetUrl: create.spreadsheetUrl,\n formatBody: { requests },\n },\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
288,
3488
],
"id": "b3e08ff4-74ca-4c32-bc46-1d1f6aa62157",
"name": "Preparar formato reporte"
},
{
"parameters": {
"method": "POST",
"url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $('Crear Google Sheet Reporte').item.json.spreadsheetId }}:batchUpdate",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleSheetsOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json.formatBody) }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.3,
"position": [
528,
3488
],
"id": "8a31ac94-260d-41eb-a506-5ec48db30934",
"name": "Formatear reporte",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
}
},
{
"parameters": {
"jsCode": "// ============================================================\n// CONFIGURACIÓN ÚNICA DE DESTINATARIOS\n// ============================================================\n// Escribe aquí los correos que deben recibir el correo y también\n// tener acceso al Google Sheet del reporte.\n// Puedes poner 1, 2, 10 o más correos separados por coma, punto y coma o salto de línea.\n// Ejemplo:\n// const DESTINATARIOS_TO = 'correo1@gomezleemarketing.com, correo2@gomezleemarketing.com';\n\nconst DESTINATARIOS_TO = 'iaracena@gomezleemarketing.com, ymadera@gomezleemarketing.com, iherrera@gomezleemarketing.com, vparamo@gomezleemarketing.com, asrodriguez@gomezleemarketing.com, mgomez@gomezleemarketing.com, msoto@gomezleemarketing.com';\nconst DESTINATARIOS_CC = '';\nconst DESTINATARIOS_BCC = '';\n\n// Permiso del Google Sheet generado: reader = solo lectura, writer = editor.\n// RD producción: los destinatarios configurados reciben acceso de edición al reporte.\nconst PERMISSION_ROLE = 'writer';\n\nfunction extractEmails(value) {\n if (value === null || value === undefined) return [];\n const text = Array.isArray(value) ? value.join(',') : String(value);\n return (text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/gi) || [])\n .map(e => e.trim().toLowerCase())\n .filter(Boolean);\n}\n\nconst toEmails = [...new Set(extractEmails(DESTINATARIOS_TO))];\nconst ccEmails = [...new Set(extractEmails(DESTINATARIOS_CC))];\nconst bccEmails = [...new Set(extractEmails(DESTINATARIOS_BCC))];\nconst allEmails = [...new Set([...toEmails, ...ccEmails, ...bccEmails])];\n\nif (!toEmails.length) {\n throw new Error('Configura al menos un correo en DESTINATARIOS_TO dentro del nodo \"CONFIG - Destinatarios correo y permisos\".');\n}\n\nconst spreadsheetId = $('Crear Google Sheet Reporte').item.json.spreadsheetId;\nconst spreadsheetUrl = $('Crear Google Sheet Reporte').item.json.spreadsheetUrl;\n\nreturn [{\n json: {\n toEmails,\n ccEmails,\n bccEmails,\n allEmails,\n sendTo: toEmails.join(', '),\n cc: ccEmails.join(', '),\n bcc: bccEmails.join(', '),\n spreadsheetId,\n spreadsheetUrl,\n permissionRole: PERMISSION_ROLE,\n },\n}];"
},
"position": [
768,
3488
],
"id": "a2f779e8-6d88-4bd3-85d7-e0817a1c3195",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"name": "CONFIG - Destinatarios correo y permisos"
},
{
"parameters": {
"method": "POST",
"url": "=https://www.googleapis.com/drive/v3/files/{{ $json.spreadsheetId }}/permissions?sendNotificationEmail=false&supportsAllDrives=true",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleDriveOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ role: $json.permissionRole || 'reader', type: 'user', emailAddress: $json.emailAddress }) }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.3,
"position": [
1184,
3488
],
"id": "bb859891-f994-48f6-90c9-6dbc8de02b91",
"name": "Compartir reporte destinatarios Gmail",
"credentials": {
"googleDriveOAuth2Api": {
"id": "g23xdGLZRzBGqKgH",
"name": "Isaac - Google Drive"
}
},
"continueOnFail": true
},
{
"parameters": {
"sendTo": "={{ $json.sendTo }}",
"subject": "={{ `Cruce Seguridad Social RD - ${$('Construir reporte producción').item.json.periodoLabel}` }}",
"message": "=<div style=\"margin:0;padding:0;background:#F4F8FB;font-family:Arial,Helvetica,sans-serif;color:#263238;\">\n <div style=\"max-width:640px;margin:0 auto;padding:28px 18px;\">\n <div style=\"background:#ffffff;border:1px solid #D5E2EA;border-radius:14px;padding:34px 36px;box-shadow:0 2px 8px rgba(79,117,139,0.08);\">\n\n <div\n style=\"\n margin:0 0 18px 0;\n width:270px;\n height:76px;\n line-height:0;\n font-size:0;\n background-image:url('https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png');\n background-repeat:no-repeat;\n background-position:-14px 0;\n background-size:230px auto;\n \"\n >\n &nbsp;\n </div>\n\n <div style=\"font-size:11px;letter-spacing:5px;color:#2F9E44;font-weight:700;margin-bottom:14px;\">\n SEGURIDAD SOCIAL · REPÚBLICA DOMINICANA\n </div>\n\n <h1 style=\"font-size:24px;line-height:1.25;color:#4F758B;margin:0 0 6px 0;\">\n Cruce mensual completado\n </h1>\n\n <div style=\"font-size:14px;color:#4F758B;margin-bottom:26px;\">\n {{ $('Construir reporte producción').item.json.periodoLabel }}\n </div>\n\n <div style=\"border:1px solid #F59E0B;background:#FFF7E6;color:#92400E;border-radius:10px;padding:14px 16px;font-size:13px;font-weight:700;margin-bottom:20px;\">\n {{ $('Construir reporte producción').item.json.summary.statusText }}\n </div>\n\n <table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" style=\"border-collapse:separate;border-spacing:0 10px;margin-bottom:10px;\">\n <tr>\n <td width=\"33%\" style=\"border:1px solid #D5E2EA;border-top:3px solid #4F758B;border-radius:10px;padding:14px 12px;\">\n <div style=\"font-size:10px;color:#7D99AA;font-weight:700;letter-spacing:1px;\">NÓMINA</div>\n <div style=\"font-size:26px;color:#4F758B;font-weight:800;\">\n {{ Number($('Construir reporte producción').item.json.summary.payrollPersons || 0).toLocaleString('es-DO') }}\n </div>\n </td>\n <td width=\"33%\" style=\"border:1px solid #D5E2EA;border-top:3px solid #4F758B;border-radius:10px;padding:14px 12px;\">\n <div style=\"font-size:10px;color:#7D99AA;font-weight:700;letter-spacing:1px;\">TSS</div>\n <div style=\"font-size:26px;color:#4F758B;font-weight:800;\">\n {{ Number($('Construir reporte producción').item.json.summary.tssDocuments || 0).toLocaleString('es-DO') }}\n </div>\n </td>\n <td width=\"33%\" style=\"border:1px solid #D5E2EA;border-top:3px solid #F97316;border-radius:10px;padding:14px 12px;\">\n <div style=\"font-size:10px;color:#7D99AA;font-weight:700;letter-spacing:1px;\">DIF. AFP + SFS</div>\n <div style=\"font-size:22px;color:#4F758B;font-weight:800;\">\n RD$ {{ Number($('Construir reporte producción').item.json.summary.diffTotals.totalEmpleado || 0).toLocaleString('es-DO', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}\n </div>\n </td>\n </tr>\n </table>\n\n <div style=\"border:1px solid #D5E2EA;border-radius:10px;padding:18px 18px;margin-top:14px;\">\n <h2 style=\"font-size:16px;color:#4F758B;margin:0 0 14px 0;\">Identidad</h2>\n <table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" style=\"font-size:13px;line-height:1.9;\">\n <tr>\n <td>Coinciden / resueltos</td>\n <td align=\"right\" style=\"font-weight:700;color:#4F758B;\">\n {{ Number($('Construir reporte producción').item.json.summary.matched || 0).toLocaleString('es-DO') }}\n </td>\n </tr>\n <tr>\n <td>Solo nómina</td>\n <td align=\"right\" style=\"font-weight:700;color:#4F758B;\">\n {{ Number($('Construir reporte producción').item.json.summary.soloNomina || 0).toLocaleString('es-DO') }}\n </td>\n </tr>\n <tr>\n <td>Solo TSS</td>\n <td align=\"right\" style=\"font-weight:700;color:#4F758B;\">\n {{ Number($('Construir reporte producción').item.json.summary.soloTss || 0).toLocaleString('es-DO') }}\n </td>\n </tr>\n </table>\n </div>\n\n <div style=\"border:1px solid #D5E2EA;border-radius:10px;padding:18px 18px;margin-top:12px;\">\n <h2 style=\"font-size:16px;color:#4F758B;margin:0 0 14px 0;\">Montos TSS</h2>\n <table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" style=\"font-size:13px;line-height:1.9;\">\n <tr>\n <td>Salario cotizable nómina</td>\n <td align=\"right\" style=\"font-weight:700;color:#4F758B;\">\n RD$ {{ Number($('Construir reporte producción').item.json.summary.payrollTotals.base || 0).toLocaleString('es-DO', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}\n </td>\n </tr>\n <tr>\n <td>Salario cotizable TSS</td>\n <td align=\"right\" style=\"font-weight:700;color:#4F758B;\">\n RD$ {{ Number($('Construir reporte producción').item.json.summary.tssTotals.base || 0).toLocaleString('es-DO', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}\n </td>\n </tr>\n <tr>\n <td>Diferencia salario</td>\n <td align=\"right\" style=\"font-weight:700;color:#4F758B;\">\n RD$ {{ Number($('Construir reporte producción').item.json.summary.diffTotals.base || 0).toLocaleString('es-DO', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}\n </td>\n </tr>\n <tr>\n <td>AFP diferencia</td>\n <td align=\"right\" style=\"font-weight:700;color:#4F758B;\">\n RD$ {{ Number($('Construir reporte producción').item.json.summary.diffTotals.afp || 0).toLocaleString('es-DO', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}\n </td>\n </tr>\n <tr>\n <td>SFS/ARS diferencia</td>\n <td align=\"right\" style=\"font-weight:700;color:#4F758B;\">\n RD$ {{ Number($('Construir reporte producción').item.json.summary.diffTotals.sfs || 0).toLocaleString('es-DO', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}\n </td>\n </tr>\n </table>\n </div>\n\n <div style=\"border:1px solid #D5E2EA;border-radius:10px;padding:18px 18px;margin-top:12px;\">\n <h2 style=\"font-size:16px;color:#4F758B;margin:0 0 14px 0;\">Hallazgos</h2>\n <table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" style=\"font-size:13px;line-height:1.9;\">\n <tr>\n <td>Diferencias reales mayores a RD$1</td>\n <td align=\"right\" style=\"font-weight:700;color:#4F758B;\">\n {{ Number($('Construir reporte producción').item.json.summary.realDifferencesCount || 0).toLocaleString('es-DO') }}\n </td>\n </tr>\n <tr>\n <td>Cambios de documento</td>\n <td align=\"right\" style=\"font-weight:700;color:#4F758B;\">\n {{ Number($('Construir reporte producción').item.json.summary.cambiosDocumento || 0).toLocaleString('es-DO') }}\n </td>\n </tr>\n <tr>\n <td>Documentos duplicados</td>\n <td align=\"right\" style=\"font-weight:700;color:#4F758B;\">\n {{ Number($('Construir reporte producción').item.json.summary.documentosDuplicados || 0).toLocaleString('es-DO') }}\n </td>\n </tr>\n <tr>\n <td>Documentos normalizados (informativo)</td>\n <td align=\"right\" style=\"font-weight:700;color:#4F758B;\">\n {{ Number($('Construir reporte producción').item.json.summary.documentosNormalizados || 0).toLocaleString('es-DO') }}\n </td>\n </tr>\n </table>\n </div>\n\n <div style=\"margin-top:24px;\">\n <a href=\"{{ $json.spreadsheetUrl }}\" style=\"display:inline-block;background:#6CC24A;color:white;text-decoration:none;padding:13px 20px;border-radius:999px;font-weight:700;font-size:13px;\">\n Abrir reporte en Google Sheets\n </a>\n </div>\n\n <div style=\"margin-top:26px;font-size:11px;color:#6B8FA3;line-height:1.5;\">\n Archivos procesados:<br>\n Nómina 1: {{ $('Construir reporte producción').item.json.summary.archivos[\"Nómina 1\"] }}<br>\n Nómina 2: {{ $('Construir reporte producción').item.json.summary.archivos[\"Nómina 2\"] }}<br>\n TSS: {{ $('Construir reporte producción').item.json.summary.archivos[\"TSS\"] }}\n </div>\n\n </div>\n </div>\n</div>",
"options": {
"appendAttribution": false
}
},
"type": "n8n-nodes-base.gmail",
"typeVersion": 2.1,
"position": [
1600,
3488
],
"id": "ec1ff324-65fc-4051-a95f-fbe5ce9df419",
"name": "Enviar correo resumen",
"webhookId": "5ba65efc-f980-4aef-8b08-4f8dfb1c855e",
"executeOnce": true,
"credentials": {
"gmailOAuth2": {
"id": "UDcO1FLJqA453V2D",
"name": "Gmail account 3"
}
}
},
{
"parameters": {
"jsCode": "const cfg = $input.first().json;\nconst emails = cfg.allEmails || [];\nif (!emails.length) {\n throw new Error('No hay destinatarios para compartir el reporte. Revisa el nodo CONFIG - Destinatarios correo y permisos.');\n}\nreturn emails.map(email => ({\n json: {\n emailAddress: email,\n spreadsheetId: cfg.spreadsheetId,\n spreadsheetUrl: cfg.spreadsheetUrl,\n permissionRole: cfg.permissionRole || 'reader',\n },\n}));"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
992,
3488
],
"id": "86620c74-9f65-45e3-8517-df3be32aa845",
"name": "Expandir permisos destinatarios"
},
{
"parameters": {
"jsCode": "// Este nodo corre después de compartir permisos. Devuelve un solo item\n// para que Gmail se envíe una sola vez, aunque haya 10 destinatarios.\nconst cfg = $('CONFIG - Destinatarios correo y permisos').item.json;\nreturn [{\n json: {\n sendTo: cfg.sendTo,\n cc: cfg.cc || '',\n bcc: cfg.bcc || '',\n spreadsheetId: cfg.spreadsheetId,\n spreadsheetUrl: cfg.spreadsheetUrl,\n },\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1376,
3488
],
"id": "66373341-ee6b-4aa5-8b57-5dae144ca76d",
"name": "Preparar correo único"
},
{
"parameters": {
"content": "## Entrada mensual\n\nFormulario para seleccionar mes/año y cargar:\n- Nómina 1\n- Nómina 2\n- Archivo TSS\n\nEl flujo valida el período seleccionado y prepara los archivos para lectura desde Google Sheets.",
"height": 688,
"width": 1296
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-4560,
2832
],
"id": "c7204427-2a9a-40b0-96d5-e9afdcb68c97",
"name": "Sticky Note"
},
{
"parameters": {
"content": "## Extracción y consolidación\n\nLee Nómina 1, Nómina 2 y TSS.\n\nLa nómina se consolida por documento normalizado.\nEl nombre se usa solo como apoyo para resolver variaciones entre quincenas.",
"height": 976,
"width": 1776,
"color": 5
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-3168,
2928
],
"id": "ec4e77c9-c91b-4011-9184-af8f52ede00e",
"name": "Sticky Note1"
},
{
"parameters": {
"content": "## Cruce de seguridad social\n\nCompara nómina vs TSS por:\n- Identidad/documento\n- Salario cotizable\n- AFP empleado\n- SFS/ARS empleado\n\nSolo marca como diferencias reales los montos mayores a RD$1.",
"height": 544,
"width": 928,
"color": 6
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-1280,
3120
],
"id": "3f7eec21-0390-4d8e-9346-a3433f019d60",
"name": "Sticky Note2"
},
{
"parameters": {
"content": "## Reporte y envío\n\nGenera un Google Sheet simple de una sola hoja.\nComparte el reporte con permisos de edición a los destinatarios configurados.\nEnvía correo HTML con resumen ejecutivo y enlace al reporte.",
"height": 400,
"width": 2064,
"color": 4
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-272,
3328
],
"id": "b1e2b263-7c8e-4791-83f7-cf931a75f808",
"name": "Sticky Note3"
},
{
"parameters": {
"jsCode": "const init = $items('Inicializar ejecución')[0].json;\n\nconst workflowName = (typeof $workflow !== 'undefined' && $workflow.name)\n ? $workflow.name\n : 'Cruce Seguridad Social RD';\n\nconst workflowId = (typeof $workflow !== 'undefined' && $workflow.id)\n ? String($workflow.id)\n : '';\n\nconst executionId = (typeof $execution !== 'undefined' && $execution.id)\n ? String($execution.id)\n : '';\n\nreturn [{\n json: {\n id_ejecucion: init.runId,\n nombre_flujo: workflowName,\n id_flujo_n8n: workflowId || null,\n id_ejecucion_n8n: executionId || null,\n\n pais_codigo: 'RD',\n pais_nombre: 'República Dominicana',\n entidad_seguridad_social: 'TSS',\n\n mes: Number(init.mes || 0),\n anio: Number(init.anio || 0),\n periodo: init.periodoLabel || `${init.mes}/${init.anio}`,\n\n estado: 'procesando',\n resultado: 'Cruce iniciado',\n\n fecha_inicio: new Date().toISOString(),\n\n moneda: 'DOP',\n archivos_procesados: {},\n conteos_fuente: {},\n metricas: {\n etapa: 'inicio',\n origen: 'n8n',\n formulario: 'Cruce Seguridad Social RD'\n },\n resumen_completo: {\n reportTitle: init.reportTitle,\n runId: init.runId\n }\n }\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-3696,
3008
],
"id": "2d3dc332-3582-4afa-9e71-b836a33993c2",
"name": "Preparar auditoría inicial Supabase",
"continueOnFail": true
},
{
"parameters": {
"method": "POST",
"url": "=https://dbit.digitalcompass.agency/rest/v1/auditoria_cruces_seguridad_social?on_conflict=id_ejecucion",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "apikey",
"value": "=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Authorization",
"value": "=Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Prefer",
"value": "resolution=merge-duplicates,return=minimal"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json) }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.3,
"position": [
-3456,
3008
],
"id": "67e3e2ea-4875-49b1-a739-dd532b9a5885",
"name": "Registrar auditoría inicial Supabase",
"continueOnFail": true
},
{
"parameters": {
"jsCode": "function num(v) {\n const n = Number(v || 0);\n return Number.isFinite(n) ? Number(n.toFixed(2)) : 0;\n}\nfunction arr(v) {\n return Array.isArray(v) ? v : [];\n}\n\nconst cruce = $('Construir reporte producción').item.json;\nconst cfg = $('CONFIG - Destinatarios correo y permisos').item.json;\nconst sheet = $('Crear Google Sheet Reporte').item.json;\n\nconst summary = cruce.summary || {};\nconst payrollTotals = summary.payrollTotals || {};\nconst tssTotals = summary.tssTotals || {};\nconst diffTotals = summary.diffTotals || {};\n\nconst diferencias = arr(cruce.diferencias);\nconst alertasDocumentales = arr(cruce.alertasDocumentales);\n\nconst identityDiffs = num(summary.soloNomina) + num(summary.soloTss);\nconst monetaryDiffs = diferencias.filter(d => !/^solo\\s+/i.test(String(d.tipo || ''))).length;\nconst documentAlerts = alertasDocumentales.length;\n\nconst payrollEmployeeContrib = num(payrollTotals.afp) + num(payrollTotals.sfs);\nconst tssEmployeeContrib = num(tssTotals.afp) + num(tssTotals.sfs);\n\nconst spreadsheetId = cfg.spreadsheetId || sheet.spreadsheetId || '';\nconst spreadsheetUrl = cfg.spreadsheetUrl || sheet.spreadsheetUrl || (spreadsheetId ? `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit` : '');\n\nconst allEmails = arr(cfg.allEmails);\nconst manualMinutes = 60;\nconst automationMinutes = 5;\n\nconst workflowName = (typeof $workflow !== 'undefined' && $workflow.name)\n ? $workflow.name\n : 'Cruce Seguridad Social RD';\n\nconst workflowId = (typeof $workflow !== 'undefined' && $workflow.id)\n ? String($workflow.id)\n : '';\n\nconst executionId = (typeof $execution !== 'undefined' && $execution.id)\n ? String($execution.id)\n : '';\n\nreturn [{\n json: {\n id_ejecucion: cruce.runId,\n nombre_flujo: workflowName,\n id_flujo_n8n: workflowId || null,\n id_ejecucion_n8n: executionId || null,\n\n pais_codigo: 'RD',\n pais_nombre: 'República Dominicana',\n entidad_seguridad_social: 'TSS',\n\n mes: Number(cruce.mes || 0),\n anio: Number(cruce.anio || 0),\n periodo: cruce.periodoLabel || '',\n\n estado: 'exitoso',\n resultado: summary.statusText || 'Completado',\n\n fecha_fin: new Date().toISOString(),\n\n url_reporte: spreadsheetUrl,\n id_google_sheet: spreadsheetId || null,\n destinatarios_correo: allEmails,\n\n moneda: 'DOP',\n\n empleados_nomina: Number(summary.payrollPersons || 0),\n empleados_entidad: Number(summary.tssDocuments || 0),\n empleados_coincidentes: Number(summary.matched || 0),\n\n solo_nomina: Number(summary.soloNomina || 0),\n solo_entidad: Number(summary.soloTss || 0),\n\n diferencias_total: identityDiffs + monetaryDiffs + documentAlerts,\n diferencias_monetarias: monetaryDiffs,\n diferencias_identidad: identityDiffs,\n alertas_documentales: documentAlerts,\n registros_duplicados: Number(summary.documentosDuplicados || 0),\n alertas_periodo: 0,\n\n total_nomina: payrollEmployeeContrib,\n total_entidad: tssEmployeeContrib,\n diferencia_monto: num(diffTotals.totalEmpleado),\n\n minutos_estimados_manual: manualMinutes,\n minutos_automatizacion: automationMinutes,\n minutos_ahorrados: Math.max(manualMinutes - automationMinutes, 0),\n\n archivos_procesados: summary.archivos || {},\n conteos_fuente: {\n empleados_nomina: Number(summary.payrollPersons || 0),\n empleados_tss: Number(summary.tssDocuments || 0),\n coincidentes_resueltos: Number(summary.matched || 0),\n solo_nomina: Number(summary.soloNomina || 0),\n solo_tss: Number(summary.soloTss || 0)\n },\n metricas: {\n salario_cotizable_nomina: num(payrollTotals.base),\n salario_cotizable_tss: num(tssTotals.base),\n diferencia_salario_cotizable: num(diffTotals.base),\n afp_nomina: num(payrollTotals.afp),\n afp_tss: num(tssTotals.afp),\n diferencia_afp: num(diffTotals.afp),\n sfs_nomina: num(payrollTotals.sfs),\n sfs_tss: num(tssTotals.sfs),\n diferencia_sfs: num(diffTotals.sfs),\n cambios_documento: Number(summary.cambiosDocumento || 0),\n documentos_normalizados: Number(summary.documentosNormalizados || 0)\n },\n resumen_completo: {\n summary,\n diferencias,\n alertasDocumentales,\n reporte: {\n spreadsheetId,\n spreadsheetUrl\n }\n },\n\n mensaje_error: null,\n nodo_error: null\n }\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1920,
4064
],
"id": "81c4d558-c355-400c-8a87-ad3f60e134e8",
"name": "Preparar auditoría final Supabase",
"continueOnFail": true
},
{
"parameters": {
"method": "PATCH",
"url": "=https://dbit.digitalcompass.agency/rest/v1/auditoria_cruces_seguridad_social?id_ejecucion=eq.{{$json.id_ejecucion}}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "apikey",
"value": "=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Authorization",
"value": "=Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Prefer",
"value": "resolution=merge-duplicates,return=minimal"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json) }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.3,
"position": [
2160,
4064
],
"id": "5d9965a0-e4a2-4099-84ff-a9c1ba4cd243",
"name": "Guardar auditoría final Supabase",
"continueOnFail": true
},
{
"parameters": {
"jsCode": "function num(v) {\n const n = Number(v || 0);\n return Number.isFinite(n) ? Number(n.toFixed(2)) : 0;\n}\nfunction clean(v) {\n return String(v || '').replace(/\\s+/g, ' ').trim();\n}\n\nconst cruce = $('Construir reporte producción').item.json;\nconst summary = cruce.summary || {};\nconst rows = [];\n\nfor (const d of (cruce.diferencias || [])) {\n const tipo = clean(d.tipo);\n const isIdentity = /^solo\\s+/i.test(tipo);\n const amountDiff = Math.abs(num(d.dif_base)) + Math.abs(num(d.dif_afp)) + Math.abs(num(d.dif_sfs));\n\n rows.push({\n id_ejecucion: cruce.runId,\n pais_codigo: 'RD',\n pais_nombre: 'República Dominicana',\n\n categoria: isIdentity ? 'diferencia_identidad' : 'diferencia_monetaria',\n severidad: isIdentity ? 'revision' : (amountDiff > 1000 ? 'critico' : 'revision'),\n\n identificador_empleado: clean(d.documento) || null,\n nombre_empleado: clean(d.nombre_nomina || d.nombre_tss) || null,\n\n fuente_a: 'Nómina',\n fuente_b: 'TSS',\n\n diferencia_monto: num(amountDiff),\n moneda: 'DOP',\n\n descripcion: isIdentity\n ? `${tipo}: empleado no aparece en ambas fuentes.`\n : `${tipo}: diferencia en salario cotizable, AFP o SFS/ARS.`,\n accion_sugerida: clean(d.accion) || 'Revisar con Nómina/Administración.',\n\n estado_resolucion: 'abierto',\n datos_originales: d\n });\n}\n\nfor (const a of (cruce.alertasDocumentales || [])) {\n rows.push({\n id_ejecucion: cruce.runId,\n pais_codigo: 'RD',\n pais_nombre: 'República Dominicana',\n\n categoria: 'alerta_documental',\n severidad: 'advertencia',\n\n identificador_empleado: clean(a.documento_nomina || a.documento_tss) || null,\n nombre_empleado: clean(a.nombre_nomina || a.nombre_tss) || null,\n\n fuente_a: 'Nómina',\n fuente_b: 'TSS',\n\n diferencia_monto: null,\n moneda: 'DOP',\n\n descripcion: clean(a.tipo) || 'Alerta documental detectada.',\n accion_sugerida: clean(a.accion) || 'Revisar documento/identidad en archivos fuente.',\n\n estado_resolucion: 'abierto',\n datos_originales: a\n });\n}\n\nreturn rows.map(r => ({ json: r }));"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1920,
4256
],
"id": "78f93bbd-d67f-4c90-bebb-7926561730bf",
"name": "Preparar hallazgos auditoría Supabase",
"continueOnFail": true
},
{
"parameters": {
"method": "POST",
"url": "=https://dbit.digitalcompass.agency/rest/v1/auditoria_cruces_hallazgos",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "apikey",
"value": "=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Authorization",
"value": "=Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Prefer",
"value": "return=minimal"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json) }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.3,
"position": [
2160,
4256
],
"id": "66c92bb1-771d-4ac8-8c63-603571b6eb43",
"name": "Guardar hallazgos auditoría Supabase",
"continueOnFail": true
},
{
"parameters": {
"jsCode": "function clean(v) {\n return String(v || '').replace(/\\s+/g, ' ').trim();\n}\n\nconst cruce = $('Construir reporte producción').item.json;\nconst summary = cruce.summary || {};\nconst archivos = summary.archivos || {};\n\nconst rows = [];\n\nconst defs = [\n { rol: 'Nómina 1', nombre: archivos['Nómina 1'], registros: null },\n { rol: 'Nómina 2', nombre: archivos['Nómina 2'], registros: null },\n { rol: 'TSS', nombre: archivos['TSS'], registros: Number(summary.tssDocuments || 0) }\n];\n\nfor (const d of defs) {\n if (!clean(d.nombre)) continue;\n rows.push({\n id_ejecucion: cruce.runId,\n pais_codigo: 'RD',\n rol_archivo: d.rol,\n nombre_archivo: clean(d.nombre),\n tipo_archivo: 'Excel',\n registros_detectados: d.registros,\n monto_total: null,\n datos_originales: {\n periodo: cruce.periodoLabel,\n reportTitle: cruce.reportTitle\n }\n });\n}\n\nreturn rows.map(r => ({ json: r }));"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1920,
4448
],
"id": "3c09292e-9ec9-414f-8294-f18d170c7446",
"name": "Preparar archivos auditoría Supabase",
"continueOnFail": true
},
{
"parameters": {
"method": "POST",
"url": "=https://dbit.digitalcompass.agency/rest/v1/auditoria_cruces_archivos",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "apikey",
"value": "=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Authorization",
"value": "=Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Prefer",
"value": "return=minimal"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json) }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.3,
"position": [
2160,
4448
],
"id": "02ed299e-09e6-4454-9485-b94927ddf1ec",
"name": "Guardar archivos auditoría Supabase",
"continueOnFail": true
},
{
"parameters": {
"content": "## Auditoría Supabase\n\nGuarda uso del formulario, resultado del cruce, diferencias, archivos y enlace del reporte.\n\nUsa variables de entorno:\n- SUPABASE_URL\n- SUPABASE_SERVICE_ROLE_KEY\n\nLos nodos tienen Continue On Fail para no detener el cruce si Supabase falla.",
"height": 816,
"width": 568,
"color": 7
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
1808,
3840
],
"id": "8327bacb-0777-4691-8e9e-13c9856beffc",
"name": "Sticky Note Supabase"
},
{
"parameters": {
"url": "https://glm.bamboohr.com/api/v1/employees?fields=fullName5,displayName,firstName,middleName,lastName,hireDate,originalHireDate,status,employeeNumber,location,country,workEmail&page%5Blimit%5D=2500",
"authentication": "genericCredentialType",
"genericAuthType": "httpBasicAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Accept",
"value": "application/json"
}
]
},
"options": {
"response": {
"response": {
"responseFormat": "json"
}
},
"pagination": {
"pagination": {
"parameters": {
"parameters": [
{
"name": "page[after]",
"value": "={{ $response.body.meta.page.nextCursor }}"
}
]
},
"paginationCompleteWhen": "other",
"completeExpression": "={{ !$response.body?.meta?.page?.nextCursor }}",
"limitPagesFetched": true,
"requestInterval": 500
}
},
"timeout": 300000
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
-1152,
4112
],
"id": "547e47b9-ee4c-4c2d-bfe8-0226fbed3e11",
"name": "Consultar empleados BambooHR",
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 2000,
"credentials": {
"httpBasicAuth": {
"id": "7VrpNZ2jBLmiJ35q",
"name": "BambooHR GLM Full Access"
}
}
},
{
"parameters": {
"jsCode": "const init = $items('Inicializar ejecución')[0].json;\nconst inputItems = $input.all();\n\nfunction clean(value) {\n return String(value ?? '').replace(/\\s+/g, ' ').trim();\n}\nfunction normalizeName(value) {\n return clean(value)\n .normalize('NFD').replace(/[\\u0300-\\u036f]/g, '')\n .toUpperCase()\n .replace(/[^A-Z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\nfunction normalizeDate(value) {\n const raw = clean(value);\n if (!raw) return '';\n const match = raw.match(/^(\\d{4}-\\d{2}-\\d{2})/);\n return match ? match[1] : raw;\n}\nfunction unwrapResponse(item) {\n const payload = item?.json || {};\n if (payload.body && typeof payload.body === 'object') return payload.body;\n return payload;\n}\n\nconst responses = inputItems.map(unwrapResponse);\nconst rows = [];\n\nfor (const response of responses) {\n if (Array.isArray(response.data)) {\n rows.push(...response.data);\n } else if (Array.isArray(response.employees)) {\n rows.push(...response.employees);\n } else if (Array.isArray(response)) {\n rows.push(...response);\n }\n}\n\nconst totals = responses\n .map(response => Number(response?.meta?.total || 0))\n .filter(value => Number.isFinite(value) && value > 0);\nconst totalReported = totals.length ? Math.max(...totals) : rows.length;\n\nconst rawByEmployee = new Map();\nrows.forEach((row, index) => {\n const nombre = row.fullName5 || row.displayName || [row.firstName, row.middleName, row.lastName].filter(Boolean).join(' ');\n const key =\n clean(row.employeeId || row.id) ||\n clean(row.employeeNumber) ||\n `${normalizeName(nombre)}|${normalizeDate(row.hireDate || row.originalHireDate)}` ||\n `fila-${index}`;\n\n if (!rawByEmployee.has(key)) rawByEmployee.set(key, row);\n});\n\nconst uniqueRawRows = [...rawByEmployee.values()];\n\nif (totalReported > uniqueRawRows.length) {\n throw new Error(\n `BambooHR devolvió ${uniqueRawRows.length} empleados únicos de ${totalReported}. ` +\n `La paginación no terminó correctamente y el cruce no sería completo.`\n );\n}\n\nconst normalized = [];\nfor (const row of uniqueRawRows) {\n const nombre = clean(\n row.fullName5 ||\n row.displayName ||\n row.fullName1 ||\n [row.firstName, row.middleName, row.lastName].map(clean).filter(Boolean).join(' ')\n );\n if (!nombre) continue;\n\n normalized.push({\n employee_id: clean(row.employeeId || row.id),\n employee_number: clean(row.employeeNumber),\n nombre_bamboo: nombre,\n nombre_norm: normalizeName(nombre),\n fecha_ingreso: normalizeDate(row.hireDate || row.originalHireDate),\n estado_bamboo: clean(row.status),\n ubicacion: clean(row.location),\n pais: clean(row.country),\n correo: clean(row.workEmail)\n });\n}\n\nconst byEmployee = new Map();\nfor (const row of normalized) {\n const key =\n row.employee_id ||\n row.employee_number ||\n `${row.nombre_norm}|${row.fecha_ingreso}`;\n\n if (!byEmployee.has(key)) byEmployee.set(key, row);\n}\n\nconst bambooRecords = [...byEmployee.values()];\n\nif (!bambooRecords.length) {\n throw new Error(\n 'BambooHR no devolvió empleados legibles. Revisa la credencial y los permisos para nombres, fecha de ingreso y estado.'\n );\n}\n\nreturn [{\n json: {\n runId: init.runId,\n bambooRecords,\n empleadosBamboo: bambooRecords.length,\n totalReportadoBamboo: totalReported,\n paginasBamboo: responses.length,\n empleadosRecibidosBamboo: rows.length,\n empleadosUnicosBamboo: uniqueRawRows.length\n }\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-912,
4112
],
"id": "2db39e72-9ced-4c98-a87e-9d80c5101ae6",
"name": "Normalizar empleados BambooHR"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
-768,
3872
],
"id": "b15d09e1-9e16-45f0-923e-9caa617db1cd",
"name": "Esperar BambooHR y cruce"
},
{
"parameters": {
"content": "## Consultar BambooHR\nConsulta de empleados en BambooHR para elaboración de hoja \"Nómina sin BambooHR\" y columnas en la hoja \"Reporte\"",
"height": 544,
"width": 688,
"color": "#672865"
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-1280,
3760
],
"id": "fcd7eeb2-a51b-485a-9249-3c6950211eac",
"name": "Sticky Note4"
},
{
"parameters": {
"operation": "completion",
"respondWith": "showText",
"responseText": "<div style=\"font-family:Arial,Helvetica,sans-serif;max-width:620px;margin:48px auto;padding:36px 30px;text-align:center;background:#ffffff;border:1px solid #dce4ea;border-radius:18px;box-shadow:0 10px 35px rgba(15,31,45,0.10);color:#243746;\">\n <div style=\"width:64px;height:64px;margin:0 auto 18px;border-radius:50%;background:#6CC24A;color:#ffffff;font-size:38px;line-height:64px;font-weight:700;\">✓</div>\n\n <h1 style=\"margin:0 0 12px;font-size:28px;color:#0f1f2d;\">\n Proceso completado\n </h1>\n\n <p style=\"margin:0 0 28px;font-size:16px;line-height:1.6;color:#607080;\">\n El reporte de Seguridad Social de República Dominicana fue generado, compartido y enviado por correo correctamente.\n </p>\n\n <a\n href=\"https://agenteit.digitalcompass.agency/form/44280b50-f6d1-44dc-9a59-206a9c3d940f\"\n target=\"_self\"\n style=\"display:inline-block;background:#6CC24A;color:#ffffff;text-decoration:none;padding:14px 24px;border-radius:999px;font-size:15px;font-weight:700;box-shadow:0 6px 18px rgba(108,194,74,0.25);\"\n >\n Regresar al formulario\n </a>\n</div>"
},
"type": "n8n-nodes-base.form",
"typeVersion": 2.5,
"position": [
2736,
4448
],
"id": "4daf0a8b-6e45-4835-845b-1fe1907d4154",
"name": "Confirmación y regreso al formulario",
"webhookId": "5adbd6f7-496c-43ab-883e-3de67a498d14",
"notesInFlow": true,
"notes": "Página final mostrada después de enviar el correo y completar la auditoría. Incluye un botón para regresar al formulario en la misma pestaña."
},
{
"parameters": {
"content": "## Regresar al formulario\nVolver al formulario luego de su ejecución",
"height": 320,
"width": 304,
"color": "#276347"
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
2640,
4336
],
"id": "4531f817-ac38-4102-8f71-05bef2b00b98",
"name": "Sticky Note5"
}
],
"pinData": {},
"connections": {
"On form submission": {
"main": [
[
{
"node": "Inicializar ejecución",
"type": "main",
"index": 0
}
]
]
},
"Inicializar ejecución": {
"main": [
[
{
"node": "Preparar Nómina 1",
"type": "main",
"index": 0
},
{
"node": "Preparar Nómina 2",
"type": "main",
"index": 0
},
{
"node": "Preparar TSS",
"type": "main",
"index": 0
},
{
"node": "Preparar auditoría inicial Supabase",
"type": "main",
"index": 0
},
{
"node": "Consultar empleados BambooHR",
"type": "main",
"index": 0
}
]
]
},
"Preparar Nómina 1": {
"main": [
[
{
"node": "Actualizar staging Nómina 1",
"type": "main",
"index": 0
}
]
]
},
"Actualizar staging Nómina 1": {
"main": [
[
{
"node": "Esperar conversión Nómina 1",
"type": "main",
"index": 0
}
]
]
},
"Esperar conversión Nómina 1": {
"main": [
[
{
"node": "Listar hojas Nómina 1",
"type": "main",
"index": 0
}
]
]
},
"Listar hojas Nómina 1": {
"main": [
[
{
"node": "Preparar hojas Nómina 1",
"type": "main",
"index": 0
}
]
]
},
"Preparar hojas Nómina 1": {
"main": [
[
{
"node": "Leer hojas Nómina 1",
"type": "main",
"index": 0
}
]
]
},
"Leer hojas Nómina 1": {
"main": [
[
{
"node": "Extraer Nómina 1",
"type": "main",
"index": 0
}
]
]
},
"Extraer Nómina 1": {
"main": [
[
{
"node": "Esperar nóminas",
"type": "main",
"index": 0
}
]
]
},
"Preparar Nómina 2": {
"main": [
[
{
"node": "Actualizar staging Nómina 2",
"type": "main",
"index": 0
}
]
]
},
"Actualizar staging Nómina 2": {
"main": [
[
{
"node": "Esperar conversión Nómina 2",
"type": "main",
"index": 0
}
]
]
},
"Esperar conversión Nómina 2": {
"main": [
[
{
"node": "Listar hojas Nómina 2",
"type": "main",
"index": 0
}
]
]
},
"Listar hojas Nómina 2": {
"main": [
[
{
"node": "Preparar hojas Nómina 2",
"type": "main",
"index": 0
}
]
]
},
"Preparar hojas Nómina 2": {
"main": [
[
{
"node": "Leer hojas Nómina 2",
"type": "main",
"index": 0
}
]
]
},
"Leer hojas Nómina 2": {
"main": [
[
{
"node": "Extraer Nómina 2",
"type": "main",
"index": 0
}
]
]
},
"Extraer Nómina 2": {
"main": [
[
{
"node": "Esperar nóminas",
"type": "main",
"index": 1
}
]
]
},
"Esperar nóminas": {
"main": [
[
{
"node": "Esperar TSS y nóminas",
"type": "main",
"index": 0
}
]
]
},
"Preparar TSS": {
"main": [
[
{
"node": "Extraer TSS",
"type": "main",
"index": 0
}
]
]
},
"Actualizar staging TSS": {
"main": [
[
{
"node": "Esperar conversión TSS",
"type": "main",
"index": 0
}
]
]
},
"Esperar conversión TSS": {
"main": [
[
{
"node": "Listar hojas TSS",
"type": "main",
"index": 0
}
]
]
},
"Listar hojas TSS": {
"main": [
[
{
"node": "Preparar hoja TSS",
"type": "main",
"index": 0
}
]
]
},
"Preparar hoja TSS": {
"main": [
[
{
"node": "Leer hoja TSS",
"type": "main",
"index": 0
}
]
]
},
"Extraer TSS": {
"main": [
[
{
"node": "Esperar TSS y nóminas",
"type": "main",
"index": 1
}
]
]
},
"Esperar TSS y nóminas": {
"main": [
[
{
"node": "Esperar BambooHR y cruce",
"type": "main",
"index": 0
}
]
]
},
"Construir reporte producción": {
"main": [
[
{
"node": "Crear Google Sheet Reporte",
"type": "main",
"index": 0
}
]
]
},
"Crear Google Sheet Reporte": {
"main": [
[
{
"node": "Escribir reporte",
"type": "main",
"index": 0
}
]
]
},
"Escribir reporte": {
"main": [
[
{
"node": "Preparar formato reporte",
"type": "main",
"index": 0
}
]
]
},
"Formatear reporte": {
"main": [
[
{
"node": "CONFIG - Destinatarios correo y permisos",
"type": "main",
"index": 0
}
]
]
},
"Compartir reporte destinatarios Gmail": {
"main": [
[
{
"node": "Preparar correo único",
"type": "main",
"index": 0
}
]
]
},
"CONFIG - Destinatarios correo y permisos": {
"main": [
[
{
"node": "Expandir permisos destinatarios",
"type": "main",
"index": 0
}
]
]
},
"Expandir permisos destinatarios": {
"main": [
[
{
"node": "Compartir reporte destinatarios Gmail",
"type": "main",
"index": 0
}
]
]
},
"Preparar correo único": {
"main": [
[
{
"node": "Enviar correo resumen",
"type": "main",
"index": 0
},
{
"node": "Preparar auditoría final Supabase",
"type": "main",
"index": 0
},
{
"node": "Preparar hallazgos auditoría Supabase",
"type": "main",
"index": 0
},
{
"node": "Preparar archivos auditoría Supabase",
"type": "main",
"index": 0
}
]
]
},
"Preparar formato reporte": {
"main": [
[
{
"node": "Formatear reporte",
"type": "main",
"index": 0
}
]
]
},
"Preparar auditoría inicial Supabase": {
"main": [
[
{
"node": "Registrar auditoría inicial Supabase",
"type": "main",
"index": 0
}
]
]
},
"Preparar auditoría final Supabase": {
"main": [
[
{
"node": "Guardar auditoría final Supabase",
"type": "main",
"index": 0
}
]
]
},
"Preparar hallazgos auditoría Supabase": {
"main": [
[
{
"node": "Guardar hallazgos auditoría Supabase",
"type": "main",
"index": 0
}
]
]
},
"Preparar archivos auditoría Supabase": {
"main": [
[
{
"node": "Guardar archivos auditoría Supabase",
"type": "main",
"index": 0
}
]
]
},
"Consultar empleados BambooHR": {
"main": [
[
{
"node": "Normalizar empleados BambooHR",
"type": "main",
"index": 0
}
]
]
},
"Normalizar empleados BambooHR": {
"main": [
[
{
"node": "Esperar BambooHR y cruce",
"type": "main",
"index": 1
}
]
]
},
"Esperar BambooHR y cruce": {
"main": [
[
{
"node": "Construir reporte producción",
"type": "main",
"index": 0
}
]
]
},
"Guardar archivos auditoría Supabase": {
"main": [
[
{
"node": "Confirmación y regreso al formulario",
"type": "main",
"index": 0
}
]
]
}
},
"active": true,
"settings": {
"executionOrder": "v1",
"binaryMode": "separate",
"availableInMCP": true,
"timeSavedMode": "fixed",
"errorWorkflow": "puF4LUczoSz3hcek",
"timezone": "America/Santo_Domingo",
"callerPolicy": "workflowsFromSameOwner"
},
"versionId": "0e35d096-4f9e-40a1-b12c-3075978ec9c2",
"meta": {
"instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70"
},
"id": "adEY9RJsE9o9AJPb",
"tags": []
}