Files
optimizadorrutaspdv/optimizadorrutaspdv.json
T

711 lines
37 KiB
JSON

{
"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 <iaracena@gomezleemarketing.com>",
"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"
}
]
}
}