plansolve 0.25.0

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

/// Request model for starting a shift optimization.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ShiftRequest {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub contracts: Vec<Contract>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub shifts: Vec<ShiftAssignment>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub employees: Vec<ShiftEmployee>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub options: Option<Options>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub weights: Option<Weights>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub fairness: Option<Fairness>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub day_off_requests: Vec<DayOffRequest>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub shift_off_requests: Vec<ShiftOffRequest>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub hook: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub constraint_weight_overrides: Option<ConstraintWeightOverrides>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub score: Option<serde_json::Value>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub unassigned_shifts: Vec<ShiftAssignment>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub assigned_shifts: Vec<ShiftAssignment>,
}

/// A time window for shift scheduling.
#[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 shift assignment to be scheduled.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ShiftAssignment {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub from: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub to: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub skills: Vec<String>,
    #[serde(default)]
    pub cost: f64,
    #[serde(default)]
    pub value: i64,
    #[serde(default)]
    pub priority: i64,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub desired_skills: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tags: Vec<String>,
    #[serde(default)]
    pub pinned_by_user: bool,
}

/// An employee in the shift optimization.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ShiftEmployee {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub contract: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub skills: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_rest_date: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub availability: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub preference: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub period_rules: Vec<PeriodRule>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub unavailable_dates: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tags: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub maximum_minutes_per_week: Option<i64>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub shifts: Vec<ShiftAssignment>,
}

/// A shift employee contract.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Contract {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub min: Option<String>,
    #[serde(default)]
    pub max_consecutive_work_days: i64,
    #[serde(default)]
    pub max_shifts_day: i64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub min_rest_between_shifts_same_day: Option<String>,
    #[serde(default)]
    pub max_working_days: i64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub latest_shift_start: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub earliest_shift_start: Option<String>,
    #[serde(default)]
    pub minimum_consecutive_days_off: i64,
    #[serde(default)]
    pub minimum_hours_off_between_shifts: i64,
}

/// Constraint weights for the shift optimizer.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Weights {
    pub required_skills: i64,
    pub shift_capacity: i64,
    pub minimum_staffing: i64,
    pub no_double_booking: i64,
    pub rest_between_shifts: i64,
    pub employee_availability: i64,
    pub shift_preferences: i64,
    pub cost_minimization: i64,
    pub workload_balance: i64,
    pub fairness: i64,
    pub max_consecutive_work_days: i64,
    pub max_shifts_per_day: i64,
    pub max_working_days_per_week: i64,
    pub contract_rest_between_shifts: i64,
    pub earliest_shift_start: i64,
    pub latest_shift_start: i64,
    pub minimum_consecutive_days_off: i64,
    pub period_rule_violation: i64,
    pub desired_skills: i64,
    pub desired_day_off: i64,
    pub shift_off_request: i64,
    pub balance_time_worked: i64,
    pub employee_affinity: i64,
    pub avoid_shift_close_to_day_off: i64,
}

/// Solver options for the shift optimizer.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Options {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub partial_planning: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_iterations: Option<i64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub time_limit: Option<i64>,
}

/// Fairness configuration for the shift optimizer.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Fairness {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub fairness_buckets: Vec<FairnessBucket>,
}

/// A fairness bucket grouping employees and shifts.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FairnessBucket {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub employees: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub shifts: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub period: Option<String>,
}

/// An employee's request for a day off.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DayOffRequest {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub employee_name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub date: Option<String>,
    #[serde(default)]
    pub weight: i64,
}

/// An employee's request to be off a specific shift.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ShiftOffRequest {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub employee_name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub shift_name: Option<String>,
    #[serde(default)]
    pub weight: i64,
}

/// A period-based working constraint.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PeriodRule {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub period: Option<PlanningPeriod>,
    #[serde(default)]
    pub max_working_days: i64,
    #[serde(default)]
    pub min_working_days: i64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub min_working_duration: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_working_duration: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub min_rest_duration_between_shifts_same_day: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub min_rest_duration: Option<String>,
}

/// A time period for planning rules.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlanningPeriod {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub from: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub to: Option<String>,
}

/// Overrides for constraint weights.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConstraintWeightOverrides {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub known_constraint_names: Vec<String>,
}

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

/// Response from getting shift optimization results.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ShiftResultResponse {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub job_id: Option<String>,
    pub employees: Vec<ScheduledEmployee>,
    pub tasks: Vec<ScheduledTask>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub score: Option<String>,
    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
    pub weights: std::collections::HashMap<String, String>,
}

/// An employee in the optimization result.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScheduledEmployee {
    pub id: String,
    pub shifts: Vec<Shift>,
    pub skills: Vec<String>,
    pub tasks: Vec<String>,
}

/// A task in the optimization result.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScheduledTask {
    pub id: String,
    pub name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub employee: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub start_time: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub end_time: Option<String>,
    #[serde(default)]
    pub duration: f64,
    pub priority: String,
    pub required_skills: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub deadline: Option<String>,
}

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

    #[test]
    fn serializes_and_omits_empty_collections() {
        let json = serde_json::to_string(&ShiftRequest::default()).unwrap();
        // Empty optional vectors are omitted, keeping payloads lean.
        assert!(!json.contains("\"shifts\""));
        assert!(!json.contains("\"employees\""));
    }

    #[test]
    fn serializes_populated_assignment() {
        let request = ShiftRequest {
            shifts: vec![ShiftAssignment {
                name: Some("Night".into()),
                cost: 12.5,
                priority: 3,
                pinned_by_user: true,
                ..Default::default()
            }],
            ..Default::default()
        };
        let json = serde_json::to_string(&request).unwrap();
        assert!(json.contains("\"shifts\""));
        assert!(json.contains("\"pinnedByUser\":true"));
        assert!(json.contains("\"cost\":12.5"));
    }

    #[test]
    fn deserializes_result_response() {
        let json = r#"{
            "employees": [
                {"id": "e1", "shifts": [], "skills": ["nurse"], "tasks": ["t1"]}
            ],
            "tasks": [
                {"id": "t1", "name": "Ward A", "employee": "e1",
                 "startTime": "2024-01-15T22:00:00", "endTime": "2024-01-16T06:00:00",
                 "duration": 8.0, "priority": "HIGH", "requiredSkills": ["nurse"]}
            ],
            "score": "0hard/0medium/0soft"
        }"#;

        let result: ShiftResultResponse = serde_json::from_str(json).unwrap();
        assert_eq!(result.employees.len(), 1);
        assert_eq!(result.tasks[0].employee.as_deref(), Some("e1"));
        assert_eq!(
            result.tasks[0].start_time.as_deref(),
            Some("2024-01-15T22:00:00")
        );
        assert_eq!(result.tasks[0].duration, 8.0);
    }
}