diff --git a/Proyecto/Optimizacion ruta1.1.xlsx b/Proyecto/Optimizacion ruta1.1.xlsx deleted file mode 100644 index 2af1a31..0000000 Binary files a/Proyecto/Optimizacion ruta1.1.xlsx and /dev/null differ diff --git a/Proyecto/Plantilla_PDV (2).xlsx b/Proyecto/Plantilla_PDV (2).xlsx deleted file mode 100644 index 48b9e8e..0000000 Binary files a/Proyecto/Plantilla_PDV (2).xlsx and /dev/null differ diff --git a/Proyecto/Plantilla_PDV test.xlsx b/Proyecto/Plantilla_PDV test.xlsx new file mode 100644 index 0000000..1e825c4 Binary files /dev/null and b/Proyecto/Plantilla_PDV test.xlsx differ diff --git a/Proyecto/Plantilla_Usuarios (2).xlsx b/Proyecto/Plantilla_Usuarios (2).xlsx deleted file mode 100644 index 095d1a5..0000000 Binary files a/Proyecto/Plantilla_Usuarios (2).xlsx and /dev/null differ diff --git a/Proyecto/Plantilla_Usuarios test.xlsx b/Proyecto/Plantilla_Usuarios test.xlsx new file mode 100644 index 0000000..09b44e2 Binary files /dev/null and b/Proyecto/Plantilla_Usuarios test.xlsx differ diff --git a/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json b/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json index a8e76d2..2f6054a 100644 --- a/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json +++ b/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json @@ -165,7 +165,7 @@ }, { "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 [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})();" + "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\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\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 segmentWeight = { AA: 100, A: 80, B: 60, C: 40, D: 20 };\n const horasDisp = {};\n const horasNecesita = {};\n const pdvsAsignados = new Set();\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 pdvsPendientes = [...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 const scoreA = (segmentWeight[a.segmentacion] || 0) + (6 - a.prioridad) * 6;\n const scoreB = (segmentWeight[b.segmentacion] || 0) + (6 - b.prioridad) * 6;\n return scoreB - scoreA || ia - ib || a.prioridad - b.prioridad;\n });\n\n function candidatosViablesParaUsuario(usuario) {\n return pdvsPendientes\n .filter(pdv => !pdvsAsignados.has(pdv.id) && horasNecesita[pdv.id] > 0.001 && horasNecesita[pdv.id] <= horasDisp[usuario.id] + 0.001)\n .map(pdv => ({\n pdv,\n distancia: calcularDistancia(usuario.latitud, usuario.longitud, pdv.latitud, pdv.longitud),\n score: (segmentWeight[pdv.segmentacion] || 0) + (6 - pdv.prioridad) * 6\n }))\n .sort((a, b) => a.distancia - b.distancia || b.score - a.score);\n }\n\n let assignedInRound = true;\n while (assignedInRound) {\n assignedInRound = false;\n const opcionesPorUsuario = usuariosMapeados\n .filter(usuario => horasDisp[usuario.id] > 0.001)\n .map(usuario => ({ usuario, candidatos: candidatosViablesParaUsuario(usuario) }))\n .filter(item => item.candidatos.length > 0)\n .sort((a, b) => b.candidatos[0].distancia - a.candidatos[0].distancia);\n\n const usuariosAsignadosEnRonda = new Set();\n for (const opcion of opcionesPorUsuario) {\n const usuario = opcion.usuario;\n if (usuariosAsignadosEnRonda.has(usuario.id)) continue;\n\n const candidato = candidatosViablesParaUsuario(usuario)[0];\n if (!candidato) continue;\n\n const pdv = candidato.pdv;\n const horasAsignadas = horasNecesita[pdv.id];\n\n asignaciones.push({\n usuario_id: usuario.id,\n pdv_id: pdv.id,\n distancia: candidato.distancia,\n horas_asignadas: horasAsignadas,\n horasDiariasUsuario: usuario.horas_por_dia_usuario,\n visitas_semanales: pdv.visitas_semanales,\n visitas_maximas_por_dia: usuario.visitas_maximas_por_dia,\n });\n\n horasDisp[usuario.id] -= horasAsignadas;\n horasNecesita[pdv.id] = 0;\n pdvsAsignados.add(pdv.id);\n usuariosAsignadosEnRonda.add(usuario.id);\n assignedInRound = true;\n }\n }\n\n for (const pdv of pdvsPendientes) {\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: 'No hay usuario disponible con capacidad por carga para asignar el PDV completo'\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 const tiempoDesplazamiento = 0;\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 [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, diff --git a/route-planner/src/app/core/models/route.model.ts b/route-planner/src/app/core/models/route.model.ts index 0e42f57..eeccb76 100644 --- a/route-planner/src/app/core/models/route.model.ts +++ b/route-planner/src/app/core/models/route.model.ts @@ -26,6 +26,7 @@ export interface RouteAssignedJson { nombre_pdv: string; usuario_id: string; pdv_id: number; + visit_order?: number; } export interface RouteNoAssignedResponse { pdv_id: number; @@ -34,4 +35,4 @@ export interface RouteAssignedJson { latitud: number, longitud: number, motivo: string; - } \ No newline at end of file + } diff --git a/route-planner/src/app/core/route-state/route.ts b/route-planner/src/app/core/route-state/route.ts index a5dc605..38ecddc 100644 --- a/route-planner/src/app/core/route-state/route.ts +++ b/route-planner/src/app/core/route-state/route.ts @@ -4,6 +4,7 @@ import { RouteAssignedResponse, RouteNoAssignedResponse } from '../models/route.model'; +import { summarizeDailyRoutes } from '../../shared/utils/route-sequencing.utils'; const STORAGE_ASSIGNED = 'rp_assigned_routes'; const STORAGE_ASSIGNED_ORIGINAL = 'rp_assigned_routes_original'; @@ -11,6 +12,12 @@ const STORAGE_NO_ASSIGNED = 'rp_no_assigned_routes'; const STORAGE_NO_ASSIGNED_ORIGINAL = 'rp_no_assigned_routes_original'; const STORAGE_SELECTED = 'rp_selected_route'; const DAY_ORDER = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes']; +export interface UnassignedAssignment { + userId: string; + day: string; + distance: number; + duration: number; +} @Injectable({ providedIn: 'root' }) export class RouteState { @@ -69,13 +76,18 @@ export class RouteState { } addAssignedFromUnassigned(route: RouteNoAssignedResponse, userId: string, day: string, distance: number, duration: number): void { - const profile = this.assignedRoutes.getValue().find(item => String(item.json.usuario_id) === String(userId)); - if (!profile) return; - const newRoute: RouteAssignedResponse = { - json: { - dia: day, - distancia: distance, - horas_desplazamiento: duration, + this.addAssignedFromUnassignedMany(route, [{ userId, day, distance, duration }]); + } + + addAssignedFromUnassignedMany(route: RouteNoAssignedResponse, assignments: UnassignedAssignment[]): void { + const routes = this.assignedRoutes.getValue(); + const newRoutes = assignments.map(assignment => { + const profile = routes.find(item => String(item.json.usuario_id) === String(assignment.userId)); + if (!profile) return null; + return { json: { + dia: assignment.day, + distancia: assignment.distance, + horas_desplazamiento: assignment.duration, hora_laboral_semanal_usuario: profile.json.hora_laboral_semanal_usuario, hora_minima_semanal_pdv: route.minimo_horas_semana, horas_trabajo: String(route.minimo_horas_semana), @@ -87,9 +99,10 @@ export class RouteState { nombre_pdv: route.pdv_nombre, usuario_id: profile.json.usuario_id, pdv_id: route.pdv_id - } - }; - this.setDraftAssignedRoutes([...this.assignedRoutes.getValue(), newRoute]); + } } as RouteAssignedResponse; + }); + if (newRoutes.some(item => !item)) return; + this.setDraftAssignedRoutes([...routes, ...(newRoutes as RouteAssignedResponse[])]); this.setDraftNoAssignedRoutes(this.noAssignedRoutes.getValue().filter(item => item.pdv_id !== route.pdv_id)); } @@ -98,6 +111,57 @@ export class RouteState { this.setDraftNoAssignedRoutes(updated); } + canAssignToDay( + userId: string, + day: string, + requiredHours: number, + excluding?: RouteAssignedResponse, + additionalTravelHours = 0 + ): { allowed: boolean; totalHours: number; capacityHours: number } { + const userRoute = this.assignedRoutes.getValue().find(route => String(route.json.usuario_id) === String(userId)); + if (!userRoute) return { allowed: false, totalHours: 0, capacityHours: 0 }; + const weeklyCapacity = Number(userRoute.json.hora_laboral_semanal_usuario || 0); + const weeklyLoad = this.assignedRoutes.getValue() + .filter(route => String(route.json.usuario_id) === String(userId) && route !== excluding) + .reduce((sum, route) => sum + Number(route.json.horas_trabajo || route.json.hora_minima_semanal_pdv || 0), 0) + requiredHours; + if (weeklyLoad > weeklyCapacity) { + return { allowed: false, totalHours: weeklyLoad, capacityHours: weeklyCapacity }; + } + const existing = this.assignedRoutes.getValue().filter(route => + String(route.json.usuario_id) === String(userId) && route.json.dia === day && route !== excluding + ); + const capacityHours = Number(userRoute.json.hora_laboral_semanal_usuario || 0) / 5; + const workHours = existing.reduce((sum, route) => sum + Number(route.json.horas_trabajo || route.json.hora_minima_semanal_pdv || 0), 0) + requiredHours; + const existingTravelHours = summarizeDailyRoutes(existing).reduce((sum, item) => sum + item.travelHours, 0); + const totalHours = workHours + existingTravelHours + additionalTravelHours; + return { allowed: totalHours <= capacityHours, totalHours, capacityHours }; + } + + moveRouteVisit(route: RouteAssignedResponse, direction: -1 | 1): void { + const group = this.assignedRoutes.getValue().filter(item => + String(item.json.usuario_id) === String(route.json.usuario_id) && item.json.dia === route.json.dia + ); + const sequence = summarizeDailyRoutes(group)[0]?.routes ?? []; + const index = sequence.findIndex(item => item.route === route); + const target = index + direction; + if (index < 0 || target < 0 || target >= sequence.length) return; + const ordered = [...sequence]; + [ordered[index], ordered[target]] = [ordered[target], ordered[index]]; + const orderByRoute = new Map(ordered.map((item, position) => [item.route, position + 1])); + this.setDraftAssignedRoutes(this.assignedRoutes.getValue().map(item => { + const order = orderByRoute.get(item); + return order ? { json: { ...item.json, visit_order: order } } : item; + })); + } + + resetRouteVisitOrder(userId: string, day: string): void { + this.setDraftAssignedRoutes(this.assignedRoutes.getValue().map(route => + String(route.json.usuario_id) === String(userId) && route.json.dia === day + ? { json: { ...route.json, visit_order: undefined } } + : route + )); + } + restoreOriginalAssignedRoutes(): void { const original = this.loadOriginalAssigned(); this.setDraftAssignedRoutes(original); @@ -112,11 +176,18 @@ export class RouteState { const optimized = [...current] .sort((a, b) => Number(b.json.hora_minima_semanal_pdv) - Number(a.json.hora_minima_semanal_pdv)) .map(route => { + const routeHours = Number(route.json.horas_trabajo || route.json.hora_minima_semanal_pdv || 0); const user = profiles .slice() - .sort((a, b) => (load.get(String(a.json.usuario_id)) ?? 0) - (load.get(String(b.json.usuario_id)) ?? 0))[0]; + .filter(profile => { + const userId = String(profile.json.usuario_id); + const capacity = Number(profile.json.hora_laboral_semanal_usuario || 0); + return (load.get(userId) ?? 0) + routeHours <= capacity; + }) + .sort((a, b) => (load.get(String(a.json.usuario_id)) ?? 0) - (load.get(String(b.json.usuario_id)) ?? 0))[0] + ?? profiles.slice().sort((a, b) => (load.get(String(a.json.usuario_id)) ?? 0) - (load.get(String(b.json.usuario_id)) ?? 0))[0]; const userId = String(user.json.usuario_id); - load.set(userId, (load.get(userId) ?? 0) + 1); + load.set(userId, (load.get(userId) ?? 0) + routeHours); return { json: { ...route.json, diff --git a/route-planner/src/app/core/services/planning-input.service.ts b/route-planner/src/app/core/services/planning-input.service.ts index e677d36..c686c76 100644 --- a/route-planner/src/app/core/services/planning-input.service.ts +++ b/route-planner/src/app/core/services/planning-input.service.ts @@ -4,14 +4,10 @@ import { Injectable } from '@angular/core'; export class PlanningInputService { private pdvFile: File | null = null; private usuariosFile: File | null = null; - private radioKm = 50; - private velocidad = 28; - setFiles(pdvFile: File, usuariosFile: File, radioKm: number, velocidad: number): void { + setFiles(pdvFile: File, usuariosFile: File): void { this.pdvFile = pdvFile; this.usuariosFile = usuariosFile; - this.radioKm = radioKm; - this.velocidad = velocidad; } getFormData(): FormData | null { @@ -19,8 +15,6 @@ export class PlanningInputService { const data = new FormData(); data.append('pdv_file', this.pdvFile, this.pdvFile.name); data.append('usuarios_file', this.usuariosFile, this.usuariosFile.name); - data.append('radio_km', String(this.radioKm)); - data.append('velocidad', String(this.velocidad)); return data; } diff --git a/route-planner/src/app/private/home/assigned-route/assigned-route.component.html b/route-planner/src/app/private/home/assigned-route/assigned-route.component.html index 197bec7..b438a95 100644 --- a/route-planner/src/app/private/home/assigned-route/assigned-route.component.html +++ b/route-planner/src/app/private/home/assigned-route/assigned-route.component.html @@ -33,6 +33,7 @@
{{ reoptError }}
+
{{ editError }}
-
-

Parámetros de optimización

-
-
- - - PDV sin usuario dentro del radio queda sin asignar -
-
- - - Usado si ORS vial no disponible. En ciudad 22-28 es más real que 46 -
-
-
-
diff --git a/route-planner/src/app/private/home/home.component.ts b/route-planner/src/app/private/home/home.component.ts index 3d4ecc6..d7c40a5 100644 --- a/route-planner/src/app/private/home/home.component.ts +++ b/route-planner/src/app/private/home/home.component.ts @@ -7,7 +7,6 @@ import { RutasService } from '../../core/services/router.service'; import { RouteState } from '../../core/route-state/route'; import { ApiResponse } from '../../core/models/route.model'; import { ExcelPreviewService, PreviewResult } from '../../shared/services/excel-preview.service'; -import { DEFAULT_PARAMS } from '../../shared/schemas/params.schema'; import { Subscription } from 'rxjs'; import { PlanningKpisComponent } from './planning-kpis/planning-kpis.component'; import { PlanningInputService } from '../../core/services/planning-input.service'; @@ -30,32 +29,13 @@ export class HomeComponent { isLoading = false; private calcSub: Subscription | null = null; - // P2-A params - radioKm: 30 | 50 | 80 = DEFAULT_PARAMS.radioKm; - velocidad: number = DEFAULT_PARAMS.velocidad; - constructor( private rutasService: RutasService, private routeState: RouteState, private previewService: ExcelPreviewService, private planningInput: PlanningInputService, private cdr: ChangeDetectorRef - ) { - try { - const raw = sessionStorage.getItem('rp_params'); - if (raw) { - const p = JSON.parse(raw) as { radioKm?: number; velocidad?: number }; - if ([30, 50, 80].includes(p.radioKm as number)) this.radioKm = p.radioKm as 30|50|80; - if (typeof p.velocidad === 'number' && p.velocidad >= 20 && p.velocidad <= 80) this.velocidad = p.velocidad; - } - } catch { /* ignore */ } - } - - onRadioChange() { this.persistParams(); } - onVelocidadChange() { this.persistParams(); } - private persistParams() { - try { sessionStorage.setItem('rp_params', JSON.stringify({ radioKm: this.radioKm, velocidad: this.velocidad })); } catch { /* ignore */ } - } + ) {} async onPdvFileChange(event: Event) { const input = event.target as HTMLInputElement; @@ -69,7 +49,7 @@ export class HomeComponent { return; } this.pdvFile = file; - if (this.usuariosFile) this.planningInput.setFiles(file, this.usuariosFile, this.radioKm, this.velocidad); + if (this.usuariosFile) this.planningInput.setFiles(file, this.usuariosFile); this.uploadError = null; this.pdvPreviewLoading = true; this.pdvPreview = null; @@ -110,7 +90,7 @@ export class HomeComponent { return; } this.usuariosFile = file; - if (this.pdvFile) this.planningInput.setFiles(this.pdvFile, file, this.radioKm, this.velocidad); + if (this.pdvFile) this.planningInput.setFiles(this.pdvFile, file); this.uploadError = null; this.usuariosPreviewLoading = true; this.usuariosPreview = null; @@ -172,19 +152,27 @@ export class HomeComponent { return null; } - cargarRutas() { + async cargarRutas() { if (!this.canSubmit) return; this.isLoading = true; this.uploadError = null; this.routeState.setLoading(true); - this.planningInput.setFiles(this.pdvFile!, this.usuariosFile!, this.radioKm, this.velocidad); + let pdvForOptimizer: File; + try { + pdvForOptimizer = await this.previewService.preparePdvForOptimizer(this.pdvFile!); + } catch { + this.uploadError = 'No se pudo preparar el archivo PDV para el optimizador.'; + this.routeState.setLoading(false); + this.isLoading = false; + this.cdr.detectChanges(); + return; + } + this.planningInput.setFiles(pdvForOptimizer, this.usuariosFile!); const formData = new FormData(); - formData.append('pdv_file', this.pdvFile!, this.pdvFile!.name); + formData.append('pdv_file', pdvForOptimizer, pdvForOptimizer.name); formData.append('usuarios_file', this.usuariosFile!, this.usuariosFile!.name); - formData.append('radio_km', String(this.radioKm)); - formData.append('velocidad', String(this.velocidad)); this.calcSub?.unsubscribe(); this.calcSub = this.rutasService.calcularRutas(formData).subscribe({ diff --git a/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.css b/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.css index 6c3e08c..3eb2bd4 100644 --- a/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.css +++ b/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.css @@ -29,6 +29,22 @@ font-size: 0.75rem; } +.day-checkboxes { + display: flex; + flex-wrap: wrap; + gap: 3px 5px; + align-items: center; +} + +.day-checkboxes label { + display: inline-flex; + align-items: center; + gap: 2px; + color: var(--glm-acero); + font-size: 0.68rem; + font-weight: 600; +} + .spinner-inline { display: inline-block; width: 13px; diff --git a/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.html b/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.html index 27cd9a3..4795e29 100644 --- a/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.html +++ b/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.html @@ -25,10 +25,9 @@ - +
+ +
@@ -98,7 +98,7 @@
- +
@@ -108,7 +108,7 @@

Detalle de la ruta

-
@@ -135,6 +135,9 @@
Duración tramo
{{ selectedSegmentDurationMinutes | number:'1.0-0' }} min
+ + +
@@ -147,12 +150,21 @@
+
+

Rutas diarias

+
+ {{ day.userName }} · {{ day.day }} + {{ day.routes.length }} paradas · {{ day.distanceKm | number:'1.1-1' }} km · {{ (day.travelHours * 60) | number:'1.0-0' }} min + {{ day.totalHours | number:'1.1-1' }} / {{ day.capacityHours | number:'1.1-1' }} h {{ day.exceedsCapacity ? '· Excede capacidad' : '· Capacidad disponible' }} +
+
+

{{ selectedUsuarioId === 'Todos' ? 'PDVs de todos los usuarios' : 'PDVs del usuario' }} ({{ filteredRoutes.length }})

    -
  • + (click)="selectRouteFromList(r)" (keydown.enter)="selectRouteFromList(r)" (keydown.space)="selectRouteFromList(r); $event.preventDefault()"> {{ routeVisitOrder(r) }}
    {{ r.json.nombre_pdv }} diff --git a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.ts b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.ts index 59dda56..c93e3f0 100644 --- a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.ts +++ b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.ts @@ -5,11 +5,11 @@ import { Router, ActivatedRoute } from '@angular/router'; import { Subscription, Subject } from 'rxjs'; import * as L from 'leaflet'; import 'leaflet.heat'; -import { RouteState } from '../../../core/route-state/route'; +import { RouteState, UnassignedAssignment } from '../../../core/route-state/route'; import { OrsService } from '../../../core/services/ors.service'; import { RouteAssignedResponse, RouteNoAssignedResponse } from '../../../core/models/route.model'; import { colorForUsuario } from '../../../shared/utils/palette.utils'; -import { haversineKm, sequenceRoutes } from '../../../shared/utils/route-sequencing.utils'; +import { DailyRouteSummary, haversineKm, sequenceRoutes, summarizeDailyRoutes } from '../../../shared/utils/route-sequencing.utils'; interface MapRoute { data: RouteAssignedResponse; @@ -71,7 +71,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn editUnassignedLng = 0; editUnassignedReason = ''; assignmentUser: Record = {}; - assignmentDay: Record = {}; + assignmentDays: Record = {}; assigningUnassignedId: number | null = null; unassignedActionError: string | null = null; reassigningRoute = false; @@ -94,6 +94,10 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn return sum.toFixed(0); } + get dailySummaries(): DailyRouteSummary[] { + return summarizeDailyRoutes(this.filteredRoutes); + } + constructor( private routeState: RouteState, private orsService: OrsService, @@ -517,6 +521,16 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn this.reassignError = null; } + moveSelectedVisit(direction: -1 | 1): void { + if (!this.selectedRouteDetail) return; + this.routeState.moveRouteVisit(this.selectedRouteDetail, direction); + } + + resetSelectedDayOrder(): void { + if (!this.selectedRouteDetail) return; + this.routeState.resetRouteVisitOrder(this.selectedRouteDetail.json.usuario_id, this.selectedRouteDetail.json.dia); + } + async reassignRoute(): Promise { const route = this.selectedRouteDetail; if (!route || !this.reassignUserId || !this.reassignDay || this.reassigningRoute) return; @@ -540,6 +554,17 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn const response = results[0] as { geojson?: { features?: { properties?: { summary?: { distance: number; duration: number } } }[] }; error?: string } | undefined; const summary = response?.geojson?.features?.[0]?.properties?.summary; if (response?.error || !summary) throw new Error('ORS no devolvió una ruta vial'); + const capacity = this.routeState.canAssignToDay( + this.reassignUserId, + this.reassignDay, + Number(route.json.horas_trabajo || route.json.hora_minima_semanal_pdv || 0), + route, + summary.duration / 3600 + ); + if (!capacity.allowed) { + this.reassignError = `La reasignación excede la capacidad diaria (${capacity.totalHours.toFixed(1)} h de ${capacity.capacityHours.toFixed(1)} h).`; + return; + } this.routeState.updateAssignedRoute(route, this.reassignUserId, this.reassignDay, { distance: summary.distance / 1000, duration: summary.duration / 3600 @@ -586,6 +611,15 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn return segment ? `${segment.visitOrder} de ${segment.totalStops}` : '-'; } + get canMoveSelectedVisitUp(): boolean { + return (this.selectedMapSegment?.visitOrder ?? 0) > 1; + } + + get canMoveSelectedVisitDown(): boolean { + const segment = this.selectedMapSegment; + return !!segment && segment.visitOrder < segment.totalStops; + } + get selectedSegmentOrigin(): string { const segment = this.selectedMapSegment; if (!segment) return '-'; @@ -621,6 +655,13 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn return this.routeState.getDiasUnicos(); } + toggleUnassignedDay(pdvId: number, day: string, checked: boolean): void { + const key = String(pdvId); + const days = new Set(this.assignmentDays[key] ?? []); + checked ? days.add(day) : days.delete(day); + this.assignmentDays[key] = Array.from(days); + } + selectUnassigned(route: RouteNoAssignedResponse): void { this.selectedUnassigned = route; this.selectedRouteDetail = null; @@ -670,9 +711,9 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn async assignUnassigned(route: RouteNoAssignedResponse): Promise { const userId = this.assignmentUser[String(route.pdv_id)]; - const day = this.assignmentDay[String(route.pdv_id)]; - if (!userId || !day) { - this.unassignedActionError = 'Selecciona usuario y día antes de asignar.'; + const days = this.assignmentDays[String(route.pdv_id)] ?? []; + if (!userId || !days.length) { + this.unassignedActionError = 'Selecciona usuario y al menos un día antes de asignar.'; return; } const user = this.routeState.getAssignedSnapshot().find(item => String(item.json.usuario_id) === userId); @@ -682,14 +723,23 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn try { const origin = [Number(user.json.longitud_usuario), Number(user.json.latitud_usuario)]; const destination = [Number(route.longitud), Number(route.latitud)]; - const results = await this.orsService.processPairs([{ pdv_id: route.pdv_id, origin, destination }], 1, true); - const geo = (results[0] as { geojson?: { features?: { properties?: { summary?: { distance: number; duration: number } } }[] }; error?: string }); - const summary = geo?.geojson?.features?.[0]?.properties?.summary; - if (geo?.error || !summary) throw new Error('No se obtuvo una ruta vial'); - this.routeState.addAssignedFromUnassigned(route, userId, day, summary.distance / 1000, summary.duration / 3600); + const assignments: UnassignedAssignment[] = []; + for (const day of days) { + const results = await this.orsService.processPairs([{ pdv_id: route.pdv_id, origin, destination }], 1, true); + const geo = (results[0] as { geojson?: { features?: { properties?: { summary?: { distance: number; duration: number } } }[] }; error?: string }); + const summary = geo?.geojson?.features?.[0]?.properties?.summary; + if (geo?.error || !summary) throw new Error('No se obtuvo una ruta vial'); + const capacity = this.routeState.canAssignToDay(userId, day, route.minimo_horas_semana, undefined, summary.duration / 3600); + if (!capacity.allowed) { + this.unassignedActionError = `${day}: excede la capacidad diaria (${capacity.totalHours.toFixed(1)} h de ${capacity.capacityHours.toFixed(1)} h).`; + return; + } + assignments.push({ userId, day, distance: summary.distance / 1000, duration: summary.duration / 3600 }); + } + this.routeState.addAssignedFromUnassignedMany(route, assignments); this.selectedUnassigned = null; delete this.assignmentUser[String(route.pdv_id)]; - delete this.assignmentDay[String(route.pdv_id)]; + delete this.assignmentDays[String(route.pdv_id)]; } catch { this.unassignedActionError = `No se pudo calcular la ruta vial para ${route.pdv_nombre}.`; } finally { diff --git a/route-planner/src/app/shared/schemas/index.ts b/route-planner/src/app/shared/schemas/index.ts index 51a1f9e..9696ff4 100644 --- a/route-planner/src/app/shared/schemas/index.ts +++ b/route-planner/src/app/shared/schemas/index.ts @@ -1,3 +1,2 @@ export * from './pdv.schema'; export * from './usuario.schema'; -export * from './params.schema'; diff --git a/route-planner/src/app/shared/schemas/params.schema.ts b/route-planner/src/app/shared/schemas/params.schema.ts deleted file mode 100644 index 24e5939..0000000 --- a/route-planner/src/app/shared/schemas/params.schema.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { z } from 'zod'; - -export const paramsSchema = z.object({ - radioKm: z.union([z.literal(30), z.literal(50), z.literal(80)]), - velocidad: z.number().min(15).max(80), -}); - -export type Params = z.infer; -// Urbano denso Santo Domingo: 22-28 km/h es más realista que 46 (autopista). -// P2-A default 28; 46 sigue disponible como "Mixto rápido" pero ya no default. -export const DEFAULT_PARAMS: Params = { radioKm: 50, velocidad: 28 }; diff --git a/route-planner/src/app/shared/schemas/pdv.schema.ts b/route-planner/src/app/shared/schemas/pdv.schema.ts index 5c0db75..e579b47 100644 --- a/route-planner/src/app/shared/schemas/pdv.schema.ts +++ b/route-planner/src/app/shared/schemas/pdv.schema.ts @@ -17,8 +17,6 @@ export const PDV_NORMALIZED_REQUIRED = [ 'segmentacion', 'latitud', 'longitud', - 'visitas_semanales', - 'duracion_visitahoras', 'prioridad', ] as const; @@ -32,9 +30,19 @@ export const pdvRowSchema = z.object({ }), latitud: z.number().min(-90, 'Latitud mínima -90').max(90, 'Latitud máxima 90'), longitud: z.number().min(-180, 'Longitud mínima -180').max(180, 'Longitud máxima 180'), - visitas_semanales: z.number().int('Visitas semanales debe ser entero').min(1, 'Visitas semanales mínimo 1').max(7, 'Visitas semanales máximo 7'), - duracion_visitahoras: z.number().min(0.01, 'Duración visita debe ser >0').max(24, 'Duración visita máximo 24'), + visitas_semanales: z.number().int('Visitas semanales debe ser entero').min(1, 'Visitas semanales mínimo 1').max(7, 'Visitas semanales máximo 7').optional(), + duracion_visitahoras: z.number().min(0.01, 'Duración visita debe ser >0').max(168, 'Duración visita máximo 168').optional(), + minimo_horas_semana: z.number().min(0.01, 'Mínimo horas semana debe ser >0').max(168, 'Mínimo horas semana máximo 168').optional(), prioridad: z.number().int('Prioridad debe ser entero').min(1, 'Prioridad mínimo 1').max(5, 'Prioridad máximo 5'), +}).superRefine((data, ctx) => { + const hasVisitsFormat = data.visitas_semanales !== undefined && data.duracion_visitahoras !== undefined; + if (!hasVisitsFormat && data.minimo_horas_semana === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['minimo_horas_semana'], + message: 'Incluye Mínimo horas semana o Visitas semanales + Duración visita(horas)' + }); + } }); export type PdvRow = z.infer; diff --git a/route-planner/src/app/shared/services/excel-preview.service.ts b/route-planner/src/app/shared/services/excel-preview.service.ts index 8aac4dc..8ed292b 100644 --- a/route-planner/src/app/shared/services/excel-preview.service.ts +++ b/route-planner/src/app/shared/services/excel-preview.service.ts @@ -52,6 +52,13 @@ export class ExcelPreviewService { missingHeaders.push(displayRequired[i]); } } + if (type === 'PDV') { + const hasVisitsFormat = normalizedHeaders.includes('visitas_semanales') && normalizedHeaders.includes('duracion_visitahoras'); + const hasWeeklyHours = normalizedHeaders.includes('minimo_horas_semana'); + if (!hasVisitsFormat && !hasWeeklyHours) { + missingHeaders.push('Mínimo horas semana o Visitas semanales + Duración visita(horas)'); + } + } const errors: FieldError[] = []; const repeatedHeaders = normalizedHeaders.filter((header, index) => header && normalizedHeaders.indexOf(header) !== index); @@ -92,6 +99,7 @@ export class ExcelPreviewService { coerced['longitud'] = this.coerceNum(row['longitud']); coerced['visitas_semanales'] = this.coerceNum(row['visitas_semanales']); coerced['duracion_visitahoras'] = this.coerceNum(row['duracion_visitahoras']); + coerced['minimo_horas_semana'] = this.coerceNum(row['minimo_horas_semana']); coerced['prioridad'] = this.coerceNum(row['prioridad']); if (typeof coerced['segmentacion'] === 'string') { coerced['segmentacion'] = String(coerced['segmentacion']).trim().toUpperCase(); @@ -139,6 +147,33 @@ export class ExcelPreviewService { return Number.isNaN(n) ? value : n; } + async preparePdvForOptimizer(file: File): Promise { + const buffer = await file.arrayBuffer(); + const workbook = XLSX.read(buffer, { type: 'array', cellDates: true }); + const sheet = workbook.Sheets[workbook.SheetNames[0]]; + const rows = XLSX.utils.sheet_to_json(sheet, { defval: null, raw: true }) + .map(row => normalizeRow(row as Record)); + const normalized = rows.map(row => { + const weeklyHours = cleanNum(row['minimo_horas_semana']); + const visits = cleanNum(row['visitas_semanales']); + const duration = cleanNum(row['duracion_visitahoras']); + return { + 'ID': row['id'], + 'Nombre del PDV': row['nombre_del_pdv'], + 'Segmentación': row['segmentacion'], + 'Latitud': cleanNum(row['latitud']), + 'Longitud': cleanNum(row['longitud']), + 'Visitas semanales': Number.isFinite(visits) ? visits : 1, + 'Duración visita(horas)': Number.isFinite(duration) ? duration : weeklyHours, + 'Prioridad': cleanNum(row['prioridad']) + }; + }); + const output = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(output, XLSX.utils.json_to_sheet(normalized), 'PDV'); + const data = XLSX.write(output, { bookType: 'xlsx', type: 'array' }); + return new File([data], file.name, { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + } + private extractHeaders(sheet: XLSX.WorkSheet): string[] { const range = XLSX.utils.decode_range(sheet['!ref'] || 'A1'); const headers: string[] = []; diff --git a/route-planner/src/app/shared/services/planning-export.service.ts b/route-planner/src/app/shared/services/planning-export.service.ts index 8811f8c..758db56 100644 --- a/route-planner/src/app/shared/services/planning-export.service.ts +++ b/route-planner/src/app/shared/services/planning-export.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core'; import * as XLSX from 'xlsx'; import { saveAs } from 'file-saver'; import { RouteState } from '../../core/route-state/route'; +import { sequenceRoutes, summarizeDailyRoutes } from '../utils/route-sequencing.utils'; @Injectable({ providedIn: 'root' }) export class PlanningExportService { @@ -28,8 +29,9 @@ export class PlanningExportService { } const total = assigned.length + unassigned.length; - const totalDistance = assigned.reduce((sum, route) => sum + (Number(route.json.distancia) || 0), 0); - const totalTravel = assigned.reduce((sum, route) => sum + (Number(route.json.horas_desplazamiento) || 0), 0); + const dailyRoutes = summarizeDailyRoutes(assigned); + const totalDistance = dailyRoutes.reduce((sum, route) => sum + route.distanceKm, 0); + const totalTravel = dailyRoutes.reduce((sum, route) => sum + route.travelHours, 0); const workbook = XLSX.utils.book_new(); const summary = [ @@ -43,16 +45,18 @@ export class PlanningExportService { { Indicador: 'Desplazamiento total (h)', Valor: Number(totalTravel.toFixed(2)) } ]; - const assignedRows = assigned.map(route => ({ - 'Usuario ID': route.json.usuario_id, - Usuario: route.json.nombre_usuario, - 'PDV ID': route.json.pdv_id, - PDV: route.json.nombre_pdv, - Día: route.json.dia, - 'Distancia (km)': route.json.distancia, - 'Desplazamiento (h)': route.json.horas_desplazamiento, - 'Horas trabajo': route.json.horas_trabajo, - 'Horas disponibles': route.json.hora_laboral_semanal_usuario + const assignedRows = sequenceRoutes(assigned).map(segment => ({ + 'Usuario ID': segment.route.json.usuario_id, + Usuario: segment.route.json.nombre_usuario, + 'PDV ID': segment.route.json.pdv_id, + PDV: segment.route.json.nombre_pdv, + Día: segment.route.json.dia, + 'Distancia (km)': segment.route.json.distancia, + 'Desplazamiento (h)': segment.route.json.horas_desplazamiento, + 'Horas trabajo': segment.route.json.horas_trabajo, + 'Horas disponibles': segment.route.json.hora_laboral_semanal_usuario, + 'Orden visita': segment.order, + 'Origen del tramo': segment.previousPdvName ?? `Casa de ${segment.route.json.nombre_usuario}` })); const unassignedRows = unassigned.map(route => ({ 'PDV ID': route.pdv_id, @@ -62,20 +66,34 @@ export class PlanningExportService { Longitud: route.longitud, Motivo: route.motivo })); - const userRows = Array.from(users, ([id, user]) => ({ - 'Usuario ID': id, - Usuario: user.nombre, - Rutas: user.rutas, - 'Distancia (km)': Number(user.distancia.toFixed(2)), - 'Horas trabajo': Number(user.trabajo.toFixed(2)), - 'Capacidad (h)': user.capacidad, - 'Utilización (%)': user.capacidad ? Number(((user.trabajo / user.capacidad) * 100).toFixed(1)) : 0 - })); + const userRows = Array.from(users, ([id, user]) => { + const utilization = user.capacidad ? (user.trabajo / user.capacidad) * 100 : 0; + return { + 'Usuario ID': id, + Usuario: user.nombre, + Rutas: user.rutas, + 'Distancia (km)': Number(user.distancia.toFixed(2)), + 'Horas trabajo': Number(user.trabajo.toFixed(2)), + 'Capacidad (h)': user.capacidad, + 'Utilización (%)': Number(utilization.toFixed(1)), + Estado: utilization > 100 ? 'Excede capacidad' : utilization > 90 ? 'Riesgo' : 'OK' + }; + }); this.appendSheet(workbook, summary, 'Resumen'); this.appendSheet(workbook, assignedRows, 'Rutas asignadas'); this.appendSheet(workbook, unassignedRows, 'PDVs sin asignar'); this.appendSheet(workbook, userRows, 'Carga por usuario'); + this.appendSheet(workbook, dailyRoutes.map(route => ({ + Usuario: route.userName, + Día: route.day, + Paradas: route.routes.length, + 'Distancia secuencial (km)': Number(route.distanceKm.toFixed(2)), + 'Desplazamiento secuencial (h)': Number(route.travelHours.toFixed(2)), + 'Carga total (h)': Number(route.totalHours.toFixed(2)), + 'Capacidad diaria (h)': Number(route.capacityHours.toFixed(2)), + Estado: route.exceedsCapacity ? 'Excede capacidad' : 'OK' + })), 'Rutas diarias'); const output = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' }); saveAs(new Blob([output], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), 'planificacion_rutas.xlsx'); diff --git a/route-planner/src/app/shared/utils/route-sequencing.utils.ts b/route-planner/src/app/shared/utils/route-sequencing.utils.ts index 8878f92..1465139 100644 --- a/route-planner/src/app/shared/utils/route-sequencing.utils.ts +++ b/route-planner/src/app/shared/utils/route-sequencing.utils.ts @@ -9,6 +9,20 @@ export interface SequencedRoute { previousPdvName: string | null; } +export interface DailyRouteSummary { + key: string; + userId: string; + userName: string; + day: string; + routes: SequencedRoute[]; + distanceKm: number; + travelHours: number; + workHours: number; + capacityHours: number; + totalHours: number; + exceedsCapacity: boolean; +} + export function sequenceRoutes(routes: RouteAssignedResponse[]): SequencedRoute[] { const groups = new Map(); for (const route of routes) { @@ -23,17 +37,22 @@ export function sequenceRoutes(routes: RouteAssignedResponse[]): SequencedRoute[ const first = group[0].json; let current: [number, number] = [Number(first.longitud_usuario), Number(first.latitud_usuario)]; let previousPdvName: string | null = null; - const remaining = [...group]; + const hasManualOrder = group.every(route => Number.isFinite(route.json.visit_order)); + const remaining = hasManualOrder + ? [...group].sort((a, b) => Number(a.json.visit_order) - Number(b.json.visit_order)) + : [...group]; while (remaining.length) { let nearestIndex = 0; - let nearestDistance = Number.POSITIVE_INFINITY; - for (let index = 0; index < remaining.length; index++) { - const candidate = remaining[index].json; - const distance = haversineKm(current, [Number(candidate.longitud_pdv), Number(candidate.latitud_pdv)]); - if (distance < nearestDistance) { - nearestDistance = distance; - nearestIndex = index; + if (!hasManualOrder) { + let nearestDistance = Number.POSITIVE_INFINITY; + for (let index = 0; index < remaining.length; index++) { + const candidate = remaining[index].json; + const distance = haversineKm(current, [Number(candidate.longitud_pdv), Number(candidate.latitud_pdv)]); + if (distance < nearestDistance) { + nearestDistance = distance; + nearestIndex = index; + } } } @@ -55,6 +74,38 @@ export function sequenceRoutes(routes: RouteAssignedResponse[]): SequencedRoute[ return sequenced; } +export function summarizeDailyRoutes(routes: RouteAssignedResponse[]): DailyRouteSummary[] { + const sequences = sequenceRoutes(routes); + const grouped = new Map(); + for (const segment of sequences) { + const key = `${segment.route.json.usuario_id}\u0000${segment.route.json.dia}`; + const group = grouped.get(key) ?? []; + group.push(segment); + grouped.set(key, group); + } + return Array.from(grouped, ([key, segments]) => { + const first = segments[0].route.json; + const distanceKm = segments.reduce((sum, segment) => sum + haversineKm(segment.origin, segment.destination), 0); + const travelHours = segments.reduce((sum, segment) => sum + Number(segment.route.json.horas_desplazamiento || 0), 0); + const workHours = segments.reduce((sum, segment) => sum + Number(segment.route.json.horas_trabajo || segment.route.json.hora_minima_semanal_pdv || 0), 0); + const capacityHours = Number(first.hora_laboral_semanal_usuario || 0) / 5; + const totalHours = workHours + travelHours; + return { + key, + userId: String(first.usuario_id), + userName: first.nombre_usuario, + day: first.dia, + routes: segments, + distanceKm, + travelHours, + workHours, + capacityHours, + totalHours, + exceedsCapacity: totalHours > capacityHours + }; + }); +} + export function haversineKm(origin: [number, number], destination: [number, number]): number { const toRadians = (value: number) => value * Math.PI / 180; const [lon1, lat1] = origin; diff --git a/route-planner/src/environments/environment.ts b/route-planner/src/environments/environment.ts index f8a8fc4..e99e431 100644 --- a/route-planner/src/environments/environment.ts +++ b/route-planner/src/environments/environment.ts @@ -1,6 +1,6 @@ export const environment = { production: false, - n8nWebhookUrl: 'http://localhost:5678/webhook/calcular-camino', + n8nWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/calcular-camino', orsBaseUrl: 'http://localhost:18080/ors/v2', requestTimeoutMs: 30000, };