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"
+