diff --git a/Proyecto/Prueba-mexico/mx maestro pdv activos.xlsx b/Proyecto/Prueba-mexico/mx maestro pdv activos.xlsx new file mode 100644 index 0000000..090e085 Binary files /dev/null and b/Proyecto/Prueba-mexico/mx maestro pdv activos.xlsx differ diff --git a/Proyecto/Prueba-mexico/mx ruta 202609.xlsx b/Proyecto/Prueba-mexico/mx ruta 202609.xlsx new file mode 100644 index 0000000..9b37944 Binary files /dev/null and b/Proyecto/Prueba-mexico/mx ruta 202609.xlsx differ diff --git a/Proyecto/Prueba-mexico/mx_pdv_test.xlsx b/Proyecto/Prueba-mexico/mx_pdv_test.xlsx new file mode 100644 index 0000000..0937c15 Binary files /dev/null and b/Proyecto/Prueba-mexico/mx_pdv_test.xlsx differ diff --git a/Proyecto/Prueba-mexico/mx_usuarios_test.xlsx b/Proyecto/Prueba-mexico/mx_usuarios_test.xlsx new file mode 100644 index 0000000..f3f35ab Binary files /dev/null and b/Proyecto/Prueba-mexico/mx_usuarios_test.xlsx differ diff --git a/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json b/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json index 2f6054a..f7cf25b 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 v4", + "name": "OptimizadorRutasPDV - ORS Real", "nodes": [ { "parameters": { @@ -126,7 +126,7 @@ }, { "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 } }];" + "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 > 300) {\n success = false;\n message = 'Usuarios: Máximo 300 filas permitidas';\n}\nreturn [{ json: { success, data: $input.all(), message } }];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -165,16 +165,17 @@ }, { "parameters": { - "jsCode": "return await (async () => {\nconst items = $input.all();\n\nconst pdvResult = items[0]?.json;\nconst userResult = items[1]?.json;\n\nif (!pdvResult?.success) {\n return [{ json: { error: pdvResult?.message || 'Error validación PDVs' } }];\n}\nif (!userResult?.success) {\n return [{ json: { error: userResult?.message || 'Error validación Usuarios' } }];\n}\n\nconst pdvsRaw = pdvResult.data.map(item => item.json);\nconst usuariosRaw = userResult.data.map(item => item.json);\n\nif (!pdvsRaw.length || !usuariosRaw.length) {\n return [{ json: { error: 'Archivos vacíos o sin datos válidos' } }];\n}\n\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})();" + "jsCode": "const validationItems = $('Code in JavaScript').all();\nconst pdvResult = validationItems[0]?.json;\nconst userResult = validationItems[1]?.json;\nconst matrixResponses = $input.all();\nconst matrixRequests = $('Prepare ORS Matrix Requests').all();\n\nfunction header(value) {\n return String(value).toLowerCase().normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').replace(/[^a-z0-9 ]/gi, '').trim().replace(/\\s+/g, '_');\n}\nfunction normalizeRows(data) {\n return data.map(item => Object.fromEntries(Object.entries(item.json).map(([key, value]) => [header(key), typeof value === 'string' ? value.trim() : value])));\n}\nfunction number(value) {\n if (typeof value === 'number') return Number.isFinite(value) ? value : 0;\n const text = String(value ?? '').trim().replace(',', '.').replace(/[^0-9.\\-]/g, '');\n const parsed = Number(text);\n return Number.isFinite(parsed) ? parsed : 0;\n}\nfunction active(value) {\n return !['n', 'no', '0', 'false'].includes(String(value ?? 'si').trim().toLowerCase());\n}\nfunction restDays(value) {\n if (Array.isArray(value)) return value.map(String);\n try { const parsed = JSON.parse(String(value ?? '[]')); if (Array.isArray(parsed)) return parsed.map(String); } catch {}\n return String(value ?? '').split(',').map(day => day.trim()).filter(Boolean);\n}\n\nconst scheduleDays = {\n LUN_DOM_44: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes', 'Sabado', 'Domingo'],\n LUN_SAB_40: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes', 'Sabado'],\n LUN_SAB_44: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes', 'Sabado'],\n LUN_VIE_40: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes']\n};\nconst segmentWeight = { AA: 100, A: 80, B: 60, C: 40, D: 20 };\n\nconst pdvs = normalizeRows(pdvResult.data).map((pdv, index) => ({\n id: String(pdv.id ?? index + 1),\n nombre: String(pdv.nombre_del_pdv ?? ''),\n segmentacion: String(pdv.segmentacion ?? 'D').toUpperCase(),\n latitud: number(pdv.latitud), longitud: number(pdv.longitud),\n visitas: Math.max(1, Math.min(7, Math.round(number(pdv.visitas_semanales) || 1))),\n duracion: number(pdv.duracion_visitahoras ?? pdv.duracion_visita_horas ?? pdv.duracion_visita),\n prioridad: Math.max(1, Math.min(5, Math.round(number(pdv.prioridad) || 5))),\n activo: active(pdv.activo),\n pais: String(pdv.pais ?? pdv.country ?? ''), ciudad: String(pdv.ciudad ?? ''),\n estado: String(pdv.estado ?? ''), provincia: String(pdv.provincia ?? ''),\n region: String(pdv.region ?? pdv.regional ?? ''), zona: String(pdv.zona ?? ''),\n metro: String(pdv.metro_de_prueba ?? '')\n})).filter(pdv => pdv.activo && pdv.duracion > 0);\n\nconst users = normalizeRows(userResult.data).map((user, index) => {\n const jornada = String(user.jornada ?? 'LUN_VIE_40');\n const descansos = new Set(restDays(user.dias_descanso).map(day => header(day)));\n const days = (scheduleDays[jornada] ?? scheduleDays.LUN_VIE_40).filter(day => !descansos.has(header(day)));\n return {\n id: String(user.id ?? index + 1), nombre: String(user.nombre_del_empleado ?? ''),\n latitud: number(user.latitud), longitud: number(user.longitud),\n horasDia: number(user.horas_por_dia_usuario), horasSemana: number(user.horas_por_semana_usuario),\n descanso: Math.max(0, Math.round(number(user.horas_descanso_por_dia))),\n maxVisitas: Math.max(1, Math.round(number(user.visitas_maximas_por_dia) || 3)),\n jornada, days, activo: active(user.usuario_activo ?? user.usuarioactivo),\n pais: String(user.pais ?? user.country ?? ''), ciudad: String(user.ciudad ?? ''),\n estado: String(user.estado ?? ''), provincia: String(user.provincia ?? ''),\n region: String(user.region ?? user.regional ?? ''), zona: String(user.zona ?? '')\n };\n}).filter(user => user.activo && user.days.length && user.horasDia > user.descanso && user.horasSemana > 0);\n\nconst country = matrixRequests[0]?.json?.country ?? 'REPUBLICA DOMINICANA';\nfunction territory(pdv) {\n return { pais: pdv.pais || country, ciudad: pdv.ciudad, estado: pdv.estado, provincia: pdv.provincia, region: pdv.region, zona: pdv.zona, metro: pdv.metro };\n}\n\nconst road = new Map();\nfor (let i = 0; i < matrixResponses.length; i++) {\n const request = matrixRequests[i]?.json;\n const response = matrixResponses[i]?.json;\n const userIds = request?.userIds ?? (request?.userId ? [request.userId] : []);\n for (let u = 0; u < userIds.length; u++) {\n const distances = response?.distances?.[u] ?? [];\n const durations = response?.durations?.[u] ?? [];\n for (let p = 0; p < (request?.pdvIds?.length ?? 0); p++) {\n const distanceKm = Number(distances[p]);\n const durationSeconds = Number(durations[p]);\n if (Number.isFinite(distanceKm) && Number.isFinite(durationSeconds)) road.set(userIds[u] + '|' + request.pdvIds[p], { distanceKm, durationSeconds });\n }\n }\n}\n\nconst remaining = new Map(users.map(user => [user.id, user.horasSemana]));\nconst pending = new Set(pdvs.map(pdv => pdv.id));\nconst weeklyAssignments = [];\nconst noAssigned = [];\n\nconst candidatesByUser = new Map(users.map(user => {\n const candidates = pdvs.map(pdv => {\n const metric = road.get(user.id + '|' + pdv.id);\n if (!metric) return null;\n const workHours = pdv.duracion * pdv.visitas;\n const estimatedTravelHours = metric.durationSeconds / 3600 * pdv.visitas;\n return {\n pdv,\n metric,\n totalHours: workHours + estimatedTravelHours,\n score: (segmentWeight[pdv.segmentacion] ?? 0) + (6 - pdv.prioridad) * 6\n };\n }).filter(Boolean).sort((a, b) => a.metric.durationSeconds - b.metric.durationSeconds || a.metric.distanceKm - b.metric.distanceKm || b.score - a.score);\n return [user.id, candidates];\n}));\nconst candidateIndex = new Map(users.map(user => [user.id, 0]));\n\nwhile (pending.size) {\n const options = [];\n for (const user of users) {\n const candidates = candidatesByUser.get(user.id) ?? [];\n let index = candidateIndex.get(user.id) ?? 0;\n while (index < candidates.length) {\n const candidate = candidates[index];\n if (!pending.has(candidate.pdv.id) || candidate.totalHours > (remaining.get(user.id) ?? 0) + 0.001) {\n index++;\n continue;\n }\n options.push({ user, candidate });\n break;\n }\n candidateIndex.set(user.id, index);\n }\n options.sort((a, b) => b.candidate.metric.durationSeconds - a.candidate.metric.durationSeconds);\n if (!options.length) break;\n let assignedThisRound = false;\n for (const option of options) {\n const { user, candidate } = option;\n if (!pending.has(candidate.pdv.id) || candidate.totalHours > (remaining.get(user.id) ?? 0) + 0.001) continue;\n weeklyAssignments.push({ user, pdv: candidate.pdv, metric: candidate.metric });\n remaining.set(user.id, (remaining.get(user.id) ?? 0) - candidate.totalHours);\n pending.delete(candidate.pdv.id);\n candidateIndex.set(user.id, (candidateIndex.get(user.id) ?? 0) + 1);\n assignedThisRound = true;\n }\n if (!assignedThisRound) break;\n}\n\nfor (const pdv of pdvs.filter(item => pending.has(item.id))) noAssigned.push({ pdv_id: pdv.id, pdv_nombre: pdv.nombre, minimo_horas_semana: pdv.duracion * pdv.visitas, latitud: pdv.latitud, longitud: pdv.longitud, ...territory(pdv), motivo: 'No hay empleado con capacidad semanal considerando el recorrido real' });\n\nconst daily = new Map();\nconst assigned = [];\nfunction dayState(userId, day) {\n const key = userId + '|' + day;\n if (!daily.has(key)) daily.set(key, { hours: 0, visits: 0 });\n return daily.get(key);\n}\nfunction preferredDays(days, count) {\n if (count >= days.length) return days.slice(0, count);\n return Array.from({ length: count }, (_, index) => days[Math.floor(index * days.length / count)]);\n}\n\nfor (const assignment of weeklyAssignments) {\n const { user, pdv, metric } = assignment;\n if (pdv.visitas > user.days.length) {\n noAssigned.push({ pdv_id: pdv.id, pdv_nombre: pdv.nombre, minimo_horas_semana: pdv.duracion * pdv.visitas, latitud: pdv.latitud, longitud: pdv.longitud, ...territory(pdv), motivo: 'Las visitas semanales exceden los días laborables disponibles' });\n continue;\n }\n const selected = [];\n const preferred = preferredDays(user.days, pdv.visitas);\n for (let visit = 0; visit < pdv.visitas; visit++) {\n const orderedDays = [preferred[visit], ...user.days.filter(day => day !== preferred[visit])];\n const day = orderedDays.find(candidate => {\n if (selected.includes(candidate)) return false;\n const state = dayState(user.id, candidate);\n return state.visits < user.maxVisitas && state.hours + pdv.duracion + metric.durationSeconds / 3600 <= user.horasDia - user.descanso + 0.001;\n });\n if (!day) { selected.length = 0; break; }\n selected.push(day);\n }\n if (!selected.length) {\n noAssigned.push({ pdv_id: pdv.id, pdv_nombre: pdv.nombre, minimo_horas_semana: pdv.duracion * pdv.visitas, latitud: pdv.latitud, longitud: pdv.longitud, ...territory(pdv), motivo: 'Excede capacidad diaria o visitas máximas considerando el recorrido real' });\n continue;\n }\n for (const day of selected) {\n const state = dayState(user.id, day);\n state.hours += pdv.duracion + metric.durationSeconds / 3600;\n state.visits++;\n assigned.push({ json: {\n usuario_id: user.id, nombre_usuario: user.nombre, latitud_usuario: user.latitud, longitud_usuario: user.longitud,\n pais_usuario: user.pais || country, ciudad_usuario: user.ciudad, estado_usuario: user.estado, provincia_usuario: user.provincia, region_usuario: user.region, zona_usuario: user.zona,\n hora_laboral_semanal_usuario: user.horasSemana, horas_por_dia_usuario: user.horasDia, horas_descanso_por_dia: user.descanso,\n jornada_codigo: user.jornada, pdv_id: pdv.id, nombre_pdv: pdv.nombre, latitud_pdv: pdv.latitud, longitud_pdv: pdv.longitud,\n pais: pdv.pais || country, ciudad: pdv.ciudad, estado: pdv.estado, provincia: pdv.provincia, region: pdv.region, zona: pdv.zona, metro: pdv.metro,\n hora_minima_semanal_pdv: pdv.duracion * pdv.visitas, dia: day, horas_trabajo: Number(pdv.duracion.toFixed(2)),\n horas_desplazamiento: Number((metric.durationSeconds / 3600).toFixed(4)), distancia: Number(metric.distanceKm.toFixed(2))\n } });\n }\n}\n\nconst groups = new Map();\nfor (const route of assigned) {\n const key = route.json.usuario_id + '|' + route.json.dia;\n if (!groups.has(key)) groups.set(key, []);\n groups.get(key).push(route);\n}\nfor (const routes of groups.values()) routes.sort((a, b) => a.json.horas_desplazamiento - b.json.horas_desplazamiento).forEach((route, index) => route.json.visit_order = index + 1);\n\nreturn [{ json: { assigned } }, { json: { noAssigned } }];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 816, - 544 + 1620, + 280 ], "id": "e9fe9826-4755-497e-ae6e-274e7c1d4f66", - "name": "Optimize Routes" + "name": "Optimize Routes", + "notes": "Usa matrices agrupadas por zona y listas de candidatos calculadas una sola vez para evitar el timeout." }, { "parameters": { @@ -184,8 +185,8 @@ "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.5, "position": [ - 1024, - 544 + 2720, + 280 ], "id": "b024c203-1b36-4f7c-97bb-3923481b7a9b", "name": "Respond Success" @@ -203,69 +204,208 @@ "id": "27eb73de-4b08-4c44-97de-4d122a98749c", "name": "Code in JavaScript", "onError": "continueErrorOutput" + }, + { + "parameters": { + "jsCode": "const items = $input.all();\nconst pdvResult = items[0]?.json;\nconst userResult = items[1]?.json;\nif (!pdvResult?.success || !userResult?.success) throw new Error('Los archivos no superaron la validación');\n\nfunction header(value) {\n return String(value).toLowerCase().normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').replace(/[^a-z0-9 ]/gi, '').trim().replace(/\\s+/g, '_');\n}\nfunction rows(data) {\n return data.map(item => Object.fromEntries(Object.entries(item.json).map(([key, value]) => [header(key), typeof value === 'string' ? value.trim() : value])));\n}\nfunction number(value) {\n if (typeof value === 'number') return Number.isFinite(value) ? value : 0;\n const parsed = Number(String(value ?? '').trim().replace(',', '.').replace(/[^0-9.\\-]/g, ''));\n return Number.isFinite(parsed) ? parsed : 0;\n}\nfunction active(value) {\n return !['n', 'no', '0', 'false'].includes(String(value ?? 'si').trim().toLowerCase());\n}\nfunction token(value) {\n return String(value ?? '').normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').replace(/[^a-z0-9]/gi, '').toUpperCase();\n}\nfunction distance(a, b) {\n const toRad = value => value * Math.PI / 180;\n const dLat = toRad(b.latitud - a.latitud);\n const dLon = toRad(b.longitud - a.longitud);\n const x = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a.latitud)) * Math.cos(toRad(b.latitud)) * Math.sin(dLon / 2) ** 2;\n return 6371 * 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x));\n}\nfunction chunks(values, size) {\n const result = [];\n for (let index = 0; index < values.length; index += size) result.push(values.slice(index, index + size));\n return result;\n}\n\nconst normalizedPdvs = rows(pdvResult.data);\nconst normalizedUsers = rows(userResult.data);\nconst declaredCountry = normalizedPdvs.map(row => token(row.pais ?? row.country)).find(Boolean) ?? '';\nconst mexicoByCoordinates = normalizedPdvs.some(row => number(row.latitud) >= 14 && number(row.latitud) <= 33 && number(row.longitud) >= -119 && number(row.longitud) <= -86);\nconst country = declaredCountry.includes('MEXICO') || (!declaredCountry && mexicoByCoordinates) ? 'MEXICO' : 'REPUBLICA DOMINICANA';\nconst countrySource = declaredCountry ? 'PDV País: ' + declaredCountry : mexicoByCoordinates ? 'Coordenadas PDV de México' : 'Predeterminado: República Dominicana';\nconst orsBaseUrl = country === 'MEXICO' ? 'http://host.docker.internal:18081/ors/v2' : 'http://host.docker.internal:18080/ors/v2';\n\nconst pdvs = normalizedPdvs.map((pdv, index) => ({\n id: String(pdv.id ?? index + 1),\n latitud: number(pdv.latitud),\n longitud: number(pdv.longitud),\n activo: active(pdv.activo),\n areaTokens: [pdv.metro_de_prueba, pdv.ciudad, pdv.regional, pdv.region, pdv.zona].map(token).filter(Boolean)\n})).filter(pdv => pdv.activo && Number.isFinite(pdv.latitud) && Number.isFinite(pdv.longitud));\n\nconst users = normalizedUsers.map((user, index) => ({\n id: String(user.id ?? index + 1),\n latitud: number(user.latitud),\n longitud: number(user.longitud),\n activo: active(user.usuario_activo ?? user.usuarioactivo),\n areaTokens: [user.ciudad, user.metro_de_prueba, user.regional, user.region, user.zona].map(token).filter(Boolean)\n})).filter(user => user.activo && Number.isFinite(user.latitud) && Number.isFinite(user.longitud));\n\nif (!pdvs.length) throw new Error('No hay PDVs activos con coordenadas válidas');\nif (!users.length) throw new Error('No hay empleados activos con coordenadas válidas');\n\nconst pdvsByToken = new Map();\nfor (const pdv of pdvs) {\n for (const areaToken of pdv.areaTokens) {\n if (!pdvsByToken.has(areaToken)) pdvsByToken.set(areaToken, new Map());\n pdvsByToken.get(areaToken).set(pdv.id, pdv);\n }\n}\n\nconst groups = new Map();\nconst coveredPdvIds = new Set();\nfor (const user of users) {\n const areaKey = user.areaTokens.find(areaToken => pdvsByToken.has(areaToken));\n if (areaKey) {\n if (!groups.has(areaKey)) groups.set(areaKey, { areaKey, users: [], pdvs: [...pdvsByToken.get(areaKey).values()] });\n groups.get(areaKey).users.push(user);\n groups.get(areaKey).pdvs.forEach(pdv => coveredPdvIds.add(pdv.id));\n continue;\n }\n const nearest = [...pdvs].sort((a, b) => distance(user, a) - distance(user, b)).slice(0, 500);\n groups.set('NEAREST_' + user.id, { areaKey: 'NEAREST_' + user.id, users: [user], pdvs: nearest });\n nearest.forEach(pdv => coveredPdvIds.add(pdv.id));\n}\n\nconst uncoveredPdvs = pdvs.filter(pdv => !coveredPdvIds.has(pdv.id));\nfor (const [index, pdvChunk] of chunks(uncoveredPdvs, 500).entries()) {\n const center = {\n latitud: pdvChunk.reduce((sum, pdv) => sum + pdv.latitud, 0) / pdvChunk.length,\n longitud: pdvChunk.reduce((sum, pdv) => sum + pdv.longitud, 0) / pdvChunk.length\n };\n const nearestUsers = [...users].sort((a, b) => distance(center, a) - distance(center, b)).slice(0, 10);\n groups.set('UNMATCHED_' + index, { areaKey: 'UNMATCHED_' + index, users: nearestUsers, pdvs: pdvChunk });\n}\n\nconst MAX_PDVS_PER_REQUEST = 500;\nconst MAX_ROUTES_PER_REQUEST = 3000;\nconst requests = [];\nfor (const group of groups.values()) {\n for (const pdvChunk of chunks(group.pdvs, MAX_PDVS_PER_REQUEST)) {\n const usersPerRequest = Math.max(1, Math.floor(MAX_ROUTES_PER_REQUEST / pdvChunk.length));\n for (const userChunk of chunks(group.users, usersPerRequest)) {\n const userIds = userChunk.map(user => user.id);\n requests.push({ json: {\n requestIndex: requests.length,\n country,\n countrySource,\n orsBaseUrl,\n areaKey: group.areaKey,\n userIds,\n pdvIds: pdvChunk.map(pdv => pdv.id),\n routePairs: userChunk.length * pdvChunk.length,\n locations: [...userChunk.map(user => [user.longitud, user.latitud]), ...pdvChunk.map(pdv => [pdv.longitud, pdv.latitud])],\n sources: userChunk.map((_, index) => String(index)),\n destinations: pdvChunk.map((_, index) => String(userChunk.length + index))\n } });\n }\n }\n}\nreturn requests;" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1180, + 280 + ], + "id": "2561ca3d-7e4a-46a8-b90f-e3382978fc0d", + "name": "Prepare ORS Matrix Requests" + }, + { + "parameters": { + "method": "POST", + "url": "={{ $('Prepare ORS Matrix Requests').item.json.orsBaseUrl + '/matrix/driving-car' }}", + "authentication": "none", + "sendHeaders": true, + "specifyHeaders": "keypair", + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "contentType": "json", + "specifyBody": "json", + "jsonBody": "={{ { locations: $('Prepare ORS Matrix Requests').item.json.locations, sources: $('Prepare ORS Matrix Requests').item.json.sources, destinations: $('Prepare ORS Matrix Requests').item.json.destinations, metrics: ['distance', 'duration'], units: 'km', resolve_locations: false } }}", + "options": { + "batching": { + "batch": { + "batchSize": 5, + "batchInterval": 50 + } + }, + "response": { + "response": { + "fullResponse": false, + "neverError": false, + "responseFormat": "json" + } + }, + "timeout": 180000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1400, + 280 + ], + "id": "7a57c771-79e6-4ec9-a024-698cc0cf6054", + "name": "ORS Matrix", + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 3000, + "onError": "continueErrorOutput", + "notes": "Agrupa empleados y PDVs por zona; cada solicitud mantiene como máximo 3,000 pares." + }, + { + "parameters": { + "jsCode": "const optimized = $input.all();\nconst orsBaseUrl = $('Prepare ORS Matrix Requests').first().json.orsBaseUrl;\nconst assigned = optimized.find(item => Array.isArray(item.json.assigned))?.json.assigned ?? [];\nif (!assigned.length) return [{ json: { skipDirections: true, orsBaseUrl } }];\nconst byGroup = new Map();\nfor (const route of assigned) {\n const data = route.json ?? route;\n const key = String(data.usuario_id) + '|' + data.dia;\n if (!byGroup.has(key)) byGroup.set(key, []);\n byGroup.get(key).push(data);\n}\nconst requests = [];\nfor (const routes of byGroup.values()) {\n routes.sort((a, b) => Number(a.visit_order) - Number(b.visit_order));\n let origin = [Number(routes[0].longitud_usuario), Number(routes[0].latitud_usuario)];\n for (const assignment of routes) {\n const destination = [Number(assignment.longitud_pdv), Number(assignment.latitud_pdv)];\n requests.push({ json: { skipDirections: false, orsBaseUrl, assignment, coordinates: [origin, destination] } });\n origin = destination;\n }\n}\nreturn requests;" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1840, + 280 + ], + "id": "769cdb8e-b465-45be-9192-8b23abec519b", + "name": "Prepare ORS Direction Requests" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 2 + }, + "conditions": [ + { + "id": "d44518de-dfea-4636-ae5c-a1f4723e01ba", + "leftValue": "={{ $json.skipDirections }}", + "rightValue": false, + "operator": { + "type": "boolean", + "operation": "equals", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 2050, + 280 + ], + "id": "3c648989-8d24-420a-b812-ced659a9063f", + "name": "Has Direction Requests?" + }, + { + "parameters": { + "method": "POST", + "url": "={{ $('Prepare ORS Direction Requests').item.json.orsBaseUrl + '/directions/driving-car/geojson' }}", + "authentication": "none", + "sendHeaders": true, + "specifyHeaders": "keypair", + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/geo+json, application/json" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "contentType": "json", + "specifyBody": "json", + "jsonBody": "={{ { coordinates: $('Prepare ORS Direction Requests').item.json.coordinates, instructions: false, geometry: true, preference: 'fastest' } }}", + "options": { + "batching": { + "batch": { + "batchSize": 10, + "batchInterval": 50 + } + }, + "response": { + "response": { + "fullResponse": false, + "neverError": false, + "responseFormat": "json" + } + }, + "timeout": 30000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 2270, + 220 + ], + "id": "c6679ef2-2140-4240-93e0-95ed5f008160", + "name": "ORS Directions GeoJSON", + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 3000, + "onError": "continueRegularOutput", + "notes": "Conserva una salida por solicitud. Los fallos individuales se completan con los datos Matrix en Finalize Real Routes." + }, + { + "parameters": { + "jsCode": "const optimized = $('Optimize Routes').all();\nconst baseAssigned = optimized.find(item => Array.isArray(item.json.assigned))?.json.assigned ?? [];\nconst baseNoAssigned = optimized.find(item => Array.isArray(item.json.noAssigned))?.json.noAssigned ?? [];\nconst prepared = $('Prepare ORS Direction Requests').all();\nif (prepared[0]?.json?.skipDirections) return [{ json: { assigned: [] } }, { json: { noAssigned: baseNoAssigned } }];\n\nconst responses = $input.all();\nlet routes = prepared.map((item, index) => {\n const assignment = item.json.assignment;\n const response = responses[index]?.json;\n const summary = response?.features?.[0]?.properties?.summary;\n if (summary && Number.isFinite(Number(summary.distance)) && Number.isFinite(Number(summary.duration))) {\n return { json: {\n ...assignment,\n distancia: Number((Number(summary.distance) / 1000).toFixed(2)),\n horas_desplazamiento: Number((Number(summary.duration) / 3600).toFixed(4)),\n geojson: response\n } };\n }\n // Matrix already supplied viable road distance and duration. Keep this visit when its detailed geometry fails.\n return { json: { ...assignment, ors_error: response?.error?.message ?? response?.message ?? 'ORS no devolvió GeoJSON para este tramo' } };\n});\n\nconst noAssigned = [...baseNoAssigned];\nlet changed = true;\nwhile (changed) {\n changed = false;\n const groups = new Map();\n for (const route of routes) {\n const key = route.json.usuario_id + '|' + route.json.dia;\n if (!groups.has(key)) groups.set(key, []);\n groups.get(key).push(route);\n }\n for (const group of groups.values()) {\n group.sort((a, b) => Number(a.json.visit_order) - Number(b.json.visit_order));\n const sample = group[0].json;\n const capacity = Number(sample.horas_por_dia_usuario) - Number(sample.horas_descanso_por_dia || 0);\n const total = group.reduce((sum, route) => sum + Number(route.json.horas_trabajo) + Number(route.json.horas_desplazamiento), 0);\n if (total <= capacity + 0.001) continue;\n const remove = group[group.length - 1].json;\n noAssigned.push({\n pdv_id: remove.pdv_id, pdv_nombre: remove.nombre_pdv, minimo_horas_semana: remove.hora_minima_semanal_pdv,\n latitud: remove.latitud_pdv, longitud: remove.longitud_pdv, pais: remove.pais, ciudad: remove.ciudad,\n estado: remove.estado, provincia: remove.provincia, region: remove.region, zona: remove.zona, metro: remove.metro,\n motivo: 'Excede la jornada diaria después de calcular el recorrido real'\n });\n routes = routes.filter(route => route !== group[group.length - 1]);\n changed = true;\n break;\n }\n}\n\nconst uniqueNoAssigned = Array.from(new Map(noAssigned.map(item => [String(item.pdv_id), item])).values());\nreturn [{ json: { assigned: routes } }, { json: { noAssigned: uniqueNoAssigned } }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2490, + 280 + ], + "id": "a4f615ab-3ffb-4d5c-b357-fe938ad09c94", + "name": "Finalize Real Routes", + "onError": "continueErrorOutput", + "notes": "Una falla de Directions conserva la visita con métricas Matrix y una línea fallback; sólo errores internos llegan al 502." + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ { error: \"upstream_error\", message: \"No se pudo calcular la distancia real con ORS\", detail: $json.error?.message || $json.message || \"ORS no disponible\" } }}", + "options": { + "responseCode": 502 + } + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 2270, + 500 + ], + "id": "52eeef8c-956e-487e-a4b1-54b3eb6cb39c", + "name": "Error: ORS Unavailable" } ], - "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": [ @@ -360,7 +500,7 @@ "main": [ [ { - "node": "Respond Success", + "node": "Prepare ORS Direction Requests", "type": "main", "index": 0 } @@ -371,7 +511,7 @@ "main": [ [ { - "node": "Optimize Routes", + "node": "Prepare ORS Matrix Requests", "type": "main", "index": 0 } @@ -384,9 +524,103 @@ } ] ] + }, + "Prepare ORS Matrix Requests": { + "main": [ + [ + { + "node": "ORS Matrix", + "type": "main", + "index": 0 + } + ] + ] + }, + "ORS Matrix": { + "main": [ + [ + { + "node": "Optimize Routes", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Error: ORS Unavailable", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prepare ORS Direction Requests": { + "main": [ + [ + { + "node": "Has Direction Requests?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Has Direction Requests?": { + "main": [ + [ + { + "node": "ORS Directions GeoJSON", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Finalize Real Routes", + "type": "main", + "index": 0 + } + ] + ] + }, + "ORS Directions GeoJSON": { + "main": [ + [ + { + "node": "Finalize Real Routes", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Error: ORS Unavailable", + "type": "main", + "index": 0 + } + ] + ] + }, + "Finalize Real Routes": { + "main": [ + [ + { + "node": "Respond Success", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Error: ORS Unavailable", + "type": "main", + "index": 0 + } + ] + ] } }, - "active": true, + "active": false, "settings": { "executionOrder": "v1", "binaryMode": "separate", @@ -400,4 +634,4 @@ }, "id": "IkKMqgb7uZuRk9d9", "tags": [] -} \ No newline at end of file +} diff --git a/ors-app/README.md b/ors-app/README.md new file mode 100644 index 0000000..a30f053 --- /dev/null +++ b/ors-app/README.md @@ -0,0 +1,15 @@ +# OpenRouteService por país + +- República Dominicana: `http://localhost:18080/ors/v2` +- México: `http://localhost:18081/ors/v2` + +Cada país mantiene su propio archivo OSM, configuración y directorio de grafos. Desde contenedores Docker, como n8n, usa `host.docker.internal` en lugar de `localhost`. + +El workflow selecciona el servicio según la columna `País` del archivo PDV. `MEXICO` usa `18081`; cualquier proyecto anterior sin país se mantiene en República Dominicana y usa `18080`. + +La imagen ORS configura memoria con `XMS`, `XMX` y `ADDITIONAL_JAVA_OPTS`; no se debe sobrescribir `JAVA_OPTS`, porque el entrypoint de la imagen lo reconstruye. + +```powershell +docker compose -f ors-app/republica-dominicana/docker-compose.yml up -d +docker compose -f ors-app/mexico/docker-compose.yml up -d +``` diff --git a/ors-app/mexico/docker-compose.yml b/ors-app/mexico/docker-compose.yml new file mode 100644 index 0000000..873b1f7 --- /dev/null +++ b/ors-app/mexico/docker-compose.yml @@ -0,0 +1,16 @@ +services: + ors: + image: openrouteservice/openrouteservice:latest + container_name: ors-mexico + ports: + - "18081:8082" + volumes: + - ./ors-docker/mexico-latest.osm.pbf:/home/ors/files/mexico-latest.osm.pbf + - ./ors-docker/graphs:/home/ors/graphs + - ./ors-docker/elevation_cache:/home/ors/elevation_cache + - ./ors-docker/config/ors-config.yml:/home/ors/config/ors-config.yml + environment: + - XMS=4g + - XMX=12g + - ADDITIONAL_JAVA_OPTS=-XX:MaxGCPauseMillis=300 -XX:InitiatingHeapOccupancyPercent=75 + restart: unless-stopped diff --git a/ors-app/mexico/ors-docker/config/ors-config.yml b/ors-app/mexico/ors-docker/config/ors-config.yml new file mode 100644 index 0000000..a5c2d7b --- /dev/null +++ b/ors-app/mexico/ors-docker/config/ors-config.yml @@ -0,0 +1,17 @@ +ors: + cors: + allowed_origins: "*" + allowed_headers: Content-Type, X-Requested-With, accept, Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Authorization + preflight_max_age: 600 + engine: + profile_default: + enabled: false + graph_path: graphs + build: + source_file: /home/ors/files/mexico-latest.osm.pbf + elevation: false + service: + maximum_distance: 500000 + profiles: + driving-car: + enabled: true diff --git a/ors-app/docker-compose.yml b/ors-app/republica-dominicana/docker-compose.yml similarity index 63% rename from ors-app/docker-compose.yml rename to ors-app/republica-dominicana/docker-compose.yml index b8e7ec0..bbc983f 100644 --- a/ors-app/docker-compose.yml +++ b/ors-app/republica-dominicana/docker-compose.yml @@ -1,7 +1,7 @@ services: ors: image: openrouteservice/openrouteservice:latest - container_name: ors-route-planner + container_name: ors-republica-dominicana ports: - "18080:8082" volumes: @@ -10,5 +10,7 @@ services: - ./ors-docker/elevation_cache:/home/ors/elevation_cache - ./ors-docker/config/ors-config.yml:/home/ors/config/ors-config.yml environment: - - JAVA_OPTS=-Djava.awt.headless=true -server -XX:TargetSurvivorRatio=75 -XX:SurvivorRatio=64 -XX:MaxTenuringThreshold=3 -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:InitiatingHeapOccupancyPercent=80 -Xms2g -Xmx4g + - XMS=1g + - XMX=4g + - ADDITIONAL_JAVA_OPTS=-XX:MaxGCPauseMillis=200 -XX:InitiatingHeapOccupancyPercent=80 restart: unless-stopped diff --git a/ors-app/ors-docker/config/example-ors-config.yml b/ors-app/republica-dominicana/ors-docker/config/example-ors-config.yml similarity index 100% rename from ors-app/ors-docker/config/example-ors-config.yml rename to ors-app/republica-dominicana/ors-docker/config/example-ors-config.yml diff --git a/ors-app/ors-docker/config/ors-config.yml b/ors-app/republica-dominicana/ors-docker/config/ors-config.yml similarity index 100% rename from ors-app/ors-docker/config/ors-config.yml rename to ors-app/republica-dominicana/ors-docker/config/ors-config.yml diff --git a/route-planner/src/app/app.routes.ts b/route-planner/src/app/app.routes.ts index d09bed9..59395ce 100644 --- a/route-planner/src/app/app.routes.ts +++ b/route-planner/src/app/app.routes.ts @@ -5,11 +5,17 @@ import { AssignedRouteDetailComponent } from './private/home/assigned-route-deta import { RouteOverviewMapComponent } from './private/home/route-overview-map/route-overview-map.component'; import { authGuard } from './core/services/auth.guard'; import { EmployeesComponent } from './private/employees/employees.component'; +import { ProjectListComponent } from './private/projects/project-list.component'; +import { NewProjectComponent } from './private/projects/new-project.component'; export const routes: Routes = [ { path: '', redirectTo: 'login', pathMatch: 'full' }, { path: 'login', component: LoginComponent }, - { path: 'home', component: HomeComponent, pathMatch: 'full', canActivate: [authGuard] }, + { path: 'home', component: ProjectListComponent, pathMatch: 'full', canActivate: [authGuard] }, + { path: 'proyectos/nuevo', component: NewProjectComponent, canActivate: [authGuard] }, + { path: 'proyectos/:id', component: HomeComponent, pathMatch: 'full', canActivate: [authGuard] }, + { path: 'proyectos/:id/mapa', component: RouteOverviewMapComponent, canActivate: [authGuard] }, + { path: 'proyectos/:id/ruta', component: AssignedRouteDetailComponent, canActivate: [authGuard] }, { path: 'empleados', component: EmployeesComponent, canActivate: [authGuard] }, { path: 'home/assigned-route', component: AssignedRouteDetailComponent, canActivate: [authGuard] }, { path: 'home/mapa', component: RouteOverviewMapComponent, canActivate: [authGuard] }, diff --git a/route-planner/src/app/core/models/project.model.ts b/route-planner/src/app/core/models/project.model.ts new file mode 100644 index 0000000..f4d8612 --- /dev/null +++ b/route-planner/src/app/core/models/project.model.ts @@ -0,0 +1,17 @@ +export interface RuteroProyecto { + id: string; + nombre: string; + descripcion: string | null; + propietario_id: string; + estado: 'borrador' | 'optimizado' | 'archivado'; + version: number; + created_at: string; + updated_at: string; +} + +export interface RuteroProyectoPdv { + id: string; + proyecto_id: string; + pdv_id: string; + pdv_data: Record; +} diff --git a/route-planner/src/app/core/models/route.model.ts b/route-planner/src/app/core/models/route.model.ts index eeccb76..4956a6d 100644 --- a/route-planner/src/app/core/models/route.model.ts +++ b/route-planner/src/app/core/models/route.model.ts @@ -27,6 +27,23 @@ export interface RouteAssignedJson { usuario_id: string; pdv_id: number; visit_order?: number; + geojson?: unknown; + horas_por_dia_usuario?: number; + horas_descanso_por_dia?: number; + jornada_codigo?: string; + pais?: string; + ciudad?: string; + estado?: string; + provincia?: string; + region?: string; + zona?: string; + metro?: string; + pais_usuario?: string; + ciudad_usuario?: string; + estado_usuario?: string; + provincia_usuario?: string; + region_usuario?: string; + zona_usuario?: string; } export interface RouteNoAssignedResponse { pdv_id: number; @@ -35,4 +52,11 @@ export interface RouteAssignedJson { latitud: number, longitud: number, motivo: string; + pais?: string; + ciudad?: string; + estado?: string; + provincia?: string; + region?: string; + zona?: string; + metro?: string; } diff --git a/route-planner/src/app/core/models/user-profile.model.ts b/route-planner/src/app/core/models/user-profile.model.ts new file mode 100644 index 0000000..50f1740 --- /dev/null +++ b/route-planner/src/app/core/models/user-profile.model.ts @@ -0,0 +1,4 @@ +export interface UserProfile { + nombre: string; + pais: string; +} diff --git a/route-planner/src/app/core/route-state/route.ts b/route-planner/src/app/core/route-state/route.ts index 38ecddc..8209fa8 100644 --- a/route-planner/src/app/core/route-state/route.ts +++ b/route-planner/src/app/core/route-state/route.ts @@ -11,7 +11,7 @@ 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']; +const DAY_ORDER = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes', 'Sábado', 'Sabado']; export interface UnassignedAssignment { userId: string; day: string; @@ -65,6 +65,12 @@ export class RouteState { nombre_usuario: profile.json.nombre_usuario, latitud_usuario: profile.json.latitud_usuario, longitud_usuario: profile.json.longitud_usuario, + pais_usuario: profile.json.pais_usuario, + ciudad_usuario: profile.json.ciudad_usuario, + estado_usuario: profile.json.estado_usuario, + provincia_usuario: profile.json.provincia_usuario, + region_usuario: profile.json.region_usuario, + zona_usuario: profile.json.zona_usuario, hora_laboral_semanal_usuario: profile.json.hora_laboral_semanal_usuario, dia: day, distancia: metrics?.distance ?? item.json.distancia, @@ -98,7 +104,20 @@ export class RouteState { nombre_usuario: profile.json.nombre_usuario, nombre_pdv: route.pdv_nombre, usuario_id: profile.json.usuario_id, - pdv_id: route.pdv_id + pdv_id: route.pdv_id, + pais: route.pais, + ciudad: route.ciudad, + estado: route.estado, + provincia: route.provincia, + region: route.region, + zona: route.zona, + metro: route.metro, + pais_usuario: profile.json.pais_usuario, + ciudad_usuario: profile.json.ciudad_usuario, + estado_usuario: profile.json.estado_usuario, + provincia_usuario: profile.json.provincia_usuario, + region_usuario: profile.json.region_usuario, + zona_usuario: profile.json.zona_usuario } } as RouteAssignedResponse; }); if (newRoutes.some(item => !item)) return; @@ -195,6 +214,12 @@ export class RouteState { nombre_usuario: user.json.nombre_usuario, latitud_usuario: user.json.latitud_usuario, longitud_usuario: user.json.longitud_usuario, + pais_usuario: user.json.pais_usuario, + ciudad_usuario: user.json.ciudad_usuario, + estado_usuario: user.json.estado_usuario, + provincia_usuario: user.json.provincia_usuario, + region_usuario: user.json.region_usuario, + zona_usuario: user.json.zona_usuario, hora_laboral_semanal_usuario: user.json.hora_laboral_semanal_usuario } }; diff --git a/route-planner/src/app/core/services/auth.service.ts b/route-planner/src/app/core/services/auth.service.ts index fed9312..311d191 100644 --- a/route-planner/src/app/core/services/auth.service.ts +++ b/route-planner/src/app/core/services/auth.service.ts @@ -1,12 +1,14 @@ import { Injectable } from '@angular/core'; import { Session } from '@supabase/supabase-js'; import { supabaseClient } from './supabase.client'; +import { UserProfileService } from './user-profile.service'; const CORPORATE_DOMAIN = '@gomezleemarketing.com'; @Injectable({ providedIn: 'root' }) export class AuthService { private readonly client = supabaseClient; + constructor(private profile: UserProfileService) {} async signIn(email: string, password: string): Promise { const normalizedEmail = email.trim().toLowerCase(); @@ -20,7 +22,9 @@ export class AuthService { async signInWithGoogle(): Promise { const { error } = await this.client.auth.signInWithOAuth({ provider: 'google', - options: { redirectTo: `${window.location.origin}/home` } + // Return to login so the OAuth callback can load the profile from n8n + // before private routes are opened. + options: { redirectTo: `${window.location.origin}/login` } }); return error?.message ?? null; } @@ -33,5 +37,6 @@ export class AuthService { async signOut(): Promise { await this.client.auth.signOut(); + this.profile.clear(); } } diff --git a/route-planner/src/app/core/services/employee.service.ts b/route-planner/src/app/core/services/employee.service.ts index fcde2f2..bdb6a2f 100644 --- a/route-planner/src/app/core/services/employee.service.ts +++ b/route-planner/src/app/core/services/employee.service.ts @@ -43,11 +43,18 @@ export class EmployeeService { if (error) throw error; } - async activeEmployeesFile(): Promise { - const employees = (await this.list()).filter(employee => employee.activo); + async active(country = ''): Promise { + const expectedCountry = this.normalizeCountry(country); + return (await this.list()).filter(employee => employee.activo && (!expectedCountry || this.normalizeCountry(employee.pais ?? '') === expectedCountry)); + } + + async activeEmployeesFile(country = ''): Promise { + const employees = await this.active(country); const rows = employees.map(employee => ({ ID: employee.employee_code, 'Nombre del empleado': employee.nombre, + 'País': employee.pais, + Regional: employee.regional, Latitud: employee.latitud, Longitud: employee.longitud, 'Horas por día usuario': employee.horas_por_dia, @@ -63,4 +70,11 @@ export class EmployeeService { const data = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' }); return new File([data], 'empleados_activos.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); } + + private normalizeCountry(country: string): string { + const normalized = country.normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim().toUpperCase(); + if (normalized.includes('MEXICO')) return 'MEXICO'; + if (normalized.includes('DOMINICANA')) return 'REPUBLICA DOMINICANA'; + return normalized; + } } diff --git a/route-planner/src/app/core/services/ors.service.ts b/route-planner/src/app/core/services/ors.service.ts index 02b5db2..79c8296 100644 --- a/route-planner/src/app/core/services/ors.service.ts +++ b/route-planner/src/app/core/services/ors.service.ts @@ -16,8 +16,6 @@ import { import { takeUntil } from 'rxjs/operators'; import { environment } from '../../../environments/environment'; -const ENDPOINT = `${environment.orsBaseUrl}/directions/driving-car`; -const ENDPOINTJSON = `${environment.orsBaseUrl}/directions/driving-car/geojson`; const MAX_CONCURRENT_REQUESTS = 2; @Injectable({ @@ -41,8 +39,12 @@ export class OrsService { const body = { coordinates: [origin, destination] }; + const baseUrl = this.isMexico(origin) || this.isMexico(destination) + ? environment.orsMexicoBaseUrl + : environment.orsBaseUrl; + const endpoint = `${baseUrl}/directions/driving-car${isJson ? '/geojson' : ''}`; - return this.http.post(isJson ? ENDPOINTJSON : ENDPOINT, body).pipe( + return this.http.post(endpoint, body).pipe( timeout(8000), catchError(error => { throw error; @@ -50,6 +52,11 @@ export class OrsService { ); } + private isMexico(coordinates: number[]): boolean { + const [longitude, latitude] = coordinates; + return latitude >= 14 && latitude <= 33 && longitude >= -119 && longitude <= -86; + } + async processPairs( pares: { pdv_id: number; diff --git a/route-planner/src/app/core/services/project.service.ts b/route-planner/src/app/core/services/project.service.ts new file mode 100644 index 0000000..5cd2fd3 --- /dev/null +++ b/route-planner/src/app/core/services/project.service.ts @@ -0,0 +1,94 @@ +import { Injectable } from '@angular/core'; +import * as XLSX from 'xlsx'; +import { RuteroProyecto, RuteroProyectoPdv } from '../models/project.model'; +import { supabaseClient } from './supabase.client'; + +@Injectable({ providedIn: 'root' }) +export class ProjectService { + async list(): Promise { + const { data, error } = await supabaseClient.from('rutero_proyectos').select('*').order('updated_at', { ascending: false }); + if (error) throw error; + return (data ?? []) as RuteroProyecto[]; + } + + async get(id: string): Promise { + const { data, error } = await supabaseClient.from('rutero_proyectos').select('*').eq('id', id).single(); + if (error) throw error; + return data as RuteroProyecto; + } + + async update(id: string, changes: Partial>): Promise { + const { error } = await supabaseClient.from('rutero_proyectos').update(changes).eq('id', id); + if (error) throw error; + } + + async remove(id: string): Promise { + const dependentTables = ['rutero_proyecto_rutas', 'rutero_proyecto_pdvs']; + for (const table of dependentTables) { + const { error } = await supabaseClient.from(table).delete().eq('proyecto_id', id); + if (error) throw error; + } + const { error } = await supabaseClient.from('rutero_proyectos').delete().eq('id', id); + if (error) throw error; + } + + async create(nombre: string, rows: Record[]): Promise { + const { data: sessionData, error: sessionError } = await supabaseClient.auth.getSession(); + if (sessionError) throw sessionError; + const ownerId = sessionData.session?.user.id; + if (!ownerId) throw new Error('No hay una sesión activa.'); + const projectId = crypto.randomUUID(); + const now = new Date().toISOString(); + const project: RuteroProyecto = { + id: projectId, + nombre: nombre.trim(), + descripcion: null, + propietario_id: ownerId, + estado: 'borrador', + version: 1, + created_at: now, + updated_at: now + }; + const { error } = await supabaseClient + .from('rutero_proyectos') + .insert({ id: projectId, nombre: project.nombre, propietario_id: ownerId }); + if (error) throw error; + const pdvs = rows.map((pdv, index) => ({ + proyecto_id: projectId, + pdv_id: String(pdv['ID'] ?? pdv['id'] ?? pdv['Id'] ?? index + 1), + pdv_data: pdv + })); + const { error: pdvError } = await supabaseClient.from('rutero_proyecto_pdvs').insert(pdvs); + if (pdvError) { + await supabaseClient.from('rutero_proyectos').delete().eq('id', projectId); + throw pdvError; + } + return project; + } + + async pdvs(id: string): Promise { + const { data, error } = await supabaseClient.from('rutero_proyecto_pdvs').select('*').eq('proyecto_id', id).order('created_at'); + if (error) throw error; + return (data ?? []) as RuteroProyectoPdv[]; + } + + async projectInput(id: string): Promise<{ file: File; country: string }> { + const pdvs = await this.pdvs(id); + const rows = pdvs.map(pdv => pdv.pdv_data); + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(rows), 'PDVs'); + const data = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' }); + const country = rows.map(row => Object.entries(row).find(([key]) => { + const normalized = key.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); + return normalized === 'pais' || normalized === 'country'; + })?.[1]).find(value => String(value ?? '').trim()); + return { + file: new File([data], 'pdvs_proyecto.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), + country: String(country ?? 'Republica Dominicana').trim() + }; + } + + async pdvFile(id: string): Promise { + return (await this.projectInput(id)).file; + } +} diff --git a/route-planner/src/app/core/services/router.service.ts b/route-planner/src/app/core/services/router.service.ts index 7fde447..edb92a0 100644 --- a/route-planner/src/app/core/services/router.service.ts +++ b/route-planner/src/app/core/services/router.service.ts @@ -17,7 +17,7 @@ export class RutasService { 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(() => Object.assign(new Error(`Timeout al calcular rutas (${environment.requestTimeoutMs / 1000}s). Intenta con menos filas o reintenta.`), { name: 'TimeoutError' })); } return throwError(() => err); }) diff --git a/route-planner/src/app/core/services/user-profile.service.ts b/route-planner/src/app/core/services/user-profile.service.ts new file mode 100644 index 0000000..ad70e8f --- /dev/null +++ b/route-planner/src/app/core/services/user-profile.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@angular/core'; +import { environment } from '../../../environments/environment'; +import { UserProfile } from '../models/user-profile.model'; + +@Injectable({ providedIn: 'root' }) +export class UserProfileService { + private readonly storageKey = 'rp_user_profile'; + private profile: UserProfile | null = null; + avatarUrl = ''; + constructor() { this.restore(); } + get current(): UserProfile | null { return this.profile; } + clear(): void { + this.profile = null; + this.avatarUrl = ''; + sessionStorage.removeItem(this.storageKey); + } + + async load(email: string, avatarUrl = ''): Promise { + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), 15000); + try { + const response = await fetch(environment.userProfileWebhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), signal: controller.signal }); + if (!response.ok) throw new Error(`El webhook respondió con ${response.status}.`); + const data = await response.json() as Partial; + if (!data.nombre || !data.pais) throw new Error('El webhook no devolvió nombre y país.'); + this.profile = { nombre: data.nombre, pais: data.pais }; + this.avatarUrl = avatarUrl; + sessionStorage.setItem(this.storageKey, JSON.stringify({ ...this.profile, avatarUrl })); + return this.profile; + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') throw new Error('La consulta de datos del usuario tardó demasiado.'); + throw error; + } finally { window.clearTimeout(timeout); } + } + + private restore(): void { + try { + const stored = sessionStorage.getItem(this.storageKey); + if (!stored) return; + const data = JSON.parse(stored) as Partial & { avatarUrl?: string }; + if (!data.nombre || !data.pais) return; + this.profile = { nombre: data.nombre, pais: data.pais }; + this.avatarUrl = data.avatarUrl ?? ''; + } catch { + sessionStorage.removeItem(this.storageKey); + } + } +} diff --git a/route-planner/src/app/private/employees/employees.component.css b/route-planner/src/app/private/employees/employees.component.css index c86b2b3..f9193f6 100644 --- a/route-planner/src/app/private/employees/employees.component.css +++ b/route-planner/src/app/private/employees/employees.component.css @@ -1,4 +1,5 @@ .employees-topbar { display: flex; align-items: center; gap: 12px; padding: 9px 24px; border-bottom: 3px solid var(--glm-verde); background: #fff; color: var(--glm-azul); font-weight: 700; } +.user-profile { display:flex; align-items:center; gap:8px; margin-left:auto; color:var(--glm-azul); font-size:.84rem; font-weight:700; }.user-profile img,.avatar-fallback { width:30px; height:30px; border-radius:50%; }.user-profile img { object-fit:cover; }.avatar-fallback { display:inline-flex; align-items:center; justify-content:center; background:var(--glm-verde); color:#fff; } .employees-topbar img { height: 38px; width: auto; } .topbar-actions { display: flex; gap: 8px; margin-left: auto; } .topbar-actions button, .table-header button, .form-actions button, .row-actions button { border: 1px solid var(--glm-gris-medio); border-radius: 4px; background: #fff; color: var(--glm-azul); cursor: pointer; font: inherit; font-size: .78rem; font-weight: 600; padding: 7px 10px; } diff --git a/route-planner/src/app/private/employees/employees.component.html b/route-planner/src/app/private/employees/employees.component.html index 2e62676..a953774 100644 --- a/route-planner/src/app/private/employees/employees.component.html +++ b/route-planner/src/app/private/employees/employees.component.html @@ -1,7 +1,7 @@
GomezLee Marketing - Route Planner -
+ Route Planner +
diff --git a/route-planner/src/app/private/employees/employees.component.ts b/route-planner/src/app/private/employees/employees.component.ts index 5a26967..7fa8f1c 100644 --- a/route-planner/src/app/private/employees/employees.component.ts +++ b/route-planner/src/app/private/employees/employees.component.ts @@ -2,9 +2,11 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; +import { Location } from '@angular/common'; import { Employee, EmployeeInput } from '../../core/models/employee.model'; import { EmployeeService } from '../../core/services/employee.service'; import { AuthService } from '../../core/services/auth.service'; +import { UserProfileService } from '../../core/services/user-profile.service'; import { WORK_SCHEDULES, workSchedule } from '../../core/models/work-schedule.model'; import { normalizeRow, cleanNum } from '../../shared/utils/header-normalization.utils'; import * as XLSX from 'xlsx'; @@ -36,6 +38,8 @@ export class EmployeesComponent implements OnDestroy, OnInit { private employeesService: EmployeeService, private auth: AuthService, private router: Router, + private location: Location, + public profile: UserProfileService, private cdr: ChangeDetectorRef ) {} @@ -225,6 +229,11 @@ export class EmployeesComponent implements OnDestroy, OnInit { await this.router.navigate(['/home']); } + goBack(): void { + if (history.length > 1) this.location.back(); + else void this.router.navigate(['/home']); + } + private nullable(value: string | null): string | null { return value?.trim() || null; } @@ -257,7 +266,7 @@ export class EmployeesComponent implements OnDestroy, OnInit { email: this.nullable(String(row['email'] ?? '')), perfil: this.nullable(String(row['perfil_de_acceso'] ?? '')), pais: this.nullable(String(row['pais'] ?? '')), - regional: this.nullable(String(row['regionales'] ?? '')), + regional: this.nullable(String(row['regionales'] ?? row['regional'] ?? '')), latitud: latitude, longitud: longitude, horas_por_dia: dailyHours, 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 e1d9f08..9eecc70 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 @@ -99,12 +99,26 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie } private initMap() { - this.map = L.map('map').setView([18.4861, -69.9312], 13); + const territoryBounds = this.routeData ? L.latLngBounds([ + [Number(this.routeData.json.latitud_usuario), Number(this.routeData.json.longitud_usuario)], + [Number(this.routeData.json.latitud_pdv), Number(this.routeData.json.longitud_pdv)] + ]) : null; + this.map = L.map('map', { maxBoundsViscosity: 0.85 }); + if (territoryBounds?.isValid()) { + this.map.fitBounds(territoryBounds.pad(0.3)); + this.map.setMaxBounds(territoryBounds.pad(0.6)); + } else { + this.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' + attribution: '© OpenStreetMap contributors', + bounds: territoryBounds?.isValid() ? territoryBounds.pad(0.6) : undefined, + keepBuffer: 1, + noWrap: true, + updateWhenIdle: true }).addTo(this.map); } @@ -127,6 +141,13 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie this.centerMapOnPoints(origin, destination); + if (json.geojson) { + this.showRouteOnMap(json.geojson, origin, destination, json, this.segmentOriginName); + this.isLoading = false; + this.cdr.detectChanges(); + return; + } + try { const results = await this.orsService.processPairs( [{ pdv_id: json.pdv_id, origin, destination }], @@ -292,6 +313,6 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie } goBack() { - this.router.navigate(['/home']); + this.router.navigate(['/proyectos', this.activatedRoute.snapshot.paramMap.get('id')]); } } 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 b438a95..36bc86e 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 @@ -93,7 +93,7 @@ {{ row.json.dia }} {{ row.json.distancia }} - {{ row.json.horas_desplazamiento }} + {{ (row.json.horas_desplazamiento * 60).toFixed(0) }} min diff --git a/route-planner/src/app/private/home/assigned-route/assigned-route.component.ts b/route-planner/src/app/private/home/assigned-route/assigned-route.component.ts index eb158b2..afdfd31 100644 --- a/route-planner/src/app/private/home/assigned-route/assigned-route.component.ts +++ b/route-planner/src/app/private/home/assigned-route/assigned-route.component.ts @@ -1,7 +1,7 @@ import { Component, ChangeDetectorRef, ViewChild } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { Router } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { RouteAssignedResponse } from '../../../core/models/route.model'; import { saveAs } from 'file-saver'; import { Table } from 'primeng/table'; @@ -44,6 +44,7 @@ export class AssignedRouteComponent { private routesState: RouteState, private cdr: ChangeDetectorRef, private router: Router, + private activatedRoute: ActivatedRoute, private rutasService: RutasService, private planningInput: PlanningInputService ) { } @@ -79,7 +80,7 @@ export class AssignedRouteComponent { } onShowMap(row: RouteAssignedResponse) { this.routesState.sendRoute(row); - this.router.navigate(['/home/assigned-route'], { + this.router.navigate(['/proyectos', this.projectId, 'ruta'], { queryParams: { pdv_id: row.json.pdv_id, usuario_id: row.json.usuario_id, dia: row.json.dia } }); } @@ -88,18 +89,20 @@ export class AssignedRouteComponent { // If table has filtered view, use first visible row's usuario as default; else first assigned const target = (this.table?.filteredValue?.[0] as RouteAssignedResponse | undefined) ?? this.assignedRoutes[0]; if (target) { - this.router.navigate(['/home/mapa'], { + this.router.navigate(['/proyectos', this.projectId, 'mapa'], { queryParams: { usuario_id: target.json.usuario_id, dia: 'Todos' } }); } else { - this.router.navigate(['/home/mapa']); + this.router.navigate(['/proyectos', this.projectId, 'mapa']); } } goToUserMap(usuarioId: string) { - this.router.navigate(['/home/mapa'], { queryParams: { usuario_id: usuarioId, dia: 'Todos' } }); + this.router.navigate(['/proyectos', this.projectId, 'mapa'], { queryParams: { usuario_id: usuarioId, dia: 'Todos' } }); } + private get projectId(): string { return this.activatedRoute.snapshot.paramMap.get('id') ?? ''; } + get userOptions(): { id: string; name: string }[] { const users = new Map(); for (const route of this.assignedRoutes) users.set(String(route.json.usuario_id), route.json.nombre_usuario); diff --git a/route-planner/src/app/private/home/home.component.css b/route-planner/src/app/private/home/home.component.css index d45ef6d..d905a05 100644 --- a/route-planner/src/app/private/home/home.component.css +++ b/route-planner/src/app/private/home/home.component.css @@ -31,6 +31,10 @@ color: var(--glm-verde); } +.projects-link { margin-left: auto; color: var(--glm-azul); font-size: .82rem; text-decoration: none; } +.user-profile { display:flex; align-items:center; gap:8px; margin-left:auto; color:var(--glm-azul); font-size:.84rem; font-weight:700; }.user-profile img,.avatar-fallback { width:30px; height:30px; border-radius:50%; }.user-profile img { object-fit:cover; }.avatar-fallback { display:inline-flex; align-items:center; justify-content:center; background:var(--glm-verde); color:#fff; } +.projects-link button,.home-employees-link,.edit-project,.project-edit-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-azul); cursor:pointer; font:inherit; font-weight:700; padding:8px 11px; text-decoration:none; }.home-employees-link { display:inline-block; margin-bottom:12px; }.project-heading { width:100%; }.project-title-line { display:flex; align-items:center; gap:14px; }.project-title-line h1 { margin:0; }.back-projects { color:var(--glm-azul); font-size:.85rem; font-weight:700; text-decoration:none; white-space:nowrap; }.edit-project,.project-edit-actions { margin-left:auto; }.project-title-line input,.project-heading textarea { border:1px solid #9aaab3; border-radius:4px; background:#fff; color:#2f3f48; font:inherit; padding:8px; }.project-heading textarea { display:block; width:100%; margin-top:10px; resize:vertical; }.project-edit-actions { display:flex; gap:6px; }.project-edit-actions button:first-child { background:var(--glm-azul); color:#fff; } + .home-container { max-width: 1000px; margin: 0 auto; diff --git a/route-planner/src/app/private/home/home.component.html b/route-planner/src/app/private/home/home.component.html index ff225f0..18277a5 100644 --- a/route-planner/src/app/private/home/home.component.html +++ b/route-planner/src/app/private/home/home.component.html @@ -1,31 +1,31 @@
| - Route Planner + Route Planner
+ Gestionar empleados
-

Route Planner

Gestión de rutas asignadas y puntos de venta pendientes

- Gestionar empleados +
Mis proyectos

{{ projectName || 'Proyecto' }}

Gestión de rutas asignadas y puntos de venta pendientes

-

Subir PDVs para optimización

-

Cargando empleados activos...{{ activeEmployees }} empleados activos se usarán para planificar.

+

PDVs del proyecto

+

Cargando empleados activos...{{ activeEmployees }} empleados activos de {{ projectCountry }} se usarán para planificar.

diff --git a/route-planner/src/app/private/home/home.component.ts b/route-planner/src/app/private/home/home.component.ts index e22ea87..55f8f47 100644 --- a/route-planner/src/app/private/home/home.component.ts +++ b/route-planner/src/app/private/home/home.component.ts @@ -10,8 +10,11 @@ import { ExcelPreviewService, PreviewResult } from '../../shared/services/excel- import { Subscription } from 'rxjs'; import { PlanningKpisComponent } from './planning-kpis/planning-kpis.component'; import { PlanningInputService } from '../../core/services/planning-input.service'; -import { RouterLink } from '@angular/router'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { EmployeeService } from '../../core/services/employee.service'; +import { ProjectService } from '../../core/services/project.service'; +import { AuthService } from '../../core/services/auth.service'; +import { UserProfileService } from '../../core/services/user-profile.service'; @Component({ standalone: true, @@ -31,6 +34,12 @@ export class HomeComponent implements OnInit { employeesLoading = true; uploadError: string | null = null; isLoading = false; + projectName = ''; + projectId = ''; + editingProject = false; + projectDescription = ''; + projectCountry = ''; + projectSaving = false; private calcSub: Subscription | null = null; constructor( @@ -39,17 +48,60 @@ export class HomeComponent implements OnInit { private previewService: ExcelPreviewService, private planningInput: PlanningInputService, private employeesService: EmployeeService, + private projectsService: ProjectService, + private activatedRoute: ActivatedRoute, + private auth: AuthService, + private router: Router, + public profile: UserProfileService, private cdr: ChangeDetectorRef ) {} ngOnInit(): void { - void this.loadEmployees(); + void this.initialize(); } + private async initialize(): Promise { + await this.loadProject(); + await this.loadEmployees(); + } + + private async loadProject(): Promise { + this.projectId = this.activatedRoute.snapshot.paramMap.get('id') ?? ''; + if (!this.projectId) return; + try { + const [project, input] = await Promise.all([this.projectsService.get(this.projectId), this.projectsService.projectInput(this.projectId)]); + this.projectName = project.nombre; + this.projectDescription = project.descripcion ?? ''; + this.projectCountry = input.country; + this.pdvFile = input.file; + this.pdvPreview = await this.previewService.previewFile(input.file, 'PDV'); + } catch (error: any) { + this.uploadError = error?.message ?? 'No se pudo cargar el proyecto.'; + } finally { + this.cdr.detectChanges(); + } + } + + startProjectEdit(): void { this.editingProject = true; } + + cancelProjectEdit(): void { this.editingProject = false; void this.loadProject(); } + + async saveProject(): Promise { + if (!this.projectId || !this.projectName.trim()) return; + this.projectSaving = true; + try { + await this.projectsService.update(this.projectId, { nombre: this.projectName.trim(), descripcion: this.projectDescription.trim() || null }); + this.editingProject = false; + } catch (error: any) { this.uploadError = error?.message ?? 'No se pudo actualizar el proyecto.'; } + finally { this.projectSaving = false; this.cdr.detectChanges(); } + } + + async signOut(): Promise { await this.auth.signOut(); await this.router.navigate(['/login']); } + private async loadEmployees(): Promise { this.employeesLoading = true; try { - this.activeEmployees = (await this.employeesService.list()).filter(employee => employee.activo).length; + this.activeEmployees = (await this.employeesService.active(this.projectCountry)).length; } catch { this.activeEmployees = 0; } finally { @@ -190,7 +242,7 @@ export class HomeComponent implements OnInit { } let employeesFile: File; try { - employeesFile = await this.employeesService.activeEmployeesFile(); + employeesFile = await this.employeesService.activeEmployeesFile(this.projectCountry); } catch { this.uploadError = 'No se pudieron obtener los empleados activos.'; this.routeState.setLoading(false); @@ -202,6 +254,7 @@ export class HomeComponent implements OnInit { const formData = new FormData(); formData.append('pdv_file', pdvForOptimizer, pdvForOptimizer.name); formData.append('usuarios_file', employeesFile, employeesFile.name); + formData.append('pais', this.projectCountry); this.calcSub?.unsubscribe(); this.calcSub = this.rutasService.calcularRutas(formData).subscribe({ @@ -247,7 +300,7 @@ export class HomeComponent implements OnInit { 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?.name === 'TimeoutError') return 'El cálculo tardó demasiado (timeout 180s). 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; 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 index 9cf951b..97d0ae3 100644 --- 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 @@ -77,6 +77,9 @@ font-weight: 700; } +.territory-summary { margin: 4px 0 0; color: var(--glm-acero); font-size: .76rem; line-height: 1.35; } +.territory-summary i { color: var(--glm-verde); margin-right: 3px; } + .filters-card { background: var(--glm-verde-bg); border: 1px solid var(--glm-verde-claro); 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 index d1f9d85..6ef4ff8 100644 --- 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 @@ -16,6 +16,7 @@
+

{{ activeTerritories.join(', ') }}

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 4461ca4..e3e43cb 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 @@ -96,6 +96,14 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn reassignDay = ''; reassignError: string | null = null; + get activeTerritories(): string[] { + const values = [ + ...this.allRoutes.map(route => route.json.metro || route.json.ciudad || route.json.estado || route.json.provincia || route.json.region || route.json.zona), + ...this.unassignedRoutes.map(route => route.metro || route.ciudad || route.estado || route.provincia || route.region || route.zona) + ].filter((value): value is string => !!value?.trim()); + return [...new Set(values)].sort((a, b) => a.localeCompare(b)); + } + // Summary stats get uniqueFilteredRoutes(): RouteAssignedResponse[] { const unique = new Map(); @@ -140,7 +148,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn } get dailyGroups(): DailyRouteGroup[] { - const order = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes']; + const order = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes', 'Sábado', 'Sabado']; const groups = new Map(); for (const summary of this.dailySummaries) { const routes = groups.get(summary.day) ?? []; @@ -289,7 +297,14 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn } private initMap() { - this.map = L.map('map-overview').setView([18.4861, -69.9312], 10); + const territoryBounds = this.projectBounds(); + this.map = L.map('map-overview', { maxBoundsViscosity: 0.85 }); + if (territoryBounds) { + this.map.fitBounds(territoryBounds.pad(0.15)); + this.map.setMaxBounds(territoryBounds.pad(0.35)); + } else { + this.map.setView([18.4861, -69.9312], 10); + } const pdvPane = this.map.createPane('pdvPane'); const heatPane = this.map.createPane('heatPane'); pdvPane.style.zIndex = '750'; @@ -297,7 +312,11 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn const popupPane = this.map.getPane('popupPane'); if (popupPane) popupPane.style.zIndex = '1000'; L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - attribution: '© OpenStreetMap contributors' + attribution: '© OpenStreetMap contributors', + bounds: territoryBounds?.pad(0.35), + keepBuffer: 1, + noWrap: true, + updateWhenIdle: true }).addTo(this.map); // Fix tiles after sidebar overlay setTimeout(() => this.map?.invalidateSize(), 200); @@ -336,6 +355,9 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn this.orsProgress = 0; this.orsTotal = 0; if (!this.filteredRoutes.length) { + this.renderUnassignedMarkers(); + const territoryBounds = this.projectBounds(); + if (territoryBounds) this.map.fitBounds(territoryBounds.pad(0.15)); this.cdr.detectChanges(); return; } @@ -414,6 +436,12 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn marker.on('click', onClick); }); + if (this.selectedUsuarioId === 'Todos' && this.selectedDia === 'Todos') { + for (const route of this.unassignedRoutes) { + if (this.validPoint(route.latitud, route.longitud)) bounds.push([Number(route.latitud), Number(route.longitud)]); + } + } + this.updateHeatLayer(); this.toggleLayerVisibility(); @@ -428,6 +456,25 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn this.cdr.detectChanges(); } + private projectBounds(): L.LatLngBounds | null { + const points: L.LatLngTuple[] = []; + for (const route of this.allRoutes) { + const data = route.json; + if (this.validPoint(data.latitud_usuario, data.longitud_usuario)) points.push([Number(data.latitud_usuario), Number(data.longitud_usuario)]); + if (this.validPoint(data.latitud_pdv, data.longitud_pdv)) points.push([Number(data.latitud_pdv), Number(data.longitud_pdv)]); + } + for (const route of this.unassignedRoutes) { + if (this.validPoint(route.latitud, route.longitud)) points.push([Number(route.latitud), Number(route.longitud)]); + } + return points.length ? L.latLngBounds(points) : null; + } + + private validPoint(latitude: unknown, longitude: unknown): boolean { + const lat = Number(latitude); + const lng = Number(longitude); + return Number.isFinite(lat) && Number.isFinite(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180; + } + private renderUnassignedMarkers(): void { if (!this.map || this.selectedUsuarioId !== 'Todos' || this.selectedDia !== 'Todos') return; for (const route of this.unassignedRoutes) { @@ -452,15 +499,20 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn this.orsProgress = 0; this.cdr.detectChanges(); - const pares = this.mapRoutes.map(route => ({ + const embeddedResults = this.mapRoutes + .filter(route => !!route.data.json.geojson) + .map(route => ({ pdv_id: route.requestId, geojson: route.data.json.geojson })); + const pares = this.mapRoutes.filter(route => !route.data.json.geojson).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; + const remoteResults = pares.length + ? await this.orsService.processPairsWithProgress(pares, 2, true, this.abort$).promise + : []; + const results = [...embeddedResults, ...remoteResults]; if (!results.length) { this.isLoading = false; this.cdr.detectChanges(); @@ -950,6 +1002,6 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn } goBack() { - this.router.navigate(['/home']); + this.router.navigate(['/proyectos', this.activatedRoute.snapshot.paramMap.get('id')]); } } diff --git a/route-planner/src/app/private/projects/new-project.component.html b/route-planner/src/app/private/projects/new-project.component.html new file mode 100644 index 0000000..c40c068 --- /dev/null +++ b/route-planner/src/app/private/projects/new-project.component.html @@ -0,0 +1 @@ +
GomezLee MarketingRoute Planner

Crear proyecto

Define el proyecto e importa los PDVs que se planificarán.

{{ file.name }}

Validando archivo...

{{ preview!.validRows }} PDVs válidos para crear el proyecto.

El archivo tiene errores. Corrígelos antes de crear el proyecto.

{{ error }}

diff --git a/route-planner/src/app/private/projects/new-project.component.ts b/route-planner/src/app/private/projects/new-project.component.ts new file mode 100644 index 0000000..92e9b4e --- /dev/null +++ b/route-planner/src/app/private/projects/new-project.component.ts @@ -0,0 +1,52 @@ +import { ChangeDetectorRef, Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router, RouterLink } from '@angular/router'; +import * as XLSX from 'xlsx'; +import { ProjectService } from '../../core/services/project.service'; +import { AuthService } from '../../core/services/auth.service'; +import { UserProfileService } from '../../core/services/user-profile.service'; +import { ExcelPreviewService, PreviewResult } from '../../shared/services/excel-preview.service'; + +@Component({ standalone: true, selector: 'app-new-project', imports: [CommonModule, FormsModule, RouterLink], templateUrl: './new-project.component.html', styleUrl: './projects.component.css' }) +export class NewProjectComponent { + name = ''; + country = 'Republica Dominicana'; + file: File | null = null; + preview: PreviewResult | null = null; + loading = false; + saving = false; + error = ''; + constructor(private projects: ProjectService, private auth: AuthService, public profile: UserProfileService, private previewService: ExcelPreviewService, private router: Router, private cdr: ChangeDetectorRef) {} + async signOut(): Promise { await this.auth.signOut(); await this.router.navigate(['/login']); } + async fileChanged(event: Event): Promise { + const file = (event.target as HTMLInputElement).files?.[0]; + if (!file) return; + this.file = file; this.preview = null; this.error = ''; this.loading = true; + try { + this.preview = await this.previewService.previewFile(file, 'PDV'); + const workbook = XLSX.read(await file.arrayBuffer(), { type: 'array' }); + const rows = XLSX.utils.sheet_to_json>(workbook.Sheets[workbook.SheetNames[0]], { defval: null, raw: true }); + const value = rows.map(row => Object.entries(row).find(([key]) => key.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase() === 'pais')?.[1]).find(Boolean); + const normalized = String(value ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase(); + if (normalized.includes('MEXICO')) this.country = 'MEXICO'; + else if (normalized.includes('DOMINICANA')) this.country = 'Republica Dominicana'; + } + catch (error: any) { this.error = error?.message ?? 'No se pudo leer el archivo.'; } + finally { this.loading = false; this.cdr.detectChanges(); } + } + get canCreate(): boolean { return !!this.name.trim() && !!this.country && !!this.file && !!this.preview?.valid && !this.saving; } + async create(): Promise { + if (!this.canCreate || !this.file) return; + this.saving = true; this.error = ''; + try { + const workbook = XLSX.read(await this.file.arrayBuffer(), { type: 'array' }); + const sheet = workbook.Sheets[workbook.SheetNames[0]]; + const rows = XLSX.utils.sheet_to_json>(sheet, { defval: null, raw: true }); + const projectRows = rows.map(row => ({ ...row, 'País': this.country })); + const project = await this.projects.create(this.name, projectRows); + await this.router.navigate(['/proyectos', project.id]); + } catch (error: any) { this.error = error?.message ?? 'No se pudo crear el proyecto.'; } + finally { this.saving = false; this.cdr.detectChanges(); } + } +} diff --git a/route-planner/src/app/private/projects/project-list.component.html b/route-planner/src/app/private/projects/project-list.component.html new file mode 100644 index 0000000..211ea80 --- /dev/null +++ b/route-planner/src/app/private/projects/project-list.component.html @@ -0,0 +1,2 @@ +
GomezLee MarketingRoute Planner
+
Gestionar empleados

Mis proyectos

Organiza y conserva tus planificaciones de rutas.

Crear proyecto

{{ error }}

Cargando proyectos...

{{ project.estado }}

{{ project.nombre }}

{{ project.descripcion || 'Sin descripción' }}

Actualizado: {{ project.updated_at | date:'medium' }}

Aún no tienes proyectos

Crea uno e importa su archivo de PDVs para comenzar.

Crear proyecto
diff --git a/route-planner/src/app/private/projects/project-list.component.ts b/route-planner/src/app/private/projects/project-list.component.ts new file mode 100644 index 0000000..1db4007 --- /dev/null +++ b/route-planner/src/app/private/projects/project-list.component.ts @@ -0,0 +1,39 @@ +import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router, RouterLink } from '@angular/router'; +import { ProjectService } from '../../core/services/project.service'; +import { RuteroProyecto } from '../../core/models/project.model'; +import { AuthService } from '../../core/services/auth.service'; +import { UserProfileService } from '../../core/services/user-profile.service'; + +@Component({ standalone: true, selector: 'app-project-list', imports: [CommonModule, RouterLink], templateUrl: './project-list.component.html', styleUrl: './projects.component.css' }) +export class ProjectListComponent implements OnInit { + projects: RuteroProyecto[] = []; + loading = true; + deletingId = ''; + error = ''; + constructor(private projectsService: ProjectService, private auth: AuthService, public profile: UserProfileService, private router: Router, private cdr: ChangeDetectorRef) {} + ngOnInit(): void { void this.load(); } + async load(): Promise { + this.loading = true; + try { this.projects = await this.projectsService.list(); } + catch (error: any) { this.error = error?.message ?? 'No se pudieron cargar los proyectos.'; } + finally { this.loading = false; this.cdr.detectChanges(); } + } + open(project: RuteroProyecto): void { void this.router.navigate(['/proyectos', project.id]); } + async remove(project: RuteroProyecto): Promise { + if (!confirm(`Eliminar permanentemente el proyecto "${project.nombre}" y todos sus PDVs y rutas?`)) return; + this.deletingId = project.id; + this.error = ''; + try { + await this.projectsService.remove(project.id); + this.projects = this.projects.filter(item => item.id !== project.id); + } catch (error: any) { + this.error = error?.message ?? 'No se pudo eliminar el proyecto.'; + } finally { + this.deletingId = ''; + this.cdr.detectChanges(); + } + } + async signOut(): Promise { await this.auth.signOut(); await this.router.navigate(['/login']); } +} diff --git a/route-planner/src/app/private/projects/projects.component.css b/route-planner/src/app/private/projects/projects.component.css new file mode 100644 index 0000000..feb4c92 --- /dev/null +++ b/route-planner/src/app/private/projects/projects.component.css @@ -0,0 +1,5 @@ +.projects-topbar { display:flex; align-items:center; gap:12px; padding:9px 24px; border-bottom:3px solid var(--glm-verde); color:var(--glm-azul); font-weight:700; background:#fff; }.projects-topbar img { height:38px; }.projects-topbar a { margin-left:auto; color:var(--glm-azul); text-decoration:none; }.projects-page { max-width:1100px; margin:0 auto; padding:30px 24px; }.projects-page header { display:flex; justify-content:space-between; align-items:flex-start; gap:20px; border-bottom:3px solid var(--glm-verde); padding-bottom:14px; }.projects-page h1,.projects-page h2 { color:var(--glm-azul); margin:0; }.projects-page header p,.project-card p,small { color:var(--glm-acero); }.primary,.project-card button { display:inline-block; border:1px solid var(--glm-azul); border-radius:4px; padding:9px 12px; background:var(--glm-azul); color:#fff; font:inherit; font-weight:700; text-decoration:none; cursor:pointer; }.project-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(245px,1fr)); gap:16px; margin-top:22px; }.project-card,.project-form,.empty { border:1px solid var(--glm-gris-medio); border-top:3px solid var(--glm-azul); border-radius:4px; padding:18px; background:#fff; }.project-card { display:flex; flex-direction:column; gap:12px; }.project-card button { margin-top:auto; }.state { align-self:flex-start; color:var(--glm-verde); font-size:.75rem; font-weight:700; text-transform:uppercase; }.empty { grid-column:1/-1; text-align:center; padding:38px; }.narrow { max-width:720px; }.project-form { display:grid; gap:16px; margin-top:20px; }.project-form label { display:grid; gap:6px; color:var(--glm-azul); font-size:.8rem; font-weight:700; text-transform:uppercase; }.project-form input { padding:10px; border:1px solid var(--glm-gris-medio); border-radius:4px; font:inherit; }.file-label span { color:var(--glm-acero); font-size:.75rem; text-transform:none; }.error { color:#c9252d; }.valid { color:var(--glm-verde); font-weight:700; } @media(max-width:640px){.projects-page{padding:20px 16px}.projects-page header{display:grid}.projects-topbar{padding:9px 16px}} +.project-form input,.project-form select { background:#fff; color:#2f3f48; border-color:#9aaab3; }.project-form select { padding:10px; border:1px solid #9aaab3; border-radius:4px; font:inherit; }.project-form input::placeholder { color:#71808a; opacity:1; }.project-form input:focus,.project-form select:focus { outline:2px solid rgba(108,194,74,.35); border-color:var(--glm-verde); background:#fff; color:#1f2d34; }.project-form input[type=file] { color:#2f3f48; background:#fff; cursor:pointer; } +.topbar-actions { display:flex; align-items:center; gap:8px; margin-left:auto; }.topbar-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-azul); cursor:pointer; font:inherit; font-size:.82rem; font-weight:700; padding:8px 12px; }.topbar-actions button:hover { background:var(--glm-verde-bg); border-color:var(--glm-verde); }.manage-employees { display:inline-flex; align-items:center; gap:6px; margin-bottom:14px; border:1px solid var(--glm-gris-medio); border-radius:4px; padding:8px 12px; color:var(--glm-azul); font-weight:700; text-decoration:none; background:#fff; }.manage-employees:hover { background:var(--glm-verde-bg); border-color:var(--glm-verde); } +.user-profile { display:flex; align-items:center; gap:8px; margin-left:auto; color:var(--glm-azul); font-size:.84rem; font-weight:700; }.user-profile img,.avatar-fallback { width:30px; height:30px; border-radius:50%; }.user-profile img { object-fit:cover; }.avatar-fallback { display:inline-flex; align-items:center; justify-content:center; background:var(--glm-verde); color:#fff; } +.project-card-actions { display:flex; gap:8px; margin-top:auto; }.project-card-actions button { margin-top:0; }.project-card-actions .danger { border-color:#c9252d; background:#fff; color:#c9252d; }.project-card-actions .danger:hover:not(:disabled) { background:#c9252d; color:#fff; }.project-card-actions button:disabled { cursor:not-allowed; opacity:.65; } diff --git a/route-planner/src/app/public/login/login.component.html b/route-planner/src/app/public/login/login.component.html index 9fa26f2..05c8549 100644 --- a/route-planner/src/app/public/login/login.component.html +++ b/route-planner/src/app/public/login/login.component.html @@ -15,7 +15,7 @@

Solo cuentas @gomezleemarketing.com

diff --git a/route-planner/src/app/public/login/login.component.ts b/route-planner/src/app/public/login/login.component.ts index 726f097..b03a293 100644 --- a/route-planner/src/app/public/login/login.component.ts +++ b/route-planner/src/app/public/login/login.component.ts @@ -2,6 +2,7 @@ import { Component, ChangeDetectionStrategy, ChangeDetectorRef, OnInit } from '@ import { CommonModule } from '@angular/common'; import { Router } from '@angular/router'; import { AuthService } from '../../core/services/auth.service'; +import { UserProfileService } from '../../core/services/user-profile.service'; @Component({ standalone: true, @@ -15,16 +16,31 @@ export class LoginComponent implements OnInit { loading = false; error = ''; + loadingText = 'Redirigiendo a Google...'; - constructor(private router: Router, private auth: AuthService, private cdr: ChangeDetectorRef) {} + constructor(private router: Router, private auth: AuthService, private profile: UserProfileService, private cdr: ChangeDetectorRef) {} async ngOnInit(): Promise { - if (await this.auth.getSession()) await this.router.navigate(['/home']); + const session = await this.auth.getSession(); + if (!session) return; + this.loading = true; + this.loadingText = 'Obteniendo datos del usuario...'; + try { + await this.profile.load(session.user.email ?? '', String(session.user.user_metadata?.['avatar_url'] ?? '')); + await this.router.navigate(['/home']); + } catch (error: any) { + await this.auth.signOut(); + this.error = error?.message ?? 'No se pudieron obtener los datos del usuario.'; + } finally { + this.loading = false; + this.cdr.detectChanges(); + } } async loginWithGoogle() { this.error = ''; this.loading = true; + this.loadingText = 'Redirigiendo a Google...'; try { const error = await this.auth.signInWithGoogle(); if (error) { diff --git a/route-planner/src/app/shared/schemas/usuario.schema.ts b/route-planner/src/app/shared/schemas/usuario.schema.ts index eb1d505..62b0f77 100644 --- a/route-planner/src/app/shared/schemas/usuario.schema.ts +++ b/route-planner/src/app/shared/schemas/usuario.schema.ts @@ -36,4 +36,4 @@ export const usuarioRowSchema = z.object({ }); export type UsuarioRow = z.infer; -export const USUARIO_MAX_ROWS = 50; +export const USUARIO_MAX_ROWS = 300; diff --git a/route-planner/src/environments/environment.prod.ts b/route-planner/src/environments/environment.prod.ts index f256b6f..b44fdef 100644 --- a/route-planner/src/environments/environment.prod.ts +++ b/route-planner/src/environments/environment.prod.ts @@ -1,8 +1,10 @@ export const environment = { production: true, - n8nWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/calcular-camino', + n8nWebhookUrl: 'http://localhost:5678/webhook/calcular-camino', orsBaseUrl: 'http://localhost:18080/ors/v2', - requestTimeoutMs: 30000, + orsMexicoBaseUrl: 'http://localhost:18081/ors/v2', + requestTimeoutMs: 180000, supabaseUrl: 'https://dbit.digitalcompass.agency', + userProfileWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/19486044-4dbd-4751-87ae-f6664148bb8a', supabaseAnonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE', }; diff --git a/route-planner/src/environments/environment.ts b/route-planner/src/environments/environment.ts index 05faf30..c2840c1 100644 --- a/route-planner/src/environments/environment.ts +++ b/route-planner/src/environments/environment.ts @@ -1,8 +1,10 @@ export const environment = { production: false, - n8nWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/calcular-camino', + n8nWebhookUrl: 'http://localhost:5678/webhook/calcular-camino', orsBaseUrl: 'http://localhost:18080/ors/v2', - requestTimeoutMs: 30000, + orsMexicoBaseUrl: 'http://localhost:18081/ors/v2', + requestTimeoutMs: 180000, supabaseUrl: 'https://dbit.digitalcompass.agency', + userProfileWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/19486044-4dbd-4751-87ae-f6664148bb8a', supabaseAnonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE', };