///|
/// A concrete reason why a roster model cannot be scheduled as written.
pub(all) enum RosterIssue {
DuplicateWorkerIdFound(Int)
DuplicateShiftIdFound(Int)
NoEligibleWorkerFound(Int, String)
SlotCoverageShortfall(Int, Int, Int)
} derive(Eq, Debug)
///|
/// Explain one roster issue in business-facing terms.
pub fn RosterIssue::message(self : RosterIssue) -> String {
match self {
DuplicateWorkerIdFound(id) => "worker id \{id} is declared more than once"
DuplicateShiftIdFound(id) => "shift id \{id} is declared more than once"
NoEligibleWorkerFound(id, name) =>
"shift \{id} (\{name}) has no worker with the required skill and availability"
SlotCoverageShortfall(slot, required, assignable) =>
"slot \{slot} requires \{required} workers, but at most \{assignable} shifts can be covered"
}
}
///|
/// Preflight result that may contain several independent modeling issues.
pub(all) struct RosterAnalysis {
issues : Array[RosterIssue]
} derive(Eq, Debug)
///|
/// Whether no definite scheduling conflict was found during preflight.
pub fn RosterAnalysis::is_feasible(self : RosterAnalysis) -> Bool {
self.issues.length() == 0
}
///|
/// Render all discovered issues as a compact diagnostic line.
pub fn RosterAnalysis::summary(self : RosterAnalysis) -> String {
if self.issues.length() == 0 {
return "roster model passed preflight analysis"
}
let mut output = ""
for index = 0; index < self.issues.length(); index = index + 1 {
if index > 0 {
output = output + "; "
}
output = output + self.issues[index].message()
}
output
}
///|
fn augment_shift(
shift_index : Int,
shifts : Array[Shift],
workers : Array[Worker],
visited : Array[Bool],
worker_to_shift : Array[Int],
) -> Bool {
for worker_index = 0
worker_index < workers.length()
worker_index = worker_index + 1 {
if !visited[worker_index] &&
worker_is_eligible(workers[worker_index], shifts[shift_index]) {
visited[worker_index] = true
let previous_shift = worker_to_shift[worker_index]
if previous_shift == -1 ||
augment_shift(previous_shift, shifts, workers, visited, worker_to_shift) {
worker_to_shift[worker_index] = shift_index
return true
}
}
}
false
}
///|
fn maximum_slot_coverage(workers : Array[Worker], shifts : Array[Shift]) -> Int {
let worker_to_shift = Array::make(workers.length(), -1)
let mut covered = 0
for shift_index = 0
shift_index < shifts.length()
shift_index = shift_index + 1 {
let visited = Array::make(workers.length(), false)
if augment_shift(shift_index, shifts, workers, visited, worker_to_shift) {
covered = covered + 1
}
}
covered
}
///|
fn slot_seen_before(shifts : Array[Shift], index : Int) -> Bool {
for previous = 0; previous < index; previous = previous + 1 {
if shifts[previous].slot == shifts[index].slot {
return true
}
}
false
}
///|
/// Analyze definite roster conflicts before running the full solver.
///
/// Slot capacity uses maximum bipartite matching instead of only counting
/// available workers, so skill bottlenecks are reported accurately.
pub fn analyze_roster(
workers : Array[Worker],
shifts : Array[Shift],
) -> RosterAnalysis {
let issues : Array[RosterIssue] = []
match duplicate_worker_id(workers) {
Some(id) => issues.push(DuplicateWorkerIdFound(id))
None => ()
}
match duplicate_shift_id(shifts) {
Some(id) => issues.push(DuplicateShiftIdFound(id))
None => ()
}
for shift in shifts {
let mut found = false
for worker in workers {
if worker_is_eligible(worker, shift) {
found = true
break
}
}
if !found {
issues.push(NoEligibleWorkerFound(shift.id, shift.name))
}
}
for index = 0; index < shifts.length(); index = index + 1 {
if !slot_seen_before(shifts, index) {
let slot_shifts : Array[Shift] = []
for shift in shifts {
if shift.slot == shifts[index].slot {
slot_shifts.push(shift)
}
}
let coverage = maximum_slot_coverage(workers, slot_shifts)
if coverage < slot_shifts.length() {
issues.push(
SlotCoverageShortfall(
shifts[index].slot,
slot_shifts.length(),
coverage,
),
)
}
}
}
{ issues, }
}