diff --git a/cruce-de-seguridad-social-panama.json b/cruce-de-seguridad-social-panama.json new file mode 100644 index 0000000..9049d96 --- /dev/null +++ b/cruce-de-seguridad-social-panama.json @@ -0,0 +1,3937 @@ +{ + "updatedAt": "2026-08-21T21:02:04.174Z", + "createdAt": "2026-08-20T19:14:26.133Z", + "id": "qxcXg0Z1YASkWgxL", + "name": "Cruce de Seguridad Social - Panamá", + "description": null, + "active": true, + "isArchived": false, + "nodes": [ + { + "parameters": { + "formTitle": "Cruce Seguridad Social Panamá", + "formDescription": "Paso 1 de 4. Carga las dos nóminas quincenales (preferiblemente Excel; PDF también se valida con controles estrictos) y la Planilla oficial de la Caja de Seguro Social (CSS) en Excel. En los pasos siguientes podrás cargar Extraordinarias, Vacaciones y Liquidaciones en una o varias tandas, aunque estén en carpetas distintas. El período se detecta automáticamente desde la Planilla CSS.", + "formFields": { + "values": [ + { + "fieldLabel": "Nómina - 1era Quincena", + "fieldType": "file", + "multipleFiles": false, + "acceptFileTypes": ".xlsx,.xls,.pdf", + "requiredField": true + }, + { + "fieldLabel": "Nómina - 2da Quincena", + "fieldType": "file", + "multipleFiles": false, + "acceptFileTypes": ".xlsx,.xls,.pdf", + "requiredField": true + }, + { + "fieldLabel": "Planilla CSS", + "fieldType": "file", + "multipleFiles": false, + "acceptFileTypes": ".xlsx,.xls", + "requiredField": true + } + ] + }, + "responseMode": "lastNode", + "options": { + "ignoreBots": true + } + }, + "type": "n8n-nodes-base.formTrigger", + "typeVersion": 2.3, + "position": [ + 20768, + 26736 + ], + "id": "ee56d788-e286-4575-9e02-d8e31bf76837", + "name": "On form submission", + "webhookId": "4ba38e33-3ab3-4a2d-a66e-59457eefae48" + }, + { + "parameters": { + "jsCode": "const item = $input.first();\nconst now = new Date();\nconst pad = n => String(n).padStart(2,'0');\nconst runId = `PA_CSS_${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}_${Math.random().toString(36).slice(2,8)}`;\nconst recipients = [\n 'iaracena@gomezleemarketing.com'\n];\nconst logoUrl = 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png';\nconst formUrl = 'https://agenteit.digitalcompass.agency/form/86e543a4-206d-474b-ab14-4fa697897d8c';\nconst config = {\n amountTolerance: 0.10,\n cssIntegrityTolerance: 0.10,\n defaultRates: { ssRegular: 0.0975, se: 0.0125, ssDtm: 0.0725 },\n minRateSamples: 20,\n hardFailUnreadablePdf: true,\n maxSupplementalFilesSafety: 500\n};\nreturn [{json:{runId,startedAt:now.toISOString(),recipients,logoUrl,formUrl,config},binary:item.binary||{}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 21008, + 26736 + ], + "id": "ad4f589a-65cf-4e2f-af7c-dcd3c6337929", + "name": "Inicializar ejecución" + }, + { + "parameters": { + "jsCode": "\nconst base=$items('Inicializar ejecución')[0];\nconst bin=base.binary||{};\nfunction norm(s){return String(s||'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase().replace(/\\s+/g,' ').trim();}\nfunction formatOf(file,name){\n const s=norm(`${name} ${file?.mimeType||''}`);\n if(/\\.pdf(?:$|\\s)|application\\/pdf/.test(s)) return 'PDF';\n if(/\\.xlsx?(?:$|\\s)|spreadsheet|excel|application\\/vnd\\.ms-excel/.test(s)) return 'EXCEL';\n return '';\n}\nconst entries=Object.entries(bin).map(([key,file])=>{\n const name=String(file.fileName||key), k=norm(key), n=norm(name), all=`${k} ${n}`;\n const format=formatOf(file,name); let score=format?100:-1000;\n \n if(/nomina.*1era|1era.*quincena|primera.*quincena|quincena.*1/.test(k)) score+=600;\n if(/nomina.*2da|2da.*quincena|segunda.*quincena|quincena.*2/.test(k)) score-=700;\n if(/1era|primera|quincena\\s*1|q1/.test(n)) score+=180;\n if(/2da|segunda|quincena\\s*2|q2/.test(n)) score-=220;\n if(/planilla\\s*css|detalleplanilla|extraordin|vacacion|liquidacion/.test(all)) score-=300;\n\n return {key,file,name,format,score,k,n,all};\n}).sort((a,b)=>b.score-a.score);\nconst chosen=entries[0];\nif(!chosen||chosen.score<250||!chosen.format) throw new Error('No pude identificar de forma segura la Nómina de 1era Quincena. Adjunta en ese campo un Excel (.xls/.xlsx) o PDF de la planilla quincenal.');\nreturn [{json:{...base.json,fileName:chosen.name,originalBinaryKey:chosen.key,format:chosen.format,inputMimeType:chosen.file?.mimeType||''},binary:{data:chosen.file}}];\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 21248, + 26832 + ], + "id": "58ddefa4-0d02-449f-9beb-af8ac36fef1e", + "name": "Preparar Q1" + }, + { + "parameters": { + "operation": "xls", + "options": { + "headerRow": false, + "includeEmptyCells": true, + "rawData": false, + "readAsString": false + } + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + 21728, + 26688 + ], + "id": "420c890d-ad31-4500-b0ff-c0999df7795d", + "name": "Crear staging Q1" + }, + { + "parameters": { + "jsCode": "const rows=$input.all().map(x=>x.json?.row).filter(r=>Array.isArray(r));\nif(!rows.length) throw new Error('No se pudieron leer filas de Nómina Q1 directamente desde el Excel.');\nreturn [{json:{valueRanges:[{range:'Sheet1!A1:AZ50000',values:rows}],directExtraction:true}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 21968, + 26688 + ], + "id": "9691c16a-26ae-4ef3-9231-507ab11c5231", + "name": "Reunir staging Q1" + }, + { + "parameters": { + "jsCode": "\nconst payload=$json.body&&typeof $json.body==='object'?$json.body:$json;\nconst valueRanges=payload.valueRanges||[];\nconst prep=$items('Preparar Q1')[0].json;\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction asc(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase();}\nfunction key(v){return asc(v).replace(/[^A-Z0-9]/g,'');}\nfunction num(v){if(v===null||v===undefined||v==='')return 0;if(typeof v==='number')return v;let s=String(v).replace(/B\\/.?|\\$|,/g,'').replace(/\\s/g,'');if(/^\\(.*\\)$/.test(s))s='-'+s.slice(1,-1);const n=Number(s);return Number.isFinite(n)?n:0;}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction detectPeriod(values){\n for(const row of values.slice(0,120)) for(const cell of row||[]){\n const m=clean(cell).match(/Periodo\\s+del\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})\\s+hasta\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})/i);\n if(m)return {start:m[1],end:m[2]};\n }\n return null;\n}\nfunction detectPrintedCount(values){\n let gi=-1; for(let i=0;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": [ + 23408, + 26688 + ], + "id": "17fa00ac-b777-44b6-a84a-1c3d9649d998", + "name": "Parsear Q1 Excel" + }, + { + "parameters": { + "jsCode": "\nconst base=$items('Inicializar ejecución')[0];\nconst bin=base.binary||{};\nfunction norm(s){return String(s||'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase().replace(/\\s+/g,' ').trim();}\nfunction formatOf(file,name){\n const s=norm(`${name} ${file?.mimeType||''}`);\n if(/\\.pdf(?:$|\\s)|application\\/pdf/.test(s)) return 'PDF';\n if(/\\.xlsx?(?:$|\\s)|spreadsheet|excel|application\\/vnd\\.ms-excel/.test(s)) return 'EXCEL';\n return '';\n}\nconst entries=Object.entries(bin).map(([key,file])=>{\n const name=String(file.fileName||key), k=norm(key), n=norm(name), all=`${k} ${n}`;\n const format=formatOf(file,name); let score=format?100:-1000;\n \n if(/nomina.*2da|2da.*quincena|segunda.*quincena|quincena.*2/.test(k)) score+=600;\n if(/nomina.*1era|1era.*quincena|primera.*quincena|quincena.*1/.test(k)) score-=700;\n if(/2da|segunda|quincena\\s*2|q2/.test(n)) score+=180;\n if(/1era|primera|quincena\\s*1|q1/.test(n)) score-=220;\n if(/planilla\\s*css|detalleplanilla|extraordin|vacacion|liquidacion/.test(all)) score-=300;\n\n return {key,file,name,format,score,k,n,all};\n}).sort((a,b)=>b.score-a.score);\nconst chosen=entries[0];\nif(!chosen||chosen.score<250||!chosen.format) throw new Error('No pude identificar de forma segura la Nómina de 2da Quincena. Adjunta en ese campo un Excel (.xls/.xlsx) o PDF de la planilla quincenal.');\nreturn [{json:{...base.json,fileName:chosen.name,originalBinaryKey:chosen.key,format:chosen.format,inputMimeType:chosen.file?.mimeType||''},binary:{data:chosen.file}}];\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 23888, + 26832 + ], + "id": "647bdb88-d7ed-46df-a1dc-7b80d00817f1", + "name": "Preparar Q2" + }, + { + "parameters": { + "operation": "xls", + "options": { + "headerRow": false, + "includeEmptyCells": true, + "rawData": false, + "readAsString": false + } + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + 24368, + 26688 + ], + "id": "e4610d47-633c-4527-b6e8-8db8f69dd84a", + "name": "Crear staging Q2" + }, + { + "parameters": { + "jsCode": "const rows=$input.all().map(x=>x.json?.row).filter(r=>Array.isArray(r));\nif(!rows.length) throw new Error('No se pudieron leer filas de Nómina Q2 directamente desde el Excel.');\nreturn [{json:{valueRanges:[{range:'Sheet1!A1:AZ50000',values:rows}],directExtraction:true}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 24608, + 26688 + ], + "id": "6d6ca2c5-c6bd-4d4a-838a-6c9ee056ae7e", + "name": "Reunir staging Q2" + }, + { + "parameters": { + "jsCode": "\nconst payload=$json.body&&typeof $json.body==='object'?$json.body:$json;\nconst valueRanges=payload.valueRanges||[];\nconst prep=$items('Preparar Q2')[0].json;\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction asc(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase();}\nfunction key(v){return asc(v).replace(/[^A-Z0-9]/g,'');}\nfunction num(v){if(v===null||v===undefined||v==='')return 0;if(typeof v==='number')return v;let s=String(v).replace(/B\\/.?|\\$|,/g,'').replace(/\\s/g,'');if(/^\\(.*\\)$/.test(s))s='-'+s.slice(1,-1);const n=Number(s);return Number.isFinite(n)?n:0;}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction detectPeriod(values){\n for(const row of values.slice(0,120)) for(const cell of row||[]){\n const m=clean(cell).match(/Periodo\\s+del\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})\\s+hasta\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})/i);\n if(m)return {start:m[1],end:m[2]};\n }\n return null;\n}\nfunction detectPrintedCount(values){\n let gi=-1; for(let i=0;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": [ + 26048, + 26688 + ], + "id": "b5fa883c-f78d-4e32-87d0-53ff44b914a2", + "name": "Parsear Q2 Excel" + }, + { + "parameters": { + "jsCode": "\nconst base=$items('Inicializar ejecución')[0];\nconst bin=base.binary||{};\nfunction norm(s){return String(s||'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase().replace(/\\s+/g,' ').trim();}\nfunction formatOf(file,name){\n const s=norm(`${name} ${file?.mimeType||''}`);\n if(/\\.pdf(?:$|\\s)|application\\/pdf/.test(s)) return 'PDF';\n if(/\\.xlsx?(?:$|\\s)|spreadsheet|excel|application\\/vnd\\.ms-excel/.test(s)) return 'EXCEL';\n return '';\n}\nconst entries=Object.entries(bin).map(([key,file])=>{\n const name=String(file.fileName||key), k=norm(key), n=norm(name), all=`${k} ${n}`;\n const format=formatOf(file,name); let score=format?100:-1000;\n \n if(/planilla\\s*css|detalleplanilla|caja.*seguro|css/.test(k)) score+=650;\n if(/planilla\\s*css|detalleplanilla|caja.*seguro|css/.test(n)) score+=240;\n if(format!=='EXCEL') score-=900;\n if(/nomina|quincena|extraordin|vacacion|liquidacion/.test(all)) score-=300;\n\n return {key,file,name,format,score,k,n,all};\n}).sort((a,b)=>b.score-a.score);\nconst chosen=entries[0];\nif(!chosen||chosen.score<250||!chosen.format) throw new Error('No pude identificar de forma segura la Planilla CSS. Adjunta en ese campo el archivo oficial DetallePlanilla en Excel (.xls/.xlsx).');\nreturn [{json:{...base.json,fileName:chosen.name,originalBinaryKey:chosen.key,format:chosen.format,inputMimeType:chosen.file?.mimeType||''},binary:{data:chosen.file}}];\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 26288, + 26832 + ], + "id": "fe97274d-5538-4382-be55-100f474ba51f", + "name": "Preparar CSS" + }, + { + "parameters": { + "operation": "xls", + "options": { + "headerRow": false, + "includeEmptyCells": true, + "rawData": false, + "readAsString": false + } + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + 26528, + 26832 + ], + "id": "93d515a6-580d-4c36-b4bb-875309694d42", + "name": "Crear staging CSS" + }, + { + "parameters": { + "jsCode": "const rows=$input.all().map(x=>x.json?.row).filter(r=>Array.isArray(r));\nif(!rows.length) throw new Error('No se pudieron leer filas de Planilla CSS directamente desde el Excel.');\nreturn [{json:{valueRanges:[{range:'Sheet1!A1:AZ50000',values:rows}],directExtraction:true}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 26768, + 26832 + ], + "id": "ad126f54-1ed2-433b-8695-0cd83f9e26ec", + "name": "Reunir staging CSS" + }, + { + "parameters": { + "jsCode": "const payload=$json.body&&typeof $json.body==='object'?$json.body:$json;\nconst valueRanges=payload.valueRanges||[],prep=$items('Preparar CSS')[0].json,config=$items('Inicializar ejecución')[0].json.config||{};\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction asc(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase();}\nfunction num(v){if(v===null||v===undefined||v==='')return 0;if(typeof v==='number')return v;let s=String(v).replace(/B\\/.?|\\$|,/g,'').replace(/\\s/g,'');if(/^\\(.*\\)$/.test(s))s='-'+s.slice(1,-1);const n=Number(s);return Number.isFinite(n)?n:0;}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction monthPeriod(text){const months={ENERO:1,FEBRERO:2,MARZO:3,ABRIL:4,MAYO:5,JUNIO:6,JULIO:7,AGOSTO:8,SEPTIEMBRE:9,OCTUBRE:10,NOVIEMBRE:11,DICIEMBRE:12};const m=asc(text).match(/(ENERO|FEBRERO|MARZO|ABRIL|MAYO|JUNIO|JULIO|AGOSTO|SEPTIEMBRE|OCTUBRE|NOVIEMBRE|DICIEMBRE)\\s+(20\\d{2})/);return m?{month:months[m[1]],year:Number(m[2]),label:`${m[1][0]+m[1].slice(1).toLowerCase()} ${m[2]}`}:null;}\nfunction isEmployeeHeader(row){const h=(row||[]).map(asc),j=h.join('|');return j.includes('SEGURO SOCIAL')&&j.includes('APELLIDO')&&j.includes('NOMBRE')&&(/CEDULA|PASAPORTE|REGISTRO CIVIL|IDENTIFICACION|DOCUMENTO/.test(j));}\nif(!valueRanges.length)throw new Error('La Planilla CSS no contiene hojas legibles.');\nconst ranked=valueRanges.map(vr=>({vr,count:(vr.values||[]).reduce((s,r)=>s+(isEmployeeHeader(r)?1:0),0),rows:(vr.values||[]).length})).sort((a,b)=>b.count-a.count||b.rows-a.rows);\nconst chosen=ranked[0],values=chosen?.vr?.values||[];if(!values.length||chosen.count<1)throw new Error('No pude localizar la estructura de empleados dentro de la Planilla CSS oficial.');\nlet period=null;for(let r=0;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": [ + 28208, + 26832 + ], + "id": "b7c40654-7c89-4c6e-82bc-8bac76dd1221", + "name": "Parsear CSS" + }, + { + "parameters": { + "operation": "pdf", + "options": {} + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + 28688, + 26832 + ], + "id": "7b3089a6-fbbf-403f-b4fb-c02321004c8c", + "name": "Extraer texto PDFs" + }, + { + "parameters": { + "jsCode": "const extracted=$input.all();\nconst metaItems=$items('Normalizar PDFs descargados');\nconst css=($items('Parsear CSS')[0]||{}).json||{};\nconst init=(($items('Inicializar ejecución')[0]||{}).json)||{};\nconst cfg=init.config||{};\nconst rates=cfg.defaultRates||{ssRegular:0.0975,se:0.0125,ssDtm:0.0725};\nconst targetMonth=Number(css.period?.month||0),targetYear=Number(css.period?.year||0);\n\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'').toUpperCase().replace(/[^A-Z0-9 ]/g,' ').replace(/\\s+/g,' ').trim();}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction parseDMY(s){const m=String(s||'').match(/(\\d{1,2})\\/(\\d{1,2})\\/(20\\d{2})/);if(!m)return null;return new Date(Number(m[3]),Number(m[2])-1,Number(m[1]));}\nfunction dateKey(d){return d?`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`:'';}\nfunction fmtDate(d){return d?`${String(d.getDate()).padStart(2,'0')}/${String(d.getMonth()+1).padStart(2,'0')}/${d.getFullYear()}`:'';}\nfunction inTarget(d){return !!(d&&d.getMonth()+1===targetMonth&&d.getFullYear()===targetYear);}\nfunction overlapTarget(a,b){if(!a||!b)return null;const x=[a,b].sort((p,q)=>p-q),first=new Date(targetYear,targetMonth-1,1),last=new Date(targetYear,targetMonth,0,23,59,59);return x[0]<=last&&x[1]>=first;}\nfunction regexAll(text,re){const flags=(re.flags||'').includes('g')?re.flags:(re.flags||'')+'g',rg=new RegExp(re.source,flags),out=[];let m;while((m=rg.exec(String(text||'')))!==null){out.push(m);if(m[0]==='')rg.lastIndex++;}return out;}\nfunction markers(text,re){return regexAll(text,re).map(m=>({index:m.index,end:m.index+m[0].length,match:m}));}\n\n// PDF.js a veces separa el decimal en dos líneas (66.\\n54 o 66 \\n.54).\n// Solo se reparan fragmentos que contienen un punto decimal explícito para no unir cifras distintas.\nfunction healNumericFragments(src){\n let s=String(src||'').replace(/\\r/g,'');\n for(let pass=0;pass<5;pass++){\n s=s.replace(/(\\d[\\d,]*)\\.\\s+(\\d{1,2})(?=(?:\\s|$|[^\\d]))/g,'$1.$2');\n s=s.replace(/(\\d[\\d,]*)\\s+\\.\\s*(\\d{1,2})(?=(?:\\s|$|[^\\d]))/g,'$1.$2');\n s=s.replace(/(\\d{1,3}),\\s+(\\d{3})(?=(?:\\.\\d{1,2})?(?:\\s|$|[^\\d]))/g,'$1,$2');\n }\n return s;\n}\nfunction moneyNum(v){\n if(v===null||v===undefined)return null;\n let s=String(v).replace(/B\\/\\.?|\\$/gi,'').replace(/,/g,'').trim();\n if(/^\\(.*\\)$/.test(s))s='-'+s.slice(1,-1);\n if(!/^-?\\d+(?:\\.\\d+)?$/.test(s))return null;\n const n=Number(s);return Number.isFinite(n)?round(n):null;\n}\nfunction pureMoney(line){\n const s=String(line||'').trim();\n if(!/^[\\s$()\\-]*[\\d,]+(?:\\.\\d+)?\\s*$/.test(s))return null;\n return moneyNum(s);\n}\nfunction inlineNumbers(s){\n const src=String(s||''),out=[];\n const re=/[-(]?\\$?\\d[\\d,]*(?:\\.\\d+)?\\)?/g;let m;\n while((m=re.exec(src))!==null){const v=moneyNum(m[0]);if(v!==null)out.push({value:v,index:m.index,end:re.lastIndex,raw:m[0]});if(m[0]==='')re.lastIndex++;}\n return out;\n}\nfunction splitLines(block){return String(block||'').replace(/\\r/g,'').split('\\n').map(x=>x.trim());}\n\n// Lee el importe que pertenece a una etiqueta concreta. Prioridad:\n// 1) cifra en la MISMA línea de la etiqueta; 2) línea monetaria inmediatamente anterior;\n// 3) línea monetaria inmediatamente posterior. Nunca busca importes arbitrarios a distancia.\nfunction valueAtLabel(block,labelRe,opts={}){\n const {prefer='before',steps=3}=opts,ls=splitLines(block),hits=[];\n for(let i=0;i{const a=inlineNumbers(str);if(!a.length)return null;return fromEnd?a[a.length-1].value:a[0].value;};\n if(prefer==='after'){\n let v=first(after,false);if(v!==null)return {found:true,value:v,source:'INLINE_AFTER'};\n v=first(before,true);if(v!==null)return {found:true,value:v,source:'INLINE_BEFORE'};\n }else{\n let v=first(before,true);if(v!==null)return {found:true,value:v,source:'INLINE_BEFORE'};\n v=first(after,false);if(v!==null)return {found:true,value:v,source:'INLINE_AFTER'};\n }\n }\n const before=[],after=[];\n for(let d=1;d<=steps;d++){if(i-d>=0)before.push(i-d);if(i+d0?ms[j-1].end:0,ms[j].index);}\nfunction segPost(text,ms,j){return text.slice(ms[j].end,j+1({txt:x[1],d:parseDMY(x[1])})).filter(x=>x.d);\n if(dates.length<2)return null;\n const pair=dates.slice(-2).map(x=>x.d).sort((a,b)=>a-b),startDate=pair[0],endDate=pair[1];\n return {startDate,endDate,start:dateKey(startDate),end:dateKey(endDate),display:`${fmtDate(startDate)} - ${fmtDate(endDate)}`};\n}\nfunction close(a,b,tol){return Number.isFinite(a)&&Number.isFinite(b)&&Math.abs(a-b)<=tol;}\nfunction recordFingerprint(r){return [r.category,clean(r.code),norm(r.name),clean(r.date),clean(r.transaction),round(r.ss),round(r.se),round(r.gross),round(r.regularBase),round(r.dtmBase)].join('|');}\nfunction samePerson(a,b){const ac=clean(a.code).replace(/^0+(?=\\d)/,''),bc=clean(b.code).replace(/^0+(?=\\d)/,'');if(ac&&bc&&ac===bc)return true;return !!(norm(a.name)&&norm(a.name)===norm(b.name));}\n\nconst records=[],validations=[],supportDocs=[],files=[];\nfunction validation(status,category,fileName,detail){validations.push({status,severity:(status==='OK'||status==='IGNORADO'||status==='EXCLUIDO')?'INFO':'WARNING',category,fileName,detail});}\nfunction addRecord(r){records.push({...r,ss:round(r.ss||0),se:round(r.se||0),gross:round(r.gross||0),regularBase:round(r.regularBase||0),dtmBase:round(r.dtmBase||0),parserMode:'LINE_ANCHOR_V7'});}\n\nfor(let i=0;i({code:clean(x.match[1]).replace(/^0+(?=\\d)/,''),name:clean(x.match[2]).replace(/\\s+(?:A[nñ]o|C[eé]dula:).*$/i,'')}));\n const seen=new Set(),employees=[];for(const e of raw){const k=`${e.code}|${norm(e.name)}`;if(!seen.has(k)){seen.add(k);employees.push(e);}}\n supportDocs.push({fileName,employees});validation('IGNORADO',category,fileName,`Documento de cálculo/soporte (${employees.length} empleado(s)); no se suma para evitar duplicidad.`);continue;\n }\n const ms=markers(text,/Empleado\\s*#\\s*(\\d+)\\s*:\\s*([^\\n\\r]+?)(?=\\s+Clave\\s*ISR|$)/i);\n if(!ms.length){validation('REVISAR',category,fileName,'No se detectaron empleados en la Planilla de Liquidación resumida.');continue;}\n let inc=0,out=0,review=0;\n for(let j=0;j!x.found))reason='No se pudieron leer todos los campos impresos de la liquidación dentro del bloque del empleado.';\n let regularBase=0;\n if(!reason){\n regularBase=round(sv.value-dtm.value);\n const compSV=round(sal.value+vac.value+dtm.value),compGross=round(sv.value+pai.value),expSS=round(regularBase*Number(rates.ssRegular||0.0975)+dtm.value*Number(rates.ssDtm||0.0725)),expSE=round(regularBase*Number(rates.se||0.0125));\n if(regularBase<0||!close(sv.value,compSV,.08)||!close(gross.value,compGross,.08)||!close(ss.value,expSS,.08)||!close(se.value,expSE,.08))reason=`Control contable no cuadra: Sal/Vac/13 ${sv.value} vs ${compSV}; bruto ${gross.value} vs ${compGross}; S.S. ${ss.value} (~${expSS}); S.E. ${se.value} (~${expSE}).`;\n }\n if(reason){review++;addRecord({category:'Liquidación',fileName,code,name,ss:ss.found?ss.value:0,se:se.found?se.value:0,gross:gross.found?gross.value:0,regularBase,dtmBase:dtm.found?dtm.value:0,salaryVac13:sv.found?sv.value:0,salaryReg:sal.found?sal.value:0,vacationBase:vac.found?vac.value:0,date,transaction,planillaNumber,generatedAt,included:false,reason});continue;}\n const included=inTarget(parseDMY(date));if(included)inc++;else out++;\n addRecord({category:'Liquidación',fileName,code,name,ss:ss.value,se:se.value,gross:gross.value,regularBase,dtmBase:dtm.value,salaryVac13:sv.value,salaryReg:sal.value,vacationBase:vac.value,date,transaction,planillaNumber,generatedAt,included,reason:included?'':'Fecha de liquidación fuera del mes de la Planilla CSS',ssMethod:'SOURCE_LINE',seMethod:'SOURCE_LINE'});\n }\n validation(review?'REVISAR':out?'EXCLUIDO':'OK',category,fileName,`${ms.length} liquidaciones: ${inc} incluidas, ${out} fuera de período y ${review} no legibles. Lectura por línea/etiqueta + identidades contables del mismo empleado.`);\n }else validation('REVISAR',category||'desconocida',fileName,'Categoría complementaria no reconocida; archivo excluido del cálculo.');\n}\n\n// Duplicados: nunca se suman dos veces.\nconst seenExtra=new Map();\nfor(const r of records){if(r.category!=='Extraordinaria'||!r.included)continue;const k=r.transaction?`T:${r.transaction}`:`F:${r.code||norm(r.name)}|${r.periodStart}|${r.periodEnd}|${r.ss}|${r.se}|${r.gross}`;if(!seenExtra.has(k)){seenExtra.set(k,r);continue;}const p=seenExtra.get(k);if(recordFingerprint(p)===recordFingerprint(r)){r.included=false;r.reason=`Duplicado exacto; ya incluido desde ${p.fileName}`;}else{r.included=false;r.reason=`Transacción ${r.transaction} repetida con datos distintos; revisar.`;validation('REVISAR','extraordinaria',r.fileName,r.reason);}}\nconst seenVac=new Map(),vacGroups=new Map();\nfor(const r of records){if(r.category!=='Vacaciones'||!r.included)continue;const person=r.code||norm(r.name),ek=`${person}|${r.periodStart}|${r.periodEnd}|${r.ss}|${r.se}|${r.gross}`;if(seenVac.has(ek)){r.included=false;r.reason=`Duplicado exacto; ya incluido desde ${seenVac.get(ek).fileName}`;continue;}seenVac.set(ek,r);const gk=`${person}|${r.periodStart}|${r.periodEnd}`;if(!vacGroups.has(gk))vacGroups.set(gk,[]);vacGroups.get(gk).push(r);}\nfor(const arr of vacGroups.values()){const active=arr.filter(x=>x.included);if(active.length<=1)continue;active.sort((a,b)=>(Number(b.planillaNumber)||0)-(Number(a.planillaNumber)||0)||String(b.generatedAt).localeCompare(String(a.generatedAt)));for(const r of active.slice(1)){r.included=false;r.reason=`Sustituida por planilla de vacaciones más reciente (${active[0].planillaNumber||active[0].fileName}).`;}}\nconst seenLiq=new Map();\nfor(const r of records){if(r.category!=='Liquidación'||!r.included)continue;const k=r.transaction?`T:${r.transaction}`:`F:${r.code||norm(r.name)}|${r.date}|${r.ss}|${r.se}|${r.gross}`;if(!seenLiq.has(k)){seenLiq.set(k,r);continue;}const p=seenLiq.get(k);if(recordFingerprint(p)===recordFingerprint(r)){r.included=false;r.reason=`Duplicado exacto; ya incluido desde ${p.fileName}`;}else{r.included=false;r.reason=`Transacción ${r.transaction} repetida con datos distintos; revisar.`;validation('REVISAR','liquidaciones',r.fileName,r.reason);}}\n\n// Documentos de cálculo: son soporte y nunca alteran importes ni provocan doble suma.\nconst liqAll=records.filter(r=>r.category==='Liquidación');\nfor(const doc of supportDocs){const missing=(doc.employees||[]).filter(e=>!liqAll.some(r=>samePerson(e,r)));if(missing.length)validation('REVISAR','liquidaciones',doc.fileName,`Documento de cálculo contiene persona(s) sin planilla resumida: ${missing.map(x=>`${x.name}${x.code?` (#${x.code})`:''}`).join(', ')}.`);}\n\nconst includedRecords=records.filter(r=>r.included),catCount=cat=>includedRecords.filter(r=>r.category===cat).length;\nconst RESULT={records,validations,supportDocs,includedRecords,files:[...new Set(files)],counts:{extraordinarias:catCount('Extraordinaria'),vacaciones:catCount('Vacaciones'),liquidaciones:catCount('Liquidación')},totals:{extraSS:round(includedRecords.filter(r=>r.category==='Extraordinaria').reduce((s,r)=>s+r.ss,0)),vacSS:round(includedRecords.filter(r=>r.category==='Vacaciones').reduce((s,r)=>s+r.ss,0)),liqSS:round(includedRecords.filter(r=>r.category==='Liquidación').reduce((s,r)=>s+r.ss,0)),totalSS:round(includedRecords.reduce((s,r)=>s+r.ss,0)),totalSE:round(includedRecords.reduce((s,r)=>s+r.se,0))},parserVersion:'LINE_ANCHOR_V7_SIN_IA',quality:{status:validations.some(v=>v.status==='REVISAR')?'REVISAR':'VALIDADO',rule:'Los complementarios se leen por líneas ancladas a etiquetas dentro del bloque exacto de cada empleado. No se alinean listas globales, no se buscan importes por proximidad amplia y no se reconstruyen descuentos cuando el PDF trae el valor impreso.'}};\nreturn [{json:RESULT}];\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 28928, + 26832 + ], + "id": "ed614c7e-da77-4670-86ac-ba83d855ba21", + "name": "Parsear PDFs", + "notesInFlow": true, + "notes": "Parser V7 SIN IA: lectura determinista por líneas ancladas a etiquetas dentro del bloque exacto de cada empleado; sin alineación global ni búsqueda por proximidad amplia." + }, + { + "parameters": { + "jsCode": "const init=$items('Inicializar ejecución')[0].json,config=init.config||{};\nconst q1=$items('Resultado Q1')[0].json,q2=$items('Resultado Q2')[0].json,css=$items('Parsear CSS')[0].json,pdfs=$items('Parsear PDFs')[0].json;\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase().replace(/\\bVASQUEZ\\b/g,'VAZQUEZ').replace(/[^A-Z0-9 ]/g,' ').replace(/\\s+/g,' ').trim();}\nconst particles=new Set(['DE','DEL','LA','LAS','LOS','Y','DA','DOS']);\nfunction core(v){return norm(v).split(' ').filter(t=>t&&!particles.has(t));}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction money(n){return `B/. ${Number(n||0).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}`;}\nfunction parseDMY(s){const m=String(s||'').match(/(\\d{1,2})\\/(\\d{1,2})\\/(20\\d{2})/);return m?new Date(Number(m[3]),Number(m[2])-1,Number(m[1])):null;}\nfunction validatePayrollPeriod(label,p,half){\n if(!p)return {status:'REVISAR',detail:`${label}: período no detectado automáticamente; se validó estructura y totales, pero conviene confirmar el archivo.`};\n const a=parseDMY(p.start),b=parseDMY(p.end),tm=css.period.month,ty=css.period.year;\n if(!a||!b)return {status:'REVISAR',detail:`${label}: período ilegible.`};\n const same=a.getFullYear()===ty&&b.getFullYear()===ty&&a.getMonth()+1===tm&&b.getMonth()+1===tm;\n if(!same)throw new Error(`${label} pertenece a ${p.start} - ${p.end}, pero la Planilla CSS es ${css.periodoLabel}. Se detuvo el cruce.`);\n if(a>b)throw new Error(`${label} tiene un período invertido (${p.start} - ${p.end}). Se detuvo el cruce.`);\n if(half===1&&(a.getDate()>15||b.getDate()>16))throw new Error(`${label} no parece ser la 1era quincena (${p.start} - ${p.end}). Revisa que no hayas cargado la 2da quincena por error.`);\n if(half===2&&(a.getDate()<15||b.getDate()<27))throw new Error(`${label} no parece ser la 2da quincena (${p.start} - ${p.end}). Revisa que no hayas cargado la 1era quincena por error.`);\n return {status:'OK',detail:`${label}: ${p.start} - ${p.end}`};\n}\nfunction payrollFingerprint(x){return (x.records||[]).map(r=>[String(r.code||''),norm(r.name),round(r.ss),round(r.se),round(r.gross)].join('|')).sort().join('||');}\nconst periodChecks=[validatePayrollPeriod('Nómina Q1',q1.period,1),validatePayrollPeriod('Nómina Q2',q2.period,2)];\nconst fpQ1=payrollFingerprint(q1),fpQ2=payrollFingerprint(q2);\nif(fpQ1&&fpQ2&&fpQ1===fpQ2)throw new Error('Las nóminas Q1 y Q2 contienen exactamente los mismos empleados y montos. Se detuvo el cruce porque probablemente se cargó la misma quincena dos veces.');\n// Inferencia dinámica de tasas del trabajador desde los propios recibos, para no depender de tasas hardcodeadas año tras año.\nfunction median(a){const x=a.filter(Number.isFinite).sort((p,q)=>p-q);if(!x.length)return null;const m=Math.floor(x.length/2);return x.length%2?x[m]:(x[m-1]+x[m])/2;}\nconst ratioSS=[],ratioSE=[];\nfor(const r of [...(q1.records||[]),...(q2.records||[])]){const g=Number(r.gross)||0,ss=Number(r.ss)||0,se=Number(r.se)||0;if(g>=20&&ss>0){const x=ss/g;if(x>.03&&x<.20)ratioSS.push(x);}if(g>=20&&se>0){const x=se/g;if(x>.001&&x<.05)ratioSE.push(x);}}\nconst fallback=config.defaultRates||{ssRegular:.0975,se:.0125,ssDtm:.0725};\nconst ssMed=median(ratioSS),seMed=median(ratioSE),ssRegular=Number((ssMed??fallback.ssRegular).toFixed(4)),seRate=Number((seMed??fallback.se).toFixed(4));\nconst dtmRatios=[];for(const r of pdfs.includedRecords||[]){if(r.category!=='Liquidación'||Number(r.dtmBase||0)<=1)continue;const x=(Number(r.ss||0)-Number(r.regularBase||0)*ssRegular)/Number(r.dtmBase);if(x>.03&&x<.15)dtmRatios.push(x);}\nconst dtmMed=median(dtmRatios),ssDtm=Number((dtmMed??fallback.ssDtm).toFixed(4));\nconst rateInfo={ssRegular,se:seRate,ssDtm,samplesSS:ratioSS.length,samplesSE:ratioSE.length,samplesDTM:dtmRatios.length,usedFallbackSS:ssMed===null,usedFallbackSE:seMed===null,usedFallbackDTM:dtmMed===null};\nif(ratioSS.length<(config.minRateSamples||20)||ratioSE.length<(config.minRateSamples||20))throw new Error(`No hubo suficientes registros para inferir las tasas de S.S./S.E. de forma segura (${ratioSS.length}/${ratioSE.length}). Se detuvo el cruce.`);\nconst plans=(css.employees||[]).map((p,index)=>({...p,_idx:index,expectedWorkerSS:round(Number(p.salaryAdjusted||0)*ssRegular+Number(p.dtmBase||0)*ssDtm),expectedWorkerSE:round(Number(p.salaryAdjusted||0)*seRate)}));\n// Une todas las fuentes por Employee # cuando existe; cuando el PDF pierde el código, usa el nombre exacto conservadoramente.\nconst observations=[];for(const r of q1.records||[])observations.push({...r,kind:'q1'});for(const r of q2.records||[])observations.push({...r,kind:'q2'});for(const r of pdfs.includedRecords||[]){const kind=r.category==='Extraordinaria'?'extra':r.category==='Vacaciones'?'vac':'liq';observations.push({...r,kind});}\nconst groups=new Map(),nameCodes=new Map();\nfor(const r of observations){const code=clean(r.code).replace(/^0+(?=\\d)/,''),nn=norm(r.name);if(!code)continue;const k=`C:${code}`;if(!groups.has(k))groups.set(k,{key:k,code,names:[],obs:[]});const g=groups.get(k);if(r.name&&!g.names.includes(r.name))g.names.push(r.name);g.obs.push(r);if(!nameCodes.has(nn))nameCodes.set(nn,new Set());nameCodes.get(nn).add(code);}\nfor(const r of observations){const code=clean(r.code).replace(/^0+(?=\\d)/,'');if(code)continue;const nn=norm(r.name),codes=nameCodes.get(nn)||new Set();let g;if(codes.size===1)g=groups.get(`C:${[...codes][0]}`);else{const k=`N:${nn}`;if(!groups.has(k))groups.set(k,{key:k,code:'',names:[],obs:[]});g=groups.get(k);}if(r.name&&!g.names.includes(r.name))g.names.push(r.name);g.obs.push(r);}\nfor(const g of groups.values()){for(const k of ['q1SS','q1SE','q2SS','q2SE','extraSS','extraSE','vacSS','vacSE','liqSS','liqSE'])g[k]=0;for(const r of g.obs){const ss=Number(r.ss)||0,se=Number(r.se)||0;g[`${r.kind}SS`]+=ss;g[`${r.kind}SE`]+=se;}g.name=g.names[0]||'';g.totalSS=round(g.q1SS+g.q2SS+g.extraSS+g.vacSS+g.liqSS);g.totalSE=round(g.q1SE+g.q2SE+g.extraSE+g.vacSE+g.liqSE);}\nconst sources=[...groups.values()];\n// Similaridad conservadora OPTIMIZADA: conserva las mismas reglas de matching,\n// pero evita comparar cada fuente contra cada empleado CSS con Levenshtein completo.\n// Primero crea índices baratos (tokens, prefijos, iniciales y montos) y solo ejecuta\n// la similitud costosa sobre un conjunto reducido de candidatos plausibles.\nfunction levRaw(a,b){\n a=String(a||''); b=String(b||'');\n if(a===b)return 0;\n if(!a.length)return b.length;\n if(!b.length)return a.length;\n if(a.length>b.length){const t=a;a=b;b=t;}\n let prev=new Uint16Array(a.length+1),cur=new Uint16Array(a.length+1);\n for(let i=0;i<=a.length;i++)prev[i]=i;\n for(let j=1;j<=b.length;j++){\n cur[0]=j;\n const bj=b.charCodeAt(j-1);\n for(let i=1;i<=a.length;i++){\n const cost=a.charCodeAt(i-1)===bj?0:1;\n const ins=cur[i-1]+1,del=prev[i]+1,sub=prev[i-1]+cost;\n cur[i]=Math.min(ins,del,sub);\n }\n const tmp=prev;prev=cur;cur=tmp;\n }\n return prev[a.length];\n}\nfunction compactNorm(v){return norm(v).replace(/ /g,'');}\nfunction simPrepared(a,b){a=String(a||'');b=String(b||'');return !a||!b?0:1-levRaw(a,b)/Math.max(a.length,b.length);}\nfunction tokenCovPrepared(A,B){\n if(!A.length||!B.length)return 0;\n const avg=(X,Y)=>{\n let total=0;\n for(const x of X){\n let best=0;\n for(const y of Y){\n const s=simPrepared(x,y);\n if(s>best)best=s;\n if(best===1)break;\n }\n total+=best;\n }\n return total/X.length;\n };\n return (avg(A,B)+avg(B,A))/2;\n}\nfunction sourceParts(name){\n const raw=String(name||'');let l='',f='';\n if(raw.includes(',')){const pos=raw.indexOf(',');l=raw.slice(0,pos);f=raw.slice(pos+1);}\n else{const t=norm(raw).split(' ');l=t.shift()||'';f=t.join(' ');}\n const L=core(l),F=core(f);\n return {\n last:norm(l),first:norm(f),last1:L[0]||'',first1:F[0]||'',\n lastC:compactNorm(l),firstC:compactNorm(f),\n last1C:compactNorm(L[0]||''),first1C:compactNorm(F[0]||'')\n };\n}\nfunction planParts(p){\n const L=core(p.lastname),F=core(p.firstname);\n return {\n last:norm(p.lastname),first:norm(p.firstname),last1:L[0]||'',first1:F[0]||'',\n lastC:compactNorm(p.lastname),firstC:compactNorm(p.firstname),\n last1C:compactNorm(L[0]||''),first1C:compactNorm(F[0]||'')\n };\n}\nfunction uniqTokens(v){return [...new Set(core(v).map(compactNorm).filter(Boolean))];}\nfunction addIndex(map,k,v){if(!k)return;if(!map.has(k))map.set(k,[]);map.get(k).push(v);}\nfunction addBucket(set,map,k){for(const v of map.get(k)||[])set.add(v);}\nfunction prefix(v,n){v=String(v||'');return v.length>=n?v.slice(0,n):v;}\nfunction amountClass(dss,dse){if(dss<=.05&&dse<=.05)return 1;if(dss<=.12&&dse<=.12)return .95;if(dss<=.25&&dse<=.25)return .85;if(dss<=.5&&dse<=.5)return .70;if(dss<=1&&dse<=1)return .50;return 0;}\n\nconst planMeta=plans.map((p,pi)=>({\n p,pi,pp:planParts(p),tokens:uniqTokens(p.name),\n pairKey:`${p.expectedWorkerSS.toFixed(2)}|${p.expectedWorkerSE.toFixed(2)}`\n}));\nconst sourceMeta=sources.map((s,si)=>({s,si,sp:sourceParts(s.name),tokens:uniqTokens(s.name)}));\n\nconst pairFreq=new Map(),firstFreq=new Map(),lastFreq=new Map();\nfor(const m of planMeta){\n pairFreq.set(m.pairKey,(pairFreq.get(m.pairKey)||0)+1);\n firstFreq.set(m.pp.first1,(firstFreq.get(m.pp.first1)||0)+1);\n lastFreq.set(m.pp.last1,(lastFreq.get(m.pp.last1)||0)+1);\n}\n\nconst byToken=new Map(),byLast2=new Map(),byLast3=new Map(),byFirst2=new Map(),byFirst3=new Map(),byInitialPair=new Map(),byAmount=new Map();\nfunction amountKey(ss,se){return `${Math.round((Number(ss)||0)*4)}|${Math.round((Number(se)||0)*4)}`;}\nfor(const m of planMeta){\n for(const t of m.tokens)addIndex(byToken,t,m.pi);\n addIndex(byLast2,prefix(m.pp.last1C,2),m.pi);\n addIndex(byLast3,prefix(m.pp.last1C,3),m.pi);\n addIndex(byFirst2,prefix(m.pp.first1C,2),m.pi);\n addIndex(byFirst3,prefix(m.pp.first1C,3),m.pi);\n addIndex(byInitialPair,`${m.pp.last1C[0]||''}|${m.pp.first1C[0]||''}`,m.pi);\n addIndex(byAmount,amountKey(m.p.expectedWorkerSS,m.p.expectedWorkerSE),m.pi);\n}\n\nconst candidates=[],candidatesBySource=new Map();\nconst tierBuckets=[[],[],[],[],[],[]];\nfor(const sm of sourceMeta){\n const s=sm.s,sp=sm.sp,pool=new Set();\n\n // Evidencia de identidad exacta o por prefijo.\n for(const t of sm.tokens)addBucket(pool,byToken,t);\n addBucket(pool,byLast3,prefix(sp.last1C,3));\n addBucket(pool,byFirst3,prefix(sp.first1C,3));\n addBucket(pool,byLast2,prefix(sp.last1C,2));\n addBucket(pool,byFirst2,prefix(sp.first1C,2));\n addBucket(pool,byInitialPair,`${sp.last1C[0]||''}|${sp.first1C[0]||''}`);\n\n // Evidencia de monto: busca hasta +/- B/.1.00 en pasos de B/.0.25.\n const ssQ=Math.round((Number(s.totalSS)||0)*4),seQ=Math.round((Number(s.totalSE)||0)*4);\n for(let ds=-4;ds<=4;ds++)for(let de=-4;de<=4;de++)addBucket(pool,byAmount,`${ssQ+ds}|${seQ+de}`);\n\n // Red de seguridad: si los índices produjeron muy pocos candidatos, agrega los\n // 12 CSS más cercanos por monto. Este barrido solo hace aritmética, no Levenshtein.\n if(pool.size<12){\n const closest=[];\n for(const pm of planMeta){\n const d=Math.abs((Number(s.totalSS)||0)-pm.p.expectedWorkerSS)+Math.abs((Number(s.totalSE)||0)-pm.p.expectedWorkerSE);\n if(closest.length<12){closest.push([d,pm.pi]);closest.sort((a,b)=>a[0]-b[0]);}\n else if(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": [ + 29168, + 26832 + ], + "id": "cae896d0-72d3-424a-ae45-3c3483b9fbd3", + "name": "Construir reporte producción" + }, + { + "parameters": { + "method": "POST", + "url": "https://sheets.googleapis.com/v4/spreadsheets", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.createSheetBody }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 29408, + 26832 + ], + "id": "6fa8dc8f-bd83-445f-97d7-e1acdedddc97", + "name": "Crear Google Sheet Reporte", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "method": "POST", + "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.spreadsheetId }}/values:batchUpdate", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $('Construir reporte producción').first().json.writeReportBody }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 29648, + 26832 + ], + "id": "fe78de18-a2d4-4866-8e99-4a87819129fc", + "name": "Escribir reporte", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const created=$items('Crear Google Sheet Reporte')[0].json; const report=$items('Construir reporte producción')[0].json;\nconst rowsByName={'Resumen':report.summaryRows,'Cruce Identidad':report.identityRows,'Cruce Montos CSS':report.amountRows,'Detalle Fuentes':report.detailRows,'Detalle Planilla CSS':report.cssRows,'Validaciones':report.valRows};\nconst requests=[];\nfor(const s of created.sheets||[]){\n const name=s.properties.title,id=s.properties.sheetId,rows=rowsByName[name]||[]; const cols=Math.max(1,...rows.map(r=>r.length)); const rowCount=Math.max(rows.length,5);\n requests.push({updateSheetProperties:{properties:{sheetId:id,gridProperties:{hideGridlines:true,frozenRowCount:4}},fields:'gridProperties.hideGridlines,gridProperties.frozenRowCount'}});\n requests.push({repeatCell:{range:{sheetId:id,startRowIndex:0,endRowIndex:rowCount,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{textFormat:{fontFamily:'Arial',fontSize:10,foregroundColor:{red:0.290,green:0.290,blue:0.290}},verticalAlignment:'MIDDLE'}},fields:'userEnteredFormat(textFormat,verticalAlignment)'}});\n requests.push({mergeCells:{range:{sheetId:id,startRowIndex:0,endRowIndex:1,startColumnIndex:0,endColumnIndex:cols},mergeType:'MERGE_ALL'}});\n requests.push({mergeCells:{range:{sheetId:id,startRowIndex:1,endRowIndex:2,startColumnIndex:0,endColumnIndex:cols},mergeType:'MERGE_ALL'}});\n requests.push({repeatCell:{range:{sheetId:id,startRowIndex:0,endRowIndex:1,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{backgroundColor:{red:0.310,green:0.459,blue:0.545},textFormat:{fontFamily:'Arial',fontSize:13,bold:true,foregroundColor:{red:1,green:1,blue:1}},horizontalAlignment:'LEFT',borders:{bottom:{style:'SOLID_THICK',color:{red:0.424,green:0.761,blue:0.290}}}}},fields:'userEnteredFormat'}});\n requests.push({repeatCell:{range:{sheetId:id,startRowIndex:1,endRowIndex:2,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{backgroundColor:{red:0.357,green:0.498,blue:0.584},textFormat:{fontFamily:'Arial',fontSize:10,italic:true,foregroundColor:{red:1,green:1,blue:1}}}},fields:'userEnteredFormat'}});\n requests.push({repeatCell:{range:{sheetId:id,startRowIndex:3,endRowIndex:4,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{backgroundColor:{red:0.310,green:0.459,blue:0.545},textFormat:{fontFamily:'Arial',fontSize:10,bold:true,foregroundColor:{red:1,green:1,blue:1}},horizontalAlignment:'CENTER',wrapStrategy:'WRAP'}},fields:'userEnteredFormat'}});\n requests.push({autoResizeDimensions:{dimensions:{sheetId:id,dimension:'COLUMNS',startIndex:0,endIndex:cols}}});\n requests.push({updateDimensionProperties:{range:{sheetId:id,dimension:'ROWS',startIndex:0,endIndex:1},properties:{pixelSize:34},fields:'pixelSize'}});\n if(rowCount>4){\n const rules=[\n ['REVISAR',{red:0.992,green:0.925,blue:0.918},{red:0.65,green:0.15,blue:0.12}],\n ['SOLO',{red:1.0,green:0.973,blue:0.882},{red:0.55,green:0.35,blue:0.0}],\n ['ERROR',{red:0.992,green:0.925,blue:0.918},{red:0.65,green:0.15,blue:0.12}],\n ['EXCLUIDO',{red:1.0,green:0.973,blue:0.882},{red:0.55,green:0.35,blue:0.0}]\n ];\n for(const [txt,bg,fg] of rules)requests.push({addConditionalFormatRule:{rule:{ranges:[{sheetId:id,startRowIndex:4,endRowIndex:rowCount,startColumnIndex:0,endColumnIndex:1}],booleanRule:{condition:{type:'TEXT_CONTAINS',values:[{userEnteredValue:txt}]},format:{backgroundColor:bg,textFormat:{bold:true,foregroundColor:fg}}}},index:0}});\n}\n}\nreturn [{json:{formatBody:{requests}}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 29888, + 26832 + ], + "id": "6e359784-7724-4dcc-82f3-e8ec5867288b", + "name": "Preparar formato reporte" + }, + { + "parameters": { + "method": "POST", + "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $('Crear Google Sheet Reporte').item.json.spreadsheetId }}:batchUpdate", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json.formatBody) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 30128, + 26832 + ], + "id": "d4092b24-112e-4cbc-89a8-757458c3baec", + "name": "Formatear reporte", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const init=$items('Inicializar ejecución')[0].json; const id=$items('Crear Google Sheet Reporte')[0].json.spreadsheetId; const url=`https://docs.google.com/spreadsheets/d/${id}/edit`;\nreturn init.recipients.map(emailAddress=>({json:{emailAddress,spreadsheetId:id,spreadsheetUrl:url}}));" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 30368, + 26832 + ], + "id": "c569cb6f-97bc-42ef-bfc0-0636ef6f98b1", + "name": "Preparar permisos" + }, + { + "parameters": { + "method": "POST", + "url": "=https://www.googleapis.com/drive/v3/files/{{ $json.spreadsheetId }}/permissions?sendNotificationEmail=false&supportsAllDrives=true", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleDriveOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ role: 'writer', type: 'user', emailAddress: $json.emailAddress }) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 30608, + 26832 + ], + "id": "164892df-85f8-4265-bb47-a21c7700f6f4", + "name": "Compartir reporte como editor", + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + }, + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "const init=$items('Inicializar ejecución')[0].json,r=$items('Construir reporte producción')[0].json,s=r.summary,id=$items('Crear Google Sheet Reporte')[0].json.spreadsheetId,url=`https://docs.google.com/spreadsheets/d/${id}/edit`;\nconst nf=n=>Number(n||0).toLocaleString('en-US'),money=n=>`B/. ${Number(n||0).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}`,esc=x=>String(x??'').replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[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": [ + 30848, + 26832 + ], + "id": "bdee4a88-ed77-41b1-a4ea-c0321f5587d2", + "name": "Preparar correo HTML GLM" + }, + { + "parameters": { + "sendTo": "={{ $json.sendTo }}", + "subject": "={{ $json.subject }}", + "message": "={{ $json.html }}", + "options": { + "appendAttribution": false + } + }, + "type": "n8n-nodes-base.gmail", + "typeVersion": 2.2, + "position": [ + 31088, + 26832 + ], + "id": "1cf118be-34f7-46ff-9a83-414bdb9bdf5a", + "name": "Enviar correo resumen", + "webhookId": "ae5f87e7-df2b-4349-8eb5-aac208accb96", + "executeOnce": true, + "credentials": { + "gmailOAuth2": { + "id": "UDcO1FLJqA453V2D", + "name": "Gmail account 3" + } + } + }, + { + "parameters": { + "jsCode": "const init=$items('Inicializar ejecución')[0].json,r=$items('Construir reporte producción')[0].json,s=r.summary,id=$items('Crear Google Sheet Reporte')[0].json.spreadsheetId,reportUrl=`https://docs.google.com/spreadsheets/d/${id}/edit`,formUrl=init.formUrl;\nconst html=`
\"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": [ + 31808, + 26832 + ], + "id": "b99cc3cd-a57e-401b-aab3-b671895bdc05", + "name": "Preparar confirmación" + }, + { + "parameters": { + "operation": "completion", + "respondWith": "showText", + "responseText": "={{ $json.html }}" + }, + "type": "n8n-nodes-base.form", + "typeVersion": 2.5, + "position": [ + 32048, + 26832 + ], + "id": "716a9401-cf32-4eac-b329-8d318f0c686b", + "name": "Confirmación y regreso al formulario", + "webhookId": "d4a102a8-3d72-4c5b-83b2-0e0d04c71099", + "notesInFlow": true, + "notes": "Página final con reporte y botón que vuelve exactamente al mismo Form Trigger de Panamá." + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 2 + }, + "conditions": [ + { + "id": "fd4e3c0d-3ba7-4267-b86c-3b8771da3d64", + "leftValue": "={{ $json.format }}", + "rightValue": "PDF", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 21488, + 26912 + ], + "id": "5abe74ca-8b12-4ee2-8289-ff31fa149407", + "name": "¿Q1 es PDF?" + }, + { + "parameters": { + "operation": "pdf", + "options": {} + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + 21728, + 27056 + ], + "id": "34048913-682f-42a2-bc3f-8b02746dc92b", + "name": "Extraer texto Q1 PDF" + }, + { + "parameters": { + "jsCode": "const prep=$items('Preparar Q1')[0].json;\nconst item=$input.first();\nconst candidateStrings=Object.values(item.json||{}).filter(v=>typeof v==='string');\nconst raw=item.json?.text??item.json?.data??item.json?.content??candidateStrings.sort((a,b)=>b.length-a.length)[0]??'';\nconst text=String(raw).replace(/\\r/g,'');\nconst config=$items('Inicializar ejecución')[0]?.json?.config||{};\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction num(v){if(v===null||v===undefined||v==='')return null;let s=String(v).replace(/B\\/\\.?|\\$/gi,'').replace(/,/g,'').trim();let neg=/^\\(.*\\)$/.test(s);if(neg)s=s.slice(1,-1);if(!/^-?\\d+(?:\\.\\d+)?$/.test(s))return null;const n=Number(s);return Number.isFinite(n)?(neg?-n:n):null;}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction moneyLine(s){return /^[\\s$()\\-]*[\\d,]+(?:\\.\\d+)?\\s*$/.test(String(s||''));}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase();}\nfunction plausible(gross,ss,se){if(!Number.isFinite(gross)||!Number.isFinite(ss)||!Number.isFinite(se))return false;if(gross<0||ss<-.01||se<-.01)return false;if(gross>0&&(ss/gross>.30||se/gross>.08))return false;return true;}\nfunction median(a){const x=a.filter(Number.isFinite).sort((p,q)=>p-q);if(!x.length)return null;const m=Math.floor(x.length/2);return x.length%2?x[m]:(x[m-1]+x[m])/2;}\nfunction fixed2Tokens(s){return (String(s||'').match(/\\(?-?(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{2}\\)?/g)||[]).map(num).filter(v=>v!==null);}\nfunction sim(a,b){a=norm(a).replace(/[^A-Z]/g,'');b=norm(b).replace(/[^A-Z]/g,'');if(!a||!b)return 0;if(a===b)return 1;const prev=Array.from({length:b.length+1},(_,i)=>i);for(let i=1;i<=a.length;i++){const cur=[i];for(let j=1;j<=b.length;j++)cur[j]=Math.min(cur[j-1]+1,prev[j]+1,prev[j-1]+(a[i-1]===b[j-1]?0:1));for(let j=0;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": [ + 21984, + 27024 + ], + "id": "c0af9861-2562-41d1-a15b-00b2e42b7bf2", + "name": "Parsear Q1 PDF" + }, + { + "parameters": { + "jsCode": "const r=$input.first().json;if(!Array.isArray(r.records)||!r.records.length)throw new Error('Resultado Q1 vacío.');return [{json:r}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 23648, + 26832 + ], + "id": "321d3b2f-837e-48b3-af92-8662b625adc7", + "name": "Resultado Q1" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 2 + }, + "conditions": [ + { + "id": "5ceced14-980c-46ea-a1ee-3845c8620a90", + "leftValue": "={{ $json.format }}", + "rightValue": "PDF", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 24128, + 26912 + ], + "id": "ed7c9435-b43d-4e4c-a702-23127d54fe1f", + "name": "¿Q2 es PDF?" + }, + { + "parameters": { + "operation": "pdf", + "options": {} + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + 24368, + 27056 + ], + "id": "e7dfdc83-f288-48e3-82ef-72bc1ca65ca4", + "name": "Extraer texto Q2 PDF" + }, + { + "parameters": { + "jsCode": "const prep=$items('Preparar Q2')[0].json;\nconst item=$input.first();\nconst candidateStrings=Object.values(item.json||{}).filter(v=>typeof v==='string');\nconst raw=item.json?.text??item.json?.data??item.json?.content??candidateStrings.sort((a,b)=>b.length-a.length)[0]??'';\nconst text=String(raw).replace(/\\r/g,'');\nconst config=$items('Inicializar ejecución')[0]?.json?.config||{};\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction num(v){if(v===null||v===undefined||v==='')return null;let s=String(v).replace(/B\\/\\.?|\\$/gi,'').replace(/,/g,'').trim();let neg=/^\\(.*\\)$/.test(s);if(neg)s=s.slice(1,-1);if(!/^-?\\d+(?:\\.\\d+)?$/.test(s))return null;const n=Number(s);return Number.isFinite(n)?(neg?-n:n):null;}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction moneyLine(s){return /^[\\s$()\\-]*[\\d,]+(?:\\.\\d+)?\\s*$/.test(String(s||''));}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase();}\nfunction plausible(gross,ss,se){if(!Number.isFinite(gross)||!Number.isFinite(ss)||!Number.isFinite(se))return false;if(gross<0||ss<-.01||se<-.01)return false;if(gross>0&&(ss/gross>.30||se/gross>.08))return false;return true;}\nfunction median(a){const x=a.filter(Number.isFinite).sort((p,q)=>p-q);if(!x.length)return null;const m=Math.floor(x.length/2);return x.length%2?x[m]:(x[m-1]+x[m])/2;}\nfunction fixed2Tokens(s){return (String(s||'').match(/\\(?-?(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{2}\\)?/g)||[]).map(num).filter(v=>v!==null);}\nfunction sim(a,b){a=norm(a).replace(/[^A-Z]/g,'');b=norm(b).replace(/[^A-Z]/g,'');if(!a||!b)return 0;if(a===b)return 1;const prev=Array.from({length:b.length+1},(_,i)=>i);for(let i=1;i<=a.length;i++){const cur=[i];for(let j=1;j<=b.length;j++)cur[j]=Math.min(cur[j-1]+1,prev[j]+1,prev[j-1]+(a[i-1]===b[j-1]?0:1));for(let j=0;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": [ + 24608, + 27056 + ], + "id": "c8d62ff5-5098-4509-b79d-0b2a670cbbec", + "name": "Parsear Q2 PDF" + }, + { + "parameters": { + "jsCode": "const r=$input.first().json;if(!Array.isArray(r.records)||!r.records.length)throw new Error('Resultado Q2 vacío.');return [{json:r}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 25568, + 26832 + ], + "id": "012df9a7-6095-4322-8f93-8b4d24986e54", + "name": "Resultado Q2" + }, + { + "parameters": { + "jsCode": "const q1=$items('Resultado Q1')[0].json;\nconst q2=$items('Resultado Q2')[0].json;\nconst css=$items('Parsear CSS')[0].json;\nconst tempFolder=$items('Crear carpeta temporal PDFs')[0]?.json?.id||'';\nconst out=[];\nfor(const [tipo,id] of [\n ['Q1',q1.stagingSpreadsheetId],\n ['Q2',q2.stagingSpreadsheetId],\n ['CSS',css.stagingSpreadsheetId],\n ['PDFS_TEMP',tempFolder]\n]) if(id) out.push({json:{tipo,fileId:id}});\nreturn out.length?out:[{json:{tipo:'NINGUNO',fileId:''}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 31328, + 26832 + ], + "id": "e83d7ddc-c884-4034-acc3-f6331f491608", + "name": "Preparar limpieza temporales" + }, + { + "parameters": { + "method": "DELETE", + "url": "=https://www.googleapis.com/drive/v3/files/{{ $json.fileId }}?supportsAllDrives=true", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleDriveOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 31568, + 26832 + ], + "id": "d7d69f76-df90-4f5c-bad4-536410e6fc9d", + "name": "Eliminar staging temporal", + "alwaysOutputData": true, + "notesInFlow": true, + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + }, + "continueOnFail": true, + "notes": "Limpieza best-effort: elimina staging Q1/Q2/CSS y la carpeta temporal de PDFs. Si falla, no bloquea la pantalla final." + }, + { + "parameters": { + "method": "POST", + "url": "https://www.googleapis.com/drive/v3/files?fields=id,name,mimeType&supportsAllDrives=true", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleDriveOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ name: `TEMP PA CSS PDFs - ${$('Inicializar ejecución').item.json.runId}`, mimeType: 'application/vnd.google-apps.folder' }) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 21248, + 27216 + ], + "id": "b16c3231-7cda-4395-b4a7-6b17cc5be53d", + "name": "Crear carpeta temporal PDFs", + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 2000, + "notesInFlow": true, + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + }, + "notes": "Carpeta temporal aislada por ejecución para acumular PDFs cargados en varias tandas sin perder selecciones anteriores." + }, + { + "parameters": { + "formFields": { + "values": [ + { + "fieldLabel": "¿Hay extraordinarias este mes?", + "fieldType": "radio", + "fieldOptions": { + "values": [ + { + "option": "Sí" + }, + { + "option": "No" + } + ] + }, + "requiredField": true + } + ] + }, + "options": { + "formTitle": "Paso 2 de 4 · Extraordinarias", + "formDescription": "Indica si este mes existen planillas extraordinarias. Este es el único grupo opcional.", + "buttonLabel": "Continuar" + } + }, + "type": "n8n-nodes-base.form", + "typeVersion": 2.5, + "position": [ + 21488, + 27216 + ], + "id": "b784edea-6563-4f63-b8cb-900f2850753e", + "name": "Definir Extraordinarias", + "webhookId": "2d4fba32-6a24-46e8-a9c7-a38ca80b106a" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 2 + }, + "conditions": [ + { + "id": "e7a0d1ad-362a-4267-b2fd-ba254621eeaa", + "leftValue": "={{ $json[\"¿Hay extraordinarias este mes?\"] }}", + "rightValue": "Sí", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 21728, + 27216 + ], + "id": "534cbd73-479a-4797-848c-a012e29bbc58", + "name": "¿Hay extraordinarias?" + }, + { + "parameters": { + "formFields": { + "values": [ + { + "fieldLabel": "Archivos extraordinarios", + "fieldType": "file", + "acceptFileTypes": ".pdf,application/pdf", + "requiredField": true + }, + { + "fieldLabel": "¿Agregar más extraordinarias?", + "fieldType": "radio", + "fieldOptions": { + "values": [ + { + "option": "Sí" + }, + { + "option": "No" + } + ] + }, + "requiredField": true + } + ] + }, + "options": { + "formTitle": "Extraordinarias · cargar lote", + "formDescription": "Puedes seleccionar uno o varios PDFs a la vez. Si tienes más archivos en otra carpeta, selecciona “Sí” en “Agregar más” y volverás a esta misma pantalla sin perder los archivos ya guardados.", + "buttonLabel": "Guardar lote" + } + }, + "type": "n8n-nodes-base.form", + "typeVersion": 2.5, + "position": [ + 21984, + 27024 + ], + "id": "c0214ec8-c0ef-4e9d-bf81-b1cc55eaffef", + "name": "Cargar Extraordinarias", + "webhookId": "237dd241-cbba-4455-beec-b4b63e45827d" + }, + { + "parameters": { + "jsCode": "const item=$input.first();\nconst bin=item.binary||{};\nconst files=Object.entries(bin);\nfunction clean(v){return String(v??'').trim();}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase();}\nfunction yes(v){return ['si','sí','yes','true','1'].includes(norm(v));}\nfunction safeName(v){return clean(v).replace(/[\\\\/\\x00-\\x1F]/g,'_').replace(/__+/g,'_').slice(-150)||'archivo.pdf';}\nif(!files.length) throw new Error('Debes adjuntar al menos un PDF en este lote de Extraordinarias.');\nconst more=yes(item.json?.['¿Agregar más extraordinarias?']);\nconst init=$items('Inicializar ejecución')[0].json;\nconst max=Number(init.config?.maxSupplementalFilesSafety||500);\nif(files.length>max) throw new Error(`Se recibieron ${files.length} archivos en un solo lote; supera el límite técnico de seguridad (${max}).`);\nconst now=Date.now();\nconst out=[];\nfor(let i=0;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": [ + 28688, + 27056 + ], + "id": "dc199c71-3269-4587-92fa-404ad7815521", + "name": "Preparar descarga PDFs", + "notesInFlow": true, + "notes": "Reconstruye las tres categorías desde la carpeta temporal y valida mínimos antes de descargar." + }, + { + "parameters": { + "operation": "download", + "fileId": { + "__rl": true, + "value": "={{ $json.id }}", + "mode": "id" + }, + "options": { + "binaryPropertyName": "data", + "fileName": "={{ $json.storageName }}" + } + }, + "type": "n8n-nodes-base.googleDrive", + "typeVersion": 3, + "position": [ + 28928, + 27056 + ], + "id": "f0ce2d8b-da54-4aa9-9c1a-560b90f5e2c1", + "name": "Descargar PDFs temporales", + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 2000, + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + } + }, + { + "parameters": { + "jsCode": "const items=$input.all();\nfunction parseStorage(n){\n const name=String(n||'');\n let category='';\n if(name.startsWith('EXTRA__'))category='extraordinaria';\n else if(name.startsWith('VAC__'))category='vacaciones';\n else if(name.startsWith('LIQ__'))category='liquidaciones';\n const ix=name.indexOf('__ORIG__');\n return {category,fileName:ix>=0?name.slice(ix+8):name,storageName:name};\n}\nconst out=[];\nfor(const item of items){\n const data=item.binary?.data;\n if(!data) throw new Error('Google Drive no devolvió el contenido binario de uno de los PDFs temporales.');\n const p=parseStorage(data.fileName||item.json?.storageName||item.json?.name||'');\n if(!p.category) throw new Error(`No pude determinar la categoría del archivo temporal ${p.storageName||'(sin nombre)'}.`);\n data.fileName=p.fileName;\n out.push({json:{category:p.category,fileName:p.fileName,storageName:p.storageName,fileId:item.json?.id||item.json?.fileId||''},binary:{data}});\n}\nreturn out;" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 29168, + 27056 + ], + "id": "b169ad1c-478f-4fc9-b80d-2d4d020c86b8", + "name": "Normalizar PDFs descargados" + } + ], + "connections": { + "On form submission": { + "main": [ + [ + { + "node": "Inicializar ejecución", + "type": "main", + "index": 0 + } + ] + ] + }, + "Inicializar ejecución": { + "main": [ + [ + { + "node": "Crear carpeta temporal PDFs", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar Q1": { + "main": [ + [ + { + "node": "¿Q1 es PDF?", + "type": "main", + "index": 0 + } + ] + ] + }, + "¿Q1 es PDF?": { + "main": [ + [ + { + "node": "Extraer texto Q1 PDF", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Crear staging Q1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extraer texto Q1 PDF": { + "main": [ + [ + { + "node": "Parsear Q1 PDF", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parsear Q1 PDF": { + "main": [ + [ + { + "node": "Resultado Q1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Crear staging Q1": { + "main": [ + [ + { + "node": "Reunir staging Q1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Reunir staging Q1": { + "main": [ + [ + { + "node": "Parsear Q1 Excel", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parsear Q1 Excel": { + "main": [ + [ + { + "node": "Resultado Q1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Resultado Q1": { + "main": [ + [ + { + "node": "Preparar Q2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar Q2": { + "main": [ + [ + { + "node": "¿Q2 es PDF?", + "type": "main", + "index": 0 + } + ] + ] + }, + "¿Q2 es PDF?": { + "main": [ + [ + { + "node": "Extraer texto Q2 PDF", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Crear staging Q2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extraer texto Q2 PDF": { + "main": [ + [ + { + "node": "Parsear Q2 PDF", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parsear Q2 PDF": { + "main": [ + [ + { + "node": "Resultado Q2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Crear staging Q2": { + "main": [ + [ + { + "node": "Reunir staging Q2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Reunir staging Q2": { + "main": [ + [ + { + "node": "Parsear Q2 Excel", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parsear Q2 Excel": { + "main": [ + [ + { + "node": "Resultado Q2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Resultado Q2": { + "main": [ + [ + { + "node": "Preparar CSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar CSS": { + "main": [ + [ + { + "node": "Crear staging CSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Crear staging CSS": { + "main": [ + [ + { + "node": "Reunir staging CSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Reunir staging CSS": { + "main": [ + [ + { + "node": "Parsear CSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parsear CSS": { + "main": [ + [ + { + "node": "Listar PDFs temporales", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extraer texto PDFs": { + "main": [ + [ + { + "node": "Parsear PDFs", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parsear PDFs": { + "main": [ + [ + { + "node": "Construir reporte producción", + "type": "main", + "index": 0 + } + ] + ] + }, + "Construir reporte producción": { + "main": [ + [ + { + "node": "Crear Google Sheet Reporte", + "type": "main", + "index": 0 + } + ] + ] + }, + "Crear Google Sheet Reporte": { + "main": [ + [ + { + "node": "Escribir reporte", + "type": "main", + "index": 0 + } + ] + ] + }, + "Escribir reporte": { + "main": [ + [ + { + "node": "Preparar formato reporte", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar formato reporte": { + "main": [ + [ + { + "node": "Formatear reporte", + "type": "main", + "index": 0 + } + ] + ] + }, + "Formatear reporte": { + "main": [ + [ + { + "node": "Preparar permisos", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar permisos": { + "main": [ + [ + { + "node": "Compartir reporte como editor", + "type": "main", + "index": 0 + } + ] + ] + }, + "Compartir reporte como editor": { + "main": [ + [ + { + "node": "Preparar correo HTML GLM", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar correo HTML GLM": { + "main": [ + [ + { + "node": "Enviar correo resumen", + "type": "main", + "index": 0 + } + ] + ] + }, + "Enviar correo resumen": { + "main": [ + [ + { + "node": "Preparar limpieza temporales", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar limpieza temporales": { + "main": [ + [ + { + "node": "Eliminar staging temporal", + "type": "main", + "index": 0 + } + ] + ] + }, + "Eliminar staging temporal": { + "main": [ + [ + { + "node": "Preparar confirmación", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar confirmación": { + "main": [ + [ + { + "node": "Confirmación y regreso al formulario", + "type": "main", + "index": 0 + } + ] + ] + }, + "Crear carpeta temporal PDFs": { + "main": [ + [ + { + "node": "Definir Extraordinarias", + "type": "main", + "index": 0 + } + ] + ] + }, + "Definir Extraordinarias": { + "main": [ + [ + { + "node": "¿Hay extraordinarias?", + "type": "main", + "index": 0 + } + ] + ] + }, + "¿Hay extraordinarias?": { + "main": [ + [ + { + "node": "Cargar Extraordinarias", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Cargar Vacaciones", + "type": "main", + "index": 0 + } + ] + ] + }, + "Cargar Extraordinarias": { + "main": [ + [ + { + "node": "Preparar lote Extraordinarias", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar lote Extraordinarias": { + "main": [ + [ + { + "node": "Guardar lote Extraordinarias", + "type": "main", + "index": 0 + } + ] + ] + }, + "Guardar lote Extraordinarias": { + "main": [ + [ + { + "node": "Cerrar lote Extraordinarias", + "type": "main", + "index": 0 + } + ] + ] + }, + "Cerrar lote Extraordinarias": { + "main": [ + [ + { + "node": "¿Agregar más Extraordinarias?", + "type": "main", + "index": 0 + } + ] + ] + }, + "¿Agregar más Extraordinarias?": { + "main": [ + [ + { + "node": "Cargar Extraordinarias", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Cargar Vacaciones", + "type": "main", + "index": 0 + } + ] + ] + }, + "Cargar Vacaciones": { + "main": [ + [ + { + "node": "Preparar lote Vacaciones", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar lote Vacaciones": { + "main": [ + [ + { + "node": "Guardar lote Vacaciones", + "type": "main", + "index": 0 + } + ] + ] + }, + "Guardar lote Vacaciones": { + "main": [ + [ + { + "node": "Cerrar lote Vacaciones", + "type": "main", + "index": 0 + } + ] + ] + }, + "Cerrar lote Vacaciones": { + "main": [ + [ + { + "node": "¿Agregar más Vacaciones?", + "type": "main", + "index": 0 + } + ] + ] + }, + "¿Agregar más Vacaciones?": { + "main": [ + [ + { + "node": "Cargar Vacaciones", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Cargar Liquidaciones", + "type": "main", + "index": 0 + } + ] + ] + }, + "Cargar Liquidaciones": { + "main": [ + [ + { + "node": "Preparar lote Liquidaciones", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar lote Liquidaciones": { + "main": [ + [ + { + "node": "Guardar lote Liquidaciones", + "type": "main", + "index": 0 + } + ] + ] + }, + "Guardar lote Liquidaciones": { + "main": [ + [ + { + "node": "Cerrar lote Liquidaciones", + "type": "main", + "index": 0 + } + ] + ] + }, + "Cerrar lote Liquidaciones": { + "main": [ + [ + { + "node": "¿Agregar más Liquidaciones?", + "type": "main", + "index": 0 + } + ] + ] + }, + "¿Agregar más Liquidaciones?": { + "main": [ + [ + { + "node": "Cargar Liquidaciones", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Preparar Q1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Listar PDFs temporales": { + "main": [ + [ + { + "node": "Preparar descarga PDFs", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar descarga PDFs": { + "main": [ + [ + { + "node": "Descargar PDFs temporales", + "type": "main", + "index": 0 + } + ] + ] + }, + "Descargar PDFs temporales": { + "main": [ + [ + { + "node": "Normalizar PDFs descargados", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalizar PDFs descargados": { + "main": [ + [ + { + "node": "Extraer texto PDFs", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate" + }, + "staticData": null, + "meta": null, + "versionId": "6b4a28c8-b9fc-42c1-a440-6bd1aae0ab20", + "activeVersionId": "6b4a28c8-b9fc-42c1-a440-6bd1aae0ab20", + "versionCounter": 122, + "triggerCount": 1, + "shared": [ + { + "updatedAt": "2026-08-20T19:14:26.151Z", + "createdAt": "2026-08-20T19:14:26.151Z", + "role": "workflow:owner", + "workflowId": "qxcXg0Z1YASkWgxL", + "projectId": "PJpTANzTXIFibWsW", + "project": { + "updatedAt": "2026-04-22T14:25:09.686Z", + "createdAt": "2026-04-22T14:22:54.790Z", + "id": "PJpTANzTXIFibWsW", + "name": "Isaac Aracena ", + "type": "personal", + "icon": null, + "description": null, + "creatorId": "0a88c0b1-928e-4412-896e-c5d1c99b2029" + } + } + ], + "tags": [], + "activeVersion": { + "updatedAt": "2026-08-21T21:02:10.000Z", + "createdAt": "2026-08-21T21:02:01.330Z", + "versionId": "6b4a28c8-b9fc-42c1-a440-6bd1aae0ab20", + "workflowId": "qxcXg0Z1YASkWgxL", + "nodes": [ + { + "parameters": { + "formTitle": "Cruce Seguridad Social Panamá", + "formDescription": "Paso 1 de 4. Carga las dos nóminas quincenales (preferiblemente Excel; PDF también se valida con controles estrictos) y la Planilla oficial de la Caja de Seguro Social (CSS) en Excel. En los pasos siguientes podrás cargar Extraordinarias, Vacaciones y Liquidaciones en una o varias tandas, aunque estén en carpetas distintas. El período se detecta automáticamente desde la Planilla CSS.", + "formFields": { + "values": [ + { + "fieldLabel": "Nómina - 1era Quincena", + "fieldType": "file", + "multipleFiles": false, + "acceptFileTypes": ".xlsx,.xls,.pdf", + "requiredField": true + }, + { + "fieldLabel": "Nómina - 2da Quincena", + "fieldType": "file", + "multipleFiles": false, + "acceptFileTypes": ".xlsx,.xls,.pdf", + "requiredField": true + }, + { + "fieldLabel": "Planilla CSS", + "fieldType": "file", + "multipleFiles": false, + "acceptFileTypes": ".xlsx,.xls", + "requiredField": true + } + ] + }, + "responseMode": "lastNode", + "options": { + "ignoreBots": true + } + }, + "type": "n8n-nodes-base.formTrigger", + "typeVersion": 2.3, + "position": [ + 20768, + 26736 + ], + "id": "ee56d788-e286-4575-9e02-d8e31bf76837", + "name": "On form submission", + "webhookId": "4ba38e33-3ab3-4a2d-a66e-59457eefae48" + }, + { + "parameters": { + "jsCode": "const item = $input.first();\nconst now = new Date();\nconst pad = n => String(n).padStart(2,'0');\nconst runId = `PA_CSS_${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}_${Math.random().toString(36).slice(2,8)}`;\nconst recipients = [\n 'iaracena@gomezleemarketing.com'\n];\nconst logoUrl = 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png';\nconst formUrl = 'https://agenteit.digitalcompass.agency/form/86e543a4-206d-474b-ab14-4fa697897d8c';\nconst config = {\n amountTolerance: 0.10,\n cssIntegrityTolerance: 0.10,\n defaultRates: { ssRegular: 0.0975, se: 0.0125, ssDtm: 0.0725 },\n minRateSamples: 20,\n hardFailUnreadablePdf: true,\n maxSupplementalFilesSafety: 500\n};\nreturn [{json:{runId,startedAt:now.toISOString(),recipients,logoUrl,formUrl,config},binary:item.binary||{}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 21008, + 26736 + ], + "id": "ad4f589a-65cf-4e2f-af7c-dcd3c6337929", + "name": "Inicializar ejecución" + }, + { + "parameters": { + "jsCode": "\nconst base=$items('Inicializar ejecución')[0];\nconst bin=base.binary||{};\nfunction norm(s){return String(s||'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase().replace(/\\s+/g,' ').trim();}\nfunction formatOf(file,name){\n const s=norm(`${name} ${file?.mimeType||''}`);\n if(/\\.pdf(?:$|\\s)|application\\/pdf/.test(s)) return 'PDF';\n if(/\\.xlsx?(?:$|\\s)|spreadsheet|excel|application\\/vnd\\.ms-excel/.test(s)) return 'EXCEL';\n return '';\n}\nconst entries=Object.entries(bin).map(([key,file])=>{\n const name=String(file.fileName||key), k=norm(key), n=norm(name), all=`${k} ${n}`;\n const format=formatOf(file,name); let score=format?100:-1000;\n \n if(/nomina.*1era|1era.*quincena|primera.*quincena|quincena.*1/.test(k)) score+=600;\n if(/nomina.*2da|2da.*quincena|segunda.*quincena|quincena.*2/.test(k)) score-=700;\n if(/1era|primera|quincena\\s*1|q1/.test(n)) score+=180;\n if(/2da|segunda|quincena\\s*2|q2/.test(n)) score-=220;\n if(/planilla\\s*css|detalleplanilla|extraordin|vacacion|liquidacion/.test(all)) score-=300;\n\n return {key,file,name,format,score,k,n,all};\n}).sort((a,b)=>b.score-a.score);\nconst chosen=entries[0];\nif(!chosen||chosen.score<250||!chosen.format) throw new Error('No pude identificar de forma segura la Nómina de 1era Quincena. Adjunta en ese campo un Excel (.xls/.xlsx) o PDF de la planilla quincenal.');\nreturn [{json:{...base.json,fileName:chosen.name,originalBinaryKey:chosen.key,format:chosen.format,inputMimeType:chosen.file?.mimeType||''},binary:{data:chosen.file}}];\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 21248, + 26832 + ], + "id": "58ddefa4-0d02-449f-9beb-af8ac36fef1e", + "name": "Preparar Q1" + }, + { + "parameters": { + "operation": "xls", + "options": { + "headerRow": false, + "includeEmptyCells": true, + "rawData": false, + "readAsString": false + } + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + 21728, + 26688 + ], + "id": "420c890d-ad31-4500-b0ff-c0999df7795d", + "name": "Crear staging Q1" + }, + { + "parameters": { + "jsCode": "const rows=$input.all().map(x=>x.json?.row).filter(r=>Array.isArray(r));\nif(!rows.length) throw new Error('No se pudieron leer filas de Nómina Q1 directamente desde el Excel.');\nreturn [{json:{valueRanges:[{range:'Sheet1!A1:AZ50000',values:rows}],directExtraction:true}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 21968, + 26688 + ], + "id": "9691c16a-26ae-4ef3-9231-507ab11c5231", + "name": "Reunir staging Q1" + }, + { + "parameters": { + "jsCode": "\nconst payload=$json.body&&typeof $json.body==='object'?$json.body:$json;\nconst valueRanges=payload.valueRanges||[];\nconst prep=$items('Preparar Q1')[0].json;\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction asc(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase();}\nfunction key(v){return asc(v).replace(/[^A-Z0-9]/g,'');}\nfunction num(v){if(v===null||v===undefined||v==='')return 0;if(typeof v==='number')return v;let s=String(v).replace(/B\\/.?|\\$|,/g,'').replace(/\\s/g,'');if(/^\\(.*\\)$/.test(s))s='-'+s.slice(1,-1);const n=Number(s);return Number.isFinite(n)?n:0;}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction detectPeriod(values){\n for(const row of values.slice(0,120)) for(const cell of row||[]){\n const m=clean(cell).match(/Periodo\\s+del\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})\\s+hasta\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})/i);\n if(m)return {start:m[1],end:m[2]};\n }\n return null;\n}\nfunction detectPrintedCount(values){\n let gi=-1; for(let i=0;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": [ + 23408, + 26688 + ], + "id": "17fa00ac-b777-44b6-a84a-1c3d9649d998", + "name": "Parsear Q1 Excel" + }, + { + "parameters": { + "jsCode": "\nconst base=$items('Inicializar ejecución')[0];\nconst bin=base.binary||{};\nfunction norm(s){return String(s||'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase().replace(/\\s+/g,' ').trim();}\nfunction formatOf(file,name){\n const s=norm(`${name} ${file?.mimeType||''}`);\n if(/\\.pdf(?:$|\\s)|application\\/pdf/.test(s)) return 'PDF';\n if(/\\.xlsx?(?:$|\\s)|spreadsheet|excel|application\\/vnd\\.ms-excel/.test(s)) return 'EXCEL';\n return '';\n}\nconst entries=Object.entries(bin).map(([key,file])=>{\n const name=String(file.fileName||key), k=norm(key), n=norm(name), all=`${k} ${n}`;\n const format=formatOf(file,name); let score=format?100:-1000;\n \n if(/nomina.*2da|2da.*quincena|segunda.*quincena|quincena.*2/.test(k)) score+=600;\n if(/nomina.*1era|1era.*quincena|primera.*quincena|quincena.*1/.test(k)) score-=700;\n if(/2da|segunda|quincena\\s*2|q2/.test(n)) score+=180;\n if(/1era|primera|quincena\\s*1|q1/.test(n)) score-=220;\n if(/planilla\\s*css|detalleplanilla|extraordin|vacacion|liquidacion/.test(all)) score-=300;\n\n return {key,file,name,format,score,k,n,all};\n}).sort((a,b)=>b.score-a.score);\nconst chosen=entries[0];\nif(!chosen||chosen.score<250||!chosen.format) throw new Error('No pude identificar de forma segura la Nómina de 2da Quincena. Adjunta en ese campo un Excel (.xls/.xlsx) o PDF de la planilla quincenal.');\nreturn [{json:{...base.json,fileName:chosen.name,originalBinaryKey:chosen.key,format:chosen.format,inputMimeType:chosen.file?.mimeType||''},binary:{data:chosen.file}}];\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 23888, + 26832 + ], + "id": "647bdb88-d7ed-46df-a1dc-7b80d00817f1", + "name": "Preparar Q2" + }, + { + "parameters": { + "operation": "xls", + "options": { + "headerRow": false, + "includeEmptyCells": true, + "rawData": false, + "readAsString": false + } + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + 24368, + 26688 + ], + "id": "e4610d47-633c-4527-b6e8-8db8f69dd84a", + "name": "Crear staging Q2" + }, + { + "parameters": { + "jsCode": "const rows=$input.all().map(x=>x.json?.row).filter(r=>Array.isArray(r));\nif(!rows.length) throw new Error('No se pudieron leer filas de Nómina Q2 directamente desde el Excel.');\nreturn [{json:{valueRanges:[{range:'Sheet1!A1:AZ50000',values:rows}],directExtraction:true}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 24608, + 26688 + ], + "id": "6d6ca2c5-c6bd-4d4a-838a-6c9ee056ae7e", + "name": "Reunir staging Q2" + }, + { + "parameters": { + "jsCode": "\nconst payload=$json.body&&typeof $json.body==='object'?$json.body:$json;\nconst valueRanges=payload.valueRanges||[];\nconst prep=$items('Preparar Q2')[0].json;\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction asc(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase();}\nfunction key(v){return asc(v).replace(/[^A-Z0-9]/g,'');}\nfunction num(v){if(v===null||v===undefined||v==='')return 0;if(typeof v==='number')return v;let s=String(v).replace(/B\\/.?|\\$|,/g,'').replace(/\\s/g,'');if(/^\\(.*\\)$/.test(s))s='-'+s.slice(1,-1);const n=Number(s);return Number.isFinite(n)?n:0;}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction detectPeriod(values){\n for(const row of values.slice(0,120)) for(const cell of row||[]){\n const m=clean(cell).match(/Periodo\\s+del\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})\\s+hasta\\s+(\\d{1,2}\\/\\d{1,2}\\/20\\d{2})/i);\n if(m)return {start:m[1],end:m[2]};\n }\n return null;\n}\nfunction detectPrintedCount(values){\n let gi=-1; for(let i=0;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": [ + 26048, + 26688 + ], + "id": "b5fa883c-f78d-4e32-87d0-53ff44b914a2", + "name": "Parsear Q2 Excel" + }, + { + "parameters": { + "jsCode": "\nconst base=$items('Inicializar ejecución')[0];\nconst bin=base.binary||{};\nfunction norm(s){return String(s||'').normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase().replace(/\\s+/g,' ').trim();}\nfunction formatOf(file,name){\n const s=norm(`${name} ${file?.mimeType||''}`);\n if(/\\.pdf(?:$|\\s)|application\\/pdf/.test(s)) return 'PDF';\n if(/\\.xlsx?(?:$|\\s)|spreadsheet|excel|application\\/vnd\\.ms-excel/.test(s)) return 'EXCEL';\n return '';\n}\nconst entries=Object.entries(bin).map(([key,file])=>{\n const name=String(file.fileName||key), k=norm(key), n=norm(name), all=`${k} ${n}`;\n const format=formatOf(file,name); let score=format?100:-1000;\n \n if(/planilla\\s*css|detalleplanilla|caja.*seguro|css/.test(k)) score+=650;\n if(/planilla\\s*css|detalleplanilla|caja.*seguro|css/.test(n)) score+=240;\n if(format!=='EXCEL') score-=900;\n if(/nomina|quincena|extraordin|vacacion|liquidacion/.test(all)) score-=300;\n\n return {key,file,name,format,score,k,n,all};\n}).sort((a,b)=>b.score-a.score);\nconst chosen=entries[0];\nif(!chosen||chosen.score<250||!chosen.format) throw new Error('No pude identificar de forma segura la Planilla CSS. Adjunta en ese campo el archivo oficial DetallePlanilla en Excel (.xls/.xlsx).');\nreturn [{json:{...base.json,fileName:chosen.name,originalBinaryKey:chosen.key,format:chosen.format,inputMimeType:chosen.file?.mimeType||''},binary:{data:chosen.file}}];\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 26288, + 26832 + ], + "id": "fe97274d-5538-4382-be55-100f474ba51f", + "name": "Preparar CSS" + }, + { + "parameters": { + "operation": "xls", + "options": { + "headerRow": false, + "includeEmptyCells": true, + "rawData": false, + "readAsString": false + } + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + 26528, + 26832 + ], + "id": "93d515a6-580d-4c36-b4bb-875309694d42", + "name": "Crear staging CSS" + }, + { + "parameters": { + "jsCode": "const rows=$input.all().map(x=>x.json?.row).filter(r=>Array.isArray(r));\nif(!rows.length) throw new Error('No se pudieron leer filas de Planilla CSS directamente desde el Excel.');\nreturn [{json:{valueRanges:[{range:'Sheet1!A1:AZ50000',values:rows}],directExtraction:true}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 26768, + 26832 + ], + "id": "ad126f54-1ed2-433b-8695-0cd83f9e26ec", + "name": "Reunir staging CSS" + }, + { + "parameters": { + "jsCode": "const payload=$json.body&&typeof $json.body==='object'?$json.body:$json;\nconst valueRanges=payload.valueRanges||[],prep=$items('Preparar CSS')[0].json,config=$items('Inicializar ejecución')[0].json.config||{};\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction asc(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase();}\nfunction num(v){if(v===null||v===undefined||v==='')return 0;if(typeof v==='number')return v;let s=String(v).replace(/B\\/.?|\\$|,/g,'').replace(/\\s/g,'');if(/^\\(.*\\)$/.test(s))s='-'+s.slice(1,-1);const n=Number(s);return Number.isFinite(n)?n:0;}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction monthPeriod(text){const months={ENERO:1,FEBRERO:2,MARZO:3,ABRIL:4,MAYO:5,JUNIO:6,JULIO:7,AGOSTO:8,SEPTIEMBRE:9,OCTUBRE:10,NOVIEMBRE:11,DICIEMBRE:12};const m=asc(text).match(/(ENERO|FEBRERO|MARZO|ABRIL|MAYO|JUNIO|JULIO|AGOSTO|SEPTIEMBRE|OCTUBRE|NOVIEMBRE|DICIEMBRE)\\s+(20\\d{2})/);return m?{month:months[m[1]],year:Number(m[2]),label:`${m[1][0]+m[1].slice(1).toLowerCase()} ${m[2]}`}:null;}\nfunction isEmployeeHeader(row){const h=(row||[]).map(asc),j=h.join('|');return j.includes('SEGURO SOCIAL')&&j.includes('APELLIDO')&&j.includes('NOMBRE')&&(/CEDULA|PASAPORTE|REGISTRO CIVIL|IDENTIFICACION|DOCUMENTO/.test(j));}\nif(!valueRanges.length)throw new Error('La Planilla CSS no contiene hojas legibles.');\nconst ranked=valueRanges.map(vr=>({vr,count:(vr.values||[]).reduce((s,r)=>s+(isEmployeeHeader(r)?1:0),0),rows:(vr.values||[]).length})).sort((a,b)=>b.count-a.count||b.rows-a.rows);\nconst chosen=ranked[0],values=chosen?.vr?.values||[];if(!values.length||chosen.count<1)throw new Error('No pude localizar la estructura de empleados dentro de la Planilla CSS oficial.');\nlet period=null;for(let r=0;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": [ + 28208, + 26832 + ], + "id": "b7c40654-7c89-4c6e-82bc-8bac76dd1221", + "name": "Parsear CSS" + }, + { + "parameters": { + "operation": "pdf", + "options": {} + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + 28688, + 26832 + ], + "id": "7b3089a6-fbbf-403f-b4fb-c02321004c8c", + "name": "Extraer texto PDFs" + }, + { + "parameters": { + "jsCode": "const extracted=$input.all();\nconst metaItems=$items('Normalizar PDFs descargados');\nconst css=($items('Parsear CSS')[0]||{}).json||{};\nconst init=(($items('Inicializar ejecución')[0]||{}).json)||{};\nconst cfg=init.config||{};\nconst rates=cfg.defaultRates||{ssRegular:0.0975,se:0.0125,ssDtm:0.0725};\nconst targetMonth=Number(css.period?.month||0),targetYear=Number(css.period?.year||0);\n\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'').toUpperCase().replace(/[^A-Z0-9 ]/g,' ').replace(/\\s+/g,' ').trim();}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction parseDMY(s){const m=String(s||'').match(/(\\d{1,2})\\/(\\d{1,2})\\/(20\\d{2})/);if(!m)return null;return new Date(Number(m[3]),Number(m[2])-1,Number(m[1]));}\nfunction dateKey(d){return d?`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`:'';}\nfunction fmtDate(d){return d?`${String(d.getDate()).padStart(2,'0')}/${String(d.getMonth()+1).padStart(2,'0')}/${d.getFullYear()}`:'';}\nfunction inTarget(d){return !!(d&&d.getMonth()+1===targetMonth&&d.getFullYear()===targetYear);}\nfunction overlapTarget(a,b){if(!a||!b)return null;const x=[a,b].sort((p,q)=>p-q),first=new Date(targetYear,targetMonth-1,1),last=new Date(targetYear,targetMonth,0,23,59,59);return x[0]<=last&&x[1]>=first;}\nfunction regexAll(text,re){const flags=(re.flags||'').includes('g')?re.flags:(re.flags||'')+'g',rg=new RegExp(re.source,flags),out=[];let m;while((m=rg.exec(String(text||'')))!==null){out.push(m);if(m[0]==='')rg.lastIndex++;}return out;}\nfunction markers(text,re){return regexAll(text,re).map(m=>({index:m.index,end:m.index+m[0].length,match:m}));}\n\n// PDF.js a veces separa el decimal en dos líneas (66.\\n54 o 66 \\n.54).\n// Solo se reparan fragmentos que contienen un punto decimal explícito para no unir cifras distintas.\nfunction healNumericFragments(src){\n let s=String(src||'').replace(/\\r/g,'');\n for(let pass=0;pass<5;pass++){\n s=s.replace(/(\\d[\\d,]*)\\.\\s+(\\d{1,2})(?=(?:\\s|$|[^\\d]))/g,'$1.$2');\n s=s.replace(/(\\d[\\d,]*)\\s+\\.\\s*(\\d{1,2})(?=(?:\\s|$|[^\\d]))/g,'$1.$2');\n s=s.replace(/(\\d{1,3}),\\s+(\\d{3})(?=(?:\\.\\d{1,2})?(?:\\s|$|[^\\d]))/g,'$1,$2');\n }\n return s;\n}\nfunction moneyNum(v){\n if(v===null||v===undefined)return null;\n let s=String(v).replace(/B\\/\\.?|\\$/gi,'').replace(/,/g,'').trim();\n if(/^\\(.*\\)$/.test(s))s='-'+s.slice(1,-1);\n if(!/^-?\\d+(?:\\.\\d+)?$/.test(s))return null;\n const n=Number(s);return Number.isFinite(n)?round(n):null;\n}\nfunction pureMoney(line){\n const s=String(line||'').trim();\n if(!/^[\\s$()\\-]*[\\d,]+(?:\\.\\d+)?\\s*$/.test(s))return null;\n return moneyNum(s);\n}\nfunction inlineNumbers(s){\n const src=String(s||''),out=[];\n const re=/[-(]?\\$?\\d[\\d,]*(?:\\.\\d+)?\\)?/g;let m;\n while((m=re.exec(src))!==null){const v=moneyNum(m[0]);if(v!==null)out.push({value:v,index:m.index,end:re.lastIndex,raw:m[0]});if(m[0]==='')re.lastIndex++;}\n return out;\n}\nfunction splitLines(block){return String(block||'').replace(/\\r/g,'').split('\\n').map(x=>x.trim());}\n\n// Lee el importe que pertenece a una etiqueta concreta. Prioridad:\n// 1) cifra en la MISMA línea de la etiqueta; 2) línea monetaria inmediatamente anterior;\n// 3) línea monetaria inmediatamente posterior. Nunca busca importes arbitrarios a distancia.\nfunction valueAtLabel(block,labelRe,opts={}){\n const {prefer='before',steps=3}=opts,ls=splitLines(block),hits=[];\n for(let i=0;i{const a=inlineNumbers(str);if(!a.length)return null;return fromEnd?a[a.length-1].value:a[0].value;};\n if(prefer==='after'){\n let v=first(after,false);if(v!==null)return {found:true,value:v,source:'INLINE_AFTER'};\n v=first(before,true);if(v!==null)return {found:true,value:v,source:'INLINE_BEFORE'};\n }else{\n let v=first(before,true);if(v!==null)return {found:true,value:v,source:'INLINE_BEFORE'};\n v=first(after,false);if(v!==null)return {found:true,value:v,source:'INLINE_AFTER'};\n }\n }\n const before=[],after=[];\n for(let d=1;d<=steps;d++){if(i-d>=0)before.push(i-d);if(i+d0?ms[j-1].end:0,ms[j].index);}\nfunction segPost(text,ms,j){return text.slice(ms[j].end,j+1({txt:x[1],d:parseDMY(x[1])})).filter(x=>x.d);\n if(dates.length<2)return null;\n const pair=dates.slice(-2).map(x=>x.d).sort((a,b)=>a-b),startDate=pair[0],endDate=pair[1];\n return {startDate,endDate,start:dateKey(startDate),end:dateKey(endDate),display:`${fmtDate(startDate)} - ${fmtDate(endDate)}`};\n}\nfunction close(a,b,tol){return Number.isFinite(a)&&Number.isFinite(b)&&Math.abs(a-b)<=tol;}\nfunction recordFingerprint(r){return [r.category,clean(r.code),norm(r.name),clean(r.date),clean(r.transaction),round(r.ss),round(r.se),round(r.gross),round(r.regularBase),round(r.dtmBase)].join('|');}\nfunction samePerson(a,b){const ac=clean(a.code).replace(/^0+(?=\\d)/,''),bc=clean(b.code).replace(/^0+(?=\\d)/,'');if(ac&&bc&&ac===bc)return true;return !!(norm(a.name)&&norm(a.name)===norm(b.name));}\n\nconst records=[],validations=[],supportDocs=[],files=[];\nfunction validation(status,category,fileName,detail){validations.push({status,severity:(status==='OK'||status==='IGNORADO'||status==='EXCLUIDO')?'INFO':'WARNING',category,fileName,detail});}\nfunction addRecord(r){records.push({...r,ss:round(r.ss||0),se:round(r.se||0),gross:round(r.gross||0),regularBase:round(r.regularBase||0),dtmBase:round(r.dtmBase||0),parserMode:'LINE_ANCHOR_V7'});}\n\nfor(let i=0;i({code:clean(x.match[1]).replace(/^0+(?=\\d)/,''),name:clean(x.match[2]).replace(/\\s+(?:A[nñ]o|C[eé]dula:).*$/i,'')}));\n const seen=new Set(),employees=[];for(const e of raw){const k=`${e.code}|${norm(e.name)}`;if(!seen.has(k)){seen.add(k);employees.push(e);}}\n supportDocs.push({fileName,employees});validation('IGNORADO',category,fileName,`Documento de cálculo/soporte (${employees.length} empleado(s)); no se suma para evitar duplicidad.`);continue;\n }\n const ms=markers(text,/Empleado\\s*#\\s*(\\d+)\\s*:\\s*([^\\n\\r]+?)(?=\\s+Clave\\s*ISR|$)/i);\n if(!ms.length){validation('REVISAR',category,fileName,'No se detectaron empleados en la Planilla de Liquidación resumida.');continue;}\n let inc=0,out=0,review=0;\n for(let j=0;j!x.found))reason='No se pudieron leer todos los campos impresos de la liquidación dentro del bloque del empleado.';\n let regularBase=0;\n if(!reason){\n regularBase=round(sv.value-dtm.value);\n const compSV=round(sal.value+vac.value+dtm.value),compGross=round(sv.value+pai.value),expSS=round(regularBase*Number(rates.ssRegular||0.0975)+dtm.value*Number(rates.ssDtm||0.0725)),expSE=round(regularBase*Number(rates.se||0.0125));\n if(regularBase<0||!close(sv.value,compSV,.08)||!close(gross.value,compGross,.08)||!close(ss.value,expSS,.08)||!close(se.value,expSE,.08))reason=`Control contable no cuadra: Sal/Vac/13 ${sv.value} vs ${compSV}; bruto ${gross.value} vs ${compGross}; S.S. ${ss.value} (~${expSS}); S.E. ${se.value} (~${expSE}).`;\n }\n if(reason){review++;addRecord({category:'Liquidación',fileName,code,name,ss:ss.found?ss.value:0,se:se.found?se.value:0,gross:gross.found?gross.value:0,regularBase,dtmBase:dtm.found?dtm.value:0,salaryVac13:sv.found?sv.value:0,salaryReg:sal.found?sal.value:0,vacationBase:vac.found?vac.value:0,date,transaction,planillaNumber,generatedAt,included:false,reason});continue;}\n const included=inTarget(parseDMY(date));if(included)inc++;else out++;\n addRecord({category:'Liquidación',fileName,code,name,ss:ss.value,se:se.value,gross:gross.value,regularBase,dtmBase:dtm.value,salaryVac13:sv.value,salaryReg:sal.value,vacationBase:vac.value,date,transaction,planillaNumber,generatedAt,included,reason:included?'':'Fecha de liquidación fuera del mes de la Planilla CSS',ssMethod:'SOURCE_LINE',seMethod:'SOURCE_LINE'});\n }\n validation(review?'REVISAR':out?'EXCLUIDO':'OK',category,fileName,`${ms.length} liquidaciones: ${inc} incluidas, ${out} fuera de período y ${review} no legibles. Lectura por línea/etiqueta + identidades contables del mismo empleado.`);\n }else validation('REVISAR',category||'desconocida',fileName,'Categoría complementaria no reconocida; archivo excluido del cálculo.');\n}\n\n// Duplicados: nunca se suman dos veces.\nconst seenExtra=new Map();\nfor(const r of records){if(r.category!=='Extraordinaria'||!r.included)continue;const k=r.transaction?`T:${r.transaction}`:`F:${r.code||norm(r.name)}|${r.periodStart}|${r.periodEnd}|${r.ss}|${r.se}|${r.gross}`;if(!seenExtra.has(k)){seenExtra.set(k,r);continue;}const p=seenExtra.get(k);if(recordFingerprint(p)===recordFingerprint(r)){r.included=false;r.reason=`Duplicado exacto; ya incluido desde ${p.fileName}`;}else{r.included=false;r.reason=`Transacción ${r.transaction} repetida con datos distintos; revisar.`;validation('REVISAR','extraordinaria',r.fileName,r.reason);}}\nconst seenVac=new Map(),vacGroups=new Map();\nfor(const r of records){if(r.category!=='Vacaciones'||!r.included)continue;const person=r.code||norm(r.name),ek=`${person}|${r.periodStart}|${r.periodEnd}|${r.ss}|${r.se}|${r.gross}`;if(seenVac.has(ek)){r.included=false;r.reason=`Duplicado exacto; ya incluido desde ${seenVac.get(ek).fileName}`;continue;}seenVac.set(ek,r);const gk=`${person}|${r.periodStart}|${r.periodEnd}`;if(!vacGroups.has(gk))vacGroups.set(gk,[]);vacGroups.get(gk).push(r);}\nfor(const arr of vacGroups.values()){const active=arr.filter(x=>x.included);if(active.length<=1)continue;active.sort((a,b)=>(Number(b.planillaNumber)||0)-(Number(a.planillaNumber)||0)||String(b.generatedAt).localeCompare(String(a.generatedAt)));for(const r of active.slice(1)){r.included=false;r.reason=`Sustituida por planilla de vacaciones más reciente (${active[0].planillaNumber||active[0].fileName}).`;}}\nconst seenLiq=new Map();\nfor(const r of records){if(r.category!=='Liquidación'||!r.included)continue;const k=r.transaction?`T:${r.transaction}`:`F:${r.code||norm(r.name)}|${r.date}|${r.ss}|${r.se}|${r.gross}`;if(!seenLiq.has(k)){seenLiq.set(k,r);continue;}const p=seenLiq.get(k);if(recordFingerprint(p)===recordFingerprint(r)){r.included=false;r.reason=`Duplicado exacto; ya incluido desde ${p.fileName}`;}else{r.included=false;r.reason=`Transacción ${r.transaction} repetida con datos distintos; revisar.`;validation('REVISAR','liquidaciones',r.fileName,r.reason);}}\n\n// Documentos de cálculo: son soporte y nunca alteran importes ni provocan doble suma.\nconst liqAll=records.filter(r=>r.category==='Liquidación');\nfor(const doc of supportDocs){const missing=(doc.employees||[]).filter(e=>!liqAll.some(r=>samePerson(e,r)));if(missing.length)validation('REVISAR','liquidaciones',doc.fileName,`Documento de cálculo contiene persona(s) sin planilla resumida: ${missing.map(x=>`${x.name}${x.code?` (#${x.code})`:''}`).join(', ')}.`);}\n\nconst includedRecords=records.filter(r=>r.included),catCount=cat=>includedRecords.filter(r=>r.category===cat).length;\nconst RESULT={records,validations,supportDocs,includedRecords,files:[...new Set(files)],counts:{extraordinarias:catCount('Extraordinaria'),vacaciones:catCount('Vacaciones'),liquidaciones:catCount('Liquidación')},totals:{extraSS:round(includedRecords.filter(r=>r.category==='Extraordinaria').reduce((s,r)=>s+r.ss,0)),vacSS:round(includedRecords.filter(r=>r.category==='Vacaciones').reduce((s,r)=>s+r.ss,0)),liqSS:round(includedRecords.filter(r=>r.category==='Liquidación').reduce((s,r)=>s+r.ss,0)),totalSS:round(includedRecords.reduce((s,r)=>s+r.ss,0)),totalSE:round(includedRecords.reduce((s,r)=>s+r.se,0))},parserVersion:'LINE_ANCHOR_V7_SIN_IA',quality:{status:validations.some(v=>v.status==='REVISAR')?'REVISAR':'VALIDADO',rule:'Los complementarios se leen por líneas ancladas a etiquetas dentro del bloque exacto de cada empleado. No se alinean listas globales, no se buscan importes por proximidad amplia y no se reconstruyen descuentos cuando el PDF trae el valor impreso.'}};\nreturn [{json:RESULT}];\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 28928, + 26832 + ], + "id": "ed614c7e-da77-4670-86ac-ba83d855ba21", + "name": "Parsear PDFs", + "notesInFlow": true, + "notes": "Parser V7 SIN IA: lectura determinista por líneas ancladas a etiquetas dentro del bloque exacto de cada empleado; sin alineación global ni búsqueda por proximidad amplia." + }, + { + "parameters": { + "jsCode": "const init=$items('Inicializar ejecución')[0].json,config=init.config||{};\nconst q1=$items('Resultado Q1')[0].json,q2=$items('Resultado Q2')[0].json,css=$items('Parsear CSS')[0].json,pdfs=$items('Parsear PDFs')[0].json;\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase().replace(/\\bVASQUEZ\\b/g,'VAZQUEZ').replace(/[^A-Z0-9 ]/g,' ').replace(/\\s+/g,' ').trim();}\nconst particles=new Set(['DE','DEL','LA','LAS','LOS','Y','DA','DOS']);\nfunction core(v){return norm(v).split(' ').filter(t=>t&&!particles.has(t));}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction money(n){return `B/. ${Number(n||0).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}`;}\nfunction parseDMY(s){const m=String(s||'').match(/(\\d{1,2})\\/(\\d{1,2})\\/(20\\d{2})/);return m?new Date(Number(m[3]),Number(m[2])-1,Number(m[1])):null;}\nfunction validatePayrollPeriod(label,p,half){\n if(!p)return {status:'REVISAR',detail:`${label}: período no detectado automáticamente; se validó estructura y totales, pero conviene confirmar el archivo.`};\n const a=parseDMY(p.start),b=parseDMY(p.end),tm=css.period.month,ty=css.period.year;\n if(!a||!b)return {status:'REVISAR',detail:`${label}: período ilegible.`};\n const same=a.getFullYear()===ty&&b.getFullYear()===ty&&a.getMonth()+1===tm&&b.getMonth()+1===tm;\n if(!same)throw new Error(`${label} pertenece a ${p.start} - ${p.end}, pero la Planilla CSS es ${css.periodoLabel}. Se detuvo el cruce.`);\n if(a>b)throw new Error(`${label} tiene un período invertido (${p.start} - ${p.end}). Se detuvo el cruce.`);\n if(half===1&&(a.getDate()>15||b.getDate()>16))throw new Error(`${label} no parece ser la 1era quincena (${p.start} - ${p.end}). Revisa que no hayas cargado la 2da quincena por error.`);\n if(half===2&&(a.getDate()<15||b.getDate()<27))throw new Error(`${label} no parece ser la 2da quincena (${p.start} - ${p.end}). Revisa que no hayas cargado la 1era quincena por error.`);\n return {status:'OK',detail:`${label}: ${p.start} - ${p.end}`};\n}\nfunction payrollFingerprint(x){return (x.records||[]).map(r=>[String(r.code||''),norm(r.name),round(r.ss),round(r.se),round(r.gross)].join('|')).sort().join('||');}\nconst periodChecks=[validatePayrollPeriod('Nómina Q1',q1.period,1),validatePayrollPeriod('Nómina Q2',q2.period,2)];\nconst fpQ1=payrollFingerprint(q1),fpQ2=payrollFingerprint(q2);\nif(fpQ1&&fpQ2&&fpQ1===fpQ2)throw new Error('Las nóminas Q1 y Q2 contienen exactamente los mismos empleados y montos. Se detuvo el cruce porque probablemente se cargó la misma quincena dos veces.');\n// Inferencia dinámica de tasas del trabajador desde los propios recibos, para no depender de tasas hardcodeadas año tras año.\nfunction median(a){const x=a.filter(Number.isFinite).sort((p,q)=>p-q);if(!x.length)return null;const m=Math.floor(x.length/2);return x.length%2?x[m]:(x[m-1]+x[m])/2;}\nconst ratioSS=[],ratioSE=[];\nfor(const r of [...(q1.records||[]),...(q2.records||[])]){const g=Number(r.gross)||0,ss=Number(r.ss)||0,se=Number(r.se)||0;if(g>=20&&ss>0){const x=ss/g;if(x>.03&&x<.20)ratioSS.push(x);}if(g>=20&&se>0){const x=se/g;if(x>.001&&x<.05)ratioSE.push(x);}}\nconst fallback=config.defaultRates||{ssRegular:.0975,se:.0125,ssDtm:.0725};\nconst ssMed=median(ratioSS),seMed=median(ratioSE),ssRegular=Number((ssMed??fallback.ssRegular).toFixed(4)),seRate=Number((seMed??fallback.se).toFixed(4));\nconst dtmRatios=[];for(const r of pdfs.includedRecords||[]){if(r.category!=='Liquidación'||Number(r.dtmBase||0)<=1)continue;const x=(Number(r.ss||0)-Number(r.regularBase||0)*ssRegular)/Number(r.dtmBase);if(x>.03&&x<.15)dtmRatios.push(x);}\nconst dtmMed=median(dtmRatios),ssDtm=Number((dtmMed??fallback.ssDtm).toFixed(4));\nconst rateInfo={ssRegular,se:seRate,ssDtm,samplesSS:ratioSS.length,samplesSE:ratioSE.length,samplesDTM:dtmRatios.length,usedFallbackSS:ssMed===null,usedFallbackSE:seMed===null,usedFallbackDTM:dtmMed===null};\nif(ratioSS.length<(config.minRateSamples||20)||ratioSE.length<(config.minRateSamples||20))throw new Error(`No hubo suficientes registros para inferir las tasas de S.S./S.E. de forma segura (${ratioSS.length}/${ratioSE.length}). Se detuvo el cruce.`);\nconst plans=(css.employees||[]).map((p,index)=>({...p,_idx:index,expectedWorkerSS:round(Number(p.salaryAdjusted||0)*ssRegular+Number(p.dtmBase||0)*ssDtm),expectedWorkerSE:round(Number(p.salaryAdjusted||0)*seRate)}));\n// Une todas las fuentes por Employee # cuando existe; cuando el PDF pierde el código, usa el nombre exacto conservadoramente.\nconst observations=[];for(const r of q1.records||[])observations.push({...r,kind:'q1'});for(const r of q2.records||[])observations.push({...r,kind:'q2'});for(const r of pdfs.includedRecords||[]){const kind=r.category==='Extraordinaria'?'extra':r.category==='Vacaciones'?'vac':'liq';observations.push({...r,kind});}\nconst groups=new Map(),nameCodes=new Map();\nfor(const r of observations){const code=clean(r.code).replace(/^0+(?=\\d)/,''),nn=norm(r.name);if(!code)continue;const k=`C:${code}`;if(!groups.has(k))groups.set(k,{key:k,code,names:[],obs:[]});const g=groups.get(k);if(r.name&&!g.names.includes(r.name))g.names.push(r.name);g.obs.push(r);if(!nameCodes.has(nn))nameCodes.set(nn,new Set());nameCodes.get(nn).add(code);}\nfor(const r of observations){const code=clean(r.code).replace(/^0+(?=\\d)/,'');if(code)continue;const nn=norm(r.name),codes=nameCodes.get(nn)||new Set();let g;if(codes.size===1)g=groups.get(`C:${[...codes][0]}`);else{const k=`N:${nn}`;if(!groups.has(k))groups.set(k,{key:k,code:'',names:[],obs:[]});g=groups.get(k);}if(r.name&&!g.names.includes(r.name))g.names.push(r.name);g.obs.push(r);}\nfor(const g of groups.values()){for(const k of ['q1SS','q1SE','q2SS','q2SE','extraSS','extraSE','vacSS','vacSE','liqSS','liqSE'])g[k]=0;for(const r of g.obs){const ss=Number(r.ss)||0,se=Number(r.se)||0;g[`${r.kind}SS`]+=ss;g[`${r.kind}SE`]+=se;}g.name=g.names[0]||'';g.totalSS=round(g.q1SS+g.q2SS+g.extraSS+g.vacSS+g.liqSS);g.totalSE=round(g.q1SE+g.q2SE+g.extraSE+g.vacSE+g.liqSE);}\nconst sources=[...groups.values()];\n// Similaridad conservadora OPTIMIZADA: conserva las mismas reglas de matching,\n// pero evita comparar cada fuente contra cada empleado CSS con Levenshtein completo.\n// Primero crea índices baratos (tokens, prefijos, iniciales y montos) y solo ejecuta\n// la similitud costosa sobre un conjunto reducido de candidatos plausibles.\nfunction levRaw(a,b){\n a=String(a||''); b=String(b||'');\n if(a===b)return 0;\n if(!a.length)return b.length;\n if(!b.length)return a.length;\n if(a.length>b.length){const t=a;a=b;b=t;}\n let prev=new Uint16Array(a.length+1),cur=new Uint16Array(a.length+1);\n for(let i=0;i<=a.length;i++)prev[i]=i;\n for(let j=1;j<=b.length;j++){\n cur[0]=j;\n const bj=b.charCodeAt(j-1);\n for(let i=1;i<=a.length;i++){\n const cost=a.charCodeAt(i-1)===bj?0:1;\n const ins=cur[i-1]+1,del=prev[i]+1,sub=prev[i-1]+cost;\n cur[i]=Math.min(ins,del,sub);\n }\n const tmp=prev;prev=cur;cur=tmp;\n }\n return prev[a.length];\n}\nfunction compactNorm(v){return norm(v).replace(/ /g,'');}\nfunction simPrepared(a,b){a=String(a||'');b=String(b||'');return !a||!b?0:1-levRaw(a,b)/Math.max(a.length,b.length);}\nfunction tokenCovPrepared(A,B){\n if(!A.length||!B.length)return 0;\n const avg=(X,Y)=>{\n let total=0;\n for(const x of X){\n let best=0;\n for(const y of Y){\n const s=simPrepared(x,y);\n if(s>best)best=s;\n if(best===1)break;\n }\n total+=best;\n }\n return total/X.length;\n };\n return (avg(A,B)+avg(B,A))/2;\n}\nfunction sourceParts(name){\n const raw=String(name||'');let l='',f='';\n if(raw.includes(',')){const pos=raw.indexOf(',');l=raw.slice(0,pos);f=raw.slice(pos+1);}\n else{const t=norm(raw).split(' ');l=t.shift()||'';f=t.join(' ');}\n const L=core(l),F=core(f);\n return {\n last:norm(l),first:norm(f),last1:L[0]||'',first1:F[0]||'',\n lastC:compactNorm(l),firstC:compactNorm(f),\n last1C:compactNorm(L[0]||''),first1C:compactNorm(F[0]||'')\n };\n}\nfunction planParts(p){\n const L=core(p.lastname),F=core(p.firstname);\n return {\n last:norm(p.lastname),first:norm(p.firstname),last1:L[0]||'',first1:F[0]||'',\n lastC:compactNorm(p.lastname),firstC:compactNorm(p.firstname),\n last1C:compactNorm(L[0]||''),first1C:compactNorm(F[0]||'')\n };\n}\nfunction uniqTokens(v){return [...new Set(core(v).map(compactNorm).filter(Boolean))];}\nfunction addIndex(map,k,v){if(!k)return;if(!map.has(k))map.set(k,[]);map.get(k).push(v);}\nfunction addBucket(set,map,k){for(const v of map.get(k)||[])set.add(v);}\nfunction prefix(v,n){v=String(v||'');return v.length>=n?v.slice(0,n):v;}\nfunction amountClass(dss,dse){if(dss<=.05&&dse<=.05)return 1;if(dss<=.12&&dse<=.12)return .95;if(dss<=.25&&dse<=.25)return .85;if(dss<=.5&&dse<=.5)return .70;if(dss<=1&&dse<=1)return .50;return 0;}\n\nconst planMeta=plans.map((p,pi)=>({\n p,pi,pp:planParts(p),tokens:uniqTokens(p.name),\n pairKey:`${p.expectedWorkerSS.toFixed(2)}|${p.expectedWorkerSE.toFixed(2)}`\n}));\nconst sourceMeta=sources.map((s,si)=>({s,si,sp:sourceParts(s.name),tokens:uniqTokens(s.name)}));\n\nconst pairFreq=new Map(),firstFreq=new Map(),lastFreq=new Map();\nfor(const m of planMeta){\n pairFreq.set(m.pairKey,(pairFreq.get(m.pairKey)||0)+1);\n firstFreq.set(m.pp.first1,(firstFreq.get(m.pp.first1)||0)+1);\n lastFreq.set(m.pp.last1,(lastFreq.get(m.pp.last1)||0)+1);\n}\n\nconst byToken=new Map(),byLast2=new Map(),byLast3=new Map(),byFirst2=new Map(),byFirst3=new Map(),byInitialPair=new Map(),byAmount=new Map();\nfunction amountKey(ss,se){return `${Math.round((Number(ss)||0)*4)}|${Math.round((Number(se)||0)*4)}`;}\nfor(const m of planMeta){\n for(const t of m.tokens)addIndex(byToken,t,m.pi);\n addIndex(byLast2,prefix(m.pp.last1C,2),m.pi);\n addIndex(byLast3,prefix(m.pp.last1C,3),m.pi);\n addIndex(byFirst2,prefix(m.pp.first1C,2),m.pi);\n addIndex(byFirst3,prefix(m.pp.first1C,3),m.pi);\n addIndex(byInitialPair,`${m.pp.last1C[0]||''}|${m.pp.first1C[0]||''}`,m.pi);\n addIndex(byAmount,amountKey(m.p.expectedWorkerSS,m.p.expectedWorkerSE),m.pi);\n}\n\nconst candidates=[],candidatesBySource=new Map();\nconst tierBuckets=[[],[],[],[],[],[]];\nfor(const sm of sourceMeta){\n const s=sm.s,sp=sm.sp,pool=new Set();\n\n // Evidencia de identidad exacta o por prefijo.\n for(const t of sm.tokens)addBucket(pool,byToken,t);\n addBucket(pool,byLast3,prefix(sp.last1C,3));\n addBucket(pool,byFirst3,prefix(sp.first1C,3));\n addBucket(pool,byLast2,prefix(sp.last1C,2));\n addBucket(pool,byFirst2,prefix(sp.first1C,2));\n addBucket(pool,byInitialPair,`${sp.last1C[0]||''}|${sp.first1C[0]||''}`);\n\n // Evidencia de monto: busca hasta +/- B/.1.00 en pasos de B/.0.25.\n const ssQ=Math.round((Number(s.totalSS)||0)*4),seQ=Math.round((Number(s.totalSE)||0)*4);\n for(let ds=-4;ds<=4;ds++)for(let de=-4;de<=4;de++)addBucket(pool,byAmount,`${ssQ+ds}|${seQ+de}`);\n\n // Red de seguridad: si los índices produjeron muy pocos candidatos, agrega los\n // 12 CSS más cercanos por monto. Este barrido solo hace aritmética, no Levenshtein.\n if(pool.size<12){\n const closest=[];\n for(const pm of planMeta){\n const d=Math.abs((Number(s.totalSS)||0)-pm.p.expectedWorkerSS)+Math.abs((Number(s.totalSE)||0)-pm.p.expectedWorkerSE);\n if(closest.length<12){closest.push([d,pm.pi]);closest.sort((a,b)=>a[0]-b[0]);}\n else if(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": [ + 29168, + 26832 + ], + "id": "cae896d0-72d3-424a-ae45-3c3483b9fbd3", + "name": "Construir reporte producción" + }, + { + "parameters": { + "method": "POST", + "url": "https://sheets.googleapis.com/v4/spreadsheets", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.createSheetBody }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 29408, + 26832 + ], + "id": "6fa8dc8f-bd83-445f-97d7-e1acdedddc97", + "name": "Crear Google Sheet Reporte", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "method": "POST", + "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.spreadsheetId }}/values:batchUpdate", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $('Construir reporte producción').first().json.writeReportBody }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 29648, + 26832 + ], + "id": "fe78de18-a2d4-4866-8e99-4a87819129fc", + "name": "Escribir reporte", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const created=$items('Crear Google Sheet Reporte')[0].json; const report=$items('Construir reporte producción')[0].json;\nconst rowsByName={'Resumen':report.summaryRows,'Cruce Identidad':report.identityRows,'Cruce Montos CSS':report.amountRows,'Detalle Fuentes':report.detailRows,'Detalle Planilla CSS':report.cssRows,'Validaciones':report.valRows};\nconst requests=[];\nfor(const s of created.sheets||[]){\n const name=s.properties.title,id=s.properties.sheetId,rows=rowsByName[name]||[]; const cols=Math.max(1,...rows.map(r=>r.length)); const rowCount=Math.max(rows.length,5);\n requests.push({updateSheetProperties:{properties:{sheetId:id,gridProperties:{hideGridlines:true,frozenRowCount:4}},fields:'gridProperties.hideGridlines,gridProperties.frozenRowCount'}});\n requests.push({repeatCell:{range:{sheetId:id,startRowIndex:0,endRowIndex:rowCount,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{textFormat:{fontFamily:'Arial',fontSize:10,foregroundColor:{red:0.290,green:0.290,blue:0.290}},verticalAlignment:'MIDDLE'}},fields:'userEnteredFormat(textFormat,verticalAlignment)'}});\n requests.push({mergeCells:{range:{sheetId:id,startRowIndex:0,endRowIndex:1,startColumnIndex:0,endColumnIndex:cols},mergeType:'MERGE_ALL'}});\n requests.push({mergeCells:{range:{sheetId:id,startRowIndex:1,endRowIndex:2,startColumnIndex:0,endColumnIndex:cols},mergeType:'MERGE_ALL'}});\n requests.push({repeatCell:{range:{sheetId:id,startRowIndex:0,endRowIndex:1,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{backgroundColor:{red:0.310,green:0.459,blue:0.545},textFormat:{fontFamily:'Arial',fontSize:13,bold:true,foregroundColor:{red:1,green:1,blue:1}},horizontalAlignment:'LEFT',borders:{bottom:{style:'SOLID_THICK',color:{red:0.424,green:0.761,blue:0.290}}}}},fields:'userEnteredFormat'}});\n requests.push({repeatCell:{range:{sheetId:id,startRowIndex:1,endRowIndex:2,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{backgroundColor:{red:0.357,green:0.498,blue:0.584},textFormat:{fontFamily:'Arial',fontSize:10,italic:true,foregroundColor:{red:1,green:1,blue:1}}}},fields:'userEnteredFormat'}});\n requests.push({repeatCell:{range:{sheetId:id,startRowIndex:3,endRowIndex:4,startColumnIndex:0,endColumnIndex:cols},cell:{userEnteredFormat:{backgroundColor:{red:0.310,green:0.459,blue:0.545},textFormat:{fontFamily:'Arial',fontSize:10,bold:true,foregroundColor:{red:1,green:1,blue:1}},horizontalAlignment:'CENTER',wrapStrategy:'WRAP'}},fields:'userEnteredFormat'}});\n requests.push({autoResizeDimensions:{dimensions:{sheetId:id,dimension:'COLUMNS',startIndex:0,endIndex:cols}}});\n requests.push({updateDimensionProperties:{range:{sheetId:id,dimension:'ROWS',startIndex:0,endIndex:1},properties:{pixelSize:34},fields:'pixelSize'}});\n if(rowCount>4){\n const rules=[\n ['REVISAR',{red:0.992,green:0.925,blue:0.918},{red:0.65,green:0.15,blue:0.12}],\n ['SOLO',{red:1.0,green:0.973,blue:0.882},{red:0.55,green:0.35,blue:0.0}],\n ['ERROR',{red:0.992,green:0.925,blue:0.918},{red:0.65,green:0.15,blue:0.12}],\n ['EXCLUIDO',{red:1.0,green:0.973,blue:0.882},{red:0.55,green:0.35,blue:0.0}]\n ];\n for(const [txt,bg,fg] of rules)requests.push({addConditionalFormatRule:{rule:{ranges:[{sheetId:id,startRowIndex:4,endRowIndex:rowCount,startColumnIndex:0,endColumnIndex:1}],booleanRule:{condition:{type:'TEXT_CONTAINS',values:[{userEnteredValue:txt}]},format:{backgroundColor:bg,textFormat:{bold:true,foregroundColor:fg}}}},index:0}});\n}\n}\nreturn [{json:{formatBody:{requests}}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 29888, + 26832 + ], + "id": "6e359784-7724-4dcc-82f3-e8ec5867288b", + "name": "Preparar formato reporte" + }, + { + "parameters": { + "method": "POST", + "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $('Crear Google Sheet Reporte').item.json.spreadsheetId }}:batchUpdate", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json.formatBody) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 30128, + 26832 + ], + "id": "d4092b24-112e-4cbc-89a8-757458c3baec", + "name": "Formatear reporte", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const init=$items('Inicializar ejecución')[0].json; const id=$items('Crear Google Sheet Reporte')[0].json.spreadsheetId; const url=`https://docs.google.com/spreadsheets/d/${id}/edit`;\nreturn init.recipients.map(emailAddress=>({json:{emailAddress,spreadsheetId:id,spreadsheetUrl:url}}));" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 30368, + 26832 + ], + "id": "c569cb6f-97bc-42ef-bfc0-0636ef6f98b1", + "name": "Preparar permisos" + }, + { + "parameters": { + "method": "POST", + "url": "=https://www.googleapis.com/drive/v3/files/{{ $json.spreadsheetId }}/permissions?sendNotificationEmail=false&supportsAllDrives=true", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleDriveOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ role: 'writer', type: 'user', emailAddress: $json.emailAddress }) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 30608, + 26832 + ], + "id": "164892df-85f8-4265-bb47-a21c7700f6f4", + "name": "Compartir reporte como editor", + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + }, + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "const init=$items('Inicializar ejecución')[0].json,r=$items('Construir reporte producción')[0].json,s=r.summary,id=$items('Crear Google Sheet Reporte')[0].json.spreadsheetId,url=`https://docs.google.com/spreadsheets/d/${id}/edit`;\nconst nf=n=>Number(n||0).toLocaleString('en-US'),money=n=>`B/. ${Number(n||0).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}`,esc=x=>String(x??'').replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[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": [ + 30848, + 26832 + ], + "id": "bdee4a88-ed77-41b1-a4ea-c0321f5587d2", + "name": "Preparar correo HTML GLM" + }, + { + "parameters": { + "sendTo": "={{ $json.sendTo }}", + "subject": "={{ $json.subject }}", + "message": "={{ $json.html }}", + "options": { + "appendAttribution": false + } + }, + "type": "n8n-nodes-base.gmail", + "typeVersion": 2.2, + "position": [ + 31088, + 26832 + ], + "id": "1cf118be-34f7-46ff-9a83-414bdb9bdf5a", + "name": "Enviar correo resumen", + "webhookId": "ae5f87e7-df2b-4349-8eb5-aac208accb96", + "executeOnce": true, + "credentials": { + "gmailOAuth2": { + "id": "UDcO1FLJqA453V2D", + "name": "Gmail account 3" + } + } + }, + { + "parameters": { + "jsCode": "const init=$items('Inicializar ejecución')[0].json,r=$items('Construir reporte producción')[0].json,s=r.summary,id=$items('Crear Google Sheet Reporte')[0].json.spreadsheetId,reportUrl=`https://docs.google.com/spreadsheets/d/${id}/edit`,formUrl=init.formUrl;\nconst html=`
\"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": [ + 31808, + 26832 + ], + "id": "b99cc3cd-a57e-401b-aab3-b671895bdc05", + "name": "Preparar confirmación" + }, + { + "parameters": { + "operation": "completion", + "respondWith": "showText", + "responseText": "={{ $json.html }}" + }, + "type": "n8n-nodes-base.form", + "typeVersion": 2.5, + "position": [ + 32048, + 26832 + ], + "id": "716a9401-cf32-4eac-b329-8d318f0c686b", + "name": "Confirmación y regreso al formulario", + "webhookId": "d4a102a8-3d72-4c5b-83b2-0e0d04c71099", + "notesInFlow": true, + "notes": "Página final con reporte y botón que vuelve exactamente al mismo Form Trigger de Panamá." + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 2 + }, + "conditions": [ + { + "id": "fd4e3c0d-3ba7-4267-b86c-3b8771da3d64", + "leftValue": "={{ $json.format }}", + "rightValue": "PDF", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 21488, + 26912 + ], + "id": "5abe74ca-8b12-4ee2-8289-ff31fa149407", + "name": "¿Q1 es PDF?" + }, + { + "parameters": { + "operation": "pdf", + "options": {} + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + 21728, + 27056 + ], + "id": "34048913-682f-42a2-bc3f-8b02746dc92b", + "name": "Extraer texto Q1 PDF" + }, + { + "parameters": { + "jsCode": "const prep=$items('Preparar Q1')[0].json;\nconst item=$input.first();\nconst candidateStrings=Object.values(item.json||{}).filter(v=>typeof v==='string');\nconst raw=item.json?.text??item.json?.data??item.json?.content??candidateStrings.sort((a,b)=>b.length-a.length)[0]??'';\nconst text=String(raw).replace(/\\r/g,'');\nconst config=$items('Inicializar ejecución')[0]?.json?.config||{};\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction num(v){if(v===null||v===undefined||v==='')return null;let s=String(v).replace(/B\\/\\.?|\\$/gi,'').replace(/,/g,'').trim();let neg=/^\\(.*\\)$/.test(s);if(neg)s=s.slice(1,-1);if(!/^-?\\d+(?:\\.\\d+)?$/.test(s))return null;const n=Number(s);return Number.isFinite(n)?(neg?-n:n):null;}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction moneyLine(s){return /^[\\s$()\\-]*[\\d,]+(?:\\.\\d+)?\\s*$/.test(String(s||''));}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase();}\nfunction plausible(gross,ss,se){if(!Number.isFinite(gross)||!Number.isFinite(ss)||!Number.isFinite(se))return false;if(gross<0||ss<-.01||se<-.01)return false;if(gross>0&&(ss/gross>.30||se/gross>.08))return false;return true;}\nfunction median(a){const x=a.filter(Number.isFinite).sort((p,q)=>p-q);if(!x.length)return null;const m=Math.floor(x.length/2);return x.length%2?x[m]:(x[m-1]+x[m])/2;}\nfunction fixed2Tokens(s){return (String(s||'').match(/\\(?-?(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{2}\\)?/g)||[]).map(num).filter(v=>v!==null);}\nfunction sim(a,b){a=norm(a).replace(/[^A-Z]/g,'');b=norm(b).replace(/[^A-Z]/g,'');if(!a||!b)return 0;if(a===b)return 1;const prev=Array.from({length:b.length+1},(_,i)=>i);for(let i=1;i<=a.length;i++){const cur=[i];for(let j=1;j<=b.length;j++)cur[j]=Math.min(cur[j-1]+1,prev[j]+1,prev[j-1]+(a[i-1]===b[j-1]?0:1));for(let j=0;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": [ + 21984, + 27024 + ], + "id": "c0af9861-2562-41d1-a15b-00b2e42b7bf2", + "name": "Parsear Q1 PDF" + }, + { + "parameters": { + "jsCode": "const r=$input.first().json;if(!Array.isArray(r.records)||!r.records.length)throw new Error('Resultado Q1 vacío.');return [{json:r}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 23648, + 26832 + ], + "id": "321d3b2f-837e-48b3-af92-8662b625adc7", + "name": "Resultado Q1" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 2 + }, + "conditions": [ + { + "id": "5ceced14-980c-46ea-a1ee-3845c8620a90", + "leftValue": "={{ $json.format }}", + "rightValue": "PDF", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 24128, + 26912 + ], + "id": "ed7c9435-b43d-4e4c-a702-23127d54fe1f", + "name": "¿Q2 es PDF?" + }, + { + "parameters": { + "operation": "pdf", + "options": {} + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + 24368, + 27056 + ], + "id": "e7dfdc83-f288-48e3-82ef-72bc1ca65ca4", + "name": "Extraer texto Q2 PDF" + }, + { + "parameters": { + "jsCode": "const prep=$items('Preparar Q2')[0].json;\nconst item=$input.first();\nconst candidateStrings=Object.values(item.json||{}).filter(v=>typeof v==='string');\nconst raw=item.json?.text??item.json?.data??item.json?.content??candidateStrings.sort((a,b)=>b.length-a.length)[0]??'';\nconst text=String(raw).replace(/\\r/g,'');\nconst config=$items('Inicializar ejecución')[0]?.json?.config||{};\nfunction clean(v){return String(v??'').replace(/\\s+/g,' ').trim();}\nfunction num(v){if(v===null||v===undefined||v==='')return null;let s=String(v).replace(/B\\/\\.?|\\$/gi,'').replace(/,/g,'').trim();let neg=/^\\(.*\\)$/.test(s);if(neg)s=s.slice(1,-1);if(!/^-?\\d+(?:\\.\\d+)?$/.test(s))return null;const n=Number(s);return Number.isFinite(n)?(neg?-n:n):null;}\nfunction round(n){return Math.round((Number(n)||0)*100)/100;}\nfunction moneyLine(s){return /^[\\s$()\\-]*[\\d,]+(?:\\.\\d+)?\\s*$/.test(String(s||''));}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').replace(/[¿?]/g,'N').toUpperCase();}\nfunction plausible(gross,ss,se){if(!Number.isFinite(gross)||!Number.isFinite(ss)||!Number.isFinite(se))return false;if(gross<0||ss<-.01||se<-.01)return false;if(gross>0&&(ss/gross>.30||se/gross>.08))return false;return true;}\nfunction median(a){const x=a.filter(Number.isFinite).sort((p,q)=>p-q);if(!x.length)return null;const m=Math.floor(x.length/2);return x.length%2?x[m]:(x[m-1]+x[m])/2;}\nfunction fixed2Tokens(s){return (String(s||'').match(/\\(?-?(?:\\d{1,3}(?:,\\d{3})+|\\d+)\\.\\d{2}\\)?/g)||[]).map(num).filter(v=>v!==null);}\nfunction sim(a,b){a=norm(a).replace(/[^A-Z]/g,'');b=norm(b).replace(/[^A-Z]/g,'');if(!a||!b)return 0;if(a===b)return 1;const prev=Array.from({length:b.length+1},(_,i)=>i);for(let i=1;i<=a.length;i++){const cur=[i];for(let j=1;j<=b.length;j++)cur[j]=Math.min(cur[j-1]+1,prev[j]+1,prev[j-1]+(a[i-1]===b[j-1]?0:1));for(let j=0;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": [ + 24608, + 27056 + ], + "id": "c8d62ff5-5098-4509-b79d-0b2a670cbbec", + "name": "Parsear Q2 PDF" + }, + { + "parameters": { + "jsCode": "const r=$input.first().json;if(!Array.isArray(r.records)||!r.records.length)throw new Error('Resultado Q2 vacío.');return [{json:r}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 25568, + 26832 + ], + "id": "012df9a7-6095-4322-8f93-8b4d24986e54", + "name": "Resultado Q2" + }, + { + "parameters": { + "jsCode": "const q1=$items('Resultado Q1')[0].json;\nconst q2=$items('Resultado Q2')[0].json;\nconst css=$items('Parsear CSS')[0].json;\nconst tempFolder=$items('Crear carpeta temporal PDFs')[0]?.json?.id||'';\nconst out=[];\nfor(const [tipo,id] of [\n ['Q1',q1.stagingSpreadsheetId],\n ['Q2',q2.stagingSpreadsheetId],\n ['CSS',css.stagingSpreadsheetId],\n ['PDFS_TEMP',tempFolder]\n]) if(id) out.push({json:{tipo,fileId:id}});\nreturn out.length?out:[{json:{tipo:'NINGUNO',fileId:''}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 31328, + 26832 + ], + "id": "e83d7ddc-c884-4034-acc3-f6331f491608", + "name": "Preparar limpieza temporales" + }, + { + "parameters": { + "method": "DELETE", + "url": "=https://www.googleapis.com/drive/v3/files/{{ $json.fileId }}?supportsAllDrives=true", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleDriveOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 31568, + 26832 + ], + "id": "d7d69f76-df90-4f5c-bad4-536410e6fc9d", + "name": "Eliminar staging temporal", + "alwaysOutputData": true, + "notesInFlow": true, + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + }, + "continueOnFail": true, + "notes": "Limpieza best-effort: elimina staging Q1/Q2/CSS y la carpeta temporal de PDFs. Si falla, no bloquea la pantalla final." + }, + { + "parameters": { + "method": "POST", + "url": "https://www.googleapis.com/drive/v3/files?fields=id,name,mimeType&supportsAllDrives=true", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleDriveOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ name: `TEMP PA CSS PDFs - ${$('Inicializar ejecución').item.json.runId}`, mimeType: 'application/vnd.google-apps.folder' }) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 21248, + 27216 + ], + "id": "b16c3231-7cda-4395-b4a7-6b17cc5be53d", + "name": "Crear carpeta temporal PDFs", + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 2000, + "notesInFlow": true, + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + }, + "notes": "Carpeta temporal aislada por ejecución para acumular PDFs cargados en varias tandas sin perder selecciones anteriores." + }, + { + "parameters": { + "formFields": { + "values": [ + { + "fieldLabel": "¿Hay extraordinarias este mes?", + "fieldType": "radio", + "fieldOptions": { + "values": [ + { + "option": "Sí" + }, + { + "option": "No" + } + ] + }, + "requiredField": true + } + ] + }, + "options": { + "formTitle": "Paso 2 de 4 · Extraordinarias", + "formDescription": "Indica si este mes existen planillas extraordinarias. Este es el único grupo opcional.", + "buttonLabel": "Continuar" + } + }, + "type": "n8n-nodes-base.form", + "typeVersion": 2.5, + "position": [ + 21488, + 27216 + ], + "id": "b784edea-6563-4f63-b8cb-900f2850753e", + "name": "Definir Extraordinarias", + "webhookId": "2d4fba32-6a24-46e8-a9c7-a38ca80b106a" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 2 + }, + "conditions": [ + { + "id": "e7a0d1ad-362a-4267-b2fd-ba254621eeaa", + "leftValue": "={{ $json[\"¿Hay extraordinarias este mes?\"] }}", + "rightValue": "Sí", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 21728, + 27216 + ], + "id": "534cbd73-479a-4797-848c-a012e29bbc58", + "name": "¿Hay extraordinarias?" + }, + { + "parameters": { + "formFields": { + "values": [ + { + "fieldLabel": "Archivos extraordinarios", + "fieldType": "file", + "acceptFileTypes": ".pdf,application/pdf", + "requiredField": true + }, + { + "fieldLabel": "¿Agregar más extraordinarias?", + "fieldType": "radio", + "fieldOptions": { + "values": [ + { + "option": "Sí" + }, + { + "option": "No" + } + ] + }, + "requiredField": true + } + ] + }, + "options": { + "formTitle": "Extraordinarias · cargar lote", + "formDescription": "Puedes seleccionar uno o varios PDFs a la vez. Si tienes más archivos en otra carpeta, selecciona “Sí” en “Agregar más” y volverás a esta misma pantalla sin perder los archivos ya guardados.", + "buttonLabel": "Guardar lote" + } + }, + "type": "n8n-nodes-base.form", + "typeVersion": 2.5, + "position": [ + 21984, + 27024 + ], + "id": "c0214ec8-c0ef-4e9d-bf81-b1cc55eaffef", + "name": "Cargar Extraordinarias", + "webhookId": "237dd241-cbba-4455-beec-b4b63e45827d" + }, + { + "parameters": { + "jsCode": "const item=$input.first();\nconst bin=item.binary||{};\nconst files=Object.entries(bin);\nfunction clean(v){return String(v??'').trim();}\nfunction norm(v){return clean(v).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase();}\nfunction yes(v){return ['si','sí','yes','true','1'].includes(norm(v));}\nfunction safeName(v){return clean(v).replace(/[\\\\/\\x00-\\x1F]/g,'_').replace(/__+/g,'_').slice(-150)||'archivo.pdf';}\nif(!files.length) throw new Error('Debes adjuntar al menos un PDF en este lote de Extraordinarias.');\nconst more=yes(item.json?.['¿Agregar más extraordinarias?']);\nconst init=$items('Inicializar ejecución')[0].json;\nconst max=Number(init.config?.maxSupplementalFilesSafety||500);\nif(files.length>max) throw new Error(`Se recibieron ${files.length} archivos en un solo lote; supera el límite técnico de seguridad (${max}).`);\nconst now=Date.now();\nconst out=[];\nfor(let i=0;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": [ + 28688, + 27056 + ], + "id": "dc199c71-3269-4587-92fa-404ad7815521", + "name": "Preparar descarga PDFs", + "notesInFlow": true, + "notes": "Reconstruye las tres categorías desde la carpeta temporal y valida mínimos antes de descargar." + }, + { + "parameters": { + "operation": "download", + "fileId": { + "__rl": true, + "value": "={{ $json.id }}", + "mode": "id" + }, + "options": { + "binaryPropertyName": "data", + "fileName": "={{ $json.storageName }}" + } + }, + "type": "n8n-nodes-base.googleDrive", + "typeVersion": 3, + "position": [ + 28928, + 27056 + ], + "id": "f0ce2d8b-da54-4aa9-9c1a-560b90f5e2c1", + "name": "Descargar PDFs temporales", + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 2000, + "credentials": { + "googleDriveOAuth2Api": { + "id": "g23xdGLZRzBGqKgH", + "name": "Isaac - Google Drive" + } + } + }, + { + "parameters": { + "jsCode": "const items=$input.all();\nfunction parseStorage(n){\n const name=String(n||'');\n let category='';\n if(name.startsWith('EXTRA__'))category='extraordinaria';\n else if(name.startsWith('VAC__'))category='vacaciones';\n else if(name.startsWith('LIQ__'))category='liquidaciones';\n const ix=name.indexOf('__ORIG__');\n return {category,fileName:ix>=0?name.slice(ix+8):name,storageName:name};\n}\nconst out=[];\nfor(const item of items){\n const data=item.binary?.data;\n if(!data) throw new Error('Google Drive no devolvió el contenido binario de uno de los PDFs temporales.');\n const p=parseStorage(data.fileName||item.json?.storageName||item.json?.name||'');\n if(!p.category) throw new Error(`No pude determinar la categoría del archivo temporal ${p.storageName||'(sin nombre)'}.`);\n data.fileName=p.fileName;\n out.push({json:{category:p.category,fileName:p.fileName,storageName:p.storageName,fileId:item.json?.id||item.json?.fileId||''},binary:{data}});\n}\nreturn out;" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 29168, + 27056 + ], + "id": "b169ad1c-478f-4fc9-b80d-2d4d020c86b8", + "name": "Normalizar PDFs descargados" + } + ], + "connections": { + "On form submission": { + "main": [ + [ + { + "node": "Inicializar ejecución", + "type": "main", + "index": 0 + } + ] + ] + }, + "Inicializar ejecución": { + "main": [ + [ + { + "node": "Crear carpeta temporal PDFs", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar Q1": { + "main": [ + [ + { + "node": "¿Q1 es PDF?", + "type": "main", + "index": 0 + } + ] + ] + }, + "¿Q1 es PDF?": { + "main": [ + [ + { + "node": "Extraer texto Q1 PDF", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Crear staging Q1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extraer texto Q1 PDF": { + "main": [ + [ + { + "node": "Parsear Q1 PDF", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parsear Q1 PDF": { + "main": [ + [ + { + "node": "Resultado Q1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Crear staging Q1": { + "main": [ + [ + { + "node": "Reunir staging Q1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Reunir staging Q1": { + "main": [ + [ + { + "node": "Parsear Q1 Excel", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parsear Q1 Excel": { + "main": [ + [ + { + "node": "Resultado Q1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Resultado Q1": { + "main": [ + [ + { + "node": "Preparar Q2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar Q2": { + "main": [ + [ + { + "node": "¿Q2 es PDF?", + "type": "main", + "index": 0 + } + ] + ] + }, + "¿Q2 es PDF?": { + "main": [ + [ + { + "node": "Extraer texto Q2 PDF", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Crear staging Q2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extraer texto Q2 PDF": { + "main": [ + [ + { + "node": "Parsear Q2 PDF", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parsear Q2 PDF": { + "main": [ + [ + { + "node": "Resultado Q2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Crear staging Q2": { + "main": [ + [ + { + "node": "Reunir staging Q2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Reunir staging Q2": { + "main": [ + [ + { + "node": "Parsear Q2 Excel", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parsear Q2 Excel": { + "main": [ + [ + { + "node": "Resultado Q2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Resultado Q2": { + "main": [ + [ + { + "node": "Preparar CSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar CSS": { + "main": [ + [ + { + "node": "Crear staging CSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Crear staging CSS": { + "main": [ + [ + { + "node": "Reunir staging CSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Reunir staging CSS": { + "main": [ + [ + { + "node": "Parsear CSS", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parsear CSS": { + "main": [ + [ + { + "node": "Listar PDFs temporales", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extraer texto PDFs": { + "main": [ + [ + { + "node": "Parsear PDFs", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parsear PDFs": { + "main": [ + [ + { + "node": "Construir reporte producción", + "type": "main", + "index": 0 + } + ] + ] + }, + "Construir reporte producción": { + "main": [ + [ + { + "node": "Crear Google Sheet Reporte", + "type": "main", + "index": 0 + } + ] + ] + }, + "Crear Google Sheet Reporte": { + "main": [ + [ + { + "node": "Escribir reporte", + "type": "main", + "index": 0 + } + ] + ] + }, + "Escribir reporte": { + "main": [ + [ + { + "node": "Preparar formato reporte", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar formato reporte": { + "main": [ + [ + { + "node": "Formatear reporte", + "type": "main", + "index": 0 + } + ] + ] + }, + "Formatear reporte": { + "main": [ + [ + { + "node": "Preparar permisos", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar permisos": { + "main": [ + [ + { + "node": "Compartir reporte como editor", + "type": "main", + "index": 0 + } + ] + ] + }, + "Compartir reporte como editor": { + "main": [ + [ + { + "node": "Preparar correo HTML GLM", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar correo HTML GLM": { + "main": [ + [ + { + "node": "Enviar correo resumen", + "type": "main", + "index": 0 + } + ] + ] + }, + "Enviar correo resumen": { + "main": [ + [ + { + "node": "Preparar limpieza temporales", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar limpieza temporales": { + "main": [ + [ + { + "node": "Eliminar staging temporal", + "type": "main", + "index": 0 + } + ] + ] + }, + "Eliminar staging temporal": { + "main": [ + [ + { + "node": "Preparar confirmación", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar confirmación": { + "main": [ + [ + { + "node": "Confirmación y regreso al formulario", + "type": "main", + "index": 0 + } + ] + ] + }, + "Crear carpeta temporal PDFs": { + "main": [ + [ + { + "node": "Definir Extraordinarias", + "type": "main", + "index": 0 + } + ] + ] + }, + "Definir Extraordinarias": { + "main": [ + [ + { + "node": "¿Hay extraordinarias?", + "type": "main", + "index": 0 + } + ] + ] + }, + "¿Hay extraordinarias?": { + "main": [ + [ + { + "node": "Cargar Extraordinarias", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Cargar Vacaciones", + "type": "main", + "index": 0 + } + ] + ] + }, + "Cargar Extraordinarias": { + "main": [ + [ + { + "node": "Preparar lote Extraordinarias", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar lote Extraordinarias": { + "main": [ + [ + { + "node": "Guardar lote Extraordinarias", + "type": "main", + "index": 0 + } + ] + ] + }, + "Guardar lote Extraordinarias": { + "main": [ + [ + { + "node": "Cerrar lote Extraordinarias", + "type": "main", + "index": 0 + } + ] + ] + }, + "Cerrar lote Extraordinarias": { + "main": [ + [ + { + "node": "¿Agregar más Extraordinarias?", + "type": "main", + "index": 0 + } + ] + ] + }, + "¿Agregar más Extraordinarias?": { + "main": [ + [ + { + "node": "Cargar Extraordinarias", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Cargar Vacaciones", + "type": "main", + "index": 0 + } + ] + ] + }, + "Cargar Vacaciones": { + "main": [ + [ + { + "node": "Preparar lote Vacaciones", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar lote Vacaciones": { + "main": [ + [ + { + "node": "Guardar lote Vacaciones", + "type": "main", + "index": 0 + } + ] + ] + }, + "Guardar lote Vacaciones": { + "main": [ + [ + { + "node": "Cerrar lote Vacaciones", + "type": "main", + "index": 0 + } + ] + ] + }, + "Cerrar lote Vacaciones": { + "main": [ + [ + { + "node": "¿Agregar más Vacaciones?", + "type": "main", + "index": 0 + } + ] + ] + }, + "¿Agregar más Vacaciones?": { + "main": [ + [ + { + "node": "Cargar Vacaciones", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Cargar Liquidaciones", + "type": "main", + "index": 0 + } + ] + ] + }, + "Cargar Liquidaciones": { + "main": [ + [ + { + "node": "Preparar lote Liquidaciones", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar lote Liquidaciones": { + "main": [ + [ + { + "node": "Guardar lote Liquidaciones", + "type": "main", + "index": 0 + } + ] + ] + }, + "Guardar lote Liquidaciones": { + "main": [ + [ + { + "node": "Cerrar lote Liquidaciones", + "type": "main", + "index": 0 + } + ] + ] + }, + "Cerrar lote Liquidaciones": { + "main": [ + [ + { + "node": "¿Agregar más Liquidaciones?", + "type": "main", + "index": 0 + } + ] + ] + }, + "¿Agregar más Liquidaciones?": { + "main": [ + [ + { + "node": "Cargar Liquidaciones", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Preparar Q1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Listar PDFs temporales": { + "main": [ + [ + { + "node": "Preparar descarga PDFs", + "type": "main", + "index": 0 + } + ] + ] + }, + "Preparar descarga PDFs": { + "main": [ + [ + { + "node": "Descargar PDFs temporales", + "type": "main", + "index": 0 + } + ] + ] + }, + "Descargar PDFs temporales": { + "main": [ + [ + { + "node": "Normalizar PDFs descargados", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalizar PDFs descargados": { + "main": [ + [ + { + "node": "Extraer texto PDFs", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "authors": "Isaac Aracena", + "name": "Version 6b4a28c8", + "description": "", + "autosaved": true, + "workflowPublishHistory": [ + { + "createdAt": "2026-08-21T21:02:10.870Z", + "id": 5116, + "workflowId": "qxcXg0Z1YASkWgxL", + "versionId": "6b4a28c8-b9fc-42c1-a440-6bd1aae0ab20", + "event": "activated", + "userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029" + } + ] + } +} \ No newline at end of file