Files
rutero/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json
T

403 lines
18 KiB
JSON

{
"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": []
}