|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
use chrono::NaiveDate; |
|
|
use solverforge::prelude::*; |
|
|
use solverforge::stream::joiner::equal_bi; |
|
|
|
|
|
use crate::domain::{Employee, EmployeeSchedule, Shift}; |
|
|
|
|
|
|
|
|
pub fn create_fluent_constraints() -> impl ConstraintSet<EmployeeSchedule, HardSoftDecimalScore> { |
|
|
let factory = ConstraintFactory::<EmployeeSchedule, HardSoftDecimalScore>::new(); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let required_skill = factory |
|
|
.clone() |
|
|
.for_each(|s: &EmployeeSchedule| s.shifts.as_slice()) |
|
|
.join( |
|
|
|s: &EmployeeSchedule| s.employees.as_slice(), |
|
|
equal_bi( |
|
|
|shift: &Shift| shift.employee_idx, |
|
|
|emp: &Employee| Some(emp.index), |
|
|
), |
|
|
) |
|
|
.filter(|shift: &Shift, emp: &Employee| { |
|
|
shift.employee_idx.is_some() && !emp.skills.contains(&shift.required_skill) |
|
|
}) |
|
|
.penalize(HardSoftDecimalScore::ONE_HARD) |
|
|
.as_constraint("Required skill"); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let no_overlap = factory |
|
|
.clone() |
|
|
.for_each_unique_pair( |
|
|
|s: &EmployeeSchedule| s.shifts.as_slice(), |
|
|
joiner::equal(|shift: &Shift| shift.employee_idx), |
|
|
) |
|
|
.filter(|a: &Shift, b: &Shift| { |
|
|
a.employee_idx.is_some() && a.start < b.end && b.start < a.end |
|
|
}) |
|
|
.penalize_hard_with(|a: &Shift, b: &Shift| { |
|
|
HardSoftDecimalScore::of_hard_scaled(overlap_minutes(a, b) * 100000) |
|
|
}) |
|
|
.as_constraint("Overlapping shift"); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let at_least_10_hours = factory |
|
|
.clone() |
|
|
.for_each_unique_pair( |
|
|
|s: &EmployeeSchedule| s.shifts.as_slice(), |
|
|
joiner::equal(|shift: &Shift| shift.employee_idx), |
|
|
) |
|
|
.filter(|a: &Shift, b: &Shift| a.employee_idx.is_some() && gap_penalty_minutes(a, b) > 0) |
|
|
.penalize_hard_with(|a: &Shift, b: &Shift| { |
|
|
HardSoftDecimalScore::of_hard_scaled(gap_penalty_minutes(a, b) * 100000) |
|
|
}) |
|
|
.as_constraint("At least 10 hours between 2 shifts"); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let one_per_day = factory |
|
|
.clone() |
|
|
.for_each_unique_pair( |
|
|
|s: &EmployeeSchedule| s.shifts.as_slice(), |
|
|
joiner::equal(|shift: &Shift| (shift.employee_idx, shift.date())), |
|
|
) |
|
|
.filter(|a: &Shift, b: &Shift| a.employee_idx.is_some() && b.employee_idx.is_some()) |
|
|
.penalize(HardSoftDecimalScore::ONE_HARD) |
|
|
.as_constraint("One shift per day"); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let unavailable = factory |
|
|
.clone() |
|
|
.for_each(|s: &EmployeeSchedule| s.shifts.as_slice()) |
|
|
.join( |
|
|
|s: &EmployeeSchedule| s.employees.as_slice(), |
|
|
equal_bi( |
|
|
|shift: &Shift| shift.employee_idx, |
|
|
|emp: &Employee| Some(emp.index), |
|
|
), |
|
|
) |
|
|
.flatten_last( |
|
|
|emp: &Employee| emp.unavailable_days.as_slice(), |
|
|
|date: &NaiveDate| *date, |
|
|
|shift: &Shift| shift.date(), |
|
|
) |
|
|
.filter(|shift: &Shift, date: &NaiveDate| { |
|
|
shift.employee_idx.is_some() && shift_date_overlap_minutes(shift, *date) > 0 |
|
|
}) |
|
|
.penalize_hard_with(|shift: &Shift, date: &NaiveDate| { |
|
|
HardSoftDecimalScore::of_hard_scaled(shift_date_overlap_minutes(shift, *date) * 100000) |
|
|
}) |
|
|
.as_constraint("Unavailable employee"); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let undesired = factory |
|
|
.clone() |
|
|
.for_each(|s: &EmployeeSchedule| s.shifts.as_slice()) |
|
|
.join( |
|
|
|s: &EmployeeSchedule| s.employees.as_slice(), |
|
|
equal_bi( |
|
|
|shift: &Shift| shift.employee_idx, |
|
|
|emp: &Employee| Some(emp.index), |
|
|
), |
|
|
) |
|
|
.flatten_last( |
|
|
|emp: &Employee| emp.undesired_days.as_slice(), |
|
|
|date: &NaiveDate| *date, |
|
|
|shift: &Shift| shift.date(), |
|
|
) |
|
|
.filter(|shift: &Shift, _date: &NaiveDate| shift.employee_idx.is_some()) |
|
|
.penalize(HardSoftDecimalScore::ONE_SOFT) |
|
|
.as_constraint("Undesired day for employee"); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let desired = factory |
|
|
.clone() |
|
|
.for_each(|s: &EmployeeSchedule| s.shifts.as_slice()) |
|
|
.join( |
|
|
|s: &EmployeeSchedule| s.employees.as_slice(), |
|
|
equal_bi( |
|
|
|shift: &Shift| shift.employee_idx, |
|
|
|emp: &Employee| Some(emp.index), |
|
|
), |
|
|
) |
|
|
.flatten_last( |
|
|
|emp: &Employee| emp.desired_days.as_slice(), |
|
|
|date: &NaiveDate| *date, |
|
|
|shift: &Shift| shift.date(), |
|
|
) |
|
|
.filter(|shift: &Shift, _date: &NaiveDate| shift.employee_idx.is_some()) |
|
|
.reward(HardSoftDecimalScore::ONE_SOFT) |
|
|
.as_constraint("Desired day for employee"); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let balanced = factory |
|
|
.for_each(|s: &EmployeeSchedule| s.shifts.as_slice()) |
|
|
.balance(|shift: &Shift| shift.employee_idx) |
|
|
.penalize(HardSoftDecimalScore::of_soft(1)) |
|
|
.as_constraint("Balance employee assignments"); |
|
|
|
|
|
( |
|
|
required_skill, |
|
|
no_overlap, |
|
|
at_least_10_hours, |
|
|
one_per_day, |
|
|
unavailable, |
|
|
undesired, |
|
|
desired, |
|
|
balanced, |
|
|
) |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#[inline] |
|
|
fn overlap_minutes(a: &Shift, b: &Shift) -> i64 { |
|
|
let start = a.start.max(b.start); |
|
|
let end = a.end.min(b.end); |
|
|
if start < end { |
|
|
(end - start).num_minutes() |
|
|
} else { |
|
|
0 |
|
|
} |
|
|
} |
|
|
|
|
|
#[inline] |
|
|
fn gap_penalty_minutes(a: &Shift, b: &Shift) -> i64 { |
|
|
const MIN_GAP_MINUTES: i64 = 600; |
|
|
|
|
|
let (earlier, later) = if a.end <= b.start { |
|
|
(a, b) |
|
|
} else if b.end <= a.start { |
|
|
(b, a) |
|
|
} else { |
|
|
return 0; |
|
|
}; |
|
|
|
|
|
let gap = (later.start - earlier.end).num_minutes(); |
|
|
if (0..MIN_GAP_MINUTES).contains(&gap) { |
|
|
MIN_GAP_MINUTES - gap |
|
|
} else { |
|
|
0 |
|
|
} |
|
|
} |
|
|
|
|
|
#[inline] |
|
|
fn shift_date_overlap_minutes(shift: &Shift, date: NaiveDate) -> i64 { |
|
|
let day_start = date.and_hms_opt(0, 0, 0).unwrap(); |
|
|
let day_end = date.succ_opt().unwrap_or(date).and_hms_opt(0, 0, 0).unwrap(); |
|
|
|
|
|
let start = shift.start.max(day_start); |
|
|
let end = shift.end.min(day_end); |
|
|
|
|
|
if start < end { |
|
|
(end - start).num_minutes() |
|
|
} else { |
|
|
0 |
|
|
} |
|
|
} |
|
|
|
|
|
|