1537 lines
48 KiB
Python
1537 lines
48 KiB
Python
import pytest
|
|
from rota.shifts import InvalidShift, NoWorkers, RotaBuilder, SingleShift, WarningTermination, WorkerRequirement, days
|
|
|
|
import datetime
|
|
from rota.workers import NonWorkingDays, NotAvailableToWork, Worker, generate_not_available_to_works
|
|
from rota.workers import WorkRequests
|
|
|
|
def generate_basic_rota(
|
|
weeks_to_rota=10, workers=0, start_date=datetime.date(2022, 3, 7)
|
|
):
|
|
Rota = RotaBuilder(
|
|
start_date,
|
|
weeks_to_rota=weeks_to_rota,
|
|
)
|
|
|
|
# Add a few workers
|
|
if workers:
|
|
Rota.add_workers(
|
|
[
|
|
Worker(name=f"worker{i:02}", site="group1", grade=1)
|
|
for i in range(1, workers + 1)
|
|
]
|
|
)
|
|
|
|
return Rota
|
|
|
|
def test_no_workers():
|
|
"""Check error raised if attempt to solve with no workers"""
|
|
Rota = generate_basic_rota()
|
|
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="d",
|
|
length=12.5,
|
|
days=days[:5],
|
|
balance_offset=10,
|
|
workers_required=1,
|
|
),
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="w",
|
|
length=12.5,
|
|
days=days[5:],
|
|
workers_required=1,
|
|
),
|
|
)
|
|
|
|
with pytest.raises(NoWorkers):
|
|
Rota.build_and_solve(solve=False)
|
|
|
|
def test_workers_duplicate_name():
|
|
Rota = generate_basic_rota()
|
|
|
|
workers = []
|
|
workers.append(Worker(name="worker1", site="group1", grade=1, fte=100))
|
|
workers.append(Worker(name="worker1", site="group1", grade=1, fte=100))
|
|
workers.append(Worker(name="worker3", site="group2", grade=1, fte=50))
|
|
workers.append(Worker(name="worker4", site="group2", grade=1, fte=50))
|
|
|
|
Rota.add_workers(workers)
|
|
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="d",
|
|
length=12.5,
|
|
days=days[:5],
|
|
balance_offset=10,
|
|
workers_required=1,
|
|
),
|
|
)
|
|
|
|
with pytest.raises(WarningTermination):
|
|
Rota.build_and_solve(solve=False)
|
|
|
|
def test_worker_ftes():
|
|
weeks_to_rota = 10
|
|
start_date = datetime.date(2022, 3, 7)
|
|
|
|
Rota = RotaBuilder(
|
|
start_date,
|
|
weeks_to_rota=weeks_to_rota,
|
|
)
|
|
|
|
worker1_end_date = start_date + datetime.timedelta(weeks=weeks_to_rota / 2)
|
|
worker2_start_date = start_date + datetime.timedelta(weeks=weeks_to_rota / 2)
|
|
worker3_start_date = start_date + datetime.timedelta(weeks=weeks_to_rota / 4)
|
|
worker3_end_date = start_date + datetime.timedelta(weeks=3 * weeks_to_rota / 4)
|
|
|
|
workers = []
|
|
workers.append(
|
|
Worker(
|
|
name="worker1",
|
|
site="group1",
|
|
grade=1,
|
|
fte=100,
|
|
end_date=worker1_end_date,
|
|
)
|
|
)
|
|
workers.append(
|
|
Worker(
|
|
name="worker2",
|
|
site="group1",
|
|
grade=1,
|
|
fte=100,
|
|
start_date=worker2_start_date,
|
|
)
|
|
)
|
|
workers.append(
|
|
Worker(
|
|
name="worker3",
|
|
site="group2",
|
|
grade=1,
|
|
fte=100,
|
|
start_date=worker3_start_date,
|
|
end_date=worker3_end_date,
|
|
)
|
|
)
|
|
workers.append(Worker(name="worker4", site="group2", grade=1, fte=50))
|
|
workers.append(
|
|
Worker(name="worker5", site="group2", grade=1, fte=50, nwd=["Mon", "Tue"])
|
|
)
|
|
workers.append(
|
|
Worker(
|
|
name="worker6",
|
|
site="group3",
|
|
grade=1,
|
|
fte=100,
|
|
start_date=start_date + datetime.timedelta(weeks=weeks_to_rota),
|
|
)
|
|
)
|
|
workers.append(
|
|
Worker(name="worker7", site="group3", grade=1, fte=50, end_date=start_date)
|
|
)
|
|
|
|
Rota.add_workers(workers)
|
|
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1", "group2"),
|
|
name="d",
|
|
length=12.5,
|
|
days=days[:5],
|
|
balance_offset=10,
|
|
workers_required=1,
|
|
),
|
|
)
|
|
|
|
with pytest.raises(WarningTermination):
|
|
Rota.build_and_solve()
|
|
|
|
Rota.terminate_on_warning.remove("Worker/no valid shifts")
|
|
Rota.build_and_solve()
|
|
|
|
# All workers should have an adjusted fte of 50 %
|
|
for worker in Rota.get_workers():
|
|
# Except those in group3
|
|
if worker.site == "group3":
|
|
assert worker.fte_adj == 0
|
|
assert Rota.get_worker_shift_list(worker).count("d") == 0
|
|
continue
|
|
|
|
assert worker.fte_adj == 50
|
|
|
|
if worker.fte == 100:
|
|
assert worker.proportion_rota_to_work == 0.5
|
|
assert worker.days_to_work == 35
|
|
|
|
assert Rota.get_worker_shift_list(worker).count("d") == 10
|
|
|
|
if worker.name == "worker1":
|
|
assert Rota.get_worker_shift_list_string(worker).endswith(
|
|
"-" * (Rota.rota_days_length // 2)
|
|
)
|
|
if worker.name == "worker2":
|
|
assert Rota.get_worker_shift_list_string(worker).startswith(
|
|
"-" * (Rota.rota_days_length // 2)
|
|
)
|
|
if worker.name == "worker3":
|
|
assert Rota.get_worker_shift_list_string(worker).startswith(
|
|
"-" * (Rota.rota_days_length // 4)
|
|
)
|
|
assert Rota.get_worker_shift_list_string(worker).endswith(
|
|
"-" * (Rota.rota_days_length // 4)
|
|
)
|
|
|
|
assert Rota.results.solver.status == "ok"
|
|
assert Rota.results.solver.termination_condition == "optimal"
|
|
|
|
Rota.export_rota_to_html("worker_ftes")
|
|
|
|
def test_worker_oops():
|
|
weeks_to_rota = 10
|
|
start_date = datetime.date(2022, 3, 7)
|
|
|
|
Rota = RotaBuilder(
|
|
start_date,
|
|
weeks_to_rota=weeks_to_rota,
|
|
)
|
|
|
|
worker1_oop = [
|
|
{
|
|
"start_date": start_date,
|
|
"end_date": start_date + datetime.timedelta(weeks=weeks_to_rota / 2),
|
|
"reason": "First half OOP",
|
|
}
|
|
]
|
|
|
|
worker2_oop = [
|
|
{
|
|
"start_date": start_date + datetime.timedelta(weeks=weeks_to_rota / 2),
|
|
"end_date": Rota.rota_end_date,
|
|
"reason": "First half OOP",
|
|
}
|
|
]
|
|
|
|
worker3_start_date = start_date + datetime.timedelta(weeks=weeks_to_rota / 4)
|
|
worker3_oop = [
|
|
{
|
|
"start_date": start_date + datetime.timedelta(weeks=weeks_to_rota / 2),
|
|
"end_date": start_date
|
|
+ datetime.timedelta(weeks=3 * weeks_to_rota / 4),
|
|
"reason": "Quarter oop",
|
|
}
|
|
]
|
|
|
|
worker4_oop = [
|
|
{
|
|
"start_date": start_date,
|
|
"end_date": start_date + datetime.timedelta(weeks=weeks_to_rota / 4),
|
|
"reason": "Quarter oop",
|
|
},
|
|
{
|
|
"start_date": start_date + datetime.timedelta(weeks=weeks_to_rota / 2),
|
|
"end_date": start_date
|
|
+ datetime.timedelta(weeks=weeks_to_rota / 2)
|
|
+ datetime.timedelta(weeks=weeks_to_rota / 4),
|
|
"reason": "Quarter oop 2",
|
|
},
|
|
]
|
|
|
|
workers = []
|
|
workers.append(
|
|
Worker(name="worker1", site="group1", grade=1, fte=100, oop=worker1_oop)
|
|
)
|
|
workers.append(
|
|
Worker(name="worker2", site="group1", grade=1, fte=100, oop=worker2_oop)
|
|
)
|
|
workers.append(
|
|
Worker(
|
|
name="worker3",
|
|
site="group2",
|
|
grade=1,
|
|
fte=100,
|
|
start_date=worker3_start_date,
|
|
oop=worker3_oop,
|
|
)
|
|
)
|
|
workers.append(Worker(name="worker4", site="group2", grade=1, oop=worker4_oop))
|
|
workers.append(
|
|
Worker(name="worker5", site="group2", grade=1, fte=50, nwd=["Mon", "Tue"])
|
|
)
|
|
|
|
worker6_oop = [
|
|
{
|
|
"start_date": start_date,
|
|
"end_date": start_date + datetime.timedelta(weeks=weeks_to_rota / 6),
|
|
"reason": "oop",
|
|
},
|
|
{
|
|
"start_date": start_date + datetime.timedelta(weeks=weeks_to_rota / 2),
|
|
"end_date": start_date
|
|
+ datetime.timedelta(weeks=weeks_to_rota / 2)
|
|
+ datetime.timedelta(weeks=weeks_to_rota / 3),
|
|
"reason": " oop 2",
|
|
},
|
|
]
|
|
|
|
worker7_oop = [
|
|
{
|
|
"start_date": start_date - datetime.timedelta(weeks=1),
|
|
"end_date": Rota.rota_end_date,
|
|
"reason": "oop",
|
|
},
|
|
]
|
|
|
|
workers.append(
|
|
Worker(name="worker6", site="group2", grade=1, fte=100, oop=worker6_oop)
|
|
)
|
|
workers.append(
|
|
Worker(name="worker7", site="group3", grade=1, fte=100, oop=worker7_oop)
|
|
)
|
|
|
|
Rota.add_workers(workers)
|
|
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1", "group2"),
|
|
name="d",
|
|
length=12.5,
|
|
days=days[:5],
|
|
balance_offset=10,
|
|
workers_required=1,
|
|
),
|
|
)
|
|
|
|
Rota.terminate_on_warning.remove("Worker/no valid shifts")
|
|
Rota.build_and_solve()
|
|
|
|
# All workers should have an adjusted fte of 50 %
|
|
for worker in Rota.get_workers():
|
|
if worker.site == "group3":
|
|
assert worker.fte_adj == 0
|
|
continue
|
|
|
|
assert worker.fte_adj == pytest.approx(50, 2)
|
|
|
|
if worker.fte == 100:
|
|
assert worker.proportion_rota_to_work == pytest.approx(0.5, 0.2)
|
|
assert worker.days_to_work == pytest.approx(35, 1)
|
|
|
|
assert Rota.get_worker_shift_list(worker).count("d") in (8, 9)
|
|
|
|
assert Rota.results.solver.status == "ok"
|
|
assert Rota.results.solver.termination_condition == "optimal"
|
|
|
|
Rota.export_rota_to_html("worker_oops")
|
|
|
|
def test_worker_pairs():
|
|
Rota = generate_basic_rota()
|
|
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="a",
|
|
length=12.5,
|
|
days=days[:5],
|
|
balance_offset=20,
|
|
workers_required=1,
|
|
),
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="b",
|
|
length=12.5,
|
|
days=days[:5],
|
|
balance_offset=20,
|
|
workers_required=1,
|
|
),
|
|
)
|
|
|
|
Rota.add_workers(
|
|
[
|
|
Worker(name="worker1", site="group1", grade=1, fte=100, pair="1"),
|
|
Worker(name="worker2", site="group1", grade=1, fte=100, pair="1"),
|
|
]
|
|
)
|
|
|
|
Rota.constraint_options["hard_constrain_pair_separation"] = True
|
|
|
|
Rota.build_and_solve()
|
|
|
|
# With two paired workers we shouldn't be able to solve
|
|
assert Rota.results.solver.status in ("warning", "error")
|
|
|
|
worker3 = Worker(name="worker3", site="group1", grade=1, fte=100)
|
|
Rota.add_worker(worker3)
|
|
|
|
# An additional worker can take half the shifts
|
|
Rota.build_and_solve()
|
|
|
|
assert Rota.results.solver.status == "ok"
|
|
|
|
assert Rota.get_workers_total_shifts()["worker3"] == 50
|
|
assert (
|
|
Rota.get_workers_total_shifts()["worker1"]
|
|
+ Rota.get_workers_total_shifts()["worker2"]
|
|
== 50
|
|
)
|
|
|
|
worker3.pair = "1"
|
|
|
|
Rota.build_and_solve()
|
|
|
|
assert Rota.results.solver.status in ("warning", "error")
|
|
|
|
worker3.pair = 0
|
|
|
|
Rota.build_and_solve()
|
|
Rota.export_rota_to_html("worker_pairs")
|
|
|
|
assert Rota.results.solver.status == "ok"
|
|
|
|
worker4 = Worker(name="worker4", site="group1", grade=1, fte=100, pair="1")
|
|
Rota.add_worker(worker4)
|
|
|
|
Rota.build_and_solve()
|
|
Rota.export_rota_to_html("worker_pairs")
|
|
|
|
assert Rota.results.solver.status == "ok"
|
|
assert Rota.get_workers_total_shifts()["worker3"] == 50
|
|
|
|
def test_not_available_to_work():
|
|
Rota = generate_basic_rota()
|
|
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="a",
|
|
length=12.5,
|
|
days=days,
|
|
workers_required=1,
|
|
),
|
|
)
|
|
|
|
natws = generate_not_available_to_works(Rota.start_date, days=21)
|
|
|
|
natws.append(
|
|
NotAvailableToWork(date=Rota.rota_end_date - datetime.timedelta(days=1))
|
|
)
|
|
|
|
Rota.add_workers(
|
|
[
|
|
Worker(name="worker1", site="group1", grade=1),
|
|
Worker(
|
|
name="worker2", site="group1", grade=1, not_available_to_work=natws
|
|
),
|
|
]
|
|
)
|
|
|
|
Rota.build_and_solve()
|
|
Rota.export_rota_to_html("test_not_available_to_work")
|
|
|
|
assert Rota.results.solver.status == "ok"
|
|
|
|
assert Rota.get_workers_total_shifts()["worker1"] == 35
|
|
assert Rota.get_workers_total_shifts()["worker2"] == 35
|
|
|
|
assert Rota.get_worker_shift_list_string(
|
|
Rota.get_worker_by_name("worker2")
|
|
).startswith("-" * 21)
|
|
assert Rota.get_worker_shift_list_string(
|
|
Rota.get_worker_by_name("worker2")
|
|
).endswith("-")
|
|
|
|
def test_worker_ftes_2():
|
|
Rota = generate_basic_rota(workers=2)
|
|
|
|
Rota.add_workers(
|
|
[
|
|
Worker(
|
|
name="worker3",
|
|
site="group1",
|
|
grade=1,
|
|
fte=60,
|
|
),
|
|
Worker(
|
|
name="worker4",
|
|
site="group1",
|
|
grade=1,
|
|
fte=40,
|
|
),
|
|
]
|
|
)
|
|
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1", "group2"),
|
|
name="a",
|
|
length=12.5,
|
|
days=days[:6],
|
|
workers_required=1,
|
|
),
|
|
)
|
|
|
|
Rota.build_and_solve()
|
|
|
|
Rota.export_rota_to_html("test_worker_ftes")
|
|
|
|
total_shifts = Rota.get_workers_total_shifts()
|
|
for worker in Rota.get_workers():
|
|
match worker.name:
|
|
case "worker1":
|
|
assert worker.get_fte() == 100
|
|
assert total_shifts[worker.name] == 20
|
|
case "worker2":
|
|
assert worker.get_fte() == 100
|
|
assert total_shifts[worker.name] == 20
|
|
case "worker3":
|
|
assert worker.get_fte() == 60
|
|
assert total_shifts[worker.name] == 12
|
|
case "worker4":
|
|
assert worker.get_fte() == 40
|
|
assert total_shifts[worker.name] == 8
|
|
|
|
def test_worker_fte_shift_override():
|
|
Rota = generate_basic_rota(workers=1)
|
|
|
|
Rota.add_workers(
|
|
[
|
|
Worker(
|
|
name="worker2",
|
|
site="group1",
|
|
grade=1,
|
|
fte=100,
|
|
shift_fte_overrides={"b": 50}
|
|
),
|
|
]
|
|
)
|
|
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1", "group2"),
|
|
name="a",
|
|
length=12.5,
|
|
days=days[:2],
|
|
workers_required=1,
|
|
),
|
|
SingleShift(
|
|
sites=("group1", "group2"),
|
|
name="b",
|
|
length=12.5,
|
|
days=days[2:5],
|
|
workers_required=1,
|
|
),
|
|
)
|
|
|
|
Rota.build_and_solve()
|
|
|
|
Rota.export_rota_to_html("test_worker_fte_shift_override")
|
|
|
|
total_shifts = Rota.get_workers_total_shifts()
|
|
for worker in Rota.get_workers():
|
|
match worker.name:
|
|
case "worker1":
|
|
assert worker.get_fte() == 100
|
|
assert total_shifts[worker.name] == 30
|
|
assert worker.get_fte("b") == 100
|
|
case "worker2":
|
|
assert worker.get_fte() == 100
|
|
assert worker.get_fte("b") == 50
|
|
assert total_shifts[worker.name] == 20
|
|
|
|
def test_worker_requirement_and_force_block():
|
|
Rota = generate_basic_rota(workers=10, weeks_to_rota=10)
|
|
|
|
wr = [
|
|
WorkerRequirement(
|
|
end_date=Rota.start_date + datetime.timedelta(weeks=5),
|
|
number=2,
|
|
),
|
|
WorkerRequirement(
|
|
start_date=Rota.start_date + datetime.timedelta(weeks=5),
|
|
number=3,
|
|
),
|
|
]
|
|
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1", "group2"),
|
|
name="a",
|
|
length=12.5,
|
|
days=days[:2],
|
|
workers_required=1,
|
|
),
|
|
SingleShift(
|
|
sites=("group1", "group2"),
|
|
name="b",
|
|
length=12.5,
|
|
days=days[2:5],
|
|
workers_required=1,
|
|
),
|
|
SingleShift(
|
|
sites=("group1", "group2"),
|
|
name="c",
|
|
length=12.5,
|
|
days=days[:4],
|
|
workers_required=wr,
|
|
force_as_block=True,
|
|
constraint=[
|
|
{"name": "pre", "options": 2},
|
|
{"name": "post", "options": 3},
|
|
]
|
|
),
|
|
SingleShift(
|
|
sites=("group1", "group2"),
|
|
name="d",
|
|
length=12.5,
|
|
days=days[4:],
|
|
workers_required=wr,
|
|
force_as_block=True,
|
|
constraint=[
|
|
{"name": "pre", "options": 2},
|
|
{"name": "post", "options": 3},
|
|
]
|
|
),
|
|
)
|
|
|
|
Rota.build_and_solve()
|
|
|
|
Rota.export_rota_to_html("test_worker_complex")
|
|
|
|
assert Rota.results.solver.status == "ok"
|
|
for worker in Rota.get_workers():
|
|
shift_string = Rota.get_worker_shift_list_string(worker)
|
|
assert shift_string.count("cccc") == shift_string.count("c") / 4
|
|
assert shift_string.count("ddd") == shift_string.count("d") / 3
|
|
|
|
def test_split_shift():
|
|
Rota = generate_basic_rota(workers=11, weeks_to_rota=22)
|
|
|
|
Rota.set_rota_constraint("max_shifts_per_week", 2)
|
|
Rota.set_rota_constraint("max_weekend_frequency", 3)
|
|
Rota.set_rota_constraint("max_shifts_per_month", 4)
|
|
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1", "group2"),
|
|
name="a",
|
|
length=12.5,
|
|
days=days[:4],
|
|
workers_required=1,
|
|
constraint=[
|
|
{"name": "pre", "options": 2},
|
|
{"name": "post", "options": 3},
|
|
{"name": "max_shifts_per_week", "options": 1},
|
|
]
|
|
),
|
|
SingleShift(
|
|
sites=("group1", "group2"),
|
|
name="b",
|
|
length=12.5,
|
|
days=(days[4], days[6]),
|
|
workers_required=1,
|
|
assign_as_block=True,
|
|
constraint=[
|
|
{"name": "pre", "options": 2},
|
|
{"name": "post", "options": 3},
|
|
]
|
|
),
|
|
SingleShift(
|
|
sites=("group1", "group2"),
|
|
name="c",
|
|
length=12.5,
|
|
days=days[5],
|
|
workers_required=1,
|
|
constraint=[
|
|
{"name": "pre", "options": 2},
|
|
{"name": "post", "options": 3},
|
|
]
|
|
),
|
|
)
|
|
|
|
Rota.build_and_solve()
|
|
|
|
Rota.export_rota_to_html("test_split")
|
|
assert Rota.results.solver.status == "ok"
|
|
|
|
def test_locums_basic():
|
|
Rota = generate_basic_rota(workers=2, weeks_to_rota=6)
|
|
|
|
la = []
|
|
for date in Rota.get_date_range(end_date=Rota.rota_end_date-datetime.timedelta(weeks=3)):
|
|
la.append(WorkRequests(date=date, shift="*"))
|
|
la2 = []
|
|
for date in Rota.get_date_range(start_date=Rota.rota_end_date-datetime.timedelta(weeks=3)):
|
|
la2.append(WorkRequests(date=date, shift="*"))
|
|
|
|
Rota.add_workers(
|
|
[
|
|
Worker(name="worker03", site="group1", locum=False, locum_availability=la, locum_max_shifts=40, locum_max_shifts_per_week=1),
|
|
Worker(name="worker04", site="group1", locum=False, locum_availability=la2, locum_max_shifts=40, locum_max_shifts_per_week=2),
|
|
Worker(name="locum1", site="group1", locum=True),
|
|
Worker(
|
|
name="locum2", site="group1", locum=True
|
|
),
|
|
]
|
|
)
|
|
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1", "group2"),
|
|
name="a",
|
|
length=12.5,
|
|
days=days[:4],
|
|
workers_required=1,
|
|
|
|
constraint=[
|
|
#{"name": "pre", "options": 1},
|
|
#{"name": "post", "options": 1},
|
|
#{"name": "max_shifts_per_week", "options": 1},
|
|
]
|
|
),
|
|
)
|
|
Rota.set_rota_constraint("distribute_locum_shifts", True)
|
|
Rota.set_rota_constraint("maximum_allowed_shift_diff", 0)
|
|
Rota.set_rota_constraint("maximum_allowed_locum_shifts_per_worker", 4)
|
|
|
|
Rota.build_and_solve()
|
|
|
|
Rota.export_rota_to_html("test_locums_basic", timestamp_filename=False)
|
|
|
|
assert Rota.results.solver.status == "ok"
|
|
|
|
for worker in Rota.get_workers():
|
|
assert Rota.get_worker_shift_list(worker).count("a") == 4
|
|
|
|
def test_locums_nwds():
|
|
Rota = generate_basic_rota(workers=2, weeks_to_rota=6)
|
|
|
|
la = []
|
|
for date in Rota.get_date_range(end_date=Rota.rota_end_date-datetime.timedelta(weeks=3)):
|
|
la.append(WorkRequests(date=date, shift="*"))
|
|
la2 = []
|
|
for date in Rota.get_date_range(start_date=Rota.rota_end_date-datetime.timedelta(weeks=3)):
|
|
la2.append(WorkRequests(date=date, shift="*"))
|
|
|
|
Rota.add_workers(
|
|
[
|
|
Worker(name="worker03", site="group1", locum=False, locum_availability=la, locum_max_shifts=40, locum_max_shifts_per_week=1, nwds=[NonWorkingDays(day="Tue")]),
|
|
Worker(name="worker04", site="group1", locum=False, locum_availability=la2, locum_max_shifts=40, locum_max_shifts_per_week=2, nwds=[NonWorkingDays(day="Wed"),NonWorkingDays(day="Thu")]),
|
|
Worker(name="locum1", site="group1", locum=True),
|
|
Worker(
|
|
name="locum2", site="group1", locum=True
|
|
),
|
|
]
|
|
)
|
|
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1", "group2"),
|
|
name="a",
|
|
length=12.5,
|
|
days=days[:4],
|
|
workers_required=1,
|
|
|
|
constraint=[
|
|
#{"name": "pre", "options": 1},
|
|
#{"name": "post", "options": 1},
|
|
#{"name": "max_shifts_per_week", "options": 1},
|
|
]
|
|
),
|
|
)
|
|
Rota.set_rota_constraint("maximum_allowed_shift_diff", 0)
|
|
Rota.set_rota_constraint("maximum_allowed_locum_shifts_per_worker", 4)
|
|
|
|
Rota.build_and_solve()
|
|
|
|
Rota.export_rota_to_html("test_locums_basic", timestamp_filename=False)
|
|
|
|
assert Rota.results.solver.status == "ok"
|
|
|
|
for worker in Rota.get_workers():
|
|
print( Rota.get_worker_shift_list(worker))
|
|
assert Rota.get_worker_shift_list(worker).count("a") == 4
|
|
|
|
if worker.name in ("worker03", "worker04"):
|
|
assert Rota.get_worker_shift_list(worker, include_locums=True).count("a") > 6
|
|
|
|
def test_locums_no_availablity():
|
|
Rota = generate_basic_rota(workers=2, weeks_to_rota=5)
|
|
|
|
la = []
|
|
la2 = []
|
|
|
|
Rota.add_workers(
|
|
[
|
|
Worker(name="worker03", site="group1", locum=False, locum_availability=la, locum_max_shifts=40, locum_max_shifts_per_week=1),
|
|
Worker(name="worker04", site="group1", locum=False, locum_availability=la2, locum_max_shifts=40, locum_max_shifts_per_week=2),
|
|
Worker(name="locum1", site="group1", locum=True),
|
|
Worker(
|
|
name="locum2", site="group1", locum=True
|
|
),
|
|
]
|
|
)
|
|
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1", "group2"),
|
|
name="a",
|
|
length=12.5,
|
|
days=days[:4],
|
|
workers_required=1,
|
|
|
|
constraint=[
|
|
#{"name": "pre", "options": 1},
|
|
#{"name": "post", "options": 1},
|
|
#{"name": "max_shifts_per_week", "options": 1},
|
|
]
|
|
),
|
|
)
|
|
Rota.set_rota_constraint("distribute_locum_shifts", False)
|
|
|
|
with pytest.raises(WarningTermination):
|
|
Rota.build_and_solve()
|
|
|
|
def test_locums():
|
|
Rota = generate_basic_rota(workers=7, weeks_to_rota=10)
|
|
|
|
Rota.set_rota_constraint("max_shifts_per_week", 2)
|
|
Rota.set_rota_constraint("max_weekend_frequency", 3)
|
|
Rota.set_rota_constraint("max_shifts_per_month", 4)
|
|
Rota.set_rota_constraint("distribute_locum_shifts", True)
|
|
Rota.set_rota_constraint("maximum_allowed_shift_diff", 1)
|
|
Rota.set_rota_constraint("maximum_allowed_locum_shifts_per_worker", 6)
|
|
|
|
Rota.terminate_on_warning.remove("Worker/no valid shifts")
|
|
|
|
la = []
|
|
for date in Rota.get_date_range(end_date=Rota.rota_end_date-datetime.timedelta(weeks=3)):
|
|
la.append(WorkRequests(date=date, shift="*"))
|
|
la2 = []
|
|
for date in Rota.get_date_range(start_date=Rota.rota_end_date-datetime.timedelta(weeks=3)):
|
|
la2.append(WorkRequests(date=date, shift="*"))
|
|
|
|
Rota.add_workers(
|
|
[
|
|
Worker(name="worker08", site="group1", locum=False, locum_availability=la, locum_max_shifts=40, locum_max_shifts_per_week=1),
|
|
Worker(name="worker09", site="group1", locum=False, locum_availability=la2, locum_max_shifts=40, locum_max_shifts_per_week=2),
|
|
Worker(name="locum1", site="group1", locum=True),
|
|
Worker(
|
|
name="locum2", site="group1", locum=True
|
|
),
|
|
]
|
|
)
|
|
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1", "group2"),
|
|
name="a",
|
|
length=12.5,
|
|
days=days[:4],
|
|
workers_required=1,
|
|
|
|
constraint=[
|
|
{"name": "pre", "options": 2},
|
|
{"name": "post", "options": 3},
|
|
{"name": "max_shifts_per_week", "options": 1},
|
|
]
|
|
),
|
|
SingleShift(
|
|
sites=("group1", "group2"),
|
|
name="b",
|
|
length=12.5,
|
|
days=(days[4], days[6]),
|
|
workers_required=1,
|
|
assign_as_block=True,
|
|
constraint=[
|
|
{"name": "pre", "options": 2},
|
|
{"name": "post", "options": 3},
|
|
]
|
|
),
|
|
SingleShift(
|
|
sites=("group1", "group2"),
|
|
name="c",
|
|
length=12.5,
|
|
days=days[5],
|
|
workers_required=1,
|
|
constraint=[
|
|
{"name": "pre", "options": 2},
|
|
{"name": "post", "options": 3},
|
|
]
|
|
),
|
|
)
|
|
|
|
Rota.build_and_solve()
|
|
|
|
Rota.export_rota_to_html("test_locums", timestamp_filename=False)
|
|
|
|
assert Rota.results.solver.status == "ok"
|
|
|
|
def test_worker_assign_as_block_preference():
|
|
"""Test that a worker's assign_as_block preference is respected over the global setting."""
|
|
Rota = generate_basic_rota(workers=4, weeks_to_rota=12)
|
|
|
|
# Worker 1 prefers to have shift 'a' assigned as a block, worker 2 does not
|
|
workers = Rota.get_workers()
|
|
worker1 = workers[0]
|
|
worker2 = workers[1]
|
|
worker3 = workers[2]
|
|
worker4 = workers[3]
|
|
|
|
# Set a strong preference for worker1 to have 'a' as a block
|
|
worker1.assign_as_block_preferences = {"a":1}
|
|
worker2.assign_as_block_preferences = {"a":-1} # Worker 2 does not want 'a' as a block
|
|
|
|
# Add a shift 'a' that is NOT globally set as assign_as_block
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="a",
|
|
length=8,
|
|
days=days[:4],
|
|
workers_required=1,
|
|
assign_as_block=False, # global setting is off
|
|
),
|
|
)
|
|
|
|
Rota.build_and_solve(options={"ratio": 0.0})
|
|
Rota.export_rota_to_html("test_worker_assign_as_block_preference", folder="tests")
|
|
|
|
# Worker 1 should have their 'a' shifts grouped together (block), worker 2 may not
|
|
shift_list_1 = Rota.get_worker_shift_list_string(worker1)
|
|
assert shift_list_1.count("aaaa") == 3, "Worker 1 should have all 'a' shifts grouped together (block)"
|
|
|
|
shift_list_2 = Rota.get_worker_shift_list_string(worker2)
|
|
assert shift_list_2.count("a") == 12, "Worker 2 should have at 12 'a' shift, but may not be grouped together"
|
|
|
|
shift_list_3 = Rota.get_worker_shift_list_string(worker3)
|
|
assert shift_list_3.count("a") == 12, "Worker 3 should have at least 12 'a' shifts, but may not be grouped together"
|
|
|
|
shift_list_4 = Rota.get_worker_shift_list_string(worker4)
|
|
assert shift_list_4.count("a") == 12, "Worker 4 should have at least 12 'a' shifts, but may not be grouped together"
|
|
|
|
# Modify worker1's preference to prevent block 'a' shifts
|
|
worker1.assign_as_block_preferences = {"a":-1}
|
|
Rota.build_and_solve(options={"ratio": 0.0})
|
|
shift_list_1 = Rota.get_worker_shift_list_string(worker1)
|
|
assert shift_list_1.count("aaaa") == 0, "Worker 1 should have all 'a' shifts grouped together (block)"
|
|
|
|
worker1.assign_as_block_preferences = {"a":0}
|
|
|
|
a_shift = Rota.get_shift_by_name("a")
|
|
a_shift.assign_as_block = True # Set the shift 'a' to be assigned as a block globally
|
|
Rota.build_and_solve(options={"ratio": 0.0})
|
|
|
|
for worker in Rota.get_workers():
|
|
shift_list = Rota.get_worker_shift_list_string(worker)
|
|
# All workers should have 'a' shifts grouped together (block)
|
|
assert shift_list.count("aaaa") == 3, f"Worker {worker.name} should have all 'a' shifts grouped together (block)"
|
|
|
|
def test_worker_assign_as_block_preference_multiple_shifts():
|
|
"""Test that a worker's assign_as_block preference is respected over the global setting."""
|
|
Rota = generate_basic_rota(workers=4, weeks_to_rota=12)
|
|
|
|
# Worker 1 prefers to have shift 'a' assigned as a block, worker 2 does not
|
|
workers = Rota.get_workers()
|
|
worker1 = workers[0]
|
|
worker2 = workers[1]
|
|
worker3 = workers[2]
|
|
worker4 = workers[3]
|
|
|
|
# Set a strong preference for worker1 to have 'a' as a block
|
|
worker1.assign_as_block_preferences = {"a":100}
|
|
worker2.assign_as_block_preferences = {"b":-10000} # Worker 2 does not want 'a' as a block
|
|
|
|
# Add a shift 'a' that is NOT globally set as assign_as_block
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="a",
|
|
length=8,
|
|
days=days[:4],
|
|
workers_required=1,
|
|
assign_as_block=False, # global setting is off
|
|
),
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="b",
|
|
length=8,
|
|
days=days[4:],
|
|
workers_required=1,
|
|
assign_as_block=False, # global setting is off
|
|
),
|
|
)
|
|
|
|
|
|
Rota.build_and_solve(options={"ratio": 0.0})
|
|
|
|
shift_list_1 = Rota.get_worker_shift_list_string(worker1)
|
|
assert shift_list_1.count("aaaa") == 3, "Worker 1 should have all 'a' shifts grouped together (block)"
|
|
Rota.export_rota_to_html("test_worker_assign_as_block_preference", folder="tests")
|
|
|
|
def test_worker_assign_as_block_preference_multiple_sites():
|
|
"""Test that a worker's assign_as_block preference is respected over the global setting."""
|
|
Rota = generate_basic_rota(workers=6, weeks_to_rota=12)
|
|
|
|
# Worker 1 prefers to have shift 'a' assigned as a block, worker 2 does not
|
|
workers = Rota.get_workers()
|
|
worker1 = workers[0]
|
|
worker2 = workers[1]
|
|
worker3 = workers[2]
|
|
worker4 = workers[3]
|
|
worker5 = workers[4]
|
|
worker6 = workers[5]
|
|
|
|
# Set a strong preference for worker1 to have 'a' as a block
|
|
worker1.assign_as_block_preferences = {"a":1}
|
|
|
|
worker4.site = "group2"
|
|
worker5.site = "group2"
|
|
worker6.site = "group2"
|
|
worker4.assign_as_block_preferences = {"b":-10000} # Worker 2 does not want 'a' as a block
|
|
|
|
# Add a shift 'a' that is NOT globally set as assign_as_block
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="a",
|
|
length=8,
|
|
days=days[:4],
|
|
workers_required=1,
|
|
assign_as_block=False, # global setting is off
|
|
),
|
|
SingleShift(
|
|
sites=("group2",),
|
|
name="b",
|
|
length=8,
|
|
days=days[4:],
|
|
workers_required=1,
|
|
assign_as_block=False, # global setting is off
|
|
),
|
|
)
|
|
|
|
Rota.build_and_solve(options={"ratio": 0.0})
|
|
Rota.export_rota_to_html("test_worker_assign_as_block_preference", folder="tests")
|
|
|
|
def test_worker_hard_day_dependency():
|
|
Rota = generate_basic_rota(workers=6, weeks_to_rota=16)
|
|
|
|
workers = Rota.get_workers()
|
|
|
|
worker1 = workers[0]
|
|
worker2 = workers[1]
|
|
worker3 = workers[2]
|
|
|
|
worker1.add_hard_day_dependency("Mon", "Tue")
|
|
worker2.add_hard_day_dependency("Mon", "Wed")
|
|
worker3.add_hard_day_dependency("Mon", "Thu")
|
|
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="a",
|
|
length=8,
|
|
days=days,
|
|
workers_required=2,
|
|
assign_as_block=False, # global setting is off
|
|
),
|
|
)
|
|
|
|
Rota.build_and_solve(options={"ratio": 0.0})
|
|
Rota.export_rota_to_html("test_worker_hard_day_depend", folder="tests")
|
|
|
|
for worker in Rota.get_workers():
|
|
shift_list = Rota.get_worker_shift_list_string(worker)
|
|
|
|
if worker.name == "worker1":
|
|
# Check that every time 'a' is assigned on Monday, it is also assigned on Tuesday
|
|
for week in range(12):
|
|
mon_idx = week * 7 + days.index("Mon")
|
|
tue_idx = week * 7 + days.index("Tue")
|
|
if shift_list[mon_idx] == "a":
|
|
assert shift_list[tue_idx] == "a", f"Worker1 assigned 'a' on Mon (week {week}) but not on Tue"
|
|
|
|
if worker.name == "worker2":
|
|
# Check that every time 'a' is assigned on Monday, it is also assigned on Wednesday
|
|
for week in range(12):
|
|
mon_idx = week * 7 + days.index("Mon")
|
|
wed_idx = week * 7 + days.index("Wed")
|
|
if shift_list[mon_idx] == "a":
|
|
assert shift_list[wed_idx] == "a", f"Worker2 assigned 'a' on Mon (week {week}) but not on Wed"
|
|
|
|
if worker.name == "worker3":
|
|
# Check that every time 'a' is assigned on Monday, it is also assigned on Thursday
|
|
for week in range(12):
|
|
mon_idx = week * 7 + days.index("Mon")
|
|
thu_idx = week * 7 + days.index("Thu")
|
|
if shift_list[mon_idx] == "a":
|
|
assert shift_list[thu_idx] == "a", f"Worker3 assigned 'a' on Mon (week {week}) but not on Thu"
|
|
|
|
|
|
def test_worker_hard_day_dependency_weekend():
|
|
Rota = generate_basic_rota(workers=4, weeks_to_rota=16)
|
|
|
|
workers = Rota.get_workers()
|
|
|
|
worker1 = workers[0]
|
|
worker2 = workers[1]
|
|
worker3 = workers[2]
|
|
worker4 = workers[3]
|
|
|
|
worker1.add_hard_day_dependency("Fri", "Sun")
|
|
worker2.add_hard_day_dependency("Fri", "Sun")
|
|
worker3.add_hard_day_dependency("Fri", "Sun")
|
|
worker4.add_hard_day_dependency("Fri", "Sun")
|
|
|
|
worker1.add_hard_day_exclusion("Fri", "Sat")
|
|
worker2.add_hard_day_exclusion("Fri", "Sat")
|
|
worker3.add_hard_day_exclusion("Fri", "Sat")
|
|
worker4.add_hard_day_exclusion("Fri", "Sat")
|
|
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="a",
|
|
length=8,
|
|
days=days[4:],
|
|
workers_required=1,
|
|
assign_as_block=False, # global setting is off
|
|
),
|
|
)
|
|
|
|
Rota.build_and_solve(options={"ratio": 0.0})
|
|
Rota.export_rota_to_html("test_worker_hard_day_depend", folder="tests")
|
|
|
|
for worker in Rota.get_workers():
|
|
shift_list = Rota.get_worker_shift_list_string(worker)
|
|
|
|
# Check that every time 'a' is assigned on Monday, it is also assigned on Tuesday
|
|
for week in range(16):
|
|
fri_idx = week * 7 + days.index("Fri")
|
|
sat_idx = week * 7 + days.index("Sat")
|
|
sun_idx = week * 7 + days.index("Sun")
|
|
if shift_list[fri_idx] == "a":
|
|
assert shift_list[sun_idx] == "a", f"Worker1 assigned 'a' on Fri (week {week}) but not on Sun"
|
|
|
|
assert shift_list[sat_idx] != "a", f"Worker1 assigned 'a' on Fri (week {week}) but also on Sat, which should not happen due to hard day exclusion"
|
|
|
|
|
|
def test_worker_hard_day_dependency_weekend2():
|
|
Rota = generate_basic_rota(workers=4, weeks_to_rota=16)
|
|
|
|
workers = Rota.get_workers()
|
|
|
|
worker1 = workers[0]
|
|
worker2 = workers[1]
|
|
worker3 = workers[2]
|
|
worker4 = workers[3]
|
|
|
|
worker1.add_hard_day_dependency("Fri", "Sun")
|
|
worker2.add_hard_day_dependency("Fri", "Sun")
|
|
worker3.add_hard_day_dependency("Fri", "Sun")
|
|
worker4.add_hard_day_dependency("Fri", "Sun")
|
|
worker4.add_hard_day_dependency("Fri", "Sat")
|
|
|
|
worker1.add_hard_day_exclusion("Fri", "Sat")
|
|
worker2.add_hard_day_exclusion("Fri", "Sat")
|
|
worker3.add_hard_day_exclusion("Fri", "Sat")
|
|
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="a",
|
|
length=8,
|
|
days=days[4:],
|
|
workers_required=1,
|
|
assign_as_block=False, # global setting is off
|
|
),
|
|
)
|
|
|
|
Rota.build_and_solve(options={"ratio": 0.0})
|
|
Rota.export_rota_to_html("test_worker_hard_day_depend", folder="tests")
|
|
|
|
for worker in Rota.get_workers():
|
|
shift_list = Rota.get_worker_shift_list_string(worker)
|
|
|
|
# Check that every time 'a' is assigned on Monday, it is also assigned on Tuesday
|
|
for week in range(16):
|
|
fri_idx = week * 7 + days.index("Fri")
|
|
sat_idx = week * 7 + days.index("Sat")
|
|
sun_idx = week * 7 + days.index("Sun")
|
|
if worker != worker4 and shift_list[fri_idx] == "a":
|
|
assert shift_list[sun_idx] == "a", f"Worker1 assigned 'a' on Fri (week {week}) but not on Sun"
|
|
|
|
assert shift_list[sat_idx] != "a", f"Worker1 assigned 'a' on Fri (week {week}) but also on Sat, which should not happen due to hard day exclusion"
|
|
|
|
if worker == worker4 and shift_list[fri_idx] == "a":
|
|
assert shift_list[sun_idx] == "a", f"Worker4 assigned 'a' on Fri (week {week}) but not on Sun"
|
|
assert shift_list[sat_idx] == "a", f"Worker4 assigned 'a' on Fri (week {week}) but not on Sun"
|
|
|
|
|
|
def test_worker_force_assign_with_invalid_shift():
|
|
Rota = generate_basic_rota(workers=4, weeks_to_rota=2)
|
|
workers = Rota.get_workers()
|
|
for worker in workers:
|
|
worker.add_force_assign_with("a", "c")
|
|
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="a",
|
|
length=8,
|
|
days=days[:4],
|
|
workers_required=1,
|
|
assign_as_block=False, # global setting is off
|
|
),
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="b",
|
|
length=8,
|
|
days=days[:4],
|
|
workers_required=1,
|
|
assign_as_block=False, # global setting is off
|
|
),
|
|
)
|
|
|
|
with pytest.raises(InvalidShift):
|
|
# This should raise an InvalidShift error because 'c' does not exist
|
|
Rota.build_and_solve(options={"ratio": 0.0})
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="c",
|
|
length=8,
|
|
days=days[:4],
|
|
workers_required=1,
|
|
assign_as_block=False, # global setting is off
|
|
),
|
|
)
|
|
Rota.build_and_solve(options={"ratio": 0.0})
|
|
|
|
|
|
def test_worker_force_assign_with():
|
|
Rota = generate_basic_rota(workers=4, weeks_to_rota=16)
|
|
|
|
workers = Rota.get_workers()
|
|
|
|
for worker in workers:
|
|
worker.add_force_assign_with("a", "b")
|
|
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="a",
|
|
length=8,
|
|
days=days[:4],
|
|
workers_required=1,
|
|
assign_as_block=False, # global setting is off
|
|
),
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="b",
|
|
length=8,
|
|
days=days[:4],
|
|
workers_required=1,
|
|
assign_as_block=False, # global setting is off
|
|
),
|
|
)
|
|
Rota.build_and_solve(options={"ratio": 0.0})
|
|
Rota.export_rota_to_html("test_worker_force_assign_with", folder="tests")
|
|
|
|
for worker in Rota.get_workers():
|
|
shift_list = Rota.get_worker_shift_list(worker, search_multiple_assignments=True)
|
|
|
|
for shift in shift_list:
|
|
if shift == "a":
|
|
assert "b" in shift_list, f"Worker {worker.name} has 'a' but not 'b' assigned with it"
|
|
elif shift == "b":
|
|
assert "a" in shift_list, f"Worker {worker.name} has 'b' but not 'a' assigned with it"
|
|
|
|
assert shift_list.count({"a", "b"}) == 16
|
|
|
|
def test_worker_force_assign_with_and_hard_days():
|
|
Rota = generate_basic_rota(workers=4, weeks_to_rota=16)
|
|
|
|
workers = Rota.get_workers()
|
|
|
|
for worker in workers:
|
|
worker.add_force_assign_with("a", "b")
|
|
|
|
#worker.add_hard_day_dependency("Sat", "Sun")
|
|
worker.add_hard_day_dependency("Mon", "Tue")
|
|
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="a",
|
|
length=8,
|
|
days=days[:2],
|
|
workers_required=1,
|
|
assign_as_block=False, # global setting is off
|
|
),
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="b",
|
|
length=8,
|
|
days=days[:2],
|
|
workers_required=1,
|
|
assign_as_block=False, # global setting is off
|
|
),
|
|
)
|
|
Rota.build_and_solve(options={"ratio": 0.0})
|
|
Rota.export_rota_to_html("test_worker_force_assign_with_hard_days", folder="tests")
|
|
|
|
assert Rota.results.solver.status == "ok"
|
|
|
|
def test_worker_force_assign_with_and_hard_days_weekend():
|
|
Rota = generate_basic_rota(workers=4, weeks_to_rota=16)
|
|
|
|
workers = Rota.get_workers()
|
|
|
|
for worker in workers:
|
|
worker.add_force_assign_with("a", "b")
|
|
|
|
#worker.add_hard_day_dependency("Sat", "Sun")
|
|
worker.add_hard_day_dependency("Sat", "Sun")
|
|
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="a",
|
|
length=8,
|
|
days=days[5:],
|
|
workers_required=1,
|
|
assign_as_block=False, # global setting is off
|
|
),
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="b",
|
|
length=8,
|
|
days=days[5:],
|
|
workers_required=1,
|
|
assign_as_block=False, # global setting is off
|
|
),
|
|
)
|
|
Rota.build_and_solve(options={"ratio": 0.0})
|
|
Rota.export_rota_to_html("test_worker_force_assign_with_hard_days", folder="tests")
|
|
|
|
assert Rota.results.solver.status == "ok"
|
|
|
|
|
|
def test_workers_double_shifts_on_mon_tue():
|
|
Rota = generate_basic_rota(workers=2, weeks_to_rota=16)
|
|
|
|
workers = Rota.get_workers()
|
|
|
|
for worker in workers:
|
|
worker.add_force_assign_with("a", "b")
|
|
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="a",
|
|
length=8,
|
|
days=days[:2],
|
|
workers_required=2,
|
|
assign_as_block=False, # global setting is off
|
|
),
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="b",
|
|
length=8,
|
|
days=days[:2],
|
|
workers_required=2,
|
|
assign_as_block=False, # global setting is off
|
|
),
|
|
)
|
|
Rota.build_and_solve(options={"ratio": 0.0})
|
|
Rota.export_rota_to_html("test_worker_double_shifts", folder="tests")
|
|
|
|
assert Rota.results.solver.status == "ok"
|
|
|
|
def test_workers_double_shifts_on_weekends():
|
|
Rota = generate_basic_rota(workers=2, weeks_to_rota=16)
|
|
|
|
workers = Rota.get_workers()
|
|
|
|
for worker in workers:
|
|
worker.add_force_assign_with("a", "b")
|
|
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="a",
|
|
length=8,
|
|
days=days[5:],
|
|
workers_required=2,
|
|
assign_as_block=False, # global setting is off
|
|
),
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="b",
|
|
length=8,
|
|
days=days[5:],
|
|
workers_required=2,
|
|
assign_as_block=False, # global setting is off
|
|
),
|
|
)
|
|
Rota.build_and_solve(options={"ratio": 0.0})
|
|
Rota.export_rota_to_html("test_worker_double_shifts", folder="tests")
|
|
|
|
assert Rota.results.solver.status == "ok"
|
|
|
|
|
|
def test_max_shifts_per_week_by_shift_name_basic():
|
|
Rota = RotaBuilder(weeks_to_rota=2, start_date=datetime.date(2025, 6, 16))
|
|
w = Worker(name="A01", site="site1", grade=1)
|
|
w.max_shifts_per_week_by_shift_name = {"a": 1}
|
|
Rota.add_worker(w)
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("site1",),
|
|
name="a",
|
|
length=8,
|
|
days=days[:5],
|
|
workers_required=1,
|
|
),
|
|
)
|
|
Rota.build_and_solve(options={"ratio": 0.0})
|
|
# Worker should not be assigned more than 1 "a" shift per week
|
|
for week in Rota.weeks:
|
|
count = sum(
|
|
Rota.model.works[w.id, week, day, "a"].value > 0.5
|
|
for day in days[:5]
|
|
)
|
|
assert count <= 1
|
|
|
|
def test_max_shifts_per_week_by_shift_name_multiple_weeks():
|
|
Rota =generate_basic_rota(workers=4, weeks_to_rota=2)
|
|
for worker in Rota.get_workers():
|
|
worker.set_max_shifts_per_week("a", 1)
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="a",
|
|
length=8,
|
|
days=days[:5],
|
|
workers_required=1,
|
|
),
|
|
)
|
|
Rota.build_and_solve(options={"ratio": 0.0})
|
|
assert Rota.results.solver.status == "error"
|
|
|
|
Rota.add_worker(
|
|
Worker(name="extraworker", site="group1", grade=1, max_shifts_per_week_by_shift_name={"a": 1})
|
|
)
|
|
Rota.build_and_solve(options={"ratio": 0.0})
|
|
assert Rota.results.solver.status == "ok"
|
|
|
|
Rota.export_rota_to_html("test_worker_double_shifts", folder="tests")
|
|
# Worker should not be assigned more than 2 "a" shifts per week
|
|
for worker in Rota.get_workers():
|
|
for week in Rota.weeks:
|
|
count = sum(
|
|
Rota.model.works[worker.id, week, day, "a"].value > 0.5
|
|
for day in days[:5]
|
|
)
|
|
assert count <= 2
|
|
|
|
|
|
def test_max_shifts_per_week_by_shift_name_invalid_shift():
|
|
Rota = RotaBuilder(weeks_to_rota=1, start_date=datetime.date(2025, 6, 16))
|
|
w = Worker(name="A03", site="site1", grade=1)
|
|
# "b" does not exist
|
|
w.max_shifts_per_week_by_shift_name = {"b": 1}
|
|
Rota.add_worker(w)
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("site1",),
|
|
name="a",
|
|
length=8,
|
|
days=days[:5],
|
|
workers_required=1,
|
|
),
|
|
)
|
|
with pytest.raises(InvalidShift):
|
|
Rota.build_and_solve(options={"ratio": 0.0})
|
|
|
|
def test_force_assign_shift():
|
|
Rota = generate_basic_rota(workers=4, weeks_to_rota=2)
|
|
|
|
workers = Rota.get_workers()
|
|
|
|
workers[0].force_assign_shift(1, "Mon", "a")
|
|
workers[0].force_assign_shift(1, "Tue", "b")
|
|
workers[0].force_assign_shift(2, "Wed", "b")
|
|
workers[0].force_assign_shift(2, "Thu", "b")
|
|
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="a",
|
|
length=8,
|
|
days=days[:4],
|
|
workers_required=1,
|
|
assign_as_block=False, # global setting is off
|
|
),
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="b",
|
|
length=8,
|
|
days=days[:4],
|
|
workers_required=1,
|
|
assign_as_block=False, # global setting is off
|
|
),
|
|
)
|
|
Rota.build_and_solve(options={"ratio": 0.0})
|
|
Rota.export_rota_to_html("test_worker_force_assign", folder="tests")
|
|
|
|
for worker in Rota.get_workers():
|
|
shift_list = Rota.get_worker_shift_list(worker)
|
|
if worker == workers[0]:
|
|
# Worker 0 should have 'a' on Monday and 'b' on Tuesday
|
|
assert shift_list[0] == "a", "Worker 0 should have 'a' on 1st Monday"
|
|
assert shift_list[1] == "b", "Worker 0 should have 'b' on 1st Tuesday"
|
|
assert shift_list[9] == "b", "Worker 0 should have 'b' on 2nd Wednesday"
|
|
assert shift_list[10] == "b", "Worker 0 should have 'b' on 2nd Thursday"
|
|
|
|
def test_force_assign_shift2():
|
|
Rota = generate_basic_rota(workers=4, weeks_to_rota=1)
|
|
|
|
workers = Rota.get_workers()
|
|
|
|
workers[0].force_assign_shift(1, "Mon", "a")
|
|
workers[0].force_assign_shift(1, "Tue", "a")
|
|
workers[0].force_assign_shift(1, "Wed", "a")
|
|
workers[0].force_assign_shift(1, "Thu", "a")
|
|
|
|
Rota.add_shifts(
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="a",
|
|
length=8,
|
|
days=days[:4],
|
|
workers_required=1,
|
|
assign_as_block=False, # global setting is off
|
|
),
|
|
SingleShift(
|
|
sites=("group1",),
|
|
name="b",
|
|
length=8,
|
|
days=days[:4],
|
|
workers_required=1,
|
|
assign_as_block=False, # global setting is off
|
|
),
|
|
)
|
|
Rota.build_and_solve(options={"ratio": 0.0})
|
|
Rota.export_rota_to_html("test_worker_force_assign", folder="tests")
|
|
for worker in Rota.get_workers():
|
|
shift_list = Rota.get_worker_shift_list(worker)
|
|
if worker == workers[0]:
|
|
# Worker 0 should have 'a' on Monday and 'b' on Tuesday
|
|
assert shift_list[0] == "a", "Worker 0 should have 'a' on 1st Monday"
|
|
assert shift_list[1] == "a", "Worker 0 should have 'b' on 1st Tuesday"
|
|
assert shift_list[2] == "a", "Worker 0 should have 'b' on 1st Tuesday"
|
|
assert shift_list[3] == "a", "Worker 0 should have 'b' on 1st Tuesday" |