Skip to main content

plansolve/shift/
models.rs

1use serde::{Deserialize, Serialize};
2
3/// Request model for starting a shift optimization.
4#[derive(Debug, Clone, Default, Serialize, Deserialize)]
5#[serde(rename_all = "camelCase")]
6pub struct ShiftRequest {
7    #[serde(skip_serializing_if = "Option::is_none")]
8    pub id: Option<String>,
9    #[serde(skip_serializing_if = "Option::is_none")]
10    pub name: Option<String>,
11    #[serde(skip_serializing_if = "Option::is_none")]
12    pub description: Option<String>,
13    #[serde(default, skip_serializing_if = "Vec::is_empty")]
14    pub contracts: Vec<Contract>,
15    #[serde(default, skip_serializing_if = "Vec::is_empty")]
16    pub shifts: Vec<ShiftAssignment>,
17    #[serde(default, skip_serializing_if = "Vec::is_empty")]
18    pub employees: Vec<ShiftEmployee>,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub options: Option<Options>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub weights: Option<Weights>,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub fairness: Option<Fairness>,
25    #[serde(default, skip_serializing_if = "Vec::is_empty")]
26    pub day_off_requests: Vec<DayOffRequest>,
27    #[serde(default, skip_serializing_if = "Vec::is_empty")]
28    pub shift_off_requests: Vec<ShiftOffRequest>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub hook: Option<String>,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub constraint_weight_overrides: Option<ConstraintWeightOverrides>,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub score: Option<serde_json::Value>,
35    #[serde(default, skip_serializing_if = "Vec::is_empty")]
36    pub unassigned_shifts: Vec<ShiftAssignment>,
37    #[serde(default, skip_serializing_if = "Vec::is_empty")]
38    pub assigned_shifts: Vec<ShiftAssignment>,
39}
40
41/// A time window for shift scheduling.
42#[derive(Debug, Clone, Default, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct Shift {
45    pub id: String,
46    pub min_start_time: String,
47    pub max_end_time: String,
48}
49
50/// A shift assignment to be scheduled.
51#[derive(Debug, Clone, Default, Serialize, Deserialize)]
52#[serde(rename_all = "camelCase")]
53pub struct ShiftAssignment {
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub name: Option<String>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub from: Option<String>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub to: Option<String>,
60    #[serde(default, skip_serializing_if = "Vec::is_empty")]
61    pub skills: Vec<String>,
62    #[serde(default)]
63    pub cost: f64,
64    #[serde(default)]
65    pub value: i64,
66    #[serde(default)]
67    pub priority: i64,
68    #[serde(default, skip_serializing_if = "Vec::is_empty")]
69    pub desired_skills: Vec<String>,
70    #[serde(default, skip_serializing_if = "Vec::is_empty")]
71    pub tags: Vec<String>,
72    #[serde(default)]
73    pub pinned_by_user: bool,
74}
75
76/// An employee in the shift optimization.
77#[derive(Debug, Clone, Default, Serialize, Deserialize)]
78#[serde(rename_all = "camelCase")]
79pub struct ShiftEmployee {
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub name: Option<String>,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub contract: Option<String>,
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    pub skills: Vec<String>,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub last_rest_date: Option<String>,
88    #[serde(default, skip_serializing_if = "Vec::is_empty")]
89    pub availability: Vec<String>,
90    #[serde(default, skip_serializing_if = "Vec::is_empty")]
91    pub preference: Vec<String>,
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub period_rules: Vec<PeriodRule>,
94    #[serde(default, skip_serializing_if = "Vec::is_empty")]
95    pub unavailable_dates: Vec<String>,
96    #[serde(default, skip_serializing_if = "Vec::is_empty")]
97    pub tags: Vec<String>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub maximum_minutes_per_week: Option<i64>,
100    #[serde(default, skip_serializing_if = "Vec::is_empty")]
101    pub shifts: Vec<ShiftAssignment>,
102}
103
104/// A shift employee contract.
105#[derive(Debug, Clone, Default, Serialize, Deserialize)]
106#[serde(rename_all = "camelCase")]
107pub struct Contract {
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub name: Option<String>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub max: Option<String>,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub min: Option<String>,
114    #[serde(default)]
115    pub max_consecutive_work_days: i64,
116    #[serde(default)]
117    pub max_shifts_day: i64,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub min_rest_between_shifts_same_day: Option<String>,
120    #[serde(default)]
121    pub max_working_days: i64,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub latest_shift_start: Option<String>,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub earliest_shift_start: Option<String>,
126    #[serde(default)]
127    pub minimum_consecutive_days_off: i64,
128    #[serde(default)]
129    pub minimum_hours_off_between_shifts: i64,
130}
131
132/// Constraint weights for the shift optimizer.
133#[derive(Debug, Clone, Default, Serialize, Deserialize)]
134#[serde(rename_all = "camelCase")]
135pub struct Weights {
136    pub required_skills: i64,
137    pub shift_capacity: i64,
138    pub minimum_staffing: i64,
139    pub no_double_booking: i64,
140    pub rest_between_shifts: i64,
141    pub employee_availability: i64,
142    pub shift_preferences: i64,
143    pub cost_minimization: i64,
144    pub workload_balance: i64,
145    pub fairness: i64,
146    pub max_consecutive_work_days: i64,
147    pub max_shifts_per_day: i64,
148    pub max_working_days_per_week: i64,
149    pub contract_rest_between_shifts: i64,
150    pub earliest_shift_start: i64,
151    pub latest_shift_start: i64,
152    pub minimum_consecutive_days_off: i64,
153    pub period_rule_violation: i64,
154    pub desired_skills: i64,
155    pub desired_day_off: i64,
156    pub shift_off_request: i64,
157    pub balance_time_worked: i64,
158    pub employee_affinity: i64,
159    pub avoid_shift_close_to_day_off: i64,
160}
161
162/// Solver options for the shift optimizer.
163#[derive(Debug, Clone, Default, Serialize, Deserialize)]
164#[serde(rename_all = "camelCase")]
165pub struct Options {
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub partial_planning: Option<bool>,
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub max_iterations: Option<i64>,
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub time_limit: Option<i64>,
172}
173
174/// Fairness configuration for the shift optimizer.
175#[derive(Debug, Clone, Default, Serialize, Deserialize)]
176#[serde(rename_all = "camelCase")]
177pub struct Fairness {
178    #[serde(default, skip_serializing_if = "Vec::is_empty")]
179    pub fairness_buckets: Vec<FairnessBucket>,
180}
181
182/// A fairness bucket grouping employees and shifts.
183#[derive(Debug, Clone, Default, Serialize, Deserialize)]
184#[serde(rename_all = "camelCase")]
185pub struct FairnessBucket {
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub name: Option<String>,
188    #[serde(default, skip_serializing_if = "Vec::is_empty")]
189    pub employees: Vec<String>,
190    #[serde(default, skip_serializing_if = "Vec::is_empty")]
191    pub shifts: Vec<String>,
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub period: Option<String>,
194}
195
196/// An employee's request for a day off.
197#[derive(Debug, Clone, Default, Serialize, Deserialize)]
198#[serde(rename_all = "camelCase")]
199pub struct DayOffRequest {
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub id: Option<String>,
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub employee_name: Option<String>,
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub date: Option<String>,
206    #[serde(default)]
207    pub weight: i64,
208}
209
210/// An employee's request to be off a specific shift.
211#[derive(Debug, Clone, Default, Serialize, Deserialize)]
212#[serde(rename_all = "camelCase")]
213pub struct ShiftOffRequest {
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub id: Option<String>,
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub employee_name: Option<String>,
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub shift_name: Option<String>,
220    #[serde(default)]
221    pub weight: i64,
222}
223
224/// A period-based working constraint.
225#[derive(Debug, Clone, Default, Serialize, Deserialize)]
226#[serde(rename_all = "camelCase")]
227pub struct PeriodRule {
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub period: Option<PlanningPeriod>,
230    #[serde(default)]
231    pub max_working_days: i64,
232    #[serde(default)]
233    pub min_working_days: i64,
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub min_working_duration: Option<String>,
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub max_working_duration: Option<String>,
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub min_rest_duration_between_shifts_same_day: Option<String>,
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub min_rest_duration: Option<String>,
242}
243
244/// A time period for planning rules.
245#[derive(Debug, Clone, Default, Serialize, Deserialize)]
246#[serde(rename_all = "camelCase")]
247pub struct PlanningPeriod {
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub from: Option<String>,
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub to: Option<String>,
252}
253
254/// Overrides for constraint weights.
255#[derive(Debug, Clone, Default, Serialize, Deserialize)]
256#[serde(rename_all = "camelCase")]
257pub struct ConstraintWeightOverrides {
258    #[serde(default, skip_serializing_if = "Vec::is_empty")]
259    pub known_constraint_names: Vec<String>,
260}
261
262/// Response from starting a shift optimization.
263#[derive(Debug, Clone, Default, Serialize, Deserialize)]
264#[serde(rename_all = "camelCase")]
265pub struct ShiftStartResponse {
266    pub job_id: String,
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub solver_job_id: Option<String>,
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub result: Option<String>,
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub error: Option<String>,
273}
274
275/// Response from getting shift optimization results.
276#[derive(Debug, Clone, Default, Serialize, Deserialize)]
277#[serde(rename_all = "camelCase")]
278pub struct ShiftResultResponse {
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub job_id: Option<String>,
281    pub employees: Vec<ScheduledEmployee>,
282    pub tasks: Vec<ScheduledTask>,
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub score: Option<String>,
285    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
286    pub weights: std::collections::HashMap<String, String>,
287}
288
289/// An employee in the optimization result.
290#[derive(Debug, Clone, Default, Serialize, Deserialize)]
291#[serde(rename_all = "camelCase")]
292pub struct ScheduledEmployee {
293    pub id: String,
294    pub shifts: Vec<Shift>,
295    pub skills: Vec<String>,
296    pub tasks: Vec<String>,
297}
298
299/// A task in the optimization result.
300#[derive(Debug, Clone, Default, Serialize, Deserialize)]
301#[serde(rename_all = "camelCase")]
302pub struct ScheduledTask {
303    pub id: String,
304    pub name: String,
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub employee: Option<String>,
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub start_time: Option<String>,
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub end_time: Option<String>,
311    #[serde(default)]
312    pub duration: f64,
313    pub priority: String,
314    pub required_skills: Vec<String>,
315    #[serde(skip_serializing_if = "Option::is_none")]
316    pub deadline: Option<String>,
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn serializes_and_omits_empty_collections() {
325        let json = serde_json::to_string(&ShiftRequest::default()).unwrap();
326        // Empty optional vectors are omitted, keeping payloads lean.
327        assert!(!json.contains("\"shifts\""));
328        assert!(!json.contains("\"employees\""));
329    }
330
331    #[test]
332    fn serializes_populated_assignment() {
333        let request = ShiftRequest {
334            shifts: vec![ShiftAssignment {
335                name: Some("Night".into()),
336                cost: 12.5,
337                priority: 3,
338                pinned_by_user: true,
339                ..Default::default()
340            }],
341            ..Default::default()
342        };
343        let json = serde_json::to_string(&request).unwrap();
344        assert!(json.contains("\"shifts\""));
345        assert!(json.contains("\"pinnedByUser\":true"));
346        assert!(json.contains("\"cost\":12.5"));
347    }
348
349    #[test]
350    fn deserializes_result_response() {
351        let json = r#"{
352            "employees": [
353                {"id": "e1", "shifts": [], "skills": ["nurse"], "tasks": ["t1"]}
354            ],
355            "tasks": [
356                {"id": "t1", "name": "Ward A", "employee": "e1",
357                 "startTime": "2024-01-15T22:00:00", "endTime": "2024-01-16T06:00:00",
358                 "duration": 8.0, "priority": "HIGH", "requiredSkills": ["nurse"]}
359            ],
360            "score": "0hard/0medium/0soft"
361        }"#;
362
363        let result: ShiftResultResponse = serde_json::from_str(json).unwrap();
364        assert_eq!(result.employees.len(), 1);
365        assert_eq!(result.tasks[0].employee.as_deref(), Some("e1"));
366        assert_eq!(
367            result.tasks[0].start_time.as_deref(),
368            Some("2024-01-15T22:00:00")
369        );
370        assert_eq!(result.tasks[0].duration, 8.0);
371    }
372}