Commit
·
e7cf451
1
Parent(s):
e510416
update
Browse files- README.MD +72 -0
- src/employee_scheduling/.project +1 -0
- src/employee_scheduling/__init__.py +1 -1
- src/employee_scheduling/__pycache__/__init__.cpython-312.pyc +0 -0
- src/employee_scheduling/__pycache__/constraints.cpython-312.pyc +0 -0
- src/employee_scheduling/__pycache__/converters.cpython-312.pyc +0 -0
- src/employee_scheduling/__pycache__/demo_data.cpython-312.pyc +0 -0
- src/employee_scheduling/__pycache__/rest_api.cpython-312.pyc +0 -0
- src/employee_scheduling/constraints.py +8 -9
- src/employee_scheduling/converters.py +0 -3
- src/employee_scheduling/demo_data.py +1 -1
- src/employee_scheduling/rest_api.py +75 -3
- src/employee_scheduling/score_analysis.py +15 -0
- static/app.js +2 -0
- static/index.html +40 -0
- static/webjars/timefold/css/timefold-webui.css +60 -0
- static/webjars/timefold/img/timefold-favicon.svg +25 -0
- static/webjars/timefold/img/timefold-logo-horizontal-negative.svg +1 -0
- static/webjars/timefold/img/timefold-logo-horizontal-positive.svg +1 -0
- static/webjars/timefold/img/timefold-logo-stacked-positive.svg +1 -0
- static/webjars/timefold/js/timefold-webui.js +142 -0
- tests/test_constraints.py +608 -695
- tests/test_feasible.py +135 -13
README.MD
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Employee Scheduling (Python)
|
| 2 |
+
|
| 3 |
+
Schedule shifts to employees, accounting for employee availability and shift skill requirements.
|
| 4 |
+
|
| 5 |
+

