From 418e8ee3267609f5fd57d2420f36b91cd81c88e2 Mon Sep 17 00:00:00 2001 From: Isaac_Aracena Date: Sun, 9 Aug 2026 04:02:15 +0000 Subject: [PATCH] Create backup: OptimizadorRutasPDV --- optimizadorrutaspdv.json | 711 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 711 insertions(+) create mode 100644 optimizadorrutaspdv.json diff --git a/optimizadorrutaspdv.json b/optimizadorrutaspdv.json new file mode 100644 index 0000000..d379cea --- /dev/null +++ b/optimizadorrutaspdv.json @@ -0,0 +1,711 @@ +{ + "updatedAt": "2026-08-06T18:47:32.610Z", + "createdAt": "2026-08-06T16:00:27.309Z", + "id": "zxxyoFdmr1rxh4nY", + "name": "OptimizadorRutasPDV", + "description": null, + "active": true, + "isArchived": false, + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "calculate-path", + "responseMode": "responseNode", + "options": {} + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + -672, + 32 + ], + "id": "8cbfb784-e954-4dd7-9cb4-8909cb0d69fe", + "name": "Webhook", + "webhookId": "01fc4a9c-bad3-42b8-9f60-b87d91302502" + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "{\n \"error\": \"Debe subir ambos archivos: PDV y Usuarios\"\n}", + "options": {} + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + -432, + 256 + ], + "id": "9496a4b3-b2fb-465b-a6b8-b4d9ef2bc9be", + "name": "Respond to Webhook" + }, + { + "parameters": { + "operation": "xlsx", + "binaryPropertyName": "pdv_file", + "options": { + "headerRow": true + } + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + 0, + 0 + ], + "id": "d50b795b-cc21-44b7-be0c-015c8646036b", + "name": "Extract from File" + }, + { + "parameters": { + "operation": "xlsx", + "binaryPropertyName": "usuarios_file", + "options": { + "headerRow": true + } + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + 0, + 224 + ], + "id": "aac005f8-ecfa-4454-8680-414cd23987c9", + "name": "Extract from File1" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "c1b94e9c-91e1-493c-a5cf-2299e59c47e7", + "leftValue": "={{$binary.pdv_file.fileName}}", + "rightValue": "", + "operator": { + "type": "string", + "operation": "exists", + "singleValue": true + } + }, + { + "id": "c56ec67c-765c-4534-9a0b-61e80c5e59d9", + "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": [ + -432, + 32 + ], + "id": "6a77e249-921e-4b07-a43f-edf6d93462bd", + "name": "Validate" + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 480, + 128 + ], + "id": "87b886db-94cc-4235-a57c-2acd985f818e", + "name": "Merge" + }, + { + "parameters": { + "jsCode": " \n/* =====================================================\nREGLAS\n- no puede exceder de la hora por dia\n- un pdv no puede asginarse a 2 personas\n- el pdv debe asignarse primero con la coordenada del usuario y luego el mas cercano entre ellos\n- los pdv tienen un limite por semana \n- regla por segmentacion por el orden de importancia tomando como prioridad AA\n- una vez se crea el archivo te muestre un ligero tablero que se pueda filtrar por usuario pdv y lo que mostrar es la ruta en el mapa\n- filtro: por dia segun rango de fecha\n- asignacion de la ruta diaria y esa ruta debe cortar cada semana debe ser basada en 4 semana\n===================================================== */\n \nconst items = $input.all();\n \nvar pdvs = items[0].json.data;\nvar usuarios = items[1].json.data; \n \npdvs = renameHeaders(pdvs);\nusuarios = renameHeaders(usuarios); \nconst VELOCIDAD_DESPLAZAMIENTO = 46; \n \nconst asignaciones = []; // km/h\nvar PDVnoAsignados = [];\nconst pdvMenor8 = [];\nconst usuarioMenor8 = [];\n// Función Haversine\nfunction calcularDistancia(lat1, lon1, lat2, lon2) {\n const R = 6371; // Radio de la Tierra en km\n\n const dLat = (lat2 - lat1) * Math.PI / 180;\n const dLon = (lon2 - lon1) * Math.PI / 180;\n\n const a =\n Math.sin(dLat / 2) * Math.sin(dLat / 2) +\n Math.cos(lat1 * Math.PI / 180) *\n Math.cos(lat2 * Math.PI / 180) *\n Math.sin(dLon / 2) * Math.sin(dLon / 2);\n\n const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));\n\n return R * c; // kilómetros\n};\nfunction formatHeader(value) {\n return value\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^a-zA-Z0-9 ]/g, '')\n .trim()\n .replace(/\\s+/g, '_');\n}\nfunction renameHeaders(parm) {\n return parm.map(row => {\n const newRow = {};\n const res = row.json;\n\n for (const key in res) {\n newRow[formatHeader(key)] = res[key];\n }\n\n return newRow;\n });\n}\n \nfunction asignarPdvUsuarios() {\n const orden = [\"AA\", \"A\", \"B\", \"C\", \"D\"];\n var mensaje= \"\";\n // Inicializar horas disponibles reales\n const horasDisponiblesUsuarios = {};\n const horasDisponiblesPdvs = {};\n \n for (const usuario of usuarios) { \n horasDisponiblesUsuarios[usuario.id] =\n usuario.horas_por_semana_usuario;\n }\n\n for (const pdv of pdvs) {\n horasDisponiblesPdvs[pdv.id] =\n pdv.minimo_horas_semana;\n }\n\n const pdvsSort = pdvs.sort(\n (a, b) => orden.indexOf(a.segmentacion) - orden.indexOf(b.segmentacion)\n );\n\n for (const pdv of pdvsSort) {\n\n let pdvAsignado = false; \n \n if (horasDisponiblesPdvs[pdv.id] <= 0) {\n obtenerPdvsNoAsignadosConMotivo(false, pdv, \"Sin horas semanles\");\n continue;\n }\n const usuariosCandidatos = createCandidate(usuarios, pdv);\n usuariosCandidatos.sort((a, b) => a.distancia - b.distancia);\n \n for (const u of usuariosCandidatos) { \n \n if (pdvAsignado) {\n continue; \n }\n if(u.distancia > 50) {\n mensaje = \"PDV sin asignación: no hay usuarios disponible\";\n continue;\n } \n const horasUsuario = horasDisponiblesUsuarios[u.usuario_id];\n const horasPdv = horasDisponiblesPdvs[u.pdv_id];\n \n const horasAsignadas = Math.min(horasUsuario, horasPdv); \n \n if(horasUsuario >= 1 && horasUsuario <= 8){ \n usuarioMenor8.push({\n id: u.usuario_id, \n latitud: u.latitud_usuario,\n longitud: u.longitud_usuario,\n horas_por_dia_usuario: u.horasDiariasUsuario,\n horas_por_semana_usuario: horasAsignadas \n }); \n } \n \n if (horasAsignadas <= 0) {\n continue;\n } \n if(horasAsignadas >= 9){\n asignaciones.push({\n usuario_id: u.usuario_id,\n pdv_id: pdv.id,\n distancia: u.distancia,\n horas_asignadas: horasAsignadas,\n horasDiariasUsuario: u.horasDiariasUsuario\n });\n } \n horasDisponiblesUsuarios[u.usuario_id] -= horasAsignadas; \n pdvAsignado = true; \n } \n obtenerPdvsNoAsignadosConMotivo(pdvAsignado, pdv, mensaje); \n }\nif (usuarioMenor8.length > 0 && pdvMenor8.length > 0) {\n \n let pdvDisponibles = [...pdvMenor8];\n\n for (const um of usuarioMenor8) {\n\n const filtrados = pdvDisponibles.filter(item =>\n item.minimo_horas_semana <= 8 &&\n calcularDistancia(um.latitud,um.longitud,item.latitud,item.longitud) < 20);\n\n if (!filtrados.length) continue;\n\n const masCercano = filtrados.reduce((a, b) => {\n const distA = calcularDistancia(um.latitud,um.longitud,a.latitud,a.longitud);\n const distB = calcularDistancia(um.latitud,um.longitud,b.latitud,b.longitud);\n\n return distB < distA ? b : a;\n }); \n\n asignaciones.push({\n usuario_id: um.id,\n pdv_id: masCercano.id,\n distancia: 0,\n horas_asignadas: um.horas_por_semana_usuario,\n horasDiariasUsuario: um.horas_por_dia_usuario\n });\n \n pdvDisponibles = pdvDisponibles.filter(pdv => pdv.id !== masCercano.id);\n }\n}\n \n}\nfunction createCandidate(u,p){\n return u.map(usuario => {\n const distancia = calcularDistancia(\n usuario.latitud,\n usuario.longitud,\n p.latitud,\n p.longitud\n );\n \n return {\n latitud_usuario: usuario.latitud,\n longitud_usuario: usuario.longitud,\n horasDiariasUsuario: usuario.horas_por_dia_usuario,\n minimoHorasSemana: p.minimo_horas_semana,\n horasAsignadasUsuario: usuario.horas_por_semana_usuario,\n usuario_id: usuario.id,\n pdv_id: p.id,\n distancia\n };\n });\n\n} \nfunction obtenerPdvsNoAsignadosConMotivo(asignable, pdv, mensaje) { \n if (!asignable) {\n let motivoFinal = mensaje; \n pdvMenor8.push(pdv);\n \n PDVnoAsignados.push({\n pdv_id: pdv.id, \n pdv_nombre: pdv.nombre_del_pdv,\n minimo_horas_semana: pdv.minimo_horas_semana,\n latitud: Number(pdv.latitud),\n longitud: Number(pdv.longitud),\n motivo: motivoFinal\n });\n } \n }\nfunction asignarDiasHorasVisita() {\n var visitas = []; \n const DIAS_LABORALES = [\"Lunes\", \"Martes\", \"Miercoles\", \"Jueves\", \"Viernes\"]; \n \n for (var a of asignaciones) { \n \n let horasRestantes = a.horas_asignadas\n for (let i = 0; i < DIAS_LABORALES.length; i++) {\n \n //var horasTrabajadasDia = 0 \n var tiempoDesplazamiento = (a.distancia / VELOCIDAD_DESPLAZAMIENTO); \n var horaMinimaPdvDiaria = horasRestantes / (DIAS_LABORALES.length - i);\n \n if(horaMinimaPdvDiaria <= a.horasDiariasUsuario){\n visitas.push({\n usuario_id: a.usuario_id,\n pdv_id: a.pdv_id,\n dia: DIAS_LABORALES[i],\n horas_trabajo: horaMinimaPdvDiaria.toFixed(2),\n horas_desplazamiento: tiempoDesplazamiento,\n distancia: a.distancia,\n });\n horasRestantes -= horaMinimaPdvDiaria;\n }else{ \n obtenerPdvsNoAsignadosConMotivo(false,a, \"Excedió de las horas por dia del usuario\"); \n }\n } \n }\n return visitas;\n}\n \nasignarPdvUsuarios(); \nconst visitas = asignarDiasHorasVisita();\n \nconst usuariosById = Object.fromEntries(\n usuarios.map(u => [u.id, u])\n);\n\nconst pdvsById = Object.fromEntries(\n pdvs.map(p => [p.id, p])\n);\n \nconst resultadoFinal = visitas.map(v => {\n \n const u = usuariosById[v.usuario_id];\n const p = pdvsById[v.pdv_id];\n \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\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.minimo_horas_semana,\n\n dia: v.dia,\n horas_trabajo: v.horas_trabajo,\n horas_desplazamiento: v.horas_desplazamiento,\n distancia: v.distancia,\n };\n}); \n var a = resultadoFinal.map(r => ({ json: r}));\n var b = PDVnoAsignados;\n const bFiltrado = b.filter(pdv =>\n !a.some(x => x.json.pdv_id === pdv.pdv_id)\n ); \nconsole.log(bFiltrado)\nreturn [{ assigned: a },{ noAssigned: bFiltrado }];\n\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 848, + 128 + ], + "id": "06455da2-78d4-4ce9-877a-f10a2ae2351f", + "name": "Code in JavaScript2" + }, + { + "parameters": { + "respondWith": "allIncomingItems", + "options": {} + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 1056, + 128 + ], + "id": "2a6db48c-770e-402c-b926-921e1449e0ff", + "name": "Respond to Webhook3" + }, + { + "parameters": { + "jsCode": " \nconst users = $input.all()[0].json;\nconst usersCols = ['ID','Nombre del empleado']; \nvar isSuccess = true;\nvar message =\"\";\n\nfunction validate(data, cols) {\n\n const keys = Object.keys(data); \n const missing = cols.filter(c => !keys.includes(c));\n \n if (missing.length) {\n isSuccess = false; \n message = `USUARIOS: Las columnas: ${missing.join(', ')} son requeridas.`;\n }\n if ($input.all().length >= 50) {\n isSuccess = false;\n message = \"El máximo permitido es 50 usuarios\"; \n }\n \n}\nvalidate(users, usersCols);\nconsole.log($input.all())\nreturn [{\n json: {\n success: isSuccess,\n data: $input.all(),\n message: message\n }\n}];\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 224, + 224 + ], + "id": "68354521-f16f-4ef5-8134-99ccc628664a", + "name": "Code in JavaScript1" + }, + { + "parameters": { + "jsCode": "const pdvs = $input.all()[0].json;\n \nconst pdvCols = ['ID','Nombre del PDV','Latitud','Longitud','Longitud','Minimo horas semana','Maximo horas semana','Prioridad']; \nvar isSuccess = true;\nvar message = \"\";\n\nfunction validate(data, cols) { \n const keys = Object.keys(data); \n const missing = cols.filter(c => !keys.includes(c));\n \n if (missing.length) {\n isSuccess = false;\n message = `PDVS: Las columnas: ${missing.join(', ')} son requeridas.`; \n } \n if ($input.all().length >= 2000) {\n isSuccess = false;\n message = \"El archivo solo sosporta al menos 2000 PDVs\"; \n }\n \n}\nvalidate(pdvs, pdvCols);\n \nreturn [{\n json: {\n success: isSuccess,\n data: $input.all(),\n message: message\n }\n}];\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 224, + 0 + ], + "id": "6209bff8-03de-4ba6-9236-f51259ff73a8", + "name": "Code in JavaScript" + }, + { + "parameters": { + "content": "## 📥 Recepción de archivos\n\nRecibe la solicitud desde la aplicación y comprueba que se hayan cargado los dos archivos requeridos:\n\n- Archivo de PDV\n- Archivo de usuarios\n\nSi falta alguno, detiene el proceso y devuelve un mensaje de error.", + "height": 592, + "width": 544, + "color": 7 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + -736, + -176 + ], + "id": "be9684c8-f3de-4fa5-a861-ab4e1c5213a2", + "name": "Sticky Note" + }, + { + "parameters": { + "content": "## 📊 Procesamiento de archivos\n\nExtrae la información de ambos archivos XLSX y valida su estructura antes de continuar.\n\nComprueba las columnas obligatorias y los límites permitidos de usuarios y puntos de venta. Luego unifica ambos resultados para ejecutar la optimización.", + "height": 560, + "width": 752, + "color": 6 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + -112, + -160 + ], + "id": "b4bc0cc5-fe4a-4c88-afa5-813a1f111a5d", + "name": "Sticky Note1" + }, + { + "parameters": { + "content": "## 🗺️ Generación de rutas\n\nAsigna los PDV a los usuarios considerando ubicación, distancia, disponibilidad de horas, prioridad y límites semanales.\n\nGenera las visitas por día, identifica los PDV que no pudieron asignarse y devuelve el resultado final a la aplicación.", + "height": 352, + "width": 640, + "color": "#237119" + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 704, + -48 + ], + "id": "a03f0d9d-7a9f-4b05-b042-735036e6a8f4", + "name": "Sticky Note2" + } + ], + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Validate", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract from File": { + "main": [ + [ + { + "node": "Code in JavaScript", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract from File1": { + "main": [ + [ + { + "node": "Code in JavaScript1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validate": { + "main": [ + [ + { + "node": "Extract from File", + "type": "main", + "index": 0 + }, + { + "node": "Extract from File1", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Respond to Webhook", + "type": "main", + "index": 0 + } + ] + ] + }, + "Merge": { + "main": [ + [ + { + "node": "Code in JavaScript2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code in JavaScript2": { + "main": [ + [ + { + "node": "Respond to Webhook3", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code in JavaScript1": { + "main": [ + [ + { + "node": "Merge", + "type": "main", + "index": 1 + } + ] + ] + }, + "Code in JavaScript": { + "main": [ + [ + { + "node": "Merge", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate" + }, + "staticData": null, + "meta": null, + "versionId": "3fab9619-3539-4002-82d8-e399b4f516d9", + "activeVersionId": "3fab9619-3539-4002-82d8-e399b4f516d9", + "versionCounter": 42, + "triggerCount": 1, + "shared": [ + { + "updatedAt": "2026-08-06T16:00:27.315Z", + "createdAt": "2026-08-06T16:00:27.315Z", + "role": "workflow:owner", + "workflowId": "zxxyoFdmr1rxh4nY", + "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-06T18:47:33.000Z", + "createdAt": "2026-08-06T18:47:32.612Z", + "versionId": "3fab9619-3539-4002-82d8-e399b4f516d9", + "workflowId": "zxxyoFdmr1rxh4nY", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "calculate-path", + "responseMode": "responseNode", + "options": {} + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + -672, + 32 + ], + "id": "8cbfb784-e954-4dd7-9cb4-8909cb0d69fe", + "name": "Webhook", + "webhookId": "01fc4a9c-bad3-42b8-9f60-b87d91302502" + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "{\n \"error\": \"Debe subir ambos archivos: PDV y Usuarios\"\n}", + "options": {} + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + -432, + 256 + ], + "id": "9496a4b3-b2fb-465b-a6b8-b4d9ef2bc9be", + "name": "Respond to Webhook" + }, + { + "parameters": { + "operation": "xlsx", + "binaryPropertyName": "pdv_file", + "options": { + "headerRow": true + } + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + 0, + 0 + ], + "id": "d50b795b-cc21-44b7-be0c-015c8646036b", + "name": "Extract from File" + }, + { + "parameters": { + "operation": "xlsx", + "binaryPropertyName": "usuarios_file", + "options": { + "headerRow": true + } + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + 0, + 224 + ], + "id": "aac005f8-ecfa-4454-8680-414cd23987c9", + "name": "Extract from File1" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "c1b94e9c-91e1-493c-a5cf-2299e59c47e7", + "leftValue": "={{$binary.pdv_file.fileName}}", + "rightValue": "", + "operator": { + "type": "string", + "operation": "exists", + "singleValue": true + } + }, + { + "id": "c56ec67c-765c-4534-9a0b-61e80c5e59d9", + "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": [ + -432, + 32 + ], + "id": "6a77e249-921e-4b07-a43f-edf6d93462bd", + "name": "Validate" + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 480, + 128 + ], + "id": "87b886db-94cc-4235-a57c-2acd985f818e", + "name": "Merge" + }, + { + "parameters": { + "jsCode": " \n/* =====================================================\nREGLAS\n- no puede exceder de la hora por dia\n- un pdv no puede asginarse a 2 personas\n- el pdv debe asignarse primero con la coordenada del usuario y luego el mas cercano entre ellos\n- los pdv tienen un limite por semana \n- regla por segmentacion por el orden de importancia tomando como prioridad AA\n- una vez se crea el archivo te muestre un ligero tablero que se pueda filtrar por usuario pdv y lo que mostrar es la ruta en el mapa\n- filtro: por dia segun rango de fecha\n- asignacion de la ruta diaria y esa ruta debe cortar cada semana debe ser basada en 4 semana\n===================================================== */\n \nconst items = $input.all();\n \nvar pdvs = items[0].json.data;\nvar usuarios = items[1].json.data; \n \npdvs = renameHeaders(pdvs);\nusuarios = renameHeaders(usuarios); \nconst VELOCIDAD_DESPLAZAMIENTO = 46; \n \nconst asignaciones = []; // km/h\nvar PDVnoAsignados = [];\nconst pdvMenor8 = [];\nconst usuarioMenor8 = [];\n// Función Haversine\nfunction calcularDistancia(lat1, lon1, lat2, lon2) {\n const R = 6371; // Radio de la Tierra en km\n\n const dLat = (lat2 - lat1) * Math.PI / 180;\n const dLon = (lon2 - lon1) * Math.PI / 180;\n\n const a =\n Math.sin(dLat / 2) * Math.sin(dLat / 2) +\n Math.cos(lat1 * Math.PI / 180) *\n Math.cos(lat2 * Math.PI / 180) *\n Math.sin(dLon / 2) * Math.sin(dLon / 2);\n\n const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));\n\n return R * c; // kilómetros\n};\nfunction formatHeader(value) {\n return value\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^a-zA-Z0-9 ]/g, '')\n .trim()\n .replace(/\\s+/g, '_');\n}\nfunction renameHeaders(parm) {\n return parm.map(row => {\n const newRow = {};\n const res = row.json;\n\n for (const key in res) {\n newRow[formatHeader(key)] = res[key];\n }\n\n return newRow;\n });\n}\n \nfunction asignarPdvUsuarios() {\n const orden = [\"AA\", \"A\", \"B\", \"C\", \"D\"];\n var mensaje= \"\";\n // Inicializar horas disponibles reales\n const horasDisponiblesUsuarios = {};\n const horasDisponiblesPdvs = {};\n \n for (const usuario of usuarios) { \n horasDisponiblesUsuarios[usuario.id] =\n usuario.horas_por_semana_usuario;\n }\n\n for (const pdv of pdvs) {\n horasDisponiblesPdvs[pdv.id] =\n pdv.minimo_horas_semana;\n }\n\n const pdvsSort = pdvs.sort(\n (a, b) => orden.indexOf(a.segmentacion) - orden.indexOf(b.segmentacion)\n );\n\n for (const pdv of pdvsSort) {\n\n let pdvAsignado = false; \n \n if (horasDisponiblesPdvs[pdv.id] <= 0) {\n obtenerPdvsNoAsignadosConMotivo(false, pdv, \"Sin horas semanles\");\n continue;\n }\n const usuariosCandidatos = createCandidate(usuarios, pdv);\n usuariosCandidatos.sort((a, b) => a.distancia - b.distancia);\n \n for (const u of usuariosCandidatos) { \n \n if (pdvAsignado) {\n continue; \n }\n if(u.distancia > 50) {\n mensaje = \"PDV sin asignación: no hay usuarios disponible\";\n continue;\n } \n const horasUsuario = horasDisponiblesUsuarios[u.usuario_id];\n const horasPdv = horasDisponiblesPdvs[u.pdv_id];\n \n const horasAsignadas = Math.min(horasUsuario, horasPdv); \n \n if(horasUsuario >= 1 && horasUsuario <= 8){ \n usuarioMenor8.push({\n id: u.usuario_id, \n latitud: u.latitud_usuario,\n longitud: u.longitud_usuario,\n horas_por_dia_usuario: u.horasDiariasUsuario,\n horas_por_semana_usuario: horasAsignadas \n }); \n } \n \n if (horasAsignadas <= 0) {\n continue;\n } \n if(horasAsignadas >= 9){\n asignaciones.push({\n usuario_id: u.usuario_id,\n pdv_id: pdv.id,\n distancia: u.distancia,\n horas_asignadas: horasAsignadas,\n horasDiariasUsuario: u.horasDiariasUsuario\n });\n } \n horasDisponiblesUsuarios[u.usuario_id] -= horasAsignadas; \n pdvAsignado = true; \n } \n obtenerPdvsNoAsignadosConMotivo(pdvAsignado, pdv, mensaje); \n }\nif (usuarioMenor8.length > 0 && pdvMenor8.length > 0) {\n \n let pdvDisponibles = [...pdvMenor8];\n\n for (const um of usuarioMenor8) {\n\n const filtrados = pdvDisponibles.filter(item =>\n item.minimo_horas_semana <= 8 &&\n calcularDistancia(um.latitud,um.longitud,item.latitud,item.longitud) < 20);\n\n if (!filtrados.length) continue;\n\n const masCercano = filtrados.reduce((a, b) => {\n const distA = calcularDistancia(um.latitud,um.longitud,a.latitud,a.longitud);\n const distB = calcularDistancia(um.latitud,um.longitud,b.latitud,b.longitud);\n\n return distB < distA ? b : a;\n }); \n\n asignaciones.push({\n usuario_id: um.id,\n pdv_id: masCercano.id,\n distancia: 0,\n horas_asignadas: um.horas_por_semana_usuario,\n horasDiariasUsuario: um.horas_por_dia_usuario\n });\n \n pdvDisponibles = pdvDisponibles.filter(pdv => pdv.id !== masCercano.id);\n }\n}\n \n}\nfunction createCandidate(u,p){\n return u.map(usuario => {\n const distancia = calcularDistancia(\n usuario.latitud,\n usuario.longitud,\n p.latitud,\n p.longitud\n );\n \n return {\n latitud_usuario: usuario.latitud,\n longitud_usuario: usuario.longitud,\n horasDiariasUsuario: usuario.horas_por_dia_usuario,\n minimoHorasSemana: p.minimo_horas_semana,\n horasAsignadasUsuario: usuario.horas_por_semana_usuario,\n usuario_id: usuario.id,\n pdv_id: p.id,\n distancia\n };\n });\n\n} \nfunction obtenerPdvsNoAsignadosConMotivo(asignable, pdv, mensaje) { \n if (!asignable) {\n let motivoFinal = mensaje; \n pdvMenor8.push(pdv);\n \n PDVnoAsignados.push({\n pdv_id: pdv.id, \n pdv_nombre: pdv.nombre_del_pdv,\n minimo_horas_semana: pdv.minimo_horas_semana,\n latitud: Number(pdv.latitud),\n longitud: Number(pdv.longitud),\n motivo: motivoFinal\n });\n } \n }\nfunction asignarDiasHorasVisita() {\n var visitas = []; \n const DIAS_LABORALES = [\"Lunes\", \"Martes\", \"Miercoles\", \"Jueves\", \"Viernes\"]; \n \n for (var a of asignaciones) { \n \n let horasRestantes = a.horas_asignadas\n for (let i = 0; i < DIAS_LABORALES.length; i++) {\n \n //var horasTrabajadasDia = 0 \n var tiempoDesplazamiento = (a.distancia / VELOCIDAD_DESPLAZAMIENTO); \n var horaMinimaPdvDiaria = horasRestantes / (DIAS_LABORALES.length - i);\n \n if(horaMinimaPdvDiaria <= a.horasDiariasUsuario){\n visitas.push({\n usuario_id: a.usuario_id,\n pdv_id: a.pdv_id,\n dia: DIAS_LABORALES[i],\n horas_trabajo: horaMinimaPdvDiaria.toFixed(2),\n horas_desplazamiento: tiempoDesplazamiento,\n distancia: a.distancia,\n });\n horasRestantes -= horaMinimaPdvDiaria;\n }else{ \n obtenerPdvsNoAsignadosConMotivo(false,a, \"Excedió de las horas por dia del usuario\"); \n }\n } \n }\n return visitas;\n}\n \nasignarPdvUsuarios(); \nconst visitas = asignarDiasHorasVisita();\n \nconst usuariosById = Object.fromEntries(\n usuarios.map(u => [u.id, u])\n);\n\nconst pdvsById = Object.fromEntries(\n pdvs.map(p => [p.id, p])\n);\n \nconst resultadoFinal = visitas.map(v => {\n \n const u = usuariosById[v.usuario_id];\n const p = pdvsById[v.pdv_id];\n \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\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.minimo_horas_semana,\n\n dia: v.dia,\n horas_trabajo: v.horas_trabajo,\n horas_desplazamiento: v.horas_desplazamiento,\n distancia: v.distancia,\n };\n}); \n var a = resultadoFinal.map(r => ({ json: r}));\n var b = PDVnoAsignados;\n const bFiltrado = b.filter(pdv =>\n !a.some(x => x.json.pdv_id === pdv.pdv_id)\n ); \nconsole.log(bFiltrado)\nreturn [{ assigned: a },{ noAssigned: bFiltrado }];\n\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 848, + 128 + ], + "id": "06455da2-78d4-4ce9-877a-f10a2ae2351f", + "name": "Code in JavaScript2" + }, + { + "parameters": { + "respondWith": "allIncomingItems", + "options": {} + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 1056, + 128 + ], + "id": "2a6db48c-770e-402c-b926-921e1449e0ff", + "name": "Respond to Webhook3" + }, + { + "parameters": { + "jsCode": " \nconst users = $input.all()[0].json;\nconst usersCols = ['ID','Nombre del empleado']; \nvar isSuccess = true;\nvar message =\"\";\n\nfunction validate(data, cols) {\n\n const keys = Object.keys(data); \n const missing = cols.filter(c => !keys.includes(c));\n \n if (missing.length) {\n isSuccess = false; \n message = `USUARIOS: Las columnas: ${missing.join(', ')} son requeridas.`;\n }\n if ($input.all().length >= 50) {\n isSuccess = false;\n message = \"El máximo permitido es 50 usuarios\"; \n }\n \n}\nvalidate(users, usersCols);\nconsole.log($input.all())\nreturn [{\n json: {\n success: isSuccess,\n data: $input.all(),\n message: message\n }\n}];\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 224, + 224 + ], + "id": "68354521-f16f-4ef5-8134-99ccc628664a", + "name": "Code in JavaScript1" + }, + { + "parameters": { + "jsCode": "const pdvs = $input.all()[0].json;\n \nconst pdvCols = ['ID','Nombre del PDV','Latitud','Longitud','Longitud','Minimo horas semana','Maximo horas semana','Prioridad']; \nvar isSuccess = true;\nvar message = \"\";\n\nfunction validate(data, cols) { \n const keys = Object.keys(data); \n const missing = cols.filter(c => !keys.includes(c));\n \n if (missing.length) {\n isSuccess = false;\n message = `PDVS: Las columnas: ${missing.join(', ')} son requeridas.`; \n } \n if ($input.all().length >= 2000) {\n isSuccess = false;\n message = \"El archivo solo sosporta al menos 2000 PDVs\"; \n }\n \n}\nvalidate(pdvs, pdvCols);\n \nreturn [{\n json: {\n success: isSuccess,\n data: $input.all(),\n message: message\n }\n}];\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 224, + 0 + ], + "id": "6209bff8-03de-4ba6-9236-f51259ff73a8", + "name": "Code in JavaScript" + }, + { + "parameters": { + "content": "## 📥 Recepción de archivos\n\nRecibe la solicitud desde la aplicación y comprueba que se hayan cargado los dos archivos requeridos:\n\n- Archivo de PDV\n- Archivo de usuarios\n\nSi falta alguno, detiene el proceso y devuelve un mensaje de error.", + "height": 592, + "width": 544, + "color": 7 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + -736, + -176 + ], + "id": "be9684c8-f3de-4fa5-a861-ab4e1c5213a2", + "name": "Sticky Note" + }, + { + "parameters": { + "content": "## 📊 Procesamiento de archivos\n\nExtrae la información de ambos archivos XLSX y valida su estructura antes de continuar.\n\nComprueba las columnas obligatorias y los límites permitidos de usuarios y puntos de venta. Luego unifica ambos resultados para ejecutar la optimización.", + "height": 560, + "width": 752, + "color": 6 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + -112, + -160 + ], + "id": "b4bc0cc5-fe4a-4c88-afa5-813a1f111a5d", + "name": "Sticky Note1" + }, + { + "parameters": { + "content": "## 🗺️ Generación de rutas\n\nAsigna los PDV a los usuarios considerando ubicación, distancia, disponibilidad de horas, prioridad y límites semanales.\n\nGenera las visitas por día, identifica los PDV que no pudieron asignarse y devuelve el resultado final a la aplicación.", + "height": 352, + "width": 640, + "color": "#237119" + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 704, + -48 + ], + "id": "a03f0d9d-7a9f-4b05-b042-735036e6a8f4", + "name": "Sticky Note2" + } + ], + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Validate", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract from File": { + "main": [ + [ + { + "node": "Code in JavaScript", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract from File1": { + "main": [ + [ + { + "node": "Code in JavaScript1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validate": { + "main": [ + [ + { + "node": "Extract from File", + "type": "main", + "index": 0 + }, + { + "node": "Extract from File1", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Respond to Webhook", + "type": "main", + "index": 0 + } + ] + ] + }, + "Merge": { + "main": [ + [ + { + "node": "Code in JavaScript2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code in JavaScript2": { + "main": [ + [ + { + "node": "Respond to Webhook3", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code in JavaScript1": { + "main": [ + [ + { + "node": "Merge", + "type": "main", + "index": 1 + } + ] + ] + }, + "Code in JavaScript": { + "main": [ + [ + { + "node": "Merge", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "authors": "Isaac Aracena", + "name": "Version 3fab9619", + "description": "", + "autosaved": true, + "workflowPublishHistory": [ + { + "createdAt": "2026-08-06T18:47:33.929Z", + "id": 4262, + "workflowId": "zxxyoFdmr1rxh4nY", + "versionId": "3fab9619-3539-4002-82d8-e399b4f516d9", + "event": "activated", + "userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029" + } + ] + } +} \ No newline at end of file