Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ IMAGE_NAME := "staff-scheduling-api"
PORT := "8000"

# Shared Docker args for dev commands
DOCKER_DEV_ARGS := "--env-file .env -v $PWD/src:/app/src" + " -p " + PORT + ":8000"
DOCKER_DEV_ARGS := "--env-file .env -v $PWD/src:/app/src -v $PWD/tests:/app/tests -v $PWD/found_solutions:/app/found_solutions -v $PWD/processed_solutions:/app/processed_solutions" + " -p " + PORT + ":8000"

_default:
just --list
Expand Down Expand Up @@ -57,3 +57,18 @@ docker-shell:

health:
curl http://localhost:{{PORT}}/health

# Starts the container in the background and keeps it alive
up:
docker run -d --name {{IMAGE_NAME}}-dev \
{{DOCKER_DEV_ARGS}} \
{{IMAGE_NAME}} \
tail -f /dev/null

# Runs a command inside the running container
exec *args:
docker exec -it {{IMAGE_NAME}}-dev uv run {{args}}

# Stops the background container
down:
docker stop {{IMAGE_NAME}}-dev && docker rm {{IMAGE_NAME}}-dev
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ dependencies = [
"pydantic-settings>=2.14.1",
]

[project.scripts]
staff-scheduling = "main:main"

[dependency-groups]
dev = [
"pre-commit>=4.6.0",
Expand Down
102 changes: 102 additions & 0 deletions src/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
from __future__ import annotations

import argparse
from datetime import date, datetime

from scheduling.domain import PlanningMonth
from scheduling.logging import configure_logging
from scheduling.settings import get_settings
from scheduling.solver.cp_sat.builder import create_cp_sat_model_builder
from scheduling.solver.service import SolverService
from scheduling.timeoffice.database import create_db_engine
from scheduling.timeoffice.facts import TIMEOFFICE_FACTS
from scheduling.timeoffice.reading.container import TimeOfficeReaders
from scheduling.timeoffice.service import TimeOfficeService
from scheduling.timeoffice.writing.solution import TimeOfficeSolutionWriter


def _parse_date(value: str) -> date:
for fmt in ("%Y-%m-%d", "%d.%m.%Y"):
try:
return datetime.strptime(value, fmt).date()
except ValueError:
continue
raise argparse.ArgumentTypeError(f"Invalid date '{value}'. Use YYYY-MM-DD or DD.MM.YYYY.")


def _solve(
unit: int,
start_date: date,
end_date: date,
) -> None:
planning_month = PlanningMonth(year=start_date.year, month=start_date.month)

if start_date != planning_month.start or end_date != planning_month.end:
raise SystemExit(
"The new solver works on full planning months. "
f"Use {planning_month.start.isoformat()} through {planning_month.end.isoformat()}."
)

settings = get_settings()
configure_logging(level=settings.log_level)

engine = create_db_engine(settings=settings)
facts = TIMEOFFICE_FACTS
timeoffice = TimeOfficeService(
facts=facts,
engine=engine,
readers=TimeOfficeReaders.create(facts=facts),
solution_writer=TimeOfficeSolutionWriter(),
)
solver = SolverService(
settings=settings,
model_builder=create_cp_sat_model_builder(),
)

print(f"Creating staff schedule for planning unit {unit}...")

try:
dataset = timeoffice.fetch_dataset(
planning_unit_ids=(unit,),
planning_month=planning_month,
)
solution = solver.solve(dataset)
solution_name = f"solution_{unit}_{start_date}-{end_date}_wdefault"
legacy_solution_paths = timeoffice.write_solution_legacy_format(
dataset=dataset,
solution=solution,
solution_name=solution_name,
)
except ValueError as e:
raise SystemExit(str(e)) from None
finally:
engine.dispose()

if legacy_solution_paths is not None:
print(f"Saved legacy solution: {legacy_solution_paths.solution_path}")
print(f"Saved processed legacy solution: {legacy_solution_paths.processed_solution_path}")

print(
"Solved staff schedule: "
f"status={solution.status.value}, generated_assignments={len(solution.assignments)}, "
f"diagnostics={len(solution.diagnostics)}, audit_findings={len(solution.audit.findings)}"
)


def main() -> None:
parser = argparse.ArgumentParser(prog="staff-scheduling")
subparsers = parser.add_subparsers(dest="command", required=True)

solve_parser = subparsers.add_parser("solve", help="Solve the scheduling problem for a planning unit")
solve_parser.add_argument("unit", type=int)
solve_parser.add_argument("start", type=_parse_date)
solve_parser.add_argument("end", type=_parse_date)

args = parser.parse_args()

if args.command == "solve":
_solve(args.unit, args.start, args.end)


if __name__ == "__main__":
main()
15 changes: 14 additions & 1 deletion src/scheduling/timeoffice/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from scheduling.timeoffice.mapping import map_scheduling_dataset
from scheduling.timeoffice.mapping.options import map_solve_options
from scheduling.timeoffice.reading.container import TimeOfficeReaders
from scheduling.timeoffice.writing.solution import TimeOfficeSolutionWriter
from scheduling.timeoffice.writing.solution import LegacySolutionExportPaths, TimeOfficeSolutionWriter
from scheduling.validation import validate_scheduling_dataset

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -98,6 +98,19 @@ def fetch_dataset(
def write_solution_dry_run(self, solution: Solution) -> None:
self._solution_writer.write_dry_run(solution)

def write_solution_legacy_format(
self,
*,
dataset: SchedulingDataset,
solution: Solution,
solution_name: str,
) -> LegacySolutionExportPaths | None:
return self._solution_writer.write_legacy_format(
dataset=dataset,
solution=solution,
solution_name=solution_name,
)

def _normalize_planning_unit_ids(
self,
planning_unit_ids: tuple[int, ...],
Expand Down
Loading
Loading