Add ignore_dates field to worker constraints and implement related tests
This commit is contained in:
+100
-10
@@ -422,12 +422,11 @@ function renderWorkerList() {
|
|||||||
const displayName = (w.display_name || '') + '';
|
const displayName = (w.display_name || '') + '';
|
||||||
const site = (w.site || '') + '';
|
const site = (w.site || '') + '';
|
||||||
const searchName = (displayName || name).toLowerCase();
|
const searchName = (displayName || name).toLowerCase();
|
||||||
if (
|
if (filter) {
|
||||||
filter
|
const matchesName = searchName.indexOf(filter) !== -1;
|
||||||
&& searchName.indexOf(filter) === -1
|
const matchesSite = site.toLowerCase().indexOf(filter) !== -1;
|
||||||
&& name.toLowerCase().indexOf(filter) === -1
|
if (!matchesName && !matchesSite) return;
|
||||||
&& site.toLowerCase().indexOf(filter) === -1
|
}
|
||||||
) return;
|
|
||||||
// if shiftFilter, ensure worker has target or assigned for that shift
|
// if shiftFilter, ensure worker has target or assigned for that shift
|
||||||
// attempt to read per-worker targets and counts from DOM if not present in JSON
|
// attempt to read per-worker targets and counts from DOM if not present in JSON
|
||||||
let targets = w.shift_target_number || {};
|
let targets = w.shift_target_number || {};
|
||||||
@@ -451,21 +450,112 @@ function renderWorkerList() {
|
|||||||
}
|
}
|
||||||
const fte = w.fte || '';
|
const fte = w.fte || '';
|
||||||
const labelName = displayName || name;
|
const labelName = displayName || name;
|
||||||
const card = $(`<div class='worker-card' style='border:1px solid #ccc;padding:8px;margin-bottom:6px;'><strong>${labelName}</strong> <span style='color:#666'>${site}</span><div>FTE: ${fte}</div></div>`);
|
const grade = w.grade || '';
|
||||||
|
const id = w.id ? String(w.id).substring(0, 8) : '';
|
||||||
|
const card = $(`<div class='worker-card'><div class='worker-header'><strong>${labelName}</strong> <span class='worker-id'>${id}</span></div><div class='worker-meta'><span class='worker-site'>${site}</span> • <span class='worker-grade'>Grade: ${grade}</span> • <span class='worker-fte'>FTE: ${fte}%</span></div></div>`);
|
||||||
// show shift targets summary
|
// show shift targets summary
|
||||||
if (w.shift_target_number) {
|
if (w.shift_target_number && Object.keys(w.shift_target_number).length > 0) {
|
||||||
const tbl = $("<table style='margin-top:6px; width:100%;'></table>");
|
const tbl = $("<table class='worker-shifts-table'></table>");
|
||||||
|
const header = $("<thead><tr><th>Shift</th><th class='text-right'>Assigned</th><th class='text-right'>Target</th></tr></thead>");
|
||||||
|
tbl.append(header);
|
||||||
|
const tbody = $("<tbody></tbody>");
|
||||||
Object.keys(w.shift_target_number).forEach(s => {
|
Object.keys(w.shift_target_number).forEach(s => {
|
||||||
const t = w.shift_target_number[s];
|
const t = w.shift_target_number[s];
|
||||||
const c = (w.shift_counts && w.shift_counts[s]) ? w.shift_counts[s] : 0;
|
const c = (w.shift_counts && w.shift_counts[s]) ? w.shift_counts[s] : 0;
|
||||||
tbl.append(`<tr><td style='width:60%'>${s}</td><td style='text-align:right'>${c} assigned</td><td style='text-align:right'>target ${parseFloat(t).toFixed(2)}</td></tr>`);
|
tbody.append(`<tr><td>${s}</td><td class='text-right'>${c}</td><td class='text-right'>${parseFloat(t).toFixed(2)}</td></tr>`);
|
||||||
});
|
});
|
||||||
|
tbl.append(tbody);
|
||||||
card.append(tbl);
|
card.append(tbl);
|
||||||
}
|
}
|
||||||
$list.append(card);
|
$list.append(card);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add CSS for worker details styling and dark mode
|
||||||
|
if ($('#worker-details-style').length === 0) {
|
||||||
|
$('head').append(`<style id='worker-details-style'>
|
||||||
|
.worker-card {
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: #fff;
|
||||||
|
transition: background-color 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
.worker-card:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-color: #999;
|
||||||
|
}
|
||||||
|
.worker-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.worker-header strong {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.worker-id {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: #999;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.worker-meta {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.worker-site, .worker-grade, .worker-fte {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
.worker-shifts-table {
|
||||||
|
margin-top: 6px;
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.worker-shifts-table thead {
|
||||||
|
background: #f0f0f0;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.worker-shifts-table th, .worker-shifts-table td {
|
||||||
|
padding: 4px 6px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
.worker-shifts-table tr:nth-child(even) {
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
.text-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.worker-card {
|
||||||
|
background: #2d2d2d;
|
||||||
|
border-color: #444;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
.worker-card:hover {
|
||||||
|
background: #333;
|
||||||
|
border-color: #666;
|
||||||
|
}
|
||||||
|
.worker-meta, .worker-id {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
.worker-shifts-table thead {
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
.worker-shifts-table th, .worker-shifts-table td {
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
.worker-shifts-table tr:nth-child(even) {
|
||||||
|
background: #252525;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>`);
|
||||||
|
}
|
||||||
|
|
||||||
// initialize panels
|
// initialize panels
|
||||||
renderShiftSettings();
|
renderShiftSettings();
|
||||||
renderWorkerFilterOptions();
|
renderWorkerFilterOptions();
|
||||||
|
|||||||
+43
-17
@@ -2405,33 +2405,59 @@ class RotaBuilder(object):
|
|||||||
|
|
||||||
for constraint in worker.max_days_per_week_block:
|
for constraint in worker.max_days_per_week_block:
|
||||||
for week_blocks in self.get_week_block_iterator(constraint.week_block):
|
for week_blocks in self.get_week_block_iterator(constraint.week_block):
|
||||||
self.model.constraints.add(
|
# Determine which (week, day) combinations to ignore
|
||||||
sum(
|
ignored_week_days = set()
|
||||||
self.model.works_day[worker.id, week, day]
|
for ignore_date in (constraint.ignore_dates or []):
|
||||||
for week, day in self.get_week_day_combinations()
|
try:
|
||||||
if week in week_blocks
|
w, d = self.date_week_day_map.get(ignore_date, (None, None))
|
||||||
|
if w is not None and d is not None:
|
||||||
|
ignored_week_days.add((w, d))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
included_days = [
|
||||||
|
(week, day)
|
||||||
|
for week, day in self.get_week_day_combinations()
|
||||||
|
if week in week_blocks and (week, day) not in ignored_week_days
|
||||||
|
]
|
||||||
|
if included_days:
|
||||||
|
self.model.constraints.add(
|
||||||
|
sum(
|
||||||
|
self.model.works_day[worker.id, week, day]
|
||||||
|
for week, day in included_days
|
||||||
|
)
|
||||||
|
<= constraint.max_days
|
||||||
)
|
)
|
||||||
<= constraint.max_days
|
|
||||||
)
|
|
||||||
|
|
||||||
for constraint in worker.max_unique_shifts_per_week_block:
|
for constraint in worker.max_unique_shifts_per_week_block:
|
||||||
for week_blocks in self.get_week_block_iterator(constraint.week_block):
|
for week_blocks in self.get_week_block_iterator(constraint.week_block):
|
||||||
if not week_blocks:
|
if not week_blocks:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Determine which (week, day) combinations to ignore
|
||||||
|
ignored_week_days = set()
|
||||||
|
for ignore_date in (constraint.ignore_dates or []):
|
||||||
|
try:
|
||||||
|
w, d = self.date_week_day_map.get(ignore_date, (None, None))
|
||||||
|
if w is not None and d is not None:
|
||||||
|
ignored_week_days.add((w, d))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
block_start_week = week_blocks[0]
|
block_start_week = week_blocks[0]
|
||||||
used_shift_vars = []
|
used_shift_vars = []
|
||||||
for shift_name in self.get_shift_names():
|
for shift_name in self.get_shift_names():
|
||||||
shift_assignments = [
|
shift_assignments = []
|
||||||
self.model.works[worker.id, week, day, shift_name]
|
for week, day in self.get_week_day_combinations():
|
||||||
for week, day in self.get_week_day_combinations()
|
if week in week_blocks and (week, day) not in ignored_week_days:
|
||||||
if week in week_blocks
|
if shift_name in self.get_shift_names_by_week_day(
|
||||||
and shift_name in self.get_shift_names_by_week_day(
|
week,
|
||||||
week,
|
day,
|
||||||
day,
|
return_empty_if_week_day_not_found=True,
|
||||||
return_empty_if_week_day_not_found=True,
|
):
|
||||||
)
|
shift_assignments.append(
|
||||||
]
|
self.model.works[worker.id, week, day, shift_name]
|
||||||
|
)
|
||||||
if not shift_assignments:
|
if not shift_assignments:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,10 @@ class MaxDaysPerWeekBlockConstraint(BaseModel):
|
|||||||
ge=1,
|
ge=1,
|
||||||
description="Length of the sliding week block (for example, 2 means every consecutive 2-week window).",
|
description="Length of the sliding week block (for example, 2 means every consecutive 2-week window).",
|
||||||
)
|
)
|
||||||
|
ignore_dates: list[datetime.date] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="List of specific dates to exclude from this constraint.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MaxUniqueShiftsPerWeekBlockConstraint(BaseModel):
|
class MaxUniqueShiftsPerWeekBlockConstraint(BaseModel):
|
||||||
@@ -159,6 +163,10 @@ class MaxUniqueShiftsPerWeekBlockConstraint(BaseModel):
|
|||||||
ge=1,
|
ge=1,
|
||||||
description="Length of the sliding week block (for example, 2 means every consecutive 2-week window).",
|
description="Length of the sliding week block (for example, 2 means every consecutive 2-week window).",
|
||||||
)
|
)
|
||||||
|
ignore_dates: list[datetime.date] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="List of specific dates to exclude from this constraint.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Worker(BaseModel):
|
class Worker(BaseModel):
|
||||||
|
|||||||
@@ -161,6 +161,101 @@ def test_worker_block_constraint_validation():
|
|||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
MaxUniqueShiftsPerWeekBlockConstraint(max_unique_shifts=0, week_block=1)
|
MaxUniqueShiftsPerWeekBlockConstraint(max_unique_shifts=0, week_block=1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_max_days_per_week_block_pass():
|
||||||
|
"""Positive test: constraint allowing reasonable days."""
|
||||||
|
Rota = generate_basic_rota()
|
||||||
|
# Add constraint allowing up to 4 days per week
|
||||||
|
Rota.workers[0].max_days_per_week_block = [
|
||||||
|
MaxDaysPerWeekBlockConstraint(max_days=4, week_block=1)
|
||||||
|
]
|
||||||
|
# Add shifts to the rota
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1", "group2"),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=days,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Rota.build_and_solve(options={"ratio": 0.000})
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
assert Rota.results.solver.termination_condition == "optimal"
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_max_unique_shifts_per_week_block_pass():
|
||||||
|
"""Positive test: allowing multiple shifts within week block."""
|
||||||
|
Rota = generate_basic_rota()
|
||||||
|
# Add constraint allowing up to 2 unique shifts per week
|
||||||
|
Rota.workers[0].max_unique_shifts_per_week_block = [
|
||||||
|
MaxUniqueShiftsPerWeekBlockConstraint(max_unique_shifts=2, week_block=1)
|
||||||
|
]
|
||||||
|
# Add shifts to the rota
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1", "group2"),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=days,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Rota.build_and_solve(options={"ratio": 0.000})
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
assert Rota.results.solver.termination_condition == "optimal"
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_max_days_per_week_block_with_ignore_dates():
|
||||||
|
"""Test max_days_per_week_block with ignored dates works without error."""
|
||||||
|
Rota = generate_basic_rota()
|
||||||
|
rota_start = Rota.start_date
|
||||||
|
ignored_date = rota_start # Ignore first Monday
|
||||||
|
# Add constraint with ignore_dates
|
||||||
|
Rota.workers[0].max_days_per_week_block = [
|
||||||
|
MaxDaysPerWeekBlockConstraint(
|
||||||
|
max_days=3,
|
||||||
|
week_block=1,
|
||||||
|
ignore_dates=[ignored_date],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
# Add shifts to the rota
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1", "group2"),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=days,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Rota.build_and_solve(options={"ratio": 0.000})
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_max_unique_shifts_per_week_block_with_ignore_dates():
|
||||||
|
"""Test max_unique_shifts_per_week_block with ignored dates works without error."""
|
||||||
|
Rota = generate_basic_rota()
|
||||||
|
rota_start = Rota.start_date
|
||||||
|
ignored_date = rota_start + datetime.timedelta(days=1) # Ignore Tuesday
|
||||||
|
# Add constraint with ignore_dates
|
||||||
|
Rota.workers[0].max_unique_shifts_per_week_block = [
|
||||||
|
MaxUniqueShiftsPerWeekBlockConstraint(
|
||||||
|
max_unique_shifts=1,
|
||||||
|
week_block=1,
|
||||||
|
ignore_dates=[ignored_date],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
# Add shifts to the rota
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1", "group2"),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=days,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Rota.build_and_solve(options={"ratio": 0.000})
|
||||||
|
# This should still be feasible with only one shift type in main rota
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
|
||||||
def test_pre_shift_constraint():
|
def test_pre_shift_constraint():
|
||||||
Rota = generate_basic_rota()
|
Rota = generate_basic_rota()
|
||||||
for i, d in enumerate(days[:6]):
|
for i, d in enumerate(days[:6]):
|
||||||
|
|||||||
Reference in New Issue
Block a user