403 lines
30 KiB
JSON
403 lines
30 KiB
JSON
{
|
|
"name": "OptimizadorRutasPDV TEST - Fixed v4",
|
|
"nodes": [
|
|
{
|
|
"parameters": {
|
|
"httpMethod": "POST",
|
|
"path": "calcular-camino",
|
|
"responseMode": "responseNode",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.webhook",
|
|
"typeVersion": 2.1,
|
|
"position": [
|
|
-736,
|
|
544
|
|
],
|
|
"id": "f84fe2f2-2875-4e5c-ad99-4b4cc7a2c8d2",
|
|
"name": "Webhook",
|
|
"webhookId": "01fc4a9c-bad3-42b8-9f60-b87d91302502"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": "",
|
|
"typeValidation": "strict",
|
|
"version": 3
|
|
},
|
|
"conditions": [
|
|
{
|
|
"id": "c1",
|
|
"leftValue": "={{$binary.pdv_file.fileName}}",
|
|
"rightValue": "",
|
|
"operator": {
|
|
"type": "string",
|
|
"operation": "exists",
|
|
"singleValue": true
|
|
}
|
|
},
|
|
{
|
|
"id": "c2",
|
|
"leftValue": "={{$binary.usuarios_file.fileName}}",
|
|
"rightValue": "",
|
|
"operator": {
|
|
"type": "string",
|
|
"operation": "exists",
|
|
"singleValue": true
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2.3,
|
|
"position": [
|
|
-496,
|
|
544
|
|
],
|
|
"id": "5ecfc62e-849c-442b-90b8-4885494e3820",
|
|
"name": "Validate Files"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"respondWith": "json",
|
|
"responseBody": "{\n \"error\": \"Debe subir ambos archivos: PDV y Usuarios\"\n}",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.respondToWebhook",
|
|
"typeVersion": 1.5,
|
|
"position": [
|
|
-496,
|
|
768
|
|
],
|
|
"id": "2c0adaf5-050d-4c2e-a509-ed77cc5a6784",
|
|
"name": "Error: Missing Files"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "xlsx",
|
|
"binaryPropertyName": "pdv_file",
|
|
"options": {
|
|
"headerRow": true
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
-160,
|
|
416
|
|
],
|
|
"id": "90f15538-1ae9-454b-9038-b3d3cbf39f00",
|
|
"name": "Extract PDV"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "xlsx",
|
|
"binaryPropertyName": "usuarios_file",
|
|
"options": {
|
|
"headerRow": true
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.extractFromFile",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
-160,
|
|
640
|
|
],
|
|
"id": "a8a72a9e-7e38-486a-9db2-7efb130c6963",
|
|
"name": "Extract Usuarios"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const allRows = $input.all().map(item => item.json);\nif (!allRows.length) {\n return [{ json: { success: false, data: [], message: 'PDV: Archivo vacío' } }];\n}\nconst keys = new Set(allRows.flatMap(r => Object.keys(r)));\nconst required = ['ID','Nombre del PDV','Segmentación','Latitud','Longitud','Visitas semanales','Duración visita(horas)','Prioridad'];\nconst missing = required.filter(c => !keys.has(c));\nlet message = '';\nlet success = true;\nif (missing.length) {\n success = false;\n message = `PDV: Columnas faltantes: ${missing.join(', ')}`;\n}\nif (allRows.length > 2000) {\n success = false;\n message = 'PDV: Máximo 2000 filas permitidas';\n}\nreturn [{ json: { success, data: $input.all(), message } }];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
80,
|
|
416
|
|
],
|
|
"id": "e7acdf1f-4741-4189-91b9-30a90d42b531",
|
|
"name": "Validate PDV"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const allRows = $input.all().map(item => item.json);\nif (!allRows.length) {\n return [{ json: { success: false, data: [], message: 'Usuarios: Archivo vacío' } }];\n}\nconst keys = new Set(allRows.flatMap(r => Object.keys(r)));\nconst required = ['ID','Nombre del empleado','Latitud','Longitud','Horas por día usuario','Horas por semana usuario'];\nconst missing = required.filter(c => !keys.has(c));\nlet message = '';\nlet success = true;\nif (missing.length) {\n success = false;\n message = `Usuarios: Columnas faltantes: ${missing.join(', ')}`;\n}\nif (allRows.length > 50) {\n success = false;\n message = 'Usuarios: Máximo 50 filas permitidas';\n}\nreturn [{ json: { success, data: $input.all(), message } }];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
80,
|
|
640
|
|
],
|
|
"id": "0d734c5f-bf19-4eaa-9d82-16657523b53c",
|
|
"name": "Validate Usuarios"
|
|
},
|
|
{
|
|
"parameters": {},
|
|
"type": "n8n-nodes-base.merge",
|
|
"typeVersion": 3.2,
|
|
"position": [
|
|
336,
|
|
544
|
|
],
|
|
"id": "e13702a2-1b82-4a1f-bba6-e8880c8346aa",
|
|
"name": "Merge Validations"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"respondWith": "json",
|
|
"responseBody": "={{ JSON.stringify({ error: $json[0].message || $json[1].message || 'Error de validación' }) }}",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.respondToWebhook",
|
|
"typeVersion": 1.5,
|
|
"position": [
|
|
576,
|
|
768
|
|
],
|
|
"id": "d496c883-3206-47f5-89f4-f829ed167845",
|
|
"name": "Error: Validation Failed"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "return await (async () => {\nconst items = $input.all();\n\nconst pdvResult = items[0]?.json;\nconst userResult = items[1]?.json;\n\nif (!pdvResult?.success) {\n return [{ json: { error: pdvResult?.message || 'Error validación PDVs' } }];\n}\nif (!userResult?.success) {\n return [{ json: { error: userResult?.message || 'Error validación Usuarios' } }];\n}\n\nconst pdvsRaw = pdvResult.data.map(item => item.json);\nconst usuariosRaw = userResult.data.map(item => item.json);\n\nif (!pdvsRaw.length || !usuariosRaw.length) {\n return [{ json: { error: 'Archivos vacíos o sin datos válidos' } }];\n}\n\n// --- P1/P2-A: Constantes configurables ---\n// P2-A: RADIO_KM y VELOCIDAD pueden venir del front (FormData radio_km, velocidad)\n// Si el Webhook no envía esos campos, se usa el default 50/28 (urbano)\nlet VELOCIDAD_DESPLAZAMIENTO = 28; // P2: default urbano 28 km/h (antes 46 autopista)\nlet RADIO_KM = 50;\ntry {\n // Intentar leer del Webhook (nube) — soporta múltiples ubicaciones según modo binary\n const wh = typeof $ !== 'undefined' ? ($('Webhook')?.first?.()?.json ?? null) : null;\n const body = (wh && (wh.body || wh.query)) || items[0]?.json?.body || {};\n // FormData fields pueden venir como string\n const rk = body?.radio_km ?? body?.radioKm ?? body?.['radio_km'];\n const vel = body?.velocidad ?? body?.velocidad_km ?? body?.['velocidad'];\n if (rk != null && !isNaN(Number(rk))) {\n const v = Number(rk);\n if ([30,50,80].includes(v)) RADIO_KM = v;\n else if (v >= 20 && v <= 100) RADIO_KM = v;\n }\n if (vel != null && !isNaN(Number(vel))) {\n const v = Number(vel);\n if (v >= 20 && v <= 80) VELOCIDAD_DESPLAZAMIENTO = v;\n }\n} catch(e) { /* fallback a defaults */ }\nconst DIAS_SEMANA = ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes'];\n\nconst asignaciones = [];\nconst PDVnoAsignados = [];\n\nfunction calcularDistancia(lat1, lon1, lat2, lon2) {\n const R = 6371;\n const dLat = (lat2 - lat1) * Math.PI / 180;\n const dLon = (lon2 - lon1) * Math.PI / 180;\n const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180) * Math.cos(lat2*Math.PI/180) * Math.sin(dLon/2)**2;\n return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));\n}\n\nfunction formatHeader(value) {\n return value.toLowerCase()\n .normalize('NFD').replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^a-z0-9 ]/gi, '').trim().replace(/\\s+/g, '_');\n}\n\nfunction cleanNum(val) {\n if (val === null || val === undefined) return 0;\n if (typeof val === 'number') return val;\n return parseFloat(String(val).replace(/[^0-9.\\-]/g, '')) || 0;\n}\n\nfunction renameHeaders(rows) {\n return rows.map(row => {\n const newRow = {};\n for (const key in row) {\n const newKey = formatHeader(key);\n let val = row[key];\n if (typeof val === 'string') val = val.trim();\n newRow[newKey] = val;\n }\n return newRow;\n });\n}\n\nconst pdvs = renameHeaders(pdvsRaw);\nconst usuarios = renameHeaders(usuariosRaw);\n\n// Mapear columnas del Excel a nombres internos — P1 robustez\nfunction mapPdv(p) {\n const duracion = cleanNum(p.duracion_visitahoras ?? p.duracion_visita_horas ?? p.duracion_visita ?? 0);\n return {\n id: p.id,\n nombre_del_pdv: p.nombre_del_pdv,\n segmentacion: (p.segmentacion || 'D').toString().trim().toUpperCase(),\n latitud: cleanNum(p.latitud),\n longitud: cleanNum(p.longitud),\n visitas_semanales: Math.max(1, Math.min(7, Math.round(cleanNum(p.visitas_semanales) || 1))),\n duracion_visita_horas: duracion,\n prioridad: Math.round(cleanNum(p.prioridad) || 5),\n activo: (p.activo ?? 'Si').toString().trim(),\n };\n}\n\nfunction mapUsuario(u) {\n return {\n id: u.id,\n nombre_del_empleado: u.nombre_del_empleado,\n latitud: cleanNum(u.latitud),\n longitud: cleanNum(u.longitud),\n horas_por_dia_usuario: cleanNum(u.horas_por_dia_usuario),\n horas_por_semana_usuario: cleanNum(u.horas_por_semana_usuario),\n visitas_maximas_por_dia: Math.max(1, Math.round(cleanNum(u.visitas_maximas_por_dia) || 8)),\n usuario_activo: (u.usuario_activo ?? u.usuarioactivo ?? 'Si').toString().trim(),\n };\n}\n\nlet pdvsMapeados = pdvs.map(mapPdv).filter(p => {\n const v = p.activo.toLowerCase();\n return v !== 'n' && v !== 'no' && v !== '0';\n});\nlet usuariosMapeados = usuarios.map(mapUsuario).filter(u => {\n const v = u.usuario_activo.toLowerCase();\n return v !== 'n' && v !== 'no' && v !== '0';\n});\n\nif (!pdvsMapeados.length) {\n return [{ json: { error: 'No hay PDVs activos para asignar' } }];\n}\nif (!usuariosMapeados.length) {\n return [{ json: { error: 'No hay usuarios activos disponibles' } }];\n}\n\n// --- P2-Optional: Intentar matriz vial ORS local (si n8n y ORS están en localhost) ---\nlet orsDistMatrix = null;\nlet orsDurMatrix = null;\nlet useOrsVial = false;\ntry {\n // Solo intentar si hay pocos puntos (evitar 350+ y timeout)\n const totalLoc = pdvsMapeados.length + usuariosMapeados.length;\n if (totalLoc > 1 && totalLoc <= 100 && typeof fetch === 'function') {\n const locations = [...usuariosMapeados, ...pdvsMapeados].map(p => [p.longitud, p.latitud]);\n const sources = usuariosMapeados.map((_,i)=> i);\n const destinations = pdvsMapeados.map((_,i)=> i + usuariosMapeados.length);\n // ORS local sin key\n const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;\n const timeoutId = controller ? setTimeout(()=> controller.abort(), 4000) : null;\n const resp = await fetch('http://localhost:18080/ors/v2/matrix/driving-car', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ locations, metrics: ['distance','duration'], sources, destinations, units: 'km' }),\n signal: controller ? controller.signal : undefined\n });\n if (timeoutId) clearTimeout(timeoutId);\n if (resp && resp.ok) {\n const data = await resp.json();\n if (data && data.distances && data.durations) {\n orsDistMatrix = data.distances;\n orsDurMatrix = data.durations;\n useOrsVial = true;\n }\n }\n }\n} catch(e) {\n // Fallback silencioso a Haversine\n orsDistMatrix = null;\n orsDurMatrix = null;\n useOrsVial = false;\n}\n\n// Helper para distancia vial si existe\nfunction distanciaVial(uIdx, pIdx) {\n try {\n if (useOrsVial && orsDistMatrix && orsDistMatrix[uIdx] && orsDistMatrix[uIdx][pIdx] != null) {\n const d = orsDistMatrix[uIdx][pIdx];\n // ORS distance en km si units km, o metros si no — normalizar: si >1000 y units km, ya es km; si metros, convertir\n // Con units km, d ya es km. Sin units, sería metros.\n // Nuestro request usa units km, así que d es km\n if (d != null && d < 500) return d; // km válido\n }\n } catch(e) {}\n return null;\n}\nfunction duracionVialHoras(uIdx, pIdx) {\n try {\n if (useOrsVial && orsDurMatrix && orsDurMatrix[uIdx] && orsDurMatrix[uIdx][pIdx] != null) {\n const durSec = orsDurMatrix[uIdx][pIdx];\n if (durSec != null) return durSec / 3600;\n }\n } catch(e) {}\n return null;\n}\n\n\nfunction getDiasParaVisitas(n) {\n n = Math.max(1, Math.min(5, Math.round(n)));\n if (n === 1) return [DIAS_SEMANA[0]];\n if (n === 2) return [DIAS_SEMANA[0], DIAS_SEMANA[3]];\n if (n === 3) return [DIAS_SEMANA[0], DIAS_SEMANA[2], DIAS_SEMANA[4]];\n if (n === 4) return [DIAS_SEMANA[0], DIAS_SEMANA[1], DIAS_SEMANA[3], DIAS_SEMANA[4]];\n return [...DIAS_SEMANA];\n}\n\nfunction asignarPdvUsuarios() {\n const orden = ['AA', 'A', 'B', 'C', 'D'];\n const horasDisp = {};\n const horasNecesita = {};\n\n for (const u of usuariosMapeados) horasDisp[u.id] = u.horas_por_semana_usuario;\n for (const p of pdvsMapeados) horasNecesita[p.id] = p.duracion_visita_horas * p.visitas_semanales;\n\n const pdvsSortInicial = [...pdvsMapeados].sort((a, b) => {\n const idxA = orden.indexOf(a.segmentacion);\n const idxB = orden.indexOf(b.segmentacion);\n const ia = idxA === -1 ? 99 : idxA;\n const ib = idxB === -1 ? 99 : idxB;\n return ia - ib || a.prioridad - b.prioridad;\n });\n\n // Cola que permite re-encolar PDVs con horas restantes\n const cola = [...pdvsSortInicial];\n const intentos = {};\n for (const p of cola) intentos[p.id] = 0;\n\n while (cola.length) {\n const pdv = cola.shift();\n if (horasNecesita[pdv.id] <= 0.001) continue;\n\n // P2-Optional vial: si hay matriz ORS, usar distancia vial\n const uIndexMap = new Map(usuariosMapeados.map((u,i)=> [String(u.id), i]));\n const pIdx = pdvsMapeados.findIndex(pp => String(pp.id)===String(pdv.id));\n const candidatos = usuariosMapeados\n .map(u => {\n const uIdx = uIndexMap.get(String(u.id));\n let dist = null;\n if (useOrsVial && uIdx != null && pIdx >=0) {\n dist = distanciaVial(uIdx, pIdx);\n }\n if (dist == null) dist = calcularDistancia(u.latitud, u.longitud, pdv.latitud, pdv.longitud);\n return {\n usuario: u,\n distancia: dist,\n uIdx\n };\n })\n .filter(c => c.distancia <= RADIO_KM && horasDisp[c.usuario.id] > 0.001)\n .sort((a, b) => a.distancia - b.distancia);\n\n if (!candidatos.length) {\n // Solo marcar como no asignado si no se asignó nada de este PDV\n const yaAsignado = asignaciones.some(a => a.pdv_id === pdv.id);\n if (!yaAsignado) {\n PDVnoAsignados.push({\n pdv_id: pdv.id,\n pdv_nombre: pdv.nombre_del_pdv,\n minimo_horas_semana: pdv.duracion_visita_horas * pdv.visitas_semanales,\n latitud: pdv.latitud,\n longitud: pdv.longitud,\n motivo: `Sin usuario disponible dentro de ${RADIO_KM}km`\n });\n } else {\n const faltante = horasNecesita[pdv.id];\n if (faltante > 0.001) {\n PDVnoAsignados.push({\n pdv_id: pdv.id,\n pdv_nombre: pdv.nombre_del_pdv,\n minimo_horas_semana: Number(faltante.toFixed(2)),\n latitud: pdv.latitud,\n longitud: pdv.longitud,\n motivo: `Horas semana insuficientes: faltan ${faltante.toFixed(1)}h`\n });\n }\n }\n continue;\n }\n\n let asignadoEstaVuelta = false;\n for (const cand of candidatos) {\n if (asignadoEstaVuelta) break;\n const u = cand.usuario;\n const horasUsuario = horasDisp[u.id];\n const horasPdvRestante = horasNecesita[pdv.id];\n if (horasPdvRestante <= 0.001) break;\n if (horasUsuario <= 0.001) continue;\n const horasAsignadas = Math.min(horasUsuario, horasPdvRestante);\n if (horasAsignadas <= 0.001) continue;\n\n asignaciones.push({\n usuario_id: u.id,\n pdv_id: pdv.id,\n distancia: cand.distancia,\n horas_asignadas: horasAsignadas,\n horasDiariasUsuario: u.horas_por_dia_usuario,\n visitas_semanales: pdv.visitas_semanales,\n visitas_maximas_por_dia: u.visitas_maximas_por_dia,\n uIdx: cand.uIdx,\n });\n horasDisp[u.id] -= horasAsignadas;\n horasNecesita[pdv.id] -= horasAsignadas;\n asignadoEstaVuelta = true;\n }\n\n // Si aún quedan horas y no es un bucle infinito, re-encolar\n if (horasNecesita[pdv.id] > 0.001) {\n intentos[pdv.id] = (intentos[pdv.id] || 0) + 1;\n // Evitar bucle infinito: máximo reintentos = cantidad de usuarios\n if (intentos[pdv.id] <= usuariosMapeados.length) {\n cola.push(pdv);\n } else {\n // Ya se intentó con todos los candidatos\n const faltante = horasNecesita[pdv.id];\n const yaExiste = PDVnoAsignados.some(x => x.pdv_id === pdv.id);\n if (!yaExiste) {\n PDVnoAsignados.push({\n pdv_id: pdv.id,\n pdv_nombre: pdv.nombre_del_pdv,\n minimo_horas_semana: Number(faltante.toFixed(2)),\n latitud: pdv.latitud,\n longitud: pdv.longitud,\n motivo: `Horas semana insuficientes: faltan ${faltante.toFixed(1)}h tras asignar parcial`\n });\n }\n }\n }\n }\n}\n\nfunction asignarDiasHorasVisita() {\n const visitas = [];\n const visitasPorDia = {}; // usuario_id -> dia -> count\n for (const u of usuariosMapeados) {\n visitasPorDia[u.id] = {};\n for (const d of DIAS_SEMANA) visitasPorDia[u.id][d] = 0;\n }\n\n for (const a of asignaciones) {\n const nVisitas = a.visitas_semanales || 1;\n const diasAsignados = getDiasParaVisitas(nVisitas);\n const horasPorVisita = a.horas_asignadas / diasAsignados.length;\n // P2: +1.5 min fijo urbano por semáforos/paradas (0.025h) para no subestimar en ciudad\n // P2 vial: si hay duración ORS, usarla; si no, Haversine + 1.5min overhead\n let tiempoDesplazamiento = null;\n if (useOrsVial && a.uIdx != null) {\n const pIdx2 = pdvsMapeados.findIndex(pp=> String(pp.id)===String(a.pdv_id));\n const dur = pIdx2>=0 ? duracionVialHoras(a.uIdx, pIdx2) : null;\n if (dur != null) tiempoDesplazamiento = dur;\n }\n if (tiempoDesplazamiento == null) tiempoDesplazamiento = a.distancia / VELOCIDAD_DESPLAZAMIENTO + 0.025;\n\n // Validar horas por día antes de asignar\n if (horasPorVisita > a.horasDiariasUsuario + 0.001) {\n PDVnoAsignados.push({\n pdv_id: a.pdv_id,\n pdv_nombre: pdvsById[a.pdv_id]?.nombre_del_pdv || '',\n minimo_horas_semana: Number(a.horas_asignadas.toFixed(2)),\n latitud: pdvsById[a.pdv_id]?.latitud || 0,\n longitud: pdvsById[a.pdv_id]?.longitud || 0,\n motivo: `Excede horas por día del usuario (${horasPorVisita.toFixed(1)}h > ${a.horasDiariasUsuario}h)`\n });\n continue;\n }\n\n for (const dia of diasAsignados) {\n // Respetar visitas máximas por día\n const maxVisitas = a.visitas_maximas_por_dia || 8;\n if (visitasPorDia[a.usuario_id][dia] >= maxVisitas) {\n // Buscar siguiente día con cupo\n let diaAlterno = null;\n for (const d of DIAS_SEMANA) {\n if (visitasPorDia[a.usuario_id][d] < maxVisitas) { diaAlterno = d; break; }\n }\n if (!diaAlterno) {\n PDVnoAsignados.push({\n pdv_id: a.pdv_id,\n pdv_nombre: pdvsById[a.pdv_id]?.nombre_del_pdv || '',\n minimo_horas_semana: Number(horasPorVisita.toFixed(2)),\n latitud: pdvsById[a.pdv_id]?.latitud || 0,\n longitud: pdvsById[a.pdv_id]?.longitud || 0,\n motivo: `Visitas máximas por día excedidas para ${dia} (máx ${maxVisitas})`\n });\n continue;\n }\n visitas.push({\n usuario_id: a.usuario_id,\n pdv_id: a.pdv_id,\n dia: diaAlterno,\n horas_trabajo: Number(horasPorVisita.toFixed(2)),\n horas_desplazamiento: Number(tiempoDesplazamiento.toFixed(2)),\n distancia: Number(a.distancia.toFixed(2)),\n });\n visitasPorDia[a.usuario_id][diaAlterno]++;\n } else {\n visitas.push({\n usuario_id: a.usuario_id,\n pdv_id: a.pdv_id,\n dia: dia,\n horas_trabajo: Number(horasPorVisita.toFixed(2)),\n horas_desplazamiento: Number(tiempoDesplazamiento.toFixed(2)),\n distancia: Number(a.distancia.toFixed(2)),\n });\n visitasPorDia[a.usuario_id][dia]++;\n }\n }\n }\n return visitas;\n}\n\n// Necesitamos pdvsById antes para asignarDias\nconst pdvsByIdPre = Object.fromEntries(pdvsMapeados.map(p => [p.id, p]));\n// Exponer global para asignarDiasHorasVisita (usado en push de error)\nglobalThis.pdvsById = pdvsByIdPre;\nvar pdvsById = pdvsByIdPre;\n\nasignarPdvUsuarios();\nlet visitas = asignarDiasHorasVisita();\n\n// --- P2.3: Orden intra-día greedy por cercanía Haversine (mejora listado sin cambiar día) ---\ntry {\n const grupoKey = (v) => `${v.usuario_id}__${v.dia}`;\n const grupos = {};\n for (const v of visitas) {\n const k = grupoKey(v);\n if (!grupos[k]) grupos[k] = [];\n grupos[k].push(v);\n }\n let visitasOrdenadas = [];\n for (const k in grupos) {\n const arr = grupos[k];\n if (arr.length <= 2) { visitasOrdenadas.push(...arr); continue; }\n const usuariosByIdPreLocal = Object.fromEntries(usuariosMapeados.map(u=>[String(u.id), u]));\n const firstU = usuariosByIdPreLocal[String(arr[0].usuario_id)] || usuariosMapeados.find(u=> String(u.id)===String(arr[0].usuario_id));\n // Nearest neighbor greedy starting from usuario location\n let restantes = [...arr];\n // Start with pdv closest to usuario\n restantes.sort((a,b)=>{\n const pa = pdvsByIdPre[a.pdv_id], pb = pdvsByIdPre[b.pdv_id];\n if (!pa||!pb) return 0;\n return calcularDistancia(firstU.latitud, firstU.longitud, pa.latitud, pa.longitud) - calcularDistancia(firstU.latitud, firstU.longitud, pb.latitud, pb.longitud);\n });\n let ordenado = [restantes.shift()];\n while (restantes.length) {\n const last = ordenado[ordenado.length-1];\n const lp = pdvsByIdPre[last.pdv_id];\n let bestIdx = 0, bestDist = Infinity;\n for (let i=0;i<restantes.length;i++) {\n const cand = pdvsByIdPre[restantes[i].pdv_id];\n if (!cand||!lp) continue;\n const d = calcularDistancia(lp.latitud, lp.longitud, cand.latitud, cand.longitud);\n if (d < bestDist) { bestDist = d; bestIdx = i; }\n }\n ordenado.push(restantes.splice(bestIdx,1)[0]);\n }\n visitasOrdenadas.push(...ordenado);\n }\n visitas = visitasOrdenadas;\n} catch(e) { /* fallback a visitas sin ordenar */ }\n\nconst usuariosById = Object.fromEntries(usuariosMapeados.map(u => [u.id, u]));\nconst pdvsByIdFinal = Object.fromEntries(pdvsMapeados.map(p => [p.id, p]));\n// Reasignar para uso posterior\npdvsById = pdvsByIdFinal;\n\nconst resultadoFinal = visitas.map(v => {\n const u = usuariosById[v.usuario_id];\n const p = pdvsById[v.pdv_id];\n if (!u || !p) return null;\n return {\n usuario_id: v.usuario_id,\n nombre_usuario: u.nombre_del_empleado,\n latitud_usuario: u.latitud,\n longitud_usuario: u.longitud,\n hora_laboral_semanal_usuario: u.horas_por_semana_usuario,\n pdv_id: v.pdv_id,\n nombre_pdv: p.nombre_del_pdv,\n latitud_pdv: p.latitud,\n longitud_pdv: p.longitud,\n hora_minima_semanal_pdv: p.duracion_visita_horas * p.visitas_semanales,\n dia: v.dia,\n horas_trabajo: v.horas_trabajo,\n horas_desplazamiento: v.horas_desplazamiento,\n distancia: v.distancia,\n };\n}).filter(Boolean);\n\nconst a = resultadoFinal.map(r => ({ json: r }));\n// P1: ya no necesita bFiltrado tramposo, pero lo mantenemos por compatibilidad sin duplicar pdv_id con horas faltantes\nconst pdvIdsAsignados = new Set(a.map(x => x.json.pdv_id));\nconst bFiltrado = PDVnoAsignados.filter(pdv => {\n if (pdv.motivo && (pdv.motivo.includes('faltan') || pdv.motivo.includes('Visitas m') || pdv.motivo.includes('Excede'))) return true;\n return !pdvIdsAsignados.has(pdv.pdv_id);\n});\n\nreturn [{ assigned: a }, { noAssigned: bFiltrado }];\n\n})();"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
816,
|
|
544
|
|
],
|
|
"id": "e9fe9826-4755-497e-ae6e-274e7c1d4f66",
|
|
"name": "Optimize Routes"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"respondWith": "allIncomingItems",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.respondToWebhook",
|
|
"typeVersion": 1.5,
|
|
"position": [
|
|
1024,
|
|
544
|
|
],
|
|
"id": "b024c203-1b36-4f7c-97bb-3923481b7a9b",
|
|
"name": "Respond Success"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const items = $input.all();\nconst pdv = items[0]; // Validate PDV output\nconst usuarios = items[1]; // Validate Usuarios output\n\nconst pdvOk = pdv?.json?.success === true;\nconst usuariosOk = usuarios?.json?.success === true;\n\nif (pdvOk && usuariosOk) {\n // Both passed → send to Optimize Routes (output 0)\n return items;\n} else {\n // Failed → send to Error (output 1)\n const msgs = [];\n if (!pdvOk) msgs.push(pdv?.json?.message || 'Error PDV');\n if (!usuariosOk) msgs.push(usuarios?.json?.message || 'Error Usuarios');\n throw new Error(msgs.join(' | '));\n}"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
576,
|
|
544
|
|
],
|
|
"id": "27eb73de-4b08-4c44-97de-4d122a98749c",
|
|
"name": "Code in JavaScript",
|
|
"onError": "continueErrorOutput"
|
|
}
|
|
],
|
|
"pinData": {
|
|
"Webhook": [
|
|
{
|
|
"json": {
|
|
"headers": {
|
|
"host": "agenteit.digitalcompass.agency",
|
|
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
|
|
"content-length": "19829",
|
|
"accept": "application/json, text/plain, */*",
|
|
"accept-encoding": "gzip, deflate, br, zstd",
|
|
"accept-language": "en-US,en;q=0.6",
|
|
"content-type": "multipart/form-data; boundary=----WebKitFormBoundaryeTQ0nhU3IDSv0jrS",
|
|
"origin": "http://localhost:4200",
|
|
"priority": "u=1, i",
|
|
"referer": "http://localhost:4200/",
|
|
"sec-ch-ua": "\"Not=A?Brand\";v=\"99\", \"Brave\";v=\"151\", \"Chromium\";v=\"151\"",
|
|
"sec-ch-ua-mobile": "?0",
|
|
"sec-ch-ua-platform": "\"Windows\"",
|
|
"sec-fetch-dest": "empty",
|
|
"sec-fetch-mode": "cors",
|
|
"sec-fetch-site": "cross-site",
|
|
"sec-gpc": "1",
|
|
"x-forwarded-for": "152.0.172.53",
|
|
"x-forwarded-host": "agenteit.digitalcompass.agency",
|
|
"x-forwarded-port": "443",
|
|
"x-forwarded-proto": "https",
|
|
"x-forwarded-server": "07b4f09d2c65",
|
|
"x-real-ip": "152.0.172.53"
|
|
},
|
|
"params": {},
|
|
"query": {},
|
|
"body": {},
|
|
"webhookUrl": "https://agenteit.digitalcompass.agency/webhook/calcular-camino",
|
|
"executionMode": "production"
|
|
},
|
|
"binary": {
|
|
"pdv_file": {
|
|
"mimeType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
"fileExtension": "xlsx",
|
|
"data": "filesystem-v2",
|
|
"fileName": "Plantilla_PDV (2).xlsx",
|
|
"id": "filesystem-v2:workflows/IkKMqgb7uZuRk9d9/executions/48469/binary_data/48a414e1-9c74-469b-9bec-04283032f093",
|
|
"fileSize": "9.53 kB",
|
|
"bytes": 9528
|
|
},
|
|
"usuarios_file": {
|
|
"mimeType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
"fileExtension": "xlsx",
|
|
"data": "filesystem-v2",
|
|
"fileName": "Plantilla_Usuarios (2).xlsx",
|
|
"id": "filesystem-v2:workflows/IkKMqgb7uZuRk9d9/executions/48469/binary_data/6553aefd-4d40-45dd-9cdd-bfb1b1d1ec84",
|
|
"fileSize": "9.82 kB",
|
|
"bytes": 9825
|
|
}
|
|
},
|
|
"pairedItem": {
|
|
"item": 0
|
|
}
|
|
}
|
|
]
|
|
},
|
|
"connections": {
|
|
"Webhook": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Validate Files",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Validate Files": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Extract PDV",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Extract Usuarios",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Error: Missing Files",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Extract PDV": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Validate PDV",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Extract Usuarios": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Validate Usuarios",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Validate PDV": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Merge Validations",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Validate Usuarios": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Merge Validations",
|
|
"type": "main",
|
|
"index": 1
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Merge Validations": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Code in JavaScript",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Optimize Routes": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Respond Success",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Code in JavaScript": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Optimize Routes",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Error: Validation Failed",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
}
|
|
},
|
|
"active": true,
|
|
"settings": {
|
|
"executionOrder": "v1",
|
|
"binaryMode": "separate",
|
|
"timeSavedMode": "fixed",
|
|
"callerPolicy": "workflowsFromSameOwner",
|
|
"availableInMCP": true
|
|
},
|
|
"versionId": "f0dcd3f6-8043-4c22-9ca9-a477ae69db53",
|
|
"meta": {
|
|
"instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70"
|
|
},
|
|
"id": "IkKMqgb7uZuRk9d9",
|
|
"tags": []
|
|
} |