plansolve 0.25.0

Official Rust client library for the PlanSolve optimization API.
Documentation
use serde::{Deserialize, Serialize};

/// A `[longitude, latitude]` coordinate pair.
pub type Location = [f64; 2];

/// Request model for starting a field service optimization.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FieldServiceRequest {
    pub vehicles: Vec<Vehicle>,
    pub visits: Vec<Visit>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub weights: Option<Weights>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub options: Option<SolverOptions>,
}

/// A vehicle available for scheduling.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Vehicle {
    pub id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    pub location: Location,
    pub shifts: Vec<Shift>,
    pub skills: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub departure_time: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub visits: Vec<String>,
}

/// A visit (job/task) to be scheduled.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Visit {
    pub id: String,
    pub name: String,
    pub location: Location,
    pub time_windows: Vec<TimeWindow>,
    pub service_duration: String,
    pub priority: String,
    pub required_skills: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub pinned: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub vehicle: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub arrival_time: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub departure_time: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub start_service_time: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub driving_time_seconds_from_previous_standstill: Option<i64>,
}

/// A time window during which a vehicle is available.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Shift {
    pub id: String,
    pub min_start_time: String,
    pub max_end_time: String,
}

/// A time window for a visit.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TimeWindow {
    pub min_start_time: String,
    pub max_end_time: String,
}

/// A single constraint weight in the format `Xhard/Ymedium/Zsoft`. Only one of
/// `hard`, `medium`, or `soft` should be non-zero at a time.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub struct ConstraintWeight {
    pub hard: i64,
    pub medium: i64,
    pub soft: i64,
}

/// Configuration weights for field service optimization constraints.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Weights {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub pinned_visit_vehicle_assignment: Option<ConstraintWeight>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub pinned_visit_service_time: Option<ConstraintWeight>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub no_missing_skills: Option<ConstraintWeight>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub service_time_missing: Option<ConstraintWeight>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub vehicle_unassigned: Option<ConstraintWeight>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub prefer_high_priority: Option<ConstraintWeight>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub minimize_travel_time: Option<ConstraintWeight>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub prefer_using_idle_vehicles: Option<ConstraintWeight>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub minimize_early_arrival_wait: Option<ConstraintWeight>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub prefer_initial_vehicle_assignment: Option<ConstraintWeight>,
    // Note the capitalized JSON key, matching the other SDKs' contract.
    #[serde(
        rename = "PreferEarlierVisitDates",
        skip_serializing_if = "Option::is_none"
    )]
    pub prefer_earlier_visit_dates: Option<ConstraintWeight>,
}

/// Solver configuration options.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SolverOptions {
    /// Maximum time the solver can spend (ISO-8601 duration, e.g. `PT5M`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub spent_limit: Option<String>,
    /// Maximum time the solver can spend without improvement (ISO-8601 duration).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub unimproved_spent_limit: Option<String>,
}

/// Response from starting a field service optimization.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FieldServiceStartResponse {
    pub job_id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub result: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
    #[serde(default)]
    pub status_code: i32,
}

/// Response from getting field service optimization results.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FieldServiceResultResponse {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub job_id: Option<String>,
    pub vehicles: Vec<ScheduledVehicle>,
    pub visits: Vec<ScheduledVisit>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub score: Option<String>,
    #[serde(default)]
    pub total_driving_time_seconds: i64,
    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
    pub weights: std::collections::HashMap<String, String>,
}

/// A vehicle in the optimization result.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScheduledVehicle {
    pub id: String,
    pub location: Location,
    pub shifts: Vec<Shift>,
    pub skills: Vec<String>,
    pub visits: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub daily_return_times: Option<serde_json::Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub total_driving_time_seconds: Option<i64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub arrival_time: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub departure_time: Option<String>,
}

/// A visit in the optimization result.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScheduledVisit {
    pub id: String,
    pub name: String,
    pub location: Location,
    pub time_windows: Vec<TimeWindow>,
    pub service_duration: String,
    pub priority: String,
    pub required_skills: Vec<String>,
    pub vehicle: String,
    #[serde(default)]
    pub previous_visit: Option<String>,
    pub arrival_time: String,
    pub departure_time: String,
    pub start_service_time: String,
    #[serde(default)]
    pub driving_time_seconds_from_previous_standstill: i64,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn can_construct_request() {
        let request = FieldServiceRequest {
            vehicles: vec![Vehicle {
                id: "1".into(),
                location: [51.52, -0.1],
                shifts: vec![Shift {
                    id: "full-day".into(),
                    min_start_time: "2024-01-15T08:00:00".into(),
                    max_end_time: "2024-01-15T18:00:00".into(),
                }],
                skills: vec!["English".into()],
                ..Default::default()
            }],
            visits: vec![Visit {
                id: "1".into(),
                name: "Task 1".into(),
                location: [51.51, -0.12],
                service_duration: "PT30M".into(),
                priority: "HIGH".into(),
                required_skills: vec!["English".into()],
                ..Default::default()
            }],
            ..Default::default()
        };
        assert_eq!(request.vehicles.len(), 1);
        assert_eq!(request.visits.len(), 1);
    }

    #[test]
    fn serializes_expected_keys() {
        let json = serde_json::to_string(&FieldServiceRequest::default()).unwrap();
        assert!(json.contains("\"vehicles\""));
        assert!(json.contains("\"visits\""));
        // Optional fields with no value are omitted.
        assert!(!json.contains("\"weights\""));
    }

    #[test]
    fn weights_preserve_the_exact_contract_keys() {
        // Most weight keys are camelCase, but PreferEarlierVisitDates is
        // capitalized in the API contract — a mismatch here would silently drop
        // the weight server-side, so pin both spellings.
        let weights = Weights {
            minimize_travel_time: Some(ConstraintWeight {
                soft: 1,
                ..Default::default()
            }),
            prefer_earlier_visit_dates: Some(ConstraintWeight {
                soft: 2,
                ..Default::default()
            }),
            ..Default::default()
        };
        let json = serde_json::to_string(&weights).unwrap();
        assert!(json.contains("\"minimizeTravelTime\""));
        assert!(json.contains("\"PreferEarlierVisitDates\""));
        assert!(!json.contains("\"preferEarlierVisitDates\""));
    }

    #[test]
    fn deserializes_result_response() {
        let json = r#"{
            "vehicles": [
                {"id": "1", "location": [51.52, -0.1], "shifts": [], "skills": ["English"], "visits": ["1"]}
            ],
            "visits": [
                {
                    "id": "1", "name": "Task 1", "location": [51.51, -0.12],
                    "timeWindows": [], "serviceDuration": "PT30M", "priority": "HIGH",
                    "requiredSkills": ["English"], "vehicle": "1", "previousVisit": null,
                    "arrivalTime": "2024-01-15T10:15:33", "departureTime": "2024-01-15T10:45:33",
                    "startServiceTime": "2024-01-15T10:15:33",
                    "drivingTimeSecondsFromPreviousStandstill": 933
                }
            ],
            "totalDrivingTimeSeconds": 933
        }"#;

        let result: FieldServiceResultResponse = serde_json::from_str(json).unwrap();
        assert_eq!(result.vehicles.len(), 1);
        assert_eq!(result.visits.len(), 1);
        let v = &result.visits[0];
        assert_eq!(v.vehicle, "1");
        assert_eq!(v.arrival_time, "2024-01-15T10:15:33");
        assert_eq!(v.start_service_time, "2024-01-15T10:15:33");
        assert_eq!(v.driving_time_seconds_from_previous_standstill, 933);
        assert_eq!(result.total_driving_time_seconds, 933);
    }
}