| |
| |
| |
| |
| |
|
|
| mod exchange; |
| mod floor; |
| mod support; |
| mod top_up; |
|
|
| use chrono::NaiveDate; |
| use std::collections::{BTreeMap, BTreeSet}; |
|
|
| use crate::domain::{Employee, Shift}; |
|
|
| use self::exchange::assign_exchange_preferences; |
| use self::floor::ensure_preference_floor; |
| use self::top_up::{add_weekend_preference_bias, top_up_preferences}; |
| use super::coverage::employee_can_cover_shift_without_schedule; |
| use super::employees::EmployeeBlueprint; |
| use super::vocabulary::{EXCHANGE_MARK_LIMIT_PER_EMPLOYEE, MAX_DESIRED_DATES, MAX_UNDESIRED_DATES}; |
| use super::witness::WitnessRoster; |
|
|
| #[cfg(test)] |
| pub(super) fn shift_soft_preference_score(employee: &Employee, shift: &Shift) -> i64 { |
| support::shift_soft_preference_score(employee, shift) |
| } |
|
|
| |
| pub(super) fn add_preferences( |
| employees: &mut [Employee], |
| start_date: NaiveDate, |
| blueprints: &[EmployeeBlueprint], |
| shifts: &[Shift], |
| witness: &WitnessRoster, |
| ) { |
| let analysis = PreferenceAnalysis::build(employees, shifts, witness); |
|
|
| |
| |
| |
| |
| |
| |
| |
| if let Some(max_exchange_marks_per_employee) = EXCHANGE_MARK_LIMIT_PER_EMPLOYEE { |
| assign_exchange_preferences( |
| employees, |
| blueprints, |
| shifts, |
| witness, |
| &analysis, |
| max_exchange_marks_per_employee, |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| top_up_preferences(employees, start_date, blueprints); |
|
|
| |
| |
| |
| add_weekend_preference_bias(employees, start_date, blueprints); |
|
|
| ensure_preference_floor( |
| employees, |
| &witness.employee_touched_dates, |
| &analysis.coverable_dates_by_employee, |
| &analysis.date_pressure, |
| ); |
|
|
| for employee in employees.iter() { |
| assert!( |
| employee.desired_dates.len() >= 4 && employee.desired_dates.len() <= MAX_DESIRED_DATES, |
| "desired-date volume should stay in the designed band" |
| ); |
| assert!( |
| employee.undesired_dates.len() >= 4 |
| && employee.undesired_dates.len() <= MAX_UNDESIRED_DATES, |
| "undesired-date volume should stay in the designed band" |
| ); |
| assert!( |
| employee |
| .desired_dates |
| .iter() |
| .all(|date| !employee.undesired_dates.contains(date)), |
| "desired and undesired dates must stay disjoint" |
| ); |
| } |
| } |
|
|
| |
| struct PreferenceAnalysis { |
| candidate_lists: Vec<Vec<usize>>, |
| candidate_counts: Vec<usize>, |
| witness_shifts_by_employee: Vec<Vec<usize>>, |
| coverable_dates_by_employee: Vec<BTreeSet<NaiveDate>>, |
| date_pressure: BTreeMap<NaiveDate, usize>, |
| } |
|
|
| impl PreferenceAnalysis { |
| |
| fn build(employees: &[Employee], shifts: &[Shift], witness: &WitnessRoster) -> Self { |
| let candidate_lists = eligible_employees_by_shift(employees, shifts); |
| let candidate_counts: Vec<usize> = candidate_lists.iter().map(Vec::len).collect(); |
| let witness_shifts_by_employee = |
| witness_shift_indices_by_employee(&witness.assignments, employees.len()); |
| let coverable_dates_by_employee = |
| coverable_dates_by_employee(&candidate_lists, shifts, employees.len()); |
| let date_pressure = date_pressure_by_day(shifts, &candidate_counts); |
|
|
| Self { |
| candidate_lists, |
| candidate_counts, |
| witness_shifts_by_employee, |
| coverable_dates_by_employee, |
| date_pressure, |
| } |
| } |
| } |
|
|
| |
| pub(super) fn eligible_employees_by_shift( |
| employees: &[Employee], |
| shifts: &[Shift], |
| ) -> Vec<Vec<usize>> { |
| shifts |
| .iter() |
| .map(|shift| { |
| employees |
| .iter() |
| .enumerate() |
| .filter(|(_, employee)| employee_can_cover_shift_without_schedule(employee, shift)) |
| .map(|(employee_index, _)| employee_index) |
| .collect() |
| }) |
| .collect() |
| } |
|
|
| |
| fn witness_shift_indices_by_employee( |
| assignments: &[usize], |
| employee_count: usize, |
| ) -> Vec<Vec<usize>> { |
| let mut shifts_by_employee = vec![Vec::new(); employee_count]; |
| for (shift_index, &employee_index) in assignments.iter().enumerate() { |
| shifts_by_employee[employee_index].push(shift_index); |
| } |
| shifts_by_employee |
| } |
|
|
| |
| fn coverable_dates_by_employee( |
| candidate_lists: &[Vec<usize>], |
| shifts: &[Shift], |
| employee_count: usize, |
| ) -> Vec<BTreeSet<NaiveDate>> { |
| let mut dates_by_employee = vec![BTreeSet::new(); employee_count]; |
| for (shift_index, candidates) in candidate_lists.iter().enumerate() { |
| for &candidate in candidates { |
| dates_by_employee[candidate].extend(shifts[shift_index].touched_dates.iter().copied()); |
| } |
| } |
| dates_by_employee |
| } |
|
|
| |
| fn date_pressure_by_day( |
| shifts: &[Shift], |
| candidate_counts: &[usize], |
| ) -> BTreeMap<NaiveDate, usize> { |
| let mut pressure = BTreeMap::new(); |
| for (shift, &candidate_count) in shifts.iter().zip(candidate_counts.iter()) { |
| |
| |
| |
| |
| |
| let shift_pressure = candidate_count.clamp(2, 8) - 1; |
| for &date in &shift.touched_dates { |
| *pressure.entry(date).or_default() += shift_pressure.max(1); |
| } |
| } |
| pressure |
| } |
|
|