From e9ca378099019074f7576557b46c0d6eb6f9455a Mon Sep 17 00:00:00 2001 From: stefiosif Date: Tue, 13 Jan 2026 21:08:03 +0200 Subject: [PATCH] Add fairness for each shift type count --- src-tauri/src/config.rs | 114 +++++++++++++++++++++-- src-tauri/src/generator.rs | 5 + src-tauri/src/lib.rs | 5 +- src-tauri/src/resident.rs | 15 +++ src-tauri/src/schedule.rs | 185 +++++++++++++++++++++++++++++-------- src-tauri/src/slot.rs | 17 ++++ 6 files changed, 297 insertions(+), 44 deletions(-) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index f228b0d..06a1332 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -22,8 +22,8 @@ pub struct UserConfigDTO { #[derive(Debug)] pub struct UserConfig { - month: Month, - year: i32, + pub month: Month, + pub year: i32, pub holidays: Vec, pub residents: Vec, pub toxic_pairs: Vec<(String, String)>, @@ -31,6 +31,8 @@ pub struct UserConfig { // calculated from inputs pub workload_limits: HashMap, pub holiday_limits: HashMap, + pub shift_type_limits: HashMap<(String, ShiftType), u8>, + pub shift_type_threshold: HashMap<(String, ShiftType), u8>, } impl UserConfig { @@ -43,6 +45,8 @@ impl UserConfig { toxic_pairs: vec![], workload_limits: HashMap::new(), holiday_limits: HashMap::new(), + shift_type_limits: HashMap::new(), + shift_type_threshold: HashMap::new(), } } @@ -55,6 +59,8 @@ impl UserConfig { toxic_pairs: dto.toxic_pairs, workload_limits: HashMap::new(), holiday_limits: HashMap::new(), + shift_type_limits: HashMap::new(), + shift_type_threshold: HashMap::new(), } } @@ -100,6 +106,22 @@ impl UserConfig { || self.holidays.contains(&(day.0 as usize)) } + /// get map with total amount of slots in a month for each type of shift + pub fn get_initial_supply(&self) -> HashMap { + let mut supply = HashMap::new(); + let total_days = self.total_days(); + + for d in 1..=total_days { + if Day(d).is_open_shift() { + *supply.entry(ShiftType::OpenFirst).or_insert(0) += 1; + *supply.entry(ShiftType::OpenSecond).or_insert(0) += 1; + } else { + *supply.entry(ShiftType::Closed).or_insert(0) += 1; + } + } + supply + } + /// this is called after the user config params have been initialized, can be done with the builder (lite) pattern /// initialize a hashmap for O(1) search calls for the residents' max workload pub fn calculate_workload_limits(&mut self) { @@ -162,6 +184,82 @@ impl UserConfig { } } + /// shift type count fairness + pub fn calculate_shift_type_fairness(&mut self) { + let mut global_supply = self.get_initial_supply(); + let mut local_limits = HashMap::new(); + let mut local_thresholds = HashMap::new(); + + let all_shift_types = [ + ShiftType::OpenFirst, + ShiftType::OpenSecond, + ShiftType::Closed, + ]; + + // residents with 1 available shift types + for res in self.residents.iter().filter(|r| r.allowed_types.len() == 1) { + let stype = &res.allowed_types[0]; + let total_limit = *self.workload_limits.get(&res.id).unwrap_or(&0); + + local_limits.insert((res.id.clone(), stype.clone()), total_limit); + local_thresholds.insert((res.id.clone(), stype.clone()), total_limit - 1); + + for other_type in &all_shift_types { + if other_type != stype { + local_limits.insert((res.id.clone(), other_type.clone()), 0); + local_thresholds.insert((res.id.clone(), other_type.clone()), 0); + } + } + + if let Some(s) = global_supply.get_mut(stype) { + *s = s.saturating_sub(total_limit) + } + } + + // residents with 2 available shift types + for res in self.residents.iter().filter(|r| r.allowed_types.len() == 2) { + let total_limit = *self.workload_limits.get(&res.id).unwrap_or(&0) as f32; + let per_type = (total_limit / 2.0).ceil() as u8; + + for stype in &all_shift_types { + if res.allowed_types.contains(stype) { + local_limits.insert((res.id.clone(), stype.clone()), per_type); + local_thresholds.insert((res.id.clone(), stype.clone()), per_type - 1); + if let Some(s) = global_supply.get_mut(stype) { + *s = s.saturating_sub(per_type) + } + } else { + local_limits.insert((res.id.clone(), stype.clone()), 0); + local_thresholds.insert((res.id.clone(), stype.clone()), 0); + } + } + } + + // residents with 3 available shift types + let generalists: Vec<&Resident> = self + .residents + .iter() + .filter(|r| r.allowed_types.len() == 3) + .collect(); + + if !generalists.is_empty() { + for stype in &all_shift_types { + let remaining = *global_supply.get(stype).unwrap_or(&0); + let fair_slice = (remaining as f32 / generalists.len() as f32) + .ceil() + .max(0.0) as u8; + + for res in &generalists { + local_limits.insert((res.id.clone(), stype.clone()), fair_slice); + local_thresholds.insert((res.id.clone(), stype.clone()), fair_slice - 1); + } + } + } + + self.shift_type_limits = local_limits; + self.shift_type_threshold = local_thresholds; + } + /// Return all possible candidates for the next slot /// TODO: move this to another file, UserConfig should only hold the info set from GUI /// @@ -219,6 +317,8 @@ impl UserConfig { toxic_pairs: vec![], workload_limits: HashMap::new(), holiday_limits: HashMap::new(), + shift_type_limits: HashMap::new(), + shift_type_threshold: HashMap::new(), } } } @@ -284,7 +384,7 @@ mod tests { #[rstest] fn test_set_limits_fair_distribution() { - let mut config = UserConfig::new(1).with_residents(vec![ + let mut config = UserConfig::default().with_residents(vec![ Resident::new("1", "Stefanos").with_max_shifts(2), Resident::new("2", "Iordanis").with_max_shifts(2), Resident::new("3", "Maria").with_reduced_load(), @@ -296,9 +396,9 @@ mod tests { assert_eq!(config.workload_limits["1"], 2); assert_eq!(config.workload_limits["2"], 2); - assert_eq!(config.workload_limits["3"], 14); - assert_eq!(config.workload_limits["4"], 15); - assert_eq!(config.workload_limits["5"], 15); + assert_eq!(config.workload_limits["3"], 12); + assert_eq!(config.workload_limits["4"], 13); + assert_eq!(config.workload_limits["5"], 13); } #[rstest] @@ -317,6 +417,8 @@ mod tests { Resident::new("5", "Takis"), ], holiday_limits: HashMap::new(), + shift_type_limits: HashMap::new(), + shift_type_threshold: HashMap::new(), }; config.calculate_workload_limits(); diff --git a/src-tauri/src/generator.rs b/src-tauri/src/generator.rs index 77d286b..ee97679 100644 --- a/src-tauri/src/generator.rs +++ b/src-tauri/src/generator.rs @@ -12,6 +12,10 @@ pub fn backtracking(schedule: &mut MonthlySchedule, slot: Slot, config: &UserCon } if slot.greater_than(config.total_days()) { + if !schedule.is_per_shift_threshold_met(config) { + return false; + } + log::trace!("Solution found, exiting recursive algorithm"); return true; } @@ -71,6 +75,7 @@ mod tests { ]); config.calculate_workload_limits(); config.calculate_holiday_limits(); + config.calculate_shift_type_fairness(); config } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 111e646..3c9d12f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -31,14 +31,15 @@ fn generate(config: UserConfigDTO, state: tauri::State<'_, AppState>) -> Monthly let mut config = UserConfig::from_dto(config); config.calculate_workload_limits(); config.calculate_holiday_limits(); + config.calculate_shift_type_fairness(); + info!("{:?}", config); let mut schedule = MonthlySchedule::new(); schedule.prefill(&config); info!("{}", schedule.pretty_print(&config)); - let solved = backtracking(&mut schedule, Slot::default(), &config); + backtracking(&mut schedule, Slot::default(), &config); let mut internal_schedule = state.schedule.lock().unwrap(); *internal_schedule = schedule.clone(); - assert!(solved); info!("{}", schedule.pretty_print(&config)); schedule diff --git a/src-tauri/src/resident.rs b/src-tauri/src/resident.rs index 893f059..34fc8f1 100644 --- a/src-tauri/src/resident.rs +++ b/src-tauri/src/resident.rs @@ -46,6 +46,11 @@ impl Resident { } } + pub fn with_allowed_types(mut self, allowed_types: Vec) -> Self { + self.allowed_types = allowed_types; + self + } + pub fn with_max_shifts(mut self, max_shifts: usize) -> Self { self.max_shifts = Some(max_shifts); self @@ -56,6 +61,16 @@ impl Resident { self } + pub fn with_negative_shifts(mut self, negative_shifts: Vec) -> Self { + self.negative_shifts = negative_shifts; + self + } + + pub fn with_manual_shifts(mut self, manual_shifts: Vec) -> Self { + self.manual_shifts = manual_shifts; + self + } + pub fn from_dto(dto: ResidentDTO) -> Self { Self { id: dto.id, diff --git a/src-tauri/src/schedule.rs b/src-tauri/src/schedule.rs index 0ccfe11..4a5c888 100644 --- a/src-tauri/src/schedule.rs +++ b/src-tauri/src/schedule.rs @@ -1,37 +1,18 @@ -use serde::{Deserialize, Serialize}; +use serde::{ser::SerializeMap, Deserialize, Serialize}; use std::collections::HashMap; use crate::{ config::UserConfig, resident::Resident, - slot::{Day, ShiftPosition, Slot}, + slot::{weekday_to_greek, Day, ShiftPosition, Slot}, }; use serde::Serializer; -impl Serialize for MonthlySchedule { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - use serde::ser::SerializeMap; - let mut map = serializer.serialize_map(Some(self.0.len()))?; - for (slot, name) in &self.0 { - let pos_str = match slot.position { - ShiftPosition::First => "First", - ShiftPosition::Second => "Second", - }; - let key = format!("{}-{}", slot.day.0, pos_str); - map.serialize_entry(&key, name)?; - } - map.end() - } -} - -// each slot has one resident -// a day can span between 1 or 2 slots depending on if it is open(odd) or closed(even) +/// each slot has one resident +/// a day can span between 1 or 2 slots depending on if it is open(odd) or closed(even) #[derive(Deserialize, Debug, Clone)] -pub struct MonthlySchedule(HashMap); +pub struct MonthlySchedule(pub HashMap); impl MonthlySchedule { pub fn new() -> Self { @@ -66,6 +47,51 @@ impl MonthlySchedule { .count() } + pub fn count_shifts(&self, resident_id: &str, shift_type: Option) -> usize { + self.0 + .iter() + .filter(|(slot, id)| { + if *id != resident_id { + return false; + } + + match &shift_type { + None => true, + Some(target) => { + let actual_type = if slot.is_open_shift() { + match slot.position { + ShiftPosition::First => ShiftType::OpenFirst, + ShiftPosition::Second => ShiftType::OpenSecond, + } + } else { + ShiftType::Closed + }; + actual_type == *target + } + } + }) + .count() + } + + pub fn is_per_shift_threshold_met(&self, config: &UserConfig) -> bool { + for res in &config.residents { + for stype in [ + ShiftType::OpenFirst, + ShiftType::OpenSecond, + ShiftType::Closed, + ] { + let count = self.count_shifts(&res.id, Some(stype.clone())); + if let Some(&threshold) = config.shift_type_threshold.get(&(res.id.clone(), stype)) + { + if count < threshold as usize { + return false; + } + } + } + } + true + } + pub fn insert(&mut self, slot: Slot, resident: &Resident) { self.0.insert(slot, resident.id.clone()); } @@ -90,6 +116,7 @@ impl MonthlySchedule { || self.has_toxic_pair(slot, config) || self.is_workload_unbalanced(slot, config) || self.is_holiday_workload_imbalanced(slot, config) + || self.is_shift_type_distribution_unfair(slot, config) } /// same_resident_in_consecutive_days @@ -98,20 +125,24 @@ impl MonthlySchedule { return false; } - let previous_slot = if slot.is_open_second() { - slot.previous().previous() + let previous_slots = if slot.is_open_second() { + vec![slot.previous().previous()] + } else if slot.is_open_first() { + vec![slot.previous()] } else { - slot.previous() + // if current shift is closed, we need to check both residents in the previous day + vec![slot.previous(), slot.previous().previous()] }; - self.get_resident_id(&previous_slot) == self.get_resident_id(slot) + previous_slots + .iter() + .any(|s| self.get_resident_id(s) == self.get_resident_id(slot)) } /// has_toxic_pair pub fn has_toxic_pair(&self, slot: &Slot, config: &UserConfig) -> bool { - // if it is not an open shift and we only just added the first person in an open shift - // then we couldn't have just created a new toxic pair - if !slot.is_open_shift() || !slot.is_open_second() { + // can only have caused a toxic pair violation if we just added a 2nd resident in an open shift + if !slot.is_open_second() { return false; } @@ -177,11 +208,39 @@ impl MonthlySchedule { false } + /// is_shift_type_distribution_unfair + pub fn is_shift_type_distribution_unfair(&self, slot: &Slot, config: &UserConfig) -> bool { + let resident_id = match self.get_resident_id(slot) { + Some(id) => id, + None => return false, + }; + + let current_shift_type = if slot.is_open_shift() { + match slot.position { + ShiftPosition::First => ShiftType::OpenFirst, + ShiftPosition::Second => ShiftType::OpenSecond, + } + } else { + ShiftType::Closed + }; + + let current_count = self.count_shifts(resident_id, Some(current_shift_type.clone())); + + if let Some(&limit) = config + .shift_type_limits + .get(&(resident_id.clone(), current_shift_type.clone())) + { + return current_count > limit as usize; + } + + false + } + pub fn pretty_print(&self, config: &UserConfig) -> String { let mut sorted: Vec<_> = self.0.iter().collect(); sorted.sort_by_key(|(slot, _)| (slot.day, slot.position)); - let mut output = String::from("MonthlySchedule {\n"); + let mut output = String::from("Μηνιαίο Πρόγραμμα Εφημεριών\n"); for (slot, res_id) in sorted { let res_name = config .residents @@ -191,16 +250,70 @@ impl MonthlySchedule { .unwrap(); output.push_str(&format!( - "Day {:2} ({:?}): {},\n", - slot.day.0, slot.position, res_name + "Ημέρα {:2} - {:9} - {:11}: {},\n", + slot.day.0, + weekday_to_greek( + slot.day + .weekday(config.month.number_from_month(), config.year) + ), + slot.shift_type_str(), + res_name )); } - output.push('}'); + output + } + + pub fn report(&self, config: &UserConfig) -> String { + let mut output = String::new(); + output.push_str("\n--- Αναφορά ---\n"); + // Using standard widths for Greek characters and alignment + output.push_str(&format!( + "{:<15} | {:<6} | {:<10} | {:<10} | {:<7} | {:<10}\n", + "Ειδικευόμενος", "Σύνολο", "Ανοιχτή(1)", "Ανοιχτή(2)", "Κλειστή", "ΣΚ/Αργίες" + )); + output.push_str("-".repeat(75).as_str()); + output.push('\n'); + + let mut residents: Vec<_> = config.residents.iter().collect(); + residents.sort_by_key(|r| &r.name); + + for res in residents { + let total = self.current_workload(res); + let o1 = self.count_shifts(&res.id, Some(ShiftType::OpenFirst)); + let o2 = self.count_shifts(&res.id, Some(ShiftType::OpenSecond)); + let cl = self.count_shifts(&res.id, Some(ShiftType::Closed)); + let sun = self.current_holiday_workload(res, config); + + output.push_str(&format!( + "{:<15} | {:<6} | {:<10} | {:<10} | {:<7} | {:<10}\n", + res.name, total, o1, o2, cl, sun + )); + } + output.push_str("-".repeat(75).as_str()); + output.push('\n'); output } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +impl Serialize for MonthlySchedule { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut map = serializer.serialize_map(Some(self.0.len()))?; + for (slot, name) in &self.0 { + let pos_str = match slot.position { + ShiftPosition::First => "First", + ShiftPosition::Second => "Second", + }; + let key = format!("{}-{}", slot.day.0, pos_str); + map.serialize_entry(&key, name)?; + } + map.end() + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] pub enum ShiftType { Closed, OpenFirst, diff --git a/src-tauri/src/slot.rs b/src-tauri/src/slot.rs index db463bf..afb4e53 100644 --- a/src-tauri/src/slot.rs +++ b/src-tauri/src/slot.rs @@ -21,10 +21,22 @@ impl Slot { self.day == Day(1) && self.position == ShiftPosition::First } + pub fn is_open_first(&self) -> bool { + self.is_open_shift() && self.position == ShiftPosition::First + } + pub fn is_open_second(&self) -> bool { self.is_open_shift() && self.position == ShiftPosition::Second } + pub fn shift_type_str(&self) -> String { + match (self.day.is_open_shift(), self.position) { + (true, ShiftPosition::First) => "Ανοιχτή(1)".to_string(), + (true, ShiftPosition::Second) => "Ανοιχτή(2)".to_string(), + _ => "Κλειστή".to_string(), + } + } + pub fn next(&self) -> Self { match self.position { ShiftPosition::First if self.is_open_shift() => Self { @@ -103,6 +115,11 @@ impl Day { let weekday = date.weekday(); weekday == Weekday::Sat || weekday == Weekday::Sun } + + pub fn weekday(&self, month: u32, year: i32) -> Weekday { + let date = NaiveDate::from_ymd_opt(year, month, self.0 as u32).unwrap(); + date.weekday() + } } #[derive(Serialize, Deserialize, PartialEq, PartialOrd, Ord, Eq, Debug, Hash, Clone, Copy)]