From 9f42175afce29090eda926f5bed703005ec67940 Mon Sep 17 00:00:00 2001 From: EidanThen Date: Mon, 31 Aug 2026 17:56:33 -0400 Subject: [PATCH] feat: implement route assignment management and sequencing module with interactive data visualization and editing capabilities --- Proyecto/Optimizacion ruta1.1.xlsx | Bin 22607 -> 0 bytes Proyecto/Plantilla_PDV (2).xlsx | Bin 9528 -> 0 bytes Proyecto/Plantilla_PDV test.xlsx | Bin 0 -> 9361 bytes Proyecto/Plantilla_Usuarios (2).xlsx | Bin 9825 -> 0 bytes Proyecto/Plantilla_Usuarios test.xlsx | Bin 0 -> 9701 bytes .../OptimizadorRutasPDV TEST - Fixed v2.json | 2 +- .../src/app/core/models/route.model.ts | 3 +- .../src/app/core/route-state/route.ts | 95 +++++++++++++++--- .../core/services/planning-input.service.ts | 8 +- .../assigned-route.component.html | 1 + .../assigned-route.component.ts | 12 +++ .../src/app/private/home/home.component.html | 25 ----- .../src/app/private/home/home.component.ts | 44 +++----- .../no-assigned-route.component.css | 16 +++ .../no-assigned-route.component.html | 7 +- .../no-assigned-route.component.ts | 38 +++++-- .../planning-kpis/planning-kpis.component.css | 30 +++++- .../planning-kpis.component.html | 7 +- .../planning-kpis/planning-kpis.component.ts | 47 +++++---- .../route-overview-map.component.css | 53 ++-------- .../route-overview-map.component.html | 22 +++- .../route-overview-map.component.ts | 74 +++++++++++--- route-planner/src/app/shared/schemas/index.ts | 1 - .../src/app/shared/schemas/params.schema.ts | 11 -- .../src/app/shared/schemas/pdv.schema.ts | 16 ++- .../shared/services/excel-preview.service.ts | 35 +++++++ .../services/planning-export.service.ts | 60 +++++++---- .../shared/utils/route-sequencing.utils.ts | 67 ++++++++++-- route-planner/src/environments/environment.ts | 2 +- 29 files changed, 458 insertions(+), 218 deletions(-) delete mode 100644 Proyecto/Optimizacion ruta1.1.xlsx delete mode 100644 Proyecto/Plantilla_PDV (2).xlsx create mode 100644 Proyecto/Plantilla_PDV test.xlsx delete mode 100644 Proyecto/Plantilla_Usuarios (2).xlsx create mode 100644 Proyecto/Plantilla_Usuarios test.xlsx delete mode 100644 route-planner/src/app/shared/schemas/params.schema.ts diff --git a/Proyecto/Optimizacion ruta1.1.xlsx b/Proyecto/Optimizacion ruta1.1.xlsx deleted file mode 100644 index 2af1a3129a2142847ad7f910218109f0f12064cf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22607 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 diff --git a/Proyecto/Plantilla_PDV (2).xlsx b/Proyecto/Plantilla_PDV (2).xlsx deleted file mode 100644 index 48b9e8e862312e980a9b624a4d9c8256d61ba3e5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9528 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 G0Qf&lX`QwJ diff --git a/Proyecto/Plantilla_PDV test.xlsx b/Proyecto/Plantilla_PDV test.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..1e825c49c823908662e78012c8dd1636efba3813 GIT binary patch literal 9361 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 KH7+)3%(NEp?MFkqXDy08|zAFNN1fg<{Q86 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 diff --git a/Proyecto/Plantilla_Usuarios test.xlsx b/Proyecto/Plantilla_Usuarios test.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..09b44e290db4d6c0eebbe516bf309c717a9bb049 GIT binary patch literal 9701 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

0fZMU40 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 {\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, };