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