plansolve 0.25.8

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

/// A `[latitude, longitude]` 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>,
    /// Constraint weights as score-notation strings keyed by constraint name, e.g.
    /// `{"minimizeTravelTime": "0hard/0medium/1soft"}`. The server models this as an
    /// open map (`additionalProperties: string`), so any constraint key and score
    /// string passes through unchanged — do not narrow it to a fixed struct.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub weights: Option<std::collections::HashMap<String, String>>,
    #[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,
}

/// 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>,
    /// Service time in seconds. The solver serializes its `Duration` as a number
    /// (Jackson default), so the result carries seconds — not the ISO-8601 string the
    /// request uses. Matches the .NET SDK's `ScheduledVisit.ServiceDuration` (double).
    #[serde(default)]
    pub service_duration: f64,
    pub priority: String,
    pub required_skills: Vec<String>,
    // Assignment fields are null for a visit the solver could not schedule (e.g. an
    // unassigned visit when the solve is time-boxed). Nullable in the .NET reference
    // (C# strings), so they are `Option` here.
    #[serde(default)]
    pub vehicle: Option<String>,
    #[serde(default)]
    pub previous_visit: Option<String>,
    #[serde(default)]
    pub arrival_time: Option<String>,
    #[serde(default)]
    pub departure_time: Option<String>,
    #[serde(default)]
    pub start_service_time: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub min_start_time: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_end_time: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub previous_visit_same_day: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub is_day_head: Option<bool>,
    #[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_accept_score_strings_and_arbitrary_keys() {
        // The server models weights as an open map of constraint-name -> score
        // string ("Xhard/Ymedium/Zsoft"). Any key (camelCase or the capitalized
        // PreferEarlierVisitDates) and any score string must round-trip unchanged.
        let json = r#"{
            "vehicles": [],
            "visits": [],
            "weights": {
                "minimizeTravelTime": "0hard/0medium/1soft",
                "noMissingSkills": "1hard/0medium/0soft",
                "PreferEarlierVisitDates": "0hard/0medium/20soft"
            }
        }"#;

        let request: FieldServiceRequest = serde_json::from_str(json).unwrap();
        let weights = request.weights.as_ref().expect("weights present");
        assert_eq!(weights["minimizeTravelTime"], "0hard/0medium/1soft");
        assert_eq!(weights["PreferEarlierVisitDates"], "0hard/0medium/20soft");

        // Round-trips back out as string values (not nested objects).
        let out = serde_json::to_string(&request).unwrap();
        assert!(out.contains("\"minimizeTravelTime\":\"0hard/0medium/1soft\""));
    }

    #[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": 1800.0, "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.as_deref(), Some("1"));
        assert_eq!(v.arrival_time.as_deref(), Some("2024-01-15T10:15:33"));
        assert_eq!(v.start_service_time.as_deref(), Some("2024-01-15T10:15:33"));
        assert_eq!(v.driving_time_seconds_from_previous_standstill, 933);
        assert_eq!(result.total_driving_time_seconds, 933);
    }

    #[test]
    fn service_duration_is_numeric_seconds_in_results() {
        // The solver serializes its Duration as a number of seconds (Jackson default),
        // e.g. 3600.0 for a one-hour service — the result field is f64, not a string.
        let json = r#"{
            "vehicles": [],
            "visits": [
                {
                    "id": "1", "name": "Task 1", "location": [51.51, -0.12],
                    "timeWindows": [], "serviceDuration": 3600.0, "priority": "HIGH",
                    "requiredSkills": [], "vehicle": "1", "previousVisit": null,
                    "arrivalTime": "t", "departureTime": "t", "startServiceTime": "t",
                    "isDayHead": true, "previousVisitSameDay": null,
                    "drivingTimeSecondsFromPreviousStandstill": 0
                }
            ],
            "totalDrivingTimeSeconds": 0
        }"#;

        let result: FieldServiceResultResponse = serde_json::from_str(json).unwrap();
        assert_eq!(result.visits[0].service_duration, 3600.0);
        assert_eq!(result.visits[0].is_day_head, Some(true));
    }

    #[test]
    fn unassigned_visit_has_null_assignment_fields() {
        // A visit the solver couldn't schedule (e.g. a time-boxed solve) comes back with
        // null vehicle/arrival/departure/startService — must decode, not error.
        let json = r#"{
            "vehicles": [],
            "visits": [
                {
                    "id": "VISIT_010", "name": "unassigned", "location": [50.7, 3.6],
                    "timeWindows": [], "serviceDuration": 7200, "priority": "LOW",
                    "requiredSkills": [], "vehicle": null, "previousVisit": null,
                    "arrivalTime": null, "departureTime": null, "startServiceTime": null,
                    "minStartTime": null, "maxEndTime": null, "isDayHead": false,
                    "drivingTimeSecondsFromPreviousStandstill": 0
                }
            ],
            "totalDrivingTimeSeconds": 0
        }"#;

        let result: FieldServiceResultResponse = serde_json::from_str(json).unwrap();
        let v = &result.visits[0];
        assert_eq!(v.vehicle, None);
        assert_eq!(v.arrival_time, None);
        assert_eq!(v.start_service_time, None);
        assert_eq!(v.service_duration, 7200.0);
    }
}