Compare commits
7
Commits
4bb4f5b97a
...
f90e6895b4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f90e6895b4 | ||
|
|
827753644f | ||
|
|
935b4baaba | ||
|
|
dee80ed4fa | ||
|
|
a071b1b027 | ||
|
|
ee263aade1 | ||
|
|
68b80ace23 |
+59
-47
@@ -15,23 +15,36 @@ from rota_generator.workers import (
|
|||||||
NonWorkingDays,
|
NonWorkingDays,
|
||||||
WorkRequests,
|
WorkRequests,
|
||||||
PreferenceNotToWork,
|
PreferenceNotToWork,
|
||||||
OutOfProgramme,
|
OutOfProgramme, MaxUniqueShiftsPerWeekBlockConstraint,
|
||||||
)
|
)
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
app = typer.Typer()
|
app = typer.Typer()
|
||||||
|
|
||||||
|
ROTA_REQUESTS = "cons/requests.ods"
|
||||||
|
ROTA_B_PATH = "cons/rota_b.xlsx"
|
||||||
|
ROTA_START_DATE = "2026-08-03"
|
||||||
|
|
||||||
|
|
||||||
sites = ("rota a", "rota b", "rota c")
|
sites = ("rota a", "rota b", "rota c")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_sheet_date(value):
|
||||||
|
if pd.isna(value):
|
||||||
|
return None
|
||||||
|
parsed = pd.to_datetime(value, errors="coerce")
|
||||||
|
if pd.isna(parsed):
|
||||||
|
return None
|
||||||
|
return parsed.date()
|
||||||
|
|
||||||
def extract_leave_and_rota_from_calender(calender_df):
|
def extract_leave_and_rota_from_calender(calender_df):
|
||||||
"""
|
"""
|
||||||
Extract leave and rota assignments from calender_df.
|
Extract leave and rota assignments from calender_df.
|
||||||
- The first row contains worker names, starting from the 5th column (index 4).
|
- The first row contains worker names, starting from the 5th column (index 4).
|
||||||
- The second column (index 1) contains dates in dd/mm/yyyy format.
|
- The second column (index 1) contains dates in dd/mm/yyyy format.
|
||||||
- The second column (index 1) also contains the rota/leave code for that date.
|
- The second column (index 1) also contains the rota/leave code for that date.
|
||||||
Returns a list of dicts: {"date": ..., "worker": ..., "rota": ...}
|
Returns a list of dicts: {"date": ..., "/worker": ..., "rota": ...}
|
||||||
"""
|
"""
|
||||||
# Get worker names from the first row, starting from column 5 (index 4)
|
# Get worker names from the first row, starting from column 5 (index 4)
|
||||||
worker_names = [str(x).strip() for x in calender_df.columns[5:]]
|
worker_names = [str(x).strip() for x in calender_df.columns[5:]]
|
||||||
@@ -49,15 +62,11 @@ def extract_leave_and_rota_from_calender(calender_df):
|
|||||||
work_requests = []
|
work_requests = []
|
||||||
# Process leave data from row 3 onwards (index 2 and up)
|
# Process leave data from row 3 onwards (index 2 and up)
|
||||||
for idx, row in calender_df.iloc[3:].iterrows():
|
for idx, row in calender_df.iloc[3:].iterrows():
|
||||||
date_str = str(row.iloc[1]).strip().split(" ")[0] # Get the date string from the second column (index 1)
|
date = _parse_sheet_date(row.iloc[1])
|
||||||
if not date_str or date_str.lower() == "nan":
|
if date is None:
|
||||||
continue
|
continue
|
||||||
try:
|
|
||||||
date = datetime.datetime.strptime(date_str, "%Y-%m-%d").date()
|
|
||||||
except Exception:
|
|
||||||
continue # skip rows with invalid date
|
|
||||||
|
|
||||||
if date < datetime.date(2026, 2, 16):
|
if date < datetime.date.fromisoformat(ROTA_START_DATE):
|
||||||
continue # skip rows before rota start date
|
continue # skip rows before rota start date
|
||||||
for i, worker in enumerate(worker_names):
|
for i, worker in enumerate(worker_names):
|
||||||
if not worker or worker.lower() == "nan":
|
if not worker or worker.lower() == "nan":
|
||||||
@@ -96,29 +105,30 @@ def extract_shift_assignments_from_rota(rota_df):
|
|||||||
assignments = []
|
assignments = []
|
||||||
|
|
||||||
skipped_rows = 0
|
skipped_rows = 0
|
||||||
|
rota_start = datetime.date.fromisoformat(ROTA_START_DATE)
|
||||||
for idx, row in rota_df.iloc[2:].iterrows():
|
for idx, row in rota_df.iloc[2:].iterrows():
|
||||||
week_start_str = str(row.iloc[0]).strip().split(" ")[0] # Get the week start date from the first column (index 0)
|
week_start = _parse_sheet_date(row.iloc[0])
|
||||||
try:
|
if week_start is None:
|
||||||
week_start = datetime.datetime.strptime(week_start_str, "%Y-%m-%d").date()
|
print(f"Invalid date format in row {idx}: {row.iloc[0]}")
|
||||||
except Exception:
|
|
||||||
print(f"Invalid date format in row {idx}: {week_start_str}")
|
|
||||||
continue # skip rows with invalid date
|
continue # skip rows with invalid date
|
||||||
|
|
||||||
if week_start < datetime.date(2026, 2, 16):
|
if week_start < rota_start:
|
||||||
skipped_rows += 1
|
skipped_rows += 1
|
||||||
continue # skip rows before rota start date
|
continue # skip rows before rota start date
|
||||||
|
|
||||||
|
week = ((week_start - rota_start).days // 7) + 1
|
||||||
|
|
||||||
for i, day in enumerate(days):
|
for i, day in enumerate(days):
|
||||||
for j, shift in enumerate(shifts):
|
for j, shift in enumerate(shifts):
|
||||||
if i < 5:
|
if i < 5:
|
||||||
# Mon-Fri: two columns per day (twilight, oncall)
|
# Mon-Fri: two columns per day (twilight, oncall)
|
||||||
col_idx = 1 + i * 2 + j # 1 for Mon twilight, 2 for Mon oncall, etc.
|
col_idx = 2 + i * 2 + j # 2 for Mon twilight, 3 for Mon oncall, etc.
|
||||||
weekend = False
|
weekend = False
|
||||||
else:
|
else:
|
||||||
# Sat/Sun: only one column for "weekend" shift (twilight), skip "night"
|
# Sat/Sun: only one column for "weekend" shift (twilight), skip "night"
|
||||||
weekend = True
|
weekend = True
|
||||||
if j == 0:
|
if j == 0:
|
||||||
col_idx = 11 + (i - 5) # 11 for Sat, 12 for Sun
|
col_idx = 12 + (i - 5) # 12 for Sat, 13 for Sun
|
||||||
else:
|
else:
|
||||||
continue # No night shift column for Sat/Sun
|
continue # No night shift column for Sat/Sun
|
||||||
if col_idx >= len(row):
|
if col_idx >= len(row):
|
||||||
@@ -131,7 +141,7 @@ def extract_shift_assignments_from_rota(rota_df):
|
|||||||
shift = "weekend"
|
shift = "weekend"
|
||||||
assignments.append({
|
assignments.append({
|
||||||
"week_start": week_start,
|
"week_start": week_start,
|
||||||
"week": idx-1-skipped_rows, # 1-indexed week number
|
"week": week,
|
||||||
"day": day,
|
"day": day,
|
||||||
"date": week_start + datetime.timedelta(days=i),
|
"date": week_start + datetime.timedelta(days=i),
|
||||||
"shift": shift,
|
"shift": shift,
|
||||||
@@ -149,7 +159,7 @@ def extract_shift_assignments_from_rota(rota_df):
|
|||||||
|
|
||||||
def load_workers():
|
def load_workers():
|
||||||
# Path to the ODS file
|
# Path to the ODS file
|
||||||
ods_path = "cons/CONSULTANT TWILIGHT & ONCALL ROTA.ods"
|
ods_path = ROTA_REQUESTS
|
||||||
|
|
||||||
# Read all sheets
|
# Read all sheets
|
||||||
xls = pd.read_excel(ods_path, engine="odf", sheet_name=None)
|
xls = pd.read_excel(ods_path, engine="odf", sheet_name=None)
|
||||||
@@ -160,13 +170,19 @@ def load_workers():
|
|||||||
rota_df = xls["Rota"]
|
rota_df = xls["Rota"]
|
||||||
|
|
||||||
|
|
||||||
rota_b_path = "cons/Rota B Feb -July 2026.xlsx"
|
rota_b_path = ROTA_B_PATH
|
||||||
rota_b_df = pd.read_excel(rota_b_path, engine="openpyxl", sheet_name="Sheet1", header=None)
|
rota_b_df = pd.read_excel(rota_b_path, engine="openpyxl", sheet_name="Sheet1", header=None)
|
||||||
# Extract rota_b assignments: date in column A, worker in column B
|
# Extract rota_b assignments: date in column A, worker in column B
|
||||||
rota_b_assignments = defaultdict(list)
|
rota_b_assignments = defaultdict(list)
|
||||||
for idx, row in rota_b_df.iterrows():
|
for idx, row in rota_b_df.iterrows():
|
||||||
|
logger.debug(f"{idx}: {row}")
|
||||||
date_val = row.iloc[0]
|
date_val = row.iloc[0]
|
||||||
worker_val = row.iloc[1].upper() if len(row) > 1 else None
|
try:
|
||||||
|
worker_val = row.iloc[1].upper() if len(row) > 1 else None
|
||||||
|
except AttributeError:
|
||||||
|
# end of file
|
||||||
|
break
|
||||||
|
|
||||||
if pd.isna(date_val) or pd.isna(worker_val):
|
if pd.isna(date_val) or pd.isna(worker_val):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
@@ -236,19 +252,15 @@ def load_workers():
|
|||||||
rota_data = {}
|
rota_data = {}
|
||||||
for idx, row in days_df.iloc[7:].iterrows():
|
for idx, row in days_df.iloc[7:].iterrows():
|
||||||
# Extract initials from the name column (assumed to be in brackets at the end)
|
# Extract initials from the name column (assumed to be in brackets at the end)
|
||||||
text = str(row.iloc[1]).strip()
|
name = str(row.iloc[1]).strip()
|
||||||
if "(" in text and ")" in text:
|
initial = str(row.iloc[2]).strip()
|
||||||
initial = text.split("(")[-1].split(")")[0].strip()
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Invalid format in row {idx}: {text}")
|
|
||||||
name = text.split("(")[0].strip()
|
|
||||||
if not name or name.lower() == "nan":
|
if not name or name.lower() == "nan":
|
||||||
continue
|
continue
|
||||||
availability = {}
|
availability = {}
|
||||||
requests = {}
|
requests = {}
|
||||||
rota_data[name] = f"rota {str(row.iloc[2]).strip().lower()}"
|
rota_data[name] = f"rota {str(row.iloc[3]).strip().lower()}"
|
||||||
for i, day in enumerate(weekday_cols):
|
for i, day in enumerate(weekday_cols):
|
||||||
val = row.iloc[3 + i]
|
val = row.iloc[4 + i]
|
||||||
# You can adjust the logic below depending on your marking scheme (e.g. "Y", "Yes", "1", etc.)
|
# You can adjust the logic below depending on your marking scheme (e.g. "Y", "Yes", "1", etc.)
|
||||||
available = not ("no" in str(val).strip().lower() or str(val).strip().lower() == "yes*")
|
available = not ("no" in str(val).strip().lower() or str(val).strip().lower() == "yes*")
|
||||||
availability[day] = available
|
availability[day] = available
|
||||||
@@ -361,7 +373,8 @@ def load_workers():
|
|||||||
worker = worker_day["name"]
|
worker = worker_day["name"]
|
||||||
initial = worker_day["initial"]
|
initial = worker_day["initial"]
|
||||||
|
|
||||||
start_date = "2026-02-16" # Default start date, can be adjusted later
|
start_date = ROTA_START_DATE
|
||||||
|
|
||||||
|
|
||||||
#if initial == "ND":
|
#if initial == "ND":
|
||||||
# start_date = "2025-09-29" # Non-working day worker starts later
|
# start_date = "2025-09-29" # Non-working day worker starts later
|
||||||
@@ -402,10 +415,9 @@ def load_workers():
|
|||||||
],
|
],
|
||||||
nwds=non_working_days,
|
nwds=non_working_days,
|
||||||
previous_shifts=priors_map.get(initial, {}),
|
previous_shifts=priors_map.get(initial, {}),
|
||||||
|
max_days_worked_per_week=2
|
||||||
)
|
)
|
||||||
|
|
||||||
if initial == "LB":
|
|
||||||
w.start_date = datetime.date(2026, 6, 15)
|
|
||||||
|
|
||||||
#if initial == "L1":
|
#if initial == "L1":
|
||||||
# w.oop = [OutOfProgramme(
|
# w.oop = [OutOfProgramme(
|
||||||
@@ -447,19 +459,16 @@ def load_workers():
|
|||||||
#w.add_force_assign_with("twilight", "oncall")
|
#w.add_force_assign_with("twilight", "oncall")
|
||||||
w.add_force_assign_with("weekend", "oncall")
|
w.add_force_assign_with("weekend", "oncall")
|
||||||
|
|
||||||
if w.name in ("DS",):
|
if w.name in ("DS","RG"):
|
||||||
w.add_hard_day_dependency({"Fri", "Sat", "Sun"}, ignore_dates=[datetime.date(2026, 5, 2)])
|
w.add_hard_day_dependency({"Fri", "Sat", "Sun"}, ignore_dates=[datetime.date(2026, 5, 2)])
|
||||||
|
w.max_days_worked_per_week = 3
|
||||||
else:
|
else:
|
||||||
w.add_hard_day_dependency({"Fri", "Sun"})
|
w.add_hard_day_dependency({"Fri", "Sun"})
|
||||||
w.add_hard_day_exclusion({"Sat", "Sun"})
|
w.add_hard_day_exclusion({"Sat", "Sun"})
|
||||||
|
else:
|
||||||
|
w.max_unique_shifts_per_week_block = [MaxUniqueShiftsPerWeekBlockConstraint(week_block=1, max_unique_shifts=1)]
|
||||||
|
|
||||||
|
|
||||||
#if w.name == "TB":
|
|
||||||
# w.add_hard_day_exclusion({"Sat", "Sun"}, start_date="2025-11-17")
|
|
||||||
|
|
||||||
|
|
||||||
#w.set_max_shifts
|
|
||||||
#w.set_max_shifts_per_week("weekend", 1)
|
|
||||||
|
|
||||||
workers.append(w)
|
workers.append(w)
|
||||||
|
|
||||||
@@ -472,9 +481,9 @@ def main(
|
|||||||
suspend: bool = False,
|
suspend: bool = False,
|
||||||
solve: bool = True,
|
solve: bool = True,
|
||||||
time_to_run: int = 60 * 60,
|
time_to_run: int = 60 * 60,
|
||||||
ratio: float = 0.001,
|
ratio: float = 0.1,
|
||||||
start_date: datetime.datetime = "2026-02-16",
|
start_date: datetime.datetime = ROTA_START_DATE,
|
||||||
weeks: int = 24,
|
weeks: int = 22,
|
||||||
bom: int = 1,
|
bom: int = 1,
|
||||||
):
|
):
|
||||||
rota_start_date = start_date.date()
|
rota_start_date = start_date.date()
|
||||||
@@ -485,13 +494,13 @@ def main(
|
|||||||
weeks_to_rota=weeks,
|
weeks_to_rota=weeks,
|
||||||
balance_offset_modifier=bom,
|
balance_offset_modifier=bom,
|
||||||
use_previous_shifts=True,
|
use_previous_shifts=True,
|
||||||
name="cons rota feb 2026 run 5",
|
name="cons rota 2026 july",
|
||||||
allow_force_assignment_with_leave_conflict=True,
|
allow_force_assignment_with_leave_conflict=True,
|
||||||
constraint_options=RotaConstraintOptions(
|
constraint_options=RotaConstraintOptions(
|
||||||
balance_weekends=True,
|
balance_weekends=True,
|
||||||
max_shifts_per_week=6,
|
max_shifts_per_week=6,
|
||||||
max_weekend_frequency=3,
|
max_weekend_frequency=3,
|
||||||
max_days_per_week_block=[(4, 2), (4, 4)],
|
max_days_per_week_block=[(3, 2), (4, 4)],
|
||||||
#balance_weekend_days=True,
|
#balance_weekend_days=True,
|
||||||
balance_weekend_days_quadratic=True,
|
balance_weekend_days_quadratic=True,
|
||||||
weekend_day_hard_max_deviation=2,
|
weekend_day_hard_max_deviation=2,
|
||||||
@@ -512,8 +521,8 @@ def main(
|
|||||||
balance_offset=2,
|
balance_offset=2,
|
||||||
assign_as_block=False,
|
assign_as_block=False,
|
||||||
constraints=[
|
constraints=[
|
||||||
PreShiftConstraint(days=2, start_date="2025-11-17", exclude_days=("Fri", "Sat", "Sun"), exclude_dates=["2026-04-06", "2026-05-04", "2026-05-25"]),
|
#PreShiftConstraint(days=2, start_date="2025-11-17", exclude_days=("Fri", "Sat", "Sun"), exclude_dates=["2026-04-06", "2026-05-04", "2026-05-25"]),
|
||||||
PreShiftConstraint(days=1, start_date="2025-11-17", exclude_days=("Fri", "Sat", "Sun"), allow_self=False),
|
#PreShiftConstraint(days=1, start_date="2025-11-17", exclude_days=("Fri", "Sat", "Sun"), allow_self=False),
|
||||||
MaxShiftsPerWeekConstraint(max_shifts=1, days=days[:5]),
|
MaxShiftsPerWeekConstraint(max_shifts=1, days=days[:5]),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -533,7 +542,7 @@ def main(
|
|||||||
SingleShift(
|
SingleShift(
|
||||||
sites=("rota b", "rota c"),
|
sites=("rota b", "rota c"),
|
||||||
name="weekend b",
|
name="weekend b",
|
||||||
end_date="2026-07-20",
|
#end_date="2026-07-20",
|
||||||
workers_required=1,
|
workers_required=1,
|
||||||
length=12.5,
|
length=12.5,
|
||||||
days=days[5:],
|
days=days[5:],
|
||||||
@@ -685,6 +694,9 @@ def main(
|
|||||||
subprocess.run(
|
subprocess.run(
|
||||||
["scp", Rota.exported_rota_file, "ross@46.101.13.46:proc/proc-rota/output/"]
|
["scp", Rota.exported_rota_file, "ross@46.101.13.46:proc/proc-rota/output/"]
|
||||||
)
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["scp", "output/timetable.js", "ross@46.101.13.46:proc/proc-rota/output/"]
|
||||||
|
)
|
||||||
|
|
||||||
if suspend_on_finish:
|
if suspend_on_finish:
|
||||||
os.system("systemctl suspend")
|
os.system("systemctl suspend")
|
||||||
|
|||||||
+264
-8
@@ -410,17 +410,43 @@ function renderWorkerList() {
|
|||||||
const filter = ($("#worker-filter").val() || '').toLowerCase();
|
const filter = ($("#worker-filter").val() || '').toLowerCase();
|
||||||
const shiftFilter = ($("#worker-shift-select").val() || '');
|
const shiftFilter = ($("#worker-shift-select").val() || '');
|
||||||
|
|
||||||
|
// Parse the original pre block into per-worker text blocks so rich details are preserved.
|
||||||
|
const detailsByName = {};
|
||||||
|
const preText = (($wd.find("pre").first().text() || '') + '').trim();
|
||||||
|
const escapeHtml = (text) => String(text)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
if (preText.length > 0) {
|
||||||
|
preText.split(/\n\s*\n(?=Name:\s*)/g).forEach(block => {
|
||||||
|
const trimmed = (block || '').trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
const nameMatch = trimmed.match(/^Name:\s*(.+)$/m);
|
||||||
|
if (!nameMatch) return;
|
||||||
|
detailsByName[nameMatch[1].trim()] = trimmed;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let $list = $("#worker-list");
|
let $list = $("#worker-list");
|
||||||
if ($list.length === 0) {
|
if ($list.length === 0) {
|
||||||
$list = $("<div id='worker-list' style='margin-top:8px;'></div>");
|
$list = $("<div id='worker-list' style='margin-top:8px;'></div>");
|
||||||
$("#worker_details").append($list);
|
$("#worker_details").append($list);
|
||||||
}
|
}
|
||||||
|
// Hide the static pre block since we render interactive cards
|
||||||
|
$("#worker_details pre").hide();
|
||||||
|
$list.show();
|
||||||
$list.empty();
|
$list.empty();
|
||||||
if (!workers) return;
|
if (!workers) return;
|
||||||
workers.forEach(w => {
|
workers.forEach(w => {
|
||||||
const name = (w.name || '') + '';
|
const name = (w.name || '') + '';
|
||||||
const site = (w.site || '') + '';
|
const site = (w.site || '') + '';
|
||||||
if (filter && name.toLowerCase().indexOf(filter) === -1 && site.toLowerCase().indexOf(filter) === -1) return;
|
if (filter.length > 0) {
|
||||||
|
const matchesName = name.toLowerCase().indexOf(filter) !== -1;
|
||||||
|
const matchesSite = site.toLowerCase().indexOf(filter) !== -1;
|
||||||
|
if (!matchesName && !matchesSite) return;
|
||||||
|
}
|
||||||
// if shiftFilter, ensure worker has target or assigned for that shift
|
// if shiftFilter, ensure worker has target or assigned for that shift
|
||||||
// attempt to read per-worker targets and counts from DOM if not present in JSON
|
// attempt to read per-worker targets and counts from DOM if not present in JSON
|
||||||
let targets = w.shift_target_number || {};
|
let targets = w.shift_target_number || {};
|
||||||
@@ -443,21 +469,242 @@ function renderWorkerList() {
|
|||||||
if (!hasTarget && !hasAssigned) return;
|
if (!hasTarget && !hasAssigned) return;
|
||||||
}
|
}
|
||||||
const fte = w.fte || '';
|
const fte = w.fte || '';
|
||||||
const card = $(`<div class='worker-card' style='border:1px solid #ccc;padding:8px;margin-bottom:6px;'><strong>${name}</strong> <span style='color:#666'>${site}</span><div>FTE: ${fte}</div></div>`);
|
const grade = w.grade || '';
|
||||||
|
const id = w.id ? String(w.id).substring(0, 8) : '';
|
||||||
|
const card = $(`<div class='worker-card'><div class='worker-header'><strong>${name}</strong> <span class='worker-id'>${id}</span></div><div class='worker-meta'><span class='worker-site'>${site}</span> • <span class='worker-grade'>Grade: ${grade}</span> • <span class='worker-fte'>FTE: ${fte}%</span></div></div>`);
|
||||||
// show shift targets summary
|
// show shift targets summary
|
||||||
if (w.shift_target_number) {
|
if (targets && Object.keys(targets).length > 0) {
|
||||||
const tbl = $("<table style='margin-top:6px; width:100%;'></table>");
|
const tbl = $("<table class='worker-shifts-table'></table>");
|
||||||
Object.keys(w.shift_target_number).forEach(s => {
|
const header = $("<thead><tr><th>Shift</th><th class='text-right'>Assigned</th><th class='text-right'>Target</th></tr></thead>");
|
||||||
const t = w.shift_target_number[s];
|
tbl.append(header);
|
||||||
const c = (w.shift_counts && w.shift_counts[s]) ? w.shift_counts[s] : 0;
|
const tbody = $("<tbody></tbody>");
|
||||||
tbl.append(`<tr><td style='width:60%'>${s}</td><td style='text-align:right'>${c} assigned</td><td style='text-align:right'>target ${parseFloat(t).toFixed(2)}</td></tr>`);
|
Object.keys(targets).forEach(s => {
|
||||||
|
const t = targets[s];
|
||||||
|
const c = (counts && counts[s]) ? counts[s] : 0;
|
||||||
|
tbody.append(`<tr><td>${s}</td><td class='text-right'>${c}</td><td class='text-right'>${parseFloat(t).toFixed(2)}</td></tr>`);
|
||||||
});
|
});
|
||||||
|
tbl.append(tbody);
|
||||||
card.append(tbl);
|
card.append(tbl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Include the original full worker detail text block so no information is lost.
|
||||||
|
const fullDetails = detailsByName[name] || '';
|
||||||
|
if (fullDetails) {
|
||||||
|
card.append(`<details class='worker-full-details'><summary>Full details</summary><pre>${escapeHtml(fullDetails)}</pre></details>`);
|
||||||
|
}
|
||||||
$list.append(card);
|
$list.append(card);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add CSS for worker details styling and comprehensive dark mode support
|
||||||
|
if ($('#worker-details-style').length === 0) {
|
||||||
|
$('head').append(`<style id='worker-details-style'>
|
||||||
|
/* ===== Light mode defaults ===== */
|
||||||
|
body {
|
||||||
|
background-color: #fff;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
#worker_details {
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
#worker-filter, #worker-shift-select {
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
#worker-filter:focus, #worker-shift-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #0066cc;
|
||||||
|
box-shadow: 0 0 4px rgba(0,102,204,0.3);
|
||||||
|
}
|
||||||
|
.worker-card {
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
transition: background-color 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
.worker-card:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-color: #999;
|
||||||
|
}
|
||||||
|
.worker-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.worker-header strong {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.worker-id {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: #999;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.worker-meta {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.worker-site, .worker-grade, .worker-fte {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
.worker-shifts-table {
|
||||||
|
margin-top: 6px;
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9em;
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.worker-shifts-table thead {
|
||||||
|
background: #f0f0f0;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.worker-shifts-table th, .worker-shifts-table td {
|
||||||
|
padding: 4px 6px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.worker-shifts-table tr:nth-child(even) {
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
.worker-full-details {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.worker-full-details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.worker-full-details pre {
|
||||||
|
margin-top: 6px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.text-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #000;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
input, select, textarea {
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
table th {
|
||||||
|
background: #f9f9f9;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
table td {
|
||||||
|
border-color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Dark mode overrides (toggled via html.dark-mode class) ===== */
|
||||||
|
html.dark-mode body {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
html.dark-mode #worker_details {
|
||||||
|
background: #2d2d2d;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
html.dark-mode #worker-filter, html.dark-mode #worker-shift-select {
|
||||||
|
background: #3d3d3d;
|
||||||
|
color: #e0e0e0;
|
||||||
|
border: 1px solid #555;
|
||||||
|
}
|
||||||
|
html.dark-mode #worker-filter:focus, html.dark-mode #worker-shift-select:focus {
|
||||||
|
border-color: #0088ff;
|
||||||
|
box-shadow: 0 0 4px rgba(0,136,255,0.5);
|
||||||
|
}
|
||||||
|
html.dark-mode .worker-card {
|
||||||
|
background: #2d2d2d;
|
||||||
|
border-color: #444;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
html.dark-mode .worker-card:hover {
|
||||||
|
background: #333;
|
||||||
|
border-color: #666;
|
||||||
|
}
|
||||||
|
html.dark-mode .worker-header strong {
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
html.dark-mode .worker-meta, html.dark-mode .worker-id {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
html.dark-mode .worker-shifts-table {
|
||||||
|
background: #2d2d2d;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
html.dark-mode .worker-shifts-table thead {
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
html.dark-mode .worker-shifts-table th, html.dark-mode .worker-shifts-table td {
|
||||||
|
border-color: #444;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
html.dark-mode .worker-shifts-table tr:nth-child(even) {
|
||||||
|
background: #252525;
|
||||||
|
}
|
||||||
|
html.dark-mode .worker-full-details summary {
|
||||||
|
color: #cfd8e3;
|
||||||
|
}
|
||||||
|
html.dark-mode pre {
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
border: 1px solid #444;
|
||||||
|
}
|
||||||
|
html.dark-mode input, html.dark-mode select, html.dark-mode textarea {
|
||||||
|
background: #3d3d3d;
|
||||||
|
color: #e0e0e0;
|
||||||
|
border: 1px solid #555;
|
||||||
|
}
|
||||||
|
html.dark-mode table {
|
||||||
|
background: #2d2d2d;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
html.dark-mode table th {
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
html.dark-mode table td {
|
||||||
|
border-color: #444;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
html.dark-mode h1, html.dark-mode h2, html.dark-mode h3, html.dark-mode h4, html.dark-mode h5, html.dark-mode h6 {
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
html.dark-mode a {
|
||||||
|
color: #4db8ff;
|
||||||
|
}
|
||||||
|
html.dark-mode a:visited {
|
||||||
|
color: #8866ff;
|
||||||
|
}
|
||||||
|
html.dark-mode button {
|
||||||
|
background: #3d3d3d;
|
||||||
|
color: #e0e0e0;
|
||||||
|
border: 1px solid #555;
|
||||||
|
}
|
||||||
|
html.dark-mode button:hover {
|
||||||
|
background: #4d4d4d;
|
||||||
|
}
|
||||||
|
</style>`);
|
||||||
|
}
|
||||||
|
|
||||||
// initialize panels
|
// initialize panels
|
||||||
renderShiftSettings();
|
renderShiftSettings();
|
||||||
renderWorkerFilterOptions();
|
renderWorkerFilterOptions();
|
||||||
@@ -492,6 +739,15 @@ if ($('#tsummary-style').length === 0) {
|
|||||||
|
|
||||||
/* Slightly thinner avoid highlight for compact layout */
|
/* Slightly thinner avoid highlight for compact layout */
|
||||||
td.rota-day.avoid-shift.avoid-highlight { border-width: 1.5px !important; box-shadow: 0 0 4px rgba(255,140,0,0.18); }
|
td.rota-day.avoid-shift.avoid-highlight { border-width: 1.5px !important; box-shadow: 0 0 4px rgba(255,140,0,0.18); }
|
||||||
|
|
||||||
|
/* Higher-contrast dark mode for timetable readability */
|
||||||
|
html.dark-mode table.tsummary { background: #111922; color: #e8eef5; }
|
||||||
|
html.dark-mode table.tsummary th,
|
||||||
|
html.dark-mode table.tsummary td { border-color: #516274; }
|
||||||
|
html.dark-mode table.tsummary th { background: #253241; color: #f5f8fc; }
|
||||||
|
html.dark-mode table.tsummary td { background: #16212d; color: #e8eef5; }
|
||||||
|
html.dark-mode table.tsummary tr:nth-child(even) td { background: #1a2734; }
|
||||||
|
html.dark-mode table.tsummary td.target-assigned { color: #f2f6fb; }
|
||||||
</style>
|
</style>
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1055,6 +1055,35 @@ class RotaBuilder(object):
|
|||||||
initialize=0,
|
initialize=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Helper binary variables for per-worker unique-shift block limits.
|
||||||
|
# Index is (worker_id, block_length, block_start_week, shift_name).
|
||||||
|
unique_shift_block_indices = []
|
||||||
|
for worker in self.workers:
|
||||||
|
for constraint in worker.max_unique_shifts_per_week_block:
|
||||||
|
for week_blocks in self.get_week_block_iterator(constraint.week_block):
|
||||||
|
if not week_blocks:
|
||||||
|
continue
|
||||||
|
block_start_week = week_blocks[0]
|
||||||
|
for shift_name in self.get_shift_names():
|
||||||
|
if any(
|
||||||
|
shift_name in self.get_shift_names_by_week_day(
|
||||||
|
week,
|
||||||
|
day,
|
||||||
|
return_empty_if_week_day_not_found=True,
|
||||||
|
)
|
||||||
|
for week in week_blocks
|
||||||
|
for day in days
|
||||||
|
):
|
||||||
|
unique_shift_block_indices.append(
|
||||||
|
(worker.id, constraint.week_block, block_start_week, shift_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.model.worker_shift_used_in_week_block = Var(
|
||||||
|
unique_shift_block_indices,
|
||||||
|
within=Binary,
|
||||||
|
initialize=0,
|
||||||
|
)
|
||||||
|
|
||||||
if self.constraint_options_model.balance_bank_holidays:
|
if self.constraint_options_model.balance_bank_holidays:
|
||||||
# Bank holidays
|
# Bank holidays
|
||||||
self.model.bank_holiday_count = Var(
|
self.model.bank_holiday_count = Var(
|
||||||
@@ -2374,6 +2403,81 @@ class RotaBuilder(object):
|
|||||||
<= max_days
|
<= max_days
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for constraint in worker.max_days_per_week_block:
|
||||||
|
for week_blocks in self.get_week_block_iterator(constraint.week_block):
|
||||||
|
# Determine which (week, day) combinations to ignore
|
||||||
|
ignored_week_days = set()
|
||||||
|
for ignore_date in (constraint.ignore_dates or []):
|
||||||
|
try:
|
||||||
|
w, d = self.date_week_day_map.get(ignore_date, (None, None))
|
||||||
|
if w is not None and d is not None:
|
||||||
|
ignored_week_days.add((w, d))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
included_days = [
|
||||||
|
(week, day)
|
||||||
|
for week, day in self.get_week_day_combinations()
|
||||||
|
if week in week_blocks and (week, day) not in ignored_week_days
|
||||||
|
]
|
||||||
|
if included_days:
|
||||||
|
self.model.constraints.add(
|
||||||
|
sum(
|
||||||
|
self.model.works_day[worker.id, week, day]
|
||||||
|
for week, day in included_days
|
||||||
|
)
|
||||||
|
<= constraint.max_days
|
||||||
|
)
|
||||||
|
|
||||||
|
for constraint in worker.max_unique_shifts_per_week_block:
|
||||||
|
for week_blocks in self.get_week_block_iterator(constraint.week_block):
|
||||||
|
if not week_blocks:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Determine which (week, day) combinations to ignore
|
||||||
|
ignored_week_days = set()
|
||||||
|
for ignore_date in (constraint.ignore_dates or []):
|
||||||
|
try:
|
||||||
|
w, d = self.date_week_day_map.get(ignore_date, (None, None))
|
||||||
|
if w is not None and d is not None:
|
||||||
|
ignored_week_days.add((w, d))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
block_start_week = week_blocks[0]
|
||||||
|
used_shift_vars = []
|
||||||
|
for shift_name in self.get_shift_names():
|
||||||
|
shift_assignments = []
|
||||||
|
for week, day in self.get_week_day_combinations():
|
||||||
|
if week in week_blocks and (week, day) not in ignored_week_days:
|
||||||
|
if shift_name in self.get_shift_names_by_week_day(
|
||||||
|
week,
|
||||||
|
day,
|
||||||
|
return_empty_if_week_day_not_found=True,
|
||||||
|
):
|
||||||
|
shift_assignments.append(
|
||||||
|
self.model.works[worker.id, week, day, shift_name]
|
||||||
|
)
|
||||||
|
if not shift_assignments:
|
||||||
|
continue
|
||||||
|
|
||||||
|
used_shift_var = self.model.worker_shift_used_in_week_block[
|
||||||
|
worker.id,
|
||||||
|
constraint.week_block,
|
||||||
|
block_start_week,
|
||||||
|
shift_name,
|
||||||
|
]
|
||||||
|
used_shift_vars.append(used_shift_var)
|
||||||
|
self.model.constraints.add(
|
||||||
|
sum(shift_assignments)
|
||||||
|
<= len(shift_assignments) * used_shift_var
|
||||||
|
)
|
||||||
|
|
||||||
|
if used_shift_vars:
|
||||||
|
self.model.constraints.add(
|
||||||
|
sum(used_shift_vars) <= constraint.max_unique_shifts
|
||||||
|
)
|
||||||
|
|
||||||
for week_blocks in self.get_week_block_iterator(4):
|
for week_blocks in self.get_week_block_iterator(4):
|
||||||
# Prevent more than n number shifts per 4 weeks
|
# Prevent more than n number shifts per 4 weeks
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -135,6 +135,40 @@ class AvoidShiftOnDates(BaseModel):
|
|||||||
raise ValueError(f"Cannot parse date: {v}")
|
raise ValueError(f"Cannot parse date: {v}")
|
||||||
|
|
||||||
|
|
||||||
|
class MaxDaysPerWeekBlockConstraint(BaseModel):
|
||||||
|
max_days: int = Field(
|
||||||
|
...,
|
||||||
|
ge=1,
|
||||||
|
description="Maximum number of worked days allowed inside each sliding week block.",
|
||||||
|
)
|
||||||
|
week_block: int = Field(
|
||||||
|
...,
|
||||||
|
ge=1,
|
||||||
|
description="Length of the sliding week block (for example, 2 means every consecutive 2-week window).",
|
||||||
|
)
|
||||||
|
ignore_dates: list[datetime.date] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="List of specific dates to exclude from this constraint.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MaxUniqueShiftsPerWeekBlockConstraint(BaseModel):
|
||||||
|
max_unique_shifts: int = Field(
|
||||||
|
...,
|
||||||
|
ge=1,
|
||||||
|
description="Maximum number of distinct shift names allowed inside each sliding week block.",
|
||||||
|
)
|
||||||
|
week_block: int = Field(
|
||||||
|
...,
|
||||||
|
ge=1,
|
||||||
|
description="Length of the sliding week block (for example, 2 means every consecutive 2-week window).",
|
||||||
|
)
|
||||||
|
ignore_dates: list[datetime.date] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="List of specific dates to exclude from this constraint.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Worker(BaseModel):
|
class Worker(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
site: str
|
site: str
|
||||||
@@ -171,6 +205,8 @@ class Worker(BaseModel):
|
|||||||
forced_assignments: List[tuple[int, str, str]] = [] # (week, day, shift_name)
|
forced_assignments: List[tuple[int, str, str]] = [] # (week, day, shift_name)
|
||||||
forced_assignments_by_date: List[tuple[datetime.date, str]] = [] # (date, shift_name)
|
forced_assignments_by_date: List[tuple[datetime.date, str]] = [] # (date, shift_name)
|
||||||
max_days_worked_per_week: int | None= None # Default: no limit, can be set to a specific number
|
max_days_worked_per_week: int | None= None # Default: no limit, can be set to a specific number
|
||||||
|
max_days_per_week_block: list[MaxDaysPerWeekBlockConstraint] = []
|
||||||
|
max_unique_shifts_per_week_block: list[MaxUniqueShiftsPerWeekBlockConstraint] = []
|
||||||
|
|
||||||
|
|
||||||
assign_as_block_preferences: dict[str, float] = {} # shift_name -> weight (positive = prefer block)
|
assign_as_block_preferences: dict[str, float] = {} # shift_name -> weight (positive = prefer block)
|
||||||
|
|||||||
+168
-1
@@ -1,11 +1,19 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import pytest
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
from rota_generator.shifts import (
|
from rota_generator.shifts import (
|
||||||
InvalidShift, MaxShiftsPerWeekBlockConstraint, MaxShiftsPerWeekConstraint,
|
InvalidShift, MaxShiftsPerWeekBlockConstraint, MaxShiftsPerWeekConstraint,
|
||||||
NoWorkers, PostShiftConstraint, PreShiftConstraint, RotaBuilder, SingleShift,
|
NoWorkers, PostShiftConstraint, PreShiftConstraint, RotaBuilder, SingleShift,
|
||||||
WarningTermination, days, WorkerRequirement
|
WarningTermination, days, WorkerRequirement
|
||||||
)
|
)
|
||||||
from rota_generator.workers import NotAvailableToWork, WorkRequests, Worker, generate_not_available_to_works
|
from rota_generator.workers import (
|
||||||
|
MaxDaysPerWeekBlockConstraint,
|
||||||
|
MaxUniqueShiftsPerWeekBlockConstraint,
|
||||||
|
NotAvailableToWork,
|
||||||
|
WorkRequests,
|
||||||
|
Worker,
|
||||||
|
generate_not_available_to_works,
|
||||||
|
)
|
||||||
|
|
||||||
def generate_basic_rota(weeks_to_rota=10, workers=2, start_date=datetime.date(2022, 3, 7)):
|
def generate_basic_rota(weeks_to_rota=10, workers=2, start_date=datetime.date(2022, 3, 7)):
|
||||||
Rota = RotaBuilder(
|
Rota = RotaBuilder(
|
||||||
@@ -89,6 +97,165 @@ def test_max_shifts_per_week_block_shift_number_fail():
|
|||||||
Rota.export_rota_to_html("max_shifts_per_week_block")
|
Rota.export_rota_to_html("max_shifts_per_week_block")
|
||||||
assert Rota.results.solver.status in ("warning", "error")
|
assert Rota.results.solver.status in ("warning", "error")
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_max_days_per_week_block_fail():
|
||||||
|
Rota = RotaBuilder(datetime.date(2022, 3, 7), weeks_to_rota=2)
|
||||||
|
Rota.add_worker(
|
||||||
|
Worker(
|
||||||
|
name="worker1",
|
||||||
|
site="group1",
|
||||||
|
grade=1,
|
||||||
|
max_days_per_week_block=[
|
||||||
|
MaxDaysPerWeekBlockConstraint(max_days=1, week_block=1)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1",),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=days[:3],
|
||||||
|
workers_required=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Rota.build_and_solve(options={"ratio": 0.000})
|
||||||
|
assert Rota.results.solver.termination_condition == "infeasible"
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_max_unique_shifts_per_week_block_fail():
|
||||||
|
Rota = RotaBuilder(datetime.date(2022, 3, 7), weeks_to_rota=2)
|
||||||
|
Rota.add_worker(
|
||||||
|
Worker(
|
||||||
|
name="worker1",
|
||||||
|
site="group1",
|
||||||
|
grade=1,
|
||||||
|
max_unique_shifts_per_week_block=[
|
||||||
|
MaxUniqueShiftsPerWeekBlockConstraint(max_unique_shifts=1, week_block=1)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1",),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=["Mon"],
|
||||||
|
workers_required=1,
|
||||||
|
),
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1",),
|
||||||
|
name="b",
|
||||||
|
length=12.5,
|
||||||
|
days=["Tue"],
|
||||||
|
workers_required=1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Rota.build_and_solve(options={"ratio": 0.000})
|
||||||
|
assert Rota.results.solver.termination_condition == "infeasible"
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_block_constraint_validation():
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
MaxDaysPerWeekBlockConstraint(max_days=0, week_block=1)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
MaxUniqueShiftsPerWeekBlockConstraint(max_unique_shifts=0, week_block=1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_max_days_per_week_block_pass():
|
||||||
|
"""Positive test: constraint allowing reasonable days."""
|
||||||
|
Rota = generate_basic_rota()
|
||||||
|
# Add constraint allowing up to 4 days per week
|
||||||
|
Rota.workers[0].max_days_per_week_block = [
|
||||||
|
MaxDaysPerWeekBlockConstraint(max_days=4, week_block=1)
|
||||||
|
]
|
||||||
|
# Add shifts to the rota
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1", "group2"),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=days,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Rota.build_and_solve(options={"ratio": 0.000})
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
assert Rota.results.solver.termination_condition == "optimal"
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_max_unique_shifts_per_week_block_pass():
|
||||||
|
"""Positive test: allowing multiple shifts within week block."""
|
||||||
|
Rota = generate_basic_rota()
|
||||||
|
# Add constraint allowing up to 2 unique shifts per week
|
||||||
|
Rota.workers[0].max_unique_shifts_per_week_block = [
|
||||||
|
MaxUniqueShiftsPerWeekBlockConstraint(max_unique_shifts=2, week_block=1)
|
||||||
|
]
|
||||||
|
# Add shifts to the rota
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1", "group2"),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=days,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Rota.build_and_solve(options={"ratio": 0.000})
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
assert Rota.results.solver.termination_condition == "optimal"
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_max_days_per_week_block_with_ignore_dates():
|
||||||
|
"""Test max_days_per_week_block with ignored dates works without error."""
|
||||||
|
Rota = generate_basic_rota()
|
||||||
|
rota_start = Rota.start_date
|
||||||
|
ignored_date = rota_start # Ignore first Monday
|
||||||
|
# Add constraint with ignore_dates
|
||||||
|
Rota.workers[0].max_days_per_week_block = [
|
||||||
|
MaxDaysPerWeekBlockConstraint(
|
||||||
|
max_days=3,
|
||||||
|
week_block=1,
|
||||||
|
ignore_dates=[ignored_date],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
# Add shifts to the rota
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1", "group2"),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=days,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Rota.build_and_solve(options={"ratio": 0.000})
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_max_unique_shifts_per_week_block_with_ignore_dates():
|
||||||
|
"""Test max_unique_shifts_per_week_block with ignored dates works without error."""
|
||||||
|
Rota = generate_basic_rota()
|
||||||
|
rota_start = Rota.start_date
|
||||||
|
ignored_date = rota_start + datetime.timedelta(days=1) # Ignore Tuesday
|
||||||
|
# Add constraint with ignore_dates
|
||||||
|
Rota.workers[0].max_unique_shifts_per_week_block = [
|
||||||
|
MaxUniqueShiftsPerWeekBlockConstraint(
|
||||||
|
max_unique_shifts=1,
|
||||||
|
week_block=1,
|
||||||
|
ignore_dates=[ignored_date],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
# Add shifts to the rota
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1", "group2"),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=days,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Rota.build_and_solve(options={"ratio": 0.000})
|
||||||
|
# This should still be feasible with only one shift type in main rota
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
|
||||||
def test_pre_shift_constraint():
|
def test_pre_shift_constraint():
|
||||||
Rota = generate_basic_rota()
|
Rota = generate_basic_rota()
|
||||||
for i, d in enumerate(days[:6]):
|
for i, d in enumerate(days[:6]):
|
||||||
|
|||||||
Reference in New Issue
Block a user