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

640 lines
37 KiB
JSON

{
"name": "OptimizadorRutasPDV - ORS Real",
"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 > 500) {\n success = false;\n message = 'Usuarios: Máximo 500 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": "={{ { error: $json.error?.message || $json.error || $json.message || 'Error de validación' } }}",
"options": {
"responseCode": 400
}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [
576,
768
],
"id": "d496c883-3206-47f5-89f4-f829ed167845",
"name": "Error: Validation Failed"
},
{
"parameters": {
"jsCode": "const validationItems = $('Code in JavaScript').all();\nconst pdvResult = validationItems[0]?.json;\nconst userResult = validationItems[1]?.json;\nconst matrixResponses = $input.all();\nconst matrixRequests = $('Prepare ORS Matrix Requests').all();\n\nfunction header(value) {\n return String(value).toLowerCase().normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').replace(/[^a-z0-9 ]/gi, '').trim().replace(/\\s+/g, '_');\n}\nfunction normalizeRows(data) {\n return data.map(item => Object.fromEntries(Object.entries(item.json).map(([key, value]) => [header(key), typeof value === 'string' ? value.trim() : value])));\n}\nfunction number(value) {\n if (typeof value === 'number') return Number.isFinite(value) ? value : 0;\n const text = String(value ?? '').trim().replace(',', '.').replace(/[^0-9.\\-]/g, '');\n const parsed = Number(text);\n return Number.isFinite(parsed) ? parsed : 0;\n}\nfunction active(value) {\n return !['n', 'no', '0', 'false'].includes(String(value ?? 'si').trim().toLowerCase());\n}\nfunction restDays(value) {\n if (Array.isArray(value)) return value.map(String);\n try { const parsed = JSON.parse(String(value ?? '[]')); if (Array.isArray(parsed)) return parsed.map(String); } catch {}\n return String(value ?? '').split(',').map(day => day.trim()).filter(Boolean);\n}\n\nconst scheduleDays = {\n LUN_DOM_44: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes', 'Sabado', 'Domingo'],\n LUN_SAB_40: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes', 'Sabado'],\n LUN_SAB_44: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes', 'Sabado'],\n LUN_VIE_40: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes']\n};\nconst segmentWeight = { AA: 100, A: 80, B: 60, C: 40, D: 20 };\n\nconst pdvs = normalizeRows(pdvResult.data).map((pdv, index) => ({\n id: String(pdv.id ?? index + 1),\n nombre: String(pdv.nombre_del_pdv ?? ''),\n segmentacion: String(pdv.segmentacion ?? 'D').toUpperCase(),\n latitud: number(pdv.latitud), longitud: number(pdv.longitud),\n visitas: Math.max(1, Math.min(7, Math.round(number(pdv.visitas_semanales) || 1))),\n duracion: number(pdv.duracion_visitahoras ?? pdv.duracion_visita_horas ?? pdv.duracion_visita),\n prioridad: Math.max(1, Math.min(5, Math.round(number(pdv.prioridad) || 5))),\n activo: active(pdv.activo),\n pais: String(pdv.pais ?? pdv.country ?? ''), ciudad: String(pdv.ciudad ?? ''),\n estado: String(pdv.estado ?? ''), provincia: String(pdv.provincia ?? ''),\n region: String(pdv.region ?? pdv.regional ?? ''), zona: String(pdv.zona ?? ''),\n metro: String(pdv.metro_de_prueba ?? '')\n})).filter(pdv => pdv.activo && pdv.duracion > 0);\n\nconst users = normalizeRows(userResult.data).map((user, index) => {\n const jornada = String(user.jornada ?? 'LUN_VIE_40');\n const descansos = new Set(restDays(user.dias_descanso).map(day => header(day)));\n const days = (scheduleDays[jornada] ?? scheduleDays.LUN_VIE_40).filter(day => !descansos.has(header(day)));\n return {\n id: String(user.id ?? index + 1), nombre: String(user.nombre_del_empleado ?? ''),\n latitud: number(user.latitud), longitud: number(user.longitud),\n horasDia: number(user.horas_por_dia_usuario), horasSemana: number(user.horas_por_semana_usuario),\n descanso: Math.max(0, Math.round(number(user.horas_descanso_por_dia))),\n maxVisitas: Math.max(1, Math.round(number(user.visitas_maximas_por_dia) || 3)),\n jornada, days, activo: active(user.usuario_activo ?? user.usuarioactivo),\n pais: String(user.pais ?? user.country ?? ''), ciudad: String(user.ciudad ?? ''),\n estado: String(user.estado ?? ''), provincia: String(user.provincia ?? ''),\n region: String(user.region ?? user.regional ?? ''), zona: String(user.zona ?? '')\n };\n}).filter(user => user.activo && user.days.length && user.horasDia > user.descanso && user.horasSemana > 0);\n\nconst country = matrixRequests[0]?.json?.country ?? 'REPUBLICA DOMINICANA';\nfunction territory(pdv) {\n return { pais: pdv.pais || country, ciudad: pdv.ciudad, estado: pdv.estado, provincia: pdv.provincia, region: pdv.region, zona: pdv.zona, metro: pdv.metro };\n}\n\nconst road = new Map();\nfor (let i = 0; i < matrixResponses.length; i++) {\n const request = matrixRequests[i]?.json;\n const response = matrixResponses[i]?.json;\n const userIds = request?.userIds ?? (request?.userId ? [request.userId] : []);\n for (let u = 0; u < userIds.length; u++) {\n const distances = response?.distances?.[u] ?? [];\n const durations = response?.durations?.[u] ?? [];\n for (let p = 0; p < (request?.pdvIds?.length ?? 0); p++) {\n const distanceKm = Number(distances[p]);\n const durationSeconds = Number(durations[p]);\n if (Number.isFinite(distanceKm) && Number.isFinite(durationSeconds)) road.set(userIds[u] + '|' + request.pdvIds[p], { distanceKm, durationSeconds });\n }\n }\n}\n\nconst remaining = new Map(users.map(user => [user.id, user.horasSemana]));\nconst pending = new Set(pdvs.map(pdv => pdv.id));\nconst weeklyAssignments = [];\nconst noAssigned = [];\n\nconst candidatesByUser = new Map(users.map(user => {\n const candidates = pdvs.map(pdv => {\n const metric = road.get(user.id + '|' + pdv.id);\n if (!metric) return null;\n const workHours = pdv.duracion * pdv.visitas;\n const estimatedTravelHours = metric.durationSeconds / 3600 * pdv.visitas;\n return {\n pdv,\n metric,\n totalHours: workHours + estimatedTravelHours,\n score: (segmentWeight[pdv.segmentacion] ?? 0) + (6 - pdv.prioridad) * 6\n };\n }).filter(Boolean).sort((a, b) => a.metric.durationSeconds - b.metric.durationSeconds || a.metric.distanceKm - b.metric.distanceKm || b.score - a.score);\n return [user.id, candidates];\n}));\nconst candidateIndex = new Map(users.map(user => [user.id, 0]));\n\nwhile (pending.size) {\n const options = [];\n for (const user of users) {\n const candidates = candidatesByUser.get(user.id) ?? [];\n let index = candidateIndex.get(user.id) ?? 0;\n while (index < candidates.length) {\n const candidate = candidates[index];\n if (!pending.has(candidate.pdv.id) || candidate.totalHours > (remaining.get(user.id) ?? 0) + 0.001) {\n index++;\n continue;\n }\n options.push({ user, candidate });\n break;\n }\n candidateIndex.set(user.id, index);\n }\n options.sort((a, b) => b.candidate.metric.durationSeconds - a.candidate.metric.durationSeconds);\n if (!options.length) break;\n let assignedThisRound = false;\n for (const option of options) {\n const { user, candidate } = option;\n if (!pending.has(candidate.pdv.id) || candidate.totalHours > (remaining.get(user.id) ?? 0) + 0.001) continue;\n weeklyAssignments.push({ user, pdv: candidate.pdv, metric: candidate.metric });\n remaining.set(user.id, (remaining.get(user.id) ?? 0) - candidate.totalHours);\n pending.delete(candidate.pdv.id);\n candidateIndex.set(user.id, (candidateIndex.get(user.id) ?? 0) + 1);\n assignedThisRound = true;\n }\n if (!assignedThisRound) break;\n}\n\nfor (const pdv of pdvs.filter(item => pending.has(item.id))) noAssigned.push({ pdv_id: pdv.id, pdv_nombre: pdv.nombre, minimo_horas_semana: pdv.duracion * pdv.visitas, latitud: pdv.latitud, longitud: pdv.longitud, ...territory(pdv), motivo: 'No hay empleado con capacidad semanal considerando el recorrido real' });\n\nconst daily = new Map();\nconst assigned = [];\nfunction dayState(userId, day) {\n const key = userId + '|' + day;\n if (!daily.has(key)) daily.set(key, { hours: 0, visits: 0 });\n return daily.get(key);\n}\nfunction preferredDays(days, count) {\n if (count >= days.length) return days.slice(0, count);\n return Array.from({ length: count }, (_, index) => days[Math.floor(index * days.length / count)]);\n}\n\nfor (const assignment of weeklyAssignments) {\n const { user, pdv, metric } = assignment;\n if (pdv.visitas > user.days.length) {\n noAssigned.push({ pdv_id: pdv.id, pdv_nombre: pdv.nombre, minimo_horas_semana: pdv.duracion * pdv.visitas, latitud: pdv.latitud, longitud: pdv.longitud, ...territory(pdv), motivo: 'Las visitas semanales exceden los días laborables disponibles' });\n continue;\n }\n const selected = [];\n const preferred = preferredDays(user.days, pdv.visitas);\n for (let visit = 0; visit < pdv.visitas; visit++) {\n const orderedDays = [preferred[visit], ...user.days.filter(day => day !== preferred[visit])];\n const day = orderedDays.find(candidate => {\n if (selected.includes(candidate)) return false;\n const state = dayState(user.id, candidate);\n return state.visits < user.maxVisitas && state.hours + pdv.duracion + metric.durationSeconds / 3600 <= user.horasDia - user.descanso + 0.001;\n });\n if (!day) { selected.length = 0; break; }\n selected.push(day);\n }\n if (!selected.length) {\n noAssigned.push({ pdv_id: pdv.id, pdv_nombre: pdv.nombre, minimo_horas_semana: pdv.duracion * pdv.visitas, latitud: pdv.latitud, longitud: pdv.longitud, ...territory(pdv), motivo: 'Excede capacidad diaria o visitas máximas considerando el recorrido real' });\n continue;\n }\n for (const day of selected) {\n const state = dayState(user.id, day);\n state.hours += pdv.duracion + metric.durationSeconds / 3600;\n state.visits++;\n assigned.push({ json: {\n usuario_id: user.id, nombre_usuario: user.nombre, latitud_usuario: user.latitud, longitud_usuario: user.longitud,\n pais_usuario: user.pais || country, ciudad_usuario: user.ciudad, estado_usuario: user.estado, provincia_usuario: user.provincia, region_usuario: user.region, zona_usuario: user.zona,\n hora_laboral_semanal_usuario: user.horasSemana, horas_por_dia_usuario: user.horasDia, horas_descanso_por_dia: user.descanso,\n jornada_codigo: user.jornada, pdv_id: pdv.id, nombre_pdv: pdv.nombre, latitud_pdv: pdv.latitud, longitud_pdv: pdv.longitud,\n pais: pdv.pais || country, ciudad: pdv.ciudad, estado: pdv.estado, provincia: pdv.provincia, region: pdv.region, zona: pdv.zona, metro: pdv.metro,\n hora_minima_semanal_pdv: pdv.duracion * pdv.visitas, dia: day, horas_trabajo: Number(pdv.duracion.toFixed(2)),\n horas_desplazamiento: Number((metric.durationSeconds / 3600).toFixed(4)), distancia: Number(metric.distanceKm.toFixed(2))\n } });\n }\n}\n\nconst groups = new Map();\nfor (const route of assigned) {\n const key = route.json.usuario_id + '|' + route.json.dia;\n if (!groups.has(key)) groups.set(key, []);\n groups.get(key).push(route);\n}\nfor (const routes of groups.values()) routes.sort((a, b) => a.json.horas_desplazamiento - b.json.horas_desplazamiento).forEach((route, index) => route.json.visit_order = index + 1);\n\nreturn [{ json: { assigned } }, { json: { noAssigned } }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1620,
280
],
"id": "e9fe9826-4755-497e-ae6e-274e7c1d4f66",
"name": "Optimize Routes",
"notes": "Usa matrices agrupadas por zona y listas de candidatos calculadas una sola vez para evitar el timeout."
},
{
"parameters": {
"respondWith": "allIncomingItems",
"options": {}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [
2720,
280
],
"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"
},
{
"parameters": {
"jsCode": "const items = $input.all();\nconst pdvResult = items[0]?.json;\nconst userResult = items[1]?.json;\nif (!pdvResult?.success || !userResult?.success) throw new Error('Los archivos no superaron la validación');\n\nfunction header(value) {\n return String(value).toLowerCase().normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').replace(/[^a-z0-9 ]/gi, '').trim().replace(/\\s+/g, '_');\n}\nfunction rows(data) {\n return data.map(item => Object.fromEntries(Object.entries(item.json).map(([key, value]) => [header(key), typeof value === 'string' ? value.trim() : value])));\n}\nfunction number(value) {\n if (typeof value === 'number') return Number.isFinite(value) ? value : 0;\n const parsed = Number(String(value ?? '').trim().replace(',', '.').replace(/[^0-9.\\-]/g, ''));\n return Number.isFinite(parsed) ? parsed : 0;\n}\nfunction active(value) {\n return !['n', 'no', '0', 'false'].includes(String(value ?? 'si').trim().toLowerCase());\n}\nfunction token(value) {\n return String(value ?? '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').replace(/[^a-z0-9]/gi, '').toUpperCase();\n}\nfunction distance(a, b) {\n const toRad = value => value * Math.PI / 180;\n const dLat = toRad(b.latitud - a.latitud);\n const dLon = toRad(b.longitud - a.longitud);\n const x = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a.latitud)) * Math.cos(toRad(b.latitud)) * Math.sin(dLon / 2) ** 2;\n return 6371 * 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x));\n}\nfunction chunks(values, size) {\n const result = [];\n for (let index = 0; index < values.length; index += size) result.push(values.slice(index, index + size));\n return result;\n}\n\nconst normalizedPdvs = rows(pdvResult.data);\nconst normalizedUsers = rows(userResult.data);\nconst declaredCountry = normalizedPdvs.map(row => token(row.pais ?? row.country)).find(Boolean) ?? '';\nconst mexicoByCoordinates = normalizedPdvs.some(row => number(row.latitud) >= 14 && number(row.latitud) <= 33 && number(row.longitud) >= -119 && number(row.longitud) <= -86);\nconst country = declaredCountry.includes('MEXICO') || (!declaredCountry && mexicoByCoordinates) ? 'MEXICO' : 'REPUBLICA DOMINICANA';\nconst countrySource = declaredCountry ? 'PDV País: ' + declaredCountry : mexicoByCoordinates ? 'Coordenadas PDV de México' : 'Predeterminado: República Dominicana';\nconst orsBaseUrl = country === 'MEXICO' ? 'http://host.docker.internal:18081/ors/v2' : 'http://host.docker.internal:18080/ors/v2';\n\nconst pdvs = normalizedPdvs.map((pdv, index) => ({\n id: String(pdv.id ?? index + 1),\n latitud: number(pdv.latitud),\n longitud: number(pdv.longitud),\n activo: active(pdv.activo),\n areaTokens: [pdv.metro_de_prueba, pdv.ciudad, pdv.regional, pdv.region, pdv.zona].map(token).filter(Boolean)\n})).filter(pdv => pdv.activo && Number.isFinite(pdv.latitud) && Number.isFinite(pdv.longitud));\n\nconst users = normalizedUsers.map((user, index) => ({\n id: String(user.id ?? index + 1),\n latitud: number(user.latitud),\n longitud: number(user.longitud),\n activo: active(user.usuario_activo ?? user.usuarioactivo),\n areaTokens: [user.ciudad, user.metro_de_prueba, user.regional, user.region, user.zona].map(token).filter(Boolean)\n})).filter(user => user.activo && Number.isFinite(user.latitud) && Number.isFinite(user.longitud));\n\nif (!pdvs.length) throw new Error('No hay PDVs activos con coordenadas válidas');\nif (!users.length) throw new Error('No hay empleados activos con coordenadas válidas');\n\nconst pdvsByToken = new Map();\nfor (const pdv of pdvs) {\n for (const areaToken of pdv.areaTokens) {\n if (!pdvsByToken.has(areaToken)) pdvsByToken.set(areaToken, new Map());\n pdvsByToken.get(areaToken).set(pdv.id, pdv);\n }\n}\n\nconst groups = new Map();\nconst coveredPdvIds = new Set();\nfor (const user of users) {\n const areaKey = user.areaTokens.find(areaToken => pdvsByToken.has(areaToken));\n if (areaKey) {\n if (!groups.has(areaKey)) groups.set(areaKey, { areaKey, users: [], pdvs: [...pdvsByToken.get(areaKey).values()] });\n groups.get(areaKey).users.push(user);\n groups.get(areaKey).pdvs.forEach(pdv => coveredPdvIds.add(pdv.id));\n continue;\n }\n const nearest = [...pdvs].sort((a, b) => distance(user, a) - distance(user, b)).slice(0, 500);\n groups.set('NEAREST_' + user.id, { areaKey: 'NEAREST_' + user.id, users: [user], pdvs: nearest });\n nearest.forEach(pdv => coveredPdvIds.add(pdv.id));\n}\n\nconst uncoveredPdvs = pdvs.filter(pdv => !coveredPdvIds.has(pdv.id));\nfor (const [index, pdvChunk] of chunks(uncoveredPdvs, 500).entries()) {\n const center = {\n latitud: pdvChunk.reduce((sum, pdv) => sum + pdv.latitud, 0) / pdvChunk.length,\n longitud: pdvChunk.reduce((sum, pdv) => sum + pdv.longitud, 0) / pdvChunk.length\n };\n const nearestUsers = [...users].sort((a, b) => distance(center, a) - distance(center, b)).slice(0, 10);\n groups.set('UNMATCHED_' + index, { areaKey: 'UNMATCHED_' + index, users: nearestUsers, pdvs: pdvChunk });\n}\n\nconst MAX_PDVS_PER_REQUEST = 500;\nconst MAX_ROUTES_PER_REQUEST = 3000;\nconst requests = [];\nfor (const group of groups.values()) {\n for (const pdvChunk of chunks(group.pdvs, MAX_PDVS_PER_REQUEST)) {\n const usersPerRequest = Math.max(1, Math.floor(MAX_ROUTES_PER_REQUEST / pdvChunk.length));\n for (const userChunk of chunks(group.users, usersPerRequest)) {\n const userIds = userChunk.map(user => user.id);\n requests.push({ json: {\n requestIndex: requests.length,\n country,\n countrySource,\n orsBaseUrl,\n areaKey: group.areaKey,\n userIds,\n pdvIds: pdvChunk.map(pdv => pdv.id),\n routePairs: userChunk.length * pdvChunk.length,\n locations: [...userChunk.map(user => [user.longitud, user.latitud]), ...pdvChunk.map(pdv => [pdv.longitud, pdv.latitud])],\n sources: userChunk.map((_, index) => String(index)),\n destinations: pdvChunk.map((_, index) => String(userChunk.length + index))\n } });\n }\n }\n}\nreturn requests;"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1180,
280
],
"id": "2561ca3d-7e4a-46a8-b90f-e3382978fc0d",
"name": "Prepare ORS Matrix Requests"
},
{
"parameters": {
"method": "POST",
"url": "={{ $('Prepare ORS Matrix Requests').item.json.orsBaseUrl + '/matrix/driving-car' }}",
"authentication": "none",
"sendHeaders": true,
"specifyHeaders": "keypair",
"headerParameters": {
"parameters": [
{
"name": "Accept",
"value": "application/json"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"contentType": "json",
"specifyBody": "json",
"jsonBody": "={{ { locations: $('Prepare ORS Matrix Requests').item.json.locations, sources: $('Prepare ORS Matrix Requests').item.json.sources, destinations: $('Prepare ORS Matrix Requests').item.json.destinations, metrics: ['distance', 'duration'], units: 'km', resolve_locations: false } }}",
"options": {
"batching": {
"batch": {
"batchSize": 5,
"batchInterval": 50
}
},
"response": {
"response": {
"fullResponse": false,
"neverError": false,
"responseFormat": "json"
}
},
"timeout": 180000
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1400,
280
],
"id": "7a57c771-79e6-4ec9-a024-698cc0cf6054",
"name": "ORS Matrix",
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 3000,
"onError": "continueErrorOutput",
"notes": "Agrupa empleados y PDVs por zona; cada solicitud mantiene como máximo 3,000 pares."
},
{
"parameters": {
"jsCode": "const optimized = $input.all();\nconst orsBaseUrl = $('Prepare ORS Matrix Requests').first().json.orsBaseUrl;\nconst assigned = optimized.find(item => Array.isArray(item.json.assigned))?.json.assigned ?? [];\nif (!assigned.length) return [{ json: { skipDirections: true, orsBaseUrl } }];\nconst byGroup = new Map();\nfor (const route of assigned) {\n const data = route.json ?? route;\n const key = String(data.usuario_id) + '|' + data.dia;\n if (!byGroup.has(key)) byGroup.set(key, []);\n byGroup.get(key).push(data);\n}\nconst requests = [];\nfor (const routes of byGroup.values()) {\n routes.sort((a, b) => Number(a.visit_order) - Number(b.visit_order));\n let origin = [Number(routes[0].longitud_usuario), Number(routes[0].latitud_usuario)];\n for (const assignment of routes) {\n const destination = [Number(assignment.longitud_pdv), Number(assignment.latitud_pdv)];\n requests.push({ json: { skipDirections: false, orsBaseUrl, assignment, coordinates: [origin, destination] } });\n origin = destination;\n }\n}\nreturn requests;"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1840,
280
],
"id": "769cdb8e-b465-45be-9192-8b23abec519b",
"name": "Prepare ORS Direction Requests"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "d44518de-dfea-4636-ae5c-a1f4723e01ba",
"leftValue": "={{ $json.skipDirections }}",
"rightValue": false,
"operator": {
"type": "boolean",
"operation": "equals",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
2050,
280
],
"id": "3c648989-8d24-420a-b812-ced659a9063f",
"name": "Has Direction Requests?"
},
{
"parameters": {
"method": "POST",
"url": "={{ $('Prepare ORS Direction Requests').item.json.orsBaseUrl + '/directions/driving-car/geojson' }}",
"authentication": "none",
"sendHeaders": true,
"specifyHeaders": "keypair",
"headerParameters": {
"parameters": [
{
"name": "Accept",
"value": "application/geo+json, application/json"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"contentType": "json",
"specifyBody": "json",
"jsonBody": "={{ { coordinates: $('Prepare ORS Direction Requests').item.json.coordinates, instructions: false, geometry: true, preference: 'fastest' } }}",
"options": {
"batching": {
"batch": {
"batchSize": 10,
"batchInterval": 50
}
},
"response": {
"response": {
"fullResponse": false,
"neverError": false,
"responseFormat": "json"
}
},
"timeout": 30000
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2270,
220
],
"id": "c6679ef2-2140-4240-93e0-95ed5f008160",
"name": "ORS Directions GeoJSON",
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 3000,
"onError": "continueRegularOutput",
"notes": "Conserva una salida por solicitud. Los fallos individuales se completan con los datos Matrix en Finalize Real Routes."
},
{
"parameters": {
"jsCode": "const optimized = $('Optimize Routes').all();\nconst baseAssigned = optimized.find(item => Array.isArray(item.json.assigned))?.json.assigned ?? [];\nconst baseNoAssigned = optimized.find(item => Array.isArray(item.json.noAssigned))?.json.noAssigned ?? [];\nconst prepared = $('Prepare ORS Direction Requests').all();\nif (prepared[0]?.json?.skipDirections) return [{ json: { assigned: [] } }, { json: { noAssigned: baseNoAssigned } }];\n\nconst responses = $input.all();\nlet routes = prepared.map((item, index) => {\n const assignment = item.json.assignment;\n const response = responses[index]?.json;\n const summary = response?.features?.[0]?.properties?.summary;\n if (summary && Number.isFinite(Number(summary.distance)) && Number.isFinite(Number(summary.duration))) {\n return { json: {\n ...assignment,\n distancia: Number((Number(summary.distance) / 1000).toFixed(2)),\n horas_desplazamiento: Number((Number(summary.duration) / 3600).toFixed(4)),\n geojson: response\n } };\n }\n // Matrix already supplied viable road distance and duration. Keep this visit when its detailed geometry fails.\n return { json: { ...assignment, ors_error: response?.error?.message ?? response?.message ?? 'ORS no devolvió GeoJSON para este tramo' } };\n});\n\nconst noAssigned = [...baseNoAssigned];\nlet changed = true;\nwhile (changed) {\n changed = false;\n const groups = new Map();\n for (const route of routes) {\n const key = route.json.usuario_id + '|' + route.json.dia;\n if (!groups.has(key)) groups.set(key, []);\n groups.get(key).push(route);\n }\n for (const group of groups.values()) {\n group.sort((a, b) => Number(a.json.visit_order) - Number(b.json.visit_order));\n const sample = group[0].json;\n const capacity = Number(sample.horas_por_dia_usuario) - Number(sample.horas_descanso_por_dia || 0);\n const total = group.reduce((sum, route) => sum + Number(route.json.horas_trabajo) + Number(route.json.horas_desplazamiento), 0);\n if (total <= capacity + 0.001) continue;\n const remove = group[group.length - 1].json;\n noAssigned.push({\n pdv_id: remove.pdv_id, pdv_nombre: remove.nombre_pdv, minimo_horas_semana: remove.hora_minima_semanal_pdv,\n latitud: remove.latitud_pdv, longitud: remove.longitud_pdv, pais: remove.pais, ciudad: remove.ciudad,\n estado: remove.estado, provincia: remove.provincia, region: remove.region, zona: remove.zona, metro: remove.metro,\n motivo: 'Excede la jornada diaria después de calcular el recorrido real'\n });\n routes = routes.filter(route => route !== group[group.length - 1]);\n changed = true;\n break;\n }\n}\n\nconst uniqueNoAssigned = Array.from(new Map(noAssigned.map(item => [String(item.pdv_id), item])).values());\nreturn [{ json: { assigned: routes } }, { json: { noAssigned: uniqueNoAssigned } }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2490,
280
],
"id": "a4f615ab-3ffb-4d5c-b357-fe938ad09c94",
"name": "Finalize Real Routes",
"onError": "continueErrorOutput",
"notes": "Una falla de Directions conserva la visita con métricas Matrix y una línea fallback; sólo errores internos llegan al 502."
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { error: \"upstream_error\", message: \"No se pudo calcular la distancia real con ORS\", detail: $json.error?.message || $json.message || \"ORS no disponible\" } }}",
"options": {
"responseCode": 502
}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [
2270,
500
],
"id": "52eeef8c-956e-487e-a4b1-54b3eb6cb39c",
"name": "Error: ORS Unavailable"
}
],
"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": "Prepare ORS Direction Requests",
"type": "main",
"index": 0
}
]
]
},
"Code in JavaScript": {
"main": [
[
{
"node": "Prepare ORS Matrix Requests",
"type": "main",
"index": 0
}
],
[
{
"node": "Error: Validation Failed",
"type": "main",
"index": 0
}
]
]
},
"Prepare ORS Matrix Requests": {
"main": [
[
{
"node": "ORS Matrix",
"type": "main",
"index": 0
}
]
]
},
"ORS Matrix": {
"main": [
[
{
"node": "Optimize Routes",
"type": "main",
"index": 0
}
],
[
{
"node": "Error: ORS Unavailable",
"type": "main",
"index": 0
}
]
]
},
"Prepare ORS Direction Requests": {
"main": [
[
{
"node": "Has Direction Requests?",
"type": "main",
"index": 0
}
]
]
},
"Has Direction Requests?": {
"main": [
[
{
"node": "ORS Directions GeoJSON",
"type": "main",
"index": 0
}
],
[
{
"node": "Finalize Real Routes",
"type": "main",
"index": 0
}
]
]
},
"ORS Directions GeoJSON": {
"main": [
[
{
"node": "Finalize Real Routes",
"type": "main",
"index": 0
}
],
[
{
"node": "Error: ORS Unavailable",
"type": "main",
"index": 0
}
]
]
},
"Finalize Real Routes": {
"main": [
[
{
"node": "Respond Success",
"type": "main",
"index": 0
}
],
[
{
"node": "Error: ORS Unavailable",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1",
"binaryMode": "separate",
"timeSavedMode": "fixed",
"callerPolicy": "workflowsFromSameOwner",
"availableInMCP": true
},
"versionId": "f0dcd3f6-8043-4c22-9ca9-a477ae69db53",
"meta": {
"instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70"
},
"id": "IkKMqgb7uZuRk9d9",
"tags": []
}