Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
430043b
Vorbereitung der API Struktur + Bereitstellung des Gerüsts für Weight…
LenaGiebeler Jul 2, 2026
26ed794
Global Wishes zu Weekly Wishes gemacht und die API entsprechend angep…
LenaGiebeler Jul 5, 2026
03303a2
Schreiben eines neuen Wunsches in die DB mit Post
LenaGiebeler Jul 5, 2026
3ffa1fd
Schreiben, löschen und verändern von Wünschen über API in der Datenbank
LenaGiebeler Jul 5, 2026
a24c2b3
minimal staff boilerplate
FryPhilip Jul 5, 2026
4aefea2
minimum staff api infra
FryPhilip Jul 5, 2026
0e1fc0c
minimal_staff draft
FryPhilip Jul 5, 2026
207970a
added put outline
FryPhilip Jul 5, 2026
20a7955
update wishes and blocked api parameter
Zerbrechl Jul 5, 2026
062a613
Speichern, Ändern und Lesen von Globalen Wünschen in der Datenbank
LenaGiebeler Jul 6, 2026
578eea1
Wechsel von Monat, Jahr zu From Date
LenaGiebeler Jul 6, 2026
946b0a7
Entfernen von weekly Wishes da wir uns darauf geeinigt haben, dass wi…
LenaGiebeler Jul 8, 2026
4a4b5b0
Auslesen von Availabilities und Wünschen zusammen
LenaGiebeler Jul 9, 2026
d849ced
Wishes and Availabilities in einen Router gepackt und get gefiltert
LenaGiebeler Jul 10, 2026
95feec0
Update des GetMinStaffing Routings + Lesen und mappen des demands aus…
LenaGiebeler Jul 11, 2026
77a7930
Added weights routing, reading, writing and domain - Noch nicht getes…
LenaGiebeler Jul 12, 2026
13f8de6
Added Availabilities remapping + writing. Noch nicht mit Datenbank ge…
LenaGiebeler Jul 14, 2026
4ecbebc
API infra für Disnstpläne anzeigen
FryPhilip Jul 15, 2026
5d935ac
Auskommentieren der Funktionen mit CREAtE Teilen damit das Readen der…
LenaGiebeler Jul 17, 2026
a592d63
Neue TimeOfficeServiceParameter auch in den Service in der Main hinzu…
LenaGiebeler Jul 17, 2026
f32594c
Fix für pyright
LenaGiebeler Jul 17, 2026
83dbed0
Fixed some Pyright checks
LenaGiebeler Jul 18, 2026
de0d6b3
schedule api get and api solution changed to local save
FryPhilip Jul 18, 2026
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
10 changes: 10 additions & 0 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@
from scheduling.timeoffice.facts import TIMEOFFICE_FACTS
from scheduling.timeoffice.reading.container import TimeOfficeReaders
from scheduling.timeoffice.service import TimeOfficeService
from scheduling.timeoffice.writing.demand import TimeOfficeDemandWriter
from scheduling.timeoffice.writing.objective_weights import TimeOfficeWeightsWriter
from scheduling.timeoffice.writing.roster import TimeOfficeAvailabilityWriter
from scheduling.timeoffice.writing.solution import TimeOfficeSolutionWriter
from scheduling.timeoffice.writing.wishes import TimeOfficeWishWriter