|
| 6 |
+
|
| 7 |
+
- [Prerequisites](#prerequisites)
|
| 8 |
+
- [Run the application](#run-the-application)
|
| 9 |
+
- [Test the application](#test-the-application)
|
| 10 |
+
|
| 11 |
+
> [!TIP]
|
| 12 |
+
> <img src="https://docs.timefold.ai/_/img/models/employee-shift-scheduling.svg" align="right" width="50px" /> [Check out our off-the-shelf model for Employee Shift Scheduling](https://app.timefold.ai/models/employee-scheduling/v1). This model supports many additional constraints such as skills, pairing employees, fairness and more.
|
| 13 |
+
|
| 14 |
+
## Prerequisites
|
| 15 |
+
|
| 16 |
+
1. Install [Python 3.11 or 3.12](https://www.python.org/downloads/).
|
| 17 |
+
|
| 18 |
+
2. Install JDK 17+, for example with [Sdkman](https://sdkman.io):
|
| 19 |
+
|
| 20 |
+
```sh
|
| 21 |
+
$ sdk install java
|
| 22 |
+
```
|
| 23 |
+
|
| 24 |
+
## Run the application
|
| 25 |
+
|
| 26 |
+
1. Git clone the timefold-solver-python repo and navigate to this directory:
|
| 27 |
+
|
| 28 |
+
```sh
|
| 29 |
+
$ git clone https://github.com/TimefoldAI/timefold-solver-python.git
|
| 30 |
+
...
|
| 31 |
+
$ cd timefold-solver-python/quickstarts/employee-scheduling
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
2. Create a virtual environment:
|
| 35 |
+
|
| 36 |
+
```sh
|
| 37 |
+
$ python -m venv .venv
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
3. Activate the virtual environment:
|
| 41 |
+
|
| 42 |
+
```sh
|
| 43 |
+
$ . .venv/bin/activate
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
4. Install the application:
|
| 47 |
+
|
| 48 |
+
```sh
|
| 49 |
+
$ pip install -e .
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
5. Run the application:
|
| 53 |
+
|
| 54 |
+
```sh
|
| 55 |
+
$ run-app
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
6. Visit [http://localhost:8080](http://localhost:8080) in your browser.
|
| 59 |
+
|
| 60 |
+
7. Click on the **Solve** button.
|
| 61 |
+
|
| 62 |
+
## Test the application
|
| 63 |
+
|
| 64 |
+
1. Run tests:
|
| 65 |
+
|
| 66 |
+
```sh
|
| 67 |
+
$ pytest
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
## More information
|
| 71 |
+
|
| 72 |
+
Visit [timefold.ai](https://timefold.ai).
|
src/employee_scheduling/.project
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
/srv/lab/dev/solverforge/solverforge-quickstarts/fast/employee-scheduling-fast/src/employee_scheduling
|
src/employee_scheduling/__init__.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import uvicorn
|
| 2 |
|
| 3 |
-
from .rest_api import app
|
| 4 |
|
| 5 |
|
| 6 |
def main():
|
|
|
|
| 1 |
import uvicorn
|
| 2 |
|
| 3 |
+
from .rest_api import app as app
|
| 4 |
|
| 5 |
|
| 6 |
def main():
|
src/employee_scheduling/__pycache__/__init__.cpython-312.pyc
CHANGED
|
Binary files a/src/employee_scheduling/__pycache__/__init__.cpython-312.pyc and b/src/employee_scheduling/__pycache__/__init__.cpython-312.pyc differ
|
|
|
src/employee_scheduling/__pycache__/constraints.cpython-312.pyc
CHANGED
|
Binary files a/src/employee_scheduling/__pycache__/constraints.cpython-312.pyc and b/src/employee_scheduling/__pycache__/constraints.cpython-312.pyc differ
|
|
|
src/employee_scheduling/__pycache__/converters.cpython-312.pyc
CHANGED
|
Binary files a/src/employee_scheduling/__pycache__/converters.cpython-312.pyc and b/src/employee_scheduling/__pycache__/converters.cpython-312.pyc differ
|
|
|
src/employee_scheduling/__pycache__/demo_data.cpython-312.pyc
CHANGED
|
Binary files a/src/employee_scheduling/__pycache__/demo_data.cpython-312.pyc and b/src/employee_scheduling/__pycache__/demo_data.cpython-312.pyc differ
|
|
|
src/employee_scheduling/__pycache__/rest_api.cpython-312.pyc
CHANGED
|
Binary files a/src/employee_scheduling/__pycache__/rest_api.cpython-312.pyc and b/src/employee_scheduling/__pycache__/rest_api.cpython-312.pyc differ
|
|
|
src/employee_scheduling/constraints.py
CHANGED
|
@@ -5,7 +5,7 @@ from solverforge_legacy.solver.score import (
|
|
| 5 |
HardSoftDecimalScore,
|
| 6 |
ConstraintCollectors,
|
| 7 |
)
|
| 8 |
-
from datetime import datetime, date
|
| 9 |
|
| 10 |
from .domain import Employee, Shift
|
| 11 |
|
|
@@ -33,13 +33,12 @@ def overlapping_in_minutes(
|
|
| 33 |
|
| 34 |
|
| 35 |
def get_shift_overlapping_duration_in_minutes(shift: Shift, dt: date) -> int:
|
| 36 |
-
overlap = 0
|
| 37 |
start_date_time = datetime.combine(dt, datetime.min.time())
|
| 38 |
end_date_time = datetime.combine(dt, datetime.max.time())
|
| 39 |
-
overlap
|
| 40 |
start_date_time, end_date_time, shift.start, shift.end
|
| 41 |
)
|
| 42 |
-
return overlap
|
| 43 |
|
| 44 |
|
| 45 |
@constraint_provider
|
|
@@ -51,7 +50,7 @@ def define_constraints(constraint_factory: ConstraintFactory):
|
|
| 51 |
at_least_10_hours_between_two_shifts(constraint_factory),
|
| 52 |
one_shift_per_day(constraint_factory),
|
| 53 |
unavailable_employee(constraint_factory),
|
| 54 |
-
max_shifts_per_employee(constraint_factory),
|
| 55 |
# Soft constraints
|
| 56 |
undesired_day_for_employee(constraint_factory),
|
| 57 |
desired_day_for_employee(constraint_factory),
|
|
@@ -126,10 +125,10 @@ def unavailable_employee(constraint_factory: ConstraintFactory):
|
|
| 126 |
Joiners.equal(lambda shift: shift.employee, lambda employee: employee),
|
| 127 |
)
|
| 128 |
.flatten_last(lambda employee: employee.unavailable_dates)
|
| 129 |
-
.filter(lambda shift, unavailable_date:
|
| 130 |
.penalize(
|
| 131 |
HardSoftDecimalScore.ONE_HARD,
|
| 132 |
-
lambda shift, unavailable_date: shift.
|
| 133 |
)
|
| 134 |
.as_constraint("Unavailable employee")
|
| 135 |
)
|
|
@@ -169,7 +168,7 @@ def undesired_day_for_employee(constraint_factory: ConstraintFactory):
|
|
| 169 |
.filter(lambda shift, undesired_date: shift.is_overlapping_with_date(undesired_date))
|
| 170 |
.penalize(
|
| 171 |
HardSoftDecimalScore.ONE_SOFT,
|
| 172 |
-
lambda shift, undesired_date: shift.
|
| 173 |
)
|
| 174 |
.as_constraint("Undesired day for employee")
|
| 175 |
)
|
|
@@ -186,7 +185,7 @@ def desired_day_for_employee(constraint_factory: ConstraintFactory):
|
|
| 186 |
.filter(lambda shift, desired_date: shift.is_overlapping_with_date(desired_date))
|
| 187 |
.reward(
|
| 188 |
HardSoftDecimalScore.ONE_SOFT,
|
| 189 |
-
lambda shift, desired_date: shift.
|
| 190 |
)
|
| 191 |
.as_constraint("Desired day for employee")
|
| 192 |
)
|
|
|
|
| 5 |
HardSoftDecimalScore,
|
| 6 |
ConstraintCollectors,
|
| 7 |
)
|
| 8 |
+
from datetime import datetime, date, time
|
| 9 |
|
| 10 |
from .domain import Employee, Shift
|
| 11 |
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
def get_shift_overlapping_duration_in_minutes(shift: Shift, dt: date) -> int:
|
|
|
|
| 36 |
start_date_time = datetime.combine(dt, datetime.min.time())
|
| 37 |
end_date_time = datetime.combine(dt, datetime.max.time())
|
| 38 |
+
overlap = overlapping_in_minutes(
|
| 39 |
start_date_time, end_date_time, shift.start, shift.end
|
| 40 |
)
|
| 41 |
+
return int(overlap)
|
| 42 |
|
| 43 |
|
| 44 |
@constraint_provider
|
|
|
|
| 50 |
at_least_10_hours_between_two_shifts(constraint_factory),
|
| 51 |
one_shift_per_day(constraint_factory),
|
| 52 |
unavailable_employee(constraint_factory),
|
| 53 |
+
# max_shifts_per_employee(constraint_factory), # Optional extension - disabled by default
|
| 54 |
# Soft constraints
|
| 55 |
undesired_day_for_employee(constraint_factory),
|
| 56 |
desired_day_for_employee(constraint_factory),
|
|
|
|
| 125 |
Joiners.equal(lambda shift: shift.employee, lambda employee: employee),
|
| 126 |
)
|
| 127 |
.flatten_last(lambda employee: employee.unavailable_dates)
|
| 128 |
+
.filter(lambda shift, unavailable_date: is_overlapping_with_date(shift, unavailable_date))
|
| 129 |
.penalize(
|
| 130 |
HardSoftDecimalScore.ONE_HARD,
|
| 131 |
+
lambda shift, unavailable_date: int((min(shift.end, datetime.combine(unavailable_date, time(23, 59, 59))) - max(shift.start, datetime.combine(unavailable_date, time(0, 0, 0)))).total_seconds() / 60),
|
| 132 |
)
|
| 133 |
.as_constraint("Unavailable employee")
|
| 134 |
)
|
|
|
|
| 168 |
.filter(lambda shift, undesired_date: shift.is_overlapping_with_date(undesired_date))
|
| 169 |
.penalize(
|
| 170 |
HardSoftDecimalScore.ONE_SOFT,
|
| 171 |
+
lambda shift, undesired_date: int((min(shift.end, datetime.combine(undesired_date, time(23, 59, 59))) - max(shift.start, datetime.combine(undesired_date, time(0, 0, 0)))).total_seconds() / 60),
|
| 172 |
)
|
| 173 |
.as_constraint("Undesired day for employee")
|
| 174 |
)
|
|
|
|
| 185 |
.filter(lambda shift, desired_date: shift.is_overlapping_with_date(desired_date))
|
| 186 |
.reward(
|
| 187 |
HardSoftDecimalScore.ONE_SOFT,
|
| 188 |
+
lambda shift, desired_date: int((min(shift.end, datetime.combine(desired_date, time(23, 59, 59))) - max(shift.start, datetime.combine(desired_date, time(0, 0, 0)))).total_seconds() / 60),
|
| 189 |
)
|
| 190 |
.as_constraint("Desired day for employee")
|
| 191 |
)
|
src/employee_scheduling/converters.py
CHANGED
|
@@ -1,8 +1,5 @@
|
|
| 1 |
-
from typing import List, Optional, Union
|
| 2 |
from datetime import datetime, date
|
| 3 |
from . import domain
|
| 4 |
-
from .json_serialization import JsonDomainBase
|
| 5 |
-
from pydantic import Field
|
| 6 |
|
| 7 |
|
| 8 |
# Conversion functions from domain to API models
|
|
|
|
|
|
|
| 1 |
from datetime import datetime, date
|
| 2 |
from . import domain
|
|
|
|
|
|
|
| 3 |
|
| 4 |
|
| 5 |
# Conversion functions from domain to API models
|
src/employee_scheduling/demo_data.py
CHANGED
|
@@ -5,7 +5,7 @@ from random import Random
|
|
| 5 |
from typing import Generator
|
| 6 |
from dataclasses import dataclass, field
|
| 7 |
|
| 8 |
-
from .domain import
|
| 9 |
|
| 10 |
|
| 11 |
class DemoData(Enum):
|
|
|
|
| 5 |
from typing import Generator
|
| 6 |
from dataclasses import dataclass, field
|
| 7 |
|
| 8 |
+
from .domain import Employee, EmployeeSchedule, Shift
|
| 9 |
|
| 10 |
|
| 11 |
class DemoData(Enum):
|
src/employee_scheduling/rest_api.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
| 1 |
-
from fastapi import FastAPI
|
| 2 |
from fastapi.staticfiles import StaticFiles
|
| 3 |
from uuid import uuid4
|
| 4 |
from dataclasses import replace
|
|
|
|
| 5 |
|
| 6 |
from .domain import EmployeeSchedule, EmployeeScheduleModel
|
| 7 |
from .converters import (
|
|
@@ -9,6 +10,7 @@ from .converters import (
|
|
| 9 |
)
|
| 10 |
from .demo_data import DemoData, generate_demo_data
|
| 11 |
from .solver import solver_manager, solution_manager
|
|
|
|
| 12 |
|
| 13 |
app = FastAPI(docs_url='/q/swagger-ui')
|
| 14 |
data_sets: dict[str, EmployeeSchedule] = {}
|
|
@@ -48,9 +50,79 @@ async def solve_timetable(schedule_model: EmployeeScheduleModel) -> str:
|
|
| 48 |
return job_id
|
| 49 |
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
@app.delete("/schedules/{problem_id}")
|
| 52 |
-
async def stop_solving(problem_id: str) ->
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
|
| 56 |
app.mount("/", StaticFiles(directory="static", html=True), name="static")
|
|
|
|
| 1 |
+
from fastapi import FastAPI, Request
|
| 2 |
from fastapi.staticfiles import StaticFiles
|
| 3 |
from uuid import uuid4
|
| 4 |
from dataclasses import replace
|
| 5 |
+
from typing import Dict, List
|
| 6 |
|
| 7 |
from .domain import EmployeeSchedule, EmployeeScheduleModel
|
| 8 |
from .converters import (
|
|
|
|
| 10 |
)
|
| 11 |
from .demo_data import DemoData, generate_demo_data
|
| 12 |
from .solver import solver_manager, solution_manager
|
| 13 |
+
from .score_analysis import ConstraintAnalysisDTO, MatchAnalysisDTO
|
| 14 |
|
| 15 |
app = FastAPI(docs_url='/q/swagger-ui')
|
| 16 |
data_sets: dict[str, EmployeeSchedule] = {}
|
|
|
|
| 50 |
return job_id
|
| 51 |
|
| 52 |
|
| 53 |
+
@app.get("/schedules")
|
| 54 |
+
async def list_schedules() -> List[str]:
|
| 55 |
+
"""List all job IDs of submitted schedules."""
|
| 56 |
+
return list(data_sets.keys())
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@app.get("/schedules/{problem_id}/status")
|
| 60 |
+
async def get_status(problem_id: str) -> Dict:
|
| 61 |
+
"""Get the schedule status and score for a given job ID."""
|
| 62 |
+
if problem_id not in data_sets:
|
| 63 |
+
raise ValueError(f"No schedule found with ID {problem_id}")
|
| 64 |
+
|
| 65 |
+
schedule = data_sets[problem_id]
|
| 66 |
+
solver_status = solver_manager.get_solver_status(problem_id)
|
| 67 |
+
|
| 68 |
+
return {
|
| 69 |
+
"score": {
|
| 70 |
+
"hardScore": schedule.score.hard_score if schedule.score else 0,
|
| 71 |
+
"softScore": schedule.score.soft_score if schedule.score else 0,
|
| 72 |
+
},
|
| 73 |
+
"solverStatus": solver_status.name,
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
@app.delete("/schedules/{problem_id}")
|
| 78 |
+
async def stop_solving(problem_id: str) -> EmployeeScheduleModel:
|
| 79 |
+
"""Terminate solving for a given job ID."""
|
| 80 |
+
if problem_id not in data_sets:
|
| 81 |
+
raise ValueError(f"No schedule found with ID {problem_id}")
|
| 82 |
+
|
| 83 |
+
try:
|
| 84 |
+
solver_manager.terminate_early(problem_id)
|
| 85 |
+
except Exception as e:
|
| 86 |
+
print(f"Warning: terminate_early failed for {problem_id}: {e}")
|
| 87 |
+
|
| 88 |
+
return await get_timetable(problem_id)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@app.put("/schedules/analyze")
|
| 92 |
+
async def analyze_schedule(request: Request) -> Dict:
|
| 93 |
+
"""Submit a schedule to analyze its score."""
|
| 94 |
+
json_data = await request.json()
|
| 95 |
+
|
| 96 |
+
# Parse the incoming JSON using Pydantic models
|
| 97 |
+
schedule_model = EmployeeScheduleModel.model_validate(json_data)
|
| 98 |
+
|
| 99 |
+
# Convert to domain model for analysis
|
| 100 |
+
domain_schedule = model_to_schedule(schedule_model)
|
| 101 |
+
|
| 102 |
+
analysis = solution_manager.analyze(domain_schedule)
|
| 103 |
+
|
| 104 |
+
# Convert to proper DTOs for correct serialization
|
| 105 |
+
# Use str() for scores and justification to avoid Java object serialization issues
|
| 106 |
+
constraints = []
|
| 107 |
+
for constraint in getattr(analysis, 'constraint_analyses', []) or []:
|
| 108 |
+
matches = [
|
| 109 |
+
MatchAnalysisDTO(
|
| 110 |
+
name=str(getattr(getattr(match, 'constraint_ref', None), 'constraint_name', "")),
|
| 111 |
+
score=str(getattr(match, 'score', "0hard/0soft")),
|
| 112 |
+
justification=str(getattr(match, 'justification', "")),
|
| 113 |
+
)
|
| 114 |
+
for match in getattr(constraint, 'matches', []) or []
|
| 115 |
+
]
|
| 116 |
+
|
| 117 |
+
constraint_dto = ConstraintAnalysisDTO(
|
| 118 |
+
name=str(getattr(constraint, 'constraint_name', "")),
|
| 119 |
+
weight=str(getattr(constraint, 'weight', "0hard/0soft")),
|
| 120 |
+
score=str(getattr(constraint, 'score', "0hard/0soft")),
|
| 121 |
+
matches=matches,
|
| 122 |
+
)
|
| 123 |
+
constraints.append(constraint_dto)
|
| 124 |
+
|
| 125 |
+
return {"constraints": [constraint.model_dump() for constraint in constraints]}
|
| 126 |
|
| 127 |
|
| 128 |
app.mount("/", StaticFiles(directory="static", html=True), name="static")
|
src/employee_scheduling/score_analysis.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import List
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class MatchAnalysisDTO(BaseModel):
|
| 6 |
+
name: str
|
| 7 |
+
score: str
|
| 8 |
+
justification: str
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class ConstraintAnalysisDTO(BaseModel):
|
| 12 |
+
name: str
|
| 13 |
+
weight: str
|
| 14 |
+
score: str
|
| 15 |
+
matches: List[MatchAnalysisDTO]
|
static/app.js
CHANGED
|
@@ -440,12 +440,14 @@ function refreshSolvingButtons(solving) {
|
|
| 440 |
if (solving) {
|
| 441 |
$("#solveButton").hide();
|
| 442 |
$("#stopSolvingButton").show();
|
|
|
|
| 443 |
if (autoRefreshIntervalId == null) {
|
| 444 |
autoRefreshIntervalId = setInterval(refreshSchedule, 2000);
|
| 445 |
}
|
| 446 |
} else {
|
| 447 |
$("#solveButton").show();
|
| 448 |
$("#stopSolvingButton").hide();
|
|
|
|
| 449 |
if (autoRefreshIntervalId != null) {
|
| 450 |
clearInterval(autoRefreshIntervalId);
|
| 451 |
autoRefreshIntervalId = null;
|
|
|
|
| 440 |
if (solving) {
|
| 441 |
$("#solveButton").hide();
|
| 442 |
$("#stopSolvingButton").show();
|
| 443 |
+
$("#solvingSpinner").addClass("active");
|
| 444 |
if (autoRefreshIntervalId == null) {
|
| 445 |
autoRefreshIntervalId = setInterval(refreshSchedule, 2000);
|
| 446 |
}
|
| 447 |
} else {
|
| 448 |
$("#solveButton").show();
|
| 449 |
$("#stopSolvingButton").hide();
|
| 450 |
+
$("#solvingSpinner").removeClass("active");
|
| 451 |
if (autoRefreshIntervalId != null) {
|
| 452 |
clearInterval(autoRefreshIntervalId);
|
| 453 |
autoRefreshIntervalId = null;
|
static/index.html
CHANGED
|
@@ -14,6 +14,24 @@
|
|
| 14 |
.vis-time-axis .vis-grid.vis-sunday {
|
| 15 |
background: #D3D7CFFF;
|
| 16 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
</style>
|
| 18 |
<link rel="icon" href="/webjars/solverforge/img/solverforge-favicon.svg" type="image/svg+xml">
|
| 19 |
</head>
|
|
@@ -38,8 +56,12 @@
|
|
| 38 |
<button id="stopSolvingButton" type="button" class="btn btn-danger">
|
| 39 |
<span class="fas fa-stop"></span> Stop solving
|
| 40 |
</button>
|
|
|
|
| 41 |
<span id="unassignedShifts" class="ms-2 align-middle fw-bold"></span>
|
| 42 |
<span id="score" class="score ms-2 align-middle fw-bold">Score: ?</span>
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
<div class="float-end">
|
| 45 |
<ul class="nav nav-pills" role="tablist">
|
|
@@ -119,6 +141,24 @@
|
|
| 119 |
</div>
|
| 120 |
</div>
|
| 121 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
<footer id="solverforge-auto-footer"></footer>
|
| 123 |
|
| 124 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/2.11.8/umd/popper.min.js"></script>
|
|
|
|
| 14 |
.vis-time-axis .vis-grid.vis-sunday {
|
| 15 |
background: #D3D7CFFF;
|
| 16 |
}
|
| 17 |
+
|
| 18 |
+
/* Solving spinner */
|
| 19 |
+
#solvingSpinner {
|
| 20 |
+
display: none;
|
| 21 |
+
width: 1.25rem;
|
| 22 |
+
height: 1.25rem;
|
| 23 |
+
border: 2px solid #10b981;
|
| 24 |
+
border-top-color: transparent;
|
| 25 |
+
border-radius: 50%;
|
| 26 |
+
animation: spin 0.75s linear infinite;
|
| 27 |
+
vertical-align: middle;
|
| 28 |
+
}
|
| 29 |
+
#solvingSpinner.active {
|
| 30 |
+
display: inline-block;
|
| 31 |
+
}
|
| 32 |
+
@keyframes spin {
|
| 33 |
+
to { transform: rotate(360deg); }
|
| 34 |
+
}
|
| 35 |
</style>
|
| 36 |
<link rel="icon" href="/webjars/solverforge/img/solverforge-favicon.svg" type="image/svg+xml">
|
| 37 |
</head>
|
|
|
|
| 56 |
<button id="stopSolvingButton" type="button" class="btn btn-danger">
|
| 57 |
<span class="fas fa-stop"></span> Stop solving
|
| 58 |
</button>
|
| 59 |
+
<span id="solvingSpinner" class="ms-2"></span>
|
| 60 |
<span id="unassignedShifts" class="ms-2 align-middle fw-bold"></span>
|
| 61 |
<span id="score" class="score ms-2 align-middle fw-bold">Score: ?</span>
|
| 62 |
+
<button id="analyzeButton" type="button" class="ms-2 btn btn-secondary">
|
| 63 |
+
<span class="fas fa-question"></span>
|
| 64 |
+
</button>
|
| 65 |
|
| 66 |
<div class="float-end">
|
| 67 |
<ul class="nav nav-pills" role="tablist">
|
|
|
|
| 141 |
</div>
|
| 142 |
</div>
|
| 143 |
</div>
|
| 144 |
+
|
| 145 |
+
<div class="modal fadebd-example-modal-lg" id="scoreAnalysisModal" tabindex="-1" aria-labelledby="scoreAnalysisModalLabel" aria-hidden="true">
|
| 146 |
+
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
| 147 |
+
<div class="modal-content">
|
| 148 |
+
<div class="modal-header">
|
| 149 |
+
<h1 class="modal-title fs-5" id="scoreAnalysisModalLabel">Score analysis <span id="scoreAnalysisScoreLabel"></span></h1>
|
| 150 |
+
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
| 151 |
+
</div>
|
| 152 |
+
<div class="modal-body" id="scoreAnalysisModalContent">
|
| 153 |
+
<!-- Filled in by app.js -->
|
| 154 |
+
</div>
|
| 155 |
+
<div class="modal-footer">
|
| 156 |
+
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Close</button>
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
|
| 162 |
<footer id="solverforge-auto-footer"></footer>
|
| 163 |
|
| 164 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/2.11.8/umd/popper.min.js"></script>
|
static/webjars/timefold/css/timefold-webui.css
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
/* Keep in sync with .navbar height on a large screen. */
|
| 3 |
+
--ts-navbar-height: 109px;
|
| 4 |
+
|
| 5 |
+
--ts-violet-1-rgb: #3E00FF;
|
| 6 |
+
--ts-violet-2-rgb: #3423A6;
|
| 7 |
+
--ts-violet-3-rgb: #2E1760;
|
| 8 |
+
--ts-violet-4-rgb: #200F4F;
|
| 9 |
+
--ts-violet-5-rgb: #000000; /* TODO FIXME */
|
| 10 |
+
--ts-violet-dark-1-rgb: #b6adfd;
|
| 11 |
+
--ts-violet-dark-2-rgb: #c1bbfd;
|
| 12 |
+
--ts-gray-rgb: #666666;
|
| 13 |
+
--ts-white-rgb: #FFFFFF;
|
| 14 |
+
--ts-light-rgb: #F2F2F2;
|
| 15 |
+
--ts-gray-border: #c5c5c5;
|
| 16 |
+
|
| 17 |
+
--tf-light-rgb-transparent: rgb(242,242,242,0.5); /* #F2F2F2 = rgb(242,242,242) */
|
| 18 |
+
--bs-body-bg: var(--ts-light-rgb); /* link to html bg */
|
| 19 |
+
--bs-link-color: var(--ts-violet-1-rgb);
|
| 20 |
+
--bs-link-hover-color: var(--ts-violet-2-rgb);
|
| 21 |
+
|
| 22 |
+
--bs-navbar-color: var(--ts-white-rgb);
|
| 23 |
+
--bs-navbar-hover-color: var(--ts-white-rgb);
|
| 24 |
+
--bs-nav-link-font-size: 18px;
|
| 25 |
+
--bs-nav-link-font-weight: 400;
|
| 26 |
+
--bs-nav-link-color: var(--ts-white-rgb);
|
| 27 |
+
--ts-nav-link-hover-border-color: var(--ts-violet-1-rgb);
|
| 28 |
+
}
|
| 29 |
+
.btn {
|
| 30 |
+
--bs-btn-border-radius: 1.5rem;
|
| 31 |
+
}
|
| 32 |
+
.btn-primary {
|
| 33 |
+
--bs-btn-bg: var(--ts-violet-1-rgb);
|
| 34 |
+
--bs-btn-border-color: var(--ts-violet-1-rgb);
|
| 35 |
+
--bs-btn-hover-bg: var(--ts-violet-2-rgb);
|
| 36 |
+
--bs-btn-hover-border-color: var(--ts-violet-2-rgb);
|
| 37 |
+
--bs-btn-active-bg: var(--ts-violet-2-rgb);
|
| 38 |
+
--bs-btn-active-border-bg: var(--ts-violet-2-rgb);
|
| 39 |
+
--bs-btn-disabled-bg: var(--ts-violet-1-rgb);
|
| 40 |
+
--bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
|
| 41 |
+
}
|
| 42 |
+
.btn-outline-primary {
|
| 43 |
+
--bs-btn-color: var(--ts-violet-1-rgb);
|
| 44 |
+
--bs-btn-border-color: var(--ts-violet-1-rgb);
|
| 45 |
+
--bs-btn-hover-bg: var(--ts-violet-1-rgb);
|
| 46 |
+
--bs-btn-hover-border-color: var(--ts-violet-1-rgb);
|
| 47 |
+
--bs-btn-active-bg: var(--ts-violet-1-rgb);
|
| 48 |
+
--bs-btn-active-border-color: var(--ts-violet-1-rgb);
|
| 49 |
+
--bs-btn-disabled-color: var(--ts-violet-1-rgb);
|
| 50 |
+
--bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
|
| 51 |
+
}
|
| 52 |
+
.navbar-dark {
|
| 53 |
+
--bs-link-color: var(--ts-violet-dark-1-rgb);
|
| 54 |
+
--bs-link-hover-color: var(--ts-violet-dark-2-rgb);
|
| 55 |
+
--bs-navbar-color: var(--ts-white-rgb);
|
| 56 |
+
--bs-navbar-hover-color: var(--ts-white-rgb);
|
| 57 |
+
}
|
| 58 |
+
.nav-pills {
|
| 59 |
+
--bs-nav-pills-link-active-bg: var(--ts-violet-1-rgb);
|
| 60 |
+
}
|
static/webjars/timefold/img/timefold-favicon.svg
ADDED
|
|
static/webjars/timefold/img/timefold-logo-horizontal-negative.svg
ADDED
|
|
static/webjars/timefold/img/timefold-logo-horizontal-positive.svg
ADDED
|
|
static/webjars/timefold/img/timefold-logo-stacked-positive.svg
ADDED
|
|
static/webjars/timefold/js/timefold-webui.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
function replaceTimefoldAutoHeaderFooter() {
|
| 2 |
+
const timefoldHeader = $("header#timefold-auto-header");
|
| 3 |
+
if (timefoldHeader != null) {
|
| 4 |
+
timefoldHeader.addClass("bg-black")
|
| 5 |
+
timefoldHeader.append(
|
| 6 |
+
$(`<div class="container-fluid">
|
| 7 |
+
<nav class="navbar sticky-top navbar-expand-lg navbar-dark shadow mb-3">
|
| 8 |
+
<a class="navbar-brand" href="https://timefold.ai">
|
| 9 |
+
<img src="/timefold/img/timefold-logo-horizontal-negative.svg" alt="Timefold logo" width="200">
|
| 10 |
+
</a>
|
| 11 |
+
</nav>
|
| 12 |
+
</div>`));
|
| 13 |
+
}
|
| 14 |
+
const timefoldFooter = $("footer#timefold-auto-footer");
|
| 15 |
+
if (timefoldFooter != null) {
|
| 16 |
+
timefoldFooter.append(
|
| 17 |
+
$(`<footer class="bg-black text-white-50">
|
| 18 |
+
<div class="container">
|
| 19 |
+
<div class="hstack gap-3 p-4">
|
| 20 |
+
<div class="ms-auto"><a class="text-white" href="https://timefold.ai">Timefold</a></div>
|
| 21 |
+
<div class="vr"></div>
|
| 22 |
+
<div><a class="text-white" href="https://timefold.ai/docs">Documentation</a></div>
|
| 23 |
+
<div class="vr"></div>
|
| 24 |
+
<div><a class="text-white" href="https://github.com/TimefoldAI/timefold-solver-python">Code</a></div>
|
| 25 |
+
<div class="vr"></div>
|
| 26 |
+
<div class="me-auto"><a class="text-white" href="https://timefold.ai/product/support/">Support</a></div>
|
| 27 |
+
</div>
|
| 28 |
+
</div>
|
| 29 |
+
<div id="applicationInfo" class="container text-center"></div>
|
| 30 |
+
</footer>`));
|
| 31 |
+
|
| 32 |
+
applicationInfo();
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function showSimpleError(title) {
|
| 38 |
+
const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
|
| 39 |
+
.append($(`<div class="toast-header bg-danger">
|
| 40 |
+
<strong class="me-auto text-dark">Error</strong>
|
| 41 |
+
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
| 42 |
+
</div>`))
|
| 43 |
+
.append($(`<div class="toast-body"/>`)
|
| 44 |
+
.append($(`<p/>`).text(title))
|
| 45 |
+
);
|
| 46 |
+
$("#notificationPanel").append(notification);
|
| 47 |
+
notification.toast({delay: 30000});
|
| 48 |
+
notification.toast('show');
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
function showError(title, xhr) {
|
| 52 |
+
var serverErrorMessage = !xhr.responseJSON ? `${xhr.status}: ${xhr.statusText}` : xhr.responseJSON.message;
|
| 53 |
+
var serverErrorCode = !xhr.responseJSON ? `unknown` : xhr.responseJSON.code;
|
| 54 |
+
var serverErrorId = !xhr.responseJSON ? `----` : xhr.responseJSON.id;
|
| 55 |
+
var serverErrorDetails = !xhr.responseJSON ? `no details provided` : xhr.responseJSON.details;
|
| 56 |
+
|
| 57 |
+
if (xhr.responseJSON && !serverErrorMessage) {
|
| 58 |
+
serverErrorMessage = JSON.stringify(xhr.responseJSON);
|
| 59 |
+
serverErrorCode = xhr.statusText + '(' + xhr.status + ')';
|
| 60 |
+
serverErrorId = `----`;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
console.error(title + "\n" + serverErrorMessage + " : " + serverErrorDetails);
|
| 64 |
+
const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
|
| 65 |
+
.append($(`<div class="toast-header bg-danger">
|
| 66 |
+
<strong class="me-auto text-dark">Error</strong>
|
| 67 |
+
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
| 68 |
+
</div>`))
|
| 69 |
+
.append($(`<div class="toast-body"/>`)
|
| 70 |
+
.append($(`<p/>`).text(title))
|
| 71 |
+
.append($(`<pre/>`)
|
| 72 |
+
.append($(`<code/>`).text(serverErrorMessage + "\n\nCode: " + serverErrorCode + "\nError id: " + serverErrorId))
|
| 73 |
+
)
|
| 74 |
+
);
|
| 75 |
+
$("#notificationPanel").append(notification);
|
| 76 |
+
notification.toast({delay: 30000});
|
| 77 |
+
notification.toast('show');
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// ****************************************************************************
|
| 81 |
+
// Application info
|
| 82 |
+
// ****************************************************************************
|
| 83 |
+
|
| 84 |
+
function applicationInfo() {
|
| 85 |
+
$.getJSON("info", function (info) {
|
| 86 |
+
$("#applicationInfo").append("<small>" + info.application + " (version: " + info.version + ", built at: " + info.built + ")</small>");
|
| 87 |
+
}).fail(function (xhr, ajaxOptions, thrownError) {
|
| 88 |
+
console.warn("Unable to collect application information");
|
| 89 |
+
});
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
// ****************************************************************************
|
| 93 |
+
// TangoColorFactory
|
| 94 |
+
// ****************************************************************************
|
| 95 |
+
|
| 96 |
+
const SEQUENCE_1 = [0x8AE234, 0xFCE94F, 0x729FCF, 0xE9B96E, 0xAD7FA8];
|
| 97 |
+
const SEQUENCE_2 = [0x73D216, 0xEDD400, 0x3465A4, 0xC17D11, 0x75507B];
|
| 98 |
+
|
| 99 |
+
var colorMap = new Map;
|
| 100 |
+
var nextColorCount = 0;
|
| 101 |
+
|
| 102 |
+
function pickColor(object) {
|
| 103 |
+
let color = colorMap[object];
|
| 104 |
+
if (color !== undefined) {
|
| 105 |
+
return color;
|
| 106 |
+
}
|
| 107 |
+
color = nextColor();
|
| 108 |
+
colorMap[object] = color;
|
| 109 |
+
return color;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
function nextColor() {
|
| 113 |
+
let color;
|
| 114 |
+
let colorIndex = nextColorCount % SEQUENCE_1.length;
|
| 115 |
+
let shadeIndex = Math.floor(nextColorCount / SEQUENCE_1.length);
|
| 116 |
+
if (shadeIndex === 0) {
|
| 117 |
+
color = SEQUENCE_1[colorIndex];
|
| 118 |
+
} else if (shadeIndex === 1) {
|
| 119 |
+
color = SEQUENCE_2[colorIndex];
|
| 120 |
+
} else {
|
| 121 |
+
shadeIndex -= 3;
|
| 122 |
+
let floorColor = SEQUENCE_2[colorIndex];
|
| 123 |
+
let ceilColor = SEQUENCE_1[colorIndex];
|
| 124 |
+
let base = Math.floor((shadeIndex / 2) + 1);
|
| 125 |
+
let divisor = 2;
|
| 126 |
+
while (base >= divisor) {
|
| 127 |
+
divisor *= 2;
|
| 128 |
+
}
|
| 129 |
+
base = (base * 2) - divisor + 1;
|
| 130 |
+
let shadePercentage = base / divisor;
|
| 131 |
+
color = buildPercentageColor(floorColor, ceilColor, shadePercentage);
|
| 132 |
+
}
|
| 133 |
+
nextColorCount++;
|
| 134 |
+
return "#" + color.toString(16);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
function buildPercentageColor(floorColor, ceilColor, shadePercentage) {
|
| 138 |
+
let red = (floorColor & 0xFF0000) + Math.floor(shadePercentage * ((ceilColor & 0xFF0000) - (floorColor & 0xFF0000))) & 0xFF0000;
|
| 139 |
+
let green = (floorColor & 0x00FF00) + Math.floor(shadePercentage * ((ceilColor & 0x00FF00) - (floorColor & 0x00FF00))) & 0x00FF00;
|
| 140 |
+
let blue = (floorColor & 0x0000FF) + Math.floor(shadePercentage * ((ceilColor & 0x0000FF) - (floorColor & 0x0000FF))) & 0x0000FF;
|
| 141 |
+
return red | green | blue;
|
| 142 |
+
}
|
tests/test_constraints.py
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from solverforge_legacy.solver.test import ConstraintVerifier
|
| 2 |
|
| 3 |
-
from employee_scheduling.domain import
|
| 4 |
-
from employee_scheduling.constraints import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
from datetime import date, datetime, time, timedelta
|
|
|
|
| 7 |
|
|
|
|
| 8 |
DAY_1 = date(2021, 2, 1)
|
|
|
|
| 9 |
DAY_3 = date(2021, 2, 3)
|
| 10 |
DAY_START_TIME = datetime.combine(DAY_1, time(9, 0))
|
| 11 |
DAY_END_TIME = datetime.combine(DAY_1, time(17, 0))
|
|
@@ -17,708 +35,603 @@ constraint_verifier = ConstraintVerifier.build(
|
|
| 17 |
)
|
| 18 |
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
)
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
constraint_verifier.verify_that(no_overlapping_shifts)
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
.
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
.
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
)
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
constraint_verifier.verify_that(at_least_10_hours_between_two_shifts)
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
location="Location 2",
|
| 386 |
-
required_skill="Skill",
|
| 387 |
-
employee=employee1,
|
| 388 |
-
),
|
| 389 |
-
)
|
| 390 |
-
.penalizes(0)
|
| 391 |
-
)
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
def test_unavailable_employee():
|
| 395 |
-
employee1 = Employee(name="Amy", unavailable_dates={DAY_1, DAY_3})
|
| 396 |
-
employee2 = Employee(name="Beth")
|
| 397 |
-
|
| 398 |
-
(
|
| 399 |
-
constraint_verifier.verify_that(unavailable_employee)
|
| 400 |
-
.given(
|
| 401 |
-
employee1,
|
| 402 |
-
employee2,
|
| 403 |
-
Shift(
|
| 404 |
-
id="1",
|
| 405 |
-
start=DAY_START_TIME,
|
| 406 |
-
end=DAY_END_TIME,
|
| 407 |
-
location="Location",
|
| 408 |
-
required_skill="Skill",
|
| 409 |
-
employee=employee1,
|
| 410 |
-
),
|
| 411 |
-
)
|
| 412 |
-
.penalizes_by(timedelta(hours=8) // timedelta(minutes=1))
|
| 413 |
-
)
|
| 414 |
-
|
| 415 |
-
(
|
| 416 |
-
constraint_verifier.verify_that(unavailable_employee)
|
| 417 |
-
.given(
|
| 418 |
-
employee1,
|
| 419 |
-
employee2,
|
| 420 |
-
Shift(
|
| 421 |
-
id="1",
|
| 422 |
-
start=DAY_START_TIME - timedelta(days=1),
|
| 423 |
-
end=DAY_END_TIME,
|
| 424 |
-
location="Location",
|
| 425 |
-
required_skill="Skill",
|
| 426 |
-
employee=employee1,
|
| 427 |
-
),
|
| 428 |
-
)
|
| 429 |
-
.penalizes_by(timedelta(hours=17) // timedelta(minutes=1))
|
| 430 |
-
)
|
| 431 |
-
|
| 432 |
-
(
|
| 433 |
-
constraint_verifier.verify_that(unavailable_employee)
|
| 434 |
-
.given(
|
| 435 |
-
employee1,
|
| 436 |
-
employee2,
|
| 437 |
-
Shift(
|
| 438 |
-
id="1",
|
| 439 |
-
start=DAY_START_TIME + timedelta(days=1),
|
| 440 |
-
end=DAY_END_TIME + timedelta(days=1),
|
| 441 |
-
location="Location",
|
| 442 |
-
required_skill="Skill",
|
| 443 |
-
employee=employee1,
|
| 444 |
-
),
|
| 445 |
-
)
|
| 446 |
-
.penalizes(0)
|
| 447 |
-
)
|
| 448 |
-
|
| 449 |
-
(
|
| 450 |
-
constraint_verifier.verify_that(unavailable_employee)
|
| 451 |
-
.given(
|
| 452 |
-
employee1,
|
| 453 |
-
employee2,
|
| 454 |
-
Shift(
|
| 455 |
-
id="1",
|
| 456 |
-
start=DAY_START_TIME,
|
| 457 |
-
end=DAY_END_TIME,
|
| 458 |
-
location="Location",
|
| 459 |
-
required_skill="Skill",
|
| 460 |
-
employee=employee2,
|
| 461 |
-
),
|
| 462 |
-
)
|
| 463 |
-
.penalizes(0)
|
| 464 |
-
)
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
def test_undesired_day_for_employee():
|
| 468 |
-
employee1 = Employee(name="Amy", undesired_dates={DAY_1, DAY_3})
|
| 469 |
-
employee2 = Employee(name="Beth")
|
| 470 |
-
|
| 471 |
-
(
|
| 472 |
-
constraint_verifier.verify_that(undesired_day_for_employee)
|
| 473 |
-
.given(
|
| 474 |
-
employee1,
|
| 475 |
-
employee2,
|
| 476 |
-
Shift(
|
| 477 |
-
id="1",
|
| 478 |
-
start=DAY_START_TIME,
|
| 479 |
-
end=DAY_END_TIME,
|
| 480 |
-
location="Location",
|
| 481 |
-
required_skill="Skill",
|
| 482 |
-
employee=employee1,
|
| 483 |
-
),
|
| 484 |
-
)
|
| 485 |
-
.penalizes_by(timedelta(hours=8) // timedelta(minutes=1))
|
| 486 |
-
)
|
| 487 |
-
|
| 488 |
-
(
|
| 489 |
-
constraint_verifier.verify_that(undesired_day_for_employee)
|
| 490 |
-
.given(
|
| 491 |
-
employee1,
|
| 492 |
-
employee2,
|
| 493 |
-
Shift(
|
| 494 |
-
id="1",
|
| 495 |
-
start=DAY_START_TIME - timedelta(days=1),
|
| 496 |
-
end=DAY_END_TIME,
|
| 497 |
-
location="Location",
|
| 498 |
-
required_skill="Skill",
|
| 499 |
-
employee=employee1,
|
| 500 |
-
),
|
| 501 |
-
)
|
| 502 |
-
.penalizes_by(timedelta(hours=17) // timedelta(minutes=1))
|
| 503 |
-
)
|
| 504 |
-
|
| 505 |
-
(
|
| 506 |
-
constraint_verifier.verify_that(undesired_day_for_employee)
|
| 507 |
-
.given(
|
| 508 |
-
employee1,
|
| 509 |
-
employee2,
|
| 510 |
-
Shift(
|
| 511 |
-
id="1",
|
| 512 |
-
start=DAY_START_TIME + timedelta(days=1),
|
| 513 |
-
end=DAY_END_TIME + timedelta(days=1),
|
| 514 |
-
location="Location",
|
| 515 |
-
required_skill="Skill",
|
| 516 |
-
employee=employee1,
|
| 517 |
-
),
|
| 518 |
-
)
|
| 519 |
-
.penalizes(0)
|
| 520 |
-
)
|
| 521 |
-
|
| 522 |
-
(
|
| 523 |
-
constraint_verifier.verify_that(undesired_day_for_employee)
|
| 524 |
-
.given(
|
| 525 |
-
employee1,
|
| 526 |
-
employee2,
|
| 527 |
-
Shift(
|
| 528 |
-
id="1",
|
| 529 |
-
start=DAY_START_TIME,
|
| 530 |
-
end=DAY_END_TIME,
|
| 531 |
-
location="Location",
|
| 532 |
-
required_skill="Skill",
|
| 533 |
-
employee=employee2,
|
| 534 |
-
),
|
| 535 |
-
)
|
| 536 |
-
.penalizes(0)
|
| 537 |
-
)
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
def test_desired_day_for_employee():
|
| 541 |
-
employee1 = Employee(name="Amy", desired_dates={DAY_1, DAY_3})
|
| 542 |
-
employee2 = Employee(name="Beth")
|
| 543 |
-
|
| 544 |
-
(
|
| 545 |
-
constraint_verifier.verify_that(desired_day_for_employee)
|
| 546 |
-
.given(
|
| 547 |
-
employee1,
|
| 548 |
-
employee2,
|
| 549 |
-
Shift(
|
| 550 |
-
id="1",
|
| 551 |
-
start=DAY_START_TIME,
|
| 552 |
-
end=DAY_END_TIME,
|
| 553 |
-
location="Location",
|
| 554 |
-
required_skill="Skill",
|
| 555 |
-
employee=employee1,
|
| 556 |
-
),
|
| 557 |
-
)
|
| 558 |
-
.rewards_with(timedelta(hours=8) // timedelta(minutes=1))
|
| 559 |
-
)
|
| 560 |
-
|
| 561 |
-
(
|
| 562 |
-
constraint_verifier.verify_that(desired_day_for_employee)
|
| 563 |
-
.given(
|
| 564 |
-
employee1,
|
| 565 |
-
employee2,
|
| 566 |
-
Shift(
|
| 567 |
-
id="1",
|
| 568 |
-
start=DAY_START_TIME - timedelta(days=1),
|
| 569 |
-
end=DAY_END_TIME,
|
| 570 |
-
location="Location",
|
| 571 |
-
required_skill="Skill",
|
| 572 |
-
employee=employee1,
|
| 573 |
-
),
|
| 574 |
-
)
|
| 575 |
-
.rewards_with(timedelta(hours=17) // timedelta(minutes=1))
|
| 576 |
-
)
|
| 577 |
-
|
| 578 |
-
(
|
| 579 |
-
constraint_verifier.verify_that(desired_day_for_employee)
|
| 580 |
-
.given(
|
| 581 |
-
employee1,
|
| 582 |
-
employee2,
|
| 583 |
-
Shift(
|
| 584 |
-
id="1",
|
| 585 |
-
start=DAY_START_TIME + timedelta(days=1),
|
| 586 |
-
end=DAY_END_TIME + timedelta(days=1),
|
| 587 |
-
location="Location",
|
| 588 |
-
required_skill="Skill",
|
| 589 |
-
employee=employee1,
|
| 590 |
-
),
|
| 591 |
-
)
|
| 592 |
-
.rewards(0)
|
| 593 |
-
)
|
| 594 |
-
|
| 595 |
-
(
|
| 596 |
-
constraint_verifier.verify_that(desired_day_for_employee)
|
| 597 |
-
.given(
|
| 598 |
-
employee1,
|
| 599 |
-
employee2,
|
| 600 |
-
Shift(
|
| 601 |
-
id="1",
|
| 602 |
-
start=DAY_START_TIME,
|
| 603 |
-
end=DAY_END_TIME,
|
| 604 |
-
location="Location",
|
| 605 |
-
required_skill="Skill",
|
| 606 |
-
employee=employee2,
|
| 607 |
-
),
|
| 608 |
-
)
|
| 609 |
-
.rewards(0)
|
| 610 |
-
)
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
def test_max_shifts_per_employee():
|
| 614 |
-
employee = Employee(name="Amy")
|
| 615 |
-
|
| 616 |
-
# 12 shifts: no violation (at the limit)
|
| 617 |
-
shifts_12 = [
|
| 618 |
-
Shift(
|
| 619 |
-
id=str(i),
|
| 620 |
-
start=DAY_START_TIME + timedelta(days=i),
|
| 621 |
-
end=DAY_END_TIME + timedelta(days=i),
|
| 622 |
location="Location",
|
| 623 |
required_skill="Skill",
|
| 624 |
employee=employee,
|
| 625 |
)
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
start=DAY_START_TIME + timedelta(days=i),
|
| 639 |
-
end=DAY_END_TIME + timedelta(days=i),
|
| 640 |
location="Location",
|
| 641 |
required_skill="Skill",
|
| 642 |
employee=employee,
|
| 643 |
)
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
start=DAY_START_TIME + timedelta(days=i),
|
| 657 |
-
end=DAY_END_TIME + timedelta(days=i),
|
| 658 |
location="Location",
|
| 659 |
required_skill="Skill",
|
| 660 |
employee=employee,
|
| 661 |
)
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
.given(
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
)
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Constraint tests for the employee scheduling quickstart.
|
| 3 |
+
|
| 4 |
+
Each constraint is tested with both penalizing and non-penalizing scenarios.
|
| 5 |
+
"""
|
| 6 |
from solverforge_legacy.solver.test import ConstraintVerifier
|
| 7 |
|
| 8 |
+
from employee_scheduling.domain import Employee, Shift, EmployeeSchedule
|
| 9 |
+
from employee_scheduling.constraints import (
|
| 10 |
+
define_constraints,
|
| 11 |
+
required_skill,
|
| 12 |
+
no_overlapping_shifts,
|
| 13 |
+
at_least_10_hours_between_two_shifts,
|
| 14 |
+
one_shift_per_day,
|
| 15 |
+
unavailable_employee,
|
| 16 |
+
undesired_day_for_employee,
|
| 17 |
+
desired_day_for_employee,
|
| 18 |
+
balance_employee_shift_assignments,
|
| 19 |
+
)
|
| 20 |
|
| 21 |
from datetime import date, datetime, time, timedelta
|
| 22 |
+
import pytest
|
| 23 |
|
| 24 |
+
# Test constants
|
| 25 |
DAY_1 = date(2021, 2, 1)
|
| 26 |
+
DAY_2 = date(2021, 2, 2)
|
| 27 |
DAY_3 = date(2021, 2, 3)
|
| 28 |
DAY_START_TIME = datetime.combine(DAY_1, time(9, 0))
|
| 29 |
DAY_END_TIME = datetime.combine(DAY_1, time(17, 0))
|
|
|
|
| 35 |
)
|
| 36 |
|
| 37 |
|
| 38 |
+
# ========================================
|
| 39 |
+
# Required Skill Tests
|
| 40 |
+
# ========================================
|
| 41 |
+
|
| 42 |
+
class TestRequiredSkill:
|
| 43 |
+
"""Tests for the required_skill constraint."""
|
| 44 |
+
|
| 45 |
+
def test_penalized_when_employee_lacks_skill(self):
|
| 46 |
+
"""Employee without required skill should be penalized."""
|
| 47 |
+
employee = Employee(name="Amy") # No skills
|
| 48 |
+
shift = Shift(
|
| 49 |
+
id="1",
|
| 50 |
+
start=DAY_START_TIME,
|
| 51 |
+
end=DAY_END_TIME,
|
| 52 |
+
location="Location",
|
| 53 |
+
required_skill="Driving",
|
| 54 |
+
employee=employee,
|
| 55 |
+
)
|
| 56 |
+
constraint_verifier.verify_that(required_skill).given(
|
| 57 |
+
employee, shift
|
| 58 |
+
).penalizes(1)
|
| 59 |
+
|
| 60 |
+
def test_not_penalized_when_employee_has_skill(self):
|
| 61 |
+
"""Employee with required skill should not be penalized."""
|
| 62 |
+
employee = Employee(name="Amy", skills={"Driving"})
|
| 63 |
+
shift = Shift(
|
| 64 |
+
id="1",
|
| 65 |
+
start=DAY_START_TIME,
|
| 66 |
+
end=DAY_END_TIME,
|
| 67 |
+
location="Location",
|
| 68 |
+
required_skill="Driving",
|
| 69 |
+
employee=employee,
|
| 70 |
+
)
|
| 71 |
+
constraint_verifier.verify_that(required_skill).given(
|
| 72 |
+
employee, shift
|
| 73 |
+
).penalizes(0)
|
| 74 |
+
|
| 75 |
+
def test_not_penalized_when_employee_has_multiple_skills(self):
|
| 76 |
+
"""Employee with multiple skills including required should not be penalized."""
|
| 77 |
+
employee = Employee(name="Amy", skills={"Driving", "First Aid", "Cooking"})
|
| 78 |
+
shift = Shift(
|
| 79 |
+
id="1",
|
| 80 |
+
start=DAY_START_TIME,
|
| 81 |
+
end=DAY_END_TIME,
|
| 82 |
+
location="Location",
|
| 83 |
+
required_skill="First Aid",
|
| 84 |
+
employee=employee,
|
| 85 |
+
)
|
| 86 |
+
constraint_verifier.verify_that(required_skill).given(
|
| 87 |
+
employee, shift
|
| 88 |
+
).penalizes(0)
|
| 89 |
+
|
| 90 |
+
def test_penalized_when_employee_has_different_skills(self):
|
| 91 |
+
"""Employee with skills but not the required one should be penalized."""
|
| 92 |
+
employee = Employee(name="Amy", skills={"Cooking", "Cleaning"})
|
| 93 |
+
shift = Shift(
|
| 94 |
+
id="1",
|
| 95 |
+
start=DAY_START_TIME,
|
| 96 |
+
end=DAY_END_TIME,
|
| 97 |
+
location="Location",
|
| 98 |
+
required_skill="Driving",
|
| 99 |
+
employee=employee,
|
| 100 |
+
)
|
| 101 |
+
constraint_verifier.verify_that(required_skill).given(
|
| 102 |
+
employee, shift
|
| 103 |
+
).penalizes(1)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# ========================================
|
| 107 |
+
# Overlapping Shifts Tests
|
| 108 |
+
# ========================================
|
| 109 |
+
|
| 110 |
+
class TestNoOverlappingShifts:
|
| 111 |
+
"""Tests for the no_overlapping_shifts constraint."""
|
| 112 |
+
|
| 113 |
+
def test_penalized_when_shifts_fully_overlap(self):
|
| 114 |
+
"""Same employee with fully overlapping shifts should be penalized."""
|
| 115 |
+
employee = Employee(name="Amy")
|
| 116 |
+
shift1 = Shift(
|
| 117 |
+
id="1",
|
| 118 |
+
start=DAY_START_TIME,
|
| 119 |
+
end=DAY_END_TIME,
|
| 120 |
+
location="Location A",
|
| 121 |
+
required_skill="Skill",
|
| 122 |
+
employee=employee,
|
| 123 |
+
)
|
| 124 |
+
shift2 = Shift(
|
| 125 |
+
id="2",
|
| 126 |
+
start=DAY_START_TIME,
|
| 127 |
+
end=DAY_END_TIME,
|
| 128 |
+
location="Location B",
|
| 129 |
+
required_skill="Skill",
|
| 130 |
+
employee=employee,
|
| 131 |
+
)
|
| 132 |
+
# 8 hours overlap = 480 minutes
|
| 133 |
+
constraint_verifier.verify_that(no_overlapping_shifts).given(
|
| 134 |
+
employee, shift1, shift2
|
| 135 |
+
).penalizes_by(480)
|
| 136 |
+
|
| 137 |
+
def test_penalized_when_shifts_partially_overlap(self):
|
| 138 |
+
"""Same employee with partially overlapping shifts should be penalized by overlap duration."""
|
| 139 |
+
employee = Employee(name="Amy")
|
| 140 |
+
shift1 = Shift(
|
| 141 |
+
id="1",
|
| 142 |
+
start=DAY_START_TIME, # 9:00
|
| 143 |
+
end=DAY_END_TIME, # 17:00
|
| 144 |
+
location="Location A",
|
| 145 |
+
required_skill="Skill",
|
| 146 |
+
employee=employee,
|
| 147 |
+
)
|
| 148 |
+
shift2 = Shift(
|
| 149 |
+
id="2",
|
| 150 |
+
start=AFTERNOON_START_TIME, # 13:00
|
| 151 |
+
end=AFTERNOON_END_TIME, # 21:00
|
| 152 |
+
location="Location B",
|
| 153 |
+
required_skill="Skill",
|
| 154 |
+
employee=employee,
|
| 155 |
+
)
|
| 156 |
+
# Overlap from 13:00 to 17:00 = 4 hours = 240 minutes
|
| 157 |
+
constraint_verifier.verify_that(no_overlapping_shifts).given(
|
| 158 |
+
employee, shift1, shift2
|
| 159 |
+
).penalizes_by(240)
|
| 160 |
+
|
| 161 |
+
def test_not_penalized_when_different_employees(self):
|
| 162 |
+
"""Different employees with overlapping shifts should not be penalized."""
|
| 163 |
+
employee1 = Employee(name="Amy")
|
| 164 |
+
employee2 = Employee(name="Beth")
|
| 165 |
+
shift1 = Shift(
|
| 166 |
+
id="1",
|
| 167 |
+
start=DAY_START_TIME,
|
| 168 |
+
end=DAY_END_TIME,
|
| 169 |
+
location="Location A",
|
| 170 |
+
required_skill="Skill",
|
| 171 |
+
employee=employee1,
|
| 172 |
+
)
|
| 173 |
+
shift2 = Shift(
|
| 174 |
+
id="2",
|
| 175 |
+
start=DAY_START_TIME,
|
| 176 |
+
end=DAY_END_TIME,
|
| 177 |
+
location="Location B",
|
| 178 |
+
required_skill="Skill",
|
| 179 |
+
employee=employee2,
|
| 180 |
+
)
|
| 181 |
+
constraint_verifier.verify_that(no_overlapping_shifts).given(
|
| 182 |
+
employee1, employee2, shift1, shift2
|
| 183 |
+
).penalizes(0)
|
| 184 |
+
|
| 185 |
+
def test_not_penalized_when_shifts_dont_overlap(self):
|
| 186 |
+
"""Same employee with non-overlapping shifts should not be penalized."""
|
| 187 |
+
employee = Employee(name="Amy")
|
| 188 |
+
shift1 = Shift(
|
| 189 |
+
id="1",
|
| 190 |
+
start=DAY_START_TIME,
|
| 191 |
+
end=DAY_END_TIME,
|
| 192 |
+
location="Location A",
|
| 193 |
+
required_skill="Skill",
|
| 194 |
+
employee=employee,
|
| 195 |
+
)
|
| 196 |
+
shift2 = Shift(
|
| 197 |
+
id="2",
|
| 198 |
+
start=DAY_START_TIME + timedelta(days=1),
|
| 199 |
+
end=DAY_END_TIME + timedelta(days=1),
|
| 200 |
+
location="Location B",
|
| 201 |
+
required_skill="Skill",
|
| 202 |
+
employee=employee,
|
| 203 |
+
)
|
| 204 |
+
constraint_verifier.verify_that(no_overlapping_shifts).given(
|
| 205 |
+
employee, shift1, shift2
|
| 206 |
+
).penalizes(0)
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
# ========================================
|
| 210 |
+
# One Shift Per Day Tests
|
| 211 |
+
# ========================================
|
| 212 |
+
|
| 213 |
+
class TestOneShiftPerDay:
|
| 214 |
+
"""Tests for the one_shift_per_day constraint."""
|
| 215 |
+
|
| 216 |
+
def test_penalized_when_two_shifts_same_day(self):
|
| 217 |
+
"""Employee with two shifts on same day should be penalized."""
|
| 218 |
+
employee = Employee(name="Amy")
|
| 219 |
+
shift1 = Shift(
|
| 220 |
+
id="1",
|
| 221 |
+
start=datetime.combine(DAY_1, time(6, 0)),
|
| 222 |
+
end=datetime.combine(DAY_1, time(10, 0)),
|
| 223 |
+
location="Location A",
|
| 224 |
+
required_skill="Skill",
|
| 225 |
+
employee=employee,
|
| 226 |
+
)
|
| 227 |
+
shift2 = Shift(
|
| 228 |
+
id="2",
|
| 229 |
+
start=datetime.combine(DAY_1, time(18, 0)),
|
| 230 |
+
end=datetime.combine(DAY_1, time(22, 0)),
|
| 231 |
+
location="Location B",
|
| 232 |
+
required_skill="Skill",
|
| 233 |
+
employee=employee,
|
| 234 |
+
)
|
| 235 |
+
constraint_verifier.verify_that(one_shift_per_day).given(
|
| 236 |
+
employee, shift1, shift2
|
| 237 |
+
).penalizes(1)
|
| 238 |
+
|
| 239 |
+
def test_not_penalized_when_shifts_different_days(self):
|
| 240 |
+
"""Employee with shifts on different days should not be penalized."""
|
| 241 |
+
employee = Employee(name="Amy")
|
| 242 |
+
shift1 = Shift(
|
| 243 |
+
id="1",
|
| 244 |
+
start=DAY_START_TIME,
|
| 245 |
+
end=DAY_END_TIME,
|
| 246 |
+
location="Location A",
|
| 247 |
+
required_skill="Skill",
|
| 248 |
+
employee=employee,
|
| 249 |
+
)
|
| 250 |
+
shift2 = Shift(
|
| 251 |
+
id="2",
|
| 252 |
+
start=DAY_START_TIME + timedelta(days=1),
|
| 253 |
+
end=DAY_END_TIME + timedelta(days=1),
|
| 254 |
+
location="Location B",
|
| 255 |
+
required_skill="Skill",
|
| 256 |
+
employee=employee,
|
| 257 |
+
)
|
| 258 |
+
constraint_verifier.verify_that(one_shift_per_day).given(
|
| 259 |
+
employee, shift1, shift2
|
| 260 |
+
).penalizes(0)
|
| 261 |
+
|
| 262 |
+
def test_not_penalized_when_different_employees_same_day(self):
|
| 263 |
+
"""Different employees with shifts on same day should not be penalized."""
|
| 264 |
+
employee1 = Employee(name="Amy")
|
| 265 |
+
employee2 = Employee(name="Beth")
|
| 266 |
+
shift1 = Shift(
|
| 267 |
+
id="1",
|
| 268 |
+
start=DAY_START_TIME,
|
| 269 |
+
end=DAY_END_TIME,
|
| 270 |
+
location="Location A",
|
| 271 |
+
required_skill="Skill",
|
| 272 |
+
employee=employee1,
|
| 273 |
+
)
|
| 274 |
+
shift2 = Shift(
|
| 275 |
+
id="2",
|
| 276 |
+
start=DAY_START_TIME,
|
| 277 |
+
end=DAY_END_TIME,
|
| 278 |
+
location="Location B",
|
| 279 |
+
required_skill="Skill",
|
| 280 |
+
employee=employee2,
|
| 281 |
+
)
|
| 282 |
+
constraint_verifier.verify_that(one_shift_per_day).given(
|
| 283 |
+
employee1, employee2, shift1, shift2
|
| 284 |
+
).penalizes(0)
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
# ========================================
|
| 288 |
+
# 10 Hours Between Shifts Tests
|
| 289 |
+
# ========================================
|
| 290 |
+
|
| 291 |
+
class TestAtLeast10HoursBetweenShifts:
|
| 292 |
+
"""Tests for the at_least_10_hours_between_two_shifts constraint."""
|
| 293 |
+
|
| 294 |
+
def test_penalized_when_less_than_10_hours_gap(self):
|
| 295 |
+
"""Employee with less than 10 hours between shifts should be penalized."""
|
| 296 |
+
employee = Employee(name="Amy")
|
| 297 |
+
shift1 = Shift(
|
| 298 |
+
id="1",
|
| 299 |
+
start=DAY_START_TIME, # 9:00
|
| 300 |
+
end=DAY_END_TIME, # 17:00
|
| 301 |
+
location="Location A",
|
| 302 |
+
required_skill="Skill",
|
| 303 |
+
employee=employee,
|
| 304 |
+
)
|
| 305 |
+
shift2 = Shift(
|
| 306 |
+
id="2",
|
| 307 |
+
start=AFTERNOON_END_TIME, # 21:00 (4 hours after shift1 ends)
|
| 308 |
+
end=DAY_START_TIME + timedelta(days=1),
|
| 309 |
+
location="Location B",
|
| 310 |
+
required_skill="Skill",
|
| 311 |
+
employee=employee,
|
| 312 |
+
)
|
| 313 |
+
# Gap is 4 hours, need 10 hours, so 6 hours short = 360 minutes penalty
|
| 314 |
+
constraint_verifier.verify_that(at_least_10_hours_between_two_shifts).given(
|
| 315 |
+
employee, shift1, shift2
|
| 316 |
+
).penalizes_by(360)
|
| 317 |
+
|
| 318 |
+
def test_penalized_when_no_gap(self):
|
| 319 |
+
"""Back-to-back shifts should be penalized by full 600 minutes."""
|
| 320 |
+
employee = Employee(name="Amy")
|
| 321 |
+
shift1 = Shift(
|
| 322 |
+
id="1",
|
| 323 |
+
start=DAY_START_TIME,
|
| 324 |
+
end=DAY_END_TIME,
|
| 325 |
+
location="Location A",
|
| 326 |
+
required_skill="Skill",
|
| 327 |
+
employee=employee,
|
| 328 |
+
)
|
| 329 |
+
shift2 = Shift(
|
| 330 |
+
id="2",
|
| 331 |
+
start=DAY_END_TIME, # Starts exactly when shift1 ends
|
| 332 |
+
end=DAY_START_TIME + timedelta(days=1),
|
| 333 |
+
location="Location B",
|
| 334 |
+
required_skill="Skill",
|
| 335 |
+
employee=employee,
|
| 336 |
+
)
|
| 337 |
+
constraint_verifier.verify_that(at_least_10_hours_between_two_shifts).given(
|
| 338 |
+
employee, shift1, shift2
|
| 339 |
+
).penalizes_by(600)
|
| 340 |
+
|
| 341 |
+
def test_not_penalized_when_exactly_10_hours_gap(self):
|
| 342 |
+
"""Employee with exactly 10 hours between shifts should not be penalized."""
|
| 343 |
+
employee = Employee(name="Amy")
|
| 344 |
+
shift1 = Shift(
|
| 345 |
+
id="1",
|
| 346 |
+
start=DAY_START_TIME,
|
| 347 |
+
end=DAY_END_TIME, # 17:00
|
| 348 |
+
location="Location A",
|
| 349 |
+
required_skill="Skill",
|
| 350 |
+
employee=employee,
|
| 351 |
+
)
|
| 352 |
+
shift2 = Shift(
|
| 353 |
+
id="2",
|
| 354 |
+
start=DAY_END_TIME + timedelta(hours=10), # 03:00 next day
|
| 355 |
+
end=DAY_START_TIME + timedelta(days=1),
|
| 356 |
+
location="Location B",
|
| 357 |
+
required_skill="Skill",
|
| 358 |
+
employee=employee,
|
| 359 |
+
)
|
| 360 |
+
constraint_verifier.verify_that(at_least_10_hours_between_two_shifts).given(
|
| 361 |
+
employee, shift1, shift2
|
| 362 |
+
).penalizes(0)
|
| 363 |
+
|
| 364 |
+
def test_not_penalized_when_different_employees(self):
|
| 365 |
+
"""Different employees with back-to-back shifts should not be penalized."""
|
| 366 |
+
employee1 = Employee(name="Amy")
|
| 367 |
+
employee2 = Employee(name="Beth")
|
| 368 |
+
shift1 = Shift(
|
| 369 |
+
id="1",
|
| 370 |
+
start=DAY_START_TIME,
|
| 371 |
+
end=DAY_END_TIME,
|
| 372 |
+
location="Location A",
|
| 373 |
+
required_skill="Skill",
|
| 374 |
+
employee=employee1,
|
| 375 |
+
)
|
| 376 |
+
shift2 = Shift(
|
| 377 |
+
id="2",
|
| 378 |
+
start=AFTERNOON_END_TIME,
|
| 379 |
+
end=DAY_START_TIME + timedelta(days=1),
|
| 380 |
+
location="Location B",
|
| 381 |
+
required_skill="Skill",
|
| 382 |
+
employee=employee2,
|
| 383 |
+
)
|
| 384 |
+
constraint_verifier.verify_that(at_least_10_hours_between_two_shifts).given(
|
| 385 |
+
employee1, employee2, shift1, shift2
|
| 386 |
+
).penalizes(0)
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
# ========================================
|
| 390 |
+
# Unavailable Employee Tests
|
| 391 |
+
# ========================================
|
| 392 |
+
|
| 393 |
+
class TestUnavailableEmployee:
|
| 394 |
+
"""Tests for the unavailable_employee constraint."""
|
| 395 |
+
|
| 396 |
+
def test_penalized_when_shift_on_unavailable_day(self):
|
| 397 |
+
"""Employee scheduled on unavailable day should be penalized by shift duration."""
|
| 398 |
+
employee = Employee(name="Amy", unavailable_dates={DAY_1})
|
| 399 |
+
shift = Shift(
|
| 400 |
+
id="1",
|
| 401 |
+
start=DAY_START_TIME, # DAY_1 at 9:00
|
| 402 |
+
end=DAY_END_TIME, # DAY_1 at 17:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
location="Location",
|
| 404 |
required_skill="Skill",
|
| 405 |
employee=employee,
|
| 406 |
)
|
| 407 |
+
# 8 hours = 480 minutes
|
| 408 |
+
constraint_verifier.verify_that(unavailable_employee).given(
|
| 409 |
+
employee, shift
|
| 410 |
+
).penalizes_by(480)
|
| 411 |
+
|
| 412 |
+
def test_penalized_proportionally_for_multi_day_shift(self):
|
| 413 |
+
"""Multi-day shift crossing unavailable day should be penalized proportionally."""
|
| 414 |
+
employee = Employee(name="Amy", unavailable_dates={DAY_1})
|
| 415 |
+
shift = Shift(
|
| 416 |
+
id="1",
|
| 417 |
+
start=DAY_START_TIME - timedelta(days=1), # Starts day before
|
| 418 |
+
end=DAY_END_TIME, # Ends on DAY_1
|
|
|
|
|
|
|
| 419 |
location="Location",
|
| 420 |
required_skill="Skill",
|
| 421 |
employee=employee,
|
| 422 |
)
|
| 423 |
+
# Overlap with DAY_1 is from midnight to 17:00 = 17 hours = 1020 minutes
|
| 424 |
+
constraint_verifier.verify_that(unavailable_employee).given(
|
| 425 |
+
employee, shift
|
| 426 |
+
).penalizes_by(1020)
|
| 427 |
+
|
| 428 |
+
def test_not_penalized_when_shift_on_different_day(self):
|
| 429 |
+
"""Employee scheduled on available day should not be penalized."""
|
| 430 |
+
employee = Employee(name="Amy", unavailable_dates={DAY_1})
|
| 431 |
+
shift = Shift(
|
| 432 |
+
id="1",
|
| 433 |
+
start=DAY_START_TIME + timedelta(days=1), # DAY_2
|
| 434 |
+
end=DAY_END_TIME + timedelta(days=1),
|
|
|
|
|
|
|
| 435 |
location="Location",
|
| 436 |
required_skill="Skill",
|
| 437 |
employee=employee,
|
| 438 |
)
|
| 439 |
+
constraint_verifier.verify_that(unavailable_employee).given(
|
| 440 |
+
employee, shift
|
| 441 |
+
).penalizes(0)
|
| 442 |
+
|
| 443 |
+
def test_not_penalized_when_different_employee(self):
|
| 444 |
+
"""Different employee (without unavailable dates) should not be penalized."""
|
| 445 |
+
employee1 = Employee(name="Amy", unavailable_dates={DAY_1})
|
| 446 |
+
employee2 = Employee(name="Beth") # No unavailable dates
|
| 447 |
+
shift = Shift(
|
| 448 |
+
id="1",
|
| 449 |
+
start=DAY_START_TIME,
|
| 450 |
+
end=DAY_END_TIME,
|
| 451 |
+
location="Location",
|
| 452 |
+
required_skill="Skill",
|
| 453 |
+
employee=employee2,
|
| 454 |
+
)
|
| 455 |
+
constraint_verifier.verify_that(unavailable_employee).given(
|
| 456 |
+
employee1, employee2, shift
|
| 457 |
+
).penalizes(0)
|
| 458 |
+
|
| 459 |
+
def test_penalized_for_multiple_unavailable_days(self):
|
| 460 |
+
"""Shift crossing multiple unavailable days should be penalized for both."""
|
| 461 |
+
employee = Employee(name="Amy", unavailable_dates={DAY_1, DAY_3})
|
| 462 |
+
shift = Shift(
|
| 463 |
+
id="1",
|
| 464 |
+
start=DAY_START_TIME,
|
| 465 |
+
end=DAY_END_TIME,
|
| 466 |
+
location="Location",
|
| 467 |
+
required_skill="Skill",
|
| 468 |
+
employee=employee,
|
| 469 |
+
)
|
| 470 |
+
# Only DAY_1 overlaps (DAY_3 is 2 days later)
|
| 471 |
+
constraint_verifier.verify_that(unavailable_employee).given(
|
| 472 |
+
employee, shift
|
| 473 |
+
).penalizes_by(480)
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
# ========================================
|
| 477 |
+
# Undesired Day Tests
|
| 478 |
+
# ========================================
|
| 479 |
+
|
| 480 |
+
class TestUndesiredDayForEmployee:
|
| 481 |
+
"""Tests for the undesired_day_for_employee constraint (soft)."""
|
| 482 |
+
|
| 483 |
+
def test_penalized_when_shift_on_undesired_day(self):
|
| 484 |
+
"""Employee scheduled on undesired day should be penalized."""
|
| 485 |
+
employee = Employee(name="Amy", undesired_dates={DAY_1})
|
| 486 |
+
shift = Shift(
|
| 487 |
+
id="1",
|
| 488 |
+
start=DAY_START_TIME,
|
| 489 |
+
end=DAY_END_TIME,
|
| 490 |
+
location="Location",
|
| 491 |
+
required_skill="Skill",
|
| 492 |
+
employee=employee,
|
| 493 |
+
)
|
| 494 |
+
constraint_verifier.verify_that(undesired_day_for_employee).given(
|
| 495 |
+
employee, shift
|
| 496 |
+
).penalizes_by(480)
|
| 497 |
+
|
| 498 |
+
def test_not_penalized_when_shift_on_different_day(self):
|
| 499 |
+
"""Employee scheduled on non-undesired day should not be penalized."""
|
| 500 |
+
employee = Employee(name="Amy", undesired_dates={DAY_1})
|
| 501 |
+
shift = Shift(
|
| 502 |
+
id="1",
|
| 503 |
+
start=DAY_START_TIME + timedelta(days=1),
|
| 504 |
+
end=DAY_END_TIME + timedelta(days=1),
|
| 505 |
+
location="Location",
|
| 506 |
+
required_skill="Skill",
|
| 507 |
+
employee=employee,
|
| 508 |
+
)
|
| 509 |
+
constraint_verifier.verify_that(undesired_day_for_employee).given(
|
| 510 |
+
employee, shift
|
| 511 |
+
).penalizes(0)
|
| 512 |
+
|
| 513 |
+
def test_not_penalized_when_different_employee(self):
|
| 514 |
+
"""Different employee without undesired dates should not be penalized."""
|
| 515 |
+
employee1 = Employee(name="Amy", undesired_dates={DAY_1})
|
| 516 |
+
employee2 = Employee(name="Beth")
|
| 517 |
+
shift = Shift(
|
| 518 |
+
id="1",
|
| 519 |
+
start=DAY_START_TIME,
|
| 520 |
+
end=DAY_END_TIME,
|
| 521 |
+
location="Location",
|
| 522 |
+
required_skill="Skill",
|
| 523 |
+
employee=employee2,
|
| 524 |
+
)
|
| 525 |
+
constraint_verifier.verify_that(undesired_day_for_employee).given(
|
| 526 |
+
employee1, employee2, shift
|
| 527 |
+
).penalizes(0)
|
| 528 |
+
|
| 529 |
+
|
| 530 |
+
# ========================================
|
| 531 |
+
# Desired Day Tests
|
| 532 |
+
# ========================================
|
| 533 |
+
|
| 534 |
+
class TestDesiredDayForEmployee:
|
| 535 |
+
"""Tests for the desired_day_for_employee constraint (soft reward)."""
|
| 536 |
+
|
| 537 |
+
def test_rewarded_when_shift_on_desired_day(self):
|
| 538 |
+
"""Employee scheduled on desired day should be rewarded."""
|
| 539 |
+
employee = Employee(name="Amy", desired_dates={DAY_1})
|
| 540 |
+
shift = Shift(
|
| 541 |
+
id="1",
|
| 542 |
+
start=DAY_START_TIME,
|
| 543 |
+
end=DAY_END_TIME,
|
| 544 |
+
location="Location",
|
| 545 |
+
required_skill="Skill",
|
| 546 |
+
employee=employee,
|
| 547 |
+
)
|
| 548 |
+
constraint_verifier.verify_that(desired_day_for_employee).given(
|
| 549 |
+
employee, shift
|
| 550 |
+
).rewards_with(480)
|
| 551 |
+
|
| 552 |
+
def test_not_rewarded_when_shift_on_different_day(self):
|
| 553 |
+
"""Employee scheduled on non-desired day should not be rewarded."""
|
| 554 |
+
employee = Employee(name="Amy", desired_dates={DAY_1})
|
| 555 |
+
shift = Shift(
|
| 556 |
+
id="1",
|
| 557 |
+
start=DAY_START_TIME + timedelta(days=1),
|
| 558 |
+
end=DAY_END_TIME + timedelta(days=1),
|
| 559 |
+
location="Location",
|
| 560 |
+
required_skill="Skill",
|
| 561 |
+
employee=employee,
|
| 562 |
+
)
|
| 563 |
+
constraint_verifier.verify_that(desired_day_for_employee).given(
|
| 564 |
+
employee, shift
|
| 565 |
+
).rewards(0)
|
| 566 |
+
|
| 567 |
+
def test_not_rewarded_when_different_employee(self):
|
| 568 |
+
"""Different employee without desired dates should not be rewarded."""
|
| 569 |
+
employee1 = Employee(name="Amy", desired_dates={DAY_1})
|
| 570 |
+
employee2 = Employee(name="Beth")
|
| 571 |
+
shift = Shift(
|
| 572 |
+
id="1",
|
| 573 |
+
start=DAY_START_TIME,
|
| 574 |
+
end=DAY_END_TIME,
|
| 575 |
+
location="Location",
|
| 576 |
+
required_skill="Skill",
|
| 577 |
+
employee=employee2,
|
| 578 |
+
)
|
| 579 |
+
constraint_verifier.verify_that(desired_day_for_employee).given(
|
| 580 |
+
employee1, employee2, shift
|
| 581 |
+
).rewards(0)
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
# ========================================
|
| 585 |
+
# Balance Employee Shift Assignments Tests
|
| 586 |
+
# ========================================
|
| 587 |
+
|
| 588 |
+
class TestBalanceEmployeeShiftAssignments:
|
| 589 |
+
"""Tests for the balance_employee_shift_assignments constraint."""
|
| 590 |
+
|
| 591 |
+
def test_no_penalty_when_no_shifts(self):
|
| 592 |
+
"""No shifts assigned should have zero imbalance."""
|
| 593 |
+
employee1 = Employee(name="Amy")
|
| 594 |
+
employee2 = Employee(name="Beth")
|
| 595 |
+
constraint_verifier.verify_that(balance_employee_shift_assignments).given(
|
| 596 |
+
employee1, employee2
|
| 597 |
+
).penalizes_by(0)
|
| 598 |
+
|
| 599 |
+
def test_penalized_when_unbalanced(self):
|
| 600 |
+
"""Only one employee with shifts should be penalized (imbalanced)."""
|
| 601 |
+
employee1 = Employee(name="Amy")
|
| 602 |
+
employee2 = Employee(name="Beth")
|
| 603 |
+
shift = Shift(
|
| 604 |
+
id="1",
|
| 605 |
+
start=DAY_START_TIME,
|
| 606 |
+
end=DAY_END_TIME,
|
| 607 |
+
location="Location",
|
| 608 |
+
required_skill="Skill",
|
| 609 |
+
employee=employee1,
|
| 610 |
+
)
|
| 611 |
+
constraint_verifier.verify_that(balance_employee_shift_assignments).given(
|
| 612 |
+
employee1, employee2, shift
|
| 613 |
+
).penalizes_by_more_than(0)
|
| 614 |
+
|
| 615 |
+
def test_no_penalty_when_balanced(self):
|
| 616 |
+
"""Equal shifts per employee should have zero imbalance."""
|
| 617 |
+
employee1 = Employee(name="Amy")
|
| 618 |
+
employee2 = Employee(name="Beth")
|
| 619 |
+
shift1 = Shift(
|
| 620 |
+
id="1",
|
| 621 |
+
start=DAY_START_TIME,
|
| 622 |
+
end=DAY_END_TIME,
|
| 623 |
+
location="Location",
|
| 624 |
+
required_skill="Skill",
|
| 625 |
+
employee=employee1,
|
| 626 |
+
)
|
| 627 |
+
shift2 = Shift(
|
| 628 |
+
id="2",
|
| 629 |
+
start=DAY_START_TIME,
|
| 630 |
+
end=DAY_END_TIME,
|
| 631 |
+
location="Location",
|
| 632 |
+
required_skill="Skill",
|
| 633 |
+
employee=employee2,
|
| 634 |
+
)
|
| 635 |
+
constraint_verifier.verify_that(balance_employee_shift_assignments).given(
|
| 636 |
+
employee1, employee2, shift1, shift2
|
| 637 |
+
).penalizes_by(0)
|
tests/test_feasible.py
CHANGED
|
@@ -1,12 +1,9 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
SolverConfig,
|
| 4 |
-
ScoreDirectorFactoryConfig,
|
| 5 |
-
TerminationConfig,
|
| 6 |
-
Duration,
|
| 7 |
-
TerminationCompositionStyle,
|
| 8 |
-
)
|
| 9 |
|
|
|
|
|
|
|
|
|
|
| 10 |
from employee_scheduling.rest_api import app
|
| 11 |
from employee_scheduling.domain import EmployeeScheduleModel
|
| 12 |
from employee_scheduling.converters import model_to_schedule
|
|
@@ -14,11 +11,14 @@ from employee_scheduling.converters import model_to_schedule
|
|
| 14 |
from fastapi.testclient import TestClient
|
| 15 |
from time import sleep
|
| 16 |
from pytest import fail
|
|
|
|
| 17 |
|
| 18 |
client = TestClient(app)
|
| 19 |
|
| 20 |
|
|
|
|
| 21 |
def test_feasible():
|
|
|
|
| 22 |
demo_data_response = client.get("/demo-data/SMALL")
|
| 23 |
assert demo_data_response.status_code == 200
|
| 24 |
|
|
@@ -27,16 +27,138 @@ def test_feasible():
|
|
| 27 |
job_id = job_id_response.text[1:-1]
|
| 28 |
|
| 29 |
ATTEMPTS = 1_000
|
|
|
|
| 30 |
for _ in range(ATTEMPTS):
|
| 31 |
sleep(0.1)
|
| 32 |
schedule_response = client.get(f"/schedules/{job_id}")
|
| 33 |
schedule_json = schedule_response.json()
|
| 34 |
schedule_model = EmployeeScheduleModel.model_validate(schedule_json)
|
| 35 |
schedule = model_to_schedule(schedule_model)
|
| 36 |
-
if schedule.score is not None
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
| 40 |
|
| 41 |
client.delete(f"/schedules/{job_id}")
|
| 42 |
-
fail("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Integration tests for the employee scheduling solver.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
+
Tests that the solver can find feasible solutions for demo data
|
| 5 |
+
and that the REST API works correctly.
|
| 6 |
+
"""
|
| 7 |
from employee_scheduling.rest_api import app
|
| 8 |
from employee_scheduling.domain import EmployeeScheduleModel
|
| 9 |
from employee_scheduling.converters import model_to_schedule
|
|
|
|
| 11 |
from fastapi.testclient import TestClient
|
| 12 |
from time import sleep
|
| 13 |
from pytest import fail
|
| 14 |
+
import pytest
|
| 15 |
|
| 16 |
client = TestClient(app)
|
| 17 |
|
| 18 |
|
| 19 |
+
@pytest.mark.timeout(120)
|
| 20 |
def test_feasible():
|
| 21 |
+
"""Test that the solver can find a feasible solution for SMALL demo data."""
|
| 22 |
demo_data_response = client.get("/demo-data/SMALL")
|
| 23 |
assert demo_data_response.status_code == 200
|
| 24 |
|
|
|
|
| 27 |
job_id = job_id_response.text[1:-1]
|
| 28 |
|
| 29 |
ATTEMPTS = 1_000
|
| 30 |
+
best_score = None
|
| 31 |
for _ in range(ATTEMPTS):
|
| 32 |
sleep(0.1)
|
| 33 |
schedule_response = client.get(f"/schedules/{job_id}")
|
| 34 |
schedule_json = schedule_response.json()
|
| 35 |
schedule_model = EmployeeScheduleModel.model_validate(schedule_json)
|
| 36 |
schedule = model_to_schedule(schedule_model)
|
| 37 |
+
if schedule.score is not None:
|
| 38 |
+
best_score = schedule.score
|
| 39 |
+
if schedule.score.is_feasible:
|
| 40 |
+
stop_solving_response = client.delete(f"/schedules/{job_id}")
|
| 41 |
+
assert stop_solving_response.status_code == 200
|
| 42 |
+
return
|
| 43 |
|
| 44 |
client.delete(f"/schedules/{job_id}")
|
| 45 |
+
fail(f"Solution is not feasible after 100 seconds. Best score: {best_score}")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_demo_data_list():
|
| 49 |
+
"""Test that demo data list endpoint returns available datasets."""
|
| 50 |
+
response = client.get("/demo-data")
|
| 51 |
+
assert response.status_code == 200
|
| 52 |
+
data = response.json()
|
| 53 |
+
assert isinstance(data, list)
|
| 54 |
+
assert len(data) > 0
|
| 55 |
+
assert "SMALL" in data
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_demo_data_small_structure():
|
| 59 |
+
"""Test that SMALL demo data has expected structure."""
|
| 60 |
+
response = client.get("/demo-data/SMALL")
|
| 61 |
+
assert response.status_code == 200
|
| 62 |
+
data = response.json()
|
| 63 |
+
|
| 64 |
+
# Check required fields
|
| 65 |
+
assert "employees" in data
|
| 66 |
+
assert "shifts" in data
|
| 67 |
+
|
| 68 |
+
# Validate employees
|
| 69 |
+
assert len(data["employees"]) > 0
|
| 70 |
+
for employee in data["employees"]:
|
| 71 |
+
assert "name" in employee
|
| 72 |
+
|
| 73 |
+
# Validate shifts
|
| 74 |
+
assert len(data["shifts"]) > 0
|
| 75 |
+
for shift in data["shifts"]:
|
| 76 |
+
assert "id" in shift
|
| 77 |
+
assert "start" in shift
|
| 78 |
+
assert "end" in shift
|
| 79 |
+
assert "location" in shift
|
| 80 |
+
assert "requiredSkill" in shift
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def test_solver_start_and_stop():
|
| 84 |
+
"""Test that solver can be started and stopped."""
|
| 85 |
+
demo_data_response = client.get("/demo-data/SMALL")
|
| 86 |
+
assert demo_data_response.status_code == 200
|
| 87 |
+
|
| 88 |
+
# Start solving
|
| 89 |
+
start_response = client.post("/schedules", json=demo_data_response.json())
|
| 90 |
+
assert start_response.status_code == 200
|
| 91 |
+
job_id = start_response.text[1:-1]
|
| 92 |
+
|
| 93 |
+
# Wait a bit
|
| 94 |
+
sleep(0.5)
|
| 95 |
+
|
| 96 |
+
# Check status
|
| 97 |
+
status_response = client.get(f"/schedules/{job_id}")
|
| 98 |
+
assert status_response.status_code == 200
|
| 99 |
+
schedule = status_response.json()
|
| 100 |
+
assert "solverStatus" in schedule
|
| 101 |
+
|
| 102 |
+
# Stop solving
|
| 103 |
+
stop_response = client.delete(f"/schedules/{job_id}")
|
| 104 |
+
assert stop_response.status_code == 200
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_solver_assigns_employees():
|
| 108 |
+
"""Test that solver actually assigns employees to shifts."""
|
| 109 |
+
demo_data_response = client.get("/demo-data/SMALL")
|
| 110 |
+
assert demo_data_response.status_code == 200
|
| 111 |
+
|
| 112 |
+
job_id_response = client.post("/schedules", json=demo_data_response.json())
|
| 113 |
+
assert job_id_response.status_code == 200
|
| 114 |
+
job_id = job_id_response.text[1:-1]
|
| 115 |
+
|
| 116 |
+
# Wait for some solving
|
| 117 |
+
sleep(2)
|
| 118 |
+
|
| 119 |
+
schedule_response = client.get(f"/schedules/{job_id}")
|
| 120 |
+
schedule_json = schedule_response.json()
|
| 121 |
+
|
| 122 |
+
# Check that some shifts have employees assigned
|
| 123 |
+
assigned_shifts = [s for s in schedule_json["shifts"] if s.get("employee") is not None]
|
| 124 |
+
assert len(assigned_shifts) > 0, "Solver should assign some employees to shifts"
|
| 125 |
+
|
| 126 |
+
client.delete(f"/schedules/{job_id}")
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def test_score_analysis():
|
| 130 |
+
"""Test that score analysis endpoint returns constraint analysis."""
|
| 131 |
+
demo_data_response = client.get("/demo-data/SMALL")
|
| 132 |
+
assert demo_data_response.status_code == 200
|
| 133 |
+
|
| 134 |
+
# Start solving and get a scored solution
|
| 135 |
+
job_id_response = client.post("/schedules", json=demo_data_response.json())
|
| 136 |
+
assert job_id_response.status_code == 200
|
| 137 |
+
job_id = job_id_response.text[1:-1]
|
| 138 |
+
|
| 139 |
+
# Wait for solver to produce a score
|
| 140 |
+
sleep(2)
|
| 141 |
+
|
| 142 |
+
schedule_response = client.get(f"/schedules/{job_id}")
|
| 143 |
+
schedule_json = schedule_response.json()
|
| 144 |
+
|
| 145 |
+
# Stop solving
|
| 146 |
+
client.delete(f"/schedules/{job_id}")
|
| 147 |
+
|
| 148 |
+
# Call analyze endpoint
|
| 149 |
+
analyze_response = client.put("/schedules/analyze", json=schedule_json)
|
| 150 |
+
assert analyze_response.status_code == 200
|
| 151 |
+
analysis = analyze_response.json()
|
| 152 |
+
|
| 153 |
+
# Verify structure
|
| 154 |
+
assert "constraints" in analysis
|
| 155 |
+
assert isinstance(analysis["constraints"], list)
|
| 156 |
+
assert len(analysis["constraints"]) > 0
|
| 157 |
+
|
| 158 |
+
# Check constraint structure
|
| 159 |
+
for constraint in analysis["constraints"]:
|
| 160 |
+
assert "name" in constraint
|
| 161 |
+
assert "weight" in constraint
|
| 162 |
+
assert "score" in constraint
|
| 163 |
+
assert "matches" in constraint
|
| 164 |
+
assert isinstance(constraint["matches"], list)
|