From a0ea3fea4828acac4487a66a7f230e4e90ef171f Mon Sep 17 00:00:00 2001 From: EidanThen Date: Fri, 28 Aug 2026 09:43:13 -0400 Subject: [PATCH 1/3] feat: subir json del flujo de n8n --- .../OptimizadorRutasPDV TEST - Fixed v2.json | 403 ++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json diff --git a/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json b/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json new file mode 100644 index 0000000..fff7a72 --- /dev/null +++ b/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json @@ -0,0 +1,403 @@ +{ + "name": "OptimizadorRutasPDV TEST - Fixed v2", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "calcular-camino", + "responseMode": "responseNode", + "options": {} + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + -736, + 544 + ], + "id": "f84fe2f2-2875-4e5c-ad99-4b4cc7a2c8d2", + "name": "Webhook", + "webhookId": "01fc4a9c-bad3-42b8-9f60-b87d91302502" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{$binary.pdv_file.fileName}}", + "rightValue": "", + "operator": { + "type": "string", + "operation": "exists", + "singleValue": true + } + }, + { + "id": "c2", + "leftValue": "={{$binary.usuarios_file.fileName}}", + "rightValue": "", + "operator": { + "type": "string", + "operation": "exists", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + -496, + 544 + ], + "id": "5ecfc62e-849c-442b-90b8-4885494e3820", + "name": "Validate Files" + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "{\n \"error\": \"Debe subir ambos archivos: PDV y Usuarios\"\n}", + "options": {} + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + -496, + 768 + ], + "id": "2c0adaf5-050d-4c2e-a509-ed77cc5a6784", + "name": "Error: Missing Files" + }, + { + "parameters": { + "operation": "xlsx", + "binaryPropertyName": "pdv_file", + "options": { + "headerRow": true + } + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + -160, + 416 + ], + "id": "90f15538-1ae9-454b-9038-b3d3cbf39f00", + "name": "Extract PDV" + }, + { + "parameters": { + "operation": "xlsx", + "binaryPropertyName": "usuarios_file", + "options": { + "headerRow": true + } + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + -160, + 640 + ], + "id": "a8a72a9e-7e38-486a-9db2-7efb130c6963", + "name": "Extract Usuarios" + }, + { + "parameters": { + "jsCode": "const allRows = $input.all().map(item => item.json);\nif (!allRows.length) {\n return [{ json: { success: false, data: [], message: 'PDV: Archivo vacío' } }];\n}\nconst keys = new Set(allRows.flatMap(r => Object.keys(r)));\nconst required = ['ID','Nombre del PDV','Segmentación','Latitud','Longitud','Visitas semanales','Duración visita(horas)','Prioridad'];\nconst missing = required.filter(c => !keys.has(c));\nlet message = '';\nlet success = true;\nif (missing.length) {\n success = false;\n message = `PDV: Columnas faltantes: ${missing.join(', ')}`;\n}\nif (allRows.length > 2000) {\n success = false;\n message = 'PDV: Máximo 2000 filas permitidas';\n}\nreturn [{ json: { success, data: $input.all(), message } }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 80, + 416 + ], + "id": "e7acdf1f-4741-4189-91b9-30a90d42b531", + "name": "Validate PDV" + }, + { + "parameters": { + "jsCode": "const allRows = $input.all().map(item => item.json);\nif (!allRows.length) {\n return [{ json: { success: false, data: [], message: 'Usuarios: Archivo vacío' } }];\n}\nconst keys = new Set(allRows.flatMap(r => Object.keys(r)));\nconst required = ['ID','Nombre del empleado','Latitud','Longitud','Horas por día usuario','Horas por semana usuario'];\nconst missing = required.filter(c => !keys.has(c));\nlet message = '';\nlet success = true;\nif (missing.length) {\n success = false;\n message = `Usuarios: Columnas faltantes: ${missing.join(', ')}`;\n}\nif (allRows.length > 50) {\n success = false;\n message = 'Usuarios: Máximo 50 filas permitidas';\n}\nreturn [{ json: { success, data: $input.all(), message } }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 80, + 640 + ], + "id": "0d734c5f-bf19-4eaa-9d82-16657523b53c", + "name": "Validate Usuarios" + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 336, + 544 + ], + "id": "e13702a2-1b82-4a1f-bba6-e8880c8346aa", + "name": "Merge Validations" + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ JSON.stringify({ error: $json[0].message || $json[1].message || 'Error de validación' }) }}", + "options": {} + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 576, + 768 + ], + "id": "d496c883-3206-47f5-89f4-f829ed167845", + "name": "Error: Validation Failed" + }, + { + "parameters": { + "jsCode": "const 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 VELOCIDAD_DESPLAZAMIENTO = 46;\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\nfunction mapPdv(p) {\n return {\n id: p.id,\n nombre_del_pdv: p.nombre_del_pdv,\n segmentacion: p.segmentacion || 'D',\n latitud: cleanNum(p.latitud),\n longitud: cleanNum(p.longitud),\n visitas_semanales: cleanNum(p.visitas_semanales),\n duracion_visita_horas: cleanNum(p.duracion_visitahoras),\n prioridad: cleanNum(p.prioridad),\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 };\n}\n\nconst pdvsMapeados = pdvs.map(mapPdv);\nconst usuariosMapeados = usuarios.map(mapUsuario);\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 pdvsSort = [...pdvsMapeados].sort((a, b) => {\n const idxA = orden.indexOf(a.segmentacion);\n const idxB = orden.indexOf(b.segmentacion);\n return idxA - idxB || a.prioridad - b.prioridad;\n });\n\n for (const pdv of pdvsSort) {\n if (horasNecesita[pdv.id] <= 0) {\n PDVnoAsignados.push({ pdv_id: pdv.id, pdv_nombre: pdv.nombre_del_pdv, minimo_horas_semana: horasNecesita[pdv.id], latitud: pdv.latitud, longitud: pdv.longitud, motivo: 'Sin horas requeridas' });\n continue;\n }\n\n let pdvAsignado = false;\n const candidatos = usuariosMapeados\n .map(u => ({\n usuario: u,\n distancia: calcularDistancia(u.latitud, u.longitud, pdv.latitud, pdv.longitud)\n }))\n .filter(c => c.distancia <= 50)\n .sort((a, b) => a.distancia - b.distancia);\n\n for (const cand of candidatos) {\n if (pdvAsignado) break;\n const u = cand.usuario;\n const horasUsuario = horasDisp[u.id];\n const horasPdv = horasNecesita[pdv.id];\n const horasAsignadas = Math.min(horasUsuario, horasPdv);\n\n if (horasAsignadas <= 0) 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 });\n horasDisp[u.id] -= horasAsignadas;\n horasNecesita[pdv.id] -= horasAsignadas;\n pdvAsignado = true;\n }\n\n if (!pdvAsignado) {\n PDVnoAsignados.push({ pdv_id: pdv.id, pdv_nombre: pdv.nombre_del_pdv, minimo_horas_semana: pdv.duracion_visita_horas * pdv.visitas_semanales, latitud: pdv.latitud, longitud: pdv.longitud, motivo: 'Sin usuario disponible dentro de 50km' });\n }\n }\n}\n\nfunction asignarDiasHorasVisita() {\n const DIAS = ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes'];\n const visitas = [];\n for (const a of asignaciones) {\n let horasRestantes = a.horas_asignadas;\n for (let i = 0; i < DIAS.length; i++) {\n const tiempoDesplazamiento = a.distancia / VELOCIDAD_DESPLAZAMIENTO;\n const horasDia = horasRestantes / (DIAS.length - i);\n if (horasDia <= a.horasDiariasUsuario) {\n visitas.push({\n usuario_id: a.usuario_id,\n pdv_id: a.pdv_id,\n dia: DIAS[i],\n horas_trabajo: Number(horasDia.toFixed(2)),\n horas_desplazamiento: Number(tiempoDesplazamiento.toFixed(2)),\n distancia: Number(a.distancia.toFixed(2)),\n });\n horasRestantes -= horasDia;\n } else {\n PDVnoAsignados.push({ pdv_id: a.pdv_id, pdv_nombre: '', minimo_horas_semana: 0, latitud: 0, longitud: 0, motivo: 'Excedió horas por día del usuario' });\n }\n }\n }\n return visitas;\n}\n\nasignarPdvUsuarios();\nconst visitas = asignarDiasHorasVisita();\n\nconst usuariosById = Object.fromEntries(usuariosMapeados.map(u => [u.id, u]));\nconst pdvsById = Object.fromEntries(pdvsMapeados.map(p => [p.id, p]));\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 }));\nconst bFiltrado = PDVnoAsignados.filter(pdv => !a.some(x => x.json.pdv_id === pdv.pdv_id));\n\nreturn [{ assigned: a }, { noAssigned: bFiltrado }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 816, + 544 + ], + "id": "e9fe9826-4755-497e-ae6e-274e7c1d4f66", + "name": "Optimize Routes" + }, + { + "parameters": { + "respondWith": "allIncomingItems", + "options": {} + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 1024, + 544 + ], + "id": "b024c203-1b36-4f7c-97bb-3923481b7a9b", + "name": "Respond Success" + }, + { + "parameters": { + "jsCode": "const items = $input.all();\nconst pdv = items[0]; // Validate PDV output\nconst usuarios = items[1]; // Validate Usuarios output\n\nconst pdvOk = pdv?.json?.success === true;\nconst usuariosOk = usuarios?.json?.success === true;\n\nif (pdvOk && usuariosOk) {\n // Both passed → send to Optimize Routes (output 0)\n return items;\n} else {\n // Failed → send to Error (output 1)\n const msgs = [];\n if (!pdvOk) msgs.push(pdv?.json?.message || 'Error PDV');\n if (!usuariosOk) msgs.push(usuarios?.json?.message || 'Error Usuarios');\n throw new Error(msgs.join(' | '));\n}" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 576, + 544 + ], + "id": "27eb73de-4b08-4c44-97de-4d122a98749c", + "name": "Code in JavaScript", + "onError": "continueErrorOutput" + } + ], + "pinData": { + "Webhook": [ + { + "json": { + "headers": { + "host": "agenteit.digitalcompass.agency", + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36", + "content-length": "19829", + "accept": "application/json, text/plain, */*", + "accept-encoding": "gzip, deflate, br, zstd", + "accept-language": "en-US,en;q=0.6", + "content-type": "multipart/form-data; boundary=----WebKitFormBoundaryeTQ0nhU3IDSv0jrS", + "origin": "http://localhost:4200", + "priority": "u=1, i", + "referer": "http://localhost:4200/", + "sec-ch-ua": "\"Not=A?Brand\";v=\"99\", \"Brave\";v=\"151\", \"Chromium\";v=\"151\"", + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": "\"Windows\"", + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "cross-site", + "sec-gpc": "1", + "x-forwarded-for": "152.0.172.53", + "x-forwarded-host": "agenteit.digitalcompass.agency", + "x-forwarded-port": "443", + "x-forwarded-proto": "https", + "x-forwarded-server": "07b4f09d2c65", + "x-real-ip": "152.0.172.53" + }, + "params": {}, + "query": {}, + "body": {}, + "webhookUrl": "https://agenteit.digitalcompass.agency/webhook/calcular-camino", + "executionMode": "production" + }, + "binary": { + "pdv_file": { + "mimeType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "fileExtension": "xlsx", + "data": "filesystem-v2", + "fileName": "Plantilla_PDV (2).xlsx", + "id": "filesystem-v2:workflows/IkKMqgb7uZuRk9d9/executions/48469/binary_data/48a414e1-9c74-469b-9bec-04283032f093", + "fileSize": "9.53 kB", + "bytes": 9528 + }, + "usuarios_file": { + "mimeType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "fileExtension": "xlsx", + "data": "filesystem-v2", + "fileName": "Plantilla_Usuarios (2).xlsx", + "id": "filesystem-v2:workflows/IkKMqgb7uZuRk9d9/executions/48469/binary_data/6553aefd-4d40-45dd-9cdd-bfb1b1d1ec84", + "fileSize": "9.82 kB", + "bytes": 9825 + } + }, + "pairedItem": { + "item": 0 + } + } + ] + }, + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Validate Files", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validate Files": { + "main": [ + [ + { + "node": "Extract PDV", + "type": "main", + "index": 0 + }, + { + "node": "Extract Usuarios", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Error: Missing Files", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract PDV": { + "main": [ + [ + { + "node": "Validate PDV", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Usuarios": { + "main": [ + [ + { + "node": "Validate Usuarios", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validate PDV": { + "main": [ + [ + { + "node": "Merge Validations", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validate Usuarios": { + "main": [ + [ + { + "node": "Merge Validations", + "type": "main", + "index": 1 + } + ] + ] + }, + "Merge Validations": { + "main": [ + [ + { + "node": "Code in JavaScript", + "type": "main", + "index": 0 + } + ] + ] + }, + "Optimize Routes": { + "main": [ + [ + { + "node": "Respond Success", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code in JavaScript": { + "main": [ + [ + { + "node": "Optimize Routes", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Error: Validation Failed", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": true, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate", + "timeSavedMode": "fixed", + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": true + }, + "versionId": "f0dcd3f6-8043-4c22-9ca9-a477ae69db53", + "meta": { + "instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70" + }, + "id": "IkKMqgb7uZuRk9d9", + "tags": [] +} \ No newline at end of file -- 2.52.0 From cd5c57c5eebd45108c2963176fc8e2ba0922e91d Mon Sep 17 00:00:00 2001 From: EidanThen Date: Sat, 29 Aug 2026 11:55:03 -0400 Subject: [PATCH 2/3] feat: implement route planning dashboard including KPIs, user workload tracking, and route assignment management features --- .../OptimizadorRutasPDV TEST - Fixed v2.json | 4 +- route-planner/angular.json | 19 +- route-planner/package-lock.json | 16 + route-planner/package.json | 2 + route-planner/src/app/app.routes.ts | 2 + .../src/app/core/route-state/route.ts | 208 ++++- .../src/app/core/services/ors.service.ts | 132 ++- .../core/services/planning-input.service.ts | 31 + .../src/app/core/services/router.service.ts | 22 +- .../assigned-route-detail.component.css | 28 + .../assigned-route-detail.component.html | 2 + .../assigned-route-detail.component.ts | 124 ++- .../assigned-route.component.css | 28 + .../assigned-route.component.html | 42 +- .../assigned-route.component.ts | 127 ++- .../src/app/private/home/home.component.css | 155 ++++ .../src/app/private/home/home.component.html | 103 ++- .../src/app/private/home/home.component.ts | 202 ++++- .../no-assigned-route.component.css | 39 + .../no-assigned-route.component.html | 21 +- .../no-assigned-route.component.ts | 80 +- .../planning-kpis/planning-kpis.component.css | 256 ++++++ .../planning-kpis.component.html | 98 +++ .../planning-kpis/planning-kpis.component.ts | 173 ++++ .../route-overview-map.component.css | 556 +++++++++++++ .../route-overview-map.component.html | 165 ++++ .../route-overview-map.component.ts | 785 ++++++++++++++++++ route-planner/src/app/shared/schemas/index.ts | 3 + .../src/app/shared/schemas/params.schema.ts | 11 + .../src/app/shared/schemas/pdv.schema.ts | 41 + .../src/app/shared/schemas/usuario.schema.ts | 39 + .../shared/services/excel-preview.service.ts | 182 ++++ .../services/planning-export.service.ts | 88 ++ .../utils/header-normalization.utils.ts | 29 + route-planner/src/app/shared/utils/index.ts | 5 +- .../src/app/shared/utils/palette.utils.ts | 24 + .../shared/utils/route-sequencing.utils.ts | 67 ++ .../src/environments/environment.prod.ts | 6 + route-planner/src/environments/environment.ts | 6 + route-planner/src/index.html | 1 - route-planner/src/styles.css | 40 + 41 files changed, 3811 insertions(+), 151 deletions(-) create mode 100644 route-planner/src/app/core/services/planning-input.service.ts create mode 100644 route-planner/src/app/private/home/planning-kpis/planning-kpis.component.css create mode 100644 route-planner/src/app/private/home/planning-kpis/planning-kpis.component.html create mode 100644 route-planner/src/app/private/home/planning-kpis/planning-kpis.component.ts create mode 100644 route-planner/src/app/private/home/route-overview-map/route-overview-map.component.css create mode 100644 route-planner/src/app/private/home/route-overview-map/route-overview-map.component.html create mode 100644 route-planner/src/app/private/home/route-overview-map/route-overview-map.component.ts create mode 100644 route-planner/src/app/shared/schemas/index.ts create mode 100644 route-planner/src/app/shared/schemas/params.schema.ts create mode 100644 route-planner/src/app/shared/schemas/pdv.schema.ts create mode 100644 route-planner/src/app/shared/schemas/usuario.schema.ts create mode 100644 route-planner/src/app/shared/services/excel-preview.service.ts create mode 100644 route-planner/src/app/shared/services/planning-export.service.ts create mode 100644 route-planner/src/app/shared/utils/header-normalization.utils.ts create mode 100644 route-planner/src/app/shared/utils/palette.utils.ts create mode 100644 route-planner/src/app/shared/utils/route-sequencing.utils.ts create mode 100644 route-planner/src/environments/environment.prod.ts create mode 100644 route-planner/src/environments/environment.ts diff --git a/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json b/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json index fff7a72..a8e76d2 100644 --- a/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json +++ b/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json @@ -1,5 +1,5 @@ { - "name": "OptimizadorRutasPDV TEST - Fixed v2", + "name": "OptimizadorRutasPDV TEST - Fixed v4", "nodes": [ { "parameters": { @@ -165,7 +165,7 @@ }, { "parameters": { - "jsCode": "const 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 VELOCIDAD_DESPLAZAMIENTO = 46;\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\nfunction mapPdv(p) {\n return {\n id: p.id,\n nombre_del_pdv: p.nombre_del_pdv,\n segmentacion: p.segmentacion || 'D',\n latitud: cleanNum(p.latitud),\n longitud: cleanNum(p.longitud),\n visitas_semanales: cleanNum(p.visitas_semanales),\n duracion_visita_horas: cleanNum(p.duracion_visitahoras),\n prioridad: cleanNum(p.prioridad),\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 };\n}\n\nconst pdvsMapeados = pdvs.map(mapPdv);\nconst usuariosMapeados = usuarios.map(mapUsuario);\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 pdvsSort = [...pdvsMapeados].sort((a, b) => {\n const idxA = orden.indexOf(a.segmentacion);\n const idxB = orden.indexOf(b.segmentacion);\n return idxA - idxB || a.prioridad - b.prioridad;\n });\n\n for (const pdv of pdvsSort) {\n if (horasNecesita[pdv.id] <= 0) {\n PDVnoAsignados.push({ pdv_id: pdv.id, pdv_nombre: pdv.nombre_del_pdv, minimo_horas_semana: horasNecesita[pdv.id], latitud: pdv.latitud, longitud: pdv.longitud, motivo: 'Sin horas requeridas' });\n continue;\n }\n\n let pdvAsignado = false;\n const candidatos = usuariosMapeados\n .map(u => ({\n usuario: u,\n distancia: calcularDistancia(u.latitud, u.longitud, pdv.latitud, pdv.longitud)\n }))\n .filter(c => c.distancia <= 50)\n .sort((a, b) => a.distancia - b.distancia);\n\n for (const cand of candidatos) {\n if (pdvAsignado) break;\n const u = cand.usuario;\n const horasUsuario = horasDisp[u.id];\n const horasPdv = horasNecesita[pdv.id];\n const horasAsignadas = Math.min(horasUsuario, horasPdv);\n\n if (horasAsignadas <= 0) 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 });\n horasDisp[u.id] -= horasAsignadas;\n horasNecesita[pdv.id] -= horasAsignadas;\n pdvAsignado = true;\n }\n\n if (!pdvAsignado) {\n PDVnoAsignados.push({ pdv_id: pdv.id, pdv_nombre: pdv.nombre_del_pdv, minimo_horas_semana: pdv.duracion_visita_horas * pdv.visitas_semanales, latitud: pdv.latitud, longitud: pdv.longitud, motivo: 'Sin usuario disponible dentro de 50km' });\n }\n }\n}\n\nfunction asignarDiasHorasVisita() {\n const DIAS = ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes'];\n const visitas = [];\n for (const a of asignaciones) {\n let horasRestantes = a.horas_asignadas;\n for (let i = 0; i < DIAS.length; i++) {\n const tiempoDesplazamiento = a.distancia / VELOCIDAD_DESPLAZAMIENTO;\n const horasDia = horasRestantes / (DIAS.length - i);\n if (horasDia <= a.horasDiariasUsuario) {\n visitas.push({\n usuario_id: a.usuario_id,\n pdv_id: a.pdv_id,\n dia: DIAS[i],\n horas_trabajo: Number(horasDia.toFixed(2)),\n horas_desplazamiento: Number(tiempoDesplazamiento.toFixed(2)),\n distancia: Number(a.distancia.toFixed(2)),\n });\n horasRestantes -= horasDia;\n } else {\n PDVnoAsignados.push({ pdv_id: a.pdv_id, pdv_nombre: '', minimo_horas_semana: 0, latitud: 0, longitud: 0, motivo: 'Excedió horas por día del usuario' });\n }\n }\n }\n return visitas;\n}\n\nasignarPdvUsuarios();\nconst visitas = asignarDiasHorasVisita();\n\nconst usuariosById = Object.fromEntries(usuariosMapeados.map(u => [u.id, u]));\nconst pdvsById = Object.fromEntries(pdvsMapeados.map(p => [p.id, p]));\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 }));\nconst bFiltrado = PDVnoAsignados.filter(pdv => !a.some(x => x.json.pdv_id === pdv.pdv_id));\n\nreturn [{ assigned: a }, { noAssigned: bFiltrado }];" + "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})();" }, "type": "n8n-nodes-base.code", "typeVersion": 2, diff --git a/route-planner/angular.json b/route-planner/angular.json index b15a551..db8aa6d 100644 --- a/route-planner/angular.json +++ b/route-planner/angular.json @@ -18,16 +18,27 @@ "polyfills": ["zone.js"], "tsConfig": "tsconfig.app.json", "assets": [{ "glob": "**/*", "input": "public" }], - "styles": ["src/styles.css"], + "styles": [ + "src/styles.css", + "node_modules/leaflet/dist/leaflet.css", + "node_modules/leaflet.markercluster/dist/MarkerCluster.css", + "node_modules/leaflet.markercluster/dist/MarkerCluster.Default.css" + ], "scripts": [] }, "configurations": { "production": { "budgets": [ - { "type": "initial", "maximumWarning": "1.5MB", "maximumError": "2MB" }, - { "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" } + { "type": "initial", "maximumWarning": "2.1MB", "maximumError": "3MB" }, + { "type": "anyComponentStyle", "maximumWarning": "6kB", "maximumError": "10kB" } ], - "outputHashing": "all" + "outputHashing": "all", + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ] }, "development": { "optimization": false, diff --git a/route-planner/package-lock.json b/route-planner/package-lock.json index e8b64a2..5e31a70 100644 --- a/route-planner/package-lock.json +++ b/route-planner/package-lock.json @@ -17,6 +17,8 @@ "@primeng/themes": "^21.0.0", "file-saver": "^2.0.5", "leaflet": "^1.9.4", + "leaflet.heat": "^0.2.0", + "leaflet.markercluster": "^1.5.3", "primeicons": "^7.0.0", "primeng": "^21.0.0", "rxjs": "~7.8.2", @@ -6226,6 +6228,20 @@ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", "license": "BSD-2-Clause" }, + "node_modules/leaflet.heat": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/leaflet.heat/-/leaflet.heat-0.2.0.tgz", + "integrity": "sha512-Cd5PbAA/rX3X3XKxfDoUGi9qp78FyhWYurFg3nsfhntcM/MCNK08pRkf4iEenO1KNqwVPKCmkyktjW3UD+h9bQ==" + }, + "node_modules/leaflet.markercluster": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", + "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==", + "license": "MIT", + "peerDependencies": { + "leaflet": "^1.3.1" + } + }, "node_modules/listr2": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", diff --git a/route-planner/package.json b/route-planner/package.json index 0edfa83..6659ebe 100644 --- a/route-planner/package.json +++ b/route-planner/package.json @@ -19,6 +19,8 @@ "@primeng/themes": "^21.0.0", "file-saver": "^2.0.5", "leaflet": "^1.9.4", + "leaflet.heat": "^0.2.0", + "leaflet.markercluster": "^1.5.3", "primeicons": "^7.0.0", "primeng": "^21.0.0", "rxjs": "~7.8.2", diff --git a/route-planner/src/app/app.routes.ts b/route-planner/src/app/app.routes.ts index 133996e..0c37435 100644 --- a/route-planner/src/app/app.routes.ts +++ b/route-planner/src/app/app.routes.ts @@ -2,11 +2,13 @@ import { Routes } from '@angular/router'; import { LoginComponent } from './public/login/login.component'; import { HomeComponent } from './private/home/home.component'; import { AssignedRouteDetailComponent } from './private/home/assigned-route-detail/assigned-route-detail.component'; +import { RouteOverviewMapComponent } from './private/home/route-overview-map/route-overview-map.component'; export const routes: Routes = [ { path: '', redirectTo: 'login', pathMatch: 'full' }, { path: 'login', component: LoginComponent }, { path: 'home', component: HomeComponent, pathMatch: 'full' }, { path: 'home/assigned-route', component: AssignedRouteDetailComponent }, + { path: 'home/mapa', component: RouteOverviewMapComponent }, { path: '**', redirectTo: 'login' } ]; diff --git a/route-planner/src/app/core/route-state/route.ts b/route-planner/src/app/core/route-state/route.ts index c956f33..a5dc605 100644 --- a/route-planner/src/app/core/route-state/route.ts +++ b/route-planner/src/app/core/route-state/route.ts @@ -5,19 +5,26 @@ import { RouteNoAssignedResponse } from '../models/route.model'; +const STORAGE_ASSIGNED = 'rp_assigned_routes'; +const STORAGE_ASSIGNED_ORIGINAL = 'rp_assigned_routes_original'; +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']; + @Injectable({ providedIn: 'root' }) export class RouteState { private loading = new BehaviorSubject(false); loading$ = this.loading.asObservable(); - private assignedRoutes = new BehaviorSubject([]); + private assignedRoutes = new BehaviorSubject(this.loadAssigned()); assignedRoutes$ = this.assignedRoutes.asObservable(); - private noAssignedRoutes = new BehaviorSubject([]); + private noAssignedRoutes = new BehaviorSubject(this.loadNoAssigned()); noAssignedRoutes$ = this.noAssignedRoutes.asObservable(); - private selectedRoute = new BehaviorSubject(null); + private selectedRoute = new BehaviorSubject(this.loadSelected()); selectedRoute$ = this.selectedRoute.asObservable(); resetLoading() { @@ -30,13 +37,208 @@ export class RouteState { setAssignedRoutes(data: RouteAssignedResponse[]) { this.assignedRoutes.next(data); + this.persist(STORAGE_ASSIGNED, data); + this.persist(STORAGE_ASSIGNED_ORIGINAL, data); + } + + updateAssignedRoute( + route: RouteAssignedResponse, + userId: string, + day: string, + metrics?: { distance: number; duration: number } + ): void { + const profile = this.assignedRoutes.getValue().find(item => String(item.json.usuario_id) === String(userId)); + if (!profile) return; + const updated = this.assignedRoutes.getValue().map(item => { + if (item !== route) return item; + return { + json: { + ...item.json, + usuario_id: profile.json.usuario_id, + nombre_usuario: profile.json.nombre_usuario, + latitud_usuario: profile.json.latitud_usuario, + longitud_usuario: profile.json.longitud_usuario, + hora_laboral_semanal_usuario: profile.json.hora_laboral_semanal_usuario, + dia: day, + distancia: metrics?.distance ?? item.json.distancia, + horas_desplazamiento: metrics?.duration ?? item.json.horas_desplazamiento + } + }; + }); + this.setDraftAssignedRoutes(updated); + } + + 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, + 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), + latitud_usuario: profile.json.latitud_usuario, + longitud_usuario: profile.json.longitud_usuario, + latitud_pdv: route.latitud, + longitud_pdv: route.longitud, + nombre_usuario: profile.json.nombre_usuario, + nombre_pdv: route.pdv_nombre, + usuario_id: profile.json.usuario_id, + pdv_id: route.pdv_id + } + }; + this.setDraftAssignedRoutes([...this.assignedRoutes.getValue(), newRoute]); + this.setDraftNoAssignedRoutes(this.noAssignedRoutes.getValue().filter(item => item.pdv_id !== route.pdv_id)); + } + + updateNoAssignedRoute(route: RouteNoAssignedResponse, changes: Partial): void { + const updated = this.noAssignedRoutes.getValue().map(item => item.pdv_id === route.pdv_id ? { ...item, ...changes } : item); + this.setDraftNoAssignedRoutes(updated); + } + + restoreOriginalAssignedRoutes(): void { + const original = this.loadOriginalAssigned(); + this.setDraftAssignedRoutes(original); + } + + reoptimizeAssignedRoutes(): void { + const current = this.assignedRoutes.getValue(); + const profiles = Array.from(new Map(current.map(route => [String(route.json.usuario_id), route])).values()); + if (profiles.length < 2) return; + + const load = new Map(profiles.map(profile => [String(profile.json.usuario_id), 0])); + const optimized = [...current] + .sort((a, b) => Number(b.json.hora_minima_semanal_pdv) - Number(a.json.hora_minima_semanal_pdv)) + .map(route => { + const user = 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); + return { + json: { + ...route.json, + usuario_id: user.json.usuario_id, + nombre_usuario: user.json.nombre_usuario, + latitud_usuario: user.json.latitud_usuario, + longitud_usuario: user.json.longitud_usuario, + hora_laboral_semanal_usuario: user.json.hora_laboral_semanal_usuario + } + }; + }); + this.setDraftAssignedRoutes(optimized); } setNoAssignedRoutes(data: RouteNoAssignedResponse[]) { this.noAssignedRoutes.next(data); + this.persist(STORAGE_NO_ASSIGNED, data); + this.persist(STORAGE_NO_ASSIGNED_ORIGINAL, data); } sendRoute(route: RouteAssignedResponse) { this.selectedRoute.next(route); + this.persist(STORAGE_SELECTED, route); + } + + getAssignedSnapshot(): RouteAssignedResponse[] { + return this.assignedRoutes.getValue(); + } + + getNoAssignedSnapshot(): RouteNoAssignedResponse[] { + return this.noAssignedRoutes.getValue(); + } + + findRoute(pdvId: string | number, usuarioId?: string): RouteAssignedResponse | null { + const pid = String(pdvId); + const routes = this.getAssignedSnapshot(); + const found = routes.find(r => { + const jp = r.json; + if (String(jp.pdv_id) !== pid) return false; + if (usuarioId && String(jp.usuario_id) !== String(usuarioId)) return false; + return true; + }); + return found ?? null; + } + + getUsuariosUnicos(): { id: string; nombre: string }[] { + const map = new Map(); + for (const r of this.getAssignedSnapshot()) { + const id = String(r.json.usuario_id); + if (!map.has(id)) map.set(id, r.json.nombre_usuario); + } + return Array.from(map.entries()).map(([id, nombre]) => ({ id, nombre })); + } + + getDiasUnicos(): string[] { + const set = new Set(); + for (const r of this.getAssignedSnapshot()) set.add(r.json.dia); + return Array.from(set).sort((a, b) => { + const indexA = DAY_ORDER.indexOf(a); + const indexB = DAY_ORDER.indexOf(b); + return (indexA < 0 ? DAY_ORDER.length : indexA) - (indexB < 0 ? DAY_ORDER.length : indexB); + }); + } + + clearAll() { + this.assignedRoutes.next([]); + this.noAssignedRoutes.next([]); + this.selectedRoute.next(null); + try { + sessionStorage.removeItem(STORAGE_ASSIGNED); + sessionStorage.removeItem(STORAGE_ASSIGNED_ORIGINAL); + sessionStorage.removeItem(STORAGE_NO_ASSIGNED); + sessionStorage.removeItem(STORAGE_NO_ASSIGNED_ORIGINAL); + sessionStorage.removeItem(STORAGE_SELECTED); + } catch { /* ignore */ } + } + + private persist(key: string, data: unknown) { + try { + sessionStorage.setItem(key, JSON.stringify(data)); + } catch { /* ignore storage errors */ } + } + + private setDraftAssignedRoutes(data: RouteAssignedResponse[]): void { + this.assignedRoutes.next(data); + this.persist(STORAGE_ASSIGNED, data); + } + + private setDraftNoAssignedRoutes(data: RouteNoAssignedResponse[]): void { + this.noAssignedRoutes.next(data); + this.persist(STORAGE_NO_ASSIGNED, data); + } + + private loadAssigned(): RouteAssignedResponse[] { + try { + const raw = sessionStorage.getItem(STORAGE_ASSIGNED); + if (raw) return JSON.parse(raw) as RouteAssignedResponse[]; + } catch { /* ignore */ } + return []; + } + + private loadOriginalAssigned(): RouteAssignedResponse[] { + try { + const raw = sessionStorage.getItem(STORAGE_ASSIGNED_ORIGINAL); + if (raw) return JSON.parse(raw) as RouteAssignedResponse[]; + } catch { /* ignore */ } + return this.loadAssigned(); + } + + private loadNoAssigned(): RouteNoAssignedResponse[] { + try { + const raw = sessionStorage.getItem(STORAGE_NO_ASSIGNED); + if (raw) return JSON.parse(raw) as RouteNoAssignedResponse[]; + } catch { /* ignore */ } + return []; + } + + private loadSelected(): RouteAssignedResponse | null { + try { + const raw = sessionStorage.getItem(STORAGE_SELECTED); + if (raw) return JSON.parse(raw) as RouteAssignedResponse; + } catch { /* ignore */ } + return null; } } diff --git a/route-planner/src/app/core/services/ors.service.ts b/route-planner/src/app/core/services/ors.service.ts index 864975b..02b5db2 100644 --- a/route-planner/src/app/core/services/ors.service.ts +++ b/route-planner/src/app/core/services/ors.service.ts @@ -9,11 +9,15 @@ import { catchError, map, toArray, - timeout + timeout, + Observable, + Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { environment } from '../../../environments/environment'; -const ENDPOINT = 'http://localhost:18080/ors/v2/directions/driving-car'; -const ENDPOINTJSON = 'http://localhost:18080/ors/v2/directions/driving-car/geojson'; +const ENDPOINT = `${environment.orsBaseUrl}/directions/driving-car`; +const ENDPOINTJSON = `${environment.orsBaseUrl}/directions/driving-car/geojson`; const MAX_CONCURRENT_REQUESTS = 2; @Injectable({ @@ -21,8 +25,14 @@ const MAX_CONCURRENT_REQUESTS = 2; }) export class OrsService { + private geoCache = new Map(); + constructor(private http: HttpClient) { } + private cacheKey(origin: number[], dest: number[]): string { + return `${origin[0].toFixed(5)},${origin[1].toFixed(5)}->${dest[0].toFixed(5)},${dest[1].toFixed(5)}`; + } + private fetchRoute( origin: number[], destination: number[], @@ -32,7 +42,7 @@ export class OrsService { coordinates: [origin, destination] }; - return this.http.post(isJson ? ENDPOINTJSON : ENDPOINT, body).pipe( + return this.http.post(isJson ? ENDPOINTJSON : ENDPOINT, body).pipe( timeout(8000), catchError(error => { throw error; @@ -48,37 +58,91 @@ export class OrsService { }[], concurrency = MAX_CONCURRENT_REQUESTS, isJson = false - ): Promise { - return lastValueFrom( - from(pares).pipe( - mergeMap( - pair => - this.fetchRoute(pair.origin, pair.destination, isJson).pipe( - delay(300), + ): Promise { + return this.processPairsWithProgress(pares, concurrency, isJson).promise; + } + + processPairsWithProgress( + pares: { + pdv_id: number; + origin: number[]; + destination: number[]; + }[], + concurrency = MAX_CONCURRENT_REQUESTS, + isJson = false, + abort$?: Observable + ): { promise: Promise; progress$: Observable<{ completed: number; total: number; item: unknown }> } { + const progressSubject = new Subject<{ completed: number; total: number; item: unknown }>(); + let completed = 0; + + let source$: Observable; + const base$ = from(pares).pipe( + mergeMap( + pair => { + const key = this.cacheKey(pair.origin, pair.destination); + if (isJson && this.geoCache.has(key)) { + const cached = this.geoCache.get(key); + const wrapped = { pdv_id: pair.pdv_id, geojson: cached }; + return of(wrapped).pipe( map(data => { - if (!isJson) { - const summary = data.routes[0].summary; - return { - pdv_id: pair.pdv_id, - distance: summary.distance, - duration: summary.duration, - raw: data - }; - } else { - return data; - } - }), - catchError(error => - of({ + completed++; + progressSubject.next({ completed, total: pares.length, item: data as unknown }); + if (completed === pares.length) progressSubject.complete(); + return data as unknown; + }) + ); + } + return this.fetchRoute(pair.origin, pair.destination, isJson).pipe( + delay(300), + map(data => { + if (isJson) this.geoCache.set(key, data); + if (!isJson) { + const summary = (data as { routes: { summary: { distance: number; duration: number } }[] }).routes[0].summary; + const out = { pdv_id: pair.pdv_id, - error: error.message || 'Error ORS' - }) - ) - ), - concurrency - ), - toArray() - ) - ); + distance: summary.distance, + duration: summary.duration, + raw: data + }; + completed++; + progressSubject.next({ completed, total: pares.length, item: out }); + if (completed === pares.length) progressSubject.complete(); + return out; + } else { + const wrapped = { pdv_id: pair.pdv_id, geojson: data }; + completed++; + progressSubject.next({ completed, total: pares.length, item: wrapped as unknown }); + if (completed === pares.length) progressSubject.complete(); + return wrapped as unknown; + } + }), + catchError(error => { + const errOut = { + pdv_id: pair.pdv_id, + error: (error as Error).message || 'Error ORS' + }; + completed++; + progressSubject.next({ completed, total: pares.length, item: errOut }); + if (completed === pares.length) progressSubject.complete(); + return of(errOut); + }) + ); + }, + concurrency + ), + toArray() + ) as Observable; + source$ = abort$ ? (base$.pipe(takeUntil(abort$)) as Observable) : base$; + + const promise = lastValueFrom(source$).catch(() => { + progressSubject.complete(); + return []; + }); + + return { promise, progress$: progressSubject.asObservable() }; + } + + clearCache() { + this.geoCache.clear(); } } diff --git a/route-planner/src/app/core/services/planning-input.service.ts b/route-planner/src/app/core/services/planning-input.service.ts new file mode 100644 index 0000000..e677d36 --- /dev/null +++ b/route-planner/src/app/core/services/planning-input.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +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 { + this.pdvFile = pdvFile; + this.usuariosFile = usuariosFile; + this.radioKm = radioKm; + this.velocidad = velocidad; + } + + getFormData(): FormData | null { + if (!this.pdvFile || !this.usuariosFile) return null; + 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; + } + + clear(): void { + this.pdvFile = null; + this.usuariosFile = null; + } +} diff --git a/route-planner/src/app/core/services/router.service.ts b/route-planner/src/app/core/services/router.service.ts index 5cbdfdd..7fde447 100644 --- a/route-planner/src/app/core/services/router.service.ts +++ b/route-planner/src/app/core/services/router.service.ts @@ -1,6 +1,8 @@ import { Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { ApiResponse } from '../../core/models/route.model'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { ApiResponse } from '../models/route.model'; +import { environment } from '../../../environments/environment'; +import { catchError, timeout, throwError } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class RutasService { @@ -9,8 +11,20 @@ export class RutasService { calcularRutas(data: FormData) { return this.http.post( - 'https://agenteit.digitalcompass.agency/webhook/calcular-camino', + environment.n8nWebhookUrl, data + ).pipe( + timeout(environment.requestTimeoutMs), + catchError((err: HttpErrorResponse | Error) => { + if (err instanceof Error && err.name === 'TimeoutError') { + return throwError(() => Object.assign(new Error('Timeout al calcular rutas (30s). Intenta con menos filas o reintenta.'), { name: 'TimeoutError' })); + } + return throwError(() => err); + }) ); - } + } + + reoptimizarRutas(data: FormData) { + return this.calcularRutas(data); + } } diff --git a/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.css b/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.css index 2c2625e..afad04a 100644 --- a/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.css +++ b/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.css @@ -196,3 +196,31 @@ background: var(--glm-verde); border: 3px solid #fff; } + +.location-pin { + position: relative; + width: 30px; + height: 38px; + background: #c9252d; + clip-path: polygon(50% 100%, 8% 43%, 5% 31%, 10% 18%, 22% 7%, 36% 2%, 50% 0, 64% 2%, 78% 7%, 90% 18%, 95% 31%, 92% 43%); + filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.42)); +} + +.location-pin::before { + content: ''; + position: absolute; + inset: 3px 3px 7px; + background: #ff424a; + clip-path: polygon(50% 100%, 8% 43%, 5% 31%, 10% 18%, 22% 7%, 36% 2%, 50% 0, 64% 2%, 78% 7%, 90% 18%, 95% 31%, 92% 43%); +} + +.location-pin::after { + content: ''; + position: absolute; + top: 8px; + left: 8px; + width: 14px; + height: 14px; + border-radius: 50%; + background: #620000; +} diff --git a/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.html b/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.html index 0301a51..d76fbc1 100644 --- a/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.html +++ b/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.html @@ -25,6 +25,8 @@
Usuario
{{ routeData.json.nombre_usuario }}
PDV
{{ routeData.json.nombre_pdv }}
Día
{{ routeData.json.dia }}
+
Orden
{{ visitOrder }} de {{ totalStops }}
+
Origen del tramo
{{ segmentOriginName }}
Distancia
{{ routeData.json.distancia }} km
Duración
{{ (routeData.json.horas_desplazamiento * 60).toFixed(0) }} min
Horas trabajo
{{ routeData.json.horas_trabajo }}
diff --git a/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.ts b/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.ts index 8f8f3d5..7b96316 100644 --- a/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.ts +++ b/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.ts @@ -1,11 +1,12 @@ import { Component, OnInit, OnDestroy, AfterViewInit, ChangeDetectorRef } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { Router } from '@angular/router'; +import { Router, ActivatedRoute } from '@angular/router'; import { Subscription } from 'rxjs'; import * as L from 'leaflet'; import { RouteState } from '../../../core/route-state/route'; import { OrsService } from '../../../core/services/ors.service'; import { RouteAssignedResponse } from '../../../core/models/route.model'; +import { sequenceRoutes } from '../../../shared/utils/route-sequencing.utils'; @Component({ standalone: true, @@ -26,9 +27,13 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie routeData: RouteAssignedResponse | null = null; isLoading = false; error: string | null = null; + segmentOriginName = ''; + visitOrder = 0; + totalStops = 0; constructor( private router: Router, + private activatedRoute: ActivatedRoute, private routeState: RouteState, private orsService: OrsService, private cdr: ChangeDetectorRef @@ -36,11 +41,30 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie ngOnInit() { this.sub = this.routeState.selectedRoute$.subscribe((route: RouteAssignedResponse | null) => { - this.routeData = route; - if (route && this.map) { - this.loadAndShowRoute(route); - } else if (!route) { - this.error = 'No hay ruta seleccionada'; + if (route) { + this.routeData = route; + if (this.map) { + this.loadAndShowRoute(route); + } + this.error = null; + } else { + // Try to resolve from queryParams + sessionStorage snapshot + const qp = this.activatedRoute.snapshot.queryParams; + const pdvId = qp['pdv_id']; + const usuarioId = qp['usuario_id']; + if (pdvId) { + const found = this.routeState.findRoute(pdvId, usuarioId); + if (found) { + this.routeData = found; + if (this.map) this.loadAndShowRoute(found); + this.error = null; + } else { + // No data at all (e.g., direct URL after hard refresh with empty storage) + this.error = 'No hay ruta seleccionada. Vuelve al listado y selecciona una ruta.'; + } + } else if (!this.routeData) { + this.error = 'No hay ruta seleccionada'; + } } this.cdr.detectChanges(); }); @@ -50,6 +74,18 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie this.initMap(); if (this.routeData) { this.loadAndShowRoute(this.routeData); + } else { + // Re-attempt resolution after map init (queryParams case) + const qp = this.activatedRoute.snapshot.queryParams; + if (qp['pdv_id']) { + const found = this.routeState.findRoute(qp['pdv_id'], qp['usuario_id']); + if (found) { + this.routeData = found; + this.error = null; + this.loadAndShowRoute(found); + this.cdr.detectChanges(); + } + } } } @@ -64,6 +100,8 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie private initMap() { this.map = L.map('map').setView([18.4861, -69.9312], 13); + const pdvPane = this.map.createPane('pdvPane'); + pdvPane.style.zIndex = '650'; this.tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors' @@ -77,13 +115,20 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie this.cdr.detectChanges(); const json = route.json; - const origin: [number, number] = [Number(json.longitud_usuario), Number(json.latitud_usuario)]; + const sequence = sequenceRoutes(this.routeState.getAssignedSnapshot().filter(item => + String(item.json.usuario_id) === String(json.usuario_id) && item.json.dia === json.dia + )); + const segment = sequence.find(item => item.route === route) ?? sequence.find(item => item.route.json.pdv_id === json.pdv_id); + const origin: [number, number] = segment?.origin ?? [Number(json.longitud_usuario), Number(json.latitud_usuario)]; const destination: [number, number] = [Number(json.longitud_pdv), Number(json.latitud_pdv)]; + this.segmentOriginName = segment?.previousPdvName ?? `Casa de ${json.nombre_usuario}`; + this.visitOrder = segment?.order ?? 1; + this.totalStops = segment?.totalStops ?? 1; this.centerMapOnPoints(origin, destination); try { - const geojson = await this.orsService.processPairs( + const results = await this.orsService.processPairs( [{ pdv_id: json.pdv_id, origin, destination }], 1, true @@ -91,13 +136,14 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie if (this.destroyed) return; - if (geojson[0] && !geojson[0].error) { - this.showRouteOnMap(geojson[0], origin, destination, json); + const result = results[0] as { error?: string; geojson?: unknown } | undefined; + if (result && !result.error && result.geojson) { + this.showRouteOnMap(result.geojson, origin, destination, json, this.segmentOriginName); } else { - this.showFallbackRoute(origin, destination, json); + this.showFallbackRoute(origin, destination, json, this.segmentOriginName); } } catch { - this.showFallbackRoute(origin, destination, json); + this.showFallbackRoute(origin, destination, json, this.segmentOriginName); } finally { this.isLoading = false; this.cdr.detectChanges(); @@ -112,31 +158,38 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie this.map.fitBounds(bounds.pad(0.3)); } - private extractCoords(geojson: any): L.LatLngExpression[] { - if (geojson.type === 'FeatureCollection' && geojson.features?.length) { - return geojson.features[0].geometry.coordinates.map( + private extractCoords(geojson: unknown): L.LatLngExpression[] { + const g = geojson as { type: string; features?: { geometry: { coordinates: number[][] } }[]; coordinates?: number[][] }; + if (g.type === 'FeatureCollection' && g.features?.length) { + return g.features[0].geometry.coordinates.map( (c: number[]) => [c[1], c[0]] as L.LatLngExpression ); } - if (geojson.type === 'LineString') { - return geojson.coordinates.map( + if (g.type === 'LineString' && g.coordinates) { + return g.coordinates.map( (c: number[]) => [c[1], c[0]] as L.LatLngExpression ); } + // ORS geojson response wrapped + const maybeFeature = (geojson as { features?: { geometry: { coordinates: number[][] } }[] })?.features; + if (maybeFeature?.length) { + return maybeFeature[0].geometry.coordinates.map(c => [c[1], c[0]] as L.LatLngExpression); + } return []; } - private showRouteOnMap(geojson: any, origin: [number, number], destination: [number, number], json: any) { + private showRouteOnMap(geojson: unknown, origin: [number, number], destination: [number, number], json: RouteAssignedResponse['json'], originName: string) { const coords = this.extractCoords(geojson); if (!coords.length) { - this.showFallbackRoute(origin, destination, json); + this.showFallbackRoute(origin, destination, json, originName); return; } - const distance = geojson.features?.[0]?.properties?.summary?.distance - ? (geojson.features[0].properties.summary.distance / 1000).toFixed(2) : 'N/A'; - const duration = geojson.features?.[0]?.properties?.summary?.duration - ? (geojson.features[0].properties.summary.duration / 60).toFixed(0) : 'N/A'; + const g = geojson as { features?: { properties?: { summary?: { distance: number; duration: number } } }[] }; + const distance = g.features?.[0]?.properties?.summary?.distance + ? (g.features[0].properties.summary.distance / 1000).toFixed(2) : 'N/A'; + const duration = g.features?.[0]?.properties?.summary?.duration + ? (g.features[0].properties.summary.duration / 60).toFixed(0) : 'N/A'; this.routeGlow = L.polyline(coords, { color: '#6CC24A', @@ -163,11 +216,11 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie lineCap: 'round' }).addTo(this.map); - this.routeLine.bindPopup( - `${json.nombre_usuario} → ${json.nombre_pdv}
Distancia: ${distance} km
Duración: ${duration} min` + (this.routeLine as L.Polyline).bindPopup( + `${originName} → ${json.nombre_pdv}
Distancia: ${distance} km
Duración: ${duration} min` ); - this.addMarkers(origin, destination, json, distance, duration); + this.addMarkers(origin, destination, json, distance, duration, originName); const group = new L.FeatureGroup([ ...(this.routeGlow ? [this.routeGlow] : []), ...(this.routeLine ? [this.routeLine] : []), @@ -176,13 +229,13 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie this.map.fitBounds(group.getBounds().pad(0.1)); } - private showFallbackRoute(origin: [number, number], destination: [number, number], json: any) { + private showFallbackRoute(origin: [number, number], destination: [number, number], json: RouteAssignedResponse['json'], originName: string) { this.routeLine = L.polyline( [[origin[1], origin[0]], [destination[1], destination[0]]], { color: '#FF6A13', weight: 5, dashArray: '12, 10', opacity: 0.9 } ).addTo(this.map); - this.addMarkers(origin, destination, json, 'N/A', 'N/A'); + this.addMarkers(origin, destination, json, 'N/A', 'N/A', originName); const group = new L.FeatureGroup([this.routeLine, ...this.markers]); this.map.fitBounds(group.getBounds().pad(0.1)); } @@ -190,9 +243,10 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie private addMarkers( origin: [number, number], destination: [number, number], - json: any, + json: RouteAssignedResponse['json'], distance: string, - duration: string + duration: string, + originName: string ) { const startIcon = L.divIcon({ className: 'custom-marker marker-start', @@ -204,17 +258,17 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie const endIcon = L.divIcon({ className: 'custom-marker marker-end', - html: '
B
', - iconSize: [30, 30], - iconAnchor: [15, 15], + html: '
', + iconSize: [30, 38], + iconAnchor: [15, 38], popupAnchor: [0, -18] }); this.markers.push( L.marker([origin[1], origin[0]], { icon: startIcon }) .addTo(this.map) - .bindPopup(`Origen: ${json.nombre_usuario}`), - L.marker([destination[1], destination[0]], { icon: endIcon }) + .bindPopup(`Origen del tramo: ${originName}`), + L.marker([destination[1], destination[0]], { icon: endIcon, pane: 'pdvPane', zIndexOffset: 1000 }) .addTo(this.map) .bindPopup(`Destino: ${json.nombre_pdv}
Distancia: ${distance} km
Duración: ${duration} min`) ); diff --git a/route-planner/src/app/private/home/assigned-route/assigned-route.component.css b/route-planner/src/app/private/home/assigned-route/assigned-route.component.css index 5afc1e5..ebfd491 100644 --- a/route-planner/src/app/private/home/assigned-route/assigned-route.component.css +++ b/route-planner/src/app/private/home/assigned-route/assigned-route.component.css @@ -69,3 +69,31 @@ color: var(--glm-acero); font-size: 13px; } + +.link-usuario { + color: var(--glm-azul); + font-weight: 600; + cursor: pointer; + text-decoration: underline; + text-decoration-color: transparent; + transition: text-decoration-color 0.15s; +} + +.link-usuario:hover { + text-decoration-color: var(--glm-verde); + color: var(--glm-verde); +} +.inline-edit { + width: 100%; + min-width: 100px; + padding: 4px 6px; + border: 1px solid var(--glm-verde); + border-radius: 3px; + background: #fff; + color: var(--glm-gris-oscuro); + font: inherit; +} + +.edit-actions { + white-space: nowrap; +} 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 53f388d..197bec7 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 @@ -7,9 +7,19 @@ (input)="dt.filterGlobal($any($event.target).value, 'contains')" />
+ + +
+
{{ reoptError }}
- {{ row.json.nombre_usuario }} + + + + + {{ row.json.nombre_usuario }} + {{ row.json.nombre_pdv }} {{ row.json.hora_laboral_semanal_usuario }} {{ row.json.hora_minima_semanal_pdv }} {{ row.json.horas_trabajo }} - {{ row.json.dia }} + + + + + {{ row.json.dia }} + {{ row.json.distancia }} {{ row.json.horas_desplazamiento }} - + + + + + + + + + + {{ submitDisabledReason }} +
+ +
+
@@ -91,4 +176,4 @@

PDVs sin ruta asignada

- \ No newline at end of file + diff --git a/route-planner/src/app/private/home/home.component.ts b/route-planner/src/app/private/home/home.component.ts index d6f394d..3d4ecc6 100644 --- a/route-planner/src/app/private/home/home.component.ts +++ b/route-planner/src/app/private/home/home.component.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { Component, ChangeDetectorRef } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { AssignedRouteComponent } from './assigned-route/assigned-route.component'; @@ -6,60 +6,170 @@ import { NoAssignedRouteComponent } from './no-assigned-route/no-assigned-route. 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'; @Component({ standalone: true, selector: 'app-home', - imports: [CommonModule, FormsModule, AssignedRouteComponent, NoAssignedRouteComponent], + imports: [CommonModule, FormsModule, AssignedRouteComponent, NoAssignedRouteComponent, PlanningKpisComponent], templateUrl: './home.component.html', styleUrls: ['./home.component.css'] }) export class HomeComponent { pdvFile: File | null = null; usuariosFile: File | null = null; + pdvPreview: PreviewResult | null = null; + usuariosPreview: PreviewResult | null = null; + pdvPreviewLoading = false; + usuariosPreviewLoading = false; uploadError: string | null = null; 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 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 */ } + } - onPdvFileChange(event: Event) { + 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; if (input.files && input.files.length > 0) { - this.pdvFile = input.files[0]; - this.validateFile(this.pdvFile, 'PDV'); + const file = input.files[0]; + const basicError = this.validateFileBasic(file, 'PDV'); + if (basicError) { + this.pdvFile = null; + this.pdvPreview = null; + this.uploadError = basicError; + return; + } + this.pdvFile = file; + if (this.usuariosFile) this.planningInput.setFiles(file, this.usuariosFile, this.radioKm, this.velocidad); this.uploadError = null; + this.pdvPreviewLoading = true; + this.pdvPreview = null; + this.cdr.detectChanges(); + try { + this.pdvPreview = await this.previewService.previewFile(file, 'PDV'); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Error al leer el archivo PDV'; + this.pdvPreview = { + fileName: file.name, + type: 'PDV', + valid: false, + totalRows: 0, + validRows: 0, + headers: [], + normalizedHeaders: [], + missingHeaders: [], + errors: [{ row: 1, field: 'file', message: msg }], + duplicateIds: [], + sampleValidRows: [], + }; + } finally { + this.pdvPreviewLoading = false; + this.cdr.detectChanges(); + } } } - onUsuariosFileChange(event: Event) { + async onUsuariosFileChange(event: Event) { const input = event.target as HTMLInputElement; if (input.files && input.files.length > 0) { - this.usuariosFile = input.files[0]; - this.validateFile(this.usuariosFile, 'Usuarios'); + const file = input.files[0]; + const basicError = this.validateFileBasic(file, 'Usuarios'); + if (basicError) { + this.usuariosFile = null; + this.usuariosPreview = null; + this.uploadError = basicError; + return; + } + this.usuariosFile = file; + if (this.pdvFile) this.planningInput.setFiles(this.pdvFile, file, this.radioKm, this.velocidad); this.uploadError = null; + this.usuariosPreviewLoading = true; + this.usuariosPreview = null; + this.cdr.detectChanges(); + try { + this.usuariosPreview = await this.previewService.previewFile(file, 'Usuarios'); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Error al leer el archivo Usuarios'; + this.usuariosPreview = { + fileName: file.name, + type: 'Usuarios', + valid: false, + totalRows: 0, + validRows: 0, + headers: [], + normalizedHeaders: [], + missingHeaders: [], + errors: [{ row: 1, field: 'file', message: msg }], + duplicateIds: [], + sampleValidRows: [], + }; + } finally { + this.usuariosPreviewLoading = false; + this.cdr.detectChanges(); + } } } - private validateFile(file: File, type: string) { + private validateFileBasic(file: File, type: string): string | null { const validExtensions = ['.xlsx', '.xls']; const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase(); if (!validExtensions.includes(ext)) { - this.uploadError = `El archivo ${type} debe ser .xlsx o .xls`; - if (type === 'PDV') this.pdvFile = null; - else this.usuariosFile = null; + return `El archivo ${type} debe ser .xlsx o .xls`; } if (file.size > 10 * 1024 * 1024) { - this.uploadError = `El archivo ${type} supera 10MB`; - if (type === 'PDV') this.pdvFile = null; - else this.usuariosFile = null; + return `El archivo ${type} supera 10MB`; } + return null; } get canSubmit(): boolean { - return !!this.pdvFile && !!this.usuariosFile && !this.uploadError; + if (!this.pdvFile || !this.usuariosFile) return false; + if (this.pdvPreviewLoading || this.usuariosPreviewLoading) return false; + if (this.isLoading) return false; + if (this.uploadError) return false; + // Require valid previews before enabling submit + if (this.pdvPreview && !this.pdvPreview.valid) return false; + if (this.usuariosPreview && !this.usuariosPreview.valid) return false; + // Previews must exist (file was parsed) + if (!this.pdvPreview || !this.usuariosPreview) return false; + return true; + } + + get submitDisabledReason(): string | null { + if (!this.pdvFile || !this.usuariosFile) return 'Sube ambos archivos'; + if (this.pdvPreviewLoading || this.usuariosPreviewLoading) return 'Validando archivos...'; + if (this.pdvPreview && !this.pdvPreview.valid) return 'Corrige los errores del archivo PDV'; + if (this.usuariosPreview && !this.usuariosPreview.valid) return 'Corrige los errores del archivo Usuarios'; + return null; } cargarRutas() { @@ -68,39 +178,81 @@ export class HomeComponent { this.isLoading = true; this.uploadError = null; this.routeState.setLoading(true); + this.planningInput.setFiles(this.pdvFile!, this.usuariosFile!, this.radioKm, this.velocidad); const formData = new FormData(); formData.append('pdv_file', this.pdvFile!, this.pdvFile!.name); formData.append('usuarios_file', this.usuariosFile!, this.usuariosFile!.name); + formData.append('radio_km', String(this.radioKm)); + formData.append('velocidad', String(this.velocidad)); - this.rutasService.calcularRutas(formData).subscribe({ + this.calcSub?.unsubscribe(); + this.calcSub = this.rutasService.calcularRutas(formData).subscribe({ next: (res: ApiResponse) => { - const assigned = res.find(r => 'assigned' in r); - const noAssigned = res.find(r => 'noAssigned' in r); + // ApiResponse is [AssignedResponse, NoAssignedResponse] but backend may return object with error + const anyRes = res as unknown as Record; + if (anyRes && 'error' in anyRes) { + this.uploadError = String((anyRes as { error: unknown }).error); + this.routeState.setLoading(false); + this.isLoading = false; + this.cdr.detectChanges(); + return; + } + const arr = res as unknown as unknown[]; + const assigned = (arr as ApiResponse).find(r => r && typeof r === 'object' && 'assigned' in r) as unknown as { assigned: unknown } | undefined; + const noAssigned = (arr as ApiResponse).find(r => r && typeof r === 'object' && 'noAssigned' in r) as unknown as { noAssigned: unknown } | undefined; if (assigned) { - this.routeState.setAssignedRoutes(assigned.assigned); + this.routeState.setAssignedRoutes(assigned.assigned as never); } if (noAssigned) { - this.routeState.setNoAssignedRoutes(noAssigned.noAssigned); + this.routeState.setNoAssignedRoutes(noAssigned.noAssigned as never); } this.routeState.setLoading(false); this.isLoading = false; + this.cdr.detectChanges(); }, error: (err) => { - this.uploadError = err.error?.message || 'Error al procesar los archivos'; + this.uploadError = this.mapBackendError(err); this.routeState.setLoading(false); this.isLoading = false; + this.cdr.detectChanges(); } }); } + cancelarCarga() { + this.calcSub?.unsubscribe(); + this.calcSub = null; + this.isLoading = false; + this.routeState.setLoading(false); + this.uploadError = 'Cálculo cancelado'; + } + + private mapBackendError(err: unknown): string { + const anyErr = err as { error?: { error?: string; message?: string }; message?: string; status?: number; name?: string }; + if (anyErr?.name === 'TimeoutError') return 'El cálculo tardó demasiado (timeout 30s). Intenta de nuevo con menos filas.'; + if (anyErr?.error?.error) return anyErr.error.error; + if (anyErr?.error?.message) return anyErr.error.message; + if (anyErr?.message) return anyErr.message; + return 'Error al procesar los archivos'; + } + clearFiles() { + this.calcSub?.unsubscribe(); + this.planningInput.clear(); + this.routeState.clearAll(); this.pdvFile = null; this.usuariosFile = null; + this.pdvPreview = null; + this.usuariosPreview = null; + this.pdvPreviewLoading = false; + this.usuariosPreviewLoading = false; this.uploadError = null; + this.isLoading = false; + this.routeState.setLoading(false); const pdvInput = document.getElementById('pdvFile') as HTMLInputElement; const usuariosInput = document.getElementById('usuariosFile') as HTMLInputElement; if (pdvInput) pdvInput.value = ''; if (usuariosInput) usuariosInput.value = ''; } -} \ No newline at end of file +} 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 5bdb99b..6c3e08c 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 @@ -11,3 +11,42 @@ text-transform: uppercase; letter-spacing: 0.3px; } + +.assignment-actions { + display: flex; + align-items: center; + gap: 5px; + min-width: 230px; +} + +.inline-select { + max-width: 115px; + padding: 5px; + border: 1px solid var(--glm-gris-medio); + border-radius: 3px; + background: #fff; + color: var(--glm-gris-oscuro); + font-size: 0.75rem; +} + +.spinner-inline { + display: inline-block; + width: 13px; + height: 13px; + border: 2px solid transparent; + border-top-color: currentColor; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.table-error { + margin-top: 10px; + padding: 9px 12px; + background: var(--glm-rojo-ok); + border: 1px solid #F5A6A0; + border-radius: 4px; + color: var(--glm-gris-oscuro); + font-size: 0.82rem; +} + +@keyframes spin { to { transform: rotate(360deg); } } 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 e2824a6..27cd9a3 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 @@ -19,14 +19,29 @@ {{ row.minimo_horas_semana }} {{ row.latitud }} {{ row.longitud }} - {{ row.motivo }} + {{ row.motivo }} + + + + + - + No hay registros asignados -
\ No newline at end of file + +
{{ actionError }}
diff --git a/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.ts b/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.ts index 2128a6b..2b0a9a4 100644 --- a/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.ts +++ b/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.ts @@ -1,36 +1,49 @@ -import { Component, ChangeDetectorRef } from '@angular/core'; +import { Component, ChangeDetectorRef, OnDestroy } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; import { TableModule } from 'primeng/table'; import { RouteNoAssignedResponse } from '../../../core/models/route.model'; import { RouteState } from '../../../core/route-state/route'; +import { Subscription } from 'rxjs'; +import { OrsService } from '../../../core/services/ors.service'; @Component({ standalone: true, selector: 'app-no-assigned-route', - imports: [CommonModule, TableModule], + imports: [CommonModule, FormsModule, TableModule], templateUrl: './no-assigned-route.component.html', styleUrls: ['./no-assigned-route.component.css'] }) -export class NoAssignedRouteComponent { +export class NoAssignedRouteComponent implements OnDestroy { noAssignedRoutes: RouteNoAssignedResponse[] = []; isLoading = false; - cols: any[] = []; + cols: { field: string; header: string }[] = []; + private subs: Subscription[] = []; + assignmentUser: Record = {}; + assignmentDay: Record = {}; + assigningId: number | null = null; + actionError: string | null = null; constructor( private routesState: RouteState, - private cdr: ChangeDetectorRef + private cdr: ChangeDetectorRef, + private orsService: OrsService ) {} ngOnInit() { - this.routesState.loading$.subscribe(value => { - this.isLoading = value; - this.cdr.detectChanges(); - }); - this.routesState.noAssignedRoutes$.subscribe(data => { - this.noAssignedRoutes = [...data]; - this.cdr.detectChanges(); - }); + this.subs.push( + this.routesState.loading$.subscribe(value => { + this.isLoading = value; + this.cdr.detectChanges(); + }) + ); + this.subs.push( + this.routesState.noAssignedRoutes$.subscribe(data => { + this.noAssignedRoutes = [...data]; + this.cdr.detectChanges(); + }) + ); this.cols = [ { field: 'pdv_id', header: 'PDV ID' }, { field: 'pdv_nombre', header: 'Nombre' }, @@ -40,4 +53,45 @@ export class NoAssignedRouteComponent { { field: 'motivo', header: 'Motivo' } ]; } + + get userOptions(): { id: string; name: string }[] { + return this.routesState.getUsuariosUnicos().map(user => ({ id: user.id, name: user.nombre })); + } + + get dayOptions(): string[] { + return this.routesState.getDiasUnicos(); + } + + async assignRoute(route: RouteNoAssignedResponse): Promise { + const userId = this.assignmentUser[String(route.pdv_id)]; + const day = this.assignmentDay[String(route.pdv_id)]; + if (!userId || !day) { + this.actionError = 'Selecciona usuario y día antes de asignar.'; + return; + } + const user = this.routesState.getAssignedSnapshot().find(item => String(item.json.usuario_id) === userId); + if (!user) return; + this.assigningId = route.pdv_id; + this.actionError = null; + try { + const origin = [Number(user.json.longitud_usuario), Number(user.json.latitud_usuario)]; + const destination = [Number(route.longitud), Number(route.latitud)]; + const result = await this.orsService.processPairs([{ pdv_id: route.pdv_id, origin, destination }], 1, true); + const geo = (result[0] as { geojson?: { features?: { properties?: { summary?: { distance: number; duration: number } } }[] } }).geojson; + const summary = geo?.features?.[0]?.properties?.summary; + if (!summary) throw new Error('ORS no devolvió una ruta vial'); + this.routesState.addAssignedFromUnassigned(route, userId, day, summary.distance / 1000, summary.duration / 3600); + delete this.assignmentUser[String(route.pdv_id)]; + delete this.assignmentDay[String(route.pdv_id)]; + } catch { + this.actionError = `No se pudo calcular la ruta vial para ${route.pdv_nombre}.`; + } finally { + this.assigningId = null; + this.cdr.detectChanges(); + } + } + + ngOnDestroy() { + this.subs.forEach(s => s.unsubscribe()); + } } diff --git a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.css b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.css new file mode 100644 index 0000000..0aa20b5 --- /dev/null +++ b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.css @@ -0,0 +1,256 @@ +.kpis-panel { + border-top: 3px solid var(--glm-azul); +} + +.kpis-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.section-title { + margin: 0; + font-size: 16px; + color: var(--glm-azul); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.kpis-subtitle { + margin: 4px 0 0; + color: var(--glm-acero); + font-size: 0.82rem; +} + +.kpis-status { + color: var(--glm-verde); + font-size: 0.78rem; + font-weight: 600; + white-space: nowrap; +} + +.kpis-actions { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.export-button { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 10px; + border: 1px solid var(--glm-verde); + border-radius: 4px; + background: var(--glm-verde-bg); + color: var(--glm-azul); + cursor: pointer; + font-size: 0.78rem; + font-weight: 600; +} + +.export-button:hover { + background: var(--glm-verde); + color: #fff; +} + +.kpi-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.kpi-card { + display: flex; + align-items: center; + gap: 12px; + min-height: 92px; + padding: 14px; + background: var(--glm-surface); + border: 1px solid var(--glm-gris-medio); + border-left: 4px solid var(--glm-azul); + border-radius: 4px; +} + +.kpi-primary { border-left-color: var(--glm-azul); } +.kpi-success { border-left-color: var(--glm-verde); } +.kpi-warning { border-left-color: var(--glm-naranja); } +.kpi-blue { border-left-color: var(--glm-acero); } +.kpi-orange { border-left-color: var(--glm-naranja); } + +.kpi-icon { + display: grid; + flex: 0 0 34px; + width: 34px; + height: 34px; + place-items: center; + border-radius: 50%; + background: var(--glm-verde-bg); + color: var(--glm-azul); + font-size: 0.95rem; +} + +.kpi-card > div:last-child { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.kpi-label { + color: var(--glm-acero); + font-size: 0.74rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.2px; +} + +.kpi-card strong { + color: var(--glm-azul); + font-size: 1.45rem; + line-height: 1.15; +} + +.kpi-card em { + font-size: 0.8rem; + font-style: normal; + font-weight: 600; +} + +.kpi-card small { + overflow: hidden; + color: var(--glm-acero); + font-size: 0.73rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.operational-grid { + margin-top: 12px; +} + +.user-load-card { + margin-top: 14px; + padding: 12px 14px; + background: var(--glm-surface); + border: 1px solid var(--glm-gris-medio); + border-radius: 4px; +} + +.user-load-card h3 { + margin: 0 0 8px; + color: var(--glm-azul); + font-size: 0.82rem; + text-transform: uppercase; +} + +.user-load-table-wrap { + overflow-x: auto; +} + +.user-load-table { + width: 100%; + min-width: 560px; + border-collapse: collapse; + font-size: 0.8rem; +} + +.user-load-table th { + padding: 7px 8px; + background: var(--glm-azul); + color: #fff; + font-size: 0.72rem; + text-align: left; + text-transform: uppercase; +} + +.user-load-table td { + padding: 8px; + border-bottom: 1px solid var(--glm-gris-claro); + color: var(--glm-gris-oscuro); +} + +.user-load-table tbody tr:nth-child(even) { + background: var(--glm-gris-claro); +} + +.utilization-cell { + display: grid; + grid-template-columns: 48px minmax(70px, 1fr); + align-items: center; + gap: 8px; + min-width: 130px; +} + +.utilization-bar { + display: block; + height: 7px; + overflow: hidden; + border-radius: 10px; + background: var(--glm-gris-medio); +} + +.utilization-bar span { + display: block; + height: 100%; + border-radius: inherit; + background: var(--glm-verde); +} + +.reasons-card { + margin-top: 14px; + padding: 12px 14px; + background: var(--glm-verde-bg); + border: 1px solid var(--glm-verde-claro); + border-radius: 4px; +} + +.reasons-card h3 { + margin: 0 0 8px; + color: var(--glm-azul); + font-size: 0.82rem; + text-transform: uppercase; +} + +.reason-list { + display: grid; + gap: 4px; +} + +.reason-row { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 5px 0; + border-bottom: 1px solid rgba(108, 194, 74, 0.25); + color: var(--glm-gris-oscuro); + font-size: 0.82rem; +} + +.reason-row:last-child { border-bottom: 0; } +.reason-row strong { color: var(--glm-azul); } + +.kpis-empty { + display: flex; + align-items: center; + gap: 8px; + padding: 14px; + color: var(--glm-acero); + font-size: 0.85rem; +} + +.kpis-empty i { color: var(--glm-gris-medio); } + +@media (max-width: 700px) { + .kpis-heading { flex-direction: column; gap: 6px; } + .kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 430px) { + .kpi-grid { grid-template-columns: 1fr; } +} diff --git a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.html b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.html new file mode 100644 index 0000000..17bd2dc --- /dev/null +++ b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.html @@ -0,0 +1,98 @@ +
+
+
+

Indicadores de planificación

+

Resumen total de la planificación actual

+
+
+ Datos disponibles + +
+
+ +
+
+
+
PDVs totales{{ summary.totalPdvs }}en la planificación
+
+
+
+
PDVs asignados{{ summary.assignedPdvs }}{{ summary.coverage | number:'1.1-1' }}% de cobertura
+
+
+
+
Sin asignar{{ summary.unassignedPdvs }}requieren revisión
+
+
+
+
Usuarios activos{{ summary.users }}{{ summary.days }} días planificados
+
+
+
+
Distancia total{{ summary.totalDistance | number:'1.0-1' }} km{{ summary.averageDistance | number:'1.0-1' }} km promedio por ruta
+
+
+
+
Desplazamiento total{{ summary.totalTravelHours | number:'1.0-1' }} h{{ summary.averageTravelHours | number:'1.0-1' }} h promedio por ruta
+
+
+ +
+
+
+
Carga de trabajo{{ summary.totalWorkHours | number:'1.0-1' }} hhoras de trabajo planificadas
+
+
+
+
Utilización promedio{{ summary.averageUtilization | number:'1.0-1' }}%carga frente a disponibilidad
+
+
+
+
Rutas por usuario{{ summary.averageRoutesPerUser | number:'1.0-1' }}promedio de asignaciones
+
+
+
+
Brecha de balance{{ summary.assignmentBalance }}diferencia entre mayor y menor carga
+
+
+ +
+

Balance de carga por usuario

+
+ + + + + + + + + + + + + +
UsuarioRutasDistanciaTrabajoUtilización
{{ load.name }}{{ load.routes }}{{ load.distance | number:'1.0-1' }} km{{ load.workHours | number:'1.0-1' }} h +
+ {{ load.utilization | number:'1.0-1' }}% + +
+
+
+
+ +
+

Motivos de PDVs sin asignar

+
+
+ {{ item.reason }} + {{ item.count }} +
+
+
+ +
+ + Carga una planificación para visualizar sus indicadores. +
+
diff --git a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.ts b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.ts new file mode 100644 index 0000000..b1b8759 --- /dev/null +++ b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.ts @@ -0,0 +1,173 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Subscription } from 'rxjs'; +import { RouteState } from '../../../core/route-state/route'; +import { PlanningExportService } from '../../../shared/services/planning-export.service'; +import { RouteAssignedResponse } from '../../../core/models/route.model'; + +interface KpiSummary { + totalPdvs: number; + assignedPdvs: number; + unassignedPdvs: number; + coverage: number; + users: number; + days: number; + totalDistance: number; + totalTravelHours: number; + averageDistance: number; + averageTravelHours: number; + totalWorkHours: number; + averageRoutesPerUser: number; + averageUtilization: number; + assignmentBalance: number; +} + +interface ReasonSummary { + reason: string; + count: number; +} + +interface UserLoad { + name: string; + routes: number; + distance: number; + workHours: number; + capacityHours: number; + utilization: number; +} + +@Component({ + standalone: true, + selector: 'app-planning-kpis', + imports: [CommonModule], + templateUrl: './planning-kpis.component.html', + styleUrl: './planning-kpis.component.css' +}) +export class PlanningKpisComponent implements OnInit, OnDestroy { + summary: KpiSummary = this.emptySummary(); + reasons: ReasonSummary[] = []; + userLoads: UserLoad[] = []; + hasData = false; + + private readonly subs: Subscription[] = []; + + constructor( + private routeState: RouteState, + private exportService: PlanningExportService + ) {} + + ngOnInit(): void { + this.subs.push( + this.routeState.assignedRoutes$.subscribe(() => this.refresh()), + this.routeState.noAssignedRoutes$.subscribe(() => this.refresh()) + ); + } + + ngOnDestroy(): void { + this.subs.forEach(subscription => subscription.unsubscribe()); + } + + barWidth(value: number): number { + return Math.min(Math.max(value, 0), 100); + } + + exportPlanning(): void { + this.exportService.export(); + } + + private refresh(): void { + const assigned = this.routeState.getAssignedSnapshot(); + const unassigned = this.routeState.getNoAssignedSnapshot(); + const totalPdvs = assigned.length + unassigned.length; + const totalDistance = assigned.reduce((sum, route) => sum + this.numberValue(route.json.distancia), 0); + const totalTravelHours = assigned.reduce((sum, route) => sum + this.numberValue(route.json.horas_desplazamiento), 0); + const totalWorkHours = assigned.reduce((sum, route) => sum + this.parseHours(route.json.horas_trabajo), 0); + const users = new Set(assigned.map(route => String(route.json.usuario_id))).size; + const days = new Set(assigned.map(route => route.json.dia)).size; + const loads = new Map(); + + for (const route of assigned) { + const key = String(route.json.usuario_id); + const current = loads.get(key) ?? { + name: route.json.nombre_usuario, + routes: 0, + distance: 0, + workHours: 0, + capacityHours: this.numberValue(route.json.hora_laboral_semanal_usuario), + utilization: 0 + }; + current.routes++; + current.distance += this.numberValue(route.json.distancia); + current.workHours += this.parseHours(route.json.horas_trabajo); + loads.set(key, current); + } + + this.userLoads = Array.from(loads.values()) + .map(load => ({ + ...load, + utilization: load.capacityHours ? (load.workHours / load.capacityHours) * 100 : 0 + })) + .sort((a, b) => b.routes - a.routes || b.workHours - a.workHours); + const routeCounts = this.userLoads.map(load => load.routes); + const assignmentBalance = routeCounts.length ? Math.max(...routeCounts) - Math.min(...routeCounts) : 0; + const averageUtilization = this.userLoads.length + ? this.userLoads.reduce((sum, load) => sum + load.utilization, 0) / this.userLoads.length + : 0; + + this.summary = { + totalPdvs, + assignedPdvs: assigned.length, + unassignedPdvs: unassigned.length, + coverage: totalPdvs ? (assigned.length / totalPdvs) * 100 : 0, + users, + days, + totalDistance, + totalTravelHours, + averageDistance: assigned.length ? totalDistance / assigned.length : 0, + averageTravelHours: assigned.length ? totalTravelHours / assigned.length : 0, + totalWorkHours, + averageRoutesPerUser: users ? assigned.length / users : 0, + averageUtilization, + assignmentBalance + }; + + const reasonCounts = new Map(); + for (const route of unassigned) { + const reason = route.motivo?.trim() || 'Sin motivo especificado'; + reasonCounts.set(reason, (reasonCounts.get(reason) ?? 0) + 1); + } + this.reasons = Array.from(reasonCounts.entries()) + .map(([reason, count]) => ({ reason, count })) + .sort((a, b) => b.count - a.count); + this.hasData = totalPdvs > 0; + } + + private numberValue(value: number | string | null | undefined): number { + const result = Number(value); + return Number.isFinite(result) ? result : 0; + } + + private parseHours(value: number | string | null | undefined): number { + const result = Number.parseFloat(String(value ?? '')); + return Number.isFinite(result) ? result : 0; + } + + private emptySummary(): KpiSummary { + return { + totalPdvs: 0, + assignedPdvs: 0, + unassignedPdvs: 0, + coverage: 0, + users: 0, + days: 0, + totalDistance: 0, + totalTravelHours: 0, + averageDistance: 0, + averageTravelHours: 0, + totalWorkHours: 0, + averageRoutesPerUser: 0, + averageUtilization: 0, + assignmentBalance: 0 + }; + } +} diff --git a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.css b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.css new file mode 100644 index 0000000..18c9695 --- /dev/null +++ b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.css @@ -0,0 +1,556 @@ +:host { + display: block; + height: 100vh; + position: relative; +} + +#map-overview { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 380px; + width: auto; + height: 100vh; +} + +.overview-sidebar { + position: fixed; + top: 0; + left: 0; + bottom: 0; + width: 380px; + z-index: 900; + display: flex; + flex-direction: column; + gap: 14px; + padding: 16px; + overflow-y: auto; + background: var(--glm-surface); + border-right: 3px solid var(--glm-verde); +} + +.sidebar-brand { + display: flex; + justify-content: center; + padding-bottom: 10px; + border-bottom: 2px solid var(--glm-gris-medio); +} + +.sidebar-logo { + height: 42px; + width: auto; +} + +.sidebar-header { + display: flex; + align-items: center; + gap: 10px; + padding-bottom: 10px; + border-bottom: 2px solid var(--glm-gris-medio); +} + +.back-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 12px; + background: var(--glm-surface); + border: 1px solid var(--glm-gris-medio); + border-radius: 4px; + color: var(--glm-azul); + cursor: pointer; + font-size: 0.85rem; + font-weight: 600; + font-family: Arial, sans-serif; +} + +.back-btn:hover { + background: var(--glm-verde-bg); + border-color: var(--glm-verde); +} + +.sidebar-title { + margin: 0; + font-size: 1rem; + color: var(--glm-azul); + font-weight: 700; +} + +.filters-card { + background: var(--glm-verde-bg); + border: 1px solid var(--glm-verde-claro); + border-radius: 4px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.filter-label { + font-size: 0.75rem; + font-weight: 700; + color: var(--glm-azul); + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.filter-select { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--glm-gris-medio); + border-radius: 4px; + font-size: 0.9rem; + font-family: Arial, sans-serif; + background: #fff; + color: var(--glm-gris-oscuro); +} + +.filter-select:focus { + outline: none; + border-color: var(--glm-verde); + box-shadow: 0 0 0 2px rgba(108, 194, 74, 0.15); +} + +.filter-stats { + display: flex; + gap: 10px; + margin-top: 6px; + font-size: 0.82rem; + color: var(--glm-acero); + font-weight: 600; +} + +.filter-empty { + font-size: 0.85rem; + color: var(--glm-acero); +} + +.empty-state { + text-align: center; + padding: 24px 12px; + color: var(--glm-acero); +} + +.empty-state i { + font-size: 2rem; + margin-bottom: 8px; + display: block; + color: var(--glm-gris-medio); +} + +.error-card { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + background: var(--glm-rojo-ok); + border: 1px solid #F5A6A0; + border-radius: 4px; + font-size: 0.85rem; + color: var(--glm-gris-oscuro); +} + +.unassigned-map-card { + padding: 12px; + background: #fff8ed; + border: 1px solid #f2bf86; + border-left: 3px solid var(--glm-naranja); + border-radius: 4px; +} + +.unassigned-map-card .info-card-header { margin-bottom: 8px; } + +.unassigned-map-list { + display: grid; + gap: 5px; + max-height: 180px; + overflow-y: auto; +} + +.unassigned-map-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 7px; + border: 1px solid #f2bf86; + border-radius: 3px; + background: #fff; + color: var(--glm-gris-oscuro); + cursor: pointer; + text-align: left; +} + +.unassigned-map-item:hover, +.unassigned-map-item.selected-pending { background: #ffedd9; } + +.pending-icon { + display: grid; + flex: 0 0 23px; + width: 23px; + height: 23px; + place-items: center; + border-radius: 50%; + background: var(--glm-naranja); + color: #fff; + font-weight: 700; +} + +.pending-text { display: flex; min-width: 0; flex-direction: column; gap: 2px; } +.pending-text strong { font-size: 0.78rem; } +.pending-text small { overflow: hidden; color: var(--glm-acero); font-size: 0.7rem; text-overflow: ellipsis; white-space: nowrap; } + +.unassigned-detail-card { border-top-color: var(--glm-naranja); } + +.pending-edit-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-top: 8px; +} + +.pending-edit-grid label { display: flex; flex-direction: column; gap: 3px; color: var(--glm-azul); font-size: 0.72rem; font-weight: 700; text-transform: uppercase; } +.pending-edit-grid input, +.pending-edit-grid select { width: 100%; padding: 6px; border: 1px solid var(--glm-gris-medio); border-radius: 3px; background: #fff; color: var(--glm-gris-oscuro); font: inherit; font-size: 0.8rem; } +.wide-field { grid-column: 1 / -1; } +.pending-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; } +.pending-actions .btn-primary, .pending-actions .btn-secondary { padding: 7px 10px; font-size: 0.78rem; } + +.routes-list h3 { + margin: 0 0 8px; + font-size: 0.85rem; + color: var(--glm-azul); + font-weight: 700; + text-transform: uppercase; +} + +.routes-list ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.routes-list li { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border: 1px solid var(--glm-gris-medio); + border-radius: 4px; + background: #fff; + cursor: pointer; + transition: all 0.15s; +} + +.routes-list li:hover { + background: var(--glm-verde-bg); + border-color: var(--glm-verde); +} + +.routes-list li.selected { + background: var(--glm-verde); + border-color: var(--glm-verde); + color: #fff; +} + +.routes-list li.selected small { + color: rgba(255, 255, 255, 0.85); +} + +.routes-list li.selected i { + color: #fff; +} + +.idx { + min-width: 26px; + height: 26px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background: var(--glm-azul); + color: #fff; + font-size: 0.75rem; + font-weight: 700; +} + +.li-main { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; +} + +.li-main strong { + font-size: 0.85rem; + color: var(--glm-gris-oscuro); +} + +.routes-list li.selected .li-main strong { + color: #fff; +} + +.li-main small { + font-size: 0.75rem; + color: var(--glm-acero); +} + +.info-card { + background: var(--glm-surface); + border: 1px solid var(--glm-gris-medio); + border-radius: 4px; + padding: 14px; + border-top: 3px solid var(--glm-azul); +} + +.info-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + border-bottom: 2px solid var(--glm-verde); + padding-bottom: 8px; +} + +.info-card-header h3 { + margin: 0; + font-size: 0.85rem; + color: var(--glm-azul); + font-weight: 700; + text-transform: uppercase; +} + +.detail-section + .detail-section { + margin-top: 14px; + padding-top: 10px; + border-top: 1px solid var(--glm-gris-medio); +} + +.detail-section h4 { + margin: 0 0 5px; + color: var(--glm-verde); + font-size: 0.76rem; + text-transform: uppercase; +} + +.reassign-form { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid var(--glm-gris-medio); +} + +.reassign-form label { display: flex; flex-direction: column; gap: 3px; color: var(--glm-azul); font-size: 0.72rem; font-weight: 700; text-transform: uppercase; } +.reassign-form select { width: 100%; padding: 6px; border: 1px solid var(--glm-gris-medio); border-radius: 3px; background: #fff; color: var(--glm-gris-oscuro); font: inherit; font-size: 0.8rem; } +.reassign-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; } +.reassign-actions .btn-primary, .reassign-actions .btn-secondary { padding: 7px 10px; font-size: 0.78rem; } +.reassign-hint { grid-column: 1 / -1; margin: 0; color: var(--glm-acero); font-size: 0.74rem; } +.reassign-form .reassign-actions, .reassign-form .error-card { grid-column: 1 / -1; } + +.btn-icon { + background: transparent; + border: 1px solid var(--glm-gris-medio); + border-radius: 4px; + padding: 4px 6px; + cursor: pointer; + color: var(--glm-azul); +} + +.btn-icon:hover { + background: var(--glm-verde-bg); + border-color: var(--glm-verde); +} + +.info-list { + display: grid; + gap: 8px; + margin: 0; +} + +.info-list div { + display: grid; + grid-template-columns: auto 1fr; + gap: 10px; + padding: 5px 0; + border-bottom: 1px solid var(--glm-gris-claro); +} + +.info-list div:last-child { + border-bottom: none; +} + +.info-list dt { + font-weight: 600; + color: var(--glm-azul); + font-size: 0.78rem; + text-transform: uppercase; +} + +.info-list dd { + margin: 0; + font-size: 0.85rem; + color: var(--glm-gris-oscuro); + word-break: break-word; +} + +.overview-loading { + position: fixed; + top: 16px; + right: 16px; + z-index: 999; + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: rgba(255, 255, 255, 0.95); + border: 1px solid var(--glm-verde-claro); + border-radius: 4px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15); + color: var(--glm-acero); + font-size: 0.85rem; +} + +.spinner { + width: 18px; + height: 18px; + border: 2px solid var(--glm-gris-medio); + border-top-color: var(--glm-verde); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.btn-secondary { + background: var(--glm-surface); + color: var(--glm-azul); + border: 1px solid var(--glm-gris-medio); + border-radius: 4px; + padding: 8px 14px; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; +} + +.btn-secondary:hover { + background: var(--glm-verde-bg); + border-color: var(--glm-verde); +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.layer-toggles { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--glm-gris-medio); +} + +.toggle { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + color: var(--glm-gris-oscuro); +} + +.heat-opts { + display: flex; + gap: 12px; + padding-left: 22px; + font-size: 0.78rem; + color: var(--glm-acero); +} + +.cluster-glm { + background: none !important; + border: none !important; +} + +.cluster-inner { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--glm-verde); + border: 3px solid #fff; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4); + color: #fff; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + font-family: Arial, sans-serif; + font-size: 12px; +} + +.custom-marker { + background: none !important; + border: none !important; +} + +.marker-inner { + width: 30px; + height: 30px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 12px; + color: #fff; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4); + font-family: Arial, sans-serif; +} + +.location-pin { + position: relative; + width: 30px; + height: 38px; + background: #c9252d; + clip-path: polygon(50% 100%, 8% 43%, 5% 31%, 10% 18%, 22% 7%, 36% 2%, 50% 0, 64% 2%, 78% 7%, 90% 18%, 95% 31%, 92% 43%); + filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.42)); +} + +.location-pin::before { + content: ''; + position: absolute; + inset: 3px 3px 7px; + background: var(--pin-color, #ff424a); + clip-path: polygon(50% 100%, 8% 43%, 5% 31%, 10% 18%, 22% 7%, 36% 2%, 50% 0, 64% 2%, 78% 7%, 90% 18%, 95% 31%, 92% 43%); +} + +.location-pin::after { + content: ''; + position: absolute; + top: 8px; + left: 8px; + width: 14px; + height: 14px; + border-radius: 50%; + background: #620000; +} + +.location-pin span { + position: absolute; + z-index: 1; + top: 8px; + left: 8px; + width: 14px; + color: #fff; + font-size: 9px; + font-weight: 700; + line-height: 14px; + text-align: center; +} + +.unassigned-pin { --pin-color: #ff424a; } diff --git a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.html b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.html new file mode 100644 index 0000000..0a67f6a --- /dev/null +++ b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.html @@ -0,0 +1,165 @@ +
+ +
+
+

Cargando rutas ORS... {{ orsProgress }}/{{ orsTotal }}

+
+ +
+ + + + +
+ + + + + + +
+ {{ filteredRoutes.length }} PDVs + {{ totalDistancia }} km + {{ totalDuracionMin }} min +
+ +
+ +
+ + +
+
+ +
+ Sin rutas para este filtro. +
+
+ +
+ +

No hay rutas cargadas.

+ +
+ +
+ + {{ error }} +
+ +
+
+

PDVs sin ruta ({{ unassignedRoutes.length }})

+
+
+ +
+
+ +
+
+

Gestionar PDV sin ruta

+ +
+ +
+ + + + + +
+
+
+ +
+
PDV
{{ selectedUnassigned.pdv_nombre }}
+
Motivo
{{ selectedUnassigned.motivo }}
+
Coordenadas
{{ selectedUnassigned.latitud }}, {{ selectedUnassigned.longitud }}
+
+
+ + +
+
+
+
{{ unassignedActionError }}
+
+ +
+
+

Detalle de la ruta

+ +
+
+

Usuario

+
+
Nombre
{{ selectedRouteDetail.json.nombre_usuario }}
+
ID
{{ selectedRouteDetail.json.usuario_id }}
+
Origen
{{ selectedRouteDetail.json.latitud_usuario }}, {{ selectedRouteDetail.json.longitud_usuario }}
+
Horas disponibles
{{ selectedRouteDetail.json.hora_laboral_semanal_usuario }} h/semana
+
+
+
+

PDV

+
+
Nombre
{{ selectedRouteDetail.json.nombre_pdv }}
+
ID
{{ selectedRouteDetail.json.pdv_id }}
+
Día
{{ selectedRouteDetail.json.dia }}
+
Orden de visita
{{ selectedVisitOrder }}
+
Origen del tramo
{{ selectedSegmentOrigin }}
+
Destino
{{ selectedRouteDetail.json.latitud_pdv }}, {{ selectedRouteDetail.json.longitud_pdv }}
+
Horas trabajo
{{ selectedRouteDetail.json.horas_trabajo }}
+
Distancia tramo
{{ selectedSegmentDistance | number:'1.2-2' }} km
+
Duración tramo
{{ selectedSegmentDurationMinutes | number:'1.0-0' }} min
+
+
+ +
+
+ + +

Se recalcularán la distancia y duración con ruta vial ORS.

+
+
{{ reassignError }}
+
+
+
+ +
+

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

+
    +
  • + {{ routeVisitOrder(r) }} +
    + {{ r.json.nombre_pdv }} + {{ selectedUsuarioId === 'Todos' ? r.json.nombre_usuario + ' • ' : '' }}{{ r.json.dia }} • {{ routeSegmentDistance(r) | number:'1.2-2' }} km • {{ routeSegmentDurationMinutes(r) | number:'1.0-0' }} min +
    + +
  • +
+
+
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 new file mode 100644 index 0000000..59dda56 --- /dev/null +++ b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.ts @@ -0,0 +1,785 @@ +import { Component, OnInit, OnDestroy, AfterViewInit, ChangeDetectorRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +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 { 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'; + +interface MapRoute { + data: RouteAssignedResponse; + color: string; + origin: [number, number]; + destination: [number, number]; + visitOrder: number; + totalStops: number; + previousPdvName: string | null; + requestId: number; + segmentDistanceKm?: number; + segmentDurationHours?: number; + fallbackLine?: L.Polyline; + orsLine?: L.Polyline; + orsGlow?: L.Polyline; + marker?: L.Marker; + startMarker?: L.Marker; +} + +@Component({ + standalone: true, + selector: 'app-route-overview-map', + imports: [CommonModule, FormsModule], + templateUrl: './route-overview-map.component.html', + styleUrls: ['./route-overview-map.component.css'] +}) +export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewInit { + private map?: L.Map; + private abort$ = new Subject(); + private subs: Subscription[] = []; + private mapRoutes: MapRoute[] = []; + private startMarkers: L.Marker[] = []; + private selectedMapRoute: MapRoute | null = null; + private heatLayer?: any; + showHeat = true; + heatWeightMode: 'uniform' | 'horas_trabajo' = 'horas_trabajo'; + private heatPointsCache: [number, number, number][] = []; + private unassignedMarkers = new Map(); + + usuarios: { id: string; nombre: string }[] = []; + dias: string[] = []; + selectedUsuarioId: string | null = null; + selectedDia: string = 'Todos'; + selectedRouteDetail: RouteAssignedResponse | null = null; + + filteredRoutes: RouteAssignedResponse[] = []; + allRoutes: RouteAssignedResponse[] = []; + + isLoading = false; + orsProgress = 0; + orsTotal = 0; + error: string | null = null; + unassignedRoutes: RouteNoAssignedResponse[] = []; + selectedUnassigned: RouteNoAssignedResponse | null = null; + editingUnassigned: RouteNoAssignedResponse | null = null; + editUnassignedName = ''; + editUnassignedHours = 0; + editUnassignedLat = 0; + editUnassignedLng = 0; + editUnassignedReason = ''; + assignmentUser: Record = {}; + assignmentDay: Record = {}; + assigningUnassignedId: number | null = null; + unassignedActionError: string | null = null; + reassigningRoute = false; + showReassignForm = false; + reassignUserId = ''; + reassignDay = ''; + reassignError: string | null = null; + + // Summary stats + get totalDistancia(): string { + const sum = this.mapRoutes.length + ? this.mapRoutes.reduce((acc, route) => acc + (route.segmentDistanceKm ?? haversineKm(route.origin, route.destination)), 0) + : this.filteredRoutes.reduce((acc, route) => acc + Number(route.json.distancia), 0); + return sum.toFixed(2); + } + get totalDuracionMin(): string { + const sum = this.mapRoutes.length + ? this.mapRoutes.reduce((acc, route) => acc + (route.segmentDurationHours ?? Number(route.data.json.horas_desplazamiento)) * 60, 0) + : this.filteredRoutes.reduce((acc, route) => acc + Number(route.json.horas_desplazamiento) * 60, 0); + return sum.toFixed(0); + } + + constructor( + private routeState: RouteState, + private orsService: OrsService, + private router: Router, + private activatedRoute: ActivatedRoute, + private cdr: ChangeDetectorRef + ) {} + + ngOnInit() { + this.subs.push( + this.routeState.assignedRoutes$.subscribe(routes => { + this.allRoutes = [...routes]; + this.usuarios = this.routeState.getUsuariosUnicos(); + this.dias = this.routeState.getDiasUnicos(); + // Init from queryParams default (Todos) + const qp = this.activatedRoute.snapshot.queryParams; + if (qp['usuario_id']) this.selectedUsuarioId = String(qp['usuario_id']); + else if (!this.selectedUsuarioId && this.usuarios.length) this.selectedUsuarioId = 'Todos'; + if (qp['dia']) this.selectedDia = String(qp['dia']); + // If focus pdv provided, auto-select detail after map loads + this.applyFilter(); + this.cdr.detectChanges(); + if (this.map) this.renderFiltered(); + }) + ); + + this.subs.push( + this.routeState.noAssignedRoutes$.subscribe(routes => { + this.unassignedRoutes = [...routes]; + if (this.map) this.renderFiltered(); + this.cdr.detectChanges(); + }) + ); + + // React to queryParams changes + this.subs.push( + this.activatedRoute.queryParams.subscribe(qp => { + let changed = false; + if (qp['usuario_id'] && String(qp['usuario_id']) !== this.selectedUsuarioId) { + this.selectedUsuarioId = String(qp['usuario_id']); + changed = true; + } + if (qp['dia'] && qp['dia'] !== this.selectedDia) { + this.selectedDia = String(qp['dia']); + changed = true; + } + if (changed) { + this.applyFilter(); + if (this.map) this.renderFiltered(); + this.cdr.detectChanges(); + } + // Focus a specific route if pdv_id passed + if (qp['pdv_id'] && this.filteredRoutes.length) { + const found = this.filteredRoutes.find(r => String(r.json.pdv_id) === String(qp['pdv_id'])); + if (found) { + this.selectedRouteDetail = found; + this.highlightRoute(found); + } + } + }) + ); + } + + ngAfterViewInit() { + this.initMap(); + this.applyFilter(); + // Defer render to ensure container has size (fixes white tiles) + setTimeout(() => { + this.map?.invalidateSize(); + this.renderFiltered(); + }, 100); + } + + ngOnDestroy() { + this.abort$.next(); + this.abort$.complete(); + this.subs.forEach(s => s.unsubscribe()); + const onResize = (this as any)._onResize; + if (onResize) window.removeEventListener('resize', onResize); + this.clearMap(); + if (this.map) this.map.remove(); + } + + onUsuarioChange() { + this.updateQueryParams(); + this.applyFilter(); + this.renderFiltered(); + } + + onDiaChange() { + this.updateQueryParams(); + this.applyFilter(); + this.renderFiltered(); + } + + private updateQueryParams() { + this.router.navigate([], { + relativeTo: this.activatedRoute, + queryParams: { usuario_id: this.selectedUsuarioId, dia: this.selectedDia }, + queryParamsHandling: 'merge', + replaceUrl: true + }); + } + + private applyFilter() { + let list = this.selectedUsuarioId === 'Todos' + ? [...this.allRoutes] + : this.allRoutes.filter(r => String(r.json.usuario_id) === String(this.selectedUsuarioId)); + if (this.selectedDia !== 'Todos') { + list = list.filter(r => r.json.dia === this.selectedDia); + } + this.filteredRoutes = list; + if (this.filteredRoutes.length === 0) { + this.error = this.allRoutes.length === 0 ? 'No hay rutas cargadas. Sube archivos en Home.' : 'Sin rutas para este usuario/día.'; + } else { + this.error = null; + } + // Clear selected detail if not in filtered + if (this.selectedRouteDetail && !this.filteredRoutes.some(route => this.sameRoute(route, this.selectedRouteDetail!))) { + this.selectedRouteDetail = null; + this.selectedMapRoute = null; + } + } + + private initMap() { + this.map = L.map('map-overview').setView([18.4861, -69.9312], 10); + const pdvPane = this.map.createPane('pdvPane'); + pdvPane.style.zIndex = '650'; + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors' + }).addTo(this.map); + // Fix tiles after sidebar overlay + setTimeout(() => this.map?.invalidateSize(), 200); + // Resize handler + const onResize = () => this.map?.invalidateSize(); + window.addEventListener('resize', onResize); + // store for cleanup + (this as any)._onResize = onResize; + } + + private clearMap() { + for (const mr of this.mapRoutes) { + if (mr.fallbackLine) this.map?.removeLayer(mr.fallbackLine); + if (mr.orsLine) this.map?.removeLayer(mr.orsLine); + if (mr.orsGlow) this.map?.removeLayer(mr.orsGlow); + if (mr.marker) this.map?.removeLayer(mr.marker); + } + this.unassignedMarkers.forEach(marker => this.map?.removeLayer(marker)); + this.unassignedMarkers.clear(); + if (this.heatLayer) { + this.map?.removeLayer(this.heatLayer); + this.heatLayer = undefined; + } + for (const m of this.startMarkers) this.map?.removeLayer(m); + this.heatPointsCache = []; + this.mapRoutes = []; + this.startMarkers = []; + this.selectedMapRoute = null; + } + + private async renderFiltered() { + if (!this.map) return; + this.abort$.next(); + this.clearMap(); + this.isLoading = false; + this.orsProgress = 0; + this.orsTotal = 0; + if (!this.filteredRoutes.length) { + this.cdr.detectChanges(); + return; + } + + // Add one colored start marker per user in the global view. + const starts = new Map(); + this.filteredRoutes.forEach(route => starts.set(String(route.json.usuario_id), route.json)); + starts.forEach((start, userId) => { + const color = colorForUsuario(userId).color; + const count = this.filteredRoutes.filter(route => String(route.json.usuario_id) === userId).length; + const startIcon = L.divIcon({ + className: 'custom-marker marker-start', + html: `
A
`, + iconSize: [34, 34], + iconAnchor: [17, 17], + popupAnchor: [0, -18] + }); + this.startMarkers.push( + L.marker([Number(start.latitud_usuario), Number(start.longitud_usuario)], { icon: startIcon, zIndexOffset: 100 }) + .addTo(this.map!) + .bindPopup(`Usuario: ${start.nombre_usuario}
PDVs: ${count}`) + ); + }); + + // Build one daily chain per user: home -> nearest PDV -> next nearest PDV. + const bounds: L.LatLngExpression[] = []; + const sequencedRoutes = sequenceRoutes(this.filteredRoutes); + this.filteredRoutes = sequencedRoutes.map(segment => segment.route); + sequencedRoutes.forEach((segment, idx) => { + const route = segment.route; + const j = route.json; + const color = colorForUsuario(j.usuario_id).color; + const origin = segment.origin; + const dest = segment.destination; + bounds.push([origin[1], origin[0]]); + bounds.push([Number(j.latitud_pdv), Number(j.longitud_pdv)]); + + // Fallback dashed line (visible immediately) + const fallbackLine = L.polyline( + [[origin[1], origin[0]], [dest[1], dest[0]]], + { color, weight: 4, dashArray: '10, 10', opacity: 0.6 } + ).addTo(this.map!); + + // Marker for PDV + const pdvIcon = L.divIcon({ + className: 'custom-marker marker-end', + html: `
${segment.order}
`, + iconSize: [30, 38], + iconAnchor: [15, 38], + popupAnchor: [0, -18] + }); + const marker = L.marker([Number(j.latitud_pdv), Number(j.longitud_pdv)], { icon: pdvIcon, pane: 'pdvPane', zIndexOffset: 1000 }) + .addTo(this.map!) + .bindPopup(`${j.nombre_pdv}
Parada ${segment.order} de ${segment.totalStops} • ${j.dia}`); + // Heat point + const w = this.computeWeight(j); + this.heatPointsCache.push([Number(j.latitud_pdv), Number(j.longitud_pdv), w]); + + const mr: MapRoute = { + data: route, + color, + origin, + destination: dest, + visitOrder: segment.order, + totalStops: segment.totalStops, + previousPdvName: segment.previousPdvName, + requestId: idx + 1, + fallbackLine, + marker + }; + this.mapRoutes.push(mr); + + // Click handlers + const onClick = () => this.selectRoute(mr); + fallbackLine.on('click', onClick); + marker.on('click', onClick); + }); + + this.updateHeatLayer(); + this.toggleLayerVisibility(); + + // Fit bounds + try { + this.map!.fitBounds(L.latLngBounds(bounds as L.LatLngTuple[]).pad(0.15)); + } catch { /* ignore */ } + + // Progressive ORS hydration — always in background (heat only hides visually) + this.hydrateWithOrs(); + this.renderUnassignedMarkers(); + this.cdr.detectChanges(); + } + + private renderUnassignedMarkers(): void { + if (!this.map || this.selectedUsuarioId !== 'Todos' || this.selectedDia !== 'Todos') return; + for (const route of this.unassignedRoutes) { + const icon = L.divIcon({ + className: 'custom-marker unassigned-marker', + html: '
!
', + iconSize: [30, 38], + iconAnchor: [15, 38] + }); + const marker = L.marker([route.latitud, route.longitud], { icon, pane: 'pdvPane', zIndexOffset: 1000 }) + .addTo(this.map) + .bindPopup(`PDV sin ruta
${route.pdv_nombre}
Motivo: ${route.motivo}`); + marker.on('click', () => this.selectUnassigned(route)); + this.unassignedMarkers.set(route.pdv_id, marker); + } + } + + private async hydrateWithOrs() { + if (!this.filteredRoutes.length) return; + this.isLoading = true; + this.orsTotal = this.filteredRoutes.length; + this.orsProgress = 0; + this.cdr.detectChanges(); + + const pares = this.mapRoutes.map(route => ({ + pdv_id: route.requestId, + origin: route.origin, + destination: route.destination + })); + + const { promise } = this.orsService.processPairsWithProgress(pares, 2, true, this.abort$); + try { + const results = await promise; + if (!results.length) { + this.isLoading = false; + this.cdr.detectChanges(); + return; + } + const geoByRequest = new Map(); + for (const item of results) { + const g = item as { pdv_id?: number; geojson?: unknown; error?: string }; + if (g && !g.error && g.geojson) { + geoByRequest.set(g.pdv_id!, g.geojson); + } else if (g && g.error) { + // error entry, skip but count progress + } else { + // fallback: if item is raw geojson without wrapper (old cache), treat as geojson + const maybeGeo = item as unknown; + if (maybeGeo && (maybeGeo as any).type) { + // cannot map, will fallback to index correlation below + } + } + } + const useMap = geoByRequest.size > 0; + for (let i = 0; i < this.mapRoutes.length; i++) { + const mr = this.mapRoutes[i]; + let geo: unknown = null; + if (useMap) { + geo = geoByRequest.get(mr.requestId); + } else { + const res = results[i] as { error?: string; geojson?: unknown; type?: string; features?: unknown[] }; + if ((res as any)?.error) { + this.orsProgress = i + 1; + this.cdr.detectChanges(); + continue; + } + geo = (res as any)?.geojson ?? res; + } + if (!geo) { + this.orsProgress = i + 1; + this.cdr.detectChanges(); + continue; + } + const coords = this.extractCoords(geo); + if (!coords.length) { + this.orsProgress = i + 1; + this.cdr.detectChanges(); + continue; + } + if (mr.fallbackLine) { + this.map?.removeLayer(mr.fallbackLine); + mr.fallbackLine = undefined; + } + // Heatmap and route layers are independent; both remain visible together. + const glow = L.polyline(coords, { color: mr.color, weight: 12, opacity: 0.25, lineCap: 'round', lineJoin: 'round' }).addTo(this.map!); + const line = L.polyline(coords, { color: mr.color, weight: 5, opacity: 0.95, lineCap: 'round', lineJoin: 'round' }).addTo(this.map!); + const summary = this.extractSummary(geo); + if (summary) { + mr.segmentDistanceKm = summary.distance / 1000; + mr.segmentDurationHours = summary.duration / 3600; + mr.marker?.setPopupContent( + `${mr.data.json.nombre_pdv}
Parada ${mr.visitOrder} de ${mr.totalStops} • ${mr.data.json.dia}
${mr.segmentDistanceKm.toFixed(2)} km • ${(mr.segmentDurationHours * 60).toFixed(0)} min` + ); + } + line.on('click', () => this.selectRoute(mr)); + mr.orsGlow = glow; + mr.orsLine = line; + if (this.selectedRouteDetail && this.sameRoute(this.selectedRouteDetail, mr.data)) { + this.fitSelectedRoute(mr); + } + this.orsProgress = i + 1; + this.cdr.detectChanges(); + } + this.toggleLayerVisibility(); + } catch { + // Ignore abort / network errors, fallback lines remain + } finally { + this.isLoading = false; + this.cdr.detectChanges(); + } + } + + private extractCoords(geojson: unknown): L.LatLngExpression[] { + const g = geojson as { type: string; features?: { geometry: { coordinates: number[][] } }[]; coordinates?: number[][] }; + if (g?.type === 'FeatureCollection' && g.features?.length) { + return g.features[0].geometry.coordinates.map(c => [c[1], c[0]] as L.LatLngExpression); + } + if (g?.type === 'LineString' && g.coordinates) { + return g.coordinates.map(c => [c[1], c[0]] as L.LatLngExpression); + } + const maybeFeature = (geojson as { features?: { geometry: { coordinates: number[][] } }[] })?.features; + if (maybeFeature?.length) { + return maybeFeature[0].geometry.coordinates.map(c => [c[1], c[0]] as L.LatLngExpression); + } + return []; + } + + private extractSummary(geojson: unknown): { distance: number; duration: number } | null { + const summary = (geojson as { features?: { properties?: { summary?: { distance: number; duration: number } } }[] }) + ?.features?.[0]?.properties?.summary; + return summary ?? null; + } + + private selectRoute(mr: MapRoute) { + this.selectedRouteDetail = mr.data; + this.highlightRoute(mr.data); + this.fitSelectedRoute(mr); + this.cdr.detectChanges(); + } + + selectRouteFromList(route: RouteAssignedResponse): void { + const mapRoute = this.mapRoutes.find(mr => this.sameRoute(mr.data, route)); + if (mapRoute) { + this.selectRoute(mapRoute); + return; + } + this.selectedRouteDetail = route; + this.cdr.detectChanges(); + } + + startReassignRoute(): void { + if (!this.selectedRouteDetail) return; + this.showReassignForm = true; + this.reassignUserId = String(this.selectedRouteDetail.json.usuario_id); + this.reassignDay = this.selectedRouteDetail.json.dia; + this.reassignError = null; + } + + cancelReassignRoute(): void { + this.showReassignForm = false; + this.reassignError = null; + } + + async reassignRoute(): Promise { + const route = this.selectedRouteDetail; + if (!route || !this.reassignUserId || !this.reassignDay || this.reassigningRoute) return; + const sameUser = String(route.json.usuario_id) === this.reassignUserId; + const sameDay = route.json.dia === this.reassignDay; + if (sameUser && sameDay) { + this.cancelReassignRoute(); + return; + } + const target = this.routeState.getAssignedSnapshot().find(item => String(item.json.usuario_id) === this.reassignUserId); + if (!target) { + this.reassignError = 'No se encontró el usuario seleccionado.'; + return; + } + this.reassigningRoute = true; + this.reassignError = null; + try { + const origin = [Number(target.json.longitud_usuario), Number(target.json.latitud_usuario)]; + const destination = [Number(route.json.longitud_pdv), Number(route.json.latitud_pdv)]; + const results = await this.orsService.processPairs([{ pdv_id: route.json.pdv_id, origin, destination }], 1, true); + 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'); + this.routeState.updateAssignedRoute(route, this.reassignUserId, this.reassignDay, { + distance: summary.distance / 1000, + duration: summary.duration / 3600 + }); + this.selectedRouteDetail = this.routeState.findRoute(route.json.pdv_id, this.reassignUserId); + this.showReassignForm = false; + } catch { + this.reassignError = 'No se pudo recalcular la ruta vial. La asignación anterior se mantuvo.'; + } finally { + this.reassigningRoute = false; + this.cdr.detectChanges(); + } + } + + private fitSelectedRoute(mr: MapRoute): void { + if (!this.map) return; + const line = mr.orsLine || mr.fallbackLine; + if (!line) return; + const bounds = line.getBounds(); + const json = mr.data.json; + bounds.extend([Number(json.latitud_usuario), Number(json.longitud_usuario)]); + bounds.extend([Number(json.latitud_pdv), Number(json.longitud_pdv)]); + if (bounds.isValid()) { + this.map.fitBounds(bounds, { padding: [36, 36], maxZoom: 16, animate: true, duration: 0.5 }); + } + } + + routeVisitOrder(route: RouteAssignedResponse): number { + return this.mapRoutes.find(item => this.sameRoute(item.data, route))?.visitOrder ?? 0; + } + + routeSegmentDistance(route: RouteAssignedResponse): number { + const segment = this.mapRoutes.find(item => this.sameRoute(item.data, route)); + return segment?.segmentDistanceKm ?? Number(route.json.distancia); + } + + routeSegmentDurationMinutes(route: RouteAssignedResponse): number { + const segment = this.mapRoutes.find(item => this.sameRoute(item.data, route)); + return (segment?.segmentDurationHours ?? Number(route.json.horas_desplazamiento)) * 60; + } + + get selectedVisitOrder(): string { + const segment = this.selectedMapSegment; + return segment ? `${segment.visitOrder} de ${segment.totalStops}` : '-'; + } + + get selectedSegmentOrigin(): string { + const segment = this.selectedMapSegment; + if (!segment) return '-'; + return segment.previousPdvName ?? `Casa de ${segment.data.json.nombre_usuario}`; + } + + get selectedSegmentDistance(): number { + const segment = this.selectedMapSegment; + return segment?.segmentDistanceKm ?? Number(this.selectedRouteDetail?.json.distancia ?? 0); + } + + get selectedSegmentDurationMinutes(): number { + const segment = this.selectedMapSegment; + return (segment?.segmentDurationHours ?? Number(this.selectedRouteDetail?.json.horas_desplazamiento ?? 0)) * 60; + } + + private get selectedMapSegment(): MapRoute | null { + if (!this.selectedRouteDetail) return null; + return this.mapRoutes.find(item => this.sameRoute(item.data, this.selectedRouteDetail!)) ?? null; + } + + private sameRoute(left: RouteAssignedResponse, right: RouteAssignedResponse): boolean { + return left.json.pdv_id === right.json.pdv_id && + String(left.json.usuario_id) === String(right.json.usuario_id) && + left.json.dia === right.json.dia; + } + + get unassignedUserOptions(): { id: string; name: string }[] { + return this.routeState.getUsuariosUnicos().map(user => ({ id: user.id, name: user.nombre })); + } + + get unassignedDayOptions(): string[] { + return this.routeState.getDiasUnicos(); + } + + selectUnassigned(route: RouteNoAssignedResponse): void { + this.selectedUnassigned = route; + this.selectedRouteDetail = null; + this.map?.setView([route.latitud, route.longitud], Math.max(this.map.getZoom(), 14)); + this.unassignedMarkers.get(route.pdv_id)?.openPopup(); + this.cdr.detectChanges(); + } + + startUnassignedEdit(route: RouteNoAssignedResponse): void { + this.editingUnassigned = route; + this.editUnassignedName = route.pdv_nombre; + this.editUnassignedHours = route.minimo_horas_semana; + this.editUnassignedLat = route.latitud; + this.editUnassignedLng = route.longitud; + this.editUnassignedReason = route.motivo; + this.unassignedActionError = null; + } + + cancelUnassignedEdit(): void { + this.editingUnassigned = null; + } + + saveUnassignedEdit(): void { + if (!this.editingUnassigned || !this.editUnassignedName.trim()) { + this.unassignedActionError = 'El nombre del PDV es obligatorio.'; + return; + } + if (!Number.isFinite(this.editUnassignedLat) || this.editUnassignedLat < -90 || this.editUnassignedLat > 90 || + !Number.isFinite(this.editUnassignedLng) || this.editUnassignedLng < -180 || this.editUnassignedLng > 180) { + this.unassignedActionError = 'Latitud o longitud inválida.'; + return; + } + if (!Number.isFinite(this.editUnassignedHours) || this.editUnassignedHours <= 0) { + this.unassignedActionError = 'Las horas mínimas deben ser mayores que cero.'; + return; + } + this.routeState.updateNoAssignedRoute(this.editingUnassigned, { + pdv_nombre: this.editUnassignedName.trim(), + minimo_horas_semana: this.editUnassignedHours, + latitud: this.editUnassignedLat, + longitud: this.editUnassignedLng, + motivo: this.editUnassignedReason.trim() + }); + this.selectedUnassigned = { ...this.editingUnassigned, pdv_nombre: this.editUnassignedName.trim(), latitud: this.editUnassignedLat, longitud: this.editUnassignedLng }; + this.editingUnassigned = null; + } + + 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.'; + return; + } + const user = this.routeState.getAssignedSnapshot().find(item => String(item.json.usuario_id) === userId); + if (!user) return; + this.assigningUnassignedId = route.pdv_id; + this.unassignedActionError = null; + 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); + this.selectedUnassigned = null; + delete this.assignmentUser[String(route.pdv_id)]; + delete this.assignmentDay[String(route.pdv_id)]; + } catch { + this.unassignedActionError = `No se pudo calcular la ruta vial para ${route.pdv_nombre}.`; + } finally { + this.assigningUnassignedId = null; + this.cdr.detectChanges(); + } + } + + highlightRoute(route: RouteAssignedResponse) { + // Reset previous selection style + if (this.selectedMapRoute) { + const prev = this.selectedMapRoute.orsLine || this.selectedMapRoute.fallbackLine; + if (prev) (prev as L.Polyline).setStyle?.({ weight: this.selectedMapRoute.orsLine ? 5 : 4, opacity: this.selectedMapRoute.orsLine ? 0.95 : 0.6 }); + } + const mr = this.mapRoutes.find(item => this.sameRoute(item.data, route)); + if (mr) { + this.selectedMapRoute = mr; + const line = mr.orsLine || mr.fallbackLine; + if (line) (line as L.Polyline).setStyle({ weight: 8, opacity: 1 }); + // Bring to front + if (mr.orsLine) mr.orsLine.bringToFront?.(); + else if (mr.fallbackLine) mr.fallbackLine.bringToFront?.(); + try { + mr.marker?.openPopup(); + } catch {} + } + } + + private computeWeight(j: RouteAssignedResponse['json']): number { + if (this.heatWeightMode === 'uniform') return 0.8; + const v = parseFloat(String(j.horas_trabajo)); + if (isNaN(v)) return 0.8; + const vals = this.filteredRoutes.map(r => parseFloat(String(r.json.horas_trabajo))).filter(n => !isNaN(n)); + if (!vals.length) return 0.8; + const min = Math.min(...vals), max = Math.max(...vals); + if (max === min) return 0.8; + return 0.3 + 0.7 * (v - min) / (max - min); + } + + private updateHeatLayer() { + if (this.heatLayer) { + this.map?.removeLayer(this.heatLayer); + this.heatLayer = undefined; + } + if (!this.showHeat || !this.heatPointsCache.length || !this.map) return; + this.heatLayer = (L as any).heatLayer(this.heatPointsCache, { + radius: 25, blur: 18, maxZoom: 17, minOpacity: 0.4, + gradient: { 0.4: '#6CC24A', 0.65: '#FF6A13', 1.0: '#D32F2F' } + }).addTo(this.map); + } + + toggleHeat() { + this.showHeat = !this.showHeat; + this.updateHeatLayer(); + this.toggleLayerVisibility(); + const needsHydrate = !this.mapRoutes.some(m => m.orsLine) || this.orsTotal === 0 || this.orsProgress < this.orsTotal; + if (!this.showHeat && needsHydrate) { + this.hydrateWithOrs(); + } + this.cdr.detectChanges(); + } + + setHeatWeight(mode: 'uniform' | 'horas_trabajo') { + this.heatWeightMode = mode; + this.heatPointsCache = this.filteredRoutes.map(r => { + const j = r.json; + return [Number(j.latitud_pdv), Number(j.longitud_pdv), this.computeWeight(j)] as [number, number, number]; + }); + this.updateHeatLayer(); + this.cdr.detectChanges(); + } + + private toggleLayerVisibility() { + this.mapRoutes.forEach(mr => { + if (mr.fallbackLine) (mr.fallbackLine as any).setStyle({ opacity: 0.6 }); + if (mr.orsLine) (mr.orsLine as any).setStyle({ opacity: 0.95 }); + if (mr.orsGlow) (mr.orsGlow as any).setStyle({ opacity: 0.25 }); + }); + } + + clearSelection() { + if (this.selectedMapRoute) { + const line = this.selectedMapRoute.orsLine || this.selectedMapRoute.fallbackLine; + if (line) (line as L.Polyline).setStyle({ weight: this.selectedMapRoute.orsLine ? 5 : 4, opacity: this.selectedMapRoute.orsLine ? 0.95 : 0.6 }); + } + this.selectedMapRoute = null; + this.selectedRouteDetail = null; + } + + goBack() { + this.router.navigate(['/home']); + } +} diff --git a/route-planner/src/app/shared/schemas/index.ts b/route-planner/src/app/shared/schemas/index.ts new file mode 100644 index 0000000..51a1f9e --- /dev/null +++ b/route-planner/src/app/shared/schemas/index.ts @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..24e5939 --- /dev/null +++ b/route-planner/src/app/shared/schemas/params.schema.ts @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000..5c0db75 --- /dev/null +++ b/route-planner/src/app/shared/schemas/pdv.schema.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; + +export const PDV_REQUIRED_HEADERS = [ + 'ID', + 'Nombre del PDV', + 'Segmentación', + 'Latitud', + 'Longitud', + 'Visitas semanales', + 'Duración visita(horas)', + 'Prioridad', +] as const; + +export const PDV_NORMALIZED_REQUIRED = [ + 'id', + 'nombre_del_pdv', + 'segmentacion', + 'latitud', + 'longitud', + 'visitas_semanales', + 'duracion_visitahoras', + 'prioridad', +] as const; + +export const SEGMENTACIONES = ['AA', 'A', 'B', 'C', 'D'] as const; + +export const pdvRowSchema = z.object({ + id: z.union([z.string(), z.number()]).refine(v => String(v).trim() !== '', { message: 'ID requerido' }), + nombre_del_pdv: z.string().min(1, 'Nombre del PDV requerido'), + segmentacion: z.string().refine(v => (SEGMENTACIONES as readonly string[]).includes(v.toUpperCase()), { + message: `Segmentación debe ser ${SEGMENTACIONES.join(', ')}`, + }), + 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'), + prioridad: z.number().int('Prioridad debe ser entero').min(1, 'Prioridad mínimo 1').max(5, 'Prioridad máximo 5'), +}); + +export type PdvRow = z.infer; +export const PDV_MAX_ROWS = 2000; diff --git a/route-planner/src/app/shared/schemas/usuario.schema.ts b/route-planner/src/app/shared/schemas/usuario.schema.ts new file mode 100644 index 0000000..eb1d505 --- /dev/null +++ b/route-planner/src/app/shared/schemas/usuario.schema.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; + +export const USUARIO_REQUIRED_HEADERS = [ + 'ID', + 'Nombre del empleado', + 'Latitud', + 'Longitud', + 'Horas por día usuario', + 'Horas por semana usuario', +] as const; + +export const USUARIO_NORMALIZED_REQUIRED = [ + 'id', + 'nombre_del_empleado', + 'latitud', + 'longitud', + 'horas_por_dia_usuario', + 'horas_por_semana_usuario', +] as const; + +export const usuarioRowSchema = z.object({ + id: z.union([z.string(), z.number()]).refine(v => String(v).trim() !== '', { message: 'ID requerido' }), + nombre_del_empleado: z.string().min(1, 'Nombre del empleado requerido'), + 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'), + horas_por_dia_usuario: z.number().min(0.5, 'Horas por día mínimo 0.5').max(24, 'Horas por día máximo 24'), + horas_por_semana_usuario: z.number().min(1, 'Horas por semana mínimo 1').max(168, 'Horas por semana máximo 168'), +}).superRefine((data, ctx) => { + if (data.horas_por_semana_usuario > data.horas_por_dia_usuario * 7) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Horas por semana no puede exceder 7 × horas por día', + path: ['horas_por_semana_usuario'], + }); + } +}); + +export type UsuarioRow = z.infer; +export const USUARIO_MAX_ROWS = 50; diff --git a/route-planner/src/app/shared/services/excel-preview.service.ts b/route-planner/src/app/shared/services/excel-preview.service.ts new file mode 100644 index 0000000..8aac4dc --- /dev/null +++ b/route-planner/src/app/shared/services/excel-preview.service.ts @@ -0,0 +1,182 @@ +import { Injectable } from '@angular/core'; +import * as XLSX from 'xlsx'; +import { normalizeRow, normalizeHeader, cleanNum } from '../utils/header-normalization.utils'; +import { pdvRowSchema, PDV_NORMALIZED_REQUIRED, PDV_MAX_ROWS, SEGMENTACIONES } from '../schemas/pdv.schema'; +import { usuarioRowSchema, USUARIO_NORMALIZED_REQUIRED, USUARIO_MAX_ROWS } from '../schemas/usuario.schema'; + +export interface FieldError { + row: number; // 1-indexed excel row (2 = first data row) + field: string; + message: string; + value?: unknown; +} + +export interface PreviewResult { + fileName: string; + type: 'PDV' | 'Usuarios'; + valid: boolean; + totalRows: number; + validRows: number; + headers: string[]; + normalizedHeaders: string[]; + missingHeaders: string[]; + errors: FieldError[]; + duplicateIds: (string | number)[]; + sampleValidRows: Record[]; +} + +@Injectable({ providedIn: 'root' }) +export class ExcelPreviewService { + + async previewFile(file: File, type: 'PDV' | 'Usuarios'): Promise { + const buffer = await file.arrayBuffer(); + const workbook = XLSX.read(buffer, { type: 'array', cellDates: true }); + const sheetName = workbook.SheetNames[0]; + const sheet = workbook.Sheets[sheetName]; + if (!sheetName || !sheet) { + throw new Error(`${type}: El archivo no contiene una hoja válida`); + } + const rawRows: Record[] = XLSX.utils.sheet_to_json(sheet, { defval: null, raw: true }); + + const headers = this.extractHeaders(sheet); + const normalizedHeaders = headers.map(h => normalizeHeader(String(h))); + + const required = type === 'PDV' ? [...PDV_NORMALIZED_REQUIRED] : [...USUARIO_NORMALIZED_REQUIRED]; + const displayRequired = type === 'PDV' + ? ['ID', 'Nombre del PDV', 'Segmentación', 'Latitud', 'Longitud', 'Visitas semanales', 'Duración visita(horas)', 'Prioridad'] + : ['ID', 'Nombre del empleado', 'Latitud', 'Longitud', 'Horas por día usuario', 'Horas por semana usuario']; + + const missingHeaders: string[] = []; + for (let i = 0; i < required.length; i++) { + if (!normalizedHeaders.includes(required[i])) { + missingHeaders.push(displayRequired[i]); + } + } + + const errors: FieldError[] = []; + const repeatedHeaders = normalizedHeaders.filter((header, index) => header && normalizedHeaders.indexOf(header) !== index); + if (repeatedHeaders.length) { + errors.push({ row: 1, field: 'headers', message: `Columnas duplicadas: ${Array.from(new Set(repeatedHeaders)).join(', ')}` }); + } + if (missingHeaders.length) { + errors.push({ row: 1, field: 'headers', message: `Columnas faltantes: ${missingHeaders.join(', ')}` }); + } + + if (rawRows.length === 0) { + errors.push({ row: 1, field: 'file', message: `${type}: Archivo vacío o sin filas de datos` }); + return this.buildResult(file.name, type, headers, normalizedHeaders, missingHeaders, rawRows, errors, []); + } + + const maxRows = type === 'PDV' ? PDV_MAX_ROWS : USUARIO_MAX_ROWS; + if (rawRows.length > maxRows) { + errors.push({ row: 1, field: 'file', message: `${type}: Máximo ${maxRows} filas permitidas (tiene ${rawRows.length})` }); + } + + const normalizedRows = rawRows.map(r => normalizeRow(r as Record)); + const seenIds = new Map(); + const rowErrors: FieldError[] = []; + + normalizedRows.forEach((row, idx) => { + const excelRow = idx + 2; + const idVal = row['id']; + if (idVal !== null && idVal !== undefined && String(idVal).trim() !== '') { + const key = String(idVal).trim(); + if (!seenIds.has(key)) seenIds.set(key, []); + seenIds.get(key)!.push(excelRow); + } + + // Coerce numeric fields with cleanNum before zod + const coerced: Record = { ...row }; + if (type === 'PDV') { + coerced['latitud'] = this.coerceNum(row['latitud']); + coerced['longitud'] = this.coerceNum(row['longitud']); + coerced['visitas_semanales'] = this.coerceNum(row['visitas_semanales']); + coerced['duracion_visitahoras'] = this.coerceNum(row['duracion_visitahoras']); + coerced['prioridad'] = this.coerceNum(row['prioridad']); + if (typeof coerced['segmentacion'] === 'string') { + coerced['segmentacion'] = String(coerced['segmentacion']).trim().toUpperCase(); + } + // validate with zod + const result = pdvRowSchema.safeParse(coerced); + if (!result.success) { + for (const issue of result.error.issues) { + const field = String(issue.path[0] ?? 'unknown'); + rowErrors.push({ row: excelRow, field, message: issue.message, value: (row as Record)[field] ?? coerced[field] }); + } + } + } else { + coerced['latitud'] = this.coerceNum(row['latitud']); + coerced['longitud'] = this.coerceNum(row['longitud']); + coerced['horas_por_dia_usuario'] = this.coerceNum(row['horas_por_dia_usuario']); + coerced['horas_por_semana_usuario'] = this.coerceNum(row['horas_por_semana_usuario']); + const result = usuarioRowSchema.safeParse(coerced); + if (!result.success) { + for (const issue of result.error.issues) { + const field = String(issue.path[0] ?? 'unknown'); + rowErrors.push({ row: excelRow, field, message: issue.message, value: (row as Record)[field] ?? coerced[field] }); + } + } + } + }); + + const duplicateIds: (string | number)[] = []; + for (const [id, rows] of seenIds.entries()) { + if (rows.length > 1) { + duplicateIds.push(id); + for (const r of rows) { + rowErrors.push({ row: r, field: 'id', message: `ID duplicado: ${id}`, value: id }); + } + } + } + + const allErrors = [...errors, ...rowErrors]; + return this.buildResult(file.name, type, headers, normalizedHeaders, missingHeaders, normalizedRows, allErrors, duplicateIds); + } + + private coerceNum(value: unknown): unknown { + if (value === null || value === undefined || value === '') return value; + const n = cleanNum(value); + return Number.isNaN(n) ? value : n; + } + + private extractHeaders(sheet: XLSX.WorkSheet): string[] { + const range = XLSX.utils.decode_range(sheet['!ref'] || 'A1'); + const headers: string[] = []; + for (let c = range.s.c; c <= range.e.c; c++) { + const addr = XLSX.utils.encode_cell({ r: range.s.r, c }); + const cell = sheet[addr]; + headers.push(cell ? String(cell.v) : ''); + } + return headers.filter(h => h !== ''); + } + + private buildResult( + fileName: string, + type: 'PDV' | 'Usuarios', + headers: string[], + normalizedHeaders: string[], + missingHeaders: string[], + rows: Record[], + errors: FieldError[], + duplicateIds: (string | number)[] + ): PreviewResult { + const validRows = Math.max(0, rows.length - new Set(errors.filter(e => e.field !== 'headers' && e.field !== 'file').map(e => e.row)).size); + const hasHeaderErrors = missingHeaders.length > 0; + const maxRows = type === 'PDV' ? PDV_MAX_ROWS : USUARIO_MAX_ROWS; + const overLimit = rows.length > maxRows; + const valid = !hasHeaderErrors && !overLimit && errors.length === 0; + return { + fileName, + type, + valid, + totalRows: rows.length, + validRows: valid ? rows.length : validRows, + headers, + normalizedHeaders, + missingHeaders, + errors, + duplicateIds, + sampleValidRows: rows.slice(0, 3), + }; + } +} diff --git a/route-planner/src/app/shared/services/planning-export.service.ts b/route-planner/src/app/shared/services/planning-export.service.ts new file mode 100644 index 0000000..8811f8c --- /dev/null +++ b/route-planner/src/app/shared/services/planning-export.service.ts @@ -0,0 +1,88 @@ +import { Injectable } from '@angular/core'; +import * as XLSX from 'xlsx'; +import { saveAs } from 'file-saver'; +import { RouteState } from '../../core/route-state/route'; + +@Injectable({ providedIn: 'root' }) +export class PlanningExportService { + constructor(private routeState: RouteState) {} + + export(): void { + const assigned = this.routeState.getAssignedSnapshot(); + const unassigned = this.routeState.getNoAssignedSnapshot(); + const users = new Map(); + + for (const route of assigned) { + const key = String(route.json.usuario_id); + const current = users.get(key) ?? { + nombre: route.json.nombre_usuario, + rutas: 0, + distancia: 0, + trabajo: 0, + capacidad: Number(route.json.hora_laboral_semanal_usuario) || 0 + }; + current.rutas++; + current.distancia += Number(route.json.distancia) || 0; + current.trabajo += Number.parseFloat(String(route.json.horas_trabajo ?? '')) || 0; + users.set(key, current); + } + + 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 workbook = XLSX.utils.book_new(); + + const summary = [ + { Indicador: 'PDVs totales', Valor: total }, + { Indicador: 'PDVs asignados', Valor: assigned.length }, + { Indicador: 'PDVs sin asignar', Valor: unassigned.length }, + { Indicador: 'Cobertura (%)', Valor: total ? Number(((assigned.length / total) * 100).toFixed(1)) : 0 }, + { Indicador: 'Usuarios activos', Valor: users.size }, + { Indicador: 'Días planificados', Valor: new Set(assigned.map(route => route.json.dia)).size }, + { Indicador: 'Distancia total (km)', Valor: Number(totalDistance.toFixed(2)) }, + { 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 unassignedRows = unassigned.map(route => ({ + 'PDV ID': route.pdv_id, + PDV: route.pdv_nombre, + 'Mínimo horas/semana': route.minimo_horas_semana, + Latitud: route.latitud, + 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 + })); + + 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'); + + const output = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' }); + saveAs(new Blob([output], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), 'planificacion_rutas.xlsx'); + } + + private appendSheet(workbook: XLSX.WorkBook, rows: object[], name: string): void { + const sheet = XLSX.utils.json_to_sheet(rows.length ? rows : [{ Información: 'Sin registros' }]); + XLSX.utils.book_append_sheet(workbook, sheet, name); + } +} diff --git a/route-planner/src/app/shared/utils/header-normalization.utils.ts b/route-planner/src/app/shared/utils/header-normalization.utils.ts new file mode 100644 index 0000000..ac17681 --- /dev/null +++ b/route-planner/src/app/shared/utils/header-normalization.utils.ts @@ -0,0 +1,29 @@ +export function normalizeHeader(value: string): string { + return value + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9 ]/gi, '') + .trim() + .replace(/\s+/g, '_'); +} + +export function cleanNum(value: unknown): number { + if (value === null || value === undefined || value === '') return NaN; + if (typeof value === 'number') return value; + const str = String(value).trim().replace(/[^0-9.\-]/g, ''); + if (str === '' || str === '-' || str === '.') return NaN; + const num = Number(str); + return Number.isFinite(num) ? num : NaN; +} + +export function normalizeRow(row: Record): Record { + const out: Record = {}; + for (const key in row) { + const newKey = normalizeHeader(key); + let val = row[key]; + if (typeof val === 'string') val = val.trim(); + out[newKey] = val; + } + return out; +} diff --git a/route-planner/src/app/shared/utils/index.ts b/route-planner/src/app/shared/utils/index.ts index 8a726eb..08ccf84 100644 --- a/route-planner/src/app/shared/utils/index.ts +++ b/route-planner/src/app/shared/utils/index.ts @@ -1 +1,4 @@ -export * from './remove-special-character.utils'; \ No newline at end of file +export * from './remove-special-character.utils'; +export * from './header-normalization.utils'; +export * from './palette.utils'; +export * from './route-sequencing.utils'; diff --git a/route-planner/src/app/shared/utils/palette.utils.ts b/route-planner/src/app/shared/utils/palette.utils.ts new file mode 100644 index 0000000..fe27390 --- /dev/null +++ b/route-planner/src/app/shared/utils/palette.utils.ts @@ -0,0 +1,24 @@ +export function colorForUsuario(usuarioId: string | number): { color: string; glow: string } { + const str = String(usuarioId); + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0; + } + hash = Math.abs(hash); + // Use a mix of GLM palette + generated HSL to avoid collisions but keep brand feel + const glmPalette = ['#6CC24A', '#4F758B', '#FF6A13', '#C4D600', '#5B7F95', '#A4D65E', '#6B8FA3']; + const idx = hash % glmPalette.length; + // If palette length covers typical usuarios (50 max) collisions low; fallback to HSL for overflow + if (hash % 2 === 0) { + return { color: glmPalette[idx], glow: glmPalette[idx] }; + } + const h = hash % 360; + const s = 65 + (hash % 20); + const l = 48 + (hash % 10); + const hsl = `hsl(${h} ${s}% ${l}%)`; + return { color: hsl, glow: hsl }; +} + +export function markerStyleForUsuario(usuarioId: string | number): string { + return colorForUsuario(usuarioId).color; +} diff --git a/route-planner/src/app/shared/utils/route-sequencing.utils.ts b/route-planner/src/app/shared/utils/route-sequencing.utils.ts new file mode 100644 index 0000000..8878f92 --- /dev/null +++ b/route-planner/src/app/shared/utils/route-sequencing.utils.ts @@ -0,0 +1,67 @@ +import { RouteAssignedResponse } from '../../core/models/route.model'; + +export interface SequencedRoute { + route: RouteAssignedResponse; + origin: [number, number]; + destination: [number, number]; + order: number; + totalStops: number; + previousPdvName: string | null; +} + +export function sequenceRoutes(routes: RouteAssignedResponse[]): SequencedRoute[] { + const groups = new Map(); + for (const route of routes) { + const key = `${route.json.usuario_id}\u0000${route.json.dia}`; + const group = groups.get(key) ?? []; + group.push(route); + groups.set(key, group); + } + + const sequenced: SequencedRoute[] = []; + for (const group of groups.values()) { + 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]; + + 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; + } + } + + const [route] = remaining.splice(nearestIndex, 1); + const destination: [number, number] = [Number(route.json.longitud_pdv), Number(route.json.latitud_pdv)]; + sequenced.push({ + route, + origin: current, + destination, + order: group.length - remaining.length, + totalStops: group.length, + previousPdvName + }); + current = destination; + previousPdvName = route.json.nombre_pdv; + } + } + + return sequenced; +} + +export function haversineKm(origin: [number, number], destination: [number, number]): number { + const toRadians = (value: number) => value * Math.PI / 180; + const [lon1, lat1] = origin; + const [lon2, lat2] = destination; + const dLat = toRadians(lat2 - lat1); + const dLon = toRadians(lon2 - lon1); + const a = Math.sin(dLat / 2) ** 2 + + Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) * Math.sin(dLon / 2) ** 2; + return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} diff --git a/route-planner/src/environments/environment.prod.ts b/route-planner/src/environments/environment.prod.ts new file mode 100644 index 0000000..7647f41 --- /dev/null +++ b/route-planner/src/environments/environment.prod.ts @@ -0,0 +1,6 @@ +export const environment = { + production: true, + n8nWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/calcular-camino', + orsBaseUrl: 'http://localhost:18080/ors/v2', + requestTimeoutMs: 30000, +}; diff --git a/route-planner/src/environments/environment.ts b/route-planner/src/environments/environment.ts new file mode 100644 index 0000000..f8a8fc4 --- /dev/null +++ b/route-planner/src/environments/environment.ts @@ -0,0 +1,6 @@ +export const environment = { + production: false, + n8nWebhookUrl: 'http://localhost:5678/webhook/calcular-camino', + orsBaseUrl: 'http://localhost:18080/ors/v2', + requestTimeoutMs: 30000, +}; diff --git a/route-planner/src/index.html b/route-planner/src/index.html index 3f55af5..9baccbf 100644 --- a/route-planner/src/index.html +++ b/route-planner/src/index.html @@ -6,7 +6,6 @@ - diff --git a/route-planner/src/styles.css b/route-planner/src/styles.css index 0769590..2ce578c 100644 --- a/route-planner/src/styles.css +++ b/route-planner/src/styles.css @@ -1,5 +1,45 @@ @import "primeicons/primeicons.css"; +/* Leaflet creates marker HTML outside Angular's style encapsulation. */ +.location-pin { + position: relative; + width: 30px; + height: 38px; + background: #c9252d; + clip-path: polygon(50% 100%, 8% 43%, 5% 31%, 10% 18%, 22% 7%, 36% 2%, 50% 0, 64% 2%, 78% 7%, 90% 18%, 95% 31%, 92% 43%); + filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.42)); +} + +.location-pin::before { + content: ''; + position: absolute; + inset: 3px 3px 7px; + background: #ff424a; + clip-path: polygon(50% 100%, 8% 43%, 5% 31%, 10% 18%, 22% 7%, 36% 2%, 50% 0, 64% 2%, 78% 7%, 90% 18%, 95% 31%, 92% 43%); +} + +.location-pin::after { + content: ''; + position: absolute; + top: 8px; + left: 8px; + width: 14px; + height: 14px; + border-radius: 50%; + background: #620000; +} + +.location-pin span { + position: absolute; + z-index: 1; + top: 8px; + left: 8px; + width: 14px; + color: #fff; + font: 700 9px/14px Arial, sans-serif; + text-align: center; +} + :root { --glm-verde: #6CC24A; --glm-verde-claro: #A4D65E; -- 2.52.0 From 9f42175afce29090eda926f5bed703005ec67940 Mon Sep 17 00:00:00 2001 From: EidanThen Date: Mon, 31 Aug 2026 17:56:33 -0400 Subject: [PATCH 3/3] 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, }; -- 2.52.0