feat: agregar generación de reportes PDF y recomendaciones para PDVs sin asignar

This commit is contained in:
2026-09-26 10:10:52 -04:00
parent 23e01f398b
commit b3d6653143
49 changed files with 1635 additions and 906 deletions
+141
View File
@@ -0,0 +1,141 @@
{
"name": "Generar reporte de rutas PDF",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "generar-rutas-pdf",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [-600, 260],
"id": "e91ef4ce-4c71-4a7b-91d7-1a4f1c2db015",
"name": "Webhook: Generate PDF"
},
{
"parameters": {
"jsCode": "const payload = $json.body && typeof $json.body === 'object' ? $json.body : $json;\nconst errors = [];\nif (!payload?.project?.id || !payload?.project?.name) errors.push('Se requiere la información del proyecto.');\nif (!Array.isArray(payload?.assignedRoutes)) errors.push('assignedRoutes debe ser una lista.');\nif (!Array.isArray(payload?.unassignedPdvs)) errors.push('unassignedPdvs debe ser una lista.');\nif ((payload?.assignedRoutes?.length ?? 0) + (payload?.unassignedPdvs?.length ?? 0) > 5000) errors.push('El reporte excede el máximo de 5,000 registros.');\nif (errors.length) throw new Error(errors.join(' '));\nreturn [{ json: { payload } }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [-360, 260],
"id": "abbd2d6e-5c04-4bbb-ae1b-e4f0d2e4574e",
"name": "Validate Report Payload",
"onError": "continueErrorOutput"
},
{
"parameters": {
"jsCode": "const payload = $json.payload;\nconst escape = value => String(value ?? '').replace(/[&<>\"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[char]));\nconst filters = Object.entries(payload.filters ?? {}).filter(([, value]) => value).map(([key, value]) => `<span>${escape(key)}: ${escape(value)}</span>`).join('');\nconst routeRows = payload.assignedRoutes.map(route => `<tr><td>${escape(route.usuario)}</td><td>${escape(route.dia)}</td><td>${escape(route.orden)}</td><td>${escape(route.pdv)}</td><td>${escape(route.origen)}</td><td>${escape(route.distanciaKm)} km</td><td>${escape(route.horasTrabajo)} h</td></tr>`).join('') || '<tr><td colspan=\"7\">Sin rutas asignadas para los filtros seleccionados.</td></tr>';\nconst unassignedRows = payload.unassignedPdvs.map(route => `<tr><td>${escape(route.pdv_id)}</td><td>${escape(route.pdv_nombre)}</td><td>${escape(route.minimo_horas_semana)} h</td><td>${escape(route.motivo)}</td><td>${escape(route.recomendacion)}</td></tr>`).join('') || '<tr><td colspan=\"5\">No hay PDVs sin asignar para los filtros seleccionados.</td></tr>';\nconst summary = payload.summary ?? {};\nconst html = `<!doctype html><html><head><meta charset=\"utf-8\"><style>body{font-family:Arial,sans-serif;color:#243746;margin:28px;font-size:11px}h1{color:#4F758B;margin:0}h2{color:#4F758B;border-bottom:2px solid #6CC24A;padding-bottom:6px;margin-top:28px}p{line-height:1.45}.meta{color:#61798a}.filters span{display:inline-block;background:#eef6e8;border:1px solid #a4d65e;border-radius:3px;margin:3px 5px 3px 0;padding:4px 6px}.summary{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:15px 0}.metric{border:1px solid #d0d0d0;border-left:3px solid #6CC24A;padding:8px}.metric b{display:block;color:#4F758B;font-size:16px}table{width:100%;border-collapse:collapse;margin-top:10px}th{background:#4F758B;color:#fff;text-align:left;padding:7px}td{border-bottom:1px solid #e5e5e5;padding:7px;vertical-align:top}.footer{margin-top:24px;color:#6b8fa3;font-size:9px}</style></head><body><h1>Reporte de rutas</h1><p class=\"meta\"><b>${escape(payload.project.name)}</b><br>${escape(payload.project.country)}${payload.project.description ? `<br>${escape(payload.project.description)}` : ''}<br>Generado: ${escape(payload.generatedAt)}</p>${filters ? `<div class=\"filters\"><b>Filtros aplicados:</b><br>${filters}</div>` : '<p><b>Filtros aplicados:</b> Ninguno</p>'}<div class=\"summary\"><div class=\"metric\"><b>${escape(summary.totalPdvs)}</b>PDVs totales</div><div class=\"metric\"><b>${escape(summary.assignedPdvs)}</b>PDVs asignados</div><div class=\"metric\"><b>${escape(summary.unassignedPdvs)}</b>PDVs sin asignar</div><div class=\"metric\"><b>${escape(summary.coveragePercent)}%</b>Cobertura</div><div class=\"metric\"><b>${escape(summary.totalDistanceKm)} km</b>Distancia</div><div class=\"metric\"><b>${escape(summary.totalWorkHours)} h</b>Trabajo</div></div><h2>Rutas asignadas</h2><table><thead><tr><th>Usuario</th><th>Día</th><th>Orden</th><th>PDV</th><th>Origen</th><th>Distancia</th><th>Trabajo</th></tr></thead><tbody>${routeRows}</tbody></table><h2>PDVs sin asignar</h2><table><thead><tr><th>PDV ID</th><th>PDV</th><th>Horas/semana</th><th>Motivo</th><th>Recomendación</th></tr></thead><tbody>${unassignedRows}</tbody></table><p class=\"footer\">Route Planner · GomezLee Marketing</p></body></html>`;\nreturn [{ json: { fileName: `rutas_${String(payload.project.name).replace(/[^a-z0-9]+/gi, '_').toLowerCase()}.pdf` }, binary: { data: { data: Buffer.from(html).toString('base64'), mimeType: 'text/html', fileName: 'index.html' } } }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [-100, 260],
"id": "9258c394-683f-4569-8ced-af2337ec8dba",
"name": "Build Report HTML",
"onError": "continueErrorOutput",
"notes": "Genera tablas deterministas; no usa IA para alterar cifras, rutas ni recomendaciones."
},
{
"parameters": {
"method": "POST",
"url": "http://host.docker.internal:3000/forms/chromium/convert/html",
"authentication": "none",
"sendBody": true,
"contentType": "multipart-form-data",
"bodyParameters": {
"parameters": [
{
"parameterType": "formBinaryData",
"name": "files",
"inputDataFieldName": "data"
}
]
},
"options": {
"response": {
"response": {
"responseFormat": "file",
"outputPropertyName": "data"
}
},
"timeout": 120000
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [180, 260],
"id": "73e2759c-d9f9-49dc-925f-3ad1ee7b3897",
"name": "Convert HTML to PDF",
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 3000,
"onError": "continueErrorOutput",
"notes": "Gotenberg debe estar disponible en host.docker.internal:3000 desde el contenedor n8n."
},
{
"parameters": {
"respondWith": "binary",
"responseBinaryPropertyName": "data",
"options": {
"responseHeaders": {
"entries": [
{
"name": "Content-Disposition",
"value": "attachment; filename=\"reporte_rutas.pdf\""
}
]
}
}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [460, 200],
"id": "93928c3e-1e9c-4f77-a644-c156fae907a5",
"name": "Respond PDF"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { error: 'validation_error', message: $json.error?.message || $json.message || 'El reporte no contiene datos válidos.' } }}",
"options": {
"responseCode": 400
}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [-100, 460],
"id": "c8b69f14-45d0-4f55-9f7c-2e3fb3425f14",
"name": "Respond Validation Error"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { error: 'pdf_generation_error', message: 'No se pudo generar el PDF de rutas.', detail: $json.error?.message || $json.message || 'Gotenberg no está disponible.' } }}",
"options": {
"responseCode": 502
}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [460, 430],
"id": "c8d6e363-80b6-46ca-a8c6-32ce393207cc",
"name": "Respond PDF Error"
}
],
"connections": {
"Webhook: Generate PDF": { "main": [[{ "node": "Validate Report Payload", "type": "main", "index": 0 }]] },
"Validate Report Payload": { "main": [[{ "node": "Build Report HTML", "type": "main", "index": 0 }], [{ "node": "Respond Validation Error", "type": "main", "index": 0 }]] },
"Build Report HTML": { "main": [[{ "node": "Convert HTML to PDF", "type": "main", "index": 0 }], [{ "node": "Respond Validation Error", "type": "main", "index": 0 }]] },
"Convert HTML to PDF": { "main": [[{ "node": "Respond PDF", "type": "main", "index": 0 }], [{ "node": "Respond PDF Error", "type": "main", "index": 0 }]] }
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"meta": {
"templateCredsSetupCompleted": true
},
"tags": []
}
@@ -126,7 +126,7 @@
},
{
"parameters": {
"jsCode": "const allRows = $input.all().map(item => item.json);\nif (!allRows.length) {\n return [{ json: { success: false, data: [], message: 'Usuarios: Archivo vacío' } }];\n}\nconst keys = new Set(allRows.flatMap(r => Object.keys(r)));\nconst required = ['ID','Nombre del empleado','Latitud','Longitud','Horas por día usuario','Horas por semana usuario'];\nconst missing = required.filter(c => !keys.has(c));\nlet message = '';\nlet success = true;\nif (missing.length) {\n success = false;\n message = `Usuarios: Columnas faltantes: ${missing.join(', ')}`;\n}\nif (allRows.length > 300) {\n success = false;\n message = 'Usuarios: Máximo 300 filas permitidas';\n}\nreturn [{ json: { success, data: $input.all(), message } }];"
"jsCode": "const allRows = $input.all().map(item => item.json);\nif (!allRows.length) {\n return [{ json: { success: false, data: [], message: 'Usuarios: Archivo vacío' } }];\n}\nconst keys = new Set(allRows.flatMap(r => Object.keys(r)));\nconst required = ['ID','Nombre del empleado','Latitud','Longitud','Horas por día usuario','Horas por semana usuario'];\nconst missing = required.filter(c => !keys.has(c));\nlet message = '';\nlet success = true;\nif (missing.length) {\n success = false;\n message = `Usuarios: Columnas faltantes: ${missing.join(', ')}`;\n}\nif (allRows.length > 500) {\n success = false;\n message = 'Usuarios: Máximo 500 filas permitidas';\n}\nreturn [{ json: { success, data: $input.all(), message } }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
@@ -151,8 +151,10 @@
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ error: $json[0].message || $json[1].message || 'Error de validación' }) }}",
"options": {}
"responseBody": "={{ { error: $json.error?.message || $json.error || $json.message || 'Error de validación' } }}",
"options": {
"responseCode": 400
}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,