///|
/// Operational event applied after a baseline schedule has been published.
pub(all) enum ScheduleEvent {
WorkerAbsent(worker_id~ : String, from_minute~ : Int, to_minute~ : Int)
VisitCancelled(visit_id~ : String)
VisitDelayed(visit_id~ : String, delay_minutes~ : Int)
EmergencyVisit(visit~ : Visit)
WorkerCapacityChanged(
worker_id~ : String,
max_minutes~ : Int,
max_visits~ : Int
)
} derive(Debug, Eq)
///|
/// Stable event kind used by logs and JSON reports.
pub fn event_kind(event : ScheduleEvent) -> String {
match event {
WorkerAbsent(..) => "worker_absent"
VisitCancelled(..) => "visit_cancelled"
VisitDelayed(..) => "visit_delayed"
EmergencyVisit(..) => "emergency_visit"
WorkerCapacityChanged(..) => "worker_capacity_changed"
}
}
///|
/// Validate one operational event against the current request.
pub fn validate_event(
event : ScheduleEvent,
request : ScheduleRequest,
) -> Array[Diagnostic] {
let issues : Array[Diagnostic] = []
match event {
WorkerAbsent(worker_id~, from_minute~, to_minute~) => {
if find_worker(request.workers, worker_id) is None {
issues.push(
error_diagnostic(
"event.unknown_worker", "absence references an unknown worker",
).for_entity(worker_id),
)
}
if to_minute <= from_minute {
issues.push(
error_diagnostic(
"event.invalid_absence_window", "absence end must be after its start",
).for_entity(worker_id),
)
}
}
VisitCancelled(visit_id~) =>
if find_visit(request.visits, visit_id) is None {
issues.push(
error_diagnostic(
"event.unknown_visit", "cancellation references an unknown visit",
).for_entity(visit_id),
)
}
VisitDelayed(visit_id~, delay_minutes~) => {
if find_visit(request.visits, visit_id) is None {
issues.push(
error_diagnostic(
"event.unknown_visit", "delay references an unknown visit",
).for_entity(visit_id),
)
}
if delay_minutes <= 0 {
issues.push(
error_diagnostic(
"event.invalid_delay", "delay minutes must be positive",
).for_entity(visit_id),
)
}
}
EmergencyVisit(visit~) => {
issues.append(validate_visit(visit))
if find_visit(request.visits, visit.id) is Some(_) {
issues.push(
error_diagnostic(
"event.duplicate_emergency_visit", "emergency visit id already exists in the request",
).for_entity(visit.id),
)
}
}
WorkerCapacityChanged(worker_id~, max_minutes~, max_visits~) => {
if find_worker(request.workers, worker_id) is None {
issues.push(
error_diagnostic(
"event.unknown_worker", "capacity event references an unknown worker",
).for_entity(worker_id),
)
}
if max_minutes <= 0 || max_visits <= 0 {
issues.push(
error_diagnostic(
"event.invalid_capacity", "capacity limits must both be positive",
).for_entity(worker_id),
)
}
}
}
issues
}
///|
/// Remove one visit from a visit collection.
fn without_visit(visits : Array[Visit], visit_id : String) -> Array[Visit] {
let result : Array[Visit] = []
for visit in visits {
if visit.id != visit_id {
result.push(visit)
}
}
result
}
///|
/// Replace one visit while retaining deterministic input order.
fn replace_visit(visits : Array[Visit], replacement : Visit) -> Array[Visit] {
let result : Array[Visit] = []
let mut replaced = false
for visit in visits {
if visit.id == replacement.id {
result.push(replacement)
replaced = true
} else {
result.push(visit)
}
}
if !replaced {
result.push(replacement)
}
result
}
///|
/// Replace one worker while retaining deterministic input order.
fn replace_worker(
workers : Array[Worker],
replacement : Worker,
) -> Array[Worker] {
let result : Array[Worker] = []
for worker in workers {
if worker.id == replacement.id {
result.push(replacement)
} else {
result.push(worker)
}
}
result
}
///|
/// Subtract an absence window from one availability window.
pub fn subtract_window(
available : TimeWindow,
absent : TimeWindow,
) -> Array[TimeWindow] {
if !available.overlaps(absent) {
return [available]
}
let result : Array[TimeWindow] = []
if absent.start_minute > available.start_minute {
result.push(
time_window(
available.start_minute,
min_int(absent.start_minute, available.end_minute),
),
)
}
if absent.end_minute < available.end_minute {
result.push(
time_window(
max_int(absent.end_minute, available.start_minute),
available.end_minute,
),
)
}
result
}
///|
/// Apply a partial absence without marking the worker absent all day.
fn apply_worker_absence(
workers : Array[Worker],
worker_id : String,
absent : TimeWindow,
) -> Array[Worker] {
match find_worker(workers, worker_id) {
None => copy_array(workers)
Some(target) => {
let availability : Array[TimeWindow] = []
for window in target.availability {
availability.append(subtract_window(window, absent))
}
replace_worker(workers, {
..target,
availability,
unavailable: availability.length() == 0,
})
}
}
}
///|
/// Apply one validated event to a request without mutating the caller.
pub fn apply_event(
request : ScheduleRequest,
event : ScheduleEvent,
) -> ScheduleRequest {
match event {
WorkerAbsent(worker_id~, from_minute~, to_minute~) =>
{
..request,
workers: apply_worker_absence(
request.workers,
worker_id,
time_window(from_minute, to_minute),
),
}
VisitCancelled(visit_id~) =>
{ ..request, visits: without_visit(request.visits, visit_id) }
VisitDelayed(visit_id~, delay_minutes~) =>
match find_visit(request.visits, visit_id) {
None => request
Some(target) => {
let delayed = {
..target,
window: time_window(
target.window.start_minute + delay_minutes,
target.window.end_minute + delay_minutes,
),
}
{ ..request, visits: replace_visit(request.visits, delayed) }
}
}
EmergencyVisit(visit~) =>
{
..request,
visits: replace_visit(request.visits, {
..visit,
priority: Critical,
required: true,
}),
}
WorkerCapacityChanged(worker_id~, max_minutes~, max_visits~) =>
match find_worker(request.workers, worker_id) {
None => request
Some(target) =>
{
..request,
workers: replace_worker(request.workers, {
..target,
max_minutes,
max_visits,
}),
}
}
}
}
///|
/// Apply events sequentially after validating them against evolving state.
pub fn apply_events(
request : ScheduleRequest,
events : Array[ScheduleEvent],
) -> (ScheduleRequest, Array[Diagnostic]) {
let mut current = request
let diagnostics : Array[Diagnostic] = []
for event in events {
let issues = validate_event(event, current)
diagnostics.append(issues)
if !diagnostics_have_errors(issues) {
current = apply_event(current, event)
}
}
(current, diagnostics)
}