Files
cruce-de-seguridad-social-p…/cruce-de-seguridad-social-panama.json
T

3937 lines
324 KiB
JSON

{
"updatedAt": "2026-08-21T21:02:04.174Z",
"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": [
20768,
26736
],
"id": "ee56d788-e286-4575-9e02-d8e31bf76837",
"name": "On form submission",
"webhookId": "4ba38e33-3ab3-4a2d-a66e-59457eefae48"
},
{
"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/86e543a4-206d-474b-ab14-4fa697897d8c';\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": [
21008,
26736
],
"id": "ad4f589a-65cf-4e2f-af7c-dcd3c6337929",
"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": [
21248,
26832
],
"id": "58ddefa4-0d02-449f-9beb-af8ac36fef1e",
"name": "Preparar Q1"
},
{
"parameters": {
"operation": "xls",
"options": {
"headerRow": false,
"includeEmptyCells": true,
"rawData": false,
"readAsString": false
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
21728,
26688
],
"id": "420c890d-ad31-4500-b0ff-c0999df7795d",
"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": [
21968,
26688
],
"id": "9691c16a-26ae-4ef3-9231-507ab11c5231",
"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": [
23408,
26688
],
"id": "17fa00ac-b777-44b6-a84a-1c3d9649d998",
"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": [
23888,
26832
],
"id": "647bdb88-d7ed-46df-a1dc-7b80d00817f1",
"name": "Preparar Q2"
},
{
"parameters": {
"operation": "xls",
"options": {
"headerRow": false,
"includeEmptyCells": true,
"rawData": false,
"readAsString": false
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
24368,
26688
],
"id": "e4610d47-633c-4527-b6e8-8db8f69dd84a",
"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": [
24608,
26688
],
"id": "6d6ca2c5-c6bd-4d4a-838a-6c9ee056ae7e",
"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": [
26048,
26688
],
"id": "b5fa883c-f78d-4e32-87d0-53ff44b914a2",
"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": [
26288,
26832
],
"id": "fe97274d-5538-4382-be55-100f474ba51f",
"name": "Preparar CSS"
},
{
"parameters": {
"operation": "xls",
"options": {
"headerRow": false,
"includeEmptyCells": true,
"rawData": false,
"readAsString": false
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
26528,
26832
],
"id": "93d515a6-580d-4c36-b4bb-875309694d42",
"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": [
26768,
26832
],
"id": "ad126f54-1ed2-433b-8695-0cd83f9e26ec",
"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": [
28208,
26832
],
"id": "b7c40654-7c89-4c6e-82bc-8bac76dd1221",
"name": "Parsear CSS"
},
{
"parameters": {
"operation": "pdf",
"options": {}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
28688,
26832
],
"id": "7b3089a6-fbbf-403f-b4fb-c02321004c8c",
"name": "Extraer texto PDFs"
},
{
"parameters": {
"jsCode": "const extracted=$input.all();\nconst metaItems=$items('Normalizar PDFs descargados');\nconst css=($items('Parsear CSS')[0]||{}).json||{};\nconst init=(($items('Inicializar ejecución')[0]||{}).json)||{};\nconst cfg=init.config||{};\nconst rates=cfg.defaultRates||{ssRegular:0.0975,se:0.0125,ssDtm:0.0725};\nconst targetMonth=Number(css.period?.month||0),targetYear=Number(css.period?.year||0);\n\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'').toUpperCase().replace(/[^A-Z0-9 ]/g,' ').replace(/\\s+/g,' ').trim();}\nfunction 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){const flags=(re.flags||'').includes('g')?re.flags:(re.flags||'')+'g',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}));}\n\n// PDF.js a veces separa el decimal en dos líneas (66.\\n54 o 66 \\n.54).\n// Solo se reparan fragmentos que contienen un punto decimal explícito para no unir cifras distintas.\nfunction healNumericFragments(src){\n let s=String(src||'').replace(/\\r/g,'');\n for(let pass=0;pass<5;pass++){\n s=s.replace(/(\\d[\\d,]*)\\.\\s+(\\d{1,2})(?=(?:\\s|$|[^\\d]))/g,'$1.$2');\n s=s.replace(/(\\d[\\d,]*)\\s+\\.\\s*(\\d{1,2})(?=(?:\\s|$|[^\\d]))/g,'$1.$2');\n s=s.replace(/(\\d{1,3}),\\s+(\\d{3})(?=(?:\\.\\d{1,2})?(?:\\s|$|[^\\d]))/g,'$1,$2');\n }\n return s;\n}\nfunction moneyNum(v){\n if(v===null||v===undefined)return null;\n let s=String(v).replace(/B\\/\\.?|\\$/gi,'').replace(/,/g,'').trim();\n if(/^\\(.*\\)$/.test(s))s='-'+s.slice(1,-1);\n if(!/^-?\\d+(?:\\.\\d+)?$/.test(s))return null;\n const n=Number(s);return Number.isFinite(n)?round(n):null;\n}\nfunction pureMoney(line){\n const s=String(line||'').trim();\n if(!/^[\\s$()\\-]*[\\d,]+(?:\\.\\d+)?\\s*$/.test(s))return null;\n return moneyNum(s);\n}\nfunction inlineNumbers(s){\n const src=String(s||''),out=[];\n const re=/[-(]?\\$?\\d[\\d,]*(?:\\.\\d+)?\\)?/g;let m;\n while((m=re.exec(src))!==null){const v=moneyNum(m[0]);if(v!==null)out.push({value:v,index:m.index,end:re.lastIndex,raw:m[0]});if(m[0]==='')re.lastIndex++;}\n return out;\n}\nfunction splitLines(block){return String(block||'').replace(/\\r/g,'').split('\\n').map(x=>x.trim());}\n\n// Lee el importe que pertenece a una etiqueta concreta. Prioridad:\n// 1) cifra en la MISMA línea de la etiqueta; 2) línea monetaria inmediatamente anterior;\n// 3) línea monetaria inmediatamente posterior. Nunca busca importes arbitrarios a distancia.\nfunction valueAtLabel(block,labelRe,opts={}){\n const {prefer='before',steps=3}=opts,ls=splitLines(block),hits=[];\n for(let i=0;i<ls.length;i++)if(labelRe.test(ls[i]))hits.push(i);\n if(!hits.length)return {found:false,value:null};\n const i=hits[hits.length-1],line=ls[i];\n labelRe.lastIndex=0;const m=line.match(labelRe);\n if(m){\n const start=m.index||0,end=start+m[0].length,before=line.slice(0,start),after=line.slice(end);\n const first=(str,fromEnd=false)=>{const a=inlineNumbers(str);if(!a.length)return null;return fromEnd?a[a.length-1].value:a[0].value;};\n if(prefer==='after'){\n let v=first(after,false);if(v!==null)return {found:true,value:v,source:'INLINE_AFTER'};\n v=first(before,true);if(v!==null)return {found:true,value:v,source:'INLINE_BEFORE'};\n }else{\n let v=first(before,true);if(v!==null)return {found:true,value:v,source:'INLINE_BEFORE'};\n v=first(after,false);if(v!==null)return {found:true,value:v,source:'INLINE_AFTER'};\n }\n }\n const before=[],after=[];\n for(let d=1;d<=steps;d++){if(i-d>=0)before.push(i-d);if(i+d<ls.length)after.push(i+d);}\n const order=prefer==='after'?[...after,...before]:[...before,...after];\n for(const j of order){if(!ls[j])continue;const v=pureMoney(ls[j]);if(v!==null)return {found:true,value:v,source:j<i?'LINE_BEFORE':'LINE_AFTER',distance:Math.abs(j-i)};}\n return {found:false,value:null};\n}\nfunction lastTextMatch(block,re,group=1){const a=regexAll(block,re);return a.length?clean(a[a.length-1][group]||''):'';}\nfunction lastNumberMatch(block,re,group=1){const s=lastTextMatch(block,re,group);const n=moneyNum(s);return n===null?null:n;}\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 nearestPlanilla(pre){const a=regexAll(pre,/Planilla\\s+N[uú]mero\\s*:?\\s*(\\d+)/i);return a.length?Number(a[a.length-1][1]):0;}\nfunction nearestGenerated(pre){const a=regexAll(pre,/(\\d{1,2}\\/\\d{1,2}\\/20\\d{2}\\s+\\d{1,2}:\\d{2}:\\d{2}\\s*(?:AM|PM)?)/i);return a.length?clean(a[a.length-1][1]):'';}\nfunction fileExtraPeriod(text){const a=regexAll(text,/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);if(!a.length)return null;const d1=parseDMY(a[0][1]),d2=parseDMY(a[0][2]);return d1&&d2?{a:d1,b:d2,start:dateKey(d1),end:dateKey(d2),display:`${fmtDate(d1)} - ${fmtDate(d2)}`}:null;}\n\n// En las planillas de vacaciones, el Periodo Libre del empleado está DESPUÉS de su marcador.\n// Se toman exclusivamente las dos últimas fechas que aparecen ANTES de \"Periodo Libre\" dentro\n// del segmento del mismo empleado. Así no se confunden con \"Vacaciones Correspondientes del\".\nfunction periodLibre(post){\n const src=String(post||''),m=/Periodo\\s+Libre\\s*:?/i.exec(src);if(!m)return null;\n const before=src.slice(0,m.index),dates=regexAll(before,/(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})/g).map(x=>({txt:x[1],d:parseDMY(x[1])})).filter(x=>x.d);\n if(dates.length<2)return null;\n const pair=dates.slice(-2).map(x=>x.d).sort((a,b)=>a-b),startDate=pair[0],endDate=pair[1];\n return {startDate,endDate,start:dateKey(startDate),end:dateKey(endDate),display:`${fmtDate(startDate)} - ${fmtDate(endDate)}`};\n}\nfunction close(a,b,tol){return Number.isFinite(a)&&Number.isFinite(b)&&Math.abs(a-b)<=tol;}\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));}\n\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:'LINE_ANCHOR_V7'});}\n\nfor(let i=0;i<extracted.length;i++){\n const meta=metaItems[i]?.json||{},item=extracted[i]||{};\n const fileName=meta.fileName||`PDF ${i+1}`,category=meta.category||'';files.push(fileName);\n const text=healNumericFragments(String(item.json?.text??item.json?.data??item.json?.content??''));\n if(!text.trim()){validation('REVISAR',category,fileName,'PDF sin texto extraíble; archivo excluido del cálculo.');continue;}\n\n if(category==='vacaciones'){\n const ms=markers(text,/Empleado\\s*#\\s*(\\d+)\\s*:\\s*([^\\n\\r]+?)(?=\\s+Clave\\s*ISR|$)/i);\n if(!ms.length){validation('REVISAR',category,fileName,'No se detectaron empleados en la Planilla de Vacaciones.');continue;}\n let inc=0,out=0,review=0;\n for(let j=0;j<ms.length;j++){\n const m=ms[j],code=clean(m.match[1]).replace(/^0+(?=\\d)/,''),name=clean(m.match[2]);\n const pre=segPre(text,ms,j),post=segPost(text,ms,j),pr=periodLibre(post);\n const vac=valueAtLabel(pre,/^\\s*Vacaciones(?!\\s+Correspondientes)\\b\\s*:?/i,{prefer:'before',steps:2});\n const total=valueAtLabel(pre,/^\\s*TOTAL\\s+BRUTO\\s*:?/i,{prefer:'before',steps:2});\n const ss=valueAtLabel(pre,/^\\s*Seg\\.\\s*Soc\\.?/i,{prefer:'before',steps:2});\n const se=valueAtLabel(pre,/^\\s*Seg\\.\\s*Edu\\.?/i,{prefer:'before',steps:2});\n let gross=null;if(vac.found&&total.found&&close(vac.value,total.value,.05))gross=vac.value;else if(vac.found&&!total.found)gross=vac.value;else if(total.found&&!vac.found)gross=total.value;\n const planillaNumber=nearestPlanilla(pre),generatedAt=nearestGenerated(pre);\n let reason='';\n if(gross===null)reason='Vacaciones y Total Bruto no pudieron vincularse al mismo empleado.';\n else if(!ss.found||!se.found)reason='No se pudo leer S.S. y/o S.E. adyacente a su etiqueta.';\n else if(!pr)reason='No se pudo leer el Periodo Libre del mismo empleado.';\n else{\n const expSS=round(gross*Number(rates.ssRegular||0.0975)),expSE=round(gross*Number(rates.se||0.0125));\n if(!close(ss.value,expSS,.08)||!close(se.value,expSE,.08))reason=`Control de tasa no cuadra: bruto ${gross}, S.S. ${ss.value} (~${expSS}), S.E. ${se.value} (~${expSE}).`;\n }\n if(reason){review++;addRecord({category:'Vacaciones',fileName,code,name,ss:ss.found?ss.value:0,se:se.found?se.value:0,gross:gross||0,regularBase:gross||0,dtmBase:0,date:pr?.display||'',periodStart:pr?.start||'',periodEnd:pr?.end||'',transaction:'',planillaNumber,generatedAt,included:false,reason});continue;}\n const included=overlapTarget(pr.startDate,pr.endDate)!==false;if(included)inc++;else out++;\n addRecord({category:'Vacaciones',fileName,code,name,ss:ss.value,se:se.value,gross,regularBase:gross,dtmBase:0,date:pr.display,periodStart:pr.start,periodEnd:pr.end,transaction:'',planillaNumber,generatedAt,included,reason:included?'':'Período Libre fuera del mes de la Planilla CSS',ssMethod:'SOURCE_LINE',seMethod:'SOURCE_LINE'});\n }\n validation(review?'REVISAR':out?'EXCLUIDO':'OK',category,fileName,`${ms.length} vacaciones: ${inc} incluidas, ${out} fuera de período y ${review} no legibles. Lectura por línea/etiqueta del mismo empleado.`);\n\n }else if(category==='extraordinaria'){\n const ms=markers(text,/Empleado\\s*:\\s*(\\d+)\\s+([^\\n\\r]+?)\\s+Transac\\.?\\s*#\\s*:\\s*(\\d+)(?:\\s+Pla\\.?\\s*#\\s*:\\s*(\\d+))?/i);\n const pr=fileExtraPeriod(text);\n if(!ms.length){validation('REVISAR',category,fileName,'No se detectaron empleados en la Planilla Extraordinaria.');continue;}\n let inc=0,out=0,review=0;\n for(let j=0;j<ms.length;j++){\n const m=ms[j],pre=segPre(text,ms,j),code=clean(m.match[1]).replace(/^0+(?=\\d)/,''),name=clean(m.match[2]),transaction=clean(m.match[3]);\n const ss=valueAtLabel(pre,/^\\s*Seg\\.\\s*Soc\\.?/i,{prefer:'before',steps:2}),se=valueAtLabel(pre,/^\\s*Seg\\.\\s*Edu\\.?/i,{prefer:'before',steps:2});\n let gross=valueAtLabel(pre,/^\\s*Totales\\b/i,{prefer:'after',steps:2});if(!gross.found)gross=valueAtLabel(pre,/^\\s*Sal\\.\\s*Bruto\\b/i,{prefer:'before',steps:2});\n const planillaNumber=Number(m.match[4]||0)||nearestPlanilla(pre),generatedAt=nearestGenerated(pre);\n let reason='';\n if(!pr)reason='No se pudo detectar el período de la extraordinaria.';\n else if(!gross.found||!ss.found||!se.found)reason='No se pudieron leer Totales/S.S./S.E. dentro del bloque del empleado.';\n else{const expSS=round(gross.value*Number(rates.ssRegular||0.0975)),expSE=round(gross.value*Number(rates.se||0.0125));if(!close(ss.value,expSS,.08)||!close(se.value,expSE,.08))reason=`Control de tasa no cuadra: bruto ${gross.value}, S.S. ${ss.value} (~${expSS}), S.E. ${se.value} (~${expSE}).`;}\n if(reason){review++;addRecord({category:'Extraordinaria',fileName,code,name,ss:ss.found?ss.value:0,se:se.found?se.value:0,gross:gross.found?gross.value:0,regularBase:gross.found?gross.value:0,dtmBase:0,date:pr?.display||'',periodStart:pr?.start||'',periodEnd:pr?.end||'',transaction,planillaNumber,generatedAt,included:false,reason});continue;}\n const included=overlapTarget(pr.a,pr.b)!==false;if(included)inc++;else out++;\n addRecord({category:'Extraordinaria',fileName,code,name,ss:ss.value,se:se.value,gross:gross.value,regularBase:gross.value,dtmBase:0,date:pr.display,periodStart:pr.start,periodEnd:pr.end,transaction,planillaNumber,generatedAt,included,reason:included?'':'Período fuera del mes de la Planilla CSS',ssMethod:'SOURCE_LINE',seMethod:'SOURCE_LINE'});\n }\n validation(review?'REVISAR':out?'EXCLUIDO':'OK',category,fileName,`${ms.length} extraordinarias: ${inc} incluidas, ${out} fuera de período y ${review} no legibles. Lectura por línea/etiqueta del mismo empleado.`);\n\n }else if(category==='liquidaciones'){\n if(!norm(text).includes('PLANILLA DE LIQUIDACION')){\n const raw=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,'')}));\n const seen=new Set(),employees=[];for(const e of raw){const k=`${e.code}|${norm(e.name)}`;if(!seen.has(k)){seen.add(k);employees.push(e);}}\n supportDocs.push({fileName,employees});validation('IGNORADO',category,fileName,`Documento de cálculo/soporte (${employees.length} empleado(s)); no se suma para evitar duplicidad.`);continue;\n }\n const ms=markers(text,/Empleado\\s*#\\s*(\\d+)\\s*:\\s*([^\\n\\r]+?)(?=\\s+Clave\\s*ISR|$)/i);\n if(!ms.length){validation('REVISAR',category,fileName,'No se detectaron empleados en la Planilla de Liquidación resumida.');continue;}\n let inc=0,out=0,review=0;\n for(let j=0;j<ms.length;j++){\n const m=ms[j],pre=segPre(text,ms,j),code=clean(m.match[1]).replace(/^0+(?=\\d)/,''),name=clean(m.match[2]);\n const transaction=lastTextMatch(pre,/N[uú]mero\\s+de\\s+Transacci[oó]n\\s*:\\s*(\\d{3,})/i),date=lastTextMatch(pre,/FECHA\\s+DE\\s+LIQUIDACI[OÓ]N\\s*:\\s*(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})/i);\n const ss=valueAtLabel(pre,/^\\s*Seg\\.\\s*Soc\\.?/i,{prefer:'before',steps:2}),se=valueAtLabel(pre,/^\\s*Seg\\.\\s*Edu\\.?/i,{prefer:'before',steps:2});\n const sv=valueAtLabel(pre,/^\\s*Salario,?\\s*Vac,?\\s*13\\s*Mes\\s*:/i,{prefer:'before',steps:2}),sal=valueAtLabel(pre,/^\\s*Salario\\s+Reg\\.\\s*:/i,{prefer:'before',steps:2}),vac=valueAtLabel(pre,/^\\s*Vacaciones\\s*:/i,{prefer:'before',steps:2}),dtm=valueAtLabel(pre,/^\\s*XIII\\s+Mes\\s*:/i,{prefer:'before',steps:2}),pai=valueAtLabel(pre,/^\\s*Preav,?\\s*P\\s*Ant,?\\s*Indem\\s*:/i,{prefer:'before',steps:2}),gross=valueAtLabel(pre,/^\\s*Total\\s+Bruto\\s*:/i,{prefer:'before',steps:2});\n const planillaNumber=nearestPlanilla(pre),generatedAt=nearestGenerated(pre);\n let reason='';\n const required=[ss,se,sv,sal,vac,dtm,pai,gross];\n if(!date||!transaction||required.some(x=>!x.found))reason='No se pudieron leer todos los campos impresos de la liquidación dentro del bloque del empleado.';\n let regularBase=0;\n if(!reason){\n regularBase=round(sv.value-dtm.value);\n const compSV=round(sal.value+vac.value+dtm.value),compGross=round(sv.value+pai.value),expSS=round(regularBase*Number(rates.ssRegular||0.0975)+dtm.value*Number(rates.ssDtm||0.0725)),expSE=round(regularBase*Number(rates.se||0.0125));\n if(regularBase<0||!close(sv.value,compSV,.08)||!close(gross.value,compGross,.08)||!close(ss.value,expSS,.08)||!close(se.value,expSE,.08))reason=`Control contable no cuadra: Sal/Vac/13 ${sv.value} vs ${compSV}; bruto ${gross.value} vs ${compGross}; S.S. ${ss.value} (~${expSS}); S.E. ${se.value} (~${expSE}).`;\n }\n if(reason){review++;addRecord({category:'Liquidación',fileName,code,name,ss:ss.found?ss.value:0,se:se.found?se.value:0,gross:gross.found?gross.value:0,regularBase,dtmBase:dtm.found?dtm.value:0,salaryVac13:sv.found?sv.value:0,salaryReg:sal.found?sal.value:0,vacationBase:vac.found?vac.value:0,date,transaction,planillaNumber,generatedAt,included:false,reason});continue;}\n const included=inTarget(parseDMY(date));if(included)inc++;else out++;\n addRecord({category:'Liquidación',fileName,code,name,ss:ss.value,se:se.value,gross:gross.value,regularBase,dtmBase:dtm.value,salaryVac13:sv.value,salaryReg:sal.value,vacationBase:vac.value,date,transaction,planillaNumber,generatedAt,included,reason:included?'':'Fecha de liquidación fuera del mes de la Planilla CSS',ssMethod:'SOURCE_LINE',seMethod:'SOURCE_LINE'});\n }\n validation(review?'REVISAR':out?'EXCLUIDO':'OK',category,fileName,`${ms.length} liquidaciones: ${inc} incluidas, ${out} fuera de período y ${review} no legibles. Lectura por línea/etiqueta + identidades contables del mismo empleado.`);\n }else validation('REVISAR',category||'desconocida',fileName,'Categoría complementaria no reconocida; archivo excluido del cálculo.');\n}\n\n// Duplicados: nunca se suman dos veces.\nconst seenExtra=new Map();\nfor(const r of records){if(r.category!=='Extraordinaria'||!r.included)continue;const k=r.transaction?`T:${r.transaction}`:`F:${r.code||norm(r.name)}|${r.periodStart}|${r.periodEnd}|${r.ss}|${r.se}|${r.gross}`;if(!seenExtra.has(k)){seenExtra.set(k,r);continue;}const p=seenExtra.get(k);if(recordFingerprint(p)===recordFingerprint(r)){r.included=false;r.reason=`Duplicado exacto; ya incluido desde ${p.fileName}`;}else{r.included=false;r.reason=`Transacción ${r.transaction} repetida con datos distintos; revisar.`;validation('REVISAR','extraordinaria',r.fileName,r.reason);}}\nconst seenVac=new Map(),vacGroups=new Map();\nfor(const r of records){if(r.category!=='Vacaciones'||!r.included)continue;const person=r.code||norm(r.name),ek=`${person}|${r.periodStart}|${r.periodEnd}|${r.ss}|${r.se}|${r.gross}`;if(seenVac.has(ek)){r.included=false;r.reason=`Duplicado exacto; ya incluido desde ${seenVac.get(ek).fileName}`;continue;}seenVac.set(ek,r);const gk=`${person}|${r.periodStart}|${r.periodEnd}`;if(!vacGroups.has(gk))vacGroups.set(gk,[]);vacGroups.get(gk).push(r);}\nfor(const arr of vacGroups.values()){const active=arr.filter(x=>x.included);if(active.length<=1)continue;active.sort((a,b)=>(Number(b.planillaNumber)||0)-(Number(a.planillaNumber)||0)||String(b.generatedAt).localeCompare(String(a.generatedAt)));for(const r of active.slice(1)){r.included=false;r.reason=`Sustituida por planilla de vacaciones más reciente (${active[0].planillaNumber||active[0].fileName}).`;}}\nconst seenLiq=new Map();\nfor(const r of records){if(r.category!=='Liquidación'||!r.included)continue;const k=r.transaction?`T:${r.transaction}`:`F:${r.code||norm(r.name)}|${r.date}|${r.ss}|${r.se}|${r.gross}`;if(!seenLiq.has(k)){seenLiq.set(k,r);continue;}const p=seenLiq.get(k);if(recordFingerprint(p)===recordFingerprint(r)){r.included=false;r.reason=`Duplicado exacto; ya incluido desde ${p.fileName}`;}else{r.included=false;r.reason=`Transacción ${r.transaction} repetida con datos distintos; revisar.`;validation('REVISAR','liquidaciones',r.fileName,r.reason);}}\n\n// Documentos de cálculo: son soporte y nunca alteran importes ni provocan doble suma.\nconst liqAll=records.filter(r=>r.category==='Liquidación');\nfor(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(', ')}.`);}\n\nconst includedRecords=records.filter(r=>r.included),catCount=cat=>includedRecords.filter(r=>r.category===cat).length;\nconst RESULT={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:'LINE_ANCHOR_V7_SIN_IA',quality:{status:validations.some(v=>v.status==='REVISAR')?'REVISAR':'VALIDADO',rule:'Los complementarios se leen por líneas ancladas a etiquetas dentro del bloque exacto de cada empleado. No se alinean listas globales, no se buscan importes por proximidad amplia y no se reconstruyen descuentos cuando el PDF trae el valor impreso.'}};\nreturn [{json:RESULT}];\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
28928,
26832
],
"id": "ed614c7e-da77-4670-86ac-ba83d855ba21",
"name": "Parsear PDFs",
"notesInFlow": true,
"notes": "Parser V7 SIN IA: lectura determinista por líneas ancladas a etiquetas dentro del bloque exacto de cada empleado; sin alineación global ni búsqueda por proximidad amplia."
},
{
"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": [
29168,
26832
],
"id": "cae896d0-72d3-424a-ae45-3c3483b9fbd3",
"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": [
29408,
26832
],
"id": "6fa8dc8f-bd83-445f-97d7-e1acdedddc97",
"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": [
29648,
26832
],
"id": "fe78de18-a2d4-4866-8e99-4a87819129fc",
"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": [
29888,
26832
],
"id": "6e359784-7724-4dcc-82f3-e8ec5867288b",
"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": [
30128,
26832
],
"id": "d4092b24-112e-4cbc-89a8-757458c3baec",
"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": [
30368,
26832
],
"id": "c569cb6f-97bc-42ef-bfc0-0636ef6f98b1",
"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": [
30608,
26832
],
"id": "164892df-85f8-4265-bb47-a21c7700f6f4",
"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=>({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[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;\">&nbsp;</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": [
30848,
26832
],
"id": "bdee4a88-ed77-41b1-a4ea-c0321f5587d2",
"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": [
31088,
26832
],
"id": "1cf118be-34f7-46ff-9a83-414bdb9bdf5a",
"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": [
31808,
26832
],
"id": "b99cc3cd-a57e-401b-aab3-b671895bdc05",
"name": "Preparar confirmación"
},
{
"parameters": {
"operation": "completion",
"respondWith": "showText",
"responseText": "={{ $json.html }}"
},
"type": "n8n-nodes-base.form",
"typeVersion": 2.5,
"position": [
32048,
26832
],
"id": "716a9401-cf32-4eac-b329-8d318f0c686b",
"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": [
21488,
26912
],
"id": "5abe74ca-8b12-4ee2-8289-ff31fa149407",
"name": "¿Q1 es PDF?"
},
{
"parameters": {
"operation": "pdf",
"options": {}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
21728,
27056
],
"id": "34048913-682f-42a2-bc3f-8b02746dc92b",
"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": [
21984,
27024
],
"id": "c0af9861-2562-41d1-a15b-00b2e42b7bf2",
"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": [
23648,
26832
],
"id": "321d3b2f-837e-48b3-af92-8662b625adc7",
"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": [
24128,
26912
],
"id": "ed7c9435-b43d-4e4c-a702-23127d54fe1f",
"name": "¿Q2 es PDF?"
},
{
"parameters": {
"operation": "pdf",
"options": {}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
24368,
27056
],
"id": "e7dfdc83-f288-48e3-82ef-72bc1ca65ca4",
"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": [
24608,
27056
],
"id": "c8d62ff5-5098-4509-b79d-0b2a670cbbec",
"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": [
25568,
26832
],
"id": "012df9a7-6095-4322-8f93-8b4d24986e54",
"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": [
31328,
26832
],
"id": "e83d7ddc-c884-4034-acc3-f6331f491608",
"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": [
31568,
26832
],
"id": "d7d69f76-df90-4f5c-bad4-536410e6fc9d",
"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": [
21248,
27216
],
"id": "b16c3231-7cda-4395-b4a7-6b17cc5be53d",
"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": [
21488,
27216
],
"id": "b784edea-6563-4f63-b8cb-900f2850753e",
"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": [
21728,
27216
],
"id": "534cbd73-479a-4797-848c-a012e29bbc58",
"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": [
21984,
27024
],
"id": "c0214ec8-c0ef-4e9d-bf81-b1cc55eaffef",
"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": [
22224,
27024
],
"id": "693a4ce9-c131-4bb1-9809-c455482b0b3a",
"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": [
22464,
27024
],
"id": "bc86f7bb-749b-4c13-9a20-3411dcb14f94",
"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": [
22704,
27024
],
"id": "4c14fe7c-7ee1-4ba6-b214-1f7f4b450c06",
"name": "Cerrar lote Extraordinarias"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"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": [
22944,
27024
],
"id": "801c384f-0904-474c-a036-f90f13861d57",
"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": [
23184,
27184
],
"id": "cdcf55f9-3935-4775-bbd1-0f7af2053895",
"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": [
23424,
27184
],
"id": "db348186-5aa6-48ca-b987-b608371a6135",
"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": [
23664,
27184
],
"id": "765675c4-1b52-47cd-8548-c7ee8e672795",
"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": [
23888,
27216
],
"id": "a132a6cb-3992-489b-992d-391f1b25d939",
"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": [
24128,
27216
],
"id": "42d058c1-e95d-4f50-97eb-92c8b676f519",
"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": [
24368,
27216
],
"id": "b1b680ab-5f7e-46eb-ac89-217fc6e1c5bb",
"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": [
24608,
27216
],
"id": "73add3fa-2c1f-4b49-90a2-aeb8946e6407",
"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": [
24848,
27216
],
"id": "65feccb0-75ee-4dac-a387-d104b3eb6b77",
"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": [
25088,
27216
],
"id": "4cbe541e-cdcc-4f61-a8b3-3ed8f4fa5915",
"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": [
25328,
27216
],
"id": "eb344e25-564f-47fe-9c2f-78196123900c",
"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": [
28448,
27056
],
"id": "e3645976-82e4-4464-ac32-8a1be494f3df",
"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": [
28688,
27056
],
"id": "dc199c71-3269-4587-92fa-404ad7815521",
"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": [
28928,
27056
],
"id": "f0ce2d8b-da54-4aa9-9c1a-560b90f5e2c1",
"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": [
29168,
27056
],
"id": "b169ad1c-478f-4fc9-b80d-2d4d020c86b8",
"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": "6b4a28c8-b9fc-42c1-a440-6bd1aae0ab20",
"activeVersionId": "6b4a28c8-b9fc-42c1-a440-6bd1aae0ab20",
"versionCounter": 122,
"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-21T21:02:10.000Z",
"createdAt": "2026-08-21T21:02:01.330Z",
"versionId": "6b4a28c8-b9fc-42c1-a440-6bd1aae0ab20",
"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": [
20768,
26736
],
"id": "ee56d788-e286-4575-9e02-d8e31bf76837",
"name": "On form submission",
"webhookId": "4ba38e33-3ab3-4a2d-a66e-59457eefae48"
},
{
"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/86e543a4-206d-474b-ab14-4fa697897d8c';\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": [
21008,
26736
],
"id": "ad4f589a-65cf-4e2f-af7c-dcd3c6337929",
"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": [
21248,
26832
],
"id": "58ddefa4-0d02-449f-9beb-af8ac36fef1e",
"name": "Preparar Q1"
},
{
"parameters": {
"operation": "xls",
"options": {
"headerRow": false,
"includeEmptyCells": true,
"rawData": false,
"readAsString": false
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
21728,
26688
],
"id": "420c890d-ad31-4500-b0ff-c0999df7795d",
"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": [
21968,
26688
],
"id": "9691c16a-26ae-4ef3-9231-507ab11c5231",
"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": [
23408,
26688
],
"id": "17fa00ac-b777-44b6-a84a-1c3d9649d998",
"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": [
23888,
26832
],
"id": "647bdb88-d7ed-46df-a1dc-7b80d00817f1",
"name": "Preparar Q2"
},
{
"parameters": {
"operation": "xls",
"options": {
"headerRow": false,
"includeEmptyCells": true,
"rawData": false,
"readAsString": false
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
24368,
26688
],
"id": "e4610d47-633c-4527-b6e8-8db8f69dd84a",
"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": [
24608,
26688
],
"id": "6d6ca2c5-c6bd-4d4a-838a-6c9ee056ae7e",
"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": [
26048,
26688
],
"id": "b5fa883c-f78d-4e32-87d0-53ff44b914a2",
"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": [
26288,
26832
],
"id": "fe97274d-5538-4382-be55-100f474ba51f",
"name": "Preparar CSS"
},
{
"parameters": {
"operation": "xls",
"options": {
"headerRow": false,
"includeEmptyCells": true,
"rawData": false,
"readAsString": false
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
26528,
26832
],
"id": "93d515a6-580d-4c36-b4bb-875309694d42",
"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": [
26768,
26832
],
"id": "ad126f54-1ed2-433b-8695-0cd83f9e26ec",
"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": [
28208,
26832
],
"id": "b7c40654-7c89-4c6e-82bc-8bac76dd1221",
"name": "Parsear CSS"
},
{
"parameters": {
"operation": "pdf",
"options": {}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
28688,
26832
],
"id": "7b3089a6-fbbf-403f-b4fb-c02321004c8c",
"name": "Extraer texto PDFs"
},
{
"parameters": {
"jsCode": "const extracted=$input.all();\nconst metaItems=$items('Normalizar PDFs descargados');\nconst css=($items('Parsear CSS')[0]||{}).json||{};\nconst init=(($items('Inicializar ejecución')[0]||{}).json)||{};\nconst cfg=init.config||{};\nconst rates=cfg.defaultRates||{ssRegular:0.0975,se:0.0125,ssDtm:0.0725};\nconst targetMonth=Number(css.period?.month||0),targetYear=Number(css.period?.year||0);\n\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'').toUpperCase().replace(/[^A-Z0-9 ]/g,' ').replace(/\\s+/g,' ').trim();}\nfunction 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){const flags=(re.flags||'').includes('g')?re.flags:(re.flags||'')+'g',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}));}\n\n// PDF.js a veces separa el decimal en dos líneas (66.\\n54 o 66 \\n.54).\n// Solo se reparan fragmentos que contienen un punto decimal explícito para no unir cifras distintas.\nfunction healNumericFragments(src){\n let s=String(src||'').replace(/\\r/g,'');\n for(let pass=0;pass<5;pass++){\n s=s.replace(/(\\d[\\d,]*)\\.\\s+(\\d{1,2})(?=(?:\\s|$|[^\\d]))/g,'$1.$2');\n s=s.replace(/(\\d[\\d,]*)\\s+\\.\\s*(\\d{1,2})(?=(?:\\s|$|[^\\d]))/g,'$1.$2');\n s=s.replace(/(\\d{1,3}),\\s+(\\d{3})(?=(?:\\.\\d{1,2})?(?:\\s|$|[^\\d]))/g,'$1,$2');\n }\n return s;\n}\nfunction moneyNum(v){\n if(v===null||v===undefined)return null;\n let s=String(v).replace(/B\\/\\.?|\\$/gi,'').replace(/,/g,'').trim();\n if(/^\\(.*\\)$/.test(s))s='-'+s.slice(1,-1);\n if(!/^-?\\d+(?:\\.\\d+)?$/.test(s))return null;\n const n=Number(s);return Number.isFinite(n)?round(n):null;\n}\nfunction pureMoney(line){\n const s=String(line||'').trim();\n if(!/^[\\s$()\\-]*[\\d,]+(?:\\.\\d+)?\\s*$/.test(s))return null;\n return moneyNum(s);\n}\nfunction inlineNumbers(s){\n const src=String(s||''),out=[];\n const re=/[-(]?\\$?\\d[\\d,]*(?:\\.\\d+)?\\)?/g;let m;\n while((m=re.exec(src))!==null){const v=moneyNum(m[0]);if(v!==null)out.push({value:v,index:m.index,end:re.lastIndex,raw:m[0]});if(m[0]==='')re.lastIndex++;}\n return out;\n}\nfunction splitLines(block){return String(block||'').replace(/\\r/g,'').split('\\n').map(x=>x.trim());}\n\n// Lee el importe que pertenece a una etiqueta concreta. Prioridad:\n// 1) cifra en la MISMA línea de la etiqueta; 2) línea monetaria inmediatamente anterior;\n// 3) línea monetaria inmediatamente posterior. Nunca busca importes arbitrarios a distancia.\nfunction valueAtLabel(block,labelRe,opts={}){\n const {prefer='before',steps=3}=opts,ls=splitLines(block),hits=[];\n for(let i=0;i<ls.length;i++)if(labelRe.test(ls[i]))hits.push(i);\n if(!hits.length)return {found:false,value:null};\n const i=hits[hits.length-1],line=ls[i];\n labelRe.lastIndex=0;const m=line.match(labelRe);\n if(m){\n const start=m.index||0,end=start+m[0].length,before=line.slice(0,start),after=line.slice(end);\n const first=(str,fromEnd=false)=>{const a=inlineNumbers(str);if(!a.length)return null;return fromEnd?a[a.length-1].value:a[0].value;};\n if(prefer==='after'){\n let v=first(after,false);if(v!==null)return {found:true,value:v,source:'INLINE_AFTER'};\n v=first(before,true);if(v!==null)return {found:true,value:v,source:'INLINE_BEFORE'};\n }else{\n let v=first(before,true);if(v!==null)return {found:true,value:v,source:'INLINE_BEFORE'};\n v=first(after,false);if(v!==null)return {found:true,value:v,source:'INLINE_AFTER'};\n }\n }\n const before=[],after=[];\n for(let d=1;d<=steps;d++){if(i-d>=0)before.push(i-d);if(i+d<ls.length)after.push(i+d);}\n const order=prefer==='after'?[...after,...before]:[...before,...after];\n for(const j of order){if(!ls[j])continue;const v=pureMoney(ls[j]);if(v!==null)return {found:true,value:v,source:j<i?'LINE_BEFORE':'LINE_AFTER',distance:Math.abs(j-i)};}\n return {found:false,value:null};\n}\nfunction lastTextMatch(block,re,group=1){const a=regexAll(block,re);return a.length?clean(a[a.length-1][group]||''):'';}\nfunction lastNumberMatch(block,re,group=1){const s=lastTextMatch(block,re,group);const n=moneyNum(s);return n===null?null:n;}\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 nearestPlanilla(pre){const a=regexAll(pre,/Planilla\\s+N[uú]mero\\s*:?\\s*(\\d+)/i);return a.length?Number(a[a.length-1][1]):0;}\nfunction nearestGenerated(pre){const a=regexAll(pre,/(\\d{1,2}\\/\\d{1,2}\\/20\\d{2}\\s+\\d{1,2}:\\d{2}:\\d{2}\\s*(?:AM|PM)?)/i);return a.length?clean(a[a.length-1][1]):'';}\nfunction fileExtraPeriod(text){const a=regexAll(text,/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);if(!a.length)return null;const d1=parseDMY(a[0][1]),d2=parseDMY(a[0][2]);return d1&&d2?{a:d1,b:d2,start:dateKey(d1),end:dateKey(d2),display:`${fmtDate(d1)} - ${fmtDate(d2)}`}:null;}\n\n// En las planillas de vacaciones, el Periodo Libre del empleado está DESPUÉS de su marcador.\n// Se toman exclusivamente las dos últimas fechas que aparecen ANTES de \"Periodo Libre\" dentro\n// del segmento del mismo empleado. Así no se confunden con \"Vacaciones Correspondientes del\".\nfunction periodLibre(post){\n const src=String(post||''),m=/Periodo\\s+Libre\\s*:?/i.exec(src);if(!m)return null;\n const before=src.slice(0,m.index),dates=regexAll(before,/(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})/g).map(x=>({txt:x[1],d:parseDMY(x[1])})).filter(x=>x.d);\n if(dates.length<2)return null;\n const pair=dates.slice(-2).map(x=>x.d).sort((a,b)=>a-b),startDate=pair[0],endDate=pair[1];\n return {startDate,endDate,start:dateKey(startDate),end:dateKey(endDate),display:`${fmtDate(startDate)} - ${fmtDate(endDate)}`};\n}\nfunction close(a,b,tol){return Number.isFinite(a)&&Number.isFinite(b)&&Math.abs(a-b)<=tol;}\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));}\n\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:'LINE_ANCHOR_V7'});}\n\nfor(let i=0;i<extracted.length;i++){\n const meta=metaItems[i]?.json||{},item=extracted[i]||{};\n const fileName=meta.fileName||`PDF ${i+1}`,category=meta.category||'';files.push(fileName);\n const text=healNumericFragments(String(item.json?.text??item.json?.data??item.json?.content??''));\n if(!text.trim()){validation('REVISAR',category,fileName,'PDF sin texto extraíble; archivo excluido del cálculo.');continue;}\n\n if(category==='vacaciones'){\n const ms=markers(text,/Empleado\\s*#\\s*(\\d+)\\s*:\\s*([^\\n\\r]+?)(?=\\s+Clave\\s*ISR|$)/i);\n if(!ms.length){validation('REVISAR',category,fileName,'No se detectaron empleados en la Planilla de Vacaciones.');continue;}\n let inc=0,out=0,review=0;\n for(let j=0;j<ms.length;j++){\n const m=ms[j],code=clean(m.match[1]).replace(/^0+(?=\\d)/,''),name=clean(m.match[2]);\n const pre=segPre(text,ms,j),post=segPost(text,ms,j),pr=periodLibre(post);\n const vac=valueAtLabel(pre,/^\\s*Vacaciones(?!\\s+Correspondientes)\\b\\s*:?/i,{prefer:'before',steps:2});\n const total=valueAtLabel(pre,/^\\s*TOTAL\\s+BRUTO\\s*:?/i,{prefer:'before',steps:2});\n const ss=valueAtLabel(pre,/^\\s*Seg\\.\\s*Soc\\.?/i,{prefer:'before',steps:2});\n const se=valueAtLabel(pre,/^\\s*Seg\\.\\s*Edu\\.?/i,{prefer:'before',steps:2});\n let gross=null;if(vac.found&&total.found&&close(vac.value,total.value,.05))gross=vac.value;else if(vac.found&&!total.found)gross=vac.value;else if(total.found&&!vac.found)gross=total.value;\n const planillaNumber=nearestPlanilla(pre),generatedAt=nearestGenerated(pre);\n let reason='';\n if(gross===null)reason='Vacaciones y Total Bruto no pudieron vincularse al mismo empleado.';\n else if(!ss.found||!se.found)reason='No se pudo leer S.S. y/o S.E. adyacente a su etiqueta.';\n else if(!pr)reason='No se pudo leer el Periodo Libre del mismo empleado.';\n else{\n const expSS=round(gross*Number(rates.ssRegular||0.0975)),expSE=round(gross*Number(rates.se||0.0125));\n if(!close(ss.value,expSS,.08)||!close(se.value,expSE,.08))reason=`Control de tasa no cuadra: bruto ${gross}, S.S. ${ss.value} (~${expSS}), S.E. ${se.value} (~${expSE}).`;\n }\n if(reason){review++;addRecord({category:'Vacaciones',fileName,code,name,ss:ss.found?ss.value:0,se:se.found?se.value:0,gross:gross||0,regularBase:gross||0,dtmBase:0,date:pr?.display||'',periodStart:pr?.start||'',periodEnd:pr?.end||'',transaction:'',planillaNumber,generatedAt,included:false,reason});continue;}\n const included=overlapTarget(pr.startDate,pr.endDate)!==false;if(included)inc++;else out++;\n addRecord({category:'Vacaciones',fileName,code,name,ss:ss.value,se:se.value,gross,regularBase:gross,dtmBase:0,date:pr.display,periodStart:pr.start,periodEnd:pr.end,transaction:'',planillaNumber,generatedAt,included,reason:included?'':'Período Libre fuera del mes de la Planilla CSS',ssMethod:'SOURCE_LINE',seMethod:'SOURCE_LINE'});\n }\n validation(review?'REVISAR':out?'EXCLUIDO':'OK',category,fileName,`${ms.length} vacaciones: ${inc} incluidas, ${out} fuera de período y ${review} no legibles. Lectura por línea/etiqueta del mismo empleado.`);\n\n }else if(category==='extraordinaria'){\n const ms=markers(text,/Empleado\\s*:\\s*(\\d+)\\s+([^\\n\\r]+?)\\s+Transac\\.?\\s*#\\s*:\\s*(\\d+)(?:\\s+Pla\\.?\\s*#\\s*:\\s*(\\d+))?/i);\n const pr=fileExtraPeriod(text);\n if(!ms.length){validation('REVISAR',category,fileName,'No se detectaron empleados en la Planilla Extraordinaria.');continue;}\n let inc=0,out=0,review=0;\n for(let j=0;j<ms.length;j++){\n const m=ms[j],pre=segPre(text,ms,j),code=clean(m.match[1]).replace(/^0+(?=\\d)/,''),name=clean(m.match[2]),transaction=clean(m.match[3]);\n const ss=valueAtLabel(pre,/^\\s*Seg\\.\\s*Soc\\.?/i,{prefer:'before',steps:2}),se=valueAtLabel(pre,/^\\s*Seg\\.\\s*Edu\\.?/i,{prefer:'before',steps:2});\n let gross=valueAtLabel(pre,/^\\s*Totales\\b/i,{prefer:'after',steps:2});if(!gross.found)gross=valueAtLabel(pre,/^\\s*Sal\\.\\s*Bruto\\b/i,{prefer:'before',steps:2});\n const planillaNumber=Number(m.match[4]||0)||nearestPlanilla(pre),generatedAt=nearestGenerated(pre);\n let reason='';\n if(!pr)reason='No se pudo detectar el período de la extraordinaria.';\n else if(!gross.found||!ss.found||!se.found)reason='No se pudieron leer Totales/S.S./S.E. dentro del bloque del empleado.';\n else{const expSS=round(gross.value*Number(rates.ssRegular||0.0975)),expSE=round(gross.value*Number(rates.se||0.0125));if(!close(ss.value,expSS,.08)||!close(se.value,expSE,.08))reason=`Control de tasa no cuadra: bruto ${gross.value}, S.S. ${ss.value} (~${expSS}), S.E. ${se.value} (~${expSE}).`;}\n if(reason){review++;addRecord({category:'Extraordinaria',fileName,code,name,ss:ss.found?ss.value:0,se:se.found?se.value:0,gross:gross.found?gross.value:0,regularBase:gross.found?gross.value:0,dtmBase:0,date:pr?.display||'',periodStart:pr?.start||'',periodEnd:pr?.end||'',transaction,planillaNumber,generatedAt,included:false,reason});continue;}\n const included=overlapTarget(pr.a,pr.b)!==false;if(included)inc++;else out++;\n addRecord({category:'Extraordinaria',fileName,code,name,ss:ss.value,se:se.value,gross:gross.value,regularBase:gross.value,dtmBase:0,date:pr.display,periodStart:pr.start,periodEnd:pr.end,transaction,planillaNumber,generatedAt,included,reason:included?'':'Período fuera del mes de la Planilla CSS',ssMethod:'SOURCE_LINE',seMethod:'SOURCE_LINE'});\n }\n validation(review?'REVISAR':out?'EXCLUIDO':'OK',category,fileName,`${ms.length} extraordinarias: ${inc} incluidas, ${out} fuera de período y ${review} no legibles. Lectura por línea/etiqueta del mismo empleado.`);\n\n }else if(category==='liquidaciones'){\n if(!norm(text).includes('PLANILLA DE LIQUIDACION')){\n const raw=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,'')}));\n const seen=new Set(),employees=[];for(const e of raw){const k=`${e.code}|${norm(e.name)}`;if(!seen.has(k)){seen.add(k);employees.push(e);}}\n supportDocs.push({fileName,employees});validation('IGNORADO',category,fileName,`Documento de cálculo/soporte (${employees.length} empleado(s)); no se suma para evitar duplicidad.`);continue;\n }\n const ms=markers(text,/Empleado\\s*#\\s*(\\d+)\\s*:\\s*([^\\n\\r]+?)(?=\\s+Clave\\s*ISR|$)/i);\n if(!ms.length){validation('REVISAR',category,fileName,'No se detectaron empleados en la Planilla de Liquidación resumida.');continue;}\n let inc=0,out=0,review=0;\n for(let j=0;j<ms.length;j++){\n const m=ms[j],pre=segPre(text,ms,j),code=clean(m.match[1]).replace(/^0+(?=\\d)/,''),name=clean(m.match[2]);\n const transaction=lastTextMatch(pre,/N[uú]mero\\s+de\\s+Transacci[oó]n\\s*:\\s*(\\d{3,})/i),date=lastTextMatch(pre,/FECHA\\s+DE\\s+LIQUIDACI[OÓ]N\\s*:\\s*(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})/i);\n const ss=valueAtLabel(pre,/^\\s*Seg\\.\\s*Soc\\.?/i,{prefer:'before',steps:2}),se=valueAtLabel(pre,/^\\s*Seg\\.\\s*Edu\\.?/i,{prefer:'before',steps:2});\n const sv=valueAtLabel(pre,/^\\s*Salario,?\\s*Vac,?\\s*13\\s*Mes\\s*:/i,{prefer:'before',steps:2}),sal=valueAtLabel(pre,/^\\s*Salario\\s+Reg\\.\\s*:/i,{prefer:'before',steps:2}),vac=valueAtLabel(pre,/^\\s*Vacaciones\\s*:/i,{prefer:'before',steps:2}),dtm=valueAtLabel(pre,/^\\s*XIII\\s+Mes\\s*:/i,{prefer:'before',steps:2}),pai=valueAtLabel(pre,/^\\s*Preav,?\\s*P\\s*Ant,?\\s*Indem\\s*:/i,{prefer:'before',steps:2}),gross=valueAtLabel(pre,/^\\s*Total\\s+Bruto\\s*:/i,{prefer:'before',steps:2});\n const planillaNumber=nearestPlanilla(pre),generatedAt=nearestGenerated(pre);\n let reason='';\n const required=[ss,se,sv,sal,vac,dtm,pai,gross];\n if(!date||!transaction||required.some(x=>!x.found))reason='No se pudieron leer todos los campos impresos de la liquidación dentro del bloque del empleado.';\n let regularBase=0;\n if(!reason){\n regularBase=round(sv.value-dtm.value);\n const compSV=round(sal.value+vac.value+dtm.value),compGross=round(sv.value+pai.value),expSS=round(regularBase*Number(rates.ssRegular||0.0975)+dtm.value*Number(rates.ssDtm||0.0725)),expSE=round(regularBase*Number(rates.se||0.0125));\n if(regularBase<0||!close(sv.value,compSV,.08)||!close(gross.value,compGross,.08)||!close(ss.value,expSS,.08)||!close(se.value,expSE,.08))reason=`Control contable no cuadra: Sal/Vac/13 ${sv.value} vs ${compSV}; bruto ${gross.value} vs ${compGross}; S.S. ${ss.value} (~${expSS}); S.E. ${se.value} (~${expSE}).`;\n }\n if(reason){review++;addRecord({category:'Liquidación',fileName,code,name,ss:ss.found?ss.value:0,se:se.found?se.value:0,gross:gross.found?gross.value:0,regularBase,dtmBase:dtm.found?dtm.value:0,salaryVac13:sv.found?sv.value:0,salaryReg:sal.found?sal.value:0,vacationBase:vac.found?vac.value:0,date,transaction,planillaNumber,generatedAt,included:false,reason});continue;}\n const included=inTarget(parseDMY(date));if(included)inc++;else out++;\n addRecord({category:'Liquidación',fileName,code,name,ss:ss.value,se:se.value,gross:gross.value,regularBase,dtmBase:dtm.value,salaryVac13:sv.value,salaryReg:sal.value,vacationBase:vac.value,date,transaction,planillaNumber,generatedAt,included,reason:included?'':'Fecha de liquidación fuera del mes de la Planilla CSS',ssMethod:'SOURCE_LINE',seMethod:'SOURCE_LINE'});\n }\n validation(review?'REVISAR':out?'EXCLUIDO':'OK',category,fileName,`${ms.length} liquidaciones: ${inc} incluidas, ${out} fuera de período y ${review} no legibles. Lectura por línea/etiqueta + identidades contables del mismo empleado.`);\n }else validation('REVISAR',category||'desconocida',fileName,'Categoría complementaria no reconocida; archivo excluido del cálculo.');\n}\n\n// Duplicados: nunca se suman dos veces.\nconst seenExtra=new Map();\nfor(const r of records){if(r.category!=='Extraordinaria'||!r.included)continue;const k=r.transaction?`T:${r.transaction}`:`F:${r.code||norm(r.name)}|${r.periodStart}|${r.periodEnd}|${r.ss}|${r.se}|${r.gross}`;if(!seenExtra.has(k)){seenExtra.set(k,r);continue;}const p=seenExtra.get(k);if(recordFingerprint(p)===recordFingerprint(r)){r.included=false;r.reason=`Duplicado exacto; ya incluido desde ${p.fileName}`;}else{r.included=false;r.reason=`Transacción ${r.transaction} repetida con datos distintos; revisar.`;validation('REVISAR','extraordinaria',r.fileName,r.reason);}}\nconst seenVac=new Map(),vacGroups=new Map();\nfor(const r of records){if(r.category!=='Vacaciones'||!r.included)continue;const person=r.code||norm(r.name),ek=`${person}|${r.periodStart}|${r.periodEnd}|${r.ss}|${r.se}|${r.gross}`;if(seenVac.has(ek)){r.included=false;r.reason=`Duplicado exacto; ya incluido desde ${seenVac.get(ek).fileName}`;continue;}seenVac.set(ek,r);const gk=`${person}|${r.periodStart}|${r.periodEnd}`;if(!vacGroups.has(gk))vacGroups.set(gk,[]);vacGroups.get(gk).push(r);}\nfor(const arr of vacGroups.values()){const active=arr.filter(x=>x.included);if(active.length<=1)continue;active.sort((a,b)=>(Number(b.planillaNumber)||0)-(Number(a.planillaNumber)||0)||String(b.generatedAt).localeCompare(String(a.generatedAt)));for(const r of active.slice(1)){r.included=false;r.reason=`Sustituida por planilla de vacaciones más reciente (${active[0].planillaNumber||active[0].fileName}).`;}}\nconst seenLiq=new Map();\nfor(const r of records){if(r.category!=='Liquidación'||!r.included)continue;const k=r.transaction?`T:${r.transaction}`:`F:${r.code||norm(r.name)}|${r.date}|${r.ss}|${r.se}|${r.gross}`;if(!seenLiq.has(k)){seenLiq.set(k,r);continue;}const p=seenLiq.get(k);if(recordFingerprint(p)===recordFingerprint(r)){r.included=false;r.reason=`Duplicado exacto; ya incluido desde ${p.fileName}`;}else{r.included=false;r.reason=`Transacción ${r.transaction} repetida con datos distintos; revisar.`;validation('REVISAR','liquidaciones',r.fileName,r.reason);}}\n\n// Documentos de cálculo: son soporte y nunca alteran importes ni provocan doble suma.\nconst liqAll=records.filter(r=>r.category==='Liquidación');\nfor(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(', ')}.`);}\n\nconst includedRecords=records.filter(r=>r.included),catCount=cat=>includedRecords.filter(r=>r.category===cat).length;\nconst RESULT={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:'LINE_ANCHOR_V7_SIN_IA',quality:{status:validations.some(v=>v.status==='REVISAR')?'REVISAR':'VALIDADO',rule:'Los complementarios se leen por líneas ancladas a etiquetas dentro del bloque exacto de cada empleado. No se alinean listas globales, no se buscan importes por proximidad amplia y no se reconstruyen descuentos cuando el PDF trae el valor impreso.'}};\nreturn [{json:RESULT}];\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
28928,
26832
],
"id": "ed614c7e-da77-4670-86ac-ba83d855ba21",
"name": "Parsear PDFs",
"notesInFlow": true,
"notes": "Parser V7 SIN IA: lectura determinista por líneas ancladas a etiquetas dentro del bloque exacto de cada empleado; sin alineación global ni búsqueda por proximidad amplia."
},
{
"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": [
29168,
26832
],
"id": "cae896d0-72d3-424a-ae45-3c3483b9fbd3",
"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": [
29408,
26832
],
"id": "6fa8dc8f-bd83-445f-97d7-e1acdedddc97",
"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": [
29648,
26832
],
"id": "fe78de18-a2d4-4866-8e99-4a87819129fc",
"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": [
29888,
26832
],
"id": "6e359784-7724-4dcc-82f3-e8ec5867288b",
"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": [
30128,
26832
],
"id": "d4092b24-112e-4cbc-89a8-757458c3baec",
"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": [
30368,
26832
],
"id": "c569cb6f-97bc-42ef-bfc0-0636ef6f98b1",
"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": [
30608,
26832
],
"id": "164892df-85f8-4265-bb47-a21c7700f6f4",
"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=>({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[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;\">&nbsp;</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": [
30848,
26832
],
"id": "bdee4a88-ed77-41b1-a4ea-c0321f5587d2",
"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": [
31088,
26832
],
"id": "1cf118be-34f7-46ff-9a83-414bdb9bdf5a",
"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": [
31808,
26832
],
"id": "b99cc3cd-a57e-401b-aab3-b671895bdc05",
"name": "Preparar confirmación"
},
{
"parameters": {
"operation": "completion",
"respondWith": "showText",
"responseText": "={{ $json.html }}"
},
"type": "n8n-nodes-base.form",
"typeVersion": 2.5,
"position": [
32048,
26832
],
"id": "716a9401-cf32-4eac-b329-8d318f0c686b",
"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": [
21488,
26912
],
"id": "5abe74ca-8b12-4ee2-8289-ff31fa149407",
"name": "¿Q1 es PDF?"
},
{
"parameters": {
"operation": "pdf",
"options": {}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
21728,
27056
],
"id": "34048913-682f-42a2-bc3f-8b02746dc92b",
"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": [
21984,
27024
],
"id": "c0af9861-2562-41d1-a15b-00b2e42b7bf2",
"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": [
23648,
26832
],
"id": "321d3b2f-837e-48b3-af92-8662b625adc7",
"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": [
24128,
26912
],
"id": "ed7c9435-b43d-4e4c-a702-23127d54fe1f",
"name": "¿Q2 es PDF?"
},
{
"parameters": {
"operation": "pdf",
"options": {}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
24368,
27056
],
"id": "e7dfdc83-f288-48e3-82ef-72bc1ca65ca4",
"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": [
24608,
27056
],
"id": "c8d62ff5-5098-4509-b79d-0b2a670cbbec",
"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": [
25568,
26832
],
"id": "012df9a7-6095-4322-8f93-8b4d24986e54",
"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": [
31328,
26832
],
"id": "e83d7ddc-c884-4034-acc3-f6331f491608",
"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": [
31568,
26832
],
"id": "d7d69f76-df90-4f5c-bad4-536410e6fc9d",
"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": [
21248,
27216
],
"id": "b16c3231-7cda-4395-b4a7-6b17cc5be53d",
"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": [
21488,
27216
],
"id": "b784edea-6563-4f63-b8cb-900f2850753e",
"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": [
21728,
27216
],
"id": "534cbd73-479a-4797-848c-a012e29bbc58",
"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": [
21984,
27024
],
"id": "c0214ec8-c0ef-4e9d-bf81-b1cc55eaffef",
"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": [
22224,
27024
],
"id": "693a4ce9-c131-4bb1-9809-c455482b0b3a",
"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": [
22464,
27024
],
"id": "bc86f7bb-749b-4c13-9a20-3411dcb14f94",
"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": [
22704,
27024
],
"id": "4c14fe7c-7ee1-4ba6-b214-1f7f4b450c06",
"name": "Cerrar lote Extraordinarias"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"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": [
22944,
27024
],
"id": "801c384f-0904-474c-a036-f90f13861d57",
"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": [
23184,
27184
],
"id": "cdcf55f9-3935-4775-bbd1-0f7af2053895",
"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": [
23424,
27184
],
"id": "db348186-5aa6-48ca-b987-b608371a6135",
"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": [
23664,
27184
],
"id": "765675c4-1b52-47cd-8548-c7ee8e672795",
"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": [
23888,
27216
],
"id": "a132a6cb-3992-489b-992d-391f1b25d939",
"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": [
24128,
27216
],
"id": "42d058c1-e95d-4f50-97eb-92c8b676f519",
"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": [
24368,
27216
],
"id": "b1b680ab-5f7e-46eb-ac89-217fc6e1c5bb",
"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": [
24608,
27216
],
"id": "73add3fa-2c1f-4b49-90a2-aeb8946e6407",
"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": [
24848,
27216
],
"id": "65feccb0-75ee-4dac-a387-d104b3eb6b77",
"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": [
25088,
27216
],
"id": "4cbe541e-cdcc-4f61-a8b3-3ed8f4fa5915",
"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": [
25328,
27216
],
"id": "eb344e25-564f-47fe-9c2f-78196123900c",
"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": [
28448,
27056
],
"id": "e3645976-82e4-4464-ac32-8a1be494f3df",
"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": [
28688,
27056
],
"id": "dc199c71-3269-4587-92fa-404ad7815521",
"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": [
28928,
27056
],
"id": "f0ce2d8b-da54-4aa9-9c1a-560b90f5e2c1",
"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": [
29168,
27056
],
"id": "b169ad1c-478f-4fc9-b80d-2d4d020c86b8",
"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 6b4a28c8",
"description": "",
"autosaved": true,
"workflowPublishHistory": [
{
"createdAt": "2026-08-21T21:02:10.870Z",
"id": 5116,
"workflowId": "qxcXg0Z1YASkWgxL",
"versionId": "6b4a28c8-b9fc-42c1-a440-6bd1aae0ab20",
"event": "activated",
"userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029"
}
]
}
}