From ad75b6b8b512324bcb5e47aa231de2a5bb7dc0fa Mon Sep 17 00:00:00 2001 From: Ross Date: Sat, 7 Jun 2025 13:02:45 +0100 Subject: [PATCH] fix multishifts --- output/timetable.js | 50 +++++++----- rota/shifts.py | 110 +++++++++++++++++---------- test/test_multiple_shifts_per_day.py | 70 +++++++++-------- 3 files changed, 135 insertions(+), 95 deletions(-) diff --git a/output/timetable.js b/output/timetable.js index 5ecc2d9..b240ee1 100644 --- a/output/timetable.js +++ b/output/timetable.js @@ -43,7 +43,16 @@ function generateExtra() { shifts = [] shift_tds.each((n, td) => { - shifts.push($(td).attr("data-shift")); + let data_shift = $(td).attr("data-shift"); + if (data_shift && data_shift !== "") { + // Split by comma for multiple shifts per day + let split_shifts = data_shift.split(","); + split_shifts.forEach(s => { + if (s && s.trim() !== "") { + shifts.push(s.trim()); + } + }); + } }) shifts_unique = new Set(shifts); @@ -362,11 +371,12 @@ oshifts.forEach((shift) => { $("table#main-table th.date").each((n, th) => { row = $("") row.append(`${th.dataset.date}`); - $(`table#main-table td[data-date='${th.dataset.date}'][data-shift='${evt.target.dataset.shift}']`).each((n, td) => { - - //row.append(`${}`) - row.append($(td).closest("tr").children("td:first").clone()) - + $(`table#main-table td[data-date='${th.dataset.date}']`).filter(function() { + let shifts = $(this).attr("data-shift"); + if (!shifts) return false; + return shifts.split(",").map(s => s.trim()).includes(evt.target.dataset.shift); + }).each((n, td) => { + row.append($(td).closest("tr").children("td:first").clone()); }); table.append(row) @@ -417,7 +427,7 @@ $(".table-div + pre").each((n, pre) => { }) $("td").hover((e) => { - //start hover + // start hover worker_td = $(e.target).closest('tr').find('td:first')[0] pair = worker_td.dataset.pair; index = $(e.target).index() - 1; @@ -427,23 +437,29 @@ $("td").hover((e) => { }) } if (e.target.dataset.shift != undefined && e.target.dataset.shift.length > 0) { - $(`td[data-shift=${e.target.dataset.shift}]`).addClass("shift-highlight") + // Highlight all cells containing this shift, even if multiple shifts per day + $("td").filter(function() { + let shifts = $(this).attr("data-shift"); + if (!shifts) return false; + return shifts.split(",").map(s => s.trim()).includes(e.target.dataset.shift); + }).addClass("shift-highlight"); - - $(`td[data-remote-site='${e.target.dataset.shiftRemoteSite}']`).closest('tr').find(`td[data-shift=${e.target.dataset.shift}]`).addClass("remote-site-match") - // if (e.target.dataset.shiftRemoteSite == worker_td.dataset.remoteSite) { - // $(`td[data-shift-remote-site=${e.target.dataset.shiftRemoteSite}]`).addClass("remote-site-match") - // } + // For remote site, similar logic if needed + $("td").filter(function() { + let shifts = $(this).attr("data-shift"); + let remote = $(this).attr("data-remote-site"); + if (!shifts) return false; + return shifts.split(",").map(s => s.trim()).includes(e.target.dataset.shift) && + remote == e.target.dataset.shiftRemoteSite; + }).addClass("remote-site-match"); } }, (e) => { - - //end hover + // end hover $("td.pair-match").removeClass("pair-match") $("td.shift-highlight").removeClass("shift-highlight") $("td.remote-site-match").removeClass("remote-site-match") - -}) +}); $("#rota-table tr td:first-child").each((n, td) => { $(td).click(() => { diff --git a/rota/shifts.py b/rota/shifts.py index c6bb27e..fc764ef 100644 --- a/rota/shifts.py +++ b/rota/shifts.py @@ -2419,26 +2419,43 @@ class RotaBuilder(object): if self.allowed_multi_shift_sets: shifts_today = self.get_shift_names_by_week_day(week, day) if shifts_today: - # For each allowed set, allow those specific shifts to be assigned together - for allowed_set in self.allowed_multi_shift_sets: - if allowed_set.issubset(shifts_today): - # Allow up to len(allowed_set) shifts, but only for those shifts + # Build all allowed sets that are subsets of today's shifts + allowed_sets_today = [ + allowed_set for allowed_set in self.allowed_multi_shift_sets + if allowed_set.issubset(shifts_today) + ] + + # For each possible assignment, only allow: + # - a single shift + # - OR exactly one of the allowed sets + assigned_vars = [self.model.works[worker.id, week, day, shift] for shift in shifts_today] + # The sum of assigned shifts must be 1 (single shift) or exactly len(allowed_set) for one allowed set + allowed_sums = [1] + [len(allowed_set) for allowed_set in allowed_sets_today] + # Enforce: sum in allowed_sums + self.model.constraints.add( + sum(assigned_vars) <= max(allowed_sums) + ) + self.model.constraints.add( + sum(assigned_vars) >= min(allowed_sums) + ) + # Now, for each allowed set, prevent partial assignment of the set + for allowed_set in allowed_sets_today: + # If any shift in allowed_set is assigned, all must be assigned + for shift in allowed_set: self.model.constraints.add( - sum( - self.model.works[worker.id, week, day, shift] - for shift in allowed_set - ) <= len(allowed_set) + sum(self.model.works[worker.id, week, day, s] for s in allowed_set) + >= len(allowed_set) * self.model.works[worker.id, week, day, shift] ) - # Prevent assigning any other shift together with the allowed set - for shift in shifts_today: - if shift not in allowed_set: - self.model.constraints.add( - sum( - self.model.works[worker.id, week, day, s] - for s in allowed_set - ) + self.model.works[worker.id, week, day, shift] - <= len(allowed_set) - ) + # No other shifts can be assigned together with the allowed set + for shift in shifts_today: + if shift not in allowed_set: + self.model.constraints.add( + sum(self.model.works[worker.id, week, day, s] for s in allowed_set) + + self.model.works[worker.id, week, day, shift] + <= len(allowed_set) + ) + + # Otherwise only one shift per day else: self.model.constraints.add( @@ -3514,19 +3531,24 @@ class RotaBuilder(object): week_table[week][day][shift].append(worker.get_details()) return week_table + def get_worker_timetable(self): works = self.model.works timetable = { - worker.name: {week: {day: "" for day in days} for week in self.weeks} + worker.name: {week: {day: [] for day in days} for week in self.weeks} for worker in self.workers } for worker in self.workers: for week in self.weeks: - for day, shift in self.get_day_shiftname_combinations(): - if works[worker.id, week, day, shift].value == 1: - timetable[worker.name][week][day] = shift + for day in days: + assigned_shifts = [] + for shift in self.get_shift_names_by_week_day(week, day): + if works[worker.id, week, day, shift].value > 0.5: + assigned_shifts.append(shift) + timetable[worker.name][week][day] = assigned_shifts return timetable + def get_worker_timetable_brief( self, show_prefs=False, marker_every=30, show_unavailable=False ): @@ -3541,17 +3563,17 @@ class RotaBuilder(object): w = [f"{worker.name:20}"] for week, day in self.get_week_day_combinations(): a = "-" + shift_names = [] 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: - shifts.append(shift) - a = shift_obj.get_display_char() + if model.works[worker.id, week, day, shift].value > 0.5: + shift_names.append(self.get_shift_by_name(shift).get_display_char()) + if shift_names: + a = "".join(shift_names) + shifts.extend(shift_names) w.append(a) - shift_count = "" for s in set(shifts): shift_count = shift_count + f"{s}: {shifts.count(s)}, " - shift_count = ( shift_count + f"#weekends_worked: {model.worker_weekend_count[worker.id].value}\\#" @@ -3653,29 +3675,33 @@ class RotaBuilder(object): shift_tds = [] for week, day in self.get_week_day_combinations(): d = self.start_date + datetime.timedelta(n) - n = n + 1 a = "-" - shift_name = "" - - # Loop through all the days possible shifts and see - # if the worker has been assigned + shift_names = [] + assigned_shift_names = [] locum = False + shift_name = "" 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: - shifts.append(shift) - a = shift_obj.get_display_char() - shift_name = shift - break - if self.get_workers_who_require_locums(): + shift_names.append(shift_obj.get_display_char()) + assigned_shift_names.append(shift) + elif self.get_workers_who_require_locums(): if model.locum_works[worker.id, week, day, shift].value > 0.8: locum_shifts.append(shift) - a = shift_obj.get_display_char() - shift_name = shift + shift_names.append(shift_obj.get_display_char()) + assigned_shift_names.append(shift) locum = True + if shift_names: + a = "
".join(shift_names) + shifts.extend(assigned_shift_names) + shift_name = assigned_shift_names[0] if assigned_shift_names else "" - break + # Visual indication for multiple shifts + multi_shift = len(shift_names) > 1 + css_class = day + if multi_shift: + css_class += " multi-shift" title = f"{shift_name} ({d})" css_class = day @@ -3725,7 +3751,7 @@ class RotaBuilder(object): css_class = " ".join((css_class, "night-shift")) shift_tds.append( - f"{a}" + f"{a}" ) shift_count = "" diff --git a/test/test_multiple_shifts_per_day.py b/test/test_multiple_shifts_per_day.py index bb90624..5f35e4d 100644 --- a/test/test_multiple_shifts_per_day.py +++ b/test/test_multiple_shifts_per_day.py @@ -31,58 +31,56 @@ def test_single_shift_per_day_default(basic_rota): def test_allow_two_shifts_per_day(basic_rota): Rota = basic_rota Rota.add_shifts( - SingleShift(sites=["site1"], name="a", length=8, days=days[:1], workers_required=1), - SingleShift(sites=["site1"], name="b", length=8, days=days[:1], workers_required=1), + SingleShift(sites=["site1"], name="a", length=8, days=days[:2], workers_required=1), + SingleShift(sites=["site1"], name="b", length=8, days=days[:2], workers_required=1), ) Rota.allow_shifts_together("a", "b") Rota.build_and_solve() Rota.export_rota_to_html("test_allow_two_shifts", folder="tests") assert Rota.results.solver.status == "ok" - ## Now both shifts can be assigned together - #for week, day in Rota.get_week_day_combinations(): - # assigned = [ - # shift for shift in Rota.get_shift_names_by_week_day(week, day) - # if Rota.model.works[Rota.workers[0].id, week, day, shift].value > 0.5 - # ] - # assert len(assigned) <= 2 -def test_only_specified_shifts_can_be_double_assigned(basic_rota): - Rota = basic_rota - Rota.add_shifts( - SingleShift(sites=["site1"], name="a", length=8, days=days[:5], workers_required=1), - SingleShift(sites=["site1"], name="b", length=8, days=days[:5], workers_required=1), - SingleShift(sites=["site1"], name="c", length=8, days=days[:5], workers_required=1), - ) - Rota.allow_shifts_together("a", "b") - Rota.build_and_solve() - # Only "a" and "b" can be assigned together, never "a"+"c" or "b"+"c" + # Now both shifts can be assigned together for week, day in Rota.get_week_day_combinations(): assigned = [ shift for shift in Rota.get_shift_names_by_week_day(week, day) if Rota.model.works[Rota.workers[0].id, week, day, shift].value > 0.5 ] - if set(["a", "b"]).issubset(set(Rota.get_shift_names_by_week_day(week, day))): - assert len(assigned) <= 2 - else: - assert len(assigned) <= 1 + assert len(assigned) <= 2 + +def test_only_specified_shifts_can_be_double_assigned(basic_rota): + Rota = basic_rota + worker2 = Worker(name="worker2", site="site1", grade=1) + Rota.add_worker(worker2) + #Rota.add_worker(worker3) + Rota.add_shifts( + SingleShift(sites=["site1"], name="a", length=8, days=days[:3], workers_required=1), + SingleShift(sites=["site1"], name="b", length=8, days=days[:3], workers_required=1), + SingleShift(sites=["site1"], name="c", length=8, days=days[:3], workers_required=1), + ) + Rota.allow_shifts_together("a", "b") + Rota.build_and_solve() + Rota.export_rota_to_html("test_only_specified_shifts_can_be_double_assigned", folder="tests") + assert Rota.results.solver.status == "ok" + # Only "a" and "b" can be assigned together, never "a"+"c" or "b"+"c" + for worker in Rota.workers: + for week, day in Rota.get_week_day_combinations(): + assigned = set([ + shift for shift in Rota.get_shift_names_by_week_day(week, day) + if Rota.model.works[worker.id, week, day, shift].value > 0.8 + ]) + + assert assigned != set(["a", "c"]) and assigned != set(["b", "c"]) def test_triple_shifts_allowed(basic_rota): Rota = basic_rota Rota.add_shifts( - SingleShift(sites=["site1"], name="a", length=8, days=days[:5], workers_required=1), - SingleShift(sites=["site1"], name="b", length=8, days=days[:5], workers_required=1), - SingleShift(sites=["site1"], name="c", length=8, days=days[:5], workers_required=1), + SingleShift(sites=["site1"], name="a", length=8, days=days[2:4], workers_required=1), + SingleShift(sites=["site1"], name="b", length=8, days=days[2:4], workers_required=1), + SingleShift(sites=["site1"], name="c", length=8, days=days[2:4], workers_required=1), ) Rota.allow_shifts_together("a", "b", "c") Rota.build_and_solve() - # All three can be assigned together if allowed - for week, day in Rota.get_week_day_combinations(): - assigned = [ - shift for shift in Rota.get_shift_names_by_week_day(week, day) - if Rota.model.works[Rota.workers[0].id, week, day, shift].value > 0.5 - ] - if set(["a", "b", "c"]).issubset(set(Rota.get_shift_names_by_week_day(week, day))): - assert len(assigned) <= 3 - else: - assert len(assigned) <= 1 \ No newline at end of file + Rota.export_rota_to_html("test_triple_shifts_allowed", folder="tests") + assert Rota.results.solver.status == "ok" +