{ "updatedAt": "2026-08-26T12:26:15.130Z", "createdAt": "2026-08-20T19:14:26.133Z", "id": "qxcXg0Z1YASkWgxL", "name": "Cruce de Seguridad Social - Panamá", "description": null, "active": true, "isArchived": false, "nodes": [ { "parameters": { "formTitle": "Cruce Seguridad Social Panamá", "formDescription": "Paso 1 de 4. Carga las dos nóminas quincenales (preferiblemente Excel; PDF también se valida con controles estrictos) y la Planilla oficial de la Caja de Seguro Social (CSS) en Excel. En los pasos siguientes podrás cargar Extraordinarias, Vacaciones y Liquidaciones en una o varias tandas, aunque estén en carpetas distintas. El período se detecta automáticamente desde la Planilla CSS.", "formFields": { "values": [ { "fieldLabel": "Nómina - 1era Quincena", "fieldType": "file", "multipleFiles": false, "acceptFileTypes": ".xlsx,.xls,.pdf", "requiredField": true }, { "fieldLabel": "Nómina - 2da Quincena", "fieldType": "file", "multipleFiles": false, "acceptFileTypes": ".xlsx,.xls,.pdf", "requiredField": true }, { "fieldLabel": "Planilla CSS", "fieldType": "file", "multipleFiles": false, "acceptFileTypes": ".xlsx,.xls", "requiredField": true } ] }, "responseMode": "lastNode", "options": { "ignoreBots": true } }, "type": "n8n-nodes-base.formTrigger", "typeVersion": 2.3, "position": [ 53744, 34736 ], "id": "595b95d8-2d86-40b4-a418-1b9964bc76f5", "name": "On form submission", "webhookId": "9c032e76-4e26-46ce-ab02-1a0d628dd1fa" }, { "parameters": { "jsCode": "const item = $input.first();\nconst now = new Date();\nconst pad = n => String(n).padStart(2,'0');\nconst runId = `PA_CSS_${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}_${Math.random().toString(36).slice(2,8)}`;\nconst recipients = [\n 'iaracena@gomezleemarketing.com'\n];\nconst logoUrl = 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png';\nconst formUrl = 'https://agenteit.digitalcompass.agency/form/0feede33-4a67-4d98-8ae0-16f411726460';\nconst config = {\n amountTolerance: 0.10,\n cssIntegrityTolerance: 0.10,\n defaultRates: { ssRegular: 0.0975, se: 0.0125, ssDtm: 0.0725 },\n minRateSamples: 20,\n hardFailUnreadablePdf: true,\n maxSupplementalFilesSafety: 500\n};\nreturn [{json:{runId,startedAt:now.toISOString(),recipients,logoUrl,formUrl,config},binary:item.binary||{}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 53984, 34736 ], "id": "cab81d5c-e00e-4957-af5a-21ab5dae0b09", "name": "Inicializar ejecución" }, { "parameters": { "jsCode": "\nconst base=$items('Inicializar ejecución')[0];\nconst bin=base.binary||{};\nfunction norm(s){return String(s||'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase().replace(/\\s+/g,' ').trim();}\nfunction formatOf(file,name){\n const s=norm(`${name} ${file?.mimeType||''}`);\n if(/\\.pdf(?:$|\\s)|application\\/pdf/.test(s)) return 'PDF';\n if(/\\.xlsx?(?:$|\\s)|spreadsheet|excel|application\\/vnd\\.ms-excel/.test(s)) return 'EXCEL';\n return '';\n}\nconst entries=Object.entries(bin).map(([key,file])=>{\n const name=String(file.fileName||key), k=norm(key), n=norm(name), all=`${k} ${n}`;\n const format=formatOf(file,name); let score=format?100:-1000;\n \n if(/nomina.*1era|1era.*quincena|primera.*quincena|quincena.*1/.test(k)) score+=600;\n if(/nomina.*2da|2da.*quincena|segunda.*quincena|quincena.*2/.test(k)) score-=700;\n if(/1era|primera|quincena\\s*1|q1/.test(n)) score+=180;\n if(/2da|segunda|quincena\\s*2|q2/.test(n)) score-=220;\n if(/planilla\\s*css|detalleplanilla|extraordin|vacacion|liquidacion/.test(all)) score-=300;\n\n return {key,file,name,format,score,k,n,all};\n}).sort((a,b)=>b.score-a.score);\nconst chosen=entries[0];\nif(!chosen||chosen.score<250||!chosen.format) throw new Error('No pude identificar de forma segura la Nómina de 1era Quincena. Adjunta en ese campo un Excel (.xls/.xlsx) o PDF de la planilla quincenal.');\nreturn [{json:{...base.json,fileName:chosen.name,originalBinaryKey:chosen.key,format:chosen.format,inputMimeType:chosen.file?.mimeType||''},binary:{data:chosen.file}}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 54224, 34832 ], "id": "3dff7255-5e27-4c05-b126-5b233af765a7", "name": "Preparar Q1" }, { "parameters": { "operation": "xls", "options": { "headerRow": false, "includeEmptyCells": true, "rawData": false, "readAsString": false } }, "type": "n8n-nodes-base.extractFromFile", "typeVersion": 1.1, "position": [ 54704, 34688 ], "id": "85440a47-e842-43d6-9015-76690b89712f", "name": "Crear staging Q1" }, { "parameters": { "jsCode": "const rows=$input.all().map(x=>x.json?.row).filter(r=>Array.isArray(r));\nif(!rows.length) throw new Error('No se pudieron leer filas de Nómina Q1 directamente desde el Excel.');\nreturn [{json:{valueRanges:[{range:'Sheet1!A1:AZ50000',values:rows}],directExtraction:true}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 54944, 34688 ], "id": "2fe967fa-afc8-48df-87b1-d73ba67e825d", "name": "Reunir staging Q1" }, { "parameters": { "jsCode": "\nconst payload=$json.body&&typeof $json.body==='object'?$json.body:$json;\nconst valueRanges=payload.valueRanges||[];\nconst prep=$items('Preparar Q1')[0].json;\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction asc(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase();}\nfunction key(v){return asc(v).replace(/[^A-Z0-9]/g,'');}\nfunction num(v){if(v===null||v===undefined||v==='')return 0;if(typeof v==='number')return v;let s=String(v).replace(/B\\/.?|\\$|,/g,'').replace(/\\s/g,'');if(/^\\(.*\\)$/.test(s))s='-'+s.slice(1,-1);const n=Number(s);return Number.isFinite(n)?n:0;}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction detectPeriod(values){\n for(const row of values.slice(0,120)) for(const cell of row||[]){\n const m=clean(cell).match(/Periodo\\s+del\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})\\s+hasta\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})/i);\n if(m)return {start:m[1],end:m[2]};\n }\n return null;\n}\nfunction detectPrintedCount(values){\n let gi=-1; for(let i=0;i0)return n;}\n }\n }\n }\n return null;\n}\nfunction detect(values){\n for(let r=0;r(x||[]).length),row.length,1), vertical=[];\n for(let c=0;casc((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/\\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=0?clean(row[d.codeIdx]).replace(/\\.0+$/,''):'';if(code&&!/^\\d{1,8}$/.test(code))code='';\n const ss=round(num(row[d.ssIdx])),se=d.seIdx>=0?round(num(row[d.seIdx])):0,gross=d.grossIdx>=0?round(num(row[d.grossIdx])):0;\n if(!code&&ss===0&&se===0&&gross===0)continue;\n rawRecords.push({code:code.replace(/^0+(?=\\d)/,''),name,ss,se,gross,source:'Nómina Q1',fileName:prep.fileName,sheet:range});count++;\n }\n sheets.push({range,status:count?'PROCESADA':'SIN REGISTROS',records:count});\n}\n// Elimina filas exactas repetidas (por ejemplo, hojas copia) sin sumar dos veces.\nconst exactSeen=new Set(),records=[];\nfor(const r of rawRecords){const k=[r.code,asc(r.name),r.ss.toFixed(2),r.se.toFixed(2),r.gross.toFixed(2)].join('|');if(exactSeen.has(k))continue;exactSeen.add(k);records.push(r);}\nif(!records.length)throw new Error('No pude extraer empleados de Nómina Q1. El Excel no coincide con el formato de planilla esperado.');\nif(printedEmployeeCount&&records.length!==printedEmployeeCount)throw new Error(`Nómina Q1 indica ${printedEmployeeCount} empleados, pero el lector extrajo ${records.length}. Se detuvo el cruce para no generar un reporte incompleto.`);\nconst RESULT={fileName:prep.fileName,format:'EXCEL',records,employeeCount:records.length,printedEmployeeCount:printedEmployeeCount||null,missingCodeCount:records.filter(r=>!r.code).length,totalSS:round(records.reduce((s,r)=>s+r.ss,0)),totalSE:round(records.reduce((s,r)=>s+r.se,0)),period,sheets,stagingSpreadsheetId:'',integrityStatus:printedEmployeeCount?'VALIDADO CONTRA TOTAL DEL ARCHIVO':'VALIDADO ESTRUCTURALMENTE'};\nreturn [{json:RESULT}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 56384, 34688 ], "id": "693f40c8-f19f-4ab6-a526-f0ad006e7402", "name": "Parsear Q1 Excel" }, { "parameters": { "jsCode": "\nconst base=$items('Inicializar ejecución')[0];\nconst bin=base.binary||{};\nfunction norm(s){return String(s||'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase().replace(/\\s+/g,' ').trim();}\nfunction formatOf(file,name){\n const s=norm(`${name} ${file?.mimeType||''}`);\n if(/\\.pdf(?:$|\\s)|application\\/pdf/.test(s)) return 'PDF';\n if(/\\.xlsx?(?:$|\\s)|spreadsheet|excel|application\\/vnd\\.ms-excel/.test(s)) return 'EXCEL';\n return '';\n}\nconst entries=Object.entries(bin).map(([key,file])=>{\n const name=String(file.fileName||key), k=norm(key), n=norm(name), all=`${k} ${n}`;\n const format=formatOf(file,name); let score=format?100:-1000;\n \n if(/nomina.*2da|2da.*quincena|segunda.*quincena|quincena.*2/.test(k)) score+=600;\n if(/nomina.*1era|1era.*quincena|primera.*quincena|quincena.*1/.test(k)) score-=700;\n if(/2da|segunda|quincena\\s*2|q2/.test(n)) score+=180;\n if(/1era|primera|quincena\\s*1|q1/.test(n)) score-=220;\n if(/planilla\\s*css|detalleplanilla|extraordin|vacacion|liquidacion/.test(all)) score-=300;\n\n return {key,file,name,format,score,k,n,all};\n}).sort((a,b)=>b.score-a.score);\nconst chosen=entries[0];\nif(!chosen||chosen.score<250||!chosen.format) throw new Error('No pude identificar de forma segura la Nómina de 2da Quincena. Adjunta en ese campo un Excel (.xls/.xlsx) o PDF de la planilla quincenal.');\nreturn [{json:{...base.json,fileName:chosen.name,originalBinaryKey:chosen.key,format:chosen.format,inputMimeType:chosen.file?.mimeType||''},binary:{data:chosen.file}}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 56864, 34832 ], "id": "e3a1ef29-d141-4b50-b35d-2ba175a41d4e", "name": "Preparar Q2" }, { "parameters": { "operation": "xls", "options": { "headerRow": false, "includeEmptyCells": true, "rawData": false, "readAsString": false } }, "type": "n8n-nodes-base.extractFromFile", "typeVersion": 1.1, "position": [ 57344, 34688 ], "id": "70365021-6de6-43f7-b3c7-0a41fc275d5d", "name": "Crear staging Q2" }, { "parameters": { "jsCode": "const rows=$input.all().map(x=>x.json?.row).filter(r=>Array.isArray(r));\nif(!rows.length) throw new Error('No se pudieron leer filas de Nómina Q2 directamente desde el Excel.');\nreturn [{json:{valueRanges:[{range:'Sheet1!A1:AZ50000',values:rows}],directExtraction:true}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 57584, 34688 ], "id": "b182e631-7af4-4666-b74a-609f29c78e69", "name": "Reunir staging Q2" }, { "parameters": { "jsCode": "\nconst payload=$json.body&&typeof $json.body==='object'?$json.body:$json;\nconst valueRanges=payload.valueRanges||[];\nconst prep=$items('Preparar Q2')[0].json;\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction asc(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase();}\nfunction key(v){return asc(v).replace(/[^A-Z0-9]/g,'');}\nfunction num(v){if(v===null||v===undefined||v==='')return 0;if(typeof v==='number')return v;let s=String(v).replace(/B\\/.?|\\$|,/g,'').replace(/\\s/g,'');if(/^\\(.*\\)$/.test(s))s='-'+s.slice(1,-1);const n=Number(s);return Number.isFinite(n)?n:0;}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction detectPeriod(values){\n for(const row of values.slice(0,120)) for(const cell of row||[]){\n const m=clean(cell).match(/Periodo\\s+del\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})\\s+hasta\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})/i);\n if(m)return {start:m[1],end:m[2]};\n }\n return null;\n}\nfunction detectPrintedCount(values){\n let gi=-1; for(let i=0;i0)return n;}\n }\n }\n }\n return null;\n}\nfunction detect(values){\n for(let r=0;r(x||[]).length),row.length,1), vertical=[];\n for(let c=0;casc((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/\\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=0?clean(row[d.codeIdx]).replace(/\\.0+$/,''):'';if(code&&!/^\\d{1,8}$/.test(code))code='';\n const ss=round(num(row[d.ssIdx])),se=d.seIdx>=0?round(num(row[d.seIdx])):0,gross=d.grossIdx>=0?round(num(row[d.grossIdx])):0;\n if(!code&&ss===0&&se===0&&gross===0)continue;\n rawRecords.push({code:code.replace(/^0+(?=\\d)/,''),name,ss,se,gross,source:'Nómina Q2',fileName:prep.fileName,sheet:range});count++;\n }\n sheets.push({range,status:count?'PROCESADA':'SIN REGISTROS',records:count});\n}\n// Elimina filas exactas repetidas (por ejemplo, hojas copia) sin sumar dos veces.\nconst exactSeen=new Set(),records=[];\nfor(const r of rawRecords){const k=[r.code,asc(r.name),r.ss.toFixed(2),r.se.toFixed(2),r.gross.toFixed(2)].join('|');if(exactSeen.has(k))continue;exactSeen.add(k);records.push(r);}\nif(!records.length)throw new Error('No pude extraer empleados de Nómina Q2. El Excel no coincide con el formato de planilla esperado.');\nif(printedEmployeeCount&&records.length!==printedEmployeeCount)throw new Error(`Nómina Q2 indica ${printedEmployeeCount} empleados, pero el lector extrajo ${records.length}. Se detuvo el cruce para no generar un reporte incompleto.`);\nconst RESULT={fileName:prep.fileName,format:'EXCEL',records,employeeCount:records.length,printedEmployeeCount:printedEmployeeCount||null,missingCodeCount:records.filter(r=>!r.code).length,totalSS:round(records.reduce((s,r)=>s+r.ss,0)),totalSE:round(records.reduce((s,r)=>s+r.se,0)),period,sheets,stagingSpreadsheetId:'',integrityStatus:printedEmployeeCount?'VALIDADO CONTRA TOTAL DEL ARCHIVO':'VALIDADO ESTRUCTURALMENTE'};\nreturn [{json:RESULT}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 59024, 34688 ], "id": "c5ce7b48-654e-4749-94f9-06311f088443", "name": "Parsear Q2 Excel" }, { "parameters": { "jsCode": "\nconst base=$items('Inicializar ejecución')[0];\nconst bin=base.binary||{};\nfunction norm(s){return String(s||'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase().replace(/\\s+/g,' ').trim();}\nfunction formatOf(file,name){\n const s=norm(`${name} ${file?.mimeType||''}`);\n if(/\\.pdf(?:$|\\s)|application\\/pdf/.test(s)) return 'PDF';\n if(/\\.xlsx?(?:$|\\s)|spreadsheet|excel|application\\/vnd\\.ms-excel/.test(s)) return 'EXCEL';\n return '';\n}\nconst entries=Object.entries(bin).map(([key,file])=>{\n const name=String(file.fileName||key), k=norm(key), n=norm(name), all=`${k} ${n}`;\n const format=formatOf(file,name); let score=format?100:-1000;\n \n if(/planilla\\s*css|detalleplanilla|caja.*seguro|css/.test(k)) score+=650;\n if(/planilla\\s*css|detalleplanilla|caja.*seguro|css/.test(n)) score+=240;\n if(format!=='EXCEL') score-=900;\n if(/nomina|quincena|extraordin|vacacion|liquidacion/.test(all)) score-=300;\n\n return {key,file,name,format,score,k,n,all};\n}).sort((a,b)=>b.score-a.score);\nconst chosen=entries[0];\nif(!chosen||chosen.score<250||!chosen.format) throw new Error('No pude identificar de forma segura la Planilla CSS. Adjunta en ese campo el archivo oficial DetallePlanilla en Excel (.xls/.xlsx).');\nreturn [{json:{...base.json,fileName:chosen.name,originalBinaryKey:chosen.key,format:chosen.format,inputMimeType:chosen.file?.mimeType||''},binary:{data:chosen.file}}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 59264, 34832 ], "id": "0a9b3b13-da91-49d4-a018-3f72cc0209a1", "name": "Preparar CSS" }, { "parameters": { "operation": "xls", "options": { "headerRow": false, "includeEmptyCells": true, "rawData": false, "readAsString": false } }, "type": "n8n-nodes-base.extractFromFile", "typeVersion": 1.1, "position": [ 59504, 34832 ], "id": "3daecbfd-ae7c-4cbb-ad6b-564c1523972e", "name": "Crear staging CSS" }, { "parameters": { "jsCode": "const rows=$input.all().map(x=>x.json?.row).filter(r=>Array.isArray(r));\nif(!rows.length) throw new Error('No se pudieron leer filas de Planilla CSS directamente desde el Excel.');\nreturn [{json:{valueRanges:[{range:'Sheet1!A1:AZ50000',values:rows}],directExtraction:true}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 59744, 34832 ], "id": "0456e69e-4dbf-4568-8ab9-1f1efcc4d730", "name": "Reunir staging CSS" }, { "parameters": { "jsCode": "const payload=$json.body&&typeof $json.body==='object'?$json.body:$json;\nconst valueRanges=payload.valueRanges||[],prep=$items('Preparar CSS')[0].json,config=$items('Inicializar ejecución')[0].json.config||{};\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction asc(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase();}\nfunction num(v){if(v===null||v===undefined||v==='')return 0;if(typeof v==='number')return v;let s=String(v).replace(/B\\/.?|\\$|,/g,'').replace(/\\s/g,'');if(/^\\(.*\\)$/.test(s))s='-'+s.slice(1,-1);const n=Number(s);return Number.isFinite(n)?n:0;}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction monthPeriod(text){const months={ENERO:1,FEBRERO:2,MARZO:3,ABRIL:4,MAYO:5,JUNIO:6,JULIO:7,AGOSTO:8,SEPTIEMBRE:9,OCTUBRE:10,NOVIEMBRE:11,DICIEMBRE:12};const m=asc(text).match(/(ENERO|FEBRERO|MARZO|ABRIL|MAYO|JUNIO|JULIO|AGOSTO|SEPTIEMBRE|OCTUBRE|NOVIEMBRE|DICIEMBRE)\\s+(20\\d{2})/);return m?{month:months[m[1]],year:Number(m[2]),label:`${m[1][0]+m[1].slice(1).toLowerCase()} ${m[2]}`}:null;}\nfunction isEmployeeHeader(row){const h=(row||[]).map(asc),j=h.join('|');return j.includes('SEGURO SOCIAL')&&j.includes('APELLIDO')&&j.includes('NOMBRE')&&(/CEDULA|PASAPORTE|REGISTRO CIVIL|IDENTIFICACION|DOCUMENTO/.test(j));}\nif(!valueRanges.length)throw new Error('La Planilla CSS no contiene hojas legibles.');\nconst ranked=valueRanges.map(vr=>({vr,count:(vr.values||[]).reduce((s,r)=>s+(isEmployeeHeader(r)?1:0),0),rows:(vr.values||[]).length})).sort((a,b)=>b.count-a.count||b.rows-a.rows);\nconst chosen=ranked[0],values=chosen?.vr?.values||[];if(!values.length||chosen.count<1)throw new Error('No pude localizar la estructura de empleados dentro de la Planilla CSS oficial.');\nlet period=null;for(let r=0;rv==='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(drclean(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/DECIMO TERCER MES|XIII MES/.test(k)),dtmBase=round(dtmKey?components[dtmKey]:0);\n employees.push({ssNumber,identity,docType,lastname,firstname,name:clean(`${lastname} ${firstname}`),sex:sexIdx>=0?clean(data[sexIdx]):'',salary,salaryAdjusted,dtmBase,planillaSS,planillaSE,planillaDTM,components});\n}\nif(!employees.length)throw new Error('No pude extraer empleados de la Planilla CSS.');\nconst dupIds=new Set(),seenIds=new Set();for(const e of employees){const k=asc(e.identity||e.ssNumber);if(!k)continue;if(seenIds.has(k))dupIds.add(k);seenIds.add(k);}if(dupIds.size)throw new Error(`La Planilla CSS contiene identificaciones duplicadas (${[...dupIds].slice(0,5).join(', ')}). Se detuvo el cruce.`);\nconst reconstructed={seguroSocial:round(employees.reduce((s,e)=>s+e.planillaSS,0)),seguroEducativo:round(employees.reduce((s,e)=>s+e.planillaSE,0)),dtm:round(employees.reduce((s,e)=>s+e.planillaDTM,0)),salario:round(employees.reduce((s,e)=>s+e.salary,0)),salarioAjustado:round(employees.reduce((s,e)=>s+e.salaryAdjusted,0))};\nconst tol=Number(config.cssIntegrityTolerance??0.10),integrity=[];\nfor(const k of ['seguroSocial','seguroEducativo','dtm','salario','salarioAjustado']){if(!foundTotals.has(k))continue;const diff=round(reconstructed[k]-Number(totals[k]||0));integrity.push({metric:k,official:round(totals[k]),reconstructed:reconstructed[k],diff,ok:Math.abs(diff)<=tol});}\nconst bad=integrity.filter(x=>!x.ok);if(bad.length)throw new Error(`Fallo de integridad en Planilla CSS: ${bad.map(x=>`${x.metric} oficial ${x.official} vs reconstruido ${x.reconstructed} (dif. ${x.diff})`).join('; ')}. Se detuvo el cruce.`);\nconst now=new Date(),pad=n=>String(n).padStart(2,'0'),reportTitle=`Cruce Seguridad Social Panamá - ${period.label} - ${pad(now.getDate())}-${pad(now.getMonth()+1)}-${now.getFullYear()}`;\nreturn [{json:{fileName:prep.fileName,period,periodoLabel:period.label,reportTitle,employees,employeeCount:employees.length,totals,reconstructed,integrity,sourceSheet:chosen.vr.range||'',stagingSpreadsheetId:''}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 61184, 34832 ], "id": "c487313c-347e-4b81-ac82-f9f2f13bc945", "name": "Parsear CSS" }, { "parameters": { "operation": "pdf", "options": { "joinPages": true } }, "type": "n8n-nodes-base.extractFromFile", "typeVersion": 1.1, "position": [ 61664, 34832 ], "id": "a08a8ee5-7dd5-46a0-bf76-288870e633ae", "name": "Extraer texto PDFs" }, { "parameters": { "jsCode": "function parseSupplementals(extracted, metaItems, css, init={}) {\nconst cfg=init.config||{};\nconst rates=cfg.defaultRates||{ssRegular:0.0975,se:0.0125,ssDtm:0.0725};\nconst targetMonth=Number(css.period?.month||0),targetYear=Number(css.period?.year||0);\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'').toUpperCase().replace(/[^A-Z0-9 ]/g,' ').replace(/\\s+/g,' ').trim();}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction parseDMY(s){const m=String(s||'').match(/(\\d{1,2})\\/(\\d{1,2})\\/(20\\d{2})/);if(!m)return null;return new Date(Number(m[3]),Number(m[2])-1,Number(m[1]));}\nfunction dateKey(d){return d?`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`:'';}\nfunction fmtDate(d){return d?`${String(d.getDate()).padStart(2,'0')}/${String(d.getMonth()+1).padStart(2,'0')}/${d.getFullYear()}`:'';}\nfunction inTarget(d){return !!(d&&d.getMonth()+1===targetMonth&&d.getFullYear()===targetYear);}\nfunction overlapTarget(a,b){if(!a||!b)return null;const x=[a,b].sort((p,q)=>p-q),first=new Date(targetYear,targetMonth-1,1),last=new Date(targetYear,targetMonth,0,23,59,59);return x[0]<=last&&x[1]>=first;}\nfunction regexAll(text,re){let flags=re.flags||'';if(!flags.includes('g'))flags+='g';const rg=new RegExp(re.source,flags),out=[];let m;while((m=rg.exec(String(text||'')))!==null){out.push(m);if(m[0]==='')rg.lastIndex++;}return out;}\nfunction markers(text,re){return regexAll(text,re).map(m=>({index:m.index,end:m.index+m[0].length,match:m}));}\nfunction segPre(text,ms,j){return text.slice(j>0?ms[j-1].end:0,ms[j].index);}\nfunction segPost(text,ms,j){return text.slice(ms[j].end,j+1({raw:m[0],index:m.index,val:moneyNum(m[0])})).filter(x=>x.val!==null);}\nfunction moneyNearLabel(block,labelRe){\n const lines=String(block||'').split('\\n'), candidates=[];\n for(let i=0;i=lines.length)break;const p=lines[j].trim();if(!p)continue;if(/^\\(?-?\\d[\\d,]*(?:\\.\\d+)?\\)?$/.test(p)){found={line:i,val:moneyNum(p),method:`NEXT_${d}`};break;}else break;}}\n if(found)candidates.push(found);\n }\n return candidates.length?candidates[candidates.length-1]:{line:-1,val:null,method:''};\n}\nfunction textNearLabel(block,labelRe,valueRe){\n const lines=String(block||'').split('\\n'),out=[];for(let i=0;ip-q);return {a:x[0],b:x[1],start:dateKey(x[0]),end:dateKey(x[1]),display:`${fmtDate(x[0])} - ${fmtDate(x[1])}`};}\nfunction periodLibre(post){\n const lines=String(post||'').split('\\n');for(let i=0;ix[0]);if(same.length>=2)return mkPeriod(same[0],same[1]);const prev=[...lines.slice(Math.max(0,i-4),i+1).join(' ').matchAll(/\\d{1,2}\\/\\d{1,2}\\/20\\d{2}/g)].map(x=>x[0]);if(prev.length>=2)return mkPeriod(prev[prev.length-2],prev[prev.length-1]);const next=[...lines.slice(i,Math.min(lines.length,i+5)).join(' ').matchAll(/\\d{1,2}\\/\\d{1,2}\\/20\\d{2}/g)].map(x=>x[0]);if(next.length>=2)return mkPeriod(next[0],next[1]);}return null;\n}\nfunction mkPeriod(a,b){const d1=parseDMY(a),d2=parseDMY(b);if(!d1||!d2)return null;const x=[d1,d2].sort((p,q)=>p-q);return {startDate:x[0],endDate:x[1],start:dateKey(x[0]),end:dateKey(x[1]),display:`${fmtDate(x[0])} - ${fmtDate(x[1])}`};}\nfunction recordFingerprint(r){return [r.category,clean(r.code),norm(r.name),clean(r.date),clean(r.transaction),round(r.ss),round(r.se),round(r.gross),round(r.regularBase),round(r.dtmBase)].join('|');}\nfunction samePerson(a,b){const ac=clean(a.code).replace(/^0+(?=\\d)/,''),bc=clean(b.code).replace(/^0+(?=\\d)/,'');if(ac&&bc&&ac===bc)return true;return !!(norm(a.name)&&norm(a.name)===norm(b.name));}\nfunction vacFields(block){return {vac:moneyNearLabel(block,/Vacaciones(?!\\s+Correspondientes)/i),gross:moneyNearLabel(block,/TOTAL\\s+BRUTO\\s*:?/i),ss:moneyNearLabel(block,/Seg\\.\\s*Soc\\.?/i),se:moneyNearLabel(block,/Seg\\.\\s*Edu\\.?/i)};}\nfunction vacCandidateOK(f){if([f.vac.val,f.gross.val,f.ss.val,f.se.val].some(v=>v===null))return false;const g=f.gross.val;return close(f.vac.val,g,.08)&&close(f.ss.val,round(g*Number(rates.ssRegular||.0975)),.16)&&close(f.se.val,round(g*Number(rates.se||.0125)),.13);}\nfunction extraFields(block){return {gross:moneyNearLabel(block,/\\bTotales\\b/i),ss:moneyNearLabel(block,/Seg\\.\\s*Soc\\.?/i),se:moneyNearLabel(block,/Seg\\.\\s*Edu\\.?/i)};}\nfunction extraCandidateOK(f){if([f.gross.val,f.ss.val,f.se.val].some(v=>v===null))return false;const g=f.gross.val;return close(f.ss.val,round(g*Number(rates.ssRegular||.0975)),.16)&&close(f.se.val,round(g*Number(rates.se||.0125)),.13);}\nfunction liqFields(block){return {date:dateNearLabel(block,/FECHA\\s+DE\\s+LIQUIDACI[ÓO]N\\s*:?/i),transaction:txnNearLabel(block),ss:moneyNearLabel(block,/Seg\\.\\s*Soc\\.?/i),se:moneyNearLabel(block,/Seg\\.\\s*Edu\\.?/i),sv:moneyNearLabel(block,/Salario,?\\s*Vac,?\\s*13\\s*Mes\\s*:?/i),sal:moneyNearLabel(block,/Salario\\s+Reg\\.?\\s*:?/i),vac:moneyNearLabel(block,/^\\s*Vacaciones\\s*:?/i),dtm:moneyNearLabel(block,/^\\s*XIII\\s+Mes\\s*:?/i),pai:moneyNearLabel(block,/Preav,?\\s*P\\s*Ant,?\\s*Indem\\s*:?/i),gross:moneyNearLabel(block,/Total\\s+Bruto\\s*:?/i)};}\nfunction liqCandidateOK(f){if(!f.date||!f.transaction||[f.ss.val,f.se.val,f.sv.val,f.sal.val,f.vac.val,f.dtm.val,f.pai.val,f.gross.val].some(v=>v===null))return false;const rb=round(f.sal.val+f.vac.val),compSV=round(rb+f.dtm.val),compGross=round(f.sv.val+f.pai.val),es=round(rb*Number(rates.ssRegular||.0975)+f.dtm.val*Number(rates.ssDtm||.0725)),ee=round(rb*Number(rates.se||.0125));return close(f.sv.val,compSV,.14)&&close(f.gross.val,compGross,.14)&&close(f.ss.val,es,.17)&&close(f.se.val,ee,.13);}\nconst records=[],validations=[],supportDocs=[],files=[];\nfunction validation(status,category,fileName,detail){validations.push({status,severity:(status==='OK'||status==='IGNORADO'||status==='EXCLUIDO')?'INFO':'WARNING',category,fileName,detail});}\nfunction addRecord(r){records.push({...r,ss:round(r.ss||0),se:round(r.se||0),gross:round(r.gross||0),regularBase:round(r.regularBase||0),dtmBase:round(r.dtmBase||0),parserMode:'STRUCTURE_AWARE_V10'});}\nfor(let i=0;i\\n'):String(raw||''));if(!text.trim()){validation('REVISAR',category,fileName,'PDF sin texto extraíble; archivo excluido.');continue;}\n if(category==='vacaciones'){\n const ms=markers(text,/Empleado\\s*#\\s*(\\d+)\\s*:\\s*(.+?)(?=\\s+(?:Clave\\s+ISR|Vacaciones\\s+Correspondientes|Periodo\\s+Libre)|[\\n\\r]|$)/i);if(!ms.length){validation('REVISAR',category,fileName,'No se detectaron empleados.');continue;}\n const firstPre=vacFields(segPre(text,ms,0)),orientation=vacCandidateOK(firstPre)?'PRE':'POST',planillaNumber=globalPlanilla(text),generatedAt=globalGenerated(text);let inc=0,out=0,review=0;\n for(let j=0;j({code:clean(x.match[1]).replace(/^0+(?=\\d)/,''),name:clean(x.match[2]).replace(/\\s+(?:A[nñ]o|C[eé]dula:).*$/i,'')}));const seen=new Set(),employees=[];for(const e of rawm){const k=e.code+'|'+norm(e.name);if(!seen.has(k)){seen.add(k);employees.push(e);}}supportDocs.push({fileName,employees});validation('IGNORADO',category,fileName,`Documento de cálculo/soporte (${employees.length} empleado(s)); no se suma.`);continue;}\n const ms=markers(text,/Empleado\\s*#\\s*(\\d+)\\s*:\\s*(.+?)(?=\\s+(?:FECHA\\s+DE\\s+LIQUIDACI[ÓO]N|Clave\\s+ISR|N[uú]mero\\s+de\\s+Transacci[oó]n)|[\\n\\r]|$)/i);if(!ms.length){validation('REVISAR',category,fileName,'No se detectaron empleados resumidos.');continue;}const firstPre=liqFields(segPre(text,ms,0)),orientation=liqCandidateOK(firstPre)?'PRE':'POST',planillaNumber=globalPlanilla(text),generatedAt=globalGenerated(text);let inc=0,out=0,review=0;\n for(let j=0;j(Number(b.planillaNumber)||0)-(Number(a.planillaNumber)||0)||String(b.generatedAt).localeCompare(String(a.generatedAt)));for(const r of arr.slice(1)){r.included=false;r.reason=`Sustituida por planilla de vacaciones más reciente (${arr[0].planillaNumber||arr[0].fileName}).`;}}\nconst liqAll=records.filter(r=>r.category==='Liquidación');for(const doc of supportDocs){const missing=(doc.employees||[]).filter(e=>!liqAll.some(r=>samePerson(e,r)));if(missing.length)validation('REVISAR','liquidaciones',doc.fileName,`Documento de cálculo contiene persona(s) sin planilla resumida: ${missing.map(x=>`${x.name}${x.code?` (#${x.code})`:''}`).join(', ')}.`);}\nconst includedRecords=records.filter(r=>r.included),catCount=cat=>includedRecords.filter(r=>r.category===cat).length;return {records,validations,supportDocs,includedRecords,files:[...new Set(files)],counts:{extraordinarias:catCount('Extraordinaria'),vacaciones:catCount('Vacaciones'),liquidaciones:catCount('Liquidación')},totals:{extraSS:round(includedRecords.filter(r=>r.category==='Extraordinaria').reduce((s,r)=>s+r.ss,0)),vacSS:round(includedRecords.filter(r=>r.category==='Vacaciones').reduce((s,r)=>s+r.ss,0)),liqSS:round(includedRecords.filter(r=>r.category==='Liquidación').reduce((s,r)=>s+r.ss,0)),totalSS:round(includedRecords.reduce((s,r)=>s+r.ss,0)),totalSE:round(includedRecords.reduce((s,r)=>s+r.se,0))},parserVersion:'STRUCTURE_AWARE_V10_SIN_IA',quality:{status:validations.some(v=>v.status==='REVISAR')?'REVISAR':'VALIDADO',rule:'La orientación PRE/POST se determina con el primer empleado del archivo mediante controles matemáticos. La misma orientación se aplica a todo el archivo. Vacaciones lee Periodo Libre siempre del bloque posterior. No hay alineación global por listas.'}};\n}\n\nconst __result = parseSupplementals($input.all(), $items('Normalizar PDFs descargados'), (($items('Parsear CSS')[0]||{}).json||{}), (($items('Inicializar ejecución')[0]||{}).json||{}));\nreturn [{json:__result}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 61904, 34832 ], "id": "14f0d70e-f329-49c3-b500-4fdad7c2112f", "name": "Parsear PDFs", "notesInFlow": true, "notes": "Parser complementarios V8 SIN IA: extracción directa por campos del mismo empleado, detección PRE/POST por archivo, validación contable/tasas, soporte para PDF.js y decimales fragmentados. No alinea listas globales ni toma montos de empleados vecinos." }, { "parameters": { "jsCode": "const init=$items('Inicializar ejecución')[0].json,config=init.config||{};\nconst q1=$items('Resultado Q1')[0].json,q2=$items('Resultado Q2')[0].json,css=$items('Parsear CSS')[0].json,pdfs=$items('Parsear PDFs')[0].json;\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase().replace(/\\bVASQUEZ\\b/g,'VAZQUEZ').replace(/[^A-Z0-9 ]/g,' ').replace(/\\s+/g,' ').trim();}\nconst particles=new Set(['DE','DEL','LA','LAS','LOS','Y','DA','DOS']);\nfunction core(v){return norm(v).split(' ').filter(t=>t&&!particles.has(t));}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction money(n){return `B/. ${Number(n||0).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}`;}\nfunction parseDMY(s){const m=String(s||'').match(/(\\d{1,2})\\/(\\d{1,2})\\/(20\\d{2})/);return m?new Date(Number(m[3]),Number(m[2])-1,Number(m[1])):null;}\nfunction validatePayrollPeriod(label,p,half){\n if(!p)return {status:'REVISAR',detail:`${label}: período no detectado automáticamente; se validó estructura y totales, pero conviene confirmar el archivo.`};\n const a=parseDMY(p.start),b=parseDMY(p.end),tm=css.period.month,ty=css.period.year;\n if(!a||!b)return {status:'REVISAR',detail:`${label}: período ilegible.`};\n const same=a.getFullYear()===ty&&b.getFullYear()===ty&&a.getMonth()+1===tm&&b.getMonth()+1===tm;\n if(!same)throw new Error(`${label} pertenece a ${p.start} - ${p.end}, pero la Planilla CSS es ${css.periodoLabel}. Se detuvo el cruce.`);\n if(a>b)throw new Error(`${label} tiene un período invertido (${p.start} - ${p.end}). Se detuvo el cruce.`);\n if(half===1&&(a.getDate()>15||b.getDate()>16))throw new Error(`${label} no parece ser la 1era quincena (${p.start} - ${p.end}). Revisa que no hayas cargado la 2da quincena por error.`);\n if(half===2&&(a.getDate()<15||b.getDate()<27))throw new Error(`${label} no parece ser la 2da quincena (${p.start} - ${p.end}). Revisa que no hayas cargado la 1era quincena por error.`);\n return {status:'OK',detail:`${label}: ${p.start} - ${p.end}`};\n}\nfunction payrollFingerprint(x){return (x.records||[]).map(r=>[String(r.code||''),norm(r.name),round(r.ss),round(r.se),round(r.gross)].join('|')).sort().join('||');}\nconst periodChecks=[validatePayrollPeriod('Nómina Q1',q1.period,1),validatePayrollPeriod('Nómina Q2',q2.period,2)];\nconst fpQ1=payrollFingerprint(q1),fpQ2=payrollFingerprint(q2);\nif(fpQ1&&fpQ2&&fpQ1===fpQ2)throw new Error('Las nóminas Q1 y Q2 contienen exactamente los mismos empleados y montos. Se detuvo el cruce porque probablemente se cargó la misma quincena dos veces.');\n// Inferencia dinámica de tasas del trabajador desde los propios recibos, para no depender de tasas hardcodeadas año tras año.\nfunction median(a){const x=a.filter(Number.isFinite).sort((p,q)=>p-q);if(!x.length)return null;const m=Math.floor(x.length/2);return x.length%2?x[m]:(x[m-1]+x[m])/2;}\nconst ratioSS=[],ratioSE=[];\nfor(const r of [...(q1.records||[]),...(q2.records||[])]){const g=Number(r.gross)||0,ss=Number(r.ss)||0,se=Number(r.se)||0;if(g>=20&&ss>0){const x=ss/g;if(x>.03&&x<.20)ratioSS.push(x);}if(g>=20&&se>0){const x=se/g;if(x>.001&&x<.05)ratioSE.push(x);}}\nconst fallback=config.defaultRates||{ssRegular:.0975,se:.0125,ssDtm:.0725};\nconst ssMed=median(ratioSS),seMed=median(ratioSE),ssRegular=Number((ssMed??fallback.ssRegular).toFixed(4)),seRate=Number((seMed??fallback.se).toFixed(4));\nconst dtmRatios=[];for(const r of pdfs.includedRecords||[]){if(r.category!=='Liquidación'||Number(r.dtmBase||0)<=1)continue;const x=(Number(r.ss||0)-Number(r.regularBase||0)*ssRegular)/Number(r.dtmBase);if(x>.03&&x<.15)dtmRatios.push(x);}\nconst dtmMed=median(dtmRatios),ssDtm=Number((dtmMed??fallback.ssDtm).toFixed(4));\nconst rateInfo={ssRegular,se:seRate,ssDtm,samplesSS:ratioSS.length,samplesSE:ratioSE.length,samplesDTM:dtmRatios.length,usedFallbackSS:ssMed===null,usedFallbackSE:seMed===null,usedFallbackDTM:dtmMed===null};\nif(ratioSS.length<(config.minRateSamples||20)||ratioSE.length<(config.minRateSamples||20))throw new Error(`No hubo suficientes registros para inferir las tasas de S.S./S.E. de forma segura (${ratioSS.length}/${ratioSE.length}). Se detuvo el cruce.`);\nconst plans=(css.employees||[]).map((p,index)=>({...p,_idx:index,expectedWorkerSS:round(Number(p.salaryAdjusted||0)*ssRegular+Number(p.dtmBase||0)*ssDtm),expectedWorkerSE:round(Number(p.salaryAdjusted||0)*seRate)}));\n// Une todas las fuentes por Employee # cuando existe; cuando el PDF pierde el código, usa el nombre exacto conservadoramente.\nconst observations=[];for(const r of q1.records||[])observations.push({...r,kind:'q1'});for(const r of q2.records||[])observations.push({...r,kind:'q2'});for(const r of pdfs.includedRecords||[]){const kind=r.category==='Extraordinaria'?'extra':r.category==='Vacaciones'?'vac':'liq';observations.push({...r,kind});}\nconst groups=new Map(),nameCodes=new Map();\nfor(const r of observations){const code=clean(r.code).replace(/^0+(?=\\d)/,''),nn=norm(r.name);if(!code)continue;const k=`C:${code}`;if(!groups.has(k))groups.set(k,{key:k,code,names:[],obs:[]});const g=groups.get(k);if(r.name&&!g.names.includes(r.name))g.names.push(r.name);g.obs.push(r);if(!nameCodes.has(nn))nameCodes.set(nn,new Set());nameCodes.get(nn).add(code);}\nfor(const r of observations){const code=clean(r.code).replace(/^0+(?=\\d)/,'');if(code)continue;const nn=norm(r.name),codes=nameCodes.get(nn)||new Set();let g;if(codes.size===1)g=groups.get(`C:${[...codes][0]}`);else{const k=`N:${nn}`;if(!groups.has(k))groups.set(k,{key:k,code:'',names:[],obs:[]});g=groups.get(k);}if(r.name&&!g.names.includes(r.name))g.names.push(r.name);g.obs.push(r);}\nfor(const g of groups.values()){for(const k of ['q1SS','q1SE','q2SS','q2SE','extraSS','extraSE','vacSS','vacSE','liqSS','liqSE'])g[k]=0;for(const r of g.obs){const ss=Number(r.ss)||0,se=Number(r.se)||0;g[`${r.kind}SS`]+=ss;g[`${r.kind}SE`]+=se;}g.name=g.names[0]||'';g.totalSS=round(g.q1SS+g.q2SS+g.extraSS+g.vacSS+g.liqSS);g.totalSE=round(g.q1SE+g.q2SE+g.extraSE+g.vacSE+g.liqSE);}\nconst sources=[...groups.values()];\n// Similaridad conservadora OPTIMIZADA: conserva las mismas reglas de matching,\n// pero evita comparar cada fuente contra cada empleado CSS con Levenshtein completo.\n// Primero crea índices baratos (tokens, prefijos, iniciales y montos) y solo ejecuta\n// la similitud costosa sobre un conjunto reducido de candidatos plausibles.\nfunction levRaw(a,b){\n a=String(a||''); b=String(b||'');\n if(a===b)return 0;\n if(!a.length)return b.length;\n if(!b.length)return a.length;\n if(a.length>b.length){const t=a;a=b;b=t;}\n let prev=new Uint16Array(a.length+1),cur=new Uint16Array(a.length+1);\n for(let i=0;i<=a.length;i++)prev[i]=i;\n for(let j=1;j<=b.length;j++){\n cur[0]=j;\n const bj=b.charCodeAt(j-1);\n for(let i=1;i<=a.length;i++){\n const cost=a.charCodeAt(i-1)===bj?0:1;\n const ins=cur[i-1]+1,del=prev[i]+1,sub=prev[i-1]+cost;\n cur[i]=Math.min(ins,del,sub);\n }\n const tmp=prev;prev=cur;cur=tmp;\n }\n return prev[a.length];\n}\nfunction compactNorm(v){return norm(v).replace(/ /g,'');}\nfunction simPrepared(a,b){a=String(a||'');b=String(b||'');return !a||!b?0:1-levRaw(a,b)/Math.max(a.length,b.length);}\nfunction tokenCovPrepared(A,B){\n if(!A.length||!B.length)return 0;\n const avg=(X,Y)=>{\n let total=0;\n for(const x of X){\n let best=0;\n for(const y of Y){\n const s=simPrepared(x,y);\n if(s>best)best=s;\n if(best===1)break;\n }\n total+=best;\n }\n return total/X.length;\n };\n return (avg(A,B)+avg(B,A))/2;\n}\nfunction sourceParts(name){\n const raw=String(name||'');let l='',f='';\n if(raw.includes(',')){const pos=raw.indexOf(',');l=raw.slice(0,pos);f=raw.slice(pos+1);}\n else{const t=norm(raw).split(' ');l=t.shift()||'';f=t.join(' ');}\n const L=core(l),F=core(f);\n return {\n last:norm(l),first:norm(f),last1:L[0]||'',first1:F[0]||'',\n lastC:compactNorm(l),firstC:compactNorm(f),\n last1C:compactNorm(L[0]||''),first1C:compactNorm(F[0]||'')\n };\n}\nfunction planParts(p){\n const L=core(p.lastname),F=core(p.firstname);\n return {\n last:norm(p.lastname),first:norm(p.firstname),last1:L[0]||'',first1:F[0]||'',\n lastC:compactNorm(p.lastname),firstC:compactNorm(p.firstname),\n last1C:compactNorm(L[0]||''),first1C:compactNorm(F[0]||'')\n };\n}\nfunction uniqTokens(v){return [...new Set(core(v).map(compactNorm).filter(Boolean))];}\nfunction addIndex(map,k,v){if(!k)return;if(!map.has(k))map.set(k,[]);map.get(k).push(v);}\nfunction addBucket(set,map,k){for(const v of map.get(k)||[])set.add(v);}\nfunction prefix(v,n){v=String(v||'');return v.length>=n?v.slice(0,n):v;}\nfunction amountClass(dss,dse){if(dss<=.05&&dse<=.05)return 1;if(dss<=.12&&dse<=.12)return .95;if(dss<=.25&&dse<=.25)return .85;if(dss<=.5&&dse<=.5)return .70;if(dss<=1&&dse<=1)return .50;return 0;}\n\nconst planMeta=plans.map((p,pi)=>({\n p,pi,pp:planParts(p),tokens:uniqTokens(p.name),\n pairKey:`${p.expectedWorkerSS.toFixed(2)}|${p.expectedWorkerSE.toFixed(2)}`\n}));\nconst sourceMeta=sources.map((s,si)=>({s,si,sp:sourceParts(s.name),tokens:uniqTokens(s.name)}));\n\nconst pairFreq=new Map(),firstFreq=new Map(),lastFreq=new Map();\nfor(const m of planMeta){\n pairFreq.set(m.pairKey,(pairFreq.get(m.pairKey)||0)+1);\n firstFreq.set(m.pp.first1,(firstFreq.get(m.pp.first1)||0)+1);\n lastFreq.set(m.pp.last1,(lastFreq.get(m.pp.last1)||0)+1);\n}\n\nconst byToken=new Map(),byLast2=new Map(),byLast3=new Map(),byFirst2=new Map(),byFirst3=new Map(),byInitialPair=new Map(),byAmount=new Map();\nfunction amountKey(ss,se){return `${Math.round((Number(ss)||0)*4)}|${Math.round((Number(se)||0)*4)}`;}\nfor(const m of planMeta){\n for(const t of m.tokens)addIndex(byToken,t,m.pi);\n addIndex(byLast2,prefix(m.pp.last1C,2),m.pi);\n addIndex(byLast3,prefix(m.pp.last1C,3),m.pi);\n addIndex(byFirst2,prefix(m.pp.first1C,2),m.pi);\n addIndex(byFirst3,prefix(m.pp.first1C,3),m.pi);\n addIndex(byInitialPair,`${m.pp.last1C[0]||''}|${m.pp.first1C[0]||''}`,m.pi);\n addIndex(byAmount,amountKey(m.p.expectedWorkerSS,m.p.expectedWorkerSE),m.pi);\n}\n\nconst candidates=[],candidatesBySource=new Map();\nconst tierBuckets=[[],[],[],[],[],[]];\nfor(const sm of sourceMeta){\n const s=sm.s,sp=sm.sp,pool=new Set();\n\n // Evidencia de identidad exacta o por prefijo.\n for(const t of sm.tokens)addBucket(pool,byToken,t);\n addBucket(pool,byLast3,prefix(sp.last1C,3));\n addBucket(pool,byFirst3,prefix(sp.first1C,3));\n addBucket(pool,byLast2,prefix(sp.last1C,2));\n addBucket(pool,byFirst2,prefix(sp.first1C,2));\n addBucket(pool,byInitialPair,`${sp.last1C[0]||''}|${sp.first1C[0]||''}`);\n\n // Evidencia de monto: busca hasta +/- B/.1.00 en pasos de B/.0.25.\n const ssQ=Math.round((Number(s.totalSS)||0)*4),seQ=Math.round((Number(s.totalSE)||0)*4);\n for(let ds=-4;ds<=4;ds++)for(let de=-4;de<=4;de++)addBucket(pool,byAmount,`${ssQ+ds}|${seQ+de}`);\n\n // Red de seguridad: si los índices produjeron muy pocos candidatos, agrega los\n // 12 CSS más cercanos por monto. Este barrido solo hace aritmética, no Levenshtein.\n if(pool.size<12){\n const closest=[];\n for(const pm of planMeta){\n const d=Math.abs((Number(s.totalSS)||0)-pm.p.expectedWorkerSS)+Math.abs((Number(s.totalSE)||0)-pm.p.expectedWorkerSE);\n if(closest.length<12){closest.push([d,pm.pi]);closest.sort((a,b)=>a[0]-b[0]);}\n else if(da[0]-b[0]);}\n }\n for(const [,pi] of closest)pool.add(pi);\n }\n\n const local=[];\n for(const pi of pool){\n const pm=planMeta[pi],p=pm.p,pp=pm.pp;\n const lastFirst=simPrepared(sp.last1C,pp.last1C);\n const lastFull=simPrepared(sp.lastC,pp.lastC);\n const firstSim=Math.max(simPrepared(sp.first1C,pp.first1C),simPrepared(sp.firstC,pp.firstC));\n const gen=tokenCovPrepared(sm.tokens,pm.tokens);\n const dss=Math.abs(s.totalSS-p.expectedWorkerSS),dse=Math.abs(s.totalSE-p.expectedWorkerSE),am=amountClass(dss,dse);\n const pairUnique=(pairFreq.get(pm.pairKey)||0)===1;\n const firstUnique=(firstFreq.get(pp.first1)||0)===1,lastUnique=(lastFreq.get(pp.last1)||0)===1;\n let tier=0,method='';\n if(sp.last1&&sp.last1===pp.last1&&firstSim>=.84){tier=1;method='APELLIDO + NOMBRE';}\n else if(lastFirst>=.78&&firstSim>=.90&&am>=.85){tier=2;method='VARIANTE DE APELLIDO + NOMBRE + MONTO';}\n else if(gen>=.86&&am>=.70&&(lastFirst>=.65||firstSim>=.80)){tier=3;method='NOMBRE FUZZY + MONTO';}\n else if(firstSim>=.95&&(firstUnique||pairUnique)&&am>=.95){tier=4;method='NOMBRE + MONTO ÚNICO';}\n else if(sp.last1&&sp.last1===pp.last1&&lastUnique&&am>=.95){tier=4;method='APELLIDO ÚNICO + MONTO';}\n else if(gen>=.72&&pairUnique&&am>=.95){tier=5;method='MONTO ÚNICO + EVIDENCIA DE NOMBRE';}\n if(!tier)continue;\n const nameScore=.38*lastFirst+.32*firstSim+.18*gen+.12*lastFull;\n const score=(6-tier)*10+.58*nameScore+.42*am-.002*Math.min(dss,10);\n const c={tier,score,si:sm.si,pi,nameScore,lastFirst,firstSim,gen,am,dss,dse,method};\n candidates.push(c);local.push(c);tierBuckets[tier].push(c);\n }\n candidatesBySource.set(sm.si,local);\n}\n\nconst usedS=new Set(),usedP=new Set(),matches=[];\nfor(let tier=1;tier<=5;tier++){\n const arr=tierBuckets[tier].sort((a,b)=>b.score-a.score);\n for(const c of arr){\n if(usedS.has(c.si)||usedP.has(c.pi))continue;\n usedS.add(c.si);usedP.add(c.pi);\n matches.push({...c,source:sources[c.si],plan:plans[c.pi]});\n }\n}\n// Señala matches que cuadran por monto pero cuya identidad es débil, y empates de identidad indistinguibles.\nfor(const m of matches){\n const local=candidatesBySource.get(m.si)||[];\n let ambiguous=false;\n for(const c of local){\n if(c.pi===m.pi)continue;\n if(c.tier<=m.tier&&c.dss<=.05&&c.dse<=.05&&Math.abs(c.score-m.score)<.03){ambiguous=true;break;}\n }\n m.ambiguous=ambiguous;\n m.identityReview=m.tier>=4||m.ambiguous;\n m.confidence=m.tier===1&&!m.ambiguous?'ALTA':m.tier<=3&&!m.ambiguous?'MEDIA':'REVISAR';\n}\nconst onlySource=sources.filter((_,i)=>!usedS.has(i)),onlyPlan=plans.filter((_,i)=>!usedP.has(i)),identityReview=matches.filter(m=>m.identityReview);\nconst tolerance=Number(config.amountTolerance??.10),amountDiffs=matches.filter(m=>Math.abs(m.source.totalSS-m.plan.expectedWorkerSS)>tolerance||Math.abs(m.source.totalSE-m.plan.expectedWorkerSE)>tolerance);\nconst totalSourceSS=round(sources.reduce((s,r)=>s+r.totalSS,0)),totalSourceSE=round(sources.reduce((s,r)=>s+r.totalSE,0)),totalExpectedSS=round(plans.reduce((s,r)=>s+r.expectedWorkerSS,0)),totalExpectedSE=round(plans.reduce((s,r)=>s+r.expectedWorkerSE,0)),diffSS=round(totalSourceSS-totalExpectedSS),diffSE=round(totalSourceSE-totalExpectedSE);\nconst fileReview=(pdfs.validations||[]).filter(v=>['REVISAR','EXCLUIDO'].includes(v.status)).length+periodChecks.filter(v=>v.status==='REVISAR').length+(rateInfo.usedFallbackDTM?1:0);\nconst hasFindings=Boolean(onlySource.length||onlyPlan.length||amountDiffs.length||identityReview.length||fileReview);\nconst statusText=hasFindings?'COMPLETADO · REVISAR HALLAZGOS':'COMPLETADO · SIN DIFERENCIAS',statusDetail=hasFindings?`${onlySource.length+onlyPlan.length} diferencia(s) de presencia, ${amountDiffs.length} diferencia(s) de monto, ${identityReview.length} identidad(es) con match a revisar y ${fileReview} validación(es) documental(es).`:'Las fuentes coinciden con la Planilla CSS dentro de la tolerancia definida.';\nconst title=css.reportTitle,period=css.periodoLabel;\nconst summaryRows=[[title],[period],[],['Métrica','Resultado','Interpretación'],['Estado',statusText,statusDetail],['Empleados únicos en fuentes',sources.length,'Q1 + Q2 + movimientos complementarios incluidos'],['Empleados Planilla CSS',plans.length,'Registros oficiales reconstruidos y validados'],['Coincidencias asignadas',matches.length,'Matching conservador por apellido/nombre y monto'],['Identidades a revisar',identityReview.length,'Matches por evidencia débil o ambigua; no se ocultan'],['Solo en fuentes',onlySource.length,'Sin contraparte CSS con evidencia suficiente'],['Solo en Planilla CSS',onlyPlan.length,'Sin contraparte en fuentes con evidencia suficiente'],['Diferencias de monto',amountDiffs.length,`Diferencia absoluta > B/. ${tolerance.toFixed(2)} en S.S. o S.E.`],['Tasa S.S. regular detectada',`${(ssRegular*100).toFixed(2)}%`,`${rateInfo.samplesSS} observaciones; fallback: ${rateInfo.usedFallbackSS?'sí':'no'}`],['Tasa S.E. detectada',`${(seRate*100).toFixed(2)}%`,`${rateInfo.samplesSE} observaciones; fallback: ${rateInfo.usedFallbackSE?'sí':'no'}`],['Tasa S.S. DTM detectada',`${(ssDtm*100).toFixed(2)}%`,`${rateInfo.samplesDTM} liquidaciones con DTM; fallback: ${rateInfo.usedFallbackDTM?'sí':'no'}`],['S.S. fuentes',totalSourceSS,'Suma real descontada en las fuentes'],['S.S. esperado CSS',totalExpectedSS,'Calculado con tasas detectadas sobre salario ajustado + DTM'],['Diferencia total S.S.',diffSS,'Fuentes menos esperado CSS'],['S.E. fuentes',totalSourceSE,'Suma real descontada en las fuentes'],['S.E. esperado CSS',totalExpectedSE,'Calculado con tasa detectada sobre salario ajustado'],['Diferencia total S.E.',diffSE,'Fuentes menos esperado CSS'],['Seguro Social total oficial CSS',css.totals.seguroSocial,'Validado contra reconstrucción individual'],['Seguro Educativo total oficial CSS',css.totals.seguroEducativo,'Validado contra reconstrucción individual'],['Salario ajustado total CSS',css.totals.salarioAjustado,'Validado contra reconstrucción individual'],['Nómina Q1',`${q1.fileName} (${q1.format})`,''],['Nómina Q2',`${q2.fileName} (${q2.format})`,''],['Planilla CSS',css.fileName,'']];\nconst identityRows=[[title],[period],[],['Estado','Código fuente','Nombre en fuentes','Documento CSS','Nombre Planilla CSS','S.S. fuente','S.S. esperado','Método','Confianza','Observación']];\nconst amountRows=[[title],[period],[],['Estado','Código','Nombre fuentes','Nombre CSS','S.S. Q1','S.S. Q2','S.S. Extra','S.S. Vacaciones','S.S. Liquidaciones','S.S. total','S.S. esperado','Dif. S.S.','S.E. total','S.E. esperado','Dif. S.E.']];\nfor(const m of matches){const s=m.source,p=m.plan,dss=round(s.totalSS-p.expectedWorkerSS),dse=round(s.totalSE-p.expectedWorkerSE),amtOk=Math.abs(dss)<=tolerance&&Math.abs(dse)<=tolerance;let st=amtOk?'COINCIDE':'COINCIDE · REVISAR MONTO',obs='';if(m.identityReview){st='COINCIDE · REVISAR IDENTIDAD';obs=m.ambiguous?'Existen dos o más candidatos CSS prácticamente indistinguibles por nombre y monto.':`Match conservador por ${m.method}; revisar discrepancia de nombre/apellido.`;}identityRows.push([st,s.code,s.name,p.identity,p.name,s.totalSS,p.expectedWorkerSS,m.method,m.confidence,obs]);amountRows.push([amtOk?'OK':'REVISAR',s.code,s.name,p.name,round(s.q1SS),round(s.q2SS),round(s.extraSS),round(s.vacSS),round(s.liqSS),s.totalSS,p.expectedWorkerSS,dss,s.totalSE,p.expectedWorkerSE,dse]);}\nfor(const s of onlySource){identityRows.push(['SOLO FUENTES',s.code,s.name,'','',s.totalSS,'','','','Sin contraparte CSS con evidencia suficiente']);amountRows.push(['SOLO FUENTES',s.code,s.name,'',round(s.q1SS),round(s.q2SS),round(s.extraSS),round(s.vacSS),round(s.liqSS),s.totalSS,'','',s.totalSE,'','']);}\nfor(const p of onlyPlan){identityRows.push(['SOLO PLANILLA CSS','','',p.identity,p.name,'',p.expectedWorkerSS,'','','Sin contraparte en fuentes con evidencia suficiente']);amountRows.push(['SOLO PLANILLA CSS','','',p.name,'','','','','','',p.expectedWorkerSS,'','',p.expectedWorkerSE,'']);}\nconst rank=x=>String(x).includes('SOLO')?0:String(x).includes('REVISAR')?1:2;identityRows.splice(4,identityRows.length-4,...identityRows.slice(4).sort((a,b)=>rank(a[0])-rank(b[0])||String(a[2]||a[4]).localeCompare(String(b[2]||b[4]))));amountRows.splice(4,amountRows.length-4,...amountRows.slice(4).sort((a,b)=>rank(a[0])-rank(b[0])||Math.abs(Number(b[11])||0)-Math.abs(Number(a[11])||0)));\nconst detailRows=[[title],[period],[],['Estado','Tipo','Archivo','Código','Nombre','S.S.','S.E.','Bruto/Base','Base regular','Base DTM','Fecha/Período','Transacción','Motivo']];for(const r of q1.records||[])detailRows.push(['INCLUIDO','Nómina Q1',q1.fileName,r.code,r.name,r.ss,r.se,r.gross||'',r.gross||'',0,q1.period?`${q1.period.start} - ${q1.period.end}`:'','','']);for(const r of q2.records||[])detailRows.push(['INCLUIDO','Nómina Q2',q2.fileName,r.code,r.name,r.ss,r.se,r.gross||'',r.gross||'',0,q2.period?`${q2.period.start} - ${q2.period.end}`:'','','']);for(const r of pdfs.records||[])detailRows.push([r.included?'INCLUIDO':'EXCLUIDO',r.category,r.fileName,r.code,r.name,r.ss,r.se,r.gross||'',r.regularBase||'',r.dtmBase||'',r.date||'',r.transaction||'',r.reason||'']);\nconst cssRows=[[title],[period],[],['Seguro Social #','Documento','Tipo doc.','Nombre CSS','Sexo','Salario','Salario ajustado','DTM base','S.S. trabajador esperado','S.E. trabajador esperado','S.S. total CSS','S.E. total CSS','DTM total CSS']];for(const p of plans)cssRows.push([p.ssNumber,p.identity,p.docType,p.name,p.sex,p.salary,p.salaryAdjusted,p.dtmBase,p.expectedWorkerSS,p.expectedWorkerSE,p.planillaSS,p.planillaSE,p.planillaDTM]);\nconst valRows=[[title],[period],[],['Estado','Categoría','Archivo','Detalle']];valRows.push(['OK','Nómina Q1',q1.fileName,`${q1.employeeCount} empleado(s); S.S. ${money(q1.totalSS)}; S.E. ${money(q1.totalSE)}; formato ${q1.format}; ${q1.integrityStatus}; códigos ausentes ${q1.missingCodeCount||0}`]);valRows.push(['OK','Nómina Q2',q2.fileName,`${q2.employeeCount} empleado(s); S.S. ${money(q2.totalSS)}; S.E. ${money(q2.totalSE)}; formato ${q2.format}; ${q2.integrityStatus}; códigos ausentes ${q2.missingCodeCount||0}`]);for(const v of periodChecks)valRows.push([v.status,'Período','Nóminas',v.detail]);valRows.push(['OK','Planilla CSS',css.fileName,`${css.employeeCount} empleado(s); período ${period}; integridad: ${css.integrity.map(x=>`${x.metric} dif ${money(x.diff)}`).join(' · ')}`]);valRows.push([rateInfo.usedFallbackDTM?'REVISAR':'OK','Tasas','Motor de cálculo',`S.S. regular ${(ssRegular*100).toFixed(2)}% (${rateInfo.samplesSS} muestras) · S.E. ${(seRate*100).toFixed(2)}% (${rateInfo.samplesSE}) · S.S. DTM ${(ssDtm*100).toFixed(2)}% (${rateInfo.samplesDTM})`]);for(const v of pdfs.validations||[])valRows.push([v.status,v.category,v.fileName,v.detail]);\nconst topFindings=[];for(const s of onlySource.slice(0,4))topFindings.push(`Solo fuentes: ${s.name}${s.code?` (#${s.code})`:''} · S.S. ${money(s.totalSS)}`);for(const p of onlyPlan.slice(0,4))topFindings.push(`Solo CSS: ${p.name} · ${p.identity||'sin documento'} · S.S. esperado ${money(p.expectedWorkerSS)}`);for(const m of identityReview.slice(0,4))topFindings.push(`Identidad: ${m.source.name} ↔ ${m.plan.name} · ${m.method}`);for(const m of amountDiffs.slice(0,4))topFindings.push(`Monto: ${m.source.name} · Dif. S.S. ${money(round(m.source.totalSS-m.plan.expectedWorkerSS))} · Dif. S.E. ${money(round(m.source.totalSE-m.plan.expectedWorkerSE))}`);\nconst summary={statusText,statusDetail,hasFindings,periodoLabel:period,sourceCount:sources.length,cssCount:plans.length,matches:matches.length,identityReview:identityReview.length,onlySource:onlySource.length,onlyPlan:onlyPlan.length,amountDifferences:amountDiffs.length,fileReview,totalSourceSS,totalExpectedSS,diffSS,totalSourceSE,totalExpectedSE,diffSE,rateInfo,topFindings,files:{q1:q1.fileName,q2:q2.fileName,css:css.fileName,pdfs:pdfs.files||[]},stagingQ1:q1.stagingSpreadsheetId||'',stagingQ2:q2.stagingSpreadsheetId||'',stagingCSS:css.stagingSpreadsheetId||''};\nconst createSheetBody=JSON.stringify({properties:{title},sheets:[{properties:{title:'Resumen'}},{properties:{title:'Cruce Identidad'}},{properties:{title:'Cruce Montos CSS'}},{properties:{title:'Detalle Fuentes'}},{properties:{title:'Detalle Planilla CSS'}},{properties:{title:'Validaciones'}}]});\nconst writeReportBody=JSON.stringify({valueInputOption:'RAW',data:[{range:'Resumen!A1',majorDimension:'ROWS',values:summaryRows},{range:'Cruce Identidad!A1',majorDimension:'ROWS',values:identityRows},{range:'Cruce Montos CSS!A1',majorDimension:'ROWS',values:amountRows},{range:'Detalle Fuentes!A1',majorDimension:'ROWS',values:detailRows},{range:'Detalle Planilla CSS!A1',majorDimension:'ROWS',values:cssRows},{range:'Validaciones!A1',majorDimension:'ROWS',values:valRows}]});\nreturn [{json:{reportTitle:title,periodoLabel:period,summary,summaryRows,identityRows,amountRows,detailRows,cssRows,valRows,createSheetBody,writeReportBody}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 62144, 34832 ], "id": "6e10d4ed-e0ff-4f3c-bbd4-503735047765", "name": "Construir reporte producción" }, { "parameters": { "method": "POST", "url": "https://sheets.googleapis.com/v4/spreadsheets", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleSheetsOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ $json.createSheetBody }}", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 62384, 34832 ], "id": "bac4af9b-bb77-4456-9e17-be8fa3af8587", "name": "Crear Google Sheet Reporte", "credentials": { "googleSheetsOAuth2Api": { "id": "K0hDZh3a85MpOHCs", "name": "Google Sheets account 2" } } }, { "parameters": { "method": "POST", "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.spreadsheetId }}/values:batchUpdate", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleSheetsOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ $('Construir reporte producción').first().json.writeReportBody }}", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 62624, 34832 ], "id": "acc027fa-0b8e-42ae-81d3-e8fb393bf755", "name": "Escribir reporte", "credentials": { "googleSheetsOAuth2Api": { "id": "K0hDZh3a85MpOHCs", "name": "Google Sheets account 2" } } }, { "parameters": { "jsCode": "const created=$items('Crear Google Sheet Reporte')[0].json; const report=$items('Construir reporte producción')[0].json;\nconst rowsByName={'Resumen':report.summaryRows,'Cruce Identidad':report.identityRows,'Cruce Montos CSS':report.amountRows,'Detalle Fuentes':report.detailRows,'Detalle Planilla CSS':report.cssRows,'Validaciones':report.valRows};\nconst requests=[];\nfor(const s of created.sheets||[]){\n const name=s.properties.title,id=s.properties.sheetId,rows=rowsByName[name]||[]; const cols=Math.max(1,...rows.map(r=>r.length)); const rowCount=Math.max(rows.length,5);\n requests.push({updateSheetProperties:{properties:{sheetId:id,gridProperties:{hideGridlines:true,frozenRowCount:4}},fields:'gridProperties.hideGridlines,gridProperties.frozenRowCount'}});\n requests.push({repeatCell:{range:{sheetId:id,startRowIndex:0,endRowIndex:rowCount,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{textFormat:{fontFamily:'Arial',fontSize:10,foregroundColor:{red:0.290,green:0.290,blue:0.290}},verticalAlignment:'MIDDLE'}},fields:'userEnteredFormat(textFormat,verticalAlignment)'}});\n requests.push({mergeCells:{range:{sheetId:id,startRowIndex:0,endRowIndex:1,startColumnIndex:0,endColumnIndex:cols},mergeType:'MERGE_ALL'}});\n requests.push({mergeCells:{range:{sheetId:id,startRowIndex:1,endRowIndex:2,startColumnIndex:0,endColumnIndex:cols},mergeType:'MERGE_ALL'}});\n requests.push({repeatCell:{range:{sheetId:id,startRowIndex:0,endRowIndex:1,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{backgroundColor:{red:0.310,green:0.459,blue:0.545},textFormat:{fontFamily:'Arial',fontSize:13,bold:true,foregroundColor:{red:1,green:1,blue:1}},horizontalAlignment:'LEFT',borders:{bottom:{style:'SOLID_THICK',color:{red:0.424,green:0.761,blue:0.290}}}}},fields:'userEnteredFormat'}});\n requests.push({repeatCell:{range:{sheetId:id,startRowIndex:1,endRowIndex:2,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{backgroundColor:{red:0.357,green:0.498,blue:0.584},textFormat:{fontFamily:'Arial',fontSize:10,italic:true,foregroundColor:{red:1,green:1,blue:1}}}},fields:'userEnteredFormat'}});\n requests.push({repeatCell:{range:{sheetId:id,startRowIndex:3,endRowIndex:4,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{backgroundColor:{red:0.310,green:0.459,blue:0.545},textFormat:{fontFamily:'Arial',fontSize:10,bold:true,foregroundColor:{red:1,green:1,blue:1}},horizontalAlignment:'CENTER',wrapStrategy:'WRAP'}},fields:'userEnteredFormat'}});\n requests.push({autoResizeDimensions:{dimensions:{sheetId:id,dimension:'COLUMNS',startIndex:0,endIndex:cols}}});\n requests.push({updateDimensionProperties:{range:{sheetId:id,dimension:'ROWS',startIndex:0,endIndex:1},properties:{pixelSize:34},fields:'pixelSize'}});\n if(rowCount>4){\n const rules=[\n ['REVISAR',{red:0.992,green:0.925,blue:0.918},{red:0.65,green:0.15,blue:0.12}],\n ['SOLO',{red:1.0,green:0.973,blue:0.882},{red:0.55,green:0.35,blue:0.0}],\n ['ERROR',{red:0.992,green:0.925,blue:0.918},{red:0.65,green:0.15,blue:0.12}],\n ['EXCLUIDO',{red:1.0,green:0.973,blue:0.882},{red:0.55,green:0.35,blue:0.0}]\n ];\n for(const [txt,bg,fg] of rules)requests.push({addConditionalFormatRule:{rule:{ranges:[{sheetId:id,startRowIndex:4,endRowIndex:rowCount,startColumnIndex:0,endColumnIndex:1}],booleanRule:{condition:{type:'TEXT_CONTAINS',values:[{userEnteredValue:txt}]},format:{backgroundColor:bg,textFormat:{bold:true,foregroundColor:fg}}}},index:0}});\n}\n}\nreturn [{json:{formatBody:{requests}}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 62864, 34832 ], "id": "023c71e6-974a-4ac4-9c19-cdeb97719dd6", "name": "Preparar formato reporte" }, { "parameters": { "method": "POST", "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $('Crear Google Sheet Reporte').item.json.spreadsheetId }}:batchUpdate", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleSheetsOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify($json.formatBody) }}", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 63104, 34832 ], "id": "176ef0a3-091f-493a-ac08-53addee2e6b1", "name": "Formatear reporte", "credentials": { "googleSheetsOAuth2Api": { "id": "K0hDZh3a85MpOHCs", "name": "Google Sheets account 2" } } }, { "parameters": { "jsCode": "const init=$items('Inicializar ejecución')[0].json; const id=$items('Crear Google Sheet Reporte')[0].json.spreadsheetId; const url=`https://docs.google.com/spreadsheets/d/${id}/edit`;\nreturn init.recipients.map(emailAddress=>({json:{emailAddress,spreadsheetId:id,spreadsheetUrl:url}}));" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 63344, 34832 ], "id": "f12bf3d7-f5c9-4b12-93bc-a0da50c9c629", "name": "Preparar permisos" }, { "parameters": { "method": "POST", "url": "=https://www.googleapis.com/drive/v3/files/{{ $json.spreadsheetId }}/permissions?sendNotificationEmail=false&supportsAllDrives=true", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleDriveOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ role: 'writer', type: 'user', emailAddress: $json.emailAddress }) }}", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 63584, 34832 ], "id": "63765f02-d4c8-4911-bf9d-7f768ed382ff", "name": "Compartir reporte como editor", "credentials": { "googleDriveOAuth2Api": { "id": "g23xdGLZRzBGqKgH", "name": "Isaac - Google Drive" } }, "continueOnFail": true }, { "parameters": { "jsCode": "const init=$items('Inicializar ejecución')[0].json,r=$items('Construir reporte producción')[0].json,s=r.summary,id=$items('Crear Google Sheet Reporte')[0].json.spreadsheetId,url=`https://docs.google.com/spreadsheets/d/${id}/edit`;\nconst nf=n=>Number(n||0).toLocaleString('en-US'),money=n=>`B/. ${Number(n||0).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}`,esc=x=>String(x??'').replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));\nconst has=s.hasFindings,bg=has?'#FFF8E1':'#E8F5E9',state=has?'#8A5A00':'#27632A',findings=(s.topFindings||[]).length?`
Principales hallazgos
${s.topFindings.map(x=>`
• ${esc(x)}
`).join('')}
`:'';\nconst html=`
 
\"GomezLee
SEGURIDAD SOCIAL · PANAMÁ

Cruce mensual de CSS

${esc(r.periodoLabel)}

${esc(s.statusText)}
${esc(s.statusDetail)}
MétricaResultado
Empleados fuentes / CSS${nf(s.sourceCount)} / ${nf(s.cssCount)}
Solo fuentes / solo CSS${nf(s.onlySource)} / ${nf(s.onlyPlan)}
Identidades a revisar${nf(s.identityReview)}
Diferencias de monto${nf(s.amountDifferences)}
Diferencia total S.S.${money(s.diffSS)}
Diferencia total S.E.${money(s.diffSE)}
${findings}
Abrir reporte en Google Sheets
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)}%
Archivos base:
Nómina Q1: ${esc(s.files.q1||'')}
Nómina Q2: ${esc(s.files.q2||'')}
Planilla CSS: ${esc(s.files.css||'')}
GOMEZLEE MARKETING · Uso interno
`;\nreturn [{json:{sendTo:init.recipients.join(','),subject:`${has?'[REVISAR]':'[OK]'} Cruce Seguridad Social Panamá - ${r.periodoLabel}`,html,spreadsheetId:id,spreadsheetUrl:url}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 63824, 34832 ], "id": "40a9e530-9b60-4450-bfa3-0d7d5e2e95d3", "name": "Preparar correo HTML GLM" }, { "parameters": { "sendTo": "={{ $json.sendTo }}", "subject": "={{ $json.subject }}", "message": "={{ $json.html }}", "options": { "appendAttribution": false } }, "type": "n8n-nodes-base.gmail", "typeVersion": 2.2, "position": [ 64064, 34832 ], "id": "33c125b7-9b00-4e68-b197-2cfcd8651f55", "name": "Enviar correo resumen", "webhookId": "ae5f87e7-df2b-4349-8eb5-aac208accb96", "executeOnce": true, "credentials": { "gmailOAuth2": { "id": "UDcO1FLJqA453V2D", "name": "Gmail account 3" } } }, { "parameters": { "jsCode": "const init=$items('Inicializar ejecución')[0].json,r=$items('Construir reporte producción')[0].json,s=r.summary,id=$items('Crear Google Sheet Reporte')[0].json.spreadsheetId,reportUrl=`https://docs.google.com/spreadsheets/d/${id}/edit`,formUrl=init.formUrl;\nconst html=`
\"GomezLee
SEGURIDAD SOCIAL · PANAMÁ

