Skip to content

Commit 4fb7133

Browse files
committed
update employee scheduling quickstart
1 parent 6b0161c commit 4fb7133

File tree

1 file changed

+112
-53
lines changed

1 file changed

+112
-53
lines changed

content/en/docs/getting-started/employee-scheduling.md

Lines changed: 112 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -73,17 +73,23 @@ Think of it like describing what puzzle pieces you have and what rules they must
7373
cd ./solverforge-quickstarts/fast/employee-scheduling-fast
7474
```
7575

76-
2. **Install dependencies:**
76+
2. **Create and activate virtual environment:**
7777
```bash
78-
pip install -r requirements.txt
78+
python -m venv .venv
79+
source .venv/bin/activate # On Windows: .venv\Scripts\activate
7980
```
8081

81-
3. **Start the server:**
82+
3. **Install the package:**
8283
```bash
83-
python -m employee_scheduling.rest_api
84+
pip install -e .
8485
```
8586

86-
4. **Open your browser:**
87+
4. **Start the server:**
88+
```bash
89+
run-app
90+
```
91+
92+
5. **Open your browser:**
8793
```
8894
http://localhost:8080
8995
```
@@ -93,22 +99,22 @@ You'll see a scheduling interface with employees, shifts and a "Solve" button. C
9399
### File Structure Overview
94100

95101
```
96-
fast/employee_scheduling-fast/
97-
├── domain.py # Data classes (Employee, Shift, Schedule)
98-
├── constraints.py # Business rules (90% of customization happens here)
99-
├── solver.py # Solver configuration
100-
├── demo_data.py # Sample data generation
101-
├── rest_api.py # HTTP API endpoints
102-
├── converters.py # REST ↔ Domain model conversion
103-
── json_serialization.py # JSON helpers
104-
105-
static/
106-
├── index.html # Web UI
107-
── app.js # UI logic and visualization
108-
109-
tests/
110-
├── test_constraints.py # Unit tests for constraints
111-
└── test_feasible.py # Integration tests
102+
fast/employee-scheduling-fast/
103+
├── src/employee_scheduling/
104+
├── domain.py # Data classes (Employee, Shift, Schedule)
105+
├── constraints.py # Business rules (90% of customization happens here)
106+
├── solver.py # Solver configuration
107+
├── demo_data.py # Sample data generation
108+
├── rest_api.py # HTTP API endpoints
109+
│ ├── converters.py # REST ↔ Domain model conversion
110+
│ ├── json_serialization.py # JSON helpers
111+
│ └── score_analysis.py # Score breakdown DTOs
112+
├── static/
113+
│ ├── index.html # Web UI
114+
│ └── app.js # UI logic and visualization
115+
└── tests/
116+
├── test_constraints.py # Unit tests for constraints
117+
└── test_feasible.py # Integration tests
112118
```
113119

114120
**Key insight:** Most business customization happens in `constraints.py` alone. You rarely need to modify other files.
@@ -127,7 +133,6 @@ You need to assign **employees** to **shifts** while satisfying rules like:
127133
- Employee needs 10 hours rest between shifts
128134
- Employee can't work more than one shift per day
129135
- Employee can't work on days they're unavailable
130-
- Employee can't work more than 12 shifts total
131136

132137
**Soft constraints** (preferences to optimize):
133138
- Avoid scheduling on days the employee marked as "undesired"
@@ -302,7 +307,6 @@ def define_constraints(constraint_factory: ConstraintFactory):
302307
at_least_10_hours_between_two_shifts(constraint_factory),
303308
one_shift_per_day(constraint_factory),
304309
unavailable_employee(constraint_factory),
305-
max_shifts_per_employee(constraint_factory),
306310
# Soft constraints
307311
undesired_day_for_employee(constraint_factory),
308312
desired_day_for_employee(constraint_factory),
@@ -326,21 +330,11 @@ def has_required_skill(self) -> bool:
326330
def is_overlapping_with_date(self, dt: date) -> bool:
327331
"""Check if shift overlaps with a specific date."""
328332
return self.start.date() == dt or self.end.date() == dt
329-
330-
def get_overlapping_duration_in_minutes(self, dt: date) -> int:
331-
"""Calculate how many minutes of a shift fall on a specific date."""
332-
start_date_time = datetime.combine(dt, datetime.min.time())
333-
end_date_time = datetime.combine(dt, datetime.max.time())
334-
335-
# Calculate overlap between date range and shift range
336-
max_start_time = max(start_date_time, self.start)
337-
min_end_time = min(end_date_time, self.end)
338-
339-
minutes = (min_end_time - max_start_time).total_seconds() / 60
340-
return int(max(0, minutes))
341333
```
342334

