Enhance Rota Management System

- Updated .gitignore to exclude 'cons/' directory.
- Refactored gen_cons.py to extract leave and rota assignments from calendar data, and added functionality to handle shift assignments from rota data.
- Modified timetable.css to include styles for force-assigned shifts.
- Enhanced shifts.py to manage forced assignments and handle leave conflicts, including new methods for date-based assignments.
- Updated workers.py to introduce structured hard day dependencies and exclusions, allowing for more flexible scheduling.
- Adjusted test_workers.py to reflect changes in hard day dependency and exclusion methods, ensuring compatibility with new data structures.
This commit is contained in:
Ross
2025-08-09 22:11:39 +01:00
parent 6bd47ba5b4
commit 48710f50e8
6 changed files with 612 additions and 116 deletions
+193 -23
View File
@@ -332,6 +332,7 @@ class RotaBuilder(object):
"Shift/invalid start date",
"Shift/invalid end date",
"Locum/no locum availability",
"Force assignment/leave conflict", # Currently will always be infeasible
]
self.results = None
@@ -381,11 +382,13 @@ class RotaBuilder(object):
# Generate a map for week, day combinations to a given date
self.week_day_date_map = {}
self.date_week_day_map = {}
n: int = 0
for week, day in self.get_week_day_combinations():
d = self.start_date + datetime.timedelta(n)
self.week_day_date_map[(week, day)] = d
self.date_week_day_map[d] = (week, day)
n = n + 1
self.unavailable_to_work = set()
@@ -1430,13 +1433,24 @@ class RotaBuilder(object):
for week, day, shift_name in getattr(worker, "forced_assignments", []):
# Only add if this shift is valid for this week/day
if shift_name in self.get_shift_names_by_week_day(week, day, return_empty_if_week_day_not_found=True):
# Check for leave conflict
leave_conflict = False
if hasattr(self, "unavailable_to_work"):
if (worker.id, week, day) in self.unavailable_to_work:
leave_conflict = True
if leave_conflict:
self.add_warning(
"Force assignment/leave conflict",
f"Worker '{worker.name}' has a forced assignment for shift '{shift_name}' on week {week}, day {day} (date: {self.get_date_by_week_day(week, day)}), which conflicts with a leave request.",
)
self.model.constraints.add(
self.model.works[worker.id, week, day, shift_name] == 1
)
else:
date = self.get_date_by_week_day(week, day)
self.add_warning(
"Invalid forced assignment",
f"Worker '{worker.name}' has forced assignment for non-existent shift: {shift_name} on {week}, {day}",
f"Worker '{worker.name}' has forced assignment for non-existent shift: {shift_name} on week {week}, day {day} (date: {date})",
)
if hasattr(worker, "max_shifts_per_week_by_shift_name"):
@@ -1468,8 +1482,23 @@ class RotaBuilder(object):
# Add hard shift dependencies for this worker
if hasattr(worker, "hard_day_dependencies"):
for day_group in worker.hard_day_dependencies:
for week in self.weeks:
for dep in worker.hard_day_dependencies:
day_group = dep.days
weeks_to_apply = self.weeks
if dep.weeks is not None:
weeks_to_apply = dep.weeks
if dep.start_date is not None or dep.end_date is not None:
if dep.start_date is None:
dep.start_date = self.start_date
if dep.end_date is None:
dep.end_date = self.rota_end_date
weeks_to_apply = self.get_weeks_by_date_range(
dep.start_date, dep.end_date
)
for week in weeks_to_apply:
assigned_any_shift = {}
for day in day_group:
shifts_today = self.get_shift_names_by_week_day(week, day)
@@ -1490,20 +1519,31 @@ class RotaBuilder(object):
# Add hard day exclusions for this worker
if hasattr(worker, "hard_day_exclusions"):
for day1, day2 in worker.hard_day_exclusions:
for week in self.weeks:
for exclusion in worker.hard_day_exclusions:
days_to_exclude = exclusion.days
weeks_to_apply = exclusion.weeks if exclusion.weeks is not None else self.weeks
for week in weeks_to_apply:
# For each week, prevent any shift being assigned on both days
self.model.constraints.add(
sum(
self.model.works[worker.id, week, day1, shift]
for shift in self.get_shift_names_by_week_day(week, day1)
)
+ sum(
self.model.works[worker.id, week, day2, shift]
for shift in self.get_shift_names_by_week_day(week, day2)
self.model.works[worker.id, week, day, shift]
for day in days_to_exclude
for shift in self.get_shift_names_by_week_day(week, day)
)
<= 1
)
#self.model.constraints.add(
# sum(
# self.model.works[worker.id, week, day1, shift]
# for shift in self.get_shift_names_by_week_day(week, day1)
# )
# + sum(
# self.model.works[worker.id, week, day2, shift]
# for shift in self.get_shift_names_by_week_day(week, day2)
# )
# <= 1
#)
for week, day in self.get_week_day_combinations():
@@ -2075,13 +2115,20 @@ class RotaBuilder(object):
for n, start, end in worker.non_working_day_list:
if not shift.rota_on_nwds and day == n:
# print(start, self.week_day_date_map[(week, day)], end)
# If this shift is force assigned, allow it but add a warning
if start <= self.week_day_date_map[(week, day)] < end:
self.model.constraints.add(
0
== self.model.works[
worker.id, week, day, shift.name
]
)
if (week, day, shift.name) in getattr(worker, "forced_assignments", []):
self.add_warning(
"Force assignment/NWD conflict",
f"Worker '{worker.name}' has a forced assignment for shift '{shift.name}' on week {week}, day {day} (date: {self.get_date_by_week_day(week, day)}), which is a non-working day."
)
else:
self.model.constraints.add(
0
== self.model.works[
worker.id, week, day, shift.name
]
)
if self.get_workers_who_require_locums():
if not worker.locum_on_nwds and day == n:
@@ -3115,6 +3162,18 @@ class RotaBuilder(object):
message = f"Worker with name '{worker.name}' ({worker.id}) has no valid shifts (site: {worker.site})"
self.add_warning("Worker/no valid shifts", message)
for date, shift_name in worker.forced_assignments_by_date:
try:
week, day = self.get_week_day_by_date(date)
worker.force_assign_shift(week, day, shift_name)
except WeekDayNotFound:
self.add_warning(
"Worker/forced assignment date not found",
f"Worker {worker.name} ({worker.id}) has a forced assignment for {date} ({shift_name}) but this date is not in the rota",
)
continue
self.workers = sorted(self.workers)
self.full_time_equivalent = sum(
@@ -3142,6 +3201,7 @@ class RotaBuilder(object):
for p in pairs:
self.worker_pairs.append(tuple(pairs[p]))
def add_worker_name_constraint_by_week(
self, names: Iterable[str], weeks: Iterable[int], shifts
):
@@ -3241,6 +3301,25 @@ class RotaBuilder(object):
def get_date_by_week_day(self, week: WeekInt, day: DayStr) -> datetime.date:
return self.week_day_date_map[(week, day)]
def get_weeks_by_date_range(self, start_date: datetime.date, end_date: datetime.date) -> list[WeekInt]:
"""Get a list of weeks that fall within a date range.
Args:
start_date (datetime.date): The start date of the range.
end_date (datetime.date): The end date of the range.
Returns:
list[WeekInt]: A list of week numbers that fall within the date range.
"""
# TODO: think about error if not monday start date?
weeks = []
for week in self.weeks:
week_start = self.get_date_by_week_day(week, "Mon")
week_end = self.get_date_by_week_day(week, "Sun")
if week_start >= start_date and week_end <= end_date:
weeks.append(week)
return weeks
def pair_shifts(self, *shifts: SingleShift):
# NOTE: currently designed to pair two shifts
# this could be extended...
@@ -3503,6 +3582,21 @@ class RotaBuilder(object):
"""
return self.weeks_days_product
def get_week_day_by_date(self, date: datetime.date) -> tuple[int, DayStr]:
"""Returns the week and day for a given date
Args:
date (datetime.date): Date to get week and day for
Returns:
tuple[int, DayStr]: Tuple containing week number and day string
"""
try:
week, day = self.date_week_day_map[date]
return week, day
except KeyError:
raise WeekDayNotFound(f"No week/day found for date {date}")
def get_week_day_combinations_for_shift(self, shift: SingleShift) -> list:
return [
(week, day)
@@ -3799,12 +3893,14 @@ class RotaBuilder(object):
"""
Force a specific worker to be assigned to a shift on a specific date.
"""
# Find (week, day) for this date
for (week, day), dt in self.week_day_date_map.items():
if dt == date:
self.force_assign_shift(worker_name, week, day, shift_name)
return
raise ValueError(f"Date {date} is not in the rota period.")
worker = self.get_worker_by_name(worker_name)
worker.forced_assignments_by_date.append((date, shift_name))
# RESULTS
@@ -4041,17 +4137,25 @@ class RotaBuilder(object):
assigned_shift_names = []
locum = False
shift_name = ""
force_assigned = False
for shift in self.get_shift_names_by_week_day(week, day):
shift_obj = self.get_shift_by_name(shift)
if model.works[worker.id, week, day, shift].value > 0.8:
shift_names.append(shift_obj.get_display_char())
assigned_shift_names.append(shift)
# Check if this shift was force assigned
if (week, day, shift) in getattr(worker, "forced_assignments", []):
force_assigned = True
elif self.get_workers_who_require_locums():
if model.locum_works[worker.id, week, day, shift].value > 0.8:
locum_shifts.append(shift)
shift_names.append(shift_obj.get_display_char())
assigned_shift_names.append(shift)
locum = True
# Check if this shift was force assigned
if (week, day, shift) in getattr(worker, "forced_assignments", []):
force_assigned = True
if shift_names:
shift_display_char = "<br/>".join(
[
@@ -4086,6 +4190,14 @@ class RotaBuilder(object):
unavailable_reason = "????"
title = f"{title} / {unavailable_reason}"
# --- Highlight force assigned shifts ---
if force_assigned:
css_class += " force-assigned"
title = f"{shift_name} ({d}) [FORCE ASSIGNED]"
else:
title = f"{shift_name} ({d})"
bank_holiday = ""
if d in self.bank_holidays:
title = f"{title} [Bank Holiday]"
@@ -4243,11 +4355,62 @@ class RotaBuilder(object):
**limits,
})
# Add warnings export
warnings_html = "<details><summary><h2>Warnings</h2></summary><pre>\n"
for warning_type, message in self.warnings:
warnings_html += f"{warning_type}: {message}\n"
warnings_html += "</pre></details>"
# Human-readable worker details export
worker_details_human = []
for worker in self.get_all_workers():
details = [
f"Name: {worker.name}",
f"ID: {worker.id}",
f"Site: {worker.site}",
f"Grade: {worker.grade}",
f"FTE: {worker.fte}",
f"Remote site: {getattr(worker, 'remote_site', '')}",
f"Pair: {getattr(worker, 'pair', '')}",
f"Start date: {getattr(worker, 'calculated_start_date', '')}",
f"End date: {getattr(worker, 'calculated_end_date', '')}",
f"Bank holiday extra: {getattr(worker, 'bank_holiday_extra', '')}",
f"Shift balance extra: {getattr(worker, 'shift_balance_extra', '')}",
f"Non-working days: {getattr(worker, 'non_working_day_list', '')}",
f"OOP: {getattr(worker, 'oop', '')}",
f"Shift targets: {json.dumps(getattr(worker, 'shift_target_number', {}))}",
]
worker_details_human.append("\n".join(details))
worker_details_human_str = "\n\n".join(worker_details_human)
# Collect force assigned shifts for export
force_assigned_info = []
for worker in self.get_all_workers():
for assignment in getattr(worker, "forced_assignments", []):
# Support both tuple and pydantic model
if hasattr(assignment, "week") and hasattr(assignment, "day") and hasattr(assignment, "shift_name"):
week = assignment.week
day = assignment.day
shift_name = assignment.shift_name
else:
week, day, shift_name = assignment
date = self.get_date_by_week_day(week, day)
force_assigned_info.append({
"worker": worker.name,
"week": week,
"day": day,
"date": str(date),
"shift": shift_name,
})
html = f"""
<body>
<div class="table-div" id="{table_name}">
<table id="main-table">{joined_timetable}</table>
</div>
{warnings_html}
<details>
<summary><h2>Rota settings</h2></summary>
<div>
@@ -4267,6 +4430,9 @@ class RotaBuilder(object):
<div id="worker_details" data-workers='{json.dumps([json.loads(worker.model_dump_json()) for worker in self.get_all_workers()])}'>
<span id="workers-json-target"></span>
<pre>
{worker_details_human_str}
</pre>
</div>
</details>
<details>
@@ -4297,6 +4463,10 @@ class RotaBuilder(object):
{"TEST"}
{self.locum_availability_map}
<br/>
<h3>Force assigned shifts</h3>
<pre>
{json.dumps(force_assigned_info, indent=4)}
</pre>
<div>
</details>
<details><summary><h2>Hard Constrain Shift Min/Max</h2></summary>