use serde::{Deserialize, Serialize};
#[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>,
}
#[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,
}
#[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,
}
#[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>,
}
#[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,
}
#[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,
}
#[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>,
}
#[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>,
}
#[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>,
}
#[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,
}
#[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,
}
#[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>,
}
#[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>,
}
#[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>,
}
#[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>,
}
#[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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, 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)]
pub employees: Vec<ShiftEmployee>,
#[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(default, skip_serializing_if = "Option::is_none")]
pub options: Option<Options>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub weights: Option<Weights>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fairness: Option<Fairness>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hook: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub constraint_weight_overrides: Option<ConstraintWeightOverrides>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub feasible: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub score_string: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub score: Option<serde_json::Value>,
#[serde(default)]
pub unassigned_shifts: Vec<ShiftAssignment>,
#[serde(default)]
pub assigned_shifts: Vec<ShiftAssignment>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serializes_and_omits_empty_collections() {
let json = serde_json::to_string(&ShiftRequest::default()).unwrap();
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 request_omits_result_only_fields() {
let json = serde_json::to_string(&ShiftRequest::default()).unwrap();
assert!(!json.contains("\"score\""));
assert!(!json.contains("\"assignedShifts\""));
assert!(!json.contains("\"unassignedShifts\""));
}
#[test]
fn deserializes_result_response() {
let json = r#"{
"id": "roster-1",
"name": "January roster",
"description": "night ward",
"contracts": [{"name": "full-time", "max": "PT40H"}],
"shifts": [
{"name": "Ward A", "from": "2024-01-15T22:00:00", "to": "2024-01-16T06:00:00"}
],
"dayOffRequests": [{"employeeName": "Alice", "date": "2024-01-20", "weight": 5}],
"shiftOffRequests": [{"employeeName": "Alice", "shiftName": "Ward B", "weight": 3}],
"options": {"partialPlanning": true},
"hook": "https://example.test/hook",
"feasible": true,
"scoreString": "0hard/0medium/-45soft",
"score": {"hard": 0, "medium": 0, "soft": -45},
"assignedShifts": [
{"name": "Ward A", "from": "2024-01-15T22:00:00", "to": "2024-01-16T06:00:00",
"skills": ["nurse"], "cost": 12.5, "value": 2, "priority": 3}
],
"unassignedShifts": [
{"name": "Ward B", "from": "2024-01-16T22:00:00", "to": "2024-01-17T06:00:00",
"skills": ["nurse", "first_aid"]}
],
"employees": [
{"name": "Alice", "skills": ["nurse"], "shifts": [
{"name": "Ward A", "from": "2024-01-15T22:00:00", "to": "2024-01-16T06:00:00"}
]}
]
}"#;
let result: ShiftResultResponse = serde_json::from_str(json).unwrap();
assert_eq!(result.id.as_deref(), Some("roster-1"));
assert_eq!(result.name.as_deref(), Some("January roster"));
assert_eq!(result.contracts.len(), 1);
assert_eq!(result.shifts.len(), 1);
assert_eq!(result.day_off_requests.len(), 1);
assert_eq!(result.shift_off_requests.len(), 1);
assert_eq!(
result.options.as_ref().unwrap().partial_planning,
Some(true)
);
assert_eq!(result.hook.as_deref(), Some("https://example.test/hook"));
assert_eq!(result.feasible, Some(true));
assert_eq!(
result.score_string.as_deref(),
Some("0hard/0medium/-45soft")
);
assert_eq!(result.assigned_shifts.len(), 1);
assert_eq!(result.assigned_shifts[0].name.as_deref(), Some("Ward A"));
assert_eq!(
result.assigned_shifts[0].from.as_deref(),
Some("2024-01-15T22:00:00")
);
assert_eq!(result.assigned_shifts[0].cost, 12.5);
assert_eq!(result.unassigned_shifts.len(), 1);
assert_eq!(result.employees.len(), 1);
assert_eq!(result.employees[0].shifts.len(), 1);
}
}