fix multishifts
This commit is contained in:
+31
-15
@@ -43,7 +43,16 @@ function generateExtra() {
|
|||||||
shifts = []
|
shifts = []
|
||||||
|
|
||||||
shift_tds.each((n, td) => {
|
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);
|
shifts_unique = new Set(shifts);
|
||||||
@@ -362,11 +371,12 @@ oshifts.forEach((shift) => {
|
|||||||
$("table#main-table th.date").each((n, th) => {
|
$("table#main-table th.date").each((n, th) => {
|
||||||
row = $("<tr>")
|
row = $("<tr>")
|
||||||
row.append(`<td>${th.dataset.date}</td>`);
|
row.append(`<td>${th.dataset.date}</td>`);
|
||||||
$(`table#main-table td[data-date='${th.dataset.date}'][data-shift='${evt.target.dataset.shift}']`).each((n, td) => {
|
$(`table#main-table td[data-date='${th.dataset.date}']`).filter(function() {
|
||||||
|
let shifts = $(this).attr("data-shift");
|
||||||
//row.append(`<td>${}</td>`)
|
if (!shifts) return false;
|
||||||
row.append($(td).closest("tr").children("td:first").clone())
|
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)
|
table.append(row)
|
||||||
@@ -427,23 +437,29 @@ $("td").hover((e) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (e.target.dataset.shift != undefined && e.target.dataset.shift.length > 0) {
|
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");
|
||||||
|
|
||||||
|
// For remote site, similar logic if needed
|
||||||
$(`td[data-remote-site='${e.target.dataset.shiftRemoteSite}']`).closest('tr').find(`td[data-shift=${e.target.dataset.shift}]`).addClass("remote-site-match")
|
$("td").filter(function() {
|
||||||
// if (e.target.dataset.shiftRemoteSite == worker_td.dataset.remoteSite) {
|
let shifts = $(this).attr("data-shift");
|
||||||
// $(`td[data-shift-remote-site=${e.target.dataset.shiftRemoteSite}]`).addClass("remote-site-match")
|
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) => {
|
}, (e) => {
|
||||||
|
|
||||||
// end hover
|
// end hover
|
||||||
$("td.pair-match").removeClass("pair-match")
|
$("td.pair-match").removeClass("pair-match")
|
||||||
$("td.shift-highlight").removeClass("shift-highlight")
|
$("td.shift-highlight").removeClass("shift-highlight")
|
||||||
$("td.remote-site-match").removeClass("remote-site-match")
|
$("td.remote-site-match").removeClass("remote-site-match")
|
||||||
|
});
|
||||||
})
|
|
||||||
|
|
||||||
$("#rota-table tr td:first-child").each((n, td) => {
|
$("#rota-table tr td:first-child").each((n, td) => {
|
||||||
$(td).click(() => {
|
$(td).click(() => {
|
||||||
|
|||||||
+63
-37
@@ -2419,26 +2419,43 @@ class RotaBuilder(object):
|
|||||||
if self.allowed_multi_shift_sets:
|
if self.allowed_multi_shift_sets:
|
||||||
shifts_today = self.get_shift_names_by_week_day(week, day)
|
shifts_today = self.get_shift_names_by_week_day(week, day)
|
||||||
if shifts_today:
|
if shifts_today:
|
||||||
# For each allowed set, allow those specific shifts to be assigned together
|
# Build all allowed sets that are subsets of today's shifts
|
||||||
for allowed_set in self.allowed_multi_shift_sets:
|
allowed_sets_today = [
|
||||||
if allowed_set.issubset(shifts_today):
|
allowed_set for allowed_set in self.allowed_multi_shift_sets
|
||||||
# Allow up to len(allowed_set) shifts, but only for those shifts
|
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(
|
self.model.constraints.add(
|
||||||
sum(
|
sum(assigned_vars) <= max(allowed_sums)
|
||||||
self.model.works[worker.id, week, day, shift]
|
|
||||||
for shift in allowed_set
|
|
||||||
) <= len(allowed_set)
|
|
||||||
)
|
)
|
||||||
# Prevent assigning any other shift together with the allowed set
|
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, s] for s in allowed_set)
|
||||||
|
>= len(allowed_set) * self.model.works[worker.id, week, day, shift]
|
||||||
|
)
|
||||||
|
# No other shifts can be assigned together with the allowed set
|
||||||
for shift in shifts_today:
|
for shift in shifts_today:
|
||||||
if shift not in allowed_set:
|
if shift not in allowed_set:
|
||||||
self.model.constraints.add(
|
self.model.constraints.add(
|
||||||
sum(
|
sum(self.model.works[worker.id, week, day, s] for s in allowed_set)
|
||||||
self.model.works[worker.id, week, day, s]
|
+ self.model.works[worker.id, week, day, shift]
|
||||||
for s in allowed_set
|
|
||||||
) + self.model.works[worker.id, week, day, shift]
|
|
||||||
<= len(allowed_set)
|
<= len(allowed_set)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Otherwise only one shift per day
|
# Otherwise only one shift per day
|
||||||
else:
|
else:
|
||||||
self.model.constraints.add(
|
self.model.constraints.add(
|
||||||
@@ -3514,19 +3531,24 @@ class RotaBuilder(object):
|
|||||||
week_table[week][day][shift].append(worker.get_details())
|
week_table[week][day][shift].append(worker.get_details())
|
||||||
return week_table
|
return week_table
|
||||||
|
|
||||||
|
|
||||||
def get_worker_timetable(self):
|
def get_worker_timetable(self):
|
||||||
works = self.model.works
|
works = self.model.works
|
||||||
timetable = {
|
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 worker in self.workers:
|
for worker in self.workers:
|
||||||
for week in self.weeks:
|
for week in self.weeks:
|
||||||
for day, shift in self.get_day_shiftname_combinations():
|
for day in days:
|
||||||
if works[worker.id, week, day, shift].value == 1:
|
assigned_shifts = []
|
||||||
timetable[worker.name][week][day] = shift
|
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
|
return timetable
|
||||||
|
|
||||||
|
|
||||||
def get_worker_timetable_brief(
|
def get_worker_timetable_brief(
|
||||||
self, show_prefs=False, marker_every=30, show_unavailable=False
|
self, show_prefs=False, marker_every=30, show_unavailable=False
|
||||||
):
|
):
|
||||||
@@ -3541,17 +3563,17 @@ class RotaBuilder(object):
|
|||||||
w = [f"{worker.name:20}"]
|
w = [f"{worker.name:20}"]
|
||||||
for week, day in self.get_week_day_combinations():
|
for week, day in self.get_week_day_combinations():
|
||||||
a = "-"
|
a = "-"
|
||||||
|
shift_names = []
|
||||||
for shift in self.get_shift_names_by_week_day(week, day):
|
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.5:
|
||||||
if model.works[worker.id, week, day, shift].value > 0:
|
shift_names.append(self.get_shift_by_name(shift).get_display_char())
|
||||||
shifts.append(shift)
|
if shift_names:
|
||||||
a = shift_obj.get_display_char()
|
a = "".join(shift_names)
|
||||||
|
shifts.extend(shift_names)
|
||||||
w.append(a)
|
w.append(a)
|
||||||
|
|
||||||
shift_count = ""
|
shift_count = ""
|
||||||
for s in set(shifts):
|
for s in set(shifts):
|
||||||
shift_count = shift_count + f"{s}: {shifts.count(s)}, "
|
shift_count = shift_count + f"{s}: {shifts.count(s)}, "
|
||||||
|
|
||||||
shift_count = (
|
shift_count = (
|
||||||
shift_count
|
shift_count
|
||||||
+ f"#weekends_worked: {model.worker_weekend_count[worker.id].value}\\#"
|
+ f"#weekends_worked: {model.worker_weekend_count[worker.id].value}\\#"
|
||||||
@@ -3653,29 +3675,33 @@ class RotaBuilder(object):
|
|||||||
shift_tds = []
|
shift_tds = []
|
||||||
for week, day in self.get_week_day_combinations():
|
for week, day in self.get_week_day_combinations():
|
||||||
d = self.start_date + datetime.timedelta(n)
|
d = self.start_date + datetime.timedelta(n)
|
||||||
|
|
||||||
n = n + 1
|
n = n + 1
|
||||||
a = "-"
|
a = "-"
|
||||||
shift_name = ""
|
shift_names = []
|
||||||
|
assigned_shift_names = []
|
||||||
# Loop through all the days possible shifts and see
|
|
||||||
# if the worker has been assigned
|
|
||||||
locum = False
|
locum = False
|
||||||
|
shift_name = ""
|
||||||
for shift in self.get_shift_names_by_week_day(week, day):
|
for shift in self.get_shift_names_by_week_day(week, day):
|
||||||
shift_obj = self.get_shift_by_name(shift)
|
shift_obj = self.get_shift_by_name(shift)
|
||||||
if model.works[worker.id, week, day, shift].value > 0.8:
|
if model.works[worker.id, week, day, shift].value > 0.8:
|
||||||
shifts.append(shift)
|
shift_names.append(shift_obj.get_display_char())
|
||||||
a = shift_obj.get_display_char()
|
assigned_shift_names.append(shift)
|
||||||
shift_name = shift
|
elif self.get_workers_who_require_locums():
|
||||||
break
|
|
||||||
if self.get_workers_who_require_locums():
|
|
||||||
if model.locum_works[worker.id, week, day, shift].value > 0.8:
|
if model.locum_works[worker.id, week, day, shift].value > 0.8:
|
||||||
locum_shifts.append(shift)
|
locum_shifts.append(shift)
|
||||||
a = shift_obj.get_display_char()
|
shift_names.append(shift_obj.get_display_char())
|
||||||
shift_name = shift
|
assigned_shift_names.append(shift)
|
||||||
locum = True
|
locum = True
|
||||||
|
if shift_names:
|
||||||
|
a = "<br/>".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})"
|
title = f"{shift_name} ({d})"
|
||||||
css_class = day
|
css_class = day
|
||||||
@@ -3725,7 +3751,7 @@ class RotaBuilder(object):
|
|||||||
css_class = " ".join((css_class, "night-shift"))
|
css_class = " ".join((css_class, "night-shift"))
|
||||||
|
|
||||||
shift_tds.append(
|
shift_tds.append(
|
||||||
f"<td title='{title}' class='rota-day {css_class}' data-shift='{shift_name}' data-available='{available}' data-unavailable_reason='{unavailable_reason}' data-date='{d}' data-week='{week}' data-day='{day}'{remote_site}{requests}{bank_holiday}>{a}</td>"
|
f"<td title='{','.join(shift_names)} ({d})' class='rota-day {css_class}' data-shift='{','.join(shift_names)}' data-available='{available}' data-unavailable_reason='{unavailable_reason}' data-date='{d}' data-week='{week}' data-day='{day}'{remote_site}{requests}{bank_holiday}>{a}</td>"
|
||||||
)
|
)
|
||||||
|
|
||||||
shift_count = ""
|
shift_count = ""
|
||||||
|
|||||||
@@ -31,58 +31,56 @@ def test_single_shift_per_day_default(basic_rota):
|
|||||||
def test_allow_two_shifts_per_day(basic_rota):
|
def test_allow_two_shifts_per_day(basic_rota):
|
||||||
Rota = basic_rota
|
Rota = basic_rota
|
||||||
Rota.add_shifts(
|
Rota.add_shifts(
|
||||||
SingleShift(sites=["site1"], name="a", 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[:1], workers_required=1),
|
SingleShift(sites=["site1"], name="b", length=8, days=days[:2], workers_required=1),
|
||||||
)
|
)
|
||||||
Rota.allow_shifts_together("a", "b")
|
Rota.allow_shifts_together("a", "b")
|
||||||
Rota.build_and_solve()
|
Rota.build_and_solve()
|
||||||
Rota.export_rota_to_html("test_allow_two_shifts", folder="tests")
|
Rota.export_rota_to_html("test_allow_two_shifts", folder="tests")
|
||||||
assert Rota.results.solver.status == "ok"
|
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):
|
# Now both shifts can be assigned together
|
||||||
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"
|
|
||||||
for week, day in Rota.get_week_day_combinations():
|
for week, day in Rota.get_week_day_combinations():
|
||||||
assigned = [
|
assigned = [
|
||||||
shift for shift in Rota.get_shift_names_by_week_day(week, day)
|
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 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
|
assert len(assigned) <= 2
|
||||||
else:
|
|
||||||
assert len(assigned) <= 1
|
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):
|
def test_triple_shifts_allowed(basic_rota):
|
||||||
Rota = basic_rota
|
Rota = basic_rota
|
||||||
Rota.add_shifts(
|
Rota.add_shifts(
|
||||||
SingleShift(sites=["site1"], name="a", 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[:5], 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[:5], 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.allow_shifts_together("a", "b", "c")
|
||||||
Rota.build_and_solve()
|
Rota.build_and_solve()
|
||||||
# All three can be assigned together if allowed
|
Rota.export_rota_to_html("test_triple_shifts_allowed", folder="tests")
|
||||||
for week, day in Rota.get_week_day_combinations():
|
assert Rota.results.solver.status == "ok"
|
||||||
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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user