//! REST API for Vehicle Routing Problem. //! //! Provides endpoints for: //! - Demo data retrieval //! - Route plan management (create, get, stop) //! - Route geometry for map visualization //! - Swagger UI at /q/swagger-ui use axum::{ body::Body, extract::{Path, State}, http::{header, StatusCode}, response::{IntoResponse, Response}, routing::{delete, get, post, put}, Json, Router, }; use chrono::{NaiveDateTime, NaiveTime}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; use tower_http::cors::{Any, CorsLayer}; use utoipa::{OpenApi, ToSchema}; use utoipa_swagger_ui::SwaggerUi; use uuid::Uuid; use crate::demo_data::{available_datasets, generate_by_name}; use crate::domain::{Vehicle, VehicleRoutePlan, Visit}; use crate::geometry::{encode_routes, EncodedSegment}; use crate::solver::{SolverConfig, SolverService, SolverStatus}; use solverforge::prelude::HardSoftScore; use std::time::Duration; // ============================================================================ // Date/Time Utilities // ============================================================================ /// Reference date for time calculations (matches Python frontend). const BASE_DATE: &str = "2025-01-05"; /// Converts seconds from midnight to ISO datetime string. /// /// # Examples /// /// ``` /// use vehicle_routing::api::seconds_to_iso; /// /// assert_eq!(seconds_to_iso(0), "2025-01-05T00:00:00"); /// assert_eq!(seconds_to_iso(8 * 3600), "2025-01-05T08:00:00"); /// assert_eq!(seconds_to_iso(8 * 3600 + 30 * 60 + 45), "2025-01-05T08:30:45"); /// ``` pub fn seconds_to_iso(seconds: i64) -> String { let hours = (seconds / 3600) % 24; let mins = (seconds % 3600) / 60; let secs = seconds % 60; format!("{}T{:02}:{:02}:{:02}", BASE_DATE, hours, mins, secs) } /// Parses ISO datetime string to seconds from midnight. /// /// # Examples /// /// ``` /// use vehicle_routing::api::iso_to_seconds; /// /// assert_eq!(iso_to_seconds("2025-01-05T08:00:00"), 8 * 3600); /// assert_eq!(iso_to_seconds("2025-01-05T08:30:45"), 8 * 3600 + 30 * 60 + 45); /// ``` pub fn iso_to_seconds(iso: &str) -> i64 { if let Ok(dt) = NaiveDateTime::parse_from_str(iso, "%Y-%m-%dT%H:%M:%S") { let midnight = NaiveDateTime::new(dt.date(), NaiveTime::from_hms_opt(0, 0, 0).unwrap()); (dt - midnight).num_seconds() } else { 0 } } /// Application state shared across handlers. pub struct AppState { pub solver: SolverService, } impl AppState { pub fn new() -> Self { Self { solver: SolverService::new(), } } } impl Default for AppState { fn default() -> Self { Self::new() } } /// Creates the API router with CORS and Swagger UI enabled. pub fn create_router() -> Router { let state = Arc::new(AppState::new()); let cors = CorsLayer::new() .allow_origin(Any) .allow_methods(Any) .allow_headers(Any); Router::new() // Health & Info .route("/health", get(health)) .route("/info", get(info)) // Demo data .route("/demo-data", get(list_demo_data)) .route("/demo-data/{name}", get(get_demo_data)) .route("/demo-data/{name}/stream", get(get_demo_data_stream)) // Route plans .route("/route-plans", post(create_route_plan)) .route("/route-plans", get(list_route_plans)) .route("/route-plans/{id}", get(get_route_plan)) .route("/route-plans/{id}/status", get(get_route_plan_status)) .route("/route-plans/{id}", delete(stop_solving)) .route("/route-plans/{id}/geometry", get(get_route_geometry)) // Analysis and recommendations .route("/route-plans/analyze", put(analyze_route_plan)) .route("/route-plans/recommendation", post(recommend_assignment)) .route("/route-plans/recommendation/apply", post(apply_recommendation)) // Swagger UI at /q/swagger-ui (Quarkus-style path) .merge(SwaggerUi::new("/q/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi())) .layer(cors) .with_state(state) } // ============================================================================ // Health & Info // ============================================================================ /// Health check response. #[derive(Debug, Serialize, ToSchema)] pub struct HealthResponse { /// Status indicator ("UP" when healthy). pub status: &'static str, } /// GET /health - Health check endpoint. #[utoipa::path( get, path = "/health", responses((status = 200, description = "Service is healthy", body = HealthResponse)) )] async fn health() -> Json { Json(HealthResponse { status: "UP" }) } /// Application info response. #[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct InfoResponse { /// Application name. pub name: &'static str, /// Application version. pub version: &'static str, /// Solver engine name. pub solver_engine: &'static str, } /// GET /info - Application info endpoint. #[utoipa::path( get, path = "/info", responses((status = 200, description = "Application info", body = InfoResponse)) )] async fn info() -> Json { Json(InfoResponse { name: "Vehicle Routing", version: env!("CARGO_PKG_VERSION"), solver_engine: "SolverForge-RS", }) } // ============================================================================ // Demo Data // ============================================================================ /// GET /demo-data - List available demo datasets. #[utoipa::path( get, path = "/demo-data", responses((status = 200, description = "List of demo dataset names", body = Vec)) )] async fn list_demo_data() -> Json> { Json(available_datasets().to_vec()) } /// GET /demo-data/{name} - Get a specific demo dataset. #[utoipa::path( get, path = "/demo-data/{name}", params(("name" = String, Path, description = "Demo dataset name")), responses( (status = 200, description = "Demo data retrieved", body = RoutePlanDto), (status = 404, description = "Dataset not found") ) )] async fn get_demo_data(Path(name): Path) -> Result, StatusCode> { match generate_by_name(&name) { Some(plan) => Ok(Json(RoutePlanDto::from_plan(&plan, None))), None => Err(StatusCode::NOT_FOUND), } } /// GET /demo-data/{name}/stream - Get demo data with SSE progress updates. /// /// Returns Server-Sent Events (SSE) stream with progress and final solution. /// Downloads OSM road network and computes real driving times. /// Compatible with frontend's EventSource API. /// /// Progress phases: /// - `network` (0-15%): Loading road network from cache or downloading /// - `matrix` (15-75%): Computing travel time matrix (Dijkstra per location) /// - `geometry` (75-95%): Computing route geometries for visualization /// - `complete` (100%): Ready async fn get_demo_data_stream(Path(name): Path) -> impl IntoResponse { use crate::routing::{BoundingBox, RoadNetwork}; // Generate the demo data let mut plan = match generate_by_name(&name) { Some(p) => p, None => { let error = r#"data: {"event":"error","message":"Demo data not found"}"#; return Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "text/event-stream") .header(header::CACHE_CONTROL, "no-cache") .body(Body::from(format!("{}\n\n", error))) .unwrap(); } }; // Build bounding box from plan let bbox = BoundingBox::new( plan.south_west_corner[0], plan.south_west_corner[1], plan.north_east_corner[0], plan.north_east_corner[1], ) .expand(0.05); // Extract coordinates for routing let coords: Vec<(f64, f64)> = plan .locations .iter() .map(|l| (l.latitude, l.longitude)) .collect(); let n = coords.len(); // Build SSE stream with granular progress let stream = async_stream::stream! { // Phase 1: Network loading (0-15%) yield Ok::<_, std::convert::Infallible>( format!("data: {{\"event\":\"progress\",\"phase\":\"network\",\"message\":\"Loading road network...\",\"percent\":5,\"detail\":\"{} locations\"}}\n\n", n) ); let network = match RoadNetwork::load_or_fetch(&bbox).await { Ok(net) => { yield Ok(format!( "data: {{\"event\":\"progress\",\"phase\":\"network\",\"message\":\"Road network ready\",\"percent\":15,\"detail\":\"{} nodes, {} edges\"}}\n\n", net.node_count(), net.edge_count() )); net } Err(e) => { tracing::warn!("Road routing failed, using haversine: {}", e); plan.finalize(); yield Ok("data: {\"event\":\"progress\",\"phase\":\"fallback\",\"message\":\"Using straight-line distances\",\"percent\":95}\n\n".to_string()); // Build response DTO and complete let dto = RoutePlanDto::from_plan(&plan, None); let solution_json = serde_json::to_string(&dto).unwrap_or_else(|_| "{}".to_string()); yield Ok(format!( "data: {{\"event\":\"progress\",\"phase\":\"complete\",\"message\":\"Ready!\",\"percent\":100}}\n\n\ data: {{\"event\":\"complete\",\"solution\":{}}}\n\n", solution_json )); return; } }; // Phase 2: Matrix computation (15-75%) via channel for real-time progress let (matrix_tx, mut matrix_rx) = tokio::sync::mpsc::unbounded_channel::<(usize, usize)>(); let network_for_matrix = std::sync::Arc::clone(&network); let coords_for_matrix = coords.clone(); let matrix_handle = tokio::task::spawn_blocking(move || { network_for_matrix.compute_matrix_with_progress(&coords_for_matrix, |row, total| { let _ = matrix_tx.send((row, total)); }) }); // Stream matrix progress while let Some((row, total)) = matrix_rx.recv().await { // Progress from 15% to 75% (60% range) let pct = 15 + (row + 1) * 60 / total; yield Ok(format!( "data: {{\"event\":\"progress\",\"phase\":\"matrix\",\"message\":\"Computing routes\",\"percent\":{},\"detail\":\"{}/{} locations\"}}\n\n", pct, row + 1, total )); } // Get matrix result let matrix = match matrix_handle.await { Ok(m) => m, Err(e) => { tracing::error!("Matrix computation failed: {}", e); plan.finalize(); let dto = RoutePlanDto::from_plan(&plan, None); let solution_json = serde_json::to_string(&dto).unwrap_or_else(|_| "{}".to_string()); yield Ok(format!( "data: {{\"event\":\"progress\",\"phase\":\"complete\",\"message\":\"Ready (fallback)\",\"percent\":100}}\n\n\ data: {{\"event\":\"complete\",\"solution\":{}}}\n\n", solution_json )); return; } }; plan.travel_time_matrix = matrix; // Phase 3: Geometry computation (75-95%) via channel let (geo_tx, mut geo_rx) = tokio::sync::mpsc::unbounded_channel::<(usize, usize)>(); let network_for_geo = std::sync::Arc::clone(&network); let coords_for_geo = coords.clone(); let geo_handle = tokio::task::spawn_blocking(move || { network_for_geo.compute_all_geometries_with_progress(&coords_for_geo, |row, total| { let _ = geo_tx.send((row, total)); }) }); // Stream geometry progress while let Some((row, total)) = geo_rx.recv().await { // Progress from 75% to 95% (20% range) let pct = 75 + (row + 1) * 20 / total; yield Ok(format!( "data: {{\"event\":\"progress\",\"phase\":\"geometry\",\"message\":\"Generating routes\",\"percent\":{},\"detail\":\"{}/{} paths\"}}\n\n", pct, row + 1, total )); } // Get geometry result let geometries = match geo_handle.await { Ok(g) => g, Err(e) => { tracing::error!("Geometry computation failed: {}", e); std::collections::HashMap::new() } }; plan.route_geometries = geometries; // Build response DTO let dto = RoutePlanDto::from_plan(&plan, None); let solution_json = serde_json::to_string(&dto).unwrap_or_else(|_| "{}".to_string()); // Complete (100%) yield Ok(format!( "data: {{\"event\":\"progress\",\"phase\":\"complete\",\"message\":\"Ready!\",\"percent\":100}}\n\n\ data: {{\"event\":\"complete\",\"solution\":{}}}\n\n", solution_json )); }; let body = Body::from_stream(stream); Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "text/event-stream") .header(header::CACHE_CONTROL, "no-cache") .header(header::CONNECTION, "keep-alive") .body(body) .unwrap() } // ============================================================================ // DTOs (Python API Compatible) // ============================================================================ /// Visit DTO matching Python API structure. /// /// All times are ISO datetime strings (e.g., "2025-01-05T08:30:00"). /// Location is `[latitude, longitude]` array. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct VisitDto { /// Unique visit identifier. pub id: String, /// Customer name. pub name: String, /// Location as `[latitude, longitude]`. pub location: [f64; 2], /// Quantity demanded. pub demand: i32, /// Earliest service start time (ISO datetime). pub min_start_time: String, /// Latest service end time (ISO datetime). pub max_end_time: String, /// Service duration in seconds. pub service_duration: i32, /// Assigned vehicle ID (null if unassigned). #[serde(skip_serializing_if = "Option::is_none")] pub vehicle: Option, /// Previous visit in route (null if first or unassigned). #[serde(skip_serializing_if = "Option::is_none")] pub previous_visit: Option, /// Next visit in route (null if last or unassigned). #[serde(skip_serializing_if = "Option::is_none")] pub next_visit: Option, /// Arrival time at visit (ISO datetime). #[serde(skip_serializing_if = "Option::is_none")] pub arrival_time: Option, /// Service start time (ISO datetime). #[serde(skip_serializing_if = "Option::is_none")] pub start_service_time: Option, /// Departure time from visit (ISO datetime). #[serde(skip_serializing_if = "Option::is_none")] pub departure_time: Option, /// Driving time from previous stop in seconds. #[serde(skip_serializing_if = "Option::is_none")] pub driving_time_seconds_from_previous_standstill: Option, } /// Vehicle DTO matching Python API structure. /// /// Visits are referenced by ID only; full visit data is in the plan's `visits` array. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct VehicleDto { /// Unique vehicle identifier. pub id: String, /// Vehicle name for display. pub name: String, /// Maximum capacity. pub capacity: i32, /// Home depot location as `[latitude, longitude]`. pub home_location: [f64; 2], /// Departure time from depot (ISO datetime). pub departure_time: String, /// Visit IDs in route order. pub visits: Vec, /// Total demand of assigned visits. pub total_demand: i32, /// Total driving time in seconds. pub total_driving_time_seconds: i32, /// Arrival time back at depot (ISO datetime). pub arrival_time: String, } /// Termination configuration for the solver. /// /// Supports multiple termination conditions that combine with OR logic. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Default)] #[serde(rename_all = "camelCase")] pub struct TerminationConfigDto { /// Stop after this many seconds. #[serde(skip_serializing_if = "Option::is_none")] pub seconds_spent_limit: Option, /// Stop after this many seconds without improvement. #[serde(skip_serializing_if = "Option::is_none")] pub unimproved_seconds_spent_limit: Option, /// Stop after this many steps. #[serde(skip_serializing_if = "Option::is_none")] pub step_count_limit: Option, /// Stop after this many steps without improvement. #[serde(skip_serializing_if = "Option::is_none")] pub unimproved_step_count_limit: Option, } /// Full route plan DTO matching Python API structure. /// /// Contains ALL visits in a flat list; assignment is indicated by `vehicle` field. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct RoutePlanDto { /// Problem name. pub name: String, /// South-west corner of bounding box as `[latitude, longitude]`. pub south_west_corner: [f64; 2], /// North-east corner of bounding box as `[latitude, longitude]`. pub north_east_corner: [f64; 2], /// Earliest vehicle departure time (ISO datetime). #[serde(skip_serializing_if = "Option::is_none")] pub start_date_time: Option, /// Latest vehicle arrival time (ISO datetime). #[serde(skip_serializing_if = "Option::is_none")] pub end_date_time: Option, /// Total driving time across all vehicles in seconds. pub total_driving_time_seconds: i32, /// All vehicles. pub vehicles: Vec, /// All visits (assigned and unassigned). pub visits: Vec, /// Current score (e.g., "0hard/-14400soft"). #[serde(skip_serializing_if = "Option::is_none")] pub score: Option, /// Solver status ("NOT_SOLVING", "SOLVING_ACTIVE", etc.). #[serde(skip_serializing_if = "Option::is_none")] pub solver_status: Option, /// Termination configuration. #[serde(skip_serializing_if = "Option::is_none")] pub termination: Option, /// Precomputed travel time matrix (optional, from real roads). /// Row/column order: depot locations first, then visit locations. #[serde(skip_serializing_if = "Option::is_none")] pub travel_time_matrix: Option>>, } impl RoutePlanDto { /// Converts domain model to DTO for API responses. /// /// Builds flat visit list with vehicle assignments and timing info. pub fn from_plan(plan: &VehicleRoutePlan, status: Option) -> Self { // Build vehicle ID lookup: visit_idx -> (vehicle_id, position in route) let mut visit_vehicle: HashMap = HashMap::new(); for v in &plan.vehicles { for (pos, &visit_idx) in v.visits.iter().enumerate() { visit_vehicle.insert(visit_idx, (v.id.to_string(), pos)); } } // Build visit ID lookup for next/previous references let visit_id = |idx: usize| -> String { format!("v{}", idx) }; // Calculate timing for all vehicles let mut visit_timings: HashMap = HashMap::new(); // (arrival, service_start, departure, driving_time) for v in &plan.vehicles { let timings = plan.calculate_route_times(v); let mut prev_loc = v.home_location.index; for timing in timings.iter() { let driving_time = plan.travel_time(prev_loc, plan.visits[timing.visit_idx].location.index); let service_start = timing.arrival.max(plan.visits[timing.visit_idx].min_start_time); visit_timings.insert( timing.visit_idx, (timing.arrival, service_start, timing.departure, driving_time as i32), ); prev_loc = plan.visits[timing.visit_idx].location.index; } } // Build ALL visits with assignment info let visits: Vec = plan .visits .iter() .filter_map(|visit| { let loc = plan.locations.get(visit.location.index)?; let (vehicle_id, vehicle_pos) = visit_vehicle.get(&visit.index).cloned().unzip(); let vehicle_for_visit = vehicle_id.as_ref().and_then(|vid| { plan.vehicles.iter().find(|v| v.id.to_string() == *vid) }); // Get previous/next visit IDs let (prev_visit, next_visit) = if let (Some(v), Some(pos)) = (vehicle_for_visit, vehicle_pos) { let prev = if pos > 0 { Some(visit_id(v.visits[pos - 1])) } else { None }; let next = if pos + 1 < v.visits.len() { Some(visit_id(v.visits[pos + 1])) } else { None }; (prev, next) } else { (None, None) }; let timing = visit_timings.get(&visit.index); Some(VisitDto { id: visit_id(visit.index), name: visit.name.clone(), location: [loc.latitude, loc.longitude], demand: visit.demand, min_start_time: seconds_to_iso(visit.min_start_time), max_end_time: seconds_to_iso(visit.max_end_time), service_duration: visit.service_duration as i32, vehicle: vehicle_id, previous_visit: prev_visit, next_visit, arrival_time: timing.map(|t| seconds_to_iso(t.0)), start_service_time: timing.map(|t| seconds_to_iso(t.1)), departure_time: timing.map(|t| seconds_to_iso(t.2)), driving_time_seconds_from_previous_standstill: timing.map(|t| t.3), }) }) .collect(); // Build vehicles with visit ID references let vehicles: Vec = plan .vehicles .iter() .map(|v| { let home_loc = plan .locations .get(v.home_location.index) .map(|l| [l.latitude, l.longitude]) .unwrap_or([0.0, 0.0]); let total_driving = plan.total_driving_time(v); let route_times = plan.calculate_route_times(v); // Calculate arrival time back at depot let arrival = if v.visits.is_empty() { v.departure_time } else if let Some(last_timing) = route_times.last() { let last_visit = &plan.visits[last_timing.visit_idx]; let return_travel = plan.travel_time(last_visit.location.index, v.home_location.index); last_timing.departure + return_travel } else { v.departure_time }; // Compute total demand by summing visit demands let total_demand: i32 = v .visits .iter() .filter_map(|&idx| plan.visits.get(idx)) .map(|visit| visit.demand) .sum(); VehicleDto { id: v.id.to_string(), name: v.name.clone(), capacity: v.capacity, home_location: home_loc, departure_time: seconds_to_iso(v.departure_time), visits: v.visits.iter().map(|&idx| visit_id(idx)).collect(), total_demand, total_driving_time_seconds: total_driving as i32, arrival_time: seconds_to_iso(arrival), } }) .collect(); // Calculate plan-level times let start_dt = plan.vehicles.iter().map(|v| v.departure_time).min(); let end_dt = vehicles.iter().map(|v| iso_to_seconds(&v.arrival_time)).max(); Self { name: plan.name.clone(), south_west_corner: plan.south_west_corner, north_east_corner: plan.north_east_corner, start_date_time: start_dt.map(seconds_to_iso), end_date_time: end_dt.map(seconds_to_iso), total_driving_time_seconds: plan.total_driving_time_all() as i32, vehicles, visits, score: plan.score.map(|s| format!("{}", s)), solver_status: status.map(|s| s.as_str().to_string()), termination: None, travel_time_matrix: if plan.travel_time_matrix.is_empty() { None } else { Some(plan.travel_time_matrix.clone()) }, } } /// Converts DTO to domain model for solving. pub fn to_domain(&self) -> VehicleRoutePlan { use crate::domain::Location; // Build locations (depots first, then visit locations) let mut locations = Vec::new(); let mut depot_indices: HashMap<(i64, i64), usize> = HashMap::new(); // Add unique depot locations for vdto in &self.vehicles { let key = ( (vdto.home_location[0] * 1e6) as i64, (vdto.home_location[1] * 1e6) as i64, ); depot_indices.entry(key).or_insert_with(|| { let idx = locations.len(); locations.push(Location::new(idx, vdto.home_location[0], vdto.home_location[1])); idx }); } // Build visit ID to index mapping let visit_id_to_idx: HashMap<&str, usize> = self .visits .iter() .enumerate() .map(|(i, v)| (v.id.as_str(), i)) .collect(); // Add visit locations let visit_start_idx = locations.len(); for (i, vdto) in self.visits.iter().enumerate() { locations.push(Location::new( visit_start_idx + i, vdto.location[0], vdto.location[1], )); } // Build visits - now needs Location object, not index let visits: Vec = self .visits .iter() .enumerate() .map(|(i, vdto)| { let loc = locations[visit_start_idx + i].clone(); Visit::new(i, &vdto.name, loc) .with_demand(vdto.demand) .with_time_window( iso_to_seconds(&vdto.min_start_time), iso_to_seconds(&vdto.max_end_time), ) .with_service_duration(vdto.service_duration as i64) }) .collect(); // Build vehicles - now needs Location object, not index let vehicles: Vec = self .vehicles .iter() .enumerate() .map(|(i, vdto)| { let key = ( (vdto.home_location[0] * 1e6) as i64, (vdto.home_location[1] * 1e6) as i64, ); let home_idx = depot_indices[&key]; let home_loc = locations[home_idx].clone(); // Map visit IDs to indices let visit_indices: Vec = vdto .visits .iter() .filter_map(|vid| visit_id_to_idx.get(vid.as_str()).copied()) .collect(); let mut v = Vehicle::new(i, &vdto.name, vdto.capacity, home_loc); v.departure_time = iso_to_seconds(&vdto.departure_time); v.visits = visit_indices; v }) .collect(); let mut plan = VehicleRoutePlan::new(&self.name, locations, visits, vehicles); plan.south_west_corner = self.south_west_corner; plan.north_east_corner = self.north_east_corner; // Use provided matrix (from real roads) if available, otherwise compute haversine if let Some(matrix) = &self.travel_time_matrix { plan.travel_time_matrix = matrix.clone(); } else { plan.finalize(); } plan } } // ============================================================================ // Route Plan Handlers // ============================================================================ /// POST /route-plans - Create and start solving a route plan. #[utoipa::path( post, path = "/route-plans", request_body = RoutePlanDto, responses((status = 200, description = "Job ID", body = String)) )] async fn create_route_plan( State(state): State>, Json(dto): Json, ) -> Result { let id = Uuid::new_v4().to_string(); let mut plan = dto.to_domain(); // Initialize road routing (uses cached network - instant after first download) if let Err(e) = plan.init_routing().await { tracing::error!("Road routing initialization failed: {}", e); return Err(StatusCode::SERVICE_UNAVAILABLE); } // Convert termination config from DTO let config = if let Some(term) = &dto.termination { SolverConfig { time_limit: term.seconds_spent_limit.map(Duration::from_secs), unimproved_time_limit: term.unimproved_seconds_spent_limit.map(Duration::from_secs), step_limit: term.step_count_limit, unimproved_step_limit: term.unimproved_step_count_limit, } } else { SolverConfig::default_config() }; let job = state.solver.create_job_with_config(id.clone(), plan, config); state.solver.start_solving(job); Ok(id) } /// GET /route-plans - List all route plan IDs. #[utoipa::path( get, path = "/route-plans", responses((status = 200, description = "List of job IDs", body = Vec)) )] async fn list_route_plans(State(state): State>) -> Json> { Json(state.solver.list_jobs()) } /// GET /route-plans/{id} - Get current route plan state. #[utoipa::path( get, path = "/route-plans/{id}", params(("id" = String, Path, description = "Route plan ID")), responses( (status = 200, description = "Route plan retrieved", body = RoutePlanDto), (status = 404, description = "Not found") ) )] async fn get_route_plan( State(state): State>, Path(id): Path, ) -> Result, StatusCode> { match state.solver.get_job(&id) { Some(job) => { let guard = job.read(); Ok(Json(RoutePlanDto::from_plan( &guard.plan, Some(guard.status), ))) } None => Err(StatusCode::NOT_FOUND), } } /// Status response. #[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct StatusResponse { /// Current score. pub score: Option, /// Solver status. pub solver_status: String, } /// GET /route-plans/{id}/status - Get route plan status only. #[utoipa::path( get, path = "/route-plans/{id}/status", params(("id" = String, Path, description = "Route plan ID")), responses( (status = 200, description = "Status retrieved", body = StatusResponse), (status = 404, description = "Not found") ) )] async fn get_route_plan_status( State(state): State>, Path(id): Path, ) -> Result, StatusCode> { match state.solver.get_job(&id) { Some(job) => { let guard = job.read(); Ok(Json(StatusResponse { score: guard.plan.score.map(|s| format!("{}", s)), solver_status: guard.status.as_str().to_string(), })) } None => Err(StatusCode::NOT_FOUND), } } /// DELETE /route-plans/{id} - Stop solving and get final solution. #[utoipa::path( delete, path = "/route-plans/{id}", params(("id" = String, Path, description = "Route plan ID")), responses( (status = 200, description = "Solving stopped", body = RoutePlanDto), (status = 404, description = "Not found") ) )] async fn stop_solving( State(state): State>, Path(id): Path, ) -> Result, StatusCode> { state.solver.stop_solving(&id); match state.solver.remove_job(&id) { Some(job) => { let guard = job.read(); Ok(Json(RoutePlanDto::from_plan( &guard.plan, Some(SolverStatus::NotSolving), ))) } None => Err(StatusCode::NOT_FOUND), } } /// Geometry response with encoded polylines for map rendering. #[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct GeometryResponse { /// Encoded route segments per vehicle. pub segments: Vec, } /// GET /route-plans/{id}/geometry - Get encoded polylines for routes. #[utoipa::path( get, path = "/route-plans/{id}/geometry", params(("id" = String, Path, description = "Route plan ID")), responses( (status = 200, description = "Geometry retrieved", body = GeometryResponse), (status = 404, description = "Not found") ) )] async fn get_route_geometry( State(state): State>, Path(id): Path, ) -> Result, StatusCode> { match state.solver.get_job(&id) { Some(job) => { let guard = job.read(); let segments = encode_routes(&guard.plan); Ok(Json(GeometryResponse { segments })) } None => Err(StatusCode::NOT_FOUND), } } // ============================================================================ // Score Analysis // ============================================================================ /// Match analysis for a constraint violation. #[derive(Debug, Clone, Serialize, ToSchema)] pub struct MatchAnalysisDto { /// Constraint name. pub name: String, /// Score impact of this match. pub score: String, /// Description of the match. pub justification: String, } /// Constraint analysis showing all matches. #[derive(Debug, Clone, Serialize, ToSchema)] pub struct ConstraintAnalysisDto { /// Constraint name. pub name: String, /// Constraint weight (score per violation). pub weight: String, /// Total score from this constraint. pub score: String, /// Individual matches. pub matches: Vec, } /// Response from score analysis endpoint. #[derive(Debug, Serialize, ToSchema)] pub struct AnalyzeResponse { /// Per-constraint breakdown. pub constraints: Vec, } /// PUT /route-plans/analyze - Analyze constraint violations. #[utoipa::path( put, path = "/route-plans/analyze", request_body = RoutePlanDto, responses((status = 200, description = "Constraint analysis", body = AnalyzeResponse)) )] async fn analyze_route_plan(Json(dto): Json) -> Json { use crate::constraints::{calculate_late_minutes, calculate_excess_capacity}; let plan = dto.to_domain(); // Calculate constraint scores let cap_total: i64 = plan.vehicles.iter() .map(|v| calculate_excess_capacity(&plan, v) as i64) .sum(); let tw_total: i64 = plan.vehicles.iter() .map(|v| calculate_late_minutes(&plan, v)) .sum(); let travel_total: i64 = plan.vehicles.iter() .map(|v| plan.total_driving_time(v)) .sum(); let cap_score = HardSoftScore::of_hard(-cap_total); let tw_score = HardSoftScore::of_hard(-tw_total); let travel_score = HardSoftScore::of_soft(-travel_total); // Helper to compute total demand let total_demand = |v: &Vehicle| -> i32 { v.visits.iter() .filter_map(|&idx| plan.visits.get(idx)) .map(|visit| visit.demand) .sum() }; // Build detailed matches for capacity constraint let cap_matches: Vec = plan.vehicles.iter() .filter(|v| total_demand(v) > v.capacity) .map(|v| { let demand = total_demand(v); let excess = demand - v.capacity; MatchAnalysisDto { name: "Vehicle capacity".to_string(), score: format!("{}hard/0soft", -excess), justification: format!("{} is over capacity by {} (demand {} > capacity {})", v.name, excess, demand, v.capacity), } }) .collect(); // Build detailed matches for time window constraint let mut tw_matches: Vec = Vec::new(); for vehicle in &plan.vehicles { let timings = plan.calculate_route_times(vehicle); for timing in &timings { if let Some(visit) = plan.get_visit(timing.visit_idx) { if timing.departure > visit.max_end_time { let late_secs = timing.departure - visit.max_end_time; let late_mins = (late_secs + 59) / 60; tw_matches.push(MatchAnalysisDto { name: "Service finished after max end time".to_string(), score: format!("{}hard/0soft", -late_mins), justification: format!("{} finishes {} mins late (ends at {}, max {})", visit.name, late_mins, seconds_to_iso(timing.departure), seconds_to_iso(visit.max_end_time)), }); } } } } // Build matches for travel time let travel_matches: Vec = plan.vehicles.iter() .filter(|v| !v.visits.is_empty()) .map(|v| { let time = plan.total_driving_time(v); MatchAnalysisDto { name: "Minimize travel time".to_string(), score: format!("0hard/{}soft", -time), justification: format!("{} drives {} seconds", v.name, time), } }) .collect(); let constraints = vec![ ConstraintAnalysisDto { name: "Vehicle capacity".to_string(), weight: "1hard/0soft".to_string(), score: format!("{}", cap_score), matches: cap_matches, }, ConstraintAnalysisDto { name: "Service finished after max end time".to_string(), weight: "1hard/0soft".to_string(), score: format!("{}", tw_score), matches: tw_matches, }, ConstraintAnalysisDto { name: "Minimize travel time".to_string(), weight: "0hard/1soft".to_string(), score: format!("{}", travel_score), matches: travel_matches, }, ]; Json(AnalyzeResponse { constraints }) } // ============================================================================ // Recommendation // ============================================================================ /// Recommended assignment for a visit. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct VehicleRecommendation { /// Vehicle ID to assign to. pub vehicle_id: String, /// Position in vehicle's route. pub index: usize, } /// Recommendation response with score impact. #[derive(Debug, Clone, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct RecommendedAssignment { /// The recommendation. pub proposition: VehicleRecommendation, /// Score difference if applied. pub score_diff: String, } /// Request for visit recommendations. #[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct RecommendationRequest { /// Current solution. pub solution: RoutePlanDto, /// Visit ID to find recommendations for. pub visit_id: String, } /// Request to apply a recommendation. #[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ApplyRecommendationRequest { /// Current solution. pub solution: RoutePlanDto, /// Visit ID to assign. pub visit_id: String, /// Vehicle ID to assign to. pub vehicle_id: String, /// Position in vehicle's route. pub index: usize, } /// POST /route-plans/recommendation - Get recommendations for assigning a visit. #[utoipa::path( post, path = "/route-plans/recommendation", request_body = RecommendationRequest, responses((status = 200, description = "Recommendations", body = Vec)) )] async fn recommend_assignment(Json(request): Json) -> Json> { use crate::constraints::calculate_score; let mut plan = request.solution.to_domain(); // Find the visit index by ID let visit_id_num: usize = request.visit_id.trim_start_matches('v').parse().unwrap_or(usize::MAX); if visit_id_num >= plan.visits.len() { return Json(vec![]); } // Remove visit from any current assignment for vehicle in &mut plan.vehicles { vehicle.visits.retain(|&v| v != visit_id_num); } plan.finalize(); // Get baseline score let baseline = calculate_score(&plan); // Try inserting at each position in each vehicle let mut recommendations: Vec<(RecommendedAssignment, HardSoftScore)> = Vec::new(); for (v_idx, vehicle) in plan.vehicles.iter().enumerate() { for insert_pos in 0..=vehicle.visits.len() { // Clone and insert let mut test_plan = plan.clone(); test_plan.vehicles[v_idx].visits.insert(insert_pos, visit_id_num); test_plan.finalize(); let new_score = calculate_score(&test_plan); let diff = new_score - baseline; recommendations.push(( RecommendedAssignment { proposition: VehicleRecommendation { vehicle_id: vehicle.id.to_string(), index: insert_pos, }, score_diff: format!("{}", diff), }, diff, )); } } // Sort by score (best first) and take top 5 recommendations.sort_by(|a, b| b.1.cmp(&a.1)); let top5: Vec = recommendations.into_iter().take(5).map(|(r, _)| r).collect(); Json(top5) } /// POST /route-plans/recommendation/apply - Apply a recommendation. #[utoipa::path( post, path = "/route-plans/recommendation/apply", request_body = ApplyRecommendationRequest, responses((status = 200, description = "Updated solution", body = RoutePlanDto)) )] async fn apply_recommendation(Json(request): Json) -> Json { let mut plan = request.solution.to_domain(); // Find the visit index by ID let visit_id_num: usize = request.visit_id.trim_start_matches('v').parse().unwrap_or(usize::MAX); let vehicle_id_num: usize = request.vehicle_id.parse().unwrap_or(usize::MAX); // Remove visit from any current assignment for vehicle in &mut plan.vehicles { vehicle.visits.retain(|&v| v != visit_id_num); } // Insert at specified position if let Some(vehicle) = plan.vehicles.iter_mut().find(|v| v.id == vehicle_id_num) { let insert_idx = request.index.min(vehicle.visits.len()); vehicle.visits.insert(insert_idx, visit_id_num); } plan.finalize(); // Recalculate score use crate::constraints::calculate_score; plan.score = Some(calculate_score(&plan)); Json(RoutePlanDto::from_plan(&plan, None)) } // ============================================================================ // OpenAPI Documentation // ============================================================================ #[derive(OpenApi)] #[openapi( paths( health, info, list_demo_data, get_demo_data, create_route_plan, list_route_plans, get_route_plan, get_route_plan_status, stop_solving, get_route_geometry, analyze_route_plan, recommend_assignment, apply_recommendation, ), components(schemas( HealthResponse, InfoResponse, VisitDto, VehicleDto, RoutePlanDto, TerminationConfigDto, StatusResponse, GeometryResponse, MatchAnalysisDto, ConstraintAnalysisDto, AnalyzeResponse, VehicleRecommendation, RecommendedAssignment, RecommendationRequest, ApplyRecommendationRequest, )) )] struct ApiDoc;