343-
These methods encapsulate shift-related logic within the domain model, making constraints more readable and maintainable. They're particularly important for date-boundary calculations (e.g., a shift spanning midnight).
335+
These methods encapsulate shift-related logic within the domain model, making constraints more readable and maintainable.
336+
337+
> **Implementation Note:** For datetime overlap calculations in constraint penalty lambdas, the codebase uses inline calculations with explicit `time(0, 0, 0)` and `time(23, 59, 59)` rather than calling domain methods. This avoids transpilation issues with `datetime.min.time()` and `datetime.max.time()` in the constraint stream API.
344338
345339
### Hard Constraint: Required Skill
346340

@@ -469,6 +463,8 @@ def one_shift_per_day(constraint_factory: ConstraintFactory):
469463
**Business rule:** "Employees cannot work on days they marked as unavailable."
470464

471465
```python
466+
from datetime import time
467+
472468
def unavailable_employee(constraint_factory: ConstraintFactory):
473469
return (
474470
constraint_factory.for_each(Shift)
@@ -477,10 +473,13 @@ def unavailable_employee(constraint_factory: ConstraintFactory):
477473
Joiners.equal(lambda shift: shift.employee, lambda employee: employee),
478474
)
479475
.flatten_last(lambda employee: employee.unavailable_dates)
480-
.filter(lambda shift, unavailable_date: shift.is_overlapping_with_date(unavailable_date))
476+
.filter(lambda shift, unavailable_date: is_overlapping_with_date(shift, unavailable_date))
481477
.penalize(
482478
HardSoftDecimalScore.ONE_HARD,
483-
lambda shift, unavailable_date: shift.get_overlapping_duration_in_minutes(unavailable_date),
479+
lambda shift, unavailable_date: int((
480+
min(shift.end, datetime.combine(unavailable_date, time(23, 59, 59)))
481+
- max(shift.start, datetime.combine(unavailable_date, time(0, 0, 0)))
482+
).total_seconds() / 60),
484483
)
485484
.as_constraint("Unavailable employee")
486485
)
@@ -491,9 +490,11 @@ def unavailable_employee(constraint_factory: ConstraintFactory):
491490
2. `.join(Employee, ...)`: Join with the assigned employee
492491
3. `.flatten_last(lambda employee: employee.unavailable_dates)`: Expand each employee's unavailable_dates set
493492
4. `.filter(...)`: Keep only when shift overlaps the unavailable date
494-
5. `.penalize(...)`: Penalize by overlapping duration in minutes
493+
5. `.penalize(...)`: Penalize by overlapping duration in minutes (calculated inline)
494+
495+
**Optimization concept:** The `flatten_last` operation demonstrates **constraint streaming with collections**. We iterate over each date in the employee's unavailable set, creating (shift, date) pairs to check.
495496

496-
**Optimization concept:** The `flatten_last` operation demonstrates **constraint streaming with collections**. We iterate over each date in the employee's unavailable set, creating (shift, date) pairs to check. The `shift.is_overlapping_with_date()` and `shift.get_overlapping_duration_in_minutes()` methods are defined on the Shift domain model class.
497+
> **Why inline calculation?** The penalty lambda uses explicit `time(0, 0, 0)` and `time(23, 59, 59)` rather than `datetime.min.time()` or calling domain methods. This is required because certain datetime methods don't transpile correctly to the constraint stream engine.
497498
498499
### Soft Constraint: Undesired Days
499500

@@ -511,7 +512,10 @@ def undesired_day_for_employee(constraint_factory: ConstraintFactory):
511512
.filter(lambda shift, undesired_date: shift.is_overlapping_with_date(undesired_date))
512513
.penalize(
513514
HardSoftDecimalScore.ONE_SOFT,
514-
lambda shift, undesired_date: shift.get_overlapping_duration_in_minutes(undesired_date),
515+
lambda shift, undesired_date: int((
516+
min(shift.end, datetime.combine(undesired_date, time(23, 59, 59)))
517+
- max(shift.start, datetime.combine(undesired_date, time(0, 0, 0)))
518+
).total_seconds() / 60),
515519
)
516520
.as_constraint("Undesired day for employee")
517521
)
@@ -537,7 +541,10 @@ def desired_day_for_employee(constraint_factory: ConstraintFactory):
537541
.filter(lambda shift, desired_date: shift.is_overlapping_with_date(desired_date))
538542
.reward(
539543
HardSoftDecimalScore.ONE_SOFT,
540-
lambda shift, desired_date: shift.get_overlapping_duration_in_minutes(desired_date),
544+
lambda shift, desired_date: int((
545+
min(shift.end, datetime.combine(desired_date, time(23, 59, 59)))
546+
- max(shift.start, datetime.combine(desired_date, time(0, 0, 0)))
547+
).total_seconds() / 60),
541548
)
542549
.as_constraint("Desired day for employee")
543550
)
@@ -749,14 +756,65 @@ Check solving status and get current solution:
749756
}
750757
```
751758

