diff --git a/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json b/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json new file mode 100644 index 0000000..fff7a72 --- /dev/null +++ b/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json @@ -0,0 +1,403 @@ +{ + "name": "OptimizadorRutasPDV TEST - Fixed v2", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "calcular-camino", + "responseMode": "responseNode", + "options": {} + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + -736, + 544 + ], + "id": "f84fe2f2-2875-4e5c-ad99-4b4cc7a2c8d2", + "name": "Webhook", + "webhookId": "01fc4a9c-bad3-42b8-9f60-b87d91302502" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{$binary.pdv_file.fileName}}", + "rightValue": "", + "operator": { + "type": "string", + "operation": "exists", + "singleValue": true + } + }, + { + "id": "c2", + "leftValue": "={{$binary.usuarios_file.fileName}}", + "rightValue": "", + "operator": { + "type": "string", + "operation": "exists", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + -496, + 544 + ], + "id": "5ecfc62e-849c-442b-90b8-4885494e3820", + "name": "Validate Files" + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "{\n \"error\": \"Debe subir ambos archivos: PDV y Usuarios\"\n}", + "options": {} + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + -496, + 768 + ], + "id": "2c0adaf5-050d-4c2e-a509-ed77cc5a6784", + "name": "Error: Missing Files" + }, + { + "parameters": { + "operation": "xlsx", + "binaryPropertyName": "pdv_file", + "options": { + "headerRow": true + } + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + -160, + 416 + ], + "id": "90f15538-1ae9-454b-9038-b3d3cbf39f00", + "name": "Extract PDV" + }, + { + "parameters": { + "operation": "xlsx", + "binaryPropertyName": "usuarios_file", + "options": { + "headerRow": true + } + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + -160, + 640 + ], + "id": "a8a72a9e-7e38-486a-9db2-7efb130c6963", + "name": "Extract Usuarios" + }, + { + "parameters": { + "jsCode": "const allRows = $input.all().map(item => item.json);\nif (!allRows.length) {\n return [{ json: { success: false, data: [], message: 'PDV: Archivo vacío' } }];\n}\nconst keys = new Set(allRows.flatMap(r => Object.keys(r)));\nconst required = ['ID','Nombre del PDV','Segmentación','Latitud','Longitud','Visitas semanales','Duración visita(horas)','Prioridad'];\nconst missing = required.filter(c => !keys.has(c));\nlet message = '';\nlet success = true;\nif (missing.length) {\n success = false;\n message = `PDV: Columnas faltantes: ${missing.join(', ')}`;\n}\nif (allRows.length > 2000) {\n success = false;\n message = 'PDV: Máximo 2000 filas permitidas';\n}\nreturn [{ json: { success, data: $input.all(), message } }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 80, + 416 + ], + "id": "e7acdf1f-4741-4189-91b9-30a90d42b531", + "name": "Validate PDV" + }, + { + "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 > 50) {\n success = false;\n message = 'Usuarios: Máximo 50 filas permitidas';\n}\nreturn [{ json: { success, data: $input.all(), message } }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 80, + 640 + ], + "id": "0d734c5f-bf19-4eaa-9d82-16657523b53c", + "name": "Validate Usuarios" + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 336, + 544 + ], + "id": "e13702a2-1b82-4a1f-bba6-e8880c8346aa", + "name": "Merge Validations" + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ JSON.stringify({ error: $json[0].message || $json[1].message || 'Error de validación' }) }}", + "options": {} + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 576, + 768 + ], + "id": "d496c883-3206-47f5-89f4-f829ed167845", + "name": "Error: Validation Failed" + }, + { + "parameters": { + "jsCode": "const items = $input.all();\n\nconst pdvResult = items[0]?.json;\nconst userResult = items[1]?.json;\n\nif (!pdvResult?.success) {\n return [{ json: { error: pdvResult?.message || 'Error validación PDVs' } }];\n}\nif (!userResult?.success) {\n return [{ json: { error: userResult?.message || 'Error validación Usuarios' } }];\n}\n\nconst pdvsRaw = pdvResult.data.map(item => item.json);\nconst usuariosRaw = userResult.data.map(item => item.json);\n\nif (!pdvsRaw.length || !usuariosRaw.length) {\n return [{ json: { error: 'Archivos vacíos o sin datos válidos' } }];\n}\n\nconst VELOCIDAD_DESPLAZAMIENTO = 46;\nconst asignaciones = [];\nconst PDVnoAsignados = [];\n\nfunction calcularDistancia(lat1, lon1, lat2, lon2) {\n const R = 6371;\n const dLat = (lat2 - lat1) * Math.PI / 180;\n const dLon = (lon2 - lon1) * Math.PI / 180;\n const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180) * Math.cos(lat2*Math.PI/180) * Math.sin(dLon/2)**2;\n return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));\n}\n\nfunction formatHeader(value) {\n return value.toLowerCase()\n .normalize('NFD').replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^a-z0-9 ]/gi, '').trim().replace(/\\s+/g, '_');\n}\n\nfunction cleanNum(val) {\n if (val === null || val === undefined) return 0;\n if (typeof val === 'number') return val;\n return parseFloat(String(val).replace(/[^0-9.\\-]/g, '')) || 0;\n}\n\nfunction renameHeaders(rows) {\n return rows.map(row => {\n const newRow = {};\n for (const key in row) {\n const newKey = formatHeader(key);\n let val = row[key];\n if (typeof val === 'string') val = val.trim();\n newRow[newKey] = val;\n }\n return newRow;\n });\n}\n\nconst pdvs = renameHeaders(pdvsRaw);\nconst usuarios = renameHeaders(usuariosRaw);\n\n// Mapear columnas del Excel a nombres internos\nfunction mapPdv(p) {\n return {\n id: p.id,\n nombre_del_pdv: p.nombre_del_pdv,\n segmentacion: p.segmentacion || 'D',\n latitud: cleanNum(p.latitud),\n longitud: cleanNum(p.longitud),\n visitas_semanales: cleanNum(p.visitas_semanales),\n duracion_visita_horas: cleanNum(p.duracion_visitahoras),\n prioridad: cleanNum(p.prioridad),\n };\n}\n\nfunction mapUsuario(u) {\n return {\n id: u.id,\n nombre_del_empleado: u.nombre_del_empleado,\n latitud: cleanNum(u.latitud),\n longitud: cleanNum(u.longitud),\n horas_por_dia_usuario: cleanNum(u.horas_por_dia_usuario),\n horas_por_semana_usuario: cleanNum(u.horas_por_semana_usuario),\n };\n}\n\nconst pdvsMapeados = pdvs.map(mapPdv);\nconst usuariosMapeados = usuarios.map(mapUsuario);\n\nfunction asignarPdvUsuarios() {\n const orden = ['AA', 'A', 'B', 'C', 'D'];\n const horasDisp = {};\n const horasNecesita = {};\n\n for (const u of usuariosMapeados) horasDisp[u.id] = u.horas_por_semana_usuario;\n for (const p of pdvsMapeados) horasNecesita[p.id] = p.duracion_visita_horas * p.visitas_semanales;\n\n const pdvsSort = [...pdvsMapeados].sort((a, b) => {\n const idxA = orden.indexOf(a.segmentacion);\n const idxB = orden.indexOf(b.segmentacion);\n return idxA - idxB || a.prioridad - b.prioridad;\n });\n\n for (const pdv of pdvsSort) {\n if (horasNecesita[pdv.id] <= 0) {\n PDVnoAsignados.push({ pdv_id: pdv.id, pdv_nombre: pdv.nombre_del_pdv, minimo_horas_semana: horasNecesita[pdv.id], latitud: pdv.latitud, longitud: pdv.longitud, motivo: 'Sin horas requeridas' });\n continue;\n }\n\n let pdvAsignado = false;\n const candidatos = usuariosMapeados\n .map(u => ({\n usuario: u,\n distancia: calcularDistancia(u.latitud, u.longitud, pdv.latitud, pdv.longitud)\n }))\n .filter(c => c.distancia <= 50)\n .sort((a, b) => a.distancia - b.distancia);\n\n for (const cand of candidatos) {\n if (pdvAsignado) break;\n const u = cand.usuario;\n const horasUsuario = horasDisp[u.id];\n const horasPdv = horasNecesita[pdv.id];\n const horasAsignadas = Math.min(horasUsuario, horasPdv);\n\n if (horasAsignadas <= 0) continue;\n\n asignaciones.push({\n usuario_id: u.id,\n pdv_id: pdv.id,\n distancia: cand.distancia,\n horas_asignadas: horasAsignadas,\n horasDiariasUsuario: u.horas_por_dia_usuario\n });\n horasDisp[u.id] -= horasAsignadas;\n horasNecesita[pdv.id] -= horasAsignadas;\n pdvAsignado = true;\n }\n\n if (!pdvAsignado) {\n PDVnoAsignados.push({ pdv_id: pdv.id, pdv_nombre: pdv.nombre_del_pdv, minimo_horas_semana: pdv.duracion_visita_horas * pdv.visitas_semanales, latitud: pdv.latitud, longitud: pdv.longitud, motivo: 'Sin usuario disponible dentro de 50km' });\n }\n }\n}\n\nfunction asignarDiasHorasVisita() {\n const DIAS = ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes'];\n const visitas = [];\n for (const a of asignaciones) {\n let horasRestantes = a.horas_asignadas;\n for (let i = 0; i < DIAS.length; i++) {\n const tiempoDesplazamiento = a.distancia / VELOCIDAD_DESPLAZAMIENTO;\n const horasDia = horasRestantes / (DIAS.length - i);\n if (horasDia <= a.horasDiariasUsuario) {\n visitas.push({\n usuario_id: a.usuario_id,\n pdv_id: a.pdv_id,\n dia: DIAS[i],\n horas_trabajo: Number(horasDia.toFixed(2)),\n horas_desplazamiento: Number(tiempoDesplazamiento.toFixed(2)),\n distancia: Number(a.distancia.toFixed(2)),\n });\n horasRestantes -= horasDia;\n } else {\n PDVnoAsignados.push({ pdv_id: a.pdv_id, pdv_nombre: '', minimo_horas_semana: 0, latitud: 0, longitud: 0, motivo: 'Excedió horas por día del usuario' });\n }\n }\n }\n return visitas;\n}\n\nasignarPdvUsuarios();\nconst visitas = asignarDiasHorasVisita();\n\nconst usuariosById = Object.fromEntries(usuariosMapeados.map(u => [u.id, u]));\nconst pdvsById = Object.fromEntries(pdvsMapeados.map(p => [p.id, p]));\n\nconst resultadoFinal = visitas.map(v => {\n const u = usuariosById[v.usuario_id];\n const p = pdvsById[v.pdv_id];\n if (!u || !p) return null;\n return {\n usuario_id: v.usuario_id,\n nombre_usuario: u.nombre_del_empleado,\n latitud_usuario: u.latitud,\n longitud_usuario: u.longitud,\n hora_laboral_semanal_usuario: u.horas_por_semana_usuario,\n pdv_id: v.pdv_id,\n nombre_pdv: p.nombre_del_pdv,\n latitud_pdv: p.latitud,\n longitud_pdv: p.longitud,\n hora_minima_semanal_pdv: p.duracion_visita_horas * p.visitas_semanales,\n dia: v.dia,\n horas_trabajo: v.horas_trabajo,\n horas_desplazamiento: v.horas_desplazamiento,\n distancia: v.distancia,\n };\n}).filter(Boolean);\n\nconst a = resultadoFinal.map(r => ({ json: r }));\nconst bFiltrado = PDVnoAsignados.filter(pdv => !a.some(x => x.json.pdv_id === pdv.pdv_id));\n\nreturn [{ assigned: a }, { noAssigned: bFiltrado }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 816, + 544 + ], + "id": "e9fe9826-4755-497e-ae6e-274e7c1d4f66", + "name": "Optimize Routes" + }, + { + "parameters": { + "respondWith": "allIncomingItems", + "options": {} + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 1024, + 544 + ], + "id": "b024c203-1b36-4f7c-97bb-3923481b7a9b", + "name": "Respond Success" + }, + { + "parameters": { + "jsCode": "const items = $input.all();\nconst pdv = items[0]; // Validate PDV output\nconst usuarios = items[1]; // Validate Usuarios output\n\nconst pdvOk = pdv?.json?.success === true;\nconst usuariosOk = usuarios?.json?.success === true;\n\nif (pdvOk && usuariosOk) {\n // Both passed → send to Optimize Routes (output 0)\n return items;\n} else {\n // Failed → send to Error (output 1)\n const msgs = [];\n if (!pdvOk) msgs.push(pdv?.json?.message || 'Error PDV');\n if (!usuariosOk) msgs.push(usuarios?.json?.message || 'Error Usuarios');\n throw new Error(msgs.join(' | '));\n}" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 576, + 544 + ], + "id": "27eb73de-4b08-4c44-97de-4d122a98749c", + "name": "Code in JavaScript", + "onError": "continueErrorOutput" + } + ], + "pinData": { + "Webhook": [ + { + "json": { + "headers": { + "host": "agenteit.digitalcompass.agency", + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36", + "content-length": "19829", + "accept": "application/json, text/plain, */*", + "accept-encoding": "gzip, deflate, br, zstd", + "accept-language": "en-US,en;q=0.6", + "content-type": "multipart/form-data; boundary=----WebKitFormBoundaryeTQ0nhU3IDSv0jrS", + "origin": "http://localhost:4200", + "priority": "u=1, i", + "referer": "http://localhost:4200/", + "sec-ch-ua": "\"Not=A?Brand\";v=\"99\", \"Brave\";v=\"151\", \"Chromium\";v=\"151\"", + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": "\"Windows\"", + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "cross-site", + "sec-gpc": "1", + "x-forwarded-for": "152.0.172.53", + "x-forwarded-host": "agenteit.digitalcompass.agency", + "x-forwarded-port": "443", + "x-forwarded-proto": "https", + "x-forwarded-server": "07b4f09d2c65", + "x-real-ip": "152.0.172.53" + }, + "params": {}, + "query": {}, + "body": {}, + "webhookUrl": "https://agenteit.digitalcompass.agency/webhook/calcular-camino", + "executionMode": "production" + }, + "binary": { + "pdv_file": { + "mimeType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "fileExtension": "xlsx", + "data": "filesystem-v2", + "fileName": "Plantilla_PDV (2).xlsx", + "id": "filesystem-v2:workflows/IkKMqgb7uZuRk9d9/executions/48469/binary_data/48a414e1-9c74-469b-9bec-04283032f093", + "fileSize": "9.53 kB", + "bytes": 9528 + }, + "usuarios_file": { + "mimeType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "fileExtension": "xlsx", + "data": "filesystem-v2", + "fileName": "Plantilla_Usuarios (2).xlsx", + "id": "filesystem-v2:workflows/IkKMqgb7uZuRk9d9/executions/48469/binary_data/6553aefd-4d40-45dd-9cdd-bfb1b1d1ec84", + "fileSize": "9.82 kB", + "bytes": 9825 + } + }, + "pairedItem": { + "item": 0 + } + } + ] + }, + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Validate Files", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validate Files": { + "main": [ + [ + { + "node": "Extract PDV", + "type": "main", + "index": 0 + }, + { + "node": "Extract Usuarios", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Error: Missing Files", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract PDV": { + "main": [ + [ + { + "node": "Validate PDV", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Usuarios": { + "main": [ + [ + { + "node": "Validate Usuarios", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validate PDV": { + "main": [ + [ + { + "node": "Merge Validations", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validate Usuarios": { + "main": [ + [ + { + "node": "Merge Validations", + "type": "main", + "index": 1 + } + ] + ] + }, + "Merge Validations": { + "main": [ + [ + { + "node": "Code in JavaScript", + "type": "main", + "index": 0 + } + ] + ] + }, + "Optimize Routes": { + "main": [ + [ + { + "node": "Respond Success", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code in JavaScript": { + "main": [ + [ + { + "node": "Optimize Routes", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Error: Validation Failed", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": true, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate", + "timeSavedMode": "fixed", + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": true + }, + "versionId": "f0dcd3f6-8043-4c22-9ca9-a477ae69db53", + "meta": { + "instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70" + }, + "id": "IkKMqgb7uZuRk9d9", + "tags": [] +} \ No newline at end of file