def _parse_date(value: str) -> date:
Expand Down Expand Up @@ -47,6 +51,12 @@ def _solve(
engine=engine,
readers=TimeOfficeReaders.create(facts=facts),
solution_writer=TimeOfficeSolutionWriter(),
wish_writer=TimeOfficeWishWriter(
target_planning_status_id=facts.target_planning_status_id,
),
demand_writer=TimeOfficeDemandWriter(),
objective_weights_writer=TimeOfficeWeightsWriter(),
availability_writer=TimeOfficeAvailabilityWriter(),
)
solver = SolverService(
settings=settings,
Expand Down
22 changes: 20 additions & 2 deletions src/scheduling/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
from scheduling.api.dependencies import ApiRuntime
from scheduling.api.solve.job_store import InMemorySolveJobStore
from scheduling.api.solve.router import solve_router
from scheduling.api.web.router import web_router
from scheduling.api.web.employee_router import employee_router
from scheduling.api.web.minimal_staff_router import minimal_staff_router
from scheduling.api.web.schedule_router import schedule_router
from scheduling.api.web.weights_router import weights_router
from scheduling.api.web.wishes_availabilities_router import wishes_and_availabilities_router
from scheduling.logging import configure_logging
from scheduling.settings import get_settings
from scheduling.solver.cp_sat.builder import create_cp_sat_model_builder
Expand All @@ -17,7 +21,11 @@
from scheduling.timeoffice.facts import TIMEOFFICE_FACTS
from scheduling.timeoffice.reading.container import TimeOfficeReaders
from scheduling.timeoffice.service import TimeOfficeService
from scheduling.timeoffice.writing.demand import TimeOfficeDemandWriter
from scheduling.timeoffice.writing.objective_weights import TimeOfficeWeightsWriter
from scheduling.timeoffice.writing.roster import TimeOfficeAvailabilityWriter
from scheduling.timeoffice.writing.solution import TimeOfficeSolutionWriter
from scheduling.timeoffice.writing.wishes import TimeOfficeWishWriter

settings = get_settings()
configure_logging(level=settings.log_level)
Expand All @@ -38,6 +46,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
engine=engine,
readers=TimeOfficeReaders.create(facts=facts),
solution_writer=TimeOfficeSolutionWriter(),
wish_writer=TimeOfficeWishWriter(
target_planning_status_id=facts.target_planning_status_id,
),
demand_writer=TimeOfficeDemandWriter(),
objective_weights_writer=TimeOfficeWeightsWriter(),
availability_writer=TimeOfficeAvailabilityWriter(),
),
solver_service=SolverService(
settings=settings,
Expand All @@ -54,8 +68,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:


app = FastAPI(title="Staff Scheduling API", lifespan=lifespan)
app.include_router(employee_router)
app.include_router(weights_router)
app.include_router(wishes_and_availabilities_router)
app.include_router(minimal_staff_router)
app.include_router(schedule_router)
app.include_router(solve_router)
app.include_router(web_router)


@app.get("/status")
Expand Down
16 changes: 14 additions & 2 deletions src/scheduling/api/solve/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,20 @@ def kernel(command: SolveCommand) -> Solution:
logger.info("Solving scheduling dataset for solve job: job_id=%s", job.job_id)
solution = solver.solve(dataset)

logger.info("Running TimeOffice writeback dry-run for solve job: job_id=%s", job.job_id)
timeoffice.write_solution_dry_run(solution)
start_date = command.planning_month.start.isoformat()
end_date = command.planning_month.end.isoformat()
for unit in command.planning_unit_ids:
solution_name = f"solution_{unit}_{start_date}-{end_date}_wdefault"
logger.info(
"Writing legacy solution for solve job: job_id=%s solution_name=%s",
job.job_id,
solution_name,
)
timeoffice.write_solution_legacy_format(
dataset=dataset,
solution=solution,
solution_name=solution_name,
)

return solution

Expand Down
Empty file.
16 changes: 16 additions & 0 deletions src/scheduling/api/timeoffice/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from typing import Any

from pydantic import Field

from scheduling.domain import PlanningMonth, SchedulingBaseModel


class DBRequest(SchedulingBaseModel):
planning_unit_id: int
year: int = Field(ge=2000, le=2100)
month: int = Field(ge=1, le=12)

def planning_month(self) -> PlanningMonth:
return PlanningMonth(year=self.year, month=self.month)

solution_data: dict[str, Any] | None = None
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
logger = logging.getLogger(__name__)


web_router = APIRouter()
employee_router = APIRouter()


@web_router.get("/employees")
@employee_router.get("/employees")
async def get_employees(
planning_unit: int,
from_date: date,
Expand Down
185 changes: 185 additions & 0 deletions src/scheduling/api/web/minimal_staff_router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import logging
from datetime import date, timedelta
from typing import Annotated

from fastapi import APIRouter, Depends

from scheduling.api.dependencies import get_timeoffice_service
from scheduling.api.web.schemas import SuccessResponse, UpdateMinimalStaffRequest
from scheduling.domain import DemandRequirement, PlanningMonth, StaffLevel
from scheduling.timeoffice.facts import TIMEOFFICE_FACTS
from scheduling.timeoffice.service import TimeOfficeService

logger = logging.getLogger(__name__)


minimal_staff_router = APIRouter()

DAYS_OF_WEEK = ("Mo", "Di", "Mi", "Do", "Fr", "Sa", "So")

FRONTEND_STAFF_LEVEL_BY_DOMAIN = {
StaffLevel.TRAINEE: "Azubi",
StaffLevel.PROFESSIONAL: "Fachkraft",
StaffLevel.ASSISTANT: "Hilfskraft",
}

DOMAIN_STAFF_LEVEL_BY_FRONTEND = {
"Azubi": StaffLevel.TRAINEE,
"Fachkraft": StaffLevel.PROFESSIONAL,
"Hilfskraft": StaffLevel.ASSISTANT,
}

FRONTEND_STAFF_LEVELS = ("Azubi", "Fachkraft", "Hilfskraft")

FRONTEND_SHIFT_CODES = ("F", "S", "N", "Z")

WEEKDAY_TO_ID = {
"Mo": 1,
"Di": 2,
"Mi": 3,
"Do": 4,
"Fr": 5,
"Sa": 6,
"So": 7,
}


@minimal_staff_router.get("/minimal-staff")
async def get_minimal_staff(
planning_unit: int,
from_date: date,
timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)],
) -> dict[str, dict[str, dict[str, int]]]:
month = PlanningMonth(year=from_date.year, month=from_date.month)

dataset = timeoffice.fetch_dataset(
planning_unit_ids=(planning_unit,),
planning_month=month,
)

return _minimal_staff_to_frontend(dataset.demand_requirements)