Cruce mensual completado

${r.periodoLabel}

${s.statusText}
${s.statusDetail}

Fuentes / CSS: ${s.sourceCount} / ${s.cssCount} · Solo fuentes / CSS: ${s.onlySource} / ${s.onlyPlan} · Identidades a revisar: ${s.identityReview} · Diferencias de monto: ${s.amountDifferences}

Abrir reporteRealizar otro cruce

Si el botón no responde, usa este enlace: volver al formulario.

`;\nreturn [{json:{html}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 64784, 34832 ], "id": "034abc56-cf5b-4a09-a6c5-c110c46683eb", "name": "Preparar confirmación" }, { "parameters": { "operation": "completion", "respondWith": "showText", "responseText": "={{ $json.html }}" }, "type": "n8n-nodes-base.form", "typeVersion": 2.5, "position": [ 65024, 34832 ], "id": "b949ed7f-046d-4d13-beaf-d064a8b6e5bf", "name": "Confirmación y regreso al formulario", "webhookId": "d4a102a8-3d72-4c5b-83b2-0e0d04c71099", "notesInFlow": true, "notes": "Página final con reporte y botón que vuelve exactamente al mismo Form Trigger de Panamá." }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "fd4e3c0d-3ba7-4267-b86c-3b8771da3d64", "leftValue": "={{ $json.format }}", "rightValue": "PDF", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ 54464, 34912 ], "id": "1c33ef12-3450-4ad1-b9f9-f742d02bd5c6", "name": "¿Q1 es PDF?" }, { "parameters": { "operation": "pdf", "options": {} }, "type": "n8n-nodes-base.extractFromFile", "typeVersion": 1.1, "position": [ 54704, 35056 ], "id": "c291f41f-cb07-4314-9e34-cb20c98f63e5", "name": "Extraer texto Q1 PDF" }, { "parameters": { "jsCode": "const prep=$items('Preparar Q1')[0].json;\nconst item=$input.first();\nconst candidateStrings=Object.values(item.json||{}).filter(v=>typeof v==='string');\nconst raw=item.json?.text??item.json?.data??item.json?.content??candidateStrings.sort((a,b)=>b.length-a.length)[0]??'';\nconst text=String(raw).replace(/\\r/g,'');\nconst config=$items('Inicializar ejecución')[0]?.json?.config||{};\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction num(v){if(v===null||v===undefined||v==='')return null;let s=String(v).replace(/B\\/\\.?|\\$/gi,'').replace(/,/g,'').trim();let neg=/^\\(.*\\)$/.test(s);if(neg)s=s.slice(1,-1);if(!/^-?\\d+(?:\\.\\d+)?$/.test(s))return null;const n=Number(s);return Number.isFinite(n)?(neg?-n:n):null;}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction moneyLine(s){return /^[\\s$()\\-]*[\\d,]+(?:\\.\\d+)?\\s*$/.test(String(s||''));}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase();}\nfunction plausible(gross,ss,se){if(!Number.isFinite(gross)||!Number.isFinite(ss)||!Number.isFinite(se))return false;if(gross<0||ss<-.01||se<-.01)return false;if(gross>0&&(ss/gross>.30||se/gross>.08))return false;return true;}\nfunction median(a){const x=a.filter(Number.isFinite).sort((p,q)=>p-q);if(!x.length)return null;const m=Math.floor(x.length/2);return x.length%2?x[m]:(x[m-1]+x[m])/2;}\nfunction fixed2Tokens(s){return (String(s||'').match(/\\(?-?(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{2}\\)?/g)||[]).map(num).filter(v=>v!==null);}\nfunction sim(a,b){a=norm(a).replace(/[^A-Z]/g,'');b=norm(b).replace(/[^A-Z]/g,'');if(!a||!b)return 0;if(a===b)return 1;const prev=Array.from({length:b.length+1},(_,i)=>i);for(let i=1;i<=a.length;i++){const cur=[i];for(let j=1;j<=b.length;j++)cur[j]=Math.min(cur[j-1]+1,prev[j]+1,prev[j-1]+(a[i-1]===b[j-1]?0:1));for(let j=0;jt[0]||'').join('');\n if(initials===code||initials.split('').reverse().join('')===code)best=Math.max(best,.99);\n return best;\n}\nfunction splitFlatNameCode(rawSuffix){\n let x=clean(rawSuffix);\n // Extract From File (PDF.js) deja al final \" 0.000.00\" (campos de la extrema izquierda/derecha).\n x=x.replace(/\\s+(?:(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{2})+\\s*$/,'').trim();\n let m=x.match(/^(.*?)(\\d{1,8})$/);\n if(m&&m[1].includes(','))return {name:clean(m[1]),code:m[2].replace(/^0+(?=\\d)/,'')};\n if(!x.includes(','))return {name:x,code:''};\n let best=null;\n for(let k=2;k<=Math.min(6,x.length-2);k++){\n const code=x.slice(-k).toUpperCase();if(!/^[A-Z]+$/.test(code))continue;\n const name=clean(x.slice(0,-k));if(!name.includes(','))continue;\n const score=alphaCodeScore(code,name)+k*.004;\n if(!best||score>best.score)best={name,code,score};\n }\n if(best&&best.score>=.87)return {name:best.name,code:best.code};\n return {name:x,code:''};\n}\nfunction dedupe(rows){const seen=new Set(),out=[];for(const r of rows){const k=[r.code,norm(r.name),round(r.ss).toFixed(2),round(r.se).toFixed(2),round(r.gross).toFixed(2),round(r.net).toFixed(2)].join('|');if(seen.has(k))continue;seen.add(k);out.push(r);}return out;}\nif(!text.trim())throw new Error(`Nómina Q1 PDF no contiene texto extraíble. Se detuvo el cruce para evitar un reporte incompleto: ${prep.fileName}`);\n\nconst periodMatch=text.match(/Periodo\\s+del\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})\\s+hasta\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})/i);\nconst period=periodMatch?{start:periodMatch[1],end:periodMatch[2]}:null;\nconst upper=text.toUpperCase(),gi=upper.lastIndexOf('TOTALES GENERALES'),tail=gi>=0?text.slice(gi):text;\nconst cm=tail.match(/Cantidad\\s+de\\s+Empleados:\\s*(\\d+)/i),printedCount=cm?Number(cm[1]):0;\n\n// MODO 1: texto tabular normal (PDF que conserva el orden visual izquierda → derecha).\nconst horizontal=[];\nfor(const rawLine of text.split('\\n')){\n const rate=rawLine.match(/\\d+\\.\\d{5,6}/);if(!rate)continue;\n const prefix=clean(rawLine.slice(0,rate.index)),rest=rawLine.slice((rate.index||0)+rate[0].length);\n const vals=(rest.match(/\\(?-?[\\d,]+(?:\\.\\d+)?\\)?/g)||[]).map(num).filter(v=>v!==null);\n if(vals.length!==23)continue;\n let code='',name=prefix;const pm=prefix.match(/^([A-Za-z0-9]{1,8})\\s+(.+)$/);\n if(pm){code=pm[1].replace(/^0+(?=\\d)/,'');name=clean(pm[2]);}\n if(!name.includes(',')||!/[A-ZÁÉÍÓÚÑ]/i.test(name))continue;\n const u=norm(name);if(/GOMEZLEE|DEPARTAMENTO|TOTALES|NOMBRE DEL EMPLEADO|PLANILLA|PERIODO/.test(u))continue;\n const gross=round(vals[13]),ss=round(vals[15]),se=round(vals[16]);if(!plausible(gross,ss,se))continue;\n horizontal.push({code,name,ss,se,gross,otherIncome:round(vals[12]),totalDeductions:round(vals[20]),net:round(vals[21]),source:'Nómina Q1',fileName:prep.fileName,period});\n}\n\n// MODO 2: salida REAL de n8n Extract From File / PDF.js para estas planillas.\n// PDF.js invierte las columnas y concatena montos sin espacios, por ejemplo:\n// Neto + Total Deduc. + Otras + ISR + S.E. + S.S. + Otros + Bruto + ... + tarifa + Nombre + Código.\n// El parser anterior suponía el orden visual y por eso perdía empleados y fallaba el conteo total.\nconst flat=[];\nfor(const rawLine of text.split('\\n')){\n const line=clean(rawLine);if(!line.includes(','))continue;\n const rates=[...line.matchAll(/(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{5,6}/g)];if(!rates.length)continue;\n const rate=rates[rates.length-1],left=line.slice(0,rate.index),suffix=line.slice((rate.index||0)+rate[0].length);\n const vals=fixed2Tokens(left);if(vals.length<8)continue;\n const nc=splitFlatNameCode(suffix),name=clean(nc.name),code=clean(nc.code).replace(/^0+(?=\\d)/,'');\n if(!name.includes(',')||!/[A-ZÁÉÍÓÚÑ]/i.test(name))continue;\n const u=norm(name);if(/GOMEZLEE|DEPARTAMENTO|TOTALES|NOMBRE DEL EMPLEADO|PLANILLA|PERIODO/.test(u))continue;\n let se=round(vals[4]),ss=round(vals[5]),gross=round(vals[7]),otherIncome=vals.length>8?round(vals[8]):0;\n // Compatibilidad defensiva si una futura versión de PDF.js conserva una columna adicional.\n const targetSS=Number(config.defaultRates?.ssRegular??.0975),targetSE=Number(config.defaultRates?.se??.0125);\n const fixedRateOK=gross<=0||(ss===0&&se===0)||(ss/gross>.03&&ss/gross<.20&&se/gross>.001&&se/gross<.05);\n if(!plausible(gross,ss,se)||!fixedRateOK){\n let best=null;\n for(let i=2;i20)||ss0<0||se0<0)continue;\n const rs=ss0/g,re=se0/g;if(rs>.30||re>.08)continue;\n const score=Math.abs(rs-targetSS)*10+Math.abs(re-targetSE)*20+Math.abs((j-i)-3)*.03;\n if(!best||score=0&&nums.length<36){const s=lines[j];if(!s){j--;continue;}if(moneyLine(s)){nums.push(num(s));j--;continue;}if(nums.length>=10)break;j--;}\n nums.reverse();if(nums.length<10)continue;let best=null;\n for(let k=Math.max(0,nums.length-32);k<=nums.length-8;k++){\n const [net,deducciones,otrasDesc,isr,se,ss,otrosIngresos,gross]=nums.slice(k,k+8);\n if([net,deducciones,se,ss,otrosIngresos,gross].some(v=>v===null)||!plausible(gross,ss,se))continue;\n const residual=Math.abs((gross+otrosIngresos-deducciones)-net);if(residual>1.25)continue;\n const score=residual+Math.abs(k-(nums.length-22))*.001;if(!best||score0)chosen=candidates.find(c=>c.rows.length===printedCount)||null;\nif(!chosen)chosen=[...candidates].sort((a,b)=>b.rows.length-a.rows.length)[0];\nconst out=chosen?.rows||[],parserMode=chosen?.mode||'SIN_PARSER';\nif(printedCount>0&&out.length!==printedCount)throw new Error(`Nómina Q1 PDF indica ${printedCount} empleados, pero ningún lector alcanzó ese total (tabular ${candidates[0].rows.length}, PDF.js ${candidates[1].rows.length}, vertical ${candidates[2].rows.length}). Se detuvo el cruce para no generar un reporte incompleto. Archivo: ${prep.fileName}`);\nif(!out.length)throw new Error(`No pude extraer empleados de Nómina Q1 PDF. El archivo no coincide con el formato de planilla esperado.`);\n\n// 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.05&&medSS<.15&&medSE>.004&&medSE<.03))throw new Error(`Nómina Q1 PDF superó el conteo, pero las tasas implícitas no son plausibles (S.S. ${((medSS||0)*100).toFixed(2)}%, S.E. ${((medSE||0)*100).toFixed(2)}%). Se detuvo para evitar un cruce incorrecto.`);\nreturn [{json:{fileName:prep.fileName,format:'PDF',parserMode,records:out,employeeCount:out.length,printedEmployeeCount:printedCount||null,missingCodeCount:out.filter(r=>!r.code).length,totalSS:round(out.reduce((s,r)=>s+r.ss,0)),totalSE:round(out.reduce((s,r)=>s+r.se,0)),period,stagingSpreadsheetId:'',integrityStatus:printedCount?'VALIDADO CONTRA TOTAL DEL PDF + COBERTURA MONETARIA':'VALIDADO POR COBERTURA MONETARIA'}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 54960, 35024 ], "id": "e256e1d6-2774-462c-9d55-5a031485d6cb", "name": "Parsear Q1 PDF" }, { "parameters": { "jsCode": "const r=$input.first().json;if(!Array.isArray(r.records)||!r.records.length)throw new Error('Resultado Q1 vacío.');return [{json:r}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 56624, 34832 ], "id": "3d6d60e8-487a-41cc-b256-46d813aace5c", "name": "Resultado Q1" }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "5ceced14-980c-46ea-a1ee-3845c8620a90", "leftValue": "={{ $json.format }}", "rightValue": "PDF", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ 57104, 34912 ], "id": "b4e4e628-7ddb-475a-8983-892aac060cf7", "name": "¿Q2 es PDF?" }, { "parameters": { "operation": "pdf", "options": {} }, "type": "n8n-nodes-base.extractFromFile", "typeVersion": 1.1, "position": [ 57344, 35056 ], "id": "3223cc16-c235-4286-ab4b-6caa1bc402b7", "name": "Extraer texto Q2 PDF" }, { "parameters": { "jsCode": "const prep=$items('Preparar Q2')[0].json;\nconst item=$input.first();\nconst candidateStrings=Object.values(item.json||{}).filter(v=>typeof v==='string');\nconst raw=item.json?.text??item.json?.data??item.json?.content??candidateStrings.sort((a,b)=>b.length-a.length)[0]??'';\nconst text=String(raw).replace(/\\r/g,'');\nconst config=$items('Inicializar ejecución')[0]?.json?.config||{};\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction num(v){if(v===null||v===undefined||v==='')return null;let s=String(v).replace(/B\\/\\.?|\\$/gi,'').replace(/,/g,'').trim();let neg=/^\\(.*\\)$/.test(s);if(neg)s=s.slice(1,-1);if(!/^-?\\d+(?:\\.\\d+)?$/.test(s))return null;const n=Number(s);return Number.isFinite(n)?(neg?-n:n):null;}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction moneyLine(s){return /^[\\s$()\\-]*[\\d,]+(?:\\.\\d+)?\\s*$/.test(String(s||''));}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase();}\nfunction plausible(gross,ss,se){if(!Number.isFinite(gross)||!Number.isFinite(ss)||!Number.isFinite(se))return false;if(gross<0||ss<-.01||se<-.01)return false;if(gross>0&&(ss/gross>.30||se/gross>.08))return false;return true;}\nfunction median(a){const x=a.filter(Number.isFinite).sort((p,q)=>p-q);if(!x.length)return null;const m=Math.floor(x.length/2);return x.length%2?x[m]:(x[m-1]+x[m])/2;}\nfunction fixed2Tokens(s){return (String(s||'').match(/\\(?-?(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{2}\\)?/g)||[]).map(num).filter(v=>v!==null);}\nfunction sim(a,b){a=norm(a).replace(/[^A-Z]/g,'');b=norm(b).replace(/[^A-Z]/g,'');if(!a||!b)return 0;if(a===b)return 1;const prev=Array.from({length:b.length+1},(_,i)=>i);for(let i=1;i<=a.length;i++){const cur=[i];for(let j=1;j<=b.length;j++)cur[j]=Math.min(cur[j-1]+1,prev[j]+1,prev[j-1]+(a[i-1]===b[j-1]?0:1));for(let j=0;jt[0]||'').join('');\n if(initials===code||initials.split('').reverse().join('')===code)best=Math.max(best,.99);\n return best;\n}\nfunction splitFlatNameCode(rawSuffix){\n let x=clean(rawSuffix);\n // Extract From File (PDF.js) deja al final \" 0.000.00\" (campos de la extrema izquierda/derecha).\n x=x.replace(/\\s+(?:(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{2})+\\s*$/,'').trim();\n let m=x.match(/^(.*?)(\\d{1,8})$/);\n if(m&&m[1].includes(','))return {name:clean(m[1]),code:m[2].replace(/^0+(?=\\d)/,'')};\n if(!x.includes(','))return {name:x,code:''};\n let best=null;\n for(let k=2;k<=Math.min(6,x.length-2);k++){\n const code=x.slice(-k).toUpperCase();if(!/^[A-Z]+$/.test(code))continue;\n const name=clean(x.slice(0,-k));if(!name.includes(','))continue;\n const score=alphaCodeScore(code,name)+k*.004;\n if(!best||score>best.score)best={name,code,score};\n }\n if(best&&best.score>=.87)return {name:best.name,code:best.code};\n return {name:x,code:''};\n}\nfunction dedupe(rows){const seen=new Set(),out=[];for(const r of rows){const k=[r.code,norm(r.name),round(r.ss).toFixed(2),round(r.se).toFixed(2),round(r.gross).toFixed(2),round(r.net).toFixed(2)].join('|');if(seen.has(k))continue;seen.add(k);out.push(r);}return out;}\nif(!text.trim())throw new Error(`Nómina Q2 PDF no contiene texto extraíble. Se detuvo el cruce para evitar un reporte incompleto: ${prep.fileName}`);\n\nconst periodMatch=text.match(/Periodo\\s+del\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})\\s+hasta\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})/i);\nconst period=periodMatch?{start:periodMatch[1],end:periodMatch[2]}:null;\nconst upper=text.toUpperCase(),gi=upper.lastIndexOf('TOTALES GENERALES'),tail=gi>=0?text.slice(gi):text;\nconst cm=tail.match(/Cantidad\\s+de\\s+Empleados:\\s*(\\d+)/i),printedCount=cm?Number(cm[1]):0;\n\n// MODO 1: texto tabular normal (PDF que conserva el orden visual izquierda → derecha).\nconst horizontal=[];\nfor(const rawLine of text.split('\\n')){\n const rate=rawLine.match(/\\d+\\.\\d{5,6}/);if(!rate)continue;\n const prefix=clean(rawLine.slice(0,rate.index)),rest=rawLine.slice((rate.index||0)+rate[0].length);\n const vals=(rest.match(/\\(?-?[\\d,]+(?:\\.\\d+)?\\)?/g)||[]).map(num).filter(v=>v!==null);\n if(vals.length!==23)continue;\n let code='',name=prefix;const pm=prefix.match(/^([A-Za-z0-9]{1,8})\\s+(.+)$/);\n if(pm){code=pm[1].replace(/^0+(?=\\d)/,'');name=clean(pm[2]);}\n if(!name.includes(',')||!/[A-ZÁÉÍÓÚÑ]/i.test(name))continue;\n const u=norm(name);if(/GOMEZLEE|DEPARTAMENTO|TOTALES|NOMBRE DEL EMPLEADO|PLANILLA|PERIODO/.test(u))continue;\n const gross=round(vals[13]),ss=round(vals[15]),se=round(vals[16]);if(!plausible(gross,ss,se))continue;\n horizontal.push({code,name,ss,se,gross,otherIncome:round(vals[12]),totalDeductions:round(vals[20]),net:round(vals[21]),source:'Nómina Q2',fileName:prep.fileName,period});\n}\n\n// MODO 2: salida REAL de n8n Extract From File / PDF.js para estas planillas.\n// PDF.js invierte las columnas y concatena montos sin espacios, por ejemplo:\n// Neto + Total Deduc. + Otras + ISR + S.E. + S.S. + Otros + Bruto + ... + tarifa + Nombre + Código.\n// El parser anterior suponía el orden visual y por eso perdía empleados y fallaba el conteo total.\nconst flat=[];\nfor(const rawLine of text.split('\\n')){\n const line=clean(rawLine);if(!line.includes(','))continue;\n const rates=[...line.matchAll(/(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{5,6}/g)];if(!rates.length)continue;\n const rate=rates[rates.length-1],left=line.slice(0,rate.index),suffix=line.slice((rate.index||0)+rate[0].length);\n const vals=fixed2Tokens(left);if(vals.length<8)continue;\n const nc=splitFlatNameCode(suffix),name=clean(nc.name),code=clean(nc.code).replace(/^0+(?=\\d)/,'');\n if(!name.includes(',')||!/[A-ZÁÉÍÓÚÑ]/i.test(name))continue;\n const u=norm(name);if(/GOMEZLEE|DEPARTAMENTO|TOTALES|NOMBRE DEL EMPLEADO|PLANILLA|PERIODO/.test(u))continue;\n let se=round(vals[4]),ss=round(vals[5]),gross=round(vals[7]),otherIncome=vals.length>8?round(vals[8]):0;\n // Compatibilidad defensiva si una futura versión de PDF.js conserva una columna adicional.\n const targetSS=Number(config.defaultRates?.ssRegular??.0975),targetSE=Number(config.defaultRates?.se??.0125);\n const fixedRateOK=gross<=0||(ss===0&&se===0)||(ss/gross>.03&&ss/gross<.20&&se/gross>.001&&se/gross<.05);\n if(!plausible(gross,ss,se)||!fixedRateOK){\n let best=null;\n for(let i=2;i20)||ss0<0||se0<0)continue;\n const rs=ss0/g,re=se0/g;if(rs>.30||re>.08)continue;\n const score=Math.abs(rs-targetSS)*10+Math.abs(re-targetSE)*20+Math.abs((j-i)-3)*.03;\n if(!best||score=0&&nums.length<36){const s=lines[j];if(!s){j--;continue;}if(moneyLine(s)){nums.push(num(s));j--;continue;}if(nums.length>=10)break;j--;}\n nums.reverse();if(nums.length<10)continue;let best=null;\n for(let k=Math.max(0,nums.length-32);k<=nums.length-8;k++){\n const [net,deducciones,otrasDesc,isr,se,ss,otrosIngresos,gross]=nums.slice(k,k+8);\n if([net,deducciones,se,ss,otrosIngresos,gross].some(v=>v===null)||!plausible(gross,ss,se))continue;\n const residual=Math.abs((gross+otrosIngresos-deducciones)-net);if(residual>1.25)continue;\n const score=residual+Math.abs(k-(nums.length-22))*.001;if(!best||score0)chosen=candidates.find(c=>c.rows.length===printedCount)||null;\nif(!chosen)chosen=[...candidates].sort((a,b)=>b.rows.length-a.rows.length)[0];\nconst out=chosen?.rows||[],parserMode=chosen?.mode||'SIN_PARSER';\nif(printedCount>0&&out.length!==printedCount)throw new Error(`Nómina Q2 PDF indica ${printedCount} empleados, pero ningún lector alcanzó ese total (tabular ${candidates[0].rows.length}, PDF.js ${candidates[1].rows.length}, vertical ${candidates[2].rows.length}). Se detuvo el cruce para no generar un reporte incompleto. Archivo: ${prep.fileName}`);\nif(!out.length)throw new Error(`No pude extraer empleados de Nómina Q2 PDF. El archivo no coincide con el formato de planilla esperado.`);\n\n// 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.05&&medSS<.15&&medSE>.004&&medSE<.03))throw new Error(`Nómina Q2 PDF superó el conteo, pero las tasas implícitas no son plausibles (S.S. ${((medSS||0)*100).toFixed(2)}%, S.E. ${((medSE||0)*100).toFixed(2)}%). Se detuvo para evitar un cruce incorrecto.`);\nreturn [{json:{fileName:prep.fileName,format:'PDF',parserMode,records:out,employeeCount:out.length,printedEmployeeCount:printedCount||null,missingCodeCount:out.filter(r=>!r.code).length,totalSS:round(out.reduce((s,r)=>s+r.ss,0)),totalSE:round(out.reduce((s,r)=>s+r.se,0)),period,stagingSpreadsheetId:'',integrityStatus:printedCount?'VALIDADO CONTRA TOTAL DEL PDF + COBERTURA MONETARIA':'VALIDADO POR COBERTURA MONETARIA'}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 57584, 35056 ], "id": "dd41d8e5-d079-4422-a22c-ca2010fa31c2", "name": "Parsear Q2 PDF" }, { "parameters": { "jsCode": "const r=$input.first().json;if(!Array.isArray(r.records)||!r.records.length)throw new Error('Resultado Q2 vacío.');return [{json:r}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 58544, 34832 ], "id": "22ae7500-d025-4854-aeab-7b6827c4fb14", "name": "Resultado Q2" }, { "parameters": { "jsCode": "const q1=$items('Resultado Q1')[0].json;\nconst q2=$items('Resultado Q2')[0].json;\nconst css=$items('Parsear CSS')[0].json;\nconst tempFolder=$items('Crear carpeta temporal PDFs')[0]?.json?.id||'';\nconst out=[];\nfor(const [tipo,id] of [\n ['Q1',q1.stagingSpreadsheetId],\n ['Q2',q2.stagingSpreadsheetId],\n ['CSS',css.stagingSpreadsheetId],\n ['PDFS_TEMP',tempFolder]\n]) if(id) out.push({json:{tipo,fileId:id}});\nreturn out.length?out:[{json:{tipo:'NINGUNO',fileId:''}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 64304, 34832 ], "id": "a358393b-3223-450e-a6db-4a146838f266", "name": "Preparar limpieza temporales" }, { "parameters": { "method": "DELETE", "url": "=https://www.googleapis.com/drive/v3/files/{{ $json.fileId }}?supportsAllDrives=true", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleDriveOAuth2Api", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 64544, 34832 ], "id": "c20af9b0-54e4-44c9-be47-5c277d575807", "name": "Eliminar staging temporal", "alwaysOutputData": true, "notesInFlow": true, "credentials": { "googleDriveOAuth2Api": { "id": "g23xdGLZRzBGqKgH", "name": "Isaac - Google Drive" } }, "continueOnFail": true, "notes": "Limpieza best-effort: elimina staging Q1/Q2/CSS y la carpeta temporal de PDFs. Si falla, no bloquea la pantalla final." }, { "parameters": { "method": "POST", "url": "https://www.googleapis.com/drive/v3/files?fields=id,name,mimeType&supportsAllDrives=true", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleDriveOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ name: `TEMP PA CSS PDFs - ${$('Inicializar ejecución').item.json.runId}`, mimeType: 'application/vnd.google-apps.folder' }) }}", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 54224, 35216 ], "id": "09fa4c3c-7a56-46ae-a0ef-2bd5030a1417", "name": "Crear carpeta temporal PDFs", "retryOnFail": true, "maxTries": 3, "waitBetweenTries": 2000, "notesInFlow": true, "credentials": { "googleDriveOAuth2Api": { "id": "g23xdGLZRzBGqKgH", "name": "Isaac - Google Drive" } }, "notes": "Carpeta temporal aislada por ejecución para acumular PDFs cargados en varias tandas sin perder selecciones anteriores." }, { "parameters": { "formFields": { "values": [ { "fieldLabel": "¿Hay extraordinarias este mes?", "fieldType": "radio", "fieldOptions": { "values": [ { "option": "Sí" }, { "option": "No" } ] }, "requiredField": true } ] }, "options": { "formTitle": "Paso 2 de 4 · Extraordinarias", "formDescription": "Indica si este mes existen planillas extraordinarias. Este es el único grupo opcional.", "buttonLabel": "Continuar" } }, "type": "n8n-nodes-base.form", "typeVersion": 2.5, "position": [ 54464, 35216 ], "id": "8f7476cf-6957-4714-9cd3-9f71feb1e687", "name": "Definir Extraordinarias", "webhookId": "2d4fba32-6a24-46e8-a9c7-a38ca80b106a" }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "e7a0d1ad-362a-4267-b2fd-ba254621eeaa", "leftValue": "={{ $json[\"¿Hay extraordinarias este mes?\"] }}", "rightValue": "Sí", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ 54704, 35216 ], "id": "953ada8a-80d8-40ee-a130-c040a9376a09", "name": "¿Hay extraordinarias?" }, { "parameters": { "formFields": { "values": [ { "fieldLabel": "Archivos extraordinarios", "fieldType": "file", "acceptFileTypes": ".pdf,application/pdf", "requiredField": true }, { "fieldLabel": "¿Agregar más extraordinarias?", "fieldType": "radio", "fieldOptions": { "values": [ { "option": "Sí" }, { "option": "No" } ] }, "requiredField": true } ] }, "options": { "formTitle": "Extraordinarias · cargar lote", "formDescription": "Puedes seleccionar uno o varios PDFs a la vez. Si tienes más archivos en otra carpeta, selecciona “Sí” en “Agregar más” y volverás a esta misma pantalla sin perder los archivos ya guardados.", "buttonLabel": "Guardar lote" } }, "type": "n8n-nodes-base.form", "typeVersion": 2.5, "position": [ 54960, 35024 ], "id": "4bf3e422-a9fe-4f98-9122-c3c4edff7f0f", "name": "Cargar Extraordinarias", "webhookId": "237dd241-cbba-4455-beec-b4b63e45827d" }, { "parameters": { "jsCode": "const item=$input.first();\nconst bin=item.binary||{};\nconst files=Object.entries(bin);\nfunction clean(v){return String(v??'').trim();}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase();}\nfunction yes(v){return ['si','sí','yes','true','1'].includes(norm(v));}\nfunction safeName(v){return clean(v).replace(/[\\\\/\\x00-\\x1F]/g,'_').replace(/__+/g,'_').slice(-150)||'archivo.pdf';}\nif(!files.length) throw new Error('Debes adjuntar al menos un PDF en este lote de Extraordinarias.');\nconst more=yes(item.json?.['¿Agregar más extraordinarias?']);\nconst init=$items('Inicializar ejecución')[0].json;\nconst max=Number(init.config?.maxSupplementalFilesSafety||500);\nif(files.length>max) throw new Error(`Se recibieron ${files.length} archivos en un solo lote; supera el límite técnico de seguridad (${max}).`);\nconst now=Date.now();\nconst out=[];\nfor(let i=0;imax) 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;imax) 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;imax) throw new Error(`Se detectaron ${files.length} archivos complementarios; supera el límite técnico de seguridad (${max}).`);\nfunction meta(name){\n const n=String(name||'');\n let category='';\n if(n.startsWith('EXTRA__'))category='extraordinaria';\n else if(n.startsWith('VAC__'))category='vacaciones';\n else if(n.startsWith('LIQ__'))category='liquidaciones';\n const ix=n.indexOf('__ORIG__');\n const fileName=ix>=0?n.slice(ix+8):n;\n return {category,fileName};\n}\nconst docs=[];\nfor(const f of files){\n const m=meta(f.name);\n if(!m.category) throw new Error(`Encontré un archivo temporal no reconocido: ${f.name}. Se detuvo para evitar mezclar documentos.`);\n if(String(f.mimeType||'')!=='application/pdf' && !String(f.name||'').toLowerCase().endsWith('.pdf')) throw new Error(`El archivo temporal ${f.name} no parece ser PDF.`);\n docs.push({id:f.id,storageName:f.name,fileName:m.fileName,category:m.category,size:f.size||'',createdTime:f.createdTime||''});\n}\nconst vac=docs.filter(x=>x.category==='vacaciones').length;\nconst liq=docs.filter(x=>x.category==='liquidaciones').length;\nconst ext=docs.filter(x=>x.category==='extraordinaria').length;\nif(vac<1) throw new Error('No hay archivos de Vacaciones acumulados. Vacaciones requiere al menos un PDF.');\nif(liq<1) throw new Error('No hay archivos de Liquidaciones acumulados. Liquidaciones requiere al menos un PDF.');\ndocs.sort((a,b)=>a.category.localeCompare(b.category)||String(a.createdTime).localeCompare(String(b.createdTime))||a.storageName.localeCompare(b.storageName));\nreturn docs.map(d=>({json:{...d,uploadCounts:{extraordinarias:ext,vacaciones:vac,liquidaciones:liq}}}));" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 61664, 35056 ], "id": "b525df3e-0968-489a-80d7-436a9697f00a", "name": "Preparar descarga PDFs", "notesInFlow": true, "notes": "Reconstruye las tres categorías desde la carpeta temporal y valida mínimos antes de descargar." }, { "parameters": { "operation": "download", "fileId": { "__rl": true, "value": "={{ $json.id }}", "mode": "id" }, "options": { "binaryPropertyName": "data", "fileName": "={{ $json.storageName }}" } }, "type": "n8n-nodes-base.googleDrive", "typeVersion": 3, "position": [ 61904, 35056 ], "id": "b5e9a2fb-a85c-45fc-b819-431840e1e007", "name": "Descargar PDFs temporales", "retryOnFail": true, "maxTries": 3, "waitBetweenTries": 2000, "credentials": { "googleDriveOAuth2Api": { "id": "g23xdGLZRzBGqKgH", "name": "Isaac - Google Drive" } } }, { "parameters": { "jsCode": "const items=$input.all();\nfunction parseStorage(n){\n const name=String(n||'');\n let category='';\n if(name.startsWith('EXTRA__'))category='extraordinaria';\n else if(name.startsWith('VAC__'))category='vacaciones';\n else if(name.startsWith('LIQ__'))category='liquidaciones';\n const ix=name.indexOf('__ORIG__');\n return {category,fileName:ix>=0?name.slice(ix+8):name,storageName:name};\n}\nconst out=[];\nfor(const item of items){\n const data=item.binary?.data;\n if(!data) throw new Error('Google Drive no devolvió el contenido binario de uno de los PDFs temporales.');\n const p=parseStorage(data.fileName||item.json?.storageName||item.json?.name||'');\n if(!p.category) throw new Error(`No pude determinar la categoría del archivo temporal ${p.storageName||'(sin nombre)'}.`);\n data.fileName=p.fileName;\n out.push({json:{category:p.category,fileName:p.fileName,storageName:p.storageName,fileId:item.json?.id||item.json?.fileId||''},binary:{data}});\n}\nreturn out;" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 62144, 35056 ], "id": "833ab32f-3fad-4271-8a6d-81923f4eda84", "name": "Normalizar PDFs descargados" } ], "connections": { "On form submission": { "main": [ [ { "node": "Inicializar ejecución", "type": "main", "index": 0 } ] ] }, "Inicializar ejecución": { "main": [ [ { "node": "Crear carpeta temporal PDFs", "type": "main", "index": 0 } ] ] }, "Preparar Q1": { "main": [ [ { "node": "¿Q1 es PDF?", "type": "main", "index": 0 } ] ] }, "¿Q1 es PDF?": { "main": [ [ { "node": "Extraer texto Q1 PDF", "type": "main", "index": 0 } ], [ { "node": "Crear staging Q1", "type": "main", "index": 0 } ] ] }, "Extraer texto Q1 PDF": { "main": [ [ { "node": "Parsear Q1 PDF", "type": "main", "index": 0 } ] ] }, "Parsear Q1 PDF": { "main": [ [ { "node": "Resultado Q1", "type": "main", "index": 0 } ] ] }, "Crear staging Q1": { "main": [ [ { "node": "Reunir staging Q1", "type": "main", "index": 0 } ] ] }, "Reunir staging Q1": { "main": [ [ { "node": "Parsear Q1 Excel", "type": "main", "index": 0 } ] ] }, "Parsear Q1 Excel": { "main": [ [ { "node": "Resultado Q1", "type": "main", "index": 0 } ] ] }, "Resultado Q1": { "main": [ [ { "node": "Preparar Q2", "type": "main", "index": 0 } ] ] }, "Preparar Q2": { "main": [ [ { "node": "¿Q2 es PDF?", "type": "main", "index": 0 } ] ] }, "¿Q2 es PDF?": { "main": [ [ { "node": "Extraer texto Q2 PDF", "type": "main", "index": 0 } ], [ { "node": "Crear staging Q2", "type": "main", "index": 0 } ] ] }, "Extraer texto Q2 PDF": { "main": [ [ { "node": "Parsear Q2 PDF", "type": "main", "index": 0 } ] ] }, "Parsear Q2 PDF": { "main": [ [ { "node": "Resultado Q2", "type": "main", "index": 0 } ] ] }, "Crear staging Q2": { "main": [ [ { "node": "Reunir staging Q2", "type": "main", "index": 0 } ] ] }, "Reunir staging Q2": { "main": [ [ { "node": "Parsear Q2 Excel", "type": "main", "index": 0 } ] ] }, "Parsear Q2 Excel": { "main": [ [ { "node": "Resultado Q2", "type": "main", "index": 0 } ] ] }, "Resultado Q2": { "main": [ [ { "node": "Preparar CSS", "type": "main", "index": 0 } ] ] }, "Preparar CSS": { "main": [ [ { "node": "Crear staging CSS", "type": "main", "index": 0 } ] ] }, "Crear staging CSS": { "main": [ [ { "node": "Reunir staging CSS", "type": "main", "index": 0 } ] ] }, "Reunir staging CSS": { "main": [ [ { "node": "Parsear CSS", "type": "main", "index": 0 } ] ] }, "Parsear CSS": { "main": [ [ { "node": "Listar PDFs temporales", "type": "main", "index": 0 } ] ] }, "Extraer texto PDFs": { "main": [ [ { "node": "Parsear PDFs", "type": "main", "index": 0 } ] ] }, "Parsear PDFs": { "main": [ [ { "node": "Construir reporte producción", "type": "main", "index": 0 } ] ] }, "Construir reporte producción": { "main": [ [ { "node": "Crear Google Sheet Reporte", "type": "main", "index": 0 } ] ] }, "Crear Google Sheet Reporte": { "main": [ [ { "node": "Escribir reporte", "type": "main", "index": 0 } ] ] }, "Escribir reporte": { "main": [ [ { "node": "Preparar formato reporte", "type": "main", "index": 0 } ] ] }, "Preparar formato reporte": { "main": [ [ { "node": "Formatear reporte", "type": "main", "index": 0 } ] ] }, "Formatear reporte": { "main": [ [ { "node": "Preparar permisos", "type": "main", "index": 0 } ] ] }, "Preparar permisos": { "main": [ [ { "node": "Compartir reporte como editor", "type": "main", "index": 0 } ] ] }, "Compartir reporte como editor": { "main": [ [ { "node": "Preparar correo HTML GLM", "type": "main", "index": 0 } ] ] }, "Preparar correo HTML GLM": { "main": [ [ { "node": "Enviar correo resumen", "type": "main", "index": 0 } ] ] }, "Enviar correo resumen": { "main": [ [ { "node": "Preparar limpieza temporales", "type": "main", "index": 0 } ] ] }, "Preparar limpieza temporales": { "main": [ [ { "node": "Eliminar staging temporal", "type": "main", "index": 0 } ] ] }, "Eliminar staging temporal": { "main": [ [ { "node": "Preparar confirmación", "type": "main", "index": 0 } ] ] }, "Preparar confirmación": { "main": [ [ { "node": "Confirmación y regreso al formulario", "type": "main", "index": 0 } ] ] }, "Crear carpeta temporal PDFs": { "main": [ [ { "node": "Definir Extraordinarias", "type": "main", "index": 0 } ] ] }, "Definir Extraordinarias": { "main": [ [ { "node": "¿Hay extraordinarias?", "type": "main", "index": 0 } ] ] }, "¿Hay extraordinarias?": { "main": [ [ { "node": "Cargar Extraordinarias", "type": "main", "index": 0 } ], [ { "node": "Cargar Vacaciones", "type": "main", "index": 0 } ] ] }, "Cargar Extraordinarias": { "main": [ [ { "node": "Preparar lote Extraordinarias", "type": "main", "index": 0 } ] ] }, "Preparar lote Extraordinarias": { "main": [ [ { "node": "Guardar lote Extraordinarias", "type": "main", "index": 0 } ] ] }, "Guardar lote Extraordinarias": { "main": [ [ { "node": "Cerrar lote Extraordinarias", "type": "main", "index": 0 } ] ] }, "Cerrar lote Extraordinarias": { "main": [ [ { "node": "¿Agregar más Extraordinarias?", "type": "main", "index": 0 } ] ] }, "¿Agregar más Extraordinarias?": { "main": [ [ { "node": "Cargar Extraordinarias", "type": "main", "index": 0 } ], [ { "node": "Cargar Vacaciones", "type": "main", "index": 0 } ] ] }, "Cargar Vacaciones": { "main": [ [ { "node": "Preparar lote Vacaciones", "type": "main", "index": 0 } ] ] }, "Preparar lote Vacaciones": { "main": [ [ { "node": "Guardar lote Vacaciones", "type": "main", "index": 0 } ] ] }, "Guardar lote Vacaciones": { "main": [ [ { "node": "Cerrar lote Vacaciones", "type": "main", "index": 0 } ] ] }, "Cerrar lote Vacaciones": { "main": [ [ { "node": "¿Agregar más Vacaciones?", "type": "main", "index": 0 } ] ] }, "¿Agregar más Vacaciones?": { "main": [ [ { "node": "Cargar Vacaciones", "type": "main", "index": 0 } ], [ { "node": "Cargar Liquidaciones", "type": "main", "index": 0 } ] ] }, "Cargar Liquidaciones": { "main": [ [ { "node": "Preparar lote Liquidaciones", "type": "main", "index": 0 } ] ] }, "Preparar lote Liquidaciones": { "main": [ [ { "node": "Guardar lote Liquidaciones", "type": "main", "index": 0 } ] ] }, "Guardar lote Liquidaciones": { "main": [ [ { "node": "Cerrar lote Liquidaciones", "type": "main", "index": 0 } ] ] }, "Cerrar lote Liquidaciones": { "main": [ [ { "node": "¿Agregar más Liquidaciones?", "type": "main", "index": 0 } ] ] }, "¿Agregar más Liquidaciones?": { "main": [ [ { "node": "Cargar Liquidaciones", "type": "main", "index": 0 } ], [ { "node": "Preparar Q1", "type": "main", "index": 0 } ] ] }, "Listar PDFs temporales": { "main": [ [ { "node": "Preparar descarga PDFs", "type": "main", "index": 0 } ] ] }, "Preparar descarga PDFs": { "main": [ [ { "node": "Descargar PDFs temporales", "type": "main", "index": 0 } ] ] }, "Descargar PDFs temporales": { "main": [ [ { "node": "Normalizar PDFs descargados", "type": "main", "index": 0 } ] ] }, "Normalizar PDFs descargados": { "main": [ [ { "node": "Extraer texto PDFs", "type": "main", "index": 0 } ] ] } }, "settings": { "executionOrder": "v1", "binaryMode": "separate" }, "staticData": null, "meta": null, "versionId": "d6cac1fb-fb4b-4bef-a2b3-146b21e84ac4", "activeVersionId": "d6cac1fb-fb4b-4bef-a2b3-146b21e84ac4", "versionCounter": 144, "triggerCount": 1, "shared": [ { "updatedAt": "2026-08-20T19:14:26.151Z", "createdAt": "2026-08-20T19:14:26.151Z", "role": "workflow:owner", "workflowId": "qxcXg0Z1YASkWgxL", "projectId": "PJpTANzTXIFibWsW", "project": { "updatedAt": "2026-04-22T14:25:09.686Z", "createdAt": "2026-04-22T14:22:54.790Z", "id": "PJpTANzTXIFibWsW", "name": "Isaac Aracena ", "type": "personal", "icon": null, "description": null, "creatorId": "0a88c0b1-928e-4412-896e-c5d1c99b2029" } } ], "tags": [], "activeVersion": { "updatedAt": "2026-08-26T01:49:22.000Z", "createdAt": "2026-08-26T01:48:51.760Z", "versionId": "d6cac1fb-fb4b-4bef-a2b3-146b21e84ac4", "workflowId": "qxcXg0Z1YASkWgxL", "nodes": [ { "parameters": { "formTitle": "Cruce Seguridad Social Panamá", "formDescription": "Paso 1 de 4. Carga las dos nóminas quincenales (preferiblemente Excel; PDF también se valida con controles estrictos) y la Planilla oficial de la Caja de Seguro Social (CSS) en Excel. En los pasos siguientes podrás cargar Extraordinarias, Vacaciones y Liquidaciones en una o varias tandas, aunque estén en carpetas distintas. El período se detecta automáticamente desde la Planilla CSS.", "formFields": { "values": [ { "fieldLabel": "Nómina - 1era Quincena", "fieldType": "file", "multipleFiles": false, "acceptFileTypes": ".xlsx,.xls,.pdf", "requiredField": true }, { "fieldLabel": "Nómina - 2da Quincena", "fieldType": "file", "multipleFiles": false, "acceptFileTypes": ".xlsx,.xls,.pdf", "requiredField": true }, { "fieldLabel": "Planilla CSS", "fieldType": "file", "multipleFiles": false, "acceptFileTypes": ".xlsx,.xls", "requiredField": true } ] }, "responseMode": "lastNode", "options": { "ignoreBots": true } }, "type": "n8n-nodes-base.formTrigger", "typeVersion": 2.3, "position": [ 53744, 34736 ], "id": "595b95d8-2d86-40b4-a418-1b9964bc76f5", "name": "On form submission", "webhookId": "9c032e76-4e26-46ce-ab02-1a0d628dd1fa" }, { "parameters": { "jsCode": "const item = $input.first();\nconst now = new Date();\nconst pad = n => String(n).padStart(2,'0');\nconst runId = `PA_CSS_${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}_${Math.random().toString(36).slice(2,8)}`;\nconst recipients = [\n 'iaracena@gomezleemarketing.com'\n];\nconst logoUrl = 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png';\nconst formUrl = 'https://agenteit.digitalcompass.agency/form/0feede33-4a67-4d98-8ae0-16f411726460';\nconst config = {\n amountTolerance: 0.10,\n cssIntegrityTolerance: 0.10,\n defaultRates: { ssRegular: 0.0975, se: 0.0125, ssDtm: 0.0725 },\n minRateSamples: 20,\n hardFailUnreadablePdf: true,\n maxSupplementalFilesSafety: 500\n};\nreturn [{json:{runId,startedAt:now.toISOString(),recipients,logoUrl,formUrl,config},binary:item.binary||{}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 53984, 34736 ], "id": "cab81d5c-e00e-4957-af5a-21ab5dae0b09", "name": "Inicializar ejecución" }, { "parameters": { "jsCode": "\nconst base=$items('Inicializar ejecución')[0];\nconst bin=base.binary||{};\nfunction norm(s){return String(s||'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase().replace(/\\s+/g,' ').trim();}\nfunction formatOf(file,name){\n const s=norm(`${name} ${file?.mimeType||''}`);\n if(/\\.pdf(?:$|\\s)|application\\/pdf/.test(s)) return 'PDF';\n if(/\\.xlsx?(?:$|\\s)|spreadsheet|excel|application\\/vnd\\.ms-excel/.test(s)) return 'EXCEL';\n return '';\n}\nconst entries=Object.entries(bin).map(([key,file])=>{\n const name=String(file.fileName||key), k=norm(key), n=norm(name), all=`${k} ${n}`;\n const format=formatOf(file,name); let score=format?100:-1000;\n \n if(/nomina.*1era|1era.*quincena|primera.*quincena|quincena.*1/.test(k)) score+=600;\n if(/nomina.*2da|2da.*quincena|segunda.*quincena|quincena.*2/.test(k)) score-=700;\n if(/1era|primera|quincena\\s*1|q1/.test(n)) score+=180;\n if(/2da|segunda|quincena\\s*2|q2/.test(n)) score-=220;\n if(/planilla\\s*css|detalleplanilla|extraordin|vacacion|liquidacion/.test(all)) score-=300;\n\n return {key,file,name,format,score,k,n,all};\n}).sort((a,b)=>b.score-a.score);\nconst chosen=entries[0];\nif(!chosen||chosen.score<250||!chosen.format) throw new Error('No pude identificar de forma segura la Nómina de 1era Quincena. Adjunta en ese campo un Excel (.xls/.xlsx) o PDF de la planilla quincenal.');\nreturn [{json:{...base.json,fileName:chosen.name,originalBinaryKey:chosen.key,format:chosen.format,inputMimeType:chosen.file?.mimeType||''},binary:{data:chosen.file}}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 54224, 34832 ], "id": "3dff7255-5e27-4c05-b126-5b233af765a7", "name": "Preparar Q1" }, { "parameters": { "operation": "xls", "options": { "headerRow": false, "includeEmptyCells": true, "rawData": false, "readAsString": false } }, "type": "n8n-nodes-base.extractFromFile", "typeVersion": 1.1, "position": [ 54704, 34688 ], "id": "85440a47-e842-43d6-9015-76690b89712f", "name": "Crear staging Q1" }, { "parameters": { "jsCode": "const rows=$input.all().map(x=>x.json?.row).filter(r=>Array.isArray(r));\nif(!rows.length) throw new Error('No se pudieron leer filas de Nómina Q1 directamente desde el Excel.');\nreturn [{json:{valueRanges:[{range:'Sheet1!A1:AZ50000',values:rows}],directExtraction:true}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 54944, 34688 ], "id": "2fe967fa-afc8-48df-87b1-d73ba67e825d", "name": "Reunir staging Q1" }, { "parameters": { "jsCode": "\nconst payload=$json.body&&typeof $json.body==='object'?$json.body:$json;\nconst valueRanges=payload.valueRanges||[];\nconst prep=$items('Preparar Q1')[0].json;\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction asc(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase();}\nfunction key(v){return asc(v).replace(/[^A-Z0-9]/g,'');}\nfunction num(v){if(v===null||v===undefined||v==='')return 0;if(typeof v==='number')return v;let s=String(v).replace(/B\\/.?|\\$|,/g,'').replace(/\\s/g,'');if(/^\\(.*\\)$/.test(s))s='-'+s.slice(1,-1);const n=Number(s);return Number.isFinite(n)?n:0;}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction detectPeriod(values){\n for(const row of values.slice(0,120)) for(const cell of row||[]){\n const m=clean(cell).match(/Periodo\\s+del\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})\\s+hasta\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})/i);\n if(m)return {start:m[1],end:m[2]};\n }\n return null;\n}\nfunction detectPrintedCount(values){\n let gi=-1; for(let i=0;i0)return n;}\n }\n }\n }\n return null;\n}\nfunction detect(values){\n for(let r=0;r(x||[]).length),row.length,1), vertical=[];\n for(let c=0;casc((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/\\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=0?clean(row[d.codeIdx]).replace(/\\.0+$/,''):'';if(code&&!/^\\d{1,8}$/.test(code))code='';\n const ss=round(num(row[d.ssIdx])),se=d.seIdx>=0?round(num(row[d.seIdx])):0,gross=d.grossIdx>=0?round(num(row[d.grossIdx])):0;\n if(!code&&ss===0&&se===0&&gross===0)continue;\n rawRecords.push({code:code.replace(/^0+(?=\\d)/,''),name,ss,se,gross,source:'Nómina Q1',fileName:prep.fileName,sheet:range});count++;\n }\n sheets.push({range,status:count?'PROCESADA':'SIN REGISTROS',records:count});\n}\n// Elimina filas exactas repetidas (por ejemplo, hojas copia) sin sumar dos veces.\nconst exactSeen=new Set(),records=[];\nfor(const r of rawRecords){const k=[r.code,asc(r.name),r.ss.toFixed(2),r.se.toFixed(2),r.gross.toFixed(2)].join('|');if(exactSeen.has(k))continue;exactSeen.add(k);records.push(r);}\nif(!records.length)throw new Error('No pude extraer empleados de Nómina Q1. El Excel no coincide con el formato de planilla esperado.');\nif(printedEmployeeCount&&records.length!==printedEmployeeCount)throw new Error(`Nómina Q1 indica ${printedEmployeeCount} empleados, pero el lector extrajo ${records.length}. Se detuvo el cruce para no generar un reporte incompleto.`);\nconst RESULT={fileName:prep.fileName,format:'EXCEL',records,employeeCount:records.length,printedEmployeeCount:printedEmployeeCount||null,missingCodeCount:records.filter(r=>!r.code).length,totalSS:round(records.reduce((s,r)=>s+r.ss,0)),totalSE:round(records.reduce((s,r)=>s+r.se,0)),period,sheets,stagingSpreadsheetId:'',integrityStatus:printedEmployeeCount?'VALIDADO CONTRA TOTAL DEL ARCHIVO':'VALIDADO ESTRUCTURALMENTE'};\nreturn [{json:RESULT}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 56384, 34688 ], "id": "693f40c8-f19f-4ab6-a526-f0ad006e7402", "name": "Parsear Q1 Excel" }, { "parameters": { "jsCode": "\nconst base=$items('Inicializar ejecución')[0];\nconst bin=base.binary||{};\nfunction norm(s){return String(s||'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase().replace(/\\s+/g,' ').trim();}\nfunction formatOf(file,name){\n const s=norm(`${name} ${file?.mimeType||''}`);\n if(/\\.pdf(?:$|\\s)|application\\/pdf/.test(s)) return 'PDF';\n if(/\\.xlsx?(?:$|\\s)|spreadsheet|excel|application\\/vnd\\.ms-excel/.test(s)) return 'EXCEL';\n return '';\n}\nconst entries=Object.entries(bin).map(([key,file])=>{\n const name=String(file.fileName||key), k=norm(key), n=norm(name), all=`${k} ${n}`;\n const format=formatOf(file,name); let score=format?100:-1000;\n \n if(/nomina.*2da|2da.*quincena|segunda.*quincena|quincena.*2/.test(k)) score+=600;\n if(/nomina.*1era|1era.*quincena|primera.*quincena|quincena.*1/.test(k)) score-=700;\n if(/2da|segunda|quincena\\s*2|q2/.test(n)) score+=180;\n if(/1era|primera|quincena\\s*1|q1/.test(n)) score-=220;\n if(/planilla\\s*css|detalleplanilla|extraordin|vacacion|liquidacion/.test(all)) score-=300;\n\n return {key,file,name,format,score,k,n,all};\n}).sort((a,b)=>b.score-a.score);\nconst chosen=entries[0];\nif(!chosen||chosen.score<250||!chosen.format) throw new Error('No pude identificar de forma segura la Nómina de 2da Quincena. Adjunta en ese campo un Excel (.xls/.xlsx) o PDF de la planilla quincenal.');\nreturn [{json:{...base.json,fileName:chosen.name,originalBinaryKey:chosen.key,format:chosen.format,inputMimeType:chosen.file?.mimeType||''},binary:{data:chosen.file}}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 56864, 34832 ], "id": "e3a1ef29-d141-4b50-b35d-2ba175a41d4e", "name": "Preparar Q2" }, { "parameters": { "operation": "xls", "options": { "headerRow": false, "includeEmptyCells": true, "rawData": false, "readAsString": false } }, "type": "n8n-nodes-base.extractFromFile", "typeVersion": 1.1, "position": [ 57344, 34688 ], "id": "70365021-6de6-43f7-b3c7-0a41fc275d5d", "name": "Crear staging Q2" }, { "parameters": { "jsCode": "const rows=$input.all().map(x=>x.json?.row).filter(r=>Array.isArray(r));\nif(!rows.length) throw new Error('No se pudieron leer filas de Nómina Q2 directamente desde el Excel.');\nreturn [{json:{valueRanges:[{range:'Sheet1!A1:AZ50000',values:rows}],directExtraction:true}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 57584, 34688 ], "id": "b182e631-7af4-4666-b74a-609f29c78e69", "name": "Reunir staging Q2" }, { "parameters": { "jsCode": "\nconst payload=$json.body&&typeof $json.body==='object'?$json.body:$json;\nconst valueRanges=payload.valueRanges||[];\nconst prep=$items('Preparar Q2')[0].json;\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction asc(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase();}\nfunction key(v){return asc(v).replace(/[^A-Z0-9]/g,'');}\nfunction num(v){if(v===null||v===undefined||v==='')return 0;if(typeof v==='number')return v;let s=String(v).replace(/B\\/.?|\\$|,/g,'').replace(/\\s/g,'');if(/^\\(.*\\)$/.test(s))s='-'+s.slice(1,-1);const n=Number(s);return Number.isFinite(n)?n:0;}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction detectPeriod(values){\n for(const row of values.slice(0,120)) for(const cell of row||[]){\n const m=clean(cell).match(/Periodo\\s+del\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})\\s+hasta\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})/i);\n if(m)return {start:m[1],end:m[2]};\n }\n return null;\n}\nfunction detectPrintedCount(values){\n let gi=-1; for(let i=0;i0)return n;}\n }\n }\n }\n return null;\n}\nfunction detect(values){\n for(let r=0;r(x||[]).length),row.length,1), vertical=[];\n for(let c=0;casc((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/\\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=0?clean(row[d.codeIdx]).replace(/\\.0+$/,''):'';if(code&&!/^\\d{1,8}$/.test(code))code='';\n const ss=round(num(row[d.ssIdx])),se=d.seIdx>=0?round(num(row[d.seIdx])):0,gross=d.grossIdx>=0?round(num(row[d.grossIdx])):0;\n if(!code&&ss===0&&se===0&&gross===0)continue;\n rawRecords.push({code:code.replace(/^0+(?=\\d)/,''),name,ss,se,gross,source:'Nómina Q2',fileName:prep.fileName,sheet:range});count++;\n }\n sheets.push({range,status:count?'PROCESADA':'SIN REGISTROS',records:count});\n}\n// Elimina filas exactas repetidas (por ejemplo, hojas copia) sin sumar dos veces.\nconst exactSeen=new Set(),records=[];\nfor(const r of rawRecords){const k=[r.code,asc(r.name),r.ss.toFixed(2),r.se.toFixed(2),r.gross.toFixed(2)].join('|');if(exactSeen.has(k))continue;exactSeen.add(k);records.push(r);}\nif(!records.length)throw new Error('No pude extraer empleados de Nómina Q2. El Excel no coincide con el formato de planilla esperado.');\nif(printedEmployeeCount&&records.length!==printedEmployeeCount)throw new Error(`Nómina Q2 indica ${printedEmployeeCount} empleados, pero el lector extrajo ${records.length}. Se detuvo el cruce para no generar un reporte incompleto.`);\nconst RESULT={fileName:prep.fileName,format:'EXCEL',records,employeeCount:records.length,printedEmployeeCount:printedEmployeeCount||null,missingCodeCount:records.filter(r=>!r.code).length,totalSS:round(records.reduce((s,r)=>s+r.ss,0)),totalSE:round(records.reduce((s,r)=>s+r.se,0)),period,sheets,stagingSpreadsheetId:'',integrityStatus:printedEmployeeCount?'VALIDADO CONTRA TOTAL DEL ARCHIVO':'VALIDADO ESTRUCTURALMENTE'};\nreturn [{json:RESULT}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 59024, 34688 ], "id": "c5ce7b48-654e-4749-94f9-06311f088443", "name": "Parsear Q2 Excel" }, { "parameters": { "jsCode": "\nconst base=$items('Inicializar ejecución')[0];\nconst bin=base.binary||{};\nfunction norm(s){return String(s||'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase().replace(/\\s+/g,' ').trim();}\nfunction formatOf(file,name){\n const s=norm(`${name} ${file?.mimeType||''}`);\n if(/\\.pdf(?:$|\\s)|application\\/pdf/.test(s)) return 'PDF';\n if(/\\.xlsx?(?:$|\\s)|spreadsheet|excel|application\\/vnd\\.ms-excel/.test(s)) return 'EXCEL';\n return '';\n}\nconst entries=Object.entries(bin).map(([key,file])=>{\n const name=String(file.fileName||key), k=norm(key), n=norm(name), all=`${k} ${n}`;\n const format=formatOf(file,name); let score=format?100:-1000;\n \n if(/planilla\\s*css|detalleplanilla|caja.*seguro|css/.test(k)) score+=650;\n if(/planilla\\s*css|detalleplanilla|caja.*seguro|css/.test(n)) score+=240;\n if(format!=='EXCEL') score-=900;\n if(/nomina|quincena|extraordin|vacacion|liquidacion/.test(all)) score-=300;\n\n return {key,file,name,format,score,k,n,all};\n}).sort((a,b)=>b.score-a.score);\nconst chosen=entries[0];\nif(!chosen||chosen.score<250||!chosen.format) throw new Error('No pude identificar de forma segura la Planilla CSS. Adjunta en ese campo el archivo oficial DetallePlanilla en Excel (.xls/.xlsx).');\nreturn [{json:{...base.json,fileName:chosen.name,originalBinaryKey:chosen.key,format:chosen.format,inputMimeType:chosen.file?.mimeType||''},binary:{data:chosen.file}}];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 59264, 34832 ], "id": "0a9b3b13-da91-49d4-a018-3f72cc0209a1", "name": "Preparar CSS" }, { "parameters": { "operation": "xls", "options": { "headerRow": false, "includeEmptyCells": true, "rawData": false, "readAsString": false } }, "type": "n8n-nodes-base.extractFromFile", "typeVersion": 1.1, "position": [ 59504, 34832 ], "id": "3daecbfd-ae7c-4cbb-ad6b-564c1523972e", "name": "Crear staging CSS" }, { "parameters": { "jsCode": "const rows=$input.all().map(x=>x.json?.row).filter(r=>Array.isArray(r));\nif(!rows.length) throw new Error('No se pudieron leer filas de Planilla CSS directamente desde el Excel.');\nreturn [{json:{valueRanges:[{range:'Sheet1!A1:AZ50000',values:rows}],directExtraction:true}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 59744, 34832 ], "id": "0456e69e-4dbf-4568-8ab9-1f1efcc4d730", "name": "Reunir staging CSS" }, { "parameters": { "jsCode": "const payload=$json.body&&typeof $json.body==='object'?$json.body:$json;\nconst valueRanges=payload.valueRanges||[],prep=$items('Preparar CSS')[0].json,config=$items('Inicializar ejecución')[0].json.config||{};\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction asc(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase();}\nfunction num(v){if(v===null||v===undefined||v==='')return 0;if(typeof v==='number')return v;let s=String(v).replace(/B\\/.?|\\$|,/g,'').replace(/\\s/g,'');if(/^\\(.*\\)$/.test(s))s='-'+s.slice(1,-1);const n=Number(s);return Number.isFinite(n)?n:0;}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction monthPeriod(text){const months={ENERO:1,FEBRERO:2,MARZO:3,ABRIL:4,MAYO:5,JUNIO:6,JULIO:7,AGOSTO:8,SEPTIEMBRE:9,OCTUBRE:10,NOVIEMBRE:11,DICIEMBRE:12};const m=asc(text).match(/(ENERO|FEBRERO|MARZO|ABRIL|MAYO|JUNIO|JULIO|AGOSTO|SEPTIEMBRE|OCTUBRE|NOVIEMBRE|DICIEMBRE)\\s+(20\\d{2})/);return m?{month:months[m[1]],year:Number(m[2]),label:`${m[1][0]+m[1].slice(1).toLowerCase()} ${m[2]}`}:null;}\nfunction isEmployeeHeader(row){const h=(row||[]).map(asc),j=h.join('|');return j.includes('SEGURO SOCIAL')&&j.includes('APELLIDO')&&j.includes('NOMBRE')&&(/CEDULA|PASAPORTE|REGISTRO CIVIL|IDENTIFICACION|DOCUMENTO/.test(j));}\nif(!valueRanges.length)throw new Error('La Planilla CSS no contiene hojas legibles.');\nconst ranked=valueRanges.map(vr=>({vr,count:(vr.values||[]).reduce((s,r)=>s+(isEmployeeHeader(r)?1:0),0),rows:(vr.values||[]).length})).sort((a,b)=>b.count-a.count||b.rows-a.rows);\nconst chosen=ranked[0],values=chosen?.vr?.values||[];if(!values.length||chosen.count<1)throw new Error('No pude localizar la estructura de empleados dentro de la Planilla CSS oficial.');\nlet period=null;for(let r=0;rv==='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(drclean(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/DECIMO TERCER MES|XIII MES/.test(k)),dtmBase=round(dtmKey?components[dtmKey]:0);\n employees.push({ssNumber,identity,docType,lastname,firstname,name:clean(`${lastname} ${firstname}`),sex:sexIdx>=0?clean(data[sexIdx]):'',salary,salaryAdjusted,dtmBase,planillaSS,planillaSE,planillaDTM,components});\n}\nif(!employees.length)throw new Error('No pude extraer empleados de la Planilla CSS.');\nconst dupIds=new Set(),seenIds=new Set();for(const e of employees){const k=asc(e.identity||e.ssNumber);if(!k)continue;if(seenIds.has(k))dupIds.add(k);seenIds.add(k);}if(dupIds.size)throw new Error(`La Planilla CSS contiene identificaciones duplicadas (${[...dupIds].slice(0,5).join(', ')}). Se detuvo el cruce.`);\nconst reconstructed={seguroSocial:round(employees.reduce((s,e)=>s+e.planillaSS,0)),seguroEducativo:round(employees.reduce((s,e)=>s+e.planillaSE,0)),dtm:round(employees.reduce((s,e)=>s+e.planillaDTM,0)),salario:round(employees.reduce((s,e)=>s+e.salary,0)),salarioAjustado:round(employees.reduce((s,e)=>s+e.salaryAdjusted,0))};\nconst tol=Number(config.cssIntegrityTolerance??0.10),integrity=[];\nfor(const k of ['seguroSocial','seguroEducativo','dtm','salario','salarioAjustado']){if(!foundTotals.has(k))continue;const diff=round(reconstructed[k]-Number(totals[k]||0));integrity.push({metric:k,official:round(totals[k]),reconstructed:reconstructed[k],diff,ok:Math.abs(diff)<=tol});}\nconst bad=integrity.filter(x=>!x.ok);if(bad.length)throw new Error(`Fallo de integridad en Planilla CSS: ${bad.map(x=>`${x.metric} oficial ${x.official} vs reconstruido ${x.reconstructed} (dif. ${x.diff})`).join('; ')}. Se detuvo el cruce.`);\nconst now=new Date(),pad=n=>String(n).padStart(2,'0'),reportTitle=`Cruce Seguridad Social Panamá - ${period.label} - ${pad(now.getDate())}-${pad(now.getMonth()+1)}-${now.getFullYear()}`;\nreturn [{json:{fileName:prep.fileName,period,periodoLabel:period.label,reportTitle,employees,employeeCount:employees.length,totals,reconstructed,integrity,sourceSheet:chosen.vr.range||'',stagingSpreadsheetId:''}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 61184, 34832 ], "id": "c487313c-347e-4b81-ac82-f9f2f13bc945", "name": "Parsear CSS" }, { "parameters": { "operation": "pdf", "options": { "joinPages": true } }, "type": "n8n-nodes-base.extractFromFile", "typeVersion": 1.1, "position": [ 61664, 34832 ], "id": "a08a8ee5-7dd5-46a0-bf76-288870e633ae", "name": "Extraer texto PDFs" }, { "parameters": { "jsCode": "function parseSupplementals(extracted, metaItems, css, init={}) {\nconst cfg=init.config||{};\nconst rates=cfg.defaultRates||{ssRegular:0.0975,se:0.0125,ssDtm:0.0725};\nconst targetMonth=Number(css.period?.month||0),targetYear=Number(css.period?.year||0);\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'').toUpperCase().replace(/[^A-Z0-9 ]/g,' ').replace(/\\s+/g,' ').trim();}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction parseDMY(s){const m=String(s||'').match(/(\\d{1,2})\\/(\\d{1,2})\\/(20\\d{2})/);if(!m)return null;return new Date(Number(m[3]),Number(m[2])-1,Number(m[1]));}\nfunction dateKey(d){return d?`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`:'';}\nfunction fmtDate(d){return d?`${String(d.getDate()).padStart(2,'0')}/${String(d.getMonth()+1).padStart(2,'0')}/${d.getFullYear()}`:'';}\nfunction inTarget(d){return !!(d&&d.getMonth()+1===targetMonth&&d.getFullYear()===targetYear);}\nfunction overlapTarget(a,b){if(!a||!b)return null;const x=[a,b].sort((p,q)=>p-q),first=new Date(targetYear,targetMonth-1,1),last=new Date(targetYear,targetMonth,0,23,59,59);return x[0]<=last&&x[1]>=first;}\nfunction regexAll(text,re){let flags=re.flags||'';if(!flags.includes('g'))flags+='g';const rg=new RegExp(re.source,flags),out=[];let m;while((m=rg.exec(String(text||'')))!==null){out.push(m);if(m[0]==='')rg.lastIndex++;}return out;}\nfunction markers(text,re){return regexAll(text,re).map(m=>({index:m.index,end:m.index+m[0].length,match:m}));}\nfunction segPre(text,ms,j){return text.slice(j>0?ms[j-1].end:0,ms[j].index);}\nfunction segPost(text,ms,j){return text.slice(ms[j].end,j+1({raw:m[0],index:m.index,val:moneyNum(m[0])})).filter(x=>x.val!==null);}\nfunction moneyNearLabel(block,labelRe){\n const lines=String(block||'').split('\\n'), candidates=[];\n for(let i=0;i=lines.length)break;const p=lines[j].trim();if(!p)continue;if(/^\\(?-?\\d[\\d,]*(?:\\.\\d+)?\\)?$/.test(p)){found={line:i,val:moneyNum(p),method:`NEXT_${d}`};break;}else break;}}\n if(found)candidates.push(found);\n }\n return candidates.length?candidates[candidates.length-1]:{line:-1,val:null,method:''};\n}\nfunction textNearLabel(block,labelRe,valueRe){\n const lines=String(block||'').split('\\n'),out=[];for(let i=0;ip-q);return {a:x[0],b:x[1],start:dateKey(x[0]),end:dateKey(x[1]),display:`${fmtDate(x[0])} - ${fmtDate(x[1])}`};}\nfunction periodLibre(post){\n const lines=String(post||'').split('\\n');for(let i=0;ix[0]);if(same.length>=2)return mkPeriod(same[0],same[1]);const prev=[...lines.slice(Math.max(0,i-4),i+1).join(' ').matchAll(/\\d{1,2}\\/\\d{1,2}\\/20\\d{2}/g)].map(x=>x[0]);if(prev.length>=2)return mkPeriod(prev[prev.length-2],prev[prev.length-1]);const next=[...lines.slice(i,Math.min(lines.length,i+5)).join(' ').matchAll(/\\d{1,2}\\/\\d{1,2}\\/20\\d{2}/g)].map(x=>x[0]);if(next.length>=2)return mkPeriod(next[0],next[1]);}return null;\n}\nfunction mkPeriod(a,b){const d1=parseDMY(a),d2=parseDMY(b);if(!d1||!d2)return null;const x=[d1,d2].sort((p,q)=>p-q);return {startDate:x[0],endDate:x[1],start:dateKey(x[0]),end:dateKey(x[1]),display:`${fmtDate(x[0])} - ${fmtDate(x[1])}`};}\nfunction recordFingerprint(r){return [r.category,clean(r.code),norm(r.name),clean(r.date),clean(r.transaction),round(r.ss),round(r.se),round(r.gross),round(r.regularBase),round(r.dtmBase)].join('|');}\nfunction samePerson(a,b){const ac=clean(a.code).replace(/^0+(?=\\d)/,''),bc=clean(b.code).replace(/^0+(?=\\d)/,'');if(ac&&bc&&ac===bc)return true;return !!(norm(a.name)&&norm(a.name)===norm(b.name));}\nfunction vacFields(block){return {vac:moneyNearLabel(block,/Vacaciones(?!\\s+Correspondientes)/i),gross:moneyNearLabel(block,/TOTAL\\s+BRUTO\\s*:?/i),ss:moneyNearLabel(block,/Seg\\.\\s*Soc\\.?/i),se:moneyNearLabel(block,/Seg\\.\\s*Edu\\.?/i)};}\nfunction vacCandidateOK(f){if([f.vac.val,f.gross.val,f.ss.val,f.se.val].some(v=>v===null))return false;const g=f.gross.val;return close(f.vac.val,g,.08)&&close(f.ss.val,round(g*Number(rates.ssRegular||.0975)),.16)&&close(f.se.val,round(g*Number(rates.se||.0125)),.13);}\nfunction extraFields(block){return {gross:moneyNearLabel(block,/\\bTotales\\b/i),ss:moneyNearLabel(block,/Seg\\.\\s*Soc\\.?/i),se:moneyNearLabel(block,/Seg\\.\\s*Edu\\.?/i)};}\nfunction extraCandidateOK(f){if([f.gross.val,f.ss.val,f.se.val].some(v=>v===null))return false;const g=f.gross.val;return close(f.ss.val,round(g*Number(rates.ssRegular||.0975)),.16)&&close(f.se.val,round(g*Number(rates.se||.0125)),.13);}\nfunction liqFields(block){return {date:dateNearLabel(block,/FECHA\\s+DE\\s+LIQUIDACI[ÓO]N\\s*:?/i),transaction:txnNearLabel(block),ss:moneyNearLabel(block,/Seg\\.\\s*Soc\\.?/i),se:moneyNearLabel(block,/Seg\\.\\s*Edu\\.?/i),sv:moneyNearLabel(block,/Salario,?\\s*Vac,?\\s*13\\s*Mes\\s*:?/i),sal:moneyNearLabel(block,/Salario\\s+Reg\\.?\\s*:?/i),vac:moneyNearLabel(block,/^\\s*Vacaciones\\s*:?/i),dtm:moneyNearLabel(block,/^\\s*XIII\\s+Mes\\s*:?/i),pai:moneyNearLabel(block,/Preav,?\\s*P\\s*Ant,?\\s*Indem\\s*:?/i),gross:moneyNearLabel(block,/Total\\s+Bruto\\s*:?/i)};}\nfunction liqCandidateOK(f){if(!f.date||!f.transaction||[f.ss.val,f.se.val,f.sv.val,f.sal.val,f.vac.val,f.dtm.val,f.pai.val,f.gross.val].some(v=>v===null))return false;const rb=round(f.sal.val+f.vac.val),compSV=round(rb+f.dtm.val),compGross=round(f.sv.val+f.pai.val),es=round(rb*Number(rates.ssRegular||.0975)+f.dtm.val*Number(rates.ssDtm||.0725)),ee=round(rb*Number(rates.se||.0125));return close(f.sv.val,compSV,.14)&&close(f.gross.val,compGross,.14)&&close(f.ss.val,es,.17)&&close(f.se.val,ee,.13);}\nconst records=[],validations=[],supportDocs=[],files=[];\nfunction validation(status,category,fileName,detail){validations.push({status,severity:(status==='OK'||status==='IGNORADO'||status==='EXCLUIDO')?'INFO':'WARNING',category,fileName,detail});}\nfunction addRecord(r){records.push({...r,ss:round(r.ss||0),se:round(r.se||0),gross:round(r.gross||0),regularBase:round(r.regularBase||0),dtmBase:round(r.dtmBase||0),parserMode:'STRUCTURE_AWARE_V10'});}\nfor(let i=0;i\\n'):String(raw||''));if(!text.trim()){validation('REVISAR',category,fileName,'PDF sin texto extraíble; archivo excluido.');continue;}\n if(category==='vacaciones'){\n const ms=markers(text,/Empleado\\s*#\\s*(\\d+)\\s*:\\s*(.+?)(?=\\s+(?:Clave\\s+ISR|Vacaciones\\s+Correspondientes|Periodo\\s+Libre)|[\\n\\r]|$)/i);if(!ms.length){validation('REVISAR',category,fileName,'No se detectaron empleados.');continue;}\n const firstPre=vacFields(segPre(text,ms,0)),orientation=vacCandidateOK(firstPre)?'PRE':'POST',planillaNumber=globalPlanilla(text),generatedAt=globalGenerated(text);let inc=0,out=0,review=0;\n for(let j=0;j({code:clean(x.match[1]).replace(/^0+(?=\\d)/,''),name:clean(x.match[2]).replace(/\\s+(?:A[nñ]o|C[eé]dula:).*$/i,'')}));const seen=new Set(),employees=[];for(const e of rawm){const k=e.code+'|'+norm(e.name);if(!seen.has(k)){seen.add(k);employees.push(e);}}supportDocs.push({fileName,employees});validation('IGNORADO',category,fileName,`Documento de cálculo/soporte (${employees.length} empleado(s)); no se suma.`);continue;}\n const ms=markers(text,/Empleado\\s*#\\s*(\\d+)\\s*:\\s*(.+?)(?=\\s+(?:FECHA\\s+DE\\s+LIQUIDACI[ÓO]N|Clave\\s+ISR|N[uú]mero\\s+de\\s+Transacci[oó]n)|[\\n\\r]|$)/i);if(!ms.length){validation('REVISAR',category,fileName,'No se detectaron empleados resumidos.');continue;}const firstPre=liqFields(segPre(text,ms,0)),orientation=liqCandidateOK(firstPre)?'PRE':'POST',planillaNumber=globalPlanilla(text),generatedAt=globalGenerated(text);let inc=0,out=0,review=0;\n for(let j=0;j(Number(b.planillaNumber)||0)-(Number(a.planillaNumber)||0)||String(b.generatedAt).localeCompare(String(a.generatedAt)));for(const r of arr.slice(1)){r.included=false;r.reason=`Sustituida por planilla de vacaciones más reciente (${arr[0].planillaNumber||arr[0].fileName}).`;}}\nconst liqAll=records.filter(r=>r.category==='Liquidación');for(const doc of supportDocs){const missing=(doc.employees||[]).filter(e=>!liqAll.some(r=>samePerson(e,r)));if(missing.length)validation('REVISAR','liquidaciones',doc.fileName,`Documento de cálculo contiene persona(s) sin planilla resumida: ${missing.map(x=>`${x.name}${x.code?` (#${x.code})`:''}`).join(', ')}.`);}\nconst includedRecords=records.filter(r=>r.included),catCount=cat=>includedRecords.filter(r=>r.category===cat).length;return {records,validations,supportDocs,includedRecords,files:[...new Set(files)],counts:{extraordinarias:catCount('Extraordinaria'),vacaciones:catCount('Vacaciones'),liquidaciones:catCount('Liquidación')},totals:{extraSS:round(includedRecords.filter(r=>r.category==='Extraordinaria').reduce((s,r)=>s+r.ss,0)),vacSS:round(includedRecords.filter(r=>r.category==='Vacaciones').reduce((s,r)=>s+r.ss,0)),liqSS:round(includedRecords.filter(r=>r.category==='Liquidación').reduce((s,r)=>s+r.ss,0)),totalSS:round(includedRecords.reduce((s,r)=>s+r.ss,0)),totalSE:round(includedRecords.reduce((s,r)=>s+r.se,0))},parserVersion:'STRUCTURE_AWARE_V10_SIN_IA',quality:{status:validations.some(v=>v.status==='REVISAR')?'REVISAR':'VALIDADO',rule:'La orientación PRE/POST se determina con el primer empleado del archivo mediante controles matemáticos. La misma orientación se aplica a todo el archivo. Vacaciones lee Periodo Libre siempre del bloque posterior. No hay alineación global por listas.'}};\n}\n\nconst __result = parseSupplementals($input.all(), $items('Normalizar PDFs descargados'), (($items('Parsear CSS')[0]||{}).json||{}), (($items('Inicializar ejecución')[0]||{}).json||{}));\nreturn [{json:__result}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 61904, 34832 ], "id": "14f0d70e-f329-49c3-b500-4fdad7c2112f", "name": "Parsear PDFs", "notesInFlow": true, "notes": "Parser complementarios V8 SIN IA: extracción directa por campos del mismo empleado, detección PRE/POST por archivo, validación contable/tasas, soporte para PDF.js y decimales fragmentados. No alinea listas globales ni toma montos de empleados vecinos." }, { "parameters": { "jsCode": "const init=$items('Inicializar ejecución')[0].json,config=init.config||{};\nconst q1=$items('Resultado Q1')[0].json,q2=$items('Resultado Q2')[0].json,css=$items('Parsear CSS')[0].json,pdfs=$items('Parsear PDFs')[0].json;\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase().replace(/\\bVASQUEZ\\b/g,'VAZQUEZ').replace(/[^A-Z0-9 ]/g,' ').replace(/\\s+/g,' ').trim();}\nconst particles=new Set(['DE','DEL','LA','LAS','LOS','Y','DA','DOS']);\nfunction core(v){return norm(v).split(' ').filter(t=>t&&!particles.has(t));}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction money(n){return `B/. ${Number(n||0).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}`;}\nfunction parseDMY(s){const m=String(s||'').match(/(\\d{1,2})\\/(\\d{1,2})\\/(20\\d{2})/);return m?new Date(Number(m[3]),Number(m[2])-1,Number(m[1])):null;}\nfunction validatePayrollPeriod(label,p,half){\n if(!p)return {status:'REVISAR',detail:`${label}: período no detectado automáticamente; se validó estructura y totales, pero conviene confirmar el archivo.`};\n const a=parseDMY(p.start),b=parseDMY(p.end),tm=css.period.month,ty=css.period.year;\n if(!a||!b)return {status:'REVISAR',detail:`${label}: período ilegible.`};\n const same=a.getFullYear()===ty&&b.getFullYear()===ty&&a.getMonth()+1===tm&&b.getMonth()+1===tm;\n if(!same)throw new Error(`${label} pertenece a ${p.start} - ${p.end}, pero la Planilla CSS es ${css.periodoLabel}. Se detuvo el cruce.`);\n if(a>b)throw new Error(`${label} tiene un período invertido (${p.start} - ${p.end}). Se detuvo el cruce.`);\n if(half===1&&(a.getDate()>15||b.getDate()>16))throw new Error(`${label} no parece ser la 1era quincena (${p.start} - ${p.end}). Revisa que no hayas cargado la 2da quincena por error.`);\n if(half===2&&(a.getDate()<15||b.getDate()<27))throw new Error(`${label} no parece ser la 2da quincena (${p.start} - ${p.end}). Revisa que no hayas cargado la 1era quincena por error.`);\n return {status:'OK',detail:`${label}: ${p.start} - ${p.end}`};\n}\nfunction payrollFingerprint(x){return (x.records||[]).map(r=>[String(r.code||''),norm(r.name),round(r.ss),round(r.se),round(r.gross)].join('|')).sort().join('||');}\nconst periodChecks=[validatePayrollPeriod('Nómina Q1',q1.period,1),validatePayrollPeriod('Nómina Q2',q2.period,2)];\nconst fpQ1=payrollFingerprint(q1),fpQ2=payrollFingerprint(q2);\nif(fpQ1&&fpQ2&&fpQ1===fpQ2)throw new Error('Las nóminas Q1 y Q2 contienen exactamente los mismos empleados y montos. Se detuvo el cruce porque probablemente se cargó la misma quincena dos veces.');\n// Inferencia dinámica de tasas del trabajador desde los propios recibos, para no depender de tasas hardcodeadas año tras año.\nfunction median(a){const x=a.filter(Number.isFinite).sort((p,q)=>p-q);if(!x.length)return null;const m=Math.floor(x.length/2);return x.length%2?x[m]:(x[m-1]+x[m])/2;}\nconst ratioSS=[],ratioSE=[];\nfor(const r of [...(q1.records||[]),...(q2.records||[])]){const g=Number(r.gross)||0,ss=Number(r.ss)||0,se=Number(r.se)||0;if(g>=20&&ss>0){const x=ss/g;if(x>.03&&x<.20)ratioSS.push(x);}if(g>=20&&se>0){const x=se/g;if(x>.001&&x<.05)ratioSE.push(x);}}\nconst fallback=config.defaultRates||{ssRegular:.0975,se:.0125,ssDtm:.0725};\nconst ssMed=median(ratioSS),seMed=median(ratioSE),ssRegular=Number((ssMed??fallback.ssRegular).toFixed(4)),seRate=Number((seMed??fallback.se).toFixed(4));\nconst dtmRatios=[];for(const r of pdfs.includedRecords||[]){if(r.category!=='Liquidación'||Number(r.dtmBase||0)<=1)continue;const x=(Number(r.ss||0)-Number(r.regularBase||0)*ssRegular)/Number(r.dtmBase);if(x>.03&&x<.15)dtmRatios.push(x);}\nconst dtmMed=median(dtmRatios),ssDtm=Number((dtmMed??fallback.ssDtm).toFixed(4));\nconst rateInfo={ssRegular,se:seRate,ssDtm,samplesSS:ratioSS.length,samplesSE:ratioSE.length,samplesDTM:dtmRatios.length,usedFallbackSS:ssMed===null,usedFallbackSE:seMed===null,usedFallbackDTM:dtmMed===null};\nif(ratioSS.length<(config.minRateSamples||20)||ratioSE.length<(config.minRateSamples||20))throw new Error(`No hubo suficientes registros para inferir las tasas de S.S./S.E. de forma segura (${ratioSS.length}/${ratioSE.length}). Se detuvo el cruce.`);\nconst plans=(css.employees||[]).map((p,index)=>({...p,_idx:index,expectedWorkerSS:round(Number(p.salaryAdjusted||0)*ssRegular+Number(p.dtmBase||0)*ssDtm),expectedWorkerSE:round(Number(p.salaryAdjusted||0)*seRate)}));\n// Une todas las fuentes por Employee # cuando existe; cuando el PDF pierde el código, usa el nombre exacto conservadoramente.\nconst observations=[];for(const r of q1.records||[])observations.push({...r,kind:'q1'});for(const r of q2.records||[])observations.push({...r,kind:'q2'});for(const r of pdfs.includedRecords||[]){const kind=r.category==='Extraordinaria'?'extra':r.category==='Vacaciones'?'vac':'liq';observations.push({...r,kind});}\nconst groups=new Map(),nameCodes=new Map();\nfor(const r of observations){const code=clean(r.code).replace(/^0+(?=\\d)/,''),nn=norm(r.name);if(!code)continue;const k=`C:${code}`;if(!groups.has(k))groups.set(k,{key:k,code,names:[],obs:[]});const g=groups.get(k);if(r.name&&!g.names.includes(r.name))g.names.push(r.name);g.obs.push(r);if(!nameCodes.has(nn))nameCodes.set(nn,new Set());nameCodes.get(nn).add(code);}\nfor(const r of observations){const code=clean(r.code).replace(/^0+(?=\\d)/,'');if(code)continue;const nn=norm(r.name),codes=nameCodes.get(nn)||new Set();let g;if(codes.size===1)g=groups.get(`C:${[...codes][0]}`);else{const k=`N:${nn}`;if(!groups.has(k))groups.set(k,{key:k,code:'',names:[],obs:[]});g=groups.get(k);}if(r.name&&!g.names.includes(r.name))g.names.push(r.name);g.obs.push(r);}\nfor(const g of groups.values()){for(const k of ['q1SS','q1SE','q2SS','q2SE','extraSS','extraSE','vacSS','vacSE','liqSS','liqSE'])g[k]=0;for(const r of g.obs){const ss=Number(r.ss)||0,se=Number(r.se)||0;g[`${r.kind}SS`]+=ss;g[`${r.kind}SE`]+=se;}g.name=g.names[0]||'';g.totalSS=round(g.q1SS+g.q2SS+g.extraSS+g.vacSS+g.liqSS);g.totalSE=round(g.q1SE+g.q2SE+g.extraSE+g.vacSE+g.liqSE);}\nconst sources=[...groups.values()];\n// Similaridad conservadora OPTIMIZADA: conserva las mismas reglas de matching,\n// pero evita comparar cada fuente contra cada empleado CSS con Levenshtein completo.\n// Primero crea índices baratos (tokens, prefijos, iniciales y montos) y solo ejecuta\n// la similitud costosa sobre un conjunto reducido de candidatos plausibles.\nfunction levRaw(a,b){\n a=String(a||''); b=String(b||'');\n if(a===b)return 0;\n if(!a.length)return b.length;\n if(!b.length)return a.length;\n if(a.length>b.length){const t=a;a=b;b=t;}\n let prev=new Uint16Array(a.length+1),cur=new Uint16Array(a.length+1);\n for(let i=0;i<=a.length;i++)prev[i]=i;\n for(let j=1;j<=b.length;j++){\n cur[0]=j;\n const bj=b.charCodeAt(j-1);\n for(let i=1;i<=a.length;i++){\n const cost=a.charCodeAt(i-1)===bj?0:1;\n const ins=cur[i-1]+1,del=prev[i]+1,sub=prev[i-1]+cost;\n cur[i]=Math.min(ins,del,sub);\n }\n const tmp=prev;prev=cur;cur=tmp;\n }\n return prev[a.length];\n}\nfunction compactNorm(v){return norm(v).replace(/ /g,'');}\nfunction simPrepared(a,b){a=String(a||'');b=String(b||'');return !a||!b?0:1-levRaw(a,b)/Math.max(a.length,b.length);}\nfunction tokenCovPrepared(A,B){\n if(!A.length||!B.length)return 0;\n const avg=(X,Y)=>{\n let total=0;\n for(const x of X){\n let best=0;\n for(const y of Y){\n const s=simPrepared(x,y);\n if(s>best)best=s;\n if(best===1)break;\n }\n total+=best;\n }\n return total/X.length;\n };\n return (avg(A,B)+avg(B,A))/2;\n}\nfunction sourceParts(name){\n const raw=String(name||'');let l='',f='';\n if(raw.includes(',')){const pos=raw.indexOf(',');l=raw.slice(0,pos);f=raw.slice(pos+1);}\n else{const t=norm(raw).split(' ');l=t.shift()||'';f=t.join(' ');}\n const L=core(l),F=core(f);\n return {\n last:norm(l),first:norm(f),last1:L[0]||'',first1:F[0]||'',\n lastC:compactNorm(l),firstC:compactNorm(f),\n last1C:compactNorm(L[0]||''),first1C:compactNorm(F[0]||'')\n };\n}\nfunction planParts(p){\n const L=core(p.lastname),F=core(p.firstname);\n return {\n last:norm(p.lastname),first:norm(p.firstname),last1:L[0]||'',first1:F[0]||'',\n lastC:compactNorm(p.lastname),firstC:compactNorm(p.firstname),\n last1C:compactNorm(L[0]||''),first1C:compactNorm(F[0]||'')\n };\n}\nfunction uniqTokens(v){return [...new Set(core(v).map(compactNorm).filter(Boolean))];}\nfunction addIndex(map,k,v){if(!k)return;if(!map.has(k))map.set(k,[]);map.get(k).push(v);}\nfunction addBucket(set,map,k){for(const v of map.get(k)||[])set.add(v);}\nfunction prefix(v,n){v=String(v||'');return v.length>=n?v.slice(0,n):v;}\nfunction amountClass(dss,dse){if(dss<=.05&&dse<=.05)return 1;if(dss<=.12&&dse<=.12)return .95;if(dss<=.25&&dse<=.25)return .85;if(dss<=.5&&dse<=.5)return .70;if(dss<=1&&dse<=1)return .50;return 0;}\n\nconst planMeta=plans.map((p,pi)=>({\n p,pi,pp:planParts(p),tokens:uniqTokens(p.name),\n pairKey:`${p.expectedWorkerSS.toFixed(2)}|${p.expectedWorkerSE.toFixed(2)}`\n}));\nconst sourceMeta=sources.map((s,si)=>({s,si,sp:sourceParts(s.name),tokens:uniqTokens(s.name)}));\n\nconst pairFreq=new Map(),firstFreq=new Map(),lastFreq=new Map();\nfor(const m of planMeta){\n pairFreq.set(m.pairKey,(pairFreq.get(m.pairKey)||0)+1);\n firstFreq.set(m.pp.first1,(firstFreq.get(m.pp.first1)||0)+1);\n lastFreq.set(m.pp.last1,(lastFreq.get(m.pp.last1)||0)+1);\n}\n\nconst byToken=new Map(),byLast2=new Map(),byLast3=new Map(),byFirst2=new Map(),byFirst3=new Map(),byInitialPair=new Map(),byAmount=new Map();\nfunction amountKey(ss,se){return `${Math.round((Number(ss)||0)*4)}|${Math.round((Number(se)||0)*4)}`;}\nfor(const m of planMeta){\n for(const t of m.tokens)addIndex(byToken,t,m.pi);\n addIndex(byLast2,prefix(m.pp.last1C,2),m.pi);\n addIndex(byLast3,prefix(m.pp.last1C,3),m.pi);\n addIndex(byFirst2,prefix(m.pp.first1C,2),m.pi);\n addIndex(byFirst3,prefix(m.pp.first1C,3),m.pi);\n addIndex(byInitialPair,`${m.pp.last1C[0]||''}|${m.pp.first1C[0]||''}`,m.pi);\n addIndex(byAmount,amountKey(m.p.expectedWorkerSS,m.p.expectedWorkerSE),m.pi);\n}\n\nconst candidates=[],candidatesBySource=new Map();\nconst tierBuckets=[[],[],[],[],[],[]];\nfor(const sm of sourceMeta){\n const s=sm.s,sp=sm.sp,pool=new Set();\n\n // Evidencia de identidad exacta o por prefijo.\n for(const t of sm.tokens)addBucket(pool,byToken,t);\n addBucket(pool,byLast3,prefix(sp.last1C,3));\n addBucket(pool,byFirst3,prefix(sp.first1C,3));\n addBucket(pool,byLast2,prefix(sp.last1C,2));\n addBucket(pool,byFirst2,prefix(sp.first1C,2));\n addBucket(pool,byInitialPair,`${sp.last1C[0]||''}|${sp.first1C[0]||''}`);\n\n // Evidencia de monto: busca hasta +/- B/.1.00 en pasos de B/.0.25.\n const ssQ=Math.round((Number(s.totalSS)||0)*4),seQ=Math.round((Number(s.totalSE)||0)*4);\n for(let ds=-4;ds<=4;ds++)for(let de=-4;de<=4;de++)addBucket(pool,byAmount,`${ssQ+ds}|${seQ+de}`);\n\n // Red de seguridad: si los índices produjeron muy pocos candidatos, agrega los\n // 12 CSS más cercanos por monto. Este barrido solo hace aritmética, no Levenshtein.\n if(pool.size<12){\n const closest=[];\n for(const pm of planMeta){\n const d=Math.abs((Number(s.totalSS)||0)-pm.p.expectedWorkerSS)+Math.abs((Number(s.totalSE)||0)-pm.p.expectedWorkerSE);\n if(closest.length<12){closest.push([d,pm.pi]);closest.sort((a,b)=>a[0]-b[0]);}\n else if(da[0]-b[0]);}\n }\n for(const [,pi] of closest)pool.add(pi);\n }\n\n const local=[];\n for(const pi of pool){\n const pm=planMeta[pi],p=pm.p,pp=pm.pp;\n const lastFirst=simPrepared(sp.last1C,pp.last1C);\n const lastFull=simPrepared(sp.lastC,pp.lastC);\n const firstSim=Math.max(simPrepared(sp.first1C,pp.first1C),simPrepared(sp.firstC,pp.firstC));\n const gen=tokenCovPrepared(sm.tokens,pm.tokens);\n const dss=Math.abs(s.totalSS-p.expectedWorkerSS),dse=Math.abs(s.totalSE-p.expectedWorkerSE),am=amountClass(dss,dse);\n const pairUnique=(pairFreq.get(pm.pairKey)||0)===1;\n const firstUnique=(firstFreq.get(pp.first1)||0)===1,lastUnique=(lastFreq.get(pp.last1)||0)===1;\n let tier=0,method='';\n if(sp.last1&&sp.last1===pp.last1&&firstSim>=.84){tier=1;method='APELLIDO + NOMBRE';}\n else if(lastFirst>=.78&&firstSim>=.90&&am>=.85){tier=2;method='VARIANTE DE APELLIDO + NOMBRE + MONTO';}\n else if(gen>=.86&&am>=.70&&(lastFirst>=.65||firstSim>=.80)){tier=3;method='NOMBRE FUZZY + MONTO';}\n else if(firstSim>=.95&&(firstUnique||pairUnique)&&am>=.95){tier=4;method='NOMBRE + MONTO ÚNICO';}\n else if(sp.last1&&sp.last1===pp.last1&&lastUnique&&am>=.95){tier=4;method='APELLIDO ÚNICO + MONTO';}\n else if(gen>=.72&&pairUnique&&am>=.95){tier=5;method='MONTO ÚNICO + EVIDENCIA DE NOMBRE';}\n if(!tier)continue;\n const nameScore=.38*lastFirst+.32*firstSim+.18*gen+.12*lastFull;\n const score=(6-tier)*10+.58*nameScore+.42*am-.002*Math.min(dss,10);\n const c={tier,score,si:sm.si,pi,nameScore,lastFirst,firstSim,gen,am,dss,dse,method};\n candidates.push(c);local.push(c);tierBuckets[tier].push(c);\n }\n candidatesBySource.set(sm.si,local);\n}\n\nconst usedS=new Set(),usedP=new Set(),matches=[];\nfor(let tier=1;tier<=5;tier++){\n const arr=tierBuckets[tier].sort((a,b)=>b.score-a.score);\n for(const c of arr){\n if(usedS.has(c.si)||usedP.has(c.pi))continue;\n usedS.add(c.si);usedP.add(c.pi);\n matches.push({...c,source:sources[c.si],plan:plans[c.pi]});\n }\n}\n// Señala matches que cuadran por monto pero cuya identidad es débil, y empates de identidad indistinguibles.\nfor(const m of matches){\n const local=candidatesBySource.get(m.si)||[];\n let ambiguous=false;\n for(const c of local){\n if(c.pi===m.pi)continue;\n if(c.tier<=m.tier&&c.dss<=.05&&c.dse<=.05&&Math.abs(c.score-m.score)<.03){ambiguous=true;break;}\n }\n m.ambiguous=ambiguous;\n m.identityReview=m.tier>=4||m.ambiguous;\n m.confidence=m.tier===1&&!m.ambiguous?'ALTA':m.tier<=3&&!m.ambiguous?'MEDIA':'REVISAR';\n}\nconst onlySource=sources.filter((_,i)=>!usedS.has(i)),onlyPlan=plans.filter((_,i)=>!usedP.has(i)),identityReview=matches.filter(m=>m.identityReview);\nconst tolerance=Number(config.amountTolerance??.10),amountDiffs=matches.filter(m=>Math.abs(m.source.totalSS-m.plan.expectedWorkerSS)>tolerance||Math.abs(m.source.totalSE-m.plan.expectedWorkerSE)>tolerance);\nconst totalSourceSS=round(sources.reduce((s,r)=>s+r.totalSS,0)),totalSourceSE=round(sources.reduce((s,r)=>s+r.totalSE,0)),totalExpectedSS=round(plans.reduce((s,r)=>s+r.expectedWorkerSS,0)),totalExpectedSE=round(plans.reduce((s,r)=>s+r.expectedWorkerSE,0)),diffSS=round(totalSourceSS-totalExpectedSS),diffSE=round(totalSourceSE-totalExpectedSE);\nconst fileReview=(pdfs.validations||[]).filter(v=>['REVISAR','EXCLUIDO'].includes(v.status)).length+periodChecks.filter(v=>v.status==='REVISAR').length+(rateInfo.usedFallbackDTM?1:0);\nconst hasFindings=Boolean(onlySource.length||onlyPlan.length||amountDiffs.length||identityReview.length||fileReview);\nconst statusText=hasFindings?'COMPLETADO · REVISAR HALLAZGOS':'COMPLETADO · SIN DIFERENCIAS',statusDetail=hasFindings?`${onlySource.length+onlyPlan.length} diferencia(s) de presencia, ${amountDiffs.length} diferencia(s) de monto, ${identityReview.length} identidad(es) con match a revisar y ${fileReview} validación(es) documental(es).`:'Las fuentes coinciden con la Planilla CSS dentro de la tolerancia definida.';\nconst title=css.reportTitle,period=css.periodoLabel;\nconst summaryRows=[[title],[period],[],['Métrica','Resultado','Interpretación'],['Estado',statusText,statusDetail],['Empleados únicos en fuentes',sources.length,'Q1 + Q2 + movimientos complementarios incluidos'],['Empleados Planilla CSS',plans.length,'Registros oficiales reconstruidos y validados'],['Coincidencias asignadas',matches.length,'Matching conservador por apellido/nombre y monto'],['Identidades a revisar',identityReview.length,'Matches por evidencia débil o ambigua; no se ocultan'],['Solo en fuentes',onlySource.length,'Sin contraparte CSS con evidencia suficiente'],['Solo en Planilla CSS',onlyPlan.length,'Sin contraparte en fuentes con evidencia suficiente'],['Diferencias de monto',amountDiffs.length,`Diferencia absoluta > B/. ${tolerance.toFixed(2)} en S.S. o S.E.`],['Tasa S.S. regular detectada',`${(ssRegular*100).toFixed(2)}%`,`${rateInfo.samplesSS} observaciones; fallback: ${rateInfo.usedFallbackSS?'sí':'no'}`],['Tasa S.E. detectada',`${(seRate*100).toFixed(2)}%`,`${rateInfo.samplesSE} observaciones; fallback: ${rateInfo.usedFallbackSE?'sí':'no'}`],['Tasa S.S. DTM detectada',`${(ssDtm*100).toFixed(2)}%`,`${rateInfo.samplesDTM} liquidaciones con DTM; fallback: ${rateInfo.usedFallbackDTM?'sí':'no'}`],['S.S. fuentes',totalSourceSS,'Suma real descontada en las fuentes'],['S.S. esperado CSS',totalExpectedSS,'Calculado con tasas detectadas sobre salario ajustado + DTM'],['Diferencia total S.S.',diffSS,'Fuentes menos esperado CSS'],['S.E. fuentes',totalSourceSE,'Suma real descontada en las fuentes'],['S.E. esperado CSS',totalExpectedSE,'Calculado con tasa detectada sobre salario ajustado'],['Diferencia total S.E.',diffSE,'Fuentes menos esperado CSS'],['Seguro Social total oficial CSS',css.totals.seguroSocial,'Validado contra reconstrucción individual'],['Seguro Educativo total oficial CSS',css.totals.seguroEducativo,'Validado contra reconstrucción individual'],['Salario ajustado total CSS',css.totals.salarioAjustado,'Validado contra reconstrucción individual'],['Nómina Q1',`${q1.fileName} (${q1.format})`,''],['Nómina Q2',`${q2.fileName} (${q2.format})`,''],['Planilla CSS',css.fileName,'']];\nconst identityRows=[[title],[period],[],['Estado','Código fuente','Nombre en fuentes','Documento CSS','Nombre Planilla CSS','S.S. fuente','S.S. esperado','Método','Confianza','Observación']];\nconst amountRows=[[title],[period],[],['Estado','Código','Nombre fuentes','Nombre CSS','S.S. Q1','S.S. Q2','S.S. Extra','S.S. Vacaciones','S.S. Liquidaciones','S.S. total','S.S. esperado','Dif. S.S.','S.E. total','S.E. esperado','Dif. S.E.']];\nfor(const m of matches){const s=m.source,p=m.plan,dss=round(s.totalSS-p.expectedWorkerSS),dse=round(s.totalSE-p.expectedWorkerSE),amtOk=Math.abs(dss)<=tolerance&&Math.abs(dse)<=tolerance;let st=amtOk?'COINCIDE':'COINCIDE · REVISAR MONTO',obs='';if(m.identityReview){st='COINCIDE · REVISAR IDENTIDAD';obs=m.ambiguous?'Existen dos o más candidatos CSS prácticamente indistinguibles por nombre y monto.':`Match conservador por ${m.method}; revisar discrepancia de nombre/apellido.`;}identityRows.push([st,s.code,s.name,p.identity,p.name,s.totalSS,p.expectedWorkerSS,m.method,m.confidence,obs]);amountRows.push([amtOk?'OK':'REVISAR',s.code,s.name,p.name,round(s.q1SS),round(s.q2SS),round(s.extraSS),round(s.vacSS),round(s.liqSS),s.totalSS,p.expectedWorkerSS,dss,s.totalSE,p.expectedWorkerSE,dse]);}\nfor(const s of onlySource){identityRows.push(['SOLO FUENTES',s.code,s.name,'','',s.totalSS,'','','','Sin contraparte CSS con evidencia suficiente']);amountRows.push(['SOLO FUENTES',s.code,s.name,'',round(s.q1SS),round(s.q2SS),round(s.extraSS),round(s.vacSS),round(s.liqSS),s.totalSS,'','',s.totalSE,'','']);}\nfor(const p of onlyPlan){identityRows.push(['SOLO PLANILLA CSS','','',p.identity,p.name,'',p.expectedWorkerSS,'','','Sin contraparte en fuentes con evidencia suficiente']);amountRows.push(['SOLO PLANILLA CSS','','',p.name,'','','','','','',p.expectedWorkerSS,'','',p.expectedWorkerSE,'']);}\nconst rank=x=>String(x).includes('SOLO')?0:String(x).includes('REVISAR')?1:2;identityRows.splice(4,identityRows.length-4,...identityRows.slice(4).sort((a,b)=>rank(a[0])-rank(b[0])||String(a[2]||a[4]).localeCompare(String(b[2]||b[4]))));amountRows.splice(4,amountRows.length-4,...amountRows.slice(4).sort((a,b)=>rank(a[0])-rank(b[0])||Math.abs(Number(b[11])||0)-Math.abs(Number(a[11])||0)));\nconst detailRows=[[title],[period],[],['Estado','Tipo','Archivo','Código','Nombre','S.S.','S.E.','Bruto/Base','Base regular','Base DTM','Fecha/Período','Transacción','Motivo']];for(const r of q1.records||[])detailRows.push(['INCLUIDO','Nómina Q1',q1.fileName,r.code,r.name,r.ss,r.se,r.gross||'',r.gross||'',0,q1.period?`${q1.period.start} - ${q1.period.end}`:'','','']);for(const r of q2.records||[])detailRows.push(['INCLUIDO','Nómina Q2',q2.fileName,r.code,r.name,r.ss,r.se,r.gross||'',r.gross||'',0,q2.period?`${q2.period.start} - ${q2.period.end}`:'','','']);for(const r of pdfs.records||[])detailRows.push([r.included?'INCLUIDO':'EXCLUIDO',r.category,r.fileName,r.code,r.name,r.ss,r.se,r.gross||'',r.regularBase||'',r.dtmBase||'',r.date||'',r.transaction||'',r.reason||'']);\nconst cssRows=[[title],[period],[],['Seguro Social #','Documento','Tipo doc.','Nombre CSS','Sexo','Salario','Salario ajustado','DTM base','S.S. trabajador esperado','S.E. trabajador esperado','S.S. total CSS','S.E. total CSS','DTM total CSS']];for(const p of plans)cssRows.push([p.ssNumber,p.identity,p.docType,p.name,p.sex,p.salary,p.salaryAdjusted,p.dtmBase,p.expectedWorkerSS,p.expectedWorkerSE,p.planillaSS,p.planillaSE,p.planillaDTM]);\nconst valRows=[[title],[period],[],['Estado','Categoría','Archivo','Detalle']];valRows.push(['OK','Nómina Q1',q1.fileName,`${q1.employeeCount} empleado(s); S.S. ${money(q1.totalSS)}; S.E. ${money(q1.totalSE)}; formato ${q1.format}; ${q1.integrityStatus}; códigos ausentes ${q1.missingCodeCount||0}`]);valRows.push(['OK','Nómina Q2',q2.fileName,`${q2.employeeCount} empleado(s); S.S. ${money(q2.totalSS)}; S.E. ${money(q2.totalSE)}; formato ${q2.format}; ${q2.integrityStatus}; códigos ausentes ${q2.missingCodeCount||0}`]);for(const v of periodChecks)valRows.push([v.status,'Período','Nóminas',v.detail]);valRows.push(['OK','Planilla CSS',css.fileName,`${css.employeeCount} empleado(s); período ${period}; integridad: ${css.integrity.map(x=>`${x.metric} dif ${money(x.diff)}`).join(' · ')}`]);valRows.push([rateInfo.usedFallbackDTM?'REVISAR':'OK','Tasas','Motor de cálculo',`S.S. regular ${(ssRegular*100).toFixed(2)}% (${rateInfo.samplesSS} muestras) · S.E. ${(seRate*100).toFixed(2)}% (${rateInfo.samplesSE}) · S.S. DTM ${(ssDtm*100).toFixed(2)}% (${rateInfo.samplesDTM})`]);for(const v of pdfs.validations||[])valRows.push([v.status,v.category,v.fileName,v.detail]);\nconst topFindings=[];for(const s of onlySource.slice(0,4))topFindings.push(`Solo fuentes: ${s.name}${s.code?` (#${s.code})`:''} · S.S. ${money(s.totalSS)}`);for(const p of onlyPlan.slice(0,4))topFindings.push(`Solo CSS: ${p.name} · ${p.identity||'sin documento'} · S.S. esperado ${money(p.expectedWorkerSS)}`);for(const m of identityReview.slice(0,4))topFindings.push(`Identidad: ${m.source.name} ↔ ${m.plan.name} · ${m.method}`);for(const m of amountDiffs.slice(0,4))topFindings.push(`Monto: ${m.source.name} · Dif. S.S. ${money(round(m.source.totalSS-m.plan.expectedWorkerSS))} · Dif. S.E. ${money(round(m.source.totalSE-m.plan.expectedWorkerSE))}`);\nconst summary={statusText,statusDetail,hasFindings,periodoLabel:period,sourceCount:sources.length,cssCount:plans.length,matches:matches.length,identityReview:identityReview.length,onlySource:onlySource.length,onlyPlan:onlyPlan.length,amountDifferences:amountDiffs.length,fileReview,totalSourceSS,totalExpectedSS,diffSS,totalSourceSE,totalExpectedSE,diffSE,rateInfo,topFindings,files:{q1:q1.fileName,q2:q2.fileName,css:css.fileName,pdfs:pdfs.files||[]},stagingQ1:q1.stagingSpreadsheetId||'',stagingQ2:q2.stagingSpreadsheetId||'',stagingCSS:css.stagingSpreadsheetId||''};\nconst createSheetBody=JSON.stringify({properties:{title},sheets:[{properties:{title:'Resumen'}},{properties:{title:'Cruce Identidad'}},{properties:{title:'Cruce Montos CSS'}},{properties:{title:'Detalle Fuentes'}},{properties:{title:'Detalle Planilla CSS'}},{properties:{title:'Validaciones'}}]});\nconst writeReportBody=JSON.stringify({valueInputOption:'RAW',data:[{range:'Resumen!A1',majorDimension:'ROWS',values:summaryRows},{range:'Cruce Identidad!A1',majorDimension:'ROWS',values:identityRows},{range:'Cruce Montos CSS!A1',majorDimension:'ROWS',values:amountRows},{range:'Detalle Fuentes!A1',majorDimension:'ROWS',values:detailRows},{range:'Detalle Planilla CSS!A1',majorDimension:'ROWS',values:cssRows},{range:'Validaciones!A1',majorDimension:'ROWS',values:valRows}]});\nreturn [{json:{reportTitle:title,periodoLabel:period,summary,summaryRows,identityRows,amountRows,detailRows,cssRows,valRows,createSheetBody,writeReportBody}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 62144, 34832 ], "id": "6e10d4ed-e0ff-4f3c-bbd4-503735047765", "name": "Construir reporte producción" }, { "parameters": { "method": "POST", "url": "https://sheets.googleapis.com/v4/spreadsheets", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleSheetsOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ $json.createSheetBody }}", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 62384, 34832 ], "id": "bac4af9b-bb77-4456-9e17-be8fa3af8587", "name": "Crear Google Sheet Reporte", "credentials": { "googleSheetsOAuth2Api": { "id": "K0hDZh3a85MpOHCs", "name": "Google Sheets account 2" } } }, { "parameters": { "method": "POST", "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.spreadsheetId }}/values:batchUpdate", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleSheetsOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ $('Construir reporte producción').first().json.writeReportBody }}", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 62624, 34832 ], "id": "acc027fa-0b8e-42ae-81d3-e8fb393bf755", "name": "Escribir reporte", "credentials": { "googleSheetsOAuth2Api": { "id": "K0hDZh3a85MpOHCs", "name": "Google Sheets account 2" } } }, { "parameters": { "jsCode": "const created=$items('Crear Google Sheet Reporte')[0].json; const report=$items('Construir reporte producción')[0].json;\nconst rowsByName={'Resumen':report.summaryRows,'Cruce Identidad':report.identityRows,'Cruce Montos CSS':report.amountRows,'Detalle Fuentes':report.detailRows,'Detalle Planilla CSS':report.cssRows,'Validaciones':report.valRows};\nconst requests=[];\nfor(const s of created.sheets||[]){\n const name=s.properties.title,id=s.properties.sheetId,rows=rowsByName[name]||[]; const cols=Math.max(1,...rows.map(r=>r.length)); const rowCount=Math.max(rows.length,5);\n requests.push({updateSheetProperties:{properties:{sheetId:id,gridProperties:{hideGridlines:true,frozenRowCount:4}},fields:'gridProperties.hideGridlines,gridProperties.frozenRowCount'}});\n requests.push({repeatCell:{range:{sheetId:id,startRowIndex:0,endRowIndex:rowCount,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{textFormat:{fontFamily:'Arial',fontSize:10,foregroundColor:{red:0.290,green:0.290,blue:0.290}},verticalAlignment:'MIDDLE'}},fields:'userEnteredFormat(textFormat,verticalAlignment)'}});\n requests.push({mergeCells:{range:{sheetId:id,startRowIndex:0,endRowIndex:1,startColumnIndex:0,endColumnIndex:cols},mergeType:'MERGE_ALL'}});\n requests.push({mergeCells:{range:{sheetId:id,startRowIndex:1,endRowIndex:2,startColumnIndex:0,endColumnIndex:cols},mergeType:'MERGE_ALL'}});\n requests.push({repeatCell:{range:{sheetId:id,startRowIndex:0,endRowIndex:1,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{backgroundColor:{red:0.310,green:0.459,blue:0.545},textFormat:{fontFamily:'Arial',fontSize:13,bold:true,foregroundColor:{red:1,green:1,blue:1}},horizontalAlignment:'LEFT',borders:{bottom:{style:'SOLID_THICK',color:{red:0.424,green:0.761,blue:0.290}}}}},fields:'userEnteredFormat'}});\n requests.push({repeatCell:{range:{sheetId:id,startRowIndex:1,endRowIndex:2,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{backgroundColor:{red:0.357,green:0.498,blue:0.584},textFormat:{fontFamily:'Arial',fontSize:10,italic:true,foregroundColor:{red:1,green:1,blue:1}}}},fields:'userEnteredFormat'}});\n requests.push({repeatCell:{range:{sheetId:id,startRowIndex:3,endRowIndex:4,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{backgroundColor:{red:0.310,green:0.459,blue:0.545},textFormat:{fontFamily:'Arial',fontSize:10,bold:true,foregroundColor:{red:1,green:1,blue:1}},horizontalAlignment:'CENTER',wrapStrategy:'WRAP'}},fields:'userEnteredFormat'}});\n requests.push({autoResizeDimensions:{dimensions:{sheetId:id,dimension:'COLUMNS',startIndex:0,endIndex:cols}}});\n requests.push({updateDimensionProperties:{range:{sheetId:id,dimension:'ROWS',startIndex:0,endIndex:1},properties:{pixelSize:34},fields:'pixelSize'}});\n if(rowCount>4){\n const rules=[\n ['REVISAR',{red:0.992,green:0.925,blue:0.918},{red:0.65,green:0.15,blue:0.12}],\n ['SOLO',{red:1.0,green:0.973,blue:0.882},{red:0.55,green:0.35,blue:0.0}],\n ['ERROR',{red:0.992,green:0.925,blue:0.918},{red:0.65,green:0.15,blue:0.12}],\n ['EXCLUIDO',{red:1.0,green:0.973,blue:0.882},{red:0.55,green:0.35,blue:0.0}]\n ];\n for(const [txt,bg,fg] of rules)requests.push({addConditionalFormatRule:{rule:{ranges:[{sheetId:id,startRowIndex:4,endRowIndex:rowCount,startColumnIndex:0,endColumnIndex:1}],booleanRule:{condition:{type:'TEXT_CONTAINS',values:[{userEnteredValue:txt}]},format:{backgroundColor:bg,textFormat:{bold:true,foregroundColor:fg}}}},index:0}});\n}\n}\nreturn [{json:{formatBody:{requests}}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 62864, 34832 ], "id": "023c71e6-974a-4ac4-9c19-cdeb97719dd6", "name": "Preparar formato reporte" }, { "parameters": { "method": "POST", "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $('Crear Google Sheet Reporte').item.json.spreadsheetId }}:batchUpdate", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleSheetsOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify($json.formatBody) }}", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 63104, 34832 ], "id": "176ef0a3-091f-493a-ac08-53addee2e6b1", "name": "Formatear reporte", "credentials": { "googleSheetsOAuth2Api": { "id": "K0hDZh3a85MpOHCs", "name": "Google Sheets account 2" } } }, { "parameters": { "jsCode": "const init=$items('Inicializar ejecución')[0].json; const id=$items('Crear Google Sheet Reporte')[0].json.spreadsheetId; const url=`https://docs.google.com/spreadsheets/d/${id}/edit`;\nreturn init.recipients.map(emailAddress=>({json:{emailAddress,spreadsheetId:id,spreadsheetUrl:url}}));" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 63344, 34832 ], "id": "f12bf3d7-f5c9-4b12-93bc-a0da50c9c629", "name": "Preparar permisos" }, { "parameters": { "method": "POST", "url": "=https://www.googleapis.com/drive/v3/files/{{ $json.spreadsheetId }}/permissions?sendNotificationEmail=false&supportsAllDrives=true", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleDriveOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ role: 'writer', type: 'user', emailAddress: $json.emailAddress }) }}", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 63584, 34832 ], "id": "63765f02-d4c8-4911-bf9d-7f768ed382ff", "name": "Compartir reporte como editor", "credentials": { "googleDriveOAuth2Api": { "id": "g23xdGLZRzBGqKgH", "name": "Isaac - Google Drive" } }, "continueOnFail": true }, { "parameters": { "jsCode": "const init=$items('Inicializar ejecución')[0].json,r=$items('Construir reporte producción')[0].json,s=r.summary,id=$items('Crear Google Sheet Reporte')[0].json.spreadsheetId,url=`https://docs.google.com/spreadsheets/d/${id}/edit`;\nconst nf=n=>Number(n||0).toLocaleString('en-US'),money=n=>`B/. ${Number(n||0).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}`,esc=x=>String(x??'').replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));\nconst has=s.hasFindings,bg=has?'#FFF8E1':'#E8F5E9',state=has?'#8A5A00':'#27632A',findings=(s.topFindings||[]).length?`
Principales hallazgos
${s.topFindings.map(x=>`
• ${esc(x)}
`).join('')}
`:'';\nconst html=`
 
\"GomezLee
SEGURIDAD SOCIAL · PANAMÁ

Cruce mensual de CSS

${esc(r.periodoLabel)}

${esc(s.statusText)}
${esc(s.statusDetail)}
MétricaResultado
Empleados fuentes / CSS${nf(s.sourceCount)} / ${nf(s.cssCount)}
Solo fuentes / solo CSS${nf(s.onlySource)} / ${nf(s.onlyPlan)}
Identidades a revisar${nf(s.identityReview)}
Diferencias de monto${nf(s.amountDifferences)}
Diferencia total S.S.${money(s.diffSS)}
Diferencia total S.E.${money(s.diffSE)}
${findings}
Abrir reporte en Google Sheets
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)}%
Archivos base:
Nómina Q1: ${esc(s.files.q1||'')}
Nómina Q2: ${esc(s.files.q2||'')}
Planilla CSS: ${esc(s.files.css||'')}
GOMEZLEE MARKETING · Uso interno
`;\nreturn [{json:{sendTo:init.recipients.join(','),subject:`${has?'[REVISAR]':'[OK]'} Cruce Seguridad Social Panamá - ${r.periodoLabel}`,html,spreadsheetId:id,spreadsheetUrl:url}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 63824, 34832 ], "id": "40a9e530-9b60-4450-bfa3-0d7d5e2e95d3", "name": "Preparar correo HTML GLM" }, { "parameters": { "sendTo": "={{ $json.sendTo }}", "subject": "={{ $json.subject }}", "message": "={{ $json.html }}", "options": { "appendAttribution": false } }, "type": "n8n-nodes-base.gmail", "typeVersion": 2.2, "position": [ 64064, 34832 ], "id": "33c125b7-9b00-4e68-b197-2cfcd8651f55", "name": "Enviar correo resumen", "webhookId": "ae5f87e7-df2b-4349-8eb5-aac208accb96", "executeOnce": true, "credentials": { "gmailOAuth2": { "id": "UDcO1FLJqA453V2D", "name": "Gmail account 3" } } }, { "parameters": { "jsCode": "const init=$items('Inicializar ejecución')[0].json,r=$items('Construir reporte producción')[0].json,s=r.summary,id=$items('Crear Google Sheet Reporte')[0].json.spreadsheetId,reportUrl=`https://docs.google.com/spreadsheets/d/${id}/edit`,formUrl=init.formUrl;\nconst html=`
\"GomezLee
SEGURIDAD SOCIAL · PANAMÁ

Cruce mensual completado

${r.periodoLabel}

${s.statusText}
${s.statusDetail}

Fuentes / CSS: ${s.sourceCount} / ${s.cssCount} · Solo fuentes / CSS: ${s.onlySource} / ${s.onlyPlan} · Identidades a revisar: ${s.identityReview} · Diferencias de monto: ${s.amountDifferences}

Abrir reporteRealizar otro cruce

Si el botón no responde, usa este enlace: volver al formulario.

`;\nreturn [{json:{html}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 64784, 34832 ], "id": "034abc56-cf5b-4a09-a6c5-c110c46683eb", "name": "Preparar confirmación" }, { "parameters": { "operation": "completion", "respondWith": "showText", "responseText": "={{ $json.html }}" }, "type": "n8n-nodes-base.form", "typeVersion": 2.5, "position": [ 65024, 34832 ], "id": "b949ed7f-046d-4d13-beaf-d064a8b6e5bf", "name": "Confirmación y regreso al formulario", "webhookId": "d4a102a8-3d72-4c5b-83b2-0e0d04c71099", "notesInFlow": true, "notes": "Página final con reporte y botón que vuelve exactamente al mismo Form Trigger de Panamá." }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "fd4e3c0d-3ba7-4267-b86c-3b8771da3d64", "leftValue": "={{ $json.format }}", "rightValue": "PDF", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ 54464, 34912 ], "id": "1c33ef12-3450-4ad1-b9f9-f742d02bd5c6", "name": "¿Q1 es PDF?" }, { "parameters": { "operation": "pdf", "options": {} }, "type": "n8n-nodes-base.extractFromFile", "typeVersion": 1.1, "position": [ 54704, 35056 ], "id": "c291f41f-cb07-4314-9e34-cb20c98f63e5", "name": "Extraer texto Q1 PDF" }, { "parameters": { "jsCode": "const prep=$items('Preparar Q1')[0].json;\nconst item=$input.first();\nconst candidateStrings=Object.values(item.json||{}).filter(v=>typeof v==='string');\nconst raw=item.json?.text??item.json?.data??item.json?.content??candidateStrings.sort((a,b)=>b.length-a.length)[0]??'';\nconst text=String(raw).replace(/\\r/g,'');\nconst config=$items('Inicializar ejecución')[0]?.json?.config||{};\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction num(v){if(v===null||v===undefined||v==='')return null;let s=String(v).replace(/B\\/\\.?|\\$/gi,'').replace(/,/g,'').trim();let neg=/^\\(.*\\)$/.test(s);if(neg)s=s.slice(1,-1);if(!/^-?\\d+(?:\\.\\d+)?$/.test(s))return null;const n=Number(s);return Number.isFinite(n)?(neg?-n:n):null;}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction moneyLine(s){return /^[\\s$()\\-]*[\\d,]+(?:\\.\\d+)?\\s*$/.test(String(s||''));}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase();}\nfunction plausible(gross,ss,se){if(!Number.isFinite(gross)||!Number.isFinite(ss)||!Number.isFinite(se))return false;if(gross<0||ss<-.01||se<-.01)return false;if(gross>0&&(ss/gross>.30||se/gross>.08))return false;return true;}\nfunction median(a){const x=a.filter(Number.isFinite).sort((p,q)=>p-q);if(!x.length)return null;const m=Math.floor(x.length/2);return x.length%2?x[m]:(x[m-1]+x[m])/2;}\nfunction fixed2Tokens(s){return (String(s||'').match(/\\(?-?(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{2}\\)?/g)||[]).map(num).filter(v=>v!==null);}\nfunction sim(a,b){a=norm(a).replace(/[^A-Z]/g,'');b=norm(b).replace(/[^A-Z]/g,'');if(!a||!b)return 0;if(a===b)return 1;const prev=Array.from({length:b.length+1},(_,i)=>i);for(let i=1;i<=a.length;i++){const cur=[i];for(let j=1;j<=b.length;j++)cur[j]=Math.min(cur[j-1]+1,prev[j]+1,prev[j-1]+(a[i-1]===b[j-1]?0:1));for(let j=0;jt[0]||'').join('');\n if(initials===code||initials.split('').reverse().join('')===code)best=Math.max(best,.99);\n return best;\n}\nfunction splitFlatNameCode(rawSuffix){\n let x=clean(rawSuffix);\n // Extract From File (PDF.js) deja al final \" 0.000.00\" (campos de la extrema izquierda/derecha).\n x=x.replace(/\\s+(?:(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{2})+\\s*$/,'').trim();\n let m=x.match(/^(.*?)(\\d{1,8})$/);\n if(m&&m[1].includes(','))return {name:clean(m[1]),code:m[2].replace(/^0+(?=\\d)/,'')};\n if(!x.includes(','))return {name:x,code:''};\n let best=null;\n for(let k=2;k<=Math.min(6,x.length-2);k++){\n const code=x.slice(-k).toUpperCase();if(!/^[A-Z]+$/.test(code))continue;\n const name=clean(x.slice(0,-k));if(!name.includes(','))continue;\n const score=alphaCodeScore(code,name)+k*.004;\n if(!best||score>best.score)best={name,code,score};\n }\n if(best&&best.score>=.87)return {name:best.name,code:best.code};\n return {name:x,code:''};\n}\nfunction dedupe(rows){const seen=new Set(),out=[];for(const r of rows){const k=[r.code,norm(r.name),round(r.ss).toFixed(2),round(r.se).toFixed(2),round(r.gross).toFixed(2),round(r.net).toFixed(2)].join('|');if(seen.has(k))continue;seen.add(k);out.push(r);}return out;}\nif(!text.trim())throw new Error(`Nómina Q1 PDF no contiene texto extraíble. Se detuvo el cruce para evitar un reporte incompleto: ${prep.fileName}`);\n\nconst periodMatch=text.match(/Periodo\\s+del\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})\\s+hasta\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})/i);\nconst period=periodMatch?{start:periodMatch[1],end:periodMatch[2]}:null;\nconst upper=text.toUpperCase(),gi=upper.lastIndexOf('TOTALES GENERALES'),tail=gi>=0?text.slice(gi):text;\nconst cm=tail.match(/Cantidad\\s+de\\s+Empleados:\\s*(\\d+)/i),printedCount=cm?Number(cm[1]):0;\n\n// MODO 1: texto tabular normal (PDF que conserva el orden visual izquierda → derecha).\nconst horizontal=[];\nfor(const rawLine of text.split('\\n')){\n const rate=rawLine.match(/\\d+\\.\\d{5,6}/);if(!rate)continue;\n const prefix=clean(rawLine.slice(0,rate.index)),rest=rawLine.slice((rate.index||0)+rate[0].length);\n const vals=(rest.match(/\\(?-?[\\d,]+(?:\\.\\d+)?\\)?/g)||[]).map(num).filter(v=>v!==null);\n if(vals.length!==23)continue;\n let code='',name=prefix;const pm=prefix.match(/^([A-Za-z0-9]{1,8})\\s+(.+)$/);\n if(pm){code=pm[1].replace(/^0+(?=\\d)/,'');name=clean(pm[2]);}\n if(!name.includes(',')||!/[A-ZÁÉÍÓÚÑ]/i.test(name))continue;\n const u=norm(name);if(/GOMEZLEE|DEPARTAMENTO|TOTALES|NOMBRE DEL EMPLEADO|PLANILLA|PERIODO/.test(u))continue;\n const gross=round(vals[13]),ss=round(vals[15]),se=round(vals[16]);if(!plausible(gross,ss,se))continue;\n horizontal.push({code,name,ss,se,gross,otherIncome:round(vals[12]),totalDeductions:round(vals[20]),net:round(vals[21]),source:'Nómina Q1',fileName:prep.fileName,period});\n}\n\n// MODO 2: salida REAL de n8n Extract From File / PDF.js para estas planillas.\n// PDF.js invierte las columnas y concatena montos sin espacios, por ejemplo:\n// Neto + Total Deduc. + Otras + ISR + S.E. + S.S. + Otros + Bruto + ... + tarifa + Nombre + Código.\n// El parser anterior suponía el orden visual y por eso perdía empleados y fallaba el conteo total.\nconst flat=[];\nfor(const rawLine of text.split('\\n')){\n const line=clean(rawLine);if(!line.includes(','))continue;\n const rates=[...line.matchAll(/(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{5,6}/g)];if(!rates.length)continue;\n const rate=rates[rates.length-1],left=line.slice(0,rate.index),suffix=line.slice((rate.index||0)+rate[0].length);\n const vals=fixed2Tokens(left);if(vals.length<8)continue;\n const nc=splitFlatNameCode(suffix),name=clean(nc.name),code=clean(nc.code).replace(/^0+(?=\\d)/,'');\n if(!name.includes(',')||!/[A-ZÁÉÍÓÚÑ]/i.test(name))continue;\n const u=norm(name);if(/GOMEZLEE|DEPARTAMENTO|TOTALES|NOMBRE DEL EMPLEADO|PLANILLA|PERIODO/.test(u))continue;\n let se=round(vals[4]),ss=round(vals[5]),gross=round(vals[7]),otherIncome=vals.length>8?round(vals[8]):0;\n // Compatibilidad defensiva si una futura versión de PDF.js conserva una columna adicional.\n const targetSS=Number(config.defaultRates?.ssRegular??.0975),targetSE=Number(config.defaultRates?.se??.0125);\n const fixedRateOK=gross<=0||(ss===0&&se===0)||(ss/gross>.03&&ss/gross<.20&&se/gross>.001&&se/gross<.05);\n if(!plausible(gross,ss,se)||!fixedRateOK){\n let best=null;\n for(let i=2;i20)||ss0<0||se0<0)continue;\n const rs=ss0/g,re=se0/g;if(rs>.30||re>.08)continue;\n const score=Math.abs(rs-targetSS)*10+Math.abs(re-targetSE)*20+Math.abs((j-i)-3)*.03;\n if(!best||score=0&&nums.length<36){const s=lines[j];if(!s){j--;continue;}if(moneyLine(s)){nums.push(num(s));j--;continue;}if(nums.length>=10)break;j--;}\n nums.reverse();if(nums.length<10)continue;let best=null;\n for(let k=Math.max(0,nums.length-32);k<=nums.length-8;k++){\n const [net,deducciones,otrasDesc,isr,se,ss,otrosIngresos,gross]=nums.slice(k,k+8);\n if([net,deducciones,se,ss,otrosIngresos,gross].some(v=>v===null)||!plausible(gross,ss,se))continue;\n const residual=Math.abs((gross+otrosIngresos-deducciones)-net);if(residual>1.25)continue;\n const score=residual+Math.abs(k-(nums.length-22))*.001;if(!best||score0)chosen=candidates.find(c=>c.rows.length===printedCount)||null;\nif(!chosen)chosen=[...candidates].sort((a,b)=>b.rows.length-a.rows.length)[0];\nconst out=chosen?.rows||[],parserMode=chosen?.mode||'SIN_PARSER';\nif(printedCount>0&&out.length!==printedCount)throw new Error(`Nómina Q1 PDF indica ${printedCount} empleados, pero ningún lector alcanzó ese total (tabular ${candidates[0].rows.length}, PDF.js ${candidates[1].rows.length}, vertical ${candidates[2].rows.length}). Se detuvo el cruce para no generar un reporte incompleto. Archivo: ${prep.fileName}`);\nif(!out.length)throw new Error(`No pude extraer empleados de Nómina Q1 PDF. El archivo no coincide con el formato de planilla esperado.`);\n\n// 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.05&&medSS<.15&&medSE>.004&&medSE<.03))throw new Error(`Nómina Q1 PDF superó el conteo, pero las tasas implícitas no son plausibles (S.S. ${((medSS||0)*100).toFixed(2)}%, S.E. ${((medSE||0)*100).toFixed(2)}%). Se detuvo para evitar un cruce incorrecto.`);\nreturn [{json:{fileName:prep.fileName,format:'PDF',parserMode,records:out,employeeCount:out.length,printedEmployeeCount:printedCount||null,missingCodeCount:out.filter(r=>!r.code).length,totalSS:round(out.reduce((s,r)=>s+r.ss,0)),totalSE:round(out.reduce((s,r)=>s+r.se,0)),period,stagingSpreadsheetId:'',integrityStatus:printedCount?'VALIDADO CONTRA TOTAL DEL PDF + COBERTURA MONETARIA':'VALIDADO POR COBERTURA MONETARIA'}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 54960, 35024 ], "id": "e256e1d6-2774-462c-9d55-5a031485d6cb", "name": "Parsear Q1 PDF" }, { "parameters": { "jsCode": "const r=$input.first().json;if(!Array.isArray(r.records)||!r.records.length)throw new Error('Resultado Q1 vacío.');return [{json:r}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 56624, 34832 ], "id": "3d6d60e8-487a-41cc-b256-46d813aace5c", "name": "Resultado Q1" }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "5ceced14-980c-46ea-a1ee-3845c8620a90", "leftValue": "={{ $json.format }}", "rightValue": "PDF", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ 57104, 34912 ], "id": "b4e4e628-7ddb-475a-8983-892aac060cf7", "name": "¿Q2 es PDF?" }, { "parameters": { "operation": "pdf", "options": {} }, "type": "n8n-nodes-base.extractFromFile", "typeVersion": 1.1, "position": [ 57344, 35056 ], "id": "3223cc16-c235-4286-ab4b-6caa1bc402b7", "name": "Extraer texto Q2 PDF" }, { "parameters": { "jsCode": "const prep=$items('Preparar Q2')[0].json;\nconst item=$input.first();\nconst candidateStrings=Object.values(item.json||{}).filter(v=>typeof v==='string');\nconst raw=item.json?.text??item.json?.data??item.json?.content??candidateStrings.sort((a,b)=>b.length-a.length)[0]??'';\nconst text=String(raw).replace(/\\r/g,'');\nconst config=$items('Inicializar ejecución')[0]?.json?.config||{};\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction num(v){if(v===null||v===undefined||v==='')return null;let s=String(v).replace(/B\\/\\.?|\\$/gi,'').replace(/,/g,'').trim();let neg=/^\\(.*\\)$/.test(s);if(neg)s=s.slice(1,-1);if(!/^-?\\d+(?:\\.\\d+)?$/.test(s))return null;const n=Number(s);return Number.isFinite(n)?(neg?-n:n):null;}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction moneyLine(s){return /^[\\s$()\\-]*[\\d,]+(?:\\.\\d+)?\\s*$/.test(String(s||''));}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase();}\nfunction plausible(gross,ss,se){if(!Number.isFinite(gross)||!Number.isFinite(ss)||!Number.isFinite(se))return false;if(gross<0||ss<-.01||se<-.01)return false;if(gross>0&&(ss/gross>.30||se/gross>.08))return false;return true;}\nfunction median(a){const x=a.filter(Number.isFinite).sort((p,q)=>p-q);if(!x.length)return null;const m=Math.floor(x.length/2);return x.length%2?x[m]:(x[m-1]+x[m])/2;}\nfunction fixed2Tokens(s){return (String(s||'').match(/\\(?-?(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{2}\\)?/g)||[]).map(num).filter(v=>v!==null);}\nfunction sim(a,b){a=norm(a).replace(/[^A-Z]/g,'');b=norm(b).replace(/[^A-Z]/g,'');if(!a||!b)return 0;if(a===b)return 1;const prev=Array.from({length:b.length+1},(_,i)=>i);for(let i=1;i<=a.length;i++){const cur=[i];for(let j=1;j<=b.length;j++)cur[j]=Math.min(cur[j-1]+1,prev[j]+1,prev[j-1]+(a[i-1]===b[j-1]?0:1));for(let j=0;jt[0]||'').join('');\n if(initials===code||initials.split('').reverse().join('')===code)best=Math.max(best,.99);\n return best;\n}\nfunction splitFlatNameCode(rawSuffix){\n let x=clean(rawSuffix);\n // Extract From File (PDF.js) deja al final \" 0.000.00\" (campos de la extrema izquierda/derecha).\n x=x.replace(/\\s+(?:(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{2})+\\s*$/,'').trim();\n let m=x.match(/^(.*?)(\\d{1,8})$/);\n if(m&&m[1].includes(','))return {name:clean(m[1]),code:m[2].replace(/^0+(?=\\d)/,'')};\n if(!x.includes(','))return {name:x,code:''};\n let best=null;\n for(let k=2;k<=Math.min(6,x.length-2);k++){\n const code=x.slice(-k).toUpperCase();if(!/^[A-Z]+$/.test(code))continue;\n const name=clean(x.slice(0,-k));if(!name.includes(','))continue;\n const score=alphaCodeScore(code,name)+k*.004;\n if(!best||score>best.score)best={name,code,score};\n }\n if(best&&best.score>=.87)return {name:best.name,code:best.code};\n return {name:x,code:''};\n}\nfunction dedupe(rows){const seen=new Set(),out=[];for(const r of rows){const k=[r.code,norm(r.name),round(r.ss).toFixed(2),round(r.se).toFixed(2),round(r.gross).toFixed(2),round(r.net).toFixed(2)].join('|');if(seen.has(k))continue;seen.add(k);out.push(r);}return out;}\nif(!text.trim())throw new Error(`Nómina Q2 PDF no contiene texto extraíble. Se detuvo el cruce para evitar un reporte incompleto: ${prep.fileName}`);\n\nconst periodMatch=text.match(/Periodo\\s+del\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})\\s+hasta\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})/i);\nconst period=periodMatch?{start:periodMatch[1],end:periodMatch[2]}:null;\nconst upper=text.toUpperCase(),gi=upper.lastIndexOf('TOTALES GENERALES'),tail=gi>=0?text.slice(gi):text;\nconst cm=tail.match(/Cantidad\\s+de\\s+Empleados:\\s*(\\d+)/i),printedCount=cm?Number(cm[1]):0;\n\n// MODO 1: texto tabular normal (PDF que conserva el orden visual izquierda → derecha).\nconst horizontal=[];\nfor(const rawLine of text.split('\\n')){\n const rate=rawLine.match(/\\d+\\.\\d{5,6}/);if(!rate)continue;\n const prefix=clean(rawLine.slice(0,rate.index)),rest=rawLine.slice((rate.index||0)+rate[0].length);\n const vals=(rest.match(/\\(?-?[\\d,]+(?:\\.\\d+)?\\)?/g)||[]).map(num).filter(v=>v!==null);\n if(vals.length!==23)continue;\n let code='',name=prefix;const pm=prefix.match(/^([A-Za-z0-9]{1,8})\\s+(.+)$/);\n if(pm){code=pm[1].replace(/^0+(?=\\d)/,'');name=clean(pm[2]);}\n if(!name.includes(',')||!/[A-ZÁÉÍÓÚÑ]/i.test(name))continue;\n const u=norm(name);if(/GOMEZLEE|DEPARTAMENTO|TOTALES|NOMBRE DEL EMPLEADO|PLANILLA|PERIODO/.test(u))continue;\n const gross=round(vals[13]),ss=round(vals[15]),se=round(vals[16]);if(!plausible(gross,ss,se))continue;\n horizontal.push({code,name,ss,se,gross,otherIncome:round(vals[12]),totalDeductions:round(vals[20]),net:round(vals[21]),source:'Nómina Q2',fileName:prep.fileName,period});\n}\n\n// MODO 2: salida REAL de n8n Extract From File / PDF.js para estas planillas.\n// PDF.js invierte las columnas y concatena montos sin espacios, por ejemplo:\n// Neto + Total Deduc. + Otras + ISR + S.E. + S.S. + Otros + Bruto + ... + tarifa + Nombre + Código.\n// El parser anterior suponía el orden visual y por eso perdía empleados y fallaba el conteo total.\nconst flat=[];\nfor(const rawLine of text.split('\\n')){\n const line=clean(rawLine);if(!line.includes(','))continue;\n const rates=[...line.matchAll(/(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{5,6}/g)];if(!rates.length)continue;\n const rate=rates[rates.length-1],left=line.slice(0,rate.index),suffix=line.slice((rate.index||0)+rate[0].length);\n const vals=fixed2Tokens(left);if(vals.length<8)continue;\n const nc=splitFlatNameCode(suffix),name=clean(nc.name),code=clean(nc.code).replace(/^0+(?=\\d)/,'');\n if(!name.includes(',')||!/[A-ZÁÉÍÓÚÑ]/i.test(name))continue;\n const u=norm(name);if(/GOMEZLEE|DEPARTAMENTO|TOTALES|NOMBRE DEL EMPLEADO|PLANILLA|PERIODO/.test(u))continue;\n let se=round(vals[4]),ss=round(vals[5]),gross=round(vals[7]),otherIncome=vals.length>8?round(vals[8]):0;\n // Compatibilidad defensiva si una futura versión de PDF.js conserva una columna adicional.\n const targetSS=Number(config.defaultRates?.ssRegular??.0975),targetSE=Number(config.defaultRates?.se??.0125);\n const fixedRateOK=gross<=0||(ss===0&&se===0)||(ss/gross>.03&&ss/gross<.20&&se/gross>.001&&se/gross<.05);\n if(!plausible(gross,ss,se)||!fixedRateOK){\n let best=null;\n for(let i=2;i20)||ss0<0||se0<0)continue;\n const rs=ss0/g,re=se0/g;if(rs>.30||re>.08)continue;\n const score=Math.abs(rs-targetSS)*10+Math.abs(re-targetSE)*20+Math.abs((j-i)-3)*.03;\n if(!best||score=0&&nums.length<36){const s=lines[j];if(!s){j--;continue;}if(moneyLine(s)){nums.push(num(s));j--;continue;}if(nums.length>=10)break;j--;}\n nums.reverse();if(nums.length<10)continue;let best=null;\n for(let k=Math.max(0,nums.length-32);k<=nums.length-8;k++){\n const [net,deducciones,otrasDesc,isr,se,ss,otrosIngresos,gross]=nums.slice(k,k+8);\n if([net,deducciones,se,ss,otrosIngresos,gross].some(v=>v===null)||!plausible(gross,ss,se))continue;\n const residual=Math.abs((gross+otrosIngresos-deducciones)-net);if(residual>1.25)continue;\n const score=residual+Math.abs(k-(nums.length-22))*.001;if(!best||score0)chosen=candidates.find(c=>c.rows.length===printedCount)||null;\nif(!chosen)chosen=[...candidates].sort((a,b)=>b.rows.length-a.rows.length)[0];\nconst out=chosen?.rows||[],parserMode=chosen?.mode||'SIN_PARSER';\nif(printedCount>0&&out.length!==printedCount)throw new Error(`Nómina Q2 PDF indica ${printedCount} empleados, pero ningún lector alcanzó ese total (tabular ${candidates[0].rows.length}, PDF.js ${candidates[1].rows.length}, vertical ${candidates[2].rows.length}). Se detuvo el cruce para no generar un reporte incompleto. Archivo: ${prep.fileName}`);\nif(!out.length)throw new Error(`No pude extraer empleados de Nómina Q2 PDF. El archivo no coincide con el formato de planilla esperado.`);\n\n// 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.05&&medSS<.15&&medSE>.004&&medSE<.03))throw new Error(`Nómina Q2 PDF superó el conteo, pero las tasas implícitas no son plausibles (S.S. ${((medSS||0)*100).toFixed(2)}%, S.E. ${((medSE||0)*100).toFixed(2)}%). Se detuvo para evitar un cruce incorrecto.`);\nreturn [{json:{fileName:prep.fileName,format:'PDF',parserMode,records:out,employeeCount:out.length,printedEmployeeCount:printedCount||null,missingCodeCount:out.filter(r=>!r.code).length,totalSS:round(out.reduce((s,r)=>s+r.ss,0)),totalSE:round(out.reduce((s,r)=>s+r.se,0)),period,stagingSpreadsheetId:'',integrityStatus:printedCount?'VALIDADO CONTRA TOTAL DEL PDF + COBERTURA MONETARIA':'VALIDADO POR COBERTURA MONETARIA'}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 57584, 35056 ], "id": "dd41d8e5-d079-4422-a22c-ca2010fa31c2", "name": "Parsear Q2 PDF" }, { "parameters": { "jsCode": "const r=$input.first().json;if(!Array.isArray(r.records)||!r.records.length)throw new Error('Resultado Q2 vacío.');return [{json:r}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 58544, 34832 ], "id": "22ae7500-d025-4854-aeab-7b6827c4fb14", "name": "Resultado Q2" }, { "parameters": { "jsCode": "const q1=$items('Resultado Q1')[0].json;\nconst q2=$items('Resultado Q2')[0].json;\nconst css=$items('Parsear CSS')[0].json;\nconst tempFolder=$items('Crear carpeta temporal PDFs')[0]?.json?.id||'';\nconst out=[];\nfor(const [tipo,id] of [\n ['Q1',q1.stagingSpreadsheetId],\n ['Q2',q2.stagingSpreadsheetId],\n ['CSS',css.stagingSpreadsheetId],\n ['PDFS_TEMP',tempFolder]\n]) if(id) out.push({json:{tipo,fileId:id}});\nreturn out.length?out:[{json:{tipo:'NINGUNO',fileId:''}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 64304, 34832 ], "id": "a358393b-3223-450e-a6db-4a146838f266", "name": "Preparar limpieza temporales" }, { "parameters": { "method": "DELETE", "url": "=https://www.googleapis.com/drive/v3/files/{{ $json.fileId }}?supportsAllDrives=true", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleDriveOAuth2Api", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 64544, 34832 ], "id": "c20af9b0-54e4-44c9-be47-5c277d575807", "name": "Eliminar staging temporal", "alwaysOutputData": true, "notesInFlow": true, "credentials": { "googleDriveOAuth2Api": { "id": "g23xdGLZRzBGqKgH", "name": "Isaac - Google Drive" } }, "continueOnFail": true, "notes": "Limpieza best-effort: elimina staging Q1/Q2/CSS y la carpeta temporal de PDFs. Si falla, no bloquea la pantalla final." }, { "parameters": { "method": "POST", "url": "https://www.googleapis.com/drive/v3/files?fields=id,name,mimeType&supportsAllDrives=true", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleDriveOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ name: `TEMP PA CSS PDFs - ${$('Inicializar ejecución').item.json.runId}`, mimeType: 'application/vnd.google-apps.folder' }) }}", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 54224, 35216 ], "id": "09fa4c3c-7a56-46ae-a0ef-2bd5030a1417", "name": "Crear carpeta temporal PDFs", "retryOnFail": true, "maxTries": 3, "waitBetweenTries": 2000, "notesInFlow": true, "credentials": { "googleDriveOAuth2Api": { "id": "g23xdGLZRzBGqKgH", "name": "Isaac - Google Drive" } }, "notes": "Carpeta temporal aislada por ejecución para acumular PDFs cargados en varias tandas sin perder selecciones anteriores." }, { "parameters": { "formFields": { "values": [ { "fieldLabel": "¿Hay extraordinarias este mes?", "fieldType": "radio", "fieldOptions": { "values": [ { "option": "Sí" }, { "option": "No" } ] }, "requiredField": true } ] }, "options": { "formTitle": "Paso 2 de 4 · Extraordinarias", "formDescription": "Indica si este mes existen planillas extraordinarias. Este es el único grupo opcional.", "buttonLabel": "Continuar" } }, "type": "n8n-nodes-base.form", "typeVersion": 2.5, "position": [ 54464, 35216 ], "id": "8f7476cf-6957-4714-9cd3-9f71feb1e687", "name": "Definir Extraordinarias", "webhookId": "2d4fba32-6a24-46e8-a9c7-a38ca80b106a" }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "e7a0d1ad-362a-4267-b2fd-ba254621eeaa", "leftValue": "={{ $json[\"¿Hay extraordinarias este mes?\"] }}", "rightValue": "Sí", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ 54704, 35216 ], "id": "953ada8a-80d8-40ee-a130-c040a9376a09", "name": "¿Hay extraordinarias?" }, { "parameters": { "formFields": { "values": [ { "fieldLabel": "Archivos extraordinarios", "fieldType": "file", "acceptFileTypes": ".pdf,application/pdf", "requiredField": true }, { "fieldLabel": "¿Agregar más extraordinarias?", "fieldType": "radio", "fieldOptions": { "values": [ { "option": "Sí" }, { "option": "No" } ] }, "requiredField": true } ] }, "options": { "formTitle": "Extraordinarias · cargar lote", "formDescription": "Puedes seleccionar uno o varios PDFs a la vez. Si tienes más archivos en otra carpeta, selecciona “Sí” en “Agregar más” y volverás a esta misma pantalla sin perder los archivos ya guardados.", "buttonLabel": "Guardar lote" } }, "type": "n8n-nodes-base.form", "typeVersion": 2.5, "position": [ 54960, 35024 ], "id": "4bf3e422-a9fe-4f98-9122-c3c4edff7f0f", "name": "Cargar Extraordinarias", "webhookId": "237dd241-cbba-4455-beec-b4b63e45827d" }, { "parameters": { "jsCode": "const item=$input.first();\nconst bin=item.binary||{};\nconst files=Object.entries(bin);\nfunction clean(v){return String(v??'').trim();}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase();}\nfunction yes(v){return ['si','sí','yes','true','1'].includes(norm(v));}\nfunction safeName(v){return clean(v).replace(/[\\\\/\\x00-\\x1F]/g,'_').replace(/__+/g,'_').slice(-150)||'archivo.pdf';}\nif(!files.length) throw new Error('Debes adjuntar al menos un PDF en este lote de Extraordinarias.');\nconst more=yes(item.json?.['¿Agregar más extraordinarias?']);\nconst init=$items('Inicializar ejecución')[0].json;\nconst max=Number(init.config?.maxSupplementalFilesSafety||500);\nif(files.length>max) throw new Error(`Se recibieron ${files.length} archivos en un solo lote; supera el límite técnico de seguridad (${max}).`);\nconst now=Date.now();\nconst out=[];\nfor(let i=0;imax) 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;imax) 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;imax) throw new Error(`Se detectaron ${files.length} archivos complementarios; supera el límite técnico de seguridad (${max}).`);\nfunction meta(name){\n const n=String(name||'');\n let category='';\n if(n.startsWith('EXTRA__'))category='extraordinaria';\n else if(n.startsWith('VAC__'))category='vacaciones';\n else if(n.startsWith('LIQ__'))category='liquidaciones';\n const ix=n.indexOf('__ORIG__');\n const fileName=ix>=0?n.slice(ix+8):n;\n return {category,fileName};\n}\nconst docs=[];\nfor(const f of files){\n const m=meta(f.name);\n if(!m.category) throw new Error(`Encontré un archivo temporal no reconocido: ${f.name}. Se detuvo para evitar mezclar documentos.`);\n if(String(f.mimeType||'')!=='application/pdf' && !String(f.name||'').toLowerCase().endsWith('.pdf')) throw new Error(`El archivo temporal ${f.name} no parece ser PDF.`);\n docs.push({id:f.id,storageName:f.name,fileName:m.fileName,category:m.category,size:f.size||'',createdTime:f.createdTime||''});\n}\nconst vac=docs.filter(x=>x.category==='vacaciones').length;\nconst liq=docs.filter(x=>x.category==='liquidaciones').length;\nconst ext=docs.filter(x=>x.category==='extraordinaria').length;\nif(vac<1) throw new Error('No hay archivos de Vacaciones acumulados. Vacaciones requiere al menos un PDF.');\nif(liq<1) throw new Error('No hay archivos de Liquidaciones acumulados. Liquidaciones requiere al menos un PDF.');\ndocs.sort((a,b)=>a.category.localeCompare(b.category)||String(a.createdTime).localeCompare(String(b.createdTime))||a.storageName.localeCompare(b.storageName));\nreturn docs.map(d=>({json:{...d,uploadCounts:{extraordinarias:ext,vacaciones:vac,liquidaciones:liq}}}));" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 61664, 35056 ], "id": "b525df3e-0968-489a-80d7-436a9697f00a", "name": "Preparar descarga PDFs", "notesInFlow": true, "notes": "Reconstruye las tres categorías desde la carpeta temporal y valida mínimos antes de descargar." }, { "parameters": { "operation": "download", "fileId": { "__rl": true, "value": "={{ $json.id }}", "mode": "id" }, "options": { "binaryPropertyName": "data", "fileName": "={{ $json.storageName }}" } }, "type": "n8n-nodes-base.googleDrive", "typeVersion": 3, "position": [ 61904, 35056 ], "id": "b5e9a2fb-a85c-45fc-b819-431840e1e007", "name": "Descargar PDFs temporales", "retryOnFail": true, "maxTries": 3, "waitBetweenTries": 2000, "credentials": { "googleDriveOAuth2Api": { "id": "g23xdGLZRzBGqKgH", "name": "Isaac - Google Drive" } } }, { "parameters": { "jsCode": "const items=$input.all();\nfunction parseStorage(n){\n const name=String(n||'');\n let category='';\n if(name.startsWith('EXTRA__'))category='extraordinaria';\n else if(name.startsWith('VAC__'))category='vacaciones';\n else if(name.startsWith('LIQ__'))category='liquidaciones';\n const ix=name.indexOf('__ORIG__');\n return {category,fileName:ix>=0?name.slice(ix+8):name,storageName:name};\n}\nconst out=[];\nfor(const item of items){\n const data=item.binary?.data;\n if(!data) throw new Error('Google Drive no devolvió el contenido binario de uno de los PDFs temporales.');\n const p=parseStorage(data.fileName||item.json?.storageName||item.json?.name||'');\n if(!p.category) throw new Error(`No pude determinar la categoría del archivo temporal ${p.storageName||'(sin nombre)'}.`);\n data.fileName=p.fileName;\n out.push({json:{category:p.category,fileName:p.fileName,storageName:p.storageName,fileId:item.json?.id||item.json?.fileId||''},binary:{data}});\n}\nreturn out;" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 62144, 35056 ], "id": "833ab32f-3fad-4271-8a6d-81923f4eda84", "name": "Normalizar PDFs descargados" } ], "connections": { "On form submission": { "main": [ [ { "node": "Inicializar ejecución", "type": "main", "index": 0 } ] ] }, "Inicializar ejecución": { "main": [ [ { "node": "Crear carpeta temporal PDFs", "type": "main", "index": 0 } ] ] }, "Preparar Q1": { "main": [ [ { "node": "¿Q1 es PDF?", "type": "main", "index": 0 } ] ] }, "¿Q1 es PDF?": { "main": [ [ { "node": "Extraer texto Q1 PDF", "type": "main", "index": 0 } ], [ { "node": "Crear staging Q1", "type": "main", "index": 0 } ] ] }, "Extraer texto Q1 PDF": { "main": [ [ { "node": "Parsear Q1 PDF", "type": "main", "index": 0 } ] ] }, "Parsear Q1 PDF": { "main": [ [ { "node": "Resultado Q1", "type": "main", "index": 0 } ] ] }, "Crear staging Q1": { "main": [ [ { "node": "Reunir staging Q1", "type": "main", "index": 0 } ] ] }, "Reunir staging Q1": { "main": [ [ { "node": "Parsear Q1 Excel", "type": "main", "index": 0 } ] ] }, "Parsear Q1 Excel": { "main": [ [ { "node": "Resultado Q1", "type": "main", "index": 0 } ] ] }, "Resultado Q1": { "main": [ [ { "node": "Preparar Q2", "type": "main", "index": 0 } ] ] }, "Preparar Q2": { "main": [ [ { "node": "¿Q2 es PDF?", "type": "main", "index": 0 } ] ] }, "¿Q2 es PDF?": { "main": [ [ { "node": "Extraer texto Q2 PDF", "type": "main", "index": 0 } ], [ { "node": "Crear staging Q2", "type": "main", "index": 0 } ] ] }, "Extraer texto Q2 PDF": { "main": [ [ { "node": "Parsear Q2 PDF", "type": "main", "index": 0 } ] ] }, "Parsear Q2 PDF": { "main": [ [ { "node": "Resultado Q2", "type": "main", "index": 0 } ] ] }, "Crear staging Q2": { "main": [ [ { "node": "Reunir staging Q2", "type": "main", "index": 0 } ] ] }, "Reunir staging Q2": { "main": [ [ { "node": "Parsear Q2 Excel", "type": "main", "index": 0 } ] ] }, "Parsear Q2 Excel": { "main": [ [ { "node": "Resultado Q2", "type": "main", "index": 0 } ] ] }, "Resultado Q2": { "main": [ [ { "node": "Preparar CSS", "type": "main", "index": 0 } ] ] }, "Preparar CSS": { "main": [ [ { "node": "Crear staging CSS", "type": "main", "index": 0 } ] ] }, "Crear staging CSS": { "main": [ [ { "node": "Reunir staging CSS", "type": "main", "index": 0 } ] ] }, "Reunir staging CSS": { "main": [ [ { "node": "Parsear CSS", "type": "main", "index": 0 } ] ] }, "Parsear CSS": { "main": [ [ { "node": "Listar PDFs temporales", "type": "main", "index": 0 } ] ] }, "Extraer texto PDFs": { "main": [ [ { "node": "Parsear PDFs", "type": "main", "index": 0 } ] ] }, "Parsear PDFs": { "main": [ [ { "node": "Construir reporte producción", "type": "main", "index": 0 } ] ] }, "Construir reporte producción": { "main": [ [ { "node": "Crear Google Sheet Reporte", "type": "main", "index": 0 } ] ] }, "Crear Google Sheet Reporte": { "main": [ [ { "node": "Escribir reporte", "type": "main", "index": 0 } ] ] }, "Escribir reporte": { "main": [ [ { "node": "Preparar formato reporte", "type": "main", "index": 0 } ] ] }, "Preparar formato reporte": { "main": [ [ { "node": "Formatear reporte", "type": "main", "index": 0 } ] ] }, "Formatear reporte": { "main": [ [ { "node": "Preparar permisos", "type": "main", "index": 0 } ] ] }, "Preparar permisos": { "main": [ [ { "node": "Compartir reporte como editor", "type": "main", "index": 0 } ] ] }, "Compartir reporte como editor": { "main": [ [ { "node": "Preparar correo HTML GLM", "type": "main", "index": 0 } ] ] }, "Preparar correo HTML GLM": { "main": [ [ { "node": "Enviar correo resumen", "type": "main", "index": 0 } ] ] }, "Enviar correo resumen": { "main": [ [ { "node": "Preparar limpieza temporales", "type": "main", "index": 0 } ] ] }, "Preparar limpieza temporales": { "main": [ [ { "node": "Eliminar staging temporal", "type": "main", "index": 0 } ] ] }, "Eliminar staging temporal": { "main": [ [ { "node": "Preparar confirmación", "type": "main", "index": 0 } ] ] }, "Preparar confirmación": { "main": [ [ { "node": "Confirmación y regreso al formulario", "type": "main", "index": 0 } ] ] }, "Crear carpeta temporal PDFs": { "main": [ [ { "node": "Definir Extraordinarias", "type": "main", "index": 0 } ] ] }, "Definir Extraordinarias": { "main": [ [ { "node": "¿Hay extraordinarias?", "type": "main", "index": 0 } ] ] }, "¿Hay extraordinarias?": { "main": [ [ { "node": "Cargar Extraordinarias", "type": "main", "index": 0 } ], [ { "node": "Cargar Vacaciones", "type": "main", "index": 0 } ] ] }, "Cargar Extraordinarias": { "main": [ [ { "node": "Preparar lote Extraordinarias", "type": "main", "index": 0 } ] ] }, "Preparar lote Extraordinarias": { "main": [ [ { "node": "Guardar lote Extraordinarias", "type": "main", "index": 0 } ] ] }, "Guardar lote Extraordinarias": { "main": [ [ { "node": "Cerrar lote Extraordinarias", "type": "main", "index": 0 } ] ] }, "Cerrar lote Extraordinarias": { "main": [ [ { "node": "¿Agregar más Extraordinarias?", "type": "main", "index": 0 } ] ] }, "¿Agregar más Extraordinarias?": { "main": [ [ { "node": "Cargar Extraordinarias", "type": "main", "index": 0 } ], [ { "node": "Cargar Vacaciones", "type": "main", "index": 0 } ] ] }, "Cargar Vacaciones": { "main": [ [ { "node": "Preparar lote Vacaciones", "type": "main", "index": 0 } ] ] }, "Preparar lote Vacaciones": { "main": [ [ { "node": "Guardar lote Vacaciones", "type": "main", "index": 0 } ] ] }, "Guardar lote Vacaciones": { "main": [ [ { "node": "Cerrar lote Vacaciones", "type": "main", "index": 0 } ] ] }, "Cerrar lote Vacaciones": { "main": [ [ { "node": "¿Agregar más Vacaciones?", "type": "main", "index": 0 } ] ] }, "¿Agregar más Vacaciones?": { "main": [ [ { "node": "Cargar Vacaciones", "type": "main", "index": 0 } ], [ { "node": "Cargar Liquidaciones", "type": "main", "index": 0 } ] ] }, "Cargar Liquidaciones": { "main": [ [ { "node": "Preparar lote Liquidaciones", "type": "main", "index": 0 } ] ] }, "Preparar lote Liquidaciones": { "main": [ [ { "node": "Guardar lote Liquidaciones", "type": "main", "index": 0 } ] ] }, "Guardar lote Liquidaciones": { "main": [ [ { "node": "Cerrar lote Liquidaciones", "type": "main", "index": 0 } ] ] }, "Cerrar lote Liquidaciones": { "main": [ [ { "node": "¿Agregar más Liquidaciones?", "type": "main", "index": 0 } ] ] }, "¿Agregar más Liquidaciones?": { "main": [ [ { "node": "Cargar Liquidaciones", "type": "main", "index": 0 } ], [ { "node": "Preparar Q1", "type": "main", "index": 0 } ] ] }, "Listar PDFs temporales": { "main": [ [ { "node": "Preparar descarga PDFs", "type": "main", "index": 0 } ] ] }, "Preparar descarga PDFs": { "main": [ [ { "node": "Descargar PDFs temporales", "type": "main", "index": 0 } ] ] }, "Descargar PDFs temporales": { "main": [ [ { "node": "Normalizar PDFs descargados", "type": "main", "index": 0 } ] ] }, "Normalizar PDFs descargados": { "main": [ [ { "node": "Extraer texto PDFs", "type": "main", "index": 0 } ] ] } }, "authors": "Isaac Aracena", "name": "Version d6cac1fb", "description": "", "autosaved": true, "workflowPublishHistory": [ { "createdAt": "2026-08-26T01:49:22.089Z", "id": 5223, "workflowId": "qxcXg0Z1YASkWgxL", "versionId": "d6cac1fb-fb4b-4bef-a2b3-146b21e84ac4", "event": "activated", "userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029" } ] } }