Add functionality to handle missing shifts in RotaBuilder and include worker grades in the timetable

This commit is contained in:
Ross
2026-01-07 22:14:43 +00:00
parent 277f14747a
commit 64c4189d5f
3 changed files with 205 additions and 151 deletions
+146 -145
View File
@@ -84,149 +84,149 @@ def main(
#] #]
Rota.add_shifts( Rota.add_shifts(
SingleShift( #SingleShift(
sites=( # sites=(
"exeter", # "exeter",
"exeter twilights and weekends", # "exeter twilights and weekends",
"exeter no nights", # "exeter no nights",
"exeter twilights", # "exeter twilights",
), # ),
name="exeter_twilight", # name="exeter_twilight",
length=12.5, # length=12.5,
days=days[:5], # days=days[:5],
balance_offset=2, # balance_offset=2,
constraints=[MaxShiftsPerWeekConstraint(max_shifts=1)], # constraints=[MaxShiftsPerWeekConstraint(max_shifts=1)],
), #),
SingleShift( #SingleShift(
sites=( # sites=(
"truro", # "truro",
"truro no nights", # "truro no nights",
"truro twilights", # "truro twilights",
"truro twilights and weekends", # "truro twilights and weekends",
"truro twilights and weekend nights", # "truro twilights and weekend nights",
"truro twilights, weekends and weekend nights", # "truro twilights, weekends and weekend nights",
), # ),
name="truro_twilight", # name="truro_twilight",
length=12.5, # length=12.5,
days=days[:4], # days=days[:4],
balance_offset=4, # balance_offset=4,
constraints=[MaxShiftsPerWeekConstraint(max_shifts=1)], # constraints=[MaxShiftsPerWeekConstraint(max_shifts=1)],
), #),
SingleShift( #SingleShift(
sites=("torbay", "torbay twilights", "torbay twilights and weekends"), # sites=("torbay", "torbay twilights", "torbay twilights and weekends"),
name="torbay_twilight", # name="torbay_twilight",
length=12.5, # length=12.5,
days=days[:4], # days=days[:4],
balance_offset=2, # balance_offset=2,
assign_as_block=True, # assign_as_block=True,
), #),
SingleShift( #SingleShift(
sites=( # sites=(
"plymouth", # "plymouth",
"plymouth twilights", # "plymouth twilights",
"plymouth twilights and weekends", # "plymouth twilights and weekends",
"plymouth twilights, weekends and weekend nights", # "plymouth twilights, weekends and weekend nights",
), # ),
name="plymouth_twilight", # name="plymouth_twilight",
length=12.5, # length=12.5,
days=days[:5], # days=days[:5],
balance_offset=2, # balance_offset=2,
workers_required=1, # workers_required=1,
constraints=[MaxShiftsPerWeekConstraint(max_shifts=2)], # constraints=[MaxShiftsPerWeekConstraint(max_shifts=2)],
), #),
SingleShift( #SingleShift(
sites=( # sites=(
"exeter", # "exeter",
"exeter twilights and weekends", # "exeter twilights and weekends",
"exeter no nights", # "exeter no nights",
"exeter weekends", # "exeter weekends",
#"exeter twilights", # #"exeter twilights",
), # ),
name="weekend_exeter", # name="weekend_exeter",
length=12.5, # length=12.5,
days=days[5:], # days=days[5:],
#balance_offset=2, # #balance_offset=2,
rota_on_nwds=True, # rota_on_nwds=True,
force_as_block=True, # force_as_block=True,
constraints=[PostShiftConstraint(days=2), PreShiftConstraint(days=2)], # constraints=[PostShiftConstraint(days=2), PreShiftConstraint(days=2)],
# constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2},], # # constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2},],
), #),
SingleShift( #SingleShift(
sites=( # sites=(
"truro", # "truro",
"truro no nights", # "truro no nights",
"truro twilights and weekends", # "truro twilights and weekends",
"truro weekends", # "truro weekends",
"truro twilights, weekends and weekend nights", # "truro twilights, weekends and weekend nights",
), # ),
name="weekend_truro", # name="weekend_truro",
length=12.5, # length=12.5,
days=days[4:], # days=days[4:],
#balance_offset=3, # #balance_offset=3,
rota_on_nwds=True, # rota_on_nwds=True,
force_as_block=True, # force_as_block=True,
#assign_as_block=True, # #assign_as_block=True,
constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)], # constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)],
# force_as_block_unless_nwd=True # # force_as_block_unless_nwd=True
), #),
SingleShift( #SingleShift(
sites=("torbay", "torbay twilights and weekends"), # sites=("torbay", "torbay twilights and weekends"),
name="weekend_torbay", # name="weekend_torbay",
length=12.5, # length=12.5,
days=days[4:], # days=days[4:],
#balance_offset=2, # #balance_offset=2,
rota_on_nwds=True, # rota_on_nwds=True,
force_as_block=True, # force_as_block=True,
constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)], # constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)],
# force_as_block_unless_nwd=True # # force_as_block_unless_nwd=True
), #),
SingleShift( #SingleShift(
sites=( # sites=(
"plymouth", # "plymouth",
"plymouth twilights and weekends", # "plymouth twilights and weekends",
"plymouth first on weekends only", # "plymouth first on weekends only",
"plymouth twilights, weekends and weekend nights", # "plymouth twilights, weekends and weekend nights",
), # ),
name="weekend_plymouth1", # name="weekend_plymouth1",
length=8, # length=8,
days=days[5:], # days=days[5:],
#balance_offset=2.9, # #balance_offset=2.9,
workers_required=1, # workers_required=1,
rota_on_nwds=True, # rota_on_nwds=True,
force_as_block=True, # force_as_block=True,
constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)], # constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)],
), #),
SingleShift( #SingleShift(
sites=( # sites=(
"plymouth", # "plymouth",
"plymouth twilights and weekends", # "plymouth twilights and weekends",
"plymouth twilights, weekends and weekend nights", # "plymouth twilights, weekends and weekend nights",
), # ),
name="weekend_plymouth2", # name="weekend_plymouth2",
length=8, # length=8,
days=days[5:], # days=days[5:],
#balance_offset=2.9, # #balance_offset=2.9,
workers_required=1, # workers_required=1,
rota_on_nwds=True, # rota_on_nwds=True,
force_as_block=True, # force_as_block=True,
constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)], # constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)],
), #),
SingleShift( #SingleShift(
sites=( # sites=(
"plymouth", # "plymouth",
"plymouth twilights", # "plymouth twilights",
"plymouth twilights and weekends", # "plymouth twilights and weekends",
"plymouth twilights, weekends and weekend nights", # "plymouth twilights, weekends and weekend nights",
), # ),
name="plymouth_bank_holidays", # name="plymouth_bank_holidays",
length=8, # length=8,
days=days[5:], # days=days[5:],
balance_offset=1, # balance_offset=1,
workers_required=1, # workers_required=1,
rota_on_nwds=True, # rota_on_nwds=True,
force_as_block=False, # force_as_block=False,
bank_holidays_only=True, # bank_holidays_only=True,
), #),
SingleShift( SingleShift(
sites=sites, sites=sites,
name="night_weekday", name="night_weekday",
@@ -280,10 +280,11 @@ def main(
# Prevent shifts for ST2s for 2 weeks # Prevent shifts for ST2s for 2 weeks
#Rota.add_grade_constraint_by_week([2], [1, 2], [shift.name for shift in Rota.shifts]) #Rota.add_grade_constraint_by_week([2], [1, 2], [shift.name for shift in Rota.shifts])
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["weekend_plymouth1", "weekend_plymouth2"], 6) #Rota.add_min_summed_grade_by_shifts_per_day_constraint(["weekend_plymouth1", "weekend_plymouth2"], 6)
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["plymouth_twilight", "plymouth_bank_holidays"], 6) #Rota.add_min_summed_grade_by_shifts_per_day_constraint(["plymouth_twilight", "plymouth_bank_holidays"], 6)
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["night_weekday"], 12) Rota.add_min_summed_grade_by_shifts_per_day_constraint(["night_weekday"], 12)
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["night_weekend"], 12)
Rota.terminate_on_warning.remove("Worker/no valid shifts") Rota.terminate_on_warning.remove("Worker/no valid shifts")
+33 -5
View File
@@ -921,6 +921,9 @@ function renderWorkerCheckboxes() {
let workerFilterSet = new Set(); let workerFilterSet = new Set();
// Store the current setting // Store the current setting
let showShiftNames = false; let showShiftNames = false;
// Controls for rendering
let showWeekStart = false;
let showGrades = false;
// Call this after the DOM is ready and after timetable is rendered // Call this after the DOM is ready and after timetable is rendered
renderWorkerCheckboxes(); renderWorkerCheckboxes();
@@ -938,8 +941,20 @@ $(document).on("change", "input[name='worker-filter-checkbox']", function() {
); );
renderShiftTimetable(); renderShiftTimetable();
}); });
// Add a checkbox to show/hide worker grades in the shift timetable
$("#shift-timetable-options").prepend(`
<div id="shift-timetable-show-grades" style="margin-bottom:8px;">
<label>
<input type="checkbox" id="show-grades-checkbox">
Show worker grades in timetable
</label>
</div>
`);
let showWeekStart = false; $(document).on("change", "#show-grades-checkbox", function() {
showGrades = $(this).is(":checked");
renderShiftTimetable();
});
// Add a checkbox to show/hide shift names in the shift timetable section // Add a checkbox to show/hide shift names in the shift timetable section
$("#shift-timetable-options").prepend(` $("#shift-timetable-options").prepend(`
<div id="shift-timetable-show-shift-names" style="margin-bottom:8px;"> <div id="shift-timetable-show-shift-names" style="margin-bottom:8px;">
@@ -1022,7 +1037,7 @@ function renderShiftTimetable() {
row.append(`<td>${day}</td>`); row.append(`<td>${day}</td>`);
let workerCells = []; let workerCells = [];
$(`table#main-table td[data-date='${date}']`).filter(function () { $(`table#main-table td[data-date='${date}']`).filter(function () {
let shifts = $(this).attr("data-shift"); let shifts = $(this).attr("data-shift");
if (!shifts) return false; if (!shifts) return false;
let cell_shifts = shifts.split(",").map(s => s.trim()); let cell_shifts = shifts.split(",").map(s => s.trim());
@@ -1030,6 +1045,14 @@ function renderShiftTimetable() {
}).each((n, td) => { }).each((n, td) => {
let workerTd = $(td).closest("tr").children("td:first"); let workerTd = $(td).closest("tr").children("td:first");
let workerName = workerTd.find("span.name").text().trim(); let workerName = workerTd.find("span.name").text().trim();
// grade may be provided as a data attribute; if not, try to parse from visible text
let workerGrade = workerTd.attr('data-grade') || '';
if (!workerGrade) {
// attempt to parse e.g. "Name (4) [100]" -> capture 4
let txt = workerTd.text();
let m = txt.match(/\(([^)]+)\)/);
if (m) workerGrade = m[1].trim();
}
let workerId = workerTd.attr("data-worker-id"); let workerId = workerTd.attr("data-worker-id");
if (!workerMatchesFilter(workerId)) return; if (!workerMatchesFilter(workerId)) return;
let cell_shifts = $(td).attr("data-shift").split(",").map(s => s.trim()); let cell_shifts = $(td).attr("data-shift").split(",").map(s => s.trim());
@@ -1043,7 +1066,8 @@ function renderShiftTimetable() {
color = `linear-gradient(90deg, ${shiftColors[matchedShifts[0]]} 0 50%, ${shiftColors[matchedShifts[1]]} 50% 100%)`; color = `linear-gradient(90deg, ${shiftColors[matchedShifts[0]]} 0 50%, ${shiftColors[matchedShifts[1]]} 50% 100%)`;
if (showShiftNames) shiftLabel = `<span style="font-size:0.9em; color:#333;"> (${matchedShifts.join(", ")})</span>`; if (showShiftNames) shiftLabel = `<span style="font-size:0.9em; color:#333;"> (${matchedShifts.join(", ")})</span>`;
} }
workerCells.push(`<td style="background:${color}">${workerName}${shiftLabel}</td>`); let gradeTextLinear = showGrades ? ` <span style="font-size:0.85em;color:#444;">(${workerGrade})</span>` : '';
workerCells.push(`<td style="background:${color}">${workerName}${gradeTextLinear}${shiftLabel}</td>`);
}); });
if (workerCells.length === 0) { if (workerCells.length === 0) {
@@ -1084,10 +1108,12 @@ function renderShiftTimetable() {
$("table#main-table td.worker").each(function () { $("table#main-table td.worker").each(function () {
let workerId = $(this).attr("data-worker-id"); let workerId = $(this).attr("data-worker-id");
let workerName = $(this).find("span.name").text().trim(); let workerName = $(this).find("span.name").text().trim();
let workerGrade = $(this).attr('data-grade') || '';
if (workerId && !workerIds.includes(workerId)) { if (workerId && !workerIds.includes(workerId)) {
if (workerMatchesFilter(workerId)) { if (workerMatchesFilter(workerId)) {
workerIds.push(workerId); workerIds.push(workerId);
workerIdToName[workerId] = workerName; workerIdToName[workerId] = workerName;
workerIdToName[workerId + '_grade'] = workerGrade;
} }
} }
}); });
@@ -1096,7 +1122,8 @@ function renderShiftTimetable() {
$("table#main-table td.worker").each(function () { $("table#main-table td.worker").each(function () {
let workerId = $(this).attr("data-worker-id"); let workerId = $(this).attr("data-worker-id");
let workerName = $(this).find("span.name").text().trim(); let workerName = $(this).find("span.name").text().trim();
if (!cellMap[workerId]) cellMap[workerId] = { name: workerName, cells: {} }; let workerGrade = $(this).attr('data-grade') || '';
if (!cellMap[workerId]) cellMap[workerId] = { name: workerName, grade: workerGrade, cells: {} };
}); });
$("table#main-table td[data-week][data-day]").each(function () { $("table#main-table td[data-week][data-day]").each(function () {
let td = $(this); let td = $(this);
@@ -1141,7 +1168,8 @@ function renderShiftTimetable() {
color = `linear-gradient(90deg, ${shiftColors[matchedShifts[0]]} 0 50%, ${shiftColors[matchedShifts[1]]} 50% 100%)`; color = `linear-gradient(90deg, ${shiftColors[matchedShifts[0]]} 0 50%, ${shiftColors[matchedShifts[1]]} 50% 100%)`;
if (showShiftNames) shiftLabel = `<span style="font-size:0.9em; color:#333;"> (${matchedShifts.join(", ")})</span>`; if (showShiftNames) shiftLabel = `<span style="font-size:0.9em; color:#333;"> (${matchedShifts.join(", ")})</span>`;
} }
cellContent.push(`<div style="background:${color};margin:1px 0;padding:0 2px;border-radius:3px;display:inline-block;">${worker.name}${shiftLabel}</div>`); let gradeText = showGrades ? ` <span style="font-size:0.85em;color:#444;">(${worker.grade||''})</span>` : '';
cellContent.push(`<div style="background:${color};margin:1px 0;padding:0 2px;border-radius:3px;display:inline-block;">${worker.name}${gradeText}${shiftLabel}</div>`);
} }
} }
row.append(`<td style="border:1px solid #888; min-width:40px; text-align:center;">${cellContent.join(" / ")}</td>`); row.append(`<td style="border:1px solid #888; min-width:40px; text-align:center;">${cellContent.join(" / ")}</td>`);
+26 -1
View File
@@ -2439,13 +2439,37 @@ class RotaBuilder(object):
# nothing to constrain # nothing to constrain
continue continue
# Determine which of the requested shifts actually occur on the selected dates
present_shifts = set(
shift
for wk, d, shift in self.get_all_shiftname_combinations()
if (wk, d) in selected_weeks_days
)
requested_shifts = set(constraint_dict["shifts"])
used_shifts = requested_shifts.intersection(present_shifts)
missing_shifts = requested_shifts - present_shifts
if missing_shifts:
self.add_warning(
"avoid shifts by worker/dates",
(
f"Requested avoid shifts {sorted(list(missing_shifts))} for worker '{worker.name}'"
" are not present in the rota dates selected and will be ignored"
),
)
if not used_shifts:
# Nothing to constrain after filtering — skip adding the constraint
continue
# Add constraint that forbids the given shifts for this worker on the selected dates # Add constraint that forbids the given shifts for this worker on the selected dates
self.model.constraints.add( self.model.constraints.add(
0 0
== sum( == sum(
self.model.works[worker.id, wk, d, shift] self.model.works[worker.id, wk, d, shift]
for wk, d, shift in self.get_all_shiftname_combinations() for wk, d, shift in self.get_all_shiftname_combinations()
if (wk, d) in selected_weeks_days and shift in constraint_dict["shifts"] if (wk, d) in selected_weeks_days and shift in used_shifts
) )
) )
@@ -5584,6 +5608,7 @@ class RotaBuilder(object):
worker_td = """<td title='Site: {site}' class='worker {site}' worker_td = """<td title='Site: {site}' class='worker {site}'
data-worker-id='{worker_id}' data-worker-id='{worker_id}'
data-grade='{grade}'
data-nwds='{nwds}' data-site='{site}' data-worker='{name}' data-nwds='{nwds}' data-site='{site}' data-worker='{name}'
data-fte='{fte}' data-fte_adj='{fte_adj}' data-fte='{fte}' data-fte_adj='{fte_adj}'
data-start_date='{start_date}' data-start_date='{start_date}'