def _minimal_staff_to_frontend(
demand_requirements: tuple[DemandRequirement, ...],
) -> dict[str, dict[str, dict[str, int]]]:
output = _empty_minimal_staff_response()

for requirement in demand_requirements:
staff_level = FRONTEND_STAFF_LEVEL_BY_DOMAIN[requirement.staff_level]
day = DAYS_OF_WEEK[requirement.date.isoweekday() - 1]
shift_code = _shift_code_from_shift_id(requirement.shift_id)

output[staff_level][day][shift_code] = requirement.required_count

return output


def _empty_minimal_staff_response() -> dict[str, dict[str, dict[str, int]]]:
return {
staff_level: {day: dict.fromkeys(FRONTEND_SHIFT_CODES, 0) for day in DAYS_OF_WEEK}
for staff_level in FRONTEND_STAFF_LEVELS
}


def _shift_code_from_shift_id(shift_id: int) -> str:
shift_fact = TIMEOFFICE_FACTS.reference_shift_facts_by_id.get(shift_id)

if shift_fact is None:
raise ValueError(f"Unknown reference shift_id={shift_id}.")

if shift_fact.expected_code not in FRONTEND_SHIFT_CODES:
raise ValueError(f"Unsupported minimal staff shift code={shift_fact.expected_code!r} for shift_id={shift_id}.")

return shift_fact.expected_code


@minimal_staff_router.put("/minimal-staff")
async def put_minimal_staff(
planning_unit: int,
from_date: date,
request: UpdateMinimalStaffRequest,
timeoffice: Annotated[TimeOfficeService, Depends(get_timeoffice_service)],
) -> SuccessResponse:
planning_month = PlanningMonth(year=from_date.year, month=from_date.month)

demand_requirements = _minimal_staff_request_to_domain(
planning_unit=planning_unit,
planning_month=planning_month,
minimal_staff=request.data,
)

timeoffice.replace_minimal_staffing(
planning_unit_id=planning_unit,
demand_requirements=demand_requirements,
)

return SuccessResponse()


def _minimal_staff_request_to_domain(
*,
planning_unit: int,
planning_month: PlanningMonth,
minimal_staff: dict[str, dict[str, dict[str, int]]],
) -> tuple[DemandRequirement, ...]:
requirements: list[DemandRequirement] = []

for frontend_staff_level, demand_by_day in minimal_staff.items():
staff_level = DOMAIN_STAFF_LEVEL_BY_FRONTEND[frontend_staff_level]

for frontend_day, demand_by_shift in demand_by_day.items():
weekday_iso = WEEKDAY_TO_ID[frontend_day]

for shift_code, minimum_count in demand_by_shift.items():
if minimum_count < 0:
raise ValueError("Minimal staff count must be >=0")

if minimum_count == 0:
continue

shift_id = _shift_id_from_shift_code(shift_code)

for requirement_date in _dates_for_weekday(
planning_month=planning_month,
weekday_iso=weekday_iso,
):
requirements.append(
DemandRequirement(
planning_unit_id=planning_unit,
date=requirement_date,
shift_id=shift_id,
staff_level=staff_level,
required_count=minimum_count,
)
)

return tuple(requirements)


def _shift_id_from_shift_code(shift_code: str) -> int:
if shift_code not in FRONTEND_SHIFT_CODES:
raise ValueError(f"Shift Code is not defined in frontend: shift_code={shift_code}.")

for shift_id, shift_fact in TIMEOFFICE_FACTS.reference_shift_facts_by_id.items():
if shift_fact.expected_code == shift_code:
return shift_id

raise ValueError(f"Unknown shift_code={shift_code}.")


def _dates_for_weekday(
*,
planning_month: PlanningMonth,
weekday_iso: int,
) -> tuple[date, ...]:
dates: list[date] = []

current_date = planning_month.start
while current_date <= planning_month.end:
if current_date.isoweekday() == weekday_iso:
dates.append(current_date)

current_date += timedelta(days=1)

return tuple(dates)
38 changes: 38 additions & 0 deletions src/scheduling/api/web/schedule_router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import json
import logging
from datetime import date
from pathlib import Path
from typing import Any

from fastapi import APIRouter

logger = logging.getLogger(__name__)

PROCESSED_SOLUTIONS_DIR = Path("processed_solutions")

schedule_router = APIRouter()

# TODO: change solve router to use the lagacy solution writer already present in timeoffice
# TODO: adapt the get_schedule acordingly,
# TODO: use _get_solution_legacy to fetch the data so, when the db works, we only need to relace that one function


@schedule_router.get("/schedules/{schedule_id}")
async def get_schedule(
planning_unit: int,
from_date: date,
schedule_id: str,
) -> Any:
"""Return a schedule solution for a planning unit and month."""
return _get_solution_legacy(schedule_id)


def _get_solution_legacy(schedule_id: str) -> Any:
"""Read a processed solution from the legacy JSON files, place holder until database read write."""
solution_path = PROCESSED_SOLUTIONS_DIR / f"{schedule_id}_processed.json"

if not solution_path.is_file():
return {"solution": None}

with solution_path.open(encoding="utf-8") as f:
return json.load(f)
Loading
Loading