3941 lines
321 KiB
JSON
3941 lines
321 KiB
JSON
{
|
|
"updatedAt": "2026-08-26T12:26:15.130Z",
|
|
"createdAt": "2026-08-20T19:14:26.133Z",
|
|
"id": "qxcXg0Z1YASkWgxL",
|
|
"name": "Cruce de Seguridad Social - Panamá",
|
|
"description": null,
|
|
"active": true,
|
|
"isArchived": false,
|
|
"nodes": [
|
|
{
|
|
"parameters": {
|
|
"formTitle": "Cruce Seguridad Social Panamá",
|
|
"formDescription": "Paso 1 de 4. Carga las dos nóminas quincenales (preferiblemente Excel; PDF también se valida con controles estrictos) y la Planilla oficial de la Caja de Seguro Social (CSS) en Excel. En los pasos siguientes podrás cargar Extraordinarias, Vacaciones y Liquidaciones en una o varias tandas, aunque estén en carpetas distintas. El período se detecta automáticamente desde la Planilla CSS.",
|
|
"formFields": {
|
|
"values": [
|
|
{
|
|
"fieldLabel": "Nómina - 1era Quincena",
|
|
"fieldType": "file",
|
|
"multipleFiles": false,
|
|
"acceptFileTypes": ".xlsx,.xls,.pdf",
|
|
"requiredField": true
|
|
},
|
|
{
|
|
"fieldLabel": "Nómina - 2da Quincena",
|
|
"fieldType": "file",
|
|
"multipleFiles": false,
|
|
"acceptFileTypes": ".xlsx,.xls,.pdf",
|
|
"requiredField": true
|
|
},
|
|
{
|
|
"fieldLabel": "Planilla CSS",
|
|
"fieldType": "file",
|
|
"multipleFiles": false,
|
|
"acceptFileTypes": ".xlsx,.xls",
|
|
"requiredField": true
|
|
}
|
|
]
|
|
},
|
|
"responseMode": "lastNode",
|
|
"options": {
|
|
"ignoreBots": true
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.formTrigger",
|
|
"typeVersion": 2.3,
|
|
"position": [
|
|
53744,
|
|
34736
|
|
],
|
|
"id": "595b95d8-2d86-40b4-a418-1b9964bc76f5",
|
|
"name": "On form submission",
|
|
"webhookId": "9c032e76-4e26-46ce-ab02-1a0d628dd1fa"
|
|
},
|
|
{
|
|
"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 = [\n 'iaracena@gomezleemarketing.com'\n];\nconst logoUrl = 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png';\nconst formUrl = 'https://agenteit.digitalcompass.agency/form/0feede33-4a67-4d98-8ae0-16f411726460';\nconst config = {\n amountTolerance: 0.10,\n cssIntegrityTolerance: 0.10,\n defaultRates: { ssRegular: 0.0975, se: 0.0125, ssDtm: 0.0725 },\n minRateSamples: 20,\n hardFailUnreadablePdf: true,\n maxSupplementalFilesSafety: 500\n};\nreturn [{json:{runId,startedAt:now.toISOString(),recipients,logoUrl,formUrl,config},binary:item.binary||{}}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
53984,
|
|
34736
|
|
],
|
|
"id": "cab81d5c-e00e-4957-af5a-21ab5dae0b09",
|
|
"name": "Inicializar ejecución"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "\nconst base=$items('Inicializar ejecución')[0];\nconst bin=base.binary||{};\nfunction norm(s){return String(s||'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase().replace(/\\s+/g,' ').trim();}\nfunction formatOf(file,name){\n const s=norm(`${name} ${file?.mimeType||''}`);\n if(/\\.pdf(?:$|\\s)|application\\/pdf/.test(s)) return 'PDF';\n if(/\\.xlsx?(?:$|\\s)|spreadsheet|excel|application\\/vnd\\.ms-excel/.test(s)) return 'EXCEL';\n return '';\n}\nconst entries=Object.entries(bin).map(([key,file])=>{\n const name=String(file.fileName||key), k=norm(key), n=norm(name), all=`${k} ${n}`;\n const format=formatOf(file,name); let score=format?100:-1000;\n \n if(/nomina.*1era|1era.*quincena|primera.*quincena|quincena.*1/.test(k)) score+=600;\n if(/nomina.*2da|2da.*quincena|segunda.*quincena|quincena.*2/.test(k)) score-=700;\n if(/1era|primera|quincena\\s*1|q1/.test(n)) score+=180;\n if(/2da|segunda|quincena\\s*2|q2/.test(n)) score-=220;\n if(/planilla\\s*css|detalleplanilla|extraordin|vacacion|liquidacion/.test(all)) score-=300;\n\n return {key,file,name,format,score,k,n,all};\n}).sort((a,b)=>b.score-a.score);\nconst chosen=entries[0];\nif(!chosen||chosen.score<250||!chosen.format) throw new Error('No pude identificar de forma segura la Nómina de 1era Quincena. Adjunta en ese campo un Excel (.xls/.xlsx) o PDF de la planilla quincenal.');\nreturn [{json:{...base.json,fileName:chosen.name,originalBinaryKey:chosen.key,format:chosen.format,inputMimeType:chosen.file?.mimeType||''},binary:{data:chosen.file}}];\n"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
54224,
|
|
34832
|
|
],
|
|
"id": "3dff7255-5e27-4c05-b126-5b233af765a7",
|
|
"name": "Preparar Q1"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "xls",
|
|
"options": {
|
|
"headerRow": false,
|
|
"includeEmptyCells": true,
|
|
"rawData": false,
|
|
"readAsString": false
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
54704,
|
|
34688
|
|
],
|
|
"id": "85440a47-e842-43d6-9015-76690b89712f",
|
|
"name": "Crear staging Q1"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const rows=$input.all().map(x=>x.json?.row).filter(r=>Array.isArray(r));\nif(!rows.length) throw new Error('No se pudieron leer filas de Nómina Q1 directamente desde el Excel.');\nreturn [{json:{valueRanges:[{range:'Sheet1!A1:AZ50000',values:rows}],directExtraction:true}}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
54944,
|
|
34688
|
|
],
|
|
"id": "2fe967fa-afc8-48df-87b1-d73ba67e825d",
|
|
"name": "Reunir staging Q1"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "\nconst payload=$json.body&&typeof $json.body==='object'?$json.body:$json;\nconst valueRanges=payload.valueRanges||[];\nconst prep=$items('Preparar Q1')[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 key(v){return asc(v).replace(/[^A-Z0-9]/g,'');}\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 detectPeriod(values){\n for(const row of values.slice(0,120)) for(const cell of row||[]){\n const m=clean(cell).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);\n if(m)return {start:m[1],end:m[2]};\n }\n return null;\n}\nfunction detectPrintedCount(values){\n let gi=-1; for(let i=0;i<values.length;i++) if(asc((values[i]||[]).join(' ')).includes('TOTALES GENERALES')) gi=i;\n if(gi<0)return null;\n for(let i=gi;i<Math.min(values.length,gi+12);i++){\n const row=values[i]||[];\n for(let c=0;c<row.length;c++){\n const s=clean(row[c]); let m=s.match(/Cantidad\\s+de\\s+Empleados\\s*:?\\s*(\\d+)/i); if(m)return Number(m[1]);\n if(/Cantidad\\s+de\\s+Empleados/i.test(s)){\n for(let j=c+1;j<Math.min(row.length,c+5);j++){const n=Number(row[j]);if(Number.isInteger(n)&&n>0)return n;}\n }\n }\n }\n return null;\n}\nfunction detect(values){\n for(let r=0;r<Math.min(values.length,100);r++){\n const row=values[r]||[], joined=row.map(asc).join('|');\n if(/CODIGO/.test(joined)&&/NOMBRE/.test(joined)){\n const max=Math.max(...values.slice(r,r+10).map(x=>(x||[]).length),row.length,1), vertical=[];\n for(let c=0;c<max;c++)vertical[c]=values.slice(r,r+10).map(rr=>asc((rr||[])[c])).filter(Boolean).join(' ');\n const codeIdx=vertical.findIndex(h=>/CODIGO/.test(h));\n let nameIdx=vertical.findIndex(h=>/NOMBRE.*EMPLEADO|NOMBRE DEL EMPLEADO/.test(h));if(nameIdx<0)nameIdx=vertical.findIndex(h=>/^NOMBRE$| NOMBRE /.test(' '+h+' '));\n let ssIdx=-1,seIdx=-1;\n for(let rr=r;rr<Math.min(r+12,values.length);rr++){\n const arr=(values[rr]||[]).map(key);\n for(let c=0;c<arr.length;c++){\n if(ssIdx<0&&(arr[c]==='SS'||arr[c]==='SEGSOC'||arr[c]==='SEGUROSOCIAL'))ssIdx=c;\n if(seIdx<0&&(arr[c]==='SE'||arr[c]==='SEGEDU'||arr[c]==='SEGUROEDUCATIVO'))seIdx=c;\n }\n }\n if(ssIdx<0)ssIdx=vertical.findIndex(h=>/\\bS\\.?\\s*S\\.?\\b|SEG\\.?\\s*SOC|SEGURO SOCIAL/.test(h));\n if(seIdx<0)seIdx=vertical.findIndex(h=>/\\bS\\.?\\s*E\\.?\\b|SEG\\.?\\s*EDU|SEGURO EDUCATIVO/.test(h));\n const grossIdx=vertical.findIndex(h=>/TOTAL BRUTO|SALARIO BRUTO/.test(h));\n if(nameIdx>=0&&ssIdx>=0)return {r,codeIdx,nameIdx,ssIdx,seIdx,grossIdx};\n }\n }\n return null;\n}\nconst rawRecords=[],sheets=[];let period=null,printedEmployeeCount=null;\nfor(const vr of valueRanges){\n const values=vr.values||[],range=String(vr.range||''),d=detect(values);\n period=period||detectPeriod(values); printedEmployeeCount=printedEmployeeCount||detectPrintedCount(values);\n if(!d){sheets.push({range,status:'IGNORADA',reason:'No se detectó encabezado de planilla quincenal'});continue;}\n let count=0;\n for(let r=d.r+1;r<values.length;r++){\n const row=values[r]||[],rowText=asc(row.join(' '));\n if(/TOTALES GENERALES|TOTALES DE|CANTIDAD DE EMPLEADOS/.test(rowText))continue;\n const name=clean(row[d.nameIdx]);if(!name||!/[A-ZÁÉÍÓÚÑ]/i.test(name))continue;\n let code=d.codeIdx>=0?clean(row[d.codeIdx]).replace(/\\.0+$/,''):'';if(code&&!/^\\d{1,8}$/.test(code))code='';\n const ss=round(num(row[d.ssIdx])),se=d.seIdx>=0?round(num(row[d.seIdx])):0,gross=d.grossIdx>=0?round(num(row[d.grossIdx])):0;\n if(!code&&ss===0&&se===0&&gross===0)continue;\n rawRecords.push({code:code.replace(/^0+(?=\\d)/,''),name,ss,se,gross,source:'Nómina Q1',fileName:prep.fileName,sheet:range});count++;\n }\n sheets.push({range,status:count?'PROCESADA':'SIN REGISTROS',records:count});\n}\n// Elimina filas exactas repetidas (por ejemplo, hojas copia) sin sumar dos veces.\nconst exactSeen=new Set(),records=[];\nfor(const r of rawRecords){const k=[r.code,asc(r.name),r.ss.toFixed(2),r.se.toFixed(2),r.gross.toFixed(2)].join('|');if(exactSeen.has(k))continue;exactSeen.add(k);records.push(r);}\nif(!records.length)throw new Error('No pude extraer empleados de Nómina Q1. El Excel no coincide con el formato de planilla esperado.');\nif(printedEmployeeCount&&records.length!==printedEmployeeCount)throw new Error(`Nómina Q1 indica ${printedEmployeeCount} empleados, pero el lector extrajo ${records.length}. Se detuvo el cruce para no generar un reporte incompleto.`);\nconst RESULT={fileName:prep.fileName,format:'EXCEL',records,employeeCount:records.length,printedEmployeeCount:printedEmployeeCount||null,missingCodeCount:records.filter(r=>!r.code).length,totalSS:round(records.reduce((s,r)=>s+r.ss,0)),totalSE:round(records.reduce((s,r)=>s+r.se,0)),period,sheets,stagingSpreadsheetId:'',integrityStatus:printedEmployeeCount?'VALIDADO CONTRA TOTAL DEL ARCHIVO':'VALIDADO ESTRUCTURALMENTE'};\nreturn [{json:RESULT}];\n"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
56384,
|
|
34688
|
|
],
|
|
"id": "693f40c8-f19f-4ab6-a526-f0ad006e7402",
|
|
"name": "Parsear Q1 Excel"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "\nconst base=$items('Inicializar ejecución')[0];\nconst bin=base.binary||{};\nfunction norm(s){return String(s||'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase().replace(/\\s+/g,' ').trim();}\nfunction formatOf(file,name){\n const s=norm(`${name} ${file?.mimeType||''}`);\n if(/\\.pdf(?:$|\\s)|application\\/pdf/.test(s)) return 'PDF';\n if(/\\.xlsx?(?:$|\\s)|spreadsheet|excel|application\\/vnd\\.ms-excel/.test(s)) return 'EXCEL';\n return '';\n}\nconst entries=Object.entries(bin).map(([key,file])=>{\n const name=String(file.fileName||key), k=norm(key), n=norm(name), all=`${k} ${n}`;\n const format=formatOf(file,name); let score=format?100:-1000;\n \n if(/nomina.*2da|2da.*quincena|segunda.*quincena|quincena.*2/.test(k)) score+=600;\n if(/nomina.*1era|1era.*quincena|primera.*quincena|quincena.*1/.test(k)) score-=700;\n if(/2da|segunda|quincena\\s*2|q2/.test(n)) score+=180;\n if(/1era|primera|quincena\\s*1|q1/.test(n)) score-=220;\n if(/planilla\\s*css|detalleplanilla|extraordin|vacacion|liquidacion/.test(all)) score-=300;\n\n return {key,file,name,format,score,k,n,all};\n}).sort((a,b)=>b.score-a.score);\nconst chosen=entries[0];\nif(!chosen||chosen.score<250||!chosen.format) throw new Error('No pude identificar de forma segura la Nómina de 2da Quincena. Adjunta en ese campo un Excel (.xls/.xlsx) o PDF de la planilla quincenal.');\nreturn [{json:{...base.json,fileName:chosen.name,originalBinaryKey:chosen.key,format:chosen.format,inputMimeType:chosen.file?.mimeType||''},binary:{data:chosen.file}}];\n"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
56864,
|
|
34832
|
|
],
|
|
"id": "e3a1ef29-d141-4b50-b35d-2ba175a41d4e",
|
|
"name": "Preparar Q2"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "xls",
|
|
"options": {
|
|
"headerRow": false,
|
|
"includeEmptyCells": true,
|
|
"rawData": false,
|
|
"readAsString": false
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
57344,
|
|
34688
|
|
],
|
|
"id": "70365021-6de6-43f7-b3c7-0a41fc275d5d",
|
|
"name": "Crear staging Q2"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const rows=$input.all().map(x=>x.json?.row).filter(r=>Array.isArray(r));\nif(!rows.length) throw new Error('No se pudieron leer filas de Nómina Q2 directamente desde el Excel.');\nreturn [{json:{valueRanges:[{range:'Sheet1!A1:AZ50000',values:rows}],directExtraction:true}}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
57584,
|
|
34688
|
|
],
|
|
"id": "b182e631-7af4-4666-b74a-609f29c78e69",
|
|
"name": "Reunir staging Q2"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "\nconst payload=$json.body&&typeof $json.body==='object'?$json.body:$json;\nconst valueRanges=payload.valueRanges||[];\nconst prep=$items('Preparar Q2')[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 key(v){return asc(v).replace(/[^A-Z0-9]/g,'');}\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 detectPeriod(values){\n for(const row of values.slice(0,120)) for(const cell of row||[]){\n const m=clean(cell).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);\n if(m)return {start:m[1],end:m[2]};\n }\n return null;\n}\nfunction detectPrintedCount(values){\n let gi=-1; for(let i=0;i<values.length;i++) if(asc((values[i]||[]).join(' ')).includes('TOTALES GENERALES')) gi=i;\n if(gi<0)return null;\n for(let i=gi;i<Math.min(values.length,gi+12);i++){\n const row=values[i]||[];\n for(let c=0;c<row.length;c++){\n const s=clean(row[c]); let m=s.match(/Cantidad\\s+de\\s+Empleados\\s*:?\\s*(\\d+)/i); if(m)return Number(m[1]);\n if(/Cantidad\\s+de\\s+Empleados/i.test(s)){\n for(let j=c+1;j<Math.min(row.length,c+5);j++){const n=Number(row[j]);if(Number.isInteger(n)&&n>0)return n;}\n }\n }\n }\n return null;\n}\nfunction detect(values){\n for(let r=0;r<Math.min(values.length,100);r++){\n const row=values[r]||[], joined=row.map(asc).join('|');\n if(/CODIGO/.test(joined)&&/NOMBRE/.test(joined)){\n const max=Math.max(...values.slice(r,r+10).map(x=>(x||[]).length),row.length,1), vertical=[];\n for(let c=0;c<max;c++)vertical[c]=values.slice(r,r+10).map(rr=>asc((rr||[])[c])).filter(Boolean).join(' ');\n const codeIdx=vertical.findIndex(h=>/CODIGO/.test(h));\n let nameIdx=vertical.findIndex(h=>/NOMBRE.*EMPLEADO|NOMBRE DEL EMPLEADO/.test(h));if(nameIdx<0)nameIdx=vertical.findIndex(h=>/^NOMBRE$| NOMBRE /.test(' '+h+' '));\n let ssIdx=-1,seIdx=-1;\n for(let rr=r;rr<Math.min(r+12,values.length);rr++){\n const arr=(values[rr]||[]).map(key);\n for(let c=0;c<arr.length;c++){\n if(ssIdx<0&&(arr[c]==='SS'||arr[c]==='SEGSOC'||arr[c]==='SEGUROSOCIAL'))ssIdx=c;\n if(seIdx<0&&(arr[c]==='SE'||arr[c]==='SEGEDU'||arr[c]==='SEGUROEDUCATIVO'))seIdx=c;\n }\n }\n if(ssIdx<0)ssIdx=vertical.findIndex(h=>/\\bS\\.?\\s*S\\.?\\b|SEG\\.?\\s*SOC|SEGURO SOCIAL/.test(h));\n if(seIdx<0)seIdx=vertical.findIndex(h=>/\\bS\\.?\\s*E\\.?\\b|SEG\\.?\\s*EDU|SEGURO EDUCATIVO/.test(h));\n const grossIdx=vertical.findIndex(h=>/TOTAL BRUTO|SALARIO BRUTO/.test(h));\n if(nameIdx>=0&&ssIdx>=0)return {r,codeIdx,nameIdx,ssIdx,seIdx,grossIdx};\n }\n }\n return null;\n}\nconst rawRecords=[],sheets=[];let period=null,printedEmployeeCount=null;\nfor(const vr of valueRanges){\n const values=vr.values||[],range=String(vr.range||''),d=detect(values);\n period=period||detectPeriod(values); printedEmployeeCount=printedEmployeeCount||detectPrintedCount(values);\n if(!d){sheets.push({range,status:'IGNORADA',reason:'No se detectó encabezado de planilla quincenal'});continue;}\n let count=0;\n for(let r=d.r+1;r<values.length;r++){\n const row=values[r]||[],rowText=asc(row.join(' '));\n if(/TOTALES GENERALES|TOTALES DE|CANTIDAD DE EMPLEADOS/.test(rowText))continue;\n const name=clean(row[d.nameIdx]);if(!name||!/[A-ZÁÉÍÓÚÑ]/i.test(name))continue;\n let code=d.codeIdx>=0?clean(row[d.codeIdx]).replace(/\\.0+$/,''):'';if(code&&!/^\\d{1,8}$/.test(code))code='';\n const ss=round(num(row[d.ssIdx])),se=d.seIdx>=0?round(num(row[d.seIdx])):0,gross=d.grossIdx>=0?round(num(row[d.grossIdx])):0;\n if(!code&&ss===0&&se===0&&gross===0)continue;\n rawRecords.push({code:code.replace(/^0+(?=\\d)/,''),name,ss,se,gross,source:'Nómina Q2',fileName:prep.fileName,sheet:range});count++;\n }\n sheets.push({range,status:count?'PROCESADA':'SIN REGISTROS',records:count});\n}\n// Elimina filas exactas repetidas (por ejemplo, hojas copia) sin sumar dos veces.\nconst exactSeen=new Set(),records=[];\nfor(const r of rawRecords){const k=[r.code,asc(r.name),r.ss.toFixed(2),r.se.toFixed(2),r.gross.toFixed(2)].join('|');if(exactSeen.has(k))continue;exactSeen.add(k);records.push(r);}\nif(!records.length)throw new Error('No pude extraer empleados de Nómina Q2. El Excel no coincide con el formato de planilla esperado.');\nif(printedEmployeeCount&&records.length!==printedEmployeeCount)throw new Error(`Nómina Q2 indica ${printedEmployeeCount} empleados, pero el lector extrajo ${records.length}. Se detuvo el cruce para no generar un reporte incompleto.`);\nconst RESULT={fileName:prep.fileName,format:'EXCEL',records,employeeCount:records.length,printedEmployeeCount:printedEmployeeCount||null,missingCodeCount:records.filter(r=>!r.code).length,totalSS:round(records.reduce((s,r)=>s+r.ss,0)),totalSE:round(records.reduce((s,r)=>s+r.se,0)),period,sheets,stagingSpreadsheetId:'',integrityStatus:printedEmployeeCount?'VALIDADO CONTRA TOTAL DEL ARCHIVO':'VALIDADO ESTRUCTURALMENTE'};\nreturn [{json:RESULT}];\n"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
59024,
|
|
34688
|
|
],
|
|
"id": "c5ce7b48-654e-4749-94f9-06311f088443",
|
|
"name": "Parsear Q2 Excel"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "\nconst base=$items('Inicializar ejecución')[0];\nconst bin=base.binary||{};\nfunction norm(s){return String(s||'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase().replace(/\\s+/g,' ').trim();}\nfunction formatOf(file,name){\n const s=norm(`${name} ${file?.mimeType||''}`);\n if(/\\.pdf(?:$|\\s)|application\\/pdf/.test(s)) return 'PDF';\n if(/\\.xlsx?(?:$|\\s)|spreadsheet|excel|application\\/vnd\\.ms-excel/.test(s)) return 'EXCEL';\n return '';\n}\nconst entries=Object.entries(bin).map(([key,file])=>{\n const name=String(file.fileName||key), k=norm(key), n=norm(name), all=`${k} ${n}`;\n const format=formatOf(file,name); let score=format?100:-1000;\n \n if(/planilla\\s*css|detalleplanilla|caja.*seguro|css/.test(k)) score+=650;\n if(/planilla\\s*css|detalleplanilla|caja.*seguro|css/.test(n)) score+=240;\n if(format!=='EXCEL') score-=900;\n if(/nomina|quincena|extraordin|vacacion|liquidacion/.test(all)) score-=300;\n\n return {key,file,name,format,score,k,n,all};\n}).sort((a,b)=>b.score-a.score);\nconst chosen=entries[0];\nif(!chosen||chosen.score<250||!chosen.format) throw new Error('No pude identificar de forma segura la Planilla CSS. Adjunta en ese campo el archivo oficial DetallePlanilla en Excel (.xls/.xlsx).');\nreturn [{json:{...base.json,fileName:chosen.name,originalBinaryKey:chosen.key,format:chosen.format,inputMimeType:chosen.file?.mimeType||''},binary:{data:chosen.file}}];\n"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
59264,
|
|
34832
|
|
],
|
|
"id": "0a9b3b13-da91-49d4-a018-3f72cc0209a1",
|
|
"name": "Preparar CSS"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "xls",
|
|
"options": {
|
|
"headerRow": false,
|
|
"includeEmptyCells": true,
|
|
"rawData": false,
|
|
"readAsString": false
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
59504,
|
|
34832
|
|
],
|
|
"id": "3daecbfd-ae7c-4cbb-ad6b-564c1523972e",
|
|
"name": "Crear staging CSS"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const rows=$input.all().map(x=>x.json?.row).filter(r=>Array.isArray(r));\nif(!rows.length) throw new Error('No se pudieron leer filas de Planilla CSS directamente desde el Excel.');\nreturn [{json:{valueRanges:[{range:'Sheet1!A1:AZ50000',values:rows}],directExtraction:true}}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
59744,
|
|
34832
|
|
],
|
|
"id": "0456e69e-4dbf-4568-8ab9-1f1efcc4d730",
|
|
"name": "Reunir staging CSS"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const payload=$json.body&&typeof $json.body==='object'?$json.body:$json;\nconst valueRanges=payload.valueRanges||[],prep=$items('Preparar CSS')[0].json,config=$items('Inicializar ejecución')[0].json.config||{};\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 isEmployeeHeader(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+(isEmployeeHeader(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;r<Math.min(values.length,120)&&!period;r++){const row=values[r]||[];for(let c=0;c<row.length;c++){const a=asc(row[c]);if(/MES COUTA|MES CUOTA|PERIODO/.test(a)){period=monthPeriod(row[c]);for(let j=c+1;!period&&j<Math.min(c+6,row.length);j++)period=monthPeriod(row[j]);}}}\nif(!period)throw new Error('No pude detectar el período de la Planilla CSS. Se esperaba “Mes cuota/couta: Mes Año”.');\n// Los totales oficiales están en el bloque superior \"Totales Planilla\". Nunca se leen\n// después del primer encabezado de empleado, porque allí se repiten etiquetas como\n// Seguro Social, Seguro Educativo, DTM, Salario y Salario Ajustado por persona.\nconst headerRows=[];for(let r=0;r<values.length;r++)if(isEmployeeHeader(values[r]))headerRows.push(r);\nif(!headerRows.length)throw new Error('La Planilla CSS cambió de formato: no encontré encabezados de empleados.');\nconst firstEmployeeHeader=headerRows[0];\nconst totals={},foundTotals=new Set();\nfor(let r=0;r<Math.min(firstEmployeeHeader,80);r++){\n const row=values[r]||[],label=asc(row[1]??row[0]);let val=null;\n for(let c=2;c<Math.min(row.length,7);c++){const candidate=num(row[c]);if(candidate!==0){val=candidate;break;}}\n if(val===null)continue;\n if(label==='SEGURO SOCIAL'&&!foundTotals.has('seguroSocial')){totals.seguroSocial=round(val);foundTotals.add('seguroSocial');}\n if(label==='SEGURO EDUCATIVO'&&!foundTotals.has('seguroEducativo')){totals.seguroEducativo=round(val);foundTotals.add('seguroEducativo');}\n if(label==='DTM'&&!foundTotals.has('dtm')){totals.dtm=round(val);foundTotals.add('dtm');}\n if(label==='SALARIO'&&!foundTotals.has('salario')){totals.salario=round(val);foundTotals.add('salario');}\n if(label==='SALARIO AJUSTADO'&&!foundTotals.has('salarioAjustado')){totals.salarioAjustado=round(val);foundTotals.add('salarioAjustado');}\n if(label==='RIESGOS PROFESIONALES'&&!foundTotals.has('riesgos')){totals.riesgos=round(val);foundTotals.add('riesgos');}\n}\nfor(const req of ['seguroSocial','seguroEducativo','salario','salarioAjustado'])if(!foundTotals.has(req))throw new Error(`La Planilla CSS cambió de formato: no pude detectar el total oficial “${req}” antes del primer empleado.`);\nconst employees=[];\nfor(let hi=0;hi<headerRows.length;hi++){\n const r=headerRows[hi],end=hi+1<headerRows.length?headerRows[hi+1]:values.length,header=(values[r]||[]).map(asc);\n const ssIdx=header.findIndex(v=>v==='SEGURO SOCIAL'),apIdx=header.findIndex(v=>v==='APELLIDO'),nomIdx=header.findIndex(v=>v==='NOMBRE'),sexIdx=header.findIndex(v=>v==='SEXO');\n let idIdx=header.findIndex(v=>/CEDULA|PASAPORTE|REGISTRO CIVIL|IDENTIFICACION|DOCUMENTO/.test(v));if(idIdx<0)idIdx=ssIdx+1;\n const docType=clean((values[r]||[])[idIdx]);let dr=r+1;while(dr<end&&!(values[dr]||[]).some(v=>clean(v)))dr++;if(dr>=end)continue;\n 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<end;k++){\n const row=values[k]||[],label=asc(row[1]??row[0]);let amount=0;for(let c=2;c<Math.min(row.length,7);c++){const n=num(row[c]);if(n!==0){amount=n;break;}}\n if(label==='COMPONENTES'){section='components';continue;}if(label==='TIPO CUENTA INGRESO'){section='accounts';continue;}if(label==='TIPO NOVEDAD'){section='novelties';continue;}\n if(section==='components'&&label&&label!=='TOTAL')components[label]=round(amount);\n if(section!=='accounts'&&label==='SALARIO')salary=round(amount);if(section!=='accounts'&&label==='SALARIO AJUSTADO')salaryAdjusted=round(amount);\n if(section==='accounts'&&label==='SEGURO SOCIAL')planillaSS=round(amount);if(section==='accounts'&&label==='SEGURO EDUCATIVO')planillaSE=round(amount);if(section==='accounts'&&label==='DTM')planillaDTM=round(amount);\n }\n if(!salaryAdjusted&&salary)salaryAdjusted=salary;\n const dtmKey=Object.keys(components).find(k=>/DECIMO TERCER MES|XIII MES/.test(k)),dtmBase=round(dtmKey?components[dtmKey]:0);\n employees.push({ssNumber,identity,docType,lastname,firstname,name:clean(`${lastname} ${firstname}`),sex:sexIdx>=0?clean(data[sexIdx]):'',salary,salaryAdjusted,dtmBase,planillaSS,planillaSE,planillaDTM,components});\n}\nif(!employees.length)throw new Error('No pude extraer empleados de la Planilla CSS.');\nconst dupIds=new Set(),seenIds=new Set();for(const e of employees){const k=asc(e.identity||e.ssNumber);if(!k)continue;if(seenIds.has(k))dupIds.add(k);seenIds.add(k);}if(dupIds.size)throw new Error(`La Planilla CSS contiene identificaciones duplicadas (${[...dupIds].slice(0,5).join(', ')}). Se detuvo el cruce.`);\nconst reconstructed={seguroSocial:round(employees.reduce((s,e)=>s+e.planillaSS,0)),seguroEducativo:round(employees.reduce((s,e)=>s+e.planillaSE,0)),dtm:round(employees.reduce((s,e)=>s+e.planillaDTM,0)),salario:round(employees.reduce((s,e)=>s+e.salary,0)),salarioAjustado:round(employees.reduce((s,e)=>s+e.salaryAdjusted,0))};\nconst tol=Number(config.cssIntegrityTolerance??0.10),integrity=[];\nfor(const k of ['seguroSocial','seguroEducativo','dtm','salario','salarioAjustado']){if(!foundTotals.has(k))continue;const diff=round(reconstructed[k]-Number(totals[k]||0));integrity.push({metric:k,official:round(totals[k]),reconstructed:reconstructed[k],diff,ok:Math.abs(diff)<=tol});}\nconst bad=integrity.filter(x=>!x.ok);if(bad.length)throw new Error(`Fallo de integridad en Planilla CSS: ${bad.map(x=>`${x.metric} oficial ${x.official} vs reconstruido ${x.reconstructed} (dif. ${x.diff})`).join('; ')}. Se detuvo el cruce.`);\nconst now=new Date(),pad=n=>String(n).padStart(2,'0'),reportTitle=`Cruce Seguridad Social Panamá - ${period.label} - ${pad(now.getDate())}-${pad(now.getMonth()+1)}-${now.getFullYear()}`;\nreturn [{json:{fileName:prep.fileName,period,periodoLabel:period.label,reportTitle,employees,employeeCount:employees.length,totals,reconstructed,integrity,sourceSheet:chosen.vr.range||'',stagingSpreadsheetId:''}}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
61184,
|
|
34832
|
|
],
|
|
"id": "c487313c-347e-4b81-ac82-f9f2f13bc945",
|
|
"name": "Parsear CSS"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "pdf",
|
|
"options": {
|
|
"joinPages": true
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
61664,
|
|
34832
|
|
],
|
|
"id": "a08a8ee5-7dd5-46a0-bf76-288870e633ae",
|
|
"name": "Extraer texto PDFs"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "function parseSupplementals(extracted, metaItems, css, init={}) {\nconst cfg=init.config||{};\nconst rates=cfg.defaultRates||{ssRegular:0.0975,se:0.0125,ssDtm:0.0725};\nconst targetMonth=Number(css.period?.month||0),targetYear=Number(css.period?.year||0);\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 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 fmtDate(d){return d?`${String(d.getDate()).padStart(2,'0')}/${String(d.getMonth()+1).padStart(2,'0')}/${d.getFullYear()}`:'';}\nfunction inTarget(d){return !!(d&&d.getMonth()+1===targetMonth&&d.getFullYear()===targetYear);}\nfunction overlapTarget(a,b){if(!a||!b)return null;const x=[a,b].sort((p,q)=>p-q),first=new Date(targetYear,targetMonth-1,1),last=new Date(targetYear,targetMonth,0,23,59,59);return x[0]<=last&&x[1]>=first;}\nfunction regexAll(text,re){let flags=re.flags||'';if(!flags.includes('g'))flags+='g';const rg=new RegExp(re.source,flags),out=[];let m;while((m=rg.exec(String(text||'')))!==null){out.push(m);if(m[0]==='')rg.lastIndex++;}return out;}\nfunction markers(text,re){return regexAll(text,re).map(m=>({index:m.index,end:m.index+m[0].length,match:m}));}\nfunction segPre(text,ms,j){return text.slice(j>0?ms[j-1].end:0,ms[j].index);}\nfunction segPost(text,ms,j){return text.slice(ms[j].end,j+1<ms.length?ms[j+1].index:text.length);}\nfunction healNumericFragments(src){let s=String(src||'').replace(/\\r/g,'');for(let pass=0;pass<4;pass++){s=s.replace(/(\\d[\\d,]*)\\.\\s+(\\d{1,2})(?=(?:\\s|$|[^\\d]))/g,'$1.$2');s=s.replace(/(\\d[\\d,]*)\\s+\\.\\s*(\\d{1,2})(?=(?:\\s|$|[^\\d]))/g,'$1.$2');s=s.replace(/(\\d{1,3}),\\s+(\\d{3})(?=(?:\\.\\d{1,2})?(?:\\s|$|[^\\d]))/g,'$1,$2');}return s;}\nfunction moneyNum(v){if(v===null||v===undefined)return null;let s=String(v).replace(/B\\/\\.?|\\$/gi,'').replace(/,/g,'').replace(/\\s+/g,'').trim();if(/^\\(.*\\)$/.test(s))s='-'+s.slice(1,-1);if(!/^-?\\d+(?:\\.\\d+)?$/.test(s))return null;const n=Number(s);return Number.isFinite(n)?round(n):null;}\nconst moneyToken=/\\(?-?\\d[\\d,]*(?:\\.\\d+)?\\)?/g;\nfunction tokens(s){return [...String(s||'').matchAll(moneyToken)].map(m=>({raw:m[0],index:m.index,val:moneyNum(m[0])})).filter(x=>x.val!==null);}\nfunction moneyNearLabel(block,labelRe){\n const lines=String(block||'').split('\\n'), candidates=[];\n for(let i=0;i<lines.length;i++){\n const line=lines[i]; const flags=(labelRe.flags||'').replace(/g/g,''); const rg=new RegExp(labelRe.source,flags); const m=rg.exec(line); if(!m)continue;\n const after=tokens(line.slice(m.index+m[0].length)); if(after.length){candidates.push({line:i,val:after[0].val,method:'SAME_AFTER'});continue;}\n const before=tokens(line.slice(0,m.index)); if(before.length){candidates.push({line:i,val:before[before.length-1].val,method:'SAME_BEFORE'});continue;}\n let found=null;\n for(let d=1;d<=3&&!found;d++){const j=i-d;if(j<0)break;const p=lines[j].trim();if(!p)continue;if(/^\\(?-?\\d[\\d,]*(?:\\.\\d+)?\\)?$/.test(p)){found={line:i,val:moneyNum(p),method:`PREV_${d}`};break;}else break;}\n if(!found){for(let d=1;d<=2&&!found;d++){const j=i+d;if(j>=lines.length)break;const p=lines[j].trim();if(!p)continue;if(/^\\(?-?\\d[\\d,]*(?:\\.\\d+)?\\)?$/.test(p)){found={line:i,val:moneyNum(p),method:`NEXT_${d}`};break;}else break;}}\n if(found)candidates.push(found);\n }\n return candidates.length?candidates[candidates.length-1]:{line:-1,val:null,method:''};\n}\nfunction textNearLabel(block,labelRe,valueRe){\n const lines=String(block||'').split('\\n'),out=[];for(let i=0;i<lines.length;i++){const line=lines[i],flags=(labelRe.flags||'').replace(/g/g,''),rg=new RegExp(labelRe.source,flags),m=rg.exec(line);if(!m)continue;let a=line.slice(m.index+m[0].length).match(valueRe);if(a){out.push({line:i,val:clean(a[0]),method:'SAME_AFTER'});continue;}let b=line.slice(0,m.index).match(new RegExp(valueRe.source+'(?=[^'+(valueRe.source.includes('\\\\d')?'\\\\d':'')+']*$)',valueRe.flags||''));if(b)out.push({line:i,val:clean(b[0]),method:'SAME_BEFORE'});}return out.length?out[out.length-1]:{line:-1,val:'',method:''};\n}\nfunction dateNearLabel(block,labelRe){const lines=String(block||'').split('\\n'),out=[];for(let i=0;i<lines.length;i++){const line=lines[i],rg=new RegExp(labelRe.source,(labelRe.flags||'').replace(/g/g,'')),m=rg.exec(line);if(!m)continue;const after=line.slice(m.index+m[0].length).match(/\\d{1,2}\\/\\d{1,2}\\/20\\d{2}/);if(after){out.push(after[0]);continue;}const before=line.slice(0,m.index).match(/\\d{1,2}\\/\\d{1,2}\\/20\\d{2}(?![\\s\\S]*\\d{1,2}\\/\\d{1,2}\\/20\\d{2})/);if(before)out.push(before[0]);}return out.length?out[out.length-1]:'';}\nfunction txnNearLabel(block){const lines=String(block||'').split('\\n'),out=[];for(const line of lines){const m=/N[uú]mero\\s+de\\s+Transacci[oó]n\\s*:?/i.exec(line);if(!m)continue;const a=line.slice(m.index+m[0].length).match(/\\d{3,}/);if(a)out.push(a[0]);else{const b=line.slice(0,m.index).match(/\\d{3,}(?![\\s\\S]*\\d{3,})/);if(b)out.push(b[0]);}}return out.length?out[out.length-1]:'';}\nfunction close(a,b,tol=.15){return Number.isFinite(Number(a))&&Number.isFinite(Number(b))&&Math.abs(Number(a)-Number(b))<=tol;}\nfunction globalPlanilla(text){const m=/(?:Planilla\\s+(?:N[uú]mero|n[uú]mero)\\s*:?)\\s*(\\d+)/i.exec(text);return m?Number(m[1]):0;}\nfunction globalGenerated(text){const m=/(\\d{1,2}\\/\\d{1,2}\\/20\\d{2}\\s+\\d{1,2}:\\d{2}:\\d{2}\\s*(?:AM|PM)?)/i.exec(text);return m?clean(m[1]):'';}\nfunction extraPeriod(text){let m=/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.exec(text);if(!m)return null;const a=parseDMY(m[1]),b=parseDMY(m[2]);if(!a||!b)return null;const x=[a,b].sort((p,q)=>p-q);return {a:x[0],b:x[1],start:dateKey(x[0]),end:dateKey(x[1]),display:`${fmtDate(x[0])} - ${fmtDate(x[1])}`};}\nfunction periodLibre(post){\n const lines=String(post||'').split('\\n');for(let i=0;i<lines.length;i++){if(!/Periodo\\s+Libre/i.test(lines[i]))continue;const same=[...lines[i].matchAll(/\\d{1,2}\\/\\d{1,2}\\/20\\d{2}/g)].map(x=>x[0]);if(same.length>=2)return mkPeriod(same[0],same[1]);const prev=[...lines.slice(Math.max(0,i-4),i+1).join(' ').matchAll(/\\d{1,2}\\/\\d{1,2}\\/20\\d{2}/g)].map(x=>x[0]);if(prev.length>=2)return mkPeriod(prev[prev.length-2],prev[prev.length-1]);const next=[...lines.slice(i,Math.min(lines.length,i+5)).join(' ').matchAll(/\\d{1,2}\\/\\d{1,2}\\/20\\d{2}/g)].map(x=>x[0]);if(next.length>=2)return mkPeriod(next[0],next[1]);}return null;\n}\nfunction mkPeriod(a,b){const d1=parseDMY(a),d2=parseDMY(b);if(!d1||!d2)return null;const x=[d1,d2].sort((p,q)=>p-q);return {startDate:x[0],endDate:x[1],start:dateKey(x[0]),end:dateKey(x[1]),display:`${fmtDate(x[0])} - ${fmtDate(x[1])}`};}\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('|');}\nfunction samePerson(a,b){const ac=clean(a.code).replace(/^0+(?=\\d)/,''),bc=clean(b.code).replace(/^0+(?=\\d)/,'');if(ac&&bc&&ac===bc)return true;return !!(norm(a.name)&&norm(a.name)===norm(b.name));}\nfunction vacFields(block){return {vac:moneyNearLabel(block,/Vacaciones(?!\\s+Correspondientes)/i),gross:moneyNearLabel(block,/TOTAL\\s+BRUTO\\s*:?/i),ss:moneyNearLabel(block,/Seg\\.\\s*Soc\\.?/i),se:moneyNearLabel(block,/Seg\\.\\s*Edu\\.?/i)};}\nfunction vacCandidateOK(f){if([f.vac.val,f.gross.val,f.ss.val,f.se.val].some(v=>v===null))return false;const g=f.gross.val;return close(f.vac.val,g,.08)&&close(f.ss.val,round(g*Number(rates.ssRegular||.0975)),.16)&&close(f.se.val,round(g*Number(rates.se||.0125)),.13);}\nfunction extraFields(block){return {gross:moneyNearLabel(block,/\\bTotales\\b/i),ss:moneyNearLabel(block,/Seg\\.\\s*Soc\\.?/i),se:moneyNearLabel(block,/Seg\\.\\s*Edu\\.?/i)};}\nfunction extraCandidateOK(f){if([f.gross.val,f.ss.val,f.se.val].some(v=>v===null))return false;const g=f.gross.val;return close(f.ss.val,round(g*Number(rates.ssRegular||.0975)),.16)&&close(f.se.val,round(g*Number(rates.se||.0125)),.13);}\nfunction liqFields(block){return {date:dateNearLabel(block,/FECHA\\s+DE\\s+LIQUIDACI[ÓO]N\\s*:?/i),transaction:txnNearLabel(block),ss:moneyNearLabel(block,/Seg\\.\\s*Soc\\.?/i),se:moneyNearLabel(block,/Seg\\.\\s*Edu\\.?/i),sv:moneyNearLabel(block,/Salario,?\\s*Vac,?\\s*13\\s*Mes\\s*:?/i),sal:moneyNearLabel(block,/Salario\\s+Reg\\.?\\s*:?/i),vac:moneyNearLabel(block,/^\\s*Vacaciones\\s*:?/i),dtm:moneyNearLabel(block,/^\\s*XIII\\s+Mes\\s*:?/i),pai:moneyNearLabel(block,/Preav,?\\s*P\\s*Ant,?\\s*Indem\\s*:?/i),gross:moneyNearLabel(block,/Total\\s+Bruto\\s*:?/i)};}\nfunction liqCandidateOK(f){if(!f.date||!f.transaction||[f.ss.val,f.se.val,f.sv.val,f.sal.val,f.vac.val,f.dtm.val,f.pai.val,f.gross.val].some(v=>v===null))return false;const rb=round(f.sal.val+f.vac.val),compSV=round(rb+f.dtm.val),compGross=round(f.sv.val+f.pai.val),es=round(rb*Number(rates.ssRegular||.0975)+f.dtm.val*Number(rates.ssDtm||.0725)),ee=round(rb*Number(rates.se||.0125));return close(f.sv.val,compSV,.14)&&close(f.gross.val,compGross,.14)&&close(f.ss.val,es,.17)&&close(f.se.val,ee,.13);}\nconst records=[],validations=[],supportDocs=[],files=[];\nfunction validation(status,category,fileName,detail){validations.push({status,severity:(status==='OK'||status==='IGNORADO'||status==='EXCLUIDO')?'INFO':'WARNING',category,fileName,detail});}\nfunction addRecord(r){records.push({...r,ss:round(r.ss||0),se:round(r.se||0),gross:round(r.gross||0),regularBase:round(r.regularBase||0),dtmBase:round(r.dtmBase||0),parserMode:'STRUCTURE_AWARE_V10'});}\nfor(let i=0;i<extracted.length;i++){\n const meta=metaItems[i]?.json||metaItems[i]||{},item=extracted[i]||{},fileName=meta.fileName||`PDF ${i+1}`,category=meta.category||'';files.push(fileName);const raw=item.json?.text??item.json?.data??item.json?.content??item.text??'';const text=healNumericFragments(Array.isArray(raw)?raw.join('\\n<PAGE_BREAK>\\n'):String(raw||''));if(!text.trim()){validation('REVISAR',category,fileName,'PDF sin texto extraíble; archivo excluido.');continue;}\n if(category==='vacaciones'){\n const ms=markers(text,/Empleado\\s*#\\s*(\\d+)\\s*:\\s*(.+?)(?=\\s+(?:Clave\\s+ISR|Vacaciones\\s+Correspondientes|Periodo\\s+Libre)|[\\n\\r]|$)/i);if(!ms.length){validation('REVISAR',category,fileName,'No se detectaron empleados.');continue;}\n const firstPre=vacFields(segPre(text,ms,0)),orientation=vacCandidateOK(firstPre)?'PRE':'POST',planillaNumber=globalPlanilla(text),generatedAt=globalGenerated(text);let inc=0,out=0,review=0;\n for(let j=0;j<ms.length;j++){const code=clean(ms[j].match[1]).replace(/^0+(?=\\d)/,''),name=clean(ms[j].match[2]),pre=segPre(text,ms,j),post=segPost(text,ms,j),f=vacFields(orientation==='PRE'?pre:post),pr=periodLibre(post),g=f.gross.val;let reason='';if(!vacCandidateOK(f))reason=`Campos/tasas de vacaciones no cuadran: Vac ${f.vac.val}, Bruto ${f.gross.val}, S.S. ${f.ss.val}, S.E. ${f.se.val}.`;else if(!pr)reason='No se pudo leer Periodo Libre.';if(reason){review++;addRecord({category:'Vacaciones',fileName,code,name,ss:f.ss.val||0,se:f.se.val||0,gross:g||0,regularBase:g||0,dtmBase:0,date:pr?.display||'',periodStart:pr?.start||'',periodEnd:pr?.end||'',transaction:'',planillaNumber,generatedAt,included:false,reason,orientation});continue;}const included=overlapTarget(pr.startDate,pr.endDate)!==false;if(included)inc++;else out++;addRecord({category:'Vacaciones',fileName,code,name,ss:f.ss.val,se:f.se.val,gross:g,regularBase:g,dtmBase:0,date:pr.display,periodStart:pr.start,periodEnd:pr.end,transaction:'',planillaNumber,generatedAt,included,reason:included?'':'Período Libre fuera del mes CSS',orientation});}\n validation(review?'REVISAR':out?'EXCLUIDO':'OK',category,fileName,`${ms.length} vacaciones: ${inc} incluidas, ${out} fuera de período, ${review} no legibles. Orientación ${orientation}.`);\n } else if(category==='extraordinaria'){\n const ms=markers(text,/Empleado\\s*:\\s*(\\d+)\\s+(.+?)\\s+Transac\\.?\\s*#\\s*:\\s*(\\d+)(?:\\s+Pla\\.?\\s*#\\s*:\\s*(\\d+))?/i),pr=extraPeriod(text);if(!ms.length){validation('REVISAR',category,fileName,'No se detectaron empleados.');continue;}const firstPre=extraFields(segPre(text,ms,0)),orientation=extraCandidateOK(firstPre)?'PRE':'POST',gp=globalPlanilla(text),generatedAt=globalGenerated(text);let inc=0,out=0,review=0;\n for(let j=0;j<ms.length;j++){const code=clean(ms[j].match[1]).replace(/^0+(?=\\d)/,''),name=clean(ms[j].match[2]),transaction=clean(ms[j].match[3]),planillaNumber=Number(ms[j].match[4]||0)||gp,f=extraFields(orientation==='PRE'?segPre(text,ms,j):segPost(text,ms,j));let reason='';if(!pr||!extraCandidateOK(f))reason=`Campos/tasas extraordinaria no cuadran: Bruto ${f.gross.val}, S.S. ${f.ss.val}, S.E. ${f.se.val}.`;if(reason){review++;addRecord({category:'Extraordinaria',fileName,code,name,ss:f.ss.val||0,se:f.se.val||0,gross:f.gross.val||0,regularBase:f.gross.val||0,dtmBase:0,date:pr?.display||'',periodStart:pr?.start||'',periodEnd:pr?.end||'',transaction,planillaNumber,generatedAt,included:false,reason,orientation});continue;}const included=overlapTarget(pr.a,pr.b)!==false;if(included)inc++;else out++;addRecord({category:'Extraordinaria',fileName,code,name,ss:f.ss.val,se:f.se.val,gross:f.gross.val,regularBase:f.gross.val,dtmBase:0,date:pr.display,periodStart:pr.start,periodEnd:pr.end,transaction,planillaNumber,generatedAt,included,reason:included?'':'Período fuera del mes CSS',orientation});}\n validation(review?'REVISAR':out?'EXCLUIDO':'OK',category,fileName,`${ms.length} extraordinarias: ${inc} incluidas, ${out} fuera de período, ${review} no legibles. Orientación ${orientation}.`);\n } else if(category==='liquidaciones'){\n if(!norm(text).includes('PLANILLA DE LIQUIDACION')){const rawm=markers(text,/Empleado\\s*:\\s*(\\d+)\\s+([^\\n\\r]+)/i).map(x=>({code:clean(x.match[1]).replace(/^0+(?=\\d)/,''),name:clean(x.match[2]).replace(/\\s+(?:A[nñ]o|C[eé]dula:).*$/i,'')}));const seen=new Set(),employees=[];for(const e of rawm){const k=e.code+'|'+norm(e.name);if(!seen.has(k)){seen.add(k);employees.push(e);}}supportDocs.push({fileName,employees});validation('IGNORADO',category,fileName,`Documento de cálculo/soporte (${employees.length} empleado(s)); no se suma.`);continue;}\n const ms=markers(text,/Empleado\\s*#\\s*(\\d+)\\s*:\\s*(.+?)(?=\\s+(?:FECHA\\s+DE\\s+LIQUIDACI[ÓO]N|Clave\\s+ISR|N[uú]mero\\s+de\\s+Transacci[oó]n)|[\\n\\r]|$)/i);if(!ms.length){validation('REVISAR',category,fileName,'No se detectaron empleados resumidos.');continue;}const firstPre=liqFields(segPre(text,ms,0)),orientation=liqCandidateOK(firstPre)?'PRE':'POST',planillaNumber=globalPlanilla(text),generatedAt=globalGenerated(text);let inc=0,out=0,review=0;\n for(let j=0;j<ms.length;j++){const code=clean(ms[j].match[1]).replace(/^0+(?=\\d)/,''),name=clean(ms[j].match[2]),f=liqFields(orientation==='PRE'?segPre(text,ms,j):segPost(text,ms,j));let regularBase=round((f.sal.val||0)+(f.vac.val||0)),reason='';if(!liqCandidateOK(f)){const compSV=round(regularBase+(f.dtm.val||0)),compGross=round((f.sv.val||0)+(f.pai.val||0)),es=round(regularBase*Number(rates.ssRegular||.0975)+(f.dtm.val||0)*Number(rates.ssDtm||.0725)),ee=round(regularBase*Number(rates.se||.0125));reason=`Control liquidación: fecha ${f.date||'-'}, tx ${f.transaction||'-'}, Sal/Vac/13 ${f.sv.val} vs ${compSV}, bruto ${f.gross.val} vs ${compGross}, S.S. ${f.ss.val} (~${es}), S.E. ${f.se.val} (~${ee}).`;}if(reason){review++;addRecord({category:'Liquidación',fileName,code,name,ss:f.ss.val||0,se:f.se.val||0,gross:f.gross.val||0,regularBase,dtmBase:f.dtm.val||0,salaryVac13:f.sv.val||0,salaryReg:f.sal.val||0,vacationBase:f.vac.val||0,date:f.date,transaction:f.transaction,planillaNumber,generatedAt,included:false,reason,orientation});continue;}const included=inTarget(parseDMY(f.date));if(included)inc++;else out++;addRecord({category:'Liquidación',fileName,code,name,ss:f.ss.val,se:f.se.val,gross:f.gross.val,regularBase,dtmBase:f.dtm.val,salaryVac13:f.sv.val,salaryReg:f.sal.val,vacationBase:f.vac.val,date:f.date,transaction:f.transaction,planillaNumber,generatedAt,included,reason:included?'':'Fecha de liquidación fuera del mes CSS',orientation});}\n validation(review?'REVISAR':out?'EXCLUIDO':'OK',category,fileName,`${ms.length} liquidaciones: ${inc} incluidas, ${out} fuera de período, ${review} no legibles. Orientación ${orientation}.`);\n }\n}\nconst seenExact=new Map();for(const r of records){if(!r.included)continue;const k=recordFingerprint(r);if(!seenExact.has(k)){seenExact.set(k,r);continue;}r.included=false;r.reason=`Duplicado exacto; ya incluido desde ${seenExact.get(k).fileName}`;}\nfor(const cat of ['Extraordinaria','Liquidación']){const seen=new Map();for(const r of records){if(r.category!==cat||!r.included||!r.transaction)continue;const k=String(r.transaction);if(!seen.has(k)){seen.set(k,r);continue;}const p=seen.get(k);if(recordFingerprint(p)===recordFingerprint(r)){r.included=false;r.reason=`Duplicado exacto de transacción; ya incluido desde ${p.fileName}`;}else{r.included=false;r.reason=`Transacción ${k} repetida con datos diferentes; revisar.`;validation('REVISAR',cat.toLowerCase(),r.fileName,r.reason);}}}\nconst groups=new Map();for(const r of records){if(r.category!=='Vacaciones'||!r.included)continue;const k=`${r.code||norm(r.name)}|${r.periodStart}|${r.periodEnd}`;if(!groups.has(k))groups.set(k,[]);groups.get(k).push(r);}for(const arr of groups.values()){if(arr.length<2)continue;arr.sort((a,b)=>(Number(b.planillaNumber)||0)-(Number(a.planillaNumber)||0)||String(b.generatedAt).localeCompare(String(a.generatedAt)));for(const r of arr.slice(1)){r.included=false;r.reason=`Sustituida por planilla de vacaciones más reciente (${arr[0].planillaNumber||arr[0].fileName}).`;}}\nconst liqAll=records.filter(r=>r.category==='Liquidación');for(const doc of supportDocs){const missing=(doc.employees||[]).filter(e=>!liqAll.some(r=>samePerson(e,r)));if(missing.length)validation('REVISAR','liquidaciones',doc.fileName,`Documento de cálculo contiene persona(s) sin planilla resumida: ${missing.map(x=>`${x.name}${x.code?` (#${x.code})`:''}`).join(', ')}.`);}\nconst includedRecords=records.filter(r=>r.included),catCount=cat=>includedRecords.filter(r=>r.category===cat).length;return {records,validations,supportDocs,includedRecords,files:[...new Set(files)],counts:{extraordinarias:catCount('Extraordinaria'),vacaciones:catCount('Vacaciones'),liquidaciones:catCount('Liquidación')},totals:{extraSS:round(includedRecords.filter(r=>r.category==='Extraordinaria').reduce((s,r)=>s+r.ss,0)),vacSS:round(includedRecords.filter(r=>r.category==='Vacaciones').reduce((s,r)=>s+r.ss,0)),liqSS:round(includedRecords.filter(r=>r.category==='Liquidación').reduce((s,r)=>s+r.ss,0)),totalSS:round(includedRecords.reduce((s,r)=>s+r.ss,0)),totalSE:round(includedRecords.reduce((s,r)=>s+r.se,0))},parserVersion:'STRUCTURE_AWARE_V10_SIN_IA',quality:{status:validations.some(v=>v.status==='REVISAR')?'REVISAR':'VALIDADO',rule:'La orientación PRE/POST se determina con el primer empleado del archivo mediante controles matemáticos. La misma orientación se aplica a todo el archivo. Vacaciones lee Periodo Libre siempre del bloque posterior. No hay alineación global por listas.'}};\n}\n\nconst __result = parseSupplementals($input.all(), $items('Normalizar PDFs descargados'), (($items('Parsear CSS')[0]||{}).json||{}), (($items('Inicializar ejecución')[0]||{}).json||{}));\nreturn [{json:__result}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
61904,
|
|
34832
|
|
],
|
|
"id": "14f0d70e-f329-49c3-b500-4fdad7c2112f",
|
|
"name": "Parsear PDFs",
|
|
"notesInFlow": true,
|
|
"notes": "Parser complementarios V8 SIN IA: extracción directa por campos del mismo empleado, detección PRE/POST por archivo, validación contable/tasas, soporte para PDF.js y decimales fragmentados. No alinea listas globales ni toma montos de empleados vecinos."
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const init=$items('Inicializar ejecución')[0].json,config=init.config||{};\nconst q1=$items('Resultado Q1')[0].json,q2=$items('Resultado Q2')[0].json,css=$items('Parsear CSS')[0].json,pdfs=$items('Parsear PDFs')[0].json;\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').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 core(v){return norm(v).split(' ').filter(t=>t&&!particles.has(t));}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction money(n){return `B/. ${Number(n||0).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}`;}\nfunction parseDMY(s){const m=String(s||'').match(/(\\d{1,2})\\/(\\d{1,2})\\/(20\\d{2})/);return m?new Date(Number(m[3]),Number(m[2])-1,Number(m[1])):null;}\nfunction validatePayrollPeriod(label,p,half){\n if(!p)return {status:'REVISAR',detail:`${label}: período no detectado automáticamente; se validó estructura y totales, pero conviene confirmar el archivo.`};\n const a=parseDMY(p.start),b=parseDMY(p.end),tm=css.period.month,ty=css.period.year;\n if(!a||!b)return {status:'REVISAR',detail:`${label}: período ilegible.`};\n const same=a.getFullYear()===ty&&b.getFullYear()===ty&&a.getMonth()+1===tm&&b.getMonth()+1===tm;\n if(!same)throw new Error(`${label} pertenece a ${p.start} - ${p.end}, pero la Planilla CSS es ${css.periodoLabel}. Se detuvo el cruce.`);\n if(a>b)throw new Error(`${label} tiene un período invertido (${p.start} - ${p.end}). Se detuvo el cruce.`);\n if(half===1&&(a.getDate()>15||b.getDate()>16))throw new Error(`${label} no parece ser la 1era quincena (${p.start} - ${p.end}). Revisa que no hayas cargado la 2da quincena por error.`);\n if(half===2&&(a.getDate()<15||b.getDate()<27))throw new Error(`${label} no parece ser la 2da quincena (${p.start} - ${p.end}). Revisa que no hayas cargado la 1era quincena por error.`);\n return {status:'OK',detail:`${label}: ${p.start} - ${p.end}`};\n}\nfunction payrollFingerprint(x){return (x.records||[]).map(r=>[String(r.code||''),norm(r.name),round(r.ss),round(r.se),round(r.gross)].join('|')).sort().join('||');}\nconst periodChecks=[validatePayrollPeriod('Nómina Q1',q1.period,1),validatePayrollPeriod('Nómina Q2',q2.period,2)];\nconst fpQ1=payrollFingerprint(q1),fpQ2=payrollFingerprint(q2);\nif(fpQ1&&fpQ2&&fpQ1===fpQ2)throw new Error('Las nóminas Q1 y Q2 contienen exactamente los mismos empleados y montos. Se detuvo el cruce porque probablemente se cargó la misma quincena dos veces.');\n// Inferencia dinámica de tasas del trabajador desde los propios recibos, para no depender de tasas hardcodeadas año tras año.\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;}\nconst ratioSS=[],ratioSE=[];\nfor(const r of [...(q1.records||[]),...(q2.records||[])]){const g=Number(r.gross)||0,ss=Number(r.ss)||0,se=Number(r.se)||0;if(g>=20&&ss>0){const x=ss/g;if(x>.03&&x<.20)ratioSS.push(x);}if(g>=20&&se>0){const x=se/g;if(x>.001&&x<.05)ratioSE.push(x);}}\nconst fallback=config.defaultRates||{ssRegular:.0975,se:.0125,ssDtm:.0725};\nconst ssMed=median(ratioSS),seMed=median(ratioSE),ssRegular=Number((ssMed??fallback.ssRegular).toFixed(4)),seRate=Number((seMed??fallback.se).toFixed(4));\nconst dtmRatios=[];for(const r of pdfs.includedRecords||[]){if(r.category!=='Liquidación'||Number(r.dtmBase||0)<=1)continue;const x=(Number(r.ss||0)-Number(r.regularBase||0)*ssRegular)/Number(r.dtmBase);if(x>.03&&x<.15)dtmRatios.push(x);}\nconst dtmMed=median(dtmRatios),ssDtm=Number((dtmMed??fallback.ssDtm).toFixed(4));\nconst rateInfo={ssRegular,se:seRate,ssDtm,samplesSS:ratioSS.length,samplesSE:ratioSE.length,samplesDTM:dtmRatios.length,usedFallbackSS:ssMed===null,usedFallbackSE:seMed===null,usedFallbackDTM:dtmMed===null};\nif(ratioSS.length<(config.minRateSamples||20)||ratioSE.length<(config.minRateSamples||20))throw new Error(`No hubo suficientes registros para inferir las tasas de S.S./S.E. de forma segura (${ratioSS.length}/${ratioSE.length}). Se detuvo el cruce.`);\nconst plans=(css.employees||[]).map((p,index)=>({...p,_idx:index,expectedWorkerSS:round(Number(p.salaryAdjusted||0)*ssRegular+Number(p.dtmBase||0)*ssDtm),expectedWorkerSE:round(Number(p.salaryAdjusted||0)*seRate)}));\n// Une todas las fuentes por Employee # cuando existe; cuando el PDF pierde el código, usa el nombre exacto conservadoramente.\nconst observations=[];for(const r of q1.records||[])observations.push({...r,kind:'q1'});for(const r of q2.records||[])observations.push({...r,kind:'q2'});for(const r of pdfs.includedRecords||[]){const kind=r.category==='Extraordinaria'?'extra':r.category==='Vacaciones'?'vac':'liq';observations.push({...r,kind});}\nconst groups=new Map(),nameCodes=new Map();\nfor(const r of observations){const code=clean(r.code).replace(/^0+(?=\\d)/,''),nn=norm(r.name);if(!code)continue;const k=`C:${code}`;if(!groups.has(k))groups.set(k,{key:k,code,names:[],obs:[]});const g=groups.get(k);if(r.name&&!g.names.includes(r.name))g.names.push(r.name);g.obs.push(r);if(!nameCodes.has(nn))nameCodes.set(nn,new Set());nameCodes.get(nn).add(code);}\nfor(const r of observations){const code=clean(r.code).replace(/^0+(?=\\d)/,'');if(code)continue;const nn=norm(r.name),codes=nameCodes.get(nn)||new Set();let g;if(codes.size===1)g=groups.get(`C:${[...codes][0]}`);else{const k=`N:${nn}`;if(!groups.has(k))groups.set(k,{key:k,code:'',names:[],obs:[]});g=groups.get(k);}if(r.name&&!g.names.includes(r.name))g.names.push(r.name);g.obs.push(r);}\nfor(const g of groups.values()){for(const k of ['q1SS','q1SE','q2SS','q2SE','extraSS','extraSE','vacSS','vacSE','liqSS','liqSE'])g[k]=0;for(const r of g.obs){const ss=Number(r.ss)||0,se=Number(r.se)||0;g[`${r.kind}SS`]+=ss;g[`${r.kind}SE`]+=se;}g.name=g.names[0]||'';g.totalSS=round(g.q1SS+g.q2SS+g.extraSS+g.vacSS+g.liqSS);g.totalSE=round(g.q1SE+g.q2SE+g.extraSE+g.vacSE+g.liqSE);}\nconst sources=[...groups.values()];\n// Similaridad conservadora OPTIMIZADA: conserva las mismas reglas de matching,\n// pero evita comparar cada fuente contra cada empleado CSS con Levenshtein completo.\n// Primero crea índices baratos (tokens, prefijos, iniciales y montos) y solo ejecuta\n// la similitud costosa sobre un conjunto reducido de candidatos plausibles.\nfunction levRaw(a,b){\n a=String(a||''); b=String(b||'');\n if(a===b)return 0;\n if(!a.length)return b.length;\n if(!b.length)return a.length;\n if(a.length>b.length){const t=a;a=b;b=t;}\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;\n const bj=b.charCodeAt(j-1);\n for(let i=1;i<=a.length;i++){\n const cost=a.charCodeAt(i-1)===bj?0:1;\n const ins=cur[i-1]+1,del=prev[i]+1,sub=prev[i-1]+cost;\n cur[i]=Math.min(ins,del,sub);\n }\n const tmp=prev;prev=cur;cur=tmp;\n }\n return prev[a.length];\n}\nfunction compactNorm(v){return norm(v).replace(/ /g,'');}\nfunction simPrepared(a,b){a=String(a||'');b=String(b||'');return !a||!b?0:1-levRaw(a,b)/Math.max(a.length,b.length);}\nfunction tokenCovPrepared(A,B){\n if(!A.length||!B.length)return 0;\n const avg=(X,Y)=>{\n let total=0;\n for(const x of X){\n let best=0;\n for(const y of Y){\n const s=simPrepared(x,y);\n if(s>best)best=s;\n if(best===1)break;\n }\n total+=best;\n }\n return total/X.length;\n };\n return (avg(A,B)+avg(B,A))/2;\n}\nfunction sourceParts(name){\n const raw=String(name||'');let l='',f='';\n if(raw.includes(',')){const pos=raw.indexOf(',');l=raw.slice(0,pos);f=raw.slice(pos+1);}\n else{const t=norm(raw).split(' ');l=t.shift()||'';f=t.join(' ');}\n const L=core(l),F=core(f);\n return {\n last:norm(l),first:norm(f),last1:L[0]||'',first1:F[0]||'',\n lastC:compactNorm(l),firstC:compactNorm(f),\n last1C:compactNorm(L[0]||''),first1C:compactNorm(F[0]||'')\n };\n}\nfunction planParts(p){\n const L=core(p.lastname),F=core(p.firstname);\n return {\n last:norm(p.lastname),first:norm(p.firstname),last1:L[0]||'',first1:F[0]||'',\n lastC:compactNorm(p.lastname),firstC:compactNorm(p.firstname),\n last1C:compactNorm(L[0]||''),first1C:compactNorm(F[0]||'')\n };\n}\nfunction uniqTokens(v){return [...new Set(core(v).map(compactNorm).filter(Boolean))];}\nfunction addIndex(map,k,v){if(!k)return;if(!map.has(k))map.set(k,[]);map.get(k).push(v);}\nfunction addBucket(set,map,k){for(const v of map.get(k)||[])set.add(v);}\nfunction prefix(v,n){v=String(v||'');return v.length>=n?v.slice(0,n):v;}\nfunction amountClass(dss,dse){if(dss<=.05&&dse<=.05)return 1;if(dss<=.12&&dse<=.12)return .95;if(dss<=.25&&dse<=.25)return .85;if(dss<=.5&&dse<=.5)return .70;if(dss<=1&&dse<=1)return .50;return 0;}\n\nconst planMeta=plans.map((p,pi)=>({\n p,pi,pp:planParts(p),tokens:uniqTokens(p.name),\n pairKey:`${p.expectedWorkerSS.toFixed(2)}|${p.expectedWorkerSE.toFixed(2)}`\n}));\nconst sourceMeta=sources.map((s,si)=>({s,si,sp:sourceParts(s.name),tokens:uniqTokens(s.name)}));\n\nconst pairFreq=new Map(),firstFreq=new Map(),lastFreq=new Map();\nfor(const m of planMeta){\n pairFreq.set(m.pairKey,(pairFreq.get(m.pairKey)||0)+1);\n firstFreq.set(m.pp.first1,(firstFreq.get(m.pp.first1)||0)+1);\n lastFreq.set(m.pp.last1,(lastFreq.get(m.pp.last1)||0)+1);\n}\n\nconst byToken=new Map(),byLast2=new Map(),byLast3=new Map(),byFirst2=new Map(),byFirst3=new Map(),byInitialPair=new Map(),byAmount=new Map();\nfunction amountKey(ss,se){return `${Math.round((Number(ss)||0)*4)}|${Math.round((Number(se)||0)*4)}`;}\nfor(const m of planMeta){\n for(const t of m.tokens)addIndex(byToken,t,m.pi);\n addIndex(byLast2,prefix(m.pp.last1C,2),m.pi);\n addIndex(byLast3,prefix(m.pp.last1C,3),m.pi);\n addIndex(byFirst2,prefix(m.pp.first1C,2),m.pi);\n addIndex(byFirst3,prefix(m.pp.first1C,3),m.pi);\n addIndex(byInitialPair,`${m.pp.last1C[0]||''}|${m.pp.first1C[0]||''}`,m.pi);\n addIndex(byAmount,amountKey(m.p.expectedWorkerSS,m.p.expectedWorkerSE),m.pi);\n}\n\nconst candidates=[],candidatesBySource=new Map();\nconst tierBuckets=[[],[],[],[],[],[]];\nfor(const sm of sourceMeta){\n const s=sm.s,sp=sm.sp,pool=new Set();\n\n // Evidencia de identidad exacta o por prefijo.\n for(const t of sm.tokens)addBucket(pool,byToken,t);\n addBucket(pool,byLast3,prefix(sp.last1C,3));\n addBucket(pool,byFirst3,prefix(sp.first1C,3));\n addBucket(pool,byLast2,prefix(sp.last1C,2));\n addBucket(pool,byFirst2,prefix(sp.first1C,2));\n addBucket(pool,byInitialPair,`${sp.last1C[0]||''}|${sp.first1C[0]||''}`);\n\n // Evidencia de monto: busca hasta +/- B/.1.00 en pasos de B/.0.25.\n const ssQ=Math.round((Number(s.totalSS)||0)*4),seQ=Math.round((Number(s.totalSE)||0)*4);\n for(let ds=-4;ds<=4;ds++)for(let de=-4;de<=4;de++)addBucket(pool,byAmount,`${ssQ+ds}|${seQ+de}`);\n\n // Red de seguridad: si los índices produjeron muy pocos candidatos, agrega los\n // 12 CSS más cercanos por monto. Este barrido solo hace aritmética, no Levenshtein.\n if(pool.size<12){\n const closest=[];\n for(const pm of planMeta){\n const d=Math.abs((Number(s.totalSS)||0)-pm.p.expectedWorkerSS)+Math.abs((Number(s.totalSE)||0)-pm.p.expectedWorkerSE);\n if(closest.length<12){closest.push([d,pm.pi]);closest.sort((a,b)=>a[0]-b[0]);}\n else if(d<closest[closest.length-1][0]){closest[closest.length-1]=[d,pm.pi];closest.sort((a,b)=>a[0]-b[0]);}\n }\n for(const [,pi] of closest)pool.add(pi);\n }\n\n const local=[];\n for(const pi of pool){\n const pm=planMeta[pi],p=pm.p,pp=pm.pp;\n const lastFirst=simPrepared(sp.last1C,pp.last1C);\n const lastFull=simPrepared(sp.lastC,pp.lastC);\n const firstSim=Math.max(simPrepared(sp.first1C,pp.first1C),simPrepared(sp.firstC,pp.firstC));\n const gen=tokenCovPrepared(sm.tokens,pm.tokens);\n const dss=Math.abs(s.totalSS-p.expectedWorkerSS),dse=Math.abs(s.totalSE-p.expectedWorkerSE),am=amountClass(dss,dse);\n const pairUnique=(pairFreq.get(pm.pairKey)||0)===1;\n const firstUnique=(firstFreq.get(pp.first1)||0)===1,lastUnique=(lastFreq.get(pp.last1)||0)===1;\n let tier=0,method='';\n if(sp.last1&&sp.last1===pp.last1&&firstSim>=.84){tier=1;method='APELLIDO + NOMBRE';}\n else if(lastFirst>=.78&&firstSim>=.90&&am>=.85){tier=2;method='VARIANTE DE APELLIDO + NOMBRE + MONTO';}\n else if(gen>=.86&&am>=.70&&(lastFirst>=.65||firstSim>=.80)){tier=3;method='NOMBRE FUZZY + MONTO';}\n else if(firstSim>=.95&&(firstUnique||pairUnique)&&am>=.95){tier=4;method='NOMBRE + MONTO ÚNICO';}\n else if(sp.last1&&sp.last1===pp.last1&&lastUnique&&am>=.95){tier=4;method='APELLIDO ÚNICO + MONTO';}\n else if(gen>=.72&&pairUnique&&am>=.95){tier=5;method='MONTO ÚNICO + EVIDENCIA DE NOMBRE';}\n if(!tier)continue;\n const nameScore=.38*lastFirst+.32*firstSim+.18*gen+.12*lastFull;\n const score=(6-tier)*10+.58*nameScore+.42*am-.002*Math.min(dss,10);\n const c={tier,score,si:sm.si,pi,nameScore,lastFirst,firstSim,gen,am,dss,dse,method};\n candidates.push(c);local.push(c);tierBuckets[tier].push(c);\n }\n candidatesBySource.set(sm.si,local);\n}\n\nconst usedS=new Set(),usedP=new Set(),matches=[];\nfor(let tier=1;tier<=5;tier++){\n const arr=tierBuckets[tier].sort((a,b)=>b.score-a.score);\n for(const c of arr){\n if(usedS.has(c.si)||usedP.has(c.pi))continue;\n usedS.add(c.si);usedP.add(c.pi);\n matches.push({...c,source:sources[c.si],plan:plans[c.pi]});\n }\n}\n// Señala matches que cuadran por monto pero cuya identidad es débil, y empates de identidad indistinguibles.\nfor(const m of matches){\n const local=candidatesBySource.get(m.si)||[];\n let ambiguous=false;\n for(const c of local){\n if(c.pi===m.pi)continue;\n if(c.tier<=m.tier&&c.dss<=.05&&c.dse<=.05&&Math.abs(c.score-m.score)<.03){ambiguous=true;break;}\n }\n m.ambiguous=ambiguous;\n m.identityReview=m.tier>=4||m.ambiguous;\n m.confidence=m.tier===1&&!m.ambiguous?'ALTA':m.tier<=3&&!m.ambiguous?'MEDIA':'REVISAR';\n}\nconst onlySource=sources.filter((_,i)=>!usedS.has(i)),onlyPlan=plans.filter((_,i)=>!usedP.has(i)),identityReview=matches.filter(m=>m.identityReview);\nconst tolerance=Number(config.amountTolerance??.10),amountDiffs=matches.filter(m=>Math.abs(m.source.totalSS-m.plan.expectedWorkerSS)>tolerance||Math.abs(m.source.totalSE-m.plan.expectedWorkerSE)>tolerance);\nconst totalSourceSS=round(sources.reduce((s,r)=>s+r.totalSS,0)),totalSourceSE=round(sources.reduce((s,r)=>s+r.totalSE,0)),totalExpectedSS=round(plans.reduce((s,r)=>s+r.expectedWorkerSS,0)),totalExpectedSE=round(plans.reduce((s,r)=>s+r.expectedWorkerSE,0)),diffSS=round(totalSourceSS-totalExpectedSS),diffSE=round(totalSourceSE-totalExpectedSE);\nconst fileReview=(pdfs.validations||[]).filter(v=>['REVISAR','EXCLUIDO'].includes(v.status)).length+periodChecks.filter(v=>v.status==='REVISAR').length+(rateInfo.usedFallbackDTM?1:0);\nconst hasFindings=Boolean(onlySource.length||onlyPlan.length||amountDiffs.length||identityReview.length||fileReview);\nconst statusText=hasFindings?'COMPLETADO · REVISAR HALLAZGOS':'COMPLETADO · SIN DIFERENCIAS',statusDetail=hasFindings?`${onlySource.length+onlyPlan.length} diferencia(s) de presencia, ${amountDiffs.length} diferencia(s) de monto, ${identityReview.length} identidad(es) con match a revisar y ${fileReview} validación(es) documental(es).`:'Las fuentes coinciden con la Planilla CSS dentro de la tolerancia definida.';\nconst title=css.reportTitle,period=css.periodoLabel;\nconst summaryRows=[[title],[period],[],['Métrica','Resultado','Interpretación'],['Estado',statusText,statusDetail],['Empleados únicos en fuentes',sources.length,'Q1 + Q2 + movimientos complementarios incluidos'],['Empleados Planilla CSS',plans.length,'Registros oficiales reconstruidos y validados'],['Coincidencias asignadas',matches.length,'Matching conservador por apellido/nombre y monto'],['Identidades a revisar',identityReview.length,'Matches por evidencia débil o ambigua; no se ocultan'],['Solo en fuentes',onlySource.length,'Sin contraparte CSS con evidencia suficiente'],['Solo en Planilla CSS',onlyPlan.length,'Sin contraparte en fuentes con evidencia suficiente'],['Diferencias de monto',amountDiffs.length,`Diferencia absoluta > B/. ${tolerance.toFixed(2)} en S.S. o S.E.`],['Tasa S.S. regular detectada',`${(ssRegular*100).toFixed(2)}%`,`${rateInfo.samplesSS} observaciones; fallback: ${rateInfo.usedFallbackSS?'sí':'no'}`],['Tasa S.E. detectada',`${(seRate*100).toFixed(2)}%`,`${rateInfo.samplesSE} observaciones; fallback: ${rateInfo.usedFallbackSE?'sí':'no'}`],['Tasa S.S. DTM detectada',`${(ssDtm*100).toFixed(2)}%`,`${rateInfo.samplesDTM} liquidaciones con DTM; fallback: ${rateInfo.usedFallbackDTM?'sí':'no'}`],['S.S. fuentes',totalSourceSS,'Suma real descontada en las fuentes'],['S.S. esperado CSS',totalExpectedSS,'Calculado con tasas detectadas sobre salario ajustado + DTM'],['Diferencia total S.S.',diffSS,'Fuentes menos esperado CSS'],['S.E. fuentes',totalSourceSE,'Suma real descontada en las fuentes'],['S.E. esperado CSS',totalExpectedSE,'Calculado con tasa detectada sobre salario ajustado'],['Diferencia total S.E.',diffSE,'Fuentes menos esperado CSS'],['Seguro Social total oficial CSS',css.totals.seguroSocial,'Validado contra reconstrucción individual'],['Seguro Educativo total oficial CSS',css.totals.seguroEducativo,'Validado contra reconstrucción individual'],['Salario ajustado total CSS',css.totals.salarioAjustado,'Validado contra reconstrucción individual'],['Nómina Q1',`${q1.fileName} (${q1.format})`,''],['Nómina Q2',`${q2.fileName} (${q2.format})`,''],['Planilla CSS',css.fileName,'']];\nconst identityRows=[[title],[period],[],['Estado','Código fuente','Nombre en fuentes','Documento CSS','Nombre Planilla CSS','S.S. fuente','S.S. esperado','Método','Confianza','Observación']];\nconst amountRows=[[title],[period],[],['Estado','Código','Nombre fuentes','Nombre CSS','S.S. Q1','S.S. Q2','S.S. Extra','S.S. Vacaciones','S.S. Liquidaciones','S.S. total','S.S. esperado','Dif. S.S.','S.E. total','S.E. esperado','Dif. S.E.']];\nfor(const m of matches){const s=m.source,p=m.plan,dss=round(s.totalSS-p.expectedWorkerSS),dse=round(s.totalSE-p.expectedWorkerSE),amtOk=Math.abs(dss)<=tolerance&&Math.abs(dse)<=tolerance;let st=amtOk?'COINCIDE':'COINCIDE · REVISAR MONTO',obs='';if(m.identityReview){st='COINCIDE · REVISAR IDENTIDAD';obs=m.ambiguous?'Existen dos o más candidatos CSS prácticamente indistinguibles por nombre y monto.':`Match conservador por ${m.method}; revisar discrepancia de nombre/apellido.`;}identityRows.push([st,s.code,s.name,p.identity,p.name,s.totalSS,p.expectedWorkerSS,m.method,m.confidence,obs]);amountRows.push([amtOk?'OK':'REVISAR',s.code,s.name,p.name,round(s.q1SS),round(s.q2SS),round(s.extraSS),round(s.vacSS),round(s.liqSS),s.totalSS,p.expectedWorkerSS,dss,s.totalSE,p.expectedWorkerSE,dse]);}\nfor(const s of onlySource){identityRows.push(['SOLO FUENTES',s.code,s.name,'','',s.totalSS,'','','','Sin contraparte CSS con evidencia suficiente']);amountRows.push(['SOLO FUENTES',s.code,s.name,'',round(s.q1SS),round(s.q2SS),round(s.extraSS),round(s.vacSS),round(s.liqSS),s.totalSS,'','',s.totalSE,'','']);}\nfor(const p of onlyPlan){identityRows.push(['SOLO PLANILLA CSS','','',p.identity,p.name,'',p.expectedWorkerSS,'','','Sin contraparte en fuentes con evidencia suficiente']);amountRows.push(['SOLO PLANILLA CSS','','',p.name,'','','','','','',p.expectedWorkerSS,'','',p.expectedWorkerSE,'']);}\nconst rank=x=>String(x).includes('SOLO')?0:String(x).includes('REVISAR')?1:2;identityRows.splice(4,identityRows.length-4,...identityRows.slice(4).sort((a,b)=>rank(a[0])-rank(b[0])||String(a[2]||a[4]).localeCompare(String(b[2]||b[4]))));amountRows.splice(4,amountRows.length-4,...amountRows.slice(4).sort((a,b)=>rank(a[0])-rank(b[0])||Math.abs(Number(b[11])||0)-Math.abs(Number(a[11])||0)));\nconst detailRows=[[title],[period],[],['Estado','Tipo','Archivo','Código','Nombre','S.S.','S.E.','Bruto/Base','Base regular','Base DTM','Fecha/Período','Transacción','Motivo']];for(const r of q1.records||[])detailRows.push(['INCLUIDO','Nómina Q1',q1.fileName,r.code,r.name,r.ss,r.se,r.gross||'',r.gross||'',0,q1.period?`${q1.period.start} - ${q1.period.end}`:'','','']);for(const r of q2.records||[])detailRows.push(['INCLUIDO','Nómina Q2',q2.fileName,r.code,r.name,r.ss,r.se,r.gross||'',r.gross||'',0,q2.period?`${q2.period.start} - ${q2.period.end}`:'','','']);for(const r of pdfs.records||[])detailRows.push([r.included?'INCLUIDO':'EXCLUIDO',r.category,r.fileName,r.code,r.name,r.ss,r.se,r.gross||'',r.regularBase||'',r.dtmBase||'',r.date||'',r.transaction||'',r.reason||'']);\nconst cssRows=[[title],[period],[],['Seguro Social #','Documento','Tipo doc.','Nombre CSS','Sexo','Salario','Salario ajustado','DTM base','S.S. trabajador esperado','S.E. trabajador esperado','S.S. total CSS','S.E. total CSS','DTM total CSS']];for(const p of plans)cssRows.push([p.ssNumber,p.identity,p.docType,p.name,p.sex,p.salary,p.salaryAdjusted,p.dtmBase,p.expectedWorkerSS,p.expectedWorkerSE,p.planillaSS,p.planillaSE,p.planillaDTM]);\nconst valRows=[[title],[period],[],['Estado','Categoría','Archivo','Detalle']];valRows.push(['OK','Nómina Q1',q1.fileName,`${q1.employeeCount} empleado(s); S.S. ${money(q1.totalSS)}; S.E. ${money(q1.totalSE)}; formato ${q1.format}; ${q1.integrityStatus}; códigos ausentes ${q1.missingCodeCount||0}`]);valRows.push(['OK','Nómina Q2',q2.fileName,`${q2.employeeCount} empleado(s); S.S. ${money(q2.totalSS)}; S.E. ${money(q2.totalSE)}; formato ${q2.format}; ${q2.integrityStatus}; códigos ausentes ${q2.missingCodeCount||0}`]);for(const v of periodChecks)valRows.push([v.status,'Período','Nóminas',v.detail]);valRows.push(['OK','Planilla CSS',css.fileName,`${css.employeeCount} empleado(s); período ${period}; integridad: ${css.integrity.map(x=>`${x.metric} dif ${money(x.diff)}`).join(' · ')}`]);valRows.push([rateInfo.usedFallbackDTM?'REVISAR':'OK','Tasas','Motor de cálculo',`S.S. regular ${(ssRegular*100).toFixed(2)}% (${rateInfo.samplesSS} muestras) · S.E. ${(seRate*100).toFixed(2)}% (${rateInfo.samplesSE}) · S.S. DTM ${(ssDtm*100).toFixed(2)}% (${rateInfo.samplesDTM})`]);for(const v of pdfs.validations||[])valRows.push([v.status,v.category,v.fileName,v.detail]);\nconst topFindings=[];for(const s of onlySource.slice(0,4))topFindings.push(`Solo fuentes: ${s.name}${s.code?` (#${s.code})`:''} · S.S. ${money(s.totalSS)}`);for(const p of onlyPlan.slice(0,4))topFindings.push(`Solo CSS: ${p.name} · ${p.identity||'sin documento'} · S.S. esperado ${money(p.expectedWorkerSS)}`);for(const m of identityReview.slice(0,4))topFindings.push(`Identidad: ${m.source.name} ↔ ${m.plan.name} · ${m.method}`);for(const m of amountDiffs.slice(0,4))topFindings.push(`Monto: ${m.source.name} · Dif. S.S. ${money(round(m.source.totalSS-m.plan.expectedWorkerSS))} · Dif. S.E. ${money(round(m.source.totalSE-m.plan.expectedWorkerSE))}`);\nconst summary={statusText,statusDetail,hasFindings,periodoLabel:period,sourceCount:sources.length,cssCount:plans.length,matches:matches.length,identityReview:identityReview.length,onlySource:onlySource.length,onlyPlan:onlyPlan.length,amountDifferences:amountDiffs.length,fileReview,totalSourceSS,totalExpectedSS,diffSS,totalSourceSE,totalExpectedSE,diffSE,rateInfo,topFindings,files:{q1:q1.fileName,q2:q2.fileName,css:css.fileName,pdfs:pdfs.files||[]},stagingQ1:q1.stagingSpreadsheetId||'',stagingQ2:q2.stagingSpreadsheetId||'',stagingCSS:css.stagingSpreadsheetId||''};\nconst createSheetBody=JSON.stringify({properties:{title},sheets:[{properties:{title:'Resumen'}},{properties:{title:'Cruce Identidad'}},{properties:{title:'Cruce Montos CSS'}},{properties:{title:'Detalle Fuentes'}},{properties:{title:'Detalle Planilla CSS'}},{properties:{title:'Validaciones'}}]});\nconst writeReportBody=JSON.stringify({valueInputOption:'RAW',data:[{range:'Resumen!A1',majorDimension:'ROWS',values:summaryRows},{range:'Cruce Identidad!A1',majorDimension:'ROWS',values:identityRows},{range:'Cruce Montos CSS!A1',majorDimension:'ROWS',values:amountRows},{range:'Detalle Fuentes!A1',majorDimension:'ROWS',values:detailRows},{range:'Detalle Planilla CSS!A1',majorDimension:'ROWS',values:cssRows},{range:'Validaciones!A1',majorDimension:'ROWS',values:valRows}]});\nreturn [{json:{reportTitle:title,periodoLabel:period,summary,summaryRows,identityRows,amountRows,detailRows,cssRows,valRows,createSheetBody,writeReportBody}}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
62144,
|
|
34832
|
|
],
|
|
"id": "6e10d4ed-e0ff-4f3c-bbd4-503735047765",
|
|
"name": "Construir reporte producción"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "POST",
|
|
"url": "https://sheets.googleapis.com/v4/spreadsheets",
|
|
"authentication": "predefinedCredentialType",
|
|
"nodeCredentialType": "googleSheetsOAuth2Api",
|
|
"sendBody": true,
|
|
"specifyBody": "json",
|
|
"jsonBody": "={{ $json.createSheetBody }}",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.3,
|
|
"position": [
|
|
62384,
|
|
34832
|
|
],
|
|
"id": "bac4af9b-bb77-4456-9e17-be8fa3af8587",
|
|
"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": "={{ $('Construir reporte producción').first().json.writeReportBody }}",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.3,
|
|
"position": [
|
|
62624,
|
|
34832
|
|
],
|
|
"id": "acc027fa-0b8e-42ae-81d3-e8fb393bf755",
|
|
"name": "Escribir reporte",
|
|
"credentials": {
|
|
"googleSheetsOAuth2Api": {
|
|
"id": "K0hDZh3a85MpOHCs",
|
|
"name": "Google Sheets account 2"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const created=$items('Crear Google Sheet Reporte')[0].json; const report=$items('Construir reporte producción')[0].json;\nconst rowsByName={'Resumen':report.summaryRows,'Cruce Identidad':report.identityRows,'Cruce Montos CSS':report.amountRows,'Detalle Fuentes':report.detailRows,'Detalle Planilla CSS':report.cssRows,'Validaciones':report.valRows};\nconst requests=[];\nfor(const s of created.sheets||[]){\n const name=s.properties.title,id=s.properties.sheetId,rows=rowsByName[name]||[]; const cols=Math.max(1,...rows.map(r=>r.length)); const rowCount=Math.max(rows.length,5);\n requests.push({updateSheetProperties:{properties:{sheetId:id,gridProperties:{hideGridlines:true,frozenRowCount:4}},fields:'gridProperties.hideGridlines,gridProperties.frozenRowCount'}});\n requests.push({repeatCell:{range:{sheetId:id,startRowIndex:0,endRowIndex:rowCount,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{textFormat:{fontFamily:'Arial',fontSize:10,foregroundColor:{red:0.290,green:0.290,blue:0.290}},verticalAlignment:'MIDDLE'}},fields:'userEnteredFormat(textFormat,verticalAlignment)'}});\n requests.push({mergeCells:{range:{sheetId:id,startRowIndex:0,endRowIndex:1,startColumnIndex:0,endColumnIndex:cols},mergeType:'MERGE_ALL'}});\n requests.push({mergeCells:{range:{sheetId:id,startRowIndex:1,endRowIndex:2,startColumnIndex:0,endColumnIndex:cols},mergeType:'MERGE_ALL'}});\n requests.push({repeatCell:{range:{sheetId:id,startRowIndex:0,endRowIndex:1,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{backgroundColor:{red:0.310,green:0.459,blue:0.545},textFormat:{fontFamily:'Arial',fontSize:13,bold:true,foregroundColor:{red:1,green:1,blue:1}},horizontalAlignment:'LEFT',borders:{bottom:{style:'SOLID_THICK',color:{red:0.424,green:0.761,blue:0.290}}}}},fields:'userEnteredFormat'}});\n requests.push({repeatCell:{range:{sheetId:id,startRowIndex:1,endRowIndex:2,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{backgroundColor:{red:0.357,green:0.498,blue:0.584},textFormat:{fontFamily:'Arial',fontSize:10,italic:true,foregroundColor:{red:1,green:1,blue:1}}}},fields:'userEnteredFormat'}});\n requests.push({repeatCell:{range:{sheetId:id,startRowIndex:3,endRowIndex:4,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{backgroundColor:{red:0.310,green:0.459,blue:0.545},textFormat:{fontFamily:'Arial',fontSize:10,bold:true,foregroundColor:{red:1,green:1,blue:1}},horizontalAlignment:'CENTER',wrapStrategy:'WRAP'}},fields:'userEnteredFormat'}});\n requests.push({autoResizeDimensions:{dimensions:{sheetId:id,dimension:'COLUMNS',startIndex:0,endIndex:cols}}});\n requests.push({updateDimensionProperties:{range:{sheetId:id,dimension:'ROWS',startIndex:0,endIndex:1},properties:{pixelSize:34},fields:'pixelSize'}});\n if(rowCount>4){\n const rules=[\n ['REVISAR',{red:0.992,green:0.925,blue:0.918},{red:0.65,green:0.15,blue:0.12}],\n ['SOLO',{red:1.0,green:0.973,blue:0.882},{red:0.55,green:0.35,blue:0.0}],\n ['ERROR',{red:0.992,green:0.925,blue:0.918},{red:0.65,green:0.15,blue:0.12}],\n ['EXCLUIDO',{red:1.0,green:0.973,blue:0.882},{red:0.55,green:0.35,blue:0.0}]\n ];\n for(const [txt,bg,fg] of rules)requests.push({addConditionalFormatRule:{rule:{ranges:[{sheetId:id,startRowIndex:4,endRowIndex:rowCount,startColumnIndex:0,endColumnIndex:1}],booleanRule:{condition:{type:'TEXT_CONTAINS',values:[{userEnteredValue:txt}]},format:{backgroundColor:bg,textFormat:{bold:true,foregroundColor:fg}}}},index:0}});\n}\n}\nreturn [{json:{formatBody:{requests}}}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
62864,
|
|
34832
|
|
],
|
|
"id": "023c71e6-974a-4ac4-9c19-cdeb97719dd6",
|
|
"name": "Preparar formato reporte"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "POST",
|
|
"url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $('Crear Google Sheet Reporte').item.json.spreadsheetId }}:batchUpdate",
|
|
"authentication": "predefinedCredentialType",
|
|
"nodeCredentialType": "googleSheetsOAuth2Api",
|
|
"sendBody": true,
|
|
"specifyBody": "json",
|
|
"jsonBody": "={{ JSON.stringify($json.formatBody) }}",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.3,
|
|
"position": [
|
|
63104,
|
|
34832
|
|
],
|
|
"id": "176ef0a3-091f-493a-ac08-53addee2e6b1",
|
|
"name": "Formatear reporte",
|
|
"credentials": {
|
|
"googleSheetsOAuth2Api": {
|
|
"id": "K0hDZh3a85MpOHCs",
|
|
"name": "Google Sheets account 2"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const init=$items('Inicializar ejecución')[0].json; const id=$items('Crear Google Sheet Reporte')[0].json.spreadsheetId; const url=`https://docs.google.com/spreadsheets/d/${id}/edit`;\nreturn init.recipients.map(emailAddress=>({json:{emailAddress,spreadsheetId:id,spreadsheetUrl:url}}));"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
63344,
|
|
34832
|
|
],
|
|
"id": "f12bf3d7-f5c9-4b12-93bc-a0da50c9c629",
|
|
"name": "Preparar permisos"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "POST",
|
|
"url": "=https://www.googleapis.com/drive/v3/files/{{ $json.spreadsheetId }}/permissions?sendNotificationEmail=false&supportsAllDrives=true",
|
|
"authentication": "predefinedCredentialType",
|
|
"nodeCredentialType": "googleDriveOAuth2Api",
|
|
"sendBody": true,
|
|
"specifyBody": "json",
|
|
"jsonBody": "={{ JSON.stringify({ role: 'writer', type: 'user', emailAddress: $json.emailAddress }) }}",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.3,
|
|
"position": [
|
|
63584,
|
|
34832
|
|
],
|
|
"id": "63765f02-d4c8-4911-bf9d-7f768ed382ff",
|
|
"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 reporte producción')[0].json,s=r.summary,id=$items('Crear Google Sheet Reporte')[0].json.spreadsheetId,url=`https://docs.google.com/spreadsheets/d/${id}/edit`;\nconst nf=n=>Number(n||0).toLocaleString('en-US'),money=n=>`B/. ${Number(n||0).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}`,esc=x=>String(x??'').replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));\nconst has=s.hasFindings,bg=has?'#FFF8E1':'#E8F5E9',state=has?'#8A5A00':'#27632A',findings=(s.topFindings||[]).length?`<div style=\"margin-top:18px;border-top:1px solid #D0D0D0;padding-top:14px;\"><div style=\"font-size:12px;font-weight:700;color:#4F758B;margin-bottom:7px;\">Principales hallazgos</div>${s.topFindings.map(x=>`<div style=\"font-size:12px;color:#4A4A4A;line-height:1.6;\">• ${esc(x)}</div>`).join('')}</div>`:'';\nconst html=`<!doctype html><html><body style=\"margin:0;background:#F5F5F5;font-family:Arial,Helvetica,sans-serif;color:#4A4A4A;\"><table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\"><tr><td align=\"center\" style=\"padding:28px 12px;\"><table role=\"presentation\" width=\"680\" cellspacing=\"0\" cellpadding=\"0\" style=\"max-width:680px;width:100%;background:#FFFFFF;border:1px solid #D0D0D0;\"><tr><td style=\"height:6px;background:#6CC24A;font-size:0;line-height:0;\"> </td></tr><tr><td style=\"padding:26px 34px 10px;\"><img src=\"${init.logoUrl}\" alt=\"GomezLee Marketing\" width=\"280\" style=\"display:block;max-width:100%;height:auto;border:0;\"></td></tr><tr><td style=\"padding:8px 34px 0;\"><div style=\"font-size:11px;font-weight:700;letter-spacing:1.4px;color:#6CC24A;\">SEGURIDAD SOCIAL · PANAMÁ</div><h1 style=\"margin:8px 0 6px;font-size:26px;line-height:1.2;color:#4F758B;\">Cruce mensual de CSS</h1><p style=\"margin:0;font-size:15px;color:#6B8FA3;\">${esc(r.periodoLabel)}</p></td></tr><tr><td style=\"padding:22px 34px 0;\"><div style=\"background:${bg};border-left:5px solid #6CC24A;padding:14px 16px;color:${state};font-size:14px;font-weight:700;\">${esc(s.statusText)}<div style=\"font-size:12px;font-weight:400;margin-top:5px;color:#4A4A4A;\">${esc(s.statusDetail)}</div></div></td></tr><tr><td style=\"padding:22px 34px 0;\"><table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" style=\"border-collapse:collapse;font-size:13px;\"><tr><td style=\"background:#4F758B;color:#FFF;font-weight:700;padding:10px;border:1px solid #D0D0D0;\">Métrica</td><td style=\"background:#4F758B;color:#FFF;font-weight:700;padding:10px;border:1px solid #D0D0D0;text-align:right;\">Resultado</td></tr><tr><td style=\"padding:9px 10px;border:1px solid #D0D0D0;\">Empleados fuentes / CSS</td><td style=\"padding:9px 10px;border:1px solid #D0D0D0;text-align:right;font-weight:700;color:#4F758B;\">${nf(s.sourceCount)} / ${nf(s.cssCount)}</td></tr><tr><td style=\"padding:9px 10px;border:1px solid #D0D0D0;background:#F5F5F5;\">Solo fuentes / solo CSS</td><td style=\"padding:9px 10px;border:1px solid #D0D0D0;background:#F5F5F5;text-align:right;font-weight:700;color:#4F758B;\">${nf(s.onlySource)} / ${nf(s.onlyPlan)}</td></tr><tr><td style=\"padding:9px 10px;border:1px solid #D0D0D0;\">Identidades a revisar</td><td style=\"padding:9px 10px;border:1px solid #D0D0D0;text-align:right;font-weight:700;color:#4F758B;\">${nf(s.identityReview)}</td></tr><tr><td style=\"padding:9px 10px;border:1px solid #D0D0D0;background:#F5F5F5;\">Diferencias de monto</td><td style=\"padding:9px 10px;border:1px solid #D0D0D0;background:#F5F5F5;text-align:right;font-weight:700;color:#4F758B;\">${nf(s.amountDifferences)}</td></tr><tr><td style=\"padding:9px 10px;border:1px solid #D0D0D0;\">Diferencia total S.S.</td><td style=\"padding:9px 10px;border:1px solid #D0D0D0;text-align:right;font-weight:700;color:#4F758B;\">${money(s.diffSS)}</td></tr><tr><td style=\"padding:9px 10px;border:1px solid #D0D0D0;background:#F5F5F5;\">Diferencia total S.E.</td><td style=\"padding:9px 10px;border:1px solid #D0D0D0;background:#F5F5F5;text-align:right;font-weight:700;color:#4F758B;\">${money(s.diffSE)}</td></tr></table>${findings}</td></tr><tr><td style=\"padding:26px 34px;\"><a href=\"${url}\" style=\"display:inline-block;background:#6CC24A;color:#FFFFFF;text-decoration:none;padding:13px 22px;font-size:14px;font-weight:700;\">Abrir reporte en Google Sheets</a><div style=\"margin-top:20px;font-size:11px;line-height:1.6;color:#6B8FA3;\">Tasas detectadas: S.S. regular ${(s.rateInfo.ssRegular*100).toFixed(2)}% · S.E. ${(s.rateInfo.se*100).toFixed(2)}% · S.S. DTM ${(s.rateInfo.ssDtm*100).toFixed(2)}%<br>Archivos base:<br>Nómina Q1: ${esc(s.files.q1||'')}<br>Nómina Q2: ${esc(s.files.q2||'')}<br>Planilla CSS: ${esc(s.files.css||'')}</div></td></tr><tr><td style=\"border-top:1px solid #D0D0D0;padding:14px 34px;font-size:10px;color:#6B8FA3;\">GOMEZLEE MARKETING · Uso interno</td></tr></table></td></tr></table></body></html>`;\nreturn [{json:{sendTo:init.recipients.join(','),subject:`${has?'[REVISAR]':'[OK]'} Cruce Seguridad Social Panamá - ${r.periodoLabel}`,html,spreadsheetId:id,spreadsheetUrl:url}}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
63824,
|
|
34832
|
|
],
|
|
"id": "40a9e530-9b60-4450-bfa3-0d7d5e2e95d3",
|
|
"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": [
|
|
64064,
|
|
34832
|
|
],
|
|
"id": "33c125b7-9b00-4e68-b197-2cfcd8651f55",
|
|
"name": "Enviar correo resumen",
|
|
"webhookId": "ae5f87e7-df2b-4349-8eb5-aac208accb96",
|
|
"executeOnce": true,
|
|
"credentials": {
|
|
"gmailOAuth2": {
|
|
"id": "UDcO1FLJqA453V2D",
|
|
"name": "Gmail account 3"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const init=$items('Inicializar ejecución')[0].json,r=$items('Construir reporte producción')[0].json,s=r.summary,id=$items('Crear Google Sheet Reporte')[0].json.spreadsheetId,reportUrl=`https://docs.google.com/spreadsheets/d/${id}/edit`,formUrl=init.formUrl;\nconst html=`<div style=\"font-family:Arial,Helvetica,sans-serif;max-width:680px;margin:48px auto;padding:36px 30px;text-align:center;background:#ffffff;border:1px solid #D0D0D0;color:#4A4A4A;\"><div style=\"height:6px;background:#6CC24A;margin:-36px -30px 28px;\"></div><img src=\"https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png\" alt=\"GomezLee Marketing\" width=\"250\" style=\"max-width:90%;height:auto;margin:0 auto 18px;\"><div style=\"font-size:12px;font-weight:700;letter-spacing:1.4px;color:#6CC24A;margin-bottom:8px;\">SEGURIDAD SOCIAL · PANAMÁ</div><h1 style=\"margin:0 0 8px;font-size:28px;color:#4F758B;\">Cruce mensual completado</h1><p style=\"margin:0 0 10px;font-size:16px;color:#6B8FA3;\">${r.periodoLabel}</p><p style=\"margin:0 0 10px;font-size:14px;line-height:1.6;color:#4A4A4A;\"><strong>${s.statusText}</strong><br>${s.statusDetail}</p><p style=\"margin:0 0 26px;font-size:12px;color:#6B8FA3;\">Fuentes / CSS: ${s.sourceCount} / ${s.cssCount} · Solo fuentes / CSS: ${s.onlySource} / ${s.onlyPlan} · Identidades a revisar: ${s.identityReview} · Diferencias de monto: ${s.amountDifferences}</p><a href=\"${reportUrl}\" target=\"_blank\" rel=\"noopener\" style=\"display:inline-block;background:#4F758B;color:#ffffff;text-decoration:none;padding:13px 22px;font-size:14px;font-weight:700;margin:0 6px 10px;\">Abrir reporte</a><a href=\"${formUrl}\" target=\"_top\" rel=\"noopener\" style=\"display:inline-block;background:#6CC24A;color:#ffffff;text-decoration:none;padding:13px 22px;font-size:14px;font-weight:700;margin:0 6px 10px;\">Realizar otro cruce</a><p style=\"margin:16px 0 0;font-size:11px;color:#6B8FA3;\">Si el botón no responde, usa este enlace: <a href=\"${formUrl}\" target=\"_top\" style=\"color:#4F758B;\">volver al formulario</a>.</p></div>`;\nreturn [{json:{html}}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
64784,
|
|
34832
|
|
],
|
|
"id": "034abc56-cf5b-4a09-a6c5-c110c46683eb",
|
|
"name": "Preparar confirmación"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "completion",
|
|
"respondWith": "showText",
|
|
"responseText": "={{ $json.html }}"
|
|
},
|
|
"type": "n8n-nodes-base.form",
|
|
"typeVersion": 2.5,
|
|
"position": [
|
|
65024,
|
|
34832
|
|
],
|
|
"id": "b949ed7f-046d-4d13-beaf-d064a8b6e5bf",
|
|
"name": "Confirmación y regreso al formulario",
|
|
"webhookId": "d4a102a8-3d72-4c5b-83b2-0e0d04c71099",
|
|
"notesInFlow": true,
|
|
"notes": "Página final con reporte y botón que vuelve exactamente al mismo Form Trigger de Panamá."
|
|
},
|
|
{
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": "",
|
|
"typeValidation": "strict",
|
|
"version": 2
|
|
},
|
|
"conditions": [
|
|
{
|
|
"id": "fd4e3c0d-3ba7-4267-b86c-3b8771da3d64",
|
|
"leftValue": "={{ $json.format }}",
|
|
"rightValue": "PDF",
|
|
"operator": {
|
|
"type": "string",
|
|
"operation": "equals"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2.3,
|
|
"position": [
|
|
54464,
|
|
34912
|
|
],
|
|
"id": "1c33ef12-3450-4ad1-b9f9-f742d02bd5c6",
|
|
"name": "¿Q1 es PDF?"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "pdf",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
54704,
|
|
35056
|
|
],
|
|
"id": "c291f41f-cb07-4314-9e34-cb20c98f63e5",
|
|
"name": "Extraer texto Q1 PDF"
|
|
},
|
|
{
|
|
"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;j<cur.length;j++)prev[j]=cur[j];}return 1-prev[b.length]/Math.max(a.length,b.length);}\nfunction subseq(short,long){short=norm(short).replace(/[^A-Z]/g,'');long=norm(long).replace(/[^A-Z]/g,'');if(!short||!long)return false;let j=0;for(const ch of long)if(ch===short[j])j++;return j===short.length;}\nfunction alphaCodeScore(code,name){\n const parts=norm(name).split(',');\n const tokens=parts.join(' ').split(/\\s+/).filter(Boolean);\n let best=0;\n for(const t of tokens){\n if(t===code)best=Math.max(best,1);\n if(t.startsWith(code))best=Math.max(best,.99);\n if(t.endsWith(code))best=Math.max(best,.84);\n if(t.includes(code))best=Math.max(best,.90);\n if(subseq(code,t))best=Math.max(best,.97);\n best=Math.max(best,.82*sim(code,t.slice(0,Math.max(code.length,1))));\n }\n const initials=tokens.map(t=>t[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;i<Math.min(vals.length-1,10);i++){\n const se0=Number(vals[i]),ss0=Number(vals[i+1]);\n for(let j=i+2;j<=Math.min(vals.length-1,i+5);j++){\n const g=Number(vals[j]);if(!(g>20)||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<best.score)best={score,se:se0,ss:ss0,gross:g,j};\n }\n }\n if(best&&best.score<.75){se=round(best.se);ss=round(best.ss);gross=round(best.gross);otherIncome=best.j+1<vals.length?round(vals[best.j+1]):0;}\n }\n if(!plausible(gross,ss,se))continue;\n flat.push({code,name,ss,se,gross,otherIncome,totalDeductions:round(vals[1]),net:round(vals[0]),source:'Nómina Q1',fileName:prep.fileName,period});\n}\n\n// MODO 3: extractor que entrega cada importe en líneas verticales separadas.\nconst lines=text.split('\\n').map(clean),vertical=[];\nfor(let i=0;i<lines.length;i++){\n const name=lines[i],u=norm(name);if(!name.includes(',')||!/[A-ZÁÉÍÓÚÑ]/i.test(name))continue;\n if(/GOMEZLEE|DEPARTAMENTO|GENERADA|TOTALES|NOMBRE DEL EMPLEADO|PLANILLA|PERIODO/.test(u))continue;\n const nums=[];let j=i-1;\n while(j>=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||score<best.score)best={score,net,deducciones,se,ss,otrosIngresos,gross};\n }\n if(!best)continue;let code='';\n for(let z=i+1;z<Math.min(lines.length,i+4);z++){if(!lines[z])continue;if(/^[A-Za-z0-9]{1,8}$/.test(lines[z]))code=lines[z];break;}\n vertical.push({code:code.replace(/^0+(?=\\d)/,''),name,ss:round(best.ss),se:round(best.se),gross:round(best.gross),otherIncome:round(best.otrosIngresos),totalDeductions:round(best.deducciones),net:round(best.net),source:'Nómina Q1',fileName:prep.fileName,period});\n}\n\nconst candidates=[\n {mode:'TABULAR_23_CAMPOS',rows:dedupe(horizontal)},\n {mode:'PDFJS_INVERTIDO_CONCATENADO',rows:dedupe(flat)},\n {mode:'VERTICAL_VALIDADO',rows:dedupe(vertical)}\n];\nlet chosen=null;\nif(printedCount>0)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// El PDF de nómina puede imprimir fragmentos del apellido en la columna Código.\n// Solo un Employee # estrictamente numérico se usa como llave; los demás quedan vacíos\n// para que el motor una por nombre exacto y no cree identidades falsas.\nfor(const r of out){\n const c=clean(r.code);\n r.code=/^\\d{1,8}$/.test(c)?c.replace(/^0+(?=\\d)/,''):'';\n}\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<minCoverage||ssRatios.length<minCoverage||seRatios.length<minCoverage)throw new Error(`Nómina Q1 PDF: se detectaron ${out.length} empleados, pero solo ${grossPositive} registros conservaron una base salarial utilizable y ${ssRatios.length}/${seRatios.length} aportes S.S./S.E. Se detuvo el cruce porque la extracción PDF no preservó suficiente información monetaria.`);\nconst medSS=median(ssRatios),medSE=median(seRatios);\nif(!(medSS>.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": [
|
|
54960,
|
|
35024
|
|
],
|
|
"id": "e256e1d6-2774-462c-9d55-5a031485d6cb",
|
|
"name": "Parsear Q1 PDF"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const r=$input.first().json;if(!Array.isArray(r.records)||!r.records.length)throw new Error('Resultado Q1 vacío.');return [{json:r}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
56624,
|
|
34832
|
|
],
|
|
"id": "3d6d60e8-487a-41cc-b256-46d813aace5c",
|
|
"name": "Resultado Q1"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": "",
|
|
"typeValidation": "strict",
|
|
"version": 2
|
|
},
|
|
"conditions": [
|
|
{
|
|
"id": "5ceced14-980c-46ea-a1ee-3845c8620a90",
|
|
"leftValue": "={{ $json.format }}",
|
|
"rightValue": "PDF",
|
|
"operator": {
|
|
"type": "string",
|
|
"operation": "equals"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2.3,
|
|
"position": [
|
|
57104,
|
|
34912
|
|
],
|
|
"id": "b4e4e628-7ddb-475a-8983-892aac060cf7",
|
|
"name": "¿Q2 es PDF?"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "pdf",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
57344,
|
|
35056
|
|
],
|
|
"id": "3223cc16-c235-4286-ab4b-6caa1bc402b7",
|
|
"name": "Extraer texto Q2 PDF"
|
|
},
|
|
{
|
|
"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;j<cur.length;j++)prev[j]=cur[j];}return 1-prev[b.length]/Math.max(a.length,b.length);}\nfunction subseq(short,long){short=norm(short).replace(/[^A-Z]/g,'');long=norm(long).replace(/[^A-Z]/g,'');if(!short||!long)return false;let j=0;for(const ch of long)if(ch===short[j])j++;return j===short.length;}\nfunction alphaCodeScore(code,name){\n const parts=norm(name).split(',');\n const tokens=parts.join(' ').split(/\\s+/).filter(Boolean);\n let best=0;\n for(const t of tokens){\n if(t===code)best=Math.max(best,1);\n if(t.startsWith(code))best=Math.max(best,.99);\n if(t.endsWith(code))best=Math.max(best,.84);\n if(t.includes(code))best=Math.max(best,.90);\n if(subseq(code,t))best=Math.max(best,.97);\n best=Math.max(best,.82*sim(code,t.slice(0,Math.max(code.length,1))));\n }\n const initials=tokens.map(t=>t[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;i<Math.min(vals.length-1,10);i++){\n const se0=Number(vals[i]),ss0=Number(vals[i+1]);\n for(let j=i+2;j<=Math.min(vals.length-1,i+5);j++){\n const g=Number(vals[j]);if(!(g>20)||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<best.score)best={score,se:se0,ss:ss0,gross:g,j};\n }\n }\n if(best&&best.score<.75){se=round(best.se);ss=round(best.ss);gross=round(best.gross);otherIncome=best.j+1<vals.length?round(vals[best.j+1]):0;}\n }\n if(!plausible(gross,ss,se))continue;\n flat.push({code,name,ss,se,gross,otherIncome,totalDeductions:round(vals[1]),net:round(vals[0]),source:'Nómina Q2',fileName:prep.fileName,period});\n}\n\n// MODO 3: extractor que entrega cada importe en líneas verticales separadas.\nconst lines=text.split('\\n').map(clean),vertical=[];\nfor(let i=0;i<lines.length;i++){\n const name=lines[i],u=norm(name);if(!name.includes(',')||!/[A-ZÁÉÍÓÚÑ]/i.test(name))continue;\n if(/GOMEZLEE|DEPARTAMENTO|GENERADA|TOTALES|NOMBRE DEL EMPLEADO|PLANILLA|PERIODO/.test(u))continue;\n const nums=[];let j=i-1;\n while(j>=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||score<best.score)best={score,net,deducciones,se,ss,otrosIngresos,gross};\n }\n if(!best)continue;let code='';\n for(let z=i+1;z<Math.min(lines.length,i+4);z++){if(!lines[z])continue;if(/^[A-Za-z0-9]{1,8}$/.test(lines[z]))code=lines[z];break;}\n vertical.push({code:code.replace(/^0+(?=\\d)/,''),name,ss:round(best.ss),se:round(best.se),gross:round(best.gross),otherIncome:round(best.otrosIngresos),totalDeductions:round(best.deducciones),net:round(best.net),source:'Nómina Q2',fileName:prep.fileName,period});\n}\n\nconst candidates=[\n {mode:'TABULAR_23_CAMPOS',rows:dedupe(horizontal)},\n {mode:'PDFJS_INVERTIDO_CONCATENADO',rows:dedupe(flat)},\n {mode:'VERTICAL_VALIDADO',rows:dedupe(vertical)}\n];\nlet chosen=null;\nif(printedCount>0)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// El PDF de nómina puede imprimir fragmentos del apellido en la columna Código.\n// Solo un Employee # estrictamente numérico se usa como llave; los demás quedan vacíos\n// para que el motor una por nombre exacto y no cree identidades falsas.\nfor(const r of out){\n const c=clean(r.code);\n r.code=/^\\d{1,8}$/.test(c)?c.replace(/^0+(?=\\d)/,''):'';\n}\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<minCoverage||ssRatios.length<minCoverage||seRatios.length<minCoverage)throw new Error(`Nómina Q2 PDF: se detectaron ${out.length} empleados, pero solo ${grossPositive} registros conservaron una base salarial utilizable y ${ssRatios.length}/${seRatios.length} aportes S.S./S.E. Se detuvo el cruce porque la extracción PDF no preservó suficiente información monetaria.`);\nconst medSS=median(ssRatios),medSE=median(seRatios);\nif(!(medSS>.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": [
|
|
57584,
|
|
35056
|
|
],
|
|
"id": "dd41d8e5-d079-4422-a22c-ca2010fa31c2",
|
|
"name": "Parsear Q2 PDF"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const r=$input.first().json;if(!Array.isArray(r.records)||!r.records.length)throw new Error('Resultado Q2 vacío.');return [{json:r}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
58544,
|
|
34832
|
|
],
|
|
"id": "22ae7500-d025-4854-aeab-7b6827c4fb14",
|
|
"name": "Resultado Q2"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const q1=$items('Resultado Q1')[0].json;\nconst q2=$items('Resultado Q2')[0].json;\nconst css=$items('Parsear CSS')[0].json;\nconst tempFolder=$items('Crear carpeta temporal PDFs')[0]?.json?.id||'';\nconst out=[];\nfor(const [tipo,id] of [\n ['Q1',q1.stagingSpreadsheetId],\n ['Q2',q2.stagingSpreadsheetId],\n ['CSS',css.stagingSpreadsheetId],\n ['PDFS_TEMP',tempFolder]\n]) if(id) out.push({json:{tipo,fileId:id}});\nreturn out.length?out:[{json:{tipo:'NINGUNO',fileId:''}}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
64304,
|
|
34832
|
|
],
|
|
"id": "a358393b-3223-450e-a6db-4a146838f266",
|
|
"name": "Preparar limpieza temporales"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "DELETE",
|
|
"url": "=https://www.googleapis.com/drive/v3/files/{{ $json.fileId }}?supportsAllDrives=true",
|
|
"authentication": "predefinedCredentialType",
|
|
"nodeCredentialType": "googleDriveOAuth2Api",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.3,
|
|
"position": [
|
|
64544,
|
|
34832
|
|
],
|
|
"id": "c20af9b0-54e4-44c9-be47-5c277d575807",
|
|
"name": "Eliminar staging temporal",
|
|
"alwaysOutputData": true,
|
|
"notesInFlow": true,
|
|
"credentials": {
|
|
"googleDriveOAuth2Api": {
|
|
"id": "g23xdGLZRzBGqKgH",
|
|
"name": "Isaac - Google Drive"
|
|
}
|
|
},
|
|
"continueOnFail": true,
|
|
"notes": "Limpieza best-effort: elimina staging Q1/Q2/CSS y la carpeta temporal de PDFs. Si falla, no bloquea la pantalla final."
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "POST",
|
|
"url": "https://www.googleapis.com/drive/v3/files?fields=id,name,mimeType&supportsAllDrives=true",
|
|
"authentication": "predefinedCredentialType",
|
|
"nodeCredentialType": "googleDriveOAuth2Api",
|
|
"sendBody": true,
|
|
"specifyBody": "json",
|
|
"jsonBody": "={{ JSON.stringify({ name: `TEMP PA CSS PDFs - ${$('Inicializar ejecución').item.json.runId}`, mimeType: 'application/vnd.google-apps.folder' }) }}",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.3,
|
|
"position": [
|
|
54224,
|
|
35216
|
|
],
|
|
"id": "09fa4c3c-7a56-46ae-a0ef-2bd5030a1417",
|
|
"name": "Crear carpeta temporal PDFs",
|
|
"retryOnFail": true,
|
|
"maxTries": 3,
|
|
"waitBetweenTries": 2000,
|
|
"notesInFlow": true,
|
|
"credentials": {
|
|
"googleDriveOAuth2Api": {
|
|
"id": "g23xdGLZRzBGqKgH",
|
|
"name": "Isaac - Google Drive"
|
|
}
|
|
},
|
|
"notes": "Carpeta temporal aislada por ejecución para acumular PDFs cargados en varias tandas sin perder selecciones anteriores."
|
|
},
|
|
{
|
|
"parameters": {
|
|
"formFields": {
|
|
"values": [
|
|
{
|
|
"fieldLabel": "¿Hay extraordinarias este mes?",
|
|
"fieldType": "radio",
|
|
"fieldOptions": {
|
|
"values": [
|
|
{
|
|
"option": "Sí"
|
|
},
|
|
{
|
|
"option": "No"
|
|
}
|
|
]
|
|
},
|
|
"requiredField": true
|
|
}
|
|
]
|
|
},
|
|
"options": {
|
|
"formTitle": "Paso 2 de 4 · Extraordinarias",
|
|
"formDescription": "Indica si este mes existen planillas extraordinarias. Este es el único grupo opcional.",
|
|
"buttonLabel": "Continuar"
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.form",
|
|
"typeVersion": 2.5,
|
|
"position": [
|
|
54464,
|
|
35216
|
|
],
|
|
"id": "8f7476cf-6957-4714-9cd3-9f71feb1e687",
|
|
"name": "Definir Extraordinarias",
|
|
"webhookId": "2d4fba32-6a24-46e8-a9c7-a38ca80b106a"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": "",
|
|
"typeValidation": "strict",
|
|
"version": 2
|
|
},
|
|
"conditions": [
|
|
{
|
|
"id": "e7a0d1ad-362a-4267-b2fd-ba254621eeaa",
|
|
"leftValue": "={{ $json[\"¿Hay extraordinarias este mes?\"] }}",
|
|
"rightValue": "Sí",
|
|
"operator": {
|
|
"type": "string",
|
|
"operation": "equals"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2.3,
|
|
"position": [
|
|
54704,
|
|
35216
|
|
],
|
|
"id": "953ada8a-80d8-40ee-a130-c040a9376a09",
|
|
"name": "¿Hay extraordinarias?"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"formFields": {
|
|
"values": [
|
|
{
|
|
"fieldLabel": "Archivos extraordinarios",
|
|
"fieldType": "file",
|
|
"acceptFileTypes": ".pdf,application/pdf",
|
|
"requiredField": true
|
|
},
|
|
{
|
|
"fieldLabel": "¿Agregar más extraordinarias?",
|
|
"fieldType": "radio",
|
|
"fieldOptions": {
|
|
"values": [
|
|
{
|
|
"option": "Sí"
|
|
},
|
|
{
|
|
"option": "No"
|
|
}
|
|
]
|
|
},
|
|
"requiredField": true
|
|
}
|
|
]
|
|
},
|
|
"options": {
|
|
"formTitle": "Extraordinarias · cargar lote",
|
|
"formDescription": "Puedes seleccionar uno o varios PDFs a la vez. Si tienes más archivos en otra carpeta, selecciona “Sí” en “Agregar más” y volverás a esta misma pantalla sin perder los archivos ya guardados.",
|
|
"buttonLabel": "Guardar lote"
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.form",
|
|
"typeVersion": 2.5,
|
|
"position": [
|
|
54960,
|
|
35024
|
|
],
|
|
"id": "4bf3e422-a9fe-4f98-9122-c3c4edff7f0f",
|
|
"name": "Cargar Extraordinarias",
|
|
"webhookId": "237dd241-cbba-4455-beec-b4b63e45827d"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const item=$input.first();\nconst bin=item.binary||{};\nconst files=Object.entries(bin);\nfunction clean(v){return String(v??'').trim();}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase();}\nfunction yes(v){return ['si','sí','yes','true','1'].includes(norm(v));}\nfunction safeName(v){return clean(v).replace(/[\\\\/\\x00-\\x1F]/g,'_').replace(/__+/g,'_').slice(-150)||'archivo.pdf';}\nif(!files.length) throw new Error('Debes adjuntar al menos un PDF en este lote de Extraordinarias.');\nconst more=yes(item.json?.['¿Agregar más extraordinarias?']);\nconst init=$items('Inicializar ejecución')[0].json;\nconst max=Number(init.config?.maxSupplementalFilesSafety||500);\nif(files.length>max) throw new Error(`Se recibieron ${files.length} archivos en un solo lote; supera el límite técnico de seguridad (${max}).`);\nconst now=Date.now();\nconst out=[];\nfor(let i=0;i<files.length;i++){\n const [key,file]=files[i];\n const original=safeName(file?.fileName||key);\n const mime=norm(file?.mimeType||'');\n if(!original.toLowerCase().endsWith('.pdf') && !mime.includes('application/pdf')){\n throw new Error(`El archivo “${original}” no es PDF. Extraordinarias solo acepta archivos PDF.`);\n }\n const nonce=`${now}_${i}_${Math.random().toString(36).slice(2,7)}`;\n const uploadName='EXTRA__MORE'+(more?'1':'0')+'__'+nonce+'__ORIG__'+original;\n out.push({json:{uploadName,originalName:original,category:'EXTRA',addMore:more?'SI':'NO'},binary:{data:file}});\n}\nreturn out;"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
55200,
|
|
35024
|
|
],
|
|
"id": "d7cbe170-a566-42bf-9653-d5282de9a8c7",
|
|
"name": "Preparar lote Extraordinarias",
|
|
"notesInFlow": true,
|
|
"notes": "Valida PDF y prepara cada archivo del lote para guardarlo temporalmente."
|
|
},
|
|
{
|
|
"parameters": {
|
|
"name": "={{ $json.uploadName }}",
|
|
"driveId": {
|
|
"__rl": true,
|
|
"value": "My Drive",
|
|
"mode": "list",
|
|
"cachedResultName": "My Drive"
|
|
},
|
|
"folderId": {
|
|
"__rl": true,
|
|
"value": "={{ $('Crear carpeta temporal PDFs').item.json.id }}",
|
|
"mode": "id"
|
|
},
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.googleDrive",
|
|
"typeVersion": 3,
|
|
"position": [
|
|
55440,
|
|
35024
|
|
],
|
|
"id": "88420c7c-55f7-46e5-8a22-fe6f5fb91182",
|
|
"name": "Guardar lote Extraordinarias",
|
|
"retryOnFail": true,
|
|
"maxTries": 3,
|
|
"waitBetweenTries": 2000,
|
|
"credentials": {
|
|
"googleDriveOAuth2Api": {
|
|
"id": "g23xdGLZRzBGqKgH",
|
|
"name": "Isaac - Google Drive"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const uploaded=$input.all();\nlet marker='';\nfor(const it of uploaded){\n const n=String(it.json?.name||it.json?.fileName||'');\n if(n.includes('__MORE1__')||n.includes('__MORE0__')){marker=n;break;}\n}\nif(!marker){\n const prep=$items('Preparar lote Extraordinarias');\n marker=String(prep?.[0]?.json?.uploadName||'');\n}\nif(!marker) throw new Error('No pude confirmar si el usuario desea agregar más archivos. Se detuvo para evitar perder una tanda.');\nreturn [{json:{addMore:marker.includes('__MORE1__')?'SI':'NO',uploaded:uploaded.length}}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
55680,
|
|
35024
|
|
],
|
|
"id": "11655d6b-b8c4-4931-bfe5-7cc6e21d65be",
|
|
"name": "Cerrar lote Extraordinarias"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": "",
|
|
"typeValidation": "strict",
|
|
"version": 3
|
|
},
|
|
"conditions": [
|
|
{
|
|
"id": "eb1cc075-acb9-4146-a2c5-2d7910f26e9c",
|
|
"leftValue": "={{ $json.addMore }}",
|
|
"rightValue": "SI",
|
|
"operator": {
|
|
"type": "string",
|
|
"operation": "equals"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2.3,
|
|
"position": [
|
|
55920,
|
|
35024
|
|
],
|
|
"id": "b7f00446-e4a5-4314-a0dc-f27916f57120",
|
|
"name": "¿Agregar más Extraordinarias?"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"formFields": {
|
|
"values": [
|
|
{
|
|
"fieldLabel": "Archivos de vacaciones",
|
|
"fieldType": "file",
|
|
"acceptFileTypes": ".pdf,application/pdf",
|
|
"requiredField": true
|
|
},
|
|
{
|
|
"fieldLabel": "¿Agregar más vacaciones?",
|
|
"fieldType": "radio",
|
|
"fieldOptions": {
|
|
"values": [
|
|
{
|
|
"option": "Sí"
|
|
},
|
|
{
|
|
"option": "No"
|
|
}
|
|
]
|
|
},
|
|
"requiredField": true
|
|
}
|
|
]
|
|
},
|
|
"options": {
|
|
"formTitle": "Paso 3 de 4 · Vacaciones",
|
|
"formDescription": "Debes cargar al menos un PDF. Puedes seleccionar varios de una vez o cargar una tanda, elegir “Sí” en “Agregar más” y continuar desde otra carpeta.",
|
|
"buttonLabel": "Guardar lote"
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.form",
|
|
"typeVersion": 2.5,
|
|
"position": [
|
|
56160,
|
|
35184
|
|
],
|
|
"id": "cacdbf95-a924-469f-8b6b-5ec5ed9043c4",
|
|
"name": "Cargar Vacaciones",
|
|
"webhookId": "4405d87c-973b-4828-8f0b-e75ec83473f3"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const item=$input.first();\nconst bin=item.binary||{};\nconst files=Object.entries(bin);\nfunction clean(v){return String(v??'').trim();}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase();}\nfunction yes(v){return ['si','sí','yes','true','1'].includes(norm(v));}\nfunction safeName(v){return clean(v).replace(/[\\\\/\\x00-\\x1F]/g,'_').replace(/__+/g,'_').slice(-150)||'archivo.pdf';}\nif(!files.length) throw new Error('Debes adjuntar al menos un PDF en este lote de Vacaciones.');\nconst more=yes(item.json?.['¿Agregar más vacaciones?']);\nconst init=$items('Inicializar ejecución')[0].json;\nconst max=Number(init.config?.maxSupplementalFilesSafety||500);\nif(files.length>max) throw new Error(`Se recibieron ${files.length} archivos en un solo lote; supera el límite técnico de seguridad (${max}).`);\nconst now=Date.now();\nconst out=[];\nfor(let i=0;i<files.length;i++){\n const [key,file]=files[i];\n const original=safeName(file?.fileName||key);\n const mime=norm(file?.mimeType||'');\n if(!original.toLowerCase().endsWith('.pdf') && !mime.includes('application/pdf')){\n throw new Error(`El archivo “${original}” no es PDF. Vacaciones solo acepta archivos PDF.`);\n }\n const nonce=`${now}_${i}_${Math.random().toString(36).slice(2,7)}`;\n const uploadName='VAC__MORE'+(more?'1':'0')+'__'+nonce+'__ORIG__'+original;\n out.push({json:{uploadName,originalName:original,category:'VAC',addMore:more?'SI':'NO'},binary:{data:file}});\n}\nreturn out;"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
56400,
|
|
35184
|
|
],
|
|
"id": "615b13f0-62b6-41d9-b73c-df8156f4fbd4",
|
|
"name": "Preparar lote Vacaciones"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"name": "={{ $json.uploadName }}",
|
|
"driveId": {
|
|
"__rl": true,
|
|
"value": "My Drive",
|
|
"mode": "list",
|
|
"cachedResultName": "My Drive"
|
|
},
|
|
"folderId": {
|
|
"__rl": true,
|
|
"value": "={{ $('Crear carpeta temporal PDFs').item.json.id }}",
|
|
"mode": "id"
|
|
},
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.googleDrive",
|
|
"typeVersion": 3,
|
|
"position": [
|
|
56640,
|
|
35184
|
|
],
|
|
"id": "8ef5b6f6-e0c0-46e6-9063-ac981022c548",
|
|
"name": "Guardar lote Vacaciones",
|
|
"retryOnFail": true,
|
|
"maxTries": 3,
|
|
"waitBetweenTries": 2000,
|
|
"credentials": {
|
|
"googleDriveOAuth2Api": {
|
|
"id": "g23xdGLZRzBGqKgH",
|
|
"name": "Isaac - Google Drive"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const uploaded=$input.all();\nlet marker='';\nfor(const it of uploaded){\n const n=String(it.json?.name||it.json?.fileName||'');\n if(n.includes('__MORE1__')||n.includes('__MORE0__')){marker=n;break;}\n}\nif(!marker){\n const prep=$items('Preparar lote Vacaciones');\n marker=String(prep?.[0]?.json?.uploadName||'');\n}\nif(!marker) throw new Error('No pude confirmar si el usuario desea agregar más archivos. Se detuvo para evitar perder una tanda.');\nreturn [{json:{addMore:marker.includes('__MORE1__')?'SI':'NO',uploaded:uploaded.length}}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
56864,
|
|
35216
|
|
],
|
|
"id": "cd731fba-163e-4102-867f-93fdd05161c9",
|
|
"name": "Cerrar lote Vacaciones"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": "",
|
|
"typeValidation": "strict",
|
|
"version": 2
|
|
},
|
|
"conditions": [
|
|
{
|
|
"id": "0e7eace4-4da9-46a9-bc0b-ca6b3f309241",
|
|
"leftValue": "={{ $json.addMore }}",
|
|
"rightValue": "SI",
|
|
"operator": {
|
|
"type": "string",
|
|
"operation": "equals"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2.3,
|
|
"position": [
|
|
57104,
|
|
35216
|
|
],
|
|
"id": "fb6e392f-5d4a-4474-baf9-bb4079018fdc",
|
|
"name": "¿Agregar más Vacaciones?"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"formFields": {
|
|
"values": [
|
|
{
|
|
"fieldLabel": "Archivos de liquidaciones",
|
|
"fieldType": "file",
|
|
"acceptFileTypes": ".pdf,application/pdf",
|
|
"requiredField": true
|
|
},
|
|
{
|
|
"fieldLabel": "¿Agregar más liquidaciones?",
|
|
"fieldType": "radio",
|
|
"fieldOptions": {
|
|
"values": [
|
|
{
|
|
"option": "Sí"
|
|
},
|
|
{
|
|
"option": "No"
|
|
}
|
|
]
|
|
},
|
|
"requiredField": true
|
|
}
|
|
]
|
|
},
|
|
"options": {
|
|
"formTitle": "Paso 4 de 4 · Liquidaciones",
|
|
"formDescription": "Debes cargar al menos un PDF. Puedes seleccionar varios de una vez o repetir esta misma etapa tantas veces como necesites. Al elegir “No”, comenzará automáticamente el cruce.",
|
|
"buttonLabel": "Guardar lote"
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.form",
|
|
"typeVersion": 2.5,
|
|
"position": [
|
|
57344,
|
|
35216
|
|
],
|
|
"id": "4a7d5715-a5fe-4a86-84d4-78ad63edb8ea",
|
|
"name": "Cargar Liquidaciones",
|
|
"webhookId": "f04eb408-97b4-4c5c-9c90-53e58021ea20"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const item=$input.first();\nconst bin=item.binary||{};\nconst files=Object.entries(bin);\nfunction clean(v){return String(v??'').trim();}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase();}\nfunction yes(v){return ['si','sí','yes','true','1'].includes(norm(v));}\nfunction safeName(v){return clean(v).replace(/[\\\\/\\x00-\\x1F]/g,'_').replace(/__+/g,'_').slice(-150)||'archivo.pdf';}\nif(!files.length) throw new Error('Debes adjuntar al menos un PDF en este lote de Liquidaciones.');\nconst more=yes(item.json?.['¿Agregar más liquidaciones?']);\nconst init=$items('Inicializar ejecución')[0].json;\nconst max=Number(init.config?.maxSupplementalFilesSafety||500);\nif(files.length>max) throw new Error(`Se recibieron ${files.length} archivos en un solo lote; supera el límite técnico de seguridad (${max}).`);\nconst now=Date.now();\nconst out=[];\nfor(let i=0;i<files.length;i++){\n const [key,file]=files[i];\n const original=safeName(file?.fileName||key);\n const mime=norm(file?.mimeType||'');\n if(!original.toLowerCase().endsWith('.pdf') && !mime.includes('application/pdf')){\n throw new Error(`El archivo “${original}” no es PDF. Liquidaciones solo acepta archivos PDF.`);\n }\n const nonce=`${now}_${i}_${Math.random().toString(36).slice(2,7)}`;\n const uploadName='LIQ__MORE'+(more?'1':'0')+'__'+nonce+'__ORIG__'+original;\n out.push({json:{uploadName,originalName:original,category:'LIQ',addMore:more?'SI':'NO'},binary:{data:file}});\n}\nreturn out;"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
57584,
|
|
35216
|
|
],
|
|
"id": "1aacdced-cc5a-459e-9314-cbe0c48535ec",
|
|
"name": "Preparar lote Liquidaciones"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"name": "={{ $json.uploadName }}",
|
|
"driveId": {
|
|
"__rl": true,
|
|
"value": "My Drive",
|
|
"mode": "list",
|
|
"cachedResultName": "My Drive"
|
|
},
|
|
"folderId": {
|
|
"__rl": true,
|
|
"value": "={{ $('Crear carpeta temporal PDFs').item.json.id }}",
|
|
"mode": "id"
|
|
},
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.googleDrive",
|
|
"typeVersion": 3,
|
|
"position": [
|
|
57824,
|
|
35216
|
|
],
|
|
"id": "a68ad67c-236c-4417-a527-f570dcf6803a",
|
|
"name": "Guardar lote Liquidaciones",
|
|
"retryOnFail": true,
|
|
"maxTries": 3,
|
|
"waitBetweenTries": 2000,
|
|
"credentials": {
|
|
"googleDriveOAuth2Api": {
|
|
"id": "g23xdGLZRzBGqKgH",
|
|
"name": "Isaac - Google Drive"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const uploaded=$input.all();\nlet marker='';\nfor(const it of uploaded){\n const n=String(it.json?.name||it.json?.fileName||'');\n if(n.includes('__MORE1__')||n.includes('__MORE0__')){marker=n;break;}\n}\nif(!marker){\n const prep=$items('Preparar lote Liquidaciones');\n marker=String(prep?.[0]?.json?.uploadName||'');\n}\nif(!marker) throw new Error('No pude confirmar si el usuario desea agregar más archivos. Se detuvo para evitar perder una tanda.');\nreturn [{json:{addMore:marker.includes('__MORE1__')?'SI':'NO',uploaded:uploaded.length}}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
58064,
|
|
35216
|
|
],
|
|
"id": "6ef08c3a-b33f-4cd7-af93-584be7575f7f",
|
|
"name": "Cerrar lote Liquidaciones"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": "",
|
|
"typeValidation": "strict",
|
|
"version": 2
|
|
},
|
|
"conditions": [
|
|
{
|
|
"id": "10fc1a8b-cc8f-49db-af45-98b566192aca",
|
|
"leftValue": "={{ $json.addMore }}",
|
|
"rightValue": "SI",
|
|
"operator": {
|
|
"type": "string",
|
|
"operation": "equals"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2.3,
|
|
"position": [
|
|
58304,
|
|
35216
|
|
],
|
|
"id": "435f9ae2-7ccb-479f-8078-6f895bd4726a",
|
|
"name": "¿Agregar más Liquidaciones?"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"url": "=https://www.googleapis.com/drive/v3/files?q={{ encodeURIComponent(\"'\" + $('Crear carpeta temporal PDFs').item.json.id + \"' in parents and trashed = false\") }}&pageSize=1000&fields=nextPageToken,files(id,name,mimeType,size,createdTime)&supportsAllDrives=true",
|
|
"authentication": "predefinedCredentialType",
|
|
"nodeCredentialType": "googleDriveOAuth2Api",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.3,
|
|
"position": [
|
|
61424,
|
|
35056
|
|
],
|
|
"id": "1b450788-edf6-472c-8497-338312040c0d",
|
|
"name": "Listar PDFs temporales",
|
|
"retryOnFail": true,
|
|
"maxTries": 3,
|
|
"waitBetweenTries": 2000,
|
|
"credentials": {
|
|
"googleDriveOAuth2Api": {
|
|
"id": "g23xdGLZRzBGqKgH",
|
|
"name": "Isaac - Google Drive"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const payload=$json.body&&typeof $json.body==='object'?$json.body:$json;\nconst files=Array.isArray(payload.files)?payload.files:[];\nconst config=$items('Inicializar ejecución')[0].json.config||{};\nconst max=Number(config.maxSupplementalFilesSafety||500);\nif(payload.nextPageToken) throw new Error('Hay más de 1000 archivos complementarios en la ejecución. Se detuvo por seguridad antes de procesar una lista incompleta.');\nif(files.length>max) throw new Error(`Se detectaron ${files.length} archivos complementarios; supera el límite técnico de seguridad (${max}).`);\nfunction meta(name){\n const n=String(name||'');\n let category='';\n if(n.startsWith('EXTRA__'))category='extraordinaria';\n else if(n.startsWith('VAC__'))category='vacaciones';\n else if(n.startsWith('LIQ__'))category='liquidaciones';\n const ix=n.indexOf('__ORIG__');\n const fileName=ix>=0?n.slice(ix+8):n;\n return {category,fileName};\n}\nconst docs=[];\nfor(const f of files){\n const m=meta(f.name);\n if(!m.category) throw new Error(`Encontré un archivo temporal no reconocido: ${f.name}. Se detuvo para evitar mezclar documentos.`);\n if(String(f.mimeType||'')!=='application/pdf' && !String(f.name||'').toLowerCase().endsWith('.pdf')) throw new Error(`El archivo temporal ${f.name} no parece ser PDF.`);\n docs.push({id:f.id,storageName:f.name,fileName:m.fileName,category:m.category,size:f.size||'',createdTime:f.createdTime||''});\n}\nconst vac=docs.filter(x=>x.category==='vacaciones').length;\nconst liq=docs.filter(x=>x.category==='liquidaciones').length;\nconst ext=docs.filter(x=>x.category==='extraordinaria').length;\nif(vac<1) throw new Error('No hay archivos de Vacaciones acumulados. Vacaciones requiere al menos un PDF.');\nif(liq<1) throw new Error('No hay archivos de Liquidaciones acumulados. Liquidaciones requiere al menos un PDF.');\ndocs.sort((a,b)=>a.category.localeCompare(b.category)||String(a.createdTime).localeCompare(String(b.createdTime))||a.storageName.localeCompare(b.storageName));\nreturn docs.map(d=>({json:{...d,uploadCounts:{extraordinarias:ext,vacaciones:vac,liquidaciones:liq}}}));"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
61664,
|
|
35056
|
|
],
|
|
"id": "b525df3e-0968-489a-80d7-436a9697f00a",
|
|
"name": "Preparar descarga PDFs",
|
|
"notesInFlow": true,
|
|
"notes": "Reconstruye las tres categorías desde la carpeta temporal y valida mínimos antes de descargar."
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "download",
|
|
"fileId": {
|
|
"__rl": true,
|
|
"value": "={{ $json.id }}",
|
|
"mode": "id"
|
|
},
|
|
"options": {
|
|
"binaryPropertyName": "data",
|
|
"fileName": "={{ $json.storageName }}"
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.googleDrive",
|
|
"typeVersion": 3,
|
|
"position": [
|
|
61904,
|
|
35056
|
|
],
|
|
"id": "b5e9a2fb-a85c-45fc-b819-431840e1e007",
|
|
"name": "Descargar PDFs temporales",
|
|
"retryOnFail": true,
|
|
"maxTries": 3,
|
|
"waitBetweenTries": 2000,
|
|
"credentials": {
|
|
"googleDriveOAuth2Api": {
|
|
"id": "g23xdGLZRzBGqKgH",
|
|
"name": "Isaac - Google Drive"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const items=$input.all();\nfunction parseStorage(n){\n const name=String(n||'');\n let category='';\n if(name.startsWith('EXTRA__'))category='extraordinaria';\n else if(name.startsWith('VAC__'))category='vacaciones';\n else if(name.startsWith('LIQ__'))category='liquidaciones';\n const ix=name.indexOf('__ORIG__');\n return {category,fileName:ix>=0?name.slice(ix+8):name,storageName:name};\n}\nconst out=[];\nfor(const item of items){\n const data=item.binary?.data;\n if(!data) throw new Error('Google Drive no devolvió el contenido binario de uno de los PDFs temporales.');\n const p=parseStorage(data.fileName||item.json?.storageName||item.json?.name||'');\n if(!p.category) throw new Error(`No pude determinar la categoría del archivo temporal ${p.storageName||'(sin nombre)'}.`);\n data.fileName=p.fileName;\n out.push({json:{category:p.category,fileName:p.fileName,storageName:p.storageName,fileId:item.json?.id||item.json?.fileId||''},binary:{data}});\n}\nreturn out;"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
62144,
|
|
35056
|
|
],
|
|
"id": "833ab32f-3fad-4271-8a6d-81923f4eda84",
|
|
"name": "Normalizar PDFs descargados"
|
|
}
|
|
],
|
|
"connections": {
|
|
"On form submission": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Inicializar ejecución",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Inicializar ejecución": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Crear carpeta temporal PDFs",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Preparar Q1": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "¿Q1 es PDF?",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"¿Q1 es PDF?": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Extraer texto Q1 PDF",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Crear staging Q1",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Extraer texto Q1 PDF": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Parsear Q1 PDF",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Parsear Q1 PDF": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Resultado Q1",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Crear staging Q1": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Reunir staging Q1",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Reunir staging Q1": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Parsear Q1 Excel",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Parsear Q1 Excel": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Resultado Q1",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Resultado Q1": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Preparar Q2",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Preparar Q2": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "¿Q2 es PDF?",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"¿Q2 es PDF?": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Extraer texto Q2 PDF",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Crear staging Q2",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Extraer texto Q2 PDF": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Parsear Q2 PDF",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Parsear Q2 PDF": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Resultado Q2",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Crear staging Q2": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Reunir staging Q2",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Reunir staging Q2": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Parsear Q2 Excel",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Parsear Q2 Excel": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Resultado Q2",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Resultado 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": "Parsear CSS",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Parsear CSS": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Listar PDFs temporales",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Extraer texto PDFs": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Parsear PDFs",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Parsear PDFs": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Construir reporte producción",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Construir reporte producción": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Crear Google Sheet Reporte",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Crear Google Sheet Reporte": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Escribir reporte",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Escribir reporte": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Preparar formato reporte",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Preparar formato reporte": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Formatear reporte",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Formatear reporte": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Preparar permisos",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Preparar permisos": {
|
|
"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 resumen",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Enviar correo resumen": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Preparar limpieza temporales",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Preparar limpieza temporales": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Eliminar staging temporal",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Eliminar staging temporal": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Preparar confirmación",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Preparar confirmación": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Confirmación y regreso al formulario",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Crear carpeta temporal PDFs": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Definir Extraordinarias",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Definir Extraordinarias": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "¿Hay extraordinarias?",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"¿Hay extraordinarias?": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Cargar Extraordinarias",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Cargar Vacaciones",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Cargar Extraordinarias": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Preparar lote Extraordinarias",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Preparar lote Extraordinarias": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Guardar lote Extraordinarias",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Guardar lote Extraordinarias": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Cerrar lote Extraordinarias",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Cerrar lote Extraordinarias": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "¿Agregar más Extraordinarias?",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"¿Agregar más Extraordinarias?": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Cargar Extraordinarias",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Cargar Vacaciones",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Cargar Vacaciones": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Preparar lote Vacaciones",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Preparar lote Vacaciones": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Guardar lote Vacaciones",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Guardar lote Vacaciones": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Cerrar lote Vacaciones",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Cerrar lote Vacaciones": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "¿Agregar más Vacaciones?",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"¿Agregar más Vacaciones?": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Cargar Vacaciones",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Cargar Liquidaciones",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Cargar Liquidaciones": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Preparar lote Liquidaciones",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Preparar lote Liquidaciones": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Guardar lote Liquidaciones",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Guardar lote Liquidaciones": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Cerrar lote Liquidaciones",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Cerrar lote Liquidaciones": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "¿Agregar más Liquidaciones?",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"¿Agregar más Liquidaciones?": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Cargar Liquidaciones",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Preparar Q1",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Listar PDFs temporales": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Preparar descarga PDFs",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Preparar descarga PDFs": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Descargar PDFs temporales",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Descargar PDFs temporales": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Normalizar PDFs descargados",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Normalizar PDFs descargados": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Extraer texto PDFs",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
}
|
|
},
|
|
"settings": {
|
|
"executionOrder": "v1",
|
|
"binaryMode": "separate"
|
|
},
|
|
"staticData": null,
|
|
"meta": null,
|
|
"versionId": "d6cac1fb-fb4b-4bef-a2b3-146b21e84ac4",
|
|
"activeVersionId": "d6cac1fb-fb4b-4bef-a2b3-146b21e84ac4",
|
|
"versionCounter": 144,
|
|
"triggerCount": 1,
|
|
"shared": [
|
|
{
|
|
"updatedAt": "2026-08-20T19:14:26.151Z",
|
|
"createdAt": "2026-08-20T19:14:26.151Z",
|
|
"role": "workflow:owner",
|
|
"workflowId": "qxcXg0Z1YASkWgxL",
|
|
"projectId": "PJpTANzTXIFibWsW",
|
|
"project": {
|
|
"updatedAt": "2026-04-22T14:25:09.686Z",
|
|
"createdAt": "2026-04-22T14:22:54.790Z",
|
|
"id": "PJpTANzTXIFibWsW",
|
|
"name": "Isaac Aracena <iaracena@gomezleemarketing.com>",
|
|
"type": "personal",
|
|
"icon": null,
|
|
"description": null,
|
|
"creatorId": "0a88c0b1-928e-4412-896e-c5d1c99b2029"
|
|
}
|
|
}
|
|
],
|
|
"tags": [],
|
|
"activeVersion": {
|
|
"updatedAt": "2026-08-26T01:49:22.000Z",
|
|
"createdAt": "2026-08-26T01:48:51.760Z",
|
|
"versionId": "d6cac1fb-fb4b-4bef-a2b3-146b21e84ac4",
|
|
"workflowId": "qxcXg0Z1YASkWgxL",
|
|
"nodes": [
|
|
{
|
|
"parameters": {
|
|
"formTitle": "Cruce Seguridad Social Panamá",
|
|
"formDescription": "Paso 1 de 4. Carga las dos nóminas quincenales (preferiblemente Excel; PDF también se valida con controles estrictos) y la Planilla oficial de la Caja de Seguro Social (CSS) en Excel. En los pasos siguientes podrás cargar Extraordinarias, Vacaciones y Liquidaciones en una o varias tandas, aunque estén en carpetas distintas. El período se detecta automáticamente desde la Planilla CSS.",
|
|
"formFields": {
|
|
"values": [
|
|
{
|
|
"fieldLabel": "Nómina - 1era Quincena",
|
|
"fieldType": "file",
|
|
"multipleFiles": false,
|
|
"acceptFileTypes": ".xlsx,.xls,.pdf",
|
|
"requiredField": true
|
|
},
|
|
{
|
|
"fieldLabel": "Nómina - 2da Quincena",
|
|
"fieldType": "file",
|
|
"multipleFiles": false,
|
|
"acceptFileTypes": ".xlsx,.xls,.pdf",
|
|
"requiredField": true
|
|
},
|
|
{
|
|
"fieldLabel": "Planilla CSS",
|
|
"fieldType": "file",
|
|
"multipleFiles": false,
|
|
"acceptFileTypes": ".xlsx,.xls",
|
|
"requiredField": true
|
|
}
|
|
]
|
|
},
|
|
"responseMode": "lastNode",
|
|
"options": {
|
|
"ignoreBots": true
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.formTrigger",
|
|
"typeVersion": 2.3,
|
|
"position": [
|
|
53744,
|
|
34736
|
|
],
|
|
"id": "595b95d8-2d86-40b4-a418-1b9964bc76f5",
|
|
"name": "On form submission",
|
|
"webhookId": "9c032e76-4e26-46ce-ab02-1a0d628dd1fa"
|
|
},
|
|
{
|
|
"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 = [\n 'iaracena@gomezleemarketing.com'\n];\nconst logoUrl = 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png';\nconst formUrl = 'https://agenteit.digitalcompass.agency/form/0feede33-4a67-4d98-8ae0-16f411726460';\nconst config = {\n amountTolerance: 0.10,\n cssIntegrityTolerance: 0.10,\n defaultRates: { ssRegular: 0.0975, se: 0.0125, ssDtm: 0.0725 },\n minRateSamples: 20,\n hardFailUnreadablePdf: true,\n maxSupplementalFilesSafety: 500\n};\nreturn [{json:{runId,startedAt:now.toISOString(),recipients,logoUrl,formUrl,config},binary:item.binary||{}}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
53984,
|
|
34736
|
|
],
|
|
"id": "cab81d5c-e00e-4957-af5a-21ab5dae0b09",
|
|
"name": "Inicializar ejecución"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "\nconst base=$items('Inicializar ejecución')[0];\nconst bin=base.binary||{};\nfunction norm(s){return String(s||'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase().replace(/\\s+/g,' ').trim();}\nfunction formatOf(file,name){\n const s=norm(`${name} ${file?.mimeType||''}`);\n if(/\\.pdf(?:$|\\s)|application\\/pdf/.test(s)) return 'PDF';\n if(/\\.xlsx?(?:$|\\s)|spreadsheet|excel|application\\/vnd\\.ms-excel/.test(s)) return 'EXCEL';\n return '';\n}\nconst entries=Object.entries(bin).map(([key,file])=>{\n const name=String(file.fileName||key), k=norm(key), n=norm(name), all=`${k} ${n}`;\n const format=formatOf(file,name); let score=format?100:-1000;\n \n if(/nomina.*1era|1era.*quincena|primera.*quincena|quincena.*1/.test(k)) score+=600;\n if(/nomina.*2da|2da.*quincena|segunda.*quincena|quincena.*2/.test(k)) score-=700;\n if(/1era|primera|quincena\\s*1|q1/.test(n)) score+=180;\n if(/2da|segunda|quincena\\s*2|q2/.test(n)) score-=220;\n if(/planilla\\s*css|detalleplanilla|extraordin|vacacion|liquidacion/.test(all)) score-=300;\n\n return {key,file,name,format,score,k,n,all};\n}).sort((a,b)=>b.score-a.score);\nconst chosen=entries[0];\nif(!chosen||chosen.score<250||!chosen.format) throw new Error('No pude identificar de forma segura la Nómina de 1era Quincena. Adjunta en ese campo un Excel (.xls/.xlsx) o PDF de la planilla quincenal.');\nreturn [{json:{...base.json,fileName:chosen.name,originalBinaryKey:chosen.key,format:chosen.format,inputMimeType:chosen.file?.mimeType||''},binary:{data:chosen.file}}];\n"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
54224,
|
|
34832
|
|
],
|
|
"id": "3dff7255-5e27-4c05-b126-5b233af765a7",
|
|
"name": "Preparar Q1"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "xls",
|
|
"options": {
|
|
"headerRow": false,
|
|
"includeEmptyCells": true,
|
|
"rawData": false,
|
|
"readAsString": false
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
54704,
|
|
34688
|
|
],
|
|
"id": "85440a47-e842-43d6-9015-76690b89712f",
|
|
"name": "Crear staging Q1"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const rows=$input.all().map(x=>x.json?.row).filter(r=>Array.isArray(r));\nif(!rows.length) throw new Error('No se pudieron leer filas de Nómina Q1 directamente desde el Excel.');\nreturn [{json:{valueRanges:[{range:'Sheet1!A1:AZ50000',values:rows}],directExtraction:true}}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
54944,
|
|
34688
|
|
],
|
|
"id": "2fe967fa-afc8-48df-87b1-d73ba67e825d",
|
|
"name": "Reunir staging Q1"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "\nconst payload=$json.body&&typeof $json.body==='object'?$json.body:$json;\nconst valueRanges=payload.valueRanges||[];\nconst prep=$items('Preparar Q1')[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 key(v){return asc(v).replace(/[^A-Z0-9]/g,'');}\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 detectPeriod(values){\n for(const row of values.slice(0,120)) for(const cell of row||[]){\n const m=clean(cell).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);\n if(m)return {start:m[1],end:m[2]};\n }\n return null;\n}\nfunction detectPrintedCount(values){\n let gi=-1; for(let i=0;i<values.length;i++) if(asc((values[i]||[]).join(' ')).includes('TOTALES GENERALES')) gi=i;\n if(gi<0)return null;\n for(let i=gi;i<Math.min(values.length,gi+12);i++){\n const row=values[i]||[];\n for(let c=0;c<row.length;c++){\n const s=clean(row[c]); let m=s.match(/Cantidad\\s+de\\s+Empleados\\s*:?\\s*(\\d+)/i); if(m)return Number(m[1]);\n if(/Cantidad\\s+de\\s+Empleados/i.test(s)){\n for(let j=c+1;j<Math.min(row.length,c+5);j++){const n=Number(row[j]);if(Number.isInteger(n)&&n>0)return n;}\n }\n }\n }\n return null;\n}\nfunction detect(values){\n for(let r=0;r<Math.min(values.length,100);r++){\n const row=values[r]||[], joined=row.map(asc).join('|');\n if(/CODIGO/.test(joined)&&/NOMBRE/.test(joined)){\n const max=Math.max(...values.slice(r,r+10).map(x=>(x||[]).length),row.length,1), vertical=[];\n for(let c=0;c<max;c++)vertical[c]=values.slice(r,r+10).map(rr=>asc((rr||[])[c])).filter(Boolean).join(' ');\n const codeIdx=vertical.findIndex(h=>/CODIGO/.test(h));\n let nameIdx=vertical.findIndex(h=>/NOMBRE.*EMPLEADO|NOMBRE DEL EMPLEADO/.test(h));if(nameIdx<0)nameIdx=vertical.findIndex(h=>/^NOMBRE$| NOMBRE /.test(' '+h+' '));\n let ssIdx=-1,seIdx=-1;\n for(let rr=r;rr<Math.min(r+12,values.length);rr++){\n const arr=(values[rr]||[]).map(key);\n for(let c=0;c<arr.length;c++){\n if(ssIdx<0&&(arr[c]==='SS'||arr[c]==='SEGSOC'||arr[c]==='SEGUROSOCIAL'))ssIdx=c;\n if(seIdx<0&&(arr[c]==='SE'||arr[c]==='SEGEDU'||arr[c]==='SEGUROEDUCATIVO'))seIdx=c;\n }\n }\n if(ssIdx<0)ssIdx=vertical.findIndex(h=>/\\bS\\.?\\s*S\\.?\\b|SEG\\.?\\s*SOC|SEGURO SOCIAL/.test(h));\n if(seIdx<0)seIdx=vertical.findIndex(h=>/\\bS\\.?\\s*E\\.?\\b|SEG\\.?\\s*EDU|SEGURO EDUCATIVO/.test(h));\n const grossIdx=vertical.findIndex(h=>/TOTAL BRUTO|SALARIO BRUTO/.test(h));\n if(nameIdx>=0&&ssIdx>=0)return {r,codeIdx,nameIdx,ssIdx,seIdx,grossIdx};\n }\n }\n return null;\n}\nconst rawRecords=[],sheets=[];let period=null,printedEmployeeCount=null;\nfor(const vr of valueRanges){\n const values=vr.values||[],range=String(vr.range||''),d=detect(values);\n period=period||detectPeriod(values); printedEmployeeCount=printedEmployeeCount||detectPrintedCount(values);\n if(!d){sheets.push({range,status:'IGNORADA',reason:'No se detectó encabezado de planilla quincenal'});continue;}\n let count=0;\n for(let r=d.r+1;r<values.length;r++){\n const row=values[r]||[],rowText=asc(row.join(' '));\n if(/TOTALES GENERALES|TOTALES DE|CANTIDAD DE EMPLEADOS/.test(rowText))continue;\n const name=clean(row[d.nameIdx]);if(!name||!/[A-ZÁÉÍÓÚÑ]/i.test(name))continue;\n let code=d.codeIdx>=0?clean(row[d.codeIdx]).replace(/\\.0+$/,''):'';if(code&&!/^\\d{1,8}$/.test(code))code='';\n const ss=round(num(row[d.ssIdx])),se=d.seIdx>=0?round(num(row[d.seIdx])):0,gross=d.grossIdx>=0?round(num(row[d.grossIdx])):0;\n if(!code&&ss===0&&se===0&&gross===0)continue;\n rawRecords.push({code:code.replace(/^0+(?=\\d)/,''),name,ss,se,gross,source:'Nómina Q1',fileName:prep.fileName,sheet:range});count++;\n }\n sheets.push({range,status:count?'PROCESADA':'SIN REGISTROS',records:count});\n}\n// Elimina filas exactas repetidas (por ejemplo, hojas copia) sin sumar dos veces.\nconst exactSeen=new Set(),records=[];\nfor(const r of rawRecords){const k=[r.code,asc(r.name),r.ss.toFixed(2),r.se.toFixed(2),r.gross.toFixed(2)].join('|');if(exactSeen.has(k))continue;exactSeen.add(k);records.push(r);}\nif(!records.length)throw new Error('No pude extraer empleados de Nómina Q1. El Excel no coincide con el formato de planilla esperado.');\nif(printedEmployeeCount&&records.length!==printedEmployeeCount)throw new Error(`Nómina Q1 indica ${printedEmployeeCount} empleados, pero el lector extrajo ${records.length}. Se detuvo el cruce para no generar un reporte incompleto.`);\nconst RESULT={fileName:prep.fileName,format:'EXCEL',records,employeeCount:records.length,printedEmployeeCount:printedEmployeeCount||null,missingCodeCount:records.filter(r=>!r.code).length,totalSS:round(records.reduce((s,r)=>s+r.ss,0)),totalSE:round(records.reduce((s,r)=>s+r.se,0)),period,sheets,stagingSpreadsheetId:'',integrityStatus:printedEmployeeCount?'VALIDADO CONTRA TOTAL DEL ARCHIVO':'VALIDADO ESTRUCTURALMENTE'};\nreturn [{json:RESULT}];\n"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
56384,
|
|
34688
|
|
],
|
|
"id": "693f40c8-f19f-4ab6-a526-f0ad006e7402",
|
|
"name": "Parsear Q1 Excel"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "\nconst base=$items('Inicializar ejecución')[0];\nconst bin=base.binary||{};\nfunction norm(s){return String(s||'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase().replace(/\\s+/g,' ').trim();}\nfunction formatOf(file,name){\n const s=norm(`${name} ${file?.mimeType||''}`);\n if(/\\.pdf(?:$|\\s)|application\\/pdf/.test(s)) return 'PDF';\n if(/\\.xlsx?(?:$|\\s)|spreadsheet|excel|application\\/vnd\\.ms-excel/.test(s)) return 'EXCEL';\n return '';\n}\nconst entries=Object.entries(bin).map(([key,file])=>{\n const name=String(file.fileName||key), k=norm(key), n=norm(name), all=`${k} ${n}`;\n const format=formatOf(file,name); let score=format?100:-1000;\n \n if(/nomina.*2da|2da.*quincena|segunda.*quincena|quincena.*2/.test(k)) score+=600;\n if(/nomina.*1era|1era.*quincena|primera.*quincena|quincena.*1/.test(k)) score-=700;\n if(/2da|segunda|quincena\\s*2|q2/.test(n)) score+=180;\n if(/1era|primera|quincena\\s*1|q1/.test(n)) score-=220;\n if(/planilla\\s*css|detalleplanilla|extraordin|vacacion|liquidacion/.test(all)) score-=300;\n\n return {key,file,name,format,score,k,n,all};\n}).sort((a,b)=>b.score-a.score);\nconst chosen=entries[0];\nif(!chosen||chosen.score<250||!chosen.format) throw new Error('No pude identificar de forma segura la Nómina de 2da Quincena. Adjunta en ese campo un Excel (.xls/.xlsx) o PDF de la planilla quincenal.');\nreturn [{json:{...base.json,fileName:chosen.name,originalBinaryKey:chosen.key,format:chosen.format,inputMimeType:chosen.file?.mimeType||''},binary:{data:chosen.file}}];\n"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
56864,
|
|
34832
|
|
],
|
|
"id": "e3a1ef29-d141-4b50-b35d-2ba175a41d4e",
|
|
"name": "Preparar Q2"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "xls",
|
|
"options": {
|
|
"headerRow": false,
|
|
"includeEmptyCells": true,
|
|
"rawData": false,
|
|
"readAsString": false
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
57344,
|
|
34688
|
|
],
|
|
"id": "70365021-6de6-43f7-b3c7-0a41fc275d5d",
|
|
"name": "Crear staging Q2"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const rows=$input.all().map(x=>x.json?.row).filter(r=>Array.isArray(r));\nif(!rows.length) throw new Error('No se pudieron leer filas de Nómina Q2 directamente desde el Excel.');\nreturn [{json:{valueRanges:[{range:'Sheet1!A1:AZ50000',values:rows}],directExtraction:true}}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
57584,
|
|
34688
|
|
],
|
|
"id": "b182e631-7af4-4666-b74a-609f29c78e69",
|
|
"name": "Reunir staging Q2"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "\nconst payload=$json.body&&typeof $json.body==='object'?$json.body:$json;\nconst valueRanges=payload.valueRanges||[];\nconst prep=$items('Preparar Q2')[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 key(v){return asc(v).replace(/[^A-Z0-9]/g,'');}\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 detectPeriod(values){\n for(const row of values.slice(0,120)) for(const cell of row||[]){\n const m=clean(cell).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);\n if(m)return {start:m[1],end:m[2]};\n }\n return null;\n}\nfunction detectPrintedCount(values){\n let gi=-1; for(let i=0;i<values.length;i++) if(asc((values[i]||[]).join(' ')).includes('TOTALES GENERALES')) gi=i;\n if(gi<0)return null;\n for(let i=gi;i<Math.min(values.length,gi+12);i++){\n const row=values[i]||[];\n for(let c=0;c<row.length;c++){\n const s=clean(row[c]); let m=s.match(/Cantidad\\s+de\\s+Empleados\\s*:?\\s*(\\d+)/i); if(m)return Number(m[1]);\n if(/Cantidad\\s+de\\s+Empleados/i.test(s)){\n for(let j=c+1;j<Math.min(row.length,c+5);j++){const n=Number(row[j]);if(Number.isInteger(n)&&n>0)return n;}\n }\n }\n }\n return null;\n}\nfunction detect(values){\n for(let r=0;r<Math.min(values.length,100);r++){\n const row=values[r]||[], joined=row.map(asc).join('|');\n if(/CODIGO/.test(joined)&&/NOMBRE/.test(joined)){\n const max=Math.max(...values.slice(r,r+10).map(x=>(x||[]).length),row.length,1), vertical=[];\n for(let c=0;c<max;c++)vertical[c]=values.slice(r,r+10).map(rr=>asc((rr||[])[c])).filter(Boolean).join(' ');\n const codeIdx=vertical.findIndex(h=>/CODIGO/.test(h));\n let nameIdx=vertical.findIndex(h=>/NOMBRE.*EMPLEADO|NOMBRE DEL EMPLEADO/.test(h));if(nameIdx<0)nameIdx=vertical.findIndex(h=>/^NOMBRE$| NOMBRE /.test(' '+h+' '));\n let ssIdx=-1,seIdx=-1;\n for(let rr=r;rr<Math.min(r+12,values.length);rr++){\n const arr=(values[rr]||[]).map(key);\n for(let c=0;c<arr.length;c++){\n if(ssIdx<0&&(arr[c]==='SS'||arr[c]==='SEGSOC'||arr[c]==='SEGUROSOCIAL'))ssIdx=c;\n if(seIdx<0&&(arr[c]==='SE'||arr[c]==='SEGEDU'||arr[c]==='SEGUROEDUCATIVO'))seIdx=c;\n }\n }\n if(ssIdx<0)ssIdx=vertical.findIndex(h=>/\\bS\\.?\\s*S\\.?\\b|SEG\\.?\\s*SOC|SEGURO SOCIAL/.test(h));\n if(seIdx<0)seIdx=vertical.findIndex(h=>/\\bS\\.?\\s*E\\.?\\b|SEG\\.?\\s*EDU|SEGURO EDUCATIVO/.test(h));\n const grossIdx=vertical.findIndex(h=>/TOTAL BRUTO|SALARIO BRUTO/.test(h));\n if(nameIdx>=0&&ssIdx>=0)return {r,codeIdx,nameIdx,ssIdx,seIdx,grossIdx};\n }\n }\n return null;\n}\nconst rawRecords=[],sheets=[];let period=null,printedEmployeeCount=null;\nfor(const vr of valueRanges){\n const values=vr.values||[],range=String(vr.range||''),d=detect(values);\n period=period||detectPeriod(values); printedEmployeeCount=printedEmployeeCount||detectPrintedCount(values);\n if(!d){sheets.push({range,status:'IGNORADA',reason:'No se detectó encabezado de planilla quincenal'});continue;}\n let count=0;\n for(let r=d.r+1;r<values.length;r++){\n const row=values[r]||[],rowText=asc(row.join(' '));\n if(/TOTALES GENERALES|TOTALES DE|CANTIDAD DE EMPLEADOS/.test(rowText))continue;\n const name=clean(row[d.nameIdx]);if(!name||!/[A-ZÁÉÍÓÚÑ]/i.test(name))continue;\n let code=d.codeIdx>=0?clean(row[d.codeIdx]).replace(/\\.0+$/,''):'';if(code&&!/^\\d{1,8}$/.test(code))code='';\n const ss=round(num(row[d.ssIdx])),se=d.seIdx>=0?round(num(row[d.seIdx])):0,gross=d.grossIdx>=0?round(num(row[d.grossIdx])):0;\n if(!code&&ss===0&&se===0&&gross===0)continue;\n rawRecords.push({code:code.replace(/^0+(?=\\d)/,''),name,ss,se,gross,source:'Nómina Q2',fileName:prep.fileName,sheet:range});count++;\n }\n sheets.push({range,status:count?'PROCESADA':'SIN REGISTROS',records:count});\n}\n// Elimina filas exactas repetidas (por ejemplo, hojas copia) sin sumar dos veces.\nconst exactSeen=new Set(),records=[];\nfor(const r of rawRecords){const k=[r.code,asc(r.name),r.ss.toFixed(2),r.se.toFixed(2),r.gross.toFixed(2)].join('|');if(exactSeen.has(k))continue;exactSeen.add(k);records.push(r);}\nif(!records.length)throw new Error('No pude extraer empleados de Nómina Q2. El Excel no coincide con el formato de planilla esperado.');\nif(printedEmployeeCount&&records.length!==printedEmployeeCount)throw new Error(`Nómina Q2 indica ${printedEmployeeCount} empleados, pero el lector extrajo ${records.length}. Se detuvo el cruce para no generar un reporte incompleto.`);\nconst RESULT={fileName:prep.fileName,format:'EXCEL',records,employeeCount:records.length,printedEmployeeCount:printedEmployeeCount||null,missingCodeCount:records.filter(r=>!r.code).length,totalSS:round(records.reduce((s,r)=>s+r.ss,0)),totalSE:round(records.reduce((s,r)=>s+r.se,0)),period,sheets,stagingSpreadsheetId:'',integrityStatus:printedEmployeeCount?'VALIDADO CONTRA TOTAL DEL ARCHIVO':'VALIDADO ESTRUCTURALMENTE'};\nreturn [{json:RESULT}];\n"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
59024,
|
|
34688
|
|
],
|
|
"id": "c5ce7b48-654e-4749-94f9-06311f088443",
|
|
"name": "Parsear Q2 Excel"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "\nconst base=$items('Inicializar ejecución')[0];\nconst bin=base.binary||{};\nfunction norm(s){return String(s||'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase().replace(/\\s+/g,' ').trim();}\nfunction formatOf(file,name){\n const s=norm(`${name} ${file?.mimeType||''}`);\n if(/\\.pdf(?:$|\\s)|application\\/pdf/.test(s)) return 'PDF';\n if(/\\.xlsx?(?:$|\\s)|spreadsheet|excel|application\\/vnd\\.ms-excel/.test(s)) return 'EXCEL';\n return '';\n}\nconst entries=Object.entries(bin).map(([key,file])=>{\n const name=String(file.fileName||key), k=norm(key), n=norm(name), all=`${k} ${n}`;\n const format=formatOf(file,name); let score=format?100:-1000;\n \n if(/planilla\\s*css|detalleplanilla|caja.*seguro|css/.test(k)) score+=650;\n if(/planilla\\s*css|detalleplanilla|caja.*seguro|css/.test(n)) score+=240;\n if(format!=='EXCEL') score-=900;\n if(/nomina|quincena|extraordin|vacacion|liquidacion/.test(all)) score-=300;\n\n return {key,file,name,format,score,k,n,all};\n}).sort((a,b)=>b.score-a.score);\nconst chosen=entries[0];\nif(!chosen||chosen.score<250||!chosen.format) throw new Error('No pude identificar de forma segura la Planilla CSS. Adjunta en ese campo el archivo oficial DetallePlanilla en Excel (.xls/.xlsx).');\nreturn [{json:{...base.json,fileName:chosen.name,originalBinaryKey:chosen.key,format:chosen.format,inputMimeType:chosen.file?.mimeType||''},binary:{data:chosen.file}}];\n"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
59264,
|
|
34832
|
|
],
|
|
"id": "0a9b3b13-da91-49d4-a018-3f72cc0209a1",
|
|
"name": "Preparar CSS"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "xls",
|
|
"options": {
|
|
"headerRow": false,
|
|
"includeEmptyCells": true,
|
|
"rawData": false,
|
|
"readAsString": false
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
59504,
|
|
34832
|
|
],
|
|
"id": "3daecbfd-ae7c-4cbb-ad6b-564c1523972e",
|
|
"name": "Crear staging CSS"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const rows=$input.all().map(x=>x.json?.row).filter(r=>Array.isArray(r));\nif(!rows.length) throw new Error('No se pudieron leer filas de Planilla CSS directamente desde el Excel.');\nreturn [{json:{valueRanges:[{range:'Sheet1!A1:AZ50000',values:rows}],directExtraction:true}}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
59744,
|
|
34832
|
|
],
|
|
"id": "0456e69e-4dbf-4568-8ab9-1f1efcc4d730",
|
|
"name": "Reunir staging CSS"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const payload=$json.body&&typeof $json.body==='object'?$json.body:$json;\nconst valueRanges=payload.valueRanges||[],prep=$items('Preparar CSS')[0].json,config=$items('Inicializar ejecución')[0].json.config||{};\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 isEmployeeHeader(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+(isEmployeeHeader(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;r<Math.min(values.length,120)&&!period;r++){const row=values[r]||[];for(let c=0;c<row.length;c++){const a=asc(row[c]);if(/MES COUTA|MES CUOTA|PERIODO/.test(a)){period=monthPeriod(row[c]);for(let j=c+1;!period&&j<Math.min(c+6,row.length);j++)period=monthPeriod(row[j]);}}}\nif(!period)throw new Error('No pude detectar el período de la Planilla CSS. Se esperaba “Mes cuota/couta: Mes Año”.');\n// Los totales oficiales están en el bloque superior \"Totales Planilla\". Nunca se leen\n// después del primer encabezado de empleado, porque allí se repiten etiquetas como\n// Seguro Social, Seguro Educativo, DTM, Salario y Salario Ajustado por persona.\nconst headerRows=[];for(let r=0;r<values.length;r++)if(isEmployeeHeader(values[r]))headerRows.push(r);\nif(!headerRows.length)throw new Error('La Planilla CSS cambió de formato: no encontré encabezados de empleados.');\nconst firstEmployeeHeader=headerRows[0];\nconst totals={},foundTotals=new Set();\nfor(let r=0;r<Math.min(firstEmployeeHeader,80);r++){\n const row=values[r]||[],label=asc(row[1]??row[0]);let val=null;\n for(let c=2;c<Math.min(row.length,7);c++){const candidate=num(row[c]);if(candidate!==0){val=candidate;break;}}\n if(val===null)continue;\n if(label==='SEGURO SOCIAL'&&!foundTotals.has('seguroSocial')){totals.seguroSocial=round(val);foundTotals.add('seguroSocial');}\n if(label==='SEGURO EDUCATIVO'&&!foundTotals.has('seguroEducativo')){totals.seguroEducativo=round(val);foundTotals.add('seguroEducativo');}\n if(label==='DTM'&&!foundTotals.has('dtm')){totals.dtm=round(val);foundTotals.add('dtm');}\n if(label==='SALARIO'&&!foundTotals.has('salario')){totals.salario=round(val);foundTotals.add('salario');}\n if(label==='SALARIO AJUSTADO'&&!foundTotals.has('salarioAjustado')){totals.salarioAjustado=round(val);foundTotals.add('salarioAjustado');}\n if(label==='RIESGOS PROFESIONALES'&&!foundTotals.has('riesgos')){totals.riesgos=round(val);foundTotals.add('riesgos');}\n}\nfor(const req of ['seguroSocial','seguroEducativo','salario','salarioAjustado'])if(!foundTotals.has(req))throw new Error(`La Planilla CSS cambió de formato: no pude detectar el total oficial “${req}” antes del primer empleado.`);\nconst employees=[];\nfor(let hi=0;hi<headerRows.length;hi++){\n const r=headerRows[hi],end=hi+1<headerRows.length?headerRows[hi+1]:values.length,header=(values[r]||[]).map(asc);\n const ssIdx=header.findIndex(v=>v==='SEGURO SOCIAL'),apIdx=header.findIndex(v=>v==='APELLIDO'),nomIdx=header.findIndex(v=>v==='NOMBRE'),sexIdx=header.findIndex(v=>v==='SEXO');\n let idIdx=header.findIndex(v=>/CEDULA|PASAPORTE|REGISTRO CIVIL|IDENTIFICACION|DOCUMENTO/.test(v));if(idIdx<0)idIdx=ssIdx+1;\n const docType=clean((values[r]||[])[idIdx]);let dr=r+1;while(dr<end&&!(values[dr]||[]).some(v=>clean(v)))dr++;if(dr>=end)continue;\n 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<end;k++){\n const row=values[k]||[],label=asc(row[1]??row[0]);let amount=0;for(let c=2;c<Math.min(row.length,7);c++){const n=num(row[c]);if(n!==0){amount=n;break;}}\n if(label==='COMPONENTES'){section='components';continue;}if(label==='TIPO CUENTA INGRESO'){section='accounts';continue;}if(label==='TIPO NOVEDAD'){section='novelties';continue;}\n if(section==='components'&&label&&label!=='TOTAL')components[label]=round(amount);\n if(section!=='accounts'&&label==='SALARIO')salary=round(amount);if(section!=='accounts'&&label==='SALARIO AJUSTADO')salaryAdjusted=round(amount);\n if(section==='accounts'&&label==='SEGURO SOCIAL')planillaSS=round(amount);if(section==='accounts'&&label==='SEGURO EDUCATIVO')planillaSE=round(amount);if(section==='accounts'&&label==='DTM')planillaDTM=round(amount);\n }\n if(!salaryAdjusted&&salary)salaryAdjusted=salary;\n const dtmKey=Object.keys(components).find(k=>/DECIMO TERCER MES|XIII MES/.test(k)),dtmBase=round(dtmKey?components[dtmKey]:0);\n employees.push({ssNumber,identity,docType,lastname,firstname,name:clean(`${lastname} ${firstname}`),sex:sexIdx>=0?clean(data[sexIdx]):'',salary,salaryAdjusted,dtmBase,planillaSS,planillaSE,planillaDTM,components});\n}\nif(!employees.length)throw new Error('No pude extraer empleados de la Planilla CSS.');\nconst dupIds=new Set(),seenIds=new Set();for(const e of employees){const k=asc(e.identity||e.ssNumber);if(!k)continue;if(seenIds.has(k))dupIds.add(k);seenIds.add(k);}if(dupIds.size)throw new Error(`La Planilla CSS contiene identificaciones duplicadas (${[...dupIds].slice(0,5).join(', ')}). Se detuvo el cruce.`);\nconst reconstructed={seguroSocial:round(employees.reduce((s,e)=>s+e.planillaSS,0)),seguroEducativo:round(employees.reduce((s,e)=>s+e.planillaSE,0)),dtm:round(employees.reduce((s,e)=>s+e.planillaDTM,0)),salario:round(employees.reduce((s,e)=>s+e.salary,0)),salarioAjustado:round(employees.reduce((s,e)=>s+e.salaryAdjusted,0))};\nconst tol=Number(config.cssIntegrityTolerance??0.10),integrity=[];\nfor(const k of ['seguroSocial','seguroEducativo','dtm','salario','salarioAjustado']){if(!foundTotals.has(k))continue;const diff=round(reconstructed[k]-Number(totals[k]||0));integrity.push({metric:k,official:round(totals[k]),reconstructed:reconstructed[k],diff,ok:Math.abs(diff)<=tol});}\nconst bad=integrity.filter(x=>!x.ok);if(bad.length)throw new Error(`Fallo de integridad en Planilla CSS: ${bad.map(x=>`${x.metric} oficial ${x.official} vs reconstruido ${x.reconstructed} (dif. ${x.diff})`).join('; ')}. Se detuvo el cruce.`);\nconst now=new Date(),pad=n=>String(n).padStart(2,'0'),reportTitle=`Cruce Seguridad Social Panamá - ${period.label} - ${pad(now.getDate())}-${pad(now.getMonth()+1)}-${now.getFullYear()}`;\nreturn [{json:{fileName:prep.fileName,period,periodoLabel:period.label,reportTitle,employees,employeeCount:employees.length,totals,reconstructed,integrity,sourceSheet:chosen.vr.range||'',stagingSpreadsheetId:''}}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
61184,
|
|
34832
|
|
],
|
|
"id": "c487313c-347e-4b81-ac82-f9f2f13bc945",
|
|
"name": "Parsear CSS"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "pdf",
|
|
"options": {
|
|
"joinPages": true
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
61664,
|
|
34832
|
|
],
|
|
"id": "a08a8ee5-7dd5-46a0-bf76-288870e633ae",
|
|
"name": "Extraer texto PDFs"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "function parseSupplementals(extracted, metaItems, css, init={}) {\nconst cfg=init.config||{};\nconst rates=cfg.defaultRates||{ssRegular:0.0975,se:0.0125,ssDtm:0.0725};\nconst targetMonth=Number(css.period?.month||0),targetYear=Number(css.period?.year||0);\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 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 fmtDate(d){return d?`${String(d.getDate()).padStart(2,'0')}/${String(d.getMonth()+1).padStart(2,'0')}/${d.getFullYear()}`:'';}\nfunction inTarget(d){return !!(d&&d.getMonth()+1===targetMonth&&d.getFullYear()===targetYear);}\nfunction overlapTarget(a,b){if(!a||!b)return null;const x=[a,b].sort((p,q)=>p-q),first=new Date(targetYear,targetMonth-1,1),last=new Date(targetYear,targetMonth,0,23,59,59);return x[0]<=last&&x[1]>=first;}\nfunction regexAll(text,re){let flags=re.flags||'';if(!flags.includes('g'))flags+='g';const rg=new RegExp(re.source,flags),out=[];let m;while((m=rg.exec(String(text||'')))!==null){out.push(m);if(m[0]==='')rg.lastIndex++;}return out;}\nfunction markers(text,re){return regexAll(text,re).map(m=>({index:m.index,end:m.index+m[0].length,match:m}));}\nfunction segPre(text,ms,j){return text.slice(j>0?ms[j-1].end:0,ms[j].index);}\nfunction segPost(text,ms,j){return text.slice(ms[j].end,j+1<ms.length?ms[j+1].index:text.length);}\nfunction healNumericFragments(src){let s=String(src||'').replace(/\\r/g,'');for(let pass=0;pass<4;pass++){s=s.replace(/(\\d[\\d,]*)\\.\\s+(\\d{1,2})(?=(?:\\s|$|[^\\d]))/g,'$1.$2');s=s.replace(/(\\d[\\d,]*)\\s+\\.\\s*(\\d{1,2})(?=(?:\\s|$|[^\\d]))/g,'$1.$2');s=s.replace(/(\\d{1,3}),\\s+(\\d{3})(?=(?:\\.\\d{1,2})?(?:\\s|$|[^\\d]))/g,'$1,$2');}return s;}\nfunction moneyNum(v){if(v===null||v===undefined)return null;let s=String(v).replace(/B\\/\\.?|\\$/gi,'').replace(/,/g,'').replace(/\\s+/g,'').trim();if(/^\\(.*\\)$/.test(s))s='-'+s.slice(1,-1);if(!/^-?\\d+(?:\\.\\d+)?$/.test(s))return null;const n=Number(s);return Number.isFinite(n)?round(n):null;}\nconst moneyToken=/\\(?-?\\d[\\d,]*(?:\\.\\d+)?\\)?/g;\nfunction tokens(s){return [...String(s||'').matchAll(moneyToken)].map(m=>({raw:m[0],index:m.index,val:moneyNum(m[0])})).filter(x=>x.val!==null);}\nfunction moneyNearLabel(block,labelRe){\n const lines=String(block||'').split('\\n'), candidates=[];\n for(let i=0;i<lines.length;i++){\n const line=lines[i]; const flags=(labelRe.flags||'').replace(/g/g,''); const rg=new RegExp(labelRe.source,flags); const m=rg.exec(line); if(!m)continue;\n const after=tokens(line.slice(m.index+m[0].length)); if(after.length){candidates.push({line:i,val:after[0].val,method:'SAME_AFTER'});continue;}\n const before=tokens(line.slice(0,m.index)); if(before.length){candidates.push({line:i,val:before[before.length-1].val,method:'SAME_BEFORE'});continue;}\n let found=null;\n for(let d=1;d<=3&&!found;d++){const j=i-d;if(j<0)break;const p=lines[j].trim();if(!p)continue;if(/^\\(?-?\\d[\\d,]*(?:\\.\\d+)?\\)?$/.test(p)){found={line:i,val:moneyNum(p),method:`PREV_${d}`};break;}else break;}\n if(!found){for(let d=1;d<=2&&!found;d++){const j=i+d;if(j>=lines.length)break;const p=lines[j].trim();if(!p)continue;if(/^\\(?-?\\d[\\d,]*(?:\\.\\d+)?\\)?$/.test(p)){found={line:i,val:moneyNum(p),method:`NEXT_${d}`};break;}else break;}}\n if(found)candidates.push(found);\n }\n return candidates.length?candidates[candidates.length-1]:{line:-1,val:null,method:''};\n}\nfunction textNearLabel(block,labelRe,valueRe){\n const lines=String(block||'').split('\\n'),out=[];for(let i=0;i<lines.length;i++){const line=lines[i],flags=(labelRe.flags||'').replace(/g/g,''),rg=new RegExp(labelRe.source,flags),m=rg.exec(line);if(!m)continue;let a=line.slice(m.index+m[0].length).match(valueRe);if(a){out.push({line:i,val:clean(a[0]),method:'SAME_AFTER'});continue;}let b=line.slice(0,m.index).match(new RegExp(valueRe.source+'(?=[^'+(valueRe.source.includes('\\\\d')?'\\\\d':'')+']*$)',valueRe.flags||''));if(b)out.push({line:i,val:clean(b[0]),method:'SAME_BEFORE'});}return out.length?out[out.length-1]:{line:-1,val:'',method:''};\n}\nfunction dateNearLabel(block,labelRe){const lines=String(block||'').split('\\n'),out=[];for(let i=0;i<lines.length;i++){const line=lines[i],rg=new RegExp(labelRe.source,(labelRe.flags||'').replace(/g/g,'')),m=rg.exec(line);if(!m)continue;const after=line.slice(m.index+m[0].length).match(/\\d{1,2}\\/\\d{1,2}\\/20\\d{2}/);if(after){out.push(after[0]);continue;}const before=line.slice(0,m.index).match(/\\d{1,2}\\/\\d{1,2}\\/20\\d{2}(?![\\s\\S]*\\d{1,2}\\/\\d{1,2}\\/20\\d{2})/);if(before)out.push(before[0]);}return out.length?out[out.length-1]:'';}\nfunction txnNearLabel(block){const lines=String(block||'').split('\\n'),out=[];for(const line of lines){const m=/N[uú]mero\\s+de\\s+Transacci[oó]n\\s*:?/i.exec(line);if(!m)continue;const a=line.slice(m.index+m[0].length).match(/\\d{3,}/);if(a)out.push(a[0]);else{const b=line.slice(0,m.index).match(/\\d{3,}(?![\\s\\S]*\\d{3,})/);if(b)out.push(b[0]);}}return out.length?out[out.length-1]:'';}\nfunction close(a,b,tol=.15){return Number.isFinite(Number(a))&&Number.isFinite(Number(b))&&Math.abs(Number(a)-Number(b))<=tol;}\nfunction globalPlanilla(text){const m=/(?:Planilla\\s+(?:N[uú]mero|n[uú]mero)\\s*:?)\\s*(\\d+)/i.exec(text);return m?Number(m[1]):0;}\nfunction globalGenerated(text){const m=/(\\d{1,2}\\/\\d{1,2}\\/20\\d{2}\\s+\\d{1,2}:\\d{2}:\\d{2}\\s*(?:AM|PM)?)/i.exec(text);return m?clean(m[1]):'';}\nfunction extraPeriod(text){let m=/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.exec(text);if(!m)return null;const a=parseDMY(m[1]),b=parseDMY(m[2]);if(!a||!b)return null;const x=[a,b].sort((p,q)=>p-q);return {a:x[0],b:x[1],start:dateKey(x[0]),end:dateKey(x[1]),display:`${fmtDate(x[0])} - ${fmtDate(x[1])}`};}\nfunction periodLibre(post){\n const lines=String(post||'').split('\\n');for(let i=0;i<lines.length;i++){if(!/Periodo\\s+Libre/i.test(lines[i]))continue;const same=[...lines[i].matchAll(/\\d{1,2}\\/\\d{1,2}\\/20\\d{2}/g)].map(x=>x[0]);if(same.length>=2)return mkPeriod(same[0],same[1]);const prev=[...lines.slice(Math.max(0,i-4),i+1).join(' ').matchAll(/\\d{1,2}\\/\\d{1,2}\\/20\\d{2}/g)].map(x=>x[0]);if(prev.length>=2)return mkPeriod(prev[prev.length-2],prev[prev.length-1]);const next=[...lines.slice(i,Math.min(lines.length,i+5)).join(' ').matchAll(/\\d{1,2}\\/\\d{1,2}\\/20\\d{2}/g)].map(x=>x[0]);if(next.length>=2)return mkPeriod(next[0],next[1]);}return null;\n}\nfunction mkPeriod(a,b){const d1=parseDMY(a),d2=parseDMY(b);if(!d1||!d2)return null;const x=[d1,d2].sort((p,q)=>p-q);return {startDate:x[0],endDate:x[1],start:dateKey(x[0]),end:dateKey(x[1]),display:`${fmtDate(x[0])} - ${fmtDate(x[1])}`};}\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('|');}\nfunction samePerson(a,b){const ac=clean(a.code).replace(/^0+(?=\\d)/,''),bc=clean(b.code).replace(/^0+(?=\\d)/,'');if(ac&&bc&&ac===bc)return true;return !!(norm(a.name)&&norm(a.name)===norm(b.name));}\nfunction vacFields(block){return {vac:moneyNearLabel(block,/Vacaciones(?!\\s+Correspondientes)/i),gross:moneyNearLabel(block,/TOTAL\\s+BRUTO\\s*:?/i),ss:moneyNearLabel(block,/Seg\\.\\s*Soc\\.?/i),se:moneyNearLabel(block,/Seg\\.\\s*Edu\\.?/i)};}\nfunction vacCandidateOK(f){if([f.vac.val,f.gross.val,f.ss.val,f.se.val].some(v=>v===null))return false;const g=f.gross.val;return close(f.vac.val,g,.08)&&close(f.ss.val,round(g*Number(rates.ssRegular||.0975)),.16)&&close(f.se.val,round(g*Number(rates.se||.0125)),.13);}\nfunction extraFields(block){return {gross:moneyNearLabel(block,/\\bTotales\\b/i),ss:moneyNearLabel(block,/Seg\\.\\s*Soc\\.?/i),se:moneyNearLabel(block,/Seg\\.\\s*Edu\\.?/i)};}\nfunction extraCandidateOK(f){if([f.gross.val,f.ss.val,f.se.val].some(v=>v===null))return false;const g=f.gross.val;return close(f.ss.val,round(g*Number(rates.ssRegular||.0975)),.16)&&close(f.se.val,round(g*Number(rates.se||.0125)),.13);}\nfunction liqFields(block){return {date:dateNearLabel(block,/FECHA\\s+DE\\s+LIQUIDACI[ÓO]N\\s*:?/i),transaction:txnNearLabel(block),ss:moneyNearLabel(block,/Seg\\.\\s*Soc\\.?/i),se:moneyNearLabel(block,/Seg\\.\\s*Edu\\.?/i),sv:moneyNearLabel(block,/Salario,?\\s*Vac,?\\s*13\\s*Mes\\s*:?/i),sal:moneyNearLabel(block,/Salario\\s+Reg\\.?\\s*:?/i),vac:moneyNearLabel(block,/^\\s*Vacaciones\\s*:?/i),dtm:moneyNearLabel(block,/^\\s*XIII\\s+Mes\\s*:?/i),pai:moneyNearLabel(block,/Preav,?\\s*P\\s*Ant,?\\s*Indem\\s*:?/i),gross:moneyNearLabel(block,/Total\\s+Bruto\\s*:?/i)};}\nfunction liqCandidateOK(f){if(!f.date||!f.transaction||[f.ss.val,f.se.val,f.sv.val,f.sal.val,f.vac.val,f.dtm.val,f.pai.val,f.gross.val].some(v=>v===null))return false;const rb=round(f.sal.val+f.vac.val),compSV=round(rb+f.dtm.val),compGross=round(f.sv.val+f.pai.val),es=round(rb*Number(rates.ssRegular||.0975)+f.dtm.val*Number(rates.ssDtm||.0725)),ee=round(rb*Number(rates.se||.0125));return close(f.sv.val,compSV,.14)&&close(f.gross.val,compGross,.14)&&close(f.ss.val,es,.17)&&close(f.se.val,ee,.13);}\nconst records=[],validations=[],supportDocs=[],files=[];\nfunction validation(status,category,fileName,detail){validations.push({status,severity:(status==='OK'||status==='IGNORADO'||status==='EXCLUIDO')?'INFO':'WARNING',category,fileName,detail});}\nfunction addRecord(r){records.push({...r,ss:round(r.ss||0),se:round(r.se||0),gross:round(r.gross||0),regularBase:round(r.regularBase||0),dtmBase:round(r.dtmBase||0),parserMode:'STRUCTURE_AWARE_V10'});}\nfor(let i=0;i<extracted.length;i++){\n const meta=metaItems[i]?.json||metaItems[i]||{},item=extracted[i]||{},fileName=meta.fileName||`PDF ${i+1}`,category=meta.category||'';files.push(fileName);const raw=item.json?.text??item.json?.data??item.json?.content??item.text??'';const text=healNumericFragments(Array.isArray(raw)?raw.join('\\n<PAGE_BREAK>\\n'):String(raw||''));if(!text.trim()){validation('REVISAR',category,fileName,'PDF sin texto extraíble; archivo excluido.');continue;}\n if(category==='vacaciones'){\n const ms=markers(text,/Empleado\\s*#\\s*(\\d+)\\s*:\\s*(.+?)(?=\\s+(?:Clave\\s+ISR|Vacaciones\\s+Correspondientes|Periodo\\s+Libre)|[\\n\\r]|$)/i);if(!ms.length){validation('REVISAR',category,fileName,'No se detectaron empleados.');continue;}\n const firstPre=vacFields(segPre(text,ms,0)),orientation=vacCandidateOK(firstPre)?'PRE':'POST',planillaNumber=globalPlanilla(text),generatedAt=globalGenerated(text);let inc=0,out=0,review=0;\n for(let j=0;j<ms.length;j++){const code=clean(ms[j].match[1]).replace(/^0+(?=\\d)/,''),name=clean(ms[j].match[2]),pre=segPre(text,ms,j),post=segPost(text,ms,j),f=vacFields(orientation==='PRE'?pre:post),pr=periodLibre(post),g=f.gross.val;let reason='';if(!vacCandidateOK(f))reason=`Campos/tasas de vacaciones no cuadran: Vac ${f.vac.val}, Bruto ${f.gross.val}, S.S. ${f.ss.val}, S.E. ${f.se.val}.`;else if(!pr)reason='No se pudo leer Periodo Libre.';if(reason){review++;addRecord({category:'Vacaciones',fileName,code,name,ss:f.ss.val||0,se:f.se.val||0,gross:g||0,regularBase:g||0,dtmBase:0,date:pr?.display||'',periodStart:pr?.start||'',periodEnd:pr?.end||'',transaction:'',planillaNumber,generatedAt,included:false,reason,orientation});continue;}const included=overlapTarget(pr.startDate,pr.endDate)!==false;if(included)inc++;else out++;addRecord({category:'Vacaciones',fileName,code,name,ss:f.ss.val,se:f.se.val,gross:g,regularBase:g,dtmBase:0,date:pr.display,periodStart:pr.start,periodEnd:pr.end,transaction:'',planillaNumber,generatedAt,included,reason:included?'':'Período Libre fuera del mes CSS',orientation});}\n validation(review?'REVISAR':out?'EXCLUIDO':'OK',category,fileName,`${ms.length} vacaciones: ${inc} incluidas, ${out} fuera de período, ${review} no legibles. Orientación ${orientation}.`);\n } else if(category==='extraordinaria'){\n const ms=markers(text,/Empleado\\s*:\\s*(\\d+)\\s+(.+?)\\s+Transac\\.?\\s*#\\s*:\\s*(\\d+)(?:\\s+Pla\\.?\\s*#\\s*:\\s*(\\d+))?/i),pr=extraPeriod(text);if(!ms.length){validation('REVISAR',category,fileName,'No se detectaron empleados.');continue;}const firstPre=extraFields(segPre(text,ms,0)),orientation=extraCandidateOK(firstPre)?'PRE':'POST',gp=globalPlanilla(text),generatedAt=globalGenerated(text);let inc=0,out=0,review=0;\n for(let j=0;j<ms.length;j++){const code=clean(ms[j].match[1]).replace(/^0+(?=\\d)/,''),name=clean(ms[j].match[2]),transaction=clean(ms[j].match[3]),planillaNumber=Number(ms[j].match[4]||0)||gp,f=extraFields(orientation==='PRE'?segPre(text,ms,j):segPost(text,ms,j));let reason='';if(!pr||!extraCandidateOK(f))reason=`Campos/tasas extraordinaria no cuadran: Bruto ${f.gross.val}, S.S. ${f.ss.val}, S.E. ${f.se.val}.`;if(reason){review++;addRecord({category:'Extraordinaria',fileName,code,name,ss:f.ss.val||0,se:f.se.val||0,gross:f.gross.val||0,regularBase:f.gross.val||0,dtmBase:0,date:pr?.display||'',periodStart:pr?.start||'',periodEnd:pr?.end||'',transaction,planillaNumber,generatedAt,included:false,reason,orientation});continue;}const included=overlapTarget(pr.a,pr.b)!==false;if(included)inc++;else out++;addRecord({category:'Extraordinaria',fileName,code,name,ss:f.ss.val,se:f.se.val,gross:f.gross.val,regularBase:f.gross.val,dtmBase:0,date:pr.display,periodStart:pr.start,periodEnd:pr.end,transaction,planillaNumber,generatedAt,included,reason:included?'':'Período fuera del mes CSS',orientation});}\n validation(review?'REVISAR':out?'EXCLUIDO':'OK',category,fileName,`${ms.length} extraordinarias: ${inc} incluidas, ${out} fuera de período, ${review} no legibles. Orientación ${orientation}.`);\n } else if(category==='liquidaciones'){\n if(!norm(text).includes('PLANILLA DE LIQUIDACION')){const rawm=markers(text,/Empleado\\s*:\\s*(\\d+)\\s+([^\\n\\r]+)/i).map(x=>({code:clean(x.match[1]).replace(/^0+(?=\\d)/,''),name:clean(x.match[2]).replace(/\\s+(?:A[nñ]o|C[eé]dula:).*$/i,'')}));const seen=new Set(),employees=[];for(const e of rawm){const k=e.code+'|'+norm(e.name);if(!seen.has(k)){seen.add(k);employees.push(e);}}supportDocs.push({fileName,employees});validation('IGNORADO',category,fileName,`Documento de cálculo/soporte (${employees.length} empleado(s)); no se suma.`);continue;}\n const ms=markers(text,/Empleado\\s*#\\s*(\\d+)\\s*:\\s*(.+?)(?=\\s+(?:FECHA\\s+DE\\s+LIQUIDACI[ÓO]N|Clave\\s+ISR|N[uú]mero\\s+de\\s+Transacci[oó]n)|[\\n\\r]|$)/i);if(!ms.length){validation('REVISAR',category,fileName,'No se detectaron empleados resumidos.');continue;}const firstPre=liqFields(segPre(text,ms,0)),orientation=liqCandidateOK(firstPre)?'PRE':'POST',planillaNumber=globalPlanilla(text),generatedAt=globalGenerated(text);let inc=0,out=0,review=0;\n for(let j=0;j<ms.length;j++){const code=clean(ms[j].match[1]).replace(/^0+(?=\\d)/,''),name=clean(ms[j].match[2]),f=liqFields(orientation==='PRE'?segPre(text,ms,j):segPost(text,ms,j));let regularBase=round((f.sal.val||0)+(f.vac.val||0)),reason='';if(!liqCandidateOK(f)){const compSV=round(regularBase+(f.dtm.val||0)),compGross=round((f.sv.val||0)+(f.pai.val||0)),es=round(regularBase*Number(rates.ssRegular||.0975)+(f.dtm.val||0)*Number(rates.ssDtm||.0725)),ee=round(regularBase*Number(rates.se||.0125));reason=`Control liquidación: fecha ${f.date||'-'}, tx ${f.transaction||'-'}, Sal/Vac/13 ${f.sv.val} vs ${compSV}, bruto ${f.gross.val} vs ${compGross}, S.S. ${f.ss.val} (~${es}), S.E. ${f.se.val} (~${ee}).`;}if(reason){review++;addRecord({category:'Liquidación',fileName,code,name,ss:f.ss.val||0,se:f.se.val||0,gross:f.gross.val||0,regularBase,dtmBase:f.dtm.val||0,salaryVac13:f.sv.val||0,salaryReg:f.sal.val||0,vacationBase:f.vac.val||0,date:f.date,transaction:f.transaction,planillaNumber,generatedAt,included:false,reason,orientation});continue;}const included=inTarget(parseDMY(f.date));if(included)inc++;else out++;addRecord({category:'Liquidación',fileName,code,name,ss:f.ss.val,se:f.se.val,gross:f.gross.val,regularBase,dtmBase:f.dtm.val,salaryVac13:f.sv.val,salaryReg:f.sal.val,vacationBase:f.vac.val,date:f.date,transaction:f.transaction,planillaNumber,generatedAt,included,reason:included?'':'Fecha de liquidación fuera del mes CSS',orientation});}\n validation(review?'REVISAR':out?'EXCLUIDO':'OK',category,fileName,`${ms.length} liquidaciones: ${inc} incluidas, ${out} fuera de período, ${review} no legibles. Orientación ${orientation}.`);\n }\n}\nconst seenExact=new Map();for(const r of records){if(!r.included)continue;const k=recordFingerprint(r);if(!seenExact.has(k)){seenExact.set(k,r);continue;}r.included=false;r.reason=`Duplicado exacto; ya incluido desde ${seenExact.get(k).fileName}`;}\nfor(const cat of ['Extraordinaria','Liquidación']){const seen=new Map();for(const r of records){if(r.category!==cat||!r.included||!r.transaction)continue;const k=String(r.transaction);if(!seen.has(k)){seen.set(k,r);continue;}const p=seen.get(k);if(recordFingerprint(p)===recordFingerprint(r)){r.included=false;r.reason=`Duplicado exacto de transacción; ya incluido desde ${p.fileName}`;}else{r.included=false;r.reason=`Transacción ${k} repetida con datos diferentes; revisar.`;validation('REVISAR',cat.toLowerCase(),r.fileName,r.reason);}}}\nconst groups=new Map();for(const r of records){if(r.category!=='Vacaciones'||!r.included)continue;const k=`${r.code||norm(r.name)}|${r.periodStart}|${r.periodEnd}`;if(!groups.has(k))groups.set(k,[]);groups.get(k).push(r);}for(const arr of groups.values()){if(arr.length<2)continue;arr.sort((a,b)=>(Number(b.planillaNumber)||0)-(Number(a.planillaNumber)||0)||String(b.generatedAt).localeCompare(String(a.generatedAt)));for(const r of arr.slice(1)){r.included=false;r.reason=`Sustituida por planilla de vacaciones más reciente (${arr[0].planillaNumber||arr[0].fileName}).`;}}\nconst liqAll=records.filter(r=>r.category==='Liquidación');for(const doc of supportDocs){const missing=(doc.employees||[]).filter(e=>!liqAll.some(r=>samePerson(e,r)));if(missing.length)validation('REVISAR','liquidaciones',doc.fileName,`Documento de cálculo contiene persona(s) sin planilla resumida: ${missing.map(x=>`${x.name}${x.code?` (#${x.code})`:''}`).join(', ')}.`);}\nconst includedRecords=records.filter(r=>r.included),catCount=cat=>includedRecords.filter(r=>r.category===cat).length;return {records,validations,supportDocs,includedRecords,files:[...new Set(files)],counts:{extraordinarias:catCount('Extraordinaria'),vacaciones:catCount('Vacaciones'),liquidaciones:catCount('Liquidación')},totals:{extraSS:round(includedRecords.filter(r=>r.category==='Extraordinaria').reduce((s,r)=>s+r.ss,0)),vacSS:round(includedRecords.filter(r=>r.category==='Vacaciones').reduce((s,r)=>s+r.ss,0)),liqSS:round(includedRecords.filter(r=>r.category==='Liquidación').reduce((s,r)=>s+r.ss,0)),totalSS:round(includedRecords.reduce((s,r)=>s+r.ss,0)),totalSE:round(includedRecords.reduce((s,r)=>s+r.se,0))},parserVersion:'STRUCTURE_AWARE_V10_SIN_IA',quality:{status:validations.some(v=>v.status==='REVISAR')?'REVISAR':'VALIDADO',rule:'La orientación PRE/POST se determina con el primer empleado del archivo mediante controles matemáticos. La misma orientación se aplica a todo el archivo. Vacaciones lee Periodo Libre siempre del bloque posterior. No hay alineación global por listas.'}};\n}\n\nconst __result = parseSupplementals($input.all(), $items('Normalizar PDFs descargados'), (($items('Parsear CSS')[0]||{}).json||{}), (($items('Inicializar ejecución')[0]||{}).json||{}));\nreturn [{json:__result}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
61904,
|
|
34832
|
|
],
|
|
"id": "14f0d70e-f329-49c3-b500-4fdad7c2112f",
|
|
"name": "Parsear PDFs",
|
|
"notesInFlow": true,
|
|
"notes": "Parser complementarios V8 SIN IA: extracción directa por campos del mismo empleado, detección PRE/POST por archivo, validación contable/tasas, soporte para PDF.js y decimales fragmentados. No alinea listas globales ni toma montos de empleados vecinos."
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const init=$items('Inicializar ejecución')[0].json,config=init.config||{};\nconst q1=$items('Resultado Q1')[0].json,q2=$items('Resultado Q2')[0].json,css=$items('Parsear CSS')[0].json,pdfs=$items('Parsear PDFs')[0].json;\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').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 core(v){return norm(v).split(' ').filter(t=>t&&!particles.has(t));}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction money(n){return `B/. ${Number(n||0).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}`;}\nfunction parseDMY(s){const m=String(s||'').match(/(\\d{1,2})\\/(\\d{1,2})\\/(20\\d{2})/);return m?new Date(Number(m[3]),Number(m[2])-1,Number(m[1])):null;}\nfunction validatePayrollPeriod(label,p,half){\n if(!p)return {status:'REVISAR',detail:`${label}: período no detectado automáticamente; se validó estructura y totales, pero conviene confirmar el archivo.`};\n const a=parseDMY(p.start),b=parseDMY(p.end),tm=css.period.month,ty=css.period.year;\n if(!a||!b)return {status:'REVISAR',detail:`${label}: período ilegible.`};\n const same=a.getFullYear()===ty&&b.getFullYear()===ty&&a.getMonth()+1===tm&&b.getMonth()+1===tm;\n if(!same)throw new Error(`${label} pertenece a ${p.start} - ${p.end}, pero la Planilla CSS es ${css.periodoLabel}. Se detuvo el cruce.`);\n if(a>b)throw new Error(`${label} tiene un período invertido (${p.start} - ${p.end}). Se detuvo el cruce.`);\n if(half===1&&(a.getDate()>15||b.getDate()>16))throw new Error(`${label} no parece ser la 1era quincena (${p.start} - ${p.end}). Revisa que no hayas cargado la 2da quincena por error.`);\n if(half===2&&(a.getDate()<15||b.getDate()<27))throw new Error(`${label} no parece ser la 2da quincena (${p.start} - ${p.end}). Revisa que no hayas cargado la 1era quincena por error.`);\n return {status:'OK',detail:`${label}: ${p.start} - ${p.end}`};\n}\nfunction payrollFingerprint(x){return (x.records||[]).map(r=>[String(r.code||''),norm(r.name),round(r.ss),round(r.se),round(r.gross)].join('|')).sort().join('||');}\nconst periodChecks=[validatePayrollPeriod('Nómina Q1',q1.period,1),validatePayrollPeriod('Nómina Q2',q2.period,2)];\nconst fpQ1=payrollFingerprint(q1),fpQ2=payrollFingerprint(q2);\nif(fpQ1&&fpQ2&&fpQ1===fpQ2)throw new Error('Las nóminas Q1 y Q2 contienen exactamente los mismos empleados y montos. Se detuvo el cruce porque probablemente se cargó la misma quincena dos veces.');\n// Inferencia dinámica de tasas del trabajador desde los propios recibos, para no depender de tasas hardcodeadas año tras año.\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;}\nconst ratioSS=[],ratioSE=[];\nfor(const r of [...(q1.records||[]),...(q2.records||[])]){const g=Number(r.gross)||0,ss=Number(r.ss)||0,se=Number(r.se)||0;if(g>=20&&ss>0){const x=ss/g;if(x>.03&&x<.20)ratioSS.push(x);}if(g>=20&&se>0){const x=se/g;if(x>.001&&x<.05)ratioSE.push(x);}}\nconst fallback=config.defaultRates||{ssRegular:.0975,se:.0125,ssDtm:.0725};\nconst ssMed=median(ratioSS),seMed=median(ratioSE),ssRegular=Number((ssMed??fallback.ssRegular).toFixed(4)),seRate=Number((seMed??fallback.se).toFixed(4));\nconst dtmRatios=[];for(const r of pdfs.includedRecords||[]){if(r.category!=='Liquidación'||Number(r.dtmBase||0)<=1)continue;const x=(Number(r.ss||0)-Number(r.regularBase||0)*ssRegular)/Number(r.dtmBase);if(x>.03&&x<.15)dtmRatios.push(x);}\nconst dtmMed=median(dtmRatios),ssDtm=Number((dtmMed??fallback.ssDtm).toFixed(4));\nconst rateInfo={ssRegular,se:seRate,ssDtm,samplesSS:ratioSS.length,samplesSE:ratioSE.length,samplesDTM:dtmRatios.length,usedFallbackSS:ssMed===null,usedFallbackSE:seMed===null,usedFallbackDTM:dtmMed===null};\nif(ratioSS.length<(config.minRateSamples||20)||ratioSE.length<(config.minRateSamples||20))throw new Error(`No hubo suficientes registros para inferir las tasas de S.S./S.E. de forma segura (${ratioSS.length}/${ratioSE.length}). Se detuvo el cruce.`);\nconst plans=(css.employees||[]).map((p,index)=>({...p,_idx:index,expectedWorkerSS:round(Number(p.salaryAdjusted||0)*ssRegular+Number(p.dtmBase||0)*ssDtm),expectedWorkerSE:round(Number(p.salaryAdjusted||0)*seRate)}));\n// Une todas las fuentes por Employee # cuando existe; cuando el PDF pierde el código, usa el nombre exacto conservadoramente.\nconst observations=[];for(const r of q1.records||[])observations.push({...r,kind:'q1'});for(const r of q2.records||[])observations.push({...r,kind:'q2'});for(const r of pdfs.includedRecords||[]){const kind=r.category==='Extraordinaria'?'extra':r.category==='Vacaciones'?'vac':'liq';observations.push({...r,kind});}\nconst groups=new Map(),nameCodes=new Map();\nfor(const r of observations){const code=clean(r.code).replace(/^0+(?=\\d)/,''),nn=norm(r.name);if(!code)continue;const k=`C:${code}`;if(!groups.has(k))groups.set(k,{key:k,code,names:[],obs:[]});const g=groups.get(k);if(r.name&&!g.names.includes(r.name))g.names.push(r.name);g.obs.push(r);if(!nameCodes.has(nn))nameCodes.set(nn,new Set());nameCodes.get(nn).add(code);}\nfor(const r of observations){const code=clean(r.code).replace(/^0+(?=\\d)/,'');if(code)continue;const nn=norm(r.name),codes=nameCodes.get(nn)||new Set();let g;if(codes.size===1)g=groups.get(`C:${[...codes][0]}`);else{const k=`N:${nn}`;if(!groups.has(k))groups.set(k,{key:k,code:'',names:[],obs:[]});g=groups.get(k);}if(r.name&&!g.names.includes(r.name))g.names.push(r.name);g.obs.push(r);}\nfor(const g of groups.values()){for(const k of ['q1SS','q1SE','q2SS','q2SE','extraSS','extraSE','vacSS','vacSE','liqSS','liqSE'])g[k]=0;for(const r of g.obs){const ss=Number(r.ss)||0,se=Number(r.se)||0;g[`${r.kind}SS`]+=ss;g[`${r.kind}SE`]+=se;}g.name=g.names[0]||'';g.totalSS=round(g.q1SS+g.q2SS+g.extraSS+g.vacSS+g.liqSS);g.totalSE=round(g.q1SE+g.q2SE+g.extraSE+g.vacSE+g.liqSE);}\nconst sources=[...groups.values()];\n// Similaridad conservadora OPTIMIZADA: conserva las mismas reglas de matching,\n// pero evita comparar cada fuente contra cada empleado CSS con Levenshtein completo.\n// Primero crea índices baratos (tokens, prefijos, iniciales y montos) y solo ejecuta\n// la similitud costosa sobre un conjunto reducido de candidatos plausibles.\nfunction levRaw(a,b){\n a=String(a||''); b=String(b||'');\n if(a===b)return 0;\n if(!a.length)return b.length;\n if(!b.length)return a.length;\n if(a.length>b.length){const t=a;a=b;b=t;}\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;\n const bj=b.charCodeAt(j-1);\n for(let i=1;i<=a.length;i++){\n const cost=a.charCodeAt(i-1)===bj?0:1;\n const ins=cur[i-1]+1,del=prev[i]+1,sub=prev[i-1]+cost;\n cur[i]=Math.min(ins,del,sub);\n }\n const tmp=prev;prev=cur;cur=tmp;\n }\n return prev[a.length];\n}\nfunction compactNorm(v){return norm(v).replace(/ /g,'');}\nfunction simPrepared(a,b){a=String(a||'');b=String(b||'');return !a||!b?0:1-levRaw(a,b)/Math.max(a.length,b.length);}\nfunction tokenCovPrepared(A,B){\n if(!A.length||!B.length)return 0;\n const avg=(X,Y)=>{\n let total=0;\n for(const x of X){\n let best=0;\n for(const y of Y){\n const s=simPrepared(x,y);\n if(s>best)best=s;\n if(best===1)break;\n }\n total+=best;\n }\n return total/X.length;\n };\n return (avg(A,B)+avg(B,A))/2;\n}\nfunction sourceParts(name){\n const raw=String(name||'');let l='',f='';\n if(raw.includes(',')){const pos=raw.indexOf(',');l=raw.slice(0,pos);f=raw.slice(pos+1);}\n else{const t=norm(raw).split(' ');l=t.shift()||'';f=t.join(' ');}\n const L=core(l),F=core(f);\n return {\n last:norm(l),first:norm(f),last1:L[0]||'',first1:F[0]||'',\n lastC:compactNorm(l),firstC:compactNorm(f),\n last1C:compactNorm(L[0]||''),first1C:compactNorm(F[0]||'')\n };\n}\nfunction planParts(p){\n const L=core(p.lastname),F=core(p.firstname);\n return {\n last:norm(p.lastname),first:norm(p.firstname),last1:L[0]||'',first1:F[0]||'',\n lastC:compactNorm(p.lastname),firstC:compactNorm(p.firstname),\n last1C:compactNorm(L[0]||''),first1C:compactNorm(F[0]||'')\n };\n}\nfunction uniqTokens(v){return [...new Set(core(v).map(compactNorm).filter(Boolean))];}\nfunction addIndex(map,k,v){if(!k)return;if(!map.has(k))map.set(k,[]);map.get(k).push(v);}\nfunction addBucket(set,map,k){for(const v of map.get(k)||[])set.add(v);}\nfunction prefix(v,n){v=String(v||'');return v.length>=n?v.slice(0,n):v;}\nfunction amountClass(dss,dse){if(dss<=.05&&dse<=.05)return 1;if(dss<=.12&&dse<=.12)return .95;if(dss<=.25&&dse<=.25)return .85;if(dss<=.5&&dse<=.5)return .70;if(dss<=1&&dse<=1)return .50;return 0;}\n\nconst planMeta=plans.map((p,pi)=>({\n p,pi,pp:planParts(p),tokens:uniqTokens(p.name),\n pairKey:`${p.expectedWorkerSS.toFixed(2)}|${p.expectedWorkerSE.toFixed(2)}`\n}));\nconst sourceMeta=sources.map((s,si)=>({s,si,sp:sourceParts(s.name),tokens:uniqTokens(s.name)}));\n\nconst pairFreq=new Map(),firstFreq=new Map(),lastFreq=new Map();\nfor(const m of planMeta){\n pairFreq.set(m.pairKey,(pairFreq.get(m.pairKey)||0)+1);\n firstFreq.set(m.pp.first1,(firstFreq.get(m.pp.first1)||0)+1);\n lastFreq.set(m.pp.last1,(lastFreq.get(m.pp.last1)||0)+1);\n}\n\nconst byToken=new Map(),byLast2=new Map(),byLast3=new Map(),byFirst2=new Map(),byFirst3=new Map(),byInitialPair=new Map(),byAmount=new Map();\nfunction amountKey(ss,se){return `${Math.round((Number(ss)||0)*4)}|${Math.round((Number(se)||0)*4)}`;}\nfor(const m of planMeta){\n for(const t of m.tokens)addIndex(byToken,t,m.pi);\n addIndex(byLast2,prefix(m.pp.last1C,2),m.pi);\n addIndex(byLast3,prefix(m.pp.last1C,3),m.pi);\n addIndex(byFirst2,prefix(m.pp.first1C,2),m.pi);\n addIndex(byFirst3,prefix(m.pp.first1C,3),m.pi);\n addIndex(byInitialPair,`${m.pp.last1C[0]||''}|${m.pp.first1C[0]||''}`,m.pi);\n addIndex(byAmount,amountKey(m.p.expectedWorkerSS,m.p.expectedWorkerSE),m.pi);\n}\n\nconst candidates=[],candidatesBySource=new Map();\nconst tierBuckets=[[],[],[],[],[],[]];\nfor(const sm of sourceMeta){\n const s=sm.s,sp=sm.sp,pool=new Set();\n\n // Evidencia de identidad exacta o por prefijo.\n for(const t of sm.tokens)addBucket(pool,byToken,t);\n addBucket(pool,byLast3,prefix(sp.last1C,3));\n addBucket(pool,byFirst3,prefix(sp.first1C,3));\n addBucket(pool,byLast2,prefix(sp.last1C,2));\n addBucket(pool,byFirst2,prefix(sp.first1C,2));\n addBucket(pool,byInitialPair,`${sp.last1C[0]||''}|${sp.first1C[0]||''}`);\n\n // Evidencia de monto: busca hasta +/- B/.1.00 en pasos de B/.0.25.\n const ssQ=Math.round((Number(s.totalSS)||0)*4),seQ=Math.round((Number(s.totalSE)||0)*4);\n for(let ds=-4;ds<=4;ds++)for(let de=-4;de<=4;de++)addBucket(pool,byAmount,`${ssQ+ds}|${seQ+de}`);\n\n // Red de seguridad: si los índices produjeron muy pocos candidatos, agrega los\n // 12 CSS más cercanos por monto. Este barrido solo hace aritmética, no Levenshtein.\n if(pool.size<12){\n const closest=[];\n for(const pm of planMeta){\n const d=Math.abs((Number(s.totalSS)||0)-pm.p.expectedWorkerSS)+Math.abs((Number(s.totalSE)||0)-pm.p.expectedWorkerSE);\n if(closest.length<12){closest.push([d,pm.pi]);closest.sort((a,b)=>a[0]-b[0]);}\n else if(d<closest[closest.length-1][0]){closest[closest.length-1]=[d,pm.pi];closest.sort((a,b)=>a[0]-b[0]);}\n }\n for(const [,pi] of closest)pool.add(pi);\n }\n\n const local=[];\n for(const pi of pool){\n const pm=planMeta[pi],p=pm.p,pp=pm.pp;\n const lastFirst=simPrepared(sp.last1C,pp.last1C);\n const lastFull=simPrepared(sp.lastC,pp.lastC);\n const firstSim=Math.max(simPrepared(sp.first1C,pp.first1C),simPrepared(sp.firstC,pp.firstC));\n const gen=tokenCovPrepared(sm.tokens,pm.tokens);\n const dss=Math.abs(s.totalSS-p.expectedWorkerSS),dse=Math.abs(s.totalSE-p.expectedWorkerSE),am=amountClass(dss,dse);\n const pairUnique=(pairFreq.get(pm.pairKey)||0)===1;\n const firstUnique=(firstFreq.get(pp.first1)||0)===1,lastUnique=(lastFreq.get(pp.last1)||0)===1;\n let tier=0,method='';\n if(sp.last1&&sp.last1===pp.last1&&firstSim>=.84){tier=1;method='APELLIDO + NOMBRE';}\n else if(lastFirst>=.78&&firstSim>=.90&&am>=.85){tier=2;method='VARIANTE DE APELLIDO + NOMBRE + MONTO';}\n else if(gen>=.86&&am>=.70&&(lastFirst>=.65||firstSim>=.80)){tier=3;method='NOMBRE FUZZY + MONTO';}\n else if(firstSim>=.95&&(firstUnique||pairUnique)&&am>=.95){tier=4;method='NOMBRE + MONTO ÚNICO';}\n else if(sp.last1&&sp.last1===pp.last1&&lastUnique&&am>=.95){tier=4;method='APELLIDO ÚNICO + MONTO';}\n else if(gen>=.72&&pairUnique&&am>=.95){tier=5;method='MONTO ÚNICO + EVIDENCIA DE NOMBRE';}\n if(!tier)continue;\n const nameScore=.38*lastFirst+.32*firstSim+.18*gen+.12*lastFull;\n const score=(6-tier)*10+.58*nameScore+.42*am-.002*Math.min(dss,10);\n const c={tier,score,si:sm.si,pi,nameScore,lastFirst,firstSim,gen,am,dss,dse,method};\n candidates.push(c);local.push(c);tierBuckets[tier].push(c);\n }\n candidatesBySource.set(sm.si,local);\n}\n\nconst usedS=new Set(),usedP=new Set(),matches=[];\nfor(let tier=1;tier<=5;tier++){\n const arr=tierBuckets[tier].sort((a,b)=>b.score-a.score);\n for(const c of arr){\n if(usedS.has(c.si)||usedP.has(c.pi))continue;\n usedS.add(c.si);usedP.add(c.pi);\n matches.push({...c,source:sources[c.si],plan:plans[c.pi]});\n }\n}\n// Señala matches que cuadran por monto pero cuya identidad es débil, y empates de identidad indistinguibles.\nfor(const m of matches){\n const local=candidatesBySource.get(m.si)||[];\n let ambiguous=false;\n for(const c of local){\n if(c.pi===m.pi)continue;\n if(c.tier<=m.tier&&c.dss<=.05&&c.dse<=.05&&Math.abs(c.score-m.score)<.03){ambiguous=true;break;}\n }\n m.ambiguous=ambiguous;\n m.identityReview=m.tier>=4||m.ambiguous;\n m.confidence=m.tier===1&&!m.ambiguous?'ALTA':m.tier<=3&&!m.ambiguous?'MEDIA':'REVISAR';\n}\nconst onlySource=sources.filter((_,i)=>!usedS.has(i)),onlyPlan=plans.filter((_,i)=>!usedP.has(i)),identityReview=matches.filter(m=>m.identityReview);\nconst tolerance=Number(config.amountTolerance??.10),amountDiffs=matches.filter(m=>Math.abs(m.source.totalSS-m.plan.expectedWorkerSS)>tolerance||Math.abs(m.source.totalSE-m.plan.expectedWorkerSE)>tolerance);\nconst totalSourceSS=round(sources.reduce((s,r)=>s+r.totalSS,0)),totalSourceSE=round(sources.reduce((s,r)=>s+r.totalSE,0)),totalExpectedSS=round(plans.reduce((s,r)=>s+r.expectedWorkerSS,0)),totalExpectedSE=round(plans.reduce((s,r)=>s+r.expectedWorkerSE,0)),diffSS=round(totalSourceSS-totalExpectedSS),diffSE=round(totalSourceSE-totalExpectedSE);\nconst fileReview=(pdfs.validations||[]).filter(v=>['REVISAR','EXCLUIDO'].includes(v.status)).length+periodChecks.filter(v=>v.status==='REVISAR').length+(rateInfo.usedFallbackDTM?1:0);\nconst hasFindings=Boolean(onlySource.length||onlyPlan.length||amountDiffs.length||identityReview.length||fileReview);\nconst statusText=hasFindings?'COMPLETADO · REVISAR HALLAZGOS':'COMPLETADO · SIN DIFERENCIAS',statusDetail=hasFindings?`${onlySource.length+onlyPlan.length} diferencia(s) de presencia, ${amountDiffs.length} diferencia(s) de monto, ${identityReview.length} identidad(es) con match a revisar y ${fileReview} validación(es) documental(es).`:'Las fuentes coinciden con la Planilla CSS dentro de la tolerancia definida.';\nconst title=css.reportTitle,period=css.periodoLabel;\nconst summaryRows=[[title],[period],[],['Métrica','Resultado','Interpretación'],['Estado',statusText,statusDetail],['Empleados únicos en fuentes',sources.length,'Q1 + Q2 + movimientos complementarios incluidos'],['Empleados Planilla CSS',plans.length,'Registros oficiales reconstruidos y validados'],['Coincidencias asignadas',matches.length,'Matching conservador por apellido/nombre y monto'],['Identidades a revisar',identityReview.length,'Matches por evidencia débil o ambigua; no se ocultan'],['Solo en fuentes',onlySource.length,'Sin contraparte CSS con evidencia suficiente'],['Solo en Planilla CSS',onlyPlan.length,'Sin contraparte en fuentes con evidencia suficiente'],['Diferencias de monto',amountDiffs.length,`Diferencia absoluta > B/. ${tolerance.toFixed(2)} en S.S. o S.E.`],['Tasa S.S. regular detectada',`${(ssRegular*100).toFixed(2)}%`,`${rateInfo.samplesSS} observaciones; fallback: ${rateInfo.usedFallbackSS?'sí':'no'}`],['Tasa S.E. detectada',`${(seRate*100).toFixed(2)}%`,`${rateInfo.samplesSE} observaciones; fallback: ${rateInfo.usedFallbackSE?'sí':'no'}`],['Tasa S.S. DTM detectada',`${(ssDtm*100).toFixed(2)}%`,`${rateInfo.samplesDTM} liquidaciones con DTM; fallback: ${rateInfo.usedFallbackDTM?'sí':'no'}`],['S.S. fuentes',totalSourceSS,'Suma real descontada en las fuentes'],['S.S. esperado CSS',totalExpectedSS,'Calculado con tasas detectadas sobre salario ajustado + DTM'],['Diferencia total S.S.',diffSS,'Fuentes menos esperado CSS'],['S.E. fuentes',totalSourceSE,'Suma real descontada en las fuentes'],['S.E. esperado CSS',totalExpectedSE,'Calculado con tasa detectada sobre salario ajustado'],['Diferencia total S.E.',diffSE,'Fuentes menos esperado CSS'],['Seguro Social total oficial CSS',css.totals.seguroSocial,'Validado contra reconstrucción individual'],['Seguro Educativo total oficial CSS',css.totals.seguroEducativo,'Validado contra reconstrucción individual'],['Salario ajustado total CSS',css.totals.salarioAjustado,'Validado contra reconstrucción individual'],['Nómina Q1',`${q1.fileName} (${q1.format})`,''],['Nómina Q2',`${q2.fileName} (${q2.format})`,''],['Planilla CSS',css.fileName,'']];\nconst identityRows=[[title],[period],[],['Estado','Código fuente','Nombre en fuentes','Documento CSS','Nombre Planilla CSS','S.S. fuente','S.S. esperado','Método','Confianza','Observación']];\nconst amountRows=[[title],[period],[],['Estado','Código','Nombre fuentes','Nombre CSS','S.S. Q1','S.S. Q2','S.S. Extra','S.S. Vacaciones','S.S. Liquidaciones','S.S. total','S.S. esperado','Dif. S.S.','S.E. total','S.E. esperado','Dif. S.E.']];\nfor(const m of matches){const s=m.source,p=m.plan,dss=round(s.totalSS-p.expectedWorkerSS),dse=round(s.totalSE-p.expectedWorkerSE),amtOk=Math.abs(dss)<=tolerance&&Math.abs(dse)<=tolerance;let st=amtOk?'COINCIDE':'COINCIDE · REVISAR MONTO',obs='';if(m.identityReview){st='COINCIDE · REVISAR IDENTIDAD';obs=m.ambiguous?'Existen dos o más candidatos CSS prácticamente indistinguibles por nombre y monto.':`Match conservador por ${m.method}; revisar discrepancia de nombre/apellido.`;}identityRows.push([st,s.code,s.name,p.identity,p.name,s.totalSS,p.expectedWorkerSS,m.method,m.confidence,obs]);amountRows.push([amtOk?'OK':'REVISAR',s.code,s.name,p.name,round(s.q1SS),round(s.q2SS),round(s.extraSS),round(s.vacSS),round(s.liqSS),s.totalSS,p.expectedWorkerSS,dss,s.totalSE,p.expectedWorkerSE,dse]);}\nfor(const s of onlySource){identityRows.push(['SOLO FUENTES',s.code,s.name,'','',s.totalSS,'','','','Sin contraparte CSS con evidencia suficiente']);amountRows.push(['SOLO FUENTES',s.code,s.name,'',round(s.q1SS),round(s.q2SS),round(s.extraSS),round(s.vacSS),round(s.liqSS),s.totalSS,'','',s.totalSE,'','']);}\nfor(const p of onlyPlan){identityRows.push(['SOLO PLANILLA CSS','','',p.identity,p.name,'',p.expectedWorkerSS,'','','Sin contraparte en fuentes con evidencia suficiente']);amountRows.push(['SOLO PLANILLA CSS','','',p.name,'','','','','','',p.expectedWorkerSS,'','',p.expectedWorkerSE,'']);}\nconst rank=x=>String(x).includes('SOLO')?0:String(x).includes('REVISAR')?1:2;identityRows.splice(4,identityRows.length-4,...identityRows.slice(4).sort((a,b)=>rank(a[0])-rank(b[0])||String(a[2]||a[4]).localeCompare(String(b[2]||b[4]))));amountRows.splice(4,amountRows.length-4,...amountRows.slice(4).sort((a,b)=>rank(a[0])-rank(b[0])||Math.abs(Number(b[11])||0)-Math.abs(Number(a[11])||0)));\nconst detailRows=[[title],[period],[],['Estado','Tipo','Archivo','Código','Nombre','S.S.','S.E.','Bruto/Base','Base regular','Base DTM','Fecha/Período','Transacción','Motivo']];for(const r of q1.records||[])detailRows.push(['INCLUIDO','Nómina Q1',q1.fileName,r.code,r.name,r.ss,r.se,r.gross||'',r.gross||'',0,q1.period?`${q1.period.start} - ${q1.period.end}`:'','','']);for(const r of q2.records||[])detailRows.push(['INCLUIDO','Nómina Q2',q2.fileName,r.code,r.name,r.ss,r.se,r.gross||'',r.gross||'',0,q2.period?`${q2.period.start} - ${q2.period.end}`:'','','']);for(const r of pdfs.records||[])detailRows.push([r.included?'INCLUIDO':'EXCLUIDO',r.category,r.fileName,r.code,r.name,r.ss,r.se,r.gross||'',r.regularBase||'',r.dtmBase||'',r.date||'',r.transaction||'',r.reason||'']);\nconst cssRows=[[title],[period],[],['Seguro Social #','Documento','Tipo doc.','Nombre CSS','Sexo','Salario','Salario ajustado','DTM base','S.S. trabajador esperado','S.E. trabajador esperado','S.S. total CSS','S.E. total CSS','DTM total CSS']];for(const p of plans)cssRows.push([p.ssNumber,p.identity,p.docType,p.name,p.sex,p.salary,p.salaryAdjusted,p.dtmBase,p.expectedWorkerSS,p.expectedWorkerSE,p.planillaSS,p.planillaSE,p.planillaDTM]);\nconst valRows=[[title],[period],[],['Estado','Categoría','Archivo','Detalle']];valRows.push(['OK','Nómina Q1',q1.fileName,`${q1.employeeCount} empleado(s); S.S. ${money(q1.totalSS)}; S.E. ${money(q1.totalSE)}; formato ${q1.format}; ${q1.integrityStatus}; códigos ausentes ${q1.missingCodeCount||0}`]);valRows.push(['OK','Nómina Q2',q2.fileName,`${q2.employeeCount} empleado(s); S.S. ${money(q2.totalSS)}; S.E. ${money(q2.totalSE)}; formato ${q2.format}; ${q2.integrityStatus}; códigos ausentes ${q2.missingCodeCount||0}`]);for(const v of periodChecks)valRows.push([v.status,'Período','Nóminas',v.detail]);valRows.push(['OK','Planilla CSS',css.fileName,`${css.employeeCount} empleado(s); período ${period}; integridad: ${css.integrity.map(x=>`${x.metric} dif ${money(x.diff)}`).join(' · ')}`]);valRows.push([rateInfo.usedFallbackDTM?'REVISAR':'OK','Tasas','Motor de cálculo',`S.S. regular ${(ssRegular*100).toFixed(2)}% (${rateInfo.samplesSS} muestras) · S.E. ${(seRate*100).toFixed(2)}% (${rateInfo.samplesSE}) · S.S. DTM ${(ssDtm*100).toFixed(2)}% (${rateInfo.samplesDTM})`]);for(const v of pdfs.validations||[])valRows.push([v.status,v.category,v.fileName,v.detail]);\nconst topFindings=[];for(const s of onlySource.slice(0,4))topFindings.push(`Solo fuentes: ${s.name}${s.code?` (#${s.code})`:''} · S.S. ${money(s.totalSS)}`);for(const p of onlyPlan.slice(0,4))topFindings.push(`Solo CSS: ${p.name} · ${p.identity||'sin documento'} · S.S. esperado ${money(p.expectedWorkerSS)}`);for(const m of identityReview.slice(0,4))topFindings.push(`Identidad: ${m.source.name} ↔ ${m.plan.name} · ${m.method}`);for(const m of amountDiffs.slice(0,4))topFindings.push(`Monto: ${m.source.name} · Dif. S.S. ${money(round(m.source.totalSS-m.plan.expectedWorkerSS))} · Dif. S.E. ${money(round(m.source.totalSE-m.plan.expectedWorkerSE))}`);\nconst summary={statusText,statusDetail,hasFindings,periodoLabel:period,sourceCount:sources.length,cssCount:plans.length,matches:matches.length,identityReview:identityReview.length,onlySource:onlySource.length,onlyPlan:onlyPlan.length,amountDifferences:amountDiffs.length,fileReview,totalSourceSS,totalExpectedSS,diffSS,totalSourceSE,totalExpectedSE,diffSE,rateInfo,topFindings,files:{q1:q1.fileName,q2:q2.fileName,css:css.fileName,pdfs:pdfs.files||[]},stagingQ1:q1.stagingSpreadsheetId||'',stagingQ2:q2.stagingSpreadsheetId||'',stagingCSS:css.stagingSpreadsheetId||''};\nconst createSheetBody=JSON.stringify({properties:{title},sheets:[{properties:{title:'Resumen'}},{properties:{title:'Cruce Identidad'}},{properties:{title:'Cruce Montos CSS'}},{properties:{title:'Detalle Fuentes'}},{properties:{title:'Detalle Planilla CSS'}},{properties:{title:'Validaciones'}}]});\nconst writeReportBody=JSON.stringify({valueInputOption:'RAW',data:[{range:'Resumen!A1',majorDimension:'ROWS',values:summaryRows},{range:'Cruce Identidad!A1',majorDimension:'ROWS',values:identityRows},{range:'Cruce Montos CSS!A1',majorDimension:'ROWS',values:amountRows},{range:'Detalle Fuentes!A1',majorDimension:'ROWS',values:detailRows},{range:'Detalle Planilla CSS!A1',majorDimension:'ROWS',values:cssRows},{range:'Validaciones!A1',majorDimension:'ROWS',values:valRows}]});\nreturn [{json:{reportTitle:title,periodoLabel:period,summary,summaryRows,identityRows,amountRows,detailRows,cssRows,valRows,createSheetBody,writeReportBody}}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
62144,
|
|
34832
|
|
],
|
|
"id": "6e10d4ed-e0ff-4f3c-bbd4-503735047765",
|
|
"name": "Construir reporte producción"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "POST",
|
|
"url": "https://sheets.googleapis.com/v4/spreadsheets",
|
|
"authentication": "predefinedCredentialType",
|
|
"nodeCredentialType": "googleSheetsOAuth2Api",
|
|
"sendBody": true,
|
|
"specifyBody": "json",
|
|
"jsonBody": "={{ $json.createSheetBody }}",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.3,
|
|
"position": [
|
|
62384,
|
|
34832
|
|
],
|
|
"id": "bac4af9b-bb77-4456-9e17-be8fa3af8587",
|
|
"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": "={{ $('Construir reporte producción').first().json.writeReportBody }}",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.3,
|
|
"position": [
|
|
62624,
|
|
34832
|
|
],
|
|
"id": "acc027fa-0b8e-42ae-81d3-e8fb393bf755",
|
|
"name": "Escribir reporte",
|
|
"credentials": {
|
|
"googleSheetsOAuth2Api": {
|
|
"id": "K0hDZh3a85MpOHCs",
|
|
"name": "Google Sheets account 2"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const created=$items('Crear Google Sheet Reporte')[0].json; const report=$items('Construir reporte producción')[0].json;\nconst rowsByName={'Resumen':report.summaryRows,'Cruce Identidad':report.identityRows,'Cruce Montos CSS':report.amountRows,'Detalle Fuentes':report.detailRows,'Detalle Planilla CSS':report.cssRows,'Validaciones':report.valRows};\nconst requests=[];\nfor(const s of created.sheets||[]){\n const name=s.properties.title,id=s.properties.sheetId,rows=rowsByName[name]||[]; const cols=Math.max(1,...rows.map(r=>r.length)); const rowCount=Math.max(rows.length,5);\n requests.push({updateSheetProperties:{properties:{sheetId:id,gridProperties:{hideGridlines:true,frozenRowCount:4}},fields:'gridProperties.hideGridlines,gridProperties.frozenRowCount'}});\n requests.push({repeatCell:{range:{sheetId:id,startRowIndex:0,endRowIndex:rowCount,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{textFormat:{fontFamily:'Arial',fontSize:10,foregroundColor:{red:0.290,green:0.290,blue:0.290}},verticalAlignment:'MIDDLE'}},fields:'userEnteredFormat(textFormat,verticalAlignment)'}});\n requests.push({mergeCells:{range:{sheetId:id,startRowIndex:0,endRowIndex:1,startColumnIndex:0,endColumnIndex:cols},mergeType:'MERGE_ALL'}});\n requests.push({mergeCells:{range:{sheetId:id,startRowIndex:1,endRowIndex:2,startColumnIndex:0,endColumnIndex:cols},mergeType:'MERGE_ALL'}});\n requests.push({repeatCell:{range:{sheetId:id,startRowIndex:0,endRowIndex:1,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{backgroundColor:{red:0.310,green:0.459,blue:0.545},textFormat:{fontFamily:'Arial',fontSize:13,bold:true,foregroundColor:{red:1,green:1,blue:1}},horizontalAlignment:'LEFT',borders:{bottom:{style:'SOLID_THICK',color:{red:0.424,green:0.761,blue:0.290}}}}},fields:'userEnteredFormat'}});\n requests.push({repeatCell:{range:{sheetId:id,startRowIndex:1,endRowIndex:2,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{backgroundColor:{red:0.357,green:0.498,blue:0.584},textFormat:{fontFamily:'Arial',fontSize:10,italic:true,foregroundColor:{red:1,green:1,blue:1}}}},fields:'userEnteredFormat'}});\n requests.push({repeatCell:{range:{sheetId:id,startRowIndex:3,endRowIndex:4,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{backgroundColor:{red:0.310,green:0.459,blue:0.545},textFormat:{fontFamily:'Arial',fontSize:10,bold:true,foregroundColor:{red:1,green:1,blue:1}},horizontalAlignment:'CENTER',wrapStrategy:'WRAP'}},fields:'userEnteredFormat'}});\n requests.push({autoResizeDimensions:{dimensions:{sheetId:id,dimension:'COLUMNS',startIndex:0,endIndex:cols}}});\n requests.push({updateDimensionProperties:{range:{sheetId:id,dimension:'ROWS',startIndex:0,endIndex:1},properties:{pixelSize:34},fields:'pixelSize'}});\n if(rowCount>4){\n const rules=[\n ['REVISAR',{red:0.992,green:0.925,blue:0.918},{red:0.65,green:0.15,blue:0.12}],\n ['SOLO',{red:1.0,green:0.973,blue:0.882},{red:0.55,green:0.35,blue:0.0}],\n ['ERROR',{red:0.992,green:0.925,blue:0.918},{red:0.65,green:0.15,blue:0.12}],\n ['EXCLUIDO',{red:1.0,green:0.973,blue:0.882},{red:0.55,green:0.35,blue:0.0}]\n ];\n for(const [txt,bg,fg] of rules)requests.push({addConditionalFormatRule:{rule:{ranges:[{sheetId:id,startRowIndex:4,endRowIndex:rowCount,startColumnIndex:0,endColumnIndex:1}],booleanRule:{condition:{type:'TEXT_CONTAINS',values:[{userEnteredValue:txt}]},format:{backgroundColor:bg,textFormat:{bold:true,foregroundColor:fg}}}},index:0}});\n}\n}\nreturn [{json:{formatBody:{requests}}}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
62864,
|
|
34832
|
|
],
|
|
"id": "023c71e6-974a-4ac4-9c19-cdeb97719dd6",
|
|
"name": "Preparar formato reporte"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "POST",
|
|
"url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $('Crear Google Sheet Reporte').item.json.spreadsheetId }}:batchUpdate",
|
|
"authentication": "predefinedCredentialType",
|
|
"nodeCredentialType": "googleSheetsOAuth2Api",
|
|
"sendBody": true,
|
|
"specifyBody": "json",
|
|
"jsonBody": "={{ JSON.stringify($json.formatBody) }}",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.3,
|
|
"position": [
|
|
63104,
|
|
34832
|
|
],
|
|
"id": "176ef0a3-091f-493a-ac08-53addee2e6b1",
|
|
"name": "Formatear reporte",
|
|
"credentials": {
|
|
"googleSheetsOAuth2Api": {
|
|
"id": "K0hDZh3a85MpOHCs",
|
|
"name": "Google Sheets account 2"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const init=$items('Inicializar ejecución')[0].json; const id=$items('Crear Google Sheet Reporte')[0].json.spreadsheetId; const url=`https://docs.google.com/spreadsheets/d/${id}/edit`;\nreturn init.recipients.map(emailAddress=>({json:{emailAddress,spreadsheetId:id,spreadsheetUrl:url}}));"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
63344,
|
|
34832
|
|
],
|
|
"id": "f12bf3d7-f5c9-4b12-93bc-a0da50c9c629",
|
|
"name": "Preparar permisos"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "POST",
|
|
"url": "=https://www.googleapis.com/drive/v3/files/{{ $json.spreadsheetId }}/permissions?sendNotificationEmail=false&supportsAllDrives=true",
|
|
"authentication": "predefinedCredentialType",
|
|
"nodeCredentialType": "googleDriveOAuth2Api",
|
|
"sendBody": true,
|
|
"specifyBody": "json",
|
|
"jsonBody": "={{ JSON.stringify({ role: 'writer', type: 'user', emailAddress: $json.emailAddress }) }}",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.3,
|
|
"position": [
|
|
63584,
|
|
34832
|
|
],
|
|
"id": "63765f02-d4c8-4911-bf9d-7f768ed382ff",
|
|
"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 reporte producción')[0].json,s=r.summary,id=$items('Crear Google Sheet Reporte')[0].json.spreadsheetId,url=`https://docs.google.com/spreadsheets/d/${id}/edit`;\nconst nf=n=>Number(n||0).toLocaleString('en-US'),money=n=>`B/. ${Number(n||0).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}`,esc=x=>String(x??'').replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));\nconst has=s.hasFindings,bg=has?'#FFF8E1':'#E8F5E9',state=has?'#8A5A00':'#27632A',findings=(s.topFindings||[]).length?`<div style=\"margin-top:18px;border-top:1px solid #D0D0D0;padding-top:14px;\"><div style=\"font-size:12px;font-weight:700;color:#4F758B;margin-bottom:7px;\">Principales hallazgos</div>${s.topFindings.map(x=>`<div style=\"font-size:12px;color:#4A4A4A;line-height:1.6;\">• ${esc(x)}</div>`).join('')}</div>`:'';\nconst html=`<!doctype html><html><body style=\"margin:0;background:#F5F5F5;font-family:Arial,Helvetica,sans-serif;color:#4A4A4A;\"><table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\"><tr><td align=\"center\" style=\"padding:28px 12px;\"><table role=\"presentation\" width=\"680\" cellspacing=\"0\" cellpadding=\"0\" style=\"max-width:680px;width:100%;background:#FFFFFF;border:1px solid #D0D0D0;\"><tr><td style=\"height:6px;background:#6CC24A;font-size:0;line-height:0;\"> </td></tr><tr><td style=\"padding:26px 34px 10px;\"><img src=\"${init.logoUrl}\" alt=\"GomezLee Marketing\" width=\"280\" style=\"display:block;max-width:100%;height:auto;border:0;\"></td></tr><tr><td style=\"padding:8px 34px 0;\"><div style=\"font-size:11px;font-weight:700;letter-spacing:1.4px;color:#6CC24A;\">SEGURIDAD SOCIAL · PANAMÁ</div><h1 style=\"margin:8px 0 6px;font-size:26px;line-height:1.2;color:#4F758B;\">Cruce mensual de CSS</h1><p style=\"margin:0;font-size:15px;color:#6B8FA3;\">${esc(r.periodoLabel)}</p></td></tr><tr><td style=\"padding:22px 34px 0;\"><div style=\"background:${bg};border-left:5px solid #6CC24A;padding:14px 16px;color:${state};font-size:14px;font-weight:700;\">${esc(s.statusText)}<div style=\"font-size:12px;font-weight:400;margin-top:5px;color:#4A4A4A;\">${esc(s.statusDetail)}</div></div></td></tr><tr><td style=\"padding:22px 34px 0;\"><table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" style=\"border-collapse:collapse;font-size:13px;\"><tr><td style=\"background:#4F758B;color:#FFF;font-weight:700;padding:10px;border:1px solid #D0D0D0;\">Métrica</td><td style=\"background:#4F758B;color:#FFF;font-weight:700;padding:10px;border:1px solid #D0D0D0;text-align:right;\">Resultado</td></tr><tr><td style=\"padding:9px 10px;border:1px solid #D0D0D0;\">Empleados fuentes / CSS</td><td style=\"padding:9px 10px;border:1px solid #D0D0D0;text-align:right;font-weight:700;color:#4F758B;\">${nf(s.sourceCount)} / ${nf(s.cssCount)}</td></tr><tr><td style=\"padding:9px 10px;border:1px solid #D0D0D0;background:#F5F5F5;\">Solo fuentes / solo CSS</td><td style=\"padding:9px 10px;border:1px solid #D0D0D0;background:#F5F5F5;text-align:right;font-weight:700;color:#4F758B;\">${nf(s.onlySource)} / ${nf(s.onlyPlan)}</td></tr><tr><td style=\"padding:9px 10px;border:1px solid #D0D0D0;\">Identidades a revisar</td><td style=\"padding:9px 10px;border:1px solid #D0D0D0;text-align:right;font-weight:700;color:#4F758B;\">${nf(s.identityReview)}</td></tr><tr><td style=\"padding:9px 10px;border:1px solid #D0D0D0;background:#F5F5F5;\">Diferencias de monto</td><td style=\"padding:9px 10px;border:1px solid #D0D0D0;background:#F5F5F5;text-align:right;font-weight:700;color:#4F758B;\">${nf(s.amountDifferences)}</td></tr><tr><td style=\"padding:9px 10px;border:1px solid #D0D0D0;\">Diferencia total S.S.</td><td style=\"padding:9px 10px;border:1px solid #D0D0D0;text-align:right;font-weight:700;color:#4F758B;\">${money(s.diffSS)}</td></tr><tr><td style=\"padding:9px 10px;border:1px solid #D0D0D0;background:#F5F5F5;\">Diferencia total S.E.</td><td style=\"padding:9px 10px;border:1px solid #D0D0D0;background:#F5F5F5;text-align:right;font-weight:700;color:#4F758B;\">${money(s.diffSE)}</td></tr></table>${findings}</td></tr><tr><td style=\"padding:26px 34px;\"><a href=\"${url}\" style=\"display:inline-block;background:#6CC24A;color:#FFFFFF;text-decoration:none;padding:13px 22px;font-size:14px;font-weight:700;\">Abrir reporte en Google Sheets</a><div style=\"margin-top:20px;font-size:11px;line-height:1.6;color:#6B8FA3;\">Tasas detectadas: S.S. regular ${(s.rateInfo.ssRegular*100).toFixed(2)}% · S.E. ${(s.rateInfo.se*100).toFixed(2)}% · S.S. DTM ${(s.rateInfo.ssDtm*100).toFixed(2)}%<br>Archivos base:<br>Nómina Q1: ${esc(s.files.q1||'')}<br>Nómina Q2: ${esc(s.files.q2||'')}<br>Planilla CSS: ${esc(s.files.css||'')}</div></td></tr><tr><td style=\"border-top:1px solid #D0D0D0;padding:14px 34px;font-size:10px;color:#6B8FA3;\">GOMEZLEE MARKETING · Uso interno</td></tr></table></td></tr></table></body></html>`;\nreturn [{json:{sendTo:init.recipients.join(','),subject:`${has?'[REVISAR]':'[OK]'} Cruce Seguridad Social Panamá - ${r.periodoLabel}`,html,spreadsheetId:id,spreadsheetUrl:url}}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
63824,
|
|
34832
|
|
],
|
|
"id": "40a9e530-9b60-4450-bfa3-0d7d5e2e95d3",
|
|
"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": [
|
|
64064,
|
|
34832
|
|
],
|
|
"id": "33c125b7-9b00-4e68-b197-2cfcd8651f55",
|
|
"name": "Enviar correo resumen",
|
|
"webhookId": "ae5f87e7-df2b-4349-8eb5-aac208accb96",
|
|
"executeOnce": true,
|
|
"credentials": {
|
|
"gmailOAuth2": {
|
|
"id": "UDcO1FLJqA453V2D",
|
|
"name": "Gmail account 3"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const init=$items('Inicializar ejecución')[0].json,r=$items('Construir reporte producción')[0].json,s=r.summary,id=$items('Crear Google Sheet Reporte')[0].json.spreadsheetId,reportUrl=`https://docs.google.com/spreadsheets/d/${id}/edit`,formUrl=init.formUrl;\nconst html=`<div style=\"font-family:Arial,Helvetica,sans-serif;max-width:680px;margin:48px auto;padding:36px 30px;text-align:center;background:#ffffff;border:1px solid #D0D0D0;color:#4A4A4A;\"><div style=\"height:6px;background:#6CC24A;margin:-36px -30px 28px;\"></div><img src=\"https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png\" alt=\"GomezLee Marketing\" width=\"250\" style=\"max-width:90%;height:auto;margin:0 auto 18px;\"><div style=\"font-size:12px;font-weight:700;letter-spacing:1.4px;color:#6CC24A;margin-bottom:8px;\">SEGURIDAD SOCIAL · PANAMÁ</div><h1 style=\"margin:0 0 8px;font-size:28px;color:#4F758B;\">Cruce mensual completado</h1><p style=\"margin:0 0 10px;font-size:16px;color:#6B8FA3;\">${r.periodoLabel}</p><p style=\"margin:0 0 10px;font-size:14px;line-height:1.6;color:#4A4A4A;\"><strong>${s.statusText}</strong><br>${s.statusDetail}</p><p style=\"margin:0 0 26px;font-size:12px;color:#6B8FA3;\">Fuentes / CSS: ${s.sourceCount} / ${s.cssCount} · Solo fuentes / CSS: ${s.onlySource} / ${s.onlyPlan} · Identidades a revisar: ${s.identityReview} · Diferencias de monto: ${s.amountDifferences}</p><a href=\"${reportUrl}\" target=\"_blank\" rel=\"noopener\" style=\"display:inline-block;background:#4F758B;color:#ffffff;text-decoration:none;padding:13px 22px;font-size:14px;font-weight:700;margin:0 6px 10px;\">Abrir reporte</a><a href=\"${formUrl}\" target=\"_top\" rel=\"noopener\" style=\"display:inline-block;background:#6CC24A;color:#ffffff;text-decoration:none;padding:13px 22px;font-size:14px;font-weight:700;margin:0 6px 10px;\">Realizar otro cruce</a><p style=\"margin:16px 0 0;font-size:11px;color:#6B8FA3;\">Si el botón no responde, usa este enlace: <a href=\"${formUrl}\" target=\"_top\" style=\"color:#4F758B;\">volver al formulario</a>.</p></div>`;\nreturn [{json:{html}}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
64784,
|
|
34832
|
|
],
|
|
"id": "034abc56-cf5b-4a09-a6c5-c110c46683eb",
|
|
"name": "Preparar confirmación"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "completion",
|
|
"respondWith": "showText",
|
|
"responseText": "={{ $json.html }}"
|
|
},
|
|
"type": "n8n-nodes-base.form",
|
|
"typeVersion": 2.5,
|
|
"position": [
|
|
65024,
|
|
34832
|
|
],
|
|
"id": "b949ed7f-046d-4d13-beaf-d064a8b6e5bf",
|
|
"name": "Confirmación y regreso al formulario",
|
|
"webhookId": "d4a102a8-3d72-4c5b-83b2-0e0d04c71099",
|
|
"notesInFlow": true,
|
|
"notes": "Página final con reporte y botón que vuelve exactamente al mismo Form Trigger de Panamá."
|
|
},
|
|
{
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": "",
|
|
"typeValidation": "strict",
|
|
"version": 2
|
|
},
|
|
"conditions": [
|
|
{
|
|
"id": "fd4e3c0d-3ba7-4267-b86c-3b8771da3d64",
|
|
"leftValue": "={{ $json.format }}",
|
|
"rightValue": "PDF",
|
|
"operator": {
|
|
"type": "string",
|
|
"operation": "equals"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2.3,
|
|
"position": [
|
|
54464,
|
|
34912
|
|
],
|
|
"id": "1c33ef12-3450-4ad1-b9f9-f742d02bd5c6",
|
|
"name": "¿Q1 es PDF?"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "pdf",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
54704,
|
|
35056
|
|
],
|
|
"id": "c291f41f-cb07-4314-9e34-cb20c98f63e5",
|
|
"name": "Extraer texto Q1 PDF"
|
|
},
|
|
{
|
|
"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;j<cur.length;j++)prev[j]=cur[j];}return 1-prev[b.length]/Math.max(a.length,b.length);}\nfunction subseq(short,long){short=norm(short).replace(/[^A-Z]/g,'');long=norm(long).replace(/[^A-Z]/g,'');if(!short||!long)return false;let j=0;for(const ch of long)if(ch===short[j])j++;return j===short.length;}\nfunction alphaCodeScore(code,name){\n const parts=norm(name).split(',');\n const tokens=parts.join(' ').split(/\\s+/).filter(Boolean);\n let best=0;\n for(const t of tokens){\n if(t===code)best=Math.max(best,1);\n if(t.startsWith(code))best=Math.max(best,.99);\n if(t.endsWith(code))best=Math.max(best,.84);\n if(t.includes(code))best=Math.max(best,.90);\n if(subseq(code,t))best=Math.max(best,.97);\n best=Math.max(best,.82*sim(code,t.slice(0,Math.max(code.length,1))));\n }\n const initials=tokens.map(t=>t[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;i<Math.min(vals.length-1,10);i++){\n const se0=Number(vals[i]),ss0=Number(vals[i+1]);\n for(let j=i+2;j<=Math.min(vals.length-1,i+5);j++){\n const g=Number(vals[j]);if(!(g>20)||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<best.score)best={score,se:se0,ss:ss0,gross:g,j};\n }\n }\n if(best&&best.score<.75){se=round(best.se);ss=round(best.ss);gross=round(best.gross);otherIncome=best.j+1<vals.length?round(vals[best.j+1]):0;}\n }\n if(!plausible(gross,ss,se))continue;\n flat.push({code,name,ss,se,gross,otherIncome,totalDeductions:round(vals[1]),net:round(vals[0]),source:'Nómina Q1',fileName:prep.fileName,period});\n}\n\n// MODO 3: extractor que entrega cada importe en líneas verticales separadas.\nconst lines=text.split('\\n').map(clean),vertical=[];\nfor(let i=0;i<lines.length;i++){\n const name=lines[i],u=norm(name);if(!name.includes(',')||!/[A-ZÁÉÍÓÚÑ]/i.test(name))continue;\n if(/GOMEZLEE|DEPARTAMENTO|GENERADA|TOTALES|NOMBRE DEL EMPLEADO|PLANILLA|PERIODO/.test(u))continue;\n const nums=[];let j=i-1;\n while(j>=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||score<best.score)best={score,net,deducciones,se,ss,otrosIngresos,gross};\n }\n if(!best)continue;let code='';\n for(let z=i+1;z<Math.min(lines.length,i+4);z++){if(!lines[z])continue;if(/^[A-Za-z0-9]{1,8}$/.test(lines[z]))code=lines[z];break;}\n vertical.push({code:code.replace(/^0+(?=\\d)/,''),name,ss:round(best.ss),se:round(best.se),gross:round(best.gross),otherIncome:round(best.otrosIngresos),totalDeductions:round(best.deducciones),net:round(best.net),source:'Nómina Q1',fileName:prep.fileName,period});\n}\n\nconst candidates=[\n {mode:'TABULAR_23_CAMPOS',rows:dedupe(horizontal)},\n {mode:'PDFJS_INVERTIDO_CONCATENADO',rows:dedupe(flat)},\n {mode:'VERTICAL_VALIDADO',rows:dedupe(vertical)}\n];\nlet chosen=null;\nif(printedCount>0)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// El PDF de nómina puede imprimir fragmentos del apellido en la columna Código.\n// Solo un Employee # estrictamente numérico se usa como llave; los demás quedan vacíos\n// para que el motor una por nombre exacto y no cree identidades falsas.\nfor(const r of out){\n const c=clean(r.code);\n r.code=/^\\d{1,8}$/.test(c)?c.replace(/^0+(?=\\d)/,''):'';\n}\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<minCoverage||ssRatios.length<minCoverage||seRatios.length<minCoverage)throw new Error(`Nómina Q1 PDF: se detectaron ${out.length} empleados, pero solo ${grossPositive} registros conservaron una base salarial utilizable y ${ssRatios.length}/${seRatios.length} aportes S.S./S.E. Se detuvo el cruce porque la extracción PDF no preservó suficiente información monetaria.`);\nconst medSS=median(ssRatios),medSE=median(seRatios);\nif(!(medSS>.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": [
|
|
54960,
|
|
35024
|
|
],
|
|
"id": "e256e1d6-2774-462c-9d55-5a031485d6cb",
|
|
"name": "Parsear Q1 PDF"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const r=$input.first().json;if(!Array.isArray(r.records)||!r.records.length)throw new Error('Resultado Q1 vacío.');return [{json:r}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
56624,
|
|
34832
|
|
],
|
|
"id": "3d6d60e8-487a-41cc-b256-46d813aace5c",
|
|
"name": "Resultado Q1"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": "",
|
|
"typeValidation": "strict",
|
|
"version": 2
|
|
},
|
|
"conditions": [
|
|
{
|
|
"id": "5ceced14-980c-46ea-a1ee-3845c8620a90",
|
|
"leftValue": "={{ $json.format }}",
|
|
"rightValue": "PDF",
|
|
"operator": {
|
|
"type": "string",
|
|
"operation": "equals"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2.3,
|
|
"position": [
|
|
57104,
|
|
34912
|
|
],
|
|
"id": "b4e4e628-7ddb-475a-8983-892aac060cf7",
|
|
"name": "¿Q2 es PDF?"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "pdf",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
57344,
|
|
35056
|
|
],
|
|
"id": "3223cc16-c235-4286-ab4b-6caa1bc402b7",
|
|
"name": "Extraer texto Q2 PDF"
|
|
},
|
|
{
|
|
"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;j<cur.length;j++)prev[j]=cur[j];}return 1-prev[b.length]/Math.max(a.length,b.length);}\nfunction subseq(short,long){short=norm(short).replace(/[^A-Z]/g,'');long=norm(long).replace(/[^A-Z]/g,'');if(!short||!long)return false;let j=0;for(const ch of long)if(ch===short[j])j++;return j===short.length;}\nfunction alphaCodeScore(code,name){\n const parts=norm(name).split(',');\n const tokens=parts.join(' ').split(/\\s+/).filter(Boolean);\n let best=0;\n for(const t of tokens){\n if(t===code)best=Math.max(best,1);\n if(t.startsWith(code))best=Math.max(best,.99);\n if(t.endsWith(code))best=Math.max(best,.84);\n if(t.includes(code))best=Math.max(best,.90);\n if(subseq(code,t))best=Math.max(best,.97);\n best=Math.max(best,.82*sim(code,t.slice(0,Math.max(code.length,1))));\n }\n const initials=tokens.map(t=>t[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;i<Math.min(vals.length-1,10);i++){\n const se0=Number(vals[i]),ss0=Number(vals[i+1]);\n for(let j=i+2;j<=Math.min(vals.length-1,i+5);j++){\n const g=Number(vals[j]);if(!(g>20)||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<best.score)best={score,se:se0,ss:ss0,gross:g,j};\n }\n }\n if(best&&best.score<.75){se=round(best.se);ss=round(best.ss);gross=round(best.gross);otherIncome=best.j+1<vals.length?round(vals[best.j+1]):0;}\n }\n if(!plausible(gross,ss,se))continue;\n flat.push({code,name,ss,se,gross,otherIncome,totalDeductions:round(vals[1]),net:round(vals[0]),source:'Nómina Q2',fileName:prep.fileName,period});\n}\n\n// MODO 3: extractor que entrega cada importe en líneas verticales separadas.\nconst lines=text.split('\\n').map(clean),vertical=[];\nfor(let i=0;i<lines.length;i++){\n const name=lines[i],u=norm(name);if(!name.includes(',')||!/[A-ZÁÉÍÓÚÑ]/i.test(name))continue;\n if(/GOMEZLEE|DEPARTAMENTO|GENERADA|TOTALES|NOMBRE DEL EMPLEADO|PLANILLA|PERIODO/.test(u))continue;\n const nums=[];let j=i-1;\n while(j>=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||score<best.score)best={score,net,deducciones,se,ss,otrosIngresos,gross};\n }\n if(!best)continue;let code='';\n for(let z=i+1;z<Math.min(lines.length,i+4);z++){if(!lines[z])continue;if(/^[A-Za-z0-9]{1,8}$/.test(lines[z]))code=lines[z];break;}\n vertical.push({code:code.replace(/^0+(?=\\d)/,''),name,ss:round(best.ss),se:round(best.se),gross:round(best.gross),otherIncome:round(best.otrosIngresos),totalDeductions:round(best.deducciones),net:round(best.net),source:'Nómina Q2',fileName:prep.fileName,period});\n}\n\nconst candidates=[\n {mode:'TABULAR_23_CAMPOS',rows:dedupe(horizontal)},\n {mode:'PDFJS_INVERTIDO_CONCATENADO',rows:dedupe(flat)},\n {mode:'VERTICAL_VALIDADO',rows:dedupe(vertical)}\n];\nlet chosen=null;\nif(printedCount>0)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// El PDF de nómina puede imprimir fragmentos del apellido en la columna Código.\n// Solo un Employee # estrictamente numérico se usa como llave; los demás quedan vacíos\n// para que el motor una por nombre exacto y no cree identidades falsas.\nfor(const r of out){\n const c=clean(r.code);\n r.code=/^\\d{1,8}$/.test(c)?c.replace(/^0+(?=\\d)/,''):'';\n}\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<minCoverage||ssRatios.length<minCoverage||seRatios.length<minCoverage)throw new Error(`Nómina Q2 PDF: se detectaron ${out.length} empleados, pero solo ${grossPositive} registros conservaron una base salarial utilizable y ${ssRatios.length}/${seRatios.length} aportes S.S./S.E. Se detuvo el cruce porque la extracción PDF no preservó suficiente información monetaria.`);\nconst medSS=median(ssRatios),medSE=median(seRatios);\nif(!(medSS>.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": [
|
|
57584,
|
|
35056
|
|
],
|
|
"id": "dd41d8e5-d079-4422-a22c-ca2010fa31c2",
|
|
"name": "Parsear Q2 PDF"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const r=$input.first().json;if(!Array.isArray(r.records)||!r.records.length)throw new Error('Resultado Q2 vacío.');return [{json:r}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
58544,
|
|
34832
|
|
],
|
|
"id": "22ae7500-d025-4854-aeab-7b6827c4fb14",
|
|
"name": "Resultado Q2"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const q1=$items('Resultado Q1')[0].json;\nconst q2=$items('Resultado Q2')[0].json;\nconst css=$items('Parsear CSS')[0].json;\nconst tempFolder=$items('Crear carpeta temporal PDFs')[0]?.json?.id||'';\nconst out=[];\nfor(const [tipo,id] of [\n ['Q1',q1.stagingSpreadsheetId],\n ['Q2',q2.stagingSpreadsheetId],\n ['CSS',css.stagingSpreadsheetId],\n ['PDFS_TEMP',tempFolder]\n]) if(id) out.push({json:{tipo,fileId:id}});\nreturn out.length?out:[{json:{tipo:'NINGUNO',fileId:''}}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
64304,
|
|
34832
|
|
],
|
|
"id": "a358393b-3223-450e-a6db-4a146838f266",
|
|
"name": "Preparar limpieza temporales"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "DELETE",
|
|
"url": "=https://www.googleapis.com/drive/v3/files/{{ $json.fileId }}?supportsAllDrives=true",
|
|
"authentication": "predefinedCredentialType",
|
|
"nodeCredentialType": "googleDriveOAuth2Api",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.3,
|
|
"position": [
|
|
64544,
|
|
34832
|
|
],
|
|
"id": "c20af9b0-54e4-44c9-be47-5c277d575807",
|
|
"name": "Eliminar staging temporal",
|
|
"alwaysOutputData": true,
|
|
"notesInFlow": true,
|
|
"credentials": {
|
|
"googleDriveOAuth2Api": {
|
|
"id": "g23xdGLZRzBGqKgH",
|
|
"name": "Isaac - Google Drive"
|
|
}
|
|
},
|
|
"continueOnFail": true,
|
|
"notes": "Limpieza best-effort: elimina staging Q1/Q2/CSS y la carpeta temporal de PDFs. Si falla, no bloquea la pantalla final."
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "POST",
|
|
"url": "https://www.googleapis.com/drive/v3/files?fields=id,name,mimeType&supportsAllDrives=true",
|
|
"authentication": "predefinedCredentialType",
|
|
"nodeCredentialType": "googleDriveOAuth2Api",
|
|
"sendBody": true,
|
|
"specifyBody": "json",
|
|
"jsonBody": "={{ JSON.stringify({ name: `TEMP PA CSS PDFs - ${$('Inicializar ejecución').item.json.runId}`, mimeType: 'application/vnd.google-apps.folder' }) }}",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.3,
|
|
"position": [
|
|
54224,
|
|
35216
|
|
],
|
|
"id": "09fa4c3c-7a56-46ae-a0ef-2bd5030a1417",
|
|
"name": "Crear carpeta temporal PDFs",
|
|
"retryOnFail": true,
|
|
"maxTries": 3,
|
|
"waitBetweenTries": 2000,
|
|
"notesInFlow": true,
|
|
"credentials": {
|
|
"googleDriveOAuth2Api": {
|
|
"id": "g23xdGLZRzBGqKgH",
|
|
"name": "Isaac - Google Drive"
|
|
}
|
|
},
|
|
"notes": "Carpeta temporal aislada por ejecución para acumular PDFs cargados en varias tandas sin perder selecciones anteriores."
|
|
},
|
|
{
|
|
"parameters": {
|
|
"formFields": {
|
|
"values": [
|
|
{
|
|
"fieldLabel": "¿Hay extraordinarias este mes?",
|
|
"fieldType": "radio",
|
|
"fieldOptions": {
|
|
"values": [
|
|
{
|
|
"option": "Sí"
|
|
},
|
|
{
|
|
"option": "No"
|
|
}
|
|
]
|
|
},
|
|
"requiredField": true
|
|
}
|
|
]
|
|
},
|
|
"options": {
|
|
"formTitle": "Paso 2 de 4 · Extraordinarias",
|
|
"formDescription": "Indica si este mes existen planillas extraordinarias. Este es el único grupo opcional.",
|
|
"buttonLabel": "Continuar"
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.form",
|
|
"typeVersion": 2.5,
|
|
"position": [
|
|
54464,
|
|
35216
|
|
],
|
|
"id": "8f7476cf-6957-4714-9cd3-9f71feb1e687",
|
|
"name": "Definir Extraordinarias",
|
|
"webhookId": "2d4fba32-6a24-46e8-a9c7-a38ca80b106a"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": "",
|
|
"typeValidation": "strict",
|
|
"version": 2
|
|
},
|
|
"conditions": [
|
|
{
|
|
"id": "e7a0d1ad-362a-4267-b2fd-ba254621eeaa",
|
|
"leftValue": "={{ $json[\"¿Hay extraordinarias este mes?\"] }}",
|
|
"rightValue": "Sí",
|
|
"operator": {
|
|
"type": "string",
|
|
"operation": "equals"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2.3,
|
|
"position": [
|
|
54704,
|
|
35216
|
|
],
|
|
"id": "953ada8a-80d8-40ee-a130-c040a9376a09",
|
|
"name": "¿Hay extraordinarias?"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"formFields": {
|
|
"values": [
|
|
{
|
|
"fieldLabel": "Archivos extraordinarios",
|
|
"fieldType": "file",
|
|
"acceptFileTypes": ".pdf,application/pdf",
|
|
"requiredField": true
|
|
},
|
|
{
|
|
"fieldLabel": "¿Agregar más extraordinarias?",
|
|
"fieldType": "radio",
|
|
"fieldOptions": {
|
|
"values": [
|
|
{
|
|
"option": "Sí"
|
|
},
|
|
{
|
|
"option": "No"
|
|
}
|
|
]
|
|
},
|
|
"requiredField": true
|
|
}
|
|
]
|
|
},
|
|
"options": {
|
|
"formTitle": "Extraordinarias · cargar lote",
|
|
"formDescription": "Puedes seleccionar uno o varios PDFs a la vez. Si tienes más archivos en otra carpeta, selecciona “Sí” en “Agregar más” y volverás a esta misma pantalla sin perder los archivos ya guardados.",
|
|
"buttonLabel": "Guardar lote"
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.form",
|
|
"typeVersion": 2.5,
|
|
"position": [
|
|
54960,
|
|
35024
|
|
],
|
|
"id": "4bf3e422-a9fe-4f98-9122-c3c4edff7f0f",
|
|
"name": "Cargar Extraordinarias",
|
|
"webhookId": "237dd241-cbba-4455-beec-b4b63e45827d"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const item=$input.first();\nconst bin=item.binary||{};\nconst files=Object.entries(bin);\nfunction clean(v){return String(v??'').trim();}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase();}\nfunction yes(v){return ['si','sí','yes','true','1'].includes(norm(v));}\nfunction safeName(v){return clean(v).replace(/[\\\\/\\x00-\\x1F]/g,'_').replace(/__+/g,'_').slice(-150)||'archivo.pdf';}\nif(!files.length) throw new Error('Debes adjuntar al menos un PDF en este lote de Extraordinarias.');\nconst more=yes(item.json?.['¿Agregar más extraordinarias?']);\nconst init=$items('Inicializar ejecución')[0].json;\nconst max=Number(init.config?.maxSupplementalFilesSafety||500);\nif(files.length>max) throw new Error(`Se recibieron ${files.length} archivos en un solo lote; supera el límite técnico de seguridad (${max}).`);\nconst now=Date.now();\nconst out=[];\nfor(let i=0;i<files.length;i++){\n const [key,file]=files[i];\n const original=safeName(file?.fileName||key);\n const mime=norm(file?.mimeType||'');\n if(!original.toLowerCase().endsWith('.pdf') && !mime.includes('application/pdf')){\n throw new Error(`El archivo “${original}” no es PDF. Extraordinarias solo acepta archivos PDF.`);\n }\n const nonce=`${now}_${i}_${Math.random().toString(36).slice(2,7)}`;\n const uploadName='EXTRA__MORE'+(more?'1':'0')+'__'+nonce+'__ORIG__'+original;\n out.push({json:{uploadName,originalName:original,category:'EXTRA',addMore:more?'SI':'NO'},binary:{data:file}});\n}\nreturn out;"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
55200,
|
|
35024
|
|
],
|
|
"id": "d7cbe170-a566-42bf-9653-d5282de9a8c7",
|
|
"name": "Preparar lote Extraordinarias",
|
|
"notesInFlow": true,
|
|
"notes": "Valida PDF y prepara cada archivo del lote para guardarlo temporalmente."
|
|
},
|
|
{
|
|
"parameters": {
|
|
"name": "={{ $json.uploadName }}",
|
|
"driveId": {
|
|
"__rl": true,
|
|
"value": "My Drive",
|
|
"mode": "list",
|
|
"cachedResultName": "My Drive"
|
|
},
|
|
"folderId": {
|
|
"__rl": true,
|
|
"value": "={{ $('Crear carpeta temporal PDFs').item.json.id }}",
|
|
"mode": "id"
|
|
},
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.googleDrive",
|
|
"typeVersion": 3,
|
|
"position": [
|
|
55440,
|
|
35024
|
|
],
|
|
"id": "88420c7c-55f7-46e5-8a22-fe6f5fb91182",
|
|
"name": "Guardar lote Extraordinarias",
|
|
"retryOnFail": true,
|
|
"maxTries": 3,
|
|
"waitBetweenTries": 2000,
|
|
"credentials": {
|
|
"googleDriveOAuth2Api": {
|
|
"id": "g23xdGLZRzBGqKgH",
|
|
"name": "Isaac - Google Drive"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const uploaded=$input.all();\nlet marker='';\nfor(const it of uploaded){\n const n=String(it.json?.name||it.json?.fileName||'');\n if(n.includes('__MORE1__')||n.includes('__MORE0__')){marker=n;break;}\n}\nif(!marker){\n const prep=$items('Preparar lote Extraordinarias');\n marker=String(prep?.[0]?.json?.uploadName||'');\n}\nif(!marker) throw new Error('No pude confirmar si el usuario desea agregar más archivos. Se detuvo para evitar perder una tanda.');\nreturn [{json:{addMore:marker.includes('__MORE1__')?'SI':'NO',uploaded:uploaded.length}}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
55680,
|
|
35024
|
|
],
|
|
"id": "11655d6b-b8c4-4931-bfe5-7cc6e21d65be",
|
|
"name": "Cerrar lote Extraordinarias"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": "",
|
|
"typeValidation": "strict",
|
|
"version": 3
|
|
},
|
|
"conditions": [
|
|
{
|
|
"id": "eb1cc075-acb9-4146-a2c5-2d7910f26e9c",
|
|
"leftValue": "={{ $json.addMore }}",
|
|
"rightValue": "SI",
|
|
"operator": {
|
|
"type": "string",
|
|
"operation": "equals"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2.3,
|
|
"position": [
|
|
55920,
|
|
35024
|
|
],
|
|
"id": "b7f00446-e4a5-4314-a0dc-f27916f57120",
|
|
"name": "¿Agregar más Extraordinarias?"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"formFields": {
|
|
"values": [
|
|
{
|
|
"fieldLabel": "Archivos de vacaciones",
|
|
"fieldType": "file",
|
|
"acceptFileTypes": ".pdf,application/pdf",
|
|
"requiredField": true
|
|
},
|
|
{
|
|
"fieldLabel": "¿Agregar más vacaciones?",
|
|
"fieldType": "radio",
|
|
"fieldOptions": {
|
|
"values": [
|
|
{
|
|
"option": "Sí"
|
|
},
|
|
{
|
|
"option": "No"
|
|
}
|
|
]
|
|
},
|
|
"requiredField": true
|
|
}
|
|
]
|
|
},
|
|
"options": {
|
|
"formTitle": "Paso 3 de 4 · Vacaciones",
|
|
"formDescription": "Debes cargar al menos un PDF. Puedes seleccionar varios de una vez o cargar una tanda, elegir “Sí” en “Agregar más” y continuar desde otra carpeta.",
|
|
"buttonLabel": "Guardar lote"
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.form",
|
|
"typeVersion": 2.5,
|
|
"position": [
|
|
56160,
|
|
35184
|
|
],
|
|
"id": "cacdbf95-a924-469f-8b6b-5ec5ed9043c4",
|
|
"name": "Cargar Vacaciones",
|
|
"webhookId": "4405d87c-973b-4828-8f0b-e75ec83473f3"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const item=$input.first();\nconst bin=item.binary||{};\nconst files=Object.entries(bin);\nfunction clean(v){return String(v??'').trim();}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase();}\nfunction yes(v){return ['si','sí','yes','true','1'].includes(norm(v));}\nfunction safeName(v){return clean(v).replace(/[\\\\/\\x00-\\x1F]/g,'_').replace(/__+/g,'_').slice(-150)||'archivo.pdf';}\nif(!files.length) throw new Error('Debes adjuntar al menos un PDF en este lote de Vacaciones.');\nconst more=yes(item.json?.['¿Agregar más vacaciones?']);\nconst init=$items('Inicializar ejecución')[0].json;\nconst max=Number(init.config?.maxSupplementalFilesSafety||500);\nif(files.length>max) throw new Error(`Se recibieron ${files.length} archivos en un solo lote; supera el límite técnico de seguridad (${max}).`);\nconst now=Date.now();\nconst out=[];\nfor(let i=0;i<files.length;i++){\n const [key,file]=files[i];\n const original=safeName(file?.fileName||key);\n const mime=norm(file?.mimeType||'');\n if(!original.toLowerCase().endsWith('.pdf') && !mime.includes('application/pdf')){\n throw new Error(`El archivo “${original}” no es PDF. Vacaciones solo acepta archivos PDF.`);\n }\n const nonce=`${now}_${i}_${Math.random().toString(36).slice(2,7)}`;\n const uploadName='VAC__MORE'+(more?'1':'0')+'__'+nonce+'__ORIG__'+original;\n out.push({json:{uploadName,originalName:original,category:'VAC',addMore:more?'SI':'NO'},binary:{data:file}});\n}\nreturn out;"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
56400,
|
|
35184
|
|
],
|
|
"id": "615b13f0-62b6-41d9-b73c-df8156f4fbd4",
|
|
"name": "Preparar lote Vacaciones"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"name": "={{ $json.uploadName }}",
|
|
"driveId": {
|
|
"__rl": true,
|
|
"value": "My Drive",
|
|
"mode": "list",
|
|
"cachedResultName": "My Drive"
|
|
},
|
|
"folderId": {
|
|
"__rl": true,
|
|
"value": "={{ $('Crear carpeta temporal PDFs').item.json.id }}",
|
|
"mode": "id"
|
|
},
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.googleDrive",
|
|
"typeVersion": 3,
|
|
"position": [
|
|
56640,
|
|
35184
|
|
],
|
|
"id": "8ef5b6f6-e0c0-46e6-9063-ac981022c548",
|
|
"name": "Guardar lote Vacaciones",
|
|
"retryOnFail": true,
|
|
"maxTries": 3,
|
|
"waitBetweenTries": 2000,
|
|
"credentials": {
|
|
"googleDriveOAuth2Api": {
|
|
"id": "g23xdGLZRzBGqKgH",
|
|
"name": "Isaac - Google Drive"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const uploaded=$input.all();\nlet marker='';\nfor(const it of uploaded){\n const n=String(it.json?.name||it.json?.fileName||'');\n if(n.includes('__MORE1__')||n.includes('__MORE0__')){marker=n;break;}\n}\nif(!marker){\n const prep=$items('Preparar lote Vacaciones');\n marker=String(prep?.[0]?.json?.uploadName||'');\n}\nif(!marker) throw new Error('No pude confirmar si el usuario desea agregar más archivos. Se detuvo para evitar perder una tanda.');\nreturn [{json:{addMore:marker.includes('__MORE1__')?'SI':'NO',uploaded:uploaded.length}}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
56864,
|
|
35216
|
|
],
|
|
"id": "cd731fba-163e-4102-867f-93fdd05161c9",
|
|
"name": "Cerrar lote Vacaciones"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": "",
|
|
"typeValidation": "strict",
|
|
"version": 2
|
|
},
|
|
"conditions": [
|
|
{
|
|
"id": "0e7eace4-4da9-46a9-bc0b-ca6b3f309241",
|
|
"leftValue": "={{ $json.addMore }}",
|
|
"rightValue": "SI",
|
|
"operator": {
|
|
"type": "string",
|
|
"operation": "equals"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2.3,
|
|
"position": [
|
|
57104,
|
|
35216
|
|
],
|
|
"id": "fb6e392f-5d4a-4474-baf9-bb4079018fdc",
|
|
"name": "¿Agregar más Vacaciones?"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"formFields": {
|
|
"values": [
|
|
{
|
|
"fieldLabel": "Archivos de liquidaciones",
|
|
"fieldType": "file",
|
|
"acceptFileTypes": ".pdf,application/pdf",
|
|
"requiredField": true
|
|
},
|
|
{
|
|
"fieldLabel": "¿Agregar más liquidaciones?",
|
|
"fieldType": "radio",
|
|
"fieldOptions": {
|
|
"values": [
|
|
{
|
|
"option": "Sí"
|
|
},
|
|
{
|
|
"option": "No"
|
|
}
|
|
]
|
|
},
|
|
"requiredField": true
|
|
}
|
|
]
|
|
},
|
|
"options": {
|
|
"formTitle": "Paso 4 de 4 · Liquidaciones",
|
|
"formDescription": "Debes cargar al menos un PDF. Puedes seleccionar varios de una vez o repetir esta misma etapa tantas veces como necesites. Al elegir “No”, comenzará automáticamente el cruce.",
|
|
"buttonLabel": "Guardar lote"
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.form",
|
|
"typeVersion": 2.5,
|
|
"position": [
|
|
57344,
|
|
35216
|
|
],
|
|
"id": "4a7d5715-a5fe-4a86-84d4-78ad63edb8ea",
|
|
"name": "Cargar Liquidaciones",
|
|
"webhookId": "f04eb408-97b4-4c5c-9c90-53e58021ea20"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const item=$input.first();\nconst bin=item.binary||{};\nconst files=Object.entries(bin);\nfunction clean(v){return String(v??'').trim();}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase();}\nfunction yes(v){return ['si','sí','yes','true','1'].includes(norm(v));}\nfunction safeName(v){return clean(v).replace(/[\\\\/\\x00-\\x1F]/g,'_').replace(/__+/g,'_').slice(-150)||'archivo.pdf';}\nif(!files.length) throw new Error('Debes adjuntar al menos un PDF en este lote de Liquidaciones.');\nconst more=yes(item.json?.['¿Agregar más liquidaciones?']);\nconst init=$items('Inicializar ejecución')[0].json;\nconst max=Number(init.config?.maxSupplementalFilesSafety||500);\nif(files.length>max) throw new Error(`Se recibieron ${files.length} archivos en un solo lote; supera el límite técnico de seguridad (${max}).`);\nconst now=Date.now();\nconst out=[];\nfor(let i=0;i<files.length;i++){\n const [key,file]=files[i];\n const original=safeName(file?.fileName||key);\n const mime=norm(file?.mimeType||'');\n if(!original.toLowerCase().endsWith('.pdf') && !mime.includes('application/pdf')){\n throw new Error(`El archivo “${original}” no es PDF. Liquidaciones solo acepta archivos PDF.`);\n }\n const nonce=`${now}_${i}_${Math.random().toString(36).slice(2,7)}`;\n const uploadName='LIQ__MORE'+(more?'1':'0')+'__'+nonce+'__ORIG__'+original;\n out.push({json:{uploadName,originalName:original,category:'LIQ',addMore:more?'SI':'NO'},binary:{data:file}});\n}\nreturn out;"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
57584,
|
|
35216
|
|
],
|
|
"id": "1aacdced-cc5a-459e-9314-cbe0c48535ec",
|
|
"name": "Preparar lote Liquidaciones"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"name": "={{ $json.uploadName }}",
|
|
"driveId": {
|
|
"__rl": true,
|
|
"value": "My Drive",
|
|
"mode": "list",
|
|
"cachedResultName": "My Drive"
|
|
},
|
|
"folderId": {
|
|
"__rl": true,
|
|
"value": "={{ $('Crear carpeta temporal PDFs').item.json.id }}",
|
|
"mode": "id"
|
|
},
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.googleDrive",
|
|
"typeVersion": 3,
|
|
"position": [
|
|
57824,
|
|
35216
|
|
],
|
|
"id": "a68ad67c-236c-4417-a527-f570dcf6803a",
|
|
"name": "Guardar lote Liquidaciones",
|
|
"retryOnFail": true,
|
|
"maxTries": 3,
|
|
"waitBetweenTries": 2000,
|
|
"credentials": {
|
|
"googleDriveOAuth2Api": {
|
|
"id": "g23xdGLZRzBGqKgH",
|
|
"name": "Isaac - Google Drive"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const uploaded=$input.all();\nlet marker='';\nfor(const it of uploaded){\n const n=String(it.json?.name||it.json?.fileName||'');\n if(n.includes('__MORE1__')||n.includes('__MORE0__')){marker=n;break;}\n}\nif(!marker){\n const prep=$items('Preparar lote Liquidaciones');\n marker=String(prep?.[0]?.json?.uploadName||'');\n}\nif(!marker) throw new Error('No pude confirmar si el usuario desea agregar más archivos. Se detuvo para evitar perder una tanda.');\nreturn [{json:{addMore:marker.includes('__MORE1__')?'SI':'NO',uploaded:uploaded.length}}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
58064,
|
|
35216
|
|
],
|
|
"id": "6ef08c3a-b33f-4cd7-af93-584be7575f7f",
|
|
"name": "Cerrar lote Liquidaciones"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": "",
|
|
"typeValidation": "strict",
|
|
"version": 2
|
|
},
|
|
"conditions": [
|
|
{
|
|
"id": "10fc1a8b-cc8f-49db-af45-98b566192aca",
|
|
"leftValue": "={{ $json.addMore }}",
|
|
"rightValue": "SI",
|
|
"operator": {
|
|
"type": "string",
|
|
"operation": "equals"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2.3,
|
|
"position": [
|
|
58304,
|
|
35216
|
|
],
|
|
"id": "435f9ae2-7ccb-479f-8078-6f895bd4726a",
|
|
"name": "¿Agregar más Liquidaciones?"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"url": "=https://www.googleapis.com/drive/v3/files?q={{ encodeURIComponent(\"'\" + $('Crear carpeta temporal PDFs').item.json.id + \"' in parents and trashed = false\") }}&pageSize=1000&fields=nextPageToken,files(id,name,mimeType,size,createdTime)&supportsAllDrives=true",
|
|
"authentication": "predefinedCredentialType",
|
|
"nodeCredentialType": "googleDriveOAuth2Api",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.3,
|
|
"position": [
|
|
61424,
|
|
35056
|
|
],
|
|
"id": "1b450788-edf6-472c-8497-338312040c0d",
|
|
"name": "Listar PDFs temporales",
|
|
"retryOnFail": true,
|
|
"maxTries": 3,
|
|
"waitBetweenTries": 2000,
|
|
"credentials": {
|
|
"googleDriveOAuth2Api": {
|
|
"id": "g23xdGLZRzBGqKgH",
|
|
"name": "Isaac - Google Drive"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const payload=$json.body&&typeof $json.body==='object'?$json.body:$json;\nconst files=Array.isArray(payload.files)?payload.files:[];\nconst config=$items('Inicializar ejecución')[0].json.config||{};\nconst max=Number(config.maxSupplementalFilesSafety||500);\nif(payload.nextPageToken) throw new Error('Hay más de 1000 archivos complementarios en la ejecución. Se detuvo por seguridad antes de procesar una lista incompleta.');\nif(files.length>max) throw new Error(`Se detectaron ${files.length} archivos complementarios; supera el límite técnico de seguridad (${max}).`);\nfunction meta(name){\n const n=String(name||'');\n let category='';\n if(n.startsWith('EXTRA__'))category='extraordinaria';\n else if(n.startsWith('VAC__'))category='vacaciones';\n else if(n.startsWith('LIQ__'))category='liquidaciones';\n const ix=n.indexOf('__ORIG__');\n const fileName=ix>=0?n.slice(ix+8):n;\n return {category,fileName};\n}\nconst docs=[];\nfor(const f of files){\n const m=meta(f.name);\n if(!m.category) throw new Error(`Encontré un archivo temporal no reconocido: ${f.name}. Se detuvo para evitar mezclar documentos.`);\n if(String(f.mimeType||'')!=='application/pdf' && !String(f.name||'').toLowerCase().endsWith('.pdf')) throw new Error(`El archivo temporal ${f.name} no parece ser PDF.`);\n docs.push({id:f.id,storageName:f.name,fileName:m.fileName,category:m.category,size:f.size||'',createdTime:f.createdTime||''});\n}\nconst vac=docs.filter(x=>x.category==='vacaciones').length;\nconst liq=docs.filter(x=>x.category==='liquidaciones').length;\nconst ext=docs.filter(x=>x.category==='extraordinaria').length;\nif(vac<1) throw new Error('No hay archivos de Vacaciones acumulados. Vacaciones requiere al menos un PDF.');\nif(liq<1) throw new Error('No hay archivos de Liquidaciones acumulados. Liquidaciones requiere al menos un PDF.');\ndocs.sort((a,b)=>a.category.localeCompare(b.category)||String(a.createdTime).localeCompare(String(b.createdTime))||a.storageName.localeCompare(b.storageName));\nreturn docs.map(d=>({json:{...d,uploadCounts:{extraordinarias:ext,vacaciones:vac,liquidaciones:liq}}}));"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
61664,
|
|
35056
|
|
],
|
|
"id": "b525df3e-0968-489a-80d7-436a9697f00a",
|
|
"name": "Preparar descarga PDFs",
|
|
"notesInFlow": true,
|
|
"notes": "Reconstruye las tres categorías desde la carpeta temporal y valida mínimos antes de descargar."
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "download",
|
|
"fileId": {
|
|
"__rl": true,
|
|
"value": "={{ $json.id }}",
|
|
"mode": "id"
|
|
},
|
|
"options": {
|
|
"binaryPropertyName": "data",
|
|
"fileName": "={{ $json.storageName }}"
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.googleDrive",
|
|
"typeVersion": 3,
|
|
"position": [
|
|
61904,
|
|
35056
|
|
],
|
|
"id": "b5e9a2fb-a85c-45fc-b819-431840e1e007",
|
|
"name": "Descargar PDFs temporales",
|
|
"retryOnFail": true,
|
|
"maxTries": 3,
|
|
"waitBetweenTries": 2000,
|
|
"credentials": {
|
|
"googleDriveOAuth2Api": {
|
|
"id": "g23xdGLZRzBGqKgH",
|
|
"name": "Isaac - Google Drive"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const items=$input.all();\nfunction parseStorage(n){\n const name=String(n||'');\n let category='';\n if(name.startsWith('EXTRA__'))category='extraordinaria';\n else if(name.startsWith('VAC__'))category='vacaciones';\n else if(name.startsWith('LIQ__'))category='liquidaciones';\n const ix=name.indexOf('__ORIG__');\n return {category,fileName:ix>=0?name.slice(ix+8):name,storageName:name};\n}\nconst out=[];\nfor(const item of items){\n const data=item.binary?.data;\n if(!data) throw new Error('Google Drive no devolvió el contenido binario de uno de los PDFs temporales.');\n const p=parseStorage(data.fileName||item.json?.storageName||item.json?.name||'');\n if(!p.category) throw new Error(`No pude determinar la categoría del archivo temporal ${p.storageName||'(sin nombre)'}.`);\n data.fileName=p.fileName;\n out.push({json:{category:p.category,fileName:p.fileName,storageName:p.storageName,fileId:item.json?.id||item.json?.fileId||''},binary:{data}});\n}\nreturn out;"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
62144,
|
|
35056
|
|
],
|
|
"id": "833ab32f-3fad-4271-8a6d-81923f4eda84",
|
|
"name": "Normalizar PDFs descargados"
|
|
}
|
|
],
|
|
"connections": {
|
|
"On form submission": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Inicializar ejecución",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Inicializar ejecución": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Crear carpeta temporal PDFs",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Preparar Q1": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "¿Q1 es PDF?",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"¿Q1 es PDF?": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Extraer texto Q1 PDF",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Crear staging Q1",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Extraer texto Q1 PDF": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Parsear Q1 PDF",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Parsear Q1 PDF": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Resultado Q1",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Crear staging Q1": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Reunir staging Q1",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Reunir staging Q1": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Parsear Q1 Excel",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Parsear Q1 Excel": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Resultado Q1",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Resultado Q1": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Preparar Q2",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Preparar Q2": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "¿Q2 es PDF?",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"¿Q2 es PDF?": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Extraer texto Q2 PDF",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Crear staging Q2",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Extraer texto Q2 PDF": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Parsear Q2 PDF",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Parsear Q2 PDF": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Resultado Q2",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Crear staging Q2": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Reunir staging Q2",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Reunir staging Q2": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Parsear Q2 Excel",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Parsear Q2 Excel": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Resultado Q2",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Resultado 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": "Parsear CSS",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Parsear CSS": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Listar PDFs temporales",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Extraer texto PDFs": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Parsear PDFs",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Parsear PDFs": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Construir reporte producción",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Construir reporte producción": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Crear Google Sheet Reporte",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Crear Google Sheet Reporte": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Escribir reporte",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Escribir reporte": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Preparar formato reporte",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Preparar formato reporte": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Formatear reporte",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Formatear reporte": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Preparar permisos",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Preparar permisos": {
|
|
"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 resumen",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Enviar correo resumen": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Preparar limpieza temporales",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Preparar limpieza temporales": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Eliminar staging temporal",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Eliminar staging temporal": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Preparar confirmación",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Preparar confirmación": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Confirmación y regreso al formulario",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Crear carpeta temporal PDFs": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Definir Extraordinarias",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Definir Extraordinarias": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "¿Hay extraordinarias?",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"¿Hay extraordinarias?": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Cargar Extraordinarias",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Cargar Vacaciones",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Cargar Extraordinarias": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Preparar lote Extraordinarias",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Preparar lote Extraordinarias": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Guardar lote Extraordinarias",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Guardar lote Extraordinarias": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Cerrar lote Extraordinarias",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Cerrar lote Extraordinarias": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "¿Agregar más Extraordinarias?",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"¿Agregar más Extraordinarias?": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Cargar Extraordinarias",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Cargar Vacaciones",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Cargar Vacaciones": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Preparar lote Vacaciones",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Preparar lote Vacaciones": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Guardar lote Vacaciones",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Guardar lote Vacaciones": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Cerrar lote Vacaciones",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Cerrar lote Vacaciones": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "¿Agregar más Vacaciones?",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"¿Agregar más Vacaciones?": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Cargar Vacaciones",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Cargar Liquidaciones",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Cargar Liquidaciones": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Preparar lote Liquidaciones",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Preparar lote Liquidaciones": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Guardar lote Liquidaciones",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Guardar lote Liquidaciones": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Cerrar lote Liquidaciones",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Cerrar lote Liquidaciones": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "¿Agregar más Liquidaciones?",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"¿Agregar más Liquidaciones?": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Cargar Liquidaciones",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Preparar Q1",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Listar PDFs temporales": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Preparar descarga PDFs",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Preparar descarga PDFs": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Descargar PDFs temporales",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Descargar PDFs temporales": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Normalizar PDFs descargados",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Normalizar PDFs descargados": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Extraer texto PDFs",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
}
|
|
},
|
|
"authors": "Isaac Aracena",
|
|
"name": "Version d6cac1fb",
|
|
"description": "",
|
|
"autosaved": true,
|
|
"workflowPublishHistory": [
|
|
{
|
|
"createdAt": "2026-08-26T01:49:22.089Z",
|
|
"id": 5223,
|
|
"workflowId": "qxcXg0Z1YASkWgxL",
|
|
"versionId": "d6cac1fb-fb4b-4bef-a2b3-146b21e84ac4",
|
|
"event": "activated",
|
|
"userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029"
|
|
}
|
|
]
|
|
}
|
|
} |