From f534ad76decde8fc74ac80991543a76b6364c29d Mon Sep 17 00:00:00 2001 From: Isaac_Aracena Date: Sun, 30 Aug 2026 04:00:51 +0000 Subject: [PATCH] =?UTF-8?q?Create=20backup:=20Cruce=20de=20Seguridad=20Soc?= =?UTF-8?q?ial=20-=20Panam=C3=A1=20-=20PRODUCCI=C3=93N=20v8.2=207=20DESTIN?= =?UTF-8?q?ATARIOS=20Y=20EDITORES?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ccion-v8-2-7-destinatarios-y-editores.json | 2139 +++++++++++++++++ 1 file changed, 2139 insertions(+) create mode 100644 cruce-de-seguridad-social-panama-produccion-v8-2-7-destinatarios-y-editores.json diff --git a/cruce-de-seguridad-social-panama-produccion-v8-2-7-destinatarios-y-editores.json b/cruce-de-seguridad-social-panama-produccion-v8-2-7-destinatarios-y-editores.json new file mode 100644 index 0000000..e47c5f6 --- /dev/null +++ b/cruce-de-seguridad-social-panama-produccion-v8-2-7-destinatarios-y-editores.json @@ -0,0 +1,2139 @@ +{ + "updatedAt": "2026-08-28T14:42:54.659Z", + "createdAt": "2026-08-27T01:59:23.102Z", + "id": "i5dlh4n3RAbfcB4i", + "name": "Cruce de Seguridad Social - Panamá - PRODUCCIÓN v8.2 7 DESTINATARIOS Y EDITORES", + "description": null, + "active": true, + "isArchived": false, + "nodes": [ + { + "parameters": { + "formTitle": "Cruce de Seguridad Social · Panamá", + "formDescription": "Carga las dos nóminas quincenales en PDF, la Planilla CSS oficial y los movimientos complementarios. El período se detecta automáticamente desde CSS. Extraordinarias y XIII Mes son opcionales; Extraordinarias, Vacaciones, Liquidaciones y XIII Mes aceptan múltiples PDFs. El flujo valida período, integridad y bases cotizables antes de generar el reporte.", + "formFields": { + "values": [ + { + "fieldLabel": "Nómina - 1era Quincena", + "fieldType": "file", + "multipleFiles": false, + "acceptFileTypes": ".pdf", + "requiredField": true + }, + { + "fieldLabel": "Nómina - 2da Quincena", + "fieldType": "file", + "multipleFiles": false, + "acceptFileTypes": ".pdf", + "requiredField": true + }, + { + "fieldLabel": "Planilla CSS", + "fieldType": "file", + "multipleFiles": false, + "acceptFileTypes": ".xls,.xlsx", + "requiredField": true + }, + { + "fieldLabel": "Extraordinarias (opcional)", + "fieldType": "file", + "acceptFileTypes": ".pdf" + }, + { + "fieldLabel": "Vacaciones", + "fieldType": "file", + "acceptFileTypes": ".pdf", + "requiredField": true + }, + { + "fieldLabel": "Liquidaciones", + "fieldType": "file", + "acceptFileTypes": ".pdf", + "requiredField": true + }, + { + "fieldLabel": "XIII Mes (opcional)", + "fieldType": "file", + "acceptFileTypes": ".pdf" + } + ] + }, + "responseMode": "lastNode", + "options": { + "path": "95ce3005-71fd-4432-a050-b3858d3f79a5", + "ignoreBots": true + } + }, + "type": "n8n-nodes-base.formTrigger", + "typeVersion": 2.3, + "position": [ + 60400, + 8752 + ], + "id": "27ce083d-698c-482a-b3b3-97ea63563665", + "name": "Formulario · Cruce SS Panamá", + "webhookId": "95ce3005-71fd-4432-a050-b3858d3f79a5", + "notesInFlow": true, + "notes": "Formulario mensual. Extraordinarias y XIII Mes pueden omitirse sin romper el flujo." + }, + { + "parameters": { + "jsCode": "const item=$input.first();\nconst now=new Date();\nconst pad=n=>String(n).padStart(2,'0');\nconst runId=`PA_CSS_${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}_${Math.random().toString(36).slice(2,8)}`;\nconst recipients=[\"iaracena@gomezleemarketing.com\", \"ymadera@gomezleemarketing.com\", \"msoto@gomezleemarketing.com\", \"asrodriguez@gomezleemarketing.com\", \"iherrera@gomezleemarketing.com\", \"mgomez@gomezleemarketing.com\", \"vparamo@gomezleemarketing.com\"];\nreturn [{json:{\n runId,\n startedAt:now.toISOString(),\n recipient:recipients.join(', '),\n recipients,\n editorEmail:recipients[0],\n editorEmails:recipients,\n logoUrl:'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png',\n formUrl:'https://agenteit.digitalcompass.agency/form/95ce3005-71fd-4432-a050-b3858d3f79a5',\n salaryTolerance:0.10,\n dtmTolerance:0.10,\n config:{\n defaultRates:{ssRegular:0.0975,se:0.0125,ssDtm:0.0725},\n cssIntegrityTolerance:0.10\n }\n},binary:item.binary||{}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 60640, + 8752 + ], + "id": "97f89f1c-62de-4204-9dee-1512221f8f42", + "name": "Inicializar ejecución", + "notesInFlow": true, + "notes": "Configuración central v6: URL estable del formulario, destinatario/editor, logo, tolerancias y tasas." + }, + { + "parameters": { + "method": "POST", + "url": "https://glm.bamboohr.com/api/v1/reports/custom?format=JSON&onlyCurrent=false", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "{\"title\":\"Cruce Seguridad Social Panamá - empleados BambooHR\",\"fields\":[\"firstName\",\"middleName\",\"lastName\",\"displayName\",\"location\",\"division\",\"country\",\"status\",\"employmentStatus\",\"employeeNumber\"]}", + "options": { + "response": { + "response": { + "responseFormat": "json" + } + }, + "timeout": 300000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 60880, + 8752 + ], + "id": "02ec7bc1-1492-439b-a7c3-83122eabc093", + "name": "Generar reporte completo BambooHR", + "retryOnFail": true, + "notesInFlow": true, + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + }, + "notes": "Consulta BambooHR Active + Inactive con onlyCurrent=false. Se solicitan solo los campos necesarios para reducir memoria/CPU del flujo." + }, + { + "parameters": { + "jsCode": "const base=$items('Inicializar ejecución')[0];\nconst b=$input.first().json||{};\nconst employees=Array.isArray(b.employees)?b.employees:(Array.isArray(b.data?.employees)?b.data.employees:[]);\nif(!employees.length) throw new Error('BambooHR no devolvió empleados. Se detuvo el cruce para no generar una hoja “Nómina sin BambooHR” incorrecta.');\nreturn [{json:{...base.json,bambooEmployeeCount:employees.length},binary:base.binary||{}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 61120, + 8752 + ], + "id": "5b8dfa9b-d790-4498-b746-7e65fa8c873d", + "name": "Restaurar archivos del formulario" + }, + { + "parameters": { + "jsCode": "const base=$items('Restaurar archivos del formulario')[0];\nconst bin=base.binary||{};\nfunction norm(s){return String(s||'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase();}\nconst half=1;\nconst entries=Object.entries(bin).map(([key,file])=>{\n const name=String(file.fileName||key); const s=norm(`${key} ${name}`); const mime=norm(file.mimeType||'');\n let score=(s.includes('.pdf')||mime.includes('pdf'))?100:0;\n if(half===1 && /(1era|primera|1ra|q1|quincena\\s*1|1era\\s*quincena)/.test(s))score+=300;\n if(half===2 && /(2da|segunda|2da|q2|quincena\\s*2|2da\\s*quincena)/.test(s))score+=300;\n if(/css|detalleplanilla|extraordin|vacacion|liquidacion|xiii|decimo/.test(s))score-=250;\n return {key,file,name,score};\n}).sort((a,b)=>b.score-a.score);\nconst chosen=entries[0];\nif(!chosen||chosen.score<250) throw new Error(`No pude identificar la nómina de la ${half===1?'1era':'2da'} quincena en PDF.`);\nreturn [{json:{...base.json,fileName:chosen.name,originalBinaryKey:chosen.key,half},binary:{data:chosen.file}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 61360, + 8752 + ], + "id": "c1487f8a-8050-40f6-a08c-add38dec722a", + "name": "Preparar Q1" + }, + { + "parameters": { + "operation": "pdf", + "options": {} + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + 61600, + 8752 + ], + "id": "eebb39f0-7755-461c-993d-1ab09cc603b2", + "name": "Extraer texto Q1" + }, + { + "parameters": { + "jsCode": "const prep=$items('Preparar Q1')[0].json;\nconst item=$input.first();\nconst candidateStrings=Object.values(item.json||{}).filter(v=>typeof v==='string');\nconst raw=item.json?.text??item.json?.data??item.json?.content??candidateStrings.sort((a,b)=>b.length-a.length)[0]??'';\nconst text=String(raw).replace(/\\r/g,'');\nconst config=$items('Inicializar ejecución')[0]?.json?.config||{};\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction num(v){if(v===null||v===undefined||v==='')return null;let s=String(v).replace(/B\\/\\.?|\\$/gi,'').replace(/,/g,'').trim();let neg=/^\\(.*\\)$/.test(s);if(neg)s=s.slice(1,-1);if(!/^-?\\d+(?:\\.\\d+)?$/.test(s))return null;const n=Number(s);return Number.isFinite(n)?(neg?-n:n):null;}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction moneyLine(s){return /^[\\s$()\\-]*[\\d,]+(?:\\.\\d+)?\\s*$/.test(String(s||''));}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase();}\nfunction plausible(gross,ss,se){if(!Number.isFinite(gross)||!Number.isFinite(ss)||!Number.isFinite(se))return false;if(gross<0||ss<-.01||se<-.01)return false;if(gross>0&&(ss/gross>.30||se/gross>.08))return false;return true;}\nfunction median(a){const x=a.filter(Number.isFinite).sort((p,q)=>p-q);if(!x.length)return null;const m=Math.floor(x.length/2);return x.length%2?x[m]:(x[m-1]+x[m])/2;}\nfunction fixed2Tokens(s){return (String(s||'').match(/\\(?-?(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{2}\\)?/g)||[]).map(num).filter(v=>v!==null);}\nfunction sim(a,b){a=norm(a).replace(/[^A-Z]/g,'');b=norm(b).replace(/[^A-Z]/g,'');if(!a||!b)return 0;if(a===b)return 1;const prev=Array.from({length:b.length+1},(_,i)=>i);for(let i=1;i<=a.length;i++){const cur=[i];for(let j=1;j<=b.length;j++)cur[j]=Math.min(cur[j-1]+1,prev[j]+1,prev[j-1]+(a[i-1]===b[j-1]?0:1));for(let j=0;jt[0]||'').join('');\n if(initials===code||initials.split('').reverse().join('')===code)best=Math.max(best,.99);\n return best;\n}\nfunction splitFlatNameCode(rawSuffix){\n let x=clean(rawSuffix);\n // Extract From File (PDF.js) deja al final \" 0.000.00\" (campos de la extrema izquierda/derecha).\n x=x.replace(/\\s+(?:(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{2})+\\s*$/,'').trim();\n let m=x.match(/^(.*?)(\\d{1,8})$/);\n if(m&&m[1].includes(','))return {name:clean(m[1]),code:m[2].replace(/^0+(?=\\d)/,'')};\n if(!x.includes(','))return {name:x,code:''};\n let best=null;\n for(let k=2;k<=Math.min(6,x.length-2);k++){\n const code=x.slice(-k).toUpperCase();if(!/^[A-Z]+$/.test(code))continue;\n const name=clean(x.slice(0,-k));if(!name.includes(','))continue;\n const score=alphaCodeScore(code,name)+k*.004;\n if(!best||score>best.score)best={name,code,score};\n }\n if(best&&best.score>=.87)return {name:best.name,code:best.code};\n return {name:x,code:''};\n}\nfunction dedupe(rows){const seen=new Set(),out=[];for(const r of rows){const k=[r.code,norm(r.name),round(r.ss).toFixed(2),round(r.se).toFixed(2),round(r.gross).toFixed(2),round(r.net).toFixed(2)].join('|');if(seen.has(k))continue;seen.add(k);out.push(r);}return out;}\nif(!text.trim())throw new Error(`Nómina Q1 PDF no contiene texto extraíble. Se detuvo el cruce para evitar un reporte incompleto: ${prep.fileName}`);\n\nconst periodMatch=text.match(/Periodo\\s+del\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})\\s+hasta\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})/i);\nconst period=periodMatch?{start:periodMatch[1],end:periodMatch[2]}:null;\nconst upper=text.toUpperCase(),gi=upper.lastIndexOf('TOTALES GENERALES'),tail=gi>=0?text.slice(gi):text;\nconst cm=tail.match(/Cantidad\\s+de\\s+Empleados:\\s*(\\d+)/i),printedCount=cm?Number(cm[1]):0;\n\n// MODO 1: texto tabular normal (PDF que conserva el orden visual izquierda → derecha).\nconst horizontal=[];\nfor(const rawLine of text.split('\\n')){\n const rate=rawLine.match(/\\d+\\.\\d{5,6}/);if(!rate)continue;\n const prefix=clean(rawLine.slice(0,rate.index)),rest=rawLine.slice((rate.index||0)+rate[0].length);\n const vals=(rest.match(/\\(?-?[\\d,]+(?:\\.\\d+)?\\)?/g)||[]).map(num).filter(v=>v!==null);\n if(vals.length!==23)continue;\n let code='',name=prefix;const pm=prefix.match(/^([A-Za-z0-9]{1,8})\\s+(.+)$/);\n if(pm){code=pm[1].replace(/^0+(?=\\d)/,'');name=clean(pm[2]);}\n if(!name.includes(',')||!/[A-ZÁÉÍÓÚÑ]/i.test(name))continue;\n const u=norm(name);if(/GOMEZLEE|DEPARTAMENTO|TOTALES|NOMBRE DEL EMPLEADO|PLANILLA|PERIODO/.test(u))continue;\n const gross=round(vals[13]),ss=round(vals[15]),se=round(vals[16]);if(!plausible(gross,ss,se))continue;\n horizontal.push({code,name,ss,se,gross,otherIncome:round(vals[12]),totalDeductions:round(vals[20]),net:round(vals[21]),source:'Nómina Q1',fileName:prep.fileName,period});\n}\n\n// MODO 2: salida REAL de n8n Extract From File / PDF.js para estas planillas.\n// PDF.js invierte las columnas y concatena montos sin espacios, por ejemplo:\n// Neto + Total Deduc. + Otras + ISR + S.E. + S.S. + Otros + Bruto + ... + tarifa + Nombre + Código.\n// El parser anterior suponía el orden visual y por eso perdía empleados y fallaba el conteo total.\nconst flat=[];\nfor(const rawLine of text.split('\\n')){\n const line=clean(rawLine);if(!line.includes(','))continue;\n const rates=[...line.matchAll(/(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{5,6}/g)];if(!rates.length)continue;\n const rate=rates[rates.length-1],left=line.slice(0,rate.index),suffix=line.slice((rate.index||0)+rate[0].length);\n const vals=fixed2Tokens(left);if(vals.length<8)continue;\n const nc=splitFlatNameCode(suffix),name=clean(nc.name),code=clean(nc.code).replace(/^0+(?=\\d)/,'');\n if(!name.includes(',')||!/[A-ZÁÉÍÓÚÑ]/i.test(name))continue;\n const u=norm(name);if(/GOMEZLEE|DEPARTAMENTO|TOTALES|NOMBRE DEL EMPLEADO|PLANILLA|PERIODO/.test(u))continue;\n let se=round(vals[4]),ss=round(vals[5]),gross=round(vals[7]),otherIncome=vals.length>8?round(vals[8]):0;\n // Compatibilidad defensiva si una futura versión de PDF.js conserva una columna adicional.\n const targetSS=Number(config.defaultRates?.ssRegular??.0975),targetSE=Number(config.defaultRates?.se??.0125);\n const fixedRateOK=gross<=0||(ss===0&&se===0)||(ss/gross>.03&&ss/gross<.20&&se/gross>.001&&se/gross<.05);\n if(!plausible(gross,ss,se)||!fixedRateOK){\n let best=null;\n for(let i=2;i20)||ss0<0||se0<0)continue;\n const rs=ss0/g,re=se0/g;if(rs>.30||re>.08)continue;\n const score=Math.abs(rs-targetSS)*10+Math.abs(re-targetSE)*20+Math.abs((j-i)-3)*.03;\n if(!best||score=0&&nums.length<36){const s=lines[j];if(!s){j--;continue;}if(moneyLine(s)){nums.push(num(s));j--;continue;}if(nums.length>=10)break;j--;}\n nums.reverse();if(nums.length<10)continue;let best=null;\n for(let k=Math.max(0,nums.length-32);k<=nums.length-8;k++){\n const [net,deducciones,otrasDesc,isr,se,ss,otrosIngresos,gross]=nums.slice(k,k+8);\n if([net,deducciones,se,ss,otrosIngresos,gross].some(v=>v===null)||!plausible(gross,ss,se))continue;\n const residual=Math.abs((gross+otrosIngresos-deducciones)-net);if(residual>1.25)continue;\n const score=residual+Math.abs(k-(nums.length-22))*.001;if(!best||score0)chosen=candidates.find(c=>c.rows.length===printedCount)||null;\nif(!chosen)chosen=[...candidates].sort((a,b)=>b.rows.length-a.rows.length)[0];\nconst out=chosen?.rows||[],parserMode=chosen?.mode||'SIN_PARSER';\nif(printedCount>0&&out.length!==printedCount)throw new Error(`Nómina Q1 PDF indica ${printedCount} empleados, pero ningún lector alcanzó ese total (tabular ${candidates[0].rows.length}, PDF.js ${candidates[1].rows.length}, vertical ${candidates[2].rows.length}). Se detuvo el cruce para no generar un reporte incompleto. Archivo: ${prep.fileName}`);\nif(!out.length)throw new Error(`No pude extraer empleados de Nómina Q1 PDF. El archivo no coincide con el formato de planilla esperado.`);\n\n// Control de cobertura monetaria e inferencia de tasas: no basta con contar nombres.\nconst grossPositive=out.filter(r=>Number(r.gross)>20).length;\nconst ssRatios=out.filter(r=>Number(r.gross)>20&&Number(r.ss)>0).map(r=>Number(r.ss)/Number(r.gross));\nconst seRatios=out.filter(r=>Number(r.gross)>20&&Number(r.se)>0).map(r=>Number(r.se)/Number(r.gross));\nconst minCoverage=Math.max(1,Math.floor(out.length*.70));\nif(grossPositive.05&&medSS<.15&&medSE>.004&&medSE<.03))throw new Error(`Nómina Q1 PDF superó el conteo, pero las tasas implícitas no son plausibles (S.S. ${((medSS||0)*100).toFixed(2)}%, S.E. ${((medSE||0)*100).toFixed(2)}%). Se detuvo para evitar un cruce incorrecto.`);\nreturn [{json:{fileName:prep.fileName,format:'PDF',parserMode,records:out,employeeCount:out.length,printedEmployeeCount:printedCount||null,missingCodeCount:out.filter(r=>!r.code).length,totalSS:round(out.reduce((s,r)=>s+r.ss,0)),totalSE:round(out.reduce((s,r)=>s+r.se,0)),period,stagingSpreadsheetId:'',integrityStatus:printedCount?'VALIDADO CONTRA TOTAL DEL PDF + COBERTURA MONETARIA':'VALIDADO POR COBERTURA MONETARIA'}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 61840, + 8752 + ], + "id": "71e64042-e6f8-46bc-b0b7-95c5becb8c74", + "name": "Parsear Q1", + "notesInFlow": true, + "notes": "Parser multiformato para nómina Q1: soporta tabla normal, salida PDF.js invertida/concatenada y extracción vertical. Valida el total impreso de empleados y cobertura monetaria antes de continuar." + }, + { + "parameters": { + "jsCode": "const base=$items('Restaurar archivos del formulario')[0];\nconst bin=base.binary||{};\nfunction norm(s){return String(s||'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase();}\nconst half=2;\nconst entries=Object.entries(bin).map(([key,file])=>{\n const name=String(file.fileName||key); const s=norm(`${key} ${name}`); const mime=norm(file.mimeType||'');\n let score=(s.includes('.pdf')||mime.includes('pdf'))?100:0;\n if(half===1 && /(1era|primera|1ra|q1|quincena\\s*1|1era\\s*quincena)/.test(s))score+=300;\n if(half===2 && /(2da|segunda|2da|q2|quincena\\s*2|2da\\s*quincena)/.test(s))score+=300;\n if(/css|detalleplanilla|extraordin|vacacion|liquidacion|xiii|decimo/.test(s))score-=250;\n return {key,file,name,score};\n}).sort((a,b)=>b.score-a.score);\nconst chosen=entries[0];\nif(!chosen||chosen.score<250) throw new Error(`No pude identificar la nómina de la ${half===1?'1era':'2da'} quincena en PDF.`);\nreturn [{json:{...base.json,fileName:chosen.name,originalBinaryKey:chosen.key,half},binary:{data:chosen.file}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 62080, + 8752 + ], + "id": "a84a05bc-0ccc-4f34-a4b5-b0ad7f73d2d3", + "name": "Preparar Q2" + }, + { + "parameters": { + "operation": "pdf", + "options": {} + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + 62320, + 8752 + ], + "id": "6aafefde-0b6f-458f-bf50-a65d5966089a", + "name": "Extraer texto Q2" + }, + { + "parameters": { + "jsCode": "const prep=$items('Preparar Q2')[0].json;\nconst item=$input.first();\nconst candidateStrings=Object.values(item.json||{}).filter(v=>typeof v==='string');\nconst raw=item.json?.text??item.json?.data??item.json?.content??candidateStrings.sort((a,b)=>b.length-a.length)[0]??'';\nconst text=String(raw).replace(/\\r/g,'');\nconst config=$items('Inicializar ejecución')[0]?.json?.config||{};\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction num(v){if(v===null||v===undefined||v==='')return null;let s=String(v).replace(/B\\/\\.?|\\$/gi,'').replace(/,/g,'').trim();let neg=/^\\(.*\\)$/.test(s);if(neg)s=s.slice(1,-1);if(!/^-?\\d+(?:\\.\\d+)?$/.test(s))return null;const n=Number(s);return Number.isFinite(n)?(neg?-n:n):null;}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction moneyLine(s){return /^[\\s$()\\-]*[\\d,]+(?:\\.\\d+)?\\s*$/.test(String(s||''));}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase();}\nfunction plausible(gross,ss,se){if(!Number.isFinite(gross)||!Number.isFinite(ss)||!Number.isFinite(se))return false;if(gross<0||ss<-.01||se<-.01)return false;if(gross>0&&(ss/gross>.30||se/gross>.08))return false;return true;}\nfunction median(a){const x=a.filter(Number.isFinite).sort((p,q)=>p-q);if(!x.length)return null;const m=Math.floor(x.length/2);return x.length%2?x[m]:(x[m-1]+x[m])/2;}\nfunction fixed2Tokens(s){return (String(s||'').match(/\\(?-?(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{2}\\)?/g)||[]).map(num).filter(v=>v!==null);}\nfunction sim(a,b){a=norm(a).replace(/[^A-Z]/g,'');b=norm(b).replace(/[^A-Z]/g,'');if(!a||!b)return 0;if(a===b)return 1;const prev=Array.from({length:b.length+1},(_,i)=>i);for(let i=1;i<=a.length;i++){const cur=[i];for(let j=1;j<=b.length;j++)cur[j]=Math.min(cur[j-1]+1,prev[j]+1,prev[j-1]+(a[i-1]===b[j-1]?0:1));for(let j=0;jt[0]||'').join('');\n if(initials===code||initials.split('').reverse().join('')===code)best=Math.max(best,.99);\n return best;\n}\nfunction splitFlatNameCode(rawSuffix){\n let x=clean(rawSuffix);\n // Extract From File (PDF.js) deja al final \" 0.000.00\" (campos de la extrema izquierda/derecha).\n x=x.replace(/\\s+(?:(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{2})+\\s*$/,'').trim();\n let m=x.match(/^(.*?)(\\d{1,8})$/);\n if(m&&m[1].includes(','))return {name:clean(m[1]),code:m[2].replace(/^0+(?=\\d)/,'')};\n if(!x.includes(','))return {name:x,code:''};\n let best=null;\n for(let k=2;k<=Math.min(6,x.length-2);k++){\n const code=x.slice(-k).toUpperCase();if(!/^[A-Z]+$/.test(code))continue;\n const name=clean(x.slice(0,-k));if(!name.includes(','))continue;\n const score=alphaCodeScore(code,name)+k*.004;\n if(!best||score>best.score)best={name,code,score};\n }\n if(best&&best.score>=.87)return {name:best.name,code:best.code};\n return {name:x,code:''};\n}\nfunction dedupe(rows){const seen=new Set(),out=[];for(const r of rows){const k=[r.code,norm(r.name),round(r.ss).toFixed(2),round(r.se).toFixed(2),round(r.gross).toFixed(2),round(r.net).toFixed(2)].join('|');if(seen.has(k))continue;seen.add(k);out.push(r);}return out;}\nif(!text.trim())throw new Error(`Nómina Q2 PDF no contiene texto extraíble. Se detuvo el cruce para evitar un reporte incompleto: ${prep.fileName}`);\n\nconst periodMatch=text.match(/Periodo\\s+del\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})\\s+hasta\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})/i);\nconst period=periodMatch?{start:periodMatch[1],end:periodMatch[2]}:null;\nconst upper=text.toUpperCase(),gi=upper.lastIndexOf('TOTALES GENERALES'),tail=gi>=0?text.slice(gi):text;\nconst cm=tail.match(/Cantidad\\s+de\\s+Empleados:\\s*(\\d+)/i),printedCount=cm?Number(cm[1]):0;\n\n// MODO 1: texto tabular normal (PDF que conserva el orden visual izquierda → derecha).\nconst horizontal=[];\nfor(const rawLine of text.split('\\n')){\n const rate=rawLine.match(/\\d+\\.\\d{5,6}/);if(!rate)continue;\n const prefix=clean(rawLine.slice(0,rate.index)),rest=rawLine.slice((rate.index||0)+rate[0].length);\n const vals=(rest.match(/\\(?-?[\\d,]+(?:\\.\\d+)?\\)?/g)||[]).map(num).filter(v=>v!==null);\n if(vals.length!==23)continue;\n let code='',name=prefix;const pm=prefix.match(/^([A-Za-z0-9]{1,8})\\s+(.+)$/);\n if(pm){code=pm[1].replace(/^0+(?=\\d)/,'');name=clean(pm[2]);}\n if(!name.includes(',')||!/[A-ZÁÉÍÓÚÑ]/i.test(name))continue;\n const u=norm(name);if(/GOMEZLEE|DEPARTAMENTO|TOTALES|NOMBRE DEL EMPLEADO|PLANILLA|PERIODO/.test(u))continue;\n const gross=round(vals[13]),ss=round(vals[15]),se=round(vals[16]);if(!plausible(gross,ss,se))continue;\n horizontal.push({code,name,ss,se,gross,otherIncome:round(vals[12]),totalDeductions:round(vals[20]),net:round(vals[21]),source:'Nómina Q2',fileName:prep.fileName,period});\n}\n\n// MODO 2: salida REAL de n8n Extract From File / PDF.js para estas planillas.\n// PDF.js invierte las columnas y concatena montos sin espacios, por ejemplo:\n// Neto + Total Deduc. + Otras + ISR + S.E. + S.S. + Otros + Bruto + ... + tarifa + Nombre + Código.\n// El parser anterior suponía el orden visual y por eso perdía empleados y fallaba el conteo total.\nconst flat=[];\nfor(const rawLine of text.split('\\n')){\n const line=clean(rawLine);if(!line.includes(','))continue;\n const rates=[...line.matchAll(/(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{5,6}/g)];if(!rates.length)continue;\n const rate=rates[rates.length-1],left=line.slice(0,rate.index),suffix=line.slice((rate.index||0)+rate[0].length);\n const vals=fixed2Tokens(left);if(vals.length<8)continue;\n const nc=splitFlatNameCode(suffix),name=clean(nc.name),code=clean(nc.code).replace(/^0+(?=\\d)/,'');\n if(!name.includes(',')||!/[A-ZÁÉÍÓÚÑ]/i.test(name))continue;\n const u=norm(name);if(/GOMEZLEE|DEPARTAMENTO|TOTALES|NOMBRE DEL EMPLEADO|PLANILLA|PERIODO/.test(u))continue;\n let se=round(vals[4]),ss=round(vals[5]),gross=round(vals[7]),otherIncome=vals.length>8?round(vals[8]):0;\n // Compatibilidad defensiva si una futura versión de PDF.js conserva una columna adicional.\n const targetSS=Number(config.defaultRates?.ssRegular??.0975),targetSE=Number(config.defaultRates?.se??.0125);\n const fixedRateOK=gross<=0||(ss===0&&se===0)||(ss/gross>.03&&ss/gross<.20&&se/gross>.001&&se/gross<.05);\n if(!plausible(gross,ss,se)||!fixedRateOK){\n let best=null;\n for(let i=2;i20)||ss0<0||se0<0)continue;\n const rs=ss0/g,re=se0/g;if(rs>.30||re>.08)continue;\n const score=Math.abs(rs-targetSS)*10+Math.abs(re-targetSE)*20+Math.abs((j-i)-3)*.03;\n if(!best||score=0&&nums.length<36){const s=lines[j];if(!s){j--;continue;}if(moneyLine(s)){nums.push(num(s));j--;continue;}if(nums.length>=10)break;j--;}\n nums.reverse();if(nums.length<10)continue;let best=null;\n for(let k=Math.max(0,nums.length-32);k<=nums.length-8;k++){\n const [net,deducciones,otrasDesc,isr,se,ss,otrosIngresos,gross]=nums.slice(k,k+8);\n if([net,deducciones,se,ss,otrosIngresos,gross].some(v=>v===null)||!plausible(gross,ss,se))continue;\n const residual=Math.abs((gross+otrosIngresos-deducciones)-net);if(residual>1.25)continue;\n const score=residual+Math.abs(k-(nums.length-22))*.001;if(!best||score0)chosen=candidates.find(c=>c.rows.length===printedCount)||null;\nif(!chosen)chosen=[...candidates].sort((a,b)=>b.rows.length-a.rows.length)[0];\nconst out=chosen?.rows||[],parserMode=chosen?.mode||'SIN_PARSER';\nif(printedCount>0&&out.length!==printedCount)throw new Error(`Nómina Q2 PDF indica ${printedCount} empleados, pero ningún lector alcanzó ese total (tabular ${candidates[0].rows.length}, PDF.js ${candidates[1].rows.length}, vertical ${candidates[2].rows.length}). Se detuvo el cruce para no generar un reporte incompleto. Archivo: ${prep.fileName}`);\nif(!out.length)throw new Error(`No pude extraer empleados de Nómina Q2 PDF. El archivo no coincide con el formato de planilla esperado.`);\n\n// Control de cobertura monetaria e inferencia de tasas: no basta con contar nombres.\nconst grossPositive=out.filter(r=>Number(r.gross)>20).length;\nconst ssRatios=out.filter(r=>Number(r.gross)>20&&Number(r.ss)>0).map(r=>Number(r.ss)/Number(r.gross));\nconst seRatios=out.filter(r=>Number(r.gross)>20&&Number(r.se)>0).map(r=>Number(r.se)/Number(r.gross));\nconst minCoverage=Math.max(1,Math.floor(out.length*.70));\nif(grossPositive.05&&medSS<.15&&medSE>.004&&medSE<.03))throw new Error(`Nómina Q2 PDF superó el conteo, pero las tasas implícitas no son plausibles (S.S. ${((medSS||0)*100).toFixed(2)}%, S.E. ${((medSE||0)*100).toFixed(2)}%). Se detuvo para evitar un cruce incorrecto.`);\nreturn [{json:{fileName:prep.fileName,format:'PDF',parserMode,records:out,employeeCount:out.length,printedEmployeeCount:printedCount||null,missingCodeCount:out.filter(r=>!r.code).length,totalSS:round(out.reduce((s,r)=>s+r.ss,0)),totalSE:round(out.reduce((s,r)=>s+r.se,0)),period,stagingSpreadsheetId:'',integrityStatus:printedCount?'VALIDADO CONTRA TOTAL DEL PDF + COBERTURA MONETARIA':'VALIDADO POR COBERTURA MONETARIA'}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 62560, + 8752 + ], + "id": "fcfd90b4-61c2-443c-a610-064bb07db6f9", + "name": "Parsear Q2", + "notesInFlow": true, + "notes": "Parser multiformato para nómina Q2: soporta tabla normal, salida PDF.js invertida/concatenada y extracción vertical. Valida el total impreso de empleados y cobertura monetaria antes de continuar." + }, + { + "parameters": { + "jsCode": "const base=$items('Restaurar archivos del formulario')[0]; const bin=base.binary||{};\nfunction norm(s){return String(s||'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase();}\nconst entries=Object.entries(bin).map(([key,file])=>{const name=String(file.fileName||key);const s=norm(`${key} ${name} ${file.mimeType||''}`);let score=/\\.xlsx?$|spreadsheet|excel/.test(s)?100:0;if(/planilla\\s*css|detalleplanilla|caja.*seguro|css/.test(s))score+=300;if(/nomina|quincena|extraordin|vacacion|liquidacion|xiii|decimo/.test(s))score-=200;return {key,file,name,score};}).sort((a,b)=>b.score-a.score);\nconst chosen=entries[0];if(!chosen||chosen.score<250)throw new Error('No pude identificar la Planilla CSS oficial (DetallePlanilla .xls/.xlsx).');\nreturn [{json:{...base.json,fileName:chosen.name,originalBinaryKey:chosen.key},binary:{data:chosen.file}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 62800, + 8752 + ], + "id": "cdb55a95-4963-4030-89bf-d5a77fdf7ca5", + "name": "Preparar CSS" + }, + { + "parameters": { + "method": "POST", + "url": "https://sheets.googleapis.com/v4/spreadsheets", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ properties: { title: `TEMP PA CSS - ${$('Inicializar ejecución').item.json.runId}` } }) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 63040, + 8752 + ], + "id": "fcf5d9d6-6195-4a95-b65a-1935da3129ae", + "name": "Crear staging CSS", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const staging=$input.first().json||{};const prep=$items('Preparar CSS')[0];const spreadsheetId=staging.spreadsheetId||staging.id;if(!spreadsheetId)throw new Error('No se pudo crear el staging CSS.');return [{json:{...prep.json,spreadsheetId},binary:prep.binary}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 63280, + 8752 + ], + "id": "43baf006-6f42-403f-8019-4bc75416d67e", + "name": "Reunir staging CSS" + }, + { + "parameters": { + "operation": "update", + "fileId": { + "__rl": true, + "value": "={{ $json.spreadsheetId }}", + "mode": "id" + }, + "changeFileContent": true, + "options": {} + }, + "type": "n8n-nodes-base.googleDrive", + "typeVersion": 3, + "position": [ + 63520, + 8752 + ], + "id": "04278b55-df9e-4ab3-ba48-d0b90a361656", + "name": "Convertir CSS a Google Sheets", + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + } + }, + { + "parameters": { + "amount": 2 + }, + "type": "n8n-nodes-base.wait", + "typeVersion": 1.1, + "position": [ + 63760, + 8752 + ], + "id": "e7cd3a5c-abda-447f-a73f-1285dcf30d80", + "name": "Esperar conversión CSS", + "webhookId": "d77109d7-6bec-474d-8199-f46a83387c8c" + }, + { + "parameters": { + "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $('Crear staging CSS').item.json.spreadsheetId }}?fields=spreadsheetId,sheets(properties(title,sheetId,hidden))", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 64000, + 8752 + ], + "id": "c9a2ef3c-1bb4-403b-8e1e-226ab9bbf4a4", + "name": "Listar hojas CSS", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const spreadsheetId=$json.spreadsheetId||$items('Crear staging CSS')[0].json.spreadsheetId;const sheets=(Array.isArray($json.sheets)?$json.sheets:[]).filter(s=>!s?.properties?.hidden);if(!sheets.length)throw new Error('No encontré hojas legibles en la Planilla CSS.');const ranges=sheets.map(s=>`'${String(s.properties.title).replace(/'/g,\"''\")}'!A1:AZ50000`);return [{json:{spreadsheetId,rangeQuery:ranges.map(r=>'ranges='+encodeURIComponent(r)).join('&')}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 64240, + 8752 + ], + "id": "60ced88f-8da7-4386-a382-4eb43a6c70c5", + "name": "Preparar rangos CSS" + }, + { + "parameters": { + "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.spreadsheetId }}/values:batchGet?{{ $json.rangeQuery }}&majorDimension=ROWS&valueRenderOption=UNFORMATTED_VALUE&dateTimeRenderOption=FORMATTED_STRING", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 64480, + 8752 + ], + "id": "80b2d9c0-f7ed-455b-88a2-c247257dafd6", + "name": "Leer CSS", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const payload=$json.body&&typeof $json.body==='object'?$json.body:$json;\nconst valueRanges=payload.valueRanges||[],prep=$items('Preparar CSS')[0].json;\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction asc(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase();}\nfunction num(v){if(v===null||v===undefined||v==='')return 0;if(typeof v==='number')return v;let s=String(v).replace(/B\\/.?|\\$|,/g,'').replace(/\\s/g,'');if(/^\\(.*\\)$/.test(s))s='-'+s.slice(1,-1);const n=Number(s);return Number.isFinite(n)?n:0;}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction monthPeriod(text){const months={ENERO:1,FEBRERO:2,MARZO:3,ABRIL:4,MAYO:5,JUNIO:6,JULIO:7,AGOSTO:8,SEPTIEMBRE:9,OCTUBRE:10,NOVIEMBRE:11,DICIEMBRE:12};const m=asc(text).match(/(ENERO|FEBRERO|MARZO|ABRIL|MAYO|JUNIO|JULIO|AGOSTO|SEPTIEMBRE|OCTUBRE|NOVIEMBRE|DICIEMBRE)\\s+(20\\d{2})/);return m?{month:months[m[1]],year:Number(m[2]),label:`${m[1][0]+m[1].slice(1).toLowerCase()} ${m[2]}`}:null;}\nfunction isHeader(row){const h=(row||[]).map(asc),j=h.join('|');return j.includes('SEGURO SOCIAL')&&j.includes('APELLIDO')&&j.includes('NOMBRE')&&(/CEDULA|PASAPORTE|REGISTRO CIVIL|IDENTIFICACION|DOCUMENTO/.test(j));}\nif(!valueRanges.length)throw new Error('La Planilla CSS no contiene hojas legibles.');\nconst ranked=valueRanges.map(vr=>({vr,count:(vr.values||[]).reduce((s,r)=>s+(isHeader(r)?1:0),0),rows:(vr.values||[]).length})).sort((a,b)=>b.count-a.count||b.rows-a.rows);\nconst chosen=ranked[0],values=chosen?.vr?.values||[];if(!values.length||chosen.count<1)throw new Error('No pude localizar la estructura de empleados dentro de la Planilla CSS oficial.');\nlet period=null;for(let r=0;rv==='SEGURO SOCIAL'),apIdx=header.findIndex(v=>v==='APELLIDO'),nomIdx=header.findIndex(v=>v==='NOMBRE'),sexIdx=header.findIndex(v=>v==='SEXO');let idIdx=header.findIndex(v=>/CEDULA|PASAPORTE|REGISTRO CIVIL|IDENTIFICACION|DOCUMENTO/.test(v));if(idIdx<0)idIdx=ssIdx+1;\n let dr=r+1;while(drclean(v)))dr++;if(dr>=end)continue;const data=values[dr]||[],identity=clean(data[idIdx]),ssNumber=clean(data[ssIdx]),lastname=clean(data[apIdx]),firstname=clean(data[nomIdx]);if(!identity&&!lastname&&!firstname)continue;\n let salary=0,salaryAdjusted=0,planillaSS=0,planillaSE=0,planillaDTM=0,section='';const components={};\n for(let k=dr+1;k/DECIMO TERCER MES|XIII MES/.test(k));const dtmBase=round(dk?components[dk]:0);\n raw.push({ssNumber,identity,docType:clean((values[r]||[])[idIdx]),lastname,firstname,name:clean(`${lastname} ${firstname}`),sex:sexIdx>=0?clean(data[sexIdx]):'',salary,salaryAdjusted,dtmBase,planillaSS,planillaSE,planillaDTM,components});\n}\nif(!raw.length)throw new Error('No pude extraer empleados de la Planilla CSS.');\nfunction docKey(e){return asc(e.identity||e.ssNumber).replace(/[^A-Z0-9]/g,'');}\nconst grouped=new Map();\nfor(const e of raw){const k=docKey(e)||`N:${asc(e.name)}`;if(!grouped.has(k))grouped.set(k,{...e,components:{...e.components},blocks:1});else{const g=grouped.get(k);g.blocks++;g.salary=round(g.salary+e.salary);g.salaryAdjusted=round(g.salaryAdjusted+e.salaryAdjusted);g.dtmBase=round(g.dtmBase+e.dtmBase);g.planillaSS=round(g.planillaSS+e.planillaSS);g.planillaSE=round(g.planillaSE+e.planillaSE);g.planillaDTM=round(g.planillaDTM+e.planillaDTM);for(const [ck,cv] of Object.entries(e.components||{}))g.components[ck]=round((g.components[ck]||0)+Number(cv||0));}}\nconst employees=[...grouped.values()];\nconst reconstructed={seguroSocial:round(raw.reduce((s,e)=>s+e.planillaSS,0)),seguroEducativo:round(raw.reduce((s,e)=>s+e.planillaSE,0)),dtm:round(raw.reduce((s,e)=>s+e.planillaDTM,0)),salario:round(raw.reduce((s,e)=>s+e.salary,0)),salarioAjustado:round(raw.reduce((s,e)=>s+e.salaryAdjusted,0))};\nfor(const k of ['seguroSocial','seguroEducativo','salario','salarioAjustado']){const diff=round(reconstructed[k]-Number(totals[k]||0));if(Math.abs(diff)>.10)throw new Error(`Fallo de integridad CSS: ${k} oficial ${totals[k]} vs reconstruido ${reconstructed[k]} (dif. ${diff}).`);}\nreturn [{json:{fileName:prep.fileName,period,periodoLabel:period.label,employees,employeeCount:employees.length,rawBlockCount:raw.length,totals,reconstructed,stagingSpreadsheetId:$items('Crear staging CSS')[0].json.spreadsheetId}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 64720, + 8752 + ], + "id": "343c866c-0822-4eff-8805-9c0c7540bf85", + "name": "Parsear CSS" + }, + { + "parameters": { + "jsCode": "const base=$items('Restaurar archivos del formulario')[0];\nconst bin=base.binary||{},out=[];\n\nfunction norm(s){\n return String(s||'')\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g,'')\n .toLowerCase()\n .replace(/\\s+/g,' ')\n .trim();\n}\n\nfunction categoryFromFileName(name){\n const s=norm(name);\n // Prioridad por tipo REAL del archivo. No usar el nombre del campo para pisar\n // un nombre de archivo inequívoco (ej. un PDF de Liquidación dentro de Vacaciones).\n if(/xiii|decimo\\s*tercer|d[eé]cimo\\s*tercer/.test(s)) return 'xiii';\n if(/extraordin/.test(s)) return 'extraordinaria';\n if(/liquidacion|liquidaciones|resumen\\s+por\\s+tipo\\s+de\\s+pago/.test(s)) return 'liquidaciones';\n if(/vacacion/.test(s)) return 'vacaciones';\n return '';\n}\n\nfunction categoryFromField(key){\n const s=norm(key);\n if(/extraordin/.test(s)) return 'extraordinaria';\n if(/liquidacion/.test(s)) return 'liquidaciones';\n if(/vacacion/.test(s)) return 'vacaciones';\n if(/xiii|decimo\\s*tercer|d[eé]cimo\\s*tercer/.test(s)) return 'xiii';\n return '';\n}\n\nfunction isRegularOrCss(key,name,mime){\n const s=norm(`${key} ${name} ${mime||''}`);\n return /quincena|nomina.*q|n[oó]mina.*quincena|detalleplanilla|planilla\\s*css/.test(s);\n}\n\n// Dedupe únicamente cuando el MISMO nombre de archivo y el mismo tamaño fueron\n// adjuntados más de una vez (por ejemplo, el mismo PDF en Vacaciones y Liquidaciones).\n// Las copias con nombres distintos se dejan pasar y se deduplican después por\n// transacción/huella, como exigen los reportes históricos.\nconst seenExact=new Map();\n\nfor(const [key,file] of Object.entries(bin)){\n const name=String(file.fileName||key);\n const mime=String(file.mimeType||'');\n const s=norm(`${name} ${mime}`);\n\n if(!(s.includes('.pdf')||s.includes('application/pdf'))) continue;\n if(isRegularOrCss(key,name,mime)) continue;\n\n const fromName=categoryFromFileName(name);\n const fromField=categoryFromField(key);\n const category=fromName||fromField;\n if(!category) continue;\n\n const bytes=Number(file.bytes||0);\n // Solo pre-deduplicar si el tamaño binario es conocido. Si falta metadata de tamaño,\n // es más seguro dejar pasar ambos y que el parser deduzca por transacción/huella.\n const exactKey=bytes>0?`${norm(name)}|${bytes}`:'';\n\n if(exactKey&&seenExact.has(exactKey)){\n const idx=seenExact.get(exactKey);\n out[idx].json.duplicateBinaryKeys.push(key);\n continue;\n }\n\n const fieldMismatch=!!(fromName&&fromField&&fromName!==fromField);\n const item={\n json:{\n category,\n fileName:name,\n originalBinaryKey:key,\n classificationSource:fromName?'NOMBRE_ARCHIVO':'CAMPO_FORMULARIO',\n fieldCategory:fromField||'',\n fieldMismatch,\n duplicateBinaryKeys:[]\n },\n binary:{data:file}\n };\n\n if(exactKey) seenExact.set(exactKey,out.length);\n out.push(item);\n}\n\nconst vac=out.filter(i=>i.json.category==='vacaciones').length;\nconst liq=out.filter(i=>i.json.category==='liquidaciones').length;\n\nif(!vac) throw new Error('No encontré una planilla de Vacaciones real entre los PDFs adjuntados.');\nif(!liq) throw new Error('No encontré una planilla de Liquidación real entre los PDFs adjuntados.');\n\nreturn out;" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 64960, + 8752 + ], + "id": "4585e2ba-9493-4868-8273-b290b62d60c9", + "name": "Separar complementarios", + "notesInFlow": true, + "notes": "v8: clasifica primero por el tipo real indicado en el nombre del archivo y usa el campo del formulario solo como fallback. Evita que una Liquidación cargada accidentalmente en Vacaciones se procese como vacaciones. Dedupe exacto por nombre+tamaño entre campos; las copias con nombres distintos se deduplican después por transacción/huella." + }, + { + "parameters": { + "operation": "pdf", + "options": {} + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + 65200, + 8752 + ], + "id": "dcc638b7-b20e-4b5f-be39-17a87f1108ed", + "name": "Extraer texto complementarios" + }, + { + "parameters": { + "jsCode": "const extracted=$input.all();\nconst metaItems=$items('Separar complementarios');\nconst css=$items('Parsear CSS')[0].json;\nconst init=($items('Inicializar ejecución')[0]||{}).json||{};\nconst cfg=init.config||{};\nconst rates=cfg.defaultRates||{ssRegular:0.0975,se:0.0125,ssDtm:0.0725};\nconst targetMonth=Number(css.period.month),targetYear=Number(css.period.year);\n\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'').toUpperCase().replace(/[^A-Z0-9 ]/g,' ').replace(/\\s+/g,' ').trim();}\nfunction num(v){\n if(v===null||v===undefined||v==='')return 0;\n let s=String(v).replace(/B\\/\\.?|\\$/gi,'').replace(/,/g,'').trim();\n if(/^\\(.*\\)$/.test(s))s='-'+s.slice(1,-1);\n const n=Number(s);return Number.isFinite(n)?n:0;\n}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction parseDMY(s){const m=String(s||'').match(/(\\d{1,2})\\/(\\d{1,2})\\/(20\\d{2})/);if(!m)return null;return new Date(Number(m[3]),Number(m[2])-1,Number(m[1]));}\nfunction dateKey(d){return d?`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`:'';}\nfunction inTarget(d){return !!(d&&d.getMonth()+1===targetMonth&&d.getFullYear()===targetYear);}\nfunction overlapTarget(a,b){\n if(!a||!b)return null;\n const dates=[a,b].sort((x,y)=>x-y),start=dates[0],end=dates[1];\n const first=new Date(targetYear,targetMonth-1,1),last=new Date(targetYear,targetMonth,0,23,59,59);\n return start<=last&&end>=first;\n}\nfunction markers(text,re){\n const out=[];let m;const rg=new RegExp(re.source,re.flags.includes('g')?re.flags:re.flags+'g');\n while((m=rg.exec(text))!==null)out.push({index:m.index,end:rg.lastIndex,match:m});\n return out;\n}\nfunction lastText(text,re){let m,last='';const rg=new RegExp(re.source,re.flags.includes('g')?re.flags:re.flags+'g');while((m=rg.exec(String(text||'')))!==null)last=m[1]||'';return clean(last);}\n\n// Lee un monto solamente cuando la relación etiqueta/valor es local: misma línea o\n// línea numérica inmediatamente contigua. Evita \"pescar\" números de otro concepto.\nfunction amountField(segment,labelRe){\n const lines=String(segment||'').replace(/\\r/g,'').split('\\n');\n let foundLabel=false,foundValue=false,value=0;\n for(let i=0;i=0&&!clean(lines[p]))p--;\n if(p>=0&&/^\\s*[-(]?\\$?\\s*[\\d,]+(?:\\.\\d+)?\\)?\\s*$/.test(lines[p])){value=round(num(lines[p]));foundValue=true;continue;}\n let n=i+1;while(nls)continue;\n const gap=ne<=ls?ls-ne:ns>=le?ns-le:999;\n push(nm[0],gap,ne<=ls?'same-before':'same-after');\n }\n for(const dir of [-1,1]){\n let seen=0,p=i;\n while(seen=lines.length)break;\n if(!clean(lines[p]))continue;\n seen++;\n const vals=lines[p].match(moneyRe)||[];\n for(let k=0;ka.cost-b.cost).slice(0,8)};\n}\nfunction bestMoneyField(segment,labelRe){\n const f=moneyFieldCandidates(segment,labelRe);\n const c=f.candidates[0];\n return {found:!!c,labelFound:f.labelFound,value:c?c.value:0,candidates:f.candidates};\n}\nfunction candidateValues(field,optional=false,max=4){\n const a=(field?.candidates||[]).slice(0,max);\n if(optional&&!a.some(x=>Math.abs(x.value)<0.001))a.push({value:0,cost:30,where:'default-zero'});\n if(!a.length&&optional)a.push({value:0,cost:0,where:'default-zero'});\n return a;\n}\nfunction scalarFieldNear(segment,labelRe,valueRe,maxNeighborLines=2){\n const lines=String(segment||'').replace(/\\r/g,'').split('\\n');\n const out=[];let labelFound=false;\n function collect(line,costBase,labelStart=null,labelEnd=null){\n const rg=new RegExp(valueRe.source,valueRe.flags.replace(/g/g,'')+'g');let m;\n while((m=rg.exec(line))!==null){\n const s=m.index,e=rg.lastIndex;\n if(labelStart!==null&&slabelStart)continue;\n const gap=labelStart===null?0:(e<=labelStart?labelStart-e:s>=labelEnd?s-labelEnd:999);\n out.push({value:clean(m[1]??m[0]),cost:costBase+gap});\n }\n }\n for(let i=0;i=lines.length)break;\n if(!clean(lines[p]))continue;\n seen++;collect(lines[p],20*seen);\n }\n }\n }\n }\n const seen=new Set(),vals=[];\n for(const c of out.sort((a,b)=>a.cost-b.cost)){if(c.value&&!seen.has(c.value)){seen.add(c.value);vals.push(c);}}\n return {labelFound,candidates:vals.slice(0,8),found:vals.length>0,value:vals[0]?.value||''};\n}\nfunction dateFieldNear(segment,labelRe){\n return scalarFieldNear(segment,labelRe,/(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})/);\n}\nfunction integerFieldNear(segment,labelRe){\n // IDs/transacciones de planilla observados son de 5+ dígitos. No aceptar años\n // (2026, 2025, etc.) como si fueran números de transacción.\n const f=scalarFieldNear(segment,labelRe,/\\b(\\d{5,12})\\b/);\n const candidates=(f.candidates||[]).filter(c=>{\n const n=Number(c.value);\n return Number.isFinite(n)&&!(n>=1900&&n<=2100);\n });\n return {labelFound:f.labelFound,candidates,found:candidates.length>0,value:candidates[0]?.value||''};\n}\n\n// Metadatos de liquidación requieren un tratamiento distinto a campos monetarios.\n// PDF.js puede separar o invertir valor/etiqueta. Esta rutina liga exclusivamente\n// FECHA DE LIQUIDACIÓN y NÚMERO DE TRANSACCIÓN, penalizando fechas de cabecera\n// (impresión/fecha de planilla) y descartando años como posibles transacciones.\nfunction liquidationMeta(segment,hints={}){\n const raw=String(segment||'').replace(/\\r/g,'');\n const lines=raw.split('\\n');\n const dateLabel=/FECHA\\s+DE\\s+LIQUIDACI[OÓ]N\\s*:?/i;\n const txnLabel=/N[uú]mero\\s+de\\s+Transacci[oó]n\\s*:?/i;\n const dateRe=/(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})/g;\n const txnRe=/\\b(\\d{5,12})\\b/g;\n const dateCandidates=[],txnCandidates=[];\n // Registrar las fechas de cabecera incluso si PDF.js separa la fecha de la etiqueta.\n // La fecha de impresión recibe una penalización fuerte; FECHA DE PLANILLA una menor,\n // porque puede coincidir legítimamente con una fecha efectiva de liquidación.\n const printDates=new Set(Array.from(hints.printDates||[])),planillaDates=new Set(Array.from(hints.planillaDates||[]));\n function collectHeaderDates(){\n for(let i=0;i/FECHA\\s+DE\\s+PLANILLA|IMPRESO|P[AÁ]GINA|PLANILLA\\s+N[UÚ]MERO/i.test(line)?200:0;\n // Una fecha de impresión nunca debe ganar como FECHA DE LIQUIDACIÓN, aun cuando\n // aparezca justo después de la etiqueta por reordenamiento de PDF.js.\n const dateValuePenalty=value=>printDates.has(value)?500:planillaDates.has(value)?25:0;\n const txnBadLine=line=>/FECHA\\s+DE\\s+PLANILLA|IMPRESO|P[AÁ]GINA|PLANILLA\\s+N[UÚ]MERO/i.test(line);\n function pushDate(value,cost,where,line){\n const d=parseDMY(value);if(!d)return;\n dateCandidates.push({value,cost:cost+headerPenalty(line||'')+dateValuePenalty(value),where});\n }\n function pushTxn(value,cost,where,line){\n const n=Number(value);if(!Number.isFinite(n)||n<10000||n>999999999999||n>=1900&&n<=2100)return;\n if(txnBadLine(line||''))cost+=150;\n txnCandidates.push({value:String(value),cost,where});\n }\n // Patrones directos sobre texto aplanado: cubren valor a la derecha o a la izquierda.\n const flat=clean(raw);\n const D='(\\\\d{1,2}\\\\/\\\\d{1,2}\\\\/20\\\\d{2})',T='(\\\\d{5,12})';\n for(const [re,kind] of [\n [new RegExp(`FECHA\\\\s+DE\\\\s+LIQUIDACI[OÓ]N\\\\s*:?\\\\s*${D}`,'i'),'date-forward'],\n [new RegExp(`${D}\\\\s*FECHA\\\\s+DE\\\\s+LIQUIDACI[OÓ]N\\\\s*:?`,'i'),'date-reverse']\n ]){const m=flat.match(re);if(m)pushDate(m[1],-120,kind,m[0]);}\n for(const [re,kind] of [\n [new RegExp(`N[uú]mero\\\\s+de\\\\s+Transacci[oó]n\\\\s*:?\\\\s*${T}`,'i'),'txn-forward'],\n [new RegExp(`${T}\\\\s*N[uú]mero\\\\s+de\\\\s+Transacci[oó]n\\\\s*:?`,'i'),'txn-reverse']\n ]){const m=flat.match(re);if(m)pushTxn(m[1],-120,kind,m[0]);}\n\n function collectAround(labelRe,type){\n for(let i=0;i=lines.length)continue;const ln=lines[p];\n if(type==='date'){\n const rg=new RegExp(dateRe.source,'g');let m;while((m=rg.exec(ln))!==null)pushDate(m[1],delta*25+(p===i?Math.abs(m.index-(line.search(labelRe)+10)):0),`line-${delta}`,ln);\n }else{\n const rg=new RegExp(txnRe.source,'g');let m;while((m=rg.exec(ln))!==null)pushTxn(m[1],delta*25+(p===i?Math.abs(m.index-(line.search(labelRe)+10)):0),`line-${delta}`,ln);\n }\n }\n }\n }\n }\n collectAround(dateLabel,'date');collectAround(txnLabel,'txn');\n function uniqueBest(arr){\n const m=new Map();for(const c of arr){if(!m.has(c.value)||c.costa.cost-b.cost);\n }\n const dates=uniqueBest(dateCandidates),txns=uniqueBest(txnCandidates);\n return {\n dateFound:dates.length>0,txnFound:txns.length>0,\n date:dates[0]?.value||'',txn:txns[0]?.value||'',\n dateCandidates:dates.slice(0,6),txnCandidates:txns.slice(0,6)\n };\n}\n\nfunction liquidationHeaderHints(text){\n const raw=String(text||'').replace(/\\r/g,''),lines=raw.split('\\n');\n const printDates=new Set(),planillaDates=new Set();\n // Fuente autoritativa de fecha de impresión: una fecha acompañada por hora.\n // No se toman fechas “cerca de Página”, porque en PDF.js esa vecindad puede incluir\n // la FECHA DE PLANILLA o la FECHA DE LIQUIDACIÓN de un empleado.\n const stampRe=/(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})\\s+\\d{1,2}:\\d{2}(?::\\d{2})?\\s*(?:AM|PM)?/gi;\n let m;while((m=stampRe.exec(raw))!==null)printDates.add(m[1]);\n for(let i=0;i0?round(se.value/rateSE):0;\n const expectedSS=round(regularBase*rateSS+dtmBase*rateDTM),expectedSE=round(regularBase*rateSE);\n const dSS=Math.abs(ss.value-expectedSS),dSE=Math.abs(se.value-expectedSE);\n const mathOk=dSS<=0.20&&dSE<=0.10;\n const score=dSS*100+dSE*140+(ss.cost+se.cost+dtm.cost+mej.cost)*.003+0.05;\n if(mathOk&&(!best||!best.mathOk||score0,labelFound:ssF.labelFound,value:ssVal,candidates:ssF.candidates},\n seF:{found:lists.se.length>0,labelFound:seF.labelFound,value:seVal,candidates:seF.candidates},\n regularF:{found:regularF.candidates.length>0,labelFound:regularF.labelFound,value:best?.reg?.value||0},\n commissionF:{found:commissionF.candidates.length>0,labelFound:commissionF.labelFound,value:best?.com?.value||0},\n salSobF:{found:salSobF.candidates.length>0,labelFound:salSobF.labelFound,value:best?.sob?.value||0},\n bpF:{found:bpF.candidates.length>0,labelFound:bpF.labelFound,value:best?.bp?.value||0},\n dtmF:{found:dtmF.candidates.length>0,labelFound:dtmF.labelFound,value:best?.dtm?.value||0},\n mejoraF:{found:mejoraF.candidates.length>0,labelFound:mejoraF.labelFound,value:best?.mej?.value||0},\n salBrutoF,otherTotalF,\n regularBase:best?.regularBase||0,dtmBase:best?.dtmBase||0,\n expectedSS:best?.expectedSS||0,expectedSE:best?.expectedSE||0,\n baseMethod:best?.baseMethod||'NO_VALIDADA',\n grossF:{found:true,labelFound:true,value:round((best?.regularBase||0)+(best?.dtmBase||0))},\n score:complete?200-(best?.score||0):-(best?.score||9999)\n };\n}\nfunction parseVacationCandidate(segment){\n const ssRaw=moneyFieldCandidates(segment,/Seg\\.\\s*Soc\\.?/i,3);\n const seRaw=moneyFieldCandidates(segment,/Seg\\.\\s*Edu\\.?/i,3);\n const grossRaw=moneyFieldCandidates(segment,/TOTAL\\s+BRUTO\\s*:?/i,3);\n const vacRaw=moneyFieldCandidates(segment,/\\bVacaciones\\b/i,3);\n const grossCandidates=[...grossRaw.candidates];\n for(const c of vacRaw.candidates)if(!grossCandidates.some(x=>Math.abs(x.value-c.value)<.001))grossCandidates.push({...c,cost:c.cost+5,where:'vacaciones-fallback'});\n grossCandidates.sort((a,b)=>a.cost-b.cost);\n\n const ssList=ssRaw.candidates.slice(0,8),seList=seRaw.candidates.slice(0,8),rawGList=grossCandidates.slice(0,12),positiveG=rawGList.filter(x=>x.value>0.001),gList=positiveG.length?positiveG:rawGList;\n const rateSS=Number(rates.ssRegular||0.0975),rateSE=Number(rates.se||0.0125);\n let best=null;\n // Ruta preferida: importe de vacaciones / Total Bruto explícito + S.S. + S.E.\n for(const g of gList)for(const ss of ssList)for(const se of seList){\n if(g.value<0)continue;\n const expectedSS=round(g.value*rateSS),expectedSE=round(g.value*rateSE);\n const dSS=Math.abs(ss.value-expectedSS),dSE=Math.abs(se.value-expectedSE);\n const mathOk=dSS<=0.20&&dSE<=0.10;\n const score=dSS*100+dSE*140+(g.cost+ss.cost+se.cost)*.003;\n if(!best||score0&&rateSE>0){\n for(const ss of ssList)for(const se of seList){\n if(ss.value<0||se.value<0)continue;\n const derived=[];\n if(ss.value>0)derived.push(ss.value/rateSS);\n if(se.value>0)derived.push(se.value/rateSE);\n if(ss.value===0&&se.value===0)derived.push(0);\n if(!derived.length)continue;\n // Probar ambas bases implícitas y su promedio. Luego preferir un monto etiquetado\n // cercano si existe, para conservar el centavo exacto impreso en el PDF.\n if(derived.length===2)derived.push((derived[0]+derived[1])/2);\n for(const dg of derived){\n if(!Number.isFinite(dg)||dg<0||dg>1000000)continue;\n const nearby=gList.filter(x=>Math.abs(x.value-dg)<=0.75).sort((a,b)=>Math.abs(a.value-dg)-Math.abs(b.value-dg)||a.cost-b.cost)[0];\n const g=nearby||{value:round(dg),cost:35,where:'derived-ss-se'};\n const expectedSS=round(g.value*rateSS),expectedSE=round(g.value*rateSE);\n const dSS=Math.abs(ss.value-expectedSS),dSE=Math.abs(se.value-expectedSE);\n const mathOk=dSS<=0.20&&dSE<=0.10;\n if(!mathOk)continue;\n // La base derivada desde S.S. y la derivada desde S.E. deben ser compatibles.\n // El margen de 1.00 cubre exclusivamente el redondeo a centavos de los aportes.\n const baseSS=ss.value>0?ss.value/rateSS:g.value,baseSE=se.value>0?se.value/rateSE:g.value;\n if(ss.value>0&&se.value>0&&Math.abs(baseSS-baseSE)>1.00)continue;\n const score=dSS*100+dSE*140+(ss.cost+se.cost+g.cost)*.003+0.08;\n if(!best||!best.mathOk||score0,labelFound:ssRaw.labelFound,value:best?.ss?.value||0,candidates:ssRaw.candidates},\n seF:{found:seList.length>0,labelFound:seRaw.labelFound,value:best?.se?.value||0,candidates:seRaw.candidates},\n grossF:{found:!!best?.g,labelFound:grossRaw.labelFound||vacRaw.labelFound,value:best?.g?.value||0,candidates:grossCandidates,derived:best?.baseMethod==='BASE_INFERIDA_DESDE_SS_SE'},\n expectedSS:best?.expectedSS||0,expectedSE:best?.expectedSE||0,\n baseMethod:best?.baseMethod||'NO_VALIDADA',\n score:best?(best.mathOk?180-best.score:-best.score):-9999\n };\n}\nfunction parseLiquidationCandidate(segment,hints={}){\n const meta=liquidationMeta(segment,hints);\n const dateF={found:meta.dateFound,labelFound:meta.dateFound,value:meta.date,candidates:meta.dateCandidates};\n const txnF={found:meta.txnFound,labelFound:meta.txnFound,value:meta.txn,candidates:meta.txnCandidates};\n const ssRaw=moneyFieldCandidates(segment,/Seg\\.\\s*Soc\\.?/i),seRaw=moneyFieldCandidates(segment,/Seg\\.\\s*Edu\\.?/i);\n const svRaw=moneyFieldCandidates(segment,/Salario,?\\s*Vac,?\\s*13\\s*Mes\\s*:?/i);\n const dtmRaw=moneyFieldCandidates(segment,/(?0?round(se.value/rateSE):0;\n const expectedSS=round(regularBase*rateSS+dtmBase*rateDTM),expectedSE=round(regularBase*rateSE);\n const dSS=Math.abs(ss.value-expectedSS),dSE=Math.abs(se.value-expectedSE);\n const mathOk=dSS<=0.20&&dSE<=0.10;\n const score=dSS*100+dSE*140+(ss.cost+se.cost+dtm.cost+mej.cost)*.003+0.05;\n if(mathOk&&(!best||!best.mathOk||score0?se.value/rateSE:0;\n if(!Number.isFinite(regularBaseRaw)||regularBaseRaw<-.5||regularBaseRaw>1000000)continue;\n const regularBase=round(Math.max(0,regularBaseRaw));\n const dtmRawAlg=rateDTM>0?(ss.value-regularBase*rateSS)/rateDTM:0;\n if(!Number.isFinite(dtmRawAlg)||dtmRawAlg<-.75||dtmRawAlg>1000000)continue;\n const dtmBase=round(Math.max(0,dtmRawAlg));\n const expectedSS=round(regularBase*rateSS+dtmBase*rateDTM),expectedSE=round(regularBase*rateSE);\n const dSS=Math.abs(ss.value-expectedSS),dSE=Math.abs(se.value-expectedSE);\n const mathOk=dSS<=0.20&&dSE<=0.10;\n if(!mathOk)continue;\n\n // Preferir una solución compatible con montos etiquetados si existen.\n let evidencePenalty=0;\n const labeledDtm=[...(dtmRaw.candidates||[]),...(mejoraRaw.candidates||[])].map(x=>Number(x.value)).filter(v=>Number.isFinite(v)&&v>=0);\n if(labeledDtm.length)evidencePenalty+=Math.min(...labeledDtm.map(v=>Math.abs(v-dtmBase)))*0.02;\n const totalBase=round(regularBase+dtmBase);\n const labeledSV=(svRaw.candidates||[]).map(x=>Number(x.value)).filter(v=>Number.isFinite(v)&&v>=0);\n if(labeledSV.length)evidencePenalty+=Math.min(...labeledSV.map(v=>Math.abs(v-totalBase)))*0.01;\n\n const score=dSS*100+dSE*140+(ss.cost+se.cost)*.003+0.15+evidencePenalty;\n if(!best||!best.mathOk||scoreMath.abs(a-totalBase)-Math.abs(b-totalBase))[0],cost:0}:{value:totalBase,cost:0},\n dtm:{value:dtmBase,cost:0},mej:{value:0,cost:0},com:{value:0,cost:0},\n regularBase,dtmBase,expectedSS,expectedSE,\n baseMethod:'BASES_INFERIDAS_DESDE_APORTES_SS_SE'\n };\n }\n }\n }\n const date=dateF.value||'',txn=txnF.value||'';\n // Para el cruce, la transacción sirve para deduplicar pero NO es necesaria para\n // reconstruir la base cotizable. Si falta, se usa una huella persona+fecha+montos.\n // Tampoco exigimos Total Bruto/Salario-Vac-13 si las bases quedaron validadas\n // matemáticamente desde S.S./S.E.\n const completeCore=!!date&&ssRaw.labelFound&&seRaw.labelFound;\n return {\n complete:completeCore&&!!best?.mathOk,structuralComplete:completeCore&&!!best?.mathOk,mathOk:!!best?.mathOk,\n mathDiff:best?Math.abs(best.ss.value-best.expectedSS)+Math.abs(best.se.value-best.expectedSE):9999,\n date,txn,dateF,txnF,meta,\n ssF:{found:ssList.length>0,labelFound:ssRaw.labelFound,value:best?.ss?.value||0,candidates:ssRaw.candidates},\n seF:{found:seList.length>0,labelFound:seRaw.labelFound,value:best?.se?.value||0,candidates:seRaw.candidates},\n sv13F:{found:svList.length>0,labelFound:svRaw.labelFound,value:best?.sv?.value||svList[0]?.value||0,candidates:svRaw.candidates},\n dtmF:{found:dtmRaw.candidates.length>0,labelFound:dtmRaw.labelFound,value:best?.dtm?.value||0,candidates:dtmRaw.candidates},\n mejoraF:{found:mejoraRaw.candidates.length>0,labelFound:mejoraRaw.labelFound,value:best?.mej?.value||0,candidates:mejoraRaw.candidates},\n commissionF:{found:comRaw.candidates.length>0,labelFound:comRaw.labelFound,value:best?.com?.value||0,candidates:comRaw.candidates},\n grossF:{...grossF,value:grossF.found?grossF.value:round((best?.regularBase||0)+(best?.dtmBase||0)),derived:!grossF.found},salaryRegF,vacF,\n regularBase:best?.regularBase||0,dtmBase:best?.dtmBase||0,\n expectedSS:best?.expectedSS||0,expectedSE:best?.expectedSE||0,\n baseMethod:best?.baseMethod||'NO_VALIDADA',\n score:completeCore?(best?.mathOk?200-(best?.score||0):-(best?.score||9999)):-9999\n };\n}\n\n// Determina la orientación del archivo usando TODOS sus empleados. En PDF.js los\n// importes pueden salir antes de \"Empleado\"; en orden visual salen después. Elegir\n// por archivo impide desplazar un empleado con los importes del siguiente.\nfunction chooseOrientation(text,ms,parser){\n const stats={before:{valid:0,math:0,score:0},after:{valid:0,math:0,score:0}};\n for(let j=0;jstats.after.valid?'before':'after',stats};\n if(stats.before.math!==stats.after.math)return {orientation:stats.before.math>stats.after.math?'before':'after',stats};\n if(Math.abs(stats.before.score-stats.after.score)>0.01)return {orientation:stats.before.score>stats.after.score?'before':'after',stats};\n const prefix=text.slice(0,ms[0].index);\n return {orientation:/TOTAL\\s+NETO|Sal\\.\\s*Neto/i.test(prefix)?'before':'after',stats};\n}\n\n// Periodo Libre robusto: primero exige la relación explícita etiqueta/fechas. Si PDF.js\n// rompe el \"al\" o coloca cada fecha en una línea distinta, usa únicamente las dos fechas\n// más cercanas a la etiqueta Periodo Libre dentro de una ventana corta. Nunca toma fechas\n// de Vacaciones Correspondientes si ya existe una pareja más próxima al rótulo.\nfunction makePeriodLibre(d1,d2){\n const a=parseDMY(d1),b=parseDMY(d2);if(!a||!b)return null;\n const arr=[a,b].sort((x,y)=>x-y),fmt=d=>`${String(d.getDate()).padStart(2,'0')}/${String(d.getMonth()+1).padStart(2,'0')}/${d.getFullYear()}`;\n return {start:dateKey(arr[0]),end:dateKey(arr[1]),display:`${fmt(arr[0])} - ${fmt(arr[1])}`,startDate:arr[0],endDate:arr[1]};\n}\nfunction periodLibreStrict(segment,prefer='forward'){\n const flat=clean(segment),D='(\\\\d{1,2}\\\\/\\\\d{1,2}\\\\/20\\\\d{2})';\n const forward=new RegExp(`Periodo\\\\s+Libre\\\\s*:?\\\\s*${D}\\\\s*(?:al|-)\\\\s*${D}`,'i');\n const reverse=new RegExp(`${D}\\\\s*(?:al|-)\\\\s*${D}\\\\s*Periodo\\\\s+Libre\\\\s*:?`,'i');\n let m=prefer==='reverse'?flat.match(reverse):flat.match(forward);\n if(!m)m=prefer==='reverse'?flat.match(forward):flat.match(reverse);\n return m?makePeriodLibre(m[1],m[2]):null;\n}\nfunction periodLibreRobust(segment,prefer='forward'){\n const flat=clean(segment),DS='(\\\\d{1,2}\\\\/\\\\d{1,2}\\\\/20\\\\d{2})';\n const forward=new RegExp(`Periodo\\\\s+Libre\\\\s*:?\\\\s*${DS}\\\\s*(?:al|-)\\\\s*${DS}`,'i');\n const reverse=new RegExp(`${DS}\\\\s*(?:al|-)\\\\s*${DS}\\\\s*Periodo\\\\s+Libre\\\\s*:?`,'i');\n const prefMatch=flat.match(prefer==='reverse'?reverse:forward);\n if(prefMatch)return makePeriodLibre(prefMatch[1],prefMatch[2]);\n\n const options=[],lr=/Periodo\\s+Libre\\s*:?/ig,D=/\\d{1,2}\\/\\d{1,2}\\/20\\d{2}/g;let lm;\n // El patrón del lado opuesto se conserva como último recurso, pero no puede ganar a\n // una pareja de fechas inmediatamente adyacente al lado que corresponde al extractor.\n const opposite=flat.match(prefer==='reverse'?forward:reverse);\n if(opposite)options.push({p:makePeriodLibre(opposite[1],opposite[2]),score:40});\n while((lm=lr.exec(flat))!==null){\n const ls=lm.index,le=lr.lastIndex;\n const before=flat.slice(Math.max(0,ls-150),ls),after=flat.slice(le,Math.min(flat.length,le+180));\n function dates(s){const out=[];let m;const rg=new RegExp(D.source,'g');while((m=rg.exec(s))!==null)out.push({value:m[0],index:m.index});return out;}\n let bd=dates(before),ad=dates(after);\n const corr=after.search(/Vacaciones\\s+Correspondientes/i);if(corr>=0)ad=ad.filter(x=>x.index=2){const a=ad[0],b=ad[1];options.push({p:makePeriodLibre(a.value,b.value),score:a.index+b.index+2+(prefer==='forward'?-100:20)});}\n if(bd.length>=2){const a=bd[bd.length-2],b=bd[bd.length-1];const dist=(before.length-b.index)+(before.length-a.index);options.push({p:makePeriodLibre(a.value,b.value),score:dist+3+(prefer==='reverse'?-100:20)});}\n }\n const valid=options.filter(x=>x.p).sort((a,b)=>a.score-b.score);return valid[0]?.p||null;\n}\nfunction vacationPageBlock(text,marker){\n const raw=String(text||''),heads=[];let m;const hr=/GOMEZLEE\\s+MARKETING/ig;while((m=hr.exec(raw))!==null)heads.push(m.index);\n let start=0,end=raw.length;\n for(const h of heads){if(h<=marker.index)start=h;else{end=h;break;}}\n const block=raw.slice(start,end);\n const employeeCount=(block.match(/Empleado\\s*#\\s*[A-Za-z0-9]{1,12}\\s*:/ig)||[]).length;\n return {text:block,employeeCount,start,end};\n}\nfunction samePerson(a,b){\n const ac=clean(a.code).replace(/^0+(?=\\d)/,''),bc=clean(b.code).replace(/^0+(?=\\d)/,'');\n if(ac&&bc&&ac===bc)return true;\n return !!(norm(a.name)&&norm(a.name)===norm(b.name));\n}\nfunction recordFingerprint(r){return [r.category,clean(r.code),norm(r.name),clean(r.date),clean(r.transaction),round(r.ss),round(r.se),round(r.gross),round(r.regularBase),round(r.dtmBase)].join('|');}\n\n// Segunda barrera: confirma el tipo REAL usando el encabezado/contenido extraído.\n// Es deliberadamente conservadora: solo reclasifica cuando el texto es inequívoco.\n// El orden importa porque Liquidaciones/Extraordinarias pueden contener \"Vacaciones\"\n// o \"XIII Mes\" como conceptos sin ser esos tipos de documento.\nfunction categoryFromContent(text){\n const u=norm(String(text||'').slice(0,12000));\n if(/PLANILLA DE LIQUIDACION/.test(u)) return 'liquidaciones';\n if(/PLANILLA DE VACACIONES/.test(u)) return 'vacaciones';\n if(/PLANILLA EXTRAORDINARIA/.test(u)) return 'extraordinaria';\n if(/PLANILLA(?: DE)? (?:XIII|DECIMO TERCER|DECIMO TERCER MES)/.test(u) ||\n /DECIMO TERCER MES.*PRIMERA PARTIDA/.test(u)) return 'xiii';\n if(/RESUMEN POR TIPO DE PAGO/.test(u)) return 'liquidaciones';\n return '';\n}\n\nconst records=[],validations=[],supportDocs=[],files=[];\nfunction addError(category,fileName,detail){validations.push({status:'ERROR',severity:'ERROR',category,fileName,detail});}\nfunction addWarning(category,fileName,detail){validations.push({status:'REVISAR',severity:'WARNING',category,fileName,detail});}\n\n// Índice independiente de personas que SÍ aparecen en una Planilla de Liquidación resumida.\n// Se construye directamente desde el texto de TODOS los PDFs y no depende de que el registro\n// ya haya sido incluido/deduplicado. Esto evita falsos errores cuando también se adjunta el\n// documento de cálculo de liquidación (que es soporte y nunca debe sumarse dos veces).\nconst rawSummaryPeople=[];\nfor(let ri=0;ri({code:clean(x.match[1]).replace(/^0+(?=\\d)/,''),name:clean(x.match[2]).replace(/\\s+(?:A[nñ]o|C[eé]dula:).*$/i,'')}));\n const seen=new Set(),support=[];for(const e of rawSupport){const k=`${e.code}|${norm(e.name)}`;if(!seen.has(k)){seen.add(k);support.push(e);}}\n supportDocs.push({fileName,employees:support});\n validations.push({status:'IGNORADO',severity:'INFO',category,fileName,detail:'Documento de cálculo/soporte detectado. No se suma para evitar duplicidad; la existencia de la planilla resumida se verifica de forma independiente.'});\n continue;\n }\n const ms=markers(text,/Empleado\\s*#\\s*([A-Za-z0-9]{1,12})\\s*:\\s*([^\\n\\r]+?)(?=\\s+(?:FECHA\\s+DE\\s+LIQUIDACI[OÓ]N|Clave\\s*ISR|N[uú]mero\\s+de\\s+Transacci[oó]n)|$)/i);\n if(!ms.length){addError(category,fileName,'El PDF parece una Planilla de Liquidación, pero no se pudieron detectar empleados.');continue;}\n const liqHints=liquidationHeaderHints(text),liqParser=seg=>parseLiquidationCandidate(seg,liqHints);\n const orient=chooseOrientation(text,ms,liqParser),orientation=orient.orientation;\n let parsed=0,includedCount=0,excludedCount=0;\n for(let j=0;j \"158 0.00 0.00\").\n // No dependemos de posiciones fijas: buscamos la pareja Base XIII / S.S. que\n // satisfaga matemáticamente la tasa 7.25%, y luego validamos contra el total general.\n if(!rows.length){\n const ls=text.replace(/\\r/g,'').split('\\n').map(clean);\n const namePos=[];\n for(let i=0;i((String(s||'').match(/\\(?-?(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{2}\\)?/g)||[]).map(num).filter(v=>v!==null));\n const chooseDtmPair=vals=>{\n if(!vals.length)return null;\n const allZero=vals.every(v=>Math.abs(Number(v)||0)<0.005);\n if(allZero)return {dtmBase:0,ss:0,score:0};\n let best=null;\n for(let bi=0;bi0)||base>1000000)continue;\n const expected=round(base*Number(rates.ssDtm||0.0725));\n const dup=vals.reduce((s,v)=>s+(Math.abs(Number(v)-base)<0.005?1:0),0);\n for(let si=0;sibase)continue;\n const diff=Math.abs(ss-expected);if(diff>0.12)continue;\n // La base XIII suele aparecer dos veces (XIII y Total), y S.S. antes de ella.\n const score=diff+(dup>=2?0:0.03)+(si=0?prev+2:0;\n // Si comienza un nuevo departamento, descarta los totales del departamento anterior.\n for(let j=segStart;js+r.dtmBase,0)),sumSS=round(rows.reduce((s,r)=>s+r.ss,0));\n const tgIndex=text.toUpperCase().lastIndexOf('TOTALES GENERALES');\n if(tgIndex>=0){\n const tgWindow=text.slice(Math.max(0,tgIndex-600),Math.min(text.length,tgIndex+600));\n const tv=(tgWindow.match(/\\(?-?(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{2}\\)?/g)||[]).map(num).filter(v=>v!==null);\n // La suma de S.S. por empleado puede diferir algunos centavos de aplicar 7.25%\n // al total por el redondeo individual. Por eso no inferimos el total por tasa:\n // comprobamos que las sumas reconstruidas aparezcan literalmente en la ventana\n // de TOTALES GENERALES, sin depender del orden de PDF.js.\n const hasD=tv.some(v=>Math.abs(Number(v)-sumD)<=0.20);\n const hasSS=tv.some(v=>Math.abs(Number(v)-sumSS)<=0.20);\n if(tv.length>=3&&(!hasD||!hasSS)){\n addError(category,fileName,`Integridad XIII Mes fallida: las sumas reconstruidas DTM/S.S. ${sumD}/${sumSS} no coinciden con TOTALES GENERALES del PDF.`);continue;\n }\n }\n records.push(...rows);\n validations.push({status:dateOk===false?'EXCLUIDO':'OK',severity:dateOk===false?'WARNING':'INFO',category,fileName,detail:`${rows.length} registro(s) XIII Mes validados por empleado con tasa DTM; DTM ${sumD}, S.S. ${sumSS}${printed?`; fecha ${printed}`:''}.`});\n\n } else {\n addError(category||'desconocida',fileName,'Categoría de PDF complementario no reconocida.');\n }\n}\n\n// Extraordinarias: una transacción solo puede representar un movimiento. Una copia\n// idéntica se excluye; una transacción repetida con persona o montos distintos detiene\n// el proceso porque sería inseguro elegir automáticamente.\nconst seenExtra=new Map();\nfor(const r of records){\n if(r.category!=='Extraordinaria'||!r.included)continue;\n const k=r.transaction?`T:${r.transaction}`:`F:${r.code||norm(r.name)}|${r.date}|${r.ss}|${r.se}|${r.gross}`;\n if(!seenExtra.has(k)){seenExtra.set(k,r);continue;}\n const prev=seenExtra.get(k);\n if(recordFingerprint(prev)===recordFingerprint(r)){r.included=false;r.reason=`Duplicado exacto de extraordinaria; ya incluido desde ${prev.fileName}`;}\n else addError('extraordinaria',r.fileName,`La transacción extraordinaria ${r.transaction||k} aparece con datos diferentes (${prev.name} / ${r.name}). Se detuvo para no deduplicar incorrectamente.`);\n}\n\n// Vacaciones: elimina copias exactas y, para el mismo empleado + mismo Periodo Libre,\n// conserva la planilla más reciente. Dos versiones contradictorias con el mismo número\n// de planilla se consideran error.\nconst seenVacExact=new Map();\nfor(const r of records){\n if(r.category!=='Vacaciones'||!r.included)continue;\n const person=r.code||norm(r.name),k=`${person}|${r.periodStart}|${r.periodEnd}|${r.ss}|${r.se}|${r.gross}`;\n if(seenVacExact.has(k)){r.included=false;r.reason=`Duplicado exacto de vacaciones; ya incluido desde ${seenVacExact.get(k).fileName}`;}\n else seenVacExact.set(k,r);\n}\nconst vacGroups=new Map();\nfor(const r of records){\n if(r.category!=='Vacaciones'||!r.included)continue;\n const k=`${r.code||norm(r.name)}|${r.periodStart}|${r.periodEnd}`;if(!vacGroups.has(k))vacGroups.set(k,[]);vacGroups.get(k).push(r);\n}\nfor(const arr of vacGroups.values()){\n if(arr.length<=1)continue;\n const byPlan=new Map();for(const r of arr){const p=Number(r.planillaNumber)||0;if(!byPlan.has(p))byPlan.set(p,[]);byPlan.get(p).push(r);}\n for(const [p,grp] of byPlan){if(p&&grp.length>1){const sig=new Set(grp.map(recordFingerprint));if(sig.size>1)addError('vacaciones',grp[0].fileName,`La planilla de vacaciones ${p} contiene versiones contradictorias para ${grp[0].name}.`);}}\n arr.sort((a,b)=>(Number(b.planillaNumber)||0)-(Number(a.planillaNumber)||0)||String(b.generatedAt).localeCompare(String(a.generatedAt)));\n for(let i=1;i=10000&&!(txnNum>=1900&&txnNum<=2100);\n if(validTxn){\n const k=`T:${r.transaction}`;\n if(!seenTxn.has(k)){seenTxn.set(k,r);continue;}\n const prev=seenTxn.get(k);\n if(recordFingerprint(prev)===recordFingerprint(r)){r.included=false;r.reason=`Duplicado exacto de liquidación; la transacción ${r.transaction} ya fue incluida desde ${prev.fileName}`;}\n else addError('liquidaciones',r.fileName,`La transacción de liquidación ${r.transaction} aparece con datos diferentes (${prev.name} / ${r.name}). Se detuvo para no descartar un movimiento válido por error.`);\n continue;\n }\n // Si el extractor no pudo recuperar una transacción confiable, deduplicar por huella\n // de empleado + fecha + aportes + bases. Nunca usar un año como ID.\n const k=`F:${r.code||norm(r.name)}|${r.date}|${r.ss}|${r.se}|${r.gross}|${r.regularBase}|${r.dtmBase}`;\n if(seenLiqFallback.has(k)){r.included=false;r.reason=`Duplicado exacto de liquidación sin transacción; ya incluido desde ${seenLiqFallback.get(k).fileName}`;}\n else seenLiqFallback.set(k,r);\n}\n\n// XIII Mes: copias exactas por persona/fecha/base/S.S.\nconst seenXiii=new Map();\nfor(const r of records){\n if(r.category!=='XIII Mes'||!r.included)continue;\n const k=`${r.code||norm(r.name)}|${r.date}|${r.dtmBase}|${r.ss}`;\n if(seenXiii.has(k)){r.included=false;r.reason=`Duplicado exacto XIII Mes; ya incluido desde ${seenXiii.get(k).fileName}`;}\n else seenXiii.set(k,r);\n}\n\n// Los documentos de cálculo son SOPORTE, no fuente de montos para el cruce.\n// Comprobamos su cobertura contra (1) todos los registros resumidos parseados y (2) el índice\n// crudo de empleados detectados directamente en cualquier Planilla de Liquidación.\n// Una ausencia se reporta como REVISAR en el informe, pero NO tumba la ejecución: de esta forma\n// el usuario obtiene el reporte y puede ver exactamente qué documento requiere revisión.\nconst allSummaryLiq=records.filter(r=>r.category==='Liquidación');\nfor(const doc of supportDocs){\n const missing=[];\n for(const e of doc.employees||[]){\n const foundParsed=allSummaryLiq.some(r=>samePerson(e,r));\n const foundRaw=rawSummaryPeople.some(r=>samePerson(e,r));\n if(!foundParsed&&!foundRaw)missing.push(`${e.name}${e.code?` (#${e.code})`:''}`);\n }\n if(missing.length){\n addWarning('liquidaciones',doc.fileName,`Documento de cálculo detectado sin una planilla resumida localizada para: ${missing.join(', ')}. El documento de cálculo no se sumó; revisar que la planilla resumida haya sido adjuntada.`);\n }else{\n validations.push({status:'OK',severity:'INFO',category:'liquidaciones',fileName:doc.fileName,detail:'Documento de cálculo vinculado a su planilla resumida. Se usa solo como soporte y no se suma, evitando duplicidad.'});\n }\n}\n\nconst includedRecords=records.filter(r=>r.included),catCount=cat=>includedRecords.filter(r=>r.category===cat).length;\nconst reviewValidations=validations.filter(v=>v.status==='REVISAR'||v.status==='EXCLUIDO');\nconst RESULT={\n records,validations,supportDocs,includedRecords,files:[...new Set(files)],\n quality:{status:reviewValidations.length?'REVISAR':'VALIDADO',reviewCount:reviewValidations.length,rawLiquidationSummaryPeople:rawSummaryPeople.length},\n counts:{extraordinarias:catCount('Extraordinaria'),vacaciones:catCount('Vacaciones'),liquidaciones:catCount('Liquidación'),xiii:catCount('XIII Mes')},\n totals:{\n ss:round(includedRecords.reduce((s,r)=>s+r.ss,0)),se:round(includedRecords.reduce((s,r)=>s+r.se,0)),\n extraSS:round(includedRecords.filter(r=>r.category==='Extraordinaria').reduce((s,r)=>s+r.ss,0)),\n vacSS:round(includedRecords.filter(r=>r.category==='Vacaciones').reduce((s,r)=>s+r.ss,0)),\n liqSS:round(includedRecords.filter(r=>r.category==='Liquidación').reduce((s,r)=>s+r.ss,0)),\n xiiiSS:round(includedRecords.filter(r=>r.category==='XIII Mes').reduce((s,r)=>s+r.ss,0))\n }\n};\nconst hardErrors=(RESULT.validations||[]).filter(v=>v.severity==='ERROR');\nif(hardErrors.length)throw new Error('No se puede continuar: '+hardErrors.map(v=>`${v.fileName}: ${v.detail}`).join(' | '));\nreturn [{json:RESULT}];\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 65440, + 8752 + ], + "id": "6934bdd1-99b7-48ee-9171-0b70654e257a", + "name": "Parsear complementarios", + "notesInFlow": true, + "notes": "v8: además del parser robusto v7, confirma el tipo real de cada PDF por contenido antes de procesarlo. Una Planilla de Liquidación nunca se interpreta como Vacaciones aunque haya sido cargada en ese campo. Mantiene período efectivo, validación S.S./S.E., deduplicación y reglas históricas Mayo/Junio/Julio." + }, + { + "parameters": { + "jsCode": "const init=$items('Inicializar ejecución')[0].json;\nconst q1=$items('Parsear Q1')[0].json,q2=$items('Parsear Q2')[0].json;\nconst css=$items('Parsear CSS')[0].json,pdfs=$items('Parsear complementarios')[0].json;\nconst bambooRaw=$items('Generar reporte completo BambooHR')[0].json||{};\nconst rates=(init.config&&init.config.defaultRates)||{ssRegular:0.0975,se:0.0125,ssDtm:0.0725};\n\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'').toUpperCase().replace(/\\bVASQUEZ\\b/g,'VAZQUEZ').replace(/[^A-Z0-9 ]/g,' ').replace(/\\s+/g,' ').trim();}\nconst particles=new Set(['DE','DEL','LA','LAS','LOS','Y','DA','DOS']);\nfunction tokens(v){const a=norm(v).split(' ').filter(t=>t&&!particles.has(t));return [...new Set(a)];}\nfunction prepName(v){\n const raw=clean(v),n=norm(v),t=tokens(v),bag=t.slice().sort().join(' ');\n return {raw,n,t,bag,compact:bag.replace(/ /g,''),set:new Set(t)};\n}\nfunction levRaw(a,b){\n a=String(a||'');b=String(b||'');if(a===b)return 0;if(!a.length)return b.length;if(!b.length)return a.length;\n if(a.length>b.length){const z=a;a=b;b=z;}\n let prev=new Uint16Array(a.length+1),cur=new Uint16Array(a.length+1);\n for(let i=0;i<=a.length;i++)prev[i]=i;\n for(let j=1;j<=b.length;j++){\n cur[0]=j;const bj=b.charCodeAt(j-1);\n for(let i=1;i<=a.length;i++)cur[i]=Math.min(cur[i-1]+1,prev[i]+1,prev[i-1]+(a.charCodeAt(i-1)===bj?0:1));\n const z=prev;prev=cur;cur=z;\n }\n return prev[a.length];\n}\nfunction simRaw(a,b){if(!a||!b)return 0;return 1-levRaw(a,b)/Math.max(a.length,b.length);}\nfunction exactShared(A,B){let n=0;for(const t of A.t)if(t.length>=3&&B.set.has(t))n++;return n;}\nfunction nameScoreP(A,B){\n if(!A.t.length||!B.t.length)return 0;\n if(A.bag&&A.bag===B.bag)return 1;\n const avg=(X,Y)=>X.reduce((s,x)=>{\n let best=0;for(const y of Y){if(x===y){best=1;break;}const z=simRaw(x,y);if(z>best)best=z;}return s+best;\n },0)/X.length;\n const cov=(avg(A.t,B.t)+avg(B.t,A.t))/2;\n const bagSim=simRaw(A.compact,B.compact);\n const shared=exactShared(A,B)/Math.max(1,Math.min(A.t.length,B.t.length));\n return Math.min(1,.60*cov+.24*bagSim+.16*shared);\n}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction numDate(s){const m=String(s||'').match(/(\\d{1,2})\\/(\\d{1,2})\\/(20\\d{2})/);return m?{d:+m[1],m:+m[2],y:+m[3]}:null;}\nfunction validateQ(q,label,half){\n const a=numDate(q.period?.start),b=numDate(q.period?.end);if(!a||!b)throw new Error(`${label}: período ilegible.`);\n if(a.m!==css.period.month||b.m!==css.period.month||a.y!==css.period.year||b.y!==css.period.year)throw new Error(`${label} pertenece a ${q.period.start} - ${q.period.end}, pero la Planilla CSS es ${css.periodoLabel}. Se detuvo el cruce para evitar falsos positivos.`);\n if(half===1&&(a.d>2||b.d<13||b.d>16))throw new Error(`${label} no parece ser la 1era quincena.`);\n if(half===2&&(a.d<14||b.d<27))throw new Error(`${label} no parece ser la 2da quincena.`);\n}\nvalidateQ(q1,'Nómina Q1',1);validateQ(q2,'Nómina Q2',2);\n\nfunction docKey(v){return norm(v).replace(/[^A-Z0-9]/g,'');}\n\n// Defensa adicional: ningún movimiento con aportes puede llegar al cruce con base cero\n// ni con una combinación S.S./S.E. incompatible con base regular + DTM.\nfor(const r of pdfs.includedRecords||[]){\n const reg=Number(r.regularBase)||0,dtm=Number(r.dtmBase)||0,ss=Number(r.ss)||0,se=Number(r.se)||0;\n if((Math.abs(ss)>.01||Math.abs(se)>.01)&®+dtm<=0)throw new Error(`${r.fileName}: ${r.category} de ${r.name} tiene S.S./S.E. pero base cotizable 0. Se detuvo el cruce para no generar una diferencia falsa.`);\n const expSS=round(reg*Number(rates.ssRegular)+dtm*Number(rates.ssDtm)),expSE=round(reg*Number(rates.se));\n if(Math.abs(ss-expSS)>.25||Math.abs(se-expSE)>.12)throw new Error(`${r.fileName}: ${r.category} de ${r.name} no supera la validación de aportes (S.S. ${ss}/${expSS}; S.E. ${se}/${expSE}).`);\n}\n\n// BambooHR: Active + Inactive. Se usa TODO BambooHR para no depender de que Location/Country\n// estén llenos; Panamá solo aporta un pequeño desempate.\nconst bamboo=Array.isArray(bambooRaw.employees)?bambooRaw.employees:(Array.isArray(bambooRaw.data?.employees)?bambooRaw.data.employees:[]);\nif(!bamboo.length)throw new Error('BambooHR no devolvió empleados.');\nfunction bName(e){return clean(e.displayName||`${e.firstName||''} ${e.middleName||''} ${e.lastName||''}`);}\nconst bmeta=bamboo.map((e,i)=>({i,e,name:bName(e),p:prepName(bName(e)),panama:/PANAMA/.test(norm(`${e.location||''} ${e.country||''} ${e.division||''}`)),doc:docKey(e.employeeNumber)}));\n\n// Consolidar nómina + movimientos por persona.\nconst groups=new Map();\nfunction addRecord(r,kind){\n if(!r)return;\n const code=clean(r.code).replace(/^0+(?=\\d)/,''),nn=norm(r.name);\n const key=code&&/^\\d+$/.test(code)?`C:${code}`:`N:${nn}`;\n if(!groups.has(key))groups.set(key,{key,code,name:r.name||'',names:new Set(),salaryBase:0,dtmBase:0,totalSS:0,totalSE:0,q1:0,q2:0,extra:0,extraDtm:0,vac:0,liq:0,liqDtm:0,xiii:0,details:[]});\n const g=groups.get(key);if(r.name)g.names.add(r.name);g.totalSS+=Number(r.ss)||0;g.totalSE+=Number(r.se)||0;\n if(kind==='q1'){g.q1+=Number(r.gross)||0;g.salaryBase+=Number(r.gross)||0;}\n else if(kind==='q2'){g.q2+=Number(r.gross)||0;g.salaryBase+=Number(r.gross)||0;}\n else if(kind==='extra'){const a=Number(r.regularBase)||0,d=Number(r.dtmBase)||0;g.extra+=a;g.extraDtm+=d;g.salaryBase+=a;g.dtmBase+=d;}\n else if(kind==='vac'){const a=Number(r.regularBase??r.gross)||0;g.vac+=a;g.salaryBase+=a;}\n else if(kind==='liq'){const a=Number(r.regularBase)||0,d=Number(r.dtmBase)||0;g.liq+=a;g.liqDtm+=d;g.salaryBase+=a;g.dtmBase+=d;}\n else if(kind==='xiii'){const d=Number(r.dtmBase)||0;g.xiii+=d;g.dtmBase+=d;}\n g.details.push({kind,...r});\n}\nfor(const r of q1.records||[])addRecord(r,'q1');\nfor(const r of q2.records||[])addRecord(r,'q2');\nfor(const r of pdfs.includedRecords||[]){\n if(r.category==='Extraordinaria')addRecord(r,'extra');\n else if(r.category==='Vacaciones')addRecord(r,'vac');\n else if(r.category==='Liquidación')addRecord(r,'liq');\n else if(r.category==='XIII Mes')addRecord(r,'xiii');\n}\n// Une código truncado/no numérico con código normal cuando el nombre exacto coincide.\nconst mergedByName=new Map();\nfor(const g0 of groups.values()){\n const nn=norm([...g0.names][0]||g0.name)||g0.key;\n if(!mergedByName.has(nn)){mergedByName.set(nn,{...g0,names:new Set(g0.names),details:[...g0.details]});continue;}\n const a=mergedByName.get(nn),b=g0;\n for(const k of ['salaryBase','dtmBase','totalSS','totalSE','q1','q2','extra','extraDtm','vac','liq','liqDtm','xiii'])a[k]+=b[k];\n for(const x of b.names)a.names.add(x);a.details.push(...b.details);\n if((!a.code||!/^\\d+$/.test(a.code))&&b.code&&/^\\d+$/.test(b.code))a.code=b.code;\n}\nconst sources=[...mergedByName.values()].map((g,si)=>({\n ...g,si,name:[...g.names][0]||g.name,p:prepName([...g.names][0]||g.name),\n salaryBase:round(g.salaryBase),dtmBase:round(g.dtmBase),totalSS:round(g.totalSS),totalSE:round(g.totalSE),\n q1:round(g.q1),q2:round(g.q2),extra:round(g.extra),extraDtm:round(g.extraDtm),vac:round(g.vac),liq:round(g.liq),liqDtm:round(g.liqDtm),xiii:round(g.xiii)\n}));\n\n// Índices de candidatos: reducen ~millones de Levenshtein a unos pocos miles.\nfunction pushIndex(map,key,id){if(!key)return;if(!map.has(key))map.set(key,[]);map.get(key).push(id);}\nfunction buildNameIndex(meta){\n const byBag=new Map(),byToken=new Map(),byPrefix3=new Map(),byInitials=new Map();\n for(let i=0;i=3)pushIndex(byPrefix3,t.slice(0,3),i);}\n pushIndex(byInitials,p.t.map(t=>t[0]).sort().join(''),i);\n }\n return {byBag,byToken,byPrefix3,byInitials};\n}\nfunction gatherNameCandidates(P,index,limit=100){\n const out=new Set();\n const add=arr=>{if(!arr)return;for(const x of arr){out.add(x);if(out.size>=limit)return;}};\n add(index.byBag.get(P.bag));if(out.size>=limit)return out;\n const tokenLists=P.t.map(t=>({t,a:index.byToken.get(t)||[]})).sort((a,b)=>a.a.length-b.a.length);\n for(const z of tokenLists.slice(0,3)){if(z.a.length<=250)add(z.a);if(out.size>=limit)break;}\n if(out.size=3)add(index.byPrefix3.get(t.slice(0,3)));if(out.size>=limit)break;}\n if(out.sizet[0]).sort().join('')));\n return out;\n}\nconst bIndex=buildNameIndex(bmeta);\nconst bambooByDoc=new Map();\nfor(const m of bmeta)if(m.doc&&!bambooByDoc.has(m.doc))bambooByDoc.set(m.doc,m.e);\n\n// Fuente -> BambooHR con candidatos indexados.\nconst directB=new Map();\nfor(let si=0;sibest.sc){second=best;best=z;}else if(!second||z.sc>second.sc)second=z;\n }\n const exactUnique=(bIndex.byBag.get(s.p.bag)||[]).length===1;\n if(best&&(exactUnique&&best.sc>=.90 || best.sc>=.86&&((best.sc-(second?.sc||0))>=.025||best.sc>=.965)))directB.set(si,best);\n}\n\n// CSS metadata + índices.\nconst pmeta=(css.employees||[]).map((p,pi)=>({pi,p,pn:prepName(p.name),salary:Number(p.salaryAdjusted||0),dtm:Number(p.dtmBase||0),doc:docKey(p.identity||p.ssNumber)}));\nconst cssByDoc=new Map();for(const m of pmeta)if(m.doc&&!cssByDoc.has(m.doc))cssByDoc.set(m.doc,m.pi);\nconst cssNameIndex=buildNameIndex(pmeta.map(m=>({p:m.pn})));\nconst byAmount=new Map();for(const m of pmeta){const k=Math.round(m.salary);pushIndex(byAmount,k,m.pi);}\n\nfunction cssCandidates(s){\n const out=gatherNameCandidates(s.p,cssNameIndex,100);\n const k=Math.round(s.salaryBase);\n for(let d=-2;d<=2;d++){const a=byAmount.get(k+d)||[];for(const x of a){out.add(x);if(out.size>=140)break;}}\n if(out.size<8){\n // Rescate barato: solo distancia numérica, sin fuzzy, y únicamente 12 candidatos.\n const close=pmeta.map(m=>({pi:m.pi,d:Math.abs(s.salaryBase-m.salary)+.2*Math.abs(s.dtmBase-m.dtm)})).sort((a,b)=>a.d-b.d).slice(0,12);\n for(const x of close)out.add(x.pi);\n }\n return out;\n}\n\n// 1) Link de máxima confianza: Bamboo Employee # ↔ documento CSS.\nconst usedS=new Set(),usedP=new Set(),matches=[];\nfor(const [si,bm] of directB){\n const pi=cssByDoc.get(docKey(bm.e.employeeNumber));\n if(pi===undefined||usedP.has(pi))continue;\n const s=sources[si],pm=pmeta[pi],ns=nameScoreP(s.p,pm.pn);\n usedS.add(si);usedP.add(pi);\n matches.push({si,pi,source:s,plan:pm.p,method:'BambooHR Employee # ↔ documento CSS',confidence:'ALTA',identityReview:ns<.72,bamboo:bm.e,ns});\n}\n\n// 2) Resto: fuzzy solo sobre pools indexados.\nconst cand=[];\nfor(let si=0;si=.90){tier=2;method='Nombre de alta confianza';}\n else if(ns>=.78&&d<=2){tier=3;method='Nombre + monto';}\n else if(shared>=1&&d<=.15){tier=4;method='Token de nombre + monto exacto';}\n else if(shared>=1&&d<=1&&dd<=.15){tier=5;method='Nombre parcial + salario/DTM';}\n if(!tier)continue;\n const score=(6-tier)*100+ns*10-Math.min(d,20)*.03-Math.min(dd,20)*.01;\n cand.push({si,pi,tier,score,ns,d,dd,method});\n }\n}\ncand.sort((a,b)=>a.tier-b.tier||b.score-a.score);\nfor(const c of cand){\n if(usedS.has(c.si)||usedP.has(c.pi))continue;\n const s=sources[c.si],pm=pmeta[c.pi],viaDoc=bambooByDoc.get(pm.doc)||null,direct=directB.get(c.si)?.e||null;\n usedS.add(c.si);usedP.add(c.pi);\n matches.push({...c,source:s,plan:pm.p,confidence:c.tier<=2?'ALTA':c.tier<=3?'MEDIA':'REVISAR',identityReview:c.tier>=4||c.ns<.72,bamboo:viaDoc||direct});\n}\nconst onlySource=sources.filter((_,i)=>!usedS.has(i)),onlyPlan=(css.employees||[]).filter((_,i)=>!usedP.has(i));\n\nfunction conceptSource(s){\n const a=[];for(const [lab,v] of [['Q1',s.q1],['Q2',s.q2],['Extraordinaria regular',s.extra],['Extraordinaria DTM',s.extraDtm],['Vacaciones',s.vac],['Liquidación base',s.liq],['Liquidación DTM',s.liqDtm],['XIII Mes',s.xiii]])if(Math.abs(v)>.005)a.push(`${lab}: ${v.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}`);return a.join(' | ');\n}\nfunction titleCase(s){return String(s||'').toLowerCase().replace(/(^|\\s)\\S/g,m=>m.toUpperCase());}\nfunction conceptCss(p){const a=[];for(const [k,v] of Object.entries(p.components||{})){if(Math.abs(Number(v)||0)>.005)a.push(`${titleCase(k)}: ${Number(v).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}`);}return a.join(' | ');}\n\nconst rows=[];let impactCount=0,identityCount=0;\nconst salaryTol=Number(init.salaryTolerance??.10),dtmTol=Number(init.dtmTolerance??.10);\nfor(const m of matches){\n const s=m.source,p=m.plan,dSal=round(s.salaryBase-Number(p.salary||0)),dAdj=round(s.salaryBase-Number(p.salaryAdjusted||0)),dDtm=round(s.dtmBase-Number(p.dtmBase||0));\n const salaryDiff=Math.abs(dAdj)>salaryTol,dtmDiff=Math.abs(dDtm)>dtmTol,identity=!!m.identityReview;\n if(!(salaryDiff||dtmDiff||identity))continue;\n const types=[];if(salaryDiff)types.push('Salario cotizable');if(dtmDiff)types.push('Décimo tercer mes');if(identity)types.push('Revisión de identidad');\n const impact=salaryDiff||dtmDiff?'Sí':'No';if(impact==='Sí')impactCount++;if(identity)identityCount++;\n let obs=`Match: ${m.method}.`;if(salaryDiff)obs+=` Diferencia contra salario ajustado CSS: B/. ${dAdj.toFixed(2)}.`;if(dtmDiff)obs+=` Diferencia DTM: B/. ${dDtm.toFixed(2)}.`;if(identity)obs+=' Los nombres requieren revisión manual aunque existe evidencia de correspondencia.';\n rows.push([s.code||'',s.name,p.identity||p.ssNumber||'',p.name,types.join(' + '),impact,s.salaryBase,Number(p.salary)||0,Number(p.salaryAdjusted)||0,dSal,dAdj,conceptSource(s),conceptCss(p),obs]);\n}\nfor(const s of onlySource){impactCount++;rows.push([s.code||'',s.name,'','','En nómina / no en CSS','Sí',s.salaryBase,0,0,s.salaryBase,s.salaryBase,conceptSource(s),'','No se identificó contraparte en la Planilla CSS con evidencia suficiente.']);}\nfor(const p of onlyPlan){impactCount++;rows.push(['','',p.identity||p.ssNumber||'',p.name,'En CSS / no en nómina','Sí',0,Number(p.salary)||0,Number(p.salaryAdjusted)||0,round(-Number(p.salary||0)),round(-Number(p.salaryAdjusted||0)),'',conceptCss(p),'No se identificó contraparte en las nóminas/movimientos suministrados.']);}\nrows.sort((a,b)=>(a[5]==='Sí'?0:1)-(b[5]==='Sí'?0:1)||Math.abs(Number(b[10])||0)-Math.abs(Number(a[10])||0)||String(a[1]||a[3]).localeCompare(String(b[1]||b[3])));\n\n// Nómina sin BambooHR: Active o Inactive cuenta como existente.\nconst sourceToMatch=new Map(matches.map(m=>[m.si,m])),bambooMissing=[];let foundB=0;\nfor(let si=0;si0||bambooMissing.length>0,q1:q1.fileName,q2:q2.fileName,css:css.fileName,complementCounts:pdfs.counts,matchingEngine:'INDEXADO_V3'};\nreturn [{json:{reportTitle,periodoLabel:css.periodoLabel,diffRows,bambooRows,summary}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 65680, + 8752 + ], + "id": "2e812507-1b8b-4c26-9404-0f704d74751d", + "name": "Construir cruce + BambooHR", + "notesInFlow": true, + "notes": "Motor de matching indexado v3. Evita comparaciones fuzzy cartesianas que bloqueaban el task runner; valida bases complementarias antes del cruce." + }, + { + "parameters": { + "method": "POST", + "url": "https://sheets.googleapis.com/v4/spreadsheets", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ properties: { title: $json[\"reportTitle\"] }, sheets: [{ properties: { title: \"Diferencias\" } },{ properties: { title: \"Nómina sin BambooHR\" } }] }) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 65920, + 8752 + ], + "id": "18053415-f9cd-42c5-84d5-aa2f53f648d3", + "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: \"Diferencias!A1\", majorDimension: \"ROWS\", values: $node[\"Construir cruce + BambooHR\"].json[\"diffRows\"] },{ range: \"'Nómina sin BambooHR'!A1\", majorDimension: \"ROWS\", values: $node[\"Construir cruce + BambooHR\"].json[\"bambooRows\"] }] }) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 66160, + 8752 + ], + "id": "4e60905d-2dbd-415d-baff-47da0d803664", + "name": "Escribir reporte", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const created=$items('Crear Google Sheet Reporte')[0].json,r=$items('Construir cruce + BambooHR')[0].json;const map={'Diferencias':{rows:r.diffRows,cols:14,widths:[95,180,125,180,185,115,110,110,130,120,145,260,260,360]},'Nómina sin BambooHR':{rows:r.bambooRows,cols:4,widths:[110,220,120,420]}};const req=[];\nfor(const s of created.sheets||[]){const name=s.properties.title,id=s.properties.sheetId,cfg=map[name];if(!cfg)continue;const rowCount=Math.max(cfg.rows.length,6),cols=cfg.cols;req.push({updateSheetProperties:{properties:{sheetId:id,gridProperties:{hideGridlines:true,frozenRowCount:5}},fields:'gridProperties.hideGridlines,gridProperties.frozenRowCount'}});req.push({repeatCell:{range:{sheetId:id,startRowIndex:0,endRowIndex:rowCount,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{textFormat:{fontFamily:'Arial',fontSize:10,foregroundColor:{red:.29,green:.29,blue:.29}},verticalAlignment:'MIDDLE',wrapStrategy:'WRAP',borders:{top:{style:'SOLID',color:{red:.82,green:.82,blue:.82}},bottom:{style:'SOLID',color:{red:.82,green:.82,blue:.82}},left:{style:'SOLID',color:{red:.82,green:.82,blue:.82}},right:{style:'SOLID',color:{red:.82,green:.82,blue:.82}}}}},fields:'userEnteredFormat'}});for(let rr=0;rr<3;rr++)req.push({mergeCells:{range:{sheetId:id,startRowIndex:rr,endRowIndex:rr+1,startColumnIndex:0,endColumnIndex:cols},mergeType:'MERGE_ALL'}});req.push({repeatCell:{range:{sheetId:id,startRowIndex:0,endRowIndex:1,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{backgroundColor:{red:.310,green:.459,blue:.545},textFormat:{fontFamily:'Arial',fontSize:13,bold:true,foregroundColor:{red:1,green:1,blue:1}},borders:{bottom:{style:'SOLID_THICK',color:{red:.424,green:.761,blue:.290}}}}},fields:'userEnteredFormat'}});req.push({repeatCell:{range:{sheetId:id,startRowIndex:1,endRowIndex:2,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{backgroundColor:{red:.357,green:.498,blue:.584},textFormat:{fontFamily:'Arial',fontSize:10,italic:true,foregroundColor:{red:1,green:1,blue:1}}}},fields:'userEnteredFormat'}});req.push({repeatCell:{range:{sheetId:id,startRowIndex:2,endRowIndex:3,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{backgroundColor:{red:.839,green:.910,blue:.957},textFormat:{fontFamily:'Arial',fontSize:9,italic:true,foregroundColor:{red:.310,green:.459,blue:.545}}}},fields:'userEnteredFormat'}});req.push({repeatCell:{range:{sheetId:id,startRowIndex:4,endRowIndex:5,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{backgroundColor:{red:.310,green:.459,blue:.545},textFormat:{fontFamily:'Arial',fontSize:9,bold:true,foregroundColor:{red:1,green:1,blue:1}},horizontalAlignment:'CENTER'}},fields:'userEnteredFormat'}});cfg.widths.forEach((w,i)=>req.push({updateDimensionProperties:{range:{sheetId:id,dimension:'COLUMNS',startIndex:i,endIndex:i+1},properties:{pixelSize:w},fields:'pixelSize'}}));if(name==='Diferencias'){req.push({repeatCell:{range:{sheetId:id,startRowIndex:5,endRowIndex:rowCount,startColumnIndex:6,endColumnIndex:11},cell:{userEnteredFormat:{numberFormat:{type:'NUMBER',pattern:'#,##0.00'}}},fields:'userEnteredFormat.numberFormat'}});req.push({addConditionalFormatRule:{rule:{ranges:[{sheetId:id,startRowIndex:5,endRowIndex:rowCount,startColumnIndex:5,endColumnIndex:6}],booleanRule:{condition:{type:'TEXT_EQ',values:[{userEnteredValue:'Sí'}]},format:{backgroundColor:{red:.992,green:.925,blue:.918},textFormat:{bold:true,foregroundColor:{red:.65,green:.15,blue:.12}}}}},index:0}});req.push({addConditionalFormatRule:{rule:{ranges:[{sheetId:id,startRowIndex:5,endRowIndex:rowCount,startColumnIndex:4,endColumnIndex:5}],booleanRule:{condition:{type:'TEXT_CONTAINS',values:[{userEnteredValue:'Revisión de identidad'}]},format:{backgroundColor:{red:1,green:.973,blue:.882},textFormat:{bold:true,foregroundColor:{red:.55,green:.35,blue:0}}}}},index:1}});}else{req.push({addConditionalFormatRule:{rule:{ranges:[{sheetId:id,startRowIndex:5,endRowIndex:rowCount,startColumnIndex:2,endColumnIndex:3}],booleanRule:{condition:{type:'TEXT_CONTAINS',values:[{userEnteredValue:'No encontrado'}]},format:{backgroundColor:{red:.992,green:.925,blue:.918},textFormat:{bold:true,foregroundColor:{red:.65,green:.15,blue:.12}}}}},index:0}});}}\nreturn [{json:{requests:req}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 66400, + 8752 + ], + "id": "a4f94747-a4a6-4cca-a871-b5121d3cad1e", + "name": "Preparar formato GLM" + }, + { + "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({requests:$json.requests}) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 66640, + 8752 + ], + "id": "f9e80770-c139-4a71-a988-f1c5b4d4d818", + "name": "Formatear reporte", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const id=$items('Crear Google Sheet Reporte')[0].json.spreadsheetId;\nconst init=$items('Inicializar ejecución')[0].json;\nconst editors=Array.isArray(init.editorEmails)&&init.editorEmails.length\n ? init.editorEmails\n : [init.editorEmail].filter(Boolean);\nconst spreadsheetUrl=`https://docs.google.com/spreadsheets/d/${id}/edit`;\nreturn editors.map(emailAddress=>({\n json:{spreadsheetId:id,emailAddress,spreadsheetUrl}\n}));" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 66880, + 8752 + ], + "id": "99e27090-3ca3-42da-98cd-49dfda85f7db", + "name": "Preparar permiso editor" + }, + { + "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:'writer',type:'user',emailAddress:$json.emailAddress}) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 67120, + 8752 + ], + "id": "bd409798-e29b-4a35-8462-f95f56651669", + "name": "Compartir reporte como editor", + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + }, + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "const init=$items('Inicializar ejecución')[0].json,r=$items('Construir cruce + BambooHR')[0].json,s=r.summary,id=$items('Crear Google Sheet Reporte')[0].json.spreadsheetId,url=`https://docs.google.com/spreadsheets/d/${id}/edit`,has=s.hasFindings,bg=has?'#FFF8E1':'#E8F5E9',state=has?'#8A5A00':'#27632A';\nconst html=`
 
\"GomezLee
SEGURIDAD SOCIAL · PANAMÁ

Cruce mensual completado

${s.periodoLabel}

${has?'REVISAR HALLAZGOS':'SIN DIFERENCIAS'}
El reporte contiene únicamente diferencias y la hoja “Nómina sin BambooHR”.
MétricaResultado
Diferencias${s.differenceCount}
Impactan cotización${s.impactCount}
Revisión de identidad${s.identityCount}
Nómina sin BambooHR${s.bambooMissingCount}
Abrir reporte
Q1: ${s.q1}
Q2: ${s.q2}
CSS: ${s.css}
GOMEZLEE MARKETING · CONFIDENCIAL — Uso Interno
`;\nreturn [{json:{sendTo:init.recipient,subject:`${has?'[REVISAR]':'[OK]'} Cruce Seguridad Social Panamá - ${s.periodoLabel}`,html,spreadsheetUrl:url}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 67360, + 8752 + ], + "id": "5d9b86cf-6cf7-47e6-9762-bd8262201b6b", + "name": "Preparar correo HTML GLM" + }, + { + "parameters": { + "sendTo": "={{ $json.sendTo }}", + "subject": "={{ $json.subject }}", + "message": "={{ $json.html }}", + "options": { + "appendAttribution": false + } + }, + "type": "n8n-nodes-base.gmail", + "typeVersion": 2.2, + "position": [ + 67600, + 8752 + ], + "id": "d4b95742-e4cd-4592-8df5-3f76db90840d", + "name": "Enviar correo reporte", + "executeOnce": true, + "webhookId": "1f8d3e4a-5ac5-49aa-82dc-2ba72fdcc3cd", + "credentials": { + "gmailOAuth2": { + "id": "UDcO1FLJqA453V2D", + "name": "Gmail account 3" + } + } + }, + { + "parameters": { + "method": "DELETE", + "url": "=https://www.googleapis.com/drive/v3/files/{{ $('Crear staging CSS').item.json.spreadsheetId }}?supportsAllDrives=true", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleDriveOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 67840, + 8752 + ], + "id": "a625af7d-797b-46a2-884a-d7879c2432a5", + "name": "Eliminar staging CSS", + "alwaysOutputData": true, + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + }, + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "const init=$items('Inicializar ejecución')[0].json,r=$items('Construir cruce + BambooHR')[0].json,s=r.summary,id=$items('Crear Google Sheet Reporte')[0].json.spreadsheetId,reportUrl=`https://docs.google.com/spreadsheets/d/${id}/edit`,has=s.hasFindings;\nconst html=`
\"GomezLee
SEGURIDAD SOCIAL · PANAMÁ

Cruce mensual completado

${s.periodoLabel}

${has?'Se encontraron hallazgos para revisar.':'No se detectaron diferencias.'}
${s.differenceCount} diferencia(s) · ${s.bambooMissingCount} persona(s) en Nómina sin BambooHR.

Abrir reporteRealizar otro cruce

El reporte también fue enviado a ${init.recipient}.

`;return [{json:{html}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 68080, + 8752 + ], + "id": "bc7805bc-dd9c-417e-b2e4-9009c579f3af", + "name": "Preparar confirmación" + }, + { + "parameters": { + "operation": "completion", + "respondWith": "showText", + "responseText": "={{ $json.html }}" + }, + "type": "n8n-nodes-base.form", + "typeVersion": 2.5, + "position": [ + 68320, + 8752 + ], + "id": "c0b72d12-ae17-47fa-b6ce-3c43389bc17a", + "name": "Confirmación · Abrir reporte / volver", + "webhookId": "ffae77c9-e2ed-4b13-9f00-8e7cdc454c64", + "notesInFlow": true, + "notes": "Pantalla final con dos botones: abrir reporte y volver al mismo formulario." + } + ], + "connections": { + "Formulario · Cruce SS Panamá": { + "main": [ + [ + { + "node": "Inicializar ejecución", + "type": "main", + "index": 0 + } + ] + ] + }, + "Inicializar ejecución": { + "main": [ + [ + { + "node": "Generar reporte completo BambooHR", + "type": "main", + "index": 0 + } + ] + ] + }, + "Generar reporte completo BambooHR": { + "main": [ + [ + { + "node": "Restaurar archivos del formulario", + "type": "main", + "index": 0 + } + ] + ] + }, + "Restaurar archivos del formulario": { + "main": [ + [ + { + "node": "Preparar Q1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar Q1": { + "main": [ + [ + { + "node": "Extraer texto Q1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extraer texto Q1": { + "main": [ + [ + { + "node": "Parsear Q1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parsear Q1": { + "main": [ + [ + { + "node": "Preparar Q2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar Q2": { + "main": [ + [ + { + "node": "Extraer texto Q2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extraer texto Q2": { + "main": [ + [ + { + "node": "Parsear Q2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parsear Q2": { + "main": [ + [ + { + "node": "Preparar CSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar CSS": { + "main": [ + [ + { + "node": "Crear staging CSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Crear staging CSS": { + "main": [ + [ + { + "node": "Reunir staging CSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Reunir staging CSS": { + "main": [ + [ + { + "node": "Convertir CSS a Google Sheets", + "type": "main", + "index": 0 + } + ] + ] + }, + "Convertir CSS a Google Sheets": { + "main": [ + [ + { + "node": "Esperar conversión CSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Esperar conversión CSS": { + "main": [ + [ + { + "node": "Listar hojas CSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Listar hojas CSS": { + "main": [ + [ + { + "node": "Preparar rangos CSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar rangos CSS": { + "main": [ + [ + { + "node": "Leer CSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Leer CSS": { + "main": [ + [ + { + "node": "Parsear CSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parsear CSS": { + "main": [ + [ + { + "node": "Separar complementarios", + "type": "main", + "index": 0 + } + ] + ] + }, + "Separar complementarios": { + "main": [ + [ + { + "node": "Extraer texto complementarios", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extraer texto complementarios": { + "main": [ + [ + { + "node": "Parsear complementarios", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parsear complementarios": { + "main": [ + [ + { + "node": "Construir cruce + BambooHR", + "type": "main", + "index": 0 + } + ] + ] + }, + "Construir cruce + BambooHR": { + "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 GLM", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar formato GLM": { + "main": [ + [ + { + "node": "Formatear reporte", + "type": "main", + "index": 0 + } + ] + ] + }, + "Formatear reporte": { + "main": [ + [ + { + "node": "Preparar permiso editor", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar permiso editor": { + "main": [ + [ + { + "node": "Compartir reporte como editor", + "type": "main", + "index": 0 + } + ] + ] + }, + "Compartir reporte como editor": { + "main": [ + [ + { + "node": "Preparar correo HTML GLM", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar correo HTML GLM": { + "main": [ + [ + { + "node": "Enviar correo reporte", + "type": "main", + "index": 0 + } + ] + ] + }, + "Enviar correo reporte": { + "main": [ + [ + { + "node": "Eliminar staging CSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Eliminar staging CSS": { + "main": [ + [ + { + "node": "Preparar confirmación", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar confirmación": { + "main": [ + [ + { + "node": "Confirmación · Abrir reporte / volver", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate" + }, + "staticData": null, + "meta": null, + "versionId": "6f41b207-7e88-4fb0-8978-3917a67354c7", + "activeVersionId": "6f41b207-7e88-4fb0-8978-3917a67354c7", + "versionCounter": 67, + "triggerCount": 1, + "shared": [ + { + "updatedAt": "2026-08-27T01:59:23.109Z", + "createdAt": "2026-08-27T01:59:23.109Z", + "role": "workflow:owner", + "workflowId": "i5dlh4n3RAbfcB4i", + "projectId": "PJpTANzTXIFibWsW", + "project": { + "updatedAt": "2026-04-22T14:25:09.686Z", + "createdAt": "2026-04-22T14:22:54.790Z", + "id": "PJpTANzTXIFibWsW", + "name": "Isaac Aracena ", + "type": "personal", + "icon": null, + "description": null, + "creatorId": "0a88c0b1-928e-4412-896e-c5d1c99b2029" + } + } + ], + "tags": [], + "activeVersion": { + "updatedAt": "2026-08-28T14:42:57.000Z", + "createdAt": "2026-08-28T14:42:54.665Z", + "versionId": "6f41b207-7e88-4fb0-8978-3917a67354c7", + "workflowId": "i5dlh4n3RAbfcB4i", + "nodes": [ + { + "parameters": { + "formTitle": "Cruce de Seguridad Social · Panamá", + "formDescription": "Carga las dos nóminas quincenales en PDF, la Planilla CSS oficial y los movimientos complementarios. El período se detecta automáticamente desde CSS. Extraordinarias y XIII Mes son opcionales; Extraordinarias, Vacaciones, Liquidaciones y XIII Mes aceptan múltiples PDFs. El flujo valida período, integridad y bases cotizables antes de generar el reporte.", + "formFields": { + "values": [ + { + "fieldLabel": "Nómina - 1era Quincena", + "fieldType": "file", + "multipleFiles": false, + "acceptFileTypes": ".pdf", + "requiredField": true + }, + { + "fieldLabel": "Nómina - 2da Quincena", + "fieldType": "file", + "multipleFiles": false, + "acceptFileTypes": ".pdf", + "requiredField": true + }, + { + "fieldLabel": "Planilla CSS", + "fieldType": "file", + "multipleFiles": false, + "acceptFileTypes": ".xls,.xlsx", + "requiredField": true + }, + { + "fieldLabel": "Extraordinarias (opcional)", + "fieldType": "file", + "acceptFileTypes": ".pdf" + }, + { + "fieldLabel": "Vacaciones", + "fieldType": "file", + "acceptFileTypes": ".pdf", + "requiredField": true + }, + { + "fieldLabel": "Liquidaciones", + "fieldType": "file", + "acceptFileTypes": ".pdf", + "requiredField": true + }, + { + "fieldLabel": "XIII Mes (opcional)", + "fieldType": "file", + "acceptFileTypes": ".pdf" + } + ] + }, + "responseMode": "lastNode", + "options": { + "path": "95ce3005-71fd-4432-a050-b3858d3f79a5", + "ignoreBots": true + } + }, + "type": "n8n-nodes-base.formTrigger", + "typeVersion": 2.3, + "position": [ + 60400, + 8752 + ], + "id": "27ce083d-698c-482a-b3b3-97ea63563665", + "name": "Formulario · Cruce SS Panamá", + "webhookId": "95ce3005-71fd-4432-a050-b3858d3f79a5", + "notesInFlow": true, + "notes": "Formulario mensual. Extraordinarias y XIII Mes pueden omitirse sin romper el flujo." + }, + { + "parameters": { + "jsCode": "const item=$input.first();\nconst now=new Date();\nconst pad=n=>String(n).padStart(2,'0');\nconst runId=`PA_CSS_${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}_${Math.random().toString(36).slice(2,8)}`;\nconst recipients=[\"iaracena@gomezleemarketing.com\", \"ymadera@gomezleemarketing.com\", \"msoto@gomezleemarketing.com\", \"asrodriguez@gomezleemarketing.com\", \"iherrera@gomezleemarketing.com\", \"mgomez@gomezleemarketing.com\", \"vparamo@gomezleemarketing.com\"];\nreturn [{json:{\n runId,\n startedAt:now.toISOString(),\n recipient:recipients.join(', '),\n recipients,\n editorEmail:recipients[0],\n editorEmails:recipients,\n logoUrl:'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png',\n formUrl:'https://agenteit.digitalcompass.agency/form/95ce3005-71fd-4432-a050-b3858d3f79a5',\n salaryTolerance:0.10,\n dtmTolerance:0.10,\n config:{\n defaultRates:{ssRegular:0.0975,se:0.0125,ssDtm:0.0725},\n cssIntegrityTolerance:0.10\n }\n},binary:item.binary||{}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 60640, + 8752 + ], + "id": "97f89f1c-62de-4204-9dee-1512221f8f42", + "name": "Inicializar ejecución", + "notesInFlow": true, + "notes": "Configuración central v6: URL estable del formulario, destinatario/editor, logo, tolerancias y tasas." + }, + { + "parameters": { + "method": "POST", + "url": "https://glm.bamboohr.com/api/v1/reports/custom?format=JSON&onlyCurrent=false", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "{\"title\":\"Cruce Seguridad Social Panamá - empleados BambooHR\",\"fields\":[\"firstName\",\"middleName\",\"lastName\",\"displayName\",\"location\",\"division\",\"country\",\"status\",\"employmentStatus\",\"employeeNumber\"]}", + "options": { + "response": { + "response": { + "responseFormat": "json" + } + }, + "timeout": 300000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 60880, + 8752 + ], + "id": "02ec7bc1-1492-439b-a7c3-83122eabc093", + "name": "Generar reporte completo BambooHR", + "retryOnFail": true, + "notesInFlow": true, + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + }, + "notes": "Consulta BambooHR Active + Inactive con onlyCurrent=false. Se solicitan solo los campos necesarios para reducir memoria/CPU del flujo." + }, + { + "parameters": { + "jsCode": "const base=$items('Inicializar ejecución')[0];\nconst b=$input.first().json||{};\nconst employees=Array.isArray(b.employees)?b.employees:(Array.isArray(b.data?.employees)?b.data.employees:[]);\nif(!employees.length) throw new Error('BambooHR no devolvió empleados. Se detuvo el cruce para no generar una hoja “Nómina sin BambooHR” incorrecta.');\nreturn [{json:{...base.json,bambooEmployeeCount:employees.length},binary:base.binary||{}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 61120, + 8752 + ], + "id": "5b8dfa9b-d790-4498-b746-7e65fa8c873d", + "name": "Restaurar archivos del formulario" + }, + { + "parameters": { + "jsCode": "const base=$items('Restaurar archivos del formulario')[0];\nconst bin=base.binary||{};\nfunction norm(s){return String(s||'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase();}\nconst half=1;\nconst entries=Object.entries(bin).map(([key,file])=>{\n const name=String(file.fileName||key); const s=norm(`${key} ${name}`); const mime=norm(file.mimeType||'');\n let score=(s.includes('.pdf')||mime.includes('pdf'))?100:0;\n if(half===1 && /(1era|primera|1ra|q1|quincena\\s*1|1era\\s*quincena)/.test(s))score+=300;\n if(half===2 && /(2da|segunda|2da|q2|quincena\\s*2|2da\\s*quincena)/.test(s))score+=300;\n if(/css|detalleplanilla|extraordin|vacacion|liquidacion|xiii|decimo/.test(s))score-=250;\n return {key,file,name,score};\n}).sort((a,b)=>b.score-a.score);\nconst chosen=entries[0];\nif(!chosen||chosen.score<250) throw new Error(`No pude identificar la nómina de la ${half===1?'1era':'2da'} quincena en PDF.`);\nreturn [{json:{...base.json,fileName:chosen.name,originalBinaryKey:chosen.key,half},binary:{data:chosen.file}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 61360, + 8752 + ], + "id": "c1487f8a-8050-40f6-a08c-add38dec722a", + "name": "Preparar Q1" + }, + { + "parameters": { + "operation": "pdf", + "options": {} + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + 61600, + 8752 + ], + "id": "eebb39f0-7755-461c-993d-1ab09cc603b2", + "name": "Extraer texto Q1" + }, + { + "parameters": { + "jsCode": "const prep=$items('Preparar Q1')[0].json;\nconst item=$input.first();\nconst candidateStrings=Object.values(item.json||{}).filter(v=>typeof v==='string');\nconst raw=item.json?.text??item.json?.data??item.json?.content??candidateStrings.sort((a,b)=>b.length-a.length)[0]??'';\nconst text=String(raw).replace(/\\r/g,'');\nconst config=$items('Inicializar ejecución')[0]?.json?.config||{};\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction num(v){if(v===null||v===undefined||v==='')return null;let s=String(v).replace(/B\\/\\.?|\\$/gi,'').replace(/,/g,'').trim();let neg=/^\\(.*\\)$/.test(s);if(neg)s=s.slice(1,-1);if(!/^-?\\d+(?:\\.\\d+)?$/.test(s))return null;const n=Number(s);return Number.isFinite(n)?(neg?-n:n):null;}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction moneyLine(s){return /^[\\s$()\\-]*[\\d,]+(?:\\.\\d+)?\\s*$/.test(String(s||''));}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase();}\nfunction plausible(gross,ss,se){if(!Number.isFinite(gross)||!Number.isFinite(ss)||!Number.isFinite(se))return false;if(gross<0||ss<-.01||se<-.01)return false;if(gross>0&&(ss/gross>.30||se/gross>.08))return false;return true;}\nfunction median(a){const x=a.filter(Number.isFinite).sort((p,q)=>p-q);if(!x.length)return null;const m=Math.floor(x.length/2);return x.length%2?x[m]:(x[m-1]+x[m])/2;}\nfunction fixed2Tokens(s){return (String(s||'').match(/\\(?-?(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{2}\\)?/g)||[]).map(num).filter(v=>v!==null);}\nfunction sim(a,b){a=norm(a).replace(/[^A-Z]/g,'');b=norm(b).replace(/[^A-Z]/g,'');if(!a||!b)return 0;if(a===b)return 1;const prev=Array.from({length:b.length+1},(_,i)=>i);for(let i=1;i<=a.length;i++){const cur=[i];for(let j=1;j<=b.length;j++)cur[j]=Math.min(cur[j-1]+1,prev[j]+1,prev[j-1]+(a[i-1]===b[j-1]?0:1));for(let j=0;jt[0]||'').join('');\n if(initials===code||initials.split('').reverse().join('')===code)best=Math.max(best,.99);\n return best;\n}\nfunction splitFlatNameCode(rawSuffix){\n let x=clean(rawSuffix);\n // Extract From File (PDF.js) deja al final \" 0.000.00\" (campos de la extrema izquierda/derecha).\n x=x.replace(/\\s+(?:(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{2})+\\s*$/,'').trim();\n let m=x.match(/^(.*?)(\\d{1,8})$/);\n if(m&&m[1].includes(','))return {name:clean(m[1]),code:m[2].replace(/^0+(?=\\d)/,'')};\n if(!x.includes(','))return {name:x,code:''};\n let best=null;\n for(let k=2;k<=Math.min(6,x.length-2);k++){\n const code=x.slice(-k).toUpperCase();if(!/^[A-Z]+$/.test(code))continue;\n const name=clean(x.slice(0,-k));if(!name.includes(','))continue;\n const score=alphaCodeScore(code,name)+k*.004;\n if(!best||score>best.score)best={name,code,score};\n }\n if(best&&best.score>=.87)return {name:best.name,code:best.code};\n return {name:x,code:''};\n}\nfunction dedupe(rows){const seen=new Set(),out=[];for(const r of rows){const k=[r.code,norm(r.name),round(r.ss).toFixed(2),round(r.se).toFixed(2),round(r.gross).toFixed(2),round(r.net).toFixed(2)].join('|');if(seen.has(k))continue;seen.add(k);out.push(r);}return out;}\nif(!text.trim())throw new Error(`Nómina Q1 PDF no contiene texto extraíble. Se detuvo el cruce para evitar un reporte incompleto: ${prep.fileName}`);\n\nconst periodMatch=text.match(/Periodo\\s+del\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})\\s+hasta\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})/i);\nconst period=periodMatch?{start:periodMatch[1],end:periodMatch[2]}:null;\nconst upper=text.toUpperCase(),gi=upper.lastIndexOf('TOTALES GENERALES'),tail=gi>=0?text.slice(gi):text;\nconst cm=tail.match(/Cantidad\\s+de\\s+Empleados:\\s*(\\d+)/i),printedCount=cm?Number(cm[1]):0;\n\n// MODO 1: texto tabular normal (PDF que conserva el orden visual izquierda → derecha).\nconst horizontal=[];\nfor(const rawLine of text.split('\\n')){\n const rate=rawLine.match(/\\d+\\.\\d{5,6}/);if(!rate)continue;\n const prefix=clean(rawLine.slice(0,rate.index)),rest=rawLine.slice((rate.index||0)+rate[0].length);\n const vals=(rest.match(/\\(?-?[\\d,]+(?:\\.\\d+)?\\)?/g)||[]).map(num).filter(v=>v!==null);\n if(vals.length!==23)continue;\n let code='',name=prefix;const pm=prefix.match(/^([A-Za-z0-9]{1,8})\\s+(.+)$/);\n if(pm){code=pm[1].replace(/^0+(?=\\d)/,'');name=clean(pm[2]);}\n if(!name.includes(',')||!/[A-ZÁÉÍÓÚÑ]/i.test(name))continue;\n const u=norm(name);if(/GOMEZLEE|DEPARTAMENTO|TOTALES|NOMBRE DEL EMPLEADO|PLANILLA|PERIODO/.test(u))continue;\n const gross=round(vals[13]),ss=round(vals[15]),se=round(vals[16]);if(!plausible(gross,ss,se))continue;\n horizontal.push({code,name,ss,se,gross,otherIncome:round(vals[12]),totalDeductions:round(vals[20]),net:round(vals[21]),source:'Nómina Q1',fileName:prep.fileName,period});\n}\n\n// MODO 2: salida REAL de n8n Extract From File / PDF.js para estas planillas.\n// PDF.js invierte las columnas y concatena montos sin espacios, por ejemplo:\n// Neto + Total Deduc. + Otras + ISR + S.E. + S.S. + Otros + Bruto + ... + tarifa + Nombre + Código.\n// El parser anterior suponía el orden visual y por eso perdía empleados y fallaba el conteo total.\nconst flat=[];\nfor(const rawLine of text.split('\\n')){\n const line=clean(rawLine);if(!line.includes(','))continue;\n const rates=[...line.matchAll(/(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{5,6}/g)];if(!rates.length)continue;\n const rate=rates[rates.length-1],left=line.slice(0,rate.index),suffix=line.slice((rate.index||0)+rate[0].length);\n const vals=fixed2Tokens(left);if(vals.length<8)continue;\n const nc=splitFlatNameCode(suffix),name=clean(nc.name),code=clean(nc.code).replace(/^0+(?=\\d)/,'');\n if(!name.includes(',')||!/[A-ZÁÉÍÓÚÑ]/i.test(name))continue;\n const u=norm(name);if(/GOMEZLEE|DEPARTAMENTO|TOTALES|NOMBRE DEL EMPLEADO|PLANILLA|PERIODO/.test(u))continue;\n let se=round(vals[4]),ss=round(vals[5]),gross=round(vals[7]),otherIncome=vals.length>8?round(vals[8]):0;\n // Compatibilidad defensiva si una futura versión de PDF.js conserva una columna adicional.\n const targetSS=Number(config.defaultRates?.ssRegular??.0975),targetSE=Number(config.defaultRates?.se??.0125);\n const fixedRateOK=gross<=0||(ss===0&&se===0)||(ss/gross>.03&&ss/gross<.20&&se/gross>.001&&se/gross<.05);\n if(!plausible(gross,ss,se)||!fixedRateOK){\n let best=null;\n for(let i=2;i20)||ss0<0||se0<0)continue;\n const rs=ss0/g,re=se0/g;if(rs>.30||re>.08)continue;\n const score=Math.abs(rs-targetSS)*10+Math.abs(re-targetSE)*20+Math.abs((j-i)-3)*.03;\n if(!best||score=0&&nums.length<36){const s=lines[j];if(!s){j--;continue;}if(moneyLine(s)){nums.push(num(s));j--;continue;}if(nums.length>=10)break;j--;}\n nums.reverse();if(nums.length<10)continue;let best=null;\n for(let k=Math.max(0,nums.length-32);k<=nums.length-8;k++){\n const [net,deducciones,otrasDesc,isr,se,ss,otrosIngresos,gross]=nums.slice(k,k+8);\n if([net,deducciones,se,ss,otrosIngresos,gross].some(v=>v===null)||!plausible(gross,ss,se))continue;\n const residual=Math.abs((gross+otrosIngresos-deducciones)-net);if(residual>1.25)continue;\n const score=residual+Math.abs(k-(nums.length-22))*.001;if(!best||score0)chosen=candidates.find(c=>c.rows.length===printedCount)||null;\nif(!chosen)chosen=[...candidates].sort((a,b)=>b.rows.length-a.rows.length)[0];\nconst out=chosen?.rows||[],parserMode=chosen?.mode||'SIN_PARSER';\nif(printedCount>0&&out.length!==printedCount)throw new Error(`Nómina Q1 PDF indica ${printedCount} empleados, pero ningún lector alcanzó ese total (tabular ${candidates[0].rows.length}, PDF.js ${candidates[1].rows.length}, vertical ${candidates[2].rows.length}). Se detuvo el cruce para no generar un reporte incompleto. Archivo: ${prep.fileName}`);\nif(!out.length)throw new Error(`No pude extraer empleados de Nómina Q1 PDF. El archivo no coincide con el formato de planilla esperado.`);\n\n// Control de cobertura monetaria e inferencia de tasas: no basta con contar nombres.\nconst grossPositive=out.filter(r=>Number(r.gross)>20).length;\nconst ssRatios=out.filter(r=>Number(r.gross)>20&&Number(r.ss)>0).map(r=>Number(r.ss)/Number(r.gross));\nconst seRatios=out.filter(r=>Number(r.gross)>20&&Number(r.se)>0).map(r=>Number(r.se)/Number(r.gross));\nconst minCoverage=Math.max(1,Math.floor(out.length*.70));\nif(grossPositive.05&&medSS<.15&&medSE>.004&&medSE<.03))throw new Error(`Nómina Q1 PDF superó el conteo, pero las tasas implícitas no son plausibles (S.S. ${((medSS||0)*100).toFixed(2)}%, S.E. ${((medSE||0)*100).toFixed(2)}%). Se detuvo para evitar un cruce incorrecto.`);\nreturn [{json:{fileName:prep.fileName,format:'PDF',parserMode,records:out,employeeCount:out.length,printedEmployeeCount:printedCount||null,missingCodeCount:out.filter(r=>!r.code).length,totalSS:round(out.reduce((s,r)=>s+r.ss,0)),totalSE:round(out.reduce((s,r)=>s+r.se,0)),period,stagingSpreadsheetId:'',integrityStatus:printedCount?'VALIDADO CONTRA TOTAL DEL PDF + COBERTURA MONETARIA':'VALIDADO POR COBERTURA MONETARIA'}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 61840, + 8752 + ], + "id": "71e64042-e6f8-46bc-b0b7-95c5becb8c74", + "name": "Parsear Q1", + "notesInFlow": true, + "notes": "Parser multiformato para nómina Q1: soporta tabla normal, salida PDF.js invertida/concatenada y extracción vertical. Valida el total impreso de empleados y cobertura monetaria antes de continuar." + }, + { + "parameters": { + "jsCode": "const base=$items('Restaurar archivos del formulario')[0];\nconst bin=base.binary||{};\nfunction norm(s){return String(s||'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase();}\nconst half=2;\nconst entries=Object.entries(bin).map(([key,file])=>{\n const name=String(file.fileName||key); const s=norm(`${key} ${name}`); const mime=norm(file.mimeType||'');\n let score=(s.includes('.pdf')||mime.includes('pdf'))?100:0;\n if(half===1 && /(1era|primera|1ra|q1|quincena\\s*1|1era\\s*quincena)/.test(s))score+=300;\n if(half===2 && /(2da|segunda|2da|q2|quincena\\s*2|2da\\s*quincena)/.test(s))score+=300;\n if(/css|detalleplanilla|extraordin|vacacion|liquidacion|xiii|decimo/.test(s))score-=250;\n return {key,file,name,score};\n}).sort((a,b)=>b.score-a.score);\nconst chosen=entries[0];\nif(!chosen||chosen.score<250) throw new Error(`No pude identificar la nómina de la ${half===1?'1era':'2da'} quincena en PDF.`);\nreturn [{json:{...base.json,fileName:chosen.name,originalBinaryKey:chosen.key,half},binary:{data:chosen.file}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 62080, + 8752 + ], + "id": "a84a05bc-0ccc-4f34-a4b5-b0ad7f73d2d3", + "name": "Preparar Q2" + }, + { + "parameters": { + "operation": "pdf", + "options": {} + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + 62320, + 8752 + ], + "id": "6aafefde-0b6f-458f-bf50-a65d5966089a", + "name": "Extraer texto Q2" + }, + { + "parameters": { + "jsCode": "const prep=$items('Preparar Q2')[0].json;\nconst item=$input.first();\nconst candidateStrings=Object.values(item.json||{}).filter(v=>typeof v==='string');\nconst raw=item.json?.text??item.json?.data??item.json?.content??candidateStrings.sort((a,b)=>b.length-a.length)[0]??'';\nconst text=String(raw).replace(/\\r/g,'');\nconst config=$items('Inicializar ejecución')[0]?.json?.config||{};\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction num(v){if(v===null||v===undefined||v==='')return null;let s=String(v).replace(/B\\/\\.?|\\$/gi,'').replace(/,/g,'').trim();let neg=/^\\(.*\\)$/.test(s);if(neg)s=s.slice(1,-1);if(!/^-?\\d+(?:\\.\\d+)?$/.test(s))return null;const n=Number(s);return Number.isFinite(n)?(neg?-n:n):null;}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction moneyLine(s){return /^[\\s$()\\-]*[\\d,]+(?:\\.\\d+)?\\s*$/.test(String(s||''));}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase();}\nfunction plausible(gross,ss,se){if(!Number.isFinite(gross)||!Number.isFinite(ss)||!Number.isFinite(se))return false;if(gross<0||ss<-.01||se<-.01)return false;if(gross>0&&(ss/gross>.30||se/gross>.08))return false;return true;}\nfunction median(a){const x=a.filter(Number.isFinite).sort((p,q)=>p-q);if(!x.length)return null;const m=Math.floor(x.length/2);return x.length%2?x[m]:(x[m-1]+x[m])/2;}\nfunction fixed2Tokens(s){return (String(s||'').match(/\\(?-?(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{2}\\)?/g)||[]).map(num).filter(v=>v!==null);}\nfunction sim(a,b){a=norm(a).replace(/[^A-Z]/g,'');b=norm(b).replace(/[^A-Z]/g,'');if(!a||!b)return 0;if(a===b)return 1;const prev=Array.from({length:b.length+1},(_,i)=>i);for(let i=1;i<=a.length;i++){const cur=[i];for(let j=1;j<=b.length;j++)cur[j]=Math.min(cur[j-1]+1,prev[j]+1,prev[j-1]+(a[i-1]===b[j-1]?0:1));for(let j=0;jt[0]||'').join('');\n if(initials===code||initials.split('').reverse().join('')===code)best=Math.max(best,.99);\n return best;\n}\nfunction splitFlatNameCode(rawSuffix){\n let x=clean(rawSuffix);\n // Extract From File (PDF.js) deja al final \" 0.000.00\" (campos de la extrema izquierda/derecha).\n x=x.replace(/\\s+(?:(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{2})+\\s*$/,'').trim();\n let m=x.match(/^(.*?)(\\d{1,8})$/);\n if(m&&m[1].includes(','))return {name:clean(m[1]),code:m[2].replace(/^0+(?=\\d)/,'')};\n if(!x.includes(','))return {name:x,code:''};\n let best=null;\n for(let k=2;k<=Math.min(6,x.length-2);k++){\n const code=x.slice(-k).toUpperCase();if(!/^[A-Z]+$/.test(code))continue;\n const name=clean(x.slice(0,-k));if(!name.includes(','))continue;\n const score=alphaCodeScore(code,name)+k*.004;\n if(!best||score>best.score)best={name,code,score};\n }\n if(best&&best.score>=.87)return {name:best.name,code:best.code};\n return {name:x,code:''};\n}\nfunction dedupe(rows){const seen=new Set(),out=[];for(const r of rows){const k=[r.code,norm(r.name),round(r.ss).toFixed(2),round(r.se).toFixed(2),round(r.gross).toFixed(2),round(r.net).toFixed(2)].join('|');if(seen.has(k))continue;seen.add(k);out.push(r);}return out;}\nif(!text.trim())throw new Error(`Nómina Q2 PDF no contiene texto extraíble. Se detuvo el cruce para evitar un reporte incompleto: ${prep.fileName}`);\n\nconst periodMatch=text.match(/Periodo\\s+del\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})\\s+hasta\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})/i);\nconst period=periodMatch?{start:periodMatch[1],end:periodMatch[2]}:null;\nconst upper=text.toUpperCase(),gi=upper.lastIndexOf('TOTALES GENERALES'),tail=gi>=0?text.slice(gi):text;\nconst cm=tail.match(/Cantidad\\s+de\\s+Empleados:\\s*(\\d+)/i),printedCount=cm?Number(cm[1]):0;\n\n// MODO 1: texto tabular normal (PDF que conserva el orden visual izquierda → derecha).\nconst horizontal=[];\nfor(const rawLine of text.split('\\n')){\n const rate=rawLine.match(/\\d+\\.\\d{5,6}/);if(!rate)continue;\n const prefix=clean(rawLine.slice(0,rate.index)),rest=rawLine.slice((rate.index||0)+rate[0].length);\n const vals=(rest.match(/\\(?-?[\\d,]+(?:\\.\\d+)?\\)?/g)||[]).map(num).filter(v=>v!==null);\n if(vals.length!==23)continue;\n let code='',name=prefix;const pm=prefix.match(/^([A-Za-z0-9]{1,8})\\s+(.+)$/);\n if(pm){code=pm[1].replace(/^0+(?=\\d)/,'');name=clean(pm[2]);}\n if(!name.includes(',')||!/[A-ZÁÉÍÓÚÑ]/i.test(name))continue;\n const u=norm(name);if(/GOMEZLEE|DEPARTAMENTO|TOTALES|NOMBRE DEL EMPLEADO|PLANILLA|PERIODO/.test(u))continue;\n const gross=round(vals[13]),ss=round(vals[15]),se=round(vals[16]);if(!plausible(gross,ss,se))continue;\n horizontal.push({code,name,ss,se,gross,otherIncome:round(vals[12]),totalDeductions:round(vals[20]),net:round(vals[21]),source:'Nómina Q2',fileName:prep.fileName,period});\n}\n\n// MODO 2: salida REAL de n8n Extract From File / PDF.js para estas planillas.\n// PDF.js invierte las columnas y concatena montos sin espacios, por ejemplo:\n// Neto + Total Deduc. + Otras + ISR + S.E. + S.S. + Otros + Bruto + ... + tarifa + Nombre + Código.\n// El parser anterior suponía el orden visual y por eso perdía empleados y fallaba el conteo total.\nconst flat=[];\nfor(const rawLine of text.split('\\n')){\n const line=clean(rawLine);if(!line.includes(','))continue;\n const rates=[...line.matchAll(/(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{5,6}/g)];if(!rates.length)continue;\n const rate=rates[rates.length-1],left=line.slice(0,rate.index),suffix=line.slice((rate.index||0)+rate[0].length);\n const vals=fixed2Tokens(left);if(vals.length<8)continue;\n const nc=splitFlatNameCode(suffix),name=clean(nc.name),code=clean(nc.code).replace(/^0+(?=\\d)/,'');\n if(!name.includes(',')||!/[A-ZÁÉÍÓÚÑ]/i.test(name))continue;\n const u=norm(name);if(/GOMEZLEE|DEPARTAMENTO|TOTALES|NOMBRE DEL EMPLEADO|PLANILLA|PERIODO/.test(u))continue;\n let se=round(vals[4]),ss=round(vals[5]),gross=round(vals[7]),otherIncome=vals.length>8?round(vals[8]):0;\n // Compatibilidad defensiva si una futura versión de PDF.js conserva una columna adicional.\n const targetSS=Number(config.defaultRates?.ssRegular??.0975),targetSE=Number(config.defaultRates?.se??.0125);\n const fixedRateOK=gross<=0||(ss===0&&se===0)||(ss/gross>.03&&ss/gross<.20&&se/gross>.001&&se/gross<.05);\n if(!plausible(gross,ss,se)||!fixedRateOK){\n let best=null;\n for(let i=2;i20)||ss0<0||se0<0)continue;\n const rs=ss0/g,re=se0/g;if(rs>.30||re>.08)continue;\n const score=Math.abs(rs-targetSS)*10+Math.abs(re-targetSE)*20+Math.abs((j-i)-3)*.03;\n if(!best||score=0&&nums.length<36){const s=lines[j];if(!s){j--;continue;}if(moneyLine(s)){nums.push(num(s));j--;continue;}if(nums.length>=10)break;j--;}\n nums.reverse();if(nums.length<10)continue;let best=null;\n for(let k=Math.max(0,nums.length-32);k<=nums.length-8;k++){\n const [net,deducciones,otrasDesc,isr,se,ss,otrosIngresos,gross]=nums.slice(k,k+8);\n if([net,deducciones,se,ss,otrosIngresos,gross].some(v=>v===null)||!plausible(gross,ss,se))continue;\n const residual=Math.abs((gross+otrosIngresos-deducciones)-net);if(residual>1.25)continue;\n const score=residual+Math.abs(k-(nums.length-22))*.001;if(!best||score0)chosen=candidates.find(c=>c.rows.length===printedCount)||null;\nif(!chosen)chosen=[...candidates].sort((a,b)=>b.rows.length-a.rows.length)[0];\nconst out=chosen?.rows||[],parserMode=chosen?.mode||'SIN_PARSER';\nif(printedCount>0&&out.length!==printedCount)throw new Error(`Nómina Q2 PDF indica ${printedCount} empleados, pero ningún lector alcanzó ese total (tabular ${candidates[0].rows.length}, PDF.js ${candidates[1].rows.length}, vertical ${candidates[2].rows.length}). Se detuvo el cruce para no generar un reporte incompleto. Archivo: ${prep.fileName}`);\nif(!out.length)throw new Error(`No pude extraer empleados de Nómina Q2 PDF. El archivo no coincide con el formato de planilla esperado.`);\n\n// Control de cobertura monetaria e inferencia de tasas: no basta con contar nombres.\nconst grossPositive=out.filter(r=>Number(r.gross)>20).length;\nconst ssRatios=out.filter(r=>Number(r.gross)>20&&Number(r.ss)>0).map(r=>Number(r.ss)/Number(r.gross));\nconst seRatios=out.filter(r=>Number(r.gross)>20&&Number(r.se)>0).map(r=>Number(r.se)/Number(r.gross));\nconst minCoverage=Math.max(1,Math.floor(out.length*.70));\nif(grossPositive.05&&medSS<.15&&medSE>.004&&medSE<.03))throw new Error(`Nómina Q2 PDF superó el conteo, pero las tasas implícitas no son plausibles (S.S. ${((medSS||0)*100).toFixed(2)}%, S.E. ${((medSE||0)*100).toFixed(2)}%). Se detuvo para evitar un cruce incorrecto.`);\nreturn [{json:{fileName:prep.fileName,format:'PDF',parserMode,records:out,employeeCount:out.length,printedEmployeeCount:printedCount||null,missingCodeCount:out.filter(r=>!r.code).length,totalSS:round(out.reduce((s,r)=>s+r.ss,0)),totalSE:round(out.reduce((s,r)=>s+r.se,0)),period,stagingSpreadsheetId:'',integrityStatus:printedCount?'VALIDADO CONTRA TOTAL DEL PDF + COBERTURA MONETARIA':'VALIDADO POR COBERTURA MONETARIA'}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 62560, + 8752 + ], + "id": "fcfd90b4-61c2-443c-a610-064bb07db6f9", + "name": "Parsear Q2", + "notesInFlow": true, + "notes": "Parser multiformato para nómina Q2: soporta tabla normal, salida PDF.js invertida/concatenada y extracción vertical. Valida el total impreso de empleados y cobertura monetaria antes de continuar." + }, + { + "parameters": { + "jsCode": "const base=$items('Restaurar archivos del formulario')[0]; const bin=base.binary||{};\nfunction norm(s){return String(s||'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase();}\nconst entries=Object.entries(bin).map(([key,file])=>{const name=String(file.fileName||key);const s=norm(`${key} ${name} ${file.mimeType||''}`);let score=/\\.xlsx?$|spreadsheet|excel/.test(s)?100:0;if(/planilla\\s*css|detalleplanilla|caja.*seguro|css/.test(s))score+=300;if(/nomina|quincena|extraordin|vacacion|liquidacion|xiii|decimo/.test(s))score-=200;return {key,file,name,score};}).sort((a,b)=>b.score-a.score);\nconst chosen=entries[0];if(!chosen||chosen.score<250)throw new Error('No pude identificar la Planilla CSS oficial (DetallePlanilla .xls/.xlsx).');\nreturn [{json:{...base.json,fileName:chosen.name,originalBinaryKey:chosen.key},binary:{data:chosen.file}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 62800, + 8752 + ], + "id": "cdb55a95-4963-4030-89bf-d5a77fdf7ca5", + "name": "Preparar CSS" + }, + { + "parameters": { + "method": "POST", + "url": "https://sheets.googleapis.com/v4/spreadsheets", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ properties: { title: `TEMP PA CSS - ${$('Inicializar ejecución').item.json.runId}` } }) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 63040, + 8752 + ], + "id": "fcf5d9d6-6195-4a95-b65a-1935da3129ae", + "name": "Crear staging CSS", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const staging=$input.first().json||{};const prep=$items('Preparar CSS')[0];const spreadsheetId=staging.spreadsheetId||staging.id;if(!spreadsheetId)throw new Error('No se pudo crear el staging CSS.');return [{json:{...prep.json,spreadsheetId},binary:prep.binary}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 63280, + 8752 + ], + "id": "43baf006-6f42-403f-8019-4bc75416d67e", + "name": "Reunir staging CSS" + }, + { + "parameters": { + "operation": "update", + "fileId": { + "__rl": true, + "value": "={{ $json.spreadsheetId }}", + "mode": "id" + }, + "changeFileContent": true, + "options": {} + }, + "type": "n8n-nodes-base.googleDrive", + "typeVersion": 3, + "position": [ + 63520, + 8752 + ], + "id": "04278b55-df9e-4ab3-ba48-d0b90a361656", + "name": "Convertir CSS a Google Sheets", + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + } + }, + { + "parameters": { + "amount": 2 + }, + "type": "n8n-nodes-base.wait", + "typeVersion": 1.1, + "position": [ + 63760, + 8752 + ], + "id": "e7cd3a5c-abda-447f-a73f-1285dcf30d80", + "name": "Esperar conversión CSS", + "webhookId": "d77109d7-6bec-474d-8199-f46a83387c8c" + }, + { + "parameters": { + "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $('Crear staging CSS').item.json.spreadsheetId }}?fields=spreadsheetId,sheets(properties(title,sheetId,hidden))", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 64000, + 8752 + ], + "id": "c9a2ef3c-1bb4-403b-8e1e-226ab9bbf4a4", + "name": "Listar hojas CSS", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const spreadsheetId=$json.spreadsheetId||$items('Crear staging CSS')[0].json.spreadsheetId;const sheets=(Array.isArray($json.sheets)?$json.sheets:[]).filter(s=>!s?.properties?.hidden);if(!sheets.length)throw new Error('No encontré hojas legibles en la Planilla CSS.');const ranges=sheets.map(s=>`'${String(s.properties.title).replace(/'/g,\"''\")}'!A1:AZ50000`);return [{json:{spreadsheetId,rangeQuery:ranges.map(r=>'ranges='+encodeURIComponent(r)).join('&')}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 64240, + 8752 + ], + "id": "60ced88f-8da7-4386-a382-4eb43a6c70c5", + "name": "Preparar rangos CSS" + }, + { + "parameters": { + "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.spreadsheetId }}/values:batchGet?{{ $json.rangeQuery }}&majorDimension=ROWS&valueRenderOption=UNFORMATTED_VALUE&dateTimeRenderOption=FORMATTED_STRING", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 64480, + 8752 + ], + "id": "80b2d9c0-f7ed-455b-88a2-c247257dafd6", + "name": "Leer CSS", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const payload=$json.body&&typeof $json.body==='object'?$json.body:$json;\nconst valueRanges=payload.valueRanges||[],prep=$items('Preparar CSS')[0].json;\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction asc(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase();}\nfunction num(v){if(v===null||v===undefined||v==='')return 0;if(typeof v==='number')return v;let s=String(v).replace(/B\\/.?|\\$|,/g,'').replace(/\\s/g,'');if(/^\\(.*\\)$/.test(s))s='-'+s.slice(1,-1);const n=Number(s);return Number.isFinite(n)?n:0;}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction monthPeriod(text){const months={ENERO:1,FEBRERO:2,MARZO:3,ABRIL:4,MAYO:5,JUNIO:6,JULIO:7,AGOSTO:8,SEPTIEMBRE:9,OCTUBRE:10,NOVIEMBRE:11,DICIEMBRE:12};const m=asc(text).match(/(ENERO|FEBRERO|MARZO|ABRIL|MAYO|JUNIO|JULIO|AGOSTO|SEPTIEMBRE|OCTUBRE|NOVIEMBRE|DICIEMBRE)\\s+(20\\d{2})/);return m?{month:months[m[1]],year:Number(m[2]),label:`${m[1][0]+m[1].slice(1).toLowerCase()} ${m[2]}`}:null;}\nfunction isHeader(row){const h=(row||[]).map(asc),j=h.join('|');return j.includes('SEGURO SOCIAL')&&j.includes('APELLIDO')&&j.includes('NOMBRE')&&(/CEDULA|PASAPORTE|REGISTRO CIVIL|IDENTIFICACION|DOCUMENTO/.test(j));}\nif(!valueRanges.length)throw new Error('La Planilla CSS no contiene hojas legibles.');\nconst ranked=valueRanges.map(vr=>({vr,count:(vr.values||[]).reduce((s,r)=>s+(isHeader(r)?1:0),0),rows:(vr.values||[]).length})).sort((a,b)=>b.count-a.count||b.rows-a.rows);\nconst chosen=ranked[0],values=chosen?.vr?.values||[];if(!values.length||chosen.count<1)throw new Error('No pude localizar la estructura de empleados dentro de la Planilla CSS oficial.');\nlet period=null;for(let r=0;rv==='SEGURO SOCIAL'),apIdx=header.findIndex(v=>v==='APELLIDO'),nomIdx=header.findIndex(v=>v==='NOMBRE'),sexIdx=header.findIndex(v=>v==='SEXO');let idIdx=header.findIndex(v=>/CEDULA|PASAPORTE|REGISTRO CIVIL|IDENTIFICACION|DOCUMENTO/.test(v));if(idIdx<0)idIdx=ssIdx+1;\n let dr=r+1;while(drclean(v)))dr++;if(dr>=end)continue;const data=values[dr]||[],identity=clean(data[idIdx]),ssNumber=clean(data[ssIdx]),lastname=clean(data[apIdx]),firstname=clean(data[nomIdx]);if(!identity&&!lastname&&!firstname)continue;\n let salary=0,salaryAdjusted=0,planillaSS=0,planillaSE=0,planillaDTM=0,section='';const components={};\n for(let k=dr+1;k/DECIMO TERCER MES|XIII MES/.test(k));const dtmBase=round(dk?components[dk]:0);\n raw.push({ssNumber,identity,docType:clean((values[r]||[])[idIdx]),lastname,firstname,name:clean(`${lastname} ${firstname}`),sex:sexIdx>=0?clean(data[sexIdx]):'',salary,salaryAdjusted,dtmBase,planillaSS,planillaSE,planillaDTM,components});\n}\nif(!raw.length)throw new Error('No pude extraer empleados de la Planilla CSS.');\nfunction docKey(e){return asc(e.identity||e.ssNumber).replace(/[^A-Z0-9]/g,'');}\nconst grouped=new Map();\nfor(const e of raw){const k=docKey(e)||`N:${asc(e.name)}`;if(!grouped.has(k))grouped.set(k,{...e,components:{...e.components},blocks:1});else{const g=grouped.get(k);g.blocks++;g.salary=round(g.salary+e.salary);g.salaryAdjusted=round(g.salaryAdjusted+e.salaryAdjusted);g.dtmBase=round(g.dtmBase+e.dtmBase);g.planillaSS=round(g.planillaSS+e.planillaSS);g.planillaSE=round(g.planillaSE+e.planillaSE);g.planillaDTM=round(g.planillaDTM+e.planillaDTM);for(const [ck,cv] of Object.entries(e.components||{}))g.components[ck]=round((g.components[ck]||0)+Number(cv||0));}}\nconst employees=[...grouped.values()];\nconst reconstructed={seguroSocial:round(raw.reduce((s,e)=>s+e.planillaSS,0)),seguroEducativo:round(raw.reduce((s,e)=>s+e.planillaSE,0)),dtm:round(raw.reduce((s,e)=>s+e.planillaDTM,0)),salario:round(raw.reduce((s,e)=>s+e.salary,0)),salarioAjustado:round(raw.reduce((s,e)=>s+e.salaryAdjusted,0))};\nfor(const k of ['seguroSocial','seguroEducativo','salario','salarioAjustado']){const diff=round(reconstructed[k]-Number(totals[k]||0));if(Math.abs(diff)>.10)throw new Error(`Fallo de integridad CSS: ${k} oficial ${totals[k]} vs reconstruido ${reconstructed[k]} (dif. ${diff}).`);}\nreturn [{json:{fileName:prep.fileName,period,periodoLabel:period.label,employees,employeeCount:employees.length,rawBlockCount:raw.length,totals,reconstructed,stagingSpreadsheetId:$items('Crear staging CSS')[0].json.spreadsheetId}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 64720, + 8752 + ], + "id": "343c866c-0822-4eff-8805-9c0c7540bf85", + "name": "Parsear CSS" + }, + { + "parameters": { + "jsCode": "const base=$items('Restaurar archivos del formulario')[0];\nconst bin=base.binary||{},out=[];\n\nfunction norm(s){\n return String(s||'')\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g,'')\n .toLowerCase()\n .replace(/\\s+/g,' ')\n .trim();\n}\n\nfunction categoryFromFileName(name){\n const s=norm(name);\n // Prioridad por tipo REAL del archivo. No usar el nombre del campo para pisar\n // un nombre de archivo inequívoco (ej. un PDF de Liquidación dentro de Vacaciones).\n if(/xiii|decimo\\s*tercer|d[eé]cimo\\s*tercer/.test(s)) return 'xiii';\n if(/extraordin/.test(s)) return 'extraordinaria';\n if(/liquidacion|liquidaciones|resumen\\s+por\\s+tipo\\s+de\\s+pago/.test(s)) return 'liquidaciones';\n if(/vacacion/.test(s)) return 'vacaciones';\n return '';\n}\n\nfunction categoryFromField(key){\n const s=norm(key);\n if(/extraordin/.test(s)) return 'extraordinaria';\n if(/liquidacion/.test(s)) return 'liquidaciones';\n if(/vacacion/.test(s)) return 'vacaciones';\n if(/xiii|decimo\\s*tercer|d[eé]cimo\\s*tercer/.test(s)) return 'xiii';\n return '';\n}\n\nfunction isRegularOrCss(key,name,mime){\n const s=norm(`${key} ${name} ${mime||''}`);\n return /quincena|nomina.*q|n[oó]mina.*quincena|detalleplanilla|planilla\\s*css/.test(s);\n}\n\n// Dedupe únicamente cuando el MISMO nombre de archivo y el mismo tamaño fueron\n// adjuntados más de una vez (por ejemplo, el mismo PDF en Vacaciones y Liquidaciones).\n// Las copias con nombres distintos se dejan pasar y se deduplican después por\n// transacción/huella, como exigen los reportes históricos.\nconst seenExact=new Map();\n\nfor(const [key,file] of Object.entries(bin)){\n const name=String(file.fileName||key);\n const mime=String(file.mimeType||'');\n const s=norm(`${name} ${mime}`);\n\n if(!(s.includes('.pdf')||s.includes('application/pdf'))) continue;\n if(isRegularOrCss(key,name,mime)) continue;\n\n const fromName=categoryFromFileName(name);\n const fromField=categoryFromField(key);\n const category=fromName||fromField;\n if(!category) continue;\n\n const bytes=Number(file.bytes||0);\n // Solo pre-deduplicar si el tamaño binario es conocido. Si falta metadata de tamaño,\n // es más seguro dejar pasar ambos y que el parser deduzca por transacción/huella.\n const exactKey=bytes>0?`${norm(name)}|${bytes}`:'';\n\n if(exactKey&&seenExact.has(exactKey)){\n const idx=seenExact.get(exactKey);\n out[idx].json.duplicateBinaryKeys.push(key);\n continue;\n }\n\n const fieldMismatch=!!(fromName&&fromField&&fromName!==fromField);\n const item={\n json:{\n category,\n fileName:name,\n originalBinaryKey:key,\n classificationSource:fromName?'NOMBRE_ARCHIVO':'CAMPO_FORMULARIO',\n fieldCategory:fromField||'',\n fieldMismatch,\n duplicateBinaryKeys:[]\n },\n binary:{data:file}\n };\n\n if(exactKey) seenExact.set(exactKey,out.length);\n out.push(item);\n}\n\nconst vac=out.filter(i=>i.json.category==='vacaciones').length;\nconst liq=out.filter(i=>i.json.category==='liquidaciones').length;\n\nif(!vac) throw new Error('No encontré una planilla de Vacaciones real entre los PDFs adjuntados.');\nif(!liq) throw new Error('No encontré una planilla de Liquidación real entre los PDFs adjuntados.');\n\nreturn out;" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 64960, + 8752 + ], + "id": "4585e2ba-9493-4868-8273-b290b62d60c9", + "name": "Separar complementarios", + "notesInFlow": true, + "notes": "v8: clasifica primero por el tipo real indicado en el nombre del archivo y usa el campo del formulario solo como fallback. Evita que una Liquidación cargada accidentalmente en Vacaciones se procese como vacaciones. Dedupe exacto por nombre+tamaño entre campos; las copias con nombres distintos se deduplican después por transacción/huella." + }, + { + "parameters": { + "operation": "pdf", + "options": {} + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + 65200, + 8752 + ], + "id": "dcc638b7-b20e-4b5f-be39-17a87f1108ed", + "name": "Extraer texto complementarios" + }, + { + "parameters": { + "jsCode": "const extracted=$input.all();\nconst metaItems=$items('Separar complementarios');\nconst css=$items('Parsear CSS')[0].json;\nconst init=($items('Inicializar ejecución')[0]||{}).json||{};\nconst cfg=init.config||{};\nconst rates=cfg.defaultRates||{ssRegular:0.0975,se:0.0125,ssDtm:0.0725};\nconst targetMonth=Number(css.period.month),targetYear=Number(css.period.year);\n\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'').toUpperCase().replace(/[^A-Z0-9 ]/g,' ').replace(/\\s+/g,' ').trim();}\nfunction num(v){\n if(v===null||v===undefined||v==='')return 0;\n let s=String(v).replace(/B\\/\\.?|\\$/gi,'').replace(/,/g,'').trim();\n if(/^\\(.*\\)$/.test(s))s='-'+s.slice(1,-1);\n const n=Number(s);return Number.isFinite(n)?n:0;\n}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction parseDMY(s){const m=String(s||'').match(/(\\d{1,2})\\/(\\d{1,2})\\/(20\\d{2})/);if(!m)return null;return new Date(Number(m[3]),Number(m[2])-1,Number(m[1]));}\nfunction dateKey(d){return d?`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`:'';}\nfunction inTarget(d){return !!(d&&d.getMonth()+1===targetMonth&&d.getFullYear()===targetYear);}\nfunction overlapTarget(a,b){\n if(!a||!b)return null;\n const dates=[a,b].sort((x,y)=>x-y),start=dates[0],end=dates[1];\n const first=new Date(targetYear,targetMonth-1,1),last=new Date(targetYear,targetMonth,0,23,59,59);\n return start<=last&&end>=first;\n}\nfunction markers(text,re){\n const out=[];let m;const rg=new RegExp(re.source,re.flags.includes('g')?re.flags:re.flags+'g');\n while((m=rg.exec(text))!==null)out.push({index:m.index,end:rg.lastIndex,match:m});\n return out;\n}\nfunction lastText(text,re){let m,last='';const rg=new RegExp(re.source,re.flags.includes('g')?re.flags:re.flags+'g');while((m=rg.exec(String(text||'')))!==null)last=m[1]||'';return clean(last);}\n\n// Lee un monto solamente cuando la relación etiqueta/valor es local: misma línea o\n// línea numérica inmediatamente contigua. Evita \"pescar\" números de otro concepto.\nfunction amountField(segment,labelRe){\n const lines=String(segment||'').replace(/\\r/g,'').split('\\n');\n let foundLabel=false,foundValue=false,value=0;\n for(let i=0;i=0&&!clean(lines[p]))p--;\n if(p>=0&&/^\\s*[-(]?\\$?\\s*[\\d,]+(?:\\.\\d+)?\\)?\\s*$/.test(lines[p])){value=round(num(lines[p]));foundValue=true;continue;}\n let n=i+1;while(nls)continue;\n const gap=ne<=ls?ls-ne:ns>=le?ns-le:999;\n push(nm[0],gap,ne<=ls?'same-before':'same-after');\n }\n for(const dir of [-1,1]){\n let seen=0,p=i;\n while(seen=lines.length)break;\n if(!clean(lines[p]))continue;\n seen++;\n const vals=lines[p].match(moneyRe)||[];\n for(let k=0;ka.cost-b.cost).slice(0,8)};\n}\nfunction bestMoneyField(segment,labelRe){\n const f=moneyFieldCandidates(segment,labelRe);\n const c=f.candidates[0];\n return {found:!!c,labelFound:f.labelFound,value:c?c.value:0,candidates:f.candidates};\n}\nfunction candidateValues(field,optional=false,max=4){\n const a=(field?.candidates||[]).slice(0,max);\n if(optional&&!a.some(x=>Math.abs(x.value)<0.001))a.push({value:0,cost:30,where:'default-zero'});\n if(!a.length&&optional)a.push({value:0,cost:0,where:'default-zero'});\n return a;\n}\nfunction scalarFieldNear(segment,labelRe,valueRe,maxNeighborLines=2){\n const lines=String(segment||'').replace(/\\r/g,'').split('\\n');\n const out=[];let labelFound=false;\n function collect(line,costBase,labelStart=null,labelEnd=null){\n const rg=new RegExp(valueRe.source,valueRe.flags.replace(/g/g,'')+'g');let m;\n while((m=rg.exec(line))!==null){\n const s=m.index,e=rg.lastIndex;\n if(labelStart!==null&&slabelStart)continue;\n const gap=labelStart===null?0:(e<=labelStart?labelStart-e:s>=labelEnd?s-labelEnd:999);\n out.push({value:clean(m[1]??m[0]),cost:costBase+gap});\n }\n }\n for(let i=0;i=lines.length)break;\n if(!clean(lines[p]))continue;\n seen++;collect(lines[p],20*seen);\n }\n }\n }\n }\n const seen=new Set(),vals=[];\n for(const c of out.sort((a,b)=>a.cost-b.cost)){if(c.value&&!seen.has(c.value)){seen.add(c.value);vals.push(c);}}\n return {labelFound,candidates:vals.slice(0,8),found:vals.length>0,value:vals[0]?.value||''};\n}\nfunction dateFieldNear(segment,labelRe){\n return scalarFieldNear(segment,labelRe,/(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})/);\n}\nfunction integerFieldNear(segment,labelRe){\n // IDs/transacciones de planilla observados son de 5+ dígitos. No aceptar años\n // (2026, 2025, etc.) como si fueran números de transacción.\n const f=scalarFieldNear(segment,labelRe,/\\b(\\d{5,12})\\b/);\n const candidates=(f.candidates||[]).filter(c=>{\n const n=Number(c.value);\n return Number.isFinite(n)&&!(n>=1900&&n<=2100);\n });\n return {labelFound:f.labelFound,candidates,found:candidates.length>0,value:candidates[0]?.value||''};\n}\n\n// Metadatos de liquidación requieren un tratamiento distinto a campos monetarios.\n// PDF.js puede separar o invertir valor/etiqueta. Esta rutina liga exclusivamente\n// FECHA DE LIQUIDACIÓN y NÚMERO DE TRANSACCIÓN, penalizando fechas de cabecera\n// (impresión/fecha de planilla) y descartando años como posibles transacciones.\nfunction liquidationMeta(segment,hints={}){\n const raw=String(segment||'').replace(/\\r/g,'');\n const lines=raw.split('\\n');\n const dateLabel=/FECHA\\s+DE\\s+LIQUIDACI[OÓ]N\\s*:?/i;\n const txnLabel=/N[uú]mero\\s+de\\s+Transacci[oó]n\\s*:?/i;\n const dateRe=/(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})/g;\n const txnRe=/\\b(\\d{5,12})\\b/g;\n const dateCandidates=[],txnCandidates=[];\n // Registrar las fechas de cabecera incluso si PDF.js separa la fecha de la etiqueta.\n // La fecha de impresión recibe una penalización fuerte; FECHA DE PLANILLA una menor,\n // porque puede coincidir legítimamente con una fecha efectiva de liquidación.\n const printDates=new Set(Array.from(hints.printDates||[])),planillaDates=new Set(Array.from(hints.planillaDates||[]));\n function collectHeaderDates(){\n for(let i=0;i/FECHA\\s+DE\\s+PLANILLA|IMPRESO|P[AÁ]GINA|PLANILLA\\s+N[UÚ]MERO/i.test(line)?200:0;\n // Una fecha de impresión nunca debe ganar como FECHA DE LIQUIDACIÓN, aun cuando\n // aparezca justo después de la etiqueta por reordenamiento de PDF.js.\n const dateValuePenalty=value=>printDates.has(value)?500:planillaDates.has(value)?25:0;\n const txnBadLine=line=>/FECHA\\s+DE\\s+PLANILLA|IMPRESO|P[AÁ]GINA|PLANILLA\\s+N[UÚ]MERO/i.test(line);\n function pushDate(value,cost,where,line){\n const d=parseDMY(value);if(!d)return;\n dateCandidates.push({value,cost:cost+headerPenalty(line||'')+dateValuePenalty(value),where});\n }\n function pushTxn(value,cost,where,line){\n const n=Number(value);if(!Number.isFinite(n)||n<10000||n>999999999999||n>=1900&&n<=2100)return;\n if(txnBadLine(line||''))cost+=150;\n txnCandidates.push({value:String(value),cost,where});\n }\n // Patrones directos sobre texto aplanado: cubren valor a la derecha o a la izquierda.\n const flat=clean(raw);\n const D='(\\\\d{1,2}\\\\/\\\\d{1,2}\\\\/20\\\\d{2})',T='(\\\\d{5,12})';\n for(const [re,kind] of [\n [new RegExp(`FECHA\\\\s+DE\\\\s+LIQUIDACI[OÓ]N\\\\s*:?\\\\s*${D}`,'i'),'date-forward'],\n [new RegExp(`${D}\\\\s*FECHA\\\\s+DE\\\\s+LIQUIDACI[OÓ]N\\\\s*:?`,'i'),'date-reverse']\n ]){const m=flat.match(re);if(m)pushDate(m[1],-120,kind,m[0]);}\n for(const [re,kind] of [\n [new RegExp(`N[uú]mero\\\\s+de\\\\s+Transacci[oó]n\\\\s*:?\\\\s*${T}`,'i'),'txn-forward'],\n [new RegExp(`${T}\\\\s*N[uú]mero\\\\s+de\\\\s+Transacci[oó]n\\\\s*:?`,'i'),'txn-reverse']\n ]){const m=flat.match(re);if(m)pushTxn(m[1],-120,kind,m[0]);}\n\n function collectAround(labelRe,type){\n for(let i=0;i=lines.length)continue;const ln=lines[p];\n if(type==='date'){\n const rg=new RegExp(dateRe.source,'g');let m;while((m=rg.exec(ln))!==null)pushDate(m[1],delta*25+(p===i?Math.abs(m.index-(line.search(labelRe)+10)):0),`line-${delta}`,ln);\n }else{\n const rg=new RegExp(txnRe.source,'g');let m;while((m=rg.exec(ln))!==null)pushTxn(m[1],delta*25+(p===i?Math.abs(m.index-(line.search(labelRe)+10)):0),`line-${delta}`,ln);\n }\n }\n }\n }\n }\n collectAround(dateLabel,'date');collectAround(txnLabel,'txn');\n function uniqueBest(arr){\n const m=new Map();for(const c of arr){if(!m.has(c.value)||c.costa.cost-b.cost);\n }\n const dates=uniqueBest(dateCandidates),txns=uniqueBest(txnCandidates);\n return {\n dateFound:dates.length>0,txnFound:txns.length>0,\n date:dates[0]?.value||'',txn:txns[0]?.value||'',\n dateCandidates:dates.slice(0,6),txnCandidates:txns.slice(0,6)\n };\n}\n\nfunction liquidationHeaderHints(text){\n const raw=String(text||'').replace(/\\r/g,''),lines=raw.split('\\n');\n const printDates=new Set(),planillaDates=new Set();\n // Fuente autoritativa de fecha de impresión: una fecha acompañada por hora.\n // No se toman fechas “cerca de Página”, porque en PDF.js esa vecindad puede incluir\n // la FECHA DE PLANILLA o la FECHA DE LIQUIDACIÓN de un empleado.\n const stampRe=/(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})\\s+\\d{1,2}:\\d{2}(?::\\d{2})?\\s*(?:AM|PM)?/gi;\n let m;while((m=stampRe.exec(raw))!==null)printDates.add(m[1]);\n for(let i=0;i0?round(se.value/rateSE):0;\n const expectedSS=round(regularBase*rateSS+dtmBase*rateDTM),expectedSE=round(regularBase*rateSE);\n const dSS=Math.abs(ss.value-expectedSS),dSE=Math.abs(se.value-expectedSE);\n const mathOk=dSS<=0.20&&dSE<=0.10;\n const score=dSS*100+dSE*140+(ss.cost+se.cost+dtm.cost+mej.cost)*.003+0.05;\n if(mathOk&&(!best||!best.mathOk||score0,labelFound:ssF.labelFound,value:ssVal,candidates:ssF.candidates},\n seF:{found:lists.se.length>0,labelFound:seF.labelFound,value:seVal,candidates:seF.candidates},\n regularF:{found:regularF.candidates.length>0,labelFound:regularF.labelFound,value:best?.reg?.value||0},\n commissionF:{found:commissionF.candidates.length>0,labelFound:commissionF.labelFound,value:best?.com?.value||0},\n salSobF:{found:salSobF.candidates.length>0,labelFound:salSobF.labelFound,value:best?.sob?.value||0},\n bpF:{found:bpF.candidates.length>0,labelFound:bpF.labelFound,value:best?.bp?.value||0},\n dtmF:{found:dtmF.candidates.length>0,labelFound:dtmF.labelFound,value:best?.dtm?.value||0},\n mejoraF:{found:mejoraF.candidates.length>0,labelFound:mejoraF.labelFound,value:best?.mej?.value||0},\n salBrutoF,otherTotalF,\n regularBase:best?.regularBase||0,dtmBase:best?.dtmBase||0,\n expectedSS:best?.expectedSS||0,expectedSE:best?.expectedSE||0,\n baseMethod:best?.baseMethod||'NO_VALIDADA',\n grossF:{found:true,labelFound:true,value:round((best?.regularBase||0)+(best?.dtmBase||0))},\n score:complete?200-(best?.score||0):-(best?.score||9999)\n };\n}\nfunction parseVacationCandidate(segment){\n const ssRaw=moneyFieldCandidates(segment,/Seg\\.\\s*Soc\\.?/i,3);\n const seRaw=moneyFieldCandidates(segment,/Seg\\.\\s*Edu\\.?/i,3);\n const grossRaw=moneyFieldCandidates(segment,/TOTAL\\s+BRUTO\\s*:?/i,3);\n const vacRaw=moneyFieldCandidates(segment,/\\bVacaciones\\b/i,3);\n const grossCandidates=[...grossRaw.candidates];\n for(const c of vacRaw.candidates)if(!grossCandidates.some(x=>Math.abs(x.value-c.value)<.001))grossCandidates.push({...c,cost:c.cost+5,where:'vacaciones-fallback'});\n grossCandidates.sort((a,b)=>a.cost-b.cost);\n\n const ssList=ssRaw.candidates.slice(0,8),seList=seRaw.candidates.slice(0,8),rawGList=grossCandidates.slice(0,12),positiveG=rawGList.filter(x=>x.value>0.001),gList=positiveG.length?positiveG:rawGList;\n const rateSS=Number(rates.ssRegular||0.0975),rateSE=Number(rates.se||0.0125);\n let best=null;\n // Ruta preferida: importe de vacaciones / Total Bruto explícito + S.S. + S.E.\n for(const g of gList)for(const ss of ssList)for(const se of seList){\n if(g.value<0)continue;\n const expectedSS=round(g.value*rateSS),expectedSE=round(g.value*rateSE);\n const dSS=Math.abs(ss.value-expectedSS),dSE=Math.abs(se.value-expectedSE);\n const mathOk=dSS<=0.20&&dSE<=0.10;\n const score=dSS*100+dSE*140+(g.cost+ss.cost+se.cost)*.003;\n if(!best||score0&&rateSE>0){\n for(const ss of ssList)for(const se of seList){\n if(ss.value<0||se.value<0)continue;\n const derived=[];\n if(ss.value>0)derived.push(ss.value/rateSS);\n if(se.value>0)derived.push(se.value/rateSE);\n if(ss.value===0&&se.value===0)derived.push(0);\n if(!derived.length)continue;\n // Probar ambas bases implícitas y su promedio. Luego preferir un monto etiquetado\n // cercano si existe, para conservar el centavo exacto impreso en el PDF.\n if(derived.length===2)derived.push((derived[0]+derived[1])/2);\n for(const dg of derived){\n if(!Number.isFinite(dg)||dg<0||dg>1000000)continue;\n const nearby=gList.filter(x=>Math.abs(x.value-dg)<=0.75).sort((a,b)=>Math.abs(a.value-dg)-Math.abs(b.value-dg)||a.cost-b.cost)[0];\n const g=nearby||{value:round(dg),cost:35,where:'derived-ss-se'};\n const expectedSS=round(g.value*rateSS),expectedSE=round(g.value*rateSE);\n const dSS=Math.abs(ss.value-expectedSS),dSE=Math.abs(se.value-expectedSE);\n const mathOk=dSS<=0.20&&dSE<=0.10;\n if(!mathOk)continue;\n // La base derivada desde S.S. y la derivada desde S.E. deben ser compatibles.\n // El margen de 1.00 cubre exclusivamente el redondeo a centavos de los aportes.\n const baseSS=ss.value>0?ss.value/rateSS:g.value,baseSE=se.value>0?se.value/rateSE:g.value;\n if(ss.value>0&&se.value>0&&Math.abs(baseSS-baseSE)>1.00)continue;\n const score=dSS*100+dSE*140+(ss.cost+se.cost+g.cost)*.003+0.08;\n if(!best||!best.mathOk||score0,labelFound:ssRaw.labelFound,value:best?.ss?.value||0,candidates:ssRaw.candidates},\n seF:{found:seList.length>0,labelFound:seRaw.labelFound,value:best?.se?.value||0,candidates:seRaw.candidates},\n grossF:{found:!!best?.g,labelFound:grossRaw.labelFound||vacRaw.labelFound,value:best?.g?.value||0,candidates:grossCandidates,derived:best?.baseMethod==='BASE_INFERIDA_DESDE_SS_SE'},\n expectedSS:best?.expectedSS||0,expectedSE:best?.expectedSE||0,\n baseMethod:best?.baseMethod||'NO_VALIDADA',\n score:best?(best.mathOk?180-best.score:-best.score):-9999\n };\n}\nfunction parseLiquidationCandidate(segment,hints={}){\n const meta=liquidationMeta(segment,hints);\n const dateF={found:meta.dateFound,labelFound:meta.dateFound,value:meta.date,candidates:meta.dateCandidates};\n const txnF={found:meta.txnFound,labelFound:meta.txnFound,value:meta.txn,candidates:meta.txnCandidates};\n const ssRaw=moneyFieldCandidates(segment,/Seg\\.\\s*Soc\\.?/i),seRaw=moneyFieldCandidates(segment,/Seg\\.\\s*Edu\\.?/i);\n const svRaw=moneyFieldCandidates(segment,/Salario,?\\s*Vac,?\\s*13\\s*Mes\\s*:?/i);\n const dtmRaw=moneyFieldCandidates(segment,/(?0?round(se.value/rateSE):0;\n const expectedSS=round(regularBase*rateSS+dtmBase*rateDTM),expectedSE=round(regularBase*rateSE);\n const dSS=Math.abs(ss.value-expectedSS),dSE=Math.abs(se.value-expectedSE);\n const mathOk=dSS<=0.20&&dSE<=0.10;\n const score=dSS*100+dSE*140+(ss.cost+se.cost+dtm.cost+mej.cost)*.003+0.05;\n if(mathOk&&(!best||!best.mathOk||score0?se.value/rateSE:0;\n if(!Number.isFinite(regularBaseRaw)||regularBaseRaw<-.5||regularBaseRaw>1000000)continue;\n const regularBase=round(Math.max(0,regularBaseRaw));\n const dtmRawAlg=rateDTM>0?(ss.value-regularBase*rateSS)/rateDTM:0;\n if(!Number.isFinite(dtmRawAlg)||dtmRawAlg<-.75||dtmRawAlg>1000000)continue;\n const dtmBase=round(Math.max(0,dtmRawAlg));\n const expectedSS=round(regularBase*rateSS+dtmBase*rateDTM),expectedSE=round(regularBase*rateSE);\n const dSS=Math.abs(ss.value-expectedSS),dSE=Math.abs(se.value-expectedSE);\n const mathOk=dSS<=0.20&&dSE<=0.10;\n if(!mathOk)continue;\n\n // Preferir una solución compatible con montos etiquetados si existen.\n let evidencePenalty=0;\n const labeledDtm=[...(dtmRaw.candidates||[]),...(mejoraRaw.candidates||[])].map(x=>Number(x.value)).filter(v=>Number.isFinite(v)&&v>=0);\n if(labeledDtm.length)evidencePenalty+=Math.min(...labeledDtm.map(v=>Math.abs(v-dtmBase)))*0.02;\n const totalBase=round(regularBase+dtmBase);\n const labeledSV=(svRaw.candidates||[]).map(x=>Number(x.value)).filter(v=>Number.isFinite(v)&&v>=0);\n if(labeledSV.length)evidencePenalty+=Math.min(...labeledSV.map(v=>Math.abs(v-totalBase)))*0.01;\n\n const score=dSS*100+dSE*140+(ss.cost+se.cost)*.003+0.15+evidencePenalty;\n if(!best||!best.mathOk||scoreMath.abs(a-totalBase)-Math.abs(b-totalBase))[0],cost:0}:{value:totalBase,cost:0},\n dtm:{value:dtmBase,cost:0},mej:{value:0,cost:0},com:{value:0,cost:0},\n regularBase,dtmBase,expectedSS,expectedSE,\n baseMethod:'BASES_INFERIDAS_DESDE_APORTES_SS_SE'\n };\n }\n }\n }\n const date=dateF.value||'',txn=txnF.value||'';\n // Para el cruce, la transacción sirve para deduplicar pero NO es necesaria para\n // reconstruir la base cotizable. Si falta, se usa una huella persona+fecha+montos.\n // Tampoco exigimos Total Bruto/Salario-Vac-13 si las bases quedaron validadas\n // matemáticamente desde S.S./S.E.\n const completeCore=!!date&&ssRaw.labelFound&&seRaw.labelFound;\n return {\n complete:completeCore&&!!best?.mathOk,structuralComplete:completeCore&&!!best?.mathOk,mathOk:!!best?.mathOk,\n mathDiff:best?Math.abs(best.ss.value-best.expectedSS)+Math.abs(best.se.value-best.expectedSE):9999,\n date,txn,dateF,txnF,meta,\n ssF:{found:ssList.length>0,labelFound:ssRaw.labelFound,value:best?.ss?.value||0,candidates:ssRaw.candidates},\n seF:{found:seList.length>0,labelFound:seRaw.labelFound,value:best?.se?.value||0,candidates:seRaw.candidates},\n sv13F:{found:svList.length>0,labelFound:svRaw.labelFound,value:best?.sv?.value||svList[0]?.value||0,candidates:svRaw.candidates},\n dtmF:{found:dtmRaw.candidates.length>0,labelFound:dtmRaw.labelFound,value:best?.dtm?.value||0,candidates:dtmRaw.candidates},\n mejoraF:{found:mejoraRaw.candidates.length>0,labelFound:mejoraRaw.labelFound,value:best?.mej?.value||0,candidates:mejoraRaw.candidates},\n commissionF:{found:comRaw.candidates.length>0,labelFound:comRaw.labelFound,value:best?.com?.value||0,candidates:comRaw.candidates},\n grossF:{...grossF,value:grossF.found?grossF.value:round((best?.regularBase||0)+(best?.dtmBase||0)),derived:!grossF.found},salaryRegF,vacF,\n regularBase:best?.regularBase||0,dtmBase:best?.dtmBase||0,\n expectedSS:best?.expectedSS||0,expectedSE:best?.expectedSE||0,\n baseMethod:best?.baseMethod||'NO_VALIDADA',\n score:completeCore?(best?.mathOk?200-(best?.score||0):-(best?.score||9999)):-9999\n };\n}\n\n// Determina la orientación del archivo usando TODOS sus empleados. En PDF.js los\n// importes pueden salir antes de \"Empleado\"; en orden visual salen después. Elegir\n// por archivo impide desplazar un empleado con los importes del siguiente.\nfunction chooseOrientation(text,ms,parser){\n const stats={before:{valid:0,math:0,score:0},after:{valid:0,math:0,score:0}};\n for(let j=0;jstats.after.valid?'before':'after',stats};\n if(stats.before.math!==stats.after.math)return {orientation:stats.before.math>stats.after.math?'before':'after',stats};\n if(Math.abs(stats.before.score-stats.after.score)>0.01)return {orientation:stats.before.score>stats.after.score?'before':'after',stats};\n const prefix=text.slice(0,ms[0].index);\n return {orientation:/TOTAL\\s+NETO|Sal\\.\\s*Neto/i.test(prefix)?'before':'after',stats};\n}\n\n// Periodo Libre robusto: primero exige la relación explícita etiqueta/fechas. Si PDF.js\n// rompe el \"al\" o coloca cada fecha en una línea distinta, usa únicamente las dos fechas\n// más cercanas a la etiqueta Periodo Libre dentro de una ventana corta. Nunca toma fechas\n// de Vacaciones Correspondientes si ya existe una pareja más próxima al rótulo.\nfunction makePeriodLibre(d1,d2){\n const a=parseDMY(d1),b=parseDMY(d2);if(!a||!b)return null;\n const arr=[a,b].sort((x,y)=>x-y),fmt=d=>`${String(d.getDate()).padStart(2,'0')}/${String(d.getMonth()+1).padStart(2,'0')}/${d.getFullYear()}`;\n return {start:dateKey(arr[0]),end:dateKey(arr[1]),display:`${fmt(arr[0])} - ${fmt(arr[1])}`,startDate:arr[0],endDate:arr[1]};\n}\nfunction periodLibreStrict(segment,prefer='forward'){\n const flat=clean(segment),D='(\\\\d{1,2}\\\\/\\\\d{1,2}\\\\/20\\\\d{2})';\n const forward=new RegExp(`Periodo\\\\s+Libre\\\\s*:?\\\\s*${D}\\\\s*(?:al|-)\\\\s*${D}`,'i');\n const reverse=new RegExp(`${D}\\\\s*(?:al|-)\\\\s*${D}\\\\s*Periodo\\\\s+Libre\\\\s*:?`,'i');\n let m=prefer==='reverse'?flat.match(reverse):flat.match(forward);\n if(!m)m=prefer==='reverse'?flat.match(forward):flat.match(reverse);\n return m?makePeriodLibre(m[1],m[2]):null;\n}\nfunction periodLibreRobust(segment,prefer='forward'){\n const flat=clean(segment),DS='(\\\\d{1,2}\\\\/\\\\d{1,2}\\\\/20\\\\d{2})';\n const forward=new RegExp(`Periodo\\\\s+Libre\\\\s*:?\\\\s*${DS}\\\\s*(?:al|-)\\\\s*${DS}`,'i');\n const reverse=new RegExp(`${DS}\\\\s*(?:al|-)\\\\s*${DS}\\\\s*Periodo\\\\s+Libre\\\\s*:?`,'i');\n const prefMatch=flat.match(prefer==='reverse'?reverse:forward);\n if(prefMatch)return makePeriodLibre(prefMatch[1],prefMatch[2]);\n\n const options=[],lr=/Periodo\\s+Libre\\s*:?/ig,D=/\\d{1,2}\\/\\d{1,2}\\/20\\d{2}/g;let lm;\n // El patrón del lado opuesto se conserva como último recurso, pero no puede ganar a\n // una pareja de fechas inmediatamente adyacente al lado que corresponde al extractor.\n const opposite=flat.match(prefer==='reverse'?forward:reverse);\n if(opposite)options.push({p:makePeriodLibre(opposite[1],opposite[2]),score:40});\n while((lm=lr.exec(flat))!==null){\n const ls=lm.index,le=lr.lastIndex;\n const before=flat.slice(Math.max(0,ls-150),ls),after=flat.slice(le,Math.min(flat.length,le+180));\n function dates(s){const out=[];let m;const rg=new RegExp(D.source,'g');while((m=rg.exec(s))!==null)out.push({value:m[0],index:m.index});return out;}\n let bd=dates(before),ad=dates(after);\n const corr=after.search(/Vacaciones\\s+Correspondientes/i);if(corr>=0)ad=ad.filter(x=>x.index=2){const a=ad[0],b=ad[1];options.push({p:makePeriodLibre(a.value,b.value),score:a.index+b.index+2+(prefer==='forward'?-100:20)});}\n if(bd.length>=2){const a=bd[bd.length-2],b=bd[bd.length-1];const dist=(before.length-b.index)+(before.length-a.index);options.push({p:makePeriodLibre(a.value,b.value),score:dist+3+(prefer==='reverse'?-100:20)});}\n }\n const valid=options.filter(x=>x.p).sort((a,b)=>a.score-b.score);return valid[0]?.p||null;\n}\nfunction vacationPageBlock(text,marker){\n const raw=String(text||''),heads=[];let m;const hr=/GOMEZLEE\\s+MARKETING/ig;while((m=hr.exec(raw))!==null)heads.push(m.index);\n let start=0,end=raw.length;\n for(const h of heads){if(h<=marker.index)start=h;else{end=h;break;}}\n const block=raw.slice(start,end);\n const employeeCount=(block.match(/Empleado\\s*#\\s*[A-Za-z0-9]{1,12}\\s*:/ig)||[]).length;\n return {text:block,employeeCount,start,end};\n}\nfunction samePerson(a,b){\n const ac=clean(a.code).replace(/^0+(?=\\d)/,''),bc=clean(b.code).replace(/^0+(?=\\d)/,'');\n if(ac&&bc&&ac===bc)return true;\n return !!(norm(a.name)&&norm(a.name)===norm(b.name));\n}\nfunction recordFingerprint(r){return [r.category,clean(r.code),norm(r.name),clean(r.date),clean(r.transaction),round(r.ss),round(r.se),round(r.gross),round(r.regularBase),round(r.dtmBase)].join('|');}\n\n// Segunda barrera: confirma el tipo REAL usando el encabezado/contenido extraído.\n// Es deliberadamente conservadora: solo reclasifica cuando el texto es inequívoco.\n// El orden importa porque Liquidaciones/Extraordinarias pueden contener \"Vacaciones\"\n// o \"XIII Mes\" como conceptos sin ser esos tipos de documento.\nfunction categoryFromContent(text){\n const u=norm(String(text||'').slice(0,12000));\n if(/PLANILLA DE LIQUIDACION/.test(u)) return 'liquidaciones';\n if(/PLANILLA DE VACACIONES/.test(u)) return 'vacaciones';\n if(/PLANILLA EXTRAORDINARIA/.test(u)) return 'extraordinaria';\n if(/PLANILLA(?: DE)? (?:XIII|DECIMO TERCER|DECIMO TERCER MES)/.test(u) ||\n /DECIMO TERCER MES.*PRIMERA PARTIDA/.test(u)) return 'xiii';\n if(/RESUMEN POR TIPO DE PAGO/.test(u)) return 'liquidaciones';\n return '';\n}\n\nconst records=[],validations=[],supportDocs=[],files=[];\nfunction addError(category,fileName,detail){validations.push({status:'ERROR',severity:'ERROR',category,fileName,detail});}\nfunction addWarning(category,fileName,detail){validations.push({status:'REVISAR',severity:'WARNING',category,fileName,detail});}\n\n// Índice independiente de personas que SÍ aparecen en una Planilla de Liquidación resumida.\n// Se construye directamente desde el texto de TODOS los PDFs y no depende de que el registro\n// ya haya sido incluido/deduplicado. Esto evita falsos errores cuando también se adjunta el\n// documento de cálculo de liquidación (que es soporte y nunca debe sumarse dos veces).\nconst rawSummaryPeople=[];\nfor(let ri=0;ri({code:clean(x.match[1]).replace(/^0+(?=\\d)/,''),name:clean(x.match[2]).replace(/\\s+(?:A[nñ]o|C[eé]dula:).*$/i,'')}));\n const seen=new Set(),support=[];for(const e of rawSupport){const k=`${e.code}|${norm(e.name)}`;if(!seen.has(k)){seen.add(k);support.push(e);}}\n supportDocs.push({fileName,employees:support});\n validations.push({status:'IGNORADO',severity:'INFO',category,fileName,detail:'Documento de cálculo/soporte detectado. No se suma para evitar duplicidad; la existencia de la planilla resumida se verifica de forma independiente.'});\n continue;\n }\n const ms=markers(text,/Empleado\\s*#\\s*([A-Za-z0-9]{1,12})\\s*:\\s*([^\\n\\r]+?)(?=\\s+(?:FECHA\\s+DE\\s+LIQUIDACI[OÓ]N|Clave\\s*ISR|N[uú]mero\\s+de\\s+Transacci[oó]n)|$)/i);\n if(!ms.length){addError(category,fileName,'El PDF parece una Planilla de Liquidación, pero no se pudieron detectar empleados.');continue;}\n const liqHints=liquidationHeaderHints(text),liqParser=seg=>parseLiquidationCandidate(seg,liqHints);\n const orient=chooseOrientation(text,ms,liqParser),orientation=orient.orientation;\n let parsed=0,includedCount=0,excludedCount=0;\n for(let j=0;j \"158 0.00 0.00\").\n // No dependemos de posiciones fijas: buscamos la pareja Base XIII / S.S. que\n // satisfaga matemáticamente la tasa 7.25%, y luego validamos contra el total general.\n if(!rows.length){\n const ls=text.replace(/\\r/g,'').split('\\n').map(clean);\n const namePos=[];\n for(let i=0;i((String(s||'').match(/\\(?-?(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{2}\\)?/g)||[]).map(num).filter(v=>v!==null));\n const chooseDtmPair=vals=>{\n if(!vals.length)return null;\n const allZero=vals.every(v=>Math.abs(Number(v)||0)<0.005);\n if(allZero)return {dtmBase:0,ss:0,score:0};\n let best=null;\n for(let bi=0;bi0)||base>1000000)continue;\n const expected=round(base*Number(rates.ssDtm||0.0725));\n const dup=vals.reduce((s,v)=>s+(Math.abs(Number(v)-base)<0.005?1:0),0);\n for(let si=0;sibase)continue;\n const diff=Math.abs(ss-expected);if(diff>0.12)continue;\n // La base XIII suele aparecer dos veces (XIII y Total), y S.S. antes de ella.\n const score=diff+(dup>=2?0:0.03)+(si=0?prev+2:0;\n // Si comienza un nuevo departamento, descarta los totales del departamento anterior.\n for(let j=segStart;js+r.dtmBase,0)),sumSS=round(rows.reduce((s,r)=>s+r.ss,0));\n const tgIndex=text.toUpperCase().lastIndexOf('TOTALES GENERALES');\n if(tgIndex>=0){\n const tgWindow=text.slice(Math.max(0,tgIndex-600),Math.min(text.length,tgIndex+600));\n const tv=(tgWindow.match(/\\(?-?(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{2}\\)?/g)||[]).map(num).filter(v=>v!==null);\n // La suma de S.S. por empleado puede diferir algunos centavos de aplicar 7.25%\n // al total por el redondeo individual. Por eso no inferimos el total por tasa:\n // comprobamos que las sumas reconstruidas aparezcan literalmente en la ventana\n // de TOTALES GENERALES, sin depender del orden de PDF.js.\n const hasD=tv.some(v=>Math.abs(Number(v)-sumD)<=0.20);\n const hasSS=tv.some(v=>Math.abs(Number(v)-sumSS)<=0.20);\n if(tv.length>=3&&(!hasD||!hasSS)){\n addError(category,fileName,`Integridad XIII Mes fallida: las sumas reconstruidas DTM/S.S. ${sumD}/${sumSS} no coinciden con TOTALES GENERALES del PDF.`);continue;\n }\n }\n records.push(...rows);\n validations.push({status:dateOk===false?'EXCLUIDO':'OK',severity:dateOk===false?'WARNING':'INFO',category,fileName,detail:`${rows.length} registro(s) XIII Mes validados por empleado con tasa DTM; DTM ${sumD}, S.S. ${sumSS}${printed?`; fecha ${printed}`:''}.`});\n\n } else {\n addError(category||'desconocida',fileName,'Categoría de PDF complementario no reconocida.');\n }\n}\n\n// Extraordinarias: una transacción solo puede representar un movimiento. Una copia\n// idéntica se excluye; una transacción repetida con persona o montos distintos detiene\n// el proceso porque sería inseguro elegir automáticamente.\nconst seenExtra=new Map();\nfor(const r of records){\n if(r.category!=='Extraordinaria'||!r.included)continue;\n const k=r.transaction?`T:${r.transaction}`:`F:${r.code||norm(r.name)}|${r.date}|${r.ss}|${r.se}|${r.gross}`;\n if(!seenExtra.has(k)){seenExtra.set(k,r);continue;}\n const prev=seenExtra.get(k);\n if(recordFingerprint(prev)===recordFingerprint(r)){r.included=false;r.reason=`Duplicado exacto de extraordinaria; ya incluido desde ${prev.fileName}`;}\n else addError('extraordinaria',r.fileName,`La transacción extraordinaria ${r.transaction||k} aparece con datos diferentes (${prev.name} / ${r.name}). Se detuvo para no deduplicar incorrectamente.`);\n}\n\n// Vacaciones: elimina copias exactas y, para el mismo empleado + mismo Periodo Libre,\n// conserva la planilla más reciente. Dos versiones contradictorias con el mismo número\n// de planilla se consideran error.\nconst seenVacExact=new Map();\nfor(const r of records){\n if(r.category!=='Vacaciones'||!r.included)continue;\n const person=r.code||norm(r.name),k=`${person}|${r.periodStart}|${r.periodEnd}|${r.ss}|${r.se}|${r.gross}`;\n if(seenVacExact.has(k)){r.included=false;r.reason=`Duplicado exacto de vacaciones; ya incluido desde ${seenVacExact.get(k).fileName}`;}\n else seenVacExact.set(k,r);\n}\nconst vacGroups=new Map();\nfor(const r of records){\n if(r.category!=='Vacaciones'||!r.included)continue;\n const k=`${r.code||norm(r.name)}|${r.periodStart}|${r.periodEnd}`;if(!vacGroups.has(k))vacGroups.set(k,[]);vacGroups.get(k).push(r);\n}\nfor(const arr of vacGroups.values()){\n if(arr.length<=1)continue;\n const byPlan=new Map();for(const r of arr){const p=Number(r.planillaNumber)||0;if(!byPlan.has(p))byPlan.set(p,[]);byPlan.get(p).push(r);}\n for(const [p,grp] of byPlan){if(p&&grp.length>1){const sig=new Set(grp.map(recordFingerprint));if(sig.size>1)addError('vacaciones',grp[0].fileName,`La planilla de vacaciones ${p} contiene versiones contradictorias para ${grp[0].name}.`);}}\n arr.sort((a,b)=>(Number(b.planillaNumber)||0)-(Number(a.planillaNumber)||0)||String(b.generatedAt).localeCompare(String(a.generatedAt)));\n for(let i=1;i=10000&&!(txnNum>=1900&&txnNum<=2100);\n if(validTxn){\n const k=`T:${r.transaction}`;\n if(!seenTxn.has(k)){seenTxn.set(k,r);continue;}\n const prev=seenTxn.get(k);\n if(recordFingerprint(prev)===recordFingerprint(r)){r.included=false;r.reason=`Duplicado exacto de liquidación; la transacción ${r.transaction} ya fue incluida desde ${prev.fileName}`;}\n else addError('liquidaciones',r.fileName,`La transacción de liquidación ${r.transaction} aparece con datos diferentes (${prev.name} / ${r.name}). Se detuvo para no descartar un movimiento válido por error.`);\n continue;\n }\n // Si el extractor no pudo recuperar una transacción confiable, deduplicar por huella\n // de empleado + fecha + aportes + bases. Nunca usar un año como ID.\n const k=`F:${r.code||norm(r.name)}|${r.date}|${r.ss}|${r.se}|${r.gross}|${r.regularBase}|${r.dtmBase}`;\n if(seenLiqFallback.has(k)){r.included=false;r.reason=`Duplicado exacto de liquidación sin transacción; ya incluido desde ${seenLiqFallback.get(k).fileName}`;}\n else seenLiqFallback.set(k,r);\n}\n\n// XIII Mes: copias exactas por persona/fecha/base/S.S.\nconst seenXiii=new Map();\nfor(const r of records){\n if(r.category!=='XIII Mes'||!r.included)continue;\n const k=`${r.code||norm(r.name)}|${r.date}|${r.dtmBase}|${r.ss}`;\n if(seenXiii.has(k)){r.included=false;r.reason=`Duplicado exacto XIII Mes; ya incluido desde ${seenXiii.get(k).fileName}`;}\n else seenXiii.set(k,r);\n}\n\n// Los documentos de cálculo son SOPORTE, no fuente de montos para el cruce.\n// Comprobamos su cobertura contra (1) todos los registros resumidos parseados y (2) el índice\n// crudo de empleados detectados directamente en cualquier Planilla de Liquidación.\n// Una ausencia se reporta como REVISAR en el informe, pero NO tumba la ejecución: de esta forma\n// el usuario obtiene el reporte y puede ver exactamente qué documento requiere revisión.\nconst allSummaryLiq=records.filter(r=>r.category==='Liquidación');\nfor(const doc of supportDocs){\n const missing=[];\n for(const e of doc.employees||[]){\n const foundParsed=allSummaryLiq.some(r=>samePerson(e,r));\n const foundRaw=rawSummaryPeople.some(r=>samePerson(e,r));\n if(!foundParsed&&!foundRaw)missing.push(`${e.name}${e.code?` (#${e.code})`:''}`);\n }\n if(missing.length){\n addWarning('liquidaciones',doc.fileName,`Documento de cálculo detectado sin una planilla resumida localizada para: ${missing.join(', ')}. El documento de cálculo no se sumó; revisar que la planilla resumida haya sido adjuntada.`);\n }else{\n validations.push({status:'OK',severity:'INFO',category:'liquidaciones',fileName:doc.fileName,detail:'Documento de cálculo vinculado a su planilla resumida. Se usa solo como soporte y no se suma, evitando duplicidad.'});\n }\n}\n\nconst includedRecords=records.filter(r=>r.included),catCount=cat=>includedRecords.filter(r=>r.category===cat).length;\nconst reviewValidations=validations.filter(v=>v.status==='REVISAR'||v.status==='EXCLUIDO');\nconst RESULT={\n records,validations,supportDocs,includedRecords,files:[...new Set(files)],\n quality:{status:reviewValidations.length?'REVISAR':'VALIDADO',reviewCount:reviewValidations.length,rawLiquidationSummaryPeople:rawSummaryPeople.length},\n counts:{extraordinarias:catCount('Extraordinaria'),vacaciones:catCount('Vacaciones'),liquidaciones:catCount('Liquidación'),xiii:catCount('XIII Mes')},\n totals:{\n ss:round(includedRecords.reduce((s,r)=>s+r.ss,0)),se:round(includedRecords.reduce((s,r)=>s+r.se,0)),\n extraSS:round(includedRecords.filter(r=>r.category==='Extraordinaria').reduce((s,r)=>s+r.ss,0)),\n vacSS:round(includedRecords.filter(r=>r.category==='Vacaciones').reduce((s,r)=>s+r.ss,0)),\n liqSS:round(includedRecords.filter(r=>r.category==='Liquidación').reduce((s,r)=>s+r.ss,0)),\n xiiiSS:round(includedRecords.filter(r=>r.category==='XIII Mes').reduce((s,r)=>s+r.ss,0))\n }\n};\nconst hardErrors=(RESULT.validations||[]).filter(v=>v.severity==='ERROR');\nif(hardErrors.length)throw new Error('No se puede continuar: '+hardErrors.map(v=>`${v.fileName}: ${v.detail}`).join(' | '));\nreturn [{json:RESULT}];\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 65440, + 8752 + ], + "id": "6934bdd1-99b7-48ee-9171-0b70654e257a", + "name": "Parsear complementarios", + "notesInFlow": true, + "notes": "v8: además del parser robusto v7, confirma el tipo real de cada PDF por contenido antes de procesarlo. Una Planilla de Liquidación nunca se interpreta como Vacaciones aunque haya sido cargada en ese campo. Mantiene período efectivo, validación S.S./S.E., deduplicación y reglas históricas Mayo/Junio/Julio." + }, + { + "parameters": { + "jsCode": "const init=$items('Inicializar ejecución')[0].json;\nconst q1=$items('Parsear Q1')[0].json,q2=$items('Parsear Q2')[0].json;\nconst css=$items('Parsear CSS')[0].json,pdfs=$items('Parsear complementarios')[0].json;\nconst bambooRaw=$items('Generar reporte completo BambooHR')[0].json||{};\nconst rates=(init.config&&init.config.defaultRates)||{ssRegular:0.0975,se:0.0125,ssDtm:0.0725};\n\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'').toUpperCase().replace(/\\bVASQUEZ\\b/g,'VAZQUEZ').replace(/[^A-Z0-9 ]/g,' ').replace(/\\s+/g,' ').trim();}\nconst particles=new Set(['DE','DEL','LA','LAS','LOS','Y','DA','DOS']);\nfunction tokens(v){const a=norm(v).split(' ').filter(t=>t&&!particles.has(t));return [...new Set(a)];}\nfunction prepName(v){\n const raw=clean(v),n=norm(v),t=tokens(v),bag=t.slice().sort().join(' ');\n return {raw,n,t,bag,compact:bag.replace(/ /g,''),set:new Set(t)};\n}\nfunction levRaw(a,b){\n a=String(a||'');b=String(b||'');if(a===b)return 0;if(!a.length)return b.length;if(!b.length)return a.length;\n if(a.length>b.length){const z=a;a=b;b=z;}\n let prev=new Uint16Array(a.length+1),cur=new Uint16Array(a.length+1);\n for(let i=0;i<=a.length;i++)prev[i]=i;\n for(let j=1;j<=b.length;j++){\n cur[0]=j;const bj=b.charCodeAt(j-1);\n for(let i=1;i<=a.length;i++)cur[i]=Math.min(cur[i-1]+1,prev[i]+1,prev[i-1]+(a.charCodeAt(i-1)===bj?0:1));\n const z=prev;prev=cur;cur=z;\n }\n return prev[a.length];\n}\nfunction simRaw(a,b){if(!a||!b)return 0;return 1-levRaw(a,b)/Math.max(a.length,b.length);}\nfunction exactShared(A,B){let n=0;for(const t of A.t)if(t.length>=3&&B.set.has(t))n++;return n;}\nfunction nameScoreP(A,B){\n if(!A.t.length||!B.t.length)return 0;\n if(A.bag&&A.bag===B.bag)return 1;\n const avg=(X,Y)=>X.reduce((s,x)=>{\n let best=0;for(const y of Y){if(x===y){best=1;break;}const z=simRaw(x,y);if(z>best)best=z;}return s+best;\n },0)/X.length;\n const cov=(avg(A.t,B.t)+avg(B.t,A.t))/2;\n const bagSim=simRaw(A.compact,B.compact);\n const shared=exactShared(A,B)/Math.max(1,Math.min(A.t.length,B.t.length));\n return Math.min(1,.60*cov+.24*bagSim+.16*shared);\n}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction numDate(s){const m=String(s||'').match(/(\\d{1,2})\\/(\\d{1,2})\\/(20\\d{2})/);return m?{d:+m[1],m:+m[2],y:+m[3]}:null;}\nfunction validateQ(q,label,half){\n const a=numDate(q.period?.start),b=numDate(q.period?.end);if(!a||!b)throw new Error(`${label}: período ilegible.`);\n if(a.m!==css.period.month||b.m!==css.period.month||a.y!==css.period.year||b.y!==css.period.year)throw new Error(`${label} pertenece a ${q.period.start} - ${q.period.end}, pero la Planilla CSS es ${css.periodoLabel}. Se detuvo el cruce para evitar falsos positivos.`);\n if(half===1&&(a.d>2||b.d<13||b.d>16))throw new Error(`${label} no parece ser la 1era quincena.`);\n if(half===2&&(a.d<14||b.d<27))throw new Error(`${label} no parece ser la 2da quincena.`);\n}\nvalidateQ(q1,'Nómina Q1',1);validateQ(q2,'Nómina Q2',2);\n\nfunction docKey(v){return norm(v).replace(/[^A-Z0-9]/g,'');}\n\n// Defensa adicional: ningún movimiento con aportes puede llegar al cruce con base cero\n// ni con una combinación S.S./S.E. incompatible con base regular + DTM.\nfor(const r of pdfs.includedRecords||[]){\n const reg=Number(r.regularBase)||0,dtm=Number(r.dtmBase)||0,ss=Number(r.ss)||0,se=Number(r.se)||0;\n if((Math.abs(ss)>.01||Math.abs(se)>.01)&®+dtm<=0)throw new Error(`${r.fileName}: ${r.category} de ${r.name} tiene S.S./S.E. pero base cotizable 0. Se detuvo el cruce para no generar una diferencia falsa.`);\n const expSS=round(reg*Number(rates.ssRegular)+dtm*Number(rates.ssDtm)),expSE=round(reg*Number(rates.se));\n if(Math.abs(ss-expSS)>.25||Math.abs(se-expSE)>.12)throw new Error(`${r.fileName}: ${r.category} de ${r.name} no supera la validación de aportes (S.S. ${ss}/${expSS}; S.E. ${se}/${expSE}).`);\n}\n\n// BambooHR: Active + Inactive. Se usa TODO BambooHR para no depender de que Location/Country\n// estén llenos; Panamá solo aporta un pequeño desempate.\nconst bamboo=Array.isArray(bambooRaw.employees)?bambooRaw.employees:(Array.isArray(bambooRaw.data?.employees)?bambooRaw.data.employees:[]);\nif(!bamboo.length)throw new Error('BambooHR no devolvió empleados.');\nfunction bName(e){return clean(e.displayName||`${e.firstName||''} ${e.middleName||''} ${e.lastName||''}`);}\nconst bmeta=bamboo.map((e,i)=>({i,e,name:bName(e),p:prepName(bName(e)),panama:/PANAMA/.test(norm(`${e.location||''} ${e.country||''} ${e.division||''}`)),doc:docKey(e.employeeNumber)}));\n\n// Consolidar nómina + movimientos por persona.\nconst groups=new Map();\nfunction addRecord(r,kind){\n if(!r)return;\n const code=clean(r.code).replace(/^0+(?=\\d)/,''),nn=norm(r.name);\n const key=code&&/^\\d+$/.test(code)?`C:${code}`:`N:${nn}`;\n if(!groups.has(key))groups.set(key,{key,code,name:r.name||'',names:new Set(),salaryBase:0,dtmBase:0,totalSS:0,totalSE:0,q1:0,q2:0,extra:0,extraDtm:0,vac:0,liq:0,liqDtm:0,xiii:0,details:[]});\n const g=groups.get(key);if(r.name)g.names.add(r.name);g.totalSS+=Number(r.ss)||0;g.totalSE+=Number(r.se)||0;\n if(kind==='q1'){g.q1+=Number(r.gross)||0;g.salaryBase+=Number(r.gross)||0;}\n else if(kind==='q2'){g.q2+=Number(r.gross)||0;g.salaryBase+=Number(r.gross)||0;}\n else if(kind==='extra'){const a=Number(r.regularBase)||0,d=Number(r.dtmBase)||0;g.extra+=a;g.extraDtm+=d;g.salaryBase+=a;g.dtmBase+=d;}\n else if(kind==='vac'){const a=Number(r.regularBase??r.gross)||0;g.vac+=a;g.salaryBase+=a;}\n else if(kind==='liq'){const a=Number(r.regularBase)||0,d=Number(r.dtmBase)||0;g.liq+=a;g.liqDtm+=d;g.salaryBase+=a;g.dtmBase+=d;}\n else if(kind==='xiii'){const d=Number(r.dtmBase)||0;g.xiii+=d;g.dtmBase+=d;}\n g.details.push({kind,...r});\n}\nfor(const r of q1.records||[])addRecord(r,'q1');\nfor(const r of q2.records||[])addRecord(r,'q2');\nfor(const r of pdfs.includedRecords||[]){\n if(r.category==='Extraordinaria')addRecord(r,'extra');\n else if(r.category==='Vacaciones')addRecord(r,'vac');\n else if(r.category==='Liquidación')addRecord(r,'liq');\n else if(r.category==='XIII Mes')addRecord(r,'xiii');\n}\n// Une código truncado/no numérico con código normal cuando el nombre exacto coincide.\nconst mergedByName=new Map();\nfor(const g0 of groups.values()){\n const nn=norm([...g0.names][0]||g0.name)||g0.key;\n if(!mergedByName.has(nn)){mergedByName.set(nn,{...g0,names:new Set(g0.names),details:[...g0.details]});continue;}\n const a=mergedByName.get(nn),b=g0;\n for(const k of ['salaryBase','dtmBase','totalSS','totalSE','q1','q2','extra','extraDtm','vac','liq','liqDtm','xiii'])a[k]+=b[k];\n for(const x of b.names)a.names.add(x);a.details.push(...b.details);\n if((!a.code||!/^\\d+$/.test(a.code))&&b.code&&/^\\d+$/.test(b.code))a.code=b.code;\n}\nconst sources=[...mergedByName.values()].map((g,si)=>({\n ...g,si,name:[...g.names][0]||g.name,p:prepName([...g.names][0]||g.name),\n salaryBase:round(g.salaryBase),dtmBase:round(g.dtmBase),totalSS:round(g.totalSS),totalSE:round(g.totalSE),\n q1:round(g.q1),q2:round(g.q2),extra:round(g.extra),extraDtm:round(g.extraDtm),vac:round(g.vac),liq:round(g.liq),liqDtm:round(g.liqDtm),xiii:round(g.xiii)\n}));\n\n// Índices de candidatos: reducen ~millones de Levenshtein a unos pocos miles.\nfunction pushIndex(map,key,id){if(!key)return;if(!map.has(key))map.set(key,[]);map.get(key).push(id);}\nfunction buildNameIndex(meta){\n const byBag=new Map(),byToken=new Map(),byPrefix3=new Map(),byInitials=new Map();\n for(let i=0;i=3)pushIndex(byPrefix3,t.slice(0,3),i);}\n pushIndex(byInitials,p.t.map(t=>t[0]).sort().join(''),i);\n }\n return {byBag,byToken,byPrefix3,byInitials};\n}\nfunction gatherNameCandidates(P,index,limit=100){\n const out=new Set();\n const add=arr=>{if(!arr)return;for(const x of arr){out.add(x);if(out.size>=limit)return;}};\n add(index.byBag.get(P.bag));if(out.size>=limit)return out;\n const tokenLists=P.t.map(t=>({t,a:index.byToken.get(t)||[]})).sort((a,b)=>a.a.length-b.a.length);\n for(const z of tokenLists.slice(0,3)){if(z.a.length<=250)add(z.a);if(out.size>=limit)break;}\n if(out.size=3)add(index.byPrefix3.get(t.slice(0,3)));if(out.size>=limit)break;}\n if(out.sizet[0]).sort().join('')));\n return out;\n}\nconst bIndex=buildNameIndex(bmeta);\nconst bambooByDoc=new Map();\nfor(const m of bmeta)if(m.doc&&!bambooByDoc.has(m.doc))bambooByDoc.set(m.doc,m.e);\n\n// Fuente -> BambooHR con candidatos indexados.\nconst directB=new Map();\nfor(let si=0;sibest.sc){second=best;best=z;}else if(!second||z.sc>second.sc)second=z;\n }\n const exactUnique=(bIndex.byBag.get(s.p.bag)||[]).length===1;\n if(best&&(exactUnique&&best.sc>=.90 || best.sc>=.86&&((best.sc-(second?.sc||0))>=.025||best.sc>=.965)))directB.set(si,best);\n}\n\n// CSS metadata + índices.\nconst pmeta=(css.employees||[]).map((p,pi)=>({pi,p,pn:prepName(p.name),salary:Number(p.salaryAdjusted||0),dtm:Number(p.dtmBase||0),doc:docKey(p.identity||p.ssNumber)}));\nconst cssByDoc=new Map();for(const m of pmeta)if(m.doc&&!cssByDoc.has(m.doc))cssByDoc.set(m.doc,m.pi);\nconst cssNameIndex=buildNameIndex(pmeta.map(m=>({p:m.pn})));\nconst byAmount=new Map();for(const m of pmeta){const k=Math.round(m.salary);pushIndex(byAmount,k,m.pi);}\n\nfunction cssCandidates(s){\n const out=gatherNameCandidates(s.p,cssNameIndex,100);\n const k=Math.round(s.salaryBase);\n for(let d=-2;d<=2;d++){const a=byAmount.get(k+d)||[];for(const x of a){out.add(x);if(out.size>=140)break;}}\n if(out.size<8){\n // Rescate barato: solo distancia numérica, sin fuzzy, y únicamente 12 candidatos.\n const close=pmeta.map(m=>({pi:m.pi,d:Math.abs(s.salaryBase-m.salary)+.2*Math.abs(s.dtmBase-m.dtm)})).sort((a,b)=>a.d-b.d).slice(0,12);\n for(const x of close)out.add(x.pi);\n }\n return out;\n}\n\n// 1) Link de máxima confianza: Bamboo Employee # ↔ documento CSS.\nconst usedS=new Set(),usedP=new Set(),matches=[];\nfor(const [si,bm] of directB){\n const pi=cssByDoc.get(docKey(bm.e.employeeNumber));\n if(pi===undefined||usedP.has(pi))continue;\n const s=sources[si],pm=pmeta[pi],ns=nameScoreP(s.p,pm.pn);\n usedS.add(si);usedP.add(pi);\n matches.push({si,pi,source:s,plan:pm.p,method:'BambooHR Employee # ↔ documento CSS',confidence:'ALTA',identityReview:ns<.72,bamboo:bm.e,ns});\n}\n\n// 2) Resto: fuzzy solo sobre pools indexados.\nconst cand=[];\nfor(let si=0;si=.90){tier=2;method='Nombre de alta confianza';}\n else if(ns>=.78&&d<=2){tier=3;method='Nombre + monto';}\n else if(shared>=1&&d<=.15){tier=4;method='Token de nombre + monto exacto';}\n else if(shared>=1&&d<=1&&dd<=.15){tier=5;method='Nombre parcial + salario/DTM';}\n if(!tier)continue;\n const score=(6-tier)*100+ns*10-Math.min(d,20)*.03-Math.min(dd,20)*.01;\n cand.push({si,pi,tier,score,ns,d,dd,method});\n }\n}\ncand.sort((a,b)=>a.tier-b.tier||b.score-a.score);\nfor(const c of cand){\n if(usedS.has(c.si)||usedP.has(c.pi))continue;\n const s=sources[c.si],pm=pmeta[c.pi],viaDoc=bambooByDoc.get(pm.doc)||null,direct=directB.get(c.si)?.e||null;\n usedS.add(c.si);usedP.add(c.pi);\n matches.push({...c,source:s,plan:pm.p,confidence:c.tier<=2?'ALTA':c.tier<=3?'MEDIA':'REVISAR',identityReview:c.tier>=4||c.ns<.72,bamboo:viaDoc||direct});\n}\nconst onlySource=sources.filter((_,i)=>!usedS.has(i)),onlyPlan=(css.employees||[]).filter((_,i)=>!usedP.has(i));\n\nfunction conceptSource(s){\n const a=[];for(const [lab,v] of [['Q1',s.q1],['Q2',s.q2],['Extraordinaria regular',s.extra],['Extraordinaria DTM',s.extraDtm],['Vacaciones',s.vac],['Liquidación base',s.liq],['Liquidación DTM',s.liqDtm],['XIII Mes',s.xiii]])if(Math.abs(v)>.005)a.push(`${lab}: ${v.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}`);return a.join(' | ');\n}\nfunction titleCase(s){return String(s||'').toLowerCase().replace(/(^|\\s)\\S/g,m=>m.toUpperCase());}\nfunction conceptCss(p){const a=[];for(const [k,v] of Object.entries(p.components||{})){if(Math.abs(Number(v)||0)>.005)a.push(`${titleCase(k)}: ${Number(v).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}`);}return a.join(' | ');}\n\nconst rows=[];let impactCount=0,identityCount=0;\nconst salaryTol=Number(init.salaryTolerance??.10),dtmTol=Number(init.dtmTolerance??.10);\nfor(const m of matches){\n const s=m.source,p=m.plan,dSal=round(s.salaryBase-Number(p.salary||0)),dAdj=round(s.salaryBase-Number(p.salaryAdjusted||0)),dDtm=round(s.dtmBase-Number(p.dtmBase||0));\n const salaryDiff=Math.abs(dAdj)>salaryTol,dtmDiff=Math.abs(dDtm)>dtmTol,identity=!!m.identityReview;\n if(!(salaryDiff||dtmDiff||identity))continue;\n const types=[];if(salaryDiff)types.push('Salario cotizable');if(dtmDiff)types.push('Décimo tercer mes');if(identity)types.push('Revisión de identidad');\n const impact=salaryDiff||dtmDiff?'Sí':'No';if(impact==='Sí')impactCount++;if(identity)identityCount++;\n let obs=`Match: ${m.method}.`;if(salaryDiff)obs+=` Diferencia contra salario ajustado CSS: B/. ${dAdj.toFixed(2)}.`;if(dtmDiff)obs+=` Diferencia DTM: B/. ${dDtm.toFixed(2)}.`;if(identity)obs+=' Los nombres requieren revisión manual aunque existe evidencia de correspondencia.';\n rows.push([s.code||'',s.name,p.identity||p.ssNumber||'',p.name,types.join(' + '),impact,s.salaryBase,Number(p.salary)||0,Number(p.salaryAdjusted)||0,dSal,dAdj,conceptSource(s),conceptCss(p),obs]);\n}\nfor(const s of onlySource){impactCount++;rows.push([s.code||'',s.name,'','','En nómina / no en CSS','Sí',s.salaryBase,0,0,s.salaryBase,s.salaryBase,conceptSource(s),'','No se identificó contraparte en la Planilla CSS con evidencia suficiente.']);}\nfor(const p of onlyPlan){impactCount++;rows.push(['','',p.identity||p.ssNumber||'',p.name,'En CSS / no en nómina','Sí',0,Number(p.salary)||0,Number(p.salaryAdjusted)||0,round(-Number(p.salary||0)),round(-Number(p.salaryAdjusted||0)),'',conceptCss(p),'No se identificó contraparte en las nóminas/movimientos suministrados.']);}\nrows.sort((a,b)=>(a[5]==='Sí'?0:1)-(b[5]==='Sí'?0:1)||Math.abs(Number(b[10])||0)-Math.abs(Number(a[10])||0)||String(a[1]||a[3]).localeCompare(String(b[1]||b[3])));\n\n// Nómina sin BambooHR: Active o Inactive cuenta como existente.\nconst sourceToMatch=new Map(matches.map(m=>[m.si,m])),bambooMissing=[];let foundB=0;\nfor(let si=0;si0||bambooMissing.length>0,q1:q1.fileName,q2:q2.fileName,css:css.fileName,complementCounts:pdfs.counts,matchingEngine:'INDEXADO_V3'};\nreturn [{json:{reportTitle,periodoLabel:css.periodoLabel,diffRows,bambooRows,summary}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 65680, + 8752 + ], + "id": "2e812507-1b8b-4c26-9404-0f704d74751d", + "name": "Construir cruce + BambooHR", + "notesInFlow": true, + "notes": "Motor de matching indexado v3. Evita comparaciones fuzzy cartesianas que bloqueaban el task runner; valida bases complementarias antes del cruce." + }, + { + "parameters": { + "method": "POST", + "url": "https://sheets.googleapis.com/v4/spreadsheets", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ properties: { title: $json[\"reportTitle\"] }, sheets: [{ properties: { title: \"Diferencias\" } },{ properties: { title: \"Nómina sin BambooHR\" } }] }) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 65920, + 8752 + ], + "id": "18053415-f9cd-42c5-84d5-aa2f53f648d3", + "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: \"Diferencias!A1\", majorDimension: \"ROWS\", values: $node[\"Construir cruce + BambooHR\"].json[\"diffRows\"] },{ range: \"'Nómina sin BambooHR'!A1\", majorDimension: \"ROWS\", values: $node[\"Construir cruce + BambooHR\"].json[\"bambooRows\"] }] }) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 66160, + 8752 + ], + "id": "4e60905d-2dbd-415d-baff-47da0d803664", + "name": "Escribir reporte", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const created=$items('Crear Google Sheet Reporte')[0].json,r=$items('Construir cruce + BambooHR')[0].json;const map={'Diferencias':{rows:r.diffRows,cols:14,widths:[95,180,125,180,185,115,110,110,130,120,145,260,260,360]},'Nómina sin BambooHR':{rows:r.bambooRows,cols:4,widths:[110,220,120,420]}};const req=[];\nfor(const s of created.sheets||[]){const name=s.properties.title,id=s.properties.sheetId,cfg=map[name];if(!cfg)continue;const rowCount=Math.max(cfg.rows.length,6),cols=cfg.cols;req.push({updateSheetProperties:{properties:{sheetId:id,gridProperties:{hideGridlines:true,frozenRowCount:5}},fields:'gridProperties.hideGridlines,gridProperties.frozenRowCount'}});req.push({repeatCell:{range:{sheetId:id,startRowIndex:0,endRowIndex:rowCount,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{textFormat:{fontFamily:'Arial',fontSize:10,foregroundColor:{red:.29,green:.29,blue:.29}},verticalAlignment:'MIDDLE',wrapStrategy:'WRAP',borders:{top:{style:'SOLID',color:{red:.82,green:.82,blue:.82}},bottom:{style:'SOLID',color:{red:.82,green:.82,blue:.82}},left:{style:'SOLID',color:{red:.82,green:.82,blue:.82}},right:{style:'SOLID',color:{red:.82,green:.82,blue:.82}}}}},fields:'userEnteredFormat'}});for(let rr=0;rr<3;rr++)req.push({mergeCells:{range:{sheetId:id,startRowIndex:rr,endRowIndex:rr+1,startColumnIndex:0,endColumnIndex:cols},mergeType:'MERGE_ALL'}});req.push({repeatCell:{range:{sheetId:id,startRowIndex:0,endRowIndex:1,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{backgroundColor:{red:.310,green:.459,blue:.545},textFormat:{fontFamily:'Arial',fontSize:13,bold:true,foregroundColor:{red:1,green:1,blue:1}},borders:{bottom:{style:'SOLID_THICK',color:{red:.424,green:.761,blue:.290}}}}},fields:'userEnteredFormat'}});req.push({repeatCell:{range:{sheetId:id,startRowIndex:1,endRowIndex:2,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{backgroundColor:{red:.357,green:.498,blue:.584},textFormat:{fontFamily:'Arial',fontSize:10,italic:true,foregroundColor:{red:1,green:1,blue:1}}}},fields:'userEnteredFormat'}});req.push({repeatCell:{range:{sheetId:id,startRowIndex:2,endRowIndex:3,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{backgroundColor:{red:.839,green:.910,blue:.957},textFormat:{fontFamily:'Arial',fontSize:9,italic:true,foregroundColor:{red:.310,green:.459,blue:.545}}}},fields:'userEnteredFormat'}});req.push({repeatCell:{range:{sheetId:id,startRowIndex:4,endRowIndex:5,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{backgroundColor:{red:.310,green:.459,blue:.545},textFormat:{fontFamily:'Arial',fontSize:9,bold:true,foregroundColor:{red:1,green:1,blue:1}},horizontalAlignment:'CENTER'}},fields:'userEnteredFormat'}});cfg.widths.forEach((w,i)=>req.push({updateDimensionProperties:{range:{sheetId:id,dimension:'COLUMNS',startIndex:i,endIndex:i+1},properties:{pixelSize:w},fields:'pixelSize'}}));if(name==='Diferencias'){req.push({repeatCell:{range:{sheetId:id,startRowIndex:5,endRowIndex:rowCount,startColumnIndex:6,endColumnIndex:11},cell:{userEnteredFormat:{numberFormat:{type:'NUMBER',pattern:'#,##0.00'}}},fields:'userEnteredFormat.numberFormat'}});req.push({addConditionalFormatRule:{rule:{ranges:[{sheetId:id,startRowIndex:5,endRowIndex:rowCount,startColumnIndex:5,endColumnIndex:6}],booleanRule:{condition:{type:'TEXT_EQ',values:[{userEnteredValue:'Sí'}]},format:{backgroundColor:{red:.992,green:.925,blue:.918},textFormat:{bold:true,foregroundColor:{red:.65,green:.15,blue:.12}}}}},index:0}});req.push({addConditionalFormatRule:{rule:{ranges:[{sheetId:id,startRowIndex:5,endRowIndex:rowCount,startColumnIndex:4,endColumnIndex:5}],booleanRule:{condition:{type:'TEXT_CONTAINS',values:[{userEnteredValue:'Revisión de identidad'}]},format:{backgroundColor:{red:1,green:.973,blue:.882},textFormat:{bold:true,foregroundColor:{red:.55,green:.35,blue:0}}}}},index:1}});}else{req.push({addConditionalFormatRule:{rule:{ranges:[{sheetId:id,startRowIndex:5,endRowIndex:rowCount,startColumnIndex:2,endColumnIndex:3}],booleanRule:{condition:{type:'TEXT_CONTAINS',values:[{userEnteredValue:'No encontrado'}]},format:{backgroundColor:{red:.992,green:.925,blue:.918},textFormat:{bold:true,foregroundColor:{red:.65,green:.15,blue:.12}}}}},index:0}});}}\nreturn [{json:{requests:req}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 66400, + 8752 + ], + "id": "a4f94747-a4a6-4cca-a871-b5121d3cad1e", + "name": "Preparar formato GLM" + }, + { + "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({requests:$json.requests}) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 66640, + 8752 + ], + "id": "f9e80770-c139-4a71-a988-f1c5b4d4d818", + "name": "Formatear reporte", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const id=$items('Crear Google Sheet Reporte')[0].json.spreadsheetId;\nconst init=$items('Inicializar ejecución')[0].json;\nconst editors=Array.isArray(init.editorEmails)&&init.editorEmails.length\n ? init.editorEmails\n : [init.editorEmail].filter(Boolean);\nconst spreadsheetUrl=`https://docs.google.com/spreadsheets/d/${id}/edit`;\nreturn editors.map(emailAddress=>({\n json:{spreadsheetId:id,emailAddress,spreadsheetUrl}\n}));" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 66880, + 8752 + ], + "id": "99e27090-3ca3-42da-98cd-49dfda85f7db", + "name": "Preparar permiso editor" + }, + { + "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:'writer',type:'user',emailAddress:$json.emailAddress}) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 67120, + 8752 + ], + "id": "bd409798-e29b-4a35-8462-f95f56651669", + "name": "Compartir reporte como editor", + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + }, + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "const init=$items('Inicializar ejecución')[0].json,r=$items('Construir cruce + BambooHR')[0].json,s=r.summary,id=$items('Crear Google Sheet Reporte')[0].json.spreadsheetId,url=`https://docs.google.com/spreadsheets/d/${id}/edit`,has=s.hasFindings,bg=has?'#FFF8E1':'#E8F5E9',state=has?'#8A5A00':'#27632A';\nconst html=`
 
\"GomezLee
SEGURIDAD SOCIAL · PANAMÁ

Cruce mensual completado

${s.periodoLabel}

${has?'REVISAR HALLAZGOS':'SIN DIFERENCIAS'}
El reporte contiene únicamente diferencias y la hoja “Nómina sin BambooHR”.
MétricaResultado
Diferencias${s.differenceCount}
Impactan cotización${s.impactCount}
Revisión de identidad${s.identityCount}
Nómina sin BambooHR${s.bambooMissingCount}
Abrir reporte
Q1: ${s.q1}
Q2: ${s.q2}
CSS: ${s.css}
GOMEZLEE MARKETING · CONFIDENCIAL — Uso Interno
`;\nreturn [{json:{sendTo:init.recipient,subject:`${has?'[REVISAR]':'[OK]'} Cruce Seguridad Social Panamá - ${s.periodoLabel}`,html,spreadsheetUrl:url}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 67360, + 8752 + ], + "id": "5d9b86cf-6cf7-47e6-9762-bd8262201b6b", + "name": "Preparar correo HTML GLM" + }, + { + "parameters": { + "sendTo": "={{ $json.sendTo }}", + "subject": "={{ $json.subject }}", + "message": "={{ $json.html }}", + "options": { + "appendAttribution": false + } + }, + "type": "n8n-nodes-base.gmail", + "typeVersion": 2.2, + "position": [ + 67600, + 8752 + ], + "id": "d4b95742-e4cd-4592-8df5-3f76db90840d", + "name": "Enviar correo reporte", + "executeOnce": true, + "webhookId": "1f8d3e4a-5ac5-49aa-82dc-2ba72fdcc3cd", + "credentials": { + "gmailOAuth2": { + "id": "UDcO1FLJqA453V2D", + "name": "Gmail account 3" + } + } + }, + { + "parameters": { + "method": "DELETE", + "url": "=https://www.googleapis.com/drive/v3/files/{{ $('Crear staging CSS').item.json.spreadsheetId }}?supportsAllDrives=true", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleDriveOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 67840, + 8752 + ], + "id": "a625af7d-797b-46a2-884a-d7879c2432a5", + "name": "Eliminar staging CSS", + "alwaysOutputData": true, + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + }, + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "const init=$items('Inicializar ejecución')[0].json,r=$items('Construir cruce + BambooHR')[0].json,s=r.summary,id=$items('Crear Google Sheet Reporte')[0].json.spreadsheetId,reportUrl=`https://docs.google.com/spreadsheets/d/${id}/edit`,has=s.hasFindings;\nconst html=`
\"GomezLee
SEGURIDAD SOCIAL · PANAMÁ

Cruce mensual completado

${s.periodoLabel}

${has?'Se encontraron hallazgos para revisar.':'No se detectaron diferencias.'}
${s.differenceCount} diferencia(s) · ${s.bambooMissingCount} persona(s) en Nómina sin BambooHR.

Abrir reporteRealizar otro cruce

El reporte también fue enviado a ${init.recipient}.

`;return [{json:{html}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 68080, + 8752 + ], + "id": "bc7805bc-dd9c-417e-b2e4-9009c579f3af", + "name": "Preparar confirmación" + }, + { + "parameters": { + "operation": "completion", + "respondWith": "showText", + "responseText": "={{ $json.html }}" + }, + "type": "n8n-nodes-base.form", + "typeVersion": 2.5, + "position": [ + 68320, + 8752 + ], + "id": "c0b72d12-ae17-47fa-b6ce-3c43389bc17a", + "name": "Confirmación · Abrir reporte / volver", + "webhookId": "ffae77c9-e2ed-4b13-9f00-8e7cdc454c64", + "notesInFlow": true, + "notes": "Pantalla final con dos botones: abrir reporte y volver al mismo formulario." + } + ], + "connections": { + "Formulario · Cruce SS Panamá": { + "main": [ + [ + { + "node": "Inicializar ejecución", + "type": "main", + "index": 0 + } + ] + ] + }, + "Inicializar ejecución": { + "main": [ + [ + { + "node": "Generar reporte completo BambooHR", + "type": "main", + "index": 0 + } + ] + ] + }, + "Generar reporte completo BambooHR": { + "main": [ + [ + { + "node": "Restaurar archivos del formulario", + "type": "main", + "index": 0 + } + ] + ] + }, + "Restaurar archivos del formulario": { + "main": [ + [ + { + "node": "Preparar Q1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar Q1": { + "main": [ + [ + { + "node": "Extraer texto Q1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extraer texto Q1": { + "main": [ + [ + { + "node": "Parsear Q1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parsear Q1": { + "main": [ + [ + { + "node": "Preparar Q2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar Q2": { + "main": [ + [ + { + "node": "Extraer texto Q2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extraer texto Q2": { + "main": [ + [ + { + "node": "Parsear Q2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parsear Q2": { + "main": [ + [ + { + "node": "Preparar CSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar CSS": { + "main": [ + [ + { + "node": "Crear staging CSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Crear staging CSS": { + "main": [ + [ + { + "node": "Reunir staging CSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Reunir staging CSS": { + "main": [ + [ + { + "node": "Convertir CSS a Google Sheets", + "type": "main", + "index": 0 + } + ] + ] + }, + "Convertir CSS a Google Sheets": { + "main": [ + [ + { + "node": "Esperar conversión CSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Esperar conversión CSS": { + "main": [ + [ + { + "node": "Listar hojas CSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Listar hojas CSS": { + "main": [ + [ + { + "node": "Preparar rangos CSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar rangos CSS": { + "main": [ + [ + { + "node": "Leer CSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Leer CSS": { + "main": [ + [ + { + "node": "Parsear CSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parsear CSS": { + "main": [ + [ + { + "node": "Separar complementarios", + "type": "main", + "index": 0 + } + ] + ] + }, + "Separar complementarios": { + "main": [ + [ + { + "node": "Extraer texto complementarios", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extraer texto complementarios": { + "main": [ + [ + { + "node": "Parsear complementarios", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parsear complementarios": { + "main": [ + [ + { + "node": "Construir cruce + BambooHR", + "type": "main", + "index": 0 + } + ] + ] + }, + "Construir cruce + BambooHR": { + "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 GLM", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar formato GLM": { + "main": [ + [ + { + "node": "Formatear reporte", + "type": "main", + "index": 0 + } + ] + ] + }, + "Formatear reporte": { + "main": [ + [ + { + "node": "Preparar permiso editor", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar permiso editor": { + "main": [ + [ + { + "node": "Compartir reporte como editor", + "type": "main", + "index": 0 + } + ] + ] + }, + "Compartir reporte como editor": { + "main": [ + [ + { + "node": "Preparar correo HTML GLM", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar correo HTML GLM": { + "main": [ + [ + { + "node": "Enviar correo reporte", + "type": "main", + "index": 0 + } + ] + ] + }, + "Enviar correo reporte": { + "main": [ + [ + { + "node": "Eliminar staging CSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Eliminar staging CSS": { + "main": [ + [ + { + "node": "Preparar confirmación", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar confirmación": { + "main": [ + [ + { + "node": "Confirmación · Abrir reporte / volver", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "authors": "Isaac Aracena", + "name": "Version 6f41b207", + "description": "", + "autosaved": true, + "workflowPublishHistory": [ + { + "createdAt": "2026-08-28T14:42:57.054Z", + "id": 5264, + "workflowId": "i5dlh4n3RAbfcB4i", + "versionId": "6f41b207-7e88-4fb0-8978-3917a67354c7", + "event": "activated", + "userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029" + } + ] + } +} \ No newline at end of file