Add fairness for each shift type count
This commit is contained in:
@@ -22,8 +22,8 @@ pub struct UserConfigDTO {
|
|||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct UserConfig {
|
pub struct UserConfig {
|
||||||
month: Month,
|
pub month: Month,
|
||||||
year: i32,
|
pub year: i32,
|
||||||
pub holidays: Vec<usize>,
|
pub holidays: Vec<usize>,
|
||||||
pub residents: Vec<Resident>,
|
pub residents: Vec<Resident>,
|
||||||
pub toxic_pairs: Vec<(String, String)>,
|
pub toxic_pairs: Vec<(String, String)>,
|
||||||
@@ -31,6 +31,8 @@ pub struct UserConfig {
|
|||||||
// calculated from inputs
|
// calculated from inputs
|
||||||
pub workload_limits: HashMap<String, u8>,
|
pub workload_limits: HashMap<String, u8>,
|
||||||
pub holiday_limits: HashMap<String, u8>,
|
pub holiday_limits: HashMap<String, u8>,
|
||||||
|
pub shift_type_limits: HashMap<(String, ShiftType), u8>,
|
||||||
|
pub shift_type_threshold: HashMap<(String, ShiftType), u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UserConfig {
|
impl UserConfig {
|
||||||
@@ -43,6 +45,8 @@ impl UserConfig {
|
|||||||
toxic_pairs: vec![],
|
toxic_pairs: vec![],
|
||||||
workload_limits: HashMap::new(),
|
workload_limits: HashMap::new(),
|
||||||
holiday_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,
|
toxic_pairs: dto.toxic_pairs,
|
||||||
workload_limits: HashMap::new(),
|
workload_limits: HashMap::new(),
|
||||||
holiday_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))
|
|| 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<ShiftType, u8> {
|
||||||
|
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
|
/// 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
|
/// initialize a hashmap for O(1) search calls for the residents' max workload
|
||||||
pub fn calculate_workload_limits(&mut self) {
|
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
|
/// Return all possible candidates for the next slot
|
||||||
/// TODO: move this to another file, UserConfig should only hold the info set from GUI
|
/// TODO: move this to another file, UserConfig should only hold the info set from GUI
|
||||||
///
|
///
|
||||||
@@ -219,6 +317,8 @@ impl UserConfig {
|
|||||||
toxic_pairs: vec![],
|
toxic_pairs: vec![],
|
||||||
workload_limits: HashMap::new(),
|
workload_limits: HashMap::new(),
|
||||||
holiday_limits: HashMap::new(),
|
holiday_limits: HashMap::new(),
|
||||||
|
shift_type_limits: HashMap::new(),
|
||||||
|
shift_type_threshold: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,7 +384,7 @@ mod tests {
|
|||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
fn test_set_limits_fair_distribution() {
|
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("1", "Stefanos").with_max_shifts(2),
|
||||||
Resident::new("2", "Iordanis").with_max_shifts(2),
|
Resident::new("2", "Iordanis").with_max_shifts(2),
|
||||||
Resident::new("3", "Maria").with_reduced_load(),
|
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["1"], 2);
|
||||||
assert_eq!(config.workload_limits["2"], 2);
|
assert_eq!(config.workload_limits["2"], 2);
|
||||||
assert_eq!(config.workload_limits["3"], 14);
|
assert_eq!(config.workload_limits["3"], 12);
|
||||||
assert_eq!(config.workload_limits["4"], 15);
|
assert_eq!(config.workload_limits["4"], 13);
|
||||||
assert_eq!(config.workload_limits["5"], 15);
|
assert_eq!(config.workload_limits["5"], 13);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
@@ -317,6 +417,8 @@ mod tests {
|
|||||||
Resident::new("5", "Takis"),
|
Resident::new("5", "Takis"),
|
||||||
],
|
],
|
||||||
holiday_limits: HashMap::new(),
|
holiday_limits: HashMap::new(),
|
||||||
|
shift_type_limits: HashMap::new(),
|
||||||
|
shift_type_threshold: HashMap::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
config.calculate_workload_limits();
|
config.calculate_workload_limits();
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ pub fn backtracking(schedule: &mut MonthlySchedule, slot: Slot, config: &UserCon
|
|||||||
}
|
}
|
||||||
|
|
||||||
if slot.greater_than(config.total_days()) {
|
if slot.greater_than(config.total_days()) {
|
||||||
|
if !schedule.is_per_shift_threshold_met(config) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
log::trace!("Solution found, exiting recursive algorithm");
|
log::trace!("Solution found, exiting recursive algorithm");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -71,6 +75,7 @@ mod tests {
|
|||||||
]);
|
]);
|
||||||
config.calculate_workload_limits();
|
config.calculate_workload_limits();
|
||||||
config.calculate_holiday_limits();
|
config.calculate_holiday_limits();
|
||||||
|
config.calculate_shift_type_fairness();
|
||||||
config
|
config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,14 +31,15 @@ fn generate(config: UserConfigDTO, state: tauri::State<'_, AppState>) -> Monthly
|
|||||||
let mut config = UserConfig::from_dto(config);
|
let mut config = UserConfig::from_dto(config);
|
||||||
config.calculate_workload_limits();
|
config.calculate_workload_limits();
|
||||||
config.calculate_holiday_limits();
|
config.calculate_holiday_limits();
|
||||||
|
config.calculate_shift_type_fairness();
|
||||||
|
info!("{:?}", config);
|
||||||
let mut schedule = MonthlySchedule::new();
|
let mut schedule = MonthlySchedule::new();
|
||||||
schedule.prefill(&config);
|
schedule.prefill(&config);
|
||||||
info!("{}", schedule.pretty_print(&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();
|
let mut internal_schedule = state.schedule.lock().unwrap();
|
||||||
*internal_schedule = schedule.clone();
|
*internal_schedule = schedule.clone();
|
||||||
assert!(solved);
|
|
||||||
info!("{}", schedule.pretty_print(&config));
|
info!("{}", schedule.pretty_print(&config));
|
||||||
|
|
||||||
schedule
|
schedule
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ impl Resident {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_allowed_types(mut self, allowed_types: Vec<ShiftType>) -> Self {
|
||||||
|
self.allowed_types = allowed_types;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_max_shifts(mut self, max_shifts: usize) -> Self {
|
pub fn with_max_shifts(mut self, max_shifts: usize) -> Self {
|
||||||
self.max_shifts = Some(max_shifts);
|
self.max_shifts = Some(max_shifts);
|
||||||
self
|
self
|
||||||
@@ -56,6 +61,16 @@ impl Resident {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_negative_shifts(mut self, negative_shifts: Vec<Day>) -> Self {
|
||||||
|
self.negative_shifts = negative_shifts;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_manual_shifts(mut self, manual_shifts: Vec<Slot>) -> Self {
|
||||||
|
self.manual_shifts = manual_shifts;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn from_dto(dto: ResidentDTO) -> Self {
|
pub fn from_dto(dto: ResidentDTO) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: dto.id,
|
id: dto.id,
|
||||||
|
|||||||
@@ -1,37 +1,18 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{ser::SerializeMap, Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::UserConfig,
|
config::UserConfig,
|
||||||
resident::Resident,
|
resident::Resident,
|
||||||
slot::{Day, ShiftPosition, Slot},
|
slot::{weekday_to_greek, Day, ShiftPosition, Slot},
|
||||||
};
|
};
|
||||||
|
|
||||||
use serde::Serializer;
|
use serde::Serializer;
|
||||||
|
|
||||||
impl Serialize for MonthlySchedule {
|
/// each slot has one resident
|
||||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
/// a day can span between 1 or 2 slots depending on if it is open(odd) or closed(even)
|
||||||
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)
|
|
||||||
#[derive(Deserialize, Debug, Clone)]
|
#[derive(Deserialize, Debug, Clone)]
|
||||||
pub struct MonthlySchedule(HashMap<Slot, String>);
|
pub struct MonthlySchedule(pub HashMap<Slot, String>);
|
||||||
|
|
||||||
impl MonthlySchedule {
|
impl MonthlySchedule {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
@@ -66,6 +47,51 @@ impl MonthlySchedule {
|
|||||||
.count()
|
.count()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn count_shifts(&self, resident_id: &str, shift_type: Option<ShiftType>) -> 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) {
|
pub fn insert(&mut self, slot: Slot, resident: &Resident) {
|
||||||
self.0.insert(slot, resident.id.clone());
|
self.0.insert(slot, resident.id.clone());
|
||||||
}
|
}
|
||||||
@@ -90,6 +116,7 @@ impl MonthlySchedule {
|
|||||||
|| self.has_toxic_pair(slot, config)
|
|| self.has_toxic_pair(slot, config)
|
||||||
|| self.is_workload_unbalanced(slot, config)
|
|| self.is_workload_unbalanced(slot, config)
|
||||||
|| self.is_holiday_workload_imbalanced(slot, config)
|
|| self.is_holiday_workload_imbalanced(slot, config)
|
||||||
|
|| self.is_shift_type_distribution_unfair(slot, config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// same_resident_in_consecutive_days
|
/// same_resident_in_consecutive_days
|
||||||
@@ -98,20 +125,24 @@ impl MonthlySchedule {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let previous_slot = if slot.is_open_second() {
|
let previous_slots = if slot.is_open_second() {
|
||||||
slot.previous().previous()
|
vec![slot.previous().previous()]
|
||||||
|
} else if slot.is_open_first() {
|
||||||
|
vec![slot.previous()]
|
||||||
} else {
|
} 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
|
/// has_toxic_pair
|
||||||
pub fn has_toxic_pair(&self, slot: &Slot, config: &UserConfig) -> bool {
|
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
|
// can only have caused a toxic pair violation if we just added a 2nd resident in an open shift
|
||||||
// then we couldn't have just created a new toxic pair
|
if !slot.is_open_second() {
|
||||||
if !slot.is_open_shift() || !slot.is_open_second() {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,11 +208,39 @@ impl MonthlySchedule {
|
|||||||
false
|
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 {
|
pub fn pretty_print(&self, config: &UserConfig) -> String {
|
||||||
let mut sorted: Vec<_> = self.0.iter().collect();
|
let mut sorted: Vec<_> = self.0.iter().collect();
|
||||||
sorted.sort_by_key(|(slot, _)| (slot.day, slot.position));
|
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 {
|
for (slot, res_id) in sorted {
|
||||||
let res_name = config
|
let res_name = config
|
||||||
.residents
|
.residents
|
||||||
@@ -191,16 +250,70 @@ impl MonthlySchedule {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
output.push_str(&format!(
|
output.push_str(&format!(
|
||||||
"Day {:2} ({:?}): {},\n",
|
"Ημέρα {:2} - {:9} - {:11}: {},\n",
|
||||||
slot.day.0, slot.position, res_name
|
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
|
output
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
impl Serialize for MonthlySchedule {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
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 {
|
pub enum ShiftType {
|
||||||
Closed,
|
Closed,
|
||||||
OpenFirst,
|
OpenFirst,
|
||||||
|
|||||||
@@ -21,10 +21,22 @@ impl Slot {
|
|||||||
self.day == Day(1) && self.position == ShiftPosition::First
|
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 {
|
pub fn is_open_second(&self) -> bool {
|
||||||
self.is_open_shift() && self.position == ShiftPosition::Second
|
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 {
|
pub fn next(&self) -> Self {
|
||||||
match self.position {
|
match self.position {
|
||||||
ShiftPosition::First if self.is_open_shift() => Self {
|
ShiftPosition::First if self.is_open_shift() => Self {
|
||||||
@@ -103,6 +115,11 @@ impl Day {
|
|||||||
let weekday = date.weekday();
|
let weekday = date.weekday();
|
||||||
weekday == Weekday::Sat || weekday == Weekday::Sun
|
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)]
|
#[derive(Serialize, Deserialize, PartialEq, PartialOrd, Ord, Eq, Debug, Hash, Clone, Copy)]
|
||||||
|
|||||||
Reference in New Issue
Block a user