759+
#### GET /schedules
760+
761+
List all active job IDs:
762+
763+
**Response:**
764+
```json
765+
["a1b2c3d4-e5f6-7890-abcd-ef1234567890", "b2c3d4e5-f6a7-8901-bcde-f23456789012"]
766+
```
767+
768+
#### GET /schedules/{problem_id}/status
769+
770+
Lightweight status check (score and solver status only):
771+
772+
**Response:**
773+
```json
774+
{
775+
"score": {
776+
"hardScore": 0,
777+
"softScore": -12
778+
},
779+
"solverStatus": "SOLVING_ACTIVE"
780+
}
781+
```
782+
752783
#### DELETE /schedules/{problem_id}
753784

754785
Stop solving early and return best solution found so far:
755786

756787
```python
757788
@app.delete("/schedules/{problem_id}")
758-
async def stop_solving(problem_id: str) -> None:
789+
async def stop_solving(problem_id: str) -> EmployeeScheduleModel:
759790
solver_manager.terminate_early(problem_id)
791+
return await get_timetable(problem_id)
792+
```
793+
794+
#### PUT /schedules/analyze
795+
796+
Analyze a schedule without solving to understand constraint violations:
797+
798+
**Request body:** Same format as demo-data response
799+
800+
**Response:**
801+
```json
802+
{
803+
"constraints": [
804+
{
805+
"name": "Required skill",
806+
"weight": "1hard",
807+
"score": "-2hard",
808+
"matches": [
809+
{
810+
"name": "Required skill",
811+
"score": "-1hard",
812+
"justification": "Shift(id=5) assigned to Employee(Amy Cole)"
813+
}
814+
]
815+
}
816+
]
817+
}
760818
```
761819

762820
### Web UI Flow
@@ -780,15 +838,15 @@ The `static/app.js` implements this polling workflow:
780838

781839
## Making Your First Customization
782840

783-
The quickstart includes a cardinality constraint that demonstrates a common pattern. Let's understand how it works and then learn how to create similar constraints.
841+
The quickstart includes an optional cardinality constraint that demonstrates a common pattern. Let's understand how it works and then learn how to create similar constraints.
784842

785843
### Understanding the Max Shifts Constraint
786844

787-
The codebase includes `max_shifts_per_employee` which limits workload imbalance:
845+
The codebase includes `max_shifts_per_employee` which limits workload imbalance. This constraint is **disabled by default** (commented out in `define_constraints()`) but serves as a useful example:
788846

789847
**Business rule:** "No employee can work more than 12 shifts in the schedule period."
790848

791-
This is a **hard constraint** (must be satisfied).
849+
This is a **hard constraint** (must be satisfied when enabled).
792850

793851
### The Constraint Implementation
794852

@@ -827,7 +885,7 @@ def max_shifts_per_employee(constraint_factory: ConstraintFactory):
827885

828886
### How It's Registered
829887

830-
The constraint is registered in `define_constraints()` along with the other constraints:
888+
To enable this constraint, uncomment it in `define_constraints()`:
831889

832890
```python
833891
@constraint_provider
@@ -839,7 +897,7 @@ def define_constraints(constraint_factory: ConstraintFactory):
839897
at_least_10_hours_between_two_shifts(constraint_factory),
840898
one_shift_per_day(constraint_factory),
841899
unavailable_employee(constraint_factory),
842-
max_shifts_per_employee(constraint_factory), #Cardinality constraint
900+
# max_shifts_per_employee(constraint_factory), # ← Uncomment to enable
843901
# Soft constraints
844902
undesired_day_for_employee(constraint_factory),
845903
desired_day_for_employee(constraint_factory),
@@ -849,12 +907,13 @@ def define_constraints(constraint_factory: ConstraintFactory):
849907

850908
### Experimenting With It
851909

852-
Try modifying the constraint to see its effect:
910+
Try enabling and modifying the constraint to see its effect:
853911

854-
1. Change the limit from 12 to 8 in `constraints.py`
855-
2. Restart the server: `python -m employee_scheduling.rest_api`
856-
3. Load demo data and click "Solve"
857-
4. Observe how the constraint affects the solution
912+
1. Uncomment `max_shifts_per_employee(constraint_factory),` in `constraints.py`
913+
2. Change the limit from 12 to 8 if desired
914+
3. Restart the server: `python -m employee_scheduling.rest_api`
915+
4. Load demo data and click "Solve"
916+
5. Observe how the constraint affects the solution
858917

859918
**Note:** A very low limit (e.g., 5) will make the problem infeasible.
860919

0 commit comments

Comments
